MIL-006: Spoken Malay word

Review record RC-028 (Go, autonomous mode, not independent).

Task: MIL-006#1
Task: MIL-006#2
Task: MIL-006#3
Task: MIL-006#4
Task: MIL-006#5
This commit is contained in:
2026-10-14 22:39:36 +08:00
parent 5900fda90a
commit b3c5116110
22 changed files with 566 additions and 20 deletions
+29 -2
View File
@@ -35,6 +35,7 @@ from flash_card.infrastructure.persistence.csv_progress_repository import (
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.infrastructure.speech.null_speech import NullSpeech
from flash_card.presentation.features.flash_card import flash_card_view
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
from flash_card.presentation.features.flash_card.flash_card_view_model import (
@@ -58,9 +59,22 @@ class _MemoryProgress:
return None
class _RecordingSpeech:
"""Speech port that records what would have been spoken."""
def __init__(self) -> None:
self.spoken: list[tuple[str, str]] = []
def speak(self, text: str, language_code: str) -> None:
self.spoken.append((text, language_code))
SPEECH = _RecordingSpeech()
def _build_view(root: tk.Tk) -> FlashCardView:
deck = Deck(LanguagePair("ms", "da"), CARDS)
session = StudySession(deck, _first, _MemoryProgress())
session = StudySession(deck, _first, _MemoryProgress(), SPEECH)
return FlashCardView(root, FlashCardViewModel(session))
@@ -280,7 +294,10 @@ def files_view(
words.write_text("ms;da\naku;mig\nkau;du\nini;denne\n", encoding="utf-8")
progress = tmp_path / "words_to_learn.csv"
session = open_study_session(
CsvVocabularyRepository(words), CsvProgressRepository(progress), _first
CsvVocabularyRepository(words),
CsvProgressRepository(progress),
NullSpeech(),
_first,
)
view = FlashCardView(root, FlashCardViewModel(session))
yield view, progress
@@ -327,3 +344,13 @@ def test_buttons_do_nothing_visible_when_finished(
assert _shown(view) == ("", FINISHED_MESSAGE)
assert view.flip_timer is None
assert _saved(progress) == HEADER_ONLY
def test_button_press_speaks_the_malay_word_of_the_shown_card(
window: tuple[tk.Tk, FlashCardView],
) -> None:
root, view = window
SPEECH.spoken.clear()
view.wrong_button.invoke()
assert SPEECH.spoken == [(_word(view), "ms")]
_reset(root, view)
+2
View File
@@ -12,6 +12,7 @@ from flash_card.infrastructure.persistence.csv_progress_repository import (
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.infrastructure.speech.null_speech import NullSpeech
WORDS = "fr;en\nchaque;each\nparlé;speak\narrivé;come\n"
@@ -33,6 +34,7 @@ def _open(folder: Path, choose: Choose = _first) -> StudySession:
return open_study_session(
CsvVocabularyRepository(folder / "words.csv"),
CsvProgressRepository(folder / "words_to_learn.csv"),
NullSpeech(),
choose,
)
+4 -1
View File
@@ -17,6 +17,7 @@ from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.infrastructure.persistence.csv_word_list import read_word_list
from flash_card.infrastructure.speech.null_speech import NullSpeech
PATH = DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME
@@ -32,7 +33,9 @@ def test_open_study_session_yields_card_from_real_file(tmp_path: Path) -> None:
repository = CsvVocabularyRepository(PATH)
cards = repository.load().cards
progress = CsvProgressRepository(tmp_path / PROGRESS_FILE_NAME)
session = open_study_session(repository, progress, lambda candidates: candidates[0])
session = open_study_session(
repository, progress, NullSpeech(), lambda candidates: candidates[0]
)
card = session.next_card()
@@ -33,6 +33,11 @@ class _RecordingProgress:
self.saved.append(deck.cards)
class _SilentSpeech:
def speak(self, text: str, language_code: str) -> None:
return None
class _MissingRepository:
def load(self) -> Vocabulary:
raise VocabularyNotFoundError("missing")
@@ -46,7 +51,9 @@ def test_open_study_session_builds_session_over_all_cards() -> None:
cards = (Card("aku", "mig"), Card("kau", "du"))
repository = _FakeRepository(Vocabulary(LanguagePair("ms", "da"), cards))
session = open_study_session(repository, _RecordingProgress(), _first)
session = open_study_session(
repository, _RecordingProgress(), _SilentSpeech(), _first
)
assert session.remaining == 2
assert session.next_card() == CardDto("aku", "mig", "ms", "da")
@@ -54,7 +61,9 @@ def test_open_study_session_builds_session_over_all_cards() -> None:
def test_open_study_session_propagates_repository_error() -> None:
with pytest.raises(VocabularyNotFoundError):
open_study_session(_MissingRepository(), _RecordingProgress(), _first)
open_study_session(
_MissingRepository(), _RecordingProgress(), _SilentSpeech(), _first
)
def test_open_study_session_prefers_saved_progress() -> None:
@@ -62,7 +71,9 @@ def test_open_study_session_prefers_saved_progress() -> None:
vocabulary = Vocabulary(pair, (Card("aku", "mig"), Card("kau", "du")))
progress = _RecordingProgress(Deck(pair, [Card("kau", "du")]))
session = open_study_session(_FakeRepository(vocabulary), progress, _first)
session = open_study_session(
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first
)
assert session.remaining == 1
assert session.next_card() == CardDto("kau", "du", "ms", "da")
@@ -71,7 +82,9 @@ def test_open_study_session_prefers_saved_progress() -> None:
def test_open_study_session_skips_vocabulary_when_progress_exists() -> None:
progress = _RecordingProgress(Deck(LanguagePair("ms", "da"), []))
session = open_study_session(_MissingRepository(), progress, _first)
session = open_study_session(
_MissingRepository(), progress, _SilentSpeech(), _first
)
assert session.remaining == 0
@@ -80,7 +93,9 @@ def test_open_study_session_never_saves_on_start() -> None:
vocabulary = Vocabulary(LanguagePair("ms", "da"), (Card("aku", "mig"),))
progress = _RecordingProgress()
session = open_study_session(_FakeRepository(vocabulary), progress, _first)
session = open_study_session(
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first
)
session.next_card()
assert progress.saved == []
+47 -2
View File
@@ -28,9 +28,22 @@ class _RecordingProgress:
self.saved.append(deck.cards)
def _session(*cards: Card, progress: _RecordingProgress | None = None) -> StudySession:
class _RecordingSpeech:
def __init__(self) -> None:
self.spoken: list[tuple[str, str]] = []
def speak(self, text: str, language_code: str) -> None:
self.spoken.append((text, language_code))
def _session(
*cards: Card,
progress: _RecordingProgress | None = None,
speech: _RecordingSpeech | None = None,
) -> StudySession:
store = _RecordingProgress() if progress is None else progress
return StudySession(Deck(PAIR, cards), _first, store)
voice = _RecordingSpeech() if speech is None else speech
return StudySession(Deck(PAIR, cards), _first, store, voice)
def test_current_card_is_none_before_first_draw() -> None:
@@ -105,3 +118,35 @@ def test_next_card_never_saves() -> None:
session.next_card()
session.next_card()
assert progress.saved == []
def test_next_card_speaks_front_text_once_in_front_language() -> None:
speech = _RecordingSpeech()
session = _session(A, B, speech=speech)
session.next_card()
assert speech.spoken == [("aku", "ms")]
def test_next_card_does_not_speak_when_deck_is_empty() -> None:
speech = _RecordingSpeech()
session = _session(speech=speech)
session.next_card()
assert speech.spoken == []
def test_next_card_speaks_again_on_every_draw() -> None:
speech = _RecordingSpeech()
session = _session(A, B, speech=speech)
session.next_card()
session.next_card()
assert speech.spoken == [("aku", "ms"), ("kau", "ms")]
def test_mark_known_does_not_speak_but_following_draw_does() -> None:
speech = _RecordingSpeech()
session = _session(A, B, speech=speech)
session.next_card()
session.mark_known()
assert speech.spoken == [("aku", "ms")]
session.next_card()
assert speech.spoken == [("aku", "ms"), ("kau", "ms")]
@@ -0,0 +1,107 @@
"""Tests of the GttsSpeech adapter with fake synthesizer and player."""
import threading
from pathlib import Path
import pytest
from flash_card.infrastructure.speech import gtts_speech
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
class _Recorder:
def __init__(self) -> None:
self.synthesized: list[tuple[str, str]] = []
self.played: list[Path] = []
self.existed_when_played = False
def synthesize(self, text: str, language_code: str, path: Path) -> None:
self.synthesized.append((text, language_code))
path.write_bytes(b"mp3")
def play(self, path: Path) -> None:
self.played.append(path)
self.existed_when_played = path.exists()
def test_speak_synthesizes_and_plays_then_removes_file() -> None:
recorder = _Recorder()
speech = GttsSpeech(recorder.synthesize, recorder.play)
speech.speak("aku", "ms")
speech.wait()
assert recorder.synthesized == [("aku", "ms")]
assert len(recorder.played) == 1
assert recorder.existed_when_played
assert not recorder.played[0].exists()
def test_speak_returns_before_player_has_finished() -> None:
started = threading.Event()
release = threading.Event()
def blocked_play(path: Path) -> None:
started.set()
release.wait(timeout=5)
speech = GttsSpeech(lambda text, code, path: None, blocked_play)
speech.speak("aku", "ms")
assert started.wait(timeout=5)
assert speech._thread is not None
assert speech._thread.is_alive()
release.set()
speech.wait()
def test_speak_logs_and_does_not_raise_when_synthesize_fails(
caplog: pytest.LogCaptureFixture,
) -> None:
played: list[Path] = []
def failing(text: str, language_code: str, path: Path) -> None:
raise OSError("no network")
speech = GttsSpeech(failing, played.append)
speech.speak("aku", "ms")
speech.wait()
assert played == []
assert "Speech failed" in caplog.text
assert "no network" in caplog.text
def test_speak_logs_and_does_not_raise_when_play_fails(
caplog: pytest.LogCaptureFixture,
) -> None:
def failing(path: Path) -> None:
raise RuntimeError("no audio device")
speech = GttsSpeech(lambda text, code, path: None, failing)
speech.speak("aku", "ms")
speech.wait()
assert "no audio device" in caplog.text
def test_speak_logs_missing_package_when_default_synthesizer_raises_import_error(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
def missing(name: str) -> object:
raise ImportError(f"No module named {name!r}")
monkeypatch.setattr(gtts_speech.importlib, "import_module", missing)
speech = GttsSpeech()
speech.speak("aku", "ms")
speech.wait()
assert "No module named 'gtts'" in caplog.text
def test_wait_returns_when_nothing_was_spoken() -> None:
GttsSpeech().wait()
@@ -0,0 +1,7 @@
"""Tests of the NullSpeech adapter."""
from flash_card.infrastructure.speech.null_speech import NullSpeech
def test_speak_does_nothing_and_returns_none() -> None:
assert NullSpeech().speak("aku", "ms") is None
+3 -1
View File
@@ -25,7 +25,9 @@ def test_main_runs_main_loop_and_returns_zero(
root = _FakeRoot()
seen: list[Path | None] = []
def fake_create(data_dir: Path | None = None) -> tuple[_FakeRoot, object]:
def fake_create(
data_dir: Path | None = None, speech_enabled: bool = True
) -> tuple[_FakeRoot, object]:
seen.append(data_dir)
return root, object()
+70
View File
@@ -0,0 +1,70 @@
"""Tests of the speech wiring in the entry point."""
import importlib.util
from importlib.machinery import ModuleSpec
from pathlib import Path
import pytest
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
from flash_card.infrastructure.speech.null_speech import NullSpeech
from flash_card.presentation import app
class _FakeRoot:
def mainloop(self) -> None:
return None
def _found(name: str) -> ModuleSpec:
return ModuleSpec(name, None)
def test_build_speech_returns_null_speech_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(importlib.util, "find_spec", _found)
assert isinstance(app.build_speech(False), NullSpeech)
def test_build_speech_returns_null_speech_when_packages_are_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(importlib.util, "find_spec", lambda name: None)
assert isinstance(app.build_speech(True), NullSpeech)
def test_build_speech_returns_null_speech_when_one_package_is_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
importlib.util,
"find_spec",
lambda name: None if name == "playsound" else _found(name),
)
assert isinstance(app.build_speech(True), NullSpeech)
def test_build_speech_returns_gtts_speech_when_both_packages_are_found(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(importlib.util, "find_spec", _found)
assert isinstance(app.build_speech(True), GttsSpeech)
def test_main_passes_speech_disabled_with_no_speech_option(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
seen: list[bool] = []
def fake_create(
data_dir: Path | None = None, speech_enabled: bool = True
) -> tuple[_FakeRoot, object]:
seen.append(speech_enabled)
return _FakeRoot(), object()
monkeypatch.setattr(app, "create_application", fake_create)
assert app.main(["--data-dir", str(tmp_path), "--no-speech"]) == 0
assert app.main(["--data-dir", str(tmp_path)]) == 0
assert seen == [False, True]
+8 -1
View File
@@ -31,11 +31,18 @@ class _RecordingProgress:
self.saved.append(deck.cards)
class _SilentSpeech:
def speak(self, text: str, language_code: str) -> None:
return None
def _session(
code: str, *cards: Card, progress: _RecordingProgress | None = None
) -> StudySession:
store = _RecordingProgress() if progress is None else progress
return StudySession(Deck(LanguagePair(code, "da"), cards), _first, store)
return StudySession(
Deck(LanguagePair(code, "da"), cards), _first, store, _SilentSpeech()
)
def test_view_model_draws_first_card_on_construction() -> None: