From c45e388fec6f70d88e8e95d0d2259cbf8e0a5cd0 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Wed, 19 Aug 2026 21:11:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v0.10.4=20=E5=BF=AB=E7=85=A7=E5=AF=B9?= =?UTF-8?q?=E7=85=A7=E5=AF=B9=E7=A7=B0=E9=98=B2=E5=91=86=E6=94=B6=E5=AE=98?= =?UTF-8?q?=E3=80=81=E6=90=9C=E7=B4=A2=E9=AB=98=E4=BA=AE=E9=87=8D=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D=E3=80=81=E5=BF=AB=E7=85=A7=E4=BF=9D=E5=AD=98=E4=B8=AD?= =?UTF-8?q?=E6=96=AD=E4=B8=8ETB=E6=A1=A3=20-=20=E5=BF=AB=E7=85=A7=E8=87=AA?= =?UTF-8?q?=E8=BA=ABtruncated=E6=8B=A6=E6=88=AA=E5=8A=A0=E8=BD=BD=EF=BC=88?= =?UTF-8?q?=E4=B8=8E=E4=BF=9D=E5=AD=98=E4=BE=A7/=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E4=BE=A7=E5=AF=B9=E7=A7=B0=E7=9A=84=E4=B8=89=E4=BE=A7=E9=98=B2?= =?UTF-8?q?=E5=AF=B9=E7=85=A7=E8=AF=AF=E6=8A=A5=E6=94=B6=E5=AE=98=EF=BC=8C?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E5=BF=AB=E7=85=A7=E7=BC=BA=E5=A4=B1=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8D=E5=86=8D=E8=AF=AF=E6=8A=A5=E6=96=B0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=EF=BC=89=E3=80=81=E5=88=87=E9=80=89=E9=A1=B9/?= =?UTF-8?q?=E6=8D=A2=E6=96=87=E4=BB=B6=E9=87=8D=E7=AE=97=E5=90=8E=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E8=AE=A1=E6=95=B0=E4=B8=8Eactive=E9=AB=98=E4=BA=AE?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=92=B3=E5=88=B6=E9=87=8D=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=EF=BC=88rows=E9=87=8D=E5=BB=BA=E8=A1=8Cid=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E7=BC=96=E5=8F=B7=E4=B8=8D=E5=86=8D=E8=84=B1=E8=8A=82=EF=BC=8C?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E5=BD=92=E9=9B=B6=E6=B8=85=E7=A9=BA=E9=AB=98?= =?UTF-8?q?=E4=BA=AE=EF=BC=89=E3=80=81=E4=BF=9D=E5=AD=98=E4=B8=96=E4=BB=A3?= =?UTF-8?q?=E5=8F=B7=E4=BD=9C=E5=BA=9F=E5=9C=A8=E9=80=94=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=EF=BC=88=E9=80=80=E5=87=BA=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=A4=B9=E5=AF=B9=E6=AF=94=E4=B8=8D=E5=86=8D=E5=9C=A8=E5=8D=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=A9=BA=E6=80=81=E5=BC=B9=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=EF=BC=8C=E9=81=AE=E7=BD=A9=E5=8D=B3?= =?UTF-8?q?=E6=97=B6=E5=A4=8D=E4=BD=8D=EF=BC=8C0.10.2=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E9=98=B2=E6=8A=96=E4=B8=8E=E9=81=AE=E7=BD=A9=E4=B8=8D=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=E4=BA=A4=E4=BA=92=E8=AE=BE=E8=AE=A1=E4=B8=8D=E5=8F=98?= =?UTF-8?q?=EF=BC=89=E3=80=81fmtSize=E8=A1=A5TB=E6=A1=A3=EF=BC=88TB?= =?UTF-8?q?=E7=BA=A7=E6=96=87=E4=BB=B6=E4=B8=8D=E5=86=8D=E6=98=BE=E7=A4=BA?= =?UTF-8?q?1024.0GB=EF=BC=8C0.10.3=E6=A1=A3=E4=BD=8D=E5=BB=B6=E7=BB=AD?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=96=B0=E5=A2=9E5=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E4=B8=8E1=E6=9D=A1E2E=EF=BC=88639=E5=8D=95=E6=B5=8B+E2E=2030?= =?UTF-8?q?=E5=85=A8=E8=BF=87=E8=A6=86=E7=9B=96=E7=8E=8798.16/93.5/94.04/9?= =?UTF-8?q?8.16=EF=BC=89=EF=BC=8C=E6=96=87=E6=A1=A3=E5=9B=9B=E4=BB=B6?= =?UTF-8?q?=E5=A5=97=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- docs/应用开发与版本迭代规范.md | 8 +- docs/测试策略与方案.md | 14 +-- e2e/app.spec.ts | 42 ++++++++ package-lock.json | 4 +- package.json | 2 +- src/renderer/src/App.tsx | 39 +++++++- src/renderer/src/__tests__/App.test.tsx | 111 +++++++++++++++++++++ src/renderer/src/diff/folderReport.test.ts | 10 +- src/renderer/src/diff/folderReport.ts | 8 +- 10 files changed, 225 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 26b7af9..9ad68a5 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,14 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐 - **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位 - **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**扫描进度反馈**(比对阶段实时显示"已比对 N / M")、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**忽略规则**(gitignore 简化子集含高级 glob:`node_modules/` 目录整树剪枝、`*.log` 通配、`lo?g` 单字符、`*.[ch]` 字符类(范围与取反)、`src/temp` 多段路径、`build/**/cache` 跨段、`!keep.log` 否定规则(最后匹配优先);无效规则实时提示不再静默丢弃;规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫) -- **快照基线与内容级对照**:把当前文件夹对比结果保存为快照(JSON 文件,**可选携带差异条目的双侧文件内容**,保存期间批量读取实时显示"已读取 N / M"进度,保存中防重复触发),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式双击含基线内容的条目进入"**快照基线 vs 当前文件**"的单文件内容对比(完整复用比较选项/字符级/搜索/导出能力,一眼看穿"从什么变成了什么");内容体积双限防膨胀(单文件 2MB、总量 32MB 超限降级为纯状态快照,保存永不失败);**大快照专用读取通道**(加载上限 128MB,通用文件的 10MB 上限不拦截自家保存的大快照,且不污染文件对话框的目录记忆);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照(双侧防对照误报),快照与当前语义判等口径不一致时加载提示甄别,v1 旧快照兼容加载(纯状态对照) +- **快照基线与内容级对照**:把当前文件夹对比结果保存为快照(JSON 文件,**可选携带差异条目的双侧文件内容**,保存期间批量读取实时显示"已读取 N / M"进度,保存中防重复触发),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式双击含基线内容的条目进入"**快照基线 vs 当前文件**"的单文件内容对比(完整复用比较选项/字符级/搜索/导出能力,一眼看穿"从什么变成了什么");内容体积双限防膨胀(单文件 2MB、总量 32MB 超限降级为纯状态快照,保存永不失败);**大快照专用读取通道**(加载上限 128MB,通用文件的 10MB 上限不拦截自家保存的大快照,且不污染文件对话框的目录记忆);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照、自身截断的快照同样禁止加载(三侧防对照误报),保存期间退出文件夹对比自动终止在途保存(不弹名不符实的保存对话框),快照与当前语义判等口径不一致时加载提示甄别,v1 旧快照兼容加载(纯状态对照) - **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成 -- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览,文件大小 B/KB/MB/GB 人类可读) +- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览,文件大小 B/KB/MB/GB/TB 人类可读) - **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅 - **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃;重输入判定兼顾行数与字符级字符量,少行大文件同样后台计算),界面始终保持流畅响应 - **后台文件解码**:大文件解码与编码探测在主进程 worker 线程后台进行(GBK 大文件解码不再阻塞界面响应),解码失败自动回退同步路径 - **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键 -- **视图内文本搜索**:单文件对比视图内搜索任意内容——Ctrl+F 聚焦、大小写不敏感即时命中计数、Enter / Shift+Enter 在命中行间循环跳转(虚拟滚动下精确定位)、行内命中子串高亮、Esc 清空;搜索基于全量行,与“仅看差异”视图过滤互不影响 +- **视图内文本搜索**:单文件对比视图内搜索任意内容——Ctrl+F 聚焦、大小写不敏感即时命中计数、Enter / Shift+Enter 在命中行间循环跳转(虚拟滚动下精确定位)、行内命中子串高亮、Esc 清空;搜索基于全量行,与“仅看差异”视图过滤互不影响;切换比较选项/更换文件触发重算后,命中计数与高亮行保持一致(自动钳制重定位) - **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异 - **交换左右侧**:一键互换两侧内容与视角 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 @@ -117,7 +117,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主 - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.10.3 +- 版本:0.10.4 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 51c735b..0655187 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.10.3`** +当前基线版本:**`0.10.4`** --- @@ -144,6 +144,12 @@ z = 补丁版本号(Patch) - 安装包排除开发期文件补漏:electron-builder files 增 !e2e/* 与 !playwright.config.*(0.4.1 排除清单制定时 e2e 尚不存在,0.6.0 引入 E2E 后未补——E2E 源码与配置此前被打进 asar) - 扫描进度 E2E 用例采样修稳(环境敏感型瞬态断言,非功能缺陷):快机上 2000 条目比对窗口仅 ~120ms,locator.textContent() 在遮罩消失后自动重试阻塞 10s 吞掉后续采样——改为 evaluate 直读 DOM + 条目增至 4000 拉长瞬态窗口 + intervals 前密采样;诊断实证进度事件链路正常(遮罩文本完整经历 272/976/1696 推进) - 测试与文档:新增 8 单测(fmtSize 各档与 GB 修复锚 3 / scanIpc 异步 stub 兼容 1 / pathNorm 直接单测 4)与共 634 单测 + 29 E2E(进度用例采样方式修稳,语义不变) +0.10.4 ← 快照对照对称防呆收官 · 搜索高亮重定位 · 保存中断 · TB 档延续(已发布): + - 快照对照防呆第三面(对称缺陷收官):0.9.1 拦截保存侧(当前结果截断不可保存)、0.9.2 拦截当前侧(当前结果截断不可加载对照),本次补齐快照自身侧——快照 truncated: true(自家保存链路不会产出,仅来自手工构造/外部工具)时加载拦截提示"条目不完整无法对照",快照缺失的文件不再被误报为"新文件"(拦截发生在目录对一致性校验之前,与既有防呆风格一致) + - 搜索命中高亮重定位(缺陷修复):切比较选项/交换左右/更换文件触发 diff 重算后,rows 全部重建(行 id 按 r{index} 重新编号),旧 activeRowId 不再命中任何行——搜索计数(由序号钳制保持)与行高亮脱节直到下次 Enter;现导航重置 effect 在重算时同步把 active 行定位到钳制后的当前命中行(有命中定位、无命中清空),计数与高亮恒一致;searchHits/searchIndex 有意不入依赖——关键字变化路径由既有效果负责首个命中定位,避免旧序号覆盖 + - 快照保存中断(保存遮罩不拦截交互哲学的收尾):保存批量读取期间退出文件夹对比,此前保存流程会继续跑完并在单文件空态界面上突然弹出保存对话框(保存的目录对上下文已消失,名不符实);现以保存世代号(snapshotSaveSeqRef,与扫描会话序号同款模式)作废在途保存——每批读取前与 saveReport 调用前检查,世代号推进即静默终止(finally 复位遮罩幂等兜底);退出/返回时遮罩即时复位,保存按钮防抖(0.10.2)与遮罩不拦截交互(pointer-events:none)的既有设计均不变 + - TB 档大小显示(0.10.3 档位延续):fmtSize 补 TB 档——TB 级文件(虚拟机镜像/备份归档)此前显示为 1024.0 GB,与 GB 修复同源的档位逐级补全 + - 测试与文档:新增 5 单测(快照截断拦截 1 / 搜索重定位与归零回归锚 2 / 保存中断 1 / fmtSize TB 档修复锚 1,另 TB 断言并入各档换算用例)与 1 条 E2E(手工构造截断快照加载拦截),共 639 单测 + 30 E2E · 依赖 patch 级升级(npm update 语义化范围内,本轮无新版本,零变更) ... 0.y.z ← 长期停留,永不进入 1.x ``` diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index 45b294e..f3e4920 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -45,7 +45,7 @@ vitest.config.ts # Vitest 配置(jsdom + 测试范围含 playwright.config.ts # Playwright E2E 配置(对 out/ 产物启动真实 Electron,发布前必跑) e2e/ ├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口/合成拖放) -└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照与大快照专用通道/关于弹框/拖拽导入/偏好重启) +└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照与大快照专用通道与截断快照拦截/关于弹框/拖拽导入/偏好重启) src/shared/ ├── ignoreRules.ts # 忽略规则匹配器纯逻辑(gitignore 简化子集含高级 glob 与段内字符通配——? 单字符/[abc] 字符类;主进程扫描剪枝与渲染端无效提示共用,段模式预编译 + DP 段序列匹配 + 否定仲裁) ├── ignoreRules.test.ts # 忽略规则单元测试(名字/目录/通配回归 + 多段/锚定/跨段/段内通配/否定仲裁/无效清单/上限/IPC 校验 + ? 与字符类专项) @@ -74,8 +74,8 @@ src/renderer/src/ │ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化) │ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退 │ ├── report.test.ts # 单文件报告生成测试 -│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式,快照对照口径附注;对照模式条目表替换为变迁对照表;fmtSize 大小格式化含 GB 档,列表与报告共用) -│ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable/快照变迁对照表/fmtSize 各档与 GB 修复锚) +│ ├── folderReport.ts # 文件夹报告生成器(HTML/TXT/MD 三格式,快照对照口径附注;对照模式条目表替换为变迁对照表;fmtSize 大小格式化含 GB·TB 档,列表与报告共用) +│ ├── folderReport.test.ts # 文件夹报告测试(统计/结论/三格式/截断警示/unreadable/快照变迁对照表/fmtSize 各档与 GB·TB 修复锚) │ ├── snapshotDiff.ts # 快照基线纯逻辑(v1/v2 结构/解析校验含 contents/目录对归一化匹配(normDir 复用 shared/pathNorm)/六类变迁对照/共享变迁标签映射/内容保存上限常量) │ ├── snapshotDiff.test.ts # 快照单元测试(解析防御/v1 v2 兼容/contents 形态校验/变迁判定/双侧空集/unreadable 语义/目录对归一化匹配) │ └── textUtils.test.ts # 文本扩展名与行数统计测试 @@ -97,7 +97,7 @@ src/renderer/src/ │ ├── TextInputModal.test.tsx # 粘贴文本弹窗 │ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记/搜索框计数与 Enter·Shift+Enter·Esc 键盘交互/ref 转发) └── __tests__/ - ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索/Ctrl+F/文件夹模式进出与双击(含同目录异写归一化防呆)/语义判等链路/差异文件导航(含游标不在序列时导航隐藏)/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截(加载走 snapshot:read 专用通道)/快照内容级对照(v2 保存含内容与读取进度·超限降级·双击基线vs当前·v1 兼容)/扫描进度显示与过期丢弃/关于弹框(打开·元信息·复制·双模式可见·Esc 关闭)) + ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索(含重算后计数与高亮一致·命中归零清空)/Ctrl+F/文件夹模式进出与双击(含同目录异写归一化防呆)/语义判等链路/差异文件导航(含游标不在序列时导航隐藏)/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截(加载走 snapshot:read 专用通道·快照自身截断拦截·保存中退出静默终止)/快照内容级对照(v2 保存含内容与读取进度·超限降级·双击基线vs当前·v1 兼容)/扫描进度显示与过期丢弃/关于弹框(打开·元信息·复制·双模式可见·Esc 关闭)) └── main.test.ts # 入口挂载冒烟测试 ``` @@ -151,10 +151,10 @@ src/renderer/src/ - 偏好持久化(比较选项 + 仅看差异开关):启动时恢复上次保存的状态(选项勾选与视图折叠生效)/ 损坏 JSON 回退默认且不崩溃 / 非布尔字段防御性忽略 / 切换选项后写回存储(用例间 beforeEach 清理 localStorage 防串扰) - 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项灰显勾选并带"已包含"徽标,点击弹出解释提示,关闭后恢复用户原勾选状态;内容超限自动降级并 toast 提示(开关旁常显"已降级"标记);导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项 - 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 -- 单文件视图搜索(0.9.3):输入关键字即时命中计数与首个命中行 active 定位 · Enter/Shift+Enter 命中行间推进回退(行号随动)· 首尾循环 · 大小写不敏感 · 无命中 0/0 且 Enter 不崩溃不推进 · Esc 清空复位 · Ctrl+F 聚焦搜索框 · 命中行被“仅看差异”折叠时计数仍统计全量行、跳转 toast 提示且不推进 +- 单文件视图搜索(0.9.3):输入关键字即时命中计数与首个命中行 active 定位 · Enter/Shift+Enter 命中行间推进回退(行号随动)· 首尾循环 · 大小写不敏感 · 无命中 0/0 且 Enter 不崩溃不推进 · Esc 清空复位 · Ctrl+F 聚焦搜索框 · 命中行被“仅看差异”折叠时计数仍统计全量行、跳转 toast 提示且不推进 · 切选项/换文件重算后命中计数与 active 高亮行自动钳制重定位保持一致(0.10.4,rows 重建行 id 重新编号不再脱节;命中归零清空高亮) - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 - 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入(含 Windows 异写路径归一化判定,0.10.2) · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式 -- 快照基线(0.9.0,0.9.1 收尾,0.9.2 对称防呆,0.10.0 内容级对照):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 当前扫描结果截断时加载快照同样被拦截(0.9.2,不弹文件选择——截断缺失文件会被误报已移除) · 对照模式中重扫触发截断自动退出对照并提示(0.9.2) · 快照语义判等口径与当前开关不一致时加载提示含口径差异、一致时无提示(0.9.2) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(0.9.1) · 退出对照恢复常规视图 · 对照随重扫自动刷新 · 快照内容级对照(0.10.0):保存时差异条目双侧内容入 contents(批量读取按路径返回、same 条目不存、单文件超限侧不读该侧 null)/ 内容总量超限整体降级为纯状态快照并提示(vi.mock 压低上限构造)/ 对照模式双击含基线内容条目进入"快照基线 vs 当前"内容对比(左面板快照基线标注、右面板当前文件、有差异徽章)/ v1 快照双击走常规当前文件对比(兼容回归锚) · 保存读取分批进度遮罩"已读取 N / M"(0.10.1,第二批挂起构造断言,结束复位) · 双击 same 条目 / 对照模式双击 fixed 条目进入对比后差异文件导航隐藏(0.10.1,游标不在差异序列;双击差异条目导航恢复) · 加载走 snapshot:read 专用读取通道(0.10.2,mock 随通道迁移;保存/加载上限不对称修复的通道基础) +- 快照基线(0.9.0,0.9.1 收尾,0.9.2 对称防呆,0.10.0 内容级对照):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 当前扫描结果截断时加载快照同样被拦截(0.9.2,不弹文件选择——截断缺失文件会被误报已移除) · 对照模式中重扫触发截断自动退出对照并提示(0.9.2) · 快照语义判等口径与当前开关不一致时加载提示含口径差异、一致时无提示(0.9.2) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(0.9.1) · 退出对照恢复常规视图 · 对照随重扫自动刷新 · 快照内容级对照(0.10.0):保存时差异条目双侧内容入 contents(批量读取按路径返回、same 条目不存、单文件超限侧不读该侧 null)/ 内容总量超限整体降级为纯状态快照并提示(vi.mock 压低上限构造)/ 对照模式双击含基线内容条目进入"快照基线 vs 当前"内容对比(左面板快照基线标注、右面板当前文件、有差异徽章)/ v1 快照双击走常规当前文件对比(兼容回归锚) · 保存读取分批进度遮罩"已读取 N / M"(0.10.1,第二批挂起构造断言,结束复位) · 双击 same 条目 / 对照模式双击 fixed 条目进入对比后差异文件导航隐藏(0.10.1,游标不在差异序列;双击差异条目导航恢复) · 加载走 snapshot:read 专用读取通道(0.10.2,mock 随通道迁移;保存/加载上限不对称修复的通道基础) · 快照自身截断(手工构造形态 truncated: true)同样被拦截且不进入对照(0.10.4,与保存侧/当前侧对称的三侧防呆收官) · 保存批量读取期间退出文件夹对比:在途保存静默终止、saveReport 不被调用、遮罩即时消失(0.10.4,保存世代号作废;正常保存路径回归锚由读取进度用例覆盖) - 关于弹框(0.10.2):点击 header ⓘ 按钮打开(版本/作者/双邮箱/组织/开源地址/许可证全量断言,版本号来自 app:get-version) · Esc 关闭 · 点击邮箱触发剪贴板复制与 toast 反馈 · 文件夹模式下入口同样可见 ### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`) @@ -220,7 +220,7 @@ src/renderer/src/ - 忽略规则(0.9.0,0.10.0 高级 glob):真实目录含 node_modules 子树——无规则时差异参与对比,输入规则应用重扫后整树剪枝(不同归零、空态一致提示、被忽略条目消失);高级 glob——build 下深层 temp 目录经 build/**/temp 跨段剪枝、*.log 忽略 + !keep.log 否定救回(差异计数精确收敛)、无效规则提示出现 - 扫描进度反馈(0.10.0,0.10.3 采样修稳):4000 条目目录扫描期间遮罩"已比对 N / 4000"轮询抓取(瞬态断言)——evaluate 直读 DOM(避免遮罩消失后 locator 自动重试阻塞采样)+ intervals 前密采样,快机上数百毫秒的瞬态窗口稳定可抓;完成后全量判定与遮罩消失 - 增量缓存跨会话持久化(0.9.0):第一实例扫描后 userData/scan-cache.json 落盘(等待异步写盘,断言含条目与合法结构)→ 同 userData 第二实例重扫同目录结果正确(主进程注入盘上缓存) -- 快照基线(0.9.0,0.9.1 增强,0.10.0 内容级对照):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复;内容级对照——保存 v2 快照(JSON 断言含差异条目双侧内容)→ 修改左侧文件重扫 → 加载快照双击变更条目进入"快照基线 vs 当前"内容对比(左面板"快照 · 文件名"标注、基线旧词与当前新词词级高亮、有差异徽章) +- 快照基线(0.9.0,0.9.1 增强,0.10.0 内容级对照):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复;内容级对照——保存 v2 快照(JSON 断言含差异条目双侧内容)→ 修改左侧文件重扫 → 加载快照双击变更条目进入"快照基线 vs 当前"内容对比(左面板"快照 · 文件名"标注、基线旧词与当前新词词级高亮、有差异徽章);截断快照拦截(0.10.4)——手工构造 truncated: true 快照加载被拦截提示"条目不完整无法对照",不进入对照模式 - 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s;300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归) - 关于弹框(0.10.2):点击 header ⓘ 打开弹框——版本号(app.getVersion 真实链路,0.x 形态正则)/作者/双邮箱/组织/开源地址/许可证断言;点击邮箱走主进程剪贴板并 toast;Esc 关闭 - 大快照加载(0.10.2):手工构造 11MB+ 快照 JSON(contents 塞大字符串)→ snapshot:read 专用通道加载成功("已加载快照"出现且无 10MB 拦截提示)——固化保存/加载上限不对称修复 diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 36d8028..8c3b8f1 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -702,6 +702,48 @@ test.describe('文件夹对比(主进程真实扫描链路)', () => { await removeTmpDir(root) } }) + + test('加载自身截断的快照被拦截(手工构造快照的对称防呆,0.10.4)', async () => { + const root = await makeTmpDir('difflens-e2e-snaptrunc-') + try { + const leftDir = join(root, 'left') + const rightDir = join(root, 'right') + const { mkdir, writeFile: wf } = await import('fs/promises') + await mkdir(leftDir, { recursive: true }) + await mkdir(rightDir, { recursive: true }) + await wf(join(leftDir, 'same.txt'), 'identical') + await wf(join(rightDir, 'same.txt'), 'identical') + + // 手工构造截断快照(自家保存链路在截断时已拦截,此形态仅来自外部): + // 截断快照缺失的文件会被全部误报为"新文件",加载时须拦截 + const snap = { + version: 2, + leftDir, + rightDir, + semantic: true, + truncated: true, + total: 9999, + entries: [{ rel: 'same.txt', status: 'same', leftSize: 9, rightSize: 9 }], + savedAt: Date.now() + } + const snapPath = join(root, 'truncated-snapshot.json') + await wf(snapPath, JSON.stringify(snap), 'utf-8') + + const { app, window } = await launchApp() + // 对话框调用序:folder:pick 左 → folder:pick 右 → snapshot:read 快照 + await stubOpenDialogSeq(app, [[leftDir], [rightDir], [snapPath]]) + await window.getByRole('button', { name: '对比文件夹' }).click() + await expect(window.getByText('相同 1')).toBeVisible() + await window.getByRole('button', { name: '加载快照' }).click() + // 拦截提示出现,不进入对照模式 + await expect(window.getByText(/条目不完整无法对照/)).toBeVisible() + await expect(window.getByText(/已加载快照/)).toHaveCount(0) + await expect(window.getByRole('button', { name: '退出对照' })).toHaveCount(0) + await app.close() + } finally { + await removeTmpDir(root) + } + }) }) test.describe('拖拽导入(真实 Chromium DataTransfer 链路)', () => { diff --git a/package-lock.json b/package-lock.json index 91a56c4..8b1a0e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "DiffLens", - "version": "0.10.2", + "version": "0.10.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "DiffLens", - "version": "0.10.2", + "version": "0.10.4", "license": "MIT", "dependencies": { "@electron-toolkit/preload": "^3.0.1", diff --git a/package.json b/package.json index 0c6d7eb..e4b50c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.10.3", + "version": "0.10.4", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0eeaf26..a1f03d7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -415,6 +415,15 @@ export default function App(): ReactElement { */ const scanSeqRef = useRef(0) + /** + * 快照保存世代号:退出文件夹对比 / 返回文件列表时递增作废在途保存。 + * 保存遮罩不拦截交互(0.10.2 有意设计,pointer-events:none 仅视觉反馈), + * 保存批量读取期间离开视图后保存流程静默终止——否则全部读取完成后会在 + * 单文件空态界面上突然弹出保存对话框(保存的目录对上下文已消失,名不符实)。 + * 世代号而非布尔:连续多次进入/退出不误判(与 scanSeqRef 同款模式)。 + */ + const snapshotSaveSeqRef = useRef(0) + /** 文件夹对比:选择两侧目录并扫描(扫描完成切入文件夹模式;语义判等开关与忽略规则随扫描参数下发)。 * 目录与上次一致且未截断时携带增量缓存——指纹一致的条目零 IO 沿用判定, * 语义判等开关切换触发的重扫从全量 IO 变为近乎零 IO; @@ -561,6 +570,7 @@ export default function App(): ReactElement { showToast('扫描结果已截断(文件过多),快照不完整无法对照,请先缩小对比范围') return } + const seq = ++snapshotSaveSeqRef.current void (async (): Promise => { // ===== 收集差异条目双侧路径(超限/单侧缺失的侧不读) ===== const targets: Array<{ rel: string; leftPath: string | null; rightPath: string | null }> = [] @@ -589,6 +599,8 @@ export default function App(): ReactElement { ] const readMap = new Map() for (let i = 0; i < allPaths.length; i += SNAPSHOT_READ_BATCH) { + // 世代号推进(退出文件夹对比等):保存静默终止(finally 复位遮罩) + if (seq !== snapshotSaveSeqRef.current) return const batch = allPaths.slice(i, i + SNAPSHOT_READ_BATCH) let res: Awaited> try { @@ -640,6 +652,8 @@ export default function App(): ReactElement { savedAt: Date.now(), ...(contents !== undefined ? { contents } : {}) } + // 世代号推进(读取完成后才离开视图):不再弹保存对话框(静默终止) + if (seq !== snapshotSaveSeqRef.current) return const defaultName = `DiffLens-snapshot-${stamp()}.json` let res: { ok: boolean; path: string | null } try { @@ -700,6 +714,12 @@ export default function App(): ReactElement { showToast('快照文件无效或已损坏') return } + // 快照自身截断(仅可能来自手工构造/外部工具——自家保存链路在截断时已拦截): + // 快照缺失的文件在对照中会被全部误报为"新文件",与保存侧/当前侧拦截对称的第三面防呆 + if (snap.truncated) { + showToast('快照保存时扫描结果已截断(文件过多),条目不完整无法对照') + return + } if (!snapshotDirsMatch(snap, folderState.leftDir, folderState.rightDir)) { showToast('快照的对比目录与当前不一致,无法对照') return @@ -883,6 +903,10 @@ export default function App(): ReactElement { /** 从单文件对比返回文件夹列表(清空两侧面板与导航游标/聚合范围,文件夹扫描结果保留) */ const backToFolder = useCallback((): void => { + // 作废在途快照保存(用户主动离开当前视图):保存流程静默终止(防御未来交互路径) + snapshotSaveSeqRef.current++ + setSnapshotSaving(false) + setSnapshotProgress(null) setPaneL(null) setPaneR(null) setFolderEntryCursor(null) @@ -895,6 +919,11 @@ export default function App(): ReactElement { // 作废在途扫描并复位扫描态:迟到的扫描结果不得把界面拉回文件夹模式, // 也不得让扫描遮罩与进度残留到下一次文件夹会话 scanSeqRef.current++ + // 作废在途快照保存:批量读取完成后不得在单文件空态上突然弹出保存对话框 + //(保存的目录对上下文已消失);遮罩即时复位(finally 的复位幂等兜底) + snapshotSaveSeqRef.current++ + setSnapshotSaving(false) + setSnapshotProgress(null) setFolderScanning(false) setScanProgress(null) setFolderState(null) @@ -1048,10 +1077,16 @@ export default function App(): ReactElement { } }, []) - // diff 内容或视图选项变化时重置导航 + // diff 内容或视图选项变化时重置导航;搜索激活时 active 行同步定位到钳制后的 + // 当前命中行——rows 重建后行 id 全部重新编号(r{index}),旧 activeRowId 不再 + // 命中任何行,搜索计数(由 clamp 钳制保持)与行高亮会脱节直到下次 Enter; + // 无命中时清空高亮。searchHits/searchIndex 有意不列入依赖:关键字变化路径由 + // 上方 effect 负责重置定位(首个命中行),若因 hits 变化触发会按旧序号定位, + // 覆盖正确的首个命中定位。 useEffect(() => { setNavIndex(0) - setActiveRowId(null) + const ni = Math.min(searchIndex, Math.max(0, searchHits.length - 1)) + setActiveRowId(searchHits.length > 0 ? searchHits[ni].id : null) }, [paneL, paneR, options, onlyDiff]) const go = useCallback( diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 8fb11ef..10b9751 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -1017,6 +1017,53 @@ describe('App - 单文件视图搜索', () => { // 计数不推进 expect(screen.getByText('1 / 2')).toBeInTheDocument() }) + + it('切选项重算后命中计数与 active 高亮行保持一致(0.10.4 修复)', async () => { + await loadSearchFixture() + fireEvent.change(searchInput(), { target: { value: 'find' } }) + await screen.findByText('1 / 2') + // 推进到第 2 个命中(行 5) + fireEvent.keyDown(searchInput(), { key: 'Enter' }) + await screen.findByText('2 / 2') + expect(document.querySelector('.diff-row.active .ln')?.textContent).toBe('5') + // 切比较选项触发 diff 重算:rows 重建(行 id 全部重新编号),命中集随之重建。 + // 缺陷版本 active 高亮丢失(旧 id 不再存在)而计数仍显示 2 / 2——两者脱节 + fireEvent.click(screen.getByLabelText('忽略大小写')) + expect(await screen.findByText('两文件内容完全一致')).toBeInTheDocument() + // 计数与高亮行一致:仍为第 2 个命中(行 5) + expect(screen.getByText('2 / 2', { selector: '.search-count' })).toBeInTheDocument() + expect(document.querySelector('.diff-row.active .ln')?.textContent).toBe('5') + }) + + it('打开新文件使命中归零:计数 0 / 0 且 active 高亮清空(回归锚)', async () => { + // 命中归零分支的重定位语义:activeRowId 置 null(与缺陷修复前的清空行为一致) + let openCall = 0 + window.api = mockApi({ + openFile: async () => { + openCall++ + return openCall === 1 + ? { path: '/tmp/a.txt', name: 'a.txt', text: 'find here', encoding: 'UTF-8', binary: false } + : { path: '/tmp/a2.txt', name: 'a2.txt', text: 'plain stuff', encoding: 'UTF-8', binary: false } + } + }) + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + fireEvent.click(screen.getByText('打开右侧')) + await screen.findByText('有差异') + // 左侧 'find here' 命中一处(与差异导航计数区分,selector 限定搜索计数) + fireEvent.change(searchInput(), { target: { value: 'find' } }) + expect( + await screen.findByText('1 / 1', { selector: '.search-count' }) + ).toBeInTheDocument() + expect(document.querySelectorAll('.diff-row.active').length).toBeGreaterThan(0) + // 重新选择左侧(新内容不含关键字):diff 重算后命中归零,高亮随之清空 + fireEvent.click(screen.getAllByText('重新选择')[0]) + await waitFor(() => + expect(screen.getByText('0 / 0', { selector: '.search-count' })).toBeInTheDocument() + ) + expect(document.querySelectorAll('.diff-row.active')).toHaveLength(0) + }) }) describe('App - 状态栏行数', () => { @@ -2138,6 +2185,16 @@ describe('App - 快照基线', () => { expect(await screen.findByText(/快照文件无效或已损坏/)).toBeInTheDocument() }) + it('快照自身截断(手工构造形态):拦截提示且不进入对照(0.10.4 对称防呆)', async () => { + // 自家保存链路在截断时已拦截,truncated: true 仅来自手工构造/外部工具; + // 截断快照缺失的文件会被全部误报为"新文件",须与保存侧/当前侧拦截对称 + await enterForSnapshot(makeSnapJson({ truncated: true })) + fireEvent.click(screen.getByRole('button', { name: '加载快照' })) + expect(await screen.findByText(/条目不完整无法对照/)).toBeInTheDocument() + expect(screen.queryByText(/已加载快照/)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '退出对照' })).not.toBeInTheDocument() + }) + it('快照读取失败(error 返回):拦截提示', async () => { await enterForSnapshot() window.api = mockApi({ @@ -2574,6 +2631,60 @@ describe('App - 快照内容级对照(v2)', () => { expect(screen.queryByText(/正在读取差异文件内容/)).not.toBeInTheDocument() expect(screen.queryByText(/已读取/)).not.toBeInTheDocument() }) + + it('保存中退出文件夹对比:在途保存静默终止,不弹保存对话框(0.10.4)', async () => { + // 保存遮罩不拦截交互:批量读取途中点「返回文件对比」后,保存流程须终止—— + // 否则读取全部完成后会在单文件空态界面上突然弹出保存对话框(上下文已消失) + const entries = Array.from({ length: 110 }, (_, i) => ({ + rel: `d${String(i).padStart(3, '0')}.txt`, + status: 'different' as const, + leftSize: 1, + rightSize: 2 + })) + const okFile = (p: string) => ({ + path: p, + name: p.split('/').pop() ?? '', + text: '', + encoding: 'UTF-8', + binary: false + }) + let readCall = 0 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let resolveSecond: (v: any) => void = () => {} + const saveReport = vi.fn(async () => ({ ok: true, path: 'C:/docs/snap.json' })) + const dirs = ['C:/left-dir', 'D:/right-dir'] + let call = 0 + window.api = mockApi({ + pickFolder: async () => dirs[call++] ?? null, + scanFolder: async () => ({ entries, truncated: false, total: entries.length }), + readFilesByPaths: async (paths: string[]) => { + readCall++ + if (readCall === 1) return { files: paths.map(okFile) } + // 第二批挂起:挂起期间用户退出文件夹对比 + return new Promise((res) => { + resolveSecond = res + }) as unknown as ReturnType + }, + saveReport + }) + render() + fireEvent.click(screen.getByText('对比文件夹')) + await screen.findByRole('button', { name: '保存快照' }) + fireEvent.click(screen.getByRole('button', { name: '保存快照' })) + // 第一批(200 个文件)完成、第二批挂起:遮罩显示读取进度 + expect(await screen.findByText(/已读取 200 \/ 220/)).toBeInTheDocument() + // 退出文件夹对比:遮罩即时消失、回到单文件空态 + fireEvent.click(screen.getByRole('button', { name: '返回文件对比' })) + await screen.findByText(/选择左侧文件/) + expect(screen.queryByText(/正在读取差异文件内容/)).not.toBeInTheDocument() + // 第二批此刻才完成:保存流程已被作废(世代号推进),静默终止 + await act(async () => { + resolveSecond({ files: [] }) + }) + expect(saveReport).not.toHaveBeenCalled() + expect(screen.queryByText(/快照已保存/)).not.toBeInTheDocument() + expect(screen.queryByText(/正在读取差异文件内容/)).not.toBeInTheDocument() + }) }) describe('App - 扫描进度反馈', () => { diff --git a/src/renderer/src/diff/folderReport.test.ts b/src/renderer/src/diff/folderReport.test.ts index 90617a9..97c5880 100644 --- a/src/renderer/src/diff/folderReport.test.ts +++ b/src/renderer/src/diff/folderReport.test.ts @@ -52,13 +52,15 @@ describe('fmtSize - 人类可读文件大小', () => { expect(fmtSize(null)).toBe('—') }) - it('B / KB / MB / GB 各档正确换算(含边界值)', () => { + it('B / KB / MB / GB / TB 各档正确换算(含边界值)', () => { expect(fmtSize(0)).toBe('0 B') expect(fmtSize(1023)).toBe('1023 B') expect(fmtSize(1024)).toBe('1.0 KB') expect(fmtSize(1024 * 1024)).toBe('1.0 MB') expect(fmtSize(1024 * 1024 * 1024)).toBe('1.0 GB') expect(fmtSize(2.5 * 1024 * 1024 * 1024)).toBe('2.5 GB') + expect(fmtSize(1024 * 1024 * 1024 * 1024)).toBe('1.0 TB') + expect(fmtSize(1.5 * 1024 * 1024 * 1024 * 1024)).toBe('1.5 TB') }) it('GB 级文件不再显示为 1024.0 MB(0.10.3 修复锚)', () => { @@ -66,6 +68,12 @@ describe('fmtSize - 人类可读文件大小', () => { expect(fmtSize(500 * 1024 * 1024 * 1024)).toBe('500.0 GB') expect(fmtSize(1024 * 1024 * 1024)).not.toBe('1024.0 MB') }) + + it('TB 级文件不再显示为 1024.0 GB(0.10.4 档位延续修复锚)', () => { + // TB 级(虚拟机镜像/备份归档)与 GB 修复同源:大小字段照常显示,档位逐级补全 + expect(fmtSize(3 * 1024 * 1024 * 1024 * 1024)).toBe('3.0 TB') + expect(fmtSize(1024 * 1024 * 1024 * 1024)).not.toBe('1024.0 GB') + }) }) describe('plainFolderSummary', () => { diff --git a/src/renderer/src/diff/folderReport.ts b/src/renderer/src/diff/folderReport.ts index a66d739..eecf3c0 100644 --- a/src/renderer/src/diff/folderReport.ts +++ b/src/renderer/src/diff/folderReport.ts @@ -87,15 +87,17 @@ const STATUS_LABEL: Record = { /** * 人类可读的文件大小(报告与 FolderView 列表共用同一实现)。 - * GB 档必备:文件夹对比无单文件大小上限(10MB 只是全量比对上限,更大文件 - * 采样判定但大小字段照常显示),GB 级文件(视频/镜像)此前会显示为 1024.0 MB。 + * GB/TB 档必备:文件夹对比无单文件大小上限(10MB 只是全量比对上限,更大文件 + * 采样判定但大小字段照常显示),GB 级(视频/镜像)此前会显示为 1024.0 MB, + * TB 级(虚拟机镜像/备份归档)同理会显示为 1024.0 GB——档位逐级补全。 */ export function fmtSize(n: number | null): string { if (n === null) return '—' if (n < 1024) return `${n} B` if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB` - return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB` + if (n < 1024 * 1024 * 1024 * 1024) return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB` + return `${(n / 1024 / 1024 / 1024 / 1024).toFixed(1)} TB` } /** ============ HTML 报告 ============ */