MIL-011: Menu bar
Review record RC-066 (Go, autonomous mode, not independent). Task: MIL-011#1 Task: MIL-011#2 Task: MIL-011#3 Task: MIL-011#4 Task: MIL-011#5 Task: MIL-011#6 Task: MIL-011#7 Task: MIL-011#8
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
|
||||
import shutil
|
||||
import tkinter as tk
|
||||
from collections.abc import Iterator, Sequence
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -21,13 +21,14 @@ from flash_card.constants import (
|
||||
LEVEL_SCREEN_HEIGHT,
|
||||
LEVEL_TITLE,
|
||||
LEVEL_TITLE_FONT,
|
||||
PROGRESS_FILE_NAME,
|
||||
VOCABULARY_FILE_NAME,
|
||||
WINDOW_PADDING,
|
||||
WINDOW_TITLE,
|
||||
)
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.repositories.progress_repository import ProgressRepository
|
||||
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
@@ -35,6 +36,9 @@ from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.progress_file_name import (
|
||||
progress_file_name,
|
||||
)
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
from flash_card.presentation import app
|
||||
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||
@@ -57,15 +61,25 @@ def _word(view: FlashCardView) -> str:
|
||||
def _clear(root: tk.Tk, view: object) -> None:
|
||||
"""Destroy the widgets of whichever screen is shown, and pending timers."""
|
||||
if isinstance(view, FlashCardView):
|
||||
if view.flip_timer is not None:
|
||||
root.after_cancel(view.flip_timer)
|
||||
view.flip_timer = None
|
||||
for widget in (view.canvas, view.wrong_button, view.right_button):
|
||||
widget.destroy()
|
||||
view.close()
|
||||
elif isinstance(view, LevelView):
|
||||
view.destroy()
|
||||
|
||||
|
||||
def repositories_for(
|
||||
folder: Path,
|
||||
) -> Callable[[Path], tuple[VocabularyRepository, ProgressRepository]]:
|
||||
"""Build the CSV repositories of a list, its progress file in folder."""
|
||||
|
||||
def build(list_path: Path) -> tuple[VocabularyRepository, ProgressRepository]:
|
||||
return (
|
||||
CsvVocabularyRepository(list_path),
|
||||
CsvProgressRepository(folder / progress_file_name(list_path)),
|
||||
)
|
||||
|
||||
return build
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_dir(tmp_path: Path) -> Path:
|
||||
shutil.copy(DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME, tmp_path)
|
||||
@@ -76,10 +90,10 @@ def data_dir(tmp_path: Path) -> Path:
|
||||
def application(tk_root: tk.Tk, data_dir: Path) -> Iterator[app.Application]:
|
||||
instance = app.Application(
|
||||
tk_root,
|
||||
CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME),
|
||||
CsvProgressRepository(data_dir / PROGRESS_FILE_NAME),
|
||||
NullSpeech(),
|
||||
_last,
|
||||
repositories_for(data_dir),
|
||||
data_dir / VOCABULARY_FILE_NAME,
|
||||
)
|
||||
yield instance
|
||||
_clear(tk_root, instance.view)
|
||||
@@ -185,7 +199,7 @@ def test_level_whose_words_are_all_known_shows_the_finished_message(
|
||||
) -> None:
|
||||
cards = CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().cards
|
||||
pair = CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().language_pair
|
||||
CsvProgressRepository(data_dir / PROGRESS_FILE_NAME).save(Deck(pair, cards[100:]))
|
||||
CsvProgressRepository(data_dir / "words_to_learn.csv").save(Deck(pair, cards[100:]))
|
||||
application.show_level_screen()
|
||||
level_view = application.view
|
||||
assert isinstance(level_view, LevelView)
|
||||
@@ -207,8 +221,10 @@ def test_create_application_with_level_skips_the_level_screen(
|
||||
try:
|
||||
assert isinstance(application.view, FlashCardView)
|
||||
assert not [c for c in tk_root.winfo_children() if isinstance(c, tk.Frame)]
|
||||
assert str(tk_root.cget("menu"))
|
||||
finally:
|
||||
_clear(tk_root, application.view)
|
||||
tk_root.config(menu="")
|
||||
|
||||
|
||||
def test_create_application_without_level_shows_the_level_screen(
|
||||
@@ -221,5 +237,28 @@ def test_create_application_without_level_shows_the_level_screen(
|
||||
try:
|
||||
assert isinstance(application.view, LevelView)
|
||||
assert application.root is tk_root
|
||||
assert str(tk_root.cget("menu"))
|
||||
finally:
|
||||
_clear(tk_root, application.view)
|
||||
tk_root.config(menu="")
|
||||
|
||||
|
||||
def test_create_application_with_word_list_keeps_progress_in_the_data_folder(
|
||||
tk_root: tk.Tk, data_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(tk, "Tk", lambda: tk_root)
|
||||
other = tmp_path / "other" / "animals.csv"
|
||||
other.parent.mkdir()
|
||||
other.write_text("ms;da\nkucing;kat\n", encoding="utf-8")
|
||||
|
||||
application = app.create_application(data_dir, False, Level(None), other)
|
||||
|
||||
try:
|
||||
view = application.view
|
||||
assert isinstance(view, FlashCardView)
|
||||
assert application.word_list_name == "animals.csv"
|
||||
view.right_button.invoke()
|
||||
assert (data_dir / "words_to_learn_animals.csv").is_file()
|
||||
finally:
|
||||
_clear(tk_root, application.view)
|
||||
tk_root.config(menu="")
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""End-to-end tests of the menu bar and its commands on a real Tk window.
|
||||
|
||||
@file test_menu_bar.py
|
||||
@brief Builds the real menu bar with injected dialogs and checks the commands.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.constants import CONTACT_URL, WINDOW_TITLE
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.repositories.progress_repository import ProgressRepository
|
||||
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.progress_file_name import (
|
||||
progress_file_name,
|
||||
)
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
from flash_card.presentation import app
|
||||
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||
from flash_card.presentation.features.level.level_view import LevelView
|
||||
from flash_card.presentation.features.menu.menu_view import MenuView
|
||||
from flash_card.presentation.features.menu.menu_view_model import MenuViewModel
|
||||
|
||||
DEFAULT_LIST = "ms;da\naku;mig\nkau;du\n"
|
||||
OTHER_LIST = "ms;da\nkucing;kat\n"
|
||||
CONTACT_LABEL = "Jens Tirsvad Nielsen, Software engineer"
|
||||
|
||||
FILE_INDEX = 0
|
||||
ABOUT_INDEX = 1
|
||||
OPEN, CHANGE_LEVEL, RESET, SEPARATOR, EXIT = range(5)
|
||||
|
||||
|
||||
def _last(cards: Sequence[Card]) -> Card:
|
||||
return cards[-1]
|
||||
|
||||
|
||||
class _Dialogs:
|
||||
"""Records and answers the dialogs the menu view asks."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.file = ""
|
||||
self.answer = True
|
||||
self.asked_files: list[dict[str, object]] = []
|
||||
self.questions: list[str] = []
|
||||
self.errors: list[tuple[str, str]] = []
|
||||
self.urls: list[str] = []
|
||||
|
||||
def ask_open_file(self, **options: object) -> str:
|
||||
self.asked_files.append(options)
|
||||
return self.file
|
||||
|
||||
def confirm(self, title: str, message: str, **options: object) -> bool:
|
||||
self.questions.append(message)
|
||||
return self.answer
|
||||
|
||||
def show_error(self, title: str, message: str, **options: object) -> str:
|
||||
self.errors.append((title, message))
|
||||
return "ok"
|
||||
|
||||
def open_url(self, url: str) -> bool:
|
||||
self.urls.append(url)
|
||||
return True
|
||||
|
||||
|
||||
class _Window:
|
||||
def __init__(
|
||||
self,
|
||||
root: tk.Tk,
|
||||
application: app.Application,
|
||||
menu: MenuView,
|
||||
dialogs: _Dialogs,
|
||||
folder: Path,
|
||||
) -> None:
|
||||
self.root = root
|
||||
self.application = application
|
||||
self.menu = menu
|
||||
self.dialogs = dialogs
|
||||
self.folder = folder
|
||||
|
||||
|
||||
def _repositories_for(
|
||||
folder: Path,
|
||||
) -> Callable[[Path], tuple[VocabularyRepository, ProgressRepository]]:
|
||||
def build(list_path: Path) -> tuple[VocabularyRepository, ProgressRepository]:
|
||||
return (
|
||||
CsvVocabularyRepository(list_path),
|
||||
CsvProgressRepository(folder / progress_file_name(list_path)),
|
||||
)
|
||||
|
||||
return build
|
||||
|
||||
|
||||
def _close(window: _Window) -> None:
|
||||
view = window.application.view
|
||||
if isinstance(view, FlashCardView):
|
||||
view.close()
|
||||
elif isinstance(view, LevelView):
|
||||
view.destroy()
|
||||
window.root.config(menu="")
|
||||
window.menu.menubar.destroy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window(tk_root: tk.Tk, tmp_path: Path) -> Iterator[_Window]:
|
||||
default = tmp_path / "ms-da.csv"
|
||||
default.write_bytes(DEFAULT_LIST.encode("utf-8"))
|
||||
application = app.Application(
|
||||
tk_root, NullSpeech(), _last, _repositories_for(tmp_path), default
|
||||
)
|
||||
dialogs = _Dialogs()
|
||||
menu = MenuView(
|
||||
tk_root,
|
||||
MenuViewModel(),
|
||||
application,
|
||||
ask_open_file=dialogs.ask_open_file,
|
||||
confirm=dialogs.confirm,
|
||||
show_error=dialogs.show_error,
|
||||
open_url=dialogs.open_url,
|
||||
)
|
||||
application.show_level_screen()
|
||||
instance = _Window(tk_root, application, menu, dialogs, tmp_path)
|
||||
yield instance
|
||||
_close(instance)
|
||||
|
||||
|
||||
def _labels(menu: tk.Menu, last: int) -> list[str]:
|
||||
return [
|
||||
"-" if menu.type(i) == "separator" else str(menu.entrycget(i, "label"))
|
||||
for i in range(last + 1)
|
||||
]
|
||||
|
||||
|
||||
def _start_first_level(window: _Window) -> FlashCardView:
|
||||
level_view = window.application.view
|
||||
assert isinstance(level_view, LevelView)
|
||||
level_view.buttons[0].invoke()
|
||||
card_view = window.application.view
|
||||
assert isinstance(card_view, FlashCardView)
|
||||
return card_view
|
||||
|
||||
|
||||
def _word(view: FlashCardView) -> str:
|
||||
return str(view.canvas.itemcget(view.word_text_id, "text"))
|
||||
|
||||
|
||||
def test_menu_bar_has_file_and_about_in_order(window: _Window) -> None:
|
||||
menubar = window.menu.menubar
|
||||
assert str(window.root.cget("menu")) == str(menubar)
|
||||
assert menubar.index("end") == ABOUT_INDEX
|
||||
assert _labels(menubar, ABOUT_INDEX) == ["File", "About"]
|
||||
|
||||
|
||||
def test_file_menu_has_four_entries_and_a_separator_before_exit(
|
||||
window: _Window,
|
||||
) -> None:
|
||||
file_menu = window.menu.file_menu
|
||||
assert file_menu.index("end") == EXIT
|
||||
assert _labels(file_menu, EXIT) == [
|
||||
"Open...",
|
||||
"Change level...",
|
||||
"Reset word list...",
|
||||
"-",
|
||||
"Exit",
|
||||
]
|
||||
assert file_menu.type(SEPARATOR) == "separator"
|
||||
|
||||
|
||||
def test_file_entries_have_mnemonics(window: _Window) -> None:
|
||||
file_menu = window.menu.file_menu
|
||||
assert int(str(file_menu.entrycget(OPEN, "underline"))) == 0
|
||||
assert int(str(file_menu.entrycget(EXIT, "underline"))) == 1
|
||||
assert int(str(window.menu.menubar.entrycget(FILE_INDEX, "underline"))) == 0
|
||||
|
||||
|
||||
def test_about_menu_leads_to_the_contact_entry(window: _Window) -> None:
|
||||
about = window.menu.about_menu
|
||||
assert _labels(about, 0) == ["Contact"]
|
||||
assert about.type(0) == "cascade"
|
||||
contact = window.menu.contact_menu
|
||||
assert contact.index("end") == 0
|
||||
assert _labels(contact, 0) == [CONTACT_LABEL]
|
||||
|
||||
|
||||
def test_open_starts_the_chosen_list_after_a_level_choice(window: _Window) -> None:
|
||||
other = window.folder / "animals.csv"
|
||||
other.write_bytes(OTHER_LIST.encode("utf-8"))
|
||||
window.dialogs.file = str(other)
|
||||
|
||||
window.menu.file_menu.invoke(OPEN)
|
||||
|
||||
assert isinstance(window.application.view, LevelView)
|
||||
assert window.application.word_list_name == "animals.csv"
|
||||
assert _word(_start_first_level(window)) == "kucing"
|
||||
asked = window.dialogs.asked_files[0]
|
||||
assert asked["initialdir"] == str(window.folder)
|
||||
assert ("CSV files", "*.csv") in asked["filetypes"] # type: ignore[operator]
|
||||
assert window.root.title() == WINDOW_TITLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[None, b"ms;da\n\xff\xfe;x\n", "ms;da\nkøkken;x\n".encode("latin-1")],
|
||||
ids=["missing", "invalid-utf8", "latin-1"],
|
||||
)
|
||||
def test_open_of_an_invalid_file_shows_a_message_and_keeps_the_list(
|
||||
window: _Window, content: bytes | None
|
||||
) -> None:
|
||||
bad = window.folder / "bad.csv"
|
||||
if content is not None:
|
||||
bad.write_bytes(content)
|
||||
window.dialogs.file = str(bad)
|
||||
level_view = window.application.view
|
||||
|
||||
window.menu.file_menu.invoke(OPEN)
|
||||
|
||||
assert len(window.dialogs.errors) == 1
|
||||
assert "bad.csv" in window.dialogs.errors[0][1]
|
||||
assert window.application.view is level_view
|
||||
assert window.application.word_list_name == "ms-da.csv"
|
||||
assert _word(_start_first_level(window)) == "kau"
|
||||
|
||||
|
||||
def test_open_cancelled_changes_nothing(window: _Window) -> None:
|
||||
window.dialogs.file = ""
|
||||
level_view = window.application.view
|
||||
|
||||
window.menu.file_menu.invoke(OPEN)
|
||||
|
||||
assert window.application.view is level_view
|
||||
assert window.application.word_list_name == "ms-da.csv"
|
||||
assert not window.dialogs.errors
|
||||
|
||||
|
||||
def test_change_level_shows_the_level_screen_and_keeps_known_words(
|
||||
window: _Window,
|
||||
) -> None:
|
||||
card_view = _start_first_level(window)
|
||||
card_view.right_button.invoke()
|
||||
progress = window.folder / "words_to_learn.csv"
|
||||
saved = progress.read_bytes()
|
||||
|
||||
window.menu.file_menu.invoke(CHANGE_LEVEL)
|
||||
|
||||
assert isinstance(window.application.view, LevelView)
|
||||
assert card_view.flip_timer is None
|
||||
assert progress.read_bytes() == saved
|
||||
|
||||
|
||||
def test_reset_asks_and_on_yes_removes_progress_and_shows_level_screen(
|
||||
window: _Window,
|
||||
) -> None:
|
||||
card_view = _start_first_level(window)
|
||||
card_view.right_button.invoke()
|
||||
progress = window.folder / "words_to_learn.csv"
|
||||
assert progress.is_file()
|
||||
|
||||
window.menu.file_menu.invoke(RESET)
|
||||
|
||||
assert window.dialogs.questions == [
|
||||
"Start ms-da.csv from the beginning? All words will be to learn again."
|
||||
]
|
||||
assert not progress.exists()
|
||||
assert isinstance(window.application.view, LevelView)
|
||||
assert (window.folder / "ms-da.csv").read_bytes() == DEFAULT_LIST.encode("utf-8")
|
||||
|
||||
|
||||
def test_reset_answered_no_changes_nothing(window: _Window) -> None:
|
||||
card_view = _start_first_level(window)
|
||||
card_view.right_button.invoke()
|
||||
progress = window.folder / "words_to_learn.csv"
|
||||
window.dialogs.answer = False
|
||||
|
||||
window.menu.file_menu.invoke(RESET)
|
||||
|
||||
assert progress.is_file()
|
||||
assert window.application.view is card_view
|
||||
|
||||
|
||||
def test_contact_opens_the_linkedin_page(window: _Window) -> None:
|
||||
window.menu.contact_menu.invoke(0)
|
||||
assert window.dialogs.urls == [CONTACT_URL]
|
||||
assert CONTACT_URL == "https://www.linkedin.com/in/tirsvad74"
|
||||
|
||||
|
||||
def test_exit_cancels_the_timer_and_destroys_the_window(
|
||||
window: _Window, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
destroyed: list[bool] = []
|
||||
monkeypatch.setattr(window.root, "destroy", lambda: destroyed.append(True))
|
||||
card_view = _start_first_level(window)
|
||||
assert card_view.flip_timer is not None
|
||||
|
||||
window.menu.file_menu.invoke(EXIT)
|
||||
|
||||
assert destroyed == [True]
|
||||
assert card_view.flip_timer is None
|
||||
assert window.application.exit_code == 0
|
||||
Reference in New Issue
Block a user