overhaul gpx creation and usage
Sonarqube Scanner / Prepare environment (push) Successful in 8s
Sonarqube Scanner / Build and test (push) Failing after 22s
Sonarqube Scanner / SonarQube analysis (push) Skipped
Sonarqube Scanner / Deploy to Server (push) Successful in 6s

This commit is contained in:
Florian Zumpe
2026-10-03 12:40:26 +02:00
parent 1cb3977269
commit 171ea88d55
29 changed files with 969 additions and 344 deletions
+71 -4
View File
@@ -234,6 +234,16 @@ async function loadApp({ language = 'de-DE' } = {}) {
const savedStates = [];
const editor = { opened: 0, refreshed: 0 };
const apiCalls = [];
const frames = [];
const animationTimers = new Map();
let animationTimerId = 0;
function finishAnimations() {
while (frames.length) frames.shift()();
for (const [id, callback] of animationTimers) {
animationTimers.delete(id);
callback();
}
}
harness.node('#route-control-dropdown').properties.set('hidden', true);
harness.node('#route-search').value = '';
@@ -284,7 +294,9 @@ async function loadApp({ language = 'de-DE' } = {}) {
Vibration: { Patterns: { ACTIVE_POI: [100] }, start() {}, stop() {} }
},
alert() {},
clearTimeout,
clearTimeout(id) {
if (!animationTimers.delete(id)) clearTimeout(id);
},
history: {
state: null,
back() {},
@@ -292,7 +304,13 @@ async function loadApp({ language = 'de-DE' } = {}) {
replaceState(state) { this.state = state; }
},
scrollTo() {},
setTimeout,
requestAnimationFrame: callback => frames.push(callback),
setTimeout(callback, delay) {
if (delay !== 180) return setTimeout(callback, delay);
const id = --animationTimerId;
animationTimers.set(id, callback);
return id;
},
structuredClone,
globalThis: null
};
@@ -309,6 +327,7 @@ async function loadApp({ language = 'de-DE' } = {}) {
return {
apiCalls,
finishAnimations,
context,
editor,
harness,
@@ -329,6 +348,39 @@ test('English browser settings localize app controls and retain user-authored tr
assert.ok(harness.node('#local-route-list').appended.join('').includes('Lokaler Weg'));
});
test('route menu animates opening and closing and can reopen during closing', async () => {
const setup = await loadApp();
const { context, harness } = setup;
await context.Wegwichtel.App.initialize({ markStep() {} });
const button = new FakeNode('toggle');
const dropdown = harness.node('#route-control-dropdown');
await harness.activate('#route-control-button', button);
assert.equal(dropdown.properties.get('hidden'), false);
assert.equal(dropdown.classes.has('is-open'), false);
setup.finishAnimations();
assert.equal(dropdown.classes.has('is-open'), true);
await harness.activate('#route-control-button', button);
assert.equal(dropdown.classes.has('is-open'), false);
assert.equal(dropdown.properties.get('hidden'), false);
assert.equal(harness.node('#route-control-button').attributes.get('aria-expanded'), 'false');
await harness.activate('#route-control-button', button);
setup.finishAnimations();
assert.equal(dropdown.classes.has('is-open'), true);
assert.equal(dropdown.properties.get('hidden'), false);
await harness.activate('#route-control-button', button);
setup.finishAnimations();
assert.equal(dropdown.properties.get('hidden'), true);
await harness.activate('#route-control-button', button);
harness.fireDocumentKey('Escape');
setup.finishAnimations();
assert.equal(dropdown.properties.get('hidden'), true);
assert.equal(dropdown.classes.has('is-open'), false);
});
test('route control runtime loads server/local routes and supports switching without geolocation', async () => {
const setup = await loadApp();
const { context, harness, apiCalls, editor } = setup;
@@ -352,6 +404,7 @@ test('route control runtime loads server/local routes and supports switching wit
const newButton = new FakeNode('#route-control-new');
await harness.activate('.route-control-actions #route-control-new', newButton);
assert.equal(editor.opened, 1);
setup.finishAnimations();
assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), true);
const localButton = new FakeNode('local-select');
@@ -409,6 +462,7 @@ test('route control closes on escape and reports local storage failures', async
await harness.activate('#route-control-button', new FakeNode('toggle'));
assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), false);
harness.fireDocumentKey('Escape');
setup.finishAnimations();
assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), true);
context.Wegwichtel.LocalRoutes.listAppRoutes = () => { throw new Error('LocalStorage nicht lesbar'); };
@@ -443,7 +497,17 @@ test('tracking triggers each POI once, records media progress and pauses/resumes
await selectServer(setup);
await harness.activate('#start-route', new FakeNode('start'));
assert.equal(harness.node('#pause-route').properties.get('disabled'), false);
const bearingTargets = [];
context.Wegwichtel.Distance.bearing = (position, target) => {
bearingTargets.push(target);
return target.title ? 45 : 120;
};
calls.position({ lat: 50, lon: 10, accuracy: 5, heading: 90, speed: 1 });
assert.equal(harness.node('#route-navigation-arrow').properties.get('hidden'), false);
assert.equal(harness.node('#navigation-arrow').properties.get('hidden'), false);
assert.notEqual(harness.node('#route-navigation-arrow').styles.get('transform'), harness.node('#navigation-arrow').styles.get('transform'));
assert.ok(bearingTargets.some(target => target.title));
assert.ok(bearingTargets.some(target => !target.title));
assert.equal(harness.node('#poi-title').textValue, 'Serverweg Start');
assert.equal(calls.vibrations, 1);
assert.match(harness.node('#navigation-mode').textValue, /Serverweg Ziel/);
@@ -459,10 +523,13 @@ test('tracking triggers each POI once, records media progress and pauses/resumes
await harness.activate('#pause-route', new FakeNode('pause'));
assert.match(harness.node('#tracking-status').textValue, /Du machst eine Pause/);
assert.equal(savedStates.at(-1)[1], 'paused');
assert.equal(harness.node('#route-navigation-arrow').properties.get('hidden'), true);
assert.equal(harness.node('#navigation-arrow').properties.get('hidden'), true);
await harness.activate('#start-route', new FakeNode('resume'));
calls.position({ lat: 50.001, lon: 10.001, accuracy: 5 });
assert.equal(calls.vibrations, 2);
assert.equal(harness.node('#navigation-distance').textValue, '0 m');
assert.equal(harness.node('#navigation-distance').textValue, '–');
assert.equal(harness.node('#route-navigation-mode').textValue, 'Alle Stationen und das Ziel erreicht');
await harness.activate('#stop-route', new FakeNode('stop'));
assert.equal(savedStates.at(-1)[1], 'idle');
assert.equal(harness.node('#navigation-footer').properties.get('hidden'), true);
@@ -515,7 +582,7 @@ test('tracking handles an empty route and orientation permission resolving after
assert.match(harness.node('#poi-list').appended.join(''), /keine Stationen/);
await harness.activate('#start-route', new FakeNode('start'));
calls.position({ lat: 50, lon: 10, accuracy: 10 });
assert.equal(harness.node('#navigation-mode').textValue, 'Der Weg dieser Strecke fehlt.');
assert.equal(harness.node('#route-navigation-mode').textValue, 'Der Weg dieser Strecke fehlt.');
await harness.activate('#pause-route', new FakeNode('pause'));
const stopped = calls.stopped;
grant(true);
+7 -7
View File
@@ -32,10 +32,10 @@ test('editor markup and source describe local draft creation instead of immediat
assert.match(html, /id="editor-poi-images-add"[^>]+type="button"/);
assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"[^>]+hidden/);
assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple[^>]+hidden/);
assert.match(html, /id="editor-poi-lat"[^>]+type="hidden"/);
assert.match(html, /id="editor-poi-lon"[^>]+type="hidden"/);
assert.match(html, /id="editor-poi-lat"[^>]+type="number"/);
assert.match(html, /id="editor-poi-lon"[^>]+type="number"/);
assert.match(html, /id="editor-poi-radius"[^>]+type="hidden"/);
assert.doesNotMatch(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/);
assert.match(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/);
assert.match(html, /id="editor-poi-media-preview"/);
assert.match(html, /id="editor-poi-delete"[^>]+editor-danger-button/);
assert.match(html, />Stationen<\/h3>/);
@@ -327,11 +327,11 @@ function buildEditorContext() {
const now = '2026-09-19T14:00:00.000Z';
const pois = input.pois.map((poi, sequence) => ({ ...poi, sequence }));
const gpxPoints = pois
.map(poi => `<trkpt lat="${poi.lat}" lon="${poi.lon}"><name>${poi.title || ''}</name><extensions><wegwichtel:poiId>${poi.id}</wegwichtel:poiId></extensions></trkpt>`)
.map(poi => `<wpt lat="${poi.lat}" lon="${poi.lon}"><name>${poi.title || ''}</name><extensions><wegwichtel:poiId>${poi.id}</wegwichtel:poiId></extensions></wpt>`)
.join('');
const saved = {
id,
gpx: `<gpx><trk><trkseg>${gpxPoints}</trkseg></trk></gpx>`,
gpx: `<gpx>${gpxPoints}</gpx>`,
route: { ...input.route, id, updatedAt: now },
pois
};
@@ -426,7 +426,7 @@ test('public editor workflow saves new route and GPX Station locally without POS
assert.equal(local.pois[0].images.length, 1);
assert.equal(local.pois[0].images[0].storage, 'indexeddb');
assert.ok(local.pois[0].images[0].mediaKey);
assert.match(local.gpx, /<trkpt lat="50\.123456789" lon="10\.987654321">/);
assert.match(local.gpx, /<wpt lat="50\.123456789" lon="10\.987654321">/);
const pointListBeforeUpload = elements.get('editor-poi-list');
const pointItemBeforeUpload = pointListBeforeUpload.children.find(child => child.dataset.poiId);
@@ -445,7 +445,7 @@ test('public editor workflow saves new route and GPX Station locally without POS
const gpxUpload = routeUpload.body.entries.find(([name]) => name === 'gpx');
assert.ok(gpxUpload);
assert.equal(gpxUpload[2], 'Neue-lokale-Strecke.gpx');
assert.match(await gpxUpload[1].text(), /<trkpt lat=\"50\.123456789\" lon=\"10\.987654321\">/);
assert.match(await gpxUpload[1].text(), /<wpt lat=\"50\.123456789\" lon=\"10\.987654321\">/);
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois'));
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/audio'));
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/pictures'));
+24
View File
@@ -7,3 +7,27 @@ test('parseGpx liest Beispielstrecke', () => {
const parsed = parseGpx(fs.readFileSync(new URL('../examples/sample-route.gpx', import.meta.url), 'utf8'));
assert.equal(parsed.name, 'Beispielweg'); assert.equal(parsed.points.length, 3); assert.ok(parsed.metrics.distanceM > 0);
});
test('normalization separates waypoints and collapses stationary GPS drift', () => {
const xml = '<gpx><metadata><name>Pause</name></metadata><wpt lat="51" lon="11"><name>Station</name></wpt><trk><trkseg>'
+ '<trkpt lat="50" lon="10"><time>2026-10-03T10:00:00Z</time></trkpt>'
+ '<trkpt lat="50.00001" lon="10.00001"/><trkpt lat="50.00002" lon="10.00002"/>'
+ '<trkpt lat="50.001" lon="10.001"/></trkseg></trk></gpx>';
const result = parseGpx(xml);
assert.equal(result.points.length, 2);
assert.equal(result.points[0].recordedAt, '2026-10-03T10:00:00Z');
assert.match(result.normalizedXml, /<wpt /);
assert.match(result.normalizedXml, /<rtept /);
assert.doesNotMatch(result.normalizedXml, /<trkpt /);
assert.deepEqual(parseGpx(result.normalizedXml).points, result.points);
});
test('waypoint-only routes and missing-position recordings have no invented route coordinates', () => {
const waypoint = parseGpx('<gpx><wpt lat="50" lon="10"/></gpx>');
assert.equal(waypoint.points.length, 0); assert.equal(waypoint.metrics.distanceM, 0);
const empty = parseGpx('<gpx xmlns:wegwichtel="urn:wegwichtel:poi"><extensions><wegwichtel:gpsGap time="2026-10-03T10:00:00Z"/></extensions></gpx>');
assert.equal(empty.points.length, 0); assert.equal(empty.metrics.startLat, null);
assert.match(empty.normalizedXml, /gpsGap/);
assert.throws(() => parseGpx('<gpx><trk>'));
assert.throws(() => parseGpx('<gpx><trk><trkseg><trkpt lat="100" lon="10"/></trkseg></trk></gpx>'));
});
+20
View File
@@ -8,6 +8,26 @@ import { localizeHtml } from '../scripts/localize-html.js';
const root = path.resolve(import.meta.dirname, '..');
test('German legal pages and contacts remain static when the app uses English', async () => {
const context = { navigator: { language: 'en-GB' } };
const i18n = await loadI18n(context);
const html = await fs.readFile(path.join(root, 'public/index.html'), 'utf8');
const englishHtml = localizeHtml(html, key => i18n.t(key));
for (const name of ['privacy', 'imprint']) {
const pattern = new RegExp(`<article[^>]+data-info-page="${name}"[^>]*>[\\s\\S]*?</article>`);
const page = pattern.exec(html)?.[0];
assert.ok(page, name);
assert.match(page, /lang="de"/);
assert.doesNotMatch(page, /data-i18n/);
assert.equal(pattern.exec(englishHtml)?.[0], page);
}
for (const catalog of context.Wegwichtel.TextCatalogs.values()) {
for (const key of ['privacy', 'imprint', 'contacts']) {
assert.equal(Object.hasOwn(catalog, key), false, key);
}
}
});
test('checked-in HTML has synchronized German defaults and accessible headings, labels and links before scripts load', async () => {
const i18n = await loadI18n({});
const html = await fs.readFile(path.join(root, 'public/index.html'), 'utf8');
+12
View File
@@ -42,6 +42,9 @@ test('information button opens scrollable overlay pages for privacy, imprint and
assert.match(html, /data-info-target="privacy"/);
assert.match(html, /data-info-target="imprint"/);
assert.match(html, /data-info-target="technology"/);
assert.match(html, /<h3>Technisch verantwortlich<\/h3>\s*<p>Florian Zumpe<br>/);
assert.match(html, /\[Anschrift von Florian Zumpe ergänzen\]/);
assert.match(html, /\[E-Mail-Adresse von Florian Zumpe ergänzen\]/);
const storage = await read('public/js/privacy-storage.js');
assert.match(storage, /wegwichtel\.privacy\.v1/);
assert.match(storage, /wegwichtel\.route-progress\.v1/);
@@ -193,6 +196,9 @@ test('legal overlay handlers synchronize switches without advancing the start sc
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath });
elements.get('info-dialog-title').dataset.i18n = 'info.title';
context.Wegwichtel.I18n.setLanguage('en');
elements.get('info-button').dispatchEvent(pointerEvent());
assert.equal(elements.get('info-dropdown').hidden, false);
assert.equal(elements.get('info-button').attributes.get('aria-expanded'), 'true');
@@ -221,6 +227,12 @@ test('legal overlay handlers synchronize switches without advancing the start sc
assert.equal(elements.get('info-back').hidden, true);
assert.equal(privacyPage.hidden, false);
assert.equal(elements.get('info-dialog-title').textContent, 'Datenschutzerklärung');
assert.equal(elements.get('info-dialog-title').attributes.get('lang'), 'de');
assert.equal(Object.hasOwn(elements.get('info-dialog-title').dataset, 'i18n'), false);
targetButtons[1].dispatchEvent(pointerEvent());
assert.equal(elements.get('info-dialog-title').textContent, 'Impressum');
assert.equal(elements.get('info-dialog-title').attributes.get('lang'), 'de');
progressToggles[0].checked = true;
progressToggles[0].dispatchEvent(new Event('change'));
+34 -13
View File
@@ -361,6 +361,27 @@ const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
</trkseg></trk>
</gpx>`;
test('missing GPS samples and stations survive storage and later manual coordinates', async () => {
const { api } = await loadLocalRoutes();
let gpx = api.appendTrackPoint(api.emptyGpx('Ohne Empfang'), { timestamp: 1000 }, 'Ohne Empfang');
gpx = api.appendTrackPoint(gpx, { lat: 50, lon: 10, timestamp: 2000 }, 'Ohne Empfang');
const saved = api.save({ id: 'missing-gps', gpx, route: { name: 'Ohne Empfang' }, pois: [
{ id: 42, title: 'Drinnen', lat: null, lon: null, sequence: 0 }
] });
const positions = api.recordedPositions(saved.gpx);
assert.equal(positions.length, 2);
assert.equal(positions[0].lat, null);
assert.equal(api.parseWaypoints(saved.gpx)[0].lat, null);
assert.match(api.gpxForUpload(saved), /gpsGap/);
positions[0].lat = 49.999;
positions[0].lon = 9.999;
const edited = api.replaceRecordedPositions(saved.gpx, positions, 'Ohne Empfang');
assert.doesNotMatch(edited, /gpsGap/);
assert.equal(api.recordedPositions(edited)[0].lat, 49.999);
assert.equal(api.parseWaypoints(edited)[0].title, 'Drinnen');
assert.equal(api.parseWaypoints(edited)[0].lat, null);
});
test('route id generation uses randomUUID when available', async () => {
const { api } = await loadLocalRoutes();
@@ -448,10 +469,10 @@ test('local route package stores GPX and JSON separately and reconstructs an app
const gpx = localStorage.getItem(`${prefix}.${encodedId}.gpx`);
const metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`));
assert.match(gpx, /<trkpt lat="50\.1" lon="10\.1">/);
assert.match(gpx, /<wpt lat="50\.1" lon="10\.1">/);
assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/);
assert.doesNotMatch(gpx, /<wpt/);
assert.equal((gpx.match(/<trkpt /g) || []).length, 2);
assert.doesNotMatch(gpx, /<trkpt/);
assert.equal((gpx.match(/<wpt /g) || []).length, 2);
assert.equal(metadata.route.name, 'Neue Strecke');
assert.equal(metadata.route.serverRouteId, 42);
assert.equal(metadata.route.uploadedAt, '2026-09-27T18:00:00.000Z');
@@ -465,8 +486,8 @@ test('local route package stores GPX and JSON separately and reconstructs an app
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(appRoute.points.length, 0);
assert.equal(appRoute.distanceM, 0);
assert.equal(Math.round(appRoute.elevationGainM), 0);
const listed = api.listAppRoutes();
assert.equal(listed.length, 1);
@@ -485,15 +506,15 @@ test('GPX waypoint import reads stable POI ids and validates coordinates', async
assert.match(api.emptyGpx('A & B'), /<name>A &amp; B<\/name>/);
assert.throws(() => api.parseWaypoints('<not-gpx></not-gpx>'), /keinen lesbaren Weg/);
assert.throws(() => api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /Ort von Ohne Position fehlt/);
assert.match(api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /pendingWaypoint/);
const waypointOnly = api.syncGpx(
`<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`,
{ name: 'Nur Station' },
[{ id: 2, title: 'Einzelner Station', lat: 50.2, lon: 10.2, sequence: 0 }]
);
assert.match(waypointOnly, /<trkpt lat="50.2" lon="10.2">/);
assert.doesNotMatch(waypointOnly, /<wpt\b/);
assert.match(waypointOnly, /<wpt lat="50.2" lon="10.2">/);
assert.doesNotMatch(waypointOnly, /<trkpt\b/);
});
test('local points are stored directly as managed GPX Stationen and uploaded unchanged', async () => {
@@ -508,11 +529,11 @@ test('local points are stored directly as managed GPX Stationen and uploaded unc
]
});
assert.match(saved.gpx, /<trkpt lat="50\.123456789" lon="10\.987654321">/);
assert.match(saved.gpx, /<trkpt lat="50\.223456789" lon="11\.087654321">/);
assert.match(saved.gpx, /<wpt lat="50\.123456789" lon="10\.987654321">/);
assert.match(saved.gpx, /<wpt lat="50\.223456789" lon="11\.087654321">/);
assert.match(saved.gpx, /<wegwichtel:poiId>1<\/wegwichtel:poiId>/);
assert.equal((saved.gpx.match(/<trkpt /g) || []).length, 2);
assert.doesNotMatch(saved.gpx, /<wpt\b/);
assert.equal((saved.gpx.match(/<wpt /g) || []).length, 2);
assert.doesNotMatch(saved.gpx, /<trkpt\b/);
assert.equal(api.gpxForUpload(saved), saved.gpx, 'upload must use the already stored track without conversion');
});
@@ -525,7 +546,7 @@ test('sync keeps imported foreign Stationen and upload rejects a route without S
assert.match(synced, /<ele>100<\/ele>/);
assert.match(synced, /<ele>112<\/ele>/);
assert.match(synced, /<wegwichtel:poiId>200<\/wegwichtel:poiId>/);
assert.equal((synced.match(/<trkpt /g) || []).length, 3);
assert.equal((synced.match(/<trkpt /g) || []).length, 2);
assert.equal(api.gpxForUpload({ gpx: sampleGpx, route: { name: 'Import' }, pois: [] }), sampleGpx);
assert.throws(() => api.gpxForUpload({
gpx: api.emptyGpx('Leer'),
+22 -1
View File
@@ -1,3 +1,4 @@
import { parseGpx } from '../src/services/gpx.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
@@ -209,7 +210,27 @@ assert result['ok'] is True
assert.equal(route.gpxUrl, `/api/routes/${route.id}/gpx`);
const { response: gpxResponse, body: downloadedGpx } = await requestBuffer(`${baseUrl}${route.gpxUrl}`);
assert.match(gpxResponse.headers.get('content-type') || '', /application\/gpx\+xml/);
assert.deepEqual(downloadedGpx, gpx);
assert.match(downloadedGpx.toString(), /<rtept /);
assert.doesNotMatch(downloadedGpx.toString(), /<trkpt /);
assert.deepEqual(parseGpx(downloadedGpx.toString()).points, parseGpx(gpx.toString()).points);
const { body: pendingPoi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Ohne Empfang', lat: null, lon: null })
}, 201);
assert.equal(pendingPoi.lat, null);
assert.equal(pendingPoi.lon, null);
const { body: pendingGpx } = await requestBuffer(`${baseUrl}${route.gpxUrl}`);
assert.match(pendingGpx.toString(), /wegwichtel:pendingWaypoint/);
const { body: locatedPoi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${pendingPoi.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat: 50, lon: 10 })
});
assert.equal(locatedPoi.lat, 50);
const { body: locatedGpx } = await requestBuffer(`${baseUrl}${route.gpxUrl}`);
assert.doesNotMatch(locatedGpx.toString(), /wegwichtel:pendingWaypoint/);
assert.match(locatedGpx.toString(), /<wpt lat="50" lon="10">/);
await requestJson(`${baseUrl}/api/routes/${route.id}/pois/${pendingPoi.id}`, { method: 'DELETE' });
const { body: poi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, {
method: 'POST',
+7 -3
View File
@@ -57,13 +57,17 @@ test('application actions use pointer events instead of click handlers', async (
assert.doesNotMatch(scripts, /(?:\.on|addEventListener)\(\s*['"]click['"]/);
});
test('mobile navigation footer contains a rotating SVG arrow and exact metre output', async () => {
test('mobile navigation shows a path arrow without distance and a station arrow with metres', async () => {
const html = await read('public/index.html');
const app = await read('public/js/app.js');
const css = await read('public/css/app.css');
assert.match(html, /<footer id="navigation-footer"/);
assert.match(html, /<svg id="navigation-arrow"/);
assert.match(html, /<svg id="route-navigation-arrow"/);
const pathCard = html.slice(html.indexOf('<svg id="route-navigation-arrow"'), html.indexOf('id="station-navigation"'));
assert.match(pathCard, /route-navigation-mode/);
assert.doesNotMatch(pathCard, /navigation-distance/);
assert.match(app, /Distance\.bearing/);
assert.match(app, /Math\.round\((?:metric\.distance|progress\.distance|distance)\)/);
assert.match(app, /`\$\{distance\} m`/);
@@ -144,7 +148,7 @@ test('the selected or triggered Station is highlighted semantically', async () =
assert.match(css, /aktuelle Station/);
});
test('Stationen have live direction and metre indicators and take navigation priority', async () => {
test('stations retain live direction and metre indicators beside path navigation', async () => {
const app = await read('public/js/app.js');
const css = await read('public/css/app.css');
const html = await read('public/index.html');
@@ -158,7 +162,7 @@ test('Stationen have live direction and metre indicators and take navigation pri
assert.match(app, /find\('svg'\)\.css\('transform'/);
assert.match(css, /\.poi-proximity[\s\S]*grid-column:\s*2/);
assert.match(css, /\.poi-proximity svg[\s\S]*transition:\s*transform/);
assert.match(html, /Richtung zum nächsten Station/);
assert.match(html, /Richtung zur nächsten Station/);
});
+105
View File
@@ -0,0 +1,105 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import vm from 'node:vm';
import { loadI18n } from '../test-support/i18n.js';
const sourcePath = new URL('../public/js/route-recorder.js', import.meta.url);
const tick = () => new Promise(resolve => setImmediate(resolve));
async function setup() {
class Element extends EventTarget {
constructor() { super(); this.children = []; this.value = ''; this.attributes = {}; }
setAttribute(key, value) { this.attributes[key] = value; }
appendChild(child) { this.children.push(child); }
replaceChildren() { this.children = []; }
}
const ids = ['route-recording-control', 'route-recording-status', 'route-recording-pause', 'route-recording-stop', 'editor-recorded-positions', 'editor-add-position'];
const nodes = new Map(ids.map(id => [id, new Element()]));
const document = new EventTarget();
document.readyState = 'complete';
document.getElementById = id => nodes.get(id);
document.createElement = () => new Element();
const intervals = new Map();
const packages = new Map([['draft', { id: 'draft', route: { name: 'Entwurf' }, gpx: '[]', pois: [] }]]);
const context = { document, Date, console,
setInterval(callback, delay) { assert.equal(delay, 30000); intervals.set(1, callback); return 1; },
clearInterval(id) { intervals.delete(id); },
Wegwichtel: { Geo: { current: async () => ({ lat: 50, lon: 10, timestamp: Date.now() }) },
LocalRoutes: {
get: id => packages.get(id),
save: value => packages.set(value.id, value),
appendTrackPoint: (gpx, position) => JSON.stringify([...JSON.parse(gpx), position]),
recordedPositions: JSON.parse,
replaceRecordedPositions: (_gpx, positions) => JSON.stringify(positions)
}
}
};
context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(await fs.readFile(sourcePath, 'utf8'), context, { filename: sourcePath.pathname });
return { context, nodes, intervals, packages, recorder: context.Wegwichtel.RouteRecorder };
}
function activate(node, type = 'pointerup', key) {
const event = new Event(type, { cancelable: true });
if (key) Object.defineProperty(event, 'key', { value: key });
node.dispatchEvent(event);
}
test('recording persists every 30 seconds independently of the editor, pauses and finishes', async () => {
const { recorder, packages, intervals, nodes } = await setup();
recorder.start('draft'); await tick();
assert.equal(recorder.status, 'recording');
assert.equal(JSON.parse(packages.get('draft').gpx).length, 1);
await intervals.get(1)();
assert.equal(JSON.parse(packages.get('draft').gpx).length, 2);
activate(nodes.get('route-recording-pause'));
assert.equal(recorder.status, 'paused'); assert.equal(intervals.size, 0);
activate(nodes.get('route-recording-pause'), 'keydown', 'Enter'); await tick();
assert.equal(recorder.status, 'recording');
activate(nodes.get('route-recording-stop'));
assert.equal(recorder.status, 'idle'); assert.equal(intervals.size, 0);
assert.equal(nodes.get('route-recording-control').hidden, true);
});
test('GPS failures remain as empty samples and late results after stopping are discarded', async () => {
const { context, recorder, packages, intervals } = await setup();
context.Wegwichtel.Geo.current = async () => { throw new Error('GPS fehlt'); };
recorder.start('draft'); await tick();
const samples = JSON.parse(packages.get('draft').gpx);
assert.equal(samples.length, 1); assert.equal(samples[0].lat, undefined);
let finish;
context.Wegwichtel.Geo.current = () => new Promise(resolve => { finish = resolve; });
const pending = intervals.get(1)();
recorder.stop(); finish({ lat: 51, lon: 11 }); await pending;
assert.equal(JSON.parse(packages.get('draft').gpx).length, 1);
});
test('storage errors pause recording and deleted drafts cannot be recreated by the recorder', async () => {
const { context, recorder, packages, nodes } = await setup();
context.Wegwichtel.LocalRoutes.save = () => { throw new Error('voll'); };
recorder.start('draft'); await tick();
assert.equal(recorder.status, 'paused');
assert.match(nodes.get('route-recording-status').textContent, /Schaffe Platz/);
packages.delete('draft');
context.document.dispatchEvent(new Event('wegwichtel:local-routes-changed'));
assert.equal(recorder.status, 'idle');
recorder.resume(); assert.equal(recorder.status, 'idle');
});
test('missing recorded locations can be filled manually while newer samples remain intact', async () => {
const { recorder, nodes } = await setup();
const initial = JSON.stringify([{ lat: null, lon: null, timestamp: '2026-10-03T10:00:00Z' }]);
recorder.renderPoints(initial);
const row = nodes.get('editor-recorded-positions').children[0];
row.children[2].value = '50'; row.children[4].value = '10';
const latest = JSON.stringify([...JSON.parse(initial), { lat: 51, lon: 11 }]);
const result = JSON.parse(recorder.editGpx(latest, 'Entwurf'));
assert.equal(result[0].lat, 50); assert.equal(result[1].lat, 51);
row.children[4].value = '';
assert.throws(() => recorder.editGpx(latest, 'Entwurf'));
recorder.renderPoints('[]');
activate(nodes.get('editor-add-position'));
assert.equal(JSON.parse(recorder.editGpx('[]', 'Entwurf')).length, 1);
});