"""Integration test for NgspiceEngine with a simple RC circuit.""" import shutil import tempfile from pathlib import Path import pytest from spicebook.engine.ngspice import NgspiceEngine, parse_ngspice_raw # Skip the entire module if ngspice is not installed pytestmark = pytest.mark.skipif( shutil.which("ngspice") is None, reason="ngspice not found on PATH", ) RC_TRANSIENT_NETLIST = """\ RC Low-Pass Filter - Transient V1 in 0 PULSE(0 5 0 1n 1n 5m 10m) R1 in out 1k C1 out 0 1u .tran 100u 20m .end """ RC_AC_NETLIST = """\ RC Low-Pass Filter - AC V1 in 0 AC 1 R1 in out 1k C1 out 0 1u .ac dec 50 1 1Meg .end """ @pytest.fixture def engine(): return NgspiceEngine() @pytest.fixture def work_dir(): d = tempfile.mkdtemp(prefix="spicebook-test-") yield Path(d) # Cleanup handled by OS for temp dirs; explicit cleanup optional import shutil as _shutil _shutil.rmtree(d, ignore_errors=True) @pytest.mark.asyncio async def test_transient_simulation(engine, work_dir): """Run a transient analysis on an RC circuit and verify waveform output.""" result = await engine.run(RC_TRANSIENT_NETLIST, work_dir) assert result.success, f"Simulation failed: {result.error}\n{result.log}" assert result.waveform is not None assert result.waveform.x_type == "time" assert result.waveform.points > 0 assert len(result.waveform.x_data) == result.waveform.points assert result.elapsed_seconds > 0 # Should have at least one signal in y_data assert len(result.waveform.y_data) > 0 # Check that V(out) or similar exists signal_names = list(result.waveform.y_data.keys()) has_out = any("out" in name.lower() for name in signal_names) assert has_out, f"Expected 'out' signal in {signal_names}" # Verify data length matches for name, values in result.waveform.y_data.items(): assert len(values) == result.waveform.points, f"Length mismatch for {name}" @pytest.mark.asyncio async def test_ac_simulation(engine, work_dir): """Run an AC analysis and verify complex waveform output with mag/phase.""" result = await engine.run(RC_AC_NETLIST, work_dir) assert result.success, f"Simulation failed: {result.error}\n{result.log}" assert result.waveform is not None assert result.waveform.x_type == "frequency" assert result.waveform.is_complex assert result.waveform.points > 0 # AC analysis should produce magnitude and phase data assert result.waveform.y_magnitude_db is not None assert result.waveform.y_phase_deg is not None assert len(result.waveform.y_magnitude_db) > 0 assert len(result.waveform.y_phase_deg) > 0 @pytest.mark.asyncio async def test_netlist_without_end_directive(engine, work_dir): """Engine should auto-append .end if missing.""" netlist_no_end = """\ Simple Resistor Divider V1 in 0 DC 10 R1 in out 1k R2 out 0 1k .op """ # .op (operating point) does not produce a .raw file with waveforms, # but the simulation should still run without error from a missing .end. result = await engine.run(netlist_no_end, work_dir) # .op may or may not produce a raw file depending on ngspice version, # but it should not crash with a "missing .end" error assert result.error is None or ".end" not in (result.error or "").lower() @pytest.mark.asyncio async def test_invalid_netlist(engine, work_dir): """Malformed netlist should return success=False with error info.""" bad_netlist = """\ This is not a valid SPICE netlist .tran 1m 10m .end """ result = await engine.run(bad_netlist, work_dir) # Should either fail or produce empty output -- not crash # ngspice may still exit 0 for some "invalid" netlists, so we just # verify we get a response at all assert isinstance(result.success, bool) def test_raw_parser_on_transient(work_dir): """Verify the raw parser handles a real ngspice output file.""" cir = work_dir / "test.cir" cir.write_text(RC_TRANSIENT_NETLIST) raw_path = work_dir / "output.raw" import subprocess proc = subprocess.run( ["ngspice", "-b", "-r", str(raw_path), str(cir)], capture_output=True, timeout=30, ) assert raw_path.exists(), f"ngspice did not produce raw file. stderr: {proc.stderr.decode()}" raw = parse_ngspice_raw(raw_path) assert raw.points > 0 assert len(raw.variables) > 0 assert raw.data.shape[0] == len(raw.variables) assert raw.data.shape[1] == raw.points # First variable should be time assert "time" in raw.variables[0].name.lower()