Merge develop: combine the i18n test fixture with #363

Both message batches seeded locale copies at the same point of
test_command_descriptions.py. The two blocks are independent — one
seeds the backup messages, the other the storage ones — so they are
merged into a single block that reads and writes the fixture once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-09-22 16:46:04 +02:00
co-authored by Claude Opus 5
23 changed files with 1206 additions and 64 deletions
+91
View File
@@ -0,0 +1,91 @@
// Offline JSX seam, following the TypeScript compiler harness in adjacent tests.
// No component imports, React effects, API calls or host-management execution.
// Run from the repository root: node tests/test_backup_archives_empty.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const app = path.resolve(__dirname, '../AppImage');
const ts = createRequire(path.join(app, 'package.json'))('typescript');
const read = file => fs.readFileSync(path.join(app, file), 'utf8');
const parse = file => ts.createSourceFile(file, read(file), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const compile = text => ts.transpileModule(text, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.React },
}).outputText;
const printer = ts.createPrinter();
const provider = parse('lib/i18n/provider.tsx');
const pure = provider.statements.filter(node => ts.isFunctionDeclaration(node) &&
['getMessage', 'interpolate'].includes(node.name?.text));
assert.equal(pure.length, 2);
let lookup;
function visitLookup(node) {
if (ts.isVariableDeclaration(node) && node.name.getText(provider) === 't' &&
node.initializer && ts.isCallExpression(node.initializer) &&
node.initializer.expression.getText(provider) === 'useCallback') lookup = node.initializer.arguments[0];
ts.forEachChild(node, visitLookup);
}
visitLookup(provider);
assert.ok(lookup, 'real provider lookup callback');
const lookupCode = pure.map(node => printer.printNode(ts.EmitHint.Unspecified, node, provider)).join('\n') +
'\nconst t = ' + printer.printNode(ts.EmitHint.Expression, lookup, provider) + ';';
const catalogs = Object.fromEntries(fs.readdirSync(path.join(app, 'messages')).filter(locale =>
fs.existsSync(path.join(app, 'messages', locale, 'common.json'))).map(locale =>
[locale, JSON.parse(read(`messages/${locale}/common.json`))]));
// Compile only trusted, checked-out source; no catalog/input strings enter code.
const translator = (language, messages = catalogs) =>
new Function('MESSAGE_CATALOG', 'language', compile(lookupCode) + '\nreturn t;')(messages, language);
const source = parse('components/host-backup.tsx');
let branch;
function visitBranch(node) {
if (ts.isConditionalExpression(node) && node.condition.getText(source) === 'archivesErr && remoteArchivesErr') branch = node;
ts.forEachChild(node, visitBranch);
}
visitBranch(source);
assert.ok(branch, 'real archives loading/error/empty decision');
// Keep the actual guards and JSX messages. Only replace the nonempty archive
// list with a sentinel: rendering its controls is outside this message test.
const transformed = ts.transform(branch, [context => root => {
const visit = node => {
if (ts.isConditionalExpression(node) && node.condition.getText(source) === 'unifiedArchives.length === 0') {
return ts.factory.updateConditionalExpression(node, node.condition, node.questionToken,
node.whenTrue, node.colonToken, ts.factory.createStringLiteral('NONEMPTY_ARCHIVES'));
}
return ts.visitEachChild(node, visit, context);
};
return ts.visitNode(root, visit);
}]);
const expression = printer.printNode(ts.EmitHint.Expression, transformed.transformed[0], source);
transformed.dispose();
const render = new Function('React', 'Loader2', 't', 'archivesErr', 'remoteArchivesErr',
'archivesResp', 'remoteArchivesResp', 'unifiedArchives', compile(`const result = ${expression};`) + '\nreturn result;');
// Text-only JSX sink: TypeScript performs the real JSX whitespace conversion.
const React = { createElement: (tag, props, ...children) => children.flat(Infinity).filter(x => x != null && x !== false).join('') };
const text = (locale = 'en', overrides = {}, messages = catalogs) => {
const state = { archivesErr: null, remoteArchivesErr: null, archivesResp: { archives: [] },
remoteArchivesResp: { snapshots: [] }, unifiedArchives: [], ...overrides };
return render(React, 'spinner', translator(locale, messages), state.archivesErr, state.remoteArchivesErr,
state.archivesResp, state.remoteArchivesResp, state.unifiedArchives);
};
assert.equal(text(), 'No backup archives found.');
assert.equal(text('it'), 'No backup archives found.', 'Italian catalog uses English fallback');
// Keep fallback coverage even after all shipped locales gain the new key.
assert.equal(text('missing', {}, { ...catalogs, missing: {} }), 'No backup archives found.');
for (const locale of Object.keys(catalogs)) {
// Existing catalogs intentionally have no new key; actual provider falls back.
if (catalogs[locale].backup?.archives?.emptyMessage === undefined) {
assert.equal(text(locale), 'No backup archives found.', `${locale}: missing-key fallback`);
}
}
for (const count of [1, 3]) assert.equal(text('en', { unifiedArchives: Array(count).fill({}) }), 'NONEMPTY_ARCHIVES');
assert.equal(text('en', { archivesResp: undefined, remoteArchivesResp: undefined }), translator('en')('backup.common.loading'));
assert.equal(text('en', { archivesErr: true, remoteArchivesErr: true }), translator('en')('backup.archives.loadFailed'));
// Preserve existing partial-source semantics; no claim that every source was queried.
assert.equal(text('en', { archivesErr: true, archivesResp: undefined }), 'No backup archives found.');
assert.equal(text('en', { remoteArchivesErr: true, remoteArchivesResp: undefined }), 'No backup archives found.');
assert.equal(translator('en')('backup.manual.run'), 'Run');
assert.ok(read('components/host-backup.tsx').includes('{t("backup.manual.run")}'), 'manual action remains');
console.log('PASS: actual archives JSX seam, zero/one/multiple, loading/errors, locale fallback, Run unchanged');
// Export only test seams for separate, unapplied linguistic proposal checks.
module.exports = { text, translator, catalogs };
@@ -0,0 +1,56 @@
// Offline actual JSX/provider seams; no component/backend imports or effects.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const { translator, catalogs } = require('./test_backup_archives_empty.cjs');
const app = path.resolve(__dirname, '../AppImage');
const ts = createRequire(path.join(app, 'package.json'))('typescript');
const source = ts.createSourceFile('host-backup.tsx', fs.readFileSync(path.join(app, 'components/host-backup.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const printer = ts.createPrinter();
const React = { createElement: (tag, props, ...children) => children.flat(Infinity).filter(x => x != null && x !== false).join('') };
function find(predicate) {
const found = [];
function visit(node) { if (predicate(node)) found.push(node); ts.forEachChild(node, visit); }
visit(source);
assert.equal(found.length, 1, 'unique actual message seam');
return found[0];
}
function render(node, locale, backups = 1, messages = catalogs) {
const code = ts.transpileModule(`const result = ${printer.printNode(ts.EmitHint.Expression, node, source)};`, {
compilerOptions: { target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.React },
}).outputText;
// Only trusted checked-out AST enters executable code, never catalog values.
return new Function('React', 't', 'backups', code + '\nreturn result;')(React, translator(locale, messages), backups);
}
const kept = find(node => ts.isBinaryExpression(node) && node.left.getText(source) === 'backups > 0');
const keptMessage = 'Existing backup archives are not deleted.';
for (const count of [1, 3]) assert.equal(render(kept, 'it', count), keptMessage, 'Italian catalog uses English fallback');
assert.equal(render(kept, 'en', 0), false);
for (const locale of Object.keys(catalogs)) {
if (catalogs[locale].backup?.destinations?.backupsKeptMessage !== undefined && locale !== 'en') continue;
for (const count of [1, 3]) assert.equal(render(kept, locale, count), keptMessage, `${locale}: kept whole-message fallback`);
}
console.log('PASS: destination removal JSX guard 0/1/N and whole-message locale fallback');
// Select the actual help paragraph, keeping its literal paths/JSX whitespace.
const help = find(node => ts.isJsxElement(node) && node.openingElement.tagName.getText(source) === 'p' &&
/backup\.destinations\.localAttachHelp(?:Before|Message)/.test(node.getText(source)));
const helpMessage = 'Mount USB drives first. Leave blank to use the PVE storage path + /dump (fallback: /var/lib/vz/dump).';
assert.equal(render(help, 'it'), helpMessage, 'Italian catalog uses English fallback');
for (const locale of Object.keys(catalogs)) {
if (catalogs[locale].backup?.destinations?.localAttachHelpMessage !== undefined && locale !== 'en') continue;
assert.equal(render(help, locale), helpMessage, `${locale}: local attach help fallback`);
}
// Missing-key fallback must remain covered after shipped catalogs are complete.
const missing = { ...catalogs, missing: {} };
assert.equal(render(kept, 'missing', 1, missing), keptMessage);
assert.equal(render(help, 'missing', 1, missing), helpMessage);
// Synthetic locale proves complete messages are translatable, not hardcoded.
const synthetic = { ...catalogs, fixture: { backup: { destinations: {
backupsKeptMessage: 'Fixture kept.', localAttachHelpMessage: 'Fixture help.',
} } } };
assert.equal(render(kept, 'fixture', 1, synthetic), 'Fixture kept.');
assert.equal(render(help, 'fixture', 1, synthetic), 'Fixture help.');
assert.equal(catalogs.en.backup.destinations.kept, 'kept');
assert.equal(catalogs.en.backup.destinations.localAttachHelpMiddle, 'USB drives');
console.log('PASS: local attach JSX whole-message fallback and literal paths');
+206
View File
@@ -0,0 +1,206 @@
"""Offline message seams: no full administrative script is sourced or run.
Run: python3 -m unittest discover -s tests -p test_tui_count_messages.py -v
Requires Bash and jq. Only extracted translation functions and bounded message
consumers execute. PATH contains jq only; VM/disk/backup commands are unavailable.
"""
import importlib.util
import json
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
VM = "scripts/storage/add_controller_nvme_vm.sh"
BACKUP = "scripts/backup_restore/backup_host.sh"
def between(path, start, end):
text = (ROOT / path).read_text()
assert text.count(start) == 1, (path, start)
tail = text.split(start, 1)[1]
assert end in tail, (path, end)
return start + tail.split(end, 1)[0]
def function(path, name):
# These two small functions have no unindented nested closing braces.
text = (ROOT / path).read_text()
match = re.search(r"^" + re.escape(name) + r"\(\) \{\n.*?^\}", text, re.M | re.S)
assert match, (path, name)
return match[0]
def lookup(catalog, text):
return (catalog or {}).get(text) or text
class CountMessages(unittest.TestCase):
def locales(self, key, translated):
return (("en", None), ("it", {}), ("zz", None),
("it", json.loads((ROOT / "lang/it.json").read_text())),
("xx", {key: translated}),
# Presence is the bounded contract: repeated tokens are replaced
# everywhere; unrelated braces remain literal, not a format DSL.
("repeat", {key: translated + " / again " + key}),
("extra", {key: translated + " / {unknown} $(literal) %s"})
) + self.malformed_locales(key)
def malformed_locales(self, key):
required = ("{count}", "{vmid}") if "{vmid}" in key else ("{count}",)
# These cached values must fall back as a whole, not lose a value.
malformed = ["Translated without values."]
for token in required:
malformed.extend((key.replace(token, ""),
key.replace(token, "{renamed}"),
key.replace(token, token.upper())))
return tuple(("bad", {key: text}) for text in malformed)
def test_new_messages_are_discovered_by_cache_builder(self):
spec = importlib.util.spec_from_file_location(
"translation_cache", ROOT / ".github/scripts/build_translation_cache.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
keys = module.extract_translate_texts(ROOT / "scripts")
for key in ("Completed. Devices added to VM {vmid}: {count}.",
"Custom paths currently saved: {count}.", "Packages installed: {count}."):
self.assertIn(key, keys)
def render(self, seam, setup, language="en", catalog=None):
jq = shutil.which("jq")
self.assertIsNotNone(jq, "jq is required for actual translation lookup")
with tempfile.TemporaryDirectory(prefix="pmx-count-messages-") as tmp:
tmp = Path(tmp)
(tmp / "bin").mkdir()
(tmp / "bin" / "jq").symlink_to(jq)
(tmp / "lang").mkdir()
if catalog is not None:
(tmp / "lang" / f"{language}.json").write_text(json.dumps(catalog))
shell = function("scripts/utils.sh", "translate") + "\n"
shell += function("scripts/backup_restore/lib_host_backup_common.sh", "hb_translate")
shell += '''
msg_ok() { printf 'OK:%s\\n' "$1"; }
msg_warn() { printf 'WARN:%s\\n' "$1"; }
consumer() {
'''
shell += setup + "\n" + seam + "\n}\nconsumer\n"
env = {"PATH": str(tmp / "bin"), "LANGUAGE": language,
"LANG_DIR": str(tmp / "lang"), "LC_ALL": "C"}
result = subprocess.run(["/bin/bash", "--noprofile", "--norc", "-c", shell],
env=env, text=True, capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, "")
return result.stdout.rstrip("\n")
def test_vm_devices_zero_one_many_and_translation(self):
seam = between(VM, ' if [[ "$assigned_count" -gt 0 ]]; then',
' if [[ "${IOMMU_ALREADY_ACTIVE:-0}" == "1" ]]; then')
key = "Completed. Devices added to VM {vmid}: {count}."
translated = "Devices: {count} / VM {vmid} / done 100%."
for count in (0, 1, 3):
for vmid in (101, 987654):
for language, catalog in self.locales(key, translated):
with self.subTest(count=count, vmid=vmid, language=language):
actual = self.render(seam, f"assigned_count={count}; SELECTED_VMID={vmid}",
language, catalog)
if count == 0:
expected = "WARN:" + lookup(catalog, "No new Controller/NVMe entries were added.")
elif language == "xx":
expected = f"OK:Devices: {count} / VM {vmid} / done 100%."
elif language == "bad":
expected = f"OK:Completed. Devices added to VM {vmid}: {count}."
else:
expected = "OK:" + lookup(catalog, key).replace("{vmid}", str(vmid)).replace("{count}", str(count))
self.assertEqual(actual, expected)
def test_vm_summary_counts_successful_new_assignments_only(self):
seam = between(VM, ' local pci bdf assigned_count=0',
' if [[ "${IOMMU_ALREADY_ACTIVE:-0}" == "1" ]]; then')
for devices, expected_count in (([], 0), (["ok1"], 1),
(["ok1", "ok2", "ok3"], 3),
(["existing", "failed"], 0),
(["existing", "ok1", "failed", "ok2"], 2)):
with self.subTest(devices=devices):
setup = 'SELECTED_CONTROLLER_PCIS=(' + ' '.join(devices) + ')\n'
setup += '''SELECTED_VMID=101; hostpci_idx=0; LOG_FILE="$LANG_DIR/vm.log"
msg_info() { :; }
msg_error() { printf 'ERROR:%s\\n' "$1"; }
_pci_function_assigned_to_vm() { [[ "$1" == existing ]]; }
_pci_storage_display_name() { printf '%s' "$1"; }
qm() {
[[ "$1 $2" == 'set 101' && "$3" == "--hostpci${hostpci_idx}" ]] || return 99
[[ "$4" != 'failed,pcie=1' ]]
}
'''
actual = self.render(seam, setup).splitlines()[-1]
expected = (f"OK:Completed. Devices added to VM 101: {expected_count}." if expected_count
else "WARN:No new Controller/NVMe entries were added.")
self.assertEqual(actual, expected)
def test_custom_paths_zero_one_many_and_translation(self):
seam = between(BACKUP, ' local -a paths=()', ' local choice')
seam += '\n printf "%s" "$preview"\n'
key = "Custom paths currently saved: {count}."
translated = "Saved 100%: {count} custom paths."
suffix = "Custom paths are included in BOTH default and custom backup profiles."
for count in (0, 1, 3):
setup = 'hb_load_extra_paths() {\n'
setup += ''.join(f"printf '%s\\n' '/fixture/path {i}'\n" for i in range(count))
setup += ':;\n}\n'
for language, catalog in self.locales(key, translated):
with self.subTest(count=count, language=language):
actual = self.render(seam, setup, language, catalog)
colored_count = rf"\Zb\Z4{count}\Zn"
if count == 0:
expected = lookup(catalog, "You haven't added any custom paths yet.")
elif language == "xx":
expected = f"Saved 100%: {colored_count} custom paths."
elif language == "bad":
expected = f"Custom paths currently saved: {colored_count}."
else:
expected = lookup(catalog, key).replace("{count}", colored_count)
self.assertEqual(actual, expected + "\n\n" + lookup(catalog, suffix))
def test_installed_packages_zero_one_many_success_and_failure(self):
# Keep the actual nonempty guard and apt return-code consumer; omit
# unrelated preview/cache/log setup. apt-get is a function stub only.
guard = ' if (( ${#installable[@]} > 0 )); then\n'
self.assertEqual((ROOT / BACKUP).read_text().count(guard), 1)
seam = guard + between(BACKUP, ' DEBIAN_FRONTEND=noninteractive \\\n',
' if (( ${#unknown[@]} > 0 )); then')
key = "Packages installed: {count}."
translated = "Done 100% / {count} installed packages."
for count in (0, 1, 3):
for status in (0, 100):
for language, catalog in self.locales(key, translated):
with self.subTest(count=count, status=status, language=language):
setup = f'installable=({" ".join("pkg" + str(i) for i in range(count))}); apt_status={status}\n'
setup += '''apt_log="$LANG_DIR/apt.log"
apt-get() {
[[ "$DEBIAN_FRONTEND" == noninteractive ]] || return 99
[[ "$1 $2 $3 $4 $5 $6" == 'install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold' ]] || return 99
shift 6
[[ "$*" == "${installable[*]}" ]] || return 99
printf 'CALLED\\n' > "$LANG_DIR/calls"
return "$apt_status"
}
'''
check_call = '\nif [[ -f "$LANG_DIR/calls" ]]; then printf "CALLED\\n"; fi\n'
actual = self.render(seam + check_call, setup, language, catalog)
if count == 0:
self.assertEqual(actual, "")
elif status:
prefix = "WARN:" + lookup(catalog, "apt-get exited") + " 100 — " + lookup(catalog, "see log:")
self.assertRegex(actual, "^" + re.escape(prefix) + r" .*/lang/apt.log\nCALLED$")
else:
message = (f"Done 100% / {count} installed packages." if language == "xx"
else f"Packages installed: {count}." if language == "bad"
else lookup(catalog, key).replace("{count}", str(count)))
self.assertEqual(actual, f"OK:{message}\nCALLED")
if __name__ == "__main__":
unittest.main()