"""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)