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>
685 lines
27 KiB
Python
685 lines
27 KiB
Python
"""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()
|