362e089be0
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).
206 lines
6.9 KiB
Python
206 lines
6.9 KiB
Python
"""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)
|