mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-04-22 11:16:37 +00:00
feat: stage10 — Full processing cycle with State Machine
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""Tests for FlightProcessor full processing pipeline (Stage 10)."""
|
||||
|
||||
import pytest
|
||||
import numpy as np
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
from gps_denied.core.processor import FlightProcessor, TrackingState
|
||||
from gps_denied.core.models import ModelManager
|
||||
from gps_denied.core.vo import SequentialVisualOdometry
|
||||
from gps_denied.core.gpr import GlobalPlaceRecognition
|
||||
from gps_denied.core.metric import MetricRefinement
|
||||
from gps_denied.core.graph import FactorGraphOptimizer
|
||||
from gps_denied.core.chunk_manager import RouteChunkManager
|
||||
from gps_denied.core.recovery import FailureRecoveryCoordinator
|
||||
from gps_denied.schemas.graph import FactorGraphConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def processor():
|
||||
repo = MagicMock()
|
||||
streamer = MagicMock()
|
||||
streamer.push_event = AsyncMock()
|
||||
|
||||
proc = FlightProcessor(repo, streamer)
|
||||
|
||||
mm = ModelManager()
|
||||
vo = SequentialVisualOdometry(mm)
|
||||
gpr = GlobalPlaceRecognition(mm)
|
||||
gpr.load_index("test", "dummy")
|
||||
metric = MetricRefinement(mm)
|
||||
graph = FactorGraphOptimizer(FactorGraphConfig())
|
||||
chunk_mgr = RouteChunkManager(graph)
|
||||
recovery = FailureRecoveryCoordinator(chunk_mgr, gpr, metric)
|
||||
|
||||
proc.attach_components(
|
||||
vo=vo, gpr=gpr, metric=metric,
|
||||
graph=graph, recovery=recovery, chunk_mgr=chunk_mgr
|
||||
)
|
||||
return proc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_first_frame(processor):
|
||||
"""First frame has no previous image → VO skips, should still publish."""
|
||||
img = np.random.randint(0, 255, (200, 200, 3), dtype=np.uint8)
|
||||
result = await processor.process_frame("fl1", 0, img)
|
||||
|
||||
assert result.frame_id == 0
|
||||
assert result.tracking_state == TrackingState.NORMAL
|
||||
# First frame — no previous so VO is skipped
|
||||
assert result.vo_success is False
|
||||
# SSE event published
|
||||
processor.streamer.push_event.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_consecutive_frames_normal(processor):
|
||||
"""Two consecutive frames → VO attempts (mock may or may not succeed)."""
|
||||
flight = "fl2"
|
||||
img1 = np.random.randint(0, 255, (200, 200, 3), dtype=np.uint8)
|
||||
img2 = np.random.randint(0, 255, (200, 200, 3), dtype=np.uint8)
|
||||
|
||||
r1 = await processor.process_frame(flight, 0, img1)
|
||||
r2 = await processor.process_frame(flight, 1, img2)
|
||||
|
||||
assert r1.frame_id == 0
|
||||
assert r2.frame_id == 1
|
||||
# At minimum, both published SSE
|
||||
assert processor.streamer.push_event.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracking_loss_triggers_recovery(processor, monkeypatch):
|
||||
"""When VO returns tracking_good=False, state transitions to RECOVERY."""
|
||||
flight = "fl3"
|
||||
img0 = np.random.randint(0, 255, (200, 200, 3), dtype=np.uint8)
|
||||
img1 = np.random.randint(0, 255, (200, 200, 3), dtype=np.uint8)
|
||||
|
||||
# Process frame 0 — will be NORMAL
|
||||
await processor.process_frame(flight, 0, img0)
|
||||
|
||||
# Force VO to fail by monkeypatching
|
||||
def bad_vo(*args, **kwargs):
|
||||
from gps_denied.schemas.vo import RelativePose
|
||||
return RelativePose(
|
||||
translation=np.zeros(3), rotation=np.eye(3), covariance=np.eye(6),
|
||||
confidence=0.0, inlier_count=0, total_matches=0, tracking_good=False
|
||||
)
|
||||
|
||||
monkeypatch.setattr(processor._vo, "compute_relative_pose", bad_vo)
|
||||
|
||||
# Also force recovery NOT to succeed so state stays RECOVERY
|
||||
monkeypatch.setattr(processor._recovery, "process_chunk_recovery", lambda *a, **k: False)
|
||||
|
||||
r1 = await processor.process_frame(flight, 1, img1)
|
||||
|
||||
# State should be RECOVERY (LOST transitioning immediately)
|
||||
assert r1.tracking_state == TrackingState.RECOVERY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_machine_full_cycle(processor, monkeypatch):
|
||||
"""Full cycle: NORMAL → LOST → RECOVERY → NORMAL."""
|
||||
flight = "fl_cycle"
|
||||
imgs = [np.random.randint(0, 255, (200, 200, 3), dtype=np.uint8) for _ in range(3)]
|
||||
|
||||
# Frame 0: NORMAL
|
||||
r0 = await processor.process_frame(flight, 0, imgs[0])
|
||||
assert r0.tracking_state == TrackingState.NORMAL
|
||||
|
||||
# Frame 1: Force VO fail → RECOVERY
|
||||
def bad_vo(*a, **k):
|
||||
from gps_denied.schemas.vo import RelativePose
|
||||
return RelativePose(
|
||||
translation=np.zeros(3), rotation=np.eye(3), covariance=np.eye(6),
|
||||
confidence=0, inlier_count=0, total_matches=0, tracking_good=False
|
||||
)
|
||||
monkeypatch.setattr(processor._vo, "compute_relative_pose", bad_vo)
|
||||
|
||||
# Also force recovery to succeed
|
||||
monkeypatch.setattr(processor._recovery, "process_chunk_recovery", lambda *a, **k: True)
|
||||
|
||||
r1 = await processor.process_frame(flight, 1, imgs[1])
|
||||
# Should have recovered back to NORMAL
|
||||
assert r1.tracking_state == TrackingState.NORMAL
|
||||
assert r1.alignment_success is True
|
||||
Reference in New Issue
Block a user