From 6ecffb8137c7e4bfd573cef3870aa43d8763433c Mon Sep 17 00:00:00 2001 From: Florian Zumpe Date: Wed, 30 Sep 2026 09:59:48 +0200 Subject: [PATCH] update tests --- test/app-route-control-runtime.test.js | 160 +++++++++++++++++++++++++ test/browser-services.test.js | 156 ++++++++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 test/browser-services.test.js diff --git a/test/app-route-control-runtime.test.js b/test/app-route-control-runtime.test.js index 97c175a..c13c17f 100644 --- a/test/app-route-control-runtime.test.js +++ b/test/app-route-control-runtime.test.js @@ -402,3 +402,163 @@ test('route control closes on escape and reports local storage failures', async 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); +}); diff --git a/test/browser-services.test.js b/test/browser-services.test.js new file mode 100644 index 0000000..2589962 --- /dev/null +++ b/test/browser-services.test.js @@ -0,0 +1,156 @@ +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'; + +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; + vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename }); + return context; +} + +const rawPosition = { + coords: { latitude: 50, longitude: 10, accuracy: 5, altitude: null, heading: 90, speed: 2 }, + timestamp: 1234 +}; + +test('GPS normalizes positions, forwards errors and clears replaced watches including watch zero', async () => { + const cleared = []; + let watchSuccess; + let watchFailure; + let currentOptions; + let watchOptions; + let watch = 0; + const error = new Error('GPS verweigert'); + const service = { + getCurrentPosition(success, failure, options) { currentOptions = options; success(rawPosition); }, + watchPosition(success, failure, options) { + watchSuccess = success; watchFailure = failure; watchOptions = options; + return watch++; + }, + clearWatch(id) { cleared.push(id); } + }; + const { Wegwichtel: ns } = await load('geolocation.js', { navigator: { geolocation: service } }); + const current = await ns.Geo.current(); + assert.equal(current.lat, 50); + assert.equal(current.lon, 10); + assert.equal(current.timestamp, 1234); + assert.equal(current.altitude, null); + assert.equal(currentOptions.timeout, 7000); + assert.equal(currentOptions.maximumAge, 30000); + service.getCurrentPosition = (success, failure) => failure(error); + await assert.rejects(ns.Geo.current(), value => value === error); + const positions = []; + const errors = []; + ns.Geo.start(value => positions.push(value), value => errors.push(value)); + watchSuccess(rawPosition); + watchFailure(error); + assert.equal(positions[0].heading, 90); + assert.equal(positions[0].speed, 2); + assert.equal(watchOptions.enableHighAccuracy, true); + assert.equal(watchOptions.timeout, 20000); + assert.deepEqual(errors, [error]); + ns.Geo.start(() => {}); + assert.deepEqual(cleared, [0]); + ns.Geo.stop(); + ns.Geo.stop(); + assert.deepEqual(cleared, [0, 1]); +}); + +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/); + const errors = []; + ns.Geo.start(() => assert.fail('unexpected position'), error => errors.push(error)); + assert.match(errors[0].message, /keine Geolokalisierung/); + ns.Geo.start(() => {}); + ns.Geo.stop(); + } +}); + +test('orientation permission denial and rejection do not subscribe to device events', async () => { + for (const DeviceOrientationEvent of [undefined, + { requestPermission: async () => 'denied' }, + { requestPermission: async () => { throw new Error('Permission unavailable'); } } + ]) { + const context = await load('orientation.js', { + DeviceOrientationEvent, + addEventListener() { assert.fail('unexpected orientation listener'); } + }); + assert.equal(await context.Wegwichtel.Orientation.start(() => {}), false); + context.Wegwichtel.Orientation.stop(); + } +}); + +test('orientation normalizes compass/absolute headings, ignores relative readings and removes old listeners', async () => { + for (const DeviceOrientationEvent of [{}, { requestPermission: async () => 'granted' }]) { + const listeners = new Map(); + const removed = []; + const context = await load('orientation.js', { + DeviceOrientationEvent, + addEventListener(name, listener) { listeners.set(name, listener); }, + removeEventListener(name, listener) { + assert.equal(listeners.get(name), listener); + listeners.delete(name); removed.push(name); + } + }); + const values = []; + const ns = context.Wegwichtel; + assert.equal(await ns.Orientation.start(value => values.push(value)), true); + listeners.get('deviceorientation')({ webkitCompassHeading: -10 }); + listeners.get('deviceorientation')({ absolute: true, alpha: 90 }); + listeners.get('deviceorientationabsolute')({ type: 'deviceorientationabsolute', alpha: 370 }); + listeners.get('deviceorientation')({ alpha: 90, absolute: false }); + listeners.get('deviceorientation')({ absolute: true, alpha: Number.NaN }); + assert.deepEqual(values, [350, 270, 350]); + await ns.Orientation.start(value => values.push(value)); + assert.equal(removed.length, 2); + ns.Orientation.stop(); + assert.equal(listeners.size, 0); + ns.Orientation.stop(); + assert.equal(removed.length, 4); + } +}); + +test('API requests restrict listing to released routes, encode identifiers and propagate failures', async () => { + const requests = []; + const context = await load('api-client.js', { + URLSearchParams, + jQuery: { ajax(options) { requests.push(options); return Promise.resolve({ ok: true }); } } + }); + const api = context.Wegwichtel.Api; + await api.health(); + await api.routes(null); + await api.routes({ lat: 0, lon: -10 }); + await api.route('a/b ?'); + assert.deepEqual(requests.map(value => value.url), [ + '/api/health', '/api/routes?activatedOnly=true', + '/api/routes?activatedOnly=true&lat=0&lon=-10&radiusKm=5', '/api/routes/a%2Fb%20%3F' + ]); + for (const request of requests) { + assert.equal(request.method, 'GET'); + assert.equal(request.dataType, 'json'); + assert.equal(request.cache, false); + } + const error = new Error('Server nicht erreichbar'); + context.jQuery.ajax = () => Promise.reject(error); + await assert.rejects(api.health(), value => value === error); +}); + +test('distance calculates cardinal bearings, geographic distance and the meter/kilometer boundary', async () => { + const { Wegwichtel: { Distance } } = await load('distance.js'); + const origin = { lat: 0, lon: 0 }; + assert.equal(Distance.meters(origin, origin), 0); + assert.ok(Math.abs(Distance.meters(origin, { lat: 1, lon: 0 }) - 111195) < 1); + assert.equal(Distance.bearing(origin, { lat: 1, lon: 0 }), 0); + assert.equal(Distance.bearing(origin, { lat: 0, lon: 1 }), 90); + assert.equal(Distance.bearing(origin, { lat: -1, lon: 0 }), 180); + assert.equal(Distance.bearing(origin, { lat: 0, lon: -1 }), 270); + assert.equal(Distance.normalizeDegrees(-370), 350); + assert.equal(Distance.format(999), '999 m'); + assert.equal(Distance.format(1000), '1.0 km'); +});