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
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Integration tests: every word list keeps its progress in a file of its own."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.progress_file_name import (
|
||||
progress_file_name,
|
||||
)
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
|
||||
MS_DA = "ms;da\naku;mig\nkau;du\nini;denne\n"
|
||||
ANIMALS = "ms;da\nkucing;kat\nanjing;hund\n"
|
||||
|
||||
|
||||
def _first(cards: Sequence[Card]) -> Card:
|
||||
return cards[0]
|
||||
|
||||
|
||||
def _lists(folder: Path) -> tuple[Path, Path]:
|
||||
default = folder / "ms-da.csv"
|
||||
other = folder / "animals.csv"
|
||||
default.write_bytes(MS_DA.encode("utf-8"))
|
||||
other.write_bytes(ANIMALS.encode("utf-8"))
|
||||
return default, other
|
||||
|
||||
|
||||
def _progress(folder: Path, list_path: Path) -> CsvProgressRepository:
|
||||
return CsvProgressRepository(folder / progress_file_name(list_path))
|
||||
|
||||
|
||||
def _know_first_word(folder: Path, list_path: Path) -> str:
|
||||
session = open_study_session(
|
||||
CsvVocabularyRepository(list_path),
|
||||
_progress(folder, list_path),
|
||||
NullSpeech(),
|
||||
_first,
|
||||
Level(None),
|
||||
)
|
||||
card = session.next_card()
|
||||
assert card is not None
|
||||
session.mark_known()
|
||||
return card.front_text
|
||||
|
||||
|
||||
def _remaining(folder: Path, list_path: Path) -> list[str]:
|
||||
deck = _progress(folder, list_path).load()
|
||||
assert deck is not None
|
||||
return [card.front for card in deck.cards]
|
||||
|
||||
|
||||
def test_known_words_of_one_list_never_appear_in_the_other(tmp_path: Path) -> None:
|
||||
default, other = _lists(tmp_path)
|
||||
|
||||
assert _know_first_word(tmp_path, default) == "aku"
|
||||
assert _know_first_word(tmp_path, other) == "kucing"
|
||||
|
||||
assert _remaining(tmp_path, default) == ["kau", "ini"]
|
||||
assert _remaining(tmp_path, other) == ["anjing"]
|
||||
assert (tmp_path / "words_to_learn.csv").is_file()
|
||||
assert (tmp_path / "words_to_learn_animals.csv").is_file()
|
||||
|
||||
|
||||
def test_reset_of_one_list_deletes_only_its_progress_file(tmp_path: Path) -> None:
|
||||
default, other = _lists(tmp_path)
|
||||
_know_first_word(tmp_path, default)
|
||||
_know_first_word(tmp_path, other)
|
||||
|
||||
_progress(tmp_path, other).reset()
|
||||
|
||||
assert not (tmp_path / "words_to_learn_animals.csv").exists()
|
||||
assert _remaining(tmp_path, default) == ["kau", "ini"]
|
||||
|
||||
|
||||
def test_reset_leaves_the_word_list_file_byte_identical(tmp_path: Path) -> None:
|
||||
default, other = _lists(tmp_path)
|
||||
_know_first_word(tmp_path, other)
|
||||
|
||||
_progress(tmp_path, other).reset()
|
||||
|
||||
assert other.read_bytes() == ANIMALS.encode("utf-8")
|
||||
assert default.read_bytes() == MS_DA.encode("utf-8")
|
||||
|
||||
|
||||
def test_delivered_list_keeps_words_to_learn_file_name(tmp_path: Path) -> None:
|
||||
default, _other = _lists(tmp_path)
|
||||
assert progress_file_name(default) == "words_to_learn.csv"
|
||||
@@ -110,3 +110,18 @@ def test_progress_save_then_load_returns_same_cards(tmp_path: Path) -> None:
|
||||
assert deck is not None
|
||||
assert deck.cards == CARDS
|
||||
assert deck.language_pair == PAIR
|
||||
|
||||
|
||||
def test_reset_deletes_the_progress_file(tmp_path: Path) -> None:
|
||||
path = tmp_path / "words_to_learn.csv"
|
||||
path.write_text("ms;da\naku;mig\n", encoding="utf-8")
|
||||
repository = CsvProgressRepository(path)
|
||||
|
||||
repository.reset()
|
||||
|
||||
assert not path.exists()
|
||||
assert repository.load() is None
|
||||
|
||||
|
||||
def test_reset_does_nothing_when_the_file_is_absent(tmp_path: Path) -> None:
|
||||
CsvProgressRepository(tmp_path / "words_to_learn.csv").reset()
|
||||
|
||||
@@ -93,3 +93,18 @@ def test_repository_load_raises_format_error_when_no_card(tmp_path: Path) -> Non
|
||||
path = _write(tmp_path / "w.csv", "ms;da\n")
|
||||
with pytest.raises(VocabularyFormatError):
|
||||
CsvVocabularyRepository(path).load()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[b"ms;da\n\xff\xfe;x\n", "ms;da\nkøkken;x\n".encode("latin-1")],
|
||||
ids=["invalid-utf8", "latin-1"],
|
||||
)
|
||||
def test_read_word_list_raises_format_error_when_not_utf8(
|
||||
tmp_path: Path, content: bytes
|
||||
) -> None:
|
||||
path = tmp_path / "w.csv"
|
||||
path.write_bytes(content)
|
||||
with pytest.raises(VocabularyFormatError, match=r"w\.csv.*not UTF-8") as info:
|
||||
read_word_list(path)
|
||||
assert isinstance(info.value.__cause__, UnicodeDecodeError)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests of the name of the words to learn file of a word list."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.infrastructure.persistence.progress_file_name import (
|
||||
progress_file_name,
|
||||
)
|
||||
|
||||
|
||||
def test_progress_file_name_is_default_for_the_delivered_list() -> None:
|
||||
assert progress_file_name(Path("data") / "ms-da.csv") == "words_to_learn.csv"
|
||||
|
||||
|
||||
def test_progress_file_name_uses_the_stem_for_another_list() -> None:
|
||||
assert progress_file_name(Path("x") / "animals.csv") == "words_to_learn_animals.csv"
|
||||
|
||||
|
||||
def test_progress_file_name_keeps_hyphen_and_underscore() -> None:
|
||||
name = progress_file_name(Path("my_list-2.csv"))
|
||||
assert name == "words_to_learn_my_list-2.csv"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_name", "expected"),
|
||||
[
|
||||
("my list.csv", "words_to_learn_my_list.csv"),
|
||||
("a.b;c.csv", "words_to_learn_a_b_c.csv"),
|
||||
("ord (1).csv", "words_to_learn_ord__1_.csv"),
|
||||
],
|
||||
)
|
||||
def test_progress_file_name_replaces_other_characters(
|
||||
file_name: str, expected: str
|
||||
) -> None:
|
||||
assert progress_file_name(Path(file_name)) == expected
|
||||
+143
-2
@@ -63,6 +63,15 @@ class _FakeLevelView:
|
||||
class _FakeFlashCardView:
|
||||
def __init__(self, root: object, view_model: FlashCardViewModel) -> None:
|
||||
self.view_model = view_model
|
||||
self.closed = False
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeMenuView:
|
||||
def __init__(self, root: object, view_model: object, application: object) -> None:
|
||||
self.application = application
|
||||
|
||||
|
||||
class _Vocabulary:
|
||||
@@ -76,12 +85,18 @@ class _Vocabulary:
|
||||
|
||||
|
||||
class _Progress:
|
||||
def __init__(self) -> None:
|
||||
self.resets = 0
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
return None
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
return None
|
||||
|
||||
def reset(self) -> None:
|
||||
self.resets += 1
|
||||
|
||||
|
||||
class _Speech:
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
@@ -96,6 +111,7 @@ def _first(cards: Sequence[Card]) -> Card:
|
||||
def fake_tk_root(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep every test in this module away from a real Tk root."""
|
||||
monkeypatch.setattr(app.tk, "Tk", _FakeRoot)
|
||||
monkeypatch.setattr(app, "MenuView", _FakeMenuView)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -104,9 +120,31 @@ def fake_views(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(app, "FlashCardView", _FakeFlashCardView)
|
||||
|
||||
|
||||
def _application(root: _FakeRoot, error: bool = False) -> app.Application:
|
||||
LIST_PATH = Path("lists") / "ms-da.csv"
|
||||
OTHER_PATH = Path("lists") / "animals.csv"
|
||||
|
||||
|
||||
class _Factory:
|
||||
"""Repositories for a path, recorded; the paths in bad are unusable."""
|
||||
|
||||
def __init__(self, error: bool = False, bad: tuple[Path, ...] = ()) -> None:
|
||||
self._error = error
|
||||
self._bad = bad
|
||||
self.progress: dict[Path, _Progress] = {}
|
||||
self.calls: list[Path] = []
|
||||
|
||||
def __call__(self, path: Path) -> tuple[_Vocabulary, _Progress]:
|
||||
self.calls.append(path)
|
||||
progress = self.progress.setdefault(path, _Progress())
|
||||
return _Vocabulary(self._error or path in self._bad), progress
|
||||
|
||||
|
||||
def _application(
|
||||
root: _FakeRoot, error: bool = False, factory: _Factory | None = None
|
||||
) -> app.Application:
|
||||
repositories_for = _Factory(error) if factory is None else factory
|
||||
return app.Application(
|
||||
cast(tk.Tk, root), _Vocabulary(error), _Progress(), _Speech(), _first
|
||||
cast(tk.Tk, root), _Speech(), _first, repositories_for, LIST_PATH
|
||||
)
|
||||
|
||||
|
||||
@@ -293,3 +331,106 @@ def test_create_application_draws_first_card_from_bundled_list(
|
||||
assert view.view_model.text == "saya"
|
||||
view.view_model.is_known()
|
||||
assert (data / "words_to_learn.csv").is_file()
|
||||
|
||||
|
||||
def test_word_list_name_and_folder_are_those_of_the_list_in_use() -> None:
|
||||
application = _application(_FakeRoot())
|
||||
assert application.word_list_name == "ms-da.csv"
|
||||
assert application.word_list_folder == Path("lists")
|
||||
|
||||
|
||||
def test_open_word_list_replaces_repositories_and_shows_level_screen(
|
||||
fake_views: None,
|
||||
) -> None:
|
||||
factory = _Factory()
|
||||
application = _application(_FakeRoot(), factory=factory)
|
||||
application.start(Level(None))
|
||||
card_view = application.view
|
||||
assert isinstance(card_view, _FakeFlashCardView)
|
||||
|
||||
application.open_word_list(OTHER_PATH)
|
||||
|
||||
assert card_view.closed
|
||||
assert isinstance(application.view, _FakeLevelView)
|
||||
assert application.word_list_name == "animals.csv"
|
||||
application.start(Level(None))
|
||||
application.reset_word_list()
|
||||
assert factory.progress[OTHER_PATH].resets == 1
|
||||
assert factory.progress[LIST_PATH].resets == 0
|
||||
|
||||
|
||||
def test_open_word_list_raises_and_changes_nothing_when_list_is_invalid(
|
||||
fake_views: None,
|
||||
) -> None:
|
||||
factory = _Factory(bad=(OTHER_PATH,))
|
||||
application = _application(_FakeRoot(), factory=factory)
|
||||
application.start(Level(None))
|
||||
card_view = application.view
|
||||
assert isinstance(card_view, _FakeFlashCardView)
|
||||
|
||||
with pytest.raises(VocabularyNotFoundError):
|
||||
application.open_word_list(OTHER_PATH)
|
||||
|
||||
assert application.view is card_view
|
||||
assert not card_view.closed
|
||||
assert application.word_list_name == "ms-da.csv"
|
||||
application.reset_word_list()
|
||||
assert factory.progress[LIST_PATH].resets == 1
|
||||
assert factory.progress[OTHER_PATH].resets == 0
|
||||
|
||||
|
||||
def test_change_level_closes_the_card_screen_and_shows_the_level_screen(
|
||||
fake_views: None,
|
||||
) -> None:
|
||||
application = _application(_FakeRoot())
|
||||
application.start(Level(None))
|
||||
card_view = application.view
|
||||
assert isinstance(card_view, _FakeFlashCardView)
|
||||
|
||||
application.change_level()
|
||||
|
||||
assert card_view.closed
|
||||
assert isinstance(application.view, _FakeLevelView)
|
||||
|
||||
|
||||
def test_change_level_destroys_the_level_screen_it_replaces(
|
||||
fake_views: None,
|
||||
) -> None:
|
||||
application = _application(_FakeRoot())
|
||||
application.show_level_screen()
|
||||
first = application.view
|
||||
assert isinstance(first, _FakeLevelView)
|
||||
|
||||
application.change_level()
|
||||
|
||||
assert first.destroyed
|
||||
assert application.view is not first
|
||||
|
||||
|
||||
def test_reset_word_list_resets_progress_then_shows_the_level_screen(
|
||||
fake_views: None,
|
||||
) -> None:
|
||||
factory = _Factory()
|
||||
application = _application(_FakeRoot(), factory=factory)
|
||||
application.start(Level(None))
|
||||
|
||||
application.reset_word_list()
|
||||
|
||||
assert factory.progress[LIST_PATH].resets == 1
|
||||
assert isinstance(application.view, _FakeLevelView)
|
||||
|
||||
|
||||
def test_exit_application_closes_the_screen_and_destroys_the_root(
|
||||
fake_views: None,
|
||||
) -> None:
|
||||
root = _FakeRoot()
|
||||
application = _application(root)
|
||||
application.start(Level(None))
|
||||
card_view = application.view
|
||||
assert isinstance(card_view, _FakeFlashCardView)
|
||||
|
||||
application.exit_application()
|
||||
|
||||
assert card_view.closed
|
||||
assert root.destroyed
|
||||
assert application.exit_code == 0
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Tests of the menu view model; no Tkinter is involved."""
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.presentation.features.menu.menu_view_model import (
|
||||
MenuEntry,
|
||||
MenuViewModel,
|
||||
)
|
||||
|
||||
|
||||
def test_file_entries_are_open_change_level_reset_and_exit_in_order() -> None:
|
||||
labels = [entry.label for entry in MenuViewModel().file_entries]
|
||||
assert labels == ["Open...", "Change level...", "Reset word list...", "Exit"]
|
||||
|
||||
|
||||
def test_file_entries_name_distinct_commands() -> None:
|
||||
commands = [entry.command for entry in MenuViewModel().file_entries]
|
||||
assert commands == [
|
||||
"open_word_list",
|
||||
"change_level",
|
||||
"reset_word_list",
|
||||
"exit_program",
|
||||
]
|
||||
|
||||
|
||||
def test_menu_labels_are_file_about_and_contact() -> None:
|
||||
model = MenuViewModel()
|
||||
assert model.file_label == "File"
|
||||
assert model.about_label == "About"
|
||||
assert model.contact_menu_label == "Contact"
|
||||
|
||||
|
||||
def test_contact_shows_name_and_title() -> None:
|
||||
assert MenuViewModel().contact_label == "Jens Tirsvad Nielsen, Software engineer"
|
||||
|
||||
|
||||
def test_contact_url_is_the_linkedin_page() -> None:
|
||||
assert MenuViewModel().contact_url == "https://www.linkedin.com/in/tirsvad74"
|
||||
|
||||
|
||||
def test_menu_entry_is_immutable() -> None:
|
||||
entry = MenuEntry("Exit", "exit_program", 1)
|
||||
with pytest.raises(AttributeError):
|
||||
entry.label = "Quit" # type: ignore[misc]
|
||||
Reference in New Issue
Block a user