Files
WechatExplorer/tests/integration/image-text-index-incremental-repair.test.ts
T

313 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 两条**成本**契约(都在真机上被踩过):
*
* 1. 「更新图片文字索引」必须是增量的。
* 真机已经跑了 1 小时、留下 14,342 条 OCR 文本 + 31,126 条 empty 终态。
* OCR 是昂贵产物,Knowledge / FTS / binding 才是可重建派生层 ——
* 更新时只能处理 new / pending / retryable,**已有终态一条都不许重算**。
*
* 2. 修索引问题不许重跑 OCR(Derived Index Repair)。
* 只重建 L3(Knowledge 派生条目 / FTS),数据来源是已有 L1/L2;
* `ocrExecutions: 0` 是写进类型字面量的契约,不是"期望值"。
*/
import { mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
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'
const ACCOUNT = 'wxid_incremental_fixture'
const CONVERSATION = 'md5-incremental'
const roots: string[] = []
function makeRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'tm-image-incremental-'))
roots.push(root)
return root
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
/** 合法的 PNG 头(`detectSystemOcrImageFormat` 只认前 4 字节),尾部塞一个唯一序号。 */
function pngBytes(seed: number): Buffer {
return Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, seed & 0xff, (seed >> 8) & 0xff])
}
function imageMessage(localId: number): chat.FormattedMessage {
return {
id: String(localId),
localId: String(localId),
from: 'user',
type: '图片',
content: '',
isSender: false,
name: '对方',
contentData: { type: 'image', md5: `md5-${localId}`, datName: `dat-${localId}` },
createTime: 1_700_000_000 + localId
} as unknown as chat.FormattedMessage
}
/** 假的派生库预热:写入 `count` 条已完成的 OCR 记录(成功终态)。 */
function seedTerminal(store: ImageTextIndexStore, count: number, state: 'indexed' | 'empty'): void {
for (let index = 1; index <= count; index += 1) {
const artifactKey = `seeded|${index}`
store.putArtifact({
accountId: ACCOUNT,
artifactKey,
imageIdentity: `sha256:seeded-${index}`,
state,
text: state === 'indexed' ? `TRACE_SEEDED_${index}` : '',
charCount: state === 'indexed' ? 16 : 0,
engine: 'windows-system-ocr',
platform: 'win32',
runtimeVersion: '1.2.0',
language: 'zh-Hans-CN',
createdAt: index,
updatedAt: index
})
store.putBinding({
accountId: ACCOUNT,
conversationId: CONVERSATION,
messageId: `local:${index}`,
createTime: index,
imageIdentity: `sha256:seeded-${index}`,
artifactKey,
state,
updatedAt: index
})
}
}
describe('增量更新:已有终态直接复用,只对新增/未完成做 OCR', () => {
it('已有 100 条终态 + 新增 10 张 + 2 条未完成 → OCR 只跑 12 次', async () => {
const databaseRoot = makeRoot()
const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
seedTerminal(store, 100, 'indexed')
// 2 条"未完成":binding 状态不是终态 → 允许重试。
for (const id of [111, 112]) {
store.putBinding({
accountId: ACCOUNT,
conversationId: CONVERSATION,
messageId: `local:${id}`,
createTime: id,
imageIdentity: '',
artifactKey: `pending|${id}`,
state: 'pending',
updatedAt: id
})
}
// 旧 checkpoint:当时该会话只有 100 张、水位 100。
store.writeScanState({
conversationId: CONVERSATION,
state: 'done',
imageTotal: 100,
imageProcessed: 100,
maxLocalId: 100
})
store.close()
// 现在源数据变成 112 张(1..100 已有终态,101..110 全新,111..112 之前没跑完)。
const messages = Array.from({ length: 112 }, (_, index) => imageMessage(index + 1))
const recognize = vi.fn(async () => ({
success: true,
text: 'TRACE_FRESH_TEXT',
language: 'zh-Hans-CN'
}))
const decryptImage = vi.fn((path: string) => {
const seed = Number(String(path).replace(/\D/g, '')) || 0
return pngBytes(seed)
})
const service = new ImageTextIndexService()
service.bind({
databaseRoot,
resolveAccountId: () => ACCOUNT,
listContacts: async () => [
{ md5: CONVERSATION, m_nsUsrName: 'incremental', type: 'user' as const }
],
listMessages: async () => messages,
countConversationImages: async () => ({ count: 112, typeColumn: 'local_type' }),
imageWatermark: async () => ({ count: 112, maxLocalId: 112 }),
capability: async () => ({
available: true,
engine: 'windows-system-ocr',
platform: 'win32',
runtimeVersion: '1.2.0',
language: 'zh-Hans-CN'
}),
decryptService: () => ({ findImageFile: (md5) => `C:/fake/${md5}.dat`, decryptImage }) as never,
recognize
})
await service.startPass()
await vi.waitFor(() => expect(service.isRunning()).toBe(false))
// 关键断言:12 次,而不是 112 次。
expect(recognize).toHaveBeenCalledTimes(12)
// 已有终态那 100 条连解密都不该碰。
expect(decryptImage).toHaveBeenCalledTimes(12)
const after = new ImageTextIndexStore(databasePath, ACCOUNT)
// 100 条旧终态 + 100 个旧 artifact 一条不少,文本原样保留(不重算、不覆盖)。
expect(after.countByState().indexed).toBe(112)
expect(after.getArtifact('seeded|1')?.text).toBe('TRACE_SEEDED_1')
expect(after.getArtifact('seeded|100')?.text).toBe('TRACE_SEEDED_100')
after.close()
service.resetAccount()
})
it('水位完全没变 → 整个会话直接跳过,OCR 一次都不调', async () => {
const databaseRoot = makeRoot()
const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
seedTerminal(store, 20, 'empty')
store.writeScanState({
conversationId: CONVERSATION,
state: 'done',
imageTotal: 20,
imageProcessed: 20,
maxLocalId: 20
})
store.close()
const recognize = vi.fn(async () => ({ success: true, text: 'X', language: null }))
const service = new ImageTextIndexService()
service.bind({
databaseRoot,
resolveAccountId: () => ACCOUNT,
listContacts: async () => [
{ md5: CONVERSATION, m_nsUsrName: 'incremental', type: 'user' as const }
],
listMessages: async () => Array.from({ length: 20 }, (_, index) => imageMessage(index + 1)),
countConversationImages: async () => ({ count: 20, typeColumn: 'local_type' }),
imageWatermark: async () => ({ count: 20, maxLocalId: 20 }),
capability: async () => ({
available: true,
engine: 'windows-system-ocr',
platform: 'win32',
runtimeVersion: '1.2.0',
language: null
}),
decryptService: () => ({ findImageFile: () => null, decryptImage: () => null }) as never,
recognize
})
await service.startPass()
await vi.waitFor(() => expect(service.isRunning()).toBe(false))
expect(recognize).not.toHaveBeenCalled()
service.resetAccount()
})
})
describe('派生索引修复:只重建 L3,绝不重跑 OCR', () => {
it('只重建"有 OCR 文本"的会话,且 recognize 一次都不被调用', async () => {
const databaseRoot = makeRoot()
const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
seedTerminal(store, 3, 'indexed')
// 另一个会话只有 empty(没有派生文本可修)→ 不该被重建,白读一遍 WCDB。
store.putArtifact({
accountId: ACCOUNT,
artifactKey: 'empty-only|1',
imageIdentity: 'sha256:empty-only',
state: 'empty',
text: '',
charCount: 0,
engine: 'windows-system-ocr',
platform: 'win32',
runtimeVersion: '1.2.0',
language: null,
createdAt: 1,
updatedAt: 1
})
store.putBinding({
accountId: ACCOUNT,
conversationId: 'md5-empty-only',
messageId: 'local:1',
createTime: 1,
imageIdentity: 'sha256:empty-only',
artifactKey: 'empty-only|1',
state: 'empty',
updatedAt: 1
})
store.close()
const recognize = vi.fn(async () => ({ success: true, text: 'X', language: null }))
const onConversationIndexed = vi.fn(async () => undefined)
const service = new ImageTextIndexService()
service.bind({
databaseRoot,
resolveAccountId: () => ACCOUNT,
recognize,
onConversationIndexed
})
const result = await service.repairKnowledgeIndex()
expect(result.skipped).toBe(false)
expect(result.conversations).toBe(1)
// 契约:修复路径的定义就是"不调 OCR"。类型上写死成字面量 0。
expect(result.ocrExecutions).toBe(0)
expect(recognize).not.toHaveBeenCalled()
expect(onConversationIndexed).toHaveBeenCalledTimes(1)
expect(onConversationIndexed).toHaveBeenCalledWith(CONVERSATION)
service.resetAccount()
})
it('索引任务正在跑时拒绝并发修复(避免读到半程 binding)', async () => {
const databaseRoot = makeRoot()
const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
seedTerminal(store, 1, 'indexed')
store.close()
let release: (() => void) | null = null
const gate = new Promise<void>((resolve) => {
release = resolve
})
const service = new ImageTextIndexService()
service.bind({
databaseRoot,
resolveAccountId: () => ACCOUNT,
listContacts: async () => [
{ md5: CONVERSATION, m_nsUsrName: 'incremental', type: 'user' as const }
],
listMessages: async () => [imageMessage(1)],
countConversationImages: async () => ({ count: 1, typeColumn: 'local_type' }),
imageWatermark: async () => ({ count: 1, maxLocalId: 1 }),
capability: async () => ({
available: true,
engine: 'windows-system-ocr',
platform: 'win32',
runtimeVersion: '1.2.0',
language: null
}),
decryptService: () => ({ findImageFile: () => null, decryptImage: () => null }) as never,
// 卡住 pass,让 running 保持为真。
interactiveIdle: () => gate
})
await service.startPass()
await vi.waitFor(() => expect(service.isRunning()).toBe(true))
const during = await service.repairKnowledgeIndex()
expect(during.skipped).toBe(true)
expect(during.conversations).toBe(0)
release?.()
await vi.waitFor(() => expect(service.isRunning()).toBe(false))
service.resetAccount()
})
})