import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils' import { defineComponent, h, KeepAlive, ref } from 'vue' import { ElMessage } from 'element-plus' import { ApiResponseError } from '@/shared/api/http' import * as api from '../api/drivers' import type { DriverDetail } from '../driverTypes' import DriversView from './DriversView.vue' import DriverDetailView from './DriverDetailView.vue' import DriverEditorDialog from '../components/DriverEditorDialog.vue' import DriverLicensePreview from '../components/DriverLicensePreview.vue' const security = vi.hoisted(() => ({ allowWrite: true })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || security.allowWrite }) })) vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }), useRoute: () => ({ params: { driverId: '9223372036854775701' } }) })) vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined), prompt: vi.fn().mockResolvedValue({ value: '暂停' }) } })) vi.mock('../api/drivers', () => ({ listDrivers: vi.fn(), getDriver: vi.fn(), listDriverHistory: vi.fn(), listDriverWarehouses: vi.fn(), createDriver: vi.fn(), updateDriver: vi.fn(), changeDriverStatus: vi.fn(), reviewDriver: vi.fn(), submitDriver: vi.fn(), getDriverQualification: vi.fn(), uploadDriverLicense: vi.fn(), downloadDriverLicense: vi.fn() })) const driver: DriverDetail = { id: '9223372036854775701', driverNo: 'DR-TEST', details: { name: '测试司机', phone: '123', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LIC-1', licenseType: 'Class A', licenseIssuedOn: null, licenseExpiresOn: '2026-10-22', licenseZone: 'America/Los_Angeles', remark: null, profile: { warehouseId: '92', vehicleModels: '53尺拖车', languagesAndArea: '中文 / 洛杉矶', medicalExpiresOn: null, trainingExpiresOn: null, insuranceExpiresOn: null } }, active: true, reviewStatus: 'PENDING', licenseFileId: '9223372036854775801', recordVersion: 2, updatedBy: 'operator', updatedAt: '2026-10-02T12:00:00', qualification: { canDispatch: false, expiringSoon: true, reason: 'PENDING_REVIEW', licenseExpiresOn: '2026-10-22' }, licenseState: 'EXPIRING', warehouseCode: 'WH-US', warehouseName: '洛杉矶仓', } const layout = { template: '
' } const button = { props: ['disabled', 'loading'], template: '' } const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm', 'update:modelValue'], template: '
' } const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: '' } const table = { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total'], emits: ['update:page', 'update:pageSize'], template: '
{{row.driverNo}}
' } const stubs = { AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout, FormSection: layout, DataTable: table, QueryToolbar: true, RowActions: true, StatusBadge: true, LoadErrorState: true, AppDialog: dialog, DriverEditorDialog: true, DriverLicensePreview: true, ElButton: button, ElForm: layout, ElFormItem: layout, ElInput: input, ElSelect: { ...input, name: 'ElSelect' }, ElOption: true, ElDatePicker: { ...input, name: 'ElDatePicker' } } const wrappers: VueWrapper[] = [] function mount(component: object, props: Record = {}): VueWrapper { const wrapper = shallowMount(component, { props, global: { stubs: { ...stubs, DriverEditorDialog: component === DriverEditorDialog ? false : true, DriverLicensePreview: component === DriverLicensePreview ? false : true }, directives: { loading: () => undefined } } }) wrappers.push(wrapper) return wrapper } function findButton(wrapper: VueWrapper, text: string) { const found = wrapper.findAll('button').find(value => value.text().includes(text)); expect(found).toBeDefined(); return found! } beforeEach(() => { vi.clearAllMocks(); security.allowWrite = true vi.mocked(api.listDrivers).mockResolvedValue({ records: [structuredClone(driver)], page: 1, size: 20, total: 24, pages: 2, summary: { total: 24, owned: 20, dispatched: 4, expiringSoon: 3, expired: 1 } }) vi.mocked(api.getDriver).mockResolvedValue(structuredClone(driver)) vi.mocked(api.listDriverWarehouses).mockResolvedValue([{ id: '92', code: 'WH-US', name: '洛杉矶仓', region: 'OVERSEAS' }]) vi.mocked(api.listDriverHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }) vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: vi.fn().mockReturnValue('blob:test-license'), revokeObjectURL: vi.fn() })) }) afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.unstubAllGlobals() }) describe('driver page workflows', () => { it('refreshes a cached list once on return and retains applied filters and pagination', async () => { const showingList = ref(true) const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => showingList.value ? h(DriversView) : h('div', '司机详情') }) }) const wrapper = shallowMount(host, { global: { stubs: { ...stubs, KeepAlive: false, DriversView: false }, directives: { loading: () => undefined } } }) wrappers.push(wrapper) await flushPromises() expect(api.listDrivers).toHaveBeenCalledTimes(1) const list = wrapper.findComponent(DriversView) const toolbar = list.findComponent({ name: 'QueryToolbar' }) toolbar.vm.$emit('update:modelValue', { keyword: 'DR-TEST', licenseState: 'EXPIRING' }) toolbar.vm.$emit('search'); await flushPromises() list.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises() expect(api.listDrivers).toHaveBeenCalledTimes(3) showingList.value = false; await flushPromises() const refreshed = { ...structuredClone(driver), recordVersion: 3, reviewStatus: 'APPROVED' as const } vi.mocked(api.listDrivers).mockResolvedValueOnce({ records: [refreshed], page: 2, size: 20, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 1, expired: 0 } }) showingList.value = true; await flushPromises() expect(api.listDrivers).toHaveBeenCalledTimes(4) expect(api.listDrivers).toHaveBeenLastCalledWith({ keyword: 'DR-TEST', licenseState: 'EXPIRING', region: undefined, ownership: undefined, active: undefined, reviewStatus: undefined, page: 2, size: 20 }) expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([refreshed]) expect(wrapper.text()).toContain('司机总数1') expect(api.getDriver).not.toHaveBeenCalled() }) it('ignores a list response that arrives while its cached page is deactivated', async () => { let resolveList!: (value: Awaited>) => void vi.mocked(api.listDrivers).mockReturnValueOnce(new Promise(resolve => { resolveList = resolve })) const showingList = ref(true) const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => showingList.value ? h(DriversView) : h('div', '司机详情') }) }) const wrapper = shallowMount(host, { global: { stubs: { ...stubs, KeepAlive: false, DriversView: false }, directives: { loading: () => undefined } } }) wrappers.push(wrapper) await flushPromises() const list = wrapper.findComponent(DriversView) showingList.value = false; await flushPromises() resolveList({ records: [driver], page: 1, size: 20, total: 24, pages: 2, summary: { total: 24, owned: 20, dispatched: 4, expiringSoon: 3, expired: 1 } }); await flushPromises() expect(list.findComponent({ name: 'DataTable' }).props('data')).toEqual([]) showingList.value = true; await flushPromises() expect(api.listDrivers).toHaveBeenCalledTimes(2) expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([driver]) }) it('loads one paged request with server summary and does not request each driver or file', async () => { const wrapper = mount(DriversView) await flushPromises() expect(api.listDrivers).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain('司机总数24') expect(api.getDriver).not.toHaveBeenCalled() expect(api.downloadDriverLicense).not.toHaveBeenCalled() wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2) await flushPromises() expect(api.listDrivers).toHaveBeenCalledTimes(2) expect(api.listDrivers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) await findButton(wrapper, '即将到期').trigger('click') await flushPromises() expect(api.listDrivers).toHaveBeenCalledTimes(3) expect(api.listDrivers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, licenseState: 'EXPIRING' })) }) it('shows a retryable error instead of stale summary after list failure', async () => { vi.mocked(api.listDrivers).mockRejectedValueOnce(new Error('服务不可用')) const wrapper = mount(DriversView) await flushPromises() const error = wrapper.findComponent({ name: 'LoadErrorState' }) expect(error.exists()).toBe(true) expect(wrapper.text()).toContain('司机总数—') error.vm.$emit('retry'); await flushPromises() expect(api.listDrivers).toHaveBeenCalledTimes(2) }) it('does not expose write actions to a read-only user', async () => { security.allowWrite = false const list = mount(DriversView), detail = mount(DriverDetailView) await flushPromises() expect(list.text()).not.toContain('新增司机') expect(detail.text()).not.toContain('审核通过') expect(detail.text()).not.toContain('上传 / 替换驾驶证') expect(detail.text()).not.toContain('编辑档案') }) it('loads detail once, requests history only on demand, and requires a rejection reason', async () => { const wrapper = mount(DriverDetailView) await flushPromises() expect(api.getDriver).toHaveBeenCalledTimes(1) expect(api.listDriverHistory).not.toHaveBeenCalled() expect(wrapper.text()).toContain('洛杉矶仓') expect(wrapper.text()).not.toContain(driver.id) await findButton(wrapper, '打回').trigger('click') expect(api.reviewDriver).not.toHaveBeenCalled() expect(ElMessage.warning).toHaveBeenCalledWith('请填写打回原因') await findButton(wrapper, '查看操作历史').trigger('click'); await flushPromises() expect(api.listDriverHistory).toHaveBeenCalledTimes(1) }) it('submits approval with current version and uses returned state without reloading detail', async () => { vi.mocked(api.reviewDriver).mockResolvedValue({ ...structuredClone(driver), recordVersion: 3, reviewStatus: 'APPROVED' }) const wrapper = mount(DriverDetailView) await flushPromises() await findButton(wrapper, '审核通过').trigger('click'); await flushPromises() expect(api.reviewDriver).toHaveBeenCalledWith(driver.id, 2, 'APPROVED', null) expect(api.getDriver).toHaveBeenCalledTimes(1) expect(wrapper.findAll('button').some(value => value.text() === '审核通过')).toBe(false) }) it('keeps the editor and version after a failed write', async () => { vi.mocked(api.updateDriver).mockRejectedValue(new ApiResponseError('版本已变化,请刷新', 'A0409', 200, null, 'req-driver-conflict')) const wrapper = mount(DriverEditorDialog, { modelValue: false, driver }) await wrapper.setProps({ modelValue: true }); await flushPromises() wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() expect(api.updateDriver).toHaveBeenCalledWith(driver.id, 2, expect.objectContaining({ name: driver.details.name })) expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true) expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('message')).toContain('版本已变化') }) it('invalidates an old task preview after the planned end changes without inventing a new server qualification', async () => { vi.mocked(api.getDriverQualification).mockResolvedValue({ ...driver.qualification, canDispatch: true, reason: 'ELIGIBLE' }) const wrapper = mount(DriverDetailView) await flushPromises() const picker = wrapper.findComponent({ name: 'ElDatePicker' }) picker.vm.$emit('update:modelValue', new Date('2026-10-20T12:00:00Z')) picker.vm.$emit('change'); await flushPromises() await findButton(wrapper, '校验本次任务资格').trigger('click'); await flushPromises() expect(api.getDriverQualification).toHaveBeenCalledWith(driver.id, '2026-10-20T12:00:00.000Z') expect(wrapper.text()).toContain('本次任务可派车') picker.vm.$emit('update:modelValue', new Date('2026-11-20T12:00:00Z')) picker.vm.$emit('change'); await flushPromises() expect(wrapper.text()).not.toContain('本次任务可派车') expect(wrapper.text()).toContain('请重新校验') expect(api.getDriverQualification).toHaveBeenCalledTimes(1) }) it('does not create a second draft when attachment upload fails after saving', async () => { vi.mocked(api.createDriver).mockResolvedValue({ ...structuredClone(driver), recordVersion: 0 }) vi.mocked(api.uploadDriverLicense).mockRejectedValueOnce(new Error('文件服务不可用')).mockResolvedValueOnce({ ...structuredClone(driver), recordVersion: 1 }) const wrapper = mount(DriverEditorDialog, { modelValue: false, driver: null }) await wrapper.setProps({ modelValue: true }); await flushPromises() const inputs = wrapper.findAllComponents({ name: 'ElInput' }) inputs[0]!.vm.$emit('update:modelValue', '新司机') inputs[1]!.vm.$emit('update:modelValue', '123') const selects = wrapper.findAllComponents({ name: 'ElSelect' }) selects.at(-1)!.vm.$emit('update:modelValue', 'UTC') const file = new File(['%PDF-test'], 'license.pdf', { type: 'application/pdf' }) const 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(api.createDriver).toHaveBeenCalledTimes(1) expect(api.uploadDriverLicense).toHaveBeenCalledTimes(1) expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('message')).toContain('档案已保存') wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() expect(api.createDriver).toHaveBeenCalledTimes(1) expect(api.updateDriver).not.toHaveBeenCalled() expect(api.uploadDriverLicense).toHaveBeenCalledTimes(2) expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false]) }) it('keeps an old save outside the newly opened driver session and uploads only that driver file', async () => { let finishOld!: (value: DriverDetail) => void vi.mocked(api.updateDriver).mockReturnValueOnce(new Promise(resolve => { finishOld = resolve })) const other = { ...structuredClone(driver), id: '9223372036854775702', driverNo: 'DR-OTHER', details: { ...driver.details, name: '另一司机' } } const wrapper = mount(DriverEditorDialog, { modelValue: false, driver }) await wrapper.setProps({ modelValue: true }); await flushPromises() wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() expect(api.updateDriver).toHaveBeenCalledWith(driver.id, 2, expect.anything()) await wrapper.setProps({ modelValue: false }); await flushPromises() await wrapper.setProps({ driver: other, modelValue: true }); await flushPromises() const file = new File(['%PDF-other'], 'other-driver.pdf', { type: 'application/pdf' }) const picker = wrapper.get('input[type="file"]') Object.defineProperty(picker.element, 'files', { value: [file], configurable: true }) await picker.trigger('change') finishOld({ ...structuredClone(driver), recordVersion: 3 }); await flushPromises() expect(api.uploadDriverLicense).not.toHaveBeenCalled() expect(wrapper.emitted('saved')).toBeUndefined() expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true) expect(wrapper.findAllComponents({ name: 'ElInput' })[0]!.props('modelValue')).toBe('另一司机') vi.mocked(api.updateDriver).mockResolvedValue({ ...other, recordVersion: 3 }) vi.mocked(api.uploadDriverLicense).mockResolvedValue({ ...other, recordVersion: 4 }) wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() expect(api.updateDriver).toHaveBeenLastCalledWith(other.id, 2, expect.objectContaining({ name: '另一司机' })) expect(api.uploadDriverLicense).toHaveBeenCalledExactlyOnceWith(other.id, 3, file) expect(wrapper.emitted('saved')?.[0]?.[0]).toEqual(expect.objectContaining({ id: other.id })) }) it('does not continue a pending save with an upload after the editor is unmounted', async () => { let finishOld!: (value: DriverDetail) => void vi.mocked(api.updateDriver).mockReturnValueOnce(new Promise(resolve => { finishOld = resolve })) const wrapper = mount(DriverEditorDialog, { modelValue: false, driver }) await wrapper.setProps({ modelValue: true }); await flushPromises() const picker = wrapper.get('input[type="file"]') Object.defineProperty(picker.element, 'files', { value: [new File(['%PDF'], 'driver.pdf', { type: 'application/pdf' })] }) await picker.trigger('change') wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() wrapper.unmount() finishOld({ ...structuredClone(driver), recordVersion: 3 }); await flushPromises() expect(api.uploadDriverLicense).not.toHaveBeenCalled() expect(ElMessage.success).not.toHaveBeenCalled() }) it('releases preview blobs when closed and ignores a download completed after close', async () => { vi.mocked(api.downloadDriverLicense).mockResolvedValueOnce(new Blob(['%PDF'], { type: 'application/pdf' })) const wrapper = mount(DriverLicensePreview, { modelValue: false, driverId: driver.id, fileId: driver.licenseFileId, driverName: driver.details.name }) await wrapper.setProps({ modelValue: true }); await flushPromises() expect(wrapper.find('iframe').exists()).toBe(true) await wrapper.setProps({ modelValue: false }) expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:test-license') let complete!: (value: Blob) => void vi.mocked(api.downloadDriverLicense).mockReturnValueOnce(new Promise(resolve => { complete = resolve })) await wrapper.setProps({ modelValue: true }) await wrapper.setProps({ modelValue: false }) complete(new Blob(['%PDF'], { type: 'application/pdf' })); await flushPromises() expect(URL.createObjectURL).toHaveBeenCalledTimes(1) }) })