87 lines
7.2 KiB
TypeScript
87 lines
7.2 KiB
TypeScript
import { flushPromises, mount } from '@vue/test-utils'
|
|||
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
|
import { ApiResponseError } from '@/shared/api/http'
|
||
|
|
import { defineComponent, h, inject, KeepAlive, onBeforeUnmount, ref, type Ref } from 'vue'
|
||
|
|
import { ElDialog, ElPopover } from 'element-plus'
|
||
|
|
import type { LeaveGuard } from './useWorkDraft'
|
||
|
|
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||
|
|
import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi'
|
||
|
|
import { listArrivalCalendar } from './api'
|
||
|
|
import { workCalendar, workItem, workSummary, followAction } from './work.test-support'
|
||
|
|
const routing = vi.hoisted(() => ({ query: {} as Record<string, string>, push: vi.fn(), replace: vi.fn(), canReadSource: true, allowLeave: true }))
|
||
|
|
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||
|
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'platform:overseas-warehouse:last-mile-order:list' || routing.canReadSource }) }))
|
||
|
|
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkCalendar: vi.fn(), getWorkItems: vi.fn(), getWorkSummary: vi.fn() }))
|
||
|
|
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), listArrivalCalendar: vi.fn() }))
|
||
|
|
function render(realDialog = false, active?: Ref<boolean>) {
|
||
|
|
const host = active ? defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(AppointmentCalendarView) : h('div', { 'data-other-page': '' }) }) }) : AppointmentCalendarView
|
||
|
|
return mount(host, { attachTo: document.body, global: { components: { ElDialog, ElPopover }, directives: { loading: {} }, stubs: {
|
||
|
|
teleport: !active,
|
||
|
|
ElTooltip: active ? false : { template: '<slot />' },
|
||
|
|
ElDialog: realDialog ? ElDialog : { name: 'ElDialog', props: ['modelValue', 'title', 'fullscreen', 'beforeClose', 'closeOnClickModal', 'closeOnPressEscape'], emits: ['update:modelValue'], template: '<div v-if="modelValue" class="el-dialog" :class="{ \'is-fullscreen\': fullscreen }"><button aria-label="关闭工作面板" @click="beforeClose(() => $emit(\'update:modelValue\', false))">关闭</button><slot name="header" /><slot /><slot name="footer" /></div>' },
|
||
|
|
AdminPage: { template: '<main><slot /></main>' },
|
||
|
|
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields', 'loading'], emits: ['update:modelValue', 'search'], template: '<div data-list-search />' },
|
||
|
|
DataTable: { name: 'DataTable', props: ['data', 'columns', 'total', 'page', 'pageSize'], emits: ['update:page'], template: `<div><table><thead><tr><th v-for="column in columns" :key="column.key">{{ column.label }}</th></tr></thead><tbody><tr v-for="row in data" :key="row.workItemKey"><td v-for="column in columns" :key="column.key"><slot v-if="column.slotName" :name="column.slotName" :row="row" /><template v-else>{{ row[column.key] }}</template></td></tr></tbody></table><footer class="data-table__pagination"><slot name="pagination-left" /><button aria-label="下一页" @click="$emit('update:page', page + 1)">下一页</button></footer></div>` },
|
||
|
|
ElButton: { props: ['type'], template: '<button type="button" :data-variant="type"><slot /></button>' },
|
||
|
|
WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference', 'selectedDate', 'selectedType'], template: '<div data-calendar><slot name="inbox" /></div>' },
|
||
|
|
WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], setup() {
|
||
|
|
const register = inject<(guard: LeaveGuard) => () => void>('appointment-work-leave-guard')!
|
||
|
|
const unregister = register(async () => routing.allowLeave)
|
||
|
|
onBeforeUnmount(unregister)
|
||
|
|
}, template: '<div data-detail>{{ itemKey }}</div>' },
|
||
|
|
SourceAppointmentPanel: { name: 'SourceAppointmentPanel', props: ['source'], template: '<div data-cargo />' },
|
||
|
|
LoadErrorState: { props: ['title', 'message'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button @click="$emit(\'retry\')">重试</button></section>' },
|
||
|
|
ApiErrorMeta: true,
|
||
|
|
} } })
|
||
|
|
}
|
||
|
|
describe('appointment repair regression', () => {
|
||
|
|
afterEach(() => vi.useRealTimers())
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true; routing.allowLeave = true
|
||
|
|
routing.replace.mockResolvedValue(undefined); routing.push.mockResolvedValue(undefined)
|
||
|
|
vi.mocked(getWorkCalendar).mockResolvedValue(workCalendar)
|
||
|
|
vi.mocked(getWorkSummary).mockResolvedValue(workSummary)
|
||
|
|
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 1, pages: 1 })
|
||
|
|
vi.mocked(listArrivalCalendar).mockResolvedValue([])
|
||
|
|
})
|
||
|
|
it('waits for synchronization before restoring calendar, summary and current work without showing a failed save', async () => {
|
||
|
|
vi.useFakeTimers()
|
||
|
|
routing.query = { workScope: 'global', workView: 'ALL' }
|
||
|
|
const pending = new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409)
|
||
|
|
vi.mocked(getWorkCalendar).mockRejectedValueOnce(pending)
|
||
|
|
vi.mocked(getWorkSummary).mockRejectedValueOnce(pending)
|
||
|
|
vi.mocked(getWorkItems).mockRejectedValueOnce(pending)
|
||
|
|
const wrapper = render(); await flushPromises()
|
||
|
|
expect(wrapper.text()).toContain('同步中')
|
||
|
|
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||
|
|
expect(wrapper.text()).not.toContain('加载失败')
|
||
|
|
await vi.advanceTimersByTimeAsync(2000); await flushPromises()
|
||
|
|
expect(getWorkCalendar).toHaveBeenCalledTimes(2)
|
||
|
|
expect(getWorkSummary).toHaveBeenCalledTimes(2)
|
||
|
|
expect(getWorkItems).toHaveBeenCalledTimes(2)
|
||
|
|
expect(wrapper.text()).not.toContain('同步中')
|
||
|
|
wrapper.unmount()
|
||
|
|
})
|
||
|
|
it('cancels synchronization polling when leaving the cached calendar', async () => {
|
||
|
|
vi.useFakeTimers()
|
||
|
|
vi.mocked(getWorkSummary).mockRejectedValue(new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409))
|
||
|
|
const active = ref(true), wrapper = render(false, active); await flushPromises()
|
||
|
|
expect(getWorkSummary).toHaveBeenCalledTimes(1)
|
||
|
|
active.value = false; await flushPromises()
|
||
|
|
await vi.advanceTimersByTimeAsync(30000)
|
||
|
|
expect(getWorkSummary).toHaveBeenCalledTimes(1)
|
||
|
|
wrapper.unmount()
|
||
|
|
})
|
||
|
|
it('removes the teleported dialog while a cached appointment page is inactive and restores its context', async () => {
|
||
|
|
routing.query = { workScope: 'global', workView: 'UNDATED', workPage: '3', workItem: 'work:1' }
|
||
|
|
const active = ref(true), wrapper = render(true, active); await flushPromises()
|
||
|
|
expect(document.body.querySelector('.work-dialog [data-detail]')?.textContent).toBe('work:1')
|
||
|
|
active.value = false; await flushPromises()
|
||
|
|
expect(document.body.querySelector('.work-dialog')).toBeNull()
|
||
|
|
active.value = true; await flushPromises()
|
||
|
|
expect(document.body.querySelector('.work-dialog [data-detail]')?.textContent).toBe('work:1')
|
||
|
|
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal))
|
||
|
|
wrapper.unmount()
|
||
|
|
})
|
||
|
|
})
|