mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
/**
|
||
* 两条**成本**契约(都在真机上被踩过):
|
||
*
|
||
* 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()
|
||
})
|
||
})
|