import { flushPromises, mount } from '@vue/test-utils'; import { ElMessage, ElMessageBox } from 'element-plus'; import { afterEach, describe, expect, it, vi } from 'vitest'; import DocumentTable from './DocumentTable.vue'; const sseMocks = vi.hoisted(() => ({ abort: vi.fn(), post: vi.fn().mockResolvedValue(undefined), })); const tableState = vi.hoisted(() => ({ rows: [] as any[] })); vi.mock('element-plus', async (importOriginal) => { const actual = await importOriginal(); const { defineComponent, h } = await import('vue'); const ElTable = defineComponent({ name: 'ElTable', props: ['data'], emits: ['selection-change'], setup(props, { expose, slots }) { expose({ clearSelection: vi.fn(), toggleRowSelection: vi.fn(), }); return () => { tableState.rows = props.data || []; return h('div', { class: 'table-stub' }, slots.default?.()); }; }, }); const ElTableColumn = defineComponent({ name: 'ElTableColumn', props: ['label', 'reserveSelection', 'selectable', 'type'], setup(_props, { slots }) { return () => h( 'div', { class: 'table-column-stub' }, tableState.rows.map((row) => slots.default?.({ row })), ); }, }); return { ...actual, ElTable, ElTableColumn }; }); vi.mock('#/api/request', () => ({ api: {}, SseClient: class { abort = sseMocks.abort; post = sseMocks.post; }, })); vi.mock('@easyflow/locales', () => ({ $t: (key: string, params?: Record) => params ? `${key}:${JSON.stringify(params)}` : key, })); const rows = [ { id: '1', processStatus: 'COMPLETED', title: '一.docx' }, { id: '2', processStatus: 'PARSING', title: '处理中.docx' }, { id: '3', processStatus: 'COMPLETED', title: '三.pdf' }, { id: '4', processStatus: 'PARSE_FAILED', title: '四.xlsx' }, { id: '5', processStatus: 'COMPLETED', title: '五.docx' }, { id: '6', processStatus: 'COMPLETED', title: '六.pdf' }, { id: '7', processStatus: 'COMPLETED', title: '七.xlsx' }, ]; afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); }); describe('documentTable', () => { it('处理中行不可选,批量删除使用最多三个并发并汇总部分失败', async () => { let active = 0; let maxActive = 0; const post = vi.fn().mockImplementation(async (_url: string, body: any) => { active += 1; maxActive = Math.max(maxActive, active); await new Promise((resolve) => setTimeout(resolve, 5)); active -= 1; return body.id === '4' || body.id === '6' ? { data: false, errorCode: 0, message: '失败' } : { data: true, errorCode: 0 }; }); const get = vi.fn().mockResolvedValue({ data: { records: rows, totalRow: rows.length }, }); vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never); const wrapper = mount(DocumentTable, { global: { directives: { loading: {} } }, props: { knowledgeId: '10', requestClient: { download: vi.fn(), get, post }, }, }); await flushPromises(); const warning = vi.spyOn(ElMessage, 'warning'); const table = wrapper.findComponent({ name: 'ElTable' }); const selectionColumn = table .findAllComponents({ name: 'ElTableColumn' }) .find((column) => column.props('type') === 'selection'); expect(selectionColumn?.props('selectable')(rows[0])).toBe(true); expect(selectionColumn?.props('selectable')(rows[1])).toBe(false); expect(selectionColumn?.props('reserveSelection')).toBe(true); table.vm.$emit('selection-change', [ rows[0], rows[2], rows[3], rows[4], rows[5], rows[6], ]); await wrapper.vm.$nextTick(); const deleteButton = wrapper .findAll('.batch-toolbar button') .find((button) => button.text().includes('documentCollection.batchDelete'), ); if (!deleteButton) throw new Error('未找到批量删除按钮'); await deleteButton.trigger('click'); await new Promise((resolve) => setTimeout(resolve, 30)); await flushPromises(); expect(post).toHaveBeenCalledTimes(6); expect(maxActive).toBe(3); expect(maxActive).toBeLessThanOrEqual(3); expect(warning).toHaveBeenCalledWith( 'documentCollection.batchDeletePartial:{"failed":2,"success":4}', ); expect(wrapper.find('.batch-toolbar').text()).toContain( 'documentCollection.selectedCount:{"count":2}', ); wrapper.unmount(); }); it('批量删除确认期间阻止重复确认和重复请求', async () => { let resolveConfirm!: (value: unknown) => void; const confirm = vi.spyOn(ElMessageBox, 'confirm').mockReturnValue( new Promise((resolve) => { resolveConfirm = resolve; }) as never, ); const post = vi.fn().mockResolvedValue({ data: true, errorCode: 0 }); const get = vi.fn().mockResolvedValue({ data: { records: rows, totalRow: rows.length }, }); const wrapper = mount(DocumentTable, { global: { directives: { loading: {} } }, props: { knowledgeId: '10', requestClient: { download: vi.fn(), get, post }, }, }); await flushPromises(); wrapper .findComponent({ name: 'ElTable' }) .vm.$emit('selection-change', [rows[0], rows[2]]); await wrapper.vm.$nextTick(); const deleteButton = wrapper .findAll('.batch-toolbar button') .find((button) => button.text().includes('documentCollection.batchDelete'), ); if (!deleteButton) throw new Error('未找到批量删除按钮'); await deleteButton.trigger('click'); await deleteButton.trigger('click'); expect(confirm).toHaveBeenCalledTimes(1); expect(post).not.toHaveBeenCalled(); resolveConfirm('confirm'); await flushPromises(); expect(post).toHaveBeenCalledTimes(2); wrapper.unmount(); }); it('搜索和显式重载会清空当前页选择', async () => { const get = vi.fn().mockResolvedValue({ data: { records: rows, totalRow: rows.length }, }); const wrapper = mount(DocumentTable, { global: { directives: { loading: {} } }, props: { knowledgeId: '10', requestClient: { download: vi.fn(), get, post: vi.fn() }, }, }); await flushPromises(); const table = wrapper.findComponent({ name: 'ElTable' }); table.vm.$emit('selection-change', [rows[0]]); await wrapper.vm.$nextTick(); expect(wrapper.find('.batch-toolbar').exists()).toBe(true); wrapper.vm.search('关键字'); await wrapper.vm.$nextTick(); expect(wrapper.find('.batch-toolbar').exists()).toBe(false); table.vm.$emit('selection-change', [rows[2]]); await wrapper.vm.$nextTick(); wrapper.vm.reload(); await wrapper.vm.$nextTick(); expect(wrapper.find('.batch-toolbar').exists()).toBe(false); wrapper.unmount(); }); it('后台状态更新只移除转为处理中的已选文档', async () => { const get = vi.fn().mockResolvedValue({ data: { records: rows, totalRow: rows.length }, }); const wrapper = mount(DocumentTable, { global: { directives: { loading: {} } }, props: { knowledgeId: '10', requestClient: { download: vi.fn(), get, post: vi.fn() }, }, }); await flushPromises(); const table = wrapper.findComponent({ name: 'ElTable' }); table.vm.$emit('selection-change', [rows[0], rows[2]]); await wrapper.vm.$nextTick(); const streamOptions = sseMocks.post.mock.calls.at(-1)?.[2]; streamOptions?.onMessage?.({ data: JSON.stringify({ documentId: String(rows[1]?.id || ''), knowledgeId: '10', processStatus: 'PARSING', type: 'document-status', }), event: 'document-status', }); await wrapper.vm.$nextTick(); expect(wrapper.find('.batch-toolbar').text()).toContain( 'documentCollection.selectedCount:{"count":2}', ); streamOptions?.onMessage?.({ data: JSON.stringify({ documentId: String(rows[2]?.id || ''), knowledgeId: '10', processStatus: 'INDEXING', type: 'document-status', }), event: 'document-status', }); await wrapper.vm.$nextTick(); expect(wrapper.find('.batch-toolbar').text()).toContain( 'documentCollection.selectedCount:{"count":1}', ); wrapper.unmount(); }); it('后台静默重载按 ID 保留当前页有效选择', async () => { const get = vi.fn().mockResolvedValue({ data: { records: rows, totalRow: rows.length }, }); const wrapper = mount(DocumentTable, { global: { directives: { loading: {} } }, props: { knowledgeId: '10', requestClient: { download: vi.fn(), get, post: vi.fn() }, }, }); await flushPromises(); const table = wrapper.findComponent({ name: 'ElTable' }); table.vm.$emit('selection-change', [rows[0]]); await wrapper.vm.$nextTick(); const pageData = wrapper.findComponent({ name: 'PageData' }); await (pageData.vm as any).reload({ silent: true }); await flushPromises(); expect(wrapper.find('.batch-toolbar').text()).toContain( 'documentCollection.selectedCount:{"count":1}', ); wrapper.unmount(); }); it('单条删除请求期间锁定行操作并阻止重复提交', async () => { let resolveDelete!: (value: unknown) => void; const post = vi.fn().mockReturnValue( new Promise((resolve) => { resolveDelete = resolve; }), ); const get = vi.fn().mockResolvedValue({ data: { records: rows, totalRow: rows.length }, }); const confirm = vi .spyOn(ElMessageBox, 'confirm') .mockResolvedValue('confirm' as never); const wrapper = mount(DocumentTable, { global: { directives: { loading: {} } }, props: { knowledgeId: '10', requestClient: { download: vi.fn(), get, post }, }, }); await flushPromises(); const deleteButton = wrapper.get('button[aria-label="button.delete"]'); await deleteButton.trigger('click'); await deleteButton.trigger('click'); await flushPromises(); expect(confirm).toHaveBeenCalledTimes(1); expect(post).toHaveBeenCalledTimes(1); expect( wrapper.get('button[aria-label="button.delete"]').attributes(), ).toHaveProperty('disabled'); resolveDelete({ data: true, errorCode: 0 }); await flushPromises(); wrapper.unmount(); }); it('只读权限不渲染选择列和批量入口', async () => { const get = vi.fn().mockResolvedValue({ data: { records: rows, totalRow: rows.length }, }); const wrapper = mount(DocumentTable, { global: { directives: { loading: {} } }, props: { knowledgeId: '10', permissions: { canDeleteContent: false }, requestClient: { download: vi.fn(), get, post: vi.fn() }, }, }); await flushPromises(); const columns = wrapper .findComponent({ name: 'ElTable' }) .findAllComponents({ name: 'ElTableColumn' }); expect(columns.some((column) => column.props('type') === 'selection')).toBe( false, ); expect(wrapper.find('.batch-toolbar').exists()).toBe(false); wrapper.unmount(); }); });