reworked app screens and added vibration

This commit is contained in:
Florian Zumpe
2026-06-16 17:30:55 +02:00
parent 40f230864b
commit 44b1700c48
13 changed files with 418 additions and 53 deletions
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
const read = relativePath => fs.readFile(path.join(root, relativePath), 'utf8');
test('complete REST API documentation is kept outside the README', async () => {
const readme = await read('README.md');
const api = await read('docs/REST-API.md');
assert.match(readme, /\[.*REST-API.*\]\(docs\/REST-API\.md\)/i);
assert.doesNotMatch(readme, /^## REST-API$/m);
assert.doesNotMatch(readme, /curl .*\/api\//);
for (const endpoint of [
'GET /api/health',
'GET /api/routes',
'GET /api/routes/:id',
'GET /api/routes/:id/pois',
'GET /api/pois/:id',
'POST /api/routes',
'PUT /api/routes/:id',
'POST /api/routes/:id/append',
'POST /api/routes/:id/pois',
'PUT /api/pois/:id',
'DELETE /api/routes/:id',
'POST /api/routes/:id/restore'
]) {
assert.ok(api.includes(endpoint), `missing API documentation: ${endpoint}`);
}
for (const parameter of [
'lat', 'lon', 'radiusKm', 'includeDeleted', 'gpx', 'name', 'slug',
'description', 'schoolName', 'title', 'triggerRadiusM', 'sequence',
'audio', 'images'
]) {
assert.match(api, new RegExp(`\\b${parameter}\\b`), `missing parameter documentation: ${parameter}`);
}
});
+59 -1
View File
@@ -60,7 +60,8 @@ test('mobile navigation footer contains a rotating SVG arrow and exact metre out
assert.match(html, /<footer id="navigation-footer"/);
assert.match(html, /<svg id="navigation-arrow"/);
assert.match(app, /Distance\.bearing/);
assert.match(app, /Math\.round\(distance\).*m/);
assert.match(app, /Math\.round\((?:metric\.distance|progress\.distance|distance)\)/);
assert.match(app, /`\$\{distance\} m`/);
assert.match(css, /#navigation-arrow[\s\S]*transform[\s\S]*transition/);
assert.match(css, /touch-action:\s*manipulation/);
});
@@ -103,3 +104,60 @@ test('route control SVG files are present', async () => {
assert.match(svg, /viewBox="0 0 48 48"/);
}
});
test('pause and stop control audio and stop returns to route selection', async () => {
const app = await read('public/js/app.js');
const audio = await read('public/js/audio-player.js');
assert.match(app, /function pauseRoute\(\)[\s\S]*ns\.AudioPlayer\.pause\(\)/);
assert.match(app, /function endRoute[\s\S]*ns\.AudioPlayer\.unload\(\)/);
assert.match(app, /endRoute\(\{ returnToRoutes: true \}\)/);
assert.match(app, /navigate\('#routes-page', \{ replace: true \}\)/);
assert.match(audio, /audio\.removeAttribute\('src'\)/);
assert.match(audio, /audio\.load\(\)/);
});
test('the selected or triggered station is highlighted semantically', async () => {
const app = await read('public/js/app.js');
const css = await read('public/css/app.css');
assert.match(app, /setActivePoi\(poi\.id\)/);
assert.match(app, /attr\('aria-current', 'step'\)/);
assert.match(css, /#poi-list button\[aria-current="step"\]/);
assert.match(css, /aktuelle Station/);
});
test('POIs have live direction and metre indicators and take navigation priority', async () => {
const app = await read('public/js/app.js');
const css = await read('public/css/app.css');
const html = await read('public/index.html');
assert.match(app, /class="poi-proximity"/);
assert.match(app, /function updatePoiIndicators\(position\)/);
assert.match(app, /function nearestPendingPoi\(metrics\)/);
assert.match(app, /if \(nextPoi\) showPoiNavigation\(position, nextPoi\)/);
assert.match(app, /Nächster POI:/);
assert.match(app, /find\('small'\)\.text\(`\$\{distance\} m`\)/);
assert.match(app, /find\('svg'\)\.css\('transform'/);
assert.match(css, /\.poi-proximity[\s\S]*grid-column:\s*2/);
assert.match(css, /\.poi-proximity svg[\s\S]*transition:\s*transform/);
assert.match(html, /Richtung zum nächsten POI/);
});
test('reaching a POI preloads audio without autoplay and uses the vibration wrapper', async () => {
const app = await read('public/js/app.js');
const audio = await read('public/js/audio-player.js');
const html = await read('public/index.html');
const loader = await read('public/js/bootstrap-loader.js');
assert.match(app, /ns\.AudioPlayer\.load\(poi\.audioUrl\)/);
assert.doesNotMatch(app, /AudioPlayer\.play\(/);
assert.match(app, /ns\.Vibration\.start\(ns\.Vibration\.Patterns\.ACTIVE_POI\)/);
assert.doesNotMatch(app, /navigator\.vibrate/);
assert.match(app, /function stopRouteSensors\(\)[\s\S]*ns\.Vibration\.stop\(\)/);
assert.match(audio, /audio\.load\(\)/);
assert.match(html, /<audio id="poi-audio" controls preload="auto" hidden>/);
assert.match(loader, /'vibration'/);
});
+16 -10
View File
@@ -25,16 +25,22 @@ test('default server socket uses the local Wegwichtel endpoint', async () => {
assert.doesNotMatch(apiSource, /instance:/);
});
test('retired internal instance label is absent from the project', async () => {
const files = [
'.env.example',
'README.md',
'server.js',
'src/config.js',
'src/routes/api.js',
'public/index.html'
];
const content = (await Promise.all(files.map(read))).join('\n');
test('retired internal instance label is absent from authored project files', async () => {
const extensions = new Set(['.js', '.json', '.md', '.html', '.css', '.env', '.sql', '.gpx', '.svg']);
const ignoredDirectories = new Set(['node_modules', 'vendor', '.git']);
const files = [];
async function collect(directory) {
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue;
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) await collect(absolute);
else if (extensions.has(path.extname(entry.name)) || entry.name === '.env.example') files.push(absolute);
}
}
await collect(root);
const content = (await Promise.all(files.map(file => fs.readFile(file, 'utf8')))).join('\n');
const retiredLabel = ['at', 'las'].join('');
assert.doesNotMatch(content, new RegExp(retiredLabel, 'i'));
});
+61
View File
@@ -0,0 +1,61 @@
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 source() {
return fs.readFile(path.join(root, 'public/js/vibration.js'), 'utf8');
}
test('legacy vibration patterns are exposed as immutable constants', async () => {
const calls = [];
const context = {
navigator: { vibrate: pattern => { calls.push(pattern); return true; } },
window: { Wegwichtel: {} }
};
vm.runInNewContext(await source(), context);
const vibration = context.window.Wegwichtel.Vibration;
assert.equal(vibration.Patterns.MICRO, 50);
assert.equal(vibration.Patterns.MINI, 100);
assert.equal(vibration.Patterns.SHORT, 200);
assert.equal(vibration.Patterns.LONG, 2000);
assert.deepEqual(Array.from(vibration.Patterns.ALARM), [100, 50, 100]);
assert.deepEqual(Array.from(vibration.Patterns.POI), [2000, 200, 2000]);
assert.deepEqual(Array.from(vibration.Patterns.ACTIVE_POI), [500, 300, 500]);
assert.equal(Object.isFrozen(vibration.Patterns), true);
});
test('vibration wrapper starts named patterns and can cancel them', async () => {
const calls = [];
const context = {
navigator: { vibrate: pattern => { calls.push(pattern); return true; } },
window: { Wegwichtel: {} }
};
vm.runInNewContext(await source(), context);
const vibration = context.window.Wegwichtel.Vibration;
assert.equal(vibration.start(vibration.Patterns.ACTIVE_POI), true);
assert.equal(vibration.stop(), true);
assert.deepEqual(Array.from(calls[0]), [500, 300, 500]);
assert.equal(calls[1], 0);
});
test('vibration wrapper fails harmlessly when the browser API is unavailable', async () => {
const context = {
navigator: {},
window: { Wegwichtel: {} }
};
vm.runInNewContext(await source(), context);
const vibration = context.window.Wegwichtel.Vibration;
assert.equal(vibration.isSupported(), false);
assert.equal(vibration.start(vibration.Patterns.SHORT), false);
assert.equal(vibration.stop(), false);
});