"""Happy-path and bounds-validation tests for calibration API routes. Runs with the full app test-client stack but mocks the ProcessorManager so no real LED devices are required. Note: Deep adversarial coverage is deferred to the Phase 4 test-writer (Big Bang strategy). """ from __future__ import annotations import pytest import pytest_asyncio from unittest.mock import AsyncMock, MagicMock from fastapi import FastAPI from httpx import AsyncClient, ASGITransport from ledgrab.api.routes.calibration import router from ledgrab.core.capture.calibration_session import get_calibration_session # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def mock_manager() -> MagicMock: """A minimal fake ProcessorManager.""" mgr = MagicMock() # Simulate a registered device with 100 LEDs ds = MagicMock() ds.led_count = 100 mgr._devices = {"dev1": ds} mgr.get_processing_target_for_device = MagicMock(return_value=None) mgr.stop_processing = AsyncMock() mgr.start_processing = AsyncMock() mgr.send_clear_pixels = AsyncMock() mgr.set_calibration_pixel = AsyncMock() return mgr @pytest_asyncio.fixture(autouse=True) async def reset_session(): """Reset the module-level CalibrationSession singleton before each test.""" import asyncio def _clear(session) -> None: session._active = False session._device_id = None session._led_count = 0 session._prior_target_id = None session._last_activity = None session._manager = None # Reset lock so a test that aborted mid-await doesn't leave it locked session._lock = asyncio.Lock() session = get_calibration_session() # Cancel any leftover watchdog task before clearing if session._timeout_task and not session._timeout_task.done(): session._timeout_task.cancel() try: await session._timeout_task except Exception: pass session._timeout_task = None _clear(session) yield # Cleanup after test if session._timeout_task and not session._timeout_task.done(): session._timeout_task.cancel() try: await session._timeout_task except Exception: pass session._timeout_task = None _clear(session) @pytest.fixture() def app(mock_manager: MagicMock) -> FastAPI: """Tiny FastAPI app with only the calibration router and auth disabled.""" from fastapi import FastAPI from ledgrab.api.auth import verify_api_key from ledgrab.api import dependencies as deps_mod _app = FastAPI() _app.include_router(router) # Override the underlying dependency that AuthRequired resolves to _app.dependency_overrides[verify_api_key] = lambda: "test-token" _app.dependency_overrides[deps_mod.get_processor_manager] = lambda: mock_manager return _app @pytest_asyncio.fixture() async def client(app: FastAPI): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: yield c # --------------------------------------------------------------------------- # Session start # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_start_session_success(client: AsyncClient, mock_manager: MagicMock): resp = await client.post("/api/v1/calibration/session", json={"device_id": "dev1"}) assert resp.status_code == 201 data = resp.json() assert data["active"] is True assert data["device_id"] == "dev1" assert data["led_count"] == 100 mock_manager.send_clear_pixels.assert_awaited_once_with("dev1") @pytest.mark.asyncio async def test_start_session_unknown_device(client: AsyncClient): resp = await client.post("/api/v1/calibration/session", json={"device_id": "does_not_exist"}) assert resp.status_code == 404 # --------------------------------------------------------------------------- # Session position # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_position_success(client: AsyncClient, mock_manager: MagicMock): # Start session first await client.post("/api/v1/calibration/session", json={"device_id": "dev1"}) resp = await client.post( "/api/v1/calibration/session/position", json={"index": 42, "window": 2} ) assert resp.status_code == 200 data = resp.json() assert data["active"] is True mock_manager.set_calibration_pixel.assert_awaited_with("dev1", 42, window=2) @pytest.mark.asyncio async def test_position_out_of_range(client: AsyncClient, mock_manager: MagicMock): """index >= led_count → 400.""" await client.post("/api/v1/calibration/session", json={"device_id": "dev1"}) resp = await client.post( "/api/v1/calibration/session/position", json={"index": 100, "window": 1} ) assert resp.status_code == 400 @pytest.mark.asyncio async def test_position_negative_index_422(client: AsyncClient): """index < 0 → Pydantic 422.""" resp = await client.post( "/api/v1/calibration/session/position", json={"index": -1, "window": 1} ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_position_no_active_session(client: AsyncClient): """Calling position without starting a session → 400.""" resp = await client.post("/api/v1/calibration/session/position", json={"index": 5, "window": 1}) assert resp.status_code == 400 # --------------------------------------------------------------------------- # Session stop # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_stop_session_clears_device(client: AsyncClient, mock_manager: MagicMock): await client.post("/api/v1/calibration/session", json={"device_id": "dev1"}) resp = await client.post("/api/v1/calibration/session/stop") assert resp.status_code == 200 data = resp.json() assert data["active"] is False # send_clear_pixels called at start AND at stop assert mock_manager.send_clear_pixels.await_count == 2 @pytest.mark.asyncio async def test_stop_restores_prior_target(client: AsyncClient, mock_manager: MagicMock): """When a target was running, stop should restart it.""" mock_manager.get_processing_target_for_device = MagicMock(return_value="tgt1") await client.post("/api/v1/calibration/session", json={"device_id": "dev1"}) await client.post("/api/v1/calibration/session/stop") mock_manager.start_processing.assert_awaited_once_with("tgt1") @pytest.mark.asyncio async def test_stop_no_active_session_is_ok(client: AsyncClient): """stop when inactive → 200 with active=False.""" resp = await client.post("/api/v1/calibration/session/stop") assert resp.status_code == 200 assert resp.json()["active"] is False # --------------------------------------------------------------------------- # Session state # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_get_state_inactive(client: AsyncClient): resp = await client.get("/api/v1/calibration/session/state") assert resp.status_code == 200 assert resp.json()["active"] is False @pytest.mark.asyncio async def test_get_state_active(client: AsyncClient, mock_manager: MagicMock): await client.post("/api/v1/calibration/session", json={"device_id": "dev1"}) resp = await client.get("/api/v1/calibration/session/state") assert resp.status_code == 200 assert resp.json()["active"] is True # --------------------------------------------------------------------------- # Solve endpoint # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_solve_with_device_id(client: AsyncClient): resp = await client.post( "/api/v1/calibration/solve", json={ "device_id": "dev1", "start_position": "bottom_left", "layout": "clockwise", "corner_indices": [0, 30, 60, 80], }, ) assert resp.status_code == 200 data = resp.json() assert data["mode"] == "simple" # bottom_left/clockwise EDGE_ORDER: left, top, right, bottom # left=30, top=30, right=20, bottom=20 → total=100 assert data["leds_left"] == 30 assert data["leds_top"] == 30 assert data["leds_right"] == 20 assert data["leds_bottom"] == 20 assert data["layout"] == "clockwise" assert data["start_position"] == "bottom_left" @pytest.mark.asyncio async def test_solve_with_led_count(client: AsyncClient): resp = await client.post( "/api/v1/calibration/solve", json={ "led_count": 80, "start_position": "top_left", "layout": "clockwise", "corner_indices": [0, 20, 40, 60], }, ) assert resp.status_code == 200 data = resp.json() assert sum([data["leds_top"], data["leds_right"], data["leds_bottom"], data["leds_left"]]) == 80 @pytest.mark.asyncio async def test_solve_missing_device_and_led_count(client: AsyncClient): """Omitting both device_id and led_count → 422 (model validator).""" resp = await client.post( "/api/v1/calibration/solve", json={ "start_position": "bottom_left", "layout": "clockwise", "corner_indices": [0, 25, 50, 75], }, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_solve_unknown_device(client: AsyncClient): resp = await client.post( "/api/v1/calibration/solve", json={ "device_id": "no_such_device", "start_position": "bottom_left", "layout": "clockwise", "corner_indices": [0, 25, 50, 75], }, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_solve_invalid_start_position_422(client: AsyncClient): resp = await client.post( "/api/v1/calibration/solve", json={ "led_count": 100, "start_position": "invalid_corner", "layout": "clockwise", "corner_indices": [0, 25, 50, 75], }, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_solve_invalid_layout_422(client: AsyncClient): resp = await client.post( "/api/v1/calibration/solve", json={ "led_count": 100, "start_position": "bottom_left", "layout": "diagonal", "corner_indices": [0, 25, 50, 75], }, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_solve_wrong_corner_count_422(client: AsyncClient): """Only 3 corner indices → 422 (min_length=4).""" resp = await client.post( "/api/v1/calibration/solve", json={ "led_count": 100, "start_position": "bottom_left", "layout": "clockwise", "corner_indices": [0, 25, 50], }, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_solve_with_offset(client: AsyncClient): resp = await client.post( "/api/v1/calibration/solve", json={ "led_count": 100, "start_position": "bottom_left", "layout": "clockwise", "corner_indices": [0, 25, 50, 75], "offset": 7, }, ) assert resp.status_code == 200 assert resp.json()["offset"] == 7