d44e1ae8ea
- 新增 ResourcePromptPolishSlot:素材侧两处入口共用的「AI 润色 / 恢复原文 / 润色中…」动作,取数走 AGC 侧 usePromptPolish,聊天框同款失败态(失败保留原文、可重试文案) - 场景约束走 polish_local_project_prompt 的 context:「这是素材生成提示词(音效)」+「只润色这一句、不要扩写成需求/方案/验收标准、不要新增事实」(本批不动后端提示词文件),文案落在 resourceCanvasPromptPolishModel - resourceEditModel 新增 truncateResourceEditPrompt:超上限先按类型截断(背景音乐 140 / 音效 1900 / 视频与角色动画 4000 / 其余 32000)再回填,并在状态行给出「已按长度上限截断」,不静默丢内容;后端上限未改 - resourceEditModel 新增 resolveResourceEditRequestIdentity:请求身份绑定到铸造时的 prompt(Rust request_fingerprint 含 prompt,改了提示词还复用同一 operationId 会被判「已绑定到不同资源编辑请求」);提示词没变时仍复用,失败重试照旧命中同一账本 - 生成素材弹窗用注入位渲染润色动作(共享 composer 只提供插槽,不上 Tauri 调用),提交时按同一口径解析身份;原先只在这一处使用的 createResourceCanvasGenerationRequest 随之下线 - 样式落在 resourceCanvasChrome.css(resource-prompt-polish 一族),弹窗与 composer 共用一行 - 测试:模型层补截断边界与身份复用/重铸;弹窗层补润色回填与上下文、超限截断提示、失败保留原文、失败后提示词与润色一起锁定且重试复用同一请求 - 变异验证:截断改成恒等 → 模型用例与「超限截断」用例双双红灯(实测字段长度 206 ≠ 140);身份去掉重铸 → 模型用例红灯
268 lines
9.8 KiB
TypeScript
268 lines
9.8 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,
|
|
resourceCanvasGenerationSourceId,
|
|
} from '../src/features/resource-canvas/resourceCanvasGenerationModel';
|
|
import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView';
|
|
|
|
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);
|
|
});
|
|
|
|
test('无源生成的稳定源标识挂在 operationId 上', () => {
|
|
expect(resourceCanvasGenerationSourceId(' op-1 ')).toBe('create:op-1');
|
|
});
|
|
});
|
|
|
|
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('失败后锁定原请求并用同一 operationId 与幂等键重试', async () => {
|
|
const user = userEvent.setup();
|
|
const onSubmit = vi
|
|
.fn<(input: unknown) => Promise<void>>()
|
|
.mockRejectedValueOnce(new Error('result-unknown: 测试网络中断'))
|
|
.mockResolvedValueOnce(undefined);
|
|
render(
|
|
<ResourceCanvasGenerationPanelView
|
|
onSubmit={onSubmit}
|
|
onClose={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
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((await screen.findByRole('alert')).textContent).toContain(
|
|
'result-unknown: 测试网络中断',
|
|
);
|
|
expect((prompt as HTMLTextAreaElement).disabled).toBe(true);
|
|
expect((assetName as HTMLInputElement).disabled).toBe(true);
|
|
|
|
await user.click(screen.getByRole('button', { name: '使用原请求重试' }));
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2));
|
|
expect(onSubmit.mock.calls[0]?.[0]).toEqual(onSubmit.mock.calls[1]?.[0]);
|
|
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
|
|
kind: 'video',
|
|
prompt: '一段片头动画,镜头缓慢推进',
|
|
assetName: '新视频',
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
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 () => undefined);
|
|
const onSubmit = vi
|
|
.fn<(input: unknown) => Promise<void>>()
|
|
.mockRejectedValueOnce(new Error('result-unknown: 测试网络中断'))
|
|
.mockResolvedValueOnce(undefined);
|
|
render(
|
|
<ResourceCanvasGenerationPanelView
|
|
initialKind="video"
|
|
onSubmit={onSubmit}
|
|
onClose={() => undefined}
|
|
/>,
|
|
);
|
|
|
|
const prompt = screen.getByLabelText('生成提示词') as HTMLTextAreaElement;
|
|
await user.type(prompt, '一段片头动画');
|
|
await user.click(screen.getByRole('button', { name: '生成视频' }));
|
|
expect(await screen.findByRole('alert')).not.toBeNull();
|
|
|
|
// 锁定输入 = 提交的提示词不可能漂移,所以复用同一 operationId 是安全的。
|
|
expect(prompt.disabled).toBe(true);
|
|
const polishButton = screen.getByRole('button', { name: 'AI 润色' });
|
|
expect((polishButton as HTMLButtonElement).disabled).toBe(true);
|
|
|
|
await user.click(screen.getByRole('button', { name: '使用原请求重试' }));
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2));
|
|
expect(onSubmit.mock.calls[1]?.[0]).toEqual(onSubmit.mock.calls[0]?.[0]);
|
|
});
|
|
});
|