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, '..'); const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1'; const POI_NAMESPACE = 'urn:wegwichtel:poi'; function escapeXml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function decodeXml(value) { return String(value) .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll('&', '&'); } class XmlElement { constructor(namespaceURI, tagName) { this.namespaceURI = namespaceURI; this.tagName = tagName; this.localName = tagName.includes(':') ? tagName.split(':').at(-1) : tagName; this.attributes = new Map(); this.children = []; this.parentNode = null; this._text = ''; } get firstChild() { return this.children[0] || null; } get textContent() { return this._text + this.children.map(child => child.textContent).join(''); } set textContent(value) { this.children = []; this._text = String(value ?? ''); } appendChild(child) { child.parentNode = this; this.children.push(child); return child; } insertBefore(child, reference) { child.parentNode = this; const index = reference ? this.children.indexOf(reference) : -1; if (index < 0) this.children.push(child); else this.children.splice(index, 0, child); return child; } before(...nodes) { if (!this.parentNode) return; const index = this.parentNode.children.indexOf(this); if (index < 0) return; for (const node of nodes) node.parentNode = this.parentNode; this.parentNode.children.splice(index, 0, ...nodes); } remove() { if (!this.parentNode) return; this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; } setAttribute(name, value) { this.attributes.set(name, String(value)); } getAttribute(name) { return this.attributes.has(name) ? this.attributes.get(name) : null; } getElementsByTagName(name) { const result = []; for (const child of this.children) { if (name === '*' || child.tagName === name || child.localName === name) result.push(child); result.push(...child.getElementsByTagName(name)); } return result; } getElementsByTagNameNS(namespaceURI, localName) { const result = []; for (const child of this.children) { const namespaceMatches = namespaceURI === '*' || child.namespaceURI === namespaceURI; if (namespaceMatches && child.localName === localName) result.push(child); result.push(...child.getElementsByTagNameNS(namespaceURI, localName)); } return result; } } class XmlDocument { constructor(rootElement = null) { this.documentElement = rootElement; } querySelector(selector) { return selector === 'parsererror' ? null : null; } createElementNS(namespaceURI, tagName) { return new XmlElement(namespaceURI, tagName); } getElementsByTagName(name) { if (!this.documentElement) return []; const own = name === '*' || this.documentElement.tagName === name || this.documentElement.localName === name ? [this.documentElement] : []; return [...own, ...this.documentElement.getElementsByTagName(name)]; } getElementsByTagNameNS(namespaceURI, localName) { if (!this.documentElement) return []; const ownNamespaceMatches = namespaceURI === '*' || this.documentElement.namespaceURI === namespaceURI; const own = ownNamespaceMatches && this.documentElement.localName === localName ? [this.documentElement] : []; return [...own, ...this.documentElement.getElementsByTagNameNS(namespaceURI, localName)]; } } class FakeDOMParser { parseFromString(xml) { const tokens = String(xml) .replace(/<\?xml[\s\S]*?\?>/gi, '') .replace(//g, '') .match(/<[^>]+>|[^<]+/g) || []; const stack = []; let rootElement = null; for (const token of tokens) { if (!token.trim()) continue; if (token.startsWith('$/.test(token); const match = token.match(/^<\s*([^\s/>]+)([\s\S]*?)\/?\s*>$/); if (!match) return new XmlDocument(new XmlElement('', 'parsererror')); const [, tagName, attributeText] = match; const namespaceURI = tagName.startsWith('wegwichtel:') ? POI_NAMESPACE : GPX_NAMESPACE; const element = new XmlElement(namespaceURI, tagName); const attributePattern = /([^\s=]+)\s*=\s*(["'])(.*?)\2/g; for (const attribute of attributeText.matchAll(attributePattern)) { element.setAttribute(attribute[1], decodeXml(attribute[3])); } if (stack.length) stack.at(-1).appendChild(element); else if (!rootElement) rootElement = element; else return new XmlDocument(new XmlElement('', 'parsererror')); if (!selfClosing) stack.push(element); } if (!rootElement || stack.length) return new XmlDocument(new XmlElement('', 'parsererror')); return new XmlDocument(rootElement); } } function serializeElement(element) { const attributes = [...element.attributes] .map(([name, value]) => ` ${name}="${escapeXml(value)}"`) .join(''); const content = escapeXml(element._text) + element.children.map(serializeElement).join(''); return `<${element.tagName}${attributes}>${content}`; } class FakeXMLSerializer { serializeToString(document) { return serializeElement(document.documentElement); } } class FakeStorage { constructor() { this.values = new Map(); this.failNext = false; } getItem(key) { return this.values.has(key) ? this.values.get(key) : null; } setItem(key, value) { if (this.failNext) { this.failNext = false; const error = new Error('quota'); error.name = 'QuotaExceededError'; throw error; } this.values.set(String(key), String(value)); } removeItem(key) { this.values.delete(String(key)); } } class FakeCustomEvent extends Event { constructor(type, options = {}) { super(type); this.detail = options.detail; } } class FakeFileReader extends EventTarget { readAsText(file) { queueMicrotask(() => { this.result = file.content || ''; this.dispatchEvent(new Event('load')); }); } readAsDataURL(file) { queueMicrotask(() => { const type = file.type || 'application/octet-stream'; this.result = file.invalidReaderResult ? { type } : `data:${type};base64,VEVTVA==`; this.dispatchEvent(new Event('load')); }); } } async function loadLocalRoutes(options = {}) { const sourcePath = path.join(root, 'public/js/local-routes.js'); const source = await fs.readFile(sourcePath, 'utf8'); const localStorage = new FakeStorage(); const document = new EventTarget(); const events = []; document.addEventListener('wegwichtel:local-routes-changed', event => events.push(event.detail)); const context = { CustomEvent: FakeCustomEvent, DOMParser: FakeDOMParser, Event, EventTarget, FileReader: FakeFileReader, XMLSerializer: FakeXMLSerializer, crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555' }, document, globalThis: null, localStorage, queueMicrotask, Wegwichtel: {} }; context.globalThis = context; vm.runInNewContext(source, context, { filename: sourcePath }); return { api: context.Wegwichtel.LocalRoutes, events, localStorage }; } const sampleGpx = ` Alter Name Import77 Weg 100 112 `; test('route id generation uses randomUUID when available', async () => { const { api } = await loadLocalRoutes(); assert.equal( api.createRouteId(), 'local-11111111-2222-4333-8444-555555555555' ); }); test('route id generation falls back to crypto.getRandomValues with UUID v4 bits', async () => { let callCount = 0; const { api } = await loadLocalRoutes({ crypto: { getRandomValues(bytes) { callCount += 1; bytes.set([ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff ]); return bytes; } } }); assert.equal(api.createRouteId(), 'local-00112233-4455-4677-8899-aabbccddeeff'); assert.equal(callCount, 1); }); test('route id generation fails when no secure crypto source is available', async () => { const { api } = await loadLocalRoutes({ crypto: {} }); assert.throws( () => api.createRouteId(), /Keine sichere Zufallsquelle/ ); }); test('local route package stores GPX and JSON separately and reconstructs an app route', async () => { const { api, events, localStorage } = await loadLocalRoutes(); const routeId = api.createRouteId(); assert.equal(routeId, 'local-11111111-2222-4333-8444-555555555555'); const saved = api.save({ id: routeId, gpx: api.emptyGpx('Neue Route'), route: { id: routeId, name: 'Neue Route', description: 'Lokal gespeichert', serverRouteId: 42, uploadedAt: '2026-09-27T18:00:00.000Z' }, pois: [ { id: 101, title: 'Start', description: 'Erster POI', lat: 50.1, lon: 10.1, triggerRadiusM: 30, audio: { dataUrl: 'data:audio/mpeg;base64,QQ==', name: 'start.mp3' }, images: [{ id: 'img-1', dataUrl: 'data:image/jpeg;base64,Qg==', caption: 'Bild' }] }, { id: 102, title: 'Ziel', description: 'Zweiter POI', lat: 50.101, lon: 10.102, triggerRadiusM: 25, images: [] } ] }); assert.equal(saved.route.name, 'Neue Route'); assert.equal(saved.pois.length, 2); assert.equal(events.at(-1).action, 'saved'); assert.equal(events.at(-1).id, routeId); const prefix = api.STORAGE_PREFIX; const encodedId = encodeURIComponent(routeId); const gpx = localStorage.getItem(`${prefix}.${encodedId}.gpx`); const metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`)); assert.match(gpx, //); assert.match(gpx, /101<\/wegwichtel:poiId>/); assert.doesNotMatch(gpx, / 0); assert.equal(Math.round(appRoute.elevationGainM), 0); const listed = api.listAppRoutes(); assert.equal(listed.length, 1); assert.equal(listed[0].id, routeId); }); test('GPX waypoint import reads stable POI ids and validates coordinates', async () => { const { api } = await loadLocalRoutes(); const imported = api.parseWaypoints(sampleGpx); assert.equal(imported.length, 1); assert.equal(imported[0].id, 77); assert.equal(imported[0].title, 'Import'); assert.equal(imported[0].lat, 50.1); assert.equal(imported[0].lon, 10.1); assert.match(api.emptyGpx('A & B'), /A & B<\/name>/); assert.throws(() => api.parseWaypoints(''), /GPX-Datei ist nicht gültig/); assert.throws(() => api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /gültige Koordinaten/); const waypointOnly = api.syncGpx( ``, { name: 'Nur Trackpunkt' }, [{ id: 2, title: 'Einzelner Trackpunkt', lat: 50.2, lon: 10.2, sequence: 0 }] ); assert.match(waypointOnly, //); assert.doesNotMatch(waypointOnly, / { const { api } = await loadLocalRoutes(); const saved = api.save({ id: 'local-upload', gpx: api.emptyGpx('Lokale Strecke'), route: { name: 'Lokale Strecke' }, pois: [ { id: 1, title: 'Start', lat: 50.123456789, lon: 10.987654321, sequence: 0 }, { id: 2, title: 'Ziel', lat: 50.223456789, lon: 11.087654321, sequence: 1 } ] }); assert.match(saved.gpx, //); assert.match(saved.gpx, //); assert.match(saved.gpx, /1<\/wegwichtel:poiId>/); assert.equal((saved.gpx.match(/ { const { api } = await loadLocalRoutes(); const synced = api.syncGpx(sampleGpx, { name: 'Import' }, [ { id: 200, title: 'Neuer Trackpunkt', lat: 50.2, lon: 10.2, sequence: 0 } ]); assert.match(synced, /100<\/ele>/); assert.match(synced, /112<\/ele>/); assert.match(synced, /200<\/wegwichtel:poiId>/); assert.equal((synced.match(/ api.gpxForUpload({ gpx: api.emptyGpx('Leer'), route: { name: 'Leer' }, pois: [] }), /noch keine GPX-Trackpunkte/); }); test('remove updates index and dispatches a local-route change event', async () => { const { api, events } = await loadLocalRoutes(); api.save({ id: 'local-remove', gpx: sampleGpx, route: { name: 'Entfernen' }, pois: [] }); assert.equal(api.list().length, 1); api.remove('local-remove'); assert.equal(api.list().length, 0); assert.equal(events.at(-1).action, 'removed'); assert.equal(events.at(-1).id, 'local-remove'); assert.throws(() => api.getAppRoute('local-remove'), /nicht gefunden/); }); test('quota failure rolls back package writes and keeps the previous package readable', async () => { const { api, localStorage } = await loadLocalRoutes(); api.save({ id: 'local-quota', gpx: sampleGpx, route: { name: 'Vorher' }, pois: [] }); localStorage.failNext = true; assert.throws(() => api.save({ id: 'local-quota', gpx: sampleGpx, route: { name: 'Nachher' }, pois: [] }), /Browser-Speicher ist voll/); assert.equal(api.get('local-quota').route.name, 'Vorher'); }); test('file helpers support Blob.text and FileReader-backed media data URLs', async () => { const { api } = await loadLocalRoutes(); assert.equal(await api.readFileAsText({ text: async () => '' }), ''); assert.equal(await api.readFileAsText(null), ''); await assert.rejects(api.readFileAsText({}), { name: 'TypeError' }); const media = await api.fileToDataUrl({ name: 'ton.mp3', size: 4, type: 'audio/mpeg' }); assert.equal(media.name, 'ton.mp3'); assert.equal(media.size, 4); assert.equal(media.type, 'audio/mpeg'); assert.match(media.dataUrl, /^data:audio\/mpeg;base64,/); await assert.rejects(api.fileToDataUrl({ invalidReaderResult: true }), { name: 'TypeError' }); assert.equal(await api.fileToDataUrl(null), null); });