feat: 完成 UI 基础层与核心页面迁移

This commit is contained in:
Wxw-Gu
2026-08-19 17:43:07 +08:00
parent a72ae49e2c
commit e9cc996ebb
62 changed files with 4279 additions and 5178 deletions
@@ -0,0 +1,78 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import {
AISearchComposer,
type AISearchComposerProps
} from '../../src/renderer/src/components/search/AISearchComposer'
const renderComposer = (
overrides: Partial<AISearchComposerProps> = {}
): { props: AISearchComposerProps } => {
const props: AISearchComposerProps = {
query: '当前问题',
sourceLabel: '所有聊天记录',
rangeLabel: '近 30 天',
history: ['历史问题一'],
historyOpen: false,
loading: false,
knowledgeSyncing: false,
onQueryChange: vi.fn(),
onHistoryOpenChange: vi.fn(),
onRestoreHistory: vi.fn(),
onRemoveHistory: vi.fn(),
onSubmit: vi.fn(),
onCancel: vi.fn(),
...overrides
}
render(<AISearchComposer {...props} />)
return { props }
}
describe('AISearchComposer', () => {
it('submits with the form or Enter while preserving Shift+Enter and IME composition', async () => {
const user = userEvent.setup()
const { props } = renderComposer()
const textbox = screen.getByRole('textbox')
await user.click(screen.getByRole('button', { name: /开始分析/ }))
expect(props.onSubmit).toHaveBeenCalledOnce()
fireEvent.keyDown(textbox, { key: 'Enter' })
expect(props.onSubmit).toHaveBeenCalledTimes(2)
fireEvent.keyDown(textbox, { key: 'Enter', shiftKey: true })
fireEvent.keyDown(textbox, { key: 'Enter', isComposing: true })
expect(props.onSubmit).toHaveBeenCalledTimes(2)
})
it('updates the controlled query and routes loading cancellation', async () => {
const user = userEvent.setup()
const { props } = renderComposer({ loading: true })
await user.type(screen.getByRole('textbox'), '新')
expect(props.onQueryChange).toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: /取消分析/ }))
expect(props.onCancel).toHaveBeenCalledOnce()
expect(props.onSubmit).not.toHaveBeenCalled()
})
it('routes history restore, delete, and close interactions', async () => {
const user = userEvent.setup()
const { props } = renderComposer({ historyOpen: true })
await user.click(screen.getByRole('button', { name: '历史问题一' }))
expect(props.onRestoreHistory).toHaveBeenCalledWith('历史问题一')
await user.click(screen.getByRole('button', { name: '删除历史问题:历史问题一' }))
expect(props.onRemoveHistory).toHaveBeenCalledWith('历史问题一')
await user.click(screen.getByRole('button', { name: '关闭历史提问' }))
expect(props.onHistoryOpenChange).toHaveBeenCalledWith(false)
})
it('disables submission while Knowledge is syncing', () => {
renderComposer({ knowledgeSyncing: true })
expect(screen.getByRole('button', { name: /同步中,暂不可分析/ })).toBeDisabled()
})
})
@@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { AISearchEvidencePanel } from '../../src/renderer/src/components/search/AISearchEvidencePanel'
import type { EvidenceItem } from '../../src/renderer/src/components/search/searchTypes'
import { makeCacheRecord, makePipelineEvidence } from './support/ai-search-fixtures'
const evidence = makeCacheRecord({
query: '证据测试',
evidence: [makePipelineEvidence(1)]
}).evidence as EvidenceItem[]
const renderPanel = (
overrides: Partial<React.ComponentProps<typeof AISearchEvidencePanel>> = {}
): React.ComponentProps<typeof AISearchEvidencePanel> => {
const props: React.ComponentProps<typeof AISearchEvidencePanel> = {
evidence,
collectionCount: 2,
selectedEvidence: 0,
evidenceFlash: { index: -1, nonce: 0 },
senderNames: { 'sender-1': '发送者 1' },
hasMoreEvidence: true,
onFocusEvidence: vi.fn(),
onJumpToEvidence: vi.fn(),
onLoadMoreEvidence: vi.fn(),
setEvidenceCardRef: vi.fn(),
...overrides
}
render(<AISearchEvidencePanel {...props} />)
return props
}
describe('AISearchEvidencePanel', () => {
it('renders stable Evidence labels and routes focus, jump, and load-more callbacks', async () => {
const user = userEvent.setup()
const props = renderPanel()
expect(screen.getByText('1/2 条样本')).toBeVisible()
expect(screen.getByText(/E1 · 发送者 1/)).toBeVisible()
expect(screen.getByText('证据 1')).toBeVisible()
await user.click(screen.getByRole('button', { name: '选择证据 E1,发送者 1' }))
expect(props.onFocusEvidence).toHaveBeenCalledWith(0)
await user.click(screen.getByRole('button', { name: '跳转到原聊天 ↗' }))
expect(props.onJumpToEvidence).toHaveBeenCalledWith(0)
await user.click(screen.getByRole('button', { name: '加载更多证据' }))
expect(props.onLoadMoreEvidence).toHaveBeenCalledOnce()
})
it('supports keyboard focus selection and the focus-flash state', async () => {
const user = userEvent.setup()
const props = renderPanel({ evidenceFlash: { index: 0, nonce: 1 } })
const card = screen.getByText(/E1 · 发送者 1/).closest('article')
const selection = screen.getByRole('button', { name: '选择证据 E1,发送者 1' })
expect(card).toHaveClass('ai-search-evidence-focus-flash')
selection.focus()
await user.keyboard('{Enter}')
expect(props.onFocusEvidence).toHaveBeenCalledWith(0)
})
it('uses the shared EmptyState when no Evidence is visible', () => {
renderPanel({ evidence: [], collectionCount: 0, hasMoreEvidence: false })
expect(screen.getByText('等待检索结果')).toBeVisible()
expect(screen.queryByRole('button', { name: '加载更多证据' })).not.toBeInTheDocument()
})
})
@@ -132,4 +132,26 @@ describe('useExternalProviderConsent', () => {
expect(resolved).toHaveBeenCalledOnce()
expect(resolved).toHaveBeenCalledWith(true)
})
it.each([true, false])(
'settles the previous pending request when a new request replaces it, then resolves %s',
async (secondDecision) => {
const { result } = renderHook(() => useExternalProviderConsent())
let first!: Promise<boolean>
let second!: Promise<boolean>
act(() => {
first = result.current.requestExternalProviderConsent('First', 'first-recipient')
second = result.current.requestExternalProviderConsent('Second', 'second-recipient')
})
await expect(first).resolves.toBe(false)
expect(result.current.externalProviderConsent).toEqual({
providerName: 'Second',
recipient: 'second-recipient'
})
act(() => result.current.settleExternalProviderConsent(secondDecision))
await expect(second).resolves.toBe(secondDecision)
}
)
})
@@ -4,8 +4,7 @@ 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
SEARCH_HISTORY_KEY
} from '../../src/renderer/src/components/search/searchUtils'
import {
aiSearchContact,
@@ -45,12 +44,11 @@ const readyKnowledgeStatus = {
shmBytes: 32
}
let knowledgeListener: ((status: typeof readyKnowledgeStatus) => void) | undefined
let progressListener: ((progress: Record<string, unknown>) => void) | undefined
let knowledgeUnsubscribe: ReturnType<typeof vi.fn>
let progressUnsubscribe: ReturnType<typeof vi.fn>
const makeProps = (overrides: Record<string, unknown> = {}) => ({
const makeProps = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
contacts: [aiSearchContact, aiSearchGroup],
selectedContact: aiSearchContact,
dbReady: true,
@@ -68,17 +66,17 @@ const makeProps = (overrides: Record<string, unknown> = {}) => ({
...overrides
})
const renderWorkspace = (overrides: Record<string, unknown> = {}) =>
const renderWorkspace = (overrides: Record<string, unknown> = {}): ReturnType<typeof render> =>
render(<AISearchWorkspace {...(makeProps(overrides) as never)} />)
const submitQuery = async (query = '测试搜索问题') => {
const submitQuery = async (query = '测试搜索问题'): Promise<ReturnType<typeof userEvent.setup>> => {
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<string, unknown>) => {
const emitProgress = async (progress: Record<string, unknown>): Promise<void> => {
await act(async () => {
progressListener?.(progress)
})
@@ -88,7 +86,6 @@ beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.clearAllMocks()
knowledgeListener = undefined
progressListener = undefined
knowledgeUnsubscribe = vi.fn()
progressUnsubscribe = vi.fn()
@@ -97,10 +94,7 @@ beforeEach(() => {
api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } })
api.getAppLogPath.mockResolvedValue('')
api.getKnowledgeStatus.mockResolvedValue(readyKnowledgeStatus)
api.onKnowledgeStatus.mockImplementation((listener: typeof knowledgeListener) => {
knowledgeListener = listener
return knowledgeUnsubscribe
})
api.onKnowledgeStatus.mockImplementation(() => knowledgeUnsubscribe)
api.onAiSearchProgress.mockImplementation((listener: typeof progressListener) => {
progressListener = listener
return progressUnsubscribe
@@ -193,6 +187,55 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(screen.queryByText('测试搜索答案')).not.toBeInTheDocument()
})
it.each([
['no_evidence', '近 30 天内没有找到与问题相关的聊天消息。'],
['retrieval_incomplete', '证据已就绪'],
['failed', '本地检索失败'],
['ai_failed', '证据已找到,但 AI 暂时无法生成回答']
] as const)('does not persist History or Cache for %s', async (status, expectedText) => {
api.runAiSearch.mockResolvedValue(
makeSearchResult({
status,
error: status === 'no_evidence' ? '应被忽略' : expectedText,
evidence: [makePipelineEvidence(1)]
})
)
renderWorkspace()
await submitQuery(`${status} 问题`)
expect(await screen.findByText(expectedText)).toBeInTheDocument()
expect(localStorage.getItem(SEARCH_HISTORY_KEY)).toBeNull()
expect(localStorage.getItem(SEARCH_CACHE_KEY)).toBeNull()
expect(sessionStorage.length).toBe(0)
})
it('applies common Evidence state before rejecting a completed result without an answer', async () => {
api.runAiSearch.mockResolvedValue({
...makeSearchResult({ evidence: [makePipelineEvidence(1)] }),
answer: undefined
})
renderWorkspace()
await submitQuery('缺少回答')
expect(await screen.findByText('搜索任务未返回回答')).toBeInTheDocument()
expect(screen.getByText('E1 · 发送者 1')).toBeInTheDocument()
expect(localStorage.getItem(SEARCH_HISTORY_KEY)).toBeNull()
expect(localStorage.getItem(SEARCH_CACHE_KEY)).toBeNull()
})
it('keeps an explicitly empty Evidence Collection separate from Final Evidence', async () => {
api.runAiSearch.mockResolvedValue(
makeSearchResult({ evidence: [makePipelineEvidence(1)], evidenceCollection: [] })
)
renderWorkspace()
await submitQuery('空浏览集合')
expect(await screen.findByText('测试搜索答案')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'E1' })).toBeInTheDocument()
expect(screen.queryByText('E1 · 发送者 1')).not.toBeInTheDocument()
expect(screen.getByText('等待检索结果')).toBeInTheDocument()
})
it('handles an IPC rejection from the Search Worker as an insufficient result', async () => {
api.runAiSearch.mockRejectedValue(new Error('Worker IPC 连接断开'))
renderWorkspace()
@@ -218,7 +261,9 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
})
it('loads more Evidence from the current collection without calling runAiSearch again', async () => {
const evidenceCollection = Array.from({ length: 9 }, (_, index) => makePipelineEvidence(index + 1))
const evidenceCollection = Array.from({ length: 9 }, (_, index) =>
makePipelineEvidence(index + 1)
)
api.runAiSearch.mockResolvedValue(
makeSearchResult({ evidence: evidenceCollection.slice(0, 8), evidenceCollection })
)
@@ -263,7 +308,12 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
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] }))
resolveSecond?.(
makeSearchResult({
requestId: api.runAiSearch.mock.calls[1][0].requestId,
evidence: [secondEvidence]
})
)
expect(await screen.findByText('E2 · 发送者 2')).toBeInTheDocument()
})
@@ -352,6 +402,32 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(screen.queryByRole('dialog', { name: '确认发送本次搜索资料' })).not.toBeInTheDocument()
})
it('settles duplicate consent submissions without starting duplicate provider flows', async () => {
api.getAiSearchProviderStatus.mockResolvedValue({
configured: true,
requiresConsent: true,
providerId: 'remote-provider',
providerName: 'Remote Provider',
recipient: 'remote@example.test'
})
api.runAiSearch.mockResolvedValue(makeSearchResult())
renderWorkspace()
const user = userEvent.setup()
await user.type(screen.getByRole('textbox'), '重复授权问题')
const form = screen.getByRole('textbox').closest('form') as HTMLFormElement
fireEvent.submit(form)
await screen.findByRole('dialog', { name: '确认发送本次搜索资料' })
fireEvent.submit(form)
await waitFor(() => expect(api.getAiSearchProviderStatus).toHaveBeenCalledTimes(2))
await screen.findByRole('dialog', { name: '确认发送本次搜索资料' })
await user.click(screen.getByRole('button', { name: '继续并发送' }))
await screen.findByText('测试搜索答案')
expect(api.authorizeAiSearchExternalProvider).toHaveBeenCalledOnce()
expect(api.runAiSearch).toHaveBeenCalledOnce()
})
it('does not start Search when the user rejects consent', async () => {
api.getAiSearchProviderStatus.mockResolvedValue({
configured: true,
@@ -429,9 +505,7 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
const query = '没有证据的缓存'
localStorage.setItem(
SEARCH_CACHE_KEY,
JSON.stringify([
makeCacheRecord({ query, answer: '只有摘要的缓存', evidence: [] })
])
JSON.stringify([makeCacheRecord({ query, answer: '只有摘要的缓存', evidence: [] })])
)
renderWorkspace()
await submitQuery(query)
@@ -443,8 +517,12 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
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)] }))
.mockResolvedValueOnce(
makeSearchResult({ answer: '第轮答案', evidence: [makePipelineEvidence(1)] })
)
.mockResolvedValueOnce(
makeSearchResult({ answer: '第二轮答案', evidence: [makePipelineEvidence(2)] })
)
renderWorkspace()
const user = await submitQuery('第一轮问题')
await screen.findByText('第一轮答案')
@@ -0,0 +1,73 @@
import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ChatImageViewer } from '../../src/renderer/src/components/chat/ChatImageViewer'
import { TooltipProvider } from '../../src/renderer/src/components/ui'
const imageUrl =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
function Harness({ onClose = vi.fn() }: { onClose?: () => void }): React.ReactElement {
const [open, setOpen] = useState(false)
return (
<TooltipProvider>
<button type="button" onClick={() => setOpen(true)}>
</button>
{open && (
<ChatImageViewer
imageUrl={imageUrl}
onClose={() => {
onClose()
setOpen(false)
}}
/>
)}
</TooltipProvider>
)
}
describe('ChatImageViewer', () => {
it('zooms, rotates, drags, and resets the image', async () => {
const user = userEvent.setup()
render(<Harness />)
await user.click(screen.getByRole('button', { name: '查看图片' }))
const image = screen.getByAltText('图片预览')
const stage = screen.getByLabelText('图片查看区域')
expect(screen.getByText('100%')).toBeVisible()
await user.click(screen.getByRole('button', { name: '放大' }))
expect(screen.getByText('110%')).toBeVisible()
await user.click(screen.getByRole('button', { name: '右旋转' }))
expect(image).toHaveStyle({ transform: 'translate(0px, 0px) scale(1.1) rotate(90deg)' })
fireEvent.mouseDown(stage, { clientX: 10, clientY: 15 })
fireEvent.mouseMove(stage, { clientX: 35, clientY: 45 })
fireEvent.mouseUp(stage)
expect(image).toHaveStyle({ transform: 'translate(25px, 30px) scale(1.1) rotate(90deg)' })
await user.click(screen.getByRole('button', { name: '重置图片' }))
expect(screen.getByText('100%')).toBeVisible()
expect(image).toHaveStyle({ transform: 'translate(0px, 0px) scale(1) rotate(0deg)' })
})
it('closes with Escape or the overlay and restores focus', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<Harness onClose={onClose} />)
const opener = screen.getByRole('button', { name: '查看图片' })
await user.click(opener)
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '图片查看' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
await user.click(opener)
const dialog = screen.getByRole('dialog', { name: '图片查看' })
await user.click(dialog.previousElementSibling as HTMLElement)
expect(screen.queryByRole('dialog', { name: '图片查看' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
expect(onClose).toHaveBeenCalledTimes(2)
})
})
+60
View File
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ChatHeader } from '../../src/renderer/src/components/chat/ChatHeader'
import { ExportMenu } from '../../src/renderer/src/components/chat/ExportMenu'
import type { Contact } from '../../src/shared/types'
const contact: Contact = {
md5: 'group-md5',
m_nsUsrName: 'group@chatroom',
m_nsNickName: '测试群',
type: 'group'
}
describe('chat menus', () => {
it('runs the selected export range and closes the menu', async () => {
const user = userEvent.setup()
const onExport = vi.fn()
render(<ExportMenu disabled={false} onExport={onExport} />)
const trigger = screen.getByRole('button', { name: '导出' })
await user.click(trigger)
await user.click(screen.getByRole('menuitem', { name: '导出近 7 天' }))
expect(onExport).toHaveBeenCalledWith(7)
expect(screen.queryByRole('menuitem', { name: '导出近 7 天' })).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
})
it('closes the chat More menu with Escape and invokes refresh data once', async () => {
const user = userEvent.setup()
const onRefreshData = vi.fn()
render(
<ChatHeader
contact={contact}
isGroupChat
loadedCount={3}
filteredCount={3}
contentFilter=""
isAiLoading={false}
onContentFilterChange={vi.fn()}
onRefresh={vi.fn()}
onRefreshData={onRefreshData}
onTestSend={vi.fn()}
onOpenAiSettings={vi.fn()}
/>
)
const trigger = screen.getByRole('button', { name: '更多' })
await user.click(trigger)
expect(screen.getByRole('menuitem', { name: '刷新数据' })).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('menuitem', { name: '刷新数据' })).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
await user.click(trigger)
await user.click(screen.getByRole('menuitem', { name: '刷新数据' }))
expect(onRefreshData).toHaveBeenCalledOnce()
})
})
@@ -69,7 +69,9 @@ describe('export progress panel', () => {
const progressbar = screen.getByRole('progressbar', { name: '导出进度' })
expect(progressbar).toHaveAttribute('aria-valuenow', '31')
expect(progressbar.querySelector('span')).toHaveStyle({ width: '31%' })
expect(progressbar.querySelector('[data-slot="progress-indicator"]')).toHaveStyle({
transform: 'translateX(-69%)'
})
})
it('shows the current conversation and overall position for all export', () => {
+20
View File
@@ -31,6 +31,7 @@ describe('ExportWorkspace multi-chat selection', () => {
onExportProgress: vi.fn(() => vi.fn()),
getVoiceModelStatus: vi.fn().mockRejectedValue(new Error('fixture model unavailable')),
getGroupSnapshot: vi.fn(async () => ({ members: [] })),
selectExportDirectory: vi.fn(async () => ({ canceled: false, path: '/fixture/export' })),
cancelExport: vi.fn(async () => ({ success: true })),
revealExport: vi.fn(async () => ({ success: true }))
}
@@ -197,6 +198,25 @@ describe('ExportWorkspace multi-chat selection', () => {
expect(document.querySelector('.export-all-chat-avatar.user')).not.toBeInTheDocument()
expect(loadPreviewMessages).not.toHaveBeenCalled()
})
it('keeps custom dates and output directory selection in the workspace request', async () => {
const onStartExport = vi.fn(async () => ({ success: false }))
renderWorkspace(onStartExport)
await userEvent.click(screen.getByRole('button', { name: '自定义时间' }))
await userEvent.type(screen.getByLabelText('开始时间'), '2026-08-01T09:30')
await userEvent.type(screen.getByLabelText('结束时间'), '2026-08-02T18:45')
await userEvent.click(screen.getByRole('button', { name: '选择位置' }))
expect(await screen.findByText('/fixture/export/聊天 A_聊天档案.csv')).toBeVisible()
await userEvent.click(screen.getByRole('button', { name: '开始导出' }))
await waitFor(() => expect(onStartExport).toHaveBeenCalledOnce())
expect(onStartExport.mock.calls[0][0]).toMatchObject({
outputDirectory: '/fixture/export',
startTime: Math.floor(new Date('2026-08-01T09:30').getTime() / 1000),
endTime: Math.floor(new Date('2026-08-02T18:45').getTime() / 1000)
})
})
})
describe('ExportTaskCenter details', () => {
@@ -0,0 +1,84 @@
import { useState } from 'react'
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
ExternalProviderConsentDialog,
type ExternalProviderConsent
} from '../../src/renderer/src/components/search/ExternalProviderConsentDialog'
const consent: ExternalProviderConsent = {
providerName: 'Remote Provider',
recipient: 'remote@example.test'
}
describe('ExternalProviderConsentDialog', () => {
it('renders provider details and confirms the controlled action', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
render(
<ExternalProviderConsentDialog consent={consent} onConfirm={onConfirm} onCancel={vi.fn()} />
)
expect(screen.getByRole('dialog', { name: '确认发送本次搜索资料' })).toBeVisible()
expect(screen.getByText('Remote Provider')).toBeVisible()
expect(screen.getByRole('dialog')).toHaveTextContent('remote@example.test')
await user.click(screen.getByRole('button', { name: '继续并发送' }))
expect(onConfirm).toHaveBeenCalledOnce()
})
it('treats the cancel button, Escape, and outside interaction as cancellation', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
const { rerender } = render(
<ExternalProviderConsentDialog consent={consent} onConfirm={vi.fn()} onCancel={onCancel} />
)
await user.click(screen.getByRole('button', { name: '取消' }))
expect(onCancel).toHaveBeenCalledOnce()
rerender(
<ExternalProviderConsentDialog consent={consent} onConfirm={vi.fn()} onCancel={onCancel} />
)
await user.keyboard('{Escape}')
expect(onCancel).toHaveBeenCalledTimes(2)
rerender(
<ExternalProviderConsentDialog consent={consent} onConfirm={vi.fn()} onCancel={onCancel} />
)
const overlay = screen.getByRole('dialog').previousElementSibling as HTMLElement
await user.click(overlay)
expect(onCancel).toHaveBeenCalledTimes(3)
})
it('restores focus to the opener after the dialog is cancelled', async () => {
const user = userEvent.setup()
function Harness(): React.ReactElement {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
</button>
<ExternalProviderConsentDialog
consent={open ? consent : null}
onConfirm={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</>
)
}
render(<Harness />)
const opener = screen.getByRole('button', { name: '打开授权确认' })
await user.click(opener)
expect(screen.getByRole('dialog', { name: '确认发送本次搜索资料' })).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '确认发送本次搜索资料' })).not.toBeInTheDocument()
expect(opener).toHaveFocus()
})
})
@@ -0,0 +1,70 @@
import { useState } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { FirstUseWelcome } from '../../src/renderer/src/components/FirstUseWelcome'
describe('FirstUseWelcome', () => {
it('closes with Escape or the overlay and restores focus to the opener', async () => {
const user = userEvent.setup()
function Harness(): React.ReactElement {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
</button>
{open && (
<FirstUseWelcome
onDismiss={() => setOpen(false)}
onOpenSearch={vi.fn()}
onOpenReport={vi.fn()}
onOpenAISettings={vi.fn()}
/>
)}
</>
)
}
render(<Harness />)
const opener = screen.getByRole('button', { name: '打开新手引导' })
await user.click(opener)
expect(screen.getByRole('dialog', { name: '开始探索你的微信' })).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '开始探索你的微信' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
await user.click(opener)
const dialog = screen.getByRole('dialog', { name: '开始探索你的微信' })
await user.click(dialog.previousElementSibling as HTMLElement)
expect(screen.queryByRole('dialog', { name: '开始探索你的微信' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
})
it('keeps the feature callbacks and guide link intact', async () => {
const user = userEvent.setup()
const onOpenSearch = vi.fn()
const onOpenReport = vi.fn()
const onOpenAISettings = vi.fn()
render(
<FirstUseWelcome
onDismiss={vi.fn()}
onOpenSearch={onOpenSearch}
onOpenReport={onOpenReport}
onOpenAISettings={onOpenAISettings}
/>
)
await user.click(screen.getByRole('button', { name: /试试 AI 群聊日报/ }))
expect(onOpenReport).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: '问问你的微信' }))
expect(onOpenSearch).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: '配置 AI 模型' }))
expect(onOpenAISettings).toHaveBeenCalledOnce()
expect(screen.getByRole('link', { name: '查看完整使用教程' })).toHaveAttribute(
'href',
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
)
})
})
+7
View File
@@ -40,6 +40,13 @@ describe('ImageBubble', () => {
await userEvent.click(image)
await waitFor(() => expect(onImageClick).toHaveBeenCalledWith(original))
expect(requestImage.mock.calls[1][3]).toMatchObject({ force: true })
onImageClick.mockClear()
requestImage.mockResolvedValueOnce({ data: original, isThumbnail: false })
const trigger = screen.getByRole('button', { name: '查看图片' })
trigger.focus()
await userEvent.keyboard('{Enter}')
await waitFor(() => expect(onImageClick).toHaveBeenCalledWith(original))
})
it('shows an explicit error and allows retry', async () => {
@@ -1,4 +1,6 @@
import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
@@ -47,6 +49,27 @@ const readyStatus = {
message: '个人微信已绑定'
}
function Harness({ onClose = vi.fn() }: { onClose?: () => void }): React.ReactElement {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
</button>
{open && (
<PersonalWechatSendDialog
contact={contact}
isGroupChat
onClose={() => {
onClose()
setOpen(false)
}}
/>
)}
</>
)
}
describe('PersonalWechatSendDialog', () => {
beforeEach(() => {
getStatus.mockReset().mockResolvedValue(readyStatus)
@@ -208,4 +231,43 @@ describe('PersonalWechatSendDialog', () => {
expect(await screen.findByText('已绑定,等待消息初始化')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '测试发送图片到群聊' })).toBeDisabled()
})
it('closes with Escape or the overlay and restores focus to the opener', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<Harness onClose={onClose} />)
const opener = screen.getByRole('button', { name: '打开测试发送' })
await user.click(opener)
await screen.findByText('技术交流群')
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '个人微信测试发送' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
await user.click(opener)
const dialog = await screen.findByRole('dialog', { name: '个人微信测试发送' })
await user.click(dialog.previousElementSibling as HTMLElement)
expect(screen.queryByRole('dialog', { name: '个人微信测试发送' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
expect(onClose).toHaveBeenCalledTimes(2)
})
it('keeps the dialog open while a message is being sent', async () => {
const user = userEvent.setup()
sendMessage.mockImplementation(() => new Promise(() => undefined))
render(<Harness />)
await user.click(screen.getByRole('button', { name: '打开测试发送' }))
await screen.findByText('技术交流群')
await user.click(screen.getByRole('button', { name: '选择图片' }))
await user.click(screen.getByRole('button', { name: '测试发送图片到群聊' }))
expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled()
await user.keyboard('{Escape}')
expect(screen.getByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
await user.click(
screen.getByRole('dialog', { name: '个人微信测试发送' }).previousElementSibling as HTMLElement
)
expect(screen.getByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
})
})
+140 -6
View File
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ReportGroupMemberSelector } from '../../src/renderer/src/components/reports/ReportGroupMemberSelector'
import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel'
@@ -6,6 +7,7 @@ import { ReportTemplateSelector } from '../../src/renderer/src/components/report
import { ReportViewer } from '../../src/renderer/src/components/reports/ReportViewer'
import { ReportInfoPanel } from '../../src/renderer/src/components/reports/ReportInfoPanel'
import { ReportToolbar } from '../../src/renderer/src/components/reports/ReportToolbar'
import { ReportHistorySidebar } from '../../src/renderer/src/components/reports/ReportHistorySidebar'
import { ModelSummary } from '../../src/renderer/src/components/reports/ModelSummary'
import type { Contact } from '../../src/shared/types'
import type { GeneratedReportRecord } from '../../src/shared/report-history'
@@ -434,7 +436,8 @@ describe('daily report controls', () => {
globalThis.ResizeObserver = originalResizeObserver
})
it('keeps the file action inside More and labels both AI model roles', () => {
it('keeps the file action inside More and labels both AI model roles', async () => {
const user = userEvent.setup()
render(
<>
<ReportToolbar
@@ -469,13 +472,53 @@ describe('daily report controls', () => {
</>
)
expect(screen.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '更多' }))
expect(screen.getByRole('button', { name: '打开文件夹' })).toBeVisible()
expect(screen.queryByRole('menuitem', { name: '生成微信卡片' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '更多' }))
expect(screen.getByRole('menuitem', { name: '生成微信卡片' })).toBeVisible()
expect(screen.getByRole('menuitem', { name: '打开文件夹' })).toBeVisible()
expect(screen.getByText('文字模型')).toBeVisible()
expect(screen.getByText('DeepSeek Chat')).toBeVisible()
expect(screen.getByText('图片模型')).toBeVisible()
expect(screen.getByText('gpt-5.6-sol')).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('menuitem', { name: '生成微信卡片' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '更多' })).toHaveFocus()
})
it('keeps unavailable toolbar actions disabled inside More', async () => {
const user = userEvent.setup()
const onReveal = vi.fn()
const onShare = vi.fn()
render(
<ReportToolbar
canCopyImage={false}
canReveal={false}
canShare={false}
canSwitchTemplate={false}
isSwitchingTemplate={false}
onSwitchTemplate={vi.fn()}
onRegenerate={vi.fn()}
onCopyImage={vi.fn()}
onReveal={onReveal}
onShare={onShare}
/>
)
expect(screen.getByRole('button', { name: '切换模板' })).toBeDisabled()
expect(screen.getByRole('button', { name: '复制图片' })).toBeDisabled()
expect(screen.getByRole('button', { name: '打开报告' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: '更多' }))
const shareItem = screen.getByRole('menuitem', { name: '生成微信卡片' })
const revealItem = screen.getByRole('menuitem', { name: '打开文件夹' })
expect(shareItem).toHaveAttribute('data-disabled')
expect(revealItem).toHaveAttribute('data-disabled')
await user.click(shareItem)
await user.click(revealItem)
expect(onShare).not.toHaveBeenCalled()
expect(onReveal).not.toHaveBeenCalled()
})
it('opens the current group send dialog with the report PNG preselected', async () => {
@@ -550,6 +593,7 @@ describe('daily report controls', () => {
})
it('switches templates from the top toolbar using the saved report snapshot', async () => {
const user = userEvent.setup()
const onSwitchTemplate = vi.fn(async () => ({ success: true }))
const report: GeneratedReportRecord = {
id: 'report-switch',
@@ -579,10 +623,10 @@ describe('daily report controls', () => {
/>
)
fireEvent.click(screen.getByRole('button', { name: '切换模板' }))
await user.click(screen.getByRole('button', { name: '切换模板' }))
expect(screen.getByText('仅重新排版,不调用 AI')).toBeVisible()
expect(screen.getByRole('menuitem', { name: /默认模板经典日报/ })).toBeVisible()
fireEvent.click(screen.getByRole('menuitem', { name: /Mobile 03AI Command Center/ }))
await user.click(screen.getByRole('menuitem', { name: /Mobile 03AI Command Center/ }))
await waitFor(() => expect(onSwitchTemplate).toHaveBeenCalledWith(report, 'mobile-dashboard'))
rerender(
@@ -640,6 +684,96 @@ describe('daily report controls', () => {
expect(screen.getByText('wxid-one')).toBeVisible()
})
it('cancels report deletion and restores focus to the delete trigger', async () => {
const user = userEvent.setup()
const report: GeneratedReportRecord = {
id: 'report-delete-cancel',
contactId: 'group-md5',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-17T10:00:00.000Z',
reportDate: '2026-08-17',
htmlStatus: 'ready',
pngStatus: 'ready'
}
render(
<ReportHistorySidebar
reports={[report]}
selectedReportId={report.id}
selfInfo={null}
dbReady
onSelectReport={vi.fn()}
onCreateReport={vi.fn()}
onDeleteReport={vi.fn(async () => ({ success: true }))}
onOpenSettings={vi.fn()}
/>
)
const deleteTrigger = screen.getByRole('button', { name: '删除日报' })
await user.click(deleteTrigger)
expect(screen.getByRole('alertdialog', { name: '删除日报?' })).toBeVisible()
await user.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByRole('alertdialog', { name: '删除日报?' })).not.toBeInTheDocument()
expect(deleteTrigger).toHaveFocus()
})
it('prevents duplicate report deletion, keeps failures open, and closes after success', async () => {
const user = userEvent.setup()
const report: GeneratedReportRecord = {
id: 'report-delete-result',
contactId: 'group-md5',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-17T10:00:00.000Z',
reportDate: '2026-08-17',
htmlStatus: 'ready',
pngStatus: 'ready'
}
let settleDelete: ((result: { success: boolean; error?: string }) => void) | undefined
const onDeleteReport = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<{ success: boolean; error?: string }>((resolve) => {
settleDelete = resolve
})
)
.mockResolvedValueOnce({ success: true })
render(
<ReportHistorySidebar
reports={[report]}
selectedReportId={report.id}
selfInfo={null}
dbReady
onSelectReport={vi.fn()}
onCreateReport={vi.fn()}
onDeleteReport={onDeleteReport}
onOpenSettings={vi.fn()}
/>
)
await user.click(screen.getByRole('button', { name: '删除日报' }))
await user.click(screen.getByRole('button', { name: '删除', exact: true }))
const pendingButton = screen.getByRole('button', { name: '删除中…' })
expect(pendingButton).toBeDisabled()
await user.click(pendingButton)
expect(onDeleteReport).toHaveBeenCalledTimes(1)
settleDelete?.({ success: false, error: '文件正在使用' })
expect(await screen.findByText('文件正在使用')).toBeVisible()
expect(screen.getByRole('alertdialog', { name: '删除日报?' })).toBeVisible()
await user.click(screen.getByRole('button', { name: '删除', exact: true }))
await waitFor(() => expect(onDeleteReport).toHaveBeenCalledTimes(2))
await waitFor(() =>
expect(screen.queryByRole('alertdialog', { name: '删除日报?' })).not.toBeInTheDocument()
)
})
it('offers the classic default plus three mobile and two desktop report templates', () => {
const onChange = vi.fn()
render(<ReportTemplateSelector value="v1" onChange={onChange} />)
@@ -0,0 +1,76 @@
import { useState } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { SkillPreviewDialog } from '../../src/renderer/src/features/api-center/components/SkillPreviewDialog'
const content = `# TraceMemo Reader
## 能力
- 读取本地聊天记录
仅在用户授权后访问。`
function Harness({ onClose = vi.fn() }: { onClose?: () => void }): React.ReactElement {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Skill
</button>
{open && (
<SkillPreviewDialog
content={content}
version="v1.2"
onClose={() => {
onClose()
setOpen(false)
}}
/>
)}
</>
)
}
describe('SkillPreviewDialog', () => {
it('renders the line-based preview and switches between rendered and raw content', async () => {
const user = userEvent.setup()
render(<Harness />)
await user.click(screen.getByRole('button', { name: '预览 Skill' }))
const dialog = screen.getByRole('dialog', { name: 'TraceMemo Reader Skill 预览' })
expect(dialog).toBeVisible()
expect(screen.getByText('v1.2')).toBeVisible()
expect(screen.getByRole('heading', { name: '能力' })).toBeVisible()
expect(screen.getByText('读取本地聊天记录')).toBeVisible()
expect(screen.queryByText(content)).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '原始文本' }))
expect(dialog.querySelector('pre')?.textContent).toBe(content)
await user.click(screen.getByRole('button', { name: '渲染预览' }))
expect(screen.getByRole('heading', { name: '能力' })).toBeVisible()
})
it('closes with Escape or the overlay and restores focus to the opener', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<Harness onClose={onClose} />)
const opener = screen.getByRole('button', { name: '预览 Skill' })
await user.click(opener)
await user.keyboard('{Escape}')
expect(
screen.queryByRole('dialog', { name: 'TraceMemo Reader Skill 预览' })
).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
await user.click(opener)
const dialog = screen.getByRole('dialog', { name: 'TraceMemo Reader Skill 预览' })
await user.click(dialog.previousElementSibling as HTMLElement)
expect(
screen.queryByRole('dialog', { name: 'TraceMemo Reader Skill 预览' })
).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
expect(onClose).toHaveBeenCalledTimes(2)
})
})
@@ -1,7 +1,15 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TextToSpeechPage } from '../../src/renderer/src/features/settings/pages/TextToSpeechPage'
vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
isMac: true,
isWindows: false,
runtimePlatform: 'darwin',
supportsPersonalWechatSend: true
}))
const getSettings = vi.fn()
const saveSettings = vi.fn()
const listVoices = vi.fn()
@@ -45,6 +53,7 @@ const response = {
describe('TextToSpeechPage', () => {
beforeEach(() => {
sessionStorage.clear()
getSettings.mockReset().mockResolvedValue(response)
listVoices.mockReset().mockResolvedValue({
success: true,
@@ -126,4 +135,40 @@ describe('TextToSpeechPage', () => {
fireEvent.click(screen.getByRole('button', { name: '前往 api.fish.audio 获取 Key' }))
await waitFor(() => expect(openApiKeys).toHaveBeenCalledTimes(1))
})
it('opens supported versions from both triggers and restores focus after closing', async () => {
const user = userEvent.setup()
render(<TextToSpeechPage onNotice={vi.fn()} />)
const guideTrigger = await screen.findByRole('button', { name: '查看支持版本' })
await user.click(guideTrigger)
expect(screen.getByRole('dialog', { name: '支持的微信版本' })).toBeVisible()
expect(screen.getByText('请安装下列完整版本之一。')).toBeVisible()
expect(screen.getByRole('link', { name: /下载微信历史版本/ })).toHaveAttribute(
'href',
'https://github.com/zsbai/wechat-versions/releases'
)
expect(screen.getByText('4.1.6.12')).toBeVisible()
expect(screen.getByText('4.1.11.53')).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '支持的微信版本' })).not.toBeInTheDocument()
expect(guideTrigger).toHaveFocus()
const runtimeTrigger = screen.getByRole('button', { name: '支持版本' })
await user.click(runtimeTrigger)
const dialog = screen.getByRole('dialog', { name: '支持的微信版本' })
expect(dialog).toBeVisible()
await user.click(dialog.previousElementSibling as HTMLElement)
expect(screen.queryByRole('dialog', { name: '支持的微信版本' })).not.toBeInTheDocument()
expect(runtimeTrigger).toHaveFocus()
})
it('keeps the session handoff that opens supported versions automatically', async () => {
sessionStorage.setItem('wxe:show-supported-wechat-versions', '1')
render(<TextToSpeechPage onNotice={vi.fn()} />)
expect(await screen.findByRole('dialog', { name: '支持的微信版本' })).toBeVisible()
expect(sessionStorage.getItem('wxe:show-supported-wechat-versions')).toBeNull()
})
})
@@ -0,0 +1,116 @@
import { useState } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { WechatShareCardDialog } from '../../src/renderer/src/components/reports/WechatShareCardDialog'
const getConfig = vi.fn()
const saveConfig = vi.fn()
const publishCard = vi.fn()
function Harness(): React.ReactElement {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
</button>
{open && (
<WechatShareCardDialog
pngPath="/tmp/report.png"
initialTitle="测试群日报"
initialDescription="日报摘要"
onClose={() => setOpen(false)}
/>
)}
</>
)
}
describe('WechatShareCardDialog', () => {
beforeEach(() => {
getConfig.mockReset().mockResolvedValue({
success: true,
configured: true,
serviceUrl: 'https://share.example.test'
})
saveConfig.mockReset().mockResolvedValue({ success: true })
publishCard.mockReset()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getWechatShareConfig: getConfig,
saveWechatShareConfig: saveConfig,
publishWechatShareCard: publishCard
}
})
})
it('closes with Escape or the overlay and restores focus to the opener', async () => {
const user = userEvent.setup()
render(<Harness />)
const opener = screen.getByRole('button', { name: '打开分享卡片' })
await user.click(opener)
expect(screen.getByRole('dialog', { name: '生成微信分享卡片' })).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '生成微信分享卡片' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
await user.click(opener)
const dialog = screen.getByRole('dialog', { name: '生成微信分享卡片' })
await user.click(dialog.previousElementSibling as HTMLElement)
expect(screen.queryByRole('dialog', { name: '生成微信分享卡片' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
})
it('keeps the dialog open when Escape is pressed while publishing', async () => {
const user = userEvent.setup()
publishCard.mockImplementation(() => new Promise(() => undefined))
render(<Harness />)
await user.click(screen.getByRole('button', { name: '打开分享卡片' }))
await waitFor(() => expect(getConfig).toHaveBeenCalledOnce())
await user.click(screen.getByRole('button', { name: '生成二维码' }))
expect(screen.getByRole('button', { name: '正在生成卡片…' })).toBeDisabled()
await user.keyboard('{Escape}')
expect(screen.getByRole('dialog', { name: '生成微信分享卡片' })).toBeVisible()
})
it('saves first-time configuration, publishes the card, and copies its link', async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, 'writeText')
getConfig.mockResolvedValue({ success: true, configured: false })
publishCard.mockResolvedValue({
success: true,
shareUrl: 'https://share.example.test/card-1',
qrCodeDataUrl: 'data:image/png;base64,fixture',
expiresAt: '2026-08-26T08:00:00.000Z'
})
render(<Harness />)
await user.click(screen.getByRole('button', { name: '打开分享卡片' }))
const uploadToken = await screen.findByPlaceholderText('Cloudflare Worker 的 UPLOAD_TOKEN')
await user.clear(uploadToken)
await user.type(uploadToken, 'a'.repeat(24))
await user.click(screen.getByRole('button', { name: '生成二维码' }))
await waitFor(() =>
expect(saveConfig).toHaveBeenCalledWith({
serviceUrl: 'https://share.example.com',
uploadToken: 'a'.repeat(24)
})
)
expect(publishCard).toHaveBeenCalledWith({
pngPath: '/tmp/report.png',
title: '测试群日报',
description: '日报摘要',
expiresInDays: 7
})
expect(await screen.findByAltText('微信分享二维码')).toBeVisible()
await user.click(screen.getByRole('button', { name: '复制分享链接' }))
await waitFor(() => expect(writeText).toHaveBeenCalledWith('https://share.example.test/card-1'))
expect(await screen.findByRole('button', { name: '链接已复制' })).toBeVisible()
})
})