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
- 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 时第一条用例即红,粒度确实被钉住。
518 lines
18 KiB
TypeScript
518 lines
18 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-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);
|
||
});
|
||
});
|