import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' import TripHandoverPanel from './TripHandoverPanel.vue' import * as api from '../api/tripHandovers' import type { TripDetail } from '../tripTypes' import { ApiResponseError } from '@/shared/api/http' const access = vi.hoisted(() => ({ read: true, write: true })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':list') ? access.read : access.write }) })) vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } })) vi.mock('../api/tripHandovers', async original => ({ ...(await original()), listTripHandovers: vi.fn(), previewTripHandover: vi.fn(), confirmTripHandover: vi.fn(), releaseHandoverResource: vi.fn() })) vi.mock('../api/overseasMasterData', () => ({ listVehicles: vi.fn() })) vi.mock('../api/drivers', () => ({ listDrivers: vi.fn() })) const date = '2026-10-03T01:00:00Z' const detail: TripDetail = { trip: { tripNo: 'TR-ONE', status: 'IN_TRANSIT', warehouseId: '1', warehouseCode: 'WH', warehouseName: '海外仓', vehicleId: '11', vehiclePlate: 'OLD-PLATE', driverId: '21', driverName: '原司机', driverPhone: 'phone', maxPalletSpaces: 2, maxLoadKg: 200, maxVolumeCbm: 4, plannedDepartureAt: date, expectedDriverEndAt: '2026-10-04T01:00:00Z', departedAt: date, completedAt: null, stops: [], remark: null, recordVersion: 7, updatedAt: date, vehicleRelease: null, driverRelease: null }, driverQualification: null, previousDispatches: [], appointmentHistoryUnavailableReason: null, dispatches: [], loadingReview: { status: 'APPROVED', reason: null, reviewerId: null, reviewedAt: date, recordVersion: 7 } } const previous = { vehicleId: '11', vehiclePlate: 'OLD-PLATE', driverId: '21', driverName: '原司机', driverPhone: 'phone' } const replacement = { vehicleId: '12', vehiclePlate: 'NEW-PLATE', driverId: '22', driverName: '接替司机', driverPhone: 'phone' } const load = { palletSpaces: 1, weightKg: 100, volumeCbm: 2 } const cargo = { remainingCargo: [{ customerId: 'secret-customer-id', dispatchNo: 'PC-A', itemId: 'secret-item-id', palletId: 'secret-pallet-id', destinationKey: 'private-hash', packages: 10 }], completedDeliveries: [], remainingPalletIds: ['secret-pallet-id'], occupied: load } const preview: api.HandoverPreview = { tripNo: 'TR-ONE', recordVersion: 7, cargoHash: 'a'.repeat(64), previous, replacement, capacity: load, cargo, driverQualification: { canDispatch: true, expiringSoon: true, reason: 'ELIGIBLE', licenseExpiresOn: '2026-10-15' } } const record: api.HandoverRecord = { handoverNo: 'HO-ONE', tripNo: 'TR-ONE', recordVersion: 0, snapshot: { previous, replacement, handedOverAt: date, location: '实际交接点', reason: '交接', expectedDriverEndAt: date, capacity: load, cargo, recordedAt: date }, vehicleRelease: null, driverRelease: null } const wrappers: VueWrapper[] = [] const input = { props: ['modelValue'], emits: ['update:modelValue'], template: '' } function mount() { const wrapper = shallowMount(TripHandoverPanel, { props: { detail: structuredClone(detail) }, global: { stubs: { ElButton: { template: '' }, ElForm: { template: '
' }, ElFormItem: { template: '
' }, ElOption: true, ElInput: { ...input, name: 'ElInput' }, ElSelect: { ...input, name: 'ElSelect' }, ElDatePicker: input, DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '
' }, RowActions: { name: 'RowActions', props: ['actions'], emits: ['action'], template: '
' }, LoadErrorState: { props: ['message'], template: '

{{ message }}

' }, ApiErrorMeta: true, } } }); wrappers.push(wrapper); return wrapper } const button = (w: VueWrapper, text: string) => w.findAll('button').find(row => row.text() === text)! beforeEach(() => { vi.clearAllMocks(); access.read = true; access.write = true vi.mocked(api.listTripHandovers).mockResolvedValue({ records: [record], page: 1, size: 10, total: 1, pages: 1 }) vi.mocked(api.previewTripHandover).mockResolvedValue(preview); vi.mocked(api.confirmTripHandover).mockResolvedValue(record) }) afterEach(() => wrappers.splice(0).forEach(row => row.unmount())) it('only loads one page and does not expose internal IDs or write controls without permission', async () => { access.write = false; const w = mount(); await flushPromises() expect(api.listTripHandovers).toHaveBeenCalledExactlyOnceWith('TR-ONE', 1, 10) expect(api.previewTripHandover).not.toHaveBeenCalled(); expect(w.text()).not.toContain('核对交接货物') expect(w.text()).not.toContain('结束原车辆任务'); expect(w.text()).not.toContain('secret-') }) it('does not request data without list permission', async () => { access.read = false; const w = mount(); await flushPromises() expect(api.listTripHandovers).not.toHaveBeenCalled(); expect(w.text()).toContain('暂无车次交接查看权限') }) it('invalidates preview when replacement or trip version changes', async () => { const w = mount(); await flushPromises(); await button(w, '核对交接货物').trigger('click'); await flushPromises() expect(w.text()).toContain('PC-A'); expect(w.text()).not.toContain('secret-'); expect(w.text()).not.toContain('private-hash') await w.setProps({ detail: { ...detail, trip: { ...detail.trip, recordVersion: 8 } } }); await flushPromises() expect(w.text()).not.toContain('确认实际交接') }) it('retries exactly the original request after an unknown confirmation response', async () => { vi.mocked(api.confirmTripHandover).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce(record) const w = mount(); await flushPromises() w.findAllComponents({ name: 'ElInput' })[0]!.vm.$emit('update:modelValue', '实际交接点') await button(w, '核对交接货物').trigger('click'); await flushPromises() await button(w, '确认实际交接').trigger('click'); await flushPromises() const first = vi.mocked(api.confirmTripHandover).mock.calls[0] expect(w.text()).toContain('上次交接结果尚待确认') await button(w, '重试原交接请求').trigger('click'); await flushPromises() expect(vi.mocked(api.confirmTripHandover).mock.calls[1]).toEqual(first) expect(w.emitted('saved')).toHaveLength(1) }) it('ignores old trip response after switching scope', async () => { let complete!: (value: Awaited>) => void vi.mocked(api.listTripHandovers).mockImplementationOnce(() => new Promise(resolve => { complete = resolve })) const w = mount(); await w.setProps({ detail: { ...detail, trip: { ...detail.trip, tripNo: 'TR-TWO' } } }); await flushPromises() complete({ records: [{ ...record, handoverNo: 'OLD-RESULT' }], page: 1, size: 10, total: 1, pages: 1 }); await flushPromises() expect(w.findComponent({ name: 'DataTable' }).props('data')[0].handoverNo).toBe('HO-ONE') }) it('retains the original old-driver release request when its response is unknown', async () => { vi.mocked(api.releaseHandoverResource).mockRejectedValueOnce(new Error('unknown response')).mockResolvedValueOnce({ ...record, recordVersion: 1, driverRelease: { actualEndAt: date, location: '结束地点', vehicleStatus: null } }) const w = mount(); await w.setProps({ detail: { ...detail, trip: { ...detail.trip, vehicleId: '12', driverId: '22', recordVersion: 8 } } }); await flushPromises() await button(w, '结束原司机任务').trigger('click'); await flushPromises() w.findAllComponents({ name: 'ElInput' })[2]!.vm.$emit('update:modelValue', '结束地点') await button(w, '确认原资源任务结束').trigger('click'); await flushPromises() const first = vi.mocked(api.releaseHandoverResource).mock.calls[0] expect(first?.[2].resourceKind).toBe('DRIVER'); expect(first?.[2].vehicleStatus).toBeUndefined() expect(w.text()).toContain('上次任务结束结果尚待确认') await button(w, '重试原任务结束请求').trigger('click'); await flushPromises() expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]).toEqual(first) }) it('reloads the handover version after a definite release conflict', async () => { vi.mocked(api.releaseHandoverResource).mockRejectedValueOnce(new ApiResponseError('交接记录已变化', 'A0409')).mockResolvedValueOnce({ ...record, recordVersion: 2 }) const w = mount(); await w.setProps({ detail: { ...detail, trip: { ...detail.trip, vehicleId: '12', driverId: '22', recordVersion: 8 } } }); await flushPromises() await button(w, '结束原司机任务').trigger('click'); await flushPromises() w.findAllComponents({ name: 'ElInput' })[2]!.vm.$emit('update:modelValue', '结束地点') vi.mocked(api.listTripHandovers).mockResolvedValue({ records: [{ ...record, recordVersion: 1 }], page: 1, size: 10, total: 1, pages: 1 }) await button(w, '确认原资源任务结束').trigger('click'); await flushPromises() await button(w, '确认原资源任务结束').trigger('click'); await flushPromises() expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]?.[2].recordVersion).toBe(1) expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]?.[2].idempotencyKey).not.toBe(vi.mocked(api.releaseHandoverResource).mock.calls[0]?.[2].idempotencyKey) })