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:
@@ -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
Reference in New Issue
Block a user