import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import vm from 'node:vm'; import { loadI18n } from '../test-support/i18n.js'; const sourcePath = new URL('../public/js/route-recorder.js', import.meta.url); const tick = () => new Promise(resolve => setImmediate(resolve)); async function setup() { class Element extends EventTarget { constructor() { super(); this.children = []; this.value = ''; this.attributes = {}; } setAttribute(key, value) { this.attributes[key] = value; } appendChild(child) { this.children.push(child); } replaceChildren() { this.children = []; } } const ids = ['route-recording-control', 'route-recording-status', 'route-recording-pause', 'route-recording-stop', 'editor-recorded-positions', 'editor-add-position', 'route-recording-toggle']; const nodes = new Map(ids.map(id => [id, new Element()])); const document = new EventTarget(); document.readyState = 'complete'; document.getElementById = id => nodes.get(id); document.createElement = () => new Element(); const intervals = new Map(); const packages = new Map([['draft', { id: 'draft', route: { name: 'Entwurf' }, gpx: '[]', pois: [] }]]); const context = { document, Date, console, setInterval(callback, delay) { assert.equal(delay, 120000); intervals.set(1, callback); return 1; }, clearInterval(id) { intervals.delete(id); }, Wegwichtel: { Geo: { current: async () => ({ lat: 50, lon: 10, timestamp: Date.now() }) }, LocalRoutes: { get: id => packages.get(id), save: value => packages.set(value.id, value), appendTrackPoint: (gpx, position) => JSON.stringify([...JSON.parse(gpx), position]), recordedPositions: JSON.parse, replaceRecordedPositions: (_gpx, positions) => JSON.stringify(positions) } } }; context.globalThis = context; await loadI18n(context); vm.runInNewContext(await fs.readFile(sourcePath, 'utf8'), context, { filename: sourcePath.pathname }); return { context, nodes, intervals, packages, recorder: context.Wegwichtel.RouteRecorder }; } function activate(node, type = 'pointerup', key) { const event = new Event(type, { cancelable: true }); if (key) Object.defineProperty(event, 'key', { value: key }); node.dispatchEvent(event); } test('recording persists every two minutes during an editing session, pauses and finishes', async () => { const { recorder, packages, intervals, nodes } = await setup(); recorder.start('draft'); await tick(); assert.equal(recorder.status, 'recording'); assert.equal(JSON.parse(packages.get('draft').gpx).length, 1); await intervals.get(1)(); assert.equal(JSON.parse(packages.get('draft').gpx).length, 2); activate(nodes.get('route-recording-pause')); assert.equal(recorder.status, 'paused'); assert.equal(intervals.size, 0); activate(nodes.get('route-recording-pause'), 'keydown', 'Enter'); await tick(); assert.equal(recorder.status, 'recording'); activate(nodes.get('route-recording-stop')); assert.equal(recorder.status, 'idle'); assert.equal(intervals.size, 0); assert.equal(nodes.get('route-recording-control').hidden, true); }); test('GPS failures remain as empty samples and late results after stopping are discarded', async () => { const { context, recorder, packages, intervals } = await setup(); context.Wegwichtel.Geo.current = async () => { throw new Error('GPS fehlt'); }; recorder.start('draft'); await tick(); const samples = JSON.parse(packages.get('draft').gpx); assert.equal(samples.length, 1); assert.equal(samples[0].lat, undefined); let finish; context.Wegwichtel.Geo.current = () => new Promise(resolve => { finish = resolve; }); const pending = intervals.get(1)(); recorder.stop(); finish({ lat: 51, lon: 11 }); await pending; assert.equal(JSON.parse(packages.get('draft').gpx).length, 1); }); test('storage errors pause recording and deleted drafts cannot be recreated by the recorder', async () => { const { context, recorder, packages, nodes } = await setup(); context.Wegwichtel.LocalRoutes.save = () => { throw new Error('voll'); }; recorder.start('draft'); await tick(); assert.equal(recorder.status, 'paused'); assert.match(nodes.get('route-recording-status').textContent, /Schaffe Platz/); packages.delete('draft'); context.document.dispatchEvent(new Event('wegwichtel:local-routes-changed')); assert.equal(recorder.status, 'idle'); recorder.resume(); assert.equal(recorder.status, 'idle'); }); test('missing recorded locations can be filled manually while newer samples remain intact', async () => { const { recorder, nodes } = await setup(); const initial = JSON.stringify([{ lat: null, lon: null, timestamp: '2026-10-03T10:00:00Z' }]); recorder.renderPoints(initial); const row = nodes.get('editor-recorded-positions').children[0]; row.children[2].value = '50'; row.children[4].value = '10'; const latest = JSON.stringify([...JSON.parse(initial), { lat: 51, lon: 11 }]); const result = JSON.parse(recorder.editGpx(latest, 'Entwurf')); assert.equal(result[0].lat, 50); assert.equal(result[1].lat, 51); row.children[4].value = ''; assert.throws(() => recorder.editGpx(latest, 'Entwurf')); recorder.renderPoints('[]'); activate(nodes.get('editor-add-position')); assert.equal(JSON.parse(recorder.editGpx('[]', 'Entwurf')).length, 1); }); test('ending editing stops sampling, reopening permits recording and a fresh app remains inactive', async () => { const { recorder, intervals, packages, nodes } = await setup(); assert.equal(recorder.status, 'idle'); recorder.beginEditing('draft'); await tick(); assert.equal(intervals.size, 0); nodes.get('route-recording-toggle').checked = true; nodes.get('route-recording-toggle').dispatchEvent(new Event('change')); await tick(); assert.equal(intervals.size, 1); recorder.pause(); recorder.beginEditing('draft'); assert.equal(recorder.status, 'paused'); recorder.endEditing(); assert.equal(recorder.status, 'idle'); assert.equal(recorder.routeId, null); assert.equal(intervals.size, 0); assert.equal(JSON.parse(packages.get('draft').gpx).length, 1); recorder.beginEditing('draft'); await tick(); assert.equal(recorder.status, 'idle'); assert.equal(nodes.get('route-recording-toggle').checked, false); const fresh = await setup(); assert.equal(fresh.recorder.status, 'idle'); assert.equal(fresh.intervals.size, 0); }); test('the switch can prepare a new draft and late GPS from an older session is ignored', async () => { const { context, recorder, nodes, packages, intervals } = await setup(); const answers = []; context.Wegwichtel.Geo.current = () => new Promise(resolve => answers.push(resolve)); const toggle = nodes.get('route-recording-toggle'); toggle.checked = true; toggle.dispatchEvent(new Event('change')); assert.equal(answers.length, 0); recorder.beginEditing('draft'); assert.equal(answers.length, 1); toggle.checked = false; toggle.dispatchEvent(new Event('change')); toggle.checked = true; toggle.dispatchEvent(new Event('change')); assert.equal(answers.length, 2); answers[1]({ lat: 52, lon: 12 }); await tick(); answers[0]({ lat: 50, lon: 10 }); await tick(); assert.equal(JSON.parse(packages.get('draft').gpx).length, 1); assert.equal(JSON.parse(packages.get('draft').gpx)[0].lat, 52); context.Wegwichtel.Geo.current = async () => ({ lat: 53, lon: 13 }); context.document.visibilityState = 'visible'; context.document.dispatchEvent(new Event('visibilitychange')); await tick(); assert.equal(JSON.parse(packages.get('draft').gpx).length, 2); recorder.endEditing(); assert.equal(intervals.size, 0); assert.equal(toggle.checked, false); }); test('each crosshair fills only its own point without starting recording or saving', async () => { const { recorder, nodes, packages } = await setup(); recorder.renderPoints('[{"lat":1,"lon":2},{"lat":3,"lon":4}]'); const [first, second] = nodes.get('editor-recorded-positions').children; second.children[5].dispatchEvent(new Event('click')); await tick(); assert.equal(first.children[2].value, '1'); assert.equal(second.children[2].value, '50'); assert.equal(second.children[4].value, '10'); assert.equal(recorder.status, 'idle'); assert.equal(packages.get('draft').gpx, '[]'); assert.match(second.children[6].textContent, /Speichere/); }); test('manual location errors preserve coordinates and stale or duplicate requests cannot overwrite points', async () => { const { context, recorder, nodes } = await setup(); recorder.renderPoints('[{"lat":1,"lon":2}]'); const row = nodes.get('editor-recorded-positions').children[0]; context.Wegwichtel.Geo.current = async () => { throw new Error('denied'); }; row.children[5].dispatchEvent(new Event('click')); await tick(); assert.equal(row.children[2].value, '1'); assert.match(row.children[6].textContent, /nicht gefunden/); let finish; let requests = 0; context.Wegwichtel.Geo.current = () => { requests++; return new Promise(resolve => { finish = resolve; }); }; row.children[5].dispatchEvent(new Event('click')); row.children[5].dispatchEvent(new Event('click')); assert.equal(requests, 1); recorder.renderPoints('[]'); finish({ lat: 51, lon: 11 }); await tick(); assert.equal(row.children[2].value, '1'); assert.equal(row.children[5].disabled, false); });