/* @vitest-environment jsdom */ import { act, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { afterEach, expect, test, vi } from 'vitest'; import type { AuthUser } from '../../services/authService'; import { AuthUiContext } from '../auth/AuthUiContext'; import { RpgEntryHomeView, type RpgEntryHomeViewProps, } from './RpgEntryHomeView'; import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation'; const { mockGetRpgProfileWalletLedger } = vi.hoisted(() => ({ mockGetRpgProfileWalletLedger: vi.fn(async () => ({ entries: [ { id: 'ledger-1', amountDelta: -1, balanceAfter: 29, sourceType: 'asset_operation_consume', createdAt: '2026-04-28T10:00:00Z', }, { id: 'ledger-2', amountDelta: 30, balanceAfter: 30, sourceType: 'invite_invitee_reward', createdAt: '2026-04-28T09:00:00Z', }, ], })), })); vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger, getRpgProfileRechargeCenter: vi.fn(async () => ({ walletBalance: 0, membership: { status: 'normal', tier: 'normal', startedAt: null, expiresAt: null, updatedAt: null, }, pointProducts: [ { productId: 'points_60', title: '60陶泥币', priceCents: 600, kind: 'points', pointsAmount: 60, bonusPoints: 60, durationDays: 0, badgeLabel: '首充双倍', description: '首充送60陶泥币', tier: 'normal', }, ], membershipProducts: [ { productId: 'member_month', title: '月卡', priceCents: 2800, kind: 'membership', pointsAmount: 0, bonusPoints: 0, durationDays: 30, badgeLabel: '', description: '30天会员', tier: 'month', }, ], benefits: [ { benefitName: '免陶泥币回合数', normalValue: '30', monthValue: '100', seasonValue: '100', yearValue: '100', }, ], latestOrder: null, hasPointsRecharged: false, })), createRpgProfileRechargeOrder: vi.fn(async () => ({ order: { orderId: 'order-1', productId: 'points_60', productTitle: '60陶泥币', kind: 'points', amountCents: 600, status: 'paid', paymentChannel: 'mock', paidAt: '2026-04-25T10:00:00Z', createdAt: '2026-04-25T10:00:00Z', pointsDelta: 120, membershipExpiresAt: null, }, center: { walletBalance: 120, membership: { status: 'normal', tier: 'normal', startedAt: null, expiresAt: null, updatedAt: null, }, pointProducts: [], membershipProducts: [], benefits: [], latestOrder: null, hasPointsRecharged: true, }, })), })); vi.mock('../ResolvedAssetImage', () => ({ ResolvedAssetImage: ({ src, alt, className, ...rest }: { src?: string | null; alt?: string; className?: string; }) => src ? ( {alt ) : null, })); const originalMatchMedia = window.matchMedia; const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; const puzzlePublicEntry = { sourceType: 'puzzle', workId: 'puzzle-work-public-1', profileId: 'puzzle-profile-public-1', publicWorkCode: 'PZ-EPUBLIC1', ownerUserId: 'user-2', authorDisplayName: '拼图玩家', worldName: '奇幻拼图', subtitle: '拼图关卡', summaryText: '一张用于公开分享的拼图作品。', coverImageSrc: null, themeTags: ['奇幻'], playCount: 20, remixCount: 5, likeCount: 12, visibility: 'published', publishedAt: '1777110165.990127Z', updatedAt: '2026-04-25T10:00:00.000Z', } satisfies PlatformPublicGalleryCard; const remixRankEntry = { ...puzzlePublicEntry, workId: 'puzzle-work-remix-rank', profileId: 'puzzle-profile-remix-rank', publicWorkCode: 'PZ-REMIX1', worldName: '改造高分拼图', playCount: 2, remixCount: 18, likeCount: 1, recentPlayCount7d: 0, publishedAt: '2026-04-25T11:00:00.000Z', updatedAt: '2026-04-25T11:00:00.000Z', } satisfies PlatformPublicGalleryCard; function buildCarouselPuzzleEntry( id: string, worldName: string, coverPrefix: string, ) { return { ...puzzlePublicEntry, workId: `puzzle-work-${id}`, profileId: `puzzle-profile-${id}`, publicWorkCode: `PZ-${id.toUpperCase()}`, worldName, coverImageSrc: `${coverPrefix}-fallback.png`, coverSlides: [ { id: `${id}-cover-1`, imageSrc: `${coverPrefix}-1.png`, label: `${worldName} 1`, }, { id: `${id}-cover-2`, imageSrc: `${coverPrefix}-2.png`, label: `${worldName} 2`, }, ], } satisfies PlatformPublicGalleryCard; } const hotRankEntry = { ...puzzlePublicEntry, workId: 'puzzle-work-hot-rank', profileId: 'puzzle-profile-hot-rank', publicWorkCode: 'PZ-HOT001', worldName: '热门高分拼图', themeTags: ['奇幻', '机关'], playCount: 40, remixCount: 1, likeCount: 4, recentPlayCount7d: 0, publishedAt: '2026-04-24T10:00:00.000Z', updatedAt: '2026-04-24T10:00:00.000Z', } satisfies PlatformPublicGalleryCard; const newRankEntry = { ...puzzlePublicEntry, workId: 'puzzle-work-new-rank', profileId: 'puzzle-profile-new-rank', publicWorkCode: 'PZ-NEW001', worldName: '新品增长拼图', playCount: 1, remixCount: 0, likeCount: 0, recentPlayCount7d: 9, publishedAt: '2026-04-20T10:00:00.000Z', updatedAt: '2026-04-20T10:00:00.000Z', } satisfies PlatformPublicGalleryCard; const longTextRankEntry = { ...puzzlePublicEntry, workId: 'puzzle-work-long-text-rank', profileId: 'puzzle-profile-long-text-rank', publicWorkCode: 'PZ-LONG01', worldName: '关键词逍遥游拼图关卡', themeTags: ['逍遥游拼图', '古风机关'], playCount: 88, remixCount: 0, likeCount: 0, recentPlayCount7d: 0, publishedAt: '2026-04-29T10:00:00.000Z', updatedAt: '2026-04-29T10:00:00.000Z', } satisfies PlatformPublicGalleryCard; function mockDesktopLayout() { Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: vi.fn().mockImplementation(() => ({ matches: true, media: '(min-width: 1024px)', onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), })), }); } function renderProfileView( onRechargeSuccess = vi.fn(), profileDashboardOverrides: Partial< NonNullable > = {}, userOverrides: Partial = {}, ) { return render( action(), openSettingsModal: vi.fn(), openAccountModal: vi.fn(), setCurrentUser: vi.fn(), logout: vi.fn(async () => undefined), musicVolume: 0.42, setMusicVolume: vi.fn(), platformTheme: 'light', setPlatformTheme: vi.fn(), isHydratingSettings: false, isPersistingSettings: false, settingsError: null, }} > , ); } function renderLoggedOutHomeView( openLoginModal = vi.fn(), overrides: Partial< Pick< RpgEntryHomeViewProps, | 'featuredEntries' | 'latestEntries' | 'onOpenGalleryDetail' | 'onSearchPublicCode' > > = {}, ) { return render( undefined), musicVolume: 0.42, setMusicVolume: vi.fn(), platformTheme: 'light', setPlatformTheme: vi.fn(), isHydratingSettings: false, isPersistingSettings: false, settingsError: null, }} > , ); } function renderStatefulLoggedOutHomeView( overrides: Partial< Pick > = {}, ) { function StatefulLoggedOutHomeView() { const [activeTab, setActiveTab] = useState('home'); return ( undefined), musicVolume: 0.42, setMusicVolume: vi.fn(), platformTheme: 'light', setPlatformTheme: vi.fn(), isHydratingSettings: false, isPersistingSettings: false, settingsError: null, }} > ); } return render(); } afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: originalMatchMedia, }); Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, writable: true, value: originalRequestAnimationFrame, }); Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, writable: true, value: originalCancelAnimationFrame, }); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined, }); }); test('opens wallet ledger modal from narrative coin card', async () => { const user = userEvent.setup(); renderProfileView(); await user.click(screen.getByRole('button', { name: /陶泥币\s*0/u })); expect(await screen.findByText('陶泥币账单')).toBeTruthy(); expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1); expect(screen.getByText('资产操作消耗')).toBeTruthy(); expect(screen.getByText('-1')).toBeTruthy(); expect(screen.getByText('填写邀请码奖励')).toBeTruthy(); expect(screen.getByText('+30')).toBeTruthy(); }); test('profile total play time card always uses hours', () => { renderProfileView(vi.fn(), { totalPlayTimeMs: 90 * 60 * 1000, }); const playTimeCard = screen.getByRole('button', { name: /游戏时长/u, }); expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy(); expect(within(playTimeCard).queryByText('90分')).toBeNull(); }); test('profile played works card shows count unit', () => { renderProfileView(vi.fn(), { playedWorldCount: 1, }); const playedCard = screen.getByRole('button', { name: /玩过\s*1个/u, }); expect(within(playedCard).getByText('1个')).toBeTruthy(); }); test('desktop account entry uses saved avatar image when available', () => { mockDesktopLayout(); const avatarUrl = 'data:image/png;base64,AAAA'; renderProfileView(vi.fn(), {}, { avatarUrl }); const accountEntry = screen.getByRole('button', { name: /测试玩家/u }); const avatarImage = accountEntry.querySelector('img'); expect(avatarImage?.getAttribute('src')).toBe(avatarUrl); expect(within(accountEntry).queryByText('测')).toBeNull(); }); test('wallet ledger modal shows empty and error states', async () => { const user = userEvent.setup(); mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] }); renderProfileView(); await user.click(screen.getByRole('button', { name: /陶泥币\s*0/u })); expect(await screen.findByText('暂无账单记录')).toBeTruthy(); await user.click(screen.getByLabelText('关闭陶泥币账单')); mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败')); await user.click(screen.getByRole('button', { name: /陶泥币\s*0/u })); expect(await screen.findByText('加载失败')).toBeTruthy(); expect(screen.getByText('重新加载')).toBeTruthy(); }); test('opens reward code modal from profile action on mobile', async () => { const user = userEvent.setup(); renderProfileView(); await user.click(screen.getByRole('button', { name: /兑换码/u })); const modal = await screen.findByPlaceholderText('输入兑换码'); expect(modal).toBeTruthy(); expect(screen.getByRole('button', { name: '兑换' })).toBeTruthy(); expect(screen.getByLabelText('关闭兑换码')).toBeTruthy(); }); test('shows a reachable login entry in logged out mobile shell', async () => { const user = userEvent.setup(); const openLoginModal = vi.fn(); renderLoggedOutHomeView(openLoginModal); await user.click(screen.getByRole('button', { name: '登录' })); expect(openLoginModal).toHaveBeenCalledTimes(1); }); test('mobile home search submits public work code', async () => { const user = userEvent.setup(); const onSearchPublicCode = vi.fn(); render( undefined), musicVolume: 0.42, setMusicVolume: vi.fn(), platformTheme: 'light', setPlatformTheme: vi.fn(), isHydratingSettings: false, isPersistingSettings: false, settingsError: null, }} > , ); const searchInput = screen.getByPlaceholderText( '输入 SY / CW / BF / M3 / PZ 编号', ); await user.type(searchInput, 'PZ-PROFILE1{enter}'); expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1'); }); test('public gallery cards hide work code until detail is opened', async () => { const user = userEvent.setup(); const onOpenGalleryDetail = vi.fn(); renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry], onOpenGalleryDetail, }); expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull(); expect( screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }), ).toBeNull(); await user.click(screen.getByRole('button', { name: /奇幻拼图/u })); expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry); }); test('mobile public work cards render cover, author, kind and cover stats', () => { const { container } = renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry], }); const card = screen.getByRole('button', { name: /奇幻拼图,拼图,20游玩,5改造,12点赞/u, }); expect( card.querySelector('.platform-public-work-card__cover.aspect-video'), ).toBeTruthy(); expect( card.querySelector('.platform-public-work-card__cover-stats'), ).toBeTruthy(); expect( card.querySelectorAll('.platform-public-work-card__cover-stat'), ).toHaveLength(3); expect( card.querySelector('.platform-public-work-card__kind')?.textContent, ).toBe('拼图'); expect( card.querySelector('.platform-public-work-card__author-avatar') ?.textContent, ).toBe('拼'); expect(screen.getByText('奇幻拼图')).toBeTruthy(); expect(screen.getByText('拼图玩家')).toBeTruthy(); expect(screen.getByText('一张用于公开分享的拼图作品。')).toBeTruthy(); expect(screen.getByText('奇幻')).toBeTruthy(); expect(screen.getByText('20')).toBeTruthy(); expect(screen.getByText('5')).toBeTruthy(); expect(screen.getByText('12')).toBeTruthy(); expect(card.querySelector('.platform-pill--warm')?.textContent).not.toBe( '推荐', ); expect( container.querySelector('.platform-mobile-home-channel--active') ?.textContent, ).toBe('推荐'); }); test('mobile home feed only rotates the card closest to screen center', () => { vi.useFakeTimers(); Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, writable: true, value: (callback: FrameRequestCallback) => window.setTimeout(() => callback(0), 0), }); Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, writable: true, value: (handle: number) => window.clearTimeout(handle), }); const firstEntry = buildCarouselPuzzleEntry('center1', '中心拼图一', 'center-one'); const secondEntry = buildCarouselPuzzleEntry( 'center2', '中心拼图二', 'center-two', ); const cardRects = new Map(); renderLoggedOutHomeView(vi.fn(), { latestEntries: [firstEntry, secondEntry], }); const tabPanel = document.querySelector('.platform-tab-panel--active'); const firstCard = screen.getByRole('button', { name: /中心拼图一/u }); const secondCard = screen.getByRole('button', { name: /中心拼图二/u }); if (!tabPanel) { throw new Error('缺少移动端首页滚动面板'); } tabPanel.getBoundingClientRect = vi.fn( () => ({ top: 0, bottom: 600, height: 600, left: 0, right: 360, width: 360, }) as DOMRect, ); firstCard.getBoundingClientRect = vi.fn(() => cardRects.get('first')!); secondCard.getBoundingClientRect = vi.fn(() => cardRects.get('second')!); cardRects.set('first', { top: 170, bottom: 370, height: 200, left: 0, right: 320, width: 320, } as DOMRect); cardRects.set('second', { top: 420, bottom: 620, height: 200, left: 0, right: 320, width: 320, } as DOMRect); act(() => { vi.runOnlyPendingTimers(); }); expect(within(firstCard).getByRole('img').getAttribute('src')).toBe( 'center-one-1.png', ); expect(within(secondCard).getByRole('img').getAttribute('src')).toBe( 'center-two-1.png', ); act(() => { vi.advanceTimersByTime(4200); }); expect(within(firstCard).getByRole('img').getAttribute('src')).toBe( 'center-one-2.png', ); expect(within(secondCard).getByRole('img').getAttribute('src')).toBe( 'center-two-1.png', ); cardRects.set('first', { top: -120, bottom: 80, height: 200, left: 0, right: 320, width: 320, } as DOMRect); cardRects.set('second', { top: 200, bottom: 400, height: 200, left: 0, right: 320, width: 320, } as DOMRect); act(() => { tabPanel.dispatchEvent(new Event('scroll')); vi.runOnlyPendingTimers(); }); expect(within(firstCard).getByRole('img').getAttribute('src')).toBe( 'center-one-1.png', ); act(() => { vi.advanceTimersByTime(4200); }); expect(within(secondCard).getByRole('img').getAttribute('src')).toBe( 'center-two-2.png', ); }); test('mobile today channel only shows newly published works from today', async () => { const user = userEvent.setup(); const now = new Date(); const todayPublishedAt = new Date( now.getFullYear(), now.getMonth(), now.getDate(), 10, ).toISOString(); const yesterdayPublishedAt = new Date( now.getFullYear(), now.getMonth(), now.getDate() - 1, 10, ).toISOString(); const todayEntry = { ...puzzlePublicEntry, workId: 'puzzle-work-today', profileId: 'puzzle-profile-today', publicWorkCode: 'PZ-TODAY1', worldName: '今日新游', publishedAt: todayPublishedAt, updatedAt: todayPublishedAt, } satisfies PlatformPublicGalleryCard; const yesterdayEntry = { ...puzzlePublicEntry, workId: 'puzzle-work-yesterday', profileId: 'puzzle-profile-yesterday', publicWorkCode: 'PZ-YDAY01', worldName: '昨日旧作', publishedAt: yesterdayPublishedAt, updatedAt: yesterdayPublishedAt, } satisfies PlatformPublicGalleryCard; const updatedTodayEntry = { ...puzzlePublicEntry, workId: 'puzzle-work-updated-today', profileId: 'puzzle-profile-updated-today', publicWorkCode: 'PZ-UPDAY1', worldName: '今日更新旧作', publishedAt: yesterdayPublishedAt, updatedAt: todayPublishedAt, } satisfies PlatformPublicGalleryCard; renderLoggedOutHomeView(vi.fn(), { latestEntries: [yesterdayEntry, updatedTodayEntry, todayEntry], }); await user.click(screen.getByRole('button', { name: '今日游戏' })); expect(screen.getByRole('button', { name: /今日新游/u })).toBeTruthy(); expect(screen.queryByText('昨日旧作')).toBeNull(); expect(screen.queryByText('今日更新旧作')).toBeNull(); }); test('desktop home syncs mobile home modules without square or latest labels', () => { mockDesktopLayout(); const todayPublishedAt = new Date().toISOString(); const todayEntry = { ...puzzlePublicEntry, workId: 'puzzle-work-desktop-today', profileId: 'puzzle-profile-desktop-today', publicWorkCode: 'PZ-DTODAY', worldName: '桌面今日新游', publishedAt: todayPublishedAt, updatedAt: todayPublishedAt, } satisfies PlatformPublicGalleryCard; renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry, todayEntry], }); expect(screen.getByText('今日游戏')).toBeTruthy(); expect(screen.getByText('推荐')).toBeTruthy(); expect(screen.getByText('作品分类')).toBeTruthy(); expect(screen.getAllByText('桌面今日新游').length).toBeGreaterThan(0); expect(screen.queryByText('趋势关注')).toBeNull(); expect(screen.queryByText('最新发布')).toBeNull(); expect(screen.queryByText('作品广场')).toBeNull(); expect(screen.queryByText('公开作品')).toBeNull(); expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull(); expect(screen.getAllByText('拼图').length).toBeGreaterThan(0); expect(screen.queryByText('1777110165.990127Z')).toBeNull(); }); test('mobile home moves category shelf into game category channel', async () => { const user = userEvent.setup(); const { container } = renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry], }); expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull(); expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull(); await user.click(screen.getByRole('button', { name: '游戏分类' })); expect(screen.getAllByText('游戏分类').length).toBeGreaterThan(0); expect(screen.getByRole('button', { name: /筛选/u })).toBeTruthy(); expect(screen.getByRole('button', { name: '奇幻' })).toBeTruthy(); expect(screen.getByRole('button', { name: /奇幻拼图,试玩/u })).toBeTruthy(); expect(container.querySelector('.platform-category-game-list')).toBeTruthy(); expect(container.querySelector('.platform-category-game-item')).toBeTruthy(); expect( container.querySelector('.platform-category-game-item__action') ?.textContent, ).toBe('试玩'); }); test('mobile game category list orders works by composite public metric', async () => { const user = userEvent.setup(); renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry, hotRankEntry], }); await user.click(screen.getByRole('button', { name: '游戏分类' })); await user.click(screen.getByRole('button', { name: '奇幻' })); const gameItems = Array.from( document.querySelectorAll('.platform-category-game-item__title'), ).map((element) => element.textContent); expect(gameItems).toEqual(['热门高分拼图', '奇幻拼图']); }); test('bottom category tab becomes ranking and switches ranking metrics', async () => { const user = userEvent.setup(); renderStatefulLoggedOutHomeView({ latestEntries: [remixRankEntry, hotRankEntry, newRankEntry], }); expect(screen.queryByRole('button', { name: '分类' })).toBeNull(); await user.click(screen.getByRole('button', { name: '排行' })); expect(await screen.findByRole('tab', { name: '热门榜' })).toBeTruthy(); expect(screen.getByRole('tab', { name: '改造榜' })).toBeTruthy(); expect(screen.getByRole('tab', { name: '新品榜' })).toBeTruthy(); expect(screen.getByRole('tab', { name: '点赞榜' })).toBeTruthy(); const rankingPanel = document.getElementById('platform-tab-panel-category'); expect(rankingPanel?.getAttribute('aria-hidden')).toBe('false'); expect(within(rankingPanel!).getByText('热门高分拼图')).toBeTruthy(); expect(within(rankingPanel!).getByText('40')).toBeTruthy(); expect(within(rankingPanel!).getAllByText('游玩').length).toBeGreaterThan(0); await user.click(screen.getByRole('tab', { name: '改造榜' })); expect(within(rankingPanel!).getByText('改造高分拼图')).toBeTruthy(); expect(within(rankingPanel!).getByText('18')).toBeTruthy(); expect(within(rankingPanel!).getAllByText('改造').length).toBeGreaterThan(0); await user.click(screen.getByRole('tab', { name: '新品榜' })); expect(within(rankingPanel!).getByText('新品增长拼图')).toBeTruthy(); expect(within(rankingPanel!).getByText('9')).toBeTruthy(); expect(within(rankingPanel!).getAllByText('近7日').length).toBeGreaterThan(0); }); test('ranking rows limit displayed work name and show two short tags on the third line', async () => { const user = userEvent.setup(); renderStatefulLoggedOutHomeView({ latestEntries: [longTextRankEntry], }); await user.click(screen.getByRole('button', { name: '排行' })); const rankingPanel = document.getElementById('platform-tab-panel-category'); expect(rankingPanel).toBeTruthy(); expect(within(rankingPanel!).getByText('关键词逍遥游拼图')).toBeTruthy(); expect(within(rankingPanel!).queryByText('关键词逍遥游拼图关卡')).toBeNull(); expect(within(rankingPanel!).getByText('逍遥游拼')).toBeTruthy(); expect(within(rankingPanel!).getByText('古风机关')).toBeTruthy(); expect(within(rankingPanel!).queryByText(/2026-04-29/u)).toBeNull(); expect(within(rankingPanel!).queryByText('拼图玩家')).toBeNull(); });