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 | |
| parent | 8b5c8697740ff415cf7f1d03c9fb5a8c8851d420 (diff) | |
| download | tts-audiobook-generator-c147087c9d4707bffaeee58d390653637a21cce8.tar.gz | |
refactor: put shared ui screen code into ui.viewkit
Diffstat (limited to 'app/tests')
| -rw-r--r-- | app/tests/test_backends.py | 55 | ||||
| -rw-r--r-- | app/tests/test_viewkit.py | 144 |
2 files changed, 198 insertions, 1 deletions
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index 4ff6f6e..dc2b4d0 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -5,7 +5,16 @@ import unittest from pathlib import Path from unittest.mock import patch -from backends import REGISTRY, ServerSpec, detect_all, format_launch_hint, get +import backends +from backends import ( + REGISTRY, + BackendStatus, + ServerSpec, + detect_all, + format_launch_hint, + get, + invalidate_detect_cache, +) class FormatLaunchHintTests(unittest.TestCase): @@ -294,6 +303,50 @@ class RemoteSuppressionTests(unittest.TestCase): self.assertEqual(status.remote_urls, {}) +class DetectCacheTests(unittest.TestCase): + """detect_all's short-TTL cache (menu renders re-probe only after it).""" + + def setUp(self): + get("audiocpp") # build the lazy registry before patching its entries + invalidate_detect_cache() + self.addCleanup(invalidate_detect_cache) + self.probes = [] + self.patches = [] + for info in REGISTRY: + def fake_detect(key=info.key): + self.probes.append(key) + return BackendStatus(key, key, installed=False, configured=False) + self.patches.append(patch.object(info, "detect", + side_effect=fake_detect)) + for p in self.patches: + p.start() + self.addCleanup(p.stop) + + def test_repeated_calls_within_the_ttl_probe_once(self): + first = detect_all() + second = detect_all() + self.assertEqual(first, second) + self.assertEqual(sorted(self.probes), sorted(i.key for i in REGISTRY)) + self.assertEqual(len(self.probes), len(REGISTRY)) + + def test_refresh_bypasses_the_cache(self): + detect_all() + detect_all(refresh=True) + self.assertEqual(len(self.probes), 2 * len(REGISTRY)) + + def test_invalidate_forces_the_next_call_to_reprobe(self): + detect_all() + invalidate_detect_cache() + detect_all() + self.assertEqual(len(self.probes), 2 * len(REGISTRY)) + + def test_expiry_after_the_ttl_reprobes(self): + with patch.object(backends, "DETECT_TTL_SECONDS", 0.0): + detect_all() + detect_all() + self.assertEqual(len(self.probes), 2 * len(REGISTRY)) + + if __name__ == "__main__": unittest.main() 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() |
