from __future__ import annotations import inspect import json import struct import threading import time from collections.abc import Callable from pathlib import Path import lz4.block import pytest import k1link.device_plugins.xgrids_k1.viewer.runtime as runtime_module from k1link.data_plane import ( ConsumerFrameContext, DecodedDeviceStatusView, DecodedPointCloudView, DecodedPoseView, NormalizationError, ) from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage from k1link.device_plugins.xgrids_k1.viewer.runtime import VisualizationRuntime from k1link.viewer.rerun_bridge import RerunBridge class FakeRecording: def __init__(self) -> None: self.logs: list[tuple[str, object, bool]] = [] def serve_grpc(self, **_: object) -> str: return "rerun+http://127.0.0.1:9876/proxy" def log(self, path: str, entity: object, *, static: bool = False) -> None: self.logs.append((path, entity, static)) def set_time(self, _timeline: str, **_value: object) -> None: return def send_blueprint(self, _blueprint: object, **_: object) -> None: return def disconnect(self) -> None: return def flush(self, **_: object) -> None: return def _message( topic: str, payload: bytes, *, source: str = "live_mqtt", ) -> StreamMessage: return StreamMessage( sequence=9, topic=topic, payload=payload, received_at_epoch_ns=1_784_124_315_186_225_000, received_monotonic_ns=100, source=source, ) def _varint(value: int) -> bytes: encoded = bytearray() while value > 0x7F: encoded.append((value & 0x7F) | 0x80) value >>= 7 encoded.append(value) return bytes(encoded) def _key(number: int, wire_type: int) -> bytes: return _varint((number << 3) | wire_type) def _uint(number: int, value: int) -> bytes: return _key(number, 0) + _varint(value) def _sint(number: int, value: int) -> bytes: zigzag = (value << 1) ^ (value >> 63) return _uint(number, zigzag & 0xFFFFFFFFFFFFFFFF) def _bytes(number: int, value: bytes) -> bytes: return _key(number, 2) + _varint(len(value)) + value def _fixed32(number: int, value: float) -> bytes: return _key(number, 5) + struct.pack(" bytes: return _key(number, 1) + struct.pack(" bytes: return b"".join( ( _uint(1, 7), _sint(2, 123456), _sint(3, 1000), _bytes(4, b"device-redacted"), _bytes(5, b"session-redacted"), ) ) def _lio_point_payload() -> bytes: point = _sint(1, 1000) + _sint(2, -2000) + _sint(3, 500) + _uint(4, 0x44) report = _bytes(1, _lio_header()) + _bytes(2, point) compressed = lz4.block.compress(report, store_size=False) return _uint(3, len(report)) + _bytes(4, compressed) def _lio_pose_payload() -> bytes: position = _fixed64(1, 1.25) + _fixed64(2, -2.5) + _fixed64(3, 3.75) orientation = _fixed64(1, 0.0) + _fixed64(2, 0.0) + _fixed64(3, 0.0) + _fixed64(4, 1.0) pose = _bytes(1, position) + _bytes(2, orientation) stamped = _sint(1, 987654321) + _bytes(2, pose) return _bytes(1, _lio_header()) + _bytes(2, stamped) + _fixed32(3, 12.5) + _fixed32(4, 0.001) def _context() -> ConsumerFrameContext: return ConsumerFrameContext( sequence=1, captured_at_epoch_ns=1, received_monotonic_ns=None, processing_started_monotonic_ns=1, encoded_size_bytes=1, live=False, ) def test_k1_normalizer_emits_transport_neutral_point_clouds() -> None: modern = normalize_k1_message( _message("lixel/application/report/lio_pcl", _lio_point_payload()), processing_started_monotonic_ns=50, ) legacy_payload = struct.pack(" None: modern = normalize_k1_message( _message("lixel/application/report/lio_pose", _lio_pose_payload()), processing_started_monotonic_ns=50, ) legacy_payload = struct.pack(" None: with pytest.raises(NormalizationError) as caught: normalize_k1_message( _message("RealtimePointcloud", b"short"), processing_started_monotonic_ns=1, ) assert "RealtimePointcloud" not in str(caught.value) assert ( normalize_k1_message( _message("lixel/application/report/unverified", b"opaque"), processing_started_monotonic_ns=1, ) is None ) # The status channel is observed but its payload schema is not verified; # fail closed instead of inventing normalized status fields. assert ( normalize_k1_message( _message("lixel/application/report/device_status", b"opaque"), processing_started_monotonic_ns=1, ) is None ) def test_canonical_contracts_reject_misaligned_data_and_support_status() -> None: with pytest.raises(ValueError, match="RGB byte count"): DecodedPointCloudView( context=_context(), frame_id="map", positions_xyz=((0.0, 0.0, 0.0),), colors_rgb=b"\x00\x01", ) status = DecodedDeviceStatusView(context=_context(), state="ready") assert status.state == "ready" def test_rerun_bridge_source_has_no_vendor_protocol_or_raw_transport_knowledge() -> None: source = inspect.getsource(__import__("k1link.viewer.rerun_bridge", fromlist=["*"])) for forbidden in ( "k1link.device_plugins.xgrids_k1.protocol", "StreamMessage", "RealtimePointcloud", "RealtimePath", "lio_pcl", "lio_pose", ".topic", ".payload", ): assert forbidden not in source def test_visual_runtime_has_no_implicit_vendor_normalizer() -> None: source = inspect.getsource( __import__( "k1link.device_plugins.xgrids_k1.viewer.runtime", fromlist=["*"], ) ) assert "k1link.device_plugins.xgrids_k1.protocol" not in source assert "normalize_k1_message" not in source assert "normalizer: CanonicalNormalizer" in source def test_runtime_counts_transport_and_normalization_failures_before_rerun( tmp_path: Path, ) -> None: topic = b"RealtimePointcloud" payload = b"short" capture = tmp_path / "mqtt.raw.k1mqtt" capture.write_bytes(RAW_MAGIC + FRAME_HEADER.pack(len(topic), len(payload)) + topic + payload) recording = FakeRecording() def bridge_factory(**kwargs: object) -> RerunBridge: return RerunBridge( recording_factory=lambda _: recording, # type: ignore[arg-type] **kwargs, # type: ignore[arg-type] ) runtime = VisualizationRuntime( bridge_factory=bridge_factory, normalizer=normalize_k1_message, ) runtime.start_replay(capture, speed=0.0) deadline = time.monotonic() + 5.0 while runtime.snapshot()["phase"] != "idle" and time.monotonic() < deadline: time.sleep(0.01) snapshot = runtime.snapshot() assert snapshot["phase"] == "idle" assert snapshot["metrics"]["messages_received"] == 1 assert snapshot["metrics"]["payload_bytes"] == len(payload) assert snapshot["metrics"]["decode_errors"] == 1 assert snapshot["metrics"]["pcl_frames"] == 0 assert not any(path == "/world/points" for path, _, _ in recording.logs) runtime.close() def test_live_runtime_surfaces_preamble_failure_as_terminal_error( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: def fail_preamble(*_: object, **__: object) -> None: raise OSError("synthetic evidence directory failure") monkeypatch.setattr(runtime_module, "_write_live_session_preamble", fail_preamble) runtime = VisualizationRuntime(normalizer=normalize_k1_message) with pytest.raises(RuntimeError, match="session clock"): runtime.start_live( "192.168.1.20", tmp_path / "unwritable-evidence", duration_seconds=1.0, project_name="Preamble failure test", ) deadline = time.monotonic() + 2.0 snapshot = runtime.snapshot() while snapshot["phase"] == "starting_live" and time.monotonic() < deadline: time.sleep(0.01) snapshot = runtime.snapshot() assert snapshot["phase"] == "error" assert snapshot["source_mode"] == "live" assert snapshot["source_ready"] is False assert "synthetic evidence directory failure" in snapshot["message"] runtime.close() def test_live_start_waits_for_capture_clock_before_returning( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: entered_capture = threading.Event() allow_clock = threading.Event() returned = threading.Event() failures: list[BaseException] = [] recording = FakeRecording() def bridge_factory(**kwargs: object) -> RerunBridge: return RerunBridge( recording_factory=lambda _: recording, # type: ignore[arg-type] **kwargs, # type: ignore[arg-type] ) def delayed_capture( _host: str, _out_dir: Path, *, on_clock_established: Callable[[], None], should_stop: Callable[[], bool], **_kwargs: object, ) -> dict[str, object]: entered_capture.set() assert allow_clock.wait(timeout=2.0) on_clock_established() while not should_stop(): time.sleep(0.005) return {"message_count": 0} monkeypatch.setattr(runtime_module, "capture_mqtt", delayed_capture) runtime = VisualizationRuntime( bridge_factory=bridge_factory, normalizer=normalize_k1_message, ) def start() -> None: try: runtime.start_live( "192.168.1.20", tmp_path / "delayed-clock", duration_seconds=30.0, project_name="Clock barrier test", ) except BaseException as exc: failures.append(exc) finally: returned.set() caller = threading.Thread(target=start) caller.start() assert entered_capture.wait(timeout=2.0) assert not returned.wait(timeout=0.05) allow_clock.set() assert returned.wait(timeout=2.0) assert failures == [] runtime.stop() caller.join(timeout=2.0) runtime.close() def test_live_session_manifest_retains_project_display_name(tmp_path: Path) -> None: out_dir = tmp_path / "evidence-session" runtime_module._write_live_session_preamble( out_dir, "192.168.1.20", 60.0, "K1 Route Alpha", ) manifest = json.loads((out_dir / "manifest.redacted.json").read_text(encoding="utf-8")) assert manifest["project_name"] == "K1 Route Alpha" assert "192.168.1.20" not in json.dumps(manifest)