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 aż 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 ją 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 są 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"])