Multiple improvements
Sonarqube Scanner / Prepare environment (push) Successful in 10s
Sonarqube Scanner / Build and test (push) Successful in 28s
Sonarqube Scanner / SonarQube analysis (push) Successful in 1m8s
Sonarqube Scanner / Deploy to Server (push) Successful in 8s

This commit is contained in:
2026-10-05 01:18:14 +02:00
parent 2b07e9bc47
commit 49a9de0355
27 changed files with 797 additions and 80 deletions
+42
View File
@@ -277,6 +277,8 @@ async function loadApp({ language = 'de-DE' } = {}) {
stop() {}
},
LocalRoutes: {
async getAppPoi(_id, poiId) { return structuredClone(localRoute?.pois.find(poi => Number(poi.id) === Number(poiId)) || null); },
releasePoiMedia() {},
getAppRoute(id) {
if (String(id) !== 'local-1' || !localRoute) throw new Error('lokal nicht gefunden');
return structuredClone(localRoute);
@@ -640,3 +642,43 @@ test('invalid API responses reject initialization and route selection errors res
assert.deepEqual(errors, ['Strecke entfernt']);
assert.equal(button.properties.get('disabled'), false);
});
test('local station playback loads only the selected station and ignores media arriving after leaving', async () => {
const setup = await loadApp();
const { context, harness } = setup;
const ns = context.Wegwichtel;
await ns.App.initialize({ markStep() {} });
const button = new FakeNode('local-select');
button.dataValues.set('route-source', 'local');
button.dataValues.set('route-id', 'local-1');
const requests = [];
const released = [];
ns.LocalRoutes.getAppPoi = (routeId, poiId) => new Promise(resolve => requests.push({ routeId, poiId, resolve }));
ns.LocalRoutes.releasePoiMedia = poi => released.push(poi);
const calls = observeSensors(setup);
await harness.activate('[data-route-select]', button);
assert.equal(requests.length, 0);
const poiButton = new FakeNode('station');
poiButton.dataValues.set('poi-id', 10);
// Use the first actual local station ID rather than assuming server IDs.
const local = ns.LocalRoutes.getAppRoute('local-1');
poiButton.dataValues.set('poi-id', local.pois[0].id);
await harness.activate('[data-poi-select]', poiButton);
assert.equal(requests.length, 1);
assert.equal(requests[0].routeId, 'local-1');
assert.equal(requests[0].poiId, local.pois[0].id);
const firstLoaded = { images: [{ url: 'blob:shown' }], audioUrl: 'blob:shown-audio' };
requests[0].resolve(firstLoaded);
await new Promise(resolve => setImmediate(resolve));
assert.equal(calls.audio.at(-1), 'blob:shown-audio');
assert.equal(calls.slides.at(-1)[0].url, 'blob:shown');
await harness.activate('[data-poi-select]', poiButton);
assert.equal(released[0], firstLoaded);
await harness.activate('#stop-route', new FakeNode('stop'));
const loaded = { images: [{ url: 'blob:late' }], audioUrl: 'blob:late-audio' };
requests[1].resolve(loaded);
await new Promise(resolve => setImmediate(resolve));
assert.equal(released[1], loaded);
assert.equal(calls.audio.includes('blob:late-audio'), false);
});
+6
View File
@@ -149,7 +149,13 @@ test('route, POI, media and storage services cover successful and rejected lifec
const indoorStation = routes.createPoi(db, indoor.id, { title: 'Ohne GPS', lat: null, lon: null });
assert.equal(routes.getRoute(db, indoor.id).pois[0].lat, null);
routes.updatePoi(db, indoor.id, indoorStation.id, { lat: 50, lon: 10 });
assert.equal(routes.getRoute(db, indoor.id).pointCount, 1);
assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, 50);
routes.updatePoi(db, indoor.id, indoorStation.id, { lat: 51, lon: 11 });
const movedStationRoute = routes.getRoute(db, indoor.id);
assert.equal(movedStationRoute.pointCount, 1);
assert.equal(movedStationRoute.points[0].lat, 51);
assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, 51);
routes.deletePoi(db, indoor.id, indoorStation.id);
assert.equal(routes.getRoute(db, indoor.id).pointCount, 0);
assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, null);
+99
View File
@@ -302,6 +302,13 @@ function buildEditorContext() {
type: file.type || 'application/octet-stream'
}),
get: id => packages.get(String(id)) || null,
getPoiWithMedia: async (id, poiId) => {
const value = packages.get(String(id))?.pois.find(poi => String(poi.id) === String(poiId));
if (!value) return null;
return { ...value, audio: value.audio ? { ...value.audio, url: 'blob:test-audio' } : null,
images: (value.images || []).map(image => ({ ...image, url: 'blob:test-image' })) };
},
releasePoiMedia() {},
getWithMedia: async id => {
const value = packages.get(String(id));
if (!value) return null;
@@ -659,6 +666,7 @@ test('editing an activated server route uses PUT and never writes activation sta
await context.Wegwichtel.EditorUi.open();
const routeList = elements.get('editor-route-list');
await tick();
const serverItem = routeList.children.find(child => child.dataset.routeSource === 'server');
const serverActions = serverItem.children[1];
assert.equal(serverActions.children.length, 1);
@@ -737,3 +745,94 @@ test('local media picker keeps File System Access handles for later upload', asy
assert.equal(imageStore.handle, handle);
assert.equal(imageStore.file, imageFile);
});
test('local drafts can be opened and saved while the server request is still pending', async () => {
const { context, elements, packages } = buildEditorContext();
context.fetch = () => new Promise(() => {});
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context);
await context.Wegwichtel.EditorUi.open();
const list = elements.get('editor-route-list');
const item = list.children.find(child => child.dataset.routeId === 'local-existing');
assert.ok(item);
list.dispatchEvent(pointerEvent(item.children[1].children[0]));
await tick();
assert.equal(elements.get('editor-route-id').value, 'local-existing');
elements.get('editor-route-name').value = 'Offline geändert';
elements.get('editor-route-form').dispatchEvent(submitEvent());
await tick(); await tick();
assert.equal(packages.get('local-existing').route.name, 'Offline geändert');
assert.equal(elements.get('editor-status').textContent, 'Gespeichert.');
});
test('a failed public route request preserves local drafts and late replies use the latest local data', async () => {
const { context, elements, packages } = buildEditorContext();
const requests = [];
context.fetch = () => new Promise((resolve, reject) => requests.push({ resolve, reject }));
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context);
await context.Wegwichtel.EditorUi.open();
requests[0].reject(new Error('offline')); await tick();
assert.match(elements.get('editor-status').textContent, /nicht erreichbar/);
assert.ok(elements.get('editor-route-list').children.some(child => child.dataset.routeId === 'local-existing'));
await context.Wegwichtel.EditorUi.refresh();
packages.delete('local-existing');
requests[1].resolve(jsonResponse({ routes: [{ id: 7, name: 'Veröffentlicht' }] }));
await tick(); await tick();
assert.equal(elements.get('editor-route-list').children.some(child => child.dataset.routeId === 'local-existing'), false);
assert.ok(elements.get('editor-route-list').children.some(child => child.dataset.routeSource === 'server'));
});
test('opening and saving a draft does not load files; editing a station loads only that station', async () => {
const { context, elements, packages } = buildEditorContext();
const local = packages.get('local-existing');
local.pois = [0, 1].map(id => ({ id, title: `Station ${id}`, sequence: id, images: [{ id: `image-${id}`, mediaKey: `key-${id}` }] }));
const loads = [];
context.Wegwichtel.LocalRoutes.getWithMedia = () => { throw new Error('Full route hydration must not run'); };
const original = context.Wegwichtel.LocalRoutes.getPoiWithMedia;
context.Wegwichtel.LocalRoutes.getPoiWithMedia = (...args) => { loads.push(args); return original(...args); };
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context);
await context.Wegwichtel.EditorUi.open();
const list = elements.get('editor-route-list');
const item = list.children.find(child => child.dataset.routeId === 'local-existing');
list.dispatchEvent(pointerEvent(item.children[1].children[0])); await tick();
assert.equal(loads.length, 0);
elements.get('editor-route-name').value = 'Nur Name geändert';
elements.get('editor-route-form').dispatchEvent(submitEvent()); await tick(); await tick();
assert.equal(loads.length, 0);
const poiList = elements.get('editor-poi-list');
const poiItem = poiList.children.find(child => child.dataset.poiId === '1');
poiList.dispatchEvent(pointerEvent(poiItem.children[1].children[2])); await tick();
assert.equal(loads.length, 1);
assert.equal(loads[0][1], 1);
assert.equal(elements.get('editor-poi-media-preview').children.length, 1);
elements.get('editor-poi-title').value = 'Titel geändert';
elements.get('editor-poi-form').dispatchEvent(submitEvent()); await tick(); await tick();
assert.equal(loads.length, 1);
assert.equal(packages.get('local-existing').pois[1].title, 'Titel geändert');
});
test('late station previews are released after the editor closes without replacing unsaved input', async () => {
const { context, elements, packages } = buildEditorContext();
packages.get('local-existing').pois = [{ id: 0, title: 'Station', images: [] }];
let finish;
const released = [];
context.Wegwichtel.LocalRoutes.getPoiWithMedia = () => new Promise(resolve => { finish = resolve; });
context.Wegwichtel.LocalRoutes.releasePoiMedia = poi => { if(poi) released.push(poi); };
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context);
await context.Wegwichtel.EditorUi.open();
const list = elements.get('editor-route-list');
const item = list.children.find(child => child.dataset.routeId === 'local-existing');
list.dispatchEvent(pointerEvent(item.children[1].children[0])); await tick();
const poiList = elements.get('editor-poi-list');
poiList.dispatchEvent(pointerEvent(poiList.children[0].children[1].children[2])); await tick();
elements.get('editor-poi-title').value = 'Noch nicht gespeichert';
context.Wegwichtel.EditorUi.close();
finish({ id: 0, title: 'Alter Titel', images: [{ url: 'blob:late' }] }); await tick();
assert.equal(released.length, 1);
assert.equal(elements.get('editor-poi-title').value, 'Noch nicht gespeichert');
});
+71 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseGpx } from '../src/services/gpx.js';
import { parseGpx, gpxWithWaypoints } from '../src/services/gpx.js';
import { SAMPLE_GPX } from '../test-support/gpx-fixtures.js';
test('parseGpx reads synthetic track data without repository example files', () => {
@@ -11,3 +11,73 @@ test('parseGpx reads synthetic track data without repository example files', ()
assert.equal(parsed.points[0].lat, 52.5200);
assert.equal(parsed.points[2].lon, 13.4090);
});
const point = (tag, lat, lon, content = '') => `<${tag} lat="${lat}" lon="${lon}">${content}</${tag}>`;
const gpx = (waypoints, path = '', extra = '') => `<gpx version="1.1" creator="Test" xmlns:wegwichtel="urn:wegwichtel:poi">${waypoints}${path}${extra}</gpx>`;
const track = points => `<trk><trkseg>${points}</trkseg></trk>`;
const locations = points => points.map(({ lat, lon }) => [lat, lon]);
test('waypoints alone become route points in station order and contribute to distance and bounds', () => {
const parsed = parseGpx(gpx(point('wpt', 50, 10) + point('wpt', 50.01, 10.02)));
assert.deepEqual(locations(parsed.points), [[50, 10], [50.01, 10.02]]);
assert.equal(parsed.metrics.pointCount, 2);
assert.ok(parsed.metrics.distanceM > 0);
assert.equal(parsed.metrics.maxLon, 10.02);
assert.match(parsed.normalizedXml, /<wpt\b/);
assert.match(parsed.normalizedXml, /<rtept\b/);
assert.deepEqual(locations(parseGpx(parsed.normalizedXml).points), locations(parsed.points));
});
test('track points and stations both shape the path without dropping nearby station anchors', () => {
const wp = point('wpt', 50, 10.00001) + point('wpt', 50.005, 10.015);
const recorded = point('trkpt', 50, 10) + point('trkpt', 50, 10.00002)
+ point('trkpt', 50, 10.01) + point('trkpt', 50, 10.02);
const parsed = parseGpx(gpx(wp, track(recorded)));
assert.deepEqual(locations(parsed.points), [[50, 10], [50, 10.00001], [50, 10.01], [50.005, 10.015], [50, 10.02]]);
assert.equal(parsed.metrics.maxLat, 50.005);
assert.equal(parsed.points.length, 5);
assert.deepEqual(parsed.points.map(p => p.sequence), [0, 1, 2, 3, 4]);
assert.deepEqual(locations(parseGpx(parsed.normalizedXml).points), locations(parsed.points));
});
test('duplicate station locations do not multiply route points and station order is retained on loops', () => {
const stations = point('wpt', 50, 10.02) + point('wpt', 50, 10.02) + point('wpt', 50, 10.01);
const recorded = [10, 10.01, 10.02, 10.01, 10].map(lon => point('trkpt', 50, lon)).join('');
const parsed = parseGpx(gpx(stations, track(recorded)));
assert.deepEqual(locations(parsed.points), [[50, 10], [50, 10.01], [50, 10.02], [50, 10.01], [50, 10]]);
assert.deepEqual(locations(parseGpx(parsed.normalizedXml).points), locations(parsed.points));
});
test('existing routes and replacement points incorporate stations, including stations before and after the path', () => {
const stations = [9.99, 10.005, 10.02].map(lon => point('wpt', 50, lon)).join('');
const route = `<rte>${point('rtept', 50, 10)}${point('rtept', 50, 10.01)}</rte>`;
const xml = gpx(stations, route);
const parsed = parseGpx(xml);
assert.deepEqual(locations(parsed.points), [[50, 9.99], [50, 10], [50, 10.005], [50, 10.01], [50, 10.02]]);
assert.deepEqual(locations(parseGpx(xml, [{ lat: 50, lon: 10 }, { lat: 50, lon: 10.01 }]).points), locations(parsed.points));
});
test('missing and invalid station coordinates are excluded while GPS gaps remain available for editing', () => {
const stations = '<wpt><name>Ohne Ort</name></wpt>' + point('wpt', 91, 10) + point('wpt', 50, 181) + point('wpt', 50, 10);
const extra = '<extensions><wegwichtel:gpsGap time="2026-10-04T10:00:00Z"/><wegwichtel:pendingWaypoint><name>Später</name></wegwichtel:pendingWaypoint></extensions>';
const parsed = parseGpx(gpx(stations, '', extra));
assert.deepEqual(locations(parsed.points), [[50, 10]]);
assert.match(parsed.normalizedXml, /wegwichtel:gpsGap/);
assert.match(parsed.normalizedXml, /wegwichtel:pendingWaypoint/);
const empty = parseGpx(gpx('', '', extra));
assert.equal(empty.metrics.pointCount, 0);
assert.equal(empty.metrics.minLat, null);
});
test('moving or deleting a station replaces its generated route anchor without removing recorded points', () => {
const original = parseGpx(gpx(point('wpt', 50.01, 10.005), track(point('trkpt', 50, 10) + point('trkpt', 50, 10.01))));
assert.match(original.normalizedXml, /wegwichtel:waypointRoutePoint/);
const updated = parseGpx(gpxWithWaypoints(original.normalizedXml, [{ id: 1, title: 'Neu', lat: 50.02, lon: 10.006 }]));
assert.equal(updated.points.length, 3);
assert.equal(updated.metrics.maxLat, 50.02);
assert.equal(updated.points.some(p => p.lat === 50.01), false);
const removed = parseGpx(gpxWithWaypoints(updated.normalizedXml, []));
assert.deepEqual(locations(removed.points), [[50, 10], [50, 10.01]]);
assert.deepEqual(locations(parseGpx(updated.normalizedXml).points), locations(updated.points));
});
+2 -1
View File
@@ -45,7 +45,8 @@ test('information button opens scrollable overlay pages for privacy, imprint, te
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\]/);
assert.match(html, /E-Mail: <a href="mailto:info@wegwichtel\.de">info@wegwichtel\.de<\/a>/);
assert.doesNotMatch(html, /\[E-Mail-Adresse von Florian Zumpe ergänzen\]|\[Kontakt für Rechtefragen ergänzen\]/);
const storage = await read('public/js/privacy-storage.js');
assert.match(storage, /wegwichtel\.privacy\.v1/);
assert.match(storage, /wegwichtel\.route-progress\.v1/);
+32
View File
@@ -730,3 +730,35 @@ test('creator identity never falls back to insecure randomness or replaces corru
assert.throws(() => api.creatorId(), /Gerätekennung/);
assert.equal(localStorage.getItem('wegwichtel.creator-id.v1'), 'broken');
});
test('route selection reads metadata only and station media is loaded and released separately', async () => {
const { api, context } = await loadLocalRoutes();
const reads = [0, 0];
const revoked = [];
let urlNumber = 0;
context.URL.createObjectURL = () => `blob:lazy-${++urlNumber}`;
context.URL.revokeObjectURL = url => revoked.push(url);
const files = [];
for (let id = 0; id < 2; id++) {
const file = Object.assign(new Blob(['image']), { name: `${id}.jpg` });
const handle = { async getFile() { reads[id]++; return file; } };
files.push(await api.storeMediaFile('lazy', id, 'image', id, file, handle));
}
api.save({ id: 'lazy', route: { name: 'Lazy' }, pois: files.map((image, id) => ({ id, images: [image] })) });
await api.getAppRoute('lazy');
api.get('lazy');
assert.deepEqual(reads, [0, 0]);
const first = await api.getPoiWithMedia('lazy', 0, { requestPermission: true });
assert.deepEqual(reads, [1, 0]);
assert.equal(first.images[0].url, 'blob:lazy-1');
const second = await api.getAppPoi('lazy', 1);
assert.deepEqual(reads, [1, 1]);
assert.equal(second.images[0].url, 'blob:lazy-2');
api.releasePoiMedia(first); api.releasePoiMedia(first); api.releasePoiMedia(second);
api.releasePoiMedia({ images: [{ url: 'https://example.org/image.jpg' }] });
assert.deepEqual(revoked, ['blob:lazy-1', 'blob:lazy-2']);
assert.equal(await api.getPoiWithMedia('lazy', 9), null);
assert.equal(await api.getAppPoi('missing', 0), null);
assert.equal(api.get('lazy').pois[0].images[0].url, undefined);
});
+69 -5
View File
@@ -14,7 +14,7 @@ async function setup() {
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 ids = ['route-recording-control', 'route-recording-status', 'route-recording-pause', 'route-recording-stop', 'editor-recorded-positions', 'editor-add-position', 'route-recording-toggle'];
const nodes = new Map(ids.map(id => [id, new Element()]));
const document = new EventTarget();
document.readyState = 'complete';
@@ -23,7 +23,7 @@ async function setup() {
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; },
setInterval(callback, delay) { assert.equal(delay, 120000); intervals.set(1, callback); return 1; },
clearInterval(id) { intervals.delete(id); },
Wegwichtel: { Geo: { current: async () => ({ lat: 50, lon: 10, timestamp: Date.now() }) },
LocalRoutes: {
@@ -47,7 +47,7 @@ function activate(node, type = 'pointerup', key) {
node.dispatchEvent(event);
}
test('recording persists every 30 seconds during an editing session, pauses and finishes', async () => {
test('recording persists every two minutes during an editing session, pauses and finishes', async () => {
const { recorder, packages, intervals, nodes } = await setup();
recorder.start('draft'); await tick();
assert.equal(recorder.status, 'recording');
@@ -106,10 +106,14 @@ test('missing recorded locations can be filled manually while newer samples rema
test('ending editing stops sampling, reopening permits recording and a fresh app remains inactive', async () => {
const { recorder, intervals, packages } = await setup();
const { recorder, intervals, packages, nodes } = await setup();
assert.equal(recorder.status, 'idle');
recorder.beginEditing('draft');
await tick();
assert.equal(intervals.size, 0);
nodes.get('route-recording-toggle').checked = true;
nodes.get('route-recording-toggle').dispatchEvent(new Event('change'));
await tick();
assert.equal(intervals.size, 1);
recorder.pause();
recorder.beginEditing('draft');
@@ -121,8 +125,68 @@ test('ending editing stops sampling, reopening permits recording and a fresh app
assert.equal(JSON.parse(packages.get('draft').gpx).length, 1);
recorder.beginEditing('draft');
await tick();
assert.equal(recorder.status, 'recording');
assert.equal(recorder.status, 'idle');
assert.equal(nodes.get('route-recording-toggle').checked, false);
const fresh = await setup();
assert.equal(fresh.recorder.status, 'idle');
assert.equal(fresh.intervals.size, 0);
});
test('the switch can prepare a new draft and late GPS from an older session is ignored', async () => {
const { context, recorder, nodes, packages, intervals } = await setup();
const answers = [];
context.Wegwichtel.Geo.current = () => new Promise(resolve => answers.push(resolve));
const toggle = nodes.get('route-recording-toggle');
toggle.checked = true; toggle.dispatchEvent(new Event('change'));
assert.equal(answers.length, 0);
recorder.beginEditing('draft');
assert.equal(answers.length, 1);
toggle.checked = false; toggle.dispatchEvent(new Event('change'));
toggle.checked = true; toggle.dispatchEvent(new Event('change'));
assert.equal(answers.length, 2);
answers[1]({ lat: 52, lon: 12 }); await tick();
answers[0]({ lat: 50, lon: 10 }); await tick();
assert.equal(JSON.parse(packages.get('draft').gpx).length, 1);
assert.equal(JSON.parse(packages.get('draft').gpx)[0].lat, 52);
context.Wegwichtel.Geo.current = async () => ({ lat: 53, lon: 13 });
context.document.visibilityState = 'visible';
context.document.dispatchEvent(new Event('visibilitychange')); await tick();
assert.equal(JSON.parse(packages.get('draft').gpx).length, 2);
recorder.endEditing();
assert.equal(intervals.size, 0);
assert.equal(toggle.checked, false);
});
test('each crosshair fills only its own point without starting recording or saving', async () => {
const { recorder, nodes, packages } = await setup();
recorder.renderPoints('[{"lat":1,"lon":2},{"lat":3,"lon":4}]');
const [first, second] = nodes.get('editor-recorded-positions').children;
second.children[5].dispatchEvent(new Event('click')); await tick();
assert.equal(first.children[2].value, '1');
assert.equal(second.children[2].value, '50');
assert.equal(second.children[4].value, '10');
assert.equal(recorder.status, 'idle');
assert.equal(packages.get('draft').gpx, '[]');
assert.match(second.children[6].textContent, /Speichere/);
});
test('manual location errors preserve coordinates and stale or duplicate requests cannot overwrite points', async () => {
const { context, recorder, nodes } = await setup();
recorder.renderPoints('[{"lat":1,"lon":2}]');
const row = nodes.get('editor-recorded-positions').children[0];
context.Wegwichtel.Geo.current = async () => { throw new Error('denied'); };
row.children[5].dispatchEvent(new Event('click')); await tick();
assert.equal(row.children[2].value, '1');
assert.match(row.children[6].textContent, /nicht gefunden/);
let finish;
let requests = 0;
context.Wegwichtel.Geo.current = () => { requests++; return new Promise(resolve => { finish = resolve; }); };
row.children[5].dispatchEvent(new Event('click'));
row.children[5].dispatchEvent(new Event('click'));
assert.equal(requests, 1);
recorder.renderPoints('[]');
finish({ lat: 51, lon: 11 }); await tick();
assert.equal(row.children[2].value, '1');
assert.equal(row.children[5].disabled, false);
});