Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx
T
k88936 99fb6c38c1 AGC 音频生成并入图片类那份后台任务账本
- 原生:`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 记下「音频并入后台任务账本」这条长期约定
2026-09-20 23:32:09 +08:00

354 lines
13 KiB
TypeScript

// @vitest-environment jsdom
import {
cleanup,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
isResourceCanvasGenerationAvailable,
RESOURCE_CANVAS_GENERATION_OPTIONS,
} from '../src/features/resource-canvas/resourceCanvasGenerationModel';
import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView';
import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils';
type TauriInvoke = (
command: string,
args?: Record<string, unknown>,
) => Promise<unknown>;
function installTauriInvoke(invoke: TauriInvoke) {
const mock = vi.fn(invoke);
(
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof mock } };
}
).__TAURI__ = { core: { invoke: mock } };
return mock;
}
afterEach(() => {
cleanup();
delete (
window as unknown as { __TAURI__?: { core?: { invoke?: TauriInvoke } } }
).__TAURI__;
});
describe('resourceCanvasGenerationModel', () => {
test('只放行本地契约支持无源生成的三类素材', () => {
expect(RESOURCE_CANVAS_GENERATION_OPTIONS.map((item) => item.kind)).toEqual(
['video', 'sound-effect', 'background-music'],
);
expect(
RESOURCE_CANVAS_GENERATION_OPTIONS.map((item) => item.editKind),
).toEqual(['video', 'sound-effect', 'background-music']);
});
test('没有客户端桥或项目未就绪时生成能力不成立', () => {
expect(
isResourceCanvasGenerationAvailable({
hasRuntimeInvoke: false,
projectPath: '/tmp/project',
projectId: 'project-1',
}),
).toBe(false);
expect(
isResourceCanvasGenerationAvailable({
hasRuntimeInvoke: true,
projectPath: ' ',
projectId: 'project-1',
}),
).toBe(false);
expect(
isResourceCanvasGenerationAvailable({
hasRuntimeInvoke: true,
projectPath: '/tmp/project',
projectId: '',
}),
).toBe(false);
expect(
isResourceCanvasGenerationAvailable({
hasRuntimeInvoke: true,
projectPath: '/tmp/project',
projectId: 'project-1',
}),
).toBe(true);
});
});
describe('ResourceCanvasGenerationPanelView', () => {
test('只呈现三类可无源生成的素材,也不展示图片专属选项或泥点价', () => {
render(
<ResourceCanvasGenerationPanelView
onSubmit={async () => undefined}
onClose={() => undefined}
/>,
);
const panel = screen.getByRole('dialog', { name: '生成素材' });
for (const label of ['视频', '音效', '背景音乐']) {
expect(within(panel).getByRole('button', { name: label })).not.toBeNull();
}
expect(panel.textContent).not.toContain('泥点');
expect(panel.textContent).not.toContain('像素艺术');
expect(panel.textContent).not.toContain('图片');
});
test('点「生成」即同步入队并关闭面板:不等 IPC、不等排队、不等生成', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const onClose = vi.fn();
render(
<ResourceCanvasGenerationPanelView
onSubmit={onSubmit}
onClose={onClose}
/>,
);
const prompt = screen.getByLabelText('生成提示词');
const assetName = screen.getByLabelText('素材名称');
expect((assetName as HTMLInputElement).value).toBe('新视频');
await user.type(prompt, '一段片头动画,镜头缓慢推进');
await user.click(screen.getByRole('button', { name: '生成视频' }));
// 提交与关闭在同一个事件循环里发生:提交回调**同步**返回,面板不持有在途状态。
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
kind: 'video',
prompt: '一段片头动画,镜头缓慢推进',
assetName: '新视频',
});
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
operationId: expect.any(String),
idempotencyKey: expect.any(String),
});
// 面板里不出现任何阶段文案与「后台运行并关闭」这类在途按钮。
expect(document.body.textContent).not.toMatch(
/排队中。|正在生成。|提交中…|后台运行并关闭/,
);
});
test('失败重开:带原失败原因与原请求身份,改回原提示词才能重试', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const onClose = vi.fn();
const boundRequest = {
operationId: '8f2d5b1c-3a4e-4d6f-9b0c-1d2e3f4a5b6c',
idempotencyKey: '5c1e7a90-2b3d-4e5f-8a9b-0c1d2e3f4a5b',
prompt: '一段片头动画,镜头缓慢推进',
};
render(
<ResourceCanvasGenerationPanelView
initialKind="video"
initialDraft={{
kind: 'video',
prompt: boundRequest.prompt,
assetName: '新视频',
}}
initialError="生成失败:result-unknown: 测试网络中断"
request={boundRequest}
onSubmit={onSubmit}
onClose={onClose}
/>,
);
expect(screen.getByRole('alert').textContent).toContain(
'result-unknown: 测试网络中断',
);
const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement;
expect(prompt.value).toBe(boundRequest.prompt);
// 改了提示词:这次提交不再是原请求的重试(会另铸身份并另行计费),必须被挡住。
await typeGenerationPrompt(document.body, '改成另一段片头动画');
const retry = screen.getByRole('button', {
name: '使用原请求重试',
}) as HTMLButtonElement;
expect(retry.disabled).toBe(true);
expect(document.body.textContent).toContain('这次生成是「原请求重试」');
await user.click(retry);
expect(onSubmit).not.toHaveBeenCalled();
// 改回原样:重试复用同一对 operationId / 幂等键,不产生第二次付费生成。
await typeGenerationPrompt(document.body, boundRequest.prompt);
await user.click(screen.getByRole('button', { name: '使用原请求重试' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
operationId: boundRequest.operationId,
idempotencyKey: boundRequest.idempotencyKey,
prompt: boundRequest.prompt,
});
expect(onClose).toHaveBeenCalledTimes(1);
});
test('切换类型后默认名称与提交文案跟随类型', async () => {
const user = userEvent.setup();
render(
<ResourceCanvasGenerationPanelView
onSubmit={async () => undefined}
onClose={() => undefined}
/>,
);
await user.click(screen.getByRole('button', { name: '背景音乐' }));
expect((screen.getByLabelText('素材名称') as HTMLInputElement).value).toBe(
'新背景音乐',
);
expect(screen.getByRole('button', { name: '生成背景音乐' })).not.toBeNull();
});
test('只放行一种类型时不再渲染类型选择器,标题与默认名称跟该类走', () => {
render(
<ResourceCanvasGenerationPanelView
kinds={['background-music']}
initialKind="background-music"
onSubmit={async () => undefined}
onClose={() => undefined}
/>,
);
const panel = screen.getByRole('dialog', { name: '生成背景音乐' });
// 一个只有一个选项的分段控件是噪音:单类型入口不渲染它。
expect(within(panel).queryByRole('button', { name: '视频' })).toBeNull();
expect(within(panel).queryByRole('button', { name: '音效' })).toBeNull();
expect(
(within(panel).getByLabelText('素材名称') as HTMLInputElement).value,
).toBe('新背景音乐');
});
});
describe('生成素材弹窗的提示词润色', () => {
test('润色走短文本通道,带上「这是素材生成提示词」的场景约束并回填', async () => {
const user = userEvent.setup();
const invoke = installTauriInvoke(async (command) => {
if (command !== 'polish_local_project_prompt') return undefined;
return ' 润色后的音效描述 ';
});
render(
<ResourceCanvasGenerationPanelView
initialKind="sound-effect"
onSubmit={async () => undefined}
onClose={() => undefined}
/>,
);
const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement;
await user.type(prompt, '木门推开的声音');
const polishButton = screen.getByRole('button', { name: 'AI 润色' });
expect(polishButton.getAttribute('aria-busy')).toBe('false');
await user.click(polishButton);
await waitFor(() => expect(prompt.value).toBe('润色后的音效描述'));
expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', {
prompt: '木门推开的声音',
context: expect.stringContaining('这是素材生成提示词(音效)'),
});
// 场景约束只讲这一句,不把提示词扩写成需求文档。
const [, args] = invoke.mock.calls[0] as [string, { context: string }];
expect(args.context).toContain('不要扩写成需求');
// 成功后有原文快照,可以一键回到原文。
await user.click(screen.getByRole('button', { name: '恢复原文' }));
expect(prompt.value).toBe('木门推开的声音');
});
test('润色结果超上限先截断再回填,并在状态行说明', async () => {
const user = userEvent.setup();
installTauriInvoke(async (command) => {
if (command !== 'polish_local_project_prompt') return undefined;
// 背景音乐上限 140,这里给 200 字。
return `润色后的背景音乐描述${'啊'.repeat(196)}`;
});
render(
<ResourceCanvasGenerationPanelView
initialKind="background-music"
onSubmit={async () => undefined}
onClose={() => undefined}
/>,
);
const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement;
await user.type(prompt, '轻快的八音盒');
await user.click(screen.getByRole('button', { name: 'AI 润色' }));
await waitFor(() => expect(prompt.value.length).toBe(140));
expect(prompt.value.startsWith('润色后的背景音乐描述')).toBe(true);
expect(screen.getByRole('status').textContent).toBe('已按长度上限截断');
});
test('润色失败保留原文并给出可重试提示', async () => {
const user = userEvent.setup();
installTauriInvoke(async (command) => {
if (command !== 'polish_local_project_prompt') return undefined;
throw new Error('platform llm timeout');
});
render(
<ResourceCanvasGenerationPanelView
initialKind="video"
onSubmit={async () => undefined}
onClose={() => undefined}
/>,
);
const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement;
await user.type(prompt, '一段片头动画,镜头缓慢推进');
await user.click(screen.getByRole('button', { name: 'AI 润色' }));
expect(await screen.findByText('AI 润色失败,可重试')).not.toBeNull();
expect(prompt.value).toBe('一段片头动画,镜头缓慢推进');
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
});
test('失败重开后输入不锁定:润色照常可用,改动提示词就挡住原请求重试', async () => {
const user = userEvent.setup();
installTauriInvoke(async (command) => {
if (command !== 'polish_local_project_prompt') return undefined;
return ' 润色后的片头动画 ';
});
const onSubmit = vi.fn();
const boundRequest = {
operationId: '2b7c1d3e-4f5a-4b6c-8d9e-0f1a2b3c4d5e',
idempotencyKey: '6c8d2e4f-5a6b-4c7d-9e0f-1a2b3c4d5e6f',
prompt: '一段片头动画',
};
render(
<ResourceCanvasGenerationPanelView
initialKind="video"
initialDraft={{
kind: 'video',
prompt: boundRequest.prompt,
assetName: '新视频',
}}
initialError="生成失败:result-unknown: 测试网络中断"
request={boundRequest}
onSubmit={onSubmit}
onClose={() => undefined}
/>,
);
/*
提交后面板已经关了,重开的是**占位**:这里既没有在途状态也没有「锁」,用户照常能改草稿。
挡住重复付费的判据不是「锁住输入」,而是「提示词一变就不再是原请求」。
*/
const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement;
expect(prompt.disabled).toBe(false);
const polishButton = screen.getByRole('button', {
name: 'AI 润色',
}) as HTMLButtonElement;
expect(polishButton.disabled).toBe(false);
await user.click(polishButton);
await waitFor(() => expect(prompt.value).toBe('润色后的片头动画'));
const retry = screen.getByRole('button', {
name: '使用原请求重试',
}) as HTMLButtonElement;
expect(retry.disabled).toBe(true);
await user.click(retry);
expect(onSubmit).not.toHaveBeenCalled();
expect(document.body.textContent).toContain('这次生成是「原请求重试」');
});
});