sonarqube fix
This commit is contained in:
@@ -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'));
|
||||
});
|
||||
Reference in New Issue
Block a user