NODEDC_MISSION_CORE/tests/test_polygon_api.py

318 lines
11 KiB
Python

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"