"""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 = '' svg2 = '' 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 "" in cells[2]["source"] def test_single_svg_no_number(self): 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=["..."], ) # 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)