feat: 0.8.0 文件夹对比深度增强与字符级逐组预算制 - 按扩展名多选过滤(第四维过滤与其他条件取交集、N/总数计数切换)、目录行双击聚合对比(差异导航限定子树内批量审查、双击文件回全局、空差异提示与折叠态自愈)、增量重扫(size+mtime指纹一致零IO沿用、语义开关翻转按semSame纯内存组合零IO、unreadable不缓存与截断弃缓存、重新扫描按钮)、字符级变更组逐组预算制(超限组仅行级配对输出不再牵连其余组、charModePartial部分降级标记与报告说明、全部组降级仍整体降级)、拖拽导入E2E(dropFiles合成DataTransfer覆盖decodeBuffer IPC全链路4用例)、文档与版本号同步

This commit is contained in:
2026-08-18 22:02:37 +08:00
parent 494b2ad6ea
commit fa13160848
23 changed files with 1691 additions and 116 deletions
+207 -1
View File
@@ -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('语义开关翻转零 IOsemSame 非 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-samesemSame = 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')
// 第一次:语义关 → differentsemSame = 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.txtmtime 变化)+ 删除 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 })
}
})
})