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, '..'); async function read(relativePath) { return fs.readFile(path.join(root, relativePath), 'utf8'); } function tick() { return new Promise(resolve => setTimeout(resolve, 0)); } test('editor markup and source describe local draft creation instead of immediate publication', async () => { const html = await read('public/index.html'); const css = await read('public/css/editor.css'); const ui = await read('public/js/editor-ui.js'); assert.match(html, /id="editor-button"/); assert.match(html, /]+accept="[^\"]*\.gpx/); assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"/); assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple/); assert.match(html, /ausschließlich in diesem Browser/i); assert.match(css, /\.editor-dialog/); assert.match(css, /\.editor-poi-list/); assert.match(css, /\.editor-route-group/); assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/); assert.match(ui, /ns\.LocalRoutes\.save\(/); assert.match(ui, /ns\.LocalRoutes\.parseWaypoints\(/); assert.match(ui, /ns\.LocalRoutes\.fileToDataUrl\(/); assert.match(ui, /move-poi-up/); assert.match(ui, /move-poi-down/); assert.match(ui, /sortOrder:\s*index/); assert.match(ui, /pointerup/); assert.doesNotMatch(ui, /sendJson\(['"]\/routes['"],\s*['"]POST['"]/); assert.doesNotMatch(ui, /activated_at['"],\s*['"]/); assert.doesNotMatch(ui, /activatedAt:\s*null/); assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/); }); class FakeElement extends EventTarget { constructor(id = '', tagName = 'div') { super(); this.id = id; this.tagName = tagName.toUpperCase(); this.hidden = false; this.open = false; this.value = ''; this.className = ''; this.children = []; this.dataset = {}; this.files = []; this.parentElement = null; this.attributes = new Map(); this.disabled = false; this.title = ''; this.type = ''; this._textContent = ''; } get textContent() { return this._textContent; } set textContent(value) { this._textContent = String(value ?? ''); if (this._textContent === '') this.children = []; } append(...children) { children.forEach(child => this.appendChild(child)); } appendChild(child) { if (!child) return child; child.parentElement = this; this.children.push(child); return child; } setAttribute(name, value) { this.attributes.set(name, String(value)); if (name === 'open') this.open = true; } removeAttribute(name) { this.attributes.delete(name); if (name === 'open') this.open = false; } showModal() { this.open = true; } close() { this.open = false; } closest(selector) { if (selector === '[data-route-id]') return this.dataset.routeId ? this : this.parentElement?.closest(selector) ?? null; if (selector === '[data-poi-id]') return this.dataset.poiId ? this : this.parentElement?.closest(selector) ?? null; if (selector === '[data-editor-action]') return this.dataset.editorAction ? this : this.parentElement?.closest(selector) ?? null; return null; } } class FakeFormData { constructor() { this.entries = []; } append(name, value) { this.entries.push([name, value]); } } function jsonResponse(value, ok = true, status = 200) { return { ok, status, text: async () => JSON.stringify(value) }; } function pointerEvent(target = null, { pointerType = 'touch', button = 0, isPrimary = true } = {}) { const event = new Event('pointerup', { cancelable: true }); Object.defineProperties(event, { button: { value: button }, isPrimary: { value: isPrimary }, pointerType: { value: pointerType } }); if (target) Object.defineProperty(event, 'target', { value: target }); 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() { return new Event('submit', { cancelable: true }); } function buildEditorContext() { const ids = [ 'editor-add-poi', 'editor-button', 'editor-close', 'editor-dialog', 'editor-new-route', 'editor-poi-audio', 'editor-poi-cancel', 'editor-poi-description', 'editor-poi-form', 'editor-poi-form-title', 'editor-poi-form-view', 'editor-poi-id', 'editor-poi-images', 'editor-poi-lat', 'editor-poi-list', 'editor-poi-lon', 'editor-poi-radius', 'editor-poi-title', 'editor-route-activated', 'editor-route-back', 'editor-route-description', 'editor-route-detail-view', 'editor-route-form', 'editor-route-gpx', 'editor-route-id', 'editor-route-list', 'editor-route-list-view', 'editor-route-name', 'editor-status' ]; const elements = new Map(ids.map(id => [id, new FakeElement(id)])); const document = new EventTarget(); document.readyState = 'complete'; document.getElementById = id => elements.get(id) ?? null; document.createElement = tagName => new FakeElement('', tagName); const calls = []; const saves = []; const packages = new Map([ ['local-existing', { id: 'local-existing', gpx: '', route: { id: 'local-existing', name: 'Lokaler Waldweg', description: 'Lokal' }, 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 } ] }] ]); const serverRoute = { id: 1, name: 'Freigegebener Waldweg', description: 'Serverroute', activatedAt: '2026-09-01 08:00:00', 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 = {}) { const method = options.method || 'GET'; calls.push({ body: options.body, method, url }); if (url === '/api/routes?activatedOnly=true' && method === 'GET') return jsonResponse({ routes: [serverRoute] }); if (url === '/api/routes/1' && method === 'GET') return jsonResponse({ route: serverRoute }); 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); return jsonResponse({ route: { ...serverRoute, ...body } }); } 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+\/(audio|images)$/.test(url) && method === 'POST') return jsonResponse({ ok: true }); return { ok: false, status: 404, text: async () => `unexpected ${method} ${url}` }; } const LocalRoutes = { createPoiId: () => 9001, createRouteId: () => 'local-new', emptyGpx: name => `${name}`, fileToDataUrl: async file => ({ dataUrl: `data:${file.type || 'application/octet-stream'};base64,TEST`, name: file.name, size: file.size || 1, type: file.type || 'application/octet-stream' }), get: id => packages.get(String(id)) || null, list: () => [...packages.values()], parseWaypoints: () => [], readFileAsText: file => file.text(), save: input => { const id = String(input.id); const now = '2026-09-19T14:00:00.000Z'; const saved = { id, gpx: input.gpx, route: { ...input.route, id, updatedAt: now }, pois: input.pois.map((poi, sequence) => ({ ...poi, sequence })) }; packages.set(id, saved); saves.push(structuredClone(saved)); return saved; } }; const context = { document, Event, EventTarget, fetch, FormData: FakeFormData, globalThis: null, structuredClone, Wegwichtel: { App: { currentPosition: () => ({ lat: 50.5, lon: 10.5 }) }, Config: { apiBase: '/api/' }, LocalRoutes } }; context.globalThis = context; return { calls, context, elements, packages, saves, serverRoute }; } 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 source = await fs.readFile(sourcePath, 'utf8'); const { calls, context, elements, packages, saves } = buildEditorContext(); vm.runInNewContext(source, context, { filename: sourcePath }); await context.Wegwichtel.EditorUi.open(); assert.equal(elements.get('editor-dialog').open, true); assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'true'); assert.ok(calls.some(call => call.method === 'GET' && call.url === '/api/routes?activatedOnly=true')); await context.Wegwichtel.EditorUi.openNewLocalRoute(); elements.get('editor-route-name').value = 'Neue lokale Strecke'; elements.get('editor-route-description').value = 'Nur im Browser'; elements.get('editor-route-form').dispatchEvent(submitEvent()); await tick(); await tick(); assert.ok(packages.has('local-new')); assert.equal(saves.at(-1).route.name, 'Neue lokale Strecke'); assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false); assert.match(elements.get('editor-route-activated').textContent, /Lokaler Entwurf/); elements.get('editor-add-poi').dispatchEvent(pointerEvent()); elements.get('editor-poi-title').value = 'Aussicht'; 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-images').files = [{ name: 'aussicht.jpg', size: 7, type: 'image/jpeg' }]; elements.get('editor-poi-form').dispatchEvent(submitEvent()); await tick(); await tick(); const local = packages.get('local-new'); assert.equal(local.pois.length, 1); assert.equal(local.pois[0].title, 'Aussicht'); assert.equal(local.pois[0].lat, 50.51); assert.equal(local.pois[0].lon, 10.52); assert.equal(local.pois[0].triggerRadiusM, 35); assert.match(local.pois[0].audio.dataUrl, /^data:audio\/mpeg/); assert.equal(local.pois[0].images.length, 1); assert.match(local.pois[0].images[0].dataUrl, /^data:image\/jpeg/); context.Wegwichtel.EditorUi.close(); assert.equal(elements.get('editor-dialog').open, false); assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false'); }); 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 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'); const editButton = serverItem.children[1].children[0]; routeList.dispatchEvent(pointerEvent(editButton)); await tick(); await tick(); assert.equal(elements.get('editor-route-id').value, '1'); assert.match(elements.get('editor-route-activated').textContent, /Veröffentlicht am/); elements.get('editor-route-name').value = 'Freigegebener Waldweg geändert'; elements.get('editor-route-description').value = 'Bearbeitet'; elements.get('editor-route-form').dispatchEvent(submitEvent()); await tick(); await tick(); const update = calls.find(call => call.method === 'PUT' && call.url === '/api/routes/1'); assert.ok(update); const body = JSON.parse(update.body); assert.deepEqual(Object.keys(body).sort(), ['description', 'name']); assert.equal('activated_at' in body, false); assert.equal('activatedAt' in body, false); }); 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 source = await fs.readFile(sourcePath, 'utf8'); const { context, elements } = buildEditorContext(); const dialog = elements.get('editor-dialog'); dialog.showModal = undefined; dialog.close = undefined; vm.runInNewContext(source, context, { filename: sourcePath }); 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()); 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()); assert.equal(elements.get('editor-poi-lat').value, '50.5'); assert.equal(elements.get('editor-poi-lon').value, '10.5'); 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()); await tick(); await tick(); await tick(); assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/1/pois')); 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 () => '' }]; 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/); } });