mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 17:11:14 +00:00
[AZ-598] Batch 78: sitl_observer.wait_for_outbound + FT-P-01 fixture builder
Phase 1: extend sitl_observer with cursor-based `wait_for_outbound` returning `OutboundMessage` from `outbound_messages_<fc_kind>_<host>.json` fixtures. Three outcomes: message, TimeoutError (null entries), or RuntimeError (missing/malformed). Fix FT-P-01 + FT-P-05 scenarios to use `fc_kind=` kwarg. Phase 2: FT-P-01 vertical-slice fixture builder under `e2e/fixtures/sitl_replay_builder/`. Reuses the production `gps-denied-replay` CLI + `ReplayInputAdapter`: encode 60 stills as 1 fps MP4 + synthetic stationary tlog (pymavlink); run replay; project FDR outbound estimates into the schema. Avoids the 13+ cp of SUT-side frame-ingestion that a live-SITL-capture path would have required. Live execution remains a manual operator step. +35 unit tests (664 total, up from 637). K=3 cumulative review for b76-b78 documents the offline-replay arc convergence. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -211,6 +211,204 @@ def test_get_observer_missing_gps_state_raises(replay_dir: Path):
|
||||
obs.read_gps_state()
|
||||
|
||||
|
||||
# wait_for_outbound (AZ-598)
|
||||
|
||||
|
||||
def _write_observer_fixture(replay_dir: Path, fc_kind: str, host: str) -> None:
|
||||
"""Write the minimal `observer_<kind>_<host>.json` so `get_observer` succeeds."""
|
||||
_write_json(
|
||||
replay_dir / f"observer_{fc_kind}_{host}.json",
|
||||
{
|
||||
"gps_state": {
|
||||
"primary_source": "MAV",
|
||||
"last_position_lat_deg": 0.0,
|
||||
"last_position_lon_deg": 0.0,
|
||||
"last_position_alt_m": 0.0,
|
||||
"fix_quality": 3,
|
||||
"horizontal_accuracy_m": 1.0,
|
||||
"last_update_age_ms": 0,
|
||||
},
|
||||
"parameters": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_wait_for_outbound_advances_cursor_in_order(replay_dir: Path):
|
||||
# Arrange
|
||||
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||
_write_json(
|
||||
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||
{
|
||||
"messages": [
|
||||
{"image_id": "AD000001.jpg", "lat_deg": 48.275292, "lon_deg": 37.385220},
|
||||
{"image_id": "AD000002.jpg", "lat_deg": 48.275001, "lon_deg": 37.382922},
|
||||
]
|
||||
},
|
||||
)
|
||||
obs = so.get_observer("ardupilot", "sitl-host")
|
||||
|
||||
# Act
|
||||
first = obs.wait_for_outbound(timeout_s=5.0)
|
||||
second = obs.wait_for_outbound(timeout_s=5.0)
|
||||
|
||||
# Assert
|
||||
assert first.lat_deg == 48.275292 and first.lon_deg == 37.385220
|
||||
assert first.image_id == "AD000001.jpg"
|
||||
assert second.lat_deg == 48.275001 and second.lon_deg == 37.382922
|
||||
assert second.image_id == "AD000002.jpg"
|
||||
|
||||
|
||||
def test_wait_for_outbound_null_entry_raises_timeout(replay_dir: Path):
|
||||
# Arrange
|
||||
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||
_write_json(
|
||||
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||
{"messages": [None]},
|
||||
)
|
||||
obs = so.get_observer("ardupilot", "sitl-host")
|
||||
|
||||
# Assert
|
||||
with pytest.raises(TimeoutError, match="captured as timeout in fixture"):
|
||||
obs.wait_for_outbound(timeout_s=5.0)
|
||||
|
||||
|
||||
def test_wait_for_outbound_advances_cursor_past_timeout(replay_dir: Path):
|
||||
# Arrange — a real timeout in the middle of the sequence does not stall
|
||||
# the cursor; the next call advances normally.
|
||||
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||
_write_json(
|
||||
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||
{
|
||||
"messages": [
|
||||
{"lat_deg": 1.0, "lon_deg": 2.0},
|
||||
None,
|
||||
{"lat_deg": 3.0, "lon_deg": 4.0},
|
||||
]
|
||||
},
|
||||
)
|
||||
obs = so.get_observer("ardupilot", "sitl-host")
|
||||
|
||||
# Act / Assert
|
||||
assert obs.wait_for_outbound().lat_deg == 1.0
|
||||
with pytest.raises(TimeoutError):
|
||||
obs.wait_for_outbound()
|
||||
third = obs.wait_for_outbound()
|
||||
assert third.lat_deg == 3.0 and third.lon_deg == 4.0
|
||||
|
||||
|
||||
def test_wait_for_outbound_exhausted_raises_runtime(replay_dir: Path):
|
||||
# Arrange
|
||||
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||
_write_json(
|
||||
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||
{"messages": [{"lat_deg": 1.0, "lon_deg": 2.0}]},
|
||||
)
|
||||
obs = so.get_observer("ardupilot", "sitl-host")
|
||||
obs.wait_for_outbound() # drain the only entry
|
||||
|
||||
# Assert
|
||||
with pytest.raises(RuntimeError, match="outbound messages fixture exhausted"):
|
||||
obs.wait_for_outbound()
|
||||
|
||||
|
||||
def test_wait_for_outbound_missing_fixture_raises_runtime(replay_dir: Path):
|
||||
# Arrange — observer fixture present, outbound fixture missing.
|
||||
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||
obs = so.get_observer("ardupilot", "sitl-host")
|
||||
|
||||
# Assert
|
||||
with pytest.raises(RuntimeError, match="outbound_messages_ardupilot_sitl-host.json"):
|
||||
obs.wait_for_outbound()
|
||||
|
||||
|
||||
def test_wait_for_outbound_missing_env_raises_runtime(unset_replay_dir):
|
||||
# Arrange — observer dataclass constructed manually so we don't depend on env var
|
||||
# for the observer-fixture load. Verifies the outbound load itself respects the env.
|
||||
obs = so._FdrReplayObserver(fc_kind="ardupilot", host="sitl-host", _payload={})
|
||||
|
||||
# Assert
|
||||
with pytest.raises(RuntimeError, match="env var not set"):
|
||||
obs.wait_for_outbound()
|
||||
|
||||
|
||||
def test_wait_for_outbound_messages_not_list_raises_runtime(replay_dir: Path):
|
||||
# Arrange
|
||||
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||
_write_json(
|
||||
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||
{"messages": {"oops": "should be list"}},
|
||||
)
|
||||
obs = so.get_observer("ardupilot", "sitl-host")
|
||||
|
||||
# Assert
|
||||
with pytest.raises(RuntimeError, match="`messages` must be a JSON list"):
|
||||
obs.wait_for_outbound()
|
||||
|
||||
|
||||
def test_wait_for_outbound_entry_wrong_type_raises_runtime(replay_dir: Path):
|
||||
# Arrange
|
||||
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||
_write_json(
|
||||
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||
{"messages": ["not-an-object"]},
|
||||
)
|
||||
obs = so.get_observer("ardupilot", "sitl-host")
|
||||
|
||||
# Assert
|
||||
with pytest.raises(RuntimeError, match=r"messages\[0\] must be a JSON object or null"):
|
||||
obs.wait_for_outbound()
|
||||
|
||||
|
||||
def test_wait_for_outbound_entry_missing_coords_raises_runtime(replay_dir: Path):
|
||||
# Arrange
|
||||
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||
_write_json(
|
||||
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||
{"messages": [{"image_id": "AD000001.jpg"}]},
|
||||
)
|
||||
obs = so.get_observer("ardupilot", "sitl-host")
|
||||
|
||||
# Assert
|
||||
with pytest.raises(RuntimeError, match="missing required `lat_deg`/`lon_deg`"):
|
||||
obs.wait_for_outbound()
|
||||
|
||||
|
||||
def test_wait_for_outbound_image_id_optional(replay_dir: Path):
|
||||
# Arrange — entries without `image_id` are valid; consumer only needs coords.
|
||||
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||
_write_json(
|
||||
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||
{"messages": [{"lat_deg": 10.0, "lon_deg": 20.0}]},
|
||||
)
|
||||
obs = so.get_observer("ardupilot", "sitl-host")
|
||||
|
||||
# Act
|
||||
msg = obs.wait_for_outbound()
|
||||
|
||||
# Assert
|
||||
assert msg.lat_deg == 10.0 and msg.lon_deg == 20.0
|
||||
assert msg.image_id is None
|
||||
|
||||
|
||||
def test_wait_for_outbound_separate_observers_have_independent_cursors(replay_dir: Path):
|
||||
# Arrange — two observers built from the same fixture file must NOT share cursor.
|
||||
_write_observer_fixture(replay_dir, "ardupilot", "sitl-host")
|
||||
_write_json(
|
||||
replay_dir / "outbound_messages_ardupilot_sitl-host.json",
|
||||
{"messages": [{"lat_deg": 1.0, "lon_deg": 2.0}, {"lat_deg": 3.0, "lon_deg": 4.0}]},
|
||||
)
|
||||
|
||||
# Act
|
||||
obs_a = so.get_observer("ardupilot", "sitl-host")
|
||||
obs_b = so.get_observer("ardupilot", "sitl-host")
|
||||
a_first = obs_a.wait_for_outbound()
|
||||
b_first = obs_b.wait_for_outbound()
|
||||
|
||||
# Assert
|
||||
assert a_first.lat_deg == 1.0
|
||||
assert b_first.lat_deg == 1.0
|
||||
|
||||
|
||||
# prepare_sitl_*
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user