gr-mcp/tests/integration/test_fm_scanner.py
Ryan Malloy fdcbffba1a tests: add integration tests for runtime middleware
Add comprehensive integration tests that don't require Docker:

- test_xmlrpc_subprocess.py: Tests XmlRpcMiddleware against a real
  subprocess-based XML-RPC server. Covers connection, variable
  discovery, get/set operations, and flowgraph control (18 tests).

- test_mcp_runtime.py: Tests MCP runtime tools via FastMCP Client.
  Verifies connect, disconnect, list_variables, get_variable,
  set_variable, and flowgraph control work end-to-end (15 tests).

- test_fm_scanner.py: Tests signal probe functionality and FM scanner
  parsing. Includes unit tests for helper functions and integration
  tests for flowgraph construction with signal probe blocks (18 tests).

All 59 integration tests pass. These tests provide faster feedback
than Docker-based tests while still exercising real XML-RPC
communication and flowgraph construction.
2026-01-29 05:14:29 -07:00

307 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Integration tests for FM scanner signal probe functionality.
Tests the programmatic flowgraph construction and signal probe features
added to the FM scanner. Requires GNU Radio but not RTL-SDR hardware.
Run with: pytest tests/integration/test_fm_scanner.py -v
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
# Add examples to path so we can import fm_scanner
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "examples"))
# Check if GNU Radio is available
try:
from gnuradio import gr
GNURADIO_AVAILABLE = True
except ImportError:
GNURADIO_AVAILABLE = False
class TestSignalProbeHelpers:
"""Unit tests for signal probe helper functions (no GNU Radio needed)."""
def test_mag_squared_to_dbm_normal(self):
"""Test dB conversion for normal values."""
from fm_scanner import mag_squared_to_dbm
# 1.0 → 0 dB
assert mag_squared_to_dbm(1.0) == 0.0
# 0.1 → -10 dB
assert abs(mag_squared_to_dbm(0.1) - (-10.0)) < 0.01
# 0.01 → -20 dB
assert abs(mag_squared_to_dbm(0.01) - (-20.0)) < 0.01
# 0.001 → -30 dB
assert abs(mag_squared_to_dbm(0.001) - (-30.0)) < 0.01
def test_mag_squared_to_dbm_zero(self):
"""Test dB conversion handles zero gracefully."""
from fm_scanner import mag_squared_to_dbm
# Zero should return floor value, not crash
result = mag_squared_to_dbm(0.0)
assert result == -100.0
def test_mag_squared_to_dbm_negative(self):
"""Test dB conversion handles negative values (shouldn't happen but be safe)."""
from fm_scanner import mag_squared_to_dbm
result = mag_squared_to_dbm(-1.0)
assert result == -100.0
def test_format_signal_bar_strong(self):
"""Test signal bar formatting for strong signals."""
from fm_scanner import format_signal_bar
bar = format_signal_bar(-30.0, width=20)
# Should be mostly filled and green
assert "" in bar
assert "\033[32m" in bar # green color code
def test_format_signal_bar_medium(self):
"""Test signal bar formatting for medium signals."""
from fm_scanner import format_signal_bar
bar = format_signal_bar(-50.0, width=20)
# Should be yellow
assert "\033[33m" in bar # yellow color code
def test_format_signal_bar_weak(self):
"""Test signal bar formatting for weak signals."""
from fm_scanner import format_signal_bar
bar = format_signal_bar(-70.0, width=20)
# Should be red (weak signal)
assert "\033[31m" in bar # red color code
def test_format_signal_bar_empty(self):
"""Test signal bar formatting at floor."""
from fm_scanner import format_signal_bar
bar = format_signal_bar(-80.0, width=20)
# At -80 dB, should be empty (only unfilled blocks)
assert "" in bar
class TestScannerParsing:
"""Unit tests for scan data parsing."""
def test_parse_scan_valid_csv(self):
"""Test parsing valid rtl_power CSV output."""
from fm_scanner import parse_scan
csv_data = """\
2025-01-01, 12:00:00, 87500000, 87700000, 100000, 1, -45.2, -47.1
2025-01-01, 12:00:01, 87700000, 87900000, 100000, 1, -52.3, -51.8
"""
readings = parse_scan(csv_data)
# Should have 4 readings (2 bins per row × 2 rows)
assert len(readings) == 4
# Check first reading
freq_mhz, power_dbm = readings[0]
assert 87.5 <= freq_mhz <= 87.6 # First bin
assert power_dbm == -45.2
def test_parse_scan_empty(self):
"""Test parsing empty CSV."""
from fm_scanner import parse_scan
readings = parse_scan("")
assert readings == []
def test_parse_scan_malformed(self):
"""Test parsing malformed CSV (should skip bad rows)."""
from fm_scanner import parse_scan
csv_data = """\
bad data
2025-01-01, 12:00:00, 87500000, 87700000, 100000, 1, -45.2
more bad data
"""
readings = parse_scan(csv_data)
# Should parse the valid row (1 bin)
assert len(readings) == 1
def test_aggregate_channels(self):
"""Test channel aggregation snaps to FM channels."""
from fm_scanner import aggregate_channels
# Readings around 101.1 MHz
readings = [
(101.05, -35.0),
(101.10, -30.0),
(101.15, -32.0),
]
channels = aggregate_channels(readings)
# Should aggregate to one channel around 101.0-101.2
assert len(channels) >= 1
# Find the 101.0 channel
ch101 = next((c for c in channels if 100.9 <= c["freq_mhz"] <= 101.3), None)
assert ch101 is not None
# Max power should be used
assert ch101["power_dbm"] == -30.0
def test_detect_stations(self):
"""Test station detection above noise floor."""
from fm_scanner import detect_stations
channels = [
{"freq_mhz": 88.1, "power_dbm": -50.0}, # noise
{"freq_mhz": 91.5, "power_dbm": -30.0}, # station!
{"freq_mhz": 93.3, "power_dbm": -48.0}, # noise
{"freq_mhz": 101.1, "power_dbm": -25.0}, # station!
{"freq_mhz": 105.5, "power_dbm": -52.0}, # noise
]
stations, noise_floor = detect_stations(channels, threshold_db=10.0)
# Median of [-50, -30, -48, -25, -52] = -48
assert -50 < noise_floor < -45
# Should detect 2 stations (>10 dB above noise)
assert len(stations) == 2
# Strongest should be first
assert stations[0]["freq_mhz"] == 101.1
assert stations[1]["freq_mhz"] == 91.5
@pytest.mark.skipif(not GNURADIO_AVAILABLE, reason="GNU Radio not available")
class TestFlowgraphConstruction:
"""Integration tests for flowgraph construction with signal probe."""
def test_build_fm_receiver_creates_grc(self):
"""Test that build_fm_receiver creates a valid .grc file."""
from fm_scanner import build_fm_receiver
py_path = build_fm_receiver(101.1, gain=10)
# Should return a path to a Python file
assert py_path.exists()
assert py_path.suffix == ".py"
# Read and verify it contains expected components
py_code = py_path.read_text()
# Should have XML-RPC server
assert "SimpleXMLRPCServer" in py_code
# Should have signal probe (analog_probe_avg_mag_sqrd)
assert "probe_avg_mag_sqrd" in py_code
# Should have signal_level variable
assert "signal_level" in py_code
# Should have freq variable
assert "freq" in py_code
# Should have get_signal_level method
assert "get_signal_level" in py_code
def test_build_fm_receiver_has_signal_probe_block(self):
"""Verify the flowgraph includes the signal probe block."""
from gnuradio.grc.core.platform import Platform
from gnuradio import gr
# Initialize platform
platform = Platform(
version=gr.version(),
version_parts=(gr.major_version(), gr.api_version(), gr.minor_version()),
prefs=gr.prefs(),
)
platform.build_library()
# Verify the probe block type exists
block_keys = list(platform.blocks.keys())
assert "analog_probe_avg_mag_sqrd_x" in block_keys
def test_build_fm_receiver_has_function_probe_block(self):
"""Verify the flowgraph includes the variable function probe block."""
from gnuradio.grc.core.platform import Platform
from gnuradio import gr
# Initialize platform
platform = Platform(
version=gr.version(),
version_parts=(gr.major_version(), gr.api_version(), gr.minor_version()),
prefs=gr.prefs(),
)
platform.build_library()
# Verify the function probe block type exists
block_keys = list(platform.blocks.keys())
assert "variable_function_probe" in block_keys
def test_flowgraph_compiled_structure(self):
"""Verify the compiled flowgraph has correct structure."""
from fm_scanner import build_fm_receiver
import ast
py_path = build_fm_receiver(98.5, gain=20)
py_code = py_path.read_text()
# Parse as AST to verify structure
tree = ast.parse(py_code)
# Find the class definition
class_defs = [node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)]
assert len(class_defs) >= 1
# Find method definitions
fm_class = class_defs[0]
method_names = [
node.name for node in fm_class.body if isinstance(node, ast.FunctionDef)
]
# Should have get/set methods for freq and signal_level
assert "get_freq" in method_names
assert "set_freq" in method_names
assert "get_signal_level" in method_names
assert "set_signal_level" in method_names
@pytest.mark.skipif(not GNURADIO_AVAILABLE, reason="GNU Radio not available")
class TestSignalProbeIntegration:
"""Tests for signal probe XML-RPC integration (requires GNU Radio)."""
def test_compiled_flowgraph_has_xmlrpc(self):
"""Verify compiled flowgraph has XML-RPC server setup."""
from fm_scanner import build_fm_receiver
py_path = build_fm_receiver(107.2, gain=15)
py_code = py_path.read_text()
# Should configure XML-RPC on port 8090
assert "8090" in py_code
assert "0.0.0.0" in py_code or "''" in py_code # Bind address
def test_signal_probe_connection(self):
"""Verify signal probe is connected to the LPF output."""
from fm_scanner import build_fm_receiver
py_path = build_fm_receiver(101.1, gain=10)
py_code = py_path.read_text()
# The probe should be connected (look for connection pattern)
# In generated code, connections are made via self.connect()
assert "signal_probe" in py_code
# The probe should sample from the low pass filter
assert "low_pass_filter" in py_code