117 lines
4.3 KiB
Python
117 lines
4.3 KiB
Python
|
|
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]
|
||
|
|
SMOKE_CONFIG = PROJECT_ROOT / "configs" / "smoke.yaml"
|
||
|
|
DETECTION_CONFIG = PROJECT_ROOT / "detect_server" / "pipeline.yaml"
|
||
|
|
|
||
|
|
|
||
|
|
def test_application_runs_the_finite_smoke_pipeline() -> None:
|
||
|
|
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(
|
||
|
|
load_config(SMOKE_CONFIG),
|
||
|
|
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"
|
||
|
|
assert set(running_health["pipelines"]) == {"smoke"}
|
||
|
|
assert application.pipelines == ("smoke",)
|
||
|
|
assert application.state is ApplicationState.STOPPED
|
||
|
|
assert host_executor_result == "still-usable"
|
||
|
|
|
||
|
|
|
||
|
|
def test_cli_validates_and_runs_smoke_config(
|
||
|
|
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)
|
||
|
|
assert main(["validate", "--config", str(SMOKE_CONFIG)]) == 0
|
||
|
|
validation_output = capsys.readouterr()
|
||
|
|
assert "configuration is valid; pipelines: smoke" in validation_output.out
|
||
|
|
assert validation_output.err == ""
|
||
|
|
|
||
|
|
assert main(["run", "--config", str(SMOKE_CONFIG), "--log-level", "WARNING"]) == 0
|
||
|
|
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
|
||
|
|
|
||
|
|
|
||
|
|
def test_real_detection_config_compiles_without_loading_optional_runtimes() -> None:
|
||
|
|
config = load_config(DETECTION_CONFIG)
|
||
|
|
compiled = validate_application(
|
||
|
|
config,
|
||
|
|
create_default_registry(discover_entry_points=False),
|
||
|
|
)
|
||
|
|
|
||
|
|
assert len(compiled) == 1
|
||
|
|
assert (
|
||
|
|
config.pipelines["detection"].nodes["platform"].params["failure_mode"]
|
||
|
|
== "log_and_drop"
|
||
|
|
)
|
||
|
|
assert set(compiled[0].plugin_specs) == {
|
||
|
|
"camera",
|
||
|
|
"decoder",
|
||
|
|
"detector",
|
||
|
|
"repeat_gate",
|
||
|
|
"platform",
|
||
|
|
}
|