Files
Wegwichtel/test/editor-ui.test.js
T
MrRobot 49a9de0355
Sonarqube Scanner / Prepare environment (push) Successful in 10s
Sonarqube Scanner / Build and test (push) Successful in 28s
Sonarqube Scanner / SonarQube analysis (push) Successful in 1m8s
Sonarqube Scanner / Deploy to Server (push) Successful in 8s
Multiple improvements
2026-10-05 01:18:14 +02:00

839 lines
37 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' && 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 };
}
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(), ['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');
});