chore: 项目环境升级为 JDK25,Spring 4.1,项目重构为多模块

This commit is contained in:
2026-09-08 15:02:18 +08:00
parent d392436620
commit 81a0d81f11
168 changed files with 2785 additions and 1375 deletions

View File

@@ -0,0 +1,432 @@
// @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)
})
})