58 lines
5.5 KiB
TypeScript
58 lines
5.5 KiB
TypeScript
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|||
|
|
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||
|
|
import TripBookingPanel from './TripBookingPanel.vue'
|
||
|
|
import * as api from '../api/tripBookings'
|
||
|
|
import type { FleetTrip } from '../tripTypes'
|
||
|
|
import type { TripBooking } from '../bookingTypes'
|
||
|
|
const access = vi.hoisted(() => ({ writable: true }))
|
||
|
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.writable }) }))
|
||
|
|
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
|
||
|
|
vi.mock('../api/tripBookings', () => ({ listFleetCustomers: vi.fn(), listTripBookings: vi.fn(), createTripBooking: vi.fn(), updateTripBooking: vi.fn(), confirmTripBooking: vi.fn(), cancelTripBooking: vi.fn() }))
|
||
|
|
const row: TripBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'DRAFT', incomingNo: null, dispatchNo: null, receipt: null, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 0, updatedAt: '2026-10-03T00:00:00Z',
|
||
|
|
cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 10, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
|
||
|
|
const trip = { tripNo: 'TR-1', status: 'PLANNED', recordVersion: 4, stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] } as FleetTrip
|
||
|
|
const wrappers: VueWrapper[] = []
|
||
|
|
function mount() {
|
||
|
|
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
|
||
|
|
const wrapper = shallowMount(TripBookingPanel, { props: { trip }, global: { stubs: {
|
||
|
|
DataTable: { props: ['data'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.bookingNo"><slot name="actions" :row="row"/></div></div>' },
|
||
|
|
AppDialog: { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/></div>' },
|
||
|
|
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
|
||
|
|
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
|
||
|
|
ElSelect: { ...field, name: 'ElSelect' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElInput: field, ElOption: true,
|
||
|
|
LoadErrorState: true, ApiErrorMeta: true,
|
||
|
|
} } }); wrappers.push(wrapper); return wrapper
|
||
|
|
}
|
||
|
|
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
|
||
|
|
beforeEach(() => { vi.clearAllMocks(); access.writable = true; vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [structuredClone(row)] }) })
|
||
|
|
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||
|
|
it('confirms using both current versions and updates the parent version without extra list reads', async () => {
|
||
|
|
vi.mocked(api.confirmTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: { ...row, status: 'CONFIRMED', recordVersion: 1 }, replayed: false })
|
||
|
|
const wrapper = mount(); await flushPromises(); await button(wrapper, '确认承运').trigger('click'); await flushPromises()
|
||
|
|
expect(api.confirmTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 4, recordVersion: 0, idempotencyKey: expect.any(String) }))
|
||
|
|
expect(wrapper.emitted('changed')?.[0]).toEqual([5]); expect(api.listTripBookings).toHaveBeenCalledOnce()
|
||
|
|
expect(wrapper.findAll('button').some(node => node.text() === '确认承运')).toBe(false)
|
||
|
|
})
|
||
|
|
it('keeps inquiry estimates and contacts optional and retries the same uncertain create command', async () => {
|
||
|
|
vi.mocked(api.createTripBooking).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 5, booking: row, replayed: true })
|
||
|
|
const wrapper = mount(); await flushPromises(); await button(wrapper, '登记外接询价').trigger('click'); await flushPromises()
|
||
|
|
const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0]!.vm.$emit('update:modelValue', '9'); selects[1]!.vm.$emit('update:modelValue', 'FBA:ONT8'); await flushPromises()
|
||
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||
|
|
const first = vi.mocked(api.createTripBooking).mock.calls[0]![1]
|
||
|
|
expect(first.cargo).toMatchObject({ palletSpaces: null, weightKg: null, volumeCbm: null, unitPrice: null, contactPhone: null })
|
||
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||
|
|
expect(api.createTripBooking).toHaveBeenNthCalledWith(2, 'TR-1', first)
|
||
|
|
})
|
||
|
|
it('does not expose booking writes to a read-only operator', async () => {
|
||
|
|
access.writable = false; const wrapper = mount(); await flushPromises()
|
||
|
|
for (const label of ['登记外接询价', '编辑', '确认承运', '取消预订']) expect(wrapper.findAll('button').some(button => button.text() === label)).toBe(false)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('opens reactive booking data for editing and read-only details without losing the original row', async () => {
|
||
|
|
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
|
||
|
|
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
|
||
|
|
await button(wrapper, '详情').trigger('click'); await flushPromises()
|
||
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||
|
|
expect(api.updateTripBooking).not.toHaveBeenCalled(); expect(api.createTripBooking).not.toHaveBeenCalled()
|
||
|
|
})
|