Compare commits

...
Author SHA1 Message Date
Tirsvad 83fb0b1ae8 Resolve pending commit links of MIL-004 2026-10-14 22:05:52 +08:00
Tirsvad 02e0c2c55e 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
2026-10-14 22:05:48 +08:00
Tirsvad 9ecae3e6b2 Merge pull request 'MIL-003: Step 2 - New flash cards' (#40) from mil-003-step2-new-flash-cards into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
2026-10-14 15:59:43 +02:00
7 changed files with 575 additions and 255 deletions
+1 -1
View File
@@ -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
+64
View File
@@ -0,0 +1,64 @@
# 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 | [02e0c2c] |
---
## 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
[02e0c2c]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/02e0c2c55e2a62ca13e2dec9dbf3aa823cf16242
+3
View File
@@ -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
@@ -8,11 +8,14 @@ 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,
@@ -25,6 +28,7 @@ from flash_card.constants import (
)
from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
Side,
)
@@ -47,6 +51,8 @@ class FlashCardView:
# 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.
@@ -101,13 +107,75 @@ class FlashCardView:
)
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.
"""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
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
)
self.canvas.itemconfig(self.word_text_id, text=self._view_model.text)
@@ -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,15 +51,26 @@ 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 ""
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())
@@ -55,15 +78,27 @@ class FlashCardViewModel:
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
+112 -1
View File
@@ -4,16 +4,19 @@
@brief Builds the real window with Tk and checks it against the wireframe.
"""
import time
import tkinter as tk
from collections.abc import Iterator, Sequence
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,
@@ -23,6 +26,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.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,
@@ -134,3 +138,110 @@ def test_each_button_press_shows_next_card(
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)
+39
View File
@@ -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"