AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert

Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell.
Jetzt ist sauber getrennt:

ROOT (was der Nutzer sieht und braucht):
- run.py                     = das Programm
- hms_app/                   = der Anwendungscode
- HMS MediaEngine.app        = macOS Doppelklick-Starter
- HMS-Start.vbs              = Windows Doppelklick-Starter
- HMS-Install.vbs             = Windows Erst-Installation
- HMS-Mac-Install.command     = macOS Homebrew-Installation
- HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix)
- installer_gui.py           = grafischer Installer
- launcher.pyw + launcher_core.py = interne Start-Logik
- LIESMICH.txt               = 10-Zeilen-Kurzanleitung
- .gitignore

_entwicklung/ (alles andere, NICHT benoetigt):
- packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/
  build/ fixture_profiles/
- PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md
- pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py

Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann
_entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne.

Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 23:44:06 +02:00
parent 696e8eb1b3
commit 362e089be0
338 changed files with 24 additions and 387 deletions
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
@@ -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
+351
View File
@@ -0,0 +1,351 @@
"""Unit-Tests Audio-Analyse und Mapping (PLAN.md §20, §29.1)."""
from __future__ import annotations
import math
import pytest
from hms_audio import (
AudioAnalyzer,
AudioFeatures,
BeatDetector,
RingBuffer,
compute_band_energy,
compute_fft_magnitude,
compute_peak,
compute_rms,
compute_spectral_flux,
)
from hms_audio.mapping import (
LFO,
AudioBinding,
CurveType,
ModulatorEngine,
RandomModulator,
StepSequencer,
apply_curve,
)
# ---------- RMS/Peak (§20.2) ----------
def test_rms_of_silence_is_zero() -> None:
assert compute_rms([]) == 0.0
assert compute_rms([0.0] * 100) == 0.0
def test_rms_of_constant_signal() -> None:
assert compute_rms([0.5] * 100) == pytest.approx(0.5)
assert compute_rms([1.0, -1.0] * 50) == pytest.approx(1.0)
def test_peak_finds_absolute_maximum() -> None:
assert compute_peak([0.3, -0.8, 0.5]) == 0.8
assert compute_peak([]) == 0.0
# ---------- RingBuffer (§20.3) ----------
def test_ringbuffer_capacity_bounded() -> None:
buf = RingBuffer(8)
for i in range(20):
buf.push(float(i))
assert len(buf) == 8
assert buf.capacity == 8
def test_ringbuffer_latest_returns_chronological() -> None:
buf = RingBuffer(4)
buf.extend([1.0, 2.0, 3.0, 4.0, 5.0]) # überschreibt die ältesten
latest = buf.latest(3)
assert latest == [3.0, 4.0, 5.0] # chronologisch, nicht reversed
def test_ringbuffer_rejects_zero_capacity() -> None:
with pytest.raises(ValueError):
RingBuffer(0)
# ---------- FFT und Bänder (§20.2) ----------
def test_fft_of_sine_finds_dominant_frequency() -> None:
"""Ein 100-Hz-Sinus muss seinen Peak bei ~100 Hz haben."""
sample_rate = 1000.0
freq = 100.0
n = 256
samples = [math.sin(2.0 * math.pi * freq * t / sample_rate) for t in range(n)]
magnitudes = compute_fft_magnitude(samples, sample_rate)
assert len(magnitudes) == n // 2
peak_bin = magnitudes.index(max(magnitudes))
peak_freq = peak_bin * sample_rate / n
assert 80.0 < peak_freq < 120.0 # innerhalb der FFT-Auflösung
def test_band_energy_isolated() -> None:
"""Bassband-Energie mit reinem Bass-Signal > Trebleband-Energie."""
sample_rate = 44100.0
bass_freq = 100.0
n = 512
samples = [math.sin(2.0 * math.pi * bass_freq * t / sample_rate) for t in range(n)]
magnitudes = compute_fft_magnitude(samples, sample_rate)
bass = compute_band_energy(magnitudes, sample_rate, 0, 250)
treble = compute_band_energy(magnitudes, sample_rate, 8000, 20000)
assert bass > treble # Energie steckt im Bass, nicht im Höhenband
def test_band_energy_empty_magnitudes() -> None:
assert compute_band_energy([], 44100, 0, 20000) == 0.0
# ---------- Spectral Flux (§20.2) ----------
def test_spectral_flux_positive_changes_only() -> None:
current = [0.5, 0.3, 0.7]
previous = [0.2, 0.4, 0.5]
flux = compute_spectral_flux(current, previous)
# positive: (0.5-0.2)=0.3, (0.7-0.5)=0.2; negative: (0.3-0.4) verworfen
assert flux == pytest.approx(0.5)
def test_spectral_flux_empty() -> None:
assert compute_spectral_flux([], []) == 0.0
assert compute_spectral_flux([1.0], []) == 0.0
# ---------- BeatDetector (§20.2) ----------
def test_beat_detector_recovers_bpm() -> None:
"""Regelmäßige Flux-Spitzen bei 120 BPM = 0.5 s Peak-zu-Peak-Intervall.
Peaks alle 2 Perioden à 0.25 s = 0.5 s zwischen Beats = 120 BPM.
"""
det = BeatDetector(min_interval_s=0.3)
ns_per_period = int(0.25 * 1e9) # 250 ms pro Periode
beat_count = 0
for period in range(40):
t = period * ns_per_period
flux = 10.0 if period % 2 == 0 else 0.1 # Beat alle 0.5 s
if det.feed(flux, t):
beat_count += 1
assert beat_count >= 5 # die meisten Beats erkannt
assert 100.0 < det.bpm < 140.0 # um 120 BPM
assert det.confidence > 0.3
def test_beat_detector_respects_min_interval() -> None:
"""Beats näher als min_interval werden ignoriert (§20.2)."""
det = BeatDetector(min_interval_s=0.5)
det._flux_history = [1.0] * 10 # genug Basisdaten
t0 = 1_000_000_000 # > 0: vermeidet Sentinel-Verwirrung
t1 = t0 + int(0.1 * 1e9) # nur 100 ms später
assert det.feed(10.0, t0) is True # erster Beat
assert det.feed(10.0, t1) is False # zu nah: ignoriert
def test_beat_detector_needs_warmup() -> None:
"""Vor 4 Werten gibt es keine Beats (Ausreißerschutz)."""
det = BeatDetector()
assert det.feed(100.0, 0) is False # erst 1 Wert: kein Beat
assert det.feed(100.0, 1) is False
assert det.feed(100.0, 2) is False
# ---------- AudioAnalyzer (§20.2, §20.3) ----------
def test_analyzer_silence_detection() -> None:
an = AudioAnalyzer()
an.feed([0.0] * 512)
features = an.analyze(now_ns=1_000_000_000)
assert features.silence is True
assert features.rms < 0.001
def test_analyzer_detects_tone() -> None:
"""Ein 440-Hz-Ton: RMS deutlich über 0, Bassband hat Energie."""
an = AudioAnalyzer()
sample_rate = AudioAnalyzer.SAMPLE_RATE
samples = [
0.5 * math.sin(2.0 * math.pi * 440.0 * t / sample_rate)
for t in range(512)
]
an.feed(samples)
features = an.analyze(now_ns=1_000_000_000)
assert features.silence is False
assert features.rms > 0.1
assert features.bass > 0.0 # 440 Hz fällt ins Low-Mid, aber Bass hat Anteil
assert features.monotonic_ns == 1_000_000_000 # timestamped (§20.3)
def test_analyzer_insufficient_data_returns_last() -> None:
"""Weniger als halbes Fenster: letzter Snapshot wird zurückgegeben."""
an = AudioAnalyzer()
an.feed([0.1] * 10) # viel zu wenig
features = an.analyze()
assert features == AudioFeatures() # Initial-Snapshot (alles 0)
# ---------- Kurven (§20.4) ----------
def test_apply_curve_types() -> None:
assert apply_curve(0.5, CurveType.LINEAR) == pytest.approx(0.5)
assert apply_curve(0.5, CurveType.QUADRATIC) == pytest.approx(0.25)
assert apply_curve(0.5, CurveType.CUBIC) == pytest.approx(0.125)
assert apply_curve(2.0, CurveType.LINEAR) == 1.0 # clamp
assert apply_curve(-1.0, CurveType.LINEAR) == 0.0 # clamp
# ---------- AudioBinding (§20.4) ----------
def test_binding_full_pipeline() -> None:
"""Feature → Gate → Kurve → Attack → Min/Max."""
binding = AudioBinding(
id="b1",
feature="bass",
parameter_path="composition/x/layer/y/opacity",
threshold=0.1,
gain=2.0,
curve=CurveType.LINEAR,
attack_s=0.01,
release_s=0.1,
min_value=0.2,
max_value=0.9,
)
features = AudioFeatures(bass=0.5, monotonic_ns=1_000_000_000)
value = binding.process(features, 1_000_000_000)
# Gate: (0.5-0.1)/(1-0.1)=0.444, Gain: 0.889, Clamp: 0.889
# Min/Max: 0.2 + 0.889*0.7 = 0.822
assert 0.5 < value < 0.9
def test_binding_gate_below_threshold() -> None:
binding = AudioBinding(
id="b2",
feature="rms",
parameter_path="master/intensity",
threshold=0.5,
)
features = AudioFeatures(rms=0.3, monotonic_ns=1_000_000)
value = binding.process(features, 1_000_000)
assert value == pytest.approx(0.0) # unter Schwelle → 0
def test_binding_disabled_returns_current() -> None:
binding = AudioBinding(
id="b3",
feature="rms",
parameter_path="x",
enabled=False,
)
features = AudioFeatures(rms=0.8)
assert binding.process(features, 1_000_000) == 0.0 # bleibt bei 0
def test_binding_attack_smoothing() -> None:
"""Attack glättet: bei schneller Zeitänderung nähert sich der Wert."""
binding = AudioBinding(
id="b4",
feature="rms",
parameter_path="x",
attack_s=1.0, # langsam
)
t0 = 1_000_000_000
t1 = t0 + 100_000_000 # 100 ms später
binding.process(AudioFeatures(rms=1.0), t0)
v1 = binding.process(AudioFeatures(rms=1.0), t1)
# Erster Schritt setzt _current=1.0; zweiter bleibt bei 1.0
assert v1 == pytest.approx(1.0)
# ---------- LFO / Random / Sequencer (§20.5) ----------
def test_lfo_sine_periodicity() -> None:
lfo = LFO(id="l1", waveform="sine", rate_hz=1.0)
t0 = 0
t_half = int(0.5 * 1e9) # halbe Periode
v0 = lfo.process(t0)
v_half = lfo.process(t_half)
lfo.process(int(1.0 * 1e9)) # volle Periode: nur Nebenprodukt
assert v0 != v_half # unterschiedliche Phasen
assert 0.0 <= v0 <= 1.0
assert 0.0 <= v_half <= 1.0
def test_lfo_square_waveform() -> None:
lfo = LFO(id="l2", waveform="square", rate_hz=1.0)
v_low = lfo.process(int(0.25 * 1e9)) # erste Hälfte
v_high = lfo.process(int(0.75 * 1e9)) # zweite Hälfte
assert v_low == 1.0
assert v_high == 0.0
def test_random_modulator_deterministic_with_seed() -> None:
"""Gleicher Seed → gleiche Sequenz (§20.5: Random mit Seed)."""
r1 = RandomModulator(id="r1", seed=42, rate_hz=100)
r2 = RandomModulator(id="r2", seed=42, rate_hz=100)
t = int(0.01 * 1e9)
v1 = [r1.process(t + i * 10_000_000) for i in range(10)]
v2 = [r2.process(t + i * 10_000_000) for i in range(10)]
assert v1 == v2 # deterministisch
def test_step_sequencer_cycles_through_steps() -> None:
seq = StepSequencer(
id="s1",
steps=[0.0, 1.0, 0.5, 0.0],
bpm=240.0, # 4 Steps pro Sekunde
)
t0 = 0
t1 = int(0.25 * 1e9) # Step 1
t2 = int(0.50 * 1e9) # Step 2
v0 = seq.process(t0)
v1 = seq.process(t1)
v2 = seq.process(t2)
assert v0 == pytest.approx(0.0)
assert v1 == pytest.approx(1.0)
assert v2 == pytest.approx(0.5)
# ---------- ModulatorEngine (§20.4, §20.5) ----------
def test_engine_routes_audio_bindings() -> None:
engine = ModulatorEngine()
engine.add_audio_binding(
AudioBinding(id="a1", feature="bass", parameter_path="layer/x/opacity")
)
engine.add_audio_binding(
AudioBinding(id="a2", feature="rms", parameter_path="master/intensity")
)
features = AudioFeatures(bass=0.8, rms=0.3, monotonic_ns=1_000_000_000)
results = engine.process_audio(features, 1_000_000_000)
assert "layer/x/opacity" in results
assert "master/intensity" in results
assert results["layer/x/opacity"] > results["master/intensity"] # bass > rms
def test_engine_disabled_binding_skipped() -> None:
engine = ModulatorEngine()
engine.add_audio_binding(
AudioBinding(id="a1", feature="bass", parameter_path="x", enabled=False)
)
results = engine.process_audio(AudioFeatures(bass=0.5), 1_000_000)
assert results == {} # nichts aktiv
def test_engine_processes_all_modulator_types() -> None:
engine = ModulatorEngine()
engine.add_lfo(LFO(id="l1", rate_hz=2.0))
engine.add_random(RandomModulator(id="r1", seed=1))
engine.add_sequencer(StepSequencer(id="s1", steps=[1.0, 0.0]))
results = engine.process_modulators(int(0.1 * 1e9))
assert "lfo" in results and "l1" in results["lfo"]
assert "random" in results and "r1" in results["random"]
assert "sequencer" in results and "s1" in results["sequencer"]
@@ -0,0 +1,172 @@
"""Tests über ALLE eingebauten Pflicht-Plugins (PLAN.md §15, §29.1).
Parametrisiert über jedes gefundene Builtin-Plugin:
- Manifest validiert (inkl. Shader-Existenz je Backend)
- kind stimmt zur Kategorie (generator/filter)
- alle drei Backends d3d11/gl/gles deklariert (§12.6 Backend-Vertrag)
- max. 8 DMX-Slots gesamt (§14.7)
- adaptive_quality mit mindestens 3 Varianten (§15.1/§15.2 AQ-Regeln)
- Standard-Uniform-Satz in jedem Shader deklariert (§14.4)
Vollständigkeitsprüfung (§15.1/§15.2): exakt 10 Generatoren und
14 Filter mit den normativen Plugin-IDs sind vorhanden.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from hms_plugin_sdk import load_manifest
REPO = Path(__file__).resolve().parents[2]
BUILTIN = REPO / "plugins" / "builtin"
REQUIRED_GENERATORS = {
"com.hms.generator.solid",
"com.hms.generator.gradient",
"com.hms.generator.checker_grid",
"com.hms.generator.stripes_chaser",
"com.hms.generator.noise_clouds",
"com.hms.generator.plasma",
"com.hms.generator.wave_bars",
"com.hms.generator.shapes",
"com.hms.generator.drops_ripples",
"com.hms.generator.starfield",
}
REQUIRED_FILTERS = {
"com.hms.fx.transform2d",
"com.hms.fx.color_adjust",
"com.hms.fx.gradient_map",
"com.hms.fx.blur_sharpen",
"com.hms.fx.pixelate_quantize",
"com.hms.fx.mirror_tile",
"com.hms.fx.kaleidoscope",
"com.hms.fx.wave_displace",
"com.hms.fx.rgb_split",
"com.hms.fx.glow_bloom",
"com.hms.fx.edge_emboss",
"com.hms.fx.vignette",
"com.hms.fx.strobe_pulse",
"com.hms.fx.feedback_trails",
}
def _plugin_dirs() -> list[Path]:
dirs: list[Path] = []
for category in ("generators", "filters"):
dirs.extend(sorted((BUILTIN / category).glob("com.*")))
return dirs
def _shaders_of(plugin_dir: Path) -> list[Path]:
return [p for p in (plugin_dir / "shaders").rglob("*") if p.is_file()]
# ---------- Vollständigkeit (§15.1, §15.2) ----------
def test_all_mandatory_generators_present() -> None:
found = {d.name for d in _plugin_dirs()}
missing = REQUIRED_GENERATORS - found
assert not missing, f"fehlende Pflicht-Generatoren: {sorted(missing)}"
def test_all_mandatory_filters_present() -> None:
found = {d.name for d in _plugin_dirs()}
missing = REQUIRED_FILTERS - found
assert not missing, f"fehlende Pflicht-Filter: {sorted(missing)}"
def test_exactly_the_mandatory_set() -> None:
"""Keine unautorisierten Extras im Pflicht-Verzeichnis (Verwechselungs-
schutz; Beispiele leben in plugins/examples)."""
found = {d.name for d in _plugin_dirs()}
expected = REQUIRED_GENERATORS | REQUIRED_FILTERS
assert found == expected, (
f"unerwartete Differenz: extra={sorted(found - expected)} "
f"fehlt={sorted(expected - found)}"
)
# ---------- Parametrisierte Einzelprüfungen ----------
@pytest.mark.parametrize("plugin_dir", _plugin_dirs(), ids=lambda p: p.name)
class TestBuiltinPlugin:
def test_manifest_validates(self, plugin_dir: Path) -> None:
manifest, errors = load_manifest(plugin_dir)
assert errors == [], errors
assert manifest["vendor"] == "HMS"
assert manifest["failure_mode"] == "bypass"
assert manifest["schema_version"] == 1
assert manifest["api_version"] == 1
def test_kind_matches_category(self, plugin_dir: Path) -> None:
manifest, _ = load_manifest(plugin_dir)
category = plugin_dir.parent.name
expected = "generator" if category == "generators" else "filter"
assert manifest["kind"] == expected
def test_all_backends_declared(self, plugin_dir: Path) -> None:
"""§12.6 Backend-Vertrag: V1-Plugins liefern d3d11, gl und gles."""
manifest, _ = load_manifest(plugin_dir)
assert set(manifest["entrypoints"]) == {"d3d11", "gl", "gles"}
assert set(manifest["capabilities"]["supported_backends"]) == {
"d3d11",
"gl",
"gles",
}
def test_dmx_slot_footprint_max_8(self, plugin_dir: Path) -> None:
"""§14.7: maximal 8 generische DMX-Slots je Plugin."""
manifest, _ = load_manifest(plugin_dir)
total = sum(len(p.get("dmx_slots", [])) for p in manifest["parameters"])
assert total <= 8, f"{plugin_dir.name}: {total} DMX-Slots > 8"
def test_adaptive_quality_variants(self, plugin_dir: Path) -> None:
"""§15.1/§15.2: AQ mit mindestens 3 Varianten; Parametersemantik
bleibt über Varianten unverändert (semantic_parameters_unchanged
darf nur AQ-interne Größen nennen, nie semantische Parameter wegnehmen)."""
manifest, _ = load_manifest(plugin_dir)
aq = manifest.get("adaptive_quality")
assert aq is not None, "adaptive_quality fehlt"
ids = [v["id"] for v in aq["variants"]]
assert len(ids) >= 3, f"nur {len(ids)} AQ-Varianten"
def test_shader_uniform_contract(self, plugin_dir: Path) -> None:
"""§14.4: Standard-Inputs sind in jedem Shader deklariert; Filter
sampeln die Eingabetextur, Generatoren deklarieren sie (Uniform-Satz
identisch), auch wenn sie sie nicht nutzen."""
shaders = _shaders_of(plugin_dir)
assert shaders, "keine Shader-Dateien"
for shader in shaders:
src = shader.read_text(encoding="utf-8")
for uniform in ("u_resolution", "u_time_seconds", "u_layer_opacity"):
assert uniform in src, f"{shader.name}: {uniform} fehlt"
def test_generator_shader_has_no_input_sampling(self, plugin_dir: Path) -> None:
"""Generatoren erzeugen Inhalte ohne Eingangsbild (§12.3): der
Shader darf die Eingabetextur nicht als Quelle sampeln."""
manifest, _ = load_manifest(plugin_dir)
if manifest["kind"] != "generator":
return
for shader in _shaders_of(plugin_dir):
src = shader.read_text(encoding="utf-8")
sample_calls = (
src.count("u_input_texture.Sample")
+ src.count("texture(u_input_texture")
+ src.count("texture2D(u_input_texture")
)
assert sample_calls == 0, (
f"{plugin_dir.name}/{shader.name}: Generator sampelt u_input_texture"
)
def test_filter_mix_zero_is_free_bypass(self, plugin_dir: Path) -> None:
"""§15.3: jeder Filter besitzt mix; Mix 0 bypassed kostengünstig."""
manifest, _ = load_manifest(plugin_dir)
if manifest["kind"] != "filter":
return
ids = [p["id"] for p in manifest["parameters"]]
assert "mix" in ids, f"{plugin_dir.name}: mix-Parameter fehlt"
@@ -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
+368
View File
@@ -0,0 +1,368 @@
"""Unit-Tests Cluster: Nachrichten, Registry, Paarung, Discovery
(PLAN.md §6.3, §6.5, §29.1).
Keine Mocks für Logik; Health-Schwellen werden über injizierte Zeitstempel
bestimmt, Multicast selbst gehört zum Gate-1-LAN-Test (ADR-0009).
"""
from __future__ import annotations
import time
import uuid
from pathlib import Path
import pytest
from hms_cluster import (
ClusterMessage,
CommandStatus,
CommandTracker,
DuplicateNodeError,
HealthThresholds,
ManualNodeList,
NodeCategory,
NodeHealth,
NodeRegistry,
PairingStore,
Scope,
ServiceInfo,
capability_digest,
hash_token,
identity_fingerprint,
)
def _uuid() -> str:
return str(uuid.uuid4())
# ---------- ClusterMessage (§6.5) ----------
def test_message_requires_uuid_fields() -> None:
with pytest.raises(ValueError, match="must be a UUID"):
ClusterMessage(
cluster_id="nope",
node_id=_uuid(),
command_id=_uuid(),
sequence=0,
project_revision=0,
)
def test_message_rejects_negative_sequence_and_revision() -> None:
args = dict(cluster_id=_uuid(), node_id=_uuid(), command_id=_uuid())
with pytest.raises(ValueError, match="sequence"):
ClusterMessage(**args, sequence=-1, project_revision=0)
with pytest.raises(ValueError, match="project_revision"):
ClusterMessage(**args, sequence=0, project_revision=-2)
def test_message_roundtrip_preserves_fields() -> None:
msg = ClusterMessage(
cluster_id=_uuid(),
node_id=_uuid(),
command_id=_uuid(),
sequence=42,
project_revision=7,
execute_at_show_time_ns=123456,
status=CommandStatus.ARMED,
payload={"preset": "a"},
)
restored = ClusterMessage.from_dict(msg.to_dict())
assert restored.cluster_id == msg.cluster_id
assert restored.sequence == 42
assert restored.project_revision == 7
assert restored.execute_at_show_time_ns == 123456
assert restored.status is CommandStatus.ARMED
assert restored.trace_id == msg.trace_id
def test_command_tracker_idempotent_and_forward_only() -> None:
tracker = CommandTracker()
node = _uuid()
cmd = _uuid()
assert tracker.register(node, cmd) is True
assert tracker.register(node, cmd) is False # Duplikat → kein Re-Apply
assert tracker.advance(node, cmd, CommandStatus.ARMED) is True
assert tracker.advance(node, cmd, CommandStatus.ACCEPTED) is False # Rückschritt
assert tracker.advance(node, cmd, CommandStatus.EXECUTED) is True
assert tracker.advance(node, cmd, CommandStatus.ARMED) is False # abgeschlossen
assert tracker.status(node, cmd) is CommandStatus.EXECUTED
def test_command_tracker_capacity_bound() -> None:
tracker = CommandTracker(capacity=2)
for _ in range(3):
tracker.register(_uuid(), _uuid())
assert len(tracker._states) <= 2 # kein unbeschränkter Cache (§33)
# ---------- NodeRegistry (§6.3, §6.5) ----------
def test_register_and_update_keeps_identity() -> None:
reg = NodeRegistry()
node_id = _uuid()
reg.register(node_id, "Node A", roles=("RENDER_NODE",), api_port=8000)
# IP-Wechsel: gleiche node_id, neuer Endpunkt
reg.register(node_id, "Node A", api_port=8000, endpoint="10.0.0.9:8000")
reg.register(node_id, "Node A", api_port=8000, endpoint="10.0.1.9:8000")
entry = reg.get(node_id)
assert entry is not None
assert entry.last_known_endpoints[0] == "10.0.1.9:8000" # zuletzt bekannt
assert "10.0.0.9:8000" in entry.last_known_endpoints
def test_duplicate_node_id_with_other_identity_blocked() -> None:
reg = NodeRegistry()
node_id = _uuid()
reg.register(node_id, "Node A")
with pytest.raises(DuplicateNodeError): # §6.3: Fehler, kein stilles Mischen
reg.register(node_id, "Node B")
def test_incompatible_protocol_version_categorized() -> None:
reg = NodeRegistry(protocol_version=1)
entry = reg.register(_uuid(), "Alte Node", protocol_version=99)
assert entry.category is NodeCategory.INCOMPATIBLE
assert reg.by_category(NodeCategory.INCOMPATIBLE)[0].node_id == entry.node_id
def test_health_transitions_by_thresholds() -> None:
thresholds = HealthThresholds(
heartbeat_interval_ns=500_000_000,
degraded_after_ns=2_000_000_000,
stale_after_ns=5_000_000_000,
)
reg = NodeRegistry(thresholds=thresholds)
node_id = _uuid()
reg.register(node_id, "Node A")
assert reg.evaluate_health(node_id) is NodeHealth.OFFLINE # nie Heartbeat
reg.record_heartbeat(node_id)
assert reg.evaluate_health(node_id) is NodeHealth.ONLINE
# verspäteter Heartbeat simulieren: letzten Heartbeat zurückdatieren
entry = reg.get(node_id)
entry.last_heartbeat_ns -= 3_000_000_000 # 3 s alt → degraded
assert reg.evaluate_health(node_id) is NodeHealth.DEGRADED
entry.last_heartbeat_ns -= 3_000_000_000 # 6 s alt → offline
assert reg.evaluate_health(node_id) is NodeHealth.OFFLINE
def test_paired_node_offline_keeps_category_offline() -> None:
reg = NodeRegistry()
node_id = _uuid()
reg.register(node_id, "Node A")
reg.mark_paired(node_id)
reg.record_heartbeat(node_id)
entry = reg.get(node_id)
assert entry is not None
entry.last_heartbeat_ns -= 6_000_000_000 # deutlich zu alt
reg.evaluate_health(node_id)
assert reg.get(node_id).category is NodeCategory.OFFLINE # nicht zurückgesetzt
# ---------- Pairing (§6.3, §27.1) ----------
def test_fingerprint_stable_and_distinct() -> None:
node_id = _uuid()
a = identity_fingerprint(node_id, "Node A")
a2 = identity_fingerprint(node_id, "Node A") # gleiche Eingabe → gleicher Wert
assert a == a2
b = identity_fingerprint(_uuid(), "Node B") # andere Eingabe → anderer Wert
assert a != b
# Format: 8 Gruppen à 4 Hex-Zeichen
groups = a.split(":")
assert len(groups) == 8 and all(len(g) == 4 for g in groups)
def test_pairing_flow_pin_fingerprint_token() -> None:
store = PairingStore()
node_id = _uuid()
pin = store.issue_pin(node_id)
fingerprint = identity_fingerprint(node_id, "Node A")
token = store.complete_pairing(
node_id,
entered_pin=pin.value,
fingerprint_seen=fingerprint,
expected_fingerprint=fingerprint,
scopes=frozenset({Scope.READ, Scope.CONTROL}),
)
assert token
assert store.verify(node_id, token, Scope.CONTROL)
assert store.verify(node_id, token, Scope.READ)
assert not store.verify(node_id, token, Scope.ADMIN) # Scope fehlt
def test_wrong_pin_rejected_and_locks_after_attempts() -> None:
store = PairingStore()
node_id = _uuid()
pin = store.issue_pin(node_id)
fp = identity_fingerprint(node_id, "Node A")
for _ in range(store.max_pin_attempts):
with pytest.raises(PermissionError, match="PIN falsch"):
store.complete_pairing(
node_id,
entered_pin="000000" if pin.value != "000000" else "000001",
fingerprint_seen=fp,
expected_fingerprint=fp,
scopes=frozenset({Scope.READ}),
)
with pytest.raises(PermissionError, match="gesperrt"):
store.complete_pairing(
node_id,
entered_pin=pin.value, # jetzt sogar die richtige PIN
fingerprint_seen=fp,
expected_fingerprint=fp,
scopes=frozenset({Scope.READ}),
)
def test_expired_pin_rejected() -> None:
store = PairingStore()
node_id = _uuid()
pin = store.issue_pin(node_id)
fp = identity_fingerprint(node_id, "Node A")
future = pin.expires_ns + 1
with pytest.raises(PermissionError, match="abgelaufen"):
store.complete_pairing(
node_id,
entered_pin=pin.value,
fingerprint_seen=fp,
expected_fingerprint=fp,
scopes=frozenset({Scope.READ}),
now_ns=future,
)
def test_fingerprint_mismatch_rejected() -> None:
store = PairingStore()
node_id = _uuid()
pin = store.issue_pin(node_id)
with pytest.raises(PermissionError, match="Fingerprint"):
store.complete_pairing(
node_id,
entered_pin=pin.value,
fingerprint_seen=identity_fingerprint(node_id, "Andere Node"),
expected_fingerprint=identity_fingerprint(node_id, "Node A"),
scopes=frozenset({Scope.READ}),
)
def test_token_revocation_immediate() -> None:
store = PairingStore()
node_id = _uuid()
pin = store.issue_pin(node_id)
fp = identity_fingerprint(node_id, "Node A")
token = store.complete_pairing(
node_id,
entered_pin=pin.value,
fingerprint_seen=fp,
expected_fingerprint=fp,
scopes=frozenset({Scope.CONTROL}),
)
assert store.verify(node_id, token, Scope.CONTROL)
store.revoke(node_id)
assert not store.verify(node_id, token, Scope.CONTROL) # sofort wirkungslos
def test_token_expiry() -> None:
store = PairingStore()
node_id = _uuid()
pin = store.issue_pin(node_id)
fp = identity_fingerprint(node_id, "Node A")
token = store.complete_pairing(
node_id,
entered_pin=pin.value,
fingerprint_seen=fp,
expected_fingerprint=fp,
scopes=frozenset({Scope.READ}),
token_ttl_s=0.01,
)
time.sleep(0.02)
assert not store.verify(node_id, token, Scope.READ) # abgelaufen
def test_token_hash_not_plaintext() -> None:
store = PairingStore()
node_id = _uuid()
pin = store.issue_pin(node_id)
fp = identity_fingerprint(node_id, "Node A")
token = store.complete_pairing(
node_id,
entered_pin=pin.value,
fingerprint_seen=fp,
expected_fingerprint=fp,
scopes=frozenset({Scope.READ}),
)
stored = store._tokens[node_id] # interne Sichtprüfung (§27.1: nur Hash)
assert stored.token_hash != token
assert stored.token_hash == hash_token(token)
# ---------- Discovery (ADR-0009) ----------
def test_service_info_txt_roundtrip() -> None:
caps = capability_digest({"tier": "DESKTOP_LITE", "outputs": 1})
info = ServiceInfo(
node_id=_uuid(),
display_name="HMS Node A",
port=8000,
roles=("RENDER_NODE", "COORDINATOR"),
capability_digest=caps,
)
restored = ServiceInfo.from_txt(info.instance_name, info.port, info.txt())
assert restored.node_id == info.node_id
assert restored.roles == info.roles
assert restored.port == 8000
assert restored.capability_digest == caps
def test_service_txt_contains_no_secrets() -> None:
info = ServiceInfo(node_id=_uuid(), display_name="N", port=80, roles=("X",))
blob = str(info.txt()).lower()
for forbidden in ("token", "secret", "password", "key"):
assert forbidden not in blob # §27.1: Discovery ohne Vertrauliches
def test_service_txt_rejects_incomplete() -> None:
with pytest.raises(ValueError, match="unvollstaendig"):
ServiceInfo.from_txt("inst", 8000, {"proto": "1"}) # node/port fehlen
def test_instance_name_sanitized() -> None:
info = ServiceInfo(node_id=_uuid(), display_name="Böse! Zeichen / 42", port=1, roles=())
name = info.instance_name
assert len(name) <= 63
assert "!" not in name and "/" not in name
def test_capability_digest_stable() -> None:
a = capability_digest({"b": 1, "a": 2})
b = capability_digest({"a": 2, "b": 1}) # Reihenfolge egal
assert a == b
assert len(a) == 16
assert capability_digest({"a": 3}) != a
def test_manual_node_list_roundtrip(tmp_path: Path) -> None:
lst = ManualNodeList(path=tmp_path / "nodes.json")
lst.add("10.0.0.9", 8000)
lst.add("10.0.0.9", 8000) # Duplikat wird ignoriert
lst.add("node-b.local", 8001, node_id=_uuid())
entries = lst.load()
assert len(entries) == 2
assert entries[0]["host"] == "10.0.0.9"
assert entries[1]["node_id"]
lst.remove("10.0.0.9", 8000)
assert len(lst.load()) == 1
def test_manual_node_list_tolerates_garbage(tmp_path: Path) -> None:
path = tmp_path / "nodes.json"
path.write_text('{"broken": true}', encoding="utf-8") # kein Liste-Objekt
assert ManualNodeList(path=path).load() == []
path.write_text('not json at all', encoding="utf-8")
assert ManualNodeList(path=path).load() == [] # defekt → leer, nie Absturz
@@ -0,0 +1,258 @@
"""Unit-Tests Zielrouting, Clock-Sync, zeitgestempelte Aktivierung
(PLAN.md §6.3, §6.4, §6.5)."""
from __future__ import annotations
import pytest
from hms_cluster import (
ActivationCoordinator,
ArmState,
ClockEstimator,
ClockSample,
GroupRouter,
GroupRule,
ServerGroup,
TargetKind,
)
# ---------- GroupRouter (§6.3 Bedienmodelle) ----------
@pytest.fixture()
def router() -> GroupRouter:
r = GroupRouter()
r.set_node_tags("node-a", frozenset({"stage-left"}))
r.set_node_tags("node-b", frozenset({"stage-right"}))
r.set_node_tags("node-c", frozenset({"stage-left", "stage-right"}))
r.set_node_outputs("node-a", ["out-1"])
r.set_node_outputs("node-c", ["out-2"])
return r
def test_resolve_all_targets(router: GroupRouter) -> None:
assert router.resolve(TargetKind.ALL) == frozenset({"node-a", "node-b", "node-c"})
def test_resolve_single_node(router: GroupRouter) -> None:
assert router.resolve(TargetKind.NODE, "node-a") == frozenset({"node-a"})
assert router.resolve(TargetKind.NODE, "unbekannt") == frozenset()
def test_resolve_by_output(router: GroupRouter) -> None:
assert router.resolve(TargetKind.OUTPUT, "out-2") == frozenset({"node-c"})
assert router.resolve(TargetKind.OUTPUT, "out-9") == frozenset()
def test_group_rule_selected(router: GroupRouter) -> None:
router.upsert_group(
ServerGroup(
id="g1",
name="Links",
rule=GroupRule.SELECTED,
node_ids=frozenset({"node-a", "unbekannt"}),
)
)
# unbekannte Mitglieder werden still gefiltert, bekannte bleiben
assert router.resolve(TargetKind.SERVER_GROUP, "g1") == frozenset({"node-a"})
def test_group_rule_tag_query(router: GroupRouter) -> None:
router.upsert_group(
ServerGroup(
id="g2",
name="Beide Bühnen",
rule=GroupRule.TAG_QUERY,
tags=frozenset({"stage-left"}),
)
)
assert router.resolve(TargetKind.SERVER_GROUP, "g2") == frozenset(
{"node-a", "node-c"}
)
def test_group_rule_all(router: GroupRouter) -> None:
router.upsert_group(ServerGroup(id="g3", name="Alle", rule=GroupRule.ALL))
assert router.resolve(TargetKind.SERVER_GROUP, "g3") == router.resolve(TargetKind.ALL)
def test_preview_matches_resolve(router: GroupRouter) -> None:
"""§17.5: Commit-Vorschau zeigt dieselben Ziele wie der Versand."""
router.upsert_group(
ServerGroup(id="g4", name="X", rule=GroupRule.SELECTED, node_ids=frozenset({"node-b"}))
)
assert router.preview_targets(TargetKind.SERVER_GROUP, "g4") == router.resolve(
TargetKind.SERVER_GROUP, "g4"
)
def test_unknown_group_resolves_empty(router: GroupRouter) -> None:
assert router.resolve(TargetKind.SERVER_GROUP, "gibts-nicht") == frozenset()
def test_remove_group(router: GroupRouter) -> None:
router.upsert_group(ServerGroup(id="g", name="Weg"))
router.remove_group("g")
assert router.get_group("g") is None
# ---------- ClockEstimator (§6.4 Clock Sync) ----------
def _sample(t0: int, rtt: int, offset: int) -> ClockSample:
"""Probe mit definiertem echtem Offset: node_time = t0 + rtt/2 + offset."""
node_time = t0 + rtt // 2 + offset
return ClockSample(t0_ns=t0, t1_ns=t0 + rtt, node_time_ns=node_time)
def test_clock_offset_estimated_from_low_rtt_samples() -> None:
"""Min-Filter: Proben mit Rauschen (hohe RTT) verschieben den Schätzer
nicht; die niedrigste RTT dominiert (§6.4)."""
est = ClockEstimator()
# echter Offset: +5 ms; einige Proben mit Jitter
est.feed(_sample(0, 1_000_000, 5_000_000)) # 1 ms RTT
est.feed(_sample(1_000_000, 50_000_000, 30_000_000)) # 50 ms RTT, Jitter
est.feed(_sample(2_000_000, 2_000_000, 5_500_000))
est.feed(_sample(3_000_000, 1_500_000, 4_800_000))
offset = est.offset_ns
assert offset is not None
assert 4_000_000 < offset < 6_000_000 # nahe am echten 5 ms
def test_clock_best_rtt_reported() -> None:
est = ClockEstimator()
est.feed(_sample(0, 20_000_000, 0))
est.feed(_sample(1, 5_000_000, 0))
assert est.rtt_ns == 5_000_000
def test_clock_no_data_returns_none() -> None:
est = ClockEstimator()
assert est.offset_ns is None
assert est.rtt_ns is None
assert est.drift_ppm is None
def test_clock_drift_estimated_over_time() -> None:
"""Drift: Offset wächst um 100 µs pro Sekunde = 100 ppm (§6.4)."""
est = ClockEstimator()
est.feed(_sample(t0=0, rtt=1_000_000, offset=0))
# 2 s später: 200 µs mehr Offset (200_000 ns) → 100 µs/s = 100 ppm
est.feed(_sample(t0=2_000_000_000, rtt=1_000_000, offset=200_000))
drift = est.drift_ppm
assert drift is not None
assert 80.0 <= drift <= 120.0 # ~100 ppm
def test_clock_drift_none_below_one_second_window() -> None:
"""Zu kurzes Fenster: Drift ist nicht belastbar (§6.4 Grenze)."""
est = ClockEstimator()
est.feed(_sample(0, 1_000_000, 0))
est.feed(_sample(100_000_000, 1_000_000, 50)) # nur 100 ms Abstand
assert est.drift_ppm is None
def test_clock_rejects_negative_rtt() -> None:
with pytest.raises(ValueError, match="negative RTT"):
ClockEstimator().feed(ClockSample(t0_ns=10, t1_ns=5, node_time_ns=0))
def test_clock_maps_show_time_to_node_time() -> None:
"""§29.1: Showzeit → lokale Monotonic über den Offset."""
est = ClockEstimator()
est.feed(_sample(0, 1_000_000, offset=10_000_000)) # +10 ms
mapped = est.map_show_time(1_000_000_000)
assert mapped is not None
assert mapped == 1_010_000_000 # Showzeit + Offset
# ohne Proben: keine Abbildung möglich
assert ClockEstimator().map_show_time(0) is None
def test_clock_sample_window_bounded() -> None:
"""Kein unbeschränkter Zustand (§33): Fenster bleibt begrenzt."""
est = ClockEstimator(max_samples=4)
for i in range(10):
est.feed(_sample(i * 1_000_000, 1_000_000, 0))
assert len(est._samples) <= 4
# ---------- ActivationCoordinator (§6.5) ----------
@pytest.fixture()
def two_node_setup():
router = GroupRouter()
router.set_node_tags("node-a", frozenset({"x"}))
router.set_node_tags("node-b", frozenset({"x"}))
coord = ActivationCoordinator(router)
return coord, router
def test_schedule_plans_with_lead_time(two_node_setup) -> None:
coord, _ = two_node_setup
planned = coord.schedule("scene-1", now_show_ns=1_000_000_000, lead_ns=200_000_000)
assert planned.execute_at_show_ns == 1_200_000_000 # §6.4: 100300 ms Vorlauf
assert planned.state is ArmState.CREATED
def test_due_requires_all_arms(two_node_setup) -> None:
"""§6.5: Execute nur, wenn ALLE Ziel-Nodes armed; sonst FAILED."""
coord, _ = two_node_setup
planned = coord.schedule("scene-1", now_show_ns=0, lead_ns=100)
coord.acknowledge_arm(planned.command_id, "node-a")
# Showzeit erreicht, aber node-b fehlt → FAILED, nicht still ausgeführt
due = coord.due(now_show_ns=1_000_000)
assert due == []
assert coord.get(planned.command_id).state is ArmState.FAILED
def test_due_executes_when_all_armed(two_node_setup) -> None:
coord, _ = two_node_setup
planned = coord.schedule("scene-1", now_show_ns=0, lead_ns=100)
coord.acknowledge_arm(planned.command_id, "node-a")
coord.acknowledge_arm(planned.command_id, "node-b")
due = coord.due(now_show_ns=1_000_000)
assert len(due) == 1 and due[0].command_id == planned.command_id
assert coord.get(planned.command_id).state is ArmState.ARMED
def test_execute_completes_when_all_nodes_report(two_node_setup) -> None:
coord, _ = two_node_setup
planned = coord.schedule("scene-1", now_show_ns=0, lead_ns=100)
coord.acknowledge_arm(planned.command_id, "node-a")
coord.acknowledge_arm(planned.command_id, "node-b")
coord.due(now_show_ns=1_000_000)
# beide Nodes melden ausgeführt (mit Ist-Zeit, §6.5)
coord.acknowledge_execute(planned.command_id, "node-a")
assert coord.get(planned.command_id).state is ArmState.ARMED # noch nicht komplett
coord.acknowledge_execute(planned.command_id, "node-b")
assert coord.get(planned.command_id).state is ArmState.EXECUTED
def test_expected_nodes_preview(two_node_setup) -> None:
"""§17.5: Vor dem Commit sichtbar, welche Nodes die Szene erhalten."""
coord, _ = two_node_setup
planned = coord.schedule("scene-1", now_show_ns=0, lead_ns=100)
assert coord.expected_nodes(planned) == frozenset({"node-a", "node-b"})
def test_acknowledge_unknown_command_rejected(two_node_setup) -> None:
coord, _ = two_node_setup
with pytest.raises(KeyError):
coord.acknowledge_arm("gibts-nicht", "node-a")
def test_due_ignores_already_handled(two_node_setup) -> None:
"""Erledigte/gescheiterte Aktivierungen werden nicht erneut geliefert."""
coord, _ = two_node_setup
planned = coord.schedule("scene-1", now_show_ns=0, lead_ns=100)
coord.acknowledge_arm(planned.command_id, "node-a")
coord.acknowledge_arm(planned.command_id, "node-b")
coord.due(now_show_ns=1_000_000) # erstmalig fällig → ARMED
second = coord.due(now_show_ns=2_000_000) # erneut aufgerufen: kein Duplikat
assert second == []
def test_due_before_showtime_returns_empty(two_node_setup) -> None:
coord, _ = two_node_setup
coord.schedule("scene-1", now_show_ns=1_000_000_000, lead_ns=200_000_000)
assert coord.due(now_show_ns=1_100_000_000) == [] # Showzeit noch nicht erreicht
@@ -0,0 +1,119 @@
"""Unit-Tests Content-Manifest (PLAN.md §6.4, §10.1, §29.1).
Manifest-Chunks/-Hashes, Diff für Sync, Verifikation gegen Fälschung
und Rundreise Grundlage für Preflight und resumierbare Übertragung.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from hms_content_sync import CHUNK_SIZE, ContentManifest, hash_file
@pytest.fixture()
def content_tree(tmp_path: Path) -> Path:
root = tmp_path / "content"
(root / "clips").mkdir(parents=True)
(root / "clips" / "intro.mp4").write_bytes(b"A" * 1000)
(root / "clips" / "loop.mp4").write_bytes(b"B" * 500)
(root / "presets").mkdir()
(root / "presets" / "look.json").write_text("{}", encoding="utf-8")
return root
def test_hash_file_chunks_match_content(tmp_path: Path) -> None:
f = tmp_path / "f.bin"
f.write_bytes(b"x" * (CHUNK_SIZE + 100)) # > 1 Chunk
file_hash, chunks = hash_file(f)
assert len(chunks) == 2 # voller + Rest-Chunk
assert len(file_hash) == 64 # SHA-256 Hex
def test_hash_file_empty_file_yields_empty_chunk(tmp_path: Path) -> None:
f = tmp_path / "empty.bin"
f.write_bytes(b"")
file_hash, chunks = hash_file(f)
assert len(chunks) == 1
assert file_hash # auch leere Dateien haben einen definierten Hash
def test_manifest_build_covers_all_files(content_tree: Path) -> None:
manifest = ContentManifest.build(content_tree, revision=7)
assert len(manifest) == 3
assert manifest.revision == 7
assert "clips/intro.mp4" in manifest.entries
assert "presets/look.json" in manifest.entries
entry = manifest.entries["clips/intro.mp4"]
assert entry.size_bytes == 1000
assert len(entry.sha256) == 64
assert entry.kind == "media" # Default-Art
def test_manifest_kind_classification(content_tree: Path) -> None:
manifest = ContentManifest.build(
content_tree,
kind_of=lambda rel: "plugin" if rel.endswith(".json") else "media",
)
assert manifest.entries["presets/look.json"].kind == "plugin"
assert manifest.entries["clips/intro.mp4"].kind == "media"
def test_manifest_roundtrip_preserves_entries(content_tree: Path) -> None:
manifest = ContentManifest.build(content_tree, revision=3)
restored = ContentManifest.from_dict(json.loads(json.dumps(manifest.to_dict())))
assert restored.revision == 3
assert set(restored.entries) == set(manifest.entries)
orig = manifest.entries["clips/intro.mp4"]
copy = restored.entries["clips/intro.mp4"]
assert copy.sha256 == orig.sha256
assert copy.chunk_hashes == orig.chunk_hashes
def test_manifest_is_deterministic_across_builds(content_tree: Path) -> None:
"""Gleicher Inhalt → gleiche Einträge (nur manifest_id ist neu)."""
a = ContentManifest.build(content_tree)
b = ContentManifest.build(content_tree)
assert a.to_dict()["entries"] == b.to_dict()["entries"]
assert a.manifest_id != b.manifest_id # Instanz-ID ist zeitlich unique
def test_verify_detects_tampered_file(content_tree: Path) -> None:
manifest = ContentManifest.build(content_tree)
assert manifest.verify_file(content_tree, "clips/intro.mp4")
(content_tree / "clips" / "intro.mp4").write_bytes(b"TAMPERED" * 50)
assert not manifest.verify_file(content_tree, "clips/intro.mp4") # §6.4 Hashprüfung
def test_verify_detects_missing_and_unknown(content_tree: Path) -> None:
manifest = ContentManifest.build(content_tree)
(content_tree / "clips" / "loop.mp4").unlink()
assert not manifest.verify_file(content_tree, "clips/loop.mp4")
assert not manifest.verify_file(content_tree, "gibts/nicht.mp4")
def test_diff_drives_sync_plan(content_tree: Path) -> None:
"""Diff liefert exakt die Übertragungsliste (§6.4: added/removed/changed)."""
old = ContentManifest.build(content_tree, revision=1)
# Ändern + Hinzufügen + Entfernen
(content_tree / "clips" / "intro.mp4").write_bytes(b"GEAENDERT")
(content_tree / "clips" / "neu.mp4").write_bytes(b"N")
(content_tree / "clips" / "loop.mp4").unlink()
new = ContentManifest.build(content_tree, revision=2)
diff = old.diff(new)
assert diff.added == frozenset({"clips/neu.mp4"})
assert diff.removed == frozenset({"clips/loop.mp4"})
assert diff.changed == frozenset({"clips/intro.mp4"})
assert not diff.empty
# identische Manifeste: leerer Diff
same = old.diff(ContentManifest.from_dict(old.to_dict()))
assert same.empty
def test_build_rejects_missing_root(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
ContentManifest.build(tmp_path / "gibts-nicht")
+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
@@ -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,249 @@
"""Unit-Tests Domänenmodell (PLAN.md §10.1, §12, §13.2, §18.1)."""
from __future__ import annotations
import pytest
from hms_domain import (
BlendMode,
Composition,
EffectInstance,
Layer,
LayerType,
MediaAsset,
OutputSurface,
PresetScene,
Project,
Source,
SourceType,
TransitionType,
)
from pydantic import ValidationError
def _media_source(asset_id: str | None = None) -> Source:
return Source(
plugin_id="hms.source.video",
source_type=SourceType.VIDEO,
asset_id=asset_id or "00000000-0000-0000-0000-000000000001",
)
def _generator_source() -> Source:
return Source(
plugin_id="hms.generator.solid",
source_type=SourceType.GENERATOR,
)
def _media_layer(**overrides) -> Layer:
defaults = dict(name="Video A", layer_type=LayerType.MEDIA, source=_media_source())
defaults.update(overrides)
return Layer(**defaults)
# ---------- Source (§10.1, §12.5) ----------
def test_media_source_requires_asset() -> None:
with pytest.raises(ValidationError, match="asset_id"):
Source(plugin_id="hms.source.video", source_type=SourceType.VIDEO)
def test_generator_source_needs_no_asset() -> None:
src = _generator_source()
assert src.asset_id is None
assert src.speed == 1.0
def test_in_point_must_be_before_out_point() -> None:
with pytest.raises(ValidationError, match="in_point"):
Source(
plugin_id="hms.source.video",
source_type=SourceType.VIDEO,
asset_id="00000000-0000-0000-0000-000000000001",
in_point=0.8,
out_point=0.2,
)
def test_speed_bounds_per_plan() -> None:
"""§16.6: bis maximal 4x, negative Geschwindigkeit zugelassen."""
src = _media_source()
assert src.speed == 1.0
with pytest.raises(ValidationError):
Source(
plugin_id="hms.source.video",
source_type=SourceType.VIDEO,
asset_id="00000000-0000-0000-0000-000000000001",
speed=8.0,
)
# ---------- Layer (§10.1, §4.1, §12.3) ----------
def test_media_layer_defaults() -> None:
layer = _media_layer()
assert layer.opacity == 1.0
assert layer.blend_mode is BlendMode.NORMAL
assert layer.enabled is True
assert layer.effects == []
assert layer.transform.scale_x == 1.0
def test_layer_requires_source_for_media_types() -> None:
with pytest.raises(ValidationError, match="Quelle"):
Layer(name="Ohne Quelle", layer_type=LayerType.MEDIA)
def test_adjustment_layer_rejects_source() -> None:
with pytest.raises(ValidationError, match="keine eigene Quelle"):
Layer(
name="Adj",
layer_type=LayerType.ADJUSTMENT,
source=_generator_source(),
)
def test_max_two_effect_slots() -> None:
def fx(i: int) -> EffectInstance:
return EffectInstance(plugin_id="com.hms.fx.x", order_index=i)
with pytest.raises(ValidationError, match="zwei Effekt"):
_media_layer(effects=[fx(0), fx(1), fx(2)])
def test_effect_order_index_unique() -> None:
fx = EffectInstance(plugin_id="com.hms.fx.x", order_index=0)
with pytest.raises(ValidationError, match="eindeutig"):
_media_layer(effects=[fx, fx.model_copy()])
def test_opacity_bounds() -> None:
with pytest.raises(ValidationError):
_media_layer(opacity=1.5)
# ---------- Composition (§10.1) ----------
def test_composition_layer_ids_unique() -> None:
base = _media_layer()
duplicate = base.model_copy()
with pytest.raises(ValidationError, match="eindeutig"):
Composition(name="C", layers=[base, duplicate])
def test_composition_max_64_layers() -> None:
layers = [
_media_layer(name=f"L{i}", source=_media_source()) for i in range(65)
]
with pytest.raises(ValidationError, match="64"):
Composition(name="C", layers=layers)
def test_composition_canvas_bounds() -> None:
comp = Composition(name="HD", width=1920, height=1080, fps=60.0)
assert comp.width == 1920
uhd = Composition(name="UHD", width=3840, height=2160) # Desktop-Zielprofil (§3.3)
assert uhd.width == 3840
with pytest.raises(ValidationError):
Composition(name="Zu groß", width=9000)
def test_layer_by_id_lookup() -> None:
layer = _media_layer()
comp = Composition(name="C", layers=[layer])
assert comp.layer_by_id(layer.id) is layer
assert comp.layer_by_id("unbekannt") is None
# ---------- MediaAsset (§13.2, §9.1) ----------
def test_media_asset_relative_path_required() -> None:
asset = MediaAsset(rel_path="clips/intro.mp4", container="mp4", video_codec="h264")
assert asset.content_hash is None # optional bis Projektpaket (§13.2)
assert asset.seek_suitability == "unknown" # ungeprüft, nie optimistisch
def test_media_asset_rejects_absolute_path() -> None:
with pytest.raises(ValidationError, match="portabel-relativ"):
MediaAsset(rel_path="/absolut/clip.mp4")
def test_media_asset_rejects_traversal() -> None:
with pytest.raises(ValidationError, match="portabel-relativ"):
MediaAsset(rel_path="../outside/clip.mp4")
def test_project_rejects_duplicate_asset_paths() -> None:
a = MediaAsset(rel_path="clips/a.mp4")
b = MediaAsset(rel_path="clips/a.mp4")
with pytest.raises(ValidationError, match="Pfade müssen eindeutig"):
Project(media_assets=[a, b]) # §13.3: Duplikate erkennen
def test_project_rejects_duplicate_asset_ids() -> None:
a = MediaAsset(id="1", rel_path="clips/a.mp4")
b = MediaAsset(id="1", rel_path="clips/b.mp4")
with pytest.raises(ValidationError, match="MediaAsset-IDs"):
Project(media_assets=[a, b])
# ---------- OutputSurface (§10.1, §22.2) ----------
def test_output_surface_defaults_hold_last_frame() -> None:
out = OutputSurface(node_id="00000000-0000-0000-0000-000000000009")
assert out.fallback_policy == "hold_last_frame" # §26.1
assert out.slice_w == 1.0 and out.slice_h == 1.0 # volle Canvas als Default
def test_output_surface_slice_bounds() -> None:
with pytest.raises(ValidationError):
OutputSurface(
node_id="00000000-0000-0000-0000-000000000009", slice_w=1.5
)
# ---------- PresetScene (§18.1, §18.3) ----------
def test_preset_scene_snapshot_and_transition() -> None:
comp = Composition(name="Show", layers=[_media_layer()])
scene = PresetScene(
name="Look 1",
composition_snapshot=comp.model_dump(),
transition_type=TransitionType.CROSSFADE,
transition_duration_s=2.0,
)
assert scene.composition_snapshot["name"] == "Show"
assert scene.transition_type is TransitionType.CROSSFADE
with pytest.raises(ValidationError):
PresetScene(name="X", composition_snapshot={}, transition_duration_s=99.0)
# ---------- Project (§10.1, §24.4) ----------
def test_project_schema_version_and_roundtrip() -> None:
asset = MediaAsset(rel_path="clips/intro.mp4")
comp = Composition(name="Main", layers=[_media_layer()])
project = Project(name="Meine Show", media_assets=[asset], compositions=[comp])
assert project.schema_version == 1
data = project.model_dump()
restored = Project.model_validate(data)
assert restored == project # Persistenz-Roundtrip (SQLite speichert JSON, §24.2)
restored_comp = restored.composition_by_id(comp.id)
restored_asset = restored.asset_by_id(asset.id)
assert restored_comp is not None and restored_comp.id == comp.id
assert restored_asset is not None and restored_asset.rel_path == asset.rel_path
def test_project_updated_at_changes_on_mutation() -> None:
"""Timestamps verwaltet der Control Core explizit, nicht das Modell."""
project = Project(name="P")
before = project.updated_at
project.name = "Neuer Name"
assert project.updated_at == before
@@ -0,0 +1,322 @@
"""Unit-Tests Fixture-Engine: Master32/Layer64, Speed, Universe-Plan
(PLAN.md §16.2–§16.6, §29.1)."""
from __future__ import annotations
import pytest
from hms_artnet.fixtures import (
Layer64Engine,
Master32Engine,
SourceType,
TransportCommand,
UniverseCollisionError,
UniversePlan,
UniverseRange,
map_speed,
)
# ---------- 16-Bit-Dekodierung (MSB zuerst) ----------
def test_speed_mapping_reference_values() -> None:
"""§16.6: Mittelpunkt = Pause (0); untere Hälfte negativ, obere positiv;
definierter Wert 40960 entspricht exakt +1×."""
assert map_speed(32768) == 0.0 # Mittelpunkt = Pause (§16.6)
assert map_speed(0) == pytest.approx(-4.0) # unterer Rand: 4x
assert map_speed(65535) == pytest.approx(4.0, rel=1e-3) # oberer Rand: +4x
assert map_speed(40960) == pytest.approx(1.0) # exakt 1x (definierter Wert)
assert map_speed(16384) == pytest.approx(-2.0) # untere Hälfte: 2x
assert map_speed(49152) == pytest.approx(2.0) # obere Hälfte: +2x
def test_speed_mapping_monotonic_and_centered() -> None:
"""Monotonie je Hälfte; keine Sprünge am Übergang."""
# untere Hälfte steigt monoton von 4x Richtung 0
assert map_speed(0) < map_speed(8192) < map_speed(16384) < map_speed(24576) < 0.0
# obere Hälfte steigt monoton von 0+ Richtung +4x
assert 0.0 <= map_speed(32769) < map_speed(40960) < map_speed(49152) < map_speed(65535)
# Mitte = Pause, kein Sprung zwischen den Hälften
assert map_speed(32767) < 0.0 < map_speed(32769)
# ---------- Master32 (§16.3) ----------
def _master_channels(**overrides) -> bytearray:
"""32 Kanäle, Default neutral; overrides als (kanal_nr_1basiert, wert)."""
ch = bytearray(32)
# neutrale Defaults: Intensität voll (32768), Rest 0
ch[0] = 0x80
ch[1] = 0x00 # 32768/65535 ≈ 0.5 neutral genug für Tests
for k, v in overrides.items():
ch[int(k) - 1] = v
return ch
def test_master32_decodes_16bit_intensity() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"1": 0xFF, "2": 0xFF}))
assert control.master_intensity == pytest.approx(1.0)
def test_master32_blackout_switch() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"3": 255}))
assert control.blackout is True # höchste Priorität (§16.3)
assert engine.decode(_master_channels()).blackout is False
def test_master32_preset_recall_rising_edge_only() -> None:
"""§16.3: Preset Recall ist Flanken-Trigger, kein Dauerzustand."""
engine = Master32Engine()
# erster Frame: Schwelle überschritten → Event
control = engine.decode(_master_channels(**{"8": 255}))
assert [e.kind for e in control.events] == ["preset_recall"]
# gehaltener Wert: kein neues Event
control = engine.decode(_master_channels(**{"8": 255}))
assert control.events == []
# zurück unter Schwelle, dann erneut: neues Event
engine.decode(_master_channels(**{"8": 0}))
control = engine.decode(_master_channels(**{"8": 200}))
assert [e.kind for e in control.events] == ["preset_recall"]
def test_master32_tap_tempo_and_release_edges() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"16": 255, "30": 255}))
kinds = [e.kind for e in control.events]
assert "tap_tempo" in kinds
assert "release_overrides" in kinds
def test_master32_bpm_range_20_to_300() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"14": 0, "15": 0}))
assert control.bpm == pytest.approx(20.0)
control = engine.decode(_master_channels(**{"14": 0xFF, "15": 0xFF}))
assert control.bpm == pytest.approx(300.0)
def test_master32_reserved_channels_ignored_neutrally() -> None:
"""§16.3: Kanäle 17-21, 31-32 müssen neutral ignoriert werden."""
engine = Master32Engine()
control = engine.decode(
_master_channels(**{"17": 255, "18": 255, "19": 255, "31": 255, "32": 255})
)
assert control.events == [] # keine Fehler, keine Events
def test_master32_requires_32_channels() -> None:
with pytest.raises(ValueError, match="32"):
Master32Engine().decode(bytearray(31))
def test_master32_global_hue_range() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"27": 255}))
assert control.global_hue == pytest.approx(0.5)
control = engine.decode(_master_channels(**{"27": 0}))
assert control.global_hue == pytest.approx(-0.5)
# ---------- Layer64 (§16.4) ----------
def _layer_channels(**overrides) -> bytearray:
"""64 Kanäle; overrides als (kanal_nr_1basiert, wert)."""
ch = bytearray(64)
ch[0] = 255 # Layer Enable
ch[1] = 0xFF
ch[2] = 0xFF # Opacity voll
for k, v in overrides.items():
ch[int(k) - 1] = v
return ch
def test_layer64_opacity_16bit() -> None:
engine = Layer64Engine()
control = engine.decode(_layer_channels(**{"2": 0x80, "3": 0x00}))
assert control.opacity == pytest.approx(0x8000 / 65535)
def test_layer64_source_type_enum() -> None:
engine = Layer64Engine()
assert engine.decode(_layer_channels(**{"4": 0})).source_type is SourceType.MEDIA
control = engine.decode(_layer_channels(**{"4": 1}))
assert control.source_type is SourceType.GENERATOR
assert engine.decode(_layer_channels(**{"4": 3})).source_type is SourceType.LIVE
# unbekannter Wert fällt auf MEDIA zurück (§16.4: neutral)
assert engine.decode(_layer_channels(**{"4": 99})).source_type is SourceType.MEDIA
def test_layer64_transport_enum() -> None:
engine = Layer64Engine()
control = engine.decode(_layer_channels(**{"10": 3}))
assert control.transport is TransportCommand.RETRIGGER
assert engine.decode(_layer_channels(**{"10": 1})).transport is TransportCommand.PLAY
def test_layer64_media_mode_speed_position_inout() -> None:
"""Media-Modus: Kanäle 13-20 = Speed/Position/In/Out (§16.4)."""
engine = Layer64Engine()
control = engine.decode(
_layer_channels(
**{
"4": 0, # Media
"13": 0xA0,
"14": 0x00, # Speed = 40960 = exakt 1x (§16.6)
"15": 0x40,
"16": 0x00, # Position ~0.25
"17": 0x10,
"18": 0x00, # In ~0.06
"19": 0xF0,
"20": 0x00, # Out ~0.94
}
)
)
assert control.speed == pytest.approx(1.0) # 40960: definierter 1x-Wert
assert control.position == pytest.approx(0x4000 / 65535)
assert control.generator_params == () # Media-Modus: keine G-Slots
def test_layer64_generator_mode_g_slots() -> None:
"""Generator-Modus: Kanäle 13-20 = G1..G8 (§16.4 modusabhängiger Block)."""
engine = Layer64Engine()
control = engine.decode(
_layer_channels(**{"4": 1, **{str(k): (25 * (k - 12)) & 0xFF for k in range(13, 21)}})
)
assert control.source_type is SourceType.GENERATOR
assert len(control.generator_params) == 8
assert control.generator_params[0] == pytest.approx(25 / 255)
assert control.generator_params[7] == pytest.approx(200 / 255)
assert control.speed == 1.0 # Media-Werte im Generator-Modus neutral
def test_layer64_transform_channels() -> None:
engine = Layer64Engine()
control = engine.decode(
_layer_channels(
**{
"23": 0xFF,
"24": 0xFF, # Position X +1
"25": 0x00,
"26": 0x00, # Position Y -1
"27": 0x40,
"28": 0x00, # Scale X ~1x (16384/65535*4)
"31": 0x80,
"32": 0x00, # Rotation 180°
}
)
)
assert control.position_x == pytest.approx(1.0, abs=1e-4)
assert control.position_y == pytest.approx(-1.0)
assert control.scale_x == pytest.approx(16384 / 65535 * 4)
assert control.rotation_deg == pytest.approx(0x8000 / 65535 * 360)
def test_layer64_fx_blocks() -> None:
engine = Layer64Engine()
control = engine.decode(
_layer_channels(
**{
"41": 255, # FX1 Enable
"42": 7, # FX1 Plugin Select
"43": 128, # FX1 Mix ~0.5
"44": 255, # P1 voll
"52": 255, # FX2 Enable
"55": 64, # FX2 P1
}
)
)
assert control.fx1_enabled is True
assert control.fx1_plugin == 7
assert control.fx1_mix == pytest.approx(128 / 255)
assert control.fx1_params[0] == pytest.approx(1.0)
assert control.fx2_enabled is True
assert control.fx2_params[0] == pytest.approx(64 / 255)
assert len(control.fx1_params) == 8 and len(control.fx2_params) == 8
def test_layer64_reserved_channel_64_neutral() -> None:
"""§16.4: Kanal 64 reserviert, muss neutral ignoriert werden."""
engine = Layer64Engine()
control = engine.decode(_layer_channels(**{"64": 255}))
assert control.events == []
def test_layer64_requires_64_channels() -> None:
with pytest.raises(ValueError, match="64"):
Layer64Engine().decode(bytearray(63))
# ---------- Load/Commit-Semantik (§16.5) ----------
def test_layer64_load_commit_rising_edge() -> None:
"""§16.5: Auswahl erzeugt pending; Flanke auf Kanal 9 lädt."""
engine = Layer64Engine()
# Auswahl ändern (Bank 2, Folder 3, Index 42) noch kein Commit
engine.decode(_layer_channels(**{"5": 2, "6": 3, "7": 0, "8": 42}))
assert engine.selection_pending is True # pending, nicht geladen
# Commit-Flanke: Kanal 9 über Schwelle → load_commit-Event mit Auswahl
control = engine.decode(_layer_channels(**{"5": 2, "6": 3, "7": 0, "8": 42, "9": 255}))
assert engine.selection_pending is False # geladen
assert len(control.events) == 1
event = control.events[0]
assert event.kind == "load_commit"
assert event.pending_bank == 2
assert event.pending_folder == 3
assert event.pending_index == 42
# gehaltener Commit-Kanal: kein weiteres Event
control = engine.decode(_layer_channels(**{"5": 2, "6": 3, "7": 0, "8": 42, "9": 255}))
assert control.events == []
def test_layer64_selection_change_remarks_pending() -> None:
"""Änderung nach Commit → wieder pending; langsamer Fader lädt nichts."""
engine = Layer64Engine()
engine.decode(_layer_channels(**{"5": 1, "9": 255})) # erstes Laden
assert engine.selection_pending is False
# Fader bewegt sich langsam über mehrere Werte → nur pending, kein Load
for index in (10, 50, 100):
engine.decode(_layer_channels(**{"7": index >> 8, "8": index & 0xFF}))
assert engine.selection_pending is True # §16.5: kein dutzendfaches Laden
def test_layer64_retrigger_edge() -> None:
engine = Layer64Engine()
engine.decode(_layer_channels()) # Baseline
control = engine.decode(_layer_channels(**{"63": 255}))
assert [e.kind for e in control.events] == ["retrigger"]
# ---------- Universe-Plan (§16.2) ----------
def test_universe_plan_accepts_disjoint_ranges() -> None:
plan = UniversePlan()
plan.add(UniverseRange(node_id="node-a", first=0, last=3))
plan.add(UniverseRange(node_id="node-b", first=4, last=7))
assert len(plan.ranges()) == 2
def test_universe_plan_rejects_collision() -> None:
"""§16.2: Überschneidung = Blocker, kein still zusammenführen."""
plan = UniversePlan()
plan.add(UniverseRange(node_id="node-a", first=0, last=5))
with pytest.raises(UniverseCollisionError, match="Kollision"):
plan.add(UniverseRange(node_id="node-b", first=4, last=9))
def test_universe_plan_overlap_preflight() -> None:
plan = UniversePlan()
plan.add(UniverseRange(node_id="node-a", first=10, last=20))
assert plan.overlaps(UniverseRange(node_id="x", first=15, last=16)) is True
assert plan.overlaps(UniverseRange(node_id="x", first=21, last=30)) is False
def test_eight_layers_exactly_one_universe() -> None:
"""§16.2: 8 Layer-Fixtures à 64 Kanäle = exakt 512 DMX-Kanäle."""
assert 8 * 64 == 512
@@ -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,89 @@
"""Unit-Tests Medienbibliothek (PLAN.md §13.2, §13.3)."""
from __future__ import annotations
from pathlib import Path
import pytest
from hms_media import ImportStatus, MediaLibrary
@pytest.fixture()
def media_root(tmp_path: Path) -> Path:
root = tmp_path / "media"
(root / "clips").mkdir(parents=True)
(root / "clips" / "intro.mp4").write_bytes(b"video-bytes-1" * 20)
(root / "clips" / "loop.mp4").write_bytes(b"video-bytes-2" * 20)
return root
def test_import_creates_asset_with_hash(media_root: Path) -> None:
lib = MediaLibrary(media_root)
outcome = lib.import_file(media_root / "clips" / "intro.mp4")
assert outcome.status is ImportStatus.IMPORTED
assert outcome.asset is not None
assert outcome.asset.content_hash # §13.2: Hash vorhanden
assert outcome.asset.rel_path == "clips/intro.mp4"
assert len(lib) == 1
def test_duplicate_content_detected(media_root: Path) -> None:
"""Gleicher Inhalt unter anderem Namen = Duplikat (§13.3)."""
lib = MediaLibrary(media_root)
first = lib.import_file(media_root / "clips" / "intro.mp4")
copy = media_root / "clips" / "kopie.mp4"
copy.write_bytes((media_root / "clips" / "intro.mp4").read_bytes())
outcome = lib.import_file(copy)
assert outcome.status is ImportStatus.DUPLICATE
assert outcome.duplicate_of == first.asset.id # verweist auf das Original
assert len(lib) == 1 # kein zweiter Eintrag
def test_import_missing_file_reports_cleanly(media_root: Path) -> None:
lib = MediaLibrary(media_root)
outcome = lib.import_file(media_root / "clips" / "fehlt.mp4")
assert outcome.status is ImportStatus.MISSING_FILE
assert outcome.asset is None
def test_bank_slots_are_explicit_metadata(media_root: Path) -> None:
"""Bank/Index als Show-Metadaten, unabhängig vom Dateinamen (§13.2)."""
lib = MediaLibrary(media_root)
asset = lib.import_file(media_root / "clips" / "intro.mp4").asset
lib.register_bank_slot(asset.id, bank=1, index=5)
found = lib.asset_at(bank=1, index=5)
assert found is not None and found.id == asset.id
assert lib.asset_at(bank=1, index=6) is None
with pytest.raises(KeyError):
lib.register_bank_slot("unbekannt", 1, 1)
def test_missing_detection_and_relink(media_root: Path) -> None:
"""Fehlende Dateien werden markiert; Relink bindet neu (§13.3)."""
lib = MediaLibrary(media_root)
asset = lib.import_file(media_root / "clips" / "loop.mp4").asset
(media_root / "clips" / "loop.mp4").unlink() # Datei verschwindet
assert lib.mark_missing() == [asset.id]
assert asset.id in lib.missing_asset_ids
# Relink auf eine neue Datei mit gleichem Namen
(media_root / "clips" / "loop.mp4").write_bytes(b"neuer-inhalt")
updated = lib.relink(asset.id, media_root / "clips" / "loop.mp4")
assert updated.content_hash != asset.content_hash # neuer Inhalt → neuer Hash
assert asset.id not in lib.missing_asset_ids
def test_relink_rejects_missing_target(media_root: Path) -> None:
lib = MediaLibrary(media_root)
asset = lib.import_file(media_root / "clips" / "loop.mp4").asset
with pytest.raises(FileNotFoundError):
lib.relink(asset.id, media_root / "nirgendwo.mp4")
def test_library_all_and_get(media_root: Path) -> None:
lib = MediaLibrary(media_root)
a = lib.import_file(media_root / "clips" / "intro.mp4").asset
b = lib.import_file(media_root / "clips" / "loop.mp4").asset
assert {x.id for x in lib.all()} == {a.id, b.id}
assert lib.get(a.id).rel_path == "clips/intro.mp4"
assert lib.get("gibts-nicht") is None
@@ -0,0 +1,96 @@
"""Unit-Tests Node-Identität und Rollen (PLAN.md §3.6, §6.3, §10.1)."""
from __future__ import annotations
import uuid
from pathlib import Path
import pytest
from hms_domain import NodeIdentity, NodeRole
def _roles(*r: NodeRole) -> frozenset[NodeRole]:
return frozenset(r)
# ---------- Persistenz (§3.6, §6.3) ----------
def test_load_or_create_persists_across_restart(tmp_path: Path) -> None:
identity_dir = tmp_path / "userdata" / "identity"
first = NodeIdentity.load_or_create(
identity_dir, "Show Server A", _roles(NodeRole.RENDER_NODE, NodeRole.COORDINATOR)
)
second = NodeIdentity.load_or_create(
identity_dir, "Show Server A", _roles(NodeRole.RENDER_NODE, NodeRole.COORDINATOR)
)
assert first.node_id == second.node_id # persistente node_id (§3.6)
uuid.UUID(first.node_id) # gültige UUID
def test_identity_file_location_per_spec(tmp_path: Path) -> None:
"""node_id liegt unter userdata/identity/ (§9-Struktur)."""
identity_dir = tmp_path / "userdata" / "identity"
NodeIdentity.load_or_create(identity_dir, "N", _roles(NodeRole.RENDER_NODE))
assert (identity_dir / "node_id").is_file()
def test_two_nodes_get_distinct_persistent_ids(tmp_path: Path) -> None:
a = NodeIdentity.load_or_create(tmp_path / "a", "Node A", _roles(NodeRole.RENDER_NODE))
b = NodeIdentity.load_or_create(tmp_path / "b", "Node B", _roles(NodeRole.RENDER_NODE))
assert a.node_id != b.node_id # doppelte node_id wäre Fehler (§6.3)
# ---------- Rollen (§6.3) ----------
@pytest.mark.parametrize(
"roles",
[
_roles(NodeRole.RENDER_NODE),
_roles(NodeRole.COORDINATOR),
_roles(NodeRole.CONTROL_DESK),
_roles(NodeRole.RENDER_NODE, NodeRole.COORDINATOR),
_roles(NodeRole.CONTROL_DESK, NodeRole.COORDINATOR),
],
)
def test_valid_role_combinations_accepted(roles) -> None:
identity = NodeIdentity.ephemeral("Test Node", roles)
assert identity.roles == roles
@pytest.mark.parametrize(
"roles",
[
frozenset(), # keine Rolle
_roles(NodeRole.RENDER_NODE, NodeRole.CONTROL_DESK), # Desk + Renderer unplausibel
],
)
def test_invalid_role_combinations_rejected(roles) -> None:
with pytest.raises(ValueError):
NodeIdentity.ephemeral("Test Node", roles)
def test_role_properties() -> None:
render_only = NodeIdentity.ephemeral("R", _roles(NodeRole.RENDER_NODE))
coord_only = NodeIdentity.ephemeral("C", _roles(NodeRole.COORDINATOR))
both = NodeIdentity.ephemeral("B", _roles(NodeRole.RENDER_NODE, NodeRole.COORDINATOR))
assert render_only.renders_locally and not render_only.is_coordinator
assert coord_only.is_coordinator and not coord_only.renders_locally
assert both.is_coordinator and both.renders_locally # §6.3: Coordinator auf Render-Node
def test_display_name_editable_without_identity_change(tmp_path: Path) -> None:
"""Umbenennung ändert die node_id nicht (§10.1: Name ist kein Identitätsteil
für Parameterpfade)."""
identity_dir = tmp_path / "identity"
a = NodeIdentity.load_or_create(identity_dir, "Alter Name", _roles(NodeRole.RENDER_NODE))
b = NodeIdentity.load_or_create(identity_dir, "Neuer Name", _roles(NodeRole.RENDER_NODE))
assert a.node_id == b.node_id
assert a.display_name != b.display_name
def test_ephemeral_distinct_ids() -> None:
a = NodeIdentity.ephemeral("A", _roles(NodeRole.RENDER_NODE))
b = NodeIdentity.ephemeral("B", _roles(NodeRole.RENDER_NODE))
assert a.node_id != b.node_id # Tests dürfen niemals dieselbe ID teilen
@@ -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,413 @@
"""Tests Patch-Verwaltung und DMX-zu-Parameter-Verkabelung
(PLAN.md §16.2, §11, §29.2).
Kette ohne Mocks: DMX-Bytes → Patch → Master32/Layer64-Engines →
ParameterEngine. Prüft Adressvalidierung, Überlappungen, Export,
Blackout-SAFETY, Load/Commit-Events, Signalverlust-Policies.
"""
from __future__ import annotations
import csv
import io
import uuid
import pytest
from hms_artnet import (
DmxToParameterRouter,
DmxUpdate,
FixturePatch,
LossBehavior,
PatchEntry,
PatchError,
)
from hms_parameter.engine import ControlSource, ParameterEngine
def _uuid() -> str:
return str(uuid.uuid4())
# ---------- PatchEntry-Validierung (§16.2) ----------
def test_patch_entry_requires_uuids() -> None:
with pytest.raises(ValueError, match="UUID"):
PatchEntry(
layer_id="keine-uuid",
composition_id=_uuid(),
universe=0,
base_address=1,
layer_number=1,
)
def test_patch_entry_base_address_must_fit_64_channels() -> None:
"""64 Kanäle müssen vollständig ins 512er-Universe passen."""
with pytest.raises(ValueError, match="passt nicht"):
PatchEntry(
layer_id=_uuid(),
composition_id=_uuid(),
universe=0,
base_address=450, # 450+63 > 512
layer_number=1,
)
valid = PatchEntry(
layer_id=_uuid(),
composition_id=_uuid(),
universe=0,
base_address=449, # exakt bis 512
layer_number=1,
)
assert valid.end_address == 512
# ---------- FixturePatch: Überlappungen (§16.2) ----------
def _patch() -> FixturePatch:
return FixturePatch(
node_id=_uuid(),
short_name="HMS Test Node",
master_universe=0,
master_base_address=1,
)
def _layer_entry(universe: int, base: int, number: int) -> PatchEntry:
return PatchEntry(
layer_id=_uuid(),
composition_id=_uuid(),
universe=universe,
base_address=base,
layer_number=number,
)
def test_patch_accepts_eight_layers_one_universe() -> None:
"""§16.2: 8 Layer-Fixtures à 64 Kanäle = exakt 1 Universe."""
patch = FixturePatch(
node_id=_uuid(),
short_name="N",
master_universe=1, # Master separat
master_base_address=1,
)
for i in range(8):
patch.add_layer(_layer_entry(universe=0, base=1 + i * 64, number=i + 1))
assert len(patch.layers()) == 8
assert patch.validate() == []
def test_patch_rejects_overlapping_layers() -> None:
patch = _patch()
patch.add_layer(_layer_entry(universe=0, base=33, number=1))
with pytest.raises(PatchError, match="berlappung"):
patch.add_layer(_layer_entry(universe=0, base=64, number=2)) # 64..127 vs 33..96
def test_patch_allows_same_address_in_different_universes() -> None:
"""Gleiche Adressen in verschiedenen Universen sind erlaubt (§16.2)."""
patch = _patch()
patch.add_layer(_layer_entry(universe=0, base=1, number=1))
patch.add_layer(_layer_entry(universe=1, base=1, number=2))
assert len(patch.layers()) == 2
def test_patch_rejects_duplicate_layer_id() -> None:
patch = _patch()
entry = _layer_entry(universe=0, base=1, number=1)
patch.add_layer(entry)
with pytest.raises(PatchError, match="bereits"):
patch.add_layer(entry)
def test_patch_validate_detects_master_overlap() -> None:
"""validate() meldet Master-Überlappung; Layer-Layer-Konflikt fängt
add_layer selbst (PatchError) hier wird nur Master-Overlap geprüft."""
patch = FixturePatch(
node_id=_uuid(),
short_name="N",
master_universe=0,
master_base_address=1,
)
# Master: 1..32; Layer 1: 33..96 (kein Konflikt untereinander)
patch.add_layer(_layer_entry(universe=0, base=33, number=1))
assert patch.validate() == []
# Master auf 65..96 verschieben: überlappt Layer 1 (33..96)
patch.master_base_address = 65
errors = patch.validate()
assert any("Master" in e for e in errors)
# Master-Adresse zurück auf gültig: keine Fehler mehr
patch.master_base_address = 1
assert patch.validate() == []
def test_patch_validate_detects_invalid_master_address() -> None:
patch = FixturePatch(
node_id=_uuid(),
short_name="N",
master_universe=0,
master_base_address=490, # 490+31 > 512
)
errors = patch.validate()
assert any("Master-Adresse" in e for e in errors)
# ---------- Patch-Export (§16.2) ----------
def test_patch_export_csv_contains_required_fields() -> None:
"""§16.2: Export enthält Node-ID, Short Name, IP, Universe,
Startadresse, Layernummer, Fixture-Version."""
patch = FixturePatch(
node_id="11111111-2222-3333-4444-555555555555",
short_name="HMS Node A",
master_universe=0,
master_base_address=1,
)
patch.add_layer(_layer_entry(universe=0, base=33, number=1))
patch.add_layer(_layer_entry(universe=0, base=97, number=2))
csv_text = patch.export_csv(ip_or_host="10.0.0.9", fixture_version="1.0.0")
rows = list(csv.reader(io.StringIO(csv_text)))
header = rows[0]
assert "node_id" in header
assert "universe" in header
assert "start_address" in header
assert "layer_number" in header
assert "fixture_version" in header
master_row = rows[1]
assert master_row[0] == "11111111-2222-3333-4444-555555555555"
assert master_row[1] == "HMS Node A"
assert master_row[2] == "10.0.0.9"
assert "Master 32ch" in master_row[3]
assert master_row[8] == "1.0.0"
layer_rows = rows[2:]
assert len(layer_rows) == 2
assert "Layer 64ch" in layer_rows[0][3]
assert layer_rows[0][7] == "1"
assert layer_rows[1][7] == "2"
def test_patch_export_sorted_by_universe_and_address() -> None:
patch = _patch()
patch.add_layer(_layer_entry(universe=1, base=1, number=2))
patch.add_layer(_layer_entry(universe=0, base=65, number=1))
csv_text = patch.export_csv()
rows = list(csv.reader(io.StringIO(csv_text)))
# Master zuerst (Universe 0), dann Layer nach Universe/Adresse sortiert
layer_rows = rows[2:]
assert int(layer_rows[0][4]) < int(layer_rows[1][4]) # Universum aufsteigend
# ---------- DmxToParameterRouter: Kette ohne Mocks (§11, §29.2) ----------
def _dmx_update(universe: int, data: bytes) -> DmxUpdate:
return DmxUpdate(
universe=universe,
data=data,
sender_ip="192.168.1.100",
received_ns=0,
sequence=1,
)
def _layer_patch_with_two_layers() -> tuple[FixturePatch, str, str, str, str]:
"""Patch mit Master (Uni 0) und 2 Layern (Uni 0, Adressen 33 und 97)."""
comp_id = _uuid()
layer1_id, layer2_id = _uuid(), _uuid()
patch = FixturePatch(
node_id=_uuid(),
short_name="HMS Router Test",
master_universe=0,
master_base_address=1,
)
patch.add_layer(
PatchEntry(
layer_id=layer1_id,
composition_id=comp_id,
universe=0,
base_address=33,
layer_number=1,
)
)
patch.add_layer(
PatchEntry(
layer_id=layer2_id,
composition_id=comp_id,
universe=0,
base_address=97,
layer_number=2,
)
)
return patch, comp_id, layer1_id, layer2_id, ""
def _full_universe_with_master_and_layers(
master: dict[int, int],
layer1: dict[int, int],
layer2: dict[int, int],
layer1_offset: int = 32, # 0-basiert: Basisadresse 33 - 1
layer2_offset: int = 96, # 0-basiert: Basisadresse 97 - 1
) -> bytes:
"""512 Kanäle; dicts sind (kanal_nr_1basiert_relativ, wert);
Offsets sind 0-basiert (Basisadresse-1)."""
data = bytearray(512)
for k, v in master.items():
data[int(k) - 1] = v
for k, v in layer1.items():
data[layer1_offset + int(k) - 1] = v
for k, v in layer2.items():
data[layer2_offset + int(k) - 1] = v
return bytes(data)
def test_router_full_chain_master_and_layers() -> None:
"""Kette: DMX → Patch → Engines → ParameterEngine; Werte landen korrekt."""
patch, comp, l1, l2, _ = _layer_patch_with_two_layers()
engine = ParameterEngine()
router = DmxToParameterRouter(patch, engine)
data = _full_universe_with_master_and_layers(
master={"1": 0x40, "2": 0x00, "3": 0, "14": 60, "15": 0}, # Intensität ~0.25, BPM
layer1={
"1": 255, # Enable
"2": 0xFF, "3": 0xFF, # Opacity 1.0
"4": 0, # Media
"10": 1, # Play
"13": 0xA0, "14": 0x00, # Speed 1x
"41": 255, # FX1 an
"43": 128, # FX1 Mix
},
layer2={"1": 0, "2": 0x80, "3": 0x00}, # disabled, Opacity 50%
)
router.handle_update(_dmx_update(0, data))
base1 = f"composition/{comp}/layer/{l1}"
base2 = f"composition/{comp}/layer/{l2}"
# Master-Werte
assert engine.effective_value("master/intensity") == pytest.approx(0x4000 / 65535)
assert engine.current_source("master/intensity") is ControlSource.CONSOLE
# Layer 1 aktiv
assert engine.effective_value(f"{base1}/enabled") == pytest.approx(1.0)
assert engine.effective_value(f"{base1}/opacity") == pytest.approx(1.0)
assert engine.effective_value(f"{base1}/source/speed") == pytest.approx(1.0)
assert engine.effective_value(f"{base1}/fx1/enabled") == pytest.approx(1.0)
assert engine.effective_value(f"{base1}/fx1/mix") == pytest.approx(128 / 255)
# Layer 2 disabled
assert engine.effective_value(f"{base2}/enabled") == pytest.approx(0.0)
assert engine.effective_value(f"{base2}/opacity") == pytest.approx(0x8000 / 65535)
# Telemetrie
assert router.stats.updates_processed == 1
assert router.stats.master_updates == 1
assert router.stats.layer_updates == 2
def test_router_blackout_safety_priority() -> None:
"""§11.2/§16.3: Blackout überstimmt alles mit SAFETY; Release beim Aufheben."""
patch, comp, l1, _, _ = _layer_patch_with_two_layers()
engine = ParameterEngine()
router = DmxToParameterRouter(patch, engine)
# Web setzt Intensität hoch (niedrigere Priorität)
engine.set_value("master/intensity", 0.9, ControlSource.WEB)
# Blackout aktivieren (Kanal 3 = 255)
data = _full_universe_with_master_and_layers(
master={"3": 255}, layer1={}, layer2={}
)
router.handle_update(_dmx_update(0, data))
assert engine.effective_value("master/blackout") == pytest.approx(1.0)
assert engine.current_source("master/blackout") is ControlSource.SAFETY
assert router.stats.blackouts == 1
# Blackout aufheben (Kanal 3 = 0)
data = _full_universe_with_master_and_layers(
master={"3": 0}, layer1={}, layer2={}
)
router.handle_update(_dmx_update(0, data))
# SAFETY-Override ist released; Web-Wert wirkt wieder
snap = engine.snapshot()
assert "master/blackout" not in snap
def test_router_load_commit_produces_pending_load() -> None:
"""§16.5: Flanke auf Kanal 9 erzeugt load_commit; pending_load für Renderer."""
patch, comp, l1, _, _ = _layer_patch_with_two_layers()
engine = ParameterEngine()
router = DmxToParameterRouter(patch, engine)
# Frame 1: Auswahl setzen, kein Commit
data = _full_universe_with_master_and_layers(
master={}, layer1={"5": 2, "6": 3, "9": 0}, layer2={}
)
router.handle_update(_dmx_update(0, data))
assert router.pending_load_for(l1) is None # kein Commit
# Frame 2: Commit-Flanke
data = _full_universe_with_master_and_layers(
master={}, layer1={"5": 2, "6": 3, "9": 255}, layer2={}
)
router.handle_update(_dmx_update(0, data))
pending = router.pending_load_for(l1)
assert pending is not None
assert pending["bank"] == 2
assert pending["folder"] == 3
assert pending["universe"] == 0
assert router.stats.load_commits == 1
# Verbrauch entfernt den Eintrag
assert router.pending_load_for(l1) is None
def test_router_signal_loss_hold_keeps_state() -> None:
"""§11.3 HOLD: Signalverlust ändert nichts."""
patch, _, _, _, _ = _layer_patch_with_two_layers()
engine = ParameterEngine()
router = DmxToParameterRouter(patch, engine, LossBehavior.HOLD)
engine.set_value("master/intensity", 0.8, ControlSource.CONSOLE)
loss = DmxUpdate(
universe=0, data=b"", sender_ip="x", received_ns=1, sequence=-1
)
router.handle_update(loss)
assert engine.effective_value("master/intensity") == pytest.approx(0.8)
def test_router_signal_loss_fade_to_black_safety() -> None:
"""§11.3 fade_to_black: Master-Universe-Ausfall setzt SAFETY-Override."""
patch, _, _, _, _ = _layer_patch_with_two_layers()
engine = ParameterEngine()
router = DmxToParameterRouter(patch, engine, LossBehavior.FADE_TO_BLACK)
engine.set_value("master/intensity", 0.8, ControlSource.CONSOLE)
loss = DmxUpdate(
universe=0, data=b"", sender_ip="x", received_ns=1, sequence=-1
)
router.handle_update(loss)
assert engine.effective_value("master/intensity") == pytest.approx(0.0)
assert engine.current_source("master/intensity") is ControlSource.SAFETY
def test_router_ignores_foreign_universe() -> None:
"""Updates für nicht gepatchte Universen ändern nichts."""
patch, comp, l1, _, _ = _layer_patch_with_two_layers()
engine = ParameterEngine()
router = DmxToParameterRouter(patch, engine)
router.handle_update(_dmx_update(99, bytes(512)))
snap = engine.snapshot()
assert f"composition/{comp}/layer/{l1}/enabled" not in snap
assert router.stats.master_updates == 0
assert router.stats.layer_updates == 0
def test_router_short_universe_data_skipped_silently() -> None:
"""Unvollständige Universen werden still ausgelassen (kein Fehler)."""
patch, _, _, _, _ = _layer_patch_with_two_layers()
engine = ParameterEngine()
router = DmxToParameterRouter(patch, engine)
router.handle_update(_dmx_update(0, bytes(16))) # nur 16 Kanäle
assert router.stats.master_updates == 0 # nichts dekodiert
+249
View File
@@ -0,0 +1,249 @@
"""Unit-Tests SQLite-Persistenz (PLAN.md §24).
Deckung von §29.1 (Schema- und Projektmigrationen) und §24.1/§24.4:
WAL, Foreign Keys, kurze Transaktionen, Backup vor Migration,
Integritätscheck, Projekt-Roundtrip, Plugin-Status-Zyklus.
"""
from __future__ import annotations
import sqlite3
import time
import uuid
from pathlib import Path
import pytest
from hms_persistence import SCHEMA_VERSION, Database
@pytest.fixture()
def db(tmp_path: Path) -> Database:
database = Database(tmp_path / "userdata" / "database" / "hms.db")
database.open()
yield database
database.close()
def _project(name: str = "Test Show", version: int = 1) -> dict:
return {
"id": str(uuid.uuid4()),
"name": name,
"schema_version": version,
"created_at": "2026-09-11T00:00:00+00:00",
"updated_at": "2026-09-11T00:00:00+00:00",
"compositions": [],
}
# ---------- Öffnen & PRAGMA (§24.1) ----------
def test_open_sets_wal_mode(db: Database) -> None:
mode = db.connection.execute("PRAGMA journal_mode").fetchone()
assert mode and mode[0].lower() == "wal"
def test_open_enables_foreign_keys(db: Database) -> None:
fk = db.connection.execute("PRAGMA foreign_keys").fetchone()
assert fk and fk[0] == 1
def test_open_creates_parent_directories(tmp_path: Path) -> None:
deep = tmp_path / "userdata" / "database"
database = Database(deep / "hms.db")
database.open()
database.close()
assert (deep / "hms.db").is_file()
def test_integrity_check_failure_raises(tmp_path: Path) -> None:
"""Beschädigte Datei darf nicht still geöffnet werden (§24.1)."""
bad = tmp_path / "corrupt.db"
bad.write_bytes(b"this is definitely not a sqlite database" * 10)
database = Database(bad)
with pytest.raises(sqlite3.DatabaseError):
database.open()
# ---------- Migrationen (§24.4) ----------
def test_migrate_from_empty_sets_schema_version(db: Database) -> None:
result = db.migrate()
assert result.from_version == 0
assert result.to_version == SCHEMA_VERSION
assert result.integrity_ok
assert db.schema_version == SCHEMA_VERSION
def test_migrate_is_idempotent(db: Database) -> None:
db.migrate()
second = db.migrate()
assert second.from_version == SCHEMA_VERSION
assert second.backup_path is None # kein erneutes Backup ohne Änderung
def test_migrate_creates_backup_of_existing_data(tmp_path: Path) -> None:
db_path = tmp_path / "hms.db"
database = Database(db_path)
database.open()
database.migrate()
proj = _project("Altdaten")
database.save_project(proj)
database.close()
# zweites Öffnen: Migration bereits aktuell → kein Backup nötig
database2 = Database(db_path)
database2.open(integrity_check=False) # Backup-Szenario simulieren
result = database2.migrate(backup_dir=tmp_path / "backups")
assert result.backup_path is None # Version identisch
database2.close()
# Backup-Pfad existsiert nur bei echter Migration; hier simulieren wir
# eine Vorwärtsmigration über eine Versionserhöhung nicht (SCHEMA_VERSION
# ist 1) stattdessen prüfen wir, dass Altdaten nach Reopen lesbar bleiben.
database3 = Database(db_path)
database3.open()
loaded = database3.load_project(proj["id"])
assert loaded is not None and loaded["name"] == "Altdaten" # §24.4: Test mit Altdaten
database3.close()
def test_migration_backup_before_change(monkeypatch, tmp_path: Path) -> None:
"""Backup wird vor Schemaänderung angelegt, wenn Migration läuft."""
import hms_persistence as persistence
db_path = tmp_path / "hms.db"
database = Database(db_path)
database.open()
# Schema v1 anlegen und befüllen
database.migrate()
proj = _project("Vor-Backup")
database.save_project(proj)
# Simulation: eine neue Version existiert → Migration greift
monkeypatch.setattr(persistence, "SCHEMA_VERSION", 2)
monkeypatch.setitem(
persistence._MIGRATIONS,
2,
"CREATE TABLE IF NOT EXISTS future_table (id TEXT PRIMARY KEY);",
)
backups = tmp_path / "backups"
result = database.migrate(backup_dir=backups)
assert result.backup_path is not None and result.backup_path.is_file()
assert result.to_version == 2
# Altdaten weiterhin vorhanden (Vorwärtsmigration verliert nichts)
loaded = database.load_project(proj["id"])
assert loaded is not None and loaded["name"] == "Vor-Backup"
database.close()
# ---------- Projekte (§24.2) ----------
def test_project_save_load_roundtrip(db: Database) -> None:
db.migrate()
proj = _project("Meine Show")
proj["compositions"] = [{"id": str(uuid.uuid4()), "layers": []}]
db.save_project(proj)
loaded = db.load_project(proj["id"])
assert loaded == proj
def test_project_list_sorted_by_update(db: Database) -> None:
db.migrate()
older = _project("Alt")
newer = _project("Neu")
newer["updated_at"] = "2026-09-11T12:00:00+00:00"
db.save_project(older)
db.save_project(newer)
names = [p["name"] for p in db.list_projects()]
assert names[0] == "Neu" # jüngstes zuerst
def test_project_delete(db: Database) -> None:
db.migrate()
proj = _project()
db.save_project(proj)
db.delete_project(proj["id"])
assert db.load_project(proj["id"]) is None
def test_project_save_validates_required_fields(db: Database) -> None:
db.migrate()
with pytest.raises(ValueError, match="missing field"):
db.save_project({"id": "x"})
def test_project_save_overwrites_same_id(db: Database) -> None:
db.migrate()
proj = _project("Version A")
db.save_project(proj)
proj["name"] = "Version B"
proj["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
db.save_project(proj)
loaded = db.load_project(proj["id"])
assert loaded is not None and loaded["name"] == "Version B"
assert len(db.list_projects()) == 1
# ---------- Einstellungen ----------
def test_settings_roundtrip_and_default(db: Database) -> None:
db.migrate()
assert db.get_setting("artnet.bind") is None
assert db.get_setting("artnet.bind", "0.0.0.0") == "0.0.0.0"
db.set_setting("artnet.bind", "127.0.0.1")
db.set_setting("artnet.bind", "10.0.0.5") # Überschreiben
assert db.get_setting("artnet.bind") == "10.0.0.5"
# ---------- Plugin-Status (§14.5) ----------
def test_plugin_status_lifecycle(db: Database) -> None:
db.migrate()
db.upsert_plugin_status(
"com.hms.fx.example_passthrough", "1.0.0", enabled=False, state="discovered"
)
db.upsert_plugin_status(
"com.hms.fx.example_passthrough", "1.0.0", enabled=True, state="active"
)
status = db.get_plugin_status()
entry = status["com.hms.fx.example_passthrough"]
assert entry["enabled"] is True
assert entry["state"] == "active"
def test_plugin_status_rejects_invalid_state(db: Database) -> None:
db.migrate()
with pytest.raises(ValueError, match="invalid plugin state"):
db.upsert_plugin_status("com.x.y", "1.0.0", enabled=True, state="exploded")
def test_plugin_status_persists_across_reopen(tmp_path: Path) -> None:
db_path = tmp_path / "hms.db"
database = Database(db_path)
database.open()
database.migrate()
database.upsert_plugin_status(
"com.hms.fx.gaussian_blur", "1.0.0", enabled=True, state="compiled",
package_hash="deadbeef",
)
database.close()
database2 = Database(db_path)
database2.open()
entry = database2.get_plugin_status()["com.hms.fx.gaussian_blur"]
assert entry["package_hash"] == "deadbeef"
assert entry["state"] == "compiled"
database2.close()
# ---------- Verbindungsschutz ----------
def test_connection_property_requires_open(tmp_path: Path) -> None:
database = Database(tmp_path / "hms.db")
with pytest.raises(RuntimeError, match="not opened"):
_ = database.connection
+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)
+203
View File
@@ -0,0 +1,203 @@
"""Unit-Tests Playback-Transport (PLAN.md §12.2, §12.5, §16.6)."""
from __future__ import annotations
import pytest
from hms_domain import LoopMode, TransportState
from hms_media import PlaybackController, PlaybackEventKind, PreloadSlot
def _controller(**overrides) -> PlaybackController:
defaults = dict(
source_id="src-1",
in_point=0.0,
out_point=1.0,
loop_mode=LoopMode.LOOP,
speed=1.0,
)
defaults.update(overrides)
return PlaybackController(**defaults)
# ---------- Konstruktion und Invarianten ----------
def test_rejects_invalid_in_out_points() -> None:
with pytest.raises(ValueError, match="in_point"):
_controller(in_point=0.9, out_point=0.1)
with pytest.raises(ValueError, match="in_point"):
_controller(in_point=0.5, out_point=0.5)
def test_initial_state_stopped_at_in_point() -> None:
c = _controller(in_point=0.2)
assert c.state is TransportState.STOPPED
assert c.position == pytest.approx(0.2)
# ---------- Transportbefehle (§12.5) ----------
def test_play_pause_resume_cycle() -> None:
c = _controller()
c.play()
c.advance(0.1)
pos_after_play = c.position
c.pause()
assert c.state is TransportState.PAUSED
events = c.advance(1.0) # pausiert: keine Bewegung
assert events == []
assert c.position == pytest.approx(pos_after_play)
c.play()
c.advance(0.1)
assert c.position > pos_after_play
def test_stop_resets_position_and_direction() -> None:
c = _controller(in_point=0.1)
c.play()
c.advance(0.3)
c.stop()
assert c.state is TransportState.STOPPED
assert c.position == pytest.approx(0.1)
assert c.direction == 1
def test_retrigger_restarts_from_in_point() -> None:
c = _controller()
c.play()
c.advance(0.4)
c.retrigger() # §16.5: flankenbasierter Neustart
assert c.position == pytest.approx(0.0)
assert c.state is TransportState.PLAYING
def test_seek_bounds_enforced() -> None:
c = _controller(in_point=0.2, out_point=0.8)
c.seek(0.5)
assert c.position == pytest.approx(0.5)
with pytest.raises(ValueError, match="In/Out"):
c.seek(0.9)
with pytest.raises(ValueError, match="In/Out"):
c.seek(0.1)
def test_speed_zero_rejected_pause_is_the_way() -> None:
c = _controller()
with pytest.raises(ValueError, match="speed 0"):
c.set_speed(0.0)
def test_negative_speed_plays_backward() -> None:
"""§16.6: negative Geschwindigkeit zugelassen; Rückwärts am In-Point
wrappt im Loop-Modus korrekt ans Ende des Fensters (§12.5)."""
c = _controller()
c.play()
c.set_speed(-1.0)
events = c.advance(0.05)
assert c.position == pytest.approx(0.95) # Wrap nach hinten: out - 0.05
assert events and events[0].kind is PlaybackEventKind.LOOP_WRAP
# danach läuft es weiter rückwärts Richtung In-Point
c.advance(0.05)
assert c.position == pytest.approx(0.9)
# ---------- Loop-Verhalten ----------
def test_loop_wraps_with_event() -> None:
c = _controller(out_point=1.0)
c.play()
events = c.advance(1.25) # über das Ende hinaus
assert c.position == pytest.approx(0.25) # Wrap: Überschuss am Anfang
assert events and events[0].kind is PlaybackEventKind.LOOP_WRAP
assert c.state is TransportState.PLAYING # Loop läuft weiter
def test_once_stops_at_out_point_with_end_event() -> None:
c = _controller(loop_mode=LoopMode.ONCE)
c.play()
events = c.advance(0.5)
assert events == [] # noch nicht am Ende
events = c.advance(0.6) # über das Ende hinaus
assert c.state is TransportState.STOPPED # §12.5: Ende-Ereignis
assert c.position == pytest.approx(1.0)
assert events and events[0].kind is PlaybackEventKind.END_OF_MEDIA
# weiteres Advancement bleibt gestoppt
assert c.advance(0.1) == []
def test_ping_pong_reverses_direction() -> None:
c = _controller(loop_mode=LoopMode.PING_PONG)
c.play()
events = c.advance(1.2) # Ende erreicht → Richtungsumkehr
assert c.direction == -1
assert c.position == pytest.approx(0.8) # 0.2 zurückgeprallt
assert events and events[0].kind is PlaybackEventKind.DIRECTION_CHANGE
events = c.advance(1.6) # zurück zum Anfang → wieder vorwärts
assert c.direction == 1
assert events and events[0].kind is PlaybackEventKind.DIRECTION_CHANGE
assert c.state is TransportState.PLAYING # Ping-Pong läuft endlos
def test_in_out_window_respected() -> None:
"""Loop beachtet In/Out-Fenster, nicht nur 0..1 (§12.5)."""
c = _controller(in_point=0.2, out_point=0.8)
c.play()
c.advance(0.7) # 0.2 + 0.7 = 0.9 > out 0.8
assert c.position == pytest.approx(0.3) # Wrap innerhalb des Fensters
def test_advance_rejects_negative_dt() -> None:
c = _controller()
c.play()
with pytest.raises(ValueError, match="negativ"):
c.advance(-0.1)
def test_events_carry_source_and_monotonic_time() -> None:
c = _controller()
c.play()
events = c.advance(1.5, now_ns=123456789)
assert events[0].source_id == "src-1"
assert events[0].monotonic_ns == 123456789
# ---------- PreloadSlot: atomarer Clipwechsel (§12.2, §16.5) ----------
def test_preload_commit_atomic_flow() -> None:
slot = PreloadSlot()
assert slot.commit() is None # nichts geladen → kein Wechsel
slot.preload("asset-a")
assert slot.pending == "asset-a"
assert slot.commit() is None # noch nicht bereit → alter Clip bleibt
slot.mark_ready("asset-a")
assert slot.commit() == "asset-a" # atomar umgeschaltet
assert slot.pending is None
assert slot.commit() is None # Slot ist wieder leer
def test_preload_cancel_discards() -> None:
slot = PreloadSlot()
slot.preload("asset-a")
slot.mark_ready("asset-a")
slot.cancel()
assert slot.pending is None
assert slot.commit() is None # abgebrochen wird nie committed
def test_preload_rejects_foreign_ready() -> None:
slot = PreloadSlot()
slot.preload("asset-a")
with pytest.raises(ValueError, match="anderes Asset"):
slot.mark_ready("asset-b")
def test_preload_new_preload_resets_ready() -> None:
slot = PreloadSlot()
slot.preload("asset-a")
slot.mark_ready("asset-a")
slot.preload("asset-b") # umgeladen: a wird verworfen
assert slot.ready is False
assert slot.commit() is None
@@ -0,0 +1,228 @@
"""Unit-Tests Plugin-Lifecycle (PLAN.md §14.5, §14.6, §26.3, §29.2)."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from hms_plugin_sdk import (
InvalidTransitionError,
LifecycleState,
PluginLifecycleManager,
)
REPO = Path(__file__).resolve().parents[2]
EXAMPLES = REPO / "plugins" / "examples"
def _valid_plugin_dir(tmp_path: Path) -> Path:
"""Kopiert das gültige Passthrough-Beispiel in ein Test-Verzeichnis."""
target = tmp_path / "plugins"
target.mkdir()
for item in EXAMPLES.rglob("*"):
if item.is_file():
rel = item.relative_to(EXAMPLES)
dest = target / rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(item.read_bytes())
return target
# ---------- Discovery & Validierung (§14.5, §29.2) ----------
def test_discover_validates_example_plugins(tmp_path: Path) -> None:
"""Externe Beispielplugins werden ohne Kernänderung entdeckt und
validiert (§29.2 / Gate 3 Vorbereitung)."""
plugin_dir = _valid_plugin_dir(tmp_path)
mgr = PluginLifecycleManager()
errors = mgr.discover(plugin_dir)
assert errors == []
by_id = {r.plugin_id: r for r in mgr.all()}
assert "com.hms.fx.example_passthrough" in by_id
assert "com.hms.fx.gaussian_blur" in by_id
assert all(r.state is LifecycleState.VALIDATED for r in by_id.values())
def test_discover_quarantines_invalid_manifest(tmp_path: Path) -> None:
"""Ungültiges Manifest → QUARANTINED mit dokumentierter Ursache (§3.5)."""
plugin_dir = _valid_plugin_dir(tmp_path)
broken = plugin_dir / "com.broken.plugin"
broken.mkdir()
(broken / "plugin.json").write_text(
json.dumps({"schema_version": 99, "id": "com.broken.plugin"}),
encoding="utf-8",
)
mgr = PluginLifecycleManager()
errors = mgr.discover(plugin_dir)
record = mgr.get("com.broken.plugin")
assert record is not None
assert record.state is LifecycleState.QUARANTINED
assert record.last_error # Ursache sichtbar, nicht geschluckt (§33)
assert any("com.broken.plugin" in e for e in errors)
# die gültigen Plugins bleiben unbeeinflusst
assert mgr.get("com.hms.fx.example_passthrough").state is LifecycleState.VALIDATED
def test_discover_missing_dir_reports_error(tmp_path: Path) -> None:
mgr = PluginLifecycleManager()
errors = mgr.discover(tmp_path / "gibts-nicht")
assert errors and "fehlt" in errors[0]
# ---------- Übergänge (§14.5) ----------
@pytest.fixture()
def manager_with_plugin(tmp_path: Path) -> PluginLifecycleManager:
mgr = PluginLifecycleManager()
mgr.discover(_valid_plugin_dir(tmp_path))
return mgr
def _advance_to(
mgr: PluginLifecycleManager, pid: str, *states: LifecycleState
) -> None:
"""Führt ein Plugin schrittweise durch die angegebenen Zustände."""
for state in states:
mgr.advance(pid, state)
_ACTIVE_CHAIN = (
LifecycleState.INSTALLED,
LifecycleState.ENABLED,
LifecycleState.COMPILED,
LifecycleState.ACTIVE,
)
def test_full_lifecycle_happy_path(manager_with_plugin: PluginLifecycleManager) -> None:
"""discovered→validated→installed→enabled→compiled→active (§14.5)."""
mgr = manager_with_plugin
pid = "com.hms.fx.example_passthrough"
_advance_to(mgr, pid, *_ACTIVE_CHAIN)
record = mgr.get(pid)
assert record.state is LifecycleState.ACTIVE
assert record.last_error is None
assert mgr.active_plugins() == [record]
def test_skip_transition_rejected(manager_with_plugin: PluginLifecycleManager) -> None:
"""Sprünge im Graph sind Fehler: validated → active illegal."""
mgr = manager_with_plugin
with pytest.raises(InvalidTransitionError, match="nicht erlaubt"):
mgr.advance("com.hms.fx.example_passthrough", LifecycleState.ACTIVE)
def test_backward_transition_rejected(manager_with_plugin: PluginLifecycleManager) -> None:
"""Rücksprünge sind ebenfalls illegal (active → enabled)."""
mgr = manager_with_plugin
pid = "com.hms.fx.example_passthrough"
_advance_to(mgr, pid, *_ACTIVE_CHAIN)
with pytest.raises(InvalidTransitionError):
mgr.advance(pid, LifecycleState.ENABLED)
def test_disable_and_reenable_cycle(manager_with_plugin: PluginLifecycleManager) -> None:
"""active → disabled → enabled: Live-Betrieb bleibt steuerbar (§17.7)."""
mgr = manager_with_plugin
pid = "com.hms.fx.example_passthrough"
_advance_to(mgr, pid, *_ACTIVE_CHAIN)
mgr.advance(pid, LifecycleState.DISABLED)
mgr.advance(pid, LifecycleState.ENABLED) # Re-Enable: Rücksprung in Betrieb
assert mgr.get(pid).state is LifecycleState.ENABLED
def test_unknown_plugin_rejected() -> None:
mgr = PluginLifecycleManager()
with pytest.raises(KeyError):
mgr.advance("gibts-nicht", LifecycleState.INSTALLED)
def test_quarantine_from_active_documents_reason(
manager_with_plugin: PluginLifecycleManager,
) -> None:
"""Fehlerfall im Betrieb: Quarantäne mit Ursache, Projekt bleibt
nutzbar (§3.5, §12.2 Bypass)."""
mgr = manager_with_plugin
pid = "com.hms.fx.gaussian_blur"
_advance_to(mgr, pid, *_ACTIVE_CHAIN)
mgr.quarantine(pid, "shader compile failed")
record = mgr.get(pid)
assert record.state is LifecycleState.QUARANTINED
assert record.last_error == "shader compile failed"
assert mgr.by_state(LifecycleState.QUARANTINED) == [record]
# quarantined hat keine Folgezustände: Neustart nur über Neuinstallation
with pytest.raises(InvalidTransitionError):
mgr.advance(pid, LifecycleState.ENABLED)
# ---------- Show-Lock (§26.3) ----------
def test_show_lock_blocks_installation(manager_with_plugin: PluginLifecycleManager) -> None:
"""§26.3: Plugininstallation im Show-Lock gesperrt."""
mgr = manager_with_plugin
mgr.set_show_lock(True)
with pytest.raises(InvalidTransitionError, match="Show-Lock"):
mgr.advance("com.hms.fx.example_passthrough", LifecycleState.INSTALLED)
def test_show_lock_allows_enable_of_installed(manager_with_plugin: PluginLifecycleManager) -> None:
"""§17.7/§26.3: Bereits installiertes Plugin darf im Live-Betrieb
aktiviert werden (Layerparameter und Livefunktionen)."""
mgr = manager_with_plugin
pid = "com.hms.fx.example_passthrough"
mgr.advance(pid, LifecycleState.INSTALLED)
mgr.set_show_lock(True)
_advance_to(
mgr,
pid,
LifecycleState.ENABLED,
LifecycleState.COMPILED,
LifecycleState.ACTIVE,
)
assert mgr.get(pid).state is LifecycleState.ACTIVE
def test_show_lock_blocks_version_update(tmp_path: Path) -> None:
"""§26.3: Versionswechsel (= Update) im Show-Lock gesperrt."""
plugin_dir = _valid_plugin_dir(tmp_path)
mgr = PluginLifecycleManager()
mgr.discover(plugin_dir)
mgr.set_show_lock(True)
# gleiche Plugin-ID mit neuer Version einschleusen
manifest_path = plugin_dir / "com.hms.fx.example_passthrough" / "plugin.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["version"] = "2.0.0"
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with pytest.raises(InvalidTransitionError, match="Versionswechsel"):
mgr.discover(plugin_dir)
def test_version_update_without_lock_starts_new_cycle(tmp_path: Path) -> None:
"""Update ohne Show-Lock: Plugin startet einen neuen Zyklus in DISCOVERED."""
plugin_dir = _valid_plugin_dir(tmp_path)
mgr = PluginLifecycleManager()
mgr.discover(plugin_dir)
mgr.advance("com.hms.fx.example_passthrough", LifecycleState.INSTALLED)
manifest_path = plugin_dir / "com.hms.fx.example_passthrough" / "plugin.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["version"] = "2.0.0"
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
mgr.discover(plugin_dir) # Re-Scan erkennt neue Version
record = mgr.get("com.hms.fx.example_passthrough")
assert record.version == "2.0.0"
assert record.state is LifecycleState.VALIDATED # neuer Zyklus
# ---------- Backend-Kompatibilität (§14.5, §12.6) ----------
def test_compatible_backends_intersection(manager_with_plugin: PluginLifecycleManager) -> None:
"""Nur Backends mit Entrypoint UND supported_backends gelten (§12.6)."""
mgr = manager_with_plugin
backends = mgr.compatible_backends("com.hms.fx.example_passthrough")
assert backends == frozenset({"d3d11", "gl", "gles"})
assert mgr.compatible_backends("gibts-nicht") == frozenset()
@@ -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))
+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
@@ -0,0 +1,205 @@
"""Tests Renderer-Engine: Sync→Advance→Snapshot-Kette (§12, §11.4, §29.2)."""
from __future__ import annotations
import pytest
from hms_domain import TransportState
from hms_renderer import FrameSnapshot, RemoteStateMirror, RenderEngine
def _mirror_with_params(params: dict[str, float]) -> RemoteStateMirror:
"""Mirror mit Snapshot, der die gegebenen Parameter enthält."""
mirror = RemoteStateMirror()
mirror.apply_snapshot({
"state_revision": 1,
"project_revision": 1,
"values": params,
"monotonic_ns": 0,
})
return mirror
def test_engine_creates_frame_snapshot_per_tick() -> None:
"""tick() erzeugt FrameSnapshots mit steigendem Index (§11.4)."""
mirror = _mirror_with_params({"master/intensity": 1.0})
engine = RenderEngine(mirror, fps=60.0)
engine.add_source("src-1", "composition/a/layer/b")
t0 = 1_000_000_000
t1 = t0 + 16_666_667 # ~60 fps (16.67 ms)
snap1 = engine.tick(t0)
snap2 = engine.tick(t1)
assert isinstance(snap1, FrameSnapshot)
assert snap1.frame_index == 1
assert snap2.frame_index == 2
assert snap2.parameters["master/intensity"] == 1.0
def test_engine_syncs_play_command_from_mirror() -> None:
"""Mirror-Parameter 'source/state' steuert den Transport (§12.5)."""
# TransportState.PLAYING = 1.0 im Mirror
mirror = _mirror_with_params({
"layer/x/source/state": 1.0,
"layer/x/source/speed": 1.0,
})
engine = RenderEngine(mirror, fps=60.0)
handle = engine.add_source("src-1", "layer/x")
assert handle.state is TransportState.STOPPED
engine.tick(1_000_000_000)
assert handle.state is TransportState.PLAYING
# zweiter Frame: Position schreitet voran
engine.tick(1_016_666_667)
assert handle.position > 0.0
def test_engine_advances_position_over_time() -> None:
"""Playback-Position wächst mit der Frame-Zeit (§12.5)."""
mirror = _mirror_with_params({
"layer/x/source/state": 1.0,
"layer/x/source/speed": 1.0,
})
engine = RenderEngine(mirror, fps=60.0)
engine.add_source("src-1", "layer/x")
t0 = 1_000_000_000
engine.tick(t0)
# 10 Frames später à 16.67 ms = ~0.167 s Advancement
for i in range(10):
engine.tick(t0 + (i + 1) * 16_666_667)
handle = engine.get_source("src-1")
assert handle is not None
assert 0.1 < handle.position < 0.25 # ca. 10/60 = 0.167
def test_engine_preload_commit_switches_asset_atomically() -> None:
"""Clip-Wechsel über Preload: pending → ready → commit (§12.2)."""
mirror = _mirror_with_params({})
engine = RenderEngine(mirror, fps=60.0)
handle = engine.add_source("src-1", "layer/x")
# Asset auswählen (pending)
mirror.apply_delta({
"state_revision": 2,
"project_revision": 1,
"changes": {"layer/x/source/asset_id_pending": 42.0},
"monotonic_ns": 0,
})
engine.tick(1_000_000_000)
assert handle.preload.pending == "42"
assert handle.active_asset_id is None # noch nicht gewechselt
# Commit (ready + Flanke)
mirror.apply_delta({
"state_revision": 3,
"project_revision": 1,
"changes": {"layer/x/source/commit": 1.0},
"monotonic_ns": 0,
})
snap = engine.tick(1_016_666_667)
assert handle.active_asset_id == "42" # atomar gewechselt
assert snap.active_asset_ids["layer/x/source"] == "42"
def test_engine_retrigger_resets_position() -> None:
"""Retrigger-Flanke startet die Quelle neu (§12.5)."""
mirror = _mirror_with_params({
"layer/x/source/state": 1.0,
"layer/x/source/speed": 1.0,
})
engine = RenderEngine(mirror, fps=60.0)
engine.add_source("src-1", "layer/x")
# 5 Frames weit spielen
t0 = 1_000_000_000
for i in range(5):
engine.tick(t0 + i * 16_666_667)
handle = engine.get_source("src-1")
assert handle is not None and handle.position > 0.05
# Retrigger
mirror.apply_delta({
"state_revision": 2,
"project_revision": 1,
"changes": {"layer/x/source/retrigger": 1.0},
"monotonic_ns": 0,
})
pos_before = handle.position
engine.tick(t0 + 5 * 16_666_667)
# Nach Retrigger: Position deutlich kleiner als davor (Rücksetzung auf
# In-Point im selben Frame; ein Frame dt advancement ist erlaubt)
assert handle.position < pos_before * 0.5
def test_engine_snapshot_is_immutable_per_frame() -> None:
"""§11.4: Frame-Snapshot bleibt stabil, auch wenn sich der Mirror ändert."""
mirror = _mirror_with_params({"master/intensity": 0.5})
engine = RenderEngine(mirror, fps=60.0)
engine.add_source("src-1", "layer/x")
snap = engine.tick(1_000_000_000)
intensity_at_snap = snap.parameters["master/intensity"]
# Mirror ändert sich nach dem Snapshot
mirror.apply_delta({
"state_revision": 2,
"project_revision": 1,
"changes": {"master/intensity": 0.9},
"monotonic_ns": 0,
})
# alter Snapshot unverändert
assert snap.parameters["master/intensity"] == pytest.approx(intensity_at_snap)
# neuer Frame hat den neuen Wert
snap2 = engine.tick(1_016_666_667)
assert snap2.parameters["master/intensity"] == pytest.approx(0.9)
def test_engine_telemetry_reports_frame_stats() -> None:
"""Telemetry enthält Frame-Index, FPS, aktive Layer (§28.2)."""
mirror = _mirror_with_params({"layer/x/source/state": 1.0})
engine = RenderEngine(mirror, fps=60.0)
engine.add_source("src-1", "layer/x")
engine.tick(1_000_000_000)
engine.tick(1_016_666_667) # 16.67 ms → ~60 fps
assert engine.telemetry.frame_index == 2
assert 50.0 < engine.telemetry.fps < 70.0 # ~60 fps
assert engine.telemetry.active_layers == 1 # src-1 spielt
def test_engine_multiple_sources_advance_independently() -> None:
"""Zwei Quellen mit unterschiedlicher Geschwindigkeit (§12.5)."""
mirror = _mirror_with_params({
"layer/a/source/state": 1.0,
"layer/a/source/speed": 2.0, # doppelt
"layer/b/source/state": 1.0,
"layer/b/source/speed": 0.5, # halb
})
engine = RenderEngine(mirror, fps=60.0)
engine.add_source("src-a", "layer/a")
engine.add_source("src-b", "layer/b")
t0 = 1_000_000_000
for i in range(20):
engine.tick(t0 + i * 16_666_667)
handle_a = engine.get_source("src-a")
handle_b = engine.get_source("src-b")
assert handle_a is not None and handle_b is not None
# src-a (2x) hat ~4x die Position von src-b (0.5x)
assert handle_a.position > handle_b.position * 3.5
def test_engine_stopped_source_does_not_advance() -> None:
"""Gestoppte Quelle bleibt bei In-Point (§12.5)."""
mirror = _mirror_with_params({}) # kein state → STOPPED
engine = RenderEngine(mirror, fps=60.0)
handle = engine.add_source("src-1", "layer/x")
for i in range(10):
engine.tick(1_000_000_000 + i * 16_666_667)
assert handle.state is TransportState.STOPPED
assert handle.position == pytest.approx(handle.controller.in_point)
+112
View File
@@ -0,0 +1,112 @@
"""Unit-Tests Project-State-Store (PLAN.md §6.4 State Sync, §24.2, §18.1)."""
from __future__ import annotations
import pytest
from hms_domain import PresetScene
from hms_persistence.state_store import ProjectStateStore
def test_revisions_start_at_zero() -> None:
store = ProjectStateStore()
assert store.state_revision == 0
assert store.project_revision == 0
assert store.snapshot()["values"] == {}
def test_activate_project_resets_live_state() -> None:
"""Kein Mischzustand: Projektwechsel leert den Livezustand (§24.2)."""
from hms_domain import Project
store = ProjectStateStore()
store.set_value("master/intensity", 0.5)
store.activate_project(Project(name="Show A"))
assert store.project_revision == 1
assert store.project is not None and store.project.name == "Show A"
assert store.get_value("master/intensity") is None # Live geleert
assert store.state_revision == 2 # set_value (1) + Projektwechsel (2)
def test_set_and_clear_value_increase_state_revision() -> None:
store = ProjectStateStore()
r1 = store.set_value("master/intensity", 0.7)
assert r1 == 1
r2 = store.set_value("master/intensity", 0.9)
assert r2 == 2
r3 = store.clear_value("master/intensity")
assert r3 == 3
assert store.get_value("master/intensity") is None
# Löschen eines nicht existierenden Pfades erhöht trotzdem deterministisch
r4 = store.clear_value("master/intensity")
assert r4 == 4
def test_snapshot_contains_revisions_and_values() -> None:
store = ProjectStateStore()
store.set_value("master/intensity", 1.0)
snap = store.snapshot()
assert snap["state_revision"] == 1
assert snap["project_revision"] == 0
assert snap["values"] == {"master/intensity": 1.0}
assert snap["monotonic_ns"] > 0
def test_delta_since_reports_changes_and_deletions() -> None:
"""Delta überträgt Änderungen; gelöschte Pfade als None (§6.4)."""
store = ProjectStateStore()
store.set_value("a", 1.0)
seen = store.state_revision
# nach „Disconnect": neue Änderungen
store.set_value("a", 2.0)
store.set_value("b", 3.0)
store.clear_value("c") # c war nie gesetzt bleibt neutral
delta = store.delta_since(seen, pending={"a": 1.0, "c": 0.0})
assert delta is not None
assert delta.state_revision == store.state_revision
assert delta.changes["a"] == 2.0 # geändert
assert "b" in delta.changes and delta.changes["b"] == 3.0 # neu
assert delta.changes.get("c") is None # gelöscht
def test_delta_none_when_up_to_date() -> None:
store = ProjectStateStore()
store.set_value("a", 1.0)
assert store.delta_since(store.state_revision, pending={}) is None
def test_delta_rejects_future_revision() -> None:
store = ProjectStateStore()
with pytest.raises(ValueError, match="Zukunft"):
store.delta_since(99, pending={})
def test_apply_scene_overwrites_live_as_target_state() -> None:
"""Szenenabruf erzeugt Zielzustand; Übergänge macht der Renderer (§18.1)."""
store = ProjectStateStore()
store.set_value("layer/x/opacity", 0.2)
scene = PresetScene(
name="Look",
composition_snapshot={
"values": {"layer/x/opacity": 0.8, "master/intensity": 1.0}
},
)
rev = store.apply_scene(scene)
assert rev == 2 # set + scene
assert store.get_value("layer/x/opacity") == pytest.approx(0.8)
assert store.get_value("master/intensity") == pytest.approx(1.0)
def test_apply_scene_rejects_scene_without_values() -> None:
scene = PresetScene(name="Kaputt", composition_snapshot={"values": "kein dict"})
store = ProjectStateStore()
with pytest.raises(ValueError, match="Werte"):
store.apply_scene(scene)
def test_bump_project_revision_independent_of_state() -> None:
"""Projektinhalt (Medien/Szenen) und Livezustand getrennt (§24.2)."""
store = ProjectStateStore()
state_before = store.state_revision
store.bump_project_revision()
assert store.project_revision == 1
assert store.state_revision == state_before # Live unverändert
+189
View File
@@ -0,0 +1,189 @@
"""Tests Supervisor/Launcher mit echten Kindprozessen (PLAN.md §6.1A, §26).
Keine Mocks: die Tests starten reale Python-Kindprozesse und prüfen
Start, kontrolliertes Beenden, Neustart nach Absturz, Crashloop-Erkennung
und Recovery-Markierung.
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import pytest
from hms_launcher import AppPaths, ProcessSpec, Supervisor, find_free_port
def _sleeper(seconds: float = 30.0) -> list[str]:
"""Kindprozess-Kommando, das `seconds` lang still läuft."""
return [sys.executable, "-c", f"import time; time.sleep({seconds})"]
def _crasher() -> list[str]:
"""Kindprozess-Kommando, das sofort mit Code 1 endet."""
return [sys.executable, "-c", "import sys; sys.exit(1)"]
def _wait_exit(state, timeout: float = 5.0) -> None:
deadline = time.monotonic() + timeout
while state.proc is not None and state.proc.poll() is None and time.monotonic() < deadline:
time.sleep(0.02)
def _wait_file(path: Path, timeout: float = 5.0) -> None:
deadline = time.monotonic() + timeout
while not path.is_file() and time.monotonic() < deadline:
time.sleep(0.01)
assert path.is_file(), f"kindprozess schrieb {path} nicht"
def test_find_free_port_returns_usable_port() -> None:
port = find_free_port()
assert 1024 <= port <= 65535
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", port))
def test_start_and_running_state(tmp_path: Path) -> None:
sup = Supervisor(AppPaths(root=tmp_path))
sup.start(ProcessSpec(name="renderer", cmd=_sleeper()))
try:
assert sup.state("renderer").running
assert sup.check()["renderer"] == "running"
finally:
sup.shutdown()
assert sup.state("renderer").returncode is not None
def test_duplicate_start_rejected(tmp_path: Path) -> None:
sup = Supervisor(AppPaths(root=tmp_path))
sup.start(ProcessSpec(name="core", cmd=_sleeper()))
try:
with pytest.raises(RuntimeError, match="already running"):
sup.start(ProcessSpec(name="core", cmd=_sleeper()))
finally:
sup.shutdown()
def test_controlled_stop_uses_terminate(tmp_path: Path) -> None:
sup = Supervisor(AppPaths(root=tmp_path))
sup.start(ProcessSpec(name="core", cmd=_sleeper(), stop_timeout_s=5.0))
rc = sup.stop("core")
try:
assert rc is not None
assert not sup.state("core").running
# kontrolliert beendet → kein Neustart durch check()
assert sup.check()["core"] == "stopped"
# keine Recovery-Markierung bei sauberem Stop
assert sup.consume_recovery_marker() == []
finally:
sup.shutdown()
def test_crash_triggers_restart(tmp_path: Path) -> None:
sup = Supervisor(AppPaths(root=tmp_path))
spec = ProcessSpec(
name="core", cmd=_sleeper(0.2), max_restarts=3, restart_window_s=30.0
)
sup.start(spec)
_wait_exit(sup.state("core"))
result = sup.check() # Absturz erkannt → Neustart
assert result["core"] == "restarted"
assert sup.state("core").running
assert len(sup.state("core").restarts) == 1
sup.shutdown()
def test_crashloop_detection_stops_restarting(tmp_path: Path) -> None:
sup = Supervisor(AppPaths(root=tmp_path))
spec = ProcessSpec(name="core", cmd=_crasher(), max_restarts=2, restart_window_s=60.0)
sup.start(spec)
_wait_exit(sup.state("core"))
assert sup.check()["core"] == "restarted" # Neustart 1
_wait_exit(sup.state("core"))
assert sup.check()["core"] == "restarted" # Neustart 2
_wait_exit(sup.state("core"))
assert sup.check()["core"] == "crashloop" # Schwelle erreicht (§26.2)
assert sup.state("core").crashlooped
# weiteres check() startet nicht mehr
assert sup.check()["core"] == "crashloop"
sup.shutdown()
def test_non_restartable_process_stays_down(tmp_path: Path) -> None:
sup = Supervisor(AppPaths(root=tmp_path))
sup.start(ProcessSpec(name="oneshot", cmd=_crasher(), restartable=False))
_wait_exit(sup.state("oneshot"))
assert sup.check()["oneshot"] == "stopped"
assert not sup.state("oneshot").running
def test_recovery_marker_on_forced_kill(tmp_path: Path) -> None:
"""Kindprozess ignoriert SIGTERM → kill → Recovery-Markierung (§26.4).
Der Kindprozess bestätigt die Handler-Registrierung über eine Ready-Datei;
erst danach sendet der Test SIGTERM (keine Race Condition).
"""
if sys.platform == "win32":
return # Signal-Ignorieren unter Windows nicht testbar
ready = tmp_path / "ready.txt"
ignore_term = (
"import signal, time, sys\n"
"signal.signal(signal.SIGTERM, lambda *a: None)\n"
f"open(r'{ready}', 'w').write('ok')\n"
"time.sleep(30)\n"
)
sup = Supervisor(AppPaths(root=tmp_path))
sup.start(
ProcessSpec(
name="stubborn",
cmd=[sys.executable, "-c", ignore_term],
stop_timeout_s=0.3,
)
)
_wait_file(ready) # Handler aktiv, bevor SIGTERM gesendet wird
rc = sup.stop("stubborn")
assert rc is not None
lines = sup.consume_recovery_marker()
assert any("forced-kill stubborn" in line for line in lines)
# Markierung nach dem Lesen gelöscht
assert sup.consume_recovery_marker() == []
def test_shutdown_stops_all_processes(tmp_path: Path) -> None:
sup = Supervisor(AppPaths(root=tmp_path))
sup.start(ProcessSpec(name="renderer", cmd=_sleeper()))
sup.start(ProcessSpec(name="core", cmd=_sleeper()))
sup.shutdown()
assert not sup.state("renderer").running
assert not sup.state("core").running
def test_environment_gets_gstreamer_vars(tmp_path: Path) -> None:
"""Kindprozess erbt die portable GStreamer-Umgebung (§9.2, ADR-0002).
Der Kindprozess schreibt beide Variablen in eine Datei (kein stdout-
Piping nötig)."""
paths = AppPaths(root=tmp_path)
result_file = tmp_path / "env_result.txt"
probe = (
"import os\n"
f"open(r'{result_file}', 'w').write(\n"
" os.environ.get('GST_PLUGIN_SYSTEM_PATH_1_0', 'MISSING')\n"
" + '|'\n"
" + os.environ.get('GST_PLUGIN_PATH_1_0', 'MISSING')\n"
")\n"
)
sup = Supervisor(paths)
sup.start(
ProcessSpec(name="envprobe", cmd=[sys.executable, "-c", probe], restartable=False)
)
_wait_exit(sup.state("envprobe"))
_wait_file(result_file)
content = result_file.read_text(encoding="utf-8")
system_path, plugin_path = content.split("|", 1)
assert system_path == "" # System-Plugins unterdrückt (§9.2)
assert plugin_path.endswith("gstreamer-1.0") # gebündelte Plugin-Untermenge