fix sonar findings and translation structure
This commit is contained in:
@@ -224,7 +224,7 @@ function route(id, name, source = 'server') {
|
||||
};
|
||||
}
|
||||
|
||||
async function loadApp() {
|
||||
async function loadApp({ language = 'de-DE' } = {}) {
|
||||
const sourcePath = path.join(root, 'public/js/app.js');
|
||||
const source = await fs.readFile(sourcePath, 'utf8');
|
||||
const harness = createHarness();
|
||||
@@ -299,6 +299,7 @@ async function loadApp() {
|
||||
|
||||
context.addEventListener = (name, handler) => harness.globalListeners.set(name, handler);
|
||||
context.globalThis = context;
|
||||
context.navigator = { language };
|
||||
|
||||
await loadI18n(context);
|
||||
|
||||
@@ -318,6 +319,17 @@ async function loadApp() {
|
||||
};
|
||||
}
|
||||
|
||||
test('English browser settings localize app controls and retain user-authored trail names', async () => {
|
||||
const { context, harness } = await loadApp({ language: 'en-GB' });
|
||||
await context.Wegwichtel.App.initialize({ markStep() {} });
|
||||
assert.equal(harness.node('#route-control-title').textValue, 'Choose a trail');
|
||||
assert.equal(harness.node('#route-control-server-title').textValue, 'Public trails (1)');
|
||||
assert.equal(harness.node('#route-control-local-title').textValue, 'My drafts (1)');
|
||||
assert.match(harness.node('#location-hint').textValue, /You can still choose from all trails/);
|
||||
assert.ok(harness.node('#all-route-list').appended.join('').includes('Serverweg'));
|
||||
assert.ok(harness.node('#local-route-list').appended.join('').includes('Lokaler Weg'));
|
||||
});
|
||||
|
||||
test('route control runtime loads server/local routes and supports switching without geolocation', async () => {
|
||||
const setup = await loadApp();
|
||||
const { context, harness, apiCalls, editor } = setup;
|
||||
|
||||
Vendored
+4
-6
@@ -36,10 +36,8 @@ test('bootstrap status text fades between loading steps and is replaced by the s
|
||||
assert.match(fadingRule, /opacity:\s*0/);
|
||||
assert.match(loader, /statusText\.classList\.add\('is-fading'\)/);
|
||||
assert.match(loader, /statusText\.classList\.remove\('is-fading'\)/);
|
||||
assert.match(loader, /get storage\(\) \{ return globalThis\.Wegwichtel\.I18n\.t\(/);
|
||||
assert.match(loader, /get server\(\) \{ return globalThis\.Wegwichtel\.I18n\.t\(/);
|
||||
assert.match(loader, /get routes\(\) \{ return globalThis\.Wegwichtel\.I18n\.t\(/);
|
||||
assert.match(loader, /get location\(\) \{ return globalThis\.Wegwichtel\.I18n\.t\(/);
|
||||
assert.match(loader, /const stepLabel = step => globalThis\.Wegwichtel\.I18n\.t\(`bootstrap-loader\.steps\.\$\{step\}`\)/);
|
||||
assert.match(loader, /steps\.includes\(step\)/);
|
||||
assert.match(loader, /actionStatus\?\.classList\.add\('is-leaving'\)/);
|
||||
assert.match(loader, /enter\.classList\.add\('is-visible'\)/);
|
||||
assert.match(loader, /const completedSteps = new Set\(\)/);
|
||||
@@ -59,6 +57,6 @@ test('Android start hint distinguishes Chrome from other Android browsers', asyn
|
||||
assert.match(loader, /getElementById\('android-browser-hint'\)/);
|
||||
assert.match(loader, /\/Android\/i/);
|
||||
assert.match(loader, /\/Chrome\\\//i);
|
||||
assert.match(loader, /I18n\.t\("bootstrap-loader\.auf\.diesem\.gerat\.kannst\.du\.bilder\.und"\)/);
|
||||
assert.match(loader, /I18n\.t\("bootstrap-loader\.tipp\.fur\.android\.mit\.chrome\.lassen\.sich"\)/);
|
||||
assert.match(loader, /I18n\.t\("bootstrap-loader\.hints\.chrome"\)/);
|
||||
assert.match(loader, /I18n\.t\("bootstrap-loader\.hints\.android"\)/);
|
||||
});
|
||||
|
||||
+97
-9
@@ -3,19 +3,44 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import { localizeHtml } from '../scripts/localize-html.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
test('checked-in HTML has synchronized German defaults and accessible headings, labels and links before scripts load', async () => {
|
||||
const i18n = await loadI18n({});
|
||||
const html = await fs.readFile(path.join(root, 'public/index.html'), 'utf8');
|
||||
assert.equal(localizeHtml(html, key => i18n.t(key)), html, 'Run node scripts/localize-html.js after editing German texts');
|
||||
for (const match of html.matchAll(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]>/g)) assert.ok(match[1].replaceAll(/<[^>]*>/g, '').trim(), match[0]);
|
||||
for (const match of html.matchAll(/\baria-label="([^"]*)"/g)) assert.ok(match[1].trim(), 'Empty accessible label');
|
||||
for (const match of html.matchAll(/<a\b[^>]*>([\s\S]*?)<\/a>/g)) assert.ok(match[1].replaceAll(/<[^>]*>/g, '').trim(), match[0]);
|
||||
for (const match of html.matchAll(/<label\b([^>]*)>/g)) {
|
||||
const control = /\bfor="([^"]+)"/.exec(match[1])?.[1];
|
||||
assert.ok(control, `Missing label association: ${match[0]}`);
|
||||
assert.ok(html.includes(`id="${control}"`), `Missing control: ${control}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('HTML generation escapes text and attributes, preserves i18n markers and updates existing defaults', () => {
|
||||
const source = '<h1 data-i18n="heading">Old title</h1><button aria-label="Old label" data-i18n-aria-label="label">?</button>';
|
||||
const messages = { heading: '<New & title>', label: 'A "label" & $&' };
|
||||
const translate = key => messages[key];
|
||||
const result = localizeHtml(source, translate);
|
||||
assert.equal(result, '<h1 data-i18n="heading"><New & title></h1><button aria-label="A "label" & $&" data-i18n-aria-label="label">?</button>');
|
||||
assert.equal(localizeHtml(result, translate), result);
|
||||
});
|
||||
|
||||
test('translations support placeholders, plurals and German fallback without treating text as HTML', async () => {
|
||||
const context = {};
|
||||
const i18n = await loadI18n(context);
|
||||
assert.equal(i18n.t('app.strecke.wahlen'), 'Strecke wählen');
|
||||
assert.equal(i18n.t('app.choose-route'), 'Strecke wählen');
|
||||
assert.equal(i18n.t('media.image.count', { count: 1 }), '1 Bild');
|
||||
assert.equal(i18n.t('media.image.count', { count: 2 }), '2 Bilder');
|
||||
assert.equal(i18n.t('app.nachste.station', { value1: '<script>', value2: ' · 3 m' }), 'Nächste Station: <script> · 3 m');
|
||||
i18n.register('en', { 'app.strecke.wahlen': 'Choose a trail', 'media.image.count': { one: '{count} picture', other: '{count} pictures' } });
|
||||
assert.equal(i18n.t('app.next-station-summary', { value1: '<script>', value2: ' · 3 m' }), 'Nächste Station: <script> · 3 m');
|
||||
i18n.register('en', { 'app.choose-route': 'Choose a trail', 'media.image.count': { one: '{count} picture', other: '{count} pictures' } });
|
||||
assert.equal(i18n.setLanguage('en-GB', null), 'en');
|
||||
assert.equal(i18n.t('app.strecke.wahlen'), 'Choose a trail');
|
||||
assert.equal(i18n.t('app.choose-route'), 'Choose a trail');
|
||||
assert.equal(i18n.t('media.image.count', { count: 0 }), '0 pictures');
|
||||
assert.equal(i18n.t('media.audio.count', { count: 1 }), '1 Tonaufnahme');
|
||||
assert.equal(i18n.setLanguage('fr', null), 'de');
|
||||
@@ -24,10 +49,15 @@ test('translations support placeholders, plurals and German fallback without tre
|
||||
|
||||
test('language changes translate visible and spoken labels through textContent and retain text markers', async () => {
|
||||
const i18n = await loadI18n({});
|
||||
const make = key => ({ textContent: '', attributes: new Map(key), getAttribute(name) { return this.attributes.get(name); }, setAttribute(name, value) { this.attributes.set(name, value); } });
|
||||
const text = make([['data-i18n', 'app.strecke.wahlen']]);
|
||||
const make = entries => ({
|
||||
textContent: '',
|
||||
attributes: new Map(entries),
|
||||
dataset: Object.fromEntries(entries.map(([name, value]) => [name.slice(5).replaceAll(/-([a-z])/g, (_match, letter) => letter.toUpperCase()), value])),
|
||||
setAttribute(name, value) { this.attributes.set(name, value); }
|
||||
});
|
||||
const text = make([['data-i18n', 'app.choose-route']]);
|
||||
const attributes = ['aria-label', 'title', 'placeholder', 'alt'];
|
||||
const labels = new Map(attributes.map(attribute => [attribute, make([[`data-i18n-${attribute}`, 'app.strecke.wahlen']])]));
|
||||
const labels = new Map(attributes.map(attribute => [attribute, make([[`data-i18n-${attribute}`, 'app.choose-route']])]));
|
||||
const root = { documentElement: {}, querySelectorAll(selector) {
|
||||
if (selector === '[data-i18n]') return [text];
|
||||
for (const [attribute, label] of labels) {
|
||||
@@ -38,12 +68,12 @@ test('language changes translate visible and spoken labels through textContent a
|
||||
i18n.apply(root);
|
||||
assert.equal(text.textContent, 'Strecke wählen');
|
||||
for (const [attribute, label] of labels) assert.equal(label.attributes.get(attribute), 'Strecke wählen');
|
||||
i18n.register('en', { 'app.strecke.wahlen': '<Choose a trail>' });
|
||||
i18n.register('en', { 'app.choose-route': '<Choose a trail>' });
|
||||
i18n.setLanguage('en', root);
|
||||
assert.equal(text.textContent, '<Choose a trail>');
|
||||
for (const [attribute, label] of labels) assert.equal(label.attributes.get(attribute), '<Choose a trail>');
|
||||
assert.equal(root.documentElement.lang, 'en');
|
||||
assert.equal(text.attributes.get('data-i18n'), 'app.strecke.wahlen');
|
||||
assert.equal(text.attributes.get('data-i18n'), 'app.choose-route');
|
||||
});
|
||||
|
||||
test('every static HTML and JavaScript text key exists in the central catalog and translations load before app modules', async () => {
|
||||
@@ -56,5 +86,63 @@ test('every static HTML and JavaScript text key exists in the central catalog an
|
||||
for (const match of source.matchAll(/I18n\.t\(["']([^"']+)["']/g)) assert.equal(typeof i18n.t(match[1], { count: 2 }), 'string', match[1]);
|
||||
}
|
||||
assert.ok(html.indexOf('js/texts-de.js') < html.indexOf('js/i18n.js'));
|
||||
assert.ok(html.indexOf('js/texts-en.js') > html.indexOf('js/texts-de.js'));
|
||||
assert.ok(html.indexOf('js/texts-en.js') < html.indexOf('js/i18n.js'));
|
||||
assert.ok(html.indexOf('js/i18n.js') < html.indexOf('js/local-routes.js'));
|
||||
});
|
||||
|
||||
test('browser language preferences select German or English, including regional variants and unavailable languages', async () => {
|
||||
const cases = [
|
||||
[{ languages: ['en-GB', 'de-DE'], language: 'de-DE' }, 'en', 'Choose a trail'],
|
||||
[{ languages: ['de-DE', 'en-US'] }, 'de', 'Strecke wählen'],
|
||||
[{ languages: ['fr-FR', 'en-US'] }, 'en', 'Choose a trail'],
|
||||
[{ language: 'EN-us' }, 'en', 'Choose a trail'],
|
||||
[{ languages: ['fr-FR'] }, 'de', 'Strecke wählen'],
|
||||
[{}, 'de', 'Strecke wählen']
|
||||
];
|
||||
for (const [navigator, language, title] of cases) {
|
||||
const i18n = await loadI18n({ navigator });
|
||||
assert.equal(i18n.language, language);
|
||||
assert.equal(i18n.t('app.choose-route'), title);
|
||||
assert.equal(i18n.t('bootstrap-loader.steps.routes'), language === 'en' ? 'Loading trails …' : 'Strecken laden …');
|
||||
}
|
||||
});
|
||||
|
||||
function leafMessages(node, prefix = '') {
|
||||
return Object.entries(node).flatMap(([key, value]) => {
|
||||
const path = prefix + key;
|
||||
return typeof value === 'string' ? [[path, value]] : leafMessages(value, `${path}.`);
|
||||
});
|
||||
}
|
||||
|
||||
test('German and English catalogs have the same semantic keys, placeholders and plural forms', async () => {
|
||||
const context = {};
|
||||
const i18n = await loadI18n(context);
|
||||
const de = new Map(leafMessages(context.Wegwichtel.TextCatalogs.get('de')));
|
||||
const en = new Map(leafMessages(context.Wegwichtel.TextCatalogs.get('en')));
|
||||
assert.deepEqual([...en.keys()].sort(), [...de.keys()].sort());
|
||||
for (const [key, text] of de) {
|
||||
assert.ok(en.get(key).trim(), key);
|
||||
const placeholders = value => [...value.matchAll(/\{(\w+)\}/g)].map(match => match[1]).sort();
|
||||
assert.deepEqual(placeholders(en.get(key)), placeholders(text), key);
|
||||
assert.match(key, /^[a-z][a-z0-9.-]*$/, key);
|
||||
assert.ok(key.split('.').length <= 4, key);
|
||||
}
|
||||
i18n.setLanguage('en', null);
|
||||
assert.equal(i18n.t('app.no-route'), 'You have not created a trail of your own yet.');
|
||||
assert.equal(i18n.t('media.audio.count', { count: 1 }), '1 recording');
|
||||
assert.equal(i18n.t('media.audio.count', { count: 2 }), '2 recordings');
|
||||
for (const step of ['storage', 'jquery', 'jquery-ui', 'modules', 'server', 'routes', 'location']) assert.ok(i18n.t(`bootstrap-loader.steps.${step}`));
|
||||
});
|
||||
|
||||
test('the generic text class and i18n can start with only the English language file', async () => {
|
||||
const context = { navigator: { language: 'en-GB' } };
|
||||
for (const name of ['texts-en', 'i18n']) {
|
||||
const filename = path.join(root, `public/js/${name}.js`);
|
||||
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
|
||||
}
|
||||
assert.equal(context.Wegwichtel.Texts.name, 'WegwichtelTexts');
|
||||
assert.equal(context.Wegwichtel.I18n.language, 'en');
|
||||
assert.equal(context.Wegwichtel.I18n.t('app.choose-route'), 'Choose a trail');
|
||||
assert.equal(context.Wegwichtel.I18n.setLanguage('fr', null), 'en');
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ test('start screen exposes cookie status, optional local progress and deliberate
|
||||
assert.match(html, /role="switch"[^>]*aria-checked="false"/);
|
||||
assert.match(html, /id="storage-cleanup-status"/);
|
||||
assert.match(html, /id="bootstrap-enter"[^>]*hidden/);
|
||||
assert.match(loader, /I18n\.t\("bootstrap-loader\.alten\.fortschritt\.loschen", \{ value1: removed, value2: total \}\)/);
|
||||
assert.match(loader, /I18n\.t\("bootstrap-loader\.cleanup-progress", \{ value1: removed, value2: total \}\)/);
|
||||
assert.match(loader, /showReadyState\(\)/);
|
||||
assert.doesNotMatch(loader, /getElementById\('bootstrap-screen'\)\.hidden = true/);
|
||||
assert.match(loader, /event\.target\.closest\('button, input, label, a, dialog'\)/);
|
||||
|
||||
@@ -153,7 +153,7 @@ test('Stationen have live direction and metre indicators and take navigation pri
|
||||
assert.match(app, /function updatePoiIndicators\(position\)/);
|
||||
assert.match(app, /function nearestPendingPoi\(metrics\)/);
|
||||
assert.match(app, /if \(nextPoi\) showPoiNavigation\(position, nextPoi\)/);
|
||||
assert.match(app, /I18n\.t\("app\.nachste\.station\.2", \{ value1: metric\.poi\.title \}\)/);
|
||||
assert.match(app, /I18n\.t\("app\.next-station", \{ value1: metric\.poi\.title \}\)/);
|
||||
assert.match(app, /find\('small'\)\.text\(`\$\{distance\} m`\)/);
|
||||
assert.match(app, /find\('svg'\)\.css\('transform'/);
|
||||
assert.match(css, /\.poi-proximity[\s\S]*grid-column:\s*2/);
|
||||
|
||||
@@ -56,7 +56,7 @@ test('persistent route control displays the next unvisited Station and preserves
|
||||
const app = await read('public/js/app.js');
|
||||
|
||||
assert.match(app, /function nextPoiMetric\(/);
|
||||
assert.match(app, /I18n\.t\("app\.nachste\.station", \{ value1: metric\.poi\.title, value2: distance \}\)/);
|
||||
assert.match(app, /I18n\.t\("app\.next-station-summary", \{ value1: metric\.poi\.title, value2: distance \}\)/);
|
||||
assert.match(app, /state\.triggered\.has\(poi\.id\)/);
|
||||
assert.match(app, /function suspendCurrentRoute\(/);
|
||||
assert.match(app, /persistRouteProgress\(\)/);
|
||||
|
||||
@@ -47,3 +47,14 @@ test('public help and spoken labels omit implementation terms and preserve meani
|
||||
assert.match(text, /Fortschritt.*gelöscht/s);
|
||||
assert.match(text, /Meine Entwürfe/);
|
||||
});
|
||||
|
||||
test('English errors keep translated validation hints and hide technical replies', async () => {
|
||||
const context = { navigator: { language: 'en-GB' } };
|
||||
await loadI18n(context);
|
||||
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context);
|
||||
const { I18n, UserMessages } = context.Wegwichtel;
|
||||
assert.equal(UserMessages.error(new Error(I18n.t('editor.error.route-name'))), 'Give your trail a name.');
|
||||
assert.equal(UserMessages.error({ status: 413 }), 'This file is too large. Choose a smaller file.');
|
||||
assert.match(UserMessages.error({ message: 'Die GPX-Datei ist kein gültiges XML.' }), /readable path/);
|
||||
assert.equal(UserMessages.error(new Error('Failed to fetch')), 'That did not work. Please try again.');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user