Files
WechatExplorer/tests/integration/image-text-index-checkpoint-and-coverage.test.ts
T
2026-09-20 15:56:27 +08:00

510 lines
19 KiB
TypeScript

/**
* 「图片文字索引」的 checkpoint(增量水位)与覆盖度契约。
*
* 这里覆盖的都是**不能用 UI 数字糊过去**的硬约束:
* - 覆盖度必须在重启后依然诚实(派生库只知道处理过什么,不知道源数据一共多少);
* - 增量判据必须能发现「总数没变但集合变了」;
* - 清理必须真的把文件删掉,删不掉要如实上报;
* - 涉及图片的问题在索引未完成时,答案语义里必须带"不能因为没搜到就说没有"。
*/
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as chat from '../../src/main/services/chat-service'
import { ImageTextIndexService } from '../../src/main/services/image-text-index-service'
import {
ImageTextIndexStore,
getImageTextIndexDatabasePath
} from '../../src/main/services/image-text-index-store'
import { buildImageOcrCoverage } from '../../src/main/services/local-query-api-service'
import type { ImageTextIndexCoverage } from '../../src/shared/image-text-index'
import { sourceMessageId } from '../../src/main/knowledge/message-identity'
const ACCOUNT = 'wxid_fixture_account'
const CONVERSATION = 'conversation-md5-fixture'
const roots: string[] = []
function makeDatabaseRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'tm-image-text-index-'))
roots.push(root)
return root
}
afterEach(() => {
for (const root of roots.splice(0)) {
try {
rmSync(root, { recursive: true, force: true })
} catch {
// 测试收尾尽力而为。
}
}
})
/** 只带图片索引需要的字段;其余字段与本测试无关。 */
function imageMessage(localId: number, createTime: number): chat.FormattedMessage {
return {
localId: String(localId),
createTime,
content: '[图片]',
contentData: { type: 'image', md5: `md5-${localId}`, datName: `dat-${localId}` }
} as unknown as chat.FormattedMessage
}
type Harness = {
service: ImageTextIndexService
databaseRoot: string
listMessages: ReturnType<typeof vi.fn>
watermark: { count: number; maxLocalId: number }
databasePath: string
}
function makeHarness(options: { messages?: chat.FormattedMessage[] } = {}): Harness {
const databaseRoot = makeDatabaseRoot()
const listMessages = vi.fn(async () => options.messages ?? [])
const watermark = { count: 0, maxLocalId: 0 }
const service = new ImageTextIndexService()
service.bind({
databaseRoot,
resolveAccountId: () => ACCOUNT,
resolveAccountRoot: () => 'C:/fixture/account',
listContacts: async () => [
{ md5: CONVERSATION, m_nsUsrName: 'fixture', type: 'group' as const }
],
listMessages,
countConversationImages: async () => ({ count: watermark.count, typeColumn: 'local_type' }),
imageWatermark: async () => ({ ...watermark }),
// 没有解密服务 → 每张图片都会被判成 image_missing。这样测试完全不碰真实图片。
decryptService: () => ({ findImageFile: () => null, decryptImage: () => null }) as never,
capability: async () => ({
available: true,
engine: 'windows-system-ocr',
platform: 'win32',
arch: 'x64',
runtimeVersion: '1.2.0',
language: 'zh-Hans-CN',
message: ''
}),
recognize: async () => ({ success: true, text: '', language: 'zh-Hans-CN' })
})
return {
service,
databaseRoot,
listMessages,
watermark,
databasePath: getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
}
}
/**
* 增量判据。
*
* recent-first 之后,"什么时候读 WCDB"由**分段完成状态 + 增量水位**共同决定,
* 但两条硬约束一个字都没变:
* 1. 没有新内容时**不得**重复读会话消息、更不得重复 OCR;
* 2. 有新内容(含"总数不变但集合变了")时**必须**处理到 —— 宁可慢,也不漏。
*
* 这里的夹具刻意做成**窗口感知**的:新架构的"这段没有图片就整段跳过"完全依赖
* 计数说实话;忽略窗口的夹具测出来的只是"夹具不过滤",不是调度器的行为。
*/
describe('增量判据:水位不得漏掉新图片', () => {
function makeIncrementalHarness(options: {
messages: chat.FormattedMessage[]
withWatermark?: boolean
}): {
service: ImageTextIndexService
databasePath: string
state: { messages: chat.FormattedMessage[]; count: number; maxLocalId: number; now: number }
listMessages: ReturnType<typeof vi.fn>
listImageMessages: ReturnType<typeof vi.fn>
} {
const databaseRoot = makeDatabaseRoot()
const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
const state = {
messages: [...options.messages],
count: options.messages.length,
maxLocalId: options.messages.reduce((max, m) => Math.max(max, Number(m.localId) || 0), 0),
now: 1_800_000_000_000
}
const inWindow = (
message: chat.FormattedMessage,
window?: { sinceMs?: number; beforeMs?: number }
): boolean => {
const createTimeMs = (message.createTime || 0) * 1000
if (window?.sinceMs !== undefined && createTimeMs < window.sinceMs) return false
if (window?.beforeMs !== undefined && createTimeMs >= window.beforeMs) return false
return true
}
const listMessages = vi.fn(async () => state.messages)
const listImageMessages = vi.fn(
async (_conversationId: string, window?: { sinceMs?: number; beforeMs?: number }) =>
state.messages.filter((message) => inWindow(message, window))
)
const service = new ImageTextIndexService()
service.bind({
databaseRoot,
resolveAccountId: () => ACCOUNT,
resolveAccountRoot: () => 'C:/fixture/account',
now: () => state.now,
listContacts: async () => [
{ md5: CONVERSATION, m_nsUsrName: 'fixture', type: 'group' as const }
],
listMessages,
listImageMessages,
countConversationImages: async (
_conversationId: string,
range?: number | { sinceMs?: number; beforeMs?: number }
) => ({
count: state.messages.filter((message) =>
inWindow(message, typeof range === 'number' ? undefined : range)
).length,
typeColumn: 'local_type'
}),
...(options.withWatermark === false
? {}
: { imageWatermark: async () => ({ count: state.count, maxLocalId: state.maxLocalId }) }),
// 没有解密服务 → 每张图片都会被判成 image_missing。这样测试完全不碰真实图片。
decryptService: () => ({ findImageFile: () => null, decryptImage: () => null }) as never,
capability: async () => ({
available: true,
engine: 'windows-system-ocr',
platform: 'win32',
arch: 'x64',
runtimeVersion: '1.2.0',
language: 'zh-Hans-CN',
message: ''
}),
recognize: async () => ({ success: true, text: '', language: 'zh-Hans-CN' })
})
return { service, databasePath, state, listMessages, listImageMessages }
}
/** 直接读派生库的绑定,回答"到底处理了哪几条",而不是只看调用次数。 */
const bindingIds = (databasePath: string): string[] => {
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
const ids = [...store.getConversationOcr(CONVERSATION).keys()]
store.close()
return ids
}
it('全部完成之后:第二遍不再读会话消息,也不重复 OCR', async () => {
const harness = makeIncrementalHarness({
messages: [imageMessage(10, 1000), imageMessage(20, 2000)]
})
await harness.service.startPass()
await vi.waitFor(() => expect(harness.service.isRunning()).toBe(false))
// 两条图片的 create_time 都很老 → 只落在归档段,只被那一段读到。
expect(harness.listImageMessages).toHaveBeenCalledTimes(1)
const firstIds = bindingIds(harness.databasePath)
expect(firstIds).toHaveLength(2)
// 第二遍:水位完全一致 + 各分段已完成 → 一个字都不再读。
await harness.service.startPass()
await vi.waitFor(() => expect(harness.service.isRunning()).toBe(false))
expect(harness.listImageMessages).toHaveBeenCalledTimes(1)
expect(bindingIds(harness.databasePath)).toEqual(firstIds)
})
it('总数相同但最大插入序前进 → 必须处理到新图片(即使是旧时间)', async () => {
const harness = makeIncrementalHarness({
messages: [imageMessage(10, 1000), imageMessage(20, 2000)]
})
await harness.service.startPass()
await vi.waitFor(() => expect(harness.service.isRunning()).toBe(false))
expect(bindingIds(harness.databasePath)).toHaveLength(2)
// 集合变了、条数没变:localId 10 被撤回,新增 localId 30 —— 而且它带着**旧时间**,
// 只按时间窗读的实现在这里就会漏掉它。
harness.state.messages = [imageMessage(20, 2000), imageMessage(30, 2000)]
harness.state.count = 2
harness.state.maxLocalId = 30
harness.state.now += 5_000
await harness.service.startPass()
await vi.waitFor(() => expect(harness.service.isRunning()).toBe(false))
// 只看 count 的实现会在这里静默跳过 —— 那正是会漏掉新图片的洞。
const ids = bindingIds(harness.databasePath)
expect(ids).toHaveLength(3)
expect(ids).toContain(sourceMessageId(imageMessage(30, 2000)))
})
it('水位不可用(数据库不支持该聚合)时按时间窗兜底重扫,宁可慢也不漏', async () => {
const harness = makeIncrementalHarness({
messages: [imageMessage(10, 1000)],
withWatermark: false
})
const anchorSeconds = Math.floor(harness.state.now / 1000)
await harness.service.startPass()
await vi.waitFor(() => expect(harness.service.isRunning()).toBe(false))
expect(bindingIds(harness.databasePath)).toHaveLength(1)
// 锚点之后新到一张图;并且时间确实往前走了一段(否则"窗口必然为空"的短路会生效,
// 那不是漏,而是正确地判定"还没有新东西")。
harness.state.messages = [imageMessage(10, 1000), imageMessage(40, anchorSeconds + 10)]
harness.state.count = 2
harness.state.maxLocalId = 40
harness.state.now += 20_000
await harness.service.startPass()
await vi.waitFor(() => expect(harness.service.isRunning()).toBe(false))
const ids = bindingIds(harness.databasePath)
expect(ids).toHaveLength(2)
expect(ids).toContain(sourceMessageId(imageMessage(40, anchorSeconds + 10)))
})
})
describe('覆盖度诚实性', () => {
it('重启后仍是 partial:分母来自落盘统计,不会退化成 processed', async () => {
const { databaseRoot, databasePath } = makeHarness()
// 先按「已建立过索引」写库:总数 100,实际只处理了 30 条。
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
store.writeCountedTotal({ total: 100, countedAt: 1_700_000_000_000, complete: true })
for (let index = 0; index < 30; index += 1) {
store.putBinding({
accountId: ACCOUNT,
conversationId: CONVERSATION,
messageId: `local:${index}`,
createTime: index,
imageIdentity: `sha256:${index}`,
artifactKey: `sha256:${index}|fake`,
state: 'indexed',
updatedAt: index
})
}
store.close()
// 全新 service 实例 = 模拟应用重启(内存计数器归零)。
const service = new ImageTextIndexService()
service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
const status = await service.getStatus()
expect(status.coverage.totalImageMessages).toBe(100)
expect(status.coverage.processed).toBe(30)
expect(status.coverage.established).toBe(true)
// 修复前这里会因为 total 退化成 processed 而变成 true(把 30% 谎报成 100%)。
expect(status.coverage.complete).toBe(false)
expect(status.coverage.countedAt).toBe(1_700_000_000_000)
})
it('统计时有会话没数上 → 分母不完整,不允许声称 complete', async () => {
const { databaseRoot, databasePath } = makeHarness()
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
store.writeCountedTotal({ total: 10, countedAt: 1, complete: false })
store.putBinding({
accountId: ACCOUNT,
conversationId: CONVERSATION,
messageId: 'local:1',
createTime: 1,
imageIdentity: 'sha256:1',
artifactKey: 'sha256:1|fake',
state: 'indexed',
updatedAt: 1
})
store.close()
const service = new ImageTextIndexService()
service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
const status = await service.getStatus()
expect(status.coverage.processed).toBe(1)
expect(status.coverage.complete).toBe(false)
})
it('从未统计过 → 不算已建立,且查询路径不为看覆盖度凭空建库', async () => {
const { databaseRoot, databasePath } = makeHarness()
const service = new ImageTextIndexService()
service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
expect(service.getCoverageSnapshot()).toBeNull()
expect(existsSync(databasePath)).toBe(false)
})
})
describe('清理:删得掉才算成功', () => {
it('清理后派生库文件消失,覆盖度回到未建立', async () => {
const { service, databasePath } = makeHarness()
// 建一份有内容的派生数据(建库 + 写 artifact/binding/水位 + 落盘总数)。
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
store.writeCountedTotal({ total: 5, countedAt: 1, complete: true })
store.putArtifact({
accountId: ACCOUNT,
artifactKey: 'k',
imageIdentity: 'sha256:x',
state: 'indexed',
text: 'fixture',
charCount: 7,
engine: 'windows-system-ocr',
platform: 'win32',
runtimeVersion: '1.2.0',
language: 'zh-Hans-CN',
createdAt: 1,
updatedAt: 1
})
store.close()
expect(existsSync(databasePath)).toBe(true)
const result = await service.clear()
expect(result.removed).toBe(true)
expect(existsSync(databasePath)).toBe(false)
const status = await service.getStatus()
expect(status.coverage.established).toBe(false)
expect(status.coverage.totalImageMessages).toBe(0)
expect(status.coverage.countedAt).toBeNull()
})
})
describe('查询层:覆盖度必须是独立维度且带零结果诚实性', () => {
const coverageOf = (input: Partial<ImageTextIndexCoverage>): ImageTextIndexCoverage => ({
totalImageMessages: 0,
processed: 0,
indexed: 0,
empty: 0,
missing: 0,
failed: 0,
runtimeUnavailable: 0,
pending: 0,
established: false,
complete: false,
systemicFailure: false,
countedAt: null,
tiers: [],
coveredToMs: null,
...input
})
it('partial:必须明确「不能因为没搜到就回答没有」并给出真实比例', () => {
const built = buildImageOcrCoverage(
coverageOf({
totalImageMessages: 100,
processed: 30,
indexed: 28,
empty: 2,
established: true,
countedAt: 1_700_000_000_000
})
)
expect(built?.state).toBe('partial')
expect(built?.totalImageMessages).toBe(100)
expect(built?.processed).toBe(30)
expect(built?.summary).toContain('30')
expect(built?.summary).toContain('100')
expect(built?.summary).toContain('不能因为没搜到就回答')
})
it('complete:不附加零结果约束,但仍带上统计时刻', () => {
const built = buildImageOcrCoverage(
coverageOf({
totalImageMessages: 100,
processed: 100,
indexed: 90,
empty: 10,
established: true,
complete: true,
countedAt: 1_700_000_000_000
})
)
expect(built?.state).toBe('complete')
expect(built?.summary).not.toContain('不能因为没搜到就回答')
})
it('not_built:说明图片里的文字目前搜不到,且同样禁止凭零结果下"没有"', () => {
const built = buildImageOcrCoverage(coverageOf({}))
expect(built?.state).toBe('not_built')
expect(built?.summary).toContain('尚未建立')
expect(built?.summary).toContain('不能因为没搜到就回答')
})
it('没有覆盖度(库都不存在)时不下发该字段,不制造假维度', () => {
expect(buildImageOcrCoverage(null)).toBeUndefined()
})
})
describe('图片数量统计:必须区分「0 张」与「统计失败」', () => {
function bindCounting(
service: ImageTextIndexService,
databaseRoot: string,
probe: () => Promise<{ count: number | null; typeColumn: string | null; error?: string }>
): void {
service.bind({
databaseRoot,
resolveAccountId: () => ACCOUNT,
listContacts: async () => [
{ md5: 'conv-a', m_nsUsrName: 'a', type: 'group' as const },
{ md5: 'conv-b', m_nsUsrName: 'b', type: 'group' as const }
],
countConversationImages: probe
})
}
it('真的 0 张:scanned=2 / failed=0,可以放心说 0', async () => {
const service = new ImageTextIndexService()
bindCounting(service, makeDatabaseRoot(), async () => ({
count: 0,
typeColumn: 'local_type'
}))
const result = await service.countImageMessages()
expect(result.totalImageMessages).toBe(0)
expect(result.scannedConversations).toBe(2)
expect(result.failedConversations).toBe(0)
expect(result.typeColumn).toBe('local_type')
expect(result.error).toBeUndefined()
})
it('统计全部失败:不得表现为 0 张,且必须给出原因', async () => {
const service = new ImageTextIndexService()
bindCounting(service, makeDatabaseRoot(), async () => ({
count: null,
typeColumn: null,
error: '读取消息分片失败'
}))
const result = await service.countImageMessages()
expect(result.totalImageMessages).toBe(0)
// 关键区分:一个会话都没数成。
expect(result.scannedConversations).toBe(0)
expect(result.failedConversations).toBe(2)
expect(result.error).toBe('读取消息分片失败')
// 统计失败 → 分母不成立 → 不允许声称已建立覆盖。
const status = await service.getStatus()
expect(status.coverage.established).toBe(false)
expect(status.coverage.complete).toBe(false)
expect(status.coverage.countedAt).not.toBeNull()
})
it('部分失败:总数偏小,coverage 不允许声称 complete', async () => {
const service = new ImageTextIndexService()
let call = 0
bindCounting(service, makeDatabaseRoot(), async () => {
call += 1
return call === 1
? { count: 10, typeColumn: 'local_type' }
: { count: null, typeColumn: null, error: '图片消息统计查询失败' }
})
const result = await service.countImageMessages()
expect(result.totalImageMessages).toBe(10)
expect(result.scannedConversations).toBe(1)
expect(result.failedConversations).toBe(1)
const status = await service.getStatus()
expect(status.coverage.totalImageMessages).toBe(10)
expect(status.coverage.complete).toBe(false)
})
it('探测到的类型列名会向上透出(列名不一致时是唯一线索)', async () => {
const service = new ImageTextIndexService()
bindCounting(service, makeDatabaseRoot(), async () => ({
count: 3,
typeColumn: 'msg_type'
}))
const result = await service.countImageMessages()
expect(result.typeColumn).toBe('msg_type')
})
})