mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
feat: 新增mac ocr转文字,增加到问一问微信 图片索引优化速度
- 图片文字索引性能与进度诚实化 - 问问微信:证据卡区分「消息类型」与「派生来源」,派生命中内容自报来源 - 问问微信:回答规则禁止未真实执行的多轮承诺 - 本地图片文字识别:支持 macOS 系统 OCR(Apple Vision)
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 弹窗按钮必须自带主题样式。
|
||||
*
|
||||
* 这两处曾经把 Radix 的 primitive **原样导出**,渲染出来是浏览器默认的黑白方角
|
||||
* 按钮 —— 跟主界面的主题色按钮完全脱节,用户会以为是两个不同的产品。
|
||||
*
|
||||
* 所以这里锁的是"类名里确实带了按钮变体",而不是某个具体颜色值。
|
||||
* 规范见 `docs/development/ui-guidelines.md`。
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter
|
||||
} from '../../src/renderer/src/components/ui/alert-dialog'
|
||||
|
||||
function renderFooter(children: React.ReactNode): void {
|
||||
render(
|
||||
<AlertDialog open>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogFooter>{children}</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
describe('弹窗按钮的主题样式', () => {
|
||||
it('确认按钮走主题色实底', () => {
|
||||
renderFooter(<AlertDialogAction>开始索引</AlertDialogAction>)
|
||||
|
||||
const action = screen.getByRole('button', { name: '开始索引' })
|
||||
expect(action.className).toContain('bg-primary')
|
||||
expect(action.className).toContain('text-primary-foreground')
|
||||
})
|
||||
|
||||
it('取消按钮走次要描边,不抢主按钮的主题色', () => {
|
||||
renderFooter(<AlertDialogCancel>取消</AlertDialogCancel>)
|
||||
|
||||
const cancel = screen.getByRole('button', { name: '取消' })
|
||||
expect(cancel.className).toContain('border')
|
||||
expect(cancel.className).not.toContain('bg-primary')
|
||||
})
|
||||
|
||||
it('调用方传 className 能覆盖成危险动作', () => {
|
||||
renderFooter(
|
||||
<AlertDialogAction className="bg-destructive text-destructive-foreground">
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
)
|
||||
|
||||
const action = screen.getByRole('button', { name: '删除' })
|
||||
expect(action.className).toContain('bg-destructive')
|
||||
// 只断言"独立的 bg-primary 类不存在"—— `hover:bg-primary-hover` 含同样子串,
|
||||
// 用裸字符串匹配会误伤。
|
||||
expect(action.className).not.toMatch(/(^|\s)bg-primary(\s|$)/)
|
||||
})
|
||||
})
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* §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,136 @@
|
||||
/**
|
||||
* Evidence 的来源语义与来源标签。
|
||||
*
|
||||
* 不能退让的约束:
|
||||
* 1. **消息类型**与**派生来源**是两个正交维度,UI 必须分别标出来:
|
||||
* 前者说"原始消息是什么"(文本 / 图片 / 语音…),
|
||||
* 后者说"这条结果是靠什么命中的"(OCR命中 / 转写命中);
|
||||
* 2. authoritative source 仍是原始消息 —— messageRef 不变,跳转目标就是它;
|
||||
* 3. 派生命中内容必须自报来源(`OCR摘录:` / `转写摘录:`),
|
||||
* 不能被读成群友真的发过这样一段文字;
|
||||
* 4. 引擎内部前缀(`图片文字:` / `OCR:` / `system-ocr`)绝不出现在用户可见文本里;
|
||||
* 5. 普通文字消息只标「文本消息」,不凭空多出来源标记。
|
||||
*/
|
||||
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 = '今日特价 248 元'
|
||||
const TRANSCRIPT_TEXT = '明天下午三点评审'
|
||||
const IMAGE_REF = encodeMessageRef('fixture-conversation-a', '9001')
|
||||
const VOICE_REF = encodeMessageRef('fixture-conversation-a', '9002')
|
||||
const TEXT_REF = encodeMessageRef('fixture-conversation-a', '9003')
|
||||
|
||||
const base = {
|
||||
conversationName: '测试群',
|
||||
conversationType: 'group' as const,
|
||||
sender: '用户A',
|
||||
source: 'search_messages'
|
||||
}
|
||||
|
||||
/** 图片 OCR 命中。 */
|
||||
const imageOcrEvidence: AskWechatEvidenceItem = {
|
||||
...base,
|
||||
messageRef: IMAGE_REF,
|
||||
timestamp: Date.parse('2026-09-03T14:32:00+08:00'),
|
||||
messageType: 'image',
|
||||
text: OCR_TEXT,
|
||||
derivedSource: 'image_ocr',
|
||||
imageOcrText: OCR_TEXT
|
||||
}
|
||||
|
||||
/** 语音转写命中。 */
|
||||
const voiceTranscriptEvidence: AskWechatEvidenceItem = {
|
||||
...base,
|
||||
messageRef: VOICE_REF,
|
||||
timestamp: Date.parse('2026-09-03T14:34:00+08:00'),
|
||||
messageType: 'voice',
|
||||
text: TRANSCRIPT_TEXT,
|
||||
derivedSource: 'voice_transcript'
|
||||
}
|
||||
|
||||
/** 普通文字消息(对照组)。 */
|
||||
const plainEvidence: AskWechatEvidenceItem = {
|
||||
...base,
|
||||
messageRef: TEXT_REF,
|
||||
timestamp: Date.parse('2026-09-03T14:30:00+08:00'),
|
||||
messageType: 'text',
|
||||
text: '这是一条普通的文字消息'
|
||||
}
|
||||
|
||||
function renderPanel(evidence: EvidenceItem[]): React.ComponentProps<typeof AISearchEvidencePanel> {
|
||||
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('映射层保留消息类型与派生来源,且 authoritative source 不变', () => {
|
||||
const [image] = mapAskWechatEvidence([imageOcrEvidence])
|
||||
expect(image.sourceKind).toBe('image')
|
||||
expect(image.derivedSource).toBe('image_ocr')
|
||||
expect(image.imageOcrText).toBe(OCR_TEXT)
|
||||
expect(image.messageRef).toBe(IMAGE_REF)
|
||||
|
||||
const [voice] = mapAskWechatEvidence([voiceTranscriptEvidence])
|
||||
expect(voice.sourceKind).toBe('voice')
|
||||
expect(voice.derivedSource).toBe('voice_transcript')
|
||||
expect(voice.messageRef).toBe(VOICE_REF)
|
||||
})
|
||||
|
||||
it('图片 OCR 命中:标「图片消息 + OCR命中」,片段写明是 OCR 摘录', () => {
|
||||
renderPanel(mapAskWechatEvidence([imageOcrEvidence]))
|
||||
|
||||
expect(screen.getByTestId('evidence-badge-messageType').textContent).toBe('图片消息')
|
||||
expect(screen.getByTestId('evidence-badge-derivedSource').textContent).toBe('OCR命中')
|
||||
expect(screen.getByTestId('evidence-image-ocr-snippet').textContent).toContain(OCR_TEXT)
|
||||
|
||||
const panelText = document.body.textContent || ''
|
||||
expect(panelText).toContain('OCR摘录:')
|
||||
// 内部前缀绝不能出现在用户可见文本里
|
||||
expect(panelText).not.toContain('图片文字:')
|
||||
expect(panelText).not.toContain('OCR:')
|
||||
expect(panelText).not.toContain('system-ocr')
|
||||
// 不暴露工程字段名
|
||||
expect(panelText).not.toContain('image_ocr')
|
||||
expect(panelText).not.toContain('derivedSource')
|
||||
})
|
||||
|
||||
it('语音转写命中:标「语音消息 + 转写命中」,片段写明是转写摘录', () => {
|
||||
renderPanel(mapAskWechatEvidence([voiceTranscriptEvidence]))
|
||||
|
||||
expect(screen.getByTestId('evidence-badge-messageType').textContent).toBe('语音消息')
|
||||
expect(screen.getByTestId('evidence-badge-derivedSource').textContent).toBe('转写命中')
|
||||
|
||||
const panelText = document.body.textContent || ''
|
||||
expect(panelText).toContain('转写摘录:')
|
||||
expect(panelText).not.toContain('voice_transcript')
|
||||
})
|
||||
|
||||
it('普通文字消息只标「文本消息」,没有派生来源标记', () => {
|
||||
renderPanel(mapAskWechatEvidence([plainEvidence]))
|
||||
|
||||
expect(screen.getByTestId('evidence-badge-messageType').textContent).toBe('文本消息')
|
||||
expect(screen.queryByTestId('evidence-badge-derivedSource')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('evidence-image-ocr-snippet')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('这是一条普通的文字消息')).toBeVisible()
|
||||
|
||||
const panelText = document.body.textContent || ''
|
||||
expect(panelText).not.toContain('摘录:')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 图片密钥的「显示 / 隐藏」开关。
|
||||
*
|
||||
* 契约(产品要求):AES 密钥默认遮蔽,用户点一下眼睛能看见自己填的值。
|
||||
* 关键边界:这只是**显示层**行为 —— 不许改动值、不许触发保存、不许影响校验。
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ImageKeyConfiguration } from '../../src/renderer/src/features/settings/image-decryption/ImageKeyConfiguration'
|
||||
import type { ImageDecryptionState } from '../../src/renderer/src/features/settings/image-decryption/types'
|
||||
|
||||
const AES_KEY = '0123456789abcdef'
|
||||
|
||||
function state(overrides: Partial<ImageDecryptionState> = {}): ImageDecryptionState {
|
||||
return {
|
||||
phase: 'idle',
|
||||
config: null,
|
||||
status: null,
|
||||
contacts: [],
|
||||
selectedUserMd5: '',
|
||||
resourceRoot: '/tmp/fixture',
|
||||
xorKey: '0x40',
|
||||
aesKey: AES_KEY,
|
||||
testResult: null,
|
||||
autoPhase: 'idle',
|
||||
autoProgress: '',
|
||||
dirty: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const aesInput = (): HTMLInputElement => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('.image-key-secret > input')
|
||||
if (!inputs.length) throw new Error('AES input missing')
|
||||
return inputs[0]
|
||||
}
|
||||
|
||||
describe('图片密钥显示开关', () => {
|
||||
it('默认遮蔽,且按钮自称「显示图片密钥」', () => {
|
||||
render(<ImageKeyConfiguration state={state()} disabled={false} onEdit={vi.fn()} />)
|
||||
|
||||
expect(aesInput().type).toBe('password')
|
||||
expect(aesInput().value).toBe(AES_KEY)
|
||||
|
||||
const toggle = screen.getByTestId('image-key-reveal')
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(toggle).toHaveAccessibleName('显示图片密钥')
|
||||
})
|
||||
|
||||
it('点一下明文显示,再点一下回到遮蔽', async () => {
|
||||
render(<ImageKeyConfiguration state={state()} disabled={false} onEdit={vi.fn()} />)
|
||||
const toggle = screen.getByTestId('image-key-reveal')
|
||||
|
||||
await userEvent.click(toggle)
|
||||
expect(aesInput().type).toBe('text')
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(toggle).toHaveAccessibleName('隐藏图片密钥')
|
||||
// 明文里能真的读到密钥本身。
|
||||
expect(aesInput().value).toBe(AES_KEY)
|
||||
|
||||
await userEvent.click(toggle)
|
||||
expect(aesInput().type).toBe('password')
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('只是显示层:切换不许改值、不许触发保存', async () => {
|
||||
const onEdit = vi.fn()
|
||||
render(<ImageKeyConfiguration state={state()} disabled={false} onEdit={onEdit} />)
|
||||
|
||||
await userEvent.click(screen.getByTestId('image-key-reveal'))
|
||||
|
||||
expect(onEdit).not.toHaveBeenCalled()
|
||||
expect(aesInput().value).toBe(AES_KEY)
|
||||
// XOR 字段不该被顺手改成密码框 —— 它本来就不是敏感值,一直是明文。
|
||||
expect(document.querySelectorAll('input[type="password"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('不可编辑时开关也跟着禁用,避免"能看不能改"的错觉', () => {
|
||||
render(<ImageKeyConfiguration state={state()} disabled onEdit={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('image-key-reveal')).toBeDisabled()
|
||||
expect(aesInput()).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* §3 / §4:「图片文字索引」卡片的用户可见行为。
|
||||
* 「图片文字索引」卡片的用户可见行为。
|
||||
*
|
||||
* 这些断言对应的是产品需求里**写死的**交互契约,不是实现细节:
|
||||
* - 未建立时先给出检测到的图片消息数量,而不是一个空洞的按钮;
|
||||
@@ -7,7 +7,8 @@
|
||||
* - 确认弹窗要写清本机执行、原图不会因识别而自动上传、可暂停、实际可识别数量取决于本地文件;
|
||||
* - 进度只给真实数字(processed/total、识别出文字、没有文字、图片已清理、失败、百分比);
|
||||
* - 暂停 / 继续 / 取消三个动作都在,且暂停后能继续;
|
||||
* - 重启后进度来自主进程快照(这里用「首帧就是 paused 快照」模拟)。
|
||||
* - 重启后进度来自主进程快照(这里用「首帧就是 paused 快照」模拟);
|
||||
* - 操作区是**单列堆叠**:这张卡最多并列 3 个操作,横向排会撑破窄侧栏。
|
||||
*/
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -85,6 +86,20 @@ const paused = status({
|
||||
progress: { ...running.progress, state: 'paused', cancellable: false, paused: true }
|
||||
})
|
||||
|
||||
/** 取消:进度全部保留,但状态是 cancelled 而不是 paused。 */
|
||||
const cancelled = status({
|
||||
...running,
|
||||
progress: { ...running.progress, state: 'cancelled', cancellable: false, paused: false },
|
||||
coverage: { ...running.coverage, established: true }
|
||||
})
|
||||
|
||||
/** 已建立但未完成:这张状态下操作区最多并列 3 个按钮(更新 / 重新统计 / 修复)。 */
|
||||
const establishedPartial = status({
|
||||
...running,
|
||||
progress: { ...running.progress, state: 'idle', cancellable: false },
|
||||
coverage: { ...running.coverage, established: true }
|
||||
})
|
||||
|
||||
const api = {
|
||||
getImageTextIndexStatus: vi.fn(),
|
||||
countImageMessages: vi.fn(),
|
||||
@@ -174,6 +189,37 @@ describe('图片文字索引卡片', () => {
|
||||
expect(api.cancelImageTextIndex).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('运行中给出窗口速度与 ETA,而不是历史平均', async () => {
|
||||
api.getImageTextIndexStatus.mockResolvedValue({
|
||||
...running,
|
||||
progress: {
|
||||
...running.progress,
|
||||
speedPerSec: 38,
|
||||
etaMs: 2 * 60 * 60 * 1000 + 25 * 60 * 1000
|
||||
}
|
||||
})
|
||||
await renderCard()
|
||||
|
||||
const rate = screen.getByTestId('image-text-index-rate')
|
||||
expect(rate.textContent).toContain('约 38.0 张/秒')
|
||||
expect(rate.textContent).toContain('2 小时 25 分')
|
||||
// 用户界面不出现开发指标。
|
||||
expect(rate.textContent).not.toMatch(/p50|p95|percentile/i)
|
||||
})
|
||||
|
||||
it('速度样本不足时如实说「计算中」,不编数字', async () => {
|
||||
// 默认的 running 夹具没有 speedPerSec / etaMs(主进程给 null 的情形)。
|
||||
api.getImageTextIndexStatus.mockResolvedValue({
|
||||
...running,
|
||||
progress: { ...running.progress, speedPerSec: null, etaMs: null }
|
||||
})
|
||||
await renderCard()
|
||||
|
||||
const rate = screen.getByTestId('image-text-index-rate')
|
||||
expect(rate.textContent).toContain('当前速度:计算中')
|
||||
expect(rate.textContent).toContain('预计剩余:计算中')
|
||||
})
|
||||
|
||||
it('暂停后可以继续,进度仍来自主进程快照', async () => {
|
||||
api.getImageTextIndexStatus.mockResolvedValue(paused)
|
||||
await renderCard()
|
||||
@@ -183,6 +229,27 @@ describe('图片文字索引卡片', () => {
|
||||
expect(api.resumeImageTextIndex).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* 「取消」之后的入口曾经是缺失的:状态落回「部分完成」、按钮只剩「更新图片文字索引」,
|
||||
* 用户既看不出自己中断过,也找不到继续的地方 —— 于是以为进度丢了。
|
||||
* 取消和暂停一样保留 checkpoint,所以必须给同样的「继续」。
|
||||
*/
|
||||
it('取消之后仍然能继续:状态说「已取消」,「继续」入口还在', async () => {
|
||||
api.getImageTextIndexStatus.mockResolvedValue(cancelled)
|
||||
await renderCard()
|
||||
|
||||
expect(screen.getByTestId('image-text-index-state').textContent).toMatch(/^已取消 · /)
|
||||
const resume = screen.getByTestId('image-text-index-resume')
|
||||
expect(resume.textContent).toBe('继续')
|
||||
// 「更新图片文字索引」和「继续」是同一件事,不能同时抢位。
|
||||
expect(screen.queryByTestId('image-text-index-start')).toBeNull()
|
||||
// 已经取消了,没有东西可再取消。
|
||||
expect(screen.queryByTestId('image-text-index-cancel')).toBeNull()
|
||||
|
||||
await userEvent.click(resume)
|
||||
expect(api.resumeImageTextIndex).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('主进程推送真实进度后,卡片跟着更新(重启后恢复的进度同一条路径)', async () => {
|
||||
await renderCard()
|
||||
expect(screen.getByTestId('image-text-index-state').textContent).toBe('未建立')
|
||||
@@ -194,7 +261,7 @@ describe('图片文字索引卡片', () => {
|
||||
expect(screen.getByTestId('image-text-index-progress').textContent).toBe('3,842 / 12,483')
|
||||
})
|
||||
|
||||
it('统计失败时显示「无法统计」而不是 0,并给出原因与重新统计入口', async () => {
|
||||
it('统计失败时显示「无法统计」而不是 0,并给出原因', async () => {
|
||||
api.countImageMessages.mockResolvedValue({
|
||||
totalImageMessages: 0,
|
||||
scannedConversations: 0,
|
||||
@@ -213,7 +280,48 @@ describe('图片文字索引卡片', () => {
|
||||
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()
|
||||
// 「重新统计」入口已收掉:进度改用流水线真实走过的集合之后,
|
||||
// 重算那个预估值不再影响任何东西,留着只会多一个看不懂的按钮。
|
||||
// 断言按**用户可见文案**而不是已删除的 testid —— 对已移除 testid 断言
|
||||
// 「不存在」是恒真的,删掉按钮之后它就再也测不出任何东西。
|
||||
expect(screen.queryByText('重新统计')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
/**
|
||||
* 操作区布局契约:主按钮与「更多」各占一行。
|
||||
*
|
||||
* 修复类操作(修复搜索索引 / 重试失败的图片)收进了「更多」菜单 ——
|
||||
* 平铺出来时,用户看到的是几个都在说「索引」的按钮,只能靠猜哪个该点。
|
||||
*
|
||||
* jsdom 不做真实排版,所以这里锁的是**能推出该结果的结构**:
|
||||
* 两个按钮是同一个操作容器的直接子元素,且该容器带单列堆叠修饰类
|
||||
* (共享的栅格类 + `ai-search-image-index-actions`,后者把 grid 覆盖成 flex column)。
|
||||
* 一旦有人在按钮外面套一层 wrapper、或去掉修饰类,这个测试就会失败。
|
||||
*/
|
||||
it('操作区只留主按钮与「更多」:同一容器的直接子元素,顺序为 更新 / 更多', async () => {
|
||||
api.getImageTextIndexStatus.mockResolvedValue(establishedPartial)
|
||||
api.countImageMessages.mockResolvedValue({
|
||||
totalImageMessages: 12_483,
|
||||
scannedConversations: 42,
|
||||
failedConversations: 1,
|
||||
typeColumn: 'local_type',
|
||||
durationMs: 30
|
||||
})
|
||||
await renderCard()
|
||||
|
||||
const start = screen.getByTestId('image-text-index-start')
|
||||
const more = screen.getByTestId('image-text-index-more')
|
||||
|
||||
expect(start.textContent).toBe('更新图片文字索引')
|
||||
expect(more.textContent).toBe('更多')
|
||||
|
||||
const container = start.parentElement
|
||||
expect(container).toBe(more.parentElement)
|
||||
expect(container?.classList.contains('ai-search-knowledge-actions')).toBe(true)
|
||||
expect(container?.classList.contains('ai-search-image-index-actions')).toBe(true)
|
||||
|
||||
// 直接子元素 == 独占一行;顺序断言同时锁住视觉顺序。
|
||||
expect(Array.from(container?.children ?? [])).toEqual([start, more])
|
||||
})
|
||||
|
||||
it('部分会话统计失败时给出真实数字并提示偏小', async () => {
|
||||
@@ -244,7 +352,8 @@ describe('图片文字索引卡片', () => {
|
||||
|
||||
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()
|
||||
// 同上:按文案断言,避免对已删除的 testid 做恒真断言。
|
||||
expect(screen.queryByText('重新统计')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -286,7 +395,7 @@ describe('图片文字索引卡片 — 修复图片搜索索引', () => {
|
||||
}
|
||||
} as Partial<ImageTextIndexStatus>)
|
||||
|
||||
it('已建立且空闲时提供修复入口,只在点击后调用主进程', async () => {
|
||||
it('已建立且空闲时,修复入口收在「更多」里,点击后才调用主进程', async () => {
|
||||
api.getImageTextIndexStatus.mockResolvedValue(established)
|
||||
api.repairImageTextIndex.mockResolvedValue({
|
||||
conversations: 12,
|
||||
@@ -296,11 +405,15 @@ describe('图片文字索引卡片 — 修复图片搜索索引', () => {
|
||||
})
|
||||
const { onNotice } = await renderCard()
|
||||
|
||||
const button = screen.getByTestId('image-text-index-repair')
|
||||
expect(button.textContent).toBe('修复图片搜索索引')
|
||||
// 修复类操作不再平铺在操作区,必须先展开「更多」。
|
||||
expect(screen.queryByTestId('image-text-index-repair')).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByTestId('image-text-index-more'))
|
||||
|
||||
const item = await screen.findByTestId('image-text-index-repair')
|
||||
expect(item.textContent).toContain('修复搜索索引')
|
||||
expect(api.repairImageTextIndex).not.toHaveBeenCalled()
|
||||
|
||||
await userEvent.click(button)
|
||||
await userEvent.click(item)
|
||||
|
||||
expect(api.repairImageTextIndex).toHaveBeenCalledTimes(1)
|
||||
// 提示语必须讲清楚"没有重新识别",否则用户会以为又要跑几万张图。
|
||||
@@ -312,6 +425,7 @@ describe('图片文字索引卡片 — 修复图片搜索索引', () => {
|
||||
api.getImageTextIndexStatus.mockResolvedValue(running)
|
||||
await renderCard()
|
||||
|
||||
expect(screen.queryByTestId('image-text-index-more')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('image-text-index-repair')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -325,7 +439,8 @@ describe('图片文字索引卡片 — 修复图片搜索索引', () => {
|
||||
})
|
||||
const { onNotice } = await renderCard()
|
||||
|
||||
await userEvent.click(screen.getByTestId('image-text-index-repair'))
|
||||
await userEvent.click(screen.getByTestId('image-text-index-more'))
|
||||
await userEvent.click(await screen.findByTestId('image-text-index-repair'))
|
||||
|
||||
expect(String(onNotice.mock.calls.at(-1)?.[0])).toContain('正在进行中')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Markdown 表格的降级渲染。
|
||||
*
|
||||
* 结果栏很窄,真表格在这里只会列宽错位、长字段换行难读。提示词已经禁止模型为
|
||||
* 检索结果产表格,但**历史回答**与其它入口仍可能出现,所以渲染层必须保证它
|
||||
* 不会横向炸出容器,且内容读得出来。
|
||||
*
|
||||
* 这里的做法是把它降级成逐行的键值列表(每格一个 span,靠 CSS flex-wrap 换行),
|
||||
* 而不是渲染 `<table>` —— 没有 table 就不会有列宽挤压与横向溢出。
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderMarkdown } from '../../src/renderer/src/components/search/searchMarkdown'
|
||||
|
||||
function renderValue(value: string): HTMLElement {
|
||||
const { container } = render(<div>{renderMarkdown(value)}</div>)
|
||||
return container
|
||||
}
|
||||
|
||||
describe('Markdown 表格降级渲染', () => {
|
||||
it('表格行渲染成逐格的键值单元,而不是 <table>', () => {
|
||||
const container = renderValue('| 发送人 | 时间 | 图片中的文字 |')
|
||||
|
||||
expect(container.querySelector('table')).toBeNull()
|
||||
const row = container.querySelector('.ai-search-markdown-table-row')
|
||||
expect(row).not.toBeNull()
|
||||
const cells = container.querySelectorAll('.ai-search-markdown-table-cell')
|
||||
expect(cells).toHaveLength(3)
|
||||
expect(cells[0].textContent).toBe('发送人')
|
||||
expect(cells[2].textContent).toBe('图片中的文字')
|
||||
})
|
||||
|
||||
it('分隔行(|---|---|)不产生内容', () => {
|
||||
const container = renderValue('|---|---|')
|
||||
|
||||
expect(container.querySelectorAll('.ai-search-markdown-table-cell')).toHaveLength(0)
|
||||
expect(container.querySelector('.ai-search-markdown-spacer')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('超长单元格文本原样保留,不截断也不丢字', () => {
|
||||
const longText = '这是一段很长的识别文本'.repeat(12)
|
||||
const container = renderValue(`| 用户A | ${longText} |`)
|
||||
|
||||
const cells = container.querySelectorAll('.ai-search-markdown-table-cell')
|
||||
expect(cells).toHaveLength(2)
|
||||
expect(cells[1].textContent).toBe(longText)
|
||||
})
|
||||
|
||||
it('普通段落与列表不受影响', () => {
|
||||
const container = renderValue('这是一段普通说明\n\n1. 第一条\n2. 第二条')
|
||||
|
||||
expect(container.querySelector('.ai-search-markdown-table-row')).toBeNull()
|
||||
expect(screen.getByText('这是一段普通说明')).toBeVisible()
|
||||
expect(container.querySelectorAll('.ai-search-markdown-list-item')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user