diff --git a/src/typehero/content/i18n/en.yaml b/src/typehero/content/i18n/en.yaml index 2e2d1f9..94b4c3a 100644 --- a/src/typehero/content/i18n/en.yaml +++ b/src/typehero/content/i18n/en.yaml @@ -1,5 +1,41 @@ menu.locked: "locked" menu.completed: "done" +menu.tagline: "typehero v{version} · {cleared}/{total} lessons cleared" +footer.benchmark: "Benchmark" +footer.progress: "Progress" +footer.achievements: "Achievements" +footer.settings: "Settings" +footer.quit: "Quit" +footer.back: "Back" +footer.continue: "Continue" +footer.abandon: "Abandon" +footer.palette: "palette" +results.passed: "Lesson passed!" +results.failed: "Not yet — try again" +results.wpm: "WPM" +results.accuracy: "Accuracy" +results.errors: "Errors" +results.xp_earned: "XP earned" +results.level: "Level" +results.level_up: "up!" +results.streak: "Streak" +results.unlocked: "Unlocked" +baseline.yes: "Yes" +baseline.skip: "Skip" +palette.search: "Search for commands…" +palette.theme: "Theme" +palette.theme.help: "Change the current theme" +palette.quit: "Quit" +palette.quit.help: "Quit the application as soon as possible" +palette.keys: "Keys" +palette.keys_show.help: "Show help for the focused widget and a summary of available keys" +palette.keys_hide.help: "Hide the keys and widget help panel" +palette.maximize: "Maximize" +palette.maximize.help: "Maximize the focused widget" +palette.minimize: "Minimize" +palette.minimize.help: "Minimize the widget and restore to normal size" +palette.screenshot: "Screenshot" +palette.screenshot.help: "Save an SVG 'screenshot' of the current screen" benchmark.intro: "Benchmark — this does not give XP and cannot be failed" benchmark.baseline_prompt: "Take a baseline benchmark first? It measures your starting point. (y / s to skip)" language.en: "English" diff --git a/src/typehero/content/i18n/ru.yaml b/src/typehero/content/i18n/ru.yaml index d934dee..b6ea239 100644 --- a/src/typehero/content/i18n/ru.yaml +++ b/src/typehero/content/i18n/ru.yaml @@ -1,5 +1,41 @@ menu.locked: "закрыт" menu.completed: "пройден" +menu.tagline: "typehero v{version} · пройдено уроков: {cleared}/{total}" +footer.benchmark: "Бенчмарк" +footer.progress: "Прогресс" +footer.achievements: "Достижения" +footer.settings: "Настройки" +footer.quit: "Выход" +footer.back: "Назад" +footer.continue: "Продолжить" +footer.abandon: "Прервать" +footer.palette: "палитра" +results.passed: "Урок пройден!" +results.failed: "Пока нет — попробуйте снова" +results.wpm: "сл/мин" +results.accuracy: "Точность" +results.errors: "Ошибки" +results.xp_earned: "Получено XP" +results.level: "Уровень" +results.level_up: "новый!" +results.streak: "Серия" +results.unlocked: "Открыто" +baseline.yes: "Да" +baseline.skip: "Пропустить" +palette.search: "Поиск команд…" +palette.theme: "Тема" +palette.theme.help: "Сменить текущую тему" +palette.quit: "Выход" +palette.quit.help: "Выйти из приложения как можно скорее" +palette.keys: "Клавиши" +palette.keys_show.help: "Показать справку по активному виджету и список горячих клавиш" +palette.keys_hide.help: "Скрыть панель справки по клавишам и виджету" +palette.maximize: "Развернуть" +palette.maximize.help: "Развернуть активный виджет" +palette.minimize: "Свернуть" +palette.minimize.help: "Свернуть виджет до обычного размера" +palette.screenshot: "Скриншот" +palette.screenshot.help: "Сохранить SVG-скриншот текущего экрана" benchmark.intro: "Бенчмарк — не даёт XP и не может быть провален" benchmark.baseline_prompt: "Сначала снять базовый замер? Он зафиксирует стартовую точку. (y — да / s — пропустить)" language.en: "English" diff --git a/src/typehero/tui/app.py b/src/typehero/tui/app.py index 1d06590..ffc6aa8 100644 --- a/src/typehero/tui/app.py +++ b/src/typehero/tui/app.py @@ -5,17 +5,41 @@ import random import sys import time -from collections.abc import Callable +from collections.abc import Callable, Iterable from datetime import date from pathlib import Path -from textual.app import App +from textual.app import App, SystemCommand +from textual.command import CommandPalette +from textual.screen import Screen from typehero.content_loader import ContentError from typehero.paths import content_dir, profile_path from typehero.tui.screens.menu import MenuScreen from typehero.tui.state import AppState, load_app_state +_SYSTEM_COMMAND_KEYS: dict[str, tuple[str, str]] = { + "Change the current theme": ("palette.theme", "palette.theme.help"), + "Quit the application as soon as possible": ("palette.quit", "palette.quit.help"), + "Show help for the focused widget and a summary of available keys": ( + "palette.keys", + "palette.keys_show.help", + ), + "Hide the keys and widget help panel": ("palette.keys", "palette.keys_hide.help"), + "Maximize the focused widget": ("palette.maximize", "palette.maximize.help"), + "Minimize the widget and restore to normal size": ("palette.minimize", "palette.minimize.help"), + "Save an SVG 'screenshot' of the current screen": ( + "palette.screenshot", + "palette.screenshot.help", + ), +} +"""Maps a built-in system command's English help text (unique per command) to the +i18n keys for its translated title and help, so the command palette localizes +without re-implementing Textual's stateful callbacks. The "Keys" command appears +in two mutually-exclusive states (show vs hide help panel), so both share the +`palette.keys` title but differ in help. The keys are verbatim copies of +Textual's strings; `tests/tui/test_app.py` guards them against drift on upgrade.""" + class TypeHeroApp(App): """Root app. Holds the loaded `AppState` and starts on the menu.""" @@ -31,6 +55,35 @@ def on_mount(self) -> None: for notice in self.state.startup_notices: self.notify(notice, severity="warning") + def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: + """Yield Textual's built-in palette commands with localized title/help. + + Each command's callback is preserved; only its display strings are swapped + for the active UI locale. Unmapped commands pass through unchanged.""" + translator = self.state.translator + locale = self.state.progress.ui_locale + for command in super().get_system_commands(screen): + keys = _SYSTEM_COMMAND_KEYS.get(command.help) + if keys is None: + yield command + continue + title_key, help_key = keys + yield command._replace( + title=translator.t(title_key, locale), + help=translator.t(help_key, locale), + ) + + def action_command_palette(self) -> None: + """Open the command palette with a localized search placeholder. + + Mirrors `App.action_command_palette` as of Textual 8.x; only the + placeholder differs. Textual exposes no cleaner injection point for it, + so the guard (`use_command_palette`, `CommandPalette.is_open`) and the + `--command-palette` id must track the upstream method on a version bump.""" + if self.use_command_palette and not CommandPalette.is_open(self): + placeholder = self.state.translator.t("palette.search", self.state.progress.ui_locale) + self.push_screen(CommandPalette(placeholder=placeholder, id="--command-palette")) + def build_app( content_root: Path | None = None, diff --git a/src/typehero/tui/screens/base.py b/src/typehero/tui/screens/base.py index cde54dc..4f3217f 100644 --- a/src/typehero/tui/screens/base.py +++ b/src/typehero/tui/screens/base.py @@ -2,14 +2,33 @@ from __future__ import annotations +from dataclasses import replace from typing import TYPE_CHECKING, cast +from textual.binding import ActiveBinding from textual.screen import Screen if TYPE_CHECKING: from typehero.tui.app import TypeHeroApp from typehero.tui.state import AppState +_FOOTER_I18N: dict[str, str] = { + "Benchmark": "footer.benchmark", + "Progress": "footer.progress", + "Achievements": "footer.achievements", + "Settings": "footer.settings", + "Quit": "footer.quit", + "Back": "footer.back", + "Continue": "footer.continue", + "Abandon": "footer.abandon", + "palette": "footer.palette", +} +"""Maps a binding's English description (matching its en.yaml value) to the i18n +key used to translate the footer label for the current UI locale. The keys come +from each screen's `BINDINGS` literals, plus `palette` from Textual's built-in +command-palette binding (`ctrl+p`), which is injected by the framework rather +than declared here. `tests/tui/test_app.py` guards this map against drift.""" + class AppScreen(Screen): """A `Screen` that exposes the app's `AppState` without a per-call cast.""" @@ -19,6 +38,35 @@ def app_state(self) -> AppState: """The running app's shared state.""" return cast("TypeHeroApp", self.app).state + def t(self, key: str) -> str: + """Translate a UI string `key` for the active UI locale. + + Binds the screen's translator to `progress.ui_locale` so call sites do + not repeat the translator/locale lookup. View-models that must stay + testable without a running app take an injected translator instead. + """ + return self.app_state.translator.t(key, self.app_state.progress.ui_locale) + + @property + def active_bindings(self) -> dict[str, ActiveBinding]: + """Footer bindings with descriptions translated to the current UI locale. + + `BINDINGS` descriptions are static class literals fixed at import time, so + the footer would otherwise always render English. The `Footer` reads this + property on every (re)compose, so translating here keeps the labels in + sync with the active locale without mutating the static bindings. Unmapped + descriptions pass through unchanged.""" + bindings = super().active_bindings + translated: dict[str, ActiveBinding] = {} + for key, active in bindings.items(): + i18n_key = _FOOTER_I18N.get(active.binding.description) + if i18n_key is None: + translated[key] = active + continue + binding = replace(active.binding, description=self.t(i18n_key)) + translated[key] = active._replace(binding=binding) + return translated + def save_profile(self) -> bool: """Persist the profile, surfacing a write failure as a toast. diff --git a/src/typehero/tui/screens/baseline_prompt.py b/src/typehero/tui/screens/baseline_prompt.py index b341429..32a6c4c 100644 --- a/src/typehero/tui/screens/baseline_prompt.py +++ b/src/typehero/tui/screens/baseline_prompt.py @@ -13,15 +13,17 @@ class BaselinePrompt(ModalScreen[bool]): BINDINGS = [("y", "take", "Yes"), ("s", "skip", "Skip")] - def __init__(self, message: str) -> None: + def __init__(self, message: str, yes_label: str = "Yes", skip_label: str = "Skip") -> None: super().__init__() self._message = message + self._yes_label = yes_label + self._skip_label = skip_label def compose(self) -> ComposeResult: with Vertical(id="baseline-dialog"): yield Static(self._message) - yield Button("Yes", id="take", variant="primary") - yield Button("Skip", id="skip") + yield Button(self._yes_label, id="take", variant="primary") + yield Button(self._skip_label, id="skip") def on_button_pressed(self, event: Button.Pressed) -> None: self.dismiss(event.button.id == "take") diff --git a/src/typehero/tui/screens/menu.py b/src/typehero/tui/screens/menu.py index 9765465..88cf4b3 100644 --- a/src/typehero/tui/screens/menu.py +++ b/src/typehero/tui/screens/menu.py @@ -69,18 +69,27 @@ def dashboard_tagline(self) -> str: """Pure view-model for the muted status line under the banner.""" rows = self.lesson_rows() cleared = sum(1 for row in rows if row.completed) - return f"typehero v{__version__} · {cleared}/{len(rows)} lessons cleared" + template = self.t("menu.tagline") + return template.format(version=__version__, cleared=cleared, total=len(rows)) - def on_screen_resume(self) -> None: + async def on_screen_resume(self) -> None: """Rebuild the list whenever this screen is resumed, so a lesson cleared or a typing language switched while it was hidden is reflected: a freshly - cleared lesson shows as completed and unlocks its successor.""" + cleared lesson shows as completed and unlocks its successor. + + `clear`/`extend` are awaited so the index is restored against the rebuilt + rows, and the index is cleared first: re-assigning the same value is a + no-op on the reactive, which would leave no row carrying `-highlight` and + the selection invisible. A rebuild with no prior selection defaults to + row 0 so the menu always shows a highlighted lesson to act on.""" lessons = self.query_one("#lessons", ListView) index = lessons.index - lessons.clear() - lessons.extend(self._list_items()) - lessons.index = index + await lessons.clear() + await lessons.extend(self._list_items()) + lessons.index = None + lessons.index = index if index is not None else 0 self.query_one("#tagline", Static).update(self.dashboard_tagline()) + self.refresh_bindings() @property def _course(self) -> Course: @@ -104,14 +113,13 @@ def lesson_rows(self) -> list[MenuRow]: def _list_items(self) -> list[ListItem]: locale = self.app_state.progress.ui_locale - translator = self.app_state.translator items: list[ListItem] = [] for row in self.lesson_rows(): title = pick_locale(row.lesson.title, locale) if row.completed: - suffix = translator.t("menu.completed", locale) + suffix = self.t("menu.completed") elif not row.unlocked: - suffix = translator.t("menu.locked", locale) + suffix = self.t("menu.locked") else: suffix = "" label = f"{title} ({suffix})" if suffix else title @@ -133,10 +141,12 @@ def on_list_view_selected(self, event: ListView.Selected) -> None: ): from typehero.tui.screens.baseline_prompt import BaselinePrompt - locale = self.app_state.progress.ui_locale - message = self.app_state.translator.t("benchmark.baseline_prompt", locale) self.app.push_screen( - BaselinePrompt(message), + BaselinePrompt( + self.t("benchmark.baseline_prompt"), + yes_label=self.t("baseline.yes"), + skip_label=self.t("baseline.skip"), + ), lambda take: self._after_baseline_choice(bool(take), row.lesson), ) return diff --git a/src/typehero/tui/screens/results.py b/src/typehero/tui/screens/results.py index 0e5f021..ebb9f28 100644 --- a/src/typehero/tui/screens/results.py +++ b/src/typehero/tui/screens/results.py @@ -3,16 +3,17 @@ from __future__ import annotations from textual.app import ComposeResult -from textual.screen import Screen from textual.widgets import Footer, Header, Static from typehero.domain.ids import CourseId from typehero.domain.lesson import LessonOutcome from typehero.domain.progress import BenchmarkKind from typehero.gamification.rewards import RewardSummary +from typehero.localization import Translator +from typehero.tui.screens.base import AppScreen -class ResultsScreen(Screen): +class ResultsScreen(AppScreen): """Renders the outcome of a lesson attempt; Enter/Esc returns to the menu.""" BINDINGS = [("enter,escape", "to_menu", "Continue")] @@ -28,27 +29,39 @@ def __init__( self._summary = summary self._final_course_id = final_course_id - def summary_lines(self) -> list[str]: - """Pure view-model: the lines shown to the player.""" + def summary_lines(self, translator: Translator, locale: str) -> list[str]: + """Pure view-model: the lines shown to the player, localized to `locale`. + + The translator is injected rather than read from the app so the lines + stay unit-testable without a running TUI.""" metrics = self._outcome.metrics summary = self._summary - headline = "Lesson passed!" if summary.passed else "Not yet — try again" + + def t(key: str) -> str: + return translator.t(key, locale) + + headline = t("results.passed") if summary.passed else t("results.failed") + level = f"{summary.level}" + if summary.leveled_up: + level = f"{level} ({t('results.level_up')})" lines = [ headline, - f"WPM: {metrics.net_wpm:.0f}", - f"Accuracy: {metrics.accuracy * 100:.0f}%", - f"Errors: {metrics.errors}", - f"XP earned: {summary.earned_xp}", - f"Level: {summary.level}{' (up!)' if summary.leveled_up else ''}", - f"Streak: {summary.streak}", + f"{t('results.wpm')}: {metrics.net_wpm:.0f}", + f"{t('results.accuracy')}: {metrics.accuracy * 100:.0f}%", + f"{t('results.errors')}: {metrics.errors}", + f"{t('results.xp_earned')}: {summary.earned_xp}", + f"{t('results.level')}: {level}", + f"{t('results.streak')}: {summary.streak}", ] for unlocked in summary.newly_unlocked: - lines.append(f"Unlocked: {unlocked}") + lines.append(f"{t('results.unlocked')}: {unlocked}") return lines def compose(self) -> ComposeResult: + translator = self.app_state.translator + locale = self.app_state.progress.ui_locale yield Header() - yield Static("\n".join(self.summary_lines())) + yield Static("\n".join(self.summary_lines(translator, locale))) yield Footer() def action_to_menu(self) -> None: diff --git a/src/typehero/tui/screens/settings.py b/src/typehero/tui/screens/settings.py index 4ca1e7e..1c41e0c 100644 --- a/src/typehero/tui/screens/settings.py +++ b/src/typehero/tui/screens/settings.py @@ -52,17 +52,16 @@ class SettingsScreen(AppScreen): def settings_view(self) -> SettingsView: """Pure view-model: both axes with localized labels and the selected code.""" state = self.app_state - locale = state.progress.ui_locale ui = [ - LanguageChoice(code=code, label=state.translator.t(f"language.{code}", locale)) + LanguageChoice(code=code, label=self.t(f"language.{code}")) for code in sorted(state.translator.tables) ] typing = [ - LanguageChoice(code=code, label=state.translator.t(f"language.{code}", locale)) + LanguageChoice(code=code, label=self.t(f"language.{code}")) for code in sorted(state.courses) ] return SettingsView( - ui=LanguageAxis(choices=ui, selected=locale), + ui=LanguageAxis(choices=ui, selected=state.progress.ui_locale), typing=LanguageAxis(choices=typing, selected=state.progress.active_course_id), ) @@ -79,13 +78,11 @@ def compose(self) -> ComposeResult: view = self.settings_view() self._ui_codes = [choice.code for choice in view.ui.choices] self._typing_codes = [choice.code for choice in view.typing.choices] - locale = self.app_state.progress.ui_locale - translator = self.app_state.translator yield Header() - yield Label(translator.t("settings.title", locale)) - yield Label(translator.t("settings.ui_language", locale)) + yield Label(self.t("settings.title")) + yield Label(self.t("settings.ui_language")) yield self._radio_set(view.ui, _UI_LANGUAGE_ID) - yield Label(translator.t("settings.typing_language", locale)) + yield Label(self.t("settings.typing_language")) yield self._radio_set(view.typing, _TYPING_LANGUAGE_ID) yield Footer() @@ -118,6 +115,6 @@ async def on_radio_set_changed(self, event: RadioSet.Changed) -> None: else: progress.active_course_id = CourseId(current) return - self.notify(self.app_state.translator.t("settings.saved", progress.ui_locale)) + self.notify(self.t("settings.saved")) if is_ui: await self.recompose() diff --git a/tests/tui/screens/test_menu.py b/tests/tui/screens/test_menu.py index 5f5386a..af03003 100644 --- a/tests/tui/screens/test_menu.py +++ b/tests/tui/screens/test_menu.py @@ -1,19 +1,41 @@ import time from datetime import date -from textual.widgets import ListView, Static +from textual.app import ComposeResult +from textual.binding import Binding +from textual.widgets import Footer, ListView, Static +from textual.widgets._footer import FooterKey from typehero import __version__ from typehero.paths import content_dir from typehero.tui.app import build_app from typehero.tui.screens.achievements import AchievementsScreen +from typehero.tui.screens.base import _FOOTER_I18N, AppScreen from typehero.tui.screens.baseline_prompt import BaselinePrompt from typehero.tui.screens.benchmark import BenchmarkScreen from typehero.tui.screens.lesson import LessonScreen -from typehero.tui.screens.menu import MenuRow +from typehero.tui.screens.menu import MenuRow, MenuScreen from typehero.tui.screens.progress import ProgressScreen +from typehero.tui.screens.results import ResultsScreen from typehero.tui.screens.settings import SettingsScreen +# AppScreen subclasses that render a `Footer`; their binding descriptions must +# all be translatable. BaselinePrompt is excluded: it is a modal with no footer. +_FOOTER_SCREENS = ( + MenuScreen, + BenchmarkScreen, + AchievementsScreen, + ProgressScreen, + SettingsScreen, + LessonScreen, + ResultsScreen, +) + + +def _binding_descriptions(screen_cls): + for binding in screen_cls.BINDINGS: + yield binding.description if isinstance(binding, Binding) else binding[2] + def _app(tmp_path, completed=None): app = build_app( @@ -60,6 +82,29 @@ async def test_resuming_menu_refreshes_lock_state(tmp_path): assert lessons.children[1].disabled is False # now unlocked after resume +def _highlighted_rows(app): + lessons = app.screen.query_one("#lessons", ListView) + return [child for child in lessons.children if child.has_class("-highlight")] + + +async def test_selection_highlight_visible_at_startup(tmp_path): + app = _app(tmp_path) + async with app.run_test() as pilot: + await pilot.pause() + assert len(_highlighted_rows(app)) == 1 # the selected lesson is shown + + +async def test_selection_highlight_survives_resume(tmp_path): + app = _app(tmp_path, completed=["en-01-home-fj"]) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("p") # leave the menu... + await pilot.pause() + await pilot.press("escape") # ...and return → on_screen_resume rebuilds + await pilot.pause() + assert len(_highlighted_rows(app)) == 1 # selection still visible after rebuild + + async def test_dashboard_tagline_shows_version_and_cleared_count(tmp_path): app = _app(tmp_path) async with app.run_test(): @@ -111,6 +156,59 @@ async def test_baseline_prompt_yes_opens_benchmark(tmp_path): assert isinstance(app.screen, BenchmarkScreen) +def _footer_labels(app): + return [key.description for key in app.screen.query(FooterKey)] + + +async def test_tagline_localized_to_ui_locale(tmp_path): + app = _app(tmp_path) + app.state.progress.ui_locale = "ru" + async with app.run_test(): + tagline = app.screen.dashboard_tagline() + assert "пройдено уроков" in tagline + assert f"v{__version__}" in tagline + assert "0/" in tagline + + +async def test_footer_localized_to_ui_locale(tmp_path): + app = _app(tmp_path) + app.state.progress.ui_locale = "ru" + async with app.run_test() as pilot: + await pilot.pause() + labels = _footer_labels(app) + assert "Бенчмарк" in labels # binding description + assert "палитра" in labels # app-level command palette binding + assert "Benchmark" not in labels + + +async def test_switching_ui_language_relocalizes_menu_footer(tmp_path): + app = _app(tmp_path) + async with app.run_test() as pilot: + await pilot.pause() + assert "Benchmark" in _footer_labels(app) + + app.state.progress.ui_locale = "ru" + await pilot.press("p") # leave the menu... + await pilot.pause() + await pilot.press("escape") # ...and return → on_screen_resume refreshes + await pilot.pause() + + assert "Бенчмарк" in _footer_labels(app) + + +async def test_baseline_prompt_buttons_localized(tmp_path): + from textual.widgets import Button + + app = _app(tmp_path) + app.state.progress.ui_locale = "ru" + async with app.run_test() as pilot: + await pilot.press("enter") # select first lesson → baseline prompt + await pilot.pause() + labels = [str(button.label) for button in app.screen.query(Button)] + assert "Да" in labels + assert "Пропустить" in labels + + async def test_menu_s_opens_settings(tmp_path): app = _app(tmp_path) async with app.run_test() as pilot: @@ -127,3 +225,46 @@ async def test_baseline_prompt_skip_records_and_opens_lesson(tmp_path): await pilot.pause() assert isinstance(app.screen, LessonScreen) assert app.state.progress.skipped_baselines == ["en"] + + +def test_footer_i18n_covers_every_screen_binding(): + """Drift guard: a footer binding without an i18n key silently renders English + under a non-en locale. Fails if a screen adds or renames a binding, or if + Textual renames its built-in command-palette description.""" + described = {desc for cls in _FOOTER_SCREENS for desc in _binding_descriptions(cls)} + described.add("palette") # Textual's built-in command-palette binding + missing = described - set(_FOOTER_I18N) + assert not missing, f"footer bindings missing an i18n key: {missing}" + + +class _UnmappedBindingScreen(AppScreen): + BINDINGS = [("z", "noop", "Frobnicate")] + + def compose(self) -> ComposeResult: + yield Footer() + + def action_noop(self) -> None: + pass + + +async def test_footer_passes_through_unmapped_binding(tmp_path): + app = _app(tmp_path) + app.state.progress.ui_locale = "ru" + async with app.run_test() as pilot: + await app.push_screen(_UnmappedBindingScreen()) + await pilot.pause() + descriptions = [a.binding.description for a in app.screen.active_bindings.values()] + assert "Frobnicate" in descriptions # unmapped → passed through unchanged + + +async def test_resume_with_no_prior_selection_defaults_to_first_row(tmp_path): + app = _app(tmp_path) + async with app.run_test() as pilot: + app.screen.query_one("#lessons", ListView).index = None # clear any selection + await pilot.press("p") # leave the menu... + await pilot.pause() + await pilot.press("escape") # ...and return → on_screen_resume rebuilds + await pilot.pause() + lessons = app.screen.query_one("#lessons", ListView) + assert lessons.index == 0 # a missing selection defaults to the first lesson + assert len(_highlighted_rows(app)) == 1 diff --git a/tests/tui/screens/test_results.py b/tests/tui/screens/test_results.py index 635d47c..ebc716b 100644 --- a/tests/tui/screens/test_results.py +++ b/tests/tui/screens/test_results.py @@ -1,9 +1,17 @@ +from typehero.content_loader import load_i18n from typehero.domain.ids import CourseId from typehero.domain.lesson import LessonOutcome, LessonResult from typehero.engine.metrics import SessionMetrics from typehero.gamification.rewards import RewardSummary +from typehero.paths import content_dir from typehero.tui.screens.results import ResultsScreen +_TRANSLATOR = load_i18n(content_dir() / "i18n") + + +def _lines(screen: ResultsScreen, locale: str = "en") -> list[str]: + return screen.summary_lines(_TRANSLATOR, locale) + def _outcome(*, met_accuracy: bool = True, met_speed: bool = True) -> LessonOutcome: metrics = SessionMetrics( @@ -39,7 +47,7 @@ def _summary( def test_summary_lines_include_xp_and_unlocks(): screen = ResultsScreen(outcome=_outcome(), summary=_summary()) - lines = screen.summary_lines() + lines = _lines(screen) assert "Lesson passed!" in lines assert "WPM: 42" in lines assert "XP earned: 200" in lines @@ -52,7 +60,7 @@ def test_summary_lines_failed_attempt(): outcome=_outcome(met_speed=False), summary=_summary(passed=False, earned_xp=0, leveled_up=False, newly_unlocked=()), ) - lines = screen.summary_lines() + lines = _lines(screen) assert "Not yet — try again" in lines assert "XP earned: 0" in lines @@ -62,12 +70,21 @@ def test_summary_lines_no_level_up_or_unlocks(): outcome=_outcome(), summary=_summary(leveled_up=False, newly_unlocked=()), ) - lines = screen.summary_lines() + lines = _lines(screen) assert "Level: 2" in lines assert "Level: 2 (up!)" not in lines assert not any(line.startswith("Unlocked:") for line in lines) +def test_summary_lines_localized_to_locale(): + screen = ResultsScreen(outcome=_outcome(), summary=_summary()) + lines = _lines(screen, locale="ru") + assert "Урок пройден!" in lines + assert any(line.startswith("сл/мин:") for line in lines) + assert any(line.startswith("Получено XP:") for line in lines) + assert "Lesson passed!" not in lines + + def test_results_without_final_pops_to_previous(): screen = ResultsScreen(outcome=_outcome(), summary=_summary()) assert screen._final_course_id is None diff --git a/tests/tui/test_app.py b/tests/tui/test_app.py index 77156b7..62f0f2b 100644 --- a/tests/tui/test_app.py +++ b/tests/tui/test_app.py @@ -1,11 +1,37 @@ import time from datetime import date +from types import SimpleNamespace + +from textual.app import App, SystemCommand from typehero.paths import content_dir, profile_path -from typehero.tui.app import TypeHeroApp, build_app +from typehero.tui.app import _SYSTEM_COMMAND_KEYS, TypeHeroApp, build_app from typehero.tui.screens.menu import MenuScreen +def _app(tmp_path, *, ui_locale="en"): + app = build_app( + content_root=content_dir(), + profile_file=tmp_path / "profile.json", + today=date(2026, 6, 4), + clock=time.monotonic, + ) + app.state.progress.ui_locale = ui_locale + return app + + +def _fake_screen(*, help_panel, maximized, allow_maximize): + """A stand-in screen that drives every conditional branch of Textual's + `App.get_system_commands` without a live, focused, maximized screen.""" + return SimpleNamespace( + query=lambda _selector: [object()] if help_panel else [], + maximized=maximized, + focused=SimpleNamespace(allow_maximize=allow_maximize), + action_minimize=lambda: None, + action_maximize=lambda: None, + ) + + async def test_app_starts_on_the_menu(tmp_path): app = build_app( content_root=content_dir(), @@ -22,3 +48,99 @@ def test_build_app_defaults_use_real_paths(monkeypatch, tmp_path): app = build_app() assert isinstance(app, TypeHeroApp) assert app.state.profile_file == profile_path() + + +async def test_system_commands_localized_to_ui_locale(tmp_path): + app = build_app( + content_root=content_dir(), + profile_file=tmp_path / "profile.json", + today=date(2026, 6, 4), + clock=time.monotonic, + ) + app.state.progress.ui_locale = "ru" + async with app.run_test(): + titles = [command.title for command in app.get_system_commands(app.screen)] + assert "Тема" in titles + assert "Выход" in titles + assert "Theme" not in titles + + +async def test_command_palette_placeholder_localized(tmp_path): + from textual.widgets import Input + + app = build_app( + content_root=content_dir(), + profile_file=tmp_path / "profile.json", + today=date(2026, 6, 4), + clock=time.monotonic, + ) + app.state.progress.ui_locale = "ru" + async with app.run_test() as pilot: + app.action_command_palette() + await pilot.pause() + placeholders = [field.placeholder for field in app.screen.query(Input)] + assert "Поиск команд…" in placeholders + + +async def test_system_commands_default_locale_in_english(tmp_path): + app = _app(tmp_path) # default ui_locale == "en" + async with app.run_test(): + titles = [command.title for command in app.get_system_commands(app.screen)] + assert "Theme" in titles # en.yaml value, not the raw key + assert "Quit" in titles + + +async def test_system_command_keys_match_textual_strings(tmp_path): + """Drift guard: the map keys are verbatim copies of Textual's English help + strings. A Textual upgrade that rewords one would otherwise pass through + untranslated with no other test failing. The union over both help-panel and + both maximize states covers all seven (Keys show/hide and Minimize/Maximize + are mutually exclusive per screen state).""" + app = _app(tmp_path) + async with app.run_test(): + help_strings: set[str] = set() + for help_panel in (False, True): + for maximized, allow_maximize in ((object(), False), (None, True)): + screen = _fake_screen( + help_panel=help_panel, maximized=maximized, allow_maximize=allow_maximize + ) + help_strings.update(c.help for c in App.get_system_commands(app, screen)) + assert help_strings == set(_SYSTEM_COMMAND_KEYS), ( + "Textual's system-command help strings drifted from _SYSTEM_COMMAND_KEYS; " + "re-verify the mapping against the installed Textual version." + ) + + +async def test_keys_command_localized_in_show_and_hide_states(tmp_path): + """Both Keys states share the `palette.keys` title but carry distinct help, + so the show/hide pair the design exists to handle is exercised end to end.""" + app = _app(tmp_path, ui_locale="ru") + async with app.run_test(): + show = app.get_system_commands( + _fake_screen(help_panel=False, maximized=None, allow_maximize=True) + ) + hide = app.get_system_commands( + _fake_screen(help_panel=True, maximized=None, allow_maximize=True) + ) + show_help = {c.title: c.help for c in show} + hide_help = {c.title: c.help for c in hide} + assert "Клавиши" in show_help and "Клавиши" in hide_help # shared localized title + assert show_help["Клавиши"].startswith("Показать") # keys_show.help (ru) + assert hide_help["Клавиши"].startswith("Скрыть") # keys_hide.help (ru) + + +async def test_unmapped_system_command_passes_through_unchanged(tmp_path, monkeypatch): + """An unmapped command (e.g. one a future Textual adds) must survive the + localizing loop untouched, not be dropped or crash on the None unpack.""" + extra = SystemCommand("Bell", "Ring the bell", lambda: None) + base = App.get_system_commands + + def with_extra(self, screen): + yield from base(self, screen) + yield extra + + monkeypatch.setattr(App, "get_system_commands", with_extra) + app = _app(tmp_path, ui_locale="ru") + async with app.run_test(): + commands = list(app.get_system_commands(app.screen)) + assert extra in commands # yielded verbatim despite the ru locale