added privacy switches and documents, assets and local storage for already visited POIs
This commit is contained in:
Vendored
+165
@@ -0,0 +1,165 @@
|
||||
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, '..');
|
||||
|
||||
class ClassList {
|
||||
constructor() {
|
||||
this.values = new Set();
|
||||
}
|
||||
|
||||
add(...names) {
|
||||
names.forEach(name => this.values.add(name));
|
||||
}
|
||||
|
||||
remove(...names) {
|
||||
names.forEach(name => this.values.delete(name));
|
||||
}
|
||||
|
||||
contains(name) {
|
||||
return this.values.has(name);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeElement extends EventTarget {
|
||||
constructor(id = '') {
|
||||
super();
|
||||
this.id = id;
|
||||
this.hidden = false;
|
||||
this.value = 0;
|
||||
this.textContent = '';
|
||||
this.src = '';
|
||||
this.async = true;
|
||||
this.classList = new ClassList();
|
||||
this.attributes = new Map();
|
||||
this.focused = false;
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.focused = true;
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
}
|
||||
|
||||
closest() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pointerEvent() {
|
||||
const event = new Event('pointerup', { cancelable: true });
|
||||
Object.defineProperties(event, {
|
||||
isPrimary: { value: true },
|
||||
pointerType: { value: 'touch' },
|
||||
button: { value: 0 }
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
test('bootstrap remains visible after loading and opens the app only after deliberate activation', async () => {
|
||||
const sourcePath = path.join(root, 'public/js/bootstrap-loader.js');
|
||||
const source = await fs.readFile(sourcePath, 'utf8');
|
||||
const ids = [
|
||||
'bootstrap-screen',
|
||||
'bootstrap-progress',
|
||||
'bootstrap-error',
|
||||
'bootstrap-retry',
|
||||
'bootstrap-enter',
|
||||
'bootstrap-ready',
|
||||
'storage-cleanup-status',
|
||||
'app-shell'
|
||||
];
|
||||
const elements = new Map(ids.map(id => [id, new FakeElement(id)]));
|
||||
const steps = new Map([
|
||||
'jquery',
|
||||
'jquery-ui',
|
||||
'modules',
|
||||
'server',
|
||||
'routes',
|
||||
'location'
|
||||
].map(name => [name, new FakeElement(name)]));
|
||||
const navigations = [];
|
||||
|
||||
function jquery() {
|
||||
return {
|
||||
button() {
|
||||
return this;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const context = {
|
||||
Wegwichtel: {
|
||||
PrivacyStorage: {
|
||||
async cleanupExpired(callback) {
|
||||
callback(0, 1);
|
||||
callback(1, 1);
|
||||
return { removed: 1, total: 1 };
|
||||
}
|
||||
},
|
||||
App: {
|
||||
async initialize({ markStep }) {
|
||||
markStep('server', 'done');
|
||||
markStep('routes', 'done');
|
||||
markStep('location', 'done');
|
||||
},
|
||||
navigate(...args) {
|
||||
navigations.push(args);
|
||||
}
|
||||
}
|
||||
},
|
||||
document: {
|
||||
getElementById: id => elements.get(id),
|
||||
querySelector(selector) {
|
||||
const match = selector.match(/^\[data-step="(.+)"\]$/);
|
||||
return match ? steps.get(match[1]) : null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '#bootstrap-steps .done') {
|
||||
return [...steps.values()].filter(step => step.classList.contains('done'));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
createElement: () => new FakeElement('script'),
|
||||
head: {
|
||||
appendChild(script) {
|
||||
if (script.src.includes('/jquery-4.0.0.min.js')) {
|
||||
jquery.fn = { jquery: '4.0.0' };
|
||||
context.jQuery = jquery;
|
||||
}
|
||||
if (script.src.includes('/jquery-ui-1.14.2.min.js')) {
|
||||
jquery.ui = { version: '1.14.2' };
|
||||
}
|
||||
queueMicrotask(() => script.onload());
|
||||
}
|
||||
}
|
||||
},
|
||||
Event,
|
||||
EventTarget,
|
||||
Promise,
|
||||
console,
|
||||
encodeURIComponent,
|
||||
queueMicrotask,
|
||||
location: { reload() {} }
|
||||
};
|
||||
context.globalThis = context;
|
||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
assert.equal(elements.get('bootstrap-screen').hidden, false);
|
||||
assert.equal(elements.get('bootstrap-screen').attributes.get('aria-busy'), 'false');
|
||||
assert.equal(elements.get('bootstrap-enter').hidden, false);
|
||||
assert.equal(elements.get('storage-cleanup-status').textContent, 'Entferne alte Streckendaten aus dem Speicher (1 / 1)');
|
||||
assert.equal(navigations.length, 0);
|
||||
|
||||
elements.get('bootstrap-screen').dispatchEvent(pointerEvent());
|
||||
assert.equal(elements.get('bootstrap-screen').hidden, true);
|
||||
assert.equal(elements.get('app-shell').hidden, false);
|
||||
assert.equal(navigations[0][0], '#routes-page');
|
||||
assert.equal(navigations[0][1].replace, true);
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
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 read(relativePath) {
|
||||
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('start screen exposes cookie status, optional local progress and deliberate entry', async () => {
|
||||
const html = await read('public/index.html');
|
||||
const loader = await read('public/js/bootstrap-loader.js');
|
||||
const storage = await read('public/js/privacy-storage.js');
|
||||
|
||||
assert.match(html, /Diese Anwendung setzt derzeit keine Cookies/);
|
||||
assert.match(html, /class="cookie-toggle"[^>]*disabled/);
|
||||
assert.match(html, /class="progress-storage-toggle"/);
|
||||
assert.match(html, /id="storage-cleanup-status"/);
|
||||
assert.match(html, /id="bootstrap-enter"[^>]*hidden/);
|
||||
assert.match(loader, /Entferne alte Streckendaten aus dem Speicher \(\$\{removed\} \/ \$\{total\}\)/);
|
||||
assert.match(loader, /showReadyState\(\)/);
|
||||
assert.doesNotMatch(loader, /getElementById\('bootstrap-screen'\)\.hidden = true/);
|
||||
assert.match(loader, /event\.target\.closest\('button, input, label, a, dialog'\)/);
|
||||
assert.match(storage, /MAX_PROGRESS_AGE_MS = 24 \* 60 \* 60 \* 1000/);
|
||||
assert.match(storage, /cookiesEnabled: \(\) => false/);
|
||||
});
|
||||
|
||||
test('information button opens scrollable overlay pages for privacy, imprint and technology', async () => {
|
||||
const html = await read('public/index.html');
|
||||
const css = await read('public/css/app.css');
|
||||
const ui = await read('public/js/legal-ui.js');
|
||||
|
||||
assert.match(html, /id="info-button"/);
|
||||
assert.match(html, /<dialog id="info-dialog"/);
|
||||
assert.match(html, /data-info-target="privacy"/);
|
||||
assert.match(html, /data-info-target="imprint"/);
|
||||
assert.match(html, /data-info-target="technology"/);
|
||||
assert.match(html, /wegwichtel\.privacy\.v1/);
|
||||
assert.match(html, /wegwichtel\.route-progress\.v1/);
|
||||
assert.match(html, /jQuery 4\.0\.0/);
|
||||
assert.match(css, /\.info-dialog-content[\s\S]*overflow-y:\s*auto/);
|
||||
assert.match(css, /\.info-dialog-content[\s\S]*touch-action:\s*pan-y/);
|
||||
assert.match(ui, /pointerup/);
|
||||
assert.match(ui, /keydown/);
|
||||
assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/);
|
||||
});
|
||||
|
||||
test('application persists current route, viewed stations and played audio only through privacy storage', async () => {
|
||||
const app = await read('public/js/app.js');
|
||||
|
||||
assert.match(app, /PrivacyStorage\.saveRouteState/);
|
||||
assert.match(app, /PrivacyStorage\.activateRoute/);
|
||||
assert.match(app, /state\.viewed\.add\(state\.activePoiId\)/);
|
||||
assert.match(app, /\$\('#poi-audio'\)\.on\('play'/);
|
||||
assert.match(app, /state\.playedAudio\.add\(state\.activePoiId\)/);
|
||||
assert.match(app, /globalThis\.addEventListener\('pagehide', persistRouteProgress\)/);
|
||||
assert.match(app, /await initializeLocation\(context\)/);
|
||||
});
|
||||
|
||||
|
||||
class FakeElement extends EventTarget {
|
||||
constructor(id = '') {
|
||||
super();
|
||||
this.id = id;
|
||||
this.hidden = false;
|
||||
this.checked = false;
|
||||
this.disabled = false;
|
||||
this.open = false;
|
||||
this.textContent = '';
|
||||
this.scrollTop = 0;
|
||||
this.dataset = {};
|
||||
this.attributes = new Map();
|
||||
this.focused = false;
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.focused = true;
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
if (name === 'open') this.open = true;
|
||||
}
|
||||
|
||||
removeAttribute(name) {
|
||||
this.attributes.delete(name);
|
||||
if (name === 'open') this.open = false;
|
||||
}
|
||||
|
||||
showModal() {
|
||||
this.open = true;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pointerEvent() {
|
||||
const event = new Event('pointerup', { bubbles: true, cancelable: true });
|
||||
Object.defineProperties(event, {
|
||||
isPrimary: { value: true },
|
||||
pointerType: { value: 'touch' },
|
||||
button: { value: 0 }
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
test('legal overlay handlers synchronize switches without advancing the start screen', async () => {
|
||||
const sourcePath = path.join(root, 'public/js/legal-ui.js');
|
||||
const source = await fs.readFile(sourcePath, 'utf8');
|
||||
const elements = new Map([
|
||||
['info-dialog', new FakeElement('info-dialog')],
|
||||
['info-dialog-title', new FakeElement('info-dialog-title')],
|
||||
['info-menu', new FakeElement('info-menu')],
|
||||
['info-back', new FakeElement('info-back')],
|
||||
['info-close', new FakeElement('info-close')],
|
||||
['info-button', new FakeElement('info-button')]
|
||||
]);
|
||||
const content = new FakeElement();
|
||||
const privacyPage = new FakeElement();
|
||||
const imprintPage = new FakeElement();
|
||||
const technologyPage = new FakeElement();
|
||||
const pages = { privacy: privacyPage, imprint: imprintPage, technology: technologyPage };
|
||||
const targetButtons = Object.keys(pages).map(name => {
|
||||
const button = new FakeElement();
|
||||
button.dataset.infoTarget = name;
|
||||
return button;
|
||||
});
|
||||
const progressToggles = [new FakeElement(), new FakeElement()];
|
||||
const cookieToggles = [new FakeElement()];
|
||||
let enabled = false;
|
||||
const changes = [];
|
||||
|
||||
class TestCustomEvent extends Event {
|
||||
constructor(type, options = {}) {
|
||||
super(type);
|
||||
this.detail = options.detail;
|
||||
}
|
||||
}
|
||||
|
||||
const document = new EventTarget();
|
||||
document.getElementById = id => elements.get(id);
|
||||
document.querySelectorAll = selector => {
|
||||
if (selector === '.progress-storage-toggle') return progressToggles;
|
||||
if (selector === '.cookie-toggle') return cookieToggles;
|
||||
if (selector === '[data-info-page]') return Object.values(pages);
|
||||
if (selector === '[data-info-target]') return targetButtons;
|
||||
return [];
|
||||
};
|
||||
document.querySelector = selector => {
|
||||
if (selector === '.info-dialog-content') return content;
|
||||
const match = selector.match(/^\[data-info-page="(.+)"\]$/);
|
||||
return match ? pages[match[1]] : null;
|
||||
};
|
||||
document.addEventListener('wegwichtel:progress-storage-changed', event => changes.push(event.detail.enabled));
|
||||
|
||||
const context = {
|
||||
Wegwichtel: {
|
||||
PrivacyStorage: {
|
||||
isProgressStorageEnabled: () => enabled,
|
||||
setProgressStorageEnabled: value => {
|
||||
enabled = value;
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
},
|
||||
document,
|
||||
Event,
|
||||
EventTarget,
|
||||
CustomEvent: TestCustomEvent
|
||||
};
|
||||
context.globalThis = context;
|
||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||
|
||||
elements.get('info-button').dispatchEvent(pointerEvent());
|
||||
assert.equal(elements.get('info-dialog').open, true);
|
||||
assert.equal(elements.get('info-menu').hidden, false);
|
||||
|
||||
targetButtons[0].dispatchEvent(pointerEvent());
|
||||
assert.equal(privacyPage.hidden, false);
|
||||
assert.equal(elements.get('info-back').hidden, false);
|
||||
assert.equal(elements.get('info-dialog-title').textContent, 'Datenschutzerklärung');
|
||||
|
||||
progressToggles[0].checked = true;
|
||||
progressToggles[0].dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(changes, [true]);
|
||||
assert.equal(progressToggles[1].checked, true);
|
||||
assert.equal(cookieToggles[0].checked, false);
|
||||
|
||||
elements.get('info-back').dispatchEvent(pointerEvent());
|
||||
assert.equal(elements.get('info-menu').hidden, false);
|
||||
elements.get('info-close').dispatchEvent(pointerEvent());
|
||||
assert.equal(elements.get('info-dialog').open, false);
|
||||
});
|
||||
@@ -144,9 +144,9 @@ test('the npm-started local API is exercised through a write-auth proxy on loopb
|
||||
const indexHtml = await indexResponse.text();
|
||||
assert.equal(indexResponse.status, 200);
|
||||
assert.match(indexResponse.headers.get('cache-control') || '', /no-store/);
|
||||
assert.match(indexHtml, /css\/app\.css\?v=0\.12\.12/);
|
||||
assert.match(indexHtml, /css\/app\.css\?v=0\.12\.14/);
|
||||
|
||||
const appStyleResponse = await fetch(`${baseUrl}/css/app.css?v=0.12.12`);
|
||||
const appStyleResponse = await fetch(`${baseUrl}/css/app.css?v=0.12.14`);
|
||||
assert.equal(appStyleResponse.status, 200);
|
||||
assert.match(appStyleResponse.headers.get('cache-control') || '', /no-store/);
|
||||
await appStyleResponse.arrayBuffer();
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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, '..');
|
||||
|
||||
class MemoryStorage {
|
||||
constructor() {
|
||||
this.values = new Map();
|
||||
}
|
||||
|
||||
getItem(key) {
|
||||
return this.values.has(key) ? this.values.get(key) : null;
|
||||
}
|
||||
|
||||
setItem(key, value) {
|
||||
this.values.set(key, String(value));
|
||||
}
|
||||
|
||||
removeItem(key) {
|
||||
this.values.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModule() {
|
||||
const source = await fs.readFile(path.join(root, 'public/js/privacy-storage.js'), 'utf8');
|
||||
const localStorage = new MemoryStorage();
|
||||
const context = {
|
||||
Wegwichtel: {},
|
||||
localStorage,
|
||||
document: { cookie: '' },
|
||||
Date,
|
||||
JSON,
|
||||
Number,
|
||||
Set,
|
||||
String,
|
||||
Promise,
|
||||
setTimeout,
|
||||
clearTimeout
|
||||
};
|
||||
context.globalThis = context;
|
||||
vm.runInNewContext(source, context, { filename: path.join(root, 'public/js/privacy-storage.js') });
|
||||
return { storage: context.Wegwichtel.PrivacyStorage, localStorage };
|
||||
}
|
||||
|
||||
test('route progress storage is opt-in and deleting the option removes progress data', async () => {
|
||||
const { storage, localStorage } = await loadModule();
|
||||
assert.equal(storage.isProgressStorageEnabled(), false);
|
||||
assert.equal(storage.activateRoute({ id: 7, name: 'Testweg' }), null);
|
||||
|
||||
storage.setProgressStorageEnabled(true);
|
||||
storage.activateRoute({ id: 7, name: 'Testweg' });
|
||||
storage.markVisited(7, 11);
|
||||
storage.markViewed(7, 12);
|
||||
storage.markAudioPlayed(7, 12);
|
||||
storage.saveRouteState({ id: 7, name: 'Testweg' }, 'paused', 23, [11], [12], [12]);
|
||||
|
||||
const route = storage.loadRoute(7);
|
||||
assert.equal(route.routeState, 'paused');
|
||||
assert.equal(route.routeProgressIndex, 23);
|
||||
assert.deepEqual([...route.visitedPoiIds], [11]);
|
||||
assert.deepEqual([...route.viewedPoiIds], [12]);
|
||||
assert.deepEqual([...route.playedAudioPoiIds], [12]);
|
||||
|
||||
storage.setProgressStorageEnabled(false);
|
||||
assert.equal(storage.isProgressStorageEnabled(), false);
|
||||
assert.equal(localStorage.getItem(storage.PROGRESS_KEY), null);
|
||||
});
|
||||
|
||||
test('route progress older than 24 hours is removed with visible loop progress', async () => {
|
||||
const { storage, localStorage } = await loadModule();
|
||||
storage.setProgressStorageEnabled(true);
|
||||
|
||||
const oldTimestamp = new Date(Date.now() - storage.MAX_PROGRESS_AGE_MS - 1000).toISOString();
|
||||
const freshTimestamp = new Date().toISOString();
|
||||
localStorage.setItem(storage.PROGRESS_KEY, JSON.stringify({
|
||||
version: 1,
|
||||
currentRouteId: 1,
|
||||
routes: {
|
||||
1: { routeId: 1, routeName: 'Alt', updatedAt: oldTimestamp },
|
||||
2: { routeId: 2, routeName: 'Neu', updatedAt: freshTimestamp }
|
||||
}
|
||||
}));
|
||||
|
||||
const updates = [];
|
||||
const result = await storage.cleanupExpired((removed, total) => updates.push(`${removed} / ${total}`));
|
||||
assert.equal(result.removed, 1);
|
||||
assert.equal(result.total, 1);
|
||||
assert.deepEqual(updates, ['0 / 1', '1 / 1']);
|
||||
assert.equal(storage.loadRoute(1), null);
|
||||
assert.equal(storage.loadRoute(2).routeName, 'Neu');
|
||||
});
|
||||
@@ -29,19 +29,22 @@ test('route selection renders independently from geolocation and restores after
|
||||
const server = await read('server.js');
|
||||
const api = await read('src/routes/api.js');
|
||||
|
||||
assert.match(html, /vendor\/jquery-ui\/jquery-ui-1\.14\.2\.min\.css\?v=0\.12\.12/);
|
||||
assert.match(html, /css\/app\.css\?v=0\.12\.12/);
|
||||
assert.match(html, /vendor\/jquery-ui\/jquery-ui-1\.14\.2\.min\.css\?v=0\.12\.14/);
|
||||
assert.match(html, /css\/app\.css\?v=0\.12\.14/);
|
||||
assert.match(html, /id="all-routes-summary"[^>]*>Routen werden geladen/);
|
||||
assert.doesNotMatch(loader, /loadStyle\(/);
|
||||
assert.doesNotMatch(css, /:has\(/);
|
||||
assert.match(html, /<header class="has-back-button">/);
|
||||
|
||||
const firstRender = app.indexOf('renderRoutes();', app.indexOf('async initialize(context)'));
|
||||
const locationStart = app.indexOf('void initializeLocation(context);', app.indexOf('async initialize(context)'));
|
||||
const locationStart = app.indexOf('await initializeLocation(context);', app.indexOf('async initialize(context)'));
|
||||
assert.ok(firstRender >= 0 && locationStart > firstRender, 'Routen müssen vor der Standortabfrage gerendert werden.');
|
||||
assert.match(app, /globalThis\.addEventListener\('pageshow', restoreRouteLists\)/);
|
||||
assert.match(app, /document\.visibilityState === 'visible'/);
|
||||
assert.match(app, /Array\.isArray\(response\?\.routes\)/);
|
||||
assert.match(app, /throw new TypeError\('Die Routen-API hat keine gültige Routenliste geliefert\.'\)/);
|
||||
assert.match(app, /route\?\.id != null && route\?\.name != null/);
|
||||
assert.doesNotMatch(app, /route && route\.id/);
|
||||
assert.match(server, /no-cache, no-store, must-revalidate/);
|
||||
assert.match(api, /api\.use\(\(req, res, next\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user