2 Commits
Author SHA1 Message Date
janikandClaude Fable 5 634df87fe0 Release 1.4.2: load-only dialog when nothing is selected
tests / fedora:latest (push) Successful in 1m15s
tests / ubuntu-latest · py3.11 (push) Successful in 1m32s
tests / ubuntu-latest · py3.13 (push) Successful in 1m1s
tests / archlinux:latest (push) Successful in 53s
tests / debian:12 (push) Successful in 51s
tests / ubuntu:24.04 (push) Successful in 1m8s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 10s
With neither mode derivable (nothing selected, no marker rectangles,
no config) the dialog now opens anyway instead of failing with an
error figure: both radios disabled with their reasons, OK/Save
disabled, Load config… live - a saved config can bootstrap the run.

README: note that macOS/NixOS field tests last ran with plugin v1.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjSRLWQ8ywBBYyakvr3YTx
2026-08-31 11:42:51 +07:00
janikandClaude Fable 5 19327c23b1 Release 1.4.1: a Bonded checkbox in the terminal tables
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
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
12 changed files with 240 additions and 71 deletions
+30 -18
View File
@@ -45,8 +45,8 @@ SWIG API. Requires KiCad **10.0.1+**.
| Platform | Status | Verified by |
|-----------------------------|:------:|-------------|
| Windows | ✅ | development platform, full suite before every release |
| macOS | ✅ | field-tested in KiCad 10 |
| NixOS | ✅ | field-tested in KiCad 10 ([setup](docs/NIXOS.md)) |
| macOS | ✅ | field-tested in KiCad 10 (last on plugin v1.3) |
| NixOS | ✅ | field-tested in KiCad 10 (last on plugin v1.3, [setup](docs/NIXOS.md)) |
| Debian 12 | ✅ | CI test suite in container |
| Ubuntu 24.04 | ✅ | CI test suite in container |
| Fedora (latest) | ✅ | CI test suite in container |
@@ -57,16 +57,17 @@ rasterizer, and the platform-fallback regressions — headless against
the real pip wheels of each Linux row, including the
`PySide6.QtWidgets` import probe that decides the matplotlib backend.
What CI *cannot* do is launch KiCad itself, so "runs inside KiCad"
remains field-tested (Windows continuously, macOS and NixOS per
release).
remains field-tested (Windows continuously; macOS and NixOS last
field-tested with plugin v1.3 — later versions are covered there by
the test suites only).
## Setup (one-time)
The plugin is developed and tested on **Windows**; **macOS works**
(field-tested on KiCad 10 after a round of mac-specific fixes), and
**Linux works** (field-tested on NixOS — the hardest Linux to run pip
wheels on; mainstream FHS distributions should be no harder, reports
welcome). KiCad builds the plugin a private Python venv from
(field-tested on KiCad 10 after a round of mac-specific fixes, last
with plugin v1.3), and **Linux works** (field-tested on NixOS — the
hardest Linux to run pip wheels on, last with plugin v1.3; mainstream
FHS distributions should be no harder, reports welcome). KiCad builds the plugin a private Python venv from
`requirements.txt` on every platform, from pre-built wheels only, no
compiler needed. Steps 14 are the same everywhere; OS specifics are
spelled out per step and in *Platform notes* below.
@@ -110,7 +111,8 @@ spelled out per step and in *Platform notes* below.
- **Windows** is the development and test platform — everything in
this README was exercised here. KiCad's bundled Python is 3.13, so
the venv gets the current dependency stack.
- **macOS** — **works** (field-tested on KiCad 10). Requires
- **macOS** — **works** (field-tested on KiCad 10, last with plugin
v1.3). Requires
macOS 12+ (KiCad's own minimum; Intel and Apple Silicon — the dmg
is universal). KiCad's bundled Python is **3.9**, so pip resolves
an older stack (numpy 2.0, scipy 1.13, matplotlib 3.9,
@@ -119,8 +121,9 @@ spelled out per step and in *Platform notes* below.
Plot and dialog windows may open **behind** the KiCad window (they
are raised best-effort) — check the Dock if nothing seems to appear
after a solve.
- **Linux** — **works** (field-tested on NixOS, KiCad 10; mainstream
distributions are audited but not yet field-tested). The venv uses
- **Linux** — **works** (field-tested on NixOS, KiCad 10, last with
plugin v1.3; mainstream distributions are audited but not yet
field-tested). The venv uses
the system Python (3.9+), so the stack matches your distribution.
On **ARM64 (aarch64)** there are no pyamg wheels —
`requirements.txt` skips pyamg there and the solver falls back to
@@ -236,7 +239,10 @@ the picked file. **Save config…** asks for the target file name each
time (pre-filled with the loaded config), so writing back and saving
a variant under a new `fill_res_config.<name>.json` are both one
click — a name outside the auto-load set prints a reminder that it
needs Load config….
needs Load config…. The dialog opens even when nothing on the board
yields a setup (nothing selected, no marker rectangles, no config):
load-only, with both mode radios disabled and their reasons shown, so
**Load config…** can bootstrap the run.
**Precedence**: `config.py` constants < config file < dialog edits.
The file pre-fills the dialog; what the dialog shows is what runs. A
@@ -368,12 +374,18 @@ terminal for what-if runs without deleting anything: an unchecked row
takes no part in the solve, may leave its value cells blank, and is
still saved (as `"active": false`) so it can be re-enabled later — the
totals line counts disabled rows. A free-text **Comment** column
annotates each terminal and is saved along with it. Rectangles that **share one name
become a single *bonded* terminal** — one table row, one total
current, and the per-rectangle split is a solve outcome (a multi-pin
package whose pins are joined by internal metal: the total draw is
known, which pin carries how much is exactly what the solve
determines). OK solves;
annotates each terminal and is saved along with it. A **Bonded** checkbox per row
toggles the lug model (the config's `bonded` key): checked, the
terminal's contacts are shorted into one internally joined lug — the
total value stays prescribed, the per-contact split is a solve
outcome — and a single-contact terminal checked gets an
equipotential-lug contact instead of uniform injection. Rectangles
that **share one name become a single terminal with Bonded seeded
checked** — one table row, one total current, per-rectangle split
solved (a multi-pin package whose pins are joined by internal metal:
the total draw is known, which pin carries how much is exactly what
the solve determines); uncheck it to fall back to the area-share
split. OK solves;
**Save config…** writes the whole setup to a config file whose name
you pick per save (pre-filled with the loaded config, else
`fill_res_config.json`) so the values survive between runs — named rectangles are saved as live `rect:NAME`
+12
View File
@@ -0,0 +1,12 @@
Dialog completeness: every per-terminal config option is now editable
in the terminal tables.
- A Bonded checkbox per row (the config's "bonded" key, previously
file-only and 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 becomes a solve outcome.
Same-name rectangle groups seed it checked (unchanged default);
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).
+11
View File
@@ -0,0 +1,11 @@
Launching with nothing usable on the board no longer dead-ends: when
neither mode can be derived (nothing selected, no marker rectangles,
no config), the dialog now opens anyway - load-only - instead of
failing with an error figure.
- Both mode radios are unchecked and disabled with their reasons
shown; OK and Save config... are disabled too. Load config... (and
Cancel) stay live, so a saved config can bootstrap the run without
first selecting pads or drawing marker rectangles.
- A loaded file re-derives everything exactly as if it had been
present at launch - mode, net, terminals, values.
+1 -1
View File
@@ -4,4 +4,4 @@ __version__ is the runtime source of truth (metadata.json and
pyproject.toml are not deployed with the plugin); a test keeps the
three in sync.
"""
__version__ = "1.4.0"
__version__ = "1.4.2"
+7 -2
View File
@@ -769,8 +769,9 @@ def save_classic_config(path: Path, selection) -> None:
def updated_terminals_json(raw_terminals: list, rows: list) -> list:
"""Config-backed PDN save: each raw terminal object is deep-copied
verbatim (parts, "_"-prefixed keys preserved) and only the
dialog-editable values - I / R_out / V_oc and the terminal-level
contact layer - are written back POSITIONALLY: the dialog never
dialog-editable values - I / R_out / V_oc, the bonded flag and the
terminal-level contact layer - are written back POSITIONALLY: the
dialog never
reorders its tables, so index i is the same terminal in both lists.
A supply row's v_oc of None REMOVES the key (restoring the
defaults-to-v_nominal semantics); a contact of "auto" removes the
@@ -800,6 +801,10 @@ def updated_terminals_json(raw_terminals: list, rows: list) -> list:
t["contact"] = contact
else:
t.pop("contact", None)
if getattr(row, "bonded", False):
t["bonded"] = True
else:
t.pop("bonded", None) # false is the schema default
if getattr(row, "active", True):
t.pop("active", None) # true is the schema default
else:
+72 -30
View File
@@ -26,7 +26,11 @@ Two run modes share the dialog, chosen by a radio at the top:
"Load config…" swaps the whole setup for another config file: ask()
then returns a LoadRequest instead of a Selection and main re-derives
everything from that file and reopens the dialog.
everything from that file and reopens the dialog. The dialog even
opens with NO runnable mode at all (nothing selected, no marker
rectangles, no config) - load-only: both radios unchecked and
disabled with their reasons, OK/Save disabled - so a saved config can
bootstrap the run.
Row identity is POSITIONAL: the tables never sort or reorder, so
main.py zips Selection.pdn_rows with its own parallel terminal list.
@@ -122,10 +126,13 @@ class PdnTerminalRow:
i_draw_a: float | None = None # loads; None = not entered yet
r_out_ohm: float | None = None # supplies; None = not entered yet
v_oc: float | None = None # supplies; None = v_nominal
bonded: bool = False # multi-contact lug: the TOTAL value
# applies, the per-contact split is a
# solve outcome (display/data only -
# not editable in the table)
bonded: bool = False # checkbox: short the contacts into
# one lug - the TOTAL value applies,
# the per-contact split is a solve
# outcome. Seeded True for same-name
# rectangle groups and from the
# config's "bonded" key; a single
# contact checked = equipotential lug
contact: str = "all" # terminal-level layer scope: "auto"
# (per contact part - config-backed
# rows only), "all", or a layer name
@@ -202,16 +209,28 @@ class _Dialog(QDialog):
self.mode_pdn = QRadioButton("PDN")
self.mode_classic.setEnabled(self._classic_ok)
self.mode_pdn.setEnabled(self._pdn_ok)
start_pdn = self._pdn_ok and (not self._classic_ok
or start_mode == "pdn")
(self.mode_pdn if start_pdn else self.mode_classic).setChecked(True)
reason = None
# with NEITHER mode derivable (nothing selected, no marker
# rectangles, no config terminals) the dialog still opens
# LOAD-ONLY: no radio checked, OK/Save disabled below - so
# "Load config…" can pull in a saved setup instead of the
# launch dying with an error figure
self._no_mode = not (self._classic_ok or self._pdn_ok)
if not self._no_mode:
start_pdn = self._pdn_ok and (not self._classic_ok
or start_mode == "pdn")
(self.mode_pdn if start_pdn
else self.mode_classic).setChecked(True)
reasons = []
if not self._classic_ok and classic_reason:
reason = f"Classic unavailable: {classic_reason}"
reasons.append(f"Classic unavailable: {classic_reason}")
self.mode_classic.setToolTip(classic_reason)
elif not self._pdn_ok and pdn_reason:
reason = f"PDN unavailable: {pdn_reason}"
if not self._pdn_ok and pdn_reason:
reasons.append(f"PDN unavailable: {pdn_reason}")
self.mode_pdn.setToolTip(pdn_reason)
if self._no_mode and load_dir is not None:
reasons.append("Load config… below can still set up a run "
"from a saved config file.")
reason = "\n".join(reasons) if reasons else None
# --- shared form #1 --------------------------------------------
form1 = QFormLayout()
@@ -304,8 +323,10 @@ class _Dialog(QDialog):
# the role/layer mapping lives in the table titles now
hints.append("name from a text item inside the "
"rectangle; empty V_oc = V nominal")
hints.append("Layer = the copper the terminal contacts; "
"values take SI suffixes (50m = 0.05)")
hints.append("Bonded = contacts joined into one lug (the "
"per-contact split is solved); Layer = the "
"copper the terminal contacts; values take SI "
"suffixes (50m = 0.05)")
hint = QLabel("".join(hints))
hint.setWordWrap(True)
hint.setStyleSheet("color: gray; font-size: 10px;")
@@ -382,14 +403,23 @@ class _Dialog(QDialog):
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._try_accept)
buttons.rejected.connect(self.reject)
self.ok_button = buttons.button(QDialogButtonBox.Ok)
self.load_button = None
self.save_button = None
if load_dir is not None:
load_btn = buttons.addButton("Load config…",
QDialogButtonBox.ActionRole)
load_btn.clicked.connect(self._load_config)
self.load_button = buttons.addButton(
"Load config…", QDialogButtonBox.ActionRole)
self.load_button.clicked.connect(self._load_config)
if save_callback is not None:
save_btn = buttons.addButton("Save config…",
QDialogButtonBox.ActionRole)
save_btn.clicked.connect(self._save_config)
self.save_button = buttons.addButton(
"Save config…", QDialogButtonBox.ActionRole)
self.save_button.clicked.connect(self._save_config)
if self._no_mode:
# nothing to run or to save until a load re-derives; Load
# config… and Cancel stay live
self.ok_button.setEnabled(False)
if self.save_button is not None:
self.save_button.setEnabled(False)
content = QWidget()
lay = QVBoxLayout(content)
@@ -400,11 +430,12 @@ class _Dialog(QDialog):
mode_row.addWidget(self.mode_pdn)
mode_row.addStretch(1)
lay.addLayout(mode_row)
self.reason_label = None
if reason is not None:
rl = QLabel(reason)
rl.setWordWrap(True)
rl.setStyleSheet("color: gray; font-size: 10px;")
lay.addWidget(rl)
self.reason_label = QLabel(reason)
self.reason_label.setWordWrap(True)
self.reason_label.setStyleSheet("color: gray; font-size: 10px;")
lay.addWidget(self.reason_label)
lay.addLayout(form1)
if self.classic_section is not None:
lay.addWidget(self.classic_section)
@@ -532,10 +563,11 @@ class _Dialog(QDialog):
for role, cols in (
("supply",
["Active", "Name", "Component", "R_out [Ω]",
"V_oc [V]", "Layer", "Contact parts", "Comment"]),
"V_oc [V]", "Bonded", "Layer", "Contact parts",
"Comment"]),
("load",
["Active", "Name", "Component", "I draw [A]", "Layer",
"Contact parts", "Comment"])):
["Active", "Name", "Component", "I draw [A]",
"Bonded", "Layer", "Contact parts", "Comment"])):
n = sum(1 for r in pdn.rows if r.role == role)
panel = QWidget()
pv = QVBoxLayout(panel)
@@ -562,7 +594,8 @@ class _Dialog(QDialog):
self._pdn_map.append((t, i))
values = ([row.r_out_ohm, row.v_oc] if row.role == "supply"
else [row.i_draw_a])
last = t.columnCount() - 1 # ... | Layer | parts | Comment
# tail columns: ... | Bonded | Layer | parts | Comment
last = t.columnCount() - 1
cells = [(1, row.name, False), (2, row.component, False)]
for col, value in enumerate(values, start=3):
cells.append((col, "" if value is None else f"{value:g}",
@@ -579,6 +612,11 @@ class _Dialog(QDialog):
box.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable)
box.setCheckState(Qt.Checked if row.active else Qt.Unchecked)
t.setItem(i, 0, box)
bond = QTableWidgetItem("")
bond.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable)
bond.setCheckState(Qt.Checked if row.bonded
else Qt.Unchecked)
t.setItem(i, last - 3, bond)
combo = QComboBox()
idx = len(self._pdn_layer_combos)
self._pdn_layer_combos.append(combo)
@@ -695,12 +733,14 @@ class _Dialog(QDialog):
new = PdnTerminalRow(name=row.name, role=row.role,
resolved=row.resolved,
component=row.component,
bonded=row.bonded,
from_config=row.from_config)
box = t.item(r, 0)
checked = (box is None
or box.checkState() == Qt.Checked)
new.active = checked and not self._row_hidden(i)
bond = t.item(r, t.columnCount() - 4)
new.bonded = (bond is not None
and bond.checkState() == Qt.Checked)
new.comment = cell(t.columnCount() - 1)
combo = self._pdn_layer_combos[i]
new.contact = (combo.currentData() if combo.count()
@@ -983,7 +1023,9 @@ def ask(candidates: dict[str, list[str]], layer_order: list[str],
config file); pdn: the PDN terminal setup (editable tables);
pdn_candidates: net candidates for PDN mode (classic uses
`candidates`); classic_reason / pdn_reason: why a mode is
unavailable (its radio is disabled with the reason shown);
unavailable (its radio is disabled with the reason shown; with
BOTH unavailable the dialog opens load-only - OK and Save disabled
until Load config… returns a LoadRequest);
save_callback(selection, target_path) -> saved name string enables
the "Save config…" button in both modes (the file name is asked
per save, seeded with save_target); load_dir (the board directory)
+21 -11
View File
@@ -7,7 +7,10 @@ config's terminal set) -> gather fills -> dialog with a Classic/PDN
mode selector (classic: the two-contact form; PDN: editable per-role
terminal tables) -> extract vias -> solve -> figures + report. The
dialog's "Load config…" button loops back to the derivation with the
picked file, so a run can be set up from any saved config.
picked file, so a run can be set up from any saved config - and the
dialog opens even when NEITHER mode is derivable (nothing selected, no
marker rectangles, no config): load-only, both radios disabled with
their reasons, so a saved config can bootstrap the run.
Every failure is reported twice: on stdout (lands in the KiCad status-bar
warning list) and as a matplotlib error figure, so it cannot be missed.
@@ -101,8 +104,10 @@ def main() -> None:
configfile.apply_physics(cfg)
# BOTH terminal derivations always run; a failure only
# disables that mode's radio (with the reason shown) - the
# launch dies only when neither mode is possible
# disables that mode's radio (with the reason shown).
# Even with neither mode possible the dialog still
# opens - load-only - so "Load config…" works without
# first selecting pads or drawing marker rectangles
classic_reason = pdn_reason = None
es1: list = []
es2: list = []
@@ -210,9 +215,13 @@ def main() -> None:
f"workable supply+load copper; PDN "
f"candidates: {', '.join(pdn_nets)}")
if classic_reason is not None and pdn_reason is not None:
raise SelectionError(
f"{classic_reason}\n(PDN mode is also "
f"unavailable: {pdn_reason})")
# nothing runnable right now: the dialog opens with
# both radios and OK/Save disabled, offering only
# Load config… (and Cancel) - a loaded file then
# re-derives everything through the loop below
print(f"note: classic mode unavailable: "
f"{classic_reason}")
print(f"note: PDN mode unavailable: {pdn_reason}")
buildups = board_io.gather_mask_buildups(board)
except ApiError as e:
raise UserFacingError(
@@ -237,9 +246,10 @@ def main() -> None:
def marker_desc(mt):
if len(mt.electrodes) == 1:
return rect_desc(mt.electrodes[0])
# same-named rectangles grouped into one bonded lug
# same-named rectangles grouped into one terminal (the
# Bonded checkbox shows/controls the lug behavior)
return (f"{len(mt.electrodes)}× "
f"{rect_desc(mt.electrodes[0])} — bonded")
f"{rect_desc(mt.electrodes[0])}")
def live_row(mt, hint, tn):
return dialog.PdnTerminalRow(
@@ -253,8 +263,7 @@ def main() -> None:
n_cfg = len(terminals)
rows = [dialog.PdnTerminalRow(
name=t.label, role=t.role,
resolved=(group_label(t.electrodes)
+ (" — bonded" if t.bonded else "")),
resolved=group_label(t.electrodes),
component=hint,
i_draw_a=(t.i_draw_a if t.role == "load"
else None),
@@ -418,7 +427,7 @@ def main() -> None:
if row.i_draw_a is not None else 0.0),
r_out_ohm=(row.r_out_ohm
if row.r_out_ohm is not None else 0.0),
v_oc=row.v_oc, bonded=mt.bonded,
v_oc=row.v_oc, bonded=row.bonded,
component=row.component, comment=row.comment)
if pdn_cfg:
cfg_rows = selection.pdn_rows[:len(terminals)]
@@ -448,6 +457,7 @@ def main() -> None:
else:
t.r_out_ohm = row.r_out_ohm
t.v_oc = row.v_oc
t.bonded = row.bonded
t.component = row.component
t.comment = row.comment
terminals = (
+1 -1
View File
@@ -17,7 +17,7 @@
},
"versions": [
{
"version": "1.4.0",
"version": "1.4.2",
"status": "stable",
"kicad_version": "10.0",
"runtime": "ipc"
+1 -1
View File
@@ -3,7 +3,7 @@
# the dependency list there in sync with [project.dependencies].
[project]
name = "fill-resistance"
version = "1.4.0"
version = "1.4.2"
description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)"
license = "GPL-3.0-or-later"
requires-python = ">=3.11"
+14
View File
@@ -468,6 +468,20 @@ def test_active_and_comment_in_the_save_builders():
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="",
+69 -6
View File
@@ -212,8 +212,8 @@ def test_tables_split_by_role(app):
assert dlg.pdn_sup_table.item(0, 1).text() == "src"
assert dlg.pdn_load_table.item(0, 1).text() == "snk"
# supplies carry R_out + V_oc columns, loads only I draw
assert dlg.pdn_sup_table.columnCount() == 8
assert dlg.pdn_load_table.columnCount() == 7
assert dlg.pdn_sup_table.columnCount() == 9
assert dlg.pdn_load_table.columnCount() == 8
def test_table_titles_name_the_marker_layers(app):
@@ -292,10 +292,12 @@ def test_cell_flags(app):
for col in (1, 2, last - 1):
assert t.item(0, col).flags() & Qt.ItemIsEnabled
assert not (t.item(0, col).flags() & Qt.ItemIsEditable)
# the Active column is a checkbox, not an editable cell
assert t.item(0, 0).flags() & Qt.ItemIsUserCheckable
assert not (t.item(0, 0).flags() & Qt.ItemIsEditable)
# Active and Bonded are checkboxes, not editable cells
for col in (0, last - 3):
assert t.item(0, col).flags() & Qt.ItemIsUserCheckable
assert not (t.item(0, col).flags() & Qt.ItemIsEditable)
assert t.item(0, 0).checkState() == Qt.Checked
assert t.item(0, last - 3).checkState() == Qt.Unchecked
# the Layer column holds a combo, not a text item
assert t.cellWidget(0, last - 2) is not None
@@ -449,16 +451,28 @@ def test_comment_column_roundtrip(app):
assert sel.pdn_rows[1].comment == "worst case"
def test_bonded_flag_survives_the_table_roundtrip(app):
def test_bonded_checkbox_seeds_and_toggles(app):
setup = PdnSetup(rows=[
PdnTerminalRow(name="src", role="supply", resolved="r"),
PdnTerminalRow(name="pkg", role="load", resolved="2× rects",
bonded=True)], source="markers")
dlg = _dlg(app, pdn=setup, classic_reason="x")
_fill_pdn(dlg)
# seeds: the grouped load checked, the single supply not
bcol_s = dlg.pdn_sup_table.columnCount() - 4
bcol_l = dlg.pdn_load_table.columnCount() - 4
assert dlg.pdn_sup_table.item(0, bcol_s).checkState() == Qt.Unchecked
assert dlg.pdn_load_table.item(0, bcol_l).checkState() == Qt.Checked
sel = dlg._build_selection()
assert sel.pdn_rows[0].bonded is False
assert sel.pdn_rows[1].bonded is True
# editable both ways: unbond the group (area share), bond the
# single supply (equipotential lug contact)
dlg.pdn_load_table.item(0, bcol_l).setCheckState(Qt.Unchecked)
dlg.pdn_sup_table.item(0, bcol_s).setCheckState(Qt.Checked)
sel = dlg._build_selection()
assert sel.pdn_rows[0].bonded is True
assert sel.pdn_rows[1].bonded is False
def test_pdn_values_reach_the_selection(app):
@@ -576,6 +590,55 @@ def test_load_button_cancelled_picker_does_nothing(app, tmp_path,
assert not dlg.error_label.isVisible()
# --- no runnable mode: the load-only dialog ----------------------------------
def _no_mode_dlg(app, load_dir=None, save_callback=None):
"""Neither mode derivable (nothing selected, no marker rects, no
config): main opens the dialog anyway so a config can be loaded."""
return _Dialog({}, ORDER, "", "", "", "auto", "auto",
buildup_layers=[], pdn=None, pdn_candidates={},
classic_reason="nothing selected",
pdn_reason="no rectangles found",
save_callback=save_callback, load_dir=load_dir)
def test_no_mode_opens_load_only(app, tmp_path):
dlg = _no_mode_dlg(app, load_dir=tmp_path,
save_callback=lambda *_a: "x")
assert not dlg.mode_classic.isEnabled()
assert not dlg.mode_pdn.isEnabled()
# no mode is even checked - there is nothing to run
assert not dlg.mode_classic.isChecked()
assert not dlg.mode_pdn.isChecked()
assert not dlg.ok_button.isEnabled()
assert not dlg.save_button.isEnabled()
assert dlg.load_button.isEnabled()
# both reasons and the load hint are shown together
text = dlg.reason_label.text()
assert "nothing selected" in text
assert "no rectangles found" in text
assert "Load config…" in text
def test_no_mode_load_config_still_works(app, tmp_path, monkeypatch):
path = tmp_path / "fill_res_config.saved.json"
path.write_text('{"version": 1}', encoding="utf-8")
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.result = (str(path), "json")
dlg = _no_mode_dlg(app, load_dir=tmp_path)
dlg._load_config()
assert dlg._load_request == path
assert dlg.result() == QDialog.Accepted
def test_available_modes_keep_ok_and_save_enabled(app, tmp_path):
dlg = _dlg(app, pdn=_setup(), save_callback=lambda *_a: "x",
load_dir=tmp_path)
assert dlg.ok_button.isEnabled()
assert dlg.save_button.isEnabled()
assert dlg.load_button.isEnabled()
# --- save button -------------------------------------------------------------
def _patch_save(monkeypatch, name):
Generated
+1 -1
View File
@@ -216,7 +216,7 @@ wheels = [
[[package]]
name = "fill-resistance"
version = "1.4.0"
version = "1.4.2"
source = { virtual = "." }
dependencies = [
{ name = "kicad-python" },