feat: 0.8.0 文件夹对比深度增强与字符级逐组预算制 - 按扩展名多选过滤(第四维过滤与其他条件取交集、N/总数计数切换)、目录行双击聚合对比(差异导航限定子树内批量审查、双击文件回全局、空差异提示与折叠态自愈)、增量重扫(size+mtime指纹一致零IO沿用、语义开关翻转按semSame纯内存组合零IO、unreadable不缓存与截断弃缓存、重新扫描按钮)、字符级变更组逐组预算制(超限组仅行级配对输出不再牵连其余组、charModePartial部分降级标记与报告说明、全部组降级仍整体降级)、拖拽导入E2E(dropFiles合成DataTransfer覆盖decodeBuffer IPC全链路4用例)、文档与版本号同步
This commit is contained in:
+207
-1
@@ -2,7 +2,12 @@ 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, semanticEquals, SEMANTIC_MAX_BYTES, type FolderEntry } from './folderScan'
|
||||
import {
|
||||
scanFolders,
|
||||
semanticEquals,
|
||||
SEMANTIC_MAX_BYTES,
|
||||
type FolderEntry
|
||||
} from './folderScan'
|
||||
|
||||
/** 以临时目录为根构造两个待对比文件夹 */
|
||||
let leftDir: string
|
||||
@@ -375,3 +380,204 @@ describe('scanFolders - 受控并发内容比对', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanFolders - 增量重扫(previous 缓存)', () => {
|
||||
/** 语义判等注入:同步解码 + 计数器(零 IO 断言的核心观察点) */
|
||||
const semWithCounter = (): {
|
||||
opts: {
|
||||
semantic: {
|
||||
maxBytes: number
|
||||
isTextFile: (rel: string) => boolean
|
||||
decode: (b: Buffer) => Promise<string>
|
||||
}
|
||||
}
|
||||
count: () => number
|
||||
} => {
|
||||
let decoded = 0
|
||||
return {
|
||||
opts: {
|
||||
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 (b: Buffer): Promise<string> => {
|
||||
decoded++
|
||||
return b.toString('utf-8')
|
||||
}
|
||||
}
|
||||
},
|
||||
count: () => decoded
|
||||
}
|
||||
}
|
||||
|
||||
it('指纹一致零 IO:same 与语义同条目沿用判定,decode 计数零增长', async () => {
|
||||
const l = await mkdtemp(join(tmpdir(), 'difflens-inc1-l-'))
|
||||
const r = await mkdtemp(join(tmpdir(), 'difflens-inc1-r-'))
|
||||
try {
|
||||
// same(字节一致)+ semantic-same(仅空白差异,需解码判定)
|
||||
await writeFile(join(l, 'same.txt'), 'identical\n')
|
||||
await writeFile(join(r, 'same.txt'), 'identical\n')
|
||||
await writeFile(join(l, 'sem.txt'), 'a b\nc\n')
|
||||
await writeFile(join(r, 'sem.txt'), 'ab\nc\n')
|
||||
const sem = semWithCounter()
|
||||
const first = await scanFolders(l, r, sem.opts)
|
||||
expect(byRel(first.entries, 'same.txt')?.status).toBe('same')
|
||||
expect(byRel(first.entries, 'sem.txt')?.status).toBe('semantic-same')
|
||||
expect(sem.count()).toBe(2) // 首次:sem.txt 双侧各解码一次
|
||||
// 二次扫描携带缓存:指纹全部一致 → 全部零 IO 沿用
|
||||
const second = await scanFolders(l, r, { ...sem.opts, previous: first.cache })
|
||||
expect(byRel(second.entries, 'same.txt')?.status).toBe('same')
|
||||
expect(byRel(second.entries, 'sem.txt')?.status).toBe('semantic-same')
|
||||
expect(sem.count()).toBe(2) // 零增长
|
||||
// 缓存条目被原样再次产出(链式增量可用)
|
||||
expect(second.cache.length).toBe(2)
|
||||
} finally {
|
||||
await rm(l, { recursive: true, force: true })
|
||||
await rm(r, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('语义开关翻转零 IO:semSame 非 null 条目纯内存组合 different ↔ semantic-same', async () => {
|
||||
const l = await mkdtemp(join(tmpdir(), 'difflens-inc2-l-'))
|
||||
const r = await mkdtemp(join(tmpdir(), 'difflens-inc2-r-'))
|
||||
try {
|
||||
await writeFile(join(l, 'sem.txt'), 'a b\nc\n')
|
||||
await writeFile(join(r, 'sem.txt'), 'ab\nc\n')
|
||||
// 第一次:语义开 → semantic-same(semSame = true 入缓存)
|
||||
const semOn = semWithCounter()
|
||||
const on1 = await scanFolders(l, r, semOn.opts)
|
||||
expect(byRel(on1.entries, 'sem.txt')?.status).toBe('semantic-same')
|
||||
expect(semOn.count()).toBe(2)
|
||||
// 第二次:语义关 + 缓存 → different(字节不同),零 IO
|
||||
const off = await scanFolders(l, r, { previous: on1.cache })
|
||||
expect(byRel(off.entries, 'sem.txt')?.status).toBe('different')
|
||||
expect(semOn.count()).toBe(2)
|
||||
// 第三次:语义再开 + 新缓存 → semantic-same,仍零 IO
|
||||
const semOn2 = semWithCounter()
|
||||
const on2 = await scanFolders(l, r, { ...semOn2.opts, previous: off.cache })
|
||||
expect(byRel(on2.entries, 'sem.txt')?.status).toBe('semantic-same')
|
||||
expect(semOn2.count()).toBe(0)
|
||||
} finally {
|
||||
await rm(l, { recursive: true, force: true })
|
||||
await rm(r, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('上次未参与语义(semSame null)而本次开启:读内容补判', async () => {
|
||||
const l = await mkdtemp(join(tmpdir(), 'difflens-inc3-l-'))
|
||||
const r = await mkdtemp(join(tmpdir(), 'difflens-inc3-r-'))
|
||||
try {
|
||||
await writeFile(join(l, 'sem.txt'), 'a b\n')
|
||||
await writeFile(join(r, 'sem.txt'), 'ab\n')
|
||||
// 第一次:语义关 → different(semSame = null)
|
||||
const off = await scanFolders(l, r)
|
||||
expect(byRel(off.entries, 'sem.txt')?.status).toBe('different')
|
||||
// 第二次:语义开 + 缓存 → sem.txt 需补判(decode 2 次)
|
||||
const sem = semWithCounter()
|
||||
const on = await scanFolders(l, r, { ...sem.opts, previous: off.cache })
|
||||
expect(byRel(on.entries, 'sem.txt')?.status).toBe('semantic-same')
|
||||
expect(sem.count()).toBe(2)
|
||||
} finally {
|
||||
await rm(l, { recursive: true, force: true })
|
||||
await rm(r, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('指纹变化(内容修改)重新判定,文件增删正确处理', async () => {
|
||||
const l = await mkdtemp(join(tmpdir(), 'difflens-inc4-l-'))
|
||||
const r = await mkdtemp(join(tmpdir(), 'difflens-inc4-r-'))
|
||||
try {
|
||||
await writeFile(join(l, 'a.txt'), 'v1\n')
|
||||
await writeFile(join(r, 'a.txt'), 'v1\n')
|
||||
await writeFile(join(l, 'gone.txt'), 'x\n')
|
||||
const first = await scanFolders(l, r)
|
||||
expect(byRel(first.entries, 'a.txt')?.status).toBe('same')
|
||||
expect(byRel(first.entries, 'gone.txt')?.status).toBe('left-only')
|
||||
// 修改右侧 a.txt(mtime 变化)+ 删除 gone.txt + 新增 new.txt
|
||||
await writeFile(join(r, 'a.txt'), 'v2 changed\n')
|
||||
await rm(join(l, 'gone.txt'))
|
||||
await writeFile(join(r, 'new.txt'), 'n\n')
|
||||
const second = await scanFolders(l, r, { previous: first.cache })
|
||||
expect(byRel(second.entries, 'a.txt')?.status).toBe('different')
|
||||
expect(second.entries.some((e) => e.rel === 'gone.txt')).toBe(false)
|
||||
expect(byRel(second.entries, 'new.txt')?.status).toBe('right-only')
|
||||
} finally {
|
||||
await rm(l, { recursive: true, force: true })
|
||||
await rm(r, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('mtime 相同但指纹形状变化(单侧缺失变化)不误沿用', async () => {
|
||||
const l = await mkdtemp(join(tmpdir(), 'difflens-inc5-l-'))
|
||||
const r = await mkdtemp(join(tmpdir(), 'difflens-inc5-r-'))
|
||||
try {
|
||||
await writeFile(join(l, 'a.txt'), 'v1\n')
|
||||
await writeFile(join(r, 'a.txt'), 'v1\n')
|
||||
const first = await scanFolders(l, r)
|
||||
expect(byRel(first.entries, 'a.txt')?.status).toBe('same')
|
||||
// 删除右侧文件:same → left-only(缓存形状不一致,必须重判)
|
||||
await rm(join(r, 'a.txt'))
|
||||
const second = await scanFolders(l, r, { previous: first.cache })
|
||||
expect(byRel(second.entries, 'a.txt')?.status).toBe('left-only')
|
||||
} finally {
|
||||
await rm(l, { recursive: true, force: true })
|
||||
await rm(r, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('本次截断忽略 previous 且 cache 产出为空(条目不完整不可信)', async () => {
|
||||
const l = await mkdtemp(join(tmpdir(), 'difflens-inc6-l-'))
|
||||
const r = await mkdtemp(join(tmpdir(), 'difflens-inc6-r-'))
|
||||
try {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await writeFile(join(l, `f${i}.txt`), String(i))
|
||||
await writeFile(join(r, `f${i}.txt`), String(i))
|
||||
}
|
||||
const first = await scanFolders(l, r)
|
||||
expect(first.cache.length).toBe(5)
|
||||
// 截断扫描:previous 被忽略(等同全量),cache 为空
|
||||
const second = await scanFolders(l, r, { maxFiles: 2, previous: first.cache })
|
||||
expect(second.truncated).toBe(true)
|
||||
expect(second.cache).toEqual([])
|
||||
} finally {
|
||||
await rm(l, { recursive: true, force: true })
|
||||
await rm(r, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('unreadable 不进缓存:故障恢复后重扫得到正确判定', async () => {
|
||||
const l = await mkdtemp(join(tmpdir(), 'difflens-inc7-l-'))
|
||||
const r = await mkdtemp(join(tmpdir(), 'difflens-inc7-r-'))
|
||||
try {
|
||||
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')
|
||||
// 第一次:locked.txt 解码抛异常(模拟 Windows 文件锁)→ unreadable
|
||||
const sem = semWithCounter()
|
||||
const patched = {
|
||||
semantic: {
|
||||
...sem.opts.semantic,
|
||||
decode: async (b: Buffer): Promise<string> => {
|
||||
if (b.toString('utf-8').includes('x')) throw new Error('EBUSY')
|
||||
return b.toString('utf-8')
|
||||
}
|
||||
}
|
||||
}
|
||||
const first = await scanFolders(l, r, patched)
|
||||
expect(byRel(first.entries, 'ok.txt')?.status).toBe('different')
|
||||
expect(byRel(first.entries, 'locked.txt')?.status).toBe('unreadable')
|
||||
// unreadable 不进缓存(cache 仅 1 条 ok.txt)
|
||||
expect(first.cache.map((c) => c.rel)).toEqual(['ok.txt'])
|
||||
// 第二次:故障恢复(正常 decode)+ 缓存 → locked.txt 补判 semantic-same
|
||||
const second = await scanFolders(l, r, { ...sem.opts, previous: first.cache })
|
||||
expect(byRel(second.entries, 'ok.txt')?.status).toBe('different')
|
||||
expect(byRel(second.entries, 'locked.txt')?.status).toBe('semantic-same')
|
||||
} finally {
|
||||
await rm(l, { recursive: true, force: true })
|
||||
await rm(r, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user