# DiffLens 测试策略与方案 > 规范化 DiffLens 的测试体系:分层、选型、覆盖重点、运行与质量门禁。 > 配套文档:《应用开发与版本迭代规范》 --- ## 1. 目标与原则 - **保障正确性**:核心 diff 算法与报告生成必须被单元测试覆盖,防止回归 - **可回归**:每次迭代(`0.y.z`)在前都能稳定跑通测试 - **简单优先**:不做为测而测,优先覆盖纯逻辑与高风险模块 - **门禁前置**:`npm test` 纳入提交/推送前的强制检查 --- ## 2. 测试分层 | 层级 | 覆盖对象 | 工具 | | --- | --- | --- | | 单元测试(UT) | 纯逻辑:diff 引擎、报告生成器、解码探测、文件夹扫描 | Vitest | | 组件测试 | React 组件:应用空态、工具栏交互、文件夹视图 | Vitest + Testing Library | | E2E(0.6.0 起) | 构建产物真实 Electron:主进程解码/写盘/文件夹扫描/偏好重启 | Playwright | > 单元 + 组件覆盖核心逻辑与交互;E2E 覆盖 jsdom 无法触达的主进程真实链路(mock `window.api` 测不到的部分),发布前必跑。 --- ## 3. 技术栈 | 用途 | 选型 | | --- | --- | | 框架 | Vitest(与 Vite 生态一致,快) | | 环境 | jsdom | | React 测试 | @testing-library/react | | 断言扩展 | @testing-library/jest-dom | | E2E | Playwright(`_electron` 模式,复用项目自带 Electron,无需下载浏览器) | --- ## 4. 目录结构 ``` vitest.config.ts # Vitest 配置(jsdom + 测试范围 + 覆盖率 80% 硬门禁) playwright.config.ts # Playwright E2E 配置(对 out/ 产物启动真实 Electron,发布前必跑) e2e/ ├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口) └── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/偏好重启) src/main/ ├── index.ts # 主进程入口(依赖 electron 运行时,覆盖率排除,真实链路由 E2E 覆盖) ├── decode.ts # 解码与编码探测纯逻辑(Uint8Array 入参,同步与 worker 路径共用) ├── decode.test.ts # 解码单元测试(BOM 三种/严格 UTF-8/GBK 回退/二进制启发式) ├── decodeWorker.ts # 解码 worker_threads 入口(覆盖率排除,逻辑由 decode 测试覆盖) ├── folderScan.ts # 文件夹对比纯逻辑(递归枚举/对齐/字节判定/采样近似) ├── folderScan.test.ts # 文件夹扫描单元测试(真实临时目录:状态判定/递归/symlink/截断/近似) ├── windowState.ts # 应用状态持久化纯逻辑(bounds 校验/钳制,无 electron 依赖) └── windowState.test.ts # 应用状态单元测试(解析回退/工作区钳制/最小尺寸) src/renderer/src/ ├── test/ │ └── setup.ts # 全局 setup:jest-dom 匹配器 + 自动 cleanup ├── diff/ │ ├── diffEngine.test.ts # diff 引擎单元测试 │ ├── diffWorker.ts # diff 计算 Web Worker(内联打包,覆盖率排除,逻辑由 diffEngine 覆盖) │ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化) │ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退 │ ├── report.test.ts # 报告生成测试 │ └── textUtils.test.ts # 文本扩展名与行数统计测试 ├── hooks/ │ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理/字符级大字符量重路径) │ ├── useDiff.ts │ └── useDismiss.ts ├── components/ │ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出) │ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放) │ ├── FolderView.test.tsx # 文件夹视图(统计/过滤/双击/虚拟滚动/截断与空态提示) │ ├── TextInputModal.test.tsx # 粘贴文本弹窗 │ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级标记) └── __tests__/ ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/文件夹模式进出与双击) └── main.test.ts # 入口挂载冒烟测试 ``` > 约定:测试文件与被测文件同目录(`*.test.ts(x)`),或集中放各模块下 `__tests__`。 --- ## 5. 覆盖重点与用例 ### 5.1 diff 引擎(`diffEngine.ts`)— 最高优先级 - 空文本 / 空对非空(全新增) - 完全一致 → 全部 `unchanged`,无差异 - 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`) - 词级:同一删除/新增配对为 `modified` 并产生左右 `segs` - 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines`、`ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文 - 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);换位专项组(换位场景 jsdiff 对齐任意,断言只锚定不变量:segs 拼接无损 / 高亮不越侧 / 行号单调 / 同行 ne-eq-ne 多块变更仅输出一个 modified 不重复行);降级组(归一化流超 3 万字符自动降级行级并带 `charModeDowngraded` 标志 / 编辑距离超限快速降级(内部参数 charDiffMaxEdit 注入小阈值毫秒级构造)/ 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志) - 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs - 行切分:结尾换行不产生多余空行 ### 5.2 报告生成(`report.ts`) - HTML:输出合法文档;Hero 区含人话结论(verdict)、文件卡片、统计卡与图例;差异分块(hunk-head)与块内行渲染;多块时输出目录锚点(toc/href="#seg-N"),单块不输出;相同内容段折叠(details/summary)且折叠段内保留全量行数据;一致场景 verdict ok;两侧空内容提示 - 纯文本:含增删改标记(`+ -`)、行号按最大位数右对齐、头部人话结论 - Markdown:表格化、管道符转义保持表格结构、引用块含人话结论 - 差异分块(`buildSegments`):空行集 / 无变更单段全展示 / 上下文 3 行与边界收敛 / 相邻区间合并 / 远距分两段省略数正确 / 自定义 context - 人话总结(`plainSummary`):无差异一致结论 / 为 0 项省略 / 三类齐全完整罗列 - 比较选项描述(`plainOptions`)与头部说明行:无选项严格对比文案 / 生效项罗列 / 忽略所有空白遮蔽行首尾空白;TXT / Markdown / HTML 三格式头部含“比较选项”行 - 格式分发与扩展名映射(`REPORT_EXT`) ### 5.3 文本工具(`textUtils.ts`) - 文本扩展名白名单放行 / 拒绝 / 大小写不敏感 - `countLines` 与 diff 引擎切分规则一致(结尾换行、CRLF) - `displayCols` 半角 1 列 / 全角 2 列 / 中英混排叠加 / 传 cap 时达上限提前返回 ### 5.4 对比视图(`DiffView.tsx`) - 渲染:行内容、行号、词级高亮段、行尾空白标注 - 虚拟滚动:大行数下仅渲染可见窗口(DOM 行数远小于总量);滚动后窗口移动;容器总高度按全部行数撑满 - 内容最小宽度:探针测得字符宽后按最大列宽换算 minWidth(半角/全角列宽,超长行封顶);探针未测得时不设置 - 折叠提示行:默认文案与自定义文案(仅看差异视图) - 交互:左右/横向滚动同步、右键菜单回调、拖放回调、导航定位走受控 `scrollTo` ### 5.5 应用冒烟与集成(`App.tsx`) - 渲染应用名 `DiffLens`;未加载文件时展示左右空态面板、不显示导出入口 - 加载文件后的对比视图、导出、右键菜单、拖拽、粘贴、容错提示 - 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复 - F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占 - 交换左右侧后面板文件互换 - toast 队列:连续多条提示同时可见不互顶 / 各自独立计时消失 / 超过上限顶掉最旧 / 动作按钮点击后该条立即关闭 / 二进制预警与行数预警共存 - 清空二次确认:有内容时第一次点击仅提示不清空 / 确认窗口内再点执行清空 / 超时自动复位(再点仍先提示)/ 空态点击不弹确认 - 状态栏行数:单侧/双侧加载显示对应行数(切分与引擎一致)/ 清空后随空态消失 - 偏好持久化(比较选项 + 仅看差异开关):启动时恢复上次保存的状态(选项勾选与视图折叠生效)/ 损坏 JSON 回退默认且不崩溃 / 非布尔字段防御性忽略 / 切换选项后写回存储(用例间 beforeEach 清理 localStorage 防串扰) - 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项 - 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 - 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 「返回文件对比」清空文件夹状态回单文件空态 ### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`) - 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致 - 重路径:进入 computing 且 diff 置空;200ms 去抖到期后才创建 worker 并派发;响应按 jobId 匹配,错误/过期 jobId 被丢弃 - 字符级大字符量重路径:charMode 开启且两侧字符总量超过阈值(少行大文件,行数维度防不住 diffChars 耗时)同样进入重路径;未超阈值/charMode 关闭不受影响;恰好阈值不触发 - 输入变更:已派发的旧 worker 终止重建(等价取消);去抖期内变更则旧任务未创建即取消(零创建与计算开销) - worker 不可用(环境缺失/工厂返回 null):去抖到期后主线程同步回退 - 卸载:去抖期内卸载则 worker 从未创建;去抖到期后卸载则终止 worker,去抖定时器均被清理 - worker 运行崩溃(onerror 兜底):终止实例并回退主线程同步计算(computing 不再永久卡死);迟到错误(effect 已取消 / jobId 已推进)只终止不回填,新任务结果正常生效 - 工厂:无 Worker 环境返回 null;可用环境返回实例 ### 5.7 应用状态持久化(`src/main/windowState.ts`) - parseState:空输入/非法 JSON 回退空状态;合法 bounds 与 lastDir 保留;字段类型异常(非矩形/非字符串/空串/非有限数值)防御性忽略 - clampBounds:工作区内合法 bounds 原样保留;小于最小尺寸钳到最小值;超出工作区封顶;移出屏幕四向拉回(至少 60px 进入工作区);多显示器偏移工作区同样钳制;非整数取整;工作区小于最小尺寸时尺寸仍保持最小值 - 磁盘读写与窗口事件绑定在主进程入口(覆盖率排除,真实链路由 E2E 覆盖) ### 5.8 解码与编码探测(`src/main/decode.ts`) - looksBinary:空输入 / NUL 立即判定 / 纯可见 ASCII / 控制字符占比超 5% / 常见空白不计入 - decodeText:UTF-8 BOM(剥离 BOM + 标注)/ UTF-16 LE 与 BE BOM / 仅 BOM 无内容 / 短于 BOM 长度走严格 UTF-8 路径 / 无 BOM 合法 UTF-8 与纯 ASCII / GBK 中文回退解码正确 / 空输入 / 含 NUL 非法 UTF-8 的 GBK 回退 + 二进制判定 / 替换符占比超 5% 判二进制 - worker 调度(阈值分流/崩溃回退)在主进程入口(覆盖率排除,大文件 worker 链路由 E2E 覆盖) ### 5.9 文件夹扫描(`src/main/folderScan.ts`,真实临时目录) - 状态判定:内容一致 same / 同大小内容不同 different / 大小不同 different / 单侧缺失 only(另一侧大小 null) - 递归子目录按相对路径对齐(统一 / 分隔);二进制内容字节级判定;symlink 跳过;条目字典序排序;total 取两侧较大值 - 边界与选项:两个空目录 / 目录不存在抛异常(IPC 层转错误结果) / maxFiles 截断(truncated + total + 条目数受限) / 超 maxContentBytes 同大小文件头部采样一致判 same 带 approximate / 采样头部不同直接 different ### 5.10 文件夹视图(`FolderView.tsx`) - 渲染:目录路径卡片 / 重新选择与返回按钮 / 统计徽章(相同/不同/仅左/仅右/总数) - 过滤:仅看差异隐藏 same、关闭展示全部;双击回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;截断提示;空态提示(无文件 / 全部一致) - 虚拟滚动:大列表仅渲染可见窗口、容器总高度按全部条目撑满 ### 5.11 E2E 端到端(`e2e/app.spec.ts`,对 build 产物启动真实 Electron) - 启动冒烟:标题与双空态面板、空态无导出入口 - 粘贴两侧:差异渲染 + 统计徽章(主进程剪贴板链路) - 编码链路:GBK 与 UTF-8 BOM 文件打开(编码标注 + 内容渲染)、大体积 GBK 文件经解码 worker 后台解码完整渲染(状态栏总行数断言) - 比较选项:忽略大小写与忽略所有空白组合、字符级跨行重组判一致 - 导航与视图:F7 / Shift+F7、仅看差异折叠、交换左右后面板互换 - 报告导出:stub 保存对话框 → 文件真实落盘且含结论与统计 - 文件夹对比:真实临时目录扫描(状态判定 / 默认过滤)→ 双击进入单文件对比 → 返回文件夹(扫描结果保留) - 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData) - 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理) --- ## 6. 运行命令 ```bash npm test # 运行全部单元 + 组件测试(CI / 提交前) npm run test:watch # 监听模式,开发中用 npm run test:e2e # E2E 验收(先 build 再对产物启动真实 Electron;发布前必跑) ``` --- ## 7. 质量门禁 提交与推送前 **必须全部通过**: ```bash npm run typecheck npm test npm run build ``` 任一失败即禁止提交;修复通过后再提交发布。 --- ## 8. 覆盖率目标(演进基线) | 模块 | 目标 | | --- | --- | | 逻辑层(diff / report / textUtils) | 行覆盖 ≥ 80% | | 组件层(components / App) | 覆盖主流程空态与关键交互 | > 80% 四项阈值(statements / branches / functions / lines)已写入 `vitest.config.ts` 的 `coverage.thresholds`,作为 CI 硬门禁强制执行,未达标即测试失败。 --- ## 9. 演进方向(后续迭代) - 文件夹对比增强(0.7.0 候选):子目录树形展示 / 按扩展名过滤 / 文件夹对比报告导出 / 语义判定(套用忽略空白等选项) - E2E 覆盖扩展:拖拽导入链路 / 大文件性能基准 - 主进程 IPC 层补充集成测试(folder:scan 并发场景) --- © 2026 MetonaTeam · thzxx · DiffLens