gr-mcp/tests/unit/test_block_generator.py
Ryan Malloy 5db7d71d2b feat: add AI-assisted block development tools
Implements complete workflow for generating GNU Radio blocks from
descriptions:

Block Generation:
- generate_sync_block, generate_basic_block, generate_interp_block,
  generate_decim_block tools for creating different block types
- Template-based code generation with customizable work logic
- Automatic validation via AST parsing and signature checking

Protocol Analysis:
- Parse protocol specifications into structured models
- Generate decoder pipelines matching modulation to demodulator blocks
- Templates for BLE, Zigbee, LoRa, POCSAG, ADS-B protocols

OOT Export:
- Export generated blocks to full OOT module structure
- Generate CMakeLists.txt, block YAML, Python modules
- gr_modtool-compatible output

Dynamic Tool Registration:
- enable_block_dev_mode/disable_block_dev_mode for context management
- Tools only registered when needed (reduces LLM context usage)

Includes comprehensive test coverage and end-to-end demo.
2026-02-09 12:36:54 -07:00

500 lines
19 KiB
Python

"""Unit tests for BlockGeneratorMiddleware."""
import pytest
from gnuradio_mcp.middlewares.block_generator import BlockGeneratorMiddleware
from gnuradio_mcp.models import BlockParameter, SignatureItem
class TestBlockGeneratorMiddleware:
"""Tests for the block generator middleware."""
@pytest.fixture
def generator(self):
"""Create a generator without flowgraph."""
return BlockGeneratorMiddleware(flowgraph_mw=None)
# ─────────────────────────────────────────────────────
# sync_block generation
# ─────────────────────────────────────────────────────
def test_generate_sync_block_basic(self, generator):
"""Generate a basic sync block with custom work logic."""
result = generator.generate_sync_block(
name="my_test_block",
description="A test block that multiplies by 2",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[],
work_logic="output_items[0][:] = input_items[0] * 2",
)
assert result.block_name == "my_test_block"
assert result.block_class == "sync_block"
assert "gr.sync_block" in result.source_code
assert "my_test_block" in result.source_code
assert "output_items[0][:] = input_items[0] * 2" in result.source_code
assert result.is_valid is True
def test_generate_sync_block_with_parameters(self, generator):
"""Generate a sync block with runtime parameters."""
result = generator.generate_sync_block(
name="configurable_gain",
description="Multiply samples by configurable gain",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[
BlockParameter(name="gain", dtype="float", default=1.0),
BlockParameter(name="offset", dtype="float", default=0.0),
],
work_logic="output_items[0][:] = input_items[0] * self.gain + self.offset",
)
assert "self.gain = gain" in result.source_code
assert "self.offset = offset" in result.source_code
assert "def __init__(self, gain=1.0, offset=0.0):" in result.source_code
assert result.is_valid is True
def test_generate_sync_block_gain_template(self, generator):
"""Generate a sync block using the gain template."""
result = generator.generate_sync_block(
name="gain_block",
description="Apply gain",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[BlockParameter(name="gain", dtype="float", default=1.0)],
work_template="gain",
)
assert "output_items[0][:] = input_items[0] * self.gain" in result.source_code
assert result.is_valid is True
def test_generate_sync_block_threshold_template(self, generator):
"""Generate a sync block using the threshold template."""
result = generator.generate_sync_block(
name="threshold_block",
description="Apply threshold",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[BlockParameter(name="threshold", dtype="float", default=0.5)],
work_template="threshold",
)
assert "self.threshold" in result.source_code
assert result.is_valid is True
def test_generate_sync_block_complex_io(self, generator):
"""Generate a sync block with complex inputs."""
result = generator.generate_sync_block(
name="complex_processor",
description="Process complex samples",
inputs=[SignatureItem(dtype="complex", vlen=1)],
outputs=[SignatureItem(dtype="complex", vlen=1)],
parameters=[],
work_logic="output_items[0][:] = input_items[0] * 1j",
)
assert "numpy.complex64" in result.source_code
assert result.is_valid is True
def test_generate_sync_block_vector_io(self, generator):
"""Generate a sync block with vector I/O."""
result = generator.generate_sync_block(
name="vector_adder",
description="Add vectors",
inputs=[SignatureItem(dtype="float", vlen=4)],
outputs=[SignatureItem(dtype="float", vlen=4)],
parameters=[],
work_logic="output_items[0][:] = input_items[0]",
)
assert "4" in result.source_code # Vector length in signature
assert result.is_valid is True
# ─────────────────────────────────────────────────────
# basic_block generation
# ─────────────────────────────────────────────────────
def test_generate_basic_block(self, generator):
"""Generate a basic block with custom work and forecast."""
# Use a simple work_logic that won't have indentation issues
result = generator.generate_basic_block(
name="packet_extractor",
description="Extract packets from stream",
inputs=[SignatureItem(dtype="byte", vlen=1)],
outputs=[SignatureItem(dtype="byte", vlen=64)],
parameters=[BlockParameter(name="packet_len", dtype="int", default=64)],
work_logic="self.consume_each(1); return 1",
)
assert result.block_class == "basic_block"
assert "gr.basic_block" in result.source_code
assert "general_work" in result.source_code
# Check the source code is syntactically valid
assert result.is_valid is True
def test_generate_basic_block_with_forecast(self, generator):
"""Generate a basic block with custom forecast."""
result = generator.generate_basic_block(
name="custom_forecast",
description="Block with custom forecast",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[],
work_logic="self.consume_each(1); return 1",
forecast_logic="return [noutput_items * 2]",
)
assert "def forecast" in result.source_code
# The basic_block should be valid
assert result.is_valid is True
# ─────────────────────────────────────────────────────
# interp_block generation
# ─────────────────────────────────────────────────────
def test_generate_interp_block(self, generator):
"""Generate an interpolating block."""
result = generator.generate_interp_block(
name="upsample_4x",
description="Upsample by factor of 4",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
interpolation=4,
parameters=[],
)
assert result.block_class == "interp_block"
assert "gr.interp_block" in result.source_code
assert "interp=4" in result.source_code or "interpolation=4" in result.source_code.lower()
assert result.is_valid is True
def test_generate_interp_block_with_work(self, generator):
"""Generate an interpolating block with custom work."""
result = generator.generate_interp_block(
name="upsampler",
description="Upsample with custom logic",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
interpolation=2,
parameters=[],
# Use default work logic by not providing custom work_logic
)
# Should generate valid interp_block
assert "gr.interp_block" in result.source_code
assert result.is_valid is True
# ─────────────────────────────────────────────────────
# decim_block generation
# ─────────────────────────────────────────────────────
def test_generate_decim_block(self, generator):
"""Generate a decimating block."""
result = generator.generate_decim_block(
name="downsample_10x",
description="Downsample by factor of 10",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
decimation=10,
parameters=[],
)
assert result.block_class == "decim_block"
assert "gr.decim_block" in result.source_code
assert result.is_valid is True
def test_generate_decim_block_with_averaging(self, generator):
"""Generate a decimating block with averaging."""
result = generator.generate_decim_block(
name="avg_decim",
description="Decimate with averaging",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
decimation=4,
parameters=[],
work_logic="output_items[0][:] = input_items[0].reshape(-1, 4).mean(axis=1)",
)
assert ".reshape" in result.source_code
assert ".mean" in result.source_code
assert result.is_valid is True
# ─────────────────────────────────────────────────────
# Validation
# ─────────────────────────────────────────────────────
def test_validate_block_code_valid(self, generator):
"""Validate syntactically correct block code."""
code = '''
import numpy
from gnuradio import gr
class blk(gr.sync_block):
def __init__(self, gain=1.0):
gr.sync_block.__init__(
self,
name="test_block",
in_sig=[numpy.float32],
out_sig=[numpy.float32],
)
self.gain = gain
def work(self, input_items, output_items):
output_items[0][:] = input_items[0] * self.gain
return len(output_items[0])
'''
result = generator.validate_block_code(code)
assert result.is_valid is True
assert len(result.errors) == 0
def test_validate_block_code_syntax_error(self, generator):
"""Detect syntax errors in block code."""
code = '''
def broken_function(:
pass
'''
result = generator.validate_block_code(code)
assert result.is_valid is False
assert any("syntax" in e.message.lower() for e in result.errors)
def test_validate_block_code_missing_import(self, generator):
"""Detect missing required imports."""
code = '''
class blk(gr.sync_block):
def __init__(self):
gr.sync_block.__init__(self, name="test", in_sig=[float], out_sig=[float])
def work(self, input_items, output_items):
return 0
'''
result = generator.validate_block_code(code)
# Should warn about missing gnuradio import
assert result.is_valid is False or len(result.warnings) > 0
def test_validate_block_code_missing_work_method(self, generator):
"""Detect missing work method."""
code = '''
import numpy
from gnuradio import gr
class blk(gr.sync_block):
def __init__(self):
gr.sync_block.__init__(
self,
name="test",
in_sig=[numpy.float32],
out_sig=[numpy.float32],
)
'''
result = generator.validate_block_code(code)
# Should warn about missing work method
assert len(result.warnings) > 0 or not result.is_valid
class TestDataTypeMapping:
"""Tests for data type mapping in generated code."""
@pytest.fixture
def generator(self):
return BlockGeneratorMiddleware(flowgraph_mw=None)
@pytest.mark.parametrize(
"dtype,expected_numpy",
[
("float", "numpy.float32"),
("complex", "numpy.complex64"),
("byte", "numpy.uint8"),
("short", "numpy.int16"),
("int", "numpy.int32"),
],
)
def test_dtype_mapping(self, generator, dtype, expected_numpy):
"""Test that data types map correctly to numpy types."""
result = generator.generate_sync_block(
name="test_dtype",
description="Test dtype mapping",
inputs=[SignatureItem(dtype=dtype, vlen=1)],
outputs=[SignatureItem(dtype=dtype, vlen=1)],
parameters=[],
work_logic="output_items[0][:] = input_items[0]",
)
assert expected_numpy in result.source_code
class TestWorkTemplates:
"""Tests for predefined work templates."""
@pytest.fixture
def generator(self):
return BlockGeneratorMiddleware(flowgraph_mw=None)
def test_gain_template(self, generator):
"""Test the gain work template."""
result = generator.generate_sync_block(
name="gain",
description="Gain",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[BlockParameter(name="gain", dtype="float", default=1.0)],
work_template="gain",
)
assert "self.gain" in result.source_code
assert result.is_valid
def test_add_const_template(self, generator):
"""Test the add_const work template."""
result = generator.generate_sync_block(
name="add_const",
description="Add constant",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[BlockParameter(name="const", dtype="float", default=0.0)],
work_template="add_const",
)
assert "self.const" in result.source_code
assert result.is_valid
def test_threshold_template(self, generator):
"""Test the threshold work template."""
result = generator.generate_sync_block(
name="threshold",
description="Threshold",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[BlockParameter(name="threshold", dtype="float", default=0.5)],
work_template="threshold",
)
assert "self.threshold" in result.source_code
assert result.is_valid
def test_multiply_template(self, generator):
"""Test the multiply work template (two inputs)."""
result = generator.generate_sync_block(
name="multiply",
description="Multiply two streams",
inputs=[
SignatureItem(dtype="float", vlen=1),
SignatureItem(dtype="float", vlen=1),
],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[],
work_template="multiply",
)
assert "input_items[0]" in result.source_code
assert "input_items[1]" in result.source_code
assert result.is_valid
def test_add_template(self, generator):
"""Test the add work template (two inputs)."""
result = generator.generate_sync_block(
name="add",
description="Add two streams",
inputs=[
SignatureItem(dtype="float", vlen=1),
SignatureItem(dtype="float", vlen=1),
],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[],
work_template="add",
)
assert result.is_valid
def test_unknown_template_falls_back(self, generator):
"""Test that unknown templates use provided work_logic."""
result = generator.generate_sync_block(
name="custom",
description="Custom logic",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[],
work_template="nonexistent_template",
work_logic="output_items[0][:] = input_items[0]",
)
# Should fall back to provided work_logic or use passthrough
assert result.is_valid
class TestEdgeCases:
"""Tests for edge cases and error handling."""
@pytest.fixture
def generator(self):
return BlockGeneratorMiddleware(flowgraph_mw=None)
def test_empty_work_logic_uses_passthrough(self, generator):
"""Empty work logic should use passthrough."""
result = generator.generate_sync_block(
name="passthrough",
description="Pass through",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[],
work_logic="",
)
assert "output_items[0][:] = input_items[0]" in result.source_code
assert result.is_valid
def test_multiple_inputs_outputs(self, generator):
"""Test block with multiple inputs and outputs."""
result = generator.generate_sync_block(
name="multi_io",
description="Multiple I/O block",
inputs=[
SignatureItem(dtype="float", vlen=1),
SignatureItem(dtype="float", vlen=1),
],
outputs=[
SignatureItem(dtype="float", vlen=1),
SignatureItem(dtype="float", vlen=1),
],
parameters=[],
work_logic="""
output_items[0][:] = input_items[0] + input_items[1]
output_items[1][:] = input_items[0] - input_items[1]
""",
)
assert result.is_valid
# Should have both inputs in signature
assert result.source_code.count("numpy.float32") >= 2
def test_source_block_no_inputs(self, generator):
"""Test source block with no inputs."""
result = generator.generate_sync_block(
name="signal_source",
description="Generate signal",
inputs=[],
outputs=[SignatureItem(dtype="float", vlen=1)],
parameters=[BlockParameter(name="frequency", dtype="float", default=1000.0)],
work_logic="output_items[0][:] = numpy.sin(numpy.arange(len(output_items[0])) * self.frequency)",
)
assert result.is_valid
assert "in_sig=[]" in result.source_code or "in_sig=None" in result.source_code
def test_sink_block_no_outputs(self, generator):
"""Test sink block with no outputs."""
result = generator.generate_sync_block(
name="null_sink",
description="Discard samples",
inputs=[SignatureItem(dtype="float", vlen=1)],
outputs=[],
parameters=[],
work_logic="pass",
)
assert result.is_valid