200 lines
17 KiB
TypeScript
200 lines
17 KiB
TypeScript
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
import { mount as mountFull, shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
|
import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus'
|
|
import TripOperationsRecordDialog from './TripOperationsRecordDialog.vue'
|
|
import * as api from '../api/tripOperations'
|
|
import { ApiResponseError } from '@/shared/api/http'
|
|
vi.mock('../api/tripOperations', () => ({ getTripOperationsRecord: vi.fn(), saveTripOperationsRecord: vi.fn(), listTripOperationsRecordHistory: vi.fn(), uploadTripOperationsEvidence: vi.fn(), downloadTripOperationsEvidence: vi.fn() }))
|
|
const row: api.TripOperationsRow = { tripNo: 'TR1', status: 'IN_TRANSIT', vehiclePlate: 'TEST-1', driverName: '司机', warehouseName: '起运仓', departedAt: '2026-10-01T00:00:00Z', completedAt: null, destinations: [], physicalPallets: 1, palletSpaces: 1, ownPackages: 1, externalPackages: 0, externalAgreedAmounts: [], externalPalletRates: [], measured: null, financeStatus: 'NOT_QUERIED', costStatus: 'FORBIDDEN', costs: null }
|
|
const record: api.TripOperationsRecord = { tripNo: 'TR1', recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null, evidenceFiles: [] }, updatedAt: null }
|
|
const wrappers: VueWrapper[] = []
|
|
const slot = { template: '<div><slot/></div>' }
|
|
const input = { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\',Number($event.target.value))" />' }
|
|
async function mount(readonly = false, realControls = false) {
|
|
const wrapper = (realControls ? mountFull : shallowMount)(TripOperationsRecordDialog, { props: { modelValue: false, row, readonly }, global: { components: { ElForm, ElFormItem, ElInput, ElInputNumber }, stubs: {
|
|
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot/><slot name="footer"/></div>' }, ElForm: realControls ? false : slot, ElFormItem: realControls ? false : slot,
|
|
ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot/></div>' }, ElTabPane: slot, ElInputNumber: realControls ? false : input,
|
|
ElInput: realControls ? false : { ...input, name: 'ElInput' }, ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot/></button>' },
|
|
ElAlert: { props: ['title'], template: '<div>{{ title }}<slot/></div>' }, ApiErrorMeta: true, DataTable: true,
|
|
} } }); wrappers.push(wrapper); await wrapper.setProps({ modelValue: true }); await flushPromises(); return wrapper
|
|
}
|
|
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripOperationsRecord).mockResolvedValue(record); vi.mocked(api.listTripOperationsRecordHistory).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) })
|
|
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
|
|
it.each([[null, 0], [0, null]])('delayed records keep distance %s and fuel %s editable in real number controls', async (distance, fuel) => {
|
|
let resolve!: (value: api.TripOperationsRecord) => void
|
|
vi.mocked(api.getTripOperationsRecord).mockReturnValue(new Promise(done => { resolve = done }))
|
|
const wrapper = await mount(false, true)
|
|
expect(wrapper.findComponent(ElForm).exists()).toBe(false)
|
|
expect(wrapper.findAll('button').find(button => button.text() === '保存记录')!.attributes('disabled')).toBeDefined()
|
|
resolve({ ...record, details: { ...record.details, actualDistanceMiles: distance, actualFuelLitres: fuel } })
|
|
await flushPromises()
|
|
const controls = wrapper.findAllComponents(ElInputNumber)
|
|
expect(controls.map(control => control.props('modelValue'))).toEqual([distance, fuel])
|
|
const inputs = controls.map(control => control.find('input'))
|
|
expect(inputs.map(input => (input.element as HTMLInputElement).value)).toEqual([distance === null ? '' : '0.0', fuel === null ? '' : '0.0'])
|
|
for (const input of inputs) {
|
|
expect((input.element as HTMLInputElement).disabled).toBe(false)
|
|
expect((input.element as HTMLInputElement).readOnly).toBe(false)
|
|
expect(input.attributes('aria-disabled')).toBe('false')
|
|
}
|
|
await inputs[0]!.setValue('12.5')
|
|
await inputs[0]!.trigger('change')
|
|
expect(controls[0]!.props('modelValue')).toBe(12.5)
|
|
await inputs[0]!.setValue(distance === null ? '' : '0')
|
|
await inputs[0]!.trigger('change')
|
|
expect(controls[0]!.props('modelValue')).toBe(distance)
|
|
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue(record)
|
|
await wrapper.findAll('button').find(button => button.text() === '保存记录')!.trigger('click')
|
|
await flushPromises()
|
|
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1].details).toMatchObject({ actualDistanceMiles: distance, actualFuelLitres: fuel })
|
|
})
|
|
it('delayed read-only records keep real number controls disabled', async () => {
|
|
let resolve!: (value: api.TripOperationsRecord) => void
|
|
vi.mocked(api.getTripOperationsRecord).mockReturnValue(new Promise(done => { resolve = done }))
|
|
const wrapper = await mount(true, true)
|
|
expect(wrapper.findComponent(ElForm).exists()).toBe(false)
|
|
resolve({ ...record, details: { ...record.details, actualFuelLitres: 0 } })
|
|
await flushPromises()
|
|
expect(wrapper.findAllComponents(ElInputNumber).map(control => control.props('modelValue'))).toEqual([null, 0])
|
|
for (const control of wrapper.findAllComponents(ElInputNumber)) {
|
|
const input = control.find('input')
|
|
expect((input.element as HTMLInputElement).disabled).toBe(true)
|
|
expect(input.attributes('aria-disabled')).toBe('true')
|
|
}
|
|
expect(wrapper.text()).not.toContain('保存记录')
|
|
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
|
})
|
|
it('loads current record on open, history only when selected, and hides writes for readers', async () => {
|
|
const wrapper = await mount(true)
|
|
expect(api.getTripOperationsRecord).toHaveBeenCalledExactlyOnceWith('TR1'); expect(api.listTripOperationsRecordHistory).not.toHaveBeenCalled()
|
|
expect(wrapper.text()).not.toContain('保存记录')
|
|
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'history'); await flushPromises()
|
|
expect(api.listTripOperationsRecordHistory).toHaveBeenCalledExactlyOnceWith('TR1', 1, 20)
|
|
})
|
|
it('unknown write retry preserves original key, version and measured values', async () => {
|
|
const wrapper = await mount(); vi.mocked(api.saveTripOperationsRecord).mockRejectedValueOnce(new Error('network'))
|
|
wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 12.5); await flushPromises()
|
|
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
|
|
const original = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
|
|
expect(original).toMatchObject({ recordVersion: '0', details: { actualDistanceMiles: 12.5, actualFuelLitres: null } })
|
|
wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 99)
|
|
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
|
|
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
|
|
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(original); expect(wrapper.emitted('saved')).toHaveLength(1)
|
|
})
|
|
it('a failed initial read cannot save a guessed record version', async () => {
|
|
vi.mocked(api.getTripOperationsRecord).mockRejectedValue(new Error('unavailable')); const wrapper = await mount()
|
|
expect(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeDefined()
|
|
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
|
})
|
|
const evidence: api.TripOperationsEvidence = { fileId: '9007199254740993', fileName: 'meter.pdf', contentType: 'application/pdf', sizeBytes: 4 }
|
|
async function choose(wrapper: VueWrapper, file = new File(['test'], 'meter.pdf', { type: 'application/pdf' })) {
|
|
const input = wrapper.find('[data-testid="evidence-input"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises(); return file
|
|
}
|
|
it('uploads optionally without creating records; saving binds only the exact IDs and unknown save retains them', async () => {
|
|
vi.mocked(api.uploadTripOperationsEvidence).mockResolvedValue(evidence)
|
|
vi.mocked(api.saveTripOperationsRecord).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ ...record, recordVersion: '1' })
|
|
const wrapper = await mount(); await choose(wrapper)
|
|
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('meter.pdf')
|
|
expect(wrapper.text()).toContain('保存后可预览'); expect(wrapper.findAll('button').find(b => b.text() === 'meter.pdf')!.attributes('disabled')).toBeDefined()
|
|
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
|
|
const request = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
|
|
expect(request.details.evidenceFileIds).toEqual([evidence.fileId]); expect(request.details).not.toHaveProperty('evidenceFiles')
|
|
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
|
|
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(request)
|
|
})
|
|
it('a committed upload with lost response never retransmits after reopen or reload and preserves bound evidence', async () => {
|
|
const existing = { ...evidence, fileId: '81', fileName: 'bound.pdf' }
|
|
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, details: { ...record.details, evidenceFiles: [existing] } })
|
|
let storedFiles = 0
|
|
vi.mocked(api.uploadTripOperationsEvidence).mockImplementation(async () => { storedFiles++; throw new Error('response lost after storage commit') })
|
|
const wrapper = await mount(); await choose(wrapper)
|
|
expect(storedFiles).toBe(1); expect(wrapper.text()).toContain('可能已上传但尚未绑定'); expect(wrapper.text()).toContain('bound.pdf')
|
|
expect(wrapper.text()).not.toContain('重试原文件'); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
|
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
|
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('可能已上传但尚未绑定')
|
|
expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeDefined()
|
|
await wrapper.findAll('button').find(b => b.text() === '重新读取')!.trigger('click'); await flushPromises()
|
|
await choose(wrapper)
|
|
expect(storedFiles).toBe(1); expect(api.uploadTripOperationsEvidence).toHaveBeenCalledTimes(1); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
|
await wrapper.findAll('button').find(b => b.text() === '暂不绑定此附件')!.trigger('click'); await flushPromises()
|
|
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
|
|
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
|
|
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1].details.evidenceFileIds).toEqual(['81'])
|
|
expect(storedFiles).toBe(1)
|
|
const refreshed = await mount(); expect(refreshed.text()).toContain('bound.pdf'); expect(storedFiles).toBe(1)
|
|
})
|
|
it('explicit upload rejection allows corrected file selection without creating a record', async () => {
|
|
vi.mocked(api.uploadTripOperationsEvidence).mockRejectedValueOnce(new ApiResponseError('文件格式不符', 'A0400', 400)).mockResolvedValueOnce(evidence)
|
|
const wrapper = await mount(); await choose(wrapper)
|
|
expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeUndefined()
|
|
expect(wrapper.text()).not.toContain('可能已上传但尚未绑定')
|
|
const corrected = new File(['new'], 'corrected.pdf', { type: 'application/pdf' }); await choose(wrapper, corrected)
|
|
expect(api.uploadTripOperationsEvidence).toHaveBeenNthCalledWith(2, 'TR1', corrected)
|
|
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
|
})
|
|
it('late upload from another trip cannot attach a file to the newly opened record', async () => {
|
|
let done!: (value: api.TripOperationsEvidence) => void
|
|
vi.mocked(api.uploadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
|
|
const wrapper = await mount(); await choose(wrapper)
|
|
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, tripNo: 'TR2' })
|
|
await wrapper.setProps({ row: { ...row, tripNo: 'TR2' } }); await flushPromises(); done(evidence); await flushPromises()
|
|
expect(wrapper.text()).not.toContain('meter.pdf'); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
|
vi.mocked(api.getTripOperationsRecord).mockResolvedValue(record)
|
|
await wrapper.setProps({ row }); await flushPromises()
|
|
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('保存后可预览')
|
|
})
|
|
it('upload completing on history tab remains recoverable on the same trip', async () => {
|
|
let done!: (value: api.TripOperationsEvidence) => void
|
|
vi.mocked(api.uploadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
|
|
const wrapper = await mount(); await choose(wrapper)
|
|
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'history'); await flushPromises()
|
|
done(evidence); await flushPromises()
|
|
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'measured'); await flushPromises()
|
|
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('保存后可预览')
|
|
expect(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeUndefined()
|
|
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
|
})
|
|
it('preview is fetched only on explicit click and stale response cannot appear on another trip', async () => {
|
|
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, details: { ...record.details, evidenceFiles: [evidence] } })
|
|
let done!: (value: Blob) => void
|
|
vi.mocked(api.downloadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
|
|
const wrapper = await mount(true); expect(api.downloadTripOperationsEvidence).not.toHaveBeenCalled(); expect(wrapper.find('[data-testid="evidence-input"]').exists()).toBe(false)
|
|
await wrapper.findAll('button').find(b => b.text() === 'meter.pdf')!.trigger('click')
|
|
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, tripNo: 'TR2' })
|
|
await wrapper.setProps({ row: { ...row, tripNo: 'TR2' } }); await flushPromises(); done(new Blob(['pdf'], { type: 'application/pdf' })); await flushPromises()
|
|
expect(wrapper.find('iframe').exists()).toBe(false); expect(api.downloadTripOperationsEvidence).toHaveBeenCalledExactlyOnceWith('TR1', evidence.fileId)
|
|
})
|
|
it('late save cannot close another trip dialog', async () => {
|
|
let done!: (value: api.TripOperationsRecord) => void
|
|
vi.mocked(api.saveTripOperationsRecord).mockReturnValue(new Promise(resolve => { done = resolve }))
|
|
const wrapper = await mount(); await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click')
|
|
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, tripNo: 'TR2' }); await wrapper.setProps({ row: { ...row, tripNo: 'TR2' } }); await flushPromises()
|
|
done({ ...record, recordVersion: '1' }); await flushPromises()
|
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined(); expect(wrapper.text()).toContain('TR2')
|
|
})
|
|
it('closing and reopening the same trip cannot start a concurrent save retry', async () => {
|
|
let reject!: (error: Error) => void
|
|
vi.mocked(api.saveTripOperationsRecord).mockReturnValue(new Promise((_, fail) => { reject = fail }))
|
|
const wrapper = await mount(); await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click')
|
|
const original = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
|
|
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
|
const retry = wrapper.findAll('button').find(b => b.text() === '按原请求重试')!
|
|
expect(retry.attributes('disabled')).toBeDefined(); await retry.trigger('click'); expect(api.saveTripOperationsRecord).toHaveBeenCalledTimes(1)
|
|
reject(new Error('unknown')); await flushPromises()
|
|
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
|
|
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
|
|
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(original)
|
|
})
|
|
it('closing and reopening the same trip cannot duplicate an in-flight upload', async () => {
|
|
let done!: (value: api.TripOperationsEvidence) => void
|
|
vi.mocked(api.uploadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
|
|
const wrapper = await mount(); await choose(wrapper)
|
|
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
|
expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeDefined()
|
|
expect(wrapper.text()).not.toContain('重试原文件'); await choose(wrapper); expect(api.uploadTripOperationsEvidence).toHaveBeenCalledTimes(1)
|
|
done(evidence); await flushPromises()
|
|
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('保存后可预览')
|
|
expect(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeUndefined()
|
|
})
|