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>
411 lines
26 KiB
JavaScript
411 lines
26 KiB
JavaScript
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/)
|
||
})
|