feat: anki generation feature

This commit is contained in:
Krzysztof Rudnicki 2025-12-28 16:48:34 +01:00
parent 272b8c56d0
commit 88cda74a6a
4 changed files with 1110 additions and 0 deletions

View File

@ -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"<b>{word}</b>", 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: <filename>_anki_<length>.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())

View File

@ -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;...modam<b>i</b> Początek sporu o Kusego <b>i</b> Sokoła Żale Wojsk<b>i</b>ego Ostatn<b>i</b> Woźny...
w;In;#2;...Po<b>w</b>rót panicza Spotkanie się pier<b>w</b>sze <b>w</b> pokoiku drugie u stołu <b>w</b>ażna...
się;myself;#3;...pierwsza Gospodarstwo Powrót panicza Spotkanie <b>się</b> pierwsze w pokoiku drugie u...
z;With;#4;...co gród <b>z</b>amkowy Nowogród<b>z</b>ki ochranias<b>z</b> <b>z</b> jego wiernym ludem Jak mnie...
na;on;#5;...Pan Tadeusz czyli ostatni zajazd <b>na</b> Litwie ISBN 978 83 288...
nie;NO;#6;...co pod strzechą zmieścić się <b>nie</b> może Widać że okolica obfita...
jak;How;#7;...Litwo Ojczyzno moja ty jesteś <b>jak</b> zdrowie Ile cię trzeba cenić...
do;down;#8;...wiernym ludem Jak mnie dziecko <b>do</b> zdrowia powróciłaś cudem Gdy od...
a;and;#9;...Gdzie p<b>a</b>nieńskim rumieńcem dzięcielin<b>a</b> p<b>a</b>ł<b>a</b> <b>a</b> wszystko przep<b>a</b>s<b>a</b>ne j<b>a</b>kby wstęgą miedzą...
że;That;#10;...daleka pobielane ściany Tym bielsze <b>że</b> odbite od ciemnej zieleni Topoli...
to;this;#11;...w Petersburgu mieszkała przed laty <b>to</b> nie był ochmistrzyni pokój Fortepiano...
o;about;#12;...u st<b>o</b>łu Ważna Sędzieg<b>o</b> nauka <b>o</b> grzeczn<b>o</b>ści P<b>o</b>dk<b>o</b>m<b>o</b>rzeg<b>o</b> uwagi p<b>o</b>lityczne nad...
za;for;#13;...do Twych świątyń progu Iść <b>za</b> wrócone życie podziękować Bogu Tak...
po;after;#14;...Widzę i opisuję bo tęsknię <b>po</b> tobie Panno święta co Jasnej...
już;Already;#15;...Moskali Siekąc wrogów a Praga <b>już</b> się wkoło pali Nawet stary...
tak;Yes;#16;...za wrócone życie podziękować Bogu <b>tak</b> nas powrócisz cudem na Ojczyzny...
co;What;#17;...tęsknię po tobie Panno święta <b>co</b> Jasnej bronisz Częstochowy I w...
od;From;#18;...do zdrowia powróciłaś cudem Gdy <b>od</b> płaczącej matki p<b>od</b> Twoją opiekę...
lecz;but;#19;...Stał dwór szlachecki z drzewa <b>lecz</b> podmurowany Świeciły się z daleka...
bo;because;#20;...całej ozdobie Widzę i opisuję <b>bo</b> tęsknię po tobie Panno święta...
gdy;When;#21;...dziecko do zdrowia powróciłaś cudem <b>gdy</b> od płaczącej matki pod Twoją...
ja;I;#22;...z talerzem ogórki Rzekł Muszę <b>ja</b> wam służyć moje panny córki...
pan;you;#23;...Adam Mickiewicz <b>pan</b> Tadeusz czyli ostatni zajazd na...
jest;Is;#24;...myśli wkrótce sprawić ci wesele <b>jest</b> z czego wybrać u nas...
ale;But;#25;...stało wody pełne naczynie blaszane <b>ale</b> nigdzie nie widać było ogrodniczki...
był;was;#26;...niebo miecz oburącz trzyma Takim <b>był</b> gdy przysięgał na stopniach ołtarzów...
nim;him;#27;...trzech mocarzów Albo sam na <b>nim</b> padnie Dalej w polskiej szacie...
rzekł;he said;#28;...młodszej przysunąwszy z talerzem ogórki <b>rzekł</b> Muszę ja wam służyć moje...
go;him;#29;...od ciemnej zieleni Topoli co <b>go</b> bronią od wiatrów jesieni Dom...
tylko;Just;#30;...Ile cię trzeba cenić ten <b>tylko</b> się dowie Kto cię stracił...
jako;as;#31;...chciwie ściany starodawne Ogląda czule <b>jako</b> swe znajome dawne Też same...
mnie;me;#32;...z jego wiernym ludem Jak <b>mnie</b> dziecko do zdrowia powróciłaś cudem...
mu;him;#33;...Wyszedł zmieszany i czuł że <b>mu</b> serce biło Głośno i sam...
tu;here;#34;...same portrety na ścianach wisiały <b>tu</b> Kościuszko w czamarce krakowskiej z...
on;he;#35;...teraz za domem urządzał wieczerzę <b>on</b> pana zastępuje i <b>on</b> w...
czy;Whether;#36;...Głośno i sam nie wiedział <b>czy</b> go miało śmieszyć To dziwaczne...
ten;this;#37;...zdrowie Ile cię trzeba cenić <b>ten</b> tylko się dowie Kto cię...
hrabia;count;#38;...panem Hrabią sporu I pan <b>hrabia</b> ma jutro sam zjechać do...
sędzia;judge;#39;...i obrok i siano Bo <b>sędzia</b> nigdy nie chciał według nowej...
tam;there;#40;...żniwo oglądają Pod lasem i <b>tam</b> pewnie na młodzież czekają Pójdziemy...
pod;under;#41;...cudem Gdy od płaczącej matki <b>pod</b> Twoją opiekę Ofiarowany martwą <b>pod</b>niosłem...
aż;until;#42;...Wonnymi powiewami kwiatów oddychając Oblicze <b>aż</b> na krzaki fijołkowe skłonił Oczyma...
dla;For;#43;...zbiera się na sądy graniczne <b>dla</b> skończenia dawnego z panem Hrabią...
u;at;#44;...się pierwsze w pokoik<b>u</b> dr<b>u</b>gie <b>u</b> stoł<b>u</b> Ważna Sędziego na<b>u</b>ka o...
nad;over;#45;...o grzeczności Podkomorzego uwagi polityczne <b>nad</b> modami Początek sporu o Kusego...
więc;So;#46;...nie bywa od mężczyzn widziana <b>więc</b> choć świadka nie miała założyła...
ich;their;#47;...stodoły Cieszą się z niezwyczajnej <b>ich</b> lekkości woły Właśnie z lasu...
tadeusz;tadeusz;#48;...Adam Mickiewicz Pan <b>tadeusz</b> czyli ostatni zajazd na Litwie...
tym;this;#49;...się z daleka pobielane ściany <b>tym</b> bielsze że odbite od ciemnej...
przed;Before;#50;...grusze siedzą Śród takich pól <b>przed</b> laty nad brzegiem ruczaju Na...
jeszcze;still;#51;...było ogrodniczki Tylko co wyszła <b>jeszcze</b> kołyszą się drzwiczki Świeżo trącone...
sam;alone;#52;...z Polski trzech mocarzów Albo <b>sam</b> na nim padnie Dalej w...
przy;by;#53;...I stodołę miał wielką i <b>przy</b> niej trzy stogi Użątku co...
przez;By;#54;...na błonie I wionęła ogrodem <b>przez</b> płotki <b>przez</b> kwiaty I po...
ze;That;#55;...pan Sędzia każe U niego <b>ze</b> dniem kończą pracę gospodar<b>ze</b> Pan...
bez;without;#56;...ślad widać nóżki Na piasku <b>bez</b> trzewika była i pończoszki Na...
gdzie;Where;#57;...rozmaitem Wyzłacanych pszenicą posrebrzanych żytem <b>gdzie</b> bursztynowy świerzop gryka jak śnieg...
jej;her;#58;...parkanie Stała młoda dziewczyna Białe <b>jej</b> ubranie Wysmukłą postać tylko aż...
kto;Who;#59;...cenić ten tylko się dowie <b>kto</b> cię stracił Dziś piękność twą...
ku;to;#60;...ze skoszonej łąki Wszystko bieży <b>ku</b> studni której ramię z drzewa...
wszyscy;all;#61;...łacinie Mężczyznom dano wódkę wtenczas <b>wszyscy</b> siedli I chołodziec litewski milcząc...
wojski;troops;#62;...Słudzy czekają nim się pan <b>wojski</b> ubierze Który teraz za domem...
było;was;#63;...blaszane Ale nigdzie nie widać <b>było</b> ogrodniczki Tylko co wyszła jeszcze...
choć;though;#64;...bywa od mężczyzn widziana Więc <b>choć</b> świadka nie miała założyła ręce...
potem;then;#65;...Naprzód dzieci małe Z dozorcą <b>potem</b> Sędzia szedł z Podkomorzyną Obok...
mi;me;#66;...urzędów Przynajmniej tom skorzystał że <b>mi</b> w moim domu Nikt nigdy...
miał;had;#67;...lecz zewsząd chędogi I stodołę <b>miał</b> wielką i przy niej trzy...
teraz;Now;#68;...się pan Wojski ubierze Który <b>teraz</b> za domem urządzał wieczerzę On...
dziś;today;#69;...się dowie Kto cię stracił <b>dziś</b> piękność twą w całej ozdobie...
jego;his;#70;...gród zamkowy Nowogródzki ochraniasz z <b>jego</b> wiernym ludem Jak mnie dziecko...
by;by;#71;...dziecinną radością pociągnął za sznurek <b>by</b> stary Dąbrowskiego usłyszeć mazurek Biegał...
ją;I;#72;...że niecierpliwa młodzież teraźniejsza Że <b>ją</b> nudzi rzecz długa choć najwymowniejsza...
oczy;Eyes;#73;...i czyje były odgadywał Przypadkiem <b>oczy</b> podniósł i tuż na parkanie...
domu;home;#74;...ogrodowych grządek Że w tym <b>domu</b> dostatek mieszka i porządek Brama...
niech;let;#75;...kłócić się o nie Więc <b>niech</b> jaśnie wielmożny Podkomorzy raczy Odwołać...
może;Maybe;#76;...pod strzechą zmieścić się nie <b>może</b> Widać że okolica obfita we...
kiedy;When;#77;...dziś nagodził Do domu właśnie <b>kiedy</b> mamy panien wiele Stryjaszek myśli...
jeśli;If;#78;...pewnie na młodzież czekają Pójdziemy <b>jeśli</b> zechcesz i wkrótce spotkamy Stryjaszka...
ma;has;#79;...Hrabią sporu I pan Hrabia <b>ma</b> jutro sam zjechać do dworu...
który;which;#80;...nim się pan Wojski ubierze <b>który</b> teraz za domem urządzał wieczerzę...
nas;us;#81;...wrócone życie podziękować Bogu Tak <b>nas</b> powrócisz cudem na Ojczyzny łono...
nawet;even;#82;...Praga już się wkoło pali <b>nawet</b> stary stojący zegar kurantowy W...
znowu;again;#83;...ciekawymi po drożynach gonił I <b>znowu</b> je na drobnych śladach zatrzymywał...
jakby;as if;#84;...dzięcielina pała A wszystko przepasane <b>jakby</b> wstęgą miedzą Zieloną na niej...
wszystko;All;#85;...panieńskim rumieńcem dzięcielina pała A <b>wszystko</b> przepasane jakby wstęgą miedzą Zieloną...
raz;once;#86;...studni której ramię z drzewa <b>raz</b> w<b>raz</b> skrzypi i napój w...
szlachta;gentry;#87;...śmieli otwierać On rzekł Wielmożni <b>szlachta</b> bracia dobrodzieje Forum myśliwskim tylko...
lub;or;#88;...że się nam zdawał małpą <b>lub</b> papugą W wielkiej peruce którą...
tej;this one;#89;...zdumione źrenice Po ścianach w <b>tej</b> komnacie mieszkanie kobiéce Któż by...
we;in;#90;...brzegiem ruczaju Na pagórku niewielkim <b>we</b> brzozowym gaju Stał dwór szlachecki...
cóż;Well;#91;...były pod lasem zwaliska Po <b>cóż</b> te przenosiny Pan Wojski się...
sobie;yourself;#92;...ukłonem Chciała usieść na miejscu <b>sobie</b> zostawionem Trudno było bo krzeseł...
też;Too;#93;...czule jako swe znajome dawne <b>też</b> same widzi sprzęty <b>też</b> same...
albo;or;#94;...wypędzi z Polski trzech mocarzów <b>albo</b> sam na nim padnie Dalej...
gerwazy;gerwazy;#95;...Krzyknąć nim głos Hrabiego usłyszał <b>gerwazy</b> Szlachcic to był służący dawnych...
ręce;hands;#96;...choć świadka nie miała założyła <b>ręce</b> Na piersiach przydawając zasłony sukience...
są;are;#97;...upodobał mury Tłumacząc że gotyckiej <b>są</b> architektury Choć Sędzia z dokumentów...
te;these;#98;...pod lasem zwaliska Po cóż <b>te</b> przenosiny Pan Wojski się krzywił...
będzie;will be;#99;...rana wiedział Że u wieczerzy <b>będzie</b> z mnóstwem gości siedział Pan...
między;between;#100;...po kądzieli A resztę rozdzielono <b>między</b> wierzycieli Zamku żaden wziąć nie...
mój;my;#101;...dzieje tego dnia powiadał Dobrze <b>mój</b> Tadeuszu bo tak nazywano Młodzieńca...
była;was;#102;...nóżki Na piasku bez trzewika <b>była</b> i pończoszki Na piasku drobnym...
głowy;head;#103;...nie przerywał Ale częstym skinieniem <b>głowy</b> potakiwał Sędzia milczał on jeszcze...
telimena;telimena;#104;...sąd pańskiej cioci Choć pani <b>telimena</b> mieszkała w stolicy I bawi...
bardzo;Very;#105;...oko nie zobaczy Bo biegła <b>bardzo</b> szybko suwała się raczéj Jako...
panie;sir;#106;...pani Telimena i panny i <b>panie</b> Słowem zrobim na urząd wielkie...
razem;Together;#107;...z łąk i z pastwisk <b>razem</b> wracało do dworu Tu owiec...
tadeusza;tadeusz;#108;...Otarł prędko jak kochał pana <b>tadeusza</b> W ślad gospodarza wszystko ze...
głowę;head;#109;...drobne strączki białe Dziwnie ozdabiał <b>głowę</b> bo od słońca blasku Świecił...
je;eats;#110;...miły Niestare były rączki co <b>je</b> tak rzuciły Tuż i sukienka...
klucznik;steward;#111;...Podobny do zdarzenia dzisiejszej obławy <b>klucznik</b> mówił że tylko znał jednego...
nic;nothing;#112;...Okna bez szyb lecz latem <b>nic</b> to nie zawadzi Bliskość piw<b>nic</b>...
robak;worm;#113;...Jegomość Nic a nic odpowiedział <b>robak</b> obojętnie Widać było że słuchał...
ręką;hand;#114;...miejscach swoich stali Jeden drugiemu <b>ręką</b> dawał znak milczenia A wszyscy...
ziemi;earth;#115;...kogoś co zaledwie dotykał się <b>ziemi</b> Podróżny długo w oknie stał...
ci;you;#116;...wiele Stryjaszek myśli wkrótce sprawić <b>ci</b> wesele Jest z czego wybrać...
dwa;two;#117;...taił inne ważniejsze przyczyny O <b>dwa</b> tysiące kroków zamek stał za...
gdyby;if;#118;...jedno pozostało Puste miejsce jak <b>gdyby</b> na kogoś czekało Stryj nieraz...
podkomorzy;chamberlain;#119;...jutro sam zjechać do dworu <b>podkomorzy</b> już zjechał z żoną i...
ty;you;#120;...i Europy Litwo Ojczyzno moja <b>ty</b> jesteś jak zdrowie Ile cię...
zamku;castle;#121;...tylu tak szanownych gości W <b>zamku</b> sień wielka jeszcze dobrze zachowana...
wszystkie;all;#122;...mógłby spojrzeć bez wstydu królewic <b>wszystkie</b> zacnie zrodzone każda młoda ładna...
krzyknął;he shouted;#123;...kształtu jeśli równie chwytny Chwytny <b>krzyknął</b> pan Rejent mój pies faworytny...
rzecz;thing;#124;...pustym oczy swe osadzał Dziwna <b>rzecz</b> miejsca wkoło są siedzeniem dziewic...
rękę;hand;#125;...synowcem witania Dał mu poważnie <b>rękę</b> do pocałowania I w skroń...
siebie;myself;#126;...naród Bo już sam wewnątrz <b>siebie</b> czuł choroby zaród Krzyczano na...
widać;can be seen;#127;...strzechą zmieścić się nie może <b>widać</b> że okolica obfita we zboże...
ani;or;#128;...Grzeczność nie jest nauką łatwą <b>ani</b> małą Niełatwą bo nie na...
które;which;#129;...świętą Bo nawet wozy w <b>które</b> już składać zaczęto Kopę żyta...
ledwie;hardly;#130;...oczyma rodziców którzy te pogonie <b>ledwie</b> raczyli widzieć cóż kłócić się...
ni;ni;#131;...co chce wytłumaczył Bernardyn odpowiedzieć <b>ni</b> spojrzeć <b>ni</b>e raczył Kaptur tylko...
tymczasem;meanwhile;#132;...powrócisz cudem na Ojczyzny łono <b>tymczasem</b> przenoś moją duszę utęsknioną Do...
wtem;suddenly;#133;...chwyciła suknie biegła do zwierciadła <b>wtem</b> ujrzała młodzieńca i z rąk...
zaraz;In a second;#134;...Ofiarowany martwą podniosłem powiekę I <b>zaraz</b> mogłem pieszo do Twych świątyń...
jeden;one;#135;...uciekły I dwie twarze w <b>jeden</b> się rumieniec oblekły Tadeusz by...
was;mustache;#136;...mu zawiążesz wierz mi kląć <b>was</b> kiedyś będzie Zakopać taki talent...
tego;this;#137;...nabadał Na samym końcu dzieje <b>tego</b> dnia powiadał Dobrze mój Tadeuszu...
zawsze;Always;#138;...gości obejrzał porządkiem Bo choć <b>zawsze</b> i płynnie mówił i z...
nich;them;#139;...drobnych śladach zatrzymywał Myślał o <b>nich</b> i czyje były odgadywał Przypadkiem...
nikt;nobody;#140;...pół kroku Tak każe przyzwoitość <b>nikt</b> tam nie rozprawiał O porządku...
wielki;great;#141;...Po której miał przyjść wkrótce <b>wielki</b> post niewola Pamiętam chociaż byłem...
coraz;increasingly;#142;...z Rejentem wzmogła się uparta <b>coraz</b> głośniejsza kłótnia o kusego charta...
czas;time;#143;...robotnik kiedy znijdzie z nieba <b>czas</b> i ziemianinowi ustępować z pola...
długo;long;#144;...zaledwie dotykał się ziemi Podróżny <b>długo</b> w oknie stał patrząc dumając...
nigdy;Never;#145;...zwykła z rana W takim <b>nigdy</b> nie bywa od mężczyzn widziana...
pana;sir;#146;...za domem urządzał wieczerzę On <b>pana</b> zastępuje i on w niebytności...
chciał;he wanted;#147;...młodzieniec oczy zmrużył i przysłonił <b>chciał</b> coś mówić przepraszać tylko się...
dotąd;so far;#148;...wojewoda Niesiołowski stary Który ma <b>dotąd</b> pierwsze na świecie ogary I...
ksiądz;priest;#149;...Pańskim pisano Zakonie I każdy <b>ksiądz</b> toż samo gada na ambonie...
przecież;yet;#150;...a pan może zyska Bo <b>przecież</b> o ten zamek dziś toczy...
ryków;roars;#151;...Moskal był to pan kapitan <b>ryków</b> Stary żołnierz stał w bliskiej...
tyle;so much;#152;...jego proszę Pana Boga Jeślim <b>tyle</b> na jego nie korzystał dworze...
wszystkich;everyone;#153;...przechodniom ogłasza Że gościnna i <b>wszystkich</b> w gościnę zaprasza Właśnie dwukonną...
zaś;and;#154;...był majstrem z Wilna nie <b>zaś</b> Gotem Dość że Hrabia chciał...
mam;I have;#155;...polityka nudzi jeżeli z Warszawy <b>mam</b> list to rzecz zakonna to...
strony;pages;#156;...Gdy tak były zajęte stołu <b>strony</b> obie Tadeusz przyglądał się nieznanej...
sędziego;judge;#157;...pokoiku drugie u stołu Ważna <b>sędziego</b> nauka o grzeczności Podkomorzego uwagi...
bóg;God;#158;...moc ta tłuszcza Bo Pan <b>bóg</b> kiedy karę na naród przypuszcza...
drzwi;door;#159;...bramę We dworze pusto bo <b>drzwi</b> od ganku zamknięto Zaszczepkami i...
gości;guests;#160;...ganek zajechał któryś z nowych <b>gości</b> Już konie w stajnią wzięto...
trzeba;it's necessary to;#161;...jesteś jak zdrowie Ile cię <b>trzeba</b> cenić ten tylko się dowie...
właśnie;Exactly;#162;...i wszystkich w gościnę zaprasza <b>właśnie</b> dwukonną bryką wjechał młody panek...
góry;mountains;#163;...niemałą chętkę do swawoli Z <b>góry</b> już robił projekt że sobie...
niby;kind of;#164;...różowymi wstęgi Pośród nich brylant <b>niby</b> zakryty od oczu Świecił się...
niej;her;#165;...jakby wstęgą miedzą Zieloną na <b>niej</b> z rzadka ciche grusze siedzą...
szlachty;nobility;#166;...myśliwi Widząc że w tylu <b>szlachty</b> w tylu panów gronie Mają...
wtenczas;then;#167;...po łacinie Mężczyznom dano wódkę <b>wtenczas</b> wszyscy siedli I chołodziec litewski...
ów;that;#168;...byle nie w nędzy Jak <b>ów</b> Wespazyjanus nie wąchał pieniędzy I...
dobrze;All right;#169;...zapewne należne do dworu Uprawne <b>dobrze</b> na kształt ogrodowych grządek Że...
każdy;everyone;#170;...i dam nie ustawiał A <b>każdy</b> mimowolnie porządku pilnował Bo Sędzia...
litwie;Lithuania;#171;...Tadeusz czyli ostatni zajazd na <b>litwie</b> ISBN 978 83 288 2495...
pierwszy;first;#172;...pamiętam czasy kiedy do ojczyzny <b>pierwszy</b> raz zawitała moda francuszczyzny Gdy...
stał;steel;#173;...pagórku niewielkim we brzozowym gaju <b>stał</b> dwór szlachecki z drzewa lecz...
stąd;hence;#174;...Objaśniają wrodzone wdzięki i przymioty <b>stąd</b> droga do afektów i <b>stąd</b>...
asesor;assessor;#175;...utrzymywał że on zająca pochwycił <b>asesor</b> zaś dowodził na złość Rejentowi...
dwóch;two;#176;...Stolnik ja pani Kuchmistrz i <b>dwóch</b> kuchcików wszyscy trzej pijani Proboszcz...
której;which one;#177;...ta prędka zmieszana rozmowa W <b>której</b> lat kilku dzieje chciano zamknąć...
my;we;#178;...i rzekł Dziś nowym zwyczajem <b>my</b> na naukę młodzież do stolicy...
serce;heart;#179;...zmieszany i czuł że mu <b>serce</b> biło Głośno i sam nie...
wkoło;around;#180;...wrogów a Praga już się <b>wkoło</b> pali Nawet stary stojący zegar...
wnet;soon;#181;...okiennic szpary I zgasło I <b>wnet</b> sierpy gromadnie dzwoniące We zbożach...
która;which;#182;...progiem Stanęła Podczaszyca dwukolna dryndulka <b>która</b> się po francusku zwała karyjulka...
prawda;True;#183;...by nie zdradzić swego roztargnienia <b>prawda</b> rzekł mój Rejencie <b>prawda</b> bez...
przerwał;interrupted;#184;...końcu z Bonapartą Tu Ryków <b>przerwał</b> i jadł wtem z potrawą...
tych;these;#185;...przenoś moją duszę utęsknioną Do <b>tych</b> pagórków leśnych do <b>tych</b> łąk...
zosia;Zosia;#186;...poznać Prawda bardzo młodzi Szczególnie <b>zosia</b> mała lecz to nic nie...
chociaż;though;#187;...koryta rozlewa Sędzia choć utrudzony <b>chociaż</b> w gronie gości Nie chybił...
cię;you;#188;...ty jesteś jak zdrowie Ile <b>cię</b> trzeba cenić ten tylko się...
koniec;end;#189;...Maleski z Mickiewiczem a na <b>koniec</b> Hrabia Z Soplicą i czytając...
których;which;#190;...zabawia przez rozmowy grzeczne Z <b>których</b> by wychowanie poznano stołeczne To...
okiem;eye;#191;...końca doczekał nareszcie Wbiega i <b>okiem</b> chciwie ściany starodawne Ogląda czule...
rejent;notary;#192;...kusego charta Którego posiadaniem pan <b>rejent</b> się szczycił I utrzymywał że...

View File

@ -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;...modam<b>i</b> Początek sporu o Kusego <b>i</b> Sokoła Żale Wojsk<b>i</b>ego Ostatn<b>i</b> Woźny...
się;myself;#3;...pierwsza Gospodarstwo Powrót panicza Spotkanie <b>się</b> pierwsze w pokoiku drugie u...
z;With;#4;...co gród <b>z</b>amkowy Nowogród<b>z</b>ki ochranias<b>z</b> <b>z</b> jego wiernym ludem Jak mnie...
za;for;#14;...do Twych świątyń progu Iść <b>za</b> wrócone życie podziękować Bogu Tak...
nim;him;#29;...trzech mocarzów Albo sam na <b>nim</b> padnie Dalej w polskiej szacie...
mu;him;#34;...Wyszedł zmieszany i czuł że <b>mu</b> serce biło Głośno i sam...
ten;this;#39;...zdrowie Ile cię trzeba cenić <b>ten</b> tylko się dowie Kto cię...
sędzia;judge;#42;...i obrok i siano Bo <b>sędzia</b> nigdy nie chciał według nowej...
przy;by;#54;...I stodołę miał wielką i <b>przy</b> niej trzy stogi Użątku co...
podkomorzy;chamberlain;#120;...jutro sam zjechać do dworu <b>podkomorzy</b> już zjechał z żoną i...
stał;steel;#172;...pagórku niewielkim we brzozowym gaju <b>stał</b> dwór szlachecki z drzewa lecz...
tuż;next door;#205;...rączki co je tak rzuciły <b>tuż</b> i sukienka biała świeżo z...
należy;should;#351;...i z urzędu ten zaszczyt <b>należy</b> Idąc kłaniał się damom starcom...
miejsce;place;#370;...stanęli kołem Podkomorzy najwyższe brał <b>miejsce</b> za stołem Z wieku mu...
kwestarz;collection;#589;...i młodzieży Przy nim stał <b>kwestarz</b> Sędzia tuż przy bernardynie Bernardyn...
stołem;table;#666;...Podkomorzy najwyższe brał miejsce za <b>stołem</b> Z wieku mu i z...
idąc;walking;#667;...z urzędu ten zaszczyt należy <b>idąc</b> kłaniał się damom starcom i...
wieku;century;#735;...dozwalał by chybiano względu Dla <b>wieku</b> urodzenia rozumu urzędu Tym ładem...
urzędu;office;#967;...względu Dla wieku urodzenia rozumu <b>urzędu</b> Tym ładem mawiał domy i...
brał;he took;#1139;...i stanęli kołem Podkomorzy najwyższe <b>brał</b> miejsce za stołem Z wieku...
kłaniał;he bowed;#1140;...urzędu ten zaszczyt należy Idąc <b>kłaniał</b> się damom starcom i młodzieży...
młodzieży;youth;#1141;...kłaniał się damom starcom i <b>młodzieży</b> Przy nim stał kwestarz Sędzia...
damom;ladies;#1355;...zaszczyt należy Idąc kłaniał się <b>damom</b> starcom i młodzieży Przy nim...
kołem;wheel;#1671;...weszli w porządku i stanęli <b>kołem</b> Podkomorzy najwyższe brał miejsce za...
najwyższe;highest;#1672;...porządku i stanęli kołem Podkomorzy <b>najwyższe</b> brał miejsce za stołem Z...
zaszczyt;honor;#2110;...mu i z urzędu ten <b>zaszczyt</b> należy Idąc kłaniał się damom...
starcom;old men;#2111;...należy Idąc kłaniał się damom <b>starcom</b> i młodzieży Przy nim stał...

View File

@ -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"])