diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 543716f..5a400ec 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -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 diff --git a/docs/sqa/reviews/rc-026-py-mil-004.md b/docs/sqa/reviews/rc-026-py-mil-004.md new file mode 100644 index 0000000..3aaffa0 --- /dev/null +++ b/docs/sqa/reviews/rc-026-py-mil-004.md @@ -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 diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 4d7985b..b5ce66e 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.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 diff --git a/src/flash_card/presentation/features/flash_card/flash_card_view.py b/src/flash_card/presentation/features/flash_card/flash_card_view.py index 045d05a..d648a7f 100644 --- a/src/flash_card/presentation/features/flash_card/flash_card_view.py +++ b/src/flash_card/presentation/features/flash_card/flash_card_view.py @@ -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 + ) diff --git a/src/flash_card/presentation/features/flash_card/flash_card_view_model.py b/src/flash_card/presentation/features/flash_card/flash_card_view_model.py index c8aa8cb..2dc2059 100644 --- a/src/flash_card/presentation/features/flash_card/flash_card_view_model.py +++ b/src/flash_card/presentation/features/flash_card/flash_card_view_model.py @@ -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 diff --git a/tests/e2e/test_flash_card_window.py b/tests/e2e/test_flash_card_window.py index 810b27c..52b164f 100644 --- a/tests/e2e/test_flash_card_window.py +++ b/tests/e2e/test_flash_card_window.py @@ -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) diff --git a/tests/unit/test_flash_card_view_model.py b/tests/unit/test_flash_card_view_model.py index 8b1c8d9..647e904 100644 --- a/tests/unit/test_flash_card_view_model.py +++ b/tests/unit/test_flash_card_view_model.py @@ -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"