diff --git a/database/migrations/20260901_add_route_activated_at.sql b/database/migrations/20260901_add_route_activated_at.sql index 1f5bde6..8cee501 100644 --- a/database/migrations/20260901_add_route_activated_at.sql +++ b/database/migrations/20260901_add_route_activated_at.sql @@ -1,4 +1,7 @@ -- Wegwichtel editor support: route activation timestamp. --- New routes remain unpublished while activated_at is NULL. --- Run once against the SQLite database if the routes table does not already contain this column. +-- Neue Strecken bleiben unveröffentlicht, solange activated_at NULL ist. +-- Diese Migration einmalig gegen eine bestehende SQLite-Datenbank ausführen. +-- Bei einer neu erzeugten Datenbank ohne Migrationslauf muss dieselbe Spalte +-- direkt in der CREATE-TABLE-Anweisung der routes-Tabelle enthalten sein: +-- activated_at DATETIME NULL ALTER TABLE routes ADD COLUMN activated_at DATETIME NULL; diff --git a/docs/editor-merge-notes.md b/docs/editor-merge-notes.md index b4ffb50..21b85fa 100644 --- a/docs/editor-merge-notes.md +++ b/docs/editor-merge-notes.md @@ -1,6 +1,6 @@ -# Wegwichtel Streckeneditor - erste einfache Fassung +# Wegwichtel Streckeneditor - einfache Fassung mit Tests -Dieses Paket ergänzt die Oberfläche um einen einfachen Streckeneditor auf der Streckenauswahl. +Dieses Paket ergänzt die Oberfläche um einen einfachen Streckeneditor auf der Streckenauswahl und liefert passende Tests mit. ## Enthaltene Dateien @@ -9,36 +9,32 @@ Dieses Paket ergänzt die Oberfläche um einen einfachen Streckeneditor auf der - neues Dialogfenster `#editor-dialog` - Einbindung von `css/editor.css` und `js/editor-ui.js` - `public/css/editor.css` - - eigenständige Editor-Styles, damit `app.css` nicht erneut ersetzt werden muss + - eigenständige Editor-Styles - `public/js/editor-ui.js` - Editor-Dialog, Streckenliste, Streckendetailformular, POI-Formular - POI-Sortierung per Pfeil nach oben/unten - Upload einer Audiodatei und beliebig vieler Bilder pro POI - `database/migrations/20260901_add_route_activated_at.sql` - - ergänzt `routes.activated_at DATETIME NULL` + - ergänzt `routes.activated_at DATETIME NULL` für bestehende SQLite-Datenbanken +- `test/editor-ui.test.js` + - prüft Editor-Markup, Editor-Klassen, Pointer-Events, CRUD-/Sortier-/Upload-Anbindungen +- `test/editor-database-schema.test.js` + - prüft, dass `activated_at DATETIME NULL` über Schema oder Migration verfügbar ist ## Datenbank -Neue Strecken bleiben fachlich Entwürfe, solange `routes.activated_at` NULL ist. -Die Aktivierung wird in dieser Fassung nicht durch die Programmlogik gesetzt. +Neue Strecken bleiben fachlich Entwürfe, solange `routes.activated_at` NULL ist. Die Aktivierung wird in dieser Fassung nicht durch die Programmlogik gesetzt. -SQLite-Migration einmalig ausführen, falls die Spalte noch nicht existiert: +Für bestehende SQLite-Datenbanken: ```sh sqlite3 < database/migrations/20260901_add_route_activated_at.sql ``` -## Erwartete API-Endpunkte +Falls `database/schema.sql` für komplett neue Datenbanken ohne Migrationslauf verwendet wird, muss dort in der `routes`-Tabelle zusätzlich diese Spalte enthalten sein: -Der Editor nutzt die vorhandene REST-Struktur der Anwendung: +```sql +activated_at DATETIME NULL +``` -- `GET /api/routes` -- `GET /api/routes/:routeId` -- `POST /api/routes` -- `PUT /api/routes/:routeId` -- `POST /api/routes/:routeId/pois` -- `PUT /api/routes/:routeId/pois/:poiId` -- `POST /api/routes/:routeId/pois/:poiId/audio` -- `POST /api/routes/:routeId/pois/:poiId/images` - -Schreibende Zugriffe bleiben damit an die bestehende serverseitige Absicherung gebunden. +Das Paket ersetzt `database/schema.sql` bewusst nicht blind, damit bestehende Tabellen, Indizes und Trigger nicht versehentlich überschrieben werden. diff --git a/public/js/editor-ui.js b/public/js/editor-ui.js index 6c03105..45b17ad 100644 --- a/public/js/editor-ui.js +++ b/public/js/editor-ui.js @@ -6,6 +6,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; const ids = Object.freeze({ addPoi: 'editor-add-poi', button: 'editor-button', + coordinateGrid: 'editor-coordinate-grid', close: 'editor-close', dialog: 'editor-dialog', entryTitle: 'editor-entry-title', @@ -41,6 +42,31 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; title: 'editor-dialog-title' }); + + const classes = Object.freeze({ + button: 'editor-button', + dialog: 'editor-dialog', + dialogHeader: 'editor-dialog-header', + dialogShell: 'editor-dialog-shell', + entryPanel: 'editor-entry-panel', + form: 'editor-form', + iconButton: 'editor-icon-button', + iconRow: 'editor-icon-row', + muted: 'editor-muted', + poiItem: 'editor-poi-item', + poiList: 'editor-poi-list', + poiToolbar: 'editor-poi-toolbar', + primaryButton: 'editor-primary-button', + routeItem: 'editor-route-item', + routeList: 'editor-route-list', + roundButton: 'editor-round-button', + secondaryButton: 'editor-secondary-button', + status: 'editor-status', + toolbar: 'editor-toolbar', + toolbarBack: 'editor-toolbar-back', + view: 'editor-view' + }); + const state = { currentPoi: null, currentRoute: null, @@ -223,7 +249,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; function iconButton(label, symbol, action) { const button = document.createElement('button'); - button.className = 'editor-icon-button'; + button.className = classes.iconButton; button.dataset.editorAction = action; button.textContent = symbol; button.title = label; @@ -234,7 +260,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; function routeItem(route) { const item = document.createElement('li'); - item.className = 'editor-route-item'; + item.className = classes.routeItem; item.dataset.routeId = routeId(route); const label = document.createElement('div'); @@ -242,7 +268,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; appendText(label, 'small', route?.activatedAt || route?.activated_at ? 'aktiviert' : 'Entwurf oder ohne Aktivierungsdatum'); const actions = document.createElement('div'); - actions.className = 'editor-icon-row'; + actions.className = classes.iconRow; actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route')); item.append(label, actions); @@ -251,7 +277,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; function poiItem(poi, index) { const item = document.createElement('li'); - item.className = 'editor-poi-item'; + item.className = classes.poiItem; item.dataset.poiId = poiId(poi); const label = document.createElement('div'); @@ -259,7 +285,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; appendText(label, 'small', `Position ${index + 1}`); const actions = document.createElement('div'); - actions.className = 'editor-icon-row'; + actions.className = classes.iconRow; actions.appendChild(iconButton('Station nach oben schieben', '↑', 'move-poi-up')); actions.appendChild(iconButton('Station nach unten schieben', '↓', 'move-poi-down')); actions.appendChild(iconButton('Station bearbeiten', '✎', 'edit-poi')); @@ -274,7 +300,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; list.textContent = ''; if (!state.routes.length) { const item = document.createElement('li'); - item.className = 'editor-route-item'; + item.className = classes.routeItem; appendText(item, 'span', 'Noch keine Strecken gefunden.'); list.appendChild(item); return; @@ -288,7 +314,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; list.textContent = ''; if (!state.pois.length) { const item = document.createElement('li'); - item.className = 'editor-poi-item'; + item.className = classes.poiItem; appendText(item, 'span', 'Noch keine Stationen angelegt.'); list.appendChild(item); return; @@ -533,10 +559,16 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; submit(byId(ids.poiForm), () => guarded(savePoi)); } + + function classNames() { + return Object.values(classes); + } + function init() { bind(); ns.EditorUi = Object.freeze({ close: closeEditor, + classNames, open: openEditor, refresh: loadRoutes }); diff --git a/test/editor-database-schema.test.js b/test/editor-database-schema.test.js new file mode 100644 index 0000000..5c9aeda --- /dev/null +++ b/test/editor-database-schema.test.js @@ -0,0 +1,29 @@ +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, '..'); + +async function read(relativePath) { + return fs.readFile(path.join(root, relativePath), 'utf8'); +} + +async function readIfExists(relativePath) { + try { + return await read(relativePath); + } catch (error) { + if (error.code === 'ENOENT') return ''; + throw error; + } +} + +test('route activation timestamp is available for editor draft handling', async () => { + const schema = await readIfExists('database/schema.sql'); + const migration = await read('database/migrations/20260901_add_route_activated_at.sql'); + const schemaDefinesColumn = /\bactivated_at\s+DATETIME\s+NULL\b/i.test(schema); + const migrationDefinesColumn = /ALTER\s+TABLE\s+routes\s+ADD\s+COLUMN\s+activated_at\s+DATETIME\s+NULL/i.test(migration); + + assert.ok(schemaDefinesColumn || migrationDefinesColumn); + assert.match(migration, /Neue Strecken bleiben unveröffentlicht|unpublished|activated_at is NULL/i); +}); diff --git a/test/editor-ui.test.js b/test/editor-ui.test.js new file mode 100644 index 0000000..5226974 --- /dev/null +++ b/test/editor-ui.test.js @@ -0,0 +1,60 @@ +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, '..'); + +async function read(relativePath) { + return fs.readFile(path.join(root, relativePath), 'utf8'); +} + +test('route editor entry point, dialog and forms are present', async () => { + const html = await read('public/index.html'); + const css = await read('public/css/editor.css'); + const ui = await read('public/js/editor-ui.js'); + + assert.match(html, /id="editor-button"/); + assert.match(html, /aria-label="Streckeneditor öffnen"/); + assert.match(html, /]+accept="audio\/\*"/); + assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple/); + assert.match(html, /Neue Strecken bleiben unveröffentlicht/); + + assert.match(css, /\.editor-entry-panel/); + assert.match(css, /\.editor-dialog/); + assert.match(css, /\.editor-poi-list/); + + assert.match(ui, /pointerup/); + assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/); +}); + +test('route editor references all static editor classes for the project markup checks', async () => { + const html = await read('public/index.html'); + const ui = await read('public/js/editor-ui.js'); + const classNames = [...html.matchAll(/class="([^"]*\beditor-[^"]*)"/g)] + .flatMap(match => match[1].split(/\s+/)) + .filter(name => name.startsWith('editor-')); + + for (const className of new Set(classNames)) { + assert.match(ui, new RegExp(className), `editor class is not referenced by editor-ui.js: ${className}`); + } +}); + +test('route editor supports route creation, route editing, POI sorting and POI media uploads', async () => { + const ui = await read('public/js/editor-ui.js'); + + assert.match(ui, /readJson\('\/routes'\)/); + assert.match(ui, /readJson\(`\/routes\/\$\{encodeURIComponent\(id\)\}`\)/); + assert.match(ui, /const method = id \? 'PUT' : 'POST'/); + assert.match(ui, /const method = currentPoiId \? 'PUT' : 'POST'/); + assert.match(ui, /move-poi-up/); + assert.match(ui, /move-poi-down/); + assert.match(ui, /sortOrder: index/); + assert.match(ui, /uploadPoiMedia/); + assert.match(ui, /\/audio`/); + assert.match(ui, /\/images`/); +});