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; } 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 = `data:${type};base64,VEVTVA==`; this.dispatchEvent(new Event('load')); }); } } async function loadLocalRoutes() { 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: { 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('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: sampleGpx, route: { id: routeId, name: 'Neue Route', description: 'Lokal gespeichert' }, 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.ok(gpx.indexOf(''), 'GPX waypoints must be serialized before the track'); assert.equal(metadata.route.name, 'Neue Route'); assert.equal(metadata.pois[0].audio.name, 'start.mp3'); assert.equal('gpx' in metadata, false); assert.equal('_source' in metadata.route, false); const appRoute = api.getAppRoute(routeId); assert.equal(appRoute.source, 'local'); assert.equal(appRoute.local, true); assert.equal(appRoute.pois.length, 2); assert.equal(appRoute.pois[0].audioUrl, 'data:audio/mpeg;base64,QQ=='); assert.equal(appRoute.pois[0].images[0].url, 'data:image/jpeg;base64,Qg=='); assert.equal(appRoute.points.length, 2); assert.ok(appRoute.distanceM > 0); assert.equal(Math.round(appRoute.elevationGainM), 12); 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/); }); 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 native 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), ''); 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,/); assert.equal(await api.fileToDataUrl(null), null); });