feat(k1): complete primary acquisition lifecycle
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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();")
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "Повтор записи"},
|
||||
|
||||
@@ -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, {}))
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user