Files
kicad-zone-resistance/tests/test_dialog.py
T
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

685 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Dialog construction and validation (offscreen Qt): defaults
injection, the Classic/PDN mode selector, the editable supply/load
tables with their Layer combos, and the Load-/Save-config buttons."""
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QDialog
from fill_resistance import config
from fill_resistance import dialog as dialog_mod
from fill_resistance.configfile import DialogDefaults, dialog_defaults
from fill_resistance.dialog import PdnSetup, PdnTerminalRow, _Dialog
CANDIDATES = {"VCC": ["F.Cu", "In1.Cu", "B.Cu"], "GND": ["F.Cu", "B.Cu"]}
PDN_CANDS = {"VCC": ["F.Cu", "B.Cu"], "V5": ["F.Cu"]}
ORDER = ["F.Cu", "In1.Cu", "B.Cu"]
@pytest.fixture(scope="module")
def app():
from PySide6.QtWidgets import QApplication
return QApplication.instance() or QApplication([])
def _rows(values=False):
return [PdnTerminalRow(name="src", role="supply", resolved="rect a",
component="J1",
r_out_ohm=0.004 if values else None,
v_oc=3.28 if values else None),
PdnTerminalRow(name="snk", role="load", resolved="rect b",
component="near U5",
i_draw_a=1.8 if values else None)]
def _setup(from_config=False, values=False, note=""):
return PdnSetup(rows=_rows(values), source="markers",
from_config=from_config, note=note)
def _dlg(app, defaults=None, pdn=None, pdn_candidates=None,
classic_reason=None, pdn_reason=None, save_callback=None,
load_dir=None, start_mode="classic", net="VCC"):
return _Dialog(CANDIDATES, ORDER, net, "e1", "e2", "auto", "auto",
buildup_layers=["F.Cu"], defaults=defaults, pdn=pdn,
pdn_candidates=(pdn_candidates if pdn_candidates
is not None else PDN_CANDS),
classic_reason=classic_reason, pdn_reason=pdn_reason,
save_callback=save_callback, load_dir=load_dir,
start_mode=start_mode)
def _fill_pdn(dlg, r_out="0.01", i_draw="2.0"):
"""Minimal valid PDN entries (supply table row 0, load table
row 0; the value columns start after Active | Name | Component)."""
dlg.pdn_sup_table.item(0, 3).setText(r_out)
dlg.pdn_load_table.item(0, 3).setText(i_draw)
# --- classic mode (unchanged behavior) ---------------------------------------
def test_defaults_seed_the_widgets(app):
d = DialogDefaults(net="VCC", layers=["F.Cu", "B.Cu"],
include_tracks=False, vias_capped=False,
cap_max_drill_mm=0.7, adaptive=False,
contact_model="equipotential", current_a=42.0,
freq_hz=142_000.0, cell_um=80.0,
include_buildup=True, extra_cu_um=50.0,
push_overlays=True, trim_enabled=True,
trim_mode="abs", trim_value=2.5)
dlg = _dlg(app, defaults=d)
assert dlg.tracks_check.isChecked() is False
assert dlg.capped_check.isChecked() is False
assert dlg.cap_drill_edit.text() == "0.7"
assert dlg.adaptive_check.isChecked() is False
assert dlg.model_box.currentData() == "equipotential"
assert dlg.current_edit.text() == "42"
assert dlg.freq_edit.text() == "142000"
assert dlg.cell_edit.text() == "80"
assert dlg.buildup_check.isChecked() is True
assert dlg.extracu_edit.text() == "50"
assert dlg.overlay_check.isChecked() is True
assert dlg.trim_check.isChecked() is True
assert dlg.trim_mode_box.currentData() == "abs"
assert dlg.trim_edit.text() == "2.5"
# layer subset: In1.Cu was not in the config's list
assert dlg.checked_layers() == ["F.Cu", "B.Cu"]
sel = dlg._build_selection()
assert sel.mode == "classic"
assert sel.current_a == pytest.approx(42.0)
assert sel.trim_value == pytest.approx(2.5)
assert sel.layers == ["F.Cu", "B.Cu"]
assert sel.pdn_rows is None and sel.v_nominal is None
def test_no_defaults_behaves_like_config_constants(app):
dlg = _dlg(app)
assert dlg.current_edit.text() == f"{config.TEST_CURRENT_A:g}"
assert dlg.checked_layers() == ORDER # everything checked
sel = dlg._build_selection()
assert sel.mode == "classic"
def test_classic_validation_still_rejects_bad_current(app):
dlg = _dlg(app)
dlg.current_edit.setText("-3")
with pytest.raises(ValueError, match="Test current"):
dlg._build_selection()
dlg.current_edit.setText("nope")
with pytest.raises(ValueError, match="not a number"):
dlg._build_selection()
# --- mode selector -----------------------------------------------------------
def test_mode_radio_labels(app):
# deliberately NOT "two-terminal": classic terminals may bundle
# many contact parts, the old label read like a 2-contact cap
dlg = _dlg(app, pdn=_setup())
assert dlg.mode_classic.text() == "Classic"
assert dlg.mode_pdn.text() == "PDN"
def test_both_modes_available_starts_classic_and_switches(app):
dlg = _dlg(app, pdn=_setup())
assert dlg.mode_classic.isChecked()
assert dlg.mode_classic.isEnabled() and dlg.mode_pdn.isEnabled()
assert dlg.pdn_section.isHidden()
assert not dlg.classic_section.isHidden()
dlg.mode_pdn.setChecked(True)
assert dlg.classic_section.isHidden()
assert not dlg.pdn_section.isHidden()
_fill_pdn(dlg)
sel = dlg._build_selection()
assert sel.mode == "pdn"
dlg.mode_classic.setChecked(True)
assert dlg._build_selection().mode == "classic"
def test_pdn_radio_disabled_with_reason(app):
dlg = _dlg(app, pdn=None, pdn_reason="no rectangles found")
assert dlg.mode_pdn.isEnabled() is False
assert dlg.mode_classic.isChecked()
assert dlg.pdn_section is None
assert "no rectangles found" in dlg.mode_pdn.toolTip()
def test_starts_in_pdn_when_classic_unavailable(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="two contacts needed")
assert dlg.mode_pdn.isChecked()
assert dlg.mode_classic.isEnabled() is False
assert dlg.classic_section is None
assert dlg.contact1_box is None and dlg.current_edit is None
def test_net_combo_swaps_with_the_mode(app):
dlg = _dlg(app, pdn=_setup(), net="VCC")
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
assert items == sorted(CANDIDATES)
dlg.mode_pdn.setChecked(True)
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
assert items == sorted(PDN_CANDS)
assert dlg.net_box.currentText() == "VCC" # shared net survives
assert dlg.net_box.isEnabled() # editor mode: free
dlg.mode_classic.setChecked(True)
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
assert items == sorted(CANDIDATES)
# --- config-backed PDN -------------------------------------------------------
def test_config_backed_rows_prefill_and_edit_values(app):
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
classic_reason="nothing selected",
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
assert dlg.mode_pdn.isChecked() # classic unavailable
assert dlg.net_box.isEnabled() # net is NOT pinned
assert dlg.pdn_sup_table.item(0, 3).text() == "0.004"
assert dlg.pdn_sup_table.item(0, 4).text() == "3.28"
assert dlg.pdn_load_table.item(0, 3).text() == "1.8"
dlg.pdn_load_table.item(0, 3).setText("2.5") # tweak the draw
dlg.pdn_sup_table.item(0, 4).setText("") # v_oc back to nominal
sel = dlg._build_selection()
assert sel.mode == "pdn"
assert sel.pdn_rows[1].i_draw_a == pytest.approx(2.5)
assert sel.pdn_rows[0].r_out_ohm == pytest.approx(0.004)
assert sel.pdn_rows[0].v_oc is None
assert sel.current_a == pytest.approx(2.5) # summed draw
def test_config_never_pins_the_mode(app):
# a config-backed PDN setup with classic available: starts in PDN
# (start_mode from cfg.mode) but classic stays one click away
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
start_mode="pdn")
assert dlg.mode_pdn.isChecked()
assert dlg.mode_classic.isEnabled()
assert dlg.net_box.isEnabled()
dlg.mode_classic.setChecked(True)
sel = dlg._build_selection()
assert sel.mode == "classic"
# --- the editable tables -----------------------------------------------------
def test_tables_split_by_role(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
assert dlg.pdn_sup_table.rowCount() == 1
assert dlg.pdn_load_table.rowCount() == 1
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() == 9
assert dlg.pdn_load_table.columnCount() == 8
def test_table_titles_name_the_marker_layers(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
assert config.ELECTRODE_POS_LAYER in dlg.pdn_sup_label.text()
assert config.ELECTRODE_NEG_LAYER in dlg.pdn_load_label.text()
# config-backed setups too: the titles say where a NEW rectangle
# becomes a new terminal, whatever the current rows' source
backed = _dlg(app, pdn=_setup(from_config=True, values=True),
classic_reason="nothing selected")
assert config.ELECTRODE_POS_LAYER in backed.pdn_sup_label.text()
assert config.ELECTRODE_NEG_LAYER in backed.pdn_load_label.text()
def test_component_column_identifies_the_row(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
assert dlg.pdn_sup_table.item(0, 2).text() == "J1"
assert dlg.pdn_load_table.item(0, 2).text() == "near U5"
assert not (dlg.pdn_sup_table.item(0, 2).flags() & Qt.ItemIsEditable)
_fill_pdn(dlg)
sel = dlg._build_selection()
assert sel.pdn_rows[1].component == "near U5"
def test_pdn_mode_opens_with_a_roomy_default_size(app):
from PySide6.QtWidgets import QApplication
avail = QApplication.primaryScreen().availableGeometry()
dlg = _dlg(app, pdn=_setup(), classic_reason="x") # starts in PDN
assert dlg.height() >= int(avail.height() * 0.6)
assert dlg.height() <= int(avail.height() * 0.85)
assert dlg.width() <= int(avail.width() * 0.9)
# switching to PDN grows a classic-sized dialog the same way
both = _dlg(app, pdn=_setup())
both.mode_pdn.setChecked(True)
assert both.height() >= int(avail.height() * 0.6)
def test_tables_resize_and_the_dialog_scrolls(app):
from PySide6.QtWidgets import QSplitter
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
# the form scrolls; the error line and buttons stay outside
assert dlg._scroll.widgetResizable()
assert dlg.error_label.parent() is dlg
# tables sit in a draggable vertical splitter, no fixed height cap
assert isinstance(dlg.pdn_splitter, QSplitter)
assert dlg.pdn_splitter.orientation() == Qt.Vertical
assert dlg.pdn_splitter.count() == 2
assert not dlg.pdn_splitter.childrenCollapsible()
assert dlg.pdn_sup_table.maximumHeight() > 100_000
assert dlg.pdn_load_table.maximumHeight() > 100_000
def test_row_order_is_preserved_across_the_split(app):
rows = [PdnTerminalRow(name="l1", role="load", resolved="a"),
PdnTerminalRow(name="s1", role="supply", resolved="b"),
PdnTerminalRow(name="l2", role="load", resolved="c")]
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
classic_reason="x")
dlg.pdn_sup_table.item(0, 3).setText("0.01")
dlg.pdn_load_table.item(0, 3).setText("1")
dlg.pdn_load_table.item(1, 3).setText("2")
sel = dlg._build_selection()
assert [r.name for r in sel.pdn_rows] == ["l1", "s1", "l2"]
assert sel.pdn_rows[0].i_draw_a == pytest.approx(1.0)
assert sel.pdn_rows[2].i_draw_a == pytest.approx(2.0)
def test_cell_flags(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
for t, value_cols in ((dlg.pdn_sup_table, (3, 4)),
(dlg.pdn_load_table, (3,))):
last = t.columnCount() - 1
for col in value_cols + (last,): # values + Comment editable
assert t.item(0, col).flags() & Qt.ItemIsEditable
# name / component / contact parts: visible but read-only
for col in (1, 2, last - 1):
assert t.item(0, col).flags() & Qt.ItemIsEnabled
assert not (t.item(0, col).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
def test_layer_combos_default_to_all_layers(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
combos = dlg._pdn_layer_combos
assert [c.currentData() for c in combos] == ["all", "all"]
# live editor: no "auto" entry (a rectangle's natural scope IS all)
items = [combos[0].itemData(i) for i in range(combos[0].count())]
assert items == ["all", "F.Cu", "B.Cu"] # the PDN net's layers
def test_layer_pick_reaches_the_selection(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
_fill_pdn(dlg)
combo = dlg._pdn_layer_combos[1] # the load row
combo.setCurrentIndex(combo.findData("B.Cu"))
sel = dlg._build_selection()
assert sel.pdn_rows[0].contact == "all"
assert sel.pdn_rows[1].contact == "B.Cu"
def test_config_backed_combos_offer_auto_and_seed_the_scope(app):
rows = _rows(values=True)
rows[0].contact = "F.Cu"
rows[0].from_config = True
rows[1].contact = "auto"
rows[1].from_config = True
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="cfg",
from_config=True),
classic_reason="nothing selected",
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
combos = dlg._pdn_layer_combos
assert combos[0].currentData() == "F.Cu"
assert combos[1].currentData() == "auto"
assert combos[1].itemData(0) == "auto" # schema default first
def test_auto_scope_is_per_row_in_a_mixed_setup(app):
# a config-backed setup may also carry NEWLY drawn rectangles:
# only the config rows offer the "auto" scope
rows = _rows(values=True)
rows[0].from_config = True # file terminal
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="cfg",
from_config=True),
classic_reason="nothing selected",
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
combos = dlg._pdn_layer_combos
assert combos[0].findData("auto") >= 0 # config row
assert combos[1].findData("auto") == -1 # new rectangle
def test_layer_combos_follow_the_net(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x", net="VCC")
combo = dlg._pdn_layer_combos[0]
assert combo.findData("B.Cu") >= 0
dlg.net_box.setCurrentText("V5") # V5 copper: F.Cu only
assert combo.findData("B.Cu") == -1
assert combo.findData("F.Cu") >= 0
assert combo.currentData() == "all"
def test_rows_filter_by_the_selected_net(app):
rows = [PdnTerminalRow(name="s1", role="supply", resolved="a",
nets=frozenset({"VCC", "V5"})),
PdnTerminalRow(name="l1", role="load", resolved="b",
nets=frozenset({"VCC"})),
PdnTerminalRow(name="l2", role="load", resolved="c",
nets=frozenset({"V5"}))]
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
classic_reason="x", net="VCC")
assert not dlg.pdn_sup_table.isRowHidden(0)
assert not dlg.pdn_load_table.isRowHidden(0) # l1 on VCC
assert dlg.pdn_load_table.isRowHidden(1) # l2 is not
assert "1 not on this net: hidden" in dlg.pdn_totals.text()
# the hidden row is exempt from validation and the summed draw,
# but it still comes back - with active False and everything it
# holds - so a save keeps it in the config file
dlg.pdn_sup_table.item(0, 3).setText("0.01")
dlg.pdn_load_table.item(0, 3).setText("2")
dlg.pdn_load_table.item(1, 3).setText("7") # value on hidden l2
sel = dlg._build_selection()
assert sel.pdn_rows[2].active is False # off-net: not in run
assert sel.pdn_rows[2].i_draw_a == pytest.approx(7.0) # kept
assert sel.pdn_rows[1].active is True
assert sel.pdn_rows[1].i_draw_a == pytest.approx(2.0)
assert sel.current_a == pytest.approx(2.0)
# switching the net swaps the visible set
dlg.net_box.setCurrentText("V5")
assert dlg.pdn_load_table.isRowHidden(0)
assert not dlg.pdn_load_table.isRowHidden(1)
def test_rows_without_net_info_always_show(app):
# rows without net info (nets=None) are never filtered
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
classic_reason="nothing selected")
assert not dlg.pdn_sup_table.isRowHidden(0)
assert not dlg.pdn_load_table.isRowHidden(0)
assert "hidden" not in dlg.pdn_totals.text()
def test_unchecking_a_row_takes_it_out_of_the_run(app):
rows = [PdnTerminalRow(name="src", role="supply", resolved="a"),
PdnTerminalRow(name="big", role="load", resolved="b"),
PdnTerminalRow(name="small", role="load", resolved="c")]
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
classic_reason="x")
dlg.pdn_sup_table.item(0, 3).setText("0.01")
dlg.pdn_load_table.item(0, 3).setText("4")
# "small" stays BLANK and unchecked: no validation error, and it
# comes back inactive instead of being dropped (it is still saved)
dlg.pdn_load_table.item(1, 0).setCheckState(Qt.Unchecked)
assert "1 disabled" in dlg.pdn_totals.text()
assert "1 supplies, 1 loads, 4 A total draw" in dlg.pdn_totals.text()
sel = dlg._build_selection()
assert sel.pdn_rows[2].active is False
assert sel.pdn_rows[2].i_draw_a is None
assert sel.pdn_rows[1].active is True
assert sel.current_a == pytest.approx(4.0)
# a value typed into a disabled row must still be a number
dlg.pdn_load_table.item(1, 3).setText("junk")
with pytest.raises(ValueError, match="not a number"):
dlg._build_selection()
def test_all_rows_of_a_role_unchecked_blocks_ok(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
_fill_pdn(dlg)
dlg.pdn_load_table.item(0, 0).setCheckState(Qt.Unchecked)
with pytest.raises(ValueError, match="At least one active load"):
dlg._build_selection()
def test_comment_column_roundtrip(app):
rows = _rows()
rows[1].comment = "camera burst draw"
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
classic_reason="x")
last = dlg.pdn_load_table.columnCount() - 1
assert dlg.pdn_load_table.item(0, last).text() == "camera burst draw"
_fill_pdn(dlg)
dlg.pdn_load_table.item(0, last).setText("worst case")
slast = dlg.pdn_sup_table.columnCount() - 1
dlg.pdn_sup_table.item(0, slast).setText("buck output")
sel = dlg._build_selection()
assert sel.pdn_rows[0].comment == "buck output"
assert sel.pdn_rows[1].comment == "worst case"
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):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
dlg.pdn_sup_table.item(0, 3).setText("50m") # SI suffix = 0.05
dlg.pdn_sup_table.item(0, 4).setText("3.28")
dlg.pdn_load_table.item(0, 3).setText("1,5") # decimal comma ok
sel = dlg._build_selection()
src, snk = sel.pdn_rows
assert src.r_out_ohm == pytest.approx(0.05)
assert src.v_oc == pytest.approx(3.28)
assert snk.i_draw_a == pytest.approx(1.5)
assert sel.v_nominal == pytest.approx(config.PDN_V_NOMINAL)
def test_si_suffixes_work_in_every_number_field(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
dlg.pdn_sup_table.item(0, 3).setText("4m")
dlg.pdn_sup_table.item(0, 4).setText("3300m")
dlg.pdn_load_table.item(0, 3).setText("1k") # 1000 A: silly, legal
dlg.vnominal_edit.setText("5000m")
dlg.cell_edit.setText("0.1k")
sel = dlg._build_selection()
assert sel.pdn_rows[0].r_out_ohm == pytest.approx(0.004)
assert sel.pdn_rows[0].v_oc == pytest.approx(3.3)
assert sel.pdn_rows[1].i_draw_a == pytest.approx(1000.0)
assert sel.v_nominal == pytest.approx(5.0)
assert sel.cell_um == pytest.approx(100.0)
@pytest.mark.parametrize("prepare, match", [
(lambda d: _fill_pdn(d, i_draw=""), "'snk': I draw is required"),
(lambda d: _fill_pdn(d, i_draw="-1"), "'snk': I draw must be"),
(lambda d: _fill_pdn(d, i_draw="abc"), "not a number"),
(lambda d: _fill_pdn(d, r_out=""), "'src': R_out is required"),
(lambda d: _fill_pdn(d, r_out="-2"), "'src': R_out must be"),
(lambda d: (_fill_pdn(d), d.pdn_sup_table.item(0, 4).setText("0")),
"'src': V_oc must be > 0"),
(lambda d: (_fill_pdn(d), d.vnominal_edit.setText("")),
"V nominal is required"),
(lambda d: (_fill_pdn(d), d.vnominal_edit.setText("0")),
"V nominal must be > 0"),
])
def test_pdn_validation_errors(app, prepare, match):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
prepare(dlg)
with pytest.raises(ValueError, match=match):
dlg._build_selection()
def test_v_nominal_seeds_from_defaults(app):
d = dialog_defaults(None)
d.v_nominal = 5.0
dlg = _dlg(app, defaults=d, pdn=_setup(), classic_reason="x")
assert dlg.vnominal_edit.text() == "5"
_fill_pdn(dlg)
assert dlg._build_selection().v_nominal == pytest.approx(5.0)
def test_totals_label_follows_edits(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
assert "0 A total draw" in dlg.pdn_totals.text()
dlg.pdn_load_table.item(0, 3).setText("2.5")
assert "1 supplies, 1 loads, 2.5 A total draw" in dlg.pdn_totals.text()
dlg.pdn_load_table.item(0, 3).setText("2500m") # suffix counted too
assert "2.5 A total draw" in dlg.pdn_totals.text()
# --- load button -------------------------------------------------------------
class _FakePicker:
"""Stands in for QFileDialog (a native picker cannot run in the
offscreen test session)."""
result = ("", "")
save_result = ("", "")
@staticmethod
def getOpenFileName(*_args, **_kwargs):
return _FakePicker.result
@staticmethod
def getSaveFileName(*_args, **_kwargs):
return _FakePicker.save_result
def test_load_button_returns_a_load_request(app, tmp_path, monkeypatch):
path = tmp_path / "fill_res_config.other.json"
path.write_text('{"version": 1}', encoding="utf-8")
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.result = (str(path), "json")
dlg = _dlg(app, load_dir=tmp_path)
dlg._load_config()
assert dlg._load_request == path
assert dlg.result() == QDialog.Accepted
def test_load_button_rejects_an_invalid_config(app, tmp_path, monkeypatch):
path = tmp_path / "broken.json"
path.write_text('{"version": []}', encoding="utf-8")
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.result = (str(path), "json")
dlg = _dlg(app, load_dir=tmp_path)
dlg._load_config()
assert dlg._load_request is None # stays open instead
assert "version" in dlg.error_label.text()
def test_load_button_cancelled_picker_does_nothing(app, tmp_path,
monkeypatch):
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.result = ("", "")
dlg = _dlg(app, load_dir=tmp_path)
dlg._load_config()
assert dlg._load_request is None
assert not dlg.error_label.isVisible()
# --- save button -------------------------------------------------------------
def _patch_save(monkeypatch, name):
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.save_result = (name, "json")
def test_save_button_valid_selection_reaches_callback(app, monkeypatch):
got = {}
def cb(sel, target):
got["sel"], got["target"] = sel, target
return target.name
_patch_save(monkeypatch, "x.fill_res_config.json")
dlg = _dlg(app, save_callback=cb)
dlg._save_config()
assert got["sel"].mode == "classic"
assert got["target"].name == "x.fill_res_config.json"
assert "saved to x.fill_res_config.json" in dlg.error_label.text()
# an invalid field blocks the save BEFORE the file picker opens
got.clear()
dlg.current_edit.setText("bogus")
dlg._save_config()
assert not got
assert "not a number" in dlg.error_label.text()
def test_save_name_is_editable_and_sticky(app, monkeypatch, tmp_path):
got = {}
def cb(sel, target):
got["target"] = target
return target.name
_patch_save(monkeypatch, str(tmp_path / "fill_res_config.exp.json"))
dlg = _dlg(app, save_callback=cb)
assert dlg._save_target is None # seeded by main normally
dlg._save_config()
assert got["target"] == tmp_path / "fill_res_config.exp.json"
# the chosen name seeds the next save's picker
assert dlg._save_target == tmp_path / "fill_res_config.exp.json"
def test_save_appends_the_json_suffix(app, monkeypatch):
got = {}
def cb(sel, target):
got["target"] = target
return target.name
_patch_save(monkeypatch, "experiment") # typed without a suffix
dlg = _dlg(app, save_callback=cb)
dlg._save_config()
assert got["target"].name == "experiment.json"
def test_save_picker_cancel_does_nothing(app, monkeypatch):
def cb(sel, target):
raise AssertionError("must not be called")
_patch_save(monkeypatch, "")
dlg = _dlg(app, save_callback=cb)
dlg._save_config()
assert not dlg.error_label.isVisible()
def test_save_button_works_in_config_backed_pdn_mode(app, monkeypatch):
got = {}
def cb(sel, target):
got["sel"] = sel
return "y.json"
_patch_save(monkeypatch, "y.json")
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
classic_reason="nothing selected", save_callback=cb)
dlg._save_config()
assert got["sel"].mode == "pdn"
assert got["sel"].pdn_rows[1].i_draw_a == pytest.approx(1.8)
assert "saved to y.json" in dlg.error_label.text()
def test_save_callback_failure_is_shown_not_raised(app, monkeypatch):
def cb(sel, target):
raise RuntimeError("disk full")
_patch_save(monkeypatch, "z.json")
dlg = _dlg(app, save_callback=cb)
dlg._save_config()
assert "disk full" in dlg.error_label.text()