diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d20e79..1ea49f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,5 +60,5 @@ jobs: # tests into skips, and skips still count here. - name: Guard test count env: - MIN_TESTS: "1015" + MIN_TESTS: "1146" run: python .github/scripts/check_test_count.py report.xml ${{ env.MIN_TESTS }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 423d5ea..1866b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ ship under the same number. --- +## [3.4.1] — 2026-08-26 + +### Fixed + +- **The interface opened in Russian whatever language you had chosen.** On Linux the GTK entry point never applied the saved language at all, so the control in Settings had no effect on anything; and on a first run, before there is a choice to apply, both frontends fell back to the Russian default — so a new player anywhere in the world read the setup wizard, the first thing they see, in a language they may not have. A saved choice is honoured now, and a first run takes its language from the operating system instead, falling back to Russian when the system names one the interface does not have. Windows is asked which language its *interface* is in, which is a different setting there from the one dates and numbers are formatted by, and the one that actually answers the question. +- **Changing the language left every open window in the old one.** A widget keeps the string it was built with, so the new language reached nothing already on screen and took hold only at the next launch — which reads as a control that does nothing. The overlay relabels itself now, on both platforms, and on Windows so does the separate window the clipboard hotkey opens — created on demand and then kept, so it outlived the setting that was changed after it. +- **The tray menu was the worst of those, because you cannot close and reopen it.** It is built once at startup, and on Linux it was never translated at all: five English items above a Russian overlay, whatever you had picked. It is translated now and follows a language change on both platforms — and its first entry is written from where the overlay actually is, so it no longer offers to hide a window that is already hidden. +- **The setup wizard forgot what you had typed when you changed its language.** Showing it in a new language means building its pages again, and neither wizard kept what was already in the fields — an API key pasted on the second page, the WoW folder browsed for on the third, both silently gone because of the dropdown on the first. They carry across now. +- **Saving a new language leaves the settings window rebuilt rather than stale.** Reopening it is the rebuild, so that one save closes it; any other save leaves it where it is, with its confirmation where you can read it. + +--- + ## [3.4.0] — 2026-08-23 ### Added diff --git a/CHANGELOG_ru.md b/CHANGELOG_ru.md index 3096286..bb8f07e 100644 --- a/CHANGELOG_ru.md +++ b/CHANGELOG_ru.md @@ -8,6 +8,18 @@ --- +## [3.4.1] — 2026-08-26 + +### Исправлено + +- **Интерфейс открывался по-русски, какой язык ни выбери.** На Linux GTK-вход сохранённый язык не применял вовсе, так что переключатель в настройках не влиял ни на что; а на первом запуске, когда применять ещё нечего, оба фронтенда откатывались к русскому по умолчанию — и новичок в любой точке мира читал мастер настройки, первое, что он вообще видит, на языке, которого может не знать. Сохранённый выбор теперь соблюдается, а первый запуск берёт язык у операционной системы и остаётся русским, только если система называет язык, которого у интерфейса нет. У Windows спрашивается язык её *интерфейса* — это там отдельная настройка, не та, по которой форматируются даты и числа, и отвечает на вопрос именно она. +- **Смена языка оставляла все открытые окна на прежнем.** Виджет хранит ту строку, с которой был построен, поэтому новый язык не доходил ни до чего, что уже на экране, и вступал в силу только со следующего запуска — а это выглядит как переключатель, который ничего не делает. Оверлей теперь переподписывает себя сам на обеих платформах, а на Windows — и отдельное окно, которое открывает горячая клавиша перевода из буфера обмена: оно создаётся по требованию и потом остаётся жить, переживая настройку, изменённую после него. +- **Хуже всех было меню в трее, потому что его нельзя закрыть и открыть заново.** Оно строится один раз при запуске, а на Linux не переводилось вообще: пять английских пунктов над русским оверлеем, что ни выбери. Теперь переведено и следует за сменой языка на обеих платформах, а первый пункт пишется по тому, где оверлей на самом деле, — и больше не предлагает скрыть уже скрытое окно. +- **Мастер настройки забывал введённое, когда в нём меняли язык.** Показать мастер на новом языке — значит построить его страницы заново, и ни один из двух не сохранял того, что уже было в полях: ключ, вставленный на второй странице, папка WoW, выбранная на третьей, — всё пропадало молча из-за списка на первой. Теперь переносится. +- **Сохранение нового языка оставляет окно настроек перестроенным, а не устаревшим.** Перестроение — это и есть то, что происходит при следующем открытии, поэтому именно такое сохранение окно закрывает; любое другое оставляет его на месте, вместе с подтверждением, которое можно прочитать. + +--- + ## [3.4.0] — 2026-08-23 ### Добавлено diff --git a/README.md b/README.md index fcb80bd..ec60da9 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ WoW, so an addon-only setup has no egress at all. | Cache | SQLite + LRU | | Build | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Addon | Lua 5.1, WoW API | -| Tests | 998 tests (pytest) | +| Tests | 1146 tests (pytest) | ## Development diff --git a/README_es.md b/README_es.md index 2e90e34..1e9c4c9 100644 --- a/README_es.md +++ b/README_es.md @@ -280,7 +280,7 @@ alguno. | Caché | SQLite + LRU | | Compilación | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Addon | Lua 5.1, WoW API | -| Tests | 998 tests (pytest) | +| Tests | 1146 tests (pytest) | ## Desarrollo diff --git a/README_ru.md b/README_ru.md index ed19ea3..798b21e 100644 --- a/README_ru.md +++ b/README_ru.md @@ -278,7 +278,7 @@ BabelChat переводит, отправляя текст сообщений | Кэш | SQLite + LRU | | Сборка | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) | | Аддон | Lua 5.1, WoW API | -| Тесты | 998 тестов (pytest) | +| Тесты | 1146 тестов (pytest) | ## Разработка diff --git a/addon/BabelChat/BabelChat.toc b/addon/BabelChat/BabelChat.toc index be4e48f..4c2790b 100644 --- a/addon/BabelChat/BabelChat.toc +++ b/addon/BabelChat/BabelChat.toc @@ -5,7 +5,7 @@ ## Notes-esES: |cFF33CCFFTraducción de chat en tiempo real — diccionario integrado + overlay con app acompañante.|r ## Notes-esMX: |cFF33CCFFTraducción de chat en tiempo real — diccionario integrado + overlay con app acompañante.|r ## Author: Andrey Yumashev, Pirson -## Version: 3.4.0 +## Version: 3.4.1 ## X-License: MIT ## X-Website: https://github.com/Yumash/BabelChat diff --git a/addon/BabelChat/Config.lua b/addon/BabelChat/Config.lua index a78d0dc..f0dfb0c 100644 --- a/addon/BabelChat/Config.lua +++ b/addon/BabelChat/Config.lua @@ -68,7 +68,7 @@ function addonTable.CreateConfigUI() local version = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") version:SetPoint("TOP", logo, "BOTTOM", 0, -2) - version:SetText("v" .. (C_AddOns.GetAddOnMetadata(ADDON_NAME, "Version") or "3.4.0")) + version:SetText("v" .. (C_AddOns.GetAddOnMetadata(ADDON_NAME, "Version") or "3.4.1")) -- ════════════════════════════════════ -- SECTION 1: GENERAL diff --git a/app/about_dialog.py b/app/about_dialog.py index 79f2ab9..a73493f 100644 --- a/app/about_dialog.py +++ b/app/about_dialog.py @@ -18,7 +18,7 @@ from app.i18n import tr -VERSION = "3.4.0" +VERSION = "3.4.1" ABOUT_STYLESHEET = """ QDialog { diff --git a/app/config.py b/app/config.py index 8354eb4..11d97c5 100644 --- a/app/config.py +++ b/app/config.py @@ -152,8 +152,7 @@ def save(self, path: str = CONFIG_FILE) -> None: @classmethod def load(cls, path: str = CONFIG_FILE) -> AppConfig: """Load config from JSON file, using defaults for missing fields.""" - target = Path(path) - for try_path in [target, target.with_suffix(".json.bak")]: + for try_path in _config_candidates(path): try: data = json.loads(try_path.read_text(encoding="utf-8")) _migrate_provider_keys(data, try_path) @@ -173,6 +172,39 @@ def load(cls, path: str = CONFIG_FILE) -> AppConfig: return cls() +def _config_candidates(path: str = CONFIG_FILE) -> list[Path]: + """Every file `AppConfig.load` will accept a saved config from, in order. + + Declared once so that asking "is there a saved config?" and answering + "here is the saved config" cannot come to look at different files. + """ + target = Path(path) + return [target, target.with_suffix(".json.bak")] + + +def saved_config_exists(path: str = CONFIG_FILE) -> bool: + """True when a config this build can actually read is on disk. + + Deliberately not `os.path.exists(CONFIG_FILE)`, which answers a different + question and gets it wrong in both directions. `load` also reads + `config.json.bak`, so an absent main file does not mean the user has no + saved preferences — and it falls back to defaults on a corrupt one, so a + present main file does not mean any were read. Anything deciding whether + this is a first run has to ask about the same candidates `load` does. + + Parses rather than stats, for the corrupt case, but does not migrate: the + migrations write backups of their own, and running them from a question is + not what a question should do. + """ + for candidate in _config_candidates(path): + try: + json.loads(candidate.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + return True + return False + + @dataclass(frozen=True, slots=True) class ChannelToggle: """One switch in the settings window, and everything that depends on it. diff --git a/app/i18n.py b/app/i18n.py index f709188..b656d0b 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -2,6 +2,9 @@ from __future__ import annotations +import locale +import os +import sys from typing import ClassVar from app import locales @@ -25,7 +28,11 @@ class tr: @classmethod def set_language(cls, lang: str) -> None: - cls._lang = lang if lang in ("RU", "EN", "ES") else "RU" + # Against UI_LANGUAGES, not a tuple repeating it: a fourth translation + # added to the table but not here would be resolved by the locale guess + # and then silently clamped back to Russian on the way in, which is the + # first-run bug this module exists to prevent, wearing a new hat. + cls._lang = lang if lang in UI_LANGUAGES else "RU" @classmethod def get_language(cls) -> str: @@ -44,3 +51,102 @@ def __new__(cls, key: str, **kwargs: object) -> str: # type: ignore[misc] if kwargs: text = text.format(**kwargs) return text + + +#: Environment variables that state the user's preferred UI language, in the +#: order gettext resolves them: LANGUAGE wins outright, and only then do the +#: LC_* variables and LANG get a say. Getting this order wrong is easy — LANG +#: is the famous one — and it silently ignores whatever LANGUAGE asked for. +_LOCALE_ENV_VARS = ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG") + + +def _codes_in(value: str) -> list[str]: + """Language codes named by one locale environment variable. + + LANGUAGE holds a colon-separated preference list ("es:ru"); the others hold + a single locale ("es_ES.UTF-8"). Both reduce to the leading language part, + upper-cased, which is what UI_LANGUAGES is keyed by. + """ + codes = [] + for item in value.split(":"): + code = item.split(".")[0].split("@")[0].split("_")[0].strip().upper() + if code: + codes.append(code) + return codes + + +def _windows_ui_language() -> str: + """The display language Windows itself is set to, as an ISO locale name. + + `locale.getlocale()` cannot answer this on Windows: it reports the C + runtime's name for the locale — "Russian_Russia", "English_United States" — + which is not an ISO code and which nothing here can key on. Measured on a + Russian Windows 11: `getlocale()` gives `('Russian_Russia', '1252')`, so + every guess fell through to the default and the first-run language was + Russian for the whole world, which is the bug this was written to fix. + + Windows also separates the language the interface is in from the locale + dates and numbers are formatted by — a machine can be English with Russian + formats — and it is the first of those we are trying to match. That is + `GetUserDefaultUILanguage`, not the format locale `getdefaultlocale` reads + (and which is deprecated for removal besides). + """ + if sys.platform != "win32": + return "" + try: + import ctypes + + lcid = ctypes.windll.kernel32.GetUserDefaultUILanguage() + except (AttributeError, OSError, ValueError): # not the Windows we expected + return "" + return getattr(locale, "windows_locale", {}).get(lcid, "") + + +def guess_ui_language(default: str = "RU") -> str: + """The UI language to open with when nothing has been saved yet. + + First run has no preference to honour, and defaulting to Russian for + everyone meant a first-time player anywhere else read the setup wizard in a + language they may not have. The OS locale is the only signal available, so + it decides — falling back to `default` when it is absent, unreadable, or + names a language this build has no UI for. + + Only for a genuine first run: once a config file exists it carries a real + choice, and guessing over that replaces it with the machine's opinion. + """ + for env_var in _LOCALE_ENV_VARS: + for code in _codes_in(os.environ.get(env_var, "")): + if code in UI_LANGUAGES: + return code + + # Windows sets none of those variables, and its C-runtime locale name is + # not something `_codes_in` can read. Ask Win32 directly before falling + # back to the POSIX path below. + for code in _codes_in(_windows_ui_language()): + if code in UI_LANGUAGES: + return code + + try: + loc = locale.getlocale()[0] or "" + except (ValueError, TypeError): # a malformed locale setting, not our problem + return default + for code in _codes_in(loc): + if code in UI_LANGUAGES: + return code + return default + + +def startup_ui_language(*, config_exists: bool, saved: str) -> str: + """The language to render in before the user has had a chance to say. + + One rule, called by both entry points, because this is exactly the kind of + decision that has drifted between the Qt and GTK frontends every time it + was written twice: a saved choice is honoured, and only a machine that has + never run the app is asked what language it speaks. + + `config_exists` is deliberately about the FILE, not about whether the app + is fully set up. The setup wizard also reopens when a provider stops being + configured, and treating that as a first run would let the OS locale + overwrite the language the user picked the last time round. + """ + return saved if config_exists else guess_ui_language() diff --git a/app/main.py b/app/main.py index b55ae72..d4da30a 100644 --- a/app/main.py +++ b/app/main.py @@ -15,13 +15,14 @@ from app import debug_log from app.about_dialog import AboutDialog -from app.config import AppConfig, enabled_channels, enabled_filter_tabs, resolve_chatlog_path +from app.config import AppConfig, enabled_channels, enabled_filter_tabs, resolve_chatlog_path, saved_config_exists from app.hotkeys import GlobalHotkeyManager -from app.i18n import tr +from app.i18n import startup_ui_language, tr from app.overlay import ChatOverlay from app.parser import Channel from app.pipeline import PipelineConfig, TranslationPipeline from app.settings_dialog import SettingsDialog +from app.single_instance import _ensure_single_instance from app.translator import TranslatorService, any_configured from app.tray import TrayIcon @@ -187,141 +188,6 @@ def _setup_console(visible: bool) -> None: _console_initialized = True -def _get_lock_file() -> str: - if getattr(__import__("sys"), "frozen", False): - lock_dir = os.path.join(os.path.expanduser("~"), ".config", "BabelChat") - os.makedirs(lock_dir, exist_ok=True) - return os.path.join(lock_dir, "babelchat.lock") - return os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "babelchat.lock") - - -_LOCK_FILE = _get_lock_file() - - -def _linux_start_time(stat_line: str) -> str: - """`starttime` out of a line of /proc//stat. - - Separate from the reading so it can be tested where /proc does not exist, - which is where this project is developed. The parsing is not obvious: field - two is the executable name in parentheses, and it may itself contain spaces - and parentheses — `(my prog) (v2)` is a legal name — so splitting the line - on whitespace from the left puts every later field at an offset that - depends on what the process is called. Counting from the last ')' is the - documented way round it. starttime is field 22, and the last ')' ends field - two, so it is index 19 in what follows. - """ - return stat_line.rpartition(")")[2].split()[19] - - -def _start_stamp(pid: int) -> str | None: - """When the process at `pid` started, as the operating system recorded it. - - A PID on its own does not identify a process for longer than that process - lives: Windows hands the numbers back out, and Linux wraps them. The lock - file outlives the copy that wrote it, so by the time it is read the number - in it may belong to something the user very much wants to keep running. - - Paired with the PID, the start time is unique — a process that took over the - number necessarily started later. Returns None when the answer is unknown, - which the caller must treat as "do not touch it". - """ - try: - if sys.platform == "win32": - PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 - kernel32 = ctypes.windll.kernel32 - handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) - if not handle: - return None - try: - created = ctypes.c_ulonglong() - exited = ctypes.c_ulonglong() - kernel_time = ctypes.c_ulonglong() - user_time = ctypes.c_ulonglong() - ok = kernel32.GetProcessTimes( - handle, - ctypes.byref(created), - ctypes.byref(exited), - ctypes.byref(kernel_time), - ctypes.byref(user_time), - ) - return str(created.value) if ok else None - finally: - kernel32.CloseHandle(handle) - - if sys.platform.startswith("linux"): - with open(f"/proc/{pid}/stat", encoding="utf-8") as f: - return _linux_start_time(f.read()) - except (OSError, ValueError, IndexError): - return None - - return None - - -def _terminate(pid: int) -> None: - """Stop the previous copy. Only ever called for a verified match.""" - if sys.platform == "win32": - PROCESS_TERMINATE = 0x0001 - SYNCHRONIZE = 0x00100000 - kernel32 = ctypes.windll.kernel32 - handle = kernel32.OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, False, pid) - if not handle: - logger.info("Old PID %d is already gone", pid) - return - kernel32.TerminateProcess(handle, 0) - kernel32.WaitForSingleObject(handle, 2000) - kernel32.CloseHandle(handle) - else: - import time as _time - - try: - os.kill(pid, signal.SIGTERM) - except (ProcessLookupError, PermissionError): - logger.info("Old PID %d is already gone", pid) - return - _time.sleep(0.5) - logger.info("Stopped the previous instance, PID %d", pid) - - -def _ensure_single_instance() -> None: - """Stop the previous copy of BabelChat, and nothing else. - - The lock file carries the PID and the start stamp of the process that wrote - it. Both must match a live process before anything is terminated; a lock - with only a PID — written by a version before this check existed — matches - nothing, so an upgrade leaves the running copy for the user to close rather - than gambling on the number. - """ - lock_path = os.path.abspath(_LOCK_FILE) - if os.path.exists(lock_path): - try: - with open(lock_path, encoding="utf-8") as f: - recorded = f.read().splitlines() - old_pid = int(recorded[0].strip()) - was = recorded[1].strip() if len(recorded) > 1 else "" - now = _start_stamp(old_pid) - - # One condition, deliberately. An earlier version spelled the three - # ways this can fail as three branches, and each of them turned out - # to be unreachable — the comparison below already rejects a missing - # stamp, an unknown one and a mismatched one. Branches that cannot - # change the outcome cannot be tested either, and they read as if - # they were load-bearing. - if now and now == was: - _terminate(old_pid) - else: - logger.info( - "Leaving PID %d alone: the lock says it started at %s, the live process says %s", - old_pid, - was or "(nothing)", - now or "(nothing there)", - ) - except (OSError, ValueError, IndexError) as e: - logger.warning("Could not read the lock file: %s", e) - - with open(lock_path, "w", encoding="utf-8") as f: - f.write(f"{os.getpid()}\n{_start_stamp(os.getpid()) or ''}\n") - - def main() -> int: load_dotenv() @@ -349,8 +215,14 @@ def main() -> int: # other players' whispers included, so it is never on by default. debug_log.configure(config.debug_capture_trace) - # Set UI language from config - tr.set_language(config.ui_language) + # Set UI language from config — except on a genuine first run, where the + # config holds no choice yet and its RU default would show the wizard in + # Russian to a player anywhere in the world. The OS locale stands in until + # the wizard saves a real preference. Same rule as the GTK entry point, + # from the same function, so the two cannot drift apart again. + tr.set_language( + startup_ui_language(config_exists=saved_config_exists(), saved=config.ui_language) + ) # First run — setup wizard if no API key if not any_configured(config.providers): @@ -395,6 +267,11 @@ def open_settings() -> None: config = dialog.get_config() overlay.update_channel_filters(_enabled_filter_names(config)) overlay.apply_settings(config) + # The dialog has already called tr.set_language; the widgets built + # before it did are still holding the old strings. The tray menu is + # the one the user cannot close and reopen to refresh. + overlay.apply_language() + tray.apply_language() # Propagate language/channel settings to the pipeline thread new_pipeline_config = _build_pipeline_config(config) pipeline_thread.update_config(new_pipeline_config) diff --git a/app/main_gtk.py b/app/main_gtk.py index d8ff38c..327d55a 100644 --- a/app/main_gtk.py +++ b/app/main_gtk.py @@ -20,8 +20,8 @@ from lingua import Language from app import debug_log -from app.config import CONFIG_FILE, AppConfig, enabled_channels, resolve_chatlog_path -from app.i18n import tr +from app.config import AppConfig, enabled_channels, resolve_chatlog_path, saved_config_exists +from app.i18n import startup_ui_language, tr from app.overlay_gtk import ChatOverlayGtk from app.pipeline import PipelineConfig, TranslationPipeline from app.settings_gtk import SettingsWindowGtk @@ -72,20 +72,29 @@ def main() -> int: # Off unless asked for: it records every chat line in full. debug_log.configure(config.debug_capture_trace) - # The Qt entry point has always done this and the GTK one never did, so - # every Linux user got the default interface language regardless of what - # they picked in Settings — and the default is Russian. - tr.set_language(config.ui_language) + # The Qt entry point has always applied the saved language and the GTK one + # never did, so every Linux user got the default — Russian — whatever they + # had picked in Settings. This has to happen before the wizard, because the + # wizard is what a first-time player reads first. + # Whether a saved config exists is config.py's question to answer, not a + # stat of one filename: load() reads config.json.bak too, so the main file + # being gone does not mean the user's language preference is. + config_exists = saved_config_exists() + tr.set_language(startup_ui_language(config_exists=config_exists, saved=config.ui_language)) # First run: no config file yet, or no translation API configured → # run the setup wizard (its own blocking GTK loop) before normal startup. - if not os.path.exists(CONFIG_FILE) or not any_configured(config.providers): + if not config_exists or not any_configured(config.providers): from app.setup_wizard_gtk import run_setup_wizard config = run_setup_wizard(config) if config is None: # user closed the wizard without finishing return 0 + # The wizard saves a language of its own — adopt it, or the app starts + # in whatever was on screen before the user chose. + tr.set_language(config.ui_language) + overlay = ChatOverlayGtk(config) # Reply translator (outgoing): default EN unless own language is EN. @@ -122,6 +131,22 @@ def _open_settings() -> None: return def _on_saved(updated: AppConfig) -> None: + nonlocal config + config = updated + + # The translation helper is process-global. Changing the saved config + # alone is not enough because existing GTK widgets already contain + # strings produced by tr() at construction time. + tr.set_language(updated.ui_language) + overlay.apply_language() + if tray is not None: + # The tray is built once at startup and there is no reopening + # it, so nothing else will ever bring it into the new language. + tray.update_item("overlay", label=_overlay_item_label()) + tray.update_item("tr", label=tr("tray.toggle_translation")) + tray.update_item("settings", label=tr("tray.settings")) + tray.update_item("quit", label=tr("tray.quit")) + # Apply live: rebuild pipeline config (channels/langs). pipeline.update_config(_build_pipeline_config(updated)) # Rebuild the reply translator so API key/priority changes take @@ -152,10 +177,18 @@ def _icon_path() -> str | None: path = os.path.join(base, "assets", "icon.png") return path if os.path.exists(path) else None + #: Whether the overlay is on screen, which decides what the first tray item + #: offers to do. Kept here so relabelling the menu after a language change + #: does not have to guess, and cannot offer to hide a hidden window. + overlay_visible = [True] + + def _overlay_item_label() -> str: + return tr("tray.hide_overlay") if overlay_visible[0] else tr("tray.show_overlay") + def _tray_toggle_overlay() -> None: - visible = overlay.toggle_visible() + overlay_visible[0] = overlay.toggle_visible() if tray is not None: - tray.update_item("overlay", label="Hide overlay" if visible else "Show overlay") + tray.update_item("overlay", label=_overlay_item_label()) def _tray_toggle_tr() -> None: overlay.set_translation_active(not pipeline.translation_enabled) @@ -173,12 +206,12 @@ def _toggle_translation(enabled: bool) -> None: on_activate=_tray_toggle_overlay, on_secondary_activate=_tray_toggle_tr, items=[ - MenuItem("overlay", "Hide overlay", _tray_toggle_overlay), - MenuItem("tr", "Translation", _tray_toggle_tr, checkable=True, + MenuItem("overlay", _overlay_item_label(), _tray_toggle_overlay), + MenuItem("tr", tr("tray.toggle_translation"), _tray_toggle_tr, checkable=True, checked=bool(config.translation_enabled_default)), - MenuItem("settings", "Settings…", _open_settings), + MenuItem("settings", tr("tray.settings"), _open_settings), MenuItem(), # separator - MenuItem("quit", "Quit", _quit), + MenuItem("quit", tr("tray.quit"), _quit), ], ) except Exception: # noqa: BLE001 — tray is optional; never block startup diff --git a/app/overlay.py b/app/overlay.py index e3c5679..5184e85 100644 --- a/app/overlay.py +++ b/app/overlay.py @@ -434,3 +434,27 @@ def apply_settings(self, config: AppConfig) -> None: self._bg_opacity = config.overlay_opacity self._opacity_slider.setValue(config.overlay_opacity) self._on_opacity_changed(config.overlay_opacity) + + def apply_language(self) -> None: + """Relabel everything built when the window was, after a language change. + + A Qt widget keeps the string it was constructed with, so setting the + language alone changes nothing that is already on screen — the setting + appeared to do nothing until the next launch, which reads as broken. + + Only the persistent chrome belongs here. The reply panel's status line + is written afresh on every action, so it arrives in the new language by + itself, and relabelling it here would overwrite whatever it is saying. + """ + self._toggle_btn.setText( + tr("overlay.badge.on") if self._translation_enabled else tr("overlay.badge.off") + ) + self._settings_btn.setText(tr("overlay.settings")) + self._opacity_label.setText(tr("overlay.opacity")) + self._reply_input.setPlaceholderText(tr("overlay.reply.input_hint")) + self._reply_copy_btn.setText(tr("overlay.reply.copy")) + self._filter_bar.apply_language() + # The clipboard dialog is created on demand and then kept, so it + # outlives the setting that was changed after it. + if self._reply_dialog is not None: + self._reply_dialog.apply_language() diff --git a/app/overlay_chrome.py b/app/overlay_chrome.py index 276fe37..453cc51 100644 --- a/app/overlay_chrome.py +++ b/app/overlay_chrome.py @@ -119,12 +119,16 @@ def build(overlay: ChatOverlay) -> None: ) settings_btn = QPushButton(tr("overlay.settings")) + # Kept on the overlay so a language change can reach it: a Qt widget holds + # the string it was built with, and a local goes out of scope here. + overlay._settings_btn = settings_btn settings_btn.setFixedHeight(20) settings_btn.setStyleSheet(_TB_BTN) settings_btn.clicked.connect(overlay.settings_requested.emit) tb_layout.addWidget(settings_btn) opacity_label = QLabel(tr("overlay.opacity")) + overlay._opacity_label = opacity_label opacity_label.setStyleSheet("color: #999; font-size: 10px;") tb_layout.addWidget(opacity_label) diff --git a/app/overlay_gtk.py b/app/overlay_gtk.py index a027e71..9676d5c 100644 --- a/app/overlay_gtk.py +++ b/app/overlay_gtk.py @@ -260,6 +260,7 @@ def __init__(self, config: AppConfig) -> None: self._list: Gtk.Box | None = None self._scroller: Gtk.ScrolledWindow | None = None self._reply_entry: Gtk.Entry | None = None + self._grip: Gtk.DrawingArea | None = None self._reply_status: Gtk.Label | None = None # Callbacks wired by main (so this module stays UI-only). @@ -721,6 +722,7 @@ def _drag_end(_g: Gtk.GestureDrag, ox: float, oy: float) -> None: grip.set_content_height(16) grip.add_css_class("bc-grip") grip.set_tooltip_text(tr("overlay.resize_hint")) + self._grip = grip grip.set_cursor_from_name("nwse-resize") grip.set_halign(Gtk.Align.END) grip.set_valign(Gtk.Align.END) @@ -871,6 +873,26 @@ def set_translation_active(self, enabled: bool) -> None: if getattr(self, "_translate_toggle", None) is not None: self._translate_toggle.set_active(enabled) + def apply_language(self) -> None: + """Refresh all persistent overlay UI text after a language change.""" + if self._win is None: + return + self._translate_toggle.set_label( + tr("overlay.badge.on") if self._translate_toggle.get_active() else tr("overlay.badge.off") + ) + self._translate_toggle.set_tooltip_text(tr("overlay.translate_toggle")) + self._reply_entry.set_placeholder_text(tr("overlay.reply.placeholder")) + self._reply_lang_dd.set_tooltip_text(tr("overlay.reply.into")) + self._copy_btn.set_label(tr("overlay.reply.copy")) + self._copy_btn.set_tooltip_text(tr("overlay.reply.copy")) + if self._grip is not None: + self._grip.set_tooltip_text(tr("overlay.resize_hint")) + self._update_filter_labels() + + def _update_filter_labels(self) -> None: + for name, btn in getattr(self, "_filter_buttons", {}).items(): + btn.set_label(tr(_FILTER_LABELS[name])) + def apply_appearance(self) -> None: """(Re)build the overlay CSS from current config — opacity, font size. diff --git a/app/overlay_reply.py b/app/overlay_reply.py index 923c6ea..1e9f985 100644 --- a/app/overlay_reply.py +++ b/app/overlay_reply.py @@ -148,6 +148,7 @@ def _setup_ui(self) -> None: ) self._copy_btn.clicked.connect(self._copy_result) result_row.addWidget(self._copy_btn) + panel_layout.addLayout(result_row) self._status = QLabel("") @@ -157,6 +158,18 @@ def _setup_ui(self) -> None: layout.addWidget(panel) + def apply_language(self) -> None: + """Relabel what this window was built with, after a language change. + + It is created lazily — by the clipboard hotkey, or the first time the + reply panel is opened — and then kept, so it outlives the setting that + was changed after it. Only the two labels built here: the status line + and the output field are written on every action and arrive in the new + language by themselves. + """ + self._reply_input.setPlaceholderText(tr("overlay.reply.input_hint")) + self._copy_btn.setText(tr("overlay.reply.copy")) + def set_translator(self, translator: TranslatorService, target_lang: str) -> None: self._translator = translator self._target_lang = target_lang diff --git a/app/overlay_widgets.py b/app/overlay_widgets.py index b6666f3..00035e5 100644 --- a/app/overlay_widgets.py +++ b/app/overlay_widgets.py @@ -138,6 +138,17 @@ def __init__(self, parent: QWidget | None = None) -> None: layout.addStretch() + def apply_language(self) -> None: + """Relabel the tabs after the interface language changed. + + From the same shared declaration they were built from, so a tab added + there is relabelled here without this method being touched. + """ + for name, label_key in FILTER_TABS: + button = self._buttons.get(name) + if button is not None: + button.setText(tr(label_key)) + def _on_click(self, name: str) -> None: self._active = name for btn_name, btn in self._buttons.items(): diff --git a/app/settings_gtk.py b/app/settings_gtk.py index 0863c74..8a2087f 100644 --- a/app/settings_gtk.py +++ b/app/settings_gtk.py @@ -399,6 +399,7 @@ def _on_save(self, _btn: Gtk.Button) -> None: setattr(c, attr, cb.get_active()) c.own_language = self._dd_value(self._own) c.target_language = self._dd_value(self._target) + language_changed = self._dd_value(self._ui) != tr.get_language() c.ui_language = self._dd_value(self._ui) # Applied immediately, the way the Qt dialog does it: a language you # picked and saved that does not take hold reads as the setting being @@ -441,3 +442,11 @@ def _on_save(self, _btn: Gtk.Button) -> None: if self._on_saved is not None: self._on_saved(c) + + # Every label in here was built in the old language and a GTK widget + # keeps the string it was built with, so a language change has to close + # the window: the next opening is the rebuild. Any other save leaves it + # open — closing on all of them would mean the "Saved" just written two + # lines up is never on screen long enough to read. + if language_changed: + self._win.close() diff --git a/app/setup_wizard.py b/app/setup_wizard.py index 17e6d85..757b1bd 100644 --- a/app/setup_wizard.py +++ b/app/setup_wizard.py @@ -152,6 +152,13 @@ def _on_ui_lang_changed(self) -> None: if lang and lang != tr.get_language(): tr.set_language(lang) self._config.ui_language = lang + # Everything typed so far goes into the config first. Restarting is + # how this wizard changes language, and the new one builds its + # fields from the config — so anything not written here is simply + # gone: an API key pasted on page two, the WoW path browsed for on + # page three. Same two calls _finish makes, for the same reason. + self._provider_group.apply_to(self._config) + self._config.wow_path = self._wow_path_input.text().strip() # Signal main to restart wizard with new language self._restart_requested = True self.done(2) # Custom result code: restart diff --git a/app/setup_wizard_gtk.py b/app/setup_wizard_gtk.py index 68f6dd3..16364e4 100644 --- a/app/setup_wizard_gtk.py +++ b/app/setup_wizard_gtk.py @@ -13,6 +13,7 @@ from __future__ import annotations +import contextlib import threading # noqa: E402 import gi # noqa: E402 @@ -21,7 +22,7 @@ from gi.repository import GLib, Gtk # noqa: E402 from app.config import AppConfig, detect_wow_path # noqa: E402 -from app.i18n import tr # noqa: E402 +from app.i18n import UI_LANGUAGES, tr # noqa: E402 from app.translators import all_providers # noqa: E402 from app.translators import get as provider_get # noqa: E402 @@ -38,12 +39,15 @@ ("KO", "한국어"), ("JA", "日本語"), ] -_UI_LANGS = [("EN", "English"), ("RU", "Русский"), ("ES", "Español")] +#: From the one table, not a fourth copy of it: a translation added there and +#: missed here would leave the dropdown falling back to its first entry, and +#: _finish would persist that over the language the guess had got right. +_UI_LANGS = list(UI_LANGUAGES.items()) class _WizardWindow(Gtk.ApplicationWindow): def __init__(self, app: Gtk.Application, config: AppConfig, result: dict) -> None: - super().__init__(application=app, title="BabelChat Setup") + super().__init__(application=app, title=tr("wizard.title")) self._config = config self._result = result # {"config": AppConfig|None} self.set_default_size(520, 480) @@ -51,10 +55,7 @@ def __init__(self, app: Gtk.Application, config: AppConfig, result: dict) -> Non self._stack = Gtk.Stack() self._stack.set_vexpand(True) self._pages: list[Gtk.Widget] = [] - for builder in (self._page_welcome, self._page_api, self._page_wow, self._page_langs, self._page_ready): - page = builder() - self._pages.append(page) - self._stack.add_child(page) + self._build_pages() self._index = 0 # Nav bar @@ -79,6 +80,75 @@ def __init__(self, app: Gtk.Application, config: AppConfig, result: dict) -> Non self.set_child(root) self._sync_nav() + # ── page (re)building ──────────────────────────────────────────────── + def _build_pages(self) -> None: + """(Re)build all pages from scratch so tr() text reflects the current + language. Called on init and again whenever the interface-language + dropdown changes, so the wizard updates live rather than only on the + NEXT run.""" + snapshot = self._snapshot_fields() if self._pages else None + + for page in self._pages: + self._stack.remove(page) + self._pages = [] + + for builder in (self._page_welcome, self._page_api, self._page_wow, + self._page_langs, self._page_ready): + page = builder() + self._pages.append(page) + self._stack.add_child(page) + + if snapshot is not None: + self._restore_fields(snapshot) + self._stack.set_visible_child(self._pages[self._index]) + + def _snapshot_fields(self) -> dict: + """Capture whatever the user has already entered, so switching the + interface language mid-wizard (a rebuild) doesn't lose it.""" + snap: dict = { + "ui_lang": self._dd_code(self._ui_lang), + "priority": self._dd_code(self._priority), + "wow_path": self._wow_entry.get_text(), + "own_lang": self._dd_code(self._own_lang), + "target_lang": self._dd_code(self._target_lang), + "providers": { + pid: {key: entry.get_text() for key, entry in fields.items()} + for pid, fields in self._provider_entries.items() + }, + } + return snap + + def _restore_fields(self, snap: dict) -> None: + self._set_dd_code(self._ui_lang, snap["ui_lang"]) + self._set_dd_code(self._priority, snap["priority"]) + self._wow_entry.set_text(snap["wow_path"]) + self._set_dd_code(self._own_lang, snap["own_lang"]) + self._set_dd_code(self._target_lang, snap["target_lang"]) + for pid, values in snap["providers"].items(): + fields = self._provider_entries.get(pid, {}) + for key, text in values.items(): + entry = fields.get(key) + if entry is not None: + entry.set_text(text) + + @staticmethod + def _set_dd_code(dd: Gtk.DropDown, code: str) -> None: + codes = getattr(dd, "_codes", []) + with contextlib.suppress(ValueError): + dd.set_selected(codes.index(code)) + + def _on_ui_lang_changed(self, dd: Gtk.DropDown, _param: object) -> None: + code = self._dd_code(dd) + if code == tr.get_language(): + return + tr.set_language(code) + self._config.ui_language = code + # Rebuild so every page (including this one) re-renders in the new + # language immediately, instead of only taking effect next launch. + self._build_pages() + self._sync_nav() + self.set_title(tr("wizard.title")) + # ── navigation ──────────────────────────────────────────────────────── def _go(self, delta: int) -> None: self._index = max(0, min(len(self._pages) - 1, self._index + delta)) @@ -88,6 +158,7 @@ def _go(self, delta: int) -> None: def _sync_nav(self) -> None: last = self._index == len(self._pages) - 1 self._back.set_sensitive(self._index > 0) + self._back.set_label(tr("wizard.back")) self._next.set_label(tr("wizard.start") if last else tr("wizard.next")) # Same step names the Qt wizard uses, from the one string that holds # them; the key wants a name as well as the numbers. @@ -146,7 +217,14 @@ def _page_welcome(self) -> Gtk.Widget: ) row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) row.append(self._body(tr("wizard.welcome.ui_lang"))) - self._ui_lang = self._dropdown(_UI_LANGS, self._config.ui_language or "EN") + # Source the selection from tr (what's actually on screen right now), + # not self._config.ui_language directly — before anything is saved, + # config.ui_language is just its raw default and can disagree with + # the language the page text is actually rendered in (e.g. the + # locale-guessed language on first open), showing a dropdown value + # that doesn't match what the user is looking at. + self._ui_lang = self._dropdown(_UI_LANGS, tr.get_language()) + self._ui_lang.connect("notify::selected", self._on_ui_lang_changed) row.append(self._ui_lang) box.append(row) return box @@ -295,7 +373,12 @@ def worker() -> None: GLib.idle_add(done, valid, msg) def done(valid: bool, msg: str) -> bool: - btn.set_sensitive(True) + # Switching the interface language rebuilds every page, so the + # button this validation started from may no longer be in the + # window by the time the worker answers. Its replacement is + # sensitive already; poking the orphan only earns GTK criticals. + if btn.get_parent() is not None: + btn.set_sensitive(True) if valid: extra = f" — {msg}" if msg and msg != "valid" else "" self._api_status.set_markup( diff --git a/app/single_instance.py b/app/single_instance.py new file mode 100644 index 0000000..6a1e1c6 --- /dev/null +++ b/app/single_instance.py @@ -0,0 +1,160 @@ +"""Only one copy of BabelChat runs at a time, and the old one steps aside. + +Split out of the entry point, which had grown past the line limit and was +carrying two unrelated jobs: wiring the application together, and deciding +whether another copy of it is already running. This is the second one, and it +is the half with the sharp edge — it terminates a process — so it is easier to +find and to read on its own. + +The sharp edge, in one paragraph: the lock file used to hold a bare PID, and +startup opened that PID with PROCESS_TERMINATE and killed it. Windows hands +PIDs back out, so the number written yesterday may belong to the user's editor +today. The file records the start stamp beside the PID now; a process that +took over the number necessarily started later, so the pair identifies the +copy we meant and nothing else. +""" + +from __future__ import annotations + +import ctypes +import logging +import os +import signal +import sys + +logger = logging.getLogger(__name__) + + +def _get_lock_file() -> str: + if getattr(__import__("sys"), "frozen", False): + lock_dir = os.path.join(os.path.expanduser("~"), ".config", "BabelChat") + os.makedirs(lock_dir, exist_ok=True) + return os.path.join(lock_dir, "babelchat.lock") + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "babelchat.lock") + + +_LOCK_FILE = _get_lock_file() + + +def _linux_start_time(stat_line: str) -> str: + """`starttime` out of a line of /proc//stat. + + Separate from the reading so it can be tested where /proc does not exist, + which is where this project is developed. The parsing is not obvious: field + two is the executable name in parentheses, and it may itself contain spaces + and parentheses — `(my prog) (v2)` is a legal name — so splitting the line + on whitespace from the left puts every later field at an offset that + depends on what the process is called. Counting from the last ')' is the + documented way round it. starttime is field 22, and the last ')' ends field + two, so it is index 19 in what follows. + """ + return stat_line.rpartition(")")[2].split()[19] + + +def _start_stamp(pid: int) -> str | None: + """When the process at `pid` started, as the operating system recorded it. + + A PID on its own does not identify a process for longer than that process + lives: Windows hands the numbers back out, and Linux wraps them. The lock + file outlives the copy that wrote it, so by the time it is read the number + in it may belong to something the user very much wants to keep running. + + Paired with the PID, the start time is unique — a process that took over the + number necessarily started later. Returns None when the answer is unknown, + which the caller must treat as "do not touch it". + """ + try: + if sys.platform == "win32": + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return None + try: + created = ctypes.c_ulonglong() + exited = ctypes.c_ulonglong() + kernel_time = ctypes.c_ulonglong() + user_time = ctypes.c_ulonglong() + ok = kernel32.GetProcessTimes( + handle, + ctypes.byref(created), + ctypes.byref(exited), + ctypes.byref(kernel_time), + ctypes.byref(user_time), + ) + return str(created.value) if ok else None + finally: + kernel32.CloseHandle(handle) + + if sys.platform.startswith("linux"): + with open(f"/proc/{pid}/stat", encoding="utf-8") as f: + return _linux_start_time(f.read()) + except (OSError, ValueError, IndexError): + return None + + return None + + +def _terminate(pid: int) -> None: + """Stop the previous copy. Only ever called for a verified match.""" + if sys.platform == "win32": + PROCESS_TERMINATE = 0x0001 + SYNCHRONIZE = 0x00100000 + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, False, pid) + if not handle: + logger.info("Old PID %d is already gone", pid) + return + kernel32.TerminateProcess(handle, 0) + kernel32.WaitForSingleObject(handle, 2000) + kernel32.CloseHandle(handle) + else: + import time as _time + + try: + os.kill(pid, signal.SIGTERM) + except (ProcessLookupError, PermissionError): + logger.info("Old PID %d is already gone", pid) + return + _time.sleep(0.5) + logger.info("Stopped the previous instance, PID %d", pid) + + +def _ensure_single_instance() -> None: + """Stop the previous copy of BabelChat, and nothing else. + + The lock file carries the PID and the start stamp of the process that wrote + it. Both must match a live process before anything is terminated; a lock + with only a PID — written by a version before this check existed — matches + nothing, so an upgrade leaves the running copy for the user to close rather + than gambling on the number. + """ + lock_path = os.path.abspath(_LOCK_FILE) + if os.path.exists(lock_path): + try: + with open(lock_path, encoding="utf-8") as f: + recorded = f.read().splitlines() + old_pid = int(recorded[0].strip()) + was = recorded[1].strip() if len(recorded) > 1 else "" + now = _start_stamp(old_pid) + + # One condition, deliberately. An earlier version spelled the three + # ways this can fail as three branches, and each of them turned out + # to be unreachable — the comparison below already rejects a missing + # stamp, an unknown one and a mismatched one. Branches that cannot + # change the outcome cannot be tested either, and they read as if + # they were load-bearing. + if now and now == was: + _terminate(old_pid) + else: + logger.info( + "Leaving PID %d alone: the lock says it started at %s, the live process says %s", + old_pid, + was or "(nothing)", + now or "(nothing there)", + ) + except (OSError, ValueError, IndexError) as e: + logger.warning("Could not read the lock file: %s", e) + + with open(lock_path, "w", encoding="utf-8") as f: + f.write(f"{os.getpid()}\n{_start_stamp(os.getpid()) or ''}\n") diff --git a/app/tray.py b/app/tray.py index 55a77ce..6ceb5e1 100644 --- a/app/tray.py +++ b/app/tray.py @@ -85,23 +85,41 @@ def __init__(self, parent: QWidget | None = None) -> None: self._menu.addSeparator() - settings_action = QAction(tr("tray.settings")) - settings_action.triggered.connect(self.settings_requested) - self._menu.addAction(settings_action) + # Kept on the icon, not local: a menu built once holds the strings it + # was built with, and the tray is the one window a user cannot close + # and reopen to get the new language. + self._settings_action = QAction(tr("tray.settings")) + self._settings_action.triggered.connect(self.settings_requested) + self._menu.addAction(self._settings_action) - about_action = QAction(tr("tray.about")) - about_action.triggered.connect(self.about_requested) - self._menu.addAction(about_action) + self._about_action = QAction(tr("tray.about")) + self._about_action.triggered.connect(self.about_requested) + self._menu.addAction(self._about_action) self._menu.addSeparator() - quit_action = QAction(tr("tray.quit")) - quit_action.triggered.connect(self.quit_requested) - self._menu.addAction(quit_action) + self._quit_action = QAction(tr("tray.quit")) + self._quit_action.triggered.connect(self.quit_requested) + self._menu.addAction(self._quit_action) self.setContextMenu(self._menu) self.activated.connect(self._on_activated) + def apply_language(self) -> None: + """Relabel the menu after the interface language changed. + + The first item is written from state rather than from the table alone: + it says Hide or Show depending on where the overlay is, and relabelling + it unconditionally would offer to hide a window that is already hidden. + """ + self._show_action.setText( + tr("tray.hide_overlay") if self._overlay_visible else tr("tray.show_overlay") + ) + self._translate_action.setText(tr("tray.toggle_translation")) + self._settings_action.setText(tr("tray.settings")) + self._about_action.setText(tr("tray.about")) + self._quit_action.setText(tr("tray.quit")) + def _toggle_overlay(self) -> None: if self._overlay_visible: self._overlay_visible = False diff --git a/pyproject.toml b/pyproject.toml index 29c7f07..22043ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "babelchat" -version = "3.4.0" +version = "3.4.1" description = "Real-time WoW chat translator with smart overlay" requires-python = ">=3.12" diff --git a/tests/test_i18n_completeness.py b/tests/test_i18n_completeness.py index bb263fb..f1f56b3 100644 --- a/tests/test_i18n_completeness.py +++ b/tests/test_i18n_completeness.py @@ -337,10 +337,23 @@ def test_every_entry_point_applies_the_configured_ui_language(entry_point): `gi` is not installed on Windows or on CI, so `main_gtk` cannot be imported here. The call is asserted in the source instead — a weaker check than running it, and the reason it is written down. + + Read as whole statements rather than as lines: the argument is now long + enough to wrap, and a line scan would report an entry point that does this + correctly as one that never does it at all. """ - text = (APP.parent / entry_point).read_text(encoding="utf-8") + import ast - calls = [line.strip() for line in text.splitlines() if "tr.set_language(" in line] + text = (APP.parent / entry_point).read_text(encoding="utf-8") + tree = ast.parse(text) + + calls = [ + ast.get_source_segment(text, node) + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "set_language" + ] assert calls, f"{entry_point} never applies the configured language" assert any("ui_language" in call for call in calls), calls diff --git a/tests/test_overlay_language_refresh.py b/tests/test_overlay_language_refresh.py new file mode 100644 index 0000000..51dd185 --- /dev/null +++ b/tests/test_overlay_language_refresh.py @@ -0,0 +1,222 @@ +"""A language change has to reach the windows already on screen. + +Both overlays build their chrome once and a widget keeps the string it was +constructed with, so setting the interface language changed nothing already +drawn. The setting appeared to do nothing at all until the next launch, which +is indistinguishable from a broken control — and it was fixed on the GTK side +first, which left the changelog promising Windows users a repair they had not +been given. + +Read from the source rather than by driving the widgets. That is weaker, and +it is written down because it is a choice: constructing `ChatOverlay` under +pytest kills the interpreter outright — no traceback, no failure, the run just +stops — which is why nothing else in this suite instantiates it either, and +`gi` is absent on Windows and on CI so the GTK half could not be built here +regardless. The behaviour was verified by hand instead, off-screen, with the +language switched under a live overlay: all five chrome labels and all +thirteen filter tabs followed, and the ON/OFF badge kept its state. + +What the source can still be held to is the thing that actually rots: a label +added to the chrome and forgotten in the refresh. +""" + +from __future__ import annotations + +import ast +import pathlib +import re + +from app.i18n import UI_LANGUAGES + +ROOT = pathlib.Path(__file__).resolve().parent.parent +APP = ROOT / "app" + +#: `tr("some.key")` anywhere in a source file. +TR_KEY = re.compile(r'tr\(\s*"([^"]+)"') + + +def source_of(module: str) -> str: + return (APP / module).read_text(encoding="utf-8") + + +def method_source(module: str, name: str) -> str: + """The text of one method, or "" when the module does not define it.""" + text = source_of(module) + for node in ast.walk(ast.parse(text)): + if isinstance(node, ast.FunctionDef) and node.name == name: + return ast.get_source_segment(text, node) or "" + return "" + + +# ── both frontends can do it at all ────────────────────────────────────────── + + +def test_both_overlays_can_relabel_themselves(): + """This was fixed on GTK and not on Qt while the changelog described it as + fixed outright. Neither half is allowed to be the only one again.""" + for module in ("overlay.py", "overlay_gtk.py"): + assert method_source(module, "apply_language"), f"{module} cannot relabel itself" + + +def test_both_entry_points_call_it_when_settings_are_saved(): + """A refresh that exists and is never called is the same bug with more + code in it.""" + for entry, overlay in (("main.py", "overlay"), ("main_gtk.py", "overlay")): + text = source_of(entry) + calls = {ast.get_source_segment(text, node) for node in ast.walk(ast.parse(text)) if isinstance(node, ast.Call)} + + assert f"{overlay}.apply_language()" in calls, f"{entry} never refreshes the overlay" + + +# ── and neither leaves a label behind ──────────────────────────────────────── + + +def test_every_label_the_qt_chrome_builds_is_refreshed(): + """The failure mode this guards is not today's code, it is next year's: a + label added to the toolbar and forgotten here shows up as one English + word among Russian ones, which nobody reports as a bug.""" + built = set(TR_KEY.findall(source_of("overlay_chrome.py"))) + refreshed = method_source("overlay.py", "apply_language") + + missing = sorted(key for key in built if key not in refreshed) + + assert built, "no translated labels found — has the chrome moved?" + assert missing == [], f"built by the chrome and never refreshed: {missing}" + + +def test_the_clipboard_dialog_is_refreshed_too(): + """It is a separate window, created on demand by the hotkey and then kept, + so it outlives the setting changed after it — and being separate is exactly + why it was missed.""" + refresh = method_source("overlay.py", "apply_language") + + assert "self._reply_dialog.apply_language()" in refresh + assert method_source("overlay_reply.py", "apply_language"), "the dialog cannot relabel itself" + + +def test_the_tray_menu_is_refreshed_too(): + """The tray is the one window a user cannot close and reopen to get the new + language — it is only ever built once, at startup.""" + text = source_of("main.py") + calls = { + ast.get_source_segment(text, node) + for node in ast.walk(ast.parse(text)) + if isinstance(node, ast.Call) + } + + assert "tray.apply_language()" in calls, "the tray menu keeps the old language" + assert method_source("tray.py", "apply_language"), "the tray cannot relabel itself" + + +def test_the_qt_refresh_reaches_the_filter_tabs(): + """They are built from the shared FILTER_TABS declaration, so their keys + are not in the chrome's source to be matched above — the bar relabels + itself and the overlay has to ask it to.""" + assert "self._filter_bar.apply_language()" in method_source("overlay.py", "apply_language") + assert method_source("overlay_widgets.py", "apply_language"), "the filter bar cannot relabel" + + +def test_the_widgets_the_qt_refresh_touches_are_kept_somewhere(): + """Two of them were locals in a builder function, so the refresh could + name them but never reach them — an AttributeError on every settings + save.""" + # Assigned by the chrome builder as `overlay._x`, or by the overlay itself + # as `self._x` — the lazily created clipboard dialog is the second kind. + assigned = set(re.findall(r"overlay\.(_\w+)\s*=", source_of("overlay_chrome.py"))) + assigned |= set(re.findall(r"self\.(_\w+)\s*=", source_of("overlay.py"))) + + refresh = method_source("overlay.py", "apply_language") + # Widgets only — those the refresh calls a method on. `_translation_enabled` + # is read as a value, not sent a message. + attributes = set(re.findall(r"self\.(_\w+)\.", refresh)) + + assert attributes, "the refresh touches no widgets at all" + for attribute in attributes: + assert attribute in assigned, ( + f"apply_language calls self.{attribute}, which nothing ever assigns" + ) + + +def test_the_badge_is_refreshed_from_its_state_not_from_the_table_alone(): + """It is the one label whose text depends on more than the language. + Writing the "on" string unconditionally would show a stopped translation + as running, or the reverse — the label and the behaviour disagreeing is + worse than the label being stale.""" + refresh = method_source("overlay.py", "apply_language") + + assert "overlay.badge.on" in refresh and "overlay.badge.off" in refresh + assert "_translation_enabled" in refresh, "the badge is relabelled without consulting its state" + + +# ── the wizard offers what the app actually has ────────────────────────────── + + +def test_the_gtk_wizard_offers_every_language_the_app_has(): + """`_UI_LANGS` was a hand-written copy of the table. A translation added to + UI_LANGUAGES and missed there leaves the dropdown falling back to its first + entry, and finishing the wizard persists that over the language the locale + guess had got right — the same silent clamp that was just removed from + `tr.set_language`, one file over.""" + assert "_UI_LANGS = list(UI_LANGUAGES.items())" in source_of("setup_wizard_gtk.py") + + +def test_the_language_table_has_the_shape_that_call_produces(): + """`_dropdown` unpacks (code, label) pairs and indexes its codes list by an + upper-case code. A table shaped any other way fails at wizard startup, on + Linux only, where it would be found by a user rather than by this.""" + pairs = list(UI_LANGUAGES.items()) + + assert len(pairs) >= 2 + for code, label in pairs: + assert code == code.upper(), f"{code!r} is not the upper-case form the lookup uses" + assert label and label != code, f"{code!r} has no name to show in the dropdown" + + +# ── the tray on Linux, and what the wizard remembers ───────────────────────── + + +def test_the_gtk_tray_menu_is_translated_at_all(): + """It was five English literals. The keys existed and the Qt tray had been + using them since it was written; the GTK menu simply never went through + `tr`, so a Russian user got a Russian overlay above an English tray.""" + text = source_of("main_gtk.py") + items = re.findall(r"MenuItem\(\s*\"[^\"]+\"\s*,\s*([^,)]+)", text) + + assert items, "no menu items found — has the tray moved?" + for label in items: + assert "tr(" in label or label.strip().startswith("_"), f"hardcoded tray label: {label.strip()}" + + +def test_the_gtk_tray_is_refreshed_when_settings_are_saved(): + """Same reason as the Qt one: the tray is built once at startup and there + is no reopening it, so nothing else will ever bring it into the language + the user just picked.""" + text = source_of("main_gtk.py") + saved = method_source("main_gtk.py", "_on_saved") + + assert saved, "the settings callback has moved" + assert "tray.update_item" in saved, "the tray keeps the old language" + assert 'tr("tray.settings")' in text, "the menu is refreshed with something other than the table" + + +def test_the_gtk_tray_overlay_item_is_written_from_state(): + """It says Hide or Show depending on where the overlay is. Relabelling it + from the table alone would offer to hide a window that is already hidden — + the same trap as the translation badge.""" + label = method_source("main_gtk.py", "_overlay_item_label") + + assert label, "the label is not derived anywhere" + assert "tray.hide_overlay" in label and "tray.show_overlay" in label + + +def test_the_qt_wizard_keeps_what_was_typed_across_a_language_change(): + """Changing the language restarts this wizard, and the new one builds its + fields from the config — so anything not written to the config first is + gone: an API key pasted on page two, the WoW path browsed for on page + three. The GTK wizard snapshots and restores; the changelog describes the + carry-across without naming a platform.""" + handler = method_source("setup_wizard.py", "_on_ui_lang_changed") + + assert handler, "the language handler has moved" + assert "apply_to" in handler, "the entered credentials are dropped on restart" + assert "wow_path" in handler, "the entered WoW path is dropped on restart" diff --git a/tests/test_single_instance.py b/tests/test_single_instance.py index 239ac11..dbd21da 100644 --- a/tests/test_single_instance.py +++ b/tests/test_single_instance.py @@ -19,19 +19,17 @@ import pytest -pytest.importorskip("PyQt6", reason="main imports the Qt frontend") - -from app import main as app_main # noqa: E402 +from app import single_instance @pytest.fixture def lock(tmp_path, monkeypatch): """Point the module at a lock file of our own and count the kills.""" path = tmp_path / "babelchat.lock" - monkeypatch.setattr(app_main, "_LOCK_FILE", str(path)) + monkeypatch.setattr(single_instance, "_LOCK_FILE", str(path)) killed: list[int] = [] - monkeypatch.setattr(app_main, "_terminate", lambda pid: killed.append(pid)) + monkeypatch.setattr(single_instance, "_terminate", lambda pid: killed.append(pid)) return SimpleNamespace(path=path, killed=killed) @@ -39,9 +37,9 @@ def lock(tmp_path, monkeypatch): def test_a_reused_pid_is_not_killed(lock, monkeypatch): """The whole point. Same number, different process — leave it alone.""" lock.path.write_text("4242\nstarted-yesterday\n", encoding="utf-8") - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: "started-today") + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: "started-today") - app_main._ensure_single_instance() + single_instance._ensure_single_instance() assert lock.killed == [], "terminated a process that only shares the number" @@ -49,9 +47,9 @@ def test_a_reused_pid_is_not_killed(lock, monkeypatch): def test_the_previous_copy_is_killed(lock, monkeypatch): """And the feature still works: same number, same process.""" lock.path.write_text("4242\nstarted-yesterday\n", encoding="utf-8") - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: "started-yesterday") + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: "started-yesterday") - app_main._ensure_single_instance() + single_instance._ensure_single_instance() assert lock.killed == [4242] @@ -61,9 +59,9 @@ def test_an_unknown_stamp_kills_nothing(lock, monkeypatch): query failed for some other reason. Both are 'I do not know', and killing on 'I do not know' is what this test exists to prevent.""" lock.path.write_text("4242\nstarted-yesterday\n", encoding="utf-8") - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: None) + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: None) - app_main._ensure_single_instance() + single_instance._ensure_single_instance() assert lock.killed == [] @@ -73,9 +71,9 @@ def test_a_lock_from_an_older_version_kills_nothing(lock, monkeypatch): There is nothing to compare against, so the old copy is left running and the user closes it by hand. Nobody's unrelated process dies for the upgrade.""" lock.path.write_text("4242", encoding="utf-8") - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: "started-today") + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: "started-today") - app_main._ensure_single_instance() + single_instance._ensure_single_instance() assert lock.killed == [] @@ -86,42 +84,42 @@ def test_an_empty_stamp_never_matches_an_old_lock(lock, monkeypatch): bare PID again — the exact bug, reintroduced by a plausible edit somewhere else entirely.""" lock.path.write_text("4242\n\n", encoding="utf-8") - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: "") + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: "") - app_main._ensure_single_instance() + single_instance._ensure_single_instance() assert lock.killed == [] def test_a_damaged_lock_does_not_stop_the_app(lock, monkeypatch): lock.path.write_text("not a pid at all", encoding="utf-8") - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: "started-today") + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: "started-today") - app_main._ensure_single_instance() + single_instance._ensure_single_instance() assert lock.killed == [] - assert lock.path.read_text(encoding="utf-8").splitlines()[0] == str(app_main.os.getpid()) + assert lock.path.read_text(encoding="utf-8").splitlines()[0] == str(single_instance.os.getpid()) def test_the_lock_records_this_process(lock, monkeypatch): - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: f"stamp-of-{pid}") + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: f"stamp-of-{pid}") - app_main._ensure_single_instance() + single_instance._ensure_single_instance() pid, stamp = lock.path.read_text(encoding="utf-8").splitlines()[:2] - assert pid == str(app_main.os.getpid()) - assert stamp == f"stamp-of-{app_main.os.getpid()}" + assert pid == str(single_instance.os.getpid()) + assert stamp == f"stamp-of-{single_instance.os.getpid()}" def test_the_lock_is_written_even_with_no_stamp_available(lock, monkeypatch): """A platform we cannot query still gets single-instance behaviour on the next run — it just never kills anything. Writing no lock at all would leave stale files around and change nothing for the better.""" - monkeypatch.setattr(app_main, "_start_stamp", lambda pid: None) + monkeypatch.setattr(single_instance, "_start_stamp", lambda pid: None) - app_main._ensure_single_instance() + single_instance._ensure_single_instance() - assert lock.path.read_text(encoding="utf-8").splitlines()[0] == str(app_main.os.getpid()) + assert lock.path.read_text(encoding="utf-8").splitlines()[0] == str(single_instance.os.getpid()) # ── the stamp itself, against the real operating system ────────────────────── @@ -131,7 +129,7 @@ def test_this_process_has_a_stamp(): """A test double is only worth something if the real thing behaves the same way. Windows and Linux are both covered by the implementation; anywhere else the function is allowed to say it does not know.""" - stamp = app_main._start_stamp(app_main.os.getpid()) + stamp = single_instance._start_stamp(single_instance.os.getpid()) if sys.platform in ("win32", "linux"): assert stamp, f"no start stamp for our own process on {sys.platform}" @@ -141,7 +139,7 @@ def test_this_process_has_a_stamp(): def test_a_pid_that_cannot_exist_has_no_stamp(): """PIDs are bounded; this one is past the end on both platforms.""" - assert app_main._start_stamp(0x7FFFFFFF) is None + assert single_instance._start_stamp(0x7FFFFFFF) is None # ── /proc parsing, testable where there is no /proc ────────────────────────── @@ -156,7 +154,7 @@ def test_a_pid_that_cannot_exist_has_no_stamp(): def test_the_start_time_is_read_from_the_documented_field(): - assert app_main._linux_start_time(STAT) == "8654321" + assert single_instance._linux_start_time(STAT) == "8654321" def test_a_command_name_full_of_spaces_and_brackets_does_not_shift_the_field(): @@ -166,13 +164,13 @@ def test_a_command_name_full_of_spaces_and_brackets_does_not_shift_the_field(): also how a process could choose a name that makes it look like ours.""" hostile = STAT.replace("(BabelChat)", "(evil ) prog (x) )") - assert app_main._linux_start_time(hostile) == "8654321" + assert single_instance._linux_start_time(hostile) == "8654321" def test_the_stamp_is_stable_across_calls(): """It is compared between two runs of the program, so a stamp that changed between calls would make every previous copy look like a stranger and the single-instance behaviour would quietly stop working.""" - pid = app_main.os.getpid() + pid = single_instance.os.getpid() - assert app_main._start_stamp(pid) == app_main._start_stamp(pid) + assert single_instance._start_stamp(pid) == single_instance._start_stamp(pid) diff --git a/tests/test_ui_language_guess.py b/tests/test_ui_language_guess.py new file mode 100644 index 0000000..cfb5018 --- /dev/null +++ b/tests/test_ui_language_guess.py @@ -0,0 +1,384 @@ +"""Which language the interface opens in before anything has been saved. + +The app defaults to Russian, which is right for the audience it was written +for and wrong for everyone else on their very first launch: the setup wizard +is the first thing a new player sees, and it was showing them Russian whatever +their machine was set to. The OS locale is the only signal available at that +point, so it decides. + +The dangerous half is knowing when NOT to consult it. The wizard reopens +whenever no provider is configured — an expired key, a config migrated from +before the provider registry — and that is not a first run: a real preference +is sitting in the config file. The wizard seeds its dropdown from the language +on screen and writes it back on finish, so guessing there does not merely +mislabel a window, it overwrites the choice the user made. +""" + +from __future__ import annotations + +import json +import pathlib + +import pytest + +from app.config import AppConfig, saved_config_exists +from app.i18n import ( + UI_LANGUAGES, + _windows_ui_language, + guess_ui_language, + startup_ui_language, +) + +#: Every variable the guesser reads. Cleared wholesale per test so the machine +#: running the suite cannot answer for the machine being simulated. +LOCALE_VARS = ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG") + + +@pytest.fixture +def env(monkeypatch): + """A machine with no locale opinion, plus a lever to give it one.""" + for var in LOCALE_VARS: + monkeypatch.delenv(var, raising=False) + # getlocale() reads the process's own setting, which pytest inherits from + # whoever ran it. Neutralise it; the tests that care set it themselves. + monkeypatch.setattr("app.i18n.locale.getlocale", lambda *a: (None, None)) + # Same for Windows: CI runs on a Windows runner whose interface is English, + # so leaving this live would have every "no locale anywhere" test answered + # by the runner's own machine. + monkeypatch.setattr("app.i18n._windows_ui_language", lambda: "") + return monkeypatch + + +# ── reading the environment ────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("es_ES.UTF-8", "ES"), + ("en_GB", "EN"), + ("ru_RU.UTF-8", "RU"), + ("es", "ES"), + ("en_US.iso88591", "EN"), + ("ru_RU.UTF-8@cyrillic", "RU"), + ], +) +def test_a_locale_names_its_language(env, value, expected): + env.setenv("LANG", value) + + assert guess_ui_language() == expected + + +def test_language_wins_over_the_lc_variables(env): + """gettext resolves LANGUAGE first and LANG nearly last. Ordering them the + other way round is the easy mistake — LANG is the famous one — and it + silently ignores the variable the user set precisely to be obeyed.""" + env.setenv("LANGUAGE", "es") + env.setenv("LC_ALL", "ru_RU.UTF-8") + env.setenv("LC_MESSAGES", "ru_RU.UTF-8") + env.setenv("LANG", "ru_RU.UTF-8") + + assert guess_ui_language() == "ES" + + +def test_lc_all_wins_over_lang(env): + env.setenv("LC_ALL", "es_ES.UTF-8") + env.setenv("LANG", "en_US.UTF-8") + + assert guess_ui_language() == "ES" + + +def test_language_is_a_preference_list(env): + """LANGUAGE holds colon-separated fallbacks, unlike the others. Reading it + as a single locale would see "de:es" as a language named "de:es".""" + env.setenv("LANGUAGE", "de:es:ru") + + assert guess_ui_language() == "ES" + + +def test_an_unsupported_language_does_not_end_the_search(env): + """A German desktop with LANG=de_DE has no German UI to offer, but its + LC_MESSAGES may still name one this build has. Returning the default at + the first variable that says anything would never look.""" + env.setenv("LANGUAGE", "de") + env.setenv("LANG", "es_ES.UTF-8") + + assert guess_ui_language() == "ES" + + +# ── falling back ───────────────────────────────────────────────────────────── + + +def test_no_locale_at_all_falls_back(env): + assert guess_ui_language() == "RU" + + +def test_a_language_this_build_cannot_show_falls_back(env): + """Translating the UI is not the same as translating chat: the app speaks + twenty languages to WoW and three to its own user.""" + env.setenv("LANG", "ja_JP.UTF-8") + + assert guess_ui_language() == "RU" + + +@pytest.mark.parametrize("value", ["", "C", "POSIX", "C.UTF-8"]) +def test_the_uninformative_locales_say_nothing(env, value): + env.setenv("LANG", value) + + assert guess_ui_language() == "RU" + + +def test_the_process_locale_answers_when_the_environment_is_silent(env): + """Windows sets none of these variables; getlocale() is all there is.""" + env.setattr("app.i18n.locale.getlocale", lambda *a: ("es_ES", "UTF-8")) + + assert guess_ui_language() == "ES" + + +def test_a_malformed_locale_setting_is_not_a_crash(env): + """getlocale() raises on a setting it cannot parse. Failing to guess a + language must not stop the app from starting.""" + + def explode(*_args): + raise ValueError("unknown locale format") + + env.setattr("app.i18n.locale.getlocale", explode) + + assert guess_ui_language() == "RU" + + +def test_the_caller_chooses_the_fallback(env): + assert guess_ui_language(default="EN") == "EN" + + +def test_it_only_ever_returns_a_language_the_ui_has(env): + """The supported set is UI_LANGUAGES, not a list copied beside it — a + fourth translation should not need this function edited to be reachable.""" + for value in ("es_ES", "en_US", "ru_RU", "zz_ZZ", "de_DE", ""): + env.setenv("LANG", value) + + assert guess_ui_language() in UI_LANGUAGES + + +# ── when the guess is allowed to speak at all ──────────────────────────────── + + +def test_a_saved_choice_is_honoured(env): + """The ordinary case: the config file exists, so it decides.""" + env.setenv("LANG", "de_DE.UTF-8") + + assert startup_ui_language(config_exists=True, saved="ES") == "ES" + + +def test_the_guess_does_not_overrule_a_saved_choice(env): + """The wizard reopens whenever no provider is configured, not only on a + first run — an expired key does it, so does a config migrated from before + the provider registry. Treating that as a first run consults the OS locale + over a preference that already exists, and because the welcome page seeds + its dropdown from the language on screen and finish() writes it back, a + user who clicked through would find Spanish saved as German.""" + env.setenv("LANG", "de_DE.UTF-8") + + assert startup_ui_language(config_exists=True, saved="ES") != "RU" + assert startup_ui_language(config_exists=True, saved="ES") == "ES" + + +def test_a_first_run_has_nothing_to_honour(env): + """No config file: `saved` is only the dataclass default, not a choice.""" + env.setenv("LANG", "es_ES.UTF-8") + + assert startup_ui_language(config_exists=False, saved="RU") == "ES" + + +def test_a_first_run_on_a_machine_with_no_locale_keeps_the_default(env): + assert startup_ui_language(config_exists=False, saved="RU") == "RU" + + +def test_both_entry_points_ask_the_same_question(): + """This decision lived in one frontend and not the other for a release, + which is how Linux users ended up unable to change the interface language + at all. Whatever it grows into, both callers get the same answer.""" + import ast + + root = pathlib.Path(__file__).resolve().parent.parent + for entry in ("main.py", "main_gtk.py"): + source = (root / "app" / entry).read_text(encoding="utf-8") + tree = ast.parse(source) + called = { + node.func.id + for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + + assert "startup_ui_language" in called, f"{entry} decides the startup language by itself" + assert "guess_ui_language" not in called, f"{entry} reaches past the shared rule" + assert "saved_config_exists" in called, ( + f"{entry} decides what a first run is without asking config.py" + ) + + # os.path.exists(CONFIG_FILE) is the wrong question and was asked here + # twice: load() also reads config.json.bak, so the main file's absence + # is not proof there is no saved language. + segments = [ast.get_source_segment(source, node) or "" for node in ast.walk(tree)] + stats = [seg for seg in segments if "CONFIG_FILE" in seg and "exists" in seg] + assert stats == [], f"{entry} still stats the config path: {stats}" + + +# ── Windows answers this question differently ──────────────────────────────── + + +def test_windows_says_which_language_its_interface_is_in(env): + """The env vars are a POSIX convention; Windows sets none of them.""" + env.setattr("app.i18n._windows_ui_language", lambda: "en_US") + + assert guess_ui_language() == "EN" + + +def test_the_c_runtime_locale_name_is_not_an_iso_code(env): + """What `getlocale()` returns on Windows, measured: `('Russian_Russia', + '1252')`. It is the C runtime's name for the locale, not a language code, + and reading it as one is why every Windows first run answered RU whatever + the machine was set to — including the English ones this was written for. + A machine whose interface is English must not be talked out of it by the + formats locale next door.""" + env.setattr("app.i18n.locale.getlocale", lambda *a: ("English_United States", "1252")) + env.setattr("app.i18n._windows_ui_language", lambda: "en_US") + + assert guess_ui_language() == "EN" + + +def test_the_windows_lookup_stays_on_windows(monkeypatch): + """It is called unconditionally, so it has to be inert everywhere else — + `ctypes.windll` does not exist on Linux and reaching for it would raise + during startup on the platform this app was ported to.""" + monkeypatch.setattr("app.i18n.sys.platform", "linux") + + assert _windows_ui_language() == "" + + +def test_an_lcid_python_has_no_name_for_says_nothing(monkeypatch): + """`windows_locale` is a fixed table shipped with Python; a language ID + added to Windows after that table was written is simply absent from it, + and must read as "no answer" rather than as a crash.""" + monkeypatch.setattr("app.i18n.sys.platform", "win32") + monkeypatch.setattr("app.i18n.locale.windows_locale", {}, raising=False) + + assert _windows_ui_language() == "" + + +def test_the_environment_still_outranks_windows(env): + """A user who sets LANGUAGE on Windows — through a launcher script, say — + is asking for something specific, and asked first.""" + env.setenv("LANGUAGE", "es") + env.setattr("app.i18n._windows_ui_language", lambda: "en_US") + + assert guess_ui_language() == "ES" + + +# ── what counts as having run this app before ──────────────────────────────── + + +def _write(path, **fields) -> None: + path.write_text(json.dumps(fields), encoding="utf-8") + + +def test_a_config_only_in_the_backup_still_counts(tmp_path): + """`AppConfig.load` reads config.json.bak when the main file is gone — and + hands back the language saved in it. Asking `os.path.exists(CONFIG_FILE)` + instead calls that a first run, so the guess overrides a preference that + was successfully loaded one line earlier; on GTK the wizard opens too, + seeds its dropdown from the guess and writes it back on finish, which + deletes the recovered choice for good.""" + main = tmp_path / "config.json" + _write(main.with_suffix(".json.bak"), ui_language="ES") + + assert not main.exists() + assert saved_config_exists(str(main)) is True + assert AppConfig.load(str(main)).ui_language == "ES" + + +def test_a_corrupt_config_with_no_backup_is_a_first_run(tmp_path): + """The other direction. `load` falls back to defaults here, so the RU it + returns is not a choice anybody made — treating the file's presence as one + shows the wizard in Russian to someone who has never used the app.""" + main = tmp_path / "config.json" + main.write_text("{ this is not json", encoding="utf-8") + + assert saved_config_exists(str(main)) is False + assert AppConfig.load(str(main)).ui_language == "RU" + + +def test_nothing_on_disk_is_a_first_run(tmp_path): + assert saved_config_exists(str(tmp_path / "config.json")) is False + + +def test_the_question_and_the_answer_read_the_same_files(tmp_path): + """These drifted the moment they were written apart: one stats a filename, + the other has a fallback list. They share the list now.""" + from app.config import _config_candidates + + main = tmp_path / "config.json" + candidates = [str(p) for p in _config_candidates(str(main))] + + assert candidates == [str(main), str(main.with_suffix(".json.bak"))] + + for candidate in candidates: + pathlib.Path(candidate).write_text(json.dumps({"ui_language": "ES"}), encoding="utf-8") + assert saved_config_exists(str(main)) is True + pathlib.Path(candidate).unlink() + + +def test_asking_does_not_rewrite_anything(tmp_path): + """`load` runs migrations that write backups of their own. A question that + quietly does that is not a question.""" + main = tmp_path / "config.json" + _write(main, deepl_api_key="secret-from-before-the-registry", ui_language="ES") + before = {p.name for p in tmp_path.iterdir()} + + assert saved_config_exists(str(main)) is True + assert {p.name for p in tmp_path.iterdir()} == before + + +@pytest.mark.parametrize( + ("config_exists", "expected"), + [(True, "ES"), (False, "RU")], +) +def test_the_startup_rule_takes_that_answer(env, config_exists, expected): + """Joining the two halves: a recovered config keeps its language, a machine + with no config at all gets the guess (RU here, the environment is bare).""" + assert startup_ui_language(config_exists=config_exists, saved="ES") == expected + + +# ── the two halves agree on which languages exist ──────────────────────────── + + +def test_setting_a_language_accepts_every_language_the_guess_can_return(): + """These were two lists: `tr.set_language` validated against a tuple while + the guess resolved against UI_LANGUAGES. A fourth translation added to the + table and not the tuple would be found by the guess and then silently + clamped back to Russian on the way in — the first-run bug again, wearing a + new hat, and silent because set_language reports nothing.""" + from app.i18n import tr + + previous = tr.get_language() + try: + for code in UI_LANGUAGES: + tr.set_language(code) + + assert tr.get_language() == code, f"{code} is offered but not accepted" + finally: + tr.set_language(previous) + + +def test_a_language_that_does_not_exist_is_still_refused(): + """Widening the check must not turn it off: the fallback is what keeps a + stale config or a hand-edited one from blanking the interface.""" + from app.i18n import tr + + previous = tr.get_language() + try: + tr.set_language("ZZ") + + assert tr.get_language() == "RU" + finally: + tr.set_language(previous)