Files
Wegwichtel/test/editor-ui.test.js
T
Florian Zumpe 31768ae907
Sonarqube Scanner / Prepare environment (push) Successful in 6s
Sonarqube Scanner / Build and test (push) Successful in 27s
Sonarqube Scanner / Deploy to Server (push) Failing after 7s
Sonarqube Scanner / SonarQube analysis (push) Failing after 1m14s
group editor implemented
2026-10-18 16:33:12 +02:00

1106 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]*?<img[^>]+src="images\/icons\/route-management\.svg"[^>]+alt=""[^>]*>/);
assert.doesNotMatch(html, /class="editor-button-icon"/);
assert.match(html, /<dialog id="editor-dialog"/);
assert.match(html, /id="editor-route-form"/);
assert.match(html, /id="editor-poi-form"/);
assert.match(html, /id="editor-route-gpx"[^>]+accept="[^\"]*\.gpx/);
assert.match(html, /id="editor-poi-audio-add"[^>]+type="button"/);
assert.match(html, /id="editor-poi-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: '<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 => `<gpx><metadata><name>${name}</name></metadata><trk><trkseg></trkseg></trk></gpx>`,
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 => `<wpt lat="${poi.lat}" lon="${poi.lon}"><name>${poi.title || ''}</name><extensions><wegwichtel:poiId>${poi.id}</wegwichtel:poiId></extensions></wpt>`)
.join('');
const saved = {
id,
gpx: `<gpx>${gpxPoints}</gpx>`,
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: '<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, /<wpt lat="50\.123456789" lon="10\.987654321">/);
const pointListBeforeUpload = elements.get('editor-poi-list');
const pointItemBeforeUpload = pointListBeforeUpload.children.find(child => child.dataset.poiId);
assert.equal(pointItemBeforeUpload.children[1].children[3].textContent, '×');
assert.match(pointItemBeforeUpload.children[1].children[3].className, /editor-icon-danger/);
elements.get('editor-route-name').value = '--- Neue lokale Strecke ---';
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
await tick();
await tick();
await tick();
await tick();
const routeUpload = calls.find(call => call.method === 'POST' && call.url === '/api/routes');
assert.ok(routeUpload);
const gpxUpload = routeUpload.body.entries.find(([name]) => name === 'gpx');
assert.ok(gpxUpload);
assert.equal(gpxUpload[2], 'Neue-lokale-Strecke.gpx');
assert.match(await gpxUpload[1].text(), /<wpt lat=\"50\.123456789\" lon=\"10\.987654321\">/);
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois'));
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/audio'));
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/pictures'));
local = packages.get('local-new');
assert.equal(local.route.serverRouteId, '22');
assert.ok(local.route.uploadedAt);
assert.equal(elements.get('editor-route-upload').disabled, 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');
});