99fb6c38c1
- 原生:`start_local_project_asset_generation` 新增可选入参 `idempotencyKey`,并在音频 kind 上分叉;图片类载荷与分支逐字未改 - 原生:新增音频提交期收口 `prepare_local_project_audio_generation`(kind / 提示词上限 / 素材名 / operation 身份 / 幂等键) - 原生:新增 `run_local_project_audio_generation_at`,在派发时刻读项目 revision 并复用既有音频无源生成实现,不复制生成逻辑 - 原生:新增 `begin_local_project_audio_generation_task` / `run_local_project_audio_generation_task`,音频任务落同一份项目内账本并写 running → completed / failed,跑完没登记素材按失败收口 - 原生:补两条用例——被拒绝的提交零写入账本、音频任务落在同一账本且 kind 正确 - 前端任务模型:新增音频任务与 `idempotencyKey` 字段,恢复出来的历史任务不带它也不承接重试,入口文案扩到音频栏目 - 前端队列:按 kind 分流派发载荷,音频只发任务身份(任务 id 即 operation id)与幂等键 - 前端面板:音频生成面板改为点「生成」同步提交并立即关闭,删除「生成中…」「后台运行并关闭」与输入锁定 - 前端宿主:音频提交改为同步入队并展开「生成任务」侧栏,只有「后端从未受理」才连原草稿与原请求身份重开面板 - 前端清理:删除随本次改动失效的 `resourceCanvasGenerationSourceId` 与宿主里不再使用的 import - 测试:面板 / 队列 / 宿主生命周期 / 落点 / appSurface 改按后台账本口径断言,并补「未受理即时失败重开」用例(已用移除重开逻辑的变异验证其非空) - 文档:PRD §3.10 / §7.9、AGC 底部工具栏入口矩阵、V3 端到端验收 S11a 同步为音频后台化口径 - 文档:新增里程碑与实施计划(含验收证据矩阵),并在 decision-log 记下「音频并入后台任务账本」这条长期约定
200 lines
7.3 KiB
TypeScript
200 lines
7.3 KiB
TypeScript
// @vitest-environment jsdom
|
|
import {
|
|
cleanup,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
|
|
import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
|
|
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
|
|
import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView';
|
|
import {
|
|
generationPromptText,
|
|
typeGenerationPrompt,
|
|
} from './resourceGenerationPromptTestUtils';
|
|
|
|
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,
|
|
};
|
|
|
|
describe('图片类生成面板:点击即关闭,面板里不出现阶段文案', () => {
|
|
test('点击生成同步调用提交并关闭面板,面板 DOM 里从不出现阶段 / 排队文案', async () => {
|
|
const onClose = vi.fn();
|
|
const onSubmit = vi.fn();
|
|
const user = userEvent.setup();
|
|
render(
|
|
<ResourceCanvasAssetGenerationPanelView
|
|
action={uiPrototypeAction}
|
|
onSubmit={onSubmit}
|
|
onClose={onClose}
|
|
/>,
|
|
);
|
|
await typeGenerationPrompt(document.body, '主界面与背包页');
|
|
// 点击前主按钮文案就是动作名:不是阶段、也不是「已提交」。
|
|
const panel = screen.getByRole('dialog', { name: '生成 UI 设计图' });
|
|
expect(
|
|
(
|
|
within(panel).getByRole('button', {
|
|
name: '生成 UI 设计图',
|
|
}) as HTMLButtonElement
|
|
).disabled,
|
|
).toBe(false);
|
|
expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/);
|
|
|
|
await user.click(
|
|
within(panel).getByRole('button', { name: '生成 UI 设计图' }),
|
|
);
|
|
|
|
// 提交与关闭在同一个事件循环里发生:面板不等受理、不等排队、不等生成。
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/);
|
|
expect(screen.queryByRole('button', { name: '后台运行并关闭' })).toBeNull();
|
|
});
|
|
|
|
test('× / Esc / 点遮罩仍能关面板,且关闭不等于取消', async () => {
|
|
const onClose = vi.fn();
|
|
const onSubmit = vi.fn();
|
|
const user = userEvent.setup();
|
|
render(
|
|
<ResourceCanvasAssetGenerationPanelView
|
|
action={uiPrototypeAction}
|
|
onSubmit={onSubmit}
|
|
onClose={onClose}
|
|
/>,
|
|
);
|
|
await typeGenerationPrompt(document.body, '主界面与背包页');
|
|
|
|
await user.click(
|
|
screen.getByRole('button', { name: '关闭生成 UI 设计图' }),
|
|
);
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
fireEvent.keyDown(window, { key: 'Escape' });
|
|
expect(onClose).toHaveBeenCalledTimes(2);
|
|
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement;
|
|
fireEvent.pointerDown(backdrop);
|
|
fireEvent.pointerUp(backdrop);
|
|
fireEvent.click(backdrop);
|
|
expect(onClose).toHaveBeenCalledTimes(3);
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('即时失败重开时带回草稿与失败原因,改完就能重试', async () => {
|
|
const onClose = vi.fn();
|
|
const onSubmit = vi.fn();
|
|
const user = userEvent.setup();
|
|
render(
|
|
<ResourceCanvasAssetGenerationPanelView
|
|
action={uiPrototypeAction}
|
|
draft={{
|
|
prompt: '主界面与背包页',
|
|
assetName: 'AI 生成 UI 设计图',
|
|
aspectRatio: '16:9',
|
|
imageSize: '1K',
|
|
references: [],
|
|
}}
|
|
error="生成素材失败:远端拒绝"
|
|
onSubmit={onSubmit}
|
|
onClose={onClose}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByRole('alert').textContent).toContain(
|
|
'生成素材失败:远端拒绝',
|
|
);
|
|
await waitFor(() =>
|
|
expect(generationPromptText(document.body)).toBe('主界面与背包页'),
|
|
);
|
|
|
|
await user.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
|
|
kind: 'ui-design',
|
|
prompt: '主界面与背包页',
|
|
assetName: 'AI 生成 UI 设计图',
|
|
});
|
|
});
|
|
});
|
|
|
|
/* 音频面板没有在途状态:点「生成」即把输入交给宿主(同步入参 + 立即关闭),阶段文案只由
|
|
画布上的「生成任务」侧栏拥有。 */
|
|
describe('音频生成面板:点击即关闭,面板里不出现阶段文案', () => {
|
|
test('点「生成音效」同步调用提交并关闭面板,从不出现阶段 / 后台文案', async () => {
|
|
const onClose = vi.fn();
|
|
const onSubmit = vi.fn();
|
|
const user = userEvent.setup();
|
|
render(
|
|
<ResourceCanvasGenerationPanelView
|
|
kinds={['sound-effect']}
|
|
initialKind="sound-effect"
|
|
onSubmit={onSubmit}
|
|
onClose={onClose}
|
|
/>,
|
|
);
|
|
await user.type(screen.getByLabelText('生成提示词'), '木门推开的声音');
|
|
|
|
const panel = screen.getByRole('dialog', { name: '生成音效' });
|
|
expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/);
|
|
await user.click(within(panel).getByRole('button', { name: '生成音效' }));
|
|
|
|
// 提交与关闭在同一个事件循环里发生:不等受理、不等排队、不等生成。
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
|
|
kind: 'sound-effect',
|
|
prompt: '木门推开的声音',
|
|
assetName: '新音效',
|
|
});
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
// 进度只出现在「生成任务」侧栏:面板 DOM 里既没有阶段文案,也没有在途按钮。
|
|
expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/);
|
|
expect(screen.queryByRole('button', { name: '后台运行并关闭' })).toBeNull();
|
|
});
|
|
|
|
test('× / Esc 关面板只收起草稿:不提交,也不取消任何任务', async () => {
|
|
const onClose = vi.fn();
|
|
const onSubmit = vi.fn();
|
|
const user = userEvent.setup();
|
|
render(
|
|
<ResourceCanvasGenerationPanelView
|
|
kinds={['sound-effect']}
|
|
initialKind="sound-effect"
|
|
onSubmit={onSubmit}
|
|
onClose={onClose}
|
|
/>,
|
|
);
|
|
await user.type(screen.getByLabelText('生成提示词'), '木门推开的声音');
|
|
|
|
await user.click(screen.getByRole('button', { name: '关闭生成音效' }));
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
// 关闭把当前草稿交回宿主:用户再点开占位卡能接着编辑,而不是回到空表单。
|
|
expect(onClose.mock.calls[0]?.[0]).toMatchObject({
|
|
kind: 'sound-effect',
|
|
prompt: '木门推开的声音',
|
|
assetName: '新音效',
|
|
});
|
|
fireEvent.keyDown(window, { key: 'Escape' });
|
|
expect(onClose).toHaveBeenCalledTimes(2);
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
});
|