diff --git a/README.md b/README.md index 4b78b27..bc56c63 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,11 @@ # Wegwichtel Next - Neuaufbau des früheren schiffsbezogenen Ansagesystems als mobile Lernweg-Anwendung für Schulen. GPX-Strecken werden serverseitig verwaltet; POIs können einzeln verwaltete Bilder mit Beschreibungen und eine separat verwaltete Audioansage enthalten. Der Client schlägt anhand der aktuellen Position nahe Routen vor und aktiviert POIs während einer Wanderung über einen GPS-Watcher. Beim Erreichen eines POIs wird die Audioansage nur vorgeladen und erst nach einer bewussten Bedienung abgespielt. - ## Architektur - - **Server:** Node.js 22.13+, Express 5, `node:sqlite`, Multer, `fast-xml-parser`, `file-type` und `@file-type/av` - **Client:** klassisches JavaScript, jQuery 4.0.0 und jQuery UI 1.14.2 mit lokalem Base-Theme - **Medien:** getrennte Verzeichnisse für aktive und zum Löschen markierte Strecken - **Löschmodell:** Soft Delete in SQLite plus atomisches Verschieben des gesamten Streckenordners in `storage/trash/routes` - **Wiederherstellung:** `POST /api/routes/:id/restore` verschiebt die Daten zurück und korrigiert alle gespeicherten Pfade - ## Schnellstart ```bash @@ -18,10 +14,7 @@ npm install npm run init-db npm start ``` - Danach lauscht Wegwichtel ausschließlich auf dem lokalen Socket `127.0.0.1:47145` und ist unter `http://127.0.0.1:47145` erreichbar. Die offiziellen, exakt versionierten Distributionsdateien sind bereits unter `public/vendor` enthalten. `npm install` installiert zusätzlich die Pakete `jquery@4.0.0` und `jquery-ui@1.14.2` und synchronisiert daraus JavaScript, Base-Theme, Themebilder und Lizenzdateien erneut in das Vendor-Verzeichnis. Der Browser lädt keine Bibliotheken von einem CDN. - - ## Server-Socket Die Standardwerte stehen in `.env.example` und werden auch verwendet, wenn keine `.env` vorhanden ist: @@ -40,9 +33,7 @@ PORT=47145 "socket": "127.0.0.1:47145" } ``` - ## Initialisierungsablauf des Clients - 1. statischer Initialisierungsbildschirm erscheint ohne Bibliotheksabhängigkeit, 2. lokale jQuery-Datei wird geladen und auf Version 4.0.0 geprüft, 3. lokales jQuery UI 1.14.2 samt Base-Theme wird geladen und über `jQuery.ui.version` geprüft, @@ -51,9 +42,7 @@ PORT=47145 6. der Client lädt die Routenliste, 7. anschließend wird die Position ermittelt; daraus entstehen nahe Empfehlungen, während die Gesamtliste vollständig erhalten bleibt, 8. die responsive Routenansicht wird freigeschaltet. - Scheitert ein Schritt, bleibt der Initialisierungsbildschirm mit einer konkreten Fehlermeldung und einem Wiederholungsbutton sichtbar. - ## Lokale UI-Abhängigkeiten Im Vendor-Verzeichnis liegen die zur Laufzeit verwendeten Dateien vollständig lokal: @@ -65,20 +54,14 @@ public/vendor/jquery-ui/jquery-ui-1.14.2.min.js public/vendor/jquery-ui/jquery-ui-1.14.2.min.css public/vendor/jquery-ui/images/*.png ``` - Die Anwendung verwendet aus jQuery UI insbesondere das Widget **Button**. Das offizielle vollständige jQuery-UI-Bundle bleibt lokal verfügbar, damit weitere aktuelle Widgets ohne erneuten CDN-Bezug ergänzt werden können. Die frühere jQuery-Mobile-Seitensteuerung wurde durch eine eigene, History-API-basierte Navigation ersetzt. - ## API-Dokumentation Die vollständige REST-API ist einschließlich aller Pfad-, Query-, Formular- und Datei-Parameter sowie ausführlicher `curl`-Beispiele in [`docs/REST-API.md`](docs/REST-API.md) dokumentiert. Die README beschreibt bewusst nur Installation, Architektur und Bedienverhalten. - - ## Python-Werkzeuge - -Unter [`tools/python/`](tools/python/README.md) liegen eigenständige Python-3-Skripte zum Anlegen, Ändern, Erweitern, Wiederherstellen und Löschen von Routen, POIs, Bildern und Audiodateien. Die Werkzeuge selbst verwenden ausschließlich die Python-Standardbibliothek. Fehlende Parameter werden interaktiv abgefragt. Antwortet ein vorgeschalteter Passwortschutz mit HTTP 401, fragt die gemeinsame Request-Schicht Benutzername und Passwort ab und wiederholt den Request. Fehler werden mit einer verständlichen Zusammenfassung und dem vollständigen Python-Stacktrace auf `stderr` ausgegeben. - +Unter [`tools/python/`](tools/python/README.md) liegen eigenständige Python-3-Skripte zum Anlegen, Ändern, Erweitern, Wiederherstellen und Löschen von Routen, POIs, Bildern und Audiodateien. Die Werkzeuge selbst verwenden ausschließlich die Python-Standardbibliothek. Fehlende Parameter werden interaktiv abgefragt. Antwortet ein vorgeschalteter Passwortschutz mit HTTP 401, fragt die gemeinsame Request-Schicht Benutzername und Passwort ab und wiederholt den Request. +Fehler werden mit einer verständlichen Zusammenfassung und dem vollständigen Python-Stacktrace auf `stderr` ausgegeben. ## Dateistruktur - ```text public/ mobiler Client index.html reduzierte semantische Seitenstruktur @@ -96,28 +79,22 @@ examples/ Beispiel-GPX test/ automatisierte Tests tools/python/ interaktive API-Werkzeuge ohne Node.js-Abhängigkeit ``` - ## Automatisierte Tests Die REST-Integrationstests starten die Anwendung über `npm start` mit isolierten temporären Daten- und Speicherverzeichnissen. Der eigentliche Node.js-Prozess lauscht innerhalb des Testaufbaus auf `127.0.0.1:47146`. Davor läuft ein ausschließlich für die Tests bestimmter Reverse-Proxy auf `http://127.0.0.1:47145`, sodass dieser Loopback-Endpunkt das einzige Ziel der API-Aufrufe bleibt. Der Produktivserver wird von der Testsuite nicht angesprochen. - -Der lokale Proxy lässt `GET`, `HEAD` und `OPTIONS` ohne Anmeldung passieren. Für `POST`, `PUT`, `PATCH` und `DELETE` antwortet er zunächst mit `401 Unauthorized` und `WWW-Authenticate: Basic`, sofern kein gültiger Authorization-Header vorhanden ist. Nach erfolgreicher Basic-Auth-Prüfung entfernt der Proxy den Header wieder und leitet den Request an den lokal per NPM gestarteten Server weiter. Damit wird ein möglicher Nginx-Passwortschutz für schreibende Operationen realistisch simuliert, ohne Authentifizierung in die Anwendung selbst einzubauen. - +Der lokale Proxy lässt `GET`, `HEAD` und `OPTIONS` ohne Anmeldung passieren. Für `POST`, `PUT`, `PATCH` und `DELETE` antwortet er zunächst mit `401 Unauthorized` und `WWW-Authenticate: Basic`, sofern kein gültiger Authorization-Header vorhanden ist. Nach erfolgreicher Basic-Auth-Prüfung entfernt der Proxy den Header wieder und leitet den Request an den lokal per NPM gestarteten Server weiter. +Damit wird ein möglicher Nginx-Passwortschutz für schreibende Operationen realistisch simuliert, ohne Authentifizierung in die Anwendung selbst einzubauen. Der Integrationstest prüft dabei ausdrücklich, dass lesende Requests nicht präventiv authentifiziert werden und dass für `POST`, `PUT` und `DELETE` jeweils zuerst ein `401` und anschließend ein erfolgreicher authentifizierter Request erfolgt. Zusätzlich bleibt ein kleiner, isolierter HTTP-Mock auf einem zufälligen Loopback-Port bestehen, der die 401-Wiederholungslogik der Python-Request-Schicht unabhängig vom vollständigen REST-Test prüft. Die kombinierte Coverage wird mit folgenden Befehlen erzeugt: - ```bash npm ci npm run test:coverage ``` Der Python-Coverage-Runner verwendet ausschließlich die Python-Standardbibliothek. Er führt die Unit-Tests mit `unittest` unter dem eingebauten `trace`-Modul aus und erzeugt daraus ohne `pip` oder zusätzliche Pakete einen Cobertura-kompatiblen Bericht. Ausführungsfehler werden mit vollständigem Python-Stacktrace auf `stderr` ausgegeben. - Dabei entstehen `coverage/js/lcov.info` für JavaScript und `coverage/python/coverage.xml` für die Python-Werkzeuge. Die zugehörigen SonarQube-Pfade stehen in `sonar-project.properties`. Die Coverage-Skripte erzwingen selbst keinen Mindestwert; die Bewertung und die Hinweise zu nicht abgedeckten Stellen bleiben Aufgabe des SonarQube Quality Gates. - ## Technische Hinweise - - Medienpfade werden relativ zu `storage/` gespeichert. So bleibt das Projekt verschiebbar. - Öffentliche GPX-, Bild- und Audiodateien werden ausschließlich über die zugehörigen `/api/routes/...`-Ressourcen ausgeliefert. Interne Speicherpfade und ein separates `/media`-URL-Schema werden nicht veröffentlicht. - Dateiverschiebung und Datenbankänderung sind durch eine kompensierende Rückverschiebung gekoppelt: Schlägt die SQL-Transaktion fehl, wird das Verzeichnis an seinen vorherigen Ort zurückbewegt. @@ -129,9 +106,7 @@ Dabei entstehen `coverage/js/lcov.info` für JavaScript und `coverage/python/cov - Bei gestarteter Route zeigt ein fixierter Footer vorrangig Entfernung und Richtung zum nächstgelegenen, noch nicht erreichten POI. Der GPX-Fortschritt wird parallel entlang der Trackpunkte nachgeführt; sind alle POIs erreicht, übernimmt wieder der nächste GPX-Trackpunkt beziehungsweise das Routenziel. Ohne Kompassdaten wird die Peilung mit Norden oben dargestellt. - Auf iPhone und iPad wird die Freigabe der Geräteausrichtung beim Start der Route innerhalb der Benutzeraktion angefordert. - Für die Geolokalisierung sollte der Client lokal über `http://127.0.0.1:47145` oder online ausschließlich über HTTPS geöffnet werden. Das betrifft ebenso den Gerätekompass. - ## Nächste Ausbaustufen - - Administrationsoberfläche zum Zeichnen/Importieren von Routen und Platzieren der POIs - Benutzer-, Schul- und Projektzuordnung mit Rollenmodell - Offline-Cache/PWA für Wanderungen ohne Mobilfunkempfang @@ -139,62 +114,44 @@ Dabei entstehen `coverage/js/lcov.info` für JavaScript und `coverage/python/cov - Administrationsoberfläche für die bereits vorhandenen Einzelendpunkte zur Medienverwaltung - Hintergrundbereinigung des Papierkorbs nach einer konfigurierbaren Aufbewahrungsfrist - Integritätsjournal für Dateiverschiebungen und Wiederherstellungen - ## Routenauswahl Die Startseite zeigt standortbasierte Empfehlungen und darunter alle aktiven Routen. Die Gesamtliste bleibt auch bei verweigertem oder nicht verfügbarem GPS auswählbar und kann nach Name, Schule oder Beschreibung durchsucht werden. - ## Mobile Bedienung und Routennavigation Das HTML verwendet nur IDs, die von den Clientmodulen tatsächlich angesprochen werden. Die wenigen Klassen bilden wiederverwendete Layoutbausteine wie Seiten, Inhalte, Hinweise, Routenlisten und Routendaten ab. Automatisierte Tests gleichen diese Verwendungen ab. - Alle selbst implementierten Schaltaktionen reagieren primär auf `pointerup`; dadurch funktionieren dieselben Handler mit Touchscreen, Eingabestift und Maus. Die Diashow kann zusätzlich horizontal gewischt werden. Für die Tastatur werden Enter und Leertaste separat behandelt. - Beim Start der Route wird der aktuellen Position nächstgelegene GPX-Trackpunkt gesucht und der Fortschritt anschließend nur vorwärts entlang der Punktfolge nachgeführt. Für die sichtbare Navigation haben jedoch POIs Vorrang: Aus allen noch nicht automatisch aktivierten Stationen wird bei jeder Positionsänderung die räumlich nächstgelegene bestimmt. Der Footer zeigt: - - Name und auf ganze Meter gerundete Luftlinienentfernung zum nächsten POI, - einen frei rotierenden SVG-Pfeil relativ zur Geräte- oder Bewegungsrichtung, - andernfalls die absolute Peilung bei Norden oben, - nach dem Aktivieren unmittelbar den nächsten noch offenen POI, - und erst nach Erreichen aller POIs wieder den nächsten GPX-Trackpunkt beziehungsweise das Routenziel. - In der Stationsliste erhält jeder POI rechts einen kleinen Richtungspfeil und seine aktuelle Entfernung in Metern. Diese Werte werden mit jeder GPS- oder Kompassaktualisierung neu berechnet. Die Reihenfolge der Stationsliste bleibt dabei unverändert. - - ## Routensteuerung und GPS-Tracking Die Routenansicht besitzt drei nebeneinanderliegende, semantische Schaltflächen mit lokalen SVG-Symbolen: - - **Route starten beziehungsweise fortsetzen:** aktiviert `navigator.geolocation.watchPosition()` und die Geräteausrichtung. - **Route pausieren:** beendet den aktiven GPS-Watcher und den Kompass-Listener, pausiert eine laufende Audioansage und behält Routenfortschritt, Audioquelle, Abspielposition und bereits ausgelöste POIs bei. - **Route beenden:** beendet alle Sensor-Listener, hält die Audioansage an, entlädt ihre Quelldatei, setzt den Fortschritt zurück und kehrt zur Routenauswahl zurück. - Die aktuell geöffnete beziehungsweise automatisch aktivierte Station wird in der Stationsliste sichtbar hervorgehoben und mit `aria-current="step"` semantisch gekennzeichnet. Beim Eintritt in den Auslöseradius wird ihre Audiodatei mit `preload="auto"` vorbereitet, aber niemals automatisch gestartet. Die Wiedergabe beginnt ausschließlich über die nativen Audio-Bedienelemente. - Die Vibration API ist in `public/js/vibration.js` gekapselt. Die aus dem historischen Projekt übernommenen Konstanten `MICRO`, `MINI`, `SHORT`, `LONG`, `ALARM` und `POI` stehen unter `Wegwichtel.Vibration.Patterns` zur Verfügung. Für eine neu aktivierte Station wird `ACTIVE_POI` mit dem Muster `[500, 300, 500]` verwendet. Nicht unterstützende Browser ignorieren den Aufruf ohne Fehler; Pause und Beenden brechen ein laufendes Muster ab. Beispiel für einen Aufruf mit Konstante: - ```javascript 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 ` - +
+ + +
@@ -22,7 +28,6 @@

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

- -
- -
  1. jQuery laden
  2. jQuery UI laden
  3. @@ -54,14 +56,12 @@
  4. Routen laden
  5. Standort ermitteln
-
-
@@ -77,7 +77,6 @@
  • Routen werden geladen …

  • -

    Alle Routen

    @@ -91,7 +90,6 @@ placeholder="Name, Schule oder Beschreibung"> -

    Routen werden geladen …

    - -
    -
    -

    Informationen

    -
    - - - - diff --git a/public/js/legal-ui.js b/public/js/legal-ui.js index 873ca81..2459c7a 100644 --- a/public/js/legal-ui.js +++ b/public/js/legal-ui.js @@ -1,10 +1,10 @@ (function (ns) { 'use strict'; + const DROPDOWN_ANIMATION_MS = 180; 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 dropdown = document.getElementById('info-dropdown'); const closeButton = document.getElementById('info-close'); const infoButton = document.getElementById('info-button'); const pageTitles = Object.freeze({ @@ -12,6 +12,8 @@ imprint: 'Impressum', technology: 'Cookies und Technik' }); + let dropdownOpen = false; + let dropdownCloseTimer = null; function isActivation(event) { if (event.type === 'keydown') return event.key === 'Enter' || event.key === ' '; @@ -21,6 +23,7 @@ } function activate(element, handler) { + if (!element) return; element.addEventListener('pointerup', event => { if (!isActivation(event)) return; event.preventDefault(); @@ -35,6 +38,64 @@ }); } + function setClass(element, className, enabled) { + if (!element || !element.classList) return; + element.classList.toggle(className, enabled); + } + + function runNextFrame(callback) { + if (typeof globalThis.requestAnimationFrame === 'function') { + globalThis.requestAnimationFrame(callback); + return; + } + callback(); + } + + function schedule(callback, timeout) { + if (typeof globalThis.setTimeout === 'function') return globalThis.setTimeout(callback, timeout); + callback(); + return null; + } + + function cancelSchedule(timer) { + if (timer === null || typeof globalThis.clearTimeout !== 'function') return; + globalThis.clearTimeout(timer); + } + + function setDropdownOpen(open) { + if (!dropdown || !infoButton) return; + dropdownOpen = open; + infoButton.setAttribute('aria-expanded', open ? 'true' : 'false'); + cancelSchedule(dropdownCloseTimer); + dropdownCloseTimer = null; + + if (open) { + dropdown.hidden = false; + runNextFrame(() => setClass(dropdown, 'is-open', true)); + return; + } + + setClass(dropdown, 'is-open', false); + dropdownCloseTimer = schedule(() => { + if (dropdownOpen) return; + dropdown.hidden = true; + }, DROPDOWN_ANIMATION_MS); + } + + function toggleDropdown() { + setDropdownOpen(!dropdownOpen); + } + + function elementContains(element, target) { + if (!element || !target) return false; + if (element === target) return true; + return typeof element.contains === 'function' && element.contains(target); + } + + function isInsideInfoControl(target) { + return elementContains(infoButton, target) || elementContains(dropdown, target); + } + function syncStorageToggles() { const enabled = ns.PrivacyStorage.isProgressStorageEnabled(); document.querySelectorAll('.progress-storage-toggle').forEach(toggle => { @@ -45,22 +106,11 @@ }); } - 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; }); @@ -68,18 +118,22 @@ if (content) content.scrollTop = 0; } - function openDialog() { - if (dialog.open) return; - showMenu(); + function openDialog(name = 'privacy') { + const pageName = Object.prototype.hasOwnProperty.call(pageTitles, name) ? name : 'privacy'; + setDropdownOpen(false); + showPage(pageName); syncStorageToggles(); - if (typeof dialog.showModal === 'function') dialog.showModal(); - else dialog.setAttribute('open', ''); + if (!dialog.open) { + 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'); + setDropdownOpen(false); infoButton.focus(); } @@ -95,11 +149,21 @@ }); } - activate(infoButton, openDialog); + activate(infoButton, toggleDropdown); activate(closeButton, closeDialog); - activate(backButton, showMenu); document.querySelectorAll('[data-info-target]').forEach(button => { - activate(button, () => showPage(button.dataset.infoTarget)); + activate(button, () => openDialog(button.dataset.infoTarget)); + }); + + document.addEventListener('pointerup', event => { + if (!dropdownOpen || isInsideInfoControl(event.target)) return; + setDropdownOpen(false); + }); + document.addEventListener('keydown', event => { + if (!dropdownOpen || event.key !== 'Escape' || dialog.open) return; + event.preventDefault(); + setDropdownOpen(false); + infoButton.focus(); }); dialog.addEventListener('pointerup', event => { @@ -116,5 +180,11 @@ bindStorageToggles(); syncStorageToggles(); - ns.LegalUi = Object.freeze({ open: openDialog, close: closeDialog, syncStorageToggles }); + ns.LegalUi = Object.freeze({ + open: openDialog, + close: closeDialog, + toggleMenu: toggleDropdown, + closeMenu: () => setDropdownOpen(false), + syncStorageToggles + }); }(globalThis.Wegwichtel ??= {})); diff --git a/test/legal-ui.test.js b/test/legal-ui.test.js index a532f24..589f7dc 100644 --- a/test/legal-ui.test.js +++ b/test/legal-ui.test.js @@ -14,7 +14,6 @@ test('start screen exposes cookie status, optional local progress and deliberate 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"/); @@ -28,12 +27,15 @@ test('start screen exposes cookie status, optional local progress and deliberate assert.match(storage, /cookiesEnabled: \(\) => false/); }); -test('information button opens scrollable overlay pages for privacy, imprint and technology', async () => { +test('information button toggles dropdown and opens scrollable overlay pages', 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, /aria-haspopup="true"/); + assert.match(html, /aria-expanded="false"/); + assert.match(html, /id="info-dropdown"/); 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\)/); @@ -60,7 +63,6 @@ test('application persists current route, viewed stations and played audio only assert.match(app, /await initializeLocation\(context\)/); }); - class FakeElement extends EventTarget { constructor(id = '') { super(); @@ -74,12 +76,28 @@ class FakeElement extends EventTarget { this.dataset = {}; this.attributes = new Map(); this.focused = false; + this.classNames = new Set(); + this.classList = { + toggle: (name, enabled) => { + if (enabled) this.classNames.add(name); + else this.classNames.delete(name); + }, + contains: name => this.classNames.has(name) + }; } focus() { this.focused = true; } + contains(target) { + return target === this; + } + + getAttribute(name) { + return this.attributes.get(name) ?? null; + } + setAttribute(name, value) { this.attributes.set(name, String(value)); if (name === 'open') this.open = true; @@ -115,10 +133,9 @@ test('legal overlay handlers synchronize switches without advancing the start sc 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')] + ['info-button', new FakeElement('info-button')], + ['info-dropdown', new FakeElement('info-dropdown')] ]); const content = new FakeElement(); const privacyPage = new FakeElement(); @@ -134,14 +151,12 @@ test('legal overlay handlers synchronize switches without advancing the start sc 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 => { @@ -157,7 +172,6 @@ test('legal overlay handlers synchronize switches without advancing the start sc return match ? pages[match[1]] : null; }; document.addEventListener('wegwichtel:progress-storage-changed', event => changes.push(event.detail.enabled)); - const context = { Wegwichtel: { PrivacyStorage: { @@ -177,12 +191,16 @@ test('legal overlay handlers synchronize switches without advancing the start sc 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); + assert.equal(elements.get('info-dialog').open, false); + assert.equal(elements.get('info-dropdown').hidden, false); + assert.equal(elements.get('info-dropdown').classList.contains('is-open'), true); + assert.equal(elements.get('info-button').getAttribute('aria-expanded'), 'true'); targetButtons[0].dispatchEvent(pointerEvent()); + assert.equal(elements.get('info-dialog').open, true); + assert.equal(elements.get('info-dropdown').hidden, true); + assert.equal(elements.get('info-button').getAttribute('aria-expanded'), 'false'); 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; @@ -191,8 +209,6 @@ test('legal overlay handlers synchronize switches without advancing the start sc 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); });