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

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

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

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

No files matched your search

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