from __future__ import annotations import pytest from pydantic import ValidationError from cmvr_edge_ai.config import ( MissingEnvironmentVariable, load_config_data, load_config_text, ) def test_load_config_expands_environment_at_every_level() -> None: config = load_config_text( """ api_version: cmvr.edge.ai/v1 runtime: thread_workers: ${EDGE_THREADS:-2} endpoints: robot: transport: GRPC target: env://CMVR_TARGET platform: transport: http base_url: ${PLATFORM_URL:-http://127.0.0.1:8080} pipelines: detection: nodes: source: uses: core.sequence_source@1 with: items: [env://SAMPLE_ITEM] """, environ={ "CMVR_TARGET": "192.168.0.10:50052", "EDGE_THREADS": "3", "SAMPLE_ITEM": "frame-1", }, ) assert config.runtime.thread_workers == 3 assert config.endpoints["robot"].transport == "grpc" assert config.endpoints["robot"].target == "192.168.0.10:50052" assert config.endpoints["platform"].base_url == "http://127.0.0.1:8080" assert config.pipelines["detection"].nodes["source"].params["items"] == ["frame-1"] def test_load_config_rejects_missing_required_environment_variable() -> None: with pytest.raises(MissingEnvironmentVariable, match="CMVR_TARGET"): load_config_text( """ api_version: cmvr.edge.ai/v1 endpoints: robot: transport: grpc target: ${CMVR_TARGET} pipelines: smoke: nodes: source: uses: core.sequence_source@1 """, environ={}, ) def test_config_rejects_edges_to_unknown_nodes() -> None: with pytest.raises(ValidationError, match="references unknown node"): load_config_data( { "api_version": "cmvr.edge.ai/v1", "pipelines": { "invalid": { "nodes": { "source": {"uses": "test.source@1"}, }, "edges": [{"from": "source.output", "to": "missing.input"}], } }, } ) def test_config_rejects_cycles_before_runtime_construction() -> None: with pytest.raises(ValidationError, match="must be acyclic"): load_config_data( { "api_version": "cmvr.edge.ai/v1", "pipelines": { "cyclic": { "nodes": { "first": {"uses": "test.operator@1"}, "second": {"uses": "test.operator@1"}, }, "edges": [ {"from": "first.output", "to": "second.input"}, {"from": "second.output", "to": "first.input"}, ], } }, } ) def test_config_models_forbid_unknown_fields() -> None: with pytest.raises(ValidationError, match="extra_forbidden"): load_config_data( { "api_version": "cmvr.edge.ai/v1", "unexpected": True, "pipelines": { "smoke": {"nodes": {"source": {"uses": "test.source@1"}}} }, } ) @pytest.mark.parametrize( ("endpoint", "message"), [ ({"transport": "grpc"}, "gRPC endpoint requires target"), ({"transport": "http"}, "HTTP endpoint requires base_url"), ( { "transport": "http", "base_url": "https://platform.example", "tls": False, }, "HTTP endpoint must not set tls", ), ], ) def test_builtin_endpoints_reject_incomplete_or_ambiguous_addresses( endpoint: dict[str, object], message: str ) -> None: with pytest.raises(ValidationError, match=message): load_config_data( { "api_version": "cmvr.edge.ai/v1", "endpoints": {"external": endpoint}, "pipelines": { "smoke": {"nodes": {"source": {"uses": "test.source@1"}}} }, } )