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)`) } })