Files
Wegwichtel/test/app-route-control-runtime.test.js
T
Florian Zumpe aff88eba1b
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
Internationalization
2026-10-01 09:41:01 +02:00

570 lines
22 KiB
JavaScript

import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import vm from 'node:vm';
const root = path.resolve(import.meta.dirname, '..');
function tick() {
return new Promise(resolve => setTimeout(resolve, 0));
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
class FakeNode {
constructor(selector = '') {
this.selector = selector;
this.properties = new Map();
this.attributes = new Map();
this.dataValues = new Map();
this.styles = new Map();
this.classes = new Set();
this.appended = [];
this.textValue = '';
this.value = '';
this.autocompleteOptions = null;
this.handlers = new Map();
}
}
class FakeJQuery {
constructor(nodes, harness) {
this.nodes = nodes;
this.harness = harness;
this.length = nodes.length;
}
_each(callback) {
this.nodes.forEach(callback);
return this;
}
text(value) {
if (value === undefined) return this.nodes[0]?.textValue ?? '';
return this._each(node => { node.textValue = String(value); });
}
html(value) {
if (value === undefined) return escapeHtml(this.nodes[0]?.textValue ?? '');
return this._each(node => { node.textValue = String(value); });
}
prop(name, value) {
if (value === undefined) return this.nodes[0]?.properties.get(name);
return this._each(node => node.properties.set(name, value));
}
attr(name, value) {
if (typeof name === 'object' && name !== null) {
return this._each(node => {
Object.entries(name).forEach(([key, item]) => node.attributes.set(key, String(item)));
});
}
if (value === undefined) return this.nodes[0]?.attributes.get(name);
return this._each(node => node.attributes.set(name, String(value)));
}
removeAttr(name) {
return this._each(node => node.attributes.delete(name));
}
addClass(name) {
return this._each(node => node.classes.add(name));
}
removeClass(name) {
return this._each(node => node.classes.delete(name));
}
css(name, value) {
if (value === undefined) return this.nodes[0]?.styles.get(name);
return this._each(node => node.styles.set(name, value));
}
empty() {
return this._each(node => { node.appended = []; });
}
append(value) {
return this._each(node => node.appended.push(String(value)));
}
val(value) {
if (value === undefined) return this.nodes[0]?.value ?? '';
return this._each(node => { node.value = String(value); });
}
data(name, value) {
if (value === undefined) return this.nodes[0]?.dataValues.get(name);
return this._each(node => node.dataValues.set(name, value));
}
on(eventName, selector, handler) {
if (typeof selector === 'function') {
this.nodes.forEach(node => {
if (!node.handlers.has(eventName)) node.handlers.set(eventName, []);
node.handlers.get(eventName).push(selector);
});
return this;
}
this.harness.delegated.set(`${eventName}:${selector}`, handler);
return this;
}
trigger(eventName) {
this.nodes.forEach(node => {
for (const handler of node.handlers.get(eventName) || []) {
handler.call(node, { type: eventName });
}
});
const delegated = this.harness.delegated.get(`${eventName}:${this.nodes[0]?.selector}`);
if (delegated) delegated.call(this.nodes[0], { type: eventName, preventDefault() {} });
return this;
}
autocomplete(options) {
return this._each(node => { node.autocompleteOptions = options; });
}
find(selector) {
return this.harness.$(`${this.nodes[0]?.selector || ''} ${selector}`);
}
closest(selector) {
const node = this.nodes[0];
if (!node) return new FakeJQuery([], this.harness);
if (selector === '.route-control' && node.classes.has('route-control')) return this;
return new FakeJQuery([], this.harness);
}
}
function createHarness() {
const document = new EventTarget();
Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true });
const nodes = new Map();
const delegated = new Map();
const documentNode = new FakeNode('document');
const globalListeners = new Map();
const harness = {
delegated,
document,
globalListeners,
node(selector) {
if (!nodes.has(selector)) nodes.set(selector, new FakeNode(selector));
return nodes.get(selector);
},
$(selector) {
if (selector === document) return new FakeJQuery([documentNode], harness);
if (selector instanceof FakeNode) return new FakeJQuery([selector], harness);
if (typeof selector === 'string' && selector.startsWith('<')) {
return new FakeJQuery([new FakeNode(selector)], harness);
}
return new FakeJQuery([harness.node(String(selector))], harness);
},
async activate(selector, target, pointer = { isPrimary: true, pointerType: 'touch', button: 0 }) {
const handler = delegated.get(`pointerup:${selector}`);
assert.ok(handler, `missing pointer handler for ${selector}`);
const event = {
originalEvent: pointer,
preventDefault() {}
};
handler.call(target, event);
await tick();
await tick();
},
async key(selector, target, key) {
const handler = delegated.get(`keydown:${selector}`);
assert.ok(handler, `missing key handler for ${selector}`);
handler.call(target, { key, preventDefault() {} });
await tick();
await tick();
},
fireDocumentPointer(target) {
const handlers = documentNode.handlers.get('pointerup') || [];
handlers.forEach(handler => handler({ target }));
},
fireDocumentKey(key) {
const handlers = documentNode.handlers.get('keydown') || [];
handlers.forEach(handler => handler({ key }));
}
};
return harness;
}
function route(id, name, source = 'server') {
return {
id,
name,
source,
local: source === 'local',
schoolName: source === 'local' ? 'Lokal gespeichert' : 'Grundschule Herpf',
description: `${name} Beschreibung`,
start: { lat: 50, lon: 10 },
distanceM: 1000,
elevationGainM: 15,
points: [
{ lat: 50, lon: 10 },
{ lat: 50.001, lon: 10.001 }
],
pois: [
{ id: 1, title: `${name} Start`, description: 'Los', lat: 50, lon: 10, sequence: 0, triggerRadiusM: 25, images: [], audioUrl: null },
{ id: 2, title: `${name} Ziel`, description: 'Ziel', lat: 50.001, lon: 10.001, sequence: 1, triggerRadiusM: 25, images: [], audioUrl: null }
]
};
}
async function loadApp() {
const sourcePath = path.join(root, 'public/js/app.js');
const source = await fs.readFile(sourcePath, 'utf8');
const harness = createHarness();
const serverRoute = route('server-1', 'Serverweg');
let localRoute = route('local-1', 'Lokaler Weg', 'local');
const steps = [];
const savedStates = [];
const editor = { opened: 0, refreshed: 0 };
const apiCalls = [];
harness.node('#route-control-dropdown').properties.set('hidden', true);
harness.node('#route-search').value = '';
const context = {
document: harness.document,
jQuery: harness.$,
Wegwichtel: {
Api: {
async health() { apiCalls.push('health'); return { ok: true }; },
async routes() { apiCalls.push('routes'); return { routes: [serverRoute] }; },
async route(id) { apiCalls.push(`route:${id}`); return serverRoute; }
},
AudioPlayer: { load() {}, pause() {}, unload() {} },
Config: { routeRadiusKm: 5 },
Distance: {
bearing() { return 45; },
format(value) { return `${Math.round(value)} m`; },
meters(left, right) {
return Math.hypot((right.lat - left.lat) * 111000, (right.lon - left.lon) * 70000);
},
normalizeDegrees(value) { return ((value % 360) + 360) % 360; }
},
EditorUi: {
openNewLocalRoute() { editor.opened += 1; },
refresh() { editor.refreshed += 1; }
},
Geo: {
async current() { throw new Error('GPS verweigert'); },
start() {},
stop() {}
},
LocalRoutes: {
getAppRoute(id) {
if (String(id) !== 'local-1' || !localRoute) throw new Error('lokal nicht gefunden');
return structuredClone(localRoute);
},
listAppRoutes() {
return localRoute ? [structuredClone(localRoute)] : [];
}
},
Orientation: { async start() { return false; }, stop() {} },
PrivacyStorage: {
activateRoute() { return null; },
saveRouteState(...args) { savedStates.push(args); }
},
Slideshow: { show() {} },
Vibration: { Patterns: { ACTIVE_POI: [100] }, start() {}, stop() {} }
},
alert() {},
clearTimeout,
history: {
state: null,
back() {},
pushState(state) { this.state = state; },
replaceState(state) { this.state = state; }
},
scrollTo() {},
setTimeout,
structuredClone,
globalThis: null
};
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 {
apiCalls,
context,
editor,
harness,
savedStates,
serverRoute,
setLocalRoute(value) { localRoute = value; },
steps
};
}
test('route control runtime loads server/local routes and supports switching without geolocation', async () => {
const setup = await loadApp();
const { context, harness, apiCalls, editor } = setup;
const initContext = { markStep(name, value) { setup.steps.push([name, value]); } };
await context.Wegwichtel.App.initialize(initContext);
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 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'));
const controlButton = new FakeNode('#route-control-button');
await harness.activate('#route-control-button', controlButton);
assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), false);
assert.equal(harness.node('#route-control-button').attributes.get('aria-expanded'), 'true');
const newButton = new FakeNode('#route-control-new');
await harness.activate('.route-control-actions #route-control-new', newButton);
assert.equal(editor.opened, 1);
assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), true);
const localButton = new FakeNode('local-select');
localButton.dataValues.set('route-source', 'local');
localButton.dataValues.set('route-id', 'local-1');
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ächste Station: Lokaler Weg Start/);
assert.equal(harness.node('#route-page').attributes.has('hidden'), false);
const serverButton = new FakeNode('server-select');
serverButton.dataValues.set('route-source', 'server');
serverButton.dataValues.set('route-id', 'server-1');
await harness.key('[data-route-select]', serverButton, 'Enter');
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ächste Station: Serverweg Start/);
});
test('local-route change refreshes an active draft and handles its later removal', async () => {
const setup = await loadApp();
const { context, harness, editor } = setup;
await context.Wegwichtel.App.initialize({ markStep() {} });
const localButton = new FakeNode('local-select');
localButton.dataValues.set('route-source', 'local');
localButton.dataValues.set('route-id', 'local-1');
await harness.activate('[data-route-select]', localButton);
const changed = route('local-1', 'Lokaler Weg geändert', 'local');
setup.setLocalRoute(changed);
const updateEvent = new Event('wegwichtel:local-routes-changed');
Object.defineProperty(updateEvent, 'detail', { value: { id: 'local-1' } });
harness.document.dispatchEvent(updateEvent);
await tick();
assert.equal(harness.node('#route-control-title').textValue, 'Lokaler Weg geändert');
assert.equal(editor.refreshed, 1);
setup.setLocalRoute(null);
const removeEvent = new Event('wegwichtel:local-routes-changed');
Object.defineProperty(removeEvent, 'detail', { value: { id: 'local-1' } });
harness.document.dispatchEvent(removeEvent);
await tick();
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);
});
test('route control closes on escape and reports local storage failures', async () => {
const setup = await loadApp();
const { context, harness } = setup;
await context.Wegwichtel.App.initialize({ markStep() {} });
await harness.activate('#route-control-button', new FakeNode('toggle'));
assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), false);
harness.fireDocumentKey('Escape');
assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), true);
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('Deine Entwürfe konnten nicht geöffnet werden'));
});
async function selectServer(setup) {
const button = new FakeNode('server-select');
button.dataValues.set('route-id', 'server-1');
await setup.harness.activate('[data-route-select]', button);
}
function observeSensors(setup) {
const calls = { stopped: 0, vibrations: 0, audio: [], slides: [] };
const ns = setup.context.Wegwichtel;
ns.Geo.start = (position, error) => { calls.position = position; calls.error = error; };
ns.Geo.stop = () => { calls.stopped += 1; };
ns.Orientation.start = async heading => { calls.heading = heading; return true; };
ns.Orientation.stop = () => { calls.stopped += 1; };
ns.Vibration.start = () => { calls.vibrations += 1; };
ns.AudioPlayer.load = url => calls.audio.push(url);
ns.Slideshow.show = images => calls.slides.push(images);
return calls;
}
test('tracking triggers each POI once, records media progress and pauses/resumes/stops sensors', async () => {
const setup = await loadApp();
const { context, harness, savedStates } = setup;
const calls = observeSensors(setup);
await context.Wegwichtel.App.initialize({ markStep() {} });
await selectServer(setup);
await harness.activate('#start-route', new FakeNode('start'));
assert.equal(harness.node('#pause-route').properties.get('disabled'), false);
calls.position({ lat: 50, lon: 10, accuracy: 5, heading: 90, speed: 1 });
assert.equal(harness.node('#poi-title').textValue, 'Serverweg Start');
assert.equal(calls.vibrations, 1);
assert.match(harness.node('#navigation-mode').textValue, /Serverweg Ziel/);
calls.position({ lat: 50, lon: 10, accuracy: 5 });
assert.equal(calls.vibrations, 1);
harness.$('#poi-audio').trigger('play');
assert.deepEqual(Array.from(savedStates.at(-1)[5]), [1]);
calls.heading(350);
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, '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, /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 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, 'Strecke wählen');
assert.equal(harness.node('#navigation-footer').properties.get('hidden'), true);
assert.ok(calls.stopped >= 4);
});
test('saved route resumes paused, manual POI viewing and lifecycle events persist progress', async () => {
const setup = await loadApp();
const { context, harness, savedStates } = setup;
context.Wegwichtel.PrivacyStorage.activateRoute = () => ({
routeState: 'running', routeProgressIndex: 0,
visitedPoiIds: [1], viewedPoiIds: [1], playedAudioPoiIds: [1]
});
const calls = observeSensors(setup);
await context.Wegwichtel.App.initialize({ markStep() {} });
await selectServer(setup);
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, ' ');
assert.equal(harness.node('#poi-title').textValue, 'Serverweg Ziel');
assert.deepEqual(Array.from(savedStates.at(-1)[4]), [1, 2]);
await harness.activate('#start-route', new FakeNode('resume'));
calls.position({ lat: 50, lon: 10, accuracy: 5 });
assert.equal(calls.vibrations, 0);
harness.globalListeners.get('pagehide')();
harness.document.visibilityState = 'hidden';
harness.document.dispatchEvent(new Event('visibilitychange'));
const enabled = new Event('wegwichtel:progress-storage-changed');
Object.defineProperty(enabled, 'detail', { value: { enabled: true } });
const before = savedStates.length;
harness.document.dispatchEvent(enabled);
assert.equal(savedStates.length, before + 1);
harness.globalListeners.get('popstate')({ state: { page: '#route-page' } });
assert.equal(harness.node('#route-page').attributes.has('hidden'), false);
await harness.activate('#stop-route', new FakeNode('stop'));
});
test('tracking handles an empty route and orientation permission resolving after pause', async () => {
const setup = await loadApp();
const { context, harness } = setup;
setup.serverRoute.pois = [];
setup.serverRoute.points = [];
const calls = observeSensors(setup);
let grant;
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 Stationen/);
await harness.activate('#start-route', new FakeNode('start'));
calls.position({ lat: 50, lon: 10, accuracy: 10 });
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, 'Strecke pausiert');
await harness.activate('#stop-route', new FakeNode('stop'));
});
test('route recommendations and accent-insensitive search use location without changing selection', async () => {
const setup = await loadApp();
const { context, harness } = setup;
context.Wegwichtel.Geo.current = async () => ({ lat: 50, lon: 10, accuracy: 8 });
const far = route('far', 'Äpfelweg');
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 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();
position.lat = 0;
assert.equal(context.Wegwichtel.App.currentPosition().lat, 50);
harness.$('#route-search').val('apfel').trigger('input');
assert.match(harness.node('#all-routes-summary').textValue, /1 von 2/);
let suggestions;
const autocomplete = harness.node('#route-search').autocompleteOptions;
autocomplete.source({ term: 'apfel' }, items => { suggestions = items; });
assert.equal(suggestions[0].value, 'Äpfelweg');
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 passende Strecke gefunden/);
await harness.activate('#clear-route-search', new FakeNode('clear'));
assert.equal(harness.node('#route-search').value, '');
});
test('invalid API responses reject initialization and route selection errors restore the button', async () => {
const setup = await loadApp();
const { context, harness } = setup;
context.Wegwichtel.Api.routes = async () => ({ routes: null });
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' } }; };
const button = new FakeNode('missing');
button.dataValues.set('route-id', 'missing');
await harness.activate('[data-route-select]', button, { isPrimary: false });
assert.equal(errors.length, 0);
await harness.activate('[data-route-select]', button, { pointerType: 'mouse', button: 2 });
assert.equal(errors.length, 0);
await harness.key('[data-route-select]', button, 'Escape');
assert.equal(errors.length, 0);
await harness.activate('[data-route-select]', button);
assert.deepEqual(errors, ['Strecke entfernt']);
assert.equal(button.properties.get('disabled'), false);
});