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,102 @@
// tests/unit/renderer/lib/bootstrap-apply-once.test.ts
//
// 守卫(r206,2026-10-20):bootstrap 结果「应用且仅应用一次」的单点契约。
//
// 起因(r87):r85 的单飞修了「重复 IPC」,没修「重复应用」—— 两个 awaiter
// 拿到同一结果后**各自** applyBootstrapSettings + setState({paths,loaded})。
// pendingLayout 的消费在 applyPendingLayoutFallback 里
// `void api.updateSettings(...).then(removeItem)` —— key 清理是异步的,
// 同帧双 apply ⇒ 双 updateSettings 写(同值,LOW)。修:apply 收口到
// lib/bootstrap.ts 的 applyBootstrapOnce 单点。
//
// 契约三分支:
// - r.ok 且尚未应用 → 'applied':applyBootstrapSettings + setState({paths, loaded:true});
// - r.ok 但已应用(另一 continuation 先落地 / 测试直接置 loaded)
// → 'already-applied':**零 store 写、零 IPC** —— 首落地值不被覆写;
// - !r.ok → 'ipc-failed':**纯判定不碰 state**。调用方按各自职责处理
// (main:log.warn + loaded=true 标记「本轮启动尝试过」;App:legacy
// settings/paths pair 修复)。两个调用方都会拿到同一个 'ipc-failed',
// 修复只由 App 执行一次 —— 这是有意分工,不是守卫缺失。
//
// 隔离:本文件**不**用 vi.resetModules() —— applyBootstrapOnce 读写的
// paths store 是模块级 zustand 单例,resetModules 会造出**另一份**实例,
// 令「apply 写进去的 loaded」读不到(本轮第一版就踩了这个坑)。
// 改为每例显式 setState 复位 loaded。
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { BootstrapPayload, IpcResult } from '@shared/types';
const { applyBootstrapSettings } = vi.hoisted(() => ({ applyBootstrapSettings: vi.fn() }));
vi.mock('@renderer/store/settings', () => ({ applyBootstrapSettings }));
const PAYLOAD = {
settings: { themePreset: 'default' } as unknown as BootstrapPayload['settings'],
paths: { rootPath: '/tmp/x' } as unknown as BootstrapPayload['paths'],
} as BootstrapPayload;
const OK = { ok: true, data: PAYLOAD } as IpcResult<BootstrapPayload>;
const FAIL = { ok: false, code: 'INTERNAL', message: 'boom' } as IpcResult<BootstrapPayload>;
describe('bootstrap apply-once(r206)', () => {
beforeEach(async () => {
applyBootstrapSettings.mockReset();
const { usePathsStore } = await import('@renderer/store/paths');
usePathsStore.setState({ loaded: false, paths: null as never });
});
it('r.ok 且未加载 → applied,并写 settings + paths + loaded', async () => {
const { applyBootstrapOnce } = await import('@renderer/lib/bootstrap');
const { usePathsStore } = await import('@renderer/store/paths');
const out = applyBootstrapOnce(OK);
expect(out).toBe('applied');
expect(applyBootstrapSettings).toHaveBeenCalledTimes(1);
expect(applyBootstrapSettings).toHaveBeenCalledWith(PAYLOAD.settings);
const st = usePathsStore.getState();
expect(st.loaded).toBe(true);
expect(st.paths).toEqual(PAYLOAD.paths);
});
it('已应用(loaded=true)→ already-applied,零 store 写', async () => {
const { applyBootstrapOnce } = await import('@renderer/lib/bootstrap');
const { usePathsStore } = await import('@renderer/store/paths');
usePathsStore.setState({ loaded: true, paths: { rootPath: '/keep' } as never });
const out = applyBootstrapOnce(OK);
expect(out).toBe('already-applied');
expect(applyBootstrapSettings).not.toHaveBeenCalled();
// 首落地的值不被覆写
expect(usePathsStore.getState().paths).toEqual({ rootPath: '/keep' });
});
it('!r.ok → ipc-failed,且**不碰 state**(loaded 仍为 false)', async () => {
const { applyBootstrapOnce } = await import('@renderer/lib/bootstrap');
const { usePathsStore } = await import('@renderer/store/paths');
const out = applyBootstrapOnce(FAIL);
expect(out).toBe('ipc-failed');
expect(applyBootstrapSettings).not.toHaveBeenCalled();
expect(usePathsStore.getState().loaded).toBe(false);
});
it('连续两次 apply 同一个结果:第二次是 already-applied(只写一次)', async () => {
const { applyBootstrapOnce } = await import('@renderer/lib/bootstrap');
expect(applyBootstrapOnce(OK)).toBe('applied');
expect(applyBootstrapOnce(OK)).toBe('already-applied');
expect(applyBootstrapSettings).toHaveBeenCalledTimes(1);
});
it('ipc-failed 不写 loaded —— 「本轮启动尝试过」由调用方自己置', async () => {
// main.tsx 的语义是「失败也置 loaded=true」,但那是**调用方**的动作,
// 不是 applyBootstrapOnce 的动作。本例锁住这个分工边界。
const { applyBootstrapOnce } = await import('@renderer/lib/bootstrap');
const { usePathsStore } = await import('@renderer/store/paths');
applyBootstrapOnce(FAIL);
expect(usePathsStore.getState().loaded).toBe(false);
});
});

View File

@@ -0,0 +1,139 @@
// tests/unit/renderer/lib/bootstrap-single-flight.test.ts
//
// 守卫(r206,2026-10-20):bootstrap 的**单飞**(single-flight)契约。
//
// 起因(r85 recon):main.tsx 后台 IIFE 第一行发 bootstrap,App.tsx passive
// effect 又「幂等兜底」地看 usePathsStore.loaded 决定要不要再发 —— 但
// React passive effect flush(<16ms)永远早于 bootstrap IPC 往返(50-200ms),
// loaded 此刻必然还是 false,所以**每次冷启都并发两次 bootstrap IPC**
// (dev StrictMode 三次)。main.tsx 里那句「loaded 守卫保证不会跑 legacy
// fallback」是**不生效的假守卫**。
//
// 契约(本文件逐条钉):
// - 并发调用命中同一 promise ⇒ 只发**一次** IPC;
// - 成功结果(**含 ok:false 的合法回复**)被缓存 ⇒ 不重发 —— 与
// main.tsx「失败后置 loaded=true」的「本轮启动尝试过」语义一致;
// - rejection(桥未就绪 / IPC 抛)清缓存 ⇒ 瞬时 race 之后重调可再试
// (这才是原注释「幂等兜底」想表达却没做到的行为);
// - 桥缺失(api.bootstrap 不是函数)转成 **rejection** 而不是同步 throw
// —— 符合 r83 契约(调用点带同线 typeof guard,Proxy 属性读安全)。
//
// 隔离手段:bootstrap.ts 的 inflight 是**模块级**变量 ⇒ 每例
// vi.resetModules() + 动态 import 拿全新模块实例。
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { BootstrapPayload, IpcResult } from '@shared/types';
const OK_PAYLOAD = {
settings: {} as BootstrapPayload['settings'],
paths: {} as BootstrapPayload['paths'],
} as BootstrapPayload;
async function freshBootstrap() {
vi.resetModules();
return await import('@renderer/lib/bootstrap');
}
function setBridge(value: unknown): void {
(window as unknown as { api?: unknown }).api = value;
}
afterEach(() => {
setBridge(undefined);
vi.restoreAllMocks();
});
describe('bootstrap 单飞(r206)', () => {
it('并发调用只发一次 IPC', async () => {
const mod = await freshBootstrap();
const bootstrap = vi.fn().mockResolvedValue({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
setBridge({ bootstrap });
const [a, b] = await Promise.all([mod.bootstrapOnce(), mod.bootstrapOnce()]);
expect(bootstrap).toHaveBeenCalledTimes(1);
expect(a).toBe(b); // 同一个 promise ⇒ 同一个结果对象
});
it('三个并发调用仍只发一次', async () => {
const mod = await freshBootstrap();
const bootstrap = vi.fn().mockResolvedValue({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
setBridge({ bootstrap });
await Promise.all([mod.bootstrapOnce(), mod.bootstrapOnce(), mod.bootstrapOnce()]);
expect(bootstrap).toHaveBeenCalledTimes(1);
});
it('成功结果被缓存:后续调用不再发 IPC', async () => {
const mod = await freshBootstrap();
const bootstrap = vi.fn().mockResolvedValue({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
setBridge({ bootstrap });
await mod.bootstrapOnce();
await mod.bootstrapOnce();
expect(bootstrap).toHaveBeenCalledTimes(1);
});
it('ok:false 的合法回复也缓存(不重发)', async () => {
const mod = await freshBootstrap();
const bootstrap = vi
.fn()
.mockResolvedValue({ ok: false, code: 'INTERNAL', message: 'x' } as IpcResult<BootstrapPayload>);
setBridge({ bootstrap });
const first = await mod.bootstrapOnce();
const second = await mod.bootstrapOnce();
expect(bootstrap).toHaveBeenCalledTimes(1);
expect(first).toBe(second);
expect(first.ok).toBe(false);
});
it('rejection 清缓存:之后重调能再试(幂等兜底真的兜住了)', async () => {
const mod = await freshBootstrap();
const bootstrap = vi
.fn()
.mockRejectedValueOnce(new Error('bridge race'))
.mockResolvedValueOnce({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
setBridge({ bootstrap });
await expect(mod.bootstrapOnce()).rejects.toThrow('bridge race');
const retry = await mod.bootstrapOnce();
expect(bootstrap).toHaveBeenCalledTimes(2);
expect(retry.ok).toBe(true);
});
it('桥缺失 ⇒ 返回 rejected promise(不是同步 throw)', async () => {
const mod = await freshBootstrap();
setBridge(undefined);
// 同步 throw 会让调用点的 try/catch 之外代码炸掉;契约要求是 rejection。
let promise: Promise<IpcResult<BootstrapPayload>> | null = null;
expect(() => {
promise = mod.bootstrapOnce();
}).not.toThrow();
await expect(promise!).rejects.toThrow(/bridge missing/);
});
it('桥在但方法不是函数 ⇒ 同样转成 rejection', async () => {
const mod = await freshBootstrap();
setBridge({ bootstrap: 'not-a-function' });
await expect(mod.bootstrapOnce()).rejects.toThrow(/bridge missing/);
});
it('单飞是**跨调用**的:第二个 awaiter 拿到的还是第一次的结果', async () => {
const mod = await freshBootstrap();
const bootstrap = vi.fn().mockResolvedValue({ ok: true, data: OK_PAYLOAD } as IpcResult<BootstrapPayload>);
setBridge({ bootstrap });
const p1 = mod.bootstrapOnce();
const p2 = mod.bootstrapOnce();
expect(p1).toBe(p2); // 连 promise 引用都相同
await p1;
await p2;
expect(bootstrap).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,125 @@
// tests/unit/renderer/lib/click-select-route.test.ts
//
// 守 r228「修饰键点击路由单一真相源」契约:
//
// 旧状态(实测分叉):NotesList / LiteratureList 的 handleSelect 各写一份
// mode 分支。文献域的 toggle / range 分支会 setActiveId(让 listbox 本地
// 键盘光标 / aria-activedescendant 跟手),**笔记域漏了** ⇒ 笔记侧
// Ctrl+点击后光标仍停在旧行,紧接着按 F2 会重命名**旧行**而不是刚点的那
// 行(r160 B7 那次「click 后把焦点搬到 listbox」的修复只进了文献侧)。
//
// 修法不是给笔记补一行 setActiveId(下次还会分叉),而是把路由本体上收到
// lib/multi-select.applyClickSelectRoute,两域共用一份 ⇒ 分叉不可能再长
// 出来(同 isReallyGone 上收 utils/file-check.ts 的做法)。
//
// 锁两类东西:
// 1. 行为契约(applyClickSelectRoute):
// toggle ⇒ toggleSelect + setActiveId(光标跟手),不动主选中相关动作;
// range ⇒ selectRange + setActiveId,同上;
// single ⇒ selectOnly + select(设主选中),**不**调 setActiveId
// (两域的 activeId 靠「selectedId 变化 → effect 同步」跟随,
// 在路由里再写一次属重复);
// 2. 端到端一条:修饰键 → 模式 → 路由动作串起来(防「模式解析与路由
// 各改各的」)。
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import {
applyClickSelectRoute,
resolveClickSelectMode,
type ClickSelectMode,
} from '../../../../src/renderer/src/lib/multi-select';
function makeActions() {
const calls: string[] = [];
// audit-r229:参数类型放宽到 `string | null` —— 路由契约里 setActiveId 的
// 签名是 `(id: TId | null) => void`,而 applyClickSelectRoute 的 TId
// 由字面量 id 推断成 'n1' / 'n2' 这类**窄字面量类型** ⇒ mock 若只写
// `(id: string) => void`,把 mock 传给路由时 TS2345 报错
// (peer 在途文件的 typecheck 红,本轮顺手收口)。函数参数逆变 ⇒
// 宽参类型可赋给窄参类型,行为一字未改。
const rec = (name: string) => (id: string | null) => {
calls.push(`${name}:${id}`);
};
return {
calls,
actions: {
toggleSelect: rec('toggle'),
selectRange: rec('range'),
selectOnly: rec('only'),
select: rec('select'),
setActiveId: rec('active'),
},
};
}
describe('applyClickSelectRoute(r228 路由单一真相源)', () => {
it('toggle:切多选成员 + 键盘光标跟手;不动主选中', () => {
const { calls, actions } = makeActions();
applyClickSelectRoute('toggle', 'n1', actions);
expect(calls).toEqual(['toggle:n1', 'active:n1']);
});
it('range:范围选择 + 键盘光标跟手;不动主选中', () => {
const { calls, actions } = makeActions();
applyClickSelectRoute('range', 'n2', actions);
expect(calls).toEqual(['range:n2', 'active:n2']);
});
it('single:作用集收成该行 + 设主选中;不调 setActiveId', () => {
const { calls, actions } = makeActions();
applyClickSelectRoute('single', 'n3', actions);
expect(calls).toEqual(['only:n3', 'select:n3']);
});
it('端到端:修饰键 → 模式 → 动作(Ctrl=toggle / Shift=range / 其它=single)', () => {
const run = (e: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => {
const { calls, actions } = makeActions();
applyClickSelectRoute(resolveClickSelectMode(e), 'x', actions);
return calls;
};
expect(run({ ctrlKey: true, metaKey: false, shiftKey: false })).toEqual([
'toggle:x',
'active:x',
]);
expect(run({ ctrlKey: false, metaKey: true, shiftKey: false })).toEqual([
'toggle:x',
'active:x',
]);
expect(run({ ctrlKey: false, metaKey: false, shiftKey: true })).toEqual([
'range:x',
'active:x',
]);
expect(run({ ctrlKey: false, metaKey: false, shiftKey: false })).toEqual([
'only:x',
'select:x',
]);
});
it('模式解析的取值域就是路由的三分支(新增模式必须同时改路由)', () => {
const modes: ClickSelectMode[] = ['single', 'toggle', 'range'];
for (const m of modes) {
const { calls, actions } = makeActions();
applyClickSelectRoute(m, 'id', actions);
expect(calls.length).toBeGreaterThan(0);
}
});
});
describe('r228 结构契约:两域必须共用同一份路由(孪生分支不得复活)', () => {
const ROOT = process.cwd();
const FILES = [
'src/renderer/src/components/notes/NotesList.tsx',
'src/renderer/src/components/literature/LiteratureList.tsx',
];
for (const rel of FILES) {
it(`${rel} 走 lib/multi-select.applyClickSelectRoute`, () => {
const src = readFileSync(join(ROOT, rel), 'utf-8');
expect(src, '点击路由必须走 lib/multi-select 的单一真相源').toContain('applyClickSelectRoute');
expect(
src,
'孪生块复活:组件里又内联了 mode 分支(两域会再次分叉)',
).not.toContain("if (mode === 'toggle')");
});
}
});

View File

@@ -0,0 +1,170 @@
// tests/unit/renderer/lib/confirm.test.ts
//
// 守卫(r206,2026-10-20):应用内 ConfirmDialog(lib/confirm.ts)的行为契约 +
// 「renderer 不许再用原生阻塞式对话框」的结构不变式。
//
// 起因(r206 recon):删除文件夹走的是**原生 window.confirm**,文案还硬编码
// 英文(`Delete folder "X" and all its contents?`)—— 中文用户在 zh-CN 界面
// 点「删除文件夹」弹出来的是英文原生对话框,而且 window.confirm 会**阻塞
// 渲染进程**、完全脱离应用主题(项目自己在 SettingsPage 里写过这条结论)。
// r206 把它换成应用内 ConfirmDialog(lib/confirm.ts)+ i18n 文案。
//
// 三条不变式:
// ① confirm() 是 Promise 风格:open → active 有值;close(true/false)
// 分别 resolve(true/false);
// ② 并发:第二个 confirm **入队**(不静默 resolve(false) —— 那会让首调用
// 方误以为用户点了「取消」而继续走「用户说不要」分支);close 后下一个
// 提到 active;
// ③ 队列上限 1:第三个 confirm 走 toast 提示 + resolve(false)(用户不会
// 被永久卡在弹窗里)。
//
// ④ 结构不变式:renderer 源码里**零** window.confirm / window.alert /
// window.prompt(注释里的历史说明字面量不算)。
//
// ⑤ 层级不变式:renderer 的 hooks/ 与 lib/ 不得 **import 值** components/*
// (反向依赖 = 层级倒挂的前兆,r206 正是因此把 confirm store 从
// components/common/ 搬到 lib/)。type-only import 例外(见 workspace.ts)。
import { readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
import { beforeEach, describe, expect, it } from 'vitest';
import { confirm, useConfirmStore } from '@renderer/lib/confirm';
import { useToast } from '@renderer/lib/toast';
const ROOT = process.cwd();
const RENDERER = join(ROOT, 'src/renderer/src');
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walk(full));
else if (/\.tsx?$/.test(entry)) out.push(full);
}
return out;
}
/** 去掉行注释与块注释,避免「注释里提到 window.confirm」被误判 */
function stripComments(src: string): string {
return src
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('\n')
.filter((l) => !l.trimStart().startsWith('//'))
.join('\n');
}
describe('lib/confirm(r206)', () => {
beforeEach(() => {
// 复位模块级 store(confirm 队列跨用例存活)
useConfirmStore.setState({ active: null, resolve: null, queue: [] });
useToast.setState({ toasts: [] });
});
it('confirm() 会把对话框置为 active', () => {
void confirm({ title: 'T1' });
expect(useConfirmStore.getState().active).toEqual({ title: 'T1' });
});
it('close(true) ⇒ promise resolve(true)', async () => {
const p = confirm({ title: 'T1' });
useConfirmStore.getState().close(true);
await expect(p).resolves.toBe(true);
});
it('close(false) ⇒ promise resolve(false)', async () => {
const p = confirm({ title: 'T1' });
useConfirmStore.getState().close(false);
await expect(p).resolves.toBe(false);
});
it('第二个 confirm 入队(不静默 resolve false)', async () => {
const p1 = confirm({ title: 'A' });
const p2 = confirm({ title: 'B' });
expect(useConfirmStore.getState().active?.title).toBe('A');
expect(useConfirmStore.getState().queue).toHaveLength(1);
// 关键:第二个还没被裁决
let settled = false;
void p2.then(() => (settled = true));
await Promise.resolve();
expect(settled).toBe(false);
useConfirmStore.getState().close(true);
await expect(p1).resolves.toBe(true);
// close 后第二个被提到 active
expect(useConfirmStore.getState().active?.title).toBe('B');
useConfirmStore.getState().close(false);
await expect(p2).resolves.toBe(false);
});
it('队列上限 1:第三个 confirm 走 toast + resolve(false)', async () => {
const p1 = confirm({ title: 'A' });
const p2 = confirm({ title: 'B' });
const p3 = confirm({ title: 'C' });
await expect(p3).resolves.toBe(false);
// 队列没被撑爆
expect(useConfirmStore.getState().queue).toHaveLength(1);
// 用户得到了明确反馈(不是静默吞掉)
expect(useToast.getState().toasts.length).toBeGreaterThan(0);
expect(useToast.getState().toasts[0].type).toBe('info');
useConfirmStore.getState().close(true);
await expect(p1).resolves.toBe(true);
useConfirmStore.getState().close(true);
await expect(p2).resolves.toBe(true);
});
it('close 后 active/resolve 被清空(不留悬挂态)', () => {
void confirm({ title: 'T1' });
useConfirmStore.getState().close(true);
const st = useConfirmStore.getState();
expect(st.active).toBeNull();
expect(st.resolve).toBeNull();
});
});
describe('renderer 原生对话框禁令(r206)', () => {
it('renderer 源码里零 window.confirm / window.alert / window.prompt', () => {
const hits: string[] = [];
for (const file of walk(RENDERER)) {
const code = stripComments(readFileSync(file, 'utf8'));
for (const re of [/window\s*\.\s*confirm\b/g, /window\s*\.\s*alert\b/g, /window\s*\.\s*prompt\b/g]) {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(code)) !== null) {
hits.push(`${file.slice(ROOT.length + 1)} → ${m[0]}`);
}
}
}
expect(hits, `renderer 不许用原生阻塞式对话框:\n${hits.join('\n')}`).toEqual([]);
});
it('hooks/ 与 lib/ 不得 import components/* 的**值**(type-only 除外)', () => {
const hits: string[] = [];
for (const dir of ['hooks', 'lib']) {
const base = join(RENDERER, dir);
for (const file of walk(base)) {
const code = stripComments(readFileSync(file, 'utf8'));
// 值 import:import { x } from '../components/...' / import x from ...
const re = /^import\s+(?!type\b)(?!\{[^}]*\btype\b)[^;]*?from\s+['"][^'"]*\/components\/[^'"]*['"]/gm;
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(code)) !== null) {
hits.push(`${file.slice(ROOT.length + 1)} → ${m[0].trim()}`);
}
}
}
expect(
hits,
`renderer 的 hooks/ 与 lib/ 不得反向依赖 components/(层级倒挂):\n${hits.join('\n')}`,
).toEqual([]);
});
it('useDiskFolderSidebar 的删除确认走应用内 confirm + i18n key', () => {
const src = readFileSync(join(RENDERER, 'hooks/useDiskFolderSidebar.ts'), 'utf8');
expect(src).toMatch(/await confirm\(\{/);
// 文案必须走 t(...) 而不是硬编码英文
expect(src).toMatch(/t\('folders\.deleteConfirmTitle'\)/);
expect(src).toMatch(/t\('folders\.deleteConfirmBody',\s*\{\s*name:/);
});
});

View File

@@ -0,0 +1,71 @@
// tests/unit/renderer/lib/drop-bridge-missing.test.ts
//
// 守 drop.ts 的降级契约(2026-10-20,r197):
//
// 「拿不到 OS 绝对路径就返 '' / 退回 f.path,绝不抛」。
//
// 桥缺失有两种形态,旧实现只挡了第一种:
// ① 桥**不存在** → api Proxy.get 返 undefined → `api.getPathForFile(file)`
// 抛 TypeError(not a function)。r81 已 guard 住「属性读取」。
// ② 桥**存在但调用抛异常**(preload 实现变化 / 测试替身 / 第三方注入)
// → 异常一路逃出 getDroppedFilePath → 下面的 legacy f.path 兜底
// 永远走不到,整条 drop 流程炸掉。r197 补上这层。
//
// 不 mock api 模块:必须用真实 Proxy(旧 drop.test.ts 的 vi.mock 恰好绕开了
// 这条路径,才让缺口活到今天)。这里直接改写 window.api —— api Proxy 在
// 每次属性读取时实时取 window.api 的当前值。
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Api } from '@shared/api-types';
import { getDroppedFilePath, collectDroppedPaths } from '../../../../src/renderer/src/lib/drop';
const originalApi = (globalThis as { window?: Window }).window?.api;
function fakeFile(name: string, legacyPath?: string): File {
const f = { name } as unknown as File;
if (legacyPath !== undefined) {
(f as unknown as { path?: string }).path = legacyPath;
}
return f;
}
describe('getDroppedFilePath 桥异常降级(r197)', () => {
beforeEach(() => {
(globalThis as unknown as { window: { api: Api } }).window.api = {
getPathForFile: () => {
throw new Error('bridge exploded');
},
} as unknown as Api;
});
afterEach(() => {
(globalThis as unknown as { window: { api: Api } }).window.api = originalApi as Api;
});
it('桥调用抛异常时退回 legacy file.path(不抛)', () => {
const f = fakeFile('note.md', '/legacy/note.md');
expect(() => getDroppedFilePath(f)).not.toThrow();
expect(getDroppedFilePath(f)).toBe('/legacy/note.md');
});
it('桥调用抛异常且无 legacy path → 返空串(跳过此 file)', () => {
expect(getDroppedFilePath(fakeFile('note.md'))).toBe('');
});
it('桥方法缺失(属性是 undefined)→ 同样走 legacy 兜底', () => {
(globalThis as unknown as { window: { api: Api } }).window.api = {} as unknown as Api;
expect(getDroppedFilePath(fakeFile('note.md', '/legacy/note.md'))).toBe('/legacy/note.md');
expect(getDroppedFilePath(fakeFile('note.md'))).toBe('');
});
it('collectDroppedPaths:整批降级不炸,空路径被过滤掉', () => {
const files = [fakeFile('a.md', '/legacy/a.md'), fakeFile('b.md'), fakeFile('c.md', '/legacy/c.md')];
expect(collectDroppedPaths(files)).toEqual(['/legacy/a.md', '/legacy/c.md']);
});
it('桥调用抛异常时,legacy path 优先于抛异常(顺序契约不变)', () => {
// 主路径优先级:webUtils > f.path。桥异常时 webUtils 视为「拿不到」。
const f = fakeFile('note.md', '/legacy/note.md');
expect(getDroppedFilePath(f)).toBe('/legacy/note.md');
});
});

View File

@@ -0,0 +1,180 @@
// tests/unit/renderer/lib/drop.test.ts
//
// 守 src/renderer/src/lib/drop.ts 的三条契约(指针原在
// src/renderer/src/components/notes/NotesList.tsx 的「🔒(守卫缺失)」标记里,
// 本文件补齐后该标记已摘除 —— 见 tests/unit/main/lint/stale-test-reference.test.ts
// 的反向守卫:引用的测试已存在却还留着 🔒 标记 ⇒ 红)。
//
// A. getDroppedFilePath —— 路径提取的**优先级**与「拿不到返空串」:
// webUtils.getPathForFile 优先 > legacy File.path 兜底 > ''
// B. collectDroppedPaths —— 顺序保留(与原 FileList 下标对得上)、过滤空串
// C. classifyDroppedNotesFiles —— 笔记面板**三桶**分类:
// paths / invalidTypeNames / noPathNames 严格互斥,
// 「看起来是 .md 但拿不到 OS 路径」必须**独立成桶**(不能被「非 .md」吞掉)
//
// 与既有 drop-bridge-missing.test.ts 的分工:那一份专门锁「桥缺失/桥抛异常」
// 的降级路径(不 mock api 模块);本文件锁**正常路径**下的语义与优先级。
// 两份都不 vi.mock api 模块 —— 直接改写 window.api,api Proxy 每次属性
// 读取实时取 window.api 当前值(见 src/renderer/src/lib/api.ts 头注)。
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Api } from '@shared/api-types';
import {
classifyDroppedNotesFiles,
collectDroppedPaths,
getDroppedFilePath,
} from '../../../../src/renderer/src/lib/drop';
const originalApi = (globalThis as { window?: Window }).window?.api;
/** 造一个只有 name(+ 可选 legacy path)的 File 替身。 */
function fakeFile(name: string, legacyPath?: string): File {
const f = { name } as unknown as File;
if (legacyPath !== undefined) {
(f as unknown as { path?: string }).path = legacyPath;
}
return f;
}
/** 用 map 装一个 getPathForFile:命中返回路径,未命中返回 null。 */
function installBridge(paths: Record<string, string | null>): void {
(globalThis as unknown as { window: { api: Api } }).window.api = {
getPathForFile: (file: File) => paths[file.name] ?? null,
} as unknown as Api;
}
beforeEach(() => {
installBridge({});
});
afterEach(() => {
(globalThis as unknown as { window: { api: Api } }).window.api = originalApi as Api;
});
describe('getDroppedFilePath 路径提取优先级', () => {
it('优先用 webUtils.getPathForFile', () => {
installBridge({ 'a.md': '/from/webutils/a.md' });
const f = fakeFile('a.md', '/legacy/a.md');
expect(getDroppedFilePath(f)).toBe('/from/webutils/a.md');
});
it('webUtils 返回空串 → 退回 legacy file.path', () => {
installBridge({ 'a.md': '' });
expect(getDroppedFilePath(fakeFile('a.md', '/legacy/a.md'))).toBe('/legacy/a.md');
});
it('webUtils 返回 null → 退回 legacy file.path', () => {
installBridge({ 'a.md': null });
expect(getDroppedFilePath(fakeFile('a.md', '/legacy/a.md'))).toBe('/legacy/a.md');
});
it('webUtils 桥缺失 → 退回 legacy file.path', () => {
(globalThis as unknown as { window: { api: Api } }).window.api = {} as unknown as Api;
expect(getDroppedFilePath(fakeFile('a.md', '/legacy/a.md'))).toBe('/legacy/a.md');
});
it('两路都拿不到 → 返空串(绝不抛,也绝不返回 undefined)', () => {
expect(getDroppedFilePath(fakeFile('a.md'))).toBe('');
});
it('legacy path 是空串 → 同样视为拿不到', () => {
expect(getDroppedFilePath(fakeFile('a.md', ''))).toBe('');
});
});
describe('collectDroppedPaths 批量版', () => {
it('顺序保留(与原 FileList 下标对得上)', () => {
installBridge({
'a.md': '/p/a.md',
'b.md': '/p/b.md',
'c.md': '/p/c.md',
});
const paths = collectDroppedPaths([fakeFile('a.md'), fakeFile('b.md'), fakeFile('c.md')]);
expect(paths).toEqual(['/p/a.md', '/p/b.md', '/p/c.md']);
});
it('过滤掉拿不到路径的文件,其余顺序不变', () => {
installBridge({ 'a.md': '/p/a.md', 'c.md': '/p/c.md' });
const paths = collectDroppedPaths([fakeFile('a.md'), fakeFile('b.md'), fakeFile('c.md')]);
expect(paths).toEqual(['/p/a.md', '/p/c.md']);
});
it('空数组 → 空数组', () => {
expect(collectDroppedPaths([])).toEqual([]);
});
});
describe('classifyDroppedNotesFiles 三桶分类', () => {
it('.md → paths 桶', () => {
installBridge({ 'a.md': '/p/a.md' });
expect(classifyDroppedNotesFiles([fakeFile('a.md')])).toEqual({
paths: ['/p/a.md'],
invalidTypeNames: [],
noPathNames: [],
});
});
it('.markdown 也算合法后缀', () => {
installBridge({ 'a.markdown': '/p/a.markdown' });
expect(classifyDroppedNotesFiles([fakeFile('a.markdown')]).paths).toEqual(['/p/a.markdown']);
});
it('后缀大小写不敏感(.MD / .MarkDown 都算 .md)', () => {
installBridge({ 'A.MD': '/p/A.MD', 'B.MarkDown': '/p/B.MarkDown' });
const r = classifyDroppedNotesFiles([fakeFile('A.MD'), fakeFile('B.MarkDown')]);
expect(r.paths).toEqual(['/p/A.MD', '/p/B.MarkDown']);
expect(r.invalidTypeNames).toEqual([]);
expect(r.noPathNames).toEqual([]);
});
it('非 .md/.markdown → invalidTypeNames 桶(不查路径)', () => {
installBridge({ 'a.txt': '/p/a.txt' });
const r = classifyDroppedNotesFiles([fakeFile('a.txt')]);
expect(r.paths).toEqual([]);
expect(r.invalidTypeNames).toEqual(['a.txt']);
expect(r.noPathNames).toEqual([]);
});
it('.md 但拿不到 OS 路径 → noPathNames 桶(独立成桶,不被「非 .md」吞掉)', () => {
installBridge({}); // 桥给不出路径
const r = classifyDroppedNotesFiles([fakeFile('lost.md')]);
expect(r.paths).toEqual([]);
expect(r.invalidTypeNames).toEqual([]);
expect(r.noPathNames).toEqual(['lost.md']);
});
it('三桶互斥:一个文件只落一个桶', () => {
installBridge({ 'ok.md': '/p/ok.md' });
const r = classifyDroppedNotesFiles([
fakeFile('ok.md'),
fakeFile('bad.txt'),
fakeFile('lost.md'),
]);
expect(r).toEqual({
paths: ['/p/ok.md'],
invalidTypeNames: ['bad.txt'],
noPathNames: ['lost.md'],
});
// 三桶元素总数 = 输入文件数 ⇒ 无重复归属
expect(r.paths.length + r.invalidTypeNames.length + r.noPathNames.length).toBe(3);
});
it('空输入 → 三桶全空', () => {
expect(classifyDroppedNotesFiles([])).toEqual({
paths: [],
invalidTypeNames: [],
noPathNames: [],
});
});
it('空名字文件按「非 .md」处理(名字为空 ⇒ 不匹配 .md 后缀)', () => {
// 源码里 `noPathNames.push(f.name || '')` 的 `|| ''` 是**防御性**兜底:
// noPath 桶的前提是后缀已是 .md ⇒ 名字必然非空 ⇒ 该分支实际不可达。
// 这里锁「空名字不落 noPath 桶」这一侧,免得哪天有人把兜底挪到
// invalidType 桶导致 toast 渲染出空行。
installBridge({});
const r = classifyDroppedNotesFiles([fakeFile('')]);
expect(r.noPathNames).toEqual([]);
expect(r.invalidTypeNames).toEqual(['']);
});
});

View File

@@ -0,0 +1,67 @@
// tests/unit/renderer/lib/error-errmsg.test.ts
//
// 守 renderer lib/error.ts 的 errMsg 契约(r217):
//
// 1. **永不 throw** —— errMsg 跑在 catch 块里(30+ 处 toast 翻译),自己
// 炸了会把真实故障盖掉。回归:r217 前 renderer 版镜像的是 main 版
// r124 修复前的旧口径 `String(err)`,`String(Object.create(null))`
// 在 V8 直接 TypeError;现与 main 共用 @shared/display-string。
// 2. Error 实例取 message;字符串原样;其余走 toDisplayString 降级链。
//
// 变异实证:r217 修复前(旧实现 `String(err)`)跑本文件,「null-proto 对象」
// 与「throwing toString」两例红(TypeError 逃出),修复后全绿。
import { describe, expect, it } from 'vitest';
import { errMsg } from '../../../../src/renderer/src/lib/error';
import { toDisplayString } from '../../../../src/shared/display-string';
describe('renderer errMsg — 永不 throw 的 catch 翻译官(r217)', () => {
it('Error 实例 → message', () => {
expect(errMsg(new Error('boom'))).toBe('boom');
expect(errMsg(new TypeError('bad type'))).toBe('bad type');
});
it('字符串原样返回', () => {
expect(errMsg('plain failure')).toBe('plain failure');
});
it('null / undefined / number → 显示字符串', () => {
expect(errMsg(null)).toBe('null');
expect(errMsg(undefined)).toBe('undefined');
expect(errMsg(42)).toBe('42');
});
it('null-proto 对象不 throw(r124 口径,renderer 曾缺)', () => {
const nullProto = Object.create(null) as object;
expect(() => errMsg(nullProto)).not.toThrow();
expect(errMsg(nullProto)).toBe('[object Object]');
});
it('throwing toString 的对象不 throw,走降级链', () => {
const evil = {
toString(): string {
throw new Error('toString exploded');
},
valueOf(): never {
throw new Error('valueOf exploded');
},
};
expect(() => errMsg(evil)).not.toThrow();
expect(errMsg(evil)).toBe('[object Object]');
});
it('message 为 undefined 的 Error → undefined 不透出(toast 不渲染 "undefined")', () => {
// Error 分支直取 message —— 该分支契约:调用方拿到的是 err.message 本身,
// 这里锁的是「非 Error 路径永不给 undefined」。
expect(errMsg(Object.create(null))).not.toBe('undefined');
});
});
describe('shared toDisplayString — 两侧共用同一份实现(r217)', () => {
it('renderer errMsg 与 shared toDisplayString 对非 Error 输入口径一致', () => {
const inputs: unknown[] = [null, undefined, 42, 's', {}, Object.create(null)];
for (const v of inputs) {
expect(errMsg(v)).toBe(toDisplayString(v));
}
});
});

View File

@@ -0,0 +1,250 @@
// tests/unit/renderer/lib/folder-tree.test.ts
//
// 守 r176「左侧栏改树状」的行合成契约。
//
// 树行合成是纯函数(无 IO / 无 React)—— 放在 lib/folder-tree.ts,本测
// 直接单测,不必起 renderer。锁的契约:
//
// 1. 顺序:同目录下**文件夹 → input 行 → 文件**(VS Code 资源管理器同款);
// 2. 折叠的文件夹不递归,展开的才把子树插在它下面;
// 3. depth 从 0 起,子节点 = 父 + 1;
// 4. aria-posinset / aria-setsize 按**同级**算(不是全局序号);
// 5. 搜索态:扁平返回命中项,不渲染文件夹行;
// 6. 落点标记 isTarget 只打在 targetRelPath 那一个文件夹行上;
// 7. 树键盘辅助:← 找父文件夹行、→ 找第一个子行。
//
// 这套契约锁死了「树」与「钻入式列表」的本质差别 —— 钻入式只需要
// 「当前目录的一层」,树需要层级、展开态、落点三者同时正确。
import { describe, expect, it } from 'vitest';
import {
buildTreeRows,
findFirstChildRow,
findParentFolderRow,
groupItemsByDir,
NEW_FOLDER_ROW_ID,
type TreeFolderRow,
type TreeRow,
} from '../../../../src/renderer/src/lib/folder-tree';
import type { DiskFolderNode } from '../../../../src/shared/types';
function node(relPath: string, parentRelPath: string | null): DiskFolderNode {
return {
relPath,
name: relPath.slice(relPath.lastIndexOf('/') + 1),
parentRelPath,
};
}
interface Item {
id: string;
relPath: string;
}
const ITEMS: Item[] = [
{ id: 'f1', relPath: 'top.md' },
{ id: 'f2', relPath: 'a/one.md' },
{ id: 'f3', relPath: 'a/b/two.md' },
{ id: 'f4', relPath: 'a/b/three.md' },
{ id: 'f5', relPath: 'z.md' },
];
// 磁盘形状:
// root
// ├── a
// │ ├── one.md
// │ └── b
// │ ├── two.md
// │ └── three.md
// ├── top.md
// └── z.md
const CHILDREN = new Map<string, DiskFolderNode[]>([
['', [node('a', null)]],
['a', [node('a/b', 'a')]],
['a/b', []],
]);
function rows(opts: {
expanded?: string[];
targetRelPath?: string | null;
pendingInput?: { parentRelPath: string | null } | null;
searchActive?: boolean;
}): TreeRow<Item>[] {
return buildTreeRows<Item>({
childrenByDir: CHILDREN,
itemsByDir: groupItemsByDir(ITEMS),
expanded: new Set(opts.expanded ?? []),
targetRelPath: opts.targetRelPath ?? null,
searchActive: opts.searchActive ?? false,
searchItems: opts.searchActive ? ITEMS : undefined,
pendingInput: opts.pendingInput ?? null,
});
}
/** 取某个文件夹行(找不到 / 不是文件夹行 → 直接 fail,免得断言被 any 吞掉)。 */
function folderRow(r: readonly TreeRow<Item>[], id: string): TreeFolderRow {
const hit = r.find((x) => x.id === id);
if (!hit || hit.rowType !== 'folder') throw new Error(`not a folder row: ${id}`);
return hit;
}
describe('buildTreeRows —— 树行合成(r176)', () => {
it('折叠:只出根层文件夹 + 根层文件(子目录内容不露头)', () => {
const r = rows({});
expect(r.map((x) => x.id)).toEqual(['folder:a', 'f1', 'f5']);
});
it('展开:子行紧跟在父文件夹行下面,depth 递增', () => {
const r = rows({ expanded: ['a'] });
// a 展开后:它的子文件夹 a/b 在前,a 直属的 one.md 在后(文件夹在前)
expect(r.map((x) => x.id)).toEqual(['folder:a', 'folder:a/b', 'f2', 'f1', 'f5']);
const byId = new Map(r.map((x) => [x.id, x]));
expect(byId.get('folder:a')?.depth).toBe(0);
expect(byId.get('f2')?.depth).toBe(1);
expect(byId.get('folder:a/b')?.depth).toBe(1);
});
it('两层全展开:三层 depth = 0 / 1 / 2', () => {
const r = rows({ expanded: ['a', 'a/b'] });
expect(r.map((x) => x.id)).toEqual([
'folder:a',
'folder:a/b',
'f3',
'f4',
'f2',
'f1',
'f5',
]);
const byId = new Map(r.map((x) => [x.id, x]));
expect(byId.get('folder:a')?.depth).toBe(0);
expect(byId.get('folder:a/b')?.depth).toBe(1);
expect(byId.get('f3')?.depth).toBe(2);
expect(byId.get('f4')?.depth).toBe(2);
});
it('顺序:同目录下文件夹在前、文件在后', () => {
const r = rows({ expanded: ['a', 'a/b'] });
const ids = r.map((x) => x.id);
expect(ids.indexOf('folder:a')).toBeLessThan(ids.indexOf('f1'));
expect(ids.indexOf('folder:a/b')).toBeLessThan(ids.indexOf('f2'));
});
it('aria-posinset / aria-setsize 按同级算(不是全局序号)', () => {
const r = rows({ expanded: ['a'] });
const byId = new Map(r.map((x) => [x.id, x]));
// 根层:文件夹 a + 文件 top.md + 文件 z.md = 3 个兄弟
expect(byId.get('folder:a')?.posInSet).toBe(1);
expect(byId.get('folder:a')?.setSize).toBe(3);
expect(byId.get('f1')?.posInSet).toBe(2);
expect(byId.get('f5')?.posInSet).toBe(3);
// a 目录里:子文件夹 b + 文件 one.md = 2 个兄弟(文件夹在前)
expect(byId.get('folder:a/b')?.posInSet).toBe(1);
expect(byId.get('folder:a/b')?.setSize).toBe(2);
expect(byId.get('f2')?.posInSet).toBe(2);
expect(byId.get('f2')?.setSize).toBe(2);
});
it('搜索态:扁平返回命中项,不渲染文件夹行', () => {
const r = rows({ searchActive: true });
expect(r.map((x) => x.id)).toEqual(['f1', 'f2', 'f3', 'f4', 'f5']);
expect(r.every((x) => x.rowType === 'item')).toBe(true);
expect(r.every((x) => x.depth === 0)).toBe(true);
});
it('落点标记只打在 targetRelPath 那一个文件夹行上', () => {
const r = rows({ expanded: ['a'], targetRelPath: 'a/b' });
expect(folderRow(r, 'folder:a/b').isTarget).toBe(true);
expect(folderRow(r, 'folder:a').isTarget).toBe(false);
});
it('inline input 行挂在目标目录下、文件夹之后文件之前', () => {
const r = rows({ expanded: ['a'], pendingInput: { parentRelPath: 'a' } });
const ids = r.map((x) => x.id);
expect(ids).toContain(NEW_FOLDER_ROW_ID);
// a 目录的集合 = 子文件夹 b + input + 文件 one.md
const byId = new Map(r.map((x) => [x.id, x]));
expect(byId.get(NEW_FOLDER_ROW_ID)?.depth).toBe(1);
expect(byId.get(NEW_FOLDER_ROW_ID)?.setSize).toBe(3);
expect(byId.get(NEW_FOLDER_ROW_ID)?.posInSet).toBe(2);
expect(ids.indexOf('folder:a/b')).toBeLessThan(ids.indexOf(NEW_FOLDER_ROW_ID));
expect(ids.indexOf(NEW_FOLDER_ROW_ID)).toBeLessThan(ids.indexOf('f2'));
});
it('在**根目录**新建文件夹时 input 行也要渲染(parentRelPath = null)', () => {
// r176 初版把「根目录」和「没有 pendingInput」都归一成 null,
// 于是根目录建夹的 input 行永远不渲染(点 ✚ 什么都不发生)。
const r = rows({ pendingInput: { parentRelPath: null } });
const input = r.find((x) => x.rowType === 'input');
expect(input).toBeDefined();
expect(input?.id).toBe(NEW_FOLDER_ROW_ID);
expect(input?.depth).toBe(0);
});
it('NEW_FOLDER_ROW_ID 不与真名为 "new" 的文件夹撞 id', () => {
const children = new Map<string, DiskFolderNode[]>([['', [node('new', null)]]]);
const r = buildTreeRows<Item>({
childrenByDir: children,
itemsByDir: groupItemsByDir([]),
expanded: new Set(),
targetRelPath: null,
searchActive: false,
pendingInput: { parentRelPath: null },
});
const ids = r.map((x) => x.id);
expect(ids).toContain('folder:new');
expect(ids).toContain(NEW_FOLDER_ROW_ID);
expect(new Set(ids).size).toBe(ids.length);
});
it('hasChildren:未加载的目录乐观为 true(画 chevron),已加载的空目录为 false', () => {
const children = new Map<string, DiskFolderNode[]>([
['', [node('empty', null), node('unknown', null)]],
['empty', []],
]);
const r = buildTreeRows<Item>({
childrenByDir: children,
itemsByDir: groupItemsByDir([]),
expanded: new Set(),
targetRelPath: null,
searchActive: false,
});
// empty 已 listDirs 过且为空 → 不画 chevron
expect(folderRow(r, 'folder:empty').hasChildren).toBe(false);
// unknown 没 listDirs 过 → 乐观画 chevron(展开后由缓存纠正)
expect(folderRow(r, 'folder:unknown').hasChildren).toBe(true);
});
});
describe('groupItemsByDir —— 按 dirname(relPath) 分组', () => {
it('根层 / 各子目录各归各位', () => {
const byDir = groupItemsByDir(ITEMS);
expect(byDir.get('')?.map((x) => x.id)).toEqual(['f1', 'f5']);
expect(byDir.get('a')?.map((x) => x.id)).toEqual(['f2']);
expect(byDir.get('a/b')?.map((x) => x.id)).toEqual(['f3', 'f4']);
});
});
describe('树键盘辅助:← 找父行、→ 找子行', () => {
it('findParentFolderRow:子行找得到它的父文件夹行', () => {
const r = rows({ expanded: ['a', 'a/b'] });
const child = r.find((x) => x.id === 'f3')!;
const parent = findParentFolderRow(r, child);
expect(parent?.id).toBe('folder:a/b');
});
it('findParentFolderRow:根层行没有父文件夹行 → null', () => {
const r = rows({});
const rootRow = r.find((x) => x.id === 'f1')!;
expect(findParentFolderRow(r, rootRow)).toBeNull();
});
it('findFirstChildRow:展开的文件夹 → 下一行;折叠的 → null', () => {
const r = rows({ expanded: ['a'] });
const idx = r.findIndex((x) => x.id === 'folder:a');
// a 的第一个子节点是子文件夹 a/b(文件夹在前)
expect(findFirstChildRow(r, idx)?.id).toBe('folder:a/b');
// 折叠态
const collapsed = rows({});
const cIdx = collapsed.findIndex((x) => x.id === 'folder:a');
expect(findFirstChildRow(collapsed, cIdx)).toBeNull();
});
});

View File

@@ -0,0 +1,123 @@
// tests/unit/renderer/lib/literature-folder-view.test.ts
//
// 守 renderer 侧「文献行按文件夹分组」(r172)。
//
// visibleItemRows 靠 dirname(relPath) 分组(根 = '')。literature 行以前
// **没有 relPath**(LiteratureResource 无此字段)⇒ dirname 恒为 '' ⇒ 文献行
// 永远只在根视图可见,子目录视图恒空 —— 「粘贴进文件夹」后文件还在根。
//
// r172 起 LiteratureService 给每行填真实 relPath(相对 literature 根),
// renderer 这层只需把它交给分组器(旧代码在这里做的是假 cast:注释声称
// 「把 absPath 当 relPath 走」,实际一行映射都没有)。本用例锁住分组
// 契约,防止再出现「文献行永远只在根」。
//
// r176(树状改造):visibleItemRows(钻入式单层过滤)整体下线,分组职责搬到
// lib/folder-tree.ts 的 groupItemsByDir + buildTreeRows(树形行合成)。
// 本测同步改成锁**树形**下的同款契约:文献行按 relPath 归到对应目录,
// 折叠的目录不露出子行,展开才露出。
import { describe, expect, it } from 'vitest';
import { dirnameOfRelPath } from '../../../../src/renderer/src/lib/folder-view';
import {
buildTreeRows,
groupItemsByDir,
type TreeRow,
} from '../../../../src/renderer/src/lib/folder-tree';
import type { LiteratureResource } from '../../../../src/shared/types';
function lit(id: string, relPath: string): LiteratureResource {
return {
id: id as unknown as LiteratureResource['id'],
kind: 'literature',
absPath: `C:/lib/${relPath}`,
relPath,
rootId: null,
extractStatus: 'ok',
title: relPath,
mtime: 0,
size: 0,
indexedAt: 0,
};
}
const ITEMS = [lit('a', 'top.pdf'), lit('b', 'sub/inner.pdf'), lit('c', 'sub/deep/deeper.pdf')];
const EMPTY_CHILDREN = new Map<string, never[]>();
function treeRows(
items: readonly LiteratureResource[],
expanded: readonly string[] = [],
): TreeRow<LiteratureResource>[] {
return buildTreeRows<LiteratureResource>({
childrenByDir: EMPTY_CHILDREN,
itemsByDir: groupItemsByDir(items),
expanded: new Set(expanded),
targetRelPath: null,
searchActive: false,
});
}
describe('literature 行按 relPath 分组(r172 / r176 树形态)', () => {
it('子目录里的文献不在根层出现(粘贴进文件夹后不再显示在根)', () => {
const byDir = groupItemsByDir(ITEMS);
expect(byDir.get('')?.map((r) => r.id)).toEqual(['a']);
});
it('子目录里的文献归到自己的目录', () => {
const byDir = groupItemsByDir(ITEMS);
expect(byDir.get('sub')?.map((r) => r.id)).toEqual(['b']);
expect(byDir.get('sub/deep')?.map((r) => r.id)).toEqual(['c']);
});
it('搜索态返回全量(不分文件夹)', () => {
const rows = buildTreeRows<LiteratureResource>({
childrenByDir: EMPTY_CHILDREN,
itemsByDir: groupItemsByDir(ITEMS),
expanded: new Set(),
targetRelPath: null,
searchActive: true,
searchItems: ITEMS,
});
expect(rows).toHaveLength(3);
expect(rows.every((r) => r.rowType === 'item')).toBe(true);
});
it('dirnameOfRelPath:relPath 末段之前的部分即归属(根 = 空串)', () => {
expect(dirnameOfRelPath({ relPath: 'top.pdf' })).toBe('');
expect(dirnameOfRelPath({ relPath: 'sub/inner.pdf' })).toBe('sub');
expect(dirnameOfRelPath({ relPath: 'sub/deep/deeper.pdf' })).toBe('sub/deep');
// 缺 relPath 兜底到根(老行 / DB 直读路径)
expect(dirnameOfRelPath({})).toBe('');
});
// === r176:树形行合成 ===
it('没有子文件夹缓存时,文献行只出根层那一条', () => {
const rows = treeRows(ITEMS);
expect(rows.map((r) => r.id)).toEqual(['a']);
});
it('展开子目录后,目录内的文献行才出现(depth 随层级递增)', () => {
const children = new Map<string, { relPath: string; name: string; parentRelPath: string | null }[]>([
['', [{ relPath: 'sub', name: 'sub', parentRelPath: null }]],
['sub', [{ relPath: 'sub/deep', name: 'deep', parentRelPath: 'sub' }]],
]);
const rows = buildTreeRows<LiteratureResource>({
childrenByDir: children,
itemsByDir: groupItemsByDir(ITEMS),
expanded: new Set(['sub', 'sub/deep']),
targetRelPath: null,
searchActive: false,
});
expect(rows.map((r) => r.id)).toEqual([
'folder:sub',
'folder:sub/deep',
'c', // sub/deep 里的文献
'b', // sub 里的文献
'a', // 根层文献
]);
const byId = new Map(rows.map((r) => [r.id, r]));
expect(byId.get('folder:sub')?.depth).toBe(0);
expect(byId.get('folder:sub/deep')?.depth).toBe(1);
expect(byId.get('b')?.depth).toBe(1);
expect(byId.get('a')?.depth).toBe(0);
});
});

View File

@@ -0,0 +1,98 @@
// tests/unit/renderer/lib/markdown-sanitize.test.ts
//
// 守 r197「净化器不许吃正文」契约。
//
// 背景(用户可见症状):markdown-it 配 `html: false` ⇒ 用户正文里的 '<'
// 被转义成 '&lt;' ⇒ **正文中的 'onclick=alert(1)' 落在文本节点里,不是属性**。
// 旧实现却对**整串 HTML** 跑 `on[a-z]+\s*=\s*...` 正则,不看自己在不在标签
// 内部 ⇒ 文本节点里的 "onclick=alert(1)" 被当事件属性**删掉**。
//
// 实锤(旧实现):
// 输入 `<code>onclick=alert(1)</code>` → 输出 `<code></code>`
// —— 代码片段凭空消失,读者看到的是「代码被清空了」。
//
// 更糟的是 CodeReader:hljs 输出的 `<span class="hljs-attr">` 与用户代码里
// 的 "style = {" 撞上,style 正则把 hljs 自己的 <span 标签吃掉 ⇒
// 高亮结构被破坏(用户看到裸的 class="hljs-attr">a)。
//
// 本测锁的契约:
// 1. **文本节点原样保留**(标签外的 on*/style 一律不动);
// 2. **标签内部**的 on* / style 属性仍被剥除(defense-in-depth 不能松);
// 3. <a href> 危险 scheme 仍替换为 about:blank;
// 4. isSafeHref 对 C0 控制字符前缀的 javascript: 判定为 false。
import { describe, expect, it } from 'vitest';
import { isSafeHref, sanitizeMarkdownHtml } from '../../../../src/renderer/src/lib/markdown-html-sanitize';
describe('sanitizeMarkdownHtml(r197)', () => {
it('正文里的 onclick=…(文本节点)必须原样保留 —— 旧实现整串正则把它删了', () => {
const html = '<p>调用 <code>onclick=alert(1)</code> 触发</p>';
const out = sanitizeMarkdownHtml(html);
expect(out).toContain('onclick=alert(1)');
});
it('正文里的 onerror=… / style="…" 也必须保留(不在标签内 ⇒ 不是属性)', () => {
const out = sanitizeMarkdownHtml('<p>文本 onerror=boom 结尾</p>');
expect(out).toContain('onerror=boom');
const out2 = sanitizeMarkdownHtml('<p>属性 style="color:red" 出现</p>');
expect(out2).toContain('style="color:red"');
});
it('CodeReader 场景:hljs 的 span 标签不得被 style 正则吃掉', () => {
const html =
'<span class="hljs-keyword">const</span> style = {<span class="hljs-attr">a</span>:1};';
const out = sanitizeMarkdownHtml(html);
expect(out).toContain('<span class="hljs-attr">a</span>');
expect(out).toContain('const');
});
it('标签内部的 on* 事件属性仍被剥除(defense-in-depth 不松)', () => {
const out = sanitizeMarkdownHtml('<img src="x.png" onerror="alert(1)">');
expect(out).not.toContain('onerror');
expect(out).toContain('src="x.png"');
});
it('标签内部的 style 属性仍被剥除', () => {
const out = sanitizeMarkdownHtml('<p style="background:url(javascript:alert(1))">x</p>');
expect(out).not.toContain('style=');
expect(out).toContain('>x</p>');
});
it('<a href> 危险 scheme 仍替换为 about:blank', () => {
const out = sanitizeMarkdownHtml('<a href="javascript:alert(1)">点我</a>');
expect(out).toContain('about:blank');
expect(out).not.toContain('javascript:');
});
it('<a href> 正常 http(s) 链接不受影响', () => {
const out = sanitizeMarkdownHtml('<a href="https://example.com/a">链接</a>');
expect(out).toContain('https://example.com/a');
});
it('空串 / 无 tag 的纯文本原样返回', () => {
expect(sanitizeMarkdownHtml('')).toBe('');
expect(sanitizeMarkdownHtml('普通文本')).toBe('普通文本');
});
});
describe('isSafeHref(r197)', () => {
it('C0 控制字符前缀的 javascript: 必须判为不安全', () => {
// WHATWG URL 解析会先删掉所有前导 C0 控制符 ⇒ '\x01javascript:' 的
// protocol 仍是 'javascript:'。旧实现只删 \t\n\r ⇒ 漏判。
expect(isSafeHref('\x01javascript:alert(1)')).toBe(false);
expect(isSafeHref('\x08javascript:alert(1)')).toBe(false);
expect(isSafeHref('\x0bjavascript:alert(1)')).toBe(false);
});
it('常规 http(s) / mailto / 相对路径仍安全', () => {
expect(isSafeHref('https://example.com')).toBe(true);
expect(isSafeHref('mailto:a@b.com')).toBe(true);
expect(isSafeHref('#anchor')).toBe(true);
expect(isSafeHref('notes/a.md')).toBe(true);
});
it('javascript: / data:text/html / file: 仍判为不安全', () => {
expect(isSafeHref('javascript:alert(1)')).toBe(false);
expect(isSafeHref('data:text/html,<script>alert(1)</script>')).toBe(false);
expect(isSafeHref('file:///C:/Windows/System32')).toBe(false);
});
});

View File

@@ -0,0 +1,51 @@
// tests/unit/renderer/lib/move-remap.test.ts
//
// 守卫(r175):lib/move-remap.ts —— 搬移后 id 重映射的两个纯函数。
//
// 这是 notes / literature 两域**共用**的一份实现(此前两个 store 各写
// 一份字节级相同的循环)。纯函数、无副作用 → 单测不必起 React 渲染,
// 直接测输入输出。
//
// 契约:
// remapSelectedIds —— 只换落在映射里的成员,其余原样;未命中返回 null
// remapSelectedId —— 单数选中态换 id;没命中返回 null
//
// r177:landedIdsAfterMove 已随「导入后不自动打开」退役(唯一生产调用方
// 是 NotesList.importFiles 的自动选中)。
import { describe, expect, it } from 'vitest';
import {
remapSelectedId,
remapSelectedIds,
type MoveRemap,
} from '../../../../src/renderer/src/lib/move-remap';
function remap(pairs: Array<[string, string]>): MoveRemap {
const m: MoveRemap = new Map();
for (const [oldId, newId] of pairs) m.set(oldId, { id: newId });
return m;
}
describe('move-remap 纯函数(r175)', () => {
it('remapSelectedIds:命中成员换新 id,未命中的原样保留', () => {
const sel = new Set(['a', 'b', 'z']);
const next = remapSelectedIds(sel, remap([['a', 'a2'], ['b', 'b2']]));
expect(next).not.toBeNull();
expect([...(next as Set<string>)].sort()).toEqual(['a2', 'b2', 'z']);
// 入参不能被就地改
expect([...sel].sort()).toEqual(['a', 'b', 'z']);
});
it('remapSelectedIds:没有命中时返回 null(调用方据此跳过 setState)', () => {
expect(remapSelectedIds(new Set(['x']), remap([['a', 'a2']]))).toBeNull();
});
it('remapSelectedIds:映射表为空时返回 null', () => {
expect(remapSelectedIds(new Set(['a']), new Map())).toBeNull();
});
it('remapSelectedId:命中返回新 id,未命中 / 空值返回 null', () => {
expect(remapSelectedId('a', remap([['a', 'a2']]))).toBe('a2');
expect(remapSelectedId('b', remap([['a', 'a2']]))).toBeNull();
expect(remapSelectedId(null, remap([['a', 'a2']]))).toBeNull();
});
});

View File

@@ -0,0 +1,122 @@
// tests/unit/renderer/lib/multi-select-live-ids.test.ts
//
// 守 r193「删除 / 移入回收站只对**活着的** id 动手」契约。
//
// 现象(用户可复现):
// 1. 侧栏 Ctrl 多选 5 篇笔记;
// 2. 其中 1 篇在别处被删了(另一个窗口删 / 网盘同步删 / 回收站清理);
// 3. 回到应用,侧栏仍显示 5 篇被选中(那一行还在,因为列表还没刷新);
// 4. 按 Delete / 右键「移入回收站」⇒ 抛 NOT_FOUND ⇒ **串行循环中断** ⇒
// 只删了 3 篇,后面 2 篇没删,还弹一个「移入回收站失败」toast。
//
// 现象(两侧不对称版):
// 同样的操作在**文献侧**是好的 —— literature 侧删除前用
// `items.filter(r => selectedIds.has(r.id))` 过滤,死 id 被静默跳过;
// 笔记侧直接拿 selectedIds 全集去删 ⇒ 一边好用一边不好用。
//
// 本测锁:
// 1. liveSelectedIds:只返回列表里还活着的成员,按**列表顺序**;
// 2. 空选择集 → 空结果(不返回 undefined / 不抛);
// 3. 全部死 id → 空结果(而不是把死 id 原样吐出去);
// 4. 源码契约:两个列表组件的删除回调都必须经过 liveSelectedIds,
// 不得直接用 selectedIds 全集(防止以后又写回不对称的老写法)。
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { liveSelectedIds } from '../../../../src/renderer/src/lib/multi-select';
const ROOT = process.cwd();
const NOTES = 'src/renderer/src/components/notes/NotesList.tsx';
const LIT = 'src/renderer/src/components/literature/LiteratureList.tsx';
interface Row {
id: string;
}
/**
* 取出某个 `useCallback(` 回调的**函数体**。
*
* audit-r254:旧实现用 `src.slice(src.indexOf(anchor))` +
* `src.slice(0, src.indexOf(endAnchor))` 切片 —— **锚点缺失时 indexOf
* 返回 -1**,而 `slice(-1)` 取的是**最后一个字符**、`slice(0, -1)` 取的是
* 「除末字符外的全文」⇒ 切片范围**静默漂移**:
* - 起始锚点丢了(回调改名/删除)⇒ 拿到空串 ⇒ 断言红(报错信息难读);
* - 结束锚点丢了(**依赖清单一改就会丢**,最常见的改动)⇒ 范围悄悄放大
* 成「从回调一直到文件尾」⇒ 断言 `toContain('liveSelectedIds(')` 可以
* 被**函数体之外**的同名文本满足 ⇒ 回调真的不用 liveSelectedIds 了,
* 守卫照样绿(假绿)。
*
* 这里改成:锚点缺失 / 顺序不对 ⇒ **直接失败**,让守卫真的失效;
* 结束锚点只认 useCallback 的收尾形态 `}, [`(不写死依赖清单,免得依赖项
* 一改守卫就假红 —— 那种红是「守卫跟不上实现」,不是「实现有病」)。
*/
function sliceCallbackBody(src: string, startAnchor: string): string {
const s = src.indexOf(startAnchor);
expect(s, `起始锚点缺失:${startAnchor}`).toBeGreaterThanOrEqual(0);
const e = src.indexOf('\n }, [', s);
expect(e, `结束锚点缺失(未找到 useCallback 收尾):${startAnchor}`).toBeGreaterThan(s);
return src.slice(s, e);
}
/**
* 剥掉注释 —— 断言必须落在**真代码**上。
*
* 实证(旧守卫的第二个假绿面):LiteratureList.tsx 的删除回调里有一行**注释**
* // r193:与 notes 侧共用 liveSelectedIds(死 id 静默跳过,别让
* 这行注释里含字面量 `liveSelectedIds(` ⇒ 旧守卫的
* `expect(body).toContain('liveSelectedIds(')` 可以**被注释满足** ⇒
* 把真正的调用换成 `Array.from(st.selectedIds)` 也不红。
*/
function stripComments(code: string): string {
return code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
}
describe('liveSelectedIds:作用集只含活着的成员(r193)', () => {
it('只返回列表里还活着的成员,按列表顺序', () => {
const items: Row[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
// 选择集里有 'b'(活着)+ 'zz'(不在列表里 ⇒ 死 id)
const out = liveSelectedIds(items, new Set(['b', 'zz']));
expect(out.map((x) => x.id)).toEqual(['b']);
});
it('选择集顺序与列表顺序不一致时,以**列表顺序**为准', () => {
const items: Row[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const out = liveSelectedIds(items, new Set(['c', 'a']));
expect(out.map((x) => x.id)).toEqual(['a', 'c']);
});
it('空选择集 → 空结果', () => {
expect(liveSelectedIds([{ id: 'a' }], new Set<string>())).toEqual([]);
});
it('全是死 id → 空结果(不得把死 id 吐出去)', () => {
const out = liveSelectedIds([{ id: 'a' }], new Set(['x', 'y']));
expect(out).toEqual([]);
});
it('源码契约:两个域的删除回调都必须过滤成「活着的 id」', () => {
const notesSrc = readFileSync(join(ROOT, NOTES), 'utf8');
const litSrc = readFileSync(join(ROOT, LIT), 'utf8');
// notes 侧:handleTrashSelection
const notesTrashBody = stripComments(
sliceCallbackBody(notesSrc, 'const handleTrashSelection = useCallback('),
);
expect(notesTrashBody, 'notes 侧删除必须用 liveSelectedIds 过滤').toContain('liveSelectedIds(');
expect(
notesTrashBody,
'notes 侧不得再直接用 selectedIds 全集([...selectedIds])',
).not.toMatch(/\[\.\.\.\s*(?:useNotesStore\.getState\(\)\.)?selectedIds\]/);
// literature 侧:handleDeleteSelection
const litDeleteBody = stripComments(
sliceCallbackBody(litSrc, 'const handleDeleteSelection = useCallback('),
);
expect(litDeleteBody, 'literature 侧删除必须用 liveSelectedIds 过滤').toContain('liveSelectedIds(');
expect(
litDeleteBody,
'literature 侧也不得直接用 selectedIds 全集([...selectedIds])',
).not.toMatch(/\[\.\.\.\s*(?:useLiteratureStore\.getState\(\)\.)?selectedIds\]/);
});
});

View File

@@ -0,0 +1,108 @@
// tests/unit/renderer/lib/order-persistence-localstorage.test.ts
//
// 守 order-persistence.ts 的「永不抛」契约(2026-10-20,r197)。
//
// store/notes.ts + store/literature.ts 的初始 state 里有
// `displayOrderById: loadOrder('note')` —— **模块 init 时**就调 loadOrder。
// 旧实现的 `typeof localStorage === 'undefined'` 探测在 try/catch **外面**:
//
// - Chromium 对不透明 origin(file:// / 隐私模式 / 站点数据被禁)访问
// localStorage 会抛 SecurityError,而 `typeof` 只挡 ReferenceError,
// **不挡 getter 抛异常** → 探测本身就炸。
// - localStorage.getItem 也在 try/catch 外面 → quota / 禁用 → 同样炸。
//
// 后果不是「排序没保存」这种小痛:store 模块 init 抛 ⇒ 整个 store 模块
// 加载失败 ⇒ 整个侧栏/列表白屏,且用户完全不知道原因。
//
// store 里的注释已经写着「任何 throw 都被 loadOrder 内部防御吞掉,返回 {}
// 兜底」—— 那条不变式此前**代码并没有兑现**。本测把注释变成可执行契约。
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { loadOrder, saveOrder } from '../../../../src/renderer/src/lib/order-persistence';
type G = { localStorage?: Storage };
function setLocalStorage(value: unknown): void {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
get() {
return value;
},
});
}
describe('order-persistence localStorage 不可用(r197)', () => {
let original: PropertyDescriptor | undefined;
beforeEach(() => {
original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
});
afterEach(() => {
if (original) {
Object.defineProperty(globalThis, 'localStorage', original);
} else {
Reflect.deleteProperty(globalThis as G, 'localStorage');
}
});
it('访问 localStorage 本身抛异常 → loadOrder 返 {} 不抛', () => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
get() {
throw new Error('SecurityError: localStorage is disabled');
},
});
expect(() => loadOrder('note')).not.toThrow();
expect(loadOrder('note')).toEqual({});
});
it('访问 localStorage 本身抛异常 → saveOrder 不抛', () => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
get() {
throw new Error('SecurityError: localStorage is disabled');
},
});
expect(() => saveOrder('note', { a: 1 })).not.toThrow();
});
it('getItem 抛异常(配额/禁用)→ loadOrder 返 {} 不抛', () => {
setLocalStorage({
getItem: () => {
throw new Error('quota exceeded');
},
setItem: () => {
throw new Error('quota exceeded');
},
} as unknown as Storage);
expect(() => loadOrder('literature')).not.toThrow();
expect(loadOrder('literature')).toEqual({});
});
it('setItem 抛异常 → saveOrder 静默吞掉', () => {
setLocalStorage({
getItem: () => null,
setItem: () => {
throw new Error('quota exceeded');
},
} as unknown as Storage);
expect(() => saveOrder('note', { a: 1 })).not.toThrow();
});
it('localStorage 完全不存在 → 仍返 {} / 不抛', () => {
Reflect.deleteProperty(globalThis as G, 'localStorage');
expect(loadOrder('note')).toEqual({});
expect(() => saveOrder('note', { a: 1 })).not.toThrow();
});
it('正常路径不变:读回写进去的排序', () => {
const store = new Map<string, string>();
setLocalStorage({
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
} as unknown as Storage);
saveOrder('note', { 'n1': 1, 'n2': 2 });
expect(loadOrder('note')).toEqual({ n1: 1, n2: 2 });
});
});

View File

@@ -0,0 +1,110 @@
// tests/unit/renderer/lib/text-stats.test.ts
//
// 守卫(r206,2026-10-20):StatusBar 中区的字数 / 字符数 / 行数统计。
//
// 为什么值得锁:这三个数字是**用户唯一能看到的"我写了多少"反馈**,
// 算错不会报错、只会静默给一个错的数。历史上已经出过一次系统性偏差
// (r82):旧 wordCount 把「任何非空白非 CJK 字符」都起一个词,于是
// '你好,世界' 数出 5(全角逗号 +1)—— 中文写作几乎每个逗号/句号都虚增。
//
// 覆盖边界(诚实声明):charCount 用的是 String.length,**是 UTF-16 码元数**,
// 不是 Unicode 字位数 —— 表情符号(代理对)算 2。这是**当前实现的既定
// 语义**,本守卫把它钉住;若将来改成 [...text].length,这条会红,届时
// 记得同步改注释与 CHANGELOG。
import { describe, expect, it } from 'vitest';
import { charCount, lineCount, wordCount } from '@renderer/lib/text-stats';
describe('text-stats(r206)', () => {
describe('charCount', () => {
it('空串 → 0', () => {
expect(charCount('')).toBe(0);
});
it('按 UTF-16 码元计长(代理对算 2)', () => {
expect(charCount('abc')).toBe(3);
expect(charCount('你好')).toBe(2);
expect(charCount('😀')).toBe(2);
});
it('含空白也算进字符数', () => {
expect(charCount('a b\nc')).toBe(5);
});
});
describe('wordCount', () => {
it('空串 → 0', () => {
expect(wordCount('')).toBe(0);
});
it('CJK 每个字算 1', () => {
expect(wordCount('你好世界')).toBe(4);
});
it('英文按空格分词', () => {
expect(wordCount('hello world')).toBe(2);
});
it('中英混排:CJK 逐字 + 英文按词', () => {
// 注:源码 doc 注释原写「'你好 hello' → 2(1 字 + 1 词)」—— 错的,
// 「你好」是**两个**汉字 ⇒ 2 + 1 = 3。r206 更正注释,本例钉住实际行为。
expect(wordCount('你好 hello')).toBe(3);
});
it('标点/符号不起词、也不断词(r82 修的核心)', () => {
// 旧实现:全角逗号 +1 ⇒ '你好,世界' 数出 5
expect(wordCount('你好,世界')).toBe(4);
expect(wordCount('...')).toBe(0);
expect(wordCount('!!! ???')).toBe(0);
});
it('撇号/连字符不断词(既有契约)', () => {
expect(wordCount("don't")).toBe(1);
expect(wordCount('mixed-language')).toBe(1);
});
it('字母与数字连写算 1 词', () => {
expect(wordCount('a1')).toBe(1);
expect(wordCount('2024')).toBe(1);
});
it('CJK 扩展 B-G 增补平面按字计(不是并成 1 词)', () => {
// 𠀀 𠀁 —— 旧实现缺这段,会被并成 1 词
expect(wordCount('𠀀𠀁')).toBe(2);
});
it('CJK 兼容表意文字(旧字形 U+F900-U+FAFF)按字计', () => {
expect(wordCount('豈')).toBe(1);
});
it('假名 / 谚文按字计', () => {
expect(wordCount('ひらがな')).toBe(4);
expect(wordCount('カタカナ')).toBe(4);
expect(wordCount('한글')).toBe(2);
});
it('空白字符结束当前词', () => {
expect(wordCount('a\tb')).toBe(2);
expect(wordCount('a\nb')).toBe(2);
expect(wordCount('a b')).toBe(2);
});
});
describe('lineCount', () => {
it('空串 → 0', () => {
expect(lineCount('')).toBe(0);
});
it('单行无换行 → 1', () => {
expect(lineCount('a')).toBe(1);
});
it('按 \\n 分割', () => {
expect(lineCount('a\nb')).toBe(2);
expect(lineCount('a\nb\nc')).toBe(3);
});
it('末尾有换行时,末尾空行也算 1 行(split 语义)', () => {
expect(lineCount('a\n')).toBe(2);
});
});
});

View File

@@ -0,0 +1,55 @@
// tests/unit/renderer/lib/toast-duration-contract.test.ts
//
// 守 audit r252:toast 的**自动消失时长**不得被「带着 key 的 undefined」
// 打穿(否则 toast 0 毫秒闪没,用户根本来不及看)。
//
// 旧实现的坑(实测复现):
// store 里 `{ id: newId(), duration: DEFAULT_DURATION[t.type], ...t }`
// —— `...t` 排在**后面** ⇒ 只要调用方**带着 duration 这个 key**(哪怕值是
// undefined),默认值就被覆盖成 undefined。
// 而 IPC 那条路恰好如此:ToastPushPayload.duration 是**可选**字段 →
// preload 守卫放行 undefined → renderer 侧再写一遍
// `duration: payload.duration`(key 存在、值 undefined)
// ⇒ ToastItem 里 `toast.duration <= 0` 对 undefined 求值为 false(不早退)
// ⇒ `setTimeout(dismiss, undefined)` ≈ 0ms ⇒ **toast 一闪就没**。
//
// 本测锁三条:
// 1. 不传 duration ⇒ 走该类型的默认时长;
// 2. 传 `duration: undefined`(key 在!)⇒ **仍**走默认时长(本轮的 bug);
// 3. 传 `duration: 0` ⇒ 保持 0(「不自动消失」的合法值,不能用 `||` 兜底)。
import { describe, expect, it, beforeEach } from 'vitest';
import { useToast } from '../../../../src/renderer/src/lib/toast';
describe('toast 时长契约(r252)', () => {
beforeEach(() => {
useToast.setState({ toasts: [] });
});
it('不传 duration ⇒ 走该类型的默认时长', () => {
useToast.getState().show({ type: 'error', message: 'm' });
useToast.getState().show({ type: 'info', message: 'm' });
const [err, info] = useToast.getState().toasts;
expect(err?.duration).toBe(6000);
expect(info?.duration).toBe(3500);
});
it('传 duration: undefined(key 在!)⇒ 默认时长必须存活(旧实现这里被覆盖成 undefined)', () => {
// 这就是 IPC 那条路:payload.duration 可选 ⇒ undefined ⇒ key 存在。
const payload: { type: 'error'; message: string; duration?: number } = {
type: 'error',
message: '打开失败',
duration: undefined,
};
useToast.getState().show(payload);
const t = useToast.getState().toasts[0];
expect(t?.duration, '默认时长不得被 undefined 打穿(否则 toast 0ms 闪没)').toBe(6000);
});
it('显式 duration 生效;duration=0 表示「不自动消失」,必须原样保留', () => {
useToast.getState().show({ type: 'info', message: 'm', duration: 1234 });
useToast.getState().show({ type: 'info', message: 'm', duration: 0 });
const [a, b] = useToast.getState().toasts;
expect(a?.duration).toBe(1234);
expect(b?.duration, '0 是合法值(不自动消失)⇒ 不能用 || 兜底成默认值').toBe(0);
});
});