Renderer-Engine: Sync->Advance->Snapshot-Kette (§12, §11.4, §29.2)
- RenderEngine: orchestriert alle Quellen pro Frame mit fester Master-Bildrate (§12.2): Sync aus StateMirror, Playback-Advancement, unveraenderlicher FrameSnapshot, Telemetry (§28.2) - SourceHandle: verbindet PlaybackController + PreloadSlot + Parameterpfade; Transport (Play/Pause/Stop/Retrigger) aus Mirror; Clip-Wechsel atomar ueber Preload/Commit (§12.2, §16.5) - FrameSnapshot: unveränderlich pro Frame (§11.4): Parameter, Positionen, Transport-Zustaende, aktive Assets; halbe Zustaende ausgeschlossen - RenderTelemetry: Frame-Index, FPS, Framezeit, aktive Layer, pending Preloads, Events (§25.4, §28.2) - TransportState-Mapping: int-Werte 0/1/2 aus Mirror auf Enum - 9 Unit-Tests: Frame-Snapshots, Play-Command, Positions-Advancement, atomarer Asset-Wechsel, Retrigger, Snapshot-Unveränderlichkeit, Telemetry, Multi-Source-Speed, Stopped-Verhalten - Gesamtsuite 595 gruen, Ruff gruen
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user