|
|
@@ -0,0 +1,151 @@
|
|
|
+import { mount } from '@vue/test-utils'
|
|
|
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
+import { defineComponent, nextTick, type Component } from 'vue'
|
|
|
+import TypingIndicator from '@/components/common/TypingIndicator.vue'
|
|
|
+import MessageTimeline from '@/components/messages/MessageTimeline.vue'
|
|
|
+import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
|
|
|
+import type { UiMessage } from '@/components/messages/types'
|
|
|
+
|
|
|
+const AgentAvatarStub = defineComponent({
|
|
|
+ props: {
|
|
|
+ name: { type: String, required: true },
|
|
|
+ avatar: { type: String, default: '' },
|
|
|
+ size: { type: Number, required: true },
|
|
|
+ state: { type: String, required: true },
|
|
|
+ },
|
|
|
+ template: `<span class="agent-avatar-stub" :data-name="name" :data-avatar="avatar" :data-size="size" :data-state="state" />`,
|
|
|
+})
|
|
|
+
|
|
|
+const stubs = {
|
|
|
+ AgentAvatar: AgentAvatarStub,
|
|
|
+ EmptyState: true,
|
|
|
+ InteractionCard: true,
|
|
|
+ MarkdownContent: true,
|
|
|
+ ToolTrace: true,
|
|
|
+ TurnTrace: true,
|
|
|
+ WorkspaceApprovalCard: true,
|
|
|
+}
|
|
|
+
|
|
|
+const user: UiMessage = { id: 'user', role: 'user', content: '请继续' }
|
|
|
+
|
|
|
+function streamingAssistant(): UiMessage {
|
|
|
+ return {
|
|
|
+ id: 'assistant-streaming',
|
|
|
+ role: 'assistant',
|
|
|
+ author: '流式助手',
|
|
|
+ profile: 'streaming-profile',
|
|
|
+ content: '正文已经开始输出',
|
|
|
+ reasoning: '思考也已经出现',
|
|
|
+ status: 'streaming',
|
|
|
+ tools: [{ id: 'tool', name: 'read_file', status: 'running' }],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function mountTimeline(component: Component, messages: UiMessage[], thinking: boolean) {
|
|
|
+ return mount(component, {
|
|
|
+ props: {
|
|
|
+ ...(component === WorkspaceMessageTimeline ? { identity: 'timeline' } : {}),
|
|
|
+ messages,
|
|
|
+ thinking,
|
|
|
+ agentAvatars: {
|
|
|
+ 'streaming-profile': 'avatar-streaming',
|
|
|
+ 'history-profile': 'avatar-history',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ global: { stubs },
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+describe('TypingIndicator', () => {
|
|
|
+ it('renders a compact avatar, three dots, elapsed time and status semantics', () => {
|
|
|
+ const wrapper = mount(TypingIndicator, {
|
|
|
+ props: { avatarName: '丫头', avatar: '', elapsedLabel: '3 秒' },
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(wrapper.attributes('role')).toBe('status')
|
|
|
+ expect(wrapper.attributes('aria-live')).toBe('polite')
|
|
|
+ expect(wrapper.attributes('aria-label')).toBe('机器人正在输入')
|
|
|
+ expect(wrapper.attributes('data-testid')).toBe('chat-run-thinking-dots')
|
|
|
+ expect(wrapper.get('.agent-avatar').attributes('style')).toContain('width: 20px')
|
|
|
+ expect(wrapper.findAll('.thinking-indicator__dot')).toHaveLength(3)
|
|
|
+ expect(wrapper.text()).toContain('3 秒')
|
|
|
+ wrapper.unmount()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('message timeline typing state', () => {
|
|
|
+ const timelines: Array<{ name: string; component: Component }> = [
|
|
|
+ { name: 'ordinary', component: MessageTimeline },
|
|
|
+ { name: 'workspace', component: WorkspaceMessageTimeline },
|
|
|
+ ]
|
|
|
+
|
|
|
+ beforeEach(() => {
|
|
|
+ Element.prototype.scrollTo = vi.fn()
|
|
|
+ vi.stubGlobal('ResizeObserver', class {
|
|
|
+ observe() {}
|
|
|
+ unobserve() {}
|
|
|
+ disconnect() {}
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ afterEach(() => {
|
|
|
+ delete (Element.prototype as { scrollTo?: () => void }).scrollTo
|
|
|
+ vi.unstubAllGlobals()
|
|
|
+ vi.useRealTimers()
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each(timelines)('keeps the typing row visible after content, reasoning and tools start ($name)', async ({ component }) => {
|
|
|
+ const wrapper = mountTimeline(component, [user, streamingAssistant()], true)
|
|
|
+
|
|
|
+ const indicator = wrapper.get('[data-testid="chat-run-thinking-dots"]')
|
|
|
+ expect(indicator.attributes('role')).toBe('status')
|
|
|
+ expect(indicator.attributes('aria-label')).toBe('机器人正在输入')
|
|
|
+ expect(wrapper.findAll('.thinking-indicator__dot')).toHaveLength(3)
|
|
|
+ expect(wrapper.text()).toContain('0 秒')
|
|
|
+ const avatar = wrapper.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub')
|
|
|
+ expect(avatar.attributes('data-name')).toBe('流式助手')
|
|
|
+ expect(avatar.attributes('data-avatar')).toBe('avatar-streaming')
|
|
|
+ wrapper.unmount()
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each(timelines)('hides the typing row when the run is no longer thinking ($name)', ({ component }) => {
|
|
|
+ const wrapper = mountTimeline(component, [user, streamingAssistant()], false)
|
|
|
+
|
|
|
+ expect(wrapper.find('[data-testid="chat-run-thinking-dots"]').exists()).toBe(false)
|
|
|
+ wrapper.unmount()
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each(timelines)('falls back from streaming agent to history agent and default agent ($name)', ({ component }) => {
|
|
|
+ const streaming = mountTimeline(component, [
|
|
|
+ user,
|
|
|
+ { id: 'assistant-history', role: 'assistant', author: '历史助手', profile: 'history-profile', content: '旧回复' },
|
|
|
+ streamingAssistant(),
|
|
|
+ ], true)
|
|
|
+ expect(streaming.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-name')).toBe('流式助手')
|
|
|
+ streaming.unmount()
|
|
|
+
|
|
|
+ const history = mountTimeline(component, [
|
|
|
+ user,
|
|
|
+ { id: 'assistant-history', role: 'assistant', author: '历史助手', profile: 'history-profile', content: '旧回复' },
|
|
|
+ ], true)
|
|
|
+ expect(history.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-name')).toBe('历史助手')
|
|
|
+ expect(history.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-avatar')).toBe('avatar-history')
|
|
|
+ history.unmount()
|
|
|
+
|
|
|
+ const fallback = mountTimeline(component, [user], true)
|
|
|
+ expect(fallback.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-name')).toBe('夭')
|
|
|
+ expect(fallback.get('[data-testid="chat-run-thinking-dots"] .agent-avatar-stub').attributes('data-avatar')).toBe('')
|
|
|
+ fallback.unmount()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('counts elapsed time while thinking remains active', async () => {
|
|
|
+ vi.useFakeTimers()
|
|
|
+ const wrapper = mountTimeline(MessageTimeline, [user, streamingAssistant()], true)
|
|
|
+
|
|
|
+ expect(wrapper.text()).toContain('0 秒')
|
|
|
+ vi.advanceTimersByTime(1_100)
|
|
|
+ await nextTick()
|
|
|
+ expect(wrapper.text()).toContain('1 秒')
|
|
|
+ wrapper.unmount()
|
|
|
+ })
|
|
|
+})
|