Files
Webklar-Kundenbereich/test/zeitenView.test.js
T
ClaudeandClaude Opus 5.5 f20f056c60 Ö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>
2026-10-05 05:05:01 +00:00

411 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/)
})