Ö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:
1 parent
a6a4eae294
commit
ed99234ae4
4 files changed
+1542
-8
No files matched your search
+559
-8
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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–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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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*(?:–|–)\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()')
|
||||
})
|
||||
Reference in new issue
Block a user