feat: 0.7.0 文件夹对比增强(语义判等/子目录树形/搜索与状态过滤/报告导出/差异文件导航/大目录单点失败降级)+ 字符级分块锚定恢复(0.6.3 算法)与滚动回弹根因修复(重算期间保留旧结果撑住容器高度)+ E2E 滚动稳定性回归锚
This commit is contained in:
+147
-3
@@ -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-'))
|
||||
|
||||
Reference in New Issue
Block a user