"""AZ-292 — FlightHeader / FlightFooter + per-flight counters. Covers AC-1..AC-8. """ from __future__ import annotations import dataclasses import struct import time from datetime import datetime, timezone from pathlib import Path from uuid import UUID, uuid4 import pytest from gps_denied_onboard.components.c13_fdr import ( FdrCloseWithoutOpenError, FdrOpenError, FileFdrWriter, FlightFooter, FlightHeader, ) from gps_denied_onboard.config import FdrWriterConfig from gps_denied_onboard.fdr_client.client import FdrClient from gps_denied_onboard.fdr_client.records import ( OVERRUN_KIND, OVERRUN_PRODUCER_ID, FdrRecord, parse, ) _LENGTH_PREFIX = struct.Struct(" FlightHeader: return FlightHeader( flight_id=flight_id, flight_started_at_iso=datetime.now(tz=timezone.utc).isoformat(), flight_started_at_monotonic_ns=time.monotonic_ns(), config_snapshot={"tier": 2, "fdr": {"queue_size": 4096}}, signing_key_rotation_event={"current_key_id": "k1"}, manifest_content_hashes={"foo/bar.bin": "deadbeef" * 8}, build_info={"commit": "abc1234", "build_date": "2026-05-11"}, ) def _make_client(producer_id: str = "c1_vio") -> FdrClient: return FdrClient(producer_id=producer_id, capacity=256, _emit_diag_log=False) def _payload(i: int) -> FdrRecord: return FdrRecord( schema_version=1, ts=datetime.now(tz=timezone.utc).isoformat(), producer_id="c1_vio", kind="vio.tick", payload={ "frame_id": i, "R": [[1, 0, 0], [0, 1, 0], [0, 0, 1]], "t": [0, 0, 0], "P": [], "last_anchor_age_ms": 0, }, ) def _read_records(flight_dir: Path) -> list[FdrRecord]: records: list[FdrRecord] = [] for seg in sorted(flight_dir.glob("segment-*.fdr")): data = seg.read_bytes() offset = 0 while offset < len(data): (length,) = _LENGTH_PREFIX.unpack_from(data, offset) offset += _LENGTH_PREFIX.size records.append(parse(data[offset : offset + length])) offset += length return records def _build_writer( tmp_path: Path, flight_id: UUID, config: FdrWriterConfig | None = None ) -> tuple[FileFdrWriter, FdrClient, list[str]]: config = config or FdrWriterConfig() alerts: list[str] = [] client = _make_client() writer = FileFdrWriter( flight_root=tmp_path / "fdr", flight_id=flight_id, config=config, fdr_clients=[client], gcs_alert=alerts.append, ) return writer, client, alerts def test_ac1_flight_header_is_first_record(tmp_path: Path) -> None: # Arrange flight_id = uuid4() writer, _client, _alerts = _build_writer(tmp_path, flight_id) header = _make_header(flight_id) writer.start() # Act writer.open_flight(header) footer = writer.close_flight() # Assert records = _read_records(writer.flight_dir) assert records[0].kind == "flight_header" assert records[0].payload["flight_id"] == str(flight_id) assert footer.flight_id == flight_id def test_ac2_flight_footer_is_last_record(tmp_path: Path) -> None: # Arrange flight_id = uuid4() writer, client, _alerts = _build_writer(tmp_path, flight_id) writer.start() writer.open_flight(_make_header(flight_id)) for i in range(20): client.enqueue(_payload(i)) # Act deadline = time.monotonic() + 3.0 while time.monotonic() < deadline and client._buffer_size() > 0: time.sleep(0.01) footer = writer.close_flight() # Assert records = _read_records(writer.flight_dir) assert records[-1].kind == "flight_footer" assert records[-1].payload["clean_shutdown"] is True # Returned footer matches the on-disk payload (modulo flight_id stringification). on_disk = records[-1].payload assert on_disk["records_written"] == footer.records_written assert on_disk["records_dropped_overrun"] == footer.records_dropped_overrun assert on_disk["bytes_written"] == footer.bytes_written assert on_disk["rollover_count"] == footer.rollover_count def test_ac3_counters_reflect_on_disk_reality(tmp_path: Path) -> None: # Arrange flight_id = uuid4() config = FdrWriterConfig(segment_size_bytes=2048, batch_size=8, flight_cap_bytes=1024**3) writer, client, _alerts = _build_writer(tmp_path, flight_id, config) writer.start() writer.open_flight(_make_header(flight_id)) R = 30 for i in range(R): client.enqueue(_payload(i)) # Act deadline = time.monotonic() + 5.0 while time.monotonic() < deadline and client._buffer_size() > 0: time.sleep(0.01) footer = writer.close_flight() # Assert expected_written = R + 2 assert footer.records_written == expected_written assert footer.records_dropped_overrun == 0 # rollover_count: per-segment rotations (counter is monotonic; the test # writer's small cap forces ≥1 rotation). assert footer.rollover_count >= 1 # Cross-check: footer.rollover_count == observed segment_index at close. assert footer.rollover_count == writer.rollover_count def test_ac4_open_flight_fdrerror_on_disk_failure(tmp_path: Path) -> None: # Arrange — start the writer normally, then point segment 0 to a # read-only directory. flight_id = uuid4() config = FdrWriterConfig() alerts: list[str] = [] client = _make_client() ro_root = tmp_path / "ro" ro_root.mkdir(parents=True) # Create a stand-in flight_dir read-only file pretending to be the segment. ro_flight = ro_root / str(flight_id) ro_flight.mkdir() seg = ro_flight / "segment-0000.fdr" seg.write_bytes(b"") seg.chmod(0o400) writer = FileFdrWriter( flight_root=ro_root, flight_id=flight_id, config=config, fdr_clients=[client], gcs_alert=alerts.append, ) # Block the path from being opened for write — chmod the parent dir. ro_flight.chmod(0o500) try: writer.start() except Exception: # If start fails outright (cannot create flight_dir), that satisfies the contract. ro_flight.chmod(0o700) return # If start did succeed (writer created the file before chmod took effect), # explicitly fail open_flight by writing the header via a forced OSError. seg.chmod(0o400) with pytest.raises(FdrOpenError): from unittest import mock with mock.patch( "gps_denied_onboard.components.c13_fdr.writer.os.write", side_effect=PermissionError("read-only"), ): writer.open_flight(_make_header(flight_id)) # Cleanup ro_flight.chmod(0o700) def test_ac5_open_flight_rejects_flight_id_mismatch(tmp_path: Path) -> None: # Arrange writer_flight_id = uuid4() other_flight_id = uuid4() writer, _client, _alerts = _build_writer(tmp_path, writer_flight_id) writer.start() bad_header = _make_header(other_flight_id) # Act, Assert with pytest.raises(FdrOpenError, match="does not match"): writer.open_flight(bad_header) def test_ac6_close_without_open_raises(tmp_path: Path) -> None: # Arrange flight_id = uuid4() writer, _client, _alerts = _build_writer(tmp_path, flight_id) writer.start() # Act, Assert with pytest.raises(FdrCloseWithoutOpenError): writer.close_flight() writer.stop() def test_ac7_uncleansed_teardown_no_clean_shutdown(tmp_path: Path) -> None: # Arrange flight_id = uuid4() writer, _client, _alerts = _build_writer(tmp_path, flight_id) writer.start() writer.open_flight(_make_header(flight_id)) # Act — stop without close_flight. writer.stop() # Assert — no flight_footer record exists, so post-flight tooling marks the flight truncated. records = _read_records(writer.flight_dir) has_footer = any(r.kind == "flight_footer" for r in records) assert not has_footer def test_ac8_records_dropped_overrun_aggregates_dropped_counts(tmp_path: Path) -> None: # Arrange flight_id = uuid4() writer, client, _alerts = _build_writer(tmp_path, flight_id) writer.start() writer.open_flight(_make_header(flight_id)) drops = [3, 7, 2, 11, 4] for d in drops: client.enqueue( FdrRecord( schema_version=1, ts=datetime.now(tz=timezone.utc).isoformat(), producer_id=OVERRUN_PRODUCER_ID, kind=OVERRUN_KIND, payload={"producer_id": "c1_vio", "dropped_count": d}, ) ) # Act deadline = time.monotonic() + 3.0 while time.monotonic() < deadline and client._buffer_size() > 0: time.sleep(0.01) footer = writer.close_flight() # Assert assert footer.records_dropped_overrun == sum(drops) def test_close_flight_idempotent(tmp_path: Path) -> None: # Arrange flight_id = uuid4() writer, _client, _alerts = _build_writer(tmp_path, flight_id) writer.start() writer.open_flight(_make_header(flight_id)) # Act footer_a = writer.close_flight() footer_b = writer.close_flight() # Assert assert footer_a == footer_b def test_flight_header_and_footer_are_frozen() -> None: # Arrange header = _make_header(uuid4()) footer = FlightFooter( flight_id=uuid4(), flight_ended_at_iso="2026-05-11T00: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=True, ) # Assert with pytest.raises(dataclasses.FrozenInstanceError): header.flight_id = uuid4() # type: ignore[misc] with pytest.raises(dataclasses.FrozenInstanceError): footer.records_written = 999 # type: ignore[misc]