Files
Wegwichtel/test/editor-ui.test.js
T
Florian Zumpe 7424eba5f8
Sonarqube Scanner / Prepare environment (push) Successful in 15s
Sonarqube Scanner / Build and test (push) Failing after 34s
Sonarqube Scanner / SonarQube analysis (push) Skipped
Sonarqube Scanner / Deploy to Server (push) Failing after 3s
sonarqube fix
2026-09-28 19:34:10 +02:00

562 lines
22 KiB
JavaScript

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"/);
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"[^>]+accept="audio\/\*"/);
assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple/);
assert.match(html, /ausschließlich in diesem Browser/i);
assert.match(css, /\.editor-dialog/);
assert.match(css, /\.editor-poi-list/);
assert.match(css, /\.editor-route-group/);
assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/);
assert.match(ui, /ns\.LocalRoutes\.save\(/);
assert.match(ui, /ns\.LocalRoutes\.parseWaypoints\(/);
assert.match(ui, /ns\.LocalRoutes\.fileToDataUrl\(/);
assert.match(ui, /move-poi-up/);
assert.match(ui, /move-poi-down/);
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['"]/);
});
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 = '';
}
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;
}
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) {
this.entries.push([name, value]);
}
}
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 keydownEvent(key, target = null) {
const event = new Event('keydown', { cancelable: true });
Object.defineProperty(event, 'key', { value: key });
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-cancel',
'editor-poi-description',
'editor-poi-form',
'editor-poi-form-title',
'editor-poi-form-view',
'editor-poi-id',
'editor-poi-images',
'editor-poi-lat',
'editor-poi-list',
'editor-poi-lon',
'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-id',
'editor-route-list',
'editor-route-list-view',
'editor-route-name',
'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: [
{ id: 21, title: 'Lokaler Start', description: 'Start', lat: 50.2, lon: 10.2, triggerRadiusM: 25, sequence: 0 },
{ id: 22, title: 'Lokales Ziel', description: 'Ziel', lat: 50.3, lon: 10.3, triggerRadiusM: 25, sequence: 1 }
]
}]
]);
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 },
{ id: 11, title: 'Ziel', description: 'Ankommen', lat: 50.2, lon: 10.2, triggerRadiusM: 20, sequence: 1 }
]
};
async function fetch(url, options = {}) {
const method = options.method || 'GET';
calls.push({ body: options.body, method, url });
if (url === '/api/routes?activatedOnly=true' && method === 'GET') return jsonResponse({ routes: [serverRoute] });
if (url === '/api/routes/1' && method === 'GET') return jsonResponse({ route: serverRoute });
if (url === '/api/routes/1' && method === 'PUT') {
if (options.body instanceof FakeFormData) {
const values = Object.fromEntries(options.body.entries);
return jsonResponse({ route: { ...serverRoute, name: values.name, description: values.description } });
}
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|images)$/.test(url) && method === 'POST') return jsonResponse({ ok: true });
return { ok: false, status: 404, text: async () => `unexpected ${method} ${url}` };
}
const LocalRoutes = {
createPoiId: () => 9001,
createRouteId: () => 'local-new',
emptyGpx: name => `<gpx><metadata><name>${name}</name></metadata></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,
list: () => [...packages.values()],
parseWaypoints: () => [],
readFileAsText: file => file.text(),
save: input => {
const id = String(input.id);
const now = '2026-09-19T14:00:00.000Z';
const saved = {
id,
gpx: input.gpx,
route: { ...input.route, id, updatedAt: now },
pois: input.pois.map((poi, sequence) => ({ ...poi, sequence }))
};
packages.set(id, saved);
saves.push(structuredClone(saved));
return saved;
}
};
const context = {
document,
Event,
EventTarget,
fetch,
FormData: FakeFormData,
globalThis: null,
structuredClone,
Wegwichtel: {
App: { currentPosition: () => ({ lat: 50.5, lon: 10.5 }) },
Config: { apiBase: '/api/' },
LocalRoutes
}
};
context.globalThis = context;
return { calls, context, elements, packages, saves, serverRoute };
}
test('public editor workflow saves new route and GPX POI 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();
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/);
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
elements.get('editor-poi-title').value = 'Aussicht';
elements.get('editor-poi-description').value = 'Text zur Station';
elements.get('editor-poi-lat').value = '50.51';
elements.get('editor-poi-lon').value = '10.52';
elements.get('editor-poi-radius').value = '35';
elements.get('editor-poi-audio').files = [{ name: 'aussicht.mp3', size: 5, type: 'audio/mpeg' }];
elements.get('editor-poi-images').files = [{ name: 'aussicht.jpg', size: 7, type: 'image/jpeg' }];
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
const 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.51);
assert.equal(local.pois[0].lon, 10.52);
assert.equal(local.pois[0].triggerRadiusM, 35);
assert.match(local.pois[0].audio.dataUrl, /^data:audio\/mpeg/);
assert.equal(local.pois[0].images.length, 1);
assert.match(local.pois[0].images[0].dataUrl, /^data:image\/jpeg/);
context.Wegwichtel.EditorUi.close();
assert.equal(elements.get('editor-dialog').open, false);
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
});
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 editButton = serverItem.children[1].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/);
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);
});
test('editor supports keyboard activation and the dialog fallback without native dialog methods', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { context, elements } = buildEditorContext();
const dialog = elements.get('editor-dialog');
dialog.showModal = undefined;
dialog.close = undefined;
vm.runInNewContext(source, context, { filename: sourcePath });
elements.get('editor-button').dispatchEvent(keydownEvent('Enter'));
await tick();
await tick();
assert.equal(dialog.open, true);
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'true');
elements.get('editor-close').dispatchEvent(pointerEvent());
assert.equal(dialog.open, false);
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
});
test('server route GPX upload uses multipart form data and keeps activation state untouched', 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 serverItem = elements.get('editor-route-list').children.find(child => child.dataset.routeSource === 'server');
const editButton = serverItem.children[1].children[0];
elements.get('editor-route-list').dispatchEvent(pointerEvent(editButton));
await tick();
await tick();
elements.get('editor-route-name').value = 'Serverroute mit GPX';
elements.get('editor-route-description').value = 'Multipart';
elements.get('editor-route-gpx').files = [{ name: 'route.gpx', type: 'application/gpx+xml' }];
elements.get('editor-route-form').dispatchEvent(submitEvent());
await tick();
await tick();
await tick();
const update = calls.find(call => call.method === 'PUT' && call.url === '/api/routes/1' && call.body instanceof FakeFormData);
assert.ok(update);
assert.deepEqual(update.body.entries.map(([name]) => name), ['name', 'description', 'file']);
});
test('server POI workflow creates media, edits POIs and persists reordered sequence values', 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');
routeList.dispatchEvent(pointerEvent(serverItem.children[1].children[0]));
await tick();
await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
assert.equal(elements.get('editor-poi-lat').value, '50.5');
assert.equal(elements.get('editor-poi-lon').value, '10.5');
elements.get('editor-poi-title').value = 'Neue Serverstation';
elements.get('editor-poi-description').value = 'Mit Medien';
elements.get('editor-poi-audio').files = [{ name: 'station.mp3', type: 'audio/mpeg' }];
elements.get('editor-poi-images').files = [
{ name: 'eins.jpg', type: 'image/jpeg' },
{ name: 'zwei.jpg', type: 'image/jpeg' }
];
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
await tick();
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/1/pois'));
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/1/pois/12/audio'));
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes/1/pois/12/images').length, 2);
const poiList = elements.get('editor-poi-list');
const firstPoi = poiList.children.find(child => child.dataset.poiId === '10');
const secondPoi = poiList.children.find(child => child.dataset.poiId === '11');
poiList.dispatchEvent(pointerEvent(secondPoi.children[1].children[2]));
assert.equal(elements.get('editor-poi-id').value, '11');
assert.equal(elements.get('editor-poi-title').value, 'Ziel');
elements.get('editor-poi-title').value = 'Ziel geändert';
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/1/pois/11'));
const refreshedFirstPoi = elements.get('editor-poi-list').children.find(child => child.dataset.poiId === '10');
elements.get('editor-poi-list').dispatchEvent(pointerEvent(refreshedFirstPoi.children[1].children[1]));
await tick();
await tick();
const reorderCalls = calls.filter(call => call.method === 'PUT' && /^\/api\/routes\/1\/pois\/(10|11)$/.test(call.url));
assert.ok(reorderCalls.length >= 2);
assert.ok(reorderCalls.some(call => JSON.parse(call.body).sortOrder === 0));
assert.ok(reorderCalls.some(call => JSON.parse(call.body).sortOrder === 1));
// Moving the first element upward is a no-op and must not trigger another request.
const countBeforeNoop = calls.length;
const firstAfterReorder = elements.get('editor-poi-list').children.find(child => child.dataset.poiId === '11');
elements.get('editor-poi-list').dispatchEvent(pointerEvent(firstAfterReorder.children[1].children[0]));
await tick();
assert.equal(calls.length, countBeforeNoop);
});
test('local route import, local reordering and validation errors remain browser-only', 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.openNewLocalRoute();
context.Wegwichtel.LocalRoutes.parseWaypoints = () => [
{ id: 700, title: 'Importiert', description: '', lat: 50.7, lon: 10.7, triggerRadiusM: 25, sequence: 0, images: [], audio: null }
];
elements.get('editor-route-name').value = 'Importierte Strecke';
elements.get('editor-route-gpx').files = [{ text: async () => '<gpx><wpt/></gpx>' }];
elements.get('editor-route-form').dispatchEvent(submitEvent());
await tick();
await tick();
assert.equal(packages.get('local-new').pois[0].id, 700);
assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false);
await context.Wegwichtel.EditorUi.open();
const localItem = elements.get('editor-route-list').children.find(child => child.dataset.routeId === 'local-existing');
elements.get('editor-route-list').dispatchEvent(pointerEvent(localItem.children[1].children[0]));
await tick();
await tick();
const localFirst = elements.get('editor-poi-list').children.find(child => child.dataset.poiId === '21');
elements.get('editor-poi-list').dispatchEvent(pointerEvent(localFirst.children[1].children[1]));
await tick();
await tick();
assert.deepEqual(saves.at(-1).pois.map(poi => poi.id), [22, 21]);
context.Wegwichtel.App.currentPosition = () => null;
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
elements.get('editor-poi-title').value = 'Ungültige Position';
elements.get('editor-poi-lat').value = 'keine-breite';
elements.get('editor-poi-lon').value = 'keine-laenge';
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
assert.equal(elements.get('editor-status').dataset.kind, 'error');
assert.match(elements.get('editor-status').textContent, /gültige Breiten- und Längengrade/);
});
test('editor handles empty, malformed and failing route responses without stale status', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
{
const { context, elements } = buildEditorContext();
context.Wegwichtel.LocalRoutes.list = () => [];
context.fetch = async () => ({ ok: true, status: 200, text: async () => '{' });
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.open();
const emptyItems = elements.get('editor-route-list').children.filter(child => !child.dataset.routeId);
assert.ok(emptyItems.length >= 4);
assert.equal(elements.get('editor-status').hidden, true);
}
{
const { context, elements } = buildEditorContext();
context.Wegwichtel.LocalRoutes.list = () => { throw new Error('Lokaler Speicher defekt'); };
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.open();
assert.equal(elements.get('editor-status').dataset.kind, 'error');
assert.match(elements.get('editor-status').textContent, /Lokaler Speicher defekt/);
}
{
const { context, elements } = buildEditorContext();
context.fetch = async () => ({ ok: false, status: 503, text: async () => 'Dienst nicht verfügbar' });
vm.runInNewContext(source, context, { filename: sourcePath });
elements.get('editor-button').dispatchEvent(pointerEvent());
await tick();
await tick();
assert.equal(elements.get('editor-status').dataset.kind, 'error');
assert.match(elements.get('editor-status').textContent, /Dienst nicht verfügbar/);
}
});