Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx
suzmii 55fab1776a
Project CI / Repository checks (pull_request) Successful in 2m55s
Project CI / Frontend tests (pull_request) Successful in 3m59s
Project CI / Backend tests (pull_request) Successful in 6m50s
Project CI / Native shell tests (pull_request) Failing after 14m3s
AGC 画布验收问题修复与栏目画布底部工具栏
- 合并资源画布命名筛选与条件筛选为单一筛选浮层,Dock 只留放大镜入口
- 修复「编辑素材标签」面板标签多时不可见且不可滚:加高度上界与标签区独立滚动
- 替换素材新增点选替换模式,并在资源卡标注会话内替换血缘
- 新增栏目画布底部工具栏,按功能画布分流图片类生成、音频生成与上传入口
- 新增 Tauri IPC generate_local_project_asset,收口图片类无源生成的 kind 与提示词目录
- 同步 PRD、AGC 验收用例、决策日志、踩坑记录与待解决事项文档
2026-09-13 14:07:03 +08:00

287 lines
10 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();
});
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 () => 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]);
});
});