diff options
Diffstat (limited to 'app/tests')
| -rw-r--r-- | app/tests/test_backends.py | 4 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 109 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 106 |
3 files changed, 171 insertions, 48 deletions
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index d4534fb..a49a2f2 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -650,7 +650,7 @@ class QwenModelsScreenTests(unittest.TestCase): ("Install Base", ("install", "Base")), ("Install VoiceDesign", ("install", "VoiceDesign")), ]) - self.assertEqual(kwargs["table_title"], "Model state") + self.assertEqual(kwargs["table_title"], "Model State") self.assertEqual(kwargs["table_rows"][0], ("CustomVoice", "installed", "ok")) @@ -707,7 +707,7 @@ class QwenModelsScreenTests(unittest.TestCase): # No task-view run, one guidance flash instead of a download. self.assertEqual(runs, []) guidance = ("Install the qwen-tts backend first " - "(Configure backends > Install Backend).") + "(Configure Backends > Install Backend).") self.assertEqual(flashes, [(guidance, "warn")]) # While the backend is missing, Install is replaced by dimmed-out # "(backend not installed)" placeholders — actions stay inert. diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 4186c92..798c27f 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -143,9 +143,11 @@ class HubMenuTests(unittest.TestCase): return BackendStatus(key, label, installed=False, configured=False) def test_quit_returns_none_when_no_backend(self): - # No backends installed/running: menu is [Configure backends, - # Settings, Quit]. Quit is the 3rd option (Down twice) then Enter. - screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10]) + # No backends installed/running: menu is [Configure Backends, + # Settings, Help, Quit]. Quit is the 4th option (Down x3) then + # Enter. + screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, + FakeCurses.KEY_DOWN, 10]) with patch.object(hub, "detect_all", return_value=[]): result = hub._Hub(screen).run() self.assertIsNone(result) @@ -190,7 +192,8 @@ class HubMenuTests(unittest.TestCase): patch.object(hub, "detect_all", return_value=[]): hub._Hub(screen).run() labels = [label for label, _ in captured["options"]] - self.assertEqual(labels, ["Configure backends", "Settings", "Quit"]) + self.assertEqual(labels, + ["Configure Backends", "Settings", "Help", "Quit"]) def test_menu_has_all_five_when_one_installed(self): captured = {} @@ -209,8 +212,8 @@ class HubMenuTests(unittest.TestCase): labels = [label for label, _ in captured["options"]] self.assertEqual( labels, - ["Generate audiobooks", "Configure backends", - "Start/Stop Backend Servers", "Settings", "Quit"]) + ["Generate Audiobooks", "Configure Backends", + "Start/Stop Backend Servers", "Settings", "Help", "Quit"]) # The status table is passed through, one row per backend. self.assertEqual(captured["rows"], [("qwen-tts", "installed", "ok", "body")]) @@ -256,8 +259,8 @@ class HubMenuTests(unittest.TestCase): labels = [label for label, _ in captured["options"]] self.assertEqual( labels, - ["Generate audiobooks", "Configure backends", "Settings", - "Quit"]) + ["Generate Audiobooks", "Configure Backends", "Settings", + "Help", "Quit"]) def test_ffmpeg_warning_shown_when_missing(self): # ffmpeg not on PATH → a red notice is passed above the table. @@ -293,8 +296,8 @@ class HubMenuTests(unittest.TestCase): def test_convert_with_no_available_backend_flashes(self): # Installed-but-not-ready backends → Convert is offered, but the # convert flow has nothing to list: it flashes a hint (no "Configure - # backends" detour anymore) and returns to the main menu. Then - # quit: 5 main-menu options, Quit is the 5th (Down x4). + # Backends" detour anymore) and returns to the main menu. Then + # quit: 6 main-menu options, Quit is the 6th (Down x5). from backends import BackendStatus statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True, configured=False), @@ -309,16 +312,54 @@ class HubMenuTests(unittest.TestCase): with patch.object(hub, "detect_all", return_value=statuses), \ patch.object(hub.tui, "flash", fake_flash): - # Convert(Enter) → flash → main menu; Down x4 -> Quit, Enter. + # Convert(Enter) → flash → main menu; Down x5 -> Quit, Enter. screen = FakeScreen(keys=[10, FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, + FakeCurses.KEY_DOWN, 10]) result = hub._Hub(screen).run() self.assertIsNone(result) self.assertEqual(len(flashed), 1) self.assertIn("No backend is ready", flashed[0]) + def test_help_opens_viewer_and_backs_out(self): + # Selecting Help opens the text viewer with the quick-start text + # (real folder paths); closing it lands back on the main menu. + calls = [] + + def fake_viewer(stdscr, title, lines, **kwargs): + calls.append((title, list(lines), kwargs)) + return kwargs.get("back_value") + + with patch.object(hub, "detect_all", return_value=[]), \ + patch.object(hub.tui, "text_viewer", fake_viewer): + # Help is the 3rd main-menu option (Down x2), then Enter; + # back on the main menu Quit is the 4th (Down x3), Enter. + screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, + 10, + FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, + FakeCurses.KEY_DOWN, + 10]) + result = hub._Hub(screen).run() + self.assertIsNone(result) + self.assertEqual(len(calls), 1) + title, lines, kwargs = calls[0] + self.assertEqual(title, "Help") + self.assertIs(kwargs.get("back_value"), tui.Wizard.BACK) + text = "\n".join(lines) + self.assertIn("1. Put your ebooks (epub, txt, or pdf) here:", text) + self.assertIn(str(hub.BOOKS_FOLDER), text) + self.assertIn("2. Put any .wavs of voices to clone here:", text) + self.assertIn(str(hub.common.VOICES_DIR), text) + self.assertIn("Install Backend and install audio.cpp.", text) + self.assertIn("qwen3_tts_1_7b_base_q8_0", text) + self.assertIn("qwen3_tts_1_7b_customvoice_q8_0", text) + self.assertIn("5. Go to Generate Audiobooks.", text) + self.assertIn("6. Generated audiobooks (m4b, mp3, etc.) will " + "output here:", text) + self.assertIn(str(hub.AUDIOBOOKS_FOLDER), text) + class SubmenuStatusTableTests(unittest.TestCase): """First picker screen of every flow repeats the backend status table. @@ -375,7 +416,7 @@ class SubmenuStatusTableTests(unittest.TestCase): self.assertEqual([label for label, _ in captured["options"]], ["Install Backend", "Uninstall Backend"]) # ...the shared status table carries the states instead. - self.assertEqual(captured["table_title"], "Backend status") + self.assertEqual(captured["table_title"], "Backend Status") self.assertEqual( captured["table_rows"], [("qwen-tts", "installed", "ok", "body"), @@ -421,7 +462,7 @@ class SubmenuStatusTableTests(unittest.TestCase): # the whole set of installed backends (faster has nothing on disk # and so contributes nothing). self.assertEqual([label for label, _ in captured["options"]], - ["Install Backend", "Update backends", + ["Install Backend", "Update Backends", "Uninstall Backend"]) def test_configure_backends_menu_audiocpp_model_actions(self): @@ -492,9 +533,9 @@ class SubmenuStatusTableTests(unittest.TestCase): # model download stays hidden until the binary exists — Build and # Download never coexist. Configure needs an installed (built) # backend. - self.assertEqual(labels, ["Build audio.cpp server", "Uninstall Backend"]) + self.assertEqual(labels, ["Build audio.cpp Server", "Uninstall Backend"]) self.assertEqual(captured["options"][0], - ("Build audio.cpp server", "build_audiocpp", + ("Build audio.cpp Server", "build_audiocpp", ("[recommended]", "warn"))) self.assertIs(captured["options"][1], tui.MENU_SEPARATOR) @@ -520,7 +561,7 @@ class SubmenuStatusTableTests(unittest.TestCase): result = hub._Hub(None).screen_configure() self.assertIs(result, tui.Wizard.BACK) labels = self._labels(captured["options"]) - self.assertNotIn("Build audio.cpp server", labels) + self.assertNotIn("Build audio.cpp Server", labels) def test_configure_backends_menu_configure_only_when_built_unconfigured(self): captured = {} @@ -607,7 +648,7 @@ class SubmenuStatusTableTests(unittest.TestCase): def test_bare_qwen_without_configure_screen_still_has_no_entry(self): # Without a dedicated configure screen, plain qwen stays excluded - # from Configure backends (its wizard asks nothing to configure). + # from Configure Backends (its wizard asks nothing to configure). captured = {} infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0, uninstall=lambda **kwargs: 0)] @@ -633,7 +674,7 @@ class SubmenuStatusTableTests(unittest.TestCase): patch.object(hub.shutil, "which", return_value="/x"): result = hub._Hub(None).screen_convert() self.assertIs(result, tui.Wizard.BACK) - self.assertEqual(captured["title"], "Generate audiobooks") + self.assertEqual(captured["title"], "Generate Audiobooks") # One form, no picker menu: the first field is the Backend picker, # and only convertible backends are offered in it. self.assertEqual(captured["fields"][0]["key"], "backend") @@ -677,7 +718,7 @@ class SubmenuStatusTableTests(unittest.TestCase): patch.object(hub.shutil, "which", return_value="/x"): result = hub._Hub(None).screen_configure() self.assertIs(result, tui.Wizard.BACK) - self.assertEqual(captured["table_title"], "Backend status") + self.assertEqual(captured["table_title"], "Backend Status") self.assertEqual( captured["table_rows"], [("qwen-tts", "installed", "ok", "body")]) @@ -706,7 +747,7 @@ class SubmenuStatusTableTests(unittest.TestCase): ["audio.cpp"]) # The running/stopped state lives in the status table above the # menu (not on the entries, whose colors the selection bar covers). - self.assertEqual(captured["table_title"], "Server status") + self.assertEqual(captured["table_title"], "Server Status") self.assertEqual(captured["table_rows"], [("audio.cpp", "stopped", "err", "body")]) @@ -863,7 +904,7 @@ class ConvertFlowTests(unittest.TestCase): # One form, not a cascade of menus/editors. self.assertEqual(len(self.tui.forms_seen), 1) title, fields, form_kwargs = self.tui.forms_seen[0] - self.assertEqual(title, "Generate audiobooks") + self.assertEqual(title, "Generate Audiobooks") self.assertEqual([f["key"] for f in fields], ["backend", "audiocpp-remote.model_id", "audiocpp-remote.audiocpp_voice", @@ -991,7 +1032,7 @@ class ConvertFlowTests(unittest.TestCase): def test_audiocpp_local_without_voice_dir_points_at_configure(self): # The managed entry's server.json has no voice_dir: clone-capable # models get an empty Voice picker whose hint sends the user to - # Configure backends instead of crashing on menu(). + # Configure Backends instead of crashing on menu(). with tempfile.TemporaryDirectory() as td: root = Path(td) (root / "server.json").write_text(json.dumps({ @@ -1012,7 +1053,7 @@ class ConvertFlowTests(unittest.TestCase): error = voice_field["validate"]("") self.assertIsNotNone(error) self.assertIn(".wav", error) - self.assertIn("Configure backends", error) + self.assertIn("Configure Backends", error) def test_audiocpp_remote_missing_family_is_clone_capable(self): # A missing family is unknown — not guessed as qwen3_tts — so the @@ -2213,7 +2254,7 @@ class SettingsTests(unittest.TestCase): self.assertIsNotNone(hub._validate_port("abc")) def test_language_fields_are_pickers_with_edit_hint(self): - # Both Language fields (Settings and Generate audiobooks) are + # Both Language fields (Settings and Generate Audiobooks) are # static pickers over the audio.cpp-menu languages, with a dim # hint inside their edit dialog. Common languages lead. expected_choices = ["English", "Spanish", "Chinese", "French", @@ -2820,12 +2861,12 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): return_value=0) as mk_run, \ patch_flash: hub._update_backends_action(None) - # One task-view run titled "Update backends", one step per + # One task-view run titled "Update Backends", one step per # updatable backend in registry order; executing a step # forwards emit/cancel to that backend's update. mk_run.assert_called_once() self.assertEqual(mk_run.call_args[0][0], None) - self.assertEqual(mk_run.call_args[0][1], "Update backends") + self.assertEqual(mk_run.call_args[0][1], "Update Backends") steps = mk_run.call_args[0][2] self.assertEqual([step.title for step in steps], ["Update audio.cpp", "Update qwen-tts"]) @@ -2908,7 +2949,7 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): self.assertEqual(invalidated, [True]) # An inline action: the same menu re-shows (second title) with a # freshly detected status table. - self.assertEqual(titles, ["Configure backends", "Configure backends"]) + self.assertEqual(titles, ["Configure Backends", "Configure Backends"]) def test_pick_backend_install_lists_uninstalled_only(self): captured = {} @@ -3067,7 +3108,7 @@ class HubNavigationTests(unittest.TestCase): def test_esc_on_wizard_first_screen_returns_to_configure(self): # The reported bug: Esc on the audio.cpp "Select TTS model # families" tree (the wizard's first screen) must land back on - # "Configure backends", not the main menu. + # "Configure Backends", not the main menu. info = self._info() with patch.object(info, "setup_screen", return_value=1): titles = self._drive( @@ -3076,8 +3117,8 @@ class HubNavigationTests(unittest.TestCase): [self._status()], [info]) self.assertEqual( titles, - ["tts-audiobook-generator", "Configure backends", - "Configure backends", "tts-audiobook-generator"]) + ["tts-audiobook-generator", "Configure Backends", + "Configure Backends", "tts-audiobook-generator"]) def test_esc_on_install_picker_returns_to_configure(self): registry = [self._info("audiocpp", "audio.cpp"), @@ -3091,8 +3132,8 @@ class HubNavigationTests(unittest.TestCase): statuses, registry) self.assertEqual( titles, - ["tts-audiobook-generator", "Configure backends", - "Install Backend", "Configure backends", + ["tts-audiobook-generator", "Configure Backends", + "Install Backend", "Configure Backends", "tts-audiobook-generator"]) def test_esc_on_server_action_returns_one_screen_at_a_time(self): @@ -3122,8 +3163,8 @@ class HubNavigationTests(unittest.TestCase): # Esc steps back one screen at a time to the server list and main. self.assertEqual( titles, - ["tts-audiobook-generator", "Start / Stop a server", - "Start / Stop a server", "tts-audiobook-generator"]) + ["tts-audiobook-generator", "Start / Stop A Server", + "Start / Stop A Server", "tts-audiobook-generator"]) def test_esc_on_main_menu_quits(self): titles = self._drive([tui.Wizard.BACK], [], []) diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index eb6872a..916f434 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -267,7 +267,7 @@ class MenuTests(TuiTestCase): def test_suffix_renders_in_its_theme_color(self): # default_index=1 keeps the suffixed option unselected, so its # segments keep their own colors instead of the cursor bar. - options = [("Build audio.cpp server", "build", + options = [("Build audio.cpp Server", "build", ("[recommended]", "warn")), ("other", "other")] screen = FakeScreen(keys=[10]) tui.menu(screen, "Pick", options, default_index=1) @@ -276,7 +276,7 @@ class MenuTests(TuiTestCase): if drawn == text) self.assertEqual(attr, tui._THEME["warn"]) label_attr = next(a for _, _, drawn, a in screen.strings - if drawn == "Build audio.cpp server") + if drawn == "Build audio.cpp Server") self.assertEqual(label_attr, tui._THEME["body"]) def test_selected_row_has_arrow_in_the_margin(self): @@ -317,7 +317,7 @@ class MenuTableTests(TuiTestCase): def test_name_column_left_aligned_at_margin(self): screen = FakeScreen(keys=[10]) tui.menu(screen, "Hub", [("Quit", "quit")], - table_title="Backend status", table_rows=self.ROWS) + table_title="Backend Status", table_rows=self.ROWS) x0, _ = self.dialog_box(screen) margin = x0 + 1 + tui.Frame.LIST_MARGIN for name, _, _ in self.ROWS: @@ -328,7 +328,7 @@ class MenuTableTests(TuiTestCase): def test_status_column_aligned_at_one_fixed_offset(self): screen = FakeScreen(keys=[10]) tui.menu(screen, "Hub", [("Quit", "quit")], - table_title="Backend status", table_rows=self.ROWS) + table_title="Backend Status", table_rows=self.ROWS) x0, _ = self.dialog_box(screen) margin = x0 + 1 + tui.Frame.LIST_MARGIN name_w = max(len(name) for name, _, _ in self.ROWS) @@ -341,7 +341,7 @@ class MenuTableTests(TuiTestCase): def test_status_text_uses_the_theme_kind_color(self): screen = FakeScreen(keys=[10]) tui.menu(screen, "Hub", [("Quit", "quit")], - table_title="Backend status", table_rows=self.ROWS) + table_title="Backend Status", table_rows=self.ROWS) want = {"err": tui._THEME["err"], "warn": tui._THEME["warn"], "ok": tui._THEME["ok"]} for _, status, kind in self.ROWS: @@ -362,7 +362,7 @@ class MenuTableTests(TuiTestCase): def test_three_element_rows_default_to_body_names(self): screen = FakeScreen(keys=[10]) tui.menu(screen, "Hub", [("Quit", "quit")], - table_title="Backend status", table_rows=self.ROWS) + table_title="Backend Status", table_rows=self.ROWS) for name, _, _ in self.ROWS: attr = next(a for _, _, text, a in screen.strings if text.rstrip() == name) @@ -371,24 +371,24 @@ class MenuTableTests(TuiTestCase): def test_table_title_is_dim_and_left_aligned(self): screen = FakeScreen(keys=[10]) tui.menu(screen, "Hub", [("Quit", "quit")], - table_title="Backend status", table_rows=self.ROWS) + table_title="Backend Status", table_rows=self.ROWS) x0, _ = self.dialog_box(screen) margin = x0 + 1 + tui.Frame.LIST_MARGIN x, attr = next((x, a) for _, x, text, a in screen.strings - if text == "Backend status") + if text == "Backend Status") self.assertEqual(x, margin) self.assertEqual(attr, tui._THEME["dim"]) def test_table_does_not_paint_over_the_border(self): screen = FakeScreen(keys=[10]) tui.menu(screen, "Hub", [("Quit", "quit")], - table_title="Backend status", table_rows=self.ROWS) + table_title="Backend Status", table_rows=self.ROWS) self.assert_inside_border(screen) def test_notice_line_is_red_and_above_the_table(self): screen = FakeScreen(keys=[10]) tui.menu(screen, "Hub", [("Quit", "quit")], - table_title="Backend status", table_rows=self.ROWS, + table_title="Backend Status", table_rows=self.ROWS, notice_lines=[("Warning: ffmpeg not installed!", "err")]) x0, _ = self.dialog_box(screen) margin = x0 + 1 + tui.Frame.LIST_MARGIN @@ -396,11 +396,11 @@ class MenuTableTests(TuiTestCase): if text == "Warning: ffmpeg not installed!") self.assertEqual(x, margin) self.assertEqual(attr, tui._THEME["err"]) - # The notice sits above the table title ("Backend status"). + # The notice sits above the table title ("Backend Status"). notice_y = next(y for y, _, text, _ in screen.strings if text == "Warning: ffmpeg not installed!") title_y = next(y for y, _, text, _ in screen.strings - if text == "Backend status") + if text == "Backend Status") self.assertLess(notice_y, title_y) self.assert_inside_border(screen) @@ -1283,6 +1283,88 @@ class FlashTests(TuiTestCase): self.assertIsNone(frame.status) +class TextViewerTests(TuiTestCase): + """tui.text_viewer: a scrollable read-only dialog; Esc/q/Enter closes.""" + + LINES = ["first line", "second line", "third line"] + + def test_lines_render_and_enter_closes(self): + marker = object() + screen = FakeScreen(keys=[10]) + self.assertIs( + tui.text_viewer(screen, "Help", self.LINES, back_value=marker), + marker) + for line in self.LINES: + self.assertTrue(any(text == line for _, _, text, _ + in screen.strings), line) + self.assert_inside_border(screen) + + def test_esc_returns_back_value(self): + marker = object() + screen = FakeScreen(keys=[27]) + self.assertIs( + tui.text_viewer(screen, "Help", self.LINES, back_value=marker), + marker) + + def test_q_returns_back_value_like_esc(self): + marker = object() + screen = FakeScreen(keys=[ord("q")]) + self.assertIs( + tui.text_viewer(screen, "Help", self.LINES, back_value=marker), + marker) + + def test_esc_aborts_without_back_value(self): + screen = FakeScreen(keys=[27]) + with self.assertRaises(tui.WizardCancelled): + tui.text_viewer(screen, "Help", self.LINES) + + def test_enter_aborts_without_back_value(self): + screen = FakeScreen(keys=[10]) + with self.assertRaises(tui.WizardCancelled): + tui.text_viewer(screen, "Help", self.LINES) + + def test_no_cursor_bar_is_drawn(self): + # Read-only: no row is selectable, so even with the cursor on a + # line the cyan selection bar never paints. + screen = FakeScreen(keys=[FakeCurses.KEY_END, ord("q")]) + tui.text_viewer(screen, "Help", self.LINES, back_value=object()) + bars = [s for s in screen.strings + if s[3] == tui._THEME["bar"] and not s[2].strip()] + self.assertEqual(bars, []) + + def test_end_scrolls_the_last_line_into_view(self): + # Content taller than the terminal: End shows the last line and + # the frame's scroll indicator appears in the border. + lines = [f"line {i}" for i in range(40)] + screen = FakeScreen(keys=[FakeCurses.KEY_END, ord("q")]) + tui.text_viewer(screen, "Help", lines, back_value=object()) + drawn = [text for _, _, text, _ in screen.strings] + self.assertIn("line 39", drawn) + self.assertTrue(any("/40" in text for text in drawn)) + + def test_home_returns_to_the_top(self): + lines = [f"line {i}" for i in range(40)] + screen = FakeScreen(keys=[FakeCurses.KEY_END, FakeCurses.KEY_HOME, + ord("q")]) + tui.text_viewer(screen, "Help", lines, back_value=object()) + drawn = [text for _, _, text, _ in screen.strings] + self.assertIn("line 0", drawn) + + def test_page_down_moves_a_full_page(self): + # PageDown from the top lands one page (17 visible rows) down: + # the first line scrolls off, the next one is at the top. + lines = [f"line {i}" for i in range(40)] + screen = FakeScreen(keys=[FakeCurses.KEY_NPAGE, ord("q")]) + tui.text_viewer(screen, "Help", lines, back_value=object()) + # Only the final frame counts: strings accumulate across redraws. + titles = [i for i, entry in enumerate(screen.strings) + if entry[2] == " Help "] + drawn = [entry[2] for entry in screen.strings[titles[-1]:]] + self.assertIn("line 1", drawn) + self.assertNotIn("line 0", drawn) + self.assertIn(" 2/40 ", drawn) + + class WizardTests(unittest.TestCase): """The tui.Wizard screen-stack driver: Esc steps back one screen.""" |
