overhaul gpx creation and usage
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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>'));
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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 & 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
@@ -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',
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user