diff --git a/docs/README.md b/docs/README.md index b4a1f0c..90dd503 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,7 +57,8 @@ - production registry содержит 122 инструмента; - `pnpm --dir web check` проходит без ошибок и предупреждений; -- `pnpm --dir web test` — 43 test files / 465 tests; +- `pnpm --dir web test` — 45 test files / 497 tests, включая тесты token audit и + PostCSS HCT-плагина; - `pnpm --dir web test:rules` — 4 test files / 74 tests; - `pnpm --dir web lint:all` — 0 errors, 0 i18n warnings и 0 unused design tokens от token audit; diff --git a/docs/plan-codebase-quality.md b/docs/plan-codebase-quality.md index 8f0b337..b70b219 100644 --- a/docs/plan-codebase-quality.md +++ b/docs/plan-codebase-quality.md @@ -406,14 +406,39 @@ file-result-инструментам. - Второй пункт («после очистки решить, должен ли unused audit быть fail-режимом») остаётся открытым: сам audit пока только сообщает. -#### Q7b–Q7d. Оставшиеся направления +#### Q7b. Тесты token audit и PostCSS HCT-плагина + +**Статус:** выполнен. У обоих не было ни одного теста, хотя HCT-плагин +(`web/scripts/postcss-hct.mjs`, 430 строк) пересчитывает всю палитру. + +- `web/scripts/__tests__/postcss-hct.test.mjs` — 17 тестов: literal и + производный `hct()`, seed из `:root` и из hex, цепочка hct-токенов, refs + `h/c/t`, `calc/min/max/clamp`, обёртка hue, клампы tone/chroma, несколько + вызовов в одном объявлении, а также warn-and-keep для неизвестного seed, + циклической ссылки и битого тела `hct()`. +- `web/scripts/__tests__/token-audit.test.mjs` — 15 тестов на parity + (light-only, dark-only, derived, не-цвет), authorship (hct, color-mix, + seed-токены, хардкод с локацией, игнор не-цветов и обычных свойств), unused и + предикаты `isColorValue`/`isDerived`. +- Ожидания не зашивают вывод плагина, а сравнивают его с эквивалентной + литеральной записью: `hct(from var(--seed) h c calc(t + 10))` должен совпасть + с `hct(120 40 60)`, если seed — `hct(120 40 50)`. Иначе тест просто + зафиксировал бы текущее поведение. +- Единственный seam: `checkUnused(root, used = collectUsedTokens())` — + зависимость от чтения `src` стала инъецируемой, production-вызов не изменился. +- `vitest.config.ts` подхватывает `scripts/**/*.test.mjs`, поэтому скрипты + покрываются обычным прогоном и `verify`; `test:rules` не трогали. +- Проверка мутацией выявила дыру: первая версия тестов не ловила кламп tone в + ветке `from` (смена `clamp(t, 0, 100)` на `0, 90` проходила зелёной). Добавлен + отдельный тест на производный кламп; после этого мутация падает. + +#### Q7c–Q7d. Оставшиеся направления -- Добавить тесты для token audit и PostCSS HCT-плагина: у них сейчас нет - покрытия, `test:rules` их не видит. - Покрыть базовым JS recommended сам код ESLint-плагинов и lint-скриптов. - Отдельно решить, должен ли обычный CSS проходить проверку undefined tokens и hardcoded colors наравне со Svelte styles. - Новые правила подключать только после fixtures, `test:rules` и baseline. +- Решить, должен ли unused audit стать fail-режимом (см. Q7a). ### Q8. Новые автоматические правила diff --git a/web/eslint-plugins/README.md b/web/eslint-plugins/README.md index 0ec41b1..02cecc8 100644 --- a/web/eslint-plugins/README.md +++ b/web/eslint-plugins/README.md @@ -17,6 +17,8 @@ - `lint-all.mjs` — оркестратор `lint:all`. - `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`. - `postcss-hct.mjs` — постcss-плагин эммита `hct()` в sRGB-hex. + - `__tests__/` — тесты аудита и HCT-плагина; подхватываются обычным + `vitest run` через `scripts/**/*.test.mjs`, поэтому входят в `verify`. - `web/stylelint.config.js`, `web/postcss.config.js` — конфиги stylelint/css. ## Конфигурация ESLint (`web/eslint.config.js`) diff --git a/web/scripts/__tests__/postcss-hct.test.mjs b/web/scripts/__tests__/postcss-hct.test.mjs new file mode 100644 index 0000000..2f0ff63 --- /dev/null +++ b/web/scripts/__tests__/postcss-hct.test.mjs @@ -0,0 +1,217 @@ +import postcss from "postcss"; +import { describe, expect, it } from "vitest"; +import postcssHct from "../postcss-hct.mjs"; + +async function process(css) { + const result = await postcss([postcssHct]).process(css, { from: undefined }); + return { + css: result.css, + warnings: result.warnings().map((warning) => warning.text), + }; +} + +function valueOf(css, token) { + const match = css.match(new RegExp(`${token}:\\s*([^;]+)`)); + if (!match) throw new Error(`token ${token} not found in:\n${css}`); + return match[1].trim(); +} + +function isHexColor(value) { + return /^#[0-9a-f]{6}$/i.test(value); +} + +describe("postcss-hct", () => { + it("resolves a literal hct() to an sRGB hex color", async () => { + const { css, warnings } = await process( + ":root { --black: hct(0 0 0); --white: hct(0 0 100); }", + ); + + expect(valueOf(css, "--black")).toBe("#000000"); + expect(valueOf(css, "--white")).toBe("#ffffff"); + expect(warnings).toEqual([]); + }); + + it("keeps tone monotonic for achromatic colors", async () => { + const { css } = await process( + ":root { --low: hct(0 0 25); --mid: hct(0 0 50); --high: hct(0 0 75); }", + ); + const tone = (token) => Number.parseInt(valueOf(css, token).slice(1), 16); + + expect(tone("--low")).toBeLessThan(tone("--mid")); + expect(tone("--mid")).toBeLessThan(tone("--high")); + }); + + it("wraps hue into the 0-360 range", async () => { + const { css } = await process( + [ + ":root {", + " --base: hct(350 40 50);", + " --negative: hct(-10 40 50);", + " --overflow: hct(370 40 50);", + " --ten: hct(10 40 50);", + "}", + ].join("\n"), + ); + + expect(valueOf(css, "--negative")).toBe(valueOf(css, "--base")); + expect(valueOf(css, "--overflow")).toBe(valueOf(css, "--ten")); + }); + + it("clamps tone into 0-100", async () => { + const { css } = await process( + ":root { --over: hct(0 0 150); --under: hct(0 0 -20); }", + ); + + expect(valueOf(css, "--over")).toBe("#ffffff"); + expect(valueOf(css, "--under")).toBe("#000000"); + }); + + it("clamps tone derived from a seed into 0-100", async () => { + const { css } = await process( + [ + ":root {", + " --seed: hct(120 40 50);", + " --too-light: hct(from var(--seed) h c calc(t + 80));", + " --expected-light: hct(120 40 100);", + " --too-dark: hct(from var(--seed) h c calc(t - 80));", + " --expected-dark: hct(120 40 0);", + "}", + ].join("\n"), + ); + + expect(valueOf(css, "--too-light")).toBe(valueOf(css, "--expected-light")); + expect(valueOf(css, "--too-dark")).toBe(valueOf(css, "--expected-dark")); + }); + + it("takes h, c and t from a seed token", async () => { + const { css } = await process( + ":root { --seed: hct(120 40 50); --copy: hct(from var(--seed) h c t); }", + ); + + expect(valueOf(css, "--copy")).toBe(valueOf(css, "--seed")); + }); + + it("supports channel math through calc()", async () => { + const { css } = await process( + [ + ":root {", + " --seed: hct(120 40 50);", + " --lighter: hct(from var(--seed) h c calc(t + 10));", + " --expected: hct(120 40 60);", + "}", + ].join("\n"), + ); + + expect(valueOf(css, "--lighter")).toBe(valueOf(css, "--expected")); + }); + + it("supports min, max and clamp over channels", async () => { + const { css } = await process( + [ + ":root {", + " --seed: hct(120 40 50);", + " --capped: hct(from var(--seed) h c clamp(10, t, 45));", + " --expected: hct(120 40 45);", + " --smallest: hct(from var(--seed) h c min(t, 20));", + " --expected-smallest: hct(120 40 20);", + "}", + ].join("\n"), + ); + + expect(valueOf(css, "--capped")).toBe(valueOf(css, "--expected")); + expect(valueOf(css, "--smallest")).toBe( + valueOf(css, "--expected-smallest"), + ); + }); + + it("resolves a hex seed through a variable and inline alike", async () => { + const { css } = await process( + [ + ":root {", + " --seed: #336699;", + " --via-var: hct(from var(--seed) h c t);", + " --inline: hct(from #336699 h c t);", + "}", + ].join("\n"), + ); + + expect(isHexColor(valueOf(css, "--via-var"))).toBe(true); + expect(valueOf(css, "--via-var")).toBe(valueOf(css, "--inline")); + }); + + it("resolves seeds that are themselves hct() tokens", async () => { + const { css } = await process( + [ + ":root {", + " --first: hct(200 30 60);", + " --second: hct(from var(--first) h c t);", + "}", + ].join("\n"), + ); + + expect(valueOf(css, "--second")).toBe(valueOf(css, "--first")); + }); + + it("uses :root seeds inside other selectors", async () => { + const { css } = await process( + [ + ":root { --seed: hct(90 50 40); }", + ".dark { --from-root: hct(from var(--seed) h c t); }", + ].join("\n"), + ); + + expect(valueOf(css, "--from-root")).toBe(valueOf(css, "--seed")); + }); + + it("resolves every hct() call inside one declaration", async () => { + const { css } = await process(":root { --pair: hct(0 0 10) hct(0 0 90); }"); + + expect(valueOf(css, "--pair")).toBe( + `${valueOf(css, "--pair").split(" ")[0]} ${valueOf(css, "--pair").split(" ")[1]}`, + ); + expect(valueOf(css, "--pair").split(" ")).toHaveLength(2); + }); + + it("leaves values without hct() untouched", async () => { + const source = ":root { --size: 12px; --name: red; --ref: var(--size); }"; + const { css } = await process(source); + + expect(css).toContain("--size: 12px"); + expect(css).toContain("--name: red"); + expect(css).toContain("--ref: var(--size)"); + }); + + it("warns and keeps the value when the seed is unknown", async () => { + const { css, warnings } = await process( + ":root { --orphan: hct(from var(--missing) h c t); }", + ); + + expect(valueOf(css, "--orphan")).toBe("hct(from var(--missing) h c t)"); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("could not resolve"); + }); + + it("warns and keeps the value on a circular reference", async () => { + const { css, warnings } = await process( + [ + ":root {", + " --ping: hct(from var(--pong) h c t);", + " --pong: hct(from var(--ping) h c t);", + "}", + ].join("\n"), + ); + + expect(valueOf(css, "--ping")).toBe("hct(from var(--pong) h c t)"); + expect(warnings).toHaveLength(2); + expect(warnings.every((text) => text.includes("could not resolve"))).toBe( + true, + ); + }); + + it("warns and keeps the value for a malformed hct() body", async () => { + const { css, warnings } = await process(":root { --bad: hct(1 2); }"); + + expect(valueOf(css, "--bad")).toBe("hct(1 2)"); + expect(warnings).toHaveLength(1); + }); +}); diff --git a/web/scripts/__tests__/token-audit.test.mjs b/web/scripts/__tests__/token-audit.test.mjs new file mode 100644 index 0000000..0dbcbe0 --- /dev/null +++ b/web/scripts/__tests__/token-audit.test.mjs @@ -0,0 +1,162 @@ +import postcss from "postcss"; +import { describe, expect, it } from "vitest"; +import { checkColorAuthorship } from "../token-audit/colors.mjs"; +import { + collectTokens, + isColorValue, + isDerived, +} from "../token-audit/helpers.mjs"; +import { checkParity } from "../token-audit/parity.mjs"; +import { checkUnused } from "../token-audit/unused.mjs"; + +function themes(css) { + const root = postcss.parse(css); + return { + root, + light: collectTokens(root, ":root"), + dark: collectTokens(root, '[data-theme="dark"]'), + }; +} + +describe("checkParity", () => { + it("accepts a color token that has both themes", () => { + const { light, dark } = themes( + ':root { --color-text: #111; } [data-theme="dark"] { --color-text: #eee; }', + ); + + expect(checkParity({ light, dark })).toEqual([]); + }); + + it("reports a light-only color token", () => { + const { light, dark } = themes(":root { --color-text: #111; }"); + + expect(checkParity({ light, dark })).toEqual([ + ' --color-text in :root has no [data-theme="dark"] override', + ]); + }); + + it("reports a dark-only color token as orphaned", () => { + const { light, dark } = themes( + '[data-theme="dark"] { --color-text: #eee; }', + ); + + expect(checkParity({ light, dark })).toEqual([ + ' --color-text in [data-theme="dark"] is missing in :root', + ]); + }); + + it("exempts derived tokens because they follow the theme", () => { + const { light, dark } = themes( + ":root { --color-text: hct(from var(--brand-main) h c t); }", + ); + + expect(checkParity({ light, dark })).toEqual([]); + }); + + it("exempts non-color tokens", () => { + const { light, dark } = themes(":root { --space-page-top: 48px; }"); + + expect(checkParity({ light, dark })).toEqual([]); + }); +}); + +describe("checkColorAuthorship", () => { + it("accepts hct() and color-mix()", () => { + const { root } = themes( + [ + ":root {", + " --color-main: hct(160 80 48);", + " --color-mixed: color-mix(in oklch, var(--color-main), #fff);", + "}", + ].join("\n"), + ); + + expect(checkColorAuthorship(root)).toEqual([]); + }); + + it("accepts seed tokens in any format", () => { + const { root } = themes(":root { --brand-main: #ff8800; }"); + + expect(checkColorAuthorship(root)).toEqual([]); + }); + + it("reports a hardcoded hex color with its location", () => { + const { root } = themes(":root { --color-text: #112233; }"); + + expect(checkColorAuthorship(root)).toEqual([ + " --color-text (:root): #112233 — colors must be hct(); only --brand-main/--brand-alt may use other formats", + ]); + }); + + it("reports an rgb() color outside hct()", () => { + const { root } = themes( + '[data-theme="dark"] { --color-accent: rgb(1 2 3); }', + ); + + expect(checkColorAuthorship(root)[0]).toContain("--color-accent"); + }); + + it("ignores non-color values and non-custom properties", () => { + const { root } = themes( + ":root { --font-size-m: 14px; } .x { color: #ff0000; }", + ); + + expect(checkColorAuthorship(root)).toEqual([]); + }); +}); + +describe("checkUnused", () => { + it("reports a token that is never referenced", () => { + const { root } = themes(":root { --color-dust: #111; }"); + + expect(checkUnused(root, new Set())).toEqual(["--color-dust"]); + }); + + it("keeps a token that is referenced somewhere", () => { + const { root } = themes( + ":root { --color-text: #111; } .x { color: var(--color-text); }", + ); + + expect(checkUnused(root, new Set(["--color-text"]))).toEqual([]); + }); + + it("collects definitions across both themes", () => { + const { root } = themes( + [ + ":root { --color-text: #111; }", + '[data-theme="dark"] { --color-text: #eee; --color-dust: #222; }', + ].join("\n"), + ); + + expect(checkUnused(root, new Set(["--color-text"]))).toEqual([ + "--color-dust", + ]); + }); +}); + +describe("token predicates", () => { + it("detects color values in every supported notation", () => { + for (const value of [ + "#abc", + "#aabbcc", + "rgb(1 2 3)", + "hsl(1 2% 3%)", + "oklch(0.5 0.1 30)", + "hct(1 2 3)", + "color-mix(in oklch, #fff, #000)", + ]) { + expect(isColorValue(value), value).toBe(true); + } + }); + + it("does not treat sizes and durations as colors", () => { + for (const value of ["12px", "0.3s", "1fr", "bold", "var(--size-m)"]) { + expect(isColorValue(value), value).toBe(false); + } + }); + + it("treats values referencing tokens as derived", () => { + expect(isDerived("hct(from var(--brand-main) h c t)")).toBe(true); + expect(isDerived("hct(160 80 48)")).toBe(false); + }); +}); diff --git a/web/scripts/token-audit/unused.mjs b/web/scripts/token-audit/unused.mjs index 35b46a6..0132920 100644 --- a/web/scripts/token-audit/unused.mjs +++ b/web/scripts/token-audit/unused.mjs @@ -1,14 +1,14 @@ // Unused tokens: defined in app.css but never referenced via var() // anywhere in src. A dead token is not the single source of truth — it's dust. -// Reported as a warning; it does not fail the run. +// Reported as a warning; it does not fail the run. The `used` set is injectable +// so tests can pass a fixture instead of scanning src. import { collectUsedTokens } from "./helpers.mjs"; -export function checkUnused(root) { +export function checkUnused(root, used = collectUsedTokens()) { const defined = new Set(); root.walkDecls((decl) => { if (decl.prop.startsWith("--")) defined.add(decl.prop); }); - const used = collectUsedTokens(); return [...defined].filter((token) => !used.has(token)); } diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 5a65d4a..9f69648 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -4,7 +4,11 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [svelte()], test: { - include: ["src/**/*.test.ts", "eslint-plugins/**/*.test.ts"], + include: [ + "src/**/*.test.ts", + "eslint-plugins/**/*.test.ts", + "scripts/**/*.test.mjs", + ], environment: "node", coverage: { provider: "v8",