MIL-004: Step 3 - Flip the cards
Review record RC-026 (Go, autonomous mode, not independent). Task: MIL-004#1 Task: MIL-004#2 Task: MIL-004#3
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 |
|
||||
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 026 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 027 |
|
||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||
|
||||
## Languages
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# RC-026: Review of PY-MIL-004
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-026 |
|
||||
| CrossReference | [PY-MIL-004], [QC-PY-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [PY-MIL-004]
|
||||
- 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 assignment's (`flip_card`, `flip_timer`, `next_card`) and the IT terms of the dictionary; `Side` states its purpose. |
|
||||
| 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 with no suppressions. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 31 source files. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No exception handling was added; none is bare. |
|
||||
| 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 | No file or connection is opened in this change. |
|
||||
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | New and changed 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 | No `print`; no logging needed. |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `Side`, `flip_card`, `flip_timer` and the view's timer follow DCD-001 and SD-001 (`flip_card` row of Method Traceability, timer step of SSD-001). |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 76 tests: view model sides, finished state, flip rules; e2e front and back state, recording scheduler, real 50 ms timer; no network, no order dependency (state reset in each test). |
|
||||
| 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` stays empty. |
|
||||
| 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 side rule exists once in the view model; image and colour names come from `constants.py`; `_show_card` is the only place that draws a card. |
|
||||
| 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 view model imports no Tkinter. |
|
||||
| 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 | The view model holds the side rule and the view only draws; a new side would extend `Side` and `_show_card`. |
|
||||
| 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 | Files stay in `presentation/features/flash_card/`; tests in `unit` and `e2e`. |
|
||||
| 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 | Names follow the rule; no new file contains a space. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
|
||||
|
||||
Milestone MIL-004 Go criteria: 1 `flip_card` changes side, language name and text (unit test); 2 `next_card` returns to the front (unit test); 3 `after(3000, flip_card)` is scheduled and the earlier id is cancelled first (recording scheduler e2e test); 4 back image and white text, front image and black text (e2e); 5 `PhotoImage` objects are attributes created in `__init__`, none in a callback (grep); 6 this record, `pytest` 76 passed. A manual run with a 100 ms delay showed `Danish` and white text. All Go.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| none | - | - |
|
||||
|
||||
---
|
||||
|
||||
[PY-MIL-004]: ../../../src/flash_card/presentation
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
@@ -48,6 +48,7 @@ updated whenever an artifact instance is created or reviewed.
|
||||
| PY-MIL-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] |
|
||||
| PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] |
|
||||
| PY-MIL-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] |
|
||||
| PY-MIL-004 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-026] |
|
||||
|
||||
## Coverage Notes
|
||||
|
||||
@@ -108,3 +109,5 @@ updated whenever an artifact instance is created or reviewed.
|
||||
[PY-MIL-002]: ../../src/flash_card
|
||||
[RC-025]: ./reviews/rc-025-py-mil-003.md
|
||||
[PY-MIL-003]: ../../src/flash_card
|
||||
[RC-026]: ./reviews/rc-026-py-mil-004.md
|
||||
[PY-MIL-004]: ../../src/flash_card
|
||||
|
||||
@@ -1,113 +1,181 @@
|
||||
"""View of the flash card screen.
|
||||
|
||||
@file flash_card_view.py
|
||||
@brief Builds the window of the wireframe: card canvas and two buttons.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
from flash_card.constants import (
|
||||
ASSETS_DIR,
|
||||
BACKGROUND_COLOR,
|
||||
CARD_CENTER_X,
|
||||
CARD_FRONT_IMAGE_NAME,
|
||||
CARD_HEIGHT,
|
||||
CARD_WIDTH,
|
||||
FRONT_TEXT_COLOR,
|
||||
LANGUAGE_FONT,
|
||||
LANGUAGE_TEXT_Y,
|
||||
RIGHT_IMAGE_NAME,
|
||||
WINDOW_PADDING,
|
||||
WINDOW_TITLE,
|
||||
WORD_FONT,
|
||||
WORD_TEXT_Y,
|
||||
WRONG_IMAGE_NAME,
|
||||
)
|
||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
FlashCardViewModel,
|
||||
)
|
||||
|
||||
|
||||
class FlashCardView:
|
||||
"""The flash card window: a card and the wrong and right buttons."""
|
||||
|
||||
def __init__(self, root: tk.Tk, view_model: FlashCardViewModel) -> None:
|
||||
"""Build the window on a root.
|
||||
|
||||
@param root The Tk root window.
|
||||
@param view_model The data to show.
|
||||
"""
|
||||
## @brief The Tk root window.
|
||||
self._root = root
|
||||
## @brief The data source of the texts.
|
||||
self._view_model = view_model
|
||||
root.title(WINDOW_TITLE)
|
||||
root.config(padx=WINDOW_PADDING, pady=WINDOW_PADDING, bg=BACKGROUND_COLOR)
|
||||
|
||||
# Kept as attributes: Tkinter drops an image nothing refers to.
|
||||
## @brief Card front image, kept so Tk does not drop it.
|
||||
self._card_front_image = tk.PhotoImage(file=ASSETS_DIR / CARD_FRONT_IMAGE_NAME)
|
||||
## @brief Wrong button image, kept so Tk does not drop it.
|
||||
self._wrong_image = tk.PhotoImage(file=ASSETS_DIR / WRONG_IMAGE_NAME)
|
||||
## @brief Right button image, kept so Tk does not drop it.
|
||||
self._right_image = tk.PhotoImage(file=ASSETS_DIR / RIGHT_IMAGE_NAME)
|
||||
|
||||
## @brief Canvas that draws the card.
|
||||
self.canvas = tk.Canvas(
|
||||
root,
|
||||
width=CARD_WIDTH,
|
||||
height=CARD_HEIGHT,
|
||||
bg=BACKGROUND_COLOR,
|
||||
highlightthickness=0,
|
||||
)
|
||||
## @brief Canvas item id of the card image.
|
||||
self.image_id = self.canvas.create_image(
|
||||
CARD_CENTER_X, CARD_HEIGHT / 2, image=self._card_front_image
|
||||
)
|
||||
## @brief Canvas item id of the language name text.
|
||||
self.language_text_id = self.canvas.create_text(
|
||||
CARD_CENTER_X,
|
||||
LANGUAGE_TEXT_Y,
|
||||
text=view_model.language_name,
|
||||
font=LANGUAGE_FONT,
|
||||
fill=FRONT_TEXT_COLOR,
|
||||
)
|
||||
## @brief Canvas item id of the word text.
|
||||
self.word_text_id = self.canvas.create_text(
|
||||
CARD_CENTER_X,
|
||||
WORD_TEXT_Y,
|
||||
text=view_model.text,
|
||||
font=WORD_FONT,
|
||||
fill=FRONT_TEXT_COLOR,
|
||||
)
|
||||
self.canvas.grid(row=0, column=0, columnspan=2)
|
||||
|
||||
## @brief Button for a word the user does not know.
|
||||
self.wrong_button = tk.Button(
|
||||
root,
|
||||
image=self._wrong_image,
|
||||
highlightthickness=0,
|
||||
bd=0,
|
||||
command=self.next_card,
|
||||
)
|
||||
self.wrong_button.grid(row=1, column=0)
|
||||
## @brief Button for a word the user knows.
|
||||
self.right_button = tk.Button(
|
||||
root,
|
||||
image=self._right_image,
|
||||
highlightthickness=0,
|
||||
bd=0,
|
||||
command=self.next_card,
|
||||
)
|
||||
self.right_button.grid(row=1, column=1)
|
||||
|
||||
def next_card(self) -> None:
|
||||
"""Show the next card.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
self._view_model.next_card()
|
||||
self.canvas.itemconfig(
|
||||
self.language_text_id, text=self._view_model.language_name
|
||||
)
|
||||
self.canvas.itemconfig(self.word_text_id, text=self._view_model.text)
|
||||
"""View of the flash card screen.
|
||||
|
||||
@file flash_card_view.py
|
||||
@brief Builds the window of the wireframe: card canvas and two buttons.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
from flash_card.constants import (
|
||||
ASSETS_DIR,
|
||||
BACK_TEXT_COLOR,
|
||||
BACKGROUND_COLOR,
|
||||
CARD_BACK_IMAGE_NAME,
|
||||
CARD_CENTER_X,
|
||||
CARD_FRONT_IMAGE_NAME,
|
||||
CARD_HEIGHT,
|
||||
CARD_WIDTH,
|
||||
FLIP_DELAY_MS,
|
||||
FRONT_TEXT_COLOR,
|
||||
LANGUAGE_FONT,
|
||||
LANGUAGE_TEXT_Y,
|
||||
RIGHT_IMAGE_NAME,
|
||||
WINDOW_PADDING,
|
||||
WINDOW_TITLE,
|
||||
WORD_FONT,
|
||||
WORD_TEXT_Y,
|
||||
WRONG_IMAGE_NAME,
|
||||
)
|
||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
FlashCardViewModel,
|
||||
Side,
|
||||
)
|
||||
|
||||
|
||||
class FlashCardView:
|
||||
"""The flash card window: a card and the wrong and right buttons."""
|
||||
|
||||
def __init__(self, root: tk.Tk, view_model: FlashCardViewModel) -> None:
|
||||
"""Build the window on a root.
|
||||
|
||||
@param root The Tk root window.
|
||||
@param view_model The data to show.
|
||||
"""
|
||||
## @brief The Tk root window.
|
||||
self._root = root
|
||||
## @brief The data source of the texts.
|
||||
self._view_model = view_model
|
||||
root.title(WINDOW_TITLE)
|
||||
root.config(padx=WINDOW_PADDING, pady=WINDOW_PADDING, bg=BACKGROUND_COLOR)
|
||||
|
||||
# Kept as attributes: Tkinter drops an image nothing refers to.
|
||||
## @brief Card front image, kept so Tk does not drop it.
|
||||
self._card_front_image = tk.PhotoImage(file=ASSETS_DIR / CARD_FRONT_IMAGE_NAME)
|
||||
## @brief Card back image, kept so Tk does not drop it.
|
||||
self._card_back_image = tk.PhotoImage(file=ASSETS_DIR / CARD_BACK_IMAGE_NAME)
|
||||
## @brief Wrong button image, kept so Tk does not drop it.
|
||||
self._wrong_image = tk.PhotoImage(file=ASSETS_DIR / WRONG_IMAGE_NAME)
|
||||
## @brief Right button image, kept so Tk does not drop it.
|
||||
self._right_image = tk.PhotoImage(file=ASSETS_DIR / RIGHT_IMAGE_NAME)
|
||||
|
||||
## @brief Canvas that draws the card.
|
||||
self.canvas = tk.Canvas(
|
||||
root,
|
||||
width=CARD_WIDTH,
|
||||
height=CARD_HEIGHT,
|
||||
bg=BACKGROUND_COLOR,
|
||||
highlightthickness=0,
|
||||
)
|
||||
## @brief Canvas item id of the card image.
|
||||
self.image_id = self.canvas.create_image(
|
||||
CARD_CENTER_X, CARD_HEIGHT / 2, image=self._card_front_image
|
||||
)
|
||||
## @brief Canvas item id of the language name text.
|
||||
self.language_text_id = self.canvas.create_text(
|
||||
CARD_CENTER_X,
|
||||
LANGUAGE_TEXT_Y,
|
||||
text=view_model.language_name,
|
||||
font=LANGUAGE_FONT,
|
||||
fill=FRONT_TEXT_COLOR,
|
||||
)
|
||||
## @brief Canvas item id of the word text.
|
||||
self.word_text_id = self.canvas.create_text(
|
||||
CARD_CENTER_X,
|
||||
WORD_TEXT_Y,
|
||||
text=view_model.text,
|
||||
font=WORD_FONT,
|
||||
fill=FRONT_TEXT_COLOR,
|
||||
)
|
||||
self.canvas.grid(row=0, column=0, columnspan=2)
|
||||
|
||||
## @brief Button for a word the user does not know.
|
||||
self.wrong_button = tk.Button(
|
||||
root,
|
||||
image=self._wrong_image,
|
||||
highlightthickness=0,
|
||||
bd=0,
|
||||
command=self.next_card,
|
||||
)
|
||||
self.wrong_button.grid(row=1, column=0)
|
||||
## @brief Button for a word the user knows.
|
||||
self.right_button = tk.Button(
|
||||
root,
|
||||
image=self._right_image,
|
||||
highlightthickness=0,
|
||||
bd=0,
|
||||
command=self.next_card,
|
||||
)
|
||||
self.right_button.grid(row=1, column=1)
|
||||
|
||||
## @brief Id of the pending flip timer, or None when none is pending.
|
||||
self.flip_timer: str | None = None
|
||||
self._start_flip_timer()
|
||||
|
||||
@property
|
||||
def card_front_image(self) -> tk.PhotoImage:
|
||||
"""Return the card front image.
|
||||
|
||||
@return The image shown while the front side is up.
|
||||
"""
|
||||
return self._card_front_image
|
||||
|
||||
@property
|
||||
def card_back_image(self) -> tk.PhotoImage:
|
||||
"""Return the card back image.
|
||||
|
||||
@return The image shown while the back side is up.
|
||||
"""
|
||||
return self._card_back_image
|
||||
|
||||
def next_card(self) -> None:
|
||||
"""Show the next card front and restart the flip timer.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
self._cancel_flip_timer()
|
||||
self._view_model.next_card()
|
||||
self._show_card()
|
||||
self._start_flip_timer()
|
||||
|
||||
def flip_card(self) -> None:
|
||||
"""Show the back of the current card.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
self._cancel_flip_timer()
|
||||
self._view_model.flip_card()
|
||||
self._show_card()
|
||||
|
||||
def _cancel_flip_timer(self) -> None:
|
||||
"""Cancel the pending flip timer, if any.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
if self.flip_timer is not None:
|
||||
self._root.after_cancel(self.flip_timer)
|
||||
self.flip_timer = None
|
||||
|
||||
def _start_flip_timer(self) -> None:
|
||||
"""Schedule the flip unless no card is left.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
if not self._view_model.is_finished:
|
||||
self.flip_timer = self._root.after(FLIP_DELAY_MS, self.flip_card)
|
||||
|
||||
def _show_card(self) -> None:
|
||||
"""Draw image and texts of the side the view model shows.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
if self._view_model.side is Side.BACK:
|
||||
image, color = self._card_back_image, BACK_TEXT_COLOR
|
||||
else:
|
||||
image, color = self._card_front_image, FRONT_TEXT_COLOR
|
||||
self.canvas.itemconfig(self.image_id, image=image)
|
||||
self.canvas.itemconfig(
|
||||
self.language_text_id, text=self._view_model.language_name, fill=color
|
||||
)
|
||||
self.canvas.itemconfig(
|
||||
self.word_text_id, text=self._view_model.text, fill=color
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@brief Exposes the texts shown on the card; free of any Tkinter import.
|
||||
"""
|
||||
|
||||
import enum
|
||||
from collections.abc import Mapping
|
||||
|
||||
from flash_card.application.dto.card_dto import CardDto
|
||||
@@ -11,6 +12,15 @@ from flash_card.application.use_cases.study_session import StudySession
|
||||
from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES
|
||||
|
||||
|
||||
class Side(enum.Enum):
|
||||
"""The side of a card that is shown."""
|
||||
|
||||
## @brief The front: the word to learn.
|
||||
FRONT = enum.auto()
|
||||
## @brief The back: the translation.
|
||||
BACK = enum.auto()
|
||||
|
||||
|
||||
class FlashCardViewModel:
|
||||
"""Data the flash card view shows."""
|
||||
|
||||
@@ -30,6 +40,8 @@ class FlashCardViewModel:
|
||||
self._language_names = language_names
|
||||
## @brief The card shown now, or None when none is left.
|
||||
self._card: CardDto | None = session.next_card()
|
||||
## @brief The side of the card shown.
|
||||
self._side = Side.FRONT
|
||||
|
||||
@property
|
||||
def is_finished(self) -> bool:
|
||||
@@ -39,31 +51,54 @@ class FlashCardViewModel:
|
||||
"""
|
||||
return self._card is None
|
||||
|
||||
@property
|
||||
def side(self) -> Side:
|
||||
"""Return the side of the card shown.
|
||||
|
||||
@return FRONT or BACK; FRONT when no card is left.
|
||||
"""
|
||||
return self._side
|
||||
|
||||
@property
|
||||
def language_name(self) -> str:
|
||||
"""Return the language name shown on the card.
|
||||
|
||||
@return The display name of the front language, upper case code when
|
||||
unknown, or an empty string when no card is left.
|
||||
@return The display name of the language of the side shown, upper case
|
||||
code when unknown, or an empty string when no card is left.
|
||||
"""
|
||||
if self._card is None:
|
||||
return ""
|
||||
code = self._card.front_language
|
||||
if self._side is Side.BACK:
|
||||
code = self._card.back_language
|
||||
else:
|
||||
code = self._card.front_language
|
||||
return self._language_names.get(code, code.upper())
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Return the word shown on the card.
|
||||
|
||||
@return The front text, or the finished message when no card is left.
|
||||
@return The text of the side shown, or the finished message when no
|
||||
card is left.
|
||||
"""
|
||||
if self._card is None:
|
||||
return FINISHED_MESSAGE
|
||||
if self._side is Side.BACK:
|
||||
return self._card.back_text
|
||||
return self._card.front_text
|
||||
|
||||
def next_card(self) -> None:
|
||||
"""Draw the next card.
|
||||
"""Draw the next card and show its front.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
self._card = self._session.next_card()
|
||||
self._side = Side.FRONT
|
||||
|
||||
def flip_card(self) -> None:
|
||||
"""Show the back of the card; does nothing when finished or flipped.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
if self._card is not None:
|
||||
self._side = Side.BACK
|
||||
|
||||
+247
-136
@@ -1,136 +1,247 @@
|
||||
"""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 tkinter as tk
|
||||
from collections.abc import Iterator, Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.application.use_cases.study_session import StudySession
|
||||
from flash_card.constants import (
|
||||
BACKGROUND_COLOR,
|
||||
CARD_HEIGHT,
|
||||
CARD_WIDTH,
|
||||
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.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]
|
||||
|
||||
|
||||
def _build_view(root: tk.Tk) -> FlashCardView:
|
||||
deck = Deck(LanguagePair("ms", "da"), CARDS)
|
||||
return FlashCardView(root, FlashCardViewModel(StudySession(deck, _first)))
|
||||
|
||||
|
||||
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")),
|
||||
)
|
||||
|
||||
|
||||
@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 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_each_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.right_button.invoke()
|
||||
assert _shown(view) == ("Malay", "aku")
|
||||
"""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
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.application.use_cases.study_session import StudySession
|
||||
from flash_card.constants import (
|
||||
BACK_TEXT_COLOR,
|
||||
BACKGROUND_COLOR,
|
||||
CARD_HEIGHT,
|
||||
CARD_WIDTH,
|
||||
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.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]
|
||||
|
||||
|
||||
def _build_view(root: tk.Tk) -> FlashCardView:
|
||||
deck = Deck(LanguagePair("ms", "da"), CARDS)
|
||||
return FlashCardView(root, FlashCardViewModel(StudySession(deck, _first)))
|
||||
|
||||
|
||||
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")),
|
||||
)
|
||||
|
||||
|
||||
@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 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_each_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.right_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.right_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)
|
||||
|
||||
@@ -9,6 +9,7 @@ from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
FlashCardViewModel,
|
||||
Side,
|
||||
)
|
||||
|
||||
A = Card("aku", "mig")
|
||||
@@ -51,3 +52,41 @@ def test_view_model_shows_finished_message_when_no_card_is_left() -> None:
|
||||
assert view_model.is_finished
|
||||
assert view_model.language_name == ""
|
||||
assert view_model.text == FINISHED_MESSAGE
|
||||
|
||||
|
||||
def test_view_model_starts_on_front_side() -> None:
|
||||
view_model = FlashCardViewModel(_session("ms", A))
|
||||
assert view_model.side is Side.FRONT
|
||||
|
||||
|
||||
def test_flip_card_shows_back_language_and_translation() -> None:
|
||||
view_model = FlashCardViewModel(_session("ms", A))
|
||||
view_model.flip_card()
|
||||
assert view_model.side is Side.BACK
|
||||
assert view_model.language_name == "Danish"
|
||||
assert view_model.text == "mig"
|
||||
|
||||
|
||||
def test_flip_card_twice_keeps_back_side() -> None:
|
||||
view_model = FlashCardViewModel(_session("ms", A))
|
||||
view_model.flip_card()
|
||||
view_model.flip_card()
|
||||
assert view_model.side is Side.BACK
|
||||
assert view_model.text == "mig"
|
||||
|
||||
|
||||
def test_flip_card_does_nothing_when_finished() -> None:
|
||||
view_model = FlashCardViewModel(_session("ms"))
|
||||
view_model.flip_card()
|
||||
assert view_model.side is Side.FRONT
|
||||
assert view_model.language_name == ""
|
||||
assert view_model.text == FINISHED_MESSAGE
|
||||
|
||||
|
||||
def test_next_card_resets_side_to_front() -> None:
|
||||
view_model = FlashCardViewModel(_session("ms", A, B))
|
||||
view_model.flip_card()
|
||||
view_model.next_card()
|
||||
assert view_model.side is Side.FRONT
|
||||
assert view_model.language_name == "Malay"
|
||||
assert view_model.text == "kau"
|
||||
|
||||
Reference in New Issue
Block a user