// @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 ? {alt} : null), })); vi.mock('./Match3DModelPreview', () => ({ Match3DModelPreview: ({ modelSrc, }: { modelSrc?: string | null; }) => (
), })); 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 { 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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', }); }); }); });