diff --git a/tests/test_screenshot_tool.py b/tests/test_screenshot_tool.py new file mode 100644 index 0000000..fc0861d --- /dev/null +++ b/tests/test_screenshot_tool.py @@ -0,0 +1,126 @@ +"""The screenshot tool's guard against rendering real volumes. + +`tools/screenshots.py` is not shipped, but it writes the PNGs in a public +README, and the thing it is guarding against already happened once: it patched +a function the drive panel had stopped calling, so the patch became a no-op, +the real scan ran, and whether a real drive label reached the images came down +to which write landed last. A guard that can rot silently is worth a test. +""" + +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6", reason="GUI extra not installed") + +TOOL = Path(__file__).resolve().parent.parent / "tools" / "screenshots.py" + + +@pytest.fixture(scope="module") +def tool(): + """The tool, imported with its environment put back afterwards. + + Importing it redirects `APPDATA` and `XDG_CONFIG_HOME` to a sandbox at + module scope — deliberate in the tool, and not something to leave behind + for the rest of the suite, which reads those to find the config directory. + """ + saved = {name: os.environ.get(name) + for name in ("APPDATA", "XDG_CONFIG_HOME")} + try: + spec = importlib.util.spec_from_file_location("_screenshots", TOOL) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + yield module + finally: + for name, value in saved.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + + +def _window(*labels: str): + """The shape `assert_no_real_volumes` reads: rows carrying volumes.""" + rows = [SimpleNamespace(volume=SimpleNamespace(label=label)) + for label in labels] + return SimpleNamespace(drives=SimpleNamespace(_rows=rows)) + + +# ------------------------------------------------------------------- the stub + + +def test_the_stub_replaces_the_scan_the_panel_calls(tool, monkeypatch): + """Not a name it used to call. The previous version assigned + `drives.list_volumes`, which no longer exists, so nothing was overridden + and the real scan kept running.""" + monkeypatch.setattr(tool.drives, "scan_batches", + lambda: iter([("real volumes", True)])) + tool.stub_volume_scan() + + batches = list(tool.drives.scan_batches()) + assert [volumes for volumes, _ in batches] == [tool.VOLUMES] + + +def test_the_stub_yields_one_final_batch(tool, monkeypatch): + """A non-final batch tells the panel to keep network shares from the + previous scan, and a second delivery is what raced the invented list in the + first place. One batch, marked final, leaves neither opening.""" + monkeypatch.setattr(tool.drives, "scan_batches", lambda: iter([])) + tool.stub_volume_scan() + + batches = list(tool.drives.scan_batches()) + assert len(batches) == 1 + assert batches[0][1] is True + + +def test_a_missing_scan_function_stops_the_run(tool, monkeypatch): + """The regression this guard exists for: the tool must not fall back to + letting the panel scan the machine.""" + monkeypatch.delattr(tool.drives, "scan_batches") + + with pytest.raises(SystemExit, match="scan_batches"): + tool.stub_volume_scan() + + +def test_a_non_callable_scan_attribute_is_not_good_enough(tool, monkeypatch): + """`hasattr` would have accepted this, and a stub assigned over a + non-function is the same silent no-op in a different disguise.""" + monkeypatch.setattr(tool.drives, "scan_batches", "not a function") + + with pytest.raises(SystemExit, match="scan_batches"): + tool.stub_volume_scan() + + +# ------------------------------------------------------------------ the check + + +def test_invented_volumes_pass_the_check(tool): + tool.assert_no_real_volumes(_window(*[v.label for v in tool.VOLUMES])) + + +def test_a_real_volume_refuses_to_render(tool): + """What a leak looks like: a network share's own label on screen.""" + with pytest.raises(SystemExit, match="NAS_1"): + tool.assert_no_real_volumes(_window("A001 (PYXIS)", "NAS_1")) + + +def test_the_refusal_names_every_leaked_volume(tool): + """So the person running it can see it was the whole scan that got + through, not one stray row.""" + with pytest.raises(SystemExit) as raised: + tool.assert_no_real_volumes(_window("home", "Marketing")) + + message = str(raised.value) + assert "home" in message and "Marketing" in message + + +def test_an_empty_panel_is_not_treated_as_a_leak(tool): + """The panel is empty until the first batch arrives, and a guard that + failed the run for that would fire on timing rather than on a leak.""" + tool.assert_no_real_volumes(_window()) diff --git a/tools/screenshots.py b/tools/screenshots.py index b9c9b9f..66d358a 100644 --- a/tools/screenshots.py +++ b/tools/screenshots.py @@ -89,6 +89,48 @@ ] +def stub_volume_scan() -> None: + """Replace the drive panel's scan, and refuse to run if there is nothing + to replace. + + This used to assign `drives.list_volumes`. The panel's scan was later + rewritten around `scan_batches`, and since nothing verified the target + still existed, the assignment quietly began creating a new unused attribute + instead of overriding anything. The real scan then ran on every render, and + real drive labels and free space — including network shares — could reach + the PNGs that go in a public README. Whether they did came down to which + write landed last, because the network batch arrives seconds after the + local one. + + So: patch the funnel every scan goes through, and make a missing name stop + the run rather than hand the panel back to the machine. + """ + if not callable(getattr(drives, "scan_batches", None)): + raise SystemExit( + "tools/screenshots.py: drives.scan_batches is gone, so the drive " + "panel would scan this machine and put its real volumes in the " + "screenshots. Point the stub at whatever the panel calls now.") + # One final batch: no second delivery to race the first, and the panel + # never sees a non-final batch it would merge network shares into. + drives.scan_batches = lambda: iter([(list(VOLUMES), True)]) + + +def assert_no_real_volumes(window: MainWindow) -> None: + """Fail if anything but the invented volumes reached the panel. + + The stub above is the guard; this is the check that the guard worked. It + reads what is actually on screen, so it survives the next rewrite of the + scan in a way that patching a function name did not. + """ + invented = {volume.label for volume in VOLUMES} + showing = {row.volume.label for row in window.drives._rows} + leaked = showing - invented + if leaked: + raise SystemExit( + "tools/screenshots.py: the drive panel is showing real volumes " + f"({', '.join(sorted(leaked))}); refusing to write screenshots.") + + def seed_config() -> None: (config_dir() / "presets.json").write_text( json.dumps([p.to_dict() for p in PRESETS], indent=2), encoding="utf-8") @@ -139,8 +181,7 @@ def main(argv: list[str] | None = None) -> int: out.mkdir(parents=True, exist_ok=True) seed_config() - # The panel scans real volumes on a worker thread; give it ours instead. - drives.list_volumes = lambda: list(VOLUMES) + stub_volume_scan() app = QApplication([]) theme.apply(app) @@ -162,6 +203,7 @@ def main(argv: list[str] | None = None) -> int: window._set_mode(0) settle(app) + assert_no_real_volumes(window) shoot(window, out, "app-preset-mode.png") window._set_mode(1) @@ -171,6 +213,9 @@ def main(argv: list[str] | None = None) -> int: window.simple.destinations.set_paths([Path(r"D:\Archive\2026"), Path(r"N:\cold\2026")]) settle(app) + # Checked again: the panel polls every few seconds, so a scan that slipped + # past the stub would land between the two pictures. + assert_no_real_volumes(window) shoot(window, out, "app-simple-mode.png") editor = PresetEditor(PRESETS[2])