Files
janikandClaude Fable 5 19327c23b1
tests / ubuntu-latest · py3.11 (push) Successful in 54s
tests / ubuntu-latest · py3.13 (push) Successful in 58s
tests / archlinux:latest (push) Successful in 41s
tests / debian:12 (push) Successful in 1m10s
tests / fedora:latest (push) Successful in 42m6s
tests / ubuntu:24.04 (push) Successful in 1m15s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
Release 1.4.1: a Bonded checkbox in the terminal tables
The config's per-terminal bonded key becomes editable in the dialog
(previously file-only, shown as a text suffix): checked, the
terminal's contacts short into one internally joined lug - the total
value stays prescribed, the per-contact split is a solve outcome.
Same-name rectangle groups seed it checked as before; unchecking one
falls back to the per-cell area share, and checking a single-contact
terminal gives it an equipotential-lug contact instead of uniform
injection. Save config... writes the flag back (removed when
unchecked - false is the schema default). With this, every
non-structural terminal option (active, values, v_oc, contact layer,
bonded, comment) is table-editable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:20:35 +07:00

620 lines
25 KiB
Python

"""fill_res_config.json loader: parsing, validation, precedence, save."""
import json
import pytest
from fill_resistance import config, configfile
from fill_resistance.configfile import (ConfigError, dialog_defaults,
load_config, rect_terminals_json,
save_classic_config,
save_pdn_config,
strip_comment_lines,
updated_terminals_json)
from fill_resistance.dialog import PdnTerminalRow, Selection
CLASSIC = """\
// Fill Resistance run configuration - classic mode.
// Full-line comments like this one are allowed; keys starting with "_"
// are ignored everywhere.
{
"version": 1,
"mode": "classic",
"run": {
"net": "VOUT+",
"layers": ["F.Cu", "In1.Cu", "B.Cu"],
"include_tracks": true,
"vias_capped": true,
"cap_max_drill_mm": 0.5,
"adaptive": true,
"cell_um": null,
"freq_hz": "142k",
"contact_model": "uniform",
"include_buildup": false,
"extra_cu_um": 0.0,
"push_overlays": false,
"trim": {"enabled": false, "mode": "pct", "value": 10.0}
},
"classic": {
"current_a": 40.0,
"contact1": "auto",
"contact2": "auto",
"_comment": "pos/neg: terminals fully specified by the file",
"pos": ["J1.1"],
"neg": ["J2.1", "J2.2"]
},
"physics": {
"via_plating_um": 25.0
}
}
"""
PDN = """\
// PDN study of the 3.3 V rail.
{
"version": 1,
"mode": "pdn",
"run": {"net": "VCC_3V3", "freq_hz": 0, "adaptive": true,
"v_nominal": 3.30},
"terminals": [
{"name": "buck", "role": "supply",
"parts": ["U1.SW2", "U1.SW3"],
"r_out_ohm": 0.004},
{"name": "ldo", "role": "supply",
"parts": ["U2.OUT"],
"r_out_ohm": 0.050, "v_oc": 3.28},
{"name": "mcu", "role": "load", "parts": ["U7"], "i_draw_a": 1.8},
{"name": "cam", "role": "load", "parts": ["rect:CAM_ZONE"],
"i_draw_a": 0.35, "contact": "F.Cu"},
{"name": "heater", "role": "load",
"parts": [{"rect_mm": [112.0, 40.5, 118.0, 44.0],
"contact": "B.Cu"},
{"via_mm": [115.2, 42.1]}],
"i_draw_a": 2.5}
],
"markers": {"pdn_layer": "User.3"}
}
"""
def _write(tmp_path, text, name="fill_res_config.json"):
p = tmp_path / name
p.write_text(text, encoding="utf-8")
return p
def test_classic_example_loads(tmp_path):
cfg = load_config(_write(tmp_path, CLASSIC))
assert cfg.mode == "classic"
assert cfg.net == "VOUT+"
assert cfg.layers == ["F.Cu", "In1.Cu", "B.Cu"]
assert cfg.freq_hz == pytest.approx(142_000.0)
assert cfg.cell_um_given and cfg.cell_um is None
assert cfg.current_a == pytest.approx(40.0)
assert cfg.trim_enabled is False
assert cfg.trim_value == pytest.approx(10.0)
assert [p.kind for p in cfg.pos_parts] == ["pad"]
assert (cfg.neg_parts[0].ref, cfg.neg_parts[0].pad) == ("J2", "1")
assert cfg.physics == {"via_plating_um": 25.0}
assert cfg.terminals == []
def test_pdn_example_loads(tmp_path):
cfg = load_config(_write(tmp_path, PDN))
assert cfg.mode == "pdn"
assert cfg.net == "VCC_3V3"
assert cfg.v_nominal == pytest.approx(3.30)
assert [t.name for t in cfg.terminals] == ["buck", "ldo", "mcu", "cam",
"heater"]
buck, ldo, mcu, cam, heater = cfg.terminals
assert buck.role == "supply" and buck.r_out_ohm == pytest.approx(0.004)
assert buck.v_oc is None
assert ldo.v_oc == pytest.approx(3.28)
assert mcu.parts[0].kind == "footprint" and mcu.parts[0].ref == "U7"
assert cam.parts[0].kind == "rect_label"
assert cam.parts[0].label == "CAM_ZONE"
assert cam.contact == "F.Cu"
assert heater.parts[0].kind == "rect_mm"
assert heater.parts[0].rect_mm == (112.0, 40.5, 118.0, 44.0)
assert heater.parts[0].contact == "B.Cu"
assert heater.parts[1].kind == "via_mm"
assert cfg.markers == {"pdn_layer": "User.3"}
def test_comment_stripping_keeps_line_numbers(tmp_path):
# the syntax error sits on line 4 of the file; the two stripped
# comment lines above it must not shift the reported position
text = "// one\n// two\n{\n \"version\": oops\n}\n"
with pytest.raises(ConfigError, match="line 4"):
load_config(_write(tmp_path, text))
assert strip_comment_lines(text).count("\n") == text.count("\n")
def test_unknown_keys_warn_but_load(tmp_path, capsys):
text = json.dumps({"version": 1, "run": {"nett": "X", "net": "Y"}})
cfg = load_config(_write(tmp_path, text))
out = capsys.readouterr().out
assert "run.nett" in out
assert cfg.net == "Y"
def test_underscore_keys_are_silent(tmp_path, capsys):
text = json.dumps({"version": 1, "_note": "hi",
"run": {"_x": 1, "net": "Y"}})
load_config(_write(tmp_path, text))
assert "warning" not in capsys.readouterr().out
@pytest.mark.parametrize("mutate, match", [
(lambda d: d.pop("version"), "version"),
(lambda d: d.update(version=2), "newer"),
(lambda d: d.update(mode="pdn"), "no.*terminals|terminals"),
(lambda d: d["run"].update(contact_model="bonded"), "contact_model"),
(lambda d: d["run"].update(freq_hz="-5"), "freq_hz"),
(lambda d: d["run"].update(cell_um=-1), "cell_um"),
(lambda d: d["classic"].update(current_a=0), "current_a"),
(lambda d: d["classic"].pop("neg"), "pos and neg together"),
])
def test_classic_validation_failures(tmp_path, mutate, match):
d = json.loads(strip_comment_lines(CLASSIC))
mutate(d)
with pytest.raises(ConfigError, match=match):
load_config(_write(tmp_path, json.dumps(d)))
@pytest.mark.parametrize("mutate, match", [
(lambda d: d["terminals"][2].update(name="buck"), "duplicates"),
(lambda d: d["terminals"][2].pop("i_draw_a"), "i_draw_a.*required"),
(lambda d: d["terminals"][0].update(i_draw_a=1.0), "supply.*i_draw_a"),
(lambda d: d["terminals"][2].update(r_out_ohm=1.0), "load.*r_out_ohm"),
(lambda d: d["terminals"][0].update(r_out_ohm=-1), "r_out_ohm"),
(lambda d: d["terminals"][2].update(i_draw_a=-1), "i_draw_a"),
(lambda d: d["terminals"][0].update(role="source"), "role"),
(lambda d: d["terminals"][2].update(parts=["U7."]), "not a valid"),
(lambda d: d["terminals"][3].update(parts=["rect:"]), "empty rect"),
(lambda d: [d["terminals"].pop(0), d["terminals"].pop(0)],
"at least one active supply"),
(lambda d: [t.update(active=False) for t in d["terminals"]
if t["role"] == "load"], "at least one active load"),
(lambda d: d["terminals"][0].update(active="yes"), "active"),
(lambda d: d["terminals"][0].update(comment=3), "comment"),
(lambda d: d["run"].pop("net"), "run.net"),
])
def test_pdn_validation_failures(tmp_path, mutate, match):
d = json.loads(strip_comment_lines(PDN))
mutate(d)
with pytest.raises(ConfigError, match=match):
load_config(_write(tmp_path, json.dumps(d)))
def test_mode_inferred_from_terminals(tmp_path):
d = json.loads(strip_comment_lines(PDN))
del d["mode"]
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.mode == "pdn"
c = json.loads(strip_comment_lines(CLASSIC))
del c["mode"]
cfg = load_config(_write(tmp_path, json.dumps(c), name="c.json"))
assert cfg.mode == "classic"
def test_pad_number_with_dot_splits_on_first(tmp_path):
d = {"version": 1, "run": {"net": "V"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1.A.1"],
"r_out_ohm": 0},
{"name": "l", "role": "load", "parts": ["U2.1"],
"i_draw_a": 1}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
ref = cfg.terminals[0].parts[0]
assert (ref.ref, ref.pad) == ("U1", "A.1")
def test_find_config_prefers_board_stem(tmp_path):
shared = _write(tmp_path, CLASSIC)
specific = _write(tmp_path, CLASSIC,
name="myboard.fill_res_config.json")
assert configfile.find_config(tmp_path, "myboard.kicad_pcb") == specific
assert configfile.find_config(tmp_path, "other.kicad_pcb") == shared
assert configfile.find_config(tmp_path / "empty", "x.kicad_pcb") is None
def test_find_config_loads_the_config_named_default(tmp_path):
plain = _write(tmp_path, CLASSIC)
named = _write(tmp_path, CLASSIC,
name="fill_res_config.default.json")
# "default" beats the plain legacy filename, board-specific beats both
assert configfile.find_config(tmp_path, "x.kicad_pcb") == named
specific = _write(tmp_path, CLASSIC, name="x.fill_res_config.json")
assert configfile.find_config(tmp_path, "x.kicad_pcb") == specific
named.unlink()
specific.unlink()
assert configfile.find_config(tmp_path, "x.kicad_pcb") == plain
def test_named_config_filename_scheme():
assert (configfile.named_config_filename("pdn_test")
== "fill_res_config.pdn_test.json")
def test_dialog_defaults_without_config_match_constants():
d = dialog_defaults(None)
assert d.include_tracks == config.INCLUDE_TRACKS
assert d.vias_capped == config.VIAS_CAPPED
assert d.adaptive == config.ADAPTIVE_CELLS
assert d.contact_model == config.CONTACT_MODEL
assert d.current_a == config.TEST_CURRENT_A
assert d.trim_mode == config.TRIM_MODE
assert d.net is None and d.layers is None
assert d.cell_um is None and d.trim_value is None
def test_dialog_defaults_overlay_config(tmp_path):
cfg = load_config(_write(tmp_path, CLASSIC))
d = dialog_defaults(cfg)
assert d.net == "VOUT+"
assert d.current_a == pytest.approx(40.0)
assert d.freq_hz == pytest.approx(142_000.0)
assert d.layers == ["F.Cu", "In1.Cu", "B.Cu"]
assert d.contact1 == "auto"
def test_apply_physics_mutates_config_module(tmp_path, monkeypatch):
monkeypatch.setattr(config, "VIA_PLATING_UM", 18.0)
monkeypatch.setattr(config, "ELECTRODE_PDN_LAYER", "User.3")
d = {"version": 1, "physics": {"via_plating_um": 30.0},
"markers": {"pdn_layer": "User.4"}}
cfg = load_config(_write(tmp_path, json.dumps(d)))
configfile.apply_physics(cfg)
assert config.VIA_PLATING_UM == 30.0
assert config.ELECTRODE_PDN_LAYER == "User.4"
def _selection():
return Selection(net="VOUT+", layers=["F.Cu", "B.Cu"], contact1="auto",
contact2="all", current_a=12.5, cell_um=80.0,
freq_hz=0.0, contact_model="equipotential",
include_buildup=True, extra_cu_um=100.0,
include_tracks=False, vias_capped=False,
cap_max_drill_mm=0.6, adaptive=False,
push_overlays=True, trim_enabled=True,
trim_mode="abs", trim_value=2.0)
def test_save_then_load_roundtrip(tmp_path):
path = tmp_path / "fill_res_config.json"
save_classic_config(path, _selection())
cfg = load_config(path)
assert cfg.mode == "classic"
assert cfg.net == "VOUT+"
assert cfg.layers == ["F.Cu", "B.Cu"]
assert cfg.current_a == pytest.approx(12.5)
assert cfg.cell_um == pytest.approx(80.0)
assert cfg.contact_model == "equipotential"
assert cfg.contact2 == "all"
assert cfg.include_tracks is False
assert cfg.trim_enabled is True and cfg.trim_mode == "abs"
assert cfg.trim_value == pytest.approx(2.0)
def test_save_preserves_foreign_sections(tmp_path):
path = _write(tmp_path, CLASSIC)
save_classic_config(path, _selection())
cfg = load_config(path)
assert cfg.physics == {"via_plating_um": 25.0} # kept from the old file
assert [p.describe() for p in cfg.pos_parts] == ["J1.1"]
assert cfg.current_a == pytest.approx(12.5) # new dialog value
def test_classic_mode_may_carry_terminals(tmp_path):
# mode is only the STARTING mode: a classic config keeps a
# terminals section (the dialog's PDN mode offers it)
d = json.loads(strip_comment_lines(PDN))
d["mode"] = "classic"
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.mode == "classic"
assert len(cfg.terminals) > 0
def test_save_classic_over_pdn_keeps_the_terminals(tmp_path):
path = _write(tmp_path, PDN)
old = load_config(path)
save_classic_config(path, _selection())
cfg = load_config(path)
assert cfg.mode == "classic" # starting mode flipped
assert cfg.current_a == pytest.approx(12.5)
assert [t.name for t in cfg.terminals] == [t.name for t in
old.terminals]
def test_save_refuses_broken_config(tmp_path):
path = _write(tmp_path, "{ not json")
with pytest.raises(ConfigError):
save_classic_config(path, _selection())
assert path.read_text(encoding="utf-8") == "{ not json" # untouched
def test_docs_examples_load():
"""The copyable examples in docs/ must always parse."""
from pathlib import Path
root = Path(__file__).resolve().parent.parent / "docs"
classic = load_config(root / "fill_res_config.example.json")
assert classic.mode == "classic"
pdn = load_config(root / "fill_res_config.pdn.example.json")
assert pdn.mode == "pdn" and len(pdn.terminals) == 5
# --- PDN save (dialog editor) ------------------------------------------------
def _pdn_selection():
sel = _selection()
sel.mode = "pdn"
sel.net = "VCC_3V3"
sel.v_nominal = 3.3
return sel
def test_rect_terminals_json_shapes():
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)),
(False, (10.0, 20.5, 12.25, 22.0))]
tj = rect_terminals_json(rows, infos)
assert tj[0]["parts"] == ["rect:VIN"] # labeled: live ref
assert tj[0]["r_out_ohm"] == pytest.approx(0.01)
assert "v_oc" not in tj[0] # None -> key omitted
assert tj[1]["parts"] == [{"rect_mm": [10.0, 20.5, 12.25, 22.0]}]
assert tj[1]["i_draw_a"] == pytest.approx(2.0)
def test_updated_terminals_json_preserves_raw():
raw = [{"name": "buck", "role": "supply", "parts": ["U1.SW2"],
"r_out_ohm": 0.004, "v_oc": 3.3, "_comment": "keep me"},
{"name": "mcu", "role": "load", "parts": ["U7"],
"i_draw_a": 1.8}]
rows = [PdnTerminalRow(name="buck", role="supply", resolved="",
r_out_ohm=0.007, v_oc=None),
PdnTerminalRow(name="mcu", role="load", resolved="",
i_draw_a=2.2)]
tj = updated_terminals_json(raw, rows)
assert tj[0]["parts"] == ["U1.SW2"]
assert tj[0]["_comment"] == "keep me" # verbatim carry-over
assert tj[0]["r_out_ohm"] == pytest.approx(0.007)
assert "v_oc" not in tj[0] # None removes the key
assert tj[1]["i_draw_a"] == pytest.approx(2.2)
assert raw[0]["r_out_ohm"] == pytest.approx(0.004) # deep-copied
def test_rect_terminals_json_contact_layer():
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01, contact="F.Cu"),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0, contact="all")]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0))]
tj = rect_terminals_json(rows, infos)
assert tj[0]["contact"] == "F.Cu"
assert "contact" not in tj[1] # "all" is a rect's natural scope
def test_updated_terminals_json_moves_the_contact_scope():
raw = [{"name": "buck", "role": "supply", "parts": ["U1.SW2"],
"r_out_ohm": 0.004, "contact": "F.Cu"},
{"name": "mcu", "role": "load", "parts": ["U7"],
"i_draw_a": 1.8}]
rows = [PdnTerminalRow(name="buck", role="supply", resolved="",
r_out_ohm=0.004, contact="auto"),
PdnTerminalRow(name="mcu", role="load", resolved="",
i_draw_a=1.8, contact="B.Cu")]
tj = updated_terminals_json(raw, rows)
assert "contact" not in tj[0] # "auto" restores the default
assert tj[1]["contact"] == "B.Cu"
def test_numbers_accept_si_suffix_strings(tmp_path):
d = {"version": 1, "mode": "pdn",
"run": {"net": "V", "v_nominal": "3300m"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1"],
"r_out_ohm": "50m"},
{"name": "l", "role": "load", "parts": ["U2"],
"i_draw_a": "150m"}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.v_nominal == pytest.approx(3.3)
assert cfg.terminals[0].r_out_ohm == pytest.approx(0.05)
assert cfg.terminals[1].i_draw_a == pytest.approx(0.15)
d["terminals"][0]["r_out_ohm"] = "5k5" # RKM style: rejected
with pytest.raises(ConfigError, match="cannot parse number"):
load_config(_write(tmp_path, json.dumps(d)))
def test_inactive_terminal_may_omit_its_value(tmp_path):
d = {"version": 1, "mode": "pdn", "run": {"net": "VCC"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1"],
"r_out_ohm": 0.01},
{"name": "l1", "role": "load", "parts": ["U2"],
"i_draw_a": 1.0},
{"name": "l2", "role": "load", "parts": ["U3"],
"active": False, "comment": "not fitted"}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
l2 = cfg.terminals[2]
assert l2.active is False and l2.i_draw_a is None
assert l2.comment == "not fitted"
assert cfg.terminals[0].active is True and cfg.terminals[0].comment == ""
def test_active_and_comment_in_the_save_builders():
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01, comment="buck"),
PdnTerminalRow(name="L1", role="load", resolved="",
active=False, i_draw_a=None)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0))]
tj = rect_terminals_json(rows, infos)
assert "active" not in tj[0] and tj[0]["comment"] == "buck"
assert tj[1]["active"] is False
assert "i_draw_a" not in tj[1] and "comment" not in tj[1]
raw = [{"name": "VIN", "role": "supply", "parts": ["U1"],
"r_out_ohm": 0.01, "comment": "old"},
{"name": "L1", "role": "load", "parts": ["U2"],
"active": False}]
rows[0].comment = "" # cleared -> key removed
rows[1].active = True # re-enabled -> default again
rows[1].i_draw_a = 2.0
uj = updated_terminals_json(raw, rows)
assert "comment" not in uj[0]
assert "active" not in uj[1] and uj[1]["i_draw_a"] == 2.0
def test_updated_terminals_json_moves_the_bonded_flag():
raw = [{"name": "pkg", "role": "load", "parts": ["U7"],
"i_draw_a": 1.8, "bonded": True},
{"name": "heat", "role": "load", "parts": ["U9"],
"i_draw_a": 2.0}]
rows = [PdnTerminalRow(name="pkg", role="load", resolved="",
i_draw_a=1.8, bonded=False),
PdnTerminalRow(name="heat", role="load", resolved="",
i_draw_a=2.0, bonded=True)]
uj = updated_terminals_json(raw, rows)
assert "bonded" not in uj[0] # unchecked removes the key
assert uj[1]["bonded"] is True
def test_inactive_row_saves_and_reloads(tmp_path):
path = tmp_path / "fill_res_config.json"
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0, comment="main draw"),
PdnTerminalRow(name="L2", role="load", resolved="",
active=False)] # blank value: still valid
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0)),
(True, (4.0, 0.0, 5.0, 1.0))]
save_pdn_config(path, _pdn_selection(),
rect_terminals_json(rows, infos))
cfg = load_config(path)
assert cfg.terminals[2].active is False
assert cfg.terminals[2].i_draw_a is None
assert cfg.terminals[1].comment == "main draw"
def test_save_pdn_config_roundtrip(tmp_path):
path = tmp_path / "fill_res_config.json"
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01, v_oc=3.28),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (False, (5.0, 5.0, 6.0, 6.0))]
save_pdn_config(path, _pdn_selection(),
rect_terminals_json(rows, infos))
cfg = load_config(path)
assert cfg.mode == "pdn"
assert cfg.net == "VCC_3V3"
assert cfg.v_nominal == pytest.approx(3.3)
vin, l1 = cfg.terminals
assert vin.parts[0].kind == "rect_label"
assert vin.parts[0].label == "VIN"
assert vin.r_out_ohm == pytest.approx(0.01)
assert vin.v_oc == pytest.approx(3.28)
assert l1.parts[0].kind == "rect_mm"
assert l1.i_draw_a == pytest.approx(2.0)
def test_save_pdn_into_classic_file_preserves_sections(tmp_path):
path = _write(tmp_path, CLASSIC)
rows = [PdnTerminalRow(name="S1", role="supply", resolved="",
r_out_ohm=0.0),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=1.0)]
infos = [(False, (0.0, 0.0, 1.0, 1.0)), (False, (2.0, 0.0, 3.0, 1.0))]
save_pdn_config(path, _pdn_selection(),
rect_terminals_json(rows, infos))
cfg = load_config(path)
assert cfg.mode == "pdn" and len(cfg.terminals) == 2
# the classic section survived for a later hand-edit back
assert cfg.current_a == pytest.approx(40.0)
assert [p.describe() for p in cfg.pos_parts] == ["J1.1"]
assert cfg.physics == {"via_plating_um": 25.0}
def test_save_pdn_into_pdn_file_updates_values(tmp_path):
path = _write(tmp_path, PDN)
old = load_config(path)
rows = []
for spec in old.terminals:
rows.append(PdnTerminalRow(
name=spec.name, role=spec.role, resolved="",
i_draw_a=(spec.i_draw_a + 1.0 if spec.role == "load"
else None),
r_out_ohm=(spec.r_out_ohm * 2 if spec.role == "supply"
else None),
v_oc=spec.v_oc))
save_pdn_config(path, _pdn_selection(),
updated_terminals_json(old.raw["terminals"], rows))
cfg = load_config(path)
assert [t.name for t in cfg.terminals] == [t.name for t in
old.terminals]
# partrefs are byte-identical carry-overs, only values moved
assert cfg.terminals[0].parts[0].describe() == "U1.SW2"
assert cfg.terminals[0].r_out_ohm == pytest.approx(0.008)
assert cfg.terminals[2].i_draw_a == pytest.approx(2.8)
assert cfg.terminals[3].parts[0].label == "CAM_ZONE"
def test_save_pdn_refuses_broken_file(tmp_path):
path = _write(tmp_path, "{ not json")
with pytest.raises(ConfigError):
save_pdn_config(path, _pdn_selection(), [])
assert path.read_text(encoding="utf-8") == "{ not json"
def test_save_pdn_self_validates_before_writing(tmp_path):
path = tmp_path / "fill_res_config.json"
# a load without a draw is invalid - the save must refuse instead
# of writing a config the next launch rejects
bad = [{"name": "L1", "role": "load", "parts": ["U1"]}]
with pytest.raises(ConfigError, match="i_draw_a"):
save_pdn_config(path, _pdn_selection(), bad)
assert not path.exists()
def test_classic_save_still_omits_v_nominal(tmp_path):
path = tmp_path / "fill_res_config.json"
save_classic_config(path, _selection()) # v_nominal stays None
raw = json.loads(path.read_text(encoding="utf-8"))
assert "v_nominal" not in raw["run"]
def test_bonded_key_parses_and_validates(tmp_path):
d = {"version": 1, "run": {"net": "V"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1"],
"r_out_ohm": 0},
{"name": "pkg", "role": "load", "parts": ["U2"],
"i_draw_a": 3.0, "bonded": True}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.terminals[0].bonded is False
assert cfg.terminals[1].bonded is True
d["terminals"][1]["bonded"] = "yes"
with pytest.raises(ConfigError, match="bonded"):
load_config(_write(tmp_path, json.dumps(d), name="b.json"))
def test_bonded_survives_the_save_roundtrip(tmp_path):
path = tmp_path / "fill_res_config.json"
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01),
PdnTerminalRow(name="PKG", role="load", resolved="",
i_draw_a=2.0, bonded=True)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (5.0, 5.0, 6.0, 6.0))]
tj = rect_terminals_json(rows, infos)
assert "bonded" not in tj[0] # false -> key omitted
assert tj[1]["bonded"] is True
save_pdn_config(path, _pdn_selection(), tj)
cfg = load_config(path)
assert cfg.terminals[0].bonded is False
assert cfg.terminals[1].bonded is True