Files
Wegwichtel/test/i18n.test.js
T
Florian Zumpe d3311cce06
Sonarqube Scanner / Prepare environment (push) Successful in 7s
Sonarqube Scanner / Build and test (push) Successful in 30s
Sonarqube Scanner / SonarQube analysis (push) Successful in 1m6s
Sonarqube Scanner / Deploy to Server (push) Successful in 6s
insert copyright headers for images
2026-10-04 10:14:40 +02:00

202 lines
11 KiB
JavaScript

import { loadI18n } from '../test-support/i18n.js';
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('German legal pages and contacts remain static when the app uses English', async () => {
const context = { navigator: { language: 'en-GB' } };
const i18n = await loadI18n(context);
const html = await fs.readFile(path.join(root, 'public/index.html'), 'utf8');
const englishHtml = localizeHtml(html, key => i18n.t(key));
for (const name of ['privacy', 'imprint', 'terms']) {
const pattern = new RegExp(`<article[^>]+data-info-page="${name}"[^>]*>[\\s\\S]*?</article>`);
const page = pattern.exec(html)?.[0];
assert.ok(page, name);
assert.match(page, /lang="de"/);
assert.doesNotMatch(page, /data-i18n/);
assert.equal(pattern.exec(englishHtml)?.[0], page);
}
for (const catalog of context.Wegwichtel.TextCatalogs.values()) {
for (const key of ['privacy', 'imprint', 'terms', 'contacts']) {
assert.equal(Object.hasOwn(catalog, key), false, key);
}
}
});
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">&lt;New &amp; title&gt;</h1><button aria-label="A &quot;label&quot; &amp; $&amp;" data-i18n-aria-label="label">?</button>');
assert.equal(localizeHtml(result, translate), result);
});
test('HTML scanning respects quoted tag delimiters, comments, boolean attributes and nested content', () => {
const source = '<!-- <h1 data-i18n="ignored">do not translate</h1> -->'
+ '<button disabled title=old data-i18n-title="label" data-note="a > b">?</button>'
+ "<h1 data-i18n='heading'>old</h1>"
+ '<p data-i18n="parent"><strong>Keep nested text</strong></p>';
const calls = [];
const result = localizeHtml(source, key => {
calls.push(key);
return { heading: 'New heading', label: 'Sam\'s "label"' }[key];
});
assert.deepEqual(calls, ['label', 'heading']);
assert.ok(result.includes('<!-- <h1 data-i18n="ignored">do not translate</h1> -->'));
assert.ok(result.includes('disabled title="Sam&#39;s &quot;label&quot;"'));
assert.ok(result.includes('data-note="a > b"'));
assert.ok(result.includes("<h1 data-i18n='heading'>New heading</h1>"));
assert.ok(result.includes('<p data-i18n="parent"><strong>Keep nested text</strong></p>'));
});
test('HTML scanning preserves incomplete tags, quoted attributes and comments', () => {
for (const tail of ['<h1 data-i18n="title"', '<button title="unfinished >', '<!-- unfinished <h1>']) {
const source = `Plain text${tail}`;
assert.equal(localizeHtml(source, () => assert.fail('Incomplete markup must not be translated')), source);
}
});
test('HTML scanning handles long malformed input without regex backtracking', { timeout: 2000 }, () => {
const source = '<'.repeat(200000) + ' unfinished';
assert.equal(localizeHtml(source, () => assert.fail('Malformed markup must not be translated')), source);
const attribute = 'x'.repeat(200000);
const html = `<h1 data-note="${attribute}" data-i18n="heading">old</h1>`;
assert.equal(localizeHtml(html, () => 'New heading'), `<h1 data-note="${attribute}" data-i18n="heading">New heading</h1>`);
});
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.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.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.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');
assert.throws(() => i18n.t('missing.key'), /Missing translation/);
});
test('language changes translate visible and spoken labels through textContent and retain text markers', async () => {
const i18n = await loadI18n({});
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.choose-route']])]));
const root = { documentElement: {}, querySelectorAll(selector) {
if (selector === '[data-i18n]') return [text];
for (const [attribute, label] of labels) {
if (selector === `[data-i18n-${attribute}]`) return [label];
}
return [];
} };
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.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.choose-route');
});
test('every static HTML and JavaScript text key exists in the central catalog and translations load before app modules', async () => {
const i18n = await loadI18n({});
const html = await fs.readFile(path.join(root, 'public/index.html'), 'utf8');
for (const match of html.matchAll(/\bdata-i18n(?:-[\w-]+)?="([^"]+)"/g)) assert.equal(typeof i18n.t(match[1]), 'string', match[1]);
for (const name of await fs.readdir(path.join(root, 'public/js'))) {
if (!name.endsWith('.js')) continue;
const source = await fs.readFile(path.join(root, 'public/js', name), 'utf8');
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');
});