import { test } from 'node:test' import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import { ersatzHtml, wochenplanHash } from '../server/services/zeiten/ersatz.js' import { hauptplan } from '../server/services/zeiten/zeitlogik.js' // Fixture wie in zeitenZeitlogik.test.js: Wochentage 1..7 (Mo..So). // Wert: ['09:00','18:00'] = ein Fenster, [[..],[..]] = zwei Fenster, 'rund' / 'vereinbarung' = Modus, fehlt = zu. function plan(woche = {}, extra = {}) { const tage = {} for (let d = 1; d <= 7; d += 1) { const w = woche[d] if (!w) tage[d] = { modus: 'zu', fenster: [] } else if (typeof w === 'string') tage[d] = { modus: w, fenster: [] } else tage[d] = { modus: 'zeiten', fenster: Array.isArray(w[0]) ? w : [w] } } return { id: 'p1', kurz: 'standard', name: 'Standard', haupt: true, tage, ...extra } } function daten({ plaene = [plan({ 1: ['09:00', '18:00'] })], ausnahmen = [], feiertage = {}, hinweise = [], land = 'RP' } = {}) { return { land, plaene, feiertage, ausnahmen, hinweise, kontakt: { an: false, telefon: '', adresse: '', whatsapp: null } } } function tiefEinfrieren(x) { if (x && typeof x === 'object' && !Object.isFrozen(x)) { Object.freeze(x) for (const v of Object.values(x)) tiefEinfrieren(v) } return x } const ASCII = /^[\x00-\x7F]*$/ const WOCHE = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'] /** Zeilen der Tabelle: [['Montag', '09:00–18:00'], ...] */ function zeilen(html) { return [...html.matchAll(/([^<]*)<\/th>([^<]*)<\/td><\/tr>/g)].map((m) => [m[1], m[2]]) } // ------------------------------------------------------------------ ersatzHtml test('Ersatz-HTML ist reines ASCII', () => { const p = plan( { 1: ['09:00', '18:00'], 2: [['08:00', '12:00'], ['14:00', '18:30']], 3: 'rund', 4: 'vereinbarung', 5: ['18:00', '24:00'], 6: ['22:00', '02:00'] }, { name: 'Öffnungszeiten für Müller & Söhne – groß \u{1F600}' }, ) const html = ersatzHtml(daten({ plaene: [p] }), p) assert.equal(typeof html, 'string') assert.ok(html.length > 0) assert.match(html, ASCII) }) test('Ersatz-HTML zeigt alle Wochentage in der Reihenfolge Montag bis Sonntag', () => { const p = plan({ 1: ['09:00', '18:00'], 6: ['10:00', '14:00'] }) const html = ersatzHtml(daten({ plaene: [p] }), p) assert.match(html, /^/) assert.match(html, /<\/table>\s*$/) const z = zeilen(html) assert.deepEqual(z.map((r) => r[0]), WOCHE) assert.ok(html.includes('09:00–18:00')) assert.ok(html.includes('10:00–14:00')) assert.equal(z[1][1], 'geschlossen') assert.equal(z[6][1], 'geschlossen') for (const tag of WOCHE) { assert.ok(html.indexOf(``) >= 0, `${tag} fehlt`) } }) test('Ersatz-HTML: Caption ist der Planname, bei leerem Namen "Öffnungszeiten"', () => { const p = plan({ 1: ['09:00', '18:00'] }, { name: 'Sommer' }) assert.match(ersatzHtml(daten({ plaene: [p] }), p), /')) assert.ok(!html.includes('')) const boese = plan({ 1: ['09:00', '18:00'] }, { name: '"> & \'x\'' }) const h2 = ersatzHtml(daten({ plaene: [boese] }), boese) assert.ok(!h2.includes(' { const p = plan({ 1: ['09:00', '18:00'] }, { name: 'Bäckerei Müller – Öl ÄÜß é \u{1F600}' }) const html = ersatzHtml(daten({ plaene: [p] }), p) assert.ok(html.includes('')) assert.match(html, ASCII) }) test('Ersatz-HTML: Steuerzeichen und einzelne Surrogate im Namen brechen nichts', () => { const p = plan({ 1: ['09:00', '18:00'] }, { name: 'a\nb\tc\u0000d\u0085e\ud800f' }) const html = ersatzHtml(daten({ plaene: [p] }), p) assert.match(html, ASCII) assert.doesNotMatch(html, /[\x00-\x08\x0b-\x1f\x7f]/) assert.ok(html.includes('')) }) test('Ersatz-HTML: Modi als Text, nie "24:00", Mitternacht als 00:00', () => { const p = plan({ 1: 'rund', 2: 'vereinbarung', 3: ['18:00', '24:00'], 4: ['22:00', '02:00'], 5: [['08:00', '12:00'], ['14:00', '18:30']], 6: ['00:00', '12:00'], 7: ['00:00', '24:00'], }) const html = ersatzHtml(daten({ plaene: [p] }), p) const z = zeilen(html) assert.equal(z[0][1], 'rund um die Uhr') assert.equal(z[1][1], 'nach Vereinbarung') assert.equal(z[2][1], '18:00–00:00') assert.equal(z[3][1], '22:00–02:00') assert.equal(z[4][1], '08:00–12:00, 14:00–18:30') assert.equal(z[5][1], '00:00–12:00') assert.equal(z[6][1], 'rund um die Uhr', 'ein Fenster 00:00-24:00 ist rund um die Uhr') assert.ok(!html.includes('24:00')) assert.ok(!html.includes('24 Uhr')) }) test('Ersatz-HTML: Fenster erscheinen nach Beginn sortiert', () => { const p = plan({ 1: [['14:00', '18:00'], ['08:00', '12:00']] }) assert.equal(zeilen(ersatzHtml(daten({ plaene: [p] }), p))[0][1], '08:00–12:00, 14:00–18:00') }) test('Ersatz-HTML: kaputte Tageseinträge zählen als geschlossen und werfen nicht', () => { const p = plan({ 1: ['09:00', '18:00'] }) p.tage[2] = { modus: 'irgendwas', fenster: [['09:00', '18:00']] } p.tage[3] = { modus: 'zeiten', fenster: [] } p.tage[4] = { modus: 'zeiten', fenster: [['9:00', 'abc'], ['10:00', '10:00'], ['25:00', '26:00']] } delete p.tage[5] p.tage[6] = null p.tage[7] = 'x' const html = ersatzHtml(daten({ plaene: [p] }), p) const z = zeilen(html) assert.equal(z.length, 7) assert.equal(z[0][1], '09:00–18:00') for (let i = 1; i < 7; i += 1) assert.equal(z[i][1], 'geschlossen', WOCHE[i]) const ohneTage = { id: 'x', kurz: 'x', name: 'Ohne', haupt: true } const leer = zeilen(ersatzHtml(daten({ plaene: [ohneTage] }), ohneTage)) assert.equal(leer.length, 7) assert.ok(leer.every((r) => r[1] === 'geschlossen')) }) test('Ersatz-HTML zeigt nur den Wochenplan, keine Ausnahmen, Feiertage oder Hinweise', () => { const p = plan({ 1: ['09:00', '18:00'] }) const ohne = ersatzHtml(daten({ plaene: [p] }), p) const mit = ersatzHtml( daten({ plaene: [p], ausnahmen: [{ id: 'u', art: 'urlaub', von: '2026-10-05', bis: '2026-10-09', text: 'Urlaub', modus: 'zu', fenster: [], plan: null }], feiertage: { '2026-10-12': { modus: 'zeiten', fenster: [['10:00', '12:00']] } }, hinweise: [{ id: 'h', art: 'info', text: 'Hallo', plan: null }], }), p, ) assert.equal(mit, ohne) assert.ok(!mit.includes('Urlaub')) }) test('Ersatz-HTML nutzt den übergebenen Plan, ohne Plan den Hauptplan, ohne beides den leeren Text', () => { const haupt = plan({ 1: ['09:00', '18:00'] }, { name: 'Haupt' }) const sommer = plan({ 1: ['10:00', '20:00'] }, { id: 'p2', kurz: 'sommer', name: 'Sommer', haupt: false }) const d = daten({ plaene: [haupt, sommer] }) assert.match(ersatzHtml(d, sommer), /
${tag}
Sommer<\/caption>/) for (const name of ['', ' ', null, undefined, 42]) { const q = plan({ 1: ['09:00', '18:00'] }, { name }) const html = ersatzHtml(daten({ plaene: [q] }), q) assert.match(html, /Öffnungszeiten<\/caption>/, `Name ${String(name)}`) assert.match(html, ASCII) } }) test('Ersatz-HTML: Kundentext wird maskiert', () => { const p = plan({ 1: ['09:00', '18:00'] }, { name: '' }) const html = ersatzHtml(daten({ plaene: [p] }), p) assert.ok(html.includes('<b>Bäckerei Müller – Öl ÄÜß é 😀a b c d e�fSommer<\/caption>[\s\S]*10:00–20:00/) assert.match(ersatzHtml(d, haupt), /Haupt<\/caption>[\s\S]*09:00–18:00/) assert.equal(ersatzHtml(d, null), ersatzHtml(d, haupt)) assert.equal(ersatzHtml(d, undefined), ersatzHtml(d, haupt)) assert.equal(ersatzHtml(daten({ plaene: [] }), null), '') assert.equal(ersatzHtml(null, null), '') assert.equal(ersatzHtml({}, undefined), '') }) test('Ersatz-HTML ist deterministisch und verändert die Eingabe nicht', () => { const p = plan({ 1: [['14:00', '18:00'], ['08:00', '12:00']], 2: 'rund' }) const d = tiefEinfrieren(daten({ plaene: [p] })) const a = ersatzHtml(d, p) const b = ersatzHtml(d, p) assert.equal(a, b) assert.deepEqual(p.tage[1].fenster, [['14:00', '18:00'], ['08:00', '12:00']]) }) // ------------------------------------------------------------------ wochenplanHash test('Hash hat 16 Hex-Zeichen und ist die sha1 des stabilen JSON der Tage des Hauptplans', () => { const p = { id: 'p1', kurz: 's', name: 'S', haupt: true, tage: { 1: { modus: 'zu', fenster: [] } } } const h = wochenplanHash(daten({ plaene: [p] })) assert.match(h, /^[0-9a-f]{16}$/) const erwartet = createHash('sha1').update('{"1":{"fenster":[],"modus":"zu"}}').digest('hex').slice(0, 16) assert.equal(h, erwartet) }) test('Hash ändert sich nur mit dem Wochenplan des Hauptplans', () => { const basis = daten({ plaene: [plan({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'] })] }) const h = wochenplanHash(basis) assert.match(h, /^[0-9a-f]{16}$/) // Ausnahme, Hinweis, Feiertag, Kontakt, Land, Planname: gleich const anders = daten({ land: 'BY', plaene: [plan({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'] }, { name: 'Umbenannt', kurz: 'neu' })], ausnahmen: [{ id: 'u', art: 'urlaub', von: '2026-10-05', bis: '2026-10-09', text: 'Urlaub', modus: 'zu', fenster: [], plan: null }], feiertage: { '2026-10-12': { modus: 'rund', fenster: [] } }, hinweise: [{ id: 'h', art: 'info', text: 'Hallo', plan: null }], }) assert.equal(wochenplanHash(anders), h) // Montag geändert: anders const montag = daten({ plaene: [plan({ 1: ['09:00', '17:00'], 2: ['09:00', '18:00'] })] }) assert.notEqual(wochenplanHash(montag), h) // Modus geändert: anders assert.notEqual(wochenplanHash(daten({ plaene: [plan({ 1: 'rund', 2: ['09:00', '18:00'] })] })), h) // zweites Fenster dazu: anders assert.notEqual(wochenplanHash(daten({ plaene: [plan({ 1: [['09:00', '12:00'], ['13:00', '18:00']], 2: ['09:00', '18:00'] })] })), h) }) test('Hash: Änderungen an einem anderen Plan ändern ihn nicht, ein anderer Hauptplan schon', () => { const haupt = plan({ 1: ['09:00', '18:00'] }) const sommer = plan({ 1: ['10:00', '20:00'] }, { id: 'p2', kurz: 'sommer', name: 'Sommer', haupt: false }) const sommer2 = plan({ 3: ['10:00', '20:00'] }, { id: 'p2', kurz: 'sommer', name: 'Sommer', haupt: false }) const h = wochenplanHash(daten({ plaene: [haupt, sommer] })) assert.equal(wochenplanHash(daten({ plaene: [haupt, sommer2] })), h) assert.equal(wochenplanHash(daten({ plaene: [sommer, haupt] })), h, 'Reihenfolge der Pläne egal') const nachBefoerderung = [{ ...haupt, haupt: false }, { ...sommer, haupt: true }] assert.notEqual(wochenplanHash(daten({ plaene: nachBefoerderung })), h) assert.equal(hauptplan(daten({ plaene: nachBefoerderung })).kurz, 'sommer') }) test('Hash hängt nicht von der Schlüsselreihenfolge ab, wohl aber von der Fensterreihenfolge', () => { const a = { id: 'p', kurz: 'p', name: 'P', haupt: true, tage: { 2: { modus: 'zeiten', fenster: [['09:00', '12:00']] }, 1: { fenster: [], modus: 'zu' } } } const b = { haupt: true, name: 'P', kurz: 'p', id: 'p', tage: { 1: { modus: 'zu', fenster: [] }, 2: { fenster: [['09:00', '12:00']], modus: 'zeiten' } } } assert.equal(wochenplanHash(daten({ plaene: [a] })), wochenplanHash(daten({ plaene: [b] }))) const c = plan({ 1: [['08:00', '12:00'], ['14:00', '18:00']] }) const d = plan({ 1: [['14:00', '18:00'], ['08:00', '12:00']] }) assert.notEqual(wochenplanHash(daten({ plaene: [c] })), wochenplanHash(daten({ plaene: [d] }))) }) test('Hash ist leer ohne Hauptplan und wirft bei kaputten Daten nicht', () => { assert.equal(wochenplanHash(daten({ plaene: [] })), '') assert.equal(wochenplanHash(daten({ plaene: [plan({ 1: ['09:00', '18:00'] }, { haupt: false })] })), '') assert.equal(wochenplanHash(null), '') assert.equal(wochenplanHash(undefined), '') assert.equal(wochenplanHash({}), '') assert.equal(wochenplanHash({ plaene: 'x' }), '') // Hauptplan ohne Tage: stabiler Wert, kein Wurf, anders als mit Tagen const ohneTage = { id: 'p', kurz: 'p', name: 'P', haupt: true } assert.match(wochenplanHash(daten({ plaene: [ohneTage] })), /^[0-9a-f]{16}$/) assert.notEqual(wochenplanHash(daten({ plaene: [ohneTage] })), wochenplanHash(daten())) }) test('Hash verändert die Eingabe nicht', () => { const d = tiefEinfrieren(daten({ plaene: [plan({ 1: ['09:00', '18:00'] })] })) assert.equal(wochenplanHash(d), wochenplanHash(d)) }) test('ersatz.js ist rein: nur node:crypto und zeitlogik.js, kein Appwrite, keine Config, keine Uhr', () => { const quelle = readFileSync(new URL('../server/services/zeiten/ersatz.js', import.meta.url), 'utf8') const importe = [...quelle.matchAll(/^import[\s\S]*?from\s+['"]([^'"]+)['"]/gm)].map((m) => m[1]) assert.deepEqual(importe.sort(), ['./zeitlogik.js', 'node:crypto']) assert.doesNotMatch(quelle, /Date\.now|new Date\(/) assert.match(quelle, /^[\x00-\x7F]*$/, 'Quelltext selbst nur ASCII') })