import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { onBeforeRouteLeave } from 'vue-router' import { ElMessageBox } from 'element-plus' import { ApiResponseError } from '@/shared/api/http' import AppointmentCalendarView from './AppointmentCalendarView.vue' import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi' import { listTripCalendar } from './tripCalendar' import type { ScheduleEvent } from './schedule' const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record, push: vi.fn(), replace: vi.fn(), denied: new Set() })) vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) })) vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() })) vi.mock('./tripCalendar', async original => ({ ...await original(), listTripCalendar: vi.fn() })) vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } })) const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true } const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true } export function renderSchedule() { return mount(AppointmentCalendarView, { global: { stubs: { AdminPage: { template: '
' }, AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode', 'canCreate'], template: '
' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '
' }, WorkDetailPanel: true, SourceAppointmentPanel: true, ApiErrorMeta: true, AppointmentCreatePanel: { name: 'AppointmentCreatePanel', props: ['canWrite', 'canLeave'], emits: ['created'], template: '
' }, LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '
{{ title }} {{ message }}
' }, } } }) } beforeEach(() => { vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear() vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true }) vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse]) vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] }) vi.mocked(saveWarehouseArrival).mockResolvedValue({}) }) describe('simplified appointment entry', () => { it('opens creation lazily with the original read and write permissions and refreshes after creation', async () => { const wrapper = renderSchedule(); await flushPromises() expect(wrapper.find('[data-create-panel]').exists()).toBe(false) const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }) expect(calendar.props('canCreate')).toBe(true) calendar.vm.$emit('create'); await flushPromises() const panel = wrapper.getComponent({ name: 'AppointmentCreatePanel' }) expect(panel.props('canWrite')).toBe(true) vi.mocked(getCalendarSources).mockClear() panel.vm.$emit('created'); await flushPromises() expect(getCalendarSources).toHaveBeenCalledTimes(1) await wrapper.get('[data-close]').trigger('click'); await flushPromises() expect(wrapper.find('[data-create-panel]').exists()).toBe(false) wrapper.unmount() }) it.each(['platform:overseas-warehouse:inbound-order:fba-appointment', 'platform:overseas-warehouse:last-mile-order:list'])('does not open creation without %s', async permission => { routing.denied.add(permission) const wrapper = renderSchedule(); await flushPromises() const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }) expect(calendar.props('canCreate')).toBe(false) calendar.vm.$emit('create'); await flushPromises() expect(wrapper.find('[data-create-panel]').exists()).toBe(false) wrapper.unmount() }) it('keeps unsaved input when the operator declines to leave', async () => { vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel') const wrapper = renderSchedule(); await flushPromises() wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises() await wrapper.get('input').setValue('2026-10-22T09:30') await wrapper.get('[data-close]').trigger('click'); await flushPromises() expect(ElMessageBox.confirm).toHaveBeenCalled() expect(wrapper.find('[data-dialog]').exists()).toBe(true) expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30') wrapper.unmount() }) it('loads original sources and real trips without loading work summaries or generated work items', async () => { const wrapper = renderSchedule(); await flushPromises() expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-02' }, ['1'], expect.any(AbortSignal)) const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }) expect(panel.props('events').map((event: ScheduleEvent) => [event.type, event.date])).toEqual([['arrival', '2026-10-19'], ['warehouse', '2026-10-21']]) expect(wrapper.text()).not.toMatch(/全局待办|跟进|复核/) wrapper.unmount() }) it('keeps available arrivals while reporting a failed warehouse source instead of inventing defaults', async () => { vi.mocked(getWarehouseArrivals).mockRejectedValue(new Error('offline')) const wrapper = renderSchedule(); await flushPromises() expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events').map((event: ScheduleEvent) => event.type)).toEqual(['arrival']) expect(wrapper.text()).toContain('计划到仓读取失败') expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('incomplete')).toBe(true) wrapper.unmount() }) it('does not request unreadable domains and describes the incomplete scope', async () => { routing.denied.add('platform:fleet:trip:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list') const wrapper = renderSchedule(); await flushPromises() expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled() expect(wrapper.text()).toContain('缺少车次读取权限') expect(wrapper.text()).toContain('缺少尾程订单读取权限') wrapper.unmount() }) it('ignores stale calendar responses when the date range changes', async () => { let resolveOld!: (value: Awaited>) => void vi.mocked(getCalendarSources).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })) const wrapper = renderSchedule(); await flushPromises() wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('range', { from: '2026-11-01', to: '2026-11-08' }); await flushPromises() resolveOld({ containers: [], emptyReturns: [], emptyReturnsReadable: false }); await flushPromises() expect(wrapper.text()).not.toContain('缺少还柜读取权限') expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0) wrapper.unmount() }) it('retries an unknown save with the same payload and key, then only reloads reads', async () => { vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({}) const wrapper = renderSchedule(); await flushPromises() wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises() await wrapper.get('input[type="datetime-local"]').setValue('2026-10-22T09:30') await wrapper.get('[data-save]').trigger('click'); await flushPromises() expect(wrapper.text()).toContain('提交结果尚未确认') expect(wrapper.get('input').attributes('disabled')).toBeDefined() const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise expect(await guard()).toBe(false) await wrapper.get('[data-save]').trigger('click'); await flushPromises() expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0]) expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String)) expect(wrapper.find('[data-dialog]').exists()).toBe(false) wrapper.unmount() }) it('requires an explicit reread after a conflict and retains the entered date', async () => { vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409)) const wrapper = renderSchedule(); await flushPromises() wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises() await wrapper.get('input').setValue('2026-10-22T09:30') await wrapper.get('[data-save]').trigger('click'); await flushPromises() expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined() vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }]) await wrapper.get('[data-retry]').trigger('click'); await flushPromises() expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30') await wrapper.get('[data-save]').trigger('click'); await flushPromises() expect(saveWarehouseArrival).toHaveBeenLastCalledWith('1', expect.objectContaining({ expectedVersion: 2 }), expect.any(String)) wrapper.unmount() }) it('keeps the selected date and view in the source return link', async () => { const wrapper = renderSchedule(); await flushPromises() const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }) panel.vm.$emit('select', '2026-10-21', 'week') panel.vm.$emit('open', { ...row, sourcePath: '/container-loading/overseas/1' }); await flushPromises() expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } }) wrapper.unmount() }) })