Ö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

+253
View File
@@ -0,0 +1,253 @@
/* Oeffnungszeiten live im Kundenbereich: reine Helfer fuer den Editor ohne DOM (ES-Modul; die Oberflaeche in den spaeteren
zeiten*.js-Dateien nutzt sie, test/zeitenView.test.js prueft sie). Alles hier ist das Gegenstueck zu den Regeln des Servers
(server/services/zeiten/pruefen.js): gleiche Grenzen, gleiche Kurznamen, gleiche Fensterregeln, gleiche Texte. Der Server
bleibt die Instanz, die entscheidet; der Editor zeigt Fehler nur frueher an. test/zeitenView.test.js haelt jede Regel gegen den
Server (zufaellige Eingaben, gleiche Ergebnisse).
Tage sind Berliner Kalendertage 'JJJJ-MM-TT', Uhrzeiten 'HH:MM'. IDs von Plaenen, Ausnahmen und Hinweisen kommen vom Server und
koennen jede Zeichenfolge aus [A-Za-z0-9_-] sein ('__proto__', 'constructor'): hier wird nie ein Objekt nach einer ID befragt,
nur eine Liste durchsucht. */
import { datumKurz, istTag, tagPlus } from './termine-view.js'
/* ------------------------------------------------------------- Grenzen --- */
/** Grenzen der Spezifikation (Server: ZEITEN_LIMITS, test/zeitenView.test.js vergleicht beide). */
export const ZEITEN_LIMITS = Object.freeze({ plaene: 5, ausnahmen: 100, hinweise: 20, urlaubTage: 366 })
/** Laengen der Felder (maxlength der Eingaben). */
export const ZEITEN_MAX = Object.freeze({ hinweisText: 140, name: 60, ausnahmeText: 60, knopfText: 30, starttext: 200, adresse: 200, telefon: 30, link: 300, kurz: 24 })
/** Zeitfenster je Tag (Server: ZEITEN_LIMITS.fensterJeTag). */
export const FENSTER_JE_TAG = 2
/** Betriebsart eines Tages, einer Ausnahme oder eines Feiertags. */
export const MODI = Object.freeze(['zu', 'zeiten', 'rund', 'vereinbarung'])
export const HINWEIS_ARTEN = Object.freeze(['info', 'urlaub', 'aktion', 'wichtig'])
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const fehlt = (wert) => wert === undefined || wert === null
/* ------------------------------------------------------------------ IDs --- */
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
const ID_PRAEFIXE = ['p', 'x', 'h']
const zufallsBytes = (n) => crypto.getRandomValues(new Uint8Array(n))
/**
* Neue ID eines Plans ('p'), einer Ausnahme ('x') oder eines Hinweises ('h'): Praefix_ + 8 Zeichen a-z0-9, dieselbe Form wie auf dem
* Server (der behaelt gueltige IDs; so koennen neue Ausnahmen schon vor dem Speichern auf einen neuen Plan verweisen).
* bytes(n) liefert n Zufallsbytes (Vorgabe crypto.getRandomValues; nur fuer Tests austauschbar). Bytes ab 252 werden verworfen
* (252 = 7 * 36): so ist jedes Zeichen gleich wahrscheinlich.
*/
export function neueZeitenId(praefix, bytes = zufallsBytes) {
if (!ID_PRAEFIXE.includes(praefix)) throw new Error(`Unbekanntes ID-Präfix: ${String(praefix)}`)
let zeichen = ''
while (zeichen.length < 8) {
for (const byte of bytes(16)) {
if (byte < 252 && zeichen.length < 8) zeichen += ID_ALPHABET[byte % ID_ALPHABET.length]
}
}
return `${praefix}_${zeichen}`
}
/* ------------------------------------------------------------ Kurznamen --- */
/**
* Kurzname [a-z0-9-]{1,24} aus dem Namen, genau wie auf dem Server: Umlaute ae/oe/ue/ss, Akzente weg, alles Uebrige ein Bindestrich;
* leer = 'plan'; ist er in vorhandene (Liste oder Set) belegt, kommt -2, -3, ... davor (immer <= 24 Zeichen).
* Der Server vergibt den Kurznamen beim ersten Speichern endgueltig und beruecksichtigt dabei auch Namen frueher geloeschter
* Plaene; zeigt der Editor einen Kurznamen vorab, gilt nach dem Speichern der Wert aus der Antwort.
*/
export function kurzname(name, vorhandene = []) {
const belegt = vorhandene instanceof Set ? vorhandene : new Set(vorhandene)
let basis = (typeof name === 'string' ? name : '')
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.normalize('NFKD').replace(/\p{M}+/gu, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
basis = basis.slice(0, ZEITEN_MAX.kurz).replace(/-+$/, '')
if (basis === '') basis = 'plan'
if (!belegt.has(basis)) return basis
for (let n = 2; ; n += 1) {
const endung = `-${n}`
const kandidat = `${basis.slice(0, ZEITEN_MAX.kurz - endung.length).replace(/-+$/, '')}${endung}`
if (!belegt.has(kandidat)) return kandidat
}
}
/* -------------------------------------------------------------- Fenster --- */
const FENSTER_FEHLER = {
form: 'Ungültige Zeitfenster.',
zuviele: `Pro Tag sind höchstens ${FENSTER_JE_TAG} Zeitfenster möglich.`,
uhrzeit: 'Uhrzeiten bitte als HH:MM in 5-Minuten-Schritten angeben.',
gleich: 'Beginn und Ende dürfen nicht gleich sein.',
nacht: 'Ein Fenster über Mitternacht ist nur als letztes Fenster des Tages möglich.',
ueberschnitt: 'Die Zeitfenster überschneiden sich.',
folgetag: 'Das letzte Fenster reicht bis in das erste Fenster des nächsten Tages hinein.',
leer: 'Bitte trage mindestens ein Zeitfenster ein oder wähle „geschlossen“.',
modus: 'Bitte wähle „geschlossen“, „geöffnet“, „rund um die Uhr“ oder „nach Vereinbarung“.',
eintrag: 'Ungültiger Eintrag.',
}
/** 'HH:MM' -> Minuten (5er-Raster; als Ende bis 24:00), sonst null. */
function minuten(wert, istEnde) {
const m = typeof wert === 'string' ? /^(\d{2}):(\d{2})$/.exec(wert) : null
if (!m) return null
const h = Number(m[1])
const min = Number(m[2])
if (h > 24 || min > 59 || min % 5 !== 0 || (h === 24 && (min !== 0 || !istEnde))) return null
return h * 60 + min
}
/**
* Fensterregeln eines Tages ohne Blick auf den Folgetag: { fehler } oder { ersteVon (Minuten|null), nachtBis (Minuten|null) }.
* nachtBis = Ende des letzten Fensters, wenn es ueber Mitternacht geht. Reihenfolge der Pruefungen wie auf dem Server (so erscheint
* bei mehreren Fehlern derselbe Text).
*/
function fensterRegeln(fenster) {
if (!Array.isArray(fenster)) return { fehler: FENSTER_FEHLER.form }
if (fenster.length > FENSTER_JE_TAG) return { fehler: FENSTER_FEHLER.zuviele }
const f = []
for (const eintrag of fenster) {
const von = Array.isArray(eintrag) && eintrag.length === 2 ? minuten(eintrag[0], false) : null
const bis = von === null ? null : minuten(eintrag[1], true)
if (von === null || bis === null) return { fehler: FENSTER_FEHLER.uhrzeit }
if (bis === von) return { fehler: FENSTER_FEHLER.gleich }
f.push({ von, bis })
}
f.sort((a, b) => a.von - b.von)
for (let i = 0; i < f.length; i += 1) {
if (f[i].bis < f[i].von && i < f.length - 1) return { fehler: FENSTER_FEHLER.nacht }
if (i > 0 && f[i].von < f[i - 1].bis) return { fehler: FENSTER_FEHLER.ueberschnitt }
}
const letztes = f[f.length - 1]
return { ersteVon: f.length ? f[0].von : null, nachtBis: letztes && letztes.bis < letztes.von ? letztes.bis : null }
}
/** Ragt ein Fenster bis nach Mitternacht (nachtBis) in das erste Fenster des Folgetags (ersteVon) hinein? Aneinandergrenzen ist erlaubt. */
const ragtHinein = (nachtBis, ersteVonFolgetag) => nachtBis !== null && ersteVonFolgetag !== null && nachtBis > ersteVonFolgetag
/**
* Pruefung der Zeitfenster eines Tages: Meldung (Du-Form, Text wie auf dem Server) oder null. fenster = [['09:00','12:30'], ...].
* Regeln: hoechstens 2 Fenster, HH:MM im 5-Minuten-Raster (Ende bis 24:00), Ende ungleich Beginn, keine Ueberschneidung (Beruehren
* ist erlaubt), Ende vor Beginn (ueber Mitternacht) nur beim letzten Fenster und nur, wenn es nicht in das erste Fenster des Folgetags
* hineinragt. folgetagErstes = Beginn des ersten Fensters am Folgetag ('HH:MM') oder null (Folgetag zu, rund um die Uhr, nach
* Vereinbarung oder selbst ungueltig). Eine leere Liste verletzt keine Fensterregel; dass ein „geoeffneter“ Tag eines braucht, prueft tagFehler.
*/
export function fensterPruefen(fenster, { folgetagErstes = null } = {}) {
const r = fensterRegeln(fenster)
if (r.fehler) return r.fehler
return ragtHinein(r.nachtBis, minuten(folgetagErstes, false)) ? FENSTER_FEHLER.folgetag : null
}
/** Regeln eines Tages mit Betriebsart: { fehler } oder { ersteVon, nachtBis } (ohne Fenster beide null). Fehlt der Tag, ist er geschlossen. */
function tagRegeln(tag) {
if (fehlt(tag)) return { ersteVon: null, nachtBis: null }
if (!istObjekt(tag)) return { fehler: FENSTER_FEHLER.eintrag }
if (!MODI.includes(tag.modus)) return { fehler: FENSTER_FEHLER.modus }
if (tag.modus !== 'zeiten') return { ersteVon: null, nachtBis: null }
const r = fensterRegeln(tag.fenster)
if (r.fehler) return r
if (r.ersteVon === null) return { fehler: FENSTER_FEHLER.leer }
return r
}
/**
* Wie fensterPruefen, aber fuer einen ganzen Tag { modus, fenster } (Wochentag, Sondertag, Feiertag): der Modus muss einer aus MODI
* sein, „geoeffnet“ braucht mindestens ein Fenster, bei jedem anderen Modus zaehlen Fenster nicht. => Meldung oder null.
*/
export function tagFehler(tag, { folgetagErstes = null } = {}) {
const r = tagRegeln(tag)
if (r.fehler) return r.fehler
return ragtHinein(r.nachtBis, minuten(folgetagErstes, false)) ? FENSTER_FEHLER.folgetag : null
}
/**
* Fehler eines Wochenplans je Wochentag: { '3': 'Text' } (leer = alles in Ordnung). Jeder Tag nach tagFehler; dazu prueft jeder
* Tag sein Nachtfenster gegen das erste Fenster des Folgetags (Sonntag gegen Montag), sofern der Folgetag selbst in Ordnung ist
* (sonst steht dort dessen eigener Fehler). plan = { tage: { 1: { modus, fenster }, ..., 7 } }; fehlende Tage sind geschlossen.
*/
export function wochenFehler(plan) {
const tage = plan?.tage
if (!istObjekt(tage)) return {}
const regeln = {}
for (let d = 1; d <= 7; d += 1) regeln[d] = tagRegeln(tage[d])
const fehler = {}
for (let d = 1; d <= 7; d += 1) {
const r = regeln[d]
const folgetag = regeln[(d % 7) + 1]
if (r.fehler) fehler[d] = r.fehler
else if (!folgetag.fehler && ragtHinein(r.nachtBis, folgetag.ersteVon)) fehler[d] = FENSTER_FEHLER.folgetag
}
return fehler
}
/* --------------------------------------------------------------- Plaene --- */
const NIE_LOESCHBAR = 'Der letzte Zeitplan kann nicht gelöscht werden.'
const HAUPTPLAN_LOESCHEN = 'Erst einen anderen Plan zum Hauptplan machen.'
const PLAN_UNBEKANNT = 'Diesen Zeitplan gibt es nicht.'
/** Neuer Plan mit neuer ID und sieben geschlossenen Tagen (je Tag ein eigenes Objekt). kurz kommt aus kurzname(name, vorhandene). */
export function leererPlan({ name, kurz, haupt = false } = {}) {
const tage = {}
for (let d = 1; d <= 7; d += 1) tage[d] = { modus: 'zu', fenster: [] }
return { id: neueZeitenId('p'), kurz, name, haupt: haupt === true, tage }
}
/** Neue Daten, in denen genau der Plan mit dieser ID der Hauptplan ist; die Eingabe bleibt unveraendert. Unbekannte ID: Daten unveraendert. */
export function hauptplanWechseln(daten, planId) {
const plaene = Array.isArray(daten?.plaene) ? daten.plaene : []
if (!plaene.some((p) => p?.id === planId)) return daten
return { ...daten, plaene: plaene.map((p) => ({ ...p, haupt: p.id === planId })) }
}
/**
* Warum sich der Plan nicht loeschen laesst (Text in Du-Form), sonst null. Der letzte Plan ist immer auch der Hauptplan, hat aber den
* deutlicheren Grund (einen anderen Hauptplan gaebe es gar nicht); danach der Hauptplan: erst einen anderen dazu machen.
*/
export function planLoeschenGrund(daten, planId) {
const plaene = Array.isArray(daten?.plaene) ? daten.plaene : []
const plan = plaene.find((p) => p?.id === planId)
if (!plan) return PLAN_UNBEKANNT
if (plaene.length <= 1) return NIE_LOESCHBAR
if (plan.haupt === true) return HAUPTPLAN_LOESCHEN
return null
}
/* ------------------------------------------------------ Hinweis-Leiste --- */
const VORLAUF_TAGE = 7
const tageZwischen = (von, bis) => Math.round((Date.parse(`${bis}T00:00:00Z`) - Date.parse(`${von}T00:00:00Z`)) / 86400000)
const tagMonat = (tag) => datumKurz(tag).slice(0, 6) // '12.10.'
/**
* Vorschlag fuer eine Hinweis-Leiste zu einem Betriebsurlaub (von, bis; bis fehlt = ein Tag): ab 2 Tagen
* { art: 'urlaub', text: 'Betriebsurlaub vom 12.10. bis 26.10.', von: Urlaubsbeginn - 7 Tage, bis: Urlaubsende }, sonst null.
* Dauert der Urlaub so lang, dass 7 Tage Vorlauf die 366 Tage fuer einen Urlaubshinweis sprengen wuerden (der Server lehnte ihn
* ab), beginnt der Hinweis entsprechend spaeter.
*/
export function hinweisVorschlag(urlaub) {
const von = urlaub?.von
const bis = urlaub?.bis
if (!istTag(von) || !istTag(bis) || bis <= von) return null
const fruehestens = tagPlus(bis, -(ZEITEN_LIMITS.urlaubTage - 1))
const mitVorlauf = tagPlus(von, -VORLAUF_TAGE)
return { art: 'urlaub', text: `Betriebsurlaub vom ${tagMonat(von)} bis ${tagMonat(bis)}`, von: mitVorlauf < fruehestens ? fruehestens : mitVorlauf, bis }
}
/** Braucht ein Hinweistext den Hinweis zu Preisangaben (Endpreise, niedrigster Preis der letzten 30 Tage)? Bei „%“ oder „statt“. */
export function preisHinweisNoetig(text) {
return typeof text === 'string' && /%|statt/i.test(text)
}
/* ---------------------------------------------------------------- Links --- */
// Hostname wie ihn URL liefert (klein, Umlaute als Punycode): Labels aus a-z, 0-9 und Bindestrich, oder IPv6 in Klammern (wie der Server).
const HOST_RE = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])$/
/**
* Ist das ein erlaubtes Ziel fuer einen Link-Knopf? https, hoechstens 300 Zeichen, ohne Leerraum und Zugangsdaten, ASCII-Hostname
* (Umlaute-Domains als Punycode); ein Apostroph im Pfad zaehlt als %27 (so speichert ihn der Server). Aussen stehender Leerraum
* wird wie beim Speichern abgeschnitten.
*/
export function linkOk(url) {
const s = typeof url === 'string' ? url.trim() : ''
if (s === '' || s.length > ZEITEN_MAX.link || /\s/.test(s)) return false
let ziel
try { ziel = new URL(s) } catch { return false }
const href = ziel.href.replace(/'/g, '%27')
return ziel.protocol === 'https:' && !ziel.username && !ziel.password && HOST_RE.test(ziel.hostname) && href.length <= ZEITEN_MAX.link
}
+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/)
})