import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' import TripCostPanel from './TripCostPanel.vue' import * as api from '../api/tripCosts' 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/tripCosts', () => ({ listTripCosts: vi.fn(), createTripCost: vi.fn(), updateTripCost: vi.fn(), listTripCostHistory: vi.fn() })) const cost: api.TripCost = { costNo: 'TC-ONE', tripNo: 'TR-ONE', details: { category: 'FUEL', currency: 'USD', amount: 10, incurredAt: '2026-01-01T00:00:00Z', remark: null }, recordVersion: '1', updatedAt: '2026-01-01T00:00:00Z' } const result: api.TripCosts = { page: { records: [cost], page: 1, size: 10, total: 1, pages: 1 }, summary: { departureVersion: null, manifestHash: null, allocationMethod: null, allocationBlockReason: '尚无实际发车装载记录', totals: [] } } const wrappers: VueWrapper[] = [] const input = { props: ['modelValue'], emits: ['update:modelValue'], template: '' } function mount() { const wrapper = shallowMount(TripCostPanel, { props: { tripNo: 'TR-ONE' }, global: { stubs: { ElButton: { template: '' }, ElForm: { template: '
' }, ElFormItem: { template: '
' }, ElOption: true, ElInput: { ...input, name: 'ElInput' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElSelect: input, ElDatePicker: input, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], emits: ['confirm'], template: '
' }, DataTable: { name: 'DataTable', props: ['data', 'columns', 'page', 'pageSize'], emits: ['update:page', 'update:pageSize'], template: '
' }, RowActions: { name: 'RowActions', props: ['actions'], emits: ['action'], template: '
' }, LoadErrorState: { props: ['message'], template: '

{{ message }}

' }, ApiErrorMeta: true, } } }); wrappers.push(wrapper); return wrapper } function button(w: VueWrapper, label: string) { return w.findAll('button').find(b => b.text() === label)! } beforeEach(() => { vi.resetAllMocks(); access.read = true; access.write = true; vi.mocked(api.listTripCosts).mockResolvedValue(structuredClone(result)); vi.mocked(api.createTripCost).mockResolvedValue(cost); vi.mocked(api.updateTripCost).mockResolvedValue(cost) vi.mocked(api.listTripCostHistory).mockResolvedValue({ records: [{ recordVersion: '9', action: 'UPDATE', reason: '更正', occurredAt: cost.updatedAt, snapshot: { cost: { ...cost, recordVersion: '9' }, summary: result.summary } }], page: 1, size: 1, total: 1, pages: 1 }) }) afterEach(() => wrappers.splice(0).forEach(w => w.unmount())) it('loads one scoped page and summary without per-row reads or invented zero totals', async () => { const w = mount(); await flushPromises() expect(api.listTripCosts).toHaveBeenCalledExactlyOnceWith('TR-ONE', 1, 10) expect(api.listTripCostHistory).not.toHaveBeenCalled(); expect(w.text()).toContain('尚未登记直接成本') }) it('does not request cost data without list permission, and hides writes for read-only users', async () => { access.read = false; const denied = mount(); await flushPromises(); expect(api.listTripCosts).not.toHaveBeenCalled(); expect(denied.text()).toContain('暂无车次成本查看权限') access.read = true; access.write = false; const readOnly = mount(); await flushPromises() expect(readOnly.text()).not.toContain('登记成本'); expect(readOnly.text()).not.toContain('更正'); expect(readOnly.text()).toContain('历史') }) it('ignores late responses after switching trips and resets later pagination once', async () => { const w = mount(); await flushPromises(); w.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 3); await flushPromises() let finish!: (value: api.TripCosts) => void vi.mocked(api.listTripCosts).mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) await w.setProps({ tripNo: 'TR-TWO' }); await flushPromises() vi.mocked(api.listTripCosts).mockResolvedValueOnce({ ...result, page: { ...result.page, records: [{ ...cost, costNo: 'TC-THREE', tripNo: 'TR-THREE' }] } }) await w.setProps({ tripNo: 'TR-THREE' }); await flushPromises(); finish(result); await flushPromises() expect(api.listTripCosts).toHaveBeenNthCalledWith(3, 'TR-TWO', 1, 10) expect(api.listTripCosts).toHaveBeenNthCalledWith(4, 'TR-THREE', 1, 10) expect(w.findComponent({ name: 'DataTable' }).props('data')[0].costNo).toBe('TC-THREE') }) it('retains the exact key and contents when a creation response is uncertain', async () => { vi.mocked(api.createTripCost).mockRejectedValueOnce(new Error('unknown outcome')).mockResolvedValueOnce(cost) const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises() w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5); await button(w, '保存').trigger('click'); await flushPromises() const first = vi.mocked(api.createTripCost).mock.calls[0]! expect(w.text()).toContain('正在核对上次登记结果') await button(w, '保存').trigger('click'); await flushPromises() expect(vi.mocked(api.createTripCost).mock.calls[1]).toEqual(first) }) it('keeps the successful save authoritative when pagination changes while it is pending', async () => { let finish!: (value: api.TripCost) => void vi.mocked(api.createTripCost).mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises() w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5) await button(w, '保存').trigger('click'); await flushPromises() w.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises() finish(cost); await flushPromises() expect(w.findAllComponents({ name: 'AppDialog' })[0]!.props('modelValue')).toBe(false) expect(w.text()).not.toContain('正在核对上次登记结果') expect(api.listTripCosts).toHaveBeenLastCalledWith('TR-ONE', 1, 10) expect(api.createTripCost).toHaveBeenCalledTimes(1) }) it('retains the original request across trip switches and ignores the first response during its retry', async () => { let finishFirst!: (value: api.TripCost) => void let finishSecond!: (value: api.TripCost) => void vi.mocked(api.createTripCost) .mockImplementationOnce(() => new Promise(resolve => { finishFirst = resolve })) .mockImplementationOnce(() => new Promise(resolve => { finishSecond = resolve })) const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises() w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5) await button(w, '保存').trigger('click'); await flushPromises() await w.setProps({ tripNo: 'TR-TWO' }); await flushPromises() await button(w, '登记成本').trigger('click'); await flushPromises() expect(w.findAllComponents({ name: 'AppDialog' })[0]!.props('modelValue')).toBe(false) await w.setProps({ tripNo: 'TR-ONE' }); await flushPromises() await button(w, '登记成本').trigger('click'); await flushPromises() w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20) await button(w, '保存').trigger('click'); await flushPromises() finishFirst(cost); await flushPromises() await button(w, '保存').trigger('click'); await flushPromises() expect(api.createTripCost).toHaveBeenCalledTimes(2) expect(vi.mocked(api.createTripCost).mock.calls[1]).toEqual(vi.mocked(api.createTripCost).mock.calls[0]) finishSecond(cost); await flushPromises() expect(w.findAllComponents({ name: 'AppDialog' })[0]!.props('modelValue')).toBe(false) }) it('unlocks a definite HTTP200 business rejection for correction with a fresh create key', async () => { vi.mocked(api.createTripCost).mockRejectedValueOnce(new ApiResponseError('业务拒绝', 'A0422', 200)) const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises() w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5) await button(w, '保存').trigger('click'); await flushPromises() const first = vi.mocked(api.createTripCost).mock.calls[0]! expect(w.text()).not.toContain('正在核对上次登记结果') w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10) await button(w, '保存').trigger('click'); await flushPromises() const second = vi.mocked(api.createTripCost).mock.calls[1]! expect(second[1]).not.toBe(first[1]); expect(second[2].amount).toBe(10) }) it('reads the current version before editing and avoids writing unchanged contents', async () => { const w = mount(); await flushPromises(); await button(w, '更正').trigger('click'); await flushPromises() expect(api.listTripCostHistory).toHaveBeenCalledExactlyOnceWith('TR-ONE', 'TC-ONE', 1, 1) await button(w, '保存').trigger('click'); await flushPromises(); expect(api.updateTripCost).not.toHaveBeenCalled() await button(w, '更正').trigger('click'); await flushPromises(); w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 15) await button(w, '保存').trigger('click'); await flushPromises() expect(api.updateTripCost).toHaveBeenCalledWith('TR-ONE', expect.objectContaining({ recordVersion: '9' }), expect.objectContaining({ amount: 15 }), '') }) it('failed refresh clears both rows and summary instead of keeping stale evidence', async () => { const w = mount(); await flushPromises(); vi.mocked(api.listTripCosts).mockRejectedValueOnce(new Error('查询失败')) await button(w, '刷新').trigger('click'); await flushPromises() expect(w.findComponent({ name: 'DataTable' }).props('data')).toEqual([]); expect(w.text()).toContain('车次成本加载失败'); expect(w.text()).not.toContain('尚无实际发车装载记录') })