import { flushPromises, mount } from '@vue/test-utils' import ElementPlus from 'element-plus' import { beforeEach, expect, it, vi } from 'vitest' import { ApiResponseError } from '@/shared/api/http' import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue' import * as api from '../api/dispatchLoadingDocuments' const can = vi.hoisted(() => vi.fn(() => true)) vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'operator' } }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) vi.mock('../api/dispatchLoadingDocuments', async importOriginal => ({ ...await importOriginal(), getLoadingDocuments: vi.fn(), uploadLoadingPhoto: vi.fn(), recoverLoadingPhoto: vi.fn(), bindLoadingPhoto: vi.fn(), readLoadingPhoto: vi.fn() })) const photo: api.LoadingPhoto = { slot: 'LOADING_SCENE', fileId: '101', fileName: 'loading.png', contentType: 'image/png', savedAt: null } const data = (extra: Partial = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra }) function setup() { return mount(DispatchLoadingDocuments, { props: { dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) } async function upload(wrapper: ReturnType, files = [new File(['image'], 'loading.png', { type: 'image/png' })]) { const input = wrapper.get('input[aria-label="上传司机驾驶证"]') Object.defineProperty(input.element, 'files', { configurable: true, value: files }) await input.trigger('change'); await flushPromises() } beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); can.mockReturnValue(true) vi.mocked(api.getLoadingDocuments).mockResolvedValue(data()) vi.mocked(api.uploadLoadingPhoto).mockResolvedValue(photo) vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '1', photos: [photo] })) vi.mocked(api.readLoadingPhoto).mockRejectedValue(new Error('preview unavailable')) }) it('offers five whole-dispatch slots and only reports saved after binding succeeds', async () => { const wrapper = setup(); await flushPromises() expect(wrapper.findAll('input[type="file"]')).toHaveLength(5) const table = wrapper.get('table[aria-label="装车照片"]') expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '照片类别', '文件名称', '图片预览', '保存时间']) expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(Object.values(api.loadingPhotoLabels).map(label => `${label}0/5 张`)) await upload(wrapper) expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String)) expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' })) expect(wrapper.emitted('saved')).toHaveLength(1) expect(wrapper.text()).toContain('图片读取失败') expect(table.get('tbody tr').text()).toContain('loading.png') wrapper.unmount() }) it('shows every photo in a category, stops at five and replaces the selected image only', async () => { const photos = Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(101 + index), fileName: `photo-${index}.png` })) vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '5', photos })) const replacement = { ...photo, fileId: '999' } vi.mocked(api.uploadLoadingPhoto).mockResolvedValue(replacement) vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '6', photos: photos.map(item => item.fileId === '103' ? replacement : item) })) const wrapper = setup(); await flushPromises() expect(wrapper.get('input[aria-label="上传司机驾驶证"]').attributes('disabled')).toBeDefined() for (const item of photos) expect(wrapper.text()).toContain(item.fileName) const input = wrapper.get('input[aria-label="更换photo-2.png"]') Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['new'], 'new.png', { type: 'image/png' })] }) await input.trigger('change'); await flushPromises() expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '5', fileId: '999', replaceFileId: '103' })) expect(wrapper.text()).toContain('photo-0.png'); expect(wrapper.text()).toContain('photo-4.png') wrapper.unmount() }) it('saves a multiple selection with fresh versions and notifies the parent once', async () => { let latest = data(), id = 200 vi.mocked(api.getLoadingDocuments).mockImplementation(async () => latest) vi.mocked(api.uploadLoadingPhoto).mockImplementation(async () => ({ ...photo, fileId: String(++id) })) vi.mocked(api.bindLoadingPhoto).mockImplementation(async (_no, _slot, body) => { latest = data({ recordVersion: String(Number(body.recordVersion) + 1), photos: [...latest.photos, { ...photo, fileId: body.fileId }] }); return latest }) const wrapper = setup(); await flushPromises() expect(wrapper.get('input[aria-label="上传司机驾驶证"]').attributes('multiple')).toBeDefined() await upload(wrapper, Array.from({ length: 3 }, () => new File(['image'], 'photo.png', { type: 'image/png' }))) expect(api.bindLoadingPhoto).toHaveBeenCalledTimes(3) expect(vi.mocked(api.bindLoadingPhoto).mock.calls.map(call => call[2].recordVersion)).toEqual(['0', '1', '2']) expect(wrapper.emitted('saved')).toHaveLength(1); expect(wrapper.text()).toContain('3/5 张') await upload(wrapper, Array.from({ length: 3 }, () => new File(['image'], 'photo.png', { type: 'image/png' }))) expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(3) wrapper.unmount() }) it('stops a batch after an unknown binding result and displays the server snapshot', async () => { let latest = data(), id = 200, binds = 0 vi.mocked(api.getLoadingDocuments).mockImplementation(async () => latest) vi.mocked(api.uploadLoadingPhoto).mockImplementation(async () => ({ ...photo, fileId: String(++id) })) vi.mocked(api.bindLoadingPhoto).mockImplementation(async (_no, _slot, body) => { latest = data({ recordVersion: String(++binds), photos: [...latest.photos, { ...photo, fileId: body.fileId }] }) if (binds === 2) throw new Error('保存响应超时') return latest }) const wrapper = setup(); await flushPromises() await upload(wrapper, Array.from({ length: 3 }, () => new File(['image'], 'photo.png', { type: 'image/png' }))) expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2); expect(api.bindLoadingPhoto).toHaveBeenCalledTimes(2) expect(wrapper.text()).toContain('2/5 张'); expect(wrapper.text()).toContain('已读取最新资料') expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() }) it('requires a successful refresh after both binding and reconciliation fail', async () => { vi.mocked(api.getLoadingDocuments).mockResolvedValueOnce(data()).mockResolvedValueOnce(data()).mockRejectedValueOnce(new Error('读取失败')).mockResolvedValue(data()) vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new Error('保存超时')) const wrapper = setup(); await flushPromises(); await upload(wrapper) expect(wrapper.text()).toContain('保存状态尚未核实') expect(wrapper.find('input[type="file"]').exists()).toBe(false) wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises() expect(wrapper.find('input[aria-label="上传司机驾驶证"]').exists()).toBe(true) expect(api.bindLoadingPhoto).toHaveBeenCalledTimes(1); wrapper.unmount() }) it('unlocks after binding failure and accepts a new upload with a fresh key', async () => { vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new Error('network unknown')) const wrapper = setup(); await flushPromises(); await upload(wrapper) expect(wrapper.emitted('saved')).toBeUndefined() const original = vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2] await upload(wrapper) expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ fileId: original.fileId })); expect(vi.mocked(api.bindLoadingPhoto).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey) expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2) expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() }) it('uses the current version when the operator chooses a photo after a conflict', async () => { vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new ApiResponseError('version conflict', 'A0409')) const wrapper = setup(); await flushPromises(); await upload(wrapper) const original = vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2] vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '4' })) vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ stage: 'DELIVERING', complete: true, recordVersion: '5' })) await upload(wrapper) expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '4', fileId: '101' })) expect(vi.mocked(api.bindLoadingPhoto).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey) expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() }) it('shows existing photos without exposing upload for a read-only account', async () => { can.mockReturnValue(false) const wrapper = setup(); await flushPromises() expect(wrapper.find('input[type="file"]').exists()).toBe(false) expect(api.uploadLoadingPhoto).not.toHaveBeenCalled(); wrapper.unmount() }) it('allows choosing another image after the server definitively rejects the file', async () => { vi.mocked(api.uploadLoadingPhoto).mockRejectedValueOnce(new ApiResponseError('图片内容无效', 'A0400')) const wrapper = setup(); await flushPromises(); await upload(wrapper) expect(wrapper.text()).toContain('图片内容无效') expect(wrapper.find('[role="status"]').exists()).toBe(false) expect(api.bindLoadingPhoto).not.toHaveBeenCalled() await upload(wrapper) expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2) expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() }) it('allows saving photos while loading and keeps the loading stage', async () => { vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ stage: 'LOADING' })) vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ stage: 'LOADING', recordVersion: '1', photos: [photo] })) const wrapper = setup(); await flushPromises() expect(wrapper.text()).toContain('可边装车边上传') await upload(wrapper) expect(wrapper.emitted('saved')).toHaveLength(1) expect(wrapper.text()).toContain('可边装车边上传'); wrapper.unmount() }) it('renders the authenticated saved photo and fullscreen preview for a read-only Admin', async () => { can.mockReturnValue(false) const originalURL = URL const create = vi.fn(() => 'blob:admin-loading-photo'), revoke = vi.fn() vi.stubGlobal('URL', class extends originalURL { static createObjectURL = create; static revokeObjectURL = revoke }) vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ stage: 'LOADING', photos: [photo] })) const image = new Blob(['verified image'], { type: 'image/png' }) vi.mocked(api.readLoadingPhoto).mockResolvedValue(image) const wrapper = setup() try { await flushPromises() expect(api.readLoadingPhoto).toHaveBeenCalledWith('PC/1', photo) expect(create).toHaveBeenCalledWith(image) const preview = wrapper.findComponent({ name: 'ElImage' }) expect(preview.props('src')).toBe('blob:admin-loading-photo') expect(preview.props('previewSrcList')).toEqual(['blob:admin-loading-photo']) expect(wrapper.find('input[type="file"]').exists()).toBe(false) } finally { wrapper.unmount(); vi.stubGlobal('URL', originalURL) } expect(revoke.mock.calls[0]?.[0]).toBe('blob:admin-loading-photo') })