Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
"""Tests for the isolated libngspice runner (``sim.engine``).
Discovery and argv validation run everywhere; the end-to-end ``run()`` cases are
gated on an actually-loadable libngspice (present in the KiCad bundle on the dev
machine) and are real simulations, not mocks.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from akcli.sim import engine
_HAVE_NGSPICE = engine.available() is not None
_needs_engine = pytest.mark.skipif(
not _HAVE_NGSPICE, reason="libngspice not available on this machine"
)
# --- discovery --------------------------------------------------------------
def test_available_off_returns_none(monkeypatch):
for tok in ("0", "off", "OFF", "none", "false", ""):
monkeypatch.setenv("AKCLI_NGSPICE", tok)
assert engine.available() is None
def test_available_bogus_override_returns_none(monkeypatch):
monkeypatch.setenv("AKCLI_NGSPICE", "/no/such/path/libngspice.999.dylib")
assert engine.available() is None
@_needs_engine
def test_available_path_override_is_honored(monkeypatch):
monkeypatch.delenv("AKCLI_NGSPICE", raising=False)
real = engine.available()
assert real is not None
monkeypatch.setenv("AKCLI_NGSPICE", real)
assert engine.available() == real
# --- Windows install version sort (item 9) ----------------------------------
def test_kicad_version_key_sorts_numerically():
paths = [
"C:/Program Files/KiCad/9.0/bin/libngspice.dll",
"C:/Program Files/KiCad/10.0/bin/libngspice.dll",
"C:/Program Files/KiCad/8.0/bin/libngspice.dll",
]
newest = sorted(paths, key=engine._kicad_version_key, reverse=True)[0]
assert "/10.0/" in newest # 10.0 beats 9.0 (a string sort would pick 9.0)
def test_kicad_version_key_unparseable_sorts_lowest():
assert engine._kicad_version_key("C:/no/version/here.dll") == (-1,)
assert engine._kicad_version_key(
"C:/Program Files/KiCad/10.0/bin/x.dll"
) == (10, 0)
# --- fatal-line classification (item 4) -------------------------------------
def test_is_fatal_line_flags_parse_errors_but_not_meas_failed():
assert engine._is_fatal_line("Error: circuit not parsed.")
assert engine._is_fatal_line("fatal error during analysis")
# a WHEN/edge measure that never crossed is assertion-level, NOT fatal
assert not engine._is_fatal_line(
"stdout meas tran nope WHEN v(a)=99 RISE=1 failed!"
)
assert not engine._is_fatal_line("stdout vpeak = 4.9e-01 at= 5.6e-02")
# --- PYTHONPATH propagation to the child (relative-entry fix) ---------------
def test_run_absolutizes_relative_pythonpath_entries(tmp_path, monkeypatch):
# The child runs with cwd=workdir (a tempdir); a relative sys.path entry
# (e.g. 'src' from `PYTHONPATH=src python -m ...`) would resolve against the
# tempdir there and the import would fail. run() must absolutize every entry.
import os
import subprocess
captured: dict = {}
class _Done:
returncode = 0
stdout = ""
stderr = ""
def _fake_run(argv, **kw):
captured["env"] = kw.get("env", {})
return _Done()
monkeypatch.delenv("PYTHONPATH", raising=False)
monkeypatch.setattr(subprocess, "run", _fake_run)
monkeypatch.setattr(engine.sys, "path", ["relsrc", "also/rel", "/already/abs"])
engine.run("* deck\n.end\n", ["run"], timeout=5, workdir=tmp_path)
entries = [p for p in captured["env"]["PYTHONPATH"].split(os.pathsep) if p]
assert entries, "child PYTHONPATH must carry the parent import path"
assert all(os.path.isabs(p) for p in entries), entries
# the relative marker was made absolute, not forwarded verbatim
assert "relsrc" not in entries
assert os.path.abspath("relsrc") in entries
# --- child-mode argv validation ---------------------------------------------
def test_main_rejects_wrong_argc():
assert engine.main([]) == 2
assert engine.main(["only-one"]) == 2
assert engine.main(["a", "b", "c"]) == 2
# --- end-to-end (live) ------------------------------------------------------
_DIVIDER = """\
* resistive divider operating point
V1 in 0 dc 3
R1 in out 10k
R2 out 0 1.1k
.op
.end
"""
def _find_value(lines, name):
pat = re.compile(re.escape(name) + r"\s*=\s*([-+0-9.eE]+)")
for ln in lines:
m = pat.search(ln)
if m:
return float(m.group(1))
return None
@_needs_engine
def test_run_divider_operating_point(tmp_path):
res = engine.run(_DIVIDER, ["run", "print v(out)"], timeout=30, workdir=tmp_path)
assert res.ok, res.log
assert res.error is None
vout = _find_value(res.meas_lines, "v(out)")
assert vout is not None, res.log
assert abs(vout - 0.29730) < 1e-3, f"v(out)={vout!r}\n{res.log}"
_WRDATA = """\
* transient with a wrdata dump
V1 in 0 dc 1
R1 in out 1k
C1 out 0 1u
.tran 1u 100u
.end
"""
@_needs_engine
def test_run_collects_wrdata_files(tmp_path):
res = engine.run(
_WRDATA, ["run", "wrdata wave.data v(out)"], timeout=30, workdir=tmp_path
)
assert res.ok, res.log
assert any(p.endswith("wave.data") for p in res.wave_files), res.wave_files
_RUNAWAY = """\
* an infinite control loop the parent must kill on timeout
V1 in 0 dc 1
R1 in 0 1k
.control
while 1
end
.endc
.end
"""
@_needs_engine
def test_run_timeout(tmp_path):
res = engine.run(_RUNAWAY, ["run"], timeout=1, workdir=tmp_path)
assert res.ok is False
assert res.error is not None
assert "timeout" in res.error
_BAD = """\
* bad deck: diode references a model that was never defined
V1 a 0 dc 1
D1 a 0 NOSUCHMODEL
.op
.end
"""
@_needs_engine
def test_run_bad_deck(tmp_path):
res = engine.run(_BAD, ["run"], timeout=30, workdir=tmp_path)
assert (not res.ok) or (res.error is not None) or ("error" in res.log.lower())
# item 4: a deck that will not parse must yield ok=False with the error visible,
# never the old false-ok.
_UNPARSEABLE = """\
* nonsense element that ngspice cannot parse
Z1 this is not a valid device line
.op
.end
"""
@_needs_engine
def test_run_unparseable_deck_is_not_ok(tmp_path):
res = engine.run(_UNPARSEABLE, ["run"], timeout=30, workdir=tmp_path)
assert res.ok is False
assert res.error
assert "parsed" in res.error.lower() or "error" in res.error.lower()
# item 5: a relative workdir must be resolved before the child (cwd=workdir) is
# spawned, else it looks under workdir/workdir and dies.
@_needs_engine
def test_run_relative_workdir(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
res = engine.run(_DIVIDER, ["run", "print v(out)"],
timeout=30, workdir=Path("case2"))
assert res.ok, res.log
vout = _find_value(res.meas_lines, "v(out)")
assert vout is not None and abs(vout - 0.29730) < 1e-3
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.