import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import ElementPlus, { ElMessageBox } from 'element-plus' import AppDialog from '@/shared/components/AppDialog.vue' import * as api from '../api/platformSailingCalendar' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { listSailingCalendar } from '../api/platformSailingCalendar' import type { SailingCalendarResponse, SailingTemplateResource } from '../types' import SailingCalendarView from './SailingCalendarView.vue' const permissions = vi.hoisted(() => ({ write: true })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => permissions.write }), })) vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) })) vi.mock('../api/platformSailingCalendar', () => ({ listSailingCalendar: vi.fn(), cancelSailing: vi.fn(), copySailing: vi.fn(), createSailingTemplate: vi.fn(), createSailing: vi.fn(), generateSailingTemplate: vi.fn(), getSailing: vi.fn(), getSailingBookingAdvice: vi.fn(), getSailingVolumeDetail: vi.fn(), listSailingChannels: vi.fn().mockResolvedValue([]), listSailingTemplates: vi.fn().mockResolvedValue([]), previewSailingTemplateGeneration: vi.fn(), updateSailingTemplate: vi.fn(), updateSailing: vi.fn(), })) const volume = { planningCapacityCbm: 60, bookedPlanningCapacityCbm: 0, availableBookedCapacityCbm: 0, catchableReceivedCbm: 12, lateReceivedCbm: 0, snapshotAt: null, actualReceivedCbm: 12, catchableForecastCbm: 0, riskUnmatchedCbm: 0, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, lateBatchCount: 0, overCapacity: false } const calendar: SailingCalendarResponse = { nextRefreshAt: '2026-10-01T00:00:00', month: '2026-09', timezone: 'Asia/Shanghai', summary: { ...volume, sailingCount: 1 }, filterOptions: { warehouses: [], channels: [], departurePorts: [], destinationPorts: [] }, records: [{ id: 'sailing-1', sailingNo: 'SAIL-TEST', warehouseId: 'warehouse-1', warehouseName: '测试仓', channelCode: 'CH-TEST', channelName: '测试渠道', vesselName: 'TEST VESSEL', voyageNo: 'TEST-01', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', etdAt: '2026-09-30T10:00:00', cutoffAt: '2026-09-29T18:00:00', status: 'OPEN', volumeSummary: volume, version: 1 }], } let wrapper: VueWrapper | undefined function deferred() { let resolve!: (value: T) => void let reject!: (reason: Error) => void const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise }) return { promise, resolve, reject } } async function mountView() { wrapper = mount(SailingCalendarView, { attachTo: document.body, global: { plugins: [ElementPlus] } }) await flushPromises() return wrapper } beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(new Date('2026-09-30T12:00:00+08:00')) vi.clearAllMocks() vi.mocked(api.listSailingTemplates).mockResolvedValue([]) vi.mocked(api.listSailingChannels).mockResolvedValue([]) permissions.write = true vi.mocked(listSailingCalendar).mockResolvedValue(calendar) }) afterEach(() => { wrapper?.unmount(); wrapper = undefined; document.body.innerHTML = ''; vi.useRealTimers(); vi.restoreAllMocks() }) describe('SailingCalendarView integrated workspace', () => { it.each(['success', 'failure'] as const)('ignores a stale calendar %s after the next month has loaded', async (outcome) => { const view = await mountView() const previous = deferred() vi.mocked(listSailingCalendar).mockReturnValueOnce(previous.promise) const keyword = view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]') await keyword.trigger('keyup', { key: 'Enter' }) const october = { ...calendar, month: '2026-10', records: [{ ...calendar.records[0], id: 'october', sailingNo: 'SL-OCTOBER', etdAt: '2026-10-01T10:00:00' }] } vi.mocked(listSailingCalendar).mockResolvedValueOnce(october) await view.get('button[aria-label="下个月"]').trigger('click') await flushPromises() if (outcome === 'success') previous.resolve(calendar) else previous.reject(new Error('旧月份请求失败')) await flushPromises() expect(view.get('.detail-header h2').text()).toBe('2026-10-01') expect(view.get('.detail-panel').text()).toContain('SL-OCTOBER') expect(view.find('.load-error-state').exists()).toBe(false) expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false') }) it('keeps the latest calendar request loading when an earlier request finishes first', async () => { const view = await mountView() const previous = deferred() const current = deferred() vi.mocked(listSailingCalendar).mockReturnValueOnce(previous.promise).mockReturnValueOnce(current.promise) await view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]').trigger('keyup', { key: 'Enter' }) await view.get('button[aria-label="下个月"]').trigger('click') previous.resolve(calendar) await flushPromises() expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('true') current.resolve({ ...calendar, month: '2026-10', records: [] }) await flushPromises() expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false') }) it('keeps the copied sailing selected when its date is later in the current month', async () => { vi.setSystemTime(new Date('2026-09-20T12:00:00+08:00')) const original = { ...calendar.records[0], etdAt: '2026-09-21T10:00:00', cutoffAt: '2026-09-20T18:00:00' } const copied = { ...original, id: 'copied', sailingNo: 'SL-COPIED', etdAt: '2026-09-28T10:00:00', cutoffAt: '2026-09-27T18:00:00' } vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, records: [original] }) const view = await mountView() await view.findAll('.calendar-cell').find(cell => cell.get('.day-number').text() === '21')!.trigger('click') await view.get('.sailing-card').trigger('click') vi.mocked(api.copySailing).mockResolvedValueOnce({ sailing: copied, histories: [] }) vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, records: [original, copied] }) await clickAction(view, '复制下一班') expect(view.get('.detail-header h2').text()).toBe('2026-09-28') expect(view.get('.sailing-card.active').text()).toContain('SL-COPIED') expect(view.get('.detail-actions').text()).toContain('订柜建议') await view.findAll('button').find(button => button.text() === '今天')!.trigger('click') await flushPromises() expect(view.get('.detail-header h2').text()).toBe('2026-09-20') }) it('uses the Shanghai calendar date at the UTC day boundary', async () => { vi.setSystemTime(new Date('2026-09-29T16:00:00Z')) const view = await mountView() expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-09' })) expect(view.get('.detail-header h2').text()).toBe('2026-09-30') expect(view.get('.calendar-note').text()).toContain('仅今天及以后') }) it('refreshes at cutoff so the current cargo reference can roll forward', async () => { vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001', records: [{ ...calendar.records[0], cutoffAt: '2026-09-30T12:00:01' }] }) const view = await mountView() await view.get('.sailing-card').trigger('click') const emptyVolume = { ...volume, actualReceivedCbm: 0, catchableReceivedCbm: 0, recommendedContainerCount: 0, pendingContainerCount: 0 } vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, summary: { ...emptyVolume, sailingCount: 1 }, records: [{ ...calendar.records[0], volumeSummary: emptyVolume }] }) await vi.advanceTimersByTimeAsync(1002) await flushPromises() expect(listSailingCalendar).toHaveBeenCalledTimes(2) expect(view.get('.volume-line').text()).toContain('建议 0 柜') expect(view.find('.detail-actions').exists()).toBe(false) }) it('refreshes across midnight and month end and removes expired selection', async () => { vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) vi.setSystemTime(new Date('2026-09-30T23:59:59+08:00')) const view = await mountView() await view.get('.sailing-card').trigger('click') vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, month: '2026-10', records: [], summary: { ...calendar.summary, sailingCount: 0 } }) await vi.advanceTimersByTimeAsync(1001) await flushPromises() expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' })) expect(view.get('.detail-header h2').text()).toBe('2026-10-01') expect(view.find('.sailing-card').exists()).toBe(false) expect(view.find('.detail-actions').exists()).toBe(false) expect(view.get('.calendar-note').text()).toContain('共 0 个船期') const calls = vi.mocked(listSailingCalendar).mock.calls.length view.unmount(); wrapper = undefined await vi.advanceTimersByTimeAsync(86_400_000) expect(listSailingCalendar).toHaveBeenCalledTimes(calls) }) it('refreshes the next month at the server boundary even when the earlier sailing is hidden', async () => { vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T13:00:00.001', records: [{ ...calendar.records[0], cutoffAt: '2026-09-30T13:00:00', etdAt: '2026-09-30T18:00:00' }] }) const view = await mountView() const nextMonth = { ...calendar, month: '2026-10', nextRefreshAt: '2026-09-30T13:00:00.001', records: [{ ...calendar.records[0], id: 'sailing-2', etdAt: '2026-10-02T10:00:00', cutoffAt: '2026-10-01T18:00:00' }] } vi.mocked(listSailingCalendar).mockResolvedValue(nextMonth) await view.get('button[aria-label="下个月"]').trigger('click') await flushPromises() expect(listSailingCalendar).toHaveBeenCalledTimes(2) vi.mocked(listSailingCalendar).mockResolvedValue({ ...nextMonth, nextRefreshAt: '2026-10-01T00:00:00' }) await vi.advanceTimersByTimeAsync(3_600_002) await flushPromises() expect(listSailingCalendar).toHaveBeenCalledTimes(3) expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' })) }) it('backs off after a refresh failure instead of repeatedly requesting an expired boundary', async () => { vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001' }) await mountView() vi.mocked(listSailingCalendar).mockRejectedValueOnce(new Error('网络错误')) await vi.advanceTimersByTimeAsync(1002) await flushPromises() expect(listSailingCalendar).toHaveBeenCalledTimes(2) await vi.advanceTimersByTimeAsync(59_000) expect(listSailingCalendar).toHaveBeenCalledTimes(2) await vi.advanceTimersByTimeAsync(1001) await flushPromises() expect(listSailingCalendar).toHaveBeenCalledTimes(3) }) it('reloads today and clears actions when the selected sailing is no longer returned', async () => { const view = await mountView() await view.get('.sailing-card').trigger('click') vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [], summary: { ...calendar.summary, sailingCount: 0 } }) await view.findAll('button').find(button => button.text() === '今天')!.trigger('click') await flushPromises() expect(listSailingCalendar).toHaveBeenCalledTimes(2) expect(view.find('.detail-actions').exists()).toBe(false) }) it('keeps one create entry and defaults the sailing to the selected calendar date', async () => { const view = await mountView() expect(view.find('.detail-header button').exists()).toBe(false) const createButtons = view.findAll('button').filter(button => button.text() === '新增单个船次') expect(createButtons).toHaveLength(1) const otherDate = view.findAll('.calendar-cell').find(cell => cell.get('.day-number').text() === '29')! await otherDate.trigger('click') await createButtons[0].trigger('click') await flushPromises() expect(document.querySelector('.el-dialog__title')?.textContent).toBe('新增船期') const departurePicker = view.findAllComponents({ name: 'ElDatePicker' }).find(picker => picker.props('type') === 'datetime')! expect(departurePicker.props('modelValue')).toBe('2026-09-29T10:00:00') }) it('preserves keyword search, date selection and month navigation with the shared header', async () => { const view = await mountView() expect(view.get('h1').text()).toBe('船期日历') expect(view.get('.data-table__business-actions').text()).toContain('新增单个船次') expect(view.get('.data-table__business-actions').text()).toContain('渠道与周模板') expect(view.get('.data-table__business-actions').text()).not.toContain('按周批量生成') expect(view.find('button[aria-label="列设置"]').exists()).toBe(false) expect(view.find('.data-table__pagination').exists()).toBe(false) expect(view.get('.detail-panel').text()).toContain('SAIL-TEST') await view.get('.sailing-card').trigger('click') expect(view.get('.detail-actions').text()).toContain('货量明细') const otherDate = view.findAll('.calendar-cell').find(cell => cell.get('.day-number').text() === '29')! await otherDate.trigger('click') expect(view.get('.detail-header h2').text()).toBe('2026-09-29') expect(view.get('.detail-panel').text()).toContain('当天暂无船期') const keyword = view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]') await keyword.setValue('TEST VESSEL') await keyword.trigger('keyup', { key: 'Enter' }) await flushPromises() expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-09', keyword: 'TEST VESSEL' })) await view.get('.toolbar-filters button').trigger('click') await flushPromises() expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ keyword: undefined })) await view.get('button[aria-label="下个月"]').trigger('click') await flushPromises() expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' })) }) it('keeps filters and retry available on failure and preserves read-only permissions', async () => { permissions.write = false vi.mocked(listSailingCalendar).mockRejectedValueOnce(new Error('日历加载失败')) const view = await mountView() expect(view.get('h1').text()).toBe('船期日历') expect(view.find('.toolbar-filters').exists()).toBe(true) expect(view.find('.calendar-layout').exists()).toBe(false) expect(view.find('.data-table__business-actions button').exists()).toBe(false) await view.get('.load-error-state__actions button').trigger('click') await flushPromises() expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false') expect(view.get('.detail-panel').text()).toContain('SAIL-TEST') await view.get('.sailing-card').trigger('click') const operations = view.get('.detail-actions').text() expect(operations).toContain('货量明细') for (const action of ['编辑', '复制下一班', '取消']) expect(operations).not.toContain(action) }) }) const weeklyTemplate: SailingTemplateResource = { id: 'template-internal', templateNo: 'ST-TEST-01', channelId: 'channel-1', channelName: '测试渠道', warehouseId: 'warehouse-1', warehouseName: '测试仓', weekdays: [1, 3], effectiveFrom: '2026-09-30', effectiveTo: null, departureTime: '10:00:00', cutoffDays: 3, cutoffLocalTime: '18:00:00', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', status: 'ACTIVE', version: 7, } async function clickAction(view: VueWrapper, label: string) { const button = view.findAll('button').find(item => item.text() === label) expect(button, label).toBeDefined() await button!.trigger('click') await flushPromises() } async function editWeeklyTemplate() { vi.mocked(api.listSailingTemplates).mockResolvedValue([weeklyTemplate]) const view = await mountView() await clickAction(view, '渠道与周模板') const templateTab = view.get('[role="tab"][id="tab-templates"]') await templateTab.trigger('click') await flushPromises() await clickAction(view, '编辑') const dialog = view.findAllComponents(AppDialog).find(item => item.props('title').startsWith('编辑周模板'))! expect(dialog.props('title')).toBe('编辑周模板 · ST-TEST-01') return { view, dialog } } describe('SailingCalendarView dialog workflows', () => { it.each([ ['货量明细', false], ['货量明细', true], ['变更历史', false], ['变更历史', true], ['订柜建议', false], ['订柜建议', true], ] as const)('keeps the latest %s response after reopening; switch sailing: %s', async (label, switchSailing) => { const nextSailing = { ...calendar.records[0], id: 'sailing-2', sailingNo: 'SAIL-NEXT' } vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [calendar.records[0], nextSailing] }) let finishOld!: () => void if (label === '货量明细') { const previous = deferred>>() const result = (code: string): Awaited> => ({ summary: volume, assignments: [{ id: code, inboundCode: code, volumeCbm: 12, status: 'RECEIVED', assignmentSource: 'MANUAL' }] }) vi.mocked(api.getSailingVolumeDetail).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY')) finishOld = () => previous.resolve(result('OLD-ONLY')) } else if (label === '变更历史') { const previous = deferred>>() const result = (reason: string): Awaited> => ({ sailing: nextSailing, histories: [{ id: reason, action: 'UPDATE', reason, createdAt: '2026-09-30T12:00:00' }] }) vi.mocked(api.getSailing).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY')) finishOld = () => previous.resolve(result('OLD-ONLY')) } else { const previous = deferred>>() const result = (code: string) => ({ actualReceivedCbm: 12, catchableTotalCbm: 12, planningCapacityCbm: 68, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, scenarios: [{ containerTypeCode: code, containerTypeName: code, capacityCbm: 68, recommendedContainerCount: 1, pendingContainerCount: 1 }], calculatedAt: '2026-09-30T12:00:00', catchableReceivedCbm: 12, lateReceivedCbm: 0 }) vi.mocked(api.getSailingBookingAdvice).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY')) finishOld = () => previous.resolve(result('OLD-ONLY')) } const view = await mountView() await view.findAll('.sailing-card')[0].trigger('click') await clickAction(view, label) const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith(label === '货量明细' ? '货量明细' : label === '变更历史' ? '船期变更历史' : '订柜建议'))! dialog.vm.$emit('update:modelValue', false) await flushPromises() if (switchSailing) await view.findAll('.sailing-card')[1].trigger('click') await clickAction(view, label) expect(document.body.textContent).toContain('NEW-ONLY') finishOld() await flushPromises() expect(dialog.props('title')).toContain(switchSailing ? 'SAIL-NEXT' : 'SAIL-TEST') expect(document.body.textContent).toContain('NEW-ONLY') expect(document.body.textContent).not.toContain('OLD-ONLY') }) it('updates the cutoff result for presets and custom days while preserving a manually chosen time', async () => { const view = await mountView() await clickAction(view, '新增单个船次') const dialog = view.findAllComponents(AppDialog).find(item => item.props('title') === '新增船期')! const result = () => document.querySelector('.cutoff-calculated')!.textContent expect(result()).toContain('2026-09-27 18:00') const presets = dialog.findAllComponents({ name: 'ElRadioGroup' }).find(item => item.classes().includes('cutoff-presets'))! presets.vm.$emit('update:modelValue', 2) presets.vm.$emit('change', 2) await flushPromises() expect(result()).toContain('2026-09-28 18:00') const days = dialog.findComponent({ name: 'ElInputNumber' }) days.vm.$emit('update:modelValue', 5) days.vm.$emit('change', 5) await flushPromises() expect(result()).toContain('2026-09-25 18:00') expect(presets.props('modelValue')).toBe(5) const time = dialog.findComponent({ name: 'ElTimePicker' }) time.vm.$emit('update:modelValue', '16:30:00') time.vm.$emit('change', '16:30:00') await flushPromises() expect(result()).toContain('2026-09-25 16:30') const mode = dialog.findAllComponents({ name: 'ElRadioGroup' }).find(item => item.classes().includes('cutoff-mode-group'))! mode.vm.$emit('update:modelValue', 'custom') mode.vm.$emit('change', 'custom') await flushPromises() expect(document.querySelector('.cutoff-rule-row')).toBeNull() const pickers = dialog.findAllComponents({ name: 'ElDatePicker' }) pickers[1].vm.$emit('update:modelValue', '2026-09-29T15:00:00') pickers[1].vm.$emit('change', '2026-09-29T15:00:00') pickers[0].vm.$emit('update:modelValue', '2026-10-02T10:00:00') pickers[0].vm.$emit('change', '2026-10-02T10:00:00') await flushPromises() expect(result()).toContain('2026-09-29 15:00') expect(result()).toContain('自定义时间') mode.vm.$emit('update:modelValue', 'rule') mode.vm.$emit('change', 'rule') await flushPromises() expect(result()).toContain('2026-09-27 16:30') }) it('retains edited cutoff time and optimistic version through fullscreen and save', async () => { const view = await mountView() await view.get('.sailing-card').trigger('click') await clickAction(view, '编辑') const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith('编辑船期'))! expect(dialog.props('title')).toBe('编辑船期 · SAIL-TEST') const datePickers = dialog.findAllComponents({ name: 'ElDatePicker' }) await datePickers[1].vm.$emit('update:modelValue', '2026-09-28T16:30:00') await flushPromises() const fullscreen = document.querySelector('[aria-label="展开全屏"]')! fullscreen.click() await flushPromises() expect(document.querySelector('.el-dialog.is-fullscreen')).not.toBeNull() expect(vi.mocked(api.updateSailing)).not.toHaveBeenCalled() document.querySelector('[aria-label="退出全屏"]')!.click() await flushPromises() const save = Array.from(document.querySelectorAll('.el-dialog__footer button')).find(button => button.textContent?.trim() === '保存')! save.click() await flushPromises() expect(api.updateSailing).toHaveBeenCalledExactlyOnceWith('sailing-1', expect.objectContaining({ expectedVersion: 1, warehouseId: 'warehouse-1', channelCode: 'CH-TEST', cutoffAt: '2026-09-28T16:30:00', etdAt: '2026-09-30T10:00:00', })) }) it.each(['cutoff', 'midnight'] as const)('preserves the editing target, changes and version across a %s refresh', async (boundary) => { vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) if (boundary === 'midnight') vi.setSystemTime(new Date('2026-09-30T23:59:59+08:00')) vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: boundary === 'midnight' ? '2026-10-01T00:00:00' : '2026-09-30T12:00:01.001' }) const view = await mountView() await view.get('.sailing-card').trigger('click') await clickAction(view, '编辑') const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith('编辑船期'))! await dialog.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-09-28T16:30:00') vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [], nextRefreshAt: '2026-10-02T00:00:00' }) await vi.advanceTimersByTimeAsync(1002) await flushPromises() expect(view.find('.detail-actions').exists()).toBe(false) expect(dialog.props('modelValue')).toBe(true) expect(dialog.props('title')).toBe('编辑船期 · SAIL-TEST') document.querySelectorAll('.el-dialog__footer button').forEach(button => { if (button.textContent?.trim() === '保存') button.click() }) await flushPromises() expect(api.updateSailing).toHaveBeenCalledExactlyOnceWith('sailing-1', expect.objectContaining({ expectedVersion: 1, cutoffAt: '2026-09-28T16:30:00', })) expect(api.createSailing).not.toHaveBeenCalled() expect(dialog.props('modelValue')).toBe(false) }) it('keeps the edited form when the server rejects a save after automatic refresh', async () => { vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001' }) const view = await mountView() await view.get('.sailing-card').trigger('click') await clickAction(view, '编辑') const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith('编辑船期'))! await vi.advanceTimersByTimeAsync(1002) await flushPromises() vi.mocked(api.updateSailing).mockRejectedValueOnce(new Error('版本冲突')) dialog.vm.$emit('confirm') await flushPromises() expect(api.updateSailing).toHaveBeenCalledExactlyOnceWith('sailing-1', expect.objectContaining({ expectedVersion: 1 })) expect(dialog.props('modelValue')).toBe(true) expect(dialog.props('title')).toBe('编辑船期 · SAIL-TEST') }) it('loads volume, history and booking advice as read-only data for the business-number title', async () => { vi.mocked(api.getSailingVolumeDetail).mockResolvedValue({ summary: volume, assignments: [{ id: 'internal-assignment', inboundCode: 'RK-TEST-01', volumeCbm: 12, status: 'RECEIVED', assignmentSource: 'MANUAL' }] }) vi.mocked(api.getSailing).mockResolvedValue({ sailing: calendar.records[0], histories: [{ id: 'history-internal', action: 'UPDATE', reason: '调整截止时间', createdAt: '2026-09-29T10:00:00' }] }) vi.mocked(api.getSailingBookingAdvice).mockResolvedValue({ actualReceivedCbm: 12, catchableReceivedCbm: 12, lateReceivedCbm: 0, catchableTotalCbm: 12, planningCapacityCbm: 60, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, scenarios: [{ containerTypeCode: '40HQ', containerTypeName: '40HQ', capacityCbm: 60, recommendedContainerCount: 1, pendingContainerCount: 1 }], calculatedAt: '2026-09-30T12:00:00' }) const view = await mountView() await view.get('.sailing-card').trigger('click') for (const [label, title, expectedText] of [ ['货量明细', '货量明细(只读)', 'RK-TEST-01'], ['变更历史', '船期变更历史', '调整截止时间'], ['订柜建议', '订柜建议(只读初算)', '40HQ'], ]) { await clickAction(view, label) const dialog = view.findAllComponents(AppDialog).find(item => item.props('title') === `${title} · SAIL-TEST`)! expect(dialog.props('modelValue')).toBe(true) expect(document.body.textContent).toContain(expectedText) dialog.vm.$emit('update:modelValue', false) await flushPromises() } for (const operation of [api.createSailing, api.updateSailing, api.copySailing, api.generateSailingTemplate]) expect(operation).not.toHaveBeenCalled() }) it('saves the same weekly pattern and version from the wide editor', async () => { const { dialog } = await editWeeklyTemplate() const time = dialog.findAllComponents({ name: 'ElTimePicker' })[0] await time.vm.$emit('update:modelValue', '11:15:00') await flushPromises() const save = Array.from(document.querySelectorAll('.el-dialog__footer button')).find(button => button.textContent?.trim() === '保存模板')! save.click() await flushPromises() expect(api.updateSailingTemplate).toHaveBeenCalledExactlyOnceWith('template-internal', expect.objectContaining({ weekdays: [1, 3], departureTime: '11:15:00', expectedVersion: 7, channelId: 'channel-1', warehouseId: 'warehouse-1' })) expect(api.generateSailingTemplate).not.toHaveBeenCalled() }) it('previews conflicts and keeps the selected range until explicit generation confirmation', async () => { vi.mocked(api.previewSailingTemplateGeneration).mockResolvedValue({ templateId: 'template-internal', records: [{ etdAt: '2026-10-05T10:00:00', cutoffAt: '2026-10-02T18:00:00', conflict: true, conflictReason: '已有同日船期' }] }) vi.mocked(api.generateSailingTemplate).mockResolvedValue({ templateId: 'template-internal', generatedCount: 2, skippedCount: 1, records: [] }) const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) const { dialog } = await editWeeklyTemplate() const buttons = Array.from(document.querySelectorAll('.el-dialog button')) buttons.find(button => button.textContent?.trim() === '未来 4 周')!.click() await flushPromises() const preview = Array.from(document.querySelectorAll('.el-dialog__footer button')).find(button => button.textContent?.trim() === '预览生成')! preview.click() await flushPromises() expect(api.previewSailingTemplateGeneration).toHaveBeenCalledExactlyOnceWith('template-internal', { from: '2026-09-30', to: '2026-10-28' }) expect(document.body.textContent).toContain('已有同日船期') expect(api.generateSailingTemplate).not.toHaveBeenCalled() expect(confirmation).not.toHaveBeenCalled() const expand = Array.from(document.querySelectorAll('[aria-label="展开全屏"]')).at(-1)! expand.click() await flushPromises() expect(dialog.findAllComponents({ name: 'ElDatePicker' }).at(-1)!.props('modelValue')).toBe('2026-10-28') Array.from(document.querySelectorAll('.el-dialog__footer button')).find(button => button.textContent?.trim() === '生成船期')!.click() await flushPromises() expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('2026-09-30 至 2026-10-28'), '批量生成船期', expect.objectContaining({ customClass: 'admin-density-confirm', confirmButtonText: '确认生成' })) expect(api.generateSailingTemplate).toHaveBeenCalledExactlyOnceWith('template-internal', { from: '2026-09-30', to: '2026-10-28' }) }) })