Files
Genarrative/src/components/match3d-result/Match3DResultView.test.tsx
高物 ae014ac881 Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
2026-05-22 03:06:41 +08:00

1858 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
import * as match3dWorksService from '../../services/match3d-works';
import { clearMatch3DGeneratedModelBytesCache } from '../../services/match3dGeneratedModelCache';
import { Match3DResultView } from './Match3DResultView';
const match3dSpritesheetParser = vi.hoisted(() => ({
loadMatch3DSpritesheetAssetRegions: vi.fn(),
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../services/assetReadUrlService', () => ({
isGeneratedLegacyPath: (value: string) =>
/^\/?generated-[^/?#]+\/.+/u.test(value.trim()),
resolveAssetReadUrl: vi.fn((value: string) =>
Promise.resolve(`https://signed.example.com/${value.replace(/^\/+/u, '')}`),
),
}));
vi.mock('../../services/match3d-works', () => ({
generateMatch3DBackgroundImage: vi.fn(),
generateMatch3DContainerImage: vi.fn(),
generateMatch3DCoverImage: vi.fn(),
generateMatch3DItemAssets: vi.fn(),
generateMatch3DWorkTags: vi.fn(),
publishMatch3DWork: vi.fn(),
updateMatch3DGeneratedItemAssets: vi.fn(),
updateMatch3DWork: vi.fn(),
}));
vi.mock('../../services/match3dSpritesheetParser', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../services/match3dSpritesheetParser')>();
return {
...actual,
loadMatch3DSpritesheetAssetRegions:
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
};
});
afterEach(() => {
clearMatch3DGeneratedModelBytesCache();
vi.clearAllMocks();
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockReset();
vi.unstubAllGlobals();
});
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
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 {
return {
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅', '经典消除'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-01T00:00:00.000Z',
publishedAt: null,
publishReady: false,
...overrides,
};
}
function createReadyGeneratedItemAsset(index: number) {
return {
itemId: `match3d-item-${index}`,
itemName: `${index}`,
imageSrc: `/generated-match3d-assets/session/profile/items/item-${index}/image.png`,
imageObjectKey: `generated-match3d-assets/session/profile/items/item-${index}/image.png`,
imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
viewId: `view-${String(viewIndex).padStart(2, '0')}`,
viewIndex,
imageSrc: `/generated-match3d-assets/session/profile/items/item-${index}/views/view-${String(viewIndex).padStart(2, '0')}.png`,
imageObjectKey: `generated-match3d-assets/session/profile/items/item-${index}/views/view-${String(viewIndex).padStart(2, '0')}.png`,
})),
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: `task-${index}`,
subscriptionKey: `sub-${index}`,
status: 'image_ready' as const,
error: null,
};
}
describe('Match3DResultView', () => {
test('作品信息 Tab 字段命名对齐拼图草稿且描述可为空', async () => {
const profile = createProfile();
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: profile,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
expect(screen.getByLabelText('作品名称')).toHaveProperty(
'value',
'水果抓大鹅',
);
expect(screen.getByLabelText('作品描述')).toHaveProperty('value', '');
expect(screen.getByText('作品标签')).toBeTruthy();
expect(screen.getByText('水果')).toBeTruthy();
expect(screen.getByText('抓大鹅')).toBeTruthy();
expect(screen.queryByRole('button', { name: '封面图' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(match3dWorksService.updateMatch3DWork).toHaveBeenCalledWith(
'match3d-profile-1',
expect.objectContaining({
gameName: '水果抓大鹅',
summary: '',
tags: ['水果', '抓大鹅', '经典消除'],
}),
);
});
});
test('作品标签支持 AI 生成并写回标签编辑区', async () => {
vi.mocked(match3dWorksService.generateMatch3DWorkTags).mockResolvedValue({
tags: ['果园', '抓大鹅', '经典消除', '轻量休闲'],
});
render(
<Match3DResultView
profile={createProfile({ tags: [] })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
await waitFor(() => {
expect(match3dWorksService.generateMatch3DWorkTags).toHaveBeenCalledWith({
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
});
expect(screen.getByText('果园')).toBeTruthy();
expect(screen.getByText('轻量休闲')).toBeTruthy();
});
});
test('发布面板内支持引用物品素材作为多参考图生成封面', async () => {
const profile = createProfile({
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/i1/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/i1/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
});
const nextProfile = createProfile({
...profile,
coverImageSrc:
'/generated-match3d-assets/session/profile/cover/task/cover.png',
});
const onSaved = vi.fn();
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={() => {}}
onSaved={onSaved}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '发布' }));
const publishDialog = screen.getByRole('dialog', {
name: '发布抓大鹅作品',
});
fireEvent.click(
within(publishDialog).getByRole('button', { name: '引用草莓' }),
);
fireEvent.change(within(publishDialog).getByLabelText('封面描述'), {
target: { value: '草莓抓大鹅封面图' },
});
fireEvent.click(
within(publishDialog).getByRole('button', { name: '生成封面图' }),
);
await waitFor(() => {
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.getByRole('dialog', { name: '发布抓大鹅作品' }),
).toBeTruthy();
});
});
test('生成封面图只更新封面字段,不用旧回包覆盖当前物品素材和配置', async () => {
const generatedItemAssets = [createReadyGeneratedItemAsset(1)];
const profile = createProfile({
clearCount: 12,
difficulty: 4,
generatedItemAssets,
});
const staleResponseProfile = createProfile({
...profile,
coverImageSrc:
'/generated-match3d-assets/session/profile/cover/task/cover.png',
clearCount: 8,
difficulty: 2,
generatedItemAssets: [],
});
const onSaved = vi.fn();
vi.mocked(match3dWorksService.generateMatch3DCoverImage).mockResolvedValue({
item: staleResponseProfile,
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={() => {}}
onSaved={onSaved}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '发布' }));
const publishDialog = screen.getByRole('dialog', {
name: '发布抓大鹅作品',
});
fireEvent.change(within(publishDialog).getByLabelText('封面描述'), {
target: { value: '水果封面图' },
});
fireEvent.click(
within(publishDialog).getByRole('button', { name: '生成封面图' }),
);
await waitFor(() => {
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({
coverImageSrc:
'/generated-match3d-assets/session/profile/cover/task/cover.png',
clearCount: 12,
difficulty: 4,
generatedItemAssets: expect.arrayContaining([
expect.objectContaining({
itemId: 'match3d-item-1',
imageViews: expect.arrayContaining([
expect.objectContaining({ viewId: 'view-01' }),
]),
}),
]),
}),
);
});
});
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: '发布' }));
const publishDialog = screen.getByRole('dialog', {
name: '发布抓大鹅作品',
});
fireEvent.change(
within(publishDialog).getByLabelText('上传封面图', {
selector: 'input',
}),
{
target: {
files: [new File(['x'], 'cover.png', { type: 'image/png' })],
},
},
);
await waitFor(() => {
expect(
within(publishDialog).getByRole('switch', { name: 'AI重绘' }),
).toBeTruthy();
expect(
within(publishDialog).getByRole('button', { name: '移除封面图' }),
).toBeTruthy();
expect(within(publishDialog).getByLabelText('AI重绘要求')).toBeTruthy();
});
expect(within(publishDialog).queryByText('参考图')).toBeNull();
fireEvent.change(within(publishDialog).getByLabelText('AI重绘要求'), {
target: { value: '保留构图,改成节日果园' },
});
fireEvent.click(
within(publishDialog).getByRole('button', { name: '生成封面图' }),
);
await waitFor(() => {
expect(
match3dWorksService.generateMatch3DCoverImage,
).toHaveBeenCalledWith(profile.profileId, {
prompt: '保留构图,改成节日果园',
uploadedImageSrc: uploadedDataUrl,
referenceImageSrcs: [],
});
});
});
test('试玩只要求基础配置可保存,不被发布封面门槛阻断', async () => {
const profile = createProfile();
const onStartTestRun = vi.fn();
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: profile,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(match3dWorksService.updateMatch3DWork).toHaveBeenCalledWith(
'match3d-profile-1',
expect.objectContaining({
clearCount: 12,
difficulty: 4,
gameName: '水果抓大鹅',
}),
);
});
expect(onStartTestRun).toHaveBeenCalledWith(profile, {
itemTypeCountOverride: 1,
});
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
});
test('试玩前保存响应缺少素材时仍把当前生成 2D 素材带入运行态', async () => {
const generatedItemAssets = [createReadyGeneratedItemAsset(1)];
const profile = createProfile({ generatedItemAssets });
const savedProfile = createProfile({ generatedItemAssets: [] });
const persistedProfile = createProfile({ generatedItemAssets });
const onStartTestRun = vi.fn();
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: savedProfile,
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: persistedProfile,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).toHaveBeenCalledWith(
profile.profileId,
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
itemId: 'match3d-item-1',
imageViews: expect.arrayContaining([
expect.objectContaining({ viewId: 'view-01' }),
]),
status: 'image_ready',
}),
],
}),
);
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
profileId: profile.profileId,
generatedItemAssets: [
expect.objectContaining({
itemId: 'match3d-item-1',
imageViews: expect.arrayContaining([
expect.objectContaining({ viewId: 'view-01' }),
]),
status: 'image_ready',
}),
],
}),
{
itemTypeCountOverride: 1,
},
);
});
});
test('发布仍要求封面和标签数量满足门槛', () => {
render(
<Match3DResultView
profile={createProfile({ tags: ['水果', '抓大鹅'] })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
const publishButton = screen.getByRole('button', { name: '发布' });
expect(publishButton).toHaveProperty('disabled', false);
fireEvent.click(publishButton);
const publishDialog = screen.getByRole('dialog', {
name: '发布抓大鹅作品',
});
expect(within(publishDialog).getByText('封面图不能为空。')).toBeTruthy();
expect(
within(publishDialog).getByText('标签数量需要在 3 到 6 个之间。'),
).toBeTruthy();
expect(
within(publishDialog).getByRole('button', { name: '发布到广场' }),
).toHaveProperty('disabled', true);
fireEvent.click(
within(publishDialog).getByRole('button', { name: '发布到广场' }),
);
expect(within(publishDialog).getByText('封面图不能为空。')).toBeTruthy();
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
});
test('发布要求当前难度素材都具备五个视角', () => {
const generatedItemAssets = [
createReadyGeneratedItemAsset(1),
createReadyGeneratedItemAsset(2),
{
...createReadyGeneratedItemAsset(3),
imageViews: createReadyGeneratedItemAsset(3).imageViews?.slice(0, 4),
},
];
render(
<Match3DResultView
profile={createProfile({
summary: '轻松消除水果',
coverImageSrc: 'data:image/png;base64,cover',
clearCount: 8,
difficulty: 2,
generatedItemAssets,
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
const publishButton = screen.getByRole('button', { name: '发布' });
expect(publishButton).toHaveProperty('disabled', false);
fireEvent.click(publishButton);
const publishDialog = screen.getByRole('dialog', {
name: '发布抓大鹅作品',
});
expect(
within(publishDialog).getByText(
'当前难度需要 3 种物品,已生成 2 种,请先在素材配置中补齐。',
),
).toBeTruthy();
expect(
within(publishDialog).getByRole('button', { name: '发布到广场' }),
).toHaveProperty('disabled', true);
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
fireEvent.click(within(publishDialog).getByRole('button', { name: '取消' }));
fireEvent.click(screen.getByRole('button', { name: '难度配置' }));
expect(screen.getByText('已生成物品种类')).toBeTruthy();
expect(screen.getAllByText('2 种').length).toBeGreaterThan(0);
});
test('发布前会先把当前 2D 多视角素材写回 profile', async () => {
const generatedItemAssets = [
createReadyGeneratedItemAsset(1),
createReadyGeneratedItemAsset(2),
createReadyGeneratedItemAsset(3),
createReadyGeneratedItemAsset(4),
createReadyGeneratedItemAsset(5),
createReadyGeneratedItemAsset(6),
createReadyGeneratedItemAsset(7),
createReadyGeneratedItemAsset(8),
createReadyGeneratedItemAsset(9),
];
const profile = createProfile({
summary: '轻松消除水果',
coverImageSrc: 'data:image/png;base64,cover',
generatedItemAssets,
});
const savedProfile = createProfile({
...profile,
generatedItemAssets: [],
});
const onPublished = vi.fn();
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: savedProfile,
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: profile,
});
vi.mocked(match3dWorksService.publishMatch3DWork).mockResolvedValue({
item: createProfile({
...profile,
publicationStatus: 'published',
generatedItemAssets: [],
}),
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onPublished={onPublished}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '发布' }));
fireEvent.click(
within(
screen.getByRole('dialog', { name: '发布抓大鹅作品' }),
).getByRole('button', { name: '发布到广场' }),
);
await waitFor(() => {
expect(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).toHaveBeenCalledWith(
profile.profileId,
expect.objectContaining({
generatedItemAssets: expect.arrayContaining([
expect.objectContaining({
itemId: 'match3d-item-1',
imageViews: expect.arrayContaining([
expect.objectContaining({ viewId: 'view-01' }),
]),
status: 'image_ready',
}),
]),
}),
);
expect(match3dWorksService.publishMatch3DWork).toHaveBeenCalledWith(
profile.profileId,
);
expect(onPublished).toHaveBeenCalledWith(
expect.objectContaining({
publicationStatus: 'published',
generatedItemAssets: expect.arrayContaining([
expect.objectContaining({
itemId: 'match3d-item-1',
imageViews: expect.arrayContaining([
expect.objectContaining({ viewId: 'view-01' }),
]),
}),
]),
}),
);
});
const assetPersistCallOrder = vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mock.invocationCallOrder[0];
const publishCallOrder = vi.mocked(match3dWorksService.publishMatch3DWork)
.mock.invocationCallOrder[0];
expect(assetPersistCallOrder).toBeDefined();
expect(publishCallOrder).toBeDefined();
expect(assetPersistCallOrder!).toBeLessThan(publishCallOrder!);
});
test('结果页提供多 Tab物品素材点击后进入独立预览面板', () => {
render(
<Match3DResultView
profile={createProfile({ themeText: '水果' })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
expect(screen.getByRole('button', { name: '难度配置' })).toBeTruthy();
expect(screen.getByRole('button', { name: '素材配置' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByRole('button', { name: '物品' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'UI素材' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
expect(screen.getAllByRole('button', { name: /打开.+物品素材/u }))
.toHaveLength(20);
fireEvent.click(
screen.getByRole('button', { name: '打开水果核心物件物品素材' }),
);
expect(screen.getByRole('dialog', { name: /水果核心物件/u })).toBeTruthy();
expect(screen.getByText('素材名称')).toBeTruthy();
expect(screen.queryByText('暂无音效')).toBeNull();
expect(screen.queryByLabelText('生成点击音效10泥点')).toBeNull();
expect(screen.queryByRole('button', { name: '重新生成' })).toBeNull();
expect(screen.queryByText('用途')).toBeNull();
});
test('物品素材列表支持删除单项并写回剩余素材', async () => {
const profile = createProfile({
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/i1/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/i1/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc:
'/generated-match3d-assets/session/profile/items/i2/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/i2/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: createProfile({
generatedItemAssets: [profile.generatedItemAssets![1]!],
}),
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getAllByRole('button', { name: '删除物品素材' })[0]!,
);
await waitFor(() => {
expect(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).toHaveBeenCalledWith(
profile.profileId,
expect.objectContaining({
generatedItemAssets: expect.arrayContaining([
expect.objectContaining({
itemId: 'match3d-item-2',
itemName: '苹果',
}),
]),
}),
);
});
expect(
screen.queryByRole('button', { name: '打开草莓物品素材' }),
).toBeNull();
expect(
screen.getByRole('button', { name: '打开苹果物品素材' }),
).toBeTruthy();
});
test('批量新增物品会解析名称并调用作品素材生成接口', async () => {
const generatedItemAssets = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/i1/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/i1/image.png',
modelSrc:
'/generated-match3d-assets/session/profile/items/i1/model/model.glb',
modelObjectKey:
'generated-match3d-assets/session/profile/items/i1/model/model.glb',
modelFileName: 'model.glb',
taskUuid: 'task-1',
subscriptionKey: 'sub-1',
status: 'model_ready',
error: null,
},
];
const profile = createProfile({ generatedItemAssets: [] });
const onSaved = vi.fn();
vi.mocked(match3dWorksService.generateMatch3DItemAssets).mockResolvedValue({
item: createProfile({ generatedItemAssets }),
generatedItemAssets,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onSaved={onSaved}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '批量新增' }));
fireEvent.change(screen.getByLabelText('物品名称 1'), {
target: { value: '草莓' },
});
fireEvent.click(screen.getByRole('button', { name: '新增物品名称' }));
fireEvent.change(screen.getByLabelText('物品名称 2'), {
target: { value: '苹果' },
});
fireEvent.click(screen.getByRole('button', { name: '新增物品名称' }));
fireEvent.change(screen.getByLabelText('物品名称 3'), {
target: { value: '蓝莓' },
});
fireEvent.click(screen.getByRole('button', { name: '新增物品名称' }));
fireEvent.change(screen.getByLabelText('物品名称 4'), {
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: ['草莓', '苹果', '蓝莓'],
});
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({ generatedItemAssets }),
);
});
expect(screen.getByRole('dialog', { name: '批量新增物品' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(screen.queryByRole('dialog', { name: '批量新增物品' })).toBeNull();
expect(
screen.getByRole('button', { name: '打开草莓物品素材' }),
).toBeTruthy();
});
test('批量新增面板关闭后素材列表继续显示生成进度', async () => {
const deferred = createDeferred<{
item: Match3DWorkProfile;
generatedItemAssets: NonNullable<
Match3DWorkProfile['generatedItemAssets']
>;
}>();
vi.mocked(match3dWorksService.generateMatch3DItemAssets).mockReturnValue(
deferred.promise,
);
render(
<Match3DResultView
profile={createProfile({ generatedItemAssets: [] })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '批量新增' }));
fireEvent.change(screen.getByLabelText('物品名称 1'), {
target: { value: '草莓' },
});
fireEvent.click(screen.getByRole('button', { name: /生成物品素材/u }));
confirmPointCost();
await waitFor(() => {
expect(screen.getAllByText('生成中').length).toBeGreaterThan(0);
});
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(screen.queryByRole('dialog', { name: '批量新增物品' })).toBeNull();
expect(screen.getByLabelText('物品素材生成进度')).toBeTruthy();
deferred.resolve({
item: createProfile({
generatedItemAssets: [createReadyGeneratedItemAsset(1)],
}),
generatedItemAssets: [createReadyGeneratedItemAsset(1)],
});
await waitFor(() => {
expect(screen.getByText('生成完成')).toBeTruthy();
expect(
screen.getByRole('button', { name: '打开物品1物品素材' }),
).toBeTruthy();
});
});
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({
item: createProfile({ clearCount: 21, difficulty: 8 }),
});
render(
<Match3DResultView
profile={createProfile({ clearCount: 12, difficulty: 4 })}
onBack={() => {}}
onSaved={onSaved}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '难度配置' }));
expect(screen.getByRole('button', { name: '轻松 8次 · 3种' })).toBeTruthy();
const difficultySlider = screen.getByRole('slider', { name: '难度' });
expect((difficultySlider as HTMLInputElement).value).toBe('1');
expect(
screen
.getByRole('button', { name: '标准 12次 · 9种' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.getByText('36 件')).toBeTruthy();
expect(screen.getAllByText('9 种').length).toBeGreaterThan(0);
fireEvent.change(difficultySlider, { target: { value: '3' } });
await waitFor(() => {
expect(match3dWorksService.updateMatch3DWork).toHaveBeenCalledWith(
'match3d-profile-1',
expect.objectContaining({
clearCount: 21,
difficulty: 8,
}),
);
});
expect(screen.getByText('63 件')).toBeTruthy();
expect(screen.getAllByText('20 种').length).toBeGreaterThan(0);
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({
clearCount: 21,
difficulty: 8,
}),
);
});
test('试玩使用当前 2D 多视角素材进入运行态', async () => {
const generatedItemAssets = [createReadyGeneratedItemAsset(1)];
const profile = createProfile({ generatedItemAssets });
const onStartTestRun = vi.fn();
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: profile,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
itemId: 'match3d-item-1',
imageViews: expect.arrayContaining([
expect.objectContaining({ viewId: 'view-01' }),
]),
}),
],
}),
{
itemTypeCountOverride: 1,
},
);
});
});
test('结果页优先展示生成出来的 2D 视角素材', () => {
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [createReadyGeneratedItemAsset(1)],
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
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',
),
),
).toBe(true);
});
test('物品详情五视角预览不混入兼容首图', () => {
const generatedAsset = createReadyGeneratedItemAsset(1);
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [
{
...generatedAsset,
imageSrc:
'/generated-match3d-assets/session/profile/items/item-1/legacy-primary.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/item-1/legacy-primary.png',
},
],
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '打开物品1物品素材' }));
const imageSources = [...document.querySelectorAll('img')].map(
(image) => image.getAttribute('src') ?? '',
);
expect(
imageSources.some((source) => source.includes('legacy-primary.png')),
).toBe(false);
expect(
imageSources.some((source) => source.includes('views/view-05.png')),
).toBe(true);
});
test('物品详情五视角预览使用上方大图和底部缩略图栏', () => {
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [createReadyGeneratedItemAsset(1)],
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '打开物品1物品素材' }));
const preview = screen.getByLabelText('物品1五视角预览');
const stage = screen.getByTestId('match3d-item-preview-stage');
const focusImage = screen.getByTestId('match3d-item-preview-focus-image');
const thumbnails = screen.getByTestId('match3d-item-preview-thumbnails');
expect(stage.className).toContain('aspect-square');
expect(stage.className).toContain('max-w-[22rem]');
expect(focusImage.className).toContain('place-items-center');
expect(focusImage.querySelector('img')?.className).toContain('p-3');
expect(thumbnails.style.gridAutoColumns).toBe('calc((100% - 1.5rem) / 4)');
expect(preview.querySelectorAll('img')).toHaveLength(6);
expect(
screen
.getByRole('button', { name: '切换物品1视角3' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.queryByTestId('match3d-item-preview-focus-frame'),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '切换物品1视角5' }));
expect(
screen
.getByTestId('match3d-item-preview-focus-image')
.getAttribute('data-preview-src'),
).toContain('views/view-05.png');
});
test('草稿阶段仅有切割图片时展示 2D 素材', () => {
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/strawberry/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/strawberry/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '打开草莓物品素材' }));
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(
[...document.querySelectorAll('img')].some((image) =>
image.getAttribute('src')?.includes('items/strawberry/image.png'),
),
).toBe(true);
expect(screen.queryByRole('link', { name: /\.glb/u })).toBeNull();
});
test('重进草稿页时从持久化 profile 素材恢复 2D 素材列表', () => {
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-2-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-2-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
})}
draft={null}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(
screen.getByRole('button', { name: '打开草莓物品素材' }),
).toBeTruthy();
expect(
screen.getByRole('button', { name: '打开苹果物品素材' }),
).toBeTruthy();
expect(
screen.queryByRole('button', { name: '打开水果核心物件物品素材' }),
).toBeNull();
});
test('素材配置 UI素材子 Tab 仅预览背景图和UI spritesheet', () => {
render(
<Match3DResultView
profile={createProfile({
backgroundPrompt: '果园主题抓大鹅竖屏背景',
backgroundImageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
generatedBackgroundAsset: {
prompt: '果园主题抓大鹅竖屏背景',
levelScenePrompt: '果园完整关卡画面',
levelSceneImageSrc:
'/generated-match3d-assets/session/profile/level-scene/scene.png',
levelSceneImageObjectKey: null,
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey: null,
uiSpritesheetPrompt: 'UI spritesheet',
uiSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
uiSpritesheetImageObjectKey: null,
containerPrompt: null,
containerImageSrc: null,
containerImageObjectKey: null,
status: 'image_ready',
error: null,
},
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI素材' }));
expect(screen.getByAltText('游戏背景图').getAttribute('src')).toBe(
'/generated-match3d-assets/session/profile/background/background.png',
);
expect(screen.getByAltText('UI素材图').getAttribute('src')).toBe(
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
);
expect(
screen.queryByLabelText('UI背景图画面描述提示词'),
).toBeNull();
expect(screen.queryByRole('button', { name: /重新生成/u })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
expect(screen.getByRole('dialog', { name: 'UI预览' })).toBeTruthy();
expect(screen.getByText('第 1 关')).toBeTruthy();
expect(screen.getByText('抓大鹅')).toBeTruthy();
expect(screen.getByText('1:30')).toBeTruthy();
const previewBoard = screen.getByTestId('match3d-ui-preview-board');
expect(previewBoard.className).toContain('bg-transparent');
expect(previewBoard.className).not.toContain('rounded-full');
const containerImage = document.querySelector(
'img[src="/match3d-background-references/pot-fused-reference.png"]',
);
expect(containerImage).toBeTruthy();
expect(containerImage?.className).toContain('w-[min(116vw,42rem)]');
expect(containerImage?.className).toContain('-translate-x-1/2');
expect(
document.querySelector('.animate-spin, [class*="border-l-transparent"]'),
).toBeNull();
expect(
document.querySelector(
'svg[class*="lucide-settings"], [data-lucide="settings"]',
),
).toBeTruthy();
});
test('素材配置 UI素材子 Tab 从物品挂载资产展示生成背景和UI spritesheet', async () => {
const onStartTestRun = vi.fn();
const profile = createProfile({
backgroundPrompt: null,
backgroundImageSrc: null,
backgroundImageObjectKey: null,
generatedBackgroundAsset: null,
generatedItemAssets: [
{
...createReadyGeneratedItemAsset(1),
itemName: '草莓',
backgroundAsset: {
prompt: '果园背景',
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/background.png',
uiSpritesheetPrompt: '果园UI素材',
uiSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
uiSpritesheetImageObjectKey:
'generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
containerPrompt: null,
containerImageSrc: null,
containerImageObjectKey: null,
status: 'image_ready',
error: null,
},
},
],
});
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: createProfile({ generatedItemAssets: [] }),
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: profile,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI素材' }));
expect(screen.getByAltText('游戏背景图').getAttribute('src')).toBe(
'/generated-match3d-assets/session/profile/background/background.png',
);
expect(screen.getByAltText('UI素材图').getAttribute('src')).toBe(
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
);
expect(screen.queryByLabelText('UI背景图画面描述提示词')).toBeNull();
expect(screen.queryByRole('button', { name: /重新生成/u })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
expect(
document.querySelector(
'img[src="/generated-match3d-assets/session/profile/background/background.png"]',
),
).toBeTruthy();
expect(
document.querySelector(
'img[src="/generated-match3d-assets/session/profile/ui-spritesheet/ui.png"]',
),
).toBeTruthy();
expect(
document.querySelector(
'img[src="/match3d-background-references/pot-fused-reference.png"]',
),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
backgroundPrompt: '果园背景',
backgroundImageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
generatedBackgroundAsset: expect.objectContaining({
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
uiSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
}),
}),
{
itemTypeCountOverride: 1,
},
);
});
});
test('素材配置 UI素材子 Tab 预览物品spritesheet解析结果', async () => {
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue(
Array.from({ length: 100 }, (_, index) => ({
label: `${index + 1}`,
x: index * 2,
y: 0,
width: 2,
height: 2,
sheetWidth: 200,
sheetHeight: 2,
imageSrc: `data:image/png;base64,item-${index + 1}`,
})),
);
render(
<Match3DResultView
profile={createProfile({
generatedItemAssets: [
{ ...createReadyGeneratedItemAsset(1), itemName: '草莓' },
{ ...createReadyGeneratedItemAsset(2), itemName: '苹果' },
],
generatedBackgroundAsset: {
prompt: '果园主题抓大鹅竖屏背景',
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey: null,
uiSpritesheetPrompt: 'UI spritesheet',
uiSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
uiSpritesheetImageObjectKey: null,
itemSpritesheetPrompt: '物品 spritesheet',
itemSpritesheetImageSrc:
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
itemSpritesheetImageObjectKey: null,
status: 'image_ready',
error: null,
},
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI素材' }));
expect(screen.getByAltText('物品素材图').getAttribute('src')).toBe(
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
);
await waitFor(() => {
expect(
screen
.getByTestId('match3d-item-spritesheet-preview-0-0')
.getAttribute('src'),
).toBe('data:image/png;base64,item-1');
expect(
screen
.getByTestId('match3d-item-spritesheet-preview-1-4')
.getAttribute('src'),
).toBe('data:image/png;base64,item-10');
});
expect(screen.getByText('草莓')).toBeTruthy();
expect(screen.getByText('苹果')).toBeTruthy();
expect(
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
).toHaveBeenCalledWith(
expect.objectContaining({
maxRegions: 100,
source:
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
}),
);
});
test('素材配置 UI素材子 Tab 不提供背景或容器重新生成入口', () => {
const profile = createProfile({
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
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,
},
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: 'UI素材' }));
expect(screen.queryByLabelText('UI背景图画面描述提示词')).toBeNull();
expect(screen.queryByLabelText('容器形象画面描述提示词')).toBeNull();
expect(screen.queryByRole('button', { name: '容器形象' })).toBeNull();
expect(screen.queryByRole('button', { name: /重新生成/u })).toBeNull();
expect(match3dWorksService.generateMatch3DBackgroundImage).not.toHaveBeenCalled();
expect(match3dWorksService.generateMatch3DContainerImage).not.toHaveBeenCalled();
});
test('历史草稿同时带旧 draft 和 profile 素材时以 profile 多视角素材补齐试玩资产', async () => {
const draftAsset = {
...createReadyGeneratedItemAsset(1),
imageViews: [],
};
const profileAsset = {
...createReadyGeneratedItemAsset(1),
itemName: '草莓',
};
const profile = createProfile({ generatedItemAssets: [profileAsset] });
const onStartTestRun = vi.fn();
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: createProfile({ generatedItemAssets: [] }),
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: createProfile({ generatedItemAssets: [profileAsset] }),
});
render(
<Match3DResultView
profile={profile}
draft={{
profileId: profile.profileId,
gameName: profile.gameName,
themeText: profile.themeText,
summary: profile.summary,
tags: profile.tags,
coverImageSrc: null,
referenceImageSrc: null,
clearCount: profile.clearCount,
difficulty: profile.difficulty,
generatedItemAssets: [draftAsset],
}}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '打开草莓物品素材' }));
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(
[...document.querySelectorAll('img')].some((image) =>
image.getAttribute('src')?.includes('views/view-01.png'),
),
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
itemId: 'match3d-item-1',
imageViews: expect.arrayContaining([
expect.objectContaining({ viewId: 'view-01' }),
]),
status: 'image_ready',
}),
],
}),
{
itemTypeCountOverride: 1,
},
);
});
});
test('物品详情隐藏点击音效生成入口', () => {
const profile = createProfile({
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
soundPrompt: '草莓清脆点击音效',
status: 'image_ready',
error: null,
},
],
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '打开草莓物品素材' }));
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', () => {
const profile = createProfile({
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
soundPrompt: '草莓点击音效',
backgroundMusicTitle: '果园轻舞',
backgroundMusicStyle: '轻快, 休闲',
backgroundMusicPrompt: '果园主题循环背景音乐',
status: 'image_ready',
error: null,
},
],
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
expect(screen.queryByLabelText('抓大鹅背景音乐曲名')).toBeNull();
expect(screen.queryByLabelText('抓大鹅背景音乐风格')).toBeNull();
expect(screen.queryByLabelText('抓大鹅背景音乐提示词')).toBeNull();
expect(screen.queryByRole('button', { name: /生成音乐/u })).toBeNull();
expect(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).not.toHaveBeenCalled();
});
test('背景音乐在非首个素材时仍显示并进入试玩 profile', async () => {
const onStartTestRun = vi.fn();
const generatedItemAssets = [
createReadyGeneratedItemAsset(1),
{
...createReadyGeneratedItemAsset(2),
backgroundMusicTitle: '漂浮船歌',
backgroundMusicStyle: '轻快, 愉悦, 现代',
backgroundMusicPrompt: '',
backgroundMusic: {
taskId: 'music-task-2',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-2',
assetKind: 'match3d_background_music',
audioSrc: '/generated-match3d-assets/audio/floating-song.mp3',
prompt: '',
title: '漂浮船歌',
updatedAt: '2026-05-14T00:00:00.000Z',
},
},
];
const profile = createProfile({ generatedItemAssets });
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: createProfile({ generatedItemAssets: [] }),
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: createProfile({ generatedItemAssets }),
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
generatedItemAssets: expect.arrayContaining([
expect.objectContaining({
itemId: 'match3d-item-1',
backgroundMusic: expect.objectContaining({
audioSrc: '/generated-match3d-assets/audio/floating-song.mp3',
}),
}),
]),
}),
expect.objectContaining({ itemTypeCountOverride: 2 }),
);
});
});
});