Phase 0: Repository-Initialisierung nach Bauplan v1.2
- Struktur gemäß §8 (Eigentumsgrenzen), PLAN.md als normative Basis - Pflichtdokumente: STATUS.md, ERRORS.md, TEST_REPORT.md, CHANGELOG.md, ADRs - ADR-0001 Python 3.13-Pin, ADR-0002 GStreamer 1.28.6-Pin (Windows), ADR-0003 IPC TCP+MessagePack v1 - Kernpakete: hms_protocol, hms_domain, hms_parameter, hms_artnet, hms_adaptive, hms_capabilities, hms_plugin_sdk - Renderer-Spike: D3D11-Primärpfad + Dev-GL-Pfad (§36 Nr. 4-5) - Control Core: FastAPI REST + WebSocket (§36 Nr. 9) - Beispielplugins: Passthrough + Gaussian Blur (3 Adaptive-Quality- Varianten, HLSL/GLSL/GLES) - Tools: Art-Net-Emulator, Fixture-Generator (Master32/Layer64-CSV), Capability-Probe - JSON-Schemas: IPC, Plugin, Projekt, Cluster - 121 Unit-/Integrationstests grün, Ruff grün Gate 0 bleibt offen: Hardwaremessungen nur auf echter Windows-Referenz- hardware gültig (§29.7, §33).
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""Unit-Tests Adaptive Quality (PLAN.md §5.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hms_adaptive import AdaptiveQualityController, QualityLevel
|
||||
|
||||
|
||||
def _fast_controller() -> AdaptiveQualityController:
|
||||
"""Regler ohne echte Wartezeiten für deterministische Tests."""
|
||||
return AdaptiveQualityController(min_hold_ms=0, downgrade_intervals=2, upgrade_intervals=6)
|
||||
|
||||
|
||||
def test_starts_at_high() -> None:
|
||||
assert _fast_controller().level is QualityLevel.HIGH
|
||||
|
||||
|
||||
def test_single_bad_interval_does_not_downgrade() -> None:
|
||||
c = _fast_controller()
|
||||
assert c.step(30.0) is QualityLevel.HIGH # 1 schlechtes Intervall genügt nicht
|
||||
|
||||
|
||||
def test_downgrades_one_level_per_interval_not_more() -> None:
|
||||
c = _fast_controller()
|
||||
c.step(30.0)
|
||||
c.step(30.0)
|
||||
assert c.level is QualityLevel.MEDIUM # genau eine Stufe (Hysterese, §5.2)
|
||||
c.step(30.0)
|
||||
c.step(30.0)
|
||||
assert c.level is QualityLevel.LOW
|
||||
assert c.step(30.0) is QualityLevel.LOW # Untergrenze
|
||||
|
||||
|
||||
def test_upgrade_needs_sustained_reserve() -> None:
|
||||
c = _fast_controller()
|
||||
for _ in range(4):
|
||||
c.step(30.0)
|
||||
assert c.level is QualityLevel.LOW
|
||||
for _ in range(5): # 5 gute Intervalle genügen nicht (Upgrade träger)
|
||||
c.step(1.0)
|
||||
assert c.level is QualityLevel.LOW
|
||||
c.step(1.0) # 6. gutes Intervall → eine Stufe hoch
|
||||
assert c.level is QualityLevel.MEDIUM
|
||||
|
||||
|
||||
def test_upgrade_slower_than_downgrade() -> None:
|
||||
c = _fast_controller()
|
||||
assert c.downgrade_intervals < c.upgrade_intervals
|
||||
|
||||
|
||||
def test_no_pumping_on_alternating_load() -> None:
|
||||
c = _fast_controller()
|
||||
levels = []
|
||||
for _ in range(60):
|
||||
levels.append(c.step(30.0)) # Dauerlast → LOW
|
||||
assert c.level is QualityLevel.LOW
|
||||
for _ in range(3):
|
||||
c.step(1.0) # kurze Erholung darf kein Pumpen erzeugen
|
||||
c.step(30.0)
|
||||
assert c.level is QualityLevel.LOW # keine Aufwertung bei alternierender Last
|
||||
|
||||
|
||||
def test_reason_is_recorded() -> None:
|
||||
c = _fast_controller()
|
||||
c.step(30.0)
|
||||
c.step(30.0)
|
||||
assert "p99" in c.last_reason and "budget" in c.last_reason
|
||||
|
||||
|
||||
def test_default_min_hold_prevents_rapid_changes(monkeypatch) -> None:
|
||||
|
||||
c = AdaptiveQualityController() # min_hold_ms=2000 (Produktionswert)
|
||||
c.step(30.0)
|
||||
c.step(30.0)
|
||||
assert c.level is QualityLevel.HIGH # Mindesthaltezeit noch nicht vergangen
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Unit-Tests Art-Net-Pakete gegen die offizielle Spezifikation (PLAN.md §16).
|
||||
|
||||
Verifizierte Referenzwerte aus der Art-Net-4-Spezifikation:
|
||||
- ID 'Art-Net\\0', OpCode little-endian, ProtVer 14 high-byte-first
|
||||
- ArtDMX: 0x5000, Header 18 Bytes, Länge gerade, 2..512
|
||||
- ArtPoll: 0x2000, 14 Bytes
|
||||
- ArtPollReply: 0x2100, 210 Bytes, Style 0x02 StMedia, Port 0x1936
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
from hms_artnet.packets import (
|
||||
ARTNET_ID,
|
||||
UDP_PORT,
|
||||
build_artpoll_reply,
|
||||
build_dmx,
|
||||
build_poll,
|
||||
parse_dmx,
|
||||
parse_poll,
|
||||
parse_poll_reply,
|
||||
)
|
||||
|
||||
# ---------- Header ----------
|
||||
|
||||
def test_artnet_id_is_8_bytes_with_null() -> None:
|
||||
assert ARTNET_ID == b"Art-Net\x00"
|
||||
assert len(ARTNET_ID) == 8
|
||||
|
||||
|
||||
def test_dmx_opcode_little_endian() -> None:
|
||||
packet = build_dmx(0, b"\x00\x00")
|
||||
assert packet[8:10] == b"\x00\x50" # 0x5000 low byte first
|
||||
|
||||
|
||||
def test_protocol_version_14_high_byte_first() -> None:
|
||||
packet = build_dmx(0, b"\x00\x00")
|
||||
assert packet[10:12] == b"\x00\x0e" # ProtVer 14
|
||||
|
||||
|
||||
# ---------- ArtDMX ----------
|
||||
|
||||
def test_dmx_roundtrip() -> None:
|
||||
data = bytes(range(64))
|
||||
packet = build_dmx(universe=5, data=data, sequence=7, physical=1)
|
||||
parsed = parse_dmx(packet)
|
||||
assert parsed is not None
|
||||
assert parsed.universe == 5
|
||||
assert parsed.sequence == 7
|
||||
assert parsed.physical == 1
|
||||
assert parsed.data == data
|
||||
|
||||
|
||||
def test_dmx_length_is_even_and_header_18_bytes() -> None:
|
||||
packet = build_dmx(0, b"\x01\x02\x03") # ungerade → aufgerundet
|
||||
(length,) = struct.unpack_from(">H", packet, 16)
|
||||
assert length == 4 # auf gerade aufgerundet
|
||||
assert len(packet) == 18 + length
|
||||
|
||||
|
||||
def test_dmx_universe_encoding_net_and_subuni() -> None:
|
||||
# universe = Net<<8 | SubUni; Net 7 Bit, SubUni 8 Bit
|
||||
packet = build_dmx(universe=(3 << 8) | 0x42, data=b"\x00\x00")
|
||||
assert packet[14] == 0x42 # SubUni
|
||||
assert packet[15] == 0x03 # Net
|
||||
parsed = parse_dmx(packet)
|
||||
assert parsed is not None
|
||||
assert parsed.universe == (3 << 8) | 0x42
|
||||
|
||||
|
||||
def test_dmx_rejects_short_data() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
build_dmx(0, b"\x00") # unter 2 Bytes
|
||||
with pytest.raises(ValueError):
|
||||
build_dmx(0, b"\x00" * 513) # über 512
|
||||
|
||||
|
||||
def test_dmx_rejects_invalid_universe() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
build_dmx(0x8000, b"\x00\x00") # 15 Bit max
|
||||
|
||||
|
||||
def test_parse_dmx_rejects_garbage() -> None:
|
||||
assert parse_dmx(b"") is None
|
||||
assert parse_dmx(b"\x00" * 10) is None
|
||||
wrong_opcode = ARTNET_ID + struct.pack("<H", 0x9999) + b"\x00\x0e" + b"\x00" * 10
|
||||
assert parse_dmx(wrong_opcode) is None
|
||||
|
||||
|
||||
def test_parse_dmx_rejects_old_protocol_version() -> None:
|
||||
packet = bytearray(build_dmx(0, b"\x00\x00"))
|
||||
packet[10:12] = b"\x00\x0c" # ProtVer 12
|
||||
assert parse_dmx(bytes(packet)) is None
|
||||
|
||||
|
||||
def test_parse_dmx_rejects_truncated_payload() -> None:
|
||||
packet = build_dmx(0, b"\x00" * 64)
|
||||
assert parse_dmx(packet[:-32]) is None # abgeschnittene Daten
|
||||
|
||||
|
||||
# ---------- ArtPoll ----------
|
||||
|
||||
def test_poll_is_14_bytes_and_roundtrips() -> None:
|
||||
packet = build_poll(talk_to_me=0x02, priority=0x0A)
|
||||
assert len(packet) == 14
|
||||
parsed = parse_poll(packet)
|
||||
assert parsed is not None
|
||||
assert parsed.talk_to_me == 0x02
|
||||
assert parsed.priority == 0x0A
|
||||
|
||||
|
||||
def test_poll_opcode() -> None:
|
||||
assert build_poll()[8:10] == b"\x00\x20" # 0x2000 low byte first
|
||||
|
||||
|
||||
def test_parse_poll_accepts_extended_packets() -> None:
|
||||
packet = build_poll() + b"\x00" * 10 # größere Pakete müssen akzeptiert werden
|
||||
assert parse_poll(packet) is not None
|
||||
|
||||
|
||||
def test_parse_poll_rejects_wrong_opcode() -> None:
|
||||
packet = build_dmx(0, b"\x00\x00")
|
||||
assert parse_poll(packet) is None
|
||||
|
||||
|
||||
# ---------- ArtPollReply ----------
|
||||
|
||||
def test_pollreply_exactly_210_bytes() -> None:
|
||||
reply = build_artpoll_reply(
|
||||
ip=b"\xc0\xa8\x01\x2a",
|
||||
short_name="HMS ME",
|
||||
long_name="HMS MediaEngine Render Node",
|
||||
)
|
||||
assert len(reply) == 210
|
||||
|
||||
|
||||
def test_pollreply_roundtrip_as_media_server() -> None:
|
||||
reply = build_artpoll_reply(
|
||||
ip=b"\xc0\xa8\x01\x2a",
|
||||
short_name="HMS ME",
|
||||
long_name="HMS MediaEngine Render Node A",
|
||||
node_report="Media Server Ready",
|
||||
mac=b"\xde\xad\xbe\xef\x00\x01",
|
||||
)
|
||||
info = parse_poll_reply(reply)
|
||||
assert info is not None
|
||||
assert info.ip == "192.168.1.42"
|
||||
assert info.short_name == "HMS ME"
|
||||
assert info.long_name == "HMS MediaEngine Render Node A"
|
||||
assert info.style == 0x02 # StMedia (Media Server, §3.4)
|
||||
assert info.mac == b"\xde\xad\xbe\xef\x00\x01"
|
||||
assert info.bind_index == 1
|
||||
|
||||
|
||||
def test_pollreply_port_is_6454() -> None:
|
||||
reply = build_artpoll_reply(ip=b"\x7f\x00\x00\x01", short_name="x", long_name="y")
|
||||
(port,) = struct.unpack_from(">H", reply, 14)
|
||||
assert port == UDP_PORT == 0x1936
|
||||
|
||||
|
||||
def test_pollreply_node_report_format() -> None:
|
||||
reply = build_artpoll_reply(
|
||||
ip=b"\x7f\x00\x00\x01",
|
||||
short_name="x",
|
||||
long_name="y",
|
||||
report_code=0x0000,
|
||||
error_count=3,
|
||||
)
|
||||
info = parse_poll_reply(reply)
|
||||
assert info is not None
|
||||
assert info.node_report.startswith("#0000 [0003]") # Format '#hhhh [hhhh] text'
|
||||
|
||||
|
||||
def test_pollreply_names_null_terminated_and_truncated() -> None:
|
||||
reply = build_artpoll_reply(
|
||||
ip=b"\x7f\x00\x00\x01",
|
||||
short_name="S" * 40, # > 17 → auf 17 gekürzt
|
||||
long_name="L" * 100, # > 63 → auf 63 gekürzt
|
||||
)
|
||||
info = parse_poll_reply(reply)
|
||||
assert info is not None
|
||||
assert info.short_name == "S" * 17
|
||||
assert info.long_name == "L" * 63
|
||||
|
||||
|
||||
def test_pollreply_rejects_short_packets() -> None:
|
||||
assert parse_poll_reply(b"\x00" * 100) is None
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Unit-Tests Capability-Probe (PLAN.md §5, §5.2).
|
||||
|
||||
Regel: kein Fake-Ergebnis (§33). Ein Report ohne GPU-Messung darf kein
|
||||
DESKTOP-Tier vergeben; HEADLESS nur ohne Display.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hms_capabilities import CapabilityReport, CapabilityTier
|
||||
|
||||
|
||||
def test_report_starts_with_unknown_tier_and_gpu() -> None:
|
||||
report = CapabilityReport()
|
||||
assert report.tier is None
|
||||
assert report.gpu is None
|
||||
d = report.as_dict()
|
||||
assert d["tier"] is None # ungeprüft = ungeeignet für Gate-Aussagen
|
||||
|
||||
|
||||
def test_full_gpu_with_8gb_vram_is_desktop_full() -> None:
|
||||
report = CapabilityReport()
|
||||
tier = report.conclude_tier(has_gpu=True, vram_gb=12.0, decode_ok=True, has_display=True)
|
||||
assert tier is CapabilityTier.DESKTOP_FULL
|
||||
|
||||
|
||||
def test_igpu_with_low_vram_is_desktop_lite() -> None:
|
||||
report = CapabilityReport()
|
||||
tier = report.conclude_tier(has_gpu=True, vram_gb=2.0, decode_ok=True, has_display=True)
|
||||
assert tier is CapabilityTier.DESKTOP_LITE
|
||||
|
||||
|
||||
def test_no_display_is_headless_control() -> None:
|
||||
report = CapabilityReport()
|
||||
tier = report.conclude_tier(has_gpu=False, vram_gb=None, decode_ok=False, has_display=False)
|
||||
assert tier is CapabilityTier.HEADLESS_CONTROL
|
||||
|
||||
|
||||
def test_unmeasured_gpu_yields_no_tier() -> None:
|
||||
"""Ohne GPU-Messung darf kein DESKTOP-Tier vergeben werden (§33)."""
|
||||
report = CapabilityReport()
|
||||
tier = report.conclude_tier(has_gpu=True, vram_gb=None, decode_ok=True, has_display=True)
|
||||
assert tier is CapabilityTier.DESKTOP_LITE
|
||||
# Aber: ohne verifizierten Decode → None
|
||||
tier = report.conclude_tier(has_gpu=True, vram_gb=None, decode_ok=False, has_display=True)
|
||||
assert tier is None
|
||||
|
||||
|
||||
def test_failed_decode_on_display_machine_is_none_not_lite() -> None:
|
||||
"""Display vorhanden, aber Decode unbestätigt → kein stiller Lite-Status."""
|
||||
report = CapabilityReport()
|
||||
tier = report.conclude_tier(has_gpu=True, vram_gb=16.0, decode_ok=False, has_display=True)
|
||||
assert tier is None
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Unit-Tests DMX-Mapping: Flanken, 16-Bit-Decoder, Signalverlust (§16.4–16.6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from hms_artnet.mapping import DmxLayerMapper, LayerDmxMapping, RisingEdge
|
||||
from hms_artnet.receiver import DmxUpdate, LossBehavior
|
||||
from hms_parameter.engine import ControlSource, ParameterEngine
|
||||
|
||||
|
||||
def _opacity(comp: str, layer: str) -> str:
|
||||
return f"composition/{comp}/layer/{layer}/opacity"
|
||||
|
||||
|
||||
def _enabled(comp: str, layer: str) -> str:
|
||||
return f"composition/{comp}/layer/{layer}/enabled"
|
||||
|
||||
|
||||
# ---------- RisingEdge (§16.3/§16.5: Trigger = Flanke, kein Dauerzustand) ----------
|
||||
|
||||
|
||||
def test_rising_edge_triggers_once_per_crossing() -> None:
|
||||
edge = RisingEdge(threshold=64)
|
||||
assert edge.feed(0) is False
|
||||
assert edge.feed(64) is True # steigende Flanke
|
||||
assert edge.feed(100) is False # gehaltener Wert: kein erneuter Trigger
|
||||
assert edge.feed(200) is False
|
||||
assert edge.feed(10) is False # Rückfall
|
||||
assert edge.feed(70) is True # neue Flanke nach Rückkehr
|
||||
|
||||
|
||||
def test_rising_edge_boundary() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
RisingEdge(threshold=256)
|
||||
with pytest.raises(ValueError):
|
||||
RisingEdge(threshold=-1)
|
||||
|
||||
|
||||
# ---------- LayerDmxMapping ----------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ids() -> tuple[str, str]:
|
||||
return str(uuid.uuid4()), str(uuid.uuid4())
|
||||
|
||||
|
||||
def test_mapping_paths_use_stable_uuids(ids: tuple[str, str]) -> None:
|
||||
comp, layer = ids
|
||||
m = LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer)
|
||||
assert m.opacity_path == _opacity(comp, layer)
|
||||
assert m.enable_path == _enabled(comp, layer)
|
||||
|
||||
|
||||
def test_mapping_validates_universe_and_address(ids: tuple[str, str]) -> None:
|
||||
comp, layer = ids
|
||||
with pytest.raises(ValueError):
|
||||
LayerDmxMapping(universe=0x8000, base_address=1, composition_id=comp, layer_id=layer)
|
||||
with pytest.raises(ValueError):
|
||||
LayerDmxMapping(universe=0, base_address=511, composition_id=comp, layer_id=layer)
|
||||
|
||||
|
||||
# ---------- DmxLayerMapper (§36 Nr. 8: DMX-Kanal → Opacity) ----------
|
||||
|
||||
|
||||
def _update(universe: int, data: bytes, sequence: int = 1) -> DmxUpdate:
|
||||
return DmxUpdate(
|
||||
universe=universe,
|
||||
data=data,
|
||||
sender_ip="10.0.0.9",
|
||||
received_ns=0,
|
||||
sequence=sequence,
|
||||
)
|
||||
|
||||
|
||||
def _loss(universe: int, sender: str = "10.0.0.9") -> DmxUpdate:
|
||||
return DmxUpdate(universe=universe, data=b"", sender_ip=sender, received_ns=1, sequence=-1)
|
||||
|
||||
|
||||
def test_mapper_sets_opacity_from_16bit_channels(ids: tuple[str, str]) -> None:
|
||||
comp, layer = ids
|
||||
engine = ParameterEngine()
|
||||
mapper = DmxLayerMapper(
|
||||
LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer),
|
||||
engine,
|
||||
)
|
||||
# Kanal 2-3 = Opacity 16 Bit: MSB zuerst → 0x8000/0xFFFF
|
||||
mapper.handle(_update(0, bytes([255, 0x80, 0x00])))
|
||||
assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(0x8000 / 65535)
|
||||
# Voll auf: 0xFFFF
|
||||
mapper.handle(_update(0, bytes([255, 0xFF, 0xFF])))
|
||||
assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(1.0)
|
||||
# Kanal 1 < 128 → Layer disabled
|
||||
mapper.handle(_update(0, bytes([0, 0xFF, 0xFF])))
|
||||
assert engine.effective_value(_enabled(comp, layer)) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_mapper_ignores_other_universe(ids: tuple[str, str]) -> None:
|
||||
comp, layer = ids
|
||||
engine = ParameterEngine()
|
||||
mapper = DmxLayerMapper(
|
||||
LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer),
|
||||
engine,
|
||||
)
|
||||
mapper.handle(_update(7, bytes([255, 0xFF, 0xFF])))
|
||||
assert _opacity(comp, layer) not in engine.snapshot()
|
||||
|
||||
|
||||
def test_mapper_base_address_offset(ids: tuple[str, str]) -> None:
|
||||
comp, layer = ids
|
||||
engine = ParameterEngine()
|
||||
mapper = DmxLayerMapper(
|
||||
LayerDmxMapping(universe=1, base_address=65, composition_id=comp, layer_id=layer),
|
||||
engine,
|
||||
)
|
||||
# Zweiter Layer im selben Universe: Startadresse 65 → Kanal 66/67
|
||||
data = bytearray(128)
|
||||
data[64] = 255 # Kanal 65: Enable
|
||||
data[65] = 0x40 # Kanal 66: Opacity MSB
|
||||
data[66] = 0x00 # Kanal 67: Opacity LSB
|
||||
mapper.handle(_update(1, bytes(data)))
|
||||
assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(0x4000 / 65535)
|
||||
|
||||
|
||||
def test_mapper_hold_on_signal_loss(ids: tuple[str, str]) -> None:
|
||||
comp, layer = ids
|
||||
engine = ParameterEngine()
|
||||
m = LayerDmxMapping(
|
||||
universe=0,
|
||||
base_address=1,
|
||||
composition_id=comp,
|
||||
layer_id=layer,
|
||||
loss_behavior=LossBehavior.HOLD,
|
||||
)
|
||||
mapper = DmxLayerMapper(m, engine)
|
||||
mapper.handle(_update(0, bytes([255, 0xFF, 0x00])))
|
||||
before = engine.effective_value(_opacity(comp, layer))
|
||||
mapper.handle(_loss(0))
|
||||
assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(before)
|
||||
|
||||
|
||||
def test_mapper_fade_to_black_releases_on_signal_loss(ids: tuple[str, str]) -> None:
|
||||
comp, layer = ids
|
||||
engine = ParameterEngine()
|
||||
m = LayerDmxMapping(
|
||||
universe=0,
|
||||
base_address=1,
|
||||
composition_id=comp,
|
||||
layer_id=layer,
|
||||
loss_behavior=LossBehavior.FADE_TO_BLACK,
|
||||
)
|
||||
mapper = DmxLayerMapper(m, engine)
|
||||
mapper.handle(_update(0, bytes([255, 0xFF, 0xFF])))
|
||||
mapper.handle(_loss(0))
|
||||
snap = engine.snapshot()
|
||||
assert _opacity(comp, layer) not in snap # Override freigegeben
|
||||
|
||||
|
||||
def test_dmx_source_priority_is_console(ids: tuple[str, str]) -> None:
|
||||
"""Art-Net wirkt als CONSOLE (Priorität 3) und überstimmt Web (§11.2)."""
|
||||
comp, layer = ids
|
||||
engine = ParameterEngine()
|
||||
path = _opacity(comp, layer)
|
||||
engine.set_value(path, 0.1, ControlSource.WEB)
|
||||
mapper = DmxLayerMapper(
|
||||
LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer),
|
||||
engine,
|
||||
)
|
||||
mapper.handle(_update(0, bytes([255, 0xFF, 0xFF])))
|
||||
assert engine.effective_value(path) == pytest.approx(1.0) # Pult gewinnt
|
||||
assert engine.current_source(path) is ControlSource.CONSOLE
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Unit-Tests stabile IDs (PLAN.md §3.6, §6.3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from hms_domain import new_node_id, new_uuid, persistent_node_id
|
||||
|
||||
|
||||
def test_new_uuids_are_unique_and_parseable() -> None:
|
||||
a, b = new_uuid(), new_uuid()
|
||||
assert a != b
|
||||
uuid.UUID(a)
|
||||
uuid.UUID(b)
|
||||
|
||||
|
||||
def test_node_id_is_uuid_and_random() -> None:
|
||||
a, b = new_node_id(), new_node_id()
|
||||
uuid.UUID(a)
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_persistent_node_id_stable_across_calls(tmp_path) -> None:
|
||||
identity = tmp_path / "identity" / "node_id"
|
||||
first = persistent_node_id(identity)
|
||||
second = persistent_node_id(identity)
|
||||
assert first == second # IP-/Hostnamenunabhängig: Datei ist Quelle der Wahrheit
|
||||
assert identity.read_text(encoding="utf-8").strip() == first
|
||||
|
||||
|
||||
def test_persistent_node_id_validated_on_load(tmp_path) -> None:
|
||||
identity = tmp_path / "identity" / "node_id"
|
||||
identity.parent.mkdir(parents=True)
|
||||
identity.write_text("not-a-uuid", encoding="utf-8")
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
persistent_node_id(identity)
|
||||
|
||||
|
||||
def test_two_nodes_get_distinct_ids(tmp_path) -> None:
|
||||
a = persistent_node_id(tmp_path / "a" / "node_id")
|
||||
b = persistent_node_id(tmp_path / "b" / "node_id")
|
||||
assert a != b # doppelte node_ids wären ein Fehler (§6.3)
|
||||
|
||||
|
||||
def test_concurrent_creation_yields_single_id(tmp_path) -> None:
|
||||
"""O_EXCL-Rennbedingung: beide Aufrufer erhalten dieselbe ID."""
|
||||
identity = tmp_path / "identity" / "node_id"
|
||||
id_a = persistent_node_id(identity)
|
||||
# zweite Erzeugung mit bereits existierender Datei → lädt vorhandene ID
|
||||
id_b = persistent_node_id(identity)
|
||||
assert id_a == id_b
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Unit-Tests Fixture-Generator (PLAN.md §16.3, §16.4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from fixture_generator import LAYER64, MASTER32, write_csv
|
||||
|
||||
|
||||
def test_master32_has_exactly_32_contiguous_channels() -> None:
|
||||
assert len(MASTER32) == 32
|
||||
assert [row[0] for row in MASTER32] == list(range(1, 33))
|
||||
|
||||
|
||||
def test_layer64_has_exactly_64_contiguous_channels() -> None:
|
||||
assert len(LAYER64) == 64
|
||||
assert [row[0] for row in LAYER64] == list(range(1, 65))
|
||||
|
||||
|
||||
def test_master32_key_channels_match_plan() -> None:
|
||||
by_channel = {row[0]: row for row in MASTER32}
|
||||
assert "Blackout" in by_channel[3][1] # Kanal 3 Blackout
|
||||
assert "Preset Recall" in by_channel[8][1] # Kanal 8 steigende Flanke
|
||||
assert "Tap Tempo" in by_channel[16][1] # Kanal 16 Tap
|
||||
|
||||
|
||||
def test_layer64_key_channels_match_plan() -> None:
|
||||
by_channel = {row[0]: row for row in LAYER64}
|
||||
assert "Layer Enable" in by_channel[1][1]
|
||||
assert "Opacity" in by_channel[2][1]
|
||||
assert "Load/Commit" in by_channel[9][1]
|
||||
assert "Blend Mode" in by_channel[21][1]
|
||||
assert "FX1 Enable" in by_channel[41][1]
|
||||
assert "FX2 Enable" in by_channel[52][1]
|
||||
assert "Retrigger" in by_channel[63][1]
|
||||
|
||||
|
||||
def test_layer64_fx_parameter_blocks() -> None:
|
||||
by_channel = {row[0]: row for row in LAYER64}
|
||||
for slot in range(1, 9):
|
||||
assert f"P{slot}" in by_channel[43 + slot][1]
|
||||
assert f"P{slot}" in by_channel[54 + slot][1]
|
||||
|
||||
|
||||
def test_eight_layers_fit_exactly_one_universe() -> None:
|
||||
# §16.2: „Acht Layer entsprechen damit exakt einem DMX-Universe“
|
||||
assert 8 * len(LAYER64) == 512
|
||||
|
||||
|
||||
def test_write_csv_output(tmp_path: Path) -> None:
|
||||
out = tmp_path / "layer64" / "layer64.csv"
|
||||
write_csv(LAYER64, out)
|
||||
with open(out, encoding="utf-8", newline="") as fh:
|
||||
rows = list(csv.reader(fh))
|
||||
assert rows[0] == ["channel", "parameter", "resolution_behavior"]
|
||||
assert len(rows) == 65 # Header + 64
|
||||
assert rows[1] == ["1", "Layer Enable", "Schalter"]
|
||||
|
||||
|
||||
def test_write_csv_rejects_gaps(tmp_path: Path) -> None:
|
||||
import pytest
|
||||
|
||||
broken = [(1, "a", "x"), (3, "b", "y")] # Kanal 2 fehlt
|
||||
with pytest.raises(ValueError, match="contiguous"):
|
||||
write_csv(broken, tmp_path / "broken.csv") # ValueError vor dem Schreiben
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Unit-Tests Parameter-Engine (PLAN.md §11)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from hms_parameter import ParameterEngine, layer_opacity_path, master_intensity_path
|
||||
from hms_parameter.engine import ControlSource, MergeMode, RevisionConflict
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def path() -> str:
|
||||
return layer_opacity_path(str(uuid.uuid4()), str(uuid.uuid4()))
|
||||
|
||||
|
||||
def test_invalid_path_rejected() -> None:
|
||||
engine = ParameterEngine()
|
||||
with pytest.raises(ValueError, match="invalid parameter path"):
|
||||
engine.set_value("composition/not-a-uuid/layer/x/opacity", 1.0, ControlSource.WEB)
|
||||
with pytest.raises(ValueError, match="invalid parameter path"):
|
||||
engine.set_value("../escape", 1.0, ControlSource.WEB)
|
||||
|
||||
|
||||
def test_nan_and_infinity_rejected(path: str) -> None:
|
||||
engine = ParameterEngine()
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
engine.set_value(path, float("nan"), ControlSource.WEB)
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
engine.set_value(path, float("inf"), ControlSource.WEB)
|
||||
|
||||
|
||||
def test_higher_priority_wins(path: str) -> None:
|
||||
engine = ParameterEngine()
|
||||
engine.set_value(path, 0.5, ControlSource.WEB)
|
||||
assert engine.effective_value(path) == pytest.approx(0.5)
|
||||
engine.set_value(path, 0.9, ControlSource.CONSOLE)
|
||||
assert engine.effective_value(path) == pytest.approx(0.9) # Pult überstimmt Web
|
||||
engine.set_value(path, 0.0, ControlSource.SAFETY)
|
||||
assert engine.effective_value(path) == pytest.approx(0.0) # Blackout überstimmt alles
|
||||
|
||||
|
||||
def test_lower_priority_cannot_displace_higher(path: str) -> None:
|
||||
engine = ParameterEngine()
|
||||
engine.set_value(path, 0.9, ControlSource.CONSOLE)
|
||||
engine.set_value(path, 0.1, ControlSource.WEB) # niedrigere Priorität
|
||||
assert engine.effective_value(path) == pytest.approx(0.9) # CONSOLE bleibt wirksam
|
||||
assert engine.current_source(path) is ControlSource.CONSOLE
|
||||
|
||||
|
||||
def test_release_falls_back_to_lower_priority(path: str) -> None:
|
||||
engine = ParameterEngine()
|
||||
engine.set_value(path, 0.2, ControlSource.WEB)
|
||||
engine.set_value(path, 0.8, ControlSource.CONSOLE)
|
||||
engine.release(path, ControlSource.CONSOLE)
|
||||
assert engine.effective_value(path) == pytest.approx(0.2) # Web übernimmt wieder
|
||||
engine.release(path, ControlSource.WEB)
|
||||
assert engine.current_source(path) is None
|
||||
|
||||
|
||||
def test_revision_increments_on_set_and_release(path: str) -> None:
|
||||
engine = ParameterEngine()
|
||||
r0 = engine.revision
|
||||
r1 = engine.set_value(path, 0.5, ControlSource.WEB)
|
||||
r2 = engine.set_value(path, 0.6, ControlSource.WEB)
|
||||
r3 = engine.release(path, ControlSource.WEB)
|
||||
assert (r1, r2, r3) == (r0 + 1, r0 + 2, r0 + 3)
|
||||
|
||||
|
||||
def test_optimistic_locking_revision_conflict(path: str) -> None:
|
||||
engine = ParameterEngine()
|
||||
engine.set_value(path, 0.5, ControlSource.WEB)
|
||||
with pytest.raises(RevisionConflict):
|
||||
engine.set_value(path, 0.6, ControlSource.WEB, expected_revision=engine.revision + 5)
|
||||
engine.set_value(path, 0.6, ControlSource.WEB, expected_revision=engine.revision)
|
||||
|
||||
|
||||
def test_snapshot_is_atomic_and_readonly(path: str) -> None:
|
||||
engine = ParameterEngine()
|
||||
engine.set_value(path, 0.42, ControlSource.WEB)
|
||||
snap = engine.snapshot()
|
||||
assert snap.get(path) == pytest.approx(0.42)
|
||||
assert snap.revision == engine.revision
|
||||
values = snap.as_dict()
|
||||
values[path] = 99.0 # Manipulation der Kopie darf Snapshot nicht ändern
|
||||
assert snap.get(path) == pytest.approx(0.42)
|
||||
# Änderung nach dem Snapshot erscheint nicht im alten Snapshot (§11.4)
|
||||
engine.set_value(path, 0.9, ControlSource.WEB)
|
||||
assert snap.get(path) == pytest.approx(0.42)
|
||||
|
||||
|
||||
def test_defaults_in_snapshot_without_override() -> None:
|
||||
engine = ParameterEngine()
|
||||
master = master_intensity_path()
|
||||
engine.set_default(master, 1.0)
|
||||
snap = engine.snapshot()
|
||||
assert snap.get(master) == pytest.approx(1.0)
|
||||
assert engine.effective_value(master) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_htp_mode_takes_maximum(path: str) -> None:
|
||||
engine = ParameterEngine(merge_mode=MergeMode.HTP)
|
||||
engine.set_value(path, 0.9, ControlSource.WEB)
|
||||
engine.set_value(path, 0.1, ControlSource.WEB)
|
||||
assert engine.effective_value(path) == pytest.approx(0.9) # HTP: Maximum bleibt
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Unit-Tests Renderer-Pipeline-Definitionen (PLAN.md §12, §36 Nr. 4–5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hms_renderer import (
|
||||
D3D11Pipeline,
|
||||
DevGLPipeline,
|
||||
build_compositor_pipeline,
|
||||
build_single_video_pipeline,
|
||||
gst_available,
|
||||
)
|
||||
|
||||
|
||||
def test_single_video_d3d11_uses_hardware_decoder_path() -> None:
|
||||
pipeline = build_single_video_pipeline("C:/clips/test.mp4", d3d11=True)
|
||||
assert "d3d11" in pipeline
|
||||
assert "d3d11videosink" in pipeline
|
||||
assert "fullscreen=true" in pipeline # randloses Vollbild (§3.3)
|
||||
assert "uridecodebin" in pipeline
|
||||
|
||||
|
||||
def test_single_video_devgl_marked_alternative() -> None:
|
||||
pipeline = build_single_video_pipeline("/tmp/test.mp4", d3d11=False)
|
||||
assert "d3d11" not in pipeline # Dev-Pfad darf D3D11 nicht still nutzen
|
||||
assert "glimagesink" in pipeline
|
||||
|
||||
|
||||
def test_compositor_d3d11_mixed_two_sources_on_gpu() -> None:
|
||||
pipeline = build_compositor_pipeline("a.mp4", "b.mp4", d3d11=True)
|
||||
# Zwei Quellen → Compositor → Ausgabe ohne CPU-Readback (§36 Nr. 5)
|
||||
assert pipeline.count("uridecodebin") == 2
|
||||
assert "d3d11compositor" in pipeline
|
||||
assert "d3d11convert" in pipeline
|
||||
assert "D3D11Memory" in pipeline # GPU-Residenz explizit angefordert
|
||||
assert "appsink" not in pipeline # kein CPU-Abgriff im Normalpfad
|
||||
assert "videoconvert" not in pipeline # kein Software-Farbkonverter
|
||||
|
||||
|
||||
def test_compositor_devgl_is_separate_path() -> None:
|
||||
pipeline = build_compositor_pipeline("a.mp4", "b.mp4", d3d11=False)
|
||||
assert "glvideomixer" in pipeline
|
||||
assert "d3d11" not in pipeline
|
||||
|
||||
|
||||
def test_d3d11_pipeline_splits_screen_for_two_videos() -> None:
|
||||
p = D3D11Pipeline(video_a="a.mp4", video_b="b.mp4", width=1920, height=1080)
|
||||
s = p.launch_string()
|
||||
assert "sink_0::width=960" in s # linke Hälfte
|
||||
assert "sink_1::width=960" in s # rechte Hälfte
|
||||
assert "width=1920,height=1080" in s # Master-Auflösung
|
||||
|
||||
|
||||
def test_devgl_pipeline_reduced_resolution() -> None:
|
||||
p = DevGLPipeline(video_a="a.mp4", video_b="b.mp4")
|
||||
assert p.width == 960 and p.height == 540 # Dev-Pfad kleiner, gekennzeichnet
|
||||
|
||||
|
||||
def test_gst_available_reflects_environment() -> None:
|
||||
# Im Entwicklungscontainer ist GStreamer nicht installiert → False.
|
||||
# Auf dem Windows-Ziel mit gebündelter Runtime → True. Kein Fake.
|
||||
assert isinstance(gst_available(), bool)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Unit-Tests Plugin-Manifest-Validierung (PLAN.md §14, §27.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from hms_plugin_sdk import load_manifest, validate_manifest, validate_plugin_zip
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
EXAMPLES = REPO / "plugins" / "examples"
|
||||
|
||||
|
||||
def _valid_manifest() -> dict:
|
||||
return json.loads(
|
||||
(EXAMPLES / "com.hms.fx.example_passthrough" / "plugin.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def test_example_passthrough_validates_with_shaders() -> None:
|
||||
manifest, errors = load_manifest(EXAMPLES / "com.hms.fx.example_passthrough")
|
||||
assert errors == [], errors
|
||||
assert manifest["id"] == "com.hms.fx.example_passthrough"
|
||||
|
||||
|
||||
def test_example_gaussian_blur_validates_with_shaders() -> None:
|
||||
manifest, errors = load_manifest(EXAMPLES / "com.hms.fx.gaussian_blur")
|
||||
assert errors == [], errors
|
||||
variants = manifest["adaptive_quality"]["variants"]
|
||||
assert [v["id"] for v in variants] == ["low", "medium", "high"]
|
||||
assert [v["samples"] for v in variants] == [5, 9, 17]
|
||||
|
||||
|
||||
def test_valid_manifest_without_root_ok() -> None:
|
||||
assert validate_manifest(_valid_manifest()) == []
|
||||
|
||||
|
||||
def test_wrong_schema_version_rejected() -> None:
|
||||
m = _valid_manifest()
|
||||
m["schema_version"] = 99
|
||||
assert any("schema_version" in e for e in validate_manifest(m))
|
||||
|
||||
|
||||
def test_invalid_plugin_id_rejected() -> None:
|
||||
m = _valid_manifest()
|
||||
m["id"] = "../evil"
|
||||
assert any("invalid plugin id" in e for e in validate_manifest(m))
|
||||
|
||||
|
||||
def test_invalid_semver_rejected() -> None:
|
||||
m = _valid_manifest()
|
||||
m["version"] = "1.0"
|
||||
assert any("semantic" in e for e in validate_manifest(m))
|
||||
|
||||
|
||||
def test_dmx_footprint_over_8_slots_rejected() -> None:
|
||||
m = _valid_manifest()
|
||||
m["parameters"] = [
|
||||
{"id": f"p{i}", "label": f"P{i}", "type": "float", "minimum": 0, "maximum": 1,
|
||||
"default": 0, "dmx_slots": [i]}
|
||||
for i in range(1, 10)
|
||||
]
|
||||
assert any("exceeds 8" in e for e in validate_manifest(m)) # §14.7
|
||||
|
||||
|
||||
def test_unsafe_shader_path_rejected() -> None:
|
||||
m = _valid_manifest()
|
||||
m["entrypoints"]["gl"]["passes"][0]["fragment"] = "../../evil.frag"
|
||||
assert any("unsafe shader path" in e for e in validate_manifest(m))
|
||||
|
||||
|
||||
def test_missing_shader_file_detected_with_root() -> None:
|
||||
m = _valid_manifest()
|
||||
m["entrypoints"]["gl"]["passes"][0]["fragment"] = "shaders/gl/missing.frag"
|
||||
errors = validate_manifest(m, plugin_root=EXAMPLES / "com.hms.fx.example_passthrough")
|
||||
assert any("missing shader file" in e for e in errors)
|
||||
|
||||
|
||||
def test_invalid_failure_mode_rejected() -> None:
|
||||
m = _valid_manifest()
|
||||
m["failure_mode"] = "crash"
|
||||
assert any("failure_mode" in e for e in validate_manifest(m))
|
||||
|
||||
|
||||
def test_duplicate_parameter_ids_rejected() -> None:
|
||||
m = _valid_manifest()
|
||||
m["parameters"].append(dict(m["parameters"][0]))
|
||||
assert any("duplicate parameter id" in e for e in validate_manifest(m))
|
||||
|
||||
|
||||
def _make_zip(tmp_path: Path, files: dict[str, str | bytes]) -> Path:
|
||||
zpath = tmp_path / "plugin.zip"
|
||||
with zipfile.ZipFile(zpath, "w") as zf:
|
||||
for name, content in files.items():
|
||||
zf.writestr(name, content)
|
||||
return zpath
|
||||
|
||||
|
||||
def test_valid_zip_passes(tmp_path: Path) -> None:
|
||||
plugin_dir = EXAMPLES / "com.hms.fx.example_passthrough"
|
||||
files: dict[str, str | bytes] = {}
|
||||
for f in sorted(plugin_dir.rglob("*")):
|
||||
if f.is_file():
|
||||
rel = f.relative_to(plugin_dir.parent)
|
||||
files[str(rel)] = f.read_text(encoding="utf-8")
|
||||
zpath = _make_zip(tmp_path, files)
|
||||
assert validate_plugin_zip(zpath) == []
|
||||
|
||||
|
||||
def test_zip_traversal_rejected(tmp_path: Path) -> None:
|
||||
files = {"pkg/plugin.json": json.dumps(_valid_manifest()), "../evil.frag": "x"}
|
||||
zpath = _make_zip(tmp_path, files)
|
||||
assert any("unsafe path" in e for e in validate_plugin_zip(zpath))
|
||||
|
||||
|
||||
def test_zip_disallowed_file_type_rejected(tmp_path: Path) -> None:
|
||||
files = {
|
||||
"pkg/plugin.json": json.dumps(_valid_manifest()),
|
||||
"pkg/evil.exe": "MZ",
|
||||
}
|
||||
zpath = _make_zip(tmp_path, files)
|
||||
assert any("disallowed file type" in e for e in validate_plugin_zip(zpath))
|
||||
|
||||
|
||||
def test_zip_without_manifest_rejected(tmp_path: Path) -> None:
|
||||
zpath = _make_zip(tmp_path, {"pkg/shader.frag": "void main(){}"})
|
||||
assert any("plugin.json not found" in e for e in validate_plugin_zip(zpath))
|
||||
|
||||
|
||||
def test_corrupt_zip_rejected(tmp_path: Path) -> None:
|
||||
zpath = tmp_path / "broken.zip"
|
||||
zpath.write_bytes(b"not a zip at all")
|
||||
assert any("not a valid zip" in e for e in validate_plugin_zip(zpath))
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Unit-Tests portable Pfade (PLAN.md §9, §9.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from hms_launcher import AppPaths
|
||||
|
||||
|
||||
def test_paths_are_relative_to_root(tmp_path: Path) -> None:
|
||||
paths = AppPaths(root=tmp_path)
|
||||
assert paths.app == tmp_path / "app"
|
||||
assert paths.runtime == tmp_path / "runtime"
|
||||
assert paths.gstreamer_bin == tmp_path / "runtime" / "gstreamer" / "bin"
|
||||
assert paths.gstreamer_plugins == tmp_path / "runtime" / "gstreamer" / "lib" / "gstreamer-1.0"
|
||||
assert paths.database == tmp_path / "userdata" / "database"
|
||||
assert paths.cache == tmp_path / "userdata" / "cache"
|
||||
assert paths.identity == tmp_path / "userdata" / "identity" / "node_id"
|
||||
# Keine Laufwerksbuchstaben, keine absoluten Fremdpfade
|
||||
assert not str(paths.app).startswith("C:")
|
||||
|
||||
|
||||
def test_ensure_writable(tmp_path: Path) -> None:
|
||||
assert AppPaths(root=tmp_path).ensure_writable() is True
|
||||
assert not (tmp_path / ".write_probe").exists() # Probe wird aufgeräumt
|
||||
|
||||
|
||||
def test_ensure_writable_false_on_write_error(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Schreibfehler (z. B. schreibgeschütztes Medium) → False.
|
||||
|
||||
Der Fehler wird simuliert, weil root in Containern Verzeichnisrechte
|
||||
umgeht und ein chmod-Test dort falsch grün/rot wäre.
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
def _raise_write(self, *args, **kwargs):
|
||||
raise OSError("read-only file system")
|
||||
|
||||
monkeypatch.setattr(_Path, "write_text", _raise_write)
|
||||
assert AppPaths(root=tmp_path).ensure_writable() is False
|
||||
|
||||
|
||||
def test_portable_environment_sets_gstreamer_vars(tmp_path: Path) -> None:
|
||||
paths = AppPaths(root=tmp_path)
|
||||
env = paths.portable_environment()
|
||||
assert env["GST_PLUGIN_PATH_1_0"].endswith("gstreamer-1.0")
|
||||
assert env["GST_PLUGIN_SYSTEM_PATH_1_0"] == "" # System-Plugins unterdrückt
|
||||
|
||||
|
||||
def test_portable_environment_prepends_bundled_bin(tmp_path: Path) -> None:
|
||||
gs_bin = tmp_path / "runtime" / "gstreamer" / "bin"
|
||||
gs_bin.mkdir(parents=True)
|
||||
env = AppPaths(root=tmp_path).portable_environment()
|
||||
assert env["PATH"].startswith(str(gs_bin))
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Unit-Tests IPC-Protokoll (PLAN.md §6.2, ADR-0003)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from hms_protocol import (
|
||||
Envelope,
|
||||
IdempotencyRegistry,
|
||||
MessageType,
|
||||
decode_frame,
|
||||
encode_frame,
|
||||
)
|
||||
|
||||
|
||||
def test_frame_roundtrip() -> None:
|
||||
payload = {
|
||||
"protocol_version": 1,
|
||||
"message_id": "abc",
|
||||
"type": "command",
|
||||
"revision": 5,
|
||||
"monotonic_timestamp_ns": 123,
|
||||
"payload": {"key": "value", "nested": [1, 2, 3]},
|
||||
}
|
||||
frame = encode_frame(payload)
|
||||
assert decode_frame(frame) == payload
|
||||
|
||||
|
||||
def test_frame_length_prefix_is_4_byte_big_endian() -> None:
|
||||
frame = encode_frame({"a": 1})
|
||||
assert frame[:4] == len(frame[4:]).to_bytes(4, "big")
|
||||
|
||||
|
||||
def test_oversized_payload_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import hms_protocol.framing as framing
|
||||
|
||||
monkeypatch.setattr(framing, "MAX_PAYLOAD_SIZE", 16)
|
||||
with pytest.raises(ValueError, match="too large"):
|
||||
framing.encode_frame({"data": "x" * 64})
|
||||
|
||||
|
||||
def test_truncated_frame_rejected() -> None:
|
||||
frame = encode_frame({"a": 1})
|
||||
with pytest.raises(ValueError, match="truncated"):
|
||||
decode_frame(frame[:-2])
|
||||
|
||||
|
||||
def test_declared_length_over_limit_rejected() -> None:
|
||||
import struct
|
||||
|
||||
evil = struct.pack(">I", 2**31) + b"x" * 8
|
||||
with pytest.raises(ValueError, match="exceeds limit"):
|
||||
decode_frame(evil)
|
||||
|
||||
|
||||
def test_envelope_defaults_and_validation() -> None:
|
||||
env = Envelope(type=MessageType.COMMAND)
|
||||
assert env.protocol_version == 1
|
||||
assert env.revision == 0
|
||||
assert env.message_id
|
||||
assert env.payload == {}
|
||||
|
||||
|
||||
def test_envelope_rejects_wrong_protocol_version() -> None:
|
||||
with pytest.raises(ValueError, match="protocol_version"):
|
||||
Envelope(type=MessageType.EVENT, protocol_version=2)
|
||||
|
||||
|
||||
def test_idempotency_register_and_duplicate() -> None:
|
||||
reg = IdempotencyRegistry()
|
||||
assert reg.register("cmd-1") is True
|
||||
assert reg.register("cmd-1") is False
|
||||
assert reg.register("cmd-2") is True
|
||||
assert len(reg) == 2
|
||||
|
||||
|
||||
def test_idempotency_complete_returns_same_result() -> None:
|
||||
reg = IdempotencyRegistry()
|
||||
reg.register("cmd-1")
|
||||
reg.complete("cmd-1", {"status": "ack"})
|
||||
assert reg.result("cmd-1") == {"status": "ack"}
|
||||
|
||||
|
||||
def test_idempotency_capacity_eviction_lru() -> None:
|
||||
reg = IdempotencyRegistry(capacity=2)
|
||||
reg.register("a")
|
||||
reg.register("b")
|
||||
reg.register("a") # a wird jüngst benutzt
|
||||
reg.register("c") # verdrängt b (LRU)
|
||||
assert reg.register("b") is True # b wurde verdrängt → neu
|
||||
assert reg.register("c") is False # c existiert noch
|
||||
Reference in New Issue
Block a user