image handling update for route creation
Sonarqube Scanner / Build and analyze (push) Failing after 20s

This commit is contained in:
Florian Zumpe
2026-09-27 21:50:52 +02:00
parent 4b8105188f
commit 7f6d0425d5
16 changed files with 831 additions and 182 deletions
+15
View File
@@ -21,6 +21,7 @@ test('start screen uses the presentation-style hero and one action slot', async
assert.match(html, />Los geht’s</);
assert.match(html, /<ol class="bootstrap-state-model" hidden>[\s\S]*data-step="storage"/);
assert.match(html, /id="bootstrap-progress"[^>]*hidden/);
assert.match(html, /id="android-browser-hint"[^>]*hidden/);
});
test('bootstrap status text fades between loading steps and is replaced by the start button', async () => {
@@ -46,3 +47,17 @@ test('bootstrap status text fades between loading steps and is replaced by the s
assert.doesNotMatch(loader, /dataset\.state/);
assert.match(loader, /event\.stopPropagation\?\.\(\)/);
});
test('Android start hint distinguishes Chrome from other Android browsers', async () => {
const css = await read('public/css/bootstrap.css');
const loader = await read('public/js/bootstrap-loader.js');
assert.match(css, /\.android-browser-hint\s*\{/);
assert.match(css, /\.android-browser-hint\[data-browser="other"\]/);
assert.match(loader, /getElementById\('android-browser-hint'\)/);
assert.match(loader, /\/Android\/i/);
assert.match(loader, /\/Chrome\\\//i);
assert.match(loader, /Android mit Chrome erkannt/);
assert.match(loader, /Android erkannt\. Für lokale Bilder und Audiodateien bitte Chrome verwenden/);
});
+89 -14
View File
@@ -20,7 +20,7 @@ test('editor markup and source describe local draft creation instead of immediat
const ui = await read('public/js/editor-ui.js');
assert.match(html, /id="editor-button"[^>]+aria-label="Streckenverwaltung öffnen"/);
assert.match(html, /<img[^>]+src="images\/icons\/route-management\.svg"[^>]+alt=""[^>]*>/);
assert.match(html, /id="route-control-button"[\s\S]*?<img[^>]+src="images\/icons\/route-management\.svg"[^>]+alt=""[^>]*>/);
assert.doesNotMatch(html, /class="editor-button-icon"/);
assert.match(html, /<dialog id="editor-dialog"/);
assert.match(html, /id="editor-route-form"/);
@@ -36,8 +36,8 @@ test('editor markup and source describe local draft creation instead of immediat
assert.doesNotMatch(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, />Trackpunkte<\/h3>/);
assert.match(html, /aria-label="Trackpunkt hinzufügen"/);
assert.match(html, />Streckenpunkte<\/h3>/);
assert.match(html, /aria-label="Streckenpunkt hinzufügen"/);
assert.match(html, /id="editor-route-upload"[^>]+type="button"[^>]+hidden/);
assert.doesNotMatch(html, /accept="video\//);
assert.match(html, /ausschließlich in diesem Browser/i);
@@ -51,13 +51,15 @@ test('editor markup and source describe local draft creation instead of immediat
assert.match(css, /\.editor-media-remove/);
assert.match(css, /\.editor-danger-button/);
assert.match(css, /\.editor-icon-danger/);
assert.match(css, /\.editor-button\s+img/);
assert.match(css, /\.route-control-button img/);
assert.doesNotMatch(css, /\.editor-button-icon/);
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, /ns\.LocalRoutes\.storeMediaFile\(/);
assert.match(ui, /showOpenFilePicker/);
assert.match(ui, /requestPermission:\s*true/);
assert.match(ui, /ns\.LocalRoutes\.gpxForUpload\(/);
assert.match(ui, /move-poi-up/);
assert.match(ui, /move-poi-down/);
@@ -76,6 +78,7 @@ test('editor markup and source describe local draft creation instead of immediat
assert.match(ui, /sendForm\('\/routes',\s*'POST'/);
assert.match(ui, /serverRouteId/);
assert.match(ui, /position\?\.accuracy/);
assert.match(ui, /setHidden\(byId\(ids\.routeGpxGroup\), !routeId\(route\)\)/);
});
class FakeElement extends EventTarget {
@@ -213,6 +216,7 @@ function buildEditorContext() {
'editor-route-detail-view',
'editor-route-form',
'editor-route-gpx',
'editor-route-gpx-group',
'editor-route-id',
'editor-route-list',
'editor-route-list-view',
@@ -271,6 +275,8 @@ function buildEditorContext() {
return { ok: false, status: 404, text: async () => `unexpected ${method} ${url}` };
}
const mediaBlobs = new Map();
const storedMediaCalls = [];
const LocalRoutes = {
createPoiId: () => 9001,
createRouteId: () => 'local-new',
@@ -282,21 +288,48 @@ function buildEditorContext() {
type: file.type || 'application/octet-stream'
}),
get: id => packages.get(String(id)) || null,
getWithMedia: async id => {
const value = packages.get(String(id));
if (!value) return null;
return {
...value,
pois: value.pois.map(poi => ({
...poi,
audio: poi.audio ? { ...poi.audio, url: 'blob:test-audio' } : null,
images: (poi.images || []).map(image => ({ ...image, url: 'blob:test-image' }))
}))
};
},
gpxForUpload: routePackage => routePackage.gpx,
list: () => [...packages.values()],
parseWaypoints: () => [],
readFileAsText: file => file.text(),
remove: id => packages.delete(String(id)),
mediaBlob: async media => mediaBlobs.get(media.mediaKey) || new Blob(['media'], { type: media.type || 'application/octet-stream' }),
removeMedia: async media => { if (media?.mediaKey) mediaBlobs.delete(media.mediaKey); },
remove: async id => packages.delete(String(id)),
storeMediaFile: async (routeId, poiId, kind, mediaId, file, handle = null) => {
storedMediaCalls.push({ routeId, poiId, kind, mediaId, file, handle });
const mediaKey = `${routeId}:${poiId}:${kind}:${mediaId}`;
mediaBlobs.set(mediaKey, file);
return {
id: String(mediaId),
mediaKey,
storage: handle ? 'file-handle' : 'indexeddb',
name: file.name,
size: file.size || 1,
type: file.type || 'application/octet-stream'
};
},
save: input => {
const id = String(input.id);
const now = '2026-09-19T14:00:00.000Z';
const pois = input.pois.map((poi, sequence) => ({ ...poi, sequence }));
const trackPoints = pois
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>`)
.join('');
const saved = {
id,
gpx: `<gpx><trk><trkseg>${trackPoints}</trkseg></trk></gpx>`,
gpx: `<gpx><trk><trkseg>${gpxPoints}</trkseg></trk></gpx>`,
route: { ...input.route, id, updatedAt: now },
pois
};
@@ -315,6 +348,7 @@ function buildEditorContext() {
FormData: FakeFormData,
globalThis: null,
structuredClone,
URL: { createObjectURL: () => 'blob:test-preview', revokeObjectURL() {} },
Wegwichtel: {
App: { currentPosition: () => ({ lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }) },
Config: { apiBase: '/api/' },
@@ -322,10 +356,10 @@ function buildEditorContext() {
}
};
context.globalThis = context;
return { calls, context, elements, packages, saves };
return { calls, context, elements, packages, saves, storedMediaCalls };
}
test('public editor workflow saves new route and GPX track point locally without POSTing a route', async () => {
test('public editor workflow saves new route and GPX Streckenpunkt 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, packages, saves } = buildEditorContext();
@@ -338,6 +372,7 @@ test('public editor workflow saves new route and GPX track point locally without
assert.ok(calls.some(call => call.method === 'GET' && call.url === '/api/routes?activatedOnly=true'));
await context.Wegwichtel.EditorUi.openNewLocalRoute();
assert.equal(elements.get('editor-route-gpx-group').hidden, true);
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());
@@ -351,6 +386,7 @@ test('public editor workflow saves new route and GPX track point locally without
assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.');
assert.equal(elements.get('editor-route-upload').hidden, false);
assert.equal(elements.get('editor-route-upload').disabled, false);
assert.equal(elements.get('editor-route-gpx-group').hidden, false);
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
assert.equal(elements.get('editor-poi-audio').clickCount, 0);
@@ -379,9 +415,11 @@ test('public editor workflow saves new route and GPX track point locally without
assert.equal(local.pois[0].lat, 50.123456789);
assert.equal(local.pois[0].lon, 10.987654321);
assert.equal(local.pois[0].triggerRadiusM, 4.321);
assert.match(local.pois[0].audio.dataUrl, /^data:audio\/mpeg/);
assert.equal(local.pois[0].audio.storage, 'indexeddb');
assert.ok(local.pois[0].audio.mediaKey);
assert.equal(local.pois[0].images.length, 1);
assert.match(local.pois[0].images[0].dataUrl, /^data:image\/jpeg/);
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">/);
const pointListBeforeUpload = elements.get('editor-poi-list');
@@ -450,7 +488,7 @@ test('public editor workflow saves new route and GPX track point locally without
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
});
test('route and track point list expose red delete actions and remove local data', async () => {
test('route and Streckenpunkt list expose red delete actions and remove local data', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { context, elements, packages } = buildEditorContext();
@@ -463,7 +501,7 @@ test('route and track point list expose red delete actions and remove local data
await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
elements.get('editor-poi-title').value = 'Trackpunkt zum Löschen';
elements.get('editor-poi-title').value = 'Streckenpunkt zum Löschen';
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
@@ -539,3 +577,40 @@ test('editing an activated server route uses PUT and never writes activation sta
assert.equal('activatedAt' in body, false);
assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.');
});
test('local media picker keeps File System Access handles for later upload', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { context, elements, storedMediaCalls } = buildEditorContext();
const imageFile = Object.assign(new Blob(['photo'], { type: 'image/jpeg' }), { name: 'foto.jpg' });
const handle = {
name: 'foto.jpg',
async getFile() { return imageFile; }
};
context.showOpenFilePicker = async options => {
assert.equal(options.multiple, true);
return [handle];
};
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.openNewLocalRoute();
elements.get('editor-route-name').value = 'Handle-Strecke';
elements.get('editor-route-form').dispatchEvent(submitEvent());
await tick();
await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
elements.get('editor-poi-title').value = 'Streckenpunkt mit Bild';
elements.get('editor-poi-images-add').dispatchEvent(pointerEvent());
await tick();
assert.equal(elements.get('editor-poi-images').clickCount, 0);
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
const imageStore = storedMediaCalls.find(call => call.kind === 'image');
assert.ok(imageStore);
assert.equal(imageStore.handle, handle);
assert.equal(imageStore.file, imageFile);
});
+185 -11
View File
@@ -217,6 +217,83 @@ class FakeStorage {
}
}
function fakeIdbRequest(operation) {
const request = { result: null, error: null, onsuccess: null, onerror: null };
queueMicrotask(() => {
try {
request.result = operation();
request.onsuccess?.();
} catch (error) {
request.error = error;
request.onerror?.();
}
});
return request;
}
class FakeIndexedDatabaseConnection {
constructor(records) {
this.records = records;
this.objectStoreNames = { contains: name => name === 'media' };
}
createObjectStore() {
this.objectStoreNames = { contains: name => name === 'media' };
return {};
}
transaction() {
const records = this.records;
return {
error: null,
onabort: null,
objectStore() {
return {
put(record) {
return fakeIdbRequest(() => {
records.set(record.key, record);
return record.key;
});
},
get(key) {
return fakeIdbRequest(() => records.get(key) || undefined);
},
delete(key) {
return fakeIdbRequest(() => {
records.delete(key);
return undefined;
});
}
};
}
};
}
}
class FakeIndexedDB {
constructor() {
this.records = new Map();
this.database = new FakeIndexedDatabaseConnection(this.records);
}
open() {
const request = {
result: this.database,
error: null,
onupgradeneeded: null,
onsuccess: null,
onerror: null,
onblocked: null
};
queueMicrotask(() => {
request.onupgradeneeded?.();
request.onsuccess?.();
});
return request;
}
}
class FakeCustomEvent extends Event {
constructor(type, options = {}) {
super(type);
@@ -245,11 +322,13 @@ async function loadLocalRoutes(options = {}) {
const sourcePath = path.join(root, 'public/js/local-routes.js');
const source = await fs.readFile(sourcePath, 'utf8');
const localStorage = new FakeStorage();
const indexedDB = new FakeIndexedDB();
const document = new EventTarget();
const events = [];
document.addEventListener('wegwichtel:local-routes-changed', event => events.push(event.detail));
const context = {
Blob,
CustomEvent: FakeCustomEvent,
DOMParser: FakeDOMParser,
Event,
@@ -259,13 +338,15 @@ async function loadLocalRoutes(options = {}) {
crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555' },
document,
globalThis: null,
indexedDB,
localStorage,
queueMicrotask,
URL: { createObjectURL: blob => `blob:test-${blob.size}` },
Wegwichtel: {}
};
context.globalThis = context;
vm.runInNewContext(source, context, { filename: sourcePath });
return { api: context.Wegwichtel.LocalRoutes, events, localStorage };
return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage };
}
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
@@ -376,7 +457,7 @@ test('local route package stores GPX and JSON separately and reconstructs an app
assert.equal('gpx' in metadata, false);
assert.equal('_source' in metadata.route, false);
const appRoute = api.getAppRoute(routeId);
const appRoute = await api.getAppRoute(routeId);
assert.equal(appRoute.source, 'local');
assert.equal(appRoute.local, true);
assert.equal(appRoute.pois.length, 2);
@@ -406,14 +487,14 @@ test('GPX waypoint import reads stable POI ids and validates coordinates', async
const waypointOnly = api.syncGpx(
`<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`,
{ name: 'Nur Trackpunkt' },
[{ id: 2, title: 'Einzelner Trackpunkt', lat: 50.2, lon: 10.2, sequence: 0 }]
{ name: 'Nur Streckenpunkt' },
[{ id: 2, title: 'Einzelner Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 }]
);
assert.match(waypointOnly, /<trkpt lat="50.2" lon="10.2">/);
assert.doesNotMatch(waypointOnly, /<wpt\b/);
});
test('local points are stored directly as managed GPX track points and uploaded unchanged', async () => {
test('local points are stored directly as managed GPX Streckenpunkte and uploaded unchanged', async () => {
const { api } = await loadLocalRoutes();
const saved = api.save({
id: 'local-upload',
@@ -433,10 +514,10 @@ test('local points are stored directly as managed GPX track points and uploaded
assert.equal(api.gpxForUpload(saved), saved.gpx, 'upload must use the already stored track without conversion');
});
test('sync keeps imported foreign track points and upload rejects a route without track points', async () => {
test('sync keeps imported foreign Streckenpunkte and upload rejects a route without Streckenpunkte', async () => {
const { api } = await loadLocalRoutes();
const synced = api.syncGpx(sampleGpx, { name: 'Import' }, [
{ id: 200, title: 'Neuer Trackpunkt', lat: 50.2, lon: 10.2, sequence: 0 }
{ id: 200, title: 'Neuer Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 }
]);
assert.match(synced, /<ele>100<\/ele>/);
@@ -448,7 +529,7 @@ test('sync keeps imported foreign track points and upload rejects a route withou
gpx: api.emptyGpx('Leer'),
route: { name: 'Leer' },
pois: []
}), /noch keine GPX-Trackpunkte/);
}), /noch keine Streckenpunkte im GPX-Track/);
});
test('remove updates index and dispatches a local-route change event', async () => {
@@ -461,11 +542,11 @@ test('remove updates index and dispatches a local-route change event', async ()
});
assert.equal(api.list().length, 1);
api.remove('local-remove');
await 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/);
await assert.rejects(api.getAppRoute('local-remove'), /nicht gefunden/);
});
test('quota failure rolls back package writes and keeps the previous package readable', async () => {
@@ -483,11 +564,40 @@ test('quota failure rolls back package writes and keeps the previous package rea
gpx: sampleGpx,
route: { name: 'Nachher' },
pois: []
}), /Browser-Speicher ist voll/);
}), /Speicher für Streckendaten ist voll/);
assert.equal(api.get('local-quota').route.name, 'Vorher');
});
test('local media uses IndexedDB so route JSON does not consume Local Storage quota', async () => {
const { api, indexedDB, localStorage } = await loadLocalRoutes();
const routeId = 'local-media';
const audioFile = Object.assign(new Blob(['audio-payload'], { type: 'audio/mpeg' }), { name: 'weg.mp3' });
const imageFile = Object.assign(new Blob(['image-payload'], { type: 'image/jpeg' }), { name: 'weg.jpg' });
const audio = await api.storeMediaFile(routeId, 1, 'audio', '1-audio', audioFile);
const image = await api.storeMediaFile(routeId, 1, 'image', '1-image', imageFile);
const saved = api.save({
id: routeId,
gpx: api.emptyGpx('Medientest'),
route: { name: 'Medientest' },
pois: [{ id: 1, title: 'Streckenpunkt 1', lat: 50.1, lon: 10.1, audio, images: [image] }]
});
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent(routeId)}.json`);
assert.doesNotMatch(metadata, /data:audio|data:image|audio-payload|image-payload/);
assert.match(metadata, /"storage":"indexeddb"/);
assert.equal(indexedDB.records.size, 2);
const hydrated = await api.getWithMedia(routeId);
assert.match(hydrated.pois[0].audio.url, /^blob:test-/);
assert.match(hydrated.pois[0].images[0].url, /^blob:test-/);
assert.equal((await api.mediaBlob(saved.pois[0].audio)).size, audioFile.size);
await api.remove(routeId);
assert.equal(indexedDB.records.size, 0);
});
test('file helpers support Blob.text and FileReader-backed media data URLs', async () => {
const { api } = await loadLocalRoutes();
@@ -503,3 +613,67 @@ test('file helpers support Blob.text and FileReader-backed media data URLs', asy
await assert.rejects(api.fileToDataUrl({ invalidReaderResult: true }), { name: 'TypeError' });
assert.equal(await api.fileToDataUrl(null), null);
});
test('local media stores a File System Access handle instead of copying file bytes when supported', async () => {
const { api, indexedDB, localStorage } = await loadLocalRoutes();
const file = Object.assign(new Blob(['large-image'], { type: 'image/jpeg' }), { name: 'foto.jpg' });
let permission = 'granted';
let permissionRequests = 0;
const handle = {
name: 'foto.jpg',
async getFile() { return file; },
async queryPermission() { return permission; },
async requestPermission() {
permissionRequests += 1;
permission = 'granted';
return permission;
}
};
const media = await api.storeMediaFile('route-handle', 7, 'image', 'image-7', file, handle);
assert.equal(media.storage, 'file-handle');
const record = indexedDB.records.get(media.mediaKey);
assert.equal(record.handle, handle);
assert.equal('blob' in record, false);
api.save({
id: 'route-handle',
gpx: api.emptyGpx('Handle'),
route: { name: 'Handle' },
pois: [{ id: 7, title: 'Streckenpunkt', lat: 50.1, lon: 10.1, images: [media] }]
});
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent('route-handle')}.json`);
assert.match(metadata, /"storage":"file-handle"/);
assert.doesNotMatch(metadata, /large-image/);
assert.equal(await api.mediaBlob(media), file);
permission = 'prompt';
await assert.rejects(api.mediaBlob(media), /erneut erlaubt/);
assert.equal(await api.mediaBlob(media, { requestPermission: true }), file);
assert.equal(permissionRequests, 1);
});
test('multiple Streckenpunkte with large local media keep Local Storage metadata small', async () => {
const { api, localStorage } = await loadLocalRoutes();
const routeId = 'multi-media';
const large = new Uint8Array(1024 * 1024);
const firstFile = Object.assign(new Blob([large], { type: 'image/jpeg' }), { name: 'eins.jpg' });
const secondFile = Object.assign(new Blob([large], { type: 'image/jpeg' }), { name: 'zwei.jpg' });
const first = await api.storeMediaFile(routeId, 1, 'image', 'image-1', firstFile);
const second = await api.storeMediaFile(routeId, 2, 'image', 'image-2', secondFile);
api.save({
id: routeId,
gpx: api.emptyGpx('Mehrere Streckenpunkte'),
route: { name: 'Mehrere Streckenpunkte' },
pois: [
{ id: 1, title: 'Erster Streckenpunkt', lat: 50.1, lon: 10.1, images: [first] },
{ id: 2, title: 'Zweiter Streckenpunkt', lat: 50.2, lon: 10.2, images: [second] }
]
});
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent(routeId)}.json`);
assert.ok(metadata.length < 5000, `metadata unexpectedly large: ${metadata.length}`);
assert.doesNotMatch(metadata, /data:image/);
assert.equal(api.get(routeId).pois.length, 2);
});
+1 -1
View File
@@ -9,7 +9,7 @@ async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
}
test('presentation example uses real GPX track points and the existing upload API', async () => {
test('presentation example uses real GPX Streckenpunkte and the existing upload API', async () => {
const gpx = await read('examples/presentation-route.gpx');
const metadata = JSON.parse(await read('examples/presentation-route.json'));
const script = await read('scripts/upload-presentation-route.sh');
+6 -1
View File
@@ -12,6 +12,7 @@ async function read(relativePath) {
test('main screen contains a persistent route control next to the information control', async () => {
const html = await read('public/index.html');
const css = await read('public/css/editor.css');
const icon = await read('public/images/icons/route-management.svg');
assert.match(html, /class="route-control"/);
assert.match(html, /id="route-control-button"/);
@@ -22,6 +23,10 @@ test('main screen contains a persistent route control next to the information co
assert.match(html, /id="route-control-local-list"/);
assert.match(html, /id="route-control-new"/);
assert.match(html, /id="info-button"/);
assert.match(html, /id="route-control-button"[\s\S]*?<img[^>]+route-management\.svg/);
assert.match(html, /id="editor-button"[^>]*>Strecken verwalten<\/button>/);
assert.match(icon, /C[^\"]+/);
assert.match(icon, /stroke-dasharray/);
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);
@@ -50,7 +55,7 @@ test('persistent route control displays the next unvisited POI and preserves pro
const app = await read('public/js/app.js');
assert.match(app, /function nextPoiMetric\(/);
assert.match(app, /Nächster POI:/);
assert.match(app, /Nächster Streckenpunkt:/);
assert.match(app, /state\.triggered\.has\(poi\.id\)/);
assert.match(app, /function suspendCurrentRoute\(/);
assert.match(app, /persistRouteProgress\(\)/);