feat: 0.7.0 文件夹对比增强(语义判等/子目录树形/搜索与状态过滤/报告导出/差异文件导航/大目录单点失败降级)+ 字符级分块锚定恢复(0.6.3 算法)与滚动回弹根因修复(重算期间保留旧结果撑住容器高度)+ E2E 滚动稳定性回归锚

This commit is contained in:
2026-08-18 18:40:03 +08:00
parent 74d52ec0b0
commit 1c52f5d3cb
25 changed files with 2703 additions and 281 deletions
+147 -3
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { scanFolders, type FolderEntry } from './folderScan'
import { scanFolders, semanticEquals, SEMANTIC_MAX_BYTES, type FolderEntry } from './folderScan'
/** 以临时目录为根构造两个待对比文件夹 */
let leftDir: string
@@ -114,8 +114,11 @@ describe('scanFolders - 边界与选项', () => {
}
})
it('目录不存在时抛出异常(由 IPC 层转为错误结果', async () => {
await expect(scanFolders(join(leftDir, 'not-exist'), rightDir)).rejects.toThrow()
it('目录不存在时不再抛异常:该侧按无文件处理(另一侧条目为 only', async () => {
// 单目录 IO 失败降级(被锁/权限)而非整体失败:大目录下单点锁不丢弃全部结果
const r = await scanFolders(join(leftDir, 'not-exist'), rightDir)
expect(r.entries.length).toBeGreaterThan(0)
expect(r.entries.every((e) => e.status === 'right-only')).toBe(true)
})
it('maxFiles 截断:条目不完整且 truncated 为 true', async () => {
@@ -195,6 +198,147 @@ describe('scanFolders - 边界与选项', () => {
})
})
describe('scanFolders - 语义判等', () => {
/** 语义判等注入:同步解码(UTF-8 直读)+ 文本扩展名白名单 */
const SEMANTIC = {
maxBytes: SEMANTIC_MAX_BYTES,
isTextFile: (rel: string): boolean => {
const ext = rel.slice(rel.lastIndexOf('.') + 1).toLowerCase()
return ['txt', 'md', 'json', 'ts'].includes(ext)
},
decode: async (buf: Buffer): Promise<string> => buf.toString('utf-8')
}
it('semanticEquals:空白/换行/空行差异判等,跨行重排判等,实质差异与大小写不判等', () => {
expect(semanticEquals('a b', 'ab')).toBe(true)
expect(semanticEquals('a\r\nb', 'a\nb')).toBe(true)
expect(semanticEquals('a\n\n\nb', 'ab')).toBe(true)
expect(semanticEquals('ab cd', 'ab\ncd')).toBe(true)
expect(semanticEquals('abc', 'abd')).toBe(false)
expect(semanticEquals('ABC', 'abc')).toBe(false)
expect(semanticEquals('', '')).toBe(true)
})
it('仅空白/换行差异的文本文件判为 semantic-same(同大小与不同大小均覆盖)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-sem-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-sem-r-'))
try {
// 不同大小:行内空白差异(tab/空格/数量)
await writeFile(join(l, 'ws.txt'), 'a\tb\nc d\n')
await writeFile(join(r, 'ws.txt'), 'a b\nc d\n')
// 不同大小:CRLF vs LF
await writeFile(join(l, 'crlf.txt'), 'line1\r\nline2\r\n')
await writeFile(join(r, 'crlf.txt'), 'line1\nline2\n')
// 排版重排(跨行重组)
await writeFile(join(l, 'reflow.txt'), 'hello world\n')
await writeFile(join(r, 'reflow.txt'), 'hello\nworld\n')
const res = await scanFolders(l, r, { semantic: SEMANTIC })
expect(byRel(res.entries, 'ws.txt')?.status).toBe('semantic-same')
expect(byRel(res.entries, 'crlf.txt')?.status).toBe('semantic-same')
expect(byRel(res.entries, 'reflow.txt')?.status).toBe('semantic-same')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('实质内容差异与大小写差异仍判 different', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-sem2-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-sem2-r-'))
try {
await writeFile(join(l, 'real.txt'), 'value = 1\n')
await writeFile(join(r, 'real.txt'), 'value = 2\n')
await writeFile(join(l, 'case.txt'), 'Config\n')
await writeFile(join(r, 'case.txt'), 'config\n')
const res = await scanFolders(l, r, { semantic: SEMANTIC })
expect(byRel(res.entries, 'real.txt')?.status).toBe('different')
expect(byRel(res.entries, 'case.txt')?.status).toBe('different')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('非文本扩展名与超大小上限的条目不参与语义判等(保持字节级 different', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-sem3-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-sem3-r-'))
try {
// 非文本扩展名(.bin 不在白名单):仅空白差异仍判 different
await writeFile(join(l, 'data.bin'), 'a b\n')
await writeFile(join(r, 'data.bin'), 'ab\n')
// 大小超上限(注入 maxBytes=4):仅空白差异仍判 different
await writeFile(join(l, 'big.txt'), 'aaaa bbbb\n')
await writeFile(join(r, 'big.txt'), 'aaaabbbb\n')
const res = await scanFolders(l, r, {
semantic: { ...SEMANTIC, maxBytes: 4 }
})
expect(byRel(res.entries, 'data.bin')?.status).toBe('different')
expect(byRel(res.entries, 'big.txt')?.status).toBe('different')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('未开启语义判等时空白差异保持字节级 different(行为与 0.6.2 一致)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-sem4-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-sem4-r-'))
try {
await writeFile(join(l, 'ws.txt'), 'a b\n')
await writeFile(join(r, 'ws.txt'), 'ab\n')
const res = await scanFolders(l, r)
expect(byRel(res.entries, 'ws.txt')?.status).toBe('different')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('字节一致的文件不受语义开关影响(仍判 same,不触发解码)', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-sem5-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-sem5-r-'))
let decoded = 0
try {
await writeFile(join(l, 'same.txt'), 'same content\n')
await writeFile(join(r, 'same.txt'), 'same content\n')
const res = await scanFolders(l, r, {
semantic: { ...SEMANTIC, decode: async (b) => { decoded++; return b.toString('utf-8') } }
})
expect(byRel(res.entries, 'same.txt')?.status).toBe('same')
expect(decoded).toBe(0)
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
it('单文件 IO 失败(模拟被锁)标记 unreadable,不影响其余条目判定', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-sem6-l-'))
const r = await mkdtemp(join(tmpdir(), 'difflens-sem6-r-'))
try {
// 正常差异文件 + 失败文件(decode 抛异常模拟读取后置失败,如 Windows 文件锁)
await writeFile(join(l, 'ok.txt'), 'a\n')
await writeFile(join(r, 'ok.txt'), 'b\n')
await writeFile(join(l, 'locked.txt'), 'x y\n')
await writeFile(join(r, 'locked.txt'), 'xy\n')
const res = await scanFolders(l, r, {
semantic: {
...SEMANTIC,
decode: async (b) => {
if (b.toString('utf-8').includes('x')) throw new Error('EBUSY')
return b.toString('utf-8')
}
}
})
expect(byRel(res.entries, 'ok.txt')?.status).toBe('different')
expect(byRel(res.entries, 'locked.txt')?.status).toBe('unreadable')
} finally {
await rm(l, { recursive: true, force: true })
await rm(r, { recursive: true, force: true })
}
})
})
describe('scanFolders - 受控并发内容比对', () => {
it('跨多个并发批次的混合状态全部判定正确且排序不变', async () => {
const l = await mkdtemp(join(tmpdir(), 'difflens-conc-l-'))