[AZ-329] [AZ-330] [AZ-523] [AZ-524] Batch 44 atomic refactor

Implements two new C12 services and rebalances the C11/C12 boundary
in one atomic commit:

* AZ-329 PostLandingUploadOrchestrator — gates C11 upload on the
  `flight_footer` FDR record's `clean_shutdown` field; 4 refusal
  modes; new FdrFooterReader Protocol + LocalFdrFooterReader.
* AZ-330 OperatorReLocService — AC-3.4 visual-loss re-localization
  hint; reuses shared LatLonAlt; OperatorCommandTransport Protocol
  cut (E-C8 owns the future pymavlink concrete); new FDR record
  kind `c12.reloc.requested`; log redaction (lat/lon 5 decimals,
  reason 200 chars).
* AZ-523 C11 internal flight-state gate removed (SRP refactor):
  `confirm_flight_state` / `FlightStateSignal` use /
  `FlightStateNotOnGroundError` deleted from C11; TileUploader
  contract bumped to v2.0.0 (frozen) with migration note; AZ-317
  superseded.
* AZ-524 Package rename `c12_operator_tooling` →
  `c12_operator_orchestrator` across source, tests, pyproject,
  CMake, Dockerfile, compose, CI, runtime-root services class
  (`OperatorOrchestratorServices`) + factory function
  (`build_operator_orchestrator`), logger namespaces, config slug,
  docs, and the E-C12 epic title.

Tests: 1543 passed, 80 skipped (all environment gates). Targeted
AC suite (AZ-329 + AZ-330 + FdrFooterReader): 37 passed. Cold-start
NFR-perf still ≤ 500 ms p99.

Tracker: AZ-317 → Done (superseded); AZ-319 v2.0.0 contract bump
comment; AZ-329/AZ-330 → In Testing; AZ-253 epic renamed; AZ-523
+ AZ-524 created and closed as audit-trail tickets.

See `_docs/03_implementation/batch_44_cycle1_report.md`.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 19:42:46 +03:00
parent 2d88d3d674
commit 5fe67023b2
112 changed files with 3409 additions and 1311 deletions
@@ -1,297 +0,0 @@
"""AZ-317 ``FlightStateGate`` unit tests.
Covers all eight acceptance criteria + NFRs from
``_docs/02_tasks/done/AZ-317_c11_flight_state_gate.md`` (after the
batch-38 archive). Uses a hand-rolled fake :class:`FlightStateSource`
and a list-backed log handler so assertions stay close to the
captured records.
"""
from __future__ import annotations
import logging
import time
from datetime import datetime, timezone
import pytest
from gps_denied_onboard.components.c11_tile_manager import (
FlightStateGate,
FlightStateNotOnGroundError,
FlightStateSignal,
FlightStateSource,
)
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
class _FakeSource:
"""Hand-rolled :class:`FlightStateSource` returning a fixed signal.
Spies on every ``current_flight_state`` call so AC-8 can assert
the gate calls the source exactly once per ``confirm_on_ground``.
"""
def __init__(self, signal: FlightStateSignal) -> None:
self._signal = signal
self.call_count = 0
def current_flight_state(self) -> FlightStateSignal:
self.call_count += 1
return self._signal
class _RaisingSource:
""":class:`FlightStateSource` whose ``current_flight_state`` raises."""
def __init__(self, exc: Exception) -> None:
self._exc = exc
self.call_count = 0
def current_flight_state(self) -> FlightStateSignal:
self.call_count += 1
raise self._exc
class _PartialFake:
"""Type stub WITHOUT ``current_flight_state`` for AC-6 negative case."""
def something_else(self) -> str:
return "noop"
def _build_gate(
*,
source: FlightStateSource,
) -> tuple[FlightStateGate, list[logging.LogRecord]]:
records: list[logging.LogRecord] = []
class _ListHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
records.append(record)
logger = logging.getLogger(f"test_az317_{id(records)}")
logger.handlers.clear()
logger.addHandler(_ListHandler())
logger.setLevel(logging.DEBUG)
logger.propagate = False
return FlightStateGate(source=source, logger=logger), records
def _kinds(records: list[logging.LogRecord]) -> list[str]:
return [getattr(r, "kind", None) for r in records]
# ----------------------------------------------------------------------
# AC-1: ON_GROUND passes
# ----------------------------------------------------------------------
def test_ac1_on_ground_returns_signal_and_emits_info_log() -> None:
# Arrange
source = _FakeSource(FlightStateSignal.ON_GROUND)
gate, records = _build_gate(source=source)
# Act
result = gate.confirm_on_ground()
# Assert
assert result is FlightStateSignal.ON_GROUND
assert _kinds(records) == ["c11.upload.flight_state_confirmed"]
assert records[0].levelname == "INFO"
assert source.call_count == 1
# ----------------------------------------------------------------------
# AC-2: IN_FLIGHT raises
# ----------------------------------------------------------------------
def test_ac2_in_flight_raises_with_observed_and_error_log() -> None:
# Arrange
source = _FakeSource(FlightStateSignal.IN_FLIGHT)
gate, records = _build_gate(source=source)
# Act + Assert
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
gate.confirm_on_ground()
assert excinfo.value.observed is FlightStateSignal.IN_FLIGHT
assert "IN_FLIGHT" in str(excinfo.value)
assert _kinds(records) == ["c11.upload.refused.flight_state"]
assert records[0].levelname == "ERROR"
# ----------------------------------------------------------------------
# AC-3: UNKNOWN raises (fail-closed)
# ----------------------------------------------------------------------
def test_ac3_unknown_raises_fail_closed() -> None:
# Arrange
source = _FakeSource(FlightStateSignal.UNKNOWN)
gate, records = _build_gate(source=source)
# Act + Assert
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
gate.confirm_on_ground()
assert excinfo.value.observed is FlightStateSignal.UNKNOWN
assert _kinds(records) == ["c11.upload.refused.flight_state"]
# ----------------------------------------------------------------------
# AC-4: TAKING_OFF and LANDING raise
# ----------------------------------------------------------------------
@pytest.mark.parametrize(
"transition_signal",
[FlightStateSignal.TAKING_OFF, FlightStateSignal.LANDING],
)
def test_ac4_transition_states_raise(
transition_signal: FlightStateSignal,
) -> None:
# Arrange
source = _FakeSource(transition_signal)
gate, records = _build_gate(source=source)
# Act + Assert
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
gate.confirm_on_ground()
assert excinfo.value.observed is transition_signal
assert _kinds(records) == ["c11.upload.refused.flight_state"]
# ----------------------------------------------------------------------
# AC-5: source exception → UNKNOWN with __cause__ chained
# ----------------------------------------------------------------------
def test_ac5_source_exception_maps_to_unknown_and_preserves_cause() -> None:
# Arrange
original = RuntimeError("FC disconnected")
source = _RaisingSource(original)
gate, records = _build_gate(source=source)
# Act + Assert
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
gate.confirm_on_ground()
assert excinfo.value.observed is FlightStateSignal.UNKNOWN
assert excinfo.value.__cause__ is original
assert _kinds(records) == ["c11.upload.refused.flight_state"]
assert records[0].levelname == "ERROR"
assert "FC disconnected" in records[0].kv["source_error"]
# ----------------------------------------------------------------------
# AC-6: FlightStateSource Protocol is conformance-checkable
# ----------------------------------------------------------------------
def test_ac6_protocol_isinstance_check_distinguishes_conforming_from_partial() -> None:
# Arrange
conforming = _FakeSource(FlightStateSignal.ON_GROUND)
non_conforming = _PartialFake()
# Assert
assert isinstance(conforming, FlightStateSource)
assert not isinstance(non_conforming, FlightStateSource)
# ----------------------------------------------------------------------
# AC-7: Error carries diagnostic fields
# ----------------------------------------------------------------------
def test_ac7_error_carries_observed_and_observed_at_with_message_format() -> None:
# Arrange
source = _FakeSource(FlightStateSignal.IN_FLIGHT)
gate, _ = _build_gate(source=source)
# Act
with pytest.raises(FlightStateNotOnGroundError) as excinfo:
gate.confirm_on_ground()
# Assert
assert excinfo.value.observed is FlightStateSignal.IN_FLIGHT
assert isinstance(excinfo.value.observed_at, datetime)
assert excinfo.value.observed_at.tzinfo == timezone.utc
assert excinfo.value.observed_at.microsecond == 0
assert str(excinfo.value).startswith("Upload refused: flight state is ")
# ----------------------------------------------------------------------
# AC-8: Gate calls source exactly once
# ----------------------------------------------------------------------
def test_ac8_gate_calls_source_exactly_once_no_retry() -> None:
# Arrange
source = _FakeSource(FlightStateSignal.IN_FLIGHT)
gate, _ = _build_gate(source=source)
# Act
with pytest.raises(FlightStateNotOnGroundError):
gate.confirm_on_ground()
# Assert
assert source.call_count == 1
# ----------------------------------------------------------------------
# NFR-perf: confirm_on_ground microbench p99 ≤ 1 ms
# ----------------------------------------------------------------------
def test_nfr_perf_microbench_under_one_ms_p99() -> None:
# Arrange
source = _FakeSource(FlightStateSignal.ON_GROUND)
gate, _ = _build_gate(source=source)
iterations = 5_000
# Act
samples_ns: list[int] = []
for _ in range(iterations):
start = time.perf_counter_ns()
gate.confirm_on_ground()
samples_ns.append(time.perf_counter_ns() - start)
# Assert
samples_ns.sort()
p99_ns = samples_ns[int(iterations * 0.99) - 1]
assert p99_ns < 1_000_000, (
f"p99 latency {p99_ns} ns exceeds 1 ms (1_000_000 ns) NFR budget"
)
# ----------------------------------------------------------------------
# NFR-reliability-fail-closed: every non-ON_GROUND state raises
# ----------------------------------------------------------------------
@pytest.mark.parametrize(
"non_on_ground_signal",
[
FlightStateSignal.IN_FLIGHT,
FlightStateSignal.TAKING_OFF,
FlightStateSignal.LANDING,
FlightStateSignal.UNKNOWN,
],
)
def test_nfr_reliability_fail_closed_matrix_complete(
non_on_ground_signal: FlightStateSignal,
) -> None:
# Arrange
source = _FakeSource(non_on_ground_signal)
gate, _ = _build_gate(source=source)
# Act + Assert
with pytest.raises(FlightStateNotOnGroundError):
gate.confirm_on_ground()
@@ -26,8 +26,6 @@ import pytest
from gps_denied_onboard.components.c11_tile_manager import (
C11RetryConfig,
FlightStateNotOnGroundError,
FlightStateSignal,
IdempotentRetryTileUploader,
IngestStatus,
PerTileStatus,
@@ -76,7 +74,6 @@ class _ScriptedInner:
self.raises = list(raise_on_call or [])
self.calls: list[UploadRequest] = []
self.enumerate_calls: list[Any] = []
self.confirm_calls: int = 0
def upload_pending_tiles(self, request: UploadRequest) -> UploadBatchReport:
self.calls.append(request)
@@ -94,10 +91,6 @@ class _ScriptedInner:
self.enumerate_calls.append(flight_id)
return [{"sentinel": True, "flight_id": flight_id}]
def confirm_flight_state(self) -> FlightStateSignal:
self.confirm_calls += 1
return FlightStateSignal.ON_GROUND
@dataclass
class _FakeMetadataStore:
@@ -388,39 +381,11 @@ def test_ac11_enumerate_pending_passes_through() -> None:
assert out == [{"sentinel": True, "flight_id": fid}]
def test_ac11_confirm_flight_state_passes_through() -> None:
# Arrange
inner = _ScriptedInner(reports=[_success(0)])
(decorator, _logs, _store, _clk, _fdr) = _build_decorator(inner=inner)
# Act
state = decorator.confirm_flight_state()
# Assert
assert state == FlightStateSignal.ON_GROUND
assert inner.confirm_calls == 1
# ----------------------------------------------------------------------
# AC-12 — inner exception propagates without retry
# ----------------------------------------------------------------------
def test_ac12_flight_state_not_on_ground_propagates_without_retry() -> None:
# Arrange
from datetime import datetime, timezone
err = FlightStateNotOnGroundError(FlightStateSignal.IN_FLIGHT, datetime.now(timezone.utc))
inner = _ScriptedInner(raise_on_call=[err])
(decorator, _logs, _store, clk, _fdr) = _build_decorator(inner=inner)
# Act / Assert
with pytest.raises(FlightStateNotOnGroundError):
decorator.upload_pending_tiles(_request())
assert clk.sleep_calls == []
assert len(inner.calls) == 1
def test_ac12_satellite_provider_error_propagates_without_retry() -> None:
# Arrange
inner = _ScriptedInner(raise_on_call=[SatelliteProviderError("boom")])
@@ -493,7 +458,6 @@ def test_ac10_factory_returns_decorated_uploader_by_default() -> None:
http_client=_httpx.Client(transport=transport),
tile_store=object(),
tile_metadata_store=object(),
flight_state_gate=object(), # type: ignore[arg-type]
key_manager=object(), # type: ignore[arg-type]
)
@@ -516,7 +480,6 @@ def test_ac10_factory_bypasses_decorator_when_flag_set() -> None:
http_client=_httpx.Client(transport=transport),
tile_store=object(),
tile_metadata_store=object(),
flight_state_gate=object(), # type: ignore[arg-type]
key_manager=object(), # type: ignore[arg-type]
)
@@ -33,17 +33,12 @@ class _NullSleep:
return None
class _PartialFakeMissingConfirm:
"""Conformance counterexample: missing ``confirm_flight_state``."""
class _PartialFakeMissingEnumerate:
"""Conformance counterexample: missing ``enumerate_pending_tiles``."""
def upload_pending_tiles(self, request: object) -> object: # noqa: ARG002
return None
def enumerate_pending_tiles(
self, flight_id: object | None = None
) -> list[object]: # noqa: ARG002
return []
class _PartialDownloaderMissingEnumerate:
"""Conformance counterexample: missing ``enumerate_remote_coverage``."""
@@ -67,7 +62,6 @@ def test_ac12_concrete_uploader_satisfies_protocol() -> None:
http_client=httpx.Client(transport=transport),
tile_store=object(), # type: ignore[arg-type]
tile_metadata_store=object(), # type: ignore[arg-type]
flight_state_gate=object(), # type: ignore[arg-type]
key_manager=object(), # type: ignore[arg-type]
fdr_client=FakeFdrSink(_PRODUCER_ID), # type: ignore[arg-type]
logger=logging.getLogger("test_az319_conformance"),
@@ -81,7 +75,7 @@ def test_ac12_concrete_uploader_satisfies_protocol() -> None:
def test_ac12_partial_fake_is_not_protocol_conformant() -> None:
# Assert
assert not isinstance(_PartialFakeMissingConfirm(), TileUploader)
assert not isinstance(_PartialFakeMissingEnumerate(), TileUploader)
def test_ac10_concrete_downloader_satisfies_protocol() -> None:
@@ -129,7 +123,6 @@ def test_ac9_idempotent_retry_decorator_satisfies_uploader_protocol() -> None:
http_client=httpx.Client(transport=transport),
tile_store=object(), # type: ignore[arg-type]
tile_metadata_store=object(), # type: ignore[arg-type]
flight_state_gate=object(), # type: ignore[arg-type]
key_manager=object(), # type: ignore[arg-type]
fdr_client=FakeFdrSink(_PRODUCER_ID), # type: ignore[arg-type]
logger=logging.getLogger("test_az320_inner"),
@@ -1,12 +1,14 @@
"""AZ-319 ``HttpTileUploader`` unit tests.
Covers AC-1 .. AC-14 and the upload-throughput NFR from
``_docs/02_tasks/todo/AZ-319_c11_tile_uploader.md``.
Covers AC-1, AC-3 .. AC-14 and the upload-throughput NFR from
``_docs/02_tasks/done/AZ-319_c11_tile_uploader.md``. AC-2 (the legacy
ON_GROUND gate) was removed in batch 44 — gating is now C12's
``PostLandingUploadOrchestrator`` responsibility.
Uses :class:`httpx.MockTransport` for deterministic HTTP responses,
:class:`FakeFdrSink` for FDR capture, a list-backed ``logging.Handler``
for log capture, and stub C6 stores / gate / key manager so this
suite never drags in AZ-303 / AZ-305 / AZ-317 / AZ-318 internals.
for log capture, and stub C6 stores / key manager so this suite never
drags in AZ-303 / AZ-305 / AZ-318 internals.
"""
from __future__ import annotations
@@ -25,8 +27,6 @@ import pytest
from gps_denied_onboard.components.c11_tile_manager import (
C11Config,
FlightStateNotOnGroundError,
FlightStateSignal,
HttpTileUploader,
IngestStatus,
PerFlightKeyManager,
@@ -37,9 +37,6 @@ from gps_denied_onboard.components.c11_tile_manager import (
UploadRequest,
canonical_payload_bytes,
)
from gps_denied_onboard.components.c11_tile_manager.flight_state_gate import (
FlightStateGate,
)
from gps_denied_onboard.fdr_client import FdrRecord
from gps_denied_onboard.fdr_client.fakes import FakeFdrSink
@@ -125,25 +122,6 @@ class _FakeMetadataStore:
self.mark_calls.append((tile_id, uploaded_at))
class _StubGate:
"""Stand-in for AZ-317 ``FlightStateGate``."""
def __init__(
self, signal: FlightStateSignal = FlightStateSignal.ON_GROUND
) -> None:
self._signal = signal
self.confirm_calls: int = 0
def confirm_on_ground(self) -> FlightStateSignal:
self.confirm_calls += 1
if self._signal != FlightStateSignal.ON_GROUND:
raise FlightStateNotOnGroundError(
self._signal,
datetime.now(timezone.utc),
)
return self._signal
class _StubKeyManager:
"""Stand-in for AZ-318 ``PerFlightKeyManager``.
@@ -222,7 +200,6 @@ def _build_uploader(
transport: httpx.MockTransport,
pending: list[_FakeTile] | None = None,
blobs: dict[str, bytes] | None = None,
gate_signal: FlightStateSignal = FlightStateSignal.ON_GROUND,
fingerprint_hex: str = "0123456789abcdef",
config: C11Config | None = None,
sleep_recorder: list[float] | None = None,
@@ -230,7 +207,6 @@ def _build_uploader(
HttpTileUploader,
FakeFdrSink,
list[logging.LogRecord],
_StubGate,
_StubKeyManager,
_FakeTileStore,
_FakeMetadataStore,
@@ -249,7 +225,6 @@ def _build_uploader(
logger.setLevel(logging.DEBUG)
logger.propagate = False
gate = _StubGate(signal=gate_signal)
key_manager = _StubKeyManager(fingerprint_hex=fingerprint_hex)
tile_store = _FakeTileStore(blobs=blobs)
metadata_store = _FakeMetadataStore(pending=pending)
@@ -272,14 +247,13 @@ def _build_uploader(
http_client=client,
tile_store=tile_store,
tile_metadata_store=metadata_store,
flight_state_gate=gate, # type: ignore[arg-type]
key_manager=key_manager, # type: ignore[arg-type]
fdr_client=fdr, # type: ignore[arg-type]
logger=logger,
config=cfg,
sleep=_sleep,
)
return uploader, fdr, log_records, gate, key_manager, tile_store, metadata_store, sleeps
return uploader, fdr, log_records, key_manager, tile_store, metadata_store, sleeps
def _make_request(*, batch_size: int = 10, flight_id: UUID | None = None) -> UploadRequest:
@@ -361,7 +335,6 @@ def test_ac1_50_tile_happy_path_marks_all_uploaded() -> None:
uploader,
fdr,
_logs,
_gate,
key_manager,
_tile_store,
metadata_store,
@@ -385,48 +358,6 @@ def test_ac1_50_tile_happy_path_marks_all_uploaded() -> None:
assert key_manager.end_calls == 1
# ----------------------------------------------------------------------
# AC-2: gate blocks before any read or POST
# ----------------------------------------------------------------------
def test_ac2_gate_blocks_before_any_read_or_post() -> None:
# Arrange
pending = [_make_tile()]
posted: list[httpx.Request] = []
def _handler(request: httpx.Request) -> httpx.Response:
posted.append(request)
return httpx.Response(202, json={"batch_uuid": str(uuid4()), "per_tile_status": []})
transport = httpx.MockTransport(_handler)
(
uploader,
_fdr,
_logs,
gate,
key_manager,
tile_store,
metadata_store,
_sleeps,
) = _build_uploader(
transport=transport,
pending=pending,
gate_signal=FlightStateSignal.IN_FLIGHT,
)
# Act / Assert
with pytest.raises(FlightStateNotOnGroundError):
uploader.upload_pending_tiles(_make_request())
assert gate.confirm_calls == 1
assert metadata_store.pending_calls == 0
assert tile_store.read_calls == []
assert key_manager.start_calls == []
assert key_manager.end_calls == 0
assert posted == []
# ----------------------------------------------------------------------
# AC-3: signature rejection — record + skip mark_uploaded; outcome=partial
# ----------------------------------------------------------------------
@@ -457,7 +388,6 @@ def test_ac3_signature_rejection_records_and_keeps_pending() -> None:
uploader,
fdr,
_logs,
_gate,
key_manager,
_tile_store,
metadata_store,
@@ -504,7 +434,6 @@ def test_ac4_duplicate_and_superseded_are_success() -> None:
uploader,
_fdr,
_logs,
_gate,
_key_manager,
_tile_store,
metadata_store,
@@ -536,7 +465,6 @@ def test_ac5_signing_key_zeroised_on_success() -> None:
uploader,
_fdr,
_logs,
_gate,
key_manager,
_tile_store,
_metadata_store,
@@ -570,7 +498,6 @@ def test_ac6_signing_key_zeroised_on_failure() -> None:
uploader,
_fdr,
_logs,
_gate,
key_manager,
_tile_store,
metadata_store,
@@ -605,7 +532,6 @@ def test_ac7_public_key_fdr_precedes_tile_fdr() -> None:
uploader,
fdr,
_logs,
_gate,
_key_manager,
_tile_store,
_metadata_store,
@@ -661,7 +587,6 @@ def test_ac8_429_honours_retry_after_seconds() -> None:
uploader,
_fdr,
_logs,
_gate,
_key_manager,
_tile_store,
_metadata_store,
@@ -697,7 +622,6 @@ def test_ac9_persistent_5xx_raises_satellite_provider_error() -> None:
uploader,
_fdr,
_logs,
_gate,
key_manager,
_tile_store,
_metadata_store,
@@ -731,7 +655,6 @@ def test_ac10_401_fails_fast_no_retry() -> None:
uploader,
_fdr,
log_records,
_gate,
_key_manager,
_tile_store,
_metadata_store,
@@ -766,7 +689,6 @@ def test_ac11_empty_pending_set_is_success_no_posts() -> None:
uploader,
fdr,
_logs,
_gate,
key_manager,
_tile_store,
_metadata_store,
@@ -868,7 +790,6 @@ def test_ac14_partial_success_batch_does_not_raise() -> None:
uploader,
_fdr,
_logs,
_gate,
_key_manager,
_tile_store,
metadata_store,
@@ -910,7 +831,6 @@ def test_429_budget_exhaustion_raises_rate_limited_error() -> None:
uploader,
_fdr,
_logs,
_gate,
key_manager,
_tile_store,
_metadata_store,
@@ -951,7 +871,7 @@ def test_nfr_throughput_1000_tiles_under_budget() -> None:
)
transport = httpx.MockTransport(_handler)
(uploader, _fdr, _logs, _gate, _km, _ts, _ms, _sleeps) = _build_uploader(
(uploader, _fdr, _logs, _km, _ts, _ms, _sleeps) = _build_uploader(
transport=transport, pending=pending
)
@@ -22,7 +22,7 @@ import httpx
import pytest
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api import (
EmptyWaypointsError,
FlightDto,
FlightFileNotFoundError,
@@ -18,7 +18,7 @@ from uuid import UUID
import pytest
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
from gps_denied_onboard.components.c12_operator_tooling import (
from gps_denied_onboard.components.c12_operator_orchestrator import (
BuildCacheOrchestrator,
BuildCacheOutcome,
BuildCacheRequest,
@@ -51,20 +51,20 @@ from gps_denied_onboard.components.c12_operator_tooling import (
WaypointObjective,
WaypointSource,
)
from gps_denied_onboard.components.c12_operator_tooling.file_lock import LockTimeout
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
from gps_denied_onboard.components.c12_operator_orchestrator.file_lock import LockTimeout
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.interface import (
FlightDto,
FlightsApiClient,
)
from gps_denied_onboard.components.c12_operator_tooling.remote_c10_invoker import (
from gps_denied_onboard.components.c12_operator_orchestrator.remote_c10_invoker import (
RemoteBuildRequest,
)
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
from gps_denied_onboard.components.c12_operator_orchestrator.ssh_session import (
RemoteCommandResult,
SshSession,
SshSessionFactory,
)
from gps_denied_onboard.components.c12_operator_tooling.tile_downloader_cut import (
from gps_denied_onboard.components.c12_operator_orchestrator.tile_downloader_cut import (
TileDownloaderCut,
)
@@ -948,7 +948,7 @@ class TestCompositionRootSmoke:
# Reasonable smoke: real CompanionBringup with a fake SSH factory
# constructs without raising; the orchestrator pulls the same
# instance via the services dataclass.
from gps_denied_onboard.components.c12_operator_tooling.remote_sidecar_verifier import (
from gps_denied_onboard.components.c12_operator_orchestrator.remote_sidecar_verifier import (
RemoteSidecarVerifier,
)
@@ -23,7 +23,7 @@ from uuid import UUID
import pytest
from click.testing import CliRunner
from gps_denied_onboard.components.c12_operator_tooling import (
from gps_denied_onboard.components.c12_operator_orchestrator import (
EXIT_BUILD_FAILURE,
EXIT_DOWNLOAD_FAILURE,
EXIT_EMPTY_WAYPOINTS,
@@ -42,7 +42,7 @@ from gps_denied_onboard.components.c12_operator_tooling import (
FlightFromFile,
SectorClassification,
)
from gps_denied_onboard.components.c12_operator_tooling.cli import app
from gps_denied_onboard.components.c12_operator_orchestrator.cli import app
_FLIGHT_ID = UUID("00000000-0000-0000-0000-000000000001")
_API_KEY = "super-secret-api-key"
@@ -1,4 +1,4 @@
"""AZ-326 AC-8 — `operator-tool` console script is installed and runnable."""
"""AZ-326 AC-8 — `operator-orchestrator` console script is installed and runnable."""
from __future__ import annotations
@@ -12,35 +12,35 @@ import pytest
@pytest.fixture(scope="module")
def operator_tool_binary() -> str:
def operator_orchestrator_binary() -> str:
# Prefer PATH (mimics operator install). Fall back to the active Python
# interpreter's bin directory so the test still runs in an unactivated
# venv (`.venv/bin/pytest ...`), which is the common CI invocation.
candidate = shutil.which("operator-tool")
candidate = shutil.which("operator-orchestrator")
if candidate is not None:
return candidate
venv_bin = Path(sys.executable).parent / "operator-tool"
venv_bin = Path(sys.executable).parent / "operator-orchestrator"
if venv_bin.exists():
return str(venv_bin)
pytest.skip("operator-tool console script not on PATH or in venv bin")
pytest.skip("operator-orchestrator console script not on PATH or in venv bin")
class TestConsoleScript:
def test_help_exits_zero(self, operator_tool_binary: str) -> None:
def test_help_exits_zero(self, operator_orchestrator_binary: str) -> None:
# Act
result = subprocess.run(
[operator_tool_binary, "--help"],
[operator_orchestrator_binary, "--help"],
capture_output=True,
text=True,
timeout=10,
)
# Assert
assert result.returncode == 0, result.stderr
assert "operator-tool" in result.stdout
assert "operator-orchestrator" in result.stdout
@pytest.mark.slow
def test_cold_start_under_500ms_p99(self, operator_tool_binary: str) -> None:
"""NFR-perf-cold-start — `operator-tool --help` ≤ 500 ms p99 over 11 runs.
def test_cold_start_under_500ms_p99(self, operator_orchestrator_binary: str) -> None:
"""NFR-perf-cold-start — `operator-orchestrator --help` ≤ 500 ms p99 over 11 runs.
Methodology: 11 cold-start subprocess runs, drop the single
worst sample (system noise: OS context switch, disk cache
@@ -55,7 +55,7 @@ class TestConsoleScript:
for _ in range(11):
start = time.monotonic()
subprocess.run(
[operator_tool_binary, "--help"],
[operator_orchestrator_binary, "--help"],
capture_output=True,
text=True,
check=True,
@@ -14,10 +14,10 @@ from types import SimpleNamespace
import pytest
from click.testing import CliRunner
from gps_denied_onboard.components.c12_operator_tooling import (
from gps_denied_onboard.components.c12_operator_orchestrator import (
EXIT_OK,
)
from gps_denied_onboard.components.c12_operator_tooling.cli import app
from gps_denied_onboard.components.c12_operator_orchestrator.cli import app
_EXPECTED_SUBCOMMANDS = {
"download",
@@ -42,7 +42,7 @@ def isolated_log(tmp_path: Path) -> Path:
class TestSubcommandRegistration:
"""AC-1 — `operator-tool --help` lists exactly the six subcommands."""
"""AC-1 — `operator-orchestrator --help` lists exactly the six subcommands."""
def test_top_level_help_lists_all_six_subcommands(self, runner: CliRunner) -> None:
# Act
@@ -92,11 +92,11 @@ class TestSuccessfulSetSectorAcTwo:
config_obj = SimpleNamespace()
# Inject a config via the --log-path override + per-test sector store
# by calling the underlying Click command directly with a custom obj.
from gps_denied_onboard.components.c12_operator_tooling import (
from gps_denied_onboard.components.c12_operator_orchestrator import (
C12Config,
HostKeyPolicy,
)
from gps_denied_onboard.components.c12_operator_tooling.config import (
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
C12CompanionConfig,
)
@@ -142,7 +142,7 @@ class TestStructuredLoggingShapeAcSeven:
) -> None:
# Arrange
store_path = tmp_path / "sector.json"
from gps_denied_onboard.components.c12_operator_tooling import C12Config
from gps_denied_onboard.components.c12_operator_orchestrator import C12Config
# Act
result = runner.invoke(
@@ -9,7 +9,7 @@ from pathlib import Path, PurePosixPath
import pytest
from gps_denied_onboard.components.c12_operator_tooling import (
from gps_denied_onboard.components.c12_operator_orchestrator import (
C12CompanionConfig,
CompanionAddress,
CompanionBringup,
@@ -19,10 +19,10 @@ from gps_denied_onboard.components.c12_operator_tooling import (
HostKeyPolicy,
ReadinessOutcome,
)
from gps_denied_onboard.components.c12_operator_tooling.remote_sidecar_verifier import (
from gps_denied_onboard.components.c12_operator_orchestrator.remote_sidecar_verifier import (
RemoteSidecarResult,
)
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
from gps_denied_onboard.components.c12_operator_orchestrator.ssh_session import (
RemoteCommandResult,
SshSession,
SshSessionFactory,
@@ -2,7 +2,7 @@
from __future__ import annotations
from gps_denied_onboard.components.c12_operator_tooling import exit_codes
from gps_denied_onboard.components.c12_operator_orchestrator import exit_codes
class TestExitCodes:
@@ -0,0 +1,430 @@
"""AZ-329 LocalFdrFooterReader unit + integration tests.
Covers:
* AC-6 — newest-segment-first short-circuit (the reader opens only the
newest segment when the footer lives there).
* AC-9 — real FDR fixture C12-IT-03(a): clean-shutdown footer present →
the reader returns the parsed :class:`FlightFooterRecord`.
* AC-10 — real FDR fixture C12-IT-03(b): no-footer truncation → the
reader returns ``None`` and the orchestrator refuses with
``footer_missing`` (integration via a tiny composition of orchestrator
+ reader on the truncated fixture).
* Frame-corruption paths (truncated length prefix, truncated body,
invalid JSON, wrong-flight UUID, payload schema mismatch) →
:class:`FdrUnreadableError`.
The fixtures are generated in-test using the same length-prefixed
serialisation the C13 writer uses (``struct.Struct('<I')`` +
``fdr_client.records.serialise``). Driving the full
:class:`FileFdrWriter` lifecycle is unnecessary — the on-disk frame
layout is the contract.
"""
from __future__ import annotations
import logging
import struct
from pathlib import Path
from typing import Any
from uuid import UUID, uuid4
import orjson
import pytest
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
FlightFooterRecord,
PostLandingUploadRequest,
)
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
C12PostLandingConfig,
)
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
FdrUnreadableError,
FlightStateNotConfirmedError,
)
from gps_denied_onboard.components.c12_operator_orchestrator.fdr_footer_reader import (
LocalFdrFooterReader,
)
from gps_denied_onboard.components.c12_operator_orchestrator.post_landing_upload import (
PostLandingUploadOrchestrator,
)
from gps_denied_onboard.fdr_client.records import FdrRecord, serialise
_LENGTH_PREFIX = struct.Struct("<I")
def _frame_payload(record: FdrRecord) -> bytes:
body = serialise(record)
return _LENGTH_PREFIX.pack(len(body)) + body
def _make_record(*, kind: str, payload: dict[str, Any]) -> FdrRecord:
return FdrRecord(
schema_version=1,
ts="2026-05-13T12:00:00+00:00",
producer_id="test.producer",
kind=kind,
payload=payload,
)
def _make_footer_record(*, flight_id: UUID, clean_shutdown: bool) -> FdrRecord:
return _make_record(
kind="flight_footer",
payload={
"flight_id": str(flight_id),
"flight_ended_at_iso": "2026-05-13T12:00:00+00:00",
"flight_ended_at_monotonic_ns": 1234567890,
"records_written": 9876,
"records_dropped_overrun": 0 if clean_shutdown else 5,
"bytes_written": 1024 * 1024,
"rollover_count": 0,
"clean_shutdown": clean_shutdown,
},
)
def _write_segment(
flight_dir: Path, segment_index: int, records: list[FdrRecord]
) -> Path:
path = flight_dir / f"segment-{segment_index:04d}.fdr"
with open(path, "wb") as fh:
for record in records:
fh.write(_frame_payload(record))
return path
def _setup_flight_dir(tmp_path: Path, flight_id: UUID) -> tuple[Path, Path]:
fdr_root = tmp_path / "fdr"
fdr_root.mkdir()
flight_dir = fdr_root / str(flight_id)
flight_dir.mkdir()
return fdr_root, flight_dir
# ----------------------------------------------------------------------
# AC-6 — newest-segment-first short-circuit
# ----------------------------------------------------------------------
def test_ac6_reader_short_circuits_on_newest_segment(tmp_path, monkeypatch) -> None:
# Arrange — three segments; footer lives in the newest (index 2).
flight_id = uuid4()
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
_write_segment(
flight_dir, 0, [_make_record(kind="log", payload={"msg": "early"})]
)
_write_segment(
flight_dir, 1, [_make_record(kind="log", payload={"msg": "middle"})]
)
_write_segment(
flight_dir,
2,
[_make_footer_record(flight_id=flight_id, clean_shutdown=True)],
)
opened_paths: list[str] = []
real_open = open
def _tracking_open(file, *args, **kwargs):
if str(file).endswith(".fdr"):
opened_paths.append(str(file))
return real_open(file, *args, **kwargs)
monkeypatch.setattr(
"gps_denied_onboard.components.c12_operator_orchestrator.fdr_footer_reader.open",
_tracking_open,
raising=False,
)
reader = LocalFdrFooterReader(fdr_root)
# Act
footer = reader.read_footer(flight_id)
# Assert
assert footer is not None
assert footer.flight_id == flight_id
assert footer.clean_shutdown is True
assert len(opened_paths) == 1
assert opened_paths[0].endswith("segment-0002.fdr")
def test_ac6_reader_walks_older_segments_when_newest_has_no_footer(tmp_path) -> None:
# Arrange — footer lives in segment 0 (oldest); newer segments have no footer.
flight_id = uuid4()
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
_write_segment(
flight_dir,
0,
[_make_footer_record(flight_id=flight_id, clean_shutdown=True)],
)
_write_segment(
flight_dir, 1, [_make_record(kind="log", payload={"msg": "stray"})]
)
_write_segment(
flight_dir, 2, [_make_record(kind="log", payload={"msg": "stray2"})]
)
reader = LocalFdrFooterReader(fdr_root)
# Act
footer = reader.read_footer(flight_id)
# Assert
assert footer is not None
assert footer.clean_shutdown is True
def test_reader_returns_none_when_no_footer_anywhere(tmp_path) -> None:
# Arrange
flight_id = uuid4()
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
_write_segment(
flight_dir, 0, [_make_record(kind="log", payload={"msg": "only-log"})]
)
reader = LocalFdrFooterReader(fdr_root)
# Act
footer = reader.read_footer(flight_id)
# Assert
assert footer is None
# ----------------------------------------------------------------------
# Parse / framing corruption → FdrUnreadableError
# ----------------------------------------------------------------------
def test_reader_raises_on_truncated_length_prefix(tmp_path) -> None:
# Arrange — segment file ends with a single byte (not a complete prefix).
flight_id = uuid4()
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
path = flight_dir / "segment-0000.fdr"
path.write_bytes(b"\x01")
reader = LocalFdrFooterReader(fdr_root)
# Act / Assert
with pytest.raises(FdrUnreadableError, match="truncated length prefix"):
reader.read_footer(flight_id)
def test_reader_raises_on_truncated_body(tmp_path) -> None:
# Arrange — length prefix claims 100 bytes; only 10 are present.
flight_id = uuid4()
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
path = flight_dir / "segment-0000.fdr"
path.write_bytes(_LENGTH_PREFIX.pack(100) + b"x" * 10)
reader = LocalFdrFooterReader(fdr_root)
# Act / Assert
with pytest.raises(FdrUnreadableError, match="truncated record body"):
reader.read_footer(flight_id)
def test_reader_raises_on_invalid_json_body(tmp_path) -> None:
# Arrange — length matches but JSON is garbage.
flight_id = uuid4()
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
body = b"this is not json"
path = flight_dir / "segment-0000.fdr"
path.write_bytes(_LENGTH_PREFIX.pack(len(body)) + body)
reader = LocalFdrFooterReader(fdr_root)
# Act / Assert
with pytest.raises(FdrUnreadableError, match="failed to parse record"):
reader.read_footer(flight_id)
def test_reader_raises_when_footer_flight_id_mismatches(tmp_path) -> None:
# Arrange — footer carries a different flight_id than the requested one.
requested = uuid4()
other = uuid4()
fdr_root, flight_dir = _setup_flight_dir(tmp_path, requested)
_write_segment(
flight_dir, 0, [_make_footer_record(flight_id=other, clean_shutdown=True)]
)
reader = LocalFdrFooterReader(fdr_root)
# Act / Assert
with pytest.raises(FdrUnreadableError, match="flight_footer.flight_id mismatch"):
reader.read_footer(requested)
def test_reader_raises_when_footer_payload_misses_required_field(tmp_path) -> None:
# Arrange — footer payload missing `clean_shutdown`; build the bytes
# by hand so we bypass the serialise validator (which would not catch
# this because `flight_footer` allows known fields without requiring
# all of them per AZ-272's forward-compat policy).
flight_id = uuid4()
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
bad_payload = {
"flight_id": str(flight_id),
"flight_ended_at_iso": "2026-05-13T12:00:00+00:00",
"flight_ended_at_monotonic_ns": 0,
"records_written": 1,
"records_dropped_overrun": 0,
"bytes_written": 0,
"rollover_count": 0,
# clean_shutdown intentionally omitted
}
envelope = {
"schema_version": 1,
"ts": "2026-05-13T12:00:00+00:00",
"producer_id": "test.producer",
"kind": "flight_footer",
"payload": bad_payload,
}
body = orjson.dumps(envelope)
path = flight_dir / "segment-0000.fdr"
path.write_bytes(_LENGTH_PREFIX.pack(len(body)) + body)
reader = LocalFdrFooterReader(fdr_root)
# Act / Assert
with pytest.raises(FdrUnreadableError, match="flight_footer payload schema violation"):
reader.read_footer(flight_id)
def test_reader_ignores_non_segment_files_in_flight_dir(tmp_path) -> None:
# Arrange — directory contains a stray `.log` file alongside the
# segment with the footer.
flight_id = uuid4()
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
_write_segment(
flight_dir,
0,
[_make_footer_record(flight_id=flight_id, clean_shutdown=True)],
)
(flight_dir / "operator-notes.log").write_text("ignore me")
(flight_dir / "segment-bad.txt").write_text("ignore me too")
reader = LocalFdrFooterReader(fdr_root)
# Act
footer = reader.read_footer(flight_id)
# Assert
assert footer is not None
assert footer.clean_shutdown is True
# ----------------------------------------------------------------------
# AC-9 — integration: clean-shutdown footer fixture → upload invoked
# ----------------------------------------------------------------------
class _RecordingUploader:
def __init__(self, report) -> None:
self.report = report
self.calls: list = []
def upload_pending_tiles(self, request):
self.calls.append(request)
return self.report
def test_ac9_integration_clean_shutdown_fixture_triggers_upload(tmp_path) -> None:
# Arrange — real segment files on disk + LocalFdrFooterReader + recording uploader.
flight_id = uuid4()
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
_write_segment(
flight_dir, 0, [_make_record(kind="log", payload={"msg": "pre-takeoff"})]
)
_write_segment(
flight_dir, 1, [_make_record(kind="log", payload={"msg": "mid-flight"})]
)
_write_segment(
flight_dir,
2,
[
_make_record(kind="log", payload={"msg": "landing rollout"}),
_make_footer_record(flight_id=flight_id, clean_shutdown=True),
],
)
reader = LocalFdrFooterReader(fdr_root)
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
IngestStatusCut,
PerTileStatusCut,
UploadBatchReportCut,
UploadOutcomeCut,
)
fake_report = UploadBatchReportCut(
batch_uuid=uuid4(),
per_tile_status=(
PerTileStatusCut(tile_id="tile-A", status=IngestStatusCut.ACCEPTED),
),
retry_count=0,
next_retry_at_s=None,
outcome=UploadOutcomeCut.SUCCESS,
public_key_fingerprint="cd" * 8,
)
uploader = _RecordingUploader(fake_report)
orchestrator = PostLandingUploadOrchestrator(
tile_uploader=uploader,
fdr_footer_reader=reader,
logger=logging.getLogger("test.c12.it03a"),
config=C12PostLandingConfig(fdr_root=fdr_root),
)
request = PostLandingUploadRequest(
flight_id=flight_id,
satellite_provider_url="https://parent.example/ingest",
api_key="key-it03a",
batch_size=25,
)
# Act
returned = orchestrator.trigger_post_landing_upload(request)
# Assert
assert returned is fake_report
assert len(uploader.calls) == 1
assert uploader.calls[0].flight_id == flight_id
assert uploader.calls[0].batch_size == 25
# ----------------------------------------------------------------------
# AC-10 — integration: truncated fixture (no footer anywhere) → refusal
# ----------------------------------------------------------------------
def test_ac10_integration_truncated_fixture_refuses_with_footer_missing(tmp_path) -> None:
# Arrange — simulate a truncated flight: segments exist with `log`
# records but the writer terminated before close_flight() emitted the
# footer record.
flight_id = uuid4()
fdr_root, flight_dir = _setup_flight_dir(tmp_path, flight_id)
_write_segment(
flight_dir, 0, [_make_record(kind="log", payload={"msg": "in flight"})]
)
_write_segment(
flight_dir, 1, [_make_record(kind="log", payload={"msg": "still flying"})]
)
reader = LocalFdrFooterReader(fdr_root)
uploader = _RecordingUploader(None)
orchestrator = PostLandingUploadOrchestrator(
tile_uploader=uploader,
fdr_footer_reader=reader,
logger=logging.getLogger("test.c12.it03b"),
config=C12PostLandingConfig(fdr_root=fdr_root),
)
request = PostLandingUploadRequest(
flight_id=flight_id,
satellite_provider_url="https://parent.example/ingest",
api_key="key-it03b",
batch_size=25,
)
# Act / Assert
with pytest.raises(FlightStateNotConfirmedError) as exc_info:
orchestrator.trigger_post_landing_upload(request)
assert exc_info.value.not_confirmed_reason == "footer_missing"
assert uploader.calls == []
@@ -6,7 +6,7 @@ from pathlib import Path
import pytest
from gps_denied_onboard.components.c12_operator_tooling import (
from gps_denied_onboard.components.c12_operator_orchestrator import (
FilelockFileLockFactory,
LockTimeout,
)
@@ -4,7 +4,7 @@ from __future__ import annotations
import pytest
from gps_denied_onboard.components.c12_operator_tooling import (
from gps_denied_onboard.components.c12_operator_orchestrator import (
FRESHNESS_TABLE,
SectorClassification,
freshness_threshold_months,
@@ -0,0 +1,432 @@
"""AZ-330 OperatorReLocService unit tests.
Covers AC-1..AC-9 directly against the service with fakes. AC-10 (lazy
construction of the transport in the composition root) lives in
:mod:`test_cli_help_and_logging` / a composition-root regression — the
factory is verified to NOT call the transport constructor unless the
operator supplies one.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
import pytest
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
ReLocHint,
)
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
GcsLinkError,
)
from gps_denied_onboard.components.c12_operator_orchestrator.operator_reloc_service import (
OperatorReLocService,
)
from gps_denied_onboard.fdr_client import EnqueueResult, FdrClient
@dataclass
class _FakeTransport:
"""Configurable :class:`OperatorCommandTransport` for the unit tests."""
raises: GcsLinkError | None = None
calls: list[ReLocHint] = field(default_factory=list)
def send_reloc_hint(self, hint: ReLocHint) -> None:
self.calls.append(hint)
if self.raises is not None:
raise self.raises
@dataclass
class _FakeClock:
"""Deterministic Clock — ``monotonic_ns`` increments on every call."""
next_monotonic_ns: int = 1_000_000_000
fixed_time_ns: int = 1_715_600_000_000_000_000 # ~2024-05-13T12:53:20Z
def monotonic_ns(self) -> int:
v = self.next_monotonic_ns
self.next_monotonic_ns += 1
return v
def time_ns(self) -> int:
return self.fixed_time_ns
def sleep_until_ns(self, target_ns: int) -> None:
_ = target_ns
def _make_fdr_client(*, force_overrun: bool = False) -> FdrClient:
client = FdrClient(
producer_id="c12_operator_orchestrator",
capacity=4,
_emit_diag_log=False,
)
if force_overrun:
# Fill the buffer to its rounded-up power-of-two capacity so the
# next enqueue returns OVERRUN (AC-8).
from gps_denied_onboard.fdr_client.records import (
CURRENT_SCHEMA_VERSION,
FdrRecord,
)
filler = FdrRecord(
schema_version=CURRENT_SCHEMA_VERSION,
ts="2026-05-13T00:00:00.000000+00:00",
producer_id=client.producer_id,
kind="log",
payload={
"level": "INFO",
"component": "test",
"frame_id": "",
"kind": "test",
"msg": "filler",
},
)
for _ in range(client._capacity()):
client.enqueue(filler)
return client
def _make_hint(
*,
lat: float = 49.99876543,
lon: float = 36.12345678,
alt: float = 1234.5,
radius: float = 50.0,
reason: str = "lost track at WP3",
) -> ReLocHint:
return ReLocHint(
approximate_position_wgs84=LatLonAlt(lat_deg=lat, lon_deg=lon, alt_m=alt),
confidence_radius_m=radius,
reason=reason,
)
def _build_service(
*,
transport: _FakeTransport,
fdr_client: FdrClient,
clock: _FakeClock,
) -> OperatorReLocService:
logger = logging.getLogger("c12.operator_reloc_service.test")
return OperatorReLocService(
transport=transport,
fdr_client=fdr_client,
logger=logger,
clock=clock,
)
# ---------------------------------------------------------------------------
# AC-1: success → transport called once + INFO log + FDR record "sent"
# ---------------------------------------------------------------------------
def test_request_reloc_success_calls_transport_once_and_emits_fdr(
caplog: pytest.LogCaptureFixture,
) -> None:
# Arrange
transport = _FakeTransport()
fdr_client = _make_fdr_client()
clock = _FakeClock()
service = _build_service(transport=transport, fdr_client=fdr_client, clock=clock)
hint = _make_hint()
# Act
with caplog.at_level(logging.INFO, logger="c12.operator_reloc_service.test"):
service.request_reloc(hint)
# Assert — transport
assert len(transport.calls) == 1
assert transport.calls[0] is hint
# Assert — INFO log
sent_records = [r for r in caplog.records if r.kind == "c12.reloc.sent"]
assert len(sent_records) == 1
log_kv = sent_records[0].kv
assert log_kv["position_lat"] == pytest.approx(49.99877)
assert log_kv["position_lon"] == pytest.approx(36.12346)
assert log_kv["confidence_radius_m"] == 50.0
assert log_kv["reason"] == "lost track at WP3"
assert log_kv["altitude_m"] == 1234.5
# Assert — exactly one FDR record with outcome="sent"
record = fdr_client.pop_one()
assert record is not None
assert record.kind == "c12.reloc.requested"
assert record.payload["outcome"] == "sent"
assert record.payload["hint"]["reason"] == "lost track at WP3"
assert record.payload["hint"]["lat_deg"] == 49.99876543
assert record.payload["hint"]["lon_deg"] == 36.12345678
assert record.payload["hint"]["alt_m"] == 1234.5
assert record.payload["hint"]["confidence_radius_m"] == 50.0
assert "failure_reason" not in record.payload
assert isinstance(record.payload["ts_monotonic_ns"], int)
# ---------------------------------------------------------------------------
# AC-2: transport raises GcsLinkError → re-raise + ERROR log + FDR "failed"
# ---------------------------------------------------------------------------
def test_request_reloc_link_failure_reraises_with_c12_prefix_and_records_failure(
caplog: pytest.LogCaptureFixture,
) -> None:
# Arrange
inner = GcsLinkError(
reason="link signal lost",
wrapped_exception_repr="SerialTimeout(...)",
)
transport = _FakeTransport(raises=inner)
fdr_client = _make_fdr_client()
clock = _FakeClock()
service = _build_service(transport=transport, fdr_client=fdr_client, clock=clock)
hint = _make_hint()
# Act
with caplog.at_level(logging.ERROR, logger="c12.operator_reloc_service.test"):
with pytest.raises(GcsLinkError) as exc_info:
service.request_reloc(hint)
# Assert — re-raise + cause chain preserves the original
outer = exc_info.value
assert outer.reason == "C12 reloc-confirm: link signal lost"
assert outer.__cause__ is inner
assert outer.wrapped_exception_repr is not None
assert "GcsLinkError" in outer.wrapped_exception_repr
# Assert — ERROR log
failed_records = [r for r in caplog.records if r.kind == "c12.reloc.failed"]
assert len(failed_records) == 1
log_kv = failed_records[0].kv
assert log_kv["failure_reason"] == "link signal lost"
assert log_kv["wrapped_exception_repr"] == "SerialTimeout(...)"
# Assert — FDR record records the failure
record = fdr_client.pop_one()
assert record is not None
assert record.kind == "c12.reloc.requested"
assert record.payload["outcome"] == "failed"
assert record.payload["failure_reason"] == "link signal lost"
assert record.payload["hint"]["reason"] == "lost track at WP3"
# ---------------------------------------------------------------------------
# AC-3: ReLocHint(confidence_radius_m=0.0) → ValueError at construction
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("bad_radius", [0.0, -1.0, -0.0001])
def test_reloc_hint_rejects_non_positive_radius(bad_radius: float) -> None:
with pytest.raises(ValueError, match="confidence_radius_m must be > 0"):
ReLocHint(
approximate_position_wgs84=LatLonAlt(lat_deg=49.0, lon_deg=36.0, alt_m=100.0),
confidence_radius_m=bad_radius,
reason="test",
)
# ---------------------------------------------------------------------------
# AC-4: reason is preserved byte-for-byte through the transport call
# ---------------------------------------------------------------------------
def test_reason_byte_for_byte_through_transport_log_truncated(
caplog: pytest.LogCaptureFixture,
) -> None:
# Arrange
transport = _FakeTransport()
fdr_client = _make_fdr_client()
clock = _FakeClock()
service = _build_service(transport=transport, fdr_client=fdr_client, clock=clock)
long_reason = "x" * 300
hint = _make_hint(reason=long_reason)
# Act
with caplog.at_level(logging.INFO, logger="c12.operator_reloc_service.test"):
service.request_reloc(hint)
# Assert — transport sees the unchanged hint
assert transport.calls[0].reason == long_reason
# Assert — FDR record preserves the full reason
record = fdr_client.pop_one()
assert record is not None
assert record.payload["hint"]["reason"] == long_reason
# Assert — INFO log truncates to 200 chars
sent_records = [r for r in caplog.records if r.kind == "c12.reloc.sent"]
assert len(sent_records) == 1
assert sent_records[0].kv["reason"] == "x" * 200
# ---------------------------------------------------------------------------
# AC-5: Contract document exists with the exact method signature
# ---------------------------------------------------------------------------
def test_operator_command_transport_contract_document_exists() -> None:
# Arrange
from pathlib import Path
# The path is the current contract location; Phase F may move it
# to ``c12_operator_orchestrator/`` — both layouts are accepted so
# this test survives the Phase F rename.
candidates = [
Path(__file__).resolve().parents[3]
/ "_docs/02_document/contracts/c12_operator_orchestrator/operator_command_transport.md",
Path(__file__).resolve().parents[3]
/ "_docs/02_document/contracts/c12_operator_tooling/operator_command_transport.md",
]
# Act
existing = [p for p in candidates if p.exists()]
# Assert
assert existing, "expected operator_command_transport contract to exist in one of the known paths"
content = existing[0].read_text(encoding="utf-8")
assert "send_reloc_hint" in content
assert "ReLocHint" in content
assert "GcsLinkError" in content
assert "Versioning Rules" in content
assert content.count("TC-") >= 3
# ---------------------------------------------------------------------------
# AC-6: ReLocHint(reason="") → ValueError
# ---------------------------------------------------------------------------
def test_reloc_hint_rejects_empty_reason() -> None:
with pytest.raises(ValueError, match="reason must be non-empty"):
ReLocHint(
approximate_position_wgs84=LatLonAlt(lat_deg=49.0, lon_deg=36.0, alt_m=100.0),
confidence_radius_m=50.0,
reason="",
)
# ---------------------------------------------------------------------------
# AC-7: lat/lon out of range → ValueError at ReLocHint construction
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"lat, lon",
[
(91.0, 36.0),
(-91.0, 36.0),
(49.0, 181.0),
(49.0, -180.0), # lower bound is strict
],
)
def test_reloc_hint_rejects_out_of_range_lat_lon(lat: float, lon: float) -> None:
with pytest.raises(ValueError):
ReLocHint(
approximate_position_wgs84=LatLonAlt(lat_deg=lat, lon_deg=lon, alt_m=100.0),
confidence_radius_m=50.0,
reason="test",
)
# ---------------------------------------------------------------------------
# AC-8: FDR enqueue OVERRUN does NOT raise
# ---------------------------------------------------------------------------
def test_request_reloc_succeeds_when_fdr_buffer_overruns() -> None:
# Arrange
transport = _FakeTransport()
fdr_client = _make_fdr_client(force_overrun=True)
clock = _FakeClock()
service = _build_service(transport=transport, fdr_client=fdr_client, clock=clock)
hint = _make_hint()
enqueue_results: list[str] = []
original_enqueue = fdr_client.enqueue
def spy_enqueue(record):
result = original_enqueue(record)
enqueue_results.append(result)
return result
fdr_client.enqueue = spy_enqueue # type: ignore[method-assign]
# Act
service.request_reloc(hint)
# Assert — transport call unaffected; enqueue observed OVERRUN
assert len(transport.calls) == 1
assert enqueue_results == [EnqueueResult.OVERRUN]
# ---------------------------------------------------------------------------
# AC-9: Position logged at 5 decimals; transport sees full precision
# ---------------------------------------------------------------------------
def test_position_logged_at_5_decimals_transport_sees_full_precision(
caplog: pytest.LogCaptureFixture,
) -> None:
# Arrange
transport = _FakeTransport()
fdr_client = _make_fdr_client()
clock = _FakeClock()
service = _build_service(transport=transport, fdr_client=fdr_client, clock=clock)
hint = _make_hint(lat=49.99876543, lon=36.12345678, alt=42.0)
# Act
with caplog.at_level(logging.INFO, logger="c12.operator_reloc_service.test"):
service.request_reloc(hint)
# Assert — transport receives full precision
sent_hint = transport.calls[0]
assert sent_hint.approximate_position_wgs84.lat_deg == 49.99876543
assert sent_hint.approximate_position_wgs84.lon_deg == 36.12345678
# Assert — log is rounded to 5 decimals
sent_records = [r for r in caplog.records if r.kind == "c12.reloc.sent"]
assert sent_records[0].kv["position_lat"] == pytest.approx(49.99877)
assert sent_records[0].kv["position_lon"] == pytest.approx(36.12346)
# ---------------------------------------------------------------------------
# AC-10: factory does NOT construct the transport unless one is passed in
# ---------------------------------------------------------------------------
def test_build_operator_orchestrator_does_not_construct_operator_reloc_service_without_transport(
tmp_path,
) -> None:
# Arrange
from pathlib import Path
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
C12CompanionConfig,
C12Config,
HostKeyPolicy,
)
from gps_denied_onboard.config import Config
config = Config()
config.components["c12_operator_orchestrator"] = C12Config(
log_path=tmp_path / "c12.log",
sector_classification_store_path=tmp_path / "sectors.json",
companion=C12CompanionConfig(
ssh_user="op",
ssh_keyfile=Path("/tmp/fake-key"),
host_key_policy=HostKeyPolicy.STRICT,
),
)
from gps_denied_onboard.runtime_root.c12_factory import build_operator_orchestrator
# Act — no operator_command_transport supplied
services = build_operator_orchestrator(config)
# Assert
assert services.operator_reloc_service is None
@@ -12,7 +12,7 @@ from pathlib import Path
import paramiko
import pytest
from gps_denied_onboard.components.c12_operator_tooling import (
from gps_denied_onboard.components.c12_operator_orchestrator import (
HostKeyPolicy,
ParamikoSshSessionFactory,
)
@@ -0,0 +1,387 @@
"""AZ-329 PostLandingUploadOrchestrator unit tests.
Covers AC-1..AC-8 (the orchestrator-level ACs); AC-9/AC-10 live in
:mod:`test_post_landing_upload_integration` against real FDR fixtures.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from uuid import UUID, uuid4
import pytest
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
FlightFooterRecord,
IngestStatusCut,
PerTileStatusCut,
PostLandingUploadRequest,
UploadBatchReportCut,
UploadOutcomeCut,
UploadRequestCut,
)
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
C12PostLandingConfig,
)
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
FdrUnreadableError,
FlightStateNotConfirmedError,
)
from gps_denied_onboard.components.c12_operator_orchestrator.post_landing_upload import (
PostLandingUploadOrchestrator,
)
_API_KEY_LITERAL = "super-secret-token-123"
@dataclass
class _FakeFooterReader:
"""Configurable :class:`FdrFooterReader` for the orchestrator unit tests."""
footer: FlightFooterRecord | None = None
raises: FdrUnreadableError | None = None
calls: list[UUID] = field(default_factory=list)
def read_footer(self, flight_id: UUID) -> FlightFooterRecord | None:
self.calls.append(flight_id)
if self.raises is not None:
raise self.raises
return self.footer
@dataclass
class _FakeTileUploader:
"""Configurable :class:`TileUploaderCut` recording each request."""
report: UploadBatchReportCut | None = None
calls: list[UploadRequestCut] = field(default_factory=list)
def upload_pending_tiles(self, request: UploadRequestCut) -> UploadBatchReportCut:
self.calls.append(request)
assert self.report is not None, "test must wire .report before the call"
return self.report
def _make_request(flight_id: UUID | None = None) -> PostLandingUploadRequest:
return PostLandingUploadRequest(
flight_id=flight_id or uuid4(),
satellite_provider_url="https://parent.example/ingest",
api_key=_API_KEY_LITERAL,
batch_size=50,
)
def _make_footer(*, flight_id: UUID, clean_shutdown: bool) -> FlightFooterRecord:
return FlightFooterRecord(
flight_id=flight_id,
flight_ended_at_iso="2026-05-13T12:00:00+00:00",
records_written=12345,
records_dropped_overrun=0 if clean_shutdown else 42,
bytes_written=987654 if not clean_shutdown else 654321,
rollover_count=0,
clean_shutdown=clean_shutdown,
)
def _make_report() -> UploadBatchReportCut:
return UploadBatchReportCut(
batch_uuid=uuid4(),
per_tile_status=(
PerTileStatusCut(tile_id="tile-0", status=IngestStatusCut.ACCEPTED),
PerTileStatusCut(tile_id="tile-1", status=IngestStatusCut.ACCEPTED),
PerTileStatusCut(
tile_id="tile-2",
status=IngestStatusCut.REJECTED,
rejection_reason="invalid signature",
),
),
retry_count=0,
next_retry_at_s=None,
outcome=UploadOutcomeCut.PARTIAL,
public_key_fingerprint="ab" * 8,
)
def _build_orchestrator(
*,
tmp_path,
footer: FlightFooterRecord | None = None,
reader_raises: FdrUnreadableError | None = None,
report: UploadBatchReportCut | None = None,
create_flight_dir: bool = True,
flight_id: UUID | None = None,
) -> tuple[
PostLandingUploadOrchestrator,
_FakeFooterReader,
_FakeTileUploader,
UUID,
logging.Logger,
]:
fdr_root = tmp_path / "fdr"
fdr_root.mkdir()
actual_flight_id = flight_id or uuid4()
if create_flight_dir:
(fdr_root / str(actual_flight_id)).mkdir()
reader = _FakeFooterReader(footer=footer, raises=reader_raises)
uploader = _FakeTileUploader(report=report)
logger = logging.getLogger(f"test.c12.post_landing.{actual_flight_id}")
logger.setLevel(logging.DEBUG)
orchestrator = PostLandingUploadOrchestrator(
tile_uploader=uploader,
fdr_footer_reader=reader,
logger=logger,
config=C12PostLandingConfig(fdr_root=fdr_root),
)
return orchestrator, reader, uploader, actual_flight_id, logger
# ----------------------------------------------------------------------
# AC-1: clean-shutdown footer → upload invoked
# ----------------------------------------------------------------------
def test_ac1_clean_shutdown_footer_triggers_upload(tmp_path, caplog) -> None:
# Arrange
flight_id = uuid4()
report = _make_report()
orchestrator, reader, uploader, _flight_id, _logger = _build_orchestrator(
tmp_path=tmp_path,
footer=_make_footer(flight_id=flight_id, clean_shutdown=True),
report=report,
flight_id=flight_id,
)
request = _make_request(flight_id=flight_id)
# Act
with caplog.at_level(logging.DEBUG):
returned = orchestrator.trigger_post_landing_upload(request)
# Assert
assert returned is report
assert reader.calls == [flight_id]
assert len(uploader.calls) == 1
inner = uploader.calls[0]
assert inner.flight_id == flight_id
assert inner.satellite_provider_url == request.satellite_provider_url
assert inner.batch_size == request.batch_size
confirmed = [
r for r in caplog.records if getattr(r, "kind", None) == "c12.upload.confirmed_clean_shutdown"
]
complete = [
r for r in caplog.records if getattr(r, "kind", None) == "c12.upload.complete"
]
assert len(confirmed) == 1
assert len(complete) == 1
# ----------------------------------------------------------------------
# AC-2: footer absent → footer_missing
# ----------------------------------------------------------------------
def test_ac2_footer_absent_refuses_with_footer_missing(tmp_path, caplog) -> None:
# Arrange
orchestrator, _reader, uploader, flight_id, _logger = _build_orchestrator(
tmp_path=tmp_path, footer=None
)
request = _make_request(flight_id=flight_id)
# Act / Assert
with caplog.at_level(logging.DEBUG):
with pytest.raises(FlightStateNotConfirmedError) as exc_info:
orchestrator.trigger_post_landing_upload(request)
assert exc_info.value.not_confirmed_reason == "footer_missing"
assert exc_info.value.flight_id == str(flight_id)
assert exc_info.value.detail == ""
assert "No flight_footer record" in exc_info.value.remediation
assert uploader.calls == []
refusal_logs = [
r for r in caplog.records if getattr(r, "kind", None) == "c12.upload.refused.footer_missing"
]
assert len(refusal_logs) == 1
# ----------------------------------------------------------------------
# AC-3: footer with clean_shutdown=False → unclean_shutdown
# ----------------------------------------------------------------------
def test_ac3_unclean_shutdown_refuses_with_counters_in_detail(tmp_path, caplog) -> None:
# Arrange
flight_id = uuid4()
footer = _make_footer(flight_id=flight_id, clean_shutdown=False)
orchestrator, _reader, uploader, _flight_id, _logger = _build_orchestrator(
tmp_path=tmp_path,
footer=footer,
flight_id=flight_id,
)
request = _make_request(flight_id=flight_id)
# Act / Assert
with caplog.at_level(logging.DEBUG):
with pytest.raises(FlightStateNotConfirmedError) as exc_info:
orchestrator.trigger_post_landing_upload(request)
err = exc_info.value
assert err.not_confirmed_reason == "unclean_shutdown"
assert f"records_dropped_overrun={footer.records_dropped_overrun}" in err.detail
assert f"bytes_written={footer.bytes_written}" in err.detail
assert uploader.calls == []
refusal_logs = [
r for r in caplog.records if getattr(r, "kind", None) == "c12.upload.refused.unclean_shutdown"
]
assert len(refusal_logs) == 1
refusal = refusal_logs[0]
kv = getattr(refusal, "kv", {})
assert kv["records_written"] == footer.records_written
assert kv["records_dropped_overrun"] == footer.records_dropped_overrun
assert kv["bytes_written"] == footer.bytes_written
assert kv["rollover_count"] == footer.rollover_count
# ----------------------------------------------------------------------
# AC-4: <fdr_root>/<flight_id>/ does not exist → flight_id_not_found
# ----------------------------------------------------------------------
def test_ac4_flight_dir_missing_refuses_with_flight_id_not_found(tmp_path, caplog) -> None:
# Arrange
orchestrator, reader, uploader, flight_id, _logger = _build_orchestrator(
tmp_path=tmp_path,
create_flight_dir=False,
)
request = _make_request(flight_id=flight_id)
# Act / Assert
with caplog.at_level(logging.DEBUG):
with pytest.raises(FlightStateNotConfirmedError) as exc_info:
orchestrator.trigger_post_landing_upload(request)
assert exc_info.value.not_confirmed_reason == "flight_id_not_found"
assert reader.calls == []
assert uploader.calls == []
refusal_logs = [
r for r in caplog.records if getattr(r, "kind", None) == "c12.upload.refused.flight_id_not_found"
]
assert len(refusal_logs) == 1
# ----------------------------------------------------------------------
# AC-5: reader raises FdrUnreadableError → fdr_unreadable
# ----------------------------------------------------------------------
def test_ac5_fdr_unreadable_refuses_with_inner_repr(tmp_path, caplog) -> None:
# Arrange
inner_exc = FdrUnreadableError("OSError('input/output error') at segment-0001.fdr")
orchestrator, _reader, uploader, flight_id, _logger = _build_orchestrator(
tmp_path=tmp_path,
reader_raises=inner_exc,
)
request = _make_request(flight_id=flight_id)
# Act / Assert
with caplog.at_level(logging.DEBUG):
with pytest.raises(FlightStateNotConfirmedError) as exc_info:
orchestrator.trigger_post_landing_upload(request)
err = exc_info.value
assert err.not_confirmed_reason == "fdr_unreadable"
assert "OSError" in err.detail
assert err.__cause__ is inner_exc
assert uploader.calls == []
refusal_logs = [
r for r in caplog.records if getattr(r, "kind", None) == "c12.upload.refused.fdr_unreadable"
]
assert len(refusal_logs) == 1
kv = getattr(refusal_logs[0], "kv", {})
assert "OSError" in kv["fdr_unreadable_repr"]
# ----------------------------------------------------------------------
# AC-7: passthrough — returns the exact UploadBatchReportCut instance
# ----------------------------------------------------------------------
def test_ac7_upload_batch_report_is_passed_through_unchanged(tmp_path) -> None:
# Arrange
flight_id = uuid4()
report = _make_report()
orchestrator, _reader, _uploader, _flight_id, _logger = _build_orchestrator(
tmp_path=tmp_path,
footer=_make_footer(flight_id=flight_id, clean_shutdown=True),
report=report,
flight_id=flight_id,
)
# Act
returned = orchestrator.trigger_post_landing_upload(_make_request(flight_id=flight_id))
# Assert
assert returned is report
assert returned.batch_uuid == report.batch_uuid
assert returned.per_tile_status == report.per_tile_status
assert returned.outcome is report.outcome
assert returned.public_key_fingerprint == report.public_key_fingerprint
# ----------------------------------------------------------------------
# AC-8: api_key never appears in any log record across every code path
# ----------------------------------------------------------------------
@pytest.mark.parametrize(
"scenario",
["success", "footer_missing", "unclean_shutdown", "flight_id_not_found", "fdr_unreadable"],
)
def test_ac8_api_key_never_appears_in_logs(tmp_path, caplog, scenario) -> None:
# Arrange
flight_id = uuid4()
if scenario == "success":
footer = _make_footer(flight_id=flight_id, clean_shutdown=True)
orchestrator, _r, _u, _fid, _l = _build_orchestrator(
tmp_path=tmp_path, footer=footer, report=_make_report(), flight_id=flight_id
)
elif scenario == "footer_missing":
orchestrator, _r, _u, _fid, _l = _build_orchestrator(
tmp_path=tmp_path, footer=None, flight_id=flight_id
)
elif scenario == "unclean_shutdown":
footer = _make_footer(flight_id=flight_id, clean_shutdown=False)
orchestrator, _r, _u, _fid, _l = _build_orchestrator(
tmp_path=tmp_path, footer=footer, flight_id=flight_id
)
elif scenario == "flight_id_not_found":
orchestrator, _r, _u, _fid, _l = _build_orchestrator(
tmp_path=tmp_path, create_flight_dir=False, flight_id=flight_id
)
else: # fdr_unreadable
orchestrator, _r, _u, _fid, _l = _build_orchestrator(
tmp_path=tmp_path,
reader_raises=FdrUnreadableError("parse failure with token glimpse"),
flight_id=flight_id,
)
request = _make_request(flight_id=flight_id)
# Act
with caplog.at_level(logging.DEBUG):
try:
orchestrator.trigger_post_landing_upload(request)
except FlightStateNotConfirmedError:
pass
# Assert — the literal api_key value MUST NOT appear in any log record
for record in caplog.records:
for field_name in ("msg", "message"):
value = getattr(record, field_name, None)
assert value is None or _API_KEY_LITERAL not in str(value), (
f"api_key leaked into record.{field_name}: {value!r}"
)
kv = getattr(record, "kv", None)
if kv is not None:
for k, v in kv.items():
assert _API_KEY_LITERAL not in str(v), (
f"api_key leaked into record.kv[{k!r}]: {v!r}"
)
@@ -11,17 +11,17 @@ from uuid import UUID
import pytest
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
from gps_denied_onboard.components.c12_operator_tooling import (
from gps_denied_onboard.components.c12_operator_orchestrator import (
BuildReportParseError,
RemoteBuildOutcome,
RemoteCacheProvisionerInvoker,
SectorClassification,
)
from gps_denied_onboard.components.c12_operator_tooling.remote_c10_invoker import (
from gps_denied_onboard.components.c12_operator_orchestrator.remote_c10_invoker import (
REDACTED_PLACEHOLDER,
RemoteBuildRequest,
)
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
from gps_denied_onboard.components.c12_operator_orchestrator.ssh_session import (
RemoteCommandResult,
SshSession,
)
@@ -9,7 +9,7 @@ from pathlib import Path
import pytest
from gps_denied_onboard.components.c12_operator_tooling import (
from gps_denied_onboard.components.c12_operator_orchestrator import (
SectorClassification,
SectorClassificationStore,
)
@@ -88,7 +88,7 @@ class TestAtomicWriteUnderCrash:
raise OSError("simulated kill mid-replace")
monkeypatch.setattr(
"gps_denied_onboard.components.c12_operator_tooling."
"gps_denied_onboard.components.c12_operator_orchestrator."
"sector_classification_store.os.replace",
_raise_replace,
)
@@ -3,7 +3,7 @@
def test_interface_importable() -> None:
# Assert
from gps_denied_onboard.components.c12_operator_tooling import (
from gps_denied_onboard.components.c12_operator_orchestrator import (
CacheBuildWorkflow,
OperatorReLocService,
)
+2 -2
View File
@@ -33,7 +33,7 @@ REQUIRED_PATHS: tuple[str, ...] = (
".gitignore",
"README.md",
"docker/companion-tier1.Dockerfile",
"docker/operator-tooling.Dockerfile",
"docker/operator-orchestrator.Dockerfile",
"docker/mock-suite-sat-service.Dockerfile",
"docker-compose.yml",
"docker-compose.test.yml",
@@ -71,7 +71,7 @@ COMPONENT_DIRS: tuple[str, ...] = (
"c8_fc_adapter",
"c10_provisioning",
"c11_tile_manager",
"c12_operator_tooling",
"c12_operator_orchestrator",
"c13_fdr",
)
+1 -1
View File
@@ -35,7 +35,7 @@ def test_compose_yml_declares_required_services() -> None:
data = yaml.safe_load((REPO_ROOT / "docker-compose.yml").read_text())
services = data["services"]
# Assert
for required in ("companion", "operator-tooling", "mock-sat", "db"):
for required in ("companion", "operator-orchestrator", "mock-sat", "db"):
assert required in services, f"docker-compose.yml missing service: {required}"
@@ -255,6 +255,19 @@ def _kind_payload(kind: str) -> dict[str, object]:
"attempts": 5,
"last_rejection_reason": "invalid signature",
}
if kind == "c12.reloc.requested":
return {
"hint": {
"lat_deg": 49.99876543,
"lon_deg": 36.12345678,
"alt_m": 1234.5,
"confidence_radius_m": 50.0,
"reason": "lost track at WP3",
},
"outcome": "sent",
"failure_reason": None,
"ts_monotonic_ns": 1_234_567_890_123,
}
raise AssertionError(f"unhandled kind in fixture: {kind!r}")