openpencil/tests/engine/tools/describe.test.ts
mrhard9090 4ef6667ffc
fix(tools): judge describe text contrast by WCAG ratio (#758)
The describe tool judges text contrast by the WCAG 2 ratio (4.5:1, or 3:1 for large text) instead of a dark-on-dark luminance heuristic, and reports the ratio and threshold. It measures translucent and faded text as drawn, treats text as large only when the base style and every style run are, and skips variable-bound colors. contrastRatio() and compositeOver() now live in @open-pencil/scene-graph/color and are shared with the color-contrast lint rule and canvas labels. Fixes #735.
2026-09-30 11:32:34 +04:00

165 lines
5.3 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import type { Color, Fill, SceneNode, Stroke } from '@open-pencil/scene-graph'
import { expectDefined } from '#tests/helpers/assert'
import { getTool, setupToolTest, type ToolResult } from '#tests/helpers/tools'
const NAVY: Color = { r: 2 / 255, g: 26 / 255, b: 59 / 255, a: 1 }
const HIDDEN_STROKE: Stroke = {
color: { r: 1, g: 0, b: 0, a: 1 },
weight: 4,
opacity: 1,
visible: false,
align: 'CENTER',
cap: 'NONE',
join: 'MITER'
}
const VISIBLE_STROKE: Stroke = {
color: NAVY,
weight: 10.126,
opacity: 1,
visible: true,
align: 'CENTER',
cap: 'NONE',
join: 'MITER'
}
interface ChildSummary {
summary: string
}
function setupStrokedChild() {
const { figma, graph } = setupToolTest()
const frame = figma.createFrame()
frame.name = 'Outline'
frame.resize(200, 200)
const outline = figma.createRectangle()
outline.resize(100, 4)
frame.appendChild(outline)
graph.updateNode(outline.id, {
fills: [],
strokes: [HIDDEN_STROKE, VISIBLE_STROKE]
})
return { figma, frameId: frame.id, outlineId: outline.id }
}
describe('describe stroke summaries', () => {
test('reports the first visible stroke in a child summary', () => {
const { figma, frameId } = setupStrokedChild()
const result = getTool('describe').execute(figma, { id: frameId }) as ToolResult
const children = result.children as ChildSummary[]
const summary = expectDefined(children[0], 'stroked child summary').summary
expect(summary).toContain('#021A3B 10.13px stroke')
})
test('reports the first visible stroke in the node visual summary', () => {
const { figma, outlineId } = setupStrokedChild()
const result = getTool('describe').execute(figma, { id: outlineId }) as ToolResult
expect(result.visual).toContain('#021A3B 10.13px stroke')
})
})
interface DescribedIssue {
message: string
severity?: string
}
function hex(value: string): Color {
const channel = (offset: number) => Number.parseInt(value.slice(offset, offset + 2), 16) / 255
return { r: channel(1), g: channel(3), b: channel(5), a: 1 }
}
function solid(color: string, opacity = 1): Fill {
return { type: 'SOLID', color: hex(color), opacity, visible: true }
}
function describeTextOnBackground(
textColor: string,
backgroundColor: string,
fontSize: number,
fontWeight = 400,
textOpacity = 1,
options: { opacity?: number; boundColor?: string; styleRuns?: SceneNode['styleRuns'] } = {}
): DescribedIssue[] {
const { figma, graph } = setupToolTest()
const frame = figma.createFrame()
frame.name = 'Button'
frame.resize(200, 60)
graph.updateNode(frame.id, { fills: [solid(backgroundColor)] })
const label = figma.createText()
label.name = 'Label'
frame.appendChild(label)
graph.updateNode(label.id, {
fills: [solid(textColor, textOpacity)],
fontSize,
fontWeight,
opacity: options.opacity ?? 1,
boundVariables: options.boundColor ? { 'fills/0/color': options.boundColor } : {},
styleRuns: options.styleRuns ?? []
})
const result = getTool('describe').execute(figma, { id: frame.id }) as ToolResult
const issues = (result.issues ?? []) as DescribedIssue[]
return issues.filter((issue) => issue.message.startsWith('"Label"'))
}
describe('describe text contrast', () => {
test('accepts dark text on a mid-tone background that passes WCAG AA', () => {
expect(describeTextOnBackground('#14101F', '#FF5FA2', 17, 700)).toEqual([])
})
test('reports the ratio and threshold for text below WCAG AA', () => {
const issues = describeTextOnBackground('#949494', '#FFFFFF', 14)
expect(issues).toHaveLength(1)
expect(issues[0]).toMatchObject({
message: '"Label" contrast 3.03:1 is below WCAG AA 4.5:1 (#949494 on #FFFFFF)',
severity: 'error'
})
})
test('measures a translucent text fill as blended with the background', () => {
expect(describeTextOnBackground('#000000', '#FFFFFF', 14)).toEqual([])
expect(describeTextOnBackground('#000000', '#FFFFFF', 14, 400, 0.4)[0]?.message).toContain(
'contrast 2.84:1 is below WCAG AA 4.5:1'
)
})
test('uses the 3:1 threshold for large text', () => {
expect(describeTextOnBackground('#949494', '#FFFFFF', 24)).toEqual([])
expect(describeTextOnBackground('#949494', '#FFFFFF', 19, 700)).toEqual([])
expect(describeTextOnBackground('#B0B0B0', '#FFFFFF', 24)[0]?.message).toContain(
'below WCAG AA 3:1'
)
})
test('measures text faded by its own opacity', () => {
expect(
describeTextOnBackground('#000000', '#FFFFFF', 14, 400, 1, { opacity: 0.4 })[0]?.message
).toContain('contrast 2.84:1 is below WCAG AA 4.5:1')
})
test('skips text whose fill color is bound to a variable', () => {
expect(
describeTextOnBackground('#949494', '#FFFFFF', 14, 400, 1, { boundColor: 'v1' })
).toEqual([])
})
test('applies the normal threshold when a style run makes part of large text small', () => {
const smallRun = { styleRuns: [{ start: 0, length: 4, style: { fontSize: 14 } }] }
expect(
describeTextOnBackground('#949494', '#FFFFFF', 24, 400, 1, smallRun)[0]?.message
).toContain('contrast 3.03:1 is below WCAG AA 4.5:1')
const largeRun = { styleRuns: [{ start: 0, length: 4, style: { fontSize: 32 } }] }
expect(describeTextOnBackground('#949494', '#FFFFFF', 24, 400, 1, largeRun)).toEqual([])
})
})