Files
Wegwichtel/test/app-route-control-runtime.test.js
T
Florian Zumpe 171ea88d55
Sonarqube Scanner / Prepare environment (push) Successful in 8s
Sonarqube Scanner / Build and test (push) Failing after 22s
Sonarqube Scanner / SonarQube analysis (push) Skipped
Sonarqube Scanner / Deploy to Server (push) Successful in 6s
overhaul gpx creation and usage
2026-10-03 12:40:26 +02:00

643 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '..');
function tick() {
return new Promise(resolve => setTimeout(resolve, 0));
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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({ language = 'de-DE' } = {}) {
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 = [];
const frames = [];
const animationTimers = new Map();
let animationTimerId = 0;
function finishAnimations() {
while (frames.length) frames.shift()();
for (const [id, callback] of animationTimers) {
animationTimers.delete(id);
callback();
}
}
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(id) {
if (!animationTimers.delete(id)) clearTimeout(id);
},
history: {
state: null,
back() {},
pushState(state) { this.state = state; },
replaceState(state) { this.state = state; }
},
scrollTo() {},
requestAnimationFrame: callback => frames.push(callback),
setTimeout(callback, delay) {
if (delay !== 180) return setTimeout(callback, delay);
const id = --animationTimerId;
animationTimers.set(id, callback);
return id;
},
structuredClone,
globalThis: null
};
context.addEventListener = (name, handler) => harness.globalListeners.set(name, handler);
context.globalThis = context;
context.navigator = { language };
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 });
return {
apiCalls,
finishAnimations,
context,
editor,
harness,
savedStates,
serverRoute,
setLocalRoute(value) { localRoute = value; },
steps
};
}
test('English browser settings localize app controls and retain user-authored trail names', async () => {
const { context, harness } = await loadApp({ language: 'en-GB' });
await context.Wegwichtel.App.initialize({ markStep() {} });
assert.equal(harness.node('#route-control-server-title').textValue, 'Public trails (1)');
assert.equal(harness.node('#route-control-local-title').textValue, 'My drafts (1)');
assert.match(harness.node('#location-hint').textValue, /You can still choose from all trails/);
assert.ok(harness.node('#all-route-list').appended.join('').includes('Serverweg'));
assert.ok(harness.node('#local-route-list').appended.join('').includes('Lokaler Weg'));
});
test('route menu animates opening and closing and can reopen during closing', async () => {
const setup = await loadApp();
const { context, harness } = setup;
await context.Wegwichtel.App.initialize({ markStep() {} });
const button = new FakeNode('toggle');
const dropdown = harness.node('#route-control-dropdown');
await harness.activate('#route-control-button', button);
assert.equal(dropdown.properties.get('hidden'), false);
assert.equal(dropdown.classes.has('is-open'), false);
setup.finishAnimations();
assert.equal(dropdown.classes.has('is-open'), true);
await harness.activate('#route-control-button', button);
assert.equal(dropdown.classes.has('is-open'), false);
assert.equal(dropdown.properties.get('hidden'), false);
assert.equal(harness.node('#route-control-button').attributes.get('aria-expanded'), 'false');
await harness.activate('#route-control-button', button);
setup.finishAnimations();
assert.equal(dropdown.classes.has('is-open'), true);
assert.equal(dropdown.properties.get('hidden'), false);
await harness.activate('#route-control-button', button);
setup.finishAnimations();
assert.equal(dropdown.properties.get('hidden'), true);
await harness.activate('#route-control-button', button);
harness.fireDocumentKey('Escape');
setup.finishAnimations();
assert.equal(dropdown.properties.get('hidden'), true);
assert.equal(dropdown.classes.has('is-open'), false);
});
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 konnte nicht gefunden werden/);
assert.match(harness.node('#route-control-server-title').textValue, /Strecken für alle \(1\)/);
assert.match(harness.node('#route-control-local-title').textValue, /Meine Entwürfe \(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);
setup.finishAnimations();
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-title').textValue, 'Lokaler Weg');
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-title').textValue, 'Serverweg');
});
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-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('#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');
setup.finishAnimations();
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('Deine Entwürfe konnten nicht geöffnet werden'));
});
async function selectServer(setup) {
const button = new FakeNode('server-select');
button.dataValues.set('route-id', 'server-1');
await setup.harness.activate('[data-route-select]', button);
}
function observeSensors(setup) {
const calls = { stopped: 0, vibrations: 0, audio: [], slides: [] };
const ns = setup.context.Wegwichtel;
ns.Geo.start = (position, error) => { calls.position = position; calls.error = error; };
ns.Geo.stop = () => { calls.stopped += 1; };
ns.Orientation.start = async heading => { calls.heading = heading; return true; };
ns.Orientation.stop = () => { calls.stopped += 1; };
ns.Vibration.start = () => { calls.vibrations += 1; };
ns.AudioPlayer.load = url => calls.audio.push(url);
ns.Slideshow.show = images => calls.slides.push(images);
return calls;
}
test('tracking triggers each POI once, records media progress and pauses/resumes/stops sensors', async () => {
const setup = await loadApp();
const { context, harness, savedStates } = setup;
const calls = observeSensors(setup);
await context.Wegwichtel.App.initialize({ markStep() {} });
await selectServer(setup);
await harness.activate('#start-route', new FakeNode('start'));
assert.equal(harness.node('#pause-route').properties.get('disabled'), false);
const bearingTargets = [];
context.Wegwichtel.Distance.bearing = (position, target) => {
bearingTargets.push(target);
return target.title ? 45 : 120;
};
calls.position({ lat: 50, lon: 10, accuracy: 5, heading: 90, speed: 1 });
assert.equal(harness.node('#route-navigation-arrow').properties.get('hidden'), false);
assert.equal(harness.node('#navigation-arrow').properties.get('hidden'), false);
assert.notEqual(harness.node('#route-navigation-arrow').styles.get('transform'), harness.node('#navigation-arrow').styles.get('transform'));
assert.ok(bearingTargets.some(target => target.title));
assert.ok(bearingTargets.some(target => !target.title));
assert.equal(harness.node('#poi-title').textValue, 'Serverweg Start');
assert.equal(calls.vibrations, 1);
assert.match(harness.node('#navigation-mode').textValue, /Serverweg Ziel/);
calls.position({ lat: 50, lon: 10, accuracy: 5 });
assert.equal(calls.vibrations, 1);
harness.$('#poi-audio').trigger('play');
assert.deepEqual(Array.from(savedStates.at(-1)[5]), [1]);
calls.heading(350);
calls.heading(10);
assert.match(harness.node('#navigation-arrow').styles.get('transform'), /rotate\(50\.0deg\)/);
calls.error(new Error('Signal verloren'));
assert.equal(harness.node('#tracking-status').textValue, 'Dein Standort konnte nicht gefunden werden. Prüfe die Standort-Erlaubnis auf deinem Gerät.');
await harness.activate('#pause-route', new FakeNode('pause'));
assert.match(harness.node('#tracking-status').textValue, /Du machst eine Pause/);
assert.equal(savedStates.at(-1)[1], 'paused');
assert.equal(harness.node('#route-navigation-arrow').properties.get('hidden'), true);
assert.equal(harness.node('#navigation-arrow').properties.get('hidden'), true);
await harness.activate('#start-route', new FakeNode('resume'));
calls.position({ lat: 50.001, lon: 10.001, accuracy: 5 });
assert.equal(calls.vibrations, 2);
assert.equal(harness.node('#navigation-distance').textValue, '–');
assert.equal(harness.node('#route-navigation-mode').textValue, 'Alle Stationen und das Ziel erreicht');
await harness.activate('#stop-route', new FakeNode('stop'));
assert.equal(savedStates.at(-1)[1], 'idle');
assert.equal(harness.node('#navigation-footer').properties.get('hidden'), true);
assert.ok(calls.stopped >= 4);
});
test('saved route resumes paused, manual POI viewing and lifecycle events persist progress', async () => {
const setup = await loadApp();
const { context, harness, savedStates } = setup;
context.Wegwichtel.PrivacyStorage.activateRoute = () => ({
routeState: 'running', routeProgressIndex: 0,
visitedPoiIds: [1], viewedPoiIds: [1], playedAudioPoiIds: [1]
});
const calls = observeSensors(setup);
await context.Wegwichtel.App.initialize({ markStep() {} });
await selectServer(setup);
assert.match(harness.node('#tracking-status').textValue, /weitermachen/);
assert.equal(harness.node('#start-route').attributes.get('title'), 'Strecke fortsetzen');
const poi = new FakeNode('poi');
poi.dataValues.set('poi-id', 2);
await harness.key('[data-poi-select]', poi, ' ');
assert.equal(harness.node('#poi-title').textValue, 'Serverweg Ziel');
assert.deepEqual(Array.from(savedStates.at(-1)[4]), [1, 2]);
await harness.activate('#start-route', new FakeNode('resume'));
calls.position({ lat: 50, lon: 10, accuracy: 5 });
assert.equal(calls.vibrations, 0);
harness.globalListeners.get('pagehide')();
harness.document.visibilityState = 'hidden';
harness.document.dispatchEvent(new Event('visibilitychange'));
const enabled = new Event('wegwichtel:progress-storage-changed');
Object.defineProperty(enabled, 'detail', { value: { enabled: true } });
const before = savedStates.length;
harness.document.dispatchEvent(enabled);
assert.equal(savedStates.length, before + 1);
harness.globalListeners.get('popstate')({ state: { page: '#route-page' } });
assert.equal(harness.node('#route-page').attributes.has('hidden'), false);
await harness.activate('#stop-route', new FakeNode('stop'));
});
test('tracking handles an empty route and orientation permission resolving after pause', async () => {
const setup = await loadApp();
const { context, harness } = setup;
setup.serverRoute.pois = [];
setup.serverRoute.points = [];
const calls = observeSensors(setup);
let grant;
context.Wegwichtel.Orientation.start = () => new Promise(resolve => { grant = resolve; });
await context.Wegwichtel.App.initialize({ markStep() {} });
await selectServer(setup);
assert.match(harness.node('#poi-list').appended.join(''), /keine Stationen/);
await harness.activate('#start-route', new FakeNode('start'));
calls.position({ lat: 50, lon: 10, accuracy: 10 });
assert.equal(harness.node('#route-navigation-mode').textValue, 'Der Weg dieser Strecke fehlt.');
await harness.activate('#pause-route', new FakeNode('pause'));
const stopped = calls.stopped;
grant(true);
await tick();
assert.equal(calls.stopped, stopped + 1);
assert.equal(harness.node('#navigation-mode').textValue, 'Strecke pausiert');
await harness.activate('#stop-route', new FakeNode('stop'));
});
test('route recommendations and accent-insensitive search use location without changing selection', async () => {
const setup = await loadApp();
const { context, harness } = setup;
context.Wegwichtel.Geo.current = async () => ({ lat: 50, lon: 10, accuracy: 8 });
const far = route('far', 'Äpfelweg');
far.start = { lat: 51, lon: 10 };
context.Wegwichtel.Api.routes = async () => ({ routes: [far, setup.serverRoute, null, {}] });
await context.Wegwichtel.App.initialize({ markStep() {} });
assert.match(harness.node('#location-hint').textValue, /Standort wurde/);
assert.match(harness.node('#nearby-route-list').appended.join(''), /Serverweg/);
assert.doesNotMatch(harness.node('#nearby-route-list').appended.join(''), /Äpfelweg/);
const position = context.Wegwichtel.App.currentPosition();
position.lat = 0;
assert.equal(context.Wegwichtel.App.currentPosition().lat, 50);
harness.$('#route-search').val('apfel').trigger('input');
assert.match(harness.node('#all-routes-summary').textValue, /1 von 2/);
let suggestions;
const autocomplete = harness.node('#route-search').autocompleteOptions;
autocomplete.source({ term: 'apfel' }, items => { suggestions = items; });
assert.equal(suggestions[0].value, 'Äpfelweg');
autocomplete.select({}, { item: { value: 'Serverweg' } });
assert.match(harness.node('#all-route-list').appended.join(''), /Serverweg/);
harness.$('#route-search').val('unbekannt').trigger('input');
assert.match(harness.node('#all-route-list').appended.join(''), /Keine passende Strecke gefunden/);
await harness.activate('#clear-route-search', new FakeNode('clear'));
assert.equal(harness.node('#route-search').value, '');
});
test('invalid API responses reject initialization and route selection errors restore the button', async () => {
const setup = await loadApp();
const { context, harness } = setup;
context.Wegwichtel.Api.routes = async () => ({ routes: null });
await assert.rejects(context.Wegwichtel.App.initialize({ markStep() {} }), /Strecken konnten nicht geladen werden/);
const errors = [];
context.alert = message => errors.push(message);
context.Wegwichtel.Api.route = async () => { throw { responseJSON: { message: 'Strecke entfernt' } }; };
const button = new FakeNode('missing');
button.dataValues.set('route-id', 'missing');
await harness.activate('[data-route-select]', button, { isPrimary: false });
assert.equal(errors.length, 0);
await harness.activate('[data-route-select]', button, { pointerType: 'mouse', button: 2 });
assert.equal(errors.length, 0);
await harness.key('[data-route-select]', button, 'Escape');
assert.equal(errors.length, 0);
await harness.activate('[data-route-select]', button);
assert.deepEqual(errors, ['Strecke entfernt']);
assert.equal(button.properties.get('disabled'), false);
});