From b3c51161107b4cdcbc4c5334e28608423a299638 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 14 Oct 2026 22:39:36 +0800 Subject: [PATCH] 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 --- docs/artifact-registry.md | 2 +- docs/sqa/reviews/rc-028-py-mil-006.md | 63 ++++++++++ docs/sqa/traceability-matrix.md | 3 + src/flash_card/application/ports/__init__.py | 5 + .../application/ports/speech_port.py | 22 ++++ .../use_cases/open_study_session.py | 5 +- .../application/use_cases/study_session.py | 11 +- src/flash_card/constants.py | 2 - .../infrastructure/speech/__init__.py | 5 + .../infrastructure/speech/gtts_speech.py | 108 ++++++++++++++++++ .../infrastructure/speech/null_speech.py | 18 +++ src/flash_card/presentation/app.py | 33 +++++- tests/e2e/test_flash_card_window.py | 31 ++++- tests/integration/test_progress_file.py | 2 + tests/integration/test_vocabulary_file.py | 5 +- .../application/test_open_study_session.py | 25 +++- tests/unit/application/test_study_session.py | 49 +++++++- tests/unit/infrastructure/test_gtts_speech.py | 107 +++++++++++++++++ tests/unit/infrastructure/test_null_speech.py | 7 ++ tests/unit/test_app.py | 4 +- tests/unit/test_app_speech.py | 70 ++++++++++++ tests/unit/test_flash_card_view_model.py | 9 +- 22 files changed, 566 insertions(+), 20 deletions(-) create mode 100644 docs/sqa/reviews/rc-028-py-mil-006.md create mode 100644 src/flash_card/application/ports/__init__.py create mode 100644 src/flash_card/application/ports/speech_port.py create mode 100644 src/flash_card/infrastructure/speech/__init__.py create mode 100644 src/flash_card/infrastructure/speech/gtts_speech.py create mode 100644 src/flash_card/infrastructure/speech/null_speech.py create mode 100644 tests/unit/infrastructure/test_gtts_speech.py create mode 100644 tests/unit/infrastructure/test_null_speech.py create mode 100644 tests/unit/test_app_speech.py diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index c97babf..2b3bd61 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g. | DCD | Design Class Diagram | docs/dcd.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 028 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 029 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | ## Languages diff --git a/docs/sqa/reviews/rc-028-py-mil-006.md b/docs/sqa/reviews/rc-028-py-mil-006.md new file mode 100644 index 0000000..8194c39 --- /dev/null +++ b/docs/sqa/reviews/rc-028-py-mil-006.md @@ -0,0 +1,63 @@ +# RC-028: Review of PY-MIL-006 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-028 | +| CrossReference | [PY-MIL-006], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [PY-MIL-006] +- Checklist used: [QC-PY-001] +- Scope: full review +- Language and domain: n/a (technical type) +- Language reviewer: none +- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | PEP 8 casing holds; ruff rule set N passes. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names are the DCD-001 IT terms (`SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech`). | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass on 55 files with no suppressions. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 38 source files; third-party modules are imported with `importlib.import_module`, so no `type: ignore` is needed. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | The one broad `except Exception` in `GttsSpeech` is at the thread boundary, is commented, and logs with `exc_info`; it is not silent. No bare `except`. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no builtin shadowed. | +| 7 | Files, locks and connections are managed with context managers | Pass | Audio is written to a `tempfile.TemporaryDirectory` used as a context manager, so the file is always removed. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | New members have Doxygen docstrings; `doxygen Doxyfile` ends with 0 warnings. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Failures are logged with `logging`, not printed; only the spoken text (a word) is in the log context, no personal data. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `open_study_session(vocabulary_repository, progress_repository, speech, choose)` and `build_speech` match DCD-001 and SD-001 (the `speak` message of `startStudySession` and `markUnknown`). | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 124 tests (3 consecutive runs green) with fakes for the synthesiser and player; no test uses the network or plays a sound. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue; no `Any`. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` is still empty; the extra `speech` pins `playsound==1.2.2` and `gTTS>=2.5`, and no module imports them outside `GttsSpeech`. | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The speak rule is only in `StudySession.next_card`; the unused `SPEECH_LANGUAGE_CODE` constant was removed. | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `lint-imports` reports `Layers point inward` kept; the port is in `application`, the adapters in `infrastructure`, the choice in the composition root. | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | `StudySession` depends on the `SpeechPort` Protocol and receives the adapter; `NullSpeech` honours the same contract (Null Object), so no `if speech` test exists. | +| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | `application/ports/` and `infrastructure/speech/` follow the folder structure of the clean-architecture skill. | +| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names follow the rule. | + +## Overall Verdict + +Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author. + +Milestone MIL-006 Go criteria: 1 `speak(text, front_code)` once per drawn card and never when empty (unit tests); 2 `gtts` and `playsound` imported only when speaking, temporary file removed (unit tests with fakes); 3 service or player failure is logged and not raised (unit tests with caplog); 4 `build_speech` returns `NullSpeech` when the extra is missing or `--no-speech` is given (unit tests); 5 `dependencies` empty, extra pins `playsound==1.2.2` (pyproject.toml); 6 `speak` returns before a blocked player is released (unit test with an Event); 7 this record, `pytest` 124 passed. A manual check with the extra installed synthesised an mp3 of `kata` in Malay (7872 bytes) and `build_speech(True)` returned `GttsSpeech`; the audio was not played on this machine. All Go. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| none | - | - | + +--- + +[PY-MIL-006]: ../../../src/flash_card +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 9f0858f..25da67c 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -50,6 +50,7 @@ updated whenever an artifact instance is created or reviewed. | PY-MIL-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] | | PY-MIL-004 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-026] | | PY-MIL-005 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-027] | +| PY-MIL-006 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-028] | ## Coverage Notes @@ -114,3 +115,5 @@ updated whenever an artifact instance is created or reviewed. [PY-MIL-004]: ../../src/flash_card [RC-027]: ./reviews/rc-027-py-mil-005.md [PY-MIL-005]: ../../src/flash_card +[RC-028]: ./reviews/rc-028-py-mil-006.md +[PY-MIL-006]: ../../src/flash_card diff --git a/src/flash_card/application/ports/__init__.py b/src/flash_card/application/ports/__init__.py new file mode 100644 index 0000000..61553c1 --- /dev/null +++ b/src/flash_card/application/ports/__init__.py @@ -0,0 +1,5 @@ +"""Ports of the application layer. + +@file __init__.py +@brief Holds the interfaces the use cases need from outside. +""" diff --git a/src/flash_card/application/ports/speech_port.py b/src/flash_card/application/ports/speech_port.py new file mode 100644 index 0000000..f5c7726 --- /dev/null +++ b/src/flash_card/application/ports/speech_port.py @@ -0,0 +1,22 @@ +"""Port for spoken words. + +@file speech_port.py +@brief The SpeechPort interface the study session uses to say a word aloud. +""" + +from typing import Protocol + + +class SpeechPort(Protocol): + """Speaks a text aloud in a language.""" + + def speak(self, text: str, language_code: str) -> None: + """Speak a text without blocking the caller for long. + + An implementation does not raise: a failure is logged and ignored. + + @param text The text to speak. + @param language_code Language of the text, for example "ms". + @return Nothing. + """ + ... diff --git a/src/flash_card/application/use_cases/open_study_session.py b/src/flash_card/application/use_cases/open_study_session.py index 2edadb1..fa525d3 100644 --- a/src/flash_card/application/use_cases/open_study_session.py +++ b/src/flash_card/application/use_cases/open_study_session.py @@ -6,6 +6,7 @@ from collections.abc import Callable, Sequence +from flash_card.application.ports.speech_port import SpeechPort from flash_card.application.use_cases.study_session import StudySession from flash_card.domain.entities.card import Card from flash_card.domain.entities.deck import Deck @@ -16,12 +17,14 @@ from flash_card.domain.repositories.vocabulary_repository import VocabularyRepos def open_study_session( vocabulary_repository: VocabularyRepository, progress_repository: ProgressRepository, + speech: SpeechPort, choose: Callable[[Sequence[Card]], Card], ) -> StudySession: """Build a session over the saved words to learn, else the whole word list. @param vocabulary_repository Source of the delivered word list. @param progress_repository Store of the words still to learn. + @param speech Speaks the front text of each drawn card. @param choose Picks one card from a non-empty sequence. @return The new session. @throws VocabularyError When the saved progress, or the word list when no @@ -30,4 +33,4 @@ def open_study_session( deck = progress_repository.load() if deck is None: deck = Deck.from_vocabulary(vocabulary_repository.load()) - return StudySession(deck, choose, progress_repository) + return StudySession(deck, choose, progress_repository, speech) diff --git a/src/flash_card/application/use_cases/study_session.py b/src/flash_card/application/use_cases/study_session.py index 3d47118..6f16088 100644 --- a/src/flash_card/application/use_cases/study_session.py +++ b/src/flash_card/application/use_cases/study_session.py @@ -7,6 +7,7 @@ from collections.abc import Callable, Sequence from flash_card.application.dto.card_dto import CardDto +from flash_card.application.ports.speech_port import SpeechPort from flash_card.domain.entities.card import Card from flash_card.domain.entities.deck import Deck from flash_card.domain.repositories.progress_repository import ProgressRepository @@ -20,13 +21,17 @@ class StudySession: deck: Deck, choose: Callable[[Sequence[Card]], Card], progress: ProgressRepository, + speech: SpeechPort, ) -> None: """Create a session. @param deck The cards still to learn. @param choose Picks one card from a non-empty sequence. @param progress Store of the words still to learn. + @param speech Speaks the front text of each drawn card. """ + ## @brief Speaks the front text of each drawn card. + self._speech = speech ## @brief Store of the words still to learn. self._progress = progress ## @brief The cards still to learn. @@ -60,8 +65,10 @@ class StudySession: if self._deck.is_empty: self._current = None return None - self._current = self._deck.draw(self._choose, exclude=self._current) - return self._to_dto(self._current) + card = self._deck.draw(self._choose, exclude=self._current) + self._current = card + self._speech.speak(card.front, self._deck.language_pair.front_code) + return self._to_dto(card) def mark_known(self) -> None: """Remove the current card from the deck and save the remaining cards. diff --git a/src/flash_card/constants.py b/src/flash_card/constants.py index e253b15..63ccc98 100644 --- a/src/flash_card/constants.py +++ b/src/flash_card/constants.py @@ -51,8 +51,6 @@ CSV_ENCODING: Final[str] = "utf-8-sig" VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv" ## @brief File name of the words still to learn. PROGRESS_FILE_NAME: Final[str] = "words_to_learn.csv" -## @brief Language code used for speech. -SPEECH_LANGUAGE_CODE: Final[str] = "ms" ## @brief Root folder of the project. PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2] diff --git a/src/flash_card/infrastructure/speech/__init__.py b/src/flash_card/infrastructure/speech/__init__.py new file mode 100644 index 0000000..f18cfef --- /dev/null +++ b/src/flash_card/infrastructure/speech/__init__.py @@ -0,0 +1,5 @@ +"""Speech adapters. + +@file __init__.py +@brief Holds the implementations of the speech port. +""" diff --git a/src/flash_card/infrastructure/speech/gtts_speech.py b/src/flash_card/infrastructure/speech/gtts_speech.py new file mode 100644 index 0000000..e90e7d8 --- /dev/null +++ b/src/flash_card/infrastructure/speech/gtts_speech.py @@ -0,0 +1,108 @@ +"""Speech adapter over the gTTS service. + +@file gtts_speech.py +@brief GttsSpeech, the SpeechPort that synthesises with gTTS and plays the file. +The packages gtts and playsound are optional and imported only when used. +""" + +import importlib +import logging +import tempfile +import threading +from collections.abc import Callable +from pathlib import Path + +## @brief Logger of the speech adapter. +_logger = logging.getLogger(__name__) + +## @brief File name of the synthesised speech inside the temporary folder. +_SPEECH_FILE_NAME = "speech.mp3" + +## @brief Writes an mp3 of a text and language to a path. +Synthesize = Callable[[str, str, Path], None] +## @brief Plays an audio file. +Play = Callable[[Path], None] + + +def _default_synthesize(text: str, language_code: str, path: Path) -> None: + """Write an mp3 of the text with gTTS. + + @param text The text to speak. + @param language_code Language of the text. + @param path File to write. + @return Nothing. + @throws ImportError When the gtts package is not installed. + """ + # Imported by name so the program starts without the optional extra. + module = importlib.import_module("gtts") + module.gTTS(text=text, lang=language_code).save(str(path)) + + +def _default_play(path: Path) -> None: + """Play an audio file and wait until it has been played. + + @param path File to play. + @return Nothing. + @throws ImportError When the playsound package is not installed. + """ + # Imported by name so the program starts without the optional extra. + module = importlib.import_module("playsound") + module.playsound(str(path)) + + +class GttsSpeech: + """Speaks a text in a background thread; never raises.""" + + def __init__( + self, + synthesize: Synthesize | None = None, + play: Play | None = None, + ) -> None: + """Create the adapter. + + @param synthesize Writes an mp3 of a text and language to a path, or + None for gTTS. + @param play Plays an audio file, or None for playsound. + """ + ## @brief Writes the audio file. + self._synthesize = _default_synthesize if synthesize is None else synthesize + ## @brief Plays the audio file. + self._play = _default_play if play is None else play + ## @brief The thread of the latest speak call, or None. + self._thread: threading.Thread | None = None + + def speak(self, text: str, language_code: str) -> None: + """Start speaking and return at once. + + @param text The text to speak. + @param language_code Language of the text. + @return Nothing. + """ + thread = threading.Thread( + target=self._run, args=(text, language_code), daemon=True + ) + self._thread = thread + thread.start() + + def wait(self) -> None: + """Block until the latest speech has ended. + + @return Nothing. + """ + if self._thread is not None: + self._thread.join() + + def _run(self, text: str, language_code: str) -> None: + """Synthesise and play in a temporary folder, then remove the file. + + @param text The text to speak. + @param language_code Language of the text. + @return Nothing. + """ + try: + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / _SPEECH_FILE_NAME + self._synthesize(text, language_code, path) + self._play(path) + except Exception: # thread boundary: speech must never crash the program + _logger.warning("Speech failed for %r", text, exc_info=True) diff --git a/src/flash_card/infrastructure/speech/null_speech.py b/src/flash_card/infrastructure/speech/null_speech.py new file mode 100644 index 0000000..7d3c9b8 --- /dev/null +++ b/src/flash_card/infrastructure/speech/null_speech.py @@ -0,0 +1,18 @@ +"""Silent speech adapter. + +@file null_speech.py +@brief NullSpeech, the SpeechPort used when speech is off or unavailable. +""" + + +class NullSpeech: + """Speaks nothing.""" + + def speak(self, text: str, language_code: str) -> None: + """Do nothing. + + @param text The text, ignored. + @param language_code The language, ignored. + @return Nothing. + """ + return None diff --git a/src/flash_card/presentation/app.py b/src/flash_card/presentation/app.py index 30d032c..723100a 100644 --- a/src/flash_card/presentation/app.py +++ b/src/flash_card/presentation/app.py @@ -5,6 +5,7 @@ """ import argparse +import importlib.util import logging import random import tkinter as tk @@ -12,6 +13,7 @@ from collections.abc import Sequence from pathlib import Path from typing import NoReturn +from flash_card.application.ports.speech_port import SpeechPort from flash_card.application.use_cases.open_study_session import open_study_session from flash_card.constants import ( DEFAULT_DATA_DIR, @@ -25,6 +27,8 @@ from flash_card.infrastructure.persistence.csv_progress_repository import ( from flash_card.infrastructure.persistence.csv_vocabulary_repository import ( CsvVocabularyRepository, ) +from flash_card.infrastructure.speech.gtts_speech import GttsSpeech +from flash_card.infrastructure.speech.null_speech import NullSpeech from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView from flash_card.presentation.features.flash_card.flash_card_view_model import ( FlashCardViewModel, @@ -33,20 +37,40 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import ( ## @brief Logger of the entry point. _logger = logging.getLogger(__name__) +## @brief Optional packages the spoken words need. +_SPEECH_PACKAGES = ("gtts", "playsound") + + +def build_speech(enabled: bool) -> SpeechPort: + """Choose the speech adapter. + + @param enabled False when the user switched speech off. + @return GttsSpeech when enabled and gtts and playsound are installed, + else NullSpeech. + """ + if enabled and all( + importlib.util.find_spec(name) is not None for name in _SPEECH_PACKAGES + ): + return GttsSpeech() + return NullSpeech() + def create_application( data_dir: Path | None = None, + speech_enabled: bool = True, ) -> tuple[tk.Tk, FlashCardView]: """Create the root window and the flash card view. @param data_dir Folder of the data files, or None for the default folder. + @param speech_enabled False to run without spoken words. @return The root window and the view built on it. @throws VocabularyError When the word list or saved progress is unusable. """ folder = DEFAULT_DATA_DIR if data_dir is None else data_dir repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME) progress = CsvProgressRepository(folder / PROGRESS_FILE_NAME) - session = open_study_session(repository, progress, random.choice) + speech = build_speech(speech_enabled) + session = open_study_session(repository, progress, speech, random.choice) view_model = FlashCardViewModel(session) root = tk.Tk() view = FlashCardView(root, view_model) @@ -66,9 +90,14 @@ def main(argv: Sequence[str] | None = None) -> int: default=DEFAULT_DATA_DIR, help="folder of the data files", ) + parser.add_argument( + "--no-speech", + action="store_true", + help="do not speak the words", + ) args = parser.parse_args(argv) try: - root, _view = create_application(args.data_dir) + root, _view = create_application(args.data_dir, not args.no_speech) except VocabularyError as error: _logger.error("%s", error) return 1 diff --git a/tests/e2e/test_flash_card_window.py b/tests/e2e/test_flash_card_window.py index f5ae4c0..71b9903 100644 --- a/tests/e2e/test_flash_card_window.py +++ b/tests/e2e/test_flash_card_window.py @@ -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) diff --git a/tests/integration/test_progress_file.py b/tests/integration/test_progress_file.py index 2233dcc..3094cda 100644 --- a/tests/integration/test_progress_file.py +++ b/tests/integration/test_progress_file.py @@ -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, ) diff --git a/tests/integration/test_vocabulary_file.py b/tests/integration/test_vocabulary_file.py index a9fae7b..b94a46f 100644 --- a/tests/integration/test_vocabulary_file.py +++ b/tests/integration/test_vocabulary_file.py @@ -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() diff --git a/tests/unit/application/test_open_study_session.py b/tests/unit/application/test_open_study_session.py index 3bc84d5..aae56a5 100644 --- a/tests/unit/application/test_open_study_session.py +++ b/tests/unit/application/test_open_study_session.py @@ -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 == [] diff --git a/tests/unit/application/test_study_session.py b/tests/unit/application/test_study_session.py index 22be121..eee9149 100644 --- a/tests/unit/application/test_study_session.py +++ b/tests/unit/application/test_study_session.py @@ -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")] diff --git a/tests/unit/infrastructure/test_gtts_speech.py b/tests/unit/infrastructure/test_gtts_speech.py new file mode 100644 index 0000000..9d6d705 --- /dev/null +++ b/tests/unit/infrastructure/test_gtts_speech.py @@ -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() diff --git a/tests/unit/infrastructure/test_null_speech.py b/tests/unit/infrastructure/test_null_speech.py new file mode 100644 index 0000000..deae754 --- /dev/null +++ b/tests/unit/infrastructure/test_null_speech.py @@ -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 diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index b8f2ff6..419750c 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -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() diff --git a/tests/unit/test_app_speech.py b/tests/unit/test_app_speech.py new file mode 100644 index 0000000..9c71381 --- /dev/null +++ b/tests/unit/test_app_speech.py @@ -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] diff --git a/tests/unit/test_flash_card_view_model.py b/tests/unit/test_flash_card_view_model.py index 3e555c1..012dfb0 100644 --- a/tests/unit/test_flash_card_view_model.py +++ b/tests/unit/test_flash_card_view_model.py @@ -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: