diff --git a/test/app-route-control-runtime.test.js b/test/app-route-control-runtime.test.js new file mode 100644 index 0000000..54b18e0 --- /dev/null +++ b/test/app-route-control-runtime.test.js @@ -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('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +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')); +}); diff --git a/test/editor-ui.test.js b/test/editor-ui.test.js index b136fd8..17dabcd 100644 --- a/test/editor-ui.test.js +++ b/test/editor-ui.test.js @@ -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 ui = await read('public/js/editor-ui.js'); - assert.match(html, /id="editor-button"[^>]+aria-label="Strecken verwalten"/); - assert.match(html, /id="route-control-button"[\s\S]*?]+src="images\/icons\/route-management\.svg"[^>]+alt=""[^>]*>/); - assert.doesNotMatch(html, /class="editor-button-icon"/); + assert.match(html, /id="editor-button"/); assert.match(html, /]+accept="[^\"]*\.gpx/); - assert.match(html, /id="editor-poi-audio-add"[^>]+type="button"/); - assert.match(html, /id="editor-poi-images-add"[^>]+type="button"/); - 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, /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); - await fs.access(path.join(root, 'public/images/icons/route-management.svg')); assert.match(css, /\.editor-dialog/); assert.match(css, /\.editor-poi-list/); 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, /ns\.LocalRoutes\.save\(/); assert.match(ui, /ns\.LocalRoutes\.parseWaypoints\(/); - assert.match(ui, /ns\.LocalRoutes\.storeMediaFile\(/); - assert.match(ui, /showOpenFilePicker/); - assert.match(ui, /requestPermission:\s*true/); - assert.match(ui, /ns\.LocalRoutes\.gpxForUpload\(/); + assert.match(ui, /ns\.LocalRoutes\.fileToDataUrl\(/); assert.match(ui, /move-poi-up/); 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, /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['"]/); - 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 { @@ -99,7 +64,6 @@ class FakeElement extends EventTarget { this.title = ''; this.type = ''; this._textContent = ''; - this.clickCount = 0; } get textContent() { @@ -132,10 +96,6 @@ class FakeElement extends EventTarget { if (name === 'open') this.open = false; } - click() { - this.clickCount += 1; - } - showModal() { this.open = true; } @@ -157,8 +117,8 @@ class FakeFormData { this.entries = []; } - append(name, value, filename) { - this.entries.push([name, value, filename]); + append(name, value) { + this.entries.push([name, value]); } } @@ -181,6 +141,13 @@ function pointerEvent(target = null, { pointerType = 'touch', button = 0, isPrim 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 }); } @@ -193,21 +160,16 @@ function buildEditorContext() { 'editor-dialog', 'editor-new-route', 'editor-poi-audio', - 'editor-poi-audio-add', 'editor-poi-cancel', - 'editor-poi-delete', 'editor-poi-description', 'editor-poi-form', 'editor-poi-form-title', 'editor-poi-form-view', 'editor-poi-id', 'editor-poi-images', - 'editor-poi-images-add', 'editor-poi-lat', 'editor-poi-list', 'editor-poi-lon', - 'editor-poi-media-preview', - 'editor-poi-media-status', 'editor-poi-radius', 'editor-poi-title', 'editor-route-activated', @@ -216,12 +178,10 @@ function buildEditorContext() { 'editor-route-detail-view', 'editor-route-form', 'editor-route-gpx', - 'editor-route-gpx-group', 'editor-route-id', 'editor-route-list', 'editor-route-list-view', 'editor-route-name', - 'editor-route-upload', 'editor-status' ]; const elements = new Map(ids.map(id => [id, new FakeElement(id)])); @@ -237,7 +197,10 @@ function buildEditorContext() { id: 'local-existing', gpx: '', 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', 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 }] + 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 (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' && 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 === 'DELETE') return jsonResponse({ deleted: true }); 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|pictures)$/.test(url) && (method === 'POST' || method === 'PUT')) 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 }); + 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 mediaBlobs = new Map(); - const storedMediaCalls = []; const LocalRoutes = { createPoiId: () => 9001, createRouteId: () => 'local-new', - emptyGpx: name => `${name}`, + emptyGpx: name => `${name}`, fileToDataUrl: async file => ({ dataUrl: `data:${file.type || 'application/octet-stream'};base64,TEST`, name: file.name, @@ -288,50 +245,17 @@ function buildEditorContext() { type: file.type || 'application/octet-stream' }), 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()], parseWaypoints: () => [], 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 => { const id = String(input.id); const now = '2026-09-19T14:00:00.000Z'; - const pois = input.pois.map((poi, sequence) => ({ ...poi, sequence })); - const gpxPoints = pois - .map(poi => `${poi.title || ''}${poi.id}`) - .join(''); const saved = { id, - gpx: `${gpxPoints}`, + gpx: input.gpx, route: { ...input.route, id, updatedAt: now }, - pois + pois: input.pois.map((poi, sequence) => ({ ...poi, sequence })) }; packages.set(id, saved); saves.push(structuredClone(saved)); @@ -340,7 +264,6 @@ function buildEditorContext() { }; const context = { - Blob, document, Event, EventTarget, @@ -348,18 +271,17 @@ function buildEditorContext() { FormData: FakeFormData, globalThis: null, structuredClone, - URL: { createObjectURL: () => 'blob:test-preview', revokeObjectURL() {} }, Wegwichtel: { - App: { currentPosition: () => ({ lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }) }, + App: { currentPosition: () => ({ lat: 50.5, lon: 10.5 }) }, Config: { apiBase: '/api/' }, LocalRoutes } }; 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 source = await fs.readFile(sourcePath, 'utf8'); 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')); 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-description').value = 'Nur im Browser'; 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(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false); 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()); - 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-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').dispatchEvent(new Event('change')); 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()); 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[0].title, 'Aussicht'); - assert.equal(local.pois[0].lat, 50.123456789); - assert.equal(local.pois[0].lon, 10.987654321); - assert.equal(local.pois[0].triggerRadiusM, 4.321); - assert.equal(local.pois[0].audio.storage, 'indexeddb'); - assert.ok(local.pois[0].audio.mediaKey); + 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.equal(local.pois[0].images[0].storage, 'indexeddb'); - assert.ok(local.pois[0].images[0].mediaKey); - assert.match(local.gpx, //); - - 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(), //); - 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); + 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('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 () => { const sourcePath = path.join(root, 'public/js/editor-ui.js'); 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 serverItem = routeList.children.find(child => child.dataset.routeSource === 'server'); - const serverActions = serverItem.children[1]; - 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]; + 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.equal(elements.get('editor-route-gpx-group').hidden, false); 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-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.equal('activated_at' 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 source = await fs.readFile(sourcePath, 'utf8'); - const { context, elements, storedMediaCalls } = buildEditorContext(); - const imageFile = Object.assign(new Blob(['photo'], { type: 'image/jpeg' }), { name: 'foto.jpg' }); - const handle = { - name: 'foto.jpg', - async getFile() { return imageFile; } - }; - context.showOpenFilePicker = async options => { - assert.equal(options.multiple, true); - return [handle]; - }; + const { context, elements } = buildEditorContext(); + const dialog = elements.get('editor-dialog'); + dialog.showModal = undefined; + dialog.close = undefined; 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()); 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-poi-title').value = 'Streckenpunkt mit Bild'; - elements.get('editor-poi-images-add').dispatchEvent(pointerEvent()); - await tick(); - assert.equal(elements.get('editor-poi-images').clickCount, 0); + 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(); - const imageStore = storedMediaCalls.find(call => call.kind === 'image'); - assert.ok(imageStore); - assert.equal(imageStore.handle, handle); - assert.equal(imageStore.file, imageFile); + 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/); + } }); diff --git a/test/local-routes.test.js b/test/local-routes.test.js index 6c59590..0caf9b8 100644 --- a/test/local-routes.test.js +++ b/test/local-routes.test.js @@ -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 { constructor(type, options = {}) { super(type); @@ -311,6 +234,11 @@ class FakeFileReader extends EventTarget { readAsDataURL(file) { queueMicrotask(() => { + if (file.failReader) { + this.error = new Error('reader failed'); + this.dispatchEvent(new Event('error')); + return; + } const type = file.type || 'application/octet-stream'; this.result = file.invalidReaderResult ? { type } : `data:${type};base64,VEVTVA==`; this.dispatchEvent(new Event('load')); @@ -322,13 +250,11 @@ async function loadLocalRoutes(options = {}) { const sourcePath = path.join(root, 'public/js/local-routes.js'); const source = await fs.readFile(sourcePath, 'utf8'); const localStorage = new FakeStorage(); - const indexedDB = new FakeIndexedDB(); const document = new EventTarget(); const events = []; document.addEventListener('wegwichtel:local-routes-changed', event => events.push(event.detail)); const context = { - Blob, CustomEvent: FakeCustomEvent, DOMParser: FakeDOMParser, Event, @@ -338,15 +264,13 @@ async function loadLocalRoutes(options = {}) { crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555' }, document, globalThis: null, - indexedDB, localStorage, queueMicrotask, - URL: { createObjectURL: blob => `blob:test-${blob.size}` }, Wegwichtel: {} }; context.globalThis = context; vm.runInNewContext(source, context, { filename: sourcePath }); - return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage }; + return { api: context.Wegwichtel.LocalRoutes, events, localStorage }; } const sampleGpx = ` @@ -405,14 +329,8 @@ test('local route package stores GPX and JSON separately and reconstructs an app const saved = api.save({ id: routeId, - gpx: api.emptyGpx('Neue Route'), - route: { - id: routeId, - name: 'Neue Route', - description: 'Lokal gespeichert', - serverRouteId: 42, - uploadedAt: '2026-09-27T18:00:00.000Z' - }, + gpx: sampleGpx, + route: { id: routeId, name: 'Neue Route', description: 'Lokal gespeichert' }, pois: [ { 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 metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`)); - assert.match(gpx, //); + assert.match(gpx, //); assert.match(gpx, /101<\/wegwichtel:poiId>/); - assert.doesNotMatch(gpx, /'), 'GPX waypoints must be serialized before the track'); 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('gpx' in metadata, 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.local, true); 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.points.length, 2); assert.ok(appRoute.distanceM > 0); - assert.equal(Math.round(appRoute.elevationGainM), 0); + assert.equal(Math.round(appRoute.elevationGainM), 12); const listed = api.listAppRoutes(); assert.equal(listed.length, 1); 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( ``, - { name: 'Nur Streckenpunkt' }, - [{ id: 2, title: 'Einzelner Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 }] + { name: 'Nur POI' }, + [{ id: 2, title: 'Einzelner POI', lat: 50.2, lon: 10.2, sequence: 0 }] ); - assert.match(waypointOnly, //); - assert.doesNotMatch(waypointOnly, / { - const { api } = await loadLocalRoutes(); - const saved = api.save({ - id: 'local-upload', - gpx: api.emptyGpx('Lokale Strecke'), - route: { name: 'Lokale Strecke' }, - pois: [ - { id: 1, title: 'Start', lat: 50.123456789, lon: 10.987654321, sequence: 0 }, - { id: 2, title: 'Ziel', lat: 50.223456789, lon: 11.087654321, sequence: 1 } - ] - }); - - assert.match(saved.gpx, //); - assert.match(saved.gpx, //); - assert.match(saved.gpx, /1<\/wegwichtel:poiId>/); - assert.equal((saved.gpx.match(/ { - const { api } = await loadLocalRoutes(); - const synced = api.syncGpx(sampleGpx, { name: 'Import' }, [ - { id: 200, title: 'Neuer Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 } - ]); - - assert.match(synced, /100<\/ele>/); - assert.match(synced, /112<\/ele>/); - assert.match(synced, /200<\/wegwichtel:poiId>/); - assert.equal((synced.match(/ api.gpxForUpload({ - gpx: api.emptyGpx('Leer'), - route: { name: 'Leer' }, - pois: [] - }), /noch keine Streckenpunkte im GPX-Track/); + assert.match(waypointOnly, //); }); 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); - await api.remove('local-remove'); + api.remove('local-remove'); assert.equal(api.list().length, 0); assert.equal(events.at(-1).action, 'removed'); assert.equal(events.at(-1).id, 'local-remove'); - await assert.rejects(api.getAppRoute('local-remove'), /nicht gefunden/); + assert.throws(() => api.getAppRoute('local-remove'), /nicht gefunden/); }); 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, route: { name: 'Nachher' }, pois: [] - }), /Speicher für Streckendaten ist voll/); + }), /Browser-Speicher ist voll/); assert.equal(api.get('local-quota').route.name, 'Vorher'); }); -test('local media uses IndexedDB so route JSON does not consume Local Storage quota', async () => { - const { api, indexedDB, localStorage } = await loadLocalRoutes(); - const routeId = 'local-media'; - const audioFile = Object.assign(new Blob(['audio-payload'], { type: 'audio/mpeg' }), { name: 'weg.mp3' }); - const imageFile = Object.assign(new Blob(['image-payload'], { type: 'image/jpeg' }), { name: 'weg.jpg' }); - - const audio = await api.storeMediaFile(routeId, 1, 'audio', '1-audio', audioFile); - const image = await api.storeMediaFile(routeId, 1, 'image', '1-image', imageFile); - const saved = api.save({ - id: routeId, - gpx: api.emptyGpx('Medientest'), - route: { name: 'Medientest' }, - pois: [{ id: 1, title: '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 () => { 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); }); -test('local media stores a File System Access handle instead of copying file bytes when supported', async () => { - const { api, indexedDB, localStorage } = await loadLocalRoutes(); - const file = Object.assign(new Blob(['large-image'], { type: 'image/jpeg' }), { name: 'foto.jpg' }); - let permission = 'granted'; - let permissionRequests = 0; - const handle = { - name: 'foto.jpg', - async getFile() { return file; }, - async queryPermission() { return permission; }, - async requestPermission() { - permissionRequests += 1; - permission = 'granted'; - return permission; - } - }; - const media = await api.storeMediaFile('route-handle', 7, 'image', 'image-7', file, handle); - assert.equal(media.storage, 'file-handle'); - const record = indexedDB.records.get(media.mediaKey); - assert.equal(record.handle, handle); - assert.equal('blob' in record, false); +test('local route storage tolerates corrupt index and package JSON and generates unique POI ids', async () => { + const { api, localStorage } = await loadLocalRoutes(); + const prefix = api.STORAGE_PREFIX; - api.save({ - id: 'route-handle', - 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/); + localStorage.setItem(`${prefix}.index`, '{not-json'); + assert.deepEqual(Array.from(api.list()), []); - assert.equal(await api.mediaBlob(media), file); - permission = 'prompt'; - await assert.rejects(api.mediaBlob(media), /erneut erlaubt/); - assert.equal(await api.mediaBlob(media, { requestPermission: true }), file); - assert.equal(permissionRequests, 1); + const id = 'broken-package'; + const encoded = encodeURIComponent(id); + localStorage.setItem(`${prefix}.${encoded}.gpx`, api.emptyGpx('Defekt')); + localStorage.setItem(`${prefix}.${encoded}.json`, '{not-json'); + 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 () => { - const { api, localStorage } = await loadLocalRoutes(); - const routeId = 'multi-media'; - const large = new Uint8Array(1024 * 1024); - const firstFile = Object.assign(new Blob([large], { type: 'image/jpeg' }), { name: 'eins.jpg' }); - const secondFile = Object.assign(new Blob([large], { type: 'image/jpeg' }), { name: 'zwei.jpg' }); - const first = await api.storeMediaFile(routeId, 1, 'image', 'image-1', firstFile); - const second = await api.storeMediaFile(routeId, 2, 'image', 'image-2', secondFile); - - api.save({ - id: routeId, - gpx: api.emptyGpx('Mehrere Streckenpunkte'), - route: { name: 'Mehrere Streckenpunkte' }, +test('app route conversion supports routes without track points and default metadata', async () => { + const { api } = await loadLocalRoutes(); + const route = api.toAppRoute({ + id: 'poi-only', + gpx: api.emptyGpx('Nur POIs'), + route: {}, pois: [ - { id: 1, title: 'Erster Streckenpunkt', lat: 50.1, lon: 10.1, images: [first] }, - { id: 2, title: 'Zweiter Streckenpunkt', lat: 50.2, lon: 10.2, images: [second] } + { id: 1, lat: 50, lon: 10, sequence: 0, images: [{ dataUrl: '' }, { dataUrl: 'data:image/png;base64,QQ==' }] }, + { 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.ok(metadata.length < 5000, `metadata unexpectedly large: ${metadata.length}`); - assert.doesNotMatch(metadata, /data:image/); - assert.equal(api.get(routeId).pois.length, 2); + assert.equal(route.name, 'Unbenannte Strecke'); + assert.equal(route.description, ''); + assert.equal(route.points.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/); });