| from __future__ import annotations |
|
|
| import json |
| from pathlib import Path |
| from types import SimpleNamespace |
|
|
| import pytest |
| import study_pack |
|
|
| from study_pack import ( |
| DEFAULT_SENTENCE_COUNT, |
| MAX_SENTENCE_COUNT, |
| MIN_SENTENCE_COUNT, |
| MODAL_TTS_MODEL, |
| MODAL_TTS_VOICE, |
| SENTENCES_PER_AUDIO_FILE, |
| TARGET_LANGUAGE, |
| TRANSLATION_MODEL, |
| ModalTTSClient, |
| SentenceCard, |
| StudyRoutineStep, |
| build_results_rows, |
| create_study_pack, |
| extract_json_payload, |
| generate_sentence_cards, |
| get_model_stack_summary, |
| get_native_language_choices, |
| get_supported_language_labels, |
| get_tts_backend_config, |
| normalize_plan, |
| translate_sentence_cards, |
| validate_sentence_count, |
| warmup_tts_backend, |
| ) |
|
|
|
|
| def test_extract_json_payload_reads_fenced_json() -> None: |
| raw = """Here you go. |
| |
| ```json |
| {"sentences": [{"source_sentence": "Hello", "target_sentence": "Bonjour"}]} |
| ``` |
| """ |
| payload = extract_json_payload(raw) |
| assert payload["sentences"][0]["target_sentence"] == "Bonjour" |
|
|
|
|
| def test_supported_languages_are_multilingual() -> None: |
| assert get_supported_language_labels() == [ |
| "English", |
| "French", |
| "Spanish", |
| "German", |
| "Italian", |
| "Portuguese", |
| "Japanese", |
| ] |
| assert get_native_language_choices() == [ |
| "English", |
| "French", |
| "Spanish", |
| "German", |
| "Portuguese", |
| "Italian", |
| "Japanese", |
| ] |
|
|
|
|
| def test_normalize_plan_dedupes_by_target_sentence_and_reads_routine() -> None: |
| payload = { |
| "rationale": "Use daily situations.", |
| "assumptions": ["The learner wants portable phrases."], |
| "focus_verbs": ["need", "go", "be", "be able to"], |
| "study_routine": [ |
| {"title": "Preview", "minutes": 10, "instructions": "Scan verbs."}, |
| {"title": "Listen", "minutes": 20, "instructions": "Shadow the audio."}, |
| {"title": "Speak", "minutes": 15, "instructions": "Recall from prompts."}, |
| ], |
| "sentences": [ |
| { |
| "scenario": "Shop", |
| "source_sentence": "I need a bag.", |
| "target_sentence": "J'ai besoin d'un sac.", |
| "verb_lemma": "need", |
| "why_it_is_useful": "Very common request.", |
| "pronunciation_hint": "", |
| }, |
| { |
| "scenario": "Shop", |
| "source_sentence": "I need a bag.", |
| "target_sentence": "J'ai besoin d'un sac.", |
| "verb_lemma": "to need", |
| "why_it_is_useful": "Duplicate example.", |
| "pronunciation_hint": "", |
| }, |
| { |
| "scenario": "Travel", |
| "source_sentence": "Where are we going?", |
| "target_sentence": "Ou allons-nous ?", |
| "verb_lemma": "go", |
| "why_it_is_useful": "Core travel verb.", |
| "pronunciation_hint": "", |
| }, |
| { |
| "scenario": "Home", |
| "source_sentence": "I am ready.", |
| "target_sentence": "Je suis pret.", |
| "verb_lemma": "be", |
| "why_it_is_useful": "Basic state sentence.", |
| "pronunciation_hint": "", |
| }, |
| { |
| "scenario": "Cafe", |
| "source_sentence": "Can I pay now?", |
| "target_sentence": "Je peux payer maintenant ?", |
| "verb_lemma": "pay", |
| "why_it_is_useful": "Payment and permission.", |
| "pronunciation_hint": "", |
| }, |
| ], |
| } |
|
|
| plan = normalize_plan(payload, sentence_count=4) |
| assert plan.rationale == "Use daily situations." |
| assert plan.assumptions == ["The learner wants portable phrases."] |
| assert plan.focus_verbs == ["to need", "to go", "to be", "to be able to"] |
| assert [step.minutes for step in plan.routine_steps] == [10, 20, 15] |
| assert len(plan.cards) == 4 |
| assert [card.verb_lemma for card in plan.cards] == ["to need", "to go", "to be", "to pay"] |
|
|
|
|
| def test_modal_tts_client_posts_sentence_lists_and_slow_audio() -> None: |
| captured: dict[str, object] = {} |
|
|
| def fake_transport(url: str, payload: bytes, headers: dict[str, str], timeout: float) -> bytes: |
| captured["url"] = url |
| captured["payload"] = json.loads(payload.decode("utf-8")) |
| captured["headers"] = headers |
| captured["timeout"] = timeout |
| return b"ID3fake-mp3" |
|
|
| client = ModalTTSClient( |
| base_url="https://tts.example.com", |
| auth_token="secret-token", |
| timeout_seconds=45.0, |
| transport=fake_transport, |
| ) |
| audio = client.synthesize_track(["Bonjour.", "Comment allez-vous ?"], slow_audio=True) |
|
|
| assert audio == b"ID3fake-mp3" |
| assert captured["url"] == "https://tts.example.com/synthesize-track" |
| assert captured["payload"] == { |
| "sentences": ["Bonjour.", "Comment allez-vous ?"], |
| "language": "fr", |
| "slow_audio": True, |
| } |
| assert captured["headers"] == { |
| "Accept": "audio/mpeg", |
| "Content-Type": "application/json", |
| "Authorization": "Bearer secret-token", |
| } |
| assert captured["timeout"] == 45.0 |
|
|
|
|
| def test_modal_tts_client_posts_warmup_request() -> None: |
| captured: dict[str, object] = {} |
|
|
| def fake_json_transport( |
| url: str, |
| payload: bytes, |
| headers: dict[str, str], |
| timeout: float, |
| ) -> dict[str, object]: |
| captured["url"] = url |
| captured["payload"] = json.loads(payload.decode("utf-8")) |
| captured["headers"] = headers |
| captured["timeout"] = timeout |
| return {"status": "warmed", "language": "fr", "warmed_workers": 3} |
|
|
| client = ModalTTSClient( |
| base_url="https://tts.example.com", |
| auth_token="secret-token", |
| timeout_seconds=45.0, |
| json_transport=fake_json_transport, |
| ) |
|
|
| response = client.warmup() |
|
|
| assert response == {"status": "warmed", "language": "fr", "warmed_workers": 3} |
| assert captured["url"] == "https://tts.example.com/warmup" |
| assert captured["payload"] == {"language": "fr"} |
| assert captured["headers"] == { |
| "Accept": "application/json", |
| "Content-Type": "application/json", |
| "Authorization": "Bearer secret-token", |
| } |
| assert captured["timeout"] == 45.0 |
|
|
|
|
| def test_warmup_tts_backend_uses_modal_client(monkeypatch) -> None: |
| captured: list[str] = [] |
|
|
| class StubClient: |
| def warmup(self) -> dict[str, object]: |
| captured.append("warmed") |
| return {"status": "warmed"} |
|
|
| monkeypatch.setattr(study_pack, "get_modal_tts_client", lambda target_language: StubClient()) |
|
|
| response = warmup_tts_backend("French") |
|
|
| assert captured == ["warmed"] |
| assert response == {"status": "warmed"} |
|
|
|
|
| def test_get_tts_backend_config_uses_language_specific_env(monkeypatch) -> None: |
| monkeypatch.setenv("MODAL_TTS_BASE_URL_ES", "https://spanish-tts.example.com") |
| monkeypatch.setenv("MODAL_TTS_AUTH_TOKEN_ES", "spanish-token") |
| monkeypatch.setenv("MODAL_TTS_MODEL_ES", "facebook/mms-tts-spa") |
| monkeypatch.setenv("MODAL_TTS_VOICE_ES", "es-voice") |
| monkeypatch.setenv("MODAL_TTS_PARAMS_ES", "123456789") |
|
|
| backend = get_tts_backend_config("Spanish") |
|
|
| assert backend.base_url == "https://spanish-tts.example.com" |
| assert backend.auth_token == "spanish-token" |
| assert backend.model_label == "facebook/mms-tts-spa" |
| assert backend.voice_label == "es-voice" |
| assert backend.params == 123456789 |
|
|
|
|
| def test_get_tts_backend_config_uses_historical_defaults() -> None: |
| spanish_backend = get_tts_backend_config("Spanish") |
| german_backend = get_tts_backend_config("German") |
| japanese_backend = get_tts_backend_config("Japanese") |
|
|
| assert spanish_backend.model_label == "hexgrad/Kokoro-82M" |
| assert spanish_backend.voice_label == "ef_dora" |
| assert german_backend.model_label == "facebook/mms-tts-deu" |
| assert german_backend.voice_label == "checkpoint default" |
| assert japanese_backend.model_label == "hexgrad/Kokoro-82M" |
| assert japanese_backend.voice_label == "jf_alpha" |
|
|
|
|
| def test_validate_sentence_count_accepts_values_within_new_range() -> None: |
| assert validate_sentence_count(DEFAULT_SENTENCE_COUNT) == DEFAULT_SENTENCE_COUNT |
| assert validate_sentence_count(MIN_SENTENCE_COUNT) == MIN_SENTENCE_COUNT |
| assert validate_sentence_count(MAX_SENTENCE_COUNT) == MAX_SENTENCE_COUNT |
|
|
|
|
| def test_validate_sentence_count_rejects_values_outside_new_range() -> None: |
| with pytest.raises(ValueError, match=f"{MIN_SENTENCE_COUNT} and {MAX_SENTENCE_COUNT}"): |
| validate_sentence_count(MIN_SENTENCE_COUNT - 1) |
|
|
| with pytest.raises(ValueError, match=f"{MIN_SENTENCE_COUNT} and {MAX_SENTENCE_COUNT}"): |
| validate_sentence_count(MAX_SENTENCE_COUNT + 1) |
|
|
|
|
| def test_default_tts_writer_converts_legacy_wav_responses_to_mp3( |
| monkeypatch: pytest.MonkeyPatch, tmp_path: Path |
| ) -> None: |
| converted_inputs: list[bytes] = [] |
|
|
| def fake_convert(wav_bytes: bytes) -> bytes: |
| converted_inputs.append(wav_bytes) |
| return b"ID3converted-mp3" |
|
|
| monkeypatch.setattr(study_pack, "_convert_wav_bytes_to_mp3", fake_convert) |
|
|
| client = ModalTTSClient( |
| base_url="https://tts.example.com", |
| auth_token="", |
| transport=lambda *_args: b"RIFF\x00\x00\x00\x00WAVElegacy", |
| ) |
| destination = tmp_path / "legacy.mp3" |
|
|
| backend_label = study_pack.default_tts_writer( |
| ["Bonjour."], |
| destination, |
| slow_audio=False, |
| target_language=TARGET_LANGUAGE, |
| client=client, |
| ) |
|
|
| assert converted_inputs == [b"RIFF\x00\x00\x00\x00WAVElegacy"] |
| assert destination.read_bytes() == b"ID3converted-mp3" |
| assert backend_label == f"Modal ({client.model_label})" |
|
|
|
|
| def test_create_study_pack_writes_bundle_and_zip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| monkeypatch.setattr(study_pack, "build_artifact_timestamp", lambda: "20260614_213000") |
|
|
| cards = [ |
| SentenceCard( |
| scenario="Groceries", |
| source_sentence="I need apples.", |
| target_sentence="J'ai besoin de pommes.", |
| verb_lemma="to need", |
| why_it_is_useful="Shopping staple.", |
| pronunciation_hint="zhay buh-ZWAN duh pom", |
| ), |
| SentenceCard( |
| scenario="Transit", |
| source_sentence="Where does this bus go?", |
| target_sentence="Ce bus va ou ?", |
| verb_lemma="to go", |
| why_it_is_useful="Travel question.", |
| pronunciation_hint="suh boos va oo", |
| ), |
| ] |
|
|
| calls: list[tuple[list[str], bool, str]] = [] |
|
|
| def fake_tts_writer( |
| sentences: list[str], |
| destination: Path, |
| slow_audio: bool, |
| target_language: str, |
| ) -> None: |
| calls.append((sentences, slow_audio, target_language)) |
| destination.write_bytes(b"RIFFfakewav") |
|
|
| bundle = create_study_pack( |
| cards=cards, |
| target_language=TARGET_LANGUAGE, |
| focus_verbs=["to need", "to go"], |
| routine_steps=[ |
| StudyRoutineStep(title="Preview", minutes=10, instructions="Scan verbs."), |
| StudyRoutineStep(title="Listen", minutes=20, instructions="Shadow audio."), |
| StudyRoutineStep(title="Speak", minutes=15, instructions="Recall from prompts."), |
| ], |
| output_root=tmp_path, |
| tts_writer=fake_tts_writer, |
| ) |
|
|
| assert bundle.preview_audio_path.exists() |
| assert bundle.preview_audio_path.suffix == ".mp3" |
| assert bundle.preview_audio_path.name == "01_sentences_01_02_20260614_213000.mp3" |
| assert bundle.zip_path.exists() |
| assert bundle.zip_path.name == "daily_language_pack_20260614_213000.zip" |
| assert len(bundle.audio_paths) == 1 |
| assert (bundle.session_dir / "study_pack.csv").exists() |
| assert (bundle.session_dir / "study_pack.json").exists() |
| assert (bundle.session_dir / "daily_routine.md").exists() |
| assert (bundle.session_dir / "focus_verbs.txt").exists() |
| assert calls == [(["J'ai besoin de pommes.", "Ce bus va ou ?"], False, TARGET_LANGUAGE)] |
|
|
| payload = json.loads((bundle.session_dir / "study_pack.json").read_text(encoding="utf-8")) |
| assert payload[0]["target_sentence"] == "J'ai besoin de pommes." |
| summary_text = (bundle.session_dir / "README.txt").read_text(encoding="utf-8") |
| assert MODAL_TTS_MODEL in summary_text |
| assert MODAL_TTS_VOICE in summary_text |
|
|
|
|
| def test_create_study_pack_batches_every_twenty_sentences(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| monkeypatch.setattr(study_pack, "build_artifact_timestamp", lambda: "20260614_213500") |
|
|
| cards = [ |
| SentenceCard( |
| scenario=f"Scenario {index}", |
| source_sentence=f"Source sentence {index}", |
| target_sentence=f"Target sentence {index}", |
| verb_lemma=f"verb_{index}", |
| why_it_is_useful="Useful daily sentence.", |
| ) |
| for index in range(1, SENTENCES_PER_AUDIO_FILE + 2) |
| ] |
|
|
| calls: list[tuple[list[str], str]] = [] |
|
|
| def fake_tts_writer( |
| sentences: list[str], |
| destination: Path, |
| slow_audio: bool, |
| target_language: str, |
| ) -> None: |
| calls.append((sentences, target_language)) |
| destination.write_bytes(b"RIFFfakewav") |
|
|
| bundle = create_study_pack( |
| cards=cards, |
| target_language=TARGET_LANGUAGE, |
| output_root=tmp_path, |
| tts_writer=fake_tts_writer, |
| ) |
|
|
| assert len(bundle.audio_paths) == 2 |
| assert bundle.audio_paths[0].name == "01_sentences_01_20_20260614_213500.mp3" |
| assert bundle.audio_paths[1].name == "02_sentences_21_21_20260614_213500.mp3" |
| assert bundle.zip_path.name == "daily_language_pack_20260614_213500.zip" |
| assert len(calls[0][0]) == SENTENCES_PER_AUDIO_FILE |
| assert calls[0][1] == TARGET_LANGUAGE |
| assert calls[1] == (["Target sentence 21"], TARGET_LANGUAGE) |
|
|
|
|
| def test_create_study_pack_rejects_unsupported_target_language(tmp_path: Path) -> None: |
| cards = [ |
| SentenceCard( |
| scenario="Transit", |
| source_sentence="Where is the station?", |
| target_sentence="Ou est la gare ?", |
| verb_lemma="etre", |
| why_it_is_useful="Travel question.", |
| ) |
| ] |
|
|
| with pytest.raises(ValueError, match="Unsupported target language"): |
| create_study_pack(cards=cards, target_language="Dutch", output_root=tmp_path) |
|
|
|
|
| def test_model_stack_summary_mentions_qwen_tiny_aya_and_kyutai() -> None: |
| summary = get_model_stack_summary() |
| assert "Qwen/Qwen3-8B" in summary |
| assert "CohereLabs/tiny-aya-global" in summary |
| assert "kyutai/tts-1.6b-en_fr" in summary |
|
|
|
|
| def test_model_stack_summary_uses_selected_target_language_model(monkeypatch) -> None: |
| monkeypatch.setenv("MODAL_TTS_MODEL_ES", "facebook/mms-tts-spa") |
| summary = get_model_stack_summary("Spanish") |
| assert "facebook/mms-tts-spa" in summary |
|
|
|
|
| def test_build_results_rows_uses_four_columns_with_infinitive_verbs() -> None: |
| rows = build_results_rows( |
| [ |
| SentenceCard( |
| scenario="Travel", |
| source_sentence="I need to go now.", |
| target_sentence="Je dois partir maintenant.", |
| verb_lemma="to go", |
| why_it_is_useful="Common daily verb.", |
| pronunciation_hint="", |
| ) |
| ] |
| ) |
|
|
| assert rows == [["Travel", "I need to go now.", "Je dois partir maintenant.", "to go"]] |
|
|
|
|
| def test_generate_sentence_cards_rejects_unsupported_target_language() -> None: |
| with pytest.raises(ValueError, match="Unsupported target language"): |
| generate_sentence_cards( |
| use_cases="I need Spanish for errands and daily conversations.", |
| target_language="Dutch", |
| native_language="English", |
| sentence_count=20, |
| client=SimpleNamespace(), |
| ) |
|
|
|
|
| def test_generate_sentence_cards_retries_until_requested_count(monkeypatch) -> None: |
| first_batch = { |
| "rationale": "Use daily situations.", |
| "assumptions": ["The learner wants practical phrases."], |
| "focus_verbs": ["aller", "payer", "demander"], |
| "study_routine": [ |
| {"title": "Preview", "minutes": 10, "instructions": "Scan verbs."}, |
| {"title": "Listen", "minutes": 20, "instructions": "Shadow audio."}, |
| {"title": "Speak", "minutes": 15, "instructions": "Recall from prompts."}, |
| ], |
| "sentences": [ |
| { |
| "scenario": f"Scenario {index}", |
| "source_sentence": f"Source {index}", |
| "target_sentence": "Target 1" if index == 20 else f"Target {index}", |
| "verb_lemma": f"verb_{index}", |
| "why_it_is_useful": "Useful daily sentence.", |
| "pronunciation_hint": "", |
| } |
| for index in range(1, 21) |
| ], |
| } |
| top_up_batch = { |
| "sentences": [ |
| { |
| "scenario": f"Top up {index}", |
| "source_sentence": f"Top source {index}", |
| "target_sentence": f"Top target {index}", |
| "verb_lemma": f"top_verb_{index}", |
| "why_it_is_useful": "Top-up sentence.", |
| "pronunciation_hint": "", |
| } |
| for index in range(1, 11) |
| ] |
| } |
|
|
| class StubClient: |
| def __init__(self) -> None: |
| self.calls = 0 |
|
|
| def chat_completion(self, **_: object) -> SimpleNamespace: |
| self.calls += 1 |
| payload = first_batch if self.calls == 1 else top_up_batch |
| return SimpleNamespace( |
| choices=[SimpleNamespace(message=SimpleNamespace(content=json.dumps(payload)))] |
| ) |
|
|
| monkeypatch.setattr(study_pack, "HF_GENERATION_MODEL", "Qwen/Qwen3-8B") |
| monkeypatch.setattr(study_pack, "translate_sentence_cards", lambda cards, **_: cards) |
|
|
| plan = generate_sentence_cards( |
| use_cases="I need French for errands and daily conversations.", |
| target_language=TARGET_LANGUAGE, |
| native_language="English", |
| sentence_count=20, |
| client=StubClient(), |
| ) |
|
|
| assert len(plan.cards) == 20 |
| assert plan.cards[-1].target_sentence == "Top target 1" |
|
|
|
|
| def test_generate_sentence_cards_keeps_small_usable_batches(monkeypatch) -> None: |
| def build_batch(prefix: str, count: int) -> dict[str, object]: |
| return { |
| "rationale": "Use daily situations.", |
| "assumptions": ["The learner wants practical phrases."], |
| "focus_verbs": ["work", "buy", "ask"], |
| "study_routine": [ |
| {"title": "Preview", "minutes": 10, "instructions": "Scan verbs."}, |
| {"title": "Listen", "minutes": 20, "instructions": "Shadow audio."}, |
| {"title": "Speak", "minutes": 15, "instructions": "Recall from prompts."}, |
| ], |
| "sentences": [ |
| { |
| "scenario": f"{prefix} scenario {index}", |
| "source_sentence": f"{prefix} source {index}", |
| "target_sentence": f"{prefix} target {index}", |
| "verb_lemma": f"{prefix} verb {index}", |
| "why_it_is_useful": "Useful daily sentence.", |
| "pronunciation_hint": "", |
| } |
| for index in range(1, count + 1) |
| ], |
| } |
|
|
| batches = [ |
| build_batch("first", 3), |
| build_batch("second", 4), |
| build_batch("third", 3), |
| ] |
|
|
| class StubClient: |
| def __init__(self) -> None: |
| self.calls = 0 |
|
|
| def chat_completion(self, **_: object) -> SimpleNamespace: |
| payload = batches[self.calls] |
| self.calls += 1 |
| return SimpleNamespace( |
| choices=[SimpleNamespace(message=SimpleNamespace(content=json.dumps(payload)))] |
| ) |
|
|
| client = StubClient() |
| monkeypatch.setattr(study_pack, "HF_GENERATION_MODEL", "Qwen/Qwen3-8B") |
| monkeypatch.setattr(study_pack, "translate_sentence_cards", lambda cards, **_: cards) |
|
|
| plan = generate_sentence_cards( |
| use_cases="I need French for errands and daily conversations.", |
| target_language=TARGET_LANGUAGE, |
| native_language="English", |
| sentence_count=10, |
| client=client, |
| ) |
|
|
| assert client.calls == 3 |
| assert len(plan.cards) == 10 |
| assert plan.cards[0].target_sentence == "first target 1" |
| assert plan.cards[-1].target_sentence == "third target 3" |
|
|
|
|
| def test_generate_sentence_cards_reads_text_from_content_blocks(monkeypatch) -> None: |
| payload = { |
| "rationale": "Use daily situations.", |
| "assumptions": ["The learner wants practical phrases."], |
| "focus_verbs": ["aller", "payer", "demander", "prendre"], |
| "study_routine": [ |
| {"title": "Preview", "minutes": 10, "instructions": "Scan verbs."}, |
| {"title": "Listen", "minutes": 20, "instructions": "Shadow audio."}, |
| {"title": "Speak", "minutes": 15, "instructions": "Recall from prompts."}, |
| ], |
| "sentences": [ |
| { |
| "scenario": f"Scenario {index}", |
| "source_sentence": f"Source {index}", |
| "target_sentence": f"Target {index}", |
| "verb_lemma": f"verb_{index}", |
| "why_it_is_useful": "Useful daily sentence.", |
| "pronunciation_hint": "", |
| } |
| for index in range(1, 11) |
| ], |
| } |
|
|
| class StubClient: |
| def chat_completion(self, **_: object) -> SimpleNamespace: |
| return SimpleNamespace( |
| choices=[ |
| SimpleNamespace( |
| message=SimpleNamespace( |
| content=[{"type": "text", "text": json.dumps(payload)}] |
| ) |
| ) |
| ] |
| ) |
|
|
| monkeypatch.setattr(study_pack, "translate_sentence_cards", lambda cards, **_: cards) |
|
|
| plan = generate_sentence_cards( |
| use_cases="I need French for errands and daily conversations.", |
| target_language=TARGET_LANGUAGE, |
| native_language="English", |
| sentence_count=10, |
| client=StubClient(), |
| ) |
|
|
| assert len(plan.cards) == 10 |
| assert plan.cards[0].target_sentence == "Target 1" |
|
|
|
|
| def test_generate_sentence_cards_retries_after_empty_text_output(monkeypatch) -> None: |
| payload = { |
| "rationale": "Use daily situations.", |
| "assumptions": ["The learner wants practical phrases."], |
| "focus_verbs": ["aller", "payer", "demander", "prendre"], |
| "study_routine": [ |
| {"title": "Preview", "minutes": 10, "instructions": "Scan verbs."}, |
| {"title": "Listen", "minutes": 20, "instructions": "Shadow audio."}, |
| {"title": "Speak", "minutes": 15, "instructions": "Recall from prompts."}, |
| ], |
| "sentences": [ |
| { |
| "scenario": f"Scenario {index}", |
| "source_sentence": f"Source {index}", |
| "target_sentence": f"Target {index}", |
| "verb_lemma": f"verb_{index}", |
| "why_it_is_useful": "Useful daily sentence.", |
| "pronunciation_hint": "", |
| } |
| for index in range(1, 11) |
| ], |
| } |
|
|
| class StubClient: |
| def __init__(self) -> None: |
| self.calls = 0 |
|
|
| def chat_completion(self, **_: object) -> SimpleNamespace: |
| self.calls += 1 |
| if self.calls == 1: |
| return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=""))]) |
| return SimpleNamespace( |
| choices=[SimpleNamespace(message=SimpleNamespace(content=json.dumps(payload)))] |
| ) |
|
|
| client = StubClient() |
| monkeypatch.setattr(study_pack, "translate_sentence_cards", lambda cards, **_: cards) |
| plan = generate_sentence_cards( |
| use_cases="I need French for errands and daily conversations.", |
| target_language=TARGET_LANGUAGE, |
| native_language="English", |
| sentence_count=10, |
| client=client, |
| ) |
|
|
| assert client.calls == 2 |
| assert len(plan.cards) == 10 |
|
|
|
|
| def test_generate_sentence_cards_accepts_small_top_up_batch(monkeypatch) -> None: |
| first_batch = { |
| "rationale": "Use daily situations.", |
| "assumptions": ["The learner wants practical phrases."], |
| "focus_verbs": ["work", "buy", "ask"], |
| "study_routine": [ |
| {"title": "Preview", "minutes": 10, "instructions": "Scan verbs."}, |
| {"title": "Listen", "minutes": 20, "instructions": "Shadow the audio."}, |
| {"title": "Speak", "minutes": 15, "instructions": "Recall from prompts."}, |
| ], |
| "sentences": [ |
| { |
| "scenario": f"Scenario {index}", |
| "source_sentence": f"Source {index}", |
| "target_sentence": f"Target {index}", |
| "verb_lemma": f"verb_{index}", |
| "why_it_is_useful": "Useful daily sentence.", |
| "pronunciation_hint": "", |
| } |
| for index in range(1, 18) |
| ], |
| } |
| top_up_batch = { |
| "sentences": [ |
| { |
| "scenario": f"Top up {index}", |
| "source_sentence": f"Top source {index}", |
| "target_sentence": f"Top target {index}", |
| "verb_lemma": f"top_verb_{index}", |
| "why_it_is_useful": "Top-up sentence.", |
| "pronunciation_hint": "", |
| } |
| for index in range(1, 4) |
| ] |
| } |
|
|
| class StubClient: |
| def __init__(self) -> None: |
| self.calls = 0 |
|
|
| def chat_completion(self, **_: object) -> SimpleNamespace: |
| self.calls += 1 |
| payload = first_batch if self.calls == 1 else top_up_batch |
| return SimpleNamespace( |
| choices=[SimpleNamespace(message=SimpleNamespace(content=json.dumps(payload)))] |
| ) |
|
|
| monkeypatch.setattr(study_pack, "HF_GENERATION_MODEL", "Qwen/Qwen3-8B") |
| monkeypatch.setattr(study_pack, "translate_sentence_cards", lambda cards, **_: cards) |
|
|
| plan = generate_sentence_cards( |
| use_cases="I need French for errands and daily conversations.", |
| target_language=TARGET_LANGUAGE, |
| native_language="English", |
| sentence_count=20, |
| client=StubClient(), |
| ) |
|
|
| assert len(plan.cards) == 20 |
| assert plan.cards[-1].target_sentence == "Top target 3" |
|
|
|
|
| def test_translate_sentence_cards_uses_translation_model_output() -> None: |
| cards = [ |
| SentenceCard( |
| scenario="Travel", |
| source_sentence="Where is the station?", |
| target_sentence="placeholder", |
| verb_lemma="to go", |
| why_it_is_useful="Useful question.", |
| ), |
| SentenceCard( |
| scenario="Cafe", |
| source_sentence="I would like a coffee.", |
| target_sentence="placeholder", |
| verb_lemma="to order", |
| why_it_is_useful="Useful order.", |
| ), |
| ] |
|
|
| class StubClient: |
| def chat_completion(self, **kwargs: object) -> SimpleNamespace: |
| assert kwargs["model"] == TRANSLATION_MODEL |
| return SimpleNamespace( |
| choices=[ |
| SimpleNamespace( |
| message=SimpleNamespace( |
| content=json.dumps( |
| {"translations": ["Ou est la gare ?", "Je voudrais un cafe."]} |
| ) |
| ) |
| ) |
| ] |
| ) |
|
|
| translated = translate_sentence_cards( |
| cards=cards, |
| target_language=TARGET_LANGUAGE, |
| native_language="English", |
| client=StubClient(), |
| ) |
|
|
| assert [card.target_sentence for card in translated] == ["Ou est la gare ?", "Je voudrais un cafe."] |
| assert [card.source_sentence for card in translated] == ["Where is the station?", "I would like a coffee."] |
|
|