sonarqube fix
Sonarqube Scanner / Prepare environment (push) Successful in 15s
Sonarqube Scanner / Build and test (push) Failing after 34s
Sonarqube Scanner / SonarQube analysis (push) Skipped
Sonarqube Scanner / Deploy to Server (push) Failing after 3s

This commit is contained in:
Florian Zumpe
2026-09-28 19:34:10 +02:00
parent d06d19dfab
commit 7424eba5f8
3 changed files with 687 additions and 539 deletions
+404
View File
@@ -0,0 +1,404 @@
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, '..');
function tick() {
return new Promise(resolve => setTimeout(resolve, 0));
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
class FakeNode {
constructor(selector = '') {
this.selector = selector;
this.properties = new Map();
this.attributes = new Map();
this.dataValues = new Map();
this.styles = new Map();
this.classes = new Set();
this.appended = [];
this.textValue = '';
this.value = '';
this.autocompleteOptions = null;
this.handlers = new Map();
}
}
class FakeJQuery {
constructor(nodes, harness) {
this.nodes = nodes;
this.harness = harness;
this.length = nodes.length;
}
_each(callback) {
this.nodes.forEach(callback);
return this;
}
text(value) {
if (value === undefined) return this.nodes[0]?.textValue ?? '';
return this._each(node => { node.textValue = String(value); });
}
html(value) {
if (value === undefined) return escapeHtml(this.nodes[0]?.textValue ?? '');
return this._each(node => { node.textValue = String(value); });
}
prop(name, value) {
if (value === undefined) return this.nodes[0]?.properties.get(name);
return this._each(node => node.properties.set(name, value));
}
attr(name, value) {
if (typeof name === 'object' && name !== null) {
return this._each(node => {
Object.entries(name).forEach(([key, item]) => node.attributes.set(key, String(item)));
});
}
if (value === undefined) return this.nodes[0]?.attributes.get(name);
return this._each(node => node.attributes.set(name, String(value)));
}
removeAttr(name) {
return this._each(node => node.attributes.delete(name));
}
addClass(name) {
return this._each(node => node.classes.add(name));
}
removeClass(name) {
return this._each(node => node.classes.delete(name));
}
css(name, value) {
if (value === undefined) return this.nodes[0]?.styles.get(name);
return this._each(node => node.styles.set(name, value));
}
empty() {
return this._each(node => { node.appended = []; });
}
append(value) {
return this._each(node => node.appended.push(String(value)));
}
val(value) {
if (value === undefined) return this.nodes[0]?.value ?? '';
return this._each(node => { node.value = String(value); });
}
data(name, value) {
if (value === undefined) return this.nodes[0]?.dataValues.get(name);
return this._each(node => node.dataValues.set(name, value));
}
on(eventName, selector, handler) {
if (typeof selector === 'function') {
this.nodes.forEach(node => {
if (!node.handlers.has(eventName)) node.handlers.set(eventName, []);
node.handlers.get(eventName).push(selector);
});
return this;
}
this.harness.delegated.set(`${eventName}:${selector}`, handler);
return this;
}
trigger(eventName) {
this.nodes.forEach(node => {
for (const handler of node.handlers.get(eventName) || []) {
handler.call(node, { type: eventName });
}
});
const delegated = this.harness.delegated.get(`${eventName}:${this.nodes[0]?.selector}`);
if (delegated) delegated.call(this.nodes[0], { type: eventName, preventDefault() {} });
return this;
}
autocomplete(options) {
return this._each(node => { node.autocompleteOptions = options; });
}
find(selector) {
return this.harness.$(`${this.nodes[0]?.selector || ''} ${selector}`);
}
closest(selector) {
const node = this.nodes[0];
if (!node) return new FakeJQuery([], this.harness);
if (selector === '.route-control' && node.classes.has('route-control')) return this;
return new FakeJQuery([], this.harness);
}
}
function createHarness() {
const document = new EventTarget();
Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true });
const nodes = new Map();
const delegated = new Map();
const documentNode = new FakeNode('document');
const globalListeners = new Map();
const harness = {
delegated,
document,
globalListeners,
node(selector) {
if (!nodes.has(selector)) nodes.set(selector, new FakeNode(selector));
return nodes.get(selector);
},
$(selector) {
if (selector === document) return new FakeJQuery([documentNode], harness);
if (selector instanceof FakeNode) return new FakeJQuery([selector], harness);
if (typeof selector === 'string' && selector.startsWith('<')) {
return new FakeJQuery([new FakeNode(selector)], harness);
}
return new FakeJQuery([harness.node(String(selector))], harness);
},
async activate(selector, target, pointer = { isPrimary: true, pointerType: 'touch', button: 0 }) {
const handler = delegated.get(`pointerup:${selector}`);
assert.ok(handler, `missing pointer handler for ${selector}`);
const event = {
originalEvent: pointer,
preventDefault() {}
};
handler.call(target, event);
await tick();
await tick();
},
async key(selector, target, key) {
const handler = delegated.get(`keydown:${selector}`);
assert.ok(handler, `missing key handler for ${selector}`);
handler.call(target, { key, preventDefault() {} });
await tick();
await tick();
},
fireDocumentPointer(target) {
const handlers = documentNode.handlers.get('pointerup') || [];
handlers.forEach(handler => handler({ target }));
},
fireDocumentKey(key) {
const handlers = documentNode.handlers.get('keydown') || [];
handlers.forEach(handler => handler({ key }));
}
};
return harness;
}
function route(id, name, source = 'server') {
return {
id,
name,
source,
local: source === 'local',
schoolName: source === 'local' ? 'Lokal gespeichert' : 'Grundschule Herpf',
description: `${name} Beschreibung`,
start: { lat: 50, lon: 10 },
distanceM: 1000,
elevationGainM: 15,
points: [
{ lat: 50, lon: 10 },
{ lat: 50.001, lon: 10.001 }
],
pois: [
{ id: 1, title: `${name} Start`, description: 'Los', lat: 50, lon: 10, sequence: 0, triggerRadiusM: 25, images: [], audioUrl: null },
{ id: 2, title: `${name} Ziel`, description: 'Ziel', lat: 50.001, lon: 10.001, sequence: 1, triggerRadiusM: 25, images: [], audioUrl: null }
]
};
}
async function loadApp() {
const sourcePath = path.join(root, 'public/js/app.js');
const source = await fs.readFile(sourcePath, 'utf8');
const harness = createHarness();
const serverRoute = route('server-1', 'Serverweg');
let localRoute = route('local-1', 'Lokaler Weg', 'local');
const steps = [];
const savedStates = [];
const editor = { opened: 0, refreshed: 0 };
const apiCalls = [];
harness.node('#route-control-dropdown').properties.set('hidden', true);
harness.node('#route-search').value = '';
const context = {
document: harness.document,
jQuery: harness.$,
Wegwichtel: {
Api: {
async health() { apiCalls.push('health'); return { ok: true }; },
async routes() { apiCalls.push('routes'); return { routes: [serverRoute] }; },
async route(id) { apiCalls.push(`route:${id}`); return serverRoute; }
},
AudioPlayer: { load() {}, pause() {}, unload() {} },
Config: { routeRadiusKm: 5 },
Distance: {
bearing() { return 45; },
format(value) { return `${Math.round(value)} m`; },
meters(left, right) {
return Math.hypot((right.lat - left.lat) * 111000, (right.lon - left.lon) * 70000);
},
normalizeDegrees(value) { return ((value % 360) + 360) % 360; }
},
EditorUi: {
openNewLocalRoute() { editor.opened += 1; },
refresh() { editor.refreshed += 1; }
},
Geo: {
async current() { throw new Error('GPS verweigert'); },
start() {},
stop() {}
},
LocalRoutes: {
getAppRoute(id) {
if (String(id) !== 'local-1' || !localRoute) throw new Error('lokal nicht gefunden');
return structuredClone(localRoute);
},
listAppRoutes() {
return localRoute ? [structuredClone(localRoute)] : [];
}
},
Orientation: { async start() { return false; }, stop() {} },
PrivacyStorage: {
activateRoute() { return null; },
saveRouteState(...args) { savedStates.push(args); }
},
Slideshow: { show() {} },
Vibration: { Patterns: { ACTIVE_POI: [100] }, start() {}, stop() {} }
},
alert() {},
clearTimeout,
history: {
state: null,
back() {},
pushState(state) { this.state = state; },
replaceState(state) { this.state = state; }
},
scrollTo() {},
setTimeout,
structuredClone,
globalThis: null
};
context.addEventListener = (name, handler) => harness.globalListeners.set(name, handler);
context.globalThis = context;
vm.runInNewContext(source, context, { filename: sourcePath });
return {
apiCalls,
context,
editor,
harness,
savedStates,
serverRoute,
setLocalRoute(value) { localRoute = value; },
steps
};
}
test('route control runtime loads server/local routes and supports switching without geolocation', async () => {
const setup = await loadApp();
const { context, harness, apiCalls, editor } = setup;
const initContext = { markStep(name, value) { setup.steps.push([name, value]); } };
await context.Wegwichtel.App.initialize(initContext);
assert.deepEqual(apiCalls.slice(0, 2), ['health', 'routes']);
assert.deepEqual(setup.steps, [['server', 'done'], ['routes', 'done'], ['location', 'done']]);
assert.match(harness.node('#location-hint').textValue, /Standort nicht verfügbar/);
assert.match(harness.node('#route-control-server-title').textValue, /Aktive Strecken \(1\)/);
assert.match(harness.node('#route-control-local-title').textValue, /Lokale Strecken \(1\)/);
assert.ok(harness.node('#all-route-list').appended.join('').includes('Serverweg'));
assert.ok(harness.node('#local-route-list').appended.join('').includes('Lokaler Weg'));
const controlButton = new FakeNode('#route-control-button');
await harness.activate('#route-control-button', controlButton);
assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), false);
assert.equal(harness.node('#route-control-button').attributes.get('aria-expanded'), 'true');
const newButton = new FakeNode('#route-control-new');
await harness.activate('.route-control-actions #route-control-new', newButton);
assert.equal(editor.opened, 1);
assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), true);
const localButton = new FakeNode('local-select');
localButton.dataValues.set('route-source', 'local');
localButton.dataValues.set('route-id', 'local-1');
await harness.activate('[data-route-select]', localButton);
assert.equal(harness.node('#route-control-title').textValue, 'Lokaler Weg');
assert.match(harness.node('#route-control-next').textValue, /Nächster POI: Lokaler Weg Start/);
assert.equal(harness.node('#route-page').attributes.has('hidden'), false);
const serverButton = new FakeNode('server-select');
serverButton.dataValues.set('route-source', 'server');
serverButton.dataValues.set('route-id', 'server-1');
await harness.key('[data-route-select]', serverButton, 'Enter');
assert.ok(apiCalls.includes('route:server-1'));
assert.equal(harness.node('#route-control-title').textValue, 'Serverweg');
assert.match(harness.node('#route-control-next').textValue, /Nächster POI: Serverweg Start/);
});
test('local-route change refreshes an active draft and handles its later removal', async () => {
const setup = await loadApp();
const { context, harness, editor } = setup;
await context.Wegwichtel.App.initialize({ markStep() {} });
const localButton = new FakeNode('local-select');
localButton.dataValues.set('route-source', 'local');
localButton.dataValues.set('route-id', 'local-1');
await harness.activate('[data-route-select]', localButton);
const changed = route('local-1', 'Lokaler Weg geändert', 'local');
setup.setLocalRoute(changed);
const updateEvent = new Event('wegwichtel:local-routes-changed');
Object.defineProperty(updateEvent, 'detail', { value: { id: 'local-1' } });
harness.document.dispatchEvent(updateEvent);
await tick();
assert.equal(harness.node('#route-control-title').textValue, 'Lokaler Weg geändert');
assert.equal(editor.refreshed, 1);
setup.setLocalRoute(null);
const removeEvent = new Event('wegwichtel:local-routes-changed');
Object.defineProperty(removeEvent, 'detail', { value: { id: 'local-1' } });
harness.document.dispatchEvent(removeEvent);
await tick();
assert.equal(harness.node('#route-control-title').textValue, 'Route wählen');
assert.equal(harness.node('#routes-page').attributes.has('hidden'), false);
assert.equal(editor.refreshed, 2);
});
test('route control closes on escape and reports local storage failures', async () => {
const setup = await loadApp();
const { context, harness } = setup;
await context.Wegwichtel.App.initialize({ markStep() {} });
await harness.activate('#route-control-button', new FakeNode('toggle'));
assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), false);
harness.fireDocumentKey('Escape');
assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), true);
context.Wegwichtel.LocalRoutes.listAppRoutes = () => { throw new Error('LocalStorage nicht lesbar'); };
harness.globalListeners.get('pageshow')?.({});
assert.ok(harness.node('#route-control-local-list').appended.join('').includes('LocalStorage nicht lesbar'));
});
+223 -315
View File
@@ -19,66 +19,31 @@ test('editor markup and source describe local draft creation instead of immediat
const css = await read('public/css/editor.css'); const css = await read('public/css/editor.css');
const ui = await read('public/js/editor-ui.js'); const ui = await read('public/js/editor-ui.js');
assert.match(html, /id="editor-button"[^>]+aria-label="Strecken verwalten"/); assert.match(html, /id="editor-button"/);
assert.match(html, /id="route-control-button"[\s\S]*?<img[^>]+src="images\/icons\/route-management\.svg"[^>]+alt=""[^>]*>/);
assert.doesNotMatch(html, /class="editor-button-icon"/);
assert.match(html, /<dialog id="editor-dialog"/); assert.match(html, /<dialog id="editor-dialog"/);
assert.match(html, /id="editor-route-form"/); assert.match(html, /id="editor-route-form"/);
assert.match(html, /id="editor-poi-form"/); assert.match(html, /id="editor-poi-form"/);
assert.match(html, /id="editor-route-gpx"[^>]+accept="[^\"]*\.gpx/); assert.match(html, /id="editor-route-gpx"[^>]+accept="[^\"]*\.gpx/);
assert.match(html, /id="editor-poi-audio-add"[^>]+type="button"/); assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"/);
assert.match(html, /id="editor-poi-images-add"[^>]+type="button"/); assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple/);
assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"[^>]+hidden/);
assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple[^>]+hidden/);
assert.match(html, /id="editor-poi-lat"[^>]+type="hidden"/);
assert.match(html, /id="editor-poi-lon"[^>]+type="hidden"/);
assert.match(html, /id="editor-poi-radius"[^>]+type="hidden"/);
assert.doesNotMatch(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/);
assert.match(html, /id="editor-poi-media-preview"/);
assert.match(html, /id="editor-poi-delete"[^>]+editor-danger-button/);
assert.match(html, />Streckenpunkte<\/h3>/);
assert.match(html, /aria-label="Streckenpunkt hinzufügen"/);
assert.match(html, /id="editor-route-upload"[^>]+type="button"[^>]+hidden/);
assert.doesNotMatch(html, /accept="video\//);
assert.match(html, /ausschließlich in diesem Browser/i); assert.match(html, /ausschließlich in diesem Browser/i);
await fs.access(path.join(root, 'public/images/icons/route-management.svg'));
assert.match(css, /\.editor-dialog/); assert.match(css, /\.editor-dialog/);
assert.match(css, /\.editor-poi-list/); assert.match(css, /\.editor-poi-list/);
assert.match(css, /\.editor-route-group/); assert.match(css, /\.editor-route-group/);
assert.match(css, /\.editor-media-button/);
assert.match(css, /\.editor-media-preview/);
assert.match(css, /\.editor-media-remove/);
assert.match(css, /\.editor-danger-button/);
assert.match(css, /\.editor-icon-danger/);
assert.match(css, /\.route-control-button img/);
assert.doesNotMatch(css, /\.editor-button-icon/);
assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/); assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/);
assert.match(ui, /ns\.LocalRoutes\.save\(/); assert.match(ui, /ns\.LocalRoutes\.save\(/);
assert.match(ui, /ns\.LocalRoutes\.parseWaypoints\(/); assert.match(ui, /ns\.LocalRoutes\.parseWaypoints\(/);
assert.match(ui, /ns\.LocalRoutes\.storeMediaFile\(/); assert.match(ui, /ns\.LocalRoutes\.fileToDataUrl\(/);
assert.match(ui, /showOpenFilePicker/);
assert.match(ui, /requestPermission:\s*true/);
assert.match(ui, /ns\.LocalRoutes\.gpxForUpload\(/);
assert.match(ui, /move-poi-up/); assert.match(ui, /move-poi-up/);
assert.match(ui, /move-poi-down/); assert.match(ui, /move-poi-down/);
assert.match(ui, /delete-route/);
assert.match(ui, /delete-poi/);
assert.match(ui, /sortOrder:\s*index/); assert.match(ui, /sortOrder:\s*index/);
assert.match(ui, /pointerup/); assert.match(ui, /pointerup/);
assert.doesNotMatch(ui, /sendJson\(['"]\/routes['"],\s*['"]POST['"]/); assert.doesNotMatch(ui, /sendJson\(['"]\/routes['"],\s*['"]POST['"]/);
assert.doesNotMatch(ui, /activated_at['"],\s*['"]/); assert.doesNotMatch(ui, /activated_at['"],\s*['"]/);
assert.doesNotMatch(ui, /activatedAt:\s*null/); assert.doesNotMatch(ui, /activatedAt:\s*null/);
assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/); assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/);
assert.match(ui, /\/pictures/);
assert.match(ui, /'picture'/);
assert.match(ui, /'audio'/);
assert.match(ui, /method:\s*'DELETE'/);
assert.match(ui, /sendForm\('\/routes',\s*'POST'/);
assert.match(ui, /serverRouteId/);
assert.match(ui, /position\?\.accuracy/);
assert.match(ui, /setHidden\(byId\(ids\.routeGpxGroup\), state\.currentSource !== 'server'\)/);
}); });
class FakeElement extends EventTarget { class FakeElement extends EventTarget {
@@ -99,7 +64,6 @@ class FakeElement extends EventTarget {
this.title = ''; this.title = '';
this.type = ''; this.type = '';
this._textContent = ''; this._textContent = '';
this.clickCount = 0;
} }
get textContent() { get textContent() {
@@ -132,10 +96,6 @@ class FakeElement extends EventTarget {
if (name === 'open') this.open = false; if (name === 'open') this.open = false;
} }
click() {
this.clickCount += 1;
}
showModal() { showModal() {
this.open = true; this.open = true;
} }
@@ -157,8 +117,8 @@ class FakeFormData {
this.entries = []; this.entries = [];
} }
append(name, value, filename) { append(name, value) {
this.entries.push([name, value, filename]); this.entries.push([name, value]);
} }
} }
@@ -181,6 +141,13 @@ function pointerEvent(target = null, { pointerType = 'touch', button = 0, isPrim
return event; return event;
} }
function keydownEvent(key, target = null) {
const event = new Event('keydown', { cancelable: true });
Object.defineProperty(event, 'key', { value: key });
if (target) Object.defineProperty(event, 'target', { value: target });
return event;
}
function submitEvent() { function submitEvent() {
return new Event('submit', { cancelable: true }); return new Event('submit', { cancelable: true });
} }
@@ -193,21 +160,16 @@ function buildEditorContext() {
'editor-dialog', 'editor-dialog',
'editor-new-route', 'editor-new-route',
'editor-poi-audio', 'editor-poi-audio',
'editor-poi-audio-add',
'editor-poi-cancel', 'editor-poi-cancel',
'editor-poi-delete',
'editor-poi-description', 'editor-poi-description',
'editor-poi-form', 'editor-poi-form',
'editor-poi-form-title', 'editor-poi-form-title',
'editor-poi-form-view', 'editor-poi-form-view',
'editor-poi-id', 'editor-poi-id',
'editor-poi-images', 'editor-poi-images',
'editor-poi-images-add',
'editor-poi-lat', 'editor-poi-lat',
'editor-poi-list', 'editor-poi-list',
'editor-poi-lon', 'editor-poi-lon',
'editor-poi-media-preview',
'editor-poi-media-status',
'editor-poi-radius', 'editor-poi-radius',
'editor-poi-title', 'editor-poi-title',
'editor-route-activated', 'editor-route-activated',
@@ -216,12 +178,10 @@ function buildEditorContext() {
'editor-route-detail-view', 'editor-route-detail-view',
'editor-route-form', 'editor-route-form',
'editor-route-gpx', 'editor-route-gpx',
'editor-route-gpx-group',
'editor-route-id', 'editor-route-id',
'editor-route-list', 'editor-route-list',
'editor-route-list-view', 'editor-route-list-view',
'editor-route-name', 'editor-route-name',
'editor-route-upload',
'editor-status' 'editor-status'
]; ];
const elements = new Map(ids.map(id => [id, new FakeElement(id)])); const elements = new Map(ids.map(id => [id, new FakeElement(id)]));
@@ -237,7 +197,10 @@ function buildEditorContext() {
id: 'local-existing', id: 'local-existing',
gpx: '<gpx/>', gpx: '<gpx/>',
route: { id: 'local-existing', name: 'Lokaler Waldweg', description: 'Lokal' }, route: { id: 'local-existing', name: 'Lokaler Waldweg', description: 'Lokal' },
pois: [] pois: [
{ id: 21, title: 'Lokaler Start', description: 'Start', lat: 50.2, lon: 10.2, triggerRadiusM: 25, sequence: 0 },
{ id: 22, title: 'Lokales Ziel', description: 'Ziel', lat: 50.3, lon: 10.3, triggerRadiusM: 25, sequence: 1 }
]
}] }]
]); ]);
@@ -246,41 +209,35 @@ function buildEditorContext() {
name: 'Freigegebener Waldweg', name: 'Freigegebener Waldweg',
description: 'Serverroute', description: 'Serverroute',
activatedAt: '2026-09-01 08:00:00', activatedAt: '2026-09-01 08:00:00',
pois: [{ id: 10, title: 'Start', description: 'Los', lat: 50.1, lon: 10.1, triggerRadiusM: 20, sequence: 0 }] pois: [
{ id: 10, title: 'Start', description: 'Los', lat: 50.1, lon: 10.1, triggerRadiusM: 20, sequence: 0 },
{ id: 11, title: 'Ziel', description: 'Ankommen', lat: 50.2, lon: 10.2, triggerRadiusM: 20, sequence: 1 }
]
}; };
async function fetch(url, options = {}) { async function fetch(url, options = {}) {
const method = options.method || 'GET'; const method = options.method || 'GET';
calls.push({ body: options.body, method, url }); calls.push({ body: options.body, method, url });
if (String(url).startsWith('data:')) {
return { ok: true, status: 200, blob: async () => new Blob(['media'], { type: 'application/octet-stream' }) };
}
if (url === '/api/routes?activatedOnly=true' && method === 'GET') return jsonResponse({ routes: [serverRoute] }); if (url === '/api/routes?activatedOnly=true' && method === 'GET') return jsonResponse({ routes: [serverRoute] });
if (url === '/api/routes' && method === 'POST') return jsonResponse({ id: 22, name: 'Neue lokale Strecke', pois: [] }, true, 201);
if (url === '/api/routes/22/pois' && method === 'POST') return jsonResponse({ id: 23, title: 'Aussicht' }, true, 201);
if (/^\/api\/routes\/22\/pois\/23\/(audio|pictures)$/.test(url) && method === 'POST') return jsonResponse({ ok: true }, true, 201);
if (url === '/api/routes/22' && method === 'DELETE') return jsonResponse({ deleted: true });
if (url === '/api/routes/1' && method === 'GET') return jsonResponse({ route: serverRoute }); if (url === '/api/routes/1' && method === 'GET') return jsonResponse({ route: serverRoute });
if (url === '/api/routes/1' && method === 'DELETE') return jsonResponse({ deleted: true });
if (url === '/api/routes/1' && method === 'PUT') { if (url === '/api/routes/1' && method === 'PUT') {
if (options.body instanceof FakeFormData) {
const values = Object.fromEntries(options.body.entries);
return jsonResponse({ route: { ...serverRoute, name: values.name, description: values.description } });
}
const body = JSON.parse(options.body); const body = JSON.parse(options.body);
return jsonResponse({ route: { ...serverRoute, ...body } }); return jsonResponse({ route: { ...serverRoute, ...body } });
} }
if (url === '/api/routes/1/pois' && method === 'POST') return jsonResponse({ poi: { id: 12, title: 'Neue Serverstation' } }); if (url === '/api/routes/1/pois' && method === 'POST') return jsonResponse({ poi: { id: 12, title: 'Neue Serverstation' } });
if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && method === 'PUT') return jsonResponse({ poi: { id: Number(url.split('/').pop()), title: 'Station' } }); if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && method === 'PUT') return jsonResponse({ poi: { id: Number(url.split('/').pop()), title: 'Station' } });
if (/^\/api\/routes\/1\/pois\/\d+\/(audio|pictures)$/.test(url) && (method === 'POST' || method === 'PUT')) return jsonResponse({ ok: true }); if (/^\/api\/routes\/1\/pois\/\d+\/(audio|images)$/.test(url) && method === 'POST') return jsonResponse({ ok: true });
if (/^\/api\/routes\/1\/pois\/\d+\/audio$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
if (/^\/api\/routes\/1\/pois\/\d+\/pictures\/\d+$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
return { ok: false, status: 404, text: async () => `unexpected ${method} ${url}` }; return { ok: false, status: 404, text: async () => `unexpected ${method} ${url}` };
} }
const mediaBlobs = new Map();
const storedMediaCalls = [];
const LocalRoutes = { const LocalRoutes = {
createPoiId: () => 9001, createPoiId: () => 9001,
createRouteId: () => 'local-new', createRouteId: () => 'local-new',
emptyGpx: name => `<gpx><metadata><name>${name}</name></metadata><trk><trkseg></trkseg></trk></gpx>`, emptyGpx: name => `<gpx><metadata><name>${name}</name></metadata></gpx>`,
fileToDataUrl: async file => ({ fileToDataUrl: async file => ({
dataUrl: `data:${file.type || 'application/octet-stream'};base64,TEST`, dataUrl: `data:${file.type || 'application/octet-stream'};base64,TEST`,
name: file.name, name: file.name,
@@ -288,50 +245,17 @@ function buildEditorContext() {
type: file.type || 'application/octet-stream' type: file.type || 'application/octet-stream'
}), }),
get: id => packages.get(String(id)) || null, get: id => packages.get(String(id)) || null,
getWithMedia: async id => {
const value = packages.get(String(id));
if (!value) return null;
return {
...value,
pois: value.pois.map(poi => ({
...poi,
audio: poi.audio ? { ...poi.audio, url: 'blob:test-audio' } : null,
images: (poi.images || []).map(image => ({ ...image, url: 'blob:test-image' }))
}))
};
},
gpxForUpload: routePackage => routePackage.gpx,
list: () => [...packages.values()], list: () => [...packages.values()],
parseWaypoints: () => [], parseWaypoints: () => [],
readFileAsText: file => file.text(), readFileAsText: file => file.text(),
mediaBlob: async media => mediaBlobs.get(media.mediaKey) || new Blob(['media'], { type: media.type || 'application/octet-stream' }),
removeMedia: async media => { if (media?.mediaKey) mediaBlobs.delete(media.mediaKey); },
remove: async id => packages.delete(String(id)),
storeMediaFile: async (routeId, poiId, kind, mediaId, file, handle = null) => {
storedMediaCalls.push({ routeId, poiId, kind, mediaId, file, handle });
const mediaKey = `${routeId}:${poiId}:${kind}:${mediaId}`;
mediaBlobs.set(mediaKey, file);
return {
id: String(mediaId),
mediaKey,
storage: handle ? 'file-handle' : 'indexeddb',
name: file.name,
size: file.size || 1,
type: file.type || 'application/octet-stream'
};
},
save: input => { save: input => {
const id = String(input.id); const id = String(input.id);
const now = '2026-09-19T14:00:00.000Z'; const now = '2026-09-19T14:00:00.000Z';
const pois = input.pois.map((poi, sequence) => ({ ...poi, sequence }));
const gpxPoints = pois
.map(poi => `<trkpt lat="${poi.lat}" lon="${poi.lon}"><name>${poi.title || ''}</name><extensions><wegwichtel:poiId>${poi.id}</wegwichtel:poiId></extensions></trkpt>`)
.join('');
const saved = { const saved = {
id, id,
gpx: `<gpx><trk><trkseg>${gpxPoints}</trkseg></trk></gpx>`, gpx: input.gpx,
route: { ...input.route, id, updatedAt: now }, route: { ...input.route, id, updatedAt: now },
pois pois: input.pois.map((poi, sequence) => ({ ...poi, sequence }))
}; };
packages.set(id, saved); packages.set(id, saved);
saves.push(structuredClone(saved)); saves.push(structuredClone(saved));
@@ -340,7 +264,6 @@ function buildEditorContext() {
}; };
const context = { const context = {
Blob,
document, document,
Event, Event,
EventTarget, EventTarget,
@@ -348,18 +271,17 @@ function buildEditorContext() {
FormData: FakeFormData, FormData: FakeFormData,
globalThis: null, globalThis: null,
structuredClone, structuredClone,
URL: { createObjectURL: () => 'blob:test-preview', revokeObjectURL() {} },
Wegwichtel: { Wegwichtel: {
App: { currentPosition: () => ({ lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }) }, App: { currentPosition: () => ({ lat: 50.5, lon: 10.5 }) },
Config: { apiBase: '/api/' }, Config: { apiBase: '/api/' },
LocalRoutes LocalRoutes
} }
}; };
context.globalThis = context; context.globalThis = context;
return { calls, context, elements, packages, saves, storedMediaCalls }; return { calls, context, elements, packages, saves, serverRoute };
} }
test('public editor workflow saves new route and GPX Streckenpunkt locally without POSTing a route', async () => { test('public editor workflow saves new route and GPX POI locally without POSTing a route', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js'); const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8'); const source = await fs.readFile(sourcePath, 'utf8');
const { calls, context, elements, packages, saves } = buildEditorContext(); const { calls, context, elements, packages, saves } = buildEditorContext();
@@ -372,7 +294,6 @@ test('public editor workflow saves new route and GPX Streckenpunkt locally witho
assert.ok(calls.some(call => call.method === 'GET' && call.url === '/api/routes?activatedOnly=true')); assert.ok(calls.some(call => call.method === 'GET' && call.url === '/api/routes?activatedOnly=true'));
await context.Wegwichtel.EditorUi.openNewLocalRoute(); await context.Wegwichtel.EditorUi.openNewLocalRoute();
assert.equal(elements.get('editor-route-gpx-group').hidden, true);
elements.get('editor-route-name').value = 'Neue lokale Strecke'; elements.get('editor-route-name').value = 'Neue lokale Strecke';
elements.get('editor-route-description').value = 'Nur im Browser'; elements.get('editor-route-description').value = 'Nur im Browser';
elements.get('editor-route-form').dispatchEvent(submitEvent()); elements.get('editor-route-form').dispatchEvent(submitEvent());
@@ -383,191 +304,34 @@ test('public editor workflow saves new route and GPX Streckenpunkt locally witho
assert.equal(saves.at(-1).route.name, 'Neue lokale Strecke'); assert.equal(saves.at(-1).route.name, 'Neue lokale Strecke');
assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false); assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false);
assert.match(elements.get('editor-route-activated').textContent, /Lokaler Entwurf/); assert.match(elements.get('editor-route-activated').textContent, /Lokaler Entwurf/);
assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.');
assert.equal(elements.get('editor-route-upload').hidden, false);
assert.equal(elements.get('editor-route-upload').disabled, false);
assert.equal(elements.get('editor-route-gpx-group').hidden, true);
elements.get('editor-add-poi').dispatchEvent(pointerEvent()); elements.get('editor-add-poi').dispatchEvent(pointerEvent());
assert.equal(elements.get('editor-poi-audio').clickCount, 0);
assert.equal(elements.get('editor-poi-images').clickCount, 0);
elements.get('editor-poi-audio-add').dispatchEvent(pointerEvent());
elements.get('editor-poi-images-add').dispatchEvent(pointerEvent());
assert.equal(elements.get('editor-poi-audio').clickCount, 1);
assert.equal(elements.get('editor-poi-images').clickCount, 1);
assert.equal(elements.get('editor-poi-lat').value, '50.123456789');
assert.equal(elements.get('editor-poi-lon').value, '10.987654321');
assert.equal(elements.get('editor-poi-radius').value, '4.321');
elements.get('editor-poi-title').value = 'Aussicht'; elements.get('editor-poi-title').value = 'Aussicht';
elements.get('editor-poi-description').value = 'Text zum Streckenpunkt'; elements.get('editor-poi-description').value = 'Text zur Station';
elements.get('editor-poi-lat').value = '50.51';
elements.get('editor-poi-lon').value = '10.52';
elements.get('editor-poi-radius').value = '35';
elements.get('editor-poi-audio').files = [{ name: 'aussicht.mp3', size: 5, type: 'audio/mpeg' }]; elements.get('editor-poi-audio').files = [{ name: 'aussicht.mp3', size: 5, type: 'audio/mpeg' }];
elements.get('editor-poi-audio').dispatchEvent(new Event('change'));
elements.get('editor-poi-images').files = [{ name: 'aussicht.jpg', size: 7, type: 'image/jpeg' }]; elements.get('editor-poi-images').files = [{ name: 'aussicht.jpg', size: 7, type: 'image/jpeg' }];
elements.get('editor-poi-images').dispatchEvent(new Event('change'));
await tick();
elements.get('editor-poi-form').dispatchEvent(submitEvent()); elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick(); await tick();
await tick(); await tick();
let local = packages.get('local-new'); const local = packages.get('local-new');
assert.equal(local.pois.length, 1); assert.equal(local.pois.length, 1);
assert.equal(local.pois[0].title, 'Aussicht'); assert.equal(local.pois[0].title, 'Aussicht');
assert.equal(local.pois[0].lat, 50.123456789); assert.equal(local.pois[0].lat, 50.51);
assert.equal(local.pois[0].lon, 10.987654321); assert.equal(local.pois[0].lon, 10.52);
assert.equal(local.pois[0].triggerRadiusM, 4.321); assert.equal(local.pois[0].triggerRadiusM, 35);
assert.equal(local.pois[0].audio.storage, 'indexeddb'); assert.match(local.pois[0].audio.dataUrl, /^data:audio\/mpeg/);
assert.ok(local.pois[0].audio.mediaKey);
assert.equal(local.pois[0].images.length, 1); assert.equal(local.pois[0].images.length, 1);
assert.equal(local.pois[0].images[0].storage, 'indexeddb'); assert.match(local.pois[0].images[0].dataUrl, /^data:image\/jpeg/);
assert.ok(local.pois[0].images[0].mediaKey);
assert.match(local.gpx, /<trkpt lat="50\.123456789" lon="10\.987654321">/);
const pointListBeforeUpload = elements.get('editor-poi-list');
const pointItemBeforeUpload = pointListBeforeUpload.children.find(child => child.dataset.poiId);
assert.equal(pointItemBeforeUpload.children[1].children[3].textContent, '×');
assert.match(pointItemBeforeUpload.children[1].children[3].className, /editor-icon-danger/);
elements.get('editor-route-name').value = '--- Neue lokale Strecke ---';
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
await tick();
await tick();
await tick();
await tick();
const routeUpload = calls.find(call => call.method === 'POST' && call.url === '/api/routes');
assert.ok(routeUpload);
const gpxUpload = routeUpload.body.entries.find(([name]) => name === 'gpx');
assert.ok(gpxUpload);
assert.equal(gpxUpload[2], 'Neue-lokale-Strecke.gpx');
assert.match(await gpxUpload[1].text(), /<trkpt lat=\"50\.123456789\" lon=\"10\.987654321\">/);
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois'));
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/audio'));
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/pictures'));
local = packages.get('local-new');
assert.equal(local.route.serverRouteId, '22');
assert.ok(local.route.uploadedAt);
assert.equal(elements.get('editor-route-upload').disabled, true);
assert.equal(elements.get('editor-route-upload').textContent, 'Bereits übertragen');
assert.equal(elements.get('editor-status').textContent, 'Upload erfolgreich.');
const poiList = elements.get('editor-poi-list');
const poiItem = poiList.children.find(child => child.dataset.poiId);
const poiEditButton = poiItem.children[1].children[2];
poiList.dispatchEvent(pointerEvent(poiEditButton));
const mediaPreview = elements.get('editor-poi-media-preview');
assert.equal(mediaPreview.children.length, 2);
let imageCard = mediaPreview.children.find(card => card.children[1]?.dataset.mediaKind === 'image');
mediaPreview.dispatchEvent(pointerEvent(imageCard.children[0]));
imageCard = mediaPreview.children.find(card => card.children[1]?.dataset.mediaKind === 'image');
assert.equal(imageCard.children[1].hidden, false);
mediaPreview.dispatchEvent(pointerEvent(imageCard.children[1]));
await tick();
await tick();
local = packages.get('local-new');
assert.equal(local.pois[0].images.length, 0);
let audioCard = elements.get('editor-poi-media-preview').children.find(card => card.children[1]?.dataset.mediaKind === 'audio');
elements.get('editor-poi-media-preview').dispatchEvent(pointerEvent(audioCard.children[0]));
audioCard = elements.get('editor-poi-media-preview').children.find(card => card.children[1]?.dataset.mediaKind === 'audio');
elements.get('editor-poi-media-preview').dispatchEvent(pointerEvent(audioCard.children[1]));
await tick();
await tick();
local = packages.get('local-new');
assert.equal(local.pois[0].audio, null);
assert.equal(elements.get('editor-poi-delete').hidden, false);
elements.get('editor-poi-delete').dispatchEvent(pointerEvent());
await tick();
await tick();
local = packages.get('local-new');
assert.equal(local.pois.length, 0);
context.Wegwichtel.EditorUi.close(); context.Wegwichtel.EditorUi.close();
assert.equal(elements.get('editor-dialog').open, false); assert.equal(elements.get('editor-dialog').open, false);
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false'); assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
}); });
test('local Streckenpunkt ids start at zero and increment from the highest existing id', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { context, elements, packages } = buildEditorContext();
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.openNewLocalRoute();
elements.get('editor-route-name').value = 'ID-Test';
elements.get('editor-route-form').dispatchEvent(submitEvent());
await tick();
await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
assert.equal(elements.get('editor-poi-id').value, '0');
elements.get('editor-poi-title').value = 'Erster Streckenpunkt';
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
assert.equal(elements.get('editor-poi-id').value, '1');
elements.get('editor-poi-title').value = 'Zweiter Streckenpunkt';
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
const saved = packages.get('local-new');
assert.equal(saved.pois.length, 2);
assert.deepEqual(Array.from(saved.pois, poi => poi.id), [0, 1]);
assert.deepEqual(Array.from(saved.pois, poi => poi.title), ['Erster Streckenpunkt', 'Zweiter Streckenpunkt']);
const pointItems = elements.get('editor-poi-list').children.filter(child => child.dataset.poiId);
assert.equal(pointItems.length, 2);
});
test('route and Streckenpunkt list expose red delete actions and remove local data', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { context, elements, packages } = buildEditorContext();
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.openNewLocalRoute();
elements.get('editor-route-name').value = 'Löschtest';
elements.get('editor-route-form').dispatchEvent(submitEvent());
await tick();
await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
elements.get('editor-poi-title').value = 'Streckenpunkt zum Löschen';
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
let pointList = elements.get('editor-poi-list');
let pointItem = pointList.children.find(child => child.dataset.poiId);
const pointDelete = pointItem.children[1].children[3];
assert.equal(pointDelete.textContent, '×');
assert.match(pointDelete.className, /editor-icon-danger/);
pointList.dispatchEvent(pointerEvent(pointDelete));
await tick();
await tick();
assert.equal(packages.get('local-new').pois.length, 0);
elements.get('editor-route-back').dispatchEvent(pointerEvent());
await tick();
await tick();
const routeList = elements.get('editor-route-list');
const localItem = routeList.children.find(child => child.dataset.routeId === 'local-new');
const routeDelete = localItem.children[1].children[1];
assert.equal(routeDelete.textContent, '×');
assert.match(routeDelete.className, /editor-icon-danger/);
routeList.dispatchEvent(pointerEvent(routeDelete));
await tick();
await tick();
assert.equal(packages.has('local-new'), false);
assert.equal(elements.get('editor-status').textContent, 'Strecke gelöscht.');
});
test('editing an activated server route uses PUT and never writes activation state', async () => { test('editing an activated server route uses PUT and never writes activation state', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js'); const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8'); const source = await fs.readFile(sourcePath, 'utf8');
@@ -578,27 +342,13 @@ test('editing an activated server route uses PUT and never writes activation sta
const routeList = elements.get('editor-route-list'); const routeList = elements.get('editor-route-list');
const serverItem = routeList.children.find(child => child.dataset.routeSource === 'server'); const serverItem = routeList.children.find(child => child.dataset.routeSource === 'server');
const serverActions = serverItem.children[1]; const editButton = serverItem.children[1].children[0];
assert.equal(serverActions.children.length, 1);
assert.equal(serverActions.children[0].dataset.editorAction, 'edit-route');
const forgedDelete = new FakeElement('', 'button');
forgedDelete.dataset.editorAction = 'delete-route';
serverActions.appendChild(forgedDelete);
routeList.dispatchEvent(pointerEvent(forgedDelete));
await tick();
await tick();
assert.equal(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/1'), false);
const editButton = serverActions.children[0];
routeList.dispatchEvent(pointerEvent(editButton)); routeList.dispatchEvent(pointerEvent(editButton));
await tick(); await tick();
await tick(); await tick();
assert.equal(elements.get('editor-route-id').value, '1'); assert.equal(elements.get('editor-route-id').value, '1');
assert.equal(elements.get('editor-route-gpx-group').hidden, false);
assert.match(elements.get('editor-route-activated').textContent, /Veröffentlicht am/); assert.match(elements.get('editor-route-activated').textContent, /Veröffentlicht am/);
assert.equal(elements.get('editor-route-upload').hidden, true);
elements.get('editor-route-name').value = 'Freigegebener Waldweg geändert'; elements.get('editor-route-name').value = 'Freigegebener Waldweg geändert';
elements.get('editor-route-description').value = 'Bearbeitet'; elements.get('editor-route-description').value = 'Bearbeitet';
@@ -612,42 +362,200 @@ test('editing an activated server route uses PUT and never writes activation sta
assert.deepEqual(Object.keys(body).sort(), ['description', 'name']); assert.deepEqual(Object.keys(body).sort(), ['description', 'name']);
assert.equal('activated_at' in body, false); assert.equal('activated_at' in body, false);
assert.equal('activatedAt' in body, false); assert.equal('activatedAt' in body, false);
assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.');
}); });
test('local media picker keeps File System Access handles for later upload', async () => { test('editor supports keyboard activation and the dialog fallback without native dialog methods', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js'); const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8'); const source = await fs.readFile(sourcePath, 'utf8');
const { context, elements, storedMediaCalls } = buildEditorContext(); const { context, elements } = buildEditorContext();
const imageFile = Object.assign(new Blob(['photo'], { type: 'image/jpeg' }), { name: 'foto.jpg' }); const dialog = elements.get('editor-dialog');
const handle = { dialog.showModal = undefined;
name: 'foto.jpg', dialog.close = undefined;
async getFile() { return imageFile; }
};
context.showOpenFilePicker = async options => {
assert.equal(options.multiple, true);
return [handle];
};
vm.runInNewContext(source, context, { filename: sourcePath }); vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.openNewLocalRoute();
elements.get('editor-route-name').value = 'Handle-Strecke'; elements.get('editor-button').dispatchEvent(keydownEvent('Enter'));
await tick();
await tick();
assert.equal(dialog.open, true);
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'true');
elements.get('editor-close').dispatchEvent(pointerEvent());
assert.equal(dialog.open, false);
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
});
test('server route GPX upload uses multipart form data and keeps activation state untouched', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { calls, context, elements } = buildEditorContext();
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.open();
const serverItem = elements.get('editor-route-list').children.find(child => child.dataset.routeSource === 'server');
const editButton = serverItem.children[1].children[0];
elements.get('editor-route-list').dispatchEvent(pointerEvent(editButton));
await tick();
await tick();
elements.get('editor-route-name').value = 'Serverroute mit GPX';
elements.get('editor-route-description').value = 'Multipart';
elements.get('editor-route-gpx').files = [{ name: 'route.gpx', type: 'application/gpx+xml' }];
elements.get('editor-route-form').dispatchEvent(submitEvent()); elements.get('editor-route-form').dispatchEvent(submitEvent());
await tick(); await tick();
await tick(); await tick();
await tick();
const update = calls.find(call => call.method === 'PUT' && call.url === '/api/routes/1' && call.body instanceof FakeFormData);
assert.ok(update);
assert.deepEqual(update.body.entries.map(([name]) => name), ['name', 'description', 'file']);
});
test('server POI workflow creates media, edits POIs and persists reordered sequence values', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { calls, context, elements } = buildEditorContext();
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.open();
const routeList = elements.get('editor-route-list');
const serverItem = routeList.children.find(child => child.dataset.routeSource === 'server');
routeList.dispatchEvent(pointerEvent(serverItem.children[1].children[0]));
await tick();
await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent()); elements.get('editor-add-poi').dispatchEvent(pointerEvent());
elements.get('editor-poi-title').value = 'Streckenpunkt mit Bild'; assert.equal(elements.get('editor-poi-lat').value, '50.5');
elements.get('editor-poi-images-add').dispatchEvent(pointerEvent()); assert.equal(elements.get('editor-poi-lon').value, '10.5');
await tick();
assert.equal(elements.get('editor-poi-images').clickCount, 0);
elements.get('editor-poi-title').value = 'Neue Serverstation';
elements.get('editor-poi-description').value = 'Mit Medien';
elements.get('editor-poi-audio').files = [{ name: 'station.mp3', type: 'audio/mpeg' }];
elements.get('editor-poi-images').files = [
{ name: 'eins.jpg', type: 'image/jpeg' },
{ name: 'zwei.jpg', type: 'image/jpeg' }
];
elements.get('editor-poi-form').dispatchEvent(submitEvent()); elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick(); await tick();
await tick(); await tick();
const imageStore = storedMediaCalls.find(call => call.kind === 'image'); await tick();
assert.ok(imageStore);
assert.equal(imageStore.handle, handle); assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/1/pois'));
assert.equal(imageStore.file, imageFile); assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/1/pois/12/audio'));
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes/1/pois/12/images').length, 2);
const poiList = elements.get('editor-poi-list');
const firstPoi = poiList.children.find(child => child.dataset.poiId === '10');
const secondPoi = poiList.children.find(child => child.dataset.poiId === '11');
poiList.dispatchEvent(pointerEvent(secondPoi.children[1].children[2]));
assert.equal(elements.get('editor-poi-id').value, '11');
assert.equal(elements.get('editor-poi-title').value, 'Ziel');
elements.get('editor-poi-title').value = 'Ziel geändert';
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/1/pois/11'));
const refreshedFirstPoi = elements.get('editor-poi-list').children.find(child => child.dataset.poiId === '10');
elements.get('editor-poi-list').dispatchEvent(pointerEvent(refreshedFirstPoi.children[1].children[1]));
await tick();
await tick();
const reorderCalls = calls.filter(call => call.method === 'PUT' && /^\/api\/routes\/1\/pois\/(10|11)$/.test(call.url));
assert.ok(reorderCalls.length >= 2);
assert.ok(reorderCalls.some(call => JSON.parse(call.body).sortOrder === 0));
assert.ok(reorderCalls.some(call => JSON.parse(call.body).sortOrder === 1));
// Moving the first element upward is a no-op and must not trigger another request.
const countBeforeNoop = calls.length;
const firstAfterReorder = elements.get('editor-poi-list').children.find(child => child.dataset.poiId === '11');
elements.get('editor-poi-list').dispatchEvent(pointerEvent(firstAfterReorder.children[1].children[0]));
await tick();
assert.equal(calls.length, countBeforeNoop);
});
test('local route import, local reordering and validation errors remain browser-only', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { calls, context, elements, packages, saves } = buildEditorContext();
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.openNewLocalRoute();
context.Wegwichtel.LocalRoutes.parseWaypoints = () => [
{ id: 700, title: 'Importiert', description: '', lat: 50.7, lon: 10.7, triggerRadiusM: 25, sequence: 0, images: [], audio: null }
];
elements.get('editor-route-name').value = 'Importierte Strecke';
elements.get('editor-route-gpx').files = [{ text: async () => '<gpx><wpt/></gpx>' }];
elements.get('editor-route-form').dispatchEvent(submitEvent());
await tick();
await tick();
assert.equal(packages.get('local-new').pois[0].id, 700);
assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false);
await context.Wegwichtel.EditorUi.open();
const localItem = elements.get('editor-route-list').children.find(child => child.dataset.routeId === 'local-existing');
elements.get('editor-route-list').dispatchEvent(pointerEvent(localItem.children[1].children[0]));
await tick();
await tick();
const localFirst = elements.get('editor-poi-list').children.find(child => child.dataset.poiId === '21');
elements.get('editor-poi-list').dispatchEvent(pointerEvent(localFirst.children[1].children[1]));
await tick();
await tick();
assert.deepEqual(saves.at(-1).pois.map(poi => poi.id), [22, 21]);
context.Wegwichtel.App.currentPosition = () => null;
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
elements.get('editor-poi-title').value = 'Ungültige Position';
elements.get('editor-poi-lat').value = 'keine-breite';
elements.get('editor-poi-lon').value = 'keine-laenge';
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
assert.equal(elements.get('editor-status').dataset.kind, 'error');
assert.match(elements.get('editor-status').textContent, /gültige Breiten- und Längengrade/);
});
test('editor handles empty, malformed and failing route responses without stale status', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
{
const { context, elements } = buildEditorContext();
context.Wegwichtel.LocalRoutes.list = () => [];
context.fetch = async () => ({ ok: true, status: 200, text: async () => '{' });
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.open();
const emptyItems = elements.get('editor-route-list').children.filter(child => !child.dataset.routeId);
assert.ok(emptyItems.length >= 4);
assert.equal(elements.get('editor-status').hidden, true);
}
{
const { context, elements } = buildEditorContext();
context.Wegwichtel.LocalRoutes.list = () => { throw new Error('Lokaler Speicher defekt'); };
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.open();
assert.equal(elements.get('editor-status').dataset.kind, 'error');
assert.match(elements.get('editor-status').textContent, /Lokaler Speicher defekt/);
}
{
const { context, elements } = buildEditorContext();
context.fetch = async () => ({ ok: false, status: 503, text: async () => 'Dienst nicht verfügbar' });
vm.runInNewContext(source, context, { filename: sourcePath });
elements.get('editor-button').dispatchEvent(pointerEvent());
await tick();
await tick();
assert.equal(elements.get('editor-status').dataset.kind, 'error');
assert.match(elements.get('editor-status').textContent, /Dienst nicht verfügbar/);
}
}); });
+60 -224
View File
@@ -217,83 +217,6 @@ class FakeStorage {
} }
} }
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 { class FakeCustomEvent extends Event {
constructor(type, options = {}) { constructor(type, options = {}) {
super(type); super(type);
@@ -311,6 +234,11 @@ class FakeFileReader extends EventTarget {
readAsDataURL(file) { readAsDataURL(file) {
queueMicrotask(() => { queueMicrotask(() => {
if (file.failReader) {
this.error = new Error('reader failed');
this.dispatchEvent(new Event('error'));
return;
}
const type = file.type || 'application/octet-stream'; const type = file.type || 'application/octet-stream';
this.result = file.invalidReaderResult ? { type } : `data:${type};base64,VEVTVA==`; this.result = file.invalidReaderResult ? { type } : `data:${type};base64,VEVTVA==`;
this.dispatchEvent(new Event('load')); this.dispatchEvent(new Event('load'));
@@ -322,13 +250,11 @@ async function loadLocalRoutes(options = {}) {
const sourcePath = path.join(root, 'public/js/local-routes.js'); const sourcePath = path.join(root, 'public/js/local-routes.js');
const source = await fs.readFile(sourcePath, 'utf8'); const source = await fs.readFile(sourcePath, 'utf8');
const localStorage = new FakeStorage(); const localStorage = new FakeStorage();
const indexedDB = new FakeIndexedDB();
const document = new EventTarget(); const document = new EventTarget();
const events = []; const events = [];
document.addEventListener('wegwichtel:local-routes-changed', event => events.push(event.detail)); document.addEventListener('wegwichtel:local-routes-changed', event => events.push(event.detail));
const context = { const context = {
Blob,
CustomEvent: FakeCustomEvent, CustomEvent: FakeCustomEvent,
DOMParser: FakeDOMParser, DOMParser: FakeDOMParser,
Event, Event,
@@ -338,15 +264,13 @@ async function loadLocalRoutes(options = {}) {
crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555' }, crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555' },
document, document,
globalThis: null, globalThis: null,
indexedDB,
localStorage, localStorage,
queueMicrotask, queueMicrotask,
URL: { createObjectURL: blob => `blob:test-${blob.size}` },
Wegwichtel: {} Wegwichtel: {}
}; };
context.globalThis = context; context.globalThis = context;
vm.runInNewContext(source, context, { filename: sourcePath }); vm.runInNewContext(source, context, { filename: sourcePath });
return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage }; return { api: context.Wegwichtel.LocalRoutes, events, localStorage };
} }
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?> const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
@@ -405,14 +329,8 @@ test('local route package stores GPX and JSON separately and reconstructs an app
const saved = api.save({ const saved = api.save({
id: routeId, id: routeId,
gpx: api.emptyGpx('Neue Route'), gpx: sampleGpx,
route: { route: { id: routeId, name: 'Neue Route', description: 'Lokal gespeichert' },
id: routeId,
name: 'Neue Route',
description: 'Lokal gespeichert',
serverRouteId: 42,
uploadedAt: '2026-09-27T18:00:00.000Z'
},
pois: [ pois: [
{ {
id: 101, id: 101,
@@ -446,18 +364,15 @@ test('local route package stores GPX and JSON separately and reconstructs an app
const gpx = localStorage.getItem(`${prefix}.${encodedId}.gpx`); const gpx = localStorage.getItem(`${prefix}.${encodedId}.gpx`);
const metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`)); const metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`));
assert.match(gpx, /<trkpt lat="50\.1" lon="10\.1">/); assert.match(gpx, /<wpt lat="50\.1" lon="10\.1">/);
assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/); assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/);
assert.doesNotMatch(gpx, /<wpt/); assert.ok(gpx.indexOf('<wpt') < gpx.indexOf('<trk>'), 'GPX waypoints must be serialized before the track');
assert.equal((gpx.match(/<trkpt /g) || []).length, 2);
assert.equal(metadata.route.name, 'Neue Route'); assert.equal(metadata.route.name, 'Neue Route');
assert.equal(metadata.route.serverRouteId, 42);
assert.equal(metadata.route.uploadedAt, '2026-09-27T18:00:00.000Z');
assert.equal(metadata.pois[0].audio.name, 'start.mp3'); assert.equal(metadata.pois[0].audio.name, 'start.mp3');
assert.equal('gpx' in metadata, false); assert.equal('gpx' in metadata, false);
assert.equal('_source' in metadata.route, false); assert.equal('_source' in metadata.route, false);
const appRoute = await api.getAppRoute(routeId); const appRoute = api.getAppRoute(routeId);
assert.equal(appRoute.source, 'local'); assert.equal(appRoute.source, 'local');
assert.equal(appRoute.local, true); assert.equal(appRoute.local, true);
assert.equal(appRoute.pois.length, 2); assert.equal(appRoute.pois.length, 2);
@@ -465,7 +380,7 @@ test('local route package stores GPX and JSON separately and reconstructs an app
assert.equal(appRoute.pois[0].images[0].url, 'data:image/jpeg;base64,Qg=='); assert.equal(appRoute.pois[0].images[0].url, 'data:image/jpeg;base64,Qg==');
assert.equal(appRoute.points.length, 2); assert.equal(appRoute.points.length, 2);
assert.ok(appRoute.distanceM > 0); assert.ok(appRoute.distanceM > 0);
assert.equal(Math.round(appRoute.elevationGainM), 0); assert.equal(Math.round(appRoute.elevationGainM), 12);
const listed = api.listAppRoutes(); const listed = api.listAppRoutes();
assert.equal(listed.length, 1); assert.equal(listed.length, 1);
assert.equal(listed[0].id, routeId); assert.equal(listed[0].id, routeId);
@@ -487,49 +402,10 @@ test('GPX waypoint import reads stable POI ids and validates coordinates', async
const waypointOnly = api.syncGpx( const waypointOnly = api.syncGpx(
`<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`, `<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`,
{ name: 'Nur Streckenpunkt' }, { name: 'Nur POI' },
[{ id: 2, title: 'Einzelner Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 }] [{ id: 2, title: 'Einzelner POI', lat: 50.2, lon: 10.2, sequence: 0 }]
); );
assert.match(waypointOnly, /<trkpt lat="50.2" lon="10.2">/); assert.match(waypointOnly, /<wpt lat="50.2" lon="10.2">/);
assert.doesNotMatch(waypointOnly, /<wpt\b/);
});
test('local points are stored directly as managed GPX Streckenpunkte and uploaded unchanged', async () => {
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, /<trkpt lat="50\.123456789" lon="10\.987654321">/);
assert.match(saved.gpx, /<trkpt lat="50\.223456789" lon="11\.087654321">/);
assert.match(saved.gpx, /<wegwichtel:poiId>1<\/wegwichtel:poiId>/);
assert.equal((saved.gpx.match(/<trkpt /g) || []).length, 2);
assert.doesNotMatch(saved.gpx, /<wpt\b/);
assert.equal(api.gpxForUpload(saved), saved.gpx, 'upload must use the already stored track without conversion');
});
test('sync keeps imported foreign Streckenpunkte and upload rejects a route without Streckenpunkte', async () => {
const { api } = await loadLocalRoutes();
const synced = api.syncGpx(sampleGpx, { name: 'Import' }, [
{ id: 200, title: 'Neuer Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 }
]);
assert.match(synced, /<ele>100<\/ele>/);
assert.match(synced, /<ele>112<\/ele>/);
assert.match(synced, /<wegwichtel:poiId>200<\/wegwichtel:poiId>/);
assert.equal((synced.match(/<trkpt /g) || []).length, 3);
assert.equal(api.gpxForUpload({ gpx: sampleGpx, route: { name: 'Import' }, pois: [] }), sampleGpx);
assert.throws(() => api.gpxForUpload({
gpx: api.emptyGpx('Leer'),
route: { name: 'Leer' },
pois: []
}), /noch keine Streckenpunkte im GPX-Track/);
}); });
test('remove updates index and dispatches a local-route change event', async () => { test('remove updates index and dispatches a local-route change event', async () => {
@@ -542,11 +418,11 @@ test('remove updates index and dispatches a local-route change event', async ()
}); });
assert.equal(api.list().length, 1); assert.equal(api.list().length, 1);
await api.remove('local-remove'); api.remove('local-remove');
assert.equal(api.list().length, 0); assert.equal(api.list().length, 0);
assert.equal(events.at(-1).action, 'removed'); assert.equal(events.at(-1).action, 'removed');
assert.equal(events.at(-1).id, 'local-remove'); assert.equal(events.at(-1).id, 'local-remove');
await assert.rejects(api.getAppRoute('local-remove'), /nicht gefunden/); assert.throws(() => api.getAppRoute('local-remove'), /nicht gefunden/);
}); });
test('quota failure rolls back package writes and keeps the previous package readable', async () => { test('quota failure rolls back package writes and keeps the previous package readable', async () => {
@@ -564,40 +440,11 @@ test('quota failure rolls back package writes and keeps the previous package rea
gpx: sampleGpx, gpx: sampleGpx,
route: { name: 'Nachher' }, route: { name: 'Nachher' },
pois: [] pois: []
}), /Speicher für Streckendaten ist voll/); }), /Browser-Speicher ist voll/);
assert.equal(api.get('local-quota').route.name, 'Vorher'); 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: 'Streckenpunkt 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 () => { test('file helpers support Blob.text and FileReader-backed media data URLs', async () => {
const { api } = await loadLocalRoutes(); const { api } = await loadLocalRoutes();
@@ -614,66 +461,55 @@ test('file helpers support Blob.text and FileReader-backed media data URLs', asy
assert.equal(await api.fileToDataUrl(null), null); 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); test('local route storage tolerates corrupt index and package JSON and generates unique POI ids', async () => {
assert.equal(media.storage, 'file-handle'); const { api, localStorage } = await loadLocalRoutes();
const record = indexedDB.records.get(media.mediaKey); const prefix = api.STORAGE_PREFIX;
assert.equal(record.handle, handle);
assert.equal('blob' in record, false);
api.save({ localStorage.setItem(`${prefix}.index`, '{not-json');
id: 'route-handle', assert.deepEqual(Array.from(api.list()), []);
gpx: api.emptyGpx('Handle'),
route: { name: 'Handle' },
pois: [{ id: 7, title: 'Streckenpunkt', 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); const id = 'broken-package';
permission = 'prompt'; const encoded = encodeURIComponent(id);
await assert.rejects(api.mediaBlob(media), /erneut erlaubt/); localStorage.setItem(`${prefix}.${encoded}.gpx`, api.emptyGpx('Defekt'));
assert.equal(await api.mediaBlob(media, { requestPermission: true }), file); localStorage.setItem(`${prefix}.${encoded}.json`, '{not-json');
assert.equal(permissionRequests, 1); assert.equal(api.get(id), null);
const firstPoiId = api.createPoiId();
const secondPoiId = api.createPoiId();
assert.ok(Number.isSafeInteger(firstPoiId));
assert.ok(Number.isSafeInteger(secondPoiId));
assert.notEqual(firstPoiId, secondPoiId);
}); });
test('multiple Streckenpunkte with large local media keep Local Storage metadata small', async () => { test('app route conversion supports routes without track points and default metadata', async () => {
const { api, localStorage } = await loadLocalRoutes(); const { api } = await loadLocalRoutes();
const routeId = 'multi-media'; const route = api.toAppRoute({
const large = new Uint8Array(1024 * 1024); id: 'poi-only',
const firstFile = Object.assign(new Blob([large], { type: 'image/jpeg' }), { name: 'eins.jpg' }); gpx: api.emptyGpx('Nur POIs'),
const secondFile = Object.assign(new Blob([large], { type: 'image/jpeg' }), { name: 'zwei.jpg' }); route: {},
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 Streckenpunkte'),
route: { name: 'Mehrere Streckenpunkte' },
pois: [ pois: [
{ id: 1, title: 'Erster Streckenpunkt', lat: 50.1, lon: 10.1, images: [first] }, { id: 1, lat: 50, lon: 10, sequence: 0, images: [{ dataUrl: '' }, { dataUrl: 'data:image/png;base64,QQ==' }] },
{ id: 2, title: 'Zweiter Streckenpunkt', lat: 50.2, lon: 10.2, images: [second] } { id: 2, title: 'Ziel', lat: 50.001, lon: 10.001, sequence: 1, triggerRadiusM: 0 }
] ]
}); });
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent(routeId)}.json`); assert.equal(route.name, 'Unbenannte Strecke');
assert.ok(metadata.length < 5000, `metadata unexpectedly large: ${metadata.length}`); assert.equal(route.description, '');
assert.doesNotMatch(metadata, /data:image/); assert.equal(route.points.length, 2);
assert.equal(api.get(routeId).pois.length, 2); assert.equal(route.pois[0].title, 'Unbenannte Station');
assert.equal(route.pois[0].triggerRadiusM, 25);
assert.equal(route.pois[0].images.length, 1);
assert.ok(route.distanceM > 0);
const empty = api.toAppRoute({ id: 'empty', gpx: api.emptyGpx('Leer'), route: {}, pois: [] });
assert.equal(empty.distanceM, 0);
assert.equal(empty.bounds, null);
assert.equal(empty.center, null);
assert.equal(empty.start, null);
});
test('media helper propagates FileReader errors', async () => {
const { api } = await loadLocalRoutes();
await assert.rejects(api.fileToDataUrl({ failReader: true }), /reader failed/);
}); });