Add detect_oot_modules() MCP tool that analyzes .py or .grc flowgraphs to identify required OOT modules, enabling automatic Docker image selection without manual image= parameter specification. Detection approaches: - Python files: parse import statements (gnuradio.module, import module) - GRC files: heuristic prefix matching + explicit block-to-module mappings New features: - OOTDetectionResult model with detected_modules, unknown_blocks, and recommended_image fields - auto_image parameter on launch_flowgraph() for automatic image selection - _BLOCK_TO_MODULE mapping for edge cases like lora_rx → lora_sdr - Comprehensive core block filtering (variable_*, filter blocks, xmlrpc) Tests: 23 new unit tests covering Python detection, GRC detection, image recommendation, and edge cases.
848 lines
32 KiB
Python
848 lines
32 KiB
Python
"""Unit tests for OOT module installer middleware.
|
|
|
|
Tests Dockerfile generation, module name extraction, registry persistence,
|
|
image naming, and OOT module detection — all without requiring Docker.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from gnuradio_mcp.middlewares.oot import OOTInstallerMiddleware
|
|
from gnuradio_mcp.models import ComboImageInfo, OOTDetectionResult, OOTImageInfo
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_docker_client():
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def oot(mock_docker_client, tmp_path):
|
|
mw = OOTInstallerMiddleware(mock_docker_client)
|
|
# Override registry paths to use tmp_path
|
|
mw._registry_path = tmp_path / "oot-registry.json"
|
|
mw._registry = {}
|
|
mw._combo_registry_path = tmp_path / "oot-combo-registry.json"
|
|
mw._combo_registry = {}
|
|
return mw
|
|
|
|
|
|
# ──────────────────────────────────────────
|
|
# Module Name Extraction
|
|
# ──────────────────────────────────────────
|
|
|
|
|
|
class TestModuleNameFromUrl:
|
|
def test_gr_prefix_stripped(self):
|
|
name = OOTInstallerMiddleware._module_name_from_url(
|
|
"https://github.com/tapparelj/gr-lora_sdr.git"
|
|
)
|
|
assert name == "lora_sdr"
|
|
|
|
def test_gr_prefix_no_git_suffix(self):
|
|
name = OOTInstallerMiddleware._module_name_from_url(
|
|
"https://github.com/osmocom/gr-osmosdr"
|
|
)
|
|
assert name == "osmosdr"
|
|
|
|
def test_no_gr_prefix(self):
|
|
name = OOTInstallerMiddleware._module_name_from_url(
|
|
"https://github.com/gnuradio/volk.git"
|
|
)
|
|
assert name == "volk"
|
|
|
|
def test_trailing_slash(self):
|
|
name = OOTInstallerMiddleware._module_name_from_url(
|
|
"https://github.com/tapparelj/gr-lora_sdr/"
|
|
)
|
|
assert name == "lora_sdr"
|
|
|
|
def test_gr_satellites(self):
|
|
name = OOTInstallerMiddleware._module_name_from_url(
|
|
"https://github.com/daniestevez/gr-satellites.git"
|
|
)
|
|
assert name == "satellites"
|
|
|
|
|
|
class TestRepoDirFromUrl:
|
|
def test_preserves_gr_prefix(self):
|
|
d = OOTInstallerMiddleware._repo_dir_from_url(
|
|
"https://github.com/tapparelj/gr-lora_sdr.git"
|
|
)
|
|
assert d == "gr-lora_sdr"
|
|
|
|
def test_no_git_suffix(self):
|
|
d = OOTInstallerMiddleware._repo_dir_from_url(
|
|
"https://github.com/osmocom/gr-osmosdr"
|
|
)
|
|
assert d == "gr-osmosdr"
|
|
|
|
|
|
# ──────────────────────────────────────────
|
|
# Dockerfile Generation
|
|
# ──────────────────────────────────────────
|
|
|
|
|
|
class TestDockerfileGeneration:
|
|
def test_basic_dockerfile(self, oot):
|
|
dockerfile = oot.generate_dockerfile(
|
|
git_url="https://github.com/tapparelj/gr-lora_sdr.git",
|
|
branch="master",
|
|
base_image="gnuradio-runtime:latest",
|
|
)
|
|
assert "FROM gnuradio-runtime:latest" in dockerfile
|
|
assert "git clone --depth 1 --branch master" in dockerfile
|
|
assert "https://github.com/tapparelj/gr-lora_sdr.git" in dockerfile
|
|
assert "cd gr-lora_sdr" in dockerfile
|
|
assert "fix_binding_hashes.py" in dockerfile
|
|
assert "mkdir build" in dockerfile
|
|
assert "cmake -DCMAKE_INSTALL_PREFIX=/usr" in dockerfile
|
|
assert "make -j$(nproc)" in dockerfile
|
|
assert "ldconfig" in dockerfile
|
|
assert "PYTHONPATH" in dockerfile
|
|
|
|
def test_with_extra_build_deps(self, oot):
|
|
dockerfile = oot.generate_dockerfile(
|
|
git_url="https://github.com/tapparelj/gr-lora_sdr.git",
|
|
branch="master",
|
|
base_image="gnuradio-runtime:latest",
|
|
build_deps=["libvolk2-dev", "libboost-all-dev"],
|
|
)
|
|
assert "libvolk2-dev libboost-all-dev" in dockerfile
|
|
|
|
def test_with_cmake_args(self, oot):
|
|
dockerfile = oot.generate_dockerfile(
|
|
git_url="https://github.com/tapparelj/gr-lora_sdr.git",
|
|
branch="master",
|
|
base_image="gnuradio-runtime:latest",
|
|
cmake_args=["-DENABLE_TESTING=OFF", "-DBUILD_DOCS=OFF"],
|
|
)
|
|
assert "-DENABLE_TESTING=OFF -DBUILD_DOCS=OFF" in dockerfile
|
|
|
|
def test_custom_base_image(self, oot):
|
|
dockerfile = oot.generate_dockerfile(
|
|
git_url="https://github.com/tapparelj/gr-lora_sdr.git",
|
|
branch="main",
|
|
base_image="gnuradio-coverage:latest",
|
|
)
|
|
assert "FROM gnuradio-coverage:latest" in dockerfile
|
|
|
|
def test_no_extra_deps_no_trailing_space(self, oot):
|
|
dockerfile = oot.generate_dockerfile(
|
|
git_url="https://github.com/tapparelj/gr-lora_sdr.git",
|
|
branch="master",
|
|
base_image="gnuradio-runtime:latest",
|
|
)
|
|
# With no extra deps, the apt-get line should still work
|
|
assert "build-essential cmake git" in dockerfile
|
|
|
|
|
|
# ──────────────────────────────────────────
|
|
# Registry Persistence
|
|
# ──────────────────────────────────────────
|
|
|
|
|
|
class TestRegistry:
|
|
def test_empty_on_fresh_start(self, oot):
|
|
assert oot._registry == {}
|
|
assert oot.list_images() == []
|
|
|
|
def test_save_and_load_roundtrip(self, oot):
|
|
info = OOTImageInfo(
|
|
module_name="lora_sdr",
|
|
image_tag="gr-oot-lora_sdr:master-862746d",
|
|
git_url="https://github.com/tapparelj/gr-lora_sdr.git",
|
|
branch="master",
|
|
git_commit="862746d",
|
|
base_image="gnuradio-runtime:latest",
|
|
built_at="2025-01-01T00:00:00+00:00",
|
|
)
|
|
oot._registry["lora_sdr"] = info
|
|
oot._save_registry()
|
|
|
|
# Reload from disk
|
|
loaded = oot._load_registry()
|
|
assert "lora_sdr" in loaded
|
|
assert loaded["lora_sdr"].image_tag == "gr-oot-lora_sdr:master-862746d"
|
|
assert loaded["lora_sdr"].git_commit == "862746d"
|
|
|
|
def test_load_missing_file_returns_empty(self, tmp_path):
|
|
mw = OOTInstallerMiddleware(MagicMock())
|
|
mw._registry_path = tmp_path / "nonexistent" / "registry.json"
|
|
result = mw._load_registry()
|
|
assert result == {}
|
|
|
|
def test_load_corrupt_file_returns_empty(self, oot):
|
|
oot._registry_path.write_text("not valid json{{{")
|
|
result = oot._load_registry()
|
|
assert result == {}
|
|
|
|
def test_save_creates_parent_dirs(self, tmp_path):
|
|
mw = OOTInstallerMiddleware(MagicMock())
|
|
mw._registry_path = tmp_path / "nested" / "deep" / "registry.json"
|
|
mw._registry = {}
|
|
mw._save_registry()
|
|
assert mw._registry_path.exists()
|
|
|
|
def test_load_skips_corrupt_entries(self, oot):
|
|
"""Per-entry validation: one corrupt entry doesn't nuke valid ones."""
|
|
data = {
|
|
"good_module": {
|
|
"module_name": "good_module",
|
|
"image_tag": "gr-oot-good:main-abc1234",
|
|
"git_url": "https://example.com/gr-good",
|
|
"branch": "main",
|
|
"git_commit": "abc1234",
|
|
"base_image": "gnuradio-runtime:latest",
|
|
"built_at": "2025-01-01T00:00:00+00:00",
|
|
},
|
|
"bad_module": {
|
|
"image_id": "sha256:deadbeef",
|
|
"build_deps": ["libfoo-dev"],
|
|
},
|
|
}
|
|
oot._registry_path.write_text(json.dumps(data))
|
|
loaded = oot._load_registry()
|
|
assert "good_module" in loaded
|
|
assert "bad_module" not in loaded
|
|
|
|
def test_build_module_reregisters_orphaned_image(self, oot):
|
|
"""build_module re-registers when image exists but registry empty."""
|
|
# Mock: _get_remote_commit returns a commit, _image_exists says yes
|
|
oot._get_remote_commit = MagicMock(return_value="abc1234")
|
|
oot._image_exists = MagicMock(return_value=True)
|
|
|
|
result = oot.build_module(
|
|
git_url="https://github.com/example/gr-test",
|
|
branch="main",
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.skipped is True
|
|
assert "test" in oot._registry
|
|
assert oot._registry["test"].image_tag == "gr-oot-test:main-abc1234"
|
|
|
|
|
|
# ──────────────────────────────────────────
|
|
# Image Naming
|
|
# ──────────────────────────────────────────
|
|
|
|
|
|
class TestImageTagFormat:
|
|
def test_standard_format(self):
|
|
"""Image tags follow gr-oot-{name}:{branch}-{commit7}."""
|
|
# This verifies the format used in build_module()
|
|
module_name = "lora_sdr"
|
|
branch = "master"
|
|
commit = "862746d"
|
|
tag = f"gr-oot-{module_name}:{branch}-{commit}"
|
|
assert tag == "gr-oot-lora_sdr:master-862746d"
|
|
|
|
def test_different_branch(self):
|
|
tag = f"gr-oot-osmosdr:develop-abc1234"
|
|
assert "develop" in tag
|
|
assert "abc1234" in tag
|
|
|
|
|
|
# ──────────────────────────────────────────
|
|
# Remove Image
|
|
# ──────────────────────────────────────────
|
|
|
|
|
|
class TestRemoveImage:
|
|
def test_remove_existing(self, oot, mock_docker_client):
|
|
info = OOTImageInfo(
|
|
module_name="lora_sdr",
|
|
image_tag="gr-oot-lora_sdr:master-862746d",
|
|
git_url="https://github.com/tapparelj/gr-lora_sdr.git",
|
|
branch="master",
|
|
git_commit="862746d",
|
|
base_image="gnuradio-runtime:latest",
|
|
built_at="2025-01-01T00:00:00+00:00",
|
|
)
|
|
oot._registry["lora_sdr"] = info
|
|
|
|
result = oot.remove_image("lora_sdr")
|
|
assert result is True
|
|
assert "lora_sdr" not in oot._registry
|
|
mock_docker_client.images.remove.assert_called_once_with(
|
|
"gr-oot-lora_sdr:master-862746d", force=True
|
|
)
|
|
|
|
def test_remove_nonexistent(self, oot):
|
|
result = oot.remove_image("does_not_exist")
|
|
assert result is False
|
|
|
|
def test_remove_survives_docker_error(self, oot, mock_docker_client):
|
|
"""Registry entry is removed even if Docker image removal fails."""
|
|
info = OOTImageInfo(
|
|
module_name="lora_sdr",
|
|
image_tag="gr-oot-lora_sdr:master-862746d",
|
|
git_url="https://github.com/tapparelj/gr-lora_sdr.git",
|
|
branch="master",
|
|
git_commit="862746d",
|
|
base_image="gnuradio-runtime:latest",
|
|
built_at="2025-01-01T00:00:00+00:00",
|
|
)
|
|
oot._registry["lora_sdr"] = info
|
|
mock_docker_client.images.remove.side_effect = Exception("image not found")
|
|
|
|
result = oot.remove_image("lora_sdr")
|
|
assert result is True
|
|
assert "lora_sdr" not in oot._registry
|
|
|
|
|
|
# ──────────────────────────────────────────
|
|
# Combo Key Generation
|
|
# ──────────────────────────────────────────
|
|
|
|
|
|
class TestComboKeyGeneration:
|
|
def test_sorted_and_deduped(self):
|
|
key = OOTInstallerMiddleware._combo_key(["lora_sdr", "adsb", "lora_sdr"])
|
|
assert key == "combo:adsb+lora_sdr"
|
|
|
|
def test_alphabetical_order(self):
|
|
key = OOTInstallerMiddleware._combo_key(["osmosdr", "adsb", "lora_sdr"])
|
|
assert key == "combo:adsb+lora_sdr+osmosdr"
|
|
|
|
def test_single_module(self):
|
|
key = OOTInstallerMiddleware._combo_key(["adsb"])
|
|
assert key == "combo:adsb"
|
|
|
|
def test_two_modules(self):
|
|
key = OOTInstallerMiddleware._combo_key(["lora_sdr", "adsb"])
|
|
assert key == "combo:adsb+lora_sdr"
|
|
|
|
|
|
# ──────────────────────────────────────────
|
|
# Combo Image Tag
|
|
# ──────────────────────────────────────────
|
|
|
|
|
|
class TestComboImageTag:
|
|
def test_format(self):
|
|
tag = OOTInstallerMiddleware._combo_image_tag(["lora_sdr", "adsb"])
|
|
assert tag == "gr-combo-adsb-lora_sdr:latest"
|
|
|
|
def test_sorted_and_deduped(self):
|
|
tag = OOTInstallerMiddleware._combo_image_tag(
|
|
["osmosdr", "adsb", "osmosdr"]
|
|
)
|
|
assert tag == "gr-combo-adsb-osmosdr:latest"
|
|
|
|
def test_three_modules(self):
|
|
tag = OOTInstallerMiddleware._combo_image_tag(
|
|
["lora_sdr", "adsb", "osmosdr"]
|
|
)
|
|
assert tag == "gr-combo-adsb-lora_sdr-osmosdr:latest"
|
|
|
|
|
|
# ──────────────────────────────────────────
|
|
# Combo Dockerfile Generation
|
|
# ──────────────────────────────────────────
|
|
|
|
|
|
def _make_oot_info(name: str, tag: str) -> OOTImageInfo:
|
|
"""Helper to create a minimal OOTImageInfo for testing."""
|
|
return OOTImageInfo(
|
|
module_name=name,
|
|
image_tag=tag,
|
|
git_url=f"https://example.com/gr-{name}.git",
|
|
branch="main",
|
|
git_commit="abc1234",
|
|
base_image="gnuradio-runtime:latest",
|
|
built_at="2025-01-01T00:00:00+00:00",
|
|
)
|
|
|
|
|
|
class TestComboDockerfileGeneration:
|
|
def test_multi_stage_structure(self, oot):
|
|
oot._registry["adsb"] = _make_oot_info("adsb", "gr-oot-adsb:main-abc1234")
|
|
oot._registry["lora_sdr"] = _make_oot_info(
|
|
"lora_sdr", "gr-oot-lora_sdr:master-def5678"
|
|
)
|
|
|
|
dockerfile = oot.generate_combo_dockerfile(["lora_sdr", "adsb"])
|
|
|
|
# Stage aliases (sorted order: adsb first)
|
|
assert "FROM gr-oot-adsb:main-abc1234 AS stage_adsb" in dockerfile
|
|
assert "FROM gr-oot-lora_sdr:master-def5678 AS stage_lora_sdr" in dockerfile
|
|
|
|
# Final base image
|
|
assert "FROM gnuradio-runtime:latest" in dockerfile
|
|
|
|
# COPY directives for both modules
|
|
assert "COPY --from=stage_adsb /usr/lib/ /usr/lib/" in dockerfile
|
|
assert "COPY --from=stage_adsb /usr/include/ /usr/include/" in dockerfile
|
|
assert "COPY --from=stage_adsb /usr/share/gnuradio/ /usr/share/gnuradio/" in dockerfile
|
|
assert "COPY --from=stage_lora_sdr /usr/lib/ /usr/lib/" in dockerfile
|
|
assert "COPY --from=stage_lora_sdr /usr/include/ /usr/include/" in dockerfile
|
|
|
|
# Runtime setup
|
|
assert "RUN ldconfig" in dockerfile
|
|
assert "WORKDIR /flowgraphs" in dockerfile
|
|
assert "PYTHONPATH" in dockerfile
|
|
|
|
def test_missing_module_raises(self, oot):
|
|
oot._registry["adsb"] = _make_oot_info("adsb", "gr-oot-adsb:main-abc1234")
|
|
|
|
with pytest.raises(ValueError, match="lora_sdr"):
|
|
oot.generate_combo_dockerfile(["adsb", "lora_sdr"])
|
|
|
|
def test_uses_configured_base_image(self, mock_docker_client, tmp_path):
|
|
mw = OOTInstallerMiddleware(mock_docker_client, base_image="my-custom:v2")
|
|
mw._registry_path = tmp_path / "oot-registry.json"
|
|
mw._registry = {
|
|
"adsb": _make_oot_info("adsb", "gr-oot-adsb:main-abc1234"),
|
|
"lora_sdr": _make_oot_info("lora_sdr", "gr-oot-lora_sdr:main-def5678"),
|
|
}
|
|
mw._combo_registry_path = tmp_path / "oot-combo-registry.json"
|
|
mw._combo_registry = {}
|
|
|
|
dockerfile = mw.generate_combo_dockerfile(["adsb", "lora_sdr"])
|
|
assert "FROM my-custom:v2" in dockerfile
|
|
|
|
|
|
# ──────────────────────────────────────────
|
|
# Combo Registry Persistence
|
|
# ──────────────────────────────────────────
|
|
|
|
|
|
class TestComboRegistry:
|
|
def test_separate_file(self, oot):
|
|
"""Combo registry uses a different file from single-OOT registry."""
|
|
assert oot._combo_registry_path != oot._registry_path
|
|
assert "combo" in str(oot._combo_registry_path)
|
|
|
|
def test_empty_on_fresh_start(self, oot):
|
|
assert oot._combo_registry == {}
|
|
assert oot.list_combo_images() == []
|
|
|
|
def test_save_and_load_roundtrip(self, oot):
|
|
info = ComboImageInfo(
|
|
combo_key="combo:adsb+lora_sdr",
|
|
image_tag="gr-combo-adsb-lora_sdr:latest",
|
|
modules=[
|
|
_make_oot_info("adsb", "gr-oot-adsb:main-abc1234"),
|
|
_make_oot_info("lora_sdr", "gr-oot-lora_sdr:master-def5678"),
|
|
],
|
|
built_at="2025-01-01T00:00:00+00:00",
|
|
)
|
|
oot._combo_registry["combo:adsb+lora_sdr"] = info
|
|
oot._save_combo_registry()
|
|
|
|
loaded = oot._load_combo_registry()
|
|
assert "combo:adsb+lora_sdr" in loaded
|
|
assert loaded["combo:adsb+lora_sdr"].image_tag == "gr-combo-adsb-lora_sdr:latest"
|
|
assert len(loaded["combo:adsb+lora_sdr"].modules) == 2
|
|
|
|
def test_load_missing_file_returns_empty(self, oot):
|
|
oot._combo_registry_path = oot._combo_registry_path.parent / "nope" / "r.json"
|
|
result = oot._load_combo_registry()
|
|
assert result == {}
|
|
|
|
def test_load_corrupt_file_returns_empty(self, oot):
|
|
oot._combo_registry_path.write_text("broken{{{")
|
|
result = oot._load_combo_registry()
|
|
assert result == {}
|
|
|
|
def test_list_returns_values(self, oot):
|
|
info = ComboImageInfo(
|
|
combo_key="combo:adsb+lora_sdr",
|
|
image_tag="gr-combo-adsb-lora_sdr:latest",
|
|
modules=[],
|
|
built_at="2025-01-01T00:00:00+00:00",
|
|
)
|
|
oot._combo_registry["combo:adsb+lora_sdr"] = info
|
|
result = oot.list_combo_images()
|
|
assert len(result) == 1
|
|
assert result[0].combo_key == "combo:adsb+lora_sdr"
|
|
|
|
def test_remove_existing(self, oot, mock_docker_client):
|
|
info = ComboImageInfo(
|
|
combo_key="combo:adsb+lora_sdr",
|
|
image_tag="gr-combo-adsb-lora_sdr:latest",
|
|
modules=[],
|
|
built_at="2025-01-01T00:00:00+00:00",
|
|
)
|
|
oot._combo_registry["combo:adsb+lora_sdr"] = info
|
|
|
|
result = oot.remove_combo_image("combo:adsb+lora_sdr")
|
|
assert result is True
|
|
assert "combo:adsb+lora_sdr" not in oot._combo_registry
|
|
mock_docker_client.images.remove.assert_called_once_with(
|
|
"gr-combo-adsb-lora_sdr:latest", force=True
|
|
)
|
|
|
|
def test_remove_nonexistent(self, oot):
|
|
result = oot.remove_combo_image("combo:nope+nada")
|
|
assert result is False
|
|
|
|
def test_remove_survives_docker_error(self, oot, mock_docker_client):
|
|
info = ComboImageInfo(
|
|
combo_key="combo:adsb+lora_sdr",
|
|
image_tag="gr-combo-adsb-lora_sdr:latest",
|
|
modules=[],
|
|
built_at="2025-01-01T00:00:00+00:00",
|
|
)
|
|
oot._combo_registry["combo:adsb+lora_sdr"] = info
|
|
mock_docker_client.images.remove.side_effect = Exception("gone")
|
|
|
|
result = oot.remove_combo_image("combo:adsb+lora_sdr")
|
|
assert result is True
|
|
assert "combo:adsb+lora_sdr" not in oot._combo_registry
|
|
|
|
|
|
# ──────────────────────────────────────────
|
|
# Build Combo Image
|
|
# ──────────────────────────────────────────
|
|
|
|
|
|
class TestBuildComboImage:
|
|
def test_requires_at_least_two_modules(self, oot):
|
|
result = oot.build_combo_image(["adsb"])
|
|
assert result.success is False
|
|
assert "2 distinct" in result.error
|
|
|
|
def test_rejects_duplicate_as_single(self, oot):
|
|
result = oot.build_combo_image(["adsb", "adsb"])
|
|
assert result.success is False
|
|
assert "2 distinct" in result.error
|
|
|
|
def test_idempotent_skip(self, oot, mock_docker_client):
|
|
"""Skips build if combo image already exists."""
|
|
oot._registry["adsb"] = _make_oot_info("adsb", "gr-oot-adsb:main-abc")
|
|
oot._registry["lora_sdr"] = _make_oot_info("lora_sdr", "gr-oot-lora:m-def")
|
|
|
|
existing = ComboImageInfo(
|
|
combo_key="combo:adsb+lora_sdr",
|
|
image_tag="gr-combo-adsb-lora_sdr:latest",
|
|
modules=[],
|
|
built_at="2025-01-01T00:00:00+00:00",
|
|
)
|
|
oot._combo_registry["combo:adsb+lora_sdr"] = existing
|
|
|
|
# Docker image exists
|
|
mock_docker_client.images.get.return_value = MagicMock()
|
|
|
|
result = oot.build_combo_image(["lora_sdr", "adsb"])
|
|
assert result.success is True
|
|
assert result.skipped is True
|
|
|
|
def test_happy_path(self, oot, mock_docker_client):
|
|
"""Builds combo from pre-existing single-OOT images."""
|
|
oot._registry["adsb"] = _make_oot_info("adsb", "gr-oot-adsb:main-abc1234")
|
|
oot._registry["lora_sdr"] = _make_oot_info(
|
|
"lora_sdr", "gr-oot-lora_sdr:master-def5678"
|
|
)
|
|
|
|
# Docker image does not exist yet
|
|
mock_docker_client.images.get.side_effect = Exception("not found")
|
|
# Mock successful build
|
|
mock_docker_client.images.build.return_value = (
|
|
MagicMock(),
|
|
[{"stream": "Step 1/5 : FROM ...\n"}],
|
|
)
|
|
|
|
result = oot.build_combo_image(["adsb", "lora_sdr"])
|
|
assert result.success is True
|
|
assert result.skipped is False
|
|
assert result.image is not None
|
|
assert result.image.combo_key == "combo:adsb+lora_sdr"
|
|
assert result.image.image_tag == "gr-combo-adsb-lora_sdr:latest"
|
|
assert len(result.image.modules) == 2
|
|
assert result.modules_built == []
|
|
|
|
# Verify persisted to combo registry
|
|
assert "combo:adsb+lora_sdr" in oot._combo_registry
|
|
|
|
def test_unknown_module_not_in_catalog(self, oot):
|
|
"""Fails if module not in registry and not in catalog."""
|
|
oot._registry["adsb"] = _make_oot_info("adsb", "gr-oot-adsb:main-abc1234")
|
|
|
|
result = oot.build_combo_image(["adsb", "totally_fake_module"])
|
|
assert result.success is False
|
|
assert "totally_fake_module" in result.error
|
|
assert "not found in the catalog" in result.error
|
|
|
|
def test_force_rebuilds(self, oot, mock_docker_client):
|
|
"""force=True bypasses idempotency check."""
|
|
oot._registry["adsb"] = _make_oot_info("adsb", "gr-oot-adsb:main-abc")
|
|
oot._registry["lora_sdr"] = _make_oot_info("lora_sdr", "gr-oot-lora:m-def")
|
|
|
|
existing = ComboImageInfo(
|
|
combo_key="combo:adsb+lora_sdr",
|
|
image_tag="gr-combo-adsb-lora_sdr:latest",
|
|
modules=[],
|
|
built_at="2025-01-01T00:00:00+00:00",
|
|
)
|
|
oot._combo_registry["combo:adsb+lora_sdr"] = existing
|
|
|
|
# Docker image exists
|
|
mock_docker_client.images.get.return_value = MagicMock()
|
|
mock_docker_client.images.build.return_value = (
|
|
MagicMock(),
|
|
[{"stream": "rebuilt\n"}],
|
|
)
|
|
|
|
result = oot.build_combo_image(["adsb", "lora_sdr"], force=True)
|
|
assert result.success is True
|
|
assert result.skipped is False
|
|
|
|
|
|
# ──────────────────────────────────────────
|
|
# OOT Detection from Python Files
|
|
# ──────────────────────────────────────────
|
|
|
|
|
|
class TestDetectFromPython:
|
|
def test_detects_gnuradio_import(self, oot, tmp_path):
|
|
"""Detects 'import gnuradio.MODULE' pattern."""
|
|
py_file = tmp_path / "test.py"
|
|
py_file.write_text(
|
|
"import gnuradio.lora_sdr as lora_sdr\n"
|
|
"from gnuradio import blocks\n"
|
|
)
|
|
result = oot.detect_required_modules(str(py_file))
|
|
assert result.detected_modules == ["lora_sdr"]
|
|
assert result.detection_method == "python_imports"
|
|
|
|
def test_detects_from_gnuradio_import(self, oot, tmp_path):
|
|
"""Detects 'from gnuradio import MODULE' pattern."""
|
|
py_file = tmp_path / "test.py"
|
|
py_file.write_text("from gnuradio import adsb, blocks, analog\n")
|
|
result = oot.detect_required_modules(str(py_file))
|
|
assert result.detected_modules == ["adsb"]
|
|
|
|
def test_detects_multiple_modules(self, oot, tmp_path):
|
|
"""Detects multiple OOT modules in one file."""
|
|
py_file = tmp_path / "test.py"
|
|
py_file.write_text(
|
|
"import gnuradio.lora_sdr as lora_sdr\n"
|
|
"from gnuradio import adsb\n"
|
|
)
|
|
result = oot.detect_required_modules(str(py_file))
|
|
assert result.detected_modules == ["adsb", "lora_sdr"]
|
|
|
|
def test_detects_toplevel_osmosdr(self, oot, tmp_path):
|
|
"""Detects top-level 'import osmosdr' (not in gnuradio namespace)."""
|
|
py_file = tmp_path / "test.py"
|
|
py_file.write_text("import osmosdr\n")
|
|
result = oot.detect_required_modules(str(py_file))
|
|
assert "osmosdr" in result.detected_modules
|
|
|
|
def test_detects_from_toplevel_import(self, oot, tmp_path):
|
|
"""Detects 'from MODULE import ...' for top-level OOT."""
|
|
py_file = tmp_path / "test.py"
|
|
py_file.write_text("from osmosdr import source\n")
|
|
result = oot.detect_required_modules(str(py_file))
|
|
assert "osmosdr" in result.detected_modules
|
|
|
|
def test_ignores_core_modules(self, oot, tmp_path):
|
|
"""Ignores core GNU Radio modules not in catalog."""
|
|
py_file = tmp_path / "test.py"
|
|
py_file.write_text(
|
|
"from gnuradio import blocks, analog, gr, digital, filter\n"
|
|
"import numpy\n"
|
|
)
|
|
result = oot.detect_required_modules(str(py_file))
|
|
assert result.detected_modules == []
|
|
|
|
def test_no_oot_returns_empty(self, oot, tmp_path):
|
|
"""Returns empty list when no OOT imports found."""
|
|
py_file = tmp_path / "test.py"
|
|
py_file.write_text("from gnuradio import blocks\nprint('hello')\n")
|
|
result = oot.detect_required_modules(str(py_file))
|
|
assert result.detected_modules == []
|
|
|
|
|
|
# ──────────────────────────────────────────
|
|
# OOT Detection from GRC Files
|
|
# ──────────────────────────────────────────
|
|
|
|
|
|
class TestDetectFromGrc:
|
|
def test_detects_prefixed_blocks(self, oot, tmp_path):
|
|
"""Detects blocks with OOT module prefix."""
|
|
grc_file = tmp_path / "test.grc"
|
|
grc_file.write_text(
|
|
"""
|
|
blocks:
|
|
- id: lora_sdr_gray_demap
|
|
name: gray0
|
|
- id: adsb_demod
|
|
name: demod0
|
|
- id: blocks_null_sink
|
|
name: null0
|
|
"""
|
|
)
|
|
result = oot.detect_required_modules(str(grc_file))
|
|
assert result.detected_modules == ["adsb", "lora_sdr"]
|
|
assert result.detection_method == "grc_prefix_heuristic"
|
|
|
|
def test_detects_exact_match_blocks(self, oot, tmp_path):
|
|
"""Detects blocks that exactly match module name."""
|
|
grc_file = tmp_path / "test.grc"
|
|
grc_file.write_text(
|
|
"""
|
|
blocks:
|
|
- id: osmosdr_source
|
|
name: src0
|
|
"""
|
|
)
|
|
result = oot.detect_required_modules(str(grc_file))
|
|
assert "osmosdr" in result.detected_modules
|
|
|
|
def test_reports_unknown_blocks(self, oot, tmp_path):
|
|
"""Reports blocks that look OOT but aren't in catalog."""
|
|
grc_file = tmp_path / "test.grc"
|
|
grc_file.write_text(
|
|
"""
|
|
blocks:
|
|
- id: unknown_module_block
|
|
name: blk0
|
|
- id: another_weird_thing
|
|
name: blk1
|
|
"""
|
|
)
|
|
result = oot.detect_required_modules(str(grc_file))
|
|
assert "unknown_module_block" in result.unknown_blocks
|
|
assert "another_weird_thing" in result.unknown_blocks
|
|
|
|
def test_ignores_core_blocks(self, oot, tmp_path):
|
|
"""Ignores core GNU Radio blocks."""
|
|
grc_file = tmp_path / "test.grc"
|
|
grc_file.write_text(
|
|
"""
|
|
blocks:
|
|
- id: blocks_null_sink
|
|
name: null0
|
|
- id: analog_sig_source_x
|
|
name: src0
|
|
- id: digital_constellation_decoder_cb
|
|
name: dec0
|
|
- id: qtgui_time_sink_x
|
|
name: time0
|
|
- id: filter_fir_filter_xxx
|
|
name: fir0
|
|
"""
|
|
)
|
|
result = oot.detect_required_modules(str(grc_file))
|
|
assert result.detected_modules == []
|
|
assert result.unknown_blocks == []
|
|
|
|
def test_ignores_special_blocks(self, oot, tmp_path):
|
|
"""Ignores variables, imports, and other special blocks."""
|
|
grc_file = tmp_path / "test.grc"
|
|
grc_file.write_text(
|
|
"""
|
|
blocks:
|
|
- id: variable
|
|
name: samp_rate
|
|
- id: variable_qtgui_range
|
|
name: freq
|
|
- id: import
|
|
name: import_0
|
|
- id: options
|
|
name: opts
|
|
- id: pad_source
|
|
name: in0
|
|
- id: virtual_sink
|
|
name: vsink0
|
|
"""
|
|
)
|
|
result = oot.detect_required_modules(str(grc_file))
|
|
assert result.detected_modules == []
|
|
assert result.unknown_blocks == []
|
|
|
|
def test_handles_empty_blocks(self, oot, tmp_path):
|
|
"""Handles GRC with no blocks."""
|
|
grc_file = tmp_path / "test.grc"
|
|
grc_file.write_text("blocks: []\n")
|
|
result = oot.detect_required_modules(str(grc_file))
|
|
assert result.detected_modules == []
|
|
|
|
def test_handles_missing_blocks_key(self, oot, tmp_path):
|
|
"""Handles GRC without blocks key."""
|
|
grc_file = tmp_path / "test.grc"
|
|
grc_file.write_text("options:\n id: test\n")
|
|
result = oot.detect_required_modules(str(grc_file))
|
|
assert result.detected_modules == []
|
|
|
|
|
|
# ──────────────────────────────────────────
|
|
# Image Recommendation Logic
|
|
# ──────────────────────────────────────────
|
|
|
|
|
|
class TestRecommendImage:
|
|
def test_recommends_base_for_no_modules(self, oot):
|
|
"""Returns base runtime image when no OOT modules."""
|
|
result = oot._recommend_image([])
|
|
assert result == "gnuradio-runtime:latest"
|
|
|
|
def test_recommends_single_oot_image_if_built(self, oot):
|
|
"""Returns single OOT image tag if already built."""
|
|
oot._registry["lora_sdr"] = _make_oot_info(
|
|
"lora_sdr", "gr-oot-lora_sdr:master-abc1234"
|
|
)
|
|
result = oot._recommend_image(["lora_sdr"])
|
|
assert result == "gr-oot-lora_sdr:master-abc1234"
|
|
|
|
def test_returns_none_if_single_not_built(self, oot):
|
|
"""Returns None if single module not yet built."""
|
|
result = oot._recommend_image(["lora_sdr"])
|
|
assert result is None
|
|
|
|
def test_recommends_combo_tag_if_exists(self, oot):
|
|
"""Returns existing combo image tag."""
|
|
combo_info = ComboImageInfo(
|
|
combo_key="combo:adsb+lora_sdr",
|
|
image_tag="gr-combo-adsb-lora_sdr:latest",
|
|
modules=[],
|
|
built_at="2025-01-01T00:00:00+00:00",
|
|
)
|
|
oot._combo_registry["combo:adsb+lora_sdr"] = combo_info
|
|
result = oot._recommend_image(["adsb", "lora_sdr"])
|
|
assert result == "gr-combo-adsb-lora_sdr:latest"
|
|
|
|
def test_recommends_combo_tag_format_if_not_built(self, oot):
|
|
"""Returns what combo tag would be if not yet built."""
|
|
result = oot._recommend_image(["adsb", "lora_sdr"])
|
|
assert result == "gr-combo-adsb-lora_sdr:latest"
|
|
|
|
def test_combo_tag_sorted_alphabetically(self, oot):
|
|
"""Combo tag always uses sorted module names."""
|
|
result = oot._recommend_image(["lora_sdr", "adsb"])
|
|
assert result == "gr-combo-adsb-lora_sdr:latest"
|
|
|
|
|
|
# ──────────────────────────────────────────
|
|
# Edge Cases and Error Handling
|
|
# ──────────────────────────────────────────
|
|
|
|
|
|
class TestDetectionEdgeCases:
|
|
def test_file_not_found(self, oot, tmp_path):
|
|
"""Raises FileNotFoundError for missing file."""
|
|
with pytest.raises(FileNotFoundError, match="Flowgraph not found"):
|
|
oot.detect_required_modules(str(tmp_path / "does_not_exist.py"))
|
|
|
|
def test_unsupported_extension(self, oot, tmp_path):
|
|
"""Raises ValueError for unsupported file type."""
|
|
txt_file = tmp_path / "test.txt"
|
|
txt_file.write_text("some content")
|
|
with pytest.raises(ValueError, match="Unsupported file type"):
|
|
oot.detect_required_modules(str(txt_file))
|
|
|
|
def test_result_includes_flowgraph_path(self, oot, tmp_path):
|
|
"""Result includes the analyzed path."""
|
|
py_file = tmp_path / "my_flowgraph.py"
|
|
py_file.write_text("from gnuradio import blocks\n")
|
|
result = oot.detect_required_modules(str(py_file))
|
|
assert str(py_file) in result.flowgraph_path
|