Improve Spanish Anki vocabulary tooling
This commit is contained in:
+647
-1
@@ -12,7 +12,9 @@ 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,
|
||||
@@ -24,8 +26,28 @@ from saiki.importer import (
|
||||
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, read_word_file
|
||||
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
|
||||
|
||||
|
||||
@@ -81,6 +103,630 @@ class WordsTests(unittest.TestCase):
|
||||
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("<span>Para el lunes...</span>")
|
||||
self.assertEqual(result, "Para el lunes...")
|
||||
|
||||
def test_english_after_brbr_removed(self):
|
||||
result = clean_anki_field_text("hasta luego<br><br>(see you later)")
|
||||
self.assertEqual(result, "hasta luego")
|
||||
|
||||
def test_english_gloss_after_brbr_removed(self):
|
||||
result = clean_anki_field_text("Te quiero.<br><br>I love you / I want you")
|
||||
self.assertEqual(result, "Te quiero.")
|
||||
|
||||
def test_spanish_after_brbr_preserved(self):
|
||||
result = clean_anki_field_text("Hola<br><br>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<br/>Line2<br />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")
|
||||
|
||||
Reference in New Issue
Block a user