Terminplaner: Einbau-Snippet mit Knopf und Farbvorschlag nach WCAG verschoben

einbauSnippet und esc wandern aus routes/termine.js nach services/termine/einbauanleitung.js
(Parameter statt ctx/config). Neu: Modus knopf mit data-knopf (nur bei gesetztem Text, hoechstens
40 Zeichen, Zeilenumbrueche zu Leerzeichen, maskiert; unbekannter Modus gilt als inline), WCAG-
Helfer luminanz/kontrast/textFarbeAuf 1:1 aus dem Widget und cssVorschlag(farbe) mit Warnung,
wenn der Akzent gegen Weiss unter 3 : 1 liegt. VORGABE_FARBE wird exportiert. Die Kundenansicht
bleibt unveraendert (Modus inline). Farbparitaet Widget/Server ueber alle 4096 Farben im
17er-Raster getestet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 23:55:42 +00:00
1 parent 06ebcd1b0a
commit ee454017b0
5 files changed
+349 -16

No files matched your search

+206
View File
@@ -0,0 +1,206 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { VORGABE_FARBE } from '../server/services/termine/einstellungen.js'
import { cssVorschlag, einbauSnippet, esc, kontrast, luminanz, textFarbeAuf } from '../server/services/termine/einbauanleitung.js'
// Einbau-Snippet und Farbvorschlag des Terminplaners (TP2 Task D2). Reine Funktionen ohne Appwrite und ohne config.
// Das Snippet war bis D2 in server/routes/termine.js; die Aufrufstelle und ihre Antwort-Tests stehen in termineRoutes.test.js.
const SITE_KEY = 'abcdefghijklmnop'
const API = 'https://project.webklar.com'
/** Das Snippet aus Abschnitt 1 von docs/funktionen/termine-einbau.md: SITEKEY ersetzt, der Erklaer-Kommentar am Zeilenende entfernt. */
function snippetAusDoku() {
const doku = readFileSync(new URL('../docs/funktionen/termine-einbau.md', import.meta.url), 'utf8')
const abschnitt = doku.split('## 1. Das Snippet')[1]
assert.ok(abschnitt, 'Abschnitt 1 fehlt in termine-einbau.md')
const block = /```html\n([\s\S]*?)\n```/.exec(abschnitt)
assert.ok(block, 'Code-Block in Abschnitt 1 fehlt')
return block[1]
.replace('SITEKEY', SITE_KEY)
.split('\n')
.map((zeile) => zeile.replace(/\s*<!--.*-->\s*$/, ''))
.join('\n')
}
const basis = { siteKey: SITE_KEY, apiBase: API, telefon: '06301 1234' }
// ---------------------------------------------------------------- esc
test('esc maskiert & < > " und macht aus allem anderen Text', () => {
assert.equal(esc('a & b < c > d "e"'), 'a &amp; b &lt; c &gt; d &quot;e&quot;')
assert.equal(esc(42), '42')
assert.equal(esc(undefined), '')
assert.equal(esc(null), '')
})
// ---------------------------------------------------------------- Snippet
test('Snippet inline ist zeichengleich zu Abschnitt 1 von termine-einbau.md', () => {
assert.equal(einbauSnippet(basis), snippetAusDoku())
assert.equal(einbauSnippet({ ...basis, modus: 'inline' }), snippetAusDoku())
})
test('Snippet: Telefon wird maskiert, ohne Telefon steht der allgemeine Ausweichtext', () => {
const mit = einbauSnippet({ ...basis, telefon: '06301 <12>' })
assert.ok(mit.includes('<p>Termine telefonisch unter 06301 &lt;12&gt;.</p>'), mit)
assert.ok(!mit.includes('<12>'))
for (const telefon of [undefined, null, '']) {
assert.ok(einbauSnippet({ siteKey: SITE_KEY, apiBase: API, telefon }).includes('<p>Termine telefonisch vereinbaren.</p>'), String(telefon))
}
})
test('Snippet: siteKey und Adresse sind maskiert (kein Ausbrechen aus dem Attribut)', () => {
const s = einbauSnippet({ siteKey: 'a"><script>x', apiBase: 'https://x.de/"&', telefon: undefined })
assert.ok(s.includes('data-webklar-termine="a&quot;&gt;&lt;script&gt;x"'), s)
assert.ok(s.includes('data-api="https://x.de/&quot;&amp;"'), s)
assert.ok(s.includes('<script src="https://x.de/&quot;&amp;/embed/termine.js" defer></script>'), s)
assert.equal((s.match(/<script/g) || []).length, 1, 'nur das eigene Skript-Element')
})
test('Snippet im Modus knopf: data-knopf mit maskiertem Text', () => {
const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText: 'Jetzt "buchen" & mehr' })
assert.ok(s.includes('data-modus="knopf" data-knopf="Jetzt &quot;buchen&quot; &amp; mehr">'), s)
assert.ok(s.startsWith(`<div data-webklar-termine="${SITE_KEY}" data-api="${API}" data-modus="knopf"`), s)
assert.ok(s.includes('<p>Termine telefonisch unter 06301 1234.</p>'), 'Ausweichtext bleibt')
assert.ok(s.endsWith(`<script src="${API}/embed/termine.js" defer></script>`))
})
test('Snippet im Modus knopf ohne Text: kein data-knopf (das Widget nimmt "Termin buchen")', () => {
for (const knopfText of [undefined, null, '', ' ', '\n\t']) {
const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText })
assert.ok(s.includes('data-modus="knopf">'), JSON.stringify(knopfText))
assert.ok(!s.includes('data-knopf'), JSON.stringify(knopfText))
}
})
test('Snippet: der Knopf-Text ist hoechstens 40 Zeichen lang, Zeilenumbrueche werden zu Leerzeichen', () => {
const lang = `${'a'.repeat(30)}\n${'b'.repeat(29)}`
assert.equal(lang.length, 60)
const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText: lang })
const wert = /data-knopf="([^"]*)"/.exec(s)[1]
assert.equal(wert.length, 40)
assert.equal(wert, `${'a'.repeat(30)} ${'b'.repeat(9)}`)
assert.ok(!/[\r\n]/.test(wert))
for (const umbruch of ['\r\n', '\r', '\u2028']) {
const t = einbauSnippet({ ...basis, modus: 'knopf', knopfText: `Jetzt${umbruch}buchen` })
assert.ok(t.includes('data-knopf="Jetzt'), t)
assert.ok(!/data-knopf="[^"]*[\r\n\u2028]/.test(t), JSON.stringify(umbruch))
}
assert.equal(/data-knopf="([^"]*)"/.exec(einbauSnippet({ ...basis, modus: 'knopf', knopfText: 'Jetzt\nbuchen' }))[1], 'Jetzt buchen')
// gekuerzt wird der Rohtext, nicht der maskierte: 40 Anfuehrungszeichen sind 40 Zeichen, nicht 240
const ang = einbauSnippet({ ...basis, modus: 'knopf', knopfText: '"'.repeat(60) })
assert.equal(/data-knopf="([^"]*)"/.exec(ang)[1], '&quot;'.repeat(40))
})
test('Snippet: der Text kuerzt nie mitten in einem Zeichen aus zwei Code-Einheiten', () => {
const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText: `${'a'.repeat(39)}😀😀` })
const wert = /data-knopf="([^"]*)"/.exec(s)[1]
assert.equal(wert, `${'a'.repeat(39)}😀`)
})
test('Snippet: ein Knopf-Text im Modus inline und ein unbekannter Modus ergeben das Inline-Snippet', () => {
assert.equal(einbauSnippet({ ...basis, modus: 'inline', knopfText: 'Buchen' }), snippetAusDoku())
for (const modus of ['dialog', 'KNOPF', '', null, 42, {}]) {
assert.equal(einbauSnippet({ ...basis, modus, knopfText: 'Buchen' }), snippetAusDoku(), JSON.stringify(modus))
}
})
// ---------------------------------------------------------------- Farben
test('luminanz und kontrast: Eckwerte', () => {
assert.equal(luminanz('#ffffff'), 1)
assert.equal(luminanz('#000000'), 0)
assert.equal(kontrast('#000000', '#ffffff'), 21)
assert.equal(kontrast('#ffffff', '#000000'), 21, 'Reihenfolge egal')
assert.equal(kontrast('#7c3aed', '#7c3aed'), 1)
})
test('textFarbeAuf: Weiss auf dunklen, dunkles Grau auf hellen, Schwarz nur wo beides unter 4,5 : 1 bleibt', () => {
assert.equal(textFarbeAuf('#0f766e'), '#ffffff')
assert.equal(textFarbeAuf('#f5c542'), '#111827')
assert.equal(textFarbeAuf('#000000'), '#ffffff')
assert.equal(textFarbeAuf('#ffffff'), '#111827')
assert.equal(textFarbeAuf('#9d4b6f'), '#ffffff')
// mittlere Toene, bei denen weder Weiss noch das dunkle Grau 4,5 : 1 erreicht
assert.equal(textFarbeAuf('#808080'), '#000000')
assert.ok(kontrast('#808080', '#ffffff') < 4.5 && kontrast('#808080', '#111827') < 4.5)
assert.equal(textFarbeAuf('#6b7280'), '#ffffff')
})
/** Alle 4096 Farben mit Kanalwerten in 17er-Schritten (0, 17, ... 255). */
function alleFarben() {
const stufen = Array.from({ length: 16 }, (_, i) => i * 17)
const hex = (c) => c.toString(16).padStart(2, '0')
const liste = []
for (const r of stufen) for (const g of stufen) for (const b of stufen) liste.push(`#${hex(r)}${hex(g)}${hex(b)}`)
return liste
}
test('textFarbeAuf: jede der 4096 Farben erreicht mit ihrer Schrift mindestens 4,5 : 1', () => {
const farben = alleFarben()
assert.equal(farben.length, 4096)
for (const farbe of farben) {
const text = textFarbeAuf(farbe)
assert.ok(['#ffffff', '#111827', '#000000'].includes(text), `${farbe}: ${text}`)
assert.ok(kontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${kontrast(farbe, text).toFixed(2)} : 1`)
}
})
// ---------------------------------------------------------------- CSS-Vorschlag
test('cssVorschlag: Kommentar, Selektor und beide Variablen', () => {
const v = cssVorschlag('#9d4b6f')
assert.ok(v.css.includes('--wkt-akzent:#9d4b6f;'), v.css)
assert.ok(v.css.includes('--wkt-akzent-text:#ffffff;'), v.css)
assert.ok(v.css.includes('[data-webklar-termine]{'), v.css)
assert.match(v.css, /^\/\*[^*]*\*\/\n/, 'beginnt mit einem Kommentar')
assert.equal(v.akzent, '#9d4b6f')
assert.equal(v.akzentText, '#ffffff')
assert.equal(v.kontrastText, kontrast('#9d4b6f', '#ffffff'))
assert.equal(v.kontrastSeite, kontrast('#9d4b6f', '#ffffff'))
assert.deepEqual(v.hinweise, [], 'ein dunkler Akzent braucht keine Warnung')
// heller Akzent: dunkle Schrift
const hell = cssVorschlag('#f5c542')
assert.equal(hell.akzentText, '#111827')
assert.ok(hell.css.includes('--wkt-akzent-text:#111827;'))
assert.equal(hell.kontrastText, kontrast('#f5c542', '#111827'))
assert.ok(hell.kontrastText >= 4.5)
})
test('cssVorschlag: nur gueltige #rrggbb, sonst die Vorgabefarbe und der Rohtext steht nirgends', () => {
assert.equal(VORGABE_FARBE, '#7c3aed')
for (const roh of ['red', '#fff', undefined, null, 42, '#9d4b6f;}</style>', '#12345g', ' #9d4b6f', '#9d4b6f\n']) {
const v = cssVorschlag(roh)
assert.equal(v.akzent, '#7c3aed', String(roh))
assert.ok(v.css.includes('--wkt-akzent:#7c3aed;'), String(roh))
assert.ok(v.css.includes(`--wkt-akzent-text:${textFarbeAuf('#7c3aed')};`), String(roh))
const ausgabe = JSON.stringify(v)
for (const stueck of ['red', '</style>', '12345g', '#9d4b6f', '#fff;']) {
if (typeof roh === 'string' && roh.includes(stueck)) assert.ok(!ausgabe.includes(stueck), `${JSON.stringify(roh)}: ${stueck} steht in der Ausgabe`)
}
assert.ok(!/[<>]/.test(v.css), 'kein HTML im CSS')
}
// Grossbuchstaben sind gueltig und werden klein geschrieben
assert.equal(cssVorschlag('#9D4B6F').akzent, '#9d4b6f')
})
test('cssVorschlag: der Hinweis nennt den Kontrast gegen Weiss, wenn der Akzent unter 3 : 1 liegt', () => {
const v = cssVorschlag('#f5c542')
assert.ok(v.kontrastSeite < 3, String(v.kontrastSeite))
assert.equal(v.hinweise.length, 1)
assert.match(v.hinweise[0], /Weiß/)
assert.match(v.hinweise[0], /1,6 : 1/, 'Kontrast mit deutschem Komma')
assert.match(v.hinweise[0], /3 : 1/)
assert.match(v.hinweise[0], /Rahmen/)
// knapp ueber der Grenze: keine Warnung
const gut = cssVorschlag('#0f766e')
assert.ok(gut.kontrastSeite >= 3)
assert.deepEqual(gut.hinweise, [])
// Grenzfall: genau an 3 : 1 warnt nicht, knapp darunter schon
for (const farbe of alleFarben()) {
const k = kontrast(farbe, '#ffffff')
assert.equal(cssVorschlag(farbe).hinweise.length, k < 3 ? 1 : 0, `${farbe} (${k.toFixed(3)} : 1)`)
}
})
+19
View File
@@ -2,6 +2,7 @@ import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
import { luminanz as serverLuminanz, textFarbeAuf as serverTextFarbeAuf } from '../server/services/termine/einbauanleitung.js'
// Statische Pruefung des Einbau-Skripts public/embed/termine.js (Aufgabe 19, Rulings R3 und R4):
// - Das Widget rechnet nie mit dem Datum oder der Zeitzone des Browsers (Kalender, Wochentage, Uhrzeiten und Datumstexte kommen
@@ -196,6 +197,24 @@ test('Textfarbe: kein Farbton faellt unter 4,5 : 1 (alle Grautoene und ein Raste
}
})
test('Farbparitaet: Widget und Farbvorschlag des Servers (einbauanleitung.js) waehlen fuer alle 4096 Farben dieselbe Schrift', () => {
const { textFarbeAuf, luminanz } = farbFunktionen()
const stufen = Array.from({ length: 16 }, (_, i) => i * 17) // 0, 17, ... 255
const hex = (c) => c.toString(16).padStart(2, '0')
let anzahl = 0
for (const r of stufen) {
for (const g of stufen) {
for (const b of stufen) {
const farbe = `#${hex(r)}${hex(g)}${hex(b)}`
assert.equal(serverTextFarbeAuf(farbe), textFarbeAuf(farbe), `${farbe}: Schriftfarbe weicht ab`)
assert.equal(serverLuminanz(farbe), luminanz(farbe), `${farbe}: Leuchtdichte weicht ab`)
anzahl += 1
}
}
}
assert.equal(anzahl, 4096)
})
// ---------------------------------------------------------------- Demo und Einbauanleitung
test('Demo-Seite: noindex, Ausweichtext, Skript per defer, beide Modi', () => {