This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View 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([]);
});
});

View File

@@ -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*:/);
});
});

View 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;
}

View 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([]);
});
});

View 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(
[],
);
});
});