Files
WechatExplorer/tests/integration/image-text-index-coverage-honesty.test.ts
T
Wxw-Gu 1337bcb7de feat: 新增mac ocr转文字,增加到问一问微信 图片索引优化速度
- 图片文字索引性能与进度诚实化
- 问问微信:证据卡区分「消息类型」与「派生来源」,派生命中内容自报来源
- 问问微信:回答规则禁止未真实执行的多轮承诺
- 本地图片文字识别:支持 macOS 系统 OCR(Apple Vision)
2026-09-17 13:11:20 +08:00

283 lines
9.8 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. 前置依赖缺失时必须**一条记录都不写**(否则会写出一堆假失败);
* 2. 处理过但一条都没成功 = **异常**,不是"已建立",且必须阻断 complete;
* 3. 百分比不许四舍五入到 100(99.5% 不能显示成"全部完成");
* 4. 重置失败记录**不能**动已经成功的记录。
*
* 判据来自 `ImageTextIndexStore.countByState()` 的落盘统计,不依赖任何内存计数器。
*/
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'
import {
describeImageTextCoverage,
imageTextCoverageState,
imageTextProcessedPercent,
type ImageTextIndexCoverage
} from '../../src/shared/image-text-index'
const ACCOUNT = 'wxid_coverage_fixture'
const CONVERSATION = 'md5-coverage'
const roots: string[] = []
function makeRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'tm-image-coverage-'))
roots.push(root)
return root
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
function imageMessage(localId: number): chat.FormattedMessage {
return {
localId: String(localId),
createTime: 1_700_000_000 + localId,
content: '[图片]',
contentData: { type: 'image', md5: `md5-${localId}`, datName: `dat-${localId}` }
} as unknown as chat.FormattedMessage
}
function coverageOf(overrides: Partial<ImageTextIndexCoverage>): ImageTextIndexCoverage {
return {
totalImageMessages: 0,
processed: 0,
indexed: 0,
empty: 0,
missing: 0,
failed: 0,
runtimeUnavailable: 0,
pending: 0,
established: false,
complete: false,
systemicFailure: false,
countedAt: null,
...overrides
}
}
describe('全失败不能叫"已建立"', () => {
it('indexed/empty/missing 全为 0 而 failed 不为 0 → 异常,且 complete 必为 false', () => {
const coverage = coverageOf({
totalImageMessages: 2000,
processed: 1990,
failed: 1990,
pending: 10,
established: true,
countedAt: 1_789_516_520_246,
complete: false, // 服务侧已经算出 false;这里验证状态与文案
systemicFailure: true
})
expect(imageTextCoverageState(coverage)).toBe('failed')
expect(describeImageTextCoverage(coverage)).toContain('当前异常')
expect(describeImageTextCoverage(coverage)).not.toContain('已覆盖全部')
})
it('处理好绝大多数时不能四舍五入显示成 100%', () => {
// Math.round(1990 / 2000 * 100) === 100 —— 这就是"未完成却显示 100%"的来源。
expect(Math.round((1990 / 2000) * 100)).toBe(100)
// 正确口径:保留 1 位小数,未完成时封顶 99.9。
expect(imageTextProcessedPercent(1990, 2000)).toBe(99.5)
expect(imageTextProcessedPercent(2000, 2000)).toBe(100)
expect(imageTextProcessedPercent(0, 0)).toBe(0)
})
it('服务侧:一条都没成功时不给 complete,并把状态判成 failed', async () => {
const databaseRoot = makeRoot()
const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
store.writeCountedTotal({ total: 100, countedAt: 1, complete: true })
for (let index = 1; index <= 30; index += 1) {
store.putBinding({
accountId: ACCOUNT,
conversationId: CONVERSATION,
messageId: `local:${index}`,
createTime: index,
imageIdentity: '',
artifactKey: `unavailable|${index}`,
state: 'decrypt_failed',
updatedAt: index
})
}
store.close()
const service = new ImageTextIndexService()
service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
const status = await service.getStatus()
expect(status.coverage.processed).toBe(30)
expect(status.coverage.failed).toBe(30)
expect(status.coverage.systemicFailure).toBe(true)
expect(status.coverage.complete).toBe(false)
expect(imageTextCoverageState(status.coverage)).toBe('failed')
// 收干净句柄:Windows 上没关连接会让临时目录清理 EBUSY。
service.resetAccount()
})
it('运行时不可用不计入 processed,并且阻断 complete', async () => {
const databaseRoot = makeRoot()
const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
store.writeCountedTotal({ total: 10, countedAt: 1, complete: true })
store.putBinding({
accountId: ACCOUNT,
conversationId: CONVERSATION,
messageId: 'local:1',
createTime: 1,
imageIdentity: '',
artifactKey: 'unavailable|1',
state: 'decrypt_unavailable',
updatedAt: 1
})
store.close()
const service = new ImageTextIndexService()
service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
const status = await service.getStatus()
expect(status.coverage.runtimeUnavailable).toBe(1)
expect(status.coverage.processed).toBe(0)
expect(status.coverage.complete).toBe(false)
service.resetAccount()
})
it('部分成功 + 部分图片缺失 → 仍然是正常的"部分完成"(不误判成异常)', () => {
const coverage = coverageOf({
totalImageMessages: 100,
processed: 100,
indexed: 60,
empty: 20,
missing: 20,
established: true,
countedAt: 1,
complete: true,
systemicFailure: false
})
expect(imageTextCoverageState(coverage)).toBe('complete')
expect(describeImageTextCoverage(coverage)).toContain('已覆盖全部')
})
})
describe('前置依赖缺失时一条记录都不写', () => {
it('解密服务不可用 → pass 直接报错,不写任何 binding', async () => {
const databaseRoot = makeRoot()
const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
const service = new ImageTextIndexService()
service.bind({
databaseRoot,
resolveAccountId: () => ACCOUNT,
listContacts: async () => [
{ md5: CONVERSATION, m_nsUsrName: 'coverage', type: 'group' 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: 'zh-Hans-CN'
}),
// 关键:解密服务缺失是**运行时**问题,不能落成每张图的"解密失败"
decryptService: () => null
})
await service.startPass()
await vi.waitFor(() => expect(service.isRunning()).toBe(false))
const status = await service.getStatus()
// 判据:宁可一次都不跑,也不要写一堆假失败把派生库和 coverage 一起污染。
expect(status.progress.state).toBe('error')
expect(status.progress.lastError).toContain('解密服务')
expect(status.coverage.processed).toBe(0)
expect(status.coverage.failed).toBe(0)
expect(status.coverage.runtimeUnavailable).toBe(0)
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
expect(store.countByState()).toEqual({})
store.close()
service.resetAccount()
})
})
describe('重置失败记录不能动成功记录', () => {
it('只删失败绑定与它们的 checkpoint,indexed 一条不动', async () => {
const databaseRoot = makeRoot()
const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
store.writeCountedTotal({ total: 3, countedAt: 1, complete: true })
store.putArtifact({
accountId: ACCOUNT,
artifactKey: 'good|1',
imageIdentity: 'sha256:good',
state: 'indexed',
text: 'TRACE_KEEP_ME',
charCount: 13,
engine: 'windows-system-ocr',
platform: 'win32',
runtimeVersion: '1.2.0',
language: 'zh-Hans-CN',
createdAt: 1,
updatedAt: 1
})
store.putBinding({
accountId: ACCOUNT,
conversationId: CONVERSATION,
messageId: 'local:1',
createTime: 1,
imageIdentity: 'sha256:good',
artifactKey: 'good|1',
state: 'indexed',
updatedAt: 1
})
for (const id of [2, 3]) {
store.putBinding({
accountId: ACCOUNT,
conversationId: CONVERSATION,
messageId: `local:${id}`,
createTime: id,
imageIdentity: '',
artifactKey: `bad|${id}`,
state: 'decrypt_failed',
updatedAt: id
})
}
store.writeScanState({
conversationId: CONVERSATION,
state: 'done',
imageTotal: 3,
imageProcessed: 3,
maxLocalId: 3
})
store.close()
const service = new ImageTextIndexService()
service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
const result = await service.resetRetriableFailures()
expect(result.reset).toBe(2)
// 成功记录与它的 artifact 必须原封不动。
const after = new ImageTextIndexStore(databasePath, ACCOUNT)
expect(after.countByState()).toEqual({ indexed: 1 })
expect(after.getArtifact('good|1')?.text).toBe('TRACE_KEEP_ME')
// checkpoint 也要清掉,否则下一轮会以"该会话已完成"直接跳过(点了重试却没反应)。
expect(after.readScanState().size).toBe(0)
after.close()
service.resetAccount()
})
})