2026-10-09 11:01:41 +08:00
|
|
|
import { flushPromises, mount } from '@vue/test-utils'
|
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
2026-10-15 17:11:03 +08:00
|
|
|
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 }))
|
|
|
|
|
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() {
|
|
|
|
|
return mount(AppointmentCalendarView, { global: { stubs: {
|
2026-10-09 11:01:41 +08:00
|
|
|
AdminPage: { template: '<main><slot /></main>' },
|
|
|
|
|
ElButton: { template: '<button><slot /></button>' },
|
2026-10-15 17:11:03 +08:00
|
|
|
WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference'], template: '<div data-calendar />' },
|
|
|
|
|
WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], 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,
|
2026-10-09 11:01:41 +08:00
|
|
|
} } })
|
|
|
|
|
}
|
2026-10-15 17:11:03 +08:00
|
|
|
describe('appointment work calendar', () => {
|
2026-10-09 11:01:41 +08:00
|
|
|
beforeEach(() => {
|
2026-10-15 17:11:03 +08:00
|
|
|
vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = 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 })
|
2026-10-09 11:01:41 +08:00
|
|
|
vi.mocked(listArrivalCalendar).mockResolvedValue([])
|
|
|
|
|
})
|
2026-10-15 17:11:03 +08:00
|
|
|
it('opens the real work calendar by default and converts its half-open helper range to the inclusive API contract', async () => {
|
2026-10-12 11:45:00 +08:00
|
|
|
const wrapper = render(); await flushPromises()
|
2026-10-15 17:11:03 +08:00
|
|
|
expect(wrapper.text()).toContain('预约工作日历')
|
|
|
|
|
expect(getWorkCalendar).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-08', flow: undefined, types: ['PICKUP', 'WAREHOUSE_RECEIPT', 'DELIVERY', 'TRANSPORT', 'EMPTY_RETURN'] }, expect.any(AbortSignal))
|
|
|
|
|
expect(getWorkSummary).toHaveBeenCalledTimes(1); expect(getWorkItems).not.toHaveBeenCalled(); expect(listArrivalCalendar).not.toHaveBeenCalled()
|
2026-10-12 11:45:00 +08:00
|
|
|
wrapper.unmount()
|
|
|
|
|
})
|
2026-10-15 17:11:03 +08:00
|
|
|
it('sends an explicit safe return context with external business navigation', async () => {
|
|
|
|
|
routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3', workOrigins: '["work:0"]' }
|
|
|
|
|
const wrapper = render(); await flushPromises()
|
|
|
|
|
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('external', '/overseas-master-data/trips/TR-001?tab=loading'); await flushPromises()
|
|
|
|
|
expect(routing.replace).toHaveBeenCalledWith({ query: expect.objectContaining({ workItem: 'work:1', workOrigins: '["work:0"]', workPage: '3' }) })
|
|
|
|
|
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-master-data/trips/TR-001', query: { tab: 'loading', appointmentReturn: expect.stringContaining('/appointment-calendar?') } })
|
2026-10-11 13:27:39 +08:00
|
|
|
wrapper.unmount()
|
|
|
|
|
})
|
2026-10-15 17:11:03 +08:00
|
|
|
it('retains all global counts and unfiltered global detail through month, flow and all-off layer changes', async () => {
|
2026-10-11 13:07:52 +08:00
|
|
|
const wrapper = render(); await flushPromises()
|
2026-10-15 17:11:03 +08:00
|
|
|
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
|
|
|
|
const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' })
|
|
|
|
|
calendar.vm.$emit('anchor', 1); await flushPromises()
|
|
|
|
|
calendar.vm.$emit('flow', 'DIRECT'); await flushPromises()
|
|
|
|
|
const calls = vi.mocked(getWorkCalendar).mock.calls.length
|
|
|
|
|
calendar.vm.$emit('types', []); await flushPromises()
|
|
|
|
|
expect(getWorkCalendar).toHaveBeenCalledTimes(calls)
|
|
|
|
|
expect(getWorkSummary).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(wrapper.get('[data-view="UNDATED"]').text()).toContain('7')
|
|
|
|
|
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
|
|
|
|
|
expect(wrapper.text()).toContain('作业日期未定'); expect(wrapper.text()).toContain('跨月份、流程与类型')
|
2026-10-11 13:07:52 +08:00
|
|
|
wrapper.unmount()
|
|
|
|
|
})
|
2026-10-15 17:11:03 +08:00
|
|
|
it('uses explicit day and type filters only for date details, and includes completed work through the server bucket', async () => {
|
2026-10-11 13:07:52 +08:00
|
|
|
const wrapper = render(); await flushPromises()
|
2026-10-15 17:11:03 +08:00
|
|
|
wrapper.getComponent({ name: 'WorkCalendarPanel' }).vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises()
|
|
|
|
|
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, date: '2026-10-15', types: ['PICKUP'], flow: undefined, bucket: undefined }, expect.any(AbortSignal))
|
|
|
|
|
await wrapper.findAll('.bucket-buttons button').find(button => button.text() === '已完成')!.trigger('click'); await flushPromises()
|
|
|
|
|
expect(vi.mocked(getWorkItems).mock.calls.at(-1)![0].bucket).toBe('COMPLETED')
|
|
|
|
|
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
|
|
|
|
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8 }, expect.any(AbortSignal))
|
2026-10-11 13:07:52 +08:00
|
|
|
wrapper.unmount()
|
|
|
|
|
})
|
2026-10-15 17:11:03 +08:00
|
|
|
it('shows a failed source as an error, not zero work or all complete', async () => {
|
|
|
|
|
vi.mocked(getWorkSummary).mockRejectedValue(new Error('source unavailable'))
|
|
|
|
|
vi.mocked(getWorkCalendar).mockRejectedValue(new Error('source unavailable'))
|
|
|
|
|
vi.mocked(getWorkItems).mockRejectedValue(new Error('source unavailable'))
|
2026-10-10 15:33:10 +08:00
|
|
|
const wrapper = render(); await flushPromises()
|
2026-10-15 17:11:03 +08:00
|
|
|
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
|
|
|
|
expect(wrapper.get('[data-view="ALL"]').text()).toContain('加载失败')
|
|
|
|
|
expect(wrapper.getComponent({ name: 'WorkCalendarPanel' }).props('failed')).toBe(true)
|
|
|
|
|
expect(wrapper.text()).toContain('工作明细读取失败'); expect(wrapper.text()).not.toContain('当前范围没有工作事项')
|
|
|
|
|
wrapper.unmount()
|
2026-10-10 15:33:10 +08:00
|
|
|
})
|
2026-10-15 17:11:03 +08:00
|
|
|
it('ignores a late response after the global scope changes', async () => {
|
|
|
|
|
let resolveOld!: (value: Awaited<ReturnType<typeof getWorkItems>>) => void
|
|
|
|
|
vi.mocked(getWorkItems).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
|
2026-10-10 10:44:07 +08:00
|
|
|
const wrapper = render(); await flushPromises()
|
2026-10-15 17:11:03 +08:00
|
|
|
await wrapper.get('[data-view="OVERDUE"]').trigger('click')
|
|
|
|
|
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
|
|
|
|
resolveOld({ records: [workItem({ title: '过期响应不得覆盖' })], page: 1, size: 8, total: 1, pages: 1 }); await flushPromises()
|
|
|
|
|
expect(wrapper.text()).not.toContain('过期响应不得覆盖'); expect(wrapper.text()).toContain('批次 SD-001')
|
|
|
|
|
wrapper.unmount()
|
2026-10-10 10:44:07 +08:00
|
|
|
})
|
2026-10-15 17:11:03 +08:00
|
|
|
it('returns from a prerequisite to the original item and preserves the original undated list context', async () => {
|
2026-10-09 22:05:51 +08:00
|
|
|
const wrapper = render(); await flushPromises()
|
2026-10-15 17:11:03 +08:00
|
|
|
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
|
|
|
|
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
|
|
|
|
|
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(followAction)
|
|
|
|
|
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'work-pickup-1', followAction); await flushPromises()
|
|
|
|
|
expect(wrapper.text()).toContain('返回原事项')
|
|
|
|
|
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved'); await flushPromises()
|
|
|
|
|
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('work-delivery-1')
|
|
|
|
|
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
|
|
|
|
|
wrapper.unmount()
|
2026-10-09 22:05:51 +08:00
|
|
|
})
|
2026-10-15 17:11:03 +08:00
|
|
|
it('goes directly to the current container unfinished list after route confirmation', async () => {
|
2026-10-09 22:05:51 +08:00
|
|
|
const wrapper = render(); await flushPromises()
|
2026-10-15 17:11:03 +08:00
|
|
|
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
|
|
|
|
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
|
|
|
|
|
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: ['pickup', 'receipt'] }, 'CONFIRM_ROUTE'); await flushPromises()
|
|
|
|
|
expect(wrapper.findComponent({ name: 'WorkDetailPanel' }).exists()).toBe(false)
|
|
|
|
|
expect(wrapper.text()).toContain('TEST-CN-001 · 本柜待办')
|
|
|
|
|
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, containerBookingId: '9007199254740993' }, expect.any(AbortSignal))
|
|
|
|
|
expect(getWorkSummary).toHaveBeenCalledTimes(2)
|
|
|
|
|
wrapper.unmount()
|
2026-10-09 22:05:51 +08:00
|
|
|
})
|
2026-10-15 17:11:03 +08:00
|
|
|
it('opens explicit source verification without forging an appointment or guessing a customer', async () => {
|
|
|
|
|
routing.query = { ...routing.query, sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }
|
2026-10-09 22:05:51 +08:00
|
|
|
const wrapper = render(); await flushPromises()
|
2026-10-15 17:11:03 +08:00
|
|
|
expect(wrapper.getComponent({ name: 'SourceAppointmentPanel' }).props('source')).toEqual({ sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' })
|
|
|
|
|
wrapper.unmount()
|
|
|
|
|
routing.canReadSource = false
|
|
|
|
|
const denied = render(); await flushPromises()
|
|
|
|
|
expect(denied.findComponent({ name: 'SourceAppointmentPanel' }).exists()).toBe(false)
|
|
|
|
|
expect(denied.text()).toContain('没有货源读取权限'); denied.unmount()
|
2026-10-09 22:05:51 +08:00
|
|
|
})
|
2026-10-09 11:01:41 +08:00
|
|
|
})
|