diff --git a/public/embed/zeiten.js b/public/embed/zeiten.js index 54c8d93..1bffadf 100644 --- a/public/embed/zeiten.js +++ b/public/embed/zeiten.js @@ -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 , 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 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() } diff --git a/test/ui/zeiten-embed.ui.mjs b/test/ui/zeiten-embed.ui.mjs new file mode 100644 index 0000000..c327b42 --- /dev/null +++ b/test/ui/zeiten-embed.ui.mjs @@ -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-.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 = `` +const STATUS = '

Status: Ersatz-Status

' +const lang = (n = 40) => Array.from({ length: n }, (_, i) => `

Absatz ${i + 1}: Brot, Brötchen und Kuchen aus eigener Backstube.

`).join('\n') + +/** Testseite: kopf = -Zusatz, stil = CSS, anfang = Anfang von (z. B. Kopfzeile), inhalt =
. */ +function seitenHtml({ kopf = '', stil = '', anfang = '', inhalt = `${STATUS}${lang()}

Ende der Seite

`, skript = SKRIPT } = {}) { + return ` +Bäckerei Sonnenschein +${kopf} +${anfang}
${inhalt}
${skript}` +} + +const KOPF_FEST = '
Bäckerei Sonnenschein
' +const KOPF_KLEBT = '
Bäckerei Sonnenschein
' + +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: `
${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: '', + }) + 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 = '' + 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 = '
06301 123456
' + 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: '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('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 = `` + 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: `` }))) + 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: `` }))) + 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: ``, + inhalt: `
Öffnungszeiten
Montag09:00–18:00
${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: `
Ersatz-Status
${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: `
Ersatz-Status
${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: `
${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) diff --git a/test/ui/zeitenTestServer.mjs b/test/ui/zeitenTestServer.mjs new file mode 100644 index 0000000..921d554 --- /dev/null +++ b/test/ui/zeitenTestServer.mjs @@ -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/ 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 +} diff --git a/test/zeitenEmbedStatic.test.js b/test/zeitenEmbedStatic.test.js index 28a6c5d..760aa97 100644 --- a/test/zeitenEmbedStatic.test.js +++ b/test/zeitenEmbedStatic.test.js @@ -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, /[\s\S]*
[\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,x', '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()') +})