Release 1.4.0: PDN mode, the config-file workflow, and the dialog editor
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 1m17s
tests / fedora:latest (push) Successful in 1m8s
tests / ubuntu:24.04 (push) Successful in 1m23s
tests / ubuntu-latest · py3.11 (push) Successful in 1m25s
tests / ubuntu-latest · py3.13 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 1m17s
tests / fedora:latest (push) Successful in 1m8s
tests / ubuntu:24.04 (push) Successful in 1m23s
tests / ubuntu-latest · py3.11 (push) Successful in 1m25s
tests / ubuntu-latest · py3.13 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
Multiple Thevenin supplies and prescribed-current loads on one net, solved in absolute volts with the Tellegen power balance verified per run; a source-sink pair table (effective copper resistance per supply x load pair plus an exactly-summing proportional-sharing loss attribution), in summary.txt and as its own figure. Bonded terminals short a package's contacts into one lug so the per-pin split becomes a solve outcome. Geometry dumps carry the terminal set (schema v8). The dialog gained a Classic/PDN mode selector and a full PDN editor: per-role supply/load tables built from the marker rectangles (or a config's terminal set, which never pins mode or net), with Component hints, per-terminal Layer scopes, Active checkboxes, comments, a per-net row filter, resizable tables and a scrolling, screen-sized dialog. Numbers accept SI suffixes (50m, 4.7k) everywhere. fill_res_config.json fully specifies a run (classic or PDN) with validation, comments, named side-by-side configs (the one called default auto-loads), Load/Save buttons with an editable file name, and saves that never drop anything drawn on the board. 347 tests, green on Python 3.13 and on the 3.9 macOS wheel stack. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,670 @@
|
||||
"""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() == 8
|
||||
assert dlg.pdn_load_table.columnCount() == 7
|
||||
|
||||
|
||||
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)
|
||||
# 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)
|
||||
assert t.item(0, 0).checkState() == Qt.Checked
|
||||
# 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_flag_survives_the_table_roundtrip(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)
|
||||
sel = dlg._build_selection()
|
||||
assert sel.pdn_rows[0].bonded is False
|
||||
assert sel.pdn_rows[1].bonded is True
|
||||
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user