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:
HMS MediaEngine Agent
2026-09-11 00:36:59 +02:00
commit 0922cc1d68
133 changed files with 7939 additions and 0 deletions
View File
+74
View File
@@ -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
+189
View File
@@ -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
+52
View File
@@ -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
+172
View File
@@ -0,0 +1,172 @@
"""Unit-Tests DMX-Mapping: Flanken, 16-Bit-Decoder, Signalverlust (§16.416.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
+53
View File
@@ -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
+66
View File
@@ -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
+105
View File
@@ -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
+61
View File
@@ -0,0 +1,61 @@
"""Unit-Tests Renderer-Pipeline-Definitionen (PLAN.md §12, §36 Nr. 45)."""
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)
+134
View File
@@ -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))
+54
View File
@@ -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))
+90
View File
@@ -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