Files
Webklar-Kundenbereich/test/zeitenEmbedStatic.test.js
T
ClaudeandClaude Opus 5.5 aa9ee35ab6 Öffnungszeiten: Fixrunde Website-Skript und Editor (JSON-LD, Kopf beim Scrollen, späte Platzhalter, Links, Bestätigung)
- JSON-LD wird ohne "<" geschrieben (<), ein </script> im Firmennamen bricht keinen Block mehr
- verschiebe(): ist die Hinweis-Leiste weggescrollt, bekommen feste Elemente ihren eigenen Inline-top zurück
  (Köpfe, die per Klasse beim Scrollen wegfahren, funktionieren wieder)
- Späte Platzhalter (Einzelseiten-Apps): entprellter MutationObserver auf body zeichnet neue oder wieder
  eingehängte Platzhalter nach
- sichererLink lehnt Zugangsdaten in der Adresse ab; die Adresse der Schnittstelle bestimmt nur das eigene
  Script-Tag, kein Platzhalter mehr (der siteKey am Platzhalter bleibt laut Spec 4.2)
- Editor: die Bestätigung klebt unter der Kopfleiste und bleibt nach dem Speichern eines unteren Abschnitts sichtbar

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

691 lines
46 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')
})
// ---------------------------------------------------------------- Task 26: Hinweis-Leiste, Kontakt-Leiste, JSON-LD
/** Die reinen Funktionen der Leisten und des JSON-LD (zwischen den Marken) samt telefonLink (eigene Marke), einzeln in einem leeren Kontext. */
function leisten() {
const rein = /\/\/ @rein-anfang[^\n]*\n([\s\S]*?)\/\/ @rein-ende/.exec(QUELLE)
const tel = /\/\/ @telefon-anfang[^\n]*\n([\s\S]*?)\/\/ @telefon-ende/.exec(QUELLE)
assert.ok(rein && tel, 'Marken fehlen')
return vm.runInNewContext(
`${tel[1]}; ${rein[1]}; ({ jsonLdEinbauen, jsonLdText, kontaktKnoepfe, istEigeneAnrufLeiste, hinweisKnopf, HINWEIS_FARBEN })`,
{},
)
}
const WOCHE_LD = [
{ '@type': 'OpeningHoursSpecification', dayOfWeek: 'Monday', opens: '09:00', closes: '18:00' },
{ '@type': 'OpeningHoursSpecification', dayOfWeek: 'Saturday', opens: '09:00', closes: '12:00' },
]
const SONDER_LD = [{ '@type': 'OpeningHoursSpecification', validFrom: '2026-10-12', validThrough: '2026-10-16', opens: '00:00', closes: '00:00' }]
const jsonldVomServer = () => ({
openingHoursSpecification: WOCHE_LD,
specialOpeningHoursSpecification: SONDER_LD,
neuerBlock: { '@context': 'https://schema.org', '@type': 'LocalBusiness', openingHoursSpecification: WOCHE_LD, specialOpeningHoursSpecification: SONDER_LD },
})
const SEITE = { name: 'Bäckerei Sonnenschein', adresse: 'Hauptstraße 1, 67722 Winnweiler', url: 'https://baeckerei.example/' }
test('JSON-LD: vorhandener Restaurant-Knoten wird ergänzt, kein zweiter Block', () => {
const { jsonLdEinbauen } = leisten()
const bloecke = [
{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'Zum Löwen', url: 'https://loewen.example/' },
{
'@context': 'https://schema.org', '@type': 'Restaurant', name: 'Zum Löwen', telephone: '+49 6301 1234', servesCuisine: 'Deutsch',
address: { '@type': 'PostalAddress', streetAddress: 'Hauptstr. 1' },
openingHoursSpecification: [{ '@type': 'OpeningHoursSpecification', dayOfWeek: 'Sunday', opens: '11:00', closes: '14:00' }],
specialOpeningHoursSpecification: [{ '@type': 'OpeningHoursSpecification', validFrom: '2020-01-01', validThrough: '2020-01-02', opens: '00:00', closes: '00:00' }],
},
]
const erg = norm(jsonLdEinbauen(bloecke, jsonldVomServer(), SEITE))
assert.deepEqual(erg, { geaendert: [1], neu: null }, 'nur der Restaurant-Block ändert sich, es kommt kein zweiter Block dazu')
assert.deepEqual(norm(bloecke[1].openingHoursSpecification), WOCHE_LD)
assert.deepEqual(norm(bloecke[1].specialOpeningHoursSpecification), SONDER_LD)
// alles andere bleibt, wie es war
assert.equal(bloecke[1]['@type'], 'Restaurant')
assert.equal(bloecke[1].name, 'Zum Löwen')
assert.equal(bloecke[1].telephone, '+49 6301 1234')
assert.equal(bloecke[1].servesCuisine, 'Deutsch')
assert.deepEqual(norm(bloecke[1].address), { '@type': 'PostalAddress', streetAddress: 'Hauptstr. 1' })
assert.deepEqual(norm(bloecke[0]), { '@context': 'https://schema.org', '@type': 'WebSite', name: 'Zum Löwen', url: 'https://loewen.example/' })
// zweiter Durchlauf mit denselben Daten: nichts mehr zu schreiben
assert.deepEqual(norm(jsonLdEinbauen(bloecke, jsonldVomServer(), SEITE)), { geaendert: [], neu: null })
})
test('JSON-LD: @graph mit HairSalon wird ergänzt (nur dieser Knoten), auch @type als Liste', () => {
const { jsonLdEinbauen } = leisten()
const bloecke = [{
'@context': 'https://schema.org',
'@graph': [
{ '@type': 'WebSite', '@id': '#web', name: 'Salon' },
{ '@type': 'Organization', '@id': '#org', name: 'Salon GmbH', openingHoursSpecification: 'unberührt' },
{ '@type': ['Organization', 'HairSalon'], '@id': '#salon', name: 'Salon Müller', openingHours: 'Mo-Fr 09:00-18:00' },
{ '@type': 'BreadcrumbList', itemListElement: [] },
],
}]
const erg = norm(jsonLdEinbauen(bloecke, jsonldVomServer(), SEITE))
assert.deepEqual(erg, { geaendert: [0], neu: null })
const graph = bloecke[0]['@graph']
assert.deepEqual(norm(graph[2].openingHoursSpecification), WOCHE_LD)
assert.deepEqual(norm(graph[2].specialOpeningHoursSpecification), SONDER_LD)
assert.equal(graph[2].openingHours, 'Mo-Fr 09:00-18:00', 'nur die beiden Specification-Felder werden ersetzt')
assert.equal(graph[1].openingHoursSpecification, 'unberührt', 'Organization ist kein Unternehmen vor Ort')
assert.ok(!('openingHoursSpecification' in graph[0]) && !('openingHoursSpecification' in graph[3]))
})
test('JSON-LD: Typen aus der Liste der Unterarten, auch als Adresse oder mit Präfix; Felder-Listen oben (Array) und andere Typen zählen nicht', () => {
const { jsonLdEinbauen } = leisten()
for (const typ of ['LocalBusiness', 'Bakery', 'CafeOrCoffeeShop', 'Dentist', 'AutoRepair', 'Store', 'Physician', 'Hotel', 'BeautySalon', 'schema:Florist', 'Restaurant']) {
const b = [[{ '@type': typ, name: 'X' }]] // Block, der selbst eine Liste ist
assert.deepEqual(norm(jsonLdEinbauen(b, jsonldVomServer(), SEITE)).geaendert, [0], typ)
assert.deepEqual(norm(b[0][0].openingHoursSpecification), WOCHE_LD, typ)
}
for (const typ of ['Person', 'Organization', 'WebSite', 'WebPage', 'BreadcrumbList', 'Product', 'Event', 'Article', 'Dummy', '', undefined, 5]) {
const b = [{ '@type': typ, name: 'X' }]
const erg = norm(jsonLdEinbauen(b, jsonldVomServer(), SEITE))
assert.deepEqual(erg.geaendert, [], String(typ))
assert.ok(erg.neu, `${String(typ)}: kein Unternehmen vor Ort gefunden, also ein neuer Block`)
assert.ok(!('openingHoursSpecification' in b[0]), String(typ))
}
})
test('JSON-LD: unparsbarer Block bleibt unberührt, neuer Block kommt dazu', () => {
const { jsonLdEinbauen } = leisten()
const bloecke = [null, { '@context': 'https://schema.org', '@type': 'Organization', name: 'Sonnenschein GmbH' }, null]
const vorher = JSON.stringify(bloecke)
const erg = norm(jsonLdEinbauen(bloecke, jsonldVomServer(), SEITE))
assert.deepEqual(erg.geaendert, [])
assert.ok(erg.neu && erg.neu['@type'] === 'LocalBusiness')
assert.equal(JSON.stringify(bloecke), vorher, 'die vorhandenen Blöcke bleiben, wie sie waren')
// ein unparsbarer Block neben einem Unternehmen: der parsbare Block wird ergänzt, der unparsbare bleibt (null) unberührt
const b2 = [null, { '@type': 'HairSalon', name: 'S' }]
assert.deepEqual(norm(jsonLdEinbauen(b2, jsonldVomServer(), SEITE)), { geaendert: [1], neu: null })
assert.equal(b2[0], null)
})
test('JSON-LD: ohne LocalBusiness ⇒ neuerBlock mit name/url/address, die Zeiten kommen vom Server', () => {
const { jsonLdEinbauen } = leisten()
const erg = norm(jsonLdEinbauen([], jsonldVomServer(), SEITE))
assert.deepEqual(erg.geaendert, [])
assert.deepEqual(erg.neu, {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: 'Bäckerei Sonnenschein',
address: 'Hauptstraße 1, 67722 Winnweiler',
url: 'https://baeckerei.example/',
openingHoursSpecification: WOCHE_LD,
specialOpeningHoursSpecification: SONDER_LD,
})
// ohne Adresse kein address-Feld; ohne Seiten-Adresse kein url-Feld
const ohne = norm(jsonLdEinbauen([], jsonldVomServer(), { name: 'Nur Name', adresse: null, url: '' })).neu
assert.equal(ohne.name, 'Nur Name')
assert.ok(!('address' in ohne) && !('url' in ohne))
// ohne Namen (kein Firmenname, kein Projektname): kein Block (ein LocalBusiness ohne name ist für Suchmaschinen wertlos)
assert.equal(norm(jsonLdEinbauen([], jsonldVomServer(), { name: '', adresse: 'x', url: 'y' })).neu, null)
// der Block ist eine Kopie: Änderungen daran berühren die Antwort des Servers nicht
const jd = jsonldVomServer()
const neu = jsonLdEinbauen([], jd, SEITE).neu
neu.openingHoursSpecification[0].opens = '03:00'
assert.equal(jd.neuerBlock.openingHoursSpecification[0].opens, '09:00')
assert.equal(jd.openingHoursSpecification[0].opens, '09:00')
})
test('JSON-LD: jsonld null (keine Zeiten gespeichert) lässt alles unberührt; leere Listen entfernen die alten Felder', () => {
const { jsonLdEinbauen } = leisten()
for (const jd of [null, undefined, 'x', 5, []]) {
const b = [{ '@type': 'Restaurant', openingHoursSpecification: [{ x: 1 }] }]
const vorher = JSON.stringify(b)
assert.deepEqual(norm(jsonLdEinbauen(b, jd, SEITE)), { geaendert: [], neu: null }, JSON.stringify(jd))
assert.equal(JSON.stringify(b), vorher)
assert.deepEqual(norm(jsonLdEinbauen([], jd, SEITE)), { geaendert: [], neu: null })
}
// nach Vereinbarung / immer zu: nichts mehr zu sagen; die alten Zeiten dürfen nicht stehen bleiben
const b = [{ '@type': 'Bakery', name: 'B', openingHoursSpecification: [{ x: 1 }], specialOpeningHoursSpecification: [{ y: 2 }] }]
const erg = norm(jsonLdEinbauen(b, { openingHoursSpecification: [], specialOpeningHoursSpecification: [], neuerBlock: {} }, SEITE))
assert.deepEqual(erg.geaendert, [0])
assert.ok(!('openingHoursSpecification' in b[0]) && !('specialOpeningHoursSpecification' in b[0]))
// kaputte Eingaben für bloecke: kein Fehler
for (const kaputt of [undefined, null, 'x', {}, [undefined, 5, 'x', [], [null]]]) {
assert.doesNotThrow(() => jsonLdEinbauen(kaputt, jsonldVomServer(), SEITE))
}
assert.doesNotThrow(() => jsonLdEinbauen([], jsonldVomServer(), null))
})
test('Kontakt-Knöpfe aus Teildaten', () => {
const { kontaktKnoepfe } = leisten()
const route = 'https://www.google.com/maps/dir/?api=1&destination=Hauptstra%C3%9Fe%201'
const wa = 'https://wa.me/491711234567?text=Hallo'
assert.deepEqual(norm(kontaktKnoepfe({ telefon: '491711234567', routeUrl: null, whatsappUrl: null })), [{ art: 'anruf', href: 'tel:+491711234567', text: 'Anrufen' }])
assert.deepEqual(norm(kontaktKnoepfe({ telefon: null, routeUrl: route, whatsappUrl: null })), [{ art: 'route', href: route, text: 'Route' }])
assert.deepEqual(norm(kontaktKnoepfe({ telefon: null, routeUrl: null, whatsappUrl: wa })), [{ art: 'whatsapp', href: wa, text: 'WhatsApp' }])
assert.deepEqual(norm(kontaktKnoepfe({ telefon: '491711234567', routeUrl: route, whatsappUrl: wa })).map((k) => k.art), ['anruf', 'route', 'whatsapp'])
for (const leer of [null, undefined, {}, { telefon: null, routeUrl: null, whatsappUrl: null }, 'x', 5, []]) assert.deepEqual(norm(kontaktKnoepfe(leer)), [], JSON.stringify(leer))
// Browser prüft erneut: fremde Schemata, http, Leerzeichen und keine Ziffernfolge fallen weg
const k = norm(kontaktKnoepfe({ telefon: '0171 123', routeUrl: 'javascript:alert(1)', whatsappUrl: 'http://wa.me/491711234567' }))
assert.deepEqual(k, [])
assert.deepEqual(norm(kontaktKnoepfe({ telefon: '+491711234567', routeUrl: 'https://x.example/a b', whatsappUrl: 'data:text/html,x' })), [], '"+" gehört nicht in die Ziffernfolge des Servers')
assert.deepEqual(norm(kontaktKnoepfe({ telefon: '12345', routeUrl: 5, whatsappUrl: {} })), [], 'zu kurz / kein Text')
})
test('Links aus der API werden erneut geprüft (https:, tel: nur über telefonLink)', () => {
const { hinweisKnopf } = leisten()
assert.equal(hinweisKnopf({ art: 'link', ziel: 'javascript:alert(1)', text: 'Los' }), null, 'javascript: ist kein Link')
for (const bose of ['JaVaScRiPt:alert(1)', 'data:text/html,<b>x</b>', 'vbscript:x', 'http://x.example/', '//x.example/', '/aktion', 'x.example', ' https://x.example/', 'https://x.example/a b', 'https://', 'https:x', '', 'tel:+4963011234', 'https://x.example/\n', 5, null, {},
// Zugangsdaten in der Adresse (wie der Server und linkOk des Portals): nie ein Link
'https://user:pw@x.example/', 'https://user@x.example/', 'https://:pw@x.example/', 'https://x.example@evil.example/', 'https://evil.example\\@x.example/']) {
assert.equal(hinweisKnopf({ art: 'link', ziel: bose, text: 'Los' }), null, JSON.stringify(bose))
}
// Pfad, Suche, Anker und Port bleiben erlaubt (ein @ dort ist keine Zugangsdaten)
for (const gut of ['https://x.example', 'https://x.example:8443/a', 'https://x.example?x=1', 'https://x.example#a', 'https://x.example/mail@x.example', 'https://x.example/a?u=a@b#c@d']) {
assert.equal(hinweisKnopf({ art: 'link', ziel: gut, text: 'Los' })?.href, gut, gut)
}
assert.deepEqual(norm(hinweisKnopf({ art: 'link', ziel: 'https://baeckerei.example/aktion?x=1#a', text: 'Zur Aktion' })), { art: 'link', href: 'https://baeckerei.example/aktion?x=1#a', text: 'Zur Aktion' })
assert.deepEqual(norm(hinweisKnopf({ art: 'anruf', ziel: '06301 1234', text: 'Jetzt anrufen' })), { art: 'anruf', href: 'tel:063011234', text: 'Jetzt anrufen' })
assert.deepEqual(norm(hinweisKnopf({ art: 'anruf', ziel: '+49 6301 1234', text: '' })), { art: 'anruf', href: 'tel:+4963011234', text: 'Anrufen' }, 'ohne Beschriftung eine Vorgabe')
assert.equal(hinweisKnopf({ art: 'anruf', ziel: 'ruf mich an', text: 'Anrufen' }), null, 'freier Text ist keine Nummer')
assert.equal(hinweisKnopf({ art: 'anruf', ziel: 'javascript:alert(1)', text: 'x' }), null)
assert.equal(hinweisKnopf({ art: 'mail', ziel: 'mailto:a@b.de', text: 'x' }), null, 'unbekannte Art')
for (const leer of [null, undefined, {}, 'x', 5, []]) assert.equal(hinweisKnopf(leer), null, JSON.stringify(leer))
})
test('Kontakt-Knöpfe: Route und WhatsApp mit Zugangsdaten in der Adresse fallen weg', () => {
const { kontaktKnoepfe } = leisten()
assert.deepEqual(norm(kontaktKnoepfe({ telefon: null, routeUrl: 'https://user:pw@www.google.com/maps', whatsappUrl: 'https://wa.me@evil.example/491711234567' })), [])
})
test('JSON-LD-Text: kein "<" im geschriebenen Text (</script> beendet sonst ein serialisiertes Skript), Inhalt bleibt gleich lesbar', () => {
const { jsonLdText } = leisten()
const wert = { '@type': 'LocalBusiness', name: 'Bäckerei </script><img src=x onerror=alert(1)> & Co', address: 'a<b' }
const text = jsonLdText(wert)
assert.ok(!text.includes('<'), text)
assert.ok(text.includes('\\u003c/script>'))
assert.deepEqual(JSON.parse(text), wert, 'JSON.parse macht daraus wieder dasselbe')
assert.equal(jsonLdText([]), '[]')
// beide Stellen, die JSON-LD in die Seite schreiben, gehen über den Helfer
assert.doesNotMatch(OHNE_KOMMENTARE, /textContent = JSON\.stringify\(/, 'JSON-LD nie ohne Escape in textContent')
assert.equal([...OHNE_KOMMENTARE.matchAll(/textContent = jsonLdText\(/g)].length, 2, 'eigener und fremder Block')
})
test('Quelltext: die Adresse der Schnittstelle bestimmt nur das eigene Script-Tag (data-api) oder dessen Herkunft, nie ein Platzhalter', () => {
const api = /function bestimmeApi \(skript\) \{([\s\S]*?)\n \}\n/.exec(OHNE_KOMMENTARE)
assert.ok(api, 'bestimmeApi fehlt')
assert.match(api[1], /attr\(skript, 'data-api'\)/)
assert.doesNotMatch(api[1], /platzhalter/, 'kein data-api an Platzhaltern: eine Seite könnte sonst die Anfragen umleiten')
// der siteKey darf (Spec 4.2) auch am Platzhalter stehen
const key = /function bestimmeSiteKey \(skript\) \{([\s\S]*?)\n \}\n/.exec(OHNE_KOMMENTARE)
assert.ok(key && /attr\(skript, 'data-site'\)/.test(key[1]) && /attr\(platzhalter\[i\]\.root, 'data-webklar-zeiten'\)/.test(key[1]))
})
test('Späte Platzhalter (Einzelseiten-Apps): ein MutationObserver auf body, entprellt über timer, rendert nur bei neuem oder wieder eingehängtem Platzhalter', () => {
assert.match(OHNE_KOMMENTARE, /new MutationObserver\(/)
assert.match(OHNE_KOMMENTARE, /observe\(document\.body, \{ childList: true, subtree: true \}\)/)
const beo = /function beobachteSeite \(\) \{([\s\S]*?)\n \}\n/.exec(OHNE_KOMMENTARE)
assert.ok(beo, 'beobachteSeite fehlt')
assert.match(beo[1], /timer\(/, 'die Entprellung läuft über timer (die einzige setTimeout-Stelle)')
assert.match(OHNE_KOMMENTARE, /var BEOBACHTER_MS = 200\b/)
assert.match(OHNE_KOMMENTARE, /querySelector\(SELEKTOR \+ ':not\(\[data-wkz-init\]\)'\)/)
})
test('istEigeneAnrufLeiste: festes oder klebendes Element unten mit tel:-Link; oben, ausgeblendet oder bildschirmhoch zählt nicht', () => {
const { istEigeneAnrufLeiste } = leisten()
const fenster = 844
const unten = { position: 'fixed', bottom: '0px', oben: fenster - 56, unten: fenster, fenster, sichtbar: true }
assert.equal(istEigeneAnrufLeiste([unten]), true, 'feste Leiste am unteren Rand')
assert.equal(istEigeneAnrufLeiste([{ ...unten, position: 'sticky' }]), true, 'klebende Leiste unten (bottom gesetzt)')
assert.equal(istEigeneAnrufLeiste([{ ...unten, oben: fenster - 70, unten: fenster - 20 }]), true, 'schwebender Anruf-Knopf knapp über dem Rand')
assert.equal(istEigeneAnrufLeiste([{ ...unten, position: 'sticky', bottom: 'auto' }]), false, 'klebender Kopf (top) ist keine untere Leiste')
assert.equal(istEigeneAnrufLeiste([{ ...unten, oben: 0, unten: 60 }]), false, 'fester Kopf oben')
assert.equal(istEigeneAnrufLeiste([{ ...unten, oben: 0, unten: fenster }]), false, 'bildschirmhohe Seitenleiste')
assert.equal(istEigeneAnrufLeiste([{ ...unten, sichtbar: false }]), false, 'ausgeblendet')
assert.equal(istEigeneAnrufLeiste([{ ...unten, position: 'absolute' }]), false, 'nicht fest')
assert.equal(istEigeneAnrufLeiste([{ ...unten, position: 'static' }]), false)
assert.equal(istEigeneAnrufLeiste([{ ...unten, oben: fenster + 100, unten: fenster + 156 }]), false, 'außerhalb des Bildschirms')
assert.equal(istEigeneAnrufLeiste([{ ...unten, sichtbar: false }, unten]), true, 'ein Treffer genügt')
for (const leer of [[], undefined, null, 'x', [null, undefined, 5]]) assert.equal(istEigeneAnrufLeiste(leer), false, JSON.stringify(leer))
})
test('Farben der Hinweis-Leiste: je Art Hintergrund und Schrift mit Kontrast ≥ 4,5:1', () => {
const { HINWEIS_FARBEN } = leisten()
const farben = norm(HINWEIS_FARBEN)
assert.deepEqual(Object.keys(farben).sort(), ['aktion', 'info', 'urlaub', 'wichtig'])
const lum = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
for (const [art, f] of Object.entries(farben)) {
assert.match(f.bg, /^#[0-9a-f]{6}$/, `${art}: bg`)
assert.match(f.fg, /^#[0-9a-f]{6}$/, `${art}: fg`)
const [hell, dunkel] = [lum(f.bg), lum(f.fg)].sort((a, b) => b - a)
assert.ok((hell + 0.05) / (dunkel + 0.05) >= 4.5, `${art}: Kontrast ${((hell + 0.05) / (dunkel + 0.05)).toFixed(2)}`)
}
})
test('Leisten im Quelltext: Shadow DOM, Rolle und Name, Handy-Abfrage, z-index 40, Abstandhalter, scroll-padding, rAF-Scroll, reduzierte Bewegung', () => {
for (const noetig of [
'attachShadow', "'region'", "'Hinweis'", 'aria-label', 'role',
'(max-width: 768px)', 'matchMedia', 'z-index:40', 'position:fixed', 'bottom:0', 'noopener noreferrer',
'scroll-padding-top', 'scroll-padding-bottom', 'prefers-reduced-motion', 'requestAnimationFrame', 'passive',
'a[href^="tel:"]', 'application/ld+json', 'JSON.stringify', 'data-wkz-abstand', 'firstChild',
]) {
assert.ok(OHNE_KOMMENTARE.includes(noetig), `${noetig} fehlt im Skript`)
}
assert.doesNotMatch(OHNE_KOMMENTARE, /aria-live/)
assert.ok(!OHNE_KOMMENTARE.includes('close') && !/Schlie(ss|ß)en/.test(OHNE_KOMMENTARE), 'keine Schließen-Schaltfläche')
// JSON-LD wird über textContent geschrieben (nie innerHTML), der Selektor des Teams nur in try/catch
assert.match(OHNE_KOMMENTARE, /\.textContent = jsonLdText\(/, "JSON-LD wird über jsonLdText (ohne <) geschrieben")
assert.match(OHNE_KOMMENTARE, /try \{[^}]*querySelectorAll\(nav\)[^}]*\} catch/, 'navSelektor nur in try/catch')
// Linkziele nie per String in HTML, sondern per setAttribute; jeder Link bekommt rel
assert.ok(OHNE_KOMMENTARE.includes("'rel', 'noopener noreferrer'"))
})
test('Ruling R29: tick zeichnet mit try/finally, ein sofort werfender fetch beendet den Abruf, beim Zurückkehren in den Tab erst zeichnen, dann laden', () => {
assert.match(OHNE_KOMMENTARE, /function tick \(\) \{[\s\S]*?try \{ render\(\) \} finally \{ plane\(\) \}[\s\S]*?\n \}/)
const lade = /function lade \(\) \{([\s\S]*?)\n \}\n/.exec(OHNE_KOMMENTARE)
assert.ok(lade, 'lade fehlt')
assert.match(lade[1], /try \{[^}]*fetch\([\s\S]*?\} catch[\s\S]*?fertig\(\)/, 'ein synchroner Fehler von fetch führt zu fertig()')
const sicht = /addEventListener\('visibilitychange', function \(\) \{([\s\S]*?)\n \}\)/.exec(OHNE_KOMMENTARE)
assert.ok(sicht, 'visibilitychange fehlt')
assert.ok(sicht[1].indexOf('render()') >= 0 && sicht[1].indexOf('render()') < sicht[1].indexOf('lade()'), 'erst render(), dann lade()')
})