diff --git a/test/app-route-control-runtime.test.js b/test/app-route-control-runtime.test.js
new file mode 100644
index 0000000..54b18e0
--- /dev/null
+++ b/test/app-route-control-runtime.test.js
@@ -0,0 +1,404 @@
+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, '..');
+
+function tick() {
+ return new Promise(resolve => setTimeout(resolve, 0));
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+class FakeNode {
+ constructor(selector = '') {
+ this.selector = selector;
+ this.properties = new Map();
+ this.attributes = new Map();
+ this.dataValues = new Map();
+ this.styles = new Map();
+ this.classes = new Set();
+ this.appended = [];
+ this.textValue = '';
+ this.value = '';
+ this.autocompleteOptions = null;
+ this.handlers = new Map();
+ }
+}
+
+class FakeJQuery {
+ constructor(nodes, harness) {
+ this.nodes = nodes;
+ this.harness = harness;
+ this.length = nodes.length;
+ }
+
+ _each(callback) {
+ this.nodes.forEach(callback);
+ return this;
+ }
+
+ text(value) {
+ if (value === undefined) return this.nodes[0]?.textValue ?? '';
+ return this._each(node => { node.textValue = String(value); });
+ }
+
+ html(value) {
+ if (value === undefined) return escapeHtml(this.nodes[0]?.textValue ?? '');
+ return this._each(node => { node.textValue = String(value); });
+ }
+
+ prop(name, value) {
+ if (value === undefined) return this.nodes[0]?.properties.get(name);
+ return this._each(node => node.properties.set(name, value));
+ }
+
+ attr(name, value) {
+ if (typeof name === 'object' && name !== null) {
+ return this._each(node => {
+ Object.entries(name).forEach(([key, item]) => node.attributes.set(key, String(item)));
+ });
+ }
+ if (value === undefined) return this.nodes[0]?.attributes.get(name);
+ return this._each(node => node.attributes.set(name, String(value)));
+ }
+
+ removeAttr(name) {
+ return this._each(node => node.attributes.delete(name));
+ }
+
+ addClass(name) {
+ return this._each(node => node.classes.add(name));
+ }
+
+ removeClass(name) {
+ return this._each(node => node.classes.delete(name));
+ }
+
+ css(name, value) {
+ if (value === undefined) return this.nodes[0]?.styles.get(name);
+ return this._each(node => node.styles.set(name, value));
+ }
+
+ empty() {
+ return this._each(node => { node.appended = []; });
+ }
+
+ append(value) {
+ return this._each(node => node.appended.push(String(value)));
+ }
+
+ val(value) {
+ if (value === undefined) return this.nodes[0]?.value ?? '';
+ return this._each(node => { node.value = String(value); });
+ }
+
+ data(name, value) {
+ if (value === undefined) return this.nodes[0]?.dataValues.get(name);
+ return this._each(node => node.dataValues.set(name, value));
+ }
+
+ on(eventName, selector, handler) {
+ if (typeof selector === 'function') {
+ this.nodes.forEach(node => {
+ if (!node.handlers.has(eventName)) node.handlers.set(eventName, []);
+ node.handlers.get(eventName).push(selector);
+ });
+ return this;
+ }
+ this.harness.delegated.set(`${eventName}:${selector}`, handler);
+ return this;
+ }
+
+ trigger(eventName) {
+ this.nodes.forEach(node => {
+ for (const handler of node.handlers.get(eventName) || []) {
+ handler.call(node, { type: eventName });
+ }
+ });
+ const delegated = this.harness.delegated.get(`${eventName}:${this.nodes[0]?.selector}`);
+ if (delegated) delegated.call(this.nodes[0], { type: eventName, preventDefault() {} });
+ return this;
+ }
+
+ autocomplete(options) {
+ return this._each(node => { node.autocompleteOptions = options; });
+ }
+
+ find(selector) {
+ return this.harness.$(`${this.nodes[0]?.selector || ''} ${selector}`);
+ }
+
+ closest(selector) {
+ const node = this.nodes[0];
+ if (!node) return new FakeJQuery([], this.harness);
+ if (selector === '.route-control' && node.classes.has('route-control')) return this;
+ return new FakeJQuery([], this.harness);
+ }
+}
+
+function createHarness() {
+ const document = new EventTarget();
+ Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true });
+ const nodes = new Map();
+ const delegated = new Map();
+ const documentNode = new FakeNode('document');
+ const globalListeners = new Map();
+
+ const harness = {
+ delegated,
+ document,
+ globalListeners,
+ node(selector) {
+ if (!nodes.has(selector)) nodes.set(selector, new FakeNode(selector));
+ return nodes.get(selector);
+ },
+ $(selector) {
+ if (selector === document) return new FakeJQuery([documentNode], harness);
+ if (selector instanceof FakeNode) return new FakeJQuery([selector], harness);
+ if (typeof selector === 'string' && selector.startsWith('<')) {
+ return new FakeJQuery([new FakeNode(selector)], harness);
+ }
+ return new FakeJQuery([harness.node(String(selector))], harness);
+ },
+ async activate(selector, target, pointer = { isPrimary: true, pointerType: 'touch', button: 0 }) {
+ const handler = delegated.get(`pointerup:${selector}`);
+ assert.ok(handler, `missing pointer handler for ${selector}`);
+ const event = {
+ originalEvent: pointer,
+ preventDefault() {}
+ };
+ handler.call(target, event);
+ await tick();
+ await tick();
+ },
+ async key(selector, target, key) {
+ const handler = delegated.get(`keydown:${selector}`);
+ assert.ok(handler, `missing key handler for ${selector}`);
+ handler.call(target, { key, preventDefault() {} });
+ await tick();
+ await tick();
+ },
+ fireDocumentPointer(target) {
+ const handlers = documentNode.handlers.get('pointerup') || [];
+ handlers.forEach(handler => handler({ target }));
+ },
+ fireDocumentKey(key) {
+ const handlers = documentNode.handlers.get('keydown') || [];
+ handlers.forEach(handler => handler({ key }));
+ }
+ };
+
+ return harness;
+}
+
+function route(id, name, source = 'server') {
+ return {
+ id,
+ name,
+ source,
+ local: source === 'local',
+ schoolName: source === 'local' ? 'Lokal gespeichert' : 'Grundschule Herpf',
+ description: `${name} Beschreibung`,
+ start: { lat: 50, lon: 10 },
+ distanceM: 1000,
+ elevationGainM: 15,
+ points: [
+ { lat: 50, lon: 10 },
+ { lat: 50.001, lon: 10.001 }
+ ],
+ pois: [
+ { id: 1, title: `${name} Start`, description: 'Los', lat: 50, lon: 10, sequence: 0, triggerRadiusM: 25, images: [], audioUrl: null },
+ { id: 2, title: `${name} Ziel`, description: 'Ziel', lat: 50.001, lon: 10.001, sequence: 1, triggerRadiusM: 25, images: [], audioUrl: null }
+ ]
+ };
+}
+
+async function loadApp() {
+ const sourcePath = path.join(root, 'public/js/app.js');
+ const source = await fs.readFile(sourcePath, 'utf8');
+ const harness = createHarness();
+ const serverRoute = route('server-1', 'Serverweg');
+ let localRoute = route('local-1', 'Lokaler Weg', 'local');
+ const steps = [];
+ const savedStates = [];
+ const editor = { opened: 0, refreshed: 0 };
+ const apiCalls = [];
+
+ harness.node('#route-control-dropdown').properties.set('hidden', true);
+ harness.node('#route-search').value = '';
+
+ const context = {
+ document: harness.document,
+ jQuery: harness.$,
+ Wegwichtel: {
+ Api: {
+ async health() { apiCalls.push('health'); return { ok: true }; },
+ async routes() { apiCalls.push('routes'); return { routes: [serverRoute] }; },
+ async route(id) { apiCalls.push(`route:${id}`); return serverRoute; }
+ },
+ AudioPlayer: { load() {}, pause() {}, unload() {} },
+ Config: { routeRadiusKm: 5 },
+ Distance: {
+ bearing() { return 45; },
+ format(value) { return `${Math.round(value)} m`; },
+ meters(left, right) {
+ return Math.hypot((right.lat - left.lat) * 111000, (right.lon - left.lon) * 70000);
+ },
+ normalizeDegrees(value) { return ((value % 360) + 360) % 360; }
+ },
+ EditorUi: {
+ openNewLocalRoute() { editor.opened += 1; },
+ refresh() { editor.refreshed += 1; }
+ },
+ Geo: {
+ async current() { throw new Error('GPS verweigert'); },
+ start() {},
+ stop() {}
+ },
+ LocalRoutes: {
+ getAppRoute(id) {
+ if (String(id) !== 'local-1' || !localRoute) throw new Error('lokal nicht gefunden');
+ return structuredClone(localRoute);
+ },
+ listAppRoutes() {
+ return localRoute ? [structuredClone(localRoute)] : [];
+ }
+ },
+ Orientation: { async start() { return false; }, stop() {} },
+ PrivacyStorage: {
+ activateRoute() { return null; },
+ saveRouteState(...args) { savedStates.push(args); }
+ },
+ Slideshow: { show() {} },
+ Vibration: { Patterns: { ACTIVE_POI: [100] }, start() {}, stop() {} }
+ },
+ alert() {},
+ clearTimeout,
+ history: {
+ state: null,
+ back() {},
+ pushState(state) { this.state = state; },
+ replaceState(state) { this.state = state; }
+ },
+ scrollTo() {},
+ setTimeout,
+ structuredClone,
+ globalThis: null
+ };
+
+ context.addEventListener = (name, handler) => harness.globalListeners.set(name, handler);
+ context.globalThis = context;
+
+ vm.runInNewContext(source, context, { filename: sourcePath });
+
+ return {
+ apiCalls,
+ context,
+ editor,
+ harness,
+ savedStates,
+ serverRoute,
+ setLocalRoute(value) { localRoute = value; },
+ steps
+ };
+}
+
+test('route control runtime loads server/local routes and supports switching without geolocation', async () => {
+ const setup = await loadApp();
+ const { context, harness, apiCalls, editor } = setup;
+ const initContext = { markStep(name, value) { setup.steps.push([name, value]); } };
+
+ await context.Wegwichtel.App.initialize(initContext);
+
+ assert.deepEqual(apiCalls.slice(0, 2), ['health', 'routes']);
+ assert.deepEqual(setup.steps, [['server', 'done'], ['routes', 'done'], ['location', 'done']]);
+ assert.match(harness.node('#location-hint').textValue, /Standort nicht verfügbar/);
+ assert.match(harness.node('#route-control-server-title').textValue, /Aktive Strecken \(1\)/);
+ assert.match(harness.node('#route-control-local-title').textValue, /Lokale Strecken \(1\)/);
+ assert.ok(harness.node('#all-route-list').appended.join('').includes('Serverweg'));
+ assert.ok(harness.node('#local-route-list').appended.join('').includes('Lokaler Weg'));
+
+ const controlButton = new FakeNode('#route-control-button');
+ await harness.activate('#route-control-button', controlButton);
+ assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), false);
+ assert.equal(harness.node('#route-control-button').attributes.get('aria-expanded'), 'true');
+
+ const newButton = new FakeNode('#route-control-new');
+ await harness.activate('.route-control-actions #route-control-new', newButton);
+ assert.equal(editor.opened, 1);
+ assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), true);
+
+ const localButton = new FakeNode('local-select');
+ localButton.dataValues.set('route-source', 'local');
+ localButton.dataValues.set('route-id', 'local-1');
+ await harness.activate('[data-route-select]', localButton);
+
+ assert.equal(harness.node('#route-control-title').textValue, 'Lokaler Weg');
+ assert.match(harness.node('#route-control-next').textValue, /Nächster POI: Lokaler Weg Start/);
+ assert.equal(harness.node('#route-page').attributes.has('hidden'), false);
+
+ const serverButton = new FakeNode('server-select');
+ serverButton.dataValues.set('route-source', 'server');
+ serverButton.dataValues.set('route-id', 'server-1');
+ await harness.key('[data-route-select]', serverButton, 'Enter');
+
+ assert.ok(apiCalls.includes('route:server-1'));
+ assert.equal(harness.node('#route-control-title').textValue, 'Serverweg');
+ assert.match(harness.node('#route-control-next').textValue, /Nächster POI: Serverweg Start/);
+});
+
+test('local-route change refreshes an active draft and handles its later removal', async () => {
+ const setup = await loadApp();
+ const { context, harness, editor } = setup;
+ await context.Wegwichtel.App.initialize({ markStep() {} });
+
+ const localButton = new FakeNode('local-select');
+ localButton.dataValues.set('route-source', 'local');
+ localButton.dataValues.set('route-id', 'local-1');
+ await harness.activate('[data-route-select]', localButton);
+
+ const changed = route('local-1', 'Lokaler Weg geändert', 'local');
+ setup.setLocalRoute(changed);
+ const updateEvent = new Event('wegwichtel:local-routes-changed');
+ Object.defineProperty(updateEvent, 'detail', { value: { id: 'local-1' } });
+ harness.document.dispatchEvent(updateEvent);
+ await tick();
+
+ assert.equal(harness.node('#route-control-title').textValue, 'Lokaler Weg geändert');
+ assert.equal(editor.refreshed, 1);
+
+ setup.setLocalRoute(null);
+ const removeEvent = new Event('wegwichtel:local-routes-changed');
+ Object.defineProperty(removeEvent, 'detail', { value: { id: 'local-1' } });
+ harness.document.dispatchEvent(removeEvent);
+ await tick();
+
+ assert.equal(harness.node('#route-control-title').textValue, 'Route wählen');
+ assert.equal(harness.node('#routes-page').attributes.has('hidden'), false);
+ assert.equal(editor.refreshed, 2);
+});
+
+test('route control closes on escape and reports local storage failures', async () => {
+ const setup = await loadApp();
+ const { context, harness } = setup;
+ await context.Wegwichtel.App.initialize({ markStep() {} });
+
+ await harness.activate('#route-control-button', new FakeNode('toggle'));
+ assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), false);
+ harness.fireDocumentKey('Escape');
+ assert.equal(harness.node('#route-control-dropdown').properties.get('hidden'), true);
+
+ context.Wegwichtel.LocalRoutes.listAppRoutes = () => { throw new Error('LocalStorage nicht lesbar'); };
+ harness.globalListeners.get('pageshow')?.({});
+ assert.ok(harness.node('#route-control-local-list').appended.join('').includes('LocalStorage nicht lesbar'));
+});
diff --git a/test/editor-ui.test.js b/test/editor-ui.test.js
index b136fd8..17dabcd 100644
--- a/test/editor-ui.test.js
+++ b/test/editor-ui.test.js
@@ -19,66 +19,31 @@ test('editor markup and source describe local draft creation instead of immediat
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 verwalten"/);
- assert.match(html, /id="route-control-button"[\s\S]*?
]+src="images\/icons\/route-management\.svg"[^>]+alt=""[^>]*>/);
- assert.doesNotMatch(html, /class="editor-button-icon"/);
+ assert.match(html, /id="editor-button"/);
assert.match(html, /