Ctrl K
ComunidadGuías…

test/theme-tokens.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';

// The xterm light palette in terminal.js hardcodes colors that must track the
// CSS light-theme tokens in style.css — a drift makes the theme toggle look
// broken (terminal pane a different cream than the panels around it).
// DESIGN.md carries a third copy of the palette, so it is checked too.

const css = readFileSync(new URL('../public/style.css', import.meta.url), 'utf8');
const termSrc = readFileSync(new URL('../public/modules/terminal.js', import.meta.url), 'utf8');
const design = readFileSync(new URL('../DESIGN.md', import.meta.url), 'utf8');

function cssBlock(selector) {
  const m = css.match(new RegExp(`${selector}\\s*\\{([^}]*)\\}`));
  expect(m, `no ${selector} block found in style.css`).toBeTruthy();
  return m[1];
}

function cssToken(name) {
  const m = cssBlock('\\[data-theme="light"\\]').match(new RegExp(`${name}:\\s*(#[0-9a-fA-F]{3,6})`));
  expect(m, `token ${name} not found in the light theme block`).toBeTruthy();
  return m[1].toLowerCase();
}

function termLightValue(key) {
  // The light palette is the `? { ... }` arm of getTerminalTheme's ternary.
  const start = termSrc.indexOf('? {');
  const end = termSrc.indexOf(': {');
  expect(start > -1 && end > start, 'light-theme ternary arm not found in terminal.js').toBe(true);
  const m = termSrc.slice(start, end).match(new RegExp(`${key}: '(#[0-9a-fA-F]{3,6})'`));
  expect(m, `${key} not found in the light terminal palette`).toBeTruthy();
  return m[1].toLowerCase();
}

describe('light theme tokens stay in sync between style.css and terminal.js', () => {
  it('terminal background matches --bg-terminal', () => {
    expect(termLightValue('background')).toBe(cssToken('--bg-terminal'));
  });

  it('terminal foreground matches --text', () => {
    expect(termLightValue('foreground')).toBe(cssToken('--text'));
  });

  it('terminal cursor matches --accent', () => {
    expect(termLightValue('cursor')).toBe(cssToken('--accent'));
  });

  it('terminal selection is visible against the terminal background', () => {
    // Selection deliberately does NOT reuse a surface token: it must contrast
    // with --bg-terminal or selected text gives no feedback.
    expect(termLightValue('selectionBackground')).not.toBe(cssToken('--bg-terminal'));
  });
});

describe('light theme block covers every :root token', () => {
  it('every :root custom property has a light override', () => {
    // A token added to :root but missed here silently keeps its dark value on
    // the cream background — the exact bug class this palette retune fixed.
    const names = (block) => [...block.matchAll(/(--[\w-]+):/g)].map((m) => m[1]);
    const light = new Set(names(cssBlock('\\[data-theme="light"\\]')));
    for (const name of names(cssBlock(':root'))) {
      expect(light, `missing light override for ${name}`).toContain(name);
    }
  });
});

describe('DESIGN.md light palette matches style.css', () => {
  it('every token listed in the DESIGN.md light theme fence matches the CSS value', () => {
    const fence = design.match(/### Light Theme[^`]*```css\n([\s\S]*?)```/);
    expect(fence, 'light theme code fence not found in DESIGN.md').toBeTruthy();
    const entries = [...fence[1].matchAll(/(--[\w-]+):\s+(#[0-9a-fA-F]{3,6})/g)];
    expect(entries.length).toBeGreaterThan(0);
    for (const [, name, value] of entries) {
      expect(cssToken(name), `DESIGN.md drifted for ${name}`).toBe(value.toLowerCase());
    }
  });
});

describe('reduced-motion overrides win the cascade', () => {
  // Value: protects=prefers-reduced-motion stops every animation it names; fails_when=a normal rule setting `animation` on the same selector sits after the @media block (equal specificity, later wins — the #167 voice-indicator-dot bug); why_new=nothing checked CSS source order; seam=none
  // Matches identical selector text only: a later, differently written selector
  // that reaches the same element (or an animation-name longhand) is not caught.
  it('every `animation: none` under prefers-reduced-motion comes after the rules it overrides', () => {
    const media = [];
    for (const m of css.matchAll(/@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{/g)) {
      let depth = 1, i = m.index + m[0].length;
      while (depth) depth += { '{': 1, '}': -1 }[css[i++]] || 0;
      media.push([m.index, i]);
    }
    const inMedia = (pos) => media.find(([s, e]) => pos >= s && pos < e);
    const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({
      pos: m.index,
      selectors: m[1].replace(/\/\*[\s\S]*?\*\//g, '').split(',').map((s) => s.trim()),
      anim: m[2].match(/(?:^|;|\s)animation:\s*([^;]+)/)?.[1].trim(),
    })).filter((r) => r.anim);
    const overrides = rules.filter((r) => r.anim === 'none' && inMedia(r.pos));
    expect(overrides.length).toBeGreaterThan(0);
    for (const o of overrides) {
      for (const sel of o.selectors) {
        const later = rules.find((r) => r.pos > o.pos && !inMedia(r.pos) && r.anim !== 'none' && r.selectors.includes(sel));
        expect(later, `${sel} re-sets animation after its reduced-motion override`).toBeUndefined();
      }
    }
  });
});