Öffnungszeiten: Hinweis-Leiste, Kontakt-Leiste und JSON-LD im Website-Skript

zeiten.js zeichnet mit denselben Daten wie die Platzhalter eine Hinweis-Leiste
(Shadow DOM, erstes Kind von body, rückt feste und klebende Köpfe, scroll-padding-top),
eine Kontakt-Leiste nur auf schmalen Schirmen (Abstandhalter, scroll-padding-bottom,
entfällt bei eigener Anruf-Leiste) und ergänzt das JSON-LD der Seite (nur die beiden
Zeiten-Felder, sonst eigener Block). Alle Adressen aus der Schnittstelle werden im
Browser erneut geprüft. Ruling R29: tick mit try/finally, sofort werfender fetch,
erst zeichnen dann laden beim Zurückkehren in den Tab.

Tests: statische Funktionstests der reinen Funktionen und eine Playwright-Prüfung
gegen den UI-Testserver (echte öffentliche Schnittstelle, Nachbau von Appwrite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 04:27:04 +00:00
1 parent a6a4eae294
commit ed99234ae4
4 files changed
+1542 -8

No files matched your search

+559 -8
View File
@@ -15,6 +15,22 @@
* Variablen (alle optional, an den Platzhalter oder :root): --wkz-offen, --wkz-zu, --wkz-unklar (Punktfarben), --wkz-offen-hell,
* --wkz-zu-hell, --wkz-unklar-hell, --wkz-offen-text, --wkz-zu-text, --wkz-unklar-text (Badge), --wkz-rand, --wkz-heute.
*
* Leisten und JSON-LD (kommen mit den Daten, ohne Platzhalter; das Team kann sie je Seite abschalten):
* Hinweis-Leiste Shadow-DOM-Host [data-wkz-leiste="hinweis"] als erstes Kind von <body>, im Fluss (schiebt den Inhalt). Zeigt den
* gueltigen Hinweis mit dem hoechsten Rang (Info, Aktion, Urlaub, Wichtig); role="region" aria-label="Hinweis",
* kein Schliessen-Knopf. Feste und klebende Elemente mit top: 0 (und die des Team-Selektors navSelektor) rutschen per
* Inline-top um max(0, Leistenhoehe - Scrollposition) nach unten (beim Entfernen wie vorher), scroll-padding-top des
* Dokuments waechst um die Hoehe. Ein festes Element, das schon ueber der Leiste endet (die Seite hat seinen Platz per
* body-Polster freigehalten, die Leiste steht darunter), bleibt, wo es ist. Mit prefers-reduced-motion ohne Einblendung.
* Kontakt-Leiste nur bei (max-width: 768px): Shadow-DOM-Host [data-wkz-leiste="kontakt"] unten fest (z-index 40) mit Anrufen, Route
* und WhatsApp (nur, was angegeben ist), dazu ein Abstandhalter [data-wkz-abstand] am Ende von <body> und
* scroll-padding-bottom. Entfaellt, wenn die Seite selbst ein festes Element am unteren Rand mit tel:-Link hat.
* JSON-LD ein vorhandener application/ld+json-Block mit einem Unternehmen vor Ort (LocalBusiness oder Untertyp, auch in @graph)
* bekommt nur openingHoursSpecification und specialOpeningHoursSpecification neu; gibt es keinen, legt das Skript
* einen Block [data-wkz-jsonld] an. Unlesbare Bloecke bleiben unberuehrt.
* Jede Adresse aus der Schnittstelle wird im Browser noch einmal geprueft (https:, tel: nur ueber telefonLink); Links tragen
* rel="noopener noreferrer".
*
* Ein Abruf je Seite fuer alle Teile; keine Abhaengigkeiten, keine Cookies, kein Storage, keine Anfragen an Dritte. Kundentexte nur
* ueber textContent. Das Skript rechnet nie mit Feiertagen, Kalender oder Zeitzone des Browsers: "jetzt" ist Date.now() (weicht die
* Uhr laut Date-Kopf um mehr als 5 Minuten ab, die Serverzeit), alles andere kommt als Zeichenkette vom Server. Es laedt hoechstens
@@ -183,6 +199,143 @@
if (g.bis && g.bis !== g.von) datum += ' – ' + datumKurz(g.bis)
return { datum: datum, text: (g.name || artName(g.art) || 'Abweichung') + ': ' + g.text }
}
function istObjekt (v) { return v !== null && typeof v === 'object' && !Array.isArray(v) }
// Farben der Hinweis-Leiste je Art (Hintergrund, Schrift): Kontrast jeweils mindestens 4,5:1 (geprueft in test/zeitenEmbedStatic.test.js)
var HINWEIS_FARBEN = {
info: { bg: '#e0f2fe', fg: '#0c4a6e' },
aktion: { bg: '#dcfce7', fg: '#14532d' },
urlaub: { bg: '#fef3c7', fg: '#78350f' },
wichtig: { bg: '#b91c1c', fg: '#ffffff' }
}
// schema.org: LocalBusiness und seine Untertypen (Unternehmen vor Ort); Person, Organization, WebSite, WebPage u. a. gehoeren nicht dazu
var LOKALE_TYPEN = ' LocalBusiness AnimalShelter ArchiveOrganization AutomotiveBusiness AutoBodyShop AutoDealer AutoPartsStore AutoRental AutoRepair AutoWash GasStation MotorcycleDealer MotorcycleRepair ' +
'ChildCare Dentist DryCleaningOrLaundry EmergencyService FireStation Hospital PoliceStation EmploymentAgency EntertainmentBusiness AdultEntertainment AmusementPark ArtGallery Casino ComedyClub MovieTheater NightClub ' +
'FinancialService AccountingService AutomatedTeller BankOrCreditUnion InsuranceAgency FoodEstablishment Bakery BarOrPub Brewery CafeOrCoffeeShop Distillery FastFoodRestaurant IceCreamShop Restaurant Winery ' +
'GovernmentOffice PostOffice HealthAndBeautyBusiness BeautySalon DaySpa HairSalon HealthClub NailSalon TattooParlor HomeAndConstructionBusiness Electrician GeneralContractor HVACBusiness HousePainter Locksmith MovingCompany Plumber RoofingContractor ' +
'InternetCafe LegalService Attorney Notary Library LodgingBusiness BedAndBreakfast Campground Hostel Hotel Motel Resort SkiResort VacationRental ' +
'MedicalBusiness CommunityHealth Dermatology DietNutrition Emergency Geriatric Gynecologic MedicalClinic Midwifery Nursing Obstetric Oncologic Optician Optometric Otolaryngologic Pediatric Pharmacy Physician Physiotherapy PlasticSurgery Podiatric PrimaryCare Psychiatric PublicHealth ' +
'ProfessionalService RadioStation RealEstateAgent RecyclingCenter SelfStorage ShoppingCenter SportsActivityLocation BowlingAlley ExerciseGym GolfCourse PublicSwimmingPool SportsClub StadiumOrArena TennisComplex ' +
'Store BikeStore BookStore ClothingStore ComputerStore ConvenienceStore DepartmentStore ElectronicsStore Florist FurnitureStore GardenStore GroceryStore HardwareStore HobbyShop HomeGoodsStore JewelryStore LiquorStore MensClothingStore MobilePhoneStore MovieRentalStore MusicStore OfficeEquipmentStore OutletStore PawnShop PetStore ShoeStore SportingGoodsStore TireShop ToyStore WholesaleStore ' +
'TelevisionStation TouristInformationCenter TravelAgency '
// Eine Adresse aus der Schnittstelle: nur https mit Host, ohne Leer- und Steuerzeichen (sonst ''). Es steht keine feste Webadresse im Skript,
// nur diese Pruefung des Schemas.
function sichererLink (url) {
if (typeof url !== 'string' || url.length > 2000 || /[\s\u0000-\u001f\u007f]/.test(url)) return ''
return /^https:\/\/[^\/?#]+/i.test(url) ? url : ''
}
// Knopf eines Hinweises { art: 'link'|'anruf', ziel, text } als { art, href, text } oder null. Links: nur https; Anruf: tel: nur ueber telefonLink.
function hinweisKnopf (knopf) {
if (!istObjekt(knopf)) return null
var href = knopf.art === 'link' ? sichererLink(knopf.ziel) : knopf.art === 'anruf' ? telefonLink(knopf.ziel) : ''
if (!href) return null
var text = typeof knopf.text === 'string' ? knopf.text.replace(/^\s+|\s+$/g, '') : ''
return { art: knopf.art, href: href, text: text || (knopf.art === 'anruf' ? 'Anrufen' : 'Mehr erfahren') }
}
// Knoepfe der Kontakt-Leiste aus { telefon, routeUrl, whatsappUrl }: [{ art: 'anruf'|'route'|'whatsapp', href, text }], nur mit brauchbaren Angaben.
// telefon = Ziffern mit Laendervorwahl ohne "+" (E.164), daraus tel:+... ueber telefonLink; Route und WhatsApp nur als https-Adresse.
function kontaktKnoepfe (kontakt) {
var liste = []
if (!istObjekt(kontakt)) return liste
var tel = typeof kontakt.telefon === 'string' && /^[1-9][0-9]{5,14}$/.test(kontakt.telefon) ? telefonLink('+' + kontakt.telefon) : ''
if (tel) liste.push({ art: 'anruf', href: tel, text: 'Anrufen' })
var route = sichererLink(kontakt.routeUrl)
if (route) liste.push({ art: 'route', href: route, text: 'Route' })
var wa = sichererLink(kontakt.whatsappUrl)
if (wa) liste.push({ art: 'whatsapp', href: wa, text: 'WhatsApp' })
return liste
}
// Hat die Seite schon eine eigene Anruf-Leiste? elemente = die festen/klebenden Vorfahren aller a[href^="tel:"] der Seite als
// { position, bottom (berechnet), oben, unten (Rand im Fenster), fenster (Fensterhoehe), sichtbar }. Zaehlt: sichtbar, fest (oder klebend mit
// bottom) und in der unteren Haelfte, hoechstens 160 px ueber dem unteren Rand (auch ein schwebender Anruf-Knopf). Kopfzeilen, bildschirmhohe
// Seitenleisten und Ausgeblendetes zaehlen nicht.
function istEigeneAnrufLeiste (elemente) {
if (!Array.isArray(elemente)) return false
for (var i = 0; i < elemente.length; i++) {
var e = elemente[i]
if (!e || e.sichtbar !== true || !istZahl(e.oben) || !istZahl(e.unten) || !istZahl(e.fenster)) continue
if (e.position !== 'fixed' && !(e.position === 'sticky' && e.bottom !== 'auto')) continue
if (e.oben >= e.fenster / 2 && e.oben < e.fenster && e.unten >= e.fenster - 160) return true
}
return false
}
function typVon (t) { return typeof t === 'string' ? t.replace(/^.*[\/:#]/, '') : '' }
// Ein Knoten ist ein Unternehmen vor Ort, wenn einer seiner @type (Text oder Liste, auch mit Praefix oder als Adresse) LocalBusiness oder ein Untertyp ist.
function istLokalesUnternehmen (knoten) {
var typen = Array.isArray(knoten['@type']) ? knoten['@type'] : [knoten['@type']]
for (var i = 0; i < typen.length; i++) {
var t = typVon(typen[i])
if (/^[A-Za-z]+$/.test(t) && LOKALE_TYPEN.indexOf(' ' + t + ' ') >= 0) return true
}
return false
}
// Alle Objekte eines geparsten JSON-LD-Blocks: er selbst (Objekt oder Liste von Objekten) und die Knoten seines @graph.
function sammleKnoten (wert, aus, tiefe) {
if (tiefe > 4) return
if (Array.isArray(wert)) {
for (var i = 0; i < wert.length; i++) sammleKnoten(wert[i], aus, tiefe + 1)
return
}
if (!istObjekt(wert)) return
aus.push(wert)
if (Array.isArray(wert['@graph'])) sammleKnoten(wert['@graph'], aus, tiefe + 1)
}
function kopie (v) { return JSON.parse(JSON.stringify(v)) }
// Zeiten fuer schema.org: bloecke = geparste application/ld+json-Inhalte der Seite (unparsbare als null), jsonld = { openingHoursSpecification,
// specialOpeningHoursSpecification, neuerBlock } der Schnittstelle, seite = { name, adresse, url }. In jedem Unternehmen vor Ort der Bloecke
// werden NUR openingHoursSpecification und specialOpeningHoursSpecification ersetzt (leere Listen entfernen das Feld); die Bloecke werden
// dabei veraendert, geaendert = ihre Indizes (nur wo sich etwas aendert). Gibt es kein Unternehmen vor Ort, ist neu der neuerBlock des Servers
// mit name, address (wenn bekannt) und url (ohne Namen: null, ein LocalBusiness ohne name taugt nicht). Ohne jsonld bleibt alles, wie es ist.
function jsonLdEinbauen (bloecke, jsonld, seite) {
var erg = { geaendert: [], neu: null }
if (!istObjekt(jsonld)) return erg
if (!Array.isArray(bloecke)) bloecke = []
var zeiten = Array.isArray(jsonld.openingHoursSpecification) ? jsonld.openingHoursSpecification : []
var sonder = Array.isArray(jsonld.specialOpeningHoursSpecification) ? jsonld.specialOpeningHoursSpecification : []
var gefunden = false
var i, k
function setze (knoten, schluessel, liste) {
if (liste.length) knoten[schluessel] = kopie(liste)
else delete knoten[schluessel]
}
for (i = 0; i < bloecke.length; i++) {
var knoten = []
sammleKnoten(bloecke[i], knoten, 0)
var geaendert = false
for (k = 0; k < knoten.length; k++) {
if (!istLokalesUnternehmen(knoten[k])) continue
gefunden = true
var vorher = JSON.stringify([knoten[k].openingHoursSpecification, knoten[k].specialOpeningHoursSpecification])
setze(knoten[k], 'openingHoursSpecification', zeiten)
setze(knoten[k], 'specialOpeningHoursSpecification', sonder)
if (JSON.stringify([knoten[k].openingHoursSpecification, knoten[k].specialOpeningHoursSpecification]) !== vorher) geaendert = true
}
if (geaendert) erg.geaendert.push(i)
}
var s = istObjekt(seite) ? seite : {}
var name = typeof s.name === 'string' ? s.name.replace(/^\s+|\s+$/g, '') : ''
if (!gefunden && name && istObjekt(jsonld.neuerBlock)) {
var neu = kopie(jsonld.neuerBlock)
neu.name = name
if (typeof s.adresse === 'string' && s.adresse.replace(/^\s+|\s+$/g, '')) neu.address = s.adresse.replace(/^\s+|\s+$/g, '')
if (typeof s.url === 'string' && s.url) neu.url = s.url
if (Array.isArray(neu.openingHoursSpecification) && !neu.openingHoursSpecification.length) delete neu.openingHoursSpecification
if (Array.isArray(neu.specialOpeningHoursSpecification) && !neu.specialOpeningHoursSpecification.length) delete neu.specialOpeningHoursSpecification
erg.neu = neu
}
return erg
}
// @rein-ende
// @telefon-anfang (reine Funktion, test/zeitenEmbedStatic.test.js fuehrt sie einzeln aus; dieselbe Regel wie in public/embed/termine.js; fuer Kontakt-Leiste und Hinweis-Knoepfe)
@@ -225,7 +378,7 @@
// ---------------------------------------------------------------- Zustand (nur im Speicher der Seite)
var daten = null // letzte gueltige Antwort { plaene, hinweise } oder null (aktiv: false, Fehler, noch kein Abruf)
var daten = null // letzte gueltige Antwort { plaene, hinweise, kontakt, jsonld, firma, team } oder null (aktiv: false, Fehler, noch kein Abruf)
var geladenMs = 0 // Date.now() beim letzten ERFOLGREICHEN Abruf (nicht body.stand: ein 304 liefert alte Daten)
var versatz = 0 // Serverzeit minus lokale Uhr, nur bei mehr als 5 Minuten Abweichung (sonst 0)
var versuchMs = 0 // Beginn des letzten Abrufs, ob erfolgreich oder nicht
@@ -234,6 +387,18 @@
var platzhalter = []
var siteKey = ''
var api = ''
var HANDY_MQ = '(max-width: 768px)' // Kontakt-Leiste nur auf schmalen Schirmen
var hinweisLeiste = null // { host, sig, nav, hoehe, fest } der Hinweis-Leiste auf der Seite (null: keine)
var kontaktLeiste = null // { host, abstand, sig } der Kontakt-Leiste samt Abstandhalter (null: keine)
var SCROLL_PADDING = { top: 'scroll-padding-top', bottom: 'scroll-padding-bottom' }
var scrollPadding = { top: null, bottom: null } // was die Seite vor uns in scroll-padding-top/-bottom hatte: { wert, prio, basis }
var handyAbfrage = null
var hoererAn = false
var beobachter = null
var scrollWartet = false
var groesseWartet = false
var ldSig = ''
var ldEigenes = null // der von uns angelegte JSON-LD-Block (nur wenn die Seite keinen eigenen hat)
function timer (fn, ms) {
var d = Number(ms)
@@ -283,7 +448,14 @@
daten = null
return
}
daten = { plaene: body.plaene.filter(planGueltig), hinweise: Array.isArray(body.hinweise) ? body.hinweise : [] }
daten = {
plaene: body.plaene.filter(planGueltig),
hinweise: Array.isArray(body.hinweise) ? body.hinweise : [],
kontakt: istObjekt(body.kontakt) ? body.kontakt : null,
jsonld: istObjekt(body.jsonld) ? body.jsonld : null,
firma: istObjekt(body.firma) ? body.firma : {},
team: istObjekt(body.team) ? body.team : {}
}
}
function lade () {
@@ -302,7 +474,14 @@
laeuft = false
try { render() } finally { plane() }
}
fetch(api + API_PFAD + encodeURIComponent(siteKey), opts).then(function (r) {
var anfrage
try {
anfrage = fetch(api + API_PFAD + encodeURIComponent(siteKey), opts)
} catch (e) {
fertig() // ein sofort werfender fetch beendet den Abruf wie ein Fehler (laeuft bleibt nie haengen)
return
}
anfrage.then(function (r) {
if (!r.ok) throw new Error('Status ' + r.status)
var kopf = r.headers && typeof r.headers.get === 'function' ? r.headers.get('Date') : null
return r.json().then(function (body) { uebernimm(body, kopf) })
@@ -446,13 +625,380 @@
else zeigeZustand(p, 'wkz-badge', eintrag.kurz, eintrag)
}
// Ein Fehler in einem Teil (Platzhalter, Hinweis-Leiste, Kontakt-Leiste, JSON-LD) darf die anderen nicht aufhalten
function sicher (fn) {
try { fn() } catch (e) { warne('Fehler beim Zeichnen: ' + (e && e.message ? e.message : e)) }
}
function fuelleSicher (p, d, t) { sicher(function () { fuelle(p, d, t) }) }
function render () {
sammle()
platzhalter = platzhalter.filter(function (p) { return p.root.isConnected !== false })
var d = frisch() ? daten : null
var t = jetzt()
for (var i = 0; i < platzhalter.length; i++) fuelle(platzhalter[i], d, t)
// Task 26: Hinweis-Leiste, Kontakt-Leiste und JSON-LD werden hier mit denselben Daten (d) gezeichnet
for (var i = 0; i < platzhalter.length; i++) fuelleSicher(platzhalter[i], d, t)
zeichneLeisten(d, t)
sicher(function () { zeichneJsonLd(d) })
}
// ---------------------------------------------------------------- Leisten (Shadow DOM, vom Skript gezeichnet)
var LEISTE_SCHRIFT = 'system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'
function hinweisCss () {
var teile = [
':host{all:initial;display:block}',
'.wkz-leiste{box-sizing:border-box;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:4px 16px;margin:0;padding:8px 16px;font:500 15px/1.4 ' + LEISTE_SCHRIFT + ';text-align:center;background:var(--bg);color:var(--fg);animation:wkz-ein .3s ease-out}',
'.wkz-text{margin:0;min-width:0;overflow-wrap:anywhere}',
'.wkz-knopf{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px;padding:0 18px;border-radius:999px;background:var(--fg);color:var(--bg);font:inherit;font-weight:700;text-decoration:none}',
'.wkz-knopf:focus-visible{outline:3px solid var(--fg);outline-offset:3px}',
'@keyframes wkz-ein{from{opacity:0;transform:translateY(-100%)}to{opacity:1;transform:none}}',
'@media (prefers-reduced-motion:reduce){.wkz-leiste{animation:none}}'
]
for (var art in HINWEIS_FARBEN) {
if (Object.prototype.hasOwnProperty.call(HINWEIS_FARBEN, art)) teile.push('.wkz-' + art + '{--bg:' + HINWEIS_FARBEN[art].bg + ';--fg:' + HINWEIS_FARBEN[art].fg + '}')
}
return teile.join('\n')
}
function kontaktCss () {
return [
':host{all:initial;display:block}',
'.wkz-leiste{box-sizing:border-box;display:flex;gap:8px;margin:0;padding:8px 12px calc(8px + env(safe-area-inset-bottom,0px));background:#fff;border-top:1px solid #d1d5db;box-shadow:0 -2px 10px rgba(0,0,0,.15);font:600 16px/1.2 ' + LEISTE_SCHRIFT + '}',
'.wkz-knopf{box-sizing:border-box;flex:1 1 0;min-width:44px;min-height:48px;display:flex;align-items:center;justify-content:center;padding:0 8px;border:2px solid #111827;border-radius:10px;background:#fff;color:#111827;font:inherit;text-decoration:none;text-align:center}',
'.wkz-anruf{background:#111827;color:#fff}',
'.wkz-knopf:focus-visible{outline:3px solid #2563eb;outline-offset:2px}'
].join('\n')
}
// Host mit Shadow DOM und eigenem Stil (null, wo der Browser kein Shadow DOM kann: dann gibt es keine Leiste)
function neueLeiste (art, css) {
var host = document.createElement('div')
if (typeof host.attachShadow !== 'function') return null
host.setAttribute('data-wkz-leiste', art)
var wurzel = host.attachShadow({ mode: 'open' })
var st = document.createElement('style')
st.textContent = css
wurzel.appendChild(st)
return { host: host, wurzel: wurzel }
}
// Link aus einem { href, text } der reinen Funktionen (href ist dort schon geprueft); nie ueber HTML-Text
function linkKnopf (k, klasse, neuerTab) {
var a = el('a', klasse, k.text)
a.setAttribute('href', k.href)
a.setAttribute('rel', 'noopener noreferrer')
if (neuerTab) a.setAttribute('target', '_blank')
return a
}
function naechstesBild (fn) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fn)
else fn()
}
function pixel (wert) { return /^-?\d+(\.\d+)?px$/.test(wert) ? parseFloat(wert) : 0 }
// scroll-padding-top/-bottom des Dokuments um hoehePx erhoehen (der Wert der Seite wird gemerkt und beim Entfernen zurueckgesetzt)
function erhoeheScrollPadding (seite, hoehePx) {
var prop = SCROLL_PADDING[seite]
var st = document.documentElement.style
if (!scrollPadding[seite]) {
scrollPadding[seite] = { wert: st.getPropertyValue(prop), prio: st.getPropertyPriority(prop), basis: pixel(getComputedStyle(document.documentElement).getPropertyValue(prop)) }
}
st.setProperty(prop, (scrollPadding[seite].basis + hoehePx) + 'px', 'important')
}
function stelleScrollPaddingWieder (seite) {
var merk = scrollPadding[seite]
if (!merk) return
var prop = SCROLL_PADDING[seite]
var st = document.documentElement.style
if (merk.wert) st.setProperty(prop, merk.wert, merk.prio)
else st.removeProperty(prop)
scrollPadding[seite] = null
}
function istFest (cs) { return cs.position === 'fixed' || cs.position === 'sticky' }
function merke (e, cs) {
var top = parseFloat(cs.top)
return { el: e, wert: e.style.getPropertyValue('top'), prio: e.style.getPropertyPriority('top'), basis: top === top ? top : 0 }
}
// Feste und klebende Elemente, die die Leiste verdecken wuerde: die des Team-Selektors nav (jedes feste/klebende, auch mit anderem top) und
// alle mit top: 0, sichtbar und hoechstens halb so hoch wie das Fenster (kein Menue-Panel, kein Overlay). Ein festes Element, das schon
// ueber der Leiste endet (die Seite hat ihren Kopf per Polster oder Rand von body freigehalten, die Leiste steht erst darunter), bleibt,
// wo es ist: barOben ist der Abstand der Leiste vom Seitenanfang. nav kann ungueltig sein: nur in try/catch.
function sucheFeste (nav, barOben) {
var liste = []
var bekannt = []
var i, e, cs
if (nav) {
var treffer = []
try { treffer = document.querySelectorAll(nav) } catch (fehler) { treffer = [] }
for (i = 0; i < treffer.length; i++) {
cs = getComputedStyle(treffer[i])
if (!istFest(cs)) continue
liste.push(merke(treffer[i], cs))
bekannt.push(treffer[i])
}
}
var alle = document.body.getElementsByTagName('*')
var hoeheFenster = window.innerHeight || 0
for (i = 0; i < alle.length && i < 4000; i++) {
e = alle[i]
if (bekannt.indexOf(e) >= 0 || e.hasAttribute('data-wkz-leiste')) continue
cs = getComputedStyle(e)
if (!istFest(cs) || parseFloat(cs.top) !== 0 || cs.display === 'none') continue
var r = e.getBoundingClientRect()
if (r.height <= 0 || r.height > hoeheFenster * 0.5) continue
if (cs.position === 'fixed' && r.bottom <= barOben + 0.5) continue
liste.push(merke(e, cs))
}
return liste
}
// Feste Elemente um max(0, Leistenhoehe - Scrollposition) nach unten ruecken (Inline-top, hat Vorrang vor den Regeln der Seite)
function verschiebe (l) {
var y = window.pageYOffset || document.documentElement.scrollTop || 0
var s = Math.max(0, l.hoehe - Math.max(0, y))
for (var i = 0; i < l.fest.length; i++) l.fest[i].el.style.setProperty('top', (l.fest[i].basis + s) + 'px', 'important')
}
function setzeFesteZurueck (l) {
for (var i = 0; i < l.fest.length; i++) {
var f = l.fest[i]
if (f.wert) f.el.style.setProperty('top', f.wert, f.prio)
else f.el.style.removeProperty('top')
}
l.fest = []
}
// Hoehe der Hinweis-Leiste neu messen, feste Elemente neu suchen (erst die alten zuruecksetzen: berechnet wird immer der Stand der Seite) und ruecken
function misstHinweis () {
var l = hinweisLeiste
if (!l) return
setzeFesteZurueck(l)
var r = l.host.getBoundingClientRect()
l.hoehe = r.height
l.fest = sucheFeste(l.nav, r.top + (window.pageYOffset || document.documentElement.scrollTop || 0))
erhoeheScrollPadding('top', l.hoehe)
verschiebe(l)
}
function misstKontakt () {
var l = kontaktLeiste
if (!l) return
var h = l.host.getBoundingClientRect().height
l.abstand.style.cssText = 'display:block;width:100%;height:' + h + 'px;margin:0;padding:0;border:0;visibility:hidden'
erhoeheScrollPadding('bottom', h)
}
function beiScroll () {
if (!hinweisLeiste || scrollWartet) return
scrollWartet = true
naechstesBild(function () {
scrollWartet = false
if (hinweisLeiste) verschiebe(hinweisLeiste)
})
}
// Fenster oder Leiste aendern ihre Groesse: hoechstens alle 120 ms neu messen und suchen (beim Ziehen am Fenster nicht in jedem Bild)
function beiGroesse () {
if (groesseWartet) return
groesseWartet = true
timer(function () {
groesseWartet = false
sicher(misstHinweis)
sicher(misstKontakt)
}, 120)
}
// Scroll (nur gemerkt, ein Bild spaeter gerueckt) und Groesse (Fenster oder Leiste) werden einmal beobachtet, sobald es eine Leiste gibt
function sorgeFuerHoerer () {
if (hoererAn) return
hoererAn = true
window.addEventListener('scroll', beiScroll, { passive: true })
window.addEventListener('resize', beiGroesse)
if (typeof ResizeObserver === 'function') beobachter = new ResizeObserver(beiGroesse)
}
function entferneLeiste (host) {
if (beobachter) beobachter.unobserve(host)
if (host.parentNode) host.parentNode.removeChild(host)
}
function zeigeHinweisLeiste (h, nav) {
var art = Object.prototype.hasOwnProperty.call(HINWEIS_FARBEN, h.art) ? h.art : 'info'
var knopf = hinweisKnopf(h.knopf)
var sig = JSON.stringify([art, h.text, knopf, nav])
var l = hinweisLeiste
var body = document.body
if (l && l.sig === sig && l.host.parentNode === body) {
// unveraendert: nur dafuer sorgen, dass sie erstes Kind von body bleibt (die Seite kann danach etwas davorgesetzt haben)
if (body.firstChild !== l.host) {
body.insertBefore(l.host, body.firstChild)
misstHinweis()
}
return
}
entferneHinweisLeiste()
var teile = neueLeiste('hinweis', hinweisCss())
if (!teile) return
var leiste = el('div', 'wkz-leiste wkz-' + art)
leiste.setAttribute('role', 'region')
leiste.setAttribute('aria-label', 'Hinweis')
leiste.appendChild(el('p', 'wkz-text', h.text))
if (knopf) leiste.appendChild(linkKnopf(knopf, 'wkz-knopf', false))
teile.wurzel.appendChild(leiste)
body.insertBefore(teile.host, body.firstChild)
hinweisLeiste = { host: teile.host, sig: sig, nav: nav, hoehe: 0, fest: [] }
sorgeFuerHoerer()
if (beobachter) beobachter.observe(teile.host)
misstHinweis()
}
function entferneHinweisLeiste () {
var l = hinweisLeiste
if (!l) return
hinweisLeiste = null
setzeFesteZurueck(l)
stelleScrollPaddingWieder('top')
entferneLeiste(l.host)
}
function zeigeKontaktLeiste (knoepfe) {
var sig = JSON.stringify(knoepfe)
var l = kontaktLeiste
var body = document.body
if (l && l.sig === sig && l.host.parentNode === body && l.abstand.parentNode === body) {
if (l.abstand.nextSibling) body.appendChild(l.abstand) // der Abstandhalter bleibt das Letzte in body
return
}
entferneKontaktLeiste()
var teile = neueLeiste('kontakt', kontaktCss())
if (!teile) return
var leiste = el('div', 'wkz-leiste')
leiste.setAttribute('role', 'region')
leiste.setAttribute('aria-label', 'Kontakt')
for (var i = 0; i < knoepfe.length; i++) leiste.appendChild(linkKnopf(knoepfe[i], 'wkz-knopf wkz-' + knoepfe[i].art, knoepfe[i].art !== 'anruf'))
teile.wurzel.appendChild(leiste)
teile.host.style.cssText = 'position:fixed;left:0;right:0;bottom:0;z-index:40;display:block;margin:0;padding:0;border:0'
var abstand = document.createElement('div')
abstand.setAttribute('data-wkz-abstand', '')
abstand.setAttribute('aria-hidden', 'true')
body.appendChild(teile.host)
body.appendChild(abstand)
kontaktLeiste = { host: teile.host, abstand: abstand, sig: sig }
sorgeFuerHoerer()
if (beobachter) beobachter.observe(teile.host)
misstKontakt()
}
function entferneKontaktLeiste () {
var l = kontaktLeiste
if (!l) return
kontaktLeiste = null
stelleScrollPaddingWieder('bottom')
entferneLeiste(l.host)
if (l.abstand.parentNode) l.abstand.parentNode.removeChild(l.abstand)
}
function beiHandyWechsel () { sicher(render) }
function istHandy () {
if (typeof window.matchMedia !== 'function') return false
if (!handyAbfrage) {
handyAbfrage = window.matchMedia(HANDY_MQ)
if (typeof handyAbfrage.addEventListener === 'function') handyAbfrage.addEventListener('change', beiHandyWechsel)
else if (typeof handyAbfrage.addListener === 'function') handyAbfrage.addListener(beiHandyWechsel)
}
return handyAbfrage.matches
}
// Die festen/klebenden Vorfahren aller sichtbaren tel:-Links, als Beschreibung fuer istEigeneAnrufLeiste
function eigeneAnrufLeiste () {
var links = document.querySelectorAll('a[href^="tel:"]')
var elemente = []
var fenster = window.innerHeight || 0
for (var i = 0; i < links.length && i < 50; i++) {
if (!links[i].getClientRects().length) continue // ausgeblendet
for (var n = links[i]; n && n !== document.documentElement; n = n.parentElement) {
var cs = getComputedStyle(n)
if (cs.position !== 'fixed' && cs.position !== 'sticky') continue
var r = n.getBoundingClientRect()
elemente.push({ position: cs.position, bottom: cs.bottom, oben: r.top, unten: r.bottom, fenster: fenster, sichtbar: cs.display !== 'none' && cs.visibility !== 'hidden' && r.width > 0 && r.height > 0 })
break
}
}
return istEigeneAnrufLeiste(elemente)
}
// Beide Leisten aus denselben Daten (d = null: Fehler, aktiv: false oder Daten zu alt: keine Leiste). Hinweis: der gueltige mit dem hoechsten
// Rang, nicht bei team.hinweisleisteAus. Kontakt: nur mit Daten, auf schmalen Schirmen, nicht bei team.kontaktleisteAus und nicht neben einer
// eigenen Anruf-Leiste der Seite.
function zeichneLeisten (d, t) {
var team = d && istObjekt(d.team) ? d.team : {}
sicher(function () {
var h = d && team.hinweisleisteAus !== true && document.body ? waehleHinweis(d.hinweise, t) : null
if (h && typeof h.text === 'string' && h.text !== '') zeigeHinweisLeiste(h, typeof team.navSelektor === 'string' ? team.navSelektor : '')
else entferneHinweisLeiste()
})
sicher(function () {
var knoepfe = d && team.kontaktleisteAus !== true && document.body ? kontaktKnoepfe(d.kontakt) : []
if (knoepfe.length && istHandy() && !eigeneAnrufLeiste()) zeigeKontaktLeiste(knoepfe)
else entferneKontaktLeiste()
})
}
// ---------------------------------------------------------------- JSON-LD
function ldSkripte () {
var alle = document.getElementsByTagName('script')
var liste = []
for (var i = 0; i < alle.length; i++) {
if (alle[i] !== ldEigenes && String(alle[i].getAttribute('type') || '').toLowerCase().replace(/\s+/g, '') === 'application/ld+json') liste.push(alle[i])
}
return liste
}
function seitenUrl () {
var o = window.location && window.location.origin
return typeof o === 'string' && o !== '' && o !== 'null' ? o + '/' : ''
}
// Zeiten ins JSON-LD der Seite: vorhandene Unternehmens-Bloecke bekommen nur die beiden Zeiten-Felder neu (Text per JSON.stringify), sonst legt das
// Skript einen eigenen Block an. Ohne jsonld in der Antwort (noch keine Zeiten gespeichert) bleibt die Seite voellig unberuehrt.
function zeichneJsonLd (d) {
var jd = d && istObjekt(d.jsonld) ? d.jsonld : null
if (!jd) return
var firma = istObjekt(d.firma) ? d.firma : {}
var seite = { name: firma.name, adresse: firma.adresse, url: seitenUrl() }
var sig = JSON.stringify([jd, seite])
if (sig === ldSig) return
var skripte = ldSkripte()
var bloecke = []
var i
for (i = 0; i < skripte.length; i++) {
try { bloecke.push(JSON.parse(skripte[i].textContent)) } catch (e) { bloecke.push(null) }
}
var erg = jsonLdEinbauen(bloecke, jd, seite)
for (i = 0; i < erg.geaendert.length; i++) skripte[erg.geaendert[i]].textContent = JSON.stringify(bloecke[erg.geaendert[i]])
if (erg.neu) {
if (!ldEigenes) {
ldEigenes = document.createElement('script')
ldEigenes.setAttribute('type', 'application/ld+json')
ldEigenes.setAttribute('data-wkz-jsonld', '')
;(document.head || document.documentElement).appendChild(ldEigenes)
}
ldEigenes.textContent = JSON.stringify(erg.neu)
} else if (ldEigenes) {
if (ldEigenes.parentNode) ldEigenes.parentNode.removeChild(ldEigenes)
ldEigenes = null
}
ldSig = sig
}
// ---------------------------------------------------------------- Timer
@@ -477,8 +1023,7 @@
lade() // zeichnet und plant danach neu
return
}
render()
plane()
try { render() } finally { plane() }
}
// ---------------------------------------------------------------- Start
@@ -514,7 +1059,13 @@
return
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') tick()
if (document.visibilityState !== 'visible') return
// Zeit ist vergangen: ist ein Neuladen faellig, erst mit den alten Daten zeichnen (Hinweis, Status und die 24-Stunden-Grenze stimmen sofort), dann laden
if (!laeuft && Date.now() - versuchMs >= RELOAD_MS) {
try { render() } finally { lade() }
return
}
tick()
})
lade()
}
+532
View File
@@ -0,0 +1,532 @@
// Playwright-Checks des Website-Skripts public/embed/zeiten.js (Oeffnungszeiten live, Aufgabe 26: Hinweis-Leiste, Kontakt-Leiste,
// JSON-LD, Platzhalter) gegen den UI-Testserver (test/ui/zeitenTestServer.mjs): echte oeffentliche Schnittstelle, Nachbau von Appwrite.
// Nicht Teil von npm test.
// Aufruf: free -m && nice node test/ui/zeiten-embed.ui.mjs (es laeuft nur ein Browser)
// Screenshots: /root/reports/oeffnungszeiten-ui/embed-*.png (bei Fehlern fehler-embed-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
//
// Seiten: Sie laufen auf srv.pageOrigin (http://localhost:PORT) und sprechen die Schnittstelle unter srv.url (http://127.0.0.1:PORT)
// an: echte Cross-Origin-Anfragen wie auf Kundenseiten. Server-Uhr: Montag 2026-10-05 10:00 Berliner Zeit (fest, srv.setzeUhr);
// Pruefungen mit Zeitsprung installieren dieselbe Zeit im Browser (context.clock.install) und spulen mit fastForward vor.
import { mkdir, readdir, rm } from 'node:fs/promises'
import path from 'node:path'
import { startZeitenUiServer, zeitenEingabe, hinweis, FIRMA, ADRESSE } from './zeitenTestServer.mjs'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const { chromium } = await import(PW)
const SHOTS = '/root/reports/oeffnungszeiten-ui'
await mkdir(SHOTS, { recursive: true })
for (const datei of await readdir(SHOTS)) if (/^(embed|fehler-embed)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
const srv = await startZeitenUiServer()
const KEY = srv.SITE_KEY
const API = `${srv.url}/api/public/zeiten/v1/${KEY}`
// ---------------------------------------------------------------- Helfer
function assert(cond, msg) { if (!cond) throw new Error(msg) }
function eq(ist, soll, msg) {
const a = JSON.stringify(ist)
const b = JSON.stringify(soll)
if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`)
}
const nahe = (a, b, tol = 1) => Math.abs(a - b) <= tol
const fmt = (z) => (typeof z === 'number' ? z.toFixed(1) : String(z))
const SKRIPT = `<script src="/embed/zeiten.js" data-site="${KEY}" data-api="${srv.url}" defer></script>`
const STATUS = '<p>Status: <span id="status" data-webklar-zeiten data-modus="status">Ersatz-Status</span></p>'
const lang = (n = 40) => Array.from({ length: n }, (_, i) => `<p>Absatz ${i + 1}: Brot, Brötchen und Kuchen aus eigener Backstube.</p>`).join('\n')
/** Testseite: kopf = <head>-Zusatz, stil = CSS, anfang = Anfang von <body> (z. B. Kopfzeile), inhalt = <main>. */
function seitenHtml({ kopf = '', stil = '', anfang = '', inhalt = `${STATUS}${lang()}<p id="ende">Ende der Seite</p>`, skript = SKRIPT } = {}) {
return `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Bäckerei Sonnenschein</title>
<style>body{margin:0;font-family:system-ui,sans-serif;background:#faf7f2;color:#1f2937}main{max-width:760px;margin:0 auto;padding:16px}${stil}</style>${kopf}</head>
<body>${anfang}<main>${inhalt}</main>${skript}</body></html>`
}
const KOPF_FEST = '<header id="kopf" style="position:fixed;top:0;left:0;right:0;height:60px;background:#222;color:#fff;z-index:100;padding:0 16px;line-height:60px">Bäckerei Sonnenschein</header>'
const KOPF_KLEBT = '<header id="kopf" style="position:sticky;top:0;height:60px;background:#222;color:#fff;z-index:100;padding:0 16px;line-height:60px">Bäckerei Sonnenschein</header>'
const HOST = (art) => `[data-wkz-leiste="${art}"]`
const anzahl = (page, sel) => page.locator(sel).count()
const hoehe = (page, sel) => page.evaluate((s) => document.querySelector(s).getBoundingClientRect().height, sel)
const oben = (page, sel) => page.evaluate((s) => document.querySelector(s).getBoundingClientRect().top, sel)
const scrolle = (page, y) => page.evaluate((v) => window.scrollTo(0, v), y)
const shot = async (page, name) => {
await page.waitForTimeout(450) // die Einblendung der Leiste (0,3 s) ist fertig
await page.screenshot({ path: `${SHOTS}/embed-${name}.png` })
}
/** Wartet, bis das Skript die Platzhalter gefuellt hat (und damit in derselben Runde die Leisten gezeichnet). */
const warteBereit = (page) => page.waitForFunction(() => { const s = document.getElementById('status'); return s && !s.textContent.includes('Ersatz') })
/** Wartet auf die Antwort der Schnittstelle und ein paar Bilder danach (fuer Seiten, auf denen nichts gefuellt wird). */
async function warteAufSchnittstelle(page, aktion) {
const antwort = page.waitForResponse((r) => r.url().includes('/api/public/zeiten/v1/'))
await aktion()
await antwort
await page.waitForTimeout(400)
}
const STATUS_OFFEN = 'Jetzt geöffnet · schließt um 18 Uhr'
const STATUS_ZU = 'Geschlossen · öffnet morgen um 9 Uhr'
const statusText = (page) => page.locator('#status').innerText()
const results = []
let browser = null
const hosts = new Set()
async function check(name, fn, { eingabe, team, viewport = { width: 1280, height: 800 }, uhr, context = {}, initScript } = {}) {
await srv.zuruecksetzen({ eingabe, team })
if (uhr) srv.setzeUhr(uhr)
const ctx = await browser.newContext({ viewport, ...context })
if (uhr) await ctx.clock.install({ time: new Date(uhr) })
const page = await ctx.newPage()
page.setDefaultTimeout(8000)
const fehler = []
page.on('pageerror', (e) => fehler.push(e.message))
page.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
if (initScript) await page.addInitScript(initScript)
try {
await fn(page, ctx)
assert(fehler.length === 0, `JavaScript-Fehler auf der Seite: ${fehler.join(' | ')}`)
results.push([name, true])
console.log(`ok ${name}`)
} catch (e) {
results.push([name, false])
console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
await page.screenshot({ path: `${SHOTS}/fehler-embed-${name}.png`, fullPage: true }).catch(() => {})
} finally {
await ctx.close()
}
}
const INFO = hinweis('info', 'Heute frische Brezeln aus dem Ofen.')
const HANDY = { width: 390, height: 844 }
// ---------------------------------------------------------------- Pruefungen
async function pruefungen() {
await check('hinweisleiste_zeigt_gueltigen_hinweis', async (page) => {
await page.goto(srv.seite('hinweis.html', seitenHtml()))
await warteBereit(page)
const host = page.locator(HOST('hinweis'))
await host.waitFor({ state: 'attached' })
const region = page.getByRole('region', { name: 'Hinweis' })
assert(await region.isVisible(), 'Region "Hinweis" nicht sichtbar')
assert((await region.innerText()).includes('Heute frische Brezeln aus dem Ofen.'), 'Text fehlt')
eq(await region.locator('button, [role=button]').count(), 0, 'Schliessen-Knopf vorhanden')
assert(!(await region.innerText()).includes('Laden schließt'), 'der Hinweis von morgen gilt noch nicht')
const farben = await region.evaluate((e) => { const c = getComputedStyle(e); return [c.backgroundColor, c.color] })
eq(farben, ['rgb(224, 242, 254)', 'rgb(12, 74, 110)'], 'Farben der Art Info')
// erstes Kind von body, im Fluss (schiebt den Inhalt)
assert(await page.evaluate(() => document.body.firstElementChild.hasAttribute('data-wkz-leiste')), 'Host ist nicht das erste Kind von body')
assert(await page.evaluate(() => ['static', 'relative'].includes(getComputedStyle(document.querySelector('[data-wkz-leiste="hinweis"]')).position)), 'Host muss im Fluss stehen')
const h = await hoehe(page, HOST('hinweis'))
assert(h > 20, `Hoehe der Leiste ${fmt(h)}`)
assert(nahe(await oben(page, 'main'), h, 1.5), `main steht bei ${fmt(await oben(page, 'main'))} statt unter der Leiste (${fmt(h)})`)
await shot(page, 'hinweis-desktop')
}, { eingabe: zeitenEingabe({ hinweise: [INFO, hinweis('wichtig', 'Nur heute: Laden schließt um 14 Uhr.', { von: '2026-10-06', bis: '2026-10-06' })] }) })
await check('hinweis_wichtig_mit_knopf', async (page) => {
await page.goto(srv.seite('hinweis-wichtig.html', seitenHtml()))
await warteBereit(page)
const region = page.getByRole('region', { name: 'Hinweis' })
await region.waitFor()
assert((await region.innerText()).includes('Heute Betriebsfeier'), 'der Hinweis mit dem hoechsten Rang gewinnt')
const farben = await region.evaluate((e) => { const c = getComputedStyle(e); return [c.backgroundColor, c.color] })
eq(farben, ['rgb(185, 28, 28)', 'rgb(255, 255, 255)'], 'Farben der Art Wichtig')
const knopf = region.getByRole('link', { name: 'Zur Aktion' })
eq(await knopf.getAttribute('href'), 'https://baeckerei.example/aktion?x=1', 'href')
eq(await knopf.getAttribute('rel'), 'noopener noreferrer', 'rel')
const box = await knopf.boundingBox()
assert(box.height >= 44 && box.width >= 44, `Tippflaeche ${box.width}x${box.height}`)
// sichtbarer Fokus
await knopf.focus()
const fokus = await knopf.evaluate((e) => getComputedStyle(e).outlineStyle)
assert(fokus !== 'none', 'kein sichtbarer Fokus')
await shot(page, 'hinweis-wichtig')
}, { eingabe: zeitenEingabe({ hinweise: [INFO, hinweis('wichtig', 'Heute Betriebsfeier: wir schließen um 14 Uhr.', { knopf: { art: 'link', ziel: 'https://baeckerei.example/aktion?x=1', text: 'Zur Aktion' } })] }) })
await check('hinweisleiste_aus', async (page) => {
await page.goto(srv.seite('hinweis-aus.html', seitenHtml({ anfang: KOPF_FEST })))
await warteBereit(page) // das Skript hat gelaufen und die Platzhalter gefuellt ...
eq(await anzahl(page, HOST('hinweis')), 0, 'Leiste trotz Team-Schalter')
// ... und nichts an der Seite veraendert
eq(await page.evaluate(() => document.getElementById('kopf').style.top), '0px', 'Kopf angefasst')
eq(await page.evaluate(() => document.documentElement.style.scrollPaddingTop), '', 'scroll-padding-top angefasst')
assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf nicht bei 0')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { hinweisleisteAus: true } })
await check('fester_header_rueckt', async (page) => {
await page.goto(srv.seite('fest.html', seitenHtml({ anfang: KOPF_FEST, inhalt: `<div style="height:60px"></div>${STATUS}${lang()}` })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '#kopf'), h, 1), `Kopf steht bei ${fmt(await oben(page, '#kopf'))}, die Leiste ist ${fmt(h)} hoch`)
eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), 'important', 'Prioritaet des Inline-top')
// scroll-padding-top um die Hoehe erhoeht
const sp = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0)
assert(nahe(sp, h, 1), `scroll-padding-top ${fmt(sp)} statt ${fmt(h)}`)
// halb gescrollt: der Kopf folgt dem unteren Rand der Leiste
const halb = Math.round(h / 2)
await scrolle(page, halb)
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h - halb])
// weiter gescrollt: wieder bei 0
await scrolle(page, 400)
await page.waitForFunction(() => Math.abs(document.getElementById('kopf').getBoundingClientRect().top) < 0.5)
// zurueck nach oben: wieder unter der Leiste
await scrolle(page, 0)
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h])
await shot(page, 'fester-header')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('fester_header_mit_polster_bleibt', async (page) => {
// Die Seite haelt Platz fuer ihren festen Kopf per body-Polster frei: die Leiste steht erst darunter, der Kopf muss bleiben, wo er ist
await page.goto(srv.seite('polster.html', seitenHtml({ anfang: KOPF_FEST, stil: 'body{padding-top:60px}' })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
assert(nahe(await oben(page, HOST('hinweis')), 60, 1), `Leiste steht bei ${fmt(await oben(page, HOST('hinweis')))} statt unter dem Polster`)
assert(nahe(await oben(page, '#kopf'), 0, 0.5), `der Kopf wurde gerueckt (${fmt(await oben(page, '#kopf'))})`)
eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), '', 'Inline-top des Kopfes angefasst')
await shot(page, 'fester-header-polster')
await scrolle(page, 200)
await page.waitForTimeout(100)
assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf nach dem Scrollen nicht bei 0')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('klebender_header', async (page) => {
await page.goto(srv.seite('klebt.html', seitenHtml({ anfang: KOPF_KLEBT })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '#kopf'), h, 1), `klebender Kopf steht bei ${fmt(await oben(page, '#kopf'))}, die Leiste ist ${fmt(h)} hoch`)
const halb = Math.round(h / 2)
await scrolle(page, halb)
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h - halb])
await scrolle(page, 500)
await page.waitForFunction(() => Math.abs(document.getElementById('kopf').getBoundingClientRect().top) < 0.5)
await scrolle(page, 0)
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h])
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('team_navselektor', async (page) => {
// der Schwebende hat top: 10px (nicht top: 0): nur der Selektor des Teams findet ihn
const seite = seitenHtml({
stil: '.schweber{position:fixed;top:10px;left:16px;z-index:100;background:#222;color:#fff;padding:8px 12px;border-radius:8px}',
anfang: '<nav class="schweber">Menü</nav>',
})
await page.goto(srv.seite('nav.html', seite))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '.schweber'), 10 + h, 1), `Schweber steht bei ${fmt(await oben(page, '.schweber'))} statt ${fmt(10 + h)}`)
await scrolle(page, 400)
await page.waitForFunction(() => Math.abs(document.querySelector('.schweber').getBoundingClientRect().top - 10) < 0.5)
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { navSelektor: '.schweber' } })
await check('ungueltiger_selektor', async (page) => {
await page.goto(srv.seite('selektor.html', seitenHtml({ anfang: KOPF_FEST })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // die Leiste ist trotzdem da
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '#kopf'), h, 1), 'der feste Kopf wird auch ohne gueltigen Selektor erkannt und gerueckt')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { navSelektor: '[[kaputt' } })
await check('hinweis_endet_und_stellt_wieder_her', async (page, ctx) => {
await page.goto(srv.seite('ende.html', seitenHtml({ anfang: KOPF_FEST })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '#kopf'), h, 1), 'Kopf unter der Leiste')
// 23:55 Berlin: der Hinweis gilt bis 24:00 und verschwindet dann von selbst
await ctx.clock.fastForward(6 * 60 * 1000)
await page.locator(HOST('hinweis')).waitFor({ state: 'detached' })
eq(await page.evaluate(() => document.getElementById('kopf').style.top), '0px', 'Inline-top des Kopfes nicht zurueckgesetzt')
eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), '', 'Prioritaet nicht zurueckgesetzt')
eq(await page.evaluate(() => document.documentElement.style.scrollPaddingTop), '', 'scroll-padding-top nicht zurueckgesetzt')
assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf wieder bei 0')
assert(nahe(await oben(page, 'main'), 0, 0.5), 'Inhalt rueckt wieder nach oben')
}, { eingabe: zeitenEingabe({ hinweise: [hinweis('info', 'Nur heute: Kuchen zum halben Preis.', { von: '2026-10-05', bis: '2026-10-05' })] }), uhr: '2026-10-05T21:55:00.000Z' })
await check('kontaktleiste_nur_handy', async (page) => {
await page.goto(srv.seite('handy.html', seitenHtml()))
await warteBereit(page)
const host = page.locator(HOST('kontakt'))
await host.waitFor({ state: 'attached' })
const stil = await host.evaluate((e) => { const c = getComputedStyle(e); return [c.position, c.zIndex, c.bottom] })
eq(stil, ['fixed', '40', '0px'], 'position, z-index, bottom der Kontakt-Leiste')
const knoepfe = host.locator('a')
eq(await knoepfe.count(), 3, 'Anzahl Knoepfe')
eq(await knoepfe.allInnerTexts(), ['Anrufen', 'Route', 'WhatsApp'], 'Beschriftungen')
const hrefs = await knoepfe.evaluateAll((l) => l.map((a) => a.getAttribute('href')))
eq(hrefs[0], 'tel:+491711234567', 'tel:')
assert(hrefs[1].startsWith('https://www.google.com/maps/dir/'), `Route: ${hrefs[1]}`)
assert(hrefs[2].startsWith('https://wa.me/491711234567'), `WhatsApp: ${hrefs[2]}`)
for (const a of await knoepfe.elementHandles()) {
const b = await a.boundingBox()
assert(b.height >= 44 && b.width >= 44, `Tippflaeche ${b.width}x${b.height}`)
eq(await a.getAttribute('rel'), 'noopener noreferrer', 'rel')
}
// Abstandhalter am Ende von body in Hoehe der Leiste, scroll-padding-bottom
const h = await hoehe(page, HOST('kontakt'))
eq(await anzahl(page, '[data-wkz-abstand]'), 1, 'Abstandhalter')
assert(await page.evaluate(() => document.body.lastElementChild.hasAttribute('data-wkz-abstand')), 'Abstandhalter nicht am Ende von body')
assert(nahe(await hoehe(page, '[data-wkz-abstand]'), h, 1), 'Abstandhalter nicht so hoch wie die Leiste')
assert(nahe(await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom) || 0), h, 1), 'scroll-padding-bottom')
// ganz unten: der letzte Absatz liegt ueber der Leiste
await scrolle(page, 1e6)
await page.waitForFunction(() => window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 1)
const unten = await page.evaluate(() => document.getElementById('ende').getBoundingClientRect().bottom)
const leisteOben = await oben(page, HOST('kontakt'))
assert(unten <= leisteOben + 0.5, `der letzte Absatz endet bei ${fmt(unten)}, die Leiste beginnt bei ${fmt(leisteOben)}`)
await shot(page, 'kontakt-handy')
// auf breitem Schirm weg (und wieder da: die Abfrage wird beobachtet)
await page.setViewportSize({ width: 1280, height: 800 })
await host.waitFor({ state: 'detached' })
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter blieb stehen')
eq(await page.evaluate(() => document.documentElement.style.scrollPaddingBottom), '', 'scroll-padding-bottom nicht zurueckgesetzt')
await page.setViewportSize(HANDY)
await host.waitFor({ state: 'attached' })
eq(await anzahl(page, '[data-wkz-abstand]'), 1, 'Abstandhalter nach der Rueckkehr')
}, { viewport: HANDY })
await check('kontaktleiste_nicht_auf_dem_desktop', async (page) => {
await page.goto(srv.seite('desktop.html', seitenHtml()))
await warteBereit(page)
eq(await anzahl(page, HOST('kontakt')), 0, 'Kontakt-Leiste auf dem Desktop')
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter auf dem Desktop')
})
await check('kontaktleiste_aus_per_team', async (page) => {
await page.goto(srv.seite('kontakt-aus.html', seitenHtml()))
await warteBereit(page)
eq(await anzahl(page, HOST('kontakt')), 0, 'Kontakt-Leiste trotz Team-Schalter')
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter trotz Team-Schalter')
}, { viewport: HANDY, team: { kontaktleisteAus: true } })
await check('kontaktleiste_nur_mit_daten', async (page) => {
await page.goto(srv.seite('kontakt-nur-route.html', seitenHtml()))
await warteBereit(page)
const host = page.locator(HOST('kontakt'))
await host.waitFor({ state: 'attached' })
eq(await host.locator('a').allInnerTexts(), ['Route'], 'nur Route (kein Telefon, kein WhatsApp)')
}, { viewport: HANDY, eingabe: zeitenEingabe({ kontakt: { an: true, telefon: '', adresse: ADRESSE } }) })
await check('kontaktleiste_ohne_daten', async (page) => {
srv.ueberschreibe((a) => ({ ...a, kontakt: null })) // der Browser hat die Antwort eines frueheren Abrufs sonst 60 Sekunden im Zwischenspeicher
await page.goto(srv.seite('kontakt-leer.html', seitenHtml()))
await warteBereit(page)
eq(await anzahl(page, HOST('kontakt')), 0, 'Leiste ohne Daten')
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter ohne Daten')
}, { viewport: HANDY })
await check('eigene_anrufleiste', async (page) => {
const eigene = '<div id="eigene" style="position:fixed;bottom:0;left:0;right:0;height:56px;background:#fff;border-top:1px solid #ccc;z-index:50"><a href="tel:+496301123456" style="display:block;line-height:56px;text-align:center">Jetzt anrufen</a></div>'
await page.goto(srv.seite('eigene-leiste.html', seitenHtml({ anfang: eigene })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // das Skript hat gezeichnet ...
eq(await anzahl(page, HOST('kontakt')), 0, 'Kontakt-Leiste neben einer eigenen Anruf-Leiste')
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter neben einer eigenen Anruf-Leiste')
}, { viewport: HANDY, eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('anruf_im_kopf_stoert_nicht', async (page) => {
// ein tel:-Link in einem festen Kopf OBEN ist keine Anruf-Leiste unten
const kopf = '<header style="position:fixed;top:0;left:0;right:0;height:48px;background:#222;z-index:100"><a href="tel:+496301123456" style="color:#fff;line-height:48px">06301 123456</a></header>'
await page.goto(srv.seite('anruf-kopf.html', seitenHtml({ anfang: kopf })))
await warteBereit(page)
await page.locator(HOST('kontakt')).waitFor({ state: 'attached' })
}, { viewport: HANDY })
await check('unsichere_daten_werden_nicht_verwendet', async (page) => {
srv.ueberschreibe((a) => ({
...a,
hinweise: [{ art: 'wichtig', text: '<img src=x onerror="window.__x=1">Achtung', knopf: { art: 'link', ziel: 'javascript:window.__y=1', text: 'Los' }, ab: 0, bis: 8.64e15, rang: 4 }],
kontakt: { telefon: '491711234567', routeUrl: 'javascript:window.__z=1', whatsappUrl: 'http://wa.me/491711234567' },
}))
await page.goto(srv.seite('boese.html', seitenHtml()))
await warteBereit(page)
const region = page.getByRole('region', { name: 'Hinweis' })
await region.waitFor()
assert((await region.innerText()).includes('<img src=x onerror="window.__x=1">Achtung'), 'Text nicht als Text gezeigt')
eq(await region.locator('img').count(), 0, 'img eingeschleust')
eq(await region.locator('a').count(), 0, 'javascript:-Knopf wurde ein Link')
const host = page.locator(HOST('kontakt'))
await host.waitFor({ state: 'attached' })
eq(await host.locator('a').allInnerTexts(), ['Anrufen'], 'nur der Anruf-Knopf bleibt (Route: javascript:, WhatsApp: http:)')
eq(await page.evaluate(() => [window.__x, window.__y, window.__z]), [undefined, undefined, undefined], 'Skript ausgefuehrt')
}, { viewport: HANDY })
await check('jsonld_ergaenzt', async (page) => {
const bekannt = { '@context': 'https://schema.org', '@type': 'Bakery', name: FIRMA, telephone: '+49 171 1234567', address: ADRESSE, openingHoursSpecification: [{ '@type': 'OpeningHoursSpecification', dayOfWeek: 'Sunday', opens: '08:00', closes: '10:00' }] }
const web = { '@context': 'https://schema.org', '@type': 'WebSite', name: FIRMA, url: 'https://baeckerei.example/' }
const kaputt = '{ "@type": "Bakery", kaputt'
const kopf = `<script type="application/ld+json">${JSON.stringify(web)}</script><script type="application/ld+json">${kaputt}</script><script type="application/ld+json">${JSON.stringify(bekannt)}</script>`
await page.goto(srv.seite('jsonld.html', seitenHtml({ kopf })))
await warteBereit(page)
const texte = await page.evaluate(() => [...document.querySelectorAll('script[type="application/ld+json"]')].map((s) => s.textContent))
eq(texte.length, 3, 'Anzahl der JSON-LD-Bloecke (kein zweiter Block fuer ein vorhandenes Unternehmen)')
eq(JSON.parse(texte[0]), web, 'WebSite-Block veraendert')
eq(texte[1], kaputt, 'unparsbarer Block veraendert')
const api = await (await fetch(API)).json()
const neu = JSON.parse(texte[2])
eq(neu.openingHoursSpecification, api.jsonld.openingHoursSpecification, 'Zeiten nicht aus der Schnittstelle')
eq(neu.specialOpeningHoursSpecification ?? [], api.jsonld.specialOpeningHoursSpecification, 'Sonderzeiten')
assert(neu.openingHoursSpecification.length === 6, `Mo-Sa = 6 Eintraege (statt ${neu.openingHoursSpecification.length})`)
assert(!JSON.stringify(neu).includes('"Sunday"'), 'alter Sonntag-Eintrag blieb stehen')
eq([neu['@type'], neu.name, neu.telephone, neu.address], ['Bakery', FIRMA, '+49 171 1234567', ADRESSE], 'uebrige Felder des Blocks')
})
await check('jsonld_neuer_block', async (page) => {
const web = { '@context': 'https://schema.org', '@type': 'WebSite', name: FIRMA }
await page.goto(srv.seite('jsonld-neu.html', seitenHtml({ kopf: `<script type="application/ld+json">${JSON.stringify(web)}</script>` })))
await warteBereit(page)
const texte = await page.evaluate(() => [...document.querySelectorAll('script[type="application/ld+json"]')].map((s) => s.textContent))
eq(texte.length, 2, 'Anzahl der Bloecke (WebSite + neuer Block)')
eq(JSON.parse(texte[0]), web, 'WebSite-Block veraendert')
const api = await (await fetch(API)).json()
const neu = JSON.parse(texte[1])
eq(neu['@type'], 'LocalBusiness', '@type')
eq(neu['@context'], api.jsonld.neuerBlock['@context'], '@context kommt vom Server')
eq([neu.name, neu.address], [FIRMA, ADRESSE], 'name/address')
eq(neu.url, `${srv.pageOrigin}/`, 'url')
eq(neu.openingHoursSpecification, api.jsonld.openingHoursSpecification, 'Zeiten')
})
await check('jsonld_ohne_zeiten_bleibt_unberuehrt', async (page) => {
srv.ueberschreibe((a) => ({ ...a, jsonld: null }))
const bekannt = { '@context': 'https://schema.org', '@type': 'Bakery', name: FIRMA, openingHoursSpecification: [{ '@type': 'OpeningHoursSpecification', dayOfWeek: 'Sunday', opens: '08:00', closes: '10:00' }] }
const text = JSON.stringify(bekannt)
await page.goto(srv.seite('jsonld-null.html', seitenHtml({ kopf: `<script type="application/ld+json">${text}</script>` })))
await warteBereit(page)
const texte = await page.evaluate(() => [...document.querySelectorAll('script[type="application/ld+json"]')].map((s) => s.textContent))
eq(texte, [text], 'ohne Zeiten bleibt das JSON-LD wie es ist (und es kommt kein Block dazu)')
})
await check('aktiv_false_ersatz_bleibt', async (page) => {
srv.setzeFunktion('disabled')
const ld = JSON.stringify({ '@context': 'https://schema.org', '@type': 'Bakery', name: FIRMA, openingHoursSpecification: [] })
const html = seitenHtml({
kopf: `<script type="application/ld+json">${ld}</script>`,
inhalt: `<div id="ersatz" data-webklar-zeiten data-modus="tabelle"><table><caption>Öffnungszeiten</caption><tr><th>Montag</th><td>09:00&#8211;18:00</td></tr></table></div>${lang(5)}`,
})
await warteAufSchnittstelle(page, () => page.goto(srv.seite('aus.html', html)))
assert((await page.locator('#ersatz').innerText()).includes('09:00–18:00'), 'Ersatz-HTML ist weg')
eq(await anzahl(page, '#ersatz table'), 1, 'Ersatz-Tabelle')
eq(await anzahl(page, '#ersatz .wkz-tabelle'), 0, 'Tabelle des Skripts trotz aktiv: false')
eq(await anzahl(page, '[data-wkz-leiste]'), 0, 'Leiste trotz aktiv: false')
eq(await page.evaluate(() => document.querySelector('script[type="application/ld+json"]').textContent), ld, 'JSON-LD veraendert')
eq(await anzahl(page, 'script[type="application/ld+json"]'), 1, 'neuer JSON-LD-Block trotz aktiv: false')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('unbekannter_plan', async (page) => {
const meldungen = []
page.on('console', (m) => { if (m.type() === 'warning') meldungen.push(m.text()) })
const html = seitenHtml({ inhalt: `<div id="ersatz" data-webklar-zeiten data-modus="status" data-plan="gibtsnicht">Ersatz-Status</div>${lang(5)}` })
await page.goto(srv.seite('plan.html', html))
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // die Leisten haengen nicht am Plan
eq(await page.locator('#ersatz').innerText(), 'Ersatz-Status', 'Ersatz-HTML bei unbekanntem Plan')
assert(meldungen.some((m) => m.includes('gibtsnicht')), `Konsole-Warnung fehlt: ${JSON.stringify(meldungen)}`)
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('kein_storage', async (page) => {
await page.goto(srv.seite('storage.html', seitenHtml({ anfang: KOPF_FEST })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
await page.locator(HOST('kontakt')).waitFor({ state: 'attached' })
const s = await page.evaluate(async () => ({ c: document.cookie, l: localStorage.length, s: sessionStorage.length, db: (await indexedDB.databases()).length }))
eq(s, { c: '', l: 0, s: 0, db: 0 }, 'Cookies/Storage')
eq(await page.context().cookies(), [], 'Cookies im Browser')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), viewport: HANDY })
await check('doppelt_geladen', async (page) => {
const anfragen = []
page.on('request', (r) => { if (r.url().includes('/api/public/zeiten/v1/')) anfragen.push(r.url()) })
await page.goto(srv.seite('doppelt.html', seitenHtml({ skript: `${SKRIPT}${SKRIPT}` })))
await warteBereit(page)
await page.waitForTimeout(300)
eq(await anzahl(page, HOST('hinweis')), 1, 'Hinweis-Leisten')
eq(await anzahl(page, 'style#wkz-style'), 1, 'Stile')
eq(anfragen.length, 1, 'Abrufe der Schnittstelle')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('reduzierte_bewegung', async (page) => {
await page.goto(srv.seite('ruhig.html', seitenHtml()))
await warteBereit(page)
const region = page.getByRole('region', { name: 'Hinweis' })
await region.waitFor()
eq(await region.evaluate((e) => getComputedStyle(e).animationName), 'none', 'Animation trotz prefers-reduced-motion')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), context: { reducedMotion: 'reduce' } })
await check('normale_bewegung_hat_einblendung', async (page) => {
await page.goto(srv.seite('bewegt.html', seitenHtml()))
await warteBereit(page)
const region = page.getByRole('region', { name: 'Hinweis' })
await region.waitFor()
assert((await region.evaluate((e) => getComputedStyle(e).animationName)) !== 'none', 'ohne Praeferenz soll die Leiste einblenden')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), context: { reducedMotion: 'no-preference' } })
await check('status_wechselt_um_18_uhr', async (page, ctx) => {
await page.goto(srv.seite('status.html', seitenHtml()))
await warteBereit(page)
eq(await statusText(page), STATUS_OFFEN, 'Status um 17:55')
await ctx.clock.fastForward(6 * 60 * 1000) // 18:01
await page.waitForFunction((text) => document.getElementById('status').textContent.trim() === text, STATUS_ZU)
eq(await statusText(page), STATUS_ZU, 'Status nach 18 Uhr')
}, { uhr: '2026-10-05T15:55:00.000Z' })
await check('neu_laden_nach_zehn_minuten', async (page, ctx) => {
const anfragen = []
page.on('request', (r) => { if (r.url().includes('/api/public/zeiten/v1/')) anfragen.push(r.url()) })
await page.goto(srv.seite('neu-laden.html', seitenHtml()))
await warteBereit(page)
eq(anfragen.length, 1, 'ein Abruf beim Laden')
eq(await anzahl(page, HOST('hinweis')), 0, 'noch kein Hinweis')
// das Team stellt einen Hinweis ein; die Server-Uhr steht auf dem neuen Zeitpunkt
await srv.speichere(zeitenEingabe({ hinweise: [INFO] }))
srv.setzeUhr('2026-10-05T16:06:00.000Z')
await ctx.clock.fastForward(11 * 60 * 1000)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
assert(anfragen.length === 2, `zweiter Abruf nach 10 Minuten (Abrufe: ${anfragen.length})`)
}, { uhr: '2026-10-05T15:55:00.000Z' })
await check('fetch_wirft_sofort', async (page) => {
const html = seitenHtml({ inhalt: `<div id="ersatz" data-webklar-zeiten data-modus="status">Ersatz-Status</div>${lang(3)}` })
await page.goto(srv.seite('fetch-wirft.html', html))
await page.waitForTimeout(500)
eq(await page.locator('#ersatz').innerText(), 'Ersatz-Status', 'Ersatz-HTML')
eq(await anzahl(page, '[data-wkz-leiste]'), 0, 'Leiste ohne Daten')
}, { initScript: () => { window.fetch = () => { throw new TypeError('fetch kaputt') } } })
await check('screenshots_handy', async (page) => {
await page.goto(srv.seite('shot.html', seitenHtml({ anfang: KOPF_FEST, inhalt: `<div style="height:60px"></div>${STATUS}${lang(12)}` })))
await warteBereit(page)
await page.locator(HOST('kontakt')).waitFor({ state: 'attached' })
await page.waitForTimeout(400)
await shot(page, 'handy-hinweis-und-kontakt')
}, { viewport: HANDY, eingabe: zeitenEingabe({ hinweise: [hinweis('urlaub', 'Betriebsurlaub vom 12. bis 16. Oktober – ab dem 17. sind wir wieder für Euch da.', { knopf: { art: 'anruf', ziel: '06301 1234', text: 'Anrufen' } })] }) })
}
browser = await chromium.launch()
try {
await pruefungen()
} finally {
await browser.close()
await srv.stop()
}
// Nur der Testserver (und kein Dritter) wurde angefragt
const erlaubt = new Set([new URL(srv.url).host, new URL(srv.pageOrigin).host])
const fremd = [...hosts].filter((h) => !erlaubt.has(h))
if (fremd.length) {
results.push(['kein_fremder_host', false])
console.log(`FAIL kein_fremder_host: ${fremd.join(', ')}`)
} else {
results.push(['kein_fremder_host', true])
console.log('ok kein_fremder_host')
}
const failed = results.filter(([, ok]) => !ok)
console.log(`${results.length - failed.length}/${results.length} Pruefungen bestanden`)
process.exit(failed.length ? 1 : 0)
+208
View File
@@ -0,0 +1,208 @@
// UI-Testserver der Oeffnungszeiten (fuer test/ui/zeiten-embed.ui.mjs): die echte oeffentliche Schnittstelle
// (server/routes/publicZeiten.js samt Dienst, Speicher und Zeitlogik) hinter dem Nachbau von Appwrite aus test/support/,
// public/ als statische Dateien (public/embed/zeiten.js) und Testseiten, die der Server selbst ausliefert. Nichts verlaesst den Rechner
// (useTestEnv erlaubt nur 127.0.0.1).
//
// Aufruf in einem Skript (KEINE Server-Module vorher importieren, config.js liest die Umgebung beim ersten Import):
// import { startZeitenUiServer, zeitenEingabe } from './zeitenTestServer.mjs'
// const srv = await startZeitenUiServer()
// ... srv.url, srv.pageOrigin, srv.SITE_KEY, srv.seite(name, html), srv.zuruecksetzen({ eingabe, team }) ...
// await srv.stop() // erst den Browser schliessen, dann den Server
//
// Seitenherkunft: srv.url ist http://127.0.0.1:PORT (die Schnittstelle), srv.pageOrigin ist http://localhost:PORT (derselbe Server,
// anderer Host): Seiten von pageOrigin mit data-api=srv.url sprechen die Schnittstelle als echte Cross-Origin-Anfrage an (CORS `*`,
// Date-Kopf sichtbar), so wie Kundenseiten es im Betrieb tun. Die Seiten kommen vom Server selbst und nicht per page.route: Chromium
// lehnt Anfragen von Seiten, die Playwright per route.fulfill liefert, an 127.0.0.1 ab (Local Network Access).
//
// Uhr: Server-Uhr und Date-Kopf der Antworten stehen fest (Vorgabe Montag 2026-10-05 10:00 Berliner Zeit); srv.setzeUhr(datum) stellt sie
// um. Der Browser bekommt dieselbe Zeit per context.clock.install({ time: srv.uhr() }); ohne Uhr im Browser rechnet das Skript
// ohnehin mit der Serverzeit (Date-Kopf weicht um mehr als 5 Minuten von der lokalen Uhr ab).
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'
import { startAppwriteMock } from '../support/appwriteMock.js'
import { schemaKeys } from '../support/schema.js'
import { useTestEnv } from '../support/testEnv.js'
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
export const SITE_KEY = 'abcdabcdabcdabcd'
export const KUNDE = 'c_kunde01'
export const PROJEKT = 'p_projekt01'
export const FEATURE = 'f_p_projekt01_zeiten'
export const JETZT = '2026-10-05T08:00:00.000Z' // Montag, 10:00 Berlin (MESZ)
export const ADRESSE = 'Hauptstraße 1, 67722 Winnweiler'
export const FIRMA = 'Bäckerei Sonnenschein'
const P_STANDARD = 'p_aaaaaaa1'
const woche = (zeiten = {}) => {
const t = {}
for (let d = 1; d <= 7; d += 1) t[d] = zeiten[d] ? { modus: 'zeiten', fenster: [zeiten[d]] } : { modus: 'zu', fenster: [] }
return t
}
/**
* Eingabe fuer speichereZeiten: ein Plan "standard" (Mo bis Fr 09:00 bis 18:00, Sa 09:00 bis 12:00, So zu), keine Ausnahmen, keine
* Hinweise, Kontakt mit Telefon, Adresse und WhatsApp. `ueberschreibe` ersetzt oberste Felder (hinweise, kontakt, ausnahmen, ...).
*/
export function zeitenEingabe(ueberschreibe = {}) {
return {
land: 'RP',
plaene: [
{ id: P_STANDARD, kurz: 'standard', name: 'Öffnungszeiten', haupt: true, tage: woche({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'], 3: ['09:00', '18:00'], 4: ['09:00', '18:00'], 5: ['09:00', '18:00'], 6: ['09:00', '12:00'] }) },
],
feiertage: {},
ausnahmen: [],
hinweise: [],
kontakt: {
an: true,
telefon: '0171 1234567',
adresse: ADRESSE,
whatsapp: { nummer: '+49 171 1234567', starttext: 'Hallo, ich habe eine Frage!', bestaetigtAt: '2026-10-01T08:00:00.000Z' },
},
...ueberschreibe,
}
}
/** Ein Hinweis fuer zeitenEingabe({ hinweise: [hinweis(...)] }). */
export function hinweis(art, text, extra = {}) {
return { id: `h_${art.slice(0, 4)}${String(text.length).padStart(4, '0')}`, art, text, knopf: null, ...extra }
}
/**
* Startet Nachbau, Router und Seiten.
* @param {object} [opt]
* @param {string|Date} [opt.jetzt] Startzeit der Server-Uhr
*/
export async function startZeitenUiServer({ jetzt = JETZT } = {}) {
const { ZEITEN_DATETIMES } = await import('../../server/services/zeiten/schema.js')
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
},
datetimes: ZEITEN_DATETIMES,
defaultLimit: 25,
})
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../../server/config.js')
const { deleteDocument } = await import('../../server/services/appwriteAdmin.js')
const { resetZeitenCacheForTests } = await import('../../server/services/zeiten/cache.js')
const { speichereZeiten, speichereTeam } = await import('../../server/services/zeiten/speicher.js')
const { oeffentlicheZeitenAntwort } = await import('../../server/services/zeiten/oeffentlich.js')
const { default: publicZeitenRoutes, setzeUhrFuerTests, resetPublicZeitenForTests } = await import('../../server/routes/publicZeiten.js')
let uhrMs = new Date(jetzt).getTime()
const uhr = () => new Date(uhrMs)
setzeUhrFuerTests(uhr)
const modusVorher = config.zeiten.mode
config.zeiten.mode = 'on'
// Testseiten (HTML aus dem Skript) kommen vom Server selbst
const seiten = new Map()
const seitenRouter = express.Router()
seitenRouter.get('/:name', (req, res) => {
const html = seiten.get(req.params.name)
if (html === undefined) return res.status(404).type('text').send('nicht gefunden')
return res.set('Cache-Control', 'no-store').type('text/html; charset=utf-8').send(html)
})
let ueberschreibung = null // (antwort) => antwort: veraendert die echte Antwort vor dem Senden (Pruefungen mit boesen Daten)
const app = express()
app.disable('x-powered-by')
app.use('/ui-seiten', seitenRouter)
app.use('/api/public/zeiten/v1', (req, res, next) => {
res.setHeader('Date', uhr().toUTCString()) // Serverzeit in der Antwort: das Skript rechnet bei mehr als 5 Minuten Abweichung damit
// Die echte Antwort darf der Browser 60 Sekunden behalten (max-age=60). Dessen Zwischenspeicher laeuft nach der echten Uhr, die Pruefungen
// spulen aber die Uhr der Seite vor: ein Neuladen nach "10 Minuten" soll deshalb das Netz erreichen, nicht den Zwischenspeicher.
const setzeKopf = res.setHeader.bind(res)
res.setHeader = (name, wert) => setzeKopf(name, String(name).toLowerCase() === 'cache-control' ? 'no-store' : wert)
if (!ueberschreibung || req.method !== 'GET') return next()
const siteKey = req.path.replace(/^\//, '')
return oeffentlicheZeitenAntwort(siteKey, uhr()).then((antwort) => {
res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Expose-Headers': 'Date', 'Cache-Control': 'no-store' })
res.json(ueberschreibung(JSON.parse(JSON.stringify(antwort))))
}, next)
})
app.use('/api/public/zeiten/v1', publicZeitenRoutes)
app.use('/', express.static(PUBLIC_DIR, { etag: false, lastModified: false, setHeaders: (res) => res.set('Cache-Control', 'no-store') }))
const server = await new Promise((resolve, reject) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
s.on('error', reject)
})
const url = `http://127.0.0.1:${server.address().port}`
const pageOrigin = url.replace('127.0.0.1', 'localhost')
const feature = (state = 'active') => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'zeiten', siteKey: SITE_KEY, state, enabled: state === 'active' })
const featureRef = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, siteKey: SITE_KEY }
const srv = {
url, pageOrigin, SITE_KEY, aw, config, uhr,
/** Testseite unter pageOrigin/ui-seiten/<name> bereitstellen; liefert die Adresse. */
seite(name, html) {
seiten.set(name, html)
return `${pageOrigin}/ui-seiten/${name}`
},
/** Server-Uhr (und Date-Kopf) neu setzen; der Zwischenspeicher der Schnittstelle wird geleert. */
setzeUhr(datum) {
uhrMs = new Date(datum).getTime()
resetZeitenCacheForTests()
},
/** Zustand der Funktion aendern: 'active' | 'setup' | 'disabled' | 'off'. */
setzeFunktion(state) {
aw.seed('portalFeatures', feature(state))
resetZeitenCacheForTests()
},
/** Antwort der Schnittstelle vor dem Senden veraendern (null = echte Antwort): fn(antwort) liefert die neue Antwort. */
ueberschreibe(fn) {
ueberschreibung = fn
resetZeitenCacheForTests()
},
/** Zeiten speichern (Eingabe wie zeitenEingabe) und Team-Einstellungen setzen ({ navSelektor, kontaktleisteAus, hinweisleisteAus }). */
async speichere(eingabe = zeitenEingabe(), team = null) {
const doc = aw.docs('oeffnungszeiten').get(FEATURE)
await speichereZeiten({ feature: featureRef, eingabe, version: doc?.version ?? 0, now: uhr() })
if (team) {
const doc2 = aw.docs('oeffnungszeiten').get(FEATURE)
await speichereTeam({ feature: featureRef, eingabe: team, teamVersion: doc2?.teamVersion ?? 0, now: uhr() })
}
resetZeitenCacheForTests()
},
/** Alles auf Anfang: Dokumente loeschen, Uhr zurueck, Funktion (active), Kunde, Projekt und Zeiten neu anlegen. */
async zuruecksetzen({ eingabe, team = null, uhrNeu = true, state = 'active' } = {}) {
for (const collection of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects']) {
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
}
resetZeitenCacheForTests()
ueberschreibung = null
if (uhrNeu) uhrMs = new Date(jetzt).getTime()
config.zeiten.mode = 'on'
aw.seed('customers', { $id: KUNDE, name: 'Frau Sonne', companyName: FIRMA })
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Sonnenschein-Website' })
aw.seed('portalFeatures', feature(state))
await srv.speichere(eingabe ?? zeitenEingabe(), team)
},
/** Beendet Server und Nachbau (zuerst den Browser schliessen) und stellt config wieder her. */
async stop() {
config.zeiten.mode = modusVorher
resetPublicZeitenForTests()
server.closeAllConnections?.() // offene Verbindungen des Browsers (Keep-Alive) halten close() sonst auf
await new Promise((resolve) => server.close(resolve))
await aw.close()
},
}
await srv.zuruecksetzen()
return srv
}
+243
View File
@@ -401,3 +401,246 @@ test('Demo-Seite: noindex, Skript per defer mit data-site, alle drei Platzhalter
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, 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, {}]) {
assert.equal(hinweisKnopf({ art: 'link', ziel: bose, text: 'Los' }), null, JSON.stringify(bose))
}
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('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 = JSON\.stringify\(/)
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()')
})