Internationalization
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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' } }; };
|
||||
|
||||
Vendored
+3
-1
@@ -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());
|
||||
|
||||
Vendored
+2
-1
@@ -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 () => {
|
||||
|
||||
Vendored
+8
-7
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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
@@ -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 & 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] }
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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\(\)/);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user