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
+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);
});