update editor and add tests
This commit is contained in:
@@ -9,58 +9,41 @@ async function read(relativePath) {
|
||||
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function normalizedTokens(line) {
|
||||
return line
|
||||
.trim()
|
||||
.replaceAll(',', ' ')
|
||||
.replaceAll(';', ' ')
|
||||
.replaceAll('(', ' ')
|
||||
.replaceAll(')', ' ')
|
||||
.split(' ')
|
||||
.map(token => token.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function routeTableLines(schema) {
|
||||
const lines = schema.split('\n');
|
||||
const start = lines.findIndex(line => normalizedTokens(line).join(' ') === 'CREATE TABLE IF NOT EXISTS ROUTES');
|
||||
assert.notEqual(start, -1, 'database/schema.sql must define the routes table');
|
||||
|
||||
const end = lines.findIndex((line, index) => index > start && normalizedTokens(line).includes('STRICT'));
|
||||
assert.notEqual(end, -1, 'the routes table must be declared STRICT');
|
||||
return lines.slice(start, end + 1);
|
||||
}
|
||||
|
||||
function hasActivatedAtTextDefaultNull(lines) {
|
||||
return lines.some(line => {
|
||||
const tokens = normalizedTokens(line);
|
||||
return tokens[0] === 'ACTIVATED_AT'
|
||||
&& tokens[1] === 'TEXT'
|
||||
&& tokens.includes('DEFAULT')
|
||||
&& tokens.includes('NULL');
|
||||
});
|
||||
}
|
||||
|
||||
function containsTokenSequence(sql, sequence) {
|
||||
const tokens = sql
|
||||
.split('\n')
|
||||
.flatMap(normalizedTokens);
|
||||
|
||||
return tokens.some((_, index) => sequence.every((token, offset) => tokens[index + offset] === token));
|
||||
}
|
||||
|
||||
test('initial database schema creates routes with a nullable activation timestamp', async () => {
|
||||
test('fresh routes schema stores timestamps as STRICT-compatible TEXT values', async () => {
|
||||
const schema = await read('database/schema.sql');
|
||||
const migration = await read('database/migrations/20260901_add_route_activated_at.sql');
|
||||
const routes = routeTableLines(schema);
|
||||
const routesTable = schema.match(/CREATE TABLE IF NOT EXISTS routes\s*\((?<body>[\s\S]*?)\)\s*STRICT\s*;/i);
|
||||
|
||||
assert.equal(hasActivatedAtTextDefaultNull(routes), true);
|
||||
assert.equal(containsTokenSequence(routes.join('\n'), ['ACTIVATED_AT', 'DATETIME']), false);
|
||||
assert.equal(containsTokenSequence(routes.join('\n'), ['ON', 'UPDATE', 'CURRENT_TIMESTAMP']), false);
|
||||
assert.equal(containsTokenSequence(schema, ['CREATE', 'INDEX', 'IF', 'NOT', 'EXISTS', 'IDX_ROUTES_ACTIVATION', 'ON', 'ROUTES']), true);
|
||||
assert.equal(containsTokenSequence(schema, ['CREATE', 'TRIGGER', 'IF', 'NOT', 'EXISTS', 'POIS_UPDATED_AT']), true);
|
||||
assert.equal(containsTokenSequence(migration, ['ALTER', 'TABLE', 'ROUTES', 'ADD', 'COLUMN', 'ACTIVATED_AT', 'TEXT']), true);
|
||||
assert.equal(containsTokenSequence(migration, ['CREATE', 'INDEX', 'IF', 'NOT', 'EXISTS', 'IDX_ROUTES_ACTIVATION', 'ON', 'ROUTES']), true);
|
||||
assert.equal(containsTokenSequence(migration, ['ACTIVATED_AT', 'DATETIME']), false);
|
||||
assert.equal(migration.includes('activated_at is NULL'), true);
|
||||
assert.ok(routesTable?.groups?.body, 'routes must be a STRICT table');
|
||||
const body = routesTable.groups.body;
|
||||
|
||||
assert.match(body, /created_at\s+TEXT\s+NOT NULL\s+DEFAULT\s+CURRENT_TIMESTAMP/i);
|
||||
assert.match(body, /updated_at\s+TEXT\s+NOT NULL\s+DEFAULT\s+CURRENT_TIMESTAMP/i);
|
||||
assert.match(body, /deleted_at\s+TEXT\s+DEFAULT\s+NULL/i);
|
||||
assert.match(body, /activated_at\s+TEXT\s+DEFAULT\s+NULL/i);
|
||||
assert.doesNotMatch(body, /activated_at\s+DATETIME/i);
|
||||
assert.doesNotMatch(body, /ON\s+UPDATE\s+CURRENT_TIMESTAMP/i);
|
||||
});
|
||||
|
||||
test('fresh schema owns timestamp triggers and activation index', async () => {
|
||||
const schema = await read('database/schema.sql');
|
||||
|
||||
assert.match(schema, /CREATE TRIGGER IF NOT EXISTS routes_updated_at/i);
|
||||
assert.match(schema, /AFTER UPDATE ON routes/i);
|
||||
assert.match(schema, /SET updated_at\s*=\s*CURRENT_TIMESTAMP/i);
|
||||
assert.match(schema, /CREATE TRIGGER IF NOT EXISTS pois_updated_at/i);
|
||||
assert.match(schema, /AFTER UPDATE ON pois/i);
|
||||
assert.match(schema, /CREATE INDEX IF NOT EXISTS idx_routes_activation\s+ON routes\s*\(\s*status\s*,\s*activated_at\s*\)/i);
|
||||
});
|
||||
|
||||
test('public route listing can require activated routes without changing base service semantics', async () => {
|
||||
const service = await read('src/services/routes-service.js');
|
||||
const api = await read('src/routes/api.js');
|
||||
const client = await read('public/js/api-client.js');
|
||||
|
||||
assert.match(service, /publishedOnly\s*=\s*false/);
|
||||
assert.match(service, /activated_at\s+IS\s+NOT\s+NULL/i);
|
||||
assert.match(service, /activatedAt:\s*row\.activated_at/);
|
||||
|
||||
assert.match(api, /publishedOnly:\s*req\.query\.activatedOnly\s*===\s*['"]true['"]/);
|
||||
assert.match(client, /activatedOnly:\s*['"]true['"]/);
|
||||
});
|
||||
|
||||
+155
-172
@@ -10,60 +10,40 @@ async function read(relativePath) {
|
||||
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('route editor entry point, dialog and forms are present', async () => {
|
||||
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, /aria-label="Streckeneditor öffnen"/);
|
||||
assert.match(html, /aria-haspopup="dialog"/);
|
||||
assert.match(html, /aria-controls="editor-dialog"/);
|
||||
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-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, /Neue Strecken bleiben unveröffentlicht/);
|
||||
assert.match(html, /ausschließlich in diesem Browser/i);
|
||||
|
||||
assert.match(css, /\.editor-entry-panel/);
|
||||
assert.match(css, /\.editor-dialog/);
|
||||
assert.match(css, /\.editor-poi-list/);
|
||||
assert.match(css, /\.editor-route-group/);
|
||||
|
||||
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.doesNotMatch(ui, /activated_at', ''/);
|
||||
assert.doesNotMatch(ui, /activatedAt: null/);
|
||||
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: index/);
|
||||
assert.match(ui, /uploadPoiMedia/);
|
||||
assert.match(ui, /\/audio`/);
|
||||
assert.match(ui, /\/images`/);
|
||||
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 {
|
||||
@@ -74,13 +54,25 @@ class FakeElement extends EventTarget {
|
||||
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();
|
||||
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) {
|
||||
@@ -138,24 +130,21 @@ function jsonResponse(value, ok = true, status = 200) {
|
||||
};
|
||||
}
|
||||
|
||||
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 });
|
||||
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',
|
||||
@@ -167,6 +156,7 @@ function buildEditorContext() {
|
||||
'editor-poi-cancel',
|
||||
'editor-poi-description',
|
||||
'editor-poi-form',
|
||||
'editor-poi-form-title',
|
||||
'editor-poi-form-view',
|
||||
'editor-poi-id',
|
||||
'editor-poi-images',
|
||||
@@ -188,43 +178,74 @@ function buildEditorContext() {
|
||||
'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 = {
|
||||
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: '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 }
|
||||
]
|
||||
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 = {}) {
|
||||
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 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') {
|
||||
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,
|
||||
@@ -232,134 +253,96 @@ function buildEditorContext() {
|
||||
fetch,
|
||||
FormData: FakeFormData,
|
||||
globalThis: null,
|
||||
structuredClone,
|
||||
Wegwichtel: {
|
||||
Config: { apiBase: '/api/' }
|
||||
},
|
||||
WegwichtelEditorTestHooks: true
|
||||
App: { currentPosition: () => ({ lat: 50.5, lon: 10.5 }) },
|
||||
Config: { apiBase: '/api/' },
|
||||
LocalRoutes
|
||||
}
|
||||
};
|
||||
context.globalThis = context;
|
||||
return { calls, context, elements };
|
||||
return { calls, context, elements, packages, saves };
|
||||
}
|
||||
|
||||
test('route editor workflow is executable with draft routes and media uploads', async () => {
|
||||
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 } = buildEditorContext();
|
||||
const { calls, context, elements, packages, saves } = 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-button').attributes.get('aria-expanded'), '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);
|
||||
assert.ok(calls.some(call => call.method === 'GET' && call.url === '/api/routes?activatedOnly=true'));
|
||||
|
||||
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);
|
||||
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();
|
||||
|
||||
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'));
|
||||
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/);
|
||||
|
||||
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'));
|
||||
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();
|
||||
|
||||
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);
|
||||
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('route editor test hooks cover fallback payloads, errors and form branches', async () => {
|
||||
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 });
|
||||
const hooks = context.Wegwichtel.EditorUi.testHooks;
|
||||
await context.Wegwichtel.EditorUi.open();
|
||||
|
||||
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 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();
|
||||
|
||||
const unnamedRoute = hooks.routeItem({ id: 5 });
|
||||
assert.equal(unnamedRoute.dataset.routeId, '5');
|
||||
assert.equal(unnamedRoute.children[0].children[0].textContent, 'Unbenannte Strecke');
|
||||
hooks.state.pois = [{ id: 6 }, { id: 7 }];
|
||||
const unnamedPoi = hooks.poiItem({ id: 6 }, 0);
|
||||
assert.equal(unnamedPoi.dataset.poiId, '6');
|
||||
assert.equal(unnamedPoi.children[0].children[0].textContent, 'Unbenannte Station');
|
||||
assert.equal(unnamedPoi.children[1].children[0].disabled, true);
|
||||
assert.equal(unnamedPoi.children[1].children[1].disabled, false);
|
||||
assert.equal(elements.get('editor-route-id').value, '1');
|
||||
assert.match(elements.get('editor-route-activated').textContent, /Veröffentlicht am/);
|
||||
|
||||
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);
|
||||
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();
|
||||
|
||||
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';
|
||||
assert.deepEqual(Object.keys(hooks.routeFromForm()).sort(), ['description', 'name']);
|
||||
elements.get('editor-route-gpx').files = [{ name: 'route.gpx' }];
|
||||
await hooks.saveRoute();
|
||||
const gpxUpdate = calls.find(call => call.method === 'PUT' && call.url === '/api/routes/1' && call.body instanceof FakeFormData);
|
||||
assert.ok(gpxUpdate);
|
||||
assert.equal(gpxUpdate.body.entries.some(([name]) => name === 'activated_at'), false);
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
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, '..');
|
||||
const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1';
|
||||
const POI_NAMESPACE = 'urn:wegwichtel:poi';
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function decodeXml(value) {
|
||||
return String(value)
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('&', '&');
|
||||
}
|
||||
|
||||
class XmlElement {
|
||||
constructor(namespaceURI, tagName) {
|
||||
this.namespaceURI = namespaceURI;
|
||||
this.tagName = tagName;
|
||||
this.localName = tagName.includes(':') ? tagName.split(':').at(-1) : tagName;
|
||||
this.attributes = new Map();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this._text = '';
|
||||
}
|
||||
|
||||
get firstChild() {
|
||||
return this.children[0] || null;
|
||||
}
|
||||
|
||||
get textContent() {
|
||||
return this._text + this.children.map(child => child.textContent).join('');
|
||||
}
|
||||
|
||||
set textContent(value) {
|
||||
this.children = [];
|
||||
this._text = String(value ?? '');
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
insertBefore(child, reference) {
|
||||
child.parentNode = this;
|
||||
const index = reference ? this.children.indexOf(reference) : -1;
|
||||
if (index < 0) this.children.push(child);
|
||||
else this.children.splice(index, 0, child);
|
||||
return child;
|
||||
}
|
||||
|
||||
remove() {
|
||||
if (!this.parentNode) return;
|
||||
this.parentNode.children = this.parentNode.children.filter(child => child !== this);
|
||||
this.parentNode = null;
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes.has(name) ? this.attributes.get(name) : null;
|
||||
}
|
||||
|
||||
getElementsByTagName(name) {
|
||||
const result = [];
|
||||
for (const child of this.children) {
|
||||
if (name === '*' || child.tagName === name || child.localName === name) result.push(child);
|
||||
result.push(...child.getElementsByTagName(name));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getElementsByTagNameNS(namespaceURI, localName) {
|
||||
const result = [];
|
||||
for (const child of this.children) {
|
||||
const namespaceMatches = namespaceURI === '*' || child.namespaceURI === namespaceURI;
|
||||
if (namespaceMatches && child.localName === localName) result.push(child);
|
||||
result.push(...child.getElementsByTagNameNS(namespaceURI, localName));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class XmlDocument {
|
||||
constructor(rootElement = null) {
|
||||
this.documentElement = rootElement;
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
return selector === 'parsererror' ? null : null;
|
||||
}
|
||||
|
||||
createElementNS(namespaceURI, tagName) {
|
||||
return new XmlElement(namespaceURI, tagName);
|
||||
}
|
||||
|
||||
getElementsByTagName(name) {
|
||||
if (!this.documentElement) return [];
|
||||
const own = name === '*' || this.documentElement.tagName === name || this.documentElement.localName === name
|
||||
? [this.documentElement]
|
||||
: [];
|
||||
return [...own, ...this.documentElement.getElementsByTagName(name)];
|
||||
}
|
||||
|
||||
getElementsByTagNameNS(namespaceURI, localName) {
|
||||
if (!this.documentElement) return [];
|
||||
const ownNamespaceMatches = namespaceURI === '*' || this.documentElement.namespaceURI === namespaceURI;
|
||||
const own = ownNamespaceMatches && this.documentElement.localName === localName ? [this.documentElement] : [];
|
||||
return [...own, ...this.documentElement.getElementsByTagNameNS(namespaceURI, localName)];
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDOMParser {
|
||||
parseFromString(xml) {
|
||||
const tokens = String(xml)
|
||||
.replace(/<\?xml[\s\S]*?\?>/gi, '')
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.match(/<[^>]+>|[^<]+/g) || [];
|
||||
const stack = [];
|
||||
let rootElement = null;
|
||||
|
||||
for (const token of tokens) {
|
||||
if (!token.trim()) continue;
|
||||
if (token.startsWith('</')) {
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('<!')) continue;
|
||||
if (!token.startsWith('<')) {
|
||||
if (stack.length) stack.at(-1)._text += decodeXml(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const selfClosing = /\/\s*>$/.test(token);
|
||||
const match = token.match(/^<\s*([^\s/>]+)([\s\S]*?)\/?\s*>$/);
|
||||
if (!match) return new XmlDocument(new XmlElement('', 'parsererror'));
|
||||
const [, tagName, attributeText] = match;
|
||||
const namespaceURI = tagName.startsWith('wegwichtel:') ? POI_NAMESPACE : GPX_NAMESPACE;
|
||||
const element = new XmlElement(namespaceURI, tagName);
|
||||
const attributePattern = /([^\s=]+)\s*=\s*(["'])(.*?)\2/g;
|
||||
for (const attribute of attributeText.matchAll(attributePattern)) {
|
||||
element.setAttribute(attribute[1], decodeXml(attribute[3]));
|
||||
}
|
||||
|
||||
if (stack.length) stack.at(-1).appendChild(element);
|
||||
else if (!rootElement) rootElement = element;
|
||||
else return new XmlDocument(new XmlElement('', 'parsererror'));
|
||||
if (!selfClosing) stack.push(element);
|
||||
}
|
||||
|
||||
if (!rootElement || stack.length) return new XmlDocument(new XmlElement('', 'parsererror'));
|
||||
return new XmlDocument(rootElement);
|
||||
}
|
||||
}
|
||||
|
||||
function serializeElement(element) {
|
||||
const attributes = [...element.attributes]
|
||||
.map(([name, value]) => ` ${name}="${escapeXml(value)}"`)
|
||||
.join('');
|
||||
const content = escapeXml(element._text) + element.children.map(serializeElement).join('');
|
||||
return `<${element.tagName}${attributes}>${content}</${element.tagName}>`;
|
||||
}
|
||||
|
||||
class FakeXMLSerializer {
|
||||
serializeToString(document) {
|
||||
return serializeElement(document.documentElement);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeStorage {
|
||||
constructor() {
|
||||
this.values = new Map();
|
||||
this.failNext = false;
|
||||
}
|
||||
|
||||
getItem(key) {
|
||||
return this.values.has(key) ? this.values.get(key) : null;
|
||||
}
|
||||
|
||||
setItem(key, value) {
|
||||
if (this.failNext) {
|
||||
this.failNext = false;
|
||||
const error = new Error('quota');
|
||||
error.name = 'QuotaExceededError';
|
||||
throw error;
|
||||
}
|
||||
this.values.set(String(key), String(value));
|
||||
}
|
||||
|
||||
removeItem(key) {
|
||||
this.values.delete(String(key));
|
||||
}
|
||||
}
|
||||
|
||||
class FakeCustomEvent extends Event {
|
||||
constructor(type, options = {}) {
|
||||
super(type);
|
||||
this.detail = options.detail;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeFileReader extends EventTarget {
|
||||
readAsText(file) {
|
||||
queueMicrotask(() => {
|
||||
this.result = file.content || '';
|
||||
this.dispatchEvent(new Event('load'));
|
||||
});
|
||||
}
|
||||
|
||||
readAsDataURL(file) {
|
||||
queueMicrotask(() => {
|
||||
const type = file.type || 'application/octet-stream';
|
||||
this.result = `data:${type};base64,VEVTVA==`;
|
||||
this.dispatchEvent(new Event('load'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocalRoutes() {
|
||||
const sourcePath = path.join(root, 'public/js/local-routes.js');
|
||||
const source = await fs.readFile(sourcePath, 'utf8');
|
||||
const localStorage = new FakeStorage();
|
||||
const document = new EventTarget();
|
||||
const events = [];
|
||||
document.addEventListener('wegwichtel:local-routes-changed', event => events.push(event.detail));
|
||||
|
||||
const context = {
|
||||
CustomEvent: FakeCustomEvent,
|
||||
DOMParser: FakeDOMParser,
|
||||
Event,
|
||||
EventTarget,
|
||||
FileReader: FakeFileReader,
|
||||
XMLSerializer: FakeXMLSerializer,
|
||||
crypto: { randomUUID: () => '11111111-2222-4333-8444-555555555555' },
|
||||
document,
|
||||
globalThis: null,
|
||||
localStorage,
|
||||
queueMicrotask,
|
||||
Wegwichtel: {}
|
||||
};
|
||||
context.globalThis = context;
|
||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||
return { api: context.Wegwichtel.LocalRoutes, events, localStorage };
|
||||
}
|
||||
|
||||
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Test" xmlns="${GPX_NAMESPACE}" xmlns:wegwichtel="${POI_NAMESPACE}">
|
||||
<metadata><name>Alter Name</name></metadata>
|
||||
<wpt lat="50.1000" lon="10.1000"><name>Import</name><extensions><wegwichtel:poiId>77</wegwichtel:poiId></extensions></wpt>
|
||||
<trk><name>Weg</name><trkseg>
|
||||
<trkpt lat="50.1000" lon="10.1000"><ele>100</ele></trkpt>
|
||||
<trkpt lat="50.1010" lon="10.1020"><ele>112</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`;
|
||||
|
||||
test('local route package stores GPX and JSON separately and reconstructs an app route', async () => {
|
||||
const { api, events, localStorage } = await loadLocalRoutes();
|
||||
const routeId = api.createRouteId();
|
||||
assert.equal(routeId, 'local-11111111-2222-4333-8444-555555555555');
|
||||
|
||||
const saved = api.save({
|
||||
id: routeId,
|
||||
gpx: sampleGpx,
|
||||
route: { id: routeId, name: 'Neue Route', description: 'Lokal gespeichert' },
|
||||
pois: [
|
||||
{
|
||||
id: 101,
|
||||
title: 'Start',
|
||||
description: 'Erster POI',
|
||||
lat: 50.1,
|
||||
lon: 10.1,
|
||||
triggerRadiusM: 30,
|
||||
audio: { dataUrl: 'data:audio/mpeg;base64,QQ==', name: 'start.mp3' },
|
||||
images: [{ id: 'img-1', dataUrl: 'data:image/jpeg;base64,Qg==', caption: 'Bild' }]
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
title: 'Ziel',
|
||||
description: 'Zweiter POI',
|
||||
lat: 50.101,
|
||||
lon: 10.102,
|
||||
triggerRadiusM: 25,
|
||||
images: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert.equal(saved.route.name, 'Neue Route');
|
||||
assert.equal(saved.pois.length, 2);
|
||||
assert.equal(events.at(-1).action, 'saved');
|
||||
assert.equal(events.at(-1).id, routeId);
|
||||
|
||||
const prefix = api.STORAGE_PREFIX;
|
||||
const encodedId = encodeURIComponent(routeId);
|
||||
const gpx = localStorage.getItem(`${prefix}.${encodedId}.gpx`);
|
||||
const metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`));
|
||||
|
||||
assert.match(gpx, /<wpt lat="50\.1" lon="10\.1">/);
|
||||
assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/);
|
||||
assert.ok(gpx.indexOf('<wpt') < gpx.indexOf('<trk>'), 'GPX waypoints must be serialized before the track');
|
||||
assert.equal(metadata.route.name, 'Neue Route');
|
||||
assert.equal(metadata.pois[0].audio.name, 'start.mp3');
|
||||
assert.equal('gpx' in metadata, false);
|
||||
assert.equal('_source' in metadata.route, false);
|
||||
|
||||
const appRoute = api.getAppRoute(routeId);
|
||||
assert.equal(appRoute.source, 'local');
|
||||
assert.equal(appRoute.local, true);
|
||||
assert.equal(appRoute.pois.length, 2);
|
||||
assert.equal(appRoute.pois[0].audioUrl, 'data:audio/mpeg;base64,QQ==');
|
||||
assert.equal(appRoute.pois[0].images[0].url, 'data:image/jpeg;base64,Qg==');
|
||||
assert.equal(appRoute.points.length, 2);
|
||||
assert.ok(appRoute.distanceM > 0);
|
||||
assert.equal(Math.round(appRoute.elevationGainM), 12);
|
||||
const listed = api.listAppRoutes();
|
||||
assert.equal(listed.length, 1);
|
||||
assert.equal(listed[0].id, routeId);
|
||||
});
|
||||
|
||||
test('GPX waypoint import reads stable POI ids and validates coordinates', async () => {
|
||||
const { api } = await loadLocalRoutes();
|
||||
const imported = api.parseWaypoints(sampleGpx);
|
||||
|
||||
assert.equal(imported.length, 1);
|
||||
assert.equal(imported[0].id, 77);
|
||||
assert.equal(imported[0].title, 'Import');
|
||||
assert.equal(imported[0].lat, 50.1);
|
||||
assert.equal(imported[0].lon, 10.1);
|
||||
|
||||
assert.match(api.emptyGpx('A & B'), /<name>A & B<\/name>/);
|
||||
assert.throws(() => api.parseWaypoints('<not-gpx></not-gpx>'), /GPX-Datei ist nicht gültig/);
|
||||
assert.throws(() => api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /gültige Koordinaten/);
|
||||
});
|
||||
|
||||
test('remove updates index and dispatches a local-route change event', async () => {
|
||||
const { api, events } = await loadLocalRoutes();
|
||||
api.save({
|
||||
id: 'local-remove',
|
||||
gpx: sampleGpx,
|
||||
route: { name: 'Entfernen' },
|
||||
pois: []
|
||||
});
|
||||
|
||||
assert.equal(api.list().length, 1);
|
||||
api.remove('local-remove');
|
||||
assert.equal(api.list().length, 0);
|
||||
assert.equal(events.at(-1).action, 'removed');
|
||||
assert.equal(events.at(-1).id, 'local-remove');
|
||||
assert.throws(() => api.getAppRoute('local-remove'), /nicht gefunden/);
|
||||
});
|
||||
|
||||
test('quota failure rolls back package writes and keeps the previous package readable', async () => {
|
||||
const { api, localStorage } = await loadLocalRoutes();
|
||||
api.save({
|
||||
id: 'local-quota',
|
||||
gpx: sampleGpx,
|
||||
route: { name: 'Vorher' },
|
||||
pois: []
|
||||
});
|
||||
|
||||
localStorage.failNext = true;
|
||||
assert.throws(() => api.save({
|
||||
id: 'local-quota',
|
||||
gpx: sampleGpx,
|
||||
route: { name: 'Nachher' },
|
||||
pois: []
|
||||
}), /Browser-Speicher ist voll/);
|
||||
|
||||
assert.equal(api.get('local-quota').route.name, 'Vorher');
|
||||
});
|
||||
|
||||
test('file helpers support native text and FileReader-backed media data URLs', async () => {
|
||||
const { api } = await loadLocalRoutes();
|
||||
|
||||
assert.equal(await api.readFileAsText({ text: async () => '<gpx></gpx>' }), '<gpx></gpx>');
|
||||
assert.equal(await api.readFileAsText(null), '');
|
||||
|
||||
const media = await api.fileToDataUrl({ name: 'ton.mp3', size: 4, type: 'audio/mpeg' });
|
||||
assert.equal(media.name, 'ton.mp3');
|
||||
assert.equal(media.size, 4);
|
||||
assert.equal(media.type, 'audio/mpeg');
|
||||
assert.match(media.dataUrl, /^data:audio\/mpeg;base64,/);
|
||||
assert.equal(await api.fileToDataUrl(null), null);
|
||||
});
|
||||
@@ -9,42 +9,76 @@ async function read(relativePath) {
|
||||
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('start page keeps nearby recommendations and the complete route list', async () => {
|
||||
test('main screen contains a persistent route control next to the information control', async () => {
|
||||
const html = await read('public/index.html');
|
||||
const app = await read('public/js/app.js');
|
||||
const css = await read('public/css/editor.css');
|
||||
|
||||
assert.match(html, /id="nearby-route-list"/);
|
||||
assert.match(html, /id="all-route-list"/);
|
||||
assert.match(html, /id="route-search"/);
|
||||
assert.match(app, /renderRouteItems\('#nearby-route-list'/);
|
||||
assert.match(app, /renderRouteItems\(\s*'#all-route-list'/);
|
||||
assert.doesNotMatch(app, /state\.routes\s*=\s*state\.routes[\s\S]*?\.filter\(route => route\.proximityM/);
|
||||
assert.match(html, /class="route-control"/);
|
||||
assert.match(html, /id="route-control-button"/);
|
||||
assert.match(html, /id="route-control-dropdown"/);
|
||||
assert.match(html, /id="route-control-title"/);
|
||||
assert.match(html, /id="route-control-next"/);
|
||||
assert.match(html, /id="route-control-server-list"/);
|
||||
assert.match(html, /id="route-control-local-list"/);
|
||||
assert.match(html, /id="route-control-new"/);
|
||||
assert.match(html, /id="info-button"/);
|
||||
|
||||
assert.match(css, /\.route-control\s*\{[\s\S]*position:\s*fixed/i);
|
||||
assert.match(css, /\.route-control\s*\{[\s\S]*right:\s*calc\([\s\S]*--icon-button-size/i);
|
||||
assert.match(css, /\.route-control-dropdown/);
|
||||
});
|
||||
|
||||
test('route selection renders independently from geolocation and restores after mobile page resume', async () => {
|
||||
test('route selection separates activated server routes from local browser routes', async () => {
|
||||
const html = await read('public/index.html');
|
||||
const app = await read('public/js/app.js');
|
||||
const loader = await read('public/js/bootstrap-loader.js');
|
||||
const css = await read('public/css/app.css');
|
||||
const server = await read('server.js');
|
||||
const client = await read('public/js/api-client.js');
|
||||
const api = await read('src/routes/api.js');
|
||||
|
||||
assert.match(html, /vendor\/jquery-ui\/jquery-ui-1\.14\.2\.min\.css\?v=0\.12\.14/);
|
||||
assert.match(html, /css\/app\.css\?v=0\.12\.14/);
|
||||
assert.match(html, /id="all-routes-summary"[^>]*>Routen werden geladen/);
|
||||
assert.doesNotMatch(loader, /loadStyle\(/);
|
||||
assert.doesNotMatch(css, /:has\(/);
|
||||
assert.match(html, /<header class="has-back-button">/);
|
||||
assert.match(html, /id="all-route-list"/);
|
||||
assert.match(html, /id="local-route-list"/);
|
||||
assert.match(html, />Lokale Strecken</);
|
||||
|
||||
const firstRender = app.indexOf('renderRoutes();', app.indexOf('async initialize(context)'));
|
||||
const locationStart = app.indexOf('await initializeLocation(context);', app.indexOf('async initialize(context)'));
|
||||
assert.ok(firstRender >= 0 && locationStart > firstRender, 'Routen müssen vor der Standortabfrage gerendert werden.');
|
||||
assert.match(app, /globalThis\.addEventListener\('pageshow', restoreRouteLists\)/);
|
||||
assert.match(app, /document\.visibilityState === 'visible'/);
|
||||
assert.match(app, /Array\.isArray\(response\?\.routes\)/);
|
||||
assert.match(app, /throw new TypeError\('Die Routen-API hat keine gültige Routenliste geliefert\.'\)/);
|
||||
assert.match(app, /route\?\.id != null && route\?\.name != null/);
|
||||
assert.doesNotMatch(app, /route && route\.id/);
|
||||
assert.match(server, /no-cache, no-store, must-revalidate/);
|
||||
assert.match(api, /api\.use\(\(req, res, next\)/);
|
||||
assert.match(client, /activatedOnly:\s*['"]true['"]/);
|
||||
assert.match(api, /publishedOnly:\s*req\.query\.activatedOnly\s*===\s*['"]true['"]/);
|
||||
assert.match(app, /renderRouteItems\(\s*['"]#local-route-list['"]/);
|
||||
assert.match(app, /renderRouteControlList\(\s*['"]#route-control-local-list['"]/);
|
||||
assert.match(app, /ns\.LocalRoutes\.getAppRoute\(id\)/);
|
||||
assert.match(app, /\[data-route-select\]/);
|
||||
});
|
||||
|
||||
test('persistent route control displays the next unvisited POI and preserves progress when switching', async () => {
|
||||
const app = await read('public/js/app.js');
|
||||
|
||||
assert.match(app, /function nextPoiMetric\(/);
|
||||
assert.match(app, /Nächster POI:/);
|
||||
assert.match(app, /state\.triggered\.has\(poi\.id\)/);
|
||||
assert.match(app, /function suspendCurrentRoute\(/);
|
||||
assert.match(app, /persistRouteProgress\(\)/);
|
||||
assert.match(app, /await selectRoute/);
|
||||
});
|
||||
|
||||
test('local route storage is loaded independently from optional route progress storage', async () => {
|
||||
const html = await read('public/index.html');
|
||||
const app = await read('public/js/app.js');
|
||||
const localRoutes = await read('public/js/local-routes.js');
|
||||
|
||||
const localScript = html.indexOf('js/local-routes.js');
|
||||
const editorScript = html.indexOf('js/editor-ui.js');
|
||||
assert.ok(localScript >= 0 && editorScript > localScript, 'local route storage must load before the editor');
|
||||
|
||||
assert.match(localRoutes, /wegwichtel\.local-routes\.v1/);
|
||||
assert.match(localRoutes, /routeKey\(id, ['"]gpx['"]\)/);
|
||||
assert.match(localRoutes, /routeKey\(id, ['"]json['"]\)/);
|
||||
assert.match(localRoutes, /wegwichtel:local-routes-changed/);
|
||||
assert.match(app, /wegwichtel:local-routes-changed/);
|
||||
});
|
||||
|
||||
test('route selection remains pointer-oriented and avoids unsupported relational selectors', async () => {
|
||||
const app = await read('public/js/app.js');
|
||||
const editor = await read('public/js/editor-ui.js');
|
||||
|
||||
assert.match(app, /pointerup/);
|
||||
assert.match(editor, /pointerup/);
|
||||
assert.doesNotMatch(editor, /addEventListener\(['"]click['"]/);
|
||||
assert.doesNotMatch(app, /:has\(/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { config } from '../src/config.js';
|
||||
|
||||
const databasePath = path.join(config.dataDir, 'wegwichtel.sqlite');
|
||||
const activeRoutesDirectory = path.join(config.storageDir, 'active', 'routes');
|
||||
const trashRoutesDirectory = path.join(config.storageDir, 'trash', 'routes');
|
||||
|
||||
const EXPECTED_SCHEMA = Object.freeze({
|
||||
routes: {
|
||||
id: { type: 'INTEGER', pk: 1 },
|
||||
slug: { type: 'TEXT', notnull: 1 },
|
||||
name: { type: 'TEXT', notnull: 1 },
|
||||
status: { type: 'TEXT', notnull: 1 },
|
||||
gpx_path: { type: 'TEXT', notnull: 1 },
|
||||
point_count: { type: 'INTEGER', notnull: 1 },
|
||||
created_at: { type: 'TEXT', notnull: 1 },
|
||||
updated_at: { type: 'TEXT', notnull: 1 },
|
||||
deleted_at: { type: 'TEXT' },
|
||||
activated_at: { type: 'TEXT' },
|
||||
trash_path: { type: 'TEXT' }
|
||||
},
|
||||
route_points: {
|
||||
id: { type: 'INTEGER', pk: 1 },
|
||||
route_id: { type: 'INTEGER', notnull: 1 },
|
||||
sequence: { type: 'INTEGER', notnull: 1 },
|
||||
lat: { type: 'REAL', notnull: 1 },
|
||||
lon: { type: 'REAL', notnull: 1 }
|
||||
},
|
||||
pois: {
|
||||
id: { type: 'INTEGER', pk: 1 },
|
||||
route_id: { type: 'INTEGER', notnull: 1 },
|
||||
title: { type: 'TEXT', notnull: 1 },
|
||||
lat: { type: 'REAL', notnull: 1 },
|
||||
lon: { type: 'REAL', notnull: 1 },
|
||||
trigger_radius_m: { type: 'REAL', notnull: 1 },
|
||||
sequence: { type: 'INTEGER', notnull: 1 },
|
||||
audio_path: { type: 'TEXT' },
|
||||
created_at: { type: 'TEXT', notnull: 1 },
|
||||
updated_at: { type: 'TEXT', notnull: 1 }
|
||||
},
|
||||
poi_images: {
|
||||
id: { type: 'INTEGER', pk: 1 },
|
||||
poi_id: { type: 'INTEGER', notnull: 1 },
|
||||
path: { type: 'TEXT', notnull: 1 },
|
||||
caption: { type: 'TEXT', notnull: 1 },
|
||||
sequence: { type: 'INTEGER', notnull: 1 },
|
||||
created_at: { type: 'TEXT', notnull: 1 }
|
||||
}
|
||||
});
|
||||
|
||||
function assertInsideStorage(relativePath, description) {
|
||||
assert.equal(typeof relativePath, 'string', `${description}: Pfad fehlt.`);
|
||||
assert.ok(relativePath.length > 0, `${description}: Pfad ist leer.`);
|
||||
assert.equal(path.isAbsolute(relativePath), false, `${description}: Pfad muss relativ zu STORAGE_DIR sein.`);
|
||||
|
||||
const storageRoot = path.resolve(config.storageDir);
|
||||
const target = path.resolve(storageRoot, relativePath);
|
||||
assert.ok(
|
||||
target.startsWith(`${storageRoot}${path.sep}`),
|
||||
`${description}: Pfad liegt außerhalb von STORAGE_DIR: ${relativePath}`
|
||||
);
|
||||
return target;
|
||||
}
|
||||
|
||||
function assertFileExists(relativePath, description) {
|
||||
const target = assertInsideStorage(relativePath, description);
|
||||
assert.ok(fs.existsSync(target), `${description}: Datei fehlt: ${relativePath}`);
|
||||
assert.ok(fs.statSync(target).isFile(), `${description}: Erwartete Datei ist keine Datei: ${relativePath}`);
|
||||
}
|
||||
|
||||
function assertDirectoryExists(relativePath, description) {
|
||||
const target = assertInsideStorage(relativePath, description);
|
||||
assert.ok(fs.existsSync(target), `${description}: Verzeichnis fehlt: ${relativePath}`);
|
||||
assert.ok(fs.statSync(target).isDirectory(), `${description}: Erwarteter Pfad ist kein Verzeichnis: ${relativePath}`);
|
||||
}
|
||||
|
||||
function assertTableSchema(db, tableName, expectedColumns) {
|
||||
const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||
assert.ok(columns.length > 0, `Tabelle ${tableName} fehlt.`);
|
||||
|
||||
const byName = new Map(columns.map(column => [column.name, column]));
|
||||
for (const [columnName, expected] of Object.entries(expectedColumns)) {
|
||||
const actual = byName.get(columnName);
|
||||
assert.ok(actual, `Spalte ${tableName}.${columnName} fehlt.`);
|
||||
assert.equal(String(actual.type).toUpperCase(), expected.type, `Falscher Typ für ${tableName}.${columnName}.`);
|
||||
|
||||
if (Object.hasOwn(expected, 'notnull')) {
|
||||
assert.equal(actual.notnull, expected.notnull, `Falsche NOT NULL-Eigenschaft für ${tableName}.${columnName}.`);
|
||||
}
|
||||
if (Object.hasOwn(expected, 'pk')) {
|
||||
assert.equal(actual.pk, expected.pk, `Falsche PRIMARY KEY-Eigenschaft für ${tableName}.${columnName}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertStrictTables(db) {
|
||||
const tableList = db.prepare('PRAGMA table_list').all();
|
||||
const byName = new Map(tableList.map(table => [table.name, table]));
|
||||
|
||||
for (const tableName of Object.keys(EXPECTED_SCHEMA)) {
|
||||
const table = byName.get(tableName);
|
||||
assert.ok(table, `Tabelle ${tableName} fehlt in PRAGMA table_list.`);
|
||||
assert.equal(table.strict, 1, `Tabelle ${tableName} ist nicht STRICT.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertDatabaseIntegrity(db) {
|
||||
const integrity = db.prepare('PRAGMA integrity_check').all();
|
||||
assert.deepEqual(integrity, [{ integrity_check: 'ok' }], 'SQLite integrity_check ist fehlgeschlagen.');
|
||||
|
||||
const foreignKeyErrors = db.prepare('PRAGMA foreign_key_check').all();
|
||||
assert.deepEqual(foreignKeyErrors, [], 'Die Datenbank enthält verletzte Foreign-Key-Beziehungen.');
|
||||
}
|
||||
|
||||
function assertRoutePointCounts(db) {
|
||||
const rows = db.prepare(`
|
||||
SELECT r.id, r.point_count AS expected_count, COUNT(rp.id) AS actual_count
|
||||
FROM routes r
|
||||
LEFT JOIN route_points rp ON rp.route_id = r.id
|
||||
GROUP BY r.id, r.point_count
|
||||
ORDER BY r.id
|
||||
`).all();
|
||||
|
||||
for (const row of rows) {
|
||||
assert.equal(
|
||||
row.actual_count,
|
||||
row.expected_count,
|
||||
`Strecke ${row.id}: point_count=${row.expected_count}, tatsächlich vorhanden=${row.actual_count}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertActiveRoutes(db) {
|
||||
const activeRoutes = db.prepare(`
|
||||
SELECT id, gpx_path
|
||||
FROM routes
|
||||
WHERE status = 'active'
|
||||
ORDER BY id
|
||||
`).all();
|
||||
const activeIds = new Set(activeRoutes.map(route => Number(route.id)));
|
||||
|
||||
for (const route of activeRoutes) {
|
||||
const id = Number(route.id);
|
||||
const expectedDirectory = `active/routes/${id}`;
|
||||
const expectedGpxPath = `${expectedDirectory}/route.gpx`;
|
||||
|
||||
assertDirectoryExists(expectedDirectory, `Strecke ${id}`);
|
||||
assert.equal(route.gpx_path, expectedGpxPath, `Strecke ${id}: gpx_path stimmt nicht mit der Strecken-ID überein.`);
|
||||
assertFileExists(route.gpx_path, `Strecke ${id} GPX`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(activeRoutesDirectory)) return;
|
||||
|
||||
for (const entry of fs.readdirSync(activeRoutesDirectory, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
assert.match(entry.name, /^\d+$/, `Unerwartetes Streckenverzeichnis unter storage/active/routes: ${entry.name}`);
|
||||
const id = Number(entry.name);
|
||||
assert.ok(activeIds.has(id), `Aktives Streckenverzeichnis ${entry.name} hat keinen aktiven Datenbankeintrag.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertDeletedRoutes(db) {
|
||||
const deletedRoutes = db.prepare(`
|
||||
SELECT id, gpx_path, trash_path
|
||||
FROM routes
|
||||
WHERE status = 'deleted'
|
||||
ORDER BY id
|
||||
`).all();
|
||||
const trashPaths = new Set();
|
||||
|
||||
for (const route of deletedRoutes) {
|
||||
const id = Number(route.id);
|
||||
assert.equal(typeof route.trash_path, 'string', `Gelöschte Strecke ${id}: trash_path fehlt.`);
|
||||
assert.ok(route.trash_path.startsWith(`trash/routes/${id}-`), `Gelöschte Strecke ${id}: trash_path passt nicht zur ID.`);
|
||||
assertDirectoryExists(route.trash_path, `Gelöschte Strecke ${id}`);
|
||||
assert.ok(route.gpx_path.startsWith(`${route.trash_path}/`), `Gelöschte Strecke ${id}: gpx_path liegt nicht im Papierkorbpfad.`);
|
||||
assertFileExists(route.gpx_path, `Gelöschte Strecke ${id} GPX`);
|
||||
trashPaths.add(route.trash_path);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(trashRoutesDirectory)) return;
|
||||
|
||||
for (const entry of fs.readdirSync(trashRoutesDirectory, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const relativePath = `trash/routes/${entry.name}`;
|
||||
assert.ok(trashPaths.has(relativePath), `Papierkorb-Streckenverzeichnis ${entry.name} hat keinen passenden Datenbankeintrag.`);
|
||||
}
|
||||
}
|
||||
|
||||
function routeStoragePrefix(route) {
|
||||
if (route.status === 'active') return `active/routes/${route.id}/`;
|
||||
assert.equal(route.status, 'deleted', `Strecke ${route.id}: unbekannter Status ${route.status}.`);
|
||||
assert.equal(typeof route.trash_path, 'string', `Gelöschte Strecke ${route.id}: trash_path fehlt.`);
|
||||
return `${route.trash_path}/`;
|
||||
}
|
||||
|
||||
function assertMediaPaths(db) {
|
||||
const audioRows = db.prepare(`
|
||||
SELECT p.id AS poi_id, p.audio_path, r.id AS route_id, r.status, r.trash_path
|
||||
FROM pois p
|
||||
JOIN routes r ON r.id = p.route_id
|
||||
WHERE p.audio_path IS NOT NULL
|
||||
ORDER BY p.id
|
||||
`).all();
|
||||
|
||||
for (const row of audioRows) {
|
||||
const prefix = routeStoragePrefix({ id: row.route_id, status: row.status, trash_path: row.trash_path });
|
||||
assert.ok(row.audio_path.startsWith(`${prefix}audio/`), `POI ${row.poi_id}: Audio-Pfad passt nicht zu Strecke ${row.route_id}.`);
|
||||
assertFileExists(row.audio_path, `POI ${row.poi_id} Audio`);
|
||||
}
|
||||
|
||||
const imageRows = db.prepare(`
|
||||
SELECT pi.id AS image_id, pi.path, p.id AS poi_id, r.id AS route_id, r.status, r.trash_path
|
||||
FROM poi_images pi
|
||||
JOIN pois p ON p.id = pi.poi_id
|
||||
JOIN routes r ON r.id = p.route_id
|
||||
ORDER BY pi.id
|
||||
`).all();
|
||||
|
||||
for (const row of imageRows) {
|
||||
const prefix = routeStoragePrefix({ id: row.route_id, status: row.status, trash_path: row.trash_path });
|
||||
assert.ok(row.path.startsWith(`${prefix}images/`), `Bild ${row.image_id}: Pfad passt nicht zu Strecke ${row.route_id}.`);
|
||||
assertFileExists(row.path, `Bild ${row.image_id} von POI ${row.poi_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
test('Laufzeitdatenbank und gespeicherte Streckendaten sind konsistent', () => {
|
||||
if (!fs.existsSync(databasePath)) {
|
||||
console.log(`Keine Datenbank vorhanden (${databasePath}); Integritätsprüfung ist nicht erforderlich.`);
|
||||
return;
|
||||
}
|
||||
|
||||
assert.ok(fs.statSync(databasePath).isFile(), `Datenbankpfad ist keine Datei: ${databasePath}`);
|
||||
|
||||
const db = new DatabaseSync(databasePath, {
|
||||
readOnly: true,
|
||||
enableForeignKeyConstraints: true,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
try {
|
||||
assertDatabaseIntegrity(db);
|
||||
|
||||
for (const [tableName, columns] of Object.entries(EXPECTED_SCHEMA)) {
|
||||
assertTableSchema(db, tableName, columns);
|
||||
}
|
||||
assertStrictTables(db);
|
||||
assertRoutePointCounts(db);
|
||||
assertActiveRoutes(db);
|
||||
assertDeletedRoutes(db);
|
||||
assertMediaPaths(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user