733 lines
25 KiB
JavaScript
733 lines
25 KiB
JavaScript
import { loadI18n } from '../test-support/i18n.js';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import vm from 'node:vm';
|
|
import { webcrypto } from 'node:crypto';
|
|
|
|
const root = path.resolve(import.meta.dirname, '..');
|
|
const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1';
|
|
const POI_NAMESPACE = 'urn:wegwichtel:poi';
|
|
|
|
function escapeXml(value) {
|
|
return String(value)
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
function decodeXml(value) {
|
|
return String(value)
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll(''', "'")
|
|
.replaceAll('&', '&');
|
|
}
|
|
|
|
class XmlElement {
|
|
constructor(namespaceURI, tagName) {
|
|
this.namespaceURI = namespaceURI;
|
|
this.tagName = tagName;
|
|
this.localName = tagName.includes(':') ? tagName.split(':').at(-1) : tagName;
|
|
this.attributes = new Map();
|
|
this.children = [];
|
|
this.parentNode = null;
|
|
this._text = '';
|
|
}
|
|
|
|
get firstChild() {
|
|
return this.children[0] || null;
|
|
}
|
|
|
|
get textContent() {
|
|
return this._text + this.children.map(child => child.textContent).join('');
|
|
}
|
|
|
|
set textContent(value) {
|
|
this.children = [];
|
|
this._text = String(value ?? '');
|
|
}
|
|
|
|
appendChild(child) {
|
|
child.parentNode = this;
|
|
this.children.push(child);
|
|
return child;
|
|
}
|
|
|
|
insertBefore(child, reference) {
|
|
child.parentNode = this;
|
|
const index = reference ? this.children.indexOf(reference) : -1;
|
|
if (index < 0) this.children.push(child);
|
|
else this.children.splice(index, 0, child);
|
|
return child;
|
|
}
|
|
|
|
before(...nodes) {
|
|
if (!this.parentNode) return;
|
|
const index = this.parentNode.children.indexOf(this);
|
|
if (index < 0) return;
|
|
for (const node of nodes) node.parentNode = this.parentNode;
|
|
this.parentNode.children.splice(index, 0, ...nodes);
|
|
}
|
|
|
|
remove() {
|
|
if (!this.parentNode) return;
|
|
this.parentNode.children = this.parentNode.children.filter(child => child !== this);
|
|
this.parentNode = null;
|
|
}
|
|
|
|
setAttribute(name, value) {
|
|
this.attributes.set(name, String(value));
|
|
}
|
|
|
|
getAttribute(name) {
|
|
return this.attributes.has(name) ? this.attributes.get(name) : null;
|
|
}
|
|
|
|
getElementsByTagName(name) {
|
|
const result = [];
|
|
for (const child of this.children) {
|
|
if (name === '*' || child.tagName === name || child.localName === name) result.push(child);
|
|
result.push(...child.getElementsByTagName(name));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
getElementsByTagNameNS(namespaceURI, localName) {
|
|
const result = [];
|
|
for (const child of this.children) {
|
|
const namespaceMatches = namespaceURI === '*' || child.namespaceURI === namespaceURI;
|
|
if (namespaceMatches && child.localName === localName) result.push(child);
|
|
result.push(...child.getElementsByTagNameNS(namespaceURI, localName));
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
class XmlDocument {
|
|
constructor(rootElement = null) {
|
|
this.documentElement = rootElement;
|
|
}
|
|
|
|
querySelector(selector) {
|
|
return selector === 'parsererror' ? null : null;
|
|
}
|
|
|
|
createElementNS(namespaceURI, tagName) {
|
|
return new XmlElement(namespaceURI, tagName);
|
|
}
|
|
|
|
getElementsByTagName(name) {
|
|
if (!this.documentElement) return [];
|
|
const own = name === '*' || this.documentElement.tagName === name || this.documentElement.localName === name
|
|
? [this.documentElement]
|
|
: [];
|
|
return [...own, ...this.documentElement.getElementsByTagName(name)];
|
|
}
|
|
|
|
getElementsByTagNameNS(namespaceURI, localName) {
|
|
if (!this.documentElement) return [];
|
|
const ownNamespaceMatches = namespaceURI === '*' || this.documentElement.namespaceURI === namespaceURI;
|
|
const own = ownNamespaceMatches && this.documentElement.localName === localName ? [this.documentElement] : [];
|
|
return [...own, ...this.documentElement.getElementsByTagNameNS(namespaceURI, localName)];
|
|
}
|
|
}
|
|
|
|
class FakeDOMParser {
|
|
parseFromString(xml) {
|
|
const tokens = String(xml)
|
|
.replace(/<\?xml[\s\S]*?\?>/gi, '')
|
|
.replace(/<!--[\s\S]*?-->/g, '')
|
|
.match(/<[^>]+>|[^<]+/g) || [];
|
|
const stack = [];
|
|
let rootElement = null;
|
|
|
|
for (const token of tokens) {
|
|
if (!token.trim()) continue;
|
|
if (token.startsWith('</')) {
|
|
stack.pop();
|
|
continue;
|
|
}
|
|
if (token.startsWith('<!')) continue;
|
|
if (!token.startsWith('<')) {
|
|
if (stack.length) stack.at(-1)._text += decodeXml(token);
|
|
continue;
|
|
}
|
|
|
|
const selfClosing = /\/\s*>$/.test(token);
|
|
const match = token.match(/^<\s*([^\s/>]+)([\s\S]*?)\/?\s*>$/);
|
|
if (!match) return new XmlDocument(new XmlElement('', 'parsererror'));
|
|
const [, tagName, attributeText] = match;
|
|
const namespaceURI = tagName.startsWith('wegwichtel:') ? POI_NAMESPACE : GPX_NAMESPACE;
|
|
const element = new XmlElement(namespaceURI, tagName);
|
|
const attributePattern = /([^\s=]+)\s*=\s*(["'])(.*?)\2/g;
|
|
for (const attribute of attributeText.matchAll(attributePattern)) {
|
|
element.setAttribute(attribute[1], decodeXml(attribute[3]));
|
|
}
|
|
|
|
if (stack.length) stack.at(-1).appendChild(element);
|
|
else if (!rootElement) rootElement = element;
|
|
else return new XmlDocument(new XmlElement('', 'parsererror'));
|
|
if (!selfClosing) stack.push(element);
|
|
}
|
|
|
|
if (!rootElement || stack.length) return new XmlDocument(new XmlElement('', 'parsererror'));
|
|
return new XmlDocument(rootElement);
|
|
}
|
|
}
|
|
|
|
function serializeElement(element) {
|
|
const attributes = [...element.attributes]
|
|
.map(([name, value]) => ` ${name}="${escapeXml(value)}"`)
|
|
.join('');
|
|
const content = escapeXml(element._text) + element.children.map(serializeElement).join('');
|
|
return `<${element.tagName}${attributes}>${content}</${element.tagName}>`;
|
|
}
|
|
|
|
class FakeXMLSerializer {
|
|
serializeToString(document) {
|
|
return serializeElement(document.documentElement);
|
|
}
|
|
}
|
|
|
|
class FakeStorage {
|
|
constructor() {
|
|
this.values = new Map();
|
|
this.failNext = false;
|
|
}
|
|
|
|
getItem(key) {
|
|
return this.values.has(key) ? this.values.get(key) : null;
|
|
}
|
|
|
|
setItem(key, value) {
|
|
if (this.failNext) {
|
|
this.failNext = false;
|
|
const error = new Error('quota');
|
|
error.name = 'QuotaExceededError';
|
|
throw error;
|
|
}
|
|
this.values.set(String(key), String(value));
|
|
}
|
|
|
|
removeItem(key) {
|
|
this.values.delete(String(key));
|
|
}
|
|
}
|
|
|
|
|
|
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);
|
|
this.detail = options.detail;
|
|
}
|
|
}
|
|
|
|
class FakeFileReader extends EventTarget {
|
|
readAsText(file) {
|
|
queueMicrotask(() => {
|
|
this.result = file.content || '';
|
|
this.dispatchEvent(new Event('load'));
|
|
});
|
|
}
|
|
|
|
readAsDataURL(file) {
|
|
queueMicrotask(() => {
|
|
const type = file.type || 'application/octet-stream';
|
|
this.result = file.invalidReaderResult ? { type } : `data:${type};base64,VEVTVA==`;
|
|
this.dispatchEvent(new Event('load'));
|
|
});
|
|
}
|
|
}
|
|
|
|
async function loadLocalRoutes(options = {}) {
|
|
const sourcePath = path.join(root, 'public/js/local-routes.js');
|
|
const source = await fs.readFile(sourcePath, 'utf8');
|
|
const localStorage = options.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,
|
|
EventTarget,
|
|
FileReader: FakeFileReader,
|
|
XMLSerializer: FakeXMLSerializer,
|
|
crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555', getRandomValues: bytes => webcrypto.getRandomValues(bytes) },
|
|
document,
|
|
globalThis: null,
|
|
indexedDB,
|
|
localStorage,
|
|
queueMicrotask,
|
|
URL: { createObjectURL: blob => `blob:test-${blob.size}` },
|
|
Wegwichtel: {}
|
|
};
|
|
context.globalThis = context;
|
|
await loadI18n(context);
|
|
vm.runInNewContext(source, context, { filename: sourcePath });
|
|
return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage, context };
|
|
}
|
|
|
|
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<gpx version="1.1" creator="Test" xmlns="${GPX_NAMESPACE}" xmlns:wegwichtel="${POI_NAMESPACE}">
|
|
<metadata><name>Alter Name</name></metadata>
|
|
<wpt lat="50.1000" lon="10.1000"><name>Import</name><extensions><wegwichtel:poiId>77</wegwichtel:poiId></extensions></wpt>
|
|
<trk><name>Weg</name><trkseg>
|
|
<trkpt lat="50.1000" lon="10.1000"><ele>100</ele></trkpt>
|
|
<trkpt lat="50.1010" lon="10.1020"><ele>112</ele></trkpt>
|
|
</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();
|
|
|
|
assert.equal(
|
|
api.createRouteId(),
|
|
'local-11111111-2222-4333-8444-555555555555'
|
|
);
|
|
});
|
|
|
|
test('route id generation falls back to crypto.getRandomValues with UUID v4 bits', async () => {
|
|
let callCount = 0;
|
|
const { api } = await loadLocalRoutes({
|
|
crypto: {
|
|
getRandomValues(bytes) {
|
|
callCount += 1;
|
|
bytes.set([
|
|
0x00, 0x11, 0x22, 0x33,
|
|
0x44, 0x55, 0x66, 0x77,
|
|
0x88, 0x99, 0xaa, 0xbb,
|
|
0xcc, 0xdd, 0xee, 0xff
|
|
]);
|
|
return bytes;
|
|
}
|
|
}
|
|
});
|
|
|
|
assert.equal(api.createRouteId(), 'local-00112233-4455-4677-8899-aabbccddeeff');
|
|
assert.equal(callCount, 1);
|
|
});
|
|
|
|
test('route id generation fails when no secure crypto source is available', async () => {
|
|
const { api } = await loadLocalRoutes({ crypto: {} });
|
|
|
|
assert.throws(
|
|
() => api.createRouteId(),
|
|
/keine neue Strecke angelegt/
|
|
);
|
|
});
|
|
|
|
test('local route package stores GPX and JSON separately and reconstructs an app route', async () => {
|
|
const { api, events, localStorage } = await loadLocalRoutes();
|
|
const routeId = api.createRouteId();
|
|
assert.equal(routeId, 'local-11111111-2222-4333-8444-555555555555');
|
|
|
|
const saved = api.save({
|
|
id: routeId,
|
|
gpx: api.emptyGpx('Neue Strecke'),
|
|
route: {
|
|
id: routeId,
|
|
name: 'Neue Strecke',
|
|
description: 'Lokal gespeichert',
|
|
serverRouteId: 42,
|
|
uploadedAt: '2026-09-27T18:00:00.000Z'
|
|
},
|
|
pois: [
|
|
{
|
|
id: 101,
|
|
title: 'Start',
|
|
description: 'Erster POI',
|
|
lat: 50.1,
|
|
lon: 10.1,
|
|
triggerRadiusM: 30,
|
|
audio: { dataUrl: 'data:audio/mpeg;base64,QQ==', name: 'start.mp3' },
|
|
images: [{ id: 'img-1', dataUrl: 'data:image/jpeg;base64,Qg==', caption: 'Bild' }]
|
|
},
|
|
{
|
|
id: 102,
|
|
title: 'Ziel',
|
|
description: 'Zweiter POI',
|
|
lat: 50.101,
|
|
lon: 10.102,
|
|
triggerRadiusM: 25,
|
|
images: []
|
|
}
|
|
]
|
|
});
|
|
|
|
assert.equal(saved.route.name, 'Neue Strecke');
|
|
assert.equal(saved.pois.length, 2);
|
|
assert.equal(events.at(-1).action, 'saved');
|
|
assert.equal(events.at(-1).id, routeId);
|
|
|
|
const prefix = api.STORAGE_PREFIX;
|
|
const encodedId = encodeURIComponent(routeId);
|
|
const gpx = localStorage.getItem(`${prefix}.${encodedId}.gpx`);
|
|
const metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`));
|
|
|
|
assert.match(gpx, /<wpt lat="50\.1" lon="10\.1">/);
|
|
assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/);
|
|
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');
|
|
assert.equal(metadata.pois[0].audio.name, 'start.mp3');
|
|
assert.equal('gpx' in metadata, false);
|
|
assert.equal('_source' in metadata.route, false);
|
|
|
|
const appRoute = await api.getAppRoute(routeId);
|
|
assert.equal(appRoute.source, 'local');
|
|
assert.equal(appRoute.local, true);
|
|
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, 0);
|
|
assert.equal(appRoute.distanceM, 0);
|
|
assert.equal(Math.round(appRoute.elevationGainM), 0);
|
|
const listed = api.listAppRoutes();
|
|
assert.equal(listed.length, 1);
|
|
assert.equal(listed[0].id, routeId);
|
|
});
|
|
|
|
test('GPX waypoint import reads stable POI ids and validates coordinates', async () => {
|
|
const { api } = await loadLocalRoutes();
|
|
const imported = api.parseWaypoints(sampleGpx);
|
|
|
|
assert.equal(imported.length, 1);
|
|
assert.equal(imported[0].id, 77);
|
|
assert.equal(imported[0].title, 'Import');
|
|
assert.equal(imported[0].lat, 50.1);
|
|
assert.equal(imported[0].lon, 10.1);
|
|
|
|
assert.match(api.emptyGpx('A & B'), /<name>A & B<\/name>/);
|
|
assert.throws(() => api.parseWaypoints('<not-gpx></not-gpx>'), /keinen lesbaren Weg/);
|
|
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, /<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 () => {
|
|
const { api } = await loadLocalRoutes();
|
|
const saved = api.save({
|
|
id: 'local-upload',
|
|
gpx: api.emptyGpx('Lokale Strecke'),
|
|
route: { name: 'Lokale Strecke' },
|
|
pois: [
|
|
{ id: 1, title: 'Start', lat: 50.123456789, lon: 10.987654321, sequence: 0 },
|
|
{ id: 2, title: 'Ziel', lat: 50.223456789, lon: 11.087654321, sequence: 1 }
|
|
]
|
|
});
|
|
|
|
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(/<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');
|
|
});
|
|
|
|
test('sync keeps imported foreign Stationen and upload rejects a route without Stationen', async () => {
|
|
const { api } = await loadLocalRoutes();
|
|
const synced = api.syncGpx(sampleGpx, { name: 'Import' }, [
|
|
{ id: 200, title: 'Neuer Station', lat: 50.2, lon: 10.2, sequence: 0 }
|
|
]);
|
|
|
|
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, 2);
|
|
assert.equal(api.gpxForUpload({ gpx: sampleGpx, route: { name: 'Import' }, pois: [] }), sampleGpx);
|
|
assert.throws(() => api.gpxForUpload({
|
|
gpx: api.emptyGpx('Leer'),
|
|
route: { name: 'Leer' },
|
|
pois: []
|
|
}), /noch keinen aufgezeichneten Weg/);
|
|
});
|
|
|
|
test('remove updates index and dispatches a local-route change event', async () => {
|
|
const { api, events } = await loadLocalRoutes();
|
|
api.save({
|
|
id: 'local-remove',
|
|
gpx: sampleGpx,
|
|
route: { name: 'Entfernen' },
|
|
pois: []
|
|
});
|
|
|
|
assert.equal(api.list().length, 1);
|
|
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');
|
|
await assert.rejects(api.getAppRoute('local-remove'), /nicht gefunden/);
|
|
});
|
|
|
|
test('quota failure rolls back package writes and keeps the previous package readable', async () => {
|
|
const { api, localStorage } = await loadLocalRoutes();
|
|
api.save({
|
|
id: 'local-quota',
|
|
gpx: sampleGpx,
|
|
route: { name: 'Vorher' },
|
|
pois: []
|
|
});
|
|
|
|
localStorage.failNext = true;
|
|
assert.throws(() => api.save({
|
|
id: 'local-quota',
|
|
gpx: sampleGpx,
|
|
route: { name: 'Nachher' },
|
|
pois: []
|
|
}), /Platz für deine Strecken 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: 'Station 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();
|
|
|
|
assert.equal(await api.readFileAsText({ text: async () => '<gpx></gpx>' }), '<gpx></gpx>');
|
|
assert.equal(await api.readFileAsText(null), '');
|
|
await assert.rejects(api.readFileAsText({}), { name: 'TypeError' });
|
|
|
|
const media = await api.fileToDataUrl({ name: 'ton.mp3', size: 4, type: 'audio/mpeg' });
|
|
assert.equal(media.name, 'ton.mp3');
|
|
assert.equal(media.size, 4);
|
|
assert.equal(media.type, 'audio/mpeg');
|
|
assert.match(media.dataUrl, /^data:audio\/mpeg;base64,/);
|
|
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: 'Station', 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), /Erlaube noch einmal/);
|
|
assert.equal(await api.mediaBlob(media, { requestPermission: true }), file);
|
|
assert.equal(permissionRequests, 1);
|
|
});
|
|
|
|
test('multiple Stationen 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 Stationen'),
|
|
route: { name: 'Mehrere Stationen' },
|
|
pois: [
|
|
{ id: 1, title: 'Erster Station', lat: 50.1, lon: 10.1, images: [first] },
|
|
{ id: 2, title: 'Zweiter Station', 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);
|
|
});
|
|
|
|
|
|
test('256-bit creator identity survives reload, route deletion and requires explicit confirmation to reset', async () => {
|
|
const first = await loadLocalRoutes();
|
|
const identity = first.api.creatorId();
|
|
assert.match(identity, /^[a-f0-9]{64}$/);
|
|
const second = await loadLocalRoutes({ localStorage: first.localStorage });
|
|
assert.equal(second.api.creatorId(), identity);
|
|
const saved = second.api.save({ id: 'identity-test', route: { name: 'Test' }, pois: [] });
|
|
assert.equal(saved.route.creator_id, identity);
|
|
await second.api.remove('identity-test');
|
|
assert.equal(second.api.creatorId(), identity);
|
|
second.context.confirm = () => false;
|
|
assert.equal(second.api.forgetCreatorId(), false);
|
|
assert.equal(second.api.creatorId(), identity);
|
|
second.context.confirm = () => true;
|
|
assert.equal(second.api.forgetCreatorId(), true);
|
|
assert.notEqual(second.api.creatorId(), identity);
|
|
const other = await loadLocalRoutes();
|
|
assert.notEqual(other.api.creatorId(), identity);
|
|
});
|
|
|
|
test('creator identity never falls back to insecure randomness or replaces corrupt stored identity', async () => {
|
|
const { api, localStorage } = await loadLocalRoutes({ crypto: {} });
|
|
assert.throws(() => api.creatorId(), error => error.name === 'TypeError' && /Gerätekennung/.test(error.message));
|
|
localStorage.setItem('wegwichtel.creator-id.v1', 'broken');
|
|
assert.throws(() => api.creatorId(), /Gerätekennung/);
|
|
assert.equal(localStorage.getItem('wegwichtel.creator-id.v1'), 'broken');
|
|
});
|