1106 lines
52 KiB
JavaScript
1106 lines
52 KiB
JavaScript
import { readAppFile } from '../test-support/i18n.js';
|
||
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';
|
||
|
||
const root = path.resolve(import.meta.dirname, '..');
|
||
|
||
async function read(relativePath) {
|
||
return readAppFile(relativePath);
|
||
}
|
||
|
||
function tick() {
|
||
return new Promise(resolve => setTimeout(resolve, 0));
|
||
}
|
||
|
||
test('editor markup and source describe local draft creation instead of immediate publication', async () => {
|
||
const html = await read('public/index.html');
|
||
const css = await read('public/css/editor.css');
|
||
const ui = await read('public/js/editor-ui.js');
|
||
|
||
assert.match(html, /id="editor-button"[^>]+aria-label="Strecken bearbeiten"/);
|
||
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"/);
|
||
assert.match(html, /id="editor-poi-form"/);
|
||
assert.match(html, /id="editor-route-gpx"[^>]+accept="[^\"]*\.gpx/);
|
||
assert.match(html, /id="editor-poi-audio-add"[^>]+type="button"/);
|
||
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="number"/);
|
||
assert.match(html, /id="editor-poi-lon"[^>]+type="number"/);
|
||
assert.match(html, /id="editor-poi-radius"[^>]+type="hidden"/);
|
||
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>/);
|
||
assert.match(html, /aria-label="Station hinzufügen"/);
|
||
assert.match(html, /id="editor-route-upload"[^>]+type="button"[^>]+hidden/);
|
||
assert.doesNotMatch(html, /accept="video\//);
|
||
assert.match(html, /auf diesem Gerät/i);
|
||
await fs.access(path.join(root, 'public/images/icons/route-management.svg'));
|
||
|
||
assert.match(css, /\.editor-dialog/);
|
||
assert.match(css, /\.editor-poi-list/);
|
||
assert.match(css, /\.editor-route-group/);
|
||
assert.match(css, /\.editor-media-button/);
|
||
assert.match(css, /\.editor-media-preview/);
|
||
assert.match(css, /\.editor-media-remove/);
|
||
assert.match(css, /\.editor-danger-button/);
|
||
assert.match(css, /\.editor-icon-danger/);
|
||
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\.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/);
|
||
assert.match(ui, /delete-route/);
|
||
assert.match(ui, /delete-poi/);
|
||
assert.match(ui, /sortOrder:\s*index/);
|
||
assert.match(ui, /pointerup/);
|
||
assert.doesNotMatch(ui, /sendJson\(['"]\/routes['"],\s*['"]POST['"]/);
|
||
assert.doesNotMatch(ui, /activated_at['"],\s*['"]/);
|
||
assert.doesNotMatch(ui, /activatedAt:\s*null/);
|
||
assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/);
|
||
assert.match(ui, /\/pictures/);
|
||
assert.match(ui, /'picture'/);
|
||
assert.match(ui, /'audio'/);
|
||
assert.match(ui, /method:\s*'DELETE'/);
|
||
assert.match(ui, /sendForm\('\/routes',\s*'POST'/);
|
||
assert.match(ui, /serverRouteId/);
|
||
assert.match(ui, /position\?\.accuracy/);
|
||
assert.match(ui, /setHidden\(byId\(ids\.routeGpxGroup\), state\.currentSource !== 'server'\)/);
|
||
});
|
||
|
||
class FakeElement extends EventTarget {
|
||
constructor(id = '', tagName = 'div') {
|
||
super();
|
||
this.id = id;
|
||
this.tagName = tagName.toUpperCase();
|
||
this.hidden = false;
|
||
this.open = false;
|
||
this.value = '';
|
||
this.className = '';
|
||
this.children = [];
|
||
this.dataset = {};
|
||
this.files = [];
|
||
this.parentElement = null;
|
||
this.attributes = new Map();
|
||
this.disabled = false;
|
||
this.title = '';
|
||
this.type = '';
|
||
this._textContent = '';
|
||
this.clickCount = 0;
|
||
}
|
||
|
||
get textContent() {
|
||
return this._textContent;
|
||
}
|
||
|
||
set textContent(value) {
|
||
this._textContent = String(value ?? '');
|
||
if (this._textContent === '') this.children = [];
|
||
}
|
||
|
||
append(...children) {
|
||
children.forEach(child => this.appendChild(child));
|
||
}
|
||
|
||
appendChild(child) {
|
||
if (!child) return child;
|
||
child.parentElement = this;
|
||
this.children.push(child);
|
||
return child;
|
||
}
|
||
|
||
setAttribute(name, value) {
|
||
this.attributes.set(name, String(value));
|
||
if (name === 'open') this.open = true;
|
||
}
|
||
|
||
removeAttribute(name) {
|
||
this.attributes.delete(name);
|
||
if (name === 'open') this.open = false;
|
||
}
|
||
|
||
click() {
|
||
this.clickCount += 1;
|
||
}
|
||
|
||
showModal() {
|
||
this.open = true;
|
||
}
|
||
|
||
close() {
|
||
this.open = false;
|
||
}
|
||
|
||
closest(selector) {
|
||
if (selector === '[data-route-id]') return this.dataset.routeId ? this : this.parentElement?.closest(selector) ?? null;
|
||
if (selector === '[data-poi-id]') return this.dataset.poiId ? this : this.parentElement?.closest(selector) ?? null;
|
||
if (selector === '[data-editor-action]') return this.dataset.editorAction ? this : this.parentElement?.closest(selector) ?? null;
|
||
return null;
|
||
}
|
||
}
|
||
|
||
class FakeFormData {
|
||
constructor() {
|
||
this.entries = [];
|
||
}
|
||
|
||
append(name, value, filename) {
|
||
this.entries.push([name, value, filename]);
|
||
}
|
||
}
|
||
|
||
function jsonResponse(value, ok = true, status = 200) {
|
||
return {
|
||
ok,
|
||
status,
|
||
text: async () => JSON.stringify(value)
|
||
};
|
||
}
|
||
|
||
function pointerEvent(target = null, { pointerType = 'touch', button = 0, isPrimary = true } = {}) {
|
||
const event = new Event('pointerup', { cancelable: true });
|
||
Object.defineProperties(event, {
|
||
button: { value: button },
|
||
isPrimary: { value: isPrimary },
|
||
pointerType: { value: pointerType }
|
||
});
|
||
if (target) Object.defineProperty(event, 'target', { value: target });
|
||
return event;
|
||
}
|
||
|
||
function submitEvent() {
|
||
return new Event('submit', { cancelable: true });
|
||
}
|
||
|
||
function buildEditorContext() {
|
||
const controls = { failPicture: false, approved: false, recording: null };
|
||
const ids = [
|
||
'editor-add-poi',
|
||
'editor-button',
|
||
'editor-close',
|
||
'editor-dialog',
|
||
'editor-new-route',
|
||
'editor-poi-audio',
|
||
'editor-poi-audio-add',
|
||
'editor-poi-cancel',
|
||
'editor-poi-delete',
|
||
'editor-poi-description',
|
||
'editor-poi-form',
|
||
'editor-poi-form-title',
|
||
'editor-poi-form-view',
|
||
'editor-poi-id',
|
||
'editor-poi-images',
|
||
'editor-poi-images-add',
|
||
'editor-poi-lat',
|
||
'editor-poi-list',
|
||
'editor-poi-lon',
|
||
'editor-poi-media-preview',
|
||
'editor-poi-media-status',
|
||
'editor-poi-radius',
|
||
'editor-poi-title',
|
||
'editor-route-activated',
|
||
'editor-route-back',
|
||
'editor-route-description',
|
||
'editor-route-detail-view',
|
||
'editor-route-form',
|
||
'editor-route-gpx',
|
||
'editor-route-gpx-group',
|
||
'editor-route-id',
|
||
'editor-route-list',
|
||
'editor-route-list-view',
|
||
'editor-route-name',
|
||
'editor-route-upload',
|
||
'editor-rights-open',
|
||
'editor-status'
|
||
];
|
||
const elements = new Map(ids.map(id => [id, new FakeElement(id)]));
|
||
const document = new EventTarget();
|
||
document.readyState = 'complete';
|
||
document.getElementById = id => elements.get(id) ?? null;
|
||
document.createElement = tagName => new FakeElement('', tagName);
|
||
|
||
const calls = [];
|
||
const saves = [];
|
||
const packages = new Map([
|
||
['local-existing', {
|
||
id: 'local-existing',
|
||
gpx: '<gpx/>',
|
||
route: { id: 'local-existing', name: 'Lokaler Waldweg', description: 'Lokal' },
|
||
pois: []
|
||
}]
|
||
]);
|
||
|
||
const serverRoute = {
|
||
id: 1,
|
||
name: 'Freigegebener Waldweg',
|
||
description: 'Serverroute',
|
||
activatedAt: '2026-09-01 08:00:00',
|
||
pois: [{ id: 10, title: 'Start', description: 'Los', lat: 50.1, lon: 10.1, triggerRadiusM: 20, sequence: 0 }]
|
||
};
|
||
|
||
async function fetch(url, options = {}) {
|
||
const method = options.method || 'GET';
|
||
calls.push({ body: options.body, method, url });
|
||
if (controls.failPicture && url.endsWith('/pictures/24') && method === 'PUT') {
|
||
controls.failPicture = false;
|
||
return jsonResponse({ message: 'Testfehler' }, false, 500);
|
||
}
|
||
|
||
if (String(url).startsWith('data:')) {
|
||
return { ok: true, status: 200, blob: async () => new Blob(['media'], { type: 'application/octet-stream' }) };
|
||
}
|
||
if (url === '/api/routes?activatedOnly=true' && method === 'GET') return jsonResponse({ routes: [serverRoute] });
|
||
if (url === '/api/routes' && method === 'POST') return jsonResponse({ id: 22, name: 'Neue lokale Strecke', pois: [] }, true, 201);
|
||
if (url === '/api/routes/22/pois' && method === 'POST') return jsonResponse({ id: 23, title: 'Aussicht' }, true, 201);
|
||
if (/^\/api\/routes\/22\/pois\/23\/(audio|pictures)(?:\/24)?$/.test(url) && (method === 'POST' || method === 'PUT')) return jsonResponse({ id: 24 }, true, 201);
|
||
if (url === '/api/routes/22?includeDeleted=true' && method === 'GET') return jsonResponse({ id: 22, activatedAt: controls.approved ? '2026-10-03' : null, pois: [{ id: 23, audioUrl: '/audio', images: [{ id: 24 }] }] });
|
||
if (url === '/api/routes/22' && method === 'PUT') return jsonResponse({ id: 22 });
|
||
if (url === '/api/routes/22/pois/23' && method === 'PUT') return jsonResponse({ id: 23 });
|
||
if (/^\/api\/routes\/22\/pois\/23(?:\/audio|\/pictures\/24)?$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
|
||
if (url === '/api/routes/22' && method === 'DELETE') return jsonResponse({ deleted: true });
|
||
if (url === '/api/routes/1' && method === 'GET') return jsonResponse({ route: serverRoute });
|
||
if (url === '/api/routes/1' && method === 'DELETE') return jsonResponse({ deleted: true });
|
||
if (url === '/api/routes/1' && method === 'PUT') {
|
||
const body = JSON.parse(options.body);
|
||
return jsonResponse({ route: { ...serverRoute, ...body } });
|
||
}
|
||
if (url === '/api/routes/1/pois' && method === 'POST') return jsonResponse({ poi: { id: 12, title: 'Neue Serverstation' } });
|
||
if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && method === 'PUT') return jsonResponse({ poi: { id: Number(url.split('/').pop()), title: 'Station' } });
|
||
if (/^\/api\/routes\/1\/pois\/\d+\/(audio|pictures)$/.test(url) && (method === 'POST' || method === 'PUT')) return jsonResponse({ ok: true });
|
||
if (/^\/api\/routes\/1\/pois\/\d+\/audio$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
|
||
if (/^\/api\/routes\/1\/pois\/\d+\/pictures\/\d+$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
|
||
if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
|
||
return { ok: false, status: 404, text: async () => `unexpected ${method} ${url}` };
|
||
}
|
||
|
||
const mediaBlobs = new Map();
|
||
const storedMediaCalls = [];
|
||
const LocalRoutes = {
|
||
creatorId: () => "a".repeat(64),
|
||
createPoiId: () => 9001,
|
||
createRouteId: () => 'local-new',
|
||
emptyGpx: name => `<gpx><metadata><name>${name}</name></metadata><trk><trkseg></trkseg></trk></gpx>`,
|
||
fileToDataUrl: async file => ({
|
||
dataUrl: `data:${file.type || 'application/octet-stream'};base64,TEST`,
|
||
name: file.name,
|
||
size: file.size || 1,
|
||
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;
|
||
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(),
|
||
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 gpxPoints = pois
|
||
.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>${gpxPoints}</gpx>`,
|
||
route: { ...input.route, id, updatedAt: now },
|
||
pois
|
||
};
|
||
packages.set(id, saved);
|
||
saves.push(structuredClone(saved));
|
||
return saved;
|
||
}
|
||
};
|
||
|
||
const context = {
|
||
Blob,
|
||
document,
|
||
Event,
|
||
EventTarget,
|
||
fetch,
|
||
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/' },
|
||
LegalUi: { open: name => { controls.legalPage = name; } },
|
||
RouteRecorder: {
|
||
beginEditing(id) { controls.recording = id; },
|
||
endEditing() { controls.recording = null; },
|
||
renderPoints() {},
|
||
editGpx: gpx => gpx,
|
||
get routeId() { return controls.recording; },
|
||
get position() { return { lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }; }
|
||
},
|
||
LocalRoutes
|
||
}
|
||
};
|
||
context.globalThis = context;
|
||
return { calls, context, elements, packages, saves, storedMediaCalls, controls };
|
||
}
|
||
|
||
async function submittedEditor(response) {
|
||
const harness = buildEditorContext();
|
||
const { context, packages, calls, elements } = harness;
|
||
packages.set('local-existing', {
|
||
id: 'local-existing', gpx: '<gpx/>',
|
||
route: { id: 'local-existing', name: 'Waldweg', creator_id: 'a'.repeat(64),
|
||
serverRouteId: '99', uploadedAt: '2026-10-01T10:00:00Z' },
|
||
pois: [{ id: 7, title: 'Aussicht', description: 'Wiese', sequence: 0,
|
||
lat: 50, lon: 10, serverPoiId: 98, serverAudio: true,
|
||
audio: { name: 'ton.mp3', mediaKey: 'audio-key', type: 'audio/mpeg' },
|
||
images: [{ id: 'local-image', name: 'bild.png', mediaKey: 'picture-key',
|
||
type: 'image/png', serverPictureId: 97 }] }]
|
||
});
|
||
const originalFetch = context.fetch;
|
||
context.fetch = async (url, options = {}) => {
|
||
if (url === '/api/routes/99?includeDeleted=true') {
|
||
calls.push({ url, method: 'GET' });
|
||
return response();
|
||
}
|
||
return originalFetch(url, options);
|
||
};
|
||
await loadI18n(context);
|
||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
||
vm.runInNewContext(await fs.readFile(sourcePath, 'utf8'), context, { filename: sourcePath });
|
||
await context.Wegwichtel.EditorUi.open();
|
||
for (let i = 0; i < 4; i += 1) await tick();
|
||
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]));
|
||
for (let i = 0; i < 4; i += 1) await tick();
|
||
return harness;
|
||
}
|
||
|
||
async function submitDraft(harness) {
|
||
harness.elements.get('editor-route-upload').dispatchEvent(pointerEvent());
|
||
for (let i = 0; i < 12; i += 1) await tick();
|
||
}
|
||
|
||
test('removed and rejected submissions show their saved status and create fresh route and media IDs', async t => {
|
||
for (const status of ['removed', 'rejected']) {
|
||
await t.test(status, async () => {
|
||
const response = status === 'removed'
|
||
? () => jsonResponse({ message: 'Strecke nicht gefunden.' }, false, 404)
|
||
: () => jsonResponse({ id: 99, status: 'deleted', activatedAt: '2026-10-01', pois: [] });
|
||
const harness = await submittedEditor(response);
|
||
const { calls, packages, elements } = harness;
|
||
assert.equal(packages.get('local-existing').route.submissionStatus, status);
|
||
const expectedLabel = status === 'removed' ? 'Entfernt' : 'Abgelehnt';
|
||
const item = elements.get('editor-route-list').children.find(child => child.dataset.routeId === 'local-existing');
|
||
assert.ok(item.children[0].children.some(child => child.textContent === expectedLabel));
|
||
assert.match(elements.get('editor-route-activated').textContent, status === 'removed' ? /nicht mehr online/ : /abgelehnt/);
|
||
await submitDraft(harness);
|
||
const saved = packages.get('local-existing');
|
||
assert.equal(saved.route.serverRouteId, '22');
|
||
assert.equal(saved.route.submissionStatus, null);
|
||
assert.equal(saved.route.creator_id, 'a'.repeat(64));
|
||
assert.equal(saved.pois[0].serverPoiId, '23');
|
||
assert.equal(saved.pois[0].images[0].serverPictureId, 24);
|
||
assert.equal(saved.pois[0].images[0].mediaKey, 'picture-key');
|
||
assert.equal(saved.pois[0].audio.mediaKey, 'audio-key');
|
||
assert.equal(elements.get('editor-status').textContent, 'Die Strecke wurde neu angelegt und zur Prüfung gesendet.');
|
||
assert.deepEqual(calls.filter(call => call.method === 'POST').map(call => call.url), [
|
||
'/api/routes', '/api/routes/22/pois', '/api/routes/22/pois/23/audio', '/api/routes/22/pois/23/pictures'
|
||
]);
|
||
assert.equal(calls.some(call => call.method === 'PUT' || call.method === 'DELETE' || call.url.includes('/restore')), false);
|
||
await submitDraft(harness);
|
||
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes').length, 1);
|
||
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22'));
|
||
});
|
||
}
|
||
});
|
||
|
||
test('submission status refresh preserves unsaved form text and clears rejection after restoration', async () => {
|
||
let status = 'active';
|
||
const harness = await submittedEditor(() => jsonResponse({ id: 99, status }));
|
||
harness.elements.get('editor-route-name').value = 'Noch nicht gespeichert';
|
||
for (const serverStatus of ['deleted', 'active']) {
|
||
status = serverStatus;
|
||
await harness.context.Wegwichtel.EditorUi.refresh();
|
||
for (let i = 0; i < 4; i += 1) await tick();
|
||
assert.equal(harness.packages.get('local-existing').route.submissionStatus, status === 'deleted' ? 'rejected' : null);
|
||
assert.equal(harness.elements.get('editor-route-name').value, 'Noch nicht gespeichert');
|
||
}
|
||
});
|
||
|
||
test('authorization, server and network failures preserve the submitted IDs and never create a route', async t => {
|
||
for (const status of [401, 403, 500, 'offline']) {
|
||
await t.test(String(status), async () => {
|
||
const harness = await submittedEditor(() => {
|
||
if (status === 'offline') throw new TypeError('Failed to fetch');
|
||
return jsonResponse({ message: 'Testfehler' }, false, status);
|
||
});
|
||
await submitDraft(harness);
|
||
assert.equal(harness.calls.some(call => call.method !== 'GET'), false);
|
||
const saved = harness.packages.get('local-existing');
|
||
assert.equal(saved.route.serverRouteId, '99');
|
||
assert.equal(saved.pois[0].serverPoiId, 98);
|
||
assert.equal(saved.pois[0].images[0].serverPictureId, 97);
|
||
assert.equal(harness.elements.get('editor-route-upload').disabled, false);
|
||
});
|
||
}
|
||
});
|
||
|
||
test('failed fresh creation preserves local files and retries without obsolete server references', async () => {
|
||
const harness = await submittedEditor(() => jsonResponse({}, false, 404));
|
||
const originalFetch = harness.context.fetch;
|
||
let fail = true;
|
||
harness.context.fetch = async (url, options = {}) => {
|
||
if (fail && url === '/api/routes' && options.method === 'POST') {
|
||
fail = false;
|
||
return jsonResponse({ message: 'Testfehler' }, false, 500);
|
||
}
|
||
return originalFetch(url, options);
|
||
};
|
||
await submitDraft(harness);
|
||
const saved = harness.packages.get('local-existing');
|
||
assert.equal(saved.route.serverRouteId, null);
|
||
assert.equal(saved.route.uploadedAt, null);
|
||
assert.equal(saved.route.submissionStatus, 'removed');
|
||
assert.equal(saved.pois[0].serverPoiId, undefined);
|
||
assert.equal(saved.pois[0].serverAudio, undefined);
|
||
assert.equal(saved.pois[0].images[0].serverPictureId, undefined);
|
||
assert.equal(saved.pois[0].audio.mediaKey, 'audio-key');
|
||
await submitDraft(harness);
|
||
assert.equal(harness.packages.get('local-existing').route.serverRouteId, '22');
|
||
});
|
||
|
||
test('public editor workflow saves new route and GPX Station 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, controls } = buildEditorContext();
|
||
|
||
await loadI18n(context);
|
||
|
||
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
|
||
await loadI18n(context);
|
||
vm.runInNewContext(source, context, { filename: sourcePath });
|
||
|
||
await context.Wegwichtel.EditorUi.open();
|
||
assert.equal(elements.get('editor-dialog').open, true);
|
||
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'true');
|
||
assert.ok(calls.some(call => call.method === 'GET' && call.url === '/api/routes?activatedOnly=true'));
|
||
|
||
elements.get('editor-rights-open').dispatchEvent(pointerEvent());
|
||
assert.equal(controls.legalPage, 'terms');
|
||
|
||
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());
|
||
await tick();
|
||
await tick();
|
||
|
||
assert.ok(packages.has('local-new'));
|
||
assert.equal(saves.at(-1).route.name, 'Neue lokale Strecke');
|
||
assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false);
|
||
assert.match(elements.get('editor-route-activated').textContent, /Dieser Entwurf bleibt auf deinem Gerät/);
|
||
assert.equal(elements.get('editor-status').textContent, 'Gespeichert.');
|
||
assert.equal(controls.recording, 'local-new');
|
||
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, true);
|
||
|
||
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
||
assert.equal(elements.get('editor-poi-audio').clickCount, 0);
|
||
assert.equal(elements.get('editor-poi-images').clickCount, 0);
|
||
elements.get('editor-poi-audio-add').dispatchEvent(pointerEvent());
|
||
elements.get('editor-poi-images-add').dispatchEvent(pointerEvent());
|
||
assert.equal(elements.get('editor-poi-audio').clickCount, 1);
|
||
assert.equal(elements.get('editor-poi-images').clickCount, 1);
|
||
assert.equal(elements.get('editor-poi-lat').value, '50.123456789');
|
||
assert.equal(elements.get('editor-poi-lon').value, '10.987654321');
|
||
assert.equal(elements.get('editor-poi-radius').value, '4.321');
|
||
elements.get('editor-poi-title').value = 'Aussicht';
|
||
elements.get('editor-poi-description').value = 'Text zum Station';
|
||
elements.get('editor-poi-audio').files = [{ name: 'aussicht.mp3', size: 5, type: 'audio/mpeg' }];
|
||
elements.get('editor-poi-audio').dispatchEvent(new Event('change'));
|
||
elements.get('editor-poi-images').files = [{ name: 'aussicht.jpg', size: 7, type: 'image/jpeg' }];
|
||
elements.get('editor-poi-images').dispatchEvent(new Event('change'));
|
||
await tick();
|
||
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||
await tick();
|
||
await tick();
|
||
|
||
let local = packages.get('local-new');
|
||
assert.equal(local.pois.length, 1);
|
||
assert.equal(local.pois[0].title, 'Aussicht');
|
||
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.equal(local.pois[0].audio.storage, 'indexeddb');
|
||
assert.ok(local.pois[0].audio.mediaKey);
|
||
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, /<wpt lat="50\.123456789" lon="10\.987654321">/);
|
||
|
||
const pointListBeforeUpload = elements.get('editor-poi-list');
|
||
const pointItemBeforeUpload = pointListBeforeUpload.children.find(child => child.dataset.poiId);
|
||
assert.equal(pointItemBeforeUpload.children[1].children[3].textContent, '×');
|
||
assert.match(pointItemBeforeUpload.children[1].children[3].className, /editor-icon-danger/);
|
||
|
||
elements.get('editor-route-name').value = '--- Neue lokale Strecke ---';
|
||
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
|
||
await tick();
|
||
await tick();
|
||
await tick();
|
||
await tick();
|
||
|
||
const routeUpload = calls.find(call => call.method === 'POST' && call.url === '/api/routes');
|
||
assert.ok(routeUpload);
|
||
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(), /<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'));
|
||
local = packages.get('local-new');
|
||
assert.equal(local.route.serverRouteId, '22');
|
||
assert.ok(local.route.uploadedAt);
|
||
assert.equal(elements.get('editor-route-upload').disabled, false);
|
||
assert.equal(elements.get('editor-route-upload').textContent, 'Erneut zur Prüfung senden');
|
||
assert.equal(elements.get('editor-status').textContent, 'Zur Prüfung gesendet.');
|
||
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
|
||
for (let i = 0; i < 10; i += 1) await tick();
|
||
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes').length, 1);
|
||
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes/22/pois').length, 1);
|
||
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/pictures').length, 1);
|
||
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22'));
|
||
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22/pois/23/audio'));
|
||
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22/pois/23/pictures/24'));
|
||
controls.failPicture = true;
|
||
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
|
||
for (let i = 0; i < 10; i += 1) await tick();
|
||
assert.equal(packages.get('local-new').pois[0].serverPictureId, undefined);
|
||
assert.equal(packages.get('local-new').pois[0].images[0].serverPictureId, 24);
|
||
assert.equal(packages.get('local-new').route.serverRouteId, '22');
|
||
assert.equal(elements.get('editor-route-upload').disabled, false);
|
||
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
|
||
for (let i = 0; i < 10; i += 1) await tick();
|
||
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes').length, 1);
|
||
controls.approved = true;
|
||
const writesBeforeApproval = calls.filter(call => call.method !== 'GET').length;
|
||
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
|
||
for (let i = 0; i < 10; i += 1) await tick();
|
||
assert.equal(calls.filter(call => call.method !== 'GET').length, writesBeforeApproval);
|
||
controls.approved = false;
|
||
|
||
|
||
|
||
const poiList = elements.get('editor-poi-list');
|
||
const poiItem = poiList.children.find(child => child.dataset.poiId);
|
||
const poiEditButton = poiItem.children[1].children[2];
|
||
poiList.dispatchEvent(pointerEvent(poiEditButton));
|
||
const mediaPreview = elements.get('editor-poi-media-preview');
|
||
assert.equal(mediaPreview.children.length, 2);
|
||
|
||
let imageCard = mediaPreview.children.find(card => card.children[1]?.dataset.mediaKind === 'image');
|
||
mediaPreview.dispatchEvent(pointerEvent(imageCard.children[0]));
|
||
imageCard = mediaPreview.children.find(card => card.children[1]?.dataset.mediaKind === 'image');
|
||
assert.equal(imageCard.children[1].hidden, false);
|
||
mediaPreview.dispatchEvent(pointerEvent(imageCard.children[1]));
|
||
await tick();
|
||
await tick();
|
||
local = packages.get('local-new');
|
||
assert.equal(local.pois[0].images.length, 0);
|
||
|
||
let audioCard = elements.get('editor-poi-media-preview').children.find(card => card.children[1]?.dataset.mediaKind === 'audio');
|
||
elements.get('editor-poi-media-preview').dispatchEvent(pointerEvent(audioCard.children[0]));
|
||
audioCard = elements.get('editor-poi-media-preview').children.find(card => card.children[1]?.dataset.mediaKind === 'audio');
|
||
elements.get('editor-poi-media-preview').dispatchEvent(pointerEvent(audioCard.children[1]));
|
||
await tick();
|
||
await tick();
|
||
local = packages.get('local-new');
|
||
assert.equal(local.pois[0].audio, null);
|
||
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
|
||
for (let i = 0; i < 10; i += 1) await tick();
|
||
assert.ok(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/22/pois/23/audio'));
|
||
assert.ok(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/22/pois/23/pictures/24'));
|
||
|
||
|
||
const currentList = elements.get('editor-poi-list');
|
||
const currentItem = currentList.children.find(child => child.dataset.poiId);
|
||
currentList.dispatchEvent(pointerEvent(currentItem.children[1].children[2]));
|
||
assert.equal(elements.get('editor-poi-delete').hidden, false);
|
||
elements.get('editor-poi-delete').dispatchEvent(pointerEvent());
|
||
await tick();
|
||
await tick();
|
||
local = packages.get('local-new');
|
||
assert.equal(local.pois.length, 0);
|
||
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
|
||
for (let i = 0; i < 10; i += 1) await tick();
|
||
assert.ok(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/22/pois/23'));
|
||
|
||
|
||
context.Wegwichtel.EditorUi.close();
|
||
assert.equal(controls.recording, null);
|
||
assert.equal(elements.get('editor-dialog').open, false);
|
||
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
|
||
});
|
||
|
||
test('local Station ids start at zero and increment from the highest existing id', async () => {
|
||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
||
const source = await fs.readFile(sourcePath, 'utf8');
|
||
const { context, elements, packages } = buildEditorContext();
|
||
|
||
await loadI18n(context);
|
||
|
||
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
|
||
await loadI18n(context);
|
||
vm.runInNewContext(source, context, { filename: sourcePath });
|
||
await context.Wegwichtel.EditorUi.openNewLocalRoute();
|
||
elements.get('editor-route-name').value = 'ID-Test';
|
||
elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||
await tick();
|
||
await tick();
|
||
|
||
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
||
assert.equal(elements.get('editor-poi-id').value, '0');
|
||
elements.get('editor-poi-title').value = 'Erster Station';
|
||
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||
await tick();
|
||
await tick();
|
||
|
||
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
||
assert.equal(elements.get('editor-poi-id').value, '1');
|
||
elements.get('editor-poi-title').value = 'Zweiter Station';
|
||
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||
await tick();
|
||
await tick();
|
||
|
||
const saved = packages.get('local-new');
|
||
assert.equal(saved.pois.length, 2);
|
||
assert.deepEqual(Array.from(saved.pois, poi => poi.id), [0, 1]);
|
||
assert.deepEqual(Array.from(saved.pois, poi => poi.title), ['Erster Station', 'Zweiter Station']);
|
||
|
||
const pointItems = elements.get('editor-poi-list').children.filter(child => child.dataset.poiId);
|
||
assert.equal(pointItems.length, 2);
|
||
});
|
||
|
||
|
||
test('route and Station 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();
|
||
|
||
await loadI18n(context);
|
||
|
||
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
|
||
await loadI18n(context);
|
||
vm.runInNewContext(source, context, { filename: sourcePath });
|
||
await context.Wegwichtel.EditorUi.openNewLocalRoute();
|
||
elements.get('editor-route-name').value = 'Löschtest';
|
||
elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||
await tick();
|
||
await tick();
|
||
|
||
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
||
elements.get('editor-poi-title').value = 'Station zum Löschen';
|
||
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||
await tick();
|
||
await tick();
|
||
|
||
let pointList = elements.get('editor-poi-list');
|
||
let pointItem = pointList.children.find(child => child.dataset.poiId);
|
||
const pointDelete = pointItem.children[1].children[3];
|
||
assert.equal(pointDelete.textContent, '×');
|
||
assert.match(pointDelete.className, /editor-icon-danger/);
|
||
pointList.dispatchEvent(pointerEvent(pointDelete));
|
||
await tick();
|
||
await tick();
|
||
assert.equal(packages.get('local-new').pois.length, 0);
|
||
|
||
elements.get('editor-route-back').dispatchEvent(pointerEvent());
|
||
await tick();
|
||
await tick();
|
||
const routeList = elements.get('editor-route-list');
|
||
const localItem = routeList.children.find(child => child.dataset.routeId === 'local-new');
|
||
const routeDelete = localItem.children[1].children[1];
|
||
assert.equal(routeDelete.textContent, '×');
|
||
assert.match(routeDelete.className, /editor-icon-danger/);
|
||
routeList.dispatchEvent(pointerEvent(routeDelete));
|
||
await tick();
|
||
await tick();
|
||
assert.equal(packages.has('local-new'), false);
|
||
assert.equal(elements.get('editor-status').textContent, 'Strecke gelöscht.');
|
||
});
|
||
|
||
|
||
test('editing an activated server route uses PUT and never writes activation state', async () => {
|
||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
||
const source = await fs.readFile(sourcePath, 'utf8');
|
||
const { calls, context, elements } = buildEditorContext();
|
||
|
||
await loadI18n(context);
|
||
|
||
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
|
||
await loadI18n(context);
|
||
vm.runInNewContext(source, context, { filename: sourcePath });
|
||
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);
|
||
assert.equal(serverActions.children[0].dataset.editorAction, 'edit-route');
|
||
|
||
const forgedDelete = new FakeElement('', 'button');
|
||
forgedDelete.dataset.editorAction = 'delete-route';
|
||
serverActions.appendChild(forgedDelete);
|
||
routeList.dispatchEvent(pointerEvent(forgedDelete));
|
||
await tick();
|
||
await tick();
|
||
assert.equal(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/1'), false);
|
||
|
||
const editButton = serverActions.children[0];
|
||
routeList.dispatchEvent(pointerEvent(editButton));
|
||
await tick();
|
||
await tick();
|
||
|
||
assert.equal(elements.get('editor-route-id').value, '1');
|
||
assert.equal(elements.get('editor-route-gpx-group').hidden, false);
|
||
assert.match(elements.get('editor-route-activated').textContent, /für alle freigegeben/);
|
||
assert.equal(elements.get('editor-route-upload').hidden, true);
|
||
|
||
elements.get('editor-route-name').value = 'Freigegebener Waldweg geändert';
|
||
elements.get('editor-route-description').value = 'Bearbeitet';
|
||
elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||
await tick();
|
||
await tick();
|
||
|
||
const update = calls.find(call => call.method === 'PUT' && call.url === '/api/routes/1');
|
||
assert.ok(update);
|
||
const body = JSON.parse(update.body);
|
||
assert.deepEqual(Object.keys(body).sort(), ['creator_id', 'description', 'name']);
|
||
assert.equal('activated_at' in body, false);
|
||
assert.equal('activatedAt' in body, false);
|
||
assert.equal(elements.get('editor-status').textContent, 'Gespeichert.');
|
||
});
|
||
|
||
|
||
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];
|
||
};
|
||
|
||
await loadI18n(context);
|
||
|
||
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
|
||
await loadI18n(context);
|
||
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 = 'Station 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);
|
||
});
|
||
|
||
|
||
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');
|
||
});
|
||
|
||
test('shared drafts use guarded server saves, preserve the creator and retain unsaved input on conflicts', async () => {
|
||
const h = buildEditorContext();
|
||
const shared = { id: 44, name: 'Gemeinsamer Waldweg', description: '', creator_id: 'b'.repeat(64), pois: [] };
|
||
let conflict = false;
|
||
let refreshed = 0;
|
||
const originalFetch = h.context.fetch;
|
||
h.context.Wegwichtel.Groups = {
|
||
headersFor: path => path.startsWith('/routes/44') ? { 'If-Match': '3' } : {},
|
||
observe() {}, refresh() { refreshed += 1; }
|
||
};
|
||
h.context.fetch = async (url, options = {}) => {
|
||
if (url === '/api/routes/44') {
|
||
h.calls.push({ url, ...options, method: options.method || 'GET' });
|
||
if (options.method === 'PUT') {
|
||
if (conflict) return { ...jsonResponse({ message: 'Conflict' }, false, 409), headers: { get: () => '44' } };
|
||
Object.assign(shared, JSON.parse(options.body));
|
||
}
|
||
return jsonResponse(shared);
|
||
}
|
||
return originalFetch(url, options);
|
||
};
|
||
await loadI18n(h.context);
|
||
const messages = path.join(root, 'public/js/user-messages.js');
|
||
vm.runInNewContext(await fs.readFile(messages, 'utf8'), h.context, { filename: messages });
|
||
const filename = path.join(root, 'public/js/editor-ui.js');
|
||
vm.runInNewContext(await fs.readFile(filename, 'utf8'), h.context, { filename });
|
||
await h.context.Wegwichtel.EditorUi.openGroup(shared);
|
||
assert.equal(h.controls.recording, null);
|
||
assert.ok(refreshed > 0);
|
||
h.elements.get('editor-route-name').value = 'Gemeinsam geändert';
|
||
h.elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||
for (let i = 0; i < 6; i += 1) await tick();
|
||
const saved = h.calls.find(call => call.method === 'PUT' && call.url === '/api/routes/44');
|
||
assert.equal(saved.headers['If-Match'], '3');
|
||
assert.equal(JSON.parse(saved.body).creator_id, 'b'.repeat(64));
|
||
assert.equal(h.packages.has('44'), false);
|
||
assert.equal(shared.name, 'Gemeinsam geändert');
|
||
conflict = true;
|
||
h.elements.get('editor-route-name').value = 'Noch nicht gespeichert';
|
||
h.elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||
for (let i = 0; i < 6; i += 1) await tick();
|
||
assert.equal(shared.name, 'Gemeinsam geändert');
|
||
assert.equal(h.elements.get('editor-route-name').value, 'Noch nicht gespeichert');
|
||
assert.match(h.elements.get('editor-status').textContent, /inzwischen geändert/);
|
||
});
|
||
|
||
test('sharing a local draft asks before sending and opens a separate shared copy', async () => {
|
||
const h = buildEditorContext();
|
||
const sharedIds = [];
|
||
h.context.confirm = () => false;
|
||
h.context.Wegwichtel.Groups = {
|
||
shareButton(action) { const button = new FakeElement('', 'button'); button.dataset.editorAction = action; return button; },
|
||
async share(id) { sharedIds.push(id); }
|
||
};
|
||
await loadI18n(h.context);
|
||
const filename = path.join(root, 'public/js/editor-ui.js');
|
||
vm.runInNewContext(await fs.readFile(filename, 'utf8'), h.context, { filename });
|
||
await h.context.Wegwichtel.EditorUi.open();
|
||
const list = h.elements.get('editor-route-list');
|
||
const item = list.children.find(child => child.dataset.routeId === 'local-existing');
|
||
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
|
||
for (let i = 0; i < 4; i += 1) await tick();
|
||
assert.equal(h.calls.some(call => call.method === 'POST'), false);
|
||
h.context.confirm = () => true;
|
||
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
|
||
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
|
||
for (let i = 0; i < 14; i += 1) await tick();
|
||
assert.deepEqual(sharedIds, ['22']);
|
||
assert.equal(h.packages.get('local-existing').route.serverRouteId, '22');
|
||
assert.ok(h.calls.some(call => call.method === 'POST' && call.url === '/api/routes'));
|
||
});
|
||
|
||
test('shared station saves persist files before sending, retain lost-lock changes and release locks on exit', async () => {
|
||
const h = buildEditorContext();
|
||
const poi = { id: 8, title: 'Wiese', description: 'Blumen', lat: 50, lon: 10, sequence: 0, images: [], audioUrl: null };
|
||
const route = { id: 44, name: 'Gruppenweg', creator_id: 'a'.repeat(64), pois: [poi] };
|
||
const events = [], drafts = new Map();
|
||
let allowed = false;
|
||
h.context.Wegwichtel.GroupPoints = {
|
||
async begin() { events.push('lock'); return { poi }; },
|
||
async release() { events.push('release'); },
|
||
async read(id, poiId) { return drafts.get(String(poiId)); },
|
||
async remember(route, poiId, values, media) { const draft = { route, poiId, values, media }; events.push('backup'); drafts.set(String(poiId), structuredClone(draft)); return draft; },
|
||
async prepare() { events.push('prepare'); return allowed ? { poi } : false; },
|
||
async remove(id, poiId) { events.push('remove'); drafts.delete(String(poiId)); },
|
||
headersFor: () => ({ 'X-Poi-Lock': 'secret', 'If-Poi-Match': 'v1' }), observe() {},
|
||
async acceptNew() {}
|
||
};
|
||
const original = h.context.fetch;
|
||
h.context.fetch = async (url, options = {}) => {
|
||
if (url.startsWith('/api/routes/44')) {
|
||
h.calls.push({ url, ...options, method: options.method || 'GET' });
|
||
if (url === '/api/routes/44') return jsonResponse(route);
|
||
if (options.method === 'PUT') { events.push('send'); Object.assign(poi, JSON.parse(options.body)); return jsonResponse({ poi }); }
|
||
if (options.method === 'DELETE') { events.push('delete'); return jsonResponse({ deleted: true }); }
|
||
}
|
||
return original(url, options);
|
||
};
|
||
await loadI18n(h.context);
|
||
vm.runInNewContext(await fs.readFile('public/js/editor-ui.js', 'utf8'), h.context, { filename: path.resolve('public/js/editor-ui.js') });
|
||
await h.context.Wegwichtel.EditorUi.openGroup(route);
|
||
const list = h.elements.get('editor-poi-list');
|
||
list.dispatchEvent(pointerEvent(list.children[0].children[1].children[2])); await tick();
|
||
h.elements.get('editor-poi-title').value = 'Meine Blumen';
|
||
const file = new Blob(['photo'], { type: 'image/png' }); file.name = 'blume.png';
|
||
h.elements.get('editor-poi-images').files = [file];
|
||
h.elements.get('editor-poi-images').dispatchEvent(new Event('change')); await tick();
|
||
h.elements.get('editor-poi-form').dispatchEvent(submitEvent()); await tick();
|
||
assert.equal(events.includes('send'), false);
|
||
assert.equal(drafts.get('8').values.title, 'Meine Blumen');
|
||
assert.equal(drafts.get('8').media.images.length, 1);
|
||
assert.match(h.elements.get('editor-status').textContent, /auf diesem Gerät gesichert/);
|
||
// Remove the selected picture, then retry the same text edit with the lock held.
|
||
await h.context.Wegwichtel.EditorUi.openPendingPoint({ route, poiId: 8, basePoi: poi, values: poi, media: {} });
|
||
allowed = true;
|
||
h.elements.get('editor-poi-title').value = 'Gemeinsam gespeichert';
|
||
h.elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||
h.elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||
for (let i = 0; i < 6; i += 1) await tick();
|
||
assert.equal(events.filter(event => event === 'send').length, 1);
|
||
assert.ok(events.indexOf('backup') < events.indexOf('send'));
|
||
assert.equal(drafts.size, 0);
|
||
assert.equal(poi.title, 'Gemeinsam gespeichert');
|
||
const saved = h.calls.find(call => call.method === 'PUT');
|
||
assert.equal(saved.headers['X-Poi-Lock'], 'secret');
|
||
allowed = false;
|
||
const latestList = h.elements.get('editor-poi-list');
|
||
latestList.dispatchEvent(pointerEvent(latestList.children[0].children[1].children[3]));
|
||
for (let i = 0; i < 4; i += 1) await tick();
|
||
assert.equal(drafts.get('8').media.deletePoint, true);
|
||
assert.equal(events.includes('delete'), false);
|
||
allowed = true;
|
||
h.elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||
for (let i = 0; i < 5; i += 1) await tick();
|
||
assert.equal(events.includes('delete'), true);
|
||
assert.equal(drafts.size, 0);
|
||
h.context.Wegwichtel.EditorUi.close();
|
||
assert.equal(events.at(-1), 'release');
|
||
});
|