diff --git a/PyReconstruct/modules/backend/table/manager.py b/PyReconstruct/modules/backend/table/manager.py index 0e4d1e00..24a9d7aa 100644 --- a/PyReconstruct/modules/backend/table/manager.py +++ b/PyReconstruct/modules/backend/table/manager.py @@ -37,6 +37,12 @@ def __init__(self, series : Series, section : Section, series_states, mainwindow self.section = section self.mainwindow = mainwindow self.series_states = series_states + + # allow the list docks to nest/tab into a single group (UI v1 Slice 3) + self.mainwindow.setDockNestingEnabled(True) + + # guards open_tables persistence during programmatic teardown (closeAll) + self._suppress_open_tables_sync = False def newTable(self, table_type : str, section=None): """Create a new object list widget.""" @@ -55,9 +61,56 @@ def newTable(self, table_type : str, section=None): ) new_table = table_type_classes[table_type](*args) + + # find an already-open list dock to tab the new one onto, so all list + # types share a single left dock group (UI v1 Slice 3) + anchor = None + for tt in self.tables: + if self.tables[tt]: + anchor = self.tables[tt][0] + break + self.tables[table_type].append(new_table) self.mainwindow.addDockWidget(Qt.LeftDockWidgetArea, new_table) + if anchor is not None: + self.mainwindow.tabifyDockWidget(anchor, new_table) + + self._syncOpenTables(table_type, True) + + def _syncOpenTables(self, table_type : str, is_open : bool): + """Add/remove a list type from the global open_tables option so the panel + restores the same lists next open. Writes back a NEW list (the qsettings + defaults are a shallow copy, so never mutate the returned list in place).""" + if self._suppress_open_tables_sync or self.series is None: + return + current = list(self.series.getOption("open_tables")) + if is_open and table_type not in current: + current.append(table_type) + elif not is_open and table_type in current: + current.remove(table_type) + else: + return + self.series.setOption("open_tables", current) + + def onTableClosed(self, table_type : str): + """Called from DataTable.closeEvent after a dock removes itself; drop the + type from open_tables only when no dock of that type remains.""" + if self.tables.get(table_type): + return + self._syncOpenTables(table_type, False) + + def listsPanelCollapsed(self) -> bool: + """Whether the left lists panel is collapsed (global preference).""" + return self.series.getOption("lists_panel_collapsed") + + def setListsPanelCollapsed(self, collapsed : bool): + """Hide/show every list dock and persist the choice. Collapse HIDES the + docks (it does not close them), so open_tables is untouched.""" + for tt in self.tables: + for t in self.tables[tt]: + t.setHidden(collapsed) + self.series.setOption("lists_panel_collapsed", collapsed) def updateObjects(self, obj_names : list = None, clear_tracking=True): """Update the object info for the OBJECT AND TRACE LISTS ONLY. @@ -185,6 +238,14 @@ def refresh(self): def closeAll(self): """Close all tables.""" - for n, l in self.tables.items(): - for t in l: - t.close() + # iterate a copy: each close() fires closeEvent, which removes the table + # from self.tables[name] mid-iteration (data_table.py) and would skip tables. + # suppress open_tables sync: this is programmatic teardown (e.g. series + # switch), not the user closing lists, so the saved preference must persist. + self._suppress_open_tables_sync = True + try: + for n, l in self.tables.items(): + for t in l.copy(): + t.close() + finally: + self._suppress_open_tables_sync = False diff --git a/PyReconstruct/modules/datatypes/default_settings.py b/PyReconstruct/modules/datatypes/default_settings.py index 2b263ad8..d5b6115b 100644 --- a/PyReconstruct/modules/datatypes/default_settings.py +++ b/PyReconstruct/modules/datatypes/default_settings.py @@ -158,6 +158,10 @@ def get_username() -> str: "scale_bar_width": 25, # displayed as this percentage of the screen (min should be 20) "show_scale_bar_text": True, "show_scale_bar_ticks": True, + + # lists panel (UI v1 Slice 3) — global, not per-series + "lists_panel_collapsed": False, # left list-dock group collapsed? + "open_tables": ["object"], # which list docks auto-open (Object promoted/default-on) } default_series_settings = { diff --git a/PyReconstruct/modules/gui/main/field_widget_1_base.py b/PyReconstruct/modules/gui/main/field_widget_1_base.py index 9c4403c5..e5c5a9a8 100644 --- a/PyReconstruct/modules/gui/main/field_widget_1_base.py +++ b/PyReconstruct/modules/gui/main/field_widget_1_base.py @@ -155,6 +155,9 @@ def createField(self, series : Series): self.mainwindow, ) + ## Auto-open the promoted lists (UI v1 Slice 3) + self._restoreOpenTables() + ## Reset cursor self.mouse_mode = 0 self.setCursor(QCursor(Qt.ArrowCursor)) @@ -475,11 +478,23 @@ def reloadImage(self) -> None: def openList(self, list_type : str): """Open a list. - + Params: list_type (str): object, trace, section, ztrace, or flag """ self.table_manager.newTable(list_type, self.section) + + def _restoreOpenTables(self): + """Auto-open the saved list docks on series open (UI v1 Slice 3). The + welcome series opens nothing. Iterates a snapshot since openList -> newTable + updates the open_tables option as each dock opens. Unknown types (corrupted + QSettings, or a type removed in a future version) are skipped, not fatal.""" + if self.series.isWelcomeSeries(): + return + from PyReconstruct.modules.backend.table.manager import table_type_classes + for list_type in list(self.series.getOption("open_tables")): + if list_type in table_type_classes: + self.openList(list_type) def updateData(self, clear_tracking=True) -> None: """Update the series data object and the tables. diff --git a/PyReconstruct/modules/gui/main/main_window.py b/PyReconstruct/modules/gui/main/main_window.py index 720156f9..1557e31e 100644 --- a/PyReconstruct/modules/gui/main/main_window.py +++ b/PyReconstruct/modules/gui/main/main_window.py @@ -274,6 +274,9 @@ def checkActions(self, context_menu=False, clicked_trace=None, clicked_label=Non self.togglebc_act.setChecked(not self.mouse_palette.bc_hidden) self.togglesb_act.setChecked(not self.mouse_palette.sb_hidden) + ## Check for lists panel collapse (UI v1 Slice 3) + self.togglelistspanel_act.setChecked(self.field.table_manager.listsPanelCollapsed()) + ## Group visibility for group, viz in self.series.groups_visibility.items(): try: @@ -762,6 +765,7 @@ def openSeries(self, series_obj=None, jser_fp=None, query_prev=True): # create the menus self.createMenuBar() + self._applyListsPanelState() self.createContextMenus() if not self.actions_initialized: self.createShortcuts() @@ -1703,7 +1707,23 @@ def toggleZtraces(self): self.field.deselectAllTraces() self.series.setOption("show_ztraces", not self.series.getOption("show_ztraces")) self.field.generateView(generate_image=False) - + + def toggleListsPanel(self): + """Collapse/expand the left lists dock panel (UI v1 Slice 3).""" + manager = self.field.table_manager + collapsed = not manager.listsPanelCollapsed() + manager.setListsPanelCollapsed(collapsed) + self.togglelistspanel_act.setChecked(collapsed) + + def _applyListsPanelState(self): + """Apply the saved lists-panel collapsed state to the docks and sync the + menu checkbox. Called after the menu is (re)built on series open / restart, + once the docks have been restored (UI v1 Slice 3).""" + manager = self.field.table_manager + collapsed = manager.listsPanelCollapsed() + manager.setListsPanelCollapsed(collapsed) + self.togglelistspanel_act.setChecked(collapsed) + def setToObject(self, obj_name : str, section_num : int): """Focus the field on an object from a specified section. diff --git a/PyReconstruct/modules/gui/main/menubar.py b/PyReconstruct/modules/gui/main/menubar.py index 7aaf5dab..868bd07f 100644 --- a/PyReconstruct/modules/gui/main/menubar.py +++ b/PyReconstruct/modules/gui/main/menubar.py @@ -348,6 +348,7 @@ def return_view_menu(self): ("findview_act", "Set zoom when finding contours...", "", self.setFindZoom), None, ("toggleztraces_act", "Toggle show Z-traces", "", self.toggleZtraces), + ("togglelistspanel_act", "Collapse lists panel", "checkbox", self.toggleListsPanel), None, { "attr_name": "palettemenu", diff --git a/PyReconstruct/modules/gui/table/data_table.py b/PyReconstruct/modules/gui/table/data_table.py index 67941e12..a531fbc5 100644 --- a/PyReconstruct/modules/gui/table/data_table.py +++ b/PyReconstruct/modules/gui/table/data_table.py @@ -347,5 +347,6 @@ def copy(self): def closeEvent(self, event): """Remove self from manager table list.""" self.manager.tables[self.name].remove(self) + self.manager.onTableClosed(self.name) super().closeEvent(event) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..09139a33 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,55 @@ +"""Shared test fixtures. + +The autouse ``_isolate_qsettings`` fixture redirects every per-user config location +(env + Qt's QSettings paths) to a session temp dir BEFORE any QSettings or QApplication +is constructed, so a test run can never read or clobber the user's real preferences. +It asserts the isolation actually took effect and aborts the run otherwise — important +for unattended runs that exercise setOption/getOption round-trips. +""" + +import os + +import pytest + +# Project-wide QSettings identity (mirrors PyReconstruct.modules.gui.utils.theme). +QSETTINGS_ORG = "KHLab" +QSETTINGS_APP = "PyReconstruct" + + +@pytest.fixture(scope="session", autouse=True) +def _isolate_qsettings(tmp_path_factory): + cfg = tmp_path_factory.mktemp("qsettings_home") + + # Belt: redirect the env vars Qt / the OS use to locate per-user config + data. + for var in ("HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "APPDATA", "LOCALAPPDATA"): + os.environ[var] = str(cfg) + + # Suspenders: pin QSettings to the temp dir explicitly (works even if env is ignored). + try: + from PySide6.QtCore import QSettings + except Exception: + # No Qt available — env redirect still stands; pure-logic tests run fine. + yield cfg + return + + QSettings.setDefaultFormat(QSettings.IniFormat) + for scope in (QSettings.UserScope, QSettings.SystemScope): + QSettings.setPath(QSettings.IniFormat, scope, str(cfg)) + + resolved = QSettings(QSETTINGS_ORG, QSETTINGS_APP).fileName() + assert str(cfg) in resolved, ( + f"QSettings is NOT isolated to the temp dir (resolved to {resolved!r}); " + "aborting so the run cannot touch real user preferences." + ) + + yield cfg + + +@pytest.fixture(scope="session") +def qapp(): + """A single QApplication for widget tests (offscreen). Reuses the singleton so it + coexists with any module-local qapp fixtures.""" + pytest.importorskip("PySide6") + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) diff --git a/tests/test_lists_panel.py b/tests/test_lists_panel.py new file mode 100644 index 00000000..47decdca --- /dev/null +++ b/tests/test_lists_panel.py @@ -0,0 +1,474 @@ +"""UI v1 Slice 3 — lists-left tabbed/collapsible panel (headless backbone). + +These tests cover BEHAVIOR only (offscreen); the look is signed off interactively. +QSettings is isolated to a temp dir by the session-autouse fixture in conftest.py, so +the getOption/setOption round-trips never touch real user preferences. +""" + +import pytest + +pytest.importorskip("PySide6") + +from PySide6.QtCore import QSettings # noqa: E402 + +from PyReconstruct.modules.datatypes.series import Series # noqa: E402 + + +def _global_series(): + """A minimal Series instance for exercising GLOBAL (computer-wide) options. + + The global getOption/setOption path only reads ``self.options`` and the class-level + ``qsettings_defaults``; it never needs the heavy jser construction, so __new__ + an + empty options dict is sufficient and isolated. + """ + s = Series.__new__(Series) + s.options = {} + return s + + +def _clear(*keys): + settings = QSettings("KHLab", "PyReconstruct") + for k in keys: + settings.remove(k) + settings.sync() + + +# --- B1: register the two global QSettings defaults ------------------------------- + +def test_b1_keys_registered_global_with_defaults(): + assert Series.qsettings_defaults.get("lists_panel_collapsed") is False + assert Series.qsettings_defaults.get("open_tables") == ["object"] + + +def test_b1_keys_are_global_not_per_series_or_internal(): + # not per-series + assert "lists_panel_collapsed" not in Series.qsettings_series_defaults + assert "open_tables" not in Series.qsettings_series_defaults + # not an internal per-series option (so the series.py:440-444 prune never touches them) + empty_opts = Series.getEmptyDict()["options"] + assert "lists_panel_collapsed" not in empty_opts + assert "open_tables" not in empty_opts + + +def test_b1_getOption_returns_defaults_when_unset(): + _clear("lists_panel_collapsed", "open_tables") + s = _global_series() + assert s.getOption("lists_panel_collapsed") is False + assert s.getOption("open_tables") == ["object"] + + +def test_b1_setOption_getOption_roundtrip(): + s = _global_series() + s.setOption("lists_panel_collapsed", True) + assert s.getOption("lists_panel_collapsed") is True + s.setOption("open_tables", ["object", "trace"]) + assert s.getOption("open_tables") == ["object", "trace"] + + +def test_b1_resolves_via_global_qsettings_not_internal_options(): + # The keys must route through the global QSettings("KHLab","PyReconstruct"), + # not self.options. A fresh instance with empty options still resolves them. + s = _global_series() + assert "open_tables" not in s.options + s.setOption("open_tables", ["object", "ztrace"]) + # value lives in global QSettings, readable by an independent instance + other = _global_series() + assert other.getOption("open_tables") == ["object", "ztrace"] + + +# --- B2: tabify the list docks into one left group + fix closeAll skip-bug -------- + +import PyReconstruct.modules.backend.table.manager as mgrmod # noqa: E402 + + +class _StubDock: + """Stand-in for a DataTable: a no-widget object that mirrors the one behavior + closeAll depends on — closeEvent removing itself from manager.tables[name].""" + def __init__(self, manager, name): + self.manager = manager + self.name = name + self.closed = False + + def close(self): + self.closed = True + self.manager.tables[self.name].remove(self) # mirrors data_table.py:351 + + +def test_b2_closeall_closes_every_table(): + # Reproduces the mutate-while-iterate skip-bug: closing a table removes it from + # the same list closeAll iterates, so a plain `for t in l` skips half. + mgr = mgrmod.TableManager.__new__(mgrmod.TableManager) + mgr.tables = {tt: [] for tt in mgrmod.table_type_classes} + tables = [_StubDock(mgr, "object") for _ in range(4)] + mgr.tables["object"].extend(tables) + + mgr.closeAll() + + assert mgr.tables["object"] == [], "closeAll left tables open (skip-bug)" + assert all(t.closed for t in tables), "not every table received close()" + + +def _qmainwindow(): + from PySide6.QtWidgets import QMainWindow + return QMainWindow() + + +def _stub_table_classes(): + from PySide6.QtWidgets import QDockWidget, QWidget + + class StubTableDock(QDockWidget): + def __init__(self, *args): + super().__init__() + self.setWidget(QWidget()) + + return {k: StubTableDock for k in mgrmod.table_type_classes} + + +def test_b2_init_enables_dock_nesting(qapp): + win = _qmainwindow() + mgrmod.TableManager(None, None, None, win) + assert win.isDockNestingEnabled() is True + + +def test_b2_newtable_tabifies_into_one_left_group(qapp, monkeypatch): + from PySide6.QtCore import Qt + + monkeypatch.setattr(mgrmod, "table_type_classes", _stub_table_classes()) + win = _qmainwindow() + mgr = mgrmod.TableManager(None, None, None, win) + + for tt in ("object", "trace", "ztrace"): + mgr.newTable(tt) + + obj_dock = mgr.tables["object"][0] + for tt in ("object", "trace", "ztrace"): + d = mgr.tables[tt][0] + assert win.dockWidgetArea(d) == Qt.LeftDockWidgetArea + + tabbed = set(win.tabifiedDockWidgets(obj_dock)) + assert mgr.tables["trace"][0] in tabbed + assert mgr.tables["ztrace"][0] in tabbed + + +def test_b2_newtable_single_dock_no_crash(qapp, monkeypatch): + from PySide6.QtCore import Qt + + monkeypatch.setattr(mgrmod, "table_type_classes", _stub_table_classes()) + win = _qmainwindow() + mgr = mgrmod.TableManager(None, None, None, win) + mgr.newTable("object") # no tabify partner — must not raise + assert win.dockWidgetArea(mgr.tables["object"][0]) == Qt.LeftDockWidgetArea + + +def test_b2_tab_group_survives_anchor_close(qapp, monkeypatch): + # Closing the anchor (object) dock must not fragment the group: a later dock + # still joins the single existing tab group via whatever dock remains. + monkeypatch.setattr(mgrmod, "table_type_classes", _stub_table_classes()) + win = _qmainwindow() + mgr = mgrmod.TableManager(None, None, None, win) + for tt in ("object", "trace", "ztrace"): + mgr.newTable(tt) + + obj = mgr.tables["object"][0] # the anchor + mgr.tables["object"].remove(obj) + obj.close(); obj.setParent(None) + + mgr.newTable("flag") # anchors onto the remaining group + + group = set(win.tabifiedDockWidgets(mgr.tables["trace"][0])) + assert mgr.tables["ztrace"][0] in group + assert mgr.tables["flag"][0] in group # one group, not fragmented + + +# --- B3: auto-open promoted lists + keep open_tables synced ----------------------- + +class _FakeSeries: + def __init__(self, open_tables=("object",), welcome=False, collapsed=False): + self._opts = {"open_tables": list(open_tables), "lists_panel_collapsed": collapsed} + self._welcome = welcome + + def getOption(self, k): + return self._opts[k] + + def setOption(self, k, v): + self._opts[k] = v + + def isWelcomeSeries(self): + return self._welcome + + +def _bare_manager(series): + mgr = mgrmod.TableManager.__new__(mgrmod.TableManager) + mgr.series = series + mgr.tables = {tt: [] for tt in mgrmod.table_type_classes} + mgr._suppress_open_tables_sync = False + return mgr + + +def test_b3_sync_adds_type_to_open_tables(): + s = _FakeSeries(["object"]) + mgr = _bare_manager(s) + mgr._syncOpenTables("trace", True) + assert s.getOption("open_tables") == ["object", "trace"] + mgr._syncOpenTables("trace", True) # idempotent + assert s.getOption("open_tables") == ["object", "trace"] + + +def test_b3_onclosed_removes_type_when_last_closes(): + s = _FakeSeries(["object", "trace"]) + mgr = _bare_manager(s) + mgr.tables["trace"] = [] # last trace dock just removed itself + mgr.onTableClosed("trace") + assert s.getOption("open_tables") == ["object"] + + +def test_b3_onclosed_keeps_type_if_another_still_open(): + s = _FakeSeries(["object", "trace"]) + mgr = _bare_manager(s) + mgr.tables["trace"] = ["still-open"] # another trace dock remains + mgr.onTableClosed("trace") + assert s.getOption("open_tables") == ["object", "trace"] + + +def test_b3_closeall_suppresses_sync(): + s = _FakeSeries(["object", "trace"]) + mgr = _bare_manager(s) + mgr._suppress_open_tables_sync = True + mgr.tables["trace"] = [] + mgr.onTableClosed("trace") + assert s.getOption("open_tables") == ["object", "trace"] # preserved across teardown + + +def test_b3_sync_does_not_corrupt_global_default(): + from PyReconstruct.modules.datatypes.default_settings import default_settings + + _clear("open_tables") + s = Series.__new__(Series) + s.options = {} + mgr = _bare_manager(s) + snapshot = list(default_settings["open_tables"]) + mgr._syncOpenTables("trace", True) # would corrupt the default if it mutated the alias + assert default_settings["open_tables"] == snapshot + assert s.getOption("open_tables") == ["object", "trace"] + + +def _bare_field(series): + import PyReconstruct.modules.gui.main.field_widget_1_base as fwmod + fw = fwmod.FieldWidgetBase.__new__(fwmod.FieldWidgetBase) + fw.series = series + fw.section = object() + opened = [] + + class FakeMgr: + def newTable(self, lt, section=None): + opened.append(lt) + + fw.table_manager = FakeMgr() + return fw, opened + + +def test_b3_restore_opens_saved_tables(): + fw, opened = _bare_field(_FakeSeries(["object", "trace"])) + fw._restoreOpenTables() + assert opened == ["object", "trace"] + + +def test_b3_restore_skips_welcome_series(): + fw, opened = _bare_field(_FakeSeries(["object"], welcome=True)) + fw._restoreOpenTables() + assert opened == [] + + +def test_b3_restore_skips_invalid_types(): + # a stale/corrupted open_tables entry (e.g. a type removed in a future version) + # must not crash field loading — skip it. + fw, opened = _bare_field(_FakeSeries(["object", "bogus", "trace"])) + fw._restoreOpenTables() + assert opened == ["object", "trace"] + + +# --- B4: collapse/expand toggle for the left lists panel -------------------------- + +def _win_with_docks(qty_by_type): + from PySide6.QtWidgets import QMainWindow, QDockWidget + from PySide6.QtCore import Qt + win = QMainWindow() + tables = {tt: [] for tt in mgrmod.table_type_classes} + for tt, n in qty_by_type.items(): + for _ in range(n): + d = QDockWidget(win) + win.addDockWidget(Qt.LeftDockWidgetArea, d) + tables[tt].append(d) + return win, tables + + +def test_b4_listsPanelCollapsed_reads_option(): + mgr = _bare_manager(_FakeSeries(collapsed=True)) + assert mgr.listsPanelCollapsed() is True + mgr.series.setOption("lists_panel_collapsed", False) + assert mgr.listsPanelCollapsed() is False + + +def test_b4_set_collapsed_hides_and_shows_all_left_docks(qapp): + win, tables = _win_with_docks({"object": 1, "trace": 1}) + mgr = _bare_manager(_FakeSeries(["object", "trace"])) + mgr.tables = tables + + mgr.setListsPanelCollapsed(True) + assert all(d.isHidden() for ds in tables.values() for d in ds) + assert mgr.series.getOption("lists_panel_collapsed") is True + + mgr.setListsPanelCollapsed(False) + assert not any(d.isHidden() for ds in tables.values() for d in ds) + assert mgr.series.getOption("lists_panel_collapsed") is False + + +def test_b4_collapse_does_not_close_docks_or_change_open_tables(qapp): + # collapse hides (not closes) so open_tables is untouched + win, tables = _win_with_docks({"object": 1, "trace": 1}) + s = _FakeSeries(["object", "trace"]) + mgr = _bare_manager(s) + mgr.tables = tables + mgr.setListsPanelCollapsed(True) + assert s.getOption("open_tables") == ["object", "trace"] + assert tables["object"] and tables["trace"] # still tracked/open + + +def test_b4_mainwindow_toggle_flips_state_and_checkbox(qapp): + from PyReconstruct.modules.gui.main.main_window import MainWindow + from PySide6.QtGui import QAction + + win, tables = _win_with_docks({"object": 1}) + s = _FakeSeries(["object"], collapsed=False) + mgr = _bare_manager(s) + mgr.tables = tables + + mw = MainWindow.__new__(MainWindow) + + class _Field: + pass + + mw.field = _Field() + mw.field.table_manager = mgr + mw.togglelistspanel_act = QAction() + mw.togglelistspanel_act.setCheckable(True) + + mw.toggleListsPanel() + assert tables["object"][0].isHidden() is True + assert s.getOption("lists_panel_collapsed") is True + assert mw.togglelistspanel_act.isChecked() is True + + mw.toggleListsPanel() + assert tables["object"][0].isHidden() is False + assert mw.togglelistspanel_act.isChecked() is False + + +# --- B5: restore collapsed state on open + sync the checkbox; survive restart ----- + +def _mainwindow_with(mgr): + from PyReconstruct.modules.gui.main.main_window import MainWindow + from PySide6.QtGui import QAction + + mw = MainWindow.__new__(MainWindow) + + class _Field: + pass + + mw.field = _Field() + mw.field.table_manager = mgr + mw.togglelistspanel_act = QAction() + mw.togglelistspanel_act.setCheckable(True) + return mw + + +def test_b5_apply_state_collapsed_hides_docks_and_checks_box(qapp): + win, tables = _win_with_docks({"object": 1, "trace": 1}) + mgr = _bare_manager(_FakeSeries(["object", "trace"], collapsed=True)) + mgr.tables = tables + mw = _mainwindow_with(mgr) + + mw._applyListsPanelState() + + assert all(d.isHidden() for ds in tables.values() for d in ds) + assert mw.togglelistspanel_act.isChecked() is True + + +def test_b5_apply_state_expanded_shows_docks_and_unchecks_box(qapp): + win, tables = _win_with_docks({"object": 1}) + mgr = _bare_manager(_FakeSeries(["object"], collapsed=False)) + mgr.tables = tables + mw = _mainwindow_with(mgr) + + mw._applyListsPanelState() + + assert not tables["object"][0].isHidden() + assert mw.togglelistspanel_act.isChecked() is False + + +def test_b5_collapsed_state_survives_restart(qapp): + # The run.py restart recreates MainWindow and re-enters openSeries; the global + # QSettings value must persist. Simulate by reading it from a fresh series. + _clear("lists_panel_collapsed") + s1 = Series.__new__(Series) + s1.options = {} + mgr1 = _bare_manager(s1) + mgr1.setListsPanelCollapsed(True) # user collapses; writes global QSettings + + s2 = Series.__new__(Series) # "restarted" process reads the same global setting + s2.options = {} + assert s2.getOption("lists_panel_collapsed") is True + + +# --- Whole-slice: open -> collapse -> restart, end to end ------------------------- + +def test_whole_slice_open_collapse_restart_cycle(qapp, monkeypatch): + """Composed lifecycle across a simulated run.py restart: each 'boot' is a fresh + series + manager + field + window reading the SAME global QSettings. Proves + open_tables AND lists_panel_collapsed both persist and are re-applied, and the + menu checkbox stays in sync. (Reachability/persistence end-to-end.)""" + from types import SimpleNamespace + from PySide6.QtWidgets import QMainWindow + from PySide6.QtGui import QAction + import PyReconstruct.modules.gui.main.field_widget_1_base as fwmod + from PyReconstruct.modules.gui.main.main_window import MainWindow + + monkeypatch.setattr(mgrmod, "table_type_classes", _stub_table_classes()) + _clear("open_tables", "lists_panel_collapsed") + + def boot(): + series = Series.__new__(Series) + series.options = {} + series.filepath = "/nonexistent/not-a-welcome.ser" # isWelcomeSeries -> False + win = QMainWindow() + mgr = mgrmod.TableManager(series, None, None, win) + + field = fwmod.FieldWidgetBase.__new__(fwmod.FieldWidgetBase) + field.series = series + field.section = None + field.table_manager = mgr + field._restoreOpenTables() # createField step + + mw = MainWindow.__new__(MainWindow) + mw.field = SimpleNamespace(table_manager=mgr) + mw.togglelistspanel_act = QAction() + mw.togglelistspanel_act.setCheckable(True) + mw._applyListsPanelState() # openSeries-after-createMenuBar step + return series, mgr, mw + + # Boot 1: defaults -> Object auto-opens, panel expanded + _, mgr1, mw1 = boot() + assert mgr1.tables["object"], "Object list did not auto-open" + assert mgr1.tables["object"][0].isHidden() is False + assert mw1.togglelistspanel_act.isChecked() is False + + # User collapses the panel + mw1.toggleListsPanel() + assert mgr1.tables["object"][0].isHidden() is True + assert mw1.togglelistspanel_act.isChecked() is True # checkbox updates in real time + + # Boot 2 = restart: same global QSettings + series2, mgr2, mw2 = boot() + assert series2.getOption("open_tables") == ["object"] # open_tables persisted + assert len(mgr2.tables["object"]) == 1, "Object list not restored after restart" + assert mgr2.tables["object"][0].isHidden() is True # collapse persisted + applied + assert mw2.togglelistspanel_act.isChecked() is True # checkbox synced