"""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