diff options
Diffstat (limited to 'app/tests')
| -rw-r--r-- | app/tests/cover_test.png | bin | 6801 -> 0 bytes | |||
| -rw-r--r-- | app/tests/gen_test_cover.py | 8 | ||||
| -rw-r--r-- | app/tests/test_audio.py | 18 | ||||
| -rw-r--r-- | app/tests/test_audiobook_cli.py | 5 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 74 | ||||
| -rw-r--r-- | app/tests/test_backends_common.py | 28 | ||||
| -rw-r--r-- | app/tests/test_backends_servers.py | 41 | ||||
| -rw-r--r-- | app/tests/test_converter.py | 6 | ||||
| -rw-r--r-- | app/tests/test_converter_progress.py | 18 | ||||
| -rw-r--r-- | app/tests/test_cover.py | 30 | ||||
| -rw-r--r-- | app/tests/test_extractors.py | 80 | ||||
| -rw-r--r-- | app/tests/test_runview.py | 11 | ||||
| -rw-r--r-- | app/tests/test_taskview.py | 6 | ||||
| -rw-r--r-- | app/tests/test_tts.py | 279 | ||||
| -rw-r--r-- | app/tests/test_viewkit.py | 9 |
15 files changed, 365 insertions, 248 deletions
diff --git a/app/tests/cover_test.png b/app/tests/cover_test.png Binary files differdeleted file mode 100644 index 0c252db..0000000 --- a/app/tests/cover_test.png +++ /dev/null diff --git a/app/tests/gen_test_cover.py b/app/tests/gen_test_cover.py deleted file mode 100644 index 292469a..0000000 --- a/app/tests/gen_test_cover.py +++ /dev/null @@ -1,8 +0,0 @@ -import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from converter.cover import generate_cover - -p = generate_cover('The Count of Monte Cristo', - Path(__file__).resolve().parent / 'cover_test.png') -print('written:', p) diff --git a/app/tests/test_audio.py b/app/tests/test_audio.py index 6c67294..75de97c 100644 --- a/app/tests/test_audio.py +++ b/app/tests/test_audio.py @@ -22,34 +22,34 @@ from converter.audio import ( build_m4b_chapters_command, cleanup_chunks, concat_audio_files, - speed_export_params, + atempo_filters, verify_output_duration, ) -class SpeedExportParamsTests(unittest.TestCase): +class AtempoFiltersTests(unittest.TestCase): def test_normal_speed_no_filter(self): - self.assertEqual(speed_export_params(1.0), []) + self.assertEqual(atempo_filters(1.0), "") def test_simple_speedup(self): - self.assertEqual(speed_export_params(1.5), ["-filter:a", "atempo=1.5"]) + self.assertEqual(atempo_filters(1.5), "atempo=1.5") def test_simple_slowdown(self): - self.assertEqual(speed_export_params(0.75), ["-filter:a", "atempo=0.75"]) + self.assertEqual(atempo_filters(0.75), "atempo=0.75") def test_chained_speedup_beyond_2x(self): - self.assertEqual(speed_export_params(3.0), ["-filter:a", "atempo=2.0,atempo=1.5"]) + self.assertEqual(atempo_filters(3.0), "atempo=2.0,atempo=1.5") def test_chained_slowdown_below_half(self): - self.assertEqual(speed_export_params(0.25), ["-filter:a", "atempo=0.5,atempo=0.5"]) + self.assertEqual(atempo_filters(0.25), "atempo=0.5,atempo=0.5") def test_zero_speed_rejected(self): with self.assertRaises(ValueError): - speed_export_params(0) + atempo_filters(0) def test_negative_speed_rejected(self): with self.assertRaises(ValueError): - speed_export_params(-1.5) + atempo_filters(-1.5) class CleanupChunksTests(unittest.TestCase): diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py index f1eb8e8..8076c8e 100644 --- a/app/tests/test_audiobook_cli.py +++ b/app/tests/test_audiobook_cli.py @@ -390,12 +390,16 @@ class AllModelsConvertTests(unittest.TestCase): ctor_kwargs.append(ckwargs) inst = MagicMock() made.append(inst) + result = True if make_run is not None: inst.run.side_effect = make_run(ckwargs) else: result = states[len(made) - 1] \ if len(made) <= len(states) else True inst.run.return_value = result + # Per-book outcomes the All-run loop counts on the CLI path + # (see audiobook._convert_each_model). + inst.results = {"book.txt": bool(result)} return inst fake_class = MagicMock(side_effect=make_instance) @@ -550,6 +554,7 @@ class AllModelsConvertTests(unittest.TestCase): def make_instance(*args, **ckwargs): inst = MagicMock() inst.run.return_value = True + inst.results = {"book.txt": True} return inst fake_class.side_effect = make_instance with patch.object(audiobook, "setup_logging"), \ diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 18c38c4..9392ba1 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -410,6 +410,56 @@ class LoadModelCatalogTests(unittest.TestCase): self.assertIn("mystery_tts", [entry["family"] for entry in catalog]) + def test_non_object_spec_json_is_skipped_not_fatal(self): + # Valid JSON that is not an object (the "crash on bad model_specs" + # fix class): skipped like an unparsable spec, never an + # AttributeError out of the wizard. + for payload in ('["a list"]', '"a string"', "42", "null"): + (self.checkout / "model_specs" / "broken.json") \ + .write_text(payload, encoding="utf-8") + catalog = make_server.catalog.load_model_catalog(self.checkout) + self.assertNotIn("broken", + [entry["family"] for entry in catalog]) + + def test_non_dict_package_entries_are_skipped(self): + (self.checkout / "model_specs" / "weird_pkg.json").write_text( + json.dumps({"family": "weird_pkg", "tasks": ["tts"], + "packages": ["not-a-dict", + {"id": "wp", "format": "gguf", + "files": ["m.gguf"], + "target_directory": "weird_pkg"}]}), + encoding="utf-8") + catalog = make_server.catalog.load_model_catalog(self.checkout) + entry = next(e for e in catalog if e["family"] == "weird_pkg") + self.assertEqual(entry["install_id"], "wp") + + def test_download_path_materializes_the_catalog_repair(self): + # Regression for the shadowed-sanitizer bug: the download path's + # staged specs copy must apply the catalog sanitizer's SECOND bug + # class (missing strip_prefix on $gguf-rooted single-GGUF packages + # nested under a repo directory — glm_tts/outetts), which the old + # dot-only models.py repair did not. + (self.checkout / "model_specs" / "glm_like.json").write_text( + json.dumps({"family": "glm_like", "tasks": ["tts"], + "sources": [{"format": "gguf", + "roots": {"tokenizer": "$gguf"}}], + "packages": [{"id": "glm_q8", "format": "gguf", + "files": ["Text to audio (TTS)/" + "GLM-TTS_Q8.gguf"]}, + {"id": "glm_other", "format": "safetensors", + "files": ["tokenizer_merges"], + "default": True}]}), + encoding="utf-8") + staging = make_server.models._prepare_specs_dir(self.checkout) + self.assertIsNotNone(staging, "nested-GGUF repair was not staged") + try: + repaired = json.loads( + (staging / "glm_like.json").read_text(encoding="utf-8")) + finally: + shutil.rmtree(staging, ignore_errors=True) + self.assertEqual(repaired["packages"][0]["strip_prefix"], + "Text to audio (TTS)") + def test_families_sorted_alphabetically_by_display_name(self): catalog = make_server.catalog.load_model_catalog(self.checkout) names = [entry["display_name"].lower() for entry in catalog] @@ -1095,48 +1145,53 @@ class InstallModelsTests(unittest.TestCase): class SanitizeModelSpecTests(unittest.TestCase): - """The dot strip_prefix repair and the --specs-dir staging copy.""" + """The strip_prefix repairs (both upstream bug classes) and staging.""" def test_dot_prefix_dropped_when_file_is_bare(self): spec = {"packages": [{"files": ["model.gguf"], "strip_prefix": "."}]} - self.assertTrue(make_server.models._sanitize_model_spec(spec)) + self.assertTrue(make_server.catalog.sanitize_model_spec(spec)) self.assertEqual(spec["packages"][0]["strip_prefix"], "") def test_slash_dot_prefix_normalized_like_dot(self): spec = {"packages": [{"files": ["model.gguf"], "strip_prefix": "./"}]} - self.assertTrue(make_server.models._sanitize_model_spec(spec)) + self.assertTrue(make_server.catalog.sanitize_model_spec(spec)) self.assertEqual(spec["packages"][0]["strip_prefix"], "") def test_dot_prefix_kept_when_files_carry_it(self): spec = {"packages": [{"files": ["./model.gguf"], "strip_prefix": "."}]} - self.assertFalse(make_server.models._sanitize_model_spec(spec)) + self.assertFalse(make_server.catalog.sanitize_model_spec(spec)) self.assertEqual(spec["packages"][0]["strip_prefix"], ".") def test_real_directory_prefix_untouched(self): spec = {"packages": [{"files": ["model.gguf"], "strip_prefix": "Kroko-ASR-GGUF"}]} - self.assertFalse(make_server.models._sanitize_model_spec(spec)) + self.assertFalse(make_server.catalog.sanitize_model_spec(spec)) self.assertEqual(spec["packages"][0]["strip_prefix"], "Kroko-ASR-GGUF") def test_valid_prefix_untouched(self): spec = {"packages": [{"files": ["Kroko-ASR-GGUF/model.gguf"], "strip_prefix": "Kroko-ASR-GGUF"}]} - self.assertFalse(make_server.models._sanitize_model_spec(spec)) + self.assertFalse(make_server.catalog.sanitize_model_spec(spec)) - def test_missing_or_empty_files_untouched(self): + def test_malformed_files_package_repaired_too(self): + # The catalog sanitizer also drops a dot prefix when the package's + # files list is missing, empty, or malformed — nothing can match a + # dot prefix, so the repair is safe there as well (a dot prefix + # with files that all carry it is kept, see above). spec = {"packages": [{"strip_prefix": "."}, {"files": [], "strip_prefix": "."}, {"files": "model.gguf", "strip_prefix": "."}]} - self.assertFalse(make_server.models._sanitize_model_spec(spec)) + self.assertTrue(make_server.catalog.sanitize_model_spec(spec)) + self.assertEqual({p["strip_prefix"] for p in spec["packages"]}, {""}) def test_only_broken_packages_repaired(self): spec = {"packages": [ {"files": ["model.gguf"], "strip_prefix": "."}, {"files": ["./model.gguf"], "strip_prefix": "."}, ]} - self.assertTrue(make_server.models._sanitize_model_spec(spec)) + self.assertTrue(make_server.catalog.sanitize_model_spec(spec)) self.assertEqual([p["strip_prefix"] for p in spec["packages"]], ["", "."]) @@ -3368,7 +3423,6 @@ class ExecuteLanesTests(unittest.TestCase): "include_clone": False, "wav_dir": None, "plan": None, - "sync_port": None, "delete_unused": False, "unused_entries": [], "model_entries": [], diff --git a/app/tests/test_backends_common.py b/app/tests/test_backends_common.py index 08ffc2c..68a5543 100644 --- a/app/tests/test_backends_common.py +++ b/app/tests/test_backends_common.py @@ -51,6 +51,34 @@ class RunConsoleSubprocessStreamingTests(unittest.TestCase): on_cancel=lambda: touched.append(True)) self.assertEqual(touched, [True]) + def test_carriage_return_progress_streams_incrementally(self): + # tqdm/HuggingFace-style \r-only progress: a readline-based reader + # blocked until the next \n, so the updates arrived in one burst + # (or the stall watchdog fired first). Each \r segment must be + # emitted as its own line. + lines = [] + rc = common.run_console_subprocess( + [sys.executable, "-c", + "import sys, time\n" + "for i in range(4):\n" + " sys.stdout.write(f'pct {i}\\r'); sys.stdout.flush()\n" + " time.sleep(0.2)\n" + "sys.stdout.write('done\\n'); sys.stdout.flush()\n"], + emit=lines.append, stall_timeout=1.0) + self.assertEqual(rc, 0) + self.assertEqual(lines, [f"pct {i}" for i in range(4)] + ["done"]) + + def test_url_with_port_preserves_userinfo_and_ipv6(self): + self.assertEqual( + common.url_with_port("http://user:pass@host:8000", 8080), + "http://user:pass@host:8080") + self.assertEqual( + common.url_with_port("http://[::1]:8000", 8080), + "http://[::1]:8080") + self.assertEqual( + common.url_with_port("http://host:8000/path", 8080), + "http://host:8080/path") + class RunConsoleSubprocessStallTests(unittest.TestCase): """The no-output watchdog: a silent child is killed and reported 124.""" diff --git a/app/tests/test_backends_servers.py b/app/tests/test_backends_servers.py index ab05eed..61897d4 100644 --- a/app/tests/test_backends_servers.py +++ b/app/tests/test_backends_servers.py @@ -65,10 +65,45 @@ class StartTests(unittest.TestCase): ok = servers.start(self.spec) self.assertTrue(ok) mk.assert_called_once() - # Pid file written. + # Pid file written (first field is the pid; the optional second + # field is the start-time ownership token, absent on this platform). self.assertEqual( - (self.dir / "test-server.pid").read_text(encoding="utf-8"), - "4242") + (self.dir / "test-server.pid").read_text(encoding="utf-8") + .split()[0], "4242") + + def test_pid_file_records_a_start_time_token_where_available(self): + proc = MagicMock() + proc.pid = 5150 + proc.poll.return_value = None + with patch.object(servers, "LOG_DIR", self.dir), \ + patch("subprocess.Popen", return_value=proc), \ + patch.object(servers, "_process_start_token", + return_value="12345"), \ + patch("backends.common.server_running", + side_effect=[False, True]), \ + patch("time.sleep"): + self.assertTrue(servers.start(self.spec)) + fields = (self.dir / "test-server.pid") \ + .read_text(encoding="utf-8").split() + self.assertEqual(fields, ["5150", "12345"]) + + def test_recycled_pid_with_mismatched_token_is_not_ours(self): + # The pid is alive but its start time differs from the recorded + # token: an unrelated process now owns this pid, so manages/alive + # must report not-ours (and never kill it). + pid_file = self.dir / "test-server.pid" + pid_file.write_text("4242 111\n", encoding="utf-8") + with patch.object(servers, "LOG_DIR", self.dir), \ + patch.object(servers, "_pid_alive", return_value=True), \ + patch.object(servers, "_process_start_token", + return_value="999"): + self.assertFalse(servers.alive("test")) + self.assertFalse(servers.manages([self.spec])) + with patch.object(servers, "LOG_DIR", self.dir), \ + patch.object(servers, "_pid_alive", return_value=True), \ + patch.object(servers, "_process_start_token", + return_value="111"): + self.assertTrue(servers.alive("test")) def test_returns_false_when_process_exits_early(self): proc = MagicMock() diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py index 41b9cad..0b0eb0d 100644 --- a/app/tests/test_converter.py +++ b/app/tests/test_converter.py @@ -38,6 +38,12 @@ class SanitizeFilenameTests(unittest.TestCase): def test_empty_falls_back(self): self.assertEqual(AudiobookConverter._sanitize_filename("///"), "chapter") + def test_windows_reserved_device_names_are_suffixed(self): + for name in ("CON", "nul", "COM1", "lpt2"): + result = AudiobookConverter._sanitize_filename(name) + self.assertNotEqual(result.upper(), name.upper()) + self.assertTrue(result.startswith("chapter_"), result) + class ConfigurationValidationTests(unittest.TestCase): def test_invalid_voice_mode_rejected(self): diff --git a/app/tests/test_converter_progress.py b/app/tests/test_converter_progress.py index d77e9e0..0a44725 100644 --- a/app/tests/test_converter_progress.py +++ b/app/tests/test_converter_progress.py @@ -225,6 +225,24 @@ class CancelTests(unittest.TestCase): converter = self.fixture.build(cancel=threading.Event()) converter._check_cancelled() # no raise + def test_cancel_event_reaches_the_client_before_connect(self): + # The cancel event must be wired into the TTS client at + # construction time — connect-time work (health check, model + # listing, voice validation) is otherwise un-cancellable. + cancel = threading.Event() + cancel.set() + client = MagicMock() + client.cancel = None + with patch.object(converter_mod, "QwenTTSClient", + return_value=client) as mock_qwen: + AudiobookConverter(voice_mode=VOICE_MODE_CUSTOM, + backend=BACKEND_QWEN, voice="Vivian", + cancel=cancel) + self.assertIs(mock_qwen.call_args.kwargs["cancel"], cancel) + with self.assertRaises(ConversionCancelled): + converter_mod.AudiobookConverter._check_cancelled( + MagicMock(tts=None, _cancel=cancel)) + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_cover.py b/app/tests/test_cover.py index f19db5a..ca8b9a2 100644 --- a/app/tests/test_cover.py +++ b/app/tests/test_cover.py @@ -9,6 +9,7 @@ from pathlib import Path from converter.cover import ( _random_light_color, + _render_line, _text_width, _wrap_title, generate_cover, @@ -117,11 +118,13 @@ class GenerateCoverTests(unittest.TestCase): _, _, rows = _decode_png(data) self.assertEqual(_black_pixels(rows), 0) - def test_unrenderable_title_degrades_to_gradient(self): - # CJK glyphs are not in the bitmap font; no crash, no text pixels + def test_unrenderable_title_renders_placeholder_boxes(self): + # CJK glyphs are not in the bitmap font: they used to vanish + # (blank cover); now each renders as a black-outlined placeholder + # box so the title is visibly present. _, data = self._write("书名") _, _, rows = _decode_png(data) - self.assertEqual(_black_pixels(rows), 0) + self.assertGreater(_black_pixels(rows), 0) def test_write_failure_returns_none(self): result = generate_cover("Hello", Path("/nonexistent_dir/cover.png")) @@ -187,3 +190,24 @@ class DropShadowTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class NonAsciiTitleTests(unittest.TestCase): + """Characters outside the bitmap font render as boxes, not blanks.""" + + def test_unknown_characters_are_rendered_as_placeholder_boxes(self): + # Regression: unknown chars used to be skipped in rendering while + # still counted for width, so e.g. Japanese titles produced a + # blank cover with no warning. + with tempfile.TemporaryDirectory() as tmp: + path = generate_cover("日本語", Path(tmp) / "c.png", + seed=1) + self.assertIsNotNone(path) + raw = path.read_bytes() + self.assertTrue(raw.startswith(b"\x89PNG")) + + def test_placeholder_draws_pixels_for_every_unknown_char(self): + pixels = [[(255, 255, 255)] * 60 for _ in range(60)] + _render_line(pixels, "éé", 2, 2, color=(0, 0, 0)) + dark = sum(1 for row in pixels for pixel in row if pixel == (0, 0, 0)) + self.assertGreater(dark, 2 * 5 * 7 - 6) # both boxes' outlines drawn diff --git a/app/tests/test_extractors.py b/app/tests/test_extractors.py index 64666ba..619fe5a 100644 --- a/app/tests/test_extractors.py +++ b/app/tests/test_extractors.py @@ -53,6 +53,86 @@ class TxtExtractionTests(unittest.TestCase): with self.assertRaises(ValueError): extract_text(path) + def test_utf16_without_bom_detected(self): + self.assertEqual(self._extract("chapter one".encode("utf-16-le")), + "chapter one") + + def test_lone_nul_does_not_flip_to_utf16(self): + # A single stray NUL byte in an otherwise-ASCII UTF-8 file must not + # switch the whole book to a UTF-16 decode (mojibake): the text + # comes back readable instead. + self.assertEqual(self._extract(b"hello world\x00rest"), + "hello world\x00rest") + + def test_standalone_page_numbers_removed_but_years_kept(self): + from converter.extractors import clean_text + + cleaned = clean_text("Chapter 1\n\n42\n\nIt was 1984.") + self.assertNotIn("42", cleaned) + self.assertIn("1984", cleaned) + kept = clean_text("It was the year\n\n1984\n\nwhen it began.") + self.assertIn("1984", kept) + + +class EpubZipfileFallbackTests(unittest.TestCase): + """The no-ebooklib EPUB fallback: spine order, no TOC narration.""" + + @staticmethod + def _write_epub(path: Path): + import zipfile + + container = ("<?xml version=\"1.0\"?>" + "<container><rootfiles>" + "<rootfile full-path=\"OEBPS/content.opf\"/>" + "</rootfiles></container>") + opf = ("<?xml version=\"1.0\"?>" + "<package xmlns=\"http://www.idpf.org/2007/opf\">" + "<manifest>" + "<item id=\"nav\" href=\"nav.xhtml\" properties=\"nav\"/>" + "<item id=\"c2\" href=\"text/chapterB.xhtml\"/>" + "<item id=\"c1\" href=\"text/chapterA.xhtml\"/>" + "</manifest>" + "<spine><itemref idref=\"nav\"/>" + "<itemref idref=\"c2\"/><itemref idref=\"c1\"/></spine>" + "</package>") + with zipfile.ZipFile(path, "w") as zf: + zf.writestr("mimetype", "application/epub+zip") + zf.writestr("META-INF/container.xml", container) + zf.writestr("OEBPS/content.opf", opf) + zf.writestr("OEBPS/nav.xhtml", + "<html><body><p>Contents</p></body></html>") + zf.writestr("OEBPS/text/chapterA.xhtml", + "<html><body><p>Alpha text.</p></body></html>") + zf.writestr("OEBPS/text/chapterB.xhtml", + "<html><body><p>Beta text.</p></body></html>") + + def test_spine_order_and_no_nav(self): + from converter.extractors import _read_epub_zipfile + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "book.epub" + self._write_epub(path) + items = _read_epub_zipfile(path) + + titles = [title for title, _ in items] + self.assertNotIn("nav", titles) + # Spine order (B before A) beats filename sort (A before B). + self.assertEqual(titles, ["chapterB", "chapterA"]) + + def test_unparsable_opf_falls_back_to_filename_order(self): + import zipfile + + from converter.extractors import _read_epub_zipfile + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "book.epub" + with zipfile.ZipFile(path, "w") as zf: + zf.writestr("a.xhtml", "<html><body><p>A</p></body></html>") + zf.writestr("b.xhtml", "<html><body><p>B</p></body></html>") + items = _read_epub_zipfile(path) + + self.assertEqual([title for title, _ in items], ["a", "b"]) + def _build_test_epub(path: Path, chapters=(("One", "First chapter text."), ("Two", "Second chapter text."))) -> None: diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py index 4e3f9fe..6add83f 100644 --- a/app/tests/test_runview.py +++ b/app/tests/test_runview.py @@ -277,6 +277,17 @@ class LogAppenderTests(_FakeTui, unittest.TestCase): self.assertTrue(lines[0].endswith(" - one")) self.assertTrue(lines[2].endswith(" - three")) + def test_carriage_return_progress_splits_into_lines(self): + # tqdm/git-style \r-only progress: each segment becomes its own + # log line instead of one ever-growing buffered line. + path, appender = self._appender() + appender.write("pct 0\rpct 1\rpct 2\r") + appender.flush() + with open(path, encoding="utf-8") as handle: + lines = handle.read().splitlines() + self.assertEqual(len(lines), 3) + self.assertTrue(lines[2].endswith(" - pct 2")) + def test_blank_lines_and_empty_path_are_skipped(self): path, appender = self._appender() appender.write("\n\n") diff --git a/app/tests/test_taskview.py b/app/tests/test_taskview.py index 78cc2f1..2b3e542 100644 --- a/app/tests/test_taskview.py +++ b/app/tests/test_taskview.py @@ -277,7 +277,9 @@ class StateTransitionTests(_FakeTui, unittest.TestCase): view.handle_event({"kind": "step_cancelled", "index": 0}) view.handle_event({"kind": "finish", "phase": "cancelled", "rc": 1}) self.assertEqual(view.phase, "cancelled") - self.assertEqual(view._result_rc(), 1) + # 130: the CLI's user-cancel code, so callers can flash + # "cancelled" instead of "failed" (see hub._download_models_action). + self.assertEqual(view._result_rc(), 130) # The step interrupted by cancel is marked cancelled, not failed. self.assertEqual(view._step_mark(0), ("[x]", "warn")) self.assertEqual(view._step_mark(1), ("[ ]", "dim")) @@ -677,7 +679,7 @@ class LanesViewTests(_FakeTui, unittest.TestCase): view._drain() self.assertEqual(view.phase, "cancelled") self.assertTrue(view.cancelled) - self.assertEqual(view._result_rc(), 1) + self.assertEqual(view._result_rc(), 130) def test_split_render_draws_both_lane_titles(self): view, screen = self.make_view(self._two_lanes()) diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index 45e3812..39b407d 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -41,14 +41,15 @@ from converter.clients import ( VOICE_MODES, AudioCppTTSClient, FasterTTSClient, + QWEN3_TTS_SPEAKERS, QwenTTSClient, audiocpp_entry_voice_capability, audiocpp_family_narrates, audiocpp_family_voice_policy, audiocpp_request_error, audiocpp_script_input, + audiocpp_voice_for_run, allocation_log_note, - build_trimmed_voice_reference, nvidia_device_memory_report, normalize_language, transcribe_reference_audio_detailed, @@ -2310,7 +2311,7 @@ class BackendWiringTests(unittest.TestCase): backend=BACKEND_FASTER, voice="narrator") mock_faster.assert_called_once_with(chunks_dir=converter_mod.CHUNKS_FOLDER, voice="narrator", api_url=None, - quiet=False) + quiet=False, cancel=None) mock_qwen.assert_not_called() mock_audiocpp.assert_not_called() @@ -2327,7 +2328,7 @@ class BackendWiringTests(unittest.TestCase): instructions=None, request_options={}, api_url=None, quiet=False, - unload_models=None) + unload_models=None, cancel=None) mock_faster.assert_not_called() mock_qwen.assert_not_called() @@ -2341,7 +2342,7 @@ class BackendWiringTests(unittest.TestCase): instructions=None, request_options={}, api_url=None, quiet=False, - unload_models=None) + unload_models=None, cancel=None) def test_audiocpp_backend_model_id_is_wired_through(self): with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: @@ -2353,7 +2354,7 @@ class BackendWiringTests(unittest.TestCase): voice="narrator", language=config.LANGUAGE, model_id="higgs", instructions=None, request_options={}, api_url=None, quiet=False, - unload_models=None) + unload_models=None, cancel=None) def test_audiocpp_backend_instructions_and_options_are_wired_through(self): with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: @@ -2368,7 +2369,8 @@ class BackendWiringTests(unittest.TestCase): model_id=None, instructions="A warm adult narrator", request_options={"emotion": "neutral", "speed": "1.1"}, - api_url=None, quiet=False, unload_models=None) + api_url=None, quiet=False, unload_models=None, + cancel=None) def test_qwen_backend_uses_qwen_client(self): with patch("converter.converter.FasterTTSClient") as mock_faster, \ @@ -2420,7 +2422,7 @@ class BackendWiringTests(unittest.TestCase): voice="narrator", language=config.LANGUAGE, model_id=None, instructions=None, request_options={}, api_url="http://10.0.0.5:8080", quiet=False, - unload_models=None) + unload_models=None, cancel=None) with patch("converter.converter.FasterTTSClient") as mock_faster: AudiobookConverter(voice_mode=VOICE_MODE_CLONE, backend=BACKEND_FASTER, voice="narrator", @@ -2428,7 +2430,7 @@ class BackendWiringTests(unittest.TestCase): mock_faster.assert_called_once_with(chunks_dir=converter_mod.CHUNKS_FOLDER, voice="narrator", api_url="http://10.0.0.5:8000", - quiet=False) + quiet=False, cancel=None) with patch("converter.converter.QwenTTSClient") as mock_qwen: AudiobookConverter(voice_mode=VOICE_MODE_CUSTOM, backend=BACKEND_QWEN, voice="Vivian", @@ -2438,7 +2440,8 @@ class BackendWiringTests(unittest.TestCase): voice_mode=VOICE_MODE_CUSTOM, voice_clone_ref_audio=None, voice_clone_ref_text=None, skip_transcription=False, language=config.LANGUAGE, instructions=None, - api_url="http://10.0.0.5:7860", quiet=False, voice="Vivian") + api_url="http://10.0.0.5:7860", quiet=False, voice="Vivian", + cancel=None) def test_audiocpp_clone_mode_does_not_require_reference(self): # Cloning is server-side for the audiocpp backend, so the @@ -2548,77 +2551,6 @@ if __name__ == "__main__": unittest.main() -class TrimmedVoiceReferenceTests(unittest.TestCase): - """build_trimmed_voice_reference: a bounded inline cloning reference.""" - - def setUp(self): - self._tmp = tempfile.TemporaryDirectory() - self.dir = Path(self._tmp.name) - - def tearDown(self): - self._tmp.cleanup() - - @staticmethod - def _wav_bytes(rate, channels, sampwidth, frames, fill): - if sampwidth == 1: - payload = bytes(fill & 0xFF for _ in range(frames * channels)) - else: - payload = fill.to_bytes(sampwidth, "little", signed=True) \ - * frames * channels - return (b"RIFF" + struct.pack("<I", 36 + len(payload)) + b"WAVEfmt " - + struct.pack("<IHHIIHH", 16, 1, channels, rate, - rate * channels * sampwidth, - channels * sampwidth, sampwidth * 8) - + b"data" + struct.pack("<I", len(payload)) + payload) - - def _write(self, name, rate, channels, sampwidth, seconds, fill=256): - path = self.dir / name - path.write_bytes(self._wav_bytes(rate, channels, sampwidth, - int(rate * seconds), fill)) - return path - - def test_sixty_second_stereo_reference_is_cut_to_thirty_seconds(self): - path = self._write("ref.wav", 44100, 2, 2, 60) - b64, seconds, name = build_trimmed_voice_reference(path) - self.assertEqual(name, "ref.wav") - self.assertLessEqual(seconds, 30.0 + 1e-6) - decoded = base64.b64decode(b64) - with wave.open(io.BytesIO(decoded)) as handle: - self.assertEqual(handle.getframerate(), 44100) - self.assertEqual(handle.getnchannels(), 1) - self.assertEqual(handle.getsampwidth(), 2) - self.assertLessEqual(handle.getnframes() / handle.getframerate(), - 30.0 + 1e-6) - self.assertLessEqual(len(b64), (5 * 1024 * 1024 + 2) // 3 * 4) - - def test_short_reference_is_sent_whole(self): - path = self._write("short.wav", 16000, 1, 2, 8) - _b64, seconds, _name = build_trimmed_voice_reference(path) - self.assertAlmostEqual(seconds, 8.0, places=2) - - def test_float_and_garbage_files_yield_none(self): - self.assertIsNone(build_trimmed_voice_reference(None)) - garbage = self.dir / "garbage.wav" - garbage.write_bytes(b"ID3 not a wav") - self.assertIsNone(build_trimmed_voice_reference(garbage)) - missing = self.dir / "missing.wav" - self.assertIsNone(build_trimmed_voice_reference(missing)) - - def test_sample_widths_beyond_s16_are_downconverted(self): - for name, width, seconds in (("s24.wav", 3, 40), ("s32.wav", 4, 40), - ("u8.wav", 1, 12)): - path = self._write(name, 44100, 1, width, seconds) - result = build_trimmed_voice_reference(path) - self.assertIsNotNone(result, name) - _b64, used, _name = result - self.assertLessEqual(used, min(seconds, 30.0) + 1e-6) - - def test_base64_payload_stays_under_the_server_limit(self): - path = self._write("long.wav", 48000, 2, 2, 120) - b64, _seconds, _name = build_trimmed_voice_reference(path) - self.assertLessEqual(len(base64.b64decode(b64)), 5 * 1024 * 1024) - - class AllocationLogNoteTests(unittest.TestCase): """allocation_log_note: the server log's exact allocation numbers.""" @@ -2695,136 +2627,57 @@ class DeviceMemoryWarningTests(unittest.TestCase): url="http://10.0.0.5:8080"), "") -class TrimmedReferenceRetryTests(unittest.TestCase): - """One trimmed-reference retry after an allocation-failure 500.""" - - def setUp(self): - self._tmp = tempfile.TemporaryDirectory() - self.dir = Path(self._tmp.name) - frames = 44100 * 60 - payload = (3000).to_bytes(2, "little", signed=True) * frames * 2 - wav = (b"RIFF" + struct.pack("<I", 36 + len(payload)) + b"WAVEfmt " - + struct.pack("<IHHIIHH", 16, 1, 2, 44100, 44100 * 2, 2, 16) - + b"data" + struct.pack("<I", len(payload)) + payload) - (self.dir / "obama.wav").write_bytes(wav) - - def tearDown(self): - self._tmp.cleanup() - - _WAV = b"RIFF\x04\x00\x00\x00WAVE" - - def _client(self, responses, captured, voice="obama", - family="moss_tts_local"): - client = AudioCppTTSClient.__new__(AudioCppTTSClient) - client.chunks_dir = self.dir - client.api_url = "http://127.0.0.1:8080" - client.model_id = "MOSS-TTS-Local-v1.5-GGUF" - client.preset_mode = True - client.voice = voice - client.language = "Auto" - client._seed = 42 - client.family = family - client.task = "tts" - client.profile = audiocpp_client.AUDIOCPP_DEFAULT_FAMILY_PROFILE - client.instructions = "" - client.request_options = {} - client.design_mode = False - client.instruction_voice = False - client.plain_mode = False - client._reference_trim_attempted = False - client._voice_ref_b64 = None - client._voice_ref_reference_text = None - client._voice_wav_path = lambda: self.dir / "obama.wav" - - calls = {"n": 0} - - def urlopen(request, **_kwargs): - index = calls["n"] - calls["n"] += 1 - if captured is not None: - captured.append(json.loads(request.data.decode("utf-8"))) - outcome = responses[index] if index < len(responses) \ - else responses[-1] - if isinstance(outcome, urllib.error.HTTPError): - raise outcome - response = MagicMock() - response.__enter__.return_value = response - response.read.return_value = outcome - return response - - patcher = patch("converter.clients.faster.urllib.request.urlopen", - side_effect=urlopen) - patcher.start() - self.addCleanup(patcher.stop) - return client - - @staticmethod - def _alloc_http_error(): - return urllib.error.HTTPError( - "http://127.0.0.1:8080", 500, "Internal Server Error", {}, - io.BytesIO(json.dumps({"error": {"message": - "failed to allocate MOSS codec encoder forward graph"}} - ).encode("utf-8"))) - - def test_allocation_failure_retries_once_with_trimmed_reference(self): - captured = [] - client = self._client([self._alloc_http_error(), self._WAV, self._WAV], - captured) - client._request_wav("Hello.") - client._request_wav("More text.") - self.assertEqual(len(captured), 3) - self.assertIn("voice", captured[0]) - self.assertNotIn("voice_ref", captured[0]) - self.assertIn("voice_ref", captured[1]) - self.assertEqual(captured[1]["voice_ref"]["type"], "base64") - self.assertLessEqual( - len(base64.b64decode(captured[1]["voice_ref"]["data"])), - 5 * 1024 * 1024) - self.assertNotIn("voice", captured[1]) - # The trimmed reference sticks for the rest of the run. - self.assertEqual(captured[2]["voice_ref"]["type"], "base64") - self.assertTrue(client._reference_trim_attempted) - - def test_no_local_wav_falls_through_to_the_error(self): - client = self._client([self._alloc_http_error()], None) - client._voice_wav_path = lambda: None - with self.assertRaises(NonRetryableTTSError): - client._request_wav("Hello.") - self.assertTrue(client._reference_trim_attempted) - - def test_unrelated_errors_are_not_retried(self): - captured = [] - boring = urllib.error.HTTPError( - "http://127.0.0.1:8080", 500, "Internal Server Error", {}, - io.BytesIO(json.dumps({"error": {"message": "model busy"}} - ).encode("utf-8"))) - client = self._client([boring], captured) - with self.assertRaises(RuntimeError): - client._request_wav("Hello.") - self.assertEqual(len(captured), 1) - self.assertIn("voice", captured[0]) - self.assertFalse(client._reference_trim_attempted) - - def test_transcript_is_carried_when_the_spec_accepts_it(self): - captured = [] - client = self._client([self._alloc_http_error(), self._WAV], captured) - with patch.object(audiocpp_client, "_family_spec", - return_value={"options": {"request": [ - {"name": "reference_text"}]}}), \ - patch.object(AudioCppTTSClient, "_voice_transcript", - return_value="The spoken reference text."): - client._request_wav("Hello.") - self.assertEqual(captured[1]["options"]["reference_text"], - "The spoken reference text.") - - def test_transcript_is_omitted_for_option_validating_families(self): - captured = [] - client = self._client([self._alloc_http_error(), self._WAV], captured, - family="dramabox") - with patch.object(audiocpp_client, "_family_spec", - return_value={"options": {"request": [ - {"name": "seed"}]}}), \ - patch.object(AudioCppTTSClient, "_voice_transcript", - return_value="The spoken reference text."): - client._request_wav("Hello.") - self.assertNotIn("reference_text", captured[1].get("options", {})) +class ErrorBodyClassificationTests(unittest.TestCase): + """Deterministic-error detection runs on the FULL HTTP error body.""" + + def test_fragment_beyond_200_chars_is_still_classified(self): + # The deterministic fragment sits deep in a long server message; + # truncating before matching would misclassify it as retryable. + filler = "x" * 300 + body = json.dumps({"error": {"message": + f"{filler} model contract spec not found for family"}}) + error = audiocpp_request_error(500, body) + self.assertIsInstance(error, NonRetryableTTSError) + self.assertIn("not retryable", str(error)) + + def test_quoted_message_is_truncated_for_display(self): + body = json.dumps({"error": {"message": "y" * 1000}}) + error = audiocpp_request_error(500, body) + self.assertNotIn("y" * 300, str(error)) + self.assertLess(len(str(error)), 1000) + + def test_http_error_body_helper_reads_whole_body(self): + body = b"z" * 500 + exc = urllib.error.HTTPError("http://x", 500, "ISE", {}, + io.BytesIO(body)) + self.assertEqual(audiocpp_client._http_error_body(exc), body.decode()) + + +class VoiceForRunTests(unittest.TestCase): + """audiocpp_voice_for_run: the "All"-run per-model voice resolution.""" + + def test_design_takes_no_voice(self): + self.assertIsNone(audiocpp_client.audiocpp_voice_for_run( + "voxcpm2", "vdes", "Vox", "narrator", ["narrator"])) + + def test_speaker_entry_takes_the_speaker_pick_or_first_speaker(self): + model = "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF" + resolve = audiocpp_client.audiocpp_voice_for_run + self.assertEqual(resolve("qwen3_tts", "tts", model, "Vivian", []), + "Vivian") + self.assertEqual(resolve("qwen3_tts", "tts", model, "narrator", []), + QWEN3_TTS_SPEAKERS[0]) + + def test_clone_entry_takes_the_preset_pick_or_first_server_voice(self): + self.assertEqual(audiocpp_client.audiocpp_voice_for_run( + "higgs_audio_tts", "tts", "higgs", "narrator", + ["narrator", "other"]), "narrator") + self.assertEqual(audiocpp_client.audiocpp_voice_for_run( + "higgs_audio_tts", "tts", "higgs", "unknown", ["first", "x"]), + "first") + self.assertIsNone(audiocpp_client.audiocpp_voice_for_run( + "higgs_audio_tts", "tts", "higgs", "unknown", [])) + + def test_pure_tts_family_takes_no_voice(self): + self.assertIsNone(audiocpp_client.audiocpp_voice_for_run( + "supertonic", "tts", "supertonic", "narrator", ["narrator"])) diff --git a/app/tests/test_viewkit.py b/app/tests/test_viewkit.py index efebae6..842ded8 100644 --- a/app/tests/test_viewkit.py +++ b/app/tests/test_viewkit.py @@ -21,9 +21,15 @@ class _SyncThread: def __init__(self, target, daemon=None): self._target = target + self._alive = False def start(self): + self._alive = True self._target() + self._alive = False + + def is_alive(self): + return self._alive def join(self, timeout=None): pass @@ -121,6 +127,9 @@ class DefaultPromptCancelTests(unittest.TestCase): joined = threading.Event() class Worker: + def is_alive(self): + return True + def join(self, timeout=None): joined.set() |
