from __future__ import annotations from collections.abc import Callable from pathlib import Path from typing import Any import pytest from fastapi import APIRouter, HTTPException from fastapi.routing import APIRoute from k1link.simulation import ( AuthorityProfile, ProviderPin, QualificationArtifact, QualificationRun, QualificationRunStore, ReproducibilityTier, RunKind, RunState, ) from k1link.web.polygon_api import StartStockRoverRequest, build_polygon_router SHA_A = "a" * 64 SHA_B = "b" * 64 SHA_C = "c" * 64 def _run(run_id: str = "s1b-ui0-001") -> QualificationRun: return QualificationRun( run_id=run_id, episode_id=f"episode-{run_id}", kind=RunKind.SIMULATION_CLOSED_LOOP, state=RunState.ADMITTED, scenario_generation="px4-v1.17.0-stock-rover-ackermann", scenario_sha256=SHA_A, profile_generation="stock-rover-lifecycle-v1", profile_sha256=SHA_B, mission_core_commit="6cb1495a1234567890abcdef1234567890abcdef", providers=( ProviderPin( identifier="px4-autopilot", version="v1.17.0", revision="v1.17.0", ), ProviderPin( identifier="gazebo", version="harmonic", revision="8.9.0", ), ), host_profile_id="mission-gpu-s0", host_profile_sha256=SHA_C, seed=42, reproducibility_tier=ReproducibilityTier.R1, authority=AuthorityProfile( generation=1, command_ttl_max_ns=250_000_000, heartbeat_timeout_monotonic_ns=500_000_000, ), clock_domain="gazebo:/clock", created_at_utc="2026-07-24T15:35:00Z", ) def _accepted_run(root: Path) -> QualificationRun: store = QualificationRunStore(root) admitted = store.create(_run()) starting = store.transition( admitted.run_id, RunState.STARTING, expected_revision=0, observed_at_utc="2026-07-24T15:35:01Z", host_monotonic_ns=1, ) running = store.transition( admitted.run_id, RunState.RUNNING, expected_revision=starting.revision, observed_at_utc="2026-07-24T15:35:02Z", host_monotonic_ns=2, sim_time_ns=0, ) event = store.append_event( admitted.run_id, event_type="orchestrator.providers-started", observed_at_utc="2026-07-24T15:35:03Z", host_monotonic_ns=3, sim_time_ns=1_000_000, payload={"provider_ids": ["micro-xrce-dds-agent", "px4-gazebo-stock-rover"]}, expected_revision=running.revision, ) store.register_artifact( admitted.run_id, QualificationArtifact( artifact_id="provider-log-index", kind="log-index", relative_path="provider-runtime/processes.json", sha256=SHA_A, byte_length=512, source_of_record=True, ), ) stopping = store.transition( admitted.run_id, RunState.STOPPING, expected_revision=event.sequence, observed_at_utc="2026-07-24T15:35:04Z", host_monotonic_ns=4, sim_time_ns=2_000_000, ) return store.transition( admitted.run_id, RunState.COMPLETED, expected_revision=stopping.revision, observed_at_utc="2026-07-24T15:35:05Z", host_monotonic_ns=5, sim_time_ns=2_000_000, reason="operator-stop-clean", ) def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]: for route in router.routes: if isinstance(route, APIRoute) and route.path == path and method in route.methods: return route.endpoint raise AssertionError(f"{method} {path} route is missing") def test_polygon_api_is_read_only_and_fails_closed_when_unconfigured() -> None: router = build_polygon_router(root_provider=lambda: None) routes = [route for route in router.routes if isinstance(route, APIRoute)] archive_routes = [route for route in routes if "/worker" not in route.path] assert all(route.methods <= {"GET", "HEAD"} for route in archive_routes) with pytest.raises(HTTPException) as failure: _endpoint(router, "/api/v1/polygon/runs", "GET")(limit=20) assert failure.value.status_code == 503 assert "не настроен" in failure.value.detail def test_polygon_api_publishes_bounded_path_free_qualification_evidence( tmp_path: Path, ) -> None: root = tmp_path / "runs" accepted = _accepted_run(root) router = build_polygon_router(root_provider=lambda: root) catalog = _endpoint(router, "/api/v1/polygon/runs", "GET")(limit=20) detail = _endpoint(router, "/api/v1/polygon/runs/{run_id}", "GET")( run_id=accepted.run_id, event_limit=2, ) assert catalog["schema_version"] == "missioncore.polygon-run-catalog/v1" assert catalog["access"] == "read-only" assert catalog["total"] == 1 assert catalog["items"][0]["state"] == "completed" assert catalog["items"][0]["terminal_reason"] == "operator-stop-clean" assert detail["schema_version"] == "missioncore.polygon-run-detail/v1" assert detail["access"] == "read-only" assert detail["run"]["run_id"] == accepted.run_id assert detail["run"]["authority"]["actuator_authority"] is False assert detail["commands"] == {"count": 0, "content_exposed": False} assert detail["events_total"] == accepted.revision assert detail["events_truncated"] is True assert len(detail["events"]) == 2 assert detail["artifacts"][0]["relative_path"] == "provider-runtime/processes.json" serialized = repr({"catalog": catalog, "detail": detail}) assert str(tmp_path) not in serialized assert "command_ttl_max_ns" in serialized assert all("http" not in artifact for artifact in detail["artifacts"]) def test_polygon_api_rejects_corrupt_evidence_without_partial_response( tmp_path: Path, ) -> None: root = tmp_path / "runs" accepted = _accepted_run(root) event_path = root / accepted.run_id / "events" / "00000000000000000001.json" event_path.write_text("{broken", encoding="utf-8") router = build_polygon_router(root_provider=lambda: root) with pytest.raises(HTTPException) as failure: _endpoint(router, "/api/v1/polygon/runs", "GET")(limit=20) assert failure.value.status_code == 500 assert "целостности" in failure.value.detail class _FakeWorkerGateway: def __init__(self) -> None: self.calls: list[tuple[str, str]] = [] self.active_run_id: str | None = None def status(self) -> dict[str, Any]: return self._status() def live(self) -> dict[str, Any]: if self.active_run_id is None: raise AssertionError("test worker has no active run") return { "schema_version": "missioncore.vehicle-state/v1", "run_id": self.active_run_id, "sequence": 1, "observed_at_utc": "2026-07-24T18:00:00Z", "host_monotonic_ns": 123, "sim_time_ns": 456, "frame_id": "map_enu", "child_frame_id": "base_link_flu", "pose": { "position_m": {"x": 1.0, "y": 2.0, "z": 0.1}, "orientation_xyzw": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0}, }, "source": { "provider": "gazebo", "topic": "/world/rover/dynamic_pose/info", "signal": "ground-truth", "quality": "diagnostic", }, "safety": { "scope": "virtual-only", "actuator_authority": False, "navigation_or_safety_accepted": False, }, } def start( self, *, run_id: str, mission_core_commit: str, idempotency_key: str, ) -> dict[str, Any]: self.calls.append(("start", idempotency_key)) assert mission_core_commit == "d" * 40 self.active_run_id = run_id return self._status() def stop(self, *, run_id: str, idempotency_key: str) -> dict[str, Any]: self.calls.append(("stop", idempotency_key)) assert run_id == self.active_run_id self.active_run_id = None return self._status() def _status(self) -> dict[str, Any]: return { "schema_version": "missioncore.simulation-worker-status/v1", "worker_id": "mission-gpu-s1", "transport": "unix", "mode": "simulation", "available": True, "control_available": True, "active_run_id": self.active_run_id, "run_state": "running" if self.active_run_id else None, "provider_ids": ["px4-gazebo-stock-rover"] if self.active_run_id else [], "isolation": { "network": "loopback-only-netns", "process_identity": "missioncore", "artifact_policy": "d-only", }, "authority": { "scope": "virtual-only", "actuator_authority": False, "direct_actuator_setpoints_allowed": False, }, } def test_polygon_worker_api_fails_closed_then_proxies_virtual_only_lifecycle() -> None: worker = _FakeWorkerGateway() disabled = build_polygon_router( root_provider=lambda: None, worker_provider=lambda: worker, control_provider=lambda: False, commit_provider=lambda: "d" * 40, ) status = _endpoint(disabled, "/api/v1/polygon/worker", "GET")() assert status["available"] is True assert status["control_available"] is False with pytest.raises(HTTPException) as failure: _endpoint(disabled, "/api/v1/polygon/worker/runs", "POST")( request=StartStockRoverRequest(scenario_id="stock-rover-ackermann"), idempotency_key="start-disabled", ) assert failure.value.status_code == 403 enabled = build_polygon_router( root_provider=lambda: None, worker_provider=lambda: worker, control_provider=lambda: True, commit_provider=lambda: "d" * 40, ) running = _endpoint(enabled, "/api/v1/polygon/worker/runs", "POST")( request=StartStockRoverRequest(scenario_id="stock-rover-ackermann"), idempotency_key="start-001", ) assert running["active_run_id"].startswith("s1c-ddddddd-") live = _endpoint(enabled, "/api/v1/polygon/worker/live", "GET")() assert live["run_id"] == running["active_run_id"] assert live["source"]["quality"] == "diagnostic" stopped = _endpoint(enabled, "/api/v1/polygon/worker/runs/{run_id}/stop", "POST")( run_id=running["active_run_id"], idempotency_key="stop-001", ) assert stopped["active_run_id"] is None assert worker.calls == [("start", "start-001"), ("stop", "stop-001")] def test_polygon_worker_status_is_explicitly_unavailable_when_not_registered() -> None: router = build_polygon_router( root_provider=lambda: None, worker_provider=lambda: None, ) status = _endpoint(router, "/api/v1/polygon/worker", "GET")() assert status["schema_version"] == "missioncore.simulation-worker-status/v1" assert status["available"] is False assert status["control_available"] is False assert status["authority"]["scope"] == "virtual-only"