From 88cda74a6a0c8c9736798e06284c43b01068442b Mon Sep 17 00:00:00 2001 From: Krzysztof Rudnicki Date: Sun, 28 Dec 2025 16:48:34 +0100 Subject: [PATCH] feat: anki generation feature --- python_pkg/word_frequency/anki_generator.py | 499 ++++++++++++++++++ .../test_texts/polish_pan_tadeusz_anki_10.txt | 198 +++++++ .../test_texts/polish_pan_tadeusz_anki_30.txt | 33 ++ .../tests/test_anki_generator.py | 380 +++++++++++++ 4 files changed, 1110 insertions(+) create mode 100644 python_pkg/word_frequency/anki_generator.py create mode 100644 python_pkg/word_frequency/test_texts/polish_pan_tadeusz_anki_10.txt create mode 100644 python_pkg/word_frequency/test_texts/polish_pan_tadeusz_anki_30.txt create mode 100644 python_pkg/word_frequency/tests/test_anki_generator.py diff --git a/python_pkg/word_frequency/anki_generator.py b/python_pkg/word_frequency/anki_generator.py new file mode 100644 index 0000000..450eab6 --- /dev/null +++ b/python_pkg/word_frequency/anki_generator.py @@ -0,0 +1,499 @@ +#!/usr/bin/env python3 +"""Anki flashcard generator from vocabulary curve analysis. + +Generates Anki-compatible flashcard decks from the vocabulary needed to +understand excerpts of a given length. + +Usage: + # Generate flashcards for a 20-word excerpt + python -m python_pkg.word_frequency.anki_generator --file text.txt --length 20 + + # Specify source language (auto-detected by default) + python -m python_pkg.word_frequency.anki_generator --file text.txt --length 20 --from pl + + # Custom output file + python -m python_pkg.word_frequency.anki_generator --file text.txt --length 20 --output polish_vocab.txt + + # Include example sentences/context + python -m python_pkg.word_frequency.anki_generator --file text.txt --length 20 --include-context + +Output: + Creates a semicolon-separated text file that can be imported into Anki. + Format: word;translation;frequency_rank;example_context (optional) +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from collections import Counter +from pathlib import Path +from typing import TYPE_CHECKING, NamedTuple + +if TYPE_CHECKING: + from collections.abc import Sequence + +try: + from python_pkg.word_frequency.translator import ( + detect_language, + translate_words_batch, + ) + from python_pkg.word_frequency.analyzer import read_file, analyze_text +except ImportError: + from translator import detect_language, translate_words_batch + from analyzer import read_file, analyze_text + + +# Path to C vocabulary_curve executable +C_EXECUTABLE = Path(__file__).parent.parent.parent / "C" / "vocabulary_curve" / "vocabulary_curve" + + +class VocabWord(NamedTuple): + """A vocabulary word with its metadata.""" + + word: str + rank: int + translation: str + context: str + + +def run_vocabulary_curve(filepath: Path, max_length: int) -> str: + """Run the C vocabulary_curve executable. + + Args: + filepath: Path to the text file. + max_length: Maximum excerpt length. + + Returns: + Output from the executable. + + Raises: + FileNotFoundError: If executable not found. + subprocess.CalledProcessError: If execution fails. + """ + if not C_EXECUTABLE.exists(): + raise FileNotFoundError( + f"C executable not found at {C_EXECUTABLE}. " + "Please compile it first: cd C/vocabulary_curve && make" + ) + + result = subprocess.run( + [str(C_EXECUTABLE), str(filepath), str(max_length)], + capture_output=True, + text=True, + timeout=120, + check=True, + ) + return result.stdout + + +def parse_vocabulary_curve_output(output: str, target_length: int) -> tuple[str, list[tuple[str, int]]]: + """Parse output from vocabulary_curve to get words needed. + + Args: + output: Raw output from vocabulary_curve. + target_length: The target excerpt length. + + Returns: + Tuple of (excerpt_text, list of (word, rank) tuples). + """ + lines = output.split("\n") + excerpt = "" + words: list[tuple[str, int]] = [] + + # Find the line for the target length + i = 0 + while i < len(lines): + line = lines[i] + if line.strip().startswith(f"[Length {target_length}]"): + # Found our target length, now get excerpt and words + i += 1 + # Find excerpt line + while i < len(lines) and not lines[i].strip().startswith("Excerpt:"): + i += 1 + if i < len(lines): + excerpt_line = lines[i].strip() + if '"' in excerpt_line: + start = excerpt_line.index('"') + 1 + end = excerpt_line.rindex('"') + excerpt = excerpt_line[start:end] + + # Find words line + i += 1 + while i < len(lines) and not lines[i].strip().startswith("Words:"): + i += 1 + if i < len(lines): + words_line = lines[i].strip() + if words_line.startswith("Words:"): + words_part = words_line[6:].strip() + # Parse "word(#rank), word2(#rank2), ..." + pattern = r"(\S+)\(#(\d+)\)" + matches = re.findall(pattern, words_part) + words = [(w, int(r)) for w, r in matches] + break + i += 1 + + return excerpt, words + + +def get_top_n_words(text: str, n: int) -> list[tuple[str, int]]: + """Get the top N most frequent words from text. + + Args: + text: The source text. + n: Number of top words to return. + + Returns: + List of (word, rank) tuples, ranked 1 to n. + """ + word_counts = analyze_text(text) + sorted_words = sorted(word_counts.items(), key=lambda x: (-x[1], x[0])) + return [(word, rank + 1) for rank, (word, _) in enumerate(sorted_words[:n])] + + +def find_word_contexts( + text: str, + words: list[str], + context_words: int = 5, +) -> dict[str, str]: + """Find example contexts for each word in the text. + + Args: + text: The source text. + words: List of words to find contexts for. + context_words: Number of words of context on each side. + + Returns: + Dict mapping word to example context. + """ + # Extract all words preserving positions + all_words = re.findall(r"\b[\w]+\b", text, re.UNICODE) + all_words_lower = [w.lower() for w in all_words] + + contexts: dict[str, str] = {} + words_lower = {w.lower() for w in words} + + for target in words_lower: + # Find first occurrence + for i, word in enumerate(all_words_lower): + if word == target: + start = max(0, i - context_words) + end = min(len(all_words), i + context_words + 1) + context = " ".join(all_words[start:end]) + contexts[target] = f"...{context}..." + break + + return contexts + + +def generate_anki_deck( + words_with_ranks: list[tuple[str, int]], + source_lang: str, + target_lang: str = "en", + contexts: dict[str, str] | None = None, + deck_name: str = "Vocabulary", + include_context: bool = False, + no_translate: bool = False, +) -> str: + """Generate Anki-compatible deck content. + + Args: + words_with_ranks: List of (word, rank) tuples. + source_lang: Source language code. + target_lang: Target language code (default: en). + contexts: Optional dict of word -> context. + deck_name: Name for the deck. + include_context: Whether to include context in cards. + no_translate: If True, skip translation (use placeholder). + + Returns: + Semicolon-separated content ready for Anki import. + """ + lines: list[str] = [] + + # Add Anki headers + lines.append(f"#separator:semicolon") + lines.append(f"#html:true") + lines.append(f"#deck:{deck_name}") + lines.append(f"#tags:vocabulary {source_lang}") + if include_context: + lines.append("#columns:Front;Back;Rank;Context") + else: + lines.append("#columns:Front;Back;Rank") + lines.append("") # Empty line before data + + # Get translations (or skip if no_translate) + words = [w for w, _ in words_with_ranks] + if no_translate: + trans_lookup = {w.lower(): "[TODO]" for w in words} + else: + translations = translate_words_batch(words, source_lang, target_lang) + # Build translation lookup + trans_lookup = {} + for result in translations: + if result.success: + trans_lookup[result.source_word.lower()] = result.translated_word + else: + trans_lookup[result.source_word.lower()] = f"[{result.source_word}]" + + # Generate cards + for word, rank in words_with_ranks: + translation = trans_lookup.get(word.lower(), f"[{word}]") + + # Escape semicolons in fields + word_escaped = word.replace(";", ",") + translation_escaped = translation.replace(";", ",") + + if include_context and contexts: + context = contexts.get(word.lower(), "") + # Highlight the word in context + if context: + context_escaped = context.replace(";", ",") + # Make target word bold in context + pattern = re.compile(re.escape(word), re.IGNORECASE) + context_escaped = pattern.sub(f"{word}", context_escaped) + else: + context_escaped = "" + lines.append(f"{word_escaped};{translation_escaped};#{rank};{context_escaped}") + else: + lines.append(f"{word_escaped};{translation_escaped};#{rank}") + + return "\n".join(lines) + + +def generate_flashcards( + filepath: str | Path, + excerpt_length: int, + source_lang: str | None = None, + target_lang: str = "en", + include_context: bool = False, + deck_name: str | None = None, + all_vocab: bool = True, + no_translate: bool = False, +) -> tuple[str, str, int, int]: + """Generate Anki flashcards for vocabulary needed for an excerpt length. + + Args: + filepath: Path to the source text file. + excerpt_length: Target excerpt length. + source_lang: Source language (auto-detected if None). + target_lang: Target language for translations. + include_context: Whether to include example contexts. + deck_name: Optional deck name. + all_vocab: If True, include ALL words from rank 1 to max rank needed. + If False, only include words that appear in the excerpt. + no_translate: If True, skip translation. + + Returns: + Tuple of (anki_content, excerpt, num_words, max_rank). + """ + filepath = Path(filepath) + + # Read the text + text = read_file(filepath) + + # Auto-detect language if not provided + if source_lang is None: + source_lang = detect_language(text) + if source_lang is None: + source_lang = "auto" + + # Run vocabulary curve analysis + output = run_vocabulary_curve(filepath, excerpt_length) + + # Parse the output + excerpt, excerpt_words = parse_vocabulary_curve_output(output, excerpt_length) + + if not excerpt_words: + raise ValueError(f"No words found for excerpt length {excerpt_length}") + + # Find max rank needed + max_rank = max(rank for _, rank in excerpt_words) + + # Get ALL words up to max_rank if requested + if all_vocab: + words_with_ranks = get_top_n_words(text, max_rank) + else: + words_with_ranks = excerpt_words + + # Get contexts if requested + contexts = None + if include_context: + words = [w for w, _ in words_with_ranks] + contexts = find_word_contexts(text, words) + + # Generate deck name + if deck_name is None: + deck_name = f"{filepath.stem}_vocab_{excerpt_length}" + + # Generate Anki content + anki_content = generate_anki_deck( + words_with_ranks, + source_lang, + target_lang, + contexts, + deck_name, + include_context, + no_translate, + ) + + return anki_content, excerpt, len(words_with_ranks), max_rank + + +def main(argv: Sequence[str] | None = None) -> int: + """Main entry point. + + Args: + argv: Command line arguments. + + Returns: + Exit code. + """ + parser = argparse.ArgumentParser( + description="Generate Anki flashcards from vocabulary analysis.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + + parser.add_argument( + "--file", + "-f", + type=str, + required=True, + help="Path to the text file to analyze", + ) + parser.add_argument( + "--length", + "-l", + type=int, + required=True, + help="Target excerpt length (how many words you want to understand)", + ) + parser.add_argument( + "--from", + "-F", + dest="source_lang", + type=str, + default=None, + help="Source language code (e.g., 'pl', 'la', 'de'). Auto-detected if not specified.", + ) + parser.add_argument( + "--to", + "-T", + dest="target_lang", + type=str, + default="en", + help="Target language code for translations (default: 'en')", + ) + parser.add_argument( + "--output", + "-o", + type=str, + default=None, + help="Output file path (default: _anki_.txt)", + ) + parser.add_argument( + "--include-context", + "-c", + action="store_true", + help="Include example context sentences in flashcards", + ) + parser.add_argument( + "--deck-name", + "-d", + type=str, + default=None, + help="Name for the Anki deck (default: auto-generated)", + ) + parser.add_argument( + "--quiet", + "-q", + action="store_true", + help="Only output the file path, no status messages", + ) + parser.add_argument( + "--excerpt-words-only", + "-e", + action="store_true", + help="Only include words that appear in the excerpt (default: include ALL words up to max rank)", + ) + parser.add_argument( + "--no-translate", + "-n", + action="store_true", + help="Skip translation (output words without translations)", + ) + + args = parser.parse_args(argv) + + try: + filepath = Path(args.file) + if not filepath.exists(): + print(f"Error: File not found: {args.file}", file=sys.stderr) # noqa: T201 + return 1 + + if not args.quiet: + print(f"Analyzing {filepath.name}...") # noqa: T201 + print(f"Finding vocabulary for {args.length}-word excerpt...") # noqa: T201 + + # Generate flashcards + anki_content, excerpt, num_words, max_rank = generate_flashcards( + filepath, + args.length, + source_lang=args.source_lang, + target_lang=args.target_lang, + include_context=args.include_context, + deck_name=args.deck_name, + all_vocab=not args.excerpt_words_only, + no_translate=args.no_translate, + ) + + # Determine output path + if args.output: + output_path = Path(args.output) + else: + output_path = filepath.parent / f"{filepath.stem}_anki_{args.length}.txt" + + # Write output + output_path.write_text(anki_content, encoding="utf-8") + + if not args.quiet: + print("") # noqa: T201 + print("=" * 60) # noqa: T201 + print("FLASHCARD GENERATION COMPLETE") # noqa: T201 + print("=" * 60) # noqa: T201 + print(f"Excerpt to understand ({args.length} words):") # noqa: T201 + print(f' "{excerpt}"') # noqa: T201 + print("") # noqa: T201 + print(f"Max word rank needed: #{max_rank}") # noqa: T201 + if args.excerpt_words_only: + print(f"Flashcards: {num_words} (excerpt words only)") # noqa: T201 + else: + print(f"Flashcards: {num_words} (ALL words rank #1 to #{max_rank})") # noqa: T201 + print(f"Output file: {output_path}") # noqa: T201 + print("") # noqa: T201 + print("To import into Anki:") # noqa: T201 + print(" 1. Open Anki") # noqa: T201 + print(" 2. File -> Import") # noqa: T201 + print(f" 3. Select: {output_path}") # noqa: T201 + print(" 4. Click Import") # noqa: T201 + else: + print(output_path) # noqa: T201 + + return 0 + + except FileNotFoundError as e: + print(f"Error: {e}", file=sys.stderr) # noqa: T201 + return 1 + except subprocess.CalledProcessError as e: + print(f"Error running vocabulary_curve: {e}", file=sys.stderr) # noqa: T201 + return 1 + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) # noqa: T201 + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python_pkg/word_frequency/test_texts/polish_pan_tadeusz_anki_10.txt b/python_pkg/word_frequency/test_texts/polish_pan_tadeusz_anki_10.txt new file mode 100644 index 0000000..9073963 --- /dev/null +++ b/python_pkg/word_frequency/test_texts/polish_pan_tadeusz_anki_10.txt @@ -0,0 +1,198 @@ +#separator:semicolon +#html:true +#deck:polish_pan_tadeusz_vocab_10 +#tags:vocabulary pl +#columns:Front;Back;Rank;Context + +i;and;#1;...modami Początek sporu o Kusego i Sokoła Żale Wojskiego Ostatni Woźny... +w;In;#2;...Powrót panicza Spotkanie się pierwsze w pokoiku drugie u stołu ważna... +się;myself;#3;...pierwsza Gospodarstwo Powrót panicza Spotkanie się pierwsze w pokoiku drugie u... +z;With;#4;...co gród zamkowy Nowogródzki ochraniasz z jego wiernym ludem Jak mnie... +na;on;#5;...Pan Tadeusz czyli ostatni zajazd na Litwie ISBN 978 83 288... +nie;NO;#6;...co pod strzechą zmieścić się nie może Widać że okolica obfita... +jak;How;#7;...Litwo Ojczyzno moja ty jesteś jak zdrowie Ile cię trzeba cenić... +do;down;#8;...wiernym ludem Jak mnie dziecko do zdrowia powróciłaś cudem Gdy od... +a;and;#9;...Gdzie panieńskim rumieńcem dzięcielina pała a wszystko przepasane jakby wstęgą miedzą... +że;That;#10;...daleka pobielane ściany Tym bielsze że odbite od ciemnej zieleni Topoli... +to;this;#11;...w Petersburgu mieszkała przed laty to nie był ochmistrzyni pokój Fortepiano... +o;about;#12;...u stołu Ważna Sędziego nauka o grzeczności Podkomorzego uwagi polityczne nad... +za;for;#13;...do Twych świątyń progu Iść za wrócone życie podziękować Bogu Tak... +po;after;#14;...Widzę i opisuję bo tęsknię po tobie Panno święta co Jasnej... +już;Already;#15;...Moskali Siekąc wrogów a Praga już się wkoło pali Nawet stary... +tak;Yes;#16;...za wrócone życie podziękować Bogu tak nas powrócisz cudem na Ojczyzny... +co;What;#17;...tęsknię po tobie Panno święta co Jasnej bronisz Częstochowy I w... +od;From;#18;...do zdrowia powróciłaś cudem Gdy od płaczącej matki pod Twoją opiekę... +lecz;but;#19;...Stał dwór szlachecki z drzewa lecz podmurowany Świeciły się z daleka... +bo;because;#20;...całej ozdobie Widzę i opisuję bo tęsknię po tobie Panno święta... +gdy;When;#21;...dziecko do zdrowia powróciłaś cudem gdy od płaczącej matki pod Twoją... +ja;I;#22;...z talerzem ogórki Rzekł Muszę ja wam służyć moje panny córki... +pan;you;#23;...Adam Mickiewicz pan Tadeusz czyli ostatni zajazd na... +jest;Is;#24;...myśli wkrótce sprawić ci wesele jest z czego wybrać u nas... +ale;But;#25;...stało wody pełne naczynie blaszane ale nigdzie nie widać było ogrodniczki... +był;was;#26;...niebo miecz oburącz trzyma Takim był gdy przysięgał na stopniach ołtarzów... +nim;him;#27;...trzech mocarzów Albo sam na nim padnie Dalej w polskiej szacie... +rzekł;he said;#28;...młodszej przysunąwszy z talerzem ogórki rzekł Muszę ja wam służyć moje... +go;him;#29;...od ciemnej zieleni Topoli co go bronią od wiatrów jesieni Dom... +tylko;Just;#30;...Ile cię trzeba cenić ten tylko się dowie Kto cię stracił... +jako;as;#31;...chciwie ściany starodawne Ogląda czule jako swe znajome dawne Też same... +mnie;me;#32;...z jego wiernym ludem Jak mnie dziecko do zdrowia powróciłaś cudem... +mu;him;#33;...Wyszedł zmieszany i czuł że mu serce biło Głośno i sam... +tu;here;#34;...same portrety na ścianach wisiały tu Kościuszko w czamarce krakowskiej z... +on;he;#35;...teraz za domem urządzał wieczerzę on pana zastępuje i on w... +czy;Whether;#36;...Głośno i sam nie wiedział czy go miało śmieszyć To dziwaczne... +ten;this;#37;...zdrowie Ile cię trzeba cenić ten tylko się dowie Kto cię... +hrabia;count;#38;...panem Hrabią sporu I pan hrabia ma jutro sam zjechać do... +sędzia;judge;#39;...i obrok i siano Bo sędzia nigdy nie chciał według nowej... +tam;there;#40;...żniwo oglądają Pod lasem i tam pewnie na młodzież czekają Pójdziemy... +pod;under;#41;...cudem Gdy od płaczącej matki pod Twoją opiekę Ofiarowany martwą podniosłem... +aż;until;#42;...Wonnymi powiewami kwiatów oddychając Oblicze na krzaki fijołkowe skłonił Oczyma... +dla;For;#43;...zbiera się na sądy graniczne dla skończenia dawnego z panem Hrabią... +u;at;#44;...się pierwsze w pokoiku drugie u stołu Ważna Sędziego nauka o... +nad;over;#45;...o grzeczności Podkomorzego uwagi polityczne nad modami Początek sporu o Kusego... +więc;So;#46;...nie bywa od mężczyzn widziana więc choć świadka nie miała założyła... +ich;their;#47;...stodoły Cieszą się z niezwyczajnej ich lekkości woły Właśnie z lasu... +tadeusz;tadeusz;#48;...Adam Mickiewicz Pan tadeusz czyli ostatni zajazd na Litwie... +tym;this;#49;...się z daleka pobielane ściany tym bielsze że odbite od ciemnej... +przed;Before;#50;...grusze siedzą Śród takich pól przed laty nad brzegiem ruczaju Na... +jeszcze;still;#51;...było ogrodniczki Tylko co wyszła jeszcze kołyszą się drzwiczki Świeżo trącone... +sam;alone;#52;...z Polski trzech mocarzów Albo sam na nim padnie Dalej w... +przy;by;#53;...I stodołę miał wielką i przy niej trzy stogi Użątku co... +przez;By;#54;...na błonie I wionęła ogrodem przez płotki przez kwiaty I po... +ze;That;#55;...pan Sędzia każe U niego ze dniem kończą pracę gospodarze Pan... +bez;without;#56;...ślad widać nóżki Na piasku bez trzewika była i pończoszki Na... +gdzie;Where;#57;...rozmaitem Wyzłacanych pszenicą posrebrzanych żytem gdzie bursztynowy świerzop gryka jak śnieg... +jej;her;#58;...parkanie Stała młoda dziewczyna Białe jej ubranie Wysmukłą postać tylko aż... +kto;Who;#59;...cenić ten tylko się dowie kto cię stracił Dziś piękność twą... +ku;to;#60;...ze skoszonej łąki Wszystko bieży ku studni której ramię z drzewa... +wszyscy;all;#61;...łacinie Mężczyznom dano wódkę wtenczas wszyscy siedli I chołodziec litewski milcząc... +wojski;troops;#62;...Słudzy czekają nim się pan wojski ubierze Który teraz za domem... +było;was;#63;...blaszane Ale nigdzie nie widać było ogrodniczki Tylko co wyszła jeszcze... +choć;though;#64;...bywa od mężczyzn widziana Więc choć świadka nie miała założyła ręce... +potem;then;#65;...Naprzód dzieci małe Z dozorcą potem Sędzia szedł z Podkomorzyną Obok... +mi;me;#66;...urzędów Przynajmniej tom skorzystał że mi w moim domu Nikt nigdy... +miał;had;#67;...lecz zewsząd chędogi I stodołę miał wielką i przy niej trzy... +teraz;Now;#68;...się pan Wojski ubierze Który teraz za domem urządzał wieczerzę On... +dziś;today;#69;...się dowie Kto cię stracił dziś piękność twą w całej ozdobie... +jego;his;#70;...gród zamkowy Nowogródzki ochraniasz z jego wiernym ludem Jak mnie dziecko... +by;by;#71;...dziecinną radością pociągnął za sznurek by stary Dąbrowskiego usłyszeć mazurek Biegał... +ją;I;#72;...że niecierpliwa młodzież teraźniejsza Że nudzi rzecz długa choć najwymowniejsza... +oczy;Eyes;#73;...i czyje były odgadywał Przypadkiem oczy podniósł i tuż na parkanie... +domu;home;#74;...ogrodowych grządek Że w tym domu dostatek mieszka i porządek Brama... +niech;let;#75;...kłócić się o nie Więc niech jaśnie wielmożny Podkomorzy raczy Odwołać... +może;Maybe;#76;...pod strzechą zmieścić się nie może Widać że okolica obfita we... +kiedy;When;#77;...dziś nagodził Do domu właśnie kiedy mamy panien wiele Stryjaszek myśli... +jeśli;If;#78;...pewnie na młodzież czekają Pójdziemy jeśli zechcesz i wkrótce spotkamy Stryjaszka... +ma;has;#79;...Hrabią sporu I pan Hrabia ma jutro sam zjechać do dworu... +który;which;#80;...nim się pan Wojski ubierze który teraz za domem urządzał wieczerzę... +nas;us;#81;...wrócone życie podziękować Bogu Tak nas powrócisz cudem na Ojczyzny łono... +nawet;even;#82;...Praga już się wkoło pali nawet stary stojący zegar kurantowy W... +znowu;again;#83;...ciekawymi po drożynach gonił I znowu je na drobnych śladach zatrzymywał... +jakby;as if;#84;...dzięcielina pała A wszystko przepasane jakby wstęgą miedzą Zieloną na niej... +wszystko;All;#85;...panieńskim rumieńcem dzięcielina pała A wszystko przepasane jakby wstęgą miedzą Zieloną... +raz;once;#86;...studni której ramię z drzewa raz wraz skrzypi i napój w... +szlachta;gentry;#87;...śmieli otwierać On rzekł Wielmożni szlachta bracia dobrodzieje Forum myśliwskim tylko... +lub;or;#88;...że się nam zdawał małpą lub papugą W wielkiej peruce którą... +tej;this one;#89;...zdumione źrenice Po ścianach w tej komnacie mieszkanie kobiéce Któż by... +we;in;#90;...brzegiem ruczaju Na pagórku niewielkim we brzozowym gaju Stał dwór szlachecki... +cóż;Well;#91;...były pod lasem zwaliska Po cóż te przenosiny Pan Wojski się... +sobie;yourself;#92;...ukłonem Chciała usieść na miejscu sobie zostawionem Trudno było bo krzeseł... +też;Too;#93;...czule jako swe znajome dawne też same widzi sprzęty też same... +albo;or;#94;...wypędzi z Polski trzech mocarzów albo sam na nim padnie Dalej... +gerwazy;gerwazy;#95;...Krzyknąć nim głos Hrabiego usłyszał gerwazy Szlachcic to był służący dawnych... +ręce;hands;#96;...choć świadka nie miała założyła ręce Na piersiach przydawając zasłony sukience... +są;are;#97;...upodobał mury Tłumacząc że gotyckiej architektury Choć Sędzia z dokumentów... +te;these;#98;...pod lasem zwaliska Po cóż te przenosiny Pan Wojski się krzywił... +będzie;will be;#99;...rana wiedział Że u wieczerzy będzie z mnóstwem gości siedział Pan... +między;between;#100;...po kądzieli A resztę rozdzielono między wierzycieli Zamku żaden wziąć nie... +mój;my;#101;...dzieje tego dnia powiadał Dobrze mój Tadeuszu bo tak nazywano Młodzieńca... +była;was;#102;...nóżki Na piasku bez trzewika była i pończoszki Na piasku drobnym... +głowy;head;#103;...nie przerywał Ale częstym skinieniem głowy potakiwał Sędzia milczał on jeszcze... +telimena;telimena;#104;...sąd pańskiej cioci Choć pani telimena mieszkała w stolicy I bawi... +bardzo;Very;#105;...oko nie zobaczy Bo biegła bardzo szybko suwała się raczéj Jako... +panie;sir;#106;...pani Telimena i panny i panie Słowem zrobim na urząd wielkie... +razem;Together;#107;...z łąk i z pastwisk razem wracało do dworu Tu owiec... +tadeusza;tadeusz;#108;...Otarł prędko jak kochał pana tadeusza W ślad gospodarza wszystko ze... +głowę;head;#109;...drobne strączki białe Dziwnie ozdabiał głowę bo od słońca blasku Świecił... +je;eats;#110;...miły Niestare były rączki co je tak rzuciły Tuż i sukienka... +klucznik;steward;#111;...Podobny do zdarzenia dzisiejszej obławy klucznik mówił że tylko znał jednego... +nic;nothing;#112;...Okna bez szyb lecz latem nic to nie zawadzi Bliskość piwnic... +robak;worm;#113;...Jegomość Nic a nic odpowiedział robak obojętnie Widać było że słuchał... +ręką;hand;#114;...miejscach swoich stali Jeden drugiemu ręką dawał znak milczenia A wszyscy... +ziemi;earth;#115;...kogoś co zaledwie dotykał się ziemi Podróżny długo w oknie stał... +ci;you;#116;...wiele Stryjaszek myśli wkrótce sprawić ci wesele Jest z czego wybrać... +dwa;two;#117;...taił inne ważniejsze przyczyny O dwa tysiące kroków zamek stał za... +gdyby;if;#118;...jedno pozostało Puste miejsce jak gdyby na kogoś czekało Stryj nieraz... +podkomorzy;chamberlain;#119;...jutro sam zjechać do dworu podkomorzy już zjechał z żoną i... +ty;you;#120;...i Europy Litwo Ojczyzno moja ty jesteś jak zdrowie Ile cię... +zamku;castle;#121;...tylu tak szanownych gości W zamku sień wielka jeszcze dobrze zachowana... +wszystkie;all;#122;...mógłby spojrzeć bez wstydu królewic wszystkie zacnie zrodzone każda młoda ładna... +krzyknął;he shouted;#123;...kształtu jeśli równie chwytny Chwytny krzyknął pan Rejent mój pies faworytny... +rzecz;thing;#124;...pustym oczy swe osadzał Dziwna rzecz miejsca wkoło są siedzeniem dziewic... +rękę;hand;#125;...synowcem witania Dał mu poważnie rękę do pocałowania I w skroń... +siebie;myself;#126;...naród Bo już sam wewnątrz siebie czuł choroby zaród Krzyczano na... +widać;can be seen;#127;...strzechą zmieścić się nie może widać że okolica obfita we zboże... +ani;or;#128;...Grzeczność nie jest nauką łatwą ani małą Niełatwą bo nie na... +które;which;#129;...świętą Bo nawet wozy w które już składać zaczęto Kopę żyta... +ledwie;hardly;#130;...oczyma rodziców którzy te pogonie ledwie raczyli widzieć cóż kłócić się... +ni;ni;#131;...co chce wytłumaczył Bernardyn odpowiedzieć ni spojrzeć nie raczył Kaptur tylko... +tymczasem;meanwhile;#132;...powrócisz cudem na Ojczyzny łono tymczasem przenoś moją duszę utęsknioną Do... +wtem;suddenly;#133;...chwyciła suknie biegła do zwierciadła wtem ujrzała młodzieńca i z rąk... +zaraz;In a second;#134;...Ofiarowany martwą podniosłem powiekę I zaraz mogłem pieszo do Twych świątyń... +jeden;one;#135;...uciekły I dwie twarze w jeden się rumieniec oblekły Tadeusz by... +was;mustache;#136;...mu zawiążesz wierz mi kląć was kiedyś będzie Zakopać taki talent... +tego;this;#137;...nabadał Na samym końcu dzieje tego dnia powiadał Dobrze mój Tadeuszu... +zawsze;Always;#138;...gości obejrzał porządkiem Bo choć zawsze i płynnie mówił i z... +nich;them;#139;...drobnych śladach zatrzymywał Myślał o nich i czyje były odgadywał Przypadkiem... +nikt;nobody;#140;...pół kroku Tak każe przyzwoitość nikt tam nie rozprawiał O porządku... +wielki;great;#141;...Po której miał przyjść wkrótce wielki post niewola Pamiętam chociaż byłem... +coraz;increasingly;#142;...z Rejentem wzmogła się uparta coraz głośniejsza kłótnia o kusego charta... +czas;time;#143;...robotnik kiedy znijdzie z nieba czas i ziemianinowi ustępować z pola... +długo;long;#144;...zaledwie dotykał się ziemi Podróżny długo w oknie stał patrząc dumając... +nigdy;Never;#145;...zwykła z rana W takim nigdy nie bywa od mężczyzn widziana... +pana;sir;#146;...za domem urządzał wieczerzę On pana zastępuje i on w niebytności... +chciał;he wanted;#147;...młodzieniec oczy zmrużył i przysłonił chciał coś mówić przepraszać tylko się... +dotąd;so far;#148;...wojewoda Niesiołowski stary Który ma dotąd pierwsze na świecie ogary I... +ksiądz;priest;#149;...Pańskim pisano Zakonie I każdy ksiądz toż samo gada na ambonie... +przecież;yet;#150;...a pan może zyska Bo przecież o ten zamek dziś toczy... +ryków;roars;#151;...Moskal był to pan kapitan ryków Stary żołnierz stał w bliskiej... +tyle;so much;#152;...jego proszę Pana Boga Jeślim tyle na jego nie korzystał dworze... +wszystkich;everyone;#153;...przechodniom ogłasza Że gościnna i wszystkich w gościnę zaprasza Właśnie dwukonną... +zaś;and;#154;...był majstrem z Wilna nie zaś Gotem Dość że Hrabia chciał... +mam;I have;#155;...polityka nudzi jeżeli z Warszawy mam list to rzecz zakonna to... +strony;pages;#156;...Gdy tak były zajęte stołu strony obie Tadeusz przyglądał się nieznanej... +sędziego;judge;#157;...pokoiku drugie u stołu Ważna sędziego nauka o grzeczności Podkomorzego uwagi... +bóg;God;#158;...moc ta tłuszcza Bo Pan bóg kiedy karę na naród przypuszcza... +drzwi;door;#159;...bramę We dworze pusto bo drzwi od ganku zamknięto Zaszczepkami i... +gości;guests;#160;...ganek zajechał któryś z nowych gości Już konie w stajnią wzięto... +trzeba;it's necessary to;#161;...jesteś jak zdrowie Ile cię trzeba cenić ten tylko się dowie... +właśnie;Exactly;#162;...i wszystkich w gościnę zaprasza właśnie dwukonną bryką wjechał młody panek... +góry;mountains;#163;...niemałą chętkę do swawoli Z góry już robił projekt że sobie... +niby;kind of;#164;...różowymi wstęgi Pośród nich brylant niby zakryty od oczu Świecił się... +niej;her;#165;...jakby wstęgą miedzą Zieloną na niej z rzadka ciche grusze siedzą... +szlachty;nobility;#166;...myśliwi Widząc że w tylu szlachty w tylu panów gronie Mają... +wtenczas;then;#167;...po łacinie Mężczyznom dano wódkę wtenczas wszyscy siedli I chołodziec litewski... +ów;that;#168;...byle nie w nędzy Jak ów Wespazyjanus nie wąchał pieniędzy I... +dobrze;All right;#169;...zapewne należne do dworu Uprawne dobrze na kształt ogrodowych grządek Że... +każdy;everyone;#170;...i dam nie ustawiał A każdy mimowolnie porządku pilnował Bo Sędzia... +litwie;Lithuania;#171;...Tadeusz czyli ostatni zajazd na litwie ISBN 978 83 288 2495... +pierwszy;first;#172;...pamiętam czasy kiedy do ojczyzny pierwszy raz zawitała moda francuszczyzny Gdy... +stał;steel;#173;...pagórku niewielkim we brzozowym gaju stał dwór szlachecki z drzewa lecz... +stąd;hence;#174;...Objaśniają wrodzone wdzięki i przymioty stąd droga do afektów i stąd... +asesor;assessor;#175;...utrzymywał że on zająca pochwycił asesor zaś dowodził na złość Rejentowi... +dwóch;two;#176;...Stolnik ja pani Kuchmistrz i dwóch kuchcików wszyscy trzej pijani Proboszcz... +której;which one;#177;...ta prędka zmieszana rozmowa W której lat kilku dzieje chciano zamknąć... +my;we;#178;...i rzekł Dziś nowym zwyczajem my na naukę młodzież do stolicy... +serce;heart;#179;...zmieszany i czuł że mu serce biło Głośno i sam nie... +wkoło;around;#180;...wrogów a Praga już się wkoło pali Nawet stary stojący zegar... +wnet;soon;#181;...okiennic szpary I zgasło I wnet sierpy gromadnie dzwoniące We zbożach... +która;which;#182;...progiem Stanęła Podczaszyca dwukolna dryndulka która się po francusku zwała karyjulka... +prawda;True;#183;...by nie zdradzić swego roztargnienia prawda rzekł mój Rejencie prawda bez... +przerwał;interrupted;#184;...końcu z Bonapartą Tu Ryków przerwał i jadł wtem z potrawą... +tych;these;#185;...przenoś moją duszę utęsknioną Do tych pagórków leśnych do tych łąk... +zosia;Zosia;#186;...poznać Prawda bardzo młodzi Szczególnie zosia mała lecz to nic nie... +chociaż;though;#187;...koryta rozlewa Sędzia choć utrudzony chociaż w gronie gości Nie chybił... +cię;you;#188;...ty jesteś jak zdrowie Ile cię trzeba cenić ten tylko się... +koniec;end;#189;...Maleski z Mickiewiczem a na koniec Hrabia Z Soplicą i czytając... +których;which;#190;...zabawia przez rozmowy grzeczne Z których by wychowanie poznano stołeczne To... +okiem;eye;#191;...końca doczekał nareszcie Wbiega i okiem chciwie ściany starodawne Ogląda czule... +rejent;notary;#192;...kusego charta Którego posiadaniem pan rejent się szczycił I utrzymywał że... \ No newline at end of file diff --git a/python_pkg/word_frequency/test_texts/polish_pan_tadeusz_anki_30.txt b/python_pkg/word_frequency/test_texts/polish_pan_tadeusz_anki_30.txt new file mode 100644 index 0000000..603d11d --- /dev/null +++ b/python_pkg/word_frequency/test_texts/polish_pan_tadeusz_anki_30.txt @@ -0,0 +1,33 @@ +#separator:semicolon +#html:true +#deck:polish_pan_tadeusz_vocab_30 +#tags:vocabulary pl +#columns:Front;Back;Rank;Context + +i;and;#1;...modami Początek sporu o Kusego i Sokoła Żale Wojskiego Ostatni Woźny... +się;myself;#3;...pierwsza Gospodarstwo Powrót panicza Spotkanie się pierwsze w pokoiku drugie u... +z;With;#4;...co gród zamkowy Nowogródzki ochraniasz z jego wiernym ludem Jak mnie... +za;for;#14;...do Twych świątyń progu Iść za wrócone życie podziękować Bogu Tak... +nim;him;#29;...trzech mocarzów Albo sam na nim padnie Dalej w polskiej szacie... +mu;him;#34;...Wyszedł zmieszany i czuł że mu serce biło Głośno i sam... +ten;this;#39;...zdrowie Ile cię trzeba cenić ten tylko się dowie Kto cię... +sędzia;judge;#42;...i obrok i siano Bo sędzia nigdy nie chciał według nowej... +przy;by;#54;...I stodołę miał wielką i przy niej trzy stogi Użątku co... +podkomorzy;chamberlain;#120;...jutro sam zjechać do dworu podkomorzy już zjechał z żoną i... +stał;steel;#172;...pagórku niewielkim we brzozowym gaju stał dwór szlachecki z drzewa lecz... +tuż;next door;#205;...rączki co je tak rzuciły tuż i sukienka biała świeżo z... +należy;should;#351;...i z urzędu ten zaszczyt należy Idąc kłaniał się damom starcom... +miejsce;place;#370;...stanęli kołem Podkomorzy najwyższe brał miejsce za stołem Z wieku mu... +kwestarz;collection;#589;...i młodzieży Przy nim stał kwestarz Sędzia tuż przy bernardynie Bernardyn... +stołem;table;#666;...Podkomorzy najwyższe brał miejsce za stołem Z wieku mu i z... +idąc;walking;#667;...z urzędu ten zaszczyt należy idąc kłaniał się damom starcom i... +wieku;century;#735;...dozwalał by chybiano względu Dla wieku urodzenia rozumu urzędu Tym ładem... +urzędu;office;#967;...względu Dla wieku urodzenia rozumu urzędu Tym ładem mawiał domy i... +brał;he took;#1139;...i stanęli kołem Podkomorzy najwyższe brał miejsce za stołem Z wieku... +kłaniał;he bowed;#1140;...urzędu ten zaszczyt należy Idąc kłaniał się damom starcom i młodzieży... +młodzieży;youth;#1141;...kłaniał się damom starcom i młodzieży Przy nim stał kwestarz Sędzia... +damom;ladies;#1355;...zaszczyt należy Idąc kłaniał się damom starcom i młodzieży Przy nim... +kołem;wheel;#1671;...weszli w porządku i stanęli kołem Podkomorzy najwyższe brał miejsce za... +najwyższe;highest;#1672;...porządku i stanęli kołem Podkomorzy najwyższe brał miejsce za stołem Z... +zaszczyt;honor;#2110;...mu i z urzędu ten zaszczyt należy Idąc kłaniał się damom... +starcom;old men;#2111;...należy Idąc kłaniał się damom starcom i młodzieży Przy nim stał... \ No newline at end of file diff --git a/python_pkg/word_frequency/tests/test_anki_generator.py b/python_pkg/word_frequency/tests/test_anki_generator.py new file mode 100644 index 0000000..ab9d36e --- /dev/null +++ b/python_pkg/word_frequency/tests/test_anki_generator.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +"""Tests for the Anki flashcard generator.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +try: + from python_pkg.word_frequency.anki_generator import ( + find_word_contexts, + generate_anki_deck, + generate_flashcards, + get_top_n_words, + main, + parse_vocabulary_curve_output, + ) +except ImportError: + import sys + sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) + from python_pkg.word_frequency.anki_generator import ( + find_word_contexts, + generate_anki_deck, + generate_flashcards, + get_top_n_words, + main, + parse_vocabulary_curve_output, + ) + + +# Test fixtures + + +@pytest.fixture +def sample_vocabulary_output() -> str: + """Sample output from vocabulary_curve.""" + return """====================================================================== +VOCABULARY LEARNING CURVE +====================================================================== + +Total words in text: 100 +Unique words: 50 + +---------------------------------------------------------------------- + +[Length 1] Vocab needed: 1 (+1) + Excerpt: "the" + Words: the(#1) + +[Length 2] Vocab needed: 2 (+1) + Excerpt: "the dog" + Words: the(#1), dog(#2) + +[Length 3] Vocab needed: 5 (+3) + Excerpt: "the quick fox" + Words: the(#1), quick(#3), fox(#5) + +---------------------------------------------------------------------- +""" + + +@pytest.fixture +def sample_text_file(tmp_path: Path) -> Path: + """Create a sample text file.""" + text = """The quick brown fox jumps over the lazy dog. +The fox was very quick and the dog was very lazy. +Quick foxes and lazy dogs are common in stories.""" + filepath = tmp_path / "sample.txt" + filepath.write_text(text, encoding="utf-8") + return filepath + + +# Tests for parse_vocabulary_curve_output + + +class TestParseVocabularyCurveOutput: + """Tests for parsing vocabulary_curve output.""" + + def test_parse_length_1(self, sample_vocabulary_output: str) -> None: + """Test parsing output for length 1.""" + excerpt, words = parse_vocabulary_curve_output(sample_vocabulary_output, 1) + assert excerpt == "the" + assert words == [("the", 1)] + + def test_parse_length_2(self, sample_vocabulary_output: str) -> None: + """Test parsing output for length 2.""" + excerpt, words = parse_vocabulary_curve_output(sample_vocabulary_output, 2) + assert excerpt == "the dog" + assert words == [("the", 1), ("dog", 2)] + + def test_parse_length_3(self, sample_vocabulary_output: str) -> None: + """Test parsing output for length 3.""" + excerpt, words = parse_vocabulary_curve_output(sample_vocabulary_output, 3) + assert excerpt == "the quick fox" + assert len(words) == 3 + assert ("the", 1) in words + assert ("quick", 3) in words + assert ("fox", 5) in words + + def test_parse_nonexistent_length(self, sample_vocabulary_output: str) -> None: + """Test parsing output for non-existent length.""" + excerpt, words = parse_vocabulary_curve_output(sample_vocabulary_output, 100) + assert excerpt == "" + assert words == [] + + +# Tests for find_word_contexts + + +class TestFindWordContexts: + """Tests for finding word contexts.""" + + def test_find_single_word_context(self) -> None: + """Test finding context for a single word.""" + text = "The quick brown fox jumps over the lazy dog" + contexts = find_word_contexts(text, ["fox"], context_words=2) + assert "fox" in contexts + assert "fox" in contexts["fox"].lower() + + def test_find_multiple_word_contexts(self) -> None: + """Test finding contexts for multiple words.""" + text = "The quick brown fox jumps over the lazy dog" + contexts = find_word_contexts(text, ["fox", "dog"], context_words=2) + assert len(contexts) == 2 + assert "fox" in contexts + assert "dog" in contexts + + def test_word_not_found(self) -> None: + """Test when word is not in text.""" + text = "The quick brown fox" + contexts = find_word_contexts(text, ["elephant"], context_words=2) + assert "elephant" not in contexts + + +# Tests for generate_anki_deck + + +class TestGenerateAnkiDeck: + """Tests for generating Anki deck content.""" + + def test_generates_valid_header(self) -> None: + """Test that output contains valid Anki headers.""" + with patch( + "python_pkg.word_frequency.anki_generator.translate_words_batch" + ) as mock_translate: + mock_translate.return_value = [ + MagicMock(success=True, source_word="hello", translated_word="hola") + ] + result = generate_anki_deck( + [("hello", 1)], + source_lang="en", + target_lang="es", + deck_name="TestDeck", + ) + + assert "#separator:semicolon" in result + assert "#deck:TestDeck" in result + assert "#html:true" in result + + def test_generates_flashcard_content(self) -> None: + """Test that output contains flashcard data.""" + with patch( + "python_pkg.word_frequency.anki_generator.translate_words_batch" + ) as mock_translate: + mock_translate.return_value = [ + MagicMock(success=True, source_word="hello", translated_word="hola"), + MagicMock(success=True, source_word="world", translated_word="mundo"), + ] + result = generate_anki_deck( + [("hello", 1), ("world", 2)], + source_lang="en", + target_lang="es", + ) + + # Check that words and translations are present + assert "hello" in result + assert "hola" in result + assert "world" in result + assert "mundo" in result + + def test_includes_rank(self) -> None: + """Test that rank is included in output.""" + with patch( + "python_pkg.word_frequency.anki_generator.translate_words_batch" + ) as mock_translate: + mock_translate.return_value = [ + MagicMock(success=True, source_word="test", translated_word="prueba") + ] + result = generate_anki_deck( + [("test", 42)], + source_lang="en", + target_lang="es", + ) + + assert "#42" in result + + def test_escapes_semicolons(self) -> None: + """Test that semicolons in words are escaped.""" + with patch( + "python_pkg.word_frequency.anki_generator.translate_words_batch" + ) as mock_translate: + mock_translate.return_value = [ + MagicMock( + success=True, source_word="test;word", translated_word="translation" + ) + ] + result = generate_anki_deck( + [("test;word", 1)], + source_lang="en", + target_lang="es", + ) + + # Semicolons should be replaced with commas + assert "test,word" in result + + def test_includes_context_when_requested(self) -> None: + """Test that context is included when requested.""" + with patch( + "python_pkg.word_frequency.anki_generator.translate_words_batch" + ) as mock_translate: + mock_translate.return_value = [ + MagicMock(success=True, source_word="hello", translated_word="hola") + ] + contexts = {"hello": "...say hello to..."} + result = generate_anki_deck( + [("hello", 1)], + source_lang="en", + target_lang="es", + contexts=contexts, + include_context=True, + ) + + assert "Context" in result + assert "say" in result + + def test_no_translate_flag(self) -> None: + """Test that no_translate skips translation.""" + result = generate_anki_deck( + [("hello", 1), ("world", 2)], + source_lang="en", + target_lang="es", + no_translate=True, + ) + + # Should have [TODO] placeholders + assert "[TODO]" in result + assert "hello" in result + assert "world" in result + + +# Tests for get_top_n_words + + +class TestGetTopNWords: + """Tests for getting top N words.""" + + def test_get_top_5_words(self) -> None: + """Test getting top 5 words from text.""" + text = "the cat sat on the mat the cat meowed" + words = get_top_n_words(text, 5) + assert len(words) == 5 + # 'the' appears 3x, 'cat' appears 2x + assert words[0][0] == "the" + assert words[0][1] == 1 + assert words[1][0] == "cat" + assert words[1][1] == 2 + + def test_ranks_are_sequential(self) -> None: + """Test that ranks are 1-based and sequential.""" + text = "one two three four five six seven eight" + words = get_top_n_words(text, 8) + ranks = [r for _, r in words] + assert ranks == [1, 2, 3, 4, 5, 6, 7, 8] + + +# Tests for main function + + +class TestMain: + """Tests for the main CLI function.""" + + def test_missing_file_returns_error(self) -> None: + """Test that missing file returns error code.""" + result = main(["--file", "nonexistent.txt", "--length", "10"]) + assert result == 1 + + def test_help_flag(self, capsys: pytest.CaptureFixture[str]) -> None: + """Test that --help works.""" + with pytest.raises(SystemExit) as exc_info: + main(["--help"]) + assert exc_info.value.code == 0 + + +# Integration tests + + +class TestIntegration: + """Integration tests (require C executable).""" + + def test_generate_flashcards_creates_output( + self, sample_text_file: Path, tmp_path: Path + ) -> None: + """Test that generate_flashcards produces output file.""" + from python_pkg.word_frequency.anki_generator import C_EXECUTABLE + + if not C_EXECUTABLE.exists(): + pytest.skip("C executable not found") + + output_file = tmp_path / "output.txt" + + with patch( + "python_pkg.word_frequency.anki_generator.translate_words_batch" + ) as mock_translate: + # Mock translation to avoid network calls + def mock_translate_fn( + words: list[str], from_lang: str, to_lang: str + ) -> list[MagicMock]: + return [ + MagicMock(success=True, source_word=w, translated_word=f"[{w}]") + for w in words + ] + + mock_translate.side_effect = mock_translate_fn + + result = main( + [ + "--file", + str(sample_text_file), + "--length", + "5", + "--output", + str(output_file), + "--quiet", + ] + ) + + assert result == 0 + assert output_file.exists() + + content = output_file.read_text() + assert "#separator:semicolon" in content + + def test_cli_with_sample_file( + self, sample_text_file: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + """Test CLI with actual file.""" + from python_pkg.word_frequency.anki_generator import C_EXECUTABLE + + if not C_EXECUTABLE.exists(): + pytest.skip("C executable not found") + + output_file = tmp_path / "anki_output.txt" + + with patch( + "python_pkg.word_frequency.anki_generator.translate_words_batch" + ) as mock_translate: + mock_translate.return_value = [ + MagicMock(success=True, source_word="the", translated_word="le") + ] + + result = main( + [ + "--file", + str(sample_text_file), + "--length", + "1", + "--output", + str(output_file), + ] + ) + + assert result == 0 + captured = capsys.readouterr() + assert "FLASHCARD GENERATION COMPLETE" in captured.out + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])