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.
500 lines
19 KiB
Python
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
|