feat: v0.5.0 字符级对比模式 - 新增字符级对比开关(空白与换行结构全部不参与判等、两侧归一化为字符流 diff、跨行重组也能判等)、字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow 视图/报告/导航零改动)、归一化流超 20 万字符自动降级行级对比并提示、字符级开启时三个空白选项置灰
This commit is contained in:
@@ -18,6 +18,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
|||||||
- **交换左右侧**:一键互换两侧内容与视角
|
- **交换左右侧**:一键互换两侧内容与视角
|
||||||
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
|
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
|
||||||
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
|
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景
|
||||||
|
- **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮,超 20 万字符自动降级行级对比并提示
|
||||||
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
|
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
|
||||||
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿
|
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿
|
||||||
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
||||||
@@ -37,7 +38,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
|||||||
| 桌面壳 | Electron |
|
| 桌面壳 | Electron |
|
||||||
| 构建 | electron-vite (Vite 5) |
|
| 构建 | electron-vite (Vite 5) |
|
||||||
| 打包 | electron-builder |
|
| 打包 | electron-builder |
|
||||||
| Diff 引擎 | jsdiff(行级 LCS + 词级词法 diff) |
|
| Diff 引擎 | jsdiff(行级 LCS + 词级词法 diff + 字符级 diff 与行回映射) |
|
||||||
| 后台计算 | Web Worker(Vite 内联打包) |
|
| 后台计算 | Web Worker(Vite 内联打包) |
|
||||||
| 编码 | iconv-lite |
|
| 编码 | iconv-lite |
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ src/
|
|||||||
- 作者:thzxx
|
- 作者:thzxx
|
||||||
- 组织:MetonaTeam
|
- 组织:MetonaTeam
|
||||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||||
- 版本:0.4.3
|
- 版本:0.5.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch)
|
|||||||
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
||||||
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
||||||
|
|
||||||
当前基线版本:**`0.4.3`**
|
当前基线版本:**`0.5.0`**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -69,8 +69,9 @@ z = 补丁版本号(Patch)
|
|||||||
0.4.0 ← 差异计算 worker 化(已发布):大文件 diff 移入 Worker 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用
|
0.4.0 ← 差异计算 worker 化(已发布):大文件 diff 移入 Worker 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用
|
||||||
0.4.1 ← worker 创建开销优化与文档对齐(已发布):worker 延迟至去抖到期才创建(去抖期内取消零创建开销,无 Worker 回退同样走去抖) · 质量门禁文档补 npm test · README 架构树补 hooks · Markdown 报告标题移出引用块 · 安装包排除开发期文件
|
0.4.1 ← worker 创建开销优化与文档对齐(已发布):worker 延迟至去抖到期才创建(去抖期内取消零创建开销,无 Worker 回退同样走去抖) · 质量门禁文档补 npm test · README 架构树补 hooks · Markdown 报告标题移出引用块 · 安装包排除开发期文件
|
||||||
0.4.2 ← 导出菜单溢出修复与报告可读性重设计(已发布):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论
|
0.4.2 ← 导出菜单溢出修复与报告可读性重设计(已发布):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论
|
||||||
0.4.3 ← 忽略所有空白比较选项与报告选项说明(当前):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标)
|
0.4.3 ← 忽略所有空白比较选项与报告选项说明(已发布):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标)
|
||||||
0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
|
0.5.0 ← 字符级对比模式(当前):新增“字符级对比”开关(空白与换行结构全部不参与判等,两侧归一化为字符流 diff,跨行重组也能判等) · 字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow,视图/报告/导航零改动) · 归一化流超 20 万字符自动降级行级对比并提示(报告头部按实际语义描述) · 字符级开启时三个空白选项置灰(语义已包含)
|
||||||
|
0.6.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
|
||||||
...
|
...
|
||||||
0.y.z ← 长期停留,永不进入 1.x
|
0.y.z ← 长期停留,永不进入 1.x
|
||||||
```
|
```
|
||||||
|
|||||||
+3
-1
@@ -60,7 +60,7 @@ src/renderer/src/
|
|||||||
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出)
|
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出)
|
||||||
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
|
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
|
||||||
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
|
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
|
||||||
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用)
|
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动置灰)
|
||||||
└── __tests__/
|
└── __tests__/
|
||||||
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/大输入回退)
|
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/大输入回退)
|
||||||
└── main.test.ts # 入口挂载冒烟测试
|
└── main.test.ts # 入口挂载冒烟测试
|
||||||
@@ -78,6 +78,7 @@ src/renderer/src/
|
|||||||
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`)
|
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`)
|
||||||
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
|
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
|
||||||
- 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines`、`ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文
|
- 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines`、`ignoreAllWhitespace` 生效与关闭;忽略所有空白覆盖行首尾空白、换行结构差异不受其影响、空行数量差异需配合忽略空行、与大小写忽略组合、展示文本保持原文
|
||||||
|
- 字符级对比(`charMode`):判等组(跨行重组一致 / 空行数量差异 / 行内空白与全角空格 / 与忽略大小写组合 / 一致但行数不等短侧空槽仍 unchanged / 展示原文);定位组(行内替换 modified 与 segs 结构 / 行内删除与新增的对侧行回拉(含行首行尾流界跨界)/ 整行增删不误配对 / 多块修改穿插 / 删除段空白继承 / 跨行变更行号单调);降级组(归一化流超 20 万字符自动降级行级并带 `charModeDowngraded` 标志 / 降级结果与等效行级选项一致 / 恰好阈值不降级 / 未开启不携带标志)
|
||||||
- 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs
|
- 超长行防护:任一侧超过阈值跳过词级高亮(segs 为 null),行仍为 modified;边界长度仍产生 segs
|
||||||
- 行切分:结尾换行不产生多余空行
|
- 行切分:结尾换行不产生多余空行
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ src/renderer/src/
|
|||||||
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
|
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
|
||||||
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
|
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
|
||||||
- 交换左右侧后面板文件互换
|
- 交换左右侧后面板文件互换
|
||||||
|
- 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项禁用;内容超限自动降级并 toast 提示;导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项
|
||||||
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
|
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
|
||||||
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
|
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.4.3",
|
"version": "0.5.0",
|
||||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ export default function App(): ReactElement {
|
|||||||
trimWhitespace: false,
|
trimWhitespace: false,
|
||||||
ignoreCase: false,
|
ignoreCase: false,
|
||||||
ignoreBlankLines: false,
|
ignoreBlankLines: false,
|
||||||
ignoreAllWhitespace: false
|
ignoreAllWhitespace: false,
|
||||||
|
charMode: false
|
||||||
})
|
})
|
||||||
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
|
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
|
||||||
const [onlyDiff, setOnlyDiff] = useState(false)
|
const [onlyDiff, setOnlyDiff] = useState(false)
|
||||||
@@ -212,6 +213,13 @@ export default function App(): ReactElement {
|
|||||||
// diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径
|
// diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径
|
||||||
const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options)
|
const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options)
|
||||||
|
|
||||||
|
// 字符级对比因内容超限自动降级为行级时提示(依赖 diff 对象,同一结果只提示一次)
|
||||||
|
useEffect(() => {
|
||||||
|
if (diff?.charModeDowngraded) {
|
||||||
|
showToast('内容较大,字符级对比已自动降级为行级对比')
|
||||||
|
}
|
||||||
|
}, [diff, showToast])
|
||||||
|
|
||||||
const rows = useMemo(() => diff?.rows ?? [], [diff])
|
const rows = useMemo(() => diff?.rows ?? [], [diff])
|
||||||
const changedRows = useMemo(() => rows.filter((r) => r.isChanged), [rows])
|
const changedRows = useMemo(() => rows.filter((r) => r.isChanged), [rows])
|
||||||
const summary: DiffSummary = diff?.summary ?? EMPTY_SUMMARY
|
const summary: DiffSummary = diff?.summary ?? EMPTY_SUMMARY
|
||||||
@@ -314,6 +322,10 @@ export default function App(): ReactElement {
|
|||||||
async (fmt: ReportFormat) => {
|
async (fmt: ReportFormat) => {
|
||||||
// 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御
|
// 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御
|
||||||
if (!diff) return
|
if (!diff) return
|
||||||
|
// 字符级对比降级时,报告头部按实际生效的行级语义描述(全部空白忽略 + 空行忽略)
|
||||||
|
const reportOptions: DiffOptions = diff.charModeDowngraded
|
||||||
|
? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true }
|
||||||
|
: options
|
||||||
const ctx: ReportContext = {
|
const ctx: ReportContext = {
|
||||||
leftName: paneL?.meta.name ?? '',
|
leftName: paneL?.meta.name ?? '',
|
||||||
rightName: paneR?.meta.name ?? '',
|
rightName: paneR?.meta.name ?? '',
|
||||||
@@ -321,7 +333,7 @@ export default function App(): ReactElement {
|
|||||||
rightEncoding: paneR?.meta.encoding ?? '',
|
rightEncoding: paneR?.meta.encoding ?? '',
|
||||||
leftPath: paneL?.path || undefined,
|
leftPath: paneL?.path || undefined,
|
||||||
rightPath: paneR?.path || undefined,
|
rightPath: paneR?.path || undefined,
|
||||||
options
|
options: reportOptions
|
||||||
}
|
}
|
||||||
const content = buildReport(rows, ctx, summary, fmt)
|
const content = buildReport(rows, ctx, summary, fmt)
|
||||||
const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
|
const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
|
||||||
|
|||||||
@@ -570,6 +570,111 @@ describe('App - F7 / Shift+F7 差异导航快捷键', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('App - 字符级对比', () => {
|
||||||
|
it('开启后跨行重组判为完全一致(行级对比的边界在此补全)', async () => {
|
||||||
|
window.api = mockApi({
|
||||||
|
openFile: async (side) =>
|
||||||
|
side === 'left'
|
||||||
|
? { path: '/tmp/a.txt', name: 'a.txt', text: 'ab cd', encoding: 'UTF-8', binary: false }
|
||||||
|
: { path: '/tmp/b.txt', name: 'b.txt', text: 'ab\ncd', encoding: 'UTF-8', binary: false }
|
||||||
|
})
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
|
await screen.findByText('导出报告')
|
||||||
|
fireEvent.click(screen.getByText('打开右侧'))
|
||||||
|
await screen.findAllByText('b.txt')
|
||||||
|
// 默认行级对比:换行结构差异报“有差异”
|
||||||
|
expect(screen.getByText('有差异')).toBeInTheDocument()
|
||||||
|
// 开启字符级对比后:判为完全一致
|
||||||
|
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||||
|
expect(await screen.findByText('两文件内容完全一致')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('开启时三个空白选项被禁用', async () => {
|
||||||
|
window.api = mockApi()
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
|
await screen.findByText('导出报告')
|
||||||
|
expect(screen.getByLabelText('忽略行首尾空白')).toBeEnabled()
|
||||||
|
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||||
|
expect(screen.getByLabelText('忽略行首尾空白')).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText('忽略空行')).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText('忽略所有空白')).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText('忽略大小写')).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('内容超限时自动降级行级并给出提示', async () => {
|
||||||
|
// 1900 行 × 106 字符 ≈ 201,400 归一化字符(超过 20 万阈值);
|
||||||
|
// 总行数 1901 未超过快路径阈值,同步计算即时降级
|
||||||
|
const big = Array.from({ length: 1900 }, () => 'a'.repeat(106)).join('\n')
|
||||||
|
window.api = mockApi({
|
||||||
|
openFile: async () => ({
|
||||||
|
path: '/tmp/big.txt',
|
||||||
|
name: 'big.txt',
|
||||||
|
text: big,
|
||||||
|
encoding: 'UTF-8',
|
||||||
|
binary: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
|
await screen.findByText('导出报告')
|
||||||
|
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||||
|
expect(await screen.findByText(/已自动降级为行级对比/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('导出报告携带字符级对比说明', async () => {
|
||||||
|
let saved = ''
|
||||||
|
window.api = mockApi({
|
||||||
|
openFile: async () => ({
|
||||||
|
path: '/tmp/a.txt',
|
||||||
|
name: 'a.txt',
|
||||||
|
text: 'line1\nline2',
|
||||||
|
encoding: 'UTF-8',
|
||||||
|
binary: false
|
||||||
|
}),
|
||||||
|
saveReport: async (content: string) => {
|
||||||
|
saved = content
|
||||||
|
return { ok: true, path: 'C:/docs/r.txt' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
|
await screen.findByText('导出报告')
|
||||||
|
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||||
|
fireEvent.click(screen.getByText('导出报告'))
|
||||||
|
fireEvent.click(screen.getByText('纯文本'))
|
||||||
|
await screen.findByText(/报告已保存/)
|
||||||
|
expect(saved).toContain('比较选项:字符级对比(忽略全部空白与换行)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('降级后导出报告按实际生效的行级语义描述选项', async () => {
|
||||||
|
let saved = ''
|
||||||
|
const big = Array.from({ length: 1900 }, () => 'a'.repeat(106)).join('\n')
|
||||||
|
window.api = mockApi({
|
||||||
|
openFile: async (side) =>
|
||||||
|
side === 'left'
|
||||||
|
? { path: '/tmp/big.txt', name: 'big.txt', text: big, encoding: 'UTF-8', binary: false }
|
||||||
|
: { path: '/tmp/b.txt', name: 'b.txt', text: 'b', encoding: 'UTF-8', binary: false },
|
||||||
|
saveReport: async (content: string) => {
|
||||||
|
saved = content
|
||||||
|
return { ok: true, path: 'C:/docs/r.txt' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
|
await screen.findByText('导出报告')
|
||||||
|
fireEvent.click(screen.getByText('打开右侧'))
|
||||||
|
await screen.findAllByText('b.txt')
|
||||||
|
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||||
|
await screen.findByText(/已自动降级为行级对比/)
|
||||||
|
fireEvent.click(screen.getByText('导出报告'))
|
||||||
|
fireEvent.click(screen.getByText('纯文本'))
|
||||||
|
await screen.findByText(/报告已保存/)
|
||||||
|
expect(saved).toContain('比较选项:忽略所有空白、忽略空行')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('App - 交换左右侧', () => {
|
describe('App - 交换左右侧', () => {
|
||||||
it('交换后左右面板文件互换', async () => {
|
it('交换后左右面板文件互换', async () => {
|
||||||
window.api = mockApi({
|
window.api = mockApi({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { DiffSummary } from '../diff/diffEngine'
|
|||||||
|
|
||||||
const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 }
|
const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 }
|
||||||
|
|
||||||
function setup(extra?: { computing?: boolean }) {
|
function setup(extra?: { computing?: boolean; charMode?: boolean }) {
|
||||||
const onOptionsChange = vi.fn()
|
const onOptionsChange = vi.fn()
|
||||||
const onNav = vi.fn()
|
const onNav = vi.fn()
|
||||||
const onExport = vi.fn()
|
const onExport = vi.fn()
|
||||||
@@ -13,7 +13,7 @@ function setup(extra?: { computing?: boolean }) {
|
|||||||
const onSwap = vi.fn()
|
const onSwap = vi.fn()
|
||||||
const utils = render(
|
const utils = render(
|
||||||
<Toolbar
|
<Toolbar
|
||||||
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false }}
|
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false, charMode: extra?.charMode ?? false }}
|
||||||
onOptionsChange={onOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
navIndex={0}
|
navIndex={0}
|
||||||
@@ -40,25 +40,51 @@ describe('Toolbar', () => {
|
|||||||
it('切换忽略大小写触发 onOptionsChange', () => {
|
it('切换忽略大小写触发 onOptionsChange', () => {
|
||||||
const { onOptionsChange } = setup()
|
const { onOptionsChange } = setup()
|
||||||
fireEvent.click(screen.getByLabelText('忽略大小写'))
|
fireEvent.click(screen.getByLabelText('忽略大小写'))
|
||||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true, ignoreBlankLines: false, ignoreAllWhitespace: false })
|
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true, ignoreBlankLines: false, ignoreAllWhitespace: false, charMode: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('切换忽略行首尾空白触发 onOptionsChange', () => {
|
it('切换忽略行首尾空白触发 onOptionsChange', () => {
|
||||||
const { onOptionsChange } = setup()
|
const { onOptionsChange } = setup()
|
||||||
fireEvent.click(screen.getByLabelText('忽略行首尾空白'))
|
fireEvent.click(screen.getByLabelText('忽略行首尾空白'))
|
||||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false })
|
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false, charMode: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('切换忽略空行触发 onOptionsChange', () => {
|
it('切换忽略空行触发 onOptionsChange', () => {
|
||||||
const { onOptionsChange } = setup()
|
const { onOptionsChange } = setup()
|
||||||
fireEvent.click(screen.getByLabelText('忽略空行'))
|
fireEvent.click(screen.getByLabelText('忽略空行'))
|
||||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: true, ignoreAllWhitespace: false })
|
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: true, ignoreAllWhitespace: false, charMode: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('切换忽略所有空白触发 onOptionsChange', () => {
|
it('切换忽略所有空白触发 onOptionsChange', () => {
|
||||||
const { onOptionsChange } = setup()
|
const { onOptionsChange } = setup()
|
||||||
fireEvent.click(screen.getByLabelText('忽略所有空白'))
|
fireEvent.click(screen.getByLabelText('忽略所有空白'))
|
||||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: true })
|
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: true, charMode: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('切换字符级对比触发 onOptionsChange', () => {
|
||||||
|
const { onOptionsChange } = setup()
|
||||||
|
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||||
|
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false, charMode: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('字符级对比开启时三个空白选项被禁用(语义已被包含)', () => {
|
||||||
|
setup({ charMode: true })
|
||||||
|
expect(screen.getByLabelText('忽略行首尾空白')).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText('忽略空行')).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText('忽略所有空白')).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('字符级对比开启时忽略大小写仍可用', () => {
|
||||||
|
setup({ charMode: true })
|
||||||
|
expect(screen.getByLabelText('忽略大小写')).toBeEnabled()
|
||||||
|
expect(screen.getByLabelText('字符级对比')).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('字符级对比关闭时空白选项恢复可用', () => {
|
||||||
|
setup()
|
||||||
|
expect(screen.getByLabelText('忽略行首尾空白')).toBeEnabled()
|
||||||
|
expect(screen.getByLabelText('忽略空行')).toBeEnabled()
|
||||||
|
expect(screen.getByLabelText('忽略所有空白')).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('切换仅看差异触发 onOnlyDiffChange(true)', () => {
|
it('切换仅看差异触发 onOnlyDiffChange(true)', () => {
|
||||||
|
|||||||
@@ -52,10 +52,18 @@ export default function Toolbar({
|
|||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<div className="tool-group">
|
<div className="tool-group">
|
||||||
<span className="tool-label">比较选项</span>
|
<span className="tool-label">比较选项</span>
|
||||||
<label className="switch">
|
<label
|
||||||
|
className="switch"
|
||||||
|
title={
|
||||||
|
options.charMode
|
||||||
|
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
|
||||||
|
: '忽略行首尾空白差异'
|
||||||
|
}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={options.trimWhitespace}
|
checked={options.trimWhitespace}
|
||||||
|
disabled={options.charMode}
|
||||||
onChange={(e) => set({ trimWhitespace: e.target.checked })}
|
onChange={(e) => set({ trimWhitespace: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
忽略行首尾空白
|
忽略行首尾空白
|
||||||
@@ -68,25 +76,49 @@ export default function Toolbar({
|
|||||||
/>
|
/>
|
||||||
忽略大小写
|
忽略大小写
|
||||||
</label>
|
</label>
|
||||||
<label className="switch">
|
<label
|
||||||
|
className="switch"
|
||||||
|
title={
|
||||||
|
options.charMode
|
||||||
|
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
|
||||||
|
: '忽略空行差异(两侧空白行互相判等)'
|
||||||
|
}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={options.ignoreBlankLines ?? false}
|
checked={options.ignoreBlankLines ?? false}
|
||||||
|
disabled={options.charMode}
|
||||||
onChange={(e) => set({ ignoreBlankLines: e.target.checked })}
|
onChange={(e) => set({ ignoreBlankLines: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
忽略空行
|
忽略空行
|
||||||
</label>
|
</label>
|
||||||
<label
|
<label
|
||||||
className="switch"
|
className="switch"
|
||||||
title="忽略行内全部空白(空格/制表符/全角空格),仅比较实际文字;换行结构仍参与对比"
|
title={
|
||||||
|
options.charMode
|
||||||
|
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
|
||||||
|
: '忽略行内全部空白(空格/制表符/全角空格),仅比较实际文字;换行结构仍参与对比'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={options.ignoreAllWhitespace ?? false}
|
checked={options.ignoreAllWhitespace ?? false}
|
||||||
|
disabled={options.charMode}
|
||||||
onChange={(e) => set({ ignoreAllWhitespace: e.target.checked })}
|
onChange={(e) => set({ ignoreAllWhitespace: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
忽略所有空白
|
忽略所有空白
|
||||||
</label>
|
</label>
|
||||||
|
<label
|
||||||
|
className="switch"
|
||||||
|
title="空白与换行结构全部不参与判等,差异以字符粒度定位(跨行重组也能判等;超大内容自动降级行级对比)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={options.charMode ?? false}
|
||||||
|
onChange={(e) => set({ charMode: e.target.checked })}
|
||||||
|
/>
|
||||||
|
字符级对比
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tool-group">
|
<div className="tool-group">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { computeDiff } from './diffEngine'
|
import { computeDiff, CHAR_DIFF_MAX_CHARS } from './diffEngine'
|
||||||
|
|
||||||
describe('computeDiff - 空文本', () => {
|
describe('computeDiff - 空文本', () => {
|
||||||
it('空 vs 空 不产生任何行', () => {
|
it('空 vs 空 不产生任何行', () => {
|
||||||
@@ -249,4 +249,222 @@ describe('computeDiff - 行切分细节', () => {
|
|||||||
expect(rows[0].rowKind).toBe('unchanged')
|
expect(rows[0].rowKind).toBe('unchanged')
|
||||||
expect(summary.changedLines).toBe(0)
|
expect(summary.changedLines).toBe(0)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeDiff - 字符级对比(判等)', () => {
|
||||||
|
it('跨行重组一致:换行位置不同判为一致(行级对比的边界在此补全)', () => {
|
||||||
|
const { summary } = computeDiff('ab cd', 'ab\ncd', { charMode: true })
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空行数量差异不构成差异', () => {
|
||||||
|
const { summary } = computeDiff('a\n\nb', 'ab', { charMode: true })
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('行内空白差异不构成差异(隐含忽略所有空白)', () => {
|
||||||
|
const { summary } = computeDiff('a b\tc', 'abc', { charMode: true })
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('全角空格与中文内容正常判等', () => {
|
||||||
|
const { summary } = computeDiff('你好 世界', '你好世界', { charMode: true })
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('与忽略大小写组合生效', () => {
|
||||||
|
const { summary } = computeDiff('AB CD', 'ab\ncd', { charMode: true, ignoreCase: true })
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('实际字符差异仍被识别', () => {
|
||||||
|
const { summary } = computeDiff('abc', 'abd', { charMode: true })
|
||||||
|
expect(summary.changedLines).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('完全一致且行结构相同:常规 unchanged 配对', () => {
|
||||||
|
const { rows, summary } = computeDiff('a\nb', 'a\nb', { charMode: true })
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
expect(rows[0]).toMatchObject({ rowKind: 'unchanged' })
|
||||||
|
expect(rows[0].left.lineNo).toBe(1)
|
||||||
|
expect(rows[0].right.lineNo).toBe(1)
|
||||||
|
expect(rows[1].left.lineNo).toBe(2)
|
||||||
|
expect(rows[1].right.lineNo).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('一致但行数不等:短侧空槽仍为 unchanged,行数取较大侧', () => {
|
||||||
|
const { rows, summary } = computeDiff('a\n\nb', 'ab', { charMode: true })
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
expect(rows).toHaveLength(3)
|
||||||
|
expect(rows[1].left.lineNo).toBe(2)
|
||||||
|
expect(rows[1].right.lineNo).toBeNull()
|
||||||
|
expect(rows.every((r) => r.rowKind === 'unchanged')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('展示文本始终为原始行(不因归一化改写)', () => {
|
||||||
|
const { rows } = computeDiff(' a b ', 'a b', { charMode: true })
|
||||||
|
expect(rows[0].left.text).toBe(' a b ')
|
||||||
|
expect(rows[0].right.text).toBe('a b')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeDiff - 字符级对比(差异定位与回映射)', () => {
|
||||||
|
it('行内替换配对 modified,segs 含 common/删除/新增段', () => {
|
||||||
|
const { rows, summary } = computeDiff('cat', 'cap', { charMode: true })
|
||||||
|
expect(summary.modified).toBe(1)
|
||||||
|
const m = rows.find((r) => r.rowKind === 'modified')!
|
||||||
|
expect(m.left.segs).not.toBeNull()
|
||||||
|
expect(m.right.segs).not.toBeNull()
|
||||||
|
expect(m.left.segs!.some((s) => s.kind === 'common' && s.text === 'ca')).toBe(true)
|
||||||
|
expect(m.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('t'))).toBe(true)
|
||||||
|
expect(m.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('p'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('行内删除显示为 modified(对侧行回拉),删除字符精确高亮', () => {
|
||||||
|
const { rows, summary } = computeDiff('abcXYZdef', 'abcdef', { charMode: true })
|
||||||
|
expect(summary.modified).toBe(1)
|
||||||
|
expect(summary.deleted).toBe(0)
|
||||||
|
const m = rows.find((r) => r.rowKind === 'modified')!
|
||||||
|
expect(m.left.lineNo).toBe(1)
|
||||||
|
expect(m.right.lineNo).toBe(1)
|
||||||
|
expect(m.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('XYZ'))).toBe(true)
|
||||||
|
// 右侧无新增字符:整行 common
|
||||||
|
expect(m.right.segs!.every((s) => s.kind === 'common')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('行内新增显示为 modified,新增字符精确高亮', () => {
|
||||||
|
const { rows, summary } = computeDiff('abcdef', 'abcXYZdef', { charMode: true })
|
||||||
|
expect(summary.modified).toBe(1)
|
||||||
|
expect(summary.inserted).toBe(0)
|
||||||
|
const m = rows.find((r) => r.rowKind === 'modified')!
|
||||||
|
expect(m.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('XYZ'))).toBe(true)
|
||||||
|
expect(m.left.segs!.every((s) => s.kind === 'common')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('行尾追加(流尾跨界)同样回拉配对 modified', () => {
|
||||||
|
const { rows, summary } = computeDiff('abc', 'abcXYZ', { charMode: true })
|
||||||
|
expect(summary.modified).toBe(1)
|
||||||
|
const m = rows.find((r) => r.rowKind === 'modified')!
|
||||||
|
expect(m.right.segs!.some((s) => s.kind === 'insert' && s.text.includes('XYZ'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('行首插入(流首跨界)同样回拉配对 modified', () => {
|
||||||
|
const { summary } = computeDiff('abc', 'XYZabc', { charMode: true })
|
||||||
|
expect(summary.modified).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('整行删除(跨行不跨界)保持 removed 语义,不误配对', () => {
|
||||||
|
const { rows, summary } = computeDiff('aa\nbb\ncc', 'aa\ncc', { charMode: true })
|
||||||
|
expect(summary.changedLines).toBe(1)
|
||||||
|
expect(summary.deleted).toBe(1)
|
||||||
|
const rem = rows.find((r) => r.rowKind === 'removed')!
|
||||||
|
expect(rem.left.lineNo).toBe(2)
|
||||||
|
expect(rem.left.text).toBe('bb')
|
||||||
|
// 其余行锚定配对
|
||||||
|
const anchor = rows.filter((r) => r.rowKind === 'unchanged')
|
||||||
|
expect(anchor.map((r) => [r.left.lineNo, r.right.lineNo])).toEqual([
|
||||||
|
[1, 1],
|
||||||
|
[3, 2]
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('整行新增保持 added 语义', () => {
|
||||||
|
const { rows, summary } = computeDiff('aa\ncc', 'aa\nbb\ncc', { charMode: true })
|
||||||
|
expect(summary.inserted).toBe(1)
|
||||||
|
const add = rows.find((r) => r.rowKind === 'added')!
|
||||||
|
expect(add.right.lineNo).toBe(2)
|
||||||
|
expect(add.right.text).toBe('bb')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多个独立修改块各自配对 modified,锚行穿插', () => {
|
||||||
|
const { rows, summary } = computeDiff('a\nX\nb\nY\nc', 'a\nZ\nb\nW\nc', { charMode: true })
|
||||||
|
expect(summary.modified).toBe(2)
|
||||||
|
expect(summary.changedLines).toBe(2)
|
||||||
|
const mods = rows.filter((r) => r.rowKind === 'modified')
|
||||||
|
expect(mods[0].left.text).toBe('X')
|
||||||
|
expect(mods[0].right.text).toBe('Z')
|
||||||
|
expect(mods[1].left.text).toBe('Y')
|
||||||
|
expect(mods[1].right.text).toBe('W')
|
||||||
|
expect(rows.filter((r) => r.rowKind === 'unchanged')).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('删除文本中间的空白归入删除段(空白继承规则)', () => {
|
||||||
|
const { rows, summary } = computeDiff('hello big world', 'hello world', { charMode: true })
|
||||||
|
expect(summary.modified).toBe(1)
|
||||||
|
const m = rows.find((r) => r.rowKind === 'modified')!
|
||||||
|
const del = m.left.segs!.find((s) => s.kind === 'delete')!
|
||||||
|
// 'big' 与其后空白一并划入删除段
|
||||||
|
expect(del.text).toContain('big')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跨行变更配对:共享字符行锚定,变更行配 modified', () => {
|
||||||
|
const { rows, summary } = computeDiff('ab', 'a\nx', { charMode: true })
|
||||||
|
expect(summary.changedLines).toBe(1)
|
||||||
|
expect(summary.modified).toBe(1)
|
||||||
|
const m = rows.find((r) => r.rowKind === 'modified')!
|
||||||
|
expect(m.left.text).toBe('ab')
|
||||||
|
expect(m.right.text).toBe('x')
|
||||||
|
expect(m.left.segs!.some((s) => s.kind === 'delete' && s.text.includes('b'))).toBe(true)
|
||||||
|
// 双侧行号序列各自单调不减(空槽跳过)
|
||||||
|
const lNos = rows.map((r) => r.left.lineNo).filter((n): n is number => n !== null)
|
||||||
|
const rNos = rows.map((r) => r.right.lineNo).filter((n): n is number => n !== null)
|
||||||
|
expect([...lNos].sort((a, b) => a - b)).toEqual(lNos)
|
||||||
|
expect([...rNos].sort((a, b) => a - b)).toEqual(rNos)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空 vs 非空全部为新增行', () => {
|
||||||
|
const { rows, summary } = computeDiff('', 'a\nb', { charMode: true })
|
||||||
|
expect(summary.inserted).toBe(2)
|
||||||
|
expect(rows.every((r) => r.rowKind === 'added')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空 vs 空 不产生任何行', () => {
|
||||||
|
const { rows, summary } = computeDiff('', '', { charMode: true })
|
||||||
|
expect(rows).toHaveLength(0)
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('忽略大小写只影响判等,diff 定位按原字符', () => {
|
||||||
|
const { summary } = computeDiff('Cat', 'car', { charMode: true, ignoreCase: true })
|
||||||
|
// 'ca' 一致,t 与 r 为一处修改
|
||||||
|
expect(summary.modified).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeDiff - 字符级对比(超限降级)', () => {
|
||||||
|
it('归一化流超限自动降级行级并携带降级标志', () => {
|
||||||
|
const left = 'a'.repeat(100001)
|
||||||
|
const right = 'a'.repeat(100001)
|
||||||
|
const result = computeDiff(left, right, { charMode: true })
|
||||||
|
expect(result.charModeDowngraded).toBe(true)
|
||||||
|
// 降级行级采用“忽略所有空白 + 忽略空行”语义:全 a 单行仍判一致
|
||||||
|
expect(result.summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('降级结果与等效行级选项直接计算一致', () => {
|
||||||
|
const left = 'a'.repeat(100001) + '\nb'
|
||||||
|
const right = 'a'.repeat(100001) + '\nc'
|
||||||
|
const result = computeDiff(left, right, { charMode: true })
|
||||||
|
expect(result.charModeDowngraded).toBe(true)
|
||||||
|
const direct = computeDiff(left, right, {
|
||||||
|
ignoreAllWhitespace: true,
|
||||||
|
ignoreBlankLines: true
|
||||||
|
})
|
||||||
|
expect(result.summary).toEqual(direct.summary)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('恰好阈值不降级', () => {
|
||||||
|
const half = CHAR_DIFF_MAX_CHARS / 2
|
||||||
|
const { summary, charModeDowngraded } = computeDiff('a'.repeat(half), 'a'.repeat(half), {
|
||||||
|
charMode: true
|
||||||
|
})
|
||||||
|
expect(charModeDowngraded).toBeUndefined()
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未开启 charMode 时不携带降级标志', () => {
|
||||||
|
const { charModeDowngraded } = computeDiff('a', 'b', {})
|
||||||
|
expect(charModeDowngraded).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { diffArrays, diffWordsWithSpace } from 'diff'
|
import { diffArrays, diffChars, diffWordsWithSpace } from 'diff'
|
||||||
|
|
||||||
/** 词级分段类型 */
|
/** 词级分段类型 */
|
||||||
export type SegKind = 'common' | 'insert' | 'delete'
|
export type SegKind = 'common' | 'insert' | 'delete'
|
||||||
@@ -43,11 +43,19 @@ export interface DiffOptions {
|
|||||||
ignoreBlankLines?: boolean
|
ignoreBlankLines?: boolean
|
||||||
/** 忽略全部行内空白(空格/制表符/全角空格等),仅比较实际文字内容;换行结构仍参与对比 */
|
/** 忽略全部行内空白(空格/制表符/全角空格等),仅比较实际文字内容;换行结构仍参与对比 */
|
||||||
ignoreAllWhitespace?: boolean
|
ignoreAllWhitespace?: boolean
|
||||||
|
/**
|
||||||
|
* 字符级对比:全部空白与换行结构都不参与判等,两侧全文归一化为字符流做 diff,
|
||||||
|
* 差异以字符粒度回映射到行(跨行重组也能判等)。隐含 ignoreAllWhitespace 的全部语义;
|
||||||
|
* 归一化流超限(CHAR_DIFF_MAX_CHARS)时自动降级为行级对比并置 charModeDowngraded。
|
||||||
|
*/
|
||||||
|
charMode?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiffResult {
|
export interface DiffResult {
|
||||||
rows: DiffRow[]
|
rows: DiffRow[]
|
||||||
summary: DiffSummary
|
summary: DiffSummary
|
||||||
|
/** 字符级对比因内容超限自动降级为行级对比时为 true(供界面与报告提示) */
|
||||||
|
charModeDowngraded?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyLine(): SideCell {
|
function emptyLine(): SideCell {
|
||||||
@@ -93,13 +101,39 @@ export function splitLines(text: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算文本差异。
|
* 计算文本差异(统一入口)。
|
||||||
* diffArrays 以“归一化行”做行级 LCS,得到增/删/同;
|
* charMode 开启时走字符级对比(超限自动降级行级并携带降级标志);否则走行级对比。
|
||||||
|
*/
|
||||||
|
export function computeDiff(
|
||||||
|
leftText: string,
|
||||||
|
rightText: string,
|
||||||
|
options: DiffOptions = {}
|
||||||
|
): DiffResult {
|
||||||
|
if (options.charMode) {
|
||||||
|
const charResult = computeCharDiff(leftText, rightText, options)
|
||||||
|
if (charResult) return charResult
|
||||||
|
// 归一化流超限:降级为行级对比,选项取字符级语义的最接近子集
|
||||||
|
// (全部空白忽略 + 空行忽略;换行结构差异是行级架构无法消除的边界)
|
||||||
|
const result = computeLineDiff(leftText, rightText, {
|
||||||
|
...options,
|
||||||
|
charMode: false,
|
||||||
|
ignoreAllWhitespace: true,
|
||||||
|
ignoreBlankLines: true,
|
||||||
|
trimWhitespace: false
|
||||||
|
})
|
||||||
|
result.charModeDowngraded = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return computeLineDiff(leftText, rightText, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行级对比:diffArrays 以“归一化行”做行级 LCS,得到增/删/同;
|
||||||
* 删除段与新增段配对为“修改”行并做词级内联高亮。
|
* 删除段与新增段配对为“修改”行并做词级内联高亮。
|
||||||
* trimWhitespace / ignoreCase / ignoreAllWhitespace 只影响“是否判等”,展示文本始终取原始行;
|
* trimWhitespace / ignoreCase / ignoreAllWhitespace 只影响“是否判等”,展示文本始终取原始行;
|
||||||
* ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。
|
* ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。
|
||||||
*/
|
*/
|
||||||
export function computeDiff(
|
function computeLineDiff(
|
||||||
leftText: string,
|
leftText: string,
|
||||||
rightText: string,
|
rightText: string,
|
||||||
options: DiffOptions = {}
|
options: DiffOptions = {}
|
||||||
@@ -246,5 +280,363 @@ export function computeDiff(
|
|||||||
modified: rows.filter((r) => r.rowKind === 'modified').length
|
modified: rows.filter((r) => r.rowKind === 'modified').length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { rows, summary }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ============ 字符级对比 ============ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字符级对比的字符总量上限(两侧归一化流合计):
|
||||||
|
* diffChars 为 O(ND),超限后计算可能耗时失控,自动降级为行级对比。
|
||||||
|
*/
|
||||||
|
export const CHAR_DIFF_MAX_CHARS = 200000
|
||||||
|
|
||||||
|
/** 空白字符判定:与 ignoreAllWhitespace 的 \s 语义一致(含换行、制表符、全角空格) */
|
||||||
|
function isWsChar(ch: string): boolean {
|
||||||
|
return /\s/.test(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化字符流:剔除全部空白后的连续字符,并记录每个字符的原始行号与码点列号 */
|
||||||
|
interface CharStream {
|
||||||
|
/** 归一化字符连成的字符串(供 diffChars 直接消费) */
|
||||||
|
text: string
|
||||||
|
/** 与 text 逐字符对应的原始行号(1 基) */
|
||||||
|
lineNos: number[]
|
||||||
|
/** 与 text 逐字符对应的原始码点列号(0 基) */
|
||||||
|
cols: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCharStream(lines: string[], norm: (ch: string) => string): CharStream {
|
||||||
|
const chars: string[] = []
|
||||||
|
const lineNos: number[] = []
|
||||||
|
const cols: number[] = []
|
||||||
|
lines.forEach((text, i) => {
|
||||||
|
let col = 0
|
||||||
|
for (const ch of text) {
|
||||||
|
if (!isWsChar(ch)) {
|
||||||
|
chars.push(norm(ch))
|
||||||
|
lineNos.push(i + 1)
|
||||||
|
cols.push(col)
|
||||||
|
}
|
||||||
|
col++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { text: chars.join(''), lineNos, cols }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按行内字符状态重建高亮分段(modified 行的 segs):
|
||||||
|
* 非空白字符查状态表(缺省 common);空白字符继承前一个非空白字符的状态(行首空白按 common),
|
||||||
|
* 保证被删除文本中间的空白归入删除段、新增文本中间的空白归入新增段。
|
||||||
|
*/
|
||||||
|
function charSegs(text: string, stat: Map<number, number> | undefined): Seg[] {
|
||||||
|
const segs: Seg[] = []
|
||||||
|
let cur: SegKind | null = null
|
||||||
|
let buf = ''
|
||||||
|
let lastStat = 0
|
||||||
|
let col = 0
|
||||||
|
for (const ch of text) {
|
||||||
|
let st: number
|
||||||
|
if (isWsChar(ch)) {
|
||||||
|
st = lastStat
|
||||||
|
} else {
|
||||||
|
st = stat?.get(col) ?? 0
|
||||||
|
lastStat = st
|
||||||
|
}
|
||||||
|
const kind: SegKind = st === 1 ? 'delete' : st === 2 ? 'insert' : 'common'
|
||||||
|
if (kind !== cur) {
|
||||||
|
if (cur !== null) segs.push({ text: buf, kind: cur })
|
||||||
|
cur = kind
|
||||||
|
buf = ch
|
||||||
|
} else {
|
||||||
|
buf += ch
|
||||||
|
}
|
||||||
|
col++
|
||||||
|
}
|
||||||
|
if (cur !== null) segs.push({ text: buf, kind: cur })
|
||||||
|
return segs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字符级对比:两侧全文归一化为字符流 → diffChars 对齐 → 字符差异回映射为 DiffRow。
|
||||||
|
*
|
||||||
|
* 回映射规则(输出仍为 DiffRow,视图/报告/导航零改动):
|
||||||
|
* - 含变更(删除/新增)字符的行为“变更行”(跨界行——既有 common 又有变更字符——也划归变更行,
|
||||||
|
* 其 common 部分在 modified 行的 segs 中以 common 段呈现,信息不丢失);
|
||||||
|
* - 连续变更行组配对:等长配 modified(segs 按整行重建),多余侧降级 removed / added(与行级 commitModified 同构);
|
||||||
|
* - 变更组之间的行(含纯空白行)为“锚行”,左右按序 zip 配对输出 unchanged,短侧空槽仍标 unchanged
|
||||||
|
* (字符级语义下纯空白行不构成差异)。
|
||||||
|
*
|
||||||
|
* 内容超限(CHAR_DIFF_MAX_CHARS)返回 null,由调用方降级行级。
|
||||||
|
*/
|
||||||
|
function computeCharDiff(
|
||||||
|
leftText: string,
|
||||||
|
rightText: string,
|
||||||
|
options: DiffOptions
|
||||||
|
): DiffResult | null {
|
||||||
|
const leftOrig = splitLines(leftText)
|
||||||
|
const rightOrig = splitLines(rightText)
|
||||||
|
// 大小写归一化在构造流时完成:toLowerCase 展开为多字符的字符(如 İ→i̇)退回原样,
|
||||||
|
// 避免归一化后字符数变化破坏流索引
|
||||||
|
const normCh = (ch: string): string => {
|
||||||
|
if (!options.ignoreCase) return ch
|
||||||
|
const lo = ch.toLowerCase()
|
||||||
|
return lo.length === 1 ? lo : ch
|
||||||
|
}
|
||||||
|
const left = buildCharStream(leftOrig, normCh)
|
||||||
|
const right = buildCharStream(rightOrig, normCh)
|
||||||
|
if (left.text.length + right.text.length > CHAR_DIFF_MAX_CHARS) return null
|
||||||
|
|
||||||
|
const parts = diffChars(left.text, right.text)
|
||||||
|
|
||||||
|
// 流区间块序列:common 为 eq 块,连续 removed/added 合并为 ne 块(与行级 parts 循环同构)
|
||||||
|
interface Block {
|
||||||
|
eq: boolean
|
||||||
|
l0: number
|
||||||
|
l1: number
|
||||||
|
r0: number
|
||||||
|
r1: number
|
||||||
|
/** removed-only 时回拉的对侧配对行(见下方预处理说明) */
|
||||||
|
pullR?: number
|
||||||
|
/** added-only 时回拉的对侧配对行 */
|
||||||
|
pullL?: number
|
||||||
|
}
|
||||||
|
const blocks: Block[] = []
|
||||||
|
let li = 0
|
||||||
|
let ri = 0
|
||||||
|
for (const part of parts) {
|
||||||
|
const len = part.value.length
|
||||||
|
const isEq = !part.added && !part.removed
|
||||||
|
const last = blocks[blocks.length - 1]
|
||||||
|
if (!last || last.eq !== isEq) {
|
||||||
|
blocks.push({ eq: isEq, l0: li, l1: li, r0: ri, r1: ri })
|
||||||
|
}
|
||||||
|
const b = blocks[blocks.length - 1]
|
||||||
|
if (isEq) {
|
||||||
|
b.l1 += len
|
||||||
|
b.r1 += len
|
||||||
|
li += len
|
||||||
|
ri += len
|
||||||
|
} else if (part.removed) {
|
||||||
|
b.l1 += len
|
||||||
|
li += len
|
||||||
|
} else {
|
||||||
|
b.r1 += len
|
||||||
|
ri += len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ne 块字符状态与涉及行集合(跨界行划归变更组)
|
||||||
|
const changedL = new Set<number>()
|
||||||
|
const changedR = new Set<number>()
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b.eq) continue
|
||||||
|
for (let i = b.l0; i < b.l1; i++) changedL.add(left.lineNos[i])
|
||||||
|
for (let i = b.r0; i < b.r1; i++) changedR.add(right.lineNos[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已划归变更组的行(含跨界行与回拉行),锚段输出时排除
|
||||||
|
const pairedL = new Set(changedL)
|
||||||
|
const pairedR = new Set(changedR)
|
||||||
|
|
||||||
|
// 单侧变更块(removed-only / added-only)的对侧行回拉:
|
||||||
|
// 变更行与相邻 eq 块同行(跨界延续,如行内删除 abcXYZdef → abcdef)时,
|
||||||
|
// 把对侧对齐行拉入变更组配对,使行内增删显示为 modified(字符高亮)而非整行 removed/added;
|
||||||
|
// 跨行整行增删(变更行与相邻 eq 块不同行)不回拉,维持 removed/added 语义。
|
||||||
|
for (let bi = 0; bi < blocks.length; bi++) {
|
||||||
|
const b = blocks[bi]
|
||||||
|
if (b.eq) continue
|
||||||
|
const pe = bi > 0 && blocks[bi - 1].eq ? blocks[bi - 1] : undefined
|
||||||
|
const nx = bi + 1 < blocks.length && blocks[bi + 1].eq ? blocks[bi + 1] : undefined
|
||||||
|
if (b.l0 < b.l1 && b.r0 === b.r1) {
|
||||||
|
// removed-only:左侧变更行跨界时回拉右侧对齐行
|
||||||
|
const firstL = left.lineNos[b.l0]
|
||||||
|
const lastL = left.lineNos[b.l1 - 1]
|
||||||
|
const crossL =
|
||||||
|
(pe !== undefined && firstL === left.lineNos[pe.l1 - 1]) ||
|
||||||
|
(nx !== undefined && lastL === left.lineNos[nx.l0])
|
||||||
|
if (!crossL) continue
|
||||||
|
const rn =
|
||||||
|
b.r0 < right.lineNos.length
|
||||||
|
? right.lineNos[b.r0]
|
||||||
|
: right.lineNos.length > 0
|
||||||
|
? right.lineNos[right.lineNos.length - 1]
|
||||||
|
: 0
|
||||||
|
if (rn < 1) continue
|
||||||
|
const crossR =
|
||||||
|
(pe !== undefined && rn === right.lineNos[pe.r1 - 1]) ||
|
||||||
|
(nx !== undefined && rn === right.lineNos[nx.r0])
|
||||||
|
if (crossR) {
|
||||||
|
b.pullR = rn
|
||||||
|
pairedR.add(rn)
|
||||||
|
}
|
||||||
|
} else if (b.r0 < b.r1 && b.l0 === b.l1) {
|
||||||
|
// added-only:右侧变更行跨界时回拉左侧对齐行
|
||||||
|
const firstR = right.lineNos[b.r0]
|
||||||
|
const lastR = right.lineNos[b.r1 - 1]
|
||||||
|
const crossR =
|
||||||
|
(pe !== undefined && firstR === right.lineNos[pe.r1 - 1]) ||
|
||||||
|
(nx !== undefined && lastR === right.lineNos[nx.r0])
|
||||||
|
if (!crossR) continue
|
||||||
|
const ln =
|
||||||
|
b.l0 < left.lineNos.length
|
||||||
|
? left.lineNos[b.l0]
|
||||||
|
: left.lineNos.length > 0
|
||||||
|
? left.lineNos[left.lineNos.length - 1]
|
||||||
|
: 0
|
||||||
|
if (ln < 1) continue
|
||||||
|
const crossL =
|
||||||
|
(pe !== undefined && ln === left.lineNos[pe.l1 - 1]) ||
|
||||||
|
(nx !== undefined && ln === left.lineNos[nx.l0])
|
||||||
|
if (crossL) {
|
||||||
|
b.pullL = ln
|
||||||
|
pairedL.add(ln)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 行内变更字符的列位状态(行号 → 列号 → 1 删除 / 2 新增),供 segs 重建
|
||||||
|
const statByLineL = new Map<number, Map<number, number>>()
|
||||||
|
const statByLineR = new Map<number, Map<number, number>>()
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b.eq) continue
|
||||||
|
for (let i = b.l0; i < b.l1; i++) {
|
||||||
|
const ln = left.lineNos[i]
|
||||||
|
let m = statByLineL.get(ln)
|
||||||
|
if (!m) {
|
||||||
|
m = new Map()
|
||||||
|
statByLineL.set(ln, m)
|
||||||
|
}
|
||||||
|
m.set(left.cols[i], 1)
|
||||||
|
}
|
||||||
|
for (let i = b.r0; i < b.r1; i++) {
|
||||||
|
const rn = right.lineNos[i]
|
||||||
|
let m = statByLineR.get(rn)
|
||||||
|
if (!m) {
|
||||||
|
m = new Map()
|
||||||
|
statByLineR.set(rn, m)
|
||||||
|
}
|
||||||
|
m.set(right.cols[i], 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rowSeq = 0
|
||||||
|
const rows: DiffRow[] = []
|
||||||
|
let pendingL: number[] = []
|
||||||
|
let pendingR: number[] = []
|
||||||
|
|
||||||
|
/** 变更行组配对输出(与行级 commitModified 同构:等长 modified / 多余降级 removed·added) */
|
||||||
|
const flushChange = (): void => {
|
||||||
|
const n = Math.max(pendingL.length, pendingR.length)
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const ln = pendingL[i]
|
||||||
|
const rn = pendingR[i]
|
||||||
|
if (ln !== undefined && rn !== undefined) {
|
||||||
|
rows.push({
|
||||||
|
id: `r${rowSeq++}`,
|
||||||
|
rowKind: 'modified',
|
||||||
|
isChanged: true,
|
||||||
|
left: {
|
||||||
|
lineNo: ln,
|
||||||
|
text: leftOrig[ln - 1],
|
||||||
|
segs: charSegs(leftOrig[ln - 1], statByLineL.get(ln))
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
lineNo: rn,
|
||||||
|
text: rightOrig[rn - 1],
|
||||||
|
segs: charSegs(rightOrig[rn - 1], statByLineR.get(rn))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (ln !== undefined) {
|
||||||
|
rows.push({
|
||||||
|
id: `r${rowSeq++}`,
|
||||||
|
rowKind: 'removed',
|
||||||
|
isChanged: true,
|
||||||
|
left: { lineNo: ln, text: leftOrig[ln - 1], segs: null },
|
||||||
|
right: emptyLine()
|
||||||
|
})
|
||||||
|
} else if (rn !== undefined) {
|
||||||
|
rows.push({
|
||||||
|
id: `r${rowSeq++}`,
|
||||||
|
rowKind: 'added',
|
||||||
|
isChanged: true,
|
||||||
|
left: emptyLine(),
|
||||||
|
right: { lineNo: rn, text: rightOrig[rn - 1], segs: null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingL = []
|
||||||
|
pendingR = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 锚行组按序 zip 配对输出 unchanged(短侧空槽,纯空白行不构成差异) */
|
||||||
|
const emitAnchors = (la: number[], ra: number[]): void => {
|
||||||
|
const n = Math.max(la.length, ra.length)
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const ln = la[i] ?? null
|
||||||
|
const rn = ra[i] ?? null
|
||||||
|
rows.push({
|
||||||
|
id: `r${rowSeq++}`,
|
||||||
|
rowKind: 'unchanged',
|
||||||
|
isChanged: false,
|
||||||
|
left: ln === null ? emptyLine() : { lineNo: ln, text: leftOrig[ln - 1], segs: null },
|
||||||
|
right: rn === null ? emptyLine() : { lineNo: rn, text: rightOrig[rn - 1], segs: null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 行游标推进:锚段右边界 = 下一 ne 块首字符行 - 1(流尽则为文件末行)
|
||||||
|
let lCursor = 1
|
||||||
|
let rCursor = 1
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b.eq) {
|
||||||
|
flushChange()
|
||||||
|
const lEnd = b.l1 < left.lineNos.length ? left.lineNos[b.l1] - 1 : leftOrig.length
|
||||||
|
const rEnd = b.r1 < right.lineNos.length ? right.lineNos[b.r1] - 1 : rightOrig.length
|
||||||
|
const la: number[] = []
|
||||||
|
for (let ln = lCursor; ln <= lEnd; ln++) {
|
||||||
|
if (!pairedL.has(ln)) la.push(ln)
|
||||||
|
}
|
||||||
|
const ra: number[] = []
|
||||||
|
for (let rn = rCursor; rn <= rEnd; rn++) {
|
||||||
|
if (!pairedR.has(rn)) ra.push(rn)
|
||||||
|
}
|
||||||
|
emitAnchors(la, ra)
|
||||||
|
lCursor = lEnd + 1
|
||||||
|
rCursor = rEnd + 1
|
||||||
|
} else {
|
||||||
|
// 变更行收集(ne 块内行号单调不减,尾去重即可);单侧变更块带上回拉的对侧配对行
|
||||||
|
for (let i = b.l0; i < b.l1; i++) {
|
||||||
|
const ln = left.lineNos[i]
|
||||||
|
if (pendingL[pendingL.length - 1] !== ln) pendingL.push(ln)
|
||||||
|
}
|
||||||
|
for (let i = b.r0; i < b.r1; i++) {
|
||||||
|
const rn = right.lineNos[i]
|
||||||
|
if (pendingR[pendingR.length - 1] !== rn) pendingR.push(rn)
|
||||||
|
}
|
||||||
|
if (b.pullR !== undefined && pendingR.length === 0) pendingR.push(b.pullR)
|
||||||
|
if (b.pullL !== undefined && pendingL.length === 0) pendingL.push(b.pullL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushChange()
|
||||||
|
|
||||||
|
// 尾部剩余行(最后一块之后的空白行/未覆盖行)按锚行输出
|
||||||
|
const tailLa: number[] = []
|
||||||
|
for (let ln = lCursor; ln <= leftOrig.length; ln++) {
|
||||||
|
if (!pairedL.has(ln)) tailLa.push(ln)
|
||||||
|
}
|
||||||
|
const tailRa: number[] = []
|
||||||
|
for (let rn = rCursor; rn <= rightOrig.length; rn++) {
|
||||||
|
if (!pairedR.has(rn)) tailRa.push(rn)
|
||||||
|
}
|
||||||
|
emitAnchors(tailLa, tailRa)
|
||||||
|
|
||||||
|
const summary: DiffSummary = {
|
||||||
|
changedLines: rows.filter((r) => r.isChanged).length,
|
||||||
|
inserted: rows.filter((r) => r.rowKind === 'added').length,
|
||||||
|
deleted: rows.filter((r) => r.rowKind === 'removed').length,
|
||||||
|
modified: rows.filter((r) => r.rowKind === 'modified').length
|
||||||
|
}
|
||||||
|
|
||||||
return { rows, summary }
|
return { rows, summary }
|
||||||
}
|
}
|
||||||
@@ -183,6 +183,18 @@ describe('plainOptions - 比较选项描述', () => {
|
|||||||
it('忽略所有空白遮蔽行首尾空白(超集不重复列出)', () => {
|
it('忽略所有空白遮蔽行首尾空白(超集不重复列出)', () => {
|
||||||
expect(plainOptions({ ignoreAllWhitespace: true, trimWhitespace: true })).toBe('忽略所有空白')
|
expect(plainOptions({ ignoreAllWhitespace: true, trimWhitespace: true })).toBe('忽略所有空白')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('字符级对比单独描述并遮蔽全部空白类选项', () => {
|
||||||
|
expect(
|
||||||
|
plainOptions({ charMode: true, trimWhitespace: true, ignoreAllWhitespace: true, ignoreBlankLines: true })
|
||||||
|
).toBe('字符级对比(忽略全部空白与换行)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('字符级对比与忽略大小写组合列出', () => {
|
||||||
|
expect(plainOptions({ charMode: true, ignoreCase: true })).toBe(
|
||||||
|
'字符级对比(忽略全部空白与换行)、忽略大小写'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('报告头部比较选项说明', () => {
|
describe('报告头部比较选项说明', () => {
|
||||||
|
|||||||
@@ -120,6 +120,12 @@ export function plainSummary(summary: DiffSummary): string {
|
|||||||
|
|
||||||
/** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */
|
/** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */
|
||||||
export function plainOptions(options?: DiffOptions): string {
|
export function plainOptions(options?: DiffOptions): string {
|
||||||
|
// 字符级对比为最强空白语义(全部空白与换行都不参与判等),单独描述并遮蔽空白类子选项
|
||||||
|
if (options?.charMode) {
|
||||||
|
const on = ['字符级对比(忽略全部空白与换行)']
|
||||||
|
if (options.ignoreCase) on.push('忽略大小写')
|
||||||
|
return on.join('、')
|
||||||
|
}
|
||||||
const on: string[] = []
|
const on: string[] = []
|
||||||
// 忽略所有空白为行首尾空白的超集,同时开启时只列前者,避免误导
|
// 忽略所有空白为行首尾空白的超集,同时开启时只列前者,避免误导
|
||||||
if (options?.ignoreAllWhitespace) on.push('忽略所有空白')
|
if (options?.ignoreAllWhitespace) on.push('忽略所有空白')
|
||||||
|
|||||||
Reference in New Issue
Block a user