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/) })