"""Tests for the main-window UI contract. Covers: * Single-tab panes show no close button; a second tab restores it on every tab; removing the second tab hides it again. * Default splitter sizes give the right (ISO) pane a non-zero width on first launch and keep the transfer column narrow. * Toolbar and pane column-sizing contracts. Requires PySide6 + pycdlib; skipped otherwise. """ from __future__ import annotations import os import sys from pathlib import Path import pytest # Skip the entire module if PySide6 or pycdlib isn't installed -- the # rest of the test suite (test_deps.py) must still run. PySide6 = pytest.importorskip("PySide6") pycdlib = pytest.importorskip("pycdlib") from PySide6.QtWidgets import QApplication, QHeaderView, QTabBar, QToolBar, QWidget # Make the project root importable. ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) # Construct a single QApplication for all tests in this module. @pytest.fixture(scope="module") def qapp(): os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") app = QApplication.instance() or QApplication([]) yield app @pytest.fixture def main_window(qapp): from iso_scalpel.main_window import MainWindow win = MainWindow() yield win win.close() # ========================================================================== # Single-tab close button is hidden # ========================================================================== class TestPaneTabBarCloseButton: """The close-button hiding is deferred to the next event-loop iteration (via ``QTimer.singleShot(0, ...)``) so it doesn't interfere with Qt's internal tab-insert/remove layout. Tests must call ``qapp.processEvents()`` to let the deferred call run before asserting on the button state. """ def test_single_tab_has_no_close_button(self, main_window, qapp): """The screenshot showed a confusing red 'X' next to the 'FS' tab on a single-tab pane. The close button must be None when there is only one tab. """ qapp.processEvents() # let deferred _refresh_close_button run tb = main_window._left_tabs.tabBar() assert tb.count() == 1 assert tb.tabButton(0, QTabBar.RightSide) is None def test_two_tabs_have_close_buttons(self, main_window, qapp): """When a second tab is added, every tab must show its close button (so the user can close either one). """ qapp.processEvents() tb = main_window._left_tabs.tabBar() extra = QWidget() main_window._left_tabs.addTab(extra, "FS2") qapp.processEvents() # let deferred _refresh_close_button run try: assert tb.count() == 2 assert tb.tabButton(0, QTabBar.RightSide) is not None assert tb.tabButton(1, QTabBar.RightSide) is not None finally: # Clean up so other tests see a clean state. main_window._left_tabs.removeTab(1) qapp.processEvents() def test_close_button_hidden_again_after_removing_second_tab(self, main_window, qapp): """Removing the second tab must re-hide the close button on the lone remaining tab. """ qapp.processEvents() tb = main_window._left_tabs.tabBar() extra = QWidget() main_window._left_tabs.addTab(extra, "FS2") qapp.processEvents() assert tb.tabButton(0, QTabBar.RightSide) is not None main_window._left_tabs.removeTab(1) qapp.processEvents() # let deferred _refresh_close_button run assert tb.count() == 1 assert tb.tabButton(0, QTabBar.RightSide) is None # ========================================================================== # Default splitter sizes # ========================================================================== class TestSplitterSizes: def test_splitter_has_three_sections(self, main_window): """Splitter must have 3 sections: left pane, transfer column, right pane.""" assert main_window._splitter.count() == 3 def test_transfer_column_section_is_narrow(self, main_window): """The middle transfer column must stay narrow (<= 50px) so the two panes get the bulk of the width. """ sizes = main_window._splitter.sizes() assert sizes[1] <= 50, f"transfer column too wide: {sizes[1]}" def test_right_pane_gets_nonzero_width(self, main_window): """Critical: the right (ISO) pane must get a non-zero initial width -- this was the regression that made the right pane appear blank/missing in the screenshot. """ sizes = main_window._splitter.sizes() assert sizes[2] > 100, f"right pane too narrow: {sizes[2]}" def test_panes_split_width_roughly_equally(self, main_window): """Both panes should get roughly equal widths so neither side is starved when the window is resized. """ sizes = main_window._splitter.sizes() left, _mid, right = sizes # Allow up to 40% asymmetry (in case one pane has a slightly # different min-size hint), but they should be in the same order # of magnitude. assert left > 80 and right > 80 ratio = min(left, right) / max(left, right) assert ratio > 0.6, f"panes not balanced: left={left}, right={right}, ratio={ratio:.2f}" # ========================================================================== # Boot Image action has an icon # ========================================================================== class TestToolbarIcons: def test_boot_action_has_icon(self, main_window): """The 'Boot Image…' toolbar button was text-only in the screenshot, inconsistent with the other toolbar buttons. It must now carry a standard icon. """ assert not main_window.act_boot.icon().isNull(), ( "Boot Image action should have a non-null icon" ) # ========================================================================== # FsPane column sizing # ========================================================================== class TestFsPaneColumns: def test_name_column_uses_stretch_mode(self, main_window): """The Name column must use Stretch so it fills available width and the other columns (Size, Type, Date Modified) aren't cramped. """ hdr = main_window._fs_pane.view.header() assert hdr.sectionResizeMode(0) == QHeaderView.Stretch def test_size_column_uses_resize_to_contents(self, main_window): """The Size column should auto-fit its contents (regression: in the screenshot the Date Modified column was cramped because all columns used the default Interactive mode). """ hdr = main_window._fs_pane.view.header() if hdr.count() >= 2: assert hdr.sectionResizeMode(1) == QHeaderView.ResizeToContents # ========================================================================== # Directional transfer buttons # ========================================================================== class TestTransferColumn: """The transfer column carries two directional buttons: ``→`` (Add, FS→ISO) and ``←`` (Extract, ISO→FS). The column is narrow (36px) and both buttons disable when no image is open. These tests verify the structure and wiring. """ def test_transfer_column_exists(self, main_window): from iso_scalpel.main_window import _TransferColumn assert isinstance(main_window._transfer_col, _TransferColumn) def test_add_button_uses_right_arrow(self, main_window): """The Add button must use a right-pointing arrow → to make the data-flow direction (FS → ISO) unambiguous. """ assert main_window._transfer_col.add_btn.text() == "→" def test_extract_button_uses_left_arrow(self, main_window): """The Extract button must use a left-pointing arrow ← to make the data-flow direction (ISO → FS) unambiguous. """ assert main_window._transfer_col.extract_btn.text() == "←" def test_transfer_column_is_narrow(self, main_window): """The transfer column must be narrow (36px) so the two panes flanking it get the bulk of the splitter width -- this is what eliminates the 'orphaned icon in a sea of empty space' problem. """ assert main_window._transfer_col.maximumWidth() == 36 def test_transfer_buttons_disabled_when_no_image_open(self, main_window): """Both transfer buttons must be disabled when no ISO image is open, since Add/Extract require an open image to operate on. """ # MainWindow starts with no image open. assert not main_window._handler.is_open assert not main_window._transfer_col.add_btn.isEnabled() assert not main_window._transfer_col.extract_btn.isEnabled() def test_swap_button_absent(self, main_window): """No ``_swap_btn`` attribute exists on the main window; the directional transfer column is the sole on-screen swap surface. """ assert not hasattr(main_window, "_swap_btn"), ( "_swap_btn must not exist; the transfer column is the on-screen swap surface" ) def test_swap_action_still_exists_for_keyboard(self, main_window): """The swap_panes() method and act_swap action remain bound so Ctrl+Shift+X and the Navigate menu keep working. """ assert hasattr(main_window, "swap_panes") assert hasattr(main_window, "act_swap") # Verify the keyboard shortcut is bound. from PySide6.QtGui import QKeySequence assert main_window.act_swap.shortcut() == QKeySequence("Ctrl+Shift+X") # ========================================================================== # Toolbar omits the swap action # ========================================================================== class TestToolbarNoSwap: def test_swap_action_not_in_toolbar(self, main_window): """The swap action is absent from every toolbar -- the directional transfer buttons in the splitter gutter cover on-screen swap, and a duplicate toolbar entry would be ambiguous. """ toolbars = main_window.findChildren(QToolBar) assert toolbars, "expected at least one toolbar" for tb in toolbars: actions = tb.actions() assert main_window.act_swap not in actions, ( "swap action must not appear in any toolbar" ) # ========================================================================== # Filter boxes have a max width so they don't stretch on large windows # ========================================================================== class TestFilterBoxMaxWidth: def test_fs_pane_filter_has_max_width(self, main_window): """The FS pane filter box must have a maximum width so it doesn't stretch absurdly wide on large windows -- the breadcrumb bar should get the bulk of the horizontal space. """ max_w = main_window._fs_pane.filter_edit.maximumWidth() assert 100 <= max_w <= 300, f"unreasonable max width: {max_w}" def test_iso_pane_filter_has_max_width(self, main_window): """Same for the ISO pane filter box.""" max_w = main_window._iso_pane.filter_edit.maximumWidth() assert 100 <= max_w <= 300, f"unreasonable max width: {max_w}"