diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index 9a07d614..f3b48675 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -80,175 +80,74 @@ class CommandDescriptionsTests(unittest.TestCase): self.assertEqual(catalog("sv")["terminal"]["commandDescriptions"][index], FIXTURE["locales"]["sv"][index]) - def test_workflow_default_catalogs_rerun_without_provider_or_writes(self): + def assert_completed_catalogs_are_stable(self, *, future_messages=None): workflow = (ROOT / ".github/workflows/build-i18n-messages.yml").read_text() languages = re.search(r'LANGS="\$\{LANGS:-(.*?)\}"', workflow).group(1) self.assertEqual(languages.split(","), list(MODULE.DEFAULT_LANGUAGES)) + locales = ["en", *languages.split(",")] + shipped = {ROOT / f"AppImage/messages/{lang}/common.json": catalog(lang) for lang in locales} + original_bytes = {path: path.read_bytes() for path in shipped} + + def strings(node, path=()): + # Independent traversal: every existing nonempty string, including + # explicit English and obsolete human keys, must survive completion. + if isinstance(node, str) and node: + yield path, node + elif isinstance(node, dict): + for key, value in node.items(): + yield from strings(value, (*path, key)) + elif isinstance(node, list): + for index, value in enumerate(node): + yield from strings(value, (*path, index)) + with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - for lang in ["en", *languages.split(",")]: - path = root / lang / "common.json" - path.parent.mkdir() - shutil.copyfile(ROOT / f"AppImage/messages/{lang}/common.json", path) - # This steady-state fixture assumes generation already finished. - # Seed only intentional message additions; keep the - # recovered command arrays and the repo catalogs exact. Runtime - # missing-key fallback is covered by the JSX seam tests and by - # test_storage_messages.cjs and test_health_text.cjs. - if lang != "en": - temporary = json.loads(path.read_text()) - for section, key in ( - ("archives", "emptyMessage"), - ("archives", "formerJob"), - ("archives", "formerJobTitle"), - ("archives", "hiddenOtherNodes"), - ("archives", "scopeAllNodes"), - ("archives", "scopeAriaLabel"), - ("archives", "scopeShowAll"), - ("archives", "scopeThisNode"), - ("destinations", "backupsKeptMessage"), - ("destinations", "localAttachHelpMessage"), - ): - temporary["backup"][section].setdefault( - key, catalog("en")["backup"][section][key]) - temporary["storage"].setdefault( - "savedSmartData", catalog("en")["storage"]["savedSmartData"]) - report = temporary["storage"]["smartReport"] - source_report = catalog("en")["storage"]["smartReport"] - for key in ("passedAssessment", "noReallocatedSectorsReported", - "passedMeaningTitle", "passedMeaning"): - report.setdefault(key, source_report[key]) - for key in ("passedTitle", "passedText"): - report["recommendations"].setdefault( - key, source_report["recommendations"][key]) - # New health-popup messages deliberately use runtime English - # fallback until each locale is translated. Model generator - # steady state only in this disposable copy, as for thermal keys. - for key in ( - 'certificateExpired', - 'certificateExpiresDays', - 'certificateInconclusive', - 'cpuCheckFailed', - 'cpuHighSustained', - 'cpuSustained', - 'fail2banCheckUnavailable', - 'fail2banInactive', - 'failedLoginsWithinThreshold', - 'kernelPackagesMore', - 'kernelPveUpdates', - 'loginCheckUnavailable', - 'memoryCheckFailed', - 'memoryPressure', - 'noFailedLogins', - 'noSwapConfigured', - 'noTemperatureSensor', - 'nonRunningKernelUpdates', - 'pendingPackageUpdates', - 'pendingSecurityUpdates', - 'pendingSecurityUpdatesDays', - 'pendingSecurityUpdatesUnpatched', - 'pveVersionAvailable', - 'ramHighSustained', - 'ramSustained', - 'runningKernelUpdate', - 'securityCheckUnavailable', - 'sensorHighSamplesSpan', - 'swapRamPressure', - 'systemNotUpdatedMonths', - 'systemNotUpdatedYear', - 'temperatureElevated', - 'unknownDetail', - 'updatesAptError', - 'updatesAptTimeout', - 'updatesCheckUnavailable', - 'uptimeKernelAdvice', - 'uptimeUnavailable', - ): - temporary["healthStatus"]["details"].setdefault( - key, catalog("en")["healthStatus"]["details"][key]) - # New thermal messages also await automatic translation. - # Seed only these intentional additions in the temporary copy. - for key in ( - "temperatureNoDisks", "temperatureNormal", "temperatureHighCount", - "temperatureCriticalCount", "temperatureUnavailable", - "temperatureIncomplete", "temperatureUnavailableCount", - ): - temporary["storage"].setdefault(key, catalog("en")["storage"][key]) - # Every shipped locale carries the temperature alert fallback - # keys; the seed only guards a catalog that lacks them. The - # runtime fallback is tested separately. - source_fallback = catalog("en")["runtime"]["notifications"]["fallback"] - fallback = temporary["runtime"]["notifications"]["fallback"] - for key in ("temperatureAlertTitle", "temperatureAlertBody", - "recordedReason", "recordedDetails"): - fallback.setdefault(key, source_fallback[key]) - # Slovak remains the exact upstream catalog; model the - # pending outcome-key generator additions in disposable - # copies rather than modifying its curated values. - if lang == 'sk': - local = temporary['runtime']['notifications'] - source = catalog('en')['runtime']['notifications'] - for key, value in source['backup'].items(): - local.setdefault('backup', {}).setdefault(key, value) - local['channels']['email']['status'].setdefault( - 'unconfirmed', source['channels']['email']['status']['unconfirmed']) - local['channels']['email']['status'].setdefault( - 'completed_with_warnings', source['channels']['email']['status']['completed_with_warnings']) - path.write_text(json.dumps(temporary, ensure_ascii=False)) - # Model steady state after the bot fills these intentional new - # messages; keep repository locales and all other leaves intact. - if lang != "en": - messages = json.loads(path.read_text()) - english = catalog("en") - for section, key in (("encryption", "pbsHelp"), - ("keyfileActions", "pveKeyDescription")): - messages["backup"][section].setdefault(key, english["backup"][section][key]) - for key in ("sshUserHelpMessage", "sshKeyHelpMessage", "sshKeySetupTitle", - "sshKeySetupHelp", "sshAuthorizedKeyHelp"): - messages["backup"]["destinations"].setdefault( - key, english["backup"]["destinations"][key]) - messages["backup"]["actions"].setdefault( - "prepareSshKey", english["backup"]["actions"]["prepareSshKey"]) - # New LXC observation labels intentionally ship only EN/SK. - # Model the bot's future output in temporary catalogs, just - # like the other pending keys above; never rewrite locales. - messages["scriptTerminal"].setdefault("endOfInput", english["scriptTerminal"]["endOfInput"]) - for key in ("noShell", "enterFailed"): - messages["lxcTerminal"]["errors"].setdefault(key, english["lxcTerminal"]["errors"][key]) - # The notification history panel texts await the bot as well. - for key, value in english["settings"]["notifications"]["history"].items(): - messages["settings"]["notifications"].setdefault("history", {}).setdefault(key, value) - for key in ("checkNow", "checkReadOnlyHelp", "checkCooldown", - "checkStale", "checkStaleHelp", "checkBeforeUpdate", - "startBeforeCheck", "notCheckedYet", - "checkStillRunning", "refreshCheckHelp", - "checkInProgress", "checkSystemPackages", "runCustomCommand", - "installedVersionUnverified", "versionStatusUnknown"): - messages["vmLxc"]["updates"].setdefault( - key, english["vmLxc"]["updates"][key]) - for key in ("startPending", "shutdownPending", "rebootPending", "stopPending"): - messages["vmLxc"]["actions"].setdefault( - key, english["vmLxc"]["actions"][key]) - for key in ("osDockerScope", "dockerImageMethod", "unverifiedTargetsHelp"): - messages["vmLxc"]["bulkUpdate"].setdefault( - key, english["vmLxc"]["bulkUpdate"][key]) - # Guest archive controls use English fallback until the - # translation bot runs. Model only these pending additions - # in the disposable steady-state catalogs, preserving all - # shipped translations and the frozen command arrays. - for key in ("refresh", "refreshFailed", "deleteSelected", "deletedCount", - "unconfirmedCount", "deleteFailed", "selectHint", "selectBackup", - "protected", "confirmTitle", "confirmDescription", "deleting", - "cancel", "confirmDelete"): - messages["vmLxc"]["backups"].setdefault( - key, english["vmLxc"]["backups"][key]) - messages["vmLxc"]["errors"].setdefault( - "controlNotConfirmed", english["vmLxc"]["errors"]["controlNotConfirmed"]) - path.write_text(json.dumps(messages, ensure_ascii=False)) - before = {p: p.read_bytes() for p in root.glob("*/common.json")} + root = Path(directory).resolve() + for path in shipped: + destination = root / path.parent.name / "common.json" + destination.parent.mkdir() + shutil.copyfile(path, destination) + if future_messages is not None: + source = json.loads((root / "en/common.json").read_text()) + source["futureFeatureFixture"] = future_messages + (root / "en/common.json").write_text(json.dumps(source)) + argv = [str(SCRIPT), "--source", str(root / "en/common.json"), "--messages-dir", str(root), "--languages", languages, "--sleep", "0"] output = io.StringIO() + # Establish steady state with the real generator and a deterministic + # offline provider. Translation happens only in disposable catalogs; + # adding an EN-only feature never needs a hand-maintained seed list. + with mock.patch.object(sys, "argv", argv), \ + mock.patch.object(MODULE, "translate_one", side_effect=lambda text, lang, *_: f"TEST {lang} {text}") as seed, \ + mock.patch.object(MODULE, "write_json", wraps=MODULE.write_json) as initial_writer, \ + redirect_stdout(output), redirect_stderr(output): + self.assertEqual(MODULE.main(), 0, output.getvalue()) + self.assertNotRegex(output.getvalue(), r"(?m)^Completed with [0-9]+ translation failures") + self.assertTrue(all(call.args[0].is_relative_to(root) for call in initial_writer.call_args_list)) + for path, original in shipped.items(): + completed = json.loads((root / path.parent.name / "common.json").read_text()) + for keys, value in strings(original): + actual = completed + for key in keys: + actual = actual[key] + self.assertEqual(actual, value, f"{path.parent.name}: {keys}") + # The recovered arrays remain exact, including frozen English. + if path.parent.name != "en": + self.assertEqual(completed["terminal"]["commandDescriptions"], + FIXTURE["locales"].get(path.parent.name, + original["terminal"]["commandDescriptions"])) + if future_messages is not None and path.parent.name != "en": + self.assertEqual(completed["futureFeatureFixture"], { + "title": f"TEST {path.parent.name} Future feature {{count}}", + "items": [f"TEST {path.parent.name} First item", {"label": f"TEST {path.parent.name} Second item"}], + "enabled": False, "count": 2, "empty": [], + }) + if future_messages is not None: + self.assertGreaterEqual(seed.call_count, 3 * len(languages.split(","))) + before = {p: p.read_bytes() for p in root.glob("*/common.json")} + output = io.StringIO() + # Keep the original strict assertion: once completed, any provider + # call or catalog write is a regression. No production locale edits. with mock.patch.object(sys, "argv", argv), \ mock.patch.object(MODULE, "translate_one", side_effect=AssertionError("provider forbidden")) as provider, \ mock.patch.object(MODULE, "write_json", side_effect=AssertionError("writes forbidden")) as writer, \ @@ -260,6 +159,17 @@ class CommandDescriptionsTests(unittest.TestCase): self.assertEqual(before, {p: p.read_bytes() for p in before}) for lang in languages.split(","): self.assertIn(f"{lang}: nothing to translate", output.getvalue()) + self.assertEqual(original_bytes, {path: path.read_bytes() for path in shipped}) + + def test_workflow_default_catalogs_rerun_without_provider_or_writes(self): + self.assert_completed_catalogs_are_stable() + + def test_new_english_feature_preserves_curated_values_and_reruns_without_writes(self): + self.assert_completed_catalogs_are_stable(future_messages={ + "title": "Future feature {count}", + "items": ["First item", {"label": "Second item"}], + "enabled": False, "count": 2, "empty": [], + }) def test_actual_consumer_lookup_null_fallback_and_explicit_english(self): node = require_node() diff --git a/.github/workflows/test-i18n.yml b/.github/workflows/test-i18n.yml index 3a2fc5f0..2b3e4685 100644 --- a/.github/workflows/test-i18n.yml +++ b/.github/workflows/test-i18n.yml @@ -1,4 +1,4 @@ -name: Offline i18n tests +name: Offline script regressions on: pull_request: @@ -42,6 +42,8 @@ permissions: contents: read jobs: + # Preserve this check ID for existing branch protection rules. This suite + # includes menu, download and scheduler regressions as well as i18n tests. offline-i18n: runs-on: ubuntu-latest timeout-minutes: 5 @@ -61,7 +63,7 @@ jobs: test -x /usr/bin/jq - name: Install OCI wording test dependency run: python3 -m pip install 'PyYAML>=6,<7' - - name: Run complete offline script test suite + - name: Run offline i18n and script regressions env: PYTHONDONTWRITEBYTECODE: '1' run: python3 -m unittest discover -s .github/scripts/tests -v diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aab13a74..5e76a028 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -818,7 +818,7 @@ Note that the dev server has no Flask backend on the same host by default: the a ### Testing -#### Offline i18n script tests +#### Offline script regressions (including i18n) From the repository root, run the complete script suite: @@ -836,6 +836,14 @@ It does not launch React or require `npm install`, `pip install`, a Proxmox host API keys, or a translation provider. Generator tests use isolated temporary catalogs and fake/forbidden providers; repository catalogs are not regenerated. +The command-description recovery regression first completes disposable copies +of the shipped catalogs with the real generator and a deterministic offline +provider, then requires a second run to make zero provider calls and zero +writes. It checks that all existing nonempty translations and the frozen +command-description arrays survive unchanged. A synthetic EN-only feature tests +new nested strings, arrays, placeholders and non-text values. New message keys +do not need to be added to a test-specific translation seed list. + Scheduler fixtures discover every shipped `lang/*.json` cache and run the actual `translate` lookup at both empty-job consumers for PBS and local archive backends. They accept English fallback for missing/empty entries; complete translations are @@ -859,6 +867,11 @@ than silently skipping the consumer assertions. `.github/workflows/test-i18n.yml provisions Python and Node and runs this same command on relevant pull requests and main/develop pushes (or manually). It is separate from the translation publication workflow and has read-only repository permissions. +Its Actions name is **Offline script regressions** because it also covers menus, +downloads, scheduler behavior and other scripts. The `offline-i18n` check ID is +retained for compatibility with existing branch protection rules; a failure of +that check is not necessarily a translation failure. Read its failing test name +and traceback before changing catalogs or translation-provider settings. #### Qualified offline `tests/` CI lanes