Files
ManuAgent/web-ui/packages/agent/src/pages/ProjectPage.test.ts

433 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { defineComponent, h } from 'vue'
import { flushPromises, shallowMount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentEvent } from '../api'
import ProjectPage from './ProjectPage.vue'
type StreamEventsCallback = (batch: AgentEvent[]) => void
const apiMock = vi.fn()
let outputResizeCallback: ResizeObserverCallback | null = null
const observeOutputMock = vi.fn()
const unobserveOutputMock = vi.fn()
const disconnectOutputObserverMock = vi.fn()
const mountedWrappers: Array<{ unmount: () => void }> = []
/**
* JSDOM 不提供 ResizeObserver这里保留组件注册的回调模拟流式 Markdown
* 在事件已经写入后仍逐帧增高的真实浏览器行为。
*/
class ResizeObserverMock {
constructor(callback: ResizeObserverCallback) {
outputResizeCallback = callback
}
observe = observeOutputMock
unobserve = unobserveOutputMock
disconnect = disconnectOutputObserverMock
}
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
const streamEventsMock = vi.fn((
_projectId: string,
_after: number,
_onEvents: StreamEventsCallback,
_onError: (error: Error) => void
) => vi.fn())
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { id: 'project-1' } }),
useRouter: () => ({ replace: vi.fn() })
}))
vi.mock('../api', () => ({
api: (...args: unknown[]) => apiMock(...args),
streamEvents: (
projectId: string,
after: number,
onEvents: StreamEventsCallback,
onError: (error: Error) => void
) => streamEventsMock(projectId, after, onEvents, onError)
}))
vi.mock('../eventCache', () => ({
cacheEvents: vi.fn(async () => undefined),
deleteCachedEvents: vi.fn(async () => undefined),
readCachedEvents: vi.fn(async () => [])
}))
vi.mock('element-plus', () => ({
ElMessage: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
ElMessageBox: { confirm: vi.fn() }
}))
// 测试只关心项目页发送的模型选择请求,因此用原生控件模拟 Element Plus 的 v-model 契约。
const ElButtonStub = defineComponent({
inheritAttrs: false,
setup(_, { attrs, emit, slots }) {
return () => h('button', { ...attrs, onClick: () => emit('click') }, slots.default?.())
}
})
const ElDialogStub = defineComponent({
props: { modelValue: Boolean },
setup(props, { slots }) {
return () => props.modelValue
? h('div', { role: 'dialog' }, [slots.default?.(), h('footer', slots.footer?.())])
: null
}
})
const ElSelectStub = defineComponent({
props: { modelValue: String },
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
return () => h('select', {
value: props.modelValue,
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value)
}, slots.default?.())
}
})
const ElOptionStub = defineComponent({
props: { label: String, value: String },
setup(props) {
return () => h('option', { value: props.value }, props.label)
}
})
const enabledModels = [
{ id: 'model-a', name: '当前模型', modelId: 'model-a', enabled: true, defaultModel: false },
{ id: 'model-b', name: '默认模型', modelId: 'model-b', enabled: true, defaultModel: true },
{ id: 'model-c', name: '停用模型', modelId: 'model-c', enabled: false, defaultModel: false }
]
interface ProjectFixtureOptions {
projectStatus?: 'MATERIAL_CHECK' | 'PLANNING' | 'WRITING' | 'DELIVERED' | 'FAILED' | 'ARCHIVED'
files?: Array<Record<string, unknown>>
artifacts?: Array<Record<string, unknown>>
latestRun?: { status: string; modelConfigId?: string } | null
startedRun?: { status: string; modelConfigId?: string }
}
/**
* 构造项目页的最小服务端数据集,允许每个测试只覆盖自己关心的业务状态。
* 默认仍保持原有“申报书编写中”场景,避免模型切换测试因视觉改造改变测试语义。
*/
function mountProject(runStatus: 'RUNNING' | 'INTERRUPTED', options: ProjectFixtureOptions = {}) {
apiMock.mockImplementation(async (url: string, requestOptions?: RequestInit) => {
if (url === '/api/projects/project-1') {
return {
id: 'project-1', companyName: '测试企业', projectName: '申报项目', threadId: 'thread-1',
applicationLevel: 'ADVANCED', status: options.projectStatus || 'WRITING',
createdAt: '2026-08-31T09:00:00Z', updatedAt: '2026-08-31T10:00:00Z'
}
}
if (url === '/api/projects/project-1/files') return options.files || []
if (url === '/api/projects/project-1/artifacts') return options.artifacts || []
if (url.startsWith('/api/projects/project-1/events')) return []
if (url === '/api/projects/project-1/plan') return null
if (url === '/api/projects/project-1/runs/latest') {
return options.latestRun === undefined
? { status: runStatus, modelConfigId: 'model-a' }
: options.latestRun
}
if (url === '/api/models') return enabledModels
if (url === '/api/projects/project-1/runs/material-check' && requestOptions?.method === 'POST') {
return options.startedRun || { status: 'RUNNING', modelConfigId: 'model-a' }
}
if (requestOptions?.method === 'POST') return { status: 'RUNNING' }
throw new Error(`未处理的测试请求:${url}`)
})
const wrapper = shallowMount(ProjectPage, {
global: {
stubs: {
'el-button': ElButtonStub,
'el-dialog': ElDialogStub,
'el-select': ElSelectStub,
'el-option': ElOptionStub,
'el-icon': defineComponent({ setup: (_, { slots }) => () => h('span', slots.default?.()) }),
'el-upload': defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) })
}
}
})
mountedWrappers.push(wrapper)
return wrapper
}
afterEach(() => {
// ProjectPage 会注册全局滚动监听;每个用例结束后必须卸载,避免前一个实例
// 修改下一用例的 followsLatestOutput 状态,造成测试假阳性或假阴性。
while (mountedWrappers.length) mountedWrappers.pop()!.unmount()
})
describe('ProjectPage 模型切换', () => {
beforeEach(() => {
// Vitest 会把钩子返回的函数当作清理回调,因此这里不能直接返回 mockReset() 的返回值。
apiMock.mockReset()
})
it('中断任务继续时允许选择启用模型并发送模型 ID', async () => {
const wrapper = mountProject('INTERRUPTED')
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '继续')!.trigger('click')
await flushPromises()
expect(wrapper.text()).not.toContain('停用模型')
await wrapper.get('select').setValue('model-b')
await wrapper.findAll('button').find(button => button.text() === '继续运行')!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenCalledWith('/api/projects/project-1/runs/resume', {
method: 'POST', body: JSON.stringify({ modelConfigId: 'model-b' })
})
})
it('运行中的任务可以选择替代模型并调用受控切换接口', async () => {
const wrapper = mountProject('RUNNING')
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '切换模型')!.trigger('click')
await flushPromises()
await wrapper.get('select').setValue('model-b')
await wrapper.findAll('button').find(button => button.text() === '确认切换')!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenCalledWith('/api/projects/project-1/runs/switch-model', {
method: 'POST', body: JSON.stringify({ modelConfigId: 'model-b' })
})
})
})
describe('ProjectPage 项目工作区', () => {
beforeEach(() => {
apiMock.mockReset()
streamEventsMock.mockReset()
streamEventsMock.mockReturnValue(vi.fn())
outputResizeCallback = null
observeOutputMock.mockReset()
unobserveOutputMock.mockReset()
disconnectOutputObserverMock.mockReset()
})
it('根据项目状态展示四阶段进度,并在右侧汇总模型、材料和生成文件', async () => {
const wrapper = mountProject('RUNNING', {
projectStatus: 'WRITING',
files: [
{ id: 'file-1', name: '企业营业执照.pdf', relativePath: '企业营业执照.pdf', extension: 'pdf', sizeBytes: 1024, status: 'READY', createdAt: '' },
{ id: 'file-2', name: '财务报表.xlsx', relativePath: '财务报表.xlsx', extension: 'xlsx', sizeBytes: 2048, status: 'READY', createdAt: '' }
],
artifacts: [
{ id: 'artifact-1', name: '先进级申报书.docx', kind: 'DOCX', sizeBytes: 4096, metadataJson: '{}', publishedAt: '' }
]
})
await flushPromises()
const stages = wrapper.findAll('.project-stage')
expect(stages).toHaveLength(4)
expect(stages.slice(0, 2).every(stage => stage.classes().includes('is-complete'))).toBe(true)
expect(stages[2]!.text()).toContain('申报书编写')
expect(stages[2]!.attributes('aria-current')).toBe('step')
const context = wrapper.get('.project-context-panel')
expect(context.text()).toContain('当前模型')
expect(context.text()).toContain('当前模型 · model-a')
expect(context.text()).toContain('2 个文件')
expect(context.text()).toContain('企业营业执照.pdf')
expect(context.text()).toContain('1 个文件')
expect(context.text()).toContain('先进级申报书.docx')
expect(context.get('a').attributes('href')).toBe('/api/artifacts/artifact-1/download')
})
it('首次启动材料检查后立即显示本次 Run 实际绑定的模型', async () => {
const wrapper = mountProject('RUNNING', {
projectStatus: 'MATERIAL_CHECK',
latestRun: null,
startedRun: { status: 'RUNNING', modelConfigId: 'model-a' }
})
await flushPromises()
expect(wrapper.get('.current-model-name').text()).toBe('尚未选择')
await wrapper.findAll('button').find(button => button.text() === '开始材料检验')!.trigger('click')
await flushPromises()
expect(wrapper.get('.current-model-name').text()).toBe('当前模型 · model-a')
})
it('用户向上滚动后暂停跟随新输出,并可通过箭头回到最新位置', async () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 600 })
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 1800 })
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1200 })
const wrapper = mountProject('RUNNING')
await flushPromises()
scrollTo.mockClear()
// 即使只向上移动 20px也已代表用户主动回看历史下一批输出不能把页面拉回底部。
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1180 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
const latestButton = wrapper.get('.back-to-bottom')
expect(latestButton.attributes('aria-label')).toBe('转到最新输出')
const streamCallback = streamEventsMock.mock.calls[0]?.[2]
expect(streamCallback).toBeTypeOf('function')
streamCallback!([{
id: 1,
projectId: 'project-1',
runId: 'run-1',
type: 'TEXT_MESSAGE_CONTENT',
payload: { delta: '新输出' },
createdAt: '2026-09-03T10:00:00Z'
}])
await flushPromises()
expect(scrollTo).not.toHaveBeenCalled()
await latestButton.trigger('click')
expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: 'auto' })
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 箭头点击后如果输出容器先发生尺寸变化,必须保留程序滚动目标,
// 不能因 ResizeObserver 的再次滚动把“持续跟随”状态提前结束。
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 1900 })
outputResizeCallback!([], {} as ResizeObserver)
// 大文档滚动可能先派发尚未到达目标底部的中间事件,不能把程序滚动误判为用户再次上滚。
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1190 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 点击后浏览器可能先派发仍接近旧位置的事件;即使距离底部小于通用阈值,
// 也不能提前结束程序滚动跟踪,否则紧接着的布局滚动会再次显示箭头。
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1180 })
window.dispatchEvent(new Event('scroll'))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1170 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 用户上滚事件先于 scroll 到达时,必须立即取消跟随,不能被尺寸观察器抢回底部。
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
scrollTo.mockClear()
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2100 })
outputResizeCallback!([], {} as ResizeObserver)
await wrapper.vm.$nextTick()
expect(scrollTo).not.toHaveBeenCalled()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
// 已取消的程序滚动可能仍会派发到达底部的延迟事件,不能借此错误恢复跟随。
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
// 用户明确向下滚动并抵达底部时,仍应恢复持续跟随。
window.dispatchEvent(new WheelEvent('wheel', { deltaY: 32 }))
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 在输入控件内使用方向键不应改变页面的自动跟随状态。
const modelInput = document.createElement('input')
document.body.appendChild(modelInput)
modelInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
modelInput.remove()
// 控件内部的图标/子节点事件也不应触发页面滚动状态切换。
const control = document.createElement('button')
const icon = document.createElement('span')
control.appendChild(icon)
document.body.appendChild(control)
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
icon.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
control.remove()
// 空格键同样会向下翻页,回到底部后应恢复持续跟随。
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 滚动条拖动期间,旧程序滚动到达底部不能提前恢复跟随;释放后向下拖到底部才恢复。
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1000 })
window.dispatchEvent(new WheelEvent('wheel', { deltaY: -24 }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1100 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
window.dispatchEvent(new MouseEvent('mousedown', { clientX: 1000, clientY: 300 }))
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 1000, clientY: 100 }))
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1500 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
window.dispatchEvent(new MouseEvent('mouseup', { clientX: 1000 }))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
// 向下拖动滚动条并释放到底部时才恢复跟随。
window.dispatchEvent(new MouseEvent('mousedown', { clientX: 1000, clientY: 100 }))
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 1000, clientY: 300 }))
window.dispatchEvent(new MouseEvent('mouseup', { clientX: 1000, clientY: 300 }))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 触摸滚动/滚动条拖动没有 wheel 事件,也必须能取消跟随。
window.dispatchEvent(new TouchEvent('touchmove'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(true)
await wrapper.get('.back-to-bottom').trigger('click')
// 点击箭头代表重新进入持续跟随模式;同一批 Markdown 的平滑渲染继续增高时也必须跟随。
scrollTo.mockClear()
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 })
expect(outputResizeCallback).not.toBeNull()
outputResizeCallback!([], {} as ResizeObserver)
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
// 即使新内容只让底部前移 20px也必须记录程序目标防止中间 scroll 事件误停跟随。
scrollTo.mockClear()
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1380 })
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 2000 })
outputResizeCallback!([], {} as ResizeObserver)
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
Object.defineProperty(window, 'scrollY', { configurable: true, value: 1370 })
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
// 尺寸变化后的新流事件到达时仍应保持跟随,而不是重新显示箭头。
scrollTo.mockClear()
streamCallback!([{
id: 2,
projectId: 'project-1',
runId: 'run-1',
type: 'TEXT_MESSAGE_CONTENT',
payload: { delta: '后续输出' },
createdAt: '2026-09-03T10:00:01Z'
}])
await flushPromises()
expect(scrollTo).toHaveBeenCalledWith({ top: 2000 })
expect(wrapper.find('.back-to-bottom').exists()).toBe(false)
})
})