mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-07 05:56:40 +00:00
test: align Audit comparison expectations and backup i18n fixtures
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
// Test-only provider seam shared by independent backup message programs.
|
||||||
|
// Compile only checked-out provider source; catalog values are never executable code.
|
||||||
|
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 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 = ts.createSourceFile('provider.tsx', read('lib/i18n/provider.tsx'), ts.ScriptTarget.Latest, true, ts.ScriptKind.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`))]));
|
||||||
|
const registered = provider.statements.flatMap(statement => ts.isVariableStatement(statement) ?
|
||||||
|
statement.declarationList.declarations.filter(declaration => declaration.name.getText(provider) === 'MESSAGE_CATALOG') : []);
|
||||||
|
assert.equal(registered.length, 1, 'real provider catalog declaration');
|
||||||
|
assert.deepEqual(registered[0].initializer.properties.map(property => property.name.getText(provider)).sort(),
|
||||||
|
Object.keys(catalogs).sort(), 'test each runtime-registered shipped locale');
|
||||||
|
assert.ok(catalogs.en, 'English fallback catalog exists');
|
||||||
|
const translator = (language, messages = catalogs) =>
|
||||||
|
new Function('MESSAGE_CATALOG', 'language', compile(lookupCode) + '\nreturn t;')(messages, language);
|
||||||
|
module.exports = { app, ts, read, compile, printer, catalogs, translator };
|
||||||
@@ -395,8 +395,38 @@ class StoreTests(unittest.TestCase):
|
|||||||
critical = self.finding()
|
critical = self.finding()
|
||||||
critical.update(classification='critical', raw_classification='critical')
|
critical.update(classification='critical', raw_classification='critical')
|
||||||
changed = self.recorded(critical)
|
changed = self.recorded(critical)
|
||||||
self.assertEqual(len(engine.compare_runs(before, changed)['new']), 1)
|
worse = engine.compare_runs(before, changed)
|
||||||
self.assertEqual(len(engine.compare_runs(changed, before)['new']), 1)
|
self.assertEqual(worse['new'], [])
|
||||||
|
self.assertEqual(worse['resolved'], [])
|
||||||
|
self.assertEqual(len(worse['worse']), 1)
|
||||||
|
self.assertEqual(worse['worse'][0]['classification'], 'critical')
|
||||||
|
self.assertEqual(worse['worse'][0]['previous_classification'], 'warning')
|
||||||
|
self.assertEqual(worse['worse'][0]['previous_affected'], 1)
|
||||||
|
self.assertEqual(worse['worse'][0]['affected_count'], 1)
|
||||||
|
self.assertEqual(worse['better'], [])
|
||||||
|
better = engine.compare_runs(changed, before)
|
||||||
|
self.assertEqual(better['new'], [])
|
||||||
|
self.assertEqual(better['resolved'], [])
|
||||||
|
self.assertEqual(len(better['better']), 1)
|
||||||
|
self.assertEqual(better['better'][0]['classification'], 'warning')
|
||||||
|
self.assertEqual(better['better'][0]['previous_classification'], 'critical')
|
||||||
|
self.assertEqual(better['better'][0]['previous_affected'], 1)
|
||||||
|
self.assertEqual(better['better'][0]['affected_count'], 1)
|
||||||
|
self.assertEqual(better['worse'], [])
|
||||||
|
|
||||||
|
def test_new_failure_and_verified_resolution_stay_distinct(self):
|
||||||
|
conformant = self.recorded(self.finding(state='pass'))
|
||||||
|
warning = self.recorded(self.finding())
|
||||||
|
new = engine.compare_runs(conformant, warning)
|
||||||
|
self.assertEqual(len(new['new']), 1)
|
||||||
|
self.assertEqual(new['new'][0]['classification'], 'warning')
|
||||||
|
self.assertEqual(new['resolved'], [])
|
||||||
|
self.assertEqual(new['unverified'], [])
|
||||||
|
resolved = engine.compare_runs(warning, conformant)
|
||||||
|
self.assertEqual(len(resolved['resolved']), 1)
|
||||||
|
self.assertEqual(resolved['resolved'][0]['classification'], 'conformant')
|
||||||
|
self.assertEqual(resolved['new'], [])
|
||||||
|
self.assertEqual(resolved['unverified'], [])
|
||||||
|
|
||||||
def test_malformed_result_does_not_abort_remaining_checks(self):
|
def test_malformed_result_does_not_abort_remaining_checks(self):
|
||||||
for bad in (False, [], {'affected': [None]}, {'affected': 'invalid'}):
|
for bad in (False, [], {'affected': [None]}, {'affected': 'invalid'}):
|
||||||
|
|||||||
@@ -2,38 +2,8 @@
|
|||||||
// No component imports, React effects, API calls or host-management execution.
|
// No component imports, React effects, API calls or host-management execution.
|
||||||
// Run from the repository root: node tests/test_backup_archives_empty.cjs
|
// Run from the repository root: node tests/test_backup_archives_empty.cjs
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const fs = require('node:fs');
|
const { ts, read, compile, printer, catalogs, translator } = require('./backup_i18n_seam.cjs');
|
||||||
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 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');
|
const source = parse('components/host-backup.tsx');
|
||||||
let branch;
|
let branch;
|
||||||
@@ -68,16 +38,22 @@ const text = (locale = 'en', overrides = {}, messages = catalogs) => {
|
|||||||
state.archivesResp, state.remoteArchivesResp, state.unifiedArchives);
|
state.archivesResp, state.remoteArchivesResp, state.unifiedArchives);
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(text(), 'No backup archives found.');
|
const emptyMessage = 'No backup archives found.';
|
||||||
assert.equal(text('it'), 'No backup archives found.', 'Italian catalog uses English fallback');
|
assert.equal(catalogs.en.backup.archives.emptyMessage, emptyMessage);
|
||||||
// Keep fallback coverage even after all shipped locales gain the new key.
|
assert.equal(text(), emptyMessage);
|
||||||
assert.equal(text('missing', {}, { ...catalogs, missing: {} }), 'No backup archives found.');
|
// Missing-key fallback is unconditional: it must survive later catalog generation.
|
||||||
for (const locale of Object.keys(catalogs)) {
|
assert.equal(text('missing', {}, { ...catalogs, missing: {} }), emptyMessage);
|
||||||
// Existing catalogs intentionally have no new key; actual provider falls back.
|
const synthetic = { ...catalogs, fixture: { backup: { archives: { emptyMessage: 'Fixture: no archives.' } } } };
|
||||||
if (catalogs[locale].backup?.archives?.emptyMessage === undefined) {
|
assert.equal(text('fixture', {}, synthetic), 'Fixture: no archives.');
|
||||||
assert.equal(text(locale), 'No backup archives found.', `${locale}: missing-key fallback`);
|
const locales = Object.keys(catalogs);
|
||||||
}
|
assert.ok(locales.length > 1, 'shipped multilingual catalogs discovered');
|
||||||
|
for (const locale of locales) {
|
||||||
|
const shipped = catalogs[locale].backup?.archives?.emptyMessage;
|
||||||
|
assert.equal(text(locale), shipped ?? emptyMessage, `${locale}: actual shipped locale or English fallback`);
|
||||||
}
|
}
|
||||||
|
assert.ok(locales.some(locale => locale !== 'en' &&
|
||||||
|
typeof catalogs[locale].backup?.archives?.emptyMessage === 'string' &&
|
||||||
|
catalogs[locale].backup.archives.emptyMessage !== emptyMessage), 'localized shipped archive message exercised');
|
||||||
for (const count of [1, 3]) assert.equal(text('en', { unifiedArchives: Array(count).fill({}) }), 'NONEMPTY_ARCHIVES');
|
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', { archivesResp: undefined, remoteArchivesResp: undefined }), translator('en')('backup.common.loading'));
|
||||||
assert.equal(text('en', { archivesErr: true, remoteArchivesErr: true }), translator('en')('backup.archives.loadFailed'));
|
assert.equal(text('en', { archivesErr: true, remoteArchivesErr: true }), translator('en')('backup.archives.loadFailed'));
|
||||||
@@ -86,6 +62,4 @@ assert.equal(text('en', { archivesErr: true, archivesResp: undefined }), 'No bac
|
|||||||
assert.equal(text('en', { remoteArchivesErr: true, remoteArchivesResp: 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.equal(translator('en')('backup.manual.run'), 'Run');
|
||||||
assert.ok(read('components/host-backup.tsx').includes('{t("backup.manual.run")}'), 'manual action remains');
|
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');
|
console.log('PASS: actual archives JSX seam, zero/one/multiple, loading/errors, shipped locales and synthetic fallback/translation, Run unchanged');
|
||||||
// Export only test seams for separate, unapplied linguistic proposal checks.
|
|
||||||
module.exports = { text, translator, catalogs };
|
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
// Offline actual JSX/provider seams; no component/backend imports or effects.
|
// Offline actual JSX/provider seams; no component/backend imports or effects.
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const fs = require('node:fs');
|
const { ts, read, printer, translator, catalogs } = require('./backup_i18n_seam.cjs');
|
||||||
const path = require('node:path');
|
const source = ts.createSourceFile('host-backup.tsx', read('components/host-backup.tsx'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||||
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('') };
|
const React = { createElement: (tag, props, ...children) => children.flat(Infinity).filter(x => x != null && x !== false).join('') };
|
||||||
function find(predicate) {
|
function find(predicate) {
|
||||||
const found = [];
|
const found = [];
|
||||||
@@ -25,32 +20,45 @@ function render(node, locale, backups = 1, messages = catalogs) {
|
|||||||
}
|
}
|
||||||
const kept = find(node => ts.isBinaryExpression(node) && node.left.getText(source) === 'backups > 0');
|
const kept = find(node => ts.isBinaryExpression(node) && node.left.getText(source) === 'backups > 0');
|
||||||
const keptMessage = 'Existing backup archives are not deleted.';
|
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(catalogs.en.backup.destinations.backupsKeptMessage, keptMessage);
|
||||||
|
assert.equal(render(kept, 'en', 1), keptMessage);
|
||||||
|
assert.equal(render(kept, 'en', 3), keptMessage);
|
||||||
assert.equal(render(kept, 'en', 0), false);
|
assert.equal(render(kept, 'en', 0), false);
|
||||||
for (const locale of Object.keys(catalogs)) {
|
// A missing locale key must use English regardless of shipped catalog completeness.
|
||||||
if (catalogs[locale].backup?.destinations?.backupsKeptMessage !== undefined && locale !== 'en') continue;
|
const missing = { ...catalogs, missing: {} };
|
||||||
for (const count of [1, 3]) assert.equal(render(kept, locale, count), keptMessage, `${locale}: kept whole-message fallback`);
|
for (const count of [1, 3]) assert.equal(render(kept, 'missing', count, missing), keptMessage);
|
||||||
}
|
console.log('PASS: destination removal JSX guard 0/1/N and missing-key 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.
|
// Select the actual help paragraph, keeping its literal paths/JSX whitespace.
|
||||||
const help = find(node => ts.isJsxElement(node) && node.openingElement.tagName.getText(source) === 'p' &&
|
const help = find(node => ts.isJsxElement(node) && node.openingElement.tagName.getText(source) === 'p' &&
|
||||||
/backup\.destinations\.localAttachHelp(?:Before|Message)/.test(node.getText(source)));
|
/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).';
|
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');
|
assert.equal(catalogs.en.backup.destinations.localAttachHelpMessage, helpMessage);
|
||||||
for (const locale of Object.keys(catalogs)) {
|
assert.equal(render(help, 'en'), helpMessage);
|
||||||
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);
|
assert.equal(render(help, 'missing', 1, missing), helpMessage);
|
||||||
// Synthetic locale proves complete messages are translatable, not hardcoded.
|
// Synthetic whole messages prove both JSX consumers use the provider rather than hardcoded English.
|
||||||
const synthetic = { ...catalogs, fixture: { backup: { destinations: {
|
const synthetic = { ...catalogs, fixture: { backup: { destinations: {
|
||||||
backupsKeptMessage: 'Fixture kept.', localAttachHelpMessage: 'Fixture help.',
|
backupsKeptMessage: 'Fixture kept.', localAttachHelpMessage: 'Fixture help.',
|
||||||
} } } };
|
} } } };
|
||||||
assert.equal(render(kept, 'fixture', 1, synthetic), 'Fixture kept.');
|
assert.equal(render(kept, 'fixture', 1, synthetic), 'Fixture kept.');
|
||||||
|
assert.equal(render(kept, 'fixture', 3, synthetic), 'Fixture kept.');
|
||||||
assert.equal(render(help, 'fixture', 1, synthetic), 'Fixture help.');
|
assert.equal(render(help, 'fixture', 1, synthetic), 'Fixture help.');
|
||||||
|
const locales = Object.keys(catalogs);
|
||||||
|
assert.ok(locales.length > 1, 'shipped multilingual catalogs discovered');
|
||||||
|
for (const locale of locales) {
|
||||||
|
const destinations = catalogs[locale].backup?.destinations;
|
||||||
|
for (const count of [1, 3]) {
|
||||||
|
assert.equal(render(kept, locale, count), destinations?.backupsKeptMessage ?? keptMessage,
|
||||||
|
`${locale}: actual kept message or English fallback`);
|
||||||
|
}
|
||||||
|
assert.equal(render(help, locale), destinations?.localAttachHelpMessage ?? helpMessage,
|
||||||
|
`${locale}: actual local attach help or English fallback`);
|
||||||
|
}
|
||||||
|
for (const [key, english] of [['backupsKeptMessage', keptMessage], ['localAttachHelpMessage', helpMessage]]) {
|
||||||
|
assert.ok(locales.some(locale => locale !== 'en' &&
|
||||||
|
typeof catalogs[locale].backup?.destinations?.[key] === 'string' &&
|
||||||
|
catalogs[locale].backup.destinations[key] !== english),
|
||||||
|
`${key}: localized shipped message exercised`);
|
||||||
|
}
|
||||||
assert.equal(catalogs.en.backup.destinations.kept, 'kept');
|
assert.equal(catalogs.en.backup.destinations.kept, 'kept');
|
||||||
assert.equal(catalogs.en.backup.destinations.localAttachHelpMiddle, 'USB drives');
|
assert.equal(catalogs.en.backup.destinations.localAttachHelpMiddle, 'USB drives');
|
||||||
console.log('PASS: local attach JSX whole-message fallback and literal paths');
|
console.log('PASS: destination whole-message JSX, shipped locales, synthetic translation/fallback and literal paths');
|
||||||
|
|||||||
Reference in New Issue
Block a user