Öffnungszeiten: Helfer für den Editor

Reine Client-Helfer (public/zeiten-view.js): Grenzen, neue IDs, Kurznamen, Fensterregeln und
Wochenfehler wie auf dem Server, Hauptplan wechseln, Löschgründe, Hinweis-Vorschlag zum
Betriebsurlaub, Preis-Hinweis, Link-Prüfung. Die Tests halten jede Regel gegen
server/services/zeiten/pruefen.js (zufällige Eingaben, gleiche Ergebnisse).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 05:05:01 +00:00
1 parent 811e054ca4
commit f20f056c60
2 files changed
+663

No files matched your search

+410
View File
@@ -0,0 +1,410 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import {
ZEITEN_LIMITS, ZEITEN_MAX, FENSTER_JE_TAG, MODI, HINWEIS_ARTEN,
neueZeitenId, kurzname, fensterPruefen, tagFehler, wochenFehler,
leererPlan, hauptplanWechseln, planLoeschenGrund, hinweisVorschlag, preisHinweisNoetig, linkOk,
} from '../public/zeiten-view.js'
import {
ZEITEN_LIMITS as SERVER_LIMITS, ZEITEN_MAX as SERVER_MAX,
kurzname as serverKurzname, normalisiereZeiten,
} from '../server/services/zeiten/pruefen.js'
// Der Client rechnet nach denselben Regeln wie der Server (server/services/zeiten/pruefen.js, Task 8): hier wird jede
// Regel gegen den Server gehalten, nicht gegen eine zweite Lesart der Spezifikation.
const NOW = new Date('2026-10-05T10:00:00.000Z') // Berlin: Montag 2026-10-05 12:00
const P1 = 'p_aaaaaaa1'
/** Kleiner deterministischer Zufall (mulberry32), damit ein roter Lauf wiederholbar ist. */
function zufall(seed) {
let a = seed >>> 0
return () => {
a = (a + 0x6D2B79F5) >>> 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const wahl = (r, liste) => liste[Math.floor(r() * liste.length)]
function eingabe(extra = {}) {
return {
land: 'RP',
plaene: [{ id: P1, name: 'Standard', haupt: true, tage: {} }],
feiertage: {},
ausnahmen: [],
hinweise: [],
kontakt: { an: false, telefon: '', adresse: '', whatsapp: null },
...extra,
}
}
/** Fehler des Servers je Wochentag eines Plans: { '3': 'Text' }; alle anderen Pfade fuehren zu einem Testfehler. */
function serverTageFehler(tage) {
const r = normalisiereZeiten(eingabe({ plaene: [{ id: P1, name: 'Standard', haupt: true, tage }] }), { now: NOW })
const aus = {}
for (const [pfad, text] of Object.entries(r.fehler ?? {})) {
const m = new RegExp(`^plaene\\.${P1}\\.tage\\.([1-7])$`).exec(pfad)
assert.ok(m, `unerwarteter Fehlerpfad ${pfad}: ${text}`)
aus[m[1]] = text
}
return aus
}
const tageMit = (nr, tag, rest = {}) => ({ ...rest, [nr]: tag })
const zeiten = (...fenster) => ({ modus: 'zeiten', fenster })
// ------------------------------------------------------------- Konstanten
test('Grenzen und Modi: wie die Spezifikation und wie der Server', () => {
assert.deepEqual(ZEITEN_LIMITS, { plaene: 5, ausnahmen: 100, hinweise: 20, urlaubTage: 366 })
assert.deepEqual(ZEITEN_MAX, { hinweisText: 140, name: 60, ausnahmeText: 60, knopfText: 30, starttext: 200, adresse: 200, telefon: 30, link: 300, kurz: 24 })
assert.equal(FENSTER_JE_TAG, 2)
for (const [schluessel, wert] of Object.entries(ZEITEN_LIMITS)) assert.equal(SERVER_LIMITS[schluessel], wert, `ZEITEN_LIMITS.${schluessel}`)
assert.equal(SERVER_LIMITS.fensterJeTag, FENSTER_JE_TAG)
for (const [schluessel, wert] of Object.entries(ZEITEN_MAX)) assert.equal(SERVER_MAX[schluessel], wert, `ZEITEN_MAX.${schluessel}`)
assert.deepEqual([...MODI], ['zu', 'zeiten', 'rund', 'vereinbarung'])
assert.deepEqual([...HINWEIS_ARTEN], ['info', 'urlaub', 'aktion', 'wichtig'])
assert.ok(Object.isFrozen(ZEITEN_LIMITS) && Object.isFrozen(ZEITEN_MAX) && Object.isFrozen(MODI) && Object.isFrozen(HINWEIS_ARTEN), 'Konstanten sind eingefroren')
})
test('Modi und Hinweisarten: der Server nimmt genau diese an', () => {
for (const modus of MODI) {
const tage = { 3: modus === 'zeiten' ? zeiten(['09:00', '18:00']) : { modus, fenster: [] } }
assert.deepEqual(serverTageFehler(tage), {}, modus)
}
assert.deepEqual(Object.keys(serverTageFehler({ 3: { modus: 'offen', fenster: [] } })), ['3'], 'ein unbekannter Modus fällt auf')
const hinweis = (art) => normalisiereZeiten(eingabe({ hinweise: [{ id: 'h_aaaaaaa1', art, text: 'Hallo', knopf: null, von: null, bis: '2026-12-01' }] }), { now: NOW })
for (const art of HINWEIS_ARTEN) assert.equal(hinweis(art).fehler, undefined, art)
assert.ok(hinweis('werbung').fehler, 'eine unbekannte Art fällt auf')
})
// ------------------------------------------------------------- IDs
test('neueZeitenId: Präfix p, x oder h, acht Zeichen a–z0–9, vom Server unverändert übernommen', () => {
for (const praefix of ['p', 'x', 'h']) {
const id = neueZeitenId(praefix)
assert.match(id, new RegExp(`^${praefix}_[a-z0-9]{8}$`))
}
assert.equal(new Set(Array.from({ length: 200 }, () => neueZeitenId('p'))).size, 200, 'keine Wiederholung in 200 Ziehungen')
for (const falsch of ['r', 's', 'a', '', 'plan', undefined, '__proto__']) assert.throws(() => neueZeitenId(falsch), /Präfix/, String(falsch))
// der Server behält gültige IDs (sonst würden Verweise einer Ausnahme auf einen neuen Plan ins Leere laufen)
const p = neueZeitenId('p')
const x = neueZeitenId('x')
const h = neueZeitenId('h')
const r = normalisiereZeiten(eingabe({
plaene: [{ id: p, name: 'Neu', haupt: true, tage: {} }],
ausnahmen: [{ id: x, art: 'urlaub', von: '2026-10-12', bis: '2026-10-16', text: '', plan: p }],
hinweise: [{ id: h, art: 'info', text: 'Hallo', knopf: null, von: null, bis: null }],
}), { now: NOW })
assert.equal(r.fehler, undefined)
assert.deepEqual([r.daten.plaene[0].id, r.daten.ausnahmen[0].id, r.daten.hinweise[0].id], [p, x, h])
})
test('neueZeitenId: Zufallsbytes ab 252 werden verworfen (jedes Zeichen gleich wahrscheinlich)', () => {
const folge = [255, 252, 253, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
const id = neueZeitenId('h', () => Uint8Array.from(folge))
assert.equal(id, 'h_abcdefgh')
})
// ------------------------------------------------------------- Kurznamen
test('kurzname wie auf dem Server', () => {
assert.equal(kurzname('Küche'), 'kueche')
assert.equal(kurzname('Küche', ['kueche']), 'kueche-2')
assert.equal(kurzname('Küche', ['kueche', 'kueche-2']), 'kueche-3')
assert.equal(kurzname('Straße & Café'), 'strasse-cafe')
assert.equal(kurzname(''), 'plan')
assert.equal(kurzname('!!!'), 'plan')
assert.equal(kurzname(undefined), 'plan')
const lang = kurzname('abcdefghij klmnopqrst uvwxyz1234') // 32 Zeichen
assert.equal(lang.length, 24)
assert.match(lang, /^[a-z0-9-]{1,24}$/)
assert.equal(kurzname('a'.repeat(30)), 'a'.repeat(24))
assert.equal(kurzname('a'.repeat(30), ['a'.repeat(24)]), `${'a'.repeat(22)}-2`, 'die Endung passt noch in 24 Zeichen')
assert.equal(kurzname('Küche', new Set(['kueche'])), 'kueche-2', 'auch ein Set ist möglich')
assert.equal(kurzname('x', ['__proto__', 'constructor']), 'x')
})
test('kurzname: gleiche Ergebnisse wie der Server (zufällige Namen, Dubletten, Umlaute, Akzente, Emoji, lange Texte)', () => {
const r = zufall(27)
const teile = ['Küche', 'Öffnung', 'Übung', 'Straße', 'Café', 'Crème', 'Zoë', 'ñandú', 'Weihnachten', 'Sommer', '2026', 'a', '-', ' ', ' ', '&', '/', '🎄', 'Dž', 'fi', 'ABC', 'I', 'İ', '__proto__', 'constructor', 'x'.repeat(20), 'Plan', 'plan']
let gesehen = 0
for (let i = 0; i < 600; i += 1) {
const name = Array.from({ length: Math.floor(r() * 5) }, () => wahl(r, teile)).join(wahl(r, ['', ' ', '-', ' - ']))
const vorhandene = Array.from({ length: Math.floor(r() * 4) }, () => serverKurzname(wahl(r, teile) + (r() < 0.5 ? '' : ` ${wahl(r, teile)}`), []))
if (r() < 0.3) vorhandene.push(serverKurzname(name, []))
if (r() < 0.2) vorhandene.push(`${serverKurzname(name, []).slice(0, 22)}-2`)
const set = r() < 0.5
const erwartet = serverKurzname(name, set ? new Set(vorhandene) : vorhandene)
const ist = kurzname(name, set ? new Set(vorhandene) : vorhandene)
assert.equal(ist, erwartet, `Name ${JSON.stringify(name)}, vorhandene ${JSON.stringify(vorhandene)}`)
assert.match(ist, /^[a-z0-9-]{1,24}$/)
assert.ok(!vorhandene.includes(ist), 'frei')
gesehen += 1
}
assert.equal(gesehen, 600)
})
// ------------------------------------------------------------- Fenster
test('fensterPruefen: Überschneidung, gleiche Zeiten, Mitternacht nur zuletzt', () => {
assert.equal(fensterPruefen([['09:00', '12:30'], ['13:30', '18:00']]), null)
assert.equal(fensterPruefen([['09:00', '12:00'], ['12:00', '18:00']]), null, 'Berühren ist erlaubt')
assert.equal(fensterPruefen([['18:00', '24:00']]), null, 'bis 24:00 ist erlaubt')
assert.equal(fensterPruefen([['13:30', '18:00'], ['09:00', '12:30']]), null, 'die Reihenfolge der Eingabe ist egal')
assert.equal(fensterPruefen([]), null, 'kein Fenster verletzt keine Fensterregel (siehe tagFehler)')
assert.equal(fensterPruefen([['09:00', '13:00'], ['12:00', '18:00']]), 'Die Zeitfenster überschneiden sich.')
assert.equal(fensterPruefen([['09:00', '09:00']]), 'Beginn und Ende dürfen nicht gleich sein.')
assert.equal(fensterPruefen([['09:00', '12:00'], ['13:00', '13:00']]), 'Beginn und Ende dürfen nicht gleich sein.')
assert.equal(fensterPruefen([['09:00', '12:00'], ['13:00', '18:00'], ['19:00', '20:00']]), 'Pro Tag sind höchstens 2 Zeitfenster möglich.')
assert.equal(fensterPruefen([['09:03', '12:00']]), 'Uhrzeiten bitte als HH:MM in 5-Minuten-Schritten angeben.')
assert.equal(fensterPruefen([['9:00', '12:00']]), 'Uhrzeiten bitte als HH:MM in 5-Minuten-Schritten angeben.')
assert.equal(fensterPruefen([['', '']]), 'Uhrzeiten bitte als HH:MM in 5-Minuten-Schritten angeben.', 'halb ausgefüllte Zeile')
assert.equal(fensterPruefen([['24:00', '24:00']]), 'Uhrzeiten bitte als HH:MM in 5-Minuten-Schritten angeben.', '24:00 nur als Ende')
assert.equal(fensterPruefen('09:00-12:00'), 'Ungültige Zeitfenster.')
assert.equal(fensterPruefen(undefined), 'Ungültige Zeitfenster.')
// über Mitternacht: nur als letztes Fenster
assert.equal(fensterPruefen([['09:00', '12:00'], ['18:00', '02:00']]), null)
assert.equal(fensterPruefen([['22:00', '02:00'], ['09:00', '12:00']]), null, 'sortiert ist das Nachtfenster das letzte')
assert.equal(fensterPruefen([['22:00', '02:00'], ['23:00', '23:30']]), 'Ein Fenster über Mitternacht ist nur als letztes Fenster des Tages möglich.')
// Folgetag: das Nachtfenster darf nicht in das erste Fenster des nächsten Tages hineinragen
assert.equal(fensterPruefen([['18:00', '02:00']], { folgetagErstes: '09:00' }), null)
assert.equal(fensterPruefen([['18:00', '09:00']], { folgetagErstes: '09:00' }), null, 'bis zum Beginn des Folgetags ist erlaubt')
assert.equal(fensterPruefen([['18:00', '10:00']], { folgetagErstes: '09:00' }), 'Das letzte Fenster reicht bis in das erste Fenster des nächsten Tages hinein.')
assert.equal(fensterPruefen([['18:00', '10:00']], { folgetagErstes: null }), null, 'Folgetag ohne Fenster (geschlossen, rund um die Uhr)')
assert.equal(fensterPruefen([['18:00', '10:00']]), null)
assert.equal(fensterPruefen([['09:00', '18:00']], { folgetagErstes: '08:00' }), null, 'ohne Mitternacht keine Folgetag-Regel')
})
test('fensterPruefen und tagFehler: gleiche Urteile und gleiche Texte wie der Server (zufällige Fensterlisten)', () => {
const r = zufall(28)
const uhr = ['00:00', '02:00', '06:00', '08:00', '09:00', '12:00', '12:30', '13:00', '18:00', '22:00', '23:55', '24:00', '09:07', '25:00', '9:00', '', 'abc']
const folge = [null, '00:00', '06:00', '09:00', '12:00', '22:00']
for (let i = 0; i < 800; i += 1) {
const anzahl = Math.floor(r() * 4)
const fenster = Array.from({ length: anzahl }, () => (r() < 0.05 ? wahl(r, [['09:00'], 'x', [1, 2], null]) : [wahl(r, uhr), wahl(r, uhr)]))
const folgetagErstes = wahl(r, folge)
const tage = { 3: zeiten(...fenster), ...(folgetagErstes ? { 4: zeiten([folgetagErstes, '24:00']) } : {}) }
const erwartet = serverTageFehler(tage)[3] ?? null
assert.equal(tagFehler(tage[3], { folgetagErstes }), erwartet, `tagFehler ${JSON.stringify(fenster)} Folgetag ${folgetagErstes}`)
if (anzahl > 0) assert.equal(fensterPruefen(fenster, { folgetagErstes }), erwartet, `fensterPruefen ${JSON.stringify(fenster)} Folgetag ${folgetagErstes}`)
}
})
test('tagFehler: Modus, leere Fenster, Ungültiges wie der Server', () => {
assert.equal(tagFehler({ modus: 'zu', fenster: [] }), null)
assert.equal(tagFehler({ modus: 'rund', fenster: [['99:99', 'x']] }), null, 'außerhalb von „geöffnet“ zählen Fenster nicht')
assert.equal(tagFehler(undefined), null, 'fehlender Tag = geschlossen')
assert.equal(tagFehler(zeiten()), 'Bitte trage mindestens ein Zeitfenster ein oder wähle „geschlossen“.')
assert.equal(tagFehler({ modus: 'offen' }), 'Bitte wähle „geschlossen“, „geöffnet“, „rund um die Uhr“ oder „nach Vereinbarung“.')
assert.equal(tagFehler('x'), 'Ungültiger Eintrag.')
assert.equal(tagFehler([]), 'Ungültiger Eintrag.')
assert.equal(tagFehler(zeiten(['09:00', '09:00'])), 'Beginn und Ende dürfen nicht gleich sein.')
})
// ------------------------------------------------------------- Woche
test('wochenFehler: Fehler je Wochentag, Sonntag grenzt an Montag', () => {
assert.deepEqual(wochenFehler({ tage: {} }), {})
assert.deepEqual(wochenFehler({}), {})
assert.deepEqual(wochenFehler(null), {})
assert.deepEqual(wochenFehler({ tage: { 1: zeiten(['09:00', '18:00']), 2: { modus: 'zu', fenster: [] } } }), {})
assert.deepEqual(wochenFehler({ tage: { 3: zeiten(['09:00', '09:00']) } }), { 3: 'Beginn und Ende dürfen nicht gleich sein.' })
assert.deepEqual(wochenFehler({ tage: { 3: zeiten() } }), { 3: 'Bitte trage mindestens ein Zeitfenster ein oder wähle „geschlossen“.' })
// Nachtfenster am Sonntag gegen das erste Fenster am Montag
const sonntagMontag = (bis, montag) => ({ tage: { 7: zeiten(['20:00', bis]), 1: montag } })
assert.deepEqual(wochenFehler(sonntagMontag('02:00', zeiten(['09:00', '18:00']))), {})
assert.deepEqual(wochenFehler(sonntagMontag('10:00', zeiten(['09:00', '18:00']))), { 7: 'Das letzte Fenster reicht bis in das erste Fenster des nächsten Tages hinein.' })
assert.deepEqual(wochenFehler(sonntagMontag('10:00', { modus: 'rund', fenster: [] })), {}, 'Montag rund um die Uhr: keine Überschneidung zu prüfen')
// ist der Folgetag selbst ungültig, bleibt es bei seinem eigenen Fehler (wie auf dem Server)
assert.deepEqual(wochenFehler(sonntagMontag('10:00', zeiten(['09:00', '09:00']))), { 1: 'Beginn und Ende dürfen nicht gleich sein.' })
// Montag bis Dienstag mitten in der Woche
assert.deepEqual(wochenFehler({ tage: { 2: zeiten(['09:00', '12:00'], ['18:00', '10:00']), 3: zeiten(['09:00', '18:00']) } }), { 2: 'Das letzte Fenster reicht bis in das erste Fenster des nächsten Tages hinein.' })
})
test('wochenFehler: gleiche Fehler und Texte je Tag wie der Server (zufällige Wochenpläne)', () => {
const r = zufall(29)
const uhr = ['00:00', '02:00', '06:00', '08:00', '09:00', '12:00', '13:00', '18:00', '22:00', '24:00', '09:07', '25:00', '']
const zufallsTag = () => {
const w = r()
if (w < 0.12) return undefined
if (w < 0.17) return null
if (w < 0.21) return wahl(r, ['x', [], 7])
if (w < 0.3) return { modus: wahl(r, ['zu', 'rund', 'vereinbarung', 'offen', undefined]), fenster: r() < 0.5 ? [] : [['99:99', 'x']] }
const anzahl = Math.floor(r() * 4)
const fenster = Array.from({ length: anzahl }, () => (r() < 0.04 ? wahl(r, [['09:00'], 'x', null]) : [wahl(r, uhr), wahl(r, uhr)]))
return { modus: 'zeiten', fenster: r() < 0.03 ? 'x' : fenster }
}
let mitFehlern = 0
for (let i = 0; i < 1500; i += 1) {
const tage = {}
for (let d = 1; d <= 7; d += 1) {
const tag = zufallsTag()
if (tag !== undefined) tage[d] = tag
}
const erwartet = serverTageFehler(tage)
assert.deepEqual(wochenFehler({ tage }), erwartet, JSON.stringify(tage))
if (Object.keys(erwartet).length) mitFehlern += 1
}
assert.ok(mitFehlern > 300, `der Zufall trifft genug Fehlerfälle (${mitFehlern})`)
})
// ------------------------------------------------------------- Pläne
const plan = (id, extra = {}) => ({ id, kurz: id, name: id, haupt: false, tage: {}, ...extra })
test('leererPlan: neue ID, sieben geschlossene Tage, vom Server angenommen', () => {
const p = leererPlan({ name: 'Sommer', kurz: 'sommer', haupt: false })
assert.match(p.id, /^p_[a-z0-9]{8}$/)
assert.equal(p.name, 'Sommer')
assert.equal(p.kurz, 'sommer')
assert.equal(p.haupt, false)
assert.deepEqual(Object.keys(p.tage), ['1', '2', '3', '4', '5', '6', '7'])
for (const tag of Object.values(p.tage)) assert.deepEqual(tag, { modus: 'zu', fenster: [] })
assert.notEqual(p.tage[1], p.tage[2], 'jeder Tag ist ein eigenes Objekt (Bearbeiten eines Tages ändert keinen anderen)')
assert.notEqual(p.tage[1].fenster, p.tage[2].fenster)
assert.equal(leererPlan({ name: 'X', kurz: 'x', haupt: true }).haupt, true)
assert.equal(leererPlan({ name: 'X', kurz: 'x' }).haupt, false, 'haupt ist ein Wahrheitswert')
assert.notEqual(leererPlan({ name: 'A', kurz: 'a' }).id, leererPlan({ name: 'A', kurz: 'a' }).id)
// Server: ein leerer Plan (alle Tage zu) ist gültig, der Kurzname kommt aus dem Namen wie beim Client
const r = normalisiereZeiten(eingabe({ plaene: [leererPlan({ name: 'Küche', kurz: kurzname('Küche'), haupt: true })] }), { now: NOW })
assert.equal(r.fehler, undefined)
assert.equal(r.daten.plaene[0].kurz, 'kueche')
assert.equal(r.daten.plaene[0].haupt, true)
})
test('Hauptplan wechseln und Löschgründe', () => {
const daten = { land: 'RP', plaene: [plan('p_a', { haupt: true }), plan('p_b'), plan('p_c')], ausnahmen: [] }
const vorher = JSON.stringify(daten)
const neu = hauptplanWechseln(daten, 'p_b')
assert.deepEqual(neu.plaene.map((p) => [p.id, p.haupt]), [['p_a', false], ['p_b', true], ['p_c', false]])
assert.equal(JSON.stringify(daten), vorher, 'die Eingabe bleibt unverändert')
assert.notEqual(neu, daten)
assert.equal(neu.land, 'RP', 'übrige Felder bleiben')
assert.deepEqual(neu.plaene.map((p) => p.name), ['p_a', 'p_b', 'p_c'], 'Reihenfolge bleibt')
assert.deepEqual(hauptplanWechseln(neu, 'p_b'), neu, 'Wechsel auf den Hauptplan ändert nichts')
assert.deepEqual(hauptplanWechseln(daten, 'p_nix'), daten, 'eine unbekannte ID lässt genau einen Hauptplan')
assert.equal(hauptplanWechseln(daten, 'p_nix').plaene.filter((p) => p.haupt).length, 1)
assert.equal(planLoeschenGrund(daten, 'p_a'), 'Erst einen anderen Plan zum Hauptplan machen.')
assert.equal(planLoeschenGrund(daten, 'p_b'), null)
assert.equal(planLoeschenGrund(neu, 'p_a'), null, 'nach dem Wechsel ist der alte Hauptplan löschbar')
assert.equal(planLoeschenGrund(neu, 'p_b'), 'Erst einen anderen Plan zum Hauptplan machen.')
// der letzte Plan (immer auch der Hauptplan) hat den deutlicheren Grund: einen anderen Hauptplan gäbe es gar nicht
const einzig = { plaene: [plan('p_a', { haupt: true })] }
const grund = planLoeschenGrund(einzig, 'p_a')
assert.equal(grund, 'Der letzte Zeitplan kann nicht gelöscht werden.')
assert.equal(planLoeschenGrund({ plaene: [plan('p_a')] }, 'p_a'), grund, 'auch ohne Hauptplan in den Daten')
assert.equal(typeof planLoeschenGrund(daten, 'p_nix'), 'string', 'ein unbekannter Plan lässt sich nicht löschen')
assert.equal(planLoeschenGrund({ plaene: [] }, 'p_a') !== null, true)
})
test('Pläne mit IDs wie __proto__ oder constructor (der Server nimmt sie an): kein Objekt-Lookup nach ID', () => {
const ids = ['__proto__', 'constructor', 'toString', 'hasOwnProperty']
const daten = { plaene: ids.map((id, i) => plan(id, { haupt: i === 0 })) }
for (const id of ids) {
const neu = hauptplanWechseln(daten, id)
assert.deepEqual(neu.plaene.filter((p) => p.haupt).map((p) => p.id), [id], id)
assert.equal(planLoeschenGrund(neu, id), 'Erst einen anderen Plan zum Hauptplan machen.', id)
for (const andere of ids.filter((x) => x !== id)) assert.equal(planLoeschenGrund(neu, andere), null, `${andere} neben Hauptplan ${id}`)
}
assert.deepEqual(kurzname('Küche', ids), 'kueche')
})
// ------------------------------------------------------------- Hinweis-Vorschlag
test('hinweisVorschlag ab 2 Tagen Urlaub', () => {
assert.deepEqual(hinweisVorschlag({ von: '2026-10-12', bis: '2026-10-26' }), { art: 'urlaub', text: 'Betriebsurlaub vom 12.10. bis 26.10.', von: '2026-10-05', bis: '2026-10-26' })
assert.deepEqual(hinweisVorschlag({ art: 'urlaub', von: '2026-10-12', bis: '2026-10-13', text: 'Inventur', plan: null }), { art: 'urlaub', text: 'Betriebsurlaub vom 12.10. bis 13.10.', von: '2026-10-05', bis: '2026-10-13' }, 'zwei Tage sind der Mindestfall; weitere Felder der Ausnahme stören nicht')
assert.equal(hinweisVorschlag({ von: '2026-10-12', bis: '2026-10-12' }), null, 'ein Tag')
assert.equal(hinweisVorschlag({ von: '2026-10-12' }), null, 'ohne Ende = ein Tag')
assert.equal(hinweisVorschlag({ von: '2026-10-12', bis: '' }), null)
assert.equal(hinweisVorschlag({ von: '2026-10-26', bis: '2026-10-12' }), null, 'Ende vor Beginn')
assert.equal(hinweisVorschlag({ von: 'morgen', bis: '2026-10-12' }), null)
assert.equal(hinweisVorschlag({ von: '2026-02-30', bis: '2026-03-05' }), null, 'kein echter Kalendertag')
assert.equal(hinweisVorschlag(null), null)
assert.equal(hinweisVorschlag(undefined), null)
// sieben Tage vorher über Monats- und Jahresgrenzen
assert.deepEqual(hinweisVorschlag({ von: '2027-01-03', bis: '2027-01-10' }), { art: 'urlaub', text: 'Betriebsurlaub vom 03.01. bis 10.01.', von: '2026-12-27', bis: '2027-01-10' })
assert.equal(hinweisVorschlag({ von: '2028-03-05', bis: '2028-03-08' }).von, '2028-02-27', 'Schaltjahr')
})
test('hinweisVorschlag: der Server nimmt den Vorschlag an, auch bei Urlaub von 366 Tagen', () => {
const als = (v, i) => ({ id: `h_vorsch${i}`, ...v, knopf: null })
const urlaube = [
{ von: '2026-10-12', bis: '2026-10-26' },
{ von: '2026-10-12', bis: '2026-10-13' },
{ von: '2026-12-20', bis: '2027-01-05' },
{ von: '2026-10-12', bis: '2027-10-12' }, // 366 Tage: 7 Tage Vorlauf würden das Hinweis-Limit sprengen
{ von: '2026-10-12', bis: '2027-06-12' },
]
urlaube.forEach((u, i) => {
const v = hinweisVorschlag(u)
assert.ok(v, JSON.stringify(u))
const r = normalisiereZeiten(eingabe({ hinweise: [als(v, i)] }), { now: NOW })
assert.equal(r.fehler, undefined, `${JSON.stringify(u)} => ${JSON.stringify(r.fehler)}`)
assert.equal(r.daten.hinweise[0].bis, u.bis)
assert.ok(r.daten.hinweise[0].von <= u.von, 'der Hinweis steht spätestens ab Urlaubsbeginn')
})
assert.equal(hinweisVorschlag({ von: '2026-10-12', bis: '2027-10-12' }).von, '2026-10-12', 'bei 366 Tagen bleibt kein Vorlauf')
assert.equal(hinweisVorschlag({ von: '2026-10-12', bis: '2027-10-07' }).von, '2026-10-07', 'bei 361 Tagen Urlaub passen nur 5 Tage Vorlauf in 366 Tage')
assert.equal(hinweisVorschlag({ von: '2026-10-12', bis: '2027-10-01' }).von, '2026-10-05', 'bis 359 Tage Urlaub bleibt es bei 7 Tagen Vorlauf')
})
// ------------------------------------------------------------- Preise, Links
test('Preis-Hinweis bei % oder statt', () => {
assert.equal(preisHinweisNoetig('Jetzt 20 % auf alles'), true)
assert.equal(preisHinweisNoetig('20%'), true)
assert.equal(preisHinweisNoetig('Pizza für 8 € statt 10 €'), true)
assert.equal(preisHinweisNoetig('STATT Blumen'), true, 'Groß-/Kleinschreibung egal')
assert.equal(preisHinweisNoetig('Wir haben neue Öffnungszeiten.'), false)
assert.equal(preisHinweisNoetig(''), false)
assert.equal(preisHinweisNoetig(undefined), false)
assert.equal(preisHinweisNoetig(null), false)
assert.equal(preisHinweisNoetig(20), false)
})
test('linkOk: gleiche Urteile wie der Server für Link-Knöpfe', () => {
const lang = `https://beispiel.de/${'a'.repeat(300)}`
const apostroph = `https://beispiel.de/${"'".repeat(100)}` // wird als %27 gespeichert: 3 Zeichen je Apostroph
const faelle = [
'https://beispiel.de', 'https://beispiel.de/', 'https://www.beispiel.de/pfad?x=1&y=2#teil', 'https://BEISPIEL.de', 'https://beispiel.de:8443/x', 'https://1.2.3.4/x', 'https://[::1]/x',
'https://beispiel.de/a b', 'https://beispiel.de/it\'s', 'https://münchen.de', 'https://xn--mnchen-3ya.de', 'https://beispiel.de/münchen',
'http://beispiel.de', 'ftp://beispiel.de', 'javascript:alert(1)', 'tel:+4912345', 'mailto:a@b.de', '//beispiel.de', 'beispiel.de', 'https://', 'https:///x',
'https://user@beispiel.de', 'https://user:pw@beispiel.de', 'https://beispiel.de"onclick=', 'https://bei"spiel.de', 'https://bei`spiel.de', "https://bei'spiel.de",
' https://beispiel.de ', 'https://beispiel.de\n', 'https:// beispiel.de', '', ' ', 'x', lang, apostroph, 'https://a-.de', 'https://-a.de', 'https://a..de', 'https://beispiel.de.',
null, undefined, 42, {}, [],
]
for (const url of faelle) {
const r = normalisiereZeiten(eingabe({ hinweise: [{ id: 'h_aaaaaaa1', art: 'info', text: 'Hallo', knopf: { art: 'link', text: 'Mehr', ziel: url }, von: null, bis: null }] }), { now: NOW })
const serverOk = !(r.fehler && Object.keys(r.fehler).includes('hinweise.h_aaaaaaa1.knopf.ziel'))
assert.equal(linkOk(url), serverOk, `linkOk(${JSON.stringify(url)})`)
}
assert.equal(linkOk('https://beispiel.de/x'), true)
assert.equal(linkOk('http://beispiel.de'), false)
assert.equal(linkOk(`https://beispiel.de/${'a'.repeat(280)}`), true, 'knapp unter 300 Zeichen')
assert.equal(linkOk(lang), false)
})
// ------------------------------------------------------------- Quelltext
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
test('Quelltext-Pins: zeiten*.js importieren nur relativ', () => {
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^zeiten.*\.js$/.test(f)).sort()
assert.ok(dateien.includes('zeiten-view.js'))
for (const datei of dateien) {
const quelle = ohneKommentare(lies(`public/${datei}`))
const importe = [...quelle.matchAll(/\bfrom\s+(['"])([^'"]+)\1|\bimport\s*\(\s*(['"])([^'"]+)\3/g)].map((m) => m[2] ?? m[4])
for (const ziel of importe) assert.match(ziel, /^\.\/[A-Za-z0-9._-]+\.js$/, `${datei}: nur relative Importe aus public/, nicht ${ziel}`)
assert.doesNotMatch(quelle, /\bimport\s+['"]/, `${datei}: kein Import ohne Bindung`)
}
})
test('zeiten-view.js: reine Helfer ohne DOM, Browserspeicher und Netz; nur ./termine-view.js als Quelle', () => {
const quelle = ohneKommentare(lies('public/zeiten-view.js'))
assert.deepEqual(quelle.match(/from '[^']+'/g), ["from './termine-view.js'"])
assert.doesNotMatch(quelle, /\bdocument\b|\bwindow\b|localStorage|sessionStorage|document\.cookie|\bfetch\(|XMLHttpRequest/, 'kein DOM, kein Speicher, kein Netz')
assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, 'Du-Form')
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/)
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML/)
// termine*.js bleibt unberührt: die ID-Funktion von zeiten-view.js ist eine eigene, nicht neueId aus termine-view.js
assert.doesNotMatch(quelle, /\bneueId\b/)
})