aboutsummaryrefslogtreecommitdiff
path: root/app/tests/test_viewkit.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 03:02:23 -0400
committerhistoria <historiavg@proton.me>2026-08-26 03:02:23 -0400
commitc147087c9d4707bffaeee58d390653637a21cce8 (patch)
treeb080c40eaa388609dea38c2cc413cb912aa7b4af /app/tests/test_viewkit.py
parent8b5c8697740ff415cf7f1d03c9fb5a8c8851d420 (diff)
downloadtts-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.py144
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()