diff options
| author | historia <historiavg@proton.me> | 2026-08-26 03:02:23 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-26 03:02:23 -0400 |
| commit | c147087c9d4707bffaeee58d390653637a21cce8 (patch) | |
| tree | b080c40eaa388609dea38c2cc413cb912aa7b4af /app/tests/test_viewkit.py | |
| parent | 8b5c8697740ff415cf7f1d03c9fb5a8c8851d420 (diff) | |
| download | tts-audiobook-generator-c147087c9d4707bffaeee58d390653637a21cce8.tar.gz | |
refactor: put shared ui screen code into ui.viewkit
Diffstat (limited to 'app/tests/test_viewkit.py')
| -rw-r--r-- | app/tests/test_viewkit.py | 144 |
1 files changed, 144 insertions, 0 deletions
diff --git a/app/tests/test_viewkit.py b/app/tests/test_viewkit.py new file mode 100644 index 0000000..efebae6 --- /dev/null +++ b/app/tests/test_viewkit.py @@ -0,0 +1,144 @@ +"""Tests for the shared full-screen view plumbing (ui/viewkit.py). + +ScreenView is the base behind TaskView and RunView; those views' suites +cover it end to end. These tests pin the template-loop contract itself: +worker start-up, event draining, terminal/key handling, and the getch +mode switching. +""" + +import sys +import threading +import unittest +from unittest.mock import patch + +from tests.test_tui import FakeCurses, FakeScreen +from ui import viewkit +from ui.viewkit import ScreenView + + +class _SyncThread: + """Thread stand-in that runs its target synchronously on start().""" + + def __init__(self, target, daemon=None): + self._target = target + + def start(self): + self._target() + + def join(self, timeout=None): + pass + + +class EchoView(ScreenView): + """Minimal concrete view: records keys, ends after N events.""" + + def __init__(self, scr, *, terminal_after=None, clock=lambda: 1000.0): + super().__init__(scr, clock=clock) + self.keys = [] + self.terminal_after = terminal_after + + def work(): + for index in range(self.terminal_after or 0): + self._queue.put({"kind": "tick", "index": index}) + if self.terminal_after is not None: + self._finish("done") + # Created here; the base template starts it. + self._worker = _SyncThread(work) + + def handle_event(self, event): + pass + + def render(self): + pass + + def _terminal_result(self): + return "terminal" + + +class TemplateLoopTests(unittest.TestCase): + def setUp(self): + self.curses = FakeCurses() + patcher = patch.dict(sys.modules, {"curses": self.curses}) + patcher.start() + self.addCleanup(patcher.stop) + viewkit.tui._THEME.clear() + self.addCleanup(viewkit.tui._THEME.clear) + + def _view(self, keys, **kwargs): + screen = FakeScreen(keys=keys) + return EchoView(screen, **kwargs), screen + + def test_worker_runs_and_terminal_key_ends_the_view(self): + view, screen = self._view(keys=[ord("x")], terminal_after=2) + self.assertEqual(view.run(), "terminal") + # Both queued events were drained before the key was read. + self.assertEqual(view.phase, "done") + + def test_timeout_getch_keeps_the_loop_going(self): + # -1 (redraw timeout) events never end the loop. + view, screen = self._view(keys=[-1, ord("x")], terminal_after=1) + self.assertEqual(view.run(), "terminal") + + def test_esc_confirms_cancel_then_ends_via_after_cancel(self): + view, screen = self._view(keys=[27]) + with patch.object(view, "_prompt_cancel", return_value=True) as mk, \ + patch.object(view, "_after_cancel", + return_value="cancelled") as mk_after: + self.assertEqual(view.run(), "cancelled") + mk.assert_called_once() + mk_after.assert_called_once() + + def test_esc_declined_keeps_the_view_running(self): + view, screen = self._view(keys=[27, ord("x")], terminal_after=1) + with patch.object(view, "_prompt_cancel", return_value=False): + self.assertEqual(view.run(), "terminal") + + def test_early_exit_wins_before_render(self): + view, screen = self._view(keys=[], terminal_after=1) + with patch.object(view, "_early_exit", return_value="early"), \ + patch.object(view, "render") as mk_render: + self.assertEqual(view.run(), "early") + mk_render.assert_not_called() + + def test_stop_hook_restores_blocking_getch(self): + view, screen = self._view(keys=[ord("x")], terminal_after=1) + view.run() + self.assertEqual(screen.timeouts[-1], -1) + + +class DefaultPromptCancelTests(unittest.TestCase): + def setUp(self): + self.curses = FakeCurses() + patcher = patch.dict(sys.modules, {"curses": self.curses}) + patcher.start() + self.addCleanup(patcher.stop) + viewkit.tui._THEME.clear() + self.addCleanup(viewkit.tui._THEME.clear) + + def test_confirm_sets_cancel_and_joins_the_worker(self): + screen = FakeScreen(keys=[]) + view = EchoView(screen) + joined = threading.Event() + + class Worker: + def join(self, timeout=None): + joined.set() + + view._worker = Worker() + with patch.object(viewkit.tui, "confirm", return_value=True): + self.assertTrue(view._prompt_cancel()) + self.assertTrue(joined.is_set()) + self.assertTrue(view.cancelling) + self.assertTrue(view._cancel.is_set()) + + def test_declined_cancel_leaves_the_run_alone(self): + screen = FakeScreen(keys=[]) + view = EchoView(screen) + with patch.object(viewkit.tui, "confirm", return_value=False): + self.assertFalse(view._prompt_cancel()) + self.assertFalse(view.cancelling) + self.assertFalse(view._cancel.is_set()) + + +if __name__ == "__main__": + unittest.main() |
