458 lines
14 KiB
JavaScript
458 lines
14 KiB
JavaScript
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(/<!--[\s\S]*?-->/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('<!')) continue;
|
|
if (!token.startsWith('<')) {
|
|
if (stack.length) stack.at(-1)._text += decodeXml(token);
|
|
continue;
|
|
}
|
|
|
|
const selfClosing = /\/\s*>$/.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));
|
|
}
|
|
}
|
|
|
|
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<gpx version="1.1" creator="Test" xmlns="${GPX_NAMESPACE}" xmlns:wegwichtel="${POI_NAMESPACE}">
|
|
<metadata><name>Alter Name</name></metadata>
|
|
<wpt lat="50.1000" lon="10.1000"><name>Import</name><extensions><wegwichtel:poiId>77</wegwichtel:poiId></extensions></wpt>
|
|
<trk><name>Weg</name><trkseg>
|
|
<trkpt lat="50.1000" lon="10.1000"><ele>100</ele></trkpt>
|
|
<trkpt lat="50.1010" lon="10.1020"><ele>112</ele></trkpt>
|
|
</trkseg></trk>
|
|
</gpx>`;
|
|
|
|
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: 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, /<wpt lat="50\.1" lon="10\.1">/);
|
|
assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/);
|
|
assert.ok(gpx.indexOf('<wpt') < gpx.indexOf('<trk>'), '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'), /<name>A & B<\/name>/);
|
|
assert.throws(() => api.parseWaypoints('<not-gpx></not-gpx>'), /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(
|
|
`<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`,
|
|
{ name: 'Nur POI' },
|
|
[{ id: 2, title: 'Einzelner POI', lat: 50.2, lon: 10.2, sequence: 0 }]
|
|
);
|
|
assert.match(waypointOnly, /<wpt lat="50.2" lon="10.2">/);
|
|
});
|
|
|
|
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 () => '<gpx></gpx>' }), '<gpx></gpx>');
|
|
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);
|
|
});
|