// @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 ? {alt} : 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 }), ); }); }); });