mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 06:51:13 +00:00
[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:
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user