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).
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 23:44:06 +02:00
parent 696e8eb1b3
commit 362e089be0
338 changed files with 24 additions and 387 deletions
@@ -0,0 +1,113 @@
"""Integrationstests Art-Net-Receiver über echten Loopback-UDP-Socket (§16.1).
Kein Mock: echter Datagramm-Versand auf 127.0.0.1. Async-Tests laufen
über asyncio.run(), damit keine pytest-asyncio-Versionsbindung entsteht.
"""
from __future__ import annotations
import asyncio
from hms_artnet import ArtNetReceiver, build_dmx, build_poll, parse_poll_reply
def test_receiver_emits_updates_and_telemetry() -> None:
asyncio.run(_recv_updates_impl())
async def _recv_updates_impl() -> None:
receiver = ArtNetReceiver(universes={0}, bind_host="127.0.0.1", port=0)
await receiver.start()
sock = receiver._transport.get_extra_info("socket")
actual_port = sock.getsockname()[1]
received: list = []
receiver.on_dmx(received.append)
receiver._transport.sendto(
build_dmx(0, b"\x11\x22\x33\x44"), ("127.0.0.1", actual_port)
)
await asyncio.sleep(0.2)
assert len(received) >= 1
assert received[-1].universe == 0
assert received[-1].data == b"\x11\x22\x33\x44"
tel = receiver.telemetry()
assert tel[0].packets >= 1
assert tel[0].last_sender_ip == "127.0.0.1"
# nicht abonniertes Universe ignorieren
n_before = tel[0].packets
receiver._transport.sendto(
build_dmx(9, b"\x00\x00"), ("127.0.0.1", actual_port)
)
await asyncio.sleep(0.15)
assert receiver.telemetry()[0].packets == n_before
await receiver.stop()
def test_receiver_answers_poll_as_media_server() -> None:
asyncio.run(_poll_reply_impl())
async def _poll_reply_impl() -> None:
receiver = ArtNetReceiver(
universes={0},
bind_host="127.0.0.1",
port=0,
short_name="HMS Test",
long_name="HMS MediaEngine Test Node",
)
await receiver.start()
sock = receiver._transport.get_extra_info("socket")
actual_port = sock.getsockname()[1]
reply_ready = asyncio.Event()
replies: list = []
class _Client(asyncio.DatagramProtocol):
def datagram_received(self, data, addr) -> None:
replies.append(data)
reply_ready.set()
loop = asyncio.get_running_loop()
client_transport, _ = await loop.create_datagram_endpoint(
_Client, local_addr=("127.0.0.1", 0)
)
client_transport.sendto(build_poll(), ("127.0.0.1", actual_port))
await asyncio.wait_for(reply_ready.wait(), timeout=2.0)
info = parse_poll_reply(replies[0])
assert info is not None
assert info.style == 0x02 # StMedia (§3.4)
assert info.short_name == "HMS Test"
client_transport.close()
await receiver.stop()
def test_receiver_allowlist_blocks_foreign_sender() -> None:
asyncio.run(_allowlist_impl())
async def _allowlist_impl() -> None:
receiver = ArtNetReceiver(
universes={0},
bind_host="127.0.0.1",
port=0,
sender_allowlist={"192.168.50.10"}, # nur dieser Sender erlaubt
)
await receiver.start()
sock = receiver._transport.get_extra_info("socket")
actual_port = sock.getsockname()[1]
received: list = []
receiver.on_dmx(received.append)
receiver._transport.sendto(
build_dmx(0, b"\x00\x00"), ("127.0.0.1", actual_port)
)
await asyncio.sleep(0.2)
assert received == [] # Absender 127.0.0.1 steht nicht in der Allowlist
await receiver.stop()
@@ -0,0 +1,168 @@
"""Integrationstests Control Core (PLAN.md §6.1B, §23, §36 Nr. 9).
FastAPI-REST + WebSocket mit derselben Parameter-Engine, die auch
Art-Net bedient (§11: eine autoritative Instanz). Node-Identität und
Cluster-Registry sind verkabelt (§6.3).
"""
from __future__ import annotations
import uuid
import pytest
from fastapi.testclient import TestClient
from hms_control_server import create_app
from hms_domain import NodeIdentity, NodeRole
@pytest.fixture()
def client() -> TestClient:
return TestClient(create_app())
@pytest.fixture()
def named_client() -> TestClient:
"""App mit injizierter produktionsähnlicher Identität."""
identity = NodeIdentity.ephemeral(
"Show Server A", frozenset({NodeRole.RENDER_NODE, NodeRole.COORDINATOR})
)
return TestClient(create_app(identity=identity))
def _payload(path: str, value: float) -> dict:
return {"parameter_path": path, "value": value}
@pytest.fixture()
def path() -> str:
return f"composition/{uuid.uuid4()}/layer/{uuid.uuid4()}/opacity"
# ---------- Health/Capabilities/Identity ----------
def test_health(client: TestClient) -> None:
r = client.get("/api/v1/system/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
assert r.json()["phase"] == 5
assert r.json()["node_id"] # Identität in Health sichtbar
def test_identity_reports_roles_without_secrets(named_client: TestClient) -> None:
r = named_client.get("/api/v1/system/identity")
assert r.status_code == 200
body = r.json()
assert body["display_name"] == "Show Server A"
assert body["roles"] == ["COORDINATOR", "RENDER_NODE"]
assert body["renders_locally"] is True
assert body["is_coordinator"] is True
# keine vertraulichen Felder (§27.1)
blob = str(body).lower()
for forbidden in ("token", "secret", "password"):
assert forbidden not in blob
def test_two_apps_get_distinct_dev_identities() -> None:
"""Ohne injizierte Identität erhält jede App-Instanz eine eigene ID
(Dev-Modus; Showbetrieb nutzt persistente Identität über den Launcher)."""
a = TestClient(create_app()).get("/api/v1/system/identity").json()
b = TestClient(create_app()).get("/api/v1/system/identity").json()
assert a["node_id"] != b["node_id"]
def test_capabilities_reported_without_fake_tier(client: TestClient) -> None:
r = client.get("/api/v1/system/capabilities")
assert r.status_code == 200
assert r.json()["tier"] is None # ungeprüft (CPU-only), kein Fake
def test_diagnostics_reports_not_connected(client: TestClient) -> None:
r = client.get("/api/v1/diagnostics")
assert r.status_code == 200
assert r.json()["renderer"] == "not_connected"
assert r.json()["node_id"]
# ---------- Cluster-Registry (§6.3) ----------
def test_self_node_listed_in_cluster_nodes(named_client: TestClient) -> None:
r = named_client.get("/api/v1/cluster/nodes")
assert r.status_code == 200
body = r.json()
self_entry = next(n for n in body["nodes"] if n["node_id"] == body["self"])
assert self_entry["display_name"] == "Show Server A"
assert set(self_entry["roles"]) == {"RENDER_NODE", "COORDINATOR"}
assert self_entry["health"] in ("online", "offline") # ohne Heartbeat offline
# ---------- Commands (§23.2) ----------
def test_parameter_set_command_ack(client: TestClient, path: str) -> None:
r = client.post("/api/v1/commands", json={"payload": _payload(path, 0.75)})
assert r.status_code == 200
body = r.json()
assert body["status"] == "ack"
assert body["effective"] == pytest.approx(0.75)
def test_duplicate_command_id_is_idempotent(client: TestClient, path: str) -> None:
cmd = {"command_id": str(uuid.uuid4()), "payload": _payload(path, 0.5)}
first = client.post("/api/v1/commands", json=cmd).json()
second = client.post("/api/v1/commands", json=cmd).json()
assert first["status"] == "ack"
assert second.get("duplicate") is True
assert second["result"]["revision"] == first["revision"]
def test_revision_conflict_returns_409(client: TestClient, path: str) -> None:
r = client.post(
"/api/v1/commands",
json={"payload": _payload(path, 0.5), "expected_revision": 999},
)
assert r.status_code == 409
assert r.json()["detail"]["error"] == "REVISION_CONFLICT"
def test_invalid_parameter_path_returns_400(client: TestClient) -> None:
r = client.post(
"/api/v1/commands",
json={"payload": _payload("../evil/path", 1.0)},
)
assert r.status_code == 400
def test_unknown_command_type_returns_400(client: TestClient) -> None:
r = client.post("/api/v1/commands", json={"type": "renderer.draw", "payload": {}})
assert r.status_code == 400
def test_parameters_endpoint_reflects_state(client: TestClient, path: str) -> None:
client.post("/api/v1/commands", json={"payload": _payload(path, 0.42)})
r = client.get("/api/v1/parameters")
assert r.status_code == 200
assert r.json()["values"][path] == pytest.approx(0.42)
assert r.json()["revision"] >= 1
# ---------- WebSocket (§23.3) ----------
def test_websocket_snapshot_on_connect(client: TestClient, path: str) -> None:
client.post("/api/v1/commands", json={"payload": _payload(path, 0.33)})
with client.websocket_connect("/ws") as ws:
snap = ws.receive_json()
assert snap["type"] == "snapshot"
assert snap["values"][path] == pytest.approx(0.33)
def test_websocket_receives_parameter_updates(client: TestClient, path: str) -> None:
with client.websocket_connect("/ws") as ws:
ws.receive_json() # Initial-Snapshot
client.post("/api/v1/commands", json={"payload": _payload(path, 0.9)})
event = ws.receive_json()
assert event["type"] == "parameter.update"
assert event["parameter_path"] == path
assert event["value"] == pytest.approx(0.9)
@@ -0,0 +1,266 @@
"""Integrationstests IPC-Verbindung (PLAN.md §6.2, §29.2).
Echter TCP-Loopback (kein Mock): Handshake, Heartbeat, Snapshot/Delta,
Idempotenz, Version-Mismatch, Re-Sync nach Reconnect.
"""
from __future__ import annotations
import asyncio
from hms_protocol import (
Envelope,
HandshakeInfo,
IpcClient,
IpcServer,
MessageType,
)
def _run(coro):
return asyncio.run(coro)
async def _connected_pair(
server_caps: dict | None = None,
client_caps: dict | None = None,
):
"""Startet Server+Client und führt den Handshake durch."""
server = IpcServer()
if server_caps is not None:
server.capabilities = server_caps
port = await server.start()
client = IpcClient(port)
if client_caps is not None:
client.capabilities = client_caps
info = await client.connect()
return server, client, info
# ---------- Bindung ausschließlich Loopback (§6.2) ----------
def test_server_rejects_non_loopback_host() -> None:
try:
IpcServer(host="0.0.0.0")
raise AssertionError("0.0.0.0 muss abgelehnt werden")
except ValueError:
pass
def test_client_rejects_non_loopback_host() -> None:
try:
IpcClient(port=1234, host="192.168.1.5")
raise AssertionError("externe IP muss abgelehnt werden")
except ValueError:
pass
# ---------- Handshake (§6.2) ----------
def test_handshake_exchanges_capabilities() -> None:
async def impl() -> None:
server, client, info = await _connected_pair(
server_caps={"backend": "d3d11", "max_layers": 8},
client_caps={"role": "control_core"},
)
try:
assert isinstance(info, HandshakeInfo)
assert info.peer_name == "hms-renderer"
assert info.peer_capabilities == {"backend": "d3d11", "max_layers": 8}
assert info.protocol_version == 1
finally:
await client.disconnect()
await server.stop()
_run(impl())
def test_client_rejects_version_mismatch() -> None:
async def impl() -> None:
server = IpcServer()
port = await server.start()
client = IpcClient(port)
try:
# manipulierter Handshake mit falscher Protokollversion
from hms_protocol import encode_frame
reader, writer = await asyncio.open_connection("127.0.0.1", port)
writer.write(
encode_frame(
{
"protocol_version": 99,
"message_id": "x",
"type": "command",
"revision": 0,
"monotonic_timestamp_ns": 0,
"payload": {"action": "hello", "name": "evil", "capabilities": {}},
}
)
)
await writer.drain()
# Server antwortet mit ERROR VERSION_MISMATCH
from hms_protocol import read_frame_async
reply = await read_frame_async(reader)
assert reply["type"] == "error"
assert reply["payload"]["code"] == "VERSION_MISMATCH"
writer.close()
finally:
await client.disconnect()
await server.stop()
_run(impl())
# ---------- Nachrichtenübertragung ----------
def test_snapshot_and_delta_delivery() -> None:
async def impl() -> None:
server, client, _ = await _connected_pair()
try:
# Server sendet Snapshot
snap = Envelope(
type=MessageType.SNAPSHOT,
revision=5,
payload={"values": {"master/intensity": 1.0}},
)
await server.send(snap)
received = await asyncio.wait_for(client.receive(), timeout=2.0)
assert received is not None
assert received.type is MessageType.SNAPSHOT
assert received.revision == 5
assert received.payload["values"]["master/intensity"] == 1.0
# danach Delta mit höherer Revision
delta = Envelope(
type=MessageType.EVENT,
revision=6,
payload={"changes": {"master/intensity": 0.5}},
)
await server.send(delta)
received2 = await asyncio.wait_for(client.receive(), timeout=2.0)
assert received2 is not None
assert received2.revision == 6
assert received2.payload["changes"]["master/intensity"] == 0.5
finally:
await client.disconnect()
await server.stop()
_run(impl())
def test_bidirectional_commands_and_acks() -> None:
async def impl() -> None:
server, client, _ = await _connected_pair()
try:
# Client → Server: Command
cmd = Envelope(
type=MessageType.COMMAND,
revision=10,
payload={"action": "parameter.set", "path": "master/intensity", "value": 0.7},
)
await client.send(cmd)
received = await asyncio.wait_for(server.receive(), timeout=2.0)
assert received is not None
assert received.type is MessageType.COMMAND
assert received.payload["action"] == "parameter.set"
# Server → Client: Ack
ack = Envelope(
type=MessageType.ACK,
revision=11,
payload={"ack_for": received.message_id, "status": "ok"},
)
await server.send(ack)
ack_received = await asyncio.wait_for(client.receive(), timeout=2.0)
assert ack_received is not None
assert ack_received.type is MessageType.ACK
assert ack_received.payload["ack_for"] == received.message_id
finally:
await client.disconnect()
await server.stop()
_run(impl())
# ---------- Heartbeat (§6.2: mindestens alle 500 ms) ----------
def test_heartbeat_flows_bidirectionally() -> None:
async def impl() -> None:
server, client, _ = await _connected_pair()
try:
await asyncio.sleep(0.7) # > Heartbeat-Intervall
# Client empfängt Server-Heartbeat
got_server_hb = False
for _ in range(4):
msg = await asyncio.wait_for(client.receive(), timeout=1.5)
if msg is not None and msg.type is MessageType.HEARTBEAT:
got_server_hb = True
break
assert got_server_hb, "Client musste einen Heartbeat empfangen"
assert client.peer_alive, "Server-Heartbeat muss peer_alive setzen"
# Server empfängt Client-Heartbeat
await asyncio.sleep(0.1)
got_client_hb = False
for _ in range(6):
msg = await asyncio.wait_for(server.receive(), timeout=1.5)
if msg is not None and msg.type is MessageType.HEARTBEAT:
got_client_hb = True
break
assert got_client_hb, "Server musste einen Heartbeat empfangen"
assert server.peer_alive
finally:
await client.disconnect()
await server.stop()
_run(impl())
# ---------- Re-Sync nach Reconnect (§6.2, §29.2) ----------
def test_reconnect_requires_new_snapshot() -> None:
async def impl() -> None:
server = IpcServer()
port = await server.start()
try:
# erste Verbindung: Snapshot Revision 3
client1 = IpcClient(port)
await client1.connect()
await server.send(
Envelope(type=MessageType.SNAPSHOT, revision=3, payload={"values": {}})
)
snap1 = await asyncio.wait_for(client1.receive(), timeout=2.0)
assert snap1 is not None and snap1.revision == 3
await client1.disconnect()
# zweite Verbindung: neuer Snapshot (Re-Sync) mit Revision 4
client2 = IpcClient(port)
info2 = await client2.connect() # noqa: F841 Handshake reicht
await server.send(
Envelope(type=MessageType.SNAPSHOT, revision=4, payload={"values": {"a": 1}})
)
snap2 = await asyncio.wait_for(client2.receive(), timeout=2.0)
assert snap2 is not None
assert snap2.revision == 4 # Deltas erst nach erfolgreichem Re-Sync
await client2.disconnect()
finally:
await server.stop()
_run(impl())
def test_receive_returns_none_on_disconnect() -> None:
async def impl() -> None:
server, client, _ = await _connected_pair()
await client.disconnect()
msg = await asyncio.wait_for(server.receive(), timeout=2.0)
assert msg is None # Verbindung weg → sauberes None statt Exception
await server.stop()
_run(impl())
@@ -0,0 +1,142 @@
"""Integrationstests Projekt-/Media-/Plugin-API (PLAN.md §23.1, §13.3)."""
from __future__ import annotations
import io
import pytest
from fastapi.testclient import TestClient
from hms_control_server import create_app
@pytest.fixture()
def client() -> TestClient:
return TestClient(create_app())
# ---------- Projekte (§23.1, §24.2) ----------
def test_project_crud_roundtrip(client: TestClient) -> None:
"""Projekt anlegen → laden → aktualisieren → löschen (§23.1)."""
created = client.post("/api/v1/projects", json={"name": "Test Show"})
assert created.status_code == 200
project = created.json()["project"]
assert project["name"] == "Test Show"
assert project["schema_version"] == 1
listed = client.get("/api/v1/projects")
assert listed.status_code == 200
assert any(p["name"] == "Test Show" for p in listed.json()["projects"])
fetched = client.get(f"/api/v1/projects/{project['id']}")
assert fetched.status_code == 200
assert fetched.json()["name"] == "Test Show"
updated = client.put(f"/api/v1/projects/{project['id']}", json={"name": "Umbenannt"})
assert updated.status_code == 200
refetched = client.get(f"/api/v1/projects/{project['id']}")
assert refetched.json()["name"] == "Umbenannt"
deleted = client.delete(f"/api/v1/projects/{project['id']}")
assert deleted.status_code == 200
assert client.get(f"/api/v1/projects/{project['id']}").status_code == 404
def test_project_404_on_unknown(client: TestClient) -> None:
assert client.get("/api/v1/projects/00000000-0000-0000-0000-000000000000").status_code == 404
assert client.put(
"/api/v1/projects/00000000-0000-0000-0000-000000000000", json={}
).status_code == 404
def test_project_activate(client: TestClient) -> None:
"""Aktivieren setzt State-Revision im Store (§6.4)."""
created = client.post("/api/v1/projects", json={"name": "Show A"})
project_id = created.json()["project"]["id"]
activated = client.post(f"/api/v1/projects/{project_id}/activate")
assert activated.status_code == 200
assert activated.json()["state_revision"] >= 1
# Projekt aktivieren mit unbekannter ID → 404
unknown = "00000000-0000-0000-0000-000000000000"
assert client.post(f"/api/v1/projects/{unknown}/activate").status_code == 404
# ---------- Medien (§13.3) ----------
def test_media_import_and_list(client: TestClient) -> None:
"""Upload mit Duplikaterkennung: erst importiert, dann duplicate (§13.3)."""
content = b"fake-video-bytes-for-testing-only"
# 1. Import
r1 = client.post(
"/api/v1/media/import",
files={"file": ("test.mp4", io.BytesIO(content), "video/mp4")},
)
assert r1.status_code == 200
body1 = r1.json()
assert body1["status"] == "imported"
assert body1["asset"]["rel_path"] == "test.mp4"
assert body1["asset"]["content_hash"]
# 2. Duplikat (gleicher Inhalt)
r2 = client.post(
"/api/v1/media/import",
files={"file": ("kopie.mp4", io.BytesIO(content), "video/mp4")},
)
assert r2.status_code == 200
body2 = r2.json()
assert body2["status"] == "duplicate"
assert body2["duplicate_of"] == body1["asset"]["id"]
# 3. Liste zeigt beide, markiert nichts fehlend
listed = client.get("/api/v1/media")
assert listed.status_code == 200
assert len(listed.json()["media"]) >= 1
assert listed.json()["missing_count"] == 0
def test_media_list_empty(client: TestClient) -> None:
listed = client.get("/api/v1/media")
assert listed.status_code == 200
assert "media" in listed.json()
# ---------- Plugins (§14.5) ----------
def test_plugins_listed_by_state(client: TestClient) -> None:
"""Plugins erscheinen nach Lifecycle-Status gruppiert (§14.5)."""
listed = client.get("/api/v1/plugins")
assert listed.status_code == 200
body = listed.json()
assert body["total"] >= 24 # alle Builtin-Plugins gefunden
assert "validated" in body["plugins_by_state"]
generators = [
p for p in body["plugins_by_state"].get("validated", [])
if p["kind"] == "generator"
]
filters = [
p for p in body["plugins_by_state"].get("validated", [])
if p["kind"] == "filter"
]
assert len(generators) == 10 # §15.1
assert len(filters) == 14 # §15.2
def test_plugin_enable_unknown_returns_404(client: TestClient) -> None:
"""Unbekanntes Plugin → 404, kein Absturz (§14.5)."""
r = client.post("/api/v1/plugins/com.gibts.nicht/enable")
assert r.status_code == 404
# ---------- Diagnostics ----------
def test_diagnostics_reports_revisions(client: TestClient) -> None:
"""Diagnostics zeigt State- und Projekt-Revisionen (§28.2)."""
r = client.get("/api/v1/diagnostics")
assert r.status_code == 200
body = r.json()
assert "state_revision" in body
assert "project_revision" in body
@@ -0,0 +1,178 @@
"""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())