/* @vitest-environment jsdom */ import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, expect, test, vi } from 'vitest'; import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes'; import { CustomWorldCreationHub } from './CustomWorldCreationHub'; const noopCreateType = () => {}; const testEntryConfig = { startCard: { title: '新建作品', description: '选择模板后进入对应的创作表单。', idleBadge: '模板 Tab', busyBadge: '正在开启', }, typeModal: { title: '选择创作类型', description: '先选玩法类型,再进入对应创作工作台。', }, creationTypes: [ { id: 'puzzle', title: '拼图', subtitle: '拼图关卡创作', badge: '可创建', imageSrc: '/creation-type-references/puzzle.webp', visible: true, open: true, sortOrder: 30, updatedAtMicros: 1, }, { id: 'match3d', title: '抓大鹅', subtitle: '3D 消除关卡', badge: '可创建', imageSrc: '/creation-type-references/match3d.webp', visible: true, open: true, sortOrder: 40, updatedAtMicros: 1, }, { id: 'square-hole', title: '方洞', subtitle: '形状投放挑战', badge: '可创建', imageSrc: '/creation-type-references/square-hole.webp', visible: false, open: true, sortOrder: 50, updatedAtMicros: 1, }, { id: 'visual-novel', title: '视觉小说', subtitle: '分支叙事体验', badge: '敬请期待', imageSrc: '/creation-type-references/visual-novel.webp', visible: false, open: false, sortOrder: 60, updatedAtMicros: 1, }, { id: 'airp', title: 'AI RPG', subtitle: '原生角色扮演', badge: '即将开放', imageSrc: '/creation-type-references/airp.webp', visible: true, open: false, sortOrder: 70, updatedAtMicros: 1, }, ], } satisfies CreationEntryConfig; const testCreationTypes = derivePlatformCreationTypes( testEntryConfig.creationTypes, ); const originalClipboard = navigator.clipboard; afterEach(() => { window.sessionStorage.clear(); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: originalClipboard, }); }); test('creation hub shows published metric growth from cached page snapshot', async () => { window.sessionStorage.setItem( 'genarrative.creationHub.publishedMetrics.v1', JSON.stringify({ 'puzzle:puzzle:work-growth': { 'play-count': 7, 'remix-count': 1, 'like-count': 2, }, }), ); render( {}} onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} entryConfig={testEntryConfig} creationTypes={testCreationTypes} onOpenPuzzleDetail={() => {}} />, ); expect(screen.getByLabelText('游玩 10次')).toBeTruthy(); expect(screen.getByLabelText('改造 4次')).toBeTruthy(); expect(await screen.findAllByText('↑')).toHaveLength(2); }); const baseDraftItem: CustomWorldWorkSummary = { workId: 'draft:session-1', sourceType: 'agent_session', status: 'draft', title: '潮雾列岛', subtitle: '补齐关键锚点', summary: '玩家是失职返乡的守灯人。', coverImageSrc: null, updatedAt: new Date('2026-04-14T10:00:00.000Z').toISOString(), publishedAt: null, stage: 'object_refining', stageLabel: '待完善草稿', playableNpcCount: 3, landmarkCount: 4, sessionId: 'session-1', profileId: null, canResume: true, canEnterWorld: false, }; const hiddenSquareHoleItem: SquareHoleWorkSummary = { workId: 'square-hole:work-hidden', profileId: 'square-hole-profile-hidden', ownerUserId: 'user-1', gameName: '隐藏方洞挑战', themeText: '方洞', twistRule: '隐藏入口', summary: '入口隐藏后,这条作品不应出现在创作页作品架。', tags: ['方洞'], coverImageSrc: null, backgroundPrompt: '', backgroundImageSrc: null, shapeOptions: [], holeOptions: [], shapeCount: 0, difficulty: 1, publicationStatus: 'draft', playCount: 0, updatedAt: new Date('2026-05-10T10:00:00.000Z').toISOString(), publishedAt: null, publishReady: false, sourceSessionId: 'square-hole-session-hidden', }; const babyObjectMatchDraftItem: BabyObjectMatchDraft = { draftId: 'baby-object-draft-delete', profileId: 'baby-object-profile-delete', templateId: 'baby-object-match', templateName: '宝贝识物', workTitle: '宝贝识物删除测试', workDescription: '苹果和香蕉识物分类', itemNames: ['苹果', '香蕉'], itemAssets: [ { itemId: 'baby-object-item-1', itemName: '苹果', imageSrc: '/apple.png', assetObjectId: null, generationProvider: 'placeholder', prompt: '苹果', }, { itemId: 'baby-object-item-2', itemName: '香蕉', imageSrc: '/banana.png', assetObjectId: null, generationProvider: 'placeholder', prompt: '香蕉', }, ], visualPackage: null, themeTags: ['寓教于乐'], publicationStatus: 'draft', createdAt: new Date('2026-05-11T10:00:00.000Z').toISOString(), updatedAt: new Date('2026-05-11T10:00:00.000Z').toISOString(), publishedAt: null, }; const barkBattleDraftItem: BarkBattleWorkSummary = { workId: 'bark-battle-work-draft-visible', draftId: 'bark-battle-draft-visible', ownerUserId: 'user-1', authorDisplayName: '声浪作者', title: '竖屏声浪草稿', summary: '生成完成后也必须留在我的草稿里。', themeDescription: '霓虹竖屏擂台', playerImageDescription: '红围巾选手', opponentImageDescription: '蓝头带对手', onomatopoeia: ['炸场', '破阵'], playerCharacterImageSrc: '/bark/player.png', opponentCharacterImageSrc: '/bark/opponent.png', uiBackgroundImageSrc: '/bark/background.png', difficultyPreset: 'normal', status: 'draft', generationStatus: 'ready', publishReady: true, playCount: 0, updatedAt: '2026-05-21T10:00:00.000Z', publishedAt: null, }; const barkBattlePublishedItem: BarkBattleWorkSummary = { ...barkBattleDraftItem, workId: 'bark-battle-work-published-visible', draftId: 'bark-battle-draft-published-visible', title: '竖屏声浪已发布', summary: '发布完成后必须留在已发布作品里。', authorDisplayName: '发布作者', status: 'published', playCount: 9, updatedAt: '2026-05-21T10:10:00.000Z', publishedAt: '2026-05-21T10:10:00.000Z', }; test('creation hub reflects updated draft title summary and counts after rerender', async () => { const user = userEvent.setup(); const onCreateType = vi.fn(); const { rerender } = render( {}} onCreateType={onCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, ); expect(screen.getByText('潮雾列岛')).toBeTruthy(); expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy(); expect(screen.queryByText('角色 3')).toBeNull(); expect(screen.queryByText('地点 4')).toBeNull(); const puzzleButton = screen.getByRole('button', { name: /拼图.*拼图关卡创作/u, }); const match3dButton = screen.getByRole('button', { name: /抓大鹅.*3D 消除关卡/u, }); expect(puzzleButton).toBeTruthy(); expect(match3dButton).toBeTruthy(); expect((puzzleButton as HTMLButtonElement).disabled).toBe(false); expect((match3dButton as HTMLButtonElement).disabled).toBe(false); expect(screen.queryByRole('button', { name: /方洞挑战/u })).toBeNull(); expect(screen.queryByText('反直觉形状分拣')).toBeNull(); expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull(); expect(screen.queryByRole('button', { name: /文字冒险/u })).toBeNull(); expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull(); await user.click(match3dButton); expect(onCreateType).toHaveBeenCalledWith('match3d'); rerender( {}} onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, ); expect(screen.getByText('潮雾列岛·回潮版')).toBeTruthy(); expect( screen.getByText('世界总卡和角色网已经继续长出了新的支线。'), ).toBeTruthy(); expect(screen.queryByText('角色 5')).toBeNull(); expect(screen.queryByText('地点 6')).toBeNull(); }); test('creation hub hides square hole works when the creation type is hidden', () => { const onOpenSquareHoleDetail = vi.fn(); render( {}} onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} entryConfig={testEntryConfig} creationTypes={testCreationTypes} onOpenSquareHoleDetail={onOpenSquareHoleDetail} />, ); expect(screen.queryByText('隐藏方洞挑战')).toBeNull(); expect( screen.queryByText('入口隐藏后,这条作品不应出现在创作页作品架。'), ).toBeNull(); }); test('creation hub mixes puzzle works into the same grid and uses puzzle tag to distinguish', () => { render( {}} onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} entryConfig={testEntryConfig} creationTypes={testCreationTypes} onOpenPuzzleDetail={() => {}} />, ); expect(screen.getByText('潮雾列岛')).toBeTruthy(); expect(screen.getByText('沉钟拼图')).toBeTruthy(); expect(screen.getAllByText('拼图').length).toBeGreaterThan(0); expect(screen.getByLabelText('游玩 8次')).toBeTruthy(); expect(screen.getByLabelText('改造 2次')).toBeTruthy(); expect(screen.getByLabelText('点赞 3赞')).toBeTruthy(); expect(screen.queryByText('Remix')).toBeNull(); expect(screen.queryByText('PZ-PROFILE1')).toBeNull(); expect(screen.queryByText('潮雾')).toBeNull(); expect(screen.queryByText('沉钟')).toBeNull(); expect(screen.queryByText('我的拼图作品')).toBeNull(); }); test('creation hub shows puzzle point incentive and claims without opening card', async () => { const user = userEvent.setup(); const onClaimPuzzlePointIncentive = vi.fn(); const onOpenPuzzleDetail = vi.fn(); render( {}} onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} onOpenPuzzleDetail={onOpenPuzzleDetail} onClaimPuzzlePointIncentive={onClaimPuzzlePointIncentive} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, ); expect(screen.getByLabelText('积分激励总数 2.5 泥点')).toBeTruthy(); expect(screen.getByLabelText('待领取积分 1 泥点')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '领取积分' })); expect(onClaimPuzzlePointIncentive).toHaveBeenCalledWith( expect.objectContaining({ profileId: 'puzzle-profile-incentive' }), ); expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); }); test('creation hub shows RPG public work code from published library entry', () => { render( {}} onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, ); expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy(); expect(screen.getByLabelText('游玩 12次')).toBeTruthy(); expect(screen.getByLabelText('改造 4次')).toBeTruthy(); expect(screen.getByLabelText('点赞 5赞')).toBeTruthy(); expect(screen.queryByText('Remix')).toBeNull(); expect(screen.queryByText('CW-00000001')).toBeNull(); }); test('creation hub hides persisted draft delete action behind swipe underlay', () => { const { container } = render( {}} onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} onDeletePublished={() => {}} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, ); expect( container.querySelector('.creation-work-card__swipe-underlay'), ).toBeTruthy(); expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); }); test('creation hub reveals persisted draft delete action from left swipe', () => { const { container } = render( {}} onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} onDeletePublished={() => {}} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, ); const card = screen.getByRole('button', { name: /继续完善《潮雾列岛》/u }); fireEvent.touchStart(card, { touches: [{ clientX: 180, clientY: 20 }], }); fireEvent.touchMove(card, { touches: [{ clientX: 92, clientY: 22 }], }); fireEvent.touchEnd(card); expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); expect( container.querySelector('.creation-work-card-shell--actions-visible'), ).toBeTruthy(); }); test('creation hub reveals persisted draft delete action from keyboard', async () => { const user = userEvent.setup(); render( {}} onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} onDeletePublished={() => {}} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, ); screen.getByRole('button', { name: /继续完善《潮雾列岛》/u }).focus(); await user.keyboard('{ArrowLeft}'); expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); expect(screen.queryByRole('button', { name: '分享' })).toBeNull(); }); test('creation hub shows delete action for baby object match drafts', async () => { const user = userEvent.setup(); const onDeleteBabyObjectMatch = vi.fn(); const onOpenBabyObjectMatchDetail = vi.fn(); render( {}} onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} onOpenBabyObjectMatchDetail={onOpenBabyObjectMatchDetail} onDeleteBabyObjectMatch={onDeleteBabyObjectMatch} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, ); screen.getByRole('button', { name: /继续创作《宝贝识物删除测试》/u }).focus(); await user.keyboard('{ArrowLeft}'); await user.click(screen.getByRole('button', { name: '删除' })); expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith( babyObjectMatchDraftItem, ); expect(onOpenBabyObjectMatchDetail).not.toHaveBeenCalled(); }); test('creation hub works-only tab filters bark battle draft and published works', async () => { const user = userEvent.setup(); const onOpenBarkBattleDetail = vi.fn(); render( {}} onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} onOpenBarkBattleDetail={onOpenBarkBattleDetail} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, ); expect(screen.getByRole('button', { name: '全部 2' })).toBeTruthy(); expect(screen.getByRole('button', { name: '草稿 1' })).toBeTruthy(); expect(screen.getByRole('button', { name: '已发布 1' })).toBeTruthy(); expect(screen.getByText('竖屏声浪草稿')).toBeTruthy(); expect(screen.getByText('竖屏声浪已发布')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '草稿 1' })); expect(screen.getByText('竖屏声浪草稿')).toBeTruthy(); expect(screen.queryByText('竖屏声浪已发布')).toBeNull(); await user.click(screen.getByRole('button', { name: '已发布 1' })); expect(screen.queryByText('竖屏声浪草稿')).toBeNull(); expect(screen.getByText('竖屏声浪已发布')).toBeTruthy(); await user.click( screen.getByRole('button', { name: /查看详情《竖屏声浪已发布》/u }), ); expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(barkBattlePublishedItem); }); test('creation hub published work delete action is revealed without opening card', async () => { const user = userEvent.setup(); const onDeletePuzzle = vi.fn(); const onOpenPuzzleDetail = vi.fn(); render( {}} onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} onOpenPuzzleDetail={onOpenPuzzleDetail} onDeletePuzzle={onDeletePuzzle} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, ); expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); screen.getByRole('button', { name: /查看详情《待删拼图》/u }).focus(); await user.keyboard('{ArrowLeft}'); expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); await user.click(screen.getByRole('button', { name: '删除' })); expect(onDeletePuzzle).toHaveBeenCalledWith( expect.objectContaining({ profileId: 'puzzle-profile-delete' }), ); expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); }); test('creation hub opens persisted rpg drafts by card click', async () => { const user = userEvent.setup(); const openedItems: CustomWorldWorkSummary[] = []; const persistedDraft = { ...baseDraftItem, workId: 'draft:profile-1', sourceType: 'published_profile' as const, sessionId: null, profileId: 'profile-1', title: '可继续整理的草稿', }; render( {}} onCreateType={noopCreateType} onOpenDraft={(item) => { openedItems.push(item); }} onEnterPublished={() => {}} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, ); await user.click( screen.getByRole('button', { name: /继续完善《可继续整理的草稿》/u }), ); expect(openedItems).toEqual([persistedDraft]); }); test('creation hub published share icon copies share text without opening the card', async () => { const user = userEvent.setup(); const writeText = vi.fn(async () => undefined); const onOpenPuzzleDetail = vi.fn(); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, }); render( {}} onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} onOpenPuzzleDetail={onOpenPuzzleDetail} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, ); const shareButton = screen.getByRole('button', { name: '分享' }); expect(shareButton).toBeTruthy(); expect(screen.queryByText('删除')).toBeNull(); await user.click(shareButton); expect(writeText).toHaveBeenCalledWith( expect.stringContaining('邀请你来玩《沉钟拼图》'), ); expect(writeText).toHaveBeenCalledWith( expect.stringContaining('作品号:PZ-PROFILE1'), ); expect(writeText).toHaveBeenCalledWith( expect.stringContaining('/gallery/puzzle/detail?work=PZ-PROFILE1'), ); expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); expect( await screen.findByRole('button', { name: '分享内容已复制' }), ).toBeTruthy(); }); test('creation hub published share icon is shown directly on the card header', () => { render( {}} onCreateType={noopCreateType} onOpenDraft={() => {}} onEnterPublished={() => {}} onOpenPuzzleDetail={() => {}} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, ); expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); }); test('creation hub left swipe draft reveals delete without opening card', () => { const onDeletePublished = vi.fn(); const onOpenDraft = vi.fn(); render( {}} onCreateType={noopCreateType} onOpenDraft={onOpenDraft} onEnterPublished={() => {}} onDeletePublished={onDeletePublished} entryConfig={testEntryConfig} creationTypes={testCreationTypes} />, ); const card = screen.getByRole('button', { name: /继续完善《潮雾列岛》/u }); fireEvent.touchStart(card, { touches: [{ clientX: 180, clientY: 20 }], }); fireEvent.touchMove(card, { touches: [{ clientX: 88, clientY: 22 }], }); fireEvent.touchEnd(card); expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); expect(onOpenDraft).not.toHaveBeenCalled(); });