diff --git a/src/lib/funktionenView.js b/src/lib/funktionenView.js index 1005c64..d1cd727 100644 --- a/src/lib/funktionenView.js +++ b/src/lib/funktionenView.js @@ -1,4 +1,4 @@ -// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner), Aktionen je Zustand und +// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner, Öffnungszeiten live), Aktionen je Zustand und // Einarbeiten der Portal-Antwort. Rein, unter Node testbar. import { fmtYmd, fmtBerlin } from './format' @@ -24,6 +24,16 @@ export const FUNKTIONEN = [ ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?', }, }, + { + key: 'zeiten', + label: 'Öffnungszeiten live', + bestaetigen: { + activate: 'Öffnungszeiten live ohne Zahlung aktivieren? Es startet der Einbau: Das Einbau-Ticket wird angelegt und die Seite, wenn möglich, neu gebaut. Freigeschaltet wird, sobald das Ticket geschlossen ist und der Kunde seine Zeiten eingetragen hat.', + disable: 'Öffnungszeiten live abschalten? Leisten und Live-Anzeige verschwinden von der Website, die feste Ersatz-Tabelle bleibt stehen. Die Angaben des Kunden bleiben gespeichert. Weicht der Wochenplan von der Ersatz-Tabelle ab, wird ein Ticket „Ersatz-Tabelle aktualisieren“ angelegt.', + abnahme: 'Öffnungszeiten live freischalten? Der Kunde bekommt die Mail „bereit“ und der Einbau gilt als abgenommen.', + ticket: 'Fehlendes Einbau-Ticket neu anlegen?', + }, + }, ] /** Zustand einer Funktion -> [Ton des Abzeichens, Beschriftung]. */ @@ -58,12 +68,12 @@ export function abgeschaltetText(feature) { export const funktionOf = (project, key) => project?.features?.[key] || { state: 'off' } /** - * Site-Key (Schluessel fuer den Einbau auf der Kundenwebsite) einer Funktion: nur beim Terminplaner und nur, wenn das Portal - * einen nennt (feature.siteKey); sonst ''. + * Site-Key (Schluessel fuer den Einbau auf der Kundenwebsite) einer Funktion: nur beim Terminplaner und bei Oeffnungszeiten live + * und nur, wenn das Portal einen nennt (feature.siteKey); sonst ''. */ export function siteKeyOf(feature, key) { const wert = feature?.siteKey - return key === 'termine' && typeof wert === 'string' ? wert.trim() : '' + return (key === 'termine' || key === 'zeiten') && typeof wert === 'string' ? wert.trim() : '' } /** Beschriftung der Aktionsknoepfe (Aktion = action im POST /funktionen/:customerId/:key). */ @@ -76,13 +86,20 @@ export const AKTION_LABEL = { /** * Moegliche Aktionen je Zustand. News (und ohne key): Aktivieren (ohne Zahlung) ausser bei "active", Abschalten solange bestellt, in - * Einrichtung oder aktiv. Terminplaner: "ordered" nur Abschalten (Aktivieren ohne Zahlung wuerde eine spaetere Zahlung im Portal - * verschlucken), "setup" Freischalten nach der Abnahme (mit ticketMissing zuerst "Ticket nachlegen"), "active" nur Abschalten. + * Einrichtung oder aktiv. Terminplaner und Oeffnungszeiten live (beide mit Ticket-Freischaltung): "ordered" nur Abschalten (Aktivieren + * ohne Zahlung wuerde eine spaetere Zahlung im Portal verschlucken), "setup" Freischalten nach der Abnahme (mit ticketMissing zuerst + * "Ticket nachlegen"), "active" nur Abschalten; sonst ("off", "disabled", ohne Zustand) Aktivieren. Fuer "disabled" gibt es kein + * Abschalten, auch wenn das Ersatz-Ticket beim Abschalten fehlschlug (das Portal merkt sich das nicht; die Antwort nennt es nur einmal). + * hindernis (Portal: feature.hindernis): beim Terminplaner bleibt die Abnahme sichtbar und das Portal sperrt sie mit Begruendung + * (hindernisText); bei Oeffnungszeiten live entfaellt sie, solange der Kunde noch keine Zeiten eingetragen hat ('zeiten_fehlen'). */ -export function aktionen(state, key, { ticketMissing = false } = {}) { - if (key === 'termine') { +export function aktionen(state, key, { ticketMissing = false, hindernis = null } = {}) { + if (key === 'termine' || key === 'zeiten') { if (state === 'ordered' || state === 'active') return ['disable'] - if (state === 'setup') return ticketMissing ? ['ticket', 'abnahme', 'disable'] : ['abnahme', 'disable'] + if (state === 'setup') { + const abnahme = key === 'zeiten' && hindernis ? [] : ['abnahme'] + return [...(ticketMissing ? ['ticket'] : []), ...abnahme, 'disable'] + } return ['activate'] } const list = [] diff --git a/src/lib/viewLog.js b/src/lib/viewLog.js index 4354158..5a06c9f 100644 --- a/src/lib/viewLog.js +++ b/src/lib/viewLog.js @@ -11,6 +11,7 @@ const LABELS = { termin_einstellungen: 'Terminplaner-Einstellungen geändert', termin_aktion: 'Termin bearbeitet', termin_manuell: 'Termin eingetragen', + zeiten_gespeichert: 'Öffnungszeiten gespeichert', } /** Deutsche Beschriftung einer Aktion; unbekannte Aktionen erscheinen roh. */ diff --git a/src/lib/zeitenView.js b/src/lib/zeitenView.js new file mode 100644 index 0000000..cc78426 --- /dev/null +++ b/src/lib/zeitenView.js @@ -0,0 +1,77 @@ +// Oeffnungszeiten live im Kunden-Reiter "Funktionen": Statustext je Projekt, Pruefung des Navigations-Selektors (Team-Einstellung) und +// Rumpf fuer PATCH /funktionen/:customerId/zeiten/team. Rein, unter Node testbar (keine Appwrite-Importe). + +import { berlinYmd, fmtYmd } from './format' + +const TEXT_EINBAU = 'Leisten live – Einbau läuft' +const TEXT_WARTET = 'wartet auf die Zeiten des Kunden' +/** Tage zwischen dem erkannten Abo-Ende (aboEndeAt) und dem Abschalten (Portal: zeiten/aboAbgleich.js, FRIST_TAGE). */ +const FRIST_TAGE = 30 + +/** Kalendertag 'JJJJ-MM-TT' plus n Tage ('JJJJ-MM-TT'); reine Kalenderrechnung ohne Uhrzeit, also ohne Zeitumstellung. */ +function plusTage(ymd, tage) { + const [jahr, monat, tag] = ymd.split('-').map(Number) + return new Date(Date.UTC(jahr, monat - 1, tag + tage)).toISOString().slice(0, 10) +} + +/** + * Hinweis auf das Ende des Wartungs-Abos: die Funktion wird am Berliner Tag von aboEndeAt plus 30 Tage abgeschaltet, ausser der + * Kunde behaelt sie. Mit unlesbarem Datum ohne Tag ("nach 30 Tagen"), nie mit einem erfundenen. + */ +function aboEndeText(aboEndeAt) { + const lesbar = typeof aboEndeAt === 'string' && !Number.isNaN(Date.parse(aboEndeAt)) + const wann = lesbar ? `am ${fmtYmd(plusTage(berlinYmd(aboEndeAt), FRIST_TAGE))}` : `nach ${FRIST_TAGE} Tagen` + return `Abo beendet – Abschaltung ${wann}, falls der Kunde die Funktion nicht behält` +} + +/** + * Statustext unter dem Abzeichen (Portal: project.features.zeiten): in der Einrichtung "Leisten live – Einbau läuft" bzw. bei + * hindernis 'zeiten_fehlen' "wartet auf die Zeiten des Kunden"; in Einrichtung und aktiv zusaetzlich der Abo-Ende-Hinweis mit Frist + * (feature.aboEndeAt), durch " · " getrennt. In allen anderen Zustaenden '': Grund und Loeschdatum einer Abschaltung nennt + * abgeschaltetText (funktionenView). Ohne Anrede, nie "24 Uhr". + */ +export function zeitenStatusText(feature) { + const state = feature?.state + if (state !== 'setup' && state !== 'active') return '' + const teile = [] + if (state === 'setup') teile.push(feature.hindernis === 'zeiten_fehlen' ? TEXT_WARTET : TEXT_EINBAU) + if (feature.aboEndeAt) teile.push(aboEndeText(feature.aboEndeAt)) + return teile.join(' · ') +} + +// --- Team-Einstellungen (nur ueber WOMS aenderbar) --- + +/** Hoechstlaenge des Navigations-Selektors (Portal: ZEITEN_MAX.navSelektor). */ +export const NAV_SELEKTOR_MAX = 120 +/** Erlaubt sind A-Z a-z 0-9, Leerzeichen und # . - _ > : [ ] = " ' (also nie < { } ;); Spiegel von NAV_RE in zeiten/pruefen.js. */ +const NAV_RE = /^[A-Za-z0-9 #.\-_>:[\]="']+$/ +/** Meldung wie im Portal (normalisiereTeam), damit Formular und Server dieselbe Regel gleich benennen. */ +export const NAV_SELEKTOR_FEHLER = `Der Selektor darf höchstens ${NAV_SELEKTOR_MAX} Zeichen lang sein und nur A–Z, 0–9, Leerzeichen und # . - _ > : [ ] = " ' enthalten.` + +/** + * Prueft den Navigations-Selektor (wohin die Hinweis-Leiste der Website sich haengt) wie normalisiereTeam im Portal: nach dem Trimmen + * hoechstens 120 Zeichen aus der erlaubten Menge; leer (auch nur Leerraum, null, undefined) heisst "kein Selektor" und ist gueltig; kein Text + * ist ungueltig. => Fehlermeldung oder null. + */ +export function navSelektorFehler(text) { + if (text === undefined || text === null) return null + const s = typeof text === 'string' ? text.trim() : null + if (s === null || s.length > NAV_SELEKTOR_MAX || (s !== '' && !NAV_RE.test(s))) return NAV_SELEKTOR_FEHLER + return null +} + +/** + * Rumpf fuer PATCH /funktionen/:customerId/zeiten/team: { projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus }. + * Selektor getrimmt, leer => null; die Schalter sind immer echte Wahrheitswerte (nur true gilt als an). Die Version (teamVersion aus + * der Ansicht) und ein ungueltiger Selektor gehen unveraendert mit: das Portal prueft beides (409 ZEITEN_VERALTET, 400 mit fehler). + */ +export function teamBody({ projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus }) { + const selektor = typeof navSelektor === 'string' ? navSelektor.trim() : navSelektor + return { + projectId, + teamVersion, + navSelektor: selektor === '' || selektor === undefined ? null : selektor, + kontaktleisteAus: kontaktleisteAus === true, + hinweisleisteAus: hinweisleisteAus === true, + } +} diff --git a/tests/unit/funktionenView.test.mjs b/tests/unit/funktionenView.test.mjs index 131d6f1..ce02991 100644 --- a/tests/unit/funktionenView.test.mjs +++ b/tests/unit/funktionenView.test.mjs @@ -25,7 +25,7 @@ const { featureBody, } = await loadModule('src/lib/funktionenView.js') -test('FUNKTIONEN: News und Terminplaner mit den Bestätigungstexten', () => { +test('FUNKTIONEN: News, Terminplaner und Öffnungszeiten live (in dieser Reihenfolge) mit den Bestätigungstexten', () => { assert.deepEqual(FUNKTIONEN, [ { key: 'news', @@ -45,9 +45,28 @@ test('FUNKTIONEN: News und Terminplaner mit den Bestätigungstexten', () => { ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?', }, }, + { + key: 'zeiten', + label: 'Öffnungszeiten live', + bestaetigen: { + activate: 'Öffnungszeiten live ohne Zahlung aktivieren? Es startet der Einbau: Das Einbau-Ticket wird angelegt und die Seite, wenn möglich, neu gebaut. Freigeschaltet wird, sobald das Ticket geschlossen ist und der Kunde seine Zeiten eingetragen hat.', + disable: 'Öffnungszeiten live abschalten? Leisten und Live-Anzeige verschwinden von der Website, die feste Ersatz-Tabelle bleibt stehen. Die Angaben des Kunden bleiben gespeichert. Weicht der Wochenplan von der Ersatz-Tabelle ab, wird ein Ticket „Ersatz-Tabelle aktualisieren“ angelegt.', + abnahme: 'Öffnungszeiten live freischalten? Der Kunde bekommt die Mail „bereit“ und der Einbau gilt als abgenommen.', + ticket: 'Fehlendes Einbau-Ticket neu anlegen?', + }, + }, ]) }) +test('FUNKTIONEN: Schlüssel sind eindeutig und jede Funktion hat Aktivieren und Abschalten bestätigt', () => { + assert.deepEqual(FUNKTIONEN.map((f) => f.key), ['news', 'termine', 'zeiten']) + for (const f of FUNKTIONEN) { + assert.ok(f.bestaetigen.activate && f.bestaetigen.disable, f.key) + } + const zeiten = FUNKTIONEN.find((f) => f.key === 'zeiten').bestaetigen + for (const text of Object.values(zeiten)) assert.doesNotMatch(text, /24 Uhr/, 'nie "24 Uhr"') +}) + test('W1: der Abschalten-Dialog des Terminplaners nennt Buchungsstopp, Löschung nach 30 Tagen und die neue Bestellung (149 €)', () => { const text = FUNKTIONEN.find((f) => f.key === 'termine').bestaetigen.disable assert.match(text, /^Terminplaner abschalten\?/) @@ -120,6 +139,40 @@ test('aktionen: Terminplaner mit fehlendem Ticket bietet in der Einrichtung zuer } }) +test('aktionen: Öffnungszeiten live – aus nur Aktivieren, bestellt nur Abschalten, Einrichtung mit Abnahme, aktiv nur Abschalten, abgeschaltet nur Aktivieren', () => { + assert.deepEqual(aktionen('off', 'zeiten'), ['activate']) + assert.deepEqual(aktionen('ordered', 'zeiten'), ['disable'], 'wie beim Terminplaner: Aktivieren würde eine spätere Zahlung verschlucken') + assert.deepEqual(aktionen('setup', 'zeiten'), ['abnahme', 'disable']) + assert.deepEqual(aktionen('active', 'zeiten'), ['disable']) + assert.deepEqual(aktionen('disabled', 'zeiten'), ['activate']) + assert.deepEqual(aktionen(undefined, 'zeiten'), ['activate']) +}) + +test('aktionen: Öffnungszeiten live bietet die Abnahme nur ohne Hindernis an (wartet auf die Zeiten des Kunden: nur Abschalten)', () => { + assert.deepEqual(aktionen('setup', 'zeiten', { hindernis: 'zeiten_fehlen' }), ['disable']) + assert.deepEqual(aktionen('setup', 'zeiten', { hindernis: null }), ['abnahme', 'disable']) + assert.deepEqual(aktionen('setup', 'zeiten', { hindernis: undefined }), ['abnahme', 'disable']) + assert.deepEqual(aktionen('setup', 'zeiten', { ticketMissing: true, hindernis: 'zeiten_fehlen' }), ['ticket', 'disable']) + for (const state of ['off', 'ordered', 'active', 'disabled', undefined]) { + assert.deepEqual(aktionen(state, 'zeiten', { hindernis: 'zeiten_fehlen' }), aktionen(state, 'zeiten'), `${String(state)}: hindernis nur in setup`) + } +}) + +test('aktionen: Öffnungszeiten live mit fehlendem Ticket bietet in der Einrichtung zuerst "ticket" an (wie der Terminplaner)', () => { + assert.deepEqual(aktionen('setup', 'zeiten', { ticketMissing: true }), ['ticket', 'abnahme', 'disable']) + assert.deepEqual(aktionen('setup', 'zeiten', { ticketMissing: false }), ['abnahme', 'disable']) + for (const state of ['off', 'ordered', 'active', 'disabled', undefined]) { + assert.deepEqual(aktionen(state, 'zeiten', { ticketMissing: true }), aktionen(state, 'zeiten'), `${String(state)}: ticketMissing nur in setup`) + } +}) + +test('aktionen: Terminplaner und News ignorieren hindernis (der Terminplaner zeigt die Abnahme weiter, das Portal sperrt)', () => { + for (const hindernis of ['kein_abo', 'keine_einstellung', 'abo_unbekannt', 'zeiten_fehlen', null]) { + assert.deepEqual(aktionen('setup', 'termine', { hindernis }), ['abnahme', 'disable'], String(hindernis)) + assert.deepEqual(aktionen('setup', 'news', { hindernis }), ['activate', 'disable'], String(hindernis)) + } +}) + test('AKTION_LABEL: Beschriftung je Aktion', () => { assert.deepEqual(AKTION_LABEL, { activate: 'Aktivieren (ohne Zahlung)', @@ -312,6 +365,17 @@ test('siteKeyOf: nur der Terminplaner zeigt seinen Site-Key, nur wenn das Portal } }) +test('siteKeyOf liefert für zeiten den Schlüssel, für news weiter ""', () => { + assert.equal(siteKeyOf({ state: 'setup', siteKey: 'abcdefghijklmnop' }, 'zeiten'), 'abcdefghijklmnop') + assert.equal(siteKeyOf({ state: 'setup', siteKey: ' abcdefghijklmnop ' }, 'zeiten'), 'abcdefghijklmnop', 'getrimmt') + assert.equal(siteKeyOf({ state: 'active', siteKey: 'abcdefghijklmnop' }, 'news'), '') + assert.equal(siteKeyOf({ state: 'active', siteKey: 'abcdefghijklmnop' }, undefined), '') + assert.equal(siteKeyOf({ state: 'active', siteKey: 'abcdefghijklmnop' }, 'unbekannt'), '') + for (const feature of [{ state: 'off' }, { state: 'setup', siteKey: null }, { state: 'setup', siteKey: '' }, { state: 'setup', siteKey: 5 }, null, undefined]) { + assert.equal(siteKeyOf(feature, 'zeiten'), '', JSON.stringify(feature)) + } +}) + test('abgeschaltetText: Grund und Löschdatum im Zustand "disabled"', () => { const text = (disabledReason, loeschungAm) => abgeschaltetText({ state: 'disabled', disabledReason, loeschungAm }) assert.equal(text('abo_ende', '2026-11-04'), 'Abgeschaltet (Abo beendet), Löschung am 04.11.2026') diff --git a/tests/unit/viewLog.test.mjs b/tests/unit/viewLog.test.mjs index 67c5e9f..d5117c5 100644 --- a/tests/unit/viewLog.test.mjs +++ b/tests/unit/viewLog.test.mjs @@ -5,15 +5,15 @@ import { loadModule } from './load.mjs' // Protokoll der Kundenansicht: Aktionen des Portals (customerViewLog.action) als deutsche Beschriftung const { viewLogLabel, formatViewLogTime } = await loadModule('src/lib/viewLog.js') -test('viewLogLabel: alle zehn Aktionen und Rohwert', () => { +test('viewLogLabel: alle elf Aktionen und Rohwert', () => { assert.deepEqual( [ 'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht', - 'termin_einstellungen', 'termin_aktion', 'termin_manuell', + 'termin_einstellungen', 'termin_aktion', 'termin_manuell', 'zeiten_gespeichert', ].map(viewLogLabel), [ 'Kundenansicht geöffnet', 'Kundenansicht beendet', 'Kundenansicht abgelaufen', 'News angelegt', 'News-Foto hochgeladen', 'News geändert', 'News gelöscht', - 'Terminplaner-Einstellungen geändert', 'Termin bearbeitet', 'Termin eingetragen', + 'Terminplaner-Einstellungen geändert', 'Termin bearbeitet', 'Termin eingetragen', 'Öffnungszeiten gespeichert', ] ) assert.equal(viewLogLabel('neu_irgendwas'), 'neu_irgendwas') diff --git a/tests/unit/zeitenView.test.mjs b/tests/unit/zeitenView.test.mjs new file mode 100644 index 0000000..e5d3e11 --- /dev/null +++ b/tests/unit/zeitenView.test.mjs @@ -0,0 +1,154 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +// Öffnungszeiten live im Funktionen-Reiter: Statustexte, Prüfung des Navigations-Selektors (Spiegel des Portals) und Rumpf der Team-Einstellungen +const { zeitenStatusText, navSelektorFehler, NAV_SELEKTOR_MAX, NAV_SELEKTOR_FEHLER, teamBody } = await loadModule('src/lib/zeitenView.js') + +const EINBAU = 'Leisten live – Einbau läuft' +const WARTET = 'wartet auf die Zeiten des Kunden' + +test('zeitenStatusText: Einrichtung ohne Hindernis – Leisten sind live, der Einbau läuft', () => { + assert.equal(zeitenStatusText({ state: 'setup', hindernis: null }), EINBAU) + assert.equal(zeitenStatusText({ state: 'setup' }), EINBAU, 'ohne Feld hindernis') + assert.equal(zeitenStatusText({ state: 'setup', hindernis: '' }), EINBAU) +}) + +test('zeitenStatusText: Einrichtung mit hindernis "zeiten_fehlen" – wartet auf die Zeiten des Kunden', () => { + assert.equal(zeitenStatusText({ state: 'setup', hindernis: 'zeiten_fehlen' }), WARTET) +}) + +test('zeitenStatusText: unbekanntes Hindernis zeigt nichts Erfundenes, sondern den Einbau-Text', () => { + assert.equal(zeitenStatusText({ state: 'setup', hindernis: 'irgendwas_neues' }), EINBAU) +}) + +test('zeitenStatusText: Abo-Ende nennt die Frist (Berliner Tag des Abo-Endes plus 30 Tage)', () => { + const abo = (state, aboEndeAt) => zeitenStatusText({ state, hindernis: null, aboEndeAt }) + const hinweis = (datum) => `Abo beendet – Abschaltung am ${datum}, falls der Kunde die Funktion nicht behält` + assert.equal(abo('active', '2026-10-04T10:00:00.000Z'), hinweis('03.11.2026')) + // 22:30 UTC ist in Berlin schon der nächste Tag (Sommerzeit, +2 h): 05.10. -> 04.11. + assert.equal(abo('active', '2026-10-04T22:30:00.000Z'), hinweis('04.11.2026')) + // Monats- und Jahreswechsel, Zeitumstellung am 25.10. spielt bei Kalendertagen keine Rolle + assert.equal(abo('active', '2026-10-20T12:00:00.000Z'), hinweis('19.11.2026')) + assert.equal(abo('active', '2026-12-15T12:00:00.000Z'), hinweis('14.01.2027')) + // Appwrite liefert auch +00:00 + assert.equal(abo('active', '2026-10-04T10:00:00.000+00:00'), hinweis('03.11.2026')) +}) + +test('zeitenStatusText: Abo-Ende in der Einrichtung steht zusätzlich zum Einbau- bzw. Warte-Text', () => { + const aboEndeAt = '2026-10-04T10:00:00.000Z' + const frist = 'Abo beendet – Abschaltung am 03.11.2026, falls der Kunde die Funktion nicht behält' + assert.equal(zeitenStatusText({ state: 'setup', hindernis: null, aboEndeAt }), `${EINBAU} · ${frist}`) + assert.equal(zeitenStatusText({ state: 'setup', hindernis: 'zeiten_fehlen', aboEndeAt }), `${WARTET} · ${frist}`) +}) + +test('zeitenStatusText: Abo-Ende mit unlesbarem Datum nennt 30 Tage statt eines erfundenen Tages', () => { + const text = zeitenStatusText({ state: 'active', aboEndeAt: 'kein-datum' }) + assert.equal(text, 'Abo beendet – Abschaltung nach 30 Tagen, falls der Kunde die Funktion nicht behält') +}) + +test('zeitenStatusText: aktiv ohne Abo-Ende, aus, bestellt, abgeschaltet, ohne Ansicht – leer', () => { + assert.equal(zeitenStatusText({ state: 'active', hindernis: null, aboEndeAt: null }), '') + assert.equal(zeitenStatusText({ state: 'active' }), '') + assert.equal(zeitenStatusText({ state: 'off' }), '') + assert.equal(zeitenStatusText({ state: 'ordered' }), '') + // abgeschaltet: Grund und Löschdatum zeigt abgeschaltetText, nichts doppelt + assert.equal(zeitenStatusText({ state: 'disabled', disabledReason: 'abo_ende', loeschungAm: '2026-11-04', aboEndeAt: '2026-10-04T10:00:00.000Z' }), '') + assert.equal(zeitenStatusText({ state: 'off', aboEndeAt: '2026-10-04T10:00:00.000Z' }), '') + assert.equal(zeitenStatusText(null), '') + assert.equal(zeitenStatusText(undefined), '') +}) + +test('zeitenStatusText: kein Text nennt "24 Uhr"', () => { + for (const feature of [ + { state: 'setup' }, + { state: 'setup', hindernis: 'zeiten_fehlen' }, + { state: 'active', aboEndeAt: '2026-10-04T10:00:00.000Z' }, + { state: 'setup', hindernis: 'zeiten_fehlen', aboEndeAt: '2026-10-04T22:30:00.000Z' }, + ]) { + assert.doesNotMatch(zeitenStatusText(feature), /24 Uhr/) + } +}) + +test('navSelektorFehler: leer ist erlaubt (kein Selektor)', () => { + for (const wert of [undefined, null, '', ' ', '\n\t ']) { + assert.equal(navSelektorFehler(wert), null, JSON.stringify(wert)) + } +}) + +test('navSelektorFehler: übliche Selektoren sind gültig', () => { + for (const wert of [ + 'header', + '#nav', + '.site-header', + '.site-header > nav', + 'header#main-nav.fixed', + 'nav[role="navigation"]', + "a[href='x']", + 'ul.menu:first-child', + 'div_wrap .a-b', + 'ABC xyz 019', + ' header ', + ]) { + assert.equal(navSelektorFehler(wert), null, wert) + } +}) + +test('navSelektorFehler: erlaubt sind nur A–Z a–z 0–9, Leerzeichen und # . - _ > : [ ] = " \'', () => { + const zeichen = 'AZaz09 #.-_>:[]="\'' + assert.equal(navSelektorFehler(zeichen), null) + assert.equal(navSelektorFehler('a'.repeat(NAV_SELEKTOR_MAX)), null) +}) + +test('navSelektorFehler: < { } ; und alle übrigen Zeichen werden abgelehnt', () => { + for (const zeichen of ['<', '{', '}', ';', '(', ')', ',', '*', '+', '~', '/', '\\', '@', '!', '|', '$', '%', '^', '&', '?', '`', 'ä', 'ß', '€', '\n', '\t']) { + assert.equal(navSelektorFehler(`a${zeichen}b`), NAV_SELEKTOR_FEHLER, JSON.stringify(zeichen)) + } + assert.equal(navSelektorFehler('header { display: none }'), NAV_SELEKTOR_FEHLER) + assert.equal(navSelektorFehler('