Files
Genarrative/src/components/match3d-result/Match3DResultView.test.tsx
2026-05-11 16:15:48 +08:00

446 lines
14 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 } 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',
});
});
});
});