Ctrl K
ComunidadGuías…

test/chartpalette.test.js

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

Archivo original ↗Preguntar a KiBot
import { test } from "node:test";
import assert from "node:assert/strict";
import { listThemes, loadTheme } from "../src/theme.js";
import { chartSeries, contrast, ensureContrast, isMonochrome, rgbToHsl, hslToHex, deltaE } from "../src/chartpalette.js";

/**
 * The chart palette contract.
 *
 * A chart is the one place in the deck where colour carries data rather than
 * decoration, so "looks a bit flat" and "one series is missing" are the same
 * bug seen from different distances. These check the properties that make a
 * multi-series chart readable, across every theme, in both modes.
 */

const MIN_VS_BG = 3;        // WCAG non-text threshold; a bar is a large solid area
const MIN_BETWEEN = 1.35;   // adjacent series must not read as one

test("colour maths round-trips and ranks contrast the way WCAG does", () => {
  assert.equal(contrast("#000000", "#FFFFFF").toFixed(0), "21");
  assert.equal(contrast("#FFFFFF", "#FFFFFF").toFixed(0), "1");
  // Contrast is symmetric — the order of arguments must not matter.
  assert.equal(contrast("#123456", "#EEEEEE"), contrast("#EEEEEE", "#123456"));
  const hsl = rgbToHsl("#C05D4E");
  assert.equal(hslToHex(hsl), "#C05D4E");
});

test("ensureContrast moves a colour off the background, keeping its hue", () => {
  // White on white is the exact failure that made a series vanish.
  const fixed = ensureContrast("#FFFFFF", "#FFFFFF");
  assert.ok(contrast(fixed, "#FFFFFF") >= MIN_VS_BG, `got ${contrast(fixed, "#FFFFFF")}`);

  const onDark = ensureContrast("#111111", "#0B0E14");
  assert.ok(contrast(onDark, "#0B0E14") >= MIN_VS_BG);

  // A colour that already clears the floor is returned untouched.
  assert.equal(ensureContrast("#C05D4E", "#EBEBE6"), "#C05D4E");

  // Hue survives the lightness adjustment.
  const shifted = ensureContrast("#8FD4FF", "#FFFFFF");
  assert.ok(Math.abs(rgbToHsl(shifted).h - rgbToHsl("#8FD4FF").h) < 6);
});

test("a theme's declared series palette is used verbatim when it is legible", async () => {
  const theme = await loadTheme("warm-humanist");
  const declared = ["#123456", "#AA3311"];
  const withTokens = { ...theme, tokens: { ...theme.tokens, chart: { series: declared } } };
  const out = chartSeries(withTokens, 4);
  assert.equal(out[0], declared[0]);
  assert.equal(out[1], declared[1]);
});

test("a declared palette that runs out is extended, never repeated", async () => {
  // It used to cycle — `declared[i % len]` — which is harmless for series (the
  // schema caps those at four) and wrong for a pie, whose colours come from its
  // uncapped CATEGORIES. high-contrast-mono declares six, so an eight-slice pie
  // drew slices 7 and 8 in exactly the colours of slices 1 and 2.
  const theme = await loadTheme("warm-humanist");
  const declared = ["#123456", "#AA3311"];
  const withTokens = { ...theme, tokens: { ...theme.tokens, chart: { series: declared } } };
  const out = chartSeries(withTokens, 6);
  assert.equal(new Set(out).size, 6, `two slices would be drawn the same colour: ${out.join(" ")}`);
  // The wrapped entries stay recognisably the author's colour — same hue,
  // different value — rather than becoming something they did not pick.
  assert.equal(rgbToHsl(out[2]).h, rgbToHsl(declared[0]).h);
  assert.notEqual(out[2], out[0]);
});

test("no theme ever draws two series the same colour, at any slice count", async () => {
  // A pie's colours come from its categories and the schema does not cap them.
  const names = await listThemes();
  for (const name of names) {
    const theme = await loadTheme(name);
    for (const count of [4, 8, 12]) {
      const out = chartSeries(theme, count).map((c) => c.toUpperCase());
      assert.equal(new Set(out).size, count, `${name} at ${count}: ${out.join(" ")}`);
    }
  }
});

test("series stay perceptually apart, not merely different hex", async () => {
  // Hue gap alone was the old objective and it is the wrong one: two colours
  // 20 degrees apart at the same lightness read as one. dE below 2.3 is the
  // just-noticeable difference; below 10 is "similar".
  const names = await listThemes();
  let worst = { dE: Infinity };
  for (const name of names) {
    const theme = await loadTheme(name);
    for (const count of [4, 8, 12]) {
      const out = chartSeries(theme, count);
      for (let i = 0; i < out.length; i++) {
        for (let j = i + 1; j < out.length; j++) {
          const d = deltaE(out[i], out[j]);
          if (d < worst.dE) worst = { dE: d, name, count, pair: [out[i], out[j]] };
        }
      }
    }
  }
  assert.ok(worst.dE >= 10,
    `${worst.name} at ${worst.count} draws ${worst.pair?.join(" and ")} at dE ${worst.dE?.toFixed(1)}`);
});

test("every theme yields visible, distinguishable series in both modes", async () => {
  const names = await listThemes();
  assert.ok(names.length >= 30, `expected the full gallery, got ${names.length}`);

  const problems = [];
  for (const name of names) {
    for (const mode of ["light", "dark"]) {
      const theme = await loadTheme(name, { mode });
      const series = chartSeries(theme, 6);

      assert.equal(new Set(series).size, series.length, `${name}/${mode}: duplicate series colours`);

      for (const [i, c] of series.entries()) {
        const vsBg = contrast(c, theme.palette.bg);
        if (vsBg < MIN_VS_BG) {
          problems.push(`${name}/${mode}: series ${i + 1} ${c} is ${vsBg.toFixed(2)}:1 on ${theme.palette.bg}`);
        }
      }
      for (let i = 1; i < series.length; i++) {
        const between = contrast(series[i], series[i - 1]);
        // Hue-separated colours can share a lightness and still read apart, so
        // the neighbour rule only binds where hue does not do the work.
        const a = rgbToHsl(series[i - 1]);
        const b = rgbToHsl(series[i]);
        const hueGap = Math.abs(((b.h - a.h) % 360 + 360) % 360);
        const hueSeparated = Math.min(hueGap, 360 - hueGap) > 25 && a.s > 0.15 && b.s > 0.15;
        if (!hueSeparated && between < MIN_BETWEEN) {
          problems.push(`${name}/${mode}: series ${i} and ${i + 1} (${series[i - 1]}, ${series[i]}) differ by only ${between.toFixed(2)}:1`);
        }
      }
    }
  }
  assert.deepEqual(problems, [], `chart palette problems:\n  ${problems.join("\n  ")}`);
});

test("a monochrome theme is given a value ramp, not invented hues", () => {
  // Derivation must not colourise a deliberately colourless design. A theme
  // that wants colour on its charts asks for it explicitly — high-contrast-mono
  // does exactly that, because greys cannot carry four series at a readable
  // contrast and its fourth was vanishing into the page.
  const mono = { palette: { bg: "#FFFFFF", accent: "#000000", accent_alt: "#FFFFFF", ink: "#000000" }, tokens: {} };
  assert.ok(isMonochrome(mono.palette));
  for (const c of chartSeries(mono, 4)) {
    assert.ok(rgbToHsl(c).s < 0.05, `${c} carries hue in a monochrome theme`);
  }
});

test("high-contrast-mono declares real chart colours, and keeps ink in front", async () => {
  const theme = await loadTheme("high-contrast-mono");
  const series = chartSeries(theme, 4);
  // The reported bug: three identical blacks and an invisible white.
  assert.equal(new Set(series).size, 4);
  for (const c of series) assert.ok(contrast(c, theme.palette.bg) >= MIN_VS_BG);
  // Ink leads, so a one-series chart still reads as this theme.
  assert.ok(contrast(series[0], "#000000") < 2);
});