Files
Webklar-Kundenbereich/test/zeitenEmbedStatic.test.js
T
ClaudeandClaude Opus 5.5 a6a4eae294 Öffnungszeiten: Website-Skript mit Status und Tabelle
public/embed/zeiten.js ruft die öffentliche Zeiten-API einmal je Seite ab und füllt
die Platzhalter [data-webklar-zeiten] (tabelle, status, badge; data-plan, Hauptplan
ohne Angabe). Reine Funktionen (waehleWechsel, naechsterWechsel, waehleHinweis,
jetztMitServer, tabelleDaten) stehen zwischen den Marken @rein-anfang/@rein-ende.
Timer auf den nächsten Wechsel (jede Wartezeit auf 10 Minuten begrenzt), Neuladen
höchstens alle 10 Minuten, Daten älter als 24 Stunden, Fehler oder aktiv:false
lassen das Ersatz-HTML stehen bzw. stellen es wieder her. Testseite zeiten-demo.html.

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

404 lines
26 KiB
JavaScript
Raw 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 { readFileSync } from 'node:fs'
import vm from 'node:vm'
// Statische Pruefung und Funktionstests des Website-Skripts public/embed/zeiten.js (Oeffnungszeiten live, Aufgabe 25;
// Spezifikation 4.2 und 4.3 Punkt 4, Rulings R6 und Skript-Regeln der Gesamtvorgaben):
// - Das Skript rechnet nie mit Datum oder Zeitzone des Browsers: "jetzt" ist Date.now() (bei grosser Uhrabweichung die Serverzeit aus
// dem Date-Kopf), Wochentage und Kalendertage kommen als Zeichenketten vom Server und werden rein rechnerisch ausgewertet.
// - Kein Browser-Speicher, keine Cookies, keine Fremdadressen im Code; Kundentext nur ueber textContent.
// - Die reinen Funktionen (Marken @rein-anfang / @rein-ende) laufen hier einzeln in einem leeren vm-Kontext.
// Task 26 haengt Hinweis-Leiste, Kontakt-Leiste und JSON-LD an dieselbe Datei und ergaenzt diese Datei um deren Pruefungen.
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
const QUELLE = lies('public/embed/zeiten.js')
/** Quelltext ohne Kommentare (die Kopfzeile darf Beispiele nennen, der Code nicht). */
const OHNE_KOMMENTARE = QUELLE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
/** Ergebnisse aus dem vm-Kontext haben andere Prototypen (deepStrictEqual wuerde scheitern): ueber JSON angleichen. */
const norm = (x) => JSON.parse(JSON.stringify(x))
test('Quelltext ist gueltiges JavaScript (kompiliert, wird nicht ausgefuehrt)', () => {
assert.doesNotThrow(() => new vm.Script(QUELLE, { filename: 'zeiten.js' }))
})
test('Quelltext: keine Cookies, kein Storage, kein fremder Host', () => {
for (const verboten of ['localStorage', 'sessionStorage', 'document.cookie', 'indexedDB', 'document.referrer', 'sendBeacon', 'navigator.userAgent', 'resolvedOptions']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Skript nicht vorkommen`)
}
// auch nicht in Kommentaren oder Texten: nirgends ein https://-Literal (die Adresse kommt aus data-api oder der Herkunft des Skripts)
assert.doesNotMatch(QUELLE, /https?:\/\//, 'Adressen gehoeren in data-api, nicht in die Datei')
assert.doesNotMatch(OHNE_KOMMENTARE, /\bimport\s*\(|\bimportScripts\b|\bXMLHttpRequest\b|\bWebSocket\b/)
})
test('Quelltext: Date nur als Date.now( und Date.parse(, keine Zeitzonen- und Kalenderrechnung des Browsers', () => {
// erlaubt: Date.now(, Date.parse( und der Kopfname 'Date' (headers.get('Date'))
const rest = OHNE_KOMMENTARE.replace(/\bDate\.now\(/g, '').replace(/\bDate\.parse\(/g, '').replace(/headers\.get\('Date'\)/g, '')
assert.ok(!/\bDate\b/.test(rest), 'Date darf nur als Date.now( oder Date.parse( vorkommen')
assert.ok(OHNE_KOMMENTARE.includes('Date.now('), 'Date.now( fehlt')
assert.ok(OHNE_KOMMENTARE.includes('Date.parse('), 'Date.parse( fehlt')
for (const verboten of ['getHours', 'getMinutes', 'getDay', 'getDate', 'getFullYear', 'getMonth', 'getTime(', 'toLocale', 'Intl.', 'getTimezoneOffset', 'toISOString', 'toUTCString', 'new Date']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Skript nicht vorkommen`)
}
})
test('Quelltext: Kundentexte nie per innerHTML (kein innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, srcdoc)', () => {
for (const verboten of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function', 'createContextualFragment', 'srcdoc', 'DOMParser']) {
assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Skript nicht vorkommen`)
}
assert.ok(OHNE_KOMMENTARE.includes('textContent'), 'Texte kommen ueber textContent')
})
test('Quelltext: setTimeout nur mit Funktionsargument, in genau einer Stelle, jede Wartezeit auf 0 bis 600000 ms begrenzt (R6)', () => {
const stellen = [...OHNE_KOMMENTARE.matchAll(/\bsetTimeout\(/g)]
assert.equal(stellen.length, 1, 'genau eine Stelle ruft setTimeout auf (Hilfsfunktion timer)')
assert.match(OHNE_KOMMENTARE, /setTimeout\(function \(\) \{/, 'setTimeout bekommt eine Funktion, nie eine Zeichenkette')
assert.doesNotMatch(OHNE_KOMMENTARE, /\bsetInterval\(/, 'kein setInterval')
assert.match(OHNE_KOMMENTARE, /var TIMER_MAX_MS = 600000\b/, 'Obergrenze 10 Minuten (setTimeout laeuft ab 2^31-1 ms ueber)')
const zeile = OHNE_KOMMENTARE.split('\n').find((z) => z.includes('setTimeout('))
const hilfe = OHNE_KOMMENTARE.slice(OHNE_KOMMENTARE.indexOf('function timer ('), OHNE_KOMMENTARE.indexOf(zeile) + zeile.length + 80)
assert.match(hilfe, /Math\.max\(0, Math\.min\(TIMER_MAX_MS,/, 'die Wartezeit wird auf [0, TIMER_MAX_MS] begrenzt')
assert.match(OHNE_KOMMENTARE, /var RELOAD_MS = 600000\b/, 'Neuladen hoechstens alle 10 Minuten')
assert.ok(OHNE_KOMMENTARE.includes('visibilitychange'), 'Neuladen auch beim Zurueckkehren in den Tab')
})
test('Doppel-Laden-Schutz __wkZeiten', () => {
assert.match(OHNE_KOMMENTARE, /if \(window\.__wkZeiten\) return/)
assert.match(OHNE_KOMMENTARE, /window\.__wkZeiten = true/)
assert.ok(OHNE_KOMMENTARE.indexOf('window.__wkZeiten = true') < OHNE_KOMMENTARE.indexOf("var STYLE_ID"), 'der Schutz steht vor allem anderen')
})
test('Abruf: genau ein fetch, ohne Cookies, nur an die Zeiten-Schnittstelle; letzter erfolgreicher Abruf im Speicher der Seite', () => {
assert.equal([...OHNE_KOMMENTARE.matchAll(/\bfetch\(/g)].length, 1, 'genau ein fetch (ein Abruf je Seite fuer alle Teile)')
assert.ok(OHNE_KOMMENTARE.includes("credentials: 'omit'"), "fetch braucht credentials: 'omit'")
assert.ok(OHNE_KOMMENTARE.includes('/api/public/zeiten/v1/'))
assert.match(OHNE_KOMMENTARE, /var ALTER_MAX_MS = 24 \* 60 \* 60 \* 1000\b/, 'Daten aelter als 24 Stunden gelten nicht mehr')
assert.match(OHNE_KOMMENTARE, /geladenMs = /, 'Zeitpunkt des letzten erfolgreichen Abrufs wird gemerkt (nicht body.stand)')
assert.ok(!OHNE_KOMMENTARE.includes('.stand'), 'body.stand zaehlt nicht fuer das Alter der Daten')
assert.ok(OHNE_KOMMENTARE.includes("headers.get('Date')"), 'Serverzeit aus dem Date-Kopf')
})
test('Pflichtbausteine der Spezifikation (Platzhalter, Stile, Tabelle, Plaene, Texte)', () => {
for (const noetig of [
'data-webklar-zeiten', 'data-site', 'data-api', 'data-modus', 'data-plan', 'currentScript',
"'wkz-style'", '@layer wkz', 'wkz-', '--wkz-',
"'caption'", "'scope'", "'row'", "'th'", 'aria-current', "'date'",
'tabelle', 'status', 'badge', 'haupt', 'console.warn',
'Abweichungen in den nächsten 14 Tagen', 'rund um die Uhr', 'nach Vereinbarung', 'geschlossen', 'Montag', 'Sonntag',
'DOMContentLoaded', 'removeChild',
]) {
assert.ok(QUELLE.includes(noetig), `${noetig} fehlt im Skript`)
}
assert.doesNotMatch(OHNE_KOMMENTARE, /aria-live/, 'der Status meldet sich nicht von selbst (kein aria-live)')
})
test('Stile stehen einmal in <style id="wkz-style">, alle Klassen beginnen mit wkz-, alle Variablen mit --wkz-', () => {
const css = /var CSS = \[([\s\S]*?)\]\.join/.exec(QUELLE)
assert.ok(css, 'CSS-Block fehlt')
const klassen = [...css[1].matchAll(/\.([a-zA-Z][\w-]*)/g)].map((m) => m[1])
assert.ok(klassen.length > 15)
for (const k of klassen) assert.ok(k.startsWith('wkz-'), `Klasse ${k} ohne Praefix wkz-`)
assert.ok(css[1].includes('@layer wkz{'), 'Stile in @layer wkz')
for (const v of css[1].matchAll(/(?<![\w-])--([\w-]+)/g)) assert.ok(v[1].startsWith('wkz-'), `Variable --${v[1]} ohne Praefix --wkz-`)
assert.equal([...QUELLE.matchAll(/getElementById\(STYLE_ID\)/g)].length, 1)
// Kontakt- und Hinweisleisten (Task 26) leben im Shadow DOM: hier steht kein Seitenstil dafuer
assert.ok(!/position:\s*fixed|position:fixed/.test(css[1]), 'Platzhalter-Stile setzen nichts fest an den Rand')
})
test('Ersatz-HTML wird nie verbraucht (nur Kopien zurueck auf die Seite) und ohne Daten oder bei Fehlern nicht angefasst', () => {
assert.ok(!/appendChild\(p\.ausweich\)/.test(OHNE_KOMMENTARE), 'das gemerkte Ersatz-HTML darf nur als cloneNode(true) zurueckgesetzt werden')
assert.ok(OHNE_KOMMENTARE.includes('ausweich.cloneNode(true)'))
assert.ok(OHNE_KOMMENTARE.includes('child.cloneNode(true)') || OHNE_KOMMENTARE.includes('.cloneNode(true)'), 'beim Start wird eine Kopie des Ersatz-HTML gemerkt, das Original bleibt stehen')
})
// ---------------------------------------------------------------- Telefonnummer (gleiche Regel wie public/embed/termine.js)
test('telefonLink: Nummern werden gewaehlt, freier Text nie, "(0)" hinter der Landesvorwahl entfaellt', () => {
const m = /\/\/ @telefon-anfang[^\n]*\n([\s\S]*?)\/\/ @telefon-ende/.exec(QUELLE)
assert.ok(m, 'Marken @telefon-anfang/@telefon-ende fehlen')
const telefonLink = vm.runInNewContext(`${m[1]}; telefonLink`, {})
const faelle = [
['06301 1234', 'tel:063011234'],
['+49 6301 1234', 'tel:+4963011234'],
['+49 (0) 6301-1234', 'tel:+4963011234'],
['+49(0)6301 1234', 'tel:+4963011234'],
['0049 (0) 6301 1234', 'tel:004963011234'],
['(06301) 1234', 'tel:063011234'],
['06301/1234-0', 'tel:0630112340'],
[' +49 160 1234567 ', 'tel:+491601234567'],
['06301 1234 (ab 9 Uhr)', ''],
['Tel. 06301 1234', ''],
['06301 1234 oder 0171 555', ''],
['0171 1, 0171 2', ''],
['ruf an', ''],
['', ''], [' ', ''], ['12', ''], ['+', ''], [undefined, ''], [null, ''], [1234567, ''], [{}, ''],
]
for (const [eingabe, soll] of faelle) assert.equal(telefonLink(eingabe), soll, JSON.stringify(eingabe))
})
// ---------------------------------------------------------------- reine Funktionen
/** Die reinen Funktionen des Skripts (zwischen den Marken @rein-anfang und @rein-ende) einzeln ausfuehren. */
function rein() {
const m = /\/\/ @rein-anfang[^\n]*\n([\s\S]*?)\/\/ @rein-ende/.exec(QUELLE)
assert.ok(m, 'Marken @rein-anfang/@rein-ende fehlen')
return vm.runInNewContext(
`${m[1]}; ({ waehleWechsel, naechsterWechsel, waehleHinweis, jetztMitServer, tabelleDaten, wochentagVon, zeitenText, gruppiereAbweichungen, abweichungsZeile })`,
{},
)
}
const MIN = 60 * 1000
const ENDLOS = 8.64e15
test('Marken: die reinen Funktionen stehen zwischen @rein-anfang und @rein-ende und greifen auf nichts ausserhalb zu', () => {
const f = rein()
for (const name of ['waehleWechsel', 'naechsterWechsel', 'waehleHinweis', 'jetztMitServer', 'tabelleDaten']) {
assert.equal(typeof f[name], 'function', name)
}
})
test('waehleWechsel nimmt den letzten Eintrag mit ab ≤ jetzt', () => {
const { waehleWechsel } = rein()
const liste = [
{ ab: 1000, offen: true, text: 'A', kurz: 'a', tag: '2026-10-05' },
{ ab: 2000, offen: false, text: 'B', kurz: 'b', tag: '2026-10-05' },
{ ab: 3000, offen: null, text: 'C', kurz: 'c', tag: '2026-10-06' },
]
assert.equal(waehleWechsel(liste, 2500).text, 'B')
assert.equal(waehleWechsel(liste, 2000).text, 'B', 'ab == jetzt zaehlt schon')
assert.equal(waehleWechsel(liste, 1999).text, 'A')
assert.equal(waehleWechsel(liste, 1000).text, 'A')
assert.equal(waehleWechsel(liste, 999), null, 'vor dem ersten Eintrag: keiner')
assert.equal(waehleWechsel(liste, 9e12).text, 'C', 'nach dem letzten: der letzte')
assert.equal(waehleWechsel([], 5000), null)
assert.equal(waehleWechsel(undefined, 5000), null)
assert.equal(waehleWechsel(null, 5000), null)
// unsortierte Eingabe und unbrauchbare Eintraege stoeren nicht
assert.equal(waehleWechsel([liste[2], null, { ab: 'x' }, liste[0], liste[1]], 2500).text, 'B')
})
test('naechsterWechsel berücksichtigt Hinweis-Beginn und -Ende, nie Vergangenes, nie „ohne Ende“', () => {
const { naechsterWechsel } = rein()
const wechsel = [{ ab: 1000 }, { ab: 5000 }, { ab: 9000 }]
assert.equal(naechsterWechsel(wechsel, [], 1000), 5000, 'ab == jetzt ist schon da, gesucht ist der naechste')
assert.equal(naechsterWechsel(wechsel, [], 4999), 5000)
assert.equal(naechsterWechsel(wechsel, [], 9000), null, 'nichts mehr')
assert.equal(naechsterWechsel([], [], 10), null)
// Hinweis-Beginn
assert.equal(naechsterWechsel(wechsel, [{ ab: 3000, bis: ENDLOS }], 1000), 3000)
// Hinweis-Ende
assert.equal(naechsterWechsel(wechsel, [{ ab: 0, bis: 2500 }], 1000), 2500)
// Beginn und Ende, nimmt das fruehere
assert.equal(naechsterWechsel(wechsel, [{ ab: 4000, bis: 4500 }, { ab: 0, bis: 4800 }], 3999), 4000)
assert.equal(naechsterWechsel(wechsel, [{ ab: 4000, bis: 4500 }, { ab: 0, bis: 4800 }], 4000), 4500)
// unbegrenzt (ab 0, bis 8.64e15): kein Wechsel
assert.equal(naechsterWechsel([], [{ ab: 0, bis: ENDLOS }], 1000), null)
assert.equal(naechsterWechsel(wechsel, [{ ab: 0, bis: ENDLOS }], 9000), null)
// Muell
assert.equal(naechsterWechsel(wechsel, [null, { ab: 'x', bis: NaN }, {}], 4000), 5000)
assert.equal(naechsterWechsel(undefined, undefined, 4000), null)
})
test('waehleHinweis: höchster Rang, bei Gleichstand späterer Beginn; nur gültige (ab ≤ jetzt < bis)', () => {
const { waehleHinweis } = rein()
const info = { art: 'info', text: 'I', rang: 1, ab: 0, bis: ENDLOS }
const aktion = { art: 'aktion', text: 'A', rang: 2, ab: 1000, bis: 9000 }
const urlaub = { art: 'urlaub', text: 'U', rang: 3, ab: 500, bis: 4000 }
const wichtig = { art: 'wichtig', text: 'W', rang: 4, ab: 2000, bis: 3000 }
const liste = [info, aktion, urlaub, wichtig]
assert.equal(waehleHinweis(liste, 100).text, 'I', 'nur der Info-Hinweis gilt schon')
assert.equal(waehleHinweis(liste, 1500).text, 'U', 'Urlaub (3) vor Aktion (2) und Info (1)')
assert.equal(waehleHinweis(liste, 2500).text, 'W', 'Wichtig (4) schlaegt alles')
assert.equal(waehleHinweis(liste, 3000).text, 'U', 'bis ist exklusiv: ab 3000 gilt Wichtig nicht mehr')
assert.equal(waehleHinweis(liste, 4000).text, 'A', 'Urlaub endet bei 4000')
assert.equal(waehleHinweis(liste, 9000).text, 'I')
assert.equal(waehleHinweis([aktion], 999), null, 'noch nicht begonnen')
assert.equal(waehleHinweis([aktion], 9000), null, 'schon vorbei')
// Gleichstand beim Rang: der spaetere Beginn gewinnt, unabhaengig von der Reihenfolge
const frueh = { art: 'aktion', text: 'frueh', rang: 2, ab: 1000, bis: 9000 }
const spaet = { art: 'aktion', text: 'spaet', rang: 2, ab: 3000, bis: 9000 }
assert.equal(waehleHinweis([frueh, spaet], 5000).text, 'spaet')
assert.equal(waehleHinweis([spaet, frueh], 5000).text, 'spaet')
assert.equal(waehleHinweis([frueh, spaet], 2000).text, 'frueh', 'der spaetere hat noch nicht begonnen')
assert.equal(waehleHinweis([], 1), null)
assert.equal(waehleHinweis(undefined, 1), null)
assert.equal(waehleHinweis([null, { rang: 'x' }, {}], 1), null)
})
test('jetztMitServer: 4 min Abweichung ⇒ lokal, 6 min ⇒ Server', () => {
const { jetztMitServer } = rein()
const lokal = Date.parse('2026-10-05T10:00:00Z')
const kopf = (ms) => new Date(ms).toUTCString() // Date-Kopf wie ihn der Server sendet (sekundengenau)
assert.equal(jetztMitServer(kopf(lokal), lokal), lokal, 'identisch')
assert.equal(jetztMitServer(kopf(lokal + 4 * MIN), lokal), lokal, 'Server 4 min voraus: lokale Uhr bleibt')
assert.equal(jetztMitServer(kopf(lokal - 4 * MIN), lokal), lokal, 'Server 4 min zurueck: lokale Uhr bleibt')
assert.equal(jetztMitServer(kopf(lokal + 5 * MIN), lokal), lokal, 'genau 5 min: noch lokal')
assert.equal(jetztMitServer(kopf(lokal + 6 * MIN), lokal), lokal + 6 * MIN, 'Uhr geht 6 min nach: Serverzeit')
assert.equal(jetztMitServer(kopf(lokal - 6 * MIN), lokal), lokal - 6 * MIN, 'Uhr geht 6 min vor: Serverzeit')
assert.equal(jetztMitServer(kopf(lokal + 36 * 60 * MIN), lokal), lokal + 36 * 60 * MIN)
// fehlender oder kaputter Kopf: lokale Uhr
for (const kaputt of [null, undefined, '', 'gestern', 12345, {}]) assert.equal(jetztMitServer(kaputt, lokal), lokal, JSON.stringify(kaputt))
})
test('wochentagVon rechnet den ISO-Wochentag aus der Zeichenkette (1 = Montag) und stimmt mit dem Kalender ueberein', () => {
const { wochentagVon } = rein()
assert.equal(wochentagVon('2026-10-05'), 1, 'Montag')
assert.equal(wochentagVon('2026-10-11'), 7, 'Sonntag')
assert.equal(wochentagVon('1970-01-01'), 4, 'Donnerstag')
assert.equal(wochentagVon('2024-02-29'), 4, 'Schalttag')
// Gegenprobe ueber sieben Jahre, jeden Tag
const ende = Date.UTC(2031, 0, 1)
for (let ms = Date.UTC(2024, 0, 1); ms < ende; ms += 86400000) {
const d = new Date(ms)
const tag = d.toISOString().slice(0, 10)
const iso = d.getUTCDay() === 0 ? 7 : d.getUTCDay()
assert.equal(wochentagVon(tag), iso, tag)
}
})
test('zeitenText: Zeiten, rund um die Uhr, nach Vereinbarung, geschlossen – nie 24:00', () => {
const { zeitenText } = rein()
assert.equal(zeitenText('zeiten', [['09:00', '18:00']]), '09:00–18:00')
assert.equal(zeitenText('zeiten', [['09:00', '12:30'], ['13:30', '18:00']]), '09:00–12:30, 13:30–18:00')
assert.equal(zeitenText('zeiten', [['18:00', '24:00']]), '18:00–00:00', '24:00 wird als 00:00 gezeigt')
assert.equal(zeitenText('zeiten', [['22:00', '02:00']]), '22:00–02:00', 'ueber Mitternacht')
assert.equal(zeitenText('zeiten', [['00:00', '24:00']]), 'rund um die Uhr', 'ganztags = rund um die Uhr')
assert.equal(zeitenText('rund', []), 'rund um die Uhr')
assert.equal(zeitenText('vereinbarung', []), 'nach Vereinbarung')
assert.equal(zeitenText('zu', []), 'geschlossen')
assert.equal(zeitenText('zeiten', []), 'geschlossen', 'Zeiten ohne Fenster = geschlossen')
assert.equal(zeitenText('irgendwas', [['09:00', '10:00']]), 'geschlossen', 'unbekannter Modus = geschlossen')
assert.equal(zeitenText('zeiten', undefined), 'geschlossen')
for (const m of ['zeiten', 'rund', 'zu', 'vereinbarung']) assert.ok(!zeitenText(m, [['08:00', '24:00']]).includes('24:00'))
})
/** Plan wie ihn die oeffentliche API liefert: Montag bis Freitag 09:00–18:00, Samstag 09:00–13:00, Sonntag zu. */
function plan(tage = []) {
const woche = []
for (let tag = 1; tag <= 7; tag += 1) {
if (tag <= 5) woche.push({ tag, modus: 'zeiten', fenster: [['09:00', '18:00']] })
else if (tag === 6) woche.push({ tag, modus: 'zeiten', fenster: [['09:00', '13:00']] })
else woche.push({ tag, modus: 'zu', fenster: [] })
}
return { kurz: 'haupt', name: 'Öffnungszeiten', haupt: true, woche, tage, wechsel: [] }
}
/** n aufeinanderfolgende Tage ab `von`; `abweichend` ersetzt einzelne Tage (datum → Eintrag ohne datum). */
function tageListe(von, n, abweichend = {}, wochenplan = plan().woche) {
const liste = []
for (let i = 0; i < n; i += 1) {
const d = new Date(Date.parse(`${von}T12:00:00Z`) + i * 86400000)
const datum = d.toISOString().slice(0, 10)
const iso = d.getUTCDay() === 0 ? 7 : d.getUTCDay()
const regulaer = { art: 'regulaer', name: null, modus: wochenplan[iso - 1].modus, fenster: wochenplan[iso - 1].fenster }
liste.push({ datum, ...(abweichend[datum] ?? regulaer) })
}
return liste
}
test('tabelleDaten: heutiger Tag markiert, Abweichungen der nächsten 14 Tage', () => {
const { tabelleDaten } = rein()
const tage = tageListe('2026-10-05', 60, {
'2026-10-09': { art: 'sondertag', name: 'Inventur', modus: 'zu', fenster: [] },
'2026-10-12': { art: 'urlaub', name: 'Betriebsurlaub', modus: 'zu', fenster: [] },
'2026-10-13': { art: 'urlaub', name: 'Betriebsurlaub', modus: 'zu', fenster: [] },
'2026-10-17': { art: 'sondertag', name: 'Kurz', modus: 'zeiten', fenster: [['09:00', '11:00']] }, // heute + 12
'2026-10-18': { art: 'feiertag', name: 'Testfeiertag', modus: 'zu', fenster: [] }, // heute + 13: noch drin
'2026-10-19': { art: 'feiertag', name: 'Zu spaet', modus: 'zu', fenster: [] }, // heute + 14: schon draussen
'2026-11-01': { art: 'feiertag', name: 'Allerheiligen', modus: 'zu', fenster: [] },
})
const t = norm(tabelleDaten(plan(tage), '2026-10-05'))
assert.equal(t.woche.length, 7)
assert.deepEqual(t.woche.map((z) => z.name), ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'])
assert.deepEqual(t.woche.map((z) => z.tag), [1, 2, 3, 4, 5, 6, 7])
assert.deepEqual(t.woche.map((z) => z.text), ['09:00–18:00', '09:00–18:00', '09:00–18:00', '09:00–18:00', '09:00–18:00', '09:00–13:00', 'geschlossen'])
assert.deepEqual(t.woche.map((z) => z.heute), [true, false, false, false, false, false, false], '2026-10-05 ist ein Montag')
assert.deepEqual(t.abweichungen14.map((a) => a.datum), ['2026-10-09', '2026-10-12', '2026-10-13', '2026-10-17', '2026-10-18'])
assert.deepEqual(t.abweichungen14[0], { datum: '2026-10-09', art: 'sondertag', name: 'Inventur', modus: 'zu', fenster: [], text: 'geschlossen' })
assert.equal(t.abweichungen14[3].text, '09:00–11:00')
assert.equal(t.abweichungen14[4].name, 'Testfeiertag')
// ein anderer „heute“: Sonntag, 2026-10-11 → Fenster 11.10. bis 24.10.
const sonntag = norm(tabelleDaten(plan(tage), '2026-10-11'))
assert.deepEqual(sonntag.woche.map((z) => z.heute), [false, false, false, false, false, false, true])
assert.deepEqual(sonntag.abweichungen14.map((a) => a.datum), ['2026-10-12', '2026-10-13', '2026-10-17', '2026-10-18', '2026-10-19'], 'Vergangenes (09.10.) raus, 19.10. rein, 01.11. noch nicht')
})
test('tabelleDaten: Sonderfaelle (rund, vereinbarung, 24:00, regulaere Tage ohne Abweichung, kaputte Eingaben)', () => {
const { tabelleDaten } = rein()
const woche = plan().woche
woche[0] = { tag: 1, modus: 'rund', fenster: [] }
woche[1] = { tag: 2, modus: 'vereinbarung', fenster: [] }
woche[2] = { tag: 3, modus: 'zeiten', fenster: [['18:00', '24:00']] }
woche[3] = { tag: 4, modus: 'zeiten', fenster: [['09:00', '12:00'], ['14:00', '18:00']] }
woche[4] = { tag: 5, modus: 'zu', fenster: [] }
const p = { ...plan(tageListe('2026-10-05', 20, {}, woche)), woche: woche.filter((z) => z.tag !== 5) } // Freitag fehlt in den Daten: gilt als geschlossen
const t = norm(tabelleDaten(p, '2026-10-06'))
assert.deepEqual(t.woche.map((z) => z.text), ['rund um die Uhr', 'nach Vereinbarung', '18:00–00:00', '09:00–12:00, 14:00–18:00', 'geschlossen', '09:00–13:00', 'geschlossen'])
assert.ok(t.woche.every((z) => !z.text.includes('24:00')))
assert.deepEqual(t.woche.map((z) => z.heute), [false, true, false, false, false, false, false], 'Dienstag')
assert.deepEqual(t.abweichungen14, [], 'regulaere Tage ohne Abweichung werden nicht aufgelistet')
// ein „regulaerer“ Tag, dessen Zeiten vom Wochenplan abweichen, zaehlt trotzdem als Abweichung
const q = plan(tageListe('2026-10-05', 20, { '2026-10-08': { art: 'regulaer', name: null, modus: 'zeiten', fenster: [['10:00', '15:00']] } }))
assert.deepEqual(norm(tabelleDaten(q, '2026-10-05')).abweichungen14.map((a) => a.datum), ['2026-10-08'])
// Feiertag mit den ueblichen Zeiten ist trotzdem eine Zeile (die Art ist die Abweichung)
const r = plan(tageListe('2026-10-05', 20, { '2026-10-07': { art: 'feiertag', name: 'Fest', modus: 'zeiten', fenster: [['09:00', '18:00']] } }))
assert.deepEqual(norm(tabelleDaten(r, '2026-10-05')).abweichungen14.map((a) => a.datum), ['2026-10-07'])
// kaputte Eingaben: sieben Zeilen „geschlossen“ ohne Markierung, keine Abweichungen, kein Fehler
for (const kaputt of [undefined, null, {}, { woche: 'x', tage: 5 }]) {
const k = norm(tabelleDaten(kaputt, '2026-10-05'))
assert.equal(k.woche.length, 7)
assert.ok(k.woche.every((z) => z.text === 'geschlossen'))
assert.deepEqual(k.abweichungen14, [])
}
// ohne verwertbaren heutigen Tag: keine Markierung und keine Abweichungen
for (const tag of [undefined, null, '', 'heute', '5.10.2026']) {
const k = norm(tabelleDaten(p, tag))
assert.ok(k.woche.every((z) => z.heute === false), String(tag))
assert.deepEqual(k.abweichungen14, [])
}
})
test('Abweichungen: aufeinanderfolgende gleiche Tage in einer Zeile, Beschriftung mit Wochentag und Datum', () => {
const { tabelleDaten, gruppiereAbweichungen, abweichungsZeile } = rein()
const urlaub = { art: 'urlaub', name: 'Betriebsurlaub', modus: 'zu', fenster: [] }
const tage = tageListe('2026-10-05', 20, {
'2026-10-06': urlaub, '2026-10-07': urlaub, '2026-10-08': urlaub,
'2026-10-10': { art: 'sondertag', name: 'Hoffest', modus: 'zeiten', fenster: [['10:00', '16:00']] },
'2026-10-12': { art: 'feiertag', name: 'Feiertag A', modus: 'zu', fenster: [] },
'2026-10-13': { art: 'feiertag', name: 'Feiertag B', modus: 'zu', fenster: [] }, // anderer Name: eigene Zeile
'2026-10-15': { art: 'urlaub', name: null, modus: 'zu', fenster: [] },
})
const gruppen = norm(gruppiereAbweichungen(tabelleDaten(plan(tage), '2026-10-05').abweichungen14))
assert.deepEqual(gruppen.map((g) => [g.von, g.bis]), [
['2026-10-06', '2026-10-08'], ['2026-10-10', '2026-10-10'], ['2026-10-12', '2026-10-12'], ['2026-10-13', '2026-10-13'], ['2026-10-15', '2026-10-15'],
])
assert.deepEqual(norm(abweichungsZeile(gruppen[0])), { datum: 'Dienstag, 06.10. – Donnerstag, 08.10.', text: 'Betriebsurlaub: geschlossen' })
assert.deepEqual(norm(abweichungsZeile(gruppen[1])), { datum: 'Samstag, 10.10.', text: 'Hoffest: 10:00–16:00' })
assert.deepEqual(norm(abweichungsZeile(gruppen[4])), { datum: 'Donnerstag, 15.10.', text: 'Betriebsurlaub: geschlossen' }, 'ohne Namen die Art als Beschriftung')
assert.deepEqual(norm(abweichungsZeile({ von: '2026-11-01', bis: '2026-11-01', art: 'feiertag', name: null, text: 'geschlossen' })), { datum: 'Sonntag, 01.11.', text: 'Feiertag: geschlossen' })
assert.deepEqual(norm(abweichungsZeile({ von: '2026-11-02', bis: '2026-11-02', art: 'sondertag', name: null, text: 'geschlossen' })).text, 'Sondertag: geschlossen')
assert.deepEqual(norm(gruppiereAbweichungen([])), [])
assert.deepEqual(norm(gruppiereAbweichungen(undefined)), [])
})
// ---------------------------------------------------------------- Demo-Seite
test('Demo-Seite: noindex, Skript per defer mit data-site, alle drei Platzhalter-Modi mit Ersatz-HTML', () => {
const demo = lies('public/embed/zeiten-demo.html')
assert.match(demo, /<meta name="robots" content="noindex"/)
assert.match(demo, /<script src="\/embed\/zeiten\.js" data-site="[a-z2-7]{16}" defer><\/script>/)
for (const modus of ['tabelle', 'status', 'badge']) {
assert.match(demo, new RegExp(`<[a-z]+ data-webklar-zeiten data-modus="${modus}"`), `Platzhalter ${modus} fehlt`)
}
assert.match(demo, /data-plan="/, 'ein Platzhalter mit data-plan')
assert.match(demo, /\d\d:\d\d\s*(?:&#8211;|–)\s*\d\d:\d\d/, 'Ersatz-HTML mit Zeiten fehlt')
assert.match(demo, /<table>[\s\S]*<caption>[\s\S]*Montag[\s\S]*Sonntag/, 'Ersatz-Tabelle fehlt')
})