mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 05:27:48 +08:00
feat: 重构问问微信并完善知识库增量检索
统一问问微信与 Agent Hub 的查询链路 完善查询覆盖度、新鲜度和部分结果表达,避免索引滞后产生错误结论 基于会话实现真正的增量追新与历史补齐 支持后台同步、取消恢复、重启续传以及同步期间继续查询 优化知识库跨会话检索、同步状态、进度展示和侧栏布局
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
SEARCH_CACHE_KEY,
|
||||
buildSearchCacheKey
|
||||
} from '../../src/renderer/src/components/search/searchUtils'
|
||||
import { makeSearchResult } from './support/ai-search-fixtures'
|
||||
|
||||
const api = {
|
||||
getSettings: vi.fn(),
|
||||
@@ -352,12 +353,18 @@ describe('AISearchWorkspace cache privacy boundary', () => {
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('disables and guards analysis while the knowledge base is synchronizing', async () => {
|
||||
it('keeps analysis available while the knowledge base is synchronizing (partial result + freshness warning)', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue({
|
||||
state: 'syncing',
|
||||
indexedMessageCount: 20,
|
||||
indexedChunkCount: 4,
|
||||
processedMessages: 20,
|
||||
totalMessages: 100
|
||||
})
|
||||
api.runAiSearch.mockResolvedValue(makeSearchResult())
|
||||
// 本地 Provider:本用例测的是"同步中能否提问",不是远程授权边界
|
||||
// (远程授权边界由本文件另外的用例覆盖)。
|
||||
api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: false })
|
||||
const onNotice = vi.fn()
|
||||
render(
|
||||
<AISearchWorkspace
|
||||
@@ -378,18 +385,24 @@ describe('AISearchWorkspace cache privacy boundary', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
await userEvent.type(screen.getByRole('textbox'), '同步时不能分析')
|
||||
const button = await screen.findByRole('button', { name: /同步中,暂不可分析/ })
|
||||
expect(button).toBeDisabled()
|
||||
const form = screen.getByRole('textbox').closest('form')
|
||||
expect(form).not.toBeNull()
|
||||
fireEvent.submit(form as HTMLFormElement)
|
||||
await userEvent.type(screen.getByRole('textbox'), '同步时也要能分析')
|
||||
// 等状态真的落地(同步中)再点,否则测的是"状态还没到"的竞态而不是产品行为。
|
||||
await screen.findByText('Knowledge 可用 · 正在追新')
|
||||
// 同步中**不允许**禁用提问:按钮必须可用,文案也不能变成"暂不可分析"。
|
||||
const button = screen.getByRole('button', { name: /开始分析/ })
|
||||
expect(button).toBeEnabled()
|
||||
expect(screen.queryByRole('button', { name: /暂不可分析/ })).not.toBeInTheDocument()
|
||||
|
||||
await userEvent.click(button)
|
||||
|
||||
// 查询真的走到了检索(走完 consent 才会到这里),而不是被"请等待同步完成"挡回。
|
||||
await waitFor(() => expect(api.getAiSearchProviderStatus).toHaveBeenCalled())
|
||||
await waitFor(() =>
|
||||
expect(onNotice).toHaveBeenCalledWith('知识库正在同步,请等待同步完成后再开始分析')
|
||||
expect(onNotice).toHaveBeenCalledWith('知识库正在后台同步,本次结果可能未覆盖最新消息')
|
||||
)
|
||||
expect(api.getAiSearchProviderStatus).not.toHaveBeenCalled()
|
||||
expect(api.runAiSearch).not.toHaveBeenCalled()
|
||||
expect(onNotice).not.toHaveBeenCalledWith('知识库正在同步,请等待同步完成后再开始分析')
|
||||
await waitFor(() => expect(api.runAiSearch).toHaveBeenCalledOnce())
|
||||
expect(await screen.findByText('测试搜索答案')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits with Enter and keeps Shift+Enter available for a new line', async () => {
|
||||
|
||||
@@ -71,8 +71,15 @@ describe('AISearchComposer', () => {
|
||||
expect(props.onHistoryOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('disables submission while Knowledge is syncing', () => {
|
||||
it('keeps submission available while Knowledge is syncing and only warns about coverage', () => {
|
||||
// 知识库同步是后台 / 可取消 / 可断点续传的,**不允许**因此禁止提问。
|
||||
// 索引没追平时应由 coverage/freshness 契约如实标注覆盖范围。
|
||||
renderComposer({ knowledgeSyncing: true })
|
||||
expect(screen.getByRole('button', { name: /同步中,暂不可分析/ })).toBeDisabled()
|
||||
const submit = screen.getByRole('button', { name: /开始分析/ })
|
||||
expect(submit).toBeEnabled()
|
||||
expect(screen.queryByRole('button', { name: /暂不可分析/ })).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('知识库后台同步中 · 仍可提问,答案会标注覆盖范围')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useEvidenceCollection } from '../../src/renderer/src/components/search/hooks/useEvidenceCollection'
|
||||
import type { EvidenceItem } from '../../src/renderer/src/components/search/searchTypes'
|
||||
import { decodeMessageRef, encodeMessageRef } from '../../src/shared/local-query-api'
|
||||
import {
|
||||
aiSearchContact,
|
||||
makeCacheRecord,
|
||||
@@ -16,6 +17,9 @@ const makeEvidence = (count: number, offset = 0): EvidenceItem[] =>
|
||||
return {
|
||||
evidenceId: item.id,
|
||||
contact: aiSearchContact,
|
||||
// 稳定引用是"跳转到这一条"的唯一可靠身份:只给会话 + 秒级时间戳,
|
||||
// 同一秒多条消息时根本定位不到目标。
|
||||
messageRef: encodeMessageRef(aiSearchContact.md5, item.messageId),
|
||||
message: {
|
||||
id: item.messageId,
|
||||
from: item.senderId,
|
||||
@@ -168,14 +172,22 @@ describe('useEvidenceCollection', () => {
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
|
||||
it('passes the selected Evidence contact and timestamp to the jump callback', () => {
|
||||
it('passes the whole Evidence item — including its stable messageRef — to the jump callback', () => {
|
||||
const { result } = renderHook(() => useEvidenceHarness())
|
||||
const collection = makeEvidence(1)
|
||||
act(() => result.current.setEvidenceResult(collection, collection))
|
||||
|
||||
act(() => result.current.jumpToEvidence(0))
|
||||
|
||||
expect(onOpenEvidence).toHaveBeenCalledWith(aiSearchContact, collection[0].message.createTime)
|
||||
// 传整条证据而不是 `(contact, createTime)`:后者丢掉了稳定身份,跳转只能靠
|
||||
// "会话 + 秒级时间戳"猜。引用必须能被还原成真实的 conversationId / messageId。
|
||||
expect(onOpenEvidence).toHaveBeenCalledTimes(1)
|
||||
const passed = onOpenEvidence.mock.calls[0][0] as EvidenceItem
|
||||
expect(passed).toBe(collection[0])
|
||||
expect(decodeMessageRef(passed.messageRef)).toEqual({
|
||||
conversationId: aiSearchContact.md5,
|
||||
messageId: collection[0].message.id
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the previous request Evidence and selection before the next result is applied', () => {
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
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 type { AskWechatQueryResult, AskWechatStats, QueryAgentProgressEvent } from '../../src/shared/query-agent'
|
||||
|
||||
type AnsweredResult = Extract<AskWechatQueryResult, { status: 'answered' }>
|
||||
|
||||
const api = {
|
||||
getSettings: vi.fn(),
|
||||
getAppLogPath: vi.fn(),
|
||||
getKnowledgeStatus: vi.fn(),
|
||||
onKnowledgeStatus: vi.fn(),
|
||||
onAiSearchProgress: vi.fn(),
|
||||
getAiSearchProviderStatus: vi.fn(),
|
||||
authorizeAiSearchExternalProvider: vi.fn(),
|
||||
runAiSearch: vi.fn(),
|
||||
cancelAiSearch: vi.fn(),
|
||||
startKnowledgeIndex: vi.fn(),
|
||||
writeAppLog: vi.fn(),
|
||||
revealAppLog: vi.fn(),
|
||||
copyText: vi.fn(),
|
||||
getAskWechatConfig: vi.fn(),
|
||||
runAskWechatQuery: vi.fn(),
|
||||
forgetAskWechatConversation: vi.fn(),
|
||||
onAskWechatProgress: vi.fn(),
|
||||
cancelKnowledgeIndex: vi.fn()
|
||||
}
|
||||
|
||||
const indexLatestAt = new Date('2026-09-11T11:57:24+08:00').getTime()
|
||||
|
||||
/** 主进程推来的**真实**进度事件(不是定时器伪进度)。 */
|
||||
let askWechatProgressListener:
|
||||
| ((requestId: string, event: QueryAgentProgressEvent) => void)
|
||||
| undefined
|
||||
|
||||
const readyKnowledgeStatus = {
|
||||
accountId: 'fixture-account',
|
||||
state: 'ready' as const,
|
||||
indexedMessageCount: 20,
|
||||
indexedChunkCount: 4,
|
||||
sourceMessageCount: 20,
|
||||
processedMessages: 20,
|
||||
totalMessages: 20,
|
||||
estimatedRemainingMs: null,
|
||||
databaseBytes: 128,
|
||||
walBytes: 64,
|
||||
shmBytes: 32,
|
||||
indexLatestAt,
|
||||
sourceLatestAt: indexLatestAt
|
||||
}
|
||||
|
||||
const diagnostics = {
|
||||
entry: 'desktop' as const,
|
||||
provider: 'Fixture Provider',
|
||||
model: 'Fixture Model',
|
||||
modelCallCount: 2,
|
||||
toolCallCount: 1,
|
||||
tools: ['query_messages'],
|
||||
totalMs: 1200,
|
||||
outcome: 'answered' as const
|
||||
}
|
||||
|
||||
const makeProps = (): Record<string, unknown> => ({
|
||||
contacts: [aiSearchContact, aiSearchGroup],
|
||||
selectedContact: aiSearchContact,
|
||||
dbReady: true,
|
||||
aiModelConfig: {
|
||||
configured: true,
|
||||
providerName: 'Fixture Provider',
|
||||
model: 'fixture-model',
|
||||
modelName: 'Fixture Model',
|
||||
status: 'connected' as const
|
||||
},
|
||||
onSelectContact: vi.fn(),
|
||||
onOpenEvidence: vi.fn(),
|
||||
onOpenAISettings: vi.fn(),
|
||||
onNotice: vi.fn()
|
||||
})
|
||||
|
||||
const submitQuery = async (query: string): Promise<void> => {
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByRole('textbox'), query)
|
||||
await user.click(screen.getByRole('button', { name: /开始分析/ }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: api })
|
||||
api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } })
|
||||
api.getAppLogPath.mockResolvedValue('')
|
||||
api.getKnowledgeStatus.mockResolvedValue(readyKnowledgeStatus)
|
||||
api.onKnowledgeStatus.mockImplementation(() => vi.fn())
|
||||
api.onAiSearchProgress.mockImplementation(() => vi.fn())
|
||||
api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: false })
|
||||
api.authorizeAiSearchExternalProvider.mockResolvedValue({ success: true })
|
||||
api.cancelAiSearch.mockResolvedValue({ cancelled: true })
|
||||
api.startKnowledgeIndex.mockResolvedValue(readyKnowledgeStatus)
|
||||
api.writeAppLog.mockResolvedValue(undefined)
|
||||
api.revealAppLog.mockResolvedValue(undefined)
|
||||
api.copyText.mockResolvedValue({ success: true })
|
||||
api.forgetAskWechatConversation.mockResolvedValue(undefined)
|
||||
api.runAiSearch.mockResolvedValue(makeSearchResult())
|
||||
api.getAskWechatConfig.mockResolvedValue({ queryAgentEnabled: true })
|
||||
api.runAskWechatQuery.mockResolvedValue(answeredResult())
|
||||
api.onAskWechatProgress.mockImplementation(
|
||||
(listener: (requestId: string, event: QueryAgentProgressEvent) => void) => {
|
||||
askWechatProgressListener = listener
|
||||
return () => {
|
||||
askWechatProgressListener = undefined
|
||||
}
|
||||
}
|
||||
)
|
||||
api.cancelKnowledgeIndex.mockResolvedValue({ cancellable: false, cancelled: false })
|
||||
})
|
||||
|
||||
const askStats = (): AskWechatStats => ({
|
||||
tools: ['query_messages'],
|
||||
reads: { messageCount: 10, matchedCount: 0, overviewSourceCount: 0, evidenceCount: 0 },
|
||||
scope: { kind: 'all' as const, label: '所有聊天记录' },
|
||||
modelCallCount: 2,
|
||||
toolCallCount: 1,
|
||||
totalMs: 3343
|
||||
})
|
||||
|
||||
const answeredResult = (patch: Partial<AnsweredResult> = {}): AnsweredResult => ({
|
||||
engine: 'query-agent' as const,
|
||||
status: 'answered' as const,
|
||||
answer: '你们的第一次聊天是一条问候。',
|
||||
evidence: [],
|
||||
stats: askStats(),
|
||||
diagnostics,
|
||||
...patch
|
||||
})
|
||||
|
||||
describe('AISearchWorkspace — Query Agent 主路径', () => {
|
||||
it('Query Agent 开启时用它回答,不再调用 Legacy 检索', async () => {
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
await submitQuery('我和 BOBO 第一次聊了什么')
|
||||
|
||||
expect(await screen.findByText('你们的第一次聊天是一条问候。')).toBeTruthy()
|
||||
expect(api.runAskWechatQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: '我和 BOBO 第一次聊了什么' })
|
||||
)
|
||||
expect(api.runAiSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Query Agent 查 0 条时仍然是它回答,不触发 Legacy 二次检索', async () => {
|
||||
api.runAskWechatQuery.mockResolvedValue(
|
||||
answeredResult({ answer: '当前可读取的完整范围里没有找到相关记录。' })
|
||||
)
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
await submitQuery('BOBO 给我发过文件吗')
|
||||
|
||||
expect(await screen.findByText('当前可读取的完整范围里没有找到相关记录。')).toBeTruthy()
|
||||
expect(api.runAiSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Provider 不可用时给出明确文案,不静默回退成另一次检索', async () => {
|
||||
api.runAskWechatQuery.mockResolvedValue({
|
||||
engine: 'query-agent',
|
||||
status: 'provider_unavailable',
|
||||
message: '当前 AI 查询服务暂时不可用,请稍后再试。',
|
||||
diagnostics: { ...diagnostics, outcome: 'provider_failure' }
|
||||
})
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
await submitQuery('BOBO 最近说过什么')
|
||||
|
||||
expect(await screen.findByText('当前 AI 查询服务暂时不可用,请稍后再试。')).toBeTruthy()
|
||||
expect(api.runAiSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Runtime 不可恢复错误时接住主进程回退的 Legacy 结果', async () => {
|
||||
api.runAskWechatQuery.mockResolvedValue({
|
||||
engine: 'legacy',
|
||||
status: 'legacy',
|
||||
reason: 'runtime_error',
|
||||
result: makeSearchResult({ answer: 'Legacy 兜底答案' })
|
||||
})
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
await submitQuery('BOBO 最近说过什么')
|
||||
|
||||
expect(await screen.findByText('Legacy 兜底答案')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Query Agent 关闭时仍走 Legacy 检索(可回退)', async () => {
|
||||
api.getAskWechatConfig.mockResolvedValue({ queryAgentEnabled: false })
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
await submitQuery('我和测试会话最近聊了什么')
|
||||
|
||||
expect(await screen.findByText('测试搜索答案')).toBeTruthy()
|
||||
expect(api.runAskWechatQuery).not.toHaveBeenCalled()
|
||||
expect(api.runAiSearch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preload 契约缺失(测试 / 旧版本)时安全回退 Legacy', async () => {
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { ...api, getAskWechatConfig: undefined, runAskWechatQuery: undefined }
|
||||
})
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
await submitQuery('我和测试会话最近聊了什么')
|
||||
|
||||
expect(await screen.findByText('测试搜索答案')).toBeTruthy()
|
||||
expect(api.runAiSearch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据', () => {
|
||||
it('Query Agent 开启时保留搜索范围、隐藏时间范围,并提示时间写在问题里', async () => {
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
|
||||
expect(await screen.findByText('搜索范围')).toBeTruthy()
|
||||
// 开关是异步解析的:等提示出现,说明 Query Agent 已生效
|
||||
expect(await screen.findByText(/时间直接写在问题里/)).toBeTruthy()
|
||||
expect(screen.getAllByText('所有聊天记录').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('群聊专属')).toBeTruthy()
|
||||
expect(screen.getByText('单聊专属')).toBeTruthy()
|
||||
// 时间范围控件在 Query Agent 主路径下移除
|
||||
expect(screen.queryByText('时间范围')).toBeNull()
|
||||
expect(screen.queryByText('不限时间')).toBeNull()
|
||||
})
|
||||
|
||||
it('Query Agent 关闭时恢复 Legacy 的时间范围控件', async () => {
|
||||
api.getAskWechatConfig.mockResolvedValue({ queryAgentEnabled: false })
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
|
||||
expect(await screen.findByText('时间范围')).toBeTruthy()
|
||||
expect(screen.getByText('不限时间')).toBeTruthy()
|
||||
expect(screen.queryByText(/时间直接写在问题里/)).toBeNull()
|
||||
})
|
||||
|
||||
it('把界面选择的搜索范围传给 Query Agent', async () => {
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByText('群聊专属'))
|
||||
await submitQuery('最近谁聊过健身')
|
||||
|
||||
expect(api.runAskWechatQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '最近谁聊过健身',
|
||||
scope: expect.objectContaining({ scope: { kind: 'groups' } })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('展示真实统计与真实证据,不再显示「知识库已收录 0」', async () => {
|
||||
api.runAskWechatQuery.mockResolvedValue(
|
||||
answeredResult({
|
||||
answer: '张三最近提过健身。',
|
||||
evidence: [
|
||||
{
|
||||
messageRef: 'ref-1',
|
||||
conversationName: 'TraceMemo 交流群',
|
||||
conversationType: 'group',
|
||||
sender: '张三',
|
||||
timestamp: 1_787_650_302_000,
|
||||
messageType: 'text',
|
||||
text: '最近重新开始健身了',
|
||||
source: 'search_messages'
|
||||
}
|
||||
],
|
||||
stats: {
|
||||
tools: ['search_messages'],
|
||||
reads: { messageCount: 0, matchedCount: 12, overviewSourceCount: 0, evidenceCount: 1 },
|
||||
scope: { kind: 'groups', label: '群聊专属' },
|
||||
modelCallCount: 2,
|
||||
toolCallCount: 1,
|
||||
totalMs: 4034
|
||||
}
|
||||
})
|
||||
)
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
await submitQuery('最近谁聊过健身')
|
||||
|
||||
expect(await screen.findByText('张三最近提过健身。')).toBeTruthy()
|
||||
// 群消息证据必须能归属到具体群 + 成员
|
||||
expect(screen.getAllByText(/TraceMemo 交流群/).length).toBeGreaterThan(0)
|
||||
// 证据卡片把"编号 · 发送者"渲染在同一行内,用正则匹配文本内容
|
||||
expect(screen.getAllByText(/张三/).length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('最近重新开始健身了')).toBeTruthy()
|
||||
// 真实统计(不使用 Legacy 的"知识库已收录")
|
||||
expect(screen.getByText('群聊专属内查询')).toBeTruthy()
|
||||
expect(screen.getByText('使用 1 条证据')).toBeTruthy()
|
||||
expect(screen.queryByText(/知识库已收录/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AISearchWorkspace — 知识库新鲜度状态', () => {
|
||||
it('索引落后时显示「可用 · 待追新」并说明覆盖范围,而不是「已同步」', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue({
|
||||
...readyKnowledgeStatus,
|
||||
indexLatestAt: new Date('2026-08-26T11:37:24+08:00').getTime(),
|
||||
sourceLatestAt: new Date('2026-09-11T11:57:24+08:00').getTime()
|
||||
})
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
|
||||
// ready ≠ fresh:落后时必须说「可用 · 待追新」,并在描述里给出真实覆盖边界,
|
||||
// 绝不能用笼统的「已同步」把两件事混为一谈。
|
||||
expect(await screen.findByText('可用 · 待追新')).toBeTruthy()
|
||||
expect(screen.queryByText('已同步')).toBeNull()
|
||||
expect(screen.queryByText('可用 · 已追至最新')).toBeNull()
|
||||
expect(screen.getByText(/跨会话搜索目前只覆盖到 8\/26/)).toBeTruthy()
|
||||
expect(screen.getByText('最新索引')).toBeTruthy()
|
||||
expect(screen.getByText('8/26')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('索引已追平源数据最新时才显示「可用 · 已追至最新」', async () => {
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
|
||||
expect(await screen.findByText('可用 · 已追至最新')).toBeTruthy()
|
||||
expect(screen.queryByText('已同步')).toBeNull()
|
||||
expect(screen.queryByText(/跨会话搜索目前只覆盖到/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
const emitProgress = async (
|
||||
requestId: string,
|
||||
event: Partial<QueryAgentProgressEvent> & { stage: QueryAgentProgressEvent['stage'] }
|
||||
): Promise<void> => {
|
||||
await act(async () => {
|
||||
askWechatProgressListener?.(requestId, {
|
||||
elapsedMs: 0,
|
||||
at: Date.now(),
|
||||
modelCallCount: 1,
|
||||
toolCallCount: 0,
|
||||
...event
|
||||
} as QueryAgentProgressEvent)
|
||||
})
|
||||
}
|
||||
|
||||
describe('AISearchWorkspace — 查询进度与耗时拆解', () => {
|
||||
it('advances the visible stage from real Runtime progress instead of a static 4-step list', async () => {
|
||||
let release: ((value: unknown) => void) | undefined
|
||||
api.runAskWechatQuery.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
await userEvent.type(screen.getByRole('textbox'), '最近谁聊过健身')
|
||||
await userEvent.click(screen.getByRole('button', { name: /开始分析/ }))
|
||||
const requestId = api.runAskWechatQuery.mock.calls[0][0].requestId as string
|
||||
|
||||
await emitProgress(requestId, { stage: 'understanding' })
|
||||
expect(screen.getByRole('heading', { name: '正在理解你的问题' })).toBeTruthy()
|
||||
|
||||
// 跨会话范围(所有聊天记录)必须提前说明"范围较大",否则用户会以为卡住了。
|
||||
await emitProgress(requestId, { stage: 'searching', toolCallCount: 1 })
|
||||
expect(screen.getByRole('heading', { name: '正在搜索较大范围的聊天记录…' })).toBeTruthy()
|
||||
|
||||
await emitProgress(requestId, { stage: 'organizing_evidence', toolCallCount: 1 })
|
||||
expect(screen.getByRole('heading', { name: '正在整理找到的聊天记录' })).toBeTruthy()
|
||||
|
||||
await emitProgress(requestId, { stage: 'generating_answer', toolCallCount: 1 })
|
||||
expect(screen.getByRole('heading', { name: '正在生成回答' })).toBeTruthy()
|
||||
|
||||
// 进度里绝不能出现内部工具名 / SQL / 内部 id。
|
||||
expect(screen.queryByText(/search_messages|query_messages|SELECT|FTS/)).toBeNull()
|
||||
expect(screen.queryByText(/local:/)).toBeNull()
|
||||
|
||||
release?.(answeredResult())
|
||||
expect(await screen.findByText('你们的第一次聊天是一条问候。')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('uses the narrower wording for a single-conversation scope', async () => {
|
||||
let release: ((value: unknown) => void) | undefined
|
||||
api.runAskWechatQuery.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
await userEvent.click(screen.getByRole('button', { name: /当前会话/ }))
|
||||
await userEvent.type(screen.getByRole('textbox'), '最近聊了什么')
|
||||
await userEvent.click(screen.getByRole('button', { name: /开始分析/ }))
|
||||
const requestId = api.runAskWechatQuery.mock.calls[0][0].requestId as string
|
||||
|
||||
await emitProgress(requestId, { stage: 'searching', toolCallCount: 1 })
|
||||
|
||||
expect(screen.getByRole('heading', { name: '正在搜索聊天记录…' })).toBeTruthy()
|
||||
expect(screen.queryByRole('heading', { name: '正在搜索较大范围的聊天记录…' })).toBeNull()
|
||||
|
||||
release?.(answeredResult())
|
||||
expect(await screen.findByText('你们的第一次聊天是一条问候。')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('ignores progress that belongs to a different request', async () => {
|
||||
let release: ((value: unknown) => void) | undefined
|
||||
api.runAskWechatQuery.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
await submitQuery('最近谁聊过健身')
|
||||
|
||||
await emitProgress('stale-request-id', { stage: 'generating_answer' })
|
||||
|
||||
// 串台的进度会让用户看到一个根本不属于本次查询的阶段。
|
||||
expect(screen.queryByRole('heading', { name: '正在生成回答' })).toBeNull()
|
||||
expect(screen.getByRole('heading', { name: '正在准备查询' })).toBeTruthy()
|
||||
|
||||
release?.(answeredResult())
|
||||
expect(await screen.findByText('你们的第一次聊天是一条问候。')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('breaks the total duration down into AI and local query instead of a bare 48.0s', async () => {
|
||||
api.runAskWechatQuery.mockResolvedValue(
|
||||
answeredResult({
|
||||
stats: {
|
||||
...askStats(),
|
||||
totalMs: 19_548,
|
||||
timings: {
|
||||
totalMs: 19_548,
|
||||
modelMs: 3_892,
|
||||
localQueryMs: 15_648,
|
||||
modelDurationsMs: [1059, 2833],
|
||||
toolDurationsMs: [15_648]
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
await submitQuery('最近谁聊过健身')
|
||||
await screen.findByText('你们的第一次聊天是一条问候。')
|
||||
|
||||
const breakdown = screen.getByLabelText('本次查询耗时拆解')
|
||||
expect(breakdown.textContent).toContain('总耗时 19.5s')
|
||||
expect(breakdown.textContent).toContain('AI 3.9s')
|
||||
expect(breakdown.textContent).toContain('本地查询 15.6s')
|
||||
// 普通 UI 只出现用户能理解的名字,不出现 firstModelMs / toolTotalMs 这类工程字段。
|
||||
expect(breakdown.textContent).not.toMatch(/firstModelMs|toolTotalMs|finalModelMs/)
|
||||
})
|
||||
|
||||
it('does not render a timing breakdown when the runtime reported none', async () => {
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
await submitQuery('最近谁聊过健身')
|
||||
await screen.findByText('你们的第一次聊天是一条问候。')
|
||||
|
||||
expect(screen.queryByLabelText('本次查询耗时拆解')).toBeNull()
|
||||
expect(screen.queryByText(/总耗时/)).toBeNull()
|
||||
})
|
||||
|
||||
it('cancelling a query never touches the Knowledge sync abort scope', async () => {
|
||||
api.runAskWechatQuery.mockImplementation(() => new Promise(() => undefined))
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
await submitQuery('最近谁聊过健身')
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /取消分析/ }))
|
||||
|
||||
// 查询取消与索引取消必须是两套独立的 Abort scope,共用一个会互相误杀。
|
||||
expect(api.cancelKnowledgeIndex).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SEARCH_CACHE_KEY,
|
||||
SEARCH_HISTORY_KEY
|
||||
} from '../../src/renderer/src/components/search/searchUtils'
|
||||
import { decodeMessageRef } from '../../src/shared/local-query-api'
|
||||
import {
|
||||
aiSearchContact,
|
||||
aiSearchGroup,
|
||||
@@ -321,7 +322,7 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
|
||||
expect(api.runAiSearch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('passes the selected Evidence contact and timestamp to the conversation jump callback', async () => {
|
||||
it('passes the whole Evidence item with a decodable stable reference to the conversation jump callback', async () => {
|
||||
const onOpenEvidence = vi.fn()
|
||||
const item = makePipelineEvidence(1, aiSearchContact)
|
||||
api.runAiSearch.mockResolvedValue(makeSearchResult({ evidence: [item] }))
|
||||
@@ -330,7 +331,19 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
|
||||
await screen.findByText('E1 · 发送者 1')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '跳转到原聊天 ↗' }))
|
||||
expect(onOpenEvidence).toHaveBeenCalledWith(aiSearchContact, Math.floor(item.timestamp / 1000))
|
||||
|
||||
// 跳转必须拿到**稳定身份**(messageRef),而不是只靠"会话 + 秒级时间戳"猜位置:
|
||||
// 秒级时间无法定位同秒多条消息,也会因为 contact.md5 是合成 key 而跳错会话。
|
||||
expect(onOpenEvidence).toHaveBeenCalledTimes(1)
|
||||
const passed = onOpenEvidence.mock.calls[0][0] as {
|
||||
messageRef?: string
|
||||
contact: { md5: string }
|
||||
}
|
||||
expect(passed.contact.md5).toBe(aiSearchContact.md5)
|
||||
expect(decodeMessageRef(passed.messageRef)).toEqual({
|
||||
conversationId: aiSearchContact.md5,
|
||||
messageId: item.messageId
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the previous request Evidence before a refreshed request completes', async () => {
|
||||
@@ -579,15 +592,52 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
|
||||
expect(api.runAiSearch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
/**
|
||||
* Knowledge 状态语义矩阵。
|
||||
*
|
||||
* 必须把 FRESHNESS(追到源数据最新)与 COMPLETENESS(这一遍 pass 的进度)分开表达,
|
||||
* **不得**在落后时出现「已同步」。
|
||||
*/
|
||||
const STALE_INDEX_AT = new Date('2026-08-26T11:37:24+08:00').getTime()
|
||||
const SOURCE_LATEST_AT = new Date('2026-09-11T11:57:24+08:00').getTime()
|
||||
const makePass = (phase: 'full' | 'catchup' | 'backfill' | 'idle') => ({
|
||||
phase,
|
||||
cancellable: phase !== 'idle',
|
||||
startedAt: SOURCE_LATEST_AT,
|
||||
scannedMessages: 120,
|
||||
indexedMessages: 20,
|
||||
processedConversations: 3,
|
||||
totalConversations: 10,
|
||||
skippedConversations: 1,
|
||||
catchupConversations: 4,
|
||||
backfillConversations: 6,
|
||||
backfillCompletedConversations: 2,
|
||||
mainLoopLagMs: 4
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unavailable', '未建立'],
|
||||
['building', '建立中'],
|
||||
['syncing', '增量同步'],
|
||||
['ready', '已同步'],
|
||||
['error', '异常']
|
||||
] as const)('renders Knowledge state %s as %s', async (state, label) => {
|
||||
['unavailable', '未建立', {}],
|
||||
['building', '可用 · 正在补齐历史', {}],
|
||||
['syncing', '可用 · 正在追新', { pass: makePass('catchup') }],
|
||||
['syncing', '可用 · 正在补齐历史', { pass: makePass('full') }],
|
||||
// backfill(补历史缺口)与 full(首次建库)对用户是同一件事:都不是"追最新"。
|
||||
['syncing', '可用 · 正在补齐历史', { pass: makePass('backfill') }],
|
||||
[
|
||||
'ready',
|
||||
'可用 · 待追新',
|
||||
{ indexLatestAt: STALE_INDEX_AT, sourceLatestAt: SOURCE_LATEST_AT }
|
||||
],
|
||||
[
|
||||
'ready',
|
||||
'可用 · 已追至最新',
|
||||
{ indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT }
|
||||
],
|
||||
['cancelled', '可用 · 同步已取消', {}],
|
||||
['error', '可用 · 更新失败', {}]
|
||||
] as const)('renders Knowledge state %s as %s', async (state, label, overrides) => {
|
||||
api.getKnowledgeStatus.mockResolvedValue({
|
||||
...readyKnowledgeStatus,
|
||||
...overrides,
|
||||
state,
|
||||
lastError: state === 'error' ? 'Worker 异常' : undefined
|
||||
})
|
||||
@@ -595,11 +645,51 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
|
||||
|
||||
expect(await screen.findAllByText(new RegExp(`Knowledge ${label}`))).not.toHaveLength(0)
|
||||
if (state === 'error') expect(screen.getByText('Worker 异常')).toBeInTheDocument()
|
||||
// 无论哪种状态,「已同步」这种把两件事混为一谈的笼统说法都不允许出现。
|
||||
expect(screen.queryByText('已同步')).toBeNull()
|
||||
})
|
||||
|
||||
it('never claims the index is up to date when freshness cannot be confirmed', async () => {
|
||||
// 两个边界时间缺失(老库 / 运行态未上报)时无法判断是否追平。
|
||||
// 安全侧是「待追新」——**不能**把"无法确认"说成「已追至最新」。
|
||||
api.getKnowledgeStatus.mockResolvedValue({
|
||||
...readyKnowledgeStatus,
|
||||
indexLatestAt: null,
|
||||
sourceLatestAt: null
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
expect(await screen.findByText('Knowledge 可用 · 待追新')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/可用 · 已追至最新/)).toBeNull()
|
||||
expect(screen.queryByText('已同步')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not claim usability when the derived index is unavailable', async () => {
|
||||
// 没有分片却残留了历史计数时,不能说「可用 · …」——那是两句真话拼成的假话。
|
||||
api.getKnowledgeStatus.mockResolvedValue({
|
||||
...readyKnowledgeStatus,
|
||||
state: 'unavailable',
|
||||
indexedMessageCount: 0,
|
||||
indexedChunkCount: 0
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
expect(await screen.findByText('Knowledge 未建立')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/可用 · /)).toBeNull()
|
||||
})
|
||||
|
||||
it('starts Knowledge indexing and reflects the returned status', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue({ ...readyKnowledgeStatus, state: 'unavailable' })
|
||||
api.startKnowledgeIndex.mockResolvedValue({ ...readyKnowledgeStatus, state: 'syncing' })
|
||||
api.getKnowledgeStatus.mockResolvedValue({
|
||||
...readyKnowledgeStatus,
|
||||
state: 'unavailable',
|
||||
indexedMessageCount: 0,
|
||||
indexedChunkCount: 0
|
||||
})
|
||||
api.startKnowledgeIndex.mockResolvedValue({
|
||||
...readyKnowledgeStatus,
|
||||
state: 'syncing',
|
||||
pass: makePass('full')
|
||||
})
|
||||
const onNotice = vi.fn()
|
||||
renderWorkspace({ onNotice })
|
||||
await screen.findByText('Knowledge 未建立')
|
||||
@@ -607,7 +697,7 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
|
||||
|
||||
expect(api.startKnowledgeIndex).toHaveBeenCalledOnce()
|
||||
expect(onNotice).toHaveBeenCalledWith(expect.stringContaining('开始同步'))
|
||||
expect(await screen.findByText('Knowledge 增量同步')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Knowledge 可用 · 正在补齐历史')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('unsubscribes Knowledge and Progress listeners on unmount', () => {
|
||||
@@ -617,3 +707,213 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
|
||||
expect(progressUnsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Knowledge 卡片的布局契约(侧栏宽度不变,靠栅格而不是靠缩字号解决拥挤)。
|
||||
*
|
||||
* jsdom 不做布局计算,拿不到真实宽度,所以这里锁的是**布局所依赖的 DOM 契约**:
|
||||
* - 五段结构(HEADER / CURRENT PASS / DATABASE STATUS / CURRENT / ACTION)各自的可见性;
|
||||
* - 状态文案保持**单一文本节点**,换行只可能发生在「 · 」之后;
|
||||
* - 数值行是「label + value」成对结构,nowrap 挂在 value 上;
|
||||
* - 「取消同步」只在这一遍真的在跑时出现,且带不会被压扁的类。
|
||||
*
|
||||
* 刻意不写整份 CSS 快照:那种断言在调样式时全是噪声,也证明不了布局真的对。
|
||||
*/
|
||||
describe('Knowledge card layout contract', () => {
|
||||
const STALE_INDEX_AT = new Date('2026-08-26T11:37:24+08:00').getTime()
|
||||
const SOURCE_LATEST_AT = new Date('2026-09-11T11:57:24+08:00').getTime()
|
||||
|
||||
const makePass = (phase: 'full' | 'catchup' | 'backfill' | 'idle') => ({
|
||||
phase,
|
||||
cancellable: phase !== 'idle',
|
||||
startedAt: SOURCE_LATEST_AT,
|
||||
scannedMessages: 2_400,
|
||||
indexedMessages: 1_200,
|
||||
processedConversations: 400,
|
||||
totalConversations: 4_800,
|
||||
skippedConversations: 200,
|
||||
catchupConversations: 4,
|
||||
backfillConversations: 6,
|
||||
backfillCompletedConversations: 2,
|
||||
mainLoopLagMs: 0
|
||||
})
|
||||
|
||||
const status = (state: string, extra: Record<string, unknown> = {}): unknown => ({
|
||||
...readyKnowledgeStatus,
|
||||
state,
|
||||
...extra
|
||||
})
|
||||
|
||||
const findStateLabel = async (): Promise<HTMLElement> =>
|
||||
await screen.findByTestId('knowledge-state-label')
|
||||
const queryPassSection = (): HTMLElement | null => screen.queryByTestId('knowledge-pass-progress')
|
||||
|
||||
it('keeps the state label a single string that only wraps at 「 · 」', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue(
|
||||
status('ready', { indexLatestAt: STALE_INDEX_AT, sourceLatestAt: SOURCE_LATEST_AT })
|
||||
)
|
||||
renderWorkspace()
|
||||
|
||||
const label = await findStateLabel()
|
||||
// 卡片上的字符串必须与 label 函数逐字一致(侧栏外的详情行也用同一个函数)。
|
||||
// 折行位置由 CSS 的 word-break: keep-all 决定,DOM 里不做切分 —— 一旦被拆成
|
||||
// 多个元素,页面上就会出现「可用 · 正在」/「补齐历史」这种碎句。
|
||||
expect(label.textContent).toBe('可用 · 待追新')
|
||||
expect(label.childNodes).toHaveLength(1)
|
||||
expect(label).toHaveClass('ai-search-knowledge-state')
|
||||
})
|
||||
|
||||
it('keeps the header as one grid row with kicker, state and status dot', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue(
|
||||
status('ready', { indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT })
|
||||
)
|
||||
renderWorkspace()
|
||||
|
||||
const label = await findStateLabel()
|
||||
const heading = label.closest('.ai-search-knowledge-heading')
|
||||
expect(heading).not.toBeNull()
|
||||
expect(heading!.querySelector('.ai-search-knowledge-kicker')?.textContent).toBe('KNOWLEDGE BASE')
|
||||
expect(heading!.querySelector('.ai-search-knowledge-dot')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows the CURRENT PASS section only while a pass is really running', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue(
|
||||
status('unavailable', { indexedMessageCount: 0, indexedChunkCount: 0 })
|
||||
)
|
||||
const view = renderWorkspace()
|
||||
await screen.findByText('Knowledge 未建立')
|
||||
expect(queryPassSection()).toBeNull()
|
||||
view.unmount()
|
||||
|
||||
api.getKnowledgeStatus.mockResolvedValue(status('syncing', { pass: makePass('catchup') }))
|
||||
renderWorkspace()
|
||||
expect(await screen.findByTestId('knowledge-pass-progress')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the real two counters instead of a denominator-less fake progress', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue(status('syncing', { pass: makePass('catchup') }))
|
||||
renderWorkspace()
|
||||
|
||||
const progress = await screen.findByTestId('knowledge-pass-progress')
|
||||
expect(progress.textContent).toBe(
|
||||
`本轮新增索引 ${(1_200).toLocaleString()} 条 · 本轮已扫描 ${(2_400).toLocaleString()} 条`
|
||||
)
|
||||
// 同样是一个文本节点 + CSS 折行:不能被拆成多个元素(否则「 · 」会甩到行首)。
|
||||
expect(progress.childNodes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['catchup', '正在追最新消息:4 个会话有新内容'],
|
||||
['backfill', '后台补齐历史:2 / 6 个久未更新的会话'],
|
||||
['full', '正在建立索引:4,800 个会话']
|
||||
] as const)('describes the %s pass inside CURRENT PASS', async (phase, expected) => {
|
||||
api.getKnowledgeStatus.mockResolvedValue(status('syncing', { pass: makePass(phase) }))
|
||||
renderWorkspace()
|
||||
|
||||
expect(await screen.findByTestId('knowledge-pass-scope')).toHaveTextContent(expected)
|
||||
})
|
||||
|
||||
it('keeps every database status row a label/value pair with a non-wrapping value', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue(
|
||||
status('ready', { indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT })
|
||||
)
|
||||
renderWorkspace()
|
||||
await findStateLabel()
|
||||
|
||||
for (const text of ['已索引消息', '知识片段', '最新索引', '磁盘占用']) {
|
||||
const label = screen.getByText(text)
|
||||
expect(label).toHaveClass('ai-search-knowledge-label')
|
||||
const row = label.closest('.ai-search-knowledge-row')
|
||||
expect(row).not.toBeNull()
|
||||
// value 是 nowrap 的载体:数字一旦折行,这一行就不可读了。
|
||||
expect(row!.querySelector('.ai-search-knowledge-value')).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('gives the current conversation its own row and truncates the long name', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue(
|
||||
status('syncing', { pass: makePass('catchup'), currentConversationId: aiSearchGroup.md5 })
|
||||
)
|
||||
renderWorkspace()
|
||||
await findStateLabel()
|
||||
|
||||
const row = screen.getByText('当前会话').closest('.ai-search-knowledge-row')
|
||||
expect(row).not.toBeNull()
|
||||
// 群名 / 备注可以很长:必须省略而不是撑破侧栏。
|
||||
expect(row!.querySelector('.ai-search-knowledge-value')).toHaveClass(
|
||||
'ai-search-knowledge-value--truncate'
|
||||
)
|
||||
})
|
||||
|
||||
it('drops the current-conversation row once nothing is running', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue(
|
||||
status('ready', {
|
||||
indexLatestAt: SOURCE_LATEST_AT,
|
||||
sourceLatestAt: SOURCE_LATEST_AT,
|
||||
currentConversationId: aiSearchGroup.md5
|
||||
})
|
||||
)
|
||||
renderWorkspace()
|
||||
await findStateLabel()
|
||||
|
||||
expect(screen.queryByText('当前会话')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the main-loop lag in its own labelled row when it is measured', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue(
|
||||
status('syncing', { pass: { ...makePass('catchup'), mainLoopLagMs: 42 } })
|
||||
)
|
||||
renderWorkspace()
|
||||
await findStateLabel()
|
||||
|
||||
const row = screen.getByText('界面卡顿峰值').closest('.ai-search-knowledge-row')
|
||||
expect(row).not.toBeNull()
|
||||
expect(row!.querySelector('.ai-search-knowledge-value')?.textContent).toBe('42ms')
|
||||
})
|
||||
|
||||
it('keeps the cancel action on one line and only while running', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue(
|
||||
status('ready', { indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT })
|
||||
)
|
||||
const view = renderWorkspace()
|
||||
await findStateLabel()
|
||||
expect(screen.queryByTestId('knowledge-cancel-sync')).toBeNull()
|
||||
view.unmount()
|
||||
|
||||
api.getKnowledgeStatus.mockResolvedValue(status('syncing', { pass: makePass('catchup') }))
|
||||
renderWorkspace()
|
||||
|
||||
const cancel = await screen.findByTestId('knowledge-cancel-sync')
|
||||
expect(cancel).toHaveTextContent('取消同步')
|
||||
// 带 min-width 的类:按钮不会被主按钮挤到换行成「取消同」/「步」。
|
||||
expect(cancel).toHaveClass('ai-search-knowledge-cancel')
|
||||
expect(cancel.closest('.ai-search-knowledge-actions')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the cancelled state honest and free of a stale progress block', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue(status('cancelled', { pass: makePass('idle') }))
|
||||
renderWorkspace()
|
||||
|
||||
expect(await screen.findByText('Knowledge 可用 · 同步已取消')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('上一遍同步被取消,已建立的索引仍然可用;下次同步会从断点继续。')
|
||||
).toBeInTheDocument()
|
||||
// 取消之后不能残留一个假的"正在同步"。
|
||||
expect(queryPassSection()).toBeNull()
|
||||
expect(screen.queryByTestId('knowledge-cancel-sync')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the FRESH state free of the stale warning and the progress block', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue(
|
||||
status('ready', { indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT })
|
||||
)
|
||||
renderWorkspace()
|
||||
|
||||
expect(await screen.findByText('Knowledge 可用 · 已追至最新')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('后台增量同步不会影响原始微信聊天记录,也不会阻塞提问。')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText(/索引还没追上最新聊天/)).toBeNull()
|
||||
expect(queryPassSection()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user