// @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> artifacts?: Array> 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) }) })