import { loadI18n } from '../test-support/i18n.js';
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('')) {
stack.pop();
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}${element.tagName}>`;
}
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));
}
}
function fakeIdbRequest(operation) {
const request = { result: null, error: null, onsuccess: null, onerror: null };
queueMicrotask(() => {
try {
request.result = operation();
request.onsuccess?.();
} catch (error) {
request.error = error;
request.onerror?.();
}
});
return request;
}
class FakeIndexedDatabaseConnection {
constructor(records) {
this.records = records;
this.objectStoreNames = { contains: name => name === 'media' };
}
createObjectStore() {
this.objectStoreNames = { contains: name => name === 'media' };
return {};
}
transaction() {
const records = this.records;
return {
error: null,
onabort: null,
objectStore() {
return {
put(record) {
return fakeIdbRequest(() => {
records.set(record.key, record);
return record.key;
});
},
get(key) {
return fakeIdbRequest(() => records.get(key) || undefined);
},
delete(key) {
return fakeIdbRequest(() => {
records.delete(key);
return undefined;
});
}
};
}
};
}
}
class FakeIndexedDB {
constructor() {
this.records = new Map();
this.database = new FakeIndexedDatabaseConnection(this.records);
}
open() {
const request = {
result: this.database,
error: null,
onupgradeneeded: null,
onsuccess: null,
onerror: null,
onblocked: null
};
queueMicrotask(() => {
request.onupgradeneeded?.();
request.onsuccess?.();
});
return request;
}
}
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 indexedDB = new FakeIndexedDB();
const document = new EventTarget();
const events = [];
document.addEventListener('wegwichtel:local-routes-changed', event => events.push(event.detail));
const context = {
Blob,
CustomEvent: FakeCustomEvent,
DOMParser: FakeDOMParser,
Event,
EventTarget,
FileReader: FakeFileReader,
XMLSerializer: FakeXMLSerializer,
crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555' },
document,
globalThis: null,
indexedDB,
localStorage,
queueMicrotask,
URL: { createObjectURL: blob => `blob:test-${blob.size}` },
Wegwichtel: {}
};
context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath });
return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage };
}
const sampleGpx = `
Alter Name
Import77
Weg
100
112
`;
test('missing GPS samples and stations survive storage and later manual coordinates', async () => {
const { api } = await loadLocalRoutes();
let gpx = api.appendTrackPoint(api.emptyGpx('Ohne Empfang'), { timestamp: 1000 }, 'Ohne Empfang');
gpx = api.appendTrackPoint(gpx, { lat: 50, lon: 10, timestamp: 2000 }, 'Ohne Empfang');
const saved = api.save({ id: 'missing-gps', gpx, route: { name: 'Ohne Empfang' }, pois: [
{ id: 42, title: 'Drinnen', lat: null, lon: null, sequence: 0 }
] });
const positions = api.recordedPositions(saved.gpx);
assert.equal(positions.length, 2);
assert.equal(positions[0].lat, null);
assert.equal(api.parseWaypoints(saved.gpx)[0].lat, null);
assert.match(api.gpxForUpload(saved), /gpsGap/);
positions[0].lat = 49.999;
positions[0].lon = 9.999;
const edited = api.replaceRecordedPositions(saved.gpx, positions, 'Ohne Empfang');
assert.doesNotMatch(edited, /gpsGap/);
assert.equal(api.recordedPositions(edited)[0].lat, 49.999);
assert.equal(api.parseWaypoints(edited)[0].title, 'Drinnen');
assert.equal(api.parseWaypoints(edited)[0].lat, null);
});
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 neue Strecke angelegt/
);
});
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 Strecke'),
route: {
id: routeId,
name: 'Neue Strecke',
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 Strecke');
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, / {
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(''), /keinen lesbaren Weg/);
assert.match(api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /pendingWaypoint/);
const waypointOnly = api.syncGpx(
``,
{ name: 'Nur Station' },
[{ id: 2, title: 'Einzelner Station', 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 Station', 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 keinen aufgezeichneten Weg/);
});
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);
await 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');
await assert.rejects(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: []
}), /Platz für deine Strecken ist voll/);
assert.equal(api.get('local-quota').route.name, 'Vorher');
});
test('local media uses IndexedDB so route JSON does not consume Local Storage quota', async () => {
const { api, indexedDB, localStorage } = await loadLocalRoutes();
const routeId = 'local-media';
const audioFile = Object.assign(new Blob(['audio-payload'], { type: 'audio/mpeg' }), { name: 'weg.mp3' });
const imageFile = Object.assign(new Blob(['image-payload'], { type: 'image/jpeg' }), { name: 'weg.jpg' });
const audio = await api.storeMediaFile(routeId, 1, 'audio', '1-audio', audioFile);
const image = await api.storeMediaFile(routeId, 1, 'image', '1-image', imageFile);
const saved = api.save({
id: routeId,
gpx: api.emptyGpx('Medientest'),
route: { name: 'Medientest' },
pois: [{ id: 1, title: 'Station 1', lat: 50.1, lon: 10.1, audio, images: [image] }]
});
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent(routeId)}.json`);
assert.doesNotMatch(metadata, /data:audio|data:image|audio-payload|image-payload/);
assert.match(metadata, /"storage":"indexeddb"/);
assert.equal(indexedDB.records.size, 2);
const hydrated = await api.getWithMedia(routeId);
assert.match(hydrated.pois[0].audio.url, /^blob:test-/);
assert.match(hydrated.pois[0].images[0].url, /^blob:test-/);
assert.equal((await api.mediaBlob(saved.pois[0].audio)).size, audioFile.size);
await api.remove(routeId);
assert.equal(indexedDB.records.size, 0);
});
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);
});
test('local media stores a File System Access handle instead of copying file bytes when supported', async () => {
const { api, indexedDB, localStorage } = await loadLocalRoutes();
const file = Object.assign(new Blob(['large-image'], { type: 'image/jpeg' }), { name: 'foto.jpg' });
let permission = 'granted';
let permissionRequests = 0;
const handle = {
name: 'foto.jpg',
async getFile() { return file; },
async queryPermission() { return permission; },
async requestPermission() {
permissionRequests += 1;
permission = 'granted';
return permission;
}
};
const media = await api.storeMediaFile('route-handle', 7, 'image', 'image-7', file, handle);
assert.equal(media.storage, 'file-handle');
const record = indexedDB.records.get(media.mediaKey);
assert.equal(record.handle, handle);
assert.equal('blob' in record, false);
api.save({
id: 'route-handle',
gpx: api.emptyGpx('Handle'),
route: { name: 'Handle' },
pois: [{ id: 7, title: 'Station', lat: 50.1, lon: 10.1, images: [media] }]
});
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent('route-handle')}.json`);
assert.match(metadata, /"storage":"file-handle"/);
assert.doesNotMatch(metadata, /large-image/);
assert.equal(await api.mediaBlob(media), file);
permission = 'prompt';
await assert.rejects(api.mediaBlob(media), /Erlaube noch einmal/);
assert.equal(await api.mediaBlob(media, { requestPermission: true }), file);
assert.equal(permissionRequests, 1);
});
test('multiple Stationen with large local media keep Local Storage metadata small', async () => {
const { api, localStorage } = await loadLocalRoutes();
const routeId = 'multi-media';
const large = new Uint8Array(1024 * 1024);
const firstFile = Object.assign(new Blob([large], { type: 'image/jpeg' }), { name: 'eins.jpg' });
const secondFile = Object.assign(new Blob([large], { type: 'image/jpeg' }), { name: 'zwei.jpg' });
const first = await api.storeMediaFile(routeId, 1, 'image', 'image-1', firstFile);
const second = await api.storeMediaFile(routeId, 2, 'image', 'image-2', secondFile);
api.save({
id: routeId,
gpx: api.emptyGpx('Mehrere Stationen'),
route: { name: 'Mehrere Stationen' },
pois: [
{ id: 1, title: 'Erster Station', lat: 50.1, lon: 10.1, images: [first] },
{ id: 2, title: 'Zweiter Station', lat: 50.2, lon: 10.2, images: [second] }
]
});
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent(routeId)}.json`);
assert.ok(metadata.length < 5000, `metadata unexpectedly large: ${metadata.length}`);
assert.doesNotMatch(metadata, /data:image/);
assert.equal(api.get(routeId).pois.length, 2);
});