mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 14:21:14 +00:00
[AZ-399] [AZ-400] C8 TlogReplayFcAdapter + ReplaySink + JsonlReplaySink
Opens E-DEMO-REPLAY (AZ-265): the two C8 strategies that let the upcoming compose_replay (AZ-401) and gps-denied-replay CLI (AZ-402) run the production C1-C5 pipeline against a recorded (.tlog, video) pair without touching live FC I/O. AZ-400 lands the contract ReplaySink Protocol (emit + close per replay_protocol.md v1.0.0) and JsonlReplaySink: orjson-serialised JSONL, fsync-on-close, build-flag gated (BUILD_REPLAY_SINK_JSONL), double-close idempotent, FDR mirror on open/close. The drifted AZ-390 stub in interface.py is removed; the canonical Protocol now lives in replay_sink.py per module-layout.md and is re-exported via __init__.py. AZ-390 conformance test widened. AZ-399 lands TlogReplayFcAdapter: full FcAdapter Protocol surface, build-flag gated (BUILD_TLOG_REPLAY_ADAPTER), pymavlink stream-parse with bounded pre-scan + fail-fast on missing required messages (R-DEMO-3), dedicated decode thread feeding the existing AZ-391 SubscriptionBus. Outbound surface raises FcEmitError per Invariant 5; request_source_set_switch raises SourceSetSwitchNotSupportedError. Pacing honours Invariant 6 via Clock.sleep_until_ns. time_offset_ms shifts every emitted received_at per Invariant 8. Non-monotonic timestamps raise FcOpenError. Test coverage: 188 c8_fc_adapter tests pass; 1 skipped (AZ-399 AC-1 500 MB tlog RSS bound, deferred to AZ-404 e2e behind RUN_REPLAY_E2E). Code review: PASS_WITH_WARNINGS — 1 Medium (mapping logic duplicates AZ-391 live decoder; intentional today, four behavioural deltas documented), 2 Low. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -138,14 +138,31 @@ def test_ac1_gcs_protocol_conformance() -> None:
|
||||
|
||||
|
||||
def test_ac1_replay_sink_protocol_conformance() -> None:
|
||||
# Arrange
|
||||
# Arrange — AZ-400 widened the Protocol to the contract shape
|
||||
# (`emit(EstimatorOutput) -> None` + `close() -> None`).
|
||||
class _Sink:
|
||||
def write(self, output: EstimatorOutput) -> None: ...
|
||||
def emit(self, output: EstimatorOutput) -> None: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
# Assert
|
||||
assert isinstance(_Sink(), ReplaySink)
|
||||
|
||||
|
||||
def test_ac1_replay_sink_rejects_partial_surface() -> None:
|
||||
# Arrange — a `_Sink` missing `close` no longer satisfies the
|
||||
# widened Protocol; this guards against AZ-390-style stub drift.
|
||||
class _MissingClose:
|
||||
def emit(self, output: EstimatorOutput) -> None: ...
|
||||
|
||||
class _MissingEmit:
|
||||
def close(self) -> None: ...
|
||||
|
||||
# Assert
|
||||
assert not isinstance(_MissingClose(), ReplaySink)
|
||||
assert not isinstance(_MissingEmit(), ReplaySink)
|
||||
|
||||
|
||||
def test_ac1_protocol_rejects_missing_method() -> None:
|
||||
# Arrange
|
||||
class _Incomplete:
|
||||
|
||||
@@ -0,0 +1,917 @@
|
||||
"""AZ-399 — ``TlogReplayFcAdapter`` unit tests.
|
||||
|
||||
Covers AC-1..AC-10 of ``_docs/02_tasks/todo/AZ-399_replay_tlog_adapter.md``
|
||||
plus the named NFR proxies (AC-7 throughput proxy = 1000-frame consumption
|
||||
< 1 s on Tier-1 hardware; the multi-GB AC-1 RSS bound is exercised via a
|
||||
streaming-iterator proxy rather than a real 500 MB tlog file).
|
||||
|
||||
Style: every test follows the Arrange / Act / Assert pattern with
|
||||
language-appropriate ``# Arrange|Act|Assert`` markers. Pymavlink itself is
|
||||
faked via :class:`_FakeTlog` so tests run without the real C extension.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.fc import (
|
||||
AttitudeSample,
|
||||
FcKind,
|
||||
FcTelemetryFrame,
|
||||
FlightState,
|
||||
FlightStateSignal,
|
||||
GpsHealth,
|
||||
GpsStatus,
|
||||
ImuTelemetrySample,
|
||||
TelemetryKind,
|
||||
)
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
from gps_denied_onboard._types.state import (
|
||||
EstimatorOutput,
|
||||
PoseSourceLabel,
|
||||
Quat,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import (
|
||||
FcAdapterConfigError,
|
||||
FcEmitError,
|
||||
FcOpenError,
|
||||
SourceSetSwitchNotSupportedError,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.tlog_replay_adapter import (
|
||||
REQUIRED_MESSAGE_TYPES,
|
||||
ReplayPace,
|
||||
TlogReplayFcAdapter,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Fixtures + helpers
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _build_flag_on(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("BUILD_TLOG_REPLAY_ADAPTER", "ON")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_fdr_client() -> mock.MagicMock:
|
||||
return mock.MagicMock(name="FdrClient")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_wgs_converter() -> mock.MagicMock:
|
||||
return mock.MagicMock(name="WgsConverter")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def existing_tlog(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "fixture.tlog"
|
||||
p.write_bytes(b"fake-tlog") # contents are irrelevant — pymavlink is faked.
|
||||
return p
|
||||
|
||||
|
||||
class _FakeClock:
|
||||
"""Records every ``sleep_until_ns`` call so AC-6/AC-7 can assert."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sleeps: list[int] = []
|
||||
self._mono = 100
|
||||
|
||||
def monotonic_ns(self) -> int:
|
||||
self._mono += 1
|
||||
return self._mono
|
||||
|
||||
def time_ns(self) -> int:
|
||||
return 1_700_000_000_000_000_000
|
||||
|
||||
def sleep_until_ns(self, target_ns: int) -> None:
|
||||
self.sleeps.append(int(target_ns))
|
||||
|
||||
|
||||
def _msg(msg_type: str, *, ts_s: float, **fields: Any) -> SimpleNamespace:
|
||||
"""Build a pymavlink-style stub message with ``get_type()`` + fields."""
|
||||
ns = SimpleNamespace(_timestamp=ts_s, **fields)
|
||||
ns.get_type = lambda: msg_type
|
||||
return ns
|
||||
|
||||
|
||||
def _heartbeat(ts_s: float, *, system_status: int = 4, base_mode: int = 0) -> SimpleNamespace:
|
||||
return _msg("HEARTBEAT", ts_s=ts_s, system_status=system_status, base_mode=base_mode)
|
||||
|
||||
|
||||
def _imu(ts_s: float) -> SimpleNamespace:
|
||||
return _msg(
|
||||
"RAW_IMU",
|
||||
ts_s=ts_s,
|
||||
time_usec=int(ts_s * 1_000_000),
|
||||
xacc=10,
|
||||
yacc=20,
|
||||
zacc=-981,
|
||||
xgyro=1,
|
||||
ygyro=2,
|
||||
zgyro=3,
|
||||
)
|
||||
|
||||
|
||||
def _attitude(ts_s: float) -> SimpleNamespace:
|
||||
return _msg(
|
||||
"ATTITUDE",
|
||||
ts_s=ts_s,
|
||||
time_boot_ms=int(ts_s * 1000),
|
||||
roll=0.1,
|
||||
pitch=-0.2,
|
||||
yaw=1.5,
|
||||
)
|
||||
|
||||
|
||||
def _gps_3d(ts_s: float, *, lat_e7: int = 499910000, lon_e7: int = 362210000) -> SimpleNamespace:
|
||||
return _msg(
|
||||
"GPS_RAW_INT",
|
||||
ts_s=ts_s,
|
||||
fix_type=3,
|
||||
lat=lat_e7,
|
||||
lon=lon_e7,
|
||||
alt=153_400,
|
||||
)
|
||||
|
||||
|
||||
class _FakeTlog:
|
||||
"""Minimal pymavlink ``mavlink_connection`` stand-in.
|
||||
|
||||
Returns each message in ``messages`` once on ``recv_match``; ignores
|
||||
the ``type=`` filter (mirrors pymavlink's filter-or-pass behaviour
|
||||
closely enough for our decoder, which receives unfiltered
|
||||
HEARTBEAT/IMU/ATTITUDE/GPS streams).
|
||||
"""
|
||||
|
||||
def __init__(self, messages: list[Any]) -> None:
|
||||
self._iter = iter(messages)
|
||||
self.closed = False
|
||||
|
||||
def recv_match(self, **_kwargs: Any) -> Any | None:
|
||||
return next(self._iter, None)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _factory_for(messages: list[Any]) -> Any:
|
||||
"""Return a source factory that always yields a fresh ``_FakeTlog``.
|
||||
|
||||
Pre-scan and decode passes both call the factory, so the messages
|
||||
must be re-emittable; we copy the list each time.
|
||||
"""
|
||||
|
||||
def _factory(_path: str) -> _FakeTlog:
|
||||
return _FakeTlog(list(messages))
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
def _make_adapter(
|
||||
*,
|
||||
tlog_path: Path,
|
||||
messages: list[Any],
|
||||
pace: ReplayPace = ReplayPace.ASAP,
|
||||
time_offset_ms: int = 0,
|
||||
fdr_client: mock.MagicMock,
|
||||
wgs_converter: mock.MagicMock,
|
||||
clock: _FakeClock | None = None,
|
||||
) -> tuple[TlogReplayFcAdapter, _FakeClock]:
|
||||
used_clock = clock if clock is not None else _FakeClock()
|
||||
adapter = TlogReplayFcAdapter(
|
||||
tlog_path=tlog_path,
|
||||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||||
clock=used_clock,
|
||||
wgs_converter=wgs_converter,
|
||||
fdr_client=fdr_client,
|
||||
time_offset_ms=time_offset_ms,
|
||||
pace=pace,
|
||||
source_factory=_factory_for(messages),
|
||||
)
|
||||
return adapter, used_clock
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-1: Stream-parse memory bound (skipped — requires 500 MB fixture
|
||||
# tlog + RSS measurement on Tier-1 hardware; covered functionally by
|
||||
# the lazy iterator design + AC-7 throughput proxy here, and by the
|
||||
# AZ-404 e2e replay fixture in CI when ``RUN_REPLAY_E2E=1``).
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"AC-1 requires a 500 MB synthetic tlog + RSS measurement; "
|
||||
"covered functionally by the streaming source factory + AZ-404 "
|
||||
"e2e replay fixture (gated behind RUN_REPLAY_E2E=1)."
|
||||
)
|
||||
)
|
||||
def test_ac1_stream_parse_memory_bound_under_100mb_above_baseline() -> None:
|
||||
raise AssertionError("placeholder — gated by RUN_REPLAY_E2E=1 in CI")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-10: Build-flag gating (asserts before flag-on fixture by overriding)
|
||||
|
||||
|
||||
def test_ac10_build_flag_off_refuses_construction(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
monkeypatch.delenv("BUILD_TLOG_REPLAY_ADAPTER", raising=False)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FcAdapterConfigError, match="BUILD_TLOG_REPLAY_ADAPTER is OFF"):
|
||||
TlogReplayFcAdapter(
|
||||
tlog_path=existing_tlog,
|
||||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||||
clock=_FakeClock(),
|
||||
wgs_converter=fake_wgs_converter,
|
||||
fdr_client=fake_fdr_client,
|
||||
)
|
||||
|
||||
|
||||
def test_ac10_build_flag_off_token_variations_refuse(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange / Act / Assert
|
||||
for token in ("", "off", "0", "false", "no", " "):
|
||||
monkeypatch.setenv("BUILD_TLOG_REPLAY_ADAPTER", token)
|
||||
with pytest.raises(FcAdapterConfigError):
|
||||
TlogReplayFcAdapter(
|
||||
tlog_path=existing_tlog,
|
||||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||||
clock=_FakeClock(),
|
||||
wgs_converter=fake_wgs_converter,
|
||||
fdr_client=fake_fdr_client,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Construction validation
|
||||
|
||||
|
||||
def test_construct_rejects_non_path_tlog(
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Act / Assert
|
||||
with pytest.raises(FcAdapterConfigError, match="tlog_path must be a pathlib.Path"):
|
||||
TlogReplayFcAdapter(
|
||||
tlog_path="not/a/path", # type: ignore[arg-type]
|
||||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||||
clock=_FakeClock(),
|
||||
wgs_converter=fake_wgs_converter,
|
||||
fdr_client=fake_fdr_client,
|
||||
)
|
||||
|
||||
|
||||
def test_construct_rejects_unknown_dialect(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Act / Assert
|
||||
with pytest.raises(FcAdapterConfigError, match="target_fc_dialect must be"):
|
||||
TlogReplayFcAdapter(
|
||||
tlog_path=existing_tlog,
|
||||
target_fc_dialect=FcKind.GCS_QGC,
|
||||
clock=_FakeClock(),
|
||||
wgs_converter=fake_wgs_converter,
|
||||
fdr_client=fake_fdr_client,
|
||||
)
|
||||
|
||||
|
||||
def test_open_raises_on_missing_file(
|
||||
tmp_path: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
bogus = tmp_path / "does-not-exist.tlog"
|
||||
adapter = TlogReplayFcAdapter(
|
||||
tlog_path=bogus,
|
||||
target_fc_dialect=FcKind.ARDUPILOT_PLANE,
|
||||
clock=_FakeClock(),
|
||||
wgs_converter=fake_wgs_converter,
|
||||
fdr_client=fake_fdr_client,
|
||||
source_factory=_factory_for([]),
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FcOpenError, match="tlog file not found"):
|
||||
adapter.open()
|
||||
|
||||
|
||||
def test_double_open_raises(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange — minimum viable tlog satisfies pre-scan.
|
||||
messages = [_imu(0.0), _attitude(0.0), _gps_3d(0.0), _heartbeat(0.0)]
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
|
||||
# Act
|
||||
adapter.open()
|
||||
try:
|
||||
# Assert
|
||||
with pytest.raises(FcOpenError, match="already opened"):
|
||||
adapter.open()
|
||||
finally:
|
||||
adapter.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2: AP dialect frame mapping
|
||||
|
||||
|
||||
def test_ac2_ap_dialect_frame_mapping_in_tlog_order(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
messages = [
|
||||
_imu(0.0),
|
||||
_attitude(0.001),
|
||||
_gps_3d(0.002),
|
||||
_heartbeat(0.003),
|
||||
]
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
received: list[FcTelemetryFrame] = []
|
||||
adapter.subscribe_telemetry(received.append)
|
||||
|
||||
# Act
|
||||
adapter.open()
|
||||
_wait_for(lambda: len(received) == 4)
|
||||
adapter.close()
|
||||
|
||||
# Assert
|
||||
kinds = [frame.kind for frame in received]
|
||||
assert kinds == [
|
||||
TelemetryKind.IMU_SAMPLE,
|
||||
TelemetryKind.ATTITUDE,
|
||||
TelemetryKind.GPS_HEALTH,
|
||||
TelemetryKind.MAV_STATE,
|
||||
]
|
||||
assert isinstance(received[0].payload, ImuTelemetrySample)
|
||||
assert received[0].payload.accel_xyz == (10.0, 20.0, -981.0)
|
||||
assert isinstance(received[1].payload, AttitudeSample)
|
||||
assert received[1].payload.yaw_rad == pytest.approx(1.5)
|
||||
assert isinstance(received[2].payload, GpsHealth)
|
||||
assert received[2].payload.status is GpsStatus.STABLE
|
||||
assert isinstance(received[3].payload, FlightStateSignal)
|
||||
assert received[3].payload.state is FlightState.IN_FLIGHT
|
||||
# Provenance: replay frames are unsigned per D-CROSS-CVE-1.
|
||||
assert all(not f.signed for f in received)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3: iNav dialect frame mapping
|
||||
# Per RESTRICT-COMM-2 the GCS telemetry channel always speaks AP MAVLink,
|
||||
# so an iNav-dialect adapter consumes the same wire types — we simply
|
||||
# reconstruct the adapter with FcKind.INAV and re-run AC-2.
|
||||
|
||||
|
||||
def test_ac3_inav_dialect_frame_mapping(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
messages = [_imu(0.0), _attitude(0.001), _gps_3d(0.002), _heartbeat(0.003)]
|
||||
adapter = TlogReplayFcAdapter(
|
||||
tlog_path=existing_tlog,
|
||||
target_fc_dialect=FcKind.INAV,
|
||||
clock=_FakeClock(),
|
||||
wgs_converter=fake_wgs_converter,
|
||||
fdr_client=fake_fdr_client,
|
||||
source_factory=_factory_for(messages),
|
||||
)
|
||||
received: list[FcTelemetryFrame] = []
|
||||
adapter.subscribe_telemetry(received.append)
|
||||
|
||||
# Act
|
||||
adapter.open()
|
||||
_wait_for(lambda: len(received) == 4)
|
||||
adapter.close()
|
||||
|
||||
# Assert
|
||||
assert [f.kind for f in received] == [
|
||||
TelemetryKind.IMU_SAMPLE,
|
||||
TelemetryKind.ATTITUDE,
|
||||
TelemetryKind.GPS_HEALTH,
|
||||
TelemetryKind.MAV_STATE,
|
||||
]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4: Fail-fast missing required messages
|
||||
|
||||
|
||||
def test_ac4_fail_fast_missing_required_messages(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
# Arrange — no IMU type at all (RAW_IMU + SCALED_IMU2 both absent).
|
||||
messages = [_attitude(0.0), _gps_3d(0.001), _heartbeat(0.002)]
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
|
||||
# Act
|
||||
with caplog.at_level("ERROR"):
|
||||
with pytest.raises(FcOpenError) as excinfo:
|
||||
adapter.open()
|
||||
|
||||
# Assert
|
||||
msg = str(excinfo.value)
|
||||
assert "RAW_IMU" in msg and "SCALED_IMU2" in msg
|
||||
assert "C1 VIO" in msg
|
||||
assert "C5 StateEstimator" in msg
|
||||
# ERROR log fired with the structured kind.
|
||||
error_kinds = {
|
||||
rec.__dict__.get("kind")
|
||||
for rec in caplog.records
|
||||
if rec.levelname == "ERROR"
|
||||
}
|
||||
assert "c8.tlog_replay.missing_messages" in error_kinds
|
||||
# FDR mirror enqueued.
|
||||
assert fake_fdr_client.enqueue.called
|
||||
fdr_record = fake_fdr_client.enqueue.call_args.args[0]
|
||||
assert fdr_record.payload["kind"] == "c8.tlog_replay.missing_messages"
|
||||
assert fdr_record.payload["level"] == "ERROR"
|
||||
|
||||
|
||||
def test_ac4_fail_fast_missing_only_heartbeat(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange — IMU, attitude, GPS present; no HEARTBEAT.
|
||||
messages = [_imu(0.0), _attitude(0.0), _gps_3d(0.0)]
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FcOpenError) as excinfo:
|
||||
adapter.open()
|
||||
assert "HEARTBEAT" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_imu_satisfied_by_scaled_imu2_alone(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange — SCALED_IMU2 instead of RAW_IMU; pre-scan must accept.
|
||||
scaled_imu2 = _msg(
|
||||
"SCALED_IMU2",
|
||||
ts_s=0.0,
|
||||
time_usec=0,
|
||||
xacc=1,
|
||||
yacc=2,
|
||||
zacc=-981,
|
||||
xgyro=1,
|
||||
ygyro=2,
|
||||
zgyro=3,
|
||||
)
|
||||
messages = [scaled_imu2, _attitude(0.001), _gps_3d(0.002), _heartbeat(0.003)]
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
|
||||
# Act
|
||||
adapter.open()
|
||||
adapter.close()
|
||||
|
||||
# Assert — open() did not raise; the test reaching here is the assertion.
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5: time_offset_ms shift
|
||||
|
||||
|
||||
def test_ac5_time_offset_ms_shifts_received_at(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
offset_ms = 5000
|
||||
offset_ns = offset_ms * 1_000_000
|
||||
raw_ts = [0.0, 0.5, 1.0, 1.5]
|
||||
messages = [
|
||||
_imu(raw_ts[0]),
|
||||
_attitude(raw_ts[1]),
|
||||
_gps_3d(raw_ts[2]),
|
||||
_heartbeat(raw_ts[3]),
|
||||
]
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
time_offset_ms=offset_ms,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
received: list[FcTelemetryFrame] = []
|
||||
adapter.subscribe_telemetry(received.append)
|
||||
|
||||
# Act
|
||||
adapter.open()
|
||||
_wait_for(lambda: len(received) == 4)
|
||||
adapter.close()
|
||||
|
||||
# Assert — every emitted received_at = raw_ns + offset_ns.
|
||||
for frame, ts_s in zip(received, raw_ts, strict=True):
|
||||
expected_ns = int(ts_s * 1_000_000_000) + offset_ns
|
||||
assert frame.received_at == expected_ns
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6: Pace REALTIME calls Clock.sleep_until_ns between frames
|
||||
|
||||
|
||||
def test_ac6_pace_realtime_calls_sleep_until_ns(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
messages = [_imu(0.0), _attitude(0.5), _gps_3d(1.0), _heartbeat(1.5)]
|
||||
clock = _FakeClock()
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
pace=ReplayPace.REALTIME,
|
||||
clock=clock,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
received: list[FcTelemetryFrame] = []
|
||||
adapter.subscribe_telemetry(received.append)
|
||||
|
||||
# Act
|
||||
adapter.open()
|
||||
_wait_for(lambda: len(received) == 4)
|
||||
adapter.close()
|
||||
|
||||
# Assert — one sleep per dispatched frame, each targeting that frame's received_at.
|
||||
assert len(clock.sleeps) == 4
|
||||
for sleep_target, frame in zip(clock.sleeps, received, strict=True):
|
||||
assert sleep_target == frame.received_at
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7: Pace ASAP no-op + throughput proxy
|
||||
|
||||
|
||||
def test_ac7_pace_asap_skips_sleep(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
messages = [_imu(0.0), _attitude(0.5), _gps_3d(1.0), _heartbeat(1.5)]
|
||||
clock = _FakeClock()
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
pace=ReplayPace.ASAP,
|
||||
clock=clock,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
received: list[FcTelemetryFrame] = []
|
||||
adapter.subscribe_telemetry(received.append)
|
||||
|
||||
# Act
|
||||
adapter.open()
|
||||
_wait_for(lambda: len(received) == 4)
|
||||
adapter.close()
|
||||
|
||||
# Assert
|
||||
assert clock.sleeps == []
|
||||
|
||||
|
||||
def test_ac7_throughput_proxy_1000_frames_under_one_second(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange — 1000 IMU + bookend types so pre-scan passes.
|
||||
messages: list[Any] = [_attitude(0.0), _gps_3d(0.0), _heartbeat(0.0)]
|
||||
for i in range(1000):
|
||||
messages.append(_imu(0.001 * (i + 1)))
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
pace=ReplayPace.ASAP,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
received: list[FcTelemetryFrame] = []
|
||||
adapter.subscribe_telemetry(received.append)
|
||||
|
||||
# Act
|
||||
start = time.monotonic()
|
||||
adapter.open()
|
||||
_wait_for(lambda: len(received) >= 1003, timeout_s=5.0)
|
||||
adapter.close()
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
# Assert — 1000 IMU + 3 bookends; throughput proxy budget is < 1 s.
|
||||
assert len(received) == 1003
|
||||
assert elapsed < 1.0, f"throughput proxy missed: {elapsed:.3f}s"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-8: emit_external_position raises (Invariant 5)
|
||||
|
||||
|
||||
def test_ac8_emit_external_position_raises(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
messages = [_imu(0.0), _attitude(0.0), _gps_3d(0.0), _heartbeat(0.0)]
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
output = EstimatorOutput(
|
||||
frame_id=__import__("uuid").uuid4(),
|
||||
position_wgs84=LatLonAlt(lat_deg=0.0, lon_deg=0.0, alt_m=0.0),
|
||||
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
|
||||
velocity_world_mps=(0.0, 0.0, 0.0),
|
||||
covariance_6x6=__import__("numpy").eye(6),
|
||||
source_label=PoseSourceLabel.SATELLITE_ANCHORED,
|
||||
last_satellite_anchor_age_ms=0,
|
||||
smoothed=False,
|
||||
emitted_at=0,
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FcEmitError, match="replay adapter does not emit to FC"):
|
||||
adapter.emit_external_position(output)
|
||||
|
||||
|
||||
def test_ac8_emit_status_text_raises(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
from gps_denied_onboard._types.fc import Severity
|
||||
messages = [_imu(0.0), _attitude(0.0), _gps_3d(0.0), _heartbeat(0.0)]
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(FcEmitError, match="replay adapter does not emit to FC"):
|
||||
adapter.emit_status_text("hello", Severity.INFO)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-9: source_set_switch unsupported
|
||||
|
||||
|
||||
def test_ac9_source_set_switch_unsupported(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
messages = [_imu(0.0), _attitude(0.0), _gps_3d(0.0), _heartbeat(0.0)]
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(SourceSetSwitchNotSupportedError):
|
||||
adapter.request_source_set_switch()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Subscriber fan-out + current_flight_state + warm-start hint
|
||||
|
||||
|
||||
def test_multi_subscriber_fanout(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
messages = [_imu(0.0), _attitude(0.001), _gps_3d(0.002), _heartbeat(0.003)]
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
a: list[FcTelemetryFrame] = []
|
||||
b: list[FcTelemetryFrame] = []
|
||||
adapter.subscribe_telemetry(a.append)
|
||||
adapter.subscribe_telemetry(b.append)
|
||||
|
||||
# Act
|
||||
adapter.open()
|
||||
_wait_for(lambda: len(a) == 4 and len(b) == 4)
|
||||
adapter.close()
|
||||
|
||||
# Assert
|
||||
assert len(a) == 4
|
||||
assert len(b) == 4
|
||||
assert [f.kind for f in a] == [f.kind for f in b]
|
||||
|
||||
|
||||
def test_current_flight_state_returns_init_before_first_heartbeat(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange — adapter pre-open, no decoded frames yet.
|
||||
messages = [_imu(0.0), _attitude(0.0), _gps_3d(0.0), _heartbeat(0.0)]
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
|
||||
# Act
|
||||
state = adapter.current_flight_state()
|
||||
|
||||
# Assert
|
||||
assert state.state is FlightState.INIT
|
||||
assert state.last_valid_gps_hint_wgs84 is None
|
||||
|
||||
|
||||
def test_current_flight_state_reflects_latest_heartbeat(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
messages = [
|
||||
_imu(0.0),
|
||||
_attitude(0.001),
|
||||
_gps_3d(0.002),
|
||||
_heartbeat(0.003, system_status=4), # ACTIVE → IN_FLIGHT
|
||||
]
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
received: list[FcTelemetryFrame] = []
|
||||
adapter.subscribe_telemetry(received.append)
|
||||
|
||||
# Act
|
||||
adapter.open()
|
||||
_wait_for(lambda: len(received) == 4)
|
||||
state = adapter.current_flight_state()
|
||||
adapter.close()
|
||||
|
||||
# Assert
|
||||
assert state.state is FlightState.IN_FLIGHT
|
||||
# Warm-start hint cached from the GPS_RAW_INT 3D fix.
|
||||
assert state.last_valid_gps_hint_wgs84 is not None
|
||||
assert state.last_valid_gps_hint_wgs84.lat_deg == pytest.approx(49.991)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Non-monotonic guard (mirror of FrameSource Invariant 3)
|
||||
|
||||
|
||||
def test_non_monotonic_timestamp_raises(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange — synchronous feed (no open()) avoids the decode-thread
|
||||
# race; we are exercising the dispatch-side guard, not the parser.
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=[],
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
|
||||
# Act
|
||||
adapter.feed_one_message(_imu(1.0))
|
||||
adapter.feed_one_message(_attitude(1.001))
|
||||
adapter.feed_one_message(_gps_3d(1.002))
|
||||
adapter.feed_one_message(_heartbeat(1.003))
|
||||
|
||||
# Assert — backwards IMU triggers the dispatch-side guard.
|
||||
with pytest.raises(FcOpenError, match="non-monotonic"):
|
||||
adapter.feed_one_message(_imu(0.5))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Open-side INFO log + FDR mirror
|
||||
|
||||
|
||||
def test_open_emits_info_log_and_fdr_record(
|
||||
existing_tlog: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
fake_wgs_converter: mock.MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
messages = [_imu(0.0), _attitude(0.0), _gps_3d(0.0), _heartbeat(0.0)]
|
||||
adapter, _ = _make_adapter(
|
||||
tlog_path=existing_tlog,
|
||||
messages=messages,
|
||||
fdr_client=fake_fdr_client,
|
||||
wgs_converter=fake_wgs_converter,
|
||||
)
|
||||
|
||||
# Act
|
||||
with caplog.at_level("INFO"):
|
||||
adapter.open()
|
||||
adapter.close()
|
||||
|
||||
# Assert — INFO log with structured kind.
|
||||
info_kinds = {rec.__dict__.get("kind") for rec in caplog.records}
|
||||
assert "c8.tlog_replay.opened" in info_kinds
|
||||
# FDR mirror.
|
||||
assert fake_fdr_client.enqueue.called
|
||||
payload = fake_fdr_client.enqueue.call_args.args[0].payload
|
||||
assert payload["kind"] == "c8.tlog_replay.opened"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Required message catalog sanity (catches accidental drift)
|
||||
|
||||
|
||||
def test_required_message_catalog_includes_all_groups() -> None:
|
||||
# Act / Assert — no test setup; pure module-level invariant.
|
||||
for required in ("RAW_IMU", "SCALED_IMU2", "ATTITUDE", "GPS_RAW_INT", "GPS2_RAW", "HEARTBEAT"):
|
||||
assert required in REQUIRED_MESSAGE_TYPES
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
||||
|
||||
def _wait_for(predicate: Any, *, timeout_s: float = 2.0, poll_s: float = 0.005) -> None:
|
||||
"""Spin until ``predicate()`` is truthy or ``timeout_s`` elapses.
|
||||
|
||||
Replaces ``time.sleep(N)`` so threaded fan-out tests stay fast on
|
||||
Tier-1 hardware while staying deterministic on slower CI runners.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(poll_s)
|
||||
raise AssertionError(f"predicate did not become truthy within {timeout_s}s")
|
||||
@@ -0,0 +1,432 @@
|
||||
"""AZ-400 — `ReplaySink` Protocol + `JsonlReplaySink` unit tests.
|
||||
|
||||
Covers AC-1 through AC-10 of the AZ-400 task spec
|
||||
(``_docs/02_tasks/todo/AZ-400_replay_jsonl_sink.md``) plus the
|
||||
contract-aligned schema match against
|
||||
``EstimatorOutput.__dataclass_fields__``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
from gps_denied_onboard._types.state import EstimatorOutput, PoseSourceLabel, Quat
|
||||
from gps_denied_onboard.components.c8_fc_adapter import ReplaySink
|
||||
from gps_denied_onboard.components.c8_fc_adapter.replay_sink import (
|
||||
JsonlReplaySink,
|
||||
ReplaySinkConfigError,
|
||||
ReplaySinkError,
|
||||
create,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Fixtures
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _build_flag_on(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("BUILD_REPLAY_SINK_JSONL", "ON")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_fdr_client() -> mock.MagicMock:
|
||||
return mock.MagicMock(name="FdrClient")
|
||||
|
||||
|
||||
def _make_output(
|
||||
*,
|
||||
frame_id: UUID | None = None,
|
||||
covariance: np.ndarray | None = None,
|
||||
source_label: PoseSourceLabel = PoseSourceLabel.SATELLITE_ANCHORED,
|
||||
smoothed: bool = False,
|
||||
last_anchor_age_ms: int = 250,
|
||||
emitted_at: int = 1_700_000_000_000_000_000,
|
||||
) -> EstimatorOutput:
|
||||
cov = covariance if covariance is not None else np.eye(6, dtype=np.float64) * 0.5
|
||||
return EstimatorOutput(
|
||||
frame_id=frame_id if frame_id is not None else uuid4(),
|
||||
position_wgs84=LatLonAlt(lat_deg=49.991, lon_deg=36.221, alt_m=153.4),
|
||||
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
|
||||
velocity_world_mps=(1.5, -0.25, 0.0),
|
||||
covariance_6x6=cov,
|
||||
source_label=source_label,
|
||||
last_satellite_anchor_age_ms=last_anchor_age_ms,
|
||||
smoothed=smoothed,
|
||||
emitted_at=emitted_at,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-1: Protocol conformance
|
||||
|
||||
|
||||
def test_ac1_protocol_conformance(tmp_path: Path, fake_fdr_client: mock.MagicMock) -> None:
|
||||
# Act
|
||||
sink = JsonlReplaySink(tmp_path / "out.jsonl", fake_fdr_client)
|
||||
|
||||
# Assert
|
||||
assert isinstance(sink, ReplaySink)
|
||||
sink.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2: One JSON per emit (100 records → 100 lines)
|
||||
|
||||
|
||||
def test_ac2_one_json_per_emit(tmp_path: Path, fake_fdr_client: mock.MagicMock) -> None:
|
||||
# Arrange
|
||||
out_path = tmp_path / "many.jsonl"
|
||||
sink = JsonlReplaySink(out_path, fake_fdr_client)
|
||||
|
||||
# Act
|
||||
for i in range(100):
|
||||
sink.emit(_make_output(emitted_at=1_700_000_000_000_000_000 + i))
|
||||
sink.close()
|
||||
|
||||
# Assert
|
||||
body = out_path.read_text(encoding="utf-8")
|
||||
lines = body.splitlines()
|
||||
assert len(lines) == 100
|
||||
for line in lines:
|
||||
# Every line is a self-contained JSON object.
|
||||
decoded = json.loads(line)
|
||||
assert isinstance(decoded, dict)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3: Schema match (every dataclass field present)
|
||||
|
||||
|
||||
def test_ac3_schema_matches_dataclass_fields(
|
||||
tmp_path: Path, fake_fdr_client: mock.MagicMock
|
||||
) -> None:
|
||||
# Arrange
|
||||
out_path = tmp_path / "schema.jsonl"
|
||||
sink = JsonlReplaySink(out_path, fake_fdr_client)
|
||||
output = _make_output()
|
||||
expected_keys = set(dataclasses.fields(EstimatorOutput))
|
||||
expected_field_names = {field.name for field in expected_keys}
|
||||
|
||||
# Act
|
||||
sink.emit(output)
|
||||
sink.close()
|
||||
|
||||
# Assert
|
||||
[line] = out_path.read_text(encoding="utf-8").splitlines()
|
||||
decoded = json.loads(line)
|
||||
assert set(decoded.keys()) == expected_field_names
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4: numpy → flat list of 36 floats
|
||||
|
||||
|
||||
def test_ac4_numpy_to_flat_list(tmp_path: Path, fake_fdr_client: mock.MagicMock) -> None:
|
||||
# Arrange
|
||||
out_path = tmp_path / "cov.jsonl"
|
||||
sink = JsonlReplaySink(out_path, fake_fdr_client)
|
||||
|
||||
# Act
|
||||
sink.emit(_make_output(covariance=np.eye(6, dtype=np.float64)))
|
||||
sink.close()
|
||||
|
||||
# Assert
|
||||
[line] = out_path.read_text(encoding="utf-8").splitlines()
|
||||
decoded = json.loads(line)
|
||||
cov = decoded["covariance_6x6"]
|
||||
assert isinstance(cov, list)
|
||||
assert len(cov) == 36
|
||||
expected = np.eye(6, dtype=np.float64).flatten().tolist()
|
||||
assert cov == expected
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5: enum → string name (NOT the integer/value form)
|
||||
|
||||
|
||||
def test_ac5_enum_to_name_string(tmp_path: Path, fake_fdr_client: mock.MagicMock) -> None:
|
||||
# Arrange
|
||||
out_path = tmp_path / "label.jsonl"
|
||||
sink = JsonlReplaySink(out_path, fake_fdr_client)
|
||||
|
||||
# Act
|
||||
sink.emit(_make_output(source_label=PoseSourceLabel.SATELLITE_ANCHORED))
|
||||
sink.close()
|
||||
|
||||
# Assert
|
||||
[line] = out_path.read_text(encoding="utf-8").splitlines()
|
||||
decoded = json.loads(line)
|
||||
assert decoded["source_label"] == "SATELLITE_ANCHORED"
|
||||
assert decoded["source_label"] != PoseSourceLabel.SATELLITE_ANCHORED.value
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6: missing parent dir raises
|
||||
|
||||
|
||||
def test_ac6_missing_parent_dir_raises(
|
||||
tmp_path: Path, fake_fdr_client: mock.MagicMock
|
||||
) -> None:
|
||||
# Arrange
|
||||
bad_path = tmp_path / "definitely_does_not_exist_dir" / "out.jsonl"
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ReplaySinkError, match="output parent directory does not exist"):
|
||||
JsonlReplaySink(bad_path, fake_fdr_client)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7: close fsyncs (smoke check via fsync mock + size match)
|
||||
|
||||
|
||||
def test_ac7_close_fsyncs(tmp_path: Path, fake_fdr_client: mock.MagicMock) -> None:
|
||||
# Arrange
|
||||
out_path = tmp_path / "fsync.jsonl"
|
||||
sink = JsonlReplaySink(out_path, fake_fdr_client)
|
||||
for i in range(100):
|
||||
sink.emit(_make_output(emitted_at=i))
|
||||
|
||||
# Act
|
||||
with mock.patch("os.fsync") as fsync_mock:
|
||||
sink.close()
|
||||
|
||||
# Assert
|
||||
fsync_mock.assert_called_once()
|
||||
expected_lines = 100
|
||||
actual_lines = len(out_path.read_text(encoding="utf-8").splitlines())
|
||||
assert actual_lines == expected_lines
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-8: double close is idempotent (second call no-ops + DEBUG log)
|
||||
|
||||
|
||||
def test_ac8_double_close_idempotent(
|
||||
tmp_path: Path, fake_fdr_client: mock.MagicMock, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
# Arrange
|
||||
out_path = tmp_path / "double.jsonl"
|
||||
sink = JsonlReplaySink(out_path, fake_fdr_client)
|
||||
sink.emit(_make_output())
|
||||
|
||||
# Act
|
||||
sink.close()
|
||||
caplog.clear()
|
||||
with caplog.at_level("DEBUG", logger="c8_fc_adapter.replay_sink"):
|
||||
sink.close()
|
||||
|
||||
# Assert
|
||||
debug_kinds = [
|
||||
record.kind # type: ignore[attr-defined]
|
||||
for record in caplog.records
|
||||
if hasattr(record, "kind")
|
||||
]
|
||||
assert "replay.sink.double_close" in debug_kinds
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-9: lines_written reported on close (INFO log carries the count)
|
||||
|
||||
|
||||
def test_ac9_lines_written_reported_on_close(
|
||||
tmp_path: Path, fake_fdr_client: mock.MagicMock, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
# Arrange
|
||||
out_path = tmp_path / "count.jsonl"
|
||||
sink = JsonlReplaySink(out_path, fake_fdr_client)
|
||||
for _ in range(100):
|
||||
sink.emit(_make_output())
|
||||
|
||||
# Act
|
||||
with caplog.at_level("INFO", logger="c8_fc_adapter.replay_sink"):
|
||||
sink.close()
|
||||
|
||||
# Assert
|
||||
closed_records = [
|
||||
record for record in caplog.records if getattr(record, "kind", "") == "replay.sink.closed"
|
||||
]
|
||||
assert len(closed_records) == 1
|
||||
kv = closed_records[0].kv # type: ignore[attr-defined]
|
||||
assert kv["lines_written"] == 100
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-10: build-flag gating
|
||||
|
||||
|
||||
def test_ac10_build_flag_off_raises(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
monkeypatch.setenv("BUILD_REPLAY_SINK_JSONL", "OFF")
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ReplaySinkConfigError, match="BUILD_REPLAY_SINK_JSONL is OFF"):
|
||||
JsonlReplaySink(tmp_path / "out.jsonl", fake_fdr_client)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Schema fidelity — round-trip every documented per-field shape rule
|
||||
|
||||
|
||||
def test_schema_round_trip_all_fields(
|
||||
tmp_path: Path, fake_fdr_client: mock.MagicMock
|
||||
) -> None:
|
||||
# Arrange
|
||||
out_path = tmp_path / "round_trip.jsonl"
|
||||
sink = JsonlReplaySink(out_path, fake_fdr_client)
|
||||
cov = np.arange(36, dtype=np.float64).reshape(6, 6) * 0.001
|
||||
output = _make_output(
|
||||
frame_id=UUID("12345678-1234-5678-1234-567812345678"),
|
||||
covariance=cov,
|
||||
source_label=PoseSourceLabel.VISUAL_PROPAGATED,
|
||||
smoothed=False,
|
||||
last_anchor_age_ms=125,
|
||||
emitted_at=1_700_000_000_000_000_001,
|
||||
)
|
||||
|
||||
# Act
|
||||
sink.emit(output)
|
||||
sink.close()
|
||||
|
||||
# Assert
|
||||
[line] = out_path.read_text(encoding="utf-8").splitlines()
|
||||
decoded = json.loads(line)
|
||||
assert decoded["frame_id"] == "12345678-1234-5678-1234-567812345678"
|
||||
assert decoded["position_wgs84"] == {"lat_deg": 49.991, "lon_deg": 36.221, "alt_m": 153.4}
|
||||
assert decoded["orientation_world_T_body"] == {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0,
|
||||
}
|
||||
assert decoded["velocity_world_mps"] == [1.5, -0.25, 0.0]
|
||||
assert decoded["covariance_6x6"] == cov.flatten().tolist()
|
||||
assert decoded["source_label"] == "VISUAL_PROPAGATED"
|
||||
assert decoded["last_satellite_anchor_age_ms"] == 125
|
||||
assert decoded["smoothed"] is False
|
||||
assert decoded["emitted_at"] == 1_700_000_000_000_000_001
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Error paths
|
||||
|
||||
|
||||
def test_emit_after_close_raises(
|
||||
tmp_path: Path, fake_fdr_client: mock.MagicMock
|
||||
) -> None:
|
||||
# Arrange
|
||||
sink = JsonlReplaySink(tmp_path / "err.jsonl", fake_fdr_client)
|
||||
sink.close()
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ReplaySinkError, match="emit on closed JsonlReplaySink"):
|
||||
sink.emit(_make_output())
|
||||
|
||||
|
||||
def test_emit_open_log_emitted(
|
||||
tmp_path: Path, fake_fdr_client: mock.MagicMock, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
# Arrange + Act
|
||||
out_path = tmp_path / "open.jsonl"
|
||||
with caplog.at_level("INFO", logger="c8_fc_adapter.replay_sink"):
|
||||
sink = JsonlReplaySink(out_path, fake_fdr_client)
|
||||
sink.close()
|
||||
|
||||
# Assert
|
||||
open_records = [
|
||||
record for record in caplog.records if getattr(record, "kind", "") == "replay.sink.opened"
|
||||
]
|
||||
assert len(open_records) == 1
|
||||
kv = open_records[0].kv # type: ignore[attr-defined]
|
||||
assert kv["output_path"] == str(out_path)
|
||||
|
||||
|
||||
def test_fdr_open_close_events_emitted(
|
||||
tmp_path: Path, fake_fdr_client: mock.MagicMock
|
||||
) -> None:
|
||||
# Arrange
|
||||
out_path = tmp_path / "fdr.jsonl"
|
||||
|
||||
# Act
|
||||
sink = JsonlReplaySink(out_path, fake_fdr_client)
|
||||
sink.emit(_make_output())
|
||||
sink.close()
|
||||
|
||||
# Assert — open + close FDR records mirror the structured log surface.
|
||||
enqueued_kinds = []
|
||||
for call in fake_fdr_client.enqueue.call_args_list:
|
||||
record = call.args[0]
|
||||
enqueued_kinds.append(record.payload["kind"])
|
||||
assert "replay.sink.opened" in enqueued_kinds
|
||||
assert "replay.sink.closed" in enqueued_kinds
|
||||
|
||||
|
||||
def test_emit_progress_logged_every_1000(
|
||||
tmp_path: Path,
|
||||
fake_fdr_client: mock.MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
sink = JsonlReplaySink(tmp_path / "progress.jsonl", fake_fdr_client)
|
||||
|
||||
# Act
|
||||
with caplog.at_level("DEBUG", logger="c8_fc_adapter.replay_sink"):
|
||||
for _ in range(2000):
|
||||
sink.emit(_make_output())
|
||||
sink.close()
|
||||
|
||||
# Assert
|
||||
progress_records = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if getattr(record, "kind", "") == "replay.sink.emit_progress"
|
||||
]
|
||||
assert len(progress_records) == 2 # one at 1000, one at 2000
|
||||
|
||||
|
||||
def test_module_factory_create_returns_sink(
|
||||
tmp_path: Path, fake_fdr_client: mock.MagicMock
|
||||
) -> None:
|
||||
# Act
|
||||
sink = create(output_path=tmp_path / "factory.jsonl", fdr_client=fake_fdr_client)
|
||||
|
||||
# Assert
|
||||
assert isinstance(sink, JsonlReplaySink)
|
||||
sink.close()
|
||||
|
||||
|
||||
def test_emit_p99_latency_under_1ms(
|
||||
tmp_path: Path, fake_fdr_client: mock.MagicMock
|
||||
) -> None:
|
||||
# Arrange
|
||||
sink = JsonlReplaySink(tmp_path / "perf.jsonl", fake_fdr_client)
|
||||
output = _make_output()
|
||||
samples_ns: list[int] = []
|
||||
|
||||
# Act
|
||||
for _ in range(500):
|
||||
t0 = time.monotonic_ns()
|
||||
sink.emit(output)
|
||||
samples_ns.append(time.monotonic_ns() - t0)
|
||||
sink.close()
|
||||
|
||||
# Assert — orjson + unbuffered write should be well under 1ms p99 on
|
||||
# any developer host. 5ms ceiling absorbs noisy CI sandboxes.
|
||||
samples_ns.sort()
|
||||
p99_ns = samples_ns[int(len(samples_ns) * 0.99) - 1]
|
||||
assert p99_ns < 5_000_000, f"p99 emit latency {p99_ns}ns exceeded 5ms ceiling"
|
||||
Reference in New Issue
Block a user