import { flushPromises, mount } from '@vue/test-utils' import ElementPlus from 'element-plus' import { beforeEach, describe, expect, it, vi } from 'vitest' import AppointmentImpactDialog from './AppointmentImpactDialog.vue' import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router' import { getImpact, handleImpact, type ImpactDetail } from './impactApi' vi.mock('./impactApi', async importOriginal => ({ ...await importOriginal(), getImpact: vi.fn(), handleImpact: vi.fn() })) const guards = vi.hoisted(() => ({ register: vi.fn(), unregister: vi.fn(), push: vi.fn() })) vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: guards.push }) })) const detail: ImpactDetail = { impact: { containerBookingId: '1', impactNo: 'AI-TEST', version: 1, status: 'OPEN', reason: 'FORECAST_CHANGED', ownerId: null, ownerName: null, baseline: { estimatedAt: '2026-10-15T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, latest: { estimatedAt: '2026-10-17T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, forecastToken: 'current', updatedAt: '2026-10-10T12:00:00' }, cargo: [{ inboundCode: 'IN-A', customerName: '客户甲', packages: 10, destination: 'ONT8' }], appointments: [{ appointmentNo: 'AP-1', status: 'SUCCESS', destination: 'ONT8', confirmedAt: '2026-10-16T09:00:00', timezone: 'America/Los_Angeles' }], transport: null, history: { records: [], total: 0 } } const render = (canWrite = true) => mount(AppointmentImpactDialog, { props: { containerId: '1', canWrite }, global: { provide: { 'appointment-register-leave-guard': guards.register }, plugins: [ElementPlus], stubs: { AppDialog: { name: 'AppDialog', props: ['beforeClose'], template: '
' } } } }) describe('appointment impact handling', () => { beforeEach(() => { vi.resetAllMocks(); guards.register.mockReturnValue(guards.unregister); vi.mocked(getImpact).mockResolvedValue(detail) }) function deferred() { let resolve!: (value: ImpactDetail) => void, reject!: (reason: Error) => void const promise=new Promise((yes,no)=>{resolve=yes;reject=no}) return {promise,resolve,reject} } const pageDetail=(label:string):ImpactDetail=>({...detail,history:{total:60,records:[{version:1,action:'RECORD',operatorName:null,result:label,nextAction:null,followupDate:null,createdAt:'2026-10-11T00:00:00'}]}}) it('keeps the latest history page when responses finish in reverse order',async()=>{ vi.mocked(getImpact).mockResolvedValueOnce(pageDetail('第一页')) const wrapper=render();await flushPromises() const second=deferred(),third=deferred() vi.mocked(getImpact).mockReturnValueOnce(second.promise).mockReturnValueOnce(third.promise) const pager=wrapper.findComponent({name:'ElPagination'}) pager.vm.$emit('update:current-page',2);pager.vm.$emit('current-change',2) pager.vm.$emit('update:current-page',3);pager.vm.$emit('current-change',3) await flushPromises() third.resolve(pageDetail('第三页最新'));await flushPromises() second.resolve(pageDetail('第二页过期'));await flushPromises() expect(wrapper.text()).toContain('第三页最新');expect(wrapper.text()).not.toContain('第二页过期') wrapper.unmount() }) it('ignores an obsolete failure and keeps loading until the current request finishes',async()=>{ vi.mocked(getImpact).mockResolvedValueOnce(pageDetail('第一页')) const wrapper=render();await flushPromises() const second=deferred(),third=deferred() vi.mocked(getImpact).mockReturnValueOnce(second.promise).mockReturnValueOnce(third.promise) const pager=wrapper.findComponent({name:'ElPagination'}) pager.vm.$emit('update:current-page',2);pager.vm.$emit('current-change',2) pager.vm.$emit('update:current-page',3);pager.vm.$emit('current-change',3) second.reject(new Error('obsolete page failure'));await flushPromises() expect(wrapper.text()).not.toContain('obsolete page failure') expect(pager.props('disabled')).toBe(true) expect(wrapper.findAll('button').find(b=>b.text()==='认领跟进')!.attributes('disabled')).toBeDefined() wrapper.findAllComponents({name:'ElButton'}).find(b=>b.text()==='认领跟进')!.vm.$emit('click') await flushPromises();expect(handleImpact).not.toHaveBeenCalled() third.resolve(pageDetail('第三页最新'));await flushPromises() expect(pager.props('disabled')).toBe(false);wrapper.unmount() }) it('does not let a closed dialog response replace a newly opened impact',async()=>{ const old=deferred();vi.mocked(getImpact).mockReturnValueOnce(old.promise) const first=render();await flushPromises();first.unmount() vi.mocked(getImpact).mockResolvedValueOnce({...pageDetail('新影响历史'),impact:{...detail.impact,impactNo:'AI-NEW'}}) const next=render();await flushPromises() old.resolve(pageDetail('已关闭历史'));await flushPromises() expect(next.text()).toContain('AI-NEW');expect(next.text()).not.toContain('已关闭历史');next.unmount() }) it('shows separate forecasts, unchanged confirmed appointment and omitted transport permission', async () => { const wrapper = render(false); await flushPromises() expect(wrapper.text()).toContain('2026-10-15T09:00:00') expect(wrapper.text()).toContain('2026-10-17T09:00:00') expect(wrapper.text()).toContain('2026-10-16T09:00:00') expect(wrapper.text()).toContain('无车次查看权限') expect(wrapper.text()).toContain('来源:carrier') expect(wrapper.text()).toContain('核对原因:到港预测变化') expect(wrapper.text()).not.toContain('认领跟进') expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount() }) it('labels manual observations without exposing internal operator references and explains missing forecasts', async () => { vi.mocked(getImpact).mockResolvedValueOnce({ ...detail, impact: { ...detail.impact, reason: 'FORECAST_MISSING', latest: { ...detail.impact.latest!, providerCode: 'MANUAL', sourceReference: 'MANUAL:1000001', estimatedAt: null } } }) const wrapper = render(); await flushPromises() expect(wrapper.text()).toContain('来源:人工维护') expect(wrapper.text()).toContain('来源预计到港缺失,待核实') expect(wrapper.text()).not.toContain('1000001') expect(wrapper.text()).not.toContain('MANUAL:') expect(wrapper.text()).not.toContain('已延误') expect(wrapper.text()).toContain('认领跟进') expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount() }) it('freezes an uncertain command and retries exactly once with original version and key', async () => { vi.mocked(handleImpact).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...detail.impact, version: 2 }) vi.mocked(getImpact).mockResolvedValueOnce({...detail,transport:{trips:[{tripNo:'TRIP-1',status:'PLANNED',plannedDepartureAt:null}],sameTripBookings:[]}}) const wrapper = render(); await flushPromises() await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises() const original = vi.mocked(handleImpact).mock.calls[0]![1] expect(original).toMatchObject({ action: 'CLAIM', expectedVersion: 1, forecastToken: 'current' }) await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toBeUndefined() const done=vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).not.toHaveBeenCalled() expect(guards.register.mock.calls[0]![0]()).toBe(false) await wrapper.findAll('button').find(b=>b.text()==='TRIP-1')!.trigger('click');expect(guards.push).not.toHaveBeenCalled() for (const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false) await wrapper.findAll('button').find(b => b.text() === '重试本次处理')!.trigger('click'); await flushPromises() expect(handleImpact).toHaveBeenNthCalledWith(2, '1', original) expect(wrapper.emitted('saved')).toHaveLength(1) expect(getImpact).toHaveBeenCalledTimes(2) expect(guards.register.mock.calls[0]![0]()).toBe(true) wrapper.unmount(); expect(guards.unregister).toHaveBeenCalledOnce() }) it('does not resolve without an actual handling result', async () => { const wrapper = render(); await flushPromises() await wrapper.findAll('button').find(b => b.text() === '完成本次核对')!.trigger('click'); await flushPromises() expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount() }) })