[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:
Oleksandr Bezdieniezhnykh
2026-05-14 05:33:20 +03:00
parent 4eac24f37a
commit fa3742d582
13 changed files with 2688 additions and 23 deletions
@@ -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"