Files
admin-web/src/modules/overseas-master-data/components/TripHandoverPanel.test.ts
T

96 lines
9.4 KiB
TypeScript
Raw Normal View History

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<typeof api>()), 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: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
function mount() {
const wrapper = shallowMount(TripHandoverPanel, { props: { detail: structuredClone(detail) }, global: { stubs: {
ElButton: { template: '<button><slot/></button>' }, ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElOption: true,
ElInput: { ...input, name: 'ElInput' }, ElSelect: { ...input, name: 'ElSelect' }, ElDatePicker: input,
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.handoverNo"><slot name="actions" :row="row"/></div></div>' },
RowActions: { name: 'RowActions', props: ['actions'], emits: ['action'], template: '<div><button v-for="a in actions" :key="a.key" @click="$emit(\'action\', a.key)">{{ a.label }}</button></div>' },
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, 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<ReturnType<typeof api.listTripHandovers>>) => 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)
})