Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx
suzmii d0b15dbc54
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m52s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m58s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m59s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 3m2s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 1m40s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m2s
Project CI / Native shell tests (pull_request) Failing after 6m29s
Project CI / Repository checks (pull_request) Failing after 4m3s
Project CI / Backend tests (pull_request) Successful in 9m6s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m13s
Project CI / Frontend tests (pull_request) Has been cancelled
AGC 生成任务侧栏:收起也补上退场动画,退场期间内容不跳
- 进场本来就有动画(`…-enter`),收起却是瞬间卸载,观感上"闪一下没了"。现在收起先播退场再卸载:`open` 变 false 后进入 `leaving`,根节点挂 `is-leaving` 播 `…-leave`(与进场同向反向,160ms,`pointer-events: none`),播完才卸载
- 时长在组件与 CSS 两处各写一次,组件导出 `RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS` 并由用例钉住两处一致
- 减动效偏好下 CSS 已关掉动画,组件那边同步按 0ms 处理,收起立即卸载,不白等 160ms
- 退场期间沿用收起前那一份列表(`lastRenderedRef`):宿主会在同一帧里收起侧栏并把在途任务收口,直接吃新 props 会让退场动画里的内容跳一下;滚动区的空态分支也改读这份冻结快照,不能读实时 `ordered`(这是本批调试时用假时钟用例抓到的真问题)
- 用例:面板用例新增「收起时先播完收起动画再卸载,动画期间列表内容不跳」;样式用例新增「is-leaving 挂上收起动画、pointer-events: none、减动效同样关掉」;既有生成任务相关性用例改为等退场动画走完再断言(新增 `waitForTasksSidebarLeaveAnimationToFinish`)
- 文档:AGC 入口矩阵与 decision-log 同步退场动画口径

验证:tsc --noEmit 通过;appSurface 全量 441 条通过;侧栏面板 9 条、样式 10 条、开合与入口 3 条通过;变异验证:把早返回改回「立即卸载」与把 CSS 的 `is-leaving` 选择器写错,对应断言分别变红
2026-09-14 23:01:35 +08:00

318 lines
11 KiB
TypeScript

// @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<ResourceCanvasAssetGenerationTask> & { 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(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={tasks}
open={options.open ?? true}
onToggleOpen={onToggleOpen}
onFocusTask={onFocusTask}
/>,
);
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(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[runningTask]}
open
onToggleOpen={vi.fn()}
onFocusTask={vi.fn()}
/>,
);
expect(screen.queryByRole('region', { name: '生成任务' })).not.toBeNull();
// 收起的同时把任务收口成已完成:退出动画必须沿用收起前那一份列表,内容不能跳。
rerender(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[]}
open={false}
onToggleOpen={vi.fn()}
onFocusTask={vi.fn()}
/>,
);
const leaving = document.querySelector<HTMLElement>(
'.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);
});
});