// @vitest-environment jsdom import { cleanup, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { act } from 'react'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { createResourceCanvasAssetGenerationTask, type ResourceCanvasAssetGenerationTask, } from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; import { RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT, RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS, ResourceCanvasAssetGenerationTasksPanelView, } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; afterEach(() => { cleanup(); }); const uiPrototypeAction: ResourceCanvasAssetToolAction = { id: 'generate-ui-prototype', route: 'asset', label: '生成 UI 设计图', assetKind: 'ui-prototype', audioKind: null, assetName: 'AI 生成 UI 设计图', promptPlaceholder: '描述这张界面要承载的玩法与操作', adjustableDimensions: true, aspectRatio: '16:9', imageSize: '1K', requiresIconSpecReference: true, writesIconSpecReference: false, }; function task( patch: Partial & { taskId: string }, ): ResourceCanvasAssetGenerationTask { return { ...createResourceCanvasAssetGenerationTask({ taskId: patch.taskId, action: uiPrototypeAction, prompt: '主界面与背包页', assetName: '主界面设计图', aspectRatio: '16:9', imageSize: '1K', outputPath: null, projectId: 'project-1', nowMillis: 1_000, }), ...patch, }; } function renderSidebar( tasks: readonly ResourceCanvasAssetGenerationTask[], options: { open?: boolean } = {}, ) { const onToggleOpen = vi.fn(); const onFocusTask = vi.fn(); const view = render( , ); return { onToggleOpen, onFocusTask, ...view }; } describe('「生成任务」侧栏', () => { test('展开时是非模态侧栏:没有全屏遮罩,也没有 aria-modal 与焦点陷阱', () => { const { container } = renderSidebar([task({ taskId: 't1' })]); expect(screen.getByRole('region', { name: '生成任务' })).not.toBeNull(); expect(container.querySelector('[aria-modal="true"]')).toBeNull(); expect(container.querySelector('.fixed.inset-0')).toBeNull(); }); test('两个分栏各带条数,状态 / 阶段 / 已耗时 / 素材名逐条呈现', () => { renderSidebar([ task({ taskId: 't1', assetName: '主界面设计图' }), task({ taskId: 't2', assetName: '背包界面设计图', dispatched: true, status: 'running', // 前端的任何常量都不会产出「正在处理。」,出现它只可能来自后端记录。 phaseDetail: '正在处理。', startedAtMillis: 2_000, }), task({ taskId: 't3', assetName: '结算界面设计图', dispatched: true, status: 'completed', phaseDetail: '生成已完成。', assetId: 'asset-3', finishedAtMillis: 13_000, }), task({ taskId: 't4', assetName: '设置界面设计图', dispatched: true, status: 'failed', phaseDetail: '生成失败:远端拒绝', error: '远端拒绝', }), ]); const activeSection = screen.getByRole('region', { name: '排队与生成中' }); expect(within(activeSection).getByText('2')).not.toBeNull(); expect(within(activeSection).getByText('主界面设计图')).not.toBeNull(); expect(within(activeSection).getByText('排队中')).not.toBeNull(); expect(within(activeSection).getByText('正在处理。')).not.toBeNull(); expect(within(activeSection).getByText('生成中')).not.toBeNull(); // 头部标题里的在途计数:四条任务里两条未终态,徽标必须跟着走。 expect(screen.getByLabelText('在途生成任务 2').textContent).toBe('2'); const doneSection = screen.getByRole('region', { name: '已完成' }); expect(within(doneSection).getByText('2')).not.toBeNull(); expect(within(doneSection).getByText('生成已完成。')).not.toBeNull(); expect(within(doneSection).getByRole('alert').textContent).toBe('远端拒绝'); expect( screen.getAllByText(/^已耗时 \d+ (秒|分 \d+ 秒)$/).length, ).toBeGreaterThan(0); }); test('收起只有一个入口:头部那枚关闭按钮,收起即整块让出画布', async () => { const user = userEvent.setup(); const { onToggleOpen } = renderSidebar([task({ taskId: 't1' })]); // 底部那枚重复的「关闭」连同它的分割线已经删掉:展开态只有一个关闭按钮。 expect( screen.getAllByRole('button', { name: '关闭生成任务' }), ).toHaveLength(1); expect( document.querySelector('.game-resource-generation-tasks-sidebar-footer'), ).toBeNull(); await user.click(screen.getByRole('button', { name: '关闭生成任务' })); expect(onToggleOpen).toHaveBeenCalledTimes(1); }); test('侧栏高度有界、列表自己滚动,「已完成」条数封顶', () => { const doneTasks = Array.from( { length: RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT + 5 }, (_, index) => task({ taskId: `done-${index}`, assetName: `历史设计图 ${index}`, dispatched: true, status: 'completed', phaseDetail: '生成已完成。', assetId: `asset-${index}`, finishedAtMillis: 2_000, }), ); const { container } = renderSidebar(doneTasks); const aside = container.querySelector('aside') as HTMLElement; // 高度有界与「列表自己滚动」在样式声明里钉(见 SidebarStyle 用例);这里只确认 // 容器与滚动区都挂上了语义类,样式不会落空。 expect(aside.className).toContain('game-resource-generation-tasks-sidebar'); expect( container.querySelector('[data-resource-generation-task-scroll]') ?.className, ).toContain('game-resource-generation-tasks-scroll'); expect( within(screen.getByRole('region', { name: '已完成' })).getAllByRole( 'listitem', ), ).toHaveLength(RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT); expect( screen.getByText( `仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 5 条较早记录`, ), ).not.toBeNull(); }); test('折叠后不再渲染,画布上不留任何常驻入口或把手', async () => { renderSidebar([], { open: false }); expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(); expect(screen.queryByRole('button')).toBeNull(); expect( document.querySelector('.game-resource-generation-tasks-handle'), ).toBeNull(); expect( document.querySelector('.game-resource-generation-tasks-sidebar'), ).toBeNull(); }); test('收起时先播完收起动画再卸载,动画期间列表内容不跳', () => { vi.useFakeTimers(); try { const runningTask = task({ taskId: 't1', assetName: '主界面设计图' }); const { rerender } = render( , ); expect(screen.queryByRole('region', { name: '生成任务' })).not.toBeNull(); // 收起的同时把任务收口成已完成:退出动画必须沿用收起前那一份列表,内容不能跳。 rerender( , ); const leaving = document.querySelector( '.game-resource-generation-tasks-sidebar', ); expect(leaving).not.toBeNull(); expect(leaving?.className).toContain('is-leaving'); expect(within(leaving!).getByText('主界面设计图')).not.toBeNull(); // 动画时长走完就卸载,画布上不留东西。 act(() => { vi.advanceTimersByTime( RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS, ); }); expect( document.querySelector('.game-resource-generation-tasks-sidebar'), ).toBeNull(); } finally { vi.useRealTimers(); } }); test('只有已完成且拿到资源 id 的任务能定位到素材卡', async () => { const user = userEvent.setup(); const { onFocusTask } = renderSidebar([ task({ taskId: 't1', assetName: '主界面设计图', dispatched: true, status: 'completed', phaseDetail: '生成已完成。', assetId: 'asset-1', finishedAtMillis: 5_000, }), task({ taskId: 't2', assetName: '背包界面设计图', dispatched: true, status: 'running', phaseDetail: '正在生成。', }), ]); expect( screen.queryByRole('button', { name: '定位素材 背包界面设计图' }), ).toBeNull(); await user.click( screen.getByRole('button', { name: '定位素材 主界面设计图' }), ); expect(onFocusTask).toHaveBeenCalledTimes(1); expect(onFocusTask.mock.calls[0]?.[0]).toMatchObject({ taskId: 't1', assetId: 'asset-1', }); }); test('没有任务时给出空状态', () => { renderSidebar([]); expect(screen.getByRole('status').textContent).toBe('还没有生成任务'); }); test('四种状态各自输出对应的 tone,样式按档落地', () => { renderSidebar([ task({ taskId: 't-queued' }), task({ taskId: 't-running', dispatched: true, status: 'running', phaseDetail: '正在生成。', }), task({ taskId: 't-completed', dispatched: true, status: 'completed', phaseDetail: '生成已完成。', assetId: 'asset-tone', finishedAtMillis: 3_000, }), task({ taskId: 't-failed', dispatched: true, status: 'failed', phaseDetail: '生成失败:远端拒绝', error: '远端拒绝', }), ]); const tones = ['queued', 'running', 'completed', 'failed'] as const; for (const tone of tones) { expect( document.querySelector( `.game-resource-generation-task-badge[data-tone='${tone}']`, ), ).not.toBeNull(); } // 每条任务一个徽标:tone 不重复、不漏档。 expect( document.querySelectorAll('.game-resource-generation-task-badge'), ).toHaveLength(4); }); });