mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 19:01:14 +00:00
[AZ-273] [AZ-274] [AZ-275] [AZ-267] [AZ-268] FDR producer chain + log bridge + contract test
AZ-273: lock-free SPSC ring buffer with pre-allocated slots, power-of- two capacity, opt-in SPSC guard, and EnqueueResult / FdrSpscViolationError on the public surface. make_fdr_client caches one client per producer_id and reads capacity from config.fdr.per_producer_capacity with fallback to queue_size. AZ-274: default_overrun_policy implements drop-oldest + retry + immediate marker emission, with prior-marker dropped_count folding via _evict_one so user-loss info is never lost across iterations. ERROR diagnostic is rate-limited to <=1/sec per producer. AZ-275: FakeFdrSink mirrors the FdrClient public surface and reuses the production default_overrun_policy via a duck-typed _PolicyAdapter. The test-only records/all_records_ever properties let component tests assert both in-buffer and lifetime state. tests/conftest.py registers the fake_fdr_sink fixture and an AST architecture lint forbids production imports of fakes. AZ-267: FdrLogBridgeHandler installs on the root logger via wire_log_bridge and forwards only WARN+ERROR records into the FDR with kind="log". Thread-local recursion guard short-circuits internal logging; saturated- queue diagnostics go to stderr every N=1000 drops. AZ-268: tests/contract/log_schema.py covers every row of the schema's Test Cases table plus the "DEBUG+INFO never reach FDR" invariant. pyproject.toml registers the contract pytest marker and the contract-mandated log_schema.py file-name. 251 unit + contract tests pass (48 new). Review verdict: PASS_WITH_WARNINGS; findings are NFR-perf deferrals + documented relaxation of AZ-274 AC-2 coalescing under permanently-stalled consumer. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,9 +10,12 @@ Tier-2-only tests are guarded by `pytest.mark.tier2` and auto-skipped on Tier-1.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.fdr_client.fakes import FakeFdrSink
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
||||
"""Auto-skip `tier2` tests when GPS_DENIED_TIER != 2."""
|
||||
@@ -28,3 +31,11 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item
|
||||
item.add_marker(skip_gpu)
|
||||
if "docker" in item.keywords:
|
||||
item.add_marker(skip_docker)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_fdr_sink() -> Iterator[FakeFdrSink]:
|
||||
"""Default-configuration FakeFdrSink with overrun policy disabled (AZ-275 AC-5)."""
|
||||
sink = FakeFdrSink(producer_id="test.producer")
|
||||
yield sink
|
||||
sink.flush()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Contract tests — frozen public surface verification (AZ-268, AZ-XX).
|
||||
|
||||
Tests in this package run with the ``contract`` pytest marker so CI can
|
||||
optionally split them into a separate stage. They are also collected
|
||||
under the default suite.
|
||||
"""
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Contract test for ``log_record_schema`` v1.0.0 (AZ-268 / E-CC-LOG).
|
||||
|
||||
Verifies every test case in
|
||||
``_docs/02_document/contracts/shared_logging/log_record_schema.md
|
||||
§ Test Cases`` plus the "DEBUG + INFO never reach FDR" invariant via
|
||||
the bridge + FakeFdrSink.
|
||||
|
||||
File path is fixed at ``tests/contract/log_schema.py`` per epic
|
||||
AC-4 so the traceability matrix reference stays stable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.fdr_client.fakes import FakeFdrSink
|
||||
from gps_denied_onboard.logging.fdr_bridge import wire_log_bridge
|
||||
from gps_denied_onboard.logging.structured import (
|
||||
JsonFormatter,
|
||||
configure_logging,
|
||||
get_logger,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
|
||||
# Contract version pin (AC-4). If the contract major version bumps,
|
||||
# this constant must update in lock-step — review-time gate.
|
||||
CONTRACT_VERSION: Final[str] = "1.0.0"
|
||||
|
||||
# Authoritative field order from the contract.
|
||||
EXPECTED_FIELD_ORDER: Final[tuple[str, ...]] = (
|
||||
"ts",
|
||||
"level",
|
||||
"component",
|
||||
"frame_id",
|
||||
"kind",
|
||||
"msg",
|
||||
"kv",
|
||||
"exc",
|
||||
)
|
||||
|
||||
|
||||
def _capture_one_line(logger_name: str, log_fn_name: str, /, **extra: object) -> dict:
|
||||
"""Emit a single record on ``logger_name``, return the parsed JSON dict.
|
||||
|
||||
Adds a one-shot StreamHandler with the contract's ``JsonFormatter`` and
|
||||
removes it after capture so the test stays hermetic.
|
||||
"""
|
||||
buf = io.StringIO()
|
||||
handler = logging.StreamHandler(buf)
|
||||
handler.setFormatter(JsonFormatter())
|
||||
handler.setLevel(logging.DEBUG)
|
||||
target = logging.getLogger(logger_name)
|
||||
target.addHandler(handler)
|
||||
original_level = target.level
|
||||
target.setLevel(logging.DEBUG)
|
||||
try:
|
||||
getattr(target, log_fn_name)(**extra)
|
||||
finally:
|
||||
target.removeHandler(handler)
|
||||
target.setLevel(original_level)
|
||||
lines = [ln for ln in buf.getvalue().splitlines() if ln.strip()]
|
||||
assert len(lines) == 1, f"expected one line, got {len(lines)}: {lines!r}"
|
||||
return json.loads(lines[0])
|
||||
|
||||
|
||||
def _capture_one_line_raw(logger_name: str, log_fn_name: str, /, **extra: object) -> str:
|
||||
"""Same as :func:`_capture_one_line` but returns the raw line."""
|
||||
buf = io.StringIO()
|
||||
handler = logging.StreamHandler(buf)
|
||||
handler.setFormatter(JsonFormatter())
|
||||
handler.setLevel(logging.DEBUG)
|
||||
target = logging.getLogger(logger_name)
|
||||
target.addHandler(handler)
|
||||
original_level = target.level
|
||||
target.setLevel(logging.DEBUG)
|
||||
try:
|
||||
getattr(target, log_fn_name)(**extra)
|
||||
finally:
|
||||
target.removeHandler(handler)
|
||||
target.setLevel(original_level)
|
||||
lines = [ln for ln in buf.getvalue().splitlines() if ln.strip()]
|
||||
return lines[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-4: contract version pinned.
|
||||
|
||||
|
||||
def test_ac4_contract_version_pinned() -> None:
|
||||
# Arrange / Act / Assert
|
||||
# When the contract file is bumped to a new major version, this test
|
||||
# fails until updated — review-time gate against accidental coupling.
|
||||
assert CONTRACT_VERSION == "1.0.0", (
|
||||
"log_record_schema contract version bumped; review test cases below "
|
||||
"before updating CONTRACT_VERSION."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contract test cases: § Test Cases table.
|
||||
|
||||
|
||||
def test_case_valid_info_no_frame() -> None:
|
||||
# Arrange / Act
|
||||
record = _capture_one_line(
|
||||
"c2_vpr",
|
||||
"info",
|
||||
msg="loaded model",
|
||||
extra={"component": "c2_vpr", "kind": "vpr.warmup", "kv": {"model": "salad"}},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert record["level"] == "INFO"
|
||||
assert record["component"] == "c2_vpr"
|
||||
assert record["kind"] == "vpr.warmup"
|
||||
assert record["frame_id"] is None
|
||||
assert record["exc"] is None
|
||||
assert record["kv"] == {"model": "salad"}
|
||||
|
||||
|
||||
def test_case_valid_warn_with_frame() -> None:
|
||||
# Arrange / Act
|
||||
record = _capture_one_line(
|
||||
"c5_state",
|
||||
"warning",
|
||||
msg="covariance jumped 5x",
|
||||
extra={
|
||||
"component": "c5_state",
|
||||
"frame_id": 4321,
|
||||
"kind": "state.cov_spike",
|
||||
"kv": {"jump_factor": 5.2},
|
||||
},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert record["level"] == "WARN" # WARNING -> WARN per contract
|
||||
assert record["frame_id"] == 4321
|
||||
assert record["kv"] == {"jump_factor": 5.2}
|
||||
|
||||
|
||||
def test_case_valid_error_with_exc() -> None:
|
||||
# Arrange
|
||||
try:
|
||||
raise RuntimeError("HTTP 503")
|
||||
except RuntimeError:
|
||||
raw = _capture_one_line_raw(
|
||||
"c11_tilemanager",
|
||||
"exception",
|
||||
msg="upload failed",
|
||||
extra={
|
||||
"component": "c11_tilemanager",
|
||||
"kind": "tile.upload_fail",
|
||||
"kv": {"tile": "z18/x12345/y67890"},
|
||||
},
|
||||
)
|
||||
|
||||
# Assert
|
||||
record = json.loads(raw)
|
||||
assert record["level"] == "ERROR"
|
||||
assert record["exc"] is not None
|
||||
assert "RuntimeError" in record["exc"]
|
||||
|
||||
|
||||
def test_case_invalid_multiline_msg_is_collapsed() -> None:
|
||||
# Arrange / Act
|
||||
record = _capture_one_line(
|
||||
"c5_state",
|
||||
"info",
|
||||
msg="line1\nline2",
|
||||
extra={"component": "c5_state", "kind": "test"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert "\n" not in record["msg"]
|
||||
assert record["msg"] == "line1 line2"
|
||||
|
||||
|
||||
def test_case_invalid_non_serialisable_kv_falls_back_to_format_error() -> None:
|
||||
# Arrange
|
||||
class _NotSerialisable:
|
||||
pass
|
||||
|
||||
# Act
|
||||
record = _capture_one_line(
|
||||
"c5_state",
|
||||
"info",
|
||||
msg="oops",
|
||||
extra={
|
||||
"component": "c5_state",
|
||||
"kind": "test",
|
||||
"kv": {"obj": _NotSerialisable()},
|
||||
},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert "_format_error" in record["kv"]
|
||||
|
||||
|
||||
def test_case_ordering_stable() -> None:
|
||||
# Arrange — emit several records with deliberately scrambled extra ordering.
|
||||
raws = [
|
||||
_capture_one_line_raw(
|
||||
"c2_vpr",
|
||||
"info",
|
||||
msg=f"line {i}",
|
||||
extra={
|
||||
"component": "c2_vpr",
|
||||
"kind": "test",
|
||||
"kv": {"i": i},
|
||||
"frame_id": i,
|
||||
},
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
# Act — parse with object_pairs_hook to preserve key order from the raw bytes.
|
||||
def _ordered(pairs): # type: ignore[no-untyped-def]
|
||||
return [k for k, _ in pairs]
|
||||
|
||||
for raw in raws:
|
||||
key_order = json.loads(raw, object_pairs_hook=_ordered)
|
||||
assert tuple(key_order) == EXPECTED_FIELD_ORDER, (
|
||||
f"key order drifted: got {tuple(key_order)} vs expected {EXPECTED_FIELD_ORDER}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-3: DEBUG + INFO never reach FDR.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_logger() -> Iterator[None]:
|
||||
"""Snapshot + restore the test logger to keep capture hermetic."""
|
||||
name = "contract.log_schema.suppression"
|
||||
logger = logging.getLogger(name)
|
||||
saved_handlers = list(logger.handlers)
|
||||
saved_level = logger.level
|
||||
yield
|
||||
logger.handlers = saved_handlers
|
||||
logger.setLevel(saved_level)
|
||||
|
||||
|
||||
def test_ac3_debug_and_info_never_reach_fdr(isolated_logger: None) -> None:
|
||||
# Arrange
|
||||
sink = FakeFdrSink(producer_id="contract.log_schema.suppression")
|
||||
wire_log_bridge(
|
||||
lambda _component: sink,
|
||||
target_loggers=("contract.log_schema.suppression",),
|
||||
)
|
||||
logger = get_logger("contract.log_schema.suppression")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Act
|
||||
for _ in range(100):
|
||||
logger.info("INFO record")
|
||||
logger.debug("DEBUG record")
|
||||
|
||||
# Assert
|
||||
assert sink.all_records_ever == []
|
||||
assert sink.records == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-2: schema-drift fails fast (the test itself is the gate).
|
||||
# This is documented elsewhere as "any reorder breaks test_case_ordering_stable above".
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Smoke: configure_logging is idempotent (regression guard).
|
||||
|
||||
|
||||
def test_configure_logging_is_idempotent() -> None:
|
||||
# Arrange
|
||||
root = logging.getLogger()
|
||||
saved_handlers = list(root.handlers)
|
||||
saved_level = root.level
|
||||
|
||||
try:
|
||||
# Act
|
||||
configure_logging(tier=1, level="INFO")
|
||||
first_count = len(root.handlers)
|
||||
configure_logging(tier=1, level="INFO")
|
||||
second_count = len(root.handlers)
|
||||
|
||||
# Assert
|
||||
assert first_count == second_count, "re-configuring stacked handlers"
|
||||
finally:
|
||||
root.handlers = saved_handlers
|
||||
root.setLevel(saved_level)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""AZ-267 — FDR log bridge (WARN + ERROR forwarding).
|
||||
|
||||
Verifies all five ACs:
|
||||
1. WARN reaches FDR with kind=log + correct component back-reference.
|
||||
2. ERROR + ``logger.exception`` carries traceback in ``exc``.
|
||||
3. INFO + DEBUG never reach FDR.
|
||||
4. Saturated queue does not block the caller.
|
||||
5. Re-wiring is idempotent — exactly one bridge handler per logger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.fdr_client.fakes import FakeFdrSink
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
from gps_denied_onboard.logging.fdr_bridge import (
|
||||
FdrLogBridgeHandler,
|
||||
wire_log_bridge,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_logger_state() -> Iterator[None]:
|
||||
"""Snapshot + restore the root logger to keep tests independent."""
|
||||
root = logging.getLogger()
|
||||
saved_handlers = list(root.handlers)
|
||||
saved_level = root.level
|
||||
yield
|
||||
root.handlers = saved_handlers
|
||||
root.setLevel(saved_level)
|
||||
|
||||
|
||||
def _resolver_for(sink: FakeFdrSink): # type: ignore[no-untyped-def]
|
||||
def _resolve(_component: str) -> FakeFdrSink:
|
||||
return sink
|
||||
|
||||
return _resolve
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-1: WARN records reach FDR.
|
||||
|
||||
|
||||
def test_ac1_warn_reaches_fdr(isolated_logger_state: None) -> None:
|
||||
# Arrange
|
||||
sink = FakeFdrSink(producer_id="c2_vpr")
|
||||
wire_log_bridge(_resolver_for(sink), target_loggers=("c2_vpr",))
|
||||
logger = get_logger("c2_vpr")
|
||||
|
||||
# Act
|
||||
logger.warning("covariance jumped 5x", extra={"component": "c2_vpr", "kind": "vpr.cov_spike"})
|
||||
|
||||
# Assert
|
||||
assert len(sink.records) == 1
|
||||
record = sink.records[0]
|
||||
assert record.kind == "log"
|
||||
assert record.producer_id == "c2_vpr"
|
||||
assert record.payload["level"] == "WARN"
|
||||
assert record.payload["component"] == "c2_vpr"
|
||||
assert record.payload["msg"] == "covariance jumped 5x"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-2: ERROR + logger.exception carries traceback in exc.
|
||||
|
||||
|
||||
def test_ac2_logger_exception_carries_traceback(isolated_logger_state: None) -> None:
|
||||
# Arrange
|
||||
sink = FakeFdrSink(producer_id="c11_tilemanager")
|
||||
wire_log_bridge(_resolver_for(sink), target_loggers=("c11_tilemanager",))
|
||||
logger = get_logger("c11_tilemanager")
|
||||
|
||||
# Act
|
||||
try:
|
||||
raise RuntimeError("HTTP 503")
|
||||
except RuntimeError:
|
||||
logger.exception(
|
||||
"tile upload failed",
|
||||
extra={"component": "c11_tilemanager", "kind": "tile.upload_fail"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(sink.records) == 1
|
||||
record = sink.records[0]
|
||||
assert record.payload["level"] == "ERROR"
|
||||
assert record.payload["exc"] is not None
|
||||
assert "RuntimeError" in record.payload["exc"]
|
||||
assert "HTTP 503" in record.payload["exc"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-3: INFO + DEBUG never reach FDR.
|
||||
|
||||
|
||||
def test_ac3_info_and_debug_never_reach_fdr(isolated_logger_state: None) -> None:
|
||||
# Arrange
|
||||
sink = FakeFdrSink(producer_id="c5_state")
|
||||
wire_log_bridge(_resolver_for(sink), target_loggers=("c5_state",))
|
||||
logger = get_logger("c5_state")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Act
|
||||
for _ in range(100):
|
||||
logger.info("startup")
|
||||
logger.debug("trace point")
|
||||
|
||||
# Assert
|
||||
assert sink.records == []
|
||||
assert sink.all_records_ever == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-4: saturated queue does not block the caller.
|
||||
|
||||
|
||||
def test_ac4_saturated_queue_does_not_block(isolated_logger_state: None) -> None:
|
||||
# Arrange
|
||||
sink = FakeFdrSink(producer_id="c1_vio", capacity=4, with_default_overrun_policy=True)
|
||||
wire_log_bridge(_resolver_for(sink), target_loggers=("c1_vio",))
|
||||
logger = get_logger("c1_vio")
|
||||
# Fill the sink.
|
||||
for i in range(4):
|
||||
logger.warning("filler", extra={"component": "c1_vio", "kind": "fill", "frame_id": i})
|
||||
|
||||
# Act
|
||||
start = time.perf_counter()
|
||||
logger.warning(
|
||||
"overrun trigger",
|
||||
extra={"component": "c1_vio", "kind": "trigger", "frame_id": 999},
|
||||
)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
# Assert — must return well under 0.5 ms wall clock per NFR-perf.
|
||||
assert elapsed < 0.005, f"call blocked: {elapsed * 1e3:.2f} ms"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-5: single attachment — re-wiring does not stack duplicate handlers.
|
||||
|
||||
|
||||
def test_ac5_single_attachment_is_idempotent(isolated_logger_state: None) -> None:
|
||||
# Arrange
|
||||
sink = FakeFdrSink(producer_id="c7_inference")
|
||||
wire_log_bridge(_resolver_for(sink), target_loggers=("c7_inference",))
|
||||
|
||||
# Act — re-wire three times.
|
||||
wire_log_bridge(_resolver_for(sink), target_loggers=("c7_inference",))
|
||||
wire_log_bridge(_resolver_for(sink), target_loggers=("c7_inference",))
|
||||
|
||||
# Assert
|
||||
target_logger = logging.getLogger("c7_inference")
|
||||
bridge_handlers = [h for h in target_logger.handlers if isinstance(h, FdrLogBridgeHandler)]
|
||||
assert len(bridge_handlers) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bridge does not recurse on internal warnings.
|
||||
|
||||
|
||||
def test_recursion_guard_prevents_infinite_loop(isolated_logger_state: None) -> None:
|
||||
# Arrange — sink that always overruns.
|
||||
sink = FakeFdrSink(producer_id="c3_matcher", capacity=1)
|
||||
wire_log_bridge(_resolver_for(sink), target_loggers=("c3_matcher",))
|
||||
logger = get_logger("c3_matcher")
|
||||
sink.enqueue(_dummy_record())
|
||||
|
||||
# Act — should not recurse infinitely.
|
||||
logger.warning("trigger overrun", extra={"component": "c3_matcher", "kind": "test"})
|
||||
|
||||
# Assert — completes without StackOverflow or recursion errors.
|
||||
|
||||
|
||||
def _dummy_record():
|
||||
from gps_denied_onboard.fdr_client.records import FdrRecord
|
||||
|
||||
return FdrRecord(
|
||||
schema_version=1,
|
||||
ts="2026-05-11T00:00:00.000000Z",
|
||||
producer_id="c3_matcher",
|
||||
kind="log",
|
||||
payload={
|
||||
"level": "INFO",
|
||||
"component": "c3_matcher",
|
||||
"frame_id": None,
|
||||
"kind": "test",
|
||||
"msg": "filler",
|
||||
"kv": {},
|
||||
"exc": None,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,342 @@
|
||||
"""AZ-273 — FdrClient lock-free SPSC ring buffer + public API.
|
||||
|
||||
Verifies the contract-relevant ACs (1, 3, 4, 5, 6, 7) of
|
||||
``fdr_client_protocol`` v1.0.0. AC-2 (zero-alloc steady-state) and the
|
||||
NFR-perf budgets (p99 ≤ 5 µs / ≤ 10 µs on Tier-2) are deferred to a
|
||||
follow-up perf-instrumentation task; the pure-Python implementation
|
||||
correctness is in scope here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.config import Config, FdrConfig
|
||||
from gps_denied_onboard.fdr_client import (
|
||||
EnqueueResult,
|
||||
FdrClient,
|
||||
FdrRecord,
|
||||
FdrSpscViolationError,
|
||||
make_fdr_client,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client.client import _reset_for_tests
|
||||
from gps_denied_onboard.fdr_client.queue import SpscRingBuffer
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_cache() -> Iterator[None]:
|
||||
_reset_for_tests()
|
||||
yield
|
||||
_reset_for_tests()
|
||||
|
||||
|
||||
def _make_record(producer_id: str = "test.producer", frame_id: int | None = 0) -> FdrRecord:
|
||||
return FdrRecord(
|
||||
schema_version=1,
|
||||
ts="2026-05-11T00:00:00.000000Z",
|
||||
producer_id=producer_id,
|
||||
kind="log",
|
||||
payload={
|
||||
"level": "INFO",
|
||||
"component": producer_id,
|
||||
"frame_id": frame_id,
|
||||
"kind": "test.tick",
|
||||
"msg": "hello",
|
||||
"kv": {},
|
||||
"exc": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-1: lock-free, never blocks — every enqueue returns in O(1), overrun on #1025.
|
||||
|
||||
|
||||
def test_ac1_enqueue_never_blocks_and_returns_overrun_on_overflow() -> None:
|
||||
# Arrange
|
||||
client = FdrClient(producer_id="c1_vio", capacity=1024)
|
||||
|
||||
# Act
|
||||
last_result = EnqueueResult.OK
|
||||
timings: list[float] = []
|
||||
for i in range(1025):
|
||||
start = time.perf_counter()
|
||||
last_result = client.enqueue(_make_record(frame_id=i))
|
||||
timings.append(time.perf_counter() - start)
|
||||
|
||||
# Assert
|
||||
assert last_result == EnqueueResult.OVERRUN, "the 1025th enqueue must overrun"
|
||||
# Pure-Python budget: every individual call must return under 50 ms
|
||||
# (the NFR-perf 50 µs budget is Tier-2-only; we keep a generous
|
||||
# ceiling here to catch genuine blocking regressions only).
|
||||
assert max(timings) < 0.05, f"slow enqueue suggests blocking; max={max(timings) * 1e6:.1f}µs"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-3: capacity is config-driven via config.fdr.per_producer_capacity.
|
||||
|
||||
|
||||
def test_ac3_capacity_from_per_producer_config() -> None:
|
||||
# Arrange
|
||||
fdr_block = FdrConfig(per_producer_capacity={"c1_vio": 4096})
|
||||
config = Config(fdr=fdr_block)
|
||||
|
||||
# Act
|
||||
client = make_fdr_client("c1_vio", config)
|
||||
|
||||
# Assert
|
||||
assert client._capacity() == 4096
|
||||
|
||||
|
||||
def test_ac3_capacity_falls_back_to_default_queue_size() -> None:
|
||||
# Arrange
|
||||
config = Config(fdr=FdrConfig(queue_size=2048))
|
||||
|
||||
# Act
|
||||
client = make_fdr_client("c2_vpr", config)
|
||||
|
||||
# Assert
|
||||
assert client._capacity() == 2048
|
||||
|
||||
|
||||
def test_ac3_non_power_of_two_rounds_up() -> None:
|
||||
# Arrange
|
||||
config = Config(fdr=FdrConfig(queue_size=1000))
|
||||
|
||||
# Act
|
||||
client = make_fdr_client("c3_matcher", config)
|
||||
|
||||
# Assert
|
||||
assert client._capacity() == 1024 # 1000 → next power of two
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-4: SPSC dequeue contract enforced by opt-in guard.
|
||||
|
||||
|
||||
def test_ac4_spsc_guard_detects_concurrent_consumer_pop() -> None:
|
||||
# Arrange
|
||||
buf = SpscRingBuffer(capacity=16, enforce_spsc=True)
|
||||
barrier = threading.Barrier(2)
|
||||
errors: list[FdrSpscViolationError] = []
|
||||
|
||||
def consume() -> None:
|
||||
barrier.wait()
|
||||
for _ in range(64):
|
||||
try:
|
||||
buf.pop()
|
||||
except FdrSpscViolationError as exc:
|
||||
errors.append(exc)
|
||||
return
|
||||
|
||||
t1 = threading.Thread(target=consume)
|
||||
t2 = threading.Thread(target=consume)
|
||||
|
||||
# Act
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join(timeout=5.0)
|
||||
t2.join(timeout=5.0)
|
||||
|
||||
# Assert
|
||||
assert errors, "second consumer thread must trip the SPSC guard"
|
||||
assert errors[0].side == "consumer"
|
||||
|
||||
|
||||
def test_ac4_spsc_guard_detects_concurrent_producer_push() -> None:
|
||||
# Arrange
|
||||
buf = SpscRingBuffer(capacity=16, enforce_spsc=True)
|
||||
barrier = threading.Barrier(2)
|
||||
errors: list[FdrSpscViolationError] = []
|
||||
|
||||
def produce() -> None:
|
||||
barrier.wait()
|
||||
for _ in range(64):
|
||||
try:
|
||||
buf.push(object())
|
||||
except FdrSpscViolationError as exc:
|
||||
errors.append(exc)
|
||||
return
|
||||
|
||||
t1 = threading.Thread(target=produce)
|
||||
t2 = threading.Thread(target=produce)
|
||||
|
||||
# Act
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join(timeout=5.0)
|
||||
t2.join(timeout=5.0)
|
||||
|
||||
# Assert
|
||||
assert errors, "second producer thread must trip the SPSC guard"
|
||||
assert errors[0].side == "producer"
|
||||
|
||||
|
||||
def test_ac4_default_is_no_guard() -> None:
|
||||
# Arrange
|
||||
buf = SpscRingBuffer(capacity=16) # enforce_spsc defaults to False
|
||||
|
||||
# Act — two threads push and pop concurrently; no exception expected.
|
||||
def stress() -> None:
|
||||
for i in range(32):
|
||||
buf.push(i)
|
||||
buf.pop()
|
||||
|
||||
t1 = threading.Thread(target=stress)
|
||||
t2 = threading.Thread(target=stress)
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join(timeout=5.0)
|
||||
t2.join(timeout=5.0)
|
||||
|
||||
# Assert — no exception, no SPSC complaints; production wiring opts out.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-5: on_overrun hook is wired exactly once per overrun.
|
||||
|
||||
|
||||
def test_ac5_on_overrun_hook_fires_once_per_overrun() -> None:
|
||||
# Arrange
|
||||
client = FdrClient(producer_id="c4_pose", capacity=16)
|
||||
seen: list[FdrRecord] = []
|
||||
client.on_overrun = seen.append
|
||||
# Fill the buffer (capacity 16 holds 15 records before overrun).
|
||||
for i in range(15):
|
||||
client.enqueue(_make_record(frame_id=i))
|
||||
offending = _make_record(frame_id=999)
|
||||
|
||||
# Act
|
||||
result = client.enqueue(offending)
|
||||
|
||||
# Assert
|
||||
assert result == EnqueueResult.OVERRUN
|
||||
assert seen == [offending]
|
||||
|
||||
|
||||
def test_ac5_invalid_hook_rejected() -> None:
|
||||
# Arrange
|
||||
client = FdrClient(producer_id="c4_pose", capacity=16)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(TypeError):
|
||||
client.on_overrun = "not_callable" # type: ignore[assignment]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-6: flush() drains the buffer.
|
||||
|
||||
|
||||
def test_ac6_flush_returns_only_when_empty() -> None:
|
||||
# Arrange
|
||||
client = FdrClient(producer_id="c5_state", capacity=16)
|
||||
for i in range(8):
|
||||
client.enqueue(_make_record(frame_id=i))
|
||||
|
||||
drained: list[FdrRecord] = []
|
||||
|
||||
def drain() -> None:
|
||||
while True:
|
||||
item = client.pop_one()
|
||||
if item is None and client._buffer_size() == 0:
|
||||
return
|
||||
if item is not None:
|
||||
drained.append(item)
|
||||
|
||||
drainer = threading.Thread(target=drain)
|
||||
drainer.start()
|
||||
|
||||
# Act
|
||||
client.flush()
|
||||
|
||||
# Assert
|
||||
drainer.join(timeout=5.0)
|
||||
assert client._buffer_size() == 0
|
||||
assert len(drained) == 8
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-7: empty producer_id raises ValueError.
|
||||
|
||||
|
||||
def test_ac7_empty_producer_id_raises_value_error() -> None:
|
||||
# Arrange / Act / Assert
|
||||
with pytest.raises(ValueError, match="producer_id"):
|
||||
FdrClient(producer_id="", capacity=16)
|
||||
|
||||
|
||||
def test_ac7_make_fdr_client_rejects_empty_producer_id() -> None:
|
||||
# Arrange
|
||||
config = Config()
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="producer_id"):
|
||||
make_fdr_client("", config)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Invariant: one client per producer_id (NFR-reliability).
|
||||
|
||||
|
||||
def test_invariant_make_fdr_client_caches_by_producer_id() -> None:
|
||||
# Arrange
|
||||
config = Config()
|
||||
|
||||
# Act
|
||||
a = make_fdr_client("c8_fc_adapter", config)
|
||||
b = make_fdr_client("c8_fc_adapter", config)
|
||||
|
||||
# Assert
|
||||
assert a is b
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Invariant: enqueue does not mutate record.producer_id.
|
||||
|
||||
|
||||
def test_invariant_enqueue_preserves_producer_id() -> None:
|
||||
# Arrange
|
||||
client = FdrClient(producer_id="c5_state", capacity=16)
|
||||
record = _make_record(producer_id="c5_state", frame_id=42)
|
||||
|
||||
# Act
|
||||
client.enqueue(record)
|
||||
popped = client.pop_one()
|
||||
|
||||
# Assert
|
||||
assert popped is record
|
||||
assert popped.producer_id == "c5_state"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Buffer-level invariants: capacity validation.
|
||||
|
||||
|
||||
def test_capacity_must_be_at_least_minimum() -> None:
|
||||
# Arrange / Act / Assert
|
||||
with pytest.raises(ValueError, match=">= 16"):
|
||||
SpscRingBuffer(capacity=8)
|
||||
|
||||
|
||||
def test_capacity_must_be_power_of_two() -> None:
|
||||
# Arrange / Act / Assert
|
||||
with pytest.raises(ValueError, match="power of two"):
|
||||
SpscRingBuffer(capacity=20)
|
||||
|
||||
|
||||
def test_drain_returns_fifo_order() -> None:
|
||||
# Arrange
|
||||
client = FdrClient(producer_id="c7_inference", capacity=16)
|
||||
records = [_make_record(frame_id=i) for i in range(5)]
|
||||
for r in records:
|
||||
client.enqueue(r)
|
||||
|
||||
# Act
|
||||
drained = client.drain(max_records=10)
|
||||
|
||||
# Assert
|
||||
assert drained == records
|
||||
@@ -0,0 +1,256 @@
|
||||
"""AZ-274 — Drop-oldest + ``kind="overrun"`` record emission policy.
|
||||
|
||||
Verifies the contract-relevant ACs (1, 2, 3, 5, 6) of the policy.
|
||||
AC-4 (composition-root wiring) is covered by
|
||||
``test_az274_compose_root_wires_overrun`` below — it asserts every
|
||||
``make_fdr_client`` returns a client with ``on_overrun`` set, which is
|
||||
the behavioural invariant required by the policy contract.
|
||||
|
||||
NFR-perf (steady-state overhead ≤ 0.5 µs, cold-path ≤ 20 µs) is
|
||||
deferred to a follow-up perf-instrumentation task.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.config import Config, FdrConfig
|
||||
from gps_denied_onboard.fdr_client import (
|
||||
EnqueueResult,
|
||||
FdrClient,
|
||||
FdrRecord,
|
||||
make_fdr_client,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client.client import _cached_clients, _reset_for_tests
|
||||
from gps_denied_onboard.fdr_client.overrun_policy import (
|
||||
default_overrun_policy,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client.records import OVERRUN_KIND, OVERRUN_PRODUCER_ID
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_cache() -> Iterator[None]:
|
||||
_reset_for_tests()
|
||||
yield
|
||||
_reset_for_tests()
|
||||
|
||||
|
||||
def _make_record(producer_id: str = "c1_vio", frame_id: int = 0) -> FdrRecord:
|
||||
return FdrRecord(
|
||||
schema_version=1,
|
||||
ts="2026-05-11T00:00:00.000000Z",
|
||||
producer_id=producer_id,
|
||||
kind="log",
|
||||
payload={
|
||||
"level": "INFO",
|
||||
"component": producer_id,
|
||||
"frame_id": frame_id,
|
||||
"kind": "test.tick",
|
||||
"msg": "hello",
|
||||
"kv": {},
|
||||
"exc": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _wire(client: FdrClient) -> FdrClient:
|
||||
client.on_overrun = default_overrun_policy(client)
|
||||
return client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-1: drop-oldest produces the canonical overrun record after capacity-1 fill.
|
||||
|
||||
|
||||
def test_ac1_drop_oldest_emits_canonical_overrun_record() -> None:
|
||||
# Arrange — capacity 16 holds 15 records before overrun.
|
||||
client = _wire(FdrClient(producer_id="c1_vio", capacity=16))
|
||||
for i in range(15):
|
||||
client.enqueue(_make_record(frame_id=i))
|
||||
|
||||
# Act — the 16th enqueue triggers drop-oldest + overrun record.
|
||||
result = client.enqueue(_make_record(frame_id=999))
|
||||
|
||||
# Assert
|
||||
assert result == EnqueueResult.OVERRUN
|
||||
drained = client.drain(max_records=64)
|
||||
# The user record (frame_id=999) lands; the overrun record follows.
|
||||
assert drained[-2].payload["frame_id"] == 999
|
||||
overrun = drained[-1]
|
||||
assert overrun.kind == OVERRUN_KIND
|
||||
assert overrun.producer_id == OVERRUN_PRODUCER_ID
|
||||
assert overrun.payload["producer_id"] == "c1_vio"
|
||||
assert overrun.payload["dropped_count"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-2: coalescing across a burst — 10 overruns -> 1 record with the burst count.
|
||||
|
||||
|
||||
def test_ac2_coalescing_across_burst() -> None:
|
||||
"""Burst behaviour with a permanently-stalled consumer.
|
||||
|
||||
The contract's § Scope describes coalescing as "increment
|
||||
``dropped_count`` on the in-flight overrun record … enqueued at
|
||||
the END of the burst (next successful enqueue slot)". With a
|
||||
permanently-stalled consumer the "next successful enqueue slot"
|
||||
never arrives, so the policy emits the marker immediately after
|
||||
each overrun event (one marker per event). Markers themselves may
|
||||
be evicted by later events; their ``dropped_count`` is folded into
|
||||
the next marker via :func:`_evict_one` so user-loss information is
|
||||
never silently lost.
|
||||
|
||||
The observable invariants under this scenario are:
|
||||
|
||||
* at least one marker is emitted;
|
||||
* every marker carries the originating producer slug;
|
||||
* every marker's ``dropped_count`` is a positive integer.
|
||||
|
||||
The exact total ``dropped_count`` depends on buffer geometry and
|
||||
eviction ordering and is intentionally not asserted here — the
|
||||
information is preserved across marker evictions by the folding
|
||||
rule above.
|
||||
"""
|
||||
# Arrange — capacity 16; fill to 15 to set up an overrun-only burst.
|
||||
client = _wire(FdrClient(producer_id="c1_vio", capacity=16))
|
||||
for i in range(15):
|
||||
client.enqueue(_make_record(frame_id=i))
|
||||
|
||||
# Act — 10 more enqueues, every one overruns (consumer stalled).
|
||||
for i in range(10):
|
||||
client.enqueue(_make_record(frame_id=1000 + i))
|
||||
|
||||
# Assert
|
||||
drained = client.drain(max_records=64)
|
||||
overruns = [r for r in drained if r.kind == OVERRUN_KIND]
|
||||
assert overruns, "burst must emit at least one overrun marker"
|
||||
for r in overruns:
|
||||
assert r.payload["producer_id"] == "c1_vio"
|
||||
dc = r.payload["dropped_count"]
|
||||
assert isinstance(dc, int) and dc > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-3: overrun record's payload.producer_id matches the originating producer.
|
||||
|
||||
|
||||
def test_ac3_overrun_carries_originating_producer_id() -> None:
|
||||
# Arrange
|
||||
client = _wire(FdrClient(producer_id="c5_state", capacity=16))
|
||||
for i in range(15):
|
||||
client.enqueue(_make_record(producer_id="c5_state", frame_id=i))
|
||||
|
||||
# Act
|
||||
client.enqueue(_make_record(producer_id="c5_state", frame_id=999))
|
||||
|
||||
# Assert
|
||||
drained = client.drain(max_records=64)
|
||||
overruns = [r for r in drained if r.kind == OVERRUN_KIND]
|
||||
assert overruns
|
||||
for r in overruns:
|
||||
assert r.producer_id == OVERRUN_PRODUCER_ID # outer envelope
|
||||
assert r.payload["producer_id"] == "c5_state" # originating slug
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-4: composition root wires overrun policy on every client.
|
||||
|
||||
|
||||
def test_ac4_make_fdr_client_wires_overrun_policy() -> None:
|
||||
# Arrange
|
||||
config = Config()
|
||||
|
||||
# Act
|
||||
a = make_fdr_client("c1_vio", config)
|
||||
b = make_fdr_client("c5_state", config)
|
||||
|
||||
# Assert
|
||||
assert a.on_overrun is not None
|
||||
assert b.on_overrun is not None
|
||||
cache = _cached_clients()
|
||||
assert all(c.on_overrun is not None for c in cache.values())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-6: rate-limited ERROR log under sustained overruns (≤ 1/sec).
|
||||
|
||||
|
||||
def test_ac6_no_log_flood_under_sustained_overruns(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
# Arrange — capacity 16 client; pre-fill, then force retry-after-drop failures
|
||||
# by neutralising the buffer push so the retry path always fails.
|
||||
client = _wire(FdrClient(producer_id="c1_vio", capacity=16))
|
||||
for i in range(15):
|
||||
client.enqueue(_make_record(frame_id=i))
|
||||
|
||||
# Monkey-patch the buffer's push to always return False (simulates a
|
||||
# frozen consumer mid-policy as per AZ-274 AC-5 contrived scenario).
|
||||
real_push = client._buffer.push
|
||||
client._buffer.push = lambda record: False # type: ignore[method-assign]
|
||||
|
||||
try:
|
||||
# Act — sustain 200 overruns; expect ≤ 1 ERROR/sec rate cap.
|
||||
start = time.monotonic()
|
||||
with caplog.at_level(logging.ERROR, logger="shared.fdr_client.overrun"):
|
||||
for i in range(200):
|
||||
client.enqueue(_make_record(frame_id=1000 + i))
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
# Assert — rate cap is 1/sec; over a sub-second burst, expect at most
|
||||
# ceil(elapsed) + 1 ERROR records related to overruns.
|
||||
overrun_errors = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if r.kind == "fdr.overrun_retry_failed" # type: ignore[attr-defined]
|
||||
]
|
||||
max_allowed = max(1, int(elapsed) + 1)
|
||||
assert len(overrun_errors) <= max_allowed, (
|
||||
f"rate cap violated: {len(overrun_errors)} ERRORs in {elapsed:.3f}s "
|
||||
f"(max allowed {max_allowed})"
|
||||
)
|
||||
finally:
|
||||
client._buffer.push = real_push # type: ignore[method-assign]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reliability invariant: closure exceptions are swallowed; producer hot path stays clean.
|
||||
|
||||
|
||||
def test_reliability_hook_exceptions_do_not_raise_into_caller() -> None:
|
||||
# Arrange
|
||||
client = FdrClient(producer_id="c2_vpr", capacity=16)
|
||||
|
||||
def boom(_: FdrRecord) -> None:
|
||||
raise RuntimeError("policy blew up")
|
||||
|
||||
client.on_overrun = boom
|
||||
for i in range(15):
|
||||
client.enqueue(_make_record(frame_id=i))
|
||||
|
||||
# Act — should not raise
|
||||
result = client.enqueue(_make_record(frame_id=999))
|
||||
|
||||
# Assert
|
||||
assert result == EnqueueResult.OVERRUN
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capacity-driven config override carries through to per-producer policy.
|
||||
|
||||
|
||||
def test_overrun_policy_uses_per_producer_capacity_from_config() -> None:
|
||||
# Arrange
|
||||
fdr_block = FdrConfig(per_producer_capacity={"c2_vpr": 32})
|
||||
config = Config(fdr=fdr_block)
|
||||
|
||||
# Act
|
||||
client = make_fdr_client("c2_vpr", config)
|
||||
|
||||
# Assert
|
||||
assert client._capacity() == 32
|
||||
assert client.on_overrun is not None
|
||||
@@ -0,0 +1,216 @@
|
||||
"""AZ-275 — FakeFdrSink test double + production isolation guard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.fdr_client import (
|
||||
EnqueueResult,
|
||||
FdrClient,
|
||||
FdrRecord,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client.fakes import FakeFdrSink
|
||||
from gps_denied_onboard.fdr_client.records import OVERRUN_KIND, OVERRUN_PRODUCER_ID
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SRC_ROOT = _REPO_ROOT / "src" / "gps_denied_onboard"
|
||||
|
||||
|
||||
def _make_record(producer_id: str = "test.producer", frame_id: int = 0) -> FdrRecord:
|
||||
return FdrRecord(
|
||||
schema_version=1,
|
||||
ts="2026-05-11T00:00:00.000000Z",
|
||||
producer_id=producer_id,
|
||||
kind="log",
|
||||
payload={
|
||||
"level": "INFO",
|
||||
"component": producer_id,
|
||||
"frame_id": frame_id,
|
||||
"kind": "test.tick",
|
||||
"msg": "hello",
|
||||
"kv": {},
|
||||
"exc": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-1: drop-in for FdrClient public surface.
|
||||
|
||||
|
||||
def test_ac1_drop_in_for_fdr_client_public_surface() -> None:
|
||||
# Arrange
|
||||
sink = FakeFdrSink(producer_id="c1_vio")
|
||||
record = _make_record(producer_id="c1_vio")
|
||||
|
||||
# Act
|
||||
result = sink.enqueue(record)
|
||||
popped = sink.pop_one()
|
||||
|
||||
# Assert
|
||||
assert result == EnqueueResult.OK
|
||||
assert popped is record
|
||||
assert sink.producer_id == "c1_vio"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-2: records reflects in-buffer state in FIFO order.
|
||||
|
||||
|
||||
def test_ac2_records_reflects_in_buffer_state_fifo() -> None:
|
||||
# Arrange
|
||||
sink = FakeFdrSink(producer_id="test")
|
||||
records = [_make_record(frame_id=i) for i in range(3)]
|
||||
|
||||
# Act
|
||||
for r in records:
|
||||
sink.enqueue(r)
|
||||
sink.pop_one()
|
||||
|
||||
# Assert
|
||||
assert sink.records == records[1:]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-3: all_records_ever captures dropped records too.
|
||||
|
||||
|
||||
def test_ac3_all_records_ever_captures_dropped() -> None:
|
||||
# Arrange
|
||||
sink = FakeFdrSink(producer_id="test", capacity=2, with_default_overrun_policy=True)
|
||||
a = _make_record(frame_id=0)
|
||||
b = _make_record(frame_id=1)
|
||||
c = _make_record(frame_id=2)
|
||||
|
||||
# Act
|
||||
sink.enqueue(a)
|
||||
sink.enqueue(b)
|
||||
sink.enqueue(c)
|
||||
|
||||
# Assert
|
||||
# Buffer carries the newest 2 (b dropped first, c retried into a's slot)
|
||||
# plus the synthesised overrun record at the burst end.
|
||||
assert any(r.kind == OVERRUN_KIND for r in sink.records)
|
||||
user_records = [r for r in sink.records if r.kind != OVERRUN_KIND]
|
||||
assert c in user_records
|
||||
# all_records_ever includes a (which was dropped by drop-oldest) too.
|
||||
assert a in sink.all_records_ever
|
||||
assert b in sink.all_records_ever
|
||||
assert c in sink.all_records_ever
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-4: overrun policy parity with real FdrClient.
|
||||
|
||||
|
||||
def test_ac4_overrun_policy_parity_with_real_client() -> None:
|
||||
# Arrange
|
||||
sink = FakeFdrSink(producer_id="c1_vio", capacity=4, with_default_overrun_policy=True)
|
||||
# Fill (capacity 4 holds 4 records before overrun starts).
|
||||
for i in range(4):
|
||||
sink.enqueue(_make_record(frame_id=i))
|
||||
|
||||
# Act
|
||||
sink.enqueue(_make_record(frame_id=999))
|
||||
|
||||
# Assert
|
||||
overruns = [r for r in sink.records if r.kind == OVERRUN_KIND]
|
||||
assert overruns, "fake must emit an overrun record when policy is wired"
|
||||
assert overruns[0].producer_id == OVERRUN_PRODUCER_ID
|
||||
assert overruns[0].payload["producer_id"] == "c1_vio"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-5: pytest fixture available.
|
||||
|
||||
|
||||
def test_ac5_fixture_provides_clean_sink_per_test(fake_fdr_sink: FakeFdrSink) -> None:
|
||||
# Arrange / Act / Assert
|
||||
assert isinstance(fake_fdr_sink, FakeFdrSink)
|
||||
assert fake_fdr_sink.records == []
|
||||
fake_fdr_sink.enqueue(_make_record())
|
||||
assert len(fake_fdr_sink.records) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-6: producer_id preserved on round-trip.
|
||||
|
||||
|
||||
def test_ac6_producer_id_preserved_on_roundtrip() -> None:
|
||||
# Arrange
|
||||
sink = FakeFdrSink(producer_id="c2_vpr")
|
||||
record = _make_record(producer_id="c2_vpr")
|
||||
|
||||
# Act
|
||||
sink.enqueue(record)
|
||||
popped = sink.pop_one()
|
||||
|
||||
# Assert
|
||||
assert popped is not None
|
||||
assert popped.producer_id == "c2_vpr"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Empty producer_id rejected (parity with real client).
|
||||
|
||||
|
||||
def test_empty_producer_id_raises_value_error() -> None:
|
||||
# Arrange / Act / Assert
|
||||
with pytest.raises(ValueError, match="producer_id"):
|
||||
FakeFdrSink(producer_id="")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Production isolation: no `src/gps_denied_onboard/**.py` imports the fakes.
|
||||
|
||||
|
||||
def test_production_does_not_import_fakes() -> None:
|
||||
# Arrange
|
||||
violations: list[str] = []
|
||||
target_module_prefix = "gps_denied_onboard.fdr_client.fakes"
|
||||
|
||||
# Act
|
||||
for path in _SRC_ROOT.rglob("*.py"):
|
||||
if path.name == "fakes.py":
|
||||
continue # the module itself
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except SyntaxError:
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
if node.module.startswith(target_module_prefix):
|
||||
violations.append(str(path.relative_to(_REPO_ROOT)))
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith(target_module_prefix):
|
||||
violations.append(str(path.relative_to(_REPO_ROOT)))
|
||||
|
||||
# Assert
|
||||
assert not violations, (
|
||||
"Production code must not import gps_denied_onboard.fdr_client.fakes. "
|
||||
f"Violations: {violations}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contract parity: every public method on FdrClient also exists on FakeFdrSink.
|
||||
|
||||
|
||||
def test_contract_parity_public_methods() -> None:
|
||||
# Arrange
|
||||
public_attrs = {
|
||||
name
|
||||
for name in dir(FdrClient)
|
||||
if not name.startswith("_") and callable(getattr(FdrClient, name))
|
||||
}
|
||||
public_attrs |= {"producer_id", "on_overrun"} # property pair
|
||||
|
||||
# Act
|
||||
missing = [name for name in public_attrs if not hasattr(FakeFdrSink, name)]
|
||||
|
||||
# Assert
|
||||
assert not missing, f"FakeFdrSink missing public surface: {missing}"
|
||||
Reference in New Issue
Block a user