Check translation contracts offline, add Swedish to the installer and enable the website languages

- Offline checks for terminal and website catalogs reject translations that change commands, paths, identifiers or placeholders
- Terminal and documentation texts that broke those contracts are corrected in every language
- Swedish can be chosen in the stable and beta installers
- The website routes, language switcher and exported pages cover the eight languages, with the shared website texts in each of them
- Website documentation translations are generated only when requested
- From PR #475 by @jcastro
This commit is contained in:
MacRimi
2026-10-18 12:58:25 +02:00
parent f363ef4395
commit f8cabb4700
54 changed files with 2149 additions and 173 deletions
@@ -60,6 +60,124 @@ class WebDocsI18nTests(unittest.TestCase):
target = {"title": "Actualizaciones", "url": "https://example.com"}
self.assertEqual(MODULE.pending_leaves(source, target), [])
def test_rendering_metadata_is_copied_but_visible_labels_are_translated(self):
for key, value in (("tone", "amber"), ("variant", "source"),
("accent", "border-blue-300 bg-blue-50"),
("iconBg", "bg-blue-100 text-blue-700"),
("color", "green")):
with self.subTest(key=key):
self.assertTrue(MODULE.should_copy(value, ("steps", 0, key)))
self.assertFalse(MODULE.should_copy(value, ("label",)))
self.assertEqual(MODULE.merge_tree({key: value}, {key: "translated"}, {}), {key: value})
self.assertFalse(MODULE.should_copy("Translation", ("type",)))
self.assertFalse(MODULE.should_copy("English", ("lang",)))
for value in ("loader", "dedicated"):
self.assertTrue(MODULE.should_copy(value, ("cards", 0, "flow")))
self.assertFalse(MODULE.should_copy(value, ("label",)))
def test_literal_only_examples_and_api_routes_are_not_translation_gaps(self):
for source in ("<code>apt update</code>",
"<code>net0</code> / <code>net1</code>.",
"/api/task-log/&lt;upid&gt;", "/api/vms/<vmid>/config"):
with self.subTest(source=source):
self.assertTrue(MODULE.should_copy(source, ("cell",)))
self.assertFalse(MODULE.should_copy("Run <code>apt update</code> first.", ("body",)))
self.assertFalse(MODULE.should_copy("After running the command, check its output.", ("afterCode",)))
self.assertFalse(MODULE.should_copy("Before running the command, read this.", ("beforeCode",)))
self.assertTrue(MODULE.should_copy("curl http://localhost:8008/api/health", ("curlCode",)))
def test_inline_identifiers_and_encoded_delimiters_round_trip(self):
source = "Set RS_SKIP_PATHS, call _rs_apply_hot and keep localStorage at &lt;host&gt;."
protected, mapping = MODULE.protect_text(source)
for literal in ("RS_SKIP_PATHS", "_rs_apply_hot", "localStorage", "&lt;", "&gt;"):
self.assertIn(literal, mapping.values())
self.assertNotIn(literal, protected)
self.assertEqual(MODULE.restore_text(protected, mapping), source)
def test_custom_code_spans_keep_commands_literal(self):
for tag in ("codeNw", "codeXs"):
source = f"Run <{tag}>pct config 100</{tag}>."
with self.subTest(tag=tag):
protected, mapping = MODULE.protect_rich_tags(source)
self.assertEqual(protected, source)
self.assertEqual(mapping, {})
with self.assertRaisesRegex(ValueError, "literal code changed"):
MODULE.validate_contract(source, source.replace("config", "configuration"))
with self.assertRaisesRegex(ValueError, "numeric residue"):
MODULE.validate_contract(source, source.replace(f"</{tag}>", f"</{tag}>0"))
self.assertTrue(MODULE.should_copy(f"<{tag}>pct config 100</{tag}>", ("example",)))
def test_navigation_identifiers_are_distinct_from_visible_labels(self):
values = {
"categorySlug": ["system", "network"],
"menuCmd": ["menu"],
"branch": ["main", "develop", "feature/*"],
"kind": ["internal", "external"],
"key": ["nas", "linux", "windows"],
}
for key, items in values.items():
for value in items:
with self.subTest(key=key, value=value):
self.assertTrue(MODULE.should_copy(value, ("cards", 0, key)))
self.assertFalse(MODULE.should_copy(value, ("label",)))
self.assertFalse(MODULE.should_copy("Translation", ("kind",)))
self.assertFalse(MODULE.should_copy("Storage", ("branch",)))
def test_ui_breadcrumb_is_prose_while_paths_remain_literal(self):
source = "In the UI: <strong>Datacenter → Backup → Retention</strong>:"
self.assertFalse(MODULE.should_copy(source, ("retention", "uiPath")))
self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("uiPath",)))
self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("configPath",)))
def test_bare_sentinel_with_added_digit_is_rejected(self):
with mock.patch.object(MODULE, "BARE_SENTINELS", True):
protected, mapping = MODULE.protect_text("ProxMenux collects metrics.")
token = next(iter(mapping))
with self.assertRaisesRegex(ValueError, "changed protected token"):
MODULE.restore_text(protected.replace(token, token + "0"), mapping)
self.assertEqual(MODULE.restore_text(protected, mapping), "ProxMenux collects metrics.")
def test_numeric_residue_outside_intact_literal_is_rejected(self):
examples = [
('Run <code>pct config</code> first.', 'Spustite <code>pct config</code>0 najprv.'),
('<strong><code>Restart=on-failure</code></strong> restarts the service.',
'<strong>0<code>Restart=on-failure</code></strong> reštartuje službu.'),
]
for source, target in examples:
with self.subTest(source=source):
with self.assertRaisesRegex(ValueError, 'numeric residue'):
MODULE.validate_contract(source, target)
MODULE.validate_contract('The result is <code>echo</code>0.',
'Výsledok je <code>echo</code>0.')
def test_product_and_kernel_module_names_are_protected(self):
source = "Flask uses SQLite and journald; rclone works with Coral, gasket, apex and Alpine."
protected, mapping = MODULE.protect_text(source)
for name in ("Flask", "SQLite", "journald", "rclone", "Coral", "gasket", "apex", "Alpine"):
self.assertIn(name, mapping.values())
self.assertNotIn(name, protected)
self.assertEqual(MODULE.restore_text(protected, mapping), source)
def test_literal_only_changes_sync_without_retranslating_human_prose(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "messages/en/common.json"
target = root / "messages/de/common.json"
source.parent.mkdir(parents=True)
target.parent.mkdir(parents=True)
source.write_text(json.dumps({"title": "Updates", "code": "tool --version=2", "tone": "blue"}))
target.write_text(json.dumps({"title": "Aktualisierungen", "code": "tool --version=1", "tone": "blau"}))
provider = mock.Mock(side_effect=AssertionError("Existing prose must not be retranslated"))
self.assertEqual(self.run_generator(root, provider), 0)
self.assertEqual(json.loads(target.read_text()), {
"title": "Aktualisierungen", "code": "tool --version=2", "tone": "blue",
})
provider.assert_not_called()
before = target.stat().st_mtime_ns
self.assertEqual(self.run_generator(root, provider), 0)
self.assertEqual(target.stat().st_mtime_ns, before)
provider.assert_not_called()
def test_changed_source_text_is_pending_even_with_existing_translation(self):
source = {"title": "Updated installation guidance"}
target = {"title": "Vorherige Installationsanleitung"}
@@ -0,0 +1,30 @@
"""Keep machine generation explicit without losing automatic validation."""
from pathlib import Path
import unittest
import yaml
ROOT = Path(__file__).resolve().parents[3]
class DocumentationReviewWorkflowTests(unittest.TestCase):
def workflow(self, name):
return yaml.load(
(ROOT / '.github/workflows' / name).read_text(encoding='utf-8'),
Loader=yaml.BaseLoader,
)
def test_generation_requires_explicit_dispatch(self):
triggers = self.workflow('build-web-docs-i18n.yml')['on']
self.assertEqual(set(triggers), {'workflow_dispatch'})
def test_website_changes_keep_automatic_offline_validation(self):
triggers = self.workflow('test-i18n.yml')['on']
for event in ('pull_request', 'push'):
with self.subTest(event=event):
self.assertIn('web/**', triggers[event]['paths'])
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,30 @@
"""Every shipped terminal language must be selectable during installation."""
from pathlib import Path
import re
import sys
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
ROOT = SCRIPTS.parents[1]
sys.path.insert(0, str(SCRIPTS))
from build_translation_cache import DEFAULT_LANGUAGES
class InstallerLanguageTests(unittest.TestCase):
def test_both_installers_offer_every_shipped_language(self):
expected = {'en', *DEFAULT_LANGUAGES}
for name in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
with self.subTest(installer=name):
source = (ROOT / name).read_text(encoding='utf-8')
function = re.search(r'^select_language\(\) \{(.*?)^\}', source, re.M | re.S)
self.assertIsNotNone(function, 'Missing installer language selector')
offered = re.findall(r'^\s*"([a-z]{2})"\s+"[^"]+"', function.group(1), re.M)
self.assertEqual(set(offered), expected)
self.assertEqual(len(offered), len(expected), 'Duplicate language option')
# English is the source text in the scripts, not a JSON catalog.
for language in set(offered) - {'en'}:
self.assertTrue((ROOT / 'lang' / f'{language}.json').is_file(), language)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,82 @@
"""Offline regressions for copyable commands and runtime format fields."""
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
ROOT = SCRIPTS.parents[1]
sys.path.insert(0, str(SCRIPTS))
SPEC = importlib.util.spec_from_file_location('check_terminal_catalogs', SCRIPTS / 'check_terminal_catalogs.py')
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC and SPEC.loader
SPEC.loader.exec_module(MODULE)
class TerminalCatalogsTests(unittest.TestCase):
def test_extractor_includes_launchers_remote_scripts_and_catalog_text(self):
fixtures = {
'menu': 'translate "Main launcher"',
'install_proxmenux.sh': 'translate "Stable installer"',
'install_proxmenux_beta.sh': 'translate "Beta installer"',
'scripts/check.sh': 'translate "Host shell script"',
'scripts/check.py': 'translate("Host Python script")',
'oci/src/check.py': 'N_("OCI Python script")',
'oci/remote/check.py': 'translate("Remote Python script")',
'oci/remote/check.sh': 'translate "Remote shell script"',
'oci/catalog/app.json': json.dumps({'catalog_ui': {'tagline': {'en_US': 'Application tagline'}}}),
}
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for name, content in fixtures.items():
file = root / name
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(content, encoding='utf-8')
self.assertEqual(set(MODULE.active_sources(root)), {
'Main launcher', 'Stable installer', 'Beta installer',
'Host shell script', 'Host Python script', 'OCI Python script',
'Remote Python script', 'Remote shell script', 'Application tagline',
})
def test_runtime_format_field_cannot_be_translated(self):
source = 'Kimai has no default account. Enter the container with: pct enter {main_vmid}'
with self.assertRaisesRegex(ValueError, 'placeholder'):
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main vmid}')
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main_vmid}')
def test_copyable_command_and_environment_variable_are_literal(self):
examples = [
('Example: pct config 110 | grep unprivileged', 'Ejemplo: configuración pct 110 | grep sin privilegios'),
("Use 'pct restore' to recover the disks.", "Verwenden Sie 'pct restart', um die Datenträger wiederherzustellen."),
('Invalid ALLOWED_HOSTS value', 'Valeur HÔTES INDÉSIRABLES'),
]
for source, target in examples:
with self.subTest(source=source):
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
MODULE.validate_message(source, target)
MODULE.validate_message('Example: pct config 110 | grep unprivileged',
'Ejemplo: pct config 110 | grep unprivileged')
def test_dns_capitalization_does_not_relax_file_path_case(self):
MODULE.validate_message('Cannot reach download.proxmox.com.',
'Download.proxmox.com ist nicht erreichbar.')
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
MODULE.validate_message('Check /etc/pve/storage.cfg', 'Prüfen Sie /etc/PVE/storage.cfg')
def test_shipped_active_catalogs_keep_literal_contracts(self):
result = subprocess.run(
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
'--root', str(ROOT), '--require-complete'],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
for locale in MODULE.DEFAULT_LANGUAGES:
self.assertIn(f'[{locale}] active=', result.stdout)
self.assertNotIn('missing=1', result.stdout)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,94 @@
"""Validate complete catalogs without rejecting intentional English fallback."""
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / "check_web_docs_catalogs.py"
class WebDocsCatalogTests(unittest.TestCase):
def run_check(self, root, *options):
return subprocess.run(
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(root),
"--languages", "de", *options],
capture_output=True, text=True,
)
def write_catalog(self, root, locale, content):
file = root / locale / "common.json"
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(json.dumps(content), encoding="utf-8")
def test_partial_catalog_keeps_fallback_but_complete_mode_reports_gap(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"title": "Updates", "body": "Check the host."})
self.write_catalog(root, "de", {"title": "Aktualisierungen"})
partial = self.run_check(root)
self.assertEqual(partial.returncode, 0, partial.stdout + partial.stderr)
self.assertIn("missing=1", partial.stdout)
complete = self.run_check(root, "--require-complete")
self.assertEqual(complete.returncode, 1, complete.stdout + complete.stderr)
self.assertIn("schema differs", complete.stdout)
def test_translated_code_is_rejected_in_both_modes(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"body": "Run <code>apt update</code>."})
self.write_catalog(root, "de", {"body": "Führe <code>apt aktualisieren</code> aus."})
for options in ((), ("--require-complete",)):
with self.subTest(options=options):
result = self.run_check(root, *options)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
def test_bare_commands_and_operators_are_not_prose(self):
examples = [
("Restore with pct restore.", "Mit pct restart wiederherstellen."),
("pveversion | grep pve-manager", "pveversion grep pve-manager"),
("cd /opt/my-app && ./update.sh", "cd /opt/meine-app && ./update.sh"),
]
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for source, target in examples:
with self.subTest(source=source):
self.write_catalog(root, "en", {"body": source})
self.write_catalog(root, "de", {"body": target})
result = self.run_check(root)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("literal command or identifier", result.stdout)
def test_title_separator_can_change_without_relaxing_body_pipes(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"meta": {"title": "Storage | ProxMenux"}})
self.write_catalog(root, "de", {"meta": {"title": "Speicher — ProxMenux"}})
result = self.run_check(root, "--require-complete")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.write_catalog(root, "en", {"meta": {"description": "pveversion | grep pve-manager"}})
self.write_catalog(root, "de", {"meta": {"description": "pveversion grep pve-manager"}})
result = self.run_check(root)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
def test_shipped_web_catalogs_keep_literal_contracts(self):
messages = SCRIPT.parents[2] / "web" / "messages"
result = subprocess.run(
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(messages)],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_literal_metadata_is_required_but_labels_can_be_localized(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"label": "Updates", "tone": "blue"})
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blau"})
invalid = self.run_check(root, "--require-complete")
self.assertEqual(invalid.returncode, 1, invalid.stdout + invalid.stderr)
self.assertIn("rendering metadata differ", invalid.stdout)
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blue"})
valid = self.run_check(root, "--require-complete")
self.assertEqual(valid.returncode, 0, valid.stdout + valid.stderr)
@@ -0,0 +1,41 @@
"""Exercise the static-export language repair on real HTML files."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_command_descriptions import require_node
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = ROOT / "web/scripts/localize-export.mjs"
class WebExportLanguageTests(unittest.TestCase):
def test_locales_nested_pages_preserved_content_and_idempotence(self):
node = require_node()
html = '<!doctype html><html lang="en" class="dark"><body><h1>Text</h1><script>const example=\'<html lang="en">\';</script></body></html>'
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
locales = ["en", "es", "de", "fr", "it", "pt", "sk", "sv"]
for locale in locales:
file = root / "out" / locale / "docs/monitor/index.html"
file.parent.mkdir(parents=True)
file.write_text(html)
for relative in ["out/index.html", "out/assets/index.html"]:
file = root / relative
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(html)
result = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("8 localized pages", result.stdout)
for locale in locales:
actual = (root / "out" / locale / "docs/monitor/index.html").read_text()
self.assertEqual(actual, html.replace('<html lang="en"', f'<html lang="{locale}"', 1))
for relative in ["out/index.html", "out/assets/index.html"]:
self.assertEqual((root / relative).read_text(), html)
tracked = root / "out/de/docs/monitor/index.html"
before = tracked.stat().st_mtime_ns
second = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
self.assertEqual(second.returncode, 0, second.stderr)
self.assertEqual(tracked.stat().st_mtime_ns, before)