Files
ClaudeandClaude Opus 5.5 5f52ef54e8 Öffnungszeiten: Ersatz-Tabelle und Wochenplan-Hash
ersatzHtml liefert die schlichte Wochentabelle eines Plans als reines ASCII
(Entities, maskierter Planname), wochenplanHash den 16-stelligen sha1 der
Tage des Hauptplans. Dafuer wird modusUndFenster aus der Zeitlogik exportiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:19:22 +00:00

274 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&#8211;18:00'], ...] */
function zeilen(html) {
return [...html.matchAll(/<tr><th scope="row">([^<]*)<\/th><td>([^<]*)<\/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, /^<table class="wkz-ersatz">/)
assert.match(html, /<\/table>\s*$/)
const z = zeilen(html)
assert.deepEqual(z.map((r) => r[0]), WOCHE)
assert.ok(html.includes('09:00&#8211;18:00'))
assert.ok(html.includes('10:00&#8211;14:00'))
assert.equal(z[1][1], 'geschlossen')
assert.equal(z[6][1], 'geschlossen')
for (const tag of WOCHE) {
assert.ok(html.indexOf(`<th scope="row">${tag}</th>`) >= 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), /<caption>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, /<caption>&Ouml;ffnungszeiten<\/caption>/, `Name ${String(name)}`)
assert.match(html, ASCII)
}
})
test('Ersatz-HTML: Kundentext wird maskiert', () => {
const p = plan({ 1: ['09:00', '18:00'] }, { name: '<b>' })
const html = ersatzHtml(daten({ plaene: [p] }), p)
assert.ok(html.includes('<caption>&lt;b&gt;</caption>'))
assert.ok(!html.includes('<b>'))
const boese = plan({ 1: ['09:00', '18:00'] }, { name: '"><script>alert(1)</script> & \'x\'' })
const h2 = ersatzHtml(daten({ plaene: [boese] }), boese)
assert.ok(!h2.includes('<script'))
assert.ok(h2.includes('&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt; &amp; &#39;x&#39;'))
assert.match(h2, ASCII)
})
test('Ersatz-HTML: Umlaute im Kundentext werden zu Entities, andere Zeichen zu numerischen', () => {
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('<caption>B&auml;ckerei M&uuml;ller &#8211; &Ouml;l &Auml;&Uuml;&szlig; &#233; &#128512;</caption>'))
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('<caption>a b c d e&#65533;f</caption>'))
})
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&#8211;00:00')
assert.equal(z[3][1], '22:00&#8211;02:00')
assert.equal(z[4][1], '08:00&#8211;12:00, 14:00&#8211;18:30')
assert.equal(z[5][1], '00:00&#8211;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&#8211;12:00, 14:00&#8211;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&#8211;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), /<caption>Sommer<\/caption>[\s\S]*10:00&#8211;20:00/)
assert.match(ersatzHtml(d, haupt), /<caption>Haupt<\/caption>[\s\S]*09:00&#8211;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')
})