reworked app screens and added vibration
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user