"""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() # --- 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): 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()