MIL-008: Choose level
Review record RC-041 (Go, autonomous mode, not independent). Task: MIL-008#1 Task: MIL-008#2 Task: MIL-008#3 Task: MIL-008#4 Task: MIL-008#5 Task: MIL-008#6 Task: MIL-008#7 Task: MIL-008#8
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
"""Fixtures shared by the end-to-end tests."""
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tk_root() -> Iterator[tk.Tk]:
|
||||
"""The one Tk root of the process, shared by every e2e module.
|
||||
|
||||
A second interpreter right after destroying the first fails to find
|
||||
init.tcl on some Windows Python installs, so there is only one. Each test
|
||||
builds its views on it and destroys their widgets afterwards.
|
||||
"""
|
||||
try:
|
||||
root = tk.Tk()
|
||||
except tk.TclError:
|
||||
pytest.skip("no display available for Tk")
|
||||
yield root
|
||||
root.destroy()
|
||||
@@ -29,6 +29,7 @@ from flash_card.constants import (
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
@@ -85,18 +86,20 @@ def _shown(view: FlashCardView) -> tuple[str, str]:
|
||||
)
|
||||
|
||||
|
||||
def _destroy_view(root: tk.Tk, view: FlashCardView) -> None:
|
||||
if view.flip_timer is not None:
|
||||
root.after_cancel(view.flip_timer)
|
||||
view.flip_timer = None
|
||||
for widget in (view.canvas, view.wrong_button, view.right_button):
|
||||
widget.destroy()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def window() -> Iterator[tuple[tk.Tk, FlashCardView]]:
|
||||
# One Tk per module: a second interpreter right after destroying the
|
||||
# first fails to find init.tcl on some Windows Python installs.
|
||||
try:
|
||||
root = tk.Tk()
|
||||
except tk.TclError:
|
||||
pytest.skip("no display available for Tk")
|
||||
view = _build_view(root)
|
||||
root.update_idletasks()
|
||||
yield root, view
|
||||
root.destroy()
|
||||
def window(tk_root: tk.Tk) -> Iterator[tuple[tk.Tk, FlashCardView]]:
|
||||
view = _build_view(tk_root)
|
||||
tk_root.update_idletasks()
|
||||
yield tk_root, view
|
||||
_destroy_view(tk_root, view)
|
||||
|
||||
|
||||
def test_window_has_title_background_and_padding(
|
||||
@@ -306,6 +309,7 @@ def files_view(
|
||||
CsvProgressRepository(progress),
|
||||
NullSpeech(),
|
||||
_first,
|
||||
Level(None),
|
||||
)
|
||||
view = FlashCardView(root, FlashCardViewModel(session))
|
||||
yield view, progress
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""End-to-end tests of the level screen and the flow to the first card.
|
||||
|
||||
@file test_level_window.py
|
||||
@brief Builds the real level screen with Tk and checks it against the wireframe.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import tkinter as tk
|
||||
from collections.abc import Iterator, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.constants import (
|
||||
BACKGROUND_COLOR,
|
||||
CARD_WIDTH,
|
||||
DEFAULT_DATA_DIR,
|
||||
FINISHED_MESSAGE,
|
||||
LEVEL_BUTTON_COLOR,
|
||||
LEVEL_BUTTON_FONT,
|
||||
LEVEL_SCREEN_HEIGHT,
|
||||
LEVEL_TITLE,
|
||||
LEVEL_TITLE_FONT,
|
||||
PROGRESS_FILE_NAME,
|
||||
VOCABULARY_FILE_NAME,
|
||||
WINDOW_PADDING,
|
||||
WINDOW_TITLE,
|
||||
)
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
from flash_card.presentation import app
|
||||
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||
from flash_card.presentation.features.level.level_view import LevelView
|
||||
from flash_card.presentation.features.level.level_view_model import LevelViewModel
|
||||
|
||||
|
||||
def _last(cards: Sequence[Card]) -> Card:
|
||||
return cards[-1]
|
||||
|
||||
|
||||
def _label(button: tk.Button) -> str:
|
||||
return str(button.cget("text"))
|
||||
|
||||
|
||||
def _word(view: FlashCardView) -> str:
|
||||
return str(view.canvas.itemcget(view.word_text_id, "text"))
|
||||
|
||||
|
||||
def _clear(root: tk.Tk, view: object) -> None:
|
||||
"""Destroy the widgets of whichever screen is shown, and pending timers."""
|
||||
if isinstance(view, FlashCardView):
|
||||
if view.flip_timer is not None:
|
||||
root.after_cancel(view.flip_timer)
|
||||
view.flip_timer = None
|
||||
for widget in (view.canvas, view.wrong_button, view.right_button):
|
||||
widget.destroy()
|
||||
elif isinstance(view, LevelView):
|
||||
view.destroy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_dir(tmp_path: Path) -> Path:
|
||||
shutil.copy(DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME, tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def application(tk_root: tk.Tk, data_dir: Path) -> Iterator[app.Application]:
|
||||
instance = app.Application(
|
||||
tk_root,
|
||||
CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME),
|
||||
CsvProgressRepository(data_dir / PROGRESS_FILE_NAME),
|
||||
NullSpeech(),
|
||||
_last,
|
||||
)
|
||||
yield instance
|
||||
_clear(tk_root, instance.view)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def level_view(tk_root: tk.Tk) -> Iterator[LevelView]:
|
||||
chosen: list[Level] = []
|
||||
view = LevelView(tk_root, LevelViewModel(), chosen.append)
|
||||
tk_root.update_idletasks()
|
||||
yield view
|
||||
view.destroy()
|
||||
|
||||
|
||||
def test_level_screen_shows_title_with_font_on_window_background(
|
||||
level_view: LevelView,
|
||||
) -> None:
|
||||
label = level_view.title_label
|
||||
assert label.cget("text") == LEVEL_TITLE == "Choose your level"
|
||||
assert label.cget("bg") == BACKGROUND_COLOR
|
||||
family, size, style = LEVEL_TITLE_FONT
|
||||
assert str(label.cget("font")).split() == [family, str(size), style]
|
||||
assert level_view.frame.cget("bg") == BACKGROUND_COLOR
|
||||
|
||||
|
||||
def test_level_screen_frame_has_the_card_window_size(level_view: LevelView) -> None:
|
||||
assert level_view.frame.winfo_width() == CARD_WIDTH
|
||||
assert level_view.frame.winfo_height() == LEVEL_SCREEN_HEIGHT
|
||||
|
||||
|
||||
def test_level_screen_shows_three_buttons_in_order(level_view: LevelView) -> None:
|
||||
labels = [_label(button) for button in level_view.buttons]
|
||||
assert labels == ["100 words", "1000 words", "All words"]
|
||||
rows = [int(button.grid_info()["row"]) for button in level_view.buttons]
|
||||
assert rows == [1, 2, 3]
|
||||
|
||||
|
||||
def test_level_buttons_have_equal_width_font_and_colour(
|
||||
level_view: LevelView,
|
||||
) -> None:
|
||||
widths = {int(str(button.cget("width"))) for button in level_view.buttons}
|
||||
assert len(widths) == 1
|
||||
family, size, style = LEVEL_BUTTON_FONT
|
||||
for button in level_view.buttons:
|
||||
assert str(button.cget("font")).split() == [family, str(size), style]
|
||||
assert button.cget("bg") == LEVEL_BUTTON_COLOR
|
||||
assert len({button.winfo_reqwidth() for button in level_view.buttons}) == 1
|
||||
|
||||
|
||||
def test_clicking_a_button_forwards_its_level(tk_root: tk.Tk) -> None:
|
||||
chosen: list[Level] = []
|
||||
view = LevelView(tk_root, LevelViewModel(), chosen.append)
|
||||
for button in view.buttons:
|
||||
button.invoke()
|
||||
view.destroy()
|
||||
assert chosen == [Level(100), Level(1000), Level(None)]
|
||||
|
||||
|
||||
def test_destroy_removes_the_level_frame(tk_root: tk.Tk) -> None:
|
||||
view = LevelView(tk_root, LevelViewModel(), lambda level: None)
|
||||
view.destroy()
|
||||
assert not view.frame.winfo_exists()
|
||||
|
||||
|
||||
def test_clicking_100_words_replaces_screen_with_first_card_of_first_hundred(
|
||||
tk_root: tk.Tk, application: app.Application, data_dir: Path
|
||||
) -> None:
|
||||
first_hundred = (
|
||||
CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().cards[:100]
|
||||
)
|
||||
application.show_level_screen()
|
||||
level_view = application.view
|
||||
assert isinstance(level_view, LevelView)
|
||||
|
||||
level_view.buttons[0].invoke()
|
||||
|
||||
card_view = application.view
|
||||
assert isinstance(card_view, FlashCardView)
|
||||
assert not level_view.frame.winfo_exists()
|
||||
assert _word(card_view) == first_hundred[-1].front
|
||||
assert tk_root.title() == WINDOW_TITLE
|
||||
assert int(str(tk_root.cget("padx"))) == WINDOW_PADDING
|
||||
|
||||
|
||||
def test_level_whose_words_are_all_known_shows_the_finished_message(
|
||||
application: app.Application, data_dir: Path
|
||||
) -> None:
|
||||
cards = CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().cards
|
||||
pair = CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().language_pair
|
||||
CsvProgressRepository(data_dir / PROGRESS_FILE_NAME).save(Deck(pair, cards[100:]))
|
||||
application.show_level_screen()
|
||||
level_view = application.view
|
||||
assert isinstance(level_view, LevelView)
|
||||
|
||||
level_view.buttons[0].invoke()
|
||||
|
||||
card_view = application.view
|
||||
assert isinstance(card_view, FlashCardView)
|
||||
assert _word(card_view) == FINISHED_MESSAGE
|
||||
|
||||
|
||||
def test_create_application_with_level_skips_the_level_screen(
|
||||
tk_root: tk.Tk, data_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(tk, "Tk", lambda: tk_root)
|
||||
|
||||
application = app.create_application(data_dir, False, Level(100))
|
||||
|
||||
try:
|
||||
assert isinstance(application.view, FlashCardView)
|
||||
assert not [c for c in tk_root.winfo_children() if isinstance(c, tk.Frame)]
|
||||
finally:
|
||||
_clear(tk_root, application.view)
|
||||
|
||||
|
||||
def test_create_application_without_level_shows_the_level_screen(
|
||||
tk_root: tk.Tk, data_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(tk, "Tk", lambda: tk_root)
|
||||
|
||||
application = app.create_application(data_dir, False)
|
||||
|
||||
try:
|
||||
assert isinstance(application.view, LevelView)
|
||||
assert application.root is tk_root
|
||||
finally:
|
||||
_clear(tk_root, application.view)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Integration tests of the levels over the real word list and a progress file."""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.application.use_cases.study_session import StudySession
|
||||
from flash_card.constants import (
|
||||
DEFAULT_DATA_DIR,
|
||||
PROGRESS_FILE_NAME,
|
||||
VOCABULARY_FILE_NAME,
|
||||
)
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
|
||||
VOCABULARY = CsvVocabularyRepository(DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME)
|
||||
ALL_CARDS = VOCABULARY.load().cards
|
||||
FIRST_HUNDRED = ALL_CARDS[:100]
|
||||
|
||||
Choose = Callable[[Sequence[Card]], Card]
|
||||
|
||||
|
||||
def _first(cards: Sequence[Card]) -> Card:
|
||||
return cards[0]
|
||||
|
||||
|
||||
def _open(folder: Path, level: Level, choose: Choose = _first) -> StudySession:
|
||||
return open_study_session(
|
||||
VOCABULARY,
|
||||
CsvProgressRepository(folder / PROGRESS_FILE_NAME),
|
||||
NullSpeech(),
|
||||
choose,
|
||||
level,
|
||||
)
|
||||
|
||||
|
||||
def _learn_to_the_end(session: StudySession) -> list[Card]:
|
||||
"""Mark every drawn card known until the session is finished."""
|
||||
drawn: list[Card] = []
|
||||
while (card := session.next_card()) is not None:
|
||||
drawn.append(Card(card.front_text, card.back_text))
|
||||
session.mark_known()
|
||||
return drawn
|
||||
|
||||
|
||||
def _saved(folder: Path) -> tuple[Card, ...]:
|
||||
loaded = CsvProgressRepository(folder / PROGRESS_FILE_NAME).load()
|
||||
assert loaded is not None
|
||||
return loaded.cards
|
||||
|
||||
|
||||
def test_word_list_has_one_thousand_distinct_words() -> None:
|
||||
assert len(ALL_CARDS) == 1000
|
||||
assert len(set(ALL_CARDS)) == 1000
|
||||
|
||||
|
||||
def test_level_100_draws_exactly_the_first_hundred_words(tmp_path: Path) -> None:
|
||||
drawn = _learn_to_the_end(_open(tmp_path, Level(100)))
|
||||
assert len(drawn) == 100
|
||||
assert len(set(drawn)) == 100
|
||||
assert set(drawn) == set(FIRST_HUNDRED)
|
||||
|
||||
|
||||
def test_level_100_keeps_the_other_words_in_order_in_the_file(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_learn_to_the_end(_open(tmp_path, Level(100)))
|
||||
assert _saved(tmp_path) == ALL_CARDS[100:]
|
||||
assert len(_saved(tmp_path)) == 900
|
||||
|
||||
|
||||
def test_level_all_after_level_100_draws_only_the_other_words(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_learn_to_the_end(_open(tmp_path, Level(100)))
|
||||
drawn = _learn_to_the_end(_open(tmp_path, Level(None)))
|
||||
assert len(drawn) == 900
|
||||
assert set(drawn) == set(ALL_CARDS[100:])
|
||||
assert not set(drawn) & set(FIRST_HUNDRED)
|
||||
|
||||
|
||||
def test_level_1000_equals_all_words_for_this_list(tmp_path: Path) -> None:
|
||||
session_1000 = _open(tmp_path, Level(1000))
|
||||
session_all = _open(tmp_path, Level(None))
|
||||
assert session_1000.remaining_in_level == 1000
|
||||
assert session_all.remaining_in_level == 1000
|
||||
|
||||
|
||||
def test_words_known_at_level_100_stay_known_at_level_1000(tmp_path: Path) -> None:
|
||||
first = _open(tmp_path, Level(100))
|
||||
shown = first.next_card()
|
||||
assert shown is not None
|
||||
first.mark_known()
|
||||
session = _open(tmp_path, Level(1000))
|
||||
assert session.remaining == 999
|
||||
assert session.remaining_in_level == 999
|
||||
|
||||
|
||||
def test_marking_known_at_level_100_keeps_words_outside_the_level(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _open(tmp_path, Level(100))
|
||||
session.next_card()
|
||||
session.mark_known()
|
||||
saved = _saved(tmp_path)
|
||||
assert len(saved) == 999
|
||||
assert saved[-900:] == ALL_CARDS[100:]
|
||||
|
||||
|
||||
def test_level_whose_words_are_all_known_is_finished_at_once(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_learn_to_the_end(_open(tmp_path, Level(100)))
|
||||
session = _open(tmp_path, Level(100))
|
||||
assert session.remaining_in_level == 0
|
||||
assert session.remaining == 900
|
||||
assert session.next_card() is None
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.application.use_cases.study_session import StudySession
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
@@ -36,6 +37,7 @@ def _open(folder: Path, choose: Choose = _first) -> StudySession:
|
||||
CsvProgressRepository(folder / "words_to_learn.csv"),
|
||||
NullSpeech(),
|
||||
choose,
|
||||
Level(None),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from flash_card.constants import (
|
||||
)
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
@@ -34,7 +35,11 @@ def test_open_study_session_yields_card_from_real_file(tmp_path: Path) -> None:
|
||||
cards = repository.load().cards
|
||||
progress = CsvProgressRepository(tmp_path / PROGRESS_FILE_NAME)
|
||||
session = open_study_session(
|
||||
repository, progress, NullSpeech(), lambda candidates: candidates[0]
|
||||
repository,
|
||||
progress,
|
||||
NullSpeech(),
|
||||
lambda candidates: candidates[0],
|
||||
Level(None),
|
||||
)
|
||||
|
||||
card = session.next_card()
|
||||
|
||||
@@ -11,6 +11,7 @@ from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.entities.vocabulary import Vocabulary
|
||||
from flash_card.domain.errors.vocabulary_errors import VocabularyNotFoundError
|
||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
|
||||
|
||||
class _FakeRepository:
|
||||
@@ -52,7 +53,7 @@ def test_open_study_session_builds_session_over_all_cards() -> None:
|
||||
repository = _FakeRepository(Vocabulary(LanguagePair("ms", "da"), cards))
|
||||
|
||||
session = open_study_session(
|
||||
repository, _RecordingProgress(), _SilentSpeech(), _first
|
||||
repository, _RecordingProgress(), _SilentSpeech(), _first, Level(None)
|
||||
)
|
||||
|
||||
assert session.remaining == 2
|
||||
@@ -62,7 +63,11 @@ 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(), _SilentSpeech(), _first
|
||||
_MissingRepository(),
|
||||
_RecordingProgress(),
|
||||
_SilentSpeech(),
|
||||
_first,
|
||||
Level(None),
|
||||
)
|
||||
|
||||
|
||||
@@ -72,21 +77,83 @@ def test_open_study_session_prefers_saved_progress() -> None:
|
||||
progress = _RecordingProgress(Deck(pair, [Card("kau", "du")]))
|
||||
|
||||
session = open_study_session(
|
||||
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first
|
||||
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first, Level(None)
|
||||
)
|
||||
|
||||
assert session.remaining == 1
|
||||
assert session.next_card() == CardDto("kau", "du", "ms", "da")
|
||||
|
||||
|
||||
def test_open_study_session_skips_vocabulary_when_progress_exists() -> None:
|
||||
progress = _RecordingProgress(Deck(LanguagePair("ms", "da"), []))
|
||||
PAIR = LanguagePair("ms", "da")
|
||||
THREE = (Card("aku", "mig"), Card("kau", "du"), Card("ini", "denne"))
|
||||
|
||||
|
||||
def _last(cards: Sequence[Card]) -> Card:
|
||||
return cards[-1]
|
||||
|
||||
|
||||
def test_open_study_session_propagates_error_when_list_is_missing() -> None:
|
||||
progress = _RecordingProgress(Deck(PAIR, []))
|
||||
|
||||
with pytest.raises(VocabularyNotFoundError):
|
||||
open_study_session(
|
||||
_MissingRepository(), progress, _SilentSpeech(), _first, Level(None)
|
||||
)
|
||||
|
||||
|
||||
def test_open_study_session_limits_draws_to_first_cards_of_level() -> None:
|
||||
session = open_study_session(
|
||||
_MissingRepository(), progress, _SilentSpeech(), _first
|
||||
_FakeRepository(Vocabulary(PAIR, THREE)),
|
||||
_RecordingProgress(),
|
||||
_SilentSpeech(),
|
||||
_last,
|
||||
Level(2),
|
||||
)
|
||||
|
||||
assert session.remaining == 0
|
||||
assert session.remaining == 3
|
||||
assert session.remaining_in_level == 2
|
||||
assert session.next_card() == CardDto("kau", "du", "ms", "da")
|
||||
|
||||
|
||||
def test_open_study_session_uses_vocabulary_order_with_saved_progress() -> None:
|
||||
progress = _RecordingProgress(Deck(PAIR, [THREE[2], THREE[1]]))
|
||||
session = open_study_session(
|
||||
_FakeRepository(Vocabulary(PAIR, THREE)),
|
||||
progress,
|
||||
_SilentSpeech(),
|
||||
_first,
|
||||
Level(2),
|
||||
)
|
||||
|
||||
assert session.remaining == 2
|
||||
assert session.remaining_in_level == 1
|
||||
assert session.next_card() == CardDto("kau", "du", "ms", "da")
|
||||
|
||||
|
||||
def test_open_study_session_is_finished_when_level_has_nothing_left() -> None:
|
||||
progress = _RecordingProgress(Deck(PAIR, [THREE[1]]))
|
||||
session = open_study_session(
|
||||
_FakeRepository(Vocabulary(PAIR, THREE)),
|
||||
progress,
|
||||
_SilentSpeech(),
|
||||
_first,
|
||||
Level(1),
|
||||
)
|
||||
|
||||
assert session.next_card() is None
|
||||
assert session.remaining == 1
|
||||
|
||||
|
||||
def test_open_study_session_uses_all_cards_when_level_exceeds_list() -> None:
|
||||
session = open_study_session(
|
||||
_FakeRepository(Vocabulary(PAIR, THREE)),
|
||||
_RecordingProgress(),
|
||||
_SilentSpeech(),
|
||||
_first,
|
||||
Level(1000),
|
||||
)
|
||||
|
||||
assert session.remaining_in_level == 3
|
||||
|
||||
|
||||
def test_open_study_session_never_saves_on_start() -> None:
|
||||
@@ -94,7 +161,7 @@ def test_open_study_session_never_saves_on_start() -> None:
|
||||
progress = _RecordingProgress()
|
||||
|
||||
session = open_study_session(
|
||||
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first
|
||||
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first, Level(None)
|
||||
)
|
||||
session.next_card()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests of the StudySession use case."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
import itertools
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from flash_card.application.dto.card_dto import CardDto
|
||||
from flash_card.application.use_cases.study_session import StudySession
|
||||
@@ -150,3 +151,94 @@ def test_mark_known_does_not_speak_but_following_draw_does() -> None:
|
||||
assert speech.spoken == [("aku", "ms")]
|
||||
session.next_card()
|
||||
assert speech.spoken == [("aku", "ms"), ("kau", "ms")]
|
||||
|
||||
|
||||
C = Card("ini", "denne")
|
||||
D = Card("itu", "den")
|
||||
|
||||
|
||||
def _level_session(
|
||||
deck_cards: Sequence[Card],
|
||||
level_cards: Sequence[Card] | None,
|
||||
choose: Callable[[Sequence[Card]], Card] = _first,
|
||||
progress: _RecordingProgress | None = None,
|
||||
speech: _RecordingSpeech | None = None,
|
||||
) -> StudySession:
|
||||
store = _RecordingProgress() if progress is None else progress
|
||||
voice = _RecordingSpeech() if speech is None else speech
|
||||
return StudySession(Deck(PAIR, deck_cards), choose, store, voice, level_cards)
|
||||
|
||||
|
||||
def _last(cards: Sequence[Card]) -> Card:
|
||||
return cards[-1]
|
||||
|
||||
|
||||
def test_next_card_draws_only_cards_of_the_level_over_many_draws() -> None:
|
||||
chooser = itertools.cycle([_first, _last])
|
||||
session = _level_session(
|
||||
[A, B, C, D], [A, B], choose=lambda cards: next(chooser)(cards)
|
||||
)
|
||||
drawn = {session.next_card() for _ in range(40)}
|
||||
assert drawn == {
|
||||
CardDto("aku", "mig", "ms", "da"),
|
||||
CardDto("kau", "du", "ms", "da"),
|
||||
}
|
||||
|
||||
|
||||
def test_next_card_never_draws_a_card_outside_the_level() -> None:
|
||||
session = _level_session([A, B, C], [B], choose=_last)
|
||||
for _ in range(5):
|
||||
assert session.next_card() == CardDto("kau", "du", "ms", "da")
|
||||
|
||||
|
||||
def test_remaining_counts_whole_deck_and_remaining_in_level_counts_level() -> None:
|
||||
session = _level_session([A, B, C], [A, B])
|
||||
assert session.remaining == 3
|
||||
assert session.remaining_in_level == 2
|
||||
|
||||
|
||||
def test_remaining_in_level_counts_whole_deck_without_level() -> None:
|
||||
assert _level_session([A, B, C], None).remaining_in_level == 3
|
||||
|
||||
|
||||
def test_remaining_in_level_ignores_level_cards_missing_from_deck() -> None:
|
||||
assert _level_session([B], [A, B]).remaining_in_level == 1
|
||||
|
||||
|
||||
def test_mark_known_keeps_cards_outside_the_level_and_saves_whole_deck() -> None:
|
||||
progress = _RecordingProgress()
|
||||
session = _level_session([A, B, C], [A], progress=progress)
|
||||
session.next_card()
|
||||
session.mark_known()
|
||||
assert progress.saved == [(B, C)]
|
||||
assert session.remaining == 2
|
||||
assert session.remaining_in_level == 0
|
||||
|
||||
|
||||
def test_next_card_is_none_when_nothing_of_the_level_is_left() -> None:
|
||||
session = _level_session([B, C], [A])
|
||||
assert session.next_card() is None
|
||||
assert session.current_card is None
|
||||
|
||||
|
||||
def test_session_finishes_after_last_card_of_level_is_known() -> None:
|
||||
session = _level_session([A, B], [A])
|
||||
session.next_card()
|
||||
session.mark_known()
|
||||
assert session.next_card() is None
|
||||
assert session.remaining == 1
|
||||
|
||||
|
||||
def test_next_card_speaks_only_cards_of_the_level() -> None:
|
||||
speech = _RecordingSpeech()
|
||||
session = _level_session([A, B, C], [B], choose=_last, speech=speech)
|
||||
session.next_card()
|
||||
session.next_card()
|
||||
assert speech.spoken == [("kau", "ms"), ("kau", "ms")]
|
||||
|
||||
|
||||
def test_next_card_speaks_nothing_when_level_is_finished() -> None:
|
||||
speech = _RecordingSpeech()
|
||||
session = _level_session([B], [A], speech=speech)
|
||||
session.next_card()
|
||||
assert speech.spoken == []
|
||||
|
||||
@@ -71,3 +71,47 @@ def test_remove_raises_when_card_is_absent() -> None:
|
||||
def test_is_empty_reflects_cards() -> None:
|
||||
assert Deck(PAIR, []).is_empty
|
||||
assert not Deck(PAIR, [A]).is_empty
|
||||
|
||||
|
||||
def test_within_returns_deck_cards_found_in_given_cards_in_deck_order() -> None:
|
||||
c = Card("ini", "denne")
|
||||
deck = Deck(PAIR, [A, B, c])
|
||||
assert deck.within([c, A]) == (A, c)
|
||||
|
||||
|
||||
def test_within_respects_multiplicity() -> None:
|
||||
deck = Deck(PAIR, [A, A, B])
|
||||
assert deck.within([A, B]) == (A, B)
|
||||
assert deck.within([A, A, A]) == (A, A)
|
||||
|
||||
|
||||
def test_within_returns_empty_when_no_card_matches() -> None:
|
||||
assert Deck(PAIR, [A]).within([B]) == ()
|
||||
|
||||
|
||||
def test_within_does_not_change_the_deck() -> None:
|
||||
deck = Deck(PAIR, [A, B])
|
||||
deck.within([A])
|
||||
assert deck.cards == (A, B)
|
||||
|
||||
|
||||
def test_draw_chooses_among_given_candidates_only() -> None:
|
||||
deck = Deck(PAIR, [A, B])
|
||||
assert deck.draw(_first, among=[B]) == B
|
||||
|
||||
|
||||
def test_draw_among_avoids_excluded_when_another_candidate_exists() -> None:
|
||||
c = Card("ini", "denne")
|
||||
deck = Deck(PAIR, [A, B, c])
|
||||
assert deck.draw(_first, exclude=B, among=[B, c]) == c
|
||||
|
||||
|
||||
def test_draw_among_returns_excluded_when_it_is_the_only_candidate() -> None:
|
||||
deck = Deck(PAIR, [A, B])
|
||||
assert deck.draw(_first, exclude=B, among=[B]) == B
|
||||
|
||||
|
||||
def test_draw_among_empty_raises_empty_deck_error() -> None:
|
||||
deck = Deck(PAIR, [A, B])
|
||||
with pytest.raises(EmptyDeckError):
|
||||
deck.draw(_first, among=[])
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Tests of the Level value object."""
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
|
||||
CARDS = tuple(Card(f"w{i}", f"v{i}") for i in range(1000))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit", [0, -1, -100])
|
||||
def test_level_refuses_limit_not_above_zero(limit: int) -> None:
|
||||
with pytest.raises(ValueError, match="above zero"):
|
||||
Level(limit)
|
||||
|
||||
|
||||
def test_level_accepts_no_limit() -> None:
|
||||
assert Level(None).limit is None
|
||||
|
||||
|
||||
def test_level_is_frozen() -> None:
|
||||
level = Level(100)
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
level.limit = 5 # type: ignore[misc]
|
||||
|
||||
|
||||
def test_first_returns_first_hundred_cards_when_limit_is_100() -> None:
|
||||
assert Level(100).first(CARDS) == CARDS[:100]
|
||||
|
||||
|
||||
def test_first_returns_all_cards_when_there_is_no_limit() -> None:
|
||||
assert Level(None).first(CARDS) == CARDS
|
||||
|
||||
|
||||
def test_first_returns_all_cards_when_limit_exceeds_the_list() -> None:
|
||||
assert Level(5000).first(CARDS) == CARDS
|
||||
|
||||
|
||||
def test_first_returns_empty_when_list_is_empty() -> None:
|
||||
assert Level(100).first(()) == ()
|
||||
+207
-9
@@ -1,45 +1,243 @@
|
||||
"""Tests of the entry point.
|
||||
|
||||
@file test_app.py
|
||||
@brief Checks main of flash_card.presentation.app without opening a window.
|
||||
@brief Checks main and Application of flash_card.presentation.app without Tk.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.entities.vocabulary import Vocabulary
|
||||
from flash_card.domain.errors.vocabulary_errors import VocabularyNotFoundError
|
||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.presentation import app
|
||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
FlashCardViewModel,
|
||||
)
|
||||
from flash_card.presentation.features.level.level_view_model import LevelViewModel
|
||||
|
||||
PAIR = LanguagePair("ms", "da")
|
||||
CARDS = (Card("aku", "mig"), Card("kau", "du"), Card("ini", "denne"))
|
||||
|
||||
|
||||
class _FakeRoot:
|
||||
def __init__(self) -> None:
|
||||
self.mainloop_calls = 0
|
||||
self.destroyed = False
|
||||
|
||||
def mainloop(self) -> None:
|
||||
self.mainloop_calls += 1
|
||||
|
||||
def destroy(self) -> None:
|
||||
self.destroyed = True
|
||||
|
||||
|
||||
class _FakeApplication:
|
||||
def __init__(self, root: _FakeRoot, exit_code: int = 0) -> None:
|
||||
self.root = root
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
class _FakeLevelView:
|
||||
def __init__(
|
||||
self,
|
||||
root: object,
|
||||
view_model: LevelViewModel,
|
||||
on_choose: Callable[[Level], None],
|
||||
) -> None:
|
||||
self.view_model = view_model
|
||||
self.on_choose = on_choose
|
||||
self.destroyed = False
|
||||
|
||||
def destroy(self) -> None:
|
||||
self.destroyed = True
|
||||
|
||||
|
||||
class _FakeFlashCardView:
|
||||
def __init__(self, root: object, view_model: FlashCardViewModel) -> None:
|
||||
self.view_model = view_model
|
||||
|
||||
|
||||
class _Vocabulary:
|
||||
def __init__(self, error: bool = False) -> None:
|
||||
self._error = error
|
||||
|
||||
def load(self) -> Vocabulary:
|
||||
if self._error:
|
||||
raise VocabularyNotFoundError("word list not found")
|
||||
return Vocabulary(PAIR, CARDS)
|
||||
|
||||
|
||||
class _Progress:
|
||||
def load(self) -> Deck | None:
|
||||
return None
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Speech:
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _first(cards: Sequence[Card]) -> Card:
|
||||
return cards[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_views(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(app, "LevelView", _FakeLevelView)
|
||||
monkeypatch.setattr(app, "FlashCardView", _FakeFlashCardView)
|
||||
|
||||
|
||||
def _application(root: _FakeRoot, error: bool = False) -> app.Application:
|
||||
return app.Application(
|
||||
cast(tk.Tk, root), _Vocabulary(error), _Progress(), _Speech(), _first
|
||||
)
|
||||
|
||||
|
||||
def _recording_create(
|
||||
seen: list[Level | None], exit_code: int = 0
|
||||
) -> Callable[..., _FakeApplication]:
|
||||
def fake_create(
|
||||
data_dir: Path | None = None,
|
||||
speech_enabled: bool = True,
|
||||
level: Level | None = None,
|
||||
) -> _FakeApplication:
|
||||
seen.append(level)
|
||||
return _FakeApplication(_FakeRoot(), exit_code)
|
||||
|
||||
return fake_create
|
||||
|
||||
|
||||
def test_main_runs_main_loop_and_returns_zero(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
root = _FakeRoot()
|
||||
seen: list[Path | None] = []
|
||||
seen: list[tuple[Path | None, Level | None]] = []
|
||||
|
||||
def fake_create(
|
||||
data_dir: Path | None = None, speech_enabled: bool = True
|
||||
) -> tuple[_FakeRoot, object]:
|
||||
seen.append(data_dir)
|
||||
return root, object()
|
||||
data_dir: Path | None = None,
|
||||
speech_enabled: bool = True,
|
||||
level: Level | None = None,
|
||||
) -> _FakeApplication:
|
||||
seen.append((data_dir, level))
|
||||
return _FakeApplication(root)
|
||||
|
||||
monkeypatch.setattr(app, "create_application", fake_create)
|
||||
|
||||
assert app.main(["--data-dir", str(tmp_path)]) == 0
|
||||
assert root.mainloop_calls == 1
|
||||
assert seen == [tmp_path]
|
||||
assert seen == [(tmp_path, None)]
|
||||
|
||||
|
||||
def test_main_returns_one_when_word_list_is_missing(
|
||||
def test_main_returns_one_when_word_list_is_missing_with_level(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
assert app.main(["--data-dir", str(tmp_path / "nowhere")]) == 1
|
||||
code = app.main(["--data-dir", str(tmp_path / "nowhere"), "--level", "all"])
|
||||
assert code == 1
|
||||
assert "word list not found" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "limit"), [("100", 100), ("1000", 1000), ("all", None)]
|
||||
)
|
||||
def test_main_passes_level_of_level_option(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, value: str, limit: int | None
|
||||
) -> None:
|
||||
seen: list[Level | None] = []
|
||||
monkeypatch.setattr(app, "create_application", _recording_create(seen))
|
||||
|
||||
assert app.main(["--data-dir", str(tmp_path), "--level", value]) == 0
|
||||
assert seen == [Level(limit)]
|
||||
|
||||
|
||||
def test_main_refuses_unknown_level(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with pytest.raises(SystemExit) as raised:
|
||||
app.main(["--level", "50"])
|
||||
assert raised.value.code == 2
|
||||
assert "invalid level" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_main_returns_exit_code_of_application(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
seen: list[Level | None] = []
|
||||
monkeypatch.setattr(app, "create_application", _recording_create(seen, 1))
|
||||
|
||||
assert app.main(["--data-dir", str(tmp_path)]) == 1
|
||||
|
||||
|
||||
def test_application_shows_no_view_before_a_screen_is_requested(
|
||||
fake_views: None,
|
||||
) -> None:
|
||||
assert _application(_FakeRoot()).view is None
|
||||
|
||||
|
||||
def test_show_level_screen_offers_the_three_levels(fake_views: None) -> None:
|
||||
application = _application(_FakeRoot())
|
||||
application.show_level_screen()
|
||||
view = application.view
|
||||
assert isinstance(view, _FakeLevelView)
|
||||
assert [c.level.limit for c in view.view_model.choices] == [100, 1000, None]
|
||||
|
||||
|
||||
def test_choosing_a_level_replaces_level_screen_by_card_screen(
|
||||
fake_views: None,
|
||||
) -> None:
|
||||
application = _application(_FakeRoot())
|
||||
application.show_level_screen()
|
||||
level_view = application.view
|
||||
assert isinstance(level_view, _FakeLevelView)
|
||||
|
||||
level_view.on_choose(Level(100))
|
||||
|
||||
assert level_view.destroyed
|
||||
assert isinstance(application.view, _FakeFlashCardView)
|
||||
assert application.exit_code == 0
|
||||
|
||||
|
||||
def test_start_draws_the_first_card_of_the_level(fake_views: None) -> None:
|
||||
application = _application(_FakeRoot())
|
||||
application.start(Level(2))
|
||||
view = application.view
|
||||
assert isinstance(view, _FakeFlashCardView)
|
||||
assert view.view_model.text == "aku"
|
||||
|
||||
|
||||
def test_start_without_level_screen_shows_card_screen_only(
|
||||
fake_views: None,
|
||||
) -> None:
|
||||
application = _application(_FakeRoot())
|
||||
application.start(Level(None))
|
||||
assert isinstance(application.view, _FakeFlashCardView)
|
||||
|
||||
|
||||
def test_choosing_a_level_closes_window_with_code_one_on_vocabulary_error(
|
||||
fake_views: None, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
root = _FakeRoot()
|
||||
application = _application(root, error=True)
|
||||
application.show_level_screen()
|
||||
level_view = application.view
|
||||
assert isinstance(level_view, _FakeLevelView)
|
||||
|
||||
level_view.on_choose(Level(100))
|
||||
|
||||
assert root.destroyed
|
||||
assert application.exit_code == 1
|
||||
assert "word list not found" in caplog.text
|
||||
|
||||
|
||||
def test_start_raises_vocabulary_error_for_the_caller(fake_views: None) -> None:
|
||||
application = _application(_FakeRoot(), error=True)
|
||||
with pytest.raises(VocabularyNotFoundError):
|
||||
application.start(Level(100))
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
from flash_card.presentation import app
|
||||
@@ -16,6 +17,11 @@ class _FakeRoot:
|
||||
return None
|
||||
|
||||
|
||||
class _FakeApplication:
|
||||
root = _FakeRoot()
|
||||
exit_code = 0
|
||||
|
||||
|
||||
def _found(name: str) -> ModuleSpec:
|
||||
return ModuleSpec(name, None)
|
||||
|
||||
@@ -58,10 +64,12 @@ def test_main_passes_speech_disabled_with_no_speech_option(
|
||||
seen: list[bool] = []
|
||||
|
||||
def fake_create(
|
||||
data_dir: Path | None = None, speech_enabled: bool = True
|
||||
) -> tuple[_FakeRoot, object]:
|
||||
data_dir: Path | None = None,
|
||||
speech_enabled: bool = True,
|
||||
level: Level | None = None,
|
||||
) -> _FakeApplication:
|
||||
seen.append(speech_enabled)
|
||||
return _FakeRoot(), object()
|
||||
return _FakeApplication()
|
||||
|
||||
monkeypatch.setattr(app, "create_application", fake_create)
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests of the level view model."""
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.constants import LEVEL_TITLE
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.presentation.features.level import level_view_model
|
||||
from flash_card.presentation.features.level.level_view_model import (
|
||||
LevelChoice,
|
||||
LevelViewModel,
|
||||
)
|
||||
|
||||
|
||||
def test_title_is_choose_your_level() -> None:
|
||||
assert LevelViewModel().title == LEVEL_TITLE == "Choose your level"
|
||||
|
||||
|
||||
def test_choices_are_three_levels_in_order() -> None:
|
||||
choices = LevelViewModel().choices
|
||||
assert [choice.label for choice in choices] == [
|
||||
"100 words",
|
||||
"1000 words",
|
||||
"All words",
|
||||
]
|
||||
assert [choice.level for choice in choices] == [
|
||||
Level(100),
|
||||
Level(1000),
|
||||
Level(None),
|
||||
]
|
||||
|
||||
|
||||
def test_choices_come_from_given_choices() -> None:
|
||||
model = LevelViewModel([("Ten", 10)])
|
||||
assert model.choices == (LevelChoice("Ten", Level(10)),)
|
||||
|
||||
|
||||
def test_choice_is_frozen() -> None:
|
||||
choice = LevelChoice("Ten", Level(10))
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
choice.label = "x" # type: ignore[misc]
|
||||
|
||||
|
||||
def test_view_model_module_does_not_import_tkinter() -> None:
|
||||
assert "tkinter" not in vars(level_view_model)
|
||||
Reference in New Issue
Block a user