mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
287 lines
9.9 KiB
TypeScript
287 lines
9.9 KiB
TypeScript
/**
|
||
* 覆盖度诚实性:**派生库的统计绝不能替用户宣称"已经建好了"。**
|
||
*
|
||
* 这一组覆盖四条彼此独立的硬约束:
|
||
* 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,
|
||
tiers: [],
|
||
coveredToMs: 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',
|
||
arch: 'x64',
|
||
runtimeVersion: '1.2.0',
|
||
language: 'zh-Hans-CN',
|
||
message: ''
|
||
}),
|
||
// 关键:解密服务缺失是**运行时**问题,不能落成每张图的"解密失败"
|
||
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()
|
||
})
|
||
})
|