From 3352936744670621a677aa3fd5d8a213ea13576e Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Tue, 18 Aug 2026 15:49:49 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0AISearch=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai-search-workspace-regression.test.tsx | 497 ++++++++++++++++++ tests/component/support/ai-search-fixtures.ts | 153 ++++++ 2 files changed, 650 insertions(+) create mode 100644 tests/component/ai-search-workspace-regression.test.tsx create mode 100644 tests/component/support/ai-search-fixtures.ts diff --git a/tests/component/ai-search-workspace-regression.test.tsx b/tests/component/ai-search-workspace-regression.test.tsx new file mode 100644 index 0000000..a93c7dc --- /dev/null +++ b/tests/component/ai-search-workspace-regression.test.tsx @@ -0,0 +1,497 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AISearchWorkspace } from '../../src/renderer/src/components/search/AISearchWorkspace' +import { + SEARCH_CACHE_KEY, + SEARCH_HISTORY_KEY, + buildSearchCacheKey +} from '../../src/renderer/src/components/search/searchUtils' +import { + aiSearchContact, + aiSearchGroup, + makeCacheRecord, + makePipelineEvidence, + makeSearchResult +} from './support/ai-search-fixtures' + +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() +} + +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 +} + +let knowledgeListener: ((status: typeof readyKnowledgeStatus) => void) | undefined +let progressListener: ((progress: Record) => void) | undefined +let knowledgeUnsubscribe: ReturnType +let progressUnsubscribe: ReturnType + +const makeProps = (overrides: Record = {}) => ({ + 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(), + ...overrides +}) + +const renderWorkspace = (overrides: Record = {}) => + render() + +const submitQuery = async (query = '测试搜索问题') => { + const user = userEvent.setup() + await user.type(screen.getByRole('textbox'), query) + await user.click(screen.getByRole('button', { name: /开始分析/ })) + return user +} + +const emitProgress = async (progress: Record) => { + await act(async () => { + progressListener?.(progress) + }) +} + +beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + vi.clearAllMocks() + knowledgeListener = undefined + progressListener = undefined + knowledgeUnsubscribe = vi.fn() + progressUnsubscribe = vi.fn() + Object.defineProperty(window, 'api', { configurable: true, value: api }) + + api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } }) + api.getAppLogPath.mockResolvedValue('') + api.getKnowledgeStatus.mockResolvedValue(readyKnowledgeStatus) + api.onKnowledgeStatus.mockImplementation((listener: typeof knowledgeListener) => { + knowledgeListener = listener + return knowledgeUnsubscribe + }) + api.onAiSearchProgress.mockImplementation((listener: typeof progressListener) => { + progressListener = listener + return progressUnsubscribe + }) + 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.runAiSearch.mockResolvedValue(makeSearchResult()) +}) + +describe('AISearchWorkspace regression coverage before decomposition', () => { + it('preserves conversation scope, conversationId, range and the UI time override in a Search Request', async () => { + renderWorkspace() + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /当前会话/ })) + await user.click(screen.getByRole('button', { name: '近 30 天' })) + await submitQuery('我和测试会话最近聊了什么') + + await screen.findByText('测试搜索答案') + expect(api.runAiSearch).toHaveBeenCalledWith( + expect.objectContaining({ + text: '我和测试会话最近聊了什么', + scope: 'conversation', + conversationId: aiSearchContact.md5, + range: '30d', + timeRangeOverride: expect.objectContaining({ + label: '近 30 天', + source: 'user_selected' + }) + }) + ) + }) + + it('guards duplicate submissions while the current Search Request is still running', async () => { + let resolveSearch: ((value: unknown) => void) | undefined + api.runAiSearch.mockImplementation( + () => + new Promise((resolve) => { + resolveSearch = resolve + }) + ) + renderWorkspace() + const user = await submitQuery() + const form = screen.getByRole('textbox').closest('form') as HTMLFormElement + fireEvent.submit(form) + fireEvent.submit(form) + expect(api.runAiSearch).toHaveBeenCalledOnce() + + resolveSearch?.(makeSearchResult({ requestId: api.runAiSearch.mock.calls[0][0].requestId })) + await screen.findByText('测试搜索答案') + expect(screen.getByRole('button', { name: '新问题' })).toBeInTheDocument() + void user + }) + + it('renders a failed retrieval without producing a Summary', async () => { + api.runAiSearch.mockResolvedValue( + makeSearchResult({ + status: 'failed', + error: 'Knowledge Worker 失败', + errorStage: 'knowledge_searching' + }) + ) + renderWorkspace() + await submitQuery() + + expect(await screen.findByText('Knowledge Worker 失败')).toBeInTheDocument() + expect(screen.queryByText('测试搜索答案')).not.toBeInTheDocument() + }) + + it('renders an AI failure as partial evidence without treating it as a successful Summary', async () => { + api.runAiSearch.mockResolvedValue( + makeSearchResult({ + status: 'ai_failed', + error: 'Provider 返回 429', + evidence: [makePipelineEvidence(1)] + }) + ) + renderWorkspace() + await submitQuery() + + expect(await screen.findByText('证据已找到,但 AI 暂时无法生成回答')).toBeInTheDocument() + expect(screen.getByText('E1 · 发送者 1')).toBeInTheDocument() + expect(screen.queryByText('测试搜索答案')).not.toBeInTheDocument() + }) + + it('handles an IPC rejection from the Search Worker as an insufficient result', async () => { + api.runAiSearch.mockRejectedValue(new Error('Worker IPC 连接断开')) + renderWorkspace() + await submitQuery() + + expect(await screen.findByText('Worker IPC 连接断开')).toBeInTheDocument() + expect(screen.getByText('检索反馈')).toBeInTheDocument() + }) + + it.each([0, 1, 8])('keeps stable E numbering for %s Final Evidence items', async (count) => { + const evidence = Array.from({ length: count }, (_, index) => makePipelineEvidence(index + 1)) + api.runAiSearch.mockResolvedValue(makeSearchResult({ evidence })) + renderWorkspace() + await submitQuery(`证据数量 ${count}`) + + if (count === 0) { + expect(await screen.findByText('等待检索结果')).toBeInTheDocument() + return + } + await screen.findByText(`E1 · 发送者 1`) + expect(screen.getByText(`E${count} · 发送者 ${count}`)).toBeInTheDocument() + expect(screen.queryByText(`E${count + 1} · 发送者 ${count + 1}`)).not.toBeInTheDocument() + }) + + it('loads more Evidence from the current collection without calling runAiSearch again', async () => { + const evidenceCollection = Array.from({ length: 9 }, (_, index) => makePipelineEvidence(index + 1)) + api.runAiSearch.mockResolvedValue( + makeSearchResult({ evidence: evidenceCollection.slice(0, 8), evidenceCollection }) + ) + renderWorkspace() + await submitQuery() + await screen.findByText('E8 · 发送者 8') + expect(api.runAiSearch).toHaveBeenCalledOnce() + + await userEvent.click(screen.getByRole('button', { name: '加载更多证据' })) + expect(screen.getByText('E9 · 发送者 9')).toBeInTheDocument() + expect(api.runAiSearch).toHaveBeenCalledOnce() + }) + + it('passes the selected Evidence contact and timestamp to the conversation jump callback', async () => { + const onOpenEvidence = vi.fn() + const item = makePipelineEvidence(1, aiSearchContact) + api.runAiSearch.mockResolvedValue(makeSearchResult({ evidence: [item] })) + renderWorkspace({ onOpenEvidence }) + await submitQuery() + await screen.findByText('E1 · 发送者 1') + + await userEvent.click(screen.getByRole('button', { name: '跳转到原聊天 ↗' })) + expect(onOpenEvidence).toHaveBeenCalledWith(aiSearchContact, Math.floor(item.timestamp / 1000)) + }) + + it('clears the previous request Evidence before a refreshed request completes', async () => { + const firstEvidence = makePipelineEvidence(1) + const secondEvidence = makePipelineEvidence(2) + let resolveSecond: ((value: unknown) => void) | undefined + api.runAiSearch + .mockResolvedValueOnce(makeSearchResult({ evidence: [firstEvidence] })) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve + }) + ) + renderWorkspace() + const user = await submitQuery('第一轮问题') + await screen.findByText('E1 · 发送者 1') + await user.click(screen.getByRole('button', { name: '刷新数据' })) + + await waitFor(() => expect(api.runAiSearch).toHaveBeenCalledTimes(2)) + expect(screen.queryByText('E1 · 发送者 1')).not.toBeInTheDocument() + resolveSecond?.(makeSearchResult({ requestId: api.runAiSearch.mock.calls[1][0].requestId, evidence: [secondEvidence] })) + expect(await screen.findByText('E2 · 发送者 2')).toBeInTheDocument() + }) + + it('updates Progress and Agent Trace for the active request only', async () => { + let resolveSearch: ((value: unknown) => void) | undefined + api.runAiSearch.mockImplementation( + () => + new Promise((resolve) => { + resolveSearch = resolve + }) + ) + renderWorkspace() + await submitQuery() + const requestId = api.runAiSearch.mock.calls[0][0].requestId as string + + await emitProgress({ + requestId, + stage: 'search_plan_ready', + status: 'running', + message: '正在生成搜索计划', + plan: makeSearchResult().plan, + agentTrace: { + sequence: 1, + event: 'agentDecision', + label: '使用受控检索' + } + }) + expect(screen.getByText('正在生成搜索计划')).toBeInTheDocument() + expect(screen.getByText(/使用受控检索/)).toBeInTheDocument() + + await emitProgress({ + requestId: 'stale-request', + stage: 'ai_generating', + status: 'running', + message: '不应显示的旧请求进度' + }) + expect(screen.queryByText('不应显示的旧请求进度')).not.toBeInTheDocument() + + resolveSearch?.(makeSearchResult({ requestId, agentTrace: [] })) + await screen.findByText('测试搜索答案') + expect(screen.queryByText('不应显示的旧请求进度')).not.toBeInTheDocument() + }) + + it('clears Progress and Agent Trace when an active request is cancelled', async () => { + api.runAiSearch.mockImplementation(() => new Promise(() => undefined)) + renderWorkspace() + await submitQuery() + const requestId = api.runAiSearch.mock.calls[0][0].requestId as string + await emitProgress({ + requestId, + stage: 'knowledge_searching', + status: 'running', + message: '正在读取知识库' + }) + expect(screen.getByText('正在读取知识库')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: /取消分析/ })) + expect(api.cancelAiSearch).toHaveBeenCalledWith(requestId) + expect(screen.queryByText('正在读取知识库')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /开始分析/ })).toBeEnabled() + }) + + it('requires consent before the provider request and authorizes successfully', async () => { + api.getAiSearchProviderStatus.mockResolvedValue({ + configured: true, + requiresConsent: true, + providerId: 'remote-provider', + providerName: 'Remote Provider', + recipient: 'remote@example.test' + }) + api.runAiSearch.mockResolvedValue(makeSearchResult()) + renderWorkspace() + await submitQuery() + expect(await screen.findByRole('dialog', { name: '确认发送本次搜索资料' })).toBeInTheDocument() + expect(api.runAiSearch).not.toHaveBeenCalled() + + await userEvent.click(screen.getByRole('button', { name: '继续并发送' })) + await screen.findByText('测试搜索答案') + expect(api.authorizeAiSearchExternalProvider).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: 'remote-provider', + recipient: 'remote@example.test' + }) + ) + expect(api.runAiSearch).toHaveBeenCalledOnce() + expect(screen.queryByRole('dialog', { name: '确认发送本次搜索资料' })).not.toBeInTheDocument() + }) + + it('does not start Search when the user rejects consent', async () => { + api.getAiSearchProviderStatus.mockResolvedValue({ + configured: true, + requiresConsent: true, + providerId: 'remote-provider', + recipient: 'remote@example.test' + }) + const onNotice = vi.fn() + renderWorkspace({ onNotice }) + await submitQuery() + await userEvent.click(screen.getByRole('button', { name: '取消' })) + + expect(api.authorizeAiSearchExternalProvider).not.toHaveBeenCalled() + expect(api.runAiSearch).not.toHaveBeenCalled() + expect(onNotice).toHaveBeenCalledWith(expect.stringContaining('未执行检索')) + }) + + it('handles provider status errors without leaving a Consent Dialog or starting Search', async () => { + api.getAiSearchProviderStatus.mockRejectedValue(new Error('Provider 状态读取失败')) + const onNotice = vi.fn() + renderWorkspace({ onNotice }) + await submitQuery() + + await waitFor(() => expect(onNotice).toHaveBeenCalledWith(expect.stringContaining('未执行'))) + expect(api.authorizeAiSearchExternalProvider).not.toHaveBeenCalled() + expect(api.runAiSearch).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog', { name: '确认发送本次搜索资料' })).not.toBeInTheDocument() + }) + + it('closes the consent resolver when the active Search is cancelled after authorization', async () => { + api.getAiSearchProviderStatus.mockResolvedValue({ + configured: true, + requiresConsent: true, + providerId: 'remote-provider', + recipient: 'remote@example.test' + }) + api.runAiSearch.mockImplementation(() => new Promise(() => undefined)) + renderWorkspace() + await submitQuery() + await userEvent.click(screen.getByRole('button', { name: '继续并发送' })) + await screen.findByRole('button', { name: /取消分析/ }) + await userEvent.click(screen.getByRole('button', { name: /取消分析/ })) + + expect(screen.queryByRole('dialog', { name: '确认发送本次搜索资料' })).not.toBeInTheDocument() + expect(api.cancelAiSearch).toHaveBeenCalledOnce() + }) + + it('restores a cached history query with its original conversation scope and range', async () => { + const query = '恢复群聊历史' + localStorage.setItem( + SEARCH_CACHE_KEY, + JSON.stringify([ + makeCacheRecord({ + query, + scope: 'conversation', + contactMd5: aiSearchContact.md5, + range: 'all', + answer: '恢复后的答案', + evidence: [makePipelineEvidence(1)] + }) + ]) + ) + localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify([query])) + renderWorkspace() + await userEvent.click(screen.getByRole('button', { name: /历史提问/ })) + await userEvent.click(screen.getByRole('button', { name: query })) + + expect(await screen.findByText('恢复后的答案')).toBeInTheDocument() + expect(screen.getByText(/当前会话 · 测试会话/)).toBeInTheDocument() + expect(screen.getByText('全部历史')).toBeInTheDocument() + expect(api.runAiSearch).not.toHaveBeenCalled() + }) + + it('supports legacy cache records without an Evidence Collection and without Evidence', async () => { + const query = '没有证据的缓存' + localStorage.setItem( + SEARCH_CACHE_KEY, + JSON.stringify([ + makeCacheRecord({ query, answer: '只有摘要的缓存', evidence: [] }) + ]) + ) + renderWorkspace() + await submitQuery(query) + + expect(await screen.findByText('只有摘要的缓存')).toBeInTheDocument() + expect(screen.getByText('等待检索结果')).toBeInTheDocument() + expect(api.runAiSearch).not.toHaveBeenCalled() + }) + + it('does not reuse the previous successful result for a new query', async () => { + api.runAiSearch + .mockResolvedValueOnce(makeSearchResult({ answer: '第一轮答案', evidence: [makePipelineEvidence(1)] })) + .mockResolvedValueOnce(makeSearchResult({ answer: '第二轮答案', evidence: [makePipelineEvidence(2)] })) + renderWorkspace() + const user = await submitQuery('第一轮问题') + await screen.findByText('第一轮答案') + await user.click(screen.getByRole('button', { name: '新问题' })) + await user.type(screen.getByRole('textbox'), '第二轮问题') + await user.click(screen.getByRole('button', { name: /开始分析/ })) + + await screen.findByText('第二轮答案') + expect(screen.queryByText('第一轮答案')).not.toBeInTheDocument() + expect(api.runAiSearch).toHaveBeenCalledTimes(2) + }) + + it.each([ + ['unavailable', '未建立'], + ['building', '建立中'], + ['syncing', '增量同步'], + ['ready', '已同步'], + ['error', '异常'] + ] as const)('renders Knowledge state %s as %s', async (state, label) => { + api.getKnowledgeStatus.mockResolvedValue({ + ...readyKnowledgeStatus, + state, + lastError: state === 'error' ? 'Worker 异常' : undefined + }) + renderWorkspace() + + expect(await screen.findAllByText(new RegExp(`Knowledge ${label}`))).not.toHaveLength(0) + if (state === 'error') expect(screen.getByText('Worker 异常')).toBeInTheDocument() + }) + + it('starts Knowledge indexing and reflects the returned status', async () => { + api.getKnowledgeStatus.mockResolvedValue({ ...readyKnowledgeStatus, state: 'unavailable' }) + api.startKnowledgeIndex.mockResolvedValue({ ...readyKnowledgeStatus, state: 'syncing' }) + const onNotice = vi.fn() + renderWorkspace({ onNotice }) + await screen.findByText('Knowledge 未建立') + await userEvent.click(screen.getByRole('button', { name: '建立本地知识库' })) + + expect(api.startKnowledgeIndex).toHaveBeenCalledOnce() + expect(onNotice).toHaveBeenCalledWith(expect.stringContaining('开始同步')) + expect(await screen.findByText('Knowledge 增量同步')).toBeInTheDocument() + }) + + it('unsubscribes Knowledge and Progress listeners on unmount', () => { + const view = renderWorkspace() + view.unmount() + expect(knowledgeUnsubscribe).toHaveBeenCalledOnce() + expect(progressUnsubscribe).toHaveBeenCalledOnce() + }) +}) diff --git a/tests/component/support/ai-search-fixtures.ts b/tests/component/support/ai-search-fixtures.ts new file mode 100644 index 0000000..96ea7f7 --- /dev/null +++ b/tests/component/support/ai-search-fixtures.ts @@ -0,0 +1,153 @@ +import type { Contact } from '../../../src/shared/types' +import { buildSearchCacheKey } from '../../../src/renderer/src/components/search/searchUtils' + +export const aiSearchContact: Contact = { + md5: 'fixture-contact', + m_nsUsrName: 'wxid_fixture', + m_nsNickName: '测试会话', + wechatNickname: 'Fixture User', + remark: '测试联系人', + type: 'user' +} + +export const aiSearchGroup: Contact = { + md5: 'fixture-group', + m_nsUsrName: 'fixture-group@chatroom', + m_nsNickName: '测试群聊', + type: 'group' +} + +export const makePipelineEvidence = (index: number, conversation = aiSearchContact) => ({ + id: `E${index}`, + conversationId: conversation.md5, + conversationName: conversation.m_nsNickName, + conversationType: conversation.type, + messageId: `message-${index}`, + sender: `发送者 ${index}`, + senderId: `sender-${index}`, + timestamp: 1_700_000_000_000 + index * 1_000, + text: `证据 ${index}` +}) + +export const makeSearchResult = ({ + requestId = 'request-1', + status = 'completed', + answer = '测试搜索答案', + evidence = [], + evidenceCollection = evidence, + agentTrace = [], + error, + errorStage +}: { + requestId?: string + status?: 'completed' | 'no_evidence' | 'retrieval_incomplete' | 'ai_failed' | 'failed' | 'cancelled' + answer?: string + evidence?: ReturnType[] + evidenceCollection?: ReturnType[] + agentTrace?: Record[] + error?: string + errorStage?: string +} = {}) => + ({ + requestId, + status, + answer, + plan: { + intent: 'global_topic_search', + keywords: ['测试'], + variants: ['测试'], + source: 'local', + scopeLabel: '所有聊天记录', + rangeLabel: '近 30 天', + timeRange: { + startTime: 1_699_000_000, + label: '近 30 天', + reason: '测试', + source: 'ui' + }, + contactNames: [] + }, + knowledge: { + source: 'knowledge', + state: 'ready', + indexedMessageCount: 20, + indexedChunkCount: 4, + totalMessages: 20, + voiceCoverage: undefined + }, + candidateEvidenceCount: evidenceCollection.length, + retrieval: { + intent: 'global_topic_search', + timeRange: { + startTime: 1_699_000_000, + label: '近 30 天', + reason: '测试', + source: 'ui' + }, + retrievalMode: 'global_fts', + candidateCount: evidenceCollection.length, + uniqueCandidateCount: evidenceCollection.length, + sourceCoverage: 'complete', + isComplete: true, + fallbackUsed: false, + suspicious: false + }, + evidence, + evidenceCollection, + contextEvidenceCount: evidence.length, + aggregation: { + messageCount: evidenceCollection.length, + peopleCount: evidenceCollection.length ? 1 : 0, + conversationCount: evidenceCollection.length ? 1 : 0, + people: [], + conversations: [] + }, + agent: { + mode: 'agent', + toolCalls: agentTrace.length, + trace: agentTrace + }, + citationValidation: { status: 'valid', invalidCitationIds: [] }, + timings: {}, + elapsedMs: 12, + error, + errorStage + }) as never + +export const makeCacheRecord = ({ + query, + scope = 'global', + contactMd5 = '', + range = '30d', + answer = '缓存答案', + evidence = [] +}: { + query: string + scope?: 'global' | 'groups' | 'contacts' | 'conversation' + contactMd5?: string + range?: 'today' | '7d' | '30d' | 'all' + answer?: string + evidence?: ReturnType[] +}) => ({ + version: 3 as const, + key: buildSearchCacheKey(scope, contactMd5, range, query), + createdAt: 1_700_000_000_000, + answer, + evidence: evidence.map((item) => ({ + evidenceId: item.id, + contact: scope === 'conversation' ? aiSearchContact : aiSearchContact, + message: { + id: item.messageId, + from: item.senderId, + type: '检索消息', + datetime: new Date(item.timestamp).toLocaleString('zh-CN', { hour12: false }), + content: item.text, + isSender: false, + name: item.sender, + senderId: item.senderId, + createTime: Math.floor(item.timestamp / 1000) + } + })), + senderNames: Object.fromEntries(evidence.map((item) => [item.senderId, item.sender])), + messageCount: evidence.length +})