// @vitest-environment jsdom import { act, fireEvent, render as testingLibraryRender, screen, waitFor, } from '@testing-library/react'; import type { ReactElement, ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; import { listExternalGenerationTasks } from '../../services/external-generation'; import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider.tsx'; import { ImageCanvasTaskSidebarView } from './ImageCanvasTaskSidebarView'; import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts'; const focusResourceMock = vi.fn(); const refreshCanvasMock = vi.fn(); function render(ui: ReactElement) { return testingLibraryRender(ui, { wrapper: ({ children }: { children: ReactNode }) => ( {children} ), }); } vi.mock('../../services/external-generation', () => ({ listExternalGenerationTasks: vi.fn().mockResolvedValue({ overview: { pendingCount: 0, runningCount: 0, unacknowledgedTerminalCount: 0, updatedAtMicros: 1, }, tasks: [], }), })); const listExternalGenerationTasksMock = vi.mocked(listExternalGenerationTasks); beforeEach(() => { useImageCanvasContextStore.getState().setProjectId('project-1'); focusResourceMock.mockReset(); refreshCanvasMock.mockReset(); listExternalGenerationTasksMock.mockClear(); listExternalGenerationTasksMock.mockResolvedValue({ overview: { pendingCount: 0, runningCount: 0, unacknowledgedTerminalCount: 0, updatedAtMicros: 1, }, tasks: [], }); }); function createExternalTask( overrides: Partial = {}, ): ExternalGenerationTaskRecord { const now = new Date().toISOString(); return { jobId: overrides.jobId ?? 'extgen-1', jobKind: overrides.jobKind ?? 'editor_image_generation', sourceModule: overrides.sourceModule ?? 'editor-canvas', sourceEntityId: overrides.sourceEntityId ?? 'project-1', requestLabel: overrides.requestLabel ?? '图片画布生成图片', requestPrompt: overrides.requestPrompt ?? '发光猫咪主视觉', status: overrides.status ?? 'running', phaseLabel: overrides.phaseLabel ?? '生成图片', phaseDetail: overrides.phaseDetail ?? '正在生成第 2/4 段。', progress: overrides.progress ?? 35, error: overrides.error ?? null, warning: overrides.warning ?? null, priceMudPoints: overrides.priceMudPoints ?? 1, refundLedgerId: overrides.refundLedgerId ?? null, notificationAcknowledgedAt: overrides.notificationAcknowledgedAt ?? null, createdAt: overrides.createdAt ?? now, startedAt: overrides.startedAt ?? now, completedAt: overrides.completedAt ?? null, updatedAt: overrides.updatedAt ?? now, updatedAtMicros: overrides.updatedAtMicros ?? 1, }; } function formatBackendTimestampMs(timestampMs: number) { const seconds = Math.floor(timestampMs / 1000); const micros = Math.max(0, Math.floor(timestampMs % 1000) * 1000); return `${seconds}.${String(micros).padStart(6, '0')}Z`; } describe('ImageCanvasTaskSidebarView', () => { it('groups tasks by tabs and keeps running tasks before queued tasks', async () => { const focusExternalTask = vi.fn(); const parentWheel = vi.fn(); const completedAt = new Date(Date.now() - 1000).toISOString(); listExternalGenerationTasksMock.mockImplementation( (options: Parameters[0] = {}) => Promise.resolve({ overview: { pendingCount: 1, runningCount: 1, unacknowledgedTerminalCount: 1, updatedAtMicros: 1, }, tasks: options.statuses?.includes('running') ? [ createExternalTask({ jobId: 'queued-task', requestLabel: '图标素材', status: 'queued', }), createExternalTask({ jobId: 'running-task', requestLabel: '角色图片', status: 'running', startedAt: new Date(Date.now() - 3000).toISOString(), }), ] : [ createExternalTask({ jobId: 'older-done-task', requestLabel: '较早完成', requestPrompt: '较早完成任务', status: 'completed', startedAt: new Date(Date.now() - 9000).toISOString(), completedAt: new Date(Date.now() - 8000).toISOString(), updatedAt: new Date(Date.now() - 500).toISOString(), }), createExternalTask({ jobId: 'done-task', requestLabel: '视频生成', requestPrompt: '生成一段开场动画', status: 'completed', startedAt: new Date(Date.now() - 4000).toISOString(), completedAt, updatedAt: completedAt, }), ], }), ); render(
, ); await waitFor(() => { expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(2); }); expect(listExternalGenerationTasksMock).toHaveBeenNthCalledWith( 1, expect.objectContaining({ includeAcknowledgedTerminal: false, limit: 100, statuses: ['running', 'queued'], }), ); expect(listExternalGenerationTasksMock).toHaveBeenNthCalledWith( 2, expect.objectContaining({ includeAcknowledgedTerminal: true, limit: 20, statuses: ['completed', 'failed'], }), ); const activeTitles = screen .getAllByRole('button') .map((button) => button.textContent ?? '') .filter((text) => text.includes('角色图片') || text.includes('图标素材')); expect(activeTitles[0]).toContain('角色图片'); expect(activeTitles[1]).toContain('图标素材'); expect(activeTitles[0]).toContain('已用时'); expect(activeTitles[1]).not.toContain('已用时'); const runningButton = screen.getByText('角色图片').closest('button'); expect( runningButton?.querySelector( '.image-canvas-editor__task-sidebar-item-meta', )?.textContent, ).toContain('已用时'); await waitFor(() => expect( screen .getByRole('tab', { name: /排队\/生成中/u }) .getAttribute('aria-selected'), ).toBe('true'), ); const completedTab = screen.getByRole('tab', { name: /已完成/u }); fireEvent.click(completedTab); expect(await screen.findByText('视频生成')).toBeTruthy(); const completedTitles = screen .getAllByRole('button') .map((button) => button.textContent ?? '') .filter((text) => text.includes('视频生成') || text.includes('较早完成')); expect(completedTitles[0]).toContain('视频生成'); expect(completedTitles[1]).toContain('较早完成'); expect(screen.getByText(/生成一段开场动画/u)).toBeTruthy(); expect(screen.queryAllByText(/完成于/u).length).toBeGreaterThan(0); expect(screen.queryByText(/生成完成/u)).toBeNull(); fireEvent.wheel(screen.getByLabelText('画布任务列表'), { deltaY: 120 }); expect(parentWheel).not.toHaveBeenCalled(); fireEvent.click(screen.getByText('视频生成')); expect(focusExternalTask).toHaveBeenCalledWith( expect.objectContaining({ jobId: 'done-task' }), ); expect(completedTab.getAttribute('aria-selected')).toBe('true'); }); it('shows only current project external tasks with backend timing and phase detail', async () => { const nowMs = Date.now(); const startedAt = formatBackendTimestampMs(nowMs - 65_000); const completedAt = formatBackendTimestampMs(nowMs - 20_000); listExternalGenerationTasksMock.mockImplementation( (options: Parameters[0] = {}) => Promise.resolve({ overview: { pendingCount: 0, runningCount: 1, unacknowledgedTerminalCount: 1, updatedAtMicros: 1, }, tasks: options.statuses?.includes('running') ? [ createExternalTask({ jobId: 'active-current', startedAt, phaseDetail: '正在处理。', progress: 70, }), createExternalTask({ jobId: 'active-other-project', sourceEntityId: 'project-other', requestPrompt: '不该显示的外部项目任务', }), ] : [ createExternalTask({ jobId: 'done-current', status: 'completed', requestPrompt: '已完成提示词', startedAt: formatBackendTimestampMs(nowMs - 90_000), completedAt, updatedAt: completedAt, progress: 100, phaseDetail: '生成已完成。', warning: '连通域数量不足', }), createExternalTask({ jobId: 'done-other-project', sourceEntityId: 'project-other', status: 'completed', requestPrompt: '另一个项目的已完成任务', completedAt, }), ], }), ); render( , ); expect(await screen.findByText('图片画布生成图片')).toBeTruthy(); expect(screen.getByText(/发光猫咪主视觉/u)).toBeTruthy(); expect(screen.getByText('正在处理。')).toBeTruthy(); expect(screen.queryByText(/\d{1,3}%/u)).toBeNull(); expect(screen.getByText(/已用时 1分/u)).toBeTruthy(); expect(screen.queryByText(/总进度/u)).toBeNull(); expect(screen.queryByText(/不该显示的外部项目任务/u)).toBeNull(); await waitFor(() => expect( screen .getByRole('tab', { name: /排队\/生成中/u }) .getAttribute('aria-selected'), ).toBe('true'), ); fireEvent.click(screen.getByRole('tab', { name: /已完成/u })); expect(await screen.findByText(/已完成提示词/u)).toBeTruthy(); expect(screen.getByText('连通域数量不足')).toBeTruthy(); expect(screen.getByText(/完成于/u)).toBeTruthy(); expect(screen.queryByText(/另一个项目的已完成任务/u)).toBeNull(); }); it('keeps the empty active tab selected after manual tab click', async () => { const completedAt = new Date(Date.now() - 20_000).toISOString(); listExternalGenerationTasksMock.mockImplementation( (options: Parameters[0] = {}) => Promise.resolve({ overview: { pendingCount: 0, runningCount: 0, unacknowledgedTerminalCount: 1, updatedAtMicros: 1, }, tasks: options.statuses?.includes('running') ? [] : [ createExternalTask({ jobId: 'done-current', status: 'completed', requestPrompt: '已完成提示词', completedAt, updatedAt: completedAt, }), ], }), ); render( , ); await screen.findByText('暂无任务'); const activeTab = screen.getByRole('tab', { name: /排队\/生成中/u }); const completedTab = screen.getByRole('tab', { name: /已完成/u }); fireEvent.click(completedTab); expect(await screen.findByText(/已完成提示词/u)).toBeTruthy(); fireEvent.click(activeTab); expect(activeTab.getAttribute('aria-selected')).toBe('true'); expect(screen.getByText('暂无任务')).toBeTruthy(); expect(screen.queryByText(/已完成提示词/u)).toBeNull(); }); it('refreshes external tasks when queued generation bumps the refresh key', async () => { let activeTasks: ExternalGenerationTaskRecord[] = []; listExternalGenerationTasksMock.mockImplementation( (options: Parameters[0] = {}) => Promise.resolve({ overview: { pendingCount: activeTasks.length, runningCount: 0, unacknowledgedTerminalCount: 0, updatedAtMicros: 1, }, tasks: options.statuses?.includes('running') ? activeTasks : [], }), ); const { rerender } = render( , ); await waitFor(() => { expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(2); }); expect(screen.getByText('暂无任务')).toBeTruthy(); activeTasks = [ createExternalTask({ jobId: 'queued-after-submit', requestLabel: '角色生成', status: 'queued', }), ]; rerender( , ); expect(await screen.findByText('角色生成')).toBeTruthy(); expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(4); }); it('notifies when a restored active external task completes during polling', async () => { vi.useFakeTimers(); try { const onExternalTasksCompleted = vi.fn(); let activeRequestCount = 0; const runningTask = createExternalTask({ jobId: 'restored-character-task', requestLabel: '角色图片', status: 'running', }); const completedTask = createExternalTask({ ...runningTask, status: 'completed', progress: 100, phaseDetail: '生成已完成。', completedAt: new Date().toISOString(), }); listExternalGenerationTasksMock.mockImplementation( (options: Parameters[0] = {}) => { if (options.statuses?.includes('running')) { activeRequestCount += 1; return Promise.resolve({ overview: { pendingCount: activeRequestCount === 1 ? 1 : 0, runningCount: activeRequestCount === 1 ? 1 : 0, unacknowledgedTerminalCount: activeRequestCount === 1 ? 0 : 1, updatedAtMicros: activeRequestCount, }, tasks: activeRequestCount === 1 ? [runningTask] : [], }); } return Promise.resolve({ overview: { pendingCount: 0, runningCount: 0, unacknowledgedTerminalCount: activeRequestCount > 1 ? 1 : 0, updatedAtMicros: activeRequestCount, }, tasks: activeRequestCount > 1 ? [completedTask] : [], }); }, ); render( , ); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(screen.getByText('角色图片')).toBeTruthy(); await act(async () => { await vi.advanceTimersByTimeAsync(4000); await Promise.resolve(); }); expect(onExternalTasksCompleted).toHaveBeenCalledWith([ expect.objectContaining({ jobId: 'restored-character-task', status: 'completed', }), ]); expect(onExternalTasksCompleted).toHaveBeenCalledTimes(1); expect(refreshCanvasMock).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } }); it('refreshes the wallet while an external task is running and after a failed refund settles', async () => { vi.useFakeTimers(); try { const onExternalTasksCompleted = vi.fn(); const onExternalTaskWalletMayHaveChanged = vi.fn(); let activeRequestCount = 0; const runningTask = createExternalTask({ jobId: 'wallet-running-task', requestLabel: '长耗时图片生成', status: 'running', priceMudPoints: 20, }); const failedTask = createExternalTask({ ...runningTask, status: 'failed', progress: 0, phaseDetail: '生成失败。', error: '生成失败,泥点已退回。', completedAt: new Date().toISOString(), }); listExternalGenerationTasksMock.mockImplementation( (options: Parameters[0] = {}) => { if (options.statuses?.includes('running')) { activeRequestCount += 1; return Promise.resolve({ overview: { pendingCount: 0, runningCount: activeRequestCount === 1 ? 1 : 0, unacknowledgedTerminalCount: activeRequestCount === 1 ? 0 : 1, updatedAtMicros: activeRequestCount, }, tasks: activeRequestCount === 1 ? [runningTask] : [], }); } return Promise.resolve({ overview: { pendingCount: 0, runningCount: 0, unacknowledgedTerminalCount: activeRequestCount > 1 ? 1 : 0, updatedAtMicros: activeRequestCount, }, tasks: activeRequestCount > 1 ? [failedTask] : [], }); }, ); render( , ); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1); await act(async () => { await vi.advanceTimersByTimeAsync(4000); await Promise.resolve(); }); expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2); expect(onExternalTasksCompleted).not.toHaveBeenCalled(); expect(refreshCanvasMock).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); it('keeps wallet polling global when the active external task belongs to another project', async () => { vi.useFakeTimers(); try { const onExternalTaskWalletMayHaveChanged = vi.fn(); let activeRequestCount = 0; const otherProjectTask = createExternalTask({ jobId: 'other-project-wallet-task', sourceEntityId: 'project-other', status: 'running', }); listExternalGenerationTasksMock.mockImplementation( (options: Parameters[0] = {}) => { if (options.statuses?.includes('running')) { activeRequestCount += 1; return Promise.resolve({ overview: { pendingCount: 0, runningCount: activeRequestCount === 1 ? 1 : 0, unacknowledgedTerminalCount: 0, updatedAtMicros: activeRequestCount, }, tasks: activeRequestCount === 1 ? [otherProjectTask] : [], }); } return Promise.resolve({ overview: { pendingCount: 0, runningCount: 0, unacknowledgedTerminalCount: 0, updatedAtMicros: activeRequestCount, }, tasks: [], }); }, ); render( , ); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(screen.queryByText('发光猫咪主视觉')).toBeNull(); expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1); await act(async () => { await vi.advanceTimersByTimeAsync(4000); await Promise.resolve(); }); expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2); } finally { vi.useRealTimers(); } }); it('retries a failed bootstrap and starts wallet polling from the recovered active task', async () => { vi.useFakeTimers(); try { const onExternalTaskWalletMayHaveChanged = vi.fn(); const runningTask = createExternalTask({ jobId: 'bootstrap-recovered-task', status: 'running', }); let activeRequestCount = 0; listExternalGenerationTasksMock.mockImplementation( ( options: Parameters[0] = {}, ): ReturnType => { if (options.statuses?.includes('running')) { activeRequestCount += 1; if (activeRequestCount === 1) { return Promise.reject(new Error('temporary bootstrap failure')); } return Promise.resolve({ overview: { pendingCount: 0, runningCount: 1, unacknowledgedTerminalCount: 0, updatedAtMicros: activeRequestCount, }, tasks: [runningTask], }); } return Promise.resolve({ overview: { pendingCount: 0, runningCount: 0, unacknowledgedTerminalCount: 0, updatedAtMicros: activeRequestCount, }, tasks: [], }); }, ); render( , ); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(onExternalTaskWalletMayHaveChanged).not.toHaveBeenCalled(); await act(async () => { await vi.advanceTimersByTimeAsync(1000); await Promise.resolve(); }); expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1); await act(async () => { await vi.advanceTimersByTimeAsync(4000); await Promise.resolve(); }); expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2); expect(activeRequestCount).toBe(3); } finally { vi.useRealTimers(); } }); it('keeps a successful active task and wallet polling when only completed tasks fail', async () => { vi.useFakeTimers(); try { const onExternalTaskWalletMayHaveChanged = vi.fn(); const runningTask = createExternalTask({ jobId: 'active-while-completed-fails', requestLabel: '仍在生成的任务', status: 'running', }); listExternalGenerationTasksMock.mockImplementation( (options: Parameters[0] = {}) => { if (options.statuses?.includes('running')) { return Promise.resolve({ overview: { pendingCount: 0, runningCount: 1, unacknowledgedTerminalCount: 0, updatedAtMicros: 1, }, tasks: [runningTask], }); } return Promise.reject(new Error('completed tasks unavailable')); }, ); render( , ); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(screen.getByText('仍在生成的任务')).toBeTruthy(); expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalled(); await act(async () => { await vi.advanceTimersByTimeAsync(60_000); await Promise.resolve(); }); expect(screen.getByText('仍在生成的任务')).toBeTruthy(); expect( onExternalTaskWalletMayHaveChanged.mock.calls.length, ).toBeGreaterThan(1); } finally { vi.useRealTimers(); } }); it('stops bootstrap retries after the bounded retry schedule is exhausted', async () => { vi.useFakeTimers(); try { listExternalGenerationTasksMock.mockRejectedValue( new Error('task list unavailable'), ); render( , ); await act(async () => { await vi.advanceTimersByTimeAsync(60_000); await Promise.resolve(); }); expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(8); } finally { vi.useRealTimers(); } }); it('clears stale tasks after a refreshed bootstrap exhausts its retries', async () => { vi.useFakeTimers(); try { const staleTask = createExternalTask({ jobId: 'stale-running-task', requestLabel: '旧生成任务', status: 'running', }); listExternalGenerationTasksMock.mockImplementation( (options: Parameters[0] = {}) => Promise.resolve({ overview: { pendingCount: 0, runningCount: options.statuses?.includes('running') ? 1 : 0, unacknowledgedTerminalCount: 0, updatedAtMicros: 1, }, tasks: options.statuses?.includes('running') ? [staleTask] : [], }), ); const { rerender } = render( , ); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(screen.getByText('旧生成任务')).toBeTruthy(); listExternalGenerationTasksMock.mockRejectedValue( new Error('task list unavailable'), ); rerender( , ); await act(async () => { await vi.advanceTimersByTimeAsync(60_000); await Promise.resolve(); }); expect(screen.queryByText('旧生成任务')).toBeNull(); expect(screen.getByText('暂无任务')).toBeTruthy(); } finally { vi.useRealTimers(); } }); it('aborts every bootstrap attempt when one branch fails and after retry unmounts', async () => { vi.useFakeTimers(); try { const requestSignals: AbortSignal[] = []; let activeRequestCount = 0; listExternalGenerationTasksMock.mockImplementation( (options: Parameters[0] = {}) => { const signal = options.signal; if (!signal) { return Promise.reject(new Error('missing abort signal')); } requestSignals.push(signal); if (options.statuses?.includes('running')) { activeRequestCount += 1; if (activeRequestCount === 1) { return Promise.reject(new Error('active bootstrap failed')); } } return new Promise< Awaited> >((_, reject) => { signal.addEventListener( 'abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true }, ); }); }, ); const { unmount } = render( , ); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(requestSignals).toHaveLength(2); expect(requestSignals[0]).toBe(requestSignals[1]); expect(requestSignals[0]!.aborted).toBe(true); await act(async () => { await vi.advanceTimersByTimeAsync(1000); await Promise.resolve(); }); expect(requestSignals).toHaveLength(4); expect(requestSignals[2]).toBe(requestSignals[3]); expect(requestSignals[2]!.aborted).toBe(false); unmount(); expect(requestSignals[2]!.aborted).toBe(true); } finally { vi.useRealTimers(); } }); it('loads more completed tasks while scrolling and keeps the request capped', async () => { const completedTasks = Array.from({ length: 120 }, (_, index) => { const completedAt = new Date(Date.now() - index * 1000).toISOString(); return createExternalTask({ jobId: `done-${index + 1}`, requestLabel: `已完成任务 ${index + 1}`, status: 'completed', requestPrompt: `提示词 ${index + 1}`, completedAt, updatedAt: completedAt, }); }); listExternalGenerationTasksMock.mockImplementation( (options: Parameters[0] = {}) => Promise.resolve({ overview: { pendingCount: 0, runningCount: 0, unacknowledgedTerminalCount: completedTasks.length, updatedAtMicros: 1, }, tasks: options.statuses?.includes('running') ? [] : completedTasks.slice(0, options.limit ?? 20), }), ); render( , ); await waitFor(() => { expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(2); }); expect(listExternalGenerationTasksMock).toHaveBeenNthCalledWith( 2, expect.objectContaining({ includeAcknowledgedTerminal: true, limit: 20, statuses: ['completed', 'failed'], }), ); fireEvent.click(screen.getByRole('tab', { name: /已完成/u })); expect(await screen.findByText('已完成任务 20')).toBeTruthy(); expect(screen.queryByText('已完成任务 21')).toBeNull(); const list = document.querySelector( '.image-canvas-editor__task-sidebar-list', ); expect(list).not.toBeNull(); Object.defineProperty(list, 'scrollHeight', { configurable: true, value: 1000, }); Object.defineProperty(list, 'clientHeight', { configurable: true, value: 200, }); Object.defineProperty(list, 'scrollTop', { configurable: true, value: 800, }); fireEvent.scroll(list!); await waitFor(() => { expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(4); }); expect(listExternalGenerationTasksMock).toHaveBeenNthCalledWith( 4, expect.objectContaining({ includeAcknowledgedTerminal: true, limit: 40, statuses: ['completed', 'failed'], }), ); expect(await screen.findByText('已完成任务 40')).toBeTruthy(); }); });