Phase 006: migrate the chat page from naive-ui to Arco Design Vue
Swaps the rendering layer only. Chat store logic, keyboard handling, and the /chat-run Socket.IO contract are untouched, so a mixed UI cannot hide a chat regression behind a library swap. Arco's dropdowns take their items from a #content slot of a-doption children rather than a droplist prop, and Arco Triggers cannot be nested, so the two toolbar menus lost their a-tooltip in favour of a native title. Both rules are recorded in docs/arco-mapping.md, and tests/e2e/chat-input-dropdown.spec.ts plus tests/e2e/chat-rendering-smoke.spec.ts keep the menus, the streaming markdown rendering, and the no-Arco-inside-the-bubble red line from regressing silently.
This commit is contained in:
@@ -3,6 +3,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { nextTick } from 'vue'
|
||||
import ArcoVue, { Message } from '@arco-design/web-vue'
|
||||
import { useChatStore } from '@/stores/hermes/chat'
|
||||
import { useSettingsStore } from '@/stores/hermes/settings'
|
||||
import ChatInput from '@/components/hermes/chat/ChatInput.vue'
|
||||
@@ -13,34 +14,63 @@ vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NButton: { template: '<button type="button" v-bind="$attrs"><slot /><slot name="icon" /></button>' },
|
||||
NTooltip: { template: '<div><slot name="trigger" /><slot /></div>' },
|
||||
NSwitch: { template: '<button type="button"></button>' },
|
||||
NDropdown: { template: '<div><slot /></div>' },
|
||||
NModal: { template: '<div><slot /><slot name="footer" /></div>' },
|
||||
NInputNumber: { template: '<input />' },
|
||||
NPopselect: {
|
||||
props: ['value', 'options'],
|
||||
emits: ['update:value'],
|
||||
template: `
|
||||
<div class="n-popselect-stub">
|
||||
<slot />
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="n-popselect-option"
|
||||
:data-value="option.value"
|
||||
@click="$emit('update:value', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
// Arco's imperative message API needs a live DOM; the rest runs for real.
|
||||
vi.mock('@arco-design/web-vue', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@arco-design/web-vue')>(
|
||||
'@arco-design/web-vue',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
Message: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* a-modal / a-dropdown teleport to body in jsdom, so stub them to render inline.
|
||||
* Arco is registered globally, so stubs must key off Arco's internal component
|
||||
* name (`Modal`, `Dropdown`), not the `a-*` template tag.
|
||||
*/
|
||||
const AModalStub = {
|
||||
name: 'Modal',
|
||||
props: ['visible'],
|
||||
template: '<div v-if="visible" class="a-modal-stub"><slot /><slot name="footer" /></div>',
|
||||
}
|
||||
|
||||
/**
|
||||
* Arco 的 a-dropdown 通过 #content 插槽 + a-doption 声明菜单,没有 droplist prop。
|
||||
* 这里把 content 插槽内联渲染出来,好让测试直接点到某个推理档位;真实 Arco 里
|
||||
* 是 Doption 通过 inject 调 dropdown 的 onOptionClick,stub 用事件冒泡等价实现。
|
||||
*/
|
||||
const ADropdownStub = {
|
||||
name: 'Dropdown',
|
||||
emits: ['select'],
|
||||
methods: {
|
||||
forwardSelect(event: MouseEvent) {
|
||||
const target = (event.target as HTMLElement | null)?.closest('[data-value]')
|
||||
const value = target?.getAttribute('data-value')
|
||||
if (value !== undefined && value !== null) this.$emit('select', value)
|
||||
},
|
||||
},
|
||||
useMessage: () => ({ error: vi.fn(), success: vi.fn() }),
|
||||
}))
|
||||
template: `
|
||||
<div class="a-dropdown-stub" @click="forwardSelect">
|
||||
<slot />
|
||||
<slot name="content" />
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
const ADoptionStub = {
|
||||
name: 'Doption',
|
||||
props: ['value'],
|
||||
template: '<button type="button" class="a-dropdown-option" :data-value="value"><slot /></button>',
|
||||
}
|
||||
|
||||
vi.mock('@/api/hermes/sessions', () => ({
|
||||
fetchContextLength: vi.fn().mockResolvedValue(256000),
|
||||
@@ -72,7 +102,13 @@ function mountForSession(
|
||||
chatStore.activeSessionId = sessionId
|
||||
chatStore.activeSession = chatStore.sessions[0]
|
||||
settingsStore.display = displayOverrides
|
||||
return mount(ChatInput, { global: { plugins: [pinia] } })
|
||||
return mount(ChatInput, {
|
||||
global: {
|
||||
plugins: [pinia, ArcoVue],
|
||||
// Overlays teleport in jsdom, so render dropdown and modal inline.
|
||||
stubs: { Dropdown: ADropdownStub, Doption: ADoptionStub, Modal: AModalStub },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ChatInput draft persistence', () => {
|
||||
@@ -171,15 +207,24 @@ describe('ChatInput draft persistence', () => {
|
||||
const wrapper = mountForSession('session-reasoning-effort')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.n-popselect-stub').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-value="high"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.reasoning-effort-button').exists()).toBe(true)
|
||||
expect(
|
||||
wrapper.find('.reasoning-effort-button').element.closest('.a-dropdown-stub'),
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('stores the selected reasoning effort for the active session', async () => {
|
||||
const wrapper = mountForSession('session-reasoning')
|
||||
const store = useChatStore()
|
||||
|
||||
await wrapper.get('[data-value="high"]').trigger('click')
|
||||
const effortDropdown = wrapper
|
||||
.find('.reasoning-effort-button')
|
||||
.element.closest('.a-dropdown-stub')
|
||||
expect(effortDropdown).not.toBeNull()
|
||||
|
||||
const effortOptions = wrapper.findAll('.a-dropdown-stub [data-value="high"]')
|
||||
expect(effortOptions.length).toBeGreaterThan(0)
|
||||
await effortOptions[0].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(store.sessions[0].reasoningEffort).toBe('high')
|
||||
|
||||
Reference in New Issue
Block a user