272 lines
11 KiB
Python
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}"
|