import { flushPromises, mount } from '@vue/test-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import DocumentImportBatchStatus from './DocumentImportBatchStatus.vue'; const apiMocks = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), })); vi.mock('#/api/request', () => ({ api: apiMocks })); vi.mock('@easyflow/locales', () => ({ $t: (key: string) => key, })); function createBatch(status: 'COMPLETED' | 'RUNNING', completedCount: number) { return { batchId: 'batch-1', completedCount, failedCount: 0, importMode: 'AUTO', pendingCount: status === 'RUNNING' ? 1 : 0, processingCount: 0, progressPercent: status === 'COMPLETED' ? 100 : 50, skippedCount: 0, status, totalCount: 2, }; } describe('documentImportBatchStatus', () => { beforeEach(() => { localStorage.clear(); }); afterEach(() => { vi.clearAllMocks(); vi.useRealTimers(); }); it('页面首次进入时不恢复已完成批次', async () => { apiMocks.get.mockResolvedValue({ data: createBatch('COMPLETED', 2), errorCode: 0, }); const wrapper = mount(DocumentImportBatchStatus, { props: { knowledgeId: 'knowledge-1' }, }); await flushPromises(); expect(wrapper.find('.batch-status').exists()).toBe(false); wrapper.unmount(); }); it('当前页面跟踪的批次完成后移除提示', async () => { vi.useFakeTimers(); apiMocks.get .mockResolvedValueOnce({ data: createBatch('RUNNING', 1), errorCode: 0, }) .mockResolvedValueOnce({ data: createBatch('COMPLETED', 2), errorCode: 0, }); const wrapper = mount(DocumentImportBatchStatus, { props: { knowledgeId: 'knowledge-1' }, }); await flushPromises(); expect(wrapper.find('.batch-status').exists()).toBe(true); await vi.advanceTimersByTimeAsync(3000); await flushPromises(); expect(wrapper.find('.batch-status').exists()).toBe(false); wrapper.unmount(); }); it('关闭当前批次提示后停止轮询且不影响导入接口', async () => { vi.useFakeTimers(); apiMocks.get.mockResolvedValue({ data: createBatch('RUNNING', 1), errorCode: 0, }); const wrapper = mount(DocumentImportBatchStatus, { props: { knowledgeId: 'knowledge-1' }, }); await flushPromises(); const closeButton = wrapper.find('.batch-status__close'); expect(closeButton.attributes('aria-label')).toBe( 'documentCollection.importDoc.dismissBatchStatus', ); await closeButton.trigger('click'); expect(wrapper.find('.batch-status').exists()).toBe(false); await vi.advanceTimersByTimeAsync(30_000); expect(apiMocks.get).toHaveBeenCalledTimes(1); expect(apiMocks.post).not.toHaveBeenCalled(); wrapper.unmount(); }); it('当前批次关闭状态在本地保留且新批次仍会展示', async () => { apiMocks.get .mockResolvedValueOnce({ data: createBatch('RUNNING', 1), errorCode: 0, }) .mockResolvedValueOnce({ data: createBatch('RUNNING', 1), errorCode: 0, }) .mockResolvedValueOnce({ data: { ...createBatch('RUNNING', 0), batchId: 'batch-2' }, errorCode: 0, }); const firstWrapper = mount(DocumentImportBatchStatus, { props: { knowledgeId: 'knowledge-1' }, }); await flushPromises(); await firstWrapper.find('.batch-status__close').trigger('click'); firstWrapper.unmount(); const secondWrapper = mount(DocumentImportBatchStatus, { props: { knowledgeId: 'knowledge-1' }, }); await flushPromises(); expect(secondWrapper.find('.batch-status').exists()).toBe(false); await secondWrapper.setProps({ refreshKey: 1 }); await flushPromises(); expect(secondWrapper.find('.batch-status').exists()).toBe(true); secondWrapper.unmount(); }); it('失败批次关联任务实际完成后不再展示提示', async () => { apiMocks.get.mockResolvedValue({ data: { ...createBatch('RUNNING', 0), actualCompleted: true, failedCount: 2, pendingCount: 0, status: 'PARTIAL_SUCCEEDED', }, errorCode: 0, }); const wrapper = mount(DocumentImportBatchStatus, { props: { knowledgeId: 'knowledge-1' }, }); await flushPromises(); expect(wrapper.find('.batch-status').exists()).toBe(false); wrapper.unmount(); }); it('失败批次低频复查后在关联任务完成时移除提示', async () => { vi.useFakeTimers(); const failedBatch = { ...createBatch('RUNNING', 0), actualCompleted: false, failedCount: 2, pendingCount: 0, status: 'PARTIAL_SUCCEEDED', }; apiMocks.get .mockResolvedValueOnce({ data: failedBatch, errorCode: 0, }) .mockResolvedValueOnce({ data: { ...failedBatch, actualCompleted: true }, errorCode: 0, }); const wrapper = mount(DocumentImportBatchStatus, { props: { knowledgeId: 'knowledge-1' }, }); await flushPromises(); expect(wrapper.find('.batch-status').exists()).toBe(true); await vi.advanceTimersByTimeAsync(15_000); await flushPromises(); expect(apiMocks.get).toHaveBeenCalledTimes(2); expect(wrapper.find('.batch-status').exists()).toBe(false); wrapper.unmount(); }); it('部分失败批次按 failedCount 继续并发送统一批次参数', async () => { const failedBatch = { batchId: 'batch-1', completedCount: 1, failedCount: 1, importMode: 'AUTO', pendingCount: 0, processingCount: 0, progressPercent: 100, retryableFailedCount: 0, skippedCount: 0, status: 'PARTIAL_SUCCEEDED', totalCount: 2, }; apiMocks.get.mockResolvedValue({ data: failedBatch, errorCode: 0, }); apiMocks.post.mockResolvedValue({ data: { ...failedBatch, failedCount: 0, pendingCount: 1, progressPercent: 50, status: 'RUNNING', }, errorCode: 0, }); const wrapper = mount(DocumentImportBatchStatus, { props: { knowledgeId: 'knowledge-1', manageable: true, }, }); await flushPromises(); const continueButton = wrapper.find('.batch-status__continue'); expect(continueButton.exists()).toBe(true); await continueButton.trigger('click'); await flushPromises(); expect(apiMocks.post).toHaveBeenCalledWith( '/api/v1/document/import/batch/continue', { batchId: 'batch-1', knowledgeId: 'knowledge-1', }, ); wrapper.unmount(); }); it('没有失败项时不使用旧兼容计数显示继续按钮', async () => { apiMocks.get.mockResolvedValue({ data: { ...createBatch('COMPLETED', 2), retryableFailedCount: 1, status: 'PARTIAL_SUCCEEDED', }, errorCode: 0, }); const wrapper = mount(DocumentImportBatchStatus, { props: { knowledgeId: 'knowledge-1', manageable: true, }, }); await flushPromises(); expect(wrapper.find('.batch-status__continue').exists()).toBe(false); wrapper.unmount(); }); it('中断批次展示异常原因、错误标识和继续入口', async () => { apiMocks.get.mockResolvedValue({ data: { ...createBatch('RUNNING', 0), failedCount: 2, interruptCode: 'redis_unavailable', interruptedAt: '2026-08-07T09:25:28+08:00', interruptMessage: '缓存与消息服务异常,自动导入已中断', pendingCount: 0, status: 'INTERRUPTED', }, errorCode: 0, }); const wrapper = mount(DocumentImportBatchStatus, { props: { knowledgeId: 'knowledge-1', manageable: true, }, }); await flushPromises(); const alert = wrapper.find('.batch-status__alert'); expect(alert.exists()).toBe(true); expect(alert.attributes('role')).toBe('alert'); expect(alert.text()).toContain('缓存与消息服务异常,自动导入已中断'); expect(alert.text()).toContain('redis_unavailable'); expect(wrapper.find('.batch-status__count').text()).toContain('2 / 2'); expect(wrapper.find('.batch-status__continue').exists()).toBe(true); wrapper.unmount(); }); it('状态接口暂时失败时展示错误并自动退避恢复轮询', async () => { vi.useFakeTimers(); apiMocks.get .mockRejectedValueOnce(new Error('network unavailable')) .mockResolvedValueOnce({ data: createBatch('RUNNING', 1), errorCode: 0, }); const wrapper = mount(DocumentImportBatchStatus, { props: { knowledgeId: 'knowledge-1' }, }); await flushPromises(); expect(wrapper.find('.batch-status--error').exists()).toBe(true); await vi.advanceTimersByTimeAsync(3000); await flushPromises(); expect(apiMocks.get).toHaveBeenCalledTimes(2); expect(wrapper.find('.batch-status--error').exists()).toBe(false); expect(wrapper.find('.batch-status').exists()).toBe(true); wrapper.unmount(); }); it('运行批次轮询失败时保留旧状态、展示错误并自动恢复', async () => { vi.useFakeTimers(); apiMocks.get .mockResolvedValueOnce({ data: createBatch('RUNNING', 1), errorCode: 0, }) .mockRejectedValueOnce(new Error('network unavailable')) .mockResolvedValueOnce({ data: createBatch('COMPLETED', 2), errorCode: 0, }); const wrapper = mount(DocumentImportBatchStatus, { props: { knowledgeId: 'knowledge-1' }, }); await flushPromises(); await vi.advanceTimersByTimeAsync(3000); await flushPromises(); expect(wrapper.find('.batch-status__summary').exists()).toBe(true); expect(wrapper.find('.batch-status__load-error').exists()).toBe(true); await vi.advanceTimersByTimeAsync(3000); await flushPromises(); expect(apiMocks.get).toHaveBeenCalledTimes(3); expect(wrapper.find('.batch-status__load-error').exists()).toBe(false); expect(wrapper.find('.batch-status').exists()).toBe(false); wrapper.unmount(); }); });