446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||
|
||
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
|
||
import * as assetReadUrlService from '../../services/assetReadUrlService';
|
||
import * as hyper3dService from '../../services/hyper3dModelGenerationService';
|
||
import * as match3dWorksService from '../../services/match3d-works';
|
||
import { Match3DResultView } from './Match3DResultView';
|
||
|
||
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('./Match3DModelPreview', () => ({
|
||
Match3DModelPreview: ({
|
||
modelSrc,
|
||
}: {
|
||
modelSrc?: string | null;
|
||
}) => (
|
||
<div data-model-src={modelSrc ?? ''} data-testid="match3d-model-preview" />
|
||
),
|
||
}));
|
||
|
||
vi.mock('../../services/assetReadUrlService', () => ({
|
||
readAssetBytes: vi.fn(() =>
|
||
Promise.resolve(
|
||
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
|
||
status: 200,
|
||
headers: {
|
||
'Content-Type': 'image/png',
|
||
},
|
||
}),
|
||
),
|
||
),
|
||
}));
|
||
|
||
vi.mock('../../services/match3d-works', () => ({
|
||
generateMatch3DWorkTags: vi.fn(),
|
||
publishMatch3DWork: vi.fn(),
|
||
updateMatch3DWork: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/hyper3dModelGenerationService', () => ({
|
||
getHyper3dDownloads: vi.fn(),
|
||
getHyper3dTaskStatus: vi.fn(),
|
||
submitHyper3dImageToModel: vi.fn(),
|
||
}));
|
||
|
||
afterEach(() => {
|
||
vi.clearAllMocks();
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
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: 4,
|
||
difficulty: 3,
|
||
publicationStatus: 'draft',
|
||
playCount: 0,
|
||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||
publishedAt: null,
|
||
publishReady: false,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
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();
|
||
|
||
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: '水果',
|
||
});
|
||
expect(screen.getByText('果园')).toBeTruthy();
|
||
expect(screen.getByText('轻量休闲')).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
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: 4,
|
||
difficulty: 3,
|
||
gameName: '水果抓大鹅',
|
||
}),
|
||
);
|
||
});
|
||
expect(onStartTestRun).toHaveBeenCalledWith(profile);
|
||
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('发布仍要求封面和标签数量满足门槛', () => {
|
||
render(
|
||
<Match3DResultView
|
||
profile={createProfile({ tags: ['水果', '抓大鹅'] })}
|
||
onBack={() => {}}
|
||
onStartTestRun={() => {}}
|
||
/>,
|
||
);
|
||
|
||
const publishButton = screen.getByRole('button', { name: '发布' });
|
||
expect(publishButton).toHaveProperty('disabled', true);
|
||
|
||
fireEvent.click(publishButton);
|
||
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('结果页提供多 Tab,并能进入 Rodin 3D 素材详情', () => {
|
||
render(
|
||
<Match3DResultView
|
||
profile={createProfile({ themeText: '水果' })}
|
||
onBack={() => {}}
|
||
onStartTestRun={() => {}}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: '玩法配置' })).toBeTruthy();
|
||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /水果核心物件/u }));
|
||
|
||
expect(screen.getByText('素材名称')).toBeTruthy();
|
||
expect(screen.getByTestId('match3d-model-preview')).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: '重新生成' })).toBeTruthy();
|
||
expect(screen.queryByRole('button', { name: '文生模型' })).toBeNull();
|
||
expect(screen.queryByRole('button', { name: '图生模型' })).toBeNull();
|
||
expect(screen.queryByText('用途')).toBeNull();
|
||
expect(screen.queryByText('提示词')).toBeNull();
|
||
});
|
||
|
||
test('重新生成缺少参考图时阻止提交', async () => {
|
||
render(
|
||
<Match3DResultView
|
||
profile={createProfile({ themeText: '水果' })}
|
||
onBack={() => {}}
|
||
onStartTestRun={() => {}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||
fireEvent.click(screen.getByRole('button', { name: /水果核心物件/u }));
|
||
|
||
const generateButton = screen.getByRole('button', { name: '重新生成' });
|
||
expect(generateButton).toHaveProperty('disabled', true);
|
||
expect(hyper3dService.submitHyper3dImageToModel).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('结果页优先把生成出来的模型文件交给模型预览', () => {
|
||
const modelSrc =
|
||
'/generated-match3d-assets/session/profile/items/strawberry/model/model.glb';
|
||
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,
|
||
modelObjectKey:
|
||
'generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
|
||
modelFileName: 'strawberry.glb',
|
||
taskUuid: 'task-strawberry',
|
||
subscriptionKey: 'sub-strawberry',
|
||
status: 'model_ready',
|
||
error: null,
|
||
},
|
||
],
|
||
})}
|
||
onBack={() => {}}
|
||
onStartTestRun={() => {}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||
fireEvent.click(screen.getByRole('button', { name: /草莓/u }));
|
||
|
||
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
|
||
expect(screen.getAllByText('已完成').length).toBeGreaterThan(0);
|
||
expect(screen.getByTestId('match3d-model-preview').getAttribute('data-model-src')).toBe(
|
||
modelSrc,
|
||
);
|
||
});
|
||
|
||
test('草稿阶段仅有切割图片时模型预览为空', () => {
|
||
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: '3D素材' }));
|
||
fireEvent.click(screen.getByRole('button', { name: /草莓/u }));
|
||
|
||
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
|
||
expect(screen.getAllByText('图片已就绪').length).toBeGreaterThan(0);
|
||
expect(screen.getByTestId('match3d-model-preview').getAttribute('data-model-src')).toBe('');
|
||
expect(screen.queryByRole('link', { name: /\.glb/u })).toBeNull();
|
||
});
|
||
|
||
test('重进草稿页时从持久化 profile 素材恢复 3D 素材列表', () => {
|
||
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: '3D素材' }));
|
||
|
||
expect(screen.getByRole('button', { name: /草莓/u })).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: /苹果/u })).toBeTruthy();
|
||
expect(screen.queryByRole('button', { name: /水果核心物件/u })).toBeNull();
|
||
});
|
||
|
||
test('Rodin 图生模型提交前会把 generated 参考图转成 data URL', async () => {
|
||
vi.mocked(hyper3dService.submitHyper3dImageToModel).mockResolvedValue({
|
||
ok: true,
|
||
provider: 'hyper3d-rodin',
|
||
mode: 'image-to-model',
|
||
taskUuid: 'task-image',
|
||
subscriptionKey: 'sub-image',
|
||
jobUuids: ['job-image'],
|
||
message: 'submitted',
|
||
tier: 'Gen-2',
|
||
});
|
||
vi.mocked(hyper3dService.getHyper3dTaskStatus).mockResolvedValue({
|
||
ok: true,
|
||
provider: 'hyper3d-rodin',
|
||
status: 'done',
|
||
jobs: [],
|
||
raw: {},
|
||
});
|
||
vi.mocked(hyper3dService.getHyper3dDownloads).mockResolvedValue({
|
||
ok: true,
|
||
provider: 'hyper3d-rodin',
|
||
files: [
|
||
{
|
||
name: 'strawberry.glb',
|
||
url: 'https://cdn.example.com/strawberry.glb',
|
||
},
|
||
],
|
||
raw: {},
|
||
});
|
||
vi.stubGlobal('fetch', vi.fn());
|
||
|
||
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,
|
||
},
|
||
],
|
||
})}
|
||
onBack={() => {}}
|
||
onStartTestRun={() => {}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||
fireEvent.click(screen.getByRole('button', { name: /草莓/u }));
|
||
fireEvent.click(screen.getByRole('button', { name: '重新生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(assetReadUrlService.readAssetBytes).toHaveBeenCalledWith(
|
||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||
expect.objectContaining({ expireSeconds: 300 }),
|
||
);
|
||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||
expect(hyper3dService.submitHyper3dImageToModel).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
imageDataUrls: ['data:image/png;base64,aGVsbG8='],
|
||
prompt: expect.stringContaining('草莓'),
|
||
}),
|
||
);
|
||
expect(hyper3dService.getHyper3dDownloads).toHaveBeenCalledWith({
|
||
taskUuid: 'task-image',
|
||
});
|
||
});
|
||
});
|
||
});
|