Internationalization
Sonarqube Scanner / Prepare environment (push) Successful in 20s
Sonarqube Scanner / Build and test (push) Failing after 36s
Sonarqube Scanner / SonarQube analysis (push) Skipped
Sonarqube Scanner / Deploy to Server (push) Successful in 9s

This commit is contained in:
Florian Zumpe
2026-10-01 09:41:01 +02:00
parent 607e5450e1
commit aff88eba1b
41 changed files with 1368 additions and 636 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -13,7 +14,7 @@ const ignoredDirectories = new Set([
]);
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
return readAppFile(relativePath);
}
async function markdownFiles(directory = root) {
+24 -19
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -299,6 +300,10 @@ async function loadApp() {
context.addEventListener = (name, handler) => harness.globalListeners.set(name, handler);
context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath });
return {
@@ -322,9 +327,9 @@ test('route control runtime loads server/local routes and supports switching wit
assert.deepEqual(apiCalls.slice(0, 2), ['health', 'routes']);
assert.deepEqual(setup.steps, [['server', 'done'], ['routes', 'done'], ['location', 'done']]);
assert.match(harness.node('#location-hint').textValue, /Standort nicht verfügbar/);
assert.match(harness.node('#route-control-server-title').textValue, /Aktive Strecken \(1\)/);
assert.match(harness.node('#route-control-local-title').textValue, /Lokale Strecken \(1\)/);
assert.match(harness.node('#location-hint').textValue, /Standort konnte nicht gefunden werden/);
assert.match(harness.node('#route-control-server-title').textValue, /Strecken für alle \(1\)/);
assert.match(harness.node('#route-control-local-title').textValue, /Meine Entwürfe \(1\)/);
assert.ok(harness.node('#all-route-list').appended.join('').includes('Serverweg'));
assert.ok(harness.node('#local-route-list').appended.join('').includes('Lokaler Weg'));
@@ -344,7 +349,7 @@ test('route control runtime loads server/local routes and supports switching wit
await harness.activate('[data-route-select]', localButton);
assert.equal(harness.node('#route-control-title').textValue, 'Lokaler Weg');
assert.match(harness.node('#route-control-next').textValue, /Nächster Streckenpunkt: Lokaler Weg Start/);
assert.match(harness.node('#route-control-next').textValue, /Nächste Station: Lokaler Weg Start/);
assert.equal(harness.node('#route-page').attributes.has('hidden'), false);
const serverButton = new FakeNode('server-select');
@@ -354,7 +359,7 @@ test('route control runtime loads server/local routes and supports switching wit
assert.ok(apiCalls.includes('route:server-1'));
assert.equal(harness.node('#route-control-title').textValue, 'Serverweg');
assert.match(harness.node('#route-control-next').textValue, /Nächster Streckenpunkt: Serverweg Start/);
assert.match(harness.node('#route-control-next').textValue, /Nächste Station: Serverweg Start/);
});
test('local-route change refreshes an active draft and handles its later removal', async () => {
@@ -383,7 +388,7 @@ test('local-route change refreshes an active draft and handles its later removal
harness.document.dispatchEvent(removeEvent);
await tick();
assert.equal(harness.node('#route-control-title').textValue, 'Route wählen');
assert.equal(harness.node('#route-control-title').textValue, 'Strecke wählen');
assert.equal(harness.node('#routes-page').attributes.has('hidden'), false);
assert.equal(editor.refreshed, 2);
});
@@ -400,7 +405,7 @@ test('route control closes on escape and reports local storage failures', async
context.Wegwichtel.LocalRoutes.listAppRoutes = () => { throw new Error('LocalStorage nicht lesbar'); };
harness.globalListeners.get('pageshow')?.({});
assert.ok(harness.node('#route-control-local-list').appended.join('').includes('LocalStorage nicht lesbar'));
assert.ok(harness.node('#route-control-local-list').appended.join('').includes('Deine Entwürfe konnten nicht geöffnet werden'));
});
async function selectServer(setup) {
@@ -442,18 +447,18 @@ test('tracking triggers each POI once, records media progress and pauses/resumes
calls.heading(10);
assert.match(harness.node('#navigation-arrow').styles.get('transform'), /rotate\(50\.0deg\)/);
calls.error(new Error('Signal verloren'));
assert.equal(harness.node('#tracking-status').textValue, 'GPS-Fehler: Signal verloren');
assert.equal(harness.node('#tracking-status').textValue, 'Dein Standort konnte nicht gefunden werden. Prüfe die Standort-Erlaubnis auf deinem Gerät.');
await harness.activate('#pause-route', new FakeNode('pause'));
assert.match(harness.node('#tracking-status').textValue, /pausiert/);
assert.match(harness.node('#tracking-status').textValue, /Du machst eine Pause/);
assert.equal(savedStates.at(-1)[1], 'paused');
await harness.activate('#start-route', new FakeNode('resume'));
calls.position({ lat: 50.001, lon: 10.001, accuracy: 5 });
assert.equal(calls.vibrations, 2);
assert.equal(harness.node('#route-control-next').textValue, 'Alle Streckenpunkte erreicht');
assert.equal(harness.node('#route-control-next').textValue, 'Alle Stationen erreicht');
assert.equal(harness.node('#navigation-distance').textValue, '0 m');
await harness.activate('#stop-route', new FakeNode('stop'));
assert.equal(savedStates.at(-1)[1], 'idle');
assert.equal(harness.node('#route-control-title').textValue, 'Route wählen');
assert.equal(harness.node('#route-control-title').textValue, 'Strecke wählen');
assert.equal(harness.node('#navigation-footer').properties.get('hidden'), true);
assert.ok(calls.stopped >= 4);
});
@@ -468,8 +473,8 @@ test('saved route resumes paused, manual POI viewing and lifecycle events persis
const calls = observeSensors(setup);
await context.Wegwichtel.App.initialize({ markStep() {} });
await selectServer(setup);
assert.match(harness.node('#tracking-status').textValue, /Gespeicherter/);
assert.equal(harness.node('#start-route').attributes.get('title'), 'Route fortsetzen');
assert.match(harness.node('#tracking-status').textValue, /weitermachen/);
assert.equal(harness.node('#start-route').attributes.get('title'), 'Strecke fortsetzen');
const poi = new FakeNode('poi');
poi.dataValues.set('poi-id', 2);
await harness.key('[data-poi-select]', poi, ' ');
@@ -501,16 +506,16 @@ test('tracking handles an empty route and orientation permission resolving after
context.Wegwichtel.Orientation.start = () => new Promise(resolve => { grant = resolve; });
await context.Wegwichtel.App.initialize({ markStep() {} });
await selectServer(setup);
assert.match(harness.node('#poi-list').appended.join(''), /keine Streckenpunkte/);
assert.match(harness.node('#poi-list').appended.join(''), /keine Stationen/);
await harness.activate('#start-route', new FakeNode('start'));
calls.position({ lat: 50, lon: 10, accuracy: 10 });
assert.equal(harness.node('#navigation-mode').textValue, 'Keine Navigationspunkte vorhanden');
assert.equal(harness.node('#navigation-mode').textValue, 'Der Weg dieser Strecke fehlt.');
await harness.activate('#pause-route', new FakeNode('pause'));
const stopped = calls.stopped;
grant(true);
await tick();
assert.equal(calls.stopped, stopped + 1);
assert.equal(harness.node('#navigation-mode').textValue, 'Route pausiert');
assert.equal(harness.node('#navigation-mode').textValue, 'Strecke pausiert');
await harness.activate('#stop-route', new FakeNode('stop'));
});
@@ -522,7 +527,7 @@ test('route recommendations and accent-insensitive search use location without c
far.start = { lat: 51, lon: 10 };
context.Wegwichtel.Api.routes = async () => ({ routes: [far, setup.serverRoute, null, {}] });
await context.Wegwichtel.App.initialize({ markStep() {} });
assert.match(harness.node('#location-hint').textValue, /Standort ermittelt/);
assert.match(harness.node('#location-hint').textValue, /Standort wurde/);
assert.match(harness.node('#nearby-route-list').appended.join(''), /Serverweg/);
assert.doesNotMatch(harness.node('#nearby-route-list').appended.join(''), /Äpfelweg/);
const position = context.Wegwichtel.App.currentPosition();
@@ -537,7 +542,7 @@ test('route recommendations and accent-insensitive search use location without c
autocomplete.select({}, { item: { value: 'Serverweg' } });
assert.match(harness.node('#all-route-list').appended.join(''), /Serverweg/);
harness.$('#route-search').val('unbekannt').trigger('input');
assert.match(harness.node('#all-route-list').appended.join(''), /Keine Route entspricht/);
assert.match(harness.node('#all-route-list').appended.join(''), /Keine passende Strecke gefunden/);
await harness.activate('#clear-route-search', new FakeNode('clear'));
assert.equal(harness.node('#route-search').value, '');
});
@@ -546,7 +551,7 @@ test('invalid API responses reject initialization and route selection errors res
const setup = await loadApp();
const { context, harness } = setup;
context.Wegwichtel.Api.routes = async () => ({ routes: null });
await assert.rejects(context.Wegwichtel.App.initialize({ markStep() {} }), /gültige Routenliste/);
await assert.rejects(context.Wegwichtel.App.initialize({ markStep() {} }), /Strecken konnten nicht geladen werden/);
const errors = [];
context.alert = message => errors.push(message);
context.Wegwichtel.Api.route = async () => { throw { responseJSON: { message: 'Strecke entfernt' } }; };
+3 -1
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -148,13 +149,14 @@ test('bootstrap remains visible after loading and opens the app only after delib
location: { reload() {} }
};
context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath });
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(elements.get('bootstrap-screen').hidden, false);
assert.equal(elements.get('bootstrap-screen').attributes.get('aria-busy'), 'false');
assert.equal(elements.get('bootstrap-enter').hidden, false);
assert.equal(elements.get('storage-cleanup-status').textContent, 'Entferne alte Streckendaten aus dem Speicher (1 / 1)');
assert.equal(elements.get('storage-cleanup-status').textContent, 'Alten Fortschritt löschen (1 / 1)');
assert.equal(navigations.length, 0);
elements.get('bootstrap-screen').dispatchEvent(pointerEvent());
+2 -1
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -6,7 +7,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
return readAppFile(relativePath);
}
test('bootstrap screen remains vertically scrollable on small displays', async () => {
+8 -7
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -6,7 +7,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
return readAppFile(relativePath);
}
test('start screen uses the presentation-style hero and one action slot', async () => {
@@ -35,10 +36,10 @@ 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, /storage:\s*'Lokalen Speicher prüfen …'/);
assert.match(loader, /server:\s*'Server prüfen …'/);
assert.match(loader, /routes:\s*'Routen laden …'/);
assert.match(loader, /location:\s*'Standort ermitteln …'/);
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, /actionStatus\?\.classList\.add\('is-leaving'\)/);
assert.match(loader, /enter\.classList\.add\('is-visible'\)/);
assert.match(loader, /const completedSteps = new Set\(\)/);
@@ -58,6 +59,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, /Android mit Chrome erkannt/);
assert.match(loader, /Android erkannt\. Für lokale Bilder und Audiodateien bitte Chrome verwenden/);
assert.match(loader, /Auf diesem Gerät kannst du Bilder und Tonaufnahmen direkt auswählen/);
assert.match(loader, /Tipp für Android: Mit Chrome/);
});
+4 -2
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -8,6 +9,7 @@ async function load(name, options = {}) {
const filename = path.resolve(import.meta.dirname, '../public/js', name);
const context = { Wegwichtel: { Config: { apiBase: '/api', routeRadiusKm: 5, geolocationTimeoutMs: 7000 } }, ...options };
context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
return context;
}
@@ -63,10 +65,10 @@ test('GPS normalizes positions, forwards errors and clears replaced watches incl
test('GPS unsupported devices reject current location and optionally report tracking errors', async () => {
for (const navigator of [undefined, {}]) {
const { Wegwichtel: ns } = await load('geolocation.js', { navigator });
await assert.rejects(ns.Geo.current(), /keine Geolokalisierung/);
await assert.rejects(ns.Geo.current(), /Standort nicht finden/);
const errors = [];
ns.Geo.start(() => assert.fail('unexpected position'), error => errors.push(error));
assert.match(errors[0].message, /keine Geolokalisierung/);
assert.match(errors[0].message, /Standort nicht finden/);
ns.Geo.start(() => {});
ns.Geo.stop();
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -6,7 +7,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
return readAppFile(relativePath);
}
test('fresh routes schema stores timestamps as STRICT-compatible TEXT values', async () => {
+42 -20
View File
@@ -1,3 +1,5 @@
import { readAppFile } from '../test-support/i18n.js';
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -7,7 +9,7 @@ import vm from 'node:vm';
const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
return readAppFile(relativePath);
}
function tick() {
@@ -19,7 +21,7 @@ test('editor markup and source describe local draft creation instead of immediat
const css = await read('public/css/editor.css');
const ui = await read('public/js/editor-ui.js');
assert.match(html, /id="editor-button"[^>]+aria-label="Strecken verwalten"/);
assert.match(html, /id="editor-button"[^>]+aria-label="Strecken bearbeiten"/);
assert.match(html, /id="route-control-button"[\s\S]*?<img[^>]+src="images\/icons\/route-management\.svg"[^>]+alt=""[^>]*>/);
assert.doesNotMatch(html, /class="editor-button-icon"/);
assert.match(html, /<dialog id="editor-dialog"/);
@@ -36,11 +38,11 @@ test('editor markup and source describe local draft creation instead of immediat
assert.doesNotMatch(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/);
assert.match(html, /id="editor-poi-media-preview"/);
assert.match(html, /id="editor-poi-delete"[^>]+editor-danger-button/);
assert.match(html, />Streckenpunkte<\/h3>/);
assert.match(html, /aria-label="Streckenpunkt hinzufügen"/);
assert.match(html, />Stationen<\/h3>/);
assert.match(html, /aria-label="Station hinzufügen"/);
assert.match(html, /id="editor-route-upload"[^>]+type="button"[^>]+hidden/);
assert.doesNotMatch(html, /accept="video\//);
assert.match(html, /ausschließlich in diesem Browser/i);
assert.match(html, /auf diesem Gerät/i);
await fs.access(path.join(root, 'public/images/icons/route-management.svg'));
assert.match(css, /\.editor-dialog/);
@@ -359,11 +361,15 @@ function buildEditorContext() {
return { calls, context, elements, packages, saves, storedMediaCalls };
}
test('public editor workflow saves new route and GPX Streckenpunkt locally without POSTing a route', async () => {
test('public editor workflow saves new route and GPX Station locally without POSTing a route', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { calls, context, elements, packages, saves } = buildEditorContext();
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.open();
@@ -382,8 +388,8 @@ test('public editor workflow saves new route and GPX Streckenpunkt locally witho
assert.ok(packages.has('local-new'));
assert.equal(saves.at(-1).route.name, 'Neue lokale Strecke');
assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false);
assert.match(elements.get('editor-route-activated').textContent, /Lokaler Entwurf/);
assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.');
assert.match(elements.get('editor-route-activated').textContent, /Dieser Entwurf bleibt auf deinem Gerät/);
assert.equal(elements.get('editor-status').textContent, 'Gespeichert.');
assert.equal(elements.get('editor-route-upload').hidden, false);
assert.equal(elements.get('editor-route-upload').disabled, false);
assert.equal(elements.get('editor-route-gpx-group').hidden, true);
@@ -399,7 +405,7 @@ test('public editor workflow saves new route and GPX Streckenpunkt locally witho
assert.equal(elements.get('editor-poi-lon').value, '10.987654321');
assert.equal(elements.get('editor-poi-radius').value, '4.321');
elements.get('editor-poi-title').value = 'Aussicht';
elements.get('editor-poi-description').value = 'Text zum Streckenpunkt';
elements.get('editor-poi-description').value = 'Text zum Station';
elements.get('editor-poi-audio').files = [{ name: 'aussicht.mp3', size: 5, type: 'audio/mpeg' }];
elements.get('editor-poi-audio').dispatchEvent(new Event('change'));
elements.get('editor-poi-images').files = [{ name: 'aussicht.jpg', size: 7, type: 'image/jpeg' }];
@@ -447,8 +453,8 @@ test('public editor workflow saves new route and GPX Streckenpunkt locally witho
assert.equal(local.route.serverRouteId, '22');
assert.ok(local.route.uploadedAt);
assert.equal(elements.get('editor-route-upload').disabled, true);
assert.equal(elements.get('editor-route-upload').textContent, 'Bereits übertragen');
assert.equal(elements.get('editor-status').textContent, 'Upload erfolgreich.');
assert.equal(elements.get('editor-route-upload').textContent, 'Schon zur Prüfung gesendet');
assert.equal(elements.get('editor-status').textContent, 'Zur Prüfung gesendet.');
const poiList = elements.get('editor-poi-list');
const poiItem = poiList.children.find(child => child.dataset.poiId);
@@ -488,11 +494,15 @@ test('public editor workflow saves new route and GPX Streckenpunkt locally witho
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
});
test('local Streckenpunkt ids start at zero and increment from the highest existing id', async () => {
test('local Station ids start at zero and increment from the highest existing id', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { context, elements, packages } = buildEditorContext();
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.openNewLocalRoute();
elements.get('editor-route-name').value = 'ID-Test';
@@ -502,14 +512,14 @@ test('local Streckenpunkt ids start at zero and increment from the highest exist
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
assert.equal(elements.get('editor-poi-id').value, '0');
elements.get('editor-poi-title').value = 'Erster Streckenpunkt';
elements.get('editor-poi-title').value = 'Erster Station';
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
assert.equal(elements.get('editor-poi-id').value, '1');
elements.get('editor-poi-title').value = 'Zweiter Streckenpunkt';
elements.get('editor-poi-title').value = 'Zweiter Station';
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
@@ -517,18 +527,22 @@ test('local Streckenpunkt ids start at zero and increment from the highest exist
const saved = packages.get('local-new');
assert.equal(saved.pois.length, 2);
assert.deepEqual(Array.from(saved.pois, poi => poi.id), [0, 1]);
assert.deepEqual(Array.from(saved.pois, poi => poi.title), ['Erster Streckenpunkt', 'Zweiter Streckenpunkt']);
assert.deepEqual(Array.from(saved.pois, poi => poi.title), ['Erster Station', 'Zweiter Station']);
const pointItems = elements.get('editor-poi-list').children.filter(child => child.dataset.poiId);
assert.equal(pointItems.length, 2);
});
test('route and Streckenpunkt list expose red delete actions and remove local data', async () => {
test('route and Station list expose red delete actions and remove local data', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { context, elements, packages } = buildEditorContext();
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.openNewLocalRoute();
elements.get('editor-route-name').value = 'Löschtest';
@@ -537,7 +551,7 @@ test('route and Streckenpunkt list expose red delete actions and remove local da
await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
elements.get('editor-poi-title').value = 'Streckenpunkt zum Löschen';
elements.get('editor-poi-title').value = 'Station zum Löschen';
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
@@ -573,6 +587,10 @@ test('editing an activated server route uses PUT and never writes activation sta
const source = await fs.readFile(sourcePath, 'utf8');
const { calls, context, elements } = buildEditorContext();
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.open();
@@ -597,7 +615,7 @@ test('editing an activated server route uses PUT and never writes activation sta
assert.equal(elements.get('editor-route-id').value, '1');
assert.equal(elements.get('editor-route-gpx-group').hidden, false);
assert.match(elements.get('editor-route-activated').textContent, /Veröffentlicht am/);
assert.match(elements.get('editor-route-activated').textContent, /für alle freigegeben/);
assert.equal(elements.get('editor-route-upload').hidden, true);
elements.get('editor-route-name').value = 'Freigegebener Waldweg geändert';
@@ -612,7 +630,7 @@ test('editing an activated server route uses PUT and never writes activation sta
assert.deepEqual(Object.keys(body).sort(), ['description', 'name']);
assert.equal('activated_at' in body, false);
assert.equal('activatedAt' in body, false);
assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.');
assert.equal(elements.get('editor-status').textContent, 'Gespeichert.');
});
@@ -630,6 +648,10 @@ test('local media picker keeps File System Access handles for later upload', asy
return [handle];
};
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.openNewLocalRoute();
elements.get('editor-route-name').value = 'Handle-Strecke';
@@ -638,7 +660,7 @@ test('local media picker keeps File System Access handles for later upload', asy
await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
elements.get('editor-poi-title').value = 'Streckenpunkt mit Bild';
elements.get('editor-poi-title').value = 'Station mit Bild';
elements.get('editor-poi-images-add').dispatchEvent(pointerEvent());
await tick();
assert.equal(elements.get('editor-poi-images').clickCount, 0);
+2
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -10,6 +11,7 @@ async function catalogue(namespace) {
const filename = path.join(root, 'public/js/group-symbols.js');
const context = { Wegwichtel: namespace };
context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
return context.Wegwichtel.GroupSymbols;
}
+56
View File
@@ -0,0 +1,56 @@
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';
const root = path.resolve(import.meta.dirname, '..');
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('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.setLanguage('en-GB', null), 'en');
assert.equal(i18n.t('app.strecke.wahlen'), '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 = 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 label = make([['data-i18n-aria-label', 'app.strecke.wahlen']]);
const root = { documentElement: {}, querySelectorAll(selector) {
if (selector === '[data-i18n]') return [text];
if (selector === '[data-i18n-aria-label]') return [label];
return [];
} };
i18n.apply(root);
assert.equal(text.textContent, 'Strecke wählen');
assert.equal(label.attributes.get('aria-label'), 'Strecke wählen');
i18n.register('en', { 'app.strecke.wahlen': '<Choose a trail>' });
i18n.setLanguage('en', root);
assert.equal(text.textContent, '<Choose a trail>');
assert.equal(root.documentElement.lang, 'en');
assert.equal(text.attributes.get('data-i18n'), 'app.strecke.wahlen');
});
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/i18n.js') < html.indexOf('js/local-routes.js'));
});
+11 -6
View File
@@ -1,3 +1,5 @@
import { readAppFile } from '../test-support/i18n.js';
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -7,7 +9,7 @@ import vm from 'node:vm';
const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
return readAppFile(relativePath);
}
test('start screen exposes cookie status, optional local progress and deliberate entry', async () => {
@@ -15,13 +17,13 @@ test('start screen exposes cookie status, optional local progress and deliberate
const loader = await read('public/js/bootstrap-loader.js');
const storage = await read('public/js/privacy-storage.js');
assert.match(html, /Diese Anwendung setzt derzeit keine Cookies/);
assert.match(html, /Wegwichtel verwendet keine Cookies/);
assert.match(html, /class="cookie-toggle"[^>]*disabled/);
assert.match(html, /class="progress-storage-toggle"/);
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, /Entferne alte Streckendaten aus dem Speicher \(\$\{removed\} \/ \$\{total\}\)/);
assert.match(loader, /Alten Fortschritt löschen \(\{value1\} \/ \{value2\}\)/);
assert.match(loader, /showReadyState\(\)/);
assert.doesNotMatch(loader, /getElementById\('bootstrap-screen'\)\.hidden = true/);
assert.match(loader, /event\.target\.closest\('button, input, label, a, dialog'\)/);
@@ -40,9 +42,11 @@ test('information button opens scrollable overlay pages for privacy, imprint and
assert.match(html, /data-info-target="privacy"/);
assert.match(html, /data-info-target="imprint"/);
assert.match(html, /data-info-target="technology"/);
assert.match(html, /wegwichtel\.privacy\.v1/);
assert.match(html, /wegwichtel\.route-progress\.v1/);
assert.match(html, /jQuery 4\.0\.0/);
const storage = await read('public/js/privacy-storage.js');
assert.match(storage, /wegwichtel\.privacy\.v1/);
assert.match(storage, /wegwichtel\.route-progress\.v1/);
assert.match(html, /Speichern und Berechtigungen/);
assert.doesNotMatch(html, /Local Storage|GPX|jQuery 4\.0\.0/);
assert.match(css, /\.info-dialog-content[\s\S]*overflow-y:\s*auto/);
assert.match(css, /\.info-dialog-content[\s\S]*touch-action:\s*pan-y/);
assert.match(ui, /pointerup/);
@@ -177,6 +181,7 @@ test('legal overlay handlers synchronize switches without advancing the start sc
CustomEvent: TestCustomEvent
};
context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath });
elements.get('info-button').dispatchEvent(pointerEvent());
+2
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -91,6 +92,7 @@ function createHarness() {
async function loadModule(relativePath, harness) {
const sourcePath = path.join(root, relativePath);
const source = await fs.readFile(sourcePath, 'utf8');
await loadI18n(harness.context);
vm.runInNewContext(source, harness.context, { filename: sourcePath });
}
+24 -22
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -345,6 +346,7 @@ async function loadLocalRoutes(options = {}) {
Wegwichtel: {}
};
context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath });
return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage };
}
@@ -394,7 +396,7 @@ test('route id generation fails when no secure crypto source is available', asyn
assert.throws(
() => api.createRouteId(),
/Keine sichere Zufallsquelle/
/keine neue Strecke angelegt/
);
});
@@ -405,10 +407,10 @@ test('local route package stores GPX and JSON separately and reconstructs an app
const saved = api.save({
id: routeId,
gpx: api.emptyGpx('Neue Route'),
gpx: api.emptyGpx('Neue Strecke'),
route: {
id: routeId,
name: 'Neue Route',
name: 'Neue Strecke',
description: 'Lokal gespeichert',
serverRouteId: 42,
uploadedAt: '2026-09-27T18:00:00.000Z'
@@ -436,7 +438,7 @@ test('local route package stores GPX and JSON separately and reconstructs an app
]
});
assert.equal(saved.route.name, 'Neue Route');
assert.equal(saved.route.name, 'Neue Strecke');
assert.equal(saved.pois.length, 2);
assert.equal(events.at(-1).action, 'saved');
assert.equal(events.at(-1).id, routeId);
@@ -450,7 +452,7 @@ test('local route package stores GPX and JSON separately and reconstructs an app
assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/);
assert.doesNotMatch(gpx, /<wpt/);
assert.equal((gpx.match(/<trkpt /g) || []).length, 2);
assert.equal(metadata.route.name, 'Neue Route');
assert.equal(metadata.route.name, 'Neue Strecke');
assert.equal(metadata.route.serverRouteId, 42);
assert.equal(metadata.route.uploadedAt, '2026-09-27T18:00:00.000Z');
assert.equal(metadata.pois[0].audio.name, 'start.mp3');
@@ -482,19 +484,19 @@ test('GPX waypoint import reads stable POI ids and validates coordinates', async
assert.equal(imported[0].lon, 10.1);
assert.match(api.emptyGpx('A & B'), /<name>A &amp; B<\/name>/);
assert.throws(() => api.parseWaypoints('<not-gpx></not-gpx>'), /GPX-Datei ist nicht gültig/);
assert.throws(() => api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /gültige Koordinaten/);
assert.throws(() => api.parseWaypoints('<not-gpx></not-gpx>'), /keinen lesbaren Weg/);
assert.throws(() => api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /Ort von Ohne Position fehlt/);
const waypointOnly = api.syncGpx(
`<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`,
{ name: 'Nur Streckenpunkt' },
[{ id: 2, title: 'Einzelner Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 }]
{ name: 'Nur Station' },
[{ id: 2, title: 'Einzelner Station', lat: 50.2, lon: 10.2, sequence: 0 }]
);
assert.match(waypointOnly, /<trkpt lat="50.2" lon="10.2">/);
assert.doesNotMatch(waypointOnly, /<wpt\b/);
});
test('local points are stored directly as managed GPX Streckenpunkte and uploaded unchanged', async () => {
test('local points are stored directly as managed GPX Stationen and uploaded unchanged', async () => {
const { api } = await loadLocalRoutes();
const saved = api.save({
id: 'local-upload',
@@ -514,10 +516,10 @@ test('local points are stored directly as managed GPX Streckenpunkte and uploade
assert.equal(api.gpxForUpload(saved), saved.gpx, 'upload must use the already stored track without conversion');
});
test('sync keeps imported foreign Streckenpunkte and upload rejects a route without Streckenpunkte', async () => {
test('sync keeps imported foreign Stationen and upload rejects a route without Stationen', async () => {
const { api } = await loadLocalRoutes();
const synced = api.syncGpx(sampleGpx, { name: 'Import' }, [
{ id: 200, title: 'Neuer Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 }
{ id: 200, title: 'Neuer Station', lat: 50.2, lon: 10.2, sequence: 0 }
]);
assert.match(synced, /<ele>100<\/ele>/);
@@ -529,7 +531,7 @@ test('sync keeps imported foreign Streckenpunkte and upload rejects a route with
gpx: api.emptyGpx('Leer'),
route: { name: 'Leer' },
pois: []
}), /noch keine Streckenpunkte im GPX-Track/);
}), /noch keinen aufgezeichneten Weg/);
});
test('remove updates index and dispatches a local-route change event', async () => {
@@ -564,7 +566,7 @@ test('quota failure rolls back package writes and keeps the previous package rea
gpx: sampleGpx,
route: { name: 'Nachher' },
pois: []
}), /Speicher für Streckendaten ist voll/);
}), /Platz für deine Strecken ist voll/);
assert.equal(api.get('local-quota').route.name, 'Vorher');
});
@@ -581,7 +583,7 @@ test('local media uses IndexedDB so route JSON does not consume Local Storage qu
id: routeId,
gpx: api.emptyGpx('Medientest'),
route: { name: 'Medientest' },
pois: [{ id: 1, title: 'Streckenpunkt 1', lat: 50.1, lon: 10.1, audio, images: [image] }]
pois: [{ id: 1, title: 'Station 1', lat: 50.1, lon: 10.1, audio, images: [image] }]
});
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent(routeId)}.json`);
@@ -640,7 +642,7 @@ test('local media stores a File System Access handle instead of copying file byt
id: 'route-handle',
gpx: api.emptyGpx('Handle'),
route: { name: 'Handle' },
pois: [{ id: 7, title: 'Streckenpunkt', lat: 50.1, lon: 10.1, images: [media] }]
pois: [{ id: 7, title: 'Station', lat: 50.1, lon: 10.1, images: [media] }]
});
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent('route-handle')}.json`);
assert.match(metadata, /"storage":"file-handle"/);
@@ -648,12 +650,12 @@ test('local media stores a File System Access handle instead of copying file byt
assert.equal(await api.mediaBlob(media), file);
permission = 'prompt';
await assert.rejects(api.mediaBlob(media), /erneut erlaubt/);
await assert.rejects(api.mediaBlob(media), /Erlaube noch einmal/);
assert.equal(await api.mediaBlob(media, { requestPermission: true }), file);
assert.equal(permissionRequests, 1);
});
test('multiple Streckenpunkte with large local media keep Local Storage metadata small', async () => {
test('multiple Stationen with large local media keep Local Storage metadata small', async () => {
const { api, localStorage } = await loadLocalRoutes();
const routeId = 'multi-media';
const large = new Uint8Array(1024 * 1024);
@@ -664,11 +666,11 @@ test('multiple Streckenpunkte with large local media keep Local Storage metadata
api.save({
id: routeId,
gpx: api.emptyGpx('Mehrere Streckenpunkte'),
route: { name: 'Mehrere Streckenpunkte' },
gpx: api.emptyGpx('Mehrere Stationen'),
route: { name: 'Mehrere Stationen' },
pois: [
{ id: 1, title: 'Erster Streckenpunkt', lat: 50.1, lon: 10.1, images: [first] },
{ id: 2, title: 'Zweiter Streckenpunkt', lat: 50.2, lon: 10.2, images: [second] }
{ id: 1, title: 'Erster Station', lat: 50.1, lon: 10.1, images: [first] },
{ id: 2, title: 'Zweiter Station', lat: 50.2, lon: 10.2, images: [second] }
]
});
+16 -13
View File
@@ -1,3 +1,5 @@
import { readAppFile } from '../test-support/i18n.js';
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -7,7 +9,7 @@ import vm from 'node:vm';
const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
return readAppFile(relativePath);
}
async function clientSources() {
@@ -72,6 +74,7 @@ test('mobile navigation footer contains a rotating SVG arrow and exact metre out
test('client bearing calculation points north and east correctly', async () => {
const source = await read('public/js/distance.js');
const context = { Wegwichtel: {} };
await loadI18n(context);
vm.runInNewContext(source, context);
const distance = context.Wegwichtel.Distance;
@@ -96,10 +99,10 @@ test('route controls use accessible icon buttons with start, pause and stop stat
assert.match(html, /id="start-route"[\s\S]*images\/icons\/play\.svg/);
assert.match(html, /id="pause-route"[\s\S]*images\/icons\/pause\.svg/);
assert.match(html, /id="stop-route"[\s\S]*images\/icons\/stop\.svg/);
assert.match(html, /aria-label="Route starten"/);
assert.match(html, /aria-label="Route pausieren"/);
assert.match(html, /aria-label="Route beenden"/);
assert.match(html, /<fieldset>[\s\S]*<legend>Routensteuerung<\/legend>/);
assert.match(html, /aria-label="Strecke starten"/);
assert.match(html, /aria-label="Strecke pausieren"/);
assert.match(html, /aria-label="Strecke beenden"/);
assert.match(html, /<fieldset>[\s\S]*<legend>Unterwegs<\/legend>/);
assert.doesNotMatch(html, /role="group"/);
assert.doesNotMatch(html, /aria-type=/);
assert.match(app, /routeState:\s*'idle'/);
@@ -131,7 +134,7 @@ test('pause and stop control audio and stop returns to route selection', async (
assert.match(audio, /audio\.load\(\)/);
});
test('the selected or triggered Streckenpunkt is highlighted semantically', async () => {
test('the selected or triggered Station is highlighted semantically', async () => {
const app = await read('public/js/app.js');
const css = await read('public/css/app.css');
@@ -141,7 +144,7 @@ test('the selected or triggered Streckenpunkt is highlighted semantically', asyn
assert.match(css, /aktuelle Station/);
});
test('Streckenpunkte have live direction and metre indicators and take navigation priority', async () => {
test('Stationen have live direction and metre indicators and take navigation priority', async () => {
const app = await read('public/js/app.js');
const css = await read('public/css/app.css');
const html = await read('public/index.html');
@@ -150,16 +153,16 @@ test('Streckenpunkte have live direction and metre indicators and take navigatio
assert.match(app, /function updatePoiIndicators\(position\)/);
assert.match(app, /function nearestPendingPoi\(metrics\)/);
assert.match(app, /if \(nextPoi\) showPoiNavigation\(position, nextPoi\)/);
assert.match(app, /Nächster Streckenpunkt:/);
assert.match(app, /Nächste Station:/);
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/);
assert.match(css, /\.poi-proximity svg[\s\S]*transition:\s*transform/);
assert.match(html, /Richtung zum nächsten Streckenpunkt/);
assert.match(html, /Richtung zum nächsten Station/);
});
test('reaching a Streckenpunkt preloads audio without autoplay and uses the vibration wrapper', async () => {
test('reaching a Station preloads audio without autoplay and uses the vibration wrapper', async () => {
const app = await read('public/js/app.js');
const audio = await read('public/js/audio-player.js');
const html = await read('public/index.html');
@@ -171,14 +174,14 @@ test('reaching a Streckenpunkt preloads audio without autoplay and uses the vibr
assert.doesNotMatch(app, /navigator\.vibrate/);
assert.match(app, /function stopRouteSensors\(\)[\s\S]*ns\.Vibration\.stop\(\)/);
assert.match(audio, /audio\.load\(\)/);
assert.match(html, /<audio id="poi-audio" controls preload="metadata" aria-label="Audiowiedergabe zum Streckenpunkt" hidden>/);
assert.match(html, /<audio id="poi-audio" controls preload="metadata" aria-label="Ton zur Station" hidden>/);
assert.match(audio, /removeAttr\('hidden'\)/);
assert.match(audio, /Date\.now\(\)/);
assert.match(loader, /'vibration'/);
});
test('Streckenpunkt page uses icon-based back and slideshow controls in a single responsive row', async () => {
test('Station page uses icon-based back and slideshow controls in a single responsive row', async () => {
const html = await read('public/index.html');
const css = await read('public/css/app.css');
const loader = await read('public/js/bootstrap-loader.js');
@@ -196,7 +199,7 @@ test('Streckenpunkt page uses icon-based back and slideshow controls in a single
assert.match(css, /#slide-caption[\s\S]*overflow-wrap:\s*anywhere/);
assert.match(css, /#slide-image[\s\S]*object-fit:\s*contain/);
assert.doesNotMatch(css, /#slideshow img\s*\{/);
assert.match(html, /images\/icons\/play\.svg" alt="Route starten"/);
assert.match(html, /images\/icons\/play\.svg" alt="Strecke starten"/);
assert.match(html, /images\/icons\/chevron-left\.svg" alt="Vorheriges Bild"/);
assert.doesNotMatch(loader, /controlgroup/);
});
+21 -13
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -65,6 +66,7 @@ async function loadStore(filesystem = false) {
caches: memoryCaches(), crypto, URL, Response, Headers, Blob, Date
};
context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/offline-storage.js'), 'utf8'), context, { filename: path.join(root, 'public/js/offline-storage.js') });
return { context, directory, store: context.Wegwichtel.OfflineStore };
}
@@ -120,10 +122,10 @@ for (const filesystem of [false, true]) {
assert.equal(await (await store.match('/image.png?v=99')).text(), 'first');
assert.equal((await store.match('/image.png')).headers.get('Content-Type'), 'image/png');
const failed = await store.create();
await assert.rejects(store.put(failed, '/image.png', new Response('missing', { status: 404 })), /Download fehlgeschlagen/);
await assert.rejects(store.put(failed, '/image.png', new Response('missing', { status: 404 })), /Teil konnte nicht gespeichert werden/);
await store.remove(failed.id);
assert.equal(await (await store.match('/image.png')).text(), 'first');
assert.throws(() => store.key('https://outside.test/image.png'), /dieser App/);
assert.throws(() => store.key('https://outside.test/image.png'), /anderen Seiten/);
const second = await store.create();
await store.put(second, '/image.png', new Response('second'));
await store.commit(second);
@@ -150,6 +152,7 @@ async function workerHarness() {
skipWaiting: async () => {}, clients: { claim: async () => {} }
};
context.self = context;
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/offline-worker.js'), 'utf8'), context, { filename: path.join(root, 'public/offline-worker.js') });
async function request(url, options) {
let response;
@@ -222,6 +225,9 @@ async function managerHarness({ supported = true, saved = false } = {}) {
await store.put(stage, '/', new Response('old'));
await store.commit(stage);
}
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/offline-cache.js'), 'utf8'), context, { filename: path.join(root, 'public/js/offline-cache.js') });
return { context, store, elements, downloaded, manager: context.Wegwichtel.OfflineCache };
}
@@ -241,7 +247,7 @@ test('offline switch prepares all resources once, persists opt-in and clears onl
assert.equal(downloaded.length, 3, 'duplicate resources must not be stored twice');
assert.equal(elements.get('offline-cache-toggle').checked, true);
assert.equal(elements.get('offline-cache-refresh').hidden, false);
assert.match(elements.get('offline-cache-status').textContent, /3 Dateien sind offline verfügbar/);
assert.match(elements.get('offline-cache-status').textContent, /Wegwichtel ist für unterwegs bereit/);
assert.ok(await store.match('/image.png'));
await manager.disable();
assert.equal(await store.state(), null);
@@ -257,9 +263,9 @@ test('failed refresh preserves the old snapshot and download can be cancelled wi
assert.equal(elements.get('offline-cache-toggle').checked, true);
const fetchResource = context.fetch;
context.fetch = async (url, options) => url.endsWith('/image.png') ? new Response('missing', { status: 404 }) : fetchResource(url, options);
await assert.rejects(manager.download(), /Download fehlgeschlagen/);
await assert.rejects(manager.download(), /Teil konnte nicht gespeichert werden/);
assert.equal(await (await store.match('/')).text(), 'old');
assert.match(elements.get('offline-cache-status').textContent, /bisherige Offline-Stand/);
assert.match(elements.get('offline-cache-status').textContent, /vorher gespeichert hast/);
let started;
const downloading = new Promise(resolve => { started = resolve; });
context.fetch = (url, options) => new Promise((resolve, reject) => {
@@ -279,14 +285,14 @@ test('offline UI handles unsupported browsers and malformed manifests without en
const unsupported = await managerHarness({ supported: false });
await unsupported.manager.initialize();
assert.equal(unsupported.elements.get('offline-cache-toggle').disabled, true);
assert.match(unsupported.elements.get('offline-cache-status').textContent, /nicht verfügbar/);
assert.match(unsupported.elements.get('offline-cache-status').textContent, /nicht für unterwegs speichern/);
const setup = await managerHarness();
await setup.manager.initialize();
setup.context.fetch = async () => Response.json({ resources: [] });
await assert.rejects(setup.manager.download(), /Ungültige Offline/);
await assert.rejects(setup.manager.download(), /nicht für unterwegs geladen/);
assert.equal(await setup.store.state(), null);
setup.context.fetch = async () => new Response('error', { status: 500 });
await assert.rejects(setup.manager.download(), /Liste der Offline-Dateien/);
await assert.rejects(setup.manager.download(), /nicht für unterwegs geladen/);
assert.equal(setup.elements.get('offline-cache-toggle').checked, false);
});
@@ -300,7 +306,7 @@ for (const filesystem of [false, true]) {
const stage = await store.create();
await store.put(stage, `/api/routes/${id}`, Response.json({ id }));
await store.put(stage, `/api/routes/${id}/gpx`, new Response(`gpx${id}`));
await store.commit(stage, { id, name: `Route ${id}`, pois: [], points: [] });
await store.commit(stage, { id, name: `Strecke ${id}`, pois: [], points: [] });
}
assert.equal((await store.routes()).length, 2);
assert.deepEqual(await (await store.match('/api/routes/1?_=123')).json(), { id: 1 });
@@ -332,7 +338,7 @@ test('explicit route download stores only selected GPX/media and failed refresh
assert.equal(await (await store.match('/api/routes/7/gpx')).text(), 'bytes:' + origin + '/api/routes/7/gpx');
const previous = (await store.routes())[0].id;
context.fetch = async url => url === '/api/routes/7' ? Response.json(route) : new Response('full', { status: 507 });
await assert.rejects(manager.saveRoute(route), /Download fehlgeschlagen/);
await assert.rejects(manager.saveRoute(route), /Teil konnte nicht gespeichert werden/);
assert.equal((await store.routes())[0].id, previous);
await manager.disable();
assert.equal((await store.routes()).length, 1);
@@ -360,6 +366,7 @@ test('API starts from selected offline routes and an empty list for app-only sto
const app = await store.create();
await store.put(app, '/', new Response('shell'));
await store.commit(app);
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/api-client.js'), 'utf8'), context);
const api = context.Wegwichtel.Api;
assert.equal((await api.health()).offline, true);
@@ -388,15 +395,16 @@ test('storage gauge uses browser estimate, separates Local Storage and reports q
navigator: { storage: { estimate: async () => ({ usage: 95 * 1024 * 1024, quota: 100 * 1024 * 1024 }) } },
localStorage: { length: 1, key: () => 'image', getItem: () => 'x'.repeat(2400000) }
};
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/storage-usage.js'), 'utf8'), context);
const usage = context.Wegwichtel.StorageUsage;
await usage.refresh();
assert.equal(elements.get('[data-storage-site-meter]').value, 95 * 1024 * 1024);
assert.match(elements.get('[data-storage-site-text]').textContent, /95 %.*5 MiB verfügbar.*fast voll/);
assert.match(elements.get('[data-storage-local-text]').textContent, /Richtwert.*tatsächliche Grenze/);
assert.match(elements.get('[data-storage-site-text]').textContent, /95 %.*5,2 MB frei.*Fast voll/);
assert.match(elements.get('[data-storage-local-text]').textContent, /Richtwert.*wirklich hineinpasst/);
usage.reportError({ cause: { name: 'QuotaExceededError' } });
await usage.refresh();
assert.match(elements.get('[data-storage-warning]').textContent, /Speicher voll/);
assert.match(elements.get('[data-storage-warning]').textContent, /Platz ist voll/);
context.navigator.storage.estimate = async () => { throw new Error('Unavailable'); };
await usage.refresh();
assert.equal(elements.get('[data-storage-site-meter]').hidden, true);
+2 -1
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -6,7 +7,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
return readAppFile(relativePath);
}
test('presentation example uses real GPX Streckenpunkte and the existing upload API', async () => {
+2
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -41,6 +42,7 @@ async function loadModule() {
clearTimeout
};
context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(source, context, { filename: path.join(root, 'public/js/privacy-storage.js') });
return { storage: context.Wegwichtel.PrivacyStorage, localStorage };
}
+6 -5
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -6,7 +7,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
return readAppFile(relativePath);
}
test('main screen contains a persistent route control next to the information control', async () => {
@@ -24,7 +25,7 @@ test('main screen contains a persistent route control next to the information co
assert.match(html, /id="route-control-new"/);
assert.match(html, /id="info-button"/);
assert.match(html, /id="route-control-button"[\s\S]*?<img[^>]+route-management\.svg/);
assert.match(html, /id="editor-button"[^>]*>Strecken verwalten<\/button>/);
assert.match(html, /id="editor-button"[^>]*>Strecken bearbeiten<\/button>/);
assert.match(icon, /C[^\"]+/);
assert.match(icon, /stroke-dasharray/);
@@ -41,7 +42,7 @@ test('route selection separates activated server routes from local browser route
assert.match(html, /id="all-route-list"/);
assert.match(html, /id="local-route-list"/);
assert.match(html, />Lokale Strecken</);
assert.match(html, />Meine Entwürfe</);
assert.match(client, /activatedOnly:\s*['"]true['"]/);
assert.match(api, /publishedOnly:\s*req\.query\.activatedOnly\s*===\s*['"]true['"]/);
@@ -51,11 +52,11 @@ test('route selection separates activated server routes from local browser route
assert.match(app, /\[data-route-select\]/);
});
test('persistent route control displays the next unvisited Streckenpunkt and preserves progress when switching', async () => {
test('persistent route control displays the next unvisited Station and preserves progress when switching', async () => {
const app = await read('public/js/app.js');
assert.match(app, /function nextPoiMetric\(/);
assert.match(app, /Nächster Streckenpunkt:/);
assert.match(app, /Nächste Station:/);
assert.match(app, /state\.triggered\.has\(poi\.id\)/);
assert.match(app, /function suspendCurrentRoute\(/);
assert.match(app, /persistRouteProgress\(\)/);
+2 -1
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -6,7 +7,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
return readAppFile(relativePath);
}
test('default server socket uses the local Wegwichtel endpoint', async () => {
+49
View File
@@ -0,0 +1,49 @@
import { localizedHtml } from '../test-support/i18n.js';
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 vm from 'node:vm';
import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
async function messages() {
const context = {};
const filename = path.join(root, 'public/js/user-messages.js');
await loadI18n(context);
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
return context.Wegwichtel.UserMessages;
}
test('visible errors explain file, permission and storage problems without exposing technical replies', async () => {
const ui = await messages();
assert.match(ui.error({ message: 'Die GPX-Datei ist kein gültiges XML.' }), /keinen lesbaren Weg/);
assert.match(ui.error({ message: JSON.stringify({ message: 'POI nicht gefunden.', details: 'internal' }) }), /Diese Station wurde nicht gefunden/);
assert.match(ui.error({ responseJSON: { message: 'Freigegebene Strecken dürfen nur durch einen direkten lokalen Serveraufruf gelöscht werden.' } }), /Person löschen/);
assert.match(ui.error({ responseJSON: { message: 'Freigegebene Strecken können nicht mehr verändert werden.' } }), /nicht mehr verändern/);
assert.match(ui.error({ message: 'Nicht unterstütztes Bildformat: image/unknown' }), /anderes Bild/);
assert.match(ui.error({ message: 'Nicht unterstütztes Audioformat: audio/unknown' }), /andere Aufnahme/);
assert.match(ui.error({ message: 'POI-Koordinaten sind ungültig.' }), /Ort dieser Station/);
assert.match(ui.error({ status: 413 }), /zu groß/);
for (const status of [401, 403]) assert.match(ui.error({ status }), /Du darfst/);
assert.match(ui.error({ status: 500 }), /später/);
assert.match(ui.error({ name: 'QuotaExceededError' }), /Platz ist voll/);
assert.match(ui.error({ cause: { name: 'QuotaExceededError' } }), /Platz ist voll/);
assert.equal(ui.error(new Error('Gib deiner Station einen Namen.')), 'Gib deiner Station einen Namen.');
for (const error of [new Error('Failed to fetch'), { message: 'sequence muss eine Ganzzahl sein.' }, { message: 'HTTP 500 /internal/path' }, {}, 'undefined', '{"details":"raw"}']) {
assert.equal(ui.error(error, 'Bitte noch einmal versuchen.'), 'Bitte noch einmal versuchen.');
}
});
test('public help and spoken labels omit implementation terms and preserve meaningful storage guidance', async () => {
const html = await localizedHtml(await fs.readFile(path.join(root, 'public/index.html'), 'utf8'));
const text = html.replace(/<[^>]*>/g, ' ');
const labels = [...html.matchAll(/(?:aria-label|title|placeholder)="([^"]+)"/g)].map(match => match[1]).join(' ');
assert.doesNotMatch(text + labels, /\b(?:GPX|JSON|Cache|API|OPFS|IndexedDB|Local Storage|Server|Token|HTTP|JavaScripts|jQuery)\b/i);
assert.match(text, /Für unterwegs speichern/);
assert.match(text, /Weg als Datei herunterladen/);
assert.match(text, /Bilder und Tonaufnahmen sind in dieser Datei nicht enthalten/);
assert.match(text, /Fortschritt.*gelöscht/s);
assert.match(text, /Meine Entwürfe/);
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -6,7 +7,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
return readAppFile(relativePath);
}
test('current local jQuery and jQuery UI distributions are present', async () => {
+7
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -17,6 +18,8 @@ test('legacy vibration patterns are exposed as immutable constants', async () =>
Wegwichtel: {}
};
await loadI18n(context);
vm.runInNewContext(await source(), context);
const vibration = context.Wegwichtel.Vibration;
@@ -37,6 +40,8 @@ test('vibration wrapper starts named patterns and can cancel them', async () =>
Wegwichtel: {}
};
await loadI18n(context);
vm.runInNewContext(await source(), context);
const vibration = context.Wegwichtel.Vibration;
@@ -54,6 +59,8 @@ test('vibration wrapper fails harmlessly when the browser API is unavailable', a
Wegwichtel: {}
};
await loadI18n(context);
vm.runInNewContext(await source(), context);
const vibration = context.Wegwichtel.Vibration;