From 03ec3c43faead18886fce35858a495615ed75908 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:35:21 +0200 Subject: [PATCH] test: Measure the ClickHouse Pinned Pairs Inside the Pinning Test --- .../scenarios/clickhouse-pinned-pairs.spec.ts | 44 +++ .../src/theme/themes/clickhouse.spec.tsx | 316 ++++++++++-------- 2 files changed, 221 insertions(+), 139 deletions(-) create mode 100644 e2e/specs/mock/scenarios/clickhouse-pinned-pairs.spec.ts diff --git a/e2e/specs/mock/scenarios/clickhouse-pinned-pairs.spec.ts b/e2e/specs/mock/scenarios/clickhouse-pinned-pairs.spec.ts new file mode 100644 index 00000000000..803d303dc56 --- /dev/null +++ b/e2e/specs/mock/scenarios/clickhouse-pinned-pairs.spec.ts @@ -0,0 +1,44 @@ +import { resolve } from 'node:path'; +import { expect, test } from '@playwright/test'; +import { stripVTControlCharacters } from 'node:util'; +import { inOneProject, repoRoot, run } from './lint.helpers'; + +/** + * The ClickHouse palette spec pins every contrast pair that holds a Click UI + * value below its WCAG floor. A contributor reruns one failing test by name, and + * Jest may run the file in any order, so the pinning test must reach the same + * verdict alone and shuffled. Neither needs the browser: each runs the spec the + * way a contributor does and reads the verdict. + */ + +const clientRoot = resolve(repoRoot, 'packages/client'); +const JEST = resolve(repoRoot, 'node_modules/.bin/jest'); +const SPEC = 'src/theme/themes/clickhouse.spec.tsx'; + +/** The harness may force colour, so the verdict is read with the escape codes stripped. */ +const runSpec = (args: string[]) => { + const result = run(JEST, [SPEC, '--maxWorkers=1', '--ci', ...args], { cwd: clientRoot }); + return { ...result, output: stripVTControlCharacters(result.output) }; +}; + +test.describe('the ClickHouse pinned contrast pairs', () => { + test('the pinning test passes when run alone by name @scenario:clickhouse-pinned-pairs-run-alone', () => { + inOneProject(); + test.setTimeout(180_000); + + const result = runSpec(['-t', 'pins every pair that sits below its floor']); + expect(result.status, result.output).toBe(0); + expect(result.output).toMatch(/Tests:\s+\d+ skipped, 2 passed/); + }); + + test('the palette spec passes in shuffled order @scenario:clickhouse-pinned-pairs-shuffled-order', () => { + inOneProject(); + test.setTimeout(180_000); + + const result = runSpec(['--randomize', '--seed=16821']); + expect(result.status, result.output).toBe(0); + expect(result.output).toMatch( + /keeps control boundaries and rings at the 3:1 floor on every canvas/, + ); + }); +}); diff --git a/packages/client/src/theme/themes/clickhouse.spec.tsx b/packages/client/src/theme/themes/clickhouse.spec.tsx index cc43050a571..13c1afa5ac5 100644 --- a/packages/client/src/theme/themes/clickhouse.spec.tsx +++ b/packages/client/src/theme/themes/clickhouse.spec.tsx @@ -176,6 +176,162 @@ function below( ); } +/** Every foreground against every background, at one floor. */ +interface FloorCheck { + minimum: number; + foregrounds: Array; + backgrounds: Array; +} + +/** + * The contrast matrix the palette tests assert, one entry per test. The pinning + * test measures the whole matrix itself, so it holds whichever tests run and in + * whatever order. + */ +const floorChecks = { + neutralText: [ + { minimum: WCAG_AA_NORMAL, foregrounds: neutralTextTokens, backgrounds: canvasSurfaces }, + ], + interactiveFills: [ + { minimum: WCAG_AA_NORMAL, foregrounds: ['rgb-text-primary'], backgrounds: interactiveFills }, + ], + statusFillText: [ + { + minimum: WCAG_AA_NORMAL, + foregrounds: [ + 'rgb-text-primary', + 'rgb-text-secondary', + 'rgb-text-secondary-alt', + 'rgb-text-tertiary', + 'rgb-text-destructive', + ], + backgrounds: statusHues.map((hue) => `rgb-status-${hue}-subtle` as keyof IThemeRGB), + }, + ], + primaryButton: [ + { + minimum: WCAG_AA_NORMAL, + foregrounds: ['rgb-text-inverted'], + backgrounds: [ + 'rgb-button-primary', + 'rgb-button-primary-hover', + 'rgb-surface-inverted-pressed', + ], + }, + ], + warningText: [ + { + minimum: WCAG_AA_NORMAL, + foregrounds: ['rgb-text-warning', 'rgb-text-destructive'], + backgrounds: canvasSurfaces, + }, + ], + statusHues: statusHues.map((hue) => ({ + minimum: WCAG_AA_NORMAL, + foregrounds: [`rgb-status-${hue}` as keyof IThemeRGB], + backgrounds: [`rgb-status-${hue}-subtle` as keyof IThemeRGB], + })), + accents: [ + { + minimum: WCAG_AA_NORMAL, + foregrounds: accentTokens, + backgrounds: ['rgb-surface-primary', 'rgb-surface-secondary'], + }, + ], + syntax: [ + { + minimum: WCAG_AA_NORMAL, + foregrounds: syntaxTokens, + backgrounds: ['rgb-surface-code', 'rgb-surface-code-body'], + }, + ], + invertedAndFixed: [ + { + minimum: WCAG_AA_NORMAL, + foregrounds: ['rgb-text-inverted'], + backgrounds: ['rgb-surface-inverted', 'rgb-surface-inverted-hover'], + }, + { + minimum: WCAG_AA_NORMAL, + foregrounds: ['rgb-text-fixed'], + backgrounds: ['rgb-surface-fixed', 'rgb-surface-fixed-hover'], + }, + ], + statusLabel: [ + { minimum: WCAG_AA_NORMAL, foregrounds: ['rgb-text-on-status'], backgrounds: solidFills }, + ], + solidFills: [ + { + minimum: WCAG_NON_TEXT, + foregrounds: [...solidFills, 'rgb-status-verified'], + backgrounds: ['rgb-surface-primary', 'rgb-surface-secondary', 'rgb-surface-dialog'], + }, + { + minimum: WCAG_NON_TEXT, + foregrounds: ['rgb-text-on-status'], + backgrounds: ['rgb-status-verified'], + }, + ], + controlBoundaries: [ + { + minimum: WCAG_NON_TEXT, + foregrounds: boundaryTokens, + backgrounds: [ + 'rgb-surface-primary', + 'rgb-surface-secondary', + 'rgb-surface-tertiary', + 'rgb-surface-dialog', + ], + }, + ], + switchTrack: [ + { + minimum: WCAG_NON_TEXT, + foregrounds: ['rgb-switch-unchecked'], + backgrounds: ['rgb-switch-thumb', 'rgb-surface-inverted'], + }, + ], + proseMarks: [ + { + minimum: WCAG_NON_TEXT, + foregrounds: ['rgb-prose-bullet', 'rgb-prose-quote-bar'], + backgrounds: [ + 'rgb-surface-chat', + 'rgb-surface-primary', + 'rgb-presentation', + 'rgb-surface-user-message', + ], + }, + ], + inlineCode: [ + { + minimum: WCAG_AA_NORMAL, + foregrounds: ['rgb-text-primary'], + backgrounds: ['rgb-surface-code-inline'], + }, + ], + series: [ + { + minimum: WCAG_NON_TEXT, + foregrounds: seriesTokens, + backgrounds: [ + 'rgb-surface-primary', + 'rgb-surface-secondary', + 'rgb-surface-tertiary', + 'rgb-surface-chat', + 'rgb-surface-dialog', + 'rgb-text-on-status', + ], + }, + ], +} satisfies Record; + +function measure(theme: IThemeRGB, checks: FloorCheck[]): string[] { + return checks.flatMap(({ minimum, foregrounds, backgrounds }) => + below(theme, minimum, foregrounds, backgrounds), + ); +} + /** * Pairs below their WCAG floor because the theme holds the Click UI value, by owner decision: * ClickHouse matches Click UI exactly, and where Click UI misses a floor the theme misses it too. @@ -265,19 +421,9 @@ const clickUiBelowFloor: Record = { /** `rgb-series-1` is Click UI's chart blue, which is also its info foreground. */ const clickUiSharedSeries: Record = { light: ['rgb-series-1'], dark: [] }; -const measuredBelow: Record> = { light: new Set(), dark: new Set() }; - -/** The pairs `below` reports that are not pinned in `clickUiBelowFloor`. */ -function belowPinned( - mode: ThemeMode, - theme: IThemeRGB, - minimum: number, - foregrounds: Array, - backgrounds: Array, -): string[] { - const failures = below(theme, minimum, foregrounds, backgrounds); - failures.forEach((failure) => measuredBelow[mode].add(failure)); - return failures.filter((failure) => !clickUiBelowFloor[mode].includes(failure)); +/** The pairs a check reports that are not pinned in `clickUiBelowFloor`. */ +function belowPinned(mode: ThemeMode, theme: IThemeRGB, checks: FloorCheck[]): string[] { + return measure(theme, checks).filter((failure) => !clickUiBelowFloor[mode].includes(failure)); } const modes: Array<[ThemeMode, IThemeRGB]> = [ @@ -291,7 +437,7 @@ describe.each(modes)('clickhouse %s palette', (mode, theme) => { }); it('keeps neutral text at WCAG AA on every canvas surface', () => { - expect(belowPinned(mode, theme, WCAG_AA_NORMAL, neutralTextTokens, canvasSurfaces)).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.neutralText)).toEqual([]); }); it('steps each layer off the one it sits on, so hover and selection stay apart', () => { @@ -308,156 +454,58 @@ describe.each(modes)('clickhouse %s palette', (mode, theme) => { }); it('keeps primary text at WCAG AA on hover, selected and header fills', () => { - expect( - belowPinned(mode, theme, WCAG_AA_NORMAL, ['rgb-text-primary'], interactiveFills), - ).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.interactiveFills)).toEqual([]); }); /** The chat error box, the route error boundary, the sign-in notices and the * deleted rows of a diff paint neutral copy on a status fill rather than on a * canvas. `text-muted` is left out because no consumer puts it on one. */ it('keeps the text roles that meet status fills at WCAG AA on every one', () => { - const fills = statusHues.map((hue) => `rgb-status-${hue}-subtle` as keyof IThemeRGB); - const onStatusFills: Array = [ - 'rgb-text-primary', - 'rgb-text-secondary', - 'rgb-text-secondary-alt', - 'rgb-text-tertiary', - 'rgb-text-destructive', - ]; - expect(belowPinned(mode, theme, WCAG_AA_NORMAL, onStatusFills, fills)).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.statusFillText)).toEqual([]); }); it('keeps the primary button label at WCAG AA on its fill, hovered and pressed', () => { - expect( - belowPinned( - mode, - theme, - WCAG_AA_NORMAL, - ['rgb-text-inverted'], - ['rgb-button-primary', 'rgb-button-primary-hover', 'rgb-surface-inverted-pressed'], - ), - ).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.primaryButton)).toEqual([]); }); it('keeps warning and destructive text at WCAG AA on canvas surfaces', () => { - expect( - belowPinned( - mode, - theme, - WCAG_AA_NORMAL, - ['rgb-text-warning', 'rgb-text-destructive'], - canvasSurfaces, - ), - ).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.warningText)).toEqual([]); }); it('keeps every status hue at WCAG AA against its own subtle fill', () => { - const failures = statusHues.flatMap((hue) => - belowPinned( - mode, - theme, - WCAG_AA_NORMAL, - [`rgb-status-${hue}` as keyof IThemeRGB], - [`rgb-status-${hue}-subtle` as keyof IThemeRGB], - ), - ); - expect(failures).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.statusHues)).toEqual([]); }); it('keeps accents and links at WCAG AA on the page', () => { - expect( - belowPinned(mode, theme, WCAG_AA_NORMAL, accentTokens, [ - 'rgb-surface-primary', - 'rgb-surface-secondary', - ]), - ).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.accents)).toEqual([]); }); it('keeps every syntax colour at WCAG AA on the code surface', () => { - expect( - belowPinned(mode, theme, WCAG_AA_NORMAL, syntaxTokens, [ - 'rgb-surface-code', - 'rgb-surface-code-body', - ]), - ).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.syntax)).toEqual([]); }); it('keeps inverted and fixed pairs at WCAG AA', () => { - expect( - belowPinned( - mode, - theme, - WCAG_AA_NORMAL, - ['rgb-text-inverted'], - ['rgb-surface-inverted', 'rgb-surface-inverted-hover'], - ), - ).toEqual([]); - expect( - belowPinned( - mode, - theme, - WCAG_AA_NORMAL, - ['rgb-text-fixed'], - ['rgb-surface-fixed', 'rgb-surface-fixed-hover'], - ), - ).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.invertedAndFixed)).toEqual([]); }); it('carries the status label at WCAG AA on every solid fill', () => { - expect(belowPinned(mode, theme, WCAG_AA_NORMAL, ['rgb-text-on-status'], solidFills)).toEqual( - [], - ); + expect(belowPinned(mode, theme, floorChecks.statusLabel)).toEqual([]); }); it('keeps solid fills and the verified mark at the 3:1 floor against the page', () => { - expect( - belowPinned( - mode, - theme, - WCAG_NON_TEXT, - [...solidFills, 'rgb-status-verified'], - ['rgb-surface-primary', 'rgb-surface-secondary', 'rgb-surface-dialog'], - ), - ).toEqual([]); - expect( - belowPinned(mode, theme, WCAG_NON_TEXT, ['rgb-text-on-status'], ['rgb-status-verified']), - ).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.solidFills)).toEqual([]); }); it('keeps control boundaries and rings at the 3:1 floor on every canvas', () => { - expect( - belowPinned(mode, theme, WCAG_NON_TEXT, boundaryTokens, [ - 'rgb-surface-primary', - 'rgb-surface-secondary', - 'rgb-surface-tertiary', - 'rgb-surface-dialog', - ]), - ).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.controlBoundaries)).toEqual([]); }); it('keeps the unchecked switch track at the 3:1 floor against the thumb and the checked fill', () => { - expect( - belowPinned( - mode, - theme, - WCAG_NON_TEXT, - ['rgb-switch-unchecked'], - ['rgb-switch-thumb', 'rgb-surface-inverted'], - ), - ).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.switchTrack)).toEqual([]); }); it('keeps the list marker and the quote bar at the 3:1 floor on the page and the user bubble', () => { - expect( - belowPinned( - mode, - theme, - WCAG_NON_TEXT, - ['rgb-prose-bullet', 'rgb-prose-quote-bar'], - ['rgb-surface-chat', 'rgb-surface-primary', 'rgb-presentation', 'rgb-surface-user-message'], - ), - ).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.proseMarks)).toEqual([]); }); it('keeps the inline code chip off the user message bubble it can sit on', () => { @@ -465,22 +513,11 @@ describe.each(modes)('clickhouse %s palette', (mode, theme) => { }); it('keeps primary text at WCAG AA on the inline code chip', () => { - expect( - belowPinned(mode, theme, WCAG_AA_NORMAL, ['rgb-text-primary'], ['rgb-surface-code-inline']), - ).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.inlineCode)).toEqual([]); }); it('keeps every series mark at the 3:1 floor on the page and under the status label', () => { - expect( - belowPinned(mode, theme, WCAG_NON_TEXT, seriesTokens, [ - 'rgb-surface-primary', - 'rgb-surface-secondary', - 'rgb-surface-tertiary', - 'rgb-surface-chat', - 'rgb-surface-dialog', - 'rgb-text-on-status', - ]), - ).toEqual([]); + expect(belowPinned(mode, theme, floorChecks.series)).toEqual([]); }); it('never reuses a reserved status colour for series identity', () => { @@ -495,7 +532,8 @@ describe.each(modes)('clickhouse %s palette', (mode, theme) => { }); it('pins every pair that sits below its floor because it holds the Click UI value', () => { - expect([...measuredBelow[mode]].sort()).toEqual([...clickUiBelowFloor[mode]].sort()); + const measured = new Set(measure(theme, Object.values(floorChecks).flat())); + expect([...measured].sort()).toEqual([...clickUiBelowFloor[mode]].sort()); }); });