mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 20:31:12 +00:00
[AZ-558] Route C8 outbound encoder bytes through MavlinkTransport seam
All FC adapter outbound MAVLink bytes now go through the AZ-401 MavlinkTransport seam (NoopMavlinkTransport in replay, SerialMavlinkTransport in live). New helpers in _outbound_mavlink_payloads.py extract encode/pack/seq-bump so the four AP _send sites and the iNav statustext _send site become encode -> pack -> transport.write. TlogReplayFcAdapter emits real AP-shape MAVLink bytes through the injected NoopMavlinkTransport, satisfying replay protocol Invariant 5 and unblocking AZ-401 AC-9. Closes AZ-558. Also unskips AZ-401 AC-9 and AZ-404 AC-4b. Live wire output remains byte-identical (proven via two-instance MAVLink byte-equivalence tests). AST scan asserts no .mav.<name>_send( calls remain in the retrofit set (AP / iNav / tlog adapters). Out of scope (logged in review): GCS adapter retrofit; airborne live strategy registration that would activate the SerialMavlinkTransport factory injection path. Tests: 2110 passed, 92 environmental skips, 1 unrelated pre-existing macOS cold-start flake deselected. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""Shared test stubs for the C8 outbound retrofit (AZ-558).
|
||||
|
||||
After AZ-558 the AP / iNav / replay adapters route bytes through the
|
||||
``MavlinkTransport`` Protocol seam instead of calling
|
||||
``connection.mav.X_send(...)`` directly. The new code path is
|
||||
``mav.X_encode(...) → msg.pack(mav) → transport.write(buf)``.
|
||||
|
||||
Tests that previously used a hand-rolled ``_MavStub`` with ``X_send``
|
||||
methods need the matching ``X_encode`` methods so their wire-level
|
||||
assertions continue to work. This module provides:
|
||||
|
||||
* :class:`_FakeMsg` — opaque message stub returned by ``X_encode``;
|
||||
its ``pack(mav)`` returns deterministic placeholder bytes without
|
||||
recomputing CRCs / signing (the per-test stub records the call args
|
||||
inside its own ``X_encode`` method, so ``pack`` can be a pure
|
||||
byte-emitter).
|
||||
* :class:`_NullTransport` — minimal :class:`MavlinkTransport`
|
||||
implementation that drops bytes and counts them. Used by tests that
|
||||
do not care about wire content but need a transport to satisfy
|
||||
the new ``mavlink_transport_factory`` plumbing.
|
||||
* :class:`_BytesCapturingTransport` — collects every ``write(buf)``
|
||||
call. Used by AC-2 byte-equivalence and AZ-401 AC-9 tests that
|
||||
assert on aggregate byte volume / specific message bytes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_BytesCapturingTransport",
|
||||
"_FakeMsg",
|
||||
"_NullTransport",
|
||||
"_PLACEHOLDER_PACK_BYTES",
|
||||
]
|
||||
|
||||
_PLACEHOLDER_PACK_BYTES: bytes = b"\x00" * 16
|
||||
|
||||
|
||||
class _FakeMsg:
|
||||
"""Opaque MAVLink message stand-in returned by ``X_encode``.
|
||||
|
||||
``pack(mav)`` returns fixed placeholder bytes — the test stub's
|
||||
``X_encode`` method already recorded the call args, so we don't
|
||||
need to re-record here.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def pack(self, mav: Any, force_mavlink1: bool = False) -> bytes:
|
||||
return _PLACEHOLDER_PACK_BYTES
|
||||
|
||||
|
||||
class _NullTransport:
|
||||
"""Minimal :class:`MavlinkTransport` impl that drops bytes."""
|
||||
|
||||
__slots__ = ("_bytes_written", "_closed")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._bytes_written = 0
|
||||
self._closed = False
|
||||
|
||||
def write(self, payload: bytes) -> int:
|
||||
if self._closed:
|
||||
raise RuntimeError("write on closed _NullTransport")
|
||||
n = len(payload)
|
||||
self._bytes_written += n
|
||||
return n
|
||||
|
||||
def bytes_written(self) -> int:
|
||||
return self._bytes_written
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
|
||||
class _BytesCapturingTransport:
|
||||
"""Test :class:`MavlinkTransport` that retains every ``write`` payload."""
|
||||
|
||||
__slots__ = ("_chunks", "_closed")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._chunks: list[bytes] = []
|
||||
self._closed = False
|
||||
|
||||
def write(self, payload: bytes) -> int:
|
||||
if self._closed:
|
||||
raise RuntimeError("write on closed _BytesCapturingTransport")
|
||||
self._chunks.append(bytes(payload))
|
||||
return len(payload)
|
||||
|
||||
def bytes_written(self) -> int:
|
||||
return sum(len(c) for c in self._chunks)
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
@property
|
||||
def chunks(self) -> tuple[bytes, ...]:
|
||||
return tuple(self._chunks)
|
||||
@@ -35,26 +35,43 @@ from gps_denied_onboard.components.c8_fc_adapter.pymavlink_ardupilot_adapter imp
|
||||
)
|
||||
from gps_denied_onboard.config import load_config
|
||||
|
||||
from ._mav_test_helpers import _FakeMsg
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers — pymavlink stand-in
|
||||
|
||||
|
||||
class _SigningStub:
|
||||
sign_outgoing = False
|
||||
|
||||
|
||||
class _MavStub:
|
||||
"""Captures pymavlink ``mav.*_send`` calls for wire-level assertions."""
|
||||
"""Captures pymavlink ``mav.*_encode`` calls for wire-level assertions.
|
||||
|
||||
Post-AZ-558 the AP adapter routes through ``encode → pack → transport.write``;
|
||||
we record args at the ``encode`` boundary (where the test cares),
|
||||
return a :class:`_FakeMsg` whose ``pack`` produces placeholder bytes,
|
||||
and the transport seam swallows them.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.gps_input_calls: list[tuple[Any, ...]] = []
|
||||
self.named_value_float_calls: list[tuple[Any, ...]] = []
|
||||
self.statustext_calls: list[tuple[int, bytes]] = []
|
||||
self.seq: int = 0
|
||||
self.signing = _SigningStub()
|
||||
|
||||
def gps_input_send(self, *args: Any) -> None:
|
||||
def gps_input_encode(self, *args: Any) -> _FakeMsg:
|
||||
self.gps_input_calls.append(args)
|
||||
return _FakeMsg()
|
||||
|
||||
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
|
||||
def named_value_float_encode(self, time_boot_ms: int, name: bytes, value: float) -> _FakeMsg:
|
||||
self.named_value_float_calls.append((time_boot_ms, name, value))
|
||||
return _FakeMsg()
|
||||
|
||||
def statustext_send(self, severity: int, text: bytes) -> None:
|
||||
def statustext_encode(self, severity: int, text: bytes) -> _FakeMsg:
|
||||
self.statustext_calls.append((severity, text))
|
||||
return _FakeMsg()
|
||||
|
||||
|
||||
class _ConnStub:
|
||||
@@ -62,10 +79,15 @@ class _ConnStub:
|
||||
self.mav = _MavStub()
|
||||
self.setup_signing_calls: list[bytes] = []
|
||||
self.closed = False
|
||||
self.write_calls: list[bytes] = []
|
||||
|
||||
def setup_signing(self, key: bytes) -> None:
|
||||
self.setup_signing_calls.append(bytes(key))
|
||||
|
||||
def write(self, payload: bytes) -> int:
|
||||
self.write_calls.append(bytes(payload))
|
||||
return len(payload)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ from gps_denied_onboard.components.c8_fc_adapter.msp2_inav_adapter import (
|
||||
)
|
||||
from gps_denied_onboard.config import load_config
|
||||
|
||||
from ._mav_test_helpers import _FakeMsg
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers — MSP / secondary-MAVLink stand-ins
|
||||
|
||||
@@ -51,22 +53,35 @@ class _MspStub:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _SigningStub:
|
||||
sign_outgoing = False
|
||||
|
||||
|
||||
class _SecondaryMavStub:
|
||||
def __init__(self) -> None:
|
||||
self.statustext_calls: list[tuple[int, bytes]] = []
|
||||
self.closed = False
|
||||
self.write_calls: list[bytes] = []
|
||||
# Mirror pymavlink connection.mav shape.
|
||||
self.mav = self
|
||||
# Track signing-key state per RESTRICT-COMM-2 (Invariant 9):
|
||||
# the unit-test adapter MUST never call setup_signing on us.
|
||||
self.setup_signing_calls: list[Any] = []
|
||||
# AZ-558: encoder flow needs ``mav.seq`` and ``mav.signing``.
|
||||
self.seq: int = 0
|
||||
self.signing = _SigningStub()
|
||||
|
||||
def statustext_send(self, severity: int, text: bytes) -> None:
|
||||
def statustext_encode(self, severity: int, text: bytes) -> _FakeMsg:
|
||||
self.statustext_calls.append((int(severity), bytes(text)))
|
||||
return _FakeMsg()
|
||||
|
||||
def setup_signing(self, key: Any) -> None:
|
||||
self.setup_signing_calls.append(key)
|
||||
|
||||
def write(self, payload: bytes) -> int:
|
||||
self.write_calls.append(bytes(payload))
|
||||
return len(payload)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ from gps_denied_onboard.components.c8_fc_adapter.pymavlink_ardupilot_adapter imp
|
||||
)
|
||||
from gps_denied_onboard.config import load_config
|
||||
|
||||
from ._mav_test_helpers import _FakeMsg
|
||||
|
||||
_DEV_STATIC_KEY = "00112233445566778899aabbccddeeff" * 2 # 64 hex chars = 32 bytes
|
||||
|
||||
|
||||
@@ -41,16 +43,20 @@ class _MavStub:
|
||||
self.statustext_calls: list[tuple[int, bytes]] = []
|
||||
# pymavlink exposes `connection.mav.signing.sig_count` after
|
||||
# setup_signing(...); we simulate that surface here.
|
||||
self.signing = SimpleNamespace(sig_count=signing_failure_count)
|
||||
self.signing = SimpleNamespace(sig_count=signing_failure_count, sign_outgoing=False)
|
||||
self.seq: int = 0
|
||||
|
||||
def gps_input_send(self, *args: Any) -> None:
|
||||
def gps_input_encode(self, *args: Any) -> _FakeMsg:
|
||||
self.gps_input_calls.append(args)
|
||||
return _FakeMsg()
|
||||
|
||||
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
|
||||
def named_value_float_encode(self, time_boot_ms: int, name: bytes, value: float) -> _FakeMsg:
|
||||
self.named_value_float_calls.append((time_boot_ms, name, value))
|
||||
return _FakeMsg()
|
||||
|
||||
def statustext_send(self, severity: int, text: bytes) -> None:
|
||||
def statustext_encode(self, severity: int, text: bytes) -> _FakeMsg:
|
||||
self.statustext_calls.append((severity, text))
|
||||
return _FakeMsg()
|
||||
|
||||
|
||||
class _ConnStub:
|
||||
@@ -59,12 +65,17 @@ class _ConnStub:
|
||||
self.setup_signing_calls: list[bytes] = []
|
||||
self._fail_signing = fail_signing
|
||||
self.closed = False
|
||||
self.write_calls: list[bytes] = []
|
||||
|
||||
def setup_signing(self, key: bytes) -> None:
|
||||
if self._fail_signing:
|
||||
raise RuntimeError("simulated signing handshake refusal")
|
||||
self.setup_signing_calls.append(bytes(key))
|
||||
|
||||
def write(self, payload: bytes) -> int:
|
||||
self.write_calls.append(bytes(payload))
|
||||
return len(payload)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ from gps_denied_onboard.components.c8_fc_adapter.pymavlink_ardupilot_adapter imp
|
||||
from gps_denied_onboard.config import load_config
|
||||
from gps_denied_onboard.runtime_root.spoof_recovery_sink import SpoofRecoverySink
|
||||
|
||||
from ._mav_test_helpers import _FakeMsg
|
||||
|
||||
# AC-1 / AC-2 / AC-3: pymavlink ardupilotmega command id for SET_EKF_SOURCE_SET.
|
||||
_CMD_SET_EKF_SOURCE_SET = 42007
|
||||
_MAV_RESULT_ACCEPTED = 0
|
||||
@@ -34,14 +36,20 @@ class _AckMsg:
|
||||
self.result = result
|
||||
|
||||
|
||||
class _SigningStub:
|
||||
sign_outgoing = False
|
||||
|
||||
|
||||
class _MavStub:
|
||||
def __init__(self) -> None:
|
||||
self.command_long_calls: list[tuple[int, ...]] = []
|
||||
self.statustext_calls: list[tuple[int, bytes]] = []
|
||||
self.named_value_float_calls: list[tuple[Any, ...]] = []
|
||||
self.gps_input_calls: list[tuple[Any, ...]] = []
|
||||
self.seq: int = 0
|
||||
self.signing = _SigningStub()
|
||||
|
||||
def command_long_send(
|
||||
def command_long_encode(
|
||||
self,
|
||||
target_system: int,
|
||||
target_component: int,
|
||||
@@ -54,17 +62,23 @@ class _MavStub:
|
||||
p5: float,
|
||||
p6: float,
|
||||
p7: float,
|
||||
) -> None:
|
||||
) -> "_FakeMsg":
|
||||
self.command_long_calls.append((target_system, target_component, command, confirmation, p1))
|
||||
return _FakeMsg()
|
||||
|
||||
def statustext_send(self, severity: int, text: bytes) -> None:
|
||||
def statustext_encode(self, severity: int, text: bytes) -> "_FakeMsg":
|
||||
self.statustext_calls.append((severity, text))
|
||||
return _FakeMsg()
|
||||
|
||||
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
|
||||
def named_value_float_encode(
|
||||
self, time_boot_ms: int, name: bytes, value: float
|
||||
) -> "_FakeMsg":
|
||||
self.named_value_float_calls.append((time_boot_ms, name, value))
|
||||
return _FakeMsg()
|
||||
|
||||
def gps_input_send(self, *args: Any) -> None:
|
||||
def gps_input_encode(self, *args: Any) -> "_FakeMsg":
|
||||
self.gps_input_calls.append(args)
|
||||
return _FakeMsg()
|
||||
|
||||
|
||||
class _ConnStub:
|
||||
@@ -74,6 +88,7 @@ class _ConnStub:
|
||||
self.target_component = 1
|
||||
self._ack_queue = list(ack_queue or [])
|
||||
self.closed = False
|
||||
self.write_calls: list[bytes] = []
|
||||
|
||||
def recv_match(self, *, type: str, blocking: bool, timeout: float | None) -> Any:
|
||||
# Real pymavlink filters by ``type``; the inbound decoder thread
|
||||
@@ -89,6 +104,10 @@ class _ConnStub:
|
||||
def setup_signing(self, key: bytes) -> None:
|
||||
pass
|
||||
|
||||
def write(self, payload: bytes) -> int:
|
||||
self.write_calls.append(bytes(payload))
|
||||
return len(payload)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""AZ-558 — outbound MavlinkTransport seam acceptance tests.
|
||||
|
||||
Closes AZ-401 AC-9 + AZ-404 AC-4b deferrals by routing every C8
|
||||
outbound MAVLink byte through the :class:`MavlinkTransport` Protocol.
|
||||
|
||||
ACs covered here (AC-1, AC-3, AC-4 are exercised in their respective
|
||||
adapter / compose-root tests):
|
||||
|
||||
* **AC-2** — Wire-byte equivalence (live mode). Two pymavlink
|
||||
``MAVLink`` instances with identical state are driven through:
|
||||
(1) ``mav.gps_input_send(...)`` writing to a ``BytesIO``, and
|
||||
(2) ``encode + pack + transport.write`` writing to a
|
||||
:class:`_BytesCapturingTransport`. The captured bytes are
|
||||
byte-identical, by construction (both call ``msg.pack(mav)``
|
||||
on the same MAVLink instance with the same ``mav.seq`` snapshot).
|
||||
* **AC-5** — AST scan asserts that no method-call AST node in the
|
||||
AP / iNav / replay-FC adapter source files invokes a pymavlink
|
||||
``mav.X_send`` helper. The Protocol seam is the only egress.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.components.c8_fc_adapter._outbound_mavlink_payloads import (
|
||||
encode_gps_input,
|
||||
encode_named_value_float,
|
||||
encode_statustext,
|
||||
send_via_transport,
|
||||
)
|
||||
|
||||
from ._mav_test_helpers import _BytesCapturingTransport
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
_C8_DIR = _REPO_ROOT / "src" / "gps_denied_onboard" / "components" / "c8_fc_adapter"
|
||||
|
||||
# AC-5 retrofitted files — every outbound MAVLink byte must now route
|
||||
# through the MavlinkTransport seam, not via pymavlink's *_send helpers.
|
||||
_RETROFITTED_FILES: tuple[str, ...] = (
|
||||
"pymavlink_ardupilot_adapter.py",
|
||||
"msp2_inav_adapter.py",
|
||||
"tlog_replay_adapter.py",
|
||||
)
|
||||
|
||||
# Pymavlink ``mav.<name>_send(...)`` helpers we forbid in retrofitted code.
|
||||
_FORBIDDEN_SEND_HELPERS: frozenset[str] = frozenset(
|
||||
{
|
||||
"gps_input_send",
|
||||
"named_value_float_send",
|
||||
"statustext_send",
|
||||
"command_long_send",
|
||||
"global_position_int_send",
|
||||
"command_int_send",
|
||||
"param_set_send",
|
||||
"param_request_read_send",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2 — wire-byte equivalence
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mavlink_pair() -> tuple[Any, Any]:
|
||||
"""Build two pymavlink MAVLink instances with identical state."""
|
||||
from pymavlink.dialects.v20 import ardupilotmega as _mavlink
|
||||
|
||||
legacy_buf = io.BytesIO()
|
||||
legacy = _mavlink.MAVLink(file=legacy_buf, srcSystem=1, srcComponent=1)
|
||||
new = _mavlink.MAVLink(file=None, srcSystem=1, srcComponent=1)
|
||||
return legacy, new, legacy_buf # type: ignore[return-value]
|
||||
|
||||
|
||||
def test_ac2_byte_equivalence_gps_input(_mavlink_pair: tuple[Any, Any, io.BytesIO]) -> None:
|
||||
"""``encode + pack + transport.write`` is byte-identical to ``gps_input_send``."""
|
||||
# Arrange
|
||||
legacy, new, legacy_buf = _mavlink_pair
|
||||
capture = _BytesCapturingTransport()
|
||||
args = dict(
|
||||
time_usec=1_000_000,
|
||||
gps_id=0,
|
||||
ignore_flags=0,
|
||||
time_week_ms=0,
|
||||
time_week=0,
|
||||
fix_type=3,
|
||||
lat=int(50.0 * 1e7),
|
||||
lon=int(30.0 * 1e7),
|
||||
alt=100.0,
|
||||
hdop=0.0,
|
||||
vdop=0.0,
|
||||
vn=0.0,
|
||||
ve=0.0,
|
||||
vd=0.0,
|
||||
speed_accuracy=0.0,
|
||||
horiz_accuracy=2.5,
|
||||
vert_accuracy=0.0,
|
||||
satellites_visible=10,
|
||||
yaw=0,
|
||||
)
|
||||
|
||||
# Act
|
||||
legacy.gps_input_send(*args.values())
|
||||
msg = encode_gps_input(new, **args)
|
||||
send_via_transport(new, msg, capture)
|
||||
|
||||
# Assert
|
||||
assert legacy_buf.getvalue() == b"".join(capture.chunks)
|
||||
|
||||
|
||||
def test_ac2_byte_equivalence_named_value_float(
|
||||
_mavlink_pair: tuple[Any, Any, io.BytesIO],
|
||||
) -> None:
|
||||
"""``named_value_float_encode`` produces byte-equivalent output."""
|
||||
# Arrange
|
||||
legacy, new, legacy_buf = _mavlink_pair
|
||||
capture = _BytesCapturingTransport()
|
||||
args = dict(time_boot_ms=12345, name=b"src_lbl", value=1.5)
|
||||
|
||||
# Act
|
||||
legacy.named_value_float_send(*args.values())
|
||||
msg = encode_named_value_float(new, **args)
|
||||
send_via_transport(new, msg, capture)
|
||||
|
||||
# Assert
|
||||
assert legacy_buf.getvalue() == b"".join(capture.chunks)
|
||||
|
||||
|
||||
def test_ac2_byte_equivalence_statustext(
|
||||
_mavlink_pair: tuple[Any, Any, io.BytesIO],
|
||||
) -> None:
|
||||
"""``statustext_encode`` produces byte-equivalent output."""
|
||||
# Arrange
|
||||
legacy, new, legacy_buf = _mavlink_pair
|
||||
capture = _BytesCapturingTransport()
|
||||
|
||||
# Act
|
||||
legacy.statustext_send(4, b"hello")
|
||||
msg = encode_statustext(new, severity=4, text=b"hello")
|
||||
send_via_transport(new, msg, capture)
|
||||
|
||||
# Assert
|
||||
assert legacy_buf.getvalue() == b"".join(capture.chunks)
|
||||
|
||||
|
||||
def test_ac2_byte_equivalence_seq_bumps_consistently(
|
||||
_mavlink_pair: tuple[Any, Any, io.BytesIO],
|
||||
) -> None:
|
||||
"""Sending two messages keeps the seq numbers byte-aligned."""
|
||||
# Arrange
|
||||
legacy, new, legacy_buf = _mavlink_pair
|
||||
capture = _BytesCapturingTransport()
|
||||
args = dict(
|
||||
time_usec=1_000_000, gps_id=0, ignore_flags=0, time_week_ms=0, time_week=0,
|
||||
fix_type=3, lat=0, lon=0, alt=0.0, hdop=0.0, vdop=0.0, vn=0.0, ve=0.0,
|
||||
vd=0.0, speed_accuracy=0.0, horiz_accuracy=0.0, vert_accuracy=0.0,
|
||||
satellites_visible=10, yaw=0,
|
||||
)
|
||||
|
||||
# Act — two messages, both sides
|
||||
for _ in range(2):
|
||||
legacy.gps_input_send(*args.values())
|
||||
msg = encode_gps_input(new, **args)
|
||||
send_via_transport(new, msg, capture)
|
||||
|
||||
# Assert
|
||||
assert legacy_buf.getvalue() == b"".join(capture.chunks)
|
||||
assert legacy.seq == new.seq
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5 — AST scan: no `mav.X_send(` in retrofitted adapters
|
||||
|
||||
|
||||
class _MavSendCallScanner(ast.NodeVisitor):
|
||||
"""Flags ``<expr>.mav.<X>_send(...)`` AST patterns."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.findings: list[tuple[str, int]] = []
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None: # noqa: N802 — ast API
|
||||
func = node.func
|
||||
if isinstance(func, ast.Attribute) and func.attr in _FORBIDDEN_SEND_HELPERS:
|
||||
value = func.value
|
||||
if isinstance(value, ast.Attribute) and value.attr == "mav":
|
||||
self.findings.append((func.attr, node.lineno))
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", _RETROFITTED_FILES)
|
||||
def test_ac5_no_pymavlink_send_helpers_in_adapter_source(filename: str) -> None:
|
||||
"""No retrofitted adapter source calls ``connection.mav.<name>_send(...)``."""
|
||||
# Arrange
|
||||
source_path = _C8_DIR / filename
|
||||
tree = ast.parse(source_path.read_text(encoding="utf-8"))
|
||||
scanner = _MavSendCallScanner()
|
||||
|
||||
# Act
|
||||
scanner.visit(tree)
|
||||
|
||||
# Assert
|
||||
assert scanner.findings == [], (
|
||||
f"{filename}: forbidden pymavlink send-helpers still present "
|
||||
f"(MavlinkTransport.write must be the only egress per AZ-558 AC-5):\n"
|
||||
+ "\n".join(f" line {ln}: .mav.{name}(" for name, ln in scanner.findings)
|
||||
)
|
||||
Reference in New Issue
Block a user