Files
HMS MediaEngine Agent 362e089be0 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).
2026-09-11 23:44:06 +02:00

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)