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('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } 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; 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 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.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ächster Streckenpunkt: 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ächster Streckenpunkt: 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, 'Route 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('LocalStorage nicht lesbar')); }); 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, 'GPS-Fehler: Signal verloren'); await harness.activate('#pause-route', new FakeNode('pause')); assert.match(harness.node('#tracking-status').textValue, /pausiert/); 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('#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('#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, /Gespeicherter/); assert.equal(harness.node('#start-route').attributes.get('title'), 'Route 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 Streckenpunkte/); 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'); 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'); 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 ermittelt/); 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 Route entspricht/); 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() {} }), /gültige Routenliste/); 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); });