181 lines
16 KiB
TypeScript
181 lines
16 KiB
TypeScript
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
|
|
import { ApiResponseError } from '@/shared/api/http'
|
|
import VehicleCareEditor from './VehicleCareEditor.vue'
|
|
import VehicleCareTable from './VehicleCareTable.vue'
|
|
import VehicleDetailView from '../pages/VehicleDetailView.vue'
|
|
import * as api from '../api/vehicleCare'
|
|
import { getVehicle } from '../api/overseasMasterData'
|
|
import type { VehicleCareRecord } from '../vehicleCareTypes'
|
|
const access = vi.hoisted(() => ({ write: true }))
|
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || access.write }) }))
|
|
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { vehicleId: '71' } }), useRouter: () => ({ push: vi.fn() }) }))
|
|
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() } }))
|
|
vi.mock('../api/vehicleCare', async original => ({ ...(await original<typeof import('../api/vehicleCare')>()), listVehicleCare: vi.fn(), getVehicleCare: vi.fn(), createVehicleCare: vi.fn(), updateVehicleCare: vi.fn(), uploadVehicleCarePhoto: vi.fn(), downloadVehicleCarePhoto: vi.fn(), listVehicleCareFinance: vi.fn(), submitVehicleCareFinance: vi.fn(), listVehicleCareSuppliers: vi.fn() }))
|
|
vi.mock('../api/overseasMasterData', () => ({ getVehicle: vi.fn() }))
|
|
const row: VehicleCareRecord = { recordNo: 'VR-ONE', vehicleId: '71', vehiclePlate: 'VEH-A', recordVersion: 0, details: { recordType: 'FUEL', occurredAt: '2026-01-01T00:00:00Z', distanceUnit: 'MI', odometer: 100, lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: 28.6, fuelUnit: 'US_GAL', verificationStatus: 'PENDING', remark: null, incident: null }, files: [], updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'operator', paymentStatus: null }
|
|
const container = { template: '<div><slot name="header"/><slot/></div>' }
|
|
const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
|
|
const table = { name: 'DataTable', props: ['data', 'page', 'pageSize', 'columns'], emits: ['update:page'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.recordNo"><slot name="actions" :row="row"/><span data-test="status"><slot name="status" :row="row"/></span><span data-test="payment"><slot name="payment" :row="row"/></span><span data-test="cost"><slot name="cost" :row="row"/></span></div></div>' }
|
|
const dialog = { name: 'AppDialog', props: ['modelValue', 'showConfirm'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存</button></div>' }
|
|
const wrappers: VueWrapper[] = []
|
|
function mount(component: object, props: Record<string, unknown>): VueWrapper {
|
|
const wrapper = shallowMount(component, { props, global: { directives: { loading: () => undefined }, stubs: {
|
|
AdminPage: container, DetailPageLayout: container, FormSection: container, AppDialog: dialog, LoadErrorState: true, ApiErrorMeta: true,
|
|
ElButton: { template: '<button><slot/></button>' }, ElForm: container, ElFormItem: container,
|
|
ElInput: input, ElSelect: { ...input, name: 'ElSelect' }, ElDatePicker: { ...input, name: 'ElDatePicker' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElOption: true,
|
|
ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot/></div>' }, ElTabPane: true, DataTable: table, RowActions: true, VehicleCareTable: component === VehicleCareTable ? false : true, VehicleCareEditor: component === VehicleCareEditor ? false : true,
|
|
} } }); wrappers.push(wrapper); return wrapper
|
|
}
|
|
beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [structuredClone(row)], page: 1, size: 10, total: 1, pages: 1 })
|
|
vi.mocked(api.listVehicleCareFinance).mockImplementation(async (_vehicle, numbers) => numbers.map(recordNo => ({ recordNo, status: 'UNLINKED', sourceVersion: null, submittedRecordVersion: null, finance: null })))
|
|
vi.mocked(api.listVehicleCareSuppliers).mockResolvedValue([])
|
|
})
|
|
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
|
it('saving an unchanged record does not create a correction due to JSON property order', async () => {
|
|
const record = structuredClone(row)
|
|
record.details = Object.fromEntries(Object.entries(record.details).reverse()) as typeof record.details
|
|
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record })
|
|
await wrapper.setProps({ modelValue: true })
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
expect(api.updateVehicleCare).not.toHaveBeenCalled()
|
|
expect(wrapper.emitted('saved')).toHaveLength(1)
|
|
})
|
|
it('loads care only after the detail tab is selected', async () => {
|
|
vi.mocked(getVehicle).mockResolvedValue({ id: '71', plateNumber: 'VEH-A', model: 'Truck', fuelConsumptionPer100Km: 10, mileageKm: 1000, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', recordVersion: 0 } as Awaited<ReturnType<typeof getVehicle>>)
|
|
const wrapper = mount(VehicleDetailView, {}); await flushPromises()
|
|
expect(wrapper.findAllComponents({ name: 'VehicleCareTable' })).toHaveLength(0)
|
|
expect(api.listVehicleCare).not.toHaveBeenCalled()
|
|
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'care'); await flushPromises()
|
|
expect(wrapper.findAllComponents({ name: 'VehicleCareTable' })).toHaveLength(2)
|
|
})
|
|
it('requests one page at a time, never preloads photos, and ignores the old vehicle response', async () => {
|
|
let resolveOld!: (value: Awaited<ReturnType<typeof api.listVehicleCare>>) => void
|
|
vi.mocked(api.listVehicleCare).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
|
|
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL' })
|
|
await wrapper.setProps({ vehicleId: '72', plate: 'VEH-B' }); await flushPromises()
|
|
expect(api.listVehicleCare).toHaveBeenCalledTimes(2)
|
|
resolveOld({ records: [{ ...row, recordNo: 'OLD' }], page: 1, size: 10, total: 1, pages: 1 }); await flushPromises()
|
|
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')[0].recordNo).toBe('VR-ONE')
|
|
expect(api.downloadVehicleCarePhoto).not.toHaveBeenCalled()
|
|
})
|
|
it('retains the saved record and exact photo key after an upload failure', async () => {
|
|
vi.mocked(api.createVehicleCare).mockResolvedValue(structuredClone(row))
|
|
vi.mocked(api.uploadVehicleCarePhoto).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ ...row, recordVersion: 1 })
|
|
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
|
|
await wrapper.setProps({ modelValue: true })
|
|
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
|
|
const file = new File(['photo'], 'meter.png', { type: 'image/png' }), picker = wrapper.get('input[type="file"]')
|
|
Object.defineProperty(picker.element, 'files', { value: [file] }); await picker.trigger('change')
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('message')).toContain('记录已保存')
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
expect(api.createVehicleCare).toHaveBeenCalledTimes(1); expect(api.updateVehicleCare).not.toHaveBeenCalled()
|
|
expect(api.uploadVehicleCarePhoto).toHaveBeenCalledTimes(2)
|
|
expect(vi.mocked(api.uploadVehicleCarePhoto).mock.calls[0]).toEqual(vi.mocked(api.uploadVehicleCarePhoto).mock.calls[1])
|
|
})
|
|
it('keeps read-only users out of create edit and upload controls', async () => {
|
|
access.write = false
|
|
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL' }); await flushPromises()
|
|
expect(wrapper.text()).not.toContain('添加加油记录')
|
|
expect(wrapper.findComponent({ name: 'RowActions' }).props('actions').every((action: { visible: boolean }) => !action.visible)).toBe(true)
|
|
})
|
|
it('reads the latest record only when editing instead of reusing an old page version', async () => {
|
|
vi.mocked(api.getVehicleCare).mockResolvedValue({ ...row, recordVersion: 5 })
|
|
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL' }); await flushPromises()
|
|
expect(api.getVehicleCare).not.toHaveBeenCalled()
|
|
wrapper.findComponent({ name: 'RowActions' }).vm.$emit('action', 'edit'); await flushPromises()
|
|
expect(api.getVehicleCare).toHaveBeenCalledExactlyOnceWith('71', 'VR-ONE')
|
|
expect(wrapper.findComponent({ name: 'VehicleCareEditor' }).props('record').recordVersion).toBe(5)
|
|
})
|
|
it('allows correction after a definitive validation failure instead of locking the invalid draft', async () => {
|
|
vi.mocked(api.createVehicleCare).mockRejectedValueOnce(new ApiResponseError('请填写加油量', 'A0400', 200)).mockResolvedValueOnce(structuredClone(row))
|
|
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
|
|
await wrapper.setProps({ modelValue: true }); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
expect(wrapper.text()).not.toContain('正在核对原保存结果')
|
|
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
expect(api.createVehicleCare).toHaveBeenLastCalledWith('71', expect.any(String), expect.objectContaining({ fuelQuantity: 28.6 }))
|
|
})
|
|
|
|
it('does not continue uploading vehicle photos after the editor is unmounted', async () => {
|
|
let finish!: (value: VehicleCareRecord) => void
|
|
vi.mocked(api.createVehicleCare).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
|
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
|
|
await wrapper.setProps({ modelValue: true })
|
|
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
|
|
const picker = wrapper.get('input[type="file"]')
|
|
Object.defineProperty(picker.element, 'files', { value: [new File(['png'], 'fuel.png', { type: 'image/png' })] })
|
|
await picker.trigger('change')
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
wrapper.unmount(); finish(structuredClone(row)); await flushPromises()
|
|
expect(api.uploadVehicleCarePhoto).not.toHaveBeenCalled()
|
|
expect(wrapper.emitted('saved')).toBeUndefined()
|
|
})
|
|
|
|
it('keeps an uncertain create key scoped to its vehicle when the editor is reused', async () => {
|
|
vi.mocked(api.createVehicleCare).mockRejectedValueOnce(new Error('unknown network outcome')).mockResolvedValueOnce({ ...row, vehicleId: '72', recordNo: 'VR-TWO' })
|
|
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
|
|
await wrapper.setProps({ modelValue: true })
|
|
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
const oldKey = vi.mocked(api.createVehicleCare).mock.calls[0]![1]
|
|
expect(wrapper.text()).toContain('正在核对原保存结果')
|
|
await wrapper.setProps({ modelValue: false })
|
|
await wrapper.setProps({ vehicleId: '72', plate: 'VEH-B', modelValue: true }); await flushPromises()
|
|
expect(wrapper.text()).not.toContain('正在核对原保存结果')
|
|
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 12)
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
expect(api.createVehicleCare).toHaveBeenLastCalledWith('72', expect.any(String), expect.objectContaining({ fuelQuantity: 12 }))
|
|
expect(vi.mocked(api.createVehicleCare).mock.calls[1]![1]).not.toBe(oldKey)
|
|
})
|
|
|
|
it('saves repair project and place separately without submitting hidden mileage or fuel fields', async () => {
|
|
vi.mocked(api.createVehicleCare).mockImplementation(async (vehicleId, _key, details) => ({ ...row, vehicleId, details, paymentStatus: 'UNLINKED' }))
|
|
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'REPAIR', record: null })
|
|
await wrapper.setProps({ modelValue: true })
|
|
await wrapper.get('input[placeholder="请输入具体项目"]').setValue('更换轮胎')
|
|
await wrapper.get('input[placeholder="请输入实际地点"]').setValue('维修站')
|
|
await wrapper.get('input[placeholder="请说明事件及实际处理情况"]').setValue('轮胎损坏,现场维修')
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
expect(api.createVehicleCare).toHaveBeenCalledWith('71', expect.any(String), expect.objectContaining({ recordType: 'REPAIR', odometer: null, distanceUnit: null, verificationStatus: null, fuelQuantity: null,
|
|
incident: expect.objectContaining({ item: '更换轮胎', place: '维修站', description: '轮胎损坏,现场维修', reportedCost: null, currency: null }) }))
|
|
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('renders completed rescue and zero reported cost separately from payment and provides the approved update action', async () => {
|
|
const rescue: VehicleCareRecord = { ...row, paymentStatus: 'UNLINKED', details: { ...row.details, recordType: 'RESCUE', distanceUnit: null, odometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null,
|
|
incident: { item: '拖车', place: '现场', description: '处理完成', handler: '救援人员', processingStatus: 'COMPLETED', exceptionType: null, reportedCost: 0, currency: 'USD' } } }
|
|
vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [rescue], page: 1, size: 10, total: 1, pages: 1 })
|
|
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'RESCUE' }); await flushPromises()
|
|
expect(wrapper.get('[data-test="status"]').text()).toBe('已完成')
|
|
expect(wrapper.get('[data-test="payment"]').text()).toBe('未提交财务')
|
|
expect(wrapper.get('[data-test="cost"]').text()).toBe('USD 0.00')
|
|
expect(wrapper.findComponent({ name: 'DataTable' }).props('columns')[0].key).toBe('actions')
|
|
expect(wrapper.findComponent({ name: 'RowActions' }).props('actions')[0].label).toBe('更新处理')
|
|
})
|
|
|
|
it('editing an exception does not mutate the displayed source until a command succeeds', async () => {
|
|
const exception: VehicleCareRecord = { ...row, details: { ...row.details, recordType: 'EXCEPTION', distanceUnit: null, odometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null,
|
|
incident: { item: null, place: null, description: '原异常描述', handler: null, processingStatus: 'PENDING', exceptionType: 'TIMEOUT', reportedCost: null, currency: null } } }
|
|
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'EXCEPTION', record: exception })
|
|
await wrapper.setProps({ modelValue: true })
|
|
await wrapper.get('input[placeholder="请说明事件及实际处理情况"]').setValue('尚未保存的修改')
|
|
expect(exception.details.incident?.description).toBe('原异常描述')
|
|
expect(api.updateVehicleCare).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not let an older finance batch overwrite the status just read for the opened record', async () => {
|
|
const repair: VehicleCareRecord = { ...row, details: { ...row.details, recordType: 'REPAIR' } }
|
|
vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [repair], page: 1, size: 10, total: 1, pages: 1 })
|
|
vi.mocked(api.getVehicleCare).mockResolvedValue(repair)
|
|
let finish!: (value: Awaited<ReturnType<typeof api.listVehicleCareFinance>>) => void
|
|
vi.mocked(api.listVehicleCareFinance).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
|
.mockResolvedValueOnce([{ recordNo: row.recordNo, status: 'PENDING_FINANCE', sourceVersion: '1', submittedRecordVersion: '1', finance: null }])
|
|
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'REPAIR' }); await flushPromises()
|
|
wrapper.findComponent({ name: 'RowActions' }).vm.$emit('action', 'finance'); await flushPromises()
|
|
finish([{ recordNo: row.recordNo, status: 'UNLINKED', sourceVersion: null, submittedRecordVersion: null, finance: null }]); await flushPromises()
|
|
expect(wrapper.get('[data-test="payment"]').text()).not.toBe('未提交财务')
|
|
expect(wrapper.findComponent({ name: 'AppDialog' }).props('showConfirm')).toBe(false)
|
|
})
|