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 记下「音频并入后台任务账本」这条长期约定
354 lines
13 KiB
TypeScript
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('这次生成是「原请求重试」');
|
|
});
|
|
});
|