[AZ-401] [AZ-400] Replay — compose_root replay-mode branch + transport seam

Wires the airborne composition root for replay-as-configuration (ADR-011):

- compose_root(config) branches on config.mode in {"live", "replay"}.
  Live behaviour is unchanged; replay builds ReplayInputAdapter,
  attaches JsonlReplaySink, and injects NoopMavlinkTransport.
- New private module runtime_root/_replay_branch.py holds the
  replay-only strategy graph + build-flag gate + calibration loader.
- Config gains Config.mode (Literal["live","replay"]) plus
  Config.replay sub-block with nested ReplayAutoSyncConfig that mirrors
  the AZ-405 AutoSyncConfig DTO; YAML loader + ENV map updated.

Absorbs the AZ-400 transport-seam retrofit that AZ-401 strictly
required but AZ-400 had not delivered:

- New MavlinkTransport Protocol (write/bytes_written/close).
- NoopMavlinkTransport (replay; build-flag gated, idempotent close,
  thread-safe byte counter).
- SerialMavlinkTransport (live, no-op restructure of existing pymavlink
  byte path; encoder retrofit to actually USE it is the AZ-558
  follow-up).

AZ-401 AC-9 (NoopMavlinkTransport.bytes_written > 0 after C8 encoders
run) is BLOCKED on AZ-558 — the encoder routing retrofit is out of
the AZ-401 task envelope (FORBIDDEN files: pymavlink_ardupilot_adapter,
msp2_inav_adapter). AZ-558 spec, batch_61_review.md, and the test's
@pytest.mark.skip rationale all carry the deferral reason.

Tests: 22 compose_root replay-branch tests + 17 transport tests.
Full regression: 2063 passed, 86 environment-skips, 1 documented
skip (AC-9 / AZ-558), 1 pre-existing flaky perf test deselected.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 11:55:33 +03:00
parent 8149083cac
commit 17a0d074af
19 changed files with 2156 additions and 45 deletions
@@ -0,0 +1,287 @@
"""AZ-400 retrofit — `MavlinkTransport` Protocol + Noop / Serial impls.
Covers the part of AZ-400 that the v1.0.0 sprint deferred:
the transport seam declared by the replay contract Invariant 5 and
required by AZ-401's ``compose_root`` replay branch (per
``_docs/02_document/contracts/replay/replay_protocol.md`` v2.0.0 lines
14, 109, 222, 237).
Per-test references:
- AC-Transport-1 — protocol conformance
- AC-Transport-2 — noop accepts every byte length, counts cumulatively
- AC-Transport-3 — serial forwards bytes through the underlying connection
- AC-Transport-4 — both raise on write-after-close
- AC-Transport-5 — close is idempotent
- AC-Transport-6 — build flag OFF refuses noop construction
- AC-Transport-7 — serial OSError surfaces as ``MavlinkTransportError``
"""
from __future__ import annotations
from typing import Any
from unittest import mock
import pytest
from gps_denied_onboard.components.c8_fc_adapter import MavlinkTransport
from gps_denied_onboard.components.c8_fc_adapter.errors import (
MavlinkTransportConfigError,
MavlinkTransportError,
)
from gps_denied_onboard.components.c8_fc_adapter.noop_mavlink_transport import (
NoopMavlinkTransport,
)
from gps_denied_onboard.components.c8_fc_adapter.serial_mavlink_transport import (
SerialMavlinkTransport,
)
# ----------------------------------------------------------------------
# Fixtures
@pytest.fixture(autouse=True)
def _build_flag_on(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BUILD_REPLAY_SINK_JSONL", "ON")
class _FakeConnection:
"""Stub for ``mavutil.mavlink_connection`` — exposes ``write(bytes)``."""
def __init__(self) -> None:
self.received: list[bytes] = []
self.fail_with: Exception | None = None
def write(self, data: bytes) -> int:
if self.fail_with is not None:
raise self.fail_with
self.received.append(bytes(data))
return len(data)
# ----------------------------------------------------------------------
# AC-Transport-1: Protocol conformance
def test_noop_transport_satisfies_protocol() -> None:
# Act
transport = NoopMavlinkTransport()
# Assert
assert isinstance(transport, MavlinkTransport)
def test_serial_transport_satisfies_protocol() -> None:
# Arrange
conn = _FakeConnection()
# Act
transport = SerialMavlinkTransport(connection=conn)
# Assert
assert isinstance(transport, MavlinkTransport)
# ----------------------------------------------------------------------
# AC-Transport-2: NoopMavlinkTransport accepts and counts bytes
def test_noop_transport_counts_cumulative_bytes() -> None:
# Arrange
transport = NoopMavlinkTransport()
# Act
n1 = transport.write(b"abc")
n2 = transport.write(b"")
n3 = transport.write(b"defgh")
# Assert
assert n1 == 3
assert n2 == 0
assert n3 == 5
assert transport.bytes_written() == 8
def test_noop_transport_accepts_bytes_like_views() -> None:
# Arrange
transport = NoopMavlinkTransport()
# Act
transport.write(bytearray(b"abc"))
transport.write(memoryview(b"def"))
# Assert
assert transport.bytes_written() == 6
def test_noop_transport_rejects_non_bytes() -> None:
# Arrange
transport = NoopMavlinkTransport()
# Act / Assert
with pytest.raises(MavlinkTransportError, match="bytes-like"):
transport.write("not-bytes") # type: ignore[arg-type]
# ----------------------------------------------------------------------
# AC-Transport-3: SerialMavlinkTransport forwards bytes
def test_serial_transport_forwards_bytes_to_underlying_connection() -> None:
# Arrange
conn = _FakeConnection()
transport = SerialMavlinkTransport(connection=conn)
# Act
n = transport.write(b"hello")
# Assert
assert n == 5
assert conn.received == [b"hello"]
assert transport.bytes_written() == 5
def test_serial_transport_rejects_missing_write_method() -> None:
# Arrange
class _NoWrite:
pass
# Act / Assert
with pytest.raises(MavlinkTransportError, match=r"\.write\(bytes\)"):
SerialMavlinkTransport(connection=_NoWrite())
def test_serial_transport_rejects_none_connection() -> None:
# Act / Assert
with pytest.raises(MavlinkTransportError, match="open pymavlink connection"):
SerialMavlinkTransport(connection=None)
# ----------------------------------------------------------------------
# AC-Transport-4: write after close raises
def test_noop_transport_write_after_close_raises() -> None:
# Arrange
transport = NoopMavlinkTransport()
transport.write(b"first")
transport.close()
# Act / Assert
with pytest.raises(MavlinkTransportError, match="closed"):
transport.write(b"second")
def test_serial_transport_write_after_close_raises() -> None:
# Arrange
conn = _FakeConnection()
transport = SerialMavlinkTransport(connection=conn)
transport.write(b"first")
transport.close()
# Act / Assert
with pytest.raises(MavlinkTransportError, match="closed"):
transport.write(b"second")
# ----------------------------------------------------------------------
# AC-Transport-5: idempotent close
def test_noop_transport_close_is_idempotent() -> None:
# Arrange
transport = NoopMavlinkTransport()
# Act
transport.close()
transport.close() # must not raise
def test_serial_transport_close_is_idempotent() -> None:
# Arrange
conn = _FakeConnection()
transport = SerialMavlinkTransport(connection=conn)
# Act
transport.close()
transport.close() # must not raise
# ----------------------------------------------------------------------
# AC-Transport-6: BUILD flag gating
def test_noop_transport_build_flag_off_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Arrange
monkeypatch.setenv("BUILD_REPLAY_SINK_JSONL", "OFF")
# Act / Assert
with pytest.raises(MavlinkTransportConfigError, match="BUILD_REPLAY_SINK_JSONL is OFF"):
NoopMavlinkTransport()
# ----------------------------------------------------------------------
# AC-Transport-7: SerialMavlinkTransport surfaces OSError as MavlinkTransportError
def test_serial_transport_oserror_wrapped() -> None:
# Arrange
conn = _FakeConnection()
conn.fail_with = OSError("device disconnected")
transport = SerialMavlinkTransport(connection=conn)
# Act / Assert
with pytest.raises(MavlinkTransportError, match="underlying write failed"):
transport.write(b"abc")
# ----------------------------------------------------------------------
# bytes_written reads safely after close
def test_noop_bytes_written_after_close_returns_total() -> None:
# Arrange
transport = NoopMavlinkTransport()
transport.write(b"abcd")
transport.close()
# Assert
assert transport.bytes_written() == 4
def test_serial_bytes_written_after_close_returns_total() -> None:
# Arrange
conn = _FakeConnection()
transport = SerialMavlinkTransport(connection=conn)
transport.write(b"abcdef")
transport.close()
# Assert
assert transport.bytes_written() == 6
# ----------------------------------------------------------------------
# Smoke: serial transport handles ``returned is None`` from underlying write
def test_serial_transport_falls_back_to_payload_length_when_write_returns_none() -> None:
# Arrange
conn = mock.MagicMock(spec=["write"])
conn.write.return_value = None
transport = SerialMavlinkTransport(connection=conn)
# Act
n = transport.write(b"abcde")
# Assert
assert n == 5
assert transport.bytes_written() == 5
# ----------------------------------------------------------------------
# Smoke: ad-hoc Any annotation removes pytest unused-import warnings.
_ = Any
+15 -2
View File
@@ -1,4 +1,9 @@
"""C8 FC Adapter smoke test — AC-9 (legacy) + AZ-390 public-API gate."""
"""C8 FC Adapter smoke test — AC-9 (legacy) + AZ-390 public-API gate.
AZ-401 expands the public Protocol surface with ``MavlinkTransport``,
the outbound byte-stream seam shared by ``SerialMavlinkTransport``
(live) and ``NoopMavlinkTransport`` (replay).
"""
def test_interface_importable() -> None:
@@ -7,10 +12,17 @@ def test_interface_importable() -> None:
EmittedExternalPosition,
FcAdapter,
GcsAdapter,
MavlinkTransport,
ReplaySink,
)
for sym in (FcAdapter, GcsAdapter, ReplaySink, EmittedExternalPosition):
for sym in (
FcAdapter,
GcsAdapter,
ReplaySink,
EmittedExternalPosition,
MavlinkTransport,
):
assert sym is not None
@@ -24,5 +36,6 @@ def test_internal_modules_not_in_public_all() -> None:
"EmittedExternalPosition",
"FcAdapter",
"GcsAdapter",
"MavlinkTransport",
"ReplaySink",
}