feat: stage10 — Full processing cycle with State Machine

This commit is contained in:
Yuzviak
2026-03-22 23:14:33 +02:00
parent 74aa6454b8
commit c86cdc2e82
4 changed files with 342 additions and 15 deletions
+126
View File
@@ -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