Files
Wegwichtel/test/editor-ui.test.js
T
Florian Zumpe b37712a2e3
Sonarqube Scanner / Build and analyze (push) Failing after 1m10s
sonarqube fixes
2026-09-27 22:18:37 +02:00

617 lines
25 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 assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import vm from 'node:vm';
const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
}
function tick() {
return new Promise(resolve => setTimeout(resolve, 0));
}
test('editor markup and source describe local draft creation instead of immediate publication', async () => {
const html = await read('public/index.html');
const css = await read('public/css/editor.css');
const ui = await read('public/js/editor-ui.js');
assert.match(html, /id="editor-button"[^>]+aria-label="Strecken verwalten"/);
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="hidden"/);
assert.match(html, /id="editor-poi-lon"[^>]+type="hidden"/);
assert.match(html, /id="editor-poi-radius"[^>]+type="hidden"/);
assert.doesNotMatch(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/);
assert.match(html, /id="editor-poi-media-preview"/);
assert.match(html, /id="editor-poi-delete"[^>]+editor-danger-button/);
assert.match(html, />Streckenpunkte<\/h3>/);
assert.match(html, /aria-label="Streckenpunkt hinzufügen"/);
assert.match(html, /id="editor-route-upload"[^>]+type="button"[^>]+hidden/);
assert.doesNotMatch(html, /accept="video\//);
assert.match(html, /ausschließlich in diesem Browser/i);
await fs.access(path.join(root, 'public/images/icons/route-management.svg'));
assert.match(css, /\.editor-dialog/);
assert.match(css, /\.editor-poi-list/);
assert.match(css, /\.editor-route-group/);
assert.match(css, /\.editor-media-button/);
assert.match(css, /\.editor-media-preview/);
assert.match(css, /\.editor-media-remove/);
assert.match(css, /\.editor-danger-button/);
assert.match(css, /\.editor-icon-danger/);
assert.match(css, /\.route-control-button img/);
assert.doesNotMatch(css, /\.editor-button-icon/);
assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/);
assert.match(ui, /ns\.LocalRoutes\.save\(/);
assert.match(ui, /ns\.LocalRoutes\.parseWaypoints\(/);
assert.match(ui, /ns\.LocalRoutes\.storeMediaFile\(/);
assert.match(ui, /showOpenFilePicker/);
assert.match(ui, /requestPermission:\s*true/);
assert.match(ui, /ns\.LocalRoutes\.gpxForUpload\(/);
assert.match(ui, /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\), !routeId\(route\)\)/);
});
class FakeElement extends EventTarget {
constructor(id = '', tagName = 'div') {
super();
this.id = id;
this.tagName = tagName.toUpperCase();
this.hidden = false;
this.open = false;
this.value = '';
this.className = '';
this.children = [];
this.dataset = {};
this.files = [];
this.parentElement = null;
this.attributes = new Map();
this.disabled = false;
this.title = '';
this.type = '';
this._textContent = '';
this.clickCount = 0;
}
get textContent() {
return this._textContent;
}
set textContent(value) {
this._textContent = String(value ?? '');
if (this._textContent === '') this.children = [];
}
append(...children) {
children.forEach(child => this.appendChild(child));
}
appendChild(child) {
if (!child) return child;
child.parentElement = this;
this.children.push(child);
return child;
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
if (name === 'open') this.open = true;
}
removeAttribute(name) {
this.attributes.delete(name);
if (name === 'open') this.open = false;
}
click() {
this.clickCount += 1;
}
showModal() {
this.open = true;
}
close() {
this.open = false;
}
closest(selector) {
if (selector === '[data-route-id]') return this.dataset.routeId ? this : this.parentElement?.closest(selector) ?? null;
if (selector === '[data-poi-id]') return this.dataset.poiId ? this : this.parentElement?.closest(selector) ?? null;
if (selector === '[data-editor-action]') return this.dataset.editorAction ? this : this.parentElement?.closest(selector) ?? null;
return null;
}
}
class FakeFormData {
constructor() {
this.entries = [];
}
append(name, value, filename) {
this.entries.push([name, value, filename]);
}
}
function jsonResponse(value, ok = true, status = 200) {
return {
ok,
status,
text: async () => JSON.stringify(value)
};
}
function pointerEvent(target = null, { pointerType = 'touch', button = 0, isPrimary = true } = {}) {
const event = new Event('pointerup', { cancelable: true });
Object.defineProperties(event, {
button: { value: button },
isPrimary: { value: isPrimary },
pointerType: { value: pointerType }
});
if (target) Object.defineProperty(event, 'target', { value: target });
return event;
}
function submitEvent() {
return new Event('submit', { cancelable: true });
}
function buildEditorContext() {
const ids = [
'editor-add-poi',
'editor-button',
'editor-close',
'editor-dialog',
'editor-new-route',
'editor-poi-audio',
'editor-poi-audio-add',
'editor-poi-cancel',
'editor-poi-delete',
'editor-poi-description',
'editor-poi-form',
'editor-poi-form-title',
'editor-poi-form-view',
'editor-poi-id',
'editor-poi-images',
'editor-poi-images-add',
'editor-poi-lat',
'editor-poi-list',
'editor-poi-lon',
'editor-poi-media-preview',
'editor-poi-media-status',
'editor-poi-radius',
'editor-poi-title',
'editor-route-activated',
'editor-route-back',
'editor-route-description',
'editor-route-detail-view',
'editor-route-form',
'editor-route-gpx',
'editor-route-gpx-group',
'editor-route-id',
'editor-route-list',
'editor-route-list-view',
'editor-route-name',
'editor-route-upload',
'editor-status'
];
const elements = new Map(ids.map(id => [id, new FakeElement(id)]));
const document = new EventTarget();
document.readyState = 'complete';
document.getElementById = id => elements.get(id) ?? null;
document.createElement = tagName => new FakeElement('', tagName);
const calls = [];
const saves = [];
const packages = new Map([
['local-existing', {
id: 'local-existing',
gpx: '<gpx/>',
route: { id: 'local-existing', name: 'Lokaler Waldweg', description: 'Lokal' },
pois: []
}]
]);
const serverRoute = {
id: 1,
name: 'Freigegebener Waldweg',
description: 'Serverroute',
activatedAt: '2026-09-01 08:00:00',
pois: [{ id: 10, title: 'Start', description: 'Los', lat: 50.1, lon: 10.1, triggerRadiusM: 20, sequence: 0 }]
};
async function fetch(url, options = {}) {
const method = options.method || 'GET';
calls.push({ body: options.body, method, url });
if (String(url).startsWith('data:')) {
return { ok: true, status: 200, blob: async () => new Blob(['media'], { type: 'application/octet-stream' }) };
}
if (url === '/api/routes?activatedOnly=true' && method === 'GET') return jsonResponse({ routes: [serverRoute] });
if (url === '/api/routes' && method === 'POST') return jsonResponse({ id: 22, name: 'Neue lokale Strecke', pois: [] }, true, 201);
if (url === '/api/routes/22/pois' && method === 'POST') return jsonResponse({ id: 23, title: 'Aussicht' }, true, 201);
if (/^\/api\/routes\/22\/pois\/23\/(audio|pictures)$/.test(url) && method === 'POST') return jsonResponse({ ok: true }, true, 201);
if (url === '/api/routes/22' && method === 'DELETE') return jsonResponse({ deleted: true });
if (url === '/api/routes/1' && method === 'GET') return jsonResponse({ route: serverRoute });
if (url === '/api/routes/1' && method === 'DELETE') return jsonResponse({ deleted: true });
if (url === '/api/routes/1' && method === 'PUT') {
const body = JSON.parse(options.body);
return jsonResponse({ route: { ...serverRoute, ...body } });
}
if (url === '/api/routes/1/pois' && method === 'POST') return jsonResponse({ poi: { id: 12, title: 'Neue Serverstation' } });
if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && method === 'PUT') return jsonResponse({ poi: { id: Number(url.split('/').pop()), title: 'Station' } });
if (/^\/api\/routes\/1\/pois\/\d+\/(audio|pictures)$/.test(url) && (method === 'POST' || method === 'PUT')) return jsonResponse({ ok: true });
if (/^\/api\/routes\/1\/pois\/\d+\/audio$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
if (/^\/api\/routes\/1\/pois\/\d+\/pictures\/\d+$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
return { ok: false, status: 404, text: async () => `unexpected ${method} ${url}` };
}
const mediaBlobs = new Map();
const storedMediaCalls = [];
const LocalRoutes = {
createPoiId: () => 9001,
createRouteId: () => 'local-new',
emptyGpx: name => `<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,
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 => `<trkpt lat="${poi.lat}" lon="${poi.lon}"><name>${poi.title || ''}</name><extensions><wegwichtel:poiId>${poi.id}</wegwichtel:poiId></extensions></trkpt>`)
.join('');
const saved = {
id,
gpx: `<gpx><trk><trkseg>${gpxPoints}</trkseg></trk></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/' },
LocalRoutes
}
};
context.globalThis = context;
return { calls, context, elements, packages, saves, storedMediaCalls };
}
test('public editor workflow saves new route and GPX Streckenpunkt locally without POSTing a route', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { calls, context, elements, packages, saves } = buildEditorContext();
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.open();
assert.equal(elements.get('editor-dialog').open, true);
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'true');
assert.ok(calls.some(call => call.method === 'GET' && call.url === '/api/routes?activatedOnly=true'));
await context.Wegwichtel.EditorUi.openNewLocalRoute();
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, /Lokaler Entwurf/);
assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.');
assert.equal(elements.get('editor-route-upload').hidden, false);
assert.equal(elements.get('editor-route-upload').disabled, false);
assert.equal(elements.get('editor-route-gpx-group').hidden, false);
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 zur 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, /<trkpt 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(), /<trkpt 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, true);
assert.equal(elements.get('editor-route-upload').textContent, 'Bereits übertragen');
assert.equal(elements.get('editor-status').textContent, 'Upload erfolgreich.');
const poiList = elements.get('editor-poi-list');
const poiItem = poiList.children.find(child => child.dataset.poiId);
const poiEditButton = poiItem.children[1].children[2];
poiList.dispatchEvent(pointerEvent(poiEditButton));
const mediaPreview = elements.get('editor-poi-media-preview');
assert.equal(mediaPreview.children.length, 2);
let imageCard = mediaPreview.children.find(card => card.children[1]?.dataset.mediaKind === 'image');
mediaPreview.dispatchEvent(pointerEvent(imageCard.children[0]));
imageCard = mediaPreview.children.find(card => card.children[1]?.dataset.mediaKind === 'image');
assert.equal(imageCard.children[1].hidden, false);
mediaPreview.dispatchEvent(pointerEvent(imageCard.children[1]));
await tick();
await tick();
local = packages.get('local-new');
assert.equal(local.pois[0].images.length, 0);
let audioCard = elements.get('editor-poi-media-preview').children.find(card => card.children[1]?.dataset.mediaKind === 'audio');
elements.get('editor-poi-media-preview').dispatchEvent(pointerEvent(audioCard.children[0]));
audioCard = elements.get('editor-poi-media-preview').children.find(card => card.children[1]?.dataset.mediaKind === 'audio');
elements.get('editor-poi-media-preview').dispatchEvent(pointerEvent(audioCard.children[1]));
await tick();
await tick();
local = packages.get('local-new');
assert.equal(local.pois[0].audio, null);
assert.equal(elements.get('editor-poi-delete').hidden, false);
elements.get('editor-poi-delete').dispatchEvent(pointerEvent());
await tick();
await tick();
local = packages.get('local-new');
assert.equal(local.pois.length, 0);
context.Wegwichtel.EditorUi.close();
assert.equal(elements.get('editor-dialog').open, false);
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
});
test('route and Streckenpunkt list expose red delete actions and remove local data', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { context, elements, packages } = buildEditorContext();
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.openNewLocalRoute();
elements.get('editor-route-name').value = 'Löschtest';
elements.get('editor-route-form').dispatchEvent(submitEvent());
await tick();
await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
elements.get('editor-poi-title').value = 'Streckenpunkt zum Löschen';
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
let pointList = elements.get('editor-poi-list');
let pointItem = pointList.children.find(child => child.dataset.poiId);
const pointDelete = pointItem.children[1].children[3];
assert.equal(pointDelete.textContent, '×');
assert.match(pointDelete.className, /editor-icon-danger/);
pointList.dispatchEvent(pointerEvent(pointDelete));
await tick();
await tick();
assert.equal(packages.get('local-new').pois.length, 0);
elements.get('editor-route-back').dispatchEvent(pointerEvent());
await tick();
await tick();
const routeList = elements.get('editor-route-list');
const localItem = routeList.children.find(child => child.dataset.routeId === 'local-new');
const routeDelete = localItem.children[1].children[1];
assert.equal(routeDelete.textContent, '×');
assert.match(routeDelete.className, /editor-icon-danger/);
routeList.dispatchEvent(pointerEvent(routeDelete));
await tick();
await tick();
assert.equal(packages.has('local-new'), false);
assert.equal(elements.get('editor-status').textContent, 'Strecke gelöscht.');
});
test('editing an activated server route uses PUT and never writes activation state', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { calls, context, elements } = buildEditorContext();
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.open();
const routeList = elements.get('editor-route-list');
const serverItem = routeList.children.find(child => child.dataset.routeSource === 'server');
const 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.match(elements.get('editor-route-activated').textContent, /Veröffentlicht am/);
assert.equal(elements.get('editor-route-upload').hidden, true);
elements.get('editor-route-name').value = 'Freigegebener Waldweg geändert';
elements.get('editor-route-description').value = 'Bearbeitet';
elements.get('editor-route-form').dispatchEvent(submitEvent());
await tick();
await tick();
const update = calls.find(call => call.method === 'PUT' && call.url === '/api/routes/1');
assert.ok(update);
const body = JSON.parse(update.body);
assert.deepEqual(Object.keys(body).sort(), ['description', 'name']);
assert.equal('activated_at' in body, false);
assert.equal('activatedAt' in body, false);
assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.');
});
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];
};
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 = 'Streckenpunkt 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);
});