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:
@@ -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())
|
||||
Reference in New Issue
Block a user