update
This commit is contained in:
75
tests/unit/renderer/styles/no-html-scrollbar-gutter.test.ts
Normal file
75
tests/unit/renderer/styles/no-html-scrollbar-gutter.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// tests/unit/renderer/styles/no-html-scrollbar-gutter.test.ts
|
||||
//
|
||||
// 行为锁(audit-r237,2026-10-20):globals.css 里**任何命中 html 元素的规则**
|
||||
// (html / html.xxx / :root / :root[...] 等)都不得带 scrollbar-gutter 声明。
|
||||
//
|
||||
// audit-r145c 给 html 加了 scrollbar-gutter: stable,想治「列表出现/收起
|
||||
// 滚动条时内容跳 8px」。但本 app 文档流根本不滚动(App 根节点
|
||||
// `h-screen … overflow-hidden`,所有滚动都在内部面板),stable 在 html 上
|
||||
// = 永久预留一条右侧死白条(实测 1440px 窗口只排版到 1429px,用户报
|
||||
// 「窗口关闭按钮右边一条窄空白」)。r145c 的跳变属于内部滚动容器,要治
|
||||
// 也得加在真正滚动的元素上。
|
||||
//
|
||||
// 形态:源码字面解析(css-tree 不在 devDeps,用注释剥离 + 最内层块正则)。
|
||||
// 注释先剥掉 ⇒ 本文件/globals.css 注释里出现 "scrollbar-gutter" 字样不会误判。
|
||||
// 读文件走 process.cwd()(jsdom 环境下 fileURLToPath(import.meta.url) 不可靠,
|
||||
// 见 r236 同族测试)。
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** 剥注释后的 globals.css 全文 */
|
||||
function cssWithoutComments(): string {
|
||||
return readFileSync(join(process.cwd(), 'src/renderer/src/styles/globals.css'), 'utf8').replace(
|
||||
/\/\*[\s\S]*?\*\//g,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
interface CssRule {
|
||||
selector: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取所有**最内层**规则块。@layer / @media 等嵌套 at-rule 的头部
|
||||
* (`... {` 后紧跟的内容含 `{`)正则匹配不上会被自然跳过,只留下叶子规则。
|
||||
*/
|
||||
function leafRules(css: string): CssRule[] {
|
||||
const out: CssRule[] = [];
|
||||
const re = /([^{}]+)\{([^{}]*)\}/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(css)) !== null) {
|
||||
out.push({ selector: m[1].trim(), body: m[2] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 选择器逗号拆段后,任一段命中 html 元素本体:html / html.light / :root / :root[...] */
|
||||
function hitsHtmlElement(selector: string): boolean {
|
||||
return selector
|
||||
.split(',')
|
||||
.some((part) => /^(?:html|:root)(?:[.#[:]|$)/.test(part.trim()));
|
||||
}
|
||||
|
||||
describe('html 不得带 scrollbar-gutter(r237 行为锁)', () => {
|
||||
const rules = leafRules(cssWithoutComments());
|
||||
|
||||
it('能解析出 html 的 scrollbar 着色规则(解析器没瞎、r143c 着色未被顺手删)', () => {
|
||||
const htmlRules = rules.filter((r) => hitsHtmlElement(r.selector));
|
||||
expect(htmlRules.length).toBeGreaterThan(0);
|
||||
expect(htmlRules.some((r) => /scrollbar-color\s*:/.test(r.body))).toBe(true);
|
||||
});
|
||||
|
||||
it('没有任何命中 html 元素的规则声明 scrollbar-gutter(右侧死白条不复发)', () => {
|
||||
const offenders = rules.filter(
|
||||
(r) => hitsHtmlElement(r.selector) && /scrollbar-gutter\s*:/.test(r.body),
|
||||
);
|
||||
expect(
|
||||
offenders.map((o) => o.selector),
|
||||
'html/:root 上的 scrollbar-gutter 会在不滚动的文档流上永久预留死白条;r145c 的跳变要治请加到真正滚动的内部元素',
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// tests/unit/renderer/styles/scrollbar-standard-props-firefox-only.test.ts
|
||||
//
|
||||
// 行为锁(audit-r250,2026-10-21):globals.css 里 `scrollbar-color` /
|
||||
// `scrollbar-width: thin` 这类**标准滚动条属性**只允许出现在
|
||||
// `@supports (-moz-appearance: none)`(≈ Firefox-only)块里。
|
||||
//
|
||||
// 为什么:这两个属性一旦在祖先(html)上生效,就会**继承**到每个内部滚动
|
||||
// 容器(侧栏列表 / 笔记编辑器 / Reader / 设置抽屉 / 搜索面板)。
|
||||
// Chromium 的规则是:scrollbar-color 不是 auto 时,UA 改用「标准滚动条」
|
||||
// 渲染并**忽略整套 ::-webkit-scrollbar 规则** —— 于是 r143 校准到 3:1 的
|
||||
// --scrollbar-thumb-rgb 整套主题从来没生效,用户看到的是 Windows 原生
|
||||
// 17px 滚动条(▲▼ 箭头 + 浅灰 thumb),暗色主题下尤其刺眼,还白占 9px 宽度。
|
||||
//
|
||||
// 实测(Chromium 124 / Electron 30,真机 CDP 探针):
|
||||
// scrollbar-color 继承中 → 滚动条 17px
|
||||
// scrollbar-color: auto → 滚动条 8px(::-webkit-scrollbar 生效)
|
||||
//
|
||||
// 形态:源码字面解析(css-tree 不在 devDeps ⇒ 注释剥离 + 括号配平)。
|
||||
// 注释先剥掉 ⇒ 本文件/globals.css 注释里出现 "scrollbar-color" 字样不会误判。
|
||||
// 读文件走 process.cwd()(jsdom 环境下 fileURLToPath(import.meta.url) 不可靠,
|
||||
// 见 r236 同族测试)。
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const FIREFOX_ONLY_HEADER = '@supports (-moz-appearance: none)';
|
||||
|
||||
/** 剥注释后的 globals.css 全文 */
|
||||
function cssWithoutComments(): string {
|
||||
return readFileSync(join(process.cwd(), 'src/renderer/src/styles/globals.css'), 'utf8').replace(
|
||||
/\/\*[\s\S]*?\*\//g,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
interface BlockRange {
|
||||
/** 块内容起止(含头不含尾),用于「某次出现是否落在块内」的区间判定 */
|
||||
start: number;
|
||||
end: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** 按括号配平取出 `header` 之后那个块的正文;找不到返回 null */
|
||||
function extractBlock(css: string, header: string): BlockRange | null {
|
||||
const headerIdx = css.indexOf(header);
|
||||
if (headerIdx < 0) return null;
|
||||
const open = css.indexOf('{', headerIdx + header.length);
|
||||
if (open < 0) return null;
|
||||
let depth = 0;
|
||||
for (let i = open; i < css.length; i += 1) {
|
||||
if (css[i] === '{') depth += 1;
|
||||
else if (css[i] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return { start: open + 1, end: i, body: css.slice(open + 1, i) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 全文里某个 token 的所有出现位置 */
|
||||
function occurrences(css: string, token: string): number[] {
|
||||
const out: number[] = [];
|
||||
let idx = css.indexOf(token);
|
||||
while (idx >= 0) {
|
||||
out.push(idx);
|
||||
idx = css.indexOf(token, idx + token.length);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('标准滚动条属性必须收在 Firefox-only 块里(r250 行为锁)', () => {
|
||||
const css = cssWithoutComments();
|
||||
|
||||
it('Firefox-only 块存在,且带 scrollbar-color + scrollbar-width: thin', () => {
|
||||
const block = extractBlock(css, FIREFOX_ONLY_HEADER);
|
||||
expect(block, `globals.css 必须有 ${FIREFOX_ONLY_HEADER} 块承载标准滚动条属性`).not.toBeNull();
|
||||
expect(block?.body).toMatch(/scrollbar-color\s*:/);
|
||||
expect(block?.body).toMatch(/scrollbar-width\s*:\s*thin/);
|
||||
});
|
||||
|
||||
it('任何 scrollbar-color 声明都落在 Firefox-only 块内(Chromium 不再被劫持)', () => {
|
||||
const block = extractBlock(css, FIREFOX_ONLY_HEADER);
|
||||
expect(block).not.toBeNull();
|
||||
const outside = occurrences(css, 'scrollbar-color').filter(
|
||||
(i) => i < block!.start || i >= block!.end,
|
||||
);
|
||||
expect(
|
||||
outside.length,
|
||||
'scrollbar-color 是可继承属性,漏在块外会继承到所有内部滚动容器,' +
|
||||
'Chromium 随即忽略 ::-webkit-scrollbar ⇒ 用户看到 Windows 原生滚动条',
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('任何 scrollbar-width: thin 声明都落在 Firefox-only 块内', () => {
|
||||
const block = extractBlock(css, FIREFOX_ONLY_HEADER);
|
||||
expect(block).not.toBeNull();
|
||||
const thinPositions = occurrences(css, 'scrollbar-width').filter((i) =>
|
||||
/scrollbar-width\s*:\s*thin/.test(css.slice(i, i + 40)),
|
||||
);
|
||||
const outside = thinPositions.filter((i) => i < block!.start || i >= block!.end);
|
||||
expect(outside.length, 'scrollbar-width: thin 落在块外同样会让 Chromium 走标准滚动条').toBe(0);
|
||||
});
|
||||
|
||||
it('::-webkit-scrollbar 主题仍在(8px 细滚动条 + token 着色没被顺手删)', () => {
|
||||
// Chromium 下真正生效的就是这一套 —— 它被忽略等于主题全废
|
||||
expect(css).toMatch(/::-webkit-scrollbar\s*\{[^}]*width\s*:\s*8px/);
|
||||
expect(css).toMatch(
|
||||
/::-webkit-scrollbar-thumb\s*\{[^}]*background\s*:\s*rgb\(var\(--scrollbar-thumb-rgb\)\)/,
|
||||
);
|
||||
expect(css).toMatch(/--scrollbar-thumb-rgb\s*:/);
|
||||
});
|
||||
});
|
||||
112
tests/unit/renderer/styles/theme-css.ts
Normal file
112
tests/unit/renderer/styles/theme-css.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// tests/unit/renderer/styles/theme-css.ts
|
||||
//
|
||||
// 主题 CSS 的最小解析工具(只给 theme-presets-*.test.ts 用,不是测试文件)。
|
||||
//
|
||||
// 为什么自己写解析器而不用 postcss:
|
||||
// 依赖树里没有 postcss,为一个断言脚本引入一个解析器不划算;而本文件要读的
|
||||
// 只是「顶层 CSS 块 + 块内的 `--token: R G B` 声明」,60 行足够且没有歧义。
|
||||
//
|
||||
// 解析结果:
|
||||
export interface ThemeBlock {
|
||||
/** 块选择器原文(可能多行,已折叠空白) */
|
||||
selector: string;
|
||||
/** `--token 名 → [r, g, b]` */
|
||||
tokens: Map<string, [number, number, number]>;
|
||||
/** 块内的非颜色声明(如 color-scheme) */
|
||||
decls: Map<string, string>;
|
||||
}
|
||||
|
||||
/** 从 globals.css 解析出所有顶层块 */
|
||||
export function parseTopLevelBlocks(css: string): ThemeBlock[] {
|
||||
// 注释整体剥掉(含跨行注释 —— 里面的 `选择器写法 :root[data-theme-preset='x']`
|
||||
// 这种示例文字会被误当成真选择器)。
|
||||
const lines = css.replace(/\/\*[\s\S]*?\*\//g, '').split('\n');
|
||||
const out: ThemeBlock[] = [];
|
||||
let depth = 0;
|
||||
let selectorBuf: string[] = [];
|
||||
let current: ThemeBlock | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (depth === 0) {
|
||||
// 块外:攒选择器文本(选择器可能跨多行,`:root,` 单独一行那种)
|
||||
const braceAt = line.indexOf('{');
|
||||
if (braceAt >= 0) {
|
||||
selectorBuf.push(line.slice(0, braceAt));
|
||||
const selector = selectorBuf.join(' ').replace(/\s+/g, ' ').trim();
|
||||
selectorBuf = [];
|
||||
current = { selector, tokens: new Map(), decls: new Map() };
|
||||
} else if (line.trim()) {
|
||||
selectorBuf.push(line);
|
||||
}
|
||||
} else {
|
||||
const decl = line.match(/^\s*([-a-z0-9]+)\s*:\s*([^;]+);/);
|
||||
if (decl && current) {
|
||||
const name = decl[1];
|
||||
const value = decl[2].trim();
|
||||
const rgb = value.match(/^(\d+)\s+(\d+)\s+(\d+)$/);
|
||||
if (name.startsWith('--')) {
|
||||
if (rgb) {
|
||||
current.tokens.set(name.slice(2), [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]);
|
||||
} else {
|
||||
current.decls.set(name, value);
|
||||
}
|
||||
} else {
|
||||
current.decls.set(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ch of line) {
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
if (current) out.push(current);
|
||||
current = null;
|
||||
selectorBuf = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 找「选择器里带 [data-theme-preset='x']」的块 */
|
||||
export function findPresetBlock(blocks: ThemeBlock[], preset: string): ThemeBlock | undefined {
|
||||
return blocks.find((b) => b.selector.includes(`[data-theme-preset='${preset}']`));
|
||||
}
|
||||
|
||||
/** 找基础配方块([data-theme-preview='dark' | 'light']) */
|
||||
export function findPreviewBlock(blocks: ThemeBlock[], polarity: 'dark' | 'light'): ThemeBlock | undefined {
|
||||
return blocks.find((b) => b.selector.includes(`[data-theme-preview='${polarity}']`));
|
||||
}
|
||||
|
||||
// ── WCAG 对比度 ──────────────────────────────────────────────────────────
|
||||
|
||||
export type Rgb = [number, number, number];
|
||||
|
||||
function srgbToLin(c: number): number {
|
||||
const x = c / 255;
|
||||
return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
export function relativeLuminance([r, g, b]: Rgb): number {
|
||||
return 0.2126 * srgbToLin(r) + 0.7152 * srgbToLin(g) + 0.0722 * srgbToLin(b);
|
||||
}
|
||||
|
||||
/** WCAG 2.x 对比度(1..21) */
|
||||
export function contrastRatio(a: Rgb, b: Rgb): number {
|
||||
const l1 = relativeLuminance(a);
|
||||
const l2 = relativeLuminance(b);
|
||||
const hi = Math.max(l1, l2);
|
||||
const lo = Math.min(l1, l2);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
export const WHITE: Rgb = [255, 255, 255];
|
||||
|
||||
/** 从块里取 token(缺了就 throw,调用方保证覆盖) */
|
||||
export function token(block: ThemeBlock, name: string): Rgb {
|
||||
const v = block.tokens.get(name);
|
||||
if (!v) throw new Error(`块「${block.selector}」缺 token ${name}`);
|
||||
return v;
|
||||
}
|
||||
165
tests/unit/renderer/styles/theme-presets-contrast.test.ts
Normal file
165
tests/unit/renderer/styles/theme-presets-contrast.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
// tests/unit/renderer/styles/theme-presets-contrast.test.ts
|
||||
//
|
||||
// 守 r247「15 档预设都按 WCAG 校准过」的**对比度**契约。
|
||||
//
|
||||
// 为什么要有这个守卫:
|
||||
// 配色方案的「好不好看」很难自动判,但「看不看得清」可以。本守卫把每一档
|
||||
// 配方的关键色对量成 WCAG 2.x 对比度,低于阈值就红 —— 于是
|
||||
// 「新加一档配色,顺手把某个灰阶调暗了两档」这种事在 CI 就被拦住,
|
||||
// 不用等用户来吐槽「Nord 下正文发灰看不清」。
|
||||
//
|
||||
// 阈值出处(WCAG 2.1):
|
||||
// - 正文级文字 4.5:1 (SC 1.4.3 AA)
|
||||
// - 大号文字 / 图形控件 / 焦点环 3:1 (SC 1.4.11)
|
||||
// - 状态色的「实心底 + 白字」= 正文级 ⇒ 4.5:1
|
||||
//
|
||||
// 语义分工(见 globals.css「状态色两槽」注释):
|
||||
// --success-rgb 等是「色」(图标 / 文字 / 描边);
|
||||
// --success-strong-rgb 等是「底」(实心按钮 / toast 底 + 白字)——
|
||||
// Dracula 的原色 #50FA7B 压白字只有 1.37:1,所以底必须走 strong 档。
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { THEME_PRESETS } from '../../../../src/shared/types';
|
||||
import {
|
||||
parseTopLevelBlocks,
|
||||
findPresetBlock,
|
||||
findPreviewBlock,
|
||||
contrastRatio,
|
||||
token,
|
||||
WHITE,
|
||||
type ThemeBlock,
|
||||
} from './theme-css';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const CSS = readFileSync(join(ROOT, 'src/renderer/src/styles/globals.css'), 'utf8');
|
||||
const blocks = parseTopLevelBlocks(CSS);
|
||||
|
||||
/** 每一档都要查的「块」:两块基础配方 + 14 档具体预设 */
|
||||
function allThemeBlocks(): Array<{ name: string; block: ThemeBlock }> {
|
||||
const out: Array<{ name: string; block: ThemeBlock }> = [];
|
||||
for (const polarity of ['dark', 'light'] as const) {
|
||||
const b = findPreviewBlock(blocks, polarity);
|
||||
if (b) out.push({ name: `基础${polarity}`, block: b });
|
||||
}
|
||||
for (const preset of THEME_PRESETS) {
|
||||
if (preset === 'default') continue;
|
||||
const b = findPresetBlock(blocks, preset);
|
||||
if (b) out.push({ name: preset, block: b });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const THEMES = allThemeBlocks();
|
||||
|
||||
/** 收集不达标的项,统一报错(比逐条 expect 好读) */
|
||||
function collect(
|
||||
themes: Array<{ name: string; block: ThemeBlock }>,
|
||||
check: (block: ThemeBlock) => { label: string; ratio: number; min: number },
|
||||
): string[] {
|
||||
const bad: string[] = [];
|
||||
for (const { name, block } of themes) {
|
||||
const { label, ratio, min } = check(block);
|
||||
if (ratio < min) bad.push(`${name}: ${label} = ${ratio.toFixed(2)}:1 (< ${min}:1)`);
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
|
||||
describe('主题预设的 WCAG 对比度(r247)', () => {
|
||||
it('量到了全部 16 个配方块(2 基础 + 14 预设)', () => {
|
||||
expect(THEMES.length).toBe(16);
|
||||
});
|
||||
|
||||
it('正文级文字 ≥ 4.5:1(fg / fg-secondary 压在主区 bg 上)', () => {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: 'fg vs bg',
|
||||
ratio: contrastRatio(token(b, 'fg-rgb'), token(b, 'bg-rgb')),
|
||||
min: 4.5,
|
||||
}));
|
||||
expect(bad, `正文对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
|
||||
const bad2 = collect(THEMES, (b) => ({
|
||||
label: 'fg-secondary vs bg',
|
||||
ratio: contrastRatio(token(b, 'fg-secondary-rgb'), token(b, 'bg-rgb')),
|
||||
min: 4.5,
|
||||
}));
|
||||
expect(bad2, `次级文字对比度不足:\n${bad2.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('三级文字 ≥ 4.5:1(fg-tertiary 压在 header / tabs 的 bg-raised 上)', () => {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: 'fg-tertiary vs bg-raised',
|
||||
ratio: contrastRatio(token(b, 'fg-tertiary-rgb'), token(b, 'bg-raised-rgb')),
|
||||
min: 4.5,
|
||||
}));
|
||||
expect(bad, `三级文字对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('弱化文字 ≥ 3:1(fg-muted 压在面板 bg-surface 上)', () => {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: 'fg-muted vs bg-surface',
|
||||
ratio: contrastRatio(token(b, 'fg-muted-rgb'), token(b, 'bg-surface-rgb')),
|
||||
min: 3,
|
||||
}));
|
||||
expect(bad, `弱化文字对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('强调色 ≥ 3:1(accent 作为图标 / 焦点环 / 链接底)', () => {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: 'accent vs bg',
|
||||
ratio: contrastRatio(token(b, 'accent-rgb'), token(b, 'bg-rgb')),
|
||||
min: 3,
|
||||
}));
|
||||
expect(bad, `强调色对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
|
||||
const bad2 = collect(THEMES, (b) => ({
|
||||
label: 'accent vs bg-raised',
|
||||
ratio: contrastRatio(token(b, 'accent-rgb'), token(b, 'bg-raised-rgb')),
|
||||
min: 3,
|
||||
}));
|
||||
expect(bad2, `强调色在 header 上对比度不足:\n${bad2.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('状态色 ≥ 3:1(作为文字 / 图标色时)', () => {
|
||||
for (const name of ['success', 'warning', 'danger']) {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: `${name} vs bg`,
|
||||
ratio: contrastRatio(token(b, `${name}-rgb`), token(b, 'bg-rgb')),
|
||||
min: 3,
|
||||
}));
|
||||
expect(bad, `${name} 对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('模块语义色 ≥ 3:1(侧栏模块图标 / 徽标)', () => {
|
||||
for (const name of ['module-library', 'module-notes']) {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: `${name} vs bg`,
|
||||
ratio: contrastRatio(token(b, `${name}-rgb`), token(b, 'bg-rgb')),
|
||||
min: 3,
|
||||
}));
|
||||
expect(bad, `${name} 对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('实心底 + 白字 ≥ 4.5:1(success-strong / warning-strong / danger-strong)', () => {
|
||||
for (const name of ['success-strong', 'warning-strong', 'danger-strong']) {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: `white vs ${name}`,
|
||||
ratio: contrastRatio(WHITE, token(b, `${name}-rgb`)),
|
||||
min: 4.5,
|
||||
}));
|
||||
expect(bad, `${name} 压白字对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('滚动条 thumb ≥ 3:1(压在面板 bg-surface 上)', () => {
|
||||
const bad = collect(THEMES, (b) => ({
|
||||
label: 'scrollbar-thumb vs bg-surface',
|
||||
ratio: contrastRatio(token(b, 'scrollbar-thumb-rgb'), token(b, 'bg-surface-rgb')),
|
||||
min: 3,
|
||||
}));
|
||||
expect(bad, `滚动条对比度不足:\n${bad.join('\n')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
140
tests/unit/renderer/styles/theme-presets-token-coverage.test.ts
Normal file
140
tests/unit/renderer/styles/theme-presets-token-coverage.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// tests/unit/renderer/styles/theme-presets-token-coverage.test.ts
|
||||
//
|
||||
// 守 r247「主题预设扩到 15 档」的**配方完整性**契约:
|
||||
//
|
||||
// 每档预设(除 default 外)在 globals.css 里都必须有一块自己的配方,
|
||||
// 且该块必须把 **全部 28 个颜色 token** 都定义齐 —— 少一个,那个 token
|
||||
// 就会**继承**自 :root(基础暗色 / 浅色配方),于是「选 Nord 却看到一截
|
||||
// 基础暗色的灰」这种半吊子配色,而且极难被发现(只有切到那一档才看得见)。
|
||||
//
|
||||
// 为什么这是行为面而不是实现细节:
|
||||
// 预设清单的真相源是 shared/types.ts 的 THEME_PRESETS;CSS 配方是它的
|
||||
// **另一半**。加了一档预设却忘了写 CSS ⇒ 用户在设置页点得到那一档,
|
||||
// 主题「切了但没变」(或只变了一半)—— 这正是 r125 / r229 在字体族、
|
||||
// 主题枚举上修过的同款漂移。
|
||||
//
|
||||
// 守卫点:
|
||||
// 1. THEME_PRESETS 里每个非 default 预设都有对应的 CSS 块;
|
||||
// 2. 每块(以及两块基础配方)都定义了全部必需 token;
|
||||
// 3. 每块都声明了 color-scheme(浏览器原生控件 / 滚动条 / 表单跟随);
|
||||
// 4. 没有「孤儿块」:CSS 里有、THEME_PRESETS 里没有的预设(删预设漏删 CSS)。
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { THEME_PRESETS } from '../../../../src/shared/types';
|
||||
import {
|
||||
parseTopLevelBlocks,
|
||||
findPresetBlock,
|
||||
findPreviewBlock,
|
||||
} from './theme-css';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const CSS = readFileSync(join(ROOT, 'src/renderer/src/styles/globals.css'), 'utf8');
|
||||
|
||||
/**
|
||||
* 每一档配方必须定义齐的颜色 token。
|
||||
*
|
||||
* 分组与 globals.css 的设计 token 注释一一对应:
|
||||
* 背景 6 档 + 边框 4 档 + 文字 4 档 + 强调 3 档
|
||||
* + 状态 6 个(色 / 实心底 各 3)+ 模块语义 2 + 遮罩 1 + 滚动条 1
|
||||
*/
|
||||
const REQUIRED_TOKENS = [
|
||||
// 背景层级
|
||||
'bg-rgb',
|
||||
'bg-canvas-rgb',
|
||||
'bg-surface-rgb',
|
||||
'bg-raised-rgb',
|
||||
'bg-hover-rgb',
|
||||
'bg-active-rgb',
|
||||
'bg-selected-rgb',
|
||||
// 边框
|
||||
'border-rgb',
|
||||
'border-subtle-rgb',
|
||||
'border-strong-rgb',
|
||||
'border-focus-rgb',
|
||||
// 文字四级灰阶
|
||||
'fg-rgb',
|
||||
'fg-secondary-rgb',
|
||||
'fg-tertiary-rgb',
|
||||
'fg-muted-rgb',
|
||||
// 强调
|
||||
'accent-rgb',
|
||||
'accent-hover-rgb',
|
||||
'accent-dim-rgb',
|
||||
// 状态色(色 / 实心底 两槽)
|
||||
'success-rgb',
|
||||
'success-strong-rgb',
|
||||
'warning-rgb',
|
||||
'warning-strong-rgb',
|
||||
'danger-rgb',
|
||||
'danger-strong-rgb',
|
||||
// 模块语义色 / 滚动条
|
||||
'module-library-rgb',
|
||||
'module-notes-rgb',
|
||||
'scrollbar-thumb-rgb',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 刻意**不**要求每档预设都定义的 token:
|
||||
* overlay-scrim-rgb —— 遮罩层永远是纯黑(浅色主题上也是黑遮罩,这是
|
||||
* 桌面端通用做法)。它随 :root 继承即可,逐档重复写没有意义。
|
||||
*/
|
||||
|
||||
const blocks = parseTopLevelBlocks(CSS);
|
||||
|
||||
describe('主题配方 token 完整性(r247)', () => {
|
||||
it('两块基础配方(暗 / 浅)定义了全部必需 token', () => {
|
||||
for (const polarity of ['dark', 'light'] as const) {
|
||||
const block = findPreviewBlock(blocks, polarity);
|
||||
expect(block, `globals.css 里找不到 [data-theme-preview='${polarity}'] 基础配方块`).toBeDefined();
|
||||
const missing = REQUIRED_TOKENS.filter((t) => !block!.tokens.has(t));
|
||||
expect(missing, `基础${polarity}配方缺 token: ${missing.join(', ')}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('每档预设都有对应的 CSS 配方块', () => {
|
||||
const missing = THEME_PRESETS.filter((p) => p !== 'default' && !findPresetBlock(blocks, p));
|
||||
expect(missing, `这些预设没有 CSS 配方块(切过去会继承基础配方): ${missing.join(', ')}`).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('每档预设都定义了全部必需 token', () => {
|
||||
const problems: string[] = [];
|
||||
for (const preset of THEME_PRESETS) {
|
||||
if (preset === 'default') continue;
|
||||
const block = findPresetBlock(blocks, preset);
|
||||
if (!block) continue; // 上一条已报
|
||||
const missing = REQUIRED_TOKENS.filter((t) => !block.tokens.has(t));
|
||||
if (missing.length) problems.push(`${preset} 缺 ${missing.join(', ')}`);
|
||||
}
|
||||
expect(problems, `预设配方不完整:\n${problems.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('每档预设都声明了 color-scheme(原生控件 / 滚动条跟随)', () => {
|
||||
const problems: string[] = [];
|
||||
for (const preset of THEME_PRESETS) {
|
||||
if (preset === 'default') continue;
|
||||
const block = findPresetBlock(blocks, preset);
|
||||
if (!block) continue;
|
||||
const scheme = block.decls.get('color-scheme');
|
||||
if (scheme !== 'light' && scheme !== 'dark') {
|
||||
problems.push(`${preset} → color-scheme=${String(scheme)}`);
|
||||
}
|
||||
}
|
||||
expect(problems, `这些预设没声明 color-scheme:\n${problems.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('没有孤儿预设块(CSS 里有、THEME_PRESETS 里没有的预设)', () => {
|
||||
const orphans: string[] = [];
|
||||
for (const block of blocks) {
|
||||
const m = block.selector.match(/\[data-theme-preset='([^']+)'\]/);
|
||||
if (!m) continue;
|
||||
if (!(THEME_PRESETS as readonly string[]).includes(m[1])) orphans.push(m[1]);
|
||||
}
|
||||
expect(orphans, `这些 CSS 预设块已不在 THEME_PRESETS 里(删预设漏删 CSS): ${orphans.join(', ')}`).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user