From d1b368d0c4b6760e05b7849e3f28026647082db9 Mon Sep 17 00:00:00 2001 From: Florian Zumpe Date: Tue, 23 Jun 2026 16:10:19 +0200 Subject: [PATCH] added privacy switches and documents, assets and local storage for already visited POIs --- README.md | 16 ++ docs/PUBLICATION-CHECKLIST.md | 43 ++++++ package-lock.json | 4 +- package.json | 2 +- public/assets/logo_1254.png | Bin 0 -> 3549607 bytes public/assets/logo_256.png | Bin 0 -> 166708 bytes public/css/app.css | 249 +++++++++++++++++++++++++++++++ public/css/bootstrap.css | 56 +++++++ public/index.html | 177 +++++++++++++++++++++- public/js/app.js | 86 ++++++++++- public/js/bootstrap-loader.js | 62 +++++++- public/js/legal-ui.js | 120 +++++++++++++++ public/js/privacy-storage.js | 251 ++++++++++++++++++++++++++++++++ test/bootstrap-loader.test.js | 165 +++++++++++++++++++++ test/legal-ui.test.js | 198 +++++++++++++++++++++++++ test/media-api.test.js | 4 +- test/privacy-storage.test.js | 94 ++++++++++++ test/route-selection-ui.test.js | 9 +- 18 files changed, 1511 insertions(+), 25 deletions(-) create mode 100644 docs/PUBLICATION-CHECKLIST.md create mode 100644 public/assets/logo_1254.png create mode 100644 public/assets/logo_256.png create mode 100644 public/js/legal-ui.js create mode 100644 public/js/privacy-storage.js create mode 100644 test/bootstrap-loader.test.js create mode 100644 test/legal-ui.test.js create mode 100644 test/privacy-storage.test.js diff --git a/README.md b/README.md index 36006ed..4b78b27 100644 --- a/README.md +++ b/README.md @@ -182,3 +182,19 @@ Wegwichtel.Vibration.start(Wegwichtel.Vibration.Patterns.ACTIVE_POI); Während die Route läuft, werden Positionsänderungen fortlaufend verarbeitet. Eine Browser-Webanwendung ist jedoch kein nativer Hintergrunddienst: Betriebssystem und Browser können die Aktualisierung bei gesperrtem Bildschirm, Energiesparmodus oder im Hintergrund drosseln beziehungsweise anhalten. Die Symbole liegen unter `public/images/icons/`. Sie befinden sich innerhalb nativer ` +

Wegwichtel

Der Wegwichtel bereitet Routen und Medien vor.

+
+

Cookies: Diese Anwendung setzt derzeit keine Cookies und verwendet weder Werbung noch Tracking.

+ + + + +
+ + +
    @@ -27,8 +55,10 @@
  1. Standort ermitteln
+ +
@@ -151,6 +181,145 @@ - + +
+
+ +

Informationen

+ +
+ +
+ + + + + + + +
+
+
+ + + + diff --git a/public/js/app.js b/public/js/app.js index 45a0011..241f1d8 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -9,13 +9,61 @@ routeState: 'idle', activePage: null, routeProgressIndex: null, - deviceHeading: null + deviceHeading: null, + viewed: new Set(), + playedAudio: new Set(), + activePoiId: null, + persistTimer: null }; function escapeHtml(value) { return $('
').text(value ?? '').html(); } + + function persistRouteProgress() { + if (!state.route) return; + if (state.persistTimer != null) { + globalThis.clearTimeout(state.persistTimer); + state.persistTimer = null; + } + + ns.PrivacyStorage.saveRouteState( + state.route, + state.routeState, + state.routeProgressIndex, + [...state.triggered], + [...state.viewed], + [...state.playedAudio] + ); + } + + function scheduleRouteProgressPersist() { + if (!state.route || state.persistTimer != null) return; + state.persistTimer = globalThis.setTimeout(persistRouteProgress, 1200); + } + + function restoreRouteProgress(route) { + const saved = ns.PrivacyStorage.activateRoute(route); + state.triggered = new Set(saved?.visitedPoiIds ?? []); + state.viewed = new Set(saved?.viewedPoiIds ?? []); + state.playedAudio = new Set(saved?.playedAudioPoiIds ?? []); + state.routeProgressIndex = Number.isInteger(saved?.routeProgressIndex) + ? saved.routeProgressIndex + : null; + state.routeState = saved?.routeState === 'running' || saved?.routeState === 'paused' + ? 'paused' + : 'idle'; + + if (state.routeState === 'paused') { + $('#tracking-status') + .prop('hidden', false) + .text('Gespeicherter Streckenfortschritt gefunden. Die Route kann fortgesetzt werden.'); + } else { + $('#tracking-status').prop('hidden', true).text(''); + } + } + function activate(selector, handler) { $(document) .on('pointerup', selector, function (event) { @@ -185,8 +233,12 @@ function endRoute({ announce = true, returnToRoutes = false } = {}) { const wasActive = state.routeState !== 'idle'; state.routeState = 'idle'; + persistRouteProgress(); state.routeProgressIndex = null; state.triggered.clear(); + state.viewed.clear(); + state.playedAudio.clear(); + state.activePoiId = null; stopRouteSensors(); ns.AudioPlayer.unload(); setActivePoi(null); @@ -210,6 +262,7 @@ if (state.routeState !== 'running') return; state.routeState = 'paused'; + persistRouteProgress(); stopRouteSensors(); ns.AudioPlayer.pause(); $('#tracking-status') @@ -223,15 +276,14 @@ if (state.routeState !== 'idle') endRoute({ announce: false }); state.route = route; - state.triggered.clear(); - state.routeProgressIndex = null; + state.activePoiId = null; + restoreRouteProgress(route); setActivePoi(null); $('#route-title').text(route.name); $('#route-description').text(route.description || ''); $('#route-distance').text(ns.Distance.format(route.distanceM)); $('#route-elevation').text(`${Math.round(route.elevationGainM)} m`); $('#route-poi-count').text(route.pois.length); - $('#tracking-status').prop('hidden', true).text(''); updateRouteControls(); const list = $('#poi-list').empty(); @@ -261,6 +313,9 @@ } function showPoi(poi) { + state.activePoiId = Number(poi.id); + state.viewed.add(state.activePoiId); + persistRouteProgress(); setActivePoi(poi.id); $('#poi-title').text(poi.title); $('#poi-description').text(poi.description || ''); @@ -345,6 +400,7 @@ } function updateRouteProgress(position) { + const previousProgressIndex = state.routeProgressIndex; const points = state.route?.points || []; if (!points.length) return null; @@ -370,9 +426,11 @@ if (targetIndex === lastIndex && distance <= reachedRadius) { state.routeProgressIndex = lastIndex; + if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist(); return { reached: true, target, distance: 0 }; } + if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist(); return { reached: false, target, distance }; } @@ -464,6 +522,7 @@ if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) { state.triggered.add(nearest.poi.id); + persistRouteProgress(); ns.Vibration.start(ns.Vibration.Patterns.ACTIVE_POI); showPoi(nearest.poi); updateNavigation(position); @@ -481,6 +540,7 @@ state.routeState = 'running'; state.deviceHeading = null; + persistRouteProgress(); updateRouteControls(); $('#tracking-status') .prop('hidden', false) @@ -505,9 +565,9 @@ function routesFromResponse(response) { if (!Array.isArray(response?.routes)) { - throw new Error('Die Routen-API hat keine gültige Routenliste geliefert.'); + throw new TypeError('Die Routen-API hat keine gültige Routenliste geliefert.'); } - return response.routes.filter(route => route && route.id != null && route.name != null); + return response.routes.filter(route => route?.id != null && route?.name != null); } function updateRouteProximity(position) { @@ -584,6 +644,17 @@ endRoute({ returnToRoutes: true }); }); + $('#poi-audio').on('play', function () { + if (!state.route || state.activePoiId == null) return; + state.playedAudio.add(state.activePoiId); + persistRouteProgress(); + }); + + document.addEventListener('wegwichtel:progress-storage-changed', event => { + if (event.detail?.enabled) persistRouteProgress(); + }); + + globalThis.addEventListener('pagehide', persistRouteProgress); globalThis.addEventListener('popstate', event => { const page = event.state?.page || '#routes-page'; navigate(page, { updateHistory: false, instant: true }); @@ -592,6 +663,7 @@ globalThis.addEventListener('pageshow', restoreRouteLists); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') restoreRouteLists(); + else persistRouteProgress(); }); } @@ -612,7 +684,7 @@ updateRouteControls(); hideNavigation(); - void initializeLocation(context); + await initializeLocation(context); } }; }(globalThis.Wegwichtel, globalThis.jQuery)); diff --git a/public/js/bootstrap-loader.js b/public/js/bootstrap-loader.js index b6fd2de..be33404 100644 --- a/public/js/bootstrap-loader.js +++ b/public/js/bootstrap-loader.js @@ -1,11 +1,16 @@ (function () { 'use strict'; - const versions = Object.freeze({ app: '0.12.12', jquery: '4.0.0', jqueryUi: '1.14.2' }); + const versions = Object.freeze({ app: '0.12.14', jquery: '4.0.0', jqueryUi: '1.14.2' }); const steps = ['jquery', 'jquery-ui', 'modules', 'server', 'routes', 'location']; + const screen = document.getElementById('bootstrap-screen'); const progress = document.getElementById('bootstrap-progress'); const errorBox = document.getElementById('bootstrap-error'); const retry = document.getElementById('bootstrap-retry'); + const enter = document.getElementById('bootstrap-enter'); + const readyText = document.getElementById('bootstrap-ready'); + const cleanupStatus = document.getElementById('storage-cleanup-status'); + let ready = false; const versioned = path => `${path}${path.includes('?') ? '&' : '?'}v=${encodeURIComponent(versions.app)}`; @@ -34,17 +39,61 @@ function initializeJqueryUi() { const $ = globalThis.jQuery; $('#bootstrap-retry').button({ icon: 'ui-icon-refresh' }); + $('#bootstrap-enter').button(); $('.back-button').button(); $('#clear-route-search').button({ icon: 'ui-icon-close', showLabel: false }); $('#slide-prev').button(); $('#slide-next').button(); } + function updateCleanupProgress(removed, total) { + cleanupStatus.textContent = `Entferne alte Streckendaten aus dem Speicher (${removed} / ${total})`; + cleanupStatus.hidden = false; + } + + async function cleanupOldProgress() { + cleanupStatus.hidden = true; + await globalThis.Wegwichtel.PrivacyStorage.cleanupExpired(updateCleanupProgress); + } + + function isActivation(event) { + if (event.type === 'keydown') return event.key === 'Enter' || event.key === ' '; + if (event.type !== 'pointerup') return false; + if (event.isPrimary === false) return false; + return event.pointerType !== 'mouse' || event.button === 0; + } + + function openApplication(event) { + if (!ready || !isActivation(event)) return; + if (event.target !== enter && event.target.closest('button, input, label, a, dialog')) return; + + event.preventDefault(); + screen.hidden = true; + document.getElementById('app-shell').hidden = false; + globalThis.Wegwichtel.App.navigate('#routes-page', { replace: true }); + } + + function showReadyState() { + ready = true; + screen.setAttribute('aria-busy', 'false'); + screen.classList.add('ready'); + readyText.hidden = false; + enter.hidden = false; + enter.focus(); + } + async function boot() { + ready = false; + screen.setAttribute('aria-busy', 'true'); + screen.classList.remove('ready'); + readyText.hidden = true; + enter.hidden = true; errorBox.hidden = true; retry.hidden = true; try { + await cleanupOldProgress(); + await loadScript( `vendor/jquery/jquery-${versions.jquery}.min.js`, () => globalThis.jQuery?.fn?.jquery === versions.jquery @@ -64,9 +113,7 @@ mark('modules', 'done'); await globalThis.Wegwichtel.App.initialize({ markStep: mark }); - document.getElementById('bootstrap-screen').hidden = true; - document.getElementById('app-shell').hidden = false; - globalThis.Wegwichtel.App.navigate('#routes-page', { replace: true }); + showReadyState(); } catch (error) { console.error(error); const current = steps.find(step => !document.querySelector(`[data-step="${step}"]`)?.classList.contains('done')); @@ -78,13 +125,16 @@ } function retryBoot(event) { - if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return; - if (event.type === 'pointerup' && event.pointerType === 'mouse' && event.button !== 0) return; + if (!isActivation(event)) return; event.preventDefault(); + event.stopPropagation(); location.reload(); } retry.addEventListener('pointerup', retryBoot); retry.addEventListener('keydown', retryBoot); + enter.addEventListener('pointerup', openApplication); + enter.addEventListener('keydown', openApplication); + screen.addEventListener('pointerup', openApplication); boot(); }()); diff --git a/public/js/legal-ui.js b/public/js/legal-ui.js new file mode 100644 index 0000000..873ca81 --- /dev/null +++ b/public/js/legal-ui.js @@ -0,0 +1,120 @@ +(function (ns) { + 'use strict'; + + const dialog = document.getElementById('info-dialog'); + const title = document.getElementById('info-dialog-title'); + const menu = document.getElementById('info-menu'); + const backButton = document.getElementById('info-back'); + const closeButton = document.getElementById('info-close'); + const infoButton = document.getElementById('info-button'); + const pageTitles = Object.freeze({ + privacy: 'Datenschutzerklärung', + imprint: 'Impressum', + technology: 'Cookies und Technik' + }); + + function isActivation(event) { + if (event.type === 'keydown') return event.key === 'Enter' || event.key === ' '; + if (event.type !== 'pointerup') return false; + if (event.isPrimary === false) return false; + return event.pointerType !== 'mouse' || event.button === 0; + } + + function activate(element, handler) { + element.addEventListener('pointerup', event => { + if (!isActivation(event)) return; + event.preventDefault(); + event.stopPropagation(); + handler(event); + }); + element.addEventListener('keydown', event => { + if (!isActivation(event)) return; + event.preventDefault(); + event.stopPropagation(); + handler(event); + }); + } + + function syncStorageToggles() { + const enabled = ns.PrivacyStorage.isProgressStorageEnabled(); + document.querySelectorAll('.progress-storage-toggle').forEach(toggle => { + toggle.checked = enabled; + }); + document.querySelectorAll('.cookie-toggle').forEach(toggle => { + toggle.checked = false; + }); + } + + function showMenu() { + title.textContent = 'Informationen'; + menu.hidden = false; + backButton.hidden = true; + document.querySelectorAll('[data-info-page]').forEach(page => { + page.hidden = true; + }); + } + + function showPage(name) { + const page = document.querySelector(`[data-info-page="${name}"]`); + if (!page) return; + + title.textContent = pageTitles[name] ?? 'Informationen'; + menu.hidden = true; + backButton.hidden = false; + document.querySelectorAll('[data-info-page]').forEach(item => { + item.hidden = item !== page; + }); + const content = document.querySelector('.info-dialog-content'); + if (content) content.scrollTop = 0; + } + + function openDialog() { + if (dialog.open) return; + showMenu(); + syncStorageToggles(); + if (typeof dialog.showModal === 'function') dialog.showModal(); + else dialog.setAttribute('open', ''); + closeButton.focus(); + } + + function closeDialog() { + if (typeof dialog.close === 'function') dialog.close(); + else dialog.removeAttribute('open'); + infoButton.focus(); + } + + function bindStorageToggles() { + document.querySelectorAll('.progress-storage-toggle').forEach(toggle => { + toggle.addEventListener('change', event => { + const enabled = ns.PrivacyStorage.setProgressStorageEnabled(event.currentTarget.checked); + syncStorageToggles(); + document.dispatchEvent(new CustomEvent('wegwichtel:progress-storage-changed', { + detail: { enabled } + })); + }); + }); + } + + activate(infoButton, openDialog); + activate(closeButton, closeDialog); + activate(backButton, showMenu); + document.querySelectorAll('[data-info-target]').forEach(button => { + activate(button, () => showPage(button.dataset.infoTarget)); + }); + + dialog.addEventListener('pointerup', event => { + if (event.target !== dialog || !isActivation(event)) return; + event.preventDefault(); + event.stopPropagation(); + closeDialog(); + }); + dialog.addEventListener('cancel', event => { + event.preventDefault(); + closeDialog(); + }); + + bindStorageToggles(); + syncStorageToggles(); + + ns.LegalUi = Object.freeze({ open: openDialog, close: closeDialog, syncStorageToggles }); +}(globalThis.Wegwichtel ??= {})); diff --git a/public/js/privacy-storage.js b/public/js/privacy-storage.js new file mode 100644 index 0000000..f441eff --- /dev/null +++ b/public/js/privacy-storage.js @@ -0,0 +1,251 @@ +(function (ns) { + 'use strict'; + + const SETTINGS_KEY = 'wegwichtel.privacy.v1'; + const PROGRESS_KEY = 'wegwichtel.route-progress.v1'; + const COOKIE_PREFIX = 'wegwichtel_'; + const MAX_PROGRESS_AGE_MS = 24 * 60 * 60 * 1000; + const STORE_VERSION = 1; + + function storage() { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } + } + + function nowIso() { + return new Date().toISOString(); + } + + function readJson(key, fallback) { + const backend = storage(); + if (!backend) return fallback; + + try { + const raw = backend.getItem(key); + return raw ? JSON.parse(raw) : fallback; + } catch { + return fallback; + } + } + + function writeJson(key, value) { + const backend = storage(); + if (!backend) return false; + + try { + backend.setItem(key, JSON.stringify(value)); + return true; + } catch { + return false; + } + } + + function removeItem(key) { + const backend = storage(); + if (!backend) return; + + try { + backend.removeItem(key); + } catch { + // Private browsing modes may reject storage access. The app remains usable without persistence. + } + } + + function defaultSettings() { + return { progressStorage: false, updatedAt: null }; + } + + function settings() { + const value = readJson(SETTINGS_KEY, defaultSettings()); + return { + progressStorage: value?.progressStorage === true, + updatedAt: typeof value?.updatedAt === 'string' ? value.updatedAt : null + }; + } + + function saveSettings(progressStorage) { + return writeJson(SETTINGS_KEY, { + progressStorage: progressStorage === true, + updatedAt: nowIso() + }); + } + + function defaultProgressStore() { + return { version: STORE_VERSION, currentRouteId: null, routes: {} }; + } + + function progressStore() { + const value = readJson(PROGRESS_KEY, defaultProgressStore()); + if (value?.version !== STORE_VERSION || typeof value?.routes !== 'object' || value.routes === null) { + return defaultProgressStore(); + } + + return { + version: STORE_VERSION, + currentRouteId: value.currentRouteId ?? null, + routes: value.routes + }; + } + + function persistProgressStore(value) { + if (!settings().progressStorage) return false; + return writeJson(PROGRESS_KEY, value); + } + + function uniqueIds(values) { + return [...new Set((Array.isArray(values) ? values : []) + .map(value => Number(value)) + .filter(Number.isFinite))]; + } + + function routeRecord(route, previous = {}) { + const timestamp = nowIso(); + return { + routeId: route.id, + routeName: String(route.name ?? previous.routeName ?? ''), + routeState: previous.routeState ?? 'idle', + routeProgressIndex: Number.isInteger(previous.routeProgressIndex) + ? previous.routeProgressIndex + : null, + visitedPoiIds: uniqueIds(previous.visitedPoiIds), + viewedPoiIds: uniqueIds(previous.viewedPoiIds), + playedAudioPoiIds: uniqueIds(previous.playedAudioPoiIds), + startedAt: previous.startedAt ?? timestamp, + updatedAt: timestamp + }; + } + + function updateRoute(routeId, updater) { + if (!settings().progressStorage || routeId == null) return null; + + const store = progressStore(); + const key = String(routeId); + const current = store.routes[key]; + if (!current) return null; + + const updated = updater({ ...current }); + updated.updatedAt = nowIso(); + store.currentRouteId = routeId; + store.routes[key] = updated; + persistProgressStore(store); + return updated; + } + + function markPoi(routeId, field, poiId) { + const normalizedId = Number(poiId); + if (!Number.isFinite(normalizedId)) return null; + + return updateRoute(routeId, record => { + record[field] = uniqueIds([...(record[field] ?? []), normalizedId]); + return record; + }); + } + + function clearApplicationCookies() { + const cookieText = globalThis.document?.cookie; + if (!cookieText) return; + + cookieText.split(';').forEach(item => { + const name = item.split('=', 1)[0].trim(); + if (!name.startsWith(COOKIE_PREFIX)) return; + globalThis.document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`; + }); + } + + function setProgressStorageEnabled(enabled) { + const active = enabled === true; + const saved = saveSettings(active); + if (!active) removeItem(PROGRESS_KEY); + return saved && settings().progressStorage; + } + + function activateRoute(route) { + if (!settings().progressStorage || route?.id == null) return null; + + const store = progressStore(); + const key = String(route.id); + const record = routeRecord(route, store.routes[key]); + store.currentRouteId = route.id; + store.routes[key] = record; + persistProgressStore(store); + return record; + } + + function saveRouteState(route, routeState, routeProgressIndex, visitedPoiIds, viewedPoiIds, playedAudioPoiIds) { + if (route?.id == null) return null; + + return updateRoute(route.id, record => ({ + ...record, + routeName: String(route.name ?? record.routeName ?? ''), + routeState: String(routeState ?? 'idle'), + routeProgressIndex: Number.isInteger(routeProgressIndex) ? routeProgressIndex : null, + visitedPoiIds: uniqueIds(visitedPoiIds), + viewedPoiIds: uniqueIds(viewedPoiIds), + playedAudioPoiIds: uniqueIds(playedAudioPoiIds) + })); + } + + function loadRoute(routeId) { + if (!settings().progressStorage || routeId == null) return null; + return progressStore().routes[String(routeId)] ?? null; + } + + function nextTurn() { + return new Promise(resolve => globalThis.setTimeout(resolve, 0)); + } + + async function cleanupExpired(onProgress = () => {}) { + if (!settings().progressStorage) { + removeItem(PROGRESS_KEY); + return { removed: 0, total: 0 }; + } + + const store = progressStore(); + const cutoff = Date.now() - MAX_PROGRESS_AGE_MS; + const expiredKeys = Object.entries(store.routes) + .filter(([, route]) => { + const timestamp = Date.parse(route?.updatedAt ?? ''); + return !Number.isFinite(timestamp) || timestamp <= cutoff; + }) + .map(([key]) => key); + + if (!expiredKeys.length) return { removed: 0, total: 0 }; + + let removed = 0; + onProgress(removed, expiredKeys.length); + + for (const key of expiredKeys) { + delete store.routes[key]; + if (String(store.currentRouteId) === key) store.currentRouteId = null; + removed += 1; + persistProgressStore(store); + onProgress(removed, expiredKeys.length); + await nextTurn(); + } + + return { removed, total: expiredKeys.length }; + } + + clearApplicationCookies(); + + ns.PrivacyStorage = Object.freeze({ + SETTINGS_KEY, + PROGRESS_KEY, + MAX_PROGRESS_AGE_MS, + cookiesEnabled: () => false, + clearApplicationCookies, + isProgressStorageEnabled: () => settings().progressStorage, + setProgressStorageEnabled, + cleanupExpired, + activateRoute, + loadRoute, + saveRouteState, + markVisited: (routeId, poiId) => markPoi(routeId, 'visitedPoiIds', poiId), + markViewed: (routeId, poiId) => markPoi(routeId, 'viewedPoiIds', poiId), + markAudioPlayed: (routeId, poiId) => markPoi(routeId, 'playedAudioPoiIds', poiId), + clearProgress: () => removeItem(PROGRESS_KEY) + }); +}(globalThis.Wegwichtel ??= {})); diff --git a/test/bootstrap-loader.test.js b/test/bootstrap-loader.test.js new file mode 100644 index 0000000..e4957fe --- /dev/null +++ b/test/bootstrap-loader.test.js @@ -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); +}); diff --git a/test/legal-ui.test.js b/test/legal-ui.test.js new file mode 100644 index 0000000..a532f24 --- /dev/null +++ b/test/legal-ui.test.js @@ -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, / { + 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); +}); diff --git a/test/media-api.test.js b/test/media-api.test.js index 678b85b..973a7fe 100644 --- a/test/media-api.test.js +++ b/test/media-api.test.js @@ -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(); diff --git a/test/privacy-storage.test.js b/test/privacy-storage.test.js new file mode 100644 index 0000000..771b57a --- /dev/null +++ b/test/privacy-storage.test.js @@ -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'); +}); diff --git a/test/route-selection-ui.test.js b/test/route-selection-ui.test.js index d488e43..fbf1d6b 100644 --- a/test/route-selection-ui.test.js +++ b/test/route-selection-ui.test.js @@ -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, /
/); 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\)/); });