Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx
T
k88936 a11cdf13d7 画布生成面板重开草稿与重试身份收敛到 canonical content
生成面板 state、重开草稿、重试身份与提交载荷统一为一份 content[]
重试身份改用 directCodexContentKey 内容指纹比对并删除 referenceIdsMatch
directCodexContentToLegacyContentDto 只在任务落账的出站边界派生
账本回到草稿的反向翻译收敛为 legacyContentDtoToContent 单一实现
token 扫描从 ResourceReferenceInput 提到 resourceReferences 并两处共用
无法解析的资源引用合成 unknown 占位而不再静默丢弃
出站提示词规范化收敛为 resourceCanvasAssetGenerationPromptText 供两处共用
directCodexContentToPromptText 去掉 trim,空白只在整条 content 上判定
同步决策记录与 canonical content 里程碑落地结果
2026-09-21 22:56:32 +08:00

566 lines
19 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,
resourceCanvasAssetGenerationPromptText,
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,
};
const spritesheetAction: ResourceCanvasAssetToolAction = {
...uiPrototypeAction,
id: 'generate-icon-spritesheet',
label: '生成图标素材',
assetKind: 'icon-spritesheet',
};
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);
});
});
describe('出站提示词规范化', () => {
test('任务落账时按动作类型收掉首尾空白:图集用 Unicode 口径,其余用 JS 口径', () => {
// 图集:Rust `str::trim` 认 NEL(U+0085)为空白;JS `trim` 不认,所以两条必须分开。
expect(
resourceCanvasAssetGenerationPromptText(
spritesheetAction,
'\u0085金币\u0085',
),
).toBe('金币');
expect(
resourceCanvasAssetGenerationPromptText(spritesheetAction, '\uFEFF金币'),
).toBe('\uFEFF金币');
// 其余入口沿用 JS 口径:NEL 不是空白,不会被收掉。
expect(
resourceCanvasAssetGenerationPromptText(
uiPrototypeAction,
'\u0085金币\u0085',
),
).toBe('\u0085金币\u0085');
expect(
resourceCanvasAssetGenerationPromptText(uiPrototypeAction, ' 主界面 '),
).toBe('主界面');
});
test('createResourceCanvasAssetGenerationTask 把这份规范化落到账本提示词上', () => {
const task = createResourceCanvasAssetGenerationTask({
taskId: 't-trim',
action: uiPrototypeAction,
prompt: ' 主界面与背包页 ',
assetName: '主界面设计图',
aspectRatio: '16:9',
imageSize: '1K',
outputPath: null,
projectId: 'project-1',
nowMillis: 1_000,
});
expect(task.prompt).toBe('主界面与背包页');
});
});