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
369 lines
11 KiB
Python
369 lines
11 KiB
Python
"""End-to-end tests of the flash card window.
|
|
|
|
@file test_flash_card_window.py
|
|
@brief Builds the real window with Tk and checks it against the wireframe.
|
|
"""
|
|
|
|
import time
|
|
import tkinter as tk
|
|
from collections.abc import Callable, Iterator, Sequence
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
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 (
|
|
BACK_TEXT_COLOR,
|
|
BACKGROUND_COLOR,
|
|
CARD_HEIGHT,
|
|
CARD_WIDTH,
|
|
FINISHED_MESSAGE,
|
|
FLIP_DELAY_MS,
|
|
FRONT_TEXT_COLOR,
|
|
LANGUAGE_FONT,
|
|
WINDOW_PADDING,
|
|
WINDOW_TITLE,
|
|
WORD_FONT,
|
|
)
|
|
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,
|
|
)
|
|
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 (
|
|
FlashCardViewModel,
|
|
)
|
|
|
|
CARDS = (Card("aku", "mig"), Card("kau", "du"), Card("ini", "denne"))
|
|
|
|
|
|
def _first(cards: Sequence[Card]) -> Card:
|
|
return cards[0]
|
|
|
|
|
|
class _MemoryProgress:
|
|
"""Progress store that keeps nothing: the shared window never deletes cards."""
|
|
|
|
def load(self) -> Deck | None:
|
|
return None
|
|
|
|
def save(self, deck: Deck) -> 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:
|
|
deck = Deck(LanguagePair("ms", "da"), CARDS)
|
|
session = StudySession(deck, _first, _MemoryProgress(), SPEECH)
|
|
return FlashCardView(root, FlashCardViewModel(session))
|
|
|
|
|
|
def _shown(view: FlashCardView) -> tuple[str, str]:
|
|
return (
|
|
str(view.canvas.itemcget(view.language_text_id, "text")),
|
|
str(view.canvas.itemcget(view.word_text_id, "text")),
|
|
)
|
|
|
|
|
|
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(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(
|
|
window: tuple[tk.Tk, FlashCardView],
|
|
) -> None:
|
|
root, _view = window
|
|
assert root.title() == WINDOW_TITLE
|
|
assert root.cget("bg") == BACKGROUND_COLOR
|
|
assert int(str(root.cget("padx"))) == WINDOW_PADDING
|
|
assert int(str(root.cget("pady"))) == WINDOW_PADDING
|
|
|
|
|
|
def test_canvas_has_card_size_and_spans_two_columns(
|
|
window: tuple[tk.Tk, FlashCardView],
|
|
) -> None:
|
|
_root, view = window
|
|
assert int(str(view.canvas.cget("width"))) == CARD_WIDTH
|
|
assert int(str(view.canvas.cget("height"))) == CARD_HEIGHT
|
|
info = view.canvas.grid_info()
|
|
assert (int(info["row"]), int(info["column"])) == (0, 0)
|
|
assert int(info["columnspan"]) == 2
|
|
|
|
|
|
def test_canvas_holds_one_image_and_two_texts(
|
|
window: tuple[tk.Tk, FlashCardView],
|
|
) -> None:
|
|
_root, view = window
|
|
types = [view.canvas.type(item) for item in view.canvas.find_all()]
|
|
assert types.count("image") == 1
|
|
assert types.count("text") == 2
|
|
assert len(types) == 3
|
|
|
|
|
|
def test_texts_show_first_card_with_font_and_colour(
|
|
window: tuple[tk.Tk, FlashCardView],
|
|
) -> None:
|
|
_root, view = window
|
|
canvas = view.canvas
|
|
expected = [
|
|
(view.language_text_id, "Malay", LANGUAGE_FONT),
|
|
(view.word_text_id, "aku", WORD_FONT),
|
|
]
|
|
for item, text, font in expected:
|
|
assert canvas.itemcget(item, "text") == text
|
|
assert canvas.itemcget(item, "fill") == FRONT_TEXT_COLOR
|
|
family, size, style = font
|
|
assert canvas.itemcget(item, "font").split() == [family, str(size), style]
|
|
|
|
|
|
def test_buttons_sit_in_row_one_with_their_images(
|
|
window: tuple[tk.Tk, FlashCardView],
|
|
) -> None:
|
|
_root, view = window
|
|
wrong = view.wrong_button.grid_info()
|
|
right = view.right_button.grid_info()
|
|
assert (int(wrong["row"]), int(wrong["column"])) == (1, 0)
|
|
assert (int(right["row"]), int(right["column"])) == (1, 1)
|
|
wrong_image = str(view.wrong_button.cget("image"))
|
|
right_image = str(view.right_button.cget("image"))
|
|
assert wrong_image
|
|
assert right_image
|
|
assert wrong_image != right_image
|
|
assert str(view.wrong_button.cget("command"))
|
|
assert str(view.right_button.cget("command"))
|
|
|
|
|
|
def test_buttons_sit_on_the_window_background(
|
|
window: tuple[tk.Tk, FlashCardView],
|
|
) -> None:
|
|
_root, view = window
|
|
assert view.wrong_button.cget("bg") == BACKGROUND_COLOR
|
|
assert view.right_button.cget("bg") == BACKGROUND_COLOR
|
|
|
|
|
|
def test_wrong_button_press_shows_next_card(
|
|
window: tuple[tk.Tk, FlashCardView],
|
|
) -> None:
|
|
_root, view = window
|
|
assert _shown(view) == ("Malay", "aku")
|
|
view.wrong_button.invoke()
|
|
assert _shown(view) == ("Malay", "kau")
|
|
view.wrong_button.invoke()
|
|
assert _shown(view) == ("Malay", "aku")
|
|
|
|
|
|
def _word(view: FlashCardView) -> str:
|
|
return _shown(view)[1]
|
|
|
|
|
|
def _image(view: FlashCardView) -> str:
|
|
return str(view.canvas.itemcget(view.image_id, "image"))
|
|
|
|
|
|
def _fill(view: FlashCardView) -> tuple[str, str]:
|
|
return (
|
|
str(view.canvas.itemcget(view.language_text_id, "fill")),
|
|
str(view.canvas.itemcget(view.word_text_id, "fill")),
|
|
)
|
|
|
|
|
|
def _reset(root: tk.Tk, view: FlashCardView) -> None:
|
|
"""Show a fresh front with no pending timer."""
|
|
view.next_card()
|
|
if view.flip_timer is not None:
|
|
root.after_cancel(view.flip_timer)
|
|
view.flip_timer = None
|
|
|
|
|
|
def test_front_state_uses_front_image_black_text_and_front_language(
|
|
window: tuple[tk.Tk, FlashCardView],
|
|
) -> None:
|
|
root, view = window
|
|
_reset(root, view)
|
|
assert _image(view) == str(view.card_front_image)
|
|
assert _fill(view) == (FRONT_TEXT_COLOR, FRONT_TEXT_COLOR)
|
|
assert _shown(view)[0] == "Malay"
|
|
assert _word(view) in {card.front for card in CARDS}
|
|
|
|
|
|
def test_flip_card_shows_back_image_white_text_and_translation(
|
|
window: tuple[tk.Tk, FlashCardView],
|
|
) -> None:
|
|
root, view = window
|
|
_reset(root, view)
|
|
front = _word(view)
|
|
view.flip_card()
|
|
translations = {card.front: card.back for card in CARDS}
|
|
assert _image(view) == str(view.card_back_image)
|
|
assert _image(view) != str(view.card_front_image)
|
|
assert _fill(view) == (BACK_TEXT_COLOR, BACK_TEXT_COLOR)
|
|
assert _shown(view) == ("Danish", translations[front])
|
|
assert view.flip_timer is None
|
|
|
|
|
|
def test_next_card_after_flip_shows_front_again(
|
|
window: tuple[tk.Tk, FlashCardView],
|
|
) -> None:
|
|
root, view = window
|
|
_reset(root, view)
|
|
view.flip_card()
|
|
view.wrong_button.invoke()
|
|
assert _image(view) == str(view.card_front_image)
|
|
assert _fill(view) == (FRONT_TEXT_COLOR, FRONT_TEXT_COLOR)
|
|
assert _shown(view)[0] == "Malay"
|
|
_reset(root, view)
|
|
|
|
|
|
def test_next_card_cancels_pending_timer_and_schedules_flip(
|
|
window: tuple[tk.Tk, FlashCardView],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
root, view = window
|
|
_reset(root, view)
|
|
calls: list[tuple[str, object]] = []
|
|
|
|
def fake_after(delay: int, callback: Callable[[], None]) -> str:
|
|
calls.append(("after", (delay, callback)))
|
|
return "timer-id"
|
|
|
|
def fake_cancel(timer_id: str) -> None:
|
|
calls.append(("cancel", timer_id))
|
|
|
|
monkeypatch.setattr(root, "after", fake_after, raising=False)
|
|
monkeypatch.setattr(root, "after_cancel", fake_cancel, raising=False)
|
|
view.flip_timer = "old-id"
|
|
view.next_card()
|
|
assert calls == [
|
|
("cancel", "old-id"),
|
|
("after", (FLIP_DELAY_MS, view.flip_card)),
|
|
]
|
|
assert view.flip_timer == "timer-id"
|
|
view.flip_timer = None
|
|
|
|
|
|
def test_real_timer_flips_card_after_delay(
|
|
window: tuple[tk.Tk, FlashCardView],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
root, view = window
|
|
monkeypatch.setattr(flash_card_view, "FLIP_DELAY_MS", 50)
|
|
view.next_card()
|
|
assert _image(view) == str(view.card_front_image)
|
|
deadline = time.monotonic() + 2
|
|
while view.flip_timer is not None and time.monotonic() < deadline:
|
|
root.update()
|
|
time.sleep(0.005)
|
|
assert view.flip_timer is None
|
|
assert _image(view) == str(view.card_back_image)
|
|
assert _shown(view)[0] == "Danish"
|
|
_reset(root, view)
|
|
|
|
|
|
HEADER_ONLY = "ms;da\n"
|
|
|
|
|
|
@pytest.fixture
|
|
def files_view(
|
|
window: tuple[tk.Tk, FlashCardView], tmp_path: Path
|
|
) -> Iterator[tuple[FlashCardView, Path]]:
|
|
"""A second view on the shared root, over real repositories in tmp_path."""
|
|
root, _shared = window
|
|
words = tmp_path / "words.csv"
|
|
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),
|
|
NullSpeech(),
|
|
_first,
|
|
Level(None),
|
|
)
|
|
view = FlashCardView(root, FlashCardViewModel(session))
|
|
yield view, progress
|
|
if view.flip_timer is not None:
|
|
root.after_cancel(view.flip_timer)
|
|
for widget in (view.canvas, view.wrong_button, view.right_button):
|
|
widget.destroy()
|
|
|
|
|
|
def _saved(progress: Path) -> str:
|
|
return progress.read_bytes().decode("utf-8-sig")
|
|
|
|
|
|
def test_right_button_removes_card_and_writes_progress_file(
|
|
files_view: tuple[FlashCardView, Path],
|
|
) -> None:
|
|
view, progress = files_view
|
|
assert _shown(view) == ("Malay", "aku")
|
|
view.right_button.invoke()
|
|
assert _shown(view) == ("Malay", "kau")
|
|
assert _saved(progress) == "ms;da\nkau;du\nini;denne\n"
|
|
|
|
|
|
def test_wrong_button_shows_next_card_and_leaves_progress_file_absent(
|
|
files_view: tuple[FlashCardView, Path],
|
|
) -> None:
|
|
view, progress = files_view
|
|
view.wrong_button.invoke()
|
|
assert _shown(view) == ("Malay", "kau")
|
|
assert not progress.exists()
|
|
|
|
|
|
def test_buttons_do_nothing_visible_when_finished(
|
|
files_view: tuple[FlashCardView, Path],
|
|
) -> None:
|
|
view, progress = files_view
|
|
for _ in range(3):
|
|
view.right_button.invoke()
|
|
assert _shown(view) == ("", FINISHED_MESSAGE)
|
|
assert view.flip_timer is None
|
|
assert _saved(progress) == HEADER_ONLY
|
|
view.right_button.invoke()
|
|
view.wrong_button.invoke()
|
|
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)
|