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:
@@ -0,0 +1,74 @@
|
||||
"""Unit-Tests Adaptive Quality (PLAN.md §5.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hms_adaptive import AdaptiveQualityController, QualityLevel
|
||||
|
||||
|
||||
def _fast_controller() -> AdaptiveQualityController:
|
||||
"""Regler ohne echte Wartezeiten für deterministische Tests."""
|
||||
return AdaptiveQualityController(min_hold_ms=0, downgrade_intervals=2, upgrade_intervals=6)
|
||||
|
||||
|
||||
def test_starts_at_high() -> None:
|
||||
assert _fast_controller().level is QualityLevel.HIGH
|
||||
|
||||
|
||||
def test_single_bad_interval_does_not_downgrade() -> None:
|
||||
c = _fast_controller()
|
||||
assert c.step(30.0) is QualityLevel.HIGH # 1 schlechtes Intervall genügt nicht
|
||||
|
||||
|
||||
def test_downgrades_one_level_per_interval_not_more() -> None:
|
||||
c = _fast_controller()
|
||||
c.step(30.0)
|
||||
c.step(30.0)
|
||||
assert c.level is QualityLevel.MEDIUM # genau eine Stufe (Hysterese, §5.2)
|
||||
c.step(30.0)
|
||||
c.step(30.0)
|
||||
assert c.level is QualityLevel.LOW
|
||||
assert c.step(30.0) is QualityLevel.LOW # Untergrenze
|
||||
|
||||
|
||||
def test_upgrade_needs_sustained_reserve() -> None:
|
||||
c = _fast_controller()
|
||||
for _ in range(4):
|
||||
c.step(30.0)
|
||||
assert c.level is QualityLevel.LOW
|
||||
for _ in range(5): # 5 gute Intervalle genügen nicht (Upgrade träger)
|
||||
c.step(1.0)
|
||||
assert c.level is QualityLevel.LOW
|
||||
c.step(1.0) # 6. gutes Intervall → eine Stufe hoch
|
||||
assert c.level is QualityLevel.MEDIUM
|
||||
|
||||
|
||||
def test_upgrade_slower_than_downgrade() -> None:
|
||||
c = _fast_controller()
|
||||
assert c.downgrade_intervals < c.upgrade_intervals
|
||||
|
||||
|
||||
def test_no_pumping_on_alternating_load() -> None:
|
||||
c = _fast_controller()
|
||||
levels = []
|
||||
for _ in range(60):
|
||||
levels.append(c.step(30.0)) # Dauerlast → LOW
|
||||
assert c.level is QualityLevel.LOW
|
||||
for _ in range(3):
|
||||
c.step(1.0) # kurze Erholung darf kein Pumpen erzeugen
|
||||
c.step(30.0)
|
||||
assert c.level is QualityLevel.LOW # keine Aufwertung bei alternierender Last
|
||||
|
||||
|
||||
def test_reason_is_recorded() -> None:
|
||||
c = _fast_controller()
|
||||
c.step(30.0)
|
||||
c.step(30.0)
|
||||
assert "p99" in c.last_reason and "budget" in c.last_reason
|
||||
|
||||
|
||||
def test_default_min_hold_prevents_rapid_changes(monkeypatch) -> None:
|
||||
|
||||
c = AdaptiveQualityController() # min_hold_ms=2000 (Produktionswert)
|
||||
c.step(30.0)
|
||||
c.step(30.0)
|
||||
assert c.level is QualityLevel.HIGH # Mindesthaltezeit noch nicht vergangen
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Unit-Tests Art-Net-Pakete gegen die offizielle Spezifikation (PLAN.md §16).
|
||||
|
||||
Verifizierte Referenzwerte aus der Art-Net-4-Spezifikation:
|
||||
- ID 'Art-Net\\0', OpCode little-endian, ProtVer 14 high-byte-first
|
||||
- ArtDMX: 0x5000, Header 18 Bytes, Länge gerade, 2..512
|
||||
- ArtPoll: 0x2000, 14 Bytes
|
||||
- ArtPollReply: 0x2100, 210 Bytes, Style 0x02 StMedia, Port 0x1936
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
from hms_artnet.packets import (
|
||||
ARTNET_ID,
|
||||
UDP_PORT,
|
||||
build_artpoll_reply,
|
||||
build_dmx,
|
||||
build_poll,
|
||||
parse_dmx,
|
||||
parse_poll,
|
||||
parse_poll_reply,
|
||||
)
|
||||
|
||||
# ---------- Header ----------
|
||||
|
||||
def test_artnet_id_is_8_bytes_with_null() -> None:
|
||||
assert ARTNET_ID == b"Art-Net\x00"
|
||||
assert len(ARTNET_ID) == 8
|
||||
|
||||
|
||||
def test_dmx_opcode_little_endian() -> None:
|
||||
packet = build_dmx(0, b"\x00\x00")
|
||||
assert packet[8:10] == b"\x00\x50" # 0x5000 low byte first
|
||||
|
||||
|
||||
def test_protocol_version_14_high_byte_first() -> None:
|
||||
packet = build_dmx(0, b"\x00\x00")
|
||||
assert packet[10:12] == b"\x00\x0e" # ProtVer 14
|
||||
|
||||
|
||||
# ---------- ArtDMX ----------
|
||||
|
||||
def test_dmx_roundtrip() -> None:
|
||||
data = bytes(range(64))
|
||||
packet = build_dmx(universe=5, data=data, sequence=7, physical=1)
|
||||
parsed = parse_dmx(packet)
|
||||
assert parsed is not None
|
||||
assert parsed.universe == 5
|
||||
assert parsed.sequence == 7
|
||||
assert parsed.physical == 1
|
||||
assert parsed.data == data
|
||||
|
||||
|
||||
def test_dmx_length_is_even_and_header_18_bytes() -> None:
|
||||
packet = build_dmx(0, b"\x01\x02\x03") # ungerade → aufgerundet
|
||||
(length,) = struct.unpack_from(">H", packet, 16)
|
||||
assert length == 4 # auf gerade aufgerundet
|
||||
assert len(packet) == 18 + length
|
||||
|
||||
|
||||
def test_dmx_universe_encoding_net_and_subuni() -> None:
|
||||
# universe = Net<<8 | SubUni; Net 7 Bit, SubUni 8 Bit
|
||||
packet = build_dmx(universe=(3 << 8) | 0x42, data=b"\x00\x00")
|
||||
assert packet[14] == 0x42 # SubUni
|
||||
assert packet[15] == 0x03 # Net
|
||||
parsed = parse_dmx(packet)
|
||||
assert parsed is not None
|
||||
assert parsed.universe == (3 << 8) | 0x42
|
||||
|
||||
|
||||
def test_dmx_rejects_short_data() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
build_dmx(0, b"\x00") # unter 2 Bytes
|
||||
with pytest.raises(ValueError):
|
||||
build_dmx(0, b"\x00" * 513) # über 512
|
||||
|
||||
|
||||
def test_dmx_rejects_invalid_universe() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
build_dmx(0x8000, b"\x00\x00") # 15 Bit max
|
||||
|
||||
|
||||
def test_parse_dmx_rejects_garbage() -> None:
|
||||
assert parse_dmx(b"") is None
|
||||
assert parse_dmx(b"\x00" * 10) is None
|
||||
wrong_opcode = ARTNET_ID + struct.pack("<H", 0x9999) + b"\x00\x0e" + b"\x00" * 10
|
||||
assert parse_dmx(wrong_opcode) is None
|
||||
|
||||
|
||||
def test_parse_dmx_rejects_old_protocol_version() -> None:
|
||||
packet = bytearray(build_dmx(0, b"\x00\x00"))
|
||||
packet[10:12] = b"\x00\x0c" # ProtVer 12
|
||||
assert parse_dmx(bytes(packet)) is None
|
||||
|
||||
|
||||
def test_parse_dmx_rejects_truncated_payload() -> None:
|
||||
packet = build_dmx(0, b"\x00" * 64)
|
||||
assert parse_dmx(packet[:-32]) is None # abgeschnittene Daten
|
||||
|
||||
|
||||
# ---------- ArtPoll ----------
|
||||
|
||||
def test_poll_is_14_bytes_and_roundtrips() -> None:
|
||||
packet = build_poll(talk_to_me=0x02, priority=0x0A)
|
||||
assert len(packet) == 14
|
||||
parsed = parse_poll(packet)
|
||||
assert parsed is not None
|
||||
assert parsed.talk_to_me == 0x02
|
||||
assert parsed.priority == 0x0A
|
||||
|
||||
|
||||
def test_poll_opcode() -> None:
|
||||
assert build_poll()[8:10] == b"\x00\x20" # 0x2000 low byte first
|
||||
|
||||
|
||||
def test_parse_poll_accepts_extended_packets() -> None:
|
||||
packet = build_poll() + b"\x00" * 10 # größere Pakete müssen akzeptiert werden
|
||||
assert parse_poll(packet) is not None
|
||||
|
||||
|
||||
def test_parse_poll_rejects_wrong_opcode() -> None:
|
||||
packet = build_dmx(0, b"\x00\x00")
|
||||
assert parse_poll(packet) is None
|
||||
|
||||
|
||||
# ---------- ArtPollReply ----------
|
||||
|
||||
def test_pollreply_exactly_210_bytes() -> None:
|
||||
reply = build_artpoll_reply(
|
||||
ip=b"\xc0\xa8\x01\x2a",
|
||||
short_name="HMS ME",
|
||||
long_name="HMS MediaEngine Render Node",
|
||||
)
|
||||
assert len(reply) == 210
|
||||
|
||||
|
||||
def test_pollreply_roundtrip_as_media_server() -> None:
|
||||
reply = build_artpoll_reply(
|
||||
ip=b"\xc0\xa8\x01\x2a",
|
||||
short_name="HMS ME",
|
||||
long_name="HMS MediaEngine Render Node A",
|
||||
node_report="Media Server Ready",
|
||||
mac=b"\xde\xad\xbe\xef\x00\x01",
|
||||
)
|
||||
info = parse_poll_reply(reply)
|
||||
assert info is not None
|
||||
assert info.ip == "192.168.1.42"
|
||||
assert info.short_name == "HMS ME"
|
||||
assert info.long_name == "HMS MediaEngine Render Node A"
|
||||
assert info.style == 0x02 # StMedia (Media Server, §3.4)
|
||||
assert info.mac == b"\xde\xad\xbe\xef\x00\x01"
|
||||
assert info.bind_index == 1
|
||||
|
||||
|
||||
def test_pollreply_port_is_6454() -> None:
|
||||
reply = build_artpoll_reply(ip=b"\x7f\x00\x00\x01", short_name="x", long_name="y")
|
||||
(port,) = struct.unpack_from(">H", reply, 14)
|
||||
assert port == UDP_PORT == 0x1936
|
||||
|
||||
|
||||
def test_pollreply_node_report_format() -> None:
|
||||
reply = build_artpoll_reply(
|
||||
ip=b"\x7f\x00\x00\x01",
|
||||
short_name="x",
|
||||
long_name="y",
|
||||
report_code=0x0000,
|
||||
error_count=3,
|
||||
)
|
||||
info = parse_poll_reply(reply)
|
||||
assert info is not None
|
||||
assert info.node_report.startswith("#0000 [0003]") # Format '#hhhh [hhhh] text'
|
||||
|
||||
|
||||
def test_pollreply_names_null_terminated_and_truncated() -> None:
|
||||
reply = build_artpoll_reply(
|
||||
ip=b"\x7f\x00\x00\x01",
|
||||
short_name="S" * 40, # > 17 → auf 17 gekürzt
|
||||
long_name="L" * 100, # > 63 → auf 63 gekürzt
|
||||
)
|
||||
info = parse_poll_reply(reply)
|
||||
assert info is not None
|
||||
assert info.short_name == "S" * 17
|
||||
assert info.long_name == "L" * 63
|
||||
|
||||
|
||||
def test_pollreply_rejects_short_packets() -> None:
|
||||
assert parse_poll_reply(b"\x00" * 100) is None
|
||||
@@ -0,0 +1,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
|
||||
@@ -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: 100–300 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")
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Unit-Tests DMX-Mapping: Flanken, 16-Bit-Decoder, Signalverlust (§16.4–16.6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from hms_artnet.mapping import DmxLayerMapper, LayerDmxMapping, RisingEdge
|
||||
from hms_artnet.receiver import DmxUpdate, LossBehavior
|
||||
from hms_parameter.engine import ControlSource, ParameterEngine
|
||||
|
||||
|
||||
def _opacity(comp: str, layer: str) -> str:
|
||||
return f"composition/{comp}/layer/{layer}/opacity"
|
||||
|
||||
|
||||
def _enabled(comp: str, layer: str) -> str:
|
||||
return f"composition/{comp}/layer/{layer}/enabled"
|
||||
|
||||
|
||||
# ---------- RisingEdge (§16.3/§16.5: Trigger = Flanke, kein Dauerzustand) ----------
|
||||
|
||||
|
||||
def test_rising_edge_triggers_once_per_crossing() -> None:
|
||||
edge = RisingEdge(threshold=64)
|
||||
assert edge.feed(0) is False
|
||||
assert edge.feed(64) is True # steigende Flanke
|
||||
assert edge.feed(100) is False # gehaltener Wert: kein erneuter Trigger
|
||||
assert edge.feed(200) is False
|
||||
assert edge.feed(10) is False # Rückfall
|
||||
assert edge.feed(70) is True # neue Flanke nach Rückkehr
|
||||
|
||||
|
||||
def test_rising_edge_boundary() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
RisingEdge(threshold=256)
|
||||
with pytest.raises(ValueError):
|
||||
RisingEdge(threshold=-1)
|
||||
|
||||
|
||||
# ---------- LayerDmxMapping ----------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ids() -> tuple[str, str]:
|
||||
return str(uuid.uuid4()), str(uuid.uuid4())
|
||||
|
||||
|
||||
def test_mapping_paths_use_stable_uuids(ids: tuple[str, str]) -> None:
|
||||
comp, layer = ids
|
||||
m = LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer)
|
||||
assert m.opacity_path == _opacity(comp, layer)
|
||||
assert m.enable_path == _enabled(comp, layer)
|
||||
|
||||
|
||||
def test_mapping_validates_universe_and_address(ids: tuple[str, str]) -> None:
|
||||
comp, layer = ids
|
||||
with pytest.raises(ValueError):
|
||||
LayerDmxMapping(universe=0x8000, base_address=1, composition_id=comp, layer_id=layer)
|
||||
with pytest.raises(ValueError):
|
||||
LayerDmxMapping(universe=0, base_address=511, composition_id=comp, layer_id=layer)
|
||||
|
||||
|
||||
# ---------- DmxLayerMapper (§36 Nr. 8: DMX-Kanal → Opacity) ----------
|
||||
|
||||
|
||||
def _update(universe: int, data: bytes, sequence: int = 1) -> DmxUpdate:
|
||||
return DmxUpdate(
|
||||
universe=universe,
|
||||
data=data,
|
||||
sender_ip="10.0.0.9",
|
||||
received_ns=0,
|
||||
sequence=sequence,
|
||||
)
|
||||
|
||||
|
||||
def _loss(universe: int, sender: str = "10.0.0.9") -> DmxUpdate:
|
||||
return DmxUpdate(universe=universe, data=b"", sender_ip=sender, received_ns=1, sequence=-1)
|
||||
|
||||
|
||||
def test_mapper_sets_opacity_from_16bit_channels(ids: tuple[str, str]) -> None:
|
||||
comp, layer = ids
|
||||
engine = ParameterEngine()
|
||||
mapper = DmxLayerMapper(
|
||||
LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer),
|
||||
engine,
|
||||
)
|
||||
# Kanal 2-3 = Opacity 16 Bit: MSB zuerst → 0x8000/0xFFFF
|
||||
mapper.handle(_update(0, bytes([255, 0x80, 0x00])))
|
||||
assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(0x8000 / 65535)
|
||||
# Voll auf: 0xFFFF
|
||||
mapper.handle(_update(0, bytes([255, 0xFF, 0xFF])))
|
||||
assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(1.0)
|
||||
# Kanal 1 < 128 → Layer disabled
|
||||
mapper.handle(_update(0, bytes([0, 0xFF, 0xFF])))
|
||||
assert engine.effective_value(_enabled(comp, layer)) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_mapper_ignores_other_universe(ids: tuple[str, str]) -> None:
|
||||
comp, layer = ids
|
||||
engine = ParameterEngine()
|
||||
mapper = DmxLayerMapper(
|
||||
LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer),
|
||||
engine,
|
||||
)
|
||||
mapper.handle(_update(7, bytes([255, 0xFF, 0xFF])))
|
||||
assert _opacity(comp, layer) not in engine.snapshot()
|
||||
|
||||
|
||||
def test_mapper_base_address_offset(ids: tuple[str, str]) -> None:
|
||||
comp, layer = ids
|
||||
engine = ParameterEngine()
|
||||
mapper = DmxLayerMapper(
|
||||
LayerDmxMapping(universe=1, base_address=65, composition_id=comp, layer_id=layer),
|
||||
engine,
|
||||
)
|
||||
# Zweiter Layer im selben Universe: Startadresse 65 → Kanal 66/67
|
||||
data = bytearray(128)
|
||||
data[64] = 255 # Kanal 65: Enable
|
||||
data[65] = 0x40 # Kanal 66: Opacity MSB
|
||||
data[66] = 0x00 # Kanal 67: Opacity LSB
|
||||
mapper.handle(_update(1, bytes(data)))
|
||||
assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(0x4000 / 65535)
|
||||
|
||||
|
||||
def test_mapper_hold_on_signal_loss(ids: tuple[str, str]) -> None:
|
||||
comp, layer = ids
|
||||
engine = ParameterEngine()
|
||||
m = LayerDmxMapping(
|
||||
universe=0,
|
||||
base_address=1,
|
||||
composition_id=comp,
|
||||
layer_id=layer,
|
||||
loss_behavior=LossBehavior.HOLD,
|
||||
)
|
||||
mapper = DmxLayerMapper(m, engine)
|
||||
mapper.handle(_update(0, bytes([255, 0xFF, 0x00])))
|
||||
before = engine.effective_value(_opacity(comp, layer))
|
||||
mapper.handle(_loss(0))
|
||||
assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(before)
|
||||
|
||||
|
||||
def test_mapper_fade_to_black_releases_on_signal_loss(ids: tuple[str, str]) -> None:
|
||||
comp, layer = ids
|
||||
engine = ParameterEngine()
|
||||
m = LayerDmxMapping(
|
||||
universe=0,
|
||||
base_address=1,
|
||||
composition_id=comp,
|
||||
layer_id=layer,
|
||||
loss_behavior=LossBehavior.FADE_TO_BLACK,
|
||||
)
|
||||
mapper = DmxLayerMapper(m, engine)
|
||||
mapper.handle(_update(0, bytes([255, 0xFF, 0xFF])))
|
||||
mapper.handle(_loss(0))
|
||||
snap = engine.snapshot()
|
||||
assert _opacity(comp, layer) not in snap # Override freigegeben
|
||||
|
||||
|
||||
def test_dmx_source_priority_is_console(ids: tuple[str, str]) -> None:
|
||||
"""Art-Net wirkt als CONSOLE (Priorität 3) und überstimmt Web (§11.2)."""
|
||||
comp, layer = ids
|
||||
engine = ParameterEngine()
|
||||
path = _opacity(comp, layer)
|
||||
engine.set_value(path, 0.1, ControlSource.WEB)
|
||||
mapper = DmxLayerMapper(
|
||||
LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer),
|
||||
engine,
|
||||
)
|
||||
mapper.handle(_update(0, bytes([255, 0xFF, 0xFF])))
|
||||
assert engine.effective_value(path) == pytest.approx(1.0) # Pult gewinnt
|
||||
assert engine.current_source(path) is ControlSource.CONSOLE
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Unit-Tests stabile IDs (PLAN.md §3.6, §6.3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from hms_domain import new_node_id, new_uuid, persistent_node_id
|
||||
|
||||
|
||||
def test_new_uuids_are_unique_and_parseable() -> None:
|
||||
a, b = new_uuid(), new_uuid()
|
||||
assert a != b
|
||||
uuid.UUID(a)
|
||||
uuid.UUID(b)
|
||||
|
||||
|
||||
def test_node_id_is_uuid_and_random() -> None:
|
||||
a, b = new_node_id(), new_node_id()
|
||||
uuid.UUID(a)
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_persistent_node_id_stable_across_calls(tmp_path) -> None:
|
||||
identity = tmp_path / "identity" / "node_id"
|
||||
first = persistent_node_id(identity)
|
||||
second = persistent_node_id(identity)
|
||||
assert first == second # IP-/Hostnamenunabhängig: Datei ist Quelle der Wahrheit
|
||||
assert identity.read_text(encoding="utf-8").strip() == first
|
||||
|
||||
|
||||
def test_persistent_node_id_validated_on_load(tmp_path) -> None:
|
||||
identity = tmp_path / "identity" / "node_id"
|
||||
identity.parent.mkdir(parents=True)
|
||||
identity.write_text("not-a-uuid", encoding="utf-8")
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
persistent_node_id(identity)
|
||||
|
||||
|
||||
def test_two_nodes_get_distinct_ids(tmp_path) -> None:
|
||||
a = persistent_node_id(tmp_path / "a" / "node_id")
|
||||
b = persistent_node_id(tmp_path / "b" / "node_id")
|
||||
assert a != b # doppelte node_ids wären ein Fehler (§6.3)
|
||||
|
||||
|
||||
def test_concurrent_creation_yields_single_id(tmp_path) -> None:
|
||||
"""O_EXCL-Rennbedingung: beide Aufrufer erhalten dieselbe ID."""
|
||||
identity = tmp_path / "identity" / "node_id"
|
||||
id_a = persistent_node_id(identity)
|
||||
# zweite Erzeugung mit bereits existierender Datei → lädt vorhandene ID
|
||||
id_b = persistent_node_id(identity)
|
||||
assert id_a == id_b
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Unit-Tests Renderer-Pipeline-Definitionen (PLAN.md §12, §36 Nr. 4–5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hms_renderer import (
|
||||
D3D11Pipeline,
|
||||
DevGLPipeline,
|
||||
build_compositor_pipeline,
|
||||
build_single_video_pipeline,
|
||||
gst_available,
|
||||
)
|
||||
|
||||
|
||||
def test_single_video_d3d11_uses_hardware_decoder_path() -> None:
|
||||
pipeline = build_single_video_pipeline("C:/clips/test.mp4", d3d11=True)
|
||||
assert "d3d11" in pipeline
|
||||
assert "d3d11videosink" in pipeline
|
||||
assert "fullscreen=true" in pipeline # randloses Vollbild (§3.3)
|
||||
assert "uridecodebin" in pipeline
|
||||
|
||||
|
||||
def test_single_video_devgl_marked_alternative() -> None:
|
||||
pipeline = build_single_video_pipeline("/tmp/test.mp4", d3d11=False)
|
||||
assert "d3d11" not in pipeline # Dev-Pfad darf D3D11 nicht still nutzen
|
||||
assert "glimagesink" in pipeline
|
||||
|
||||
|
||||
def test_compositor_d3d11_mixed_two_sources_on_gpu() -> None:
|
||||
pipeline = build_compositor_pipeline("a.mp4", "b.mp4", d3d11=True)
|
||||
# Zwei Quellen → Compositor → Ausgabe ohne CPU-Readback (§36 Nr. 5)
|
||||
assert pipeline.count("uridecodebin") == 2
|
||||
assert "d3d11compositor" in pipeline
|
||||
assert "d3d11convert" in pipeline
|
||||
assert "D3D11Memory" in pipeline # GPU-Residenz explizit angefordert
|
||||
assert "appsink" not in pipeline # kein CPU-Abgriff im Normalpfad
|
||||
assert "videoconvert" not in pipeline # kein Software-Farbkonverter
|
||||
|
||||
|
||||
def test_compositor_devgl_is_separate_path() -> None:
|
||||
pipeline = build_compositor_pipeline("a.mp4", "b.mp4", d3d11=False)
|
||||
assert "glvideomixer" in pipeline
|
||||
assert "d3d11" not in pipeline
|
||||
|
||||
|
||||
def test_d3d11_pipeline_splits_screen_for_two_videos() -> None:
|
||||
p = D3D11Pipeline(video_a="a.mp4", video_b="b.mp4", width=1920, height=1080)
|
||||
s = p.launch_string()
|
||||
assert "sink_0::width=960" in s # linke Hälfte
|
||||
assert "sink_1::width=960" in s # rechte Hälfte
|
||||
assert "width=1920,height=1080" in s # Master-Auflösung
|
||||
|
||||
|
||||
def test_devgl_pipeline_reduced_resolution() -> None:
|
||||
p = DevGLPipeline(video_a="a.mp4", video_b="b.mp4")
|
||||
assert p.width == 960 and p.height == 540 # Dev-Pfad kleiner, gekennzeichnet
|
||||
|
||||
|
||||
def test_gst_available_reflects_environment() -> None:
|
||||
# Im Entwicklungscontainer ist GStreamer nicht installiert → False.
|
||||
# Auf dem Windows-Ziel mit gebündelter Runtime → True. Kein Fake.
|
||||
assert isinstance(gst_available(), bool)
|
||||
@@ -0,0 +1,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))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user