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()), 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: '
' } const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: '' } const table = { name: 'DataTable', props: ['data', 'page', 'pageSize', 'columns'], emits: ['update:page'], template: '
' } const dialog = { name: 'AppDialog', props: ['modelValue', 'showConfirm'], emits: ['confirm'], template: '
' } const wrappers: VueWrapper[] = [] function mount(component: object, props: Record): 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: '' }, 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: '
' }, 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>) 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>) => 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>) => 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) })