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