Files
admin-web/src/modules/overseas-master-data/components/TripCostPanel.test.ts
T

119 lines
10 KiB
TypeScript

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: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
function mount() {
const wrapper = shallowMount(TripCostPanel, { props: { tripNo: 'TR-ONE' }, global: { stubs: {
ElButton: { template: '<button><slot/></button>' }, ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElOption: true,
ElInput: { ...input, name: 'ElInput' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElSelect: input, ElDatePicker: input,
AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存</button></div>' },
DataTable: { name: 'DataTable', props: ['data', 'columns', 'page', 'pageSize'], emits: ['update:page', 'update:pageSize'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.costNo"><slot name="actions" :row="row"/></div></div>' },
RowActions: { name: 'RowActions', props: ['actions'], emits: ['action'], template: '<div><button v-for="a in actions.filter(a => a.visible !== false)" :key="a.key" @click="$emit(\'action\', a.key)">{{ a.label }}</button></div>' },
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, 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('尚无实际发车装载记录')
})