Files
admin-web/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts
T

234 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import TruckAppointmentForm from './TruckAppointmentForm.vue'
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
import { listSuppliers } from '@/modules/suppliers'
import { getOceanBooking } from '@/modules/ocean-bookings'
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
vi.mock('@/modules/suppliers', () => ({
listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE', contactName: '张调度', phone: '13800000001' }, { id: 'carrier-2', name: '第二车队', category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE', contactName: '李调度', phone: '13800000002' }, { id: 'overseas-1', name: '海外车队候选', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }] }),
}))
vi.mock('@/modules/ocean-bookings', () => ({
getOceanBooking: vi.fn(),
}))
vi.mock('../api/platformTrucking', () => ({ listTruckAppointmentCandidates: vi.fn() }))
const model: SaveTruckAppointmentRequest = {
expectedVersion: 0,
oceanBookingId: null,
carrierId: null,
plannedLoadingAt: null,
}
const candidates = [
{ oceanBookingId: 'booking-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运' },
{ oceanBookingId: 'booking-2', oceanBookingNo: 'OB-2', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓', channelCode: 'SEA', channelName: '海运' },
]
function detail(id: string) {
const candidate = candidates.find((item) => item.oceanBookingId === id)!
return {
id, bookingNo: candidate.oceanBookingNo, status: 'RELEASED', billOfLadingNo: candidate.billOfLadingNo, warehouseId: candidate.warehouseId, warehouseName: candidate.warehouseName,
documents: [{ id: `doc-${id}`, documentType: 'BILL_OF_LADING', valid: true }],
containers: [],
}
}
function resource(id: string, bookingId: string): TruckAppointmentResource {
const candidate = candidates.find((item) => item.oceanBookingId === bookingId)!
return { id, status: 'DRAFT', oceanBookingId: bookingId, oceanBookingNo: candidate.oceanBookingNo, billOfLadingNo: candidate.billOfLadingNo, warehouseId: candidate.warehouseId, warehouseName: candidate.warehouseName }
}
describe('TruckAppointmentForm booking-only behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(listTruckAppointmentCandidates).mockResolvedValue({ records: candidates, page: 1, size: 200, total: candidates.length, pages: 1 })
vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never)
})
it.each([false, true])('validates a booking without documents when editing is %s', async (editing) => {
vi.mocked(getOceanBooking).mockResolvedValue({ ...detail('booking-1'), documents: [] } as never)
const wrapper = mount(TruckAppointmentForm, {
props: {
modelValue: { ...model, oceanBookingId: 'booking-1', carrierId: 'carrier-1', plannedLoadingAt: '2026-10-25T10:00:00' },
resource: editing ? resource('appointment-1', 'booking-1') : null,
},
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(await wrapper.vm.validate()).toBe(true)
expect(wrapper.text()).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件')
wrapper.unmount()
})
it('selects only server-approved released bookings and never exposes container identity', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', 'booking-1')
await flushPromises()
expect(listTruckAppointmentCandidates).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(getOceanBooking).not.toHaveBeenCalled()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('OB-1') && label.includes('BL-1'))).toBe(true)
expect(labels.some((label) => label.includes('CAB-'))).toBe(false)
expect(candidates[0]).not.toHaveProperty('containerBookingId')
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.oceanBookingId).toBe('booking-1')
expect(emitted.billOfLadingNo).toBe('BL-1')
expect(emitted).not.toHaveProperty('customerId')
expect(emitted).not.toHaveProperty('customerName')
wrapper.unmount()
})
it('keeps only the latest candidate response when option reloads overlap', async () => {
let resolveSlow!: (value: Awaited<ReturnType<typeof listTruckAppointmentCandidates>>) => void
let resolveFast!: (value: Awaited<ReturnType<typeof listTruckAppointmentCandidates>>) => void
vi.mocked(listTruckAppointmentCandidates)
.mockResolvedValueOnce({ records: candidates, page: 1, size: 200, total: candidates.length, pages: 1 })
.mockImplementationOnce(() => new Promise((resolve) => { resolveSlow = resolve }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveFast = resolve }))
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as { loadOptions: () => Promise<void> }
const slow = vm.loadOptions()
const fast = vm.loadOptions()
resolveFast({ records: [candidates[1]!], page: 1, size: 200, total: 1, pages: 1 })
await fast
resolveSlow({ records: [candidates[0]!], page: 1, size: 200, total: 1, pages: 1 })
await slow
await flushPromises()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('OB-2'))).toBe(true)
expect(labels.some((label) => label.includes('OB-1'))).toBe(false)
wrapper.unmount()
})
it('replaces frozen booking snapshots when switching the selected booking', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', 'booking-1')
await flushPromises()
selects[0]!.vm.$emit('update:modelValue', 'booking-2')
await flushPromises()
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.oceanBookingId).toBe('booking-2')
expect(emitted.billOfLadingNo).toBe('BL-2')
expect(emitted.warehouseId).toBe('WH-2')
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('OB-2'))).toBe(true)
wrapper.unmount()
})
it('ignores stale current-booking details when a cached edit route changes', async () => {
let resolveFirst!: (value: ReturnType<typeof detail>) => void
let resolveSecond!: (value: ReturnType<typeof detail>) => void
vi.mocked(getOceanBooking).mockImplementation((id) => new Promise((resolve) => {
if (id === 'booking-1') resolveFirst = resolve as typeof resolveFirst
else resolveSecond = resolve as typeof resolveSecond
}) as never)
const firstResource = resource('appointment-1', 'booking-1')
const secondResource = resource('appointment-2', 'booking-2')
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: firstResource }, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.setProps({ modelValue: { ...model, oceanBookingId: 'booking-2' }, resource: secondResource })
await flushPromises()
resolveSecond(detail('booking-2'))
await flushPromises()
resolveFirst({ ...detail('booking-1'), documents: [] })
await flushPromises()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('OB-2') && label.includes('不可重选'))).toBe(true)
expect(labels.some((label) => label.includes('OB-1'))).toBe(false)
expect(wrapper.text()).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件')
expect(listTruckAppointmentCandidates).not.toHaveBeenCalled()
wrapper.unmount()
})
it('preserves the business code and request trace when booking readiness fails', async () => {
vi.mocked(getOceanBooking).mockRejectedValueOnce(new ApiResponseError('订舱版本冲突', 'A0409', 409, undefined, 'req-booking', 'trace-booking'))
const current = resource('appointment-1', 'booking-1')
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: current }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('订舱版本冲突')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('req-booking')
expect(wrapper.text()).toContain('trace-booking')
wrapper.unmount()
})
it('flags an edit binding whose booking has become unreleased without adding it to create candidates', async () => {
vi.mocked(getOceanBooking).mockResolvedValueOnce({ ...detail('booking-1'), status: 'DRAFT' } as never)
const current = resource('appointment-1', 'booking-1')
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: current }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('当前绑定订舱已不是放舱状态')
expect(listTruckAppointmentCandidates).not.toHaveBeenCalled()
wrapper.unmount()
})
it('starts with five main fields and optional collapsed dispatch information', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.findAll('.appointment-grid > .el-form-item').filter(item => !item.element.closest('details'))).toHaveLength(5)
expect((wrapper.get('details').element as HTMLDetailsElement).open).toBe(false)
for (const label of ['门店名称', '司机身份证号', '毛重(kg)', '体积(m³)', '在途分钟', '托盘数量', '查看附件引用']) expect(wrapper.text()).not.toContain(label)
expect(wrapper.findAllComponents({ name: 'ElSelect' })).toHaveLength(2)
expect(wrapper.text()).toContain('元')
expect(wrapper.text()).toContain('保存预约')
wrapper.unmount()
})
it('copies supplier contacts and replaces or clears them when switching suppliers', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE' })
const supplier = wrapper.findAllComponents({ name: 'ElSelect' })[1]!
expect(supplier.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).not.toContain('overseas-1')
supplier.vm.$emit('update:modelValue', 'carrier-1')
await flushPromises()
expect(wrapper.emitted('update:modelValue')!.at(-1)![0]).toMatchObject({ carrierId: 'carrier-1', fleetContact: '张调度', fleetPhone: '13800000001', currency: 'RMB' })
expect(wrapper.get('[aria-label="车队联系方式"]').text()).toContain('张调度')
supplier.vm.$emit('update:modelValue', 'carrier-2')
await flushPromises()
expect(wrapper.emitted('update:modelValue')!.at(-1)![0]).toMatchObject({ carrierId: 'carrier-2', fleetContact: '李调度', fleetPhone: '13800000002' })
supplier.vm.$emit('update:modelValue', '')
await flushPromises()
expect(wrapper.emitted('update:modelValue')!.at(-1)![0]).toMatchObject({ carrierId: null, carrierName: null, fleetContact: null, fleetPhone: null })
wrapper.unmount()
})
it('preserves saved contact overrides and supplementary values when editing', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1', carrierId: 'carrier-1', fleetContact: '本次调度', fleetPhone: '13900000000', storeName: '目的门店', grossWeightKg: '120.00' }, resource: resource('appointment-1', 'booking-1') }, global: { plugins: [ElementPlus] } })
await flushPromises()
wrapper.findAllComponents({ name: 'ElSelect' })[1]!.vm.$emit('update:modelValue', 'carrier-1')
await flushPromises()
expect(wrapper.get('[aria-label="车队联系方式"]').text()).toContain('本次调度')
expect(wrapper.text()).toContain('其他已存资料')
expect(wrapper.findAllComponents({ name: 'ElInput' }).some(input => input.props('modelValue') === '目的门店')).toBe(true)
wrapper.unmount()
})
it('keeps amount validation at two decimal places on the displayed field', async () => {
const valid = { ...model, oceanBookingId: 'booking-1', carrierId: 'carrier-1', plannedLoadingAt: '2026-09-19T09:00:00', payableAmount: '100.00' }
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: valid }, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.setProps({ modelValue: { ...valid, billOfLadingNo: 'BL-1', payableAmount: '100.001' } })
await flushPromises()
expect(wrapper.findAllComponents({ name: 'ElFormItem' }).find(field => field.props('prop') === 'payableAmount')!.findComponent({ name: 'ElInput' }).props('modelValue')).toBe('100.001')
const rule = wrapper.findComponent({ name: 'ElForm' }).props('rules').payableAmount[0]
const reject = vi.fn()
rule.validator(null, '100.001', reject)
expect(reject).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('应付拖车费') }))
const accept = vi.fn()
rule.validator(null, '100.00', accept)
expect(accept).toHaveBeenCalledWith(undefined)
wrapper.unmount()
})
})