iso-scalpel/tests/test_iso_model.py

216 lines
8.4 KiB
Python

"""Tests for :class:`IsoTreeModel` root-handling and lazy fetch.
The model root's children are the entries of the ISO ``/`` directory,
surfaced directly so the view displays them without manual expansion.
rowCount() returns 0 for an unloaded root and lets Qt drive
canFetchMore()/fetchMore(); index_from_path('/') yields the invalid
model-root index; refresh_parent('/') drops and re-fetches the virtual
root without crashing.
"""
from __future__ import annotations
import os
import sys
from io import BytesIO
from pathlib import Path
import pytest
PySide6 = pytest.importorskip("PySide6")
pycdlib = pytest.importorskip("pycdlib")
from PySide6.QtCore import QModelIndex
from PySide6.QtWidgets import QApplication
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
@pytest.fixture(scope="module")
def qapp():
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
app = QApplication.instance() or QApplication([])
yield app
@pytest.fixture
def tiny_iso_path(tmp_path):
"""Create a small ISO with two files and one subdirectory at /."""
iso_path = tmp_path / "tiny.iso"
iso = pycdlib.PyCdlib()
iso.new()
iso.add_fp(BytesIO(b"hello"), 5, "/HELLO.TXT;1")
iso.add_fp(BytesIO(b"world"), 5, "/WORLD.TXT;1")
iso.add_directory("/SUBDIR")
iso.add_fp(BytesIO(b"sub"), 3, "/SUBDIR/INSIDE.TXT;1")
iso.write(str(iso_path))
iso.close()
return str(iso_path)
@pytest.fixture
def open_handler(tiny_iso_path):
from iso_scalpel.iso_handler import IsoHandler
h = IsoHandler()
h.open(tiny_iso_path)
yield h
h.close()
# ==========================================================================
# IsoTreeModel: rowCount(QModelIndex()) must reflect / contents directly
# ==========================================================================
class TestModelRootShowsDirectoryContents:
"""The model root's children are the entries of the ISO ``/`` directory.
rowCount(QModelIndex()) reflects the count of root entries directly --
there is no virtual ``"/"`` placeholder row that the user must expand.
"""
def test_rowCount_of_model_root_is_zero_before_fetch(self, open_handler, qapp):
from iso_scalpel.iso_model import IsoTreeModel
m = IsoTreeModel(open_handler)
# Before fetchMore is called, the root isn't loaded -- rowCount
# must return 0 (not 1) so the view triggers canFetchMore/fetchMore.
assert m.rowCount(QModelIndex()) == 0
def test_canFetchMore_returns_true_for_unloaded_root(self, open_handler, qapp):
from iso_scalpel.iso_model import IsoTreeModel
m = IsoTreeModel(open_handler)
assert m.canFetchMore(QModelIndex()) is True
def test_fetchMore_populates_root_children(self, open_handler, qapp):
from iso_scalpel.iso_model import IsoTreeModel
m = IsoTreeModel(open_handler)
m.fetchMore(QModelIndex())
# The ISO has 3 entries at /: HELLO.TXT, WORLD.TXT, SUBDIR
assert m.rowCount(QModelIndex()) == 3
def test_root_children_names_are_correct(self, open_handler, qapp):
from iso_scalpel.iso_model import IsoTreeModel
m = IsoTreeModel(open_handler)
m.fetchMore(QModelIndex())
names = [m.index(r, 0, QModelIndex()).data() for r in range(m.rowCount(QModelIndex()))]
assert set(names) == {"HELLO.TXT", "WORLD.TXT", "SUBDIR"}
def test_no_infinite_recursion_in_rowCount(self, open_handler, qapp):
"""rowCount() must never call fetchMore().
Calling fetchMore() from inside rowCount() recurses through
beginInsertRows and blows the stack. The contract is: rowCount()
returns 0 for an unloaded root; Qt then calls canFetchMore() /
fetchMore() on its own schedule.
"""
from iso_scalpel.iso_model import IsoTreeModel
m = IsoTreeModel(open_handler)
# Calling rowCount on an unloaded root must return immediately
# without recursing.
result = m.rowCount(QModelIndex())
assert isinstance(result, int)
# ==========================================================================
# index_from_path: "/" maps to the model root (invalid QModelIndex)
# ==========================================================================
class TestIndexFromPath:
"""``index_from_path("/")`` must return the model root (an invalid
:class:`QModelIndex`) -- not ``createIndex(0, 0, self._root)`` -- so
that the view's default root index correctly shows "/" contents.
"""
def test_root_path_returns_invalid_index(self, open_handler, qapp):
from iso_scalpel.iso_model import IsoTreeModel
m = IsoTreeModel(open_handler)
idx = m.index_from_path("/")
assert not idx.isValid(), (
"index_from_path('/') should return the model root "
"(invalid QModelIndex), not a createIndex"
)
def test_subdir_path_returns_valid_index(self, open_handler, qapp):
from iso_scalpel.iso_model import IsoTreeModel
m = IsoTreeModel(open_handler)
m.fetchMore(QModelIndex())
idx = m.index_from_path("/SUBDIR")
assert idx.isValid()
assert idx.data() == "SUBDIR"
def test_nonexistent_path_returns_invalid_index(self, open_handler, qapp):
from iso_scalpel.iso_model import IsoTreeModel
m = IsoTreeModel(open_handler)
m.fetchMore(QModelIndex())
idx = m.index_from_path("/DOES_NOT_EXIST")
assert not idx.isValid()
# ==========================================================================
# refresh_parent: handles the root path correctly
# ==========================================================================
class TestRefreshParent:
"""``refresh_parent("/")`` must not crash when the model's
``index_from_path("/")`` returns an invalid index.
"""
def test_refresh_parent_root_does_not_crash(self, open_handler, qapp):
from iso_scalpel.iso_model import IsoTreeModel
m = IsoTreeModel(open_handler)
m.fetchMore(QModelIndex())
# Should not raise.
m.refresh_parent("/")
# After refresh, root should be unloaded again.
assert m.rowCount(QModelIndex()) == 0
# Re-fetching should still work.
m.fetchMore(QModelIndex())
assert m.rowCount(QModelIndex()) == 3
def test_refresh_parent_subdir(self, open_handler, qapp):
from iso_scalpel.iso_model import IsoTreeModel
m = IsoTreeModel(open_handler)
m.fetchMore(QModelIndex())
subdir_idx = m.index_from_path("/SUBDIR")
assert subdir_idx.isValid()
m.fetchMore(subdir_idx)
assert m.rowCount(subdir_idx) == 1 # INSIDE.TXT
m.refresh_parent("/SUBDIR")
# After refresh, subdir is unloaded.
assert m.rowCount(subdir_idx) == 0
# ==========================================================================
# End-to-end: IsoPane displays entries after open
# ==========================================================================
class TestIsoPaneShowsEntriesAfterOpen:
"""End-to-end regression: after ``handler.open()`` and
``pane.refresh()``, the ISO pane's view must report a non-zero
row count for the model root.
"""
def test_pane_shows_entries_after_refresh(self, open_handler, qapp):
from iso_scalpel.config import Settings
from iso_scalpel.widgets.iso_pane import IsoPane
s = Settings()
pane = IsoPane(open_handler, s)
pane.refresh()
# Force the view's model to fetch the root children.
pane.view.model().fetchMore(pane.view.rootIndex())
qapp.processEvents()
# The view's root index is the model root (invalid QModelIndex);
# its row count must be 3 (HELLO.TXT, WORLD.TXT, SUBDIR).
rc = pane.view.model().rowCount(pane.view.rootIndex())
assert rc == 3, f"ISO pane should show 3 entries, got {rc}"
def test_pane_breadcrumb_shows_iso_filename_after_open(self, open_handler, qapp):
"""The breadcrumb bar at the top of the ISO pane shows the ISO
filename after opening. The pane must reach the
``_rebuild_breadcrumbs`` call path on refresh.
"""
from iso_scalpel.config import Settings
from iso_scalpel.widgets.iso_pane import IsoPane
s = Settings()
pane = IsoPane(open_handler, s)
pane.refresh()
# The breadcrumb frame must have at least one child widget (the
# root button showing the ISO filename).
assert pane._crumb_layout.count() > 0