import { readAppFile } from '../test-support/i18n.js'; import { loadI18n } from '../test-support/i18n.js'; 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 readAppFile(relativePath); } 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"[^>]+aria-label="Strecken bearbeiten"/); 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, /]+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, />Stationen<\/h3>/); assert.match(html, /aria-label="Station hinzufügen"/); assert.match(html, /id="editor-route-upload"[^>]+type="button"[^>]+hidden/); assert.doesNotMatch(html, /accept="video\//); assert.match(html, /auf diesem Gerät/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, /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 { 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 = ''; this.clickCount = 0; } 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; } click() { this.clickCount += 1; } 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, filename) { this.entries.push([name, value, filename]); } } 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 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-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', 'editor-route-back', 'editor-route-description', '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)])); 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: [] }] ]); 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 }] }; 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') { 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 }); 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}`, 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, 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}`, route: { ...input.route, id, updatedAt: now }, pois }; packages.set(id, saved); saves.push(structuredClone(saved)); return saved; } }; const context = { Blob, document, Event, EventTarget, fetch, FormData: FakeFormData, globalThis: null, structuredClone, URL: { createObjectURL: () => 'blob:test-preview', revokeObjectURL() {} }, Wegwichtel: { App: { currentPosition: () => ({ lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }) }, Config: { apiBase: '/api/' }, LocalRoutes } }; context.globalThis = context; return { calls, context, elements, packages, saves, storedMediaCalls }; } test('public editor workflow saves new route and GPX Station 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(); await loadI18n(context); vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') }); await loadI18n(context); 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(); 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()); 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, /Dieser Entwurf bleibt auf deinem Gerät/); assert.equal(elements.get('editor-status').textContent, 'Gespeichert.'); 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 Station'; 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'); 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].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, 'Schon zur Prüfung gesendet'); assert.equal(elements.get('editor-status').textContent, 'Zur Prüfung gesendet.'); 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(); assert.equal(elements.get('editor-dialog').open, false); assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false'); }); test('local Station 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(); await loadI18n(context); vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') }); await loadI18n(context); 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 Station'; 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 Station'; 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 Station', 'Zweiter Station']); const pointItems = elements.get('editor-poi-list').children.filter(child => child.dataset.poiId); assert.equal(pointItems.length, 2); }); test('route and Station 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(); await loadI18n(context); vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') }); await loadI18n(context); 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 = 'Station 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'); const { calls, context, elements } = buildEditorContext(); await loadI18n(context); vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') }); await loadI18n(context); 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 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]; 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, /für alle freigegeben/); 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'; 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); assert.equal(elements.get('editor-status').textContent, 'Gespeichert.'); }); test('local media picker keeps File System Access handles for later upload', 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]; }; await loadI18n(context); vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') }); await loadI18n(context); vm.runInNewContext(source, context, { filename: sourcePath }); await context.Wegwichtel.EditorUi.openNewLocalRoute(); elements.get('editor-route-name').value = 'Handle-Strecke'; elements.get('editor-route-form').dispatchEvent(submitEvent()); await tick(); await tick(); elements.get('editor-add-poi').dispatchEvent(pointerEvent()); elements.get('editor-poi-title').value = 'Station mit Bild'; elements.get('editor-poi-images-add').dispatchEvent(pointerEvent()); await tick(); assert.equal(elements.get('editor-poi-images').clickCount, 0); 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); });