from __future__ import annotations import os import sys import tempfile import unittest from copy import deepcopy from pathlib import Path from unittest.mock import patch SRC_DIR = Path(__file__).resolve().parents[1] / "src" if str(SRC_DIR) not in sys.path: sys.path.insert(0, str(SRC_DIR)) from saiki.anki_tsv import parse_anki_headers, parse_anki_tsv from saiki.audio import build_playlist, resolve_media_paths from saiki.cli import build_parser, main from saiki.config import Config, DEFAULT_CONFIG, deep_merge from saiki.importer import ( PreparedTtsBackend, import_sentences, list_tts_voices, parse_tags, prepare_tts_backend, read_sentences, synthesize_tts_sample, supported_tts_backends, ) from saiki.spanish import ( BUILTIN_LEMMA_CORRECTIONS, check_card_for_issues, clean_anki_field_text, extract_detailed_counts, lint_anki_cards, load_lemma_corrections, safe_spanish_lemma, safe_spanish_lemma_info, spanish_content_filter, spanish_function_word_filter, write_debug_tsv, write_suspicious_tokens, ) from saiki.text import extract_first_visible_line, extract_visible_text from saiki.words import ( build_query_from_decks, compare_word_files, compare_word_lists, normalize_word_for_comparison, read_word_file, ) from saiki.youtube import TranscriptLine, extract_video_id, sentence_vocab, write_sentence_export class ConfigTests(unittest.TestCase): def test_deep_merge_preserves_nested_defaults(self): merged = deep_merge(DEFAULT_CONFIG, {"languages": {"es": {"decks": ["Spanish"]}}}) self.assertEqual(merged["languages"]["es"]["decks"], ["Spanish"]) self.assertEqual(merged["languages"]["es"]["transcript_code"], "es") self.assertEqual(merged["tts_model_dir"], "~/.local/share/saiki/models") self.assertIn("jp", merged["languages"]) class TextTests(unittest.TestCase): def test_visible_text_helpers_strip_html(self): raw = "
Hola mundo

segunda linea

" self.assertEqual(extract_first_visible_line(raw), "Hola\xa0mundo") self.assertEqual(extract_visible_text(raw), "Hola\xa0mundo\nsegunda linea") class AudioTests(unittest.TestCase): def test_resolve_media_paths_rejects_unsafe_names(self): self.assertIsNone(resolve_media_paths("/media", "/out", "../secret.mp3")) self.assertIsNone(resolve_media_paths("/media", "/out", "/tmp/secret.mp3")) self.assertEqual( resolve_media_paths("/media", "/out", "nested/audio.mp3"), ("/media/nested/audio.mp3", "/out/nested/audio.mp3"), ) def test_build_playlist_includes_audio_files(self): with tempfile.TemporaryDirectory() as tmp: os.makedirs(os.path.join(tmp, "nested")) for rel in ["b.mp3", "nested/a.ogg", "note.txt", "spanish_concat.mp3"]: with open(os.path.join(tmp, rel), "w", encoding="utf-8") as fh: fh.write("x") playlist = build_playlist(tmp, "spanish") with open(playlist, "r", encoding="utf-8") as fh: self.assertEqual(fh.read().splitlines(), ["b.mp3", "nested/a.ogg"]) class WordsTests(unittest.TestCase): def test_build_query_from_decks(self): self.assertEqual(build_query_from_decks(["A", "B"]), 'deck:"A" OR deck:"B"') def test_compare_word_files(self): with tempfile.TemporaryDirectory() as tmp: source = os.path.join(tmp, "source.txt") known = os.path.join(tmp, "known.txt") with open(source, "w", encoding="utf-8") as fh: fh.write("comer 3\nhablar 2\n") with open(known, "w", encoding="utf-8") as fh: fh.write("Comer 10\n") self.assertEqual(read_word_file(known), {"comer"}) self.assertEqual(compare_word_files(source, known), ["hablar 2"]) class AnkiTsvTests(unittest.TestCase): def test_parse_headers_separator_tab(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "test.txt") with open(path, "w", encoding="utf-8") as f: f.write("#separator:tab\n#html:true\n#tags column:3\ncol1\tcol2\tcol3\n") headers = parse_anki_headers(path) self.assertEqual(headers["separator"], "\t") self.assertTrue(headers["html"]) self.assertEqual(headers["tags_column"], "3") def test_parse_headers_default_separator(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "test.txt") with open(path, "w", encoding="utf-8") as f: f.write("# some comment\ncol1\tcol2\n") headers = parse_anki_headers(path) self.assertNotIn("separator", headers) def test_parse_tsv_skips_header_lines(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "test.txt") with open(path, "w", encoding="utf-8") as f: f.write("#separator:tab\n#html:true\nA\tB\tC\nD\tE\tF\n") records = parse_anki_tsv(path, field_index=2) self.assertEqual(len(records), 2) self.assertEqual(records[0]["text"], "B") self.assertEqual(records[1]["text"], "E") def test_parse_tsv_field_index_1_based(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "test.txt") with open(path, "w", encoding="utf-8") as f: f.write("alpha\tbeta\tgamma\n") records = parse_anki_tsv(path, field_index=1) self.assertEqual(records[0]["text"], "alpha") records = parse_anki_tsv(path, field_index=3) self.assertEqual(records[0]["text"], "gamma") def test_parse_tsv_default_field_2_ignores_sound_field(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "test.txt") with open(path, "w", encoding="utf-8") as f: f.write("#separator:tab\n#tags column:3\n") f.write("[sound:es_001.mp3]\tYo como manzanas.\tanki-tag\n") records = parse_anki_tsv(path) self.assertEqual(records[0]["text"], "Yo como manzanas.") self.assertNotIn("tags", records[0]) def test_parse_tsv_rejects_zero_field_index(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "test.txt") with open(path, "w", encoding="utf-8") as f: f.write("alpha\tbeta\tgamma\n") with self.assertRaises(ValueError): parse_anki_tsv(path, field_index=0) def test_parse_tsv_rejects_missing_field(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "test.txt") with open(path, "w", encoding="utf-8") as f: f.write("alpha\tbeta\n") with self.assertRaises(ValueError): parse_anki_tsv(path, field_index=3) def test_parse_tsv_line_numbers(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "test.txt") with open(path, "w", encoding="utf-8") as f: f.write("#header\nuno\tdos\nthree\tfour\n") records = parse_anki_tsv(path, field_index=2) self.assertEqual(records[0]["line_number"], 2) self.assertEqual(records[1]["line_number"], 3) def test_parse_tsv_include_tags(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "test.txt") with open(path, "w", encoding="utf-8") as f: f.write("#tags column:3\nx\ty\ttag_a\n") records = parse_anki_tsv(path, field_index=2, include_tags=True) self.assertEqual(records[0]["tags"], "tag_a") def test_parse_tsv_without_tags(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "test.txt") with open(path, "w", encoding="utf-8") as f: f.write("#tags column:3\nx\ty\ttag_a\n") records = parse_anki_tsv(path, field_index=2, include_tags=False) self.assertNotIn("tags", records[0]) class CliValidationTests(unittest.TestCase): """Test CLI-level validation against the real parser/main paths.""" def test_words_accepts_lang_option_and_output_alias(self): with patch("saiki.cli.importlib.metadata.version", return_value="0"): parser = build_parser() args = parser.parse_args([ "words", "--lang", "es", "--input", "deck.tsv", "--output", "out.txt" ]) self.assertEqual(args.lang_option, "es") self.assertEqual(args.out, "out.txt") def test_words_input_rejects_zero_field(self): from io import StringIO with patch("saiki.cli.importlib.metadata.version", return_value="0"): with patch("sys.stderr", new_callable=StringIO) as stderr: code = main(["words", "--lang", "es", "--input", "deck.tsv", "--field", "0"]) self.assertEqual(code, 1) self.assertIn("1-based", stderr.getvalue()) def test_words_input_is_spanish_only(self): from io import StringIO with patch("saiki.cli.importlib.metadata.version", return_value="0"): with patch("sys.stderr", new_callable=StringIO) as stderr: code = main(["words", "jp", "--input", "deck.tsv", "--field", "2"]) self.assertEqual(code, 1) self.assertIn("Spanish-only", stderr.getvalue()) class SpanishTextCleaningTests(unittest.TestCase): def test_remove_sound_marker(self): self.assertEqual( clean_anki_field_text("[sound:es_001.mp3] Hola mundo"), "Hola mundo", ) def test_strip_html_and_unescape(self): result = clean_anki_field_text("Para el lunes...") self.assertEqual(result, "Para el lunes...") def test_english_after_brbr_removed(self): result = clean_anki_field_text("hasta luego

(see you later)") self.assertEqual(result, "hasta luego") def test_english_gloss_after_brbr_removed(self): result = clean_anki_field_text("Te quiero.

I love you / I want you") self.assertEqual(result, "Te quiero.") def test_spanish_after_brbr_preserved(self): result = clean_anki_field_text("Hola

Otra línea") self.assertEqual(result, "Hola Otra línea") def test_normalize_whitespace(self): result = clean_anki_field_text(" Hola mundo ") self.assertEqual(result, "Hola mundo") def test_br_variants(self): result = clean_anki_field_text("Line1
Line2
Line3") self.assertEqual(result, "Line1 Line2 Line3") def test_preserves_accents(self): result = clean_anki_field_text("camión corazón") self.assertEqual(result, "camión corazón") def test_cleaned_text_empty_input(self): self.assertEqual(clean_anki_field_text(""), "") self.assertEqual(clean_anki_field_text(None), "") class MockToken: """Minimal mock of a spaCy token for lemmatisation tests.""" def __init__(self, text="", lemma="", pos="", **kwargs): self.text = text self.lemma_ = lemma self.pos_ = pos self.is_stop = kwargs.get("is_stop", False) self.is_alpha = kwargs.get("is_alpha", True) self.is_punct = kwargs.get("is_punct", False) self.is_space = kwargs.get("is_space", False) self.is_digit = kwargs.get("is_digit", False) self.is_currency = kwargs.get("is_currency", False) self.like_url = kwargs.get("like_url", False) self.like_email = kwargs.get("like_email", False) self.sent = None class SpanishLemmaTests(unittest.TestCase): def test_builtin_correction_comar(self): token = MockToken(text="como", lemma="comar") self.assertEqual(safe_spanish_lemma(token), "comer") def test_builtin_correction_acabir(self): token = MockToken(text="acabas", lemma="acabir") self.assertEqual(safe_spanish_lemma(token), "acabar") def test_builtin_correction_delicius(self): token = MockToken(text="delicioso", lemma="deliciós") self.assertEqual(safe_spanish_lemma(token), "delicioso") def test_builtin_correction_llover_slash(self): token = MockToken(text="llueve", lemma="llover/llover") self.assertEqual(safe_spanish_lemma(token), "llover") def test_multi_word_lemma_falls_back(self): token = MockToken(text="ayudar", lemma="ayudar yo") self.assertEqual(safe_spanish_lemma(token), "ayudar") def test_multi_word_lemma_lavar_el(self): token = MockToken(text="lava", lemma="lavar él") self.assertEqual(safe_spanish_lemma(token), "lava") def test_prir_corrected_when_source_is_pedir(self): for form in ["pide", "pido", "piden", "pidiendo", "pedir"]: token = MockToken(text=form, lemma="prir") self.assertEqual(safe_spanish_lemma(token), "pedir") def test_prir_not_corrected_for_unknown_form(self): token = MockToken(text="prir", lemma="prir") self.assertEqual(safe_spanish_lemma(token), "prir") def test_empty_lemma_falls_back(self): token = MockToken(text="hola", lemma="") self.assertEqual(safe_spanish_lemma(token), "hola") def test_lemma_with_punctuation_falls_back(self): token = MockToken(text="comiendo", lemma="comiendo,") self.assertEqual(safe_spanish_lemma(token), "comiendo") def test_normal_lemma_passes_through(self): token = MockToken(text="comiendo", lemma="comer") self.assertEqual(safe_spanish_lemma(token), "comer") def test_lemma_info_tracks_correction_reason(self): token = MockToken(text="como", lemma="comar") info = safe_spanish_lemma_info(token) self.assertEqual(info.lemma, "comer") self.assertEqual(info.original_lemma, "comar") self.assertIn("corrected", info.status) def test_non_spanish_lemma_falls_back(self): token = MockToken(text="hola", lemma="hello_world") info = safe_spanish_lemma_info(token) self.assertEqual(info.lemma, "hola") self.assertEqual(info.status, "fallback_bad_lemma_characters") def test_extra_corrections_override_builtin(self): extra = {"comar": "comprar"} token = MockToken(text="como", lemma="comar") self.assertEqual( safe_spanish_lemma(token, extra_corrections=extra), "comprar", ) def test_load_lemma_corrections(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "corrections.tsv") with open(path, "w", encoding="utf-8") as f: f.write("malo\tbueno\nfeo\tbonito\n") corrections = load_lemma_corrections(path) self.assertEqual(corrections, {"malo": "bueno", "feo": "bonito"}) def test_load_lemma_corrections_skips_comments(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "corrections.tsv") with open(path, "w", encoding="utf-8") as f: f.write("# comment\nmalo\tbueno\n") corrections = load_lemma_corrections(path) self.assertEqual(corrections, {"malo": "bueno"}) class SpanishFilterTests(unittest.TestCase): def test_content_filter_keeps_noun(self): token = MockToken(text="casa", lemma="casa", pos="NOUN") self.assertTrue(spanish_content_filter(token)) def test_content_filter_keeps_verb(self): token = MockToken(text="come", lemma="comer", pos="VERB") self.assertTrue(spanish_content_filter(token)) def test_content_filter_keeps_adj(self): token = MockToken(text="grande", lemma="grande", pos="ADJ") self.assertTrue(spanish_content_filter(token)) def test_content_filter_keeps_adv(self): token = MockToken(text="bien", lemma="bien", pos="ADV") self.assertTrue(spanish_content_filter(token)) def test_content_filter_excludes_propn_by_default(self): token = MockToken(text="Madrid", lemma="Madrid", pos="PROPN") self.assertFalse(spanish_content_filter(token)) def test_content_filter_includes_propn_with_flag(self): token = MockToken(text="Madrid", lemma="Madrid", pos="PROPN") self.assertTrue( spanish_content_filter(token, include_proper_nouns=True) ) def test_content_filter_excludes_punct(self): token = MockToken(text=".", lemma=".", pos="PUNCT", is_punct=True) self.assertFalse(spanish_content_filter(token)) def test_content_filter_excludes_digit(self): token = MockToken(text="123", lemma="123", pos="NUM", is_digit=True) self.assertFalse(spanish_content_filter(token)) def test_content_filter_excludes_url(self): token = MockToken( text="http://x.com", lemma="http://x.com", pos="X", like_url=True ) self.assertFalse(spanish_content_filter(token)) def test_content_filter_excludes_media_filename(self): token = MockToken(text="es_001.mp3", lemma="es_001.mp3", pos="NOUN") self.assertFalse(spanish_content_filter(token)) def test_content_filter_excludes_one_char_non_vowel(self): token = MockToken(text="x", lemma="x", pos="NOUN") self.assertFalse(spanish_content_filter(token)) def test_function_word_filter_keeps_preposition(self): token = MockToken(text="para", lemma="para", pos="ADP") self.assertTrue(spanish_function_word_filter(token)) def test_function_word_filter_keeps_conjunction(self): token = MockToken(text="y", lemma="y", pos="CCONJ") self.assertTrue(spanish_function_word_filter(token)) def test_function_word_filter_rejects_noun(self): token = MockToken(text="casa", lemma="casa", pos="NOUN") self.assertFalse(spanish_function_word_filter(token)) class SpanishLintTests(unittest.TestCase): def _check(self, text: str) -> list: return check_card_for_issues(text, clean_anki_field_text(text)) def test_detect_tu_sabes_nada(self): issues = self._check("Tú sabes nada") self.assertTrue(any("Missing negation" in i["reason"] for i in issues)) def test_detect_dulce_suenos(self): issues = self._check("Dulce sueños") self.assertTrue( any("agree in number" in i["reason"] for i in issues) ) def test_detect_ella_sona(self): issues = self._check("Ella soña con viajar.") self.assertTrue( any("present indicative" in i["reason"] for i in issues) ) def test_detect_tengo_uno_libro(self): issues = self._check("Tengo uno libro en la mesa.") self.assertTrue( any("instead of 'un'" in i["reason"] for i in issues) ) def test_detect_observando_nadan(self): issues = self._check( "La pareja está en un bote observando a los peces nadan." ) self.assertTrue( any("'cómo'" in i["reason"] for i in issues) ) def test_detect_le_vuelven_loca(self): issues = self._check("Los perros pequeños le vuelven loca.") self.assertTrue( any("direct object" in i["reason"] for i in issues) ) def test_detect_ai_generated_tts(self): issues = self._check("AI-generated text-to-speech") self.assertTrue( any("Metadata contamination" in i["reason"] for i in issues) ) def test_detect_necesito_missing_article(self): issues = self._check("Necesito jardinero") self.assertTrue( any("Missing article" in i["reason"] for i in issues) ) def test_detect_le_vuelven_loco(self): issues = self._check("Estos problemas le vuelven loco.") self.assertTrue( any("direct object" in i["reason"] or "direct object pronoun" in i["reason"] for i in issues) ) def test_veintiuno_anos(self): issues = self._check("Tengo veintiuno años de edad.") self.assertTrue( any("veintiún" in i["suggested_fix"] for i in issues) ) def test_lint_anki_cards_multiple_records(self): records = [ {"text": "Hola mundo", "line_number": 1, "raw_line": "Hola mundo"}, {"text": "Tú sabes nada", "line_number": 2, "raw_line": "Tú sabes nada"}, {"text": "Ella soña con viajar.", "line_number": 3, "raw_line": "Ella soña con viajar."}, ] results = lint_anki_cards(records) self.assertEqual(len(results), 2) reasons = results[0]["reason"] + results[1]["reason"] self.assertIn("Missing negation", reasons) self.assertIn("present indicative", reasons) class SpanishDetailedCountsTests(unittest.TestCase): def test_extract_detailed_counts_basic(self): records = [ {"text": "Yo como manzanas.", "line_number": 1}, {"text": "Tú comes pan.", "line_number": 2}, ] class MockDoc: def __init__(self, tokens): self.tokens = tokens def __iter__(self): return iter(self.tokens) class MockNLP: def __call__(self, text): if "como" in text: return MockDoc([ MockToken(text="Yo", lemma="yo", pos="PRON"), MockToken(text="como", lemma="comar", pos="VERB"), MockToken(text="manzanas", lemma="manzana", pos="NOUN"), MockToken(text=".", lemma=".", pos="PUNCT", is_punct=True), ]) return MockDoc([ MockToken(text="Tú", lemma="tú", pos="PRON"), MockToken(text="comes", lemma="comer", pos="VERB"), MockToken(text="pan", lemma="pan", pos="NOUN"), MockToken(text=".", lemma=".", pos="PUNCT", is_punct=True), ]) stats = extract_detailed_counts( records, MockNLP(), lambda t: t.pos_ in {"NOUN", "VERB", "ADJ", "ADV"}, safe_spanish_lemma, ) # "comar" should be corrected to "comer" self.assertIn("comer", stats) # "comer" should have count 2 (comes + como -> comer) self.assertEqual(stats["comer"]["count"], 2) self.assertIn("manzana", stats) self.assertIn("pan", stats) def test_debug_tsv_output(self): stats = { "comer": { "count": 3, "pos_counts": {"VERB": 3}, "surface_forms": ["como", "comes", "come"], "example_sentences": ["Yo como.", "Tú comes."], "source_lines": ["1", "2", "3"], } } with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "debug.tsv") write_debug_tsv(stats, path) with open(path, "r", encoding="utf-8") as f: content = f.read() self.assertIn("comer", content) self.assertIn("VERB:3", content) self.assertIn("como, comes, come", content) self.assertIn("Yo como.", content) self.assertIn("1, 2, 3", content) def test_suspicious_tokens_output(self): stats = { "comer": { "count": 3, "pos_counts": {"VERB": 3}, "surface_forms": ["como", "comes"], "original_lemmas": ["comar", "comer"], "lemma_statuses": {"corrected:comar->comer": 1, "ok": 2}, "example_sentences": ["Yo como."], "source_lines": ["1"], } } with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "suspicious.tsv") write_suspicious_tokens(stats, path) with open(path, "r", encoding="utf-8") as f: content = f.read() self.assertIn("comer", content) self.assertIn("comar", content) self.assertIn("corrected:comar->comer", content) def test_debug_tsv_min_freq_filters(self): stats = { "comer": {"count": 5, "pos_counts": {"VERB": 5}, "surface_forms": ["come"], "example_sentences": ["Yo come."], "source_lines": ["1"]}, "hablar": {"count": 2, "pos_counts": {"VERB": 2}, "surface_forms": ["habla"], "example_sentences": ["Él habla."], "source_lines": ["2"]}, } with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "debug.tsv") write_debug_tsv(stats, path, min_freq=3) with open(path, "r", encoding="utf-8") as f: content = f.read() self.assertIn("comer", content) self.assertNotIn("hablar", content) def test_extract_detailed_counts_no_clean(self): class MockDoc: def __init__(self, tokens): self.tokens = tokens def __iter__(self): return iter(self.tokens) class MockNLP: def __call__(self, text): return MockDoc([ MockToken(text="[sound:x.mp3]", lemma="[sound:x.mp3]", pos="X"), ]) records = [{"text": "[sound:x.mp3]", "line_number": 1}] stats = extract_detailed_counts( records, MockNLP(), lambda t: True, lambda t: t.text.lower(), clean=False, ) # With clean=False, [sound:x.mp3] is NOT stripped self.assertIn("[sound:x.mp3]", stats) stats_clean = extract_detailed_counts( records, MockNLP(), lambda t: True, lambda t: t.text.lower(), clean=True, ) # With clean=True, the sound marker is stripped → empty text → no stats self.assertEqual(len(stats_clean), 0) def test_lint_anki_cards_verbose_includes_all(self): records = [ {"text": "Hola mundo", "line_number": 1, "raw_line": "Hola mundo"}, {"text": "Tú sabes nada", "line_number": 2, "raw_line": "Tú sabes nada"}, ] # Without verbose – only suspicious results = lint_anki_cards(records, verbose=False) self.assertEqual(len(results), 1) # With verbose – both records results = lint_anki_cards(records, verbose=True) self.assertEqual(len(results), 2) def test_lint_anki_cards_verbose_empty_field(self): records = [ {"text": "", "line_number": 1, "raw_line": ""}, ] results = lint_anki_cards(records, verbose=True) self.assertEqual(len(results), 1) self.assertEqual(results[0]["reason"], "empty field") class CompareWordListsTests(unittest.TestCase): def test_normalize_word_for_comparison(self): self.assertEqual(normalize_word_for_comparison("camión"), "camion") self.assertEqual(normalize_word_for_comparison("Corazón"), "corazon") self.assertEqual(normalize_word_for_comparison("ESPAÑOL"), "espanol") def test_compare_word_lists_identifies_missing(self): with tempfile.TemporaryDirectory() as tmp: deck = os.path.join(tmp, "deck.txt") target = os.path.join(tmp, "target.txt") with open(deck, "w", encoding="utf-8") as f: f.write("comer 5\nhablar 3\n") with open(target, "w", encoding="utf-8") as f: f.write("comer 10\nvivir 8\ncorrer 6\n") result = compare_word_lists(deck, target) self.assertIn("vivir", result["missing"]) self.assertIn("correr", result["missing"]) self.assertNotIn("comer", result["missing"]) def test_compare_word_lists_with_min_frequency(self): with tempfile.TemporaryDirectory() as tmp: deck = os.path.join(tmp, "deck.txt") target = os.path.join(tmp, "target.txt") with open(deck, "w", encoding="utf-8") as f: f.write("comer 1\nhablar 5\n") with open(target, "w", encoding="utf-8") as f: f.write("comer 10\nhablar 10\n") result = compare_word_lists(deck, target, min_frequency=3) # "comer" has freq 1 < 3, so it's excluded from deck entirely # meaning it should be "missing" from deck's perspective self.assertIn("comer", result["missing"]) self.assertNotIn("hablar", result["missing"]) self.assertIn("hablar", result["seen"]) def test_compare_word_lists_writes_output(self): with tempfile.TemporaryDirectory() as tmp: deck = os.path.join(tmp, "deck.txt") target = os.path.join(tmp, "target.txt") out = os.path.join(tmp, "output.txt") with open(deck, "w", encoding="utf-8") as f: f.write("comer 5\n") with open(target, "w", encoding="utf-8") as f: f.write("comer 10\nvivir 8\n") result = compare_word_lists(deck, target, output_path=out) self.assertEqual(result["total_missing"], 1) with open(out, "r", encoding="utf-8") as f: self.assertIn("vivir", f.read()) def test_compare_word_lists_writes_seen_output(self): with tempfile.TemporaryDirectory() as tmp: deck = os.path.join(tmp, "deck.txt") target = os.path.join(tmp, "target.txt") seen_out = os.path.join(tmp, "seen.txt") with open(deck, "w", encoding="utf-8") as f: f.write("comer 5\n") with open(target, "w", encoding="utf-8") as f: f.write("comer 10\nvivir 8\n") result = compare_word_lists(deck, target, seen_output_path=seen_out) self.assertIn("comer", result["seen"]) with open(seen_out, "r", encoding="utf-8") as f: self.assertIn("comer", f.read()) self.assertNotIn("vivir", f.read()) class YoutubeTests(unittest.TestCase): def test_extract_video_id(self): self.assertEqual(extract_video_id("https://youtu.be/abc123"), "abc123") self.assertEqual(extract_video_id("https://www.youtube.com/watch?v=abc123&t=5"), "abc123") self.assertEqual(extract_video_id("abc123"), "abc123") def test_sentence_vocab_filters_known_words(self): self.assertEqual(sentence_vocab("Hola hola mundo", "es", {"hola"}), ["mundo"]) def test_write_sentence_export(self): with tempfile.TemporaryDirectory() as tmp: out = os.path.join(tmp, "sentences.tsv") written = write_sentence_export( [TranscriptLine(12.3, "Hola mundo")], out, "abc123", "es", ) self.assertEqual(written, 1) with open(out, "r", encoding="utf-8") as fh: rows = fh.read().splitlines() self.assertEqual(rows[0], "sentence\ttimestamp\tvideo_url\tvocab_guess") self.assertEqual(rows[1], "Hola mundo\t12.30\thttps://www.youtube.com/watch?v=abc123\thola, mundo") class ImporterTests(unittest.TestCase): def test_parse_tags(self): self.assertEqual(parse_tags(None), ["text-to-speech", "AI-generated"]) self.assertEqual(parse_tags("youtube, manual "), ["text-to-speech", "youtube", "manual"]) def test_read_sentences_from_tsv_export(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "youtube.tsv") with open(path, "w", encoding="utf-8") as fh: fh.write("sentence\ttimestamp\tvideo_url\tvocab_guess\n") fh.write("Hola mundo\t1.00\thttps://example.test\tmundo\n") self.assertEqual(read_sentences(path), ["Hola mundo"]) def test_supported_tts_backends_are_free_options(self): self.assertEqual(supported_tts_backends(), ["edge-tts", "espeak-ng", "gtts", "kokoro", "piper"]) def test_list_gtts_voice_hint(self): config = Config(deep_merge(deepcopy(DEFAULT_CONFIG), {"languages": {"es": {"tts_code": "es", "tts_tld": "es"}}})) voices = list_tts_voices(config, "es", backend="gtts") self.assertIn("gtts does not expose named voices.", voices) self.assertIn("tts_code=es", voices[1]) def test_list_edge_voice_uses_configured_voice(self): voices = list_tts_voices(Config(deepcopy(DEFAULT_CONFIG)), "es") self.assertEqual(voices[0], "Configured edge-tts voice: es-ES-ElviraNeural") def test_prepare_tts_backend_validates_required_keys(self): with self.assertRaisesRegex(RuntimeError, "tts_voice"): prepare_tts_backend({"tts_backend": "edge-tts"}) def test_prepare_tts_backend_expands_model_paths(self): with tempfile.TemporaryDirectory() as tmp: model = os.path.join(tmp, "voice.onnx") config = os.path.join(tmp, "voice.onnx.json") for path in [model, config]: with open(path, "w", encoding="utf-8") as fh: fh.write("{}") with patch("saiki.importer.require_command"): backend = prepare_tts_backend( { "tts_backend": "piper", "tts_model_dir": tmp, "tts_model": "voice.onnx", "tts_config": "voice.onnx.json", } ) with patch("saiki.importer.subprocess.run") as run: backend.synthesize("Hola", "/tmp/out.wav") args = run.call_args.args[0] self.assertEqual(args[2], model) self.assertEqual(args[4], config) self.assertEqual(run.call_args.kwargs["input"], b"Hola\n") def test_synthesize_tts_sample_uses_backend_and_speed_audio(self): seen: dict[str, str] = {} def synthesize(sentence: str, output: str) -> None: seen["sentence"] = sentence seen["raw_output"] = output with tempfile.TemporaryDirectory() as tmp: output = os.path.join(tmp, "sample.mp3") with patch("saiki.importer.prepare_tts_backend") as prepare, patch( "saiki.importer.require_command" ), patch("saiki.importer.speed_audio") as speed: prepare.return_value = PreparedTtsBackend("fake", ".wav", synthesize) result = synthesize_tts_sample(Config(deepcopy(DEFAULT_CONFIG)), "es", output=output) self.assertEqual(result, output) self.assertEqual(seen["sentence"], "Esta es una prueba.") self.assertTrue(seen["raw_output"].endswith(".wav")) speed.assert_called_once() def test_import_sentences_returns_error_details(self): def fail_synthesis(sentence: str, output: str) -> None: raise RuntimeError("tts broke") with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "sentences.txt") with open(path, "w", encoding="utf-8") as fh: fh.write("Hola mundo\n") with patch("saiki.importer.prepare_tts_backend") as prepare, patch("saiki.importer.require_command"): prepare.return_value = PreparedTtsBackend("fake", ".mp3", fail_synthesis) result = import_sentences(Config(deepcopy(DEFAULT_CONFIG)), "es", path, request=lambda *a, **k: None) self.assertEqual(result.processed, 1) self.assertEqual(result.added, 0) self.assertEqual(result.failed, 1) self.assertEqual(result.errors, ["'Hola mundo': tts broke"]) if __name__ == "__main__": unittest.main()