Bridge mcltspice to SpiceBook (spicebook.warehack.ing) with 5 new MCP tools (publish, list, get, delete, simulate), a status resource, and a guided publish prompt. Includes LTspice-to-ngspice netlist conversion (strips .backanno, Rser=, Windows paths) with structured warnings. Path traversal protection on file reads (extension allowlist + resolve), notebook ID validation, narrowed exception handling, and size limits. 35 unit tests + 4 live integration tests.
362 lines
12 KiB
Python
362 lines
12 KiB
Python
"""Tests for SpiceBook integration (converter, cell builder, and live API)."""
|
|
|
|
import pytest
|
|
|
|
from mcltspice.server import _read_netlist
|
|
from mcltspice.spicebook import (
|
|
SpiceBookClient,
|
|
build_notebook_cells,
|
|
ltspice_to_ngspice,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ltspice_to_ngspice converter tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLtspiceToNgspice:
|
|
"""Unit tests for the netlist dialect converter."""
|
|
|
|
def test_strips_backanno(self):
|
|
netlist = "V1 in 0 AC 1\n.backanno\n.end\n"
|
|
result, warnings = ltspice_to_ngspice(netlist)
|
|
assert ".backanno" not in result
|
|
assert ".end" in result
|
|
|
|
def test_strips_plotwinsize(self):
|
|
netlist = "R1 a b 1k\n.options plotwinsize=0\n.end\n"
|
|
result, warnings = ltspice_to_ngspice(netlist)
|
|
assert "plotwinsize" not in result
|
|
|
|
def test_comments_out_windows_lib(self):
|
|
netlist = '.lib C:\\LTspice\\lib\\cmp\\standard.bjt\n.end\n'
|
|
result, warnings = ltspice_to_ngspice(netlist)
|
|
assert result.startswith("* .lib")
|
|
assert len(warnings) == 1
|
|
assert "Windows path" in warnings[0]
|
|
|
|
def test_comments_out_windows_include(self):
|
|
netlist = '.include C:\\Users\\test\\model.sub\n.end\n'
|
|
result, warnings = ltspice_to_ngspice(netlist)
|
|
assert "* .include" in result
|
|
assert len(warnings) == 1
|
|
|
|
def test_strips_rser_from_capacitor(self):
|
|
netlist = "C1 out 0 100n Rser=0.1\n.end\n"
|
|
result, warnings = ltspice_to_ngspice(netlist)
|
|
assert "Rser" not in result
|
|
assert "C1 out 0 100n" in result
|
|
assert len(warnings) == 1
|
|
assert "Rser" in warnings[0]
|
|
|
|
def test_strips_rser_from_inductor(self):
|
|
netlist = "L1 a b 10u Rser=0.5\n.end\n"
|
|
result, warnings = ltspice_to_ngspice(netlist)
|
|
assert "Rser" not in result
|
|
assert "L1 a b 10u" in result
|
|
|
|
def test_preserves_standard_spice(self):
|
|
netlist = (
|
|
"* RC Lowpass\n"
|
|
"V1 in 0 AC 1\n"
|
|
"R1 in out 1k\n"
|
|
"C1 out 0 100n\n"
|
|
".ac dec 100 1 1meg\n"
|
|
".end\n"
|
|
)
|
|
result, warnings = ltspice_to_ngspice(netlist)
|
|
assert result == netlist.rstrip("\n")
|
|
assert warnings == []
|
|
|
|
def test_multiple_conversions(self):
|
|
netlist = (
|
|
"V1 in 0 AC 1\n"
|
|
"C1 out 0 100n Rser=0.1\n"
|
|
".backanno\n"
|
|
".lib C:\\LTspice\\lib\\standard.bjt\n"
|
|
".options plotwinsize=0\n"
|
|
".end\n"
|
|
)
|
|
result, warnings = ltspice_to_ngspice(netlist)
|
|
assert ".backanno" not in result
|
|
assert "plotwinsize" not in result
|
|
assert "Rser" not in result
|
|
assert result.count("*") >= 1 # at least the .lib line commented
|
|
assert len(warnings) == 2 # Rser + Windows path (backanno/plotwinsize stripped silently)
|
|
|
|
def test_empty_netlist(self):
|
|
result, warnings = ltspice_to_ngspice("")
|
|
assert result == ""
|
|
assert warnings == []
|
|
|
|
def test_case_insensitive_backanno(self):
|
|
netlist = ".BACKANNO\n.end\n"
|
|
result, warnings = ltspice_to_ngspice(netlist)
|
|
assert "BACKANNO" not in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_notebook_cells tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildNotebookCells:
|
|
"""Unit tests for the cell builder."""
|
|
|
|
def test_minimal_cells(self):
|
|
cells = build_notebook_cells(
|
|
title="Test",
|
|
description=None,
|
|
netlist="V1 in 0 AC 1\n.end",
|
|
)
|
|
assert len(cells) == 2
|
|
assert cells[0]["type"] == "markdown"
|
|
assert "# Test" in cells[0]["source"]
|
|
assert cells[1]["type"] == "spice"
|
|
assert "V1 in 0 AC 1" in cells[1]["source"]
|
|
|
|
def test_with_description(self):
|
|
cells = build_notebook_cells(
|
|
title="RC Filter",
|
|
description="A simple RC lowpass filter.",
|
|
netlist=".end",
|
|
)
|
|
assert "A simple RC lowpass filter." in cells[0]["source"]
|
|
|
|
def test_with_warnings(self):
|
|
cells = build_notebook_cells(
|
|
title="Test",
|
|
description=None,
|
|
netlist=".end",
|
|
warnings=["Stripped Rser= from C1", "Commented out Windows path"],
|
|
)
|
|
intro = cells[0]["source"]
|
|
assert "Conversion notes" in intro
|
|
assert "Stripped Rser" in intro
|
|
assert "Windows path" in intro
|
|
|
|
def test_with_analysis(self):
|
|
cells = build_notebook_cells(
|
|
title="Test",
|
|
description=None,
|
|
netlist=".end",
|
|
analysis={"Bandwidth": "1.59 kHz", "Gain": "-20 dB"},
|
|
)
|
|
assert len(cells) == 3
|
|
table_cell = cells[2]
|
|
assert table_cell["type"] == "markdown"
|
|
assert "Bandwidth" in table_cell["source"]
|
|
assert "1.59 kHz" in table_cell["source"]
|
|
|
|
def test_with_svgs(self):
|
|
svg1 = '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
|
|
svg2 = '<svg xmlns="http://www.w3.org/2000/svg"><circle/></svg>'
|
|
cells = build_notebook_cells(
|
|
title="Test",
|
|
description=None,
|
|
netlist=".end",
|
|
svgs=[svg1, svg2],
|
|
)
|
|
# intro + spice + 2 svg cells
|
|
assert len(cells) == 4
|
|
assert "Plot 1" in cells[2]["source"]
|
|
assert "Plot 2" in cells[3]["source"]
|
|
assert "<rect/>" in cells[2]["source"]
|
|
|
|
def test_single_svg_no_number(self):
|
|
svg = '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
|
|
cells = build_notebook_cells(
|
|
title="Test",
|
|
description=None,
|
|
netlist=".end",
|
|
svgs=[svg],
|
|
)
|
|
assert len(cells) == 3
|
|
assert "### Plot\n" in cells[2]["source"]
|
|
assert "Plot 1" not in cells[2]["source"]
|
|
|
|
def test_full_build(self):
|
|
cells = build_notebook_cells(
|
|
title="RC Lowpass Filter",
|
|
description="First-order RC filter with fc = 1.59 kHz",
|
|
netlist="V1 in 0 AC 1\nR1 in out 1k\nC1 out 0 100n\n.ac dec 100 1 1meg\n.end",
|
|
warnings=["Stripped Rser= from C1"],
|
|
analysis={"Bandwidth (-3dB)": "1.59 kHz"},
|
|
svgs=["<svg>...</svg>"],
|
|
)
|
|
# intro + spice + analysis + 1 svg = 4 cells
|
|
assert len(cells) == 4
|
|
assert cells[0]["type"] == "markdown"
|
|
assert cells[1]["type"] == "spice"
|
|
assert cells[2]["type"] == "markdown"
|
|
assert cells[3]["type"] == "markdown"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SpiceBookClient unit tests (no network)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSpiceBookClient:
|
|
"""Test client construction, defaults, and input validation."""
|
|
|
|
def test_default_url(self):
|
|
client = SpiceBookClient()
|
|
assert client.base_url == "https://spicebook.warehack.ing"
|
|
|
|
def test_custom_url(self):
|
|
client = SpiceBookClient(base_url="http://localhost:8000/")
|
|
assert client.base_url == "http://localhost:8000"
|
|
|
|
def test_custom_timeout(self):
|
|
client = SpiceBookClient(timeout=60)
|
|
assert client.timeout == 60
|
|
|
|
def test_validate_notebook_id_valid(self):
|
|
assert SpiceBookClient._validate_notebook_id("rc-lowpass-abc123") is None
|
|
assert SpiceBookClient._validate_notebook_id("test_notebook") is None
|
|
assert SpiceBookClient._validate_notebook_id("a") is None
|
|
|
|
def test_validate_notebook_id_rejects_traversal(self):
|
|
err = SpiceBookClient._validate_notebook_id("../admin")
|
|
assert err is not None
|
|
assert "Invalid" in err
|
|
|
|
def test_validate_notebook_id_rejects_slashes(self):
|
|
err = SpiceBookClient._validate_notebook_id("foo/../../bar")
|
|
assert err is not None
|
|
|
|
def test_validate_notebook_id_rejects_empty(self):
|
|
err = SpiceBookClient._validate_notebook_id("")
|
|
assert err is not None
|
|
|
|
async def test_get_notebook_rejects_bad_id(self):
|
|
client = SpiceBookClient()
|
|
with pytest.raises(ValueError, match="Invalid notebook_id"):
|
|
await client.get_notebook("../admin")
|
|
|
|
async def test_delete_notebook_rejects_bad_id(self):
|
|
client = SpiceBookClient()
|
|
with pytest.raises(ValueError, match="Invalid notebook_id"):
|
|
await client.delete_notebook("foo/bar")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _read_netlist validation tests (server-level helper)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestReadNetlist:
|
|
"""Tests for the _read_netlist path validation."""
|
|
|
|
def test_rejects_both_text_and_path(self):
|
|
_, err = _read_netlist("some text", "/some/path.cir")
|
|
assert err is not None
|
|
assert "not both" in err["error"]
|
|
|
|
def test_rejects_neither(self):
|
|
_, err = _read_netlist(None, None)
|
|
assert err is not None
|
|
|
|
def test_accepts_text(self):
|
|
text, err = _read_netlist("V1 in 0 AC 1\n.end", None)
|
|
assert err is None
|
|
assert "V1" in text
|
|
|
|
def test_rejects_bad_extension(self):
|
|
_, err = _read_netlist(None, "/etc/passwd")
|
|
assert err is not None
|
|
assert "Unsupported file type" in err["error"]
|
|
|
|
def test_rejects_shadow_file(self):
|
|
_, err = _read_netlist(None, "/etc/shadow")
|
|
assert err is not None
|
|
assert "Unsupported file type" in err["error"]
|
|
|
|
def test_rejects_nonexistent_file(self, tmp_path):
|
|
path = str(tmp_path / "missing.cir")
|
|
_, err = _read_netlist(None, path)
|
|
assert err is not None
|
|
assert "not found" in err["error"]
|
|
|
|
def test_reads_valid_cir_file(self, tmp_path):
|
|
cir = tmp_path / "test.cir"
|
|
cir.write_text("V1 in 0 AC 1\n.end\n")
|
|
text, err = _read_netlist(None, str(cir))
|
|
assert err is None
|
|
assert "V1" in text
|
|
|
|
def test_reads_valid_net_file(self, tmp_path):
|
|
net = tmp_path / "test.net"
|
|
net.write_text("R1 a b 1k\n.end\n")
|
|
text, err = _read_netlist(None, str(net))
|
|
assert err is None
|
|
assert "R1" in text
|
|
|
|
def test_rejects_traversal_with_bad_extension(self):
|
|
_, err = _read_netlist(None, "/tmp/../../etc/passwd")
|
|
assert err is not None
|
|
assert "Unsupported file type" in err["error"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration tests (require live SpiceBook)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestSpiceBookIntegration:
|
|
"""Live API tests against spicebook.warehack.ing.
|
|
|
|
Run with: uv run pytest tests/test_spicebook.py -m integration
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def client(self):
|
|
return SpiceBookClient()
|
|
|
|
@pytest.fixture
|
|
def rc_netlist(self):
|
|
return (
|
|
"* RC Lowpass Filter\n"
|
|
"V1 in 0 AC 1\n"
|
|
"R1 in out 1k\n"
|
|
"C1 out 0 100n\n"
|
|
".ac dec 100 1 1meg\n"
|
|
".end\n"
|
|
)
|
|
|
|
async def test_health(self, client):
|
|
assert await client.health() is True
|
|
|
|
async def test_compose_and_get_and_delete(self, client, rc_netlist):
|
|
cells = build_notebook_cells(
|
|
title="Integration Test - RC Lowpass",
|
|
description="Auto-generated by mcltspice test suite",
|
|
netlist=rc_netlist,
|
|
)
|
|
result = await client.compose(
|
|
title="Integration Test - RC Lowpass",
|
|
cells=cells,
|
|
tags=["test", "mcltspice", "auto"],
|
|
)
|
|
notebook_id = result.get("id")
|
|
if not notebook_id:
|
|
pytest.fail(f"compose returned no 'id': {result}")
|
|
|
|
try:
|
|
# Verify we can retrieve it (title is under metadata)
|
|
notebook = await client.get_notebook(notebook_id)
|
|
assert notebook["metadata"]["title"] == "Integration Test - RC Lowpass"
|
|
assert len(notebook["cells"]) >= 2
|
|
finally:
|
|
await client.delete_notebook(notebook_id)
|
|
|
|
async def test_list_notebooks(self, client):
|
|
notebooks = await client.list_notebooks()
|
|
assert isinstance(notebooks, list)
|
|
|
|
async def test_simulate(self, client, rc_netlist):
|
|
result = await client.simulate(rc_netlist)
|
|
assert isinstance(result, dict)
|