iso-scalpel/tests/test_ui.py

272 lines
11 KiB
Python

"""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}"