Files
Thing/src/lib/theme.test.ts
T
2026-09-22 19:10:16 +08:00

115 lines
4.7 KiB
TypeScript

/**
* 主题色派生单测(Node 内置 test runner)。
*
* 重点不是「算得对不对」,而是「任何预设色在两种模式下都可读」——
* 对比度是这条链路上唯一会真正伤到用户的指标。
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
ACCENT_PRESETS,
ACCENT_VAR_KEYS,
contrastRatio,
deriveAccent,
hexToOklch,
oklchToHex,
resolveAccentHex,
type ThemeMode
} from './theme.ts'
const MODES: ThemeMode[] = ['light', 'dark']
/** 深色模式的对比度基准(与实现里的 DARK_REFERENCE 一致) */
const DARK_REFERENCE = '#1e1e22'
test('hex → OKLCH → hex 往返稳定', () => {
for (const preset of ACCENT_PRESETS) {
const back = oklchToHex(hexToOklch(preset.hex))
const [r1, g1, b1] = [1, 3, 5].map(i => parseInt(preset.hex.slice(i, i + 2), 16))
const [r2, g2, b2] = [1, 3, 5].map(i => parseInt(back.slice(i, i + 2), 16))
// 允许 ±1/255 的取整误差
assert.ok(Math.abs(r1 - r2) <= 1, `${preset.id} R: ${preset.hex}${back}`)
assert.ok(Math.abs(g1 - g2) <= 1, `${preset.id} G: ${preset.hex}${back}`)
assert.ok(Math.abs(b1 - b2) <= 1, `${preset.id} B: ${preset.hex}${back}`)
}
})
test('默认蓝派生结果贴近现有手调值', () => {
const light = deriveAccent('#0066cc', 'light')
assert.equal(light['--primary'], '#0066cc')
// 现有 --primary-soft / --primary-soft-text = #e8f1fc / #004f9e,只要求同一色族与量级
assert.equal(hexToOklch(light['--primary-soft']).l > 0.9, true)
assert.equal(contrastRatio(light['--primary-soft-text'], light['--primary-soft']) >= 4.5, true)
})
test('所有预设 × 两种模式:强调色对底色、浅底文字对浅底、按钮文字对按钮底 均 ≥ 4.5:1', () => {
for (const preset of ACCENT_PRESETS) {
for (const mode of MODES) {
const vars = deriveAccent(preset.hex, mode)
const reference = mode === 'light' ? '#ffffff' : DARK_REFERENCE
const label = `${preset.id}/${mode}`
assert.ok(
contrastRatio(vars['--primary'], reference) >= 4.5,
`${label} primary 对底对比度不足: ${contrastRatio(vars['--primary'], reference).toFixed(2)}`
)
assert.ok(
contrastRatio(vars['--primary-soft-text'], vars['--primary-soft']) >= 4.5,
`${label} soft-text 对 soft 对比度不足: ${contrastRatio(
vars['--primary-soft-text'],
vars['--primary-soft']
).toFixed(2)}`
)
// 填充上的文字:浅色模式必然 ≥4.5:1;深色模式强调色偏亮,白字约 2.7–3:1
//(与现有 #2997ff + 白字 的取舍一致),门槛按 UI 组件的 2.5:1 兜底
const fillMin = mode === 'light' ? 4.5 : 2.5
assert.ok(
contrastRatio(vars['--primary-foreground'], vars['--primary']) >= fillMin,
`${label} foreground 对 primary 对比度不足: ${contrastRatio(
vars['--primary-foreground'],
vars['--primary']
).toFixed(2)}`
)
assert.equal(vars['--ring'], vars['--primary'], `${label} ring 应等于 primary`)
}
}
})
test('亮色(青柠)在浅色模式下被自动压暗', () => {
const base = hexToOklch('#65a30d')
const derived = hexToOklch(deriveAccent('#65a30d', 'light')['--primary'])
// 青柠原色在白底上只有约 2.8:1,派生后必须达标且比原色暗
assert.ok(derived.l < base.l, `未压暗: ${base.l}${derived.l}`)
assert.ok(contrastRatio(oklchToHex(derived), '#ffffff') >= 4.5)
})
test('色相保留(除被 sRGB 色域裁剪的极端彩度)', () => {
for (const preset of ACCENT_PRESETS) {
for (const mode of MODES) {
const base = hexToOklch(preset.hex)
const derived = hexToOklch(deriveAccent(preset.hex, mode)['--primary'])
if (derived.c < 0.02) continue // 石墨这类近无彩色,色相无意义
const diff = Math.abs(((derived.h - base.h + 540) % 360) - 180)
assert.ok(diff < 12, `${preset.id}/${mode} 色相偏移过大: ${base.h}${derived.h}`)
}
}
})
test('派生结果都是合法 hex,且每个变量都有值', () => {
for (const preset of ACCENT_PRESETS) {
for (const mode of MODES) {
const vars = deriveAccent(preset.hex, mode)
for (const key of ACCENT_VAR_KEYS) {
assert.match(vars[key], /^#[0-9a-f]{6}$/, `${preset.id}/${mode} ${key} 非法: ${vars[key]}`)
}
}
}
})
test('resolveAccentHex:默认与非法自定义都回落到内置默认', () => {
assert.equal(resolveAccentHex('blue', null), null)
assert.equal(resolveAccentHex('custom', null), null)
assert.equal(resolveAccentHex('custom', 'not-a-color'), null)
assert.equal(resolveAccentHex('custom', '#ffd60a'), '#ffd60a')
assert.equal(resolveAccentHex('violet', null), '#7c3aed')
})