diff --git a/public/zeiten-view.js b/public/zeiten-view.js new file mode 100644 index 0000000..6b95863 --- /dev/null +++ b/public/zeiten-view.js @@ -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 +} diff --git a/test/zeitenView.test.js b/test/zeitenView.test.js new file mode 100644 index 0000000..c679ca8 --- /dev/null +++ b/test/zeitenView.test.js @@ -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/) +})