"""Tests for the Tk window. ``tkinter`` is replaced wholesale in the module's namespace, so every widget is a mock and no real ``Tk()`` is ever constructed. That matters beyond speed: CI has no display, and instantiating Tk there would fail outright. There is deliberately little to test here, because the window contains no decisions -- those all live in ``review`` and are covered by ``test_review``. """ from __future__ import annotations import queue from typing import TYPE_CHECKING, cast from unittest.mock import MagicMock, patch import pytest from python_pkg.wsg_grabber import review, ui from python_pkg.wsg_grabber.constants import ( KEEP_KEYS, PASS_KEYS, QUIT_KEYS, UNDO_KEYS, ) from python_pkg.wsg_grabber.models import ( ReviewedItem, ReviewItem, Verdict, ) if TYPE_CHECKING: from collections.abc import Iterator from pathlib import Path from python_pkg.wsg_grabber.models import DownloadEvent from python_pkg.wsg_grabber.review import SessionState @pytest.fixture def toolkit() -> Iterator[MagicMock]: """Replace tkinter in the ui module's namespace. Yields: MagicMock: The stand-in toolkit. """ fake = MagicMock() # a fresh mock per widget, else status and filename are the same object fake.Frame.side_effect = lambda *_a, **_k: MagicMock() fake.Label.side_effect = lambda *_a, **_k: MagicMock() fake.Button.side_effect = lambda *_a, **_k: MagicMock() fake.BOTH = "both" fake.X = "x" fake.LEFT = "left" fake.RIGHT = "right" fake.BOTTOM = "bottom" with patch.object(ui, "tk", fake): yield fake def _mock(widget: object) -> MagicMock: """View a mocked widget as the MagicMock it really is. The Widgets dataclass is typed with real Tk classes, so mypy will not let the tests reach ``call_args`` without this. Args: widget: The stand-in widget. Returns: MagicMock: The same object, typed for assertions. """ return cast("MagicMock", widget) def _item(tmp_path: Path, name: str = "a") -> ReviewItem: """Build a ReviewItem backed by a real file. Args: tmp_path: Per-test temporary directory. name: Distinguishes items. Returns: ReviewItem: Fixture value. """ path = tmp_path / f"{name}.webm" path.write_bytes(b"video") return ReviewItem( md5=f"{name}-md5", path=path, orig_name=f"{name}.webm", fsize=5, width=4, height=3, ) def _callbacks() -> tuple[ui.Callbacks, MagicMock]: """Build callbacks that record what the window asked for. Returns: tuple: The callbacks and the recorder behind them. """ recorder = MagicMock() _undone: dict[str, ReviewedItem | None] = {"item": None} def commit(state: SessionState, choice: Verdict) -> SessionState: recorder.commit(choice) if state.current is not None: _undone["item"] = ReviewedItem( item=state.current, choice=choice, reviewed_name=state.current.path.name, ) return review.on_verdict(state, choice) def missing(state: SessionState) -> SessionState: recorder.missing() return review.on_missing_locally(state) def undo(state: SessionState) -> SessionState: recorder.undo() action = _undone["item"] if action is None or not review.can_undo(state): return state return review.on_undo(state, action) return ( ui.Callbacks( commit=commit, missing=missing, undo=undo, shutdown=recorder.shutdown, ), recorder, ) def _window( state: SessionState, events: queue.SimpleQueue[DownloadEvent] | None = None, ) -> tuple[ui.ReviewWindow, MagicMock]: """Build a window over a mocked toolkit. Args: state: Starting state. events: Optional event queue. Returns: tuple: The window and the callback recorder. """ callbacks, recorder = _callbacks() window = ui.ReviewWindow(state, events or queue.SimpleQueue(), callbacks) return window, recorder def test_the_window_has_a_video_area_and_a_control_bar(toolkit: MagicMock) -> None: window, _ = _window(review.initial_state(0, [])) assert toolkit.Tk.called assert toolkit.Button.call_count == 3 # keep, pass, undo # two frames: the video area and the control bar below it assert toolkit.Frame.call_count == 2 # the video area expands; the bar sits under it rather than over it, because # mpv's --wid child window covers whatever shares its frame assert _mock(window.widgets.video).pack.call_args.kwargs["expand"] def test_every_shortcut_is_bound(toolkit: MagicMock) -> None: window, _ = _window(review.initial_state(0, [])) calls = _mock(window.widgets.root).bind.call_args_list bound = {call.args[0] for call in calls} assert set(KEEP_KEYS) <= bound assert set(PASS_KEYS) <= bound assert set(QUIT_KEYS) <= bound assert set(UNDO_KEYS) <= bound def test_keep_and_pass_shortcuts_reach_the_callbacks( toolkit: MagicMock, tmp_path: Path, ) -> None: state = review.initial_state(2, [_item(tmp_path, "a"), _item(tmp_path, "b")]) window, recorder = _window(state) window.attach_player(MagicMock()) bound = _mock(window.widgets.root).bind.call_args_list handlers = {call.args[0]: call.args[1] for call in bound} handlers[KEEP_KEYS[0]](object()) first: object = recorder.commit.call_args.args[0] assert first is Verdict.KEEP handlers[PASS_KEYS[0]](object()) second: object = recorder.commit.call_args.args[0] assert second is Verdict.SKIP def test_the_quit_shortcut_shuts_down(toolkit: MagicMock) -> None: window, recorder = _window(review.initial_state(0, [])) bound = _mock(window.widgets.root).bind.call_args_list handlers = {call.args[0]: call.args[1] for call in bound} handlers[QUIT_KEYS[0]](object()) recorder.shutdown.assert_called_once() _mock(window.widgets.root).destroy.assert_called_once() def test_the_buttons_call_the_same_handlers( toolkit: MagicMock, tmp_path: Path, ) -> None: state = review.initial_state(1, [_item(tmp_path)]) window, recorder = _window(state) window.attach_player(MagicMock()) window.on_keep() assert recorder.commit.call_args.args[0] is Verdict.KEEP