update
This commit is contained in:
102
tests/unit/renderer/lib/bootstrap-apply-once.test.ts
Normal file
102
tests/unit/renderer/lib/bootstrap-apply-once.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
139
tests/unit/renderer/lib/bootstrap-single-flight.test.ts
Normal file
139
tests/unit/renderer/lib/bootstrap-single-flight.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
125
tests/unit/renderer/lib/click-select-route.test.ts
Normal file
125
tests/unit/renderer/lib/click-select-route.test.ts
Normal 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')");
|
||||
});
|
||||
}
|
||||
});
|
||||
170
tests/unit/renderer/lib/confirm.test.ts
Normal file
170
tests/unit/renderer/lib/confirm.test.ts
Normal 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:/);
|
||||
});
|
||||
});
|
||||
71
tests/unit/renderer/lib/drop-bridge-missing.test.ts
Normal file
71
tests/unit/renderer/lib/drop-bridge-missing.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
180
tests/unit/renderer/lib/drop.test.ts
Normal file
180
tests/unit/renderer/lib/drop.test.ts
Normal 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(['']);
|
||||
});
|
||||
});
|
||||
67
tests/unit/renderer/lib/error-errmsg.test.ts
Normal file
67
tests/unit/renderer/lib/error-errmsg.test.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
250
tests/unit/renderer/lib/folder-tree.test.ts
Normal file
250
tests/unit/renderer/lib/folder-tree.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
123
tests/unit/renderer/lib/literature-folder-view.test.ts
Normal file
123
tests/unit/renderer/lib/literature-folder-view.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
98
tests/unit/renderer/lib/markdown-sanitize.test.ts
Normal file
98
tests/unit/renderer/lib/markdown-sanitize.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// tests/unit/renderer/lib/markdown-sanitize.test.ts
|
||||
//
|
||||
// 守 r197「净化器不许吃正文」契约。
|
||||
//
|
||||
// 背景(用户可见症状):markdown-it 配 `html: false` ⇒ 用户正文里的 '<'
|
||||
// 被转义成 '<' ⇒ **正文中的 '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);
|
||||
});
|
||||
});
|
||||
51
tests/unit/renderer/lib/move-remap.test.ts
Normal file
51
tests/unit/renderer/lib/move-remap.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
122
tests/unit/renderer/lib/multi-select-live-ids.test.ts
Normal file
122
tests/unit/renderer/lib/multi-select-live-ids.test.ts
Normal 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\]/);
|
||||
});
|
||||
});
|
||||
108
tests/unit/renderer/lib/order-persistence-localstorage.test.ts
Normal file
108
tests/unit/renderer/lib/order-persistence-localstorage.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
110
tests/unit/renderer/lib/text-stats.test.ts
Normal file
110
tests/unit/renderer/lib/text-stats.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
55
tests/unit/renderer/lib/toast-duration-contract.test.ts
Normal file
55
tests/unit/renderer/lib/toast-duration-contract.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user