// @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<string, string> {
  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<string, string>, 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<string> = new Set([
  'a',
  'button',
  'input',
  'label',
  'option',
  'select',
  'summary',
  'textarea',
]);

const BEDIENROLLEN: ReadonlySet<string> = 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<string, readonly Marke[]> = (() => {
  const verzeichnis = new Map<string, Marke[]>();
  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<string>();
    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([]);
  });
});
