Merge pull request 'MIL-006: Spoken Malay word' (#43) from mil-006-malay-speech into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 9s
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 9s
This commit was merged in pull request #43.
This commit is contained in:
@@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
|||||||
| DCD | Design Class Diagram | docs/dcd.md | 002 |
|
| DCD | Design Class Diagram | docs/dcd.md | 002 |
|
||||||
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
||||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
|
| 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 |
|
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||||
|
|
||||||
## Languages
|
## Languages
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 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 | [b3c5116] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
[b3c5116]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/b3c51161107b4cdcbc4c5334e28608423a299638
|
||||||
@@ -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-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] |
|
||||||
| PY-MIL-004 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-026] |
|
| 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-005 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-027] |
|
||||||
|
| PY-MIL-006 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-028] |
|
||||||
|
|
||||||
## Coverage Notes
|
## Coverage Notes
|
||||||
|
|
||||||
@@ -114,3 +115,5 @@ updated whenever an artifact instance is created or reviewed.
|
|||||||
[PY-MIL-004]: ../../src/flash_card
|
[PY-MIL-004]: ../../src/flash_card
|
||||||
[RC-027]: ./reviews/rc-027-py-mil-005.md
|
[RC-027]: ./reviews/rc-027-py-mil-005.md
|
||||||
[PY-MIL-005]: ../../src/flash_card
|
[PY-MIL-005]: ../../src/flash_card
|
||||||
|
[RC-028]: ./reviews/rc-028-py-mil-006.md
|
||||||
|
[PY-MIL-006]: ../../src/flash_card
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Ports of the application layer.
|
||||||
|
|
||||||
|
@file __init__.py
|
||||||
|
@brief Holds the interfaces the use cases need from outside.
|
||||||
|
"""
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
from collections.abc import Callable, Sequence
|
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.application.use_cases.study_session import StudySession
|
||||||
from flash_card.domain.entities.card import Card
|
from flash_card.domain.entities.card import Card
|
||||||
from flash_card.domain.entities.deck import Deck
|
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(
|
def open_study_session(
|
||||||
vocabulary_repository: VocabularyRepository,
|
vocabulary_repository: VocabularyRepository,
|
||||||
progress_repository: ProgressRepository,
|
progress_repository: ProgressRepository,
|
||||||
|
speech: SpeechPort,
|
||||||
choose: Callable[[Sequence[Card]], Card],
|
choose: Callable[[Sequence[Card]], Card],
|
||||||
) -> StudySession:
|
) -> StudySession:
|
||||||
"""Build a session over the saved words to learn, else the whole word list.
|
"""Build a session over the saved words to learn, else the whole word list.
|
||||||
|
|
||||||
@param vocabulary_repository Source of the delivered word list.
|
@param vocabulary_repository Source of the delivered word list.
|
||||||
@param progress_repository Store of the words still to learn.
|
@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.
|
@param choose Picks one card from a non-empty sequence.
|
||||||
@return The new session.
|
@return The new session.
|
||||||
@throws VocabularyError When the saved progress, or the word list when no
|
@throws VocabularyError When the saved progress, or the word list when no
|
||||||
@@ -30,4 +33,4 @@ def open_study_session(
|
|||||||
deck = progress_repository.load()
|
deck = progress_repository.load()
|
||||||
if deck is None:
|
if deck is None:
|
||||||
deck = Deck.from_vocabulary(vocabulary_repository.load())
|
deck = Deck.from_vocabulary(vocabulary_repository.load())
|
||||||
return StudySession(deck, choose, progress_repository)
|
return StudySession(deck, choose, progress_repository, speech)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Sequence
|
||||||
|
|
||||||
from flash_card.application.dto.card_dto import CardDto
|
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.card import Card
|
||||||
from flash_card.domain.entities.deck import Deck
|
from flash_card.domain.entities.deck import Deck
|
||||||
from flash_card.domain.repositories.progress_repository import ProgressRepository
|
from flash_card.domain.repositories.progress_repository import ProgressRepository
|
||||||
@@ -20,13 +21,17 @@ class StudySession:
|
|||||||
deck: Deck,
|
deck: Deck,
|
||||||
choose: Callable[[Sequence[Card]], Card],
|
choose: Callable[[Sequence[Card]], Card],
|
||||||
progress: ProgressRepository,
|
progress: ProgressRepository,
|
||||||
|
speech: SpeechPort,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create a session.
|
"""Create a session.
|
||||||
|
|
||||||
@param deck The cards still to learn.
|
@param deck The cards still to learn.
|
||||||
@param choose Picks one card from a non-empty sequence.
|
@param choose Picks one card from a non-empty sequence.
|
||||||
@param progress Store of the words still to learn.
|
@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.
|
## @brief Store of the words still to learn.
|
||||||
self._progress = progress
|
self._progress = progress
|
||||||
## @brief The cards still to learn.
|
## @brief The cards still to learn.
|
||||||
@@ -60,8 +65,10 @@ class StudySession:
|
|||||||
if self._deck.is_empty:
|
if self._deck.is_empty:
|
||||||
self._current = None
|
self._current = None
|
||||||
return None
|
return None
|
||||||
self._current = self._deck.draw(self._choose, exclude=self._current)
|
card = self._deck.draw(self._choose, exclude=self._current)
|
||||||
return self._to_dto(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:
|
def mark_known(self) -> None:
|
||||||
"""Remove the current card from the deck and save the remaining cards.
|
"""Remove the current card from the deck and save the remaining cards.
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ CSV_ENCODING: Final[str] = "utf-8-sig"
|
|||||||
VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv"
|
VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv"
|
||||||
## @brief File name of the words still to learn.
|
## @brief File name of the words still to learn.
|
||||||
PROGRESS_FILE_NAME: Final[str] = "words_to_learn.csv"
|
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.
|
## @brief Root folder of the project.
|
||||||
PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Speech adapters.
|
||||||
|
|
||||||
|
@file __init__.py
|
||||||
|
@brief Holds the implementations of the speech port.
|
||||||
|
"""
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import importlib.util
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
@@ -12,6 +13,7 @@ from collections.abc import Sequence
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import NoReturn
|
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.application.use_cases.open_study_session import open_study_session
|
||||||
from flash_card.constants import (
|
from flash_card.constants import (
|
||||||
DEFAULT_DATA_DIR,
|
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 (
|
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||||
CsvVocabularyRepository,
|
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 import FlashCardView
|
||||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||||
FlashCardViewModel,
|
FlashCardViewModel,
|
||||||
@@ -33,20 +37,40 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
|||||||
## @brief Logger of the entry point.
|
## @brief Logger of the entry point.
|
||||||
_logger = logging.getLogger(__name__)
|
_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(
|
def create_application(
|
||||||
data_dir: Path | None = None,
|
data_dir: Path | None = None,
|
||||||
|
speech_enabled: bool = True,
|
||||||
) -> tuple[tk.Tk, FlashCardView]:
|
) -> tuple[tk.Tk, FlashCardView]:
|
||||||
"""Create the root window and the flash card view.
|
"""Create the root window and the flash card view.
|
||||||
|
|
||||||
@param data_dir Folder of the data files, or None for the default folder.
|
@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.
|
@return The root window and the view built on it.
|
||||||
@throws VocabularyError When the word list or saved progress is unusable.
|
@throws VocabularyError When the word list or saved progress is unusable.
|
||||||
"""
|
"""
|
||||||
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
|
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
|
||||||
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
|
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
|
||||||
progress = CsvProgressRepository(folder / PROGRESS_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)
|
view_model = FlashCardViewModel(session)
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
view = FlashCardView(root, view_model)
|
view = FlashCardView(root, view_model)
|
||||||
@@ -66,9 +90,14 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
default=DEFAULT_DATA_DIR,
|
default=DEFAULT_DATA_DIR,
|
||||||
help="folder of the data files",
|
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)
|
args = parser.parse_args(argv)
|
||||||
try:
|
try:
|
||||||
root, _view = create_application(args.data_dir)
|
root, _view = create_application(args.data_dir, not args.no_speech)
|
||||||
except VocabularyError as error:
|
except VocabularyError as error:
|
||||||
_logger.error("%s", error)
|
_logger.error("%s", error)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from flash_card.infrastructure.persistence.csv_progress_repository import (
|
|||||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||||
CsvVocabularyRepository,
|
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 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 import FlashCardView
|
||||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||||
@@ -58,9 +59,22 @@ class _MemoryProgress:
|
|||||||
return None
|
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:
|
def _build_view(root: tk.Tk) -> FlashCardView:
|
||||||
deck = Deck(LanguagePair("ms", "da"), CARDS)
|
deck = Deck(LanguagePair("ms", "da"), CARDS)
|
||||||
session = StudySession(deck, _first, _MemoryProgress())
|
session = StudySession(deck, _first, _MemoryProgress(), SPEECH)
|
||||||
return FlashCardView(root, FlashCardViewModel(session))
|
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")
|
words.write_text("ms;da\naku;mig\nkau;du\nini;denne\n", encoding="utf-8")
|
||||||
progress = tmp_path / "words_to_learn.csv"
|
progress = tmp_path / "words_to_learn.csv"
|
||||||
session = open_study_session(
|
session = open_study_session(
|
||||||
CsvVocabularyRepository(words), CsvProgressRepository(progress), _first
|
CsvVocabularyRepository(words),
|
||||||
|
CsvProgressRepository(progress),
|
||||||
|
NullSpeech(),
|
||||||
|
_first,
|
||||||
)
|
)
|
||||||
view = FlashCardView(root, FlashCardViewModel(session))
|
view = FlashCardView(root, FlashCardViewModel(session))
|
||||||
yield view, progress
|
yield view, progress
|
||||||
@@ -327,3 +344,13 @@ def test_buttons_do_nothing_visible_when_finished(
|
|||||||
assert _shown(view) == ("", FINISHED_MESSAGE)
|
assert _shown(view) == ("", FINISHED_MESSAGE)
|
||||||
assert view.flip_timer is None
|
assert view.flip_timer is None
|
||||||
assert _saved(progress) == HEADER_ONLY
|
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)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from flash_card.infrastructure.persistence.csv_progress_repository import (
|
|||||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||||
CsvVocabularyRepository,
|
CsvVocabularyRepository,
|
||||||
)
|
)
|
||||||
|
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||||
|
|
||||||
WORDS = "fr;en\nchaque;each\nparlé;speak\narrivé;come\n"
|
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(
|
return open_study_session(
|
||||||
CsvVocabularyRepository(folder / "words.csv"),
|
CsvVocabularyRepository(folder / "words.csv"),
|
||||||
CsvProgressRepository(folder / "words_to_learn.csv"),
|
CsvProgressRepository(folder / "words_to_learn.csv"),
|
||||||
|
NullSpeech(),
|
||||||
choose,
|
choose,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
|||||||
CsvVocabularyRepository,
|
CsvVocabularyRepository,
|
||||||
)
|
)
|
||||||
from flash_card.infrastructure.persistence.csv_word_list import read_word_list
|
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
|
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)
|
repository = CsvVocabularyRepository(PATH)
|
||||||
cards = repository.load().cards
|
cards = repository.load().cards
|
||||||
progress = CsvProgressRepository(tmp_path / PROGRESS_FILE_NAME)
|
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()
|
card = session.next_card()
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ class _RecordingProgress:
|
|||||||
self.saved.append(deck.cards)
|
self.saved.append(deck.cards)
|
||||||
|
|
||||||
|
|
||||||
|
class _SilentSpeech:
|
||||||
|
def speak(self, text: str, language_code: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class _MissingRepository:
|
class _MissingRepository:
|
||||||
def load(self) -> Vocabulary:
|
def load(self) -> Vocabulary:
|
||||||
raise VocabularyNotFoundError("missing")
|
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"))
|
cards = (Card("aku", "mig"), Card("kau", "du"))
|
||||||
repository = _FakeRepository(Vocabulary(LanguagePair("ms", "da"), cards))
|
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.remaining == 2
|
||||||
assert session.next_card() == CardDto("aku", "mig", "ms", "da")
|
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:
|
def test_open_study_session_propagates_repository_error() -> None:
|
||||||
with pytest.raises(VocabularyNotFoundError):
|
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:
|
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")))
|
vocabulary = Vocabulary(pair, (Card("aku", "mig"), Card("kau", "du")))
|
||||||
progress = _RecordingProgress(Deck(pair, [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.remaining == 1
|
||||||
assert session.next_card() == CardDto("kau", "du", "ms", "da")
|
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:
|
def test_open_study_session_skips_vocabulary_when_progress_exists() -> None:
|
||||||
progress = _RecordingProgress(Deck(LanguagePair("ms", "da"), []))
|
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
|
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"),))
|
vocabulary = Vocabulary(LanguagePair("ms", "da"), (Card("aku", "mig"),))
|
||||||
progress = _RecordingProgress()
|
progress = _RecordingProgress()
|
||||||
|
|
||||||
session = open_study_session(_FakeRepository(vocabulary), progress, _first)
|
session = open_study_session(
|
||||||
|
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first
|
||||||
|
)
|
||||||
session.next_card()
|
session.next_card()
|
||||||
|
|
||||||
assert progress.saved == []
|
assert progress.saved == []
|
||||||
|
|||||||
@@ -28,9 +28,22 @@ class _RecordingProgress:
|
|||||||
self.saved.append(deck.cards)
|
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
|
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:
|
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()
|
||||||
session.next_card()
|
session.next_card()
|
||||||
assert progress.saved == []
|
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
|
||||||
@@ -25,7 +25,9 @@ def test_main_runs_main_loop_and_returns_zero(
|
|||||||
root = _FakeRoot()
|
root = _FakeRoot()
|
||||||
seen: list[Path | None] = []
|
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)
|
seen.append(data_dir)
|
||||||
return root, object()
|
return root, object()
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -31,11 +31,18 @@ class _RecordingProgress:
|
|||||||
self.saved.append(deck.cards)
|
self.saved.append(deck.cards)
|
||||||
|
|
||||||
|
|
||||||
|
class _SilentSpeech:
|
||||||
|
def speak(self, text: str, language_code: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _session(
|
def _session(
|
||||||
code: str, *cards: Card, progress: _RecordingProgress | None = None
|
code: str, *cards: Card, progress: _RecordingProgress | None = None
|
||||||
) -> StudySession:
|
) -> StudySession:
|
||||||
store = _RecordingProgress() if progress is None else progress
|
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:
|
def test_view_model_draws_first_card_on_construction() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user