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).
267 lines
8.7 KiB
Python
267 lines
8.7 KiB
Python
"""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())
|