// @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 ?
: 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();
return {
...actual,
loadMatch3DSpritesheetAssetRegions:
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
};
});
afterEach(() => {
clearMatch3DGeneratedModelBytesCache();
vi.clearAllMocks();
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockReset();
vi.unstubAllGlobals();
});
function createDeferred() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise((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 {
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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(
{}}
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 }),
);
});
});
});