diff --git a/server/routes/termine.js b/server/routes/termine.js index 40878c5..28ea5dd 100644 --- a/server/routes/termine.js +++ b/server/routes/termine.js @@ -12,6 +12,7 @@ import { createWriteLimiter } from '../services/writeLimiter.js' import { verschiebeZeiten } from '../services/termine/ablauf.js' import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js' import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js' +import { einbauSnippet } from '../services/termine/einbauanleitung.js' import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js' import { loeschTag, loeschzeit } from '../services/termine/frist.js' import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen, normalisiereFragebogen } from '../services/termine/fragebogen.js' @@ -278,20 +279,6 @@ function feedLinks(ctx) { return links } -const esc = (text) => String(text).replace(/[&<>"]/g, (z) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[z]) - -/** Einbau-Code für die Website (Spezifikation Abschnitt 7): Platzhalter mit Ausweichtext, bleibt ohne JavaScript stehen. */ -function einbauSnippet(ctx) { - const telefon = ctx.einstellung?.betrieb?.telefon - const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.' - return [ - `
`, - `

${ausweich}

`, - '
', - ``, - ].join('\n') -} - router.use((_req, res, next) => { res.set('Cache-Control', 'no-store') next() @@ -341,7 +328,7 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou bestellung: bestellbar ? await bestellungAnsicht(register) : null, feeds: e && state === 'active' ? feedLinks(ctx) : {}, siteKey: ctx.feature.siteKey || null, - einbau: laeuft ? { snippet: einbauSnippet(ctx) } : {}, + einbau: laeuft ? { snippet: einbauSnippet({ siteKey: ctx.feature.siteKey, apiBase: config.publicBaseUrl, telefon: ctx.einstellung?.betrieb?.telefon }) } : {}, anfragenOffen: laeuft ? await zaehleAnfragen(ctx.feature.$id) : 0, fehlt: e ? fehlendePflichtangaben(e) : [], // Pflichtangaben für Gäste (Ruling R41): solange etwas fehlt, ist die Funktion nicht buchbar }) diff --git a/server/services/termine/einbauanleitung.js b/server/services/termine/einbauanleitung.js new file mode 100644 index 0000000..d13541b --- /dev/null +++ b/server/services/termine/einbauanleitung.js @@ -0,0 +1,121 @@ +import { VORGABE_FARBE } from './einstellungen.js' + +/** + * Einbau des Terminplaners auf der Website des Kunden: Bausteine fuer Einbau-Code und Farbvorschlag + * (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt Einbauanleitung; Anleitung fuer das Team: + * docs/funktionen/termine-einbau.md, Abschnitte 1 und 3). + * + * Reine Funktionen ohne Appwrite und ohne config: die Adresse des Portals (`apiBase`) und alle Werte kommen als Parameter. + * Der Einbau-Code stand bis TP2 in routes/termine.js; GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert). + * + * Die Farbfunktionen (luminanz, textFarbeAuf) sind 1:1 die aus public/embed/termine.js (Marken @farben-anfang/@farben-ende), + * bewusst kopiert und nicht importiert: das Widget ist ein eigenstaendiges Browser-Skript. test/termineEmbedStatic.test.js + * haelt beide fuer 4096 Farben gleich; wer eine Seite aendert, aendert die andere mit. + */ + +const FARBE_RE = /^#[0-9a-fA-F]{6}$/ +const KNOPF_MAX = 40 +const MIN_RAHMEN = 3 // WCAG 2.1 AA fuer Bedienelemente und Rahmen (1.4.11): der Rahmen freier Tage steht in der Akzentfarbe + +// ---------------------------------------------------------------- Text + +/** Maskiert &, <, >, " fuer Text und Attributwerte in doppelten Anfuehrungszeichen. null und undefined werden zu ''. */ +export function esc(text) { + return String(text ?? '').replace(/[&<>"]/g, (z) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[z]) +} + +/** Knopf-Text fuer data-knopf: Zeilenumbrueche und Steuerzeichen zu Leerzeichen, hoechstens 40 Zeichen (nie mitten in einem Zeichen). */ +function knopfTextBereinigt(roh) { + if (typeof roh !== 'string') return '' + const eine = roh.replace(/\r\n/g, ' ').replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ').trim() + return Array.from(eine).slice(0, KNOPF_MAX).join('') +} + +// ---------------------------------------------------------------- Einbau-Code + +/** + * Einbau-Code fuer die Website: Platzhalter mit Ausweichtext (bleibt ohne JavaScript stehen) und das Skript. + * modus: 'inline' (Standard, die Strecke steht in der Seite) | 'knopf' (ein Knopf oeffnet einen Dialog); jeder andere Wert gilt + * als 'inline'. knopfText gilt nur im Modus knopf und nur, wenn er nicht leer ist; sonst nimmt das Widget "Termin buchen". + */ +export function einbauSnippet({ siteKey, apiBase, telefon, modus = 'inline', knopfText } = {}) { + const knopf = modus === 'knopf' ? knopfTextBereinigt(knopfText) : '' + const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.' + const attribute = [ + `data-webklar-termine="${esc(siteKey)}"`, + `data-api="${esc(apiBase)}"`, + `data-modus="${modus === 'knopf' ? 'knopf' : 'inline'}"`, + ...(knopf ? [`data-knopf="${esc(knopf)}"`] : []), + ] + return [ + `
`, + `

${ausweich}

`, + '
', + ``, + ].join('\n') +} + +// ---------------------------------------------------------------- Farben + +/** Relative Leuchtdichte nach WCAG 2.1 fuer '#rrggbb' (wie im Widget; erwartet eine gueltige Farbe). */ +export function luminanz(farbe) { + const rgb = [1, 3, 5].map((i) => { + const c = parseInt(farbe.slice(i, i + 2), 16) / 255 + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + }) + return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] +} + +/** Kontrastverhaeltnis zweier Leuchtdichten (1 bis 21), wie kontrast() im Widget. */ +const verhaeltnis = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) + +/** Kontrastverhaeltnis zweier Farben '#rrggbb' (1 bis 21, Reihenfolge egal). */ +export function kontrast(hexA, hexB) { + return verhaeltnis(luminanz(hexA), luminanz(hexB)) +} + +/** + * Schrift auf der Betriebsfarbe: Weiss oder das dunkle Grau, je nachdem was mehr Kontrast gibt; erreicht keines 4,5 : 1 + * (mittlere Toene), nimmt sie Schwarz (mindestens 4,58 : 1 fuer jede Farbe). 1:1 aus dem Widget. + */ +export function textFarbeAuf(farbe) { + const l = luminanz(farbe) + const weiss = verhaeltnis(l, 1) + const dunkel = verhaeltnis(l, luminanz('#111827')) + const beste = Math.max(weiss, dunkel) + if (beste < 4.5 && verhaeltnis(l, 0) > beste) return '#000000' + return weiss >= dunkel ? '#ffffff' : '#111827' +} + +/** Kontrast mit einer Nachkommastelle und deutschem Komma, abgerundet ("unter 3 : 1" bleibt auch im Text unter 3). */ +const kontrastAnzeige = (wert) => (Math.floor(wert * 10) / 10).toFixed(1).replace('.', ',') + +/** + * CSS-Vorschlag fuer die Website aus der Betriebsfarbe (Anleitung Abschnitt 3): Kommentar plus + * `[data-webklar-termine]{ --wkt-akzent; --wkt-akzent-text }`. Nur eine gueltige '#rrggbb' (gross oder klein) wird uebernommen; + * alles andere ersetzt VORGABE_FARBE, der Rohtext kommt nie in die Ausgabe. + * Rueckgabe: css, akzent (die verwendete Farbe, klein), akzentText (Schrift darauf), kontrastText (Akzent gegen Schrift), + * kontrastSeite (Akzent gegen Weiss), hinweise (Warnungen, leer wenn alles passt). Der Rahmen der freien Tage steht in der + * Akzentfarbe und braucht gegen den Hintergrund mindestens 3 : 1; das Widget hat standardmaessig einen weissen Hintergrund. + */ +export function cssVorschlag(farbe) { + const akzent = typeof farbe === 'string' && FARBE_RE.test(farbe) ? farbe.toLowerCase() : VORGABE_FARBE + const akzentText = textFarbeAuf(akzent) + const textKontrast = kontrast(akzent, akzentText) + const seitenKontrast = kontrast(akzent, '#ffffff') + const hinweise = [] + if (seitenKontrast < MIN_RAHMEN) { + hinweise.push( + `Kontrast gegen Weiß: nur ${kontrastAnzeige(seitenKontrast)} : 1. Der Rahmen der freien Tage steht in der Akzentfarbe ${akzent} und braucht ` + + `mindestens ${MIN_RAHMEN} : 1 gegen den Hintergrund. Auf weißem Grund wähle eine dunklere Farbe, auf dunklem Grund setze --wkt-hintergrund passend.` + ) + } + const css = [ + `/* Terminplaner: Farbe der Knöpfe und Schrift darauf, aus der Betriebsfarbe. Kontrast der Schrift: ${kontrastAnzeige(textKontrast)} : 1. */`, + '[data-webklar-termine]{', + ` --wkt-akzent:${akzent};`, + ` --wkt-akzent-text:${akzentText};`, + '}', + ].join('\n') + return { css, akzent, akzentText, kontrastText: textKontrast, kontrastSeite: seitenKontrast, hinweise } +} diff --git a/server/services/termine/einstellungen.js b/server/services/termine/einstellungen.js index 5478dae..1796bec 100644 --- a/server/services/termine/einstellungen.js +++ b/server/services/termine/einstellungen.js @@ -12,7 +12,7 @@ import { heute, zuUtc } from './zeit.js' * Fehler sind { [pfad]: text } mit Pfaden wie 'betrieb.name' oder 'ressourcen.0.plan.1'; Texte in Du-Form (Portal). */ -const VORGABE_FARBE = '#7c3aed' +export const VORGABE_FARBE = '#7c3aed' const GRENZEN = { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10, json: 200000 } const RASTER = [5, 10, 15, 20, 30, 60] const ERINNERUNG_STUNDEN = [2, 12, 24, 48] diff --git a/test/termineEinbauanleitung.test.js b/test/termineEinbauanleitung.test.js new file mode 100644 index 0000000..005fc79 --- /dev/null +++ b/test/termineEinbauanleitung.test.js @@ -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 & b < c > d "e"') + 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('

Termine telefonisch unter 06301 <12>.

'), mit) + assert.ok(!mit.includes('<12>')) + for (const telefon of [undefined, null, '']) { + assert.ok(einbauSnippet({ siteKey: SITE_KEY, apiBase: API, telefon }).includes('

Termine telefonisch vereinbaren.

'), String(telefon)) + } +}) + +test('Snippet: siteKey und Adresse sind maskiert (kein Ausbrechen aus dem Attribut)', () => { + const s = einbauSnippet({ siteKey: 'a">'), s) + assert.equal((s.match(/`)) +}) + +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], '"'.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;}', '#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', '', '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)`) + } +}) diff --git a/test/termineEmbedStatic.test.js b/test/termineEmbedStatic.test.js index d1b0524..c4baff9 100644 --- a/test/termineEmbedStatic.test.js +++ b/test/termineEmbedStatic.test.js @@ -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', () => {