update editor and add tests

This commit is contained in:
Florian Zumpe
2026-09-26 20:40:05 +02:00
parent bdc0aabc07
commit 96b379d8d6
14 changed files with 2099 additions and 587 deletions
+401
View File
@@ -0,0 +1,401 @@
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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
function decodeXml(value) {
return String(value)
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'")
.replaceAll('&amp;', '&');
}
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(/<!--[\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 = `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 = `<?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('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 &amp; 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/);
});
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 () => '<gpx></gpx>' }), '<gpx></gpx>');
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);
});