Update Match3D/image-generation docs & code

Adds/updates documentation, assets and implementation for Match3D and puzzle image generation workflows. Key changes: decision logs and pitfalls updated to prefer VectorEngine Gemini for Match3D material sheets and to require edits (multipart) for 1:1 container reference images; guidance added for when to use APIMart vs VectorEngine. .env.example clarified APIMart/Responses config. Many new public assets and PPT visuals added. Code changes across frontend and backend: updated shared contracts, server-rs match3d/puzzle/image-generation handlers, VectorEngine/OpenAI image generation clients, and multiple React components/tests to handle UI/background/container image signing, edits workflow, and puzzle UI background resolution. Added src/services/puzzle-runtime/puzzleUiBackgroundSource.ts and related test updates. Includes notes about multipart HTTP/1.1 requirement and test/verification commands in docs.
This commit is contained in:
2026-05-14 20:34:45 +08:00
parent d33c937ebc
commit 548db78ca7
103 changed files with 6687 additions and 3270 deletions

View File

@@ -3,9 +3,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { AudioGenerationTaskResponse } from '../../../packages/shared/src/contracts/creationAudio';
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
import * as creationAudioService from '../../services/creation-audio';
import * as match3dWorksService from '../../services/match3d-works';
import { clearMatch3DGeneratedModelBytesCache } from '../../services/match3dGeneratedModelCache';
import { Match3DResultView } from './Match3DResultView';
@@ -32,6 +30,7 @@ vi.mock('../../services/assetReadUrlService', () => ({
vi.mock('../../services/match3d-works', () => ({
generateMatch3DBackgroundImage: vi.fn(),
generateMatch3DContainerImage: vi.fn(),
generateMatch3DCoverImage: vi.fn(),
generateMatch3DItemAssets: vi.fn(),
generateMatch3DWorkTags: vi.fn(),
@@ -40,16 +39,6 @@ vi.mock('../../services/match3d-works', () => ({
updateMatch3DWork: vi.fn(),
}));
vi.mock('../../services/creation-audio', () => ({
createBackgroundMusicTask: vi.fn(),
createSoundEffectTask: vi.fn(),
publishBackgroundMusicAsset: vi.fn(),
publishSoundEffectAsset: vi.fn(),
waitForGeneratedAudioAsset: vi.fn((taskId: string, publish: () => unknown) =>
publish(),
),
}));
afterEach(() => {
clearMatch3DGeneratedModelBytesCache();
vi.clearAllMocks();
@@ -66,6 +55,28 @@ function createDeferred<T>() {
return { promise, reject, resolve };
}
function stubMatch3DCoverUpload(dataUrl: string) {
class MockFileReader {
result: string | ArrayBuffer | null = dataUrl;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
}
function confirmPointCost() {
const dialogs = screen.getAllByRole('dialog', { name: '确认消耗泥点' });
const dialog = dialogs[dialogs.length - 1]!;
fireEvent.click(
dialog.querySelector('button:last-of-type') as HTMLButtonElement,
);
}
function createProfile(
overrides: Partial<Match3DWorkProfile> = {},
): Match3DWorkProfile {
@@ -177,13 +188,14 @@ describe('Match3DResultView', () => {
});
});
test('面图独立面板支持引用物品素材后 AI 重绘', async () => {
test('面图独立面板支持引用物品素材作为多参考图生成', async () => {
const profile = createProfile({
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/generated-match3d-assets/session/profile/items/i1/image.png',
imageSrc:
'/generated-match3d-assets/session/profile/items/i1/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/i1/image.png',
modelSrc: null,
@@ -208,7 +220,7 @@ describe('Match3DResultView', () => {
'/generated-match3d-assets/session/profile/cover/task/cover.png',
coverImageObjectKey:
'generated-match3d-assets/session/profile/cover/task/cover.png',
prompt: '草莓抓大鹅面图',
prompt: '草莓抓大鹅面图',
});
render(
@@ -220,25 +232,84 @@ describe('Match3DResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '面图' }));
expect(screen.getByRole('dialog', { name: '面图' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '面图' }));
expect(screen.getByRole('dialog', { name: '面图' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '引用草莓' }));
fireEvent.change(screen.getByLabelText('碰面图提示词'), {
target: { value: '草莓抓大鹅面图' },
fireEvent.change(screen.getByLabelText('封面描述'), {
target: { value: '草莓抓大鹅面图' },
});
fireEvent.click(screen.getByRole('button', { name: '生成面图' }));
fireEvent.click(screen.getByRole('button', { name: '生成面图' }));
await waitFor(() => {
expect(match3dWorksService.generateMatch3DCoverImage).toHaveBeenCalledWith(
profile.profileId,
{
prompt: '草莓抓大鹅面图',
referenceImageSrc:
'generated-match3d-assets/session/profile/items/i1/image.png',
},
);
expect(
match3dWorksService.generateMatch3DCoverImage,
).toHaveBeenCalledWith(profile.profileId, {
prompt: '草莓抓大鹅面图',
referenceImageSrcs: [
'generated-match3d-assets/session/profile/items/i1/image.png',
],
uploadedImageSrc: null,
});
expect(onSaved).toHaveBeenCalledWith(nextProfile);
expect(screen.queryByRole('dialog', { name: '面图' })).toBeNull();
expect(screen.queryByRole('dialog', { name: '面图' })).toBeNull();
});
});
test('封面图上传后对齐拼图入口显示 AI 重绘开关和删除按钮', async () => {
const uploadedDataUrl = 'data:image/png;base64,match3d-cover-uploaded';
stubMatch3DCoverUpload(uploadedDataUrl);
const profile = createProfile();
const nextProfile = createProfile({
coverImageSrc:
'/generated-match3d-assets/session/profile/cover/task/cover.png',
});
vi.mocked(match3dWorksService.generateMatch3DCoverImage).mockResolvedValue({
item: nextProfile,
coverImageSrc:
'/generated-match3d-assets/session/profile/cover/task/cover.png',
coverImageObjectKey:
'generated-match3d-assets/session/profile/cover/task/cover.png',
prompt: '保留构图,改成节日果园',
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '封面图' }));
fireEvent.change(
screen.getByLabelText('上传封面图', { selector: 'input' }),
{
target: {
files: [new File(['x'], 'cover.png', { type: 'image/png' })],
},
},
);
await waitFor(() => {
expect(screen.getByRole('switch', { name: 'AI重绘' })).toBeTruthy();
expect(screen.getByRole('button', { name: '移除封面图' })).toBeTruthy();
expect(screen.getByLabelText('AI重绘要求')).toBeTruthy();
});
expect(screen.queryByText('参考图')).toBeNull();
fireEvent.change(screen.getByLabelText('AI重绘要求'), {
target: { value: '保留构图,改成节日果园' },
});
fireEvent.click(screen.getByRole('button', { name: '生成封面图' }));
await waitFor(() => {
expect(
match3dWorksService.generateMatch3DCoverImage,
).toHaveBeenCalledWith(profile.profileId, {
prompt: '保留构图,改成节日果园',
uploadedImageSrc: uploadedDataUrl,
referenceImageSrcs: [],
});
});
});
@@ -495,7 +566,7 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByRole('button', { name: '物品' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'UI' })).toBeTruthy();
expect(screen.getByRole('button', { name: '背景音乐' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
fireEvent.click(
screen.getByRole('button', { name: '打开水果核心物件物品素材' }),
@@ -503,8 +574,8 @@ describe('Match3DResultView', () => {
expect(screen.getByRole('dialog', { name: /水果核心物件/u })).toBeTruthy();
expect(screen.getByText('素材名称')).toBeTruthy();
expect(screen.getByText('暂无音效')).toBeTruthy();
expect(screen.getByLabelText('生成点击音效10泥点')).toBeTruthy();
expect(screen.queryByText('暂无音效')).toBeNull();
expect(screen.queryByLabelText('生成点击音效10泥点')).toBeNull();
expect(screen.queryByRole('button', { name: '重新生成' })).toBeNull();
expect(screen.queryByText('用途')).toBeNull();
});
@@ -515,7 +586,8 @@ describe('Match3DResultView', () => {
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/generated-match3d-assets/session/profile/items/i1/image.png',
imageSrc:
'/generated-match3d-assets/session/profile/items/i1/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/i1/image.png',
modelSrc: null,
@@ -529,7 +601,8 @@ describe('Match3DResultView', () => {
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc: '/generated-match3d-assets/session/profile/items/i2/image.png',
imageSrc:
'/generated-match3d-assets/session/profile/items/i2/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/i2/image.png',
modelSrc: null,
@@ -591,7 +664,8 @@ describe('Match3DResultView', () => {
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/generated-match3d-assets/session/profile/items/i1/image.png',
imageSrc:
'/generated-match3d-assets/session/profile/items/i1/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/i1/image.png',
modelSrc:
@@ -638,14 +712,18 @@ describe('Match3DResultView', () => {
fireEvent.change(screen.getByLabelText('物品名称 4'), {
target: { value: '苹果' },
});
expect(screen.getByRole('button', { name: /生成物品素材 · 2泥点/u })).toBeTruthy();
expect(
screen.getByRole('button', { name: /生成物品素材 · 2泥点/u }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成物品素材/u }));
confirmPointCost();
await waitFor(() => {
expect(match3dWorksService.generateMatch3DItemAssets).toHaveBeenCalledWith(
profile.profileId,
{ itemNames: ['草莓', '苹果', '蓝莓'] },
);
expect(
match3dWorksService.generateMatch3DItemAssets,
).toHaveBeenCalledWith(profile.profileId, {
itemNames: ['草莓', '苹果', '蓝莓'],
});
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({ generatedItemAssets }),
);
@@ -661,7 +739,9 @@ describe('Match3DResultView', () => {
test('批量新增面板关闭后素材列表继续显示生成进度', async () => {
const deferred = createDeferred<{
item: Match3DWorkProfile;
generatedItemAssets: NonNullable<Match3DWorkProfile['generatedItemAssets']>;
generatedItemAssets: NonNullable<
Match3DWorkProfile['generatedItemAssets']
>;
}>();
vi.mocked(match3dWorksService.generateMatch3DItemAssets).mockReturnValue(
deferred.promise,
@@ -681,6 +761,7 @@ describe('Match3DResultView', () => {
target: { value: '草莓' },
});
fireEvent.click(screen.getByRole('button', { name: /生成物品素材/u }));
confirmPointCost();
await waitFor(() => {
expect(screen.getAllByText('生成中').length).toBeGreaterThan(0);
@@ -705,6 +786,133 @@ describe('Match3DResultView', () => {
});
});
test('批量重新生成会收集已有物品名称并按替换模式调用素材生成接口', async () => {
const generatedItemAssets = [
{ ...createReadyGeneratedItemAsset(1), itemName: '草莓' },
{ ...createReadyGeneratedItemAsset(2), itemName: '苹果' },
];
const regeneratedAssets = [
{
...generatedItemAssets[0]!,
imageSrc:
'/generated-match3d-assets/session/profile/items/item-1/new-image.png',
},
generatedItemAssets[1]!,
];
const profile = createProfile({ generatedItemAssets });
const onSaved = vi.fn();
vi.mocked(match3dWorksService.generateMatch3DItemAssets).mockResolvedValue({
item: createProfile({ generatedItemAssets: regeneratedAssets }),
generatedItemAssets: regeneratedAssets,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onSaved={onSaved}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '批量重新生成' }));
expect(
screen.getByRole('dialog', { name: '批量重新生成物品' }),
).toBeTruthy();
expect(screen.getByLabelText('重新生成物品名称 1')).toHaveProperty(
'value',
'草莓',
);
expect(screen.getByLabelText('重新生成物品名称 2')).toHaveProperty(
'value',
'苹果',
);
fireEvent.change(screen.getByLabelText('重新生成物品名称 2'), {
target: { value: '' },
});
fireEvent.click(screen.getByRole('button', { name: /重新生成物品素材/u }));
confirmPointCost();
await waitFor(() => {
expect(
match3dWorksService.generateMatch3DItemAssets,
).toHaveBeenCalledWith(profile.profileId, {
itemNames: ['草莓'],
mode: 'replace',
});
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({ generatedItemAssets: regeneratedAssets }),
);
expect(
screen.getAllByText('已重新生成 1 种物品素材').length,
).toBeGreaterThan(0);
});
expect(
screen.getByRole('button', { name: '打开草莓物品素材' }),
).toBeTruthy();
});
test('批量重新生成只提交能匹配到的已有物品名称', async () => {
const generatedItemAssets = [
{ ...createReadyGeneratedItemAsset(1), itemName: '草莓' },
{ ...createReadyGeneratedItemAsset(2), itemName: '苹果' },
{ ...createReadyGeneratedItemAsset(3), itemName: '梨子' },
{ ...createReadyGeneratedItemAsset(4), itemName: '香蕉' },
{ ...createReadyGeneratedItemAsset(5), itemName: '葡萄' },
{ ...createReadyGeneratedItemAsset(6), itemName: '橙子' },
];
const profile = createProfile({ generatedItemAssets });
vi.mocked(match3dWorksService.generateMatch3DItemAssets).mockResolvedValue({
item: profile,
generatedItemAssets,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '批量重新生成' }));
fireEvent.change(screen.getByLabelText('重新生成物品名称 1'), {
target: { value: '草莓' },
});
fireEvent.change(screen.getByLabelText('重新生成物品名称 2'), {
target: { value: '不存在' },
});
fireEvent.change(screen.getByLabelText('重新生成物品名称 3'), {
target: { value: '梨子' },
});
fireEvent.change(screen.getByLabelText('重新生成物品名称 4'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('重新生成物品名称 5'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('重新生成物品名称 6'), {
target: { value: '' },
});
expect(
screen.getByRole('button', { name: /重新生成物品素材 · 2泥点/u }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /重新生成物品素材/u }));
confirmPointCost();
await waitFor(() => {
expect(
match3dWorksService.generateMatch3DItemAssets,
).toHaveBeenCalledWith(profile.profileId, {
itemNames: ['草莓', '梨子'],
mode: 'replace',
});
});
});
test('难度配置对齐入口页并派生消除次数与物品数量', async () => {
const onSaved = vi.fn();
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
@@ -722,9 +930,7 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '难度配置' }));
expect(
screen.getByRole('button', { name: '轻松 8次 · 3种' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '轻松 8次 · 3种' })).toBeTruthy();
const difficultySlider = screen.getByRole('slider', { name: '难度' });
expect((difficultySlider as HTMLInputElement).value).toBe('1');
expect(
@@ -806,16 +1012,16 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getByRole('button', { name: '打开物品1物品素材' }),
);
fireEvent.click(screen.getByRole('button', { name: '打开物品1物品素材' }));
expect(screen.getByDisplayValue('物品1')).toBeTruthy();
expect(
[...document.querySelectorAll('img')].some((image) =>
image
.getAttribute('src')
?.includes('generated-match3d-assets/session/profile/items/item-1/views/view-01.png'),
?.includes(
'generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
),
),
).toBe(true);
});
@@ -843,12 +1049,10 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getByRole('button', { name: '打开物品1物品素材' }),
);
fireEvent.click(screen.getByRole('button', { name: '打开物品1物品素材' }));
const imageSources = [...document.querySelectorAll('img')].map((image) =>
image.getAttribute('src') ?? '',
const imageSources = [...document.querySelectorAll('img')].map(
(image) => image.getAttribute('src') ?? '',
);
expect(
imageSources.some((source) => source.includes('legacy-primary.png')),
@@ -858,7 +1062,7 @@ describe('Match3DResultView', () => {
).toBe(true);
});
test('物品详情五视角预览使用 1:1 五格布局', () => {
test('物品详情五视角预览使用上方焦点区和底部缩略图栏', () => {
render(
<Match3DResultView
profile={createProfile({
@@ -871,14 +1075,21 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getByRole('button', { name: '打开物品1物品素材' }),
);
fireEvent.click(screen.getByRole('button', { name: '打开物品1物品素材' }));
const preview = screen.getByLabelText('物品1五视角预览');
expect(preview.className).toContain('aspect-square');
expect(preview.className).toContain('grid-cols-[repeat(5,minmax(0,1fr))]');
expect(preview.querySelectorAll('img')).toHaveLength(5);
const stage = screen.getByTestId('match3d-item-preview-stage');
const focusFrame = screen.getByTestId('match3d-item-preview-focus-frame');
const thumbnails = screen.getByTestId('match3d-item-preview-thumbnails');
expect(stage.className).toContain('aspect-square');
expect(focusFrame.className).toContain('inset-[7%]');
expect(thumbnails.style.gridAutoColumns).toBe('calc((100% - 1.5rem) / 4)');
expect(preview.querySelectorAll('img')).toHaveLength(10);
expect(
screen
.getByRole('button', { name: '切换物品1视角3' })
.getAttribute('aria-pressed'),
).toBe('true');
});
test('草稿阶段仅有切割图片时展示 2D 素材', () => {
@@ -910,9 +1121,7 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getByRole('button', { name: '打开草莓物品素材' }),
);
fireEvent.click(screen.getByRole('button', { name: '打开草莓物品素材' }));
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(
@@ -1109,8 +1318,12 @@ describe('Match3DResultView', () => {
fireEvent.change(screen.getByLabelText('UI背景图画面描述提示词'), {
target: { value: '新背景提示词' },
});
expect(screen.getByRole('button', { name: /重新生成 · 2泥点/u })).toBeTruthy();
expect(
screen.getByRole('button', { name: /重新生成 · 2泥点/u }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
expect(screen.getByRole('dialog', { name: '确认消耗泥点' })).toBeTruthy();
confirmPointCost();
await waitFor(() => {
expect(
@@ -1137,6 +1350,171 @@ describe('Match3DResultView', () => {
});
});
test('素材配置 UI 子 Tab 重新生成后显示90秒倒计时进度', async () => {
const deferred =
createDeferred<
Awaited<
ReturnType<typeof match3dWorksService.generateMatch3DBackgroundImage>
>
>();
vi.mocked(
match3dWorksService.generateMatch3DBackgroundImage,
).mockReturnValue(deferred.promise);
render(
<Match3DResultView
profile={createProfile({
generatedBackgroundAsset: {
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/old/background.png',
containerPrompt: '旧容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/old/container.png',
status: 'image_ready',
error: null,
},
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
confirmPointCost();
await waitFor(() => {
expect(
screen.getByRole('progressbar', { name: 'UI背景图生成进度' }),
).toBeTruthy();
expect(screen.getByText('预计剩余 90 秒')).toBeTruthy();
});
});
test('素材配置容器形象子 Tab 单独调用容器图生成接口并刷新素材', async () => {
const profile = createProfile({
generatedBackgroundAsset: {
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/old/background.png',
containerPrompt: '旧容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/old/container.png',
status: 'image_ready',
error: null,
},
generatedItemAssets: [
{
...createReadyGeneratedItemAsset(1),
backgroundAsset: {
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/old/background.png',
containerPrompt: '旧容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/old/container.png',
status: 'image_ready',
error: null,
},
},
],
});
const nextBackgroundAsset = {
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/old/background.png',
containerPrompt: '新容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/new/container.png',
status: 'image_ready',
error: null,
};
const nextProfile = createProfile({
...profile,
generatedBackgroundAsset: nextBackgroundAsset,
generatedItemAssets: [
{
...profile.generatedItemAssets![0]!,
backgroundAsset: nextBackgroundAsset,
},
],
});
const onSaved = vi.fn();
vi.mocked(
match3dWorksService.generateMatch3DContainerImage,
).mockResolvedValue({
item: nextProfile,
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/new/container.png',
generatedBackgroundAsset: nextBackgroundAsset,
prompt: '新容器提示词',
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onSaved={onSaved}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '容器形象' }));
fireEvent.change(screen.getByLabelText('容器形象画面描述提示词'), {
target: { value: '新容器提示词' },
});
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
confirmPointCost();
await waitFor(() => {
expect(
match3dWorksService.generateMatch3DContainerImage,
).toHaveBeenCalledWith(profile.profileId, {
prompt: '新容器提示词',
});
expect(
match3dWorksService.generateMatch3DBackgroundImage,
).not.toHaveBeenCalled();
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
itemId: 'match3d-item-1',
backgroundAsset: expect.objectContaining({
prompt: '旧背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/old/background.png',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
}),
}),
],
}),
);
});
});
test('历史草稿同时带旧 draft 和 profile 素材时以 profile 多视角素材补齐试玩资产', async () => {
const draftAsset = {
...createReadyGeneratedItemAsset(1),
@@ -1178,9 +1556,7 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getByRole('button', { name: '打开草莓物品素材' }),
);
fireEvent.click(screen.getByRole('button', { name: '打开草莓物品素材' }));
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(
@@ -1211,8 +1587,7 @@ describe('Match3DResultView', () => {
});
});
test('物品音效提示词可编辑并用于生成音效', async () => {
const createTaskDeferred = createDeferred<AudioGenerationTaskResponse>();
test('物品详情隐藏点击音效生成入口', () => {
const profile = createProfile({
generatedItemAssets: [
{
@@ -1233,23 +1608,6 @@ describe('Match3DResultView', () => {
},
],
});
vi.mocked(creationAudioService.createSoundEffectTask).mockReturnValue(
createTaskDeferred.promise,
);
vi.mocked(creationAudioService.publishSoundEffectAsset).mockResolvedValue({
kind: 'sound_effect',
taskId: 'sound-task-1',
provider: 'vector-engine-vidu',
status: 'completed',
assetObjectId: 'asset-sound-1',
assetKind: 'match3d_click_sound',
audioSrc: '/generated-match3d-assets/audio/click.wav',
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: createProfile({ generatedItemAssets: profile.generatedItemAssets }),
});
render(
<Match3DResultView
@@ -1260,53 +1618,17 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getByRole('button', { name: '打开草莓物品素材' }),
);
fireEvent.change(screen.getByLabelText('草莓点击音效提示词'), {
target: { value: '草莓泡泡破裂音效' },
});
fireEvent.click(screen.getByRole('button', { name: /生成点击音效/u }));
fireEvent.click(screen.getByRole('button', { name: '打开草莓物品素材' }));
await waitFor(() => {
expect(creationAudioService.createSoundEffectTask).toHaveBeenCalledWith({
prompt: '草莓泡泡破裂音效',
duration: 3,
});
expect(screen.getByLabelText('音效生成中')).toBeTruthy();
});
createTaskDeferred.resolve({
kind: 'sound_effect',
taskId: 'sound-task-1',
provider: 'vector-engine-vidu',
status: 'submitted',
});
await waitFor(() => {
expect(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).toHaveBeenCalledWith(
'match3d-profile-1',
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
soundPrompt: '草莓泡泡破裂音效',
clickSound: expect.objectContaining({
audioSrc: '/generated-match3d-assets/audio/click.wav',
}),
}),
],
}),
);
expect(screen.getByLabelText('草莓点击音效').getAttribute('src')).toBe(
'https://signed.example.com/generated-match3d-assets/audio/click.wav',
);
});
expect(screen.getByRole('dialog', { name: /草莓/u })).toBeTruthy();
expect(screen.queryByLabelText('草莓点击音效提示词')).toBeNull();
expect(screen.queryByRole('button', { name: /生成点击音效/u })).toBeNull();
expect(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).not.toHaveBeenCalled();
});
test('背景音乐子 Tab 使用草稿生成的背景音乐参数并显示进度', async () => {
const createTaskDeferred = createDeferred<AudioGenerationTaskResponse>();
test('素材配置隐藏背景音乐子 Tab', () => {
const profile = createProfile({
generatedItemAssets: [
{
@@ -1330,25 +1652,6 @@ describe('Match3DResultView', () => {
},
],
});
vi.mocked(creationAudioService.createBackgroundMusicTask).mockReturnValue(
createTaskDeferred.promise,
);
vi.mocked(
creationAudioService.publishBackgroundMusicAsset,
).mockResolvedValue({
kind: 'background_music',
taskId: 'music-task-1',
provider: 'vector-engine-suno',
status: 'completed',
assetObjectId: 'asset-music-1',
assetKind: 'match3d_background_music',
audioSrc: '/generated-match3d-assets/audio/music.wav',
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: createProfile({ generatedItemAssets: profile.generatedItemAssets }),
});
render(
<Match3DResultView
@@ -1359,60 +1662,14 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
expect(screen.getByLabelText('抓大鹅背景音乐曲名')).toHaveProperty(
'value',
'果园轻舞',
);
expect(screen.getByLabelText('抓大鹅背景音乐风格')).toHaveProperty(
'value',
'轻快, 休闲',
);
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
expect(screen.queryByLabelText('抓大鹅背景音乐曲名')).toBeNull();
expect(screen.queryByLabelText('抓大鹅背景音乐风格')).toBeNull();
expect(screen.queryByLabelText('抓大鹅背景音乐提示词')).toBeNull();
expect(screen.getByRole('button', { name: /生成音乐 · 5泥点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成音乐/u }));
await waitFor(() => {
expect(
creationAudioService.createBackgroundMusicTask,
).toHaveBeenCalledWith({
prompt: '',
title: '果园轻舞',
tags: '轻快, 休闲',
});
expect(screen.getByLabelText('音乐生成中')).toBeTruthy();
});
createTaskDeferred.resolve({
kind: 'background_music',
taskId: 'music-task-1',
provider: 'vector-engine-suno',
status: 'submitted',
});
await waitFor(() => {
expect(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).toHaveBeenCalledWith(
'match3d-profile-1',
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
backgroundMusicTitle: '果园轻舞',
backgroundMusicStyle: '轻快, 休闲',
backgroundMusicPrompt: '',
backgroundMusic: expect.objectContaining({
audioSrc: '/generated-match3d-assets/audio/music.wav',
}),
}),
],
}),
);
expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-match3d-assets/audio/music.wav',
);
});
expect(screen.queryByRole('button', { name: /生成音乐/u })).toBeNull();
expect(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).not.toHaveBeenCalled();
});
test('背景音乐在非首个素材时仍显示并进入试玩 profile', async () => {
@@ -1455,14 +1712,7 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
await waitFor(() => {
expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-match3d-assets/audio/floating-song.mp3',
);
});
expect(screen.queryByText('暂无音乐')).toBeNull();
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
@@ -1473,8 +1723,7 @@ describe('Match3DResultView', () => {
expect.objectContaining({
itemId: 'match3d-item-1',
backgroundMusic: expect.objectContaining({
audioSrc:
'/generated-match3d-assets/audio/floating-song.mp3',
audioSrc: '/generated-match3d-assets/audio/floating-song.mp3',
}),
}),
]),

File diff suppressed because it is too large Load Diff