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,164 @@
// tests/unit/shared/font-enum-single-source-parity.test.ts
//
// 守字体族枚举的**单一真相源**契约(2026-10-20,r229):
//
// FONT_FAMILIES(src/shared/types.ts)是唯一权威列表;任何**手抄**了字体族
// 字面量的地方(zod schema / 命令注册表 / 设置页选项表)必须与它对齐。
//
// 为什么这条是行为面:字体族是「命令面板切字体 → 设置页下拉 → 设置持久化
// 校验」三段链路的共同输入。**漏改 zod schema 的后果最隐蔽** —— 用户选了
// 新字体,存盘时 strict schema 拒掉,字体设置**静默丢失**(没有任何报错)。
// 这与 r125 修掉的 theme 族是同一类病,当时已建哨兵
// tests/unit/shared/themes-enum-single-source-parity.test.ts;字体族当时漏了,
// 本轮补齐(同款结构:运行时契约 + 源码扫描双保险)。
//
// 本文件用「运行时契约 + 源码扫描」实现,不依赖被扫文件的内部结构:
// 1. tuple 非空 + 元素唯一;
// 2. types.ts 的 `type FontFamily = 'a' | 'b' | …` 联合类型与 tuple 同成员;
// 3. **zod 边界**:ZodWorkspaceLayout 接受每个 tuple 成员、拒绝 tuple 外的名字
// (schema 手抄漂移 ⇒ 这里红);
// 4. **命令注册表**:buildCommands 为每个 tuple 成员注册且仅注册一条
// `theme.setFont.<font>` 命令;
// 5. **设置页选项表**:FONT_TITLE_KEYS 的 key 集合 === tuple 成员集合;
// 6. **源码扫描**:src/ 下任何提到 'serif' / 'mono' 的括号组,必须提到
// **全部** tuple 成员(加字族漏改副本站 ⇒ 红)。
import { readdirSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
import { FONT_FAMILIES, type FontFamily } from '../../../src/shared/types';
import { ZodWorkspaceLayout } from '../../../src/shared/schemas/settings';
import { buildCommands, type CommandActions } from '../../../src/renderer/src/command/registry';
const ROOT = process.cwd();
/** 扫 src/ 下所有 .ts/.tsx */
function walkTs(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walkTs(full));
else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) out.push(full);
}
return out;
}
/** 取出源码里所有括号组的内容(含嵌套) */
function bracketGroups(src: string): string[] {
const out: string[] = [];
const stack: number[] = [];
for (let i = 0; i < src.length; i++) {
const c = src[i];
if (c === '(' || c === '[' || c === '{') {
stack.push(i);
} else if (c === ')' || c === ']' || c === '}') {
const start = stack.pop();
if (start !== undefined) out.push(src.slice(start + 1, i));
}
}
return out;
}
/**
* 字体族「有辨识度」的名字 —— 排除 'system'(与 theme 的 light/dark/system
* 撞名,不能拿它当「这是字体族上下文」的判据)。
*/
// 显式标注 readonly FontFamily[] —— 否则 filter 的类型收窄会把元素类型
// 压成 ('serif'|'mono')[],后续拿 FontFamily 值去 includes 就报 TS2345。
const DISTINCTIVE: readonly FontFamily[] = FONT_FAMILIES.filter((f) => f !== 'system');
/** 组内出现的字体族名(引号字面量 + 裸标识符 —— `serif: '...'` 是裸键) */
function fontNamesIn(group: string): string[] {
const quoted = [...group.matchAll(/'([^']*)'|"([^"]*)"/g)].map((m) => m[1] ?? m[2] ?? '');
const bare = [...group.matchAll(/\b([A-Za-z][\w-]*)\b/g)].map((m) => m[1]!);
const found = new Set<string>();
for (const t of [...quoted, ...bare]) {
if ((FONT_FAMILIES as readonly string[]).includes(t)) found.add(t);
}
return [...found];
}
describe('字体族枚举单一真相源(r229)', () => {
it('FONT_FAMILIES 非空且元素唯一', () => {
expect(FONT_FAMILIES.length).toBeGreaterThan(0);
expect(new Set(FONT_FAMILIES).size).toBe(FONT_FAMILIES.length);
});
it('types.ts 的 FontFamily 联合类型与 FONT_FAMILIES 同成员', () => {
const typesSrc = readFileSync(join(ROOT, 'src/shared/types.ts'), 'utf8');
const m = typesSrc.match(/export type FontFamily\s*=\s*([^;]+);/);
expect(m, 'types.ts 里找不到 FontFamily 联合类型').not.toBeNull();
const unionMembers = [...(m as RegExpMatchArray)[1].matchAll(/'([^']+)'/g)].map((x) => x[1]!);
expect([...unionMembers].sort()).toEqual([...FONT_FAMILIES].sort());
});
it('zod 边界:每个 FONT_FAMILIES 成员都被 ZodWorkspaceLayout 接受', () => {
for (const f of FONT_FAMILIES) {
const r = ZodWorkspaceLayout.safeParse({ fontFamily: f });
expect(r.success, `字体族 ${f} 应被 schema 接受(存盘被拒 = 字体设置静默丢失)`).toBe(true);
}
});
it('zod 边界:tuple 外的字体名被 ZodWorkspaceLayout 拒绝', () => {
const r = ZodWorkspaceLayout.safeParse({ fontFamily: 'comic-sans-not-a-real-family' });
expect(r.success).toBe(false);
});
it('命令注册表:每个 FONT_FAMILIES 成员各注册一条 theme.setFont.<font> 命令', () => {
const actions = {
setActive: () => {},
toggleStatusBar: () => {},
// r240:新增的左栏折叠 action(CommandActions 的必填字段)
toggleSidebar: () => {},
setFontFamily: () => {},
setThemePreset: () => {},
toggleTheme: async () => {},
openSettings: () => {},
openSearch: () => {},
saveActiveNote: () => {},
newNote: () => {},
} satisfies CommandActions;
const cmds = buildCommands(actions);
const fontCmdIds = cmds.map((c) => c.id).filter((id) => id.startsWith('theme.setFont.'));
const expected = FONT_FAMILIES.map((f) => `theme.setFont.${f}`);
expect([...fontCmdIds].sort(), '命令面板里的字体命令应与 FONT_FAMILIES 一一对应').toEqual(
[...expected].sort(),
);
});
it('设置页选项表:FONT_TITLE_KEYS 的 key 集合 === FONT_FAMILIES 成员集合', () => {
const src = readFileSync(
join(ROOT, 'src/renderer/src/components/settings/SettingsPage.tsx'),
'utf8',
);
const m = /const FONT_TITLE_KEYS[^=]*=\s*\{([^}]*)\}/.exec(src);
expect(m, 'SettingsPage.tsx 里找不到 FONT_TITLE_KEYS 表').not.toBeNull();
const keys = [...(m as RegExpMatchArray)[1].matchAll(/\b([A-Za-z][\w-]*)\s*:/g)].map(
(x) => x[1]!,
);
expect([...keys].sort()).toEqual([...FONT_FAMILIES].sort());
});
it('源码扫描:提到任一字体的括号组必须提到全部 FONT_FAMILIES 成员', () => {
const violations: string[] = [];
for (const file of walkTs(join(ROOT, 'src'))) {
const src = readFileSync(file, 'utf8');
for (const group of bracketGroups(src)) {
const names = fontNamesIn(group);
if (names.length === 0) continue;
// 只在该组确实提到「有辨识度的字体名」时才算字体族上下文
if (!names.some((n) => DISTINCTIVE.includes(n as FontFamily))) continue;
const missing = FONT_FAMILIES.filter((f) => !names.includes(f));
if (missing.length > 0) {
violations.push(
`${file.replace(ROOT, '.')}: 提到字体 ${names.join('/')} 但缺 ${missing.join('/')}`,
);
}
}
}
expect(
violations,
`这些位置手抄了字体族却漏改(加字族时会静默漂移):\n${violations.join('\n')}`,
).toEqual([]);
});
});

View File

@@ -0,0 +1,82 @@
// tests/unit/shared/ipc-error-codes-dup.test.ts
//
// 守 IPC 错误码 union 的两条不变式(2026-10-20,r201):
//
// 1. IPC_ERROR_CODES 里**没有重复元素**。
// 历史事故(r35/r37):同一个 'CONFLICT' 被补了两次。Set 去重让运行时
// 无影响,但未来删/改其中一个时,漏改的那处会让 wrap.ts 的
// "code ∈ union?" 判定走 INTERNAL 兜底 ⇒ 具体的冲突提示退化成
// 泛化的 "internal error"。
//
// 2. src/ 里**每一个** `new AppError('X'` / `new LibraryError('X'`
// 的字面量 code 都必须落在 union 内。
// 为什么这条要单独锁:AppError 的 code 参数是 `string`(向后兼容),
// TS **不会**报错;而 wrap.ts 在出口处发现 code 不在 union 里会
// fallback 成 INTERNAL **并擦掉 message** ⇒ 用户看到的是
// "internal error",而真正的原因(比如 'NOT_READY'、'EOPEN')被吞掉。
// 这类 bug 只有 runtime 断言能拦。
//
// 另附:isIpcErrorCode 的运行时行为(union 内 / union 外)。
import { readdirSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
import { IPC_ERROR_CODES, isIpcErrorCode } from '../../../src/shared/types';
const ROOT = process.cwd();
function walkTs(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walkTs(full));
else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) out.push(full);
}
return out;
}
describe('IPC_ERROR_CODES — 单一真相源(r201)', () => {
it('tuple 元素互不重复(r35/r37 事故回归锁)', () => {
const seen = new Set<string>();
const dups = IPC_ERROR_CODES.filter((c) => {
if (seen.has(c)) return true;
seen.add(c);
return false;
});
expect(dups, `IPC_ERROR_CODES 里有重复元素: ${dups.join(', ')}`).toEqual([]);
});
it('每个 code 都是非空大写 snake 串', () => {
for (const c of IPC_ERROR_CODES) {
expect(c, `code 不合法: ${c}`).toMatch(/^[A-Z][A-Z0-9_]*$/);
}
});
it('isIpcErrorCode:union 内 → true,union 外 → false', () => {
for (const c of IPC_ERROR_CODES) {
expect(isIpcErrorCode(c), c).toBe(true);
}
expect(isIpcErrorCode('DUPLICATE_NAMES')).toBe(false);
expect(isIpcErrorCode('NOT_A_REAL_CODE')).toBe(false);
expect(isIpcErrorCode('')).toBe(false);
});
it('src/ 里每个 AppError/LibraryError 字面量 code 都在 union 内', () => {
const union = new Set<string>(IPC_ERROR_CODES);
const bad: string[] = [];
const re = /new\s+(?:AppError|LibraryError)\(\s*'([A-Z][A-Z0-9_]*)'/g;
for (const file of walkTs(join(ROOT, 'src'))) {
const src = readFileSync(file, 'utf8');
const rel = file.slice(ROOT.length + 1).replaceAll('\\', '/');
for (const m of src.matchAll(re)) {
if (!union.has(m[1])) bad.push(`${rel} → '${m[1]}'`);
}
}
expect(
bad,
`以下 code 不在 IPC_ERROR_CODES 里 —— wrap.ts 会 fallback INTERNAL 并擦掉 message:\n${bad.join(
'\n',
)}`,
).toEqual([]);
});
});

View File

@@ -0,0 +1,101 @@
// tests/unit/shared/sidebar-collapsed-persistence.test.ts
//
// 守 r242「折叠 / 展开左侧栏」的**持久化与接线**契约。
//
// 背景:左栏折叠态存在 settings.json 的 workspaceLayout.sidebarCollapsed。
// 这条链路上有三个「漏一环就静默失效」的坑,本测逐个钉住:
//
// ① **ZodWorkspaceLayout 是 .strict()** —— schema 漏加 sidebarCollapsed,
// renderer 写回的 workspaceLayout 被判「未声明字段」⇒ 整个
// settings.update 被拒 ⇒ 折叠态永远存不下来(用户每次重启都要
// 再点一次折叠按钮,且可能看到「保存失败」类报错)。
// ② **主进程 sanitizeWorkspaceLayout 有白名单** —— 只放行
// statusBarHidden / fontFamily / themePreset;漏加 sidebarCollapsed
// ⇒ 字段在主进程被剥掉 ⇒ 同 ① 的后果(且没有报错,更难查)。
// ③ **主进程菜单 → renderer 命令 id** —— View 菜单的「折叠 / 展开
// 左侧栏」点下去是 IPC 推 id 给 dispatcher;id 拼错 ⇒ 菜单项点了
// 没反应(dispatcher 只 warn「unknown command」,用户看不到原因)。
//
// 本测锁:
// A. ZodWorkspaceLayout 接受 sidebarCollapsed:boolean,拒绝非 boolean,
// 仍然 strict(未知字段照拒)。
// B. 主进程 sanitize 保留 sidebarCollapsed、剥掉未知字段。
// C. 主进程 View 菜单有该项(accelerator CmdOrCtrl+B)且发的是
// 'view.toggleSidebar'。
// D. 菜单里出现的每个命令 id 都能在 renderer 命令注册表里找到
// (防「菜单项点了没反应」这一类死链)。
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { ZodWorkspaceLayout } from '../../../src/shared/schemas/settings';
import { sanitizeLoadedSettings } from '../../../src/main/settings/validate';
import { buildCommands, type CommandActions } from '../../../src/renderer/src/command/registry';
const ROOT = process.cwd();
const MENU_FILE = join(ROOT, 'src/main/menu.ts');
describe('左栏折叠态的持久化与接线(r242)', () => {
it('A1. ZodWorkspaceLayout 接受 sidebarCollapsed:boolean', () => {
const r = ZodWorkspaceLayout.safeParse({ sidebarCollapsed: true });
expect(r.success, 'schema 漏加 sidebarCollapsed ⇒ 折叠态存不下来').toBe(true);
const r2 = ZodWorkspaceLayout.safeParse({});
expect(r2.success, '未设置折叠态的新用户必须能过 schema').toBe(true);
});
it('A2. ZodWorkspaceLayout 拒绝非 boolean 的 sidebarCollapsed', () => {
expect(ZodWorkspaceLayout.safeParse({ sidebarCollapsed: 'yes' }).success).toBe(false);
expect(ZodWorkspaceLayout.safeParse({ sidebarCollapsed: 1 }).success).toBe(false);
});
it('A3. ZodWorkspaceLayout 仍然 strict(未知字段照拒)', () => {
expect(ZodWorkspaceLayout.safeParse({ somethingElse: true }).success).toBe(false);
});
it('B. 主进程 sanitize 保留 sidebarCollapsed、剥掉未知字段', () => {
const out = sanitizeLoadedSettings({
workspaceLayout: { statusBarHidden: true, sidebarCollapsed: true, bogusField: 1 },
});
expect(out.workspaceLayout).toMatchObject({
statusBarHidden: true,
sidebarCollapsed: true,
});
expect(out.workspaceLayout).not.toHaveProperty('bogusField');
});
it('C. 主进程 View 菜单有「折叠 / 展开左侧栏」且发 view.toggleSidebar', () => {
const src = readFileSync(MENU_FILE, 'utf8');
expect(src, "View 菜单缺少 toggleSidebar 项").toContain("menu.view.toggleSidebar");
expect(src, "toggleSidebar 菜单项没发 view.toggleSidebar 命令 id").toContain(
"send('view.toggleSidebar')",
);
expect(src, 'toggleSidebar 菜单项的 accelerator 应为 CmdOrCtrl+B').toContain(
"accelerator: 'CmdOrCtrl+B'",
);
});
it('D. 菜单里出现的每个命令 id 都能在 renderer 命令注册表里找到', () => {
const src = readFileSync(MENU_FILE, 'utf8');
const sent = [...src.matchAll(/send\(\s*'([A-Za-z0-9_.]+)'\s*\)/g)].map((m) => m[1]);
expect(sent.length, '菜单里一个 send() 都没扫到 —— 扫点失效,本测空过').toBeGreaterThan(3);
const actions = {
setActive: () => {},
toggleStatusBar: () => {},
toggleSidebar: () => {},
setFontFamily: () => {},
setThemePreset: () => {},
toggleTheme: async () => {},
openSettings: () => {},
openSearch: () => {},
saveActiveNote: () => {},
newNote: () => {},
} satisfies CommandActions;
const ids = new Set(buildCommands(actions).map((c) => c.id));
const orphan = sent.filter((id) => !ids.has(id));
expect(
orphan,
`菜单项的命令 id 在 renderer 注册表里不存在 ⇒ 点了没反应:\n${orphan.join('\n')}`,
).toEqual([]);
});
});

View File

@@ -0,0 +1,203 @@
// tests/unit/shared/themes-enum-single-source-parity.test.ts
//
// 守 theme 枚举的**单一真相源**契约(2026-10-20,r201):
//
// THEME_PRESETS(src/shared/types.ts)是唯一权威列表;任何**手抄**了主题
// 预设字面量的地方(命令注册表 / 设置页选项表 / 暗色配方表 / zod schema)
// 必须与它**逐个对齐**。
//
// 为什么这条是行为面:预设集合是"主题切换命令 → 设置页下拉 → 暗色配方表
// → 设置持久化校验"这四段链路的共同输入。新增一个预设却漏改其中一处时,
// - 漏改命令注册表 → 命令面板里选不到新主题;
// - 漏改暗色配方表 → Record<ThemePreset, …> 编译期就红(TS 兜底);
// - 漏改 zod schema → 用户选了新主题,**存盘时被 strict schema 拒**,
// 主题设置静默丢失(最隐蔽的一种)。
//
// 本文件用「源码扫描」实现,不依赖被扫文件的内部结构:
// 1. 扫 src/ 下所有 .ts/.tsx 的**括号组**,凡是提到 ≥2 个不同预设名的组,
// 必须提到**全部**预设名(漏改即红);
// 2. types.ts 里的 `type ThemePreset = 'a' | 'b' | …` 联合类型与
// THEME_PRESETS 必须同成员(运行时 tuple 与编译期 union 两份手抄);
// 3. 预设名不得出现「已不在 tuple 里的旧名字」(删预设漏改副本站);
// 4. 四处**已知**副本站(zod schema / 暗色配方表 / 命令注册表 / 设置页
// 预设元数据表)的预设集合与 tuple **完全一致**(不多不少)。
// r247:设置页那一站搬到 components/settings/ThemePicker.tsx
// (15 档预设改色卡网格,选项表随之搬家)。
import { readdirSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
import { THEME_PRESETS } from '../../../src/shared/types';
const ROOT = process.cwd();
/** 扫 src/ 下所有 .ts/.tsx */
function walkTs(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walkTs(full));
else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) out.push(full);
}
return out;
}
/** 取出源码里所有括号组的内容(含嵌套) */
function bracketGroups(src: string): string[] {
const out: string[] = [];
const stack: number[] = [];
for (let i = 0; i < src.length; i++) {
const c = src[i];
if (c === '(' || c === '[' || c === '{') {
stack.push(i);
} else if (c === ')' || c === ']' || c === '}') {
const start = stack.pop();
if (start !== undefined) out.push(src.slice(start + 1, i));
}
}
return out;
}
/** 组内出现的预设名(引号字面量 + 裸标识符 —— `default: null` 是裸键) */
function presetNamesIn(group: string, known: readonly string[]): string[] {
const quoted = [...group.matchAll(/'([^']*)'|"([^"]*)"/g)].map((m) => m[1] ?? m[2] ?? '');
const bare = [...group.matchAll(/\b([A-Za-z][\w-]*)\b/g)].map((m) => m[1]);
const found = new Set<string>();
for (const t of [...quoted, ...bare]) {
if (known.includes(t)) found.add(t);
}
return [...found];
}
/**
* 取对象字面量**顶层**的键(嵌套对象里的键不算)。
* PRESET_META 的每个条目是 `{ titleKey: …, hintKey: … }`,内层的 titleKey /
* hintKey 不是预设名,必须排除 —— 否则抽取结果会多出两个「预设」。
*/
function topLevelKeys(body: string): string[] {
const keys: string[] = [];
let depth = 0;
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch === '{' || ch === '[' || ch === '(') {
depth++;
continue;
}
if (ch === '}' || ch === ']' || ch === ')') {
depth--;
continue;
}
if (depth !== 0) continue;
const m = body.slice(i).match(/^\s*(?:'([^']+)'|([A-Za-z][\w-]*))\s*:/);
if (m) {
keys.push(m[1] ?? m[2]);
i += m[0].length - 1;
}
}
return keys;
}
describe('theme 枚举单一真相源(r201)', () => {
it('THEME_PRESETS 非空且元素唯一', () => {
expect(THEME_PRESETS.length).toBeGreaterThan(0);
expect(new Set(THEME_PRESETS).size).toBe(THEME_PRESETS.length);
});
it('types.ts 的 ThemePreset 联合类型与 THEME_PRESETS 同成员', () => {
const typesSrc = readFileSync(join(ROOT, 'src/shared/types.ts'), 'utf8');
const m = typesSrc.match(/export type ThemePreset\s*=\s*([^;]+);/);
expect(m, 'types.ts 里找不到 ThemePreset 联合类型').not.toBeNull();
const unionMembers = [...(m as RegExpMatchArray)[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
expect([...unionMembers].sort()).toEqual([...THEME_PRESETS].sort());
});
it('src/ 里凡是提到 ≥2 个预设的括号组,必须列全所有预设', () => {
const known = [...THEME_PRESETS];
const violations: string[] = [];
for (const file of walkTs(join(ROOT, 'src'))) {
const src = readFileSync(file, 'utf8');
const rel = file.slice(ROOT.length + 1).replaceAll('\\', '/');
for (const group of bracketGroups(src)) {
const names = presetNamesIn(group, known);
if (names.length >= 2 && names.length < known.length) {
violations.push(`${rel} → 提到 [${names.join(', ')}],缺 [${known
.filter((k) => !names.includes(k))
.join(', ')}]`);
}
}
}
expect(violations, `以下位置手抄的主题预设与 THEME_PRESETS 不一致:\n${violations.join('\n')}`).toEqual(
[],
);
});
it('四处副本站的预设集合与 THEME_PRESETS **完全一致**(不多不少)', () => {
// 定向抽取 4 个副本站 —— 「多一个」= 删预设漏改副本站,
// 「少一个」= 加预设漏改副本站,两个方向都锁。
//
// r247 起两个副本站改成**引 SSOT** 而不是手抄(zod enum / 命令注册表),
// 抽取器因此接受两种形态:
// - 手抄字面量 → 抽出字面量比对
// - 直接引用 THEME_PRESETS → 视为「恒等」(集合必然一致),但仍要求
// 该引用真的存在 —— 否则「删了这行」会被当成通过。
const expected = [...THEME_PRESETS].sort();
const cases: Array<{ label: string; file: string; extract: (src: string) => string[] }> = [
{
label: 'zod schema(z.enum)',
file: 'src/shared/schemas/settings.ts',
extract: (src) => {
if (/themePreset:\s*z\.enum\(THEME_PRESETS\)/.test(src)) return expected;
const m = src.match(/themePreset:\s*z\.enum\(\[([^\]]*)\]\)/);
expect(m, 'settings schema 里找不到 themePreset 的 z.enum').not.toBeNull();
return [...(m as RegExpMatchArray)[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
},
},
{
label: '暗色配方表(PRESET_IS_DARK)',
file: 'src/renderer/src/lib/theme.ts',
extract: (src) => {
const m = src.match(/PRESET_IS_DARK[^=]*=\s*\{([^}]*)\}/);
expect(m, 'theme.ts 里找不到 PRESET_IS_DARK 表').not.toBeNull();
const body = (m as RegExpMatchArray)[1];
const quoted = [...body.matchAll(/'([^']+)'/g)].map((x) => x[1]);
const bare = [...body.matchAll(/^\s*([A-Za-z][\w-]*)\s*:/gm)].map((x) => x[1]);
return [...quoted, ...bare];
},
},
{
label: '命令注册表(presetIds)',
file: 'src/renderer/src/command/registry.ts',
extract: (src) => {
if (/presetIds[^=]*=\s*\[\.\.\.THEME_PRESETS\]/.test(src)) return expected;
const m = src.match(/presetIds[^=]*=\s*\[([^\]]*)\]/);
expect(m, 'registry.ts 里找不到 presetIds 列表').not.toBeNull();
return [...(m as RegExpMatchArray)[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
},
},
{
// r247:设置页的预设元数据搬到 components/settings/ThemePicker.tsx
// (15 档预设改色卡网格,不再用文字芯片列表)。
label: '设置页预设元数据(PRESET_META)',
file: 'src/renderer/src/components/settings/ThemePicker.tsx',
extract: (src) => {
const m = src.match(/PRESET_META[^=]*=\s*\{([\s\S]*?)\n\};/);
expect(m, 'ThemePicker.tsx 里找不到 PRESET_META 表').not.toBeNull();
return topLevelKeys((m as RegExpMatchArray)[1]);
},
},
];
const bad: string[] = [];
for (const c of cases) {
const found = c.extract(readFileSync(join(ROOT, c.file), 'utf8'));
const sorted = [...found].sort();
if (sorted.join(',') !== expected.join(',')) {
bad.push(`${c.label}(${c.file}) → [${sorted.join(', ')}] ≠ [${expected.join(', ')}]`);
}
}
expect(
bad,
`以下副本站与 THEME_PRESETS 不一致:\n${bad.join('\n')}`,
).toEqual([]);
});
});