from __future__ import annotations import asyncio import base64 import hashlib import sys from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any import pytest from pydantic import ValidationError from cmvr_edge_ai.contracts import ( ImageArtifact, ImageInput, InferenceRequest, InferenceStatus, JsonArtifact, ScalarOutput, ) from cmvr_edge_ai.core import Envelope from cmvr_edge_ai.gauge import ( GAUGE_CATEGORY, GAUGE_MODEL_ID, AnalogGaugeReaderOperator, ) from cmvr_edge_ai.workers import PROTOCOL_VERSION, FramedWorkerResponse class FakeWorker: def __init__( self, responses: Sequence[FramedWorkerResponse | Exception], *, ready: Mapping[str, Any] | None = None, ) -> None: self.responses = list(responses) self.ready = dict( ready or { "protocol": PROTOCOL_VERSION, "type": "ready", "model_id": GAUGE_MODEL_ID, } ) self.calls: list[tuple[Mapping[str, Any], tuple[bytes, ...], float]] = [] self.is_alive = False self.last_error = "" self.stopped = False async def start(self) -> Mapping[str, Any]: self.is_alive = True return self.ready async def submit( self, header: Mapping[str, Any], blobs: Sequence[bytes] = (), *, timeout_s: float, ) -> FramedWorkerResponse: self.calls.append((dict(header), tuple(blobs), timeout_s)) outcome = self.responses.pop(0) if isinstance(outcome, Exception): raise outcome return outcome async def stop(self) -> None: self.is_alive = False self.stopped = True def _params(tmp_path: Path) -> dict[str, object]: model_dir = tmp_path / "models" model_dir.mkdir(exist_ok=True) for name in ("detection.pt", "keypoints.pt", "segmentation.pt"): (model_dir / name).write_bytes(b"model") return { "python_executable": sys.executable, "project_root": str(tmp_path), "detection_model_path": "models/detection.pt", "key_point_model_path": "models/keypoints.pt", "segmentation_model_path": "models/segmentation.pt", "startup_timeout_s": 12, "request_timeout_s": 34, "shutdown_timeout_s": 5, "max_header_bytes": 4096, "max_blob_bytes": 8192, "max_pixels": 1_234_567, } def _request( *, request_id: str = "gauge-1", roles: tuple[str, ...] = ("original", "annotated", "diagnostics"), ) -> InferenceRequest: encoded = b"encoded-gauge-image" return InferenceRequest( request_id=request_id, category=GAUGE_CATEGORY, source_id="camera-front", inputs=( ImageInput( name="image", media_type="image/jpeg", data=base64.b64encode(encoded).decode("ascii"), width=640, height=480, sha256=hashlib.sha256(encoded).hexdigest(), ), ), requested_artifact_roles=roles, ) def _envelope(request: InferenceRequest) -> Envelope[InferenceRequest]: return Envelope( request, schema_name="InferenceRequest", trace_id=request.request_id or "fallback", source_id=request.source_id or "", attributes={ "invocation_request_id": request.request_id, "invocation_category": request.category, }, ) def _worker_response( *, request_id: str = "gauge-1", status: str = "succeeded", value: float | None = 12.5, with_annotated: bool = True, input_image: dict[str, int] | None = None, ) -> FramedWorkerResponse: annotated = b"annotated-jpeg" artifacts = ( [ { "blob_index": 0, "role": "annotated", "media_type": "image/jpeg", "width": 640, "height": 480, "sha256": hashlib.sha256(annotated).hexdigest(), } ] if with_annotated else [] ) error = ( { "code": "GAUGE_READER_FAILED", "message": "gauge could not be read", "retryable": False, "stage": "ocr", "details": {"reason": "no labels"}, } if status == "failed" else None ) return FramedWorkerResponse( header={ "protocol": PROTOCOL_VERSION, "type": "response", "request_id": request_id, "category": GAUGE_CATEGORY, "model_id": GAUGE_MODEL_ID, "status": status, "value": value, "unit": "bar" if value is not None else None, "inference_ms": 7.5, "input_image": ( {"width": 640, "height": 480} if input_image is None and status != "failed" else input_image ), "diagnostics": {"ocr_labels": 4, "fit": {"residual": 0.02}}, "warnings": [], "error": error, "artifacts": artifacts, }, blobs=(annotated,) if with_annotated else (), ) def test_operator_maps_scalar_and_requested_artifacts(tmp_path: Path) -> None: async def exercise() -> None: fake = FakeWorker((_worker_response(),)) constructed: dict[str, object] = {} def factory(command, **kwargs): # type: ignore[no-untyped-def] constructed["command"] = tuple(command) constructed["kwargs"] = kwargs return fake operator = AnalogGaugeReaderOperator( "reader", _params(tmp_path), worker_factory=factory, ) await operator.start() emission = await operator.process(_envelope(_request())) response = emission.envelope.payload assert response.status is InferenceStatus.SUCCEEDED assert response.request_id == "gauge-1" assert response.category == GAUGE_CATEGORY assert response.source_id == "camera-front" assert response.model is not None assert response.model.model_id == GAUGE_MODEL_ID assert response.outputs == ( ScalarOutput(name="reading", value=12.5, unit="bar"), ) assert [artifact.role for artifact in response.artifacts] == [ "original", "annotated", "diagnostics", ] original = response.artifacts[0] annotated = response.artifacts[1] diagnostics = response.artifacts[2] assert isinstance(original, ImageArtifact) assert base64.b64decode(original.data) == b"encoded-gauge-image" assert isinstance(annotated, ImageArtifact) assert base64.b64decode(annotated.data) == b"annotated-jpeg" assert isinstance(diagnostics, JsonArtifact) assert diagnostics.data == { "ocr_labels": 4, "fit": {"residual": 0.02}, } command = constructed["command"] assert isinstance(command, tuple) assert Path(command[2]).is_absolute() assert command[2].endswith("cmvr_edge_ai/gauge/analog_gauge_worker.py") max_pixels_index = command.index("--max-pixels") assert command[max_pixels_index + 1] == "1234567" sent_header, sent_blobs, timeout_s = fake.calls[0] assert sent_header["request_id"] == "gauge-1" assert sent_header["model_id"] == GAUGE_MODEL_ID assert sent_header["image"] == { "blob_index": 0, "name": "image", "role": "original", "media_type": "image/jpeg", "sha256": hashlib.sha256(b"encoded-gauge-image").hexdigest(), "width": 640, "height": 480, } assert sent_blobs == (b"encoded-gauge-image",) assert timeout_s == 34 await operator.stop() assert fake.stopped is True asyncio.run(exercise()) def test_invalid_worker_response_fails_one_request_then_recovers( tmp_path: Path, ) -> None: async def exercise() -> None: fake = FakeWorker( ( _worker_response(request_id="wrong"), _worker_response(request_id="gauge-2"), ) ) operator = AnalogGaugeReaderOperator( "reader", _params(tmp_path), worker_factory=lambda *args, **kwargs: fake, ) failed = ( await operator.process(_envelope(_request(request_id="gauge-1"))) ).envelope.payload succeeded = ( await operator.process(_envelope(_request(request_id="gauge-2"))) ).envelope.payload assert failed.status is InferenceStatus.FAILED assert failed.error is not None assert failed.error.code == "GAUGE_INVALID_RESPONSE" assert succeeded.status is InferenceStatus.SUCCEEDED health = await operator.health() assert health.healthy is True assert "completed=1" in health.detail assert "failed=1" in health.detail await operator.stop() asyncio.run(exercise()) def test_worker_failure_response_preserves_error_without_killing_operator( tmp_path: Path, ) -> None: async def exercise() -> None: fake = FakeWorker( ( _worker_response( status="failed", value=None, with_annotated=False, input_image=None, ), _worker_response(request_id="gauge-2"), ) ) operator = AnalogGaugeReaderOperator( "reader", _params(tmp_path), worker_factory=lambda *args, **kwargs: fake, ) failed = ( await operator.process(_envelope(_request(request_id="gauge-1"))) ).envelope.payload succeeded = ( await operator.process(_envelope(_request(request_id="gauge-2"))) ).envelope.payload assert failed.status is InferenceStatus.FAILED assert failed.error is not None assert failed.error.code == "GAUGE_READER_FAILED" assert failed.error.stage == "ocr" assert failed.artifacts == () assert succeeded.status is InferenceStatus.SUCCEEDED await operator.stop() asyncio.run(exercise()) def test_timeout_becomes_retryable_failed_response(tmp_path: Path) -> None: async def exercise() -> None: fake = FakeWorker((asyncio.TimeoutError(),)) operator = AnalogGaugeReaderOperator( "reader", _params(tmp_path), worker_factory=lambda *args, **kwargs: fake, ) response = ( await operator.process(_envelope(_request(roles=()))) ).envelope.payload assert response.status is InferenceStatus.FAILED assert response.error is not None assert response.error.code == "GAUGE_TIMEOUT" assert response.error.retryable is True await operator.stop() asyncio.run(exercise()) @pytest.mark.parametrize( ("status", "value", "expected"), [ ("no_result", None, InferenceStatus.NO_RESULT), ("partial", 3.25, InferenceStatus.PARTIAL), ], ) def test_operator_maps_non_success_statuses( tmp_path: Path, status: str, value: float | None, expected: InferenceStatus, ) -> None: async def exercise() -> None: fake = FakeWorker( ( _worker_response( status=status, value=value, with_annotated=False, ), ) ) operator = AnalogGaugeReaderOperator( "reader", _params(tmp_path), worker_factory=lambda *args, **kwargs: fake, ) response = ( await operator.process(_envelope(_request(roles=("diagnostics",)))) ).envelope.payload assert response.status is expected assert bool(response.outputs) is (value is not None) await operator.stop() asyncio.run(exercise()) def test_missing_unit_is_partial_with_warning(tmp_path: Path) -> None: async def exercise() -> None: worker_response = _worker_response(with_annotated=False) worker_response = FramedWorkerResponse( header={**worker_response.header, "unit": None}, blobs=worker_response.blobs, ) fake = FakeWorker((worker_response,)) operator = AnalogGaugeReaderOperator( "reader", _params(tmp_path), worker_factory=lambda *args, **kwargs: fake, ) response = ( await operator.process(_envelope(_request(roles=()))) ).envelope.payload assert response.status is InferenceStatus.PARTIAL assert response.outputs == ( ScalarOutput(name="reading", value=12.5, unit=None), ) assert "gauge unit was not recognized" in response.warnings await operator.stop() asyncio.run(exercise()) def test_operator_configuration_is_strict_and_resolves_model_paths( tmp_path: Path, ) -> None: params = _params(tmp_path) fake = FakeWorker((_worker_response(),)) operator = AnalogGaugeReaderOperator( "reader", params, worker_factory=lambda *args, **kwargs: fake, ) assert operator.params.detection_model_path == ( tmp_path / "models/detection.pt" ).resolve() assert operator.params.max_pixels == 1_234_567 default_params = dict(params) default_params.pop("max_pixels") default_operator = AnalogGaugeReaderOperator( "reader-default-pixels", default_params, worker_factory=lambda *args, **kwargs: fake, ) assert default_operator.params.max_pixels == 25_000_000 with pytest.raises(ValidationError, match="Extra inputs are not permitted"): AnalogGaugeReaderOperator( "reader", {**params, "unknown": True}, worker_factory=lambda *args, **kwargs: fake, ) with pytest.raises(ValidationError, match="absolute path"): AnalogGaugeReaderOperator( "reader", {**params, "python_executable": "python3.8"}, worker_factory=lambda *args, **kwargs: fake, ) for invalid_max_pixels in (0, 250_000_001): with pytest.raises(ValidationError, match="max_pixels"): AnalogGaugeReaderOperator( "reader", {**params, "max_pixels": invalid_max_pixels}, worker_factory=lambda *args, **kwargs: fake, ) def test_operator_command_preserves_virtualenv_python_symlink( tmp_path: Path, ) -> None: params = _params(tmp_path) python_link = tmp_path / "worker-python" python_link.symlink_to(sys.executable) params["python_executable"] = str(python_link) fake = FakeWorker((_worker_response(),)) constructed: dict[str, object] = {} def factory(command, **kwargs): # type: ignore[no-untyped-def] constructed["command"] = tuple(command) return fake operator = AnalogGaugeReaderOperator( "reader", params, worker_factory=factory, ) command = constructed["command"] assert isinstance(command, tuple) assert command[0] == str(python_link) assert operator.params.python_executable == python_link