import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support' 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, type TripCalendarEntry } 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, plannedDate: '2026-10-21', isDefault: true, 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 } function dispatchEntry(overrides: Partial = {}): TripCalendarEntry { return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides } } export function renderSchedule() { return mount(AppointmentCalendarView, { global: { stubs: { ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub, 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, ElForm: { template: '
' }, ElFormItem: { props: ['label'], template: '' }, ElInput: { props: ['modelValue', 'disabled'], emits: ['update:modelValue'], template: '' }, AppointmentCreatePanel: { name: 'AppointmentCreatePanel', props: ['canWrite', 'canLeave'], emits: ['created'], template: '
' }, LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '
{{ title }} {{ message }}
' }, } } }) } beforeEach(() => { vi.resetAllMocks(); 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:overseas-warehouse:dispatch: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('releases an unknown save and sends the edited date with a fresh key', 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[data-temporal-picker]').setValue('2026-10-22T09:30'); await wrapper.get('[data-save]').trigger('click'); await flushPromises() expect(wrapper.get('input').attributes('disabled')).toBeUndefined() await wrapper.get('input[data-temporal-picker]').setValue('2026-10-23T09:30'); await wrapper.get('[data-save]').trigger('click'); await flushPromises() const calls = vi.mocked(saveWarehouseArrival).mock.calls expect(calls[1]![2]).not.toBe(calls[0]![2]); expect(calls[1]![1].estimatedWarehouseArrivalAt).toBe('2026-10-23T09:30:00') expect(wrapper.find('[data-dialog]').exists()).toBe(false); wrapper.unmount() }) it('allows editing after a conflict and preserves input across an optional manual refresh', 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')).toBeUndefined() 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() }) it('opens the real dispatch detail with calendar context without requiring retired trip-list access', async () => { routing.denied.add('platform:fleet:trip:list') vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [dispatchEntry({ dispatchNo: 'DS/1', containerNumbers: [], plannedDepartureAt: '2026-10-21T16:00:00Z' })], unresolvedRecords: [] }) const wrapper = renderSchedule(); await flushPromises() const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }) const event = panel.props('events').find((value: ScheduleEvent) => value.type === 'dispatch') expect(listTripCalendar).toHaveBeenCalledOnce() expect(event.sourcePath).toBe('/overseas-warehouse/dispatches/DS%2F1') panel.vm.$emit('select', '2026-10-21', 'week') panel.vm.$emit('open', event); await flushPromises() expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } }) wrapper.unmount() }) it('retains a complete dispatch with its container search fields without showing an unresolved dispatch notice', async () => { const good = dispatchEntry() const unknown = dispatchEntry({ dispatchNo: 'PC-TIMEZONE-MISSING', containerNumbers: ['OTHER7654321'], originTimezone: null, incompleteReasons: ['ORIGIN_TIMEZONE_UNKNOWN'] }) vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [good, good, unknown], unresolvedRecords: [] }) const wrapper = renderSchedule(); await flushPromises() const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }) const dispatches = (panel.props('events') as ScheduleEvent[]).filter(event => event.type === 'dispatch') expect(dispatches).toHaveLength(1) expect(dispatches[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00' }) expect(dispatches[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' }) expect(dispatches[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' }) expect(panel.props('incomplete')).toBe(true) expect(wrapper.find('.data-notice').exists()).toBe(false) expect(dispatches.some(event => event.key === 'dispatch-PC-TIMEZONE-MISSING')).toBe(false) expect(listTripCalendar).toHaveBeenCalledTimes(1) wrapper.unmount() }) })