mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: 新增微信图片文字索引与问问微信图片检索能力
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
buildSearchCacheKey
|
||||
} from '../../src/renderer/src/components/search/searchUtils'
|
||||
import { makePipelineEvidence, makeSearchResult } from './support/ai-search-fixtures'
|
||||
import { makeImageTextIndexApi } from './support/image-text-index-api'
|
||||
|
||||
const api = {
|
||||
getSettings: vi.fn(),
|
||||
@@ -17,7 +18,9 @@ const api = {
|
||||
getAiSearchProviderStatus: vi.fn(),
|
||||
authorizeAiSearchExternalProvider: vi.fn(),
|
||||
runAiSearch: vi.fn(),
|
||||
cancelAiSearch: vi.fn()
|
||||
cancelAiSearch: vi.fn(),
|
||||
// 侧栏新增的「图片文字索引」卡片会读这些桥接。
|
||||
...makeImageTextIndexApi()
|
||||
}
|
||||
|
||||
describe('AISearchWorkspace cache privacy boundary', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AISearchWorkspace } from '../../src/renderer/src/components/search/AISearchWorkspace'
|
||||
import { aiSearchContact, aiSearchGroup, makeSearchResult } from './support/ai-search-fixtures'
|
||||
import { makeImageTextIndexApi } from './support/image-text-index-api'
|
||||
import type { AskWechatQueryResult, AskWechatStats, QueryAgentProgressEvent } from '../../src/shared/query-agent'
|
||||
|
||||
type AnsweredResult = Extract<AskWechatQueryResult, { status: 'answered' }>
|
||||
@@ -25,7 +26,9 @@ const api = {
|
||||
runAskWechatQuery: vi.fn(),
|
||||
forgetAskWechatConversation: vi.fn(),
|
||||
onAskWechatProgress: vi.fn(),
|
||||
cancelKnowledgeIndex: vi.fn()
|
||||
cancelKnowledgeIndex: vi.fn(),
|
||||
// 侧栏新增的「图片文字索引」卡片会读这些桥接。
|
||||
...makeImageTextIndexApi()
|
||||
}
|
||||
|
||||
const indexLatestAt = new Date('2026-09-11T11:57:24+08:00').getTime()
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
makePipelineEvidence,
|
||||
makeSearchResult
|
||||
} from './support/ai-search-fixtures'
|
||||
import { makeImageTextIndexApi } from './support/image-text-index-api'
|
||||
|
||||
const api = {
|
||||
getSettings: vi.fn(),
|
||||
@@ -28,7 +29,9 @@ const api = {
|
||||
startKnowledgeIndex: vi.fn(),
|
||||
writeAppLog: vi.fn(),
|
||||
revealAppLog: vi.fn(),
|
||||
copyText: vi.fn()
|
||||
copyText: vi.fn(),
|
||||
// 侧栏新增的「图片文字索引」卡片会读这些桥接;漏掉任何一个都会让卡片挂载即抛错。
|
||||
...makeImageTextIndexApi()
|
||||
}
|
||||
|
||||
const readyKnowledgeStatus = {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* §1 / §17:Evidence 的「图片文字」来源语义。
|
||||
*
|
||||
* 三条不能退让的约束:
|
||||
* 1. 来自图片 OCR 的命中,UI 必须有轻量来源标记(「图片文字」),
|
||||
* 让用户知道这段内容来自图片,而不是群友真的发了一条文字消息;
|
||||
* 2. authoritative source 仍然是**原始图片消息** —— messageRef 不变,跳转目标就是原图;
|
||||
* 3. 引擎内部前缀(`图片文字:` / `OCR:` / `system-ocr`)绝不允许出现在用户可见文本里;
|
||||
* 4. 普通文字消息的 Evidence 完全不受影响(不该凭空多出一个标记)。
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AISearchEvidencePanel } from '../../src/renderer/src/components/search/AISearchEvidencePanel'
|
||||
import { mapAskWechatEvidence } from '../../src/renderer/src/components/search/askWechatPresentation'
|
||||
import type { EvidenceItem } from '../../src/renderer/src/components/search/searchTypes'
|
||||
import type { AskWechatEvidenceItem } from '../../src/shared/query-agent'
|
||||
import { encodeMessageRef } from '../../src/shared/local-query-api'
|
||||
|
||||
const OCR_TEXT = 'OpenAI ChatGPT Plus $20 Pro $200'
|
||||
const IMAGE_REF = encodeMessageRef('md5-tech-group', '9001')
|
||||
const TEXT_REF = encodeMessageRef('md5-tech-group', '9002')
|
||||
|
||||
/** Query Agent 交给渲染层的证据(图片 OCR 命中)。 */
|
||||
const imageOcrEvidence: AskWechatEvidenceItem = {
|
||||
messageRef: IMAGE_REF,
|
||||
conversationName: '技术交流群',
|
||||
conversationType: 'group',
|
||||
sender: '张三',
|
||||
timestamp: Date.parse('2026-09-03T14:32:00+08:00'),
|
||||
messageType: 'image',
|
||||
// 已经由 main 侧剥掉内部前缀的可读文本
|
||||
text: OCR_TEXT,
|
||||
derivedSource: 'image_ocr',
|
||||
imageOcrText: OCR_TEXT,
|
||||
source: 'search_messages'
|
||||
}
|
||||
|
||||
/** 普通文字消息证据(对照组)。 */
|
||||
const plainEvidence: AskWechatEvidenceItem = {
|
||||
messageRef: TEXT_REF,
|
||||
conversationName: '技术交流群',
|
||||
conversationType: 'group',
|
||||
sender: '张三',
|
||||
timestamp: Date.parse('2026-09-03T14:30:00+08:00'),
|
||||
messageType: 'text',
|
||||
text: '今天正常讨论一下 API',
|
||||
source: 'search_messages'
|
||||
}
|
||||
|
||||
function renderPanel(evidence: EvidenceItem[]) {
|
||||
const props: React.ComponentProps<typeof AISearchEvidencePanel> = {
|
||||
evidence,
|
||||
collectionCount: evidence.length,
|
||||
selectedEvidence: 0,
|
||||
evidenceFlash: { index: -1, nonce: 0 },
|
||||
senderNames: {},
|
||||
hasMoreEvidence: false,
|
||||
onFocusEvidence: vi.fn(),
|
||||
onJumpToEvidence: vi.fn(),
|
||||
onLoadMoreEvidence: vi.fn(),
|
||||
setEvidenceCardRef: vi.fn()
|
||||
}
|
||||
render(<AISearchEvidencePanel {...props} />)
|
||||
return props
|
||||
}
|
||||
|
||||
describe('图片文字 Evidence 的来源语义', () => {
|
||||
it('映射层保留派生来源与 OCR 片段,且跳转目标仍是原始图片消息', () => {
|
||||
const [mapped] = mapAskWechatEvidence([imageOcrEvidence])
|
||||
|
||||
expect(mapped.derivedSource).toBe('image_ocr')
|
||||
expect(mapped.imageOcrText).toBe(OCR_TEXT)
|
||||
// authoritative source = 原始图片消息:引用不变
|
||||
expect(mapped.messageRef).toBe(IMAGE_REF)
|
||||
expect(mapped.message.id).toBe('9001')
|
||||
expect(mapped.contact.m_nsNickName).toBe('技术交流群')
|
||||
expect(mapped.sourceKind).toBe('image')
|
||||
})
|
||||
|
||||
it('图片 OCR 命中显示「图片文字」标记与命中解释,不泄露内部前缀', () => {
|
||||
const evidence = mapAskWechatEvidence([imageOcrEvidence])
|
||||
renderPanel(evidence)
|
||||
|
||||
const badge = screen.getByTestId('evidence-image-ocr-badge')
|
||||
expect(badge).toBeVisible()
|
||||
expect(badge.textContent).toBe('图片文字')
|
||||
|
||||
const snippet = screen.getByTestId('evidence-image-ocr-snippet')
|
||||
expect(snippet.textContent).toContain(OCR_TEXT)
|
||||
|
||||
// 内部前缀绝不能出现在用户可见文本里
|
||||
const panelText = document.body.textContent || ''
|
||||
expect(panelText).not.toContain('图片文字:')
|
||||
expect(panelText).not.toContain('OCR:')
|
||||
expect(panelText).not.toContain('system-ocr')
|
||||
})
|
||||
|
||||
it('普通文字消息的 Evidence 不受影响:没有来源标记,也没有 OCR 片段', () => {
|
||||
const evidence = mapAskWechatEvidence([plainEvidence])
|
||||
renderPanel(evidence)
|
||||
|
||||
expect(screen.queryByTestId('evidence-image-ocr-badge')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('evidence-image-ocr-snippet')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('今天正常讨论一下 API')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* §3 / §4:「图片文字索引」卡片的用户可见行为。
|
||||
*
|
||||
* 这些断言对应的是产品需求里**写死的**交互契约,不是实现细节:
|
||||
* - 未建立时先给出检测到的图片消息数量,而不是一个空洞的按钮;
|
||||
* - 点击建立**必须先弹确认**,不允许立刻全量开跑;
|
||||
* - 确认弹窗要写清本机执行、原图不会因识别而自动上传、可暂停、实际可识别数量取决于本地文件;
|
||||
* - 进度只给真实数字(processed/total、识别出文字、没有文字、图片已清理、失败、百分比);
|
||||
* - 暂停 / 继续 / 取消三个动作都在,且暂停后能继续;
|
||||
* - 重启后进度来自主进程快照(这里用「首帧就是 paused 快照」模拟)。
|
||||
*/
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ImageTextIndexCard } from '../../src/renderer/src/components/search/ImageTextIndexCard'
|
||||
import type { ImageTextIndexStatus } from '../../src/shared/image-text-index'
|
||||
|
||||
function status(overrides: Partial<ImageTextIndexStatus> = {}): ImageTextIndexStatus {
|
||||
return {
|
||||
progress: {
|
||||
state: 'idle',
|
||||
totalImageMessages: 0,
|
||||
processed: 0,
|
||||
indexed: 0,
|
||||
empty: 0,
|
||||
missing: 0,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
percent: 0,
|
||||
updatedAt: 0,
|
||||
cancellable: false,
|
||||
paused: false
|
||||
},
|
||||
coverage: {
|
||||
totalImageMessages: 0,
|
||||
processed: 0,
|
||||
indexed: 0,
|
||||
empty: 0,
|
||||
missing: 0,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
established: false,
|
||||
complete: false,
|
||||
countedAt: null
|
||||
},
|
||||
storage: { indexedImages: 0, ocrTextCount: 0, totalBytes: 0, updatedAt: null },
|
||||
counting: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const notBuilt = status()
|
||||
|
||||
const running = status({
|
||||
progress: {
|
||||
state: 'running',
|
||||
totalImageMessages: 12_483,
|
||||
processed: 3842,
|
||||
indexed: 2917,
|
||||
empty: 412,
|
||||
missing: 378,
|
||||
failed: 135,
|
||||
pending: 8641,
|
||||
percent: 30.8,
|
||||
updatedAt: 1,
|
||||
cancellable: true,
|
||||
paused: false
|
||||
},
|
||||
coverage: {
|
||||
totalImageMessages: 12_483,
|
||||
processed: 3842,
|
||||
indexed: 2917,
|
||||
empty: 412,
|
||||
missing: 378,
|
||||
failed: 135,
|
||||
pending: 8641,
|
||||
established: true,
|
||||
complete: false,
|
||||
countedAt: 1
|
||||
}
|
||||
})
|
||||
|
||||
const paused = status({
|
||||
...running,
|
||||
progress: { ...running.progress, state: 'paused', cancellable: false, paused: true }
|
||||
})
|
||||
|
||||
const api = {
|
||||
getImageTextIndexStatus: vi.fn(),
|
||||
countImageMessages: vi.fn(),
|
||||
startImageTextIndex: vi.fn(),
|
||||
pauseImageTextIndex: vi.fn(),
|
||||
resumeImageTextIndex: vi.fn(),
|
||||
cancelImageTextIndex: vi.fn(),
|
||||
resetImageTextIndexFailures: vi.fn(),
|
||||
repairImageTextIndex: vi.fn(),
|
||||
onImageTextIndexStatus: vi.fn(() => () => undefined)
|
||||
}
|
||||
|
||||
let pushStatus: ((next: ImageTextIndexStatus) => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
pushStatus = undefined
|
||||
api.getImageTextIndexStatus.mockResolvedValue(notBuilt)
|
||||
api.countImageMessages.mockResolvedValue({
|
||||
totalImageMessages: 12_483,
|
||||
scannedConversations: 42,
|
||||
durationMs: 30
|
||||
})
|
||||
api.startImageTextIndex.mockResolvedValue({ started: true, state: 'running' })
|
||||
api.pauseImageTextIndex.mockResolvedValue({ paused: true, state: 'paused' })
|
||||
api.resumeImageTextIndex.mockResolvedValue({ started: true, state: 'running' })
|
||||
api.cancelImageTextIndex.mockResolvedValue({ cancellable: true, cancelled: true })
|
||||
api.onImageTextIndexStatus.mockImplementation((callback: (next: ImageTextIndexStatus) => void) => {
|
||||
pushStatus = callback
|
||||
return () => undefined
|
||||
})
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: api })
|
||||
})
|
||||
|
||||
async function renderCard(): Promise<{ onNotice: ReturnType<typeof vi.fn> }> {
|
||||
const onNotice = vi.fn()
|
||||
await act(async () => {
|
||||
render(<ImageTextIndexCard dbReady onNotice={onNotice} />)
|
||||
})
|
||||
return { onNotice }
|
||||
}
|
||||
|
||||
describe('图片文字索引卡片', () => {
|
||||
it('未建立时给出检测到的图片消息数量,而不是一个空洞的按钮', async () => {
|
||||
await renderCard()
|
||||
expect(screen.getByTestId('image-text-index-state').textContent).toBe('未建立')
|
||||
expect(screen.getByTestId('image-text-index-count').textContent).toBe('12,483')
|
||||
expect(screen.getByTestId('image-text-index-start').textContent).toBe('建立图片文字索引')
|
||||
})
|
||||
|
||||
it('点击建立先弹确认,并把范围、隐私与不可控因素写清楚,确认前不启动', async () => {
|
||||
await renderCard()
|
||||
await userEvent.click(screen.getByTestId('image-text-index-start'))
|
||||
|
||||
// Radix 的 AlertDialog 用的是 role="alertdialog"(不是 "dialog")。
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
expect(dialog.textContent).toContain('12,483')
|
||||
expect(dialog.textContent).toContain('仅在本机进行识别')
|
||||
expect(dialog.textContent).toContain('不会因为本地识别而自动上传')
|
||||
expect(dialog.textContent).toContain('可以暂停并稍后继续')
|
||||
expect(dialog.textContent).toContain('取决于本地图片文件是否仍然存在')
|
||||
// 确认之前绝不允许开跑。
|
||||
expect(api.startImageTextIndex).not.toHaveBeenCalled()
|
||||
|
||||
await userEvent.click(screen.getByTestId('image-text-index-confirm'))
|
||||
await vi.waitFor(() => expect(api.startImageTextIndex).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('索引进度只给真实数字,并提供暂停与取消', async () => {
|
||||
api.getImageTextIndexStatus.mockResolvedValue(running)
|
||||
await renderCard()
|
||||
|
||||
expect(screen.getByTestId('image-text-index-state').textContent).toBe('建立中 · 30.8%')
|
||||
expect(screen.getByTestId('image-text-index-progress').textContent).toBe('3,842 / 12,483')
|
||||
const passLine = screen.getByText(/识别出文字 2,917/)
|
||||
expect(passLine.textContent).toContain('识别出文字 2,917')
|
||||
expect(passLine.textContent).toContain('没有文字 412')
|
||||
expect(passLine.textContent).toContain('图片已清理 378')
|
||||
expect(passLine.textContent).toContain('失败 135')
|
||||
// 底层实现细节绝不外泄。
|
||||
expect(document.body.textContent).not.toMatch(/HRESULT|0x[0-9A-Fa-f]{8}/)
|
||||
|
||||
await userEvent.click(screen.getByTestId('image-text-index-pause'))
|
||||
expect(api.pauseImageTextIndex).toHaveBeenCalledTimes(1)
|
||||
|
||||
await userEvent.click(screen.getByTestId('image-text-index-cancel'))
|
||||
expect(api.cancelImageTextIndex).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('暂停后可以继续,进度仍来自主进程快照', async () => {
|
||||
api.getImageTextIndexStatus.mockResolvedValue(paused)
|
||||
await renderCard()
|
||||
|
||||
expect(screen.getByTestId('image-text-index-state').textContent).toBe('已暂停 · 30.8%')
|
||||
await userEvent.click(screen.getByTestId('image-text-index-resume'))
|
||||
expect(api.resumeImageTextIndex).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('主进程推送真实进度后,卡片跟着更新(重启后恢复的进度同一条路径)', async () => {
|
||||
await renderCard()
|
||||
expect(screen.getByTestId('image-text-index-state').textContent).toBe('未建立')
|
||||
|
||||
await act(async () => {
|
||||
pushStatus?.(running)
|
||||
})
|
||||
expect(screen.getByTestId('image-text-index-state').textContent).toBe('建立中 · 30.8%')
|
||||
expect(screen.getByTestId('image-text-index-progress').textContent).toBe('3,842 / 12,483')
|
||||
})
|
||||
|
||||
it('统计失败时显示「无法统计」而不是 0,并给出原因与重新统计入口', async () => {
|
||||
api.countImageMessages.mockResolvedValue({
|
||||
totalImageMessages: 0,
|
||||
scannedConversations: 0,
|
||||
failedConversations: 7,
|
||||
typeColumn: null,
|
||||
error: '读取消息分片失败',
|
||||
durationMs: 5
|
||||
})
|
||||
await renderCard()
|
||||
|
||||
const value = screen.getByTestId('image-text-index-count')
|
||||
expect(value.textContent).toBe('无法统计')
|
||||
// 这是最关键的一条:绝不能把"数不出来"显示成 0。
|
||||
expect(value.textContent).not.toBe('0')
|
||||
|
||||
const error = screen.getByTestId('image-text-index-count-error')
|
||||
expect(error.textContent).toContain('读取消息分片失败')
|
||||
expect(error.textContent).toContain('不代表账号里没有图片')
|
||||
expect(screen.getByTestId('image-text-index-recount')).toBeVisible()
|
||||
})
|
||||
|
||||
it('部分会话统计失败时给出真实数字并提示偏小', async () => {
|
||||
api.countImageMessages.mockResolvedValue({
|
||||
totalImageMessages: 420,
|
||||
scannedConversations: 30,
|
||||
failedConversations: 2,
|
||||
typeColumn: 'local_type',
|
||||
durationMs: 9
|
||||
})
|
||||
await renderCard()
|
||||
|
||||
expect(screen.getByTestId('image-text-index-count').textContent).toBe('420')
|
||||
expect(screen.getByTestId('image-text-index-count-error').textContent).toContain(
|
||||
'2 个会话未能统计'
|
||||
)
|
||||
})
|
||||
|
||||
it('真的没有图片时才显示 0,且不出现失败提示', async () => {
|
||||
api.countImageMessages.mockResolvedValue({
|
||||
totalImageMessages: 0,
|
||||
scannedConversations: 12,
|
||||
failedConversations: 0,
|
||||
typeColumn: 'local_type',
|
||||
durationMs: 4
|
||||
})
|
||||
await renderCard()
|
||||
|
||||
expect(screen.getByTestId('image-text-index-count').textContent).toBe('0')
|
||||
expect(screen.queryByTestId('image-text-index-count-error')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('image-text-index-recount')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 派生索引修复按钮的存在意义就是"别为修一个索引问题重跑几万张图"。
|
||||
*
|
||||
* 因此这里断言的重点是**措辞**:用户看到"修复"两个字必须能确信
|
||||
* 不会又要等一小时 —— 否则这个按钮没人敢点,功能等于不存在。
|
||||
*/
|
||||
describe('图片文字索引卡片 — 修复图片搜索索引', () => {
|
||||
const established = status({
|
||||
progress: {
|
||||
state: 'idle',
|
||||
totalImageMessages: 45_740,
|
||||
processed: 45_508,
|
||||
indexed: 14_342,
|
||||
empty: 31_126,
|
||||
missing: 38,
|
||||
failed: 2,
|
||||
pending: 232,
|
||||
percent: 99.5,
|
||||
updatedAt: 1,
|
||||
cancellable: false,
|
||||
paused: false
|
||||
},
|
||||
coverage: {
|
||||
totalImageMessages: 45_740,
|
||||
processed: 45_508,
|
||||
indexed: 14_342,
|
||||
empty: 31_126,
|
||||
missing: 38,
|
||||
failed: 2,
|
||||
pending: 232,
|
||||
established: true,
|
||||
complete: false,
|
||||
countedAt: 1,
|
||||
runtimeUnavailable: 0,
|
||||
systemicFailure: false
|
||||
}
|
||||
} as Partial<ImageTextIndexStatus>)
|
||||
|
||||
it('已建立且空闲时提供修复入口,只在点击后调用主进程', async () => {
|
||||
api.getImageTextIndexStatus.mockResolvedValue(established)
|
||||
api.repairImageTextIndex.mockResolvedValue({
|
||||
conversations: 12,
|
||||
ocrExecutions: 0,
|
||||
durationMs: 800,
|
||||
skipped: false
|
||||
})
|
||||
const { onNotice } = await renderCard()
|
||||
|
||||
const button = screen.getByTestId('image-text-index-repair')
|
||||
expect(button.textContent).toBe('修复图片搜索索引')
|
||||
expect(api.repairImageTextIndex).not.toHaveBeenCalled()
|
||||
|
||||
await userEvent.click(button)
|
||||
|
||||
expect(api.repairImageTextIndex).toHaveBeenCalledTimes(1)
|
||||
// 提示语必须讲清楚"没有重新识别",否则用户会以为又要跑几万张图。
|
||||
expect(String(onNotice.mock.calls.at(-1)?.[0])).toContain('没有重新识别任何图片')
|
||||
expect(String(onNotice.mock.calls.at(-1)?.[0])).toContain('12')
|
||||
})
|
||||
|
||||
it('索引正在跑时不提供修复入口(并发重建会读到半程状态)', async () => {
|
||||
api.getImageTextIndexStatus.mockResolvedValue(running)
|
||||
await renderCard()
|
||||
|
||||
expect(screen.queryByTestId('image-text-index-repair')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('主进程拒绝并发修复时如实告知,不谎称已修复', async () => {
|
||||
api.getImageTextIndexStatus.mockResolvedValue(established)
|
||||
api.repairImageTextIndex.mockResolvedValue({
|
||||
conversations: 0,
|
||||
ocrExecutions: 0,
|
||||
durationMs: 0,
|
||||
skipped: true
|
||||
})
|
||||
const { onNotice } = await renderCard()
|
||||
|
||||
await userEvent.click(screen.getByTestId('image-text-index-repair'))
|
||||
|
||||
expect(String(onNotice.mock.calls.at(-1)?.[0])).toContain('正在进行中')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { vi } from 'vitest'
|
||||
import type { ImageTextIndexStatus } from '../../../src/shared/image-text-index'
|
||||
|
||||
/**
|
||||
* 默认的「未建立」快照。
|
||||
*
|
||||
* 与主进程 `getStatus()` 在派生库不存在时返回的形状逐字段一致 ——
|
||||
* 测试 fake 要是自己编一个形状,就测不出真实的字段缺失。
|
||||
*/
|
||||
export function notBuiltImageTextIndexStatus(): ImageTextIndexStatus {
|
||||
return {
|
||||
progress: {
|
||||
state: 'idle',
|
||||
totalImageMessages: 0,
|
||||
processed: 0,
|
||||
indexed: 0,
|
||||
empty: 0,
|
||||
missing: 0,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
percent: 0,
|
||||
updatedAt: 0,
|
||||
cancellable: false,
|
||||
paused: false
|
||||
},
|
||||
coverage: {
|
||||
totalImageMessages: 0,
|
||||
processed: 0,
|
||||
indexed: 0,
|
||||
empty: 0,
|
||||
missing: 0,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
established: false,
|
||||
complete: false,
|
||||
countedAt: null
|
||||
},
|
||||
storage: { indexedImages: 0, ocrTextCount: 0, totalBytes: 0, updatedAt: null },
|
||||
counting: false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask-WeChat 相关工作区测试用的「图片文字索引」桥接 fake。
|
||||
*
|
||||
* 真实 preload 一定暴露这些方法;测试里的 `window.api` 是手写对象字面量,
|
||||
* 漏掉任何一个都会让卡片在挂载期抛错,并连带**整个工作区**渲染失败
|
||||
* (一个次要侧栏卡片不该有能力搞挂主界面)。
|
||||
*/
|
||||
export function makeImageTextIndexApi(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
getImageTextIndexStatus: vi.fn().mockResolvedValue(notBuiltImageTextIndexStatus()),
|
||||
countImageMessages: vi.fn().mockResolvedValue({
|
||||
totalImageMessages: 0,
|
||||
scannedConversations: 0,
|
||||
durationMs: 0
|
||||
}),
|
||||
startImageTextIndex: vi.fn().mockResolvedValue({ started: true, state: 'running' }),
|
||||
pauseImageTextIndex: vi.fn().mockResolvedValue({ paused: true, state: 'paused' }),
|
||||
resumeImageTextIndex: vi.fn().mockResolvedValue({ started: true, state: 'running' }),
|
||||
cancelImageTextIndex: vi.fn().mockResolvedValue({ cancellable: true, cancelled: true }),
|
||||
clearImageTextIndex: vi.fn().mockResolvedValue({ removed: true, removedBytes: 0 }),
|
||||
resetImageTextIndexFailures: vi.fn().mockResolvedValue({ reset: 0 }),
|
||||
repairImageTextIndex: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ conversations: 0, ocrExecutions: 0, durationMs: 0, skipped: false }),
|
||||
onImageTextIndexStatus: vi.fn(() => () => undefined),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user