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
- 进场本来就有动画(`…-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` 选择器写错,对应断言分别变红
318 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|