| import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' |
| import { describe, expect, it, vi } from 'vitest' |
| import { StudioCommandPanel } from './StudioCommandPanel' |
| import type { StudioMessage, StudioSession } from '../protocol/studio-agent-types' |
|
|
| vi.mock('../../i18n', () => ({ |
| useI18n: () => ({ |
| t: (_key: string) => '输入指令...', |
| }), |
| })) |
|
|
| function createSession(): StudioSession { |
| const now = '2026-03-22T00:00:00.000Z' |
| return { |
| id: 'session-1', |
| projectId: 'project-1', |
| agentType: 'builder', |
| title: 'Studio', |
| directory: 'D:/projects/ManimCat', |
| permissionLevel: 'L2', |
| permissionRules: [], |
| createdAt: now, |
| updatedAt: now, |
| } |
| } |
|
|
| function createAssistantMessage(): Extract<StudioMessage, { role: 'assistant' }> { |
| const now = '2026-03-22T00:00:00.000Z' |
| return { |
| id: 'message-1', |
| sessionId: 'session-1', |
| role: 'assistant', |
| agent: 'builder', |
| parts: [], |
| createdAt: now, |
| updatedAt: now, |
| } |
| } |
|
|
| describe('StudioCommandPanel', () => { |
| it('restores the input when submit fails', async () => { |
| const onRun = vi.fn(async () => { |
| throw new Error('submit failed') |
| }) |
|
|
| render( |
| <StudioCommandPanel |
| session={createSession()} |
| messages={[]} |
| latestAssistantText="" |
| isBusy={false} |
| disabled={false} |
| onRun={onRun} |
| onExit={vi.fn()} |
| />, |
| ) |
|
|
| const input = screen.getByPlaceholderText('输入指令...') as HTMLInputElement |
| fireEvent.change(input, { target: { value: 'render current file' } }) |
| fireEvent.keyDown(input, { key: 'Enter' }) |
|
|
| await waitFor(() => expect(onRun).toHaveBeenCalledWith('render current file')) |
| await waitFor(() => expect(input.value).toBe('render current file')) |
| }) |
|
|
| it('does not flash the full assistant text before typing starts', async () => { |
| vi.useFakeTimers() |
|
|
| const originalRequestAnimationFrame = window.requestAnimationFrame |
| const originalCancelAnimationFrame = window.cancelAnimationFrame |
| window.requestAnimationFrame = (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0) |
| window.cancelAnimationFrame = (id: number) => window.clearTimeout(id) |
|
|
| try { |
| render( |
| <StudioCommandPanel |
| session={createSession()} |
| messages={[createAssistantMessage()]} |
| latestAssistantText="你好,世界" |
| isBusy |
| disabled={false} |
| onRun={vi.fn()} |
| onExit={vi.fn()} |
| />, |
| ) |
|
|
| expect(screen.queryByText('你好,世界')).not.toBeInTheDocument() |
|
|
| await act(async () => { |
| vi.runOnlyPendingTimers() |
| }) |
|
|
| expect(screen.getByText(/你/)).toBeInTheDocument() |
| expect(screen.queryByText('你好,世界')).not.toBeInTheDocument() |
| } finally { |
| window.requestAnimationFrame = originalRequestAnimationFrame |
| window.cancelAnimationFrame = originalCancelAnimationFrame |
| vi.useRealTimers() |
| } |
| }) |
|
|
| it('does not auto-scroll again for each typing animation step', async () => { |
| vi.useFakeTimers() |
|
|
| const originalRequestAnimationFrame = window.requestAnimationFrame |
| const originalCancelAnimationFrame = window.cancelAnimationFrame |
| const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView |
| const scrollIntoView = vi.fn() |
| window.requestAnimationFrame = (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0) |
| window.cancelAnimationFrame = (id: number) => window.clearTimeout(id) |
| window.HTMLElement.prototype.scrollIntoView = scrollIntoView |
|
|
| try { |
| render( |
| <StudioCommandPanel |
| session={createSession()} |
| messages={[createAssistantMessage()]} |
| latestAssistantText="正在生成内容" |
| isBusy |
| disabled={false} |
| onRun={vi.fn()} |
| onExit={vi.fn()} |
| />, |
| ) |
|
|
| await act(async () => { |
| vi.runOnlyPendingTimers() |
| }) |
|
|
| const initialCalls = scrollIntoView.mock.calls.length |
|
|
| await act(async () => { |
| vi.advanceTimersByTime(2000) |
| }) |
|
|
| expect(scrollIntoView.mock.calls.length).toBe(initialCalls) |
| } finally { |
| window.requestAnimationFrame = originalRequestAnimationFrame |
| window.cancelAnimationFrame = originalCancelAnimationFrame |
| window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView |
| vi.useRealTimers() |
| } |
| }) |
|
|
| it('hides stale empty assistant placeholders once a real assistant reply exists', () => { |
| const now = '2026-03-22T00:00:00.000Z' |
| render( |
| <StudioCommandPanel |
| session={createSession()} |
| messages={[ |
| { |
| id: 'message-empty', |
| sessionId: 'session-1', |
| role: 'assistant', |
| agent: 'builder', |
| parts: [], |
| createdAt: now, |
| updatedAt: now, |
| }, |
| { |
| id: 'message-real', |
| sessionId: 'session-1', |
| role: 'assistant', |
| agent: 'builder', |
| parts: [ |
| { |
| id: 'part-1', |
| messageId: 'message-real', |
| sessionId: 'session-1', |
| type: 'text', |
| text: '正式回复', |
| }, |
| ], |
| createdAt: now, |
| updatedAt: now, |
| }, |
| ]} |
| latestAssistantText="" |
| isBusy={false} |
| disabled={false} |
| onRun={vi.fn()} |
| onExit={vi.fn()} |
| />, |
| ) |
|
|
| expect(screen.getByText('正式回复')).toBeInTheDocument() |
| expect(screen.queryByText('暂无响应输出')).not.toBeInTheDocument() |
| }) |
| }) |
|
|