Files
Wegwichtel/test/editor-ui.test.js
T
Florian Zumpe a66482a137
Sonarqube Scanner / Build and analyze (push) Failing after 26s
update database schema and update editor script
2026-09-02 00:53:22 +02:00

354 lines
13 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');
}
test('route editor entry point, dialog and forms are present', 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, /aria-label="Streckeneditor öffnen"/);
assert.match(html, /<dialog id="editor-dialog"/);
assert.match(html, /<output id="editor-status" class="editor-status" aria-live="polite" hidden><\/output>/);
assert.doesNotMatch(html, /id="editor-status"[^>]*role="status"/);
assert.match(html, /id="editor-route-list"/);
assert.match(html, /id="editor-route-form"/);
assert.match(html, /id="editor-poi-form"/);
assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"/);
assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple/);
assert.match(html, /Neue Strecken bleiben unveröffentlicht/);
assert.match(css, /\.editor-entry-panel/);
assert.match(css, /\.editor-dialog/);
assert.match(css, /\.editor-poi-list/);
assert.match(ui, /pointerup/);
assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/);
});
test('route editor references all static editor classes for the project markup checks', async () => {
const html = await read('public/index.html');
const ui = await read('public/js/editor-ui.js');
const classNames = [...html.matchAll(/class="([^"]*\beditor-[^"]*)"/g)]
.flatMap(match => match[1].split(/\s+/))
.filter(name => name.startsWith('editor-'));
for (const className of new Set(classNames)) {
assert.match(ui, new RegExp(className), `editor class is not referenced by editor-ui.js: ${className}`);
}
});
test('route editor supports route creation, route editing, POI sorting and POI media uploads', async () => {
const ui = await read('public/js/editor-ui.js');
assert.match(ui, /readJson\('\/routes'\)/);
assert.match(ui, /readJson\(`\/routes\/\$\{encodeURIComponent\(id\)\}`\)/);
assert.match(ui, /const method = id \? 'PUT' : 'POST'/);
assert.match(ui, /const method = currentPoiId \? 'PUT' : 'POST'/);
assert.match(ui, /move-poi-up/);
assert.match(ui, /move-poi-down/);
assert.match(ui, /sortOrder: index/);
assert.match(ui, /uploadPoiMedia/);
assert.match(ui, /\/audio`/);
assert.match(ui, /\/images`/);
});
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.textContent = '';
this.className = '';
this.children = [];
this.dataset = {};
this.files = [];
this.parentElement = null;
this.attributes = new Map();
}
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 textResponse(value, ok = false, status = 500) {
return {
ok,
status,
text: async () => value
};
}
function pointerEvent({ pointerType = 'touch', button = 0, isPrimary = true } = {}) {
const event = new Event('pointerup', { bubbles: true, cancelable: true });
Object.defineProperties(event, {
button: { value: button },
isPrimary: { value: isPrimary },
pointerType: { value: pointerType }
});
return event;
}
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-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)]));
elements.get('editor-dialog').open = false;
elements.get('editor-route-gpx').files = [];
elements.get('editor-poi-audio').files = [];
elements.get('editor-poi-images').files = [];
const document = new EventTarget();
document.readyState = 'complete';
document.getElementById = id => elements.get(id) ?? null;
document.createElement = tagName => new FakeElement('', tagName);
const calls = [];
const routes = [
{ id: 1, name: 'Waldweg', description: 'Route im Wald', activated_at: null }
];
const routeDetail = {
id: 1,
name: 'Waldweg',
description: 'Route im Wald',
pois: [
{ id: 10, title: 'Start', description: 'Losgehen', lat: 50.1, lon: 10.1, radiusM: 20, sortOrder: 0 },
{ id: 11, title: 'Ziel', description: 'Ankommen', lat: 50.2, lon: 10.2, radiusM: 30, sortOrder: 1 }
]
};
async function fetch(url, options = {}) {
calls.push({ body: options.body, method: options.method || 'GET', url });
if (url === '/api/routes' && (!options.method || options.method === 'GET')) return jsonResponse({ routes });
if (url === '/api/routes/1' && (!options.method || options.method === 'GET')) return jsonResponse({ route: routeDetail });
if (url === '/api/routes/2' && (!options.method || options.method === 'GET')) return jsonResponse({ route: { id: 2, name: 'Neue Strecke', description: 'Neu', pois: [] } });
if (url === '/api/routes' && options.method === 'POST') return jsonResponse({ route: { id: 2, name: 'Neue Strecke', description: 'Neu' } });
if (url === '/api/routes/1' && options.method === 'PUT') return jsonResponse({ route: routeDetail });
if (url === '/api/routes/1/pois' && options.method === 'POST') return jsonResponse({ poi: { id: 12, title: 'Neue Station' } });
if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && options.method === 'PUT') return jsonResponse({ poi: { id: Number(url.split('/').pop()), title: 'Station' } });
if (/^\/api\/routes\/1\/pois\/\d+\/(audio|images)$/.test(url) && options.method === 'POST') return jsonResponse({ ok: true });
return textResponse(`unexpected ${options.method || 'GET'} ${url}`, false, 404);
}
const context = {
document,
Event,
EventTarget,
fetch,
FormData: FakeFormData,
globalThis: null,
Wegwichtel: {
Config: { apiBase: '/api/' }
},
WegwichtelEditorTestHooks: true
};
context.globalThis = context;
return { calls, context, elements };
}
test('route editor workflow is executable with draft routes and media uploads', 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 });
const hooks = context.Wegwichtel.EditorUi.testHooks;
assert.deepEqual(context.Wegwichtel.EditorUi.classNames(), hooks.classNames());
assert.equal(hooks.apiUrl('/routes'), '/api/routes');
assert.equal(hooks.isActivation(pointerEvent()), true);
assert.equal(hooks.isActivation(pointerEvent({ pointerType: 'mouse', button: 1 })), false);
await context.Wegwichtel.EditorUi.open();
assert.equal(elements.get('editor-dialog').open, true);
assert.equal(elements.get('editor-route-list-view').hidden, false);
assert.equal(elements.get('editor-route-detail-view').hidden, true);
assert.equal(elements.get('editor-route-list').children.length, 1);
await hooks.loadRoute({ id: 1 });
assert.equal(elements.get('editor-route-id').value, '1');
assert.equal(elements.get('editor-route-name').value, 'Waldweg');
assert.match(elements.get('editor-route-activated').textContent, /Nicht veröffentlicht/);
assert.equal(elements.get('editor-poi-list').children.length, 2);
hooks.swapPois(1, -1);
assert.equal(hooks.state.pois[0].id, 11);
await hooks.savePoiOrder();
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/1/pois/11'));
hooks.newRoute();
elements.get('editor-route-name').value = 'Neue Strecke';
elements.get('editor-route-description').value = 'Neu';
await hooks.saveRoute();
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes'));
await hooks.loadRoute({ id: 1 });
hooks.newPoi();
elements.get('editor-poi-title').value = 'Neue Station';
elements.get('editor-poi-description').value = 'Text';
elements.get('editor-poi-lat').value = '50.123';
elements.get('editor-poi-lon').value = '10.456';
elements.get('editor-poi-radius').value = '42';
elements.get('editor-poi-audio').files = [{ name: 'station.mp3' }];
elements.get('editor-poi-images').files = [{ name: 'eins.jpg' }, { name: 'zwei.jpg' }];
await hooks.savePoi();
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);
context.Wegwichtel.EditorUi.close();
assert.equal(elements.get('editor-dialog').open, false);
});
test('route editor test hooks cover fallback payloads, errors and form branches', 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 });
const hooks = context.Wegwichtel.EditorUi.testHooks;
assert.equal(hooks.routeArray([{ id: 'x' }]).length, 1);
assert.equal(hooks.routeArray({ items: [{ id: 'y' }] })[0].id, 'y');
assert.equal(hooks.routeArray(null).length, 0);
assert.equal(hooks.poiArray({ pointsOfInterest: [{ id: 3 }] })[0].id, 3);
assert.equal(hooks.poiArray({}).length, 0);
const unnamedRoute = hooks.routeItem({ id: 5 });
assert.equal(unnamedRoute.dataset.routeId, '5');
assert.equal(unnamedRoute.children[0].children[0].textContent, 'Unbenannte Strecke');
const unnamedPoi = hooks.poiItem({ id: 6 }, 0);
assert.equal(unnamedPoi.dataset.poiId, '6');
assert.equal(unnamedPoi.children[0].children[0].textContent, 'Unbenannte Station');
hooks.setStatus('Fehler', true);
assert.equal(elements.get('editor-status').hidden, false);
assert.equal(elements.get('editor-status').dataset.kind, 'error');
hooks.setStatus('');
assert.equal(elements.get('editor-status').hidden, true);
assert.equal(Object.keys(await hooks.parseJson({ text: async () => '' })).length, 0);
assert.equal(Object.keys(await hooks.parseJson({ text: async () => 'kein json' })).length, 0);
await assert.rejects(
hooks.handleResponse({ ok: false, status: 418, text: async () => '' }),
/HTTP 418/
);
await hooks.loadRoute({ id: 1 });
elements.get('editor-route-id').value = '1';
elements.get('editor-route-name').value = 'Waldweg geändert';
elements.get('editor-route-description').value = 'Mit GPX';
elements.get('editor-route-gpx').files = [{ name: 'route.gpx' }];
await hooks.saveRoute();
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/1' && call.body instanceof FakeFormData));
elements.get('editor-poi-id').value = '10';
elements.get('editor-poi-title').value = 'Start bearbeitet';
elements.get('editor-poi-description').value = 'Text';
elements.get('editor-poi-lat').value = '50.5';
elements.get('editor-poi-lon').value = '10.5';
elements.get('editor-poi-radius').value = '';
elements.get('editor-poi-audio').files = [];
elements.get('editor-poi-images').files = [];
await hooks.savePoi();
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/1/pois/10'));
assert.equal(elements.get('editor-route-list-view').hidden, true);
elements.get('editor-route-back').dispatchEvent(pointerEvent());
await new Promise(resolve => setTimeout(resolve, 0));
assert.equal(elements.get('editor-route-list-view').hidden, false);
});