From 0522e73b68291af62e43c387ab8f9b8ffa2cab47 Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 18:57:09 -0400 Subject: feat: configure [backend] loads existing backend config rather than just overwriting it --- app/tests/test_backends.py | 6 +- app/tests/test_backends_audiocpp.py | 296 ++++++++++++++++++++++++++++++++++++ app/tests/test_backends_faster.py | 77 ++++++++++ app/tests/test_hub.py | 270 ++++++++++++++++++++++++++++---- app/tests/test_tui.py | 31 ++++ 5 files changed, 644 insertions(+), 36 deletions(-) (limited to 'app/tests') diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index acee6b6..0e260be 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -31,13 +31,11 @@ class RegistryTests(unittest.TestCase): keys = [info.key for info in REGISTRY] self.assertEqual(keys, ["audiocpp", "qwen", "faster"]) - def test_every_entry_has_detect_and_setup_tui(self): + def test_every_entry_has_detect_setup_and_uninstall(self): for info in REGISTRY: self.assertTrue(callable(info.detect), info.key) self.assertTrue(callable(info.setup_tui), info.key) - self.assertIsInstance(info.configure_actions, list) - for action in info.configure_actions: - self.assertTrue(callable(action.run)) + self.assertTrue(callable(info.uninstall), info.key) def test_get_returns_entry_by_key(self): self.assertIs(get("audiocpp").key, "audiocpp") diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 563ed78..3d042db 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -1270,5 +1270,301 @@ class DetectServerSpecTests(unittest.TestCase): for line in status.details)) +class InstalledModelEntriesTests(unittest.TestCase): + """installed_model_entries: the complement of missing_model_entries.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def _server_json(self, models): + path = self.dir / "server.json" + path.write_text(json.dumps({"models": models}), encoding="utf-8") + return path + + def test_lists_entries_whose_files_are_on_disk(self): + (self.dir / "models" / "present").mkdir(parents=True) + (self.dir / "models" / "present" / "m.gguf").write_bytes(b"x") + path = self._server_json([ + {"id": "a", "path": "models/present"}, + {"id": "b", "path": "models/absent"}, + ]) + installed = make_server.installed_model_entries(path) + self.assertEqual([m["id"] for m in installed], ["a"]) + + def test_unreadable_json_returns_empty(self): + path = self.dir / "server.json" + path.write_text("not json", encoding="utf-8") + self.assertEqual(make_server.installed_model_entries(path), []) + + +class MissingModelInstallGuidanceTests(unittest.TestCase): + """missing_model_install_guidance: missing paths -> (id, install_id).""" + + def test_maps_paths_and_skips_unmapped(self): + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) + specs = checkout / "model_specs" + specs.mkdir() + (specs / "qwen3_tts.json").write_text(json.dumps({ + "family": "qwen3_tts", "category": "tts", + "tasks": ["tts"], + "packages": [{ + "id": "qwen3_tts_0_6b_base_q8_0", "format": "gguf", + "target_directory": "Qwen3-TTS-12Hz-0.6B-Base-GGUF", + }], + }), encoding="utf-8") + missing = [ + {"id": "qwen", "rel": "models/Qwen3-TTS-12Hz-0.6B-Base-GGUF"}, + {"id": "x", "rel": "models/nope"}, + ] + guidance = make_server.missing_model_install_guidance( + checkout, missing) + self.assertEqual(guidance, + [("qwen", "qwen3_tts_0_6b_base_q8_0")]) + + +class LoadServerConfigTests(unittest.TestCase): + """load_server_config: read server.json, or None when unusable.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def test_reads_dict_document(self): + path = self.dir / "server.json" + path.write_text(json.dumps({"host": "0.0.0.0", "models": []}), + encoding="utf-8") + self.assertEqual(make_server.load_server_config(path), + {"host": "0.0.0.0", "models": []}) + + def test_missing_file_returns_none(self): + self.assertIsNone(make_server.load_server_config( + self.dir / "nope.json")) + + def test_unreadable_json_returns_none(self): + path = self.dir / "server.json" + path.write_text("not json", encoding="utf-8") + self.assertIsNone(make_server.load_server_config(path)) + + def test_non_dict_document_returns_none(self): + path = self.dir / "server.json" + path.write_text("[1, 2, 3]", encoding="utf-8") + self.assertIsNone(make_server.load_server_config(path)) + + +class ServerConfigSelectionsTests(unittest.TestCase): + """server_config_selections: map server.json models back to the catalog.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.checkout = _make_checkout(Path(self._tmp.name)) + self.catalog = make_server.load_model_catalog(self.checkout) + + def tearDown(self): + self._tmp.cleanup() + + def test_maps_paths_to_family_dirs_and_tasks(self): + config = {"models": [ + {"id": "qwen", "family": "qwen3_tts", + "path": "models/Qwen3-TTS-12Hz-1.7B-Base-GGUF", "task": "tts"}, + {"id": "qwen-design", "family": "qwen3_tts", + "path": "models/Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF", + "task": "vdes"}, + {"id": "higgs", "family": "higgs_audio_tts", + "path": "models/Higgs-Audio-v3-TTS-4B-GGUF", "task": "tts"}, + ]} + selected, tasks = make_server.server_config_selections(config, + self.catalog) + self.assertEqual(selected["qwen3_tts"], + ["Qwen3-TTS-12Hz-1.7B-Base-GGUF", + "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF"]) + self.assertEqual(selected["higgs_audio_tts"], + ["Higgs-Audio-v3-TTS-4B-GGUF"]) + self.assertEqual(tasks[("qwen3_tts", + "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF")], + "vdes") + self.assertEqual(tasks[("qwen3_tts", + "Qwen3-TTS-12Hz-1.7B-Base-GGUF")], "tts") + + def test_unknown_family_ignored(self): + config = {"models": [ + {"id": "x", "family": "not_a_family", "path": "models/x"}, + ]} + selected, tasks = make_server.server_config_selections(config, + self.catalog) + self.assertEqual(selected, {}) + self.assertEqual(tasks, {}) + + def test_absolute_and_unprefixed_paths_kept_as_targets(self): + config = {"models": [ + {"id": "qwen", "family": "qwen3_tts", + "path": "/abs/Qwen3-TTS-12Hz-1.7B-Base-GGUF", "task": "tts"}, + ]} + selected, tasks = make_server.server_config_selections(config, + self.catalog) + self.assertEqual(selected["qwen3_tts"], + ["/abs/Qwen3-TTS-12Hz-1.7B-Base-GGUF"]) + + def test_empty_models_yield_empty_selections(self): + selected, tasks = make_server.server_config_selections({"models": []}, + self.catalog) + self.assertEqual(selected, {}) + self.assertEqual(tasks, {}) + + +class UnusedInstalledEntriesTests(unittest.TestCase): + """unused_installed_entries: installed models dropped by a new selection.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + (self.dir / "models" / "kept").mkdir(parents=True) + (self.dir / "models" / "kept" / "m.gguf").write_bytes(b"x") + (self.dir / "models" / "dropped").mkdir() + (self.dir / "models" / "dropped" / "m.gguf").write_bytes(b"x") + (self.dir / "models" / "missing").mkdir() # empty: not installed + + def tearDown(self): + self._tmp.cleanup() + + def _server_json(self, models): + path = self.dir / "server.json" + path.write_text(json.dumps({"models": models}), encoding="utf-8") + return path + + def test_returns_installed_entries_not_in_new_paths(self): + path = self._server_json([ + {"id": "kept", "path": "models/kept"}, + {"id": "dropped", "path": "models/dropped"}, + {"id": "missing", "path": "models/missing"}, + ]) + unused = make_server.unused_installed_entries( + path, {"models/kept"}) + self.assertEqual([entry["id"] for entry in unused], ["dropped"]) + + def test_nothing_unused_when_all_kept(self): + path = self._server_json([ + {"id": "kept", "path": "models/kept"}, + ]) + unused = make_server.unused_installed_entries( + path, {"models/kept"}) + self.assertEqual(unused, []) + + +class DeleteModelFilesTests(unittest.TestCase): + """delete_model_files: remove on-disk model files for {id, rel} entries.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + (self.dir / "models" / "a").mkdir(parents=True) + (self.dir / "models" / "a" / "m.gguf").write_bytes(b"x") + (self.dir / "models" / "b").mkdir() + (self.dir / "models" / "b" / "m.gguf").write_bytes(b"x") + (self.dir / "models" / "c").mkdir(parents=True) + self.server_json = self.dir / "server.json" + self.server_json.write_text(json.dumps({ + "models": [ + {"id": "a", "path": "models/a"}, + {"id": "b", "path": "models/b"}, + {"id": "c", "path": "models/c"}, + ], + }), encoding="utf-8") + + def tearDown(self): + self._tmp.cleanup() + + def test_removes_dirs_and_counts(self): + removed = make_server.delete_model_files( + self.server_json, + [{"id": "a", "rel": "models/a"}, {"id": "b", "rel": "models/b"}]) + self.assertEqual(removed, 2) + self.assertFalse((self.dir / "models" / "a").exists()) + self.assertFalse((self.dir / "models" / "b").exists()) + self.assertTrue((self.dir / "models" / "c").exists()) + + def test_missing_paths_ignored(self): + removed = make_server.delete_model_files( + self.server_json, [{"id": "ghost", "rel": "models/ghost"}]) + self.assertEqual(removed, 0) + + def test_removes_single_file(self): + file_path = self.dir / "models" / "single.gguf" + file_path.write_bytes(b"x") + removed = make_server.delete_model_files( + self.server_json, [{"id": "s", "rel": "models/single.gguf"}]) + self.assertEqual(removed, 1) + self.assertFalse(file_path.exists()) + + def test_absolute_rel_path_honored(self): + target = self.dir / "absolute" + target.mkdir() + (target / "m.gguf").write_bytes(b"x") + removed = make_server.delete_model_files( + self.server_json, [{"id": "a", "rel": str(target)}]) + self.assertEqual(removed, 1) + self.assertFalse(target.exists()) + + +class InstallModelsTests(unittest.TestCase): + """install_models: runs the install helper with download=True.""" + + def test_downloads_delegating_to_install_models(self): + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) + guidance = [("qwen", "qwen3_tts_0_6b_base_q8_0")] + with patch.object(make_server, "_install_models") as mk: + make_server.install_models(checkout, guidance) + mk.assert_called_once_with(checkout, guidance, download=True) + + +class HandInstallGuidanceTests(unittest.TestCase): + """hand_install_guidance: explains how to install models by hand.""" + + def test_lists_each_model_and_its_path(self): + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) + message = make_server.hand_install_guidance(checkout, [ + {"id": "qwen", "rel": "models/Qwen3-TTS-12Hz-0.6B-Base-GGUF"}, + {"id": "higgs", "rel": "models/Higgs-Audio-4B-GGUF"}, + ]) + self.assertIn("qwen", message) + self.assertIn("models/Qwen3-TTS-12Hz-0.6B-Base-GGUF", message) + self.assertIn("higgs", message) + self.assertIn("models/Higgs-Audio-4B-GGUF", message) + self.assertIn("download", message.lower()) + + +class UninstallTests(unittest.TestCase): + """uninstall: stop the server and remove the checkout.""" + + def test_removes_checkout(self): + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) / "audio.cpp" + checkout.mkdir() + with patch.object(make_server, "find_local_checkout", + return_value=checkout), \ + patch.object(make_server.servers, "stop") as mk_stop: + rc = make_server.uninstall() + self.assertEqual(rc, 0) + self.assertFalse(checkout.exists()) + mk_stop.assert_called_once_with("audiocpp") + + def test_no_checkout_is_a_noop(self): + with patch.object(make_server, "find_local_checkout", + return_value=None), \ + patch.object(make_server.servers, "stop") as mk_stop: + rc = make_server.uninstall() + self.assertEqual(rc, 0) + mk_stop.assert_called_once_with("audiocpp") + + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_backends_faster.py b/app/tests/test_backends_faster.py index 641f6ee..21baea7 100644 --- a/app/tests/test_backends_faster.py +++ b/app/tests/test_backends_faster.py @@ -80,6 +80,83 @@ class BuildVoicesTests(unittest.TestCase): self.assertEqual(mock_transcribe.call_args.kwargs["model_name"], "large-v3") +class LoadVoicesTests(unittest.TestCase): + """load_voices: read voices.json, or {} when unusable.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + self.path = self.dir / "voices.json" + + def tearDown(self): + self._tmp.cleanup() + + def test_reads_dict_document(self): + self.path.write_text(json.dumps({"narrator": {"ref_text": "hi"}}), + encoding="utf-8") + self.assertEqual(make_voices.load_voices(self.path), + {"narrator": {"ref_text": "hi"}}) + + def test_missing_file_returns_empty(self): + self.assertEqual(make_voices.load_voices(self.path), {}) + + def test_unreadable_json_returns_empty(self): + self.path.write_text("not json", encoding="utf-8") + self.assertEqual(make_voices.load_voices(self.path), {}) + + def test_non_dict_document_returns_empty(self): + self.path.write_text("[1, 2]", encoding="utf-8") + self.assertEqual(make_voices.load_voices(self.path), {}) + + +class DecideFasterTranscriptionTests(unittest.TestCase): + """_decide_faster_transcription: the re-transcribe plan questions.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.folder = Path(self._tmp.name) + self.narrator = self.folder / "narrator.wav" + self.narrator.write_bytes(b"x") + self.new_voice = self.folder / "new.wav" + self.new_voice.write_bytes(b"x") + + def tearDown(self): + self._tmp.cleanup() + + def test_new_voices_default_to_missing_mode(self): + confirm = lambda q, default=True: True # noqa: E731 + plan = make_voices._decide_faster_transcription( + [self.narrator, self.new_voice], + {"narrator": {"ref_text": "old"}}, confirm) + self.assertEqual(plan["mode"], "missing") + self.assertEqual([w.name for w in plan["missing"]], ["new.wav"]) + + def test_declining_new_voices_transcribes_all(self): + confirm = lambda q, default=True: False # noqa: E731 + plan = make_voices._decide_faster_transcription( + [self.narrator, self.new_voice], + {"narrator": {"ref_text": "old"}}, confirm) + self.assertEqual(plan["mode"], "all") + + def test_no_new_voices_offers_retranscribe_default_no(self): + confirm = lambda q, default=True: default # noqa: E731 + plan = make_voices._decide_faster_transcription( + [self.narrator], {"narrator": {"ref_text": "old"}}, confirm) + self.assertEqual(plan["mode"], "keep") + + def test_no_new_voices_accepted_retranscribes_all(self): + confirm = lambda q, default=True: True # noqa: E731 + plan = make_voices._decide_faster_transcription( + [self.narrator], {"narrator": {"ref_text": "old"}}, confirm) + self.assertEqual(plan["mode"], "all") + + def test_cancel_returns_none(self): + confirm = lambda q, default=True: None # noqa: E731 + plan = make_voices._decide_faster_transcription( + [self.narrator], {"narrator": {"ref_text": "old"}}, confirm) + self.assertIsNone(plan) + + class MainTests(unittest.TestCase): """The flag-only (non-TUI) path through main(), end to end.""" diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 5f91a61..a8e3ac1 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -129,16 +129,16 @@ 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 [Set up, Settings, Quit]. - # Quit is the 3rd option (Down twice) then Enter. + # 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]) with patch.object(hub, "detect_all", return_value=[]): result = hub._hub_menu(screen) self.assertIsNone(result) - def test_menu_has_only_setup_settings_and_quit_without_backends(self): + def test_menu_has_only_configure_settings_and_quit_without_backends(self): # Capture the options handed to tui.menu: with nothing installed or - # running, Convert/Configure must be absent. + # running, Convert/Server must be absent. captured = {} def fake_menu(stdscr, title, options, **kwargs): @@ -150,9 +150,9 @@ class HubMenuTests(unittest.TestCase): patch.object(hub, "detect_all", return_value=[]): hub._hub_menu(screen) labels = [label for label, _ in captured["options"]] - self.assertEqual(labels, ["Set up a backend", "Settings", "Quit"]) + self.assertEqual(labels, ["Configure backends", "Settings", "Quit"]) - def test_menu_has_all_six_when_one_installed(self): + def test_menu_has_all_five_when_one_installed(self): captured = {} def fake_menu(stdscr, title, options, **kwargs): @@ -169,9 +169,8 @@ class HubMenuTests(unittest.TestCase): labels = [label for label, _ in captured["options"]] self.assertEqual( labels, - ["Convert books", "Set up a backend", - "Configure a backend", "Start/Stop Backend Servers", - "Settings", "Quit"]) + ["Convert books", "Configure backends", + "Start/Stop Backend Servers", "Settings", "Quit"]) # The status table is passed through, one row per backend. self.assertEqual(captured["rows"], [("qwen-tts", "installed", "warn", "body")]) @@ -199,9 +198,9 @@ class HubMenuTests(unittest.TestCase): [("audio.cpp", "unavailable", "err", "dim"), ("qwen-tts", "running [remote]", "ok", "body")]) - def test_menu_hides_configure_and_server_when_only_running(self): + def test_menu_hides_server_when_only_running(self): # Running but not installed (an external server) still unlocks - # Convert — but Configure/Server need the backend on this machine. + # Convert — but Start/Stop needs the backend on this machine. captured = {} def fake_menu(stdscr, title, options, **kwargs): @@ -217,7 +216,7 @@ class HubMenuTests(unittest.TestCase): labels = [label for label, _ in captured["options"]] self.assertEqual( labels, - ["Convert books", "Set up a backend", "Settings", "Quit"]) + ["Convert books", "Configure backends", "Settings", "Quit"]) def test_ffmpeg_warning_shown_when_missing(self): # ffmpeg not on PATH → a red notice is passed above the table. @@ -252,9 +251,9 @@ 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 "Set up - # a backend" detour anymore) and returns to the main menu. Then - # quit: 6 main-menu options, Quit is the 6th (Down x5). + # 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). from backends import BackendStatus statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True, configured=False), @@ -269,11 +268,11 @@ 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 x5 -> Quit, Enter. + # Convert(Enter) → flash → main menu; Down x4 -> Quit, Enter. screen = FakeScreen(keys=[10, FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, - FakeCurses.KEY_DOWN, 10]) + 10]) result = hub._hub_menu(screen) self.assertIsNone(result) self.assertEqual(len(flashed), 1) @@ -283,8 +282,8 @@ class HubMenuTests(unittest.TestCase): class SubmenuStatusTableTests(unittest.TestCase): """First picker screen of every flow repeats the backend status table. - Entries themselves stay clean: setup lists bare labels, and the - Start/Stop menu offers only installed backends. + Entries themselves stay clean: the configure-backends menu lists flat + actions, and the Start/Stop menu offers only installed backends. """ def _capture_menu(self, captured): @@ -305,14 +304,15 @@ class SubmenuStatusTableTests(unittest.TestCase): return fake_form - def test_setup_menu_lists_bare_labels_and_status_table(self): + def test_configure_backends_menu_lists_actions_and_status_table(self): captured = {} - infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, lambda: 0), - BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)] + infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0), + BackendInfo("faster", "faster-qwen3-tts", lambda: None, + lambda: 0)] statuses = [ - BackendStatus("audiocpp", "audio.cpp", installed=True, + BackendStatus("qwen", "qwen-tts", installed=True, configured=True), - BackendStatus("qwen", "qwen-tts", installed=False, + BackendStatus("faster", "faster-qwen3-tts", installed=False, configured=False, running=True, remote=True), ] with patch.object(hub, "REGISTRY", infos), \ @@ -320,19 +320,66 @@ class SubmenuStatusTableTests(unittest.TestCase): self._capture_menu(captured)), \ patch.object(hub.shutil, "which", return_value="/usr/bin/ffmpeg"): - result = hub._setup_menu(None, statuses) + result = hub._configure_backends_menu(None, statuses) self.assertIsNone(result) - # No inline "(running)"-style suffix on the entries anymore... + # Install (faster uninstalled), Configure (qwen installed), then + # Uninstall (qwen installed); no audio.cpp means no model actions. self.assertEqual([label for label, _ in captured["options"]], - ["audio.cpp", "qwen-tts"]) + ["Install Backend", "Configure qwen-tts", + "Uninstall Backend"]) # ...the shared status table carries the states instead. self.assertEqual(captured["table_title"], "Backend status") self.assertEqual( captured["table_rows"], - [("audio.cpp", "installed", "warn", "body"), - ("qwen-tts", "running [remote]", "ok", "body")]) + [("qwen-tts", "installed", "warn", "body"), + ("faster-qwen3-tts", "running [remote]", "ok", "body")]) self.assertIsNone(captured["notice_lines"]) + def test_configure_backends_menu_install_only_when_nothing_installed(self): + captured = {} + infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)] + statuses = [BackendStatus("qwen", "qwen-tts", installed=False, + configured=False)] + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub.tui, "menu", + self._capture_menu(captured)), \ + patch.object(hub.shutil, "which", return_value="/x"): + result = hub._configure_backends_menu(None, statuses) + self.assertIsNone(result) + # Nothing installed: only the install entry is offered. + self.assertEqual([label for label, _ in captured["options"]], + ["Install Backend"]) + + def test_configure_backends_menu_audiocpp_model_actions(self): + captured = {} + infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, lambda: 0)] + statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=True)] + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) + (checkout / "server.json").write_text(json.dumps({ + "models": [{"id": "present", "path": "models/present"}, + {"id": "absent", "path": "models/absent"}], + }), encoding="utf-8") + (checkout / "models" / "present").mkdir(parents=True) + (checkout / "models" / "present" / "m.gguf").write_bytes(b"x") + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub.tui, "menu", + self._capture_menu(captured)), \ + patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=checkout), \ + patch.object(hub.shutil, "which", return_value="/x"): + result = hub._configure_backends_menu(None, statuses) + self.assertIsNone(result) + labels = [label for label, _ in captured["options"]] + # A model is missing (download), plus the installed backend's + # configure + uninstall entries. Deleting unused models now lives + # inside the "Configure audio.cpp" wizard, not here. + self.assertEqual( + labels, + ["Configure audio.cpp", "Download Missing Models (audio.cpp)", + "Uninstall Backend"]) + def test_convert_menu_builds_one_form_with_backend_field(self): captured = {} st = BackendStatus("qwen", "qwen-tts", installed=True, @@ -373,7 +420,7 @@ class SubmenuStatusTableTests(unittest.TestCase): self.assertEqual(menus, []) self.assertIn("No backend is ready", flashed[0]) - def test_configure_menu_shows_status_table(self): + def test_configure_backends_menu_shows_status_table(self): captured = {} infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)] statuses = [BackendStatus("qwen", "qwen-tts", installed=True, @@ -382,7 +429,7 @@ class SubmenuStatusTableTests(unittest.TestCase): patch.object(hub.tui, "menu", self._capture_menu(captured)), \ patch.object(hub.shutil, "which", return_value="/x"): - result = hub._configure_menu(None, statuses) + result = hub._configure_backends_menu(None, statuses) self.assertIsNone(result) self.assertEqual(captured["table_title"], "Backend status") self.assertEqual( @@ -433,7 +480,7 @@ class SubmenuStatusTableTests(unittest.TestCase): patch.object(hub.tui, "menu", self._capture_menu(captured)), \ patch.object(hub.shutil, "which", return_value=None): - hub._setup_menu(None, statuses) + hub._configure_backends_menu(None, statuses) self.assertEqual(captured["notice_lines"], [("Warning: ffmpeg not installed!", "err")]) @@ -1408,5 +1455,164 @@ class AudiocppServerConfigTests(unittest.TestCase): self.assertFalse(audiocpp_backend.update_server_config_port(9090)) +class ConfigureBackendsDispatchTests(unittest.TestCase): + """run() and the configure-backends submenus dispatch their commands.""" + + def test_run_dispatches_install_to_setup_tui(self): + info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0) + + def fake_wrapper(cb): + fake_wrapper.calls += 1 + return ("install", "qwen") if fake_wrapper.calls == 1 else None + fake_wrapper.calls = 0 + + import curses + with patch.object(curses, "wrapper", fake_wrapper), \ + patch.object(hub, "get", return_value=info), \ + patch.object(info, "setup_tui") as mk_setup: + hub.run() + mk_setup.assert_called_once_with() + + def test_run_dispatches_uninstall(self): + info = BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0) + + def fake_wrapper(cb): + fake_wrapper.calls += 1 + return ("uninstall", "qwen") if fake_wrapper.calls == 1 else None + fake_wrapper.calls = 0 + + import curses + with patch.object(curses, "wrapper", fake_wrapper), \ + patch.object(hub, "get", return_value=info), \ + patch.object(info, "uninstall") as mk_uninstall: + hub.run() + mk_uninstall.assert_called_once_with() + + def _capture_flashes(self): + flashes = [] + + def fake_flash(stdscr, text, kind="warn"): + flashes.append((text, kind)) + + return patch.object(hub.tui, "flash", fake_flash), flashes + + def test_download_models_action_flashes_hand_install_guidance(self): + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) + (checkout / "server.json").write_text(json.dumps({"models": []}), + encoding="utf-8") + missing = [{"id": "qwen", + "rel": "models/Qwen3-TTS-12Hz-0.6B-Base-GGUF"}] + patch_flash, flashes = self._capture_flashes() + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=checkout), \ + patch.object(hub.audiocpp_backend, "missing_model_entries", + return_value=missing), \ + patch.object(hub.audiocpp_backend, + "missing_model_install_guidance", + return_value=[]), \ + patch.object(hub.audiocpp_backend, "hand_install_guidance", + return_value="do it by hand") as mk_hand, \ + patch_flash: + hub._download_models_action(None) + self.assertEqual(flashes, [("do it by hand", "err")]) + mk_hand.assert_called_once() + + def test_download_models_action_flashes_ok_when_nothing_missing(self): + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) + (checkout / "server.json").write_text(json.dumps({"models": []}), + encoding="utf-8") + patch_flash, flashes = self._capture_flashes() + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=checkout), \ + patch.object(hub.audiocpp_backend, "missing_model_entries", + return_value=[]), \ + patch_flash: + hub._download_models_action(None) + self.assertEqual(len(flashes), 1) + self.assertEqual(flashes[0][1], "ok") + + def test_download_models_action_suspends_and_installs(self): + import contextlib + + @contextlib.contextmanager + def fake_suspend(scr): + yield + + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) + (checkout / "server.json").write_text(json.dumps({"models": []}), + encoding="utf-8") + missing = [{"id": "qwen", "rel": "models/q"}] + guidance = [("qwen", "qwen3_tts_0_6b_base_q8_0")] + patch_flash, flashes = self._capture_flashes() + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=checkout), \ + patch.object(hub.audiocpp_backend, "missing_model_entries", + return_value=missing), \ + patch.object(hub.audiocpp_backend, + "missing_model_install_guidance", + return_value=guidance), \ + patch.object(hub.tui, "suspend", fake_suspend), \ + patch.object(hub.audiocpp_backend, "install_models") as mk, \ + patch_flash: + hub._download_models_action(None) + mk.assert_called_once_with(checkout, guidance) + self.assertEqual(len(flashes), 1) + self.assertEqual(flashes[0][1], "ok") + + def test_download_models_action_flashes_error_when_no_checkout(self): + patch_flash, flashes = self._capture_flashes() + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=None), patch_flash: + hub._download_models_action(None) + self.assertEqual(len(flashes), 1) + self.assertEqual(flashes[0][1], "err") + + def test_pick_backend_menu_install_lists_uninstalled_only(self): + captured = {} + + def fake_menu(stdscr, title, options, **kwargs): + captured["options"] = options + return hub._GO_BACK + + infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0), + BackendInfo("faster", "faster-qwen3-tts", lambda: None, + lambda: 0)] + statuses = [BackendStatus("qwen", "qwen-tts", installed=True, + configured=True), + BackendStatus("faster", "faster-qwen3-tts", + installed=False, configured=False)] + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub.tui, "menu", fake_menu): + result = hub._pick_backend_menu(None, statuses, "Install Backend", + installed_only=False) + self.assertIsNone(result) + self.assertEqual([label for label, _ in captured["options"]], + ["faster-qwen3-tts"]) + + def test_pick_backend_menu_uninstall_lists_installed_only(self): + captured = {} + + def fake_menu(stdscr, title, options, **kwargs): + captured["options"] = options + return "qwen" + + infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0), + BackendInfo("faster", "faster-qwen3-tts", lambda: None, + lambda: 0)] + statuses = [BackendStatus("qwen", "qwen-tts", installed=True, + configured=True), + BackendStatus("faster", "faster-qwen3-tts", + installed=False, configured=False)] + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub.tui, "menu", fake_menu): + result = hub._pick_backend_menu(None, statuses, "Uninstall Backend", + installed_only=True) + self.assertEqual(result, ("uninstall", "qwen")) + self.assertEqual([label for label, _ in captured["options"]], + ["qwen-tts"]) + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index ce408af..49960a1 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -857,6 +857,37 @@ class CheckboxTreeTests(TuiTestCase): picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES) self.assertEqual(picked, [(0, "pkg-a")]) + def test_prechecked_selection_accepted_directly(self): + # checked= seeds the tree (modify flow): Enter alone accepts the + # pre-checked option without any key presses in between. + screen = FakeScreen(keys=[10]) + picked = tui.checkbox_tree( + screen, "Pick models", self.FAMILIES, + checked={(0, "pkg-b"), (1, "pkg-c")}) + self.assertEqual(picked, [(0, "pkg-b"), (1, "pkg-c")]) + + def test_prechecked_options_draw_as_checked(self): + screen = FakeScreen(keys=[10]) + tui.checkbox_tree(screen, "Pick models", self.FAMILIES, + checked={(0, "pkg-b")}) + texts = [text for _, _, text, _ in screen.strings] + # The pre-checked option row (indented) draws its box checked. + self.assertIn("[x] ", texts) + # ...and the family row is expanded (its options are listed). + self.assertIn("- Family one", texts) + + def test_prechecked_family_cursor_starts_on_it(self): + # Only the second family is pre-checked, so the cursor starts on it: + # Space clears then re-checks that family (the cursor never moves). + # If the cursor were still on the first family, the two Spaces would + # check then clear family one and Enter would flash instead of + # accepting anything. + screen = FakeScreen(keys=[ord(" "), ord(" "), 10]) + picked = tui.checkbox_tree( + screen, "Pick models", self.FAMILIES, + checked={(1, "pkg-c")}) + self.assertEqual(picked, [(1, "pkg-c")]) + def test_empty_families_rejected(self): with self.assertRaises(ValueError): tui.checkbox_tree(self.screen, "Pick", []) -- cgit v1.2.3