Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx
T
suzmii 562e1ba586
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m57s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 4m2s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m15s
Project CI / Repository checks (pull_request) Failing after 20s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m16s
Project CI / Frontend tests (pull_request) Successful in 7m30s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m1s
Project CI / Native shell tests (pull_request) Successful in 11m1s
「生成任务」侧栏读秒改为 100ms 粒度:与对话侧共用同一份时钟
- ResourceCanvasAssetGenerationTasksPanelView:删掉面板自带的 1000ms interval 与 nowMillis 状态;两条任务行的「已耗时」下沉为叶子组件(AssetGenerationElapsedLabel / ResourceEditElapsedLabel),各自订阅共享 hook useLiveNow(LIVE_TIMER_TICK_MS = 100)。
- 为什么不是面板级时钟:面板上通常同时挂着多条任务,100ms 的 tick 落在面板上等于每 0.1 秒重建整个侧栏;useLiveNow 的注释明确要求调用方是叶子节点。终态行不订阅,已耗时定格在账本给的结束时间上。
- 行为:不足一分钟的耗时本来就显示到 0.1 秒(formatElapsedDuration),现在每 100ms 真的更新一次,不再是「带小数却 1 秒跳一格」。
- 用例:新增「读秒按 100ms 刷新(0.0 → 0.1 → 0.5 秒)」「终态行不再走表,定格在结束时间」「秒表只订在运行中的那一行上(setInterval 只被调用一次)」;把 LIVE_TIMER_TICK_MS 临时改回 1000 时第一条用例即红,粒度确实被钉住。
2026-09-21 12:48:21 +08:00

518 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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-design',
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;
placement?: 'canvas-overview' | 'canvas' | 'run' | 'editor';
} = {},
) {
const onToggleOpen = vi.fn();
const onFocusTask = vi.fn();
const view = render(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={tasks}
open={options.open ?? true}
onToggleOpen={onToggleOpen}
onFocusTask={onFocusTask}
placement={options.placement}
/>,
);
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秒|(?:\d+时)?\d+分\d{2}秒)$/)
.length,
).toBeGreaterThan(0);
});
/**
* 读秒粒度:与对话侧同一份 `useLiveNow``LIVE_TIMER_TICK_MS = 100`)。
*
* 不足一分钟的耗时显示到 0.1 秒,刷新就必须是 100ms——按 1 秒一跳时,用户看到的是一块
* 「带小数却一格一格跳」的表,像卡住不动(验收现场「生成任务里的读秒更新频率应为 0.1s」)。
*/
test('读秒按 100ms 刷新:不足一分钟的耗时以 0.1 秒递增', () => {
vi.useFakeTimers();
try {
// 假时钟与任务的创建时间对齐(夹具的 `nowMillis` 是 1000)。
vi.setSystemTime(1_000);
renderSidebar([
task({
taskId: 't1',
dispatched: true,
status: 'running',
phaseDetail: '正在生成。',
}),
]);
const elapsed = () => screen.getByText(/^已耗时 /u).textContent;
expect(elapsed()).toBe('已耗时 0.0秒');
act(() => {
vi.advanceTimersByTime(100);
});
expect(elapsed()).toBe('已耗时 0.1秒');
// 1 秒一跳的实现会停在上面的 0.1 秒:再走 400ms 必须继续往上加。
act(() => {
vi.advanceTimersByTime(400);
});
expect(elapsed()).toBe('已耗时 0.5秒');
} finally {
vi.useRealTimers();
}
});
test('任务收口后不再走表:已耗时定格在账本给的结束时间上', () => {
vi.useFakeTimers();
try {
vi.setSystemTime(1_000);
renderSidebar([
task({
taskId: 't1',
dispatched: true,
status: 'completed',
phaseDetail: '生成已完成。',
assetId: 'asset-1',
finishedAtMillis: 2_500,
}),
]);
const elapsed = () => screen.getByText(/^已耗时 /u).textContent;
expect(elapsed()).toBe('已耗时 1.5秒');
act(() => {
vi.advanceTimersByTime(5_000);
});
expect(elapsed()).toBe('已耗时 1.5秒');
} finally {
vi.useRealTimers();
}
});
/**
* 秒表只订在**运行中的那一行**上:终态行不订阅,面板本身也不订阅。
*
* 这条是性能约束的可执行版本——100ms 的 tick 若落在整块面板或终态行上,每 0.1 秒都会重建
* 整个侧栏(`useLiveNow` 的注释写明「调用方必须是叶子节点」)。
*/
test('秒表只订在运行中的那一行上,终态行与面板本身都不订阅时钟', () => {
const spy = vi.spyOn(globalThis, 'setInterval');
try {
renderSidebar([
task({
taskId: 'live',
dispatched: true,
status: 'running',
phaseDetail: '正在生成。',
}),
task({
taskId: 'done',
dispatched: true,
status: 'completed',
phaseDetail: '生成已完成。',
assetId: 'asset-done',
finishedAtMillis: 2_000,
}),
]);
expect(spy).toHaveBeenCalledTimes(1);
} finally {
spy.mockRestore();
}
});
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 () => {
const user = userEvent.setup();
const { onToggleOpen, container } = renderSidebar([], { open: false });
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull();
expect(
document.querySelector('.game-resource-generation-tasks-handle'),
).toBeNull();
expect(
document.querySelector('.game-resource-generation-tasks-sidebar'),
).toBeNull();
// 开关落在右上角锚点里(形态照抄美术画布),并且是「生成任务」这个可访问名的唯一持有者。
const toggle = screen.getByRole('button', { name: '生成任务' });
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(toggle.dataset.resourceGenerationTaskCount).toBe('0');
expect(
toggle.closest('.game-resource-generation-tasks-anchor'),
).not.toBeNull();
expect(
container.querySelectorAll('.game-resource-generation-tasks-toggle'),
).toHaveLength(1);
await user.click(toggle);
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
test('开关上的在途计数与面板头部同源,且跟着任务收口归零', () => {
const running = task({
taskId: 't1',
dispatched: true,
status: 'running',
phaseDetail: '正在生成。',
});
const { rerender } = render(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[running]}
open={false}
onToggleOpen={vi.fn()}
onFocusTask={vi.fn()}
/>,
);
const collapsedToggle = screen.getByRole('button', { name: '生成任务' });
expect(collapsedToggle.dataset.resourceGenerationTaskCount).toBe('1');
expect(collapsedToggle.textContent).toContain('生成任务 · 1');
rerender(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[{ ...running, status: 'completed', finishedAtMillis: 2 }]}
open
onToggleOpen={vi.fn()}
onFocusTask={vi.fn()}
/>,
);
expect(screen.getByLabelText('在途生成任务 0').textContent).toBe('0');
});
test('展开时开关让位:同一时刻只有面板自己那枚 × 能收起它', async () => {
const user = userEvent.setup();
const { onToggleOpen } = renderSidebar([task({ taskId: 't1' })], {
open: true,
});
// 开关与面板不并排:展开态画布右上角只留面板(收起走面板头部那枚 × 或点外部)。
expect(
screen.queryByRole('button', { name: /^生成任务(?: · \d+)?$/ }),
).toBeNull();
expect(screen.getByRole('region', { name: '生成任务' })).not.toBeNull();
await user.click(screen.getByRole('button', { name: '关闭生成任务' }));
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
test('锚点按工作面分档:资源画布 / 运行表现层 / UI 编辑器各挂一档', () => {
const { container, rerender } = renderSidebar([], { placement: 'run' });
const placementOf = () =>
container
.querySelector('.game-resource-generation-tasks-anchor')
?.getAttribute('data-generation-tasks-placement');
expect(placementOf()).toBe('run');
rerender(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[]}
open
onToggleOpen={vi.fn()}
onFocusTask={vi.fn()}
placement="editor"
/>,
);
expect(placementOf()).toBe('editor');
// 缺省 = 资源栏目画布,老调用方不传这个 prop 也落在画布右上角。
rerender(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[]}
open
onToggleOpen={vi.fn()}
onFocusTask={vi.fn()}
/>,
);
expect(placementOf()).toBe('canvas');
// 资源总览(画本)页单独一档:那一页顶部没有钉死的栏目标题栏。
rerender(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[]}
open
onToggleOpen={vi.fn()}
onFocusTask={vi.fn()}
placement="canvas-overview"
/>,
);
expect(placementOf()).toBe('canvas-overview');
});
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);
});
});