// @vitest-environment node /** * Die Kontrastzusicherungen des Token-Systems. * * tokens.css nennt in seinen Kommentaren „Gemessene Kontraste“. Bis 0.20.0 * hat diese Messung niemand wiederholt – und die Zahlen drifteten: #6fc3d6 * war mit 9,2:1 kommentiert und liefert 9,0:1, #b9bcc2 mit ≈2,0:1 und * liefert 1,8:1. --farbe-warnung verfehlte im hellen Thema mit 6,97:1 sogar * das 7:1-Ziel der Datei. Dieser Test rechnet nach, wie es * `tests/druck.test.ts` für die Druckfarben vormacht: Die Kommentare bleiben * Angaben und verkommen nicht zu Behauptungen. */ import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { startfarbeFuer, type SystemAnzeigeSignale } from '../src/shared/theme'; const wurzel = fileURLToPath(new URL('..', import.meta.url)); const TOKENS_CSS = readFileSync( join(wurzel, 'src', 'renderer', 'src', 'styles', 'tokens.css'), 'utf8', ); // ─── Die WCAG-Rechnung – Formel im Test, keine neue Abhängigkeit ──────── /** Relative Leuchtdichte nach WCAG 2.x. */ function leuchtdichte(hex: string): number { const kanal = (n: number): number => { const s = n / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }; const r = Number.parseInt(hex.slice(1, 3), 16); const g = Number.parseInt(hex.slice(3, 5), 16); const b = Number.parseInt(hex.slice(5, 7), 16); return 0.2126 * kanal(r) + 0.7152 * kanal(g) + 0.0722 * kanal(b); } function kontrast(vorne: string, hinten: string): number { const a = leuchtdichte(vorne); const b = leuchtdichte(hinten); return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); } // ─── tokens.css lesen: Themenblöcke und Kommentartabellen ─────────────── /** Alle `--variable: #rrggbb`-Paare eines Regelblocks. */ function farbenImBlock(block: string): ReadonlyMap { return new Map( [...block.matchAll(/--([a-z-]+):\s*(#[0-9a-f]{6})\b/gu)].map((t) => [t[1] ?? '', t[2] ?? '']), ); } function block(muster: RegExp): string { const treffer = muster.exec(TOKENS_CSS); if (treffer?.[1] === undefined) { throw new Error(`Regelblock nicht gefunden: ${String(muster)}`); } return treffer[1]; } const HELL = farbenImBlock(block(/:root,\s*:root\[data-thema='hell'\]\s*\{([^}]*)\}/u)); const DUNKEL = farbenImBlock(block(/:root\[data-thema='dunkel'\]\s*\{([^}]*)\}/u)); const HOCHKONTRAST = farbenImBlock(block(/:root\[data-thema='hochkontrast'\]\s*\{([^}]*)\}/u)); const DUNKEL_SYSTEM = farbenImBlock( block(/@media \(prefers-color-scheme: dark\)\s*\{\s*:root[^{]*\{([^}]*)\}/u), ); function pflicht(farben: ReadonlyMap, variable: string): string { const wert = farben.get(variable); if (wert === undefined) { throw new Error(`--${variable} fehlt im Themenblock oder ist keine Hex-Farbe.`); } return wert; } interface Kommentarzeile { readonly variable: string; readonly hex: string; readonly wert: number; } /** * Die Tabelle „Gemessene Kontraste gegen --farbe-grund (#…)“ über einem * Themenblock. Die Grundfarbe identifiziert die Tabelle – so bleibt die * Zuordnung auch dann richtig, wenn Blöcke umsortiert werden. */ function kommentartabelle(grundHex: string): readonly Kommentarzeile[] { const kopf = TOKENS_CSS.indexOf(`Gemessene Kontraste gegen --farbe-grund (${grundHex})`); if (kopf < 0) { throw new Error(`Keine Kommentartabelle für ${grundHex} gefunden.`); } const ausschnitt = TOKENS_CSS.slice(kopf, TOKENS_CSS.indexOf('====', kopf)); return [...ausschnitt.matchAll(/--([a-z-]+)\s+(#[0-9a-f]{6})\s+≈\s*(\d+(?:,\d+)?):1/gu)].map( (t) => ({ variable: t[1] ?? '', hex: t[2] ?? '', wert: Number((t[3] ?? '').replace(',', '.')), }), ); } const THEMEN = [ ['hell', '#f7f7f5', HELL], ['dunkel', '#14161a', DUNKEL], ] as const; // ─── Der Block für erzwungene Farben muss auch greifen ────────────────── describe('Das Windows-Kontrastdesign in tokens.css', () => { /* Bei erzwungenen Farben setzt `themeAufloesen` das Thema unbedingt auf `hochkontrast`, und `:root[data-thema='hochkontrast']` hat die höhere Spezifität. Bis 0.24.1 stand im `forced-colors`-Block nur `:root` – der ganze Block war damit wirkungslos, und die Anwendung behielt ihre eigenen Farben, wo sie die des Systems nehmen sollte. Nichts hat es gemeldet, weil die zwei Regeln mit `forced-color-adjust: none` ihre Systemfarben von Hand setzen. */ it('gewinnt gegen die Regel des eigenen Hochkontrast-Themas', () => { const block = /@media \(forced-colors: active\)\s*\{\s*([^{]+)\{/u.exec(TOKENS_CSS); expect(block, 'Der Block für erzwungene Farben fehlt in tokens.css.').not.toBeNull(); const selektoren = (block?.[1] ?? '').split(',').map((teil) => teil.trim()); /* Ein blosses `:root` (0,0,1) verliert gegen `:root[data-thema='hochkontrast']` (0,1,1). Es braucht mindestens einen Selektor mit Attribut. */ expect( selektoren.some((teil) => teil.startsWith(':root[data-thema')), `Selektoren im forced-colors-Block: ${selektoren.join(' | ')} – ` + 'ohne Attributselektor überstimmt ihn das eigene Hochkontrast-Thema.', ).toBe(true); }); it('bildet dieselben Token ab wie das eigene Hochkontrast-Thema', () => { /* Sonst bliebe bei erzwungenen Farben ein Token auf dem hausgemachten Wert stehen, ohne dass es jemandem auffiele. */ const block = /@media \(forced-colors: active\)\s*\{\s*[^{]+\{([\s\S]*?)\n {2}\}/u.exec(TOKENS_CSS)?.[1] ?? ''; const gesetzt = new Set([...block.matchAll(/--([\w-]+)\s*:/gu)].map((treffer) => treffer[1])); const fehlend = [...HOCHKONTRAST.keys()].filter( (name) => name.startsWith('farbe-') && !gesetzt.has(name), ); expect(fehlend, 'Diese Farbtoken fehlen im Block für erzwungene Farben.').toEqual([]); }); }); // ─── Die Kommentare stimmen mit dem Code überein ──────────────────────── describe('Die Kommentartabellen in tokens.css', () => { it.each(THEMEN)('Thema %s: die Tabelle nennt die Farben des Codes', (_thema, grund, farben) => { expect(pflicht(farben, 'farbe-grund')).toBe(grund); const tabelle = kommentartabelle(grund); /* Wer eine Zeile löscht, statt sie zu messen, fällt hier auf. */ for (const variable of [ 'farbe-text', 'farbe-text-leise', 'farbe-akzent', 'farbe-erfolg', 'farbe-warnung', 'farbe-fehler', 'farbe-rahmen-kraeftig', ]) { expect( tabelle.map((z) => z.variable), `Tabellenzeile für --${variable}`, ).toContain(variable); } for (const zeile of tabelle) { expect(pflicht(farben, zeile.variable), `--${zeile.variable}`).toBe(zeile.hex); } }); it.each(THEMEN)( 'Thema %s: die genannten Werte halten der Nachrechnung stand', (_thema, grund) => { for (const zeile of kommentartabelle(grund)) { const gemessen = kontrast(zeile.hex, grund); expect( Math.abs(gemessen - zeile.wert), `--${zeile.variable}: gemessen ${gemessen.toFixed(3)}, kommentiert ${String(zeile.wert)}`, ).toBeLessThanOrEqual(0.05); } }, ); it('Hochkontrast: das bezifferte Bernsteingelb stimmt', () => { /* Der Prosa-Kommentar über dem Block nennt „(#ffd400 ≈ 14,7:1)“ – dieselbe Rechnung, nur ein anderes Format. */ const treffer = /\(#([0-9a-f]{6}) ≈ (\d+,\d+):1\)/u.exec(TOKENS_CSS); if (treffer?.[1] === undefined || treffer[2] === undefined) { throw new Error('Der Hochkontrast-Kommentar beziffert die Akzentfarbe nicht mehr.'); } const hex = `#${treffer[1]}`; expect(hex).toBe(pflicht(HOCHKONTRAST, 'farbe-akzent')); expect( Math.abs( kontrast(hex, pflicht(HOCHKONTRAST, 'farbe-grund')) - Number(treffer[2].replace(',', '.')), ), ).toBeLessThanOrEqual(0.05); }); }); // ─── Die Ziele aus dem Dateikopf gelten in jedem Thema ────────────────── describe('Die Kontrastziele des Token-Systems', () => { const ALLE_THEMEN = [ ['hell', HELL], ['dunkel', DUNKEL], ['hochkontrast', HOCHKONTRAST], ] as const; it.each(ALLE_THEMEN)('Thema %s: Fließtextfarben erreichen 7:1 (AAA)', (_thema, farben) => { const grund = pflicht(farben, 'farbe-grund'); for (const variable of ['farbe-text', 'farbe-text-leise', 'farbe-akzent']) { expect(kontrast(pflicht(farben, variable), grund), `--${variable}`).toBeGreaterThanOrEqual(7); } }); it.each(ALLE_THEMEN)( 'Thema %s: Statusfarben erreichen 7:1 – sie tragen Text', (_thema, farben) => { /* Erfolg, Warnung und Fehler stehen als Schriftfarbe im Fließtext (lernen.css, pruefung.css), nicht nur auf Abzeichen – für sie gilt deshalb das Fließtextziel, nicht das UI-Minimum. */ const grund = pflicht(farben, 'farbe-grund'); for (const variable of ['farbe-erfolg', 'farbe-warnung', 'farbe-fehler']) { expect(kontrast(pflicht(farben, variable), grund), `--${variable}`).toBeGreaterThanOrEqual( 7, ); } }, ); it.each(THEMEN)( 'Thema %s: der kräftige Rahmen erreicht 3:1 (Non-text Contrast)', (_thema, _grundHex, farben) => { expect( kontrast(pflicht(farben, 'farbe-rahmen-kraeftig'), pflicht(farben, 'farbe-grund')), ).toBeGreaterThanOrEqual(3); }, ); it('nennt in shared/theme.ts dieselben Grundfarben wie hier', () => { /* `startfarbeFuer` liefert die Fensterfarbe für den Augenblick vor dem ersten Frame. Sie steht zwangsläufig ein zweites Mal in TypeScript: Der Hauptprozess setzt sie, bevor es ein Dokument gibt, das dieses Stilblatt laden könnte. Eine Kopie, die niemand vergleicht, läuft auseinander – dann blitzte beim Start ein Farbton auf, den es in der Anwendung gar nicht gibt. */ const ohneSystemsignale: SystemAnzeigeSignale = { bevorzugtDunkel: false, erzwungeneFarben: false, bevorzugtMehrKontrast: false, }; for (const [thema, farben] of [ ['hell', HELL], ['dunkel', DUNKEL], ['hochkontrast', HOCHKONTRAST], ] as const) { expect(startfarbeFuer(thema, ohneSystemsignale), `Startfarbe für ${thema}`).toBe( pflicht(farben, 'farbe-grund'), ); } }); it('führt die Systemvorgabe „dunkel“ mit derselben Palette wie das manuelle Thema', () => { /* Die dunkle Palette steht zweimal in der Datei – einmal hinter `data-thema='dunkel'`, einmal hinter `prefers-color-scheme`. Läuft eine Kopie davon, gälten alle Messungen nur noch für die andere. */ expect(Object.fromEntries(DUNKEL_SYSTEM)).toEqual(Object.fromEntries(DUNKEL)); }); }); /* ======================================================================= Die eine Zusicherung, die keine Zahl ist: „nur dekorativ“ `--farbe-rahmen` misst 1,77:1 gegen den hellen Grund. Das ist erlaubt, solange der Token Karten begrenzt, Abschnitte trennt und Tabellenzeilen unterlegt – Zierrat im Sinne von WCAG 1.4.11, für den kein Mindestwert gilt. Zeichnete er dagegen die sichtbare Grenze eines BEDIENELEMENTS, verlangte dieselbe Erfolgsbedingung dort 3:1, und der Klammerzusatz in tokens.css wäre schlicht falsch. Die vorige Welle hat das von Hand stichprobenartig durchgesehen. Eine Stichprobe ist keine Zusicherung: Der nächste Rahmen, der an einem Knopf landet, fiele niemandem auf. Deshalb wird hier gerechnet statt geglaubt – über alle Stilblätter und gegen die Elemente, auf denen die betroffenen Klassen im JSX tatsächlich sitzen. Gefunden hat diese Rechnung beim ersten Lauf `.profilfrage__wahl`: ein `label`, das den Radioknopf der Startfrage als anklickbare Karte umfasst (`cursor: pointer`, die ganze Fläche schaltet). Sein Rahmen IST die Grenze des Bedienelements. Behoben durch --farbe-rahmen-kraeftig, nicht durch eine mildere Regel. ======================================================================= */ const STILE = join(wurzel, 'src', 'renderer', 'src', 'styles'); const RENDERER = join(wurzel, 'src', 'renderer', 'src'); function dateienUnter(verzeichnis: string, endung: string): readonly string[] { const gefunden: string[] = []; for (const eintrag of readdirSync(verzeichnis, { withFileTypes: true })) { const pfad = join(verzeichnis, eintrag.name); if (eintrag.isDirectory()) { gefunden.push(...dateienUnter(pfad, endung)); } else if (eintrag.name.endsWith(endung)) { gefunden.push(pfad); } } return gefunden; } // ─── Was im JSX ein Bedienelement ist ────────────────────────────────── /** * Elemente, deren sichtbare Grenze WCAG 1.4.11 unterstellt ist. * * `label` steht bewusst dabei: Ein Beschriftungselement mit eigenem Rahmen * ist die Trefffläche seines Feldes – ein Klick darauf schaltet. Nicht dabei * sind `fieldset` und `details`: Sie fassen zusammen, statt zu schalten; das * Schaltbare darin (`summary`, die Felder) trägt seine eigene Grenze. */ const BEDIENELEMENTE: ReadonlySet = new Set([ 'a', 'button', 'input', 'label', 'option', 'select', 'summary', 'textarea', ]); const BEDIENROLLEN: ReadonlySet = new Set([ 'button', 'checkbox', 'combobox', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem', ]); interface Marke { /** Kleingeschriebener HTML-Elementname, etwa `label`. */ readonly tag: string; /** Der Attributtext des öffnenden Tags, roh. */ readonly attribute: string; readonly datei: string; } /** * Findet die öffnenden HTML-Tags einer TSX-Datei mit ihrem Attributtext. * * Kein Parser, aber auch keine Regex über das ganze Tag: Attributwerte * enthalten Pfeilfunktionen (`() => …`), und ein `>` darin beendet kein Tag. * Deshalb wird ab dem Tagnamen zeichenweise gezählt – ein `>` schließt nur * außerhalb von `{}` und außerhalb von Zeichenketten. */ function markenIn(quelle: string, datei: string): readonly Marke[] { const gefunden: Marke[] = []; for (const anfang of quelle.matchAll(/<([a-z][a-z0-9]*)(?=[\s/>])/gu)) { const tag = anfang[1] ?? ''; const nachDemNamen = anfang.index + anfang[0].length; let i = nachDemNamen; let tiefe = 0; let anfuehrung: string | null = null; for (; i < quelle.length; i += 1) { const z = quelle[i] ?? ''; if (anfuehrung !== null) { if (z === anfuehrung) { anfuehrung = null; } continue; } if (z === '"' || z === "'" || z === '`') { anfuehrung = z; } else if (z === '{') { tiefe += 1; } else if (z === '}') { tiefe -= 1; } else if (z === '>' && tiefe === 0) { break; } } gefunden.push({ tag, attribute: quelle.slice(nachDemNamen, i), datei }); } return gefunden; } /** Die Klassennamen aus `className="…"` bzw. `className={…}` eines Tags. */ function klassenIn(attribute: string): readonly string[] { const start = /className\s*=\s*/u.exec(attribute); if (!start) { return []; } const ab = attribute.slice(start.index + start[0].length); let ausdruck: string; if (ab.startsWith('{')) { let tiefe = 0; let bis = 0; for (; bis < ab.length; bis += 1) { if (ab[bis] === '{') { tiefe += 1; } else if (ab[bis] === '}') { tiefe -= 1; if (tiefe === 0) { break; } } } ausdruck = ab.slice(0, bis + 1); } else { ausdruck = /^(["'])(?:(?!\1).)*\1/su.exec(ab)?.[0] ?? ''; } /* Aus dem Ausdruck zählt nur, was in Anführungszeichen steht – Bedingungen und Variablennamen nicht. */ return zeichenketten(ausdruck) .flatMap((stueck) => stueck.split(/\s+/u)) .filter((name) => /^[A-Za-z][\w-]*$/u.test(name)); } /** * Die Zeichenketten eines JSX-Ausdrucks, Schablonenliterale eingeschlossen. * * Ohne diesen Durchgang bliebe `` `fussbereich${knapp ? ' …--knapp' : ''}` `` * unlesbar, und die Klasse fiele stillschweigend aus der Prüfung – genau die * Lücke, die die Zuordnungszusicherung oben aufdeckt. */ function zeichenketten(ausdruck: string): readonly string[] { const stuecke: string[] = []; let i = 0; while (i < ausdruck.length) { const z = ausdruck[i]; if (z === '"' || z === "'") { const ende = ausdruck.indexOf(z, i + 1); if (ende < 0) { break; } stuecke.push(ausdruck.slice(i + 1, ende)); i = ende + 1; continue; } if (z !== '`') { i += 1; continue; } let j = i + 1; let fest = ''; while (j < ausdruck.length && ausdruck[j] !== '`') { if (ausdruck[j] === '$' && ausdruck[j + 1] === '{') { stuecke.push(fest); fest = ''; let tiefe = 1; let k = j + 2; for (; k < ausdruck.length && tiefe > 0; k += 1) { if (ausdruck[k] === '{') { tiefe += 1; } else if (ausdruck[k] === '}') { tiefe -= 1; } } /* Die Einsetzung ist wieder ein Ausdruck – dieselbe Behandlung. */ stuecke.push(...zeichenketten(ausdruck.slice(j + 2, k - 1))); j = k; continue; } fest += ausdruck[j] ?? ''; j += 1; } stuecke.push(fest); i = j + 1; } return stuecke; } const KLASSENVERZEICHNIS: ReadonlyMap = (() => { const verzeichnis = new Map(); for (const datei of dateienUnter(RENDERER, '.tsx')) { for (const marke of markenIn(readFileSync(datei, 'utf8'), datei)) { for (const klasse of klassenIn(marke.attribute)) { const bisher = verzeichnis.get(klasse); if (bisher) { bisher.push(marke); } else { verzeichnis.set(klasse, [marke]); } } } } return verzeichnis; })(); function istBedienelement(marke: Marke): boolean { if (BEDIENELEMENTE.has(marke.tag)) { return true; } const rolle = /\brole\s*=\s*["']([a-z]+)["']/u.exec(marke.attribute)?.[1]; if (rolle !== undefined && BEDIENROLLEN.has(rolle)) { return true; } if (/\bonClick\s*=/u.test(marke.attribute)) { return true; } /* `tabIndex={-1}` macht eine Überschrift anspringbar, kein Bedienelement; eine 0 dagegen setzt das Element in die Tabulatorreihenfolge. */ return /\btabIndex\s*=\s*\{\s*0\s*\}/u.test(marke.attribute); } // ─── Wo --farbe-rahmen im Stilblatt eine Grenze zeichnet ──────────────── interface Fundstelle { readonly datei: string; readonly selektor: string; readonly deklaration: string; } /** Eigenschaften, die eine sichtbare Grenze zeichnen. */ const GRENZEIGENSCHAFT = /^(?:border|outline|column-rule)(?:-[a-z-]+)?$|^box-shadow$|^(?:-webkit-)?text-decoration-color$/u; function fundstellen(): readonly Fundstelle[] { const gefunden: Fundstelle[] = []; for (const datei of dateienUnter(STILE, '.css')) { const quelle = readFileSync(datei, 'utf8').replace(/\/\*[\s\S]*?\*\//gu, ''); /* Blattregeln: ein Rumpf ohne weitere geschweifte Klammern. Verschachtelte At-Regeln (`@media`) scheitern an diesem Muster und geben dabei ihre inneren Regeln mit dem richtigen Selektor frei. */ for (const regel of quelle.matchAll(/([^{}]+)\{([^{}]*)\}/gu)) { const rumpf = regel[2] ?? ''; for (const deklaration of rumpf.split(';')) { const doppelpunkt = deklaration.indexOf(':'); if (doppelpunkt < 0) { continue; } const eigenschaft = deklaration.slice(0, doppelpunkt).trim(); const wert = deklaration.slice(doppelpunkt + 1); if (!GRENZEIGENSCHAFT.test(eigenschaft) || !wert.includes('var(--farbe-rahmen)')) { continue; } for (const selektor of (regel[1] ?? '').split(',')) { if (selektor.trim() !== '') { gefunden.push({ datei, selektor: selektor.trim().replace(/\s+/gu, ' '), deklaration: deklaration.trim().replace(/\s+/gu, ' '), }); } } } } } return gefunden; } /** * Der rechte Teil eines Selektors – das Element, das die Regel bemalt. * `.a .b:hover` betrifft `.b`, nicht `.a`. */ function schluesselteil(selektor: string): string { const teile = selektor.split(/[\s>+~]+/u).filter((t) => t !== ''); const letzter = teile[teile.length - 1] ?? ''; return letzter.split(':')[0] ?? ''; } const FUNDSTELLEN = fundstellen(); describe('Die Zusicherung „nur dekorativ“ an --farbe-rahmen', () => { it('findet den Token überhaupt in den Stilblättern', () => { /* Eine leere Prüfmenge bestünde jede Zusicherung. Der Token wird in mehreren Dateien benutzt; sinkt die Zahl auf null, ist nicht der Rahmen sauber, sondern dieses Muster kaputt. */ expect(FUNDSTELLEN.length).toBeGreaterThan(10); expect(new Set(FUNDSTELLEN.map((f) => f.datei)).size).toBeGreaterThan(1); }); it('ordnet jede betroffene Klasse einem Element im JSX zu', () => { /* Ohne diese Zusicherung liefe die eigentliche Prüfung ins Leere, sobald eine Klasse umbenannt wird: Unbekannt hieße dann stillschweigend „unbedenklich“. */ const unbekannt = new Set(); for (const stelle of FUNDSTELLEN) { for (const klasse of schluesselteil(stelle.selektor).matchAll(/\.([A-Za-z][\w-]*)/gu)) { const name = klasse[1] ?? ''; if (!KLASSENVERZEICHNIS.has(name)) { unbekannt.add(`${name} (${stelle.selektor} in ${stelle.datei})`); } } } expect([...unbekannt]).toEqual([]); }); it('zeichnet an keiner Stelle die Grenze eines Bedienelements', () => { const verstoesse: string[] = []; for (const stelle of FUNDSTELLEN) { const kern = schluesselteil(stelle.selektor); const element = /^[a-z][a-z0-9]*/u.exec(kern)?.[0]; if (element !== undefined && BEDIENELEMENTE.has(element)) { verstoesse.push( `${stelle.selektor} { ${stelle.deklaration} } – <${element}> in ${stelle.datei}`, ); continue; } for (const klasse of kern.matchAll(/\.([A-Za-z][\w-]*)/gu)) { const name = klasse[1] ?? ''; for (const marke of KLASSENVERZEICHNIS.get(name) ?? []) { if (istBedienelement(marke)) { verstoesse.push( `${stelle.selektor} { ${stelle.deklaration} } in ${stelle.datei} – ` + `.${name} sitzt auf <${marke.tag}> in ${marke.datei}`, ); } } } } expect( verstoesse, 'WCAG 1.4.11 verlangt an Bedienelementen 3:1 – hier gehört --farbe-rahmen-kraeftig hin.', ).toEqual([]); }); });