feat(k1): complete primary acquisition lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 23:03:59 +03:00
parent 9d51080d2e
commit aa3680948f
66 changed files with 6093 additions and 544 deletions
+87 -5
View File
@@ -1,8 +1,11 @@
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
@@ -301,11 +304,13 @@ def test_live_runtime_surfaces_preamble_failure_as_terminal_error(
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,
)
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:
@@ -317,3 +322,80 @@ def test_live_runtime_surfaces_preamble_failure_as_terminal_error(
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)
+31 -3
View File
@@ -104,7 +104,8 @@ def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity()
assert hook_source.index("xgridsK1Api.prepareAcquisition") < hook_source.index(
"xgridsK1Api.startAcquisition"
)
assert 'mode: "capture-only"' in hook_source
assert "isSoftwareCommandedAcquisition(state)" in hook_source
assert '? "graceful" : "capture-only"' in hook_source
assert "state.device_ref" in runtime_source
assert "instanceId: deviceRef.device_id" in runtime_source
assert "acquisition?.acquisition_id" in runtime_source
@@ -124,7 +125,34 @@ def test_xgrids_live_copy_does_not_claim_software_controls_the_physical_scanner(
/ "K1AcquisitionPipeline.tsx"
).read_text("utf-8")
assert "Подготовить приём данных" in connection_source
assert "Программная команда запуска на K1 пока не отправляется" in connection_source
assert "Подготовить локальный приём данных" in connection_source
assert "Программная команда запуска на K1 не отправляется" in connection_source
assert "Остановить локальный приём" in connection_source
assert "Физическое состояние сканера остаётся неизвестным" in connection_source
def test_xgrids_start_uses_atomic_automatic_source_transition() -> None:
repository_root = Path(__file__).resolve().parents[1]
pipeline_source = (
repository_root
/ "plugins"
/ "xgrids-k1"
/ "frontend"
/ "src"
/ "components"
/ "K1AcquisitionPipeline.tsx"
).read_text("utf-8")
transition_source = (
repository_root / "plugins" / "xgrids-k1" / "frontend" / "src" / "automaticSourceStart.ts"
).read_text("utf-8")
assert pipeline_source.count("runAutomaticSpatialSourceStart(") == 2
assert transition_source.index("const started = await start();") < (
transition_source.index("activateAutomaticSpatialSource();")
)
assert transition_source.index("if (!started) return false;") < (
transition_source.index("activateAutomaticSpatialSource();")
)
assert transition_source.index("activateAutomaticSpatialSource();") < (
transition_source.index("openSpatialScene();")
)
+87 -1
View File
@@ -19,6 +19,8 @@ from k1link.device_plugins.xgrids_k1.mqtt.capture import (
CaptureFormatError,
capture_mqtt,
iter_capture_frames,
read_capture_clock_envelope,
seal_capture_clock,
validate_private_ipv4,
)
@@ -97,11 +99,17 @@ def test_validate_private_ipv4_rejects_other_targets(address: str) -> None:
def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -> None:
fake = FakeClient()
observed = []
clock_ready: list[bool] = []
def on_clock_established() -> None:
origin = tmp_path / "capture" / "mqtt.timeline.origin.json"
clock_ready.append(origin.is_file() and bool(origin.read_bytes()))
summary = capture_mqtt(
"192.168.1.50",
tmp_path / "capture",
duration_seconds=30,
on_clock_established=on_clock_established,
on_message_recorded=observed.append,
_client_factory=lambda: cast(mqtt.Client, fake),
)
@@ -109,6 +117,7 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -
assert fake.connect_calls == [("192.168.1.50", 1883, 30)]
assert fake.subscribe_calls == [[(topic, 0) for topic in REPORT_TOPICS]]
assert fake.disconnect_count == 1
assert clock_ready == [True]
assert summary["stop_reason"] == "keyboard_interrupt"
assert summary["message_count"] == 1
assert summary["payload_bytes"] == len(fake.payload)
@@ -151,8 +160,26 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -
saved_summary = json.loads((capture_dir / "mqtt.summary.json").read_text())
assert saved_summary == summary
assert saved_summary["schema_version"] == 2
assert saved_summary["artifact_hashes"]["raw_sha256"] == hashlib.sha256(raw).hexdigest()
for artifact_name in ("mqtt.raw.k1mqtt", "mqtt.metadata.jsonl", "mqtt.summary.json"):
clock = read_capture_clock_envelope(
capture_dir / "mqtt.timeline.json",
expected_sha256=saved_summary["artifact_hashes"]["capture_clock_sha256"],
)
assert clock.started_monotonic_ns <= observed[0].received_monotonic_ns
assert observed[0].received_monotonic_ns <= clock.completed_monotonic_ns
assert clock.duration_ns > 0
assert saved_summary["session_elapsed_seconds"] == pytest.approx(
clock.duration_ns / 1_000_000_000
)
assert saved_summary["artifacts"]["capture_clock"] == "mqtt.timeline.json"
for artifact_name in (
"mqtt.raw.k1mqtt",
"mqtt.metadata.jsonl",
"mqtt.timeline.origin.json",
"mqtt.timeline.json",
"mqtt.summary.json",
):
assert stat.S_IMODE((capture_dir / artifact_name).stat().st_mode) == 0o600
@@ -216,6 +243,65 @@ def test_capture_can_be_stopped_by_owner_without_losing_artifacts(tmp_path: Path
assert list(iter_capture_frames(tmp_path / "capture" / "mqtt.raw.k1mqtt"))[0].payload
def test_owner_seals_session_clock_after_all_producers_stop(tmp_path: Path) -> None:
capture_dir = tmp_path / "capture"
summary = capture_mqtt(
"192.168.1.50",
capture_dir,
duration_seconds=30,
_client_factory=lambda: cast(mqtt.Client, FakeClient()),
)
provisional = read_capture_clock_envelope(capture_dir / "mqtt.timeline.json")
assert summary["capture_clock_scope"] == "transport"
sealed = seal_capture_clock(capture_dir)
updated = json.loads((capture_dir / "mqtt.summary.json").read_text(encoding="utf-8"))
assert updated["capture_clock_scope"] == "session"
sealed_path = capture_dir / updated["artifacts"]["capture_clock"]
assert sealed_path.name == f"mqtt.timeline.session-{sealed.artifact_sha256}.json"
assert (capture_dir / "mqtt.timeline.json").read_bytes()
assert sealed.started_monotonic_ns == provisional.started_monotonic_ns
assert sealed.completed_monotonic_ns >= provisional.completed_monotonic_ns
assert updated["artifact_hashes"]["capture_clock_sha256"] == sealed.artifact_sha256
assert updated["session_elapsed_seconds"] == pytest.approx(sealed.duration_ns / 1_000_000_000)
repeated = seal_capture_clock(capture_dir)
repeated_summary = json.loads((capture_dir / "mqtt.summary.json").read_text(encoding="utf-8"))
assert repeated == sealed
assert repeated_summary == updated
def test_owner_seal_is_retryable_if_summary_pointer_switch_fails(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture_dir = tmp_path / "capture"
capture_mqtt(
"192.168.1.50",
capture_dir,
duration_seconds=30,
_client_factory=lambda: cast(mqtt.Client, FakeClient()),
)
provisional_summary = (capture_dir / "mqtt.summary.json").read_bytes()
with monkeypatch.context() as context:
context.setattr(
capture_module,
"_write_json_atomic_replace",
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("injected switch failure")),
)
with pytest.raises(CaptureError, match="injected switch failure"):
seal_capture_clock(capture_dir)
assert (capture_dir / "mqtt.summary.json").read_bytes() == provisional_summary
assert list(capture_dir.glob("mqtt.timeline.session-*.json"))
sealed = seal_capture_clock(capture_dir)
summary = json.loads((capture_dir / "mqtt.summary.json").read_text(encoding="utf-8"))
assert summary["capture_clock_scope"] == "session"
assert summary["artifact_hashes"]["capture_clock_sha256"] == sealed.artifact_sha256
def test_preview_failure_happens_after_raw_message_is_preserved(tmp_path: Path) -> None:
fake = FakeClient()
capture_dir = tmp_path / "capture"
+2
View File
@@ -137,6 +137,8 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
},
{"id": "spatial.point-cloud.live", "label": "Облако точек"},
{"id": "spatial.pose.live", "label": "Траектория"},
{"id": "device.modeling.live", "label": "Метрики маршрута"},
{"id": "device.status.live", "label": "Состояние сканирования"},
{"id": "camera.preview.live", "label": "Видеокамеры"},
{"id": "evidence.raw-capture", "label": "Исходная запись"},
{"id": "evidence.replay", "label": "Повтор записи"},
+9 -13
View File
@@ -69,11 +69,14 @@ class FakeXgridsService:
def start_live(
self,
project_name: str,
host: str | None,
duration_seconds: float,
compatibility_attestation: CompatibilityAttestationRequest,
) -> dict[str, Any]:
self.calls.append(("live", (host, duration_seconds, compatibility_attestation)))
self.calls.append(
("live", (project_name, host, duration_seconds, compatibility_attestation))
)
return {"phase": "live"}
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
@@ -344,9 +347,7 @@ def test_environment_shutdown_attempts_every_plugin_after_close_failure(
def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
service = FakeXgridsService()
dispatcher = DevicePluginDispatcher(
[_in_process_runtime(XgridsK1PluginFacade(service))]
)
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
state = asyncio.run(
dispatcher.invoke(
@@ -373,9 +374,7 @@ def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
def test_facade_validates_payload_before_calling_service() -> None:
service = FakeXgridsService()
dispatcher = DevicePluginDispatcher(
[_in_process_runtime(XgridsK1PluginFacade(service))]
)
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
with pytest.raises(ValidationError):
asyncio.run(
@@ -400,6 +399,7 @@ def test_facade_validates_payload_before_calling_service() -> None:
(
ACTION_STREAM_START_LIVE,
{
"project_name": "Plugin runtime test",
"host": "192.168.1.20",
"compatibility_attestation": {
"firmware_version": "3.0.2",
@@ -437,9 +437,7 @@ def test_facade_preserves_existing_runtime_operations(
expected_call: str,
) -> None:
service = FakeXgridsService()
dispatcher = DevicePluginDispatcher(
[_in_process_runtime(XgridsK1PluginFacade(service))]
)
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, action_id, payload))
@@ -455,9 +453,7 @@ def test_sync_runtime_actions_run_outside_the_api_event_loop() -> None:
return super().stop()
service = ThreadAwareService()
dispatcher = DevicePluginDispatcher(
[_in_process_runtime(XgridsK1PluginFacade(service))]
)
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
event_loop_thread = threading.get_ident()
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {}))
+2 -1
View File
@@ -389,7 +389,8 @@ def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) ->
runtime.start_replay(capture, speed=0.0)
assert factory_entered.wait(timeout=2.0)
runtime.close(wait_seconds=0.01)
with pytest.raises(RuntimeError, match="evidence lease сохранены"):
runtime.close(wait_seconds=0.01)
release_factory.set()
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline and not recording.disconnected:
+170 -15
View File
@@ -11,8 +11,13 @@ from pathlib import Path
import pytest
import k1link.device_plugins.xgrids_k1.rrd_export as export_module
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.device_plugins.xgrids_k1.mqtt.capture import (
CAPTURE_CLOCK_FILENAME,
FRAME_HEADER,
RAW_MAGIC,
)
from k1link.device_plugins.xgrids_k1.rrd_export import (
RECORDED_METRICS_VIEW_ID,
RECORDED_POINTS_VISUALIZER_ID,
RECORDED_ROOT_CONTAINER_ID,
RECORDED_SPATIAL_VIEW_ID,
@@ -43,9 +48,41 @@ def _pose_payload(x: float) -> bytes:
return struct.pack("<ffffffff", x, 2.0, 3.0, 99.0, 1.0, 0.0, 0.0, 0.0)
def _varint(value: int) -> bytes:
encoded = bytearray()
while value > 0x7F:
encoded.append((value & 0x7F) | 0x80)
value >>= 7
encoded.append(value)
return bytes(encoded)
def _proto_key(number: int, wire_type: int) -> bytes:
return _varint((number << 3) | wire_type)
def _proto_uint(number: int, value: int) -> bytes:
return _proto_key(number, 0) + _varint(value)
def _proto_bytes(number: int, value: bytes) -> bytes:
return _proto_key(number, 2) + _varint(len(value)) + value
def _proto_float32(number: int, value: float) -> bytes:
return _proto_key(number, 5) + struct.pack("<f", value)
def _modeling_payload(*, distance: float, speed: float, scan_ticks: int) -> bytes:
status = _proto_float32(1, distance) + _proto_float32(2, speed) + _proto_uint(3, scan_ticks)
return _proto_uint(2, 0) + _proto_bytes(3, status)
def _write_capture(
directory: Path,
frames: list[tuple[str, bytes, int]],
*,
capture_clock_offsets_ns: tuple[int, int] | None = None,
) -> Path:
capture = directory / "mqtt.raw.k1mqtt"
raw = bytearray(RAW_MAGIC)
@@ -70,6 +107,34 @@ def _write_capture(
"".join(json.dumps(item) + "\n" for item in metadata),
encoding="utf-8",
)
if capture_clock_offsets_ns is not None:
started_offset_ns, completed_offset_ns = capture_clock_offsets_ns
(directory / "mqtt.timeline.origin.json").write_text(
json.dumps(
{
"schema_version": 1,
"started_at_epoch_ns": epoch_origin_ns + started_offset_ns,
"started_monotonic_ns": monotonic_origin_ns + started_offset_ns,
},
separators=(",", ":"),
)
+ "\n",
encoding="utf-8",
)
(directory / CAPTURE_CLOCK_FILENAME).write_text(
json.dumps(
{
"schema_version": 1,
"started_at_epoch_ns": epoch_origin_ns + started_offset_ns,
"started_monotonic_ns": monotonic_origin_ns + started_offset_ns,
"completed_at_epoch_ns": epoch_origin_ns + completed_offset_ns,
"completed_monotonic_ns": monotonic_origin_ns + completed_offset_ns,
},
separators=(",", ":"),
)
+ "\n",
encoding="utf-8",
)
return capture
@@ -139,9 +204,7 @@ def test_recorded_blueprint_accumulates_point_frames_on_session_timeline() -> No
def test_zero_accumulation_uses_latest_frame_instead_of_empty_time_range() -> None:
blueprint = _recorded_blueprint(
RerunSceneSettings(accumulation_seconds=0.0, show_grid=True)
)
blueprint = _recorded_blueprint(RerunSceneSettings(accumulation_seconds=0.0, show_grid=True))
spatial_view = blueprint.root_container.contents[0]
assert "VisibleTimeRanges" not in spatial_view.properties
@@ -149,9 +212,7 @@ def test_zero_accumulation_uses_latest_frame_instead_of_empty_time_range() -> No
trajectory_behavior = spatial_view.visualizer_overrides["/world/trajectory"]
assert point_behavior.visible.as_arrow_array().to_pylist() == [True]
assert trajectory_behavior.visible.as_arrow_array().to_pylist() == [True]
point_components = {
str(batch.component_descriptor()) for batch in point_visualizer.overrides
}
point_components = {str(batch.component_descriptor()) for batch in point_visualizer.overrides}
assert point_components == {"Points3D:radii"}
@@ -171,10 +232,10 @@ def test_dynamic_blueprint_reuses_scene_ids_without_playback_mutation() -> None:
first_view = first.root_container.contents[0]
second_view = second.root_container.contents[0]
assert first_view.id == second_view.id == RECORDED_SPATIAL_VIEW_ID
assert first.root_container.contents[1].id == RECORDED_METRICS_VIEW_ID
assert second.root_container.contents[1].id == RECORDED_METRICS_VIEW_ID
first_point_behavior, first_point_visualizer = first_view.visualizer_overrides[
"/world/points"
]
first_point_behavior, first_point_visualizer = first_view.visualizer_overrides["/world/points"]
second_point_behavior, second_point_visualizer = second_view.visualizer_overrides[
"/world/points"
]
@@ -209,6 +270,7 @@ def test_export_preserves_every_decodable_frame_and_source_timeline(tmp_path: Pa
("RealtimePath", _pose_payload(1.2), 1_200_000_000),
("lixel/application/report/heartbeat", b"opaque-tail", 9_500_000_000),
],
capture_clock_offsets_ns=(-500_000_000, 10_000_000_000),
)
output = tmp_path / "session.rrd"
@@ -222,13 +284,13 @@ def test_export_preserves_every_decodable_frame_and_source_timeline(tmp_path: Pa
assert summary["points"] == 2
assert summary["trajectory_poses"] == 2
assert summary["trajectory_updates"] == 2
assert summary["session_origin_monotonic_ns"] == 9_000_000_000
assert summary["session_origin_monotonic_ns"] == 8_500_000_000
assert summary["timeline"] == "session_time"
assert summary["timeline_start_ns"] == 0
assert summary["timeline_end_ns"] == 1_200_000_000
assert summary["timeline_span_ns"] == 1_200_000_000
assert summary["first_decoded_time_ns"] == 100_000_000
assert summary["last_decoded_time_ns"] == 1_200_000_000
assert summary["timeline_end_ns"] == 10_500_000_000
assert summary["timeline_span_ns"] == 10_500_000_000
assert summary["first_decoded_time_ns"] == 600_000_000
assert summary["last_decoded_time_ns"] == 1_700_000_000
assert summary["source_sha256"] == _sha256(capture)
assert summary["rrd_bytes"] == output.stat().st_size
assert summary["rrd_sha256"] == _sha256(output)
@@ -240,6 +302,99 @@ def test_export_preserves_every_decodable_frame_and_source_timeline(tmp_path: Pa
assert "session_time" in origin_table
assert "P0D" in origin_table
assert "[true]" in origin_table
end_table = _print_rrd_entity(output, "/__mission_core/session_end")
assert "/__mission_core/session_end" in end_table
assert "PT10.5S" in end_table
def test_export_records_device_route_time_series_on_shared_timeline(tmp_path: Path) -> None:
capture = _write_capture(
tmp_path,
[
("RealtimePointcloud", _point_payload(1.0), 0),
(
"lixel/application/report/modeling",
_modeling_payload(distance=2.25, speed=0.75, scan_ticks=20),
500_000_000,
),
(
"lixel/application/report/modeling",
_modeling_payload(distance=3.5, speed=1.25, scan_ticks=22),
1_000_000_000,
),
],
)
output = tmp_path / "session.rrd"
summary = export_k1mqtt_to_rrd(capture, output)
assert summary["modeling_reports"] == 2
assert summary["decoded_messages"] == 1
assert summary["ignored_messages"] == 0
assert summary["timeline_end_ns"] == 1_000_000_000
distance_table = _print_rrd_entity(output, "/metrics/device/route_distance_meters")
elapsed_table = _print_rrd_entity(output, "/metrics/device/scan_elapsed_seconds")
assert "2.25" in distance_table and "3.5" in distance_table
assert "10.0" in elapsed_table and "11.0" in elapsed_table
def test_export_uses_explicit_catalog_bound_sealed_clock(tmp_path: Path) -> None:
capture = _write_capture(
tmp_path,
[("RealtimePointcloud", _point_payload(1.0), 500_000_000)],
)
origin = {
"schema_version": 1,
"started_at_epoch_ns": 1_784_124_314_000_000_000,
"started_monotonic_ns": 8_000_000_000,
}
origin_path = tmp_path / "mqtt.timeline.origin.json"
origin_path.write_text(json.dumps(origin, separators=(",", ":")) + "\n")
provisional = {
**origin,
"completed_at_epoch_ns": 1_784_124_316_000_000_000,
"completed_monotonic_ns": 10_000_000_000,
}
(tmp_path / "mqtt.timeline.json").write_text(
json.dumps(provisional, separators=(",", ":")) + "\n"
)
sealed = {
**origin,
"completed_at_epoch_ns": 1_784_124_321_000_000_000,
"completed_monotonic_ns": 15_000_000_000,
}
sealed_payload = (json.dumps(sealed, separators=(",", ":")) + "\n").encode()
sealed_digest = hashlib.sha256(sealed_payload).hexdigest()
sealed_path = tmp_path / f"mqtt.timeline.session-{sealed_digest}.json"
sealed_path.write_bytes(sealed_payload)
output = tmp_path / "session.rrd"
summary = export_k1mqtt_to_rrd(
capture,
output,
capture_clock_path=sealed_path,
capture_clock_origin_path=origin_path,
)
assert summary["session_origin_monotonic_ns"] == 8_000_000_000
assert summary["timeline_end_ns"] == 7_000_000_000
def test_export_rejects_malformed_known_modeling_report(tmp_path: Path) -> None:
capture = _write_capture(
tmp_path,
[
("RealtimePointcloud", _point_payload(1.0), 0),
("lixel/application/report/modeling", b"malformed", 1_000_000),
],
)
output = tmp_path / "session.rrd"
output.write_bytes(b"trusted-existing-recording")
with pytest.raises(RrdExportError, match="modeling report"):
export_k1mqtt_to_rrd(capture, output)
assert output.read_bytes() == b"trusted-existing-recording"
def test_atomic_rename_failure_preserves_existing_recording(
+49 -13
View File
@@ -103,6 +103,7 @@ def make_recorded_h264_fixture(
*,
timescale: int = 1_000,
sample_duration: int = 500,
base_decode_time: int = 0,
) -> tuple[bytes, bytes]:
def box(box_type: bytes, payload: bytes = b"") -> bytes:
return (8 + len(payload)).to_bytes(4, "big") + box_type + payload
@@ -137,8 +138,9 @@ def make_recorded_h264_fixture(
init = box(b"ftyp", b"isom") + box(b"moov", trak + box(b"mvex", trex) + avcc)
tfhd = full_box(b"tfhd", track_id.to_bytes(4, "big"), flags=0x020000)
tfdt = full_box(b"tfdt", base_decode_time.to_bytes(4, "big"))
trun = full_box(b"trun", (1).to_bytes(4, "big"))
fragment = box(b"moof", box(b"traf", tfhd + trun)) + box(b"mdat", b"frame")
fragment = box(b"moof", box(b"traf", tfhd + tfdt + trun)) + box(b"mdat", b"frame")
return init, fragment
@@ -543,8 +545,7 @@ def test_cold_replay_returns_quick_202_then_status_returns_ready_launch(
render_file_response(viewer_response, range_header=None)
)
viewer_headers = {
key.decode("latin-1"): value.decode("latin-1")
for key, value in viewer_start["headers"]
key.decode("latin-1"): value.decode("latin-1") for key, value in viewer_start["headers"]
}
assert viewer_start["status"] == 200
assert viewer_body == payload
@@ -880,6 +881,7 @@ def test_production_router_never_materializes_recording_inline(tmp_path: Path) -
assert recording_error.value.status_code == 503
assert calls == 0
def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@@ -1065,9 +1067,7 @@ def test_session_router_exposes_opaque_recorded_media_manifest_and_ranges(
assert manifest["byte_length"] == source["byte_length"]
assert manifest["timeline_start_seconds"] == source["timeline_start_seconds"]
assert manifest["timeline_end_seconds"] == source["timeline_end_seconds"]
assert manifest_response.headers["etag"] == (
f'"sha256:{manifest["generation_sha256"]}"'
)
assert manifest_response.headers["etag"] == (f'"sha256:{manifest["generation_sha256"]}"')
init_sha256 = hashlib.sha256(init).hexdigest()
segment_sha256 = hashlib.sha256(segment).hexdigest()
assert manifest["epochs"] == [
@@ -1143,9 +1143,7 @@ def test_session_router_exposes_opaque_recorded_media_manifest_and_ranges(
}
assert segment_start["status"] == 206
assert segment_body == segment[:6]
assert headers["cache-control"] == (
"private, max-age=31536000, immutable, no-transform"
)
assert headers["cache-control"] == ("private, max-age=31536000, immutable, no-transform")
assert headers["etag"] == f'"sha256:{segment_sha256}"'
assert headers["content-length"] == "6"
assert headers["x-content-type-options"] == "nosniff"
@@ -1224,9 +1222,7 @@ def test_ready_launch_uses_background_prepared_camera_manifest_without_rescan(
manager.enqueue(store.prepare_replay(session.name))
deadline = time.monotonic() + 2
snapshot = manager.status(session.name)
while (
snapshot is None or snapshot.state != "ready"
) and time.monotonic() < deadline:
while (snapshot is None or snapshot.state != "ready") and time.monotonic() < deadline:
time.sleep(0.005)
snapshot = manager.status(session.name)
assert snapshot is not None and snapshot.state == "ready"
@@ -1276,6 +1272,10 @@ def test_recorded_media_epoch_ends_are_monotonic_generation_bound_and_not_rrd_pa
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
init, segment = make_recorded_h264_fixture(sample_duration=250)
_, following_segment = make_recorded_h264_fixture(
sample_duration=250,
base_decode_time=250,
)
first = CameraArchiveWriter(session, "sensor.camera.private-left", 1)
first.append("init", init, host_epoch_ns=1_100_000_000, host_monotonic_ns=2_100_000_000)
first.append(
@@ -1295,7 +1295,7 @@ def test_recorded_media_epoch_ends_are_monotonic_generation_bound_and_not_rrd_pa
)
second.append(
"media",
segment,
following_segment,
host_epoch_ns=2_000_000_000,
host_monotonic_ns=3_000_000_000,
)
@@ -1393,6 +1393,42 @@ def test_recorded_media_rejects_non_monotonic_segments_and_overlapping_epochs(
)
def test_recorded_media_rejects_discontinuous_tfdt_decode_timeline(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
init, first_segment = make_recorded_h264_fixture(sample_duration=500)
_, discontinuous_segment = make_recorded_h264_fixture(
sample_duration=500,
base_decode_time=1_000,
)
writer = CameraArchiveWriter(session, "sensor.camera.private-left", 1)
writer.append("init", init)
writer.append(
"media",
first_segment,
host_epoch_ns=1_250_000_000,
host_monotonic_ns=2_250_000_000,
)
writer.append(
"media",
discontinuous_segment,
host_epoch_ns=1_750_000_000,
host_monotonic_ns=2_750_000_000,
)
writer.close()
store = SessionStore(repository, data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
with pytest.raises(SessionIntegrityError, match="decode timeline is discontinuous"):
RecordedMediaInspector().inspect(
store.list_recorded_media(session.name)[0],
store.prepare_replay(session.name),
)
def test_background_preparation_fails_closed_on_unparseable_recorded_media(
tmp_path: Path,
) -> None:
+50 -4
View File
@@ -129,6 +129,40 @@ def test_materializer_reuses_only_a_digest_validated_private_cache(tmp_path: Pat
assert exporter.calls == 1
def test_materializer_rejects_changed_digest_bound_secondary_artifact(
tmp_path: Path,
) -> None:
command = _command(tmp_path / "source")
secondary = command.artifacts[1]
expected = _sha256(secondary.path)
command = replace(
command,
artifacts=(
command.artifacts[0],
replace(secondary, expected_sha256=expected),
),
)
secondary.path.write_text('{"record_type":"tampered","sequence":1}\n', encoding="utf-8")
command = replace(
command,
artifacts=(
command.artifacts[0],
replace(
command.artifacts[1],
file_byte_length=secondary.path.stat().st_size,
replay_byte_length=secondary.path.stat().st_size,
),
),
)
exporter = FakeExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
with pytest.raises(RecordingMaterializationError, match="digest.*catalog"):
materializer.materialize(command)
assert exporter.calls == 0
@pytest.mark.parametrize(
"obsolete_schema",
[
@@ -286,13 +320,25 @@ def test_materializer_exports_only_validated_prefix_before_crash_tail(
metadata = command.primary_artifact.path.with_name("mqtt.metadata.jsonl")
with metadata.open("ab") as stream:
stream.write(b'{"record_type":"message"')
observed: list[tuple[int, int]] = []
observed: list[tuple[int, int, set[str]]] = []
class PrefixExporter(FakeExporter):
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
def __call__(
self,
source: Path,
destination: Path,
*,
artifacts: dict[str, Path],
) -> dict[str, object]:
observed.append(
(source.stat().st_size, source.with_name("mqtt.metadata.jsonl").stat().st_size)
(
source.stat().st_size,
artifacts["index"].stat().st_size,
set(artifacts),
)
)
assert artifacts["primary"] == source
assert all(path.parent == source.parent for path in artifacts.values())
return super().__call__(source, destination)
exporter = PrefixExporter()
@@ -300,7 +346,7 @@ def test_materializer_exports_only_validated_prefix_before_crash_tail(
recording = materializer.materialize(command)
assert observed == [(committed_raw_bytes, committed_metadata_bytes)]
assert observed == [(committed_raw_bytes, committed_metadata_bytes, {"primary", "index"})]
assert recording.source_sha256 == hashlib.sha256(b"native-source-recording").hexdigest()
assert command.primary_artifact.path.read_bytes().endswith(b"uncommitted-raw-tail")
+189 -9
View File
@@ -100,14 +100,65 @@ def make_legacy_session(
return session
def add_capture_clock(session: Path, *, scope: str = "session") -> Path:
capture = session / "captures" / "mqtt_live"
origin_document = {
"schema_version": 1,
"started_at_epoch_ns": 1_784_235_391_699_000_000,
"started_monotonic_ns": 9_000_000_000,
}
origin_path = capture / "mqtt.timeline.origin.json"
origin_path.write_text(
json.dumps(origin_document, separators=(",", ":")) + "\n",
encoding="utf-8",
)
clock_document = {
**origin_document,
"completed_at_epoch_ns": 1_784_235_394_699_000_000,
"completed_monotonic_ns": 12_000_000_000,
}
clock_payload = (json.dumps(clock_document, separators=(",", ":")) + "\n").encode()
clock_digest = hashlib.sha256(clock_payload).hexdigest()
provisional_path = capture / "mqtt.timeline.json"
provisional_path.write_bytes(clock_payload)
clock_path = (
capture / f"mqtt.timeline.session-{clock_digest}.json"
if scope == "session"
else provisional_path
)
if clock_path != provisional_path:
clock_path.write_bytes(clock_payload)
summary_path = capture / "mqtt.summary.json"
summary = json.loads(summary_path.read_text(encoding="utf-8"))
summary["schema_version"] = 2
summary["capture_clock_scope"] = scope
summary["session_elapsed_seconds"] = 3.0
summary["artifacts"] = {
"raw": "mqtt.raw.k1mqtt",
"metadata_jsonl": "mqtt.metadata.jsonl",
"capture_clock_origin": origin_path.name,
"capture_clock": clock_path.name,
"summary": "mqtt.summary.json",
}
summary["artifact_hashes"]["capture_clock_origin_sha256"] = hashlib.sha256(
origin_path.read_bytes()
).hexdigest()
summary["artifact_hashes"]["capture_clock_sha256"] = hashlib.sha256(
clock_path.read_bytes()
).hexdigest()
summary_path.write_text(json.dumps(summary), encoding="utf-8")
return clock_path
def test_evidence_root_is_private_and_configurable(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = tmp_path / "repo"
assert resolve_missioncore_evidence_dir(repository) == (
repository / ".runtime" / "mission-core" / "evidence" / "sessions"
).resolve()
assert (
resolve_missioncore_evidence_dir(repository)
== (repository / ".runtime" / "mission-core" / "evidence" / "sessions").resolve()
)
configured = tmp_path / "external-evidence"
monkeypatch.setenv("MISSIONCORE_EVIDENCE_DIR", str(configured))
@@ -127,6 +178,35 @@ def test_catalog_reconciles_sessions_removed_from_one_evidence_root(tmp_path: Pa
assert store.list_recent().items == ()
def test_catalog_uses_valid_project_name_as_session_display_name(tmp_path: Path) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
manifest_path = session / "manifest.redacted.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["project_name"] = " K1 Route Alpha "
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
assert store.list_recent().items[0].display_name == "K1 Route Alpha"
def test_catalog_rejects_non_utf8_project_display_name(tmp_path: Path) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
manifest_path = session / "manifest.redacted.json"
manifest_path.write_text(
json.dumps({"project_name": "unsafe-\ud800"}),
encoding="utf-8",
)
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
assert store.list_recent().items[0].display_name == session.name
def make_recorded_camera_source(
session: Path,
source_id: str = "sensor.camera.left",
@@ -191,8 +271,7 @@ def replace_summary_with_recovery_metadata(
}
)
metadata = b"".join(
(json.dumps(record, separators=(",", ":")) + "\n").encode("utf-8")
for record in records
(json.dumps(record, separators=(",", ":")) + "\n").encode("utf-8") for record in records
)
if corrupt_trailing_line:
metadata += b'{"record_type":"message"'
@@ -243,6 +322,109 @@ def test_store_uses_private_wal_database_and_idempotently_imports_legacy_session
assert store.database_path.stat().st_mode & 0o777 == 0o600
def test_completed_capture_clock_is_a_digest_bound_replay_artifact(tmp_path: Path) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
clock_path = add_capture_clock(session)
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
assert store.get_session(session.name).summary.duration_seconds == 3.0
command = store.prepare_replay(session.name)
clock_artifact = next(
artifact for artifact in command.artifacts if artifact.artifact_id == "raw-transport-clock"
)
assert command.timeline_origin_monotonic_ns == 9_000_000_000
assert command.timeline_origin_epoch_ns == 1_784_235_391_699_000_000
assert clock_artifact.path == clock_path
assert clock_artifact.replay_byte_length == clock_path.stat().st_size
assert clock_artifact.expected_sha256 == hashlib.sha256(clock_path.read_bytes()).hexdigest()
def test_completed_capture_rejects_corrupt_declared_clock(tmp_path: Path) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
clock_path = add_capture_clock(session)
clock_path.write_text('{"schema_version":1,"tampered":true}\n', encoding="utf-8")
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
detail = store.get_session(session.name)
assert detail.summary.status == "failed"
assert detail.summary.replayable is False
def test_completed_capture_rejects_clock_filename_with_wrong_digest_suffix(
tmp_path: Path,
) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
clock_path = add_capture_clock(session)
wrong_path = clock_path.with_name(f"mqtt.timeline.session-{'0' * 64}.json")
wrong_path.write_bytes(clock_path.read_bytes())
summary_path = session / "captures" / "mqtt_live" / "mqtt.summary.json"
summary = json.loads(summary_path.read_text(encoding="utf-8"))
summary["artifacts"]["capture_clock"] = wrong_path.name
summary_path.write_text(json.dumps(summary), encoding="utf-8")
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
detail = store.get_session(session.name)
assert detail.summary.status == "failed"
assert detail.summary.replayable is False
def test_camera_session_does_not_advertise_provisional_transport_clock(
tmp_path: Path,
) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
add_capture_clock(session, scope="transport")
make_recorded_camera_source(session)
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
detail = store.get_session(session.name)
assert detail.summary.replayable is False
def test_interrupted_camera_session_uses_durable_origin_and_fails_closed(
tmp_path: Path,
) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
add_capture_clock(session, scope="transport")
replace_summary_with_recovery_metadata(session)
make_recorded_camera_source(session)
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
detail = store.get_session(session.name)
assert detail.summary.status == "interrupted"
assert detail.summary.replayable is False
def test_interrupted_raw_replay_preserves_durable_pre_message_origin(tmp_path: Path) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
add_capture_clock(session, scope="transport")
replace_summary_with_recovery_metadata(session)
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
command = store.prepare_replay(session.name)
assert command.timeline_origin_monotonic_ns == 9_000_000_000
assert any(
artifact.artifact_id == "raw-transport-clock-origin" for artifact in command.artifacts
)
def test_reconcile_claims_pre_plugin_catalog_row_without_changing_session_identity(
tmp_path: Path,
) -> None:
@@ -260,8 +442,7 @@ def test_reconcile_claims_pre_plugin_catalog_row_without_changing_session_identi
(session.name,),
)
connection.execute(
"UPDATE observation_session_artifacts SET replay_byte_length = 0 "
"WHERE session_id = ?",
"UPDATE observation_session_artifacts SET replay_byte_length = 0 WHERE session_id = ?",
(session.name,),
)
connection.commit()
@@ -511,8 +692,7 @@ def test_interrupted_capture_replays_only_committed_prefix_before_partial_raw_ta
assert detail.summary.replayable is True
assert command.primary_artifact.replay_byte_length == committed_bytes
assert (
command.primary_artifact.replay_byte_length
< command.primary_artifact.path.stat().st_size
command.primary_artifact.replay_byte_length < command.primary_artifact.path.stat().st_size
)
+80
View File
@@ -2,10 +2,18 @@ from __future__ import annotations
import math
import struct
from types import SimpleNamespace
import lz4.block
import pytest
from k1link.device_plugins.xgrids_k1.protocol.modeling import (
MODELING_REPORT_TOPIC,
ModelingReportDecodeError,
decode_modeling_report,
is_modeling_report_topic,
observe_modeling_report,
)
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
ProtobufWireError,
decode_zigzag64,
@@ -21,6 +29,7 @@ from k1link.device_plugins.xgrids_k1.protocol.streams import (
decode_lio_pose,
decode_pre_path_array,
)
from k1link.viewer.metrics import BridgeMetrics
def _varint(value: int) -> bytes:
@@ -136,6 +145,77 @@ def test_decode_lio_pose_rejects_nonfinite_float() -> None:
decode_lio_pose(payload)
def test_decode_modeling_report_device_telemetry() -> None:
scan_status = _fixed32(1, 41.521404) + _fixed32(2, 1.375) + _uint(3, 204)
payload = _bytes(1, _header()) + _uint(2, 73) + _bytes(3, scan_status)
telemetry = decode_modeling_report(payload)
assert telemetry.move_distance_meters == pytest.approx(41.521404)
assert telemetry.move_speed_meters_per_second == pytest.approx(1.375)
assert telemetry.scan_time_ticks == 204
assert telemetry.elapsed_seconds == pytest.approx(102.0)
assert telemetry.pgo_progress == 73
assert is_modeling_report_topic(MODELING_REPORT_TOPIC)
assert not is_modeling_report_topic(f"{MODELING_REPORT_TOPIC}/extra")
metrics = BridgeMetrics()
assert observe_modeling_report(
SimpleNamespace(topic=MODELING_REPORT_TOPIC, payload=payload),
metrics,
)
snapshot = metrics.snapshot()
assert snapshot["device_elapsed_seconds"] == pytest.approx(102.0)
assert snapshot["device_route_distance_meters"] == pytest.approx(41.521)
assert snapshot["device_speed_meters_per_second"] == pytest.approx(1.375)
assert snapshot["modeling_reports"] == 1
assert snapshot["modeling_decode_errors"] == 0
def test_decode_modeling_report_fails_closed_on_invalid_status() -> None:
with pytest.raises(ModelingReportDecodeError, match="no scan_status"):
decode_modeling_report(_uint(2, 1))
with pytest.raises(ModelingReportDecodeError, match="finite and nonnegative"):
decode_modeling_report(_bytes(3, _fixed32(1, math.nan)))
with pytest.raises(ModelingReportDecodeError, match="configured limit"):
decode_modeling_report(b"x" * 5, max_payload_bytes=4)
with pytest.raises(ModelingReportDecodeError, match="pgo_progress is duplicated"):
decode_modeling_report(_uint(2, 1) + _uint(2, 2) + _bytes(3, _uint(3, 2)))
with pytest.raises(ModelingReportDecodeError, match="scan_status is duplicated"):
decode_modeling_report(_bytes(3, b"") + _bytes(3, b""))
with pytest.raises(ModelingReportDecodeError, match="move_speed is duplicated"):
decode_modeling_report(_bytes(3, _fixed32(2, 1.0) + _fixed32(2, 2.0)))
with pytest.raises(ModelingReportDecodeError, match="signed range"):
decode_modeling_report(_uint(2, 0x8000_0000) + _bytes(3, _uint(3, 2)))
def test_modeling_metrics_follow_device_scan_generation_reset() -> None:
metrics = BridgeMetrics()
before_reset = _bytes(
3,
_fixed32(1, 2.071593) + _fixed32(2, 0.2) + _uint(3, 734),
)
after_reset = _bytes(
3,
_fixed32(1, 0.0) + _fixed32(2, 0.0) + _uint(3, 2),
)
observe_modeling_report(
SimpleNamespace(topic=MODELING_REPORT_TOPIC, payload=before_reset),
metrics,
)
observe_modeling_report(
SimpleNamespace(topic=MODELING_REPORT_TOPIC, payload=after_reset),
metrics,
)
snapshot = metrics.snapshot()
assert snapshot["device_elapsed_seconds"] == 1.0
assert snapshot["device_route_distance_meters"] == 0.0
assert snapshot["device_speed_meters_per_second"] == 0.0
assert snapshot["modeling_reports"] == 2
def test_decode_legacy_pointcloud() -> None:
envelope = struct.pack("<III", 16, 123, 456)
body = struct.pack("<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40)
File diff suppressed because it is too large Load Diff
+61 -5
View File
@@ -46,6 +46,8 @@ def test_xgrids_compatibility_profile_loads_exact_firmware_and_sources() -> None
profile = LOADER.load_compatibility_profile()
assert profile["profile_id"] == "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
assert profile["scope"]["vendor"] == "XGRIDS"
assert profile["scope"]["model"] == "LixelKity K1"
assert profile["scope"]["firmware"] == {"match": "exact", "version": "3.0.2"}
assert profile["scope"]["topology"] == "direct-lan"
assert LOADER.matches_target(profile, firmware="3.0.2", topology="direct-lan")
@@ -67,9 +69,9 @@ def test_xgrids_compatibility_profile_keeps_evidence_levels_independent() -> Non
"physical_verified": True,
"write_enabled": False,
}
raw_status = {
decoded_status = {
"observed": True,
"decoded": False,
"decoded": True,
"replay_verified": False,
"physical_verified": True,
"write_enabled": False,
@@ -84,9 +86,11 @@ def test_xgrids_compatibility_profile_keeps_evidence_levels_independent() -> Non
assert channels["spatial.point-cloud.live"]["evidence"] == verified_stream
assert channels["spatial.pose.live"]["evidence"] == verified_stream
assert channels["device.status.live"]["evidence"] == raw_status
assert channels["device.heartbeat.live"]["evidence"] == raw_status
assert channels["device.status.live"]["semantic_payload"] is None
assert channels["device.modeling.live"]["evidence"] == decoded_status
assert channels["device.status.live"]["evidence"] == decoded_status
assert channels["device.heartbeat.live"]["evidence"] == observed_raw
assert "ScanTime" in channels["device.modeling.live"]["semantic_payload"]
assert "modeling-state" in channels["device.status.live"]["semantic_payload"]
camera = channels["camera.preview.live"]
assert camera["discovery_status"] == "observed"
@@ -151,8 +155,21 @@ def test_xgrids_compatibility_profile_maps_actions_without_enabling_writes() ->
assert mapping["evidence_kind"] == "owner-controlled-wire-observation"
assert mapping["topic"] == "lixel/application/request/modeling"
assert mapping["qos"] == 2
assert mapping["retain"] is False
assert mapping["message_type"] == "ModelingRequest"
assert mapping["action_field_value"] == action_code
assert mapping["header_contract"]["session_id"] == "{device_id}:ModelingRequest"
assert mapping["success_result_code"] == 302_252_033
if action_id == "acquisition.start":
assert mapping["request_fields"] == {
"project_name": "required-operator-value",
"record_mode": 2,
"scan_mode": 1,
"mount_type": 0,
"pre_project_id": "omitted-in-retained-request",
}
else:
assert mapping["request_fields"] == {}
assert mapping["required_unresolved_context"]
assert mapping["evidence"]["observed"] is True
assert mapping["evidence"]["decoded"] is True
@@ -178,6 +195,45 @@ def test_xgrids_compatibility_profile_rejects_vendor_write_promotion() -> None:
LOADER.validate_compatibility_profile(modified)
def test_xgrids_compatibility_profile_rejects_noncanonical_modeling_header() -> None:
profile = LOADER.load_compatibility_profile()
modified = copy.deepcopy(profile)
actions = _by_id(modified["acquisition_control"]["semantic_actions"])
actions["acquisition.start"]["vendor_request_mapping"]["header_contract"]["session_id"] = (
"caller-provided"
)
with pytest.raises(LOADER.CompatibilityProfileError, match="header contract"):
LOADER.validate_compatibility_profile(modified)
def test_xgrids_compatibility_profile_pins_vendor_and_inert_request_mapping() -> None:
profile = LOADER.load_compatibility_profile()
wrong_model = copy.deepcopy(profile)
wrong_model["scope"]["vendor"] = "OTHER"
with pytest.raises(LOADER.CompatibilityProfileError, match="vendor/model"):
LOADER.validate_compatibility_profile(wrong_model)
wrong_message = copy.deepcopy(profile)
wrong_actions = _by_id(wrong_message["acquisition_control"]["semantic_actions"])
wrong_actions["acquisition.start"]["vendor_request_mapping"]["message_type"] = "OtherRequest"
with pytest.raises(LOADER.CompatibilityProfileError, match="message type"):
LOADER.validate_compatibility_profile(wrong_message)
missing_write_gate = copy.deepcopy(profile)
missing_actions = _by_id(missing_write_gate["acquisition_control"]["semantic_actions"])
del missing_actions["acquisition.stop"]["vendor_request_mapping"]["write_enabled"]
with pytest.raises(LOADER.CompatibilityProfileError, match="explicitly"):
LOADER.validate_compatibility_profile(missing_write_gate)
wrong_telemetry = copy.deepcopy(profile)
wrong_channels = _by_id(wrong_telemetry["channels"])
wrong_channels["device.modeling.live"]["bounds"]["max_mqtt_payload_bytes"] = 1
with pytest.raises(LOADER.CompatibilityProfileError, match="telemetry channel"):
LOADER.validate_compatibility_profile(wrong_telemetry)
def test_xgrids_compatibility_profile_rejects_claimed_camera_endpoint() -> None:
profile = LOADER.load_compatibility_profile()
modified = copy.deepcopy(profile)
@@ -0,0 +1,327 @@
from __future__ import annotations
from dataclasses import replace
import pytest
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
MODELING_STATE_BASE,
OPENAPI_SUCCESS,
CommandHeaderIdentity,
ModelingAction,
ModelingCommandRejected,
ModelingEncodeError,
ModelingProtocolError,
ModelingResponseCorrelationError,
MountType,
ObservedHeader,
RecordMode,
ScanMode,
SessionState,
correlate_modeling_response,
decode_device_status_report,
decode_modeling_response,
encode_modeling_start,
encode_modeling_stop,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_state import (
AcquisitionPhase,
DeviceAcquisitionStateMachine,
SaveEvidence,
)
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import iter_fields
def _varint(value: int) -> bytes:
encoded = bytearray()
while value > 0x7F:
encoded.append((value & 0x7F) | 0x80)
value >>= 7
encoded.append(value)
return bytes(encoded)
def _uint(number: int, value: int) -> bytes:
return _varint(number << 3) + _varint(value)
def _bytes(number: int, value: bytes) -> bytes:
return _varint((number << 3) | 2) + _varint(len(value)) + value
def _text(number: int, value: str) -> bytes:
return _bytes(number, value.encode())
def _identity(**changes: str) -> CommandHeaderIdentity:
values = {
"device_id": "synthetic-device",
"openapi_key": "synthetic-explicit-key",
}
values.update(changes)
return CommandHeaderIdentity(**values)
def _header(
identity: CommandHeaderIdentity,
*,
session_id: str | None = None,
) -> bytes:
return b"".join(
(
_text(4, identity.device_id),
_text(5, identity.session_id if session_id is None else session_id),
_text(6, identity.openapi_key),
)
)
def _response(
identity: CommandHeaderIdentity,
action: ModelingAction,
*,
code: int = OPENAPI_SUCCESS,
description: str = "description-is-not-the-success-gate",
session_id: str | None = None,
) -> bytes:
error = _uint(1, code) + _text(2, description)
return (
_bytes(1, _header(identity, session_id=session_id)) + _uint(2, action) + _bytes(15, error)
)
def _device_status(
state: SessionState | None,
*,
raw_code: int | None = None,
init_ready: bool = False,
project_id: str | None = None,
) -> bytes:
if raw_code is None:
assert state is not None
raw_code = MODELING_STATE_BASE + state
fields = [_uint(2, raw_code)]
if project_id is not None:
fields.append(_text(4, project_id))
if init_ready:
fields.append(_uint(6, 1))
return b"".join(fields)
def test_command_identity_is_explicit_bounded_and_redacted() -> None:
identity = _identity()
assert "synthetic" not in repr(identity)
assert identity.session_id == "synthetic-device:ModelingRequest"
for field_name in ("device_id", "openapi_key"):
with pytest.raises(ModelingEncodeError, match="non-empty"):
_identity(**{field_name: ""})
with pytest.raises(ModelingEncodeError, match="printable ASCII"):
_identity(device_id="не-ascii")
with pytest.raises(ModelingEncodeError, match="without spaces"):
_identity(openapi_key="unsafe key")
assert not identity.matches(
ObservedHeader(
device_id="не-ascii",
session_id=identity.session_id,
openapi_key=identity.openapi_key,
)
)
def test_start_encoder_matches_recovered_field_shape() -> None:
identity = _identity()
command = encode_modeling_start(
identity,
project_name="synthetic-project",
record_mode=RecordMode.RECORD_AND_CALCULATE,
scan_mode=ScanMode.LCC,
mount_type=MountType.HANDHELD,
)
fields = list(iter_fields(command.payload, max_fields=16))
assert command.action is ModelingAction.START
# HANDHELD is proto3 zero and is therefore canonically absent.
assert [field.number for field in fields] == [1, 2, 3, 4, 5]
assert fields[1].value == ModelingAction.START
assert fields[2].value == b"synthetic-project"
assert fields[3].value == RecordMode.RECORD_AND_CALCULATE
assert fields[4].value == ScanMode.LCC
header_fields = list(iter_fields(fields[0].value, max_fields=8)) # type: ignore[arg-type]
assert [field.number for field in header_fields] == [4, 5, 6]
assert [field.value for field in header_fields] == [
b"synthetic-device",
b"synthetic-device:ModelingRequest",
b"synthetic-explicit-key",
]
assert "synthetic" not in repr(command)
def test_start_requires_explicit_enums_and_encodes_optional_pre_project() -> None:
identity = _identity()
with pytest.raises(ModelingEncodeError, match="explicit RecordMode"):
encode_modeling_start(
identity,
project_name="project",
record_mode=2, # type: ignore[arg-type]
scan_mode=ScanMode.LCC,
mount_type=MountType.HANDHELD,
)
command = encode_modeling_start(
identity,
project_name="project",
record_mode=RecordMode.CALCULATE_ONLY,
scan_mode=ScanMode.POINT_CLOUD,
mount_type=MountType.UAV,
pre_project_id="synthetic-pre-project",
)
fields = list(iter_fields(command.payload, max_fields=16))
assert [field.number for field in fields] == [1, 2, 3, 6, 7]
assert fields[-2].value == MountType.UAV
assert fields[-1].value == b"synthetic-pre-project"
def test_stop_encoder_contains_only_header_and_action() -> None:
command = encode_modeling_stop(_identity())
fields = list(iter_fields(command.payload, max_fields=8))
assert command.action is ModelingAction.STOP
assert [field.number for field in fields] == [1, 2]
assert fields[1].value == ModelingAction.STOP
@pytest.mark.parametrize("field_name", ["device_id", "openapi_key"])
def test_response_correlation_requires_every_exact_identity(field_name: str) -> None:
identity = _identity()
expected = encode_modeling_stop(identity)
mismatched = replace(identity, **{field_name: f"different-{field_name}"})
with pytest.raises(ModelingResponseCorrelationError, match="identity mismatch"):
correlate_modeling_response(_response(mismatched, ModelingAction.STOP), expected)
def test_response_correlation_rejects_noncanonical_modeling_session() -> None:
identity = _identity()
expected = encode_modeling_stop(identity)
with pytest.raises(ModelingResponseCorrelationError, match="identity mismatch"):
correlate_modeling_response(
_response(
identity,
ModelingAction.STOP,
session_id="synthetic-device:DifferentRequest",
),
expected,
)
with pytest.raises(ModelingProtocolError, match="printable ASCII"):
correlate_modeling_response(
_response(identity, ModelingAction.STOP, session_id="не-ascii"),
expected,
)
def test_response_correlation_requires_action_and_numeric_success() -> None:
identity = _identity()
expected = encode_modeling_stop(identity)
response = correlate_modeling_response(
_response(identity, ModelingAction.STOP, description="arbitrary text"), expected
)
assert response.error.code == OPENAPI_SUCCESS
with pytest.raises(ModelingResponseCorrelationError, match="action mismatch"):
correlate_modeling_response(_response(identity, ModelingAction.START), expected)
with pytest.raises(ModelingCommandRejected) as rejected:
correlate_modeling_response(
_response(identity, ModelingAction.STOP, code=OPENAPI_SUCCESS + 99), expected
)
assert rejected.value.code == OPENAPI_SUCCESS + 99
def test_response_parser_fails_closed_on_incomplete_or_ambiguous_payload() -> None:
identity = _identity()
error = _uint(1, OPENAPI_SUCCESS)
with pytest.raises(ModelingProtocolError, match="identity is incomplete"):
decode_modeling_response(
_bytes(1, _text(4, identity.device_id))
+ _uint(2, ModelingAction.START)
+ _bytes(15, error)
)
with pytest.raises(ModelingProtocolError, match="duplicated"):
decode_modeling_response(
_bytes(1, _header(identity))
+ _uint(2, ModelingAction.START)
+ _uint(2, ModelingAction.START)
+ _bytes(15, error)
)
with pytest.raises(ModelingProtocolError, match="not start or stop"):
decode_modeling_response(_bytes(1, _header(identity)) + _uint(2, 99) + _bytes(15, error))
def test_device_status_maps_base_offset_states_and_preserves_unknown() -> None:
report = decode_device_status_report(
_device_status(SessionState.SCANNING, init_ready=True, project_id="synthetic-project-id")
)
assert report.modeling_state_code == MODELING_STATE_BASE + SessionState.SCANNING
assert report.session_state is SessionState.SCANNING
assert report.init_ready
assert repr(report).find("synthetic-project-id") == -1
unknown = decode_device_status_report(_device_status(None, raw_code=MODELING_STATE_BASE + 999))
assert unknown.session_state is None
with pytest.raises(ModelingProtocolError, match="no modeling_state"):
decode_device_status_report(b"")
with pytest.raises(ModelingProtocolError, match="integer range"):
decode_device_status_report(_uint(6, 2) + _uint(2, MODELING_STATE_BASE))
def test_state_machine_never_promotes_status_only_evidence_to_durable_save() -> None:
machine = DeviceAcquisitionStateMachine()
assert machine.snapshot.phase is AcquisitionPhase.UNOBSERVED
assert not machine.snapshot.durable_save_complete
sequence = (
(SessionState.READY, AcquisitionPhase.READY),
(SessionState.SCAN_STARTING, AcquisitionPhase.CALIBRATING),
(SessionState.SCANNING, AcquisitionPhase.SCANNING),
(SessionState.SCAN_STOPPING, AcquisitionPhase.STOPPING),
(SessionState.SCAN_OVER, AcquisitionPhase.SCAN_OVER_UNVERIFIED),
)
for state, phase in sequence:
snapshot = machine.observe(decode_device_status_report(_device_status(state)))
assert snapshot.phase is phase
assert not snapshot.durable_save_complete
assert machine.snapshot.save_evidence is SaveEvidence.SCAN_OVER_OBSERVED
ready = machine.observe(decode_device_status_report(_device_status(SessionState.READY)))
assert ready.save_evidence is SaveEvidence.SCAN_OVER_THEN_READY_OBSERVED
assert not ready.durable_save_complete
next_start = machine.observe(
decode_device_status_report(_device_status(SessionState.SCAN_STARTING))
)
assert next_start.save_evidence is SaveEvidence.NONE
def test_state_machine_surfaces_faults_without_claiming_save() -> None:
machine = DeviceAcquisitionStateMachine()
for state in (
SessionState.DISK_ERROR,
SessionState.SAVE_ERROR,
SessionState.CAMERA_ERROR,
SessionState.MEMORY_NOT_ENOUGH,
SessionState.LIDAR_DATA_ERROR,
SessionState.MAPPING_ERROR,
):
snapshot = machine.observe(decode_device_status_report(_device_status(state)))
assert snapshot.phase is AcquisitionPhase.FAULT
assert snapshot.save_evidence is SaveEvidence.FAULT_BEFORE_DURABLE_CONFIRMATION
assert not snapshot.durable_save_complete
unknown = machine.observe(
decode_device_status_report(_device_status(None, raw_code=MODELING_STATE_BASE + 999))
)
assert unknown.phase is AcquisitionPhase.UNKNOWN
assert not unknown.durable_save_complete