Files
hms-mediaengine/_entwicklung/tests/integration/test_state_sync_chain.py
T
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

179 lines
6.7 KiB
Python

"""End-to-End State-Sync über echte IPC (PLAN.md §6.4, §29.2).
Kette ohne Mocks: ProjectStateStore → RendererStateLink → IpcClient →
TCP-Loopback → IpcServer → RemoteStateMirror. Prüft Snapshot-Pflicht,
Delta-Anwendung (neu/geändert/gelöscht), Reconnect-Re-Sync und die
Ablehnung von Deltas vor dem ersten Snapshot (§6.2).
"""
from __future__ import annotations
import asyncio
from hms_control_server.renderer_link import RendererStateLink
from hms_persistence.state_store import ProjectStateStore
from hms_protocol import IpcClient, IpcServer, MessageType
from hms_renderer import RemoteStateMirror, apply_envelope
def _run(coro):
return asyncio.run(coro)
async def _setup():
"""Server + Client + Store + Link + Mirror, verbunden und gesynct."""
server = IpcServer()
port = await server.start()
client = IpcClient(port)
store = ProjectStateStore()
link = RendererStateLink(client, store)
mirror = RemoteStateMirror()
return server, client, store, link, mirror
async def _drain_into_mirror(server: IpcServer, mirror: RemoteStateMirror, count: int):
"""Liest Nachrichten bis `count` STATE-Nachrichten im Mirror ankamen.
Heartbeats werden übersprungen, ohne den Zähler zu verbrauchen (das
Heartbeat-Intervall von 500 ms kann otherwise mit Testnachrichten
interleaven)."""
applied = []
state_seen = 0
while state_seen < count:
env = await asyncio.wait_for(server.receive(), timeout=2.0)
assert env is not None, "Verbindung vorzeitig geschlossen"
if env.type in (MessageType.SNAPSHOT, MessageType.EVENT):
ok = apply_envelope(mirror, env)
applied.append((env.type, env.revision, ok))
state_seen += 1
# Heartbeats und andere: überspringen, nicht mitzählen
return applied
def test_full_sync_chain_snapshot_then_deltas() -> None:
"""Verbindung → Snapshot → Änderungen → Delta → Mirror stimmt überein."""
async def impl() -> None:
server, client, store, link, mirror = await _setup()
try:
# Ausgangszustand im Store
store.set_value("master/intensity", 1.0)
store.set_value("composition/x/layer/y/opacity", 0.4)
# 1) Verbindung + Pflicht-Snapshot (§6.2)
await link.connect_and_sync()
assert link.last_sent_revision == 2
applied = await _drain_into_mirror(server, mirror, 1)
assert applied[0][0] is MessageType.SNAPSHOT
assert mirror.has_snapshot
assert mirror.revision == 2
assert mirror.get_value("master/intensity") == 1.0
assert mirror.get_value("composition/x/layer/y/opacity") == 0.4
# 2) Änderungen im Store → Delta
store.set_value("master/intensity", 0.7) # ändern
store.set_value("master/speed", 1.25) # neu
store.clear_value("composition/x/layer/y/opacity") # löschen
assert await link.sync_if_changed() is True
await _drain_into_mirror(server, mirror, 1)
assert mirror.revision == 5
assert mirror.get_value("master/intensity") == 0.7
assert mirror.get_value("master/speed") == 1.25
assert mirror.get_value("composition/x/layer/y/opacity") is None
assert link.in_sync
# 3) Keine Änderung → kein Delta
assert await link.sync_if_changed() is False
finally:
await client.disconnect()
await server.stop()
_run(impl())
def test_reconnect_sends_fresh_snapshot() -> None:
"""Nach Reconnect: neuer Snapshot, danach erst wieder Deltas (§6.2)."""
async def impl() -> None:
server, client, store, link, mirror = await _setup()
try:
store.set_value("master/intensity", 0.9)
await link.connect_and_sync()
await _drain_into_mirror(server, mirror, 1)
assert mirror.revision == 1
# Trennung simulieren und erneut verbinden
await client.disconnect()
store.set_value("master/intensity", 0.3)
await link.connect_and_sync() # Re-Sync: Snapshot statt Delta
applied = await _drain_into_mirror(server, mirror, 1)
assert applied[0][0] is MessageType.SNAPSHOT # kein halber Zustand
assert mirror.revision == 2
assert mirror.get_value("master/intensity") == 0.3
assert link.in_sync
finally:
await client.disconnect()
await server.stop()
_run(impl())
def test_delta_before_snapshot_requires_resync() -> None:
"""§6.2: Deltas vor dem ersten Snapshot werden abgelehnt (Re-Sync nötig)."""
async def impl() -> None:
server, client, store, link, mirror = await _setup()
try:
# Delta-artige Nachricht ohne vorherigen Snapshot direkt senden
from hms_protocol import Envelope
fake_delta = Envelope(
type=MessageType.EVENT,
revision=5,
payload={
"state_revision": 5,
"project_revision": 1,
"changes": {"master/intensity": 0.5},
"monotonic_ns": 0,
},
)
await client.connect()
await client.send(fake_delta)
env = await asyncio.wait_for(server.receive(), timeout=2.0)
assert env is not None
assert apply_envelope(mirror, env) is False # Re-Sync erforderlich
assert not mirror.has_snapshot
assert mirror.revision == 0 # unverändert
finally:
await client.disconnect()
await server.stop()
_run(impl())
def test_stale_duplicate_delta_is_idempotent_noop() -> None:
"""Veraltete/doppelte Deltas ändern nichts (§6.5 Idempotenz)."""
async def impl() -> None:
server, client, store, link, mirror = await _setup()
try:
store.set_value("master/intensity", 1.0)
await link.connect_and_sync()
await _drain_into_mirror(server, mirror, 1)
# Delta triggern und zweimal dieselbe Nachricht zustellen
store.set_value("master/intensity", 0.6)
assert await link.sync_if_changed() is True
env = await asyncio.wait_for(server.receive(), timeout=2.0)
assert env is not None
assert apply_envelope(mirror, env) is True
# Duplikat: dieselbe Revision erneut → No-Op, kein Fehler
assert apply_envelope(mirror, env) is True
assert mirror.revision == 2 # bleibt
assert mirror.get_value("master/intensity") == 0.6
finally:
await client.disconnect()
await server.stop()
_run(impl())