NODEDC_MISSION_CORE/tests/test_canonical_pipeline.py

315 lines
10 KiB
Python

from __future__ import annotations
import inspect
import struct
import time
from pathlib import Path
import lz4.block
import pytest
import k1link.viewer.runtime as runtime_module
from k1link.data_plane import (
ConsumerFrameContext,
DecodedDeviceStatusView,
DecodedPointCloudView,
DecodedPoseView,
NormalizationError,
)
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.protocol.normalizer import normalize_k1_message
from k1link.viewer.messages import StreamMessage
from k1link.viewer.rerun_bridge import RerunBridge
from k1link.viewer.runtime import VisualizationRuntime
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("<f", value)
def _fixed64(number: int, value: float) -> bytes:
return _key(number, 1) + struct.pack("<d", value)
def _lio_header() -> 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("<III", 16, 0, 0) + struct.pack(
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
)
legacy = normalize_k1_message(
_message("RealtimePointcloud", legacy_payload, source="legacy_tsv"),
processing_started_monotonic_ns=60,
)
assert isinstance(modern, DecodedPointCloudView)
assert modern.positions_xyz == ((1.0, -2.0, 0.5),)
assert modern.intensities == bytes((0x44,))
assert modern.colors_rgb is None
assert modern.context.source_device_alias == "device-redacted"
assert modern.context.source_session_alias == "session-redacted"
assert modern.context.live is True
assert not hasattr(modern, "topic")
assert not hasattr(modern, "payload")
assert isinstance(legacy, DecodedPointCloudView)
assert legacy.positions_xyz == ((1.0, -2.0, 3.0),)
assert legacy.intensities == bytes((40,))
assert legacy.colors_rgb == bytes((10, 20, 30))
assert legacy.context.source_device_alias is None
assert legacy.context.live is False
def test_k1_normalizer_emits_transport_neutral_poses() -> None:
modern = normalize_k1_message(
_message("lixel/application/report/lio_pose", _lio_pose_payload()),
processing_started_monotonic_ns=50,
)
legacy_payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 1.0, 0.0, 0.0, 0.0)
legacy = normalize_k1_message(
_message("RealtimePath", legacy_payload, source="legacy_tsv"),
processing_started_monotonic_ns=60,
)
assert isinstance(modern, DecodedPoseView)
assert modern.frame_id == "map"
assert modern.child_frame_id == "sensor"
assert modern.position_xyz == (1.25, -2.5, 3.75)
assert modern.orientation_xyzw == (0.0, 0.0, 0.0, 1.0)
assert modern.context.source_device_alias == "device-redacted"
assert modern.context.source_session_alias == "session-redacted"
assert isinstance(legacy, DecodedPoseView)
assert legacy.position_xyz == (1.0, 2.0, 3.0)
assert legacy.orientation_xyzw == (0.0, 0.0, 0.0, 1.0)
assert legacy.context.source_device_alias is None
def test_known_invalid_and_unknown_channels_have_distinct_outcomes() -> 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.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.viewer.runtime", fromlist=["*"]))
assert "k1link.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)
runtime.start_live(
"192.168.1.20",
tmp_path / "unwritable-evidence",
duration_seconds=1.0,
)
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()