chore: 项目环境升级为 JDK25,Spring 4.1,项目重构为多模块
This commit is contained in:
432
web-ui/packages/agent/src/pages/ProjectPage.test.ts
Normal file
432
web-ui/packages/agent/src/pages/ProjectPage.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user