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="number"/); assert.match(html, /id="editor-poi-lon"[^>]+type="number"/); assert.match(html, /id="editor-poi-radius"[^>]+type="hidden"/); assert.match(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 controls = { failPicture: false, approved: false, recording: null }; 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-rights-open', '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 (controls.failPicture && url.endsWith('/pictures/24') && method === 'PUT') { controls.failPicture = false; return jsonResponse({ message: 'Testfehler' }, false, 500); } 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)(?:\/24)?$/.test(url) && (method === 'POST' || method === 'PUT')) return jsonResponse({ id: 24 }, true, 201); if (url === '/api/routes/22?includeDeleted=true' && method === 'GET') return jsonResponse({ id: 22, activatedAt: controls.approved ? '2026-10-03' : null, pois: [{ id: 23, audioUrl: '/audio', images: [{ id: 24 }] }] }); if (url === '/api/routes/22' && method === 'PUT') return jsonResponse({ id: 22 }); if (url === '/api/routes/22/pois/23' && method === 'PUT') return jsonResponse({ id: 23 }); if (/^\/api\/routes\/22\/pois\/23(?:\/audio|\/pictures\/24)?$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true }); 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 = { creatorId: () => "a".repeat(64), 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, getPoiWithMedia: async (id, poiId) => { const value = packages.get(String(id))?.pois.find(poi => String(poi.id) === String(poiId)); if (!value) return null; return { ...value, audio: value.audio ? { ...value.audio, url: 'blob:test-audio' } : null, images: (value.images || []).map(image => ({ ...image, url: 'blob:test-image' })) }; }, releasePoiMedia() {}, 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/' }, LegalUi: { open: name => { controls.legalPage = name; } }, RouteRecorder: { beginEditing(id) { controls.recording = id; }, endEditing() { controls.recording = null; }, renderPoints() {}, editGpx: gpx => gpx, get routeId() { return controls.recording; }, get position() { return { lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }; } }, LocalRoutes } }; context.globalThis = context; return { calls, context, elements, packages, saves, storedMediaCalls, controls }; } async function submittedEditor(response) { const harness = buildEditorContext(); const { context, packages, calls, elements } = harness; packages.set('local-existing', { id: 'local-existing', gpx: '', route: { id: 'local-existing', name: 'Waldweg', creator_id: 'a'.repeat(64), serverRouteId: '99', uploadedAt: '2026-10-01T10:00:00Z' }, pois: [{ id: 7, title: 'Aussicht', description: 'Wiese', sequence: 0, lat: 50, lon: 10, serverPoiId: 98, serverAudio: true, audio: { name: 'ton.mp3', mediaKey: 'audio-key', type: 'audio/mpeg' }, images: [{ id: 'local-image', name: 'bild.png', mediaKey: 'picture-key', type: 'image/png', serverPictureId: 97 }] }] }); const originalFetch = context.fetch; context.fetch = async (url, options = {}) => { if (url === '/api/routes/99?includeDeleted=true') { calls.push({ url, method: 'GET' }); return response(); } return originalFetch(url, options); }; await loadI18n(context); const sourcePath = path.join(root, 'public/js/editor-ui.js'); vm.runInNewContext(await fs.readFile(sourcePath, 'utf8'), context, { filename: sourcePath }); await context.Wegwichtel.EditorUi.open(); for (let i = 0; i < 4; i += 1) await tick(); const list = elements.get('editor-route-list'); const item = list.children.find(child => child.dataset.routeId === 'local-existing'); list.dispatchEvent(pointerEvent(item.children[1].children[0])); for (let i = 0; i < 4; i += 1) await tick(); return harness; } async function submitDraft(harness) { harness.elements.get('editor-route-upload').dispatchEvent(pointerEvent()); for (let i = 0; i < 12; i += 1) await tick(); } test('removed and rejected submissions show their saved status and create fresh route and media IDs', async t => { for (const status of ['removed', 'rejected']) { await t.test(status, async () => { const response = status === 'removed' ? () => jsonResponse({ message: 'Strecke nicht gefunden.' }, false, 404) : () => jsonResponse({ id: 99, status: 'deleted', activatedAt: '2026-10-01', pois: [] }); const harness = await submittedEditor(response); const { calls, packages, elements } = harness; assert.equal(packages.get('local-existing').route.submissionStatus, status); const expectedLabel = status === 'removed' ? 'Entfernt' : 'Abgelehnt'; const item = elements.get('editor-route-list').children.find(child => child.dataset.routeId === 'local-existing'); assert.ok(item.children[0].children.some(child => child.textContent === expectedLabel)); assert.match(elements.get('editor-route-activated').textContent, status === 'removed' ? /nicht mehr online/ : /abgelehnt/); await submitDraft(harness); const saved = packages.get('local-existing'); assert.equal(saved.route.serverRouteId, '22'); assert.equal(saved.route.submissionStatus, null); assert.equal(saved.route.creator_id, 'a'.repeat(64)); assert.equal(saved.pois[0].serverPoiId, '23'); assert.equal(saved.pois[0].images[0].serverPictureId, 24); assert.equal(saved.pois[0].images[0].mediaKey, 'picture-key'); assert.equal(saved.pois[0].audio.mediaKey, 'audio-key'); assert.equal(elements.get('editor-status').textContent, 'Die Strecke wurde neu angelegt und zur Prüfung gesendet.'); assert.deepEqual(calls.filter(call => call.method === 'POST').map(call => call.url), [ '/api/routes', '/api/routes/22/pois', '/api/routes/22/pois/23/audio', '/api/routes/22/pois/23/pictures' ]); assert.equal(calls.some(call => call.method === 'PUT' || call.method === 'DELETE' || call.url.includes('/restore')), false); await submitDraft(harness); assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes').length, 1); assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22')); }); } }); test('submission status refresh preserves unsaved form text and clears rejection after restoration', async () => { let status = 'active'; const harness = await submittedEditor(() => jsonResponse({ id: 99, status })); harness.elements.get('editor-route-name').value = 'Noch nicht gespeichert'; for (const serverStatus of ['deleted', 'active']) { status = serverStatus; await harness.context.Wegwichtel.EditorUi.refresh(); for (let i = 0; i < 4; i += 1) await tick(); assert.equal(harness.packages.get('local-existing').route.submissionStatus, status === 'deleted' ? 'rejected' : null); assert.equal(harness.elements.get('editor-route-name').value, 'Noch nicht gespeichert'); } }); test('authorization, server and network failures preserve the submitted IDs and never create a route', async t => { for (const status of [401, 403, 500, 'offline']) { await t.test(String(status), async () => { const harness = await submittedEditor(() => { if (status === 'offline') throw new TypeError('Failed to fetch'); return jsonResponse({ message: 'Testfehler' }, false, status); }); await submitDraft(harness); assert.equal(harness.calls.some(call => call.method !== 'GET'), false); const saved = harness.packages.get('local-existing'); assert.equal(saved.route.serverRouteId, '99'); assert.equal(saved.pois[0].serverPoiId, 98); assert.equal(saved.pois[0].images[0].serverPictureId, 97); assert.equal(harness.elements.get('editor-route-upload').disabled, false); }); } }); test('failed fresh creation preserves local files and retries without obsolete server references', async () => { const harness = await submittedEditor(() => jsonResponse({}, false, 404)); const originalFetch = harness.context.fetch; let fail = true; harness.context.fetch = async (url, options = {}) => { if (fail && url === '/api/routes' && options.method === 'POST') { fail = false; return jsonResponse({ message: 'Testfehler' }, false, 500); } return originalFetch(url, options); }; await submitDraft(harness); const saved = harness.packages.get('local-existing'); assert.equal(saved.route.serverRouteId, null); assert.equal(saved.route.uploadedAt, null); assert.equal(saved.route.submissionStatus, 'removed'); assert.equal(saved.pois[0].serverPoiId, undefined); assert.equal(saved.pois[0].serverAudio, undefined); assert.equal(saved.pois[0].images[0].serverPictureId, undefined); assert.equal(saved.pois[0].audio.mediaKey, 'audio-key'); await submitDraft(harness); assert.equal(harness.packages.get('local-existing').route.serverRouteId, '22'); }); 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, controls } = 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')); elements.get('editor-rights-open').dispatchEvent(pointerEvent()); assert.equal(controls.legalPage, 'terms'); 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(controls.recording, 'local-new'); 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, false); assert.equal(elements.get('editor-route-upload').textContent, 'Erneut zur Prüfung senden'); assert.equal(elements.get('editor-status').textContent, 'Zur Prüfung gesendet.'); elements.get('editor-route-upload').dispatchEvent(pointerEvent()); for (let i = 0; i < 10; i += 1) await tick(); assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes').length, 1); assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes/22/pois').length, 1); assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/pictures').length, 1); assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22')); assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22/pois/23/audio')); assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22/pois/23/pictures/24')); controls.failPicture = true; elements.get('editor-route-upload').dispatchEvent(pointerEvent()); for (let i = 0; i < 10; i += 1) await tick(); assert.equal(packages.get('local-new').pois[0].serverPictureId, undefined); assert.equal(packages.get('local-new').pois[0].images[0].serverPictureId, 24); assert.equal(packages.get('local-new').route.serverRouteId, '22'); assert.equal(elements.get('editor-route-upload').disabled, false); elements.get('editor-route-upload').dispatchEvent(pointerEvent()); for (let i = 0; i < 10; i += 1) await tick(); assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes').length, 1); controls.approved = true; const writesBeforeApproval = calls.filter(call => call.method !== 'GET').length; elements.get('editor-route-upload').dispatchEvent(pointerEvent()); for (let i = 0; i < 10; i += 1) await tick(); assert.equal(calls.filter(call => call.method !== 'GET').length, writesBeforeApproval); controls.approved = false; 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); elements.get('editor-route-upload').dispatchEvent(pointerEvent()); for (let i = 0; i < 10; i += 1) await tick(); assert.ok(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/22/pois/23/audio')); assert.ok(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/22/pois/23/pictures/24')); const currentList = elements.get('editor-poi-list'); const currentItem = currentList.children.find(child => child.dataset.poiId); currentList.dispatchEvent(pointerEvent(currentItem.children[1].children[2])); 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); elements.get('editor-route-upload').dispatchEvent(pointerEvent()); for (let i = 0; i < 10; i += 1) await tick(); assert.ok(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/22/pois/23')); context.Wegwichtel.EditorUi.close(); assert.equal(controls.recording, null); 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'); await tick(); 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(), ['creator_id', '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); }); test('local drafts can be opened and saved while the server request is still pending', async () => { const { context, elements, packages } = buildEditorContext(); context.fetch = () => new Promise(() => {}); await loadI18n(context); vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context); await context.Wegwichtel.EditorUi.open(); const list = elements.get('editor-route-list'); const item = list.children.find(child => child.dataset.routeId === 'local-existing'); assert.ok(item); list.dispatchEvent(pointerEvent(item.children[1].children[0])); await tick(); assert.equal(elements.get('editor-route-id').value, 'local-existing'); elements.get('editor-route-name').value = 'Offline geändert'; elements.get('editor-route-form').dispatchEvent(submitEvent()); await tick(); await tick(); assert.equal(packages.get('local-existing').route.name, 'Offline geändert'); assert.equal(elements.get('editor-status').textContent, 'Gespeichert.'); }); test('a failed public route request preserves local drafts and late replies use the latest local data', async () => { const { context, elements, packages } = buildEditorContext(); const requests = []; context.fetch = () => new Promise((resolve, reject) => requests.push({ resolve, reject })); await loadI18n(context); vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context); await context.Wegwichtel.EditorUi.open(); requests[0].reject(new Error('offline')); await tick(); assert.match(elements.get('editor-status').textContent, /nicht erreichbar/); assert.ok(elements.get('editor-route-list').children.some(child => child.dataset.routeId === 'local-existing')); await context.Wegwichtel.EditorUi.refresh(); packages.delete('local-existing'); requests[1].resolve(jsonResponse({ routes: [{ id: 7, name: 'Veröffentlicht' }] })); await tick(); await tick(); assert.equal(elements.get('editor-route-list').children.some(child => child.dataset.routeId === 'local-existing'), false); assert.ok(elements.get('editor-route-list').children.some(child => child.dataset.routeSource === 'server')); }); test('opening and saving a draft does not load files; editing a station loads only that station', async () => { const { context, elements, packages } = buildEditorContext(); const local = packages.get('local-existing'); local.pois = [0, 1].map(id => ({ id, title: `Station ${id}`, sequence: id, images: [{ id: `image-${id}`, mediaKey: `key-${id}` }] })); const loads = []; context.Wegwichtel.LocalRoutes.getWithMedia = () => { throw new Error('Full route hydration must not run'); }; const original = context.Wegwichtel.LocalRoutes.getPoiWithMedia; context.Wegwichtel.LocalRoutes.getPoiWithMedia = (...args) => { loads.push(args); return original(...args); }; await loadI18n(context); vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context); await context.Wegwichtel.EditorUi.open(); const list = elements.get('editor-route-list'); const item = list.children.find(child => child.dataset.routeId === 'local-existing'); list.dispatchEvent(pointerEvent(item.children[1].children[0])); await tick(); assert.equal(loads.length, 0); elements.get('editor-route-name').value = 'Nur Name geändert'; elements.get('editor-route-form').dispatchEvent(submitEvent()); await tick(); await tick(); assert.equal(loads.length, 0); const poiList = elements.get('editor-poi-list'); const poiItem = poiList.children.find(child => child.dataset.poiId === '1'); poiList.dispatchEvent(pointerEvent(poiItem.children[1].children[2])); await tick(); assert.equal(loads.length, 1); assert.equal(loads[0][1], 1); assert.equal(elements.get('editor-poi-media-preview').children.length, 1); elements.get('editor-poi-title').value = 'Titel geändert'; elements.get('editor-poi-form').dispatchEvent(submitEvent()); await tick(); await tick(); assert.equal(loads.length, 1); assert.equal(packages.get('local-existing').pois[1].title, 'Titel geändert'); }); test('late station previews are released after the editor closes without replacing unsaved input', async () => { const { context, elements, packages } = buildEditorContext(); packages.get('local-existing').pois = [{ id: 0, title: 'Station', images: [] }]; let finish; const released = []; context.Wegwichtel.LocalRoutes.getPoiWithMedia = () => new Promise(resolve => { finish = resolve; }); context.Wegwichtel.LocalRoutes.releasePoiMedia = poi => { if(poi) released.push(poi); }; await loadI18n(context); vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context); await context.Wegwichtel.EditorUi.open(); const list = elements.get('editor-route-list'); const item = list.children.find(child => child.dataset.routeId === 'local-existing'); list.dispatchEvent(pointerEvent(item.children[1].children[0])); await tick(); const poiList = elements.get('editor-poi-list'); poiList.dispatchEvent(pointerEvent(poiList.children[0].children[1].children[2])); await tick(); elements.get('editor-poi-title').value = 'Noch nicht gespeichert'; context.Wegwichtel.EditorUi.close(); finish({ id: 0, title: 'Alter Titel', images: [{ url: 'blob:late' }] }); await tick(); assert.equal(released.length, 1); assert.equal(elements.get('editor-poi-title').value, 'Noch nicht gespeichert'); }); test('shared drafts use guarded server saves, preserve the creator and retain unsaved input on conflicts', async () => { const h = buildEditorContext(); const shared = { id: 44, name: 'Gemeinsamer Waldweg', description: '', creator_id: 'b'.repeat(64), pois: [] }; let conflict = false; let refreshed = 0; const originalFetch = h.context.fetch; h.context.Wegwichtel.Groups = { headersFor: path => path.startsWith('/routes/44') ? { 'If-Match': '3' } : {}, observe() {}, refresh() { refreshed += 1; } }; h.context.fetch = async (url, options = {}) => { if (url === '/api/routes/44') { h.calls.push({ url, ...options, method: options.method || 'GET' }); if (options.method === 'PUT') { if (conflict) return { ...jsonResponse({ message: 'Conflict' }, false, 409), headers: { get: () => '44' } }; Object.assign(shared, JSON.parse(options.body)); } return jsonResponse(shared); } return originalFetch(url, options); }; await loadI18n(h.context); const messages = path.join(root, 'public/js/user-messages.js'); vm.runInNewContext(await fs.readFile(messages, 'utf8'), h.context, { filename: messages }); const filename = path.join(root, 'public/js/editor-ui.js'); vm.runInNewContext(await fs.readFile(filename, 'utf8'), h.context, { filename }); await h.context.Wegwichtel.EditorUi.openGroup(shared); assert.equal(h.controls.recording, null); assert.ok(refreshed > 0); h.elements.get('editor-route-name').value = 'Gemeinsam geändert'; h.elements.get('editor-route-form').dispatchEvent(submitEvent()); for (let i = 0; i < 6; i += 1) await tick(); const saved = h.calls.find(call => call.method === 'PUT' && call.url === '/api/routes/44'); assert.equal(saved.headers['If-Match'], '3'); assert.equal(JSON.parse(saved.body).creator_id, 'b'.repeat(64)); assert.equal(h.packages.has('44'), false); assert.equal(shared.name, 'Gemeinsam geändert'); conflict = true; h.elements.get('editor-route-name').value = 'Noch nicht gespeichert'; h.elements.get('editor-route-form').dispatchEvent(submitEvent()); for (let i = 0; i < 6; i += 1) await tick(); assert.equal(shared.name, 'Gemeinsam geändert'); assert.equal(h.elements.get('editor-route-name').value, 'Noch nicht gespeichert'); assert.match(h.elements.get('editor-status').textContent, /inzwischen geändert/); }); test('sharing a local draft asks before sending and opens a separate shared copy', async () => { const h = buildEditorContext(); const sharedIds = []; h.context.confirm = () => false; h.context.Wegwichtel.Groups = { shareButton(action) { const button = new FakeElement('', 'button'); button.dataset.editorAction = action; return button; }, async share(id) { sharedIds.push(id); } }; await loadI18n(h.context); const filename = path.join(root, 'public/js/editor-ui.js'); vm.runInNewContext(await fs.readFile(filename, 'utf8'), h.context, { filename }); await h.context.Wegwichtel.EditorUi.open(); const list = h.elements.get('editor-route-list'); const item = list.children.find(child => child.dataset.routeId === 'local-existing'); list.dispatchEvent(pointerEvent(item.children[1].children[1])); for (let i = 0; i < 4; i += 1) await tick(); assert.equal(h.calls.some(call => call.method === 'POST'), false); h.context.confirm = () => true; list.dispatchEvent(pointerEvent(item.children[1].children[1])); list.dispatchEvent(pointerEvent(item.children[1].children[1])); for (let i = 0; i < 14; i += 1) await tick(); assert.deepEqual(sharedIds, ['22']); assert.equal(h.packages.get('local-existing').route.serverRouteId, '22'); assert.ok(h.calls.some(call => call.method === 'POST' && call.url === '/api/routes')); }); test('shared station saves persist files before sending, retain lost-lock changes and release locks on exit', async () => { const h = buildEditorContext(); const poi = { id: 8, title: 'Wiese', description: 'Blumen', lat: 50, lon: 10, sequence: 0, images: [], audioUrl: null }; const route = { id: 44, name: 'Gruppenweg', creator_id: 'a'.repeat(64), pois: [poi] }; const events = [], drafts = new Map(); let allowed = false; h.context.Wegwichtel.GroupPoints = { async begin() { events.push('lock'); return { poi }; }, async release() { events.push('release'); }, async read(id, poiId) { return drafts.get(String(poiId)); }, async remember(route, poiId, values, media) { const draft = { route, poiId, values, media }; events.push('backup'); drafts.set(String(poiId), structuredClone(draft)); return draft; }, async prepare() { events.push('prepare'); return allowed ? { poi } : false; }, async remove(id, poiId) { events.push('remove'); drafts.delete(String(poiId)); }, headersFor: () => ({ 'X-Poi-Lock': 'secret', 'If-Poi-Match': 'v1' }), observe() {}, async acceptNew() {} }; const original = h.context.fetch; h.context.fetch = async (url, options = {}) => { if (url.startsWith('/api/routes/44')) { h.calls.push({ url, ...options, method: options.method || 'GET' }); if (url === '/api/routes/44') return jsonResponse(route); if (options.method === 'PUT') { events.push('send'); Object.assign(poi, JSON.parse(options.body)); return jsonResponse({ poi }); } if (options.method === 'DELETE') { events.push('delete'); return jsonResponse({ deleted: true }); } } return original(url, options); }; await loadI18n(h.context); vm.runInNewContext(await fs.readFile('public/js/editor-ui.js', 'utf8'), h.context, { filename: path.resolve('public/js/editor-ui.js') }); await h.context.Wegwichtel.EditorUi.openGroup(route); const list = h.elements.get('editor-poi-list'); list.dispatchEvent(pointerEvent(list.children[0].children[1].children[2])); await tick(); h.elements.get('editor-poi-title').value = 'Meine Blumen'; const file = new Blob(['photo'], { type: 'image/png' }); file.name = 'blume.png'; h.elements.get('editor-poi-images').files = [file]; h.elements.get('editor-poi-images').dispatchEvent(new Event('change')); await tick(); h.elements.get('editor-poi-form').dispatchEvent(submitEvent()); await tick(); assert.equal(events.includes('send'), false); assert.equal(drafts.get('8').values.title, 'Meine Blumen'); assert.equal(drafts.get('8').media.images.length, 1); assert.match(h.elements.get('editor-status').textContent, /auf diesem Gerät gesichert/); // Remove the selected picture, then retry the same text edit with the lock held. await h.context.Wegwichtel.EditorUi.openPendingPoint({ route, poiId: 8, basePoi: poi, values: poi, media: {} }); allowed = true; h.elements.get('editor-poi-title').value = 'Gemeinsam gespeichert'; h.elements.get('editor-poi-form').dispatchEvent(submitEvent()); h.elements.get('editor-poi-form').dispatchEvent(submitEvent()); for (let i = 0; i < 6; i += 1) await tick(); assert.equal(events.filter(event => event === 'send').length, 1); assert.ok(events.indexOf('backup') < events.indexOf('send')); assert.equal(drafts.size, 0); assert.equal(poi.title, 'Gemeinsam gespeichert'); const saved = h.calls.find(call => call.method === 'PUT'); assert.equal(saved.headers['X-Poi-Lock'], 'secret'); allowed = false; const latestList = h.elements.get('editor-poi-list'); latestList.dispatchEvent(pointerEvent(latestList.children[0].children[1].children[3])); for (let i = 0; i < 4; i += 1) await tick(); assert.equal(drafts.get('8').media.deletePoint, true); assert.equal(events.includes('delete'), false); allowed = true; h.elements.get('editor-poi-form').dispatchEvent(submitEvent()); for (let i = 0; i < 5; i += 1) await tick(); assert.equal(events.includes('delete'), true); assert.equal(drafts.size, 0); h.context.Wegwichtel.EditorUi.close(); assert.equal(events.at(-1), 'release'); });