2026-07-20 16:59:37 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from cmvr_edge_ai.application import (
|
|
|
|
|
ApplicationState,
|
|
|
|
|
EdgeAIApplication,
|
|
|
|
|
create_default_registry,
|
|
|
|
|
validate_application,
|
|
|
|
|
)
|
|
|
|
|
from cmvr_edge_ai.cli import main
|
|
|
|
|
from cmvr_edge_ai.config import load_config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
2026-07-21 12:07:12 +08:00
|
|
|
MINIMAL_CONFIG = PROJECT_ROOT / "tests" / "fixtures" / "minimal_pipeline.yaml"
|
|
|
|
|
EDGE_AI_CONFIG = PROJECT_ROOT / "configs" / "edge_ai.yaml"
|
2026-07-20 16:59:37 +08:00
|
|
|
|
|
|
|
|
|
2026-07-21 12:07:12 +08:00
|
|
|
def test_application_runs_the_finite_minimal_pipeline() -> None:
|
2026-07-20 16:59:37 +08:00
|
|
|
async def exercise(): # type: ignore[no-untyped-def]
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
host_executor = ThreadPoolExecutor(max_workers=1)
|
|
|
|
|
loop.set_default_executor(host_executor)
|
|
|
|
|
application = EdgeAIApplication(
|
2026-07-21 12:07:12 +08:00
|
|
|
load_config(MINIMAL_CONFIG),
|
2026-07-20 16:59:37 +08:00
|
|
|
create_default_registry(discover_entry_points=False),
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
await application.start()
|
|
|
|
|
running_health = await application.health()
|
|
|
|
|
await application.wait()
|
|
|
|
|
await application.stop()
|
|
|
|
|
# The application owns only its bounded executor. Stopping it must
|
|
|
|
|
# not poison an embedding event loop's pre-existing executor.
|
|
|
|
|
host_executor_result = await asyncio.to_thread(lambda: "still-usable")
|
|
|
|
|
return application, running_health, host_executor_result
|
|
|
|
|
finally:
|
|
|
|
|
# Python 3.12 Runner shuts its default executor down from another
|
|
|
|
|
# helper thread. Explicit cleanup keeps this regression deterministic
|
|
|
|
|
# in restricted containers where that helper cannot wake the loop.
|
|
|
|
|
host_executor.shutdown(wait=True, cancel_futures=True)
|
|
|
|
|
loop._default_executor = None # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
application, running_health, host_executor_result = asyncio.run(exercise())
|
|
|
|
|
|
|
|
|
|
assert running_health["state"] == "running"
|
2026-07-21 12:07:12 +08:00
|
|
|
assert set(running_health["pipelines"]) == {"minimal"}
|
|
|
|
|
assert application.pipelines == ("minimal",)
|
2026-07-20 16:59:37 +08:00
|
|
|
assert application.state is ApplicationState.STOPPED
|
|
|
|
|
assert host_executor_result == "still-usable"
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 12:07:12 +08:00
|
|
|
def test_cli_validates_and_runs_minimal_config(
|
2026-07-20 16:59:37 +08:00
|
|
|
capsys, monkeypatch
|
|
|
|
|
) -> None: # type: ignore[no-untyped-def]
|
|
|
|
|
# ``configure_logging(force=True)`` intentionally owns process logging in
|
|
|
|
|
# production. Avoid leaking that global CLI side effect into later tests.
|
|
|
|
|
monkeypatch.setattr("cmvr_edge_ai.cli.configure_logging", lambda *_: None)
|
2026-07-21 12:07:12 +08:00
|
|
|
assert main(["validate", "--config", str(MINIMAL_CONFIG)]) == 0
|
2026-07-20 16:59:37 +08:00
|
|
|
validation_output = capsys.readouterr()
|
2026-07-21 12:07:12 +08:00
|
|
|
assert "configuration is valid; pipelines: minimal" in validation_output.out
|
2026-07-20 16:59:37 +08:00
|
|
|
assert validation_output.err == ""
|
|
|
|
|
|
2026-07-21 12:07:12 +08:00
|
|
|
assert (
|
|
|
|
|
main(
|
|
|
|
|
[
|
|
|
|
|
"run",
|
|
|
|
|
"--config",
|
|
|
|
|
str(MINIMAL_CONFIG),
|
|
|
|
|
"--log-level",
|
|
|
|
|
"WARNING",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
== 0
|
|
|
|
|
)
|
2026-07-20 16:59:37 +08:00
|
|
|
run_output = capsys.readouterr()
|
|
|
|
|
assert run_output.err == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cli_lists_versioned_builtin_plugins(capsys) -> None: # type: ignore[no-untyped-def]
|
|
|
|
|
assert main(["plugins"]) == 0
|
|
|
|
|
|
|
|
|
|
output = capsys.readouterr().out
|
|
|
|
|
assert "core.sequence_source@1\tsource" in output
|
|
|
|
|
assert "detection.model@1\toperator" in output
|
|
|
|
|
assert "media.video_decoder.pyav@1\toperator" in output
|
|
|
|
|
assert "safety.robot_command_gate@1\toperator" in output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cli_lists_detection_model_metadata(capsys) -> None: # type: ignore[no-untyped-def]
|
|
|
|
|
assert main(["models"]) == 0
|
|
|
|
|
|
|
|
|
|
output = capsys.readouterr().out
|
|
|
|
|
assert (
|
|
|
|
|
"construction-ppe-yolov8@1\tConstruction PPE YOLOv8s\t" "ultralytics-yolo"
|
|
|
|
|
) in output
|
|
|
|
|
assert "No-Helmet" in output
|
|
|
|
|
assert "No-Vest" in output
|
|
|
|
|
assert (
|
|
|
|
|
"ppe-6classes-yolov8n@1\tPPE Detection YOLOv8n (6 Classes)\t"
|
|
|
|
|
"ultralytics-yolo"
|
|
|
|
|
) in output
|
|
|
|
|
assert "Gloves,Vest,goggles,helmet,mask,safety_shoe" in output
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 12:07:12 +08:00
|
|
|
def test_merged_config_compiles_each_pipeline_without_loading_optional_runtimes() -> None:
|
|
|
|
|
config = load_config(EDGE_AI_CONFIG)
|
|
|
|
|
registry = create_default_registry(discover_entry_points=False)
|
|
|
|
|
talk = validate_application(config, registry, ("talk",))
|
2026-07-20 16:59:37 +08:00
|
|
|
compiled = validate_application(
|
|
|
|
|
config,
|
2026-07-21 12:07:12 +08:00
|
|
|
registry,
|
|
|
|
|
("detection",),
|
2026-07-20 16:59:37 +08:00
|
|
|
)
|
|
|
|
|
|
2026-07-21 12:07:12 +08:00
|
|
|
assert set(config.pipelines) == {"detection", "talk"}
|
|
|
|
|
assert tuple(item.pipeline_id for item in talk) == ("talk",)
|
2026-07-20 16:59:37 +08:00
|
|
|
assert len(compiled) == 1
|
2026-07-21 12:07:12 +08:00
|
|
|
assert compiled[0].pipeline_id == "detection"
|
|
|
|
|
assert "ppe_alert_platform" in config.endpoints
|
|
|
|
|
assert (
|
|
|
|
|
config.pipelines["detection"]
|
|
|
|
|
.nodes["alert_platform"]
|
|
|
|
|
.params["endpoint"]
|
|
|
|
|
== "ppe_alert_platform"
|
|
|
|
|
)
|
2026-07-20 16:59:37 +08:00
|
|
|
assert (
|
2026-07-21 12:07:12 +08:00
|
|
|
config.pipelines["detection"]
|
|
|
|
|
.nodes["alert_platform"]
|
|
|
|
|
.params["failure_mode"]
|
2026-07-20 16:59:37 +08:00
|
|
|
== "log_and_drop"
|
|
|
|
|
)
|
|
|
|
|
assert set(compiled[0].plugin_specs) == {
|
|
|
|
|
"camera",
|
|
|
|
|
"decoder",
|
|
|
|
|
"detector",
|
|
|
|
|
"repeat_gate",
|
2026-07-21 12:07:12 +08:00
|
|
|
"alert_platform",
|
2026-07-20 16:59:37 +08:00
|
|
|
}
|