/* @vitest-environment jsdom */ import { act, fireEvent, render, screen, waitFor, 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, PublicUserSummary, } from '../../../packages/shared/src/contracts/auth'; import type { ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, ProfileReferralInviteCenterResponse, ProfileTaskCenterResponse, } from '../../../packages/shared/src/contracts/runtime'; import { AuthUiContext } from '../auth/AuthUiContext'; import { ICP_RECORD_NUMBER, ICP_RECORD_URL } from '../common/legalDocuments'; import { RpgEntryHomeView, type RpgEntryHomeViewProps, } from './RpgEntryHomeView'; import { EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, type PlatformEdutainmentGalleryCard, type PlatformPublicGalleryCard, type PlatformPuzzleGalleryCard, } from './rpgEntryWorldPresentation'; const { mockQrCodeToDataUrl, mockRedirectToPaymentUrl, mockBuildReferralCenter, mockBuildTaskCenter, mockClaimRpgProfileTaskReward, mockConfirmWechatRpgProfileRechargeOrder, mockCreateRpgProfileRechargeOrder, mockGetRpgProfileReferralInviteCenter, mockGetRpgProfileRechargeCenter, mockGetRpgProfileTasks, mockGetRpgProfileWalletLedger, mockRedeemRpgProfileReferralInviteCode, } = vi.hoisted(() => { const qrCodeToDataUrl = vi.fn(async () => 'data:image/png;base64,QR'); const redirectToPaymentUrl = vi.fn(); const buildReferralCenter = ( overrides: Partial = {}, ): ProfileReferralInviteCenterResponse => ({ inviteCode: 'SY12345678', inviteLinkPath: '/?inviteCode=SY12345678', invitedCount: 1, rewardedInviteCount: 1, todayInviterRewardCount: 1, todayInviterRewardRemaining: 9, rewardPoints: 30, invitedUsers: [ { userId: 'user-2', displayName: '被邀请玩家', avatarUrl: null, boundAt: '2026-05-01T08:00:00Z', }, ], hasRedeemedCode: false, boundInviterUserId: null, boundAt: null, updatedAt: '2026-05-01T08:00:00Z', ...overrides, }); const buildTaskCenter = ( overrides: Partial = {}, ): ProfileTaskCenterResponse => ({ dayKey: 20260503, walletBalance: 0, tasks: [ { taskId: 'daily_login', title: '每日登录', description: '', eventKey: 'profile.login.daily', cycle: 'daily', threshold: 1, progressCount: 1, rewardPoints: 10, status: 'claimable', dayKey: 20260503, claimedAt: null, updatedAt: '2026-05-03T08:00:00Z', }, ], updatedAt: '2026-05-03T08:00:00Z', ...overrides, }); const buildClaimedTaskCenter = () => buildTaskCenter({ walletBalance: 10, tasks: [ { taskId: 'daily_login', title: '每日登录', description: '', eventKey: 'profile.login.daily', cycle: 'daily', threshold: 1, progressCount: 1, rewardPoints: 10, status: 'claimed', dayKey: 20260503, claimedAt: '2026-05-03T08:01:00Z', updatedAt: '2026-05-03T08:01:00Z', }, ], updatedAt: '2026-05-03T08:01:00Z', }); return { mockQrCodeToDataUrl: qrCodeToDataUrl, mockRedirectToPaymentUrl: redirectToPaymentUrl, mockBuildReferralCenter: buildReferralCenter, mockBuildTaskCenter: buildTaskCenter, mockGetRpgProfileReferralInviteCenter: vi.fn(async () => buildReferralCenter(), ), mockGetRpgProfileTasks: vi.fn(async () => buildTaskCenter()), mockClaimRpgProfileTaskReward: vi.fn(async () => ({ taskId: 'daily_login', dayKey: 20260503, rewardPoints: 10, walletBalance: 10, ledgerEntry: { id: 'ledger-daily-login', amountDelta: 10, balanceAfter: 10, sourceType: 'daily_task_reward', createdAt: '2026-05-03T08:01:00Z', }, center: buildClaimedTaskCenter(), })), mockGetRpgProfileRechargeCenter: 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, })), mockCreateRpgProfileRechargeOrder: vi.fn( async (): Promise => ({ order: { orderId: 'order-1', productId: 'points_60', productTitle: '60泥点', kind: 'points', amountCents: 600, status: 'paid', paymentChannel: 'mock', paidAt: '2026-04-25T10:00:00Z', providerTransactionId: null, 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, }, }), ), mockConfirmWechatRpgProfileRechargeOrder: vi.fn( async (): Promise => ({ order: { orderId: 'order-wechat-1', productId: 'points_60', productTitle: '60泥点', kind: 'points', amountCents: 600, status: 'paid', paymentChannel: 'wechat_mp', paidAt: '2026-04-25T10:01:00Z', providerTransactionId: 'wx-transaction-1', 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: [ { productId: 'points_60', title: '60泥点', priceCents: 600, kind: 'points', pointsAmount: 60, bonusPoints: 0, durationDays: 0, badgeLabel: '', description: '60泥点', tier: 'normal', }, ], membershipProducts: [], benefits: [], latestOrder: { orderId: 'order-wechat-1', productId: 'points_60', productTitle: '60泥点', kind: 'points', amountCents: 600, status: 'paid', paymentChannel: 'wechat_mp', providerTransactionId: 'wx-transaction-1', createdAt: '2026-04-25T10:00:00Z', paidAt: '2026-04-25T10:01:00Z', pointsDelta: 120, membershipExpiresAt: null, }, hasPointsRecharged: true, }, }), ), mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({ center: buildReferralCenter({ invitedUsers: [], hasRedeemedCode: true, boundInviterUserId: 'user-2', boundAt: '2026-05-01T08:00:00Z', }), inviteeRewardGranted: true, inviterRewardGranted: true, inviteeBalanceAfter: 30, inviterBalanceAfter: 30, })), 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', }, ], })), }; }); const { mockGetPublicAuthUserByCode, mockGetPublicAuthUserById, mockUpdateAuthProfile, } = vi.hoisted(() => ({ mockGetPublicAuthUserByCode: vi.fn( async (code: string): Promise => ({ id: `id-${code}`, publicUserCode: code, displayName: '公开作者', avatarUrl: null, }), ), mockGetPublicAuthUserById: vi.fn( async (userId: string): Promise => ({ id: userId, publicUserCode: `code-${userId}`, displayName: '公开作者', avatarUrl: null, }), ), mockUpdateAuthProfile: vi.fn(), })); vi.mock('../../services/authService', () => ({ getPublicAuthUserByCode: mockGetPublicAuthUserByCode, getPublicAuthUserById: mockGetPublicAuthUserById, updateAuthProfile: mockUpdateAuthProfile, })); vi.mock('qrcode', () => ({ default: { toDataURL: mockQrCodeToDataUrl, }, })); vi.mock('../../services/payment/paymentRedirect', () => ({ redirectToPaymentUrl: mockRedirectToPaymentUrl, })); mockUpdateAuthProfile.mockResolvedValue({ id: 'user-1', publicUserCode: '100001', username: 'tester', displayName: '测试玩家', avatarUrl: null, phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, createdAt: new Date().toISOString(), }); vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ getRpgProfileReferralInviteCenter: mockGetRpgProfileReferralInviteCenter, getRpgProfileTasks: mockGetRpgProfileTasks, getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger, claimRpgProfileTaskReward: mockClaimRpgProfileTaskReward, redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode, getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter, createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder, confirmWechatRpgProfileRechargeOrder: mockConfirmWechatRpgProfileRechargeOrder, })); 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; function dispatchPointerEvent( target: HTMLElement, type: string, options: { pointerId: number; clientY: number }, ) { const event = new Event(type, { bubbles: true, cancelable: true }); Object.assign(event, options); target.dispatchEvent(event); } function stubImage(width = 800, height = 600) { class MockImage { onload: null | (() => void) = null; onerror: null | (() => void) = null; naturalWidth = width; naturalHeight = height; width = width; height = height; set src(_value: string) { this.onload?.(); } } vi.stubGlobal('Image', MockImage as unknown as typeof Image); } function stubFileReader(dataUrl: string) { class MockFileReader { result: string | null = null; onload: null | (() => void) = null; onerror: null | (() => void) = null; readAsDataURL() { this.result = dataUrl; this.onload?.(); } } vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader); } 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 buildTaggedPuzzleEntry( id: string, worldName: string, themeTags: string[], overrides: Partial = {}, ) { return { ...puzzlePublicEntry, workId: `puzzle-work-${id}`, profileId: `puzzle-profile-${id}`, publicWorkCode: `PZ-${id.toUpperCase()}`, worldName, themeTags, ...overrides, } satisfies PlatformPuzzleGalleryCard; } function buildBabyObjectMatchEntry( id: string, worldName: string, themeTags: string[] = ['寓教于乐'], overrides: Partial = {}, ) { return { sourceType: 'edutainment', templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, workId: `baby-object-match-work-${id}`, profileId: `baby-object-match-profile-${id}`, publicWorkCode: `EDU-${id.toUpperCase()}`, ownerUserId: 'user-edutainment', authorDisplayName: '动作 Demo 作者', worldName, subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, summaryText: '将物品放入对应的篮子里。', coverImageSrc: null, themeTags, playCount: 8, remixCount: 0, likeCount: 4, recentPlayCount7d: 5, visibility: 'published', publishedAt: '2026-05-11T10:00:00.000Z', updatedAt: '2026-05-11T10:00:00.000Z', ...overrides, } satisfies PlatformEdutainmentGalleryCard; } function mockDesktopLayout() { Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', }); Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 0, }); Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: vi.fn().mockImplementation((query: string) => { const normalizedQuery = query.replace(/\s/g, ''); return { matches: normalizedQuery.includes('min-width:1024px') || normalizedQuery.includes('min-width:1024'), media: query, 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' | 'onOpenRecommendGalleryDetail' | 'onSearchPublicCode' | 'recommendRuntimeContent' | 'activeRecommendEntryKey' | 'isStartingRecommendEntry' | 'recommendRuntimeError' | 'onSelectNextRecommendEntry' | 'onSelectPreviousRecommendEntry' > > = {}, activeTab: RpgEntryHomeViewProps['activeTab'] = 'home', ) { return render( undefined), musicVolume: 0.42, setMusicVolume: vi.fn(), platformTheme: 'light', setPlatformTheme: vi.fn(), isHydratingSettings: false, isPersistingSettings: false, settingsError: null, }} > 运行内容 ) } activeRecommendEntryKey={overrides.activeRecommendEntryKey} isStartingRecommendEntry={overrides.isStartingRecommendEntry} recommendRuntimeError={overrides.recommendRuntimeError} onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry} onSelectPreviousRecommendEntry={ overrides.onSelectPreviousRecommendEntry } onOpenLibraryDetail={vi.fn()} onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()} /> , ); } function renderLoggedInHomeView( overrides: Partial< Pick< RpgEntryHomeViewProps, 'activeTab' | 'hasUnreadDraftUpdate' | 'draftTabContent' > > = {}, ) { 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 renderStatefulLoggedOutHomeView( overrides: Partial< Pick< RpgEntryHomeViewProps, | 'featuredEntries' | 'latestEntries' | 'onOpenGalleryDetail' | 'onOpenRecommendGalleryDetail' | 'onSearchPublicCode' | 'recommendRuntimeContent' | 'activeRecommendEntryKey' | 'onSelectNextRecommendEntry' | 'onSelectPreviousRecommendEntry' > > = {}, ) { const authSpies = { openLoginModal: vi.fn(), }; function StatefulLoggedOutHomeView() { const [activeTab, setActiveTab] = useState('category'); return ( undefined), musicVolume: 0.42, setMusicVolume: vi.fn(), platformTheme: 'light', setPlatformTheme: vi.fn(), isHydratingSettings: false, isPersistingSettings: false, settingsError: null, }} > ) } activeRecommendEntryKey={overrides.activeRecommendEntryKey} onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry} onSelectPreviousRecommendEntry={ overrides.onSelectPreviousRecommendEntry } onOpenLibraryDetail={vi.fn()} onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()} /> ); } return { ...render(), openLoginModal: authSpies.openLoginModal, }; } afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); window.wx = undefined; document .querySelectorAll( 'script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]', ) .forEach((script) => script.remove()); mockGetRpgProfileReferralInviteCenter.mockResolvedValue( mockBuildReferralCenter(), ); mockGetRpgProfileTasks.mockResolvedValue(mockBuildTaskCenter()); mockClaimRpgProfileTaskReward.mockResolvedValue({ taskId: 'daily_login', dayKey: 20260503, rewardPoints: 10, walletBalance: 10, ledgerEntry: { id: 'ledger-daily-login', amountDelta: 10, balanceAfter: 10, sourceType: 'daily_task_reward', createdAt: '2026-05-03T08:01:00Z', }, center: mockBuildTaskCenter({ walletBalance: 10, tasks: [ { taskId: 'daily_login', title: '每日登录', description: '', eventKey: 'profile.login.daily', cycle: 'daily', threshold: 1, progressCount: 1, rewardPoints: 10, status: 'claimed', dayKey: 20260503, claimedAt: '2026-05-03T08:01:00Z', updatedAt: '2026-05-03T08:01:00Z', }, ], updatedAt: '2026-05-03T08:01:00Z', }), }); mockUpdateAuthProfile.mockResolvedValue({ id: 'user-1', publicUserCode: '100001', username: 'tester', displayName: '测试玩家', avatarUrl: null, phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, createdAt: new Date().toISOString(), }); mockQrCodeToDataUrl.mockResolvedValue('data:image/png;base64,QR'); mockRedirectToPaymentUrl.mockReset(); 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, }); window.history.replaceState(null, '', '/'); }); 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 recharge modal shows native qr code on desktop web by default', async () => { const user = userEvent.setup(); mockDesktopLayout(); mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { orderId: 'order-native-1', productId: 'points_60', productTitle: '60泥点', kind: 'points', amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_native', paidAt: null, providerTransactionId: null, createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, center: { walletBalance: 0, membership: { status: 'normal', tier: 'normal', startedAt: null, expiresAt: null, updatedAt: null, }, pointProducts: [], membershipProducts: [], benefits: [], latestOrder: null, hasPointsRecharged: false, }, wechatNativePayment: { codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-test', }, }); renderProfileView(); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); await user.click( within(shortcutRegion).getByRole('button', { name: /充值/u }), ); expect(await screen.findByText('账户充值')).toBeTruthy(); expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1); await user.click(screen.getByRole('button', { name: /60泥点/u })); await waitFor(() => { expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith( 'points_60', 'wechat_native', ); }); expect(await screen.findByText('微信扫码支付')).toBeTruthy(); await waitFor(() => { expect(screen.getByAltText('微信 Native 支付二维码')).toBeTruthy(); }); expect(mockQrCodeToDataUrl).toHaveBeenCalledWith( 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-test', expect.objectContaining({ width: 180 }), ); expect(screen.queryByRole('dialog', { name: '支付成功' })).toBeNull(); }); test('profile recharge modal jumps to h5 payment on mobile web by default', async () => { const user = userEvent.setup(); Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile', }); Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: vi.fn().mockImplementation(() => ({ matches: true, media: '(max-width: 767px)', onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), })), }); mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { orderId: 'order-h5-1', productId: 'points_60', productTitle: '60泥点', kind: 'points', amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_h5', paidAt: null, providerTransactionId: null, createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, center: { walletBalance: 0, membership: { status: 'normal', tier: 'normal', startedAt: null, expiresAt: null, updatedAt: null, }, pointProducts: [], membershipProducts: [], benefits: [], latestOrder: null, hasPointsRecharged: false, }, wechatH5Payment: { h5Url: 'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx-h5', }, }); renderProfileView(); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); await user.click( within(shortcutRegion).getByRole('button', { name: /充值/u }), ); await user.click(await screen.findByRole('button', { name: /60泥点/u })); await waitFor(() => { expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith( 'points_60', 'wechat_h5', ); }); expect(mockRedirectToPaymentUrl).toHaveBeenCalledWith( 'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx-h5', ); expect( await screen.findByRole('dialog', { name: '正在打开微信支付' }), ).toBeTruthy(); expect(screen.queryByRole('dialog', { name: '支付成功' })).toBeNull(); }); test('profile recharge modal posts requestPayment params in mini program web-view', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program'); const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { options.success?.(); }); window.wx = { miniProgram: { navigateTo, }, }; mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { orderId: 'order-wechat-1', productId: 'points_60', productTitle: '60泥点', kind: 'points', amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_mp', paidAt: null as string | null, providerTransactionId: null, createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, center: { walletBalance: 0, membership: { status: 'normal', tier: 'normal', startedAt: null, expiresAt: null, updatedAt: null, }, pointProducts: [], membershipProducts: [], benefits: [], latestOrder: null, hasPointsRecharged: false, }, wechatMiniProgramPayParams: { timeStamp: '1777110165', nonceStr: 'nonce', package: 'prepay_id=wx-prepay', signType: 'RSA', paySign: 'signature', }, }); renderProfileView(onRechargeSuccess); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); await user.click( within(shortcutRegion).getByRole('button', { name: /充值/u }), ); await user.click(await screen.findByRole('button', { name: /60泥点/u })); await waitFor(() => { expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith( 'points_60', 'wechat_mp', ); }); expect(navigateTo).toHaveBeenCalledWith({ url: expect.stringContaining('/pages/wechat-pay/index?'), success: expect.any(Function), fail: expect.any(Function), }); const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? ''; const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get( 'requestId', ); expect(requestId).toBeTruthy(); act(() => { window.location.hash = `wx_pay_result=${requestId}:success`; window.dispatchEvent(new HashChangeEvent('hashchange')); }); expect(navigateUrl).toContain('order-wechat-1'); expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay'); expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy(); expect(mockCreateRpgProfileRechargeOrder).not.toHaveBeenCalledWith( 'points_60', 'mock', ); expect(mockRedirectToPaymentUrl).not.toHaveBeenCalled(); expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy(); expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( 'order-wechat-1', ); expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); test('profile recharge modal waits for paid confirmation before refreshing dashboard', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program'); const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { options.success?.(); }); window.wx = { miniProgram: { navigateTo, }, }; mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { orderId: 'order-wechat-pending-then-paid', productId: 'points_60', productTitle: '60泥点', kind: 'points', amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_mp', paidAt: null as string | null, providerTransactionId: null, createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, center: { walletBalance: 0, membership: { status: 'normal', tier: 'normal', startedAt: null, expiresAt: null, updatedAt: null, }, pointProducts: [], membershipProducts: [], benefits: [], latestOrder: null, hasPointsRecharged: false, }, wechatMiniProgramPayParams: { timeStamp: '1777110165', nonceStr: 'nonce', package: 'prepay_id=wx-prepay', signType: 'RSA', paySign: 'signature', }, }); mockConfirmWechatRpgProfileRechargeOrder .mockResolvedValueOnce({ order: { orderId: 'order-wechat-pending-then-paid', productId: 'points_60', productTitle: '60泥点', kind: 'points', amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_mp', paidAt: null, providerTransactionId: null, createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, center: { walletBalance: 0, membership: { status: 'normal', tier: 'normal', startedAt: null, expiresAt: null, updatedAt: null, }, pointProducts: [], membershipProducts: [], benefits: [], latestOrder: null, hasPointsRecharged: false, }, }) .mockResolvedValueOnce({ order: { orderId: 'order-wechat-pending-then-paid', productId: 'points_60', productTitle: '60泥点', kind: 'points', amountCents: 600, status: 'paid' as const, paymentChannel: 'wechat_mp', paidAt: '2026-04-25T10:01:00Z', providerTransactionId: 'wx-transaction-2', 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, }, }); renderProfileView(onRechargeSuccess); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); await user.click( within(shortcutRegion).getByRole('button', { name: /充值/u }), ); await user.click(await screen.findByRole('button', { name: /60泥点/u })); const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? ''; const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get( 'requestId', ); expect(requestId).toBeTruthy(); await act(async () => { window.location.hash = `wx_pay_result=${requestId}:success`; window.dispatchEvent(new HashChangeEvent('hashchange')); }); expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(1); expect(onRechargeSuccess).not.toHaveBeenCalled(); await waitFor(() => { expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledTimes(2); }); expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy(); expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); test('profile recharge modal loads wechat js sdk before mini program payment bridge', async () => { const user = userEvent.setup(); window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program'); window.wx = undefined; const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { options.success?.(); }); mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { orderId: 'order-wechat-sdk-1', productId: 'points_60', productTitle: '60泥点', kind: 'points', amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_mp', paidAt: null as string | null, providerTransactionId: null, createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, center: { walletBalance: 0, membership: { status: 'normal', tier: 'normal', startedAt: null, expiresAt: null, updatedAt: null, }, pointProducts: [], membershipProducts: [], benefits: [], latestOrder: null, hasPointsRecharged: false, }, wechatMiniProgramPayParams: { timeStamp: '1777110165', nonceStr: 'nonce', package: 'prepay_id=wx-prepay', signType: 'RSA', paySign: 'signature', }, }); renderProfileView(); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); await user.click( within(shortcutRegion).getByRole('button', { name: /充值/u }), ); await user.click(await screen.findByRole('button', { name: /60泥点/u })); await waitFor(() => { const script = document.querySelector( 'script[src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"]', ); expect(script).toBeTruthy(); window.wx = { miniProgram: { navigateTo, }, }; script?.dispatchEvent(new Event('load')); }); await waitFor(() => { expect(navigateTo).toHaveBeenCalledWith({ url: expect.stringContaining('/pages/wechat-pay/index?'), success: expect.any(Function), fail: expect.any(Function), }); }); const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? ''; const requestId = new URL(`https://mini.test${navigateUrl}`).searchParams.get( 'requestId', ); expect(requestId).toBeTruthy(); act(() => { window.location.hash = `wx_pay_result=${requestId}:success`; window.dispatchEvent(new HashChangeEvent('hashchange')); }); expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy(); }); test('profile recharge modal releases submitting state after cancelled wechat pay result', async () => { const user = userEvent.setup(); window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program'); const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { options.success?.(); }); window.wx = { miniProgram: { navigateTo, }, }; mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { orderId: 'order-wechat-cancel-1', productId: 'points_60', productTitle: '60泥点', kind: 'points', amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_mp', paidAt: null as string | null, providerTransactionId: null, createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, center: { walletBalance: 0, membership: { status: 'normal', tier: 'normal', startedAt: null, expiresAt: null, updatedAt: null, }, pointProducts: [], membershipProducts: [], benefits: [], latestOrder: null, hasPointsRecharged: false, }, wechatMiniProgramPayParams: { timeStamp: '1777110165', nonceStr: 'nonce', package: 'prepay_id=wx-prepay-cancel', signType: 'RSA', paySign: 'signature', }, }); renderProfileView(); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); await user.click( within(shortcutRegion).getByRole('button', { name: /充值/u }), ); const buyButton = await screen.findByRole('button', { name: /60泥点/u }); await user.click(buyButton); await waitFor(() => { expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith( 'points_60', 'wechat_mp', ); }); expect( within(buyButton).getByText('处理中', { selector: 'span' }), ).toBeTruthy(); const requestUrl = navigateTo.mock.calls[0]?.[0].url ?? ''; const requestId = new URL(`https://mini.test${requestUrl}`).searchParams.get( 'requestId', ); expect(requestId).toBeTruthy(); act(() => { window.location.hash = `wx_pay_result=${requestId}:cancel`; window.dispatchEvent(new HashChangeEvent('hashchange')); }); expect( await screen.findByRole('dialog', { name: '支付已取消' }), ).toBeTruthy(); expect(screen.getByText('本次没有扣款,账户状态未发生变化。')).toBeTruthy(); await waitFor(() => { expect( within(screen.getByRole('button', { name: /60泥点/u })).getByText( '购买', { selector: 'span' }, ), ).toBeTruthy(); }); expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled(); }); test('profile native qr confirmation refreshes only after server reports paid', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); mockDesktopLayout(); mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { orderId: 'order-native-paid', productId: 'points_60', productTitle: '60泥点', kind: 'points', amountCents: 600, status: 'pending' as const, paymentChannel: 'wechat_native', paidAt: null, providerTransactionId: null, createdAt: '2026-04-25T10:00:00Z', pointsDelta: 0, membershipExpiresAt: null, }, center: { walletBalance: 0, membership: { status: 'normal', tier: 'normal', startedAt: null, expiresAt: null, updatedAt: null, }, pointProducts: [], membershipProducts: [], benefits: [], latestOrder: null, hasPointsRecharged: false, }, wechatNativePayment: { codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-paid', }, }); mockConfirmWechatRpgProfileRechargeOrder.mockResolvedValueOnce({ order: { orderId: 'order-native-paid', productId: 'points_60', productTitle: '60泥点', kind: 'points', amountCents: 600, status: 'paid' as const, paymentChannel: 'wechat_native', paidAt: '2026-04-25T10:01:00Z', providerTransactionId: 'wx-native-1', 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, }, }); renderProfileView(onRechargeSuccess); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); await user.click( within(shortcutRegion).getByRole('button', { name: /充值/u }), ); await user.click(await screen.findByRole('button', { name: /60泥点/u })); await user.click(await screen.findByRole('button', { name: '我已支付' })); await waitFor(() => { expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( 'order-native-paid', ); }); expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy(); expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); test('profile daily task shortcut opens task center and claims reward', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); renderProfileView(onRechargeSuccess); await user.click(screen.getByRole('button', { name: /每日任务/u })); expect(await screen.findByText('每日登录')).toBeTruthy(); expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1); expect(screen.getByText('1/1')).toBeTruthy(); expect(screen.getByText('+10')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '领取' })); await waitFor(() => { expect(mockClaimRpgProfileTaskReward).toHaveBeenCalledWith('daily_login'); }); expect(onRechargeSuccess).toHaveBeenCalledTimes(1); expect(await screen.findByText('已领取 10 泥点')).toBeTruthy(); expect( (screen.getByRole('button', { name: '已领取' }) as HTMLButtonElement) .disabled, ).toBe(true); }); 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('profile avatar upload uses the shared square crop tool', async () => { stubFileReader('data:image/png;base64,avatar-source'); stubImage(800, 600); renderProfileView(); fireEvent.click(screen.getByRole('button', { name: '上传头像' })); fireEvent.change(screen.getByLabelText('上传头像', { selector: 'input' }), { target: { files: [new File(['x'], 'avatar.png', { type: 'image/png' })], }, }); await waitFor(() => { expect(screen.getByRole('dialog', { name: '裁剪头像' })).toBeTruthy(); }); expect(screen.getByLabelText('头像裁剪操作区')).toBeTruthy(); expect( screen.getByRole('button', { name: '拖拽右下角裁剪边界' }), ).toBeTruthy(); expect(screen.queryByText('缩放')).toBeNull(); expect(screen.queryByText('横向')).toBeNull(); expect(screen.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('profile invite shortcut shows reward subtitle and invited users', async () => { const user = userEvent.setup(); renderProfileView(); const inviteButton = screen.getByRole('button', { name: /邀请好友/u }); expect(within(inviteButton).getByText('双方得30')).toBeTruthy(); const communityButton = screen.getByRole('button', { name: /玩家社区/u }); expect(within(communityButton).getByText('每日领福利')).toBeTruthy(); await user.click(inviteButton); expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1); expect( await screen.findByText('邀请一个用户注册,双方都可以获得30泥点。'), ).toBeTruthy(); expect(screen.getByText('每日最多获得十次邀请奖励。')).toBeTruthy(); expect(screen.getByText('成功邀请')).toBeTruthy(); expect(screen.getByText('被邀请玩家')).toBeTruthy(); expect(screen.queryByText('已奖')).toBeNull(); expect(screen.queryByText('今日')).toBeNull(); }); test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => { renderProfileView(); const inviteButton = screen.getByRole('button', { name: /邀请好友/u }); const redeemButton = await screen.findByRole('button', { name: /填邀请码/u, }); const communityButton = screen.getByRole('button', { name: /玩家社区/u }); expect( inviteButton.compareDocumentPosition(redeemButton) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect( redeemButton.compareDocumentPosition(communityButton) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy(); }); test('profile redeem invite shortcut hides after redeemed or one day old', async () => { const user = userEvent.setup(); mockGetRpgProfileReferralInviteCenter.mockResolvedValueOnce( mockBuildReferralCenter({ invitedUsers: [], hasRedeemedCode: true, boundInviterUserId: 'user-2', boundAt: '2026-05-01T08:00:00Z', }), ); const { unmount } = renderProfileView(); await user.click(screen.getByRole('button', { name: /邀请好友/u })); await screen.findByText('成功邀请'); const firstShortcutRegion = screen.getByRole('region', { name: '常用功能' }); expect( within(firstShortcutRegion).queryByRole('button', { name: /填邀请码/u }), ).toBeNull(); unmount(); renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' }); const expiredShortcutRegion = screen.getByRole('region', { name: '常用功能', }); expect( within(expiredShortcutRegion).queryByRole('button', { name: /填邀请码/u, }), ).toBeNull(); }); test('invite query opens login modal for logged out users', async () => { const openLoginModal = vi.fn(); window.history.replaceState(null, '', '/?inviteCode=spring-2026'); renderLoggedOutHomeView(openLoginModal); await waitFor(() => { expect(openLoginModal).toHaveBeenCalledTimes(1); }); }); test('invite query opens redeem modal directly for logged in users', async () => { window.history.replaceState(null, '', '/?inviteCode=spring-2026'); renderProfileView(); const input = await screen.findByLabelText('邀请码'); expect((input as HTMLInputElement).value).toBe('SPRING2026'); }); test('profile redeem invite modal reads query invite code after login', async () => { window.history.replaceState(null, '', '/?inviteCode=spring-2026'); renderProfileView(); const input = await screen.findByLabelText('邀请码'); expect((input as HTMLInputElement).value).toBe('SPRING2026'); }); test('profile redeem invite modal submits code and hides shortcut after success', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); renderProfileView(onRechargeSuccess); await user.click(await screen.findByRole('button', { name: /填邀请码/u })); const input = await screen.findByLabelText('邀请码'); await user.type(input, 'spring-2026'); await user.click(screen.getByRole('button', { name: '提交' })); await waitFor(() => { expect(mockRedeemRpgProfileReferralInviteCode).toHaveBeenCalledWith( 'SPRING2026', ); }); expect(onRechargeSuccess).toHaveBeenCalledTimes(1); expect(await screen.findByText('已填写')).toBeTruthy(); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); expect( within(shortcutRegion).queryByRole('button', { name: /填邀请码/u, }), ).toBeNull(); }); 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('profile page shows legal entries and ICP record link', async () => { const user = userEvent.setup(); renderProfileView(); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); expect( shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'), ).toBe(true); expect( within(shortcutRegion).getByRole('button', { name: /每日任务/u }), ).toBeTruthy(); expect( within(shortcutRegion).getByRole('button', { name: /邀请好友/u }), ).toBeTruthy(); expect( within(shortcutRegion).getByRole('button', { name: /玩家社区/u }), ).toBeTruthy(); expect( within(shortcutRegion).getByRole('button', { name: /反馈/u }), ).toBeTruthy(); const legalRegion = screen.getByRole('region', { name: '法律信息' }); expect( within(legalRegion).getByRole('button', { name: /用户协议/u }), ).toBeTruthy(); expect( within(legalRegion).getByRole('button', { name: /隐私政策/u }), ).toBeTruthy(); expect( within(legalRegion).getByRole('button', { name: /免责声明/u }), ).toBeTruthy(); const recordLink = within(legalRegion).getByRole('link', { name: ICP_RECORD_NUMBER, }); expect(recordLink.getAttribute('href')).toBe(ICP_RECORD_URL); expect(recordLink.getAttribute('target')).toBe('_blank'); expect(recordLink.getAttribute('rel')).toBe('noreferrer'); await user.click( within(legalRegion).getByRole('button', { name: /隐私政策/u }), ); expect(await screen.findByRole('dialog', { name: '隐私政策' })).toBeTruthy(); }); test('shows a reachable login entry outside mobile recommend tab', async () => { const user = userEvent.setup(); const openLoginModal = vi.fn(); renderLoggedOutHomeView(openLoginModal, {}, 'category'); await user.click(screen.getByRole('button', { name: '登录' })); expect(openLoginModal).toHaveBeenCalledTimes(1); }); test('logged out bottom nav turns active recommend tab into next action', () => { const { container } = renderLoggedOutHomeView(vi.fn()); const nav = container.querySelector('.platform-bottom-nav'); expect(nav).toBeTruthy(); const buttons = within(nav as HTMLElement).getAllByRole('button'); expect(buttons.map((button) => button.textContent)).toEqual([ '下一个', '创作', '发现', ]); expect(buttons[0]?.querySelector('.lucide-chevron-down')).toBeTruthy(); expect(buttons[1]?.querySelector('.lucide-sparkles')).toBeTruthy(); expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy(); }); test('logged in draft bottom tab shows unread marker', () => { const { container } = renderLoggedInHomeView({ hasUnreadDraftUpdate: true, draftTabContent:
草稿内容
, }); const nav = container.querySelector('.platform-bottom-nav'); expect(nav).toBeTruthy(); const draftButton = within(nav as HTMLElement).getByRole('button', { name: '草稿,有新草稿', }); expect(draftButton.querySelector('.platform-nav-unread-dot')).toBeTruthy(); }); test('mobile discover search submits public work code', async () => { const user = userEvent.setup(); const onSearchPublicCode = vi.fn(); renderStatefulLoggedOutHomeView({ onSearchPublicCode }); await user.click(screen.getByRole('button', { name: '发现' })); const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述'); await user.type(searchInput, 'PZ-PROFILE1{enter}'); expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1'); }); test('discover search fuzzy matches public work id, name, author and description', async () => { const user = userEvent.setup(); const onOpenGalleryDetail = vi.fn(); const onSearchPublicCode = vi.fn(); const entries = [ { ...puzzlePublicEntry, workId: 'puzzle-work-moon-gate', profileId: 'puzzle-profile-moon-gate', publicWorkCode: 'PZ-MOON01', authorDisplayName: '月井守望', worldName: '月井机关', summaryText: '需要沿着银色水路重新点亮机关。', }, { ...puzzlePublicEntry, workId: 'puzzle-work-fire-bridge', profileId: 'puzzle-profile-fire-bridge', publicWorkCode: 'PZ-FIRE02', authorDisplayName: '晨风', worldName: '火桥谜图', summaryText: '跨过熔岩断桥寻找遗失碎片。', }, ] satisfies PlatformPublicGalleryCard[]; renderStatefulLoggedOutHomeView({ latestEntries: entries, onOpenGalleryDetail, onSearchPublicCode, }); await user.click(screen.getByRole('button', { name: '发现' })); const discoverPanel = document.getElementById('platform-tab-panel-category'); if (!discoverPanel) { throw new Error('缺少发现面板'); } const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述'); await user.type(searchInput, 'MOON01{enter}'); expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy(); expect(within(discoverPanel).getByText('月井机关')).toBeTruthy(); expect(within(discoverPanel).queryByText('火桥谜图')).toBeNull(); expect(onSearchPublicCode).not.toHaveBeenCalled(); await user.clear(searchInput); await user.type(searchInput, '火桥{enter}'); expect(await within(discoverPanel).findByText('火桥谜图')).toBeTruthy(); expect(within(discoverPanel).queryByText('月井机关')).toBeNull(); await user.clear(searchInput); await user.type(searchInput, '月井守望{enter}'); expect(await within(discoverPanel).findByText('月井机关')).toBeTruthy(); expect(within(discoverPanel).queryByText('火桥谜图')).toBeNull(); await user.clear(searchInput); await user.type(searchInput, '熔岩断桥{enter}'); expect(await within(discoverPanel).findByText('火桥谜图')).toBeTruthy(); expect(within(discoverPanel).queryByText('月井机关')).toBeNull(); await user.click(screen.getByRole('button', { name: /火桥谜图/u })); expect(onOpenGalleryDetail).toHaveBeenCalledWith(entries[1]); }); test('mobile discover keeps edutainment works in the last dedicated channel only', async () => { const user = userEvent.setup(); const onSearchPublicCode = vi.fn(); const generalEntry = buildTaggedPuzzleEntry('normal01', '普通拼图作品', [ '儿童教育', ]); const edutainmentEntry = buildTaggedPuzzleEntry( 'edu001', '儿童动作热身 Demo', ['运动', '安全', '拼图', '寓教于乐'], { playCount: 99, remixCount: 30, likeCount: 50, recentPlayCount7d: 88, publishedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), summaryText: '寓教于乐专属内容', }, ); renderStatefulLoggedOutHomeView({ latestEntries: [edutainmentEntry, generalEntry], onSearchPublicCode, }); await user.click(screen.getByRole('button', { name: '发现' })); const discoverPanel = document.getElementById('platform-tab-panel-category'); if (!discoverPanel) { throw new Error('缺少发现面板'); } const channels = Array.from( discoverPanel.querySelectorAll('.platform-mobile-home-channel'), ).map((button) => button.textContent); expect(channels).toEqual(['推荐', '今日', '分类', '排行', '寓教于乐']); expect(within(discoverPanel).getByText('普通拼图作品')).toBeTruthy(); expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull(); await user.click(screen.getByRole('button', { name: '今日' })); expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull(); await user.click(screen.getByRole('button', { name: '分类' })); expect(screen.getByRole('button', { name: '儿童教育' })).toBeTruthy(); expect(screen.queryByRole('button', { name: '寓教于乐' })).toBeTruthy(); expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull(); await user.click(screen.getByRole('button', { name: '排行' })); expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull(); await user.click(screen.getByRole('button', { name: '寓教于乐' })); expect( within(discoverPanel).getByRole('button', { name: /儿童动作热身 Demo/u, }), ).toBeTruthy(); expect(within(discoverPanel).queryByText('普通拼图作品')).toBeNull(); const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述'); await user.type(searchInput, '儿童动作热身{enter}'); expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy(); expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull(); expect(onSearchPublicCode).not.toHaveBeenCalled(); }); test('mobile discover hides edutainment channel and work when switch is disabled', async () => { vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false'); const user = userEvent.setup(); const onSearchPublicCode = vi.fn(); const edutainmentEntry = buildTaggedPuzzleEntry( 'eduoff1', '关闭后隐藏的热身 Demo', ['寓教于乐'], { summaryText: '关闭后不可见', publishedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, ); renderStatefulLoggedOutHomeView({ latestEntries: [edutainmentEntry], onSearchPublicCode, }); await user.click(screen.getByRole('button', { name: '发现' })); const discoverPanel = document.getElementById('platform-tab-panel-category'); if (!discoverPanel) { throw new Error('缺少发现面板'); } const channels = Array.from( discoverPanel.querySelectorAll('.platform-mobile-home-channel'), ).map((button) => button.textContent); expect(channels).toEqual(['推荐', '今日', '分类', '排行']); expect(within(discoverPanel).queryByText('关闭后隐藏的热身 Demo')).toBeNull(); const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述'); await user.type(searchInput, 'PZ-EDUOFF1{enter}'); expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy(); expect(within(discoverPanel).queryByText('关闭后隐藏的热身 Demo')).toBeNull(); expect(onSearchPublicCode).not.toHaveBeenCalled(); }); test('mobile discover keeps baby object match works in edutainment channel only', async () => { const user = userEvent.setup(); const onSearchPublicCode = vi.fn(); const onOpenGalleryDetail = vi.fn(); const babyObjectMatchEntry = buildBabyObjectMatchEntry( 'baby01', '宝贝识物水果篮', ); const generalEntry = buildTaggedPuzzleEntry('normal02', '普通拼图作品', [ '儿童教育', ]); renderStatefulLoggedOutHomeView({ latestEntries: [babyObjectMatchEntry, generalEntry], onOpenGalleryDetail, onSearchPublicCode, }); await user.click(screen.getByRole('button', { name: '发现' })); const discoverPanel = document.getElementById('platform-tab-panel-category'); if (!discoverPanel) { throw new Error('缺少发现面板'); } expect(within(discoverPanel).getByText('普通拼图作品')).toBeTruthy(); expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull(); await user.click(screen.getByRole('button', { name: '寓教于乐' })); const babyObjectMatchButton = within(discoverPanel).getByRole('button', { name: /宝贝识物水果篮/u, }); expect(within(babyObjectMatchButton).getByText('宝贝识物')).toBeTruthy(); expect(within(discoverPanel).queryByText('普通拼图作品')).toBeNull(); await user.click(babyObjectMatchButton); expect(onOpenGalleryDetail).toHaveBeenCalledWith(babyObjectMatchEntry); const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述'); await user.type(searchInput, '宝贝识物水果篮{enter}'); expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy(); expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull(); expect(onSearchPublicCode).not.toHaveBeenCalled(); }); test('discover search keeps public code fallback when local works do not match', async () => { const user = userEvent.setup(); const onSearchPublicCode = vi.fn(); renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry], onSearchPublicCode, }); await user.click(screen.getByRole('button', { name: '发现' })); const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述'); await user.type(searchInput, 'CW-REMOTE-ONLY{enter}'); expect(onSearchPublicCode).toHaveBeenCalledWith('CW-REMOTE-ONLY'); expect(screen.queryByText('搜索结果')).toBeNull(); }); test('public gallery cards hide work code until detail is opened', async () => { const user = userEvent.setup(); const onOpenGalleryDetail = vi.fn(); renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry], onOpenGalleryDetail, }); await user.click(screen.getByRole('button', { name: '发现' })); 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('logged out mobile shell defaults to discover tab', () => { const { container } = renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry], }); const activePanel = container.querySelector('.platform-tab-panel--active'); expect(activePanel?.id).toBe('platform-tab-panel-category'); expect( screen.getByPlaceholderText('搜索作品号、名称、作者、描述'), ).toBeTruthy(); expect(container.querySelector('.platform-mobile-topbar')).toBeTruthy(); expect( container.querySelector('.platform-mobile-entry-shell--recommend'), ).toBeNull(); }); test('logged out recommend tab opens login modal and shows cover only', async () => { const user = userEvent.setup(); const { container, openLoginModal } = renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', }); const bottomNav = container.querySelector('.platform-bottom-nav'); if (!bottomNav) { throw new Error('缺少底部导航'); } await user.click( within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }), ); expect(openLoginModal).toHaveBeenCalledTimes(1); expect( container.querySelector('.platform-recommend-cover-only'), ).toBeTruthy(); expect(container.querySelector('.platform-mobile-topbar')).toBeNull(); expect( container.querySelector('.platform-mobile-entry-shell--recommend'), ).toBeTruthy(); expect(screen.queryByTestId('recommend-runtime')).toBeNull(); expect(screen.queryByLabelText('奇幻拼图 作品信息')).toBeNull(); expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0); }); test('logged out recommend cover opens login modal again', async () => { const user = userEvent.setup(); const onOpenGalleryDetail = vi.fn(); const { openLoginModal } = renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', onOpenGalleryDetail, }); const bottomNav = document.querySelector('.platform-bottom-nav'); if (!bottomNav) { throw new Error('缺少底部导航'); } await user.click( within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }), ); await user.click( screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u }), ); expect(openLoginModal).toHaveBeenCalledTimes(2); expect(openLoginModal).toHaveBeenLastCalledWith(); expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); test('logged out desktop recommend page renders cover only', () => { mockDesktopLayout(); renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', }); expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy(); expect(screen.queryByText('今日游戏')).toBeNull(); expect(screen.queryByText('作品分类')).toBeNull(); expect(screen.queryByTestId('recommend-runtime')).toBeNull(); }); test('logged in recommend page uses gated recommend detail callback', async () => { const user = userEvent.setup(); const onOpenGalleryDetail = vi.fn(); const onOpenRecommendGalleryDetail = vi.fn(); 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, }} > , ); await user.click(screen.getByText('作品暂时无法进入,请稍后再试。')); expect(onOpenRecommendGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry); expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); test('logged out mobile recommend page renders cover instead of runtime', () => { const onOpenGalleryDetail = vi.fn(); renderLoggedOutHomeView( vi.fn(), { latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', onOpenGalleryDetail, }, 'home', ); expect(screen.queryByTestId('recommend-runtime')).toBeNull(); expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy(); expect( document.querySelector('.platform-public-work-card__cover'), ).toBeNull(); expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0); fireEvent.click(screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u })); expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); test('mobile recommend loading state is themed instead of hardcoded black', () => { renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', isStartingRecommendEntry: true, recommendRuntimeContent: null, }); expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy(); }); test('logged in recommend runtime preloads adjacent work previews and drag switches like video feed', () => { vi.useFakeTimers(); const onSelectNextRecommendEntry = vi.fn(); const onSelectPreviousRecommendEntry = vi.fn(); const onLikeRecommendEntry = vi.fn(); const onRemixRecommendEntry = vi.fn(); const firstEntry = { ...puzzlePublicEntry, workId: 'puzzle-work-feed-1', profileId: 'puzzle-profile-feed-1', ownerUserId: 'user-feed-1', publicWorkCode: 'PZ-FEED1', worldName: '当前拼图', coverImageSrc: 'current-cover.png', } satisfies PlatformPublicGalleryCard; const secondEntry = { ...puzzlePublicEntry, workId: 'puzzle-work-feed-2', profileId: 'puzzle-profile-feed-2', ownerUserId: 'user-feed-2', publicWorkCode: 'PZ-FEED2', worldName: '下一拼图', coverImageSrc: 'next-cover.png', } satisfies PlatformPublicGalleryCard; const thirdEntry = { ...puzzlePublicEntry, workId: 'puzzle-work-feed-3', profileId: 'puzzle-profile-feed-3', ownerUserId: 'user-feed-3', publicWorkCode: 'PZ-FEED3', worldName: '上一拼图', coverImageSrc: 'previous-cover.png', } satisfies PlatformPublicGalleryCard; 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, }} > } activeRecommendEntryKey="puzzle:user-feed-1:puzzle-profile-feed-1" onSelectNextRecommendEntry={onSelectNextRecommendEntry} onSelectPreviousRecommendEntry={onSelectPreviousRecommendEntry} onLikeRecommendEntry={onLikeRecommendEntry} onRemixRecommendEntry={onRemixRecommendEntry} onOpenLibraryDetail={vi.fn()} onSearchPublicCode={vi.fn()} /> , ); expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); expect( document.querySelectorAll('.platform-recommend-runtime-preview'), ).toHaveLength(2); expect( document.querySelectorAll('.platform-recommend-swipe-card'), ).toHaveLength(3); expect(screen.getAllByText('下一拼图').length).toBeGreaterThanOrEqual(2); expect(screen.getAllByText('上一拼图').length).toBeGreaterThanOrEqual(2); expect(screen.queryByText('评论')).toBeNull(); expect(screen.queryByLabelText(/游玩/u)).toBeNull(); const clipboardWriteText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: clipboardWriteText }, }); const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement; expect(meta.closest('[data-recommend-swipe-zone="true"]')).toBeTruthy(); const activeRecommendCard = within(meta); const likeButton = activeRecommendCard.getByRole('button', { name: '点赞 12', }); expect(likeButton).toBeTruthy(); expect(activeRecommendCard.getByLabelText('12 个赞')).toBeTruthy(); const shareButton = activeRecommendCard.getByRole('button', { name: '分享' }); const remixButton = activeRecommendCard.getByRole('button', { name: '改造 5', }); expect(shareButton).toBeTruthy(); expect(remixButton).toBeTruthy(); fireEvent.click(likeButton); fireEvent.click(shareButton); fireEvent.click(remixButton); expect(onLikeRecommendEntry).toHaveBeenCalledWith(firstEntry); expect(onRemixRecommendEntry).toHaveBeenCalledWith(firstEntry); expect(clipboardWriteText).toHaveBeenCalledWith( expect.stringContaining('作品号:PZ-FEED1'), ); act(() => { dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 300 }); dispatchPointerEvent(meta, 'pointermove', { pointerId: 1, clientY: 210 }); }); const rail = document.querySelector( '.platform-recommend-swipe-rail', ) as HTMLElement | null; expect(rail?.className).toContain('platform-recommend-swipe-rail'); act(() => { dispatchPointerEvent(meta, 'pointerup', { pointerId: 1, clientY: 210 }); vi.advanceTimersByTime(180); }); expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1); expect(onSelectPreviousRecommendEntry).not.toHaveBeenCalled(); vi.useRealTimers(); }); test('logged out active recommend bottom tab selects next work without login', async () => { const user = userEvent.setup(); const onSelectNextRecommendEntry = vi.fn(); const openLoginModal = vi.fn(); renderLoggedOutHomeView(openLoginModal, { latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', onSelectNextRecommendEntry, }); await user.click(screen.getByRole('button', { name: '下一个' })); expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1); expect(openLoginModal).not.toHaveBeenCalled(); }); test('mobile recommend meta loads real author avatar from public user summary', async () => { mockGetPublicAuthUserById.mockResolvedValueOnce({ id: 'user-2', publicUserCode: 'SY-00000002', displayName: '拼图玩家', avatarUrl: 'data:image/png;base64,AUTHOR', }); renderLoggedOutHomeView(vi.fn(), { featuredEntries: [puzzlePublicEntry], latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', }); await waitFor(() => { expect( document .querySelector('.platform-recommend-cover-only__author img') ?.getAttribute('src'), ).toBe('data:image/png;base64,AUTHOR'); }); expect(mockGetPublicAuthUserById).toHaveBeenCalledTimes(1); expect(mockGetPublicAuthUserById).toHaveBeenCalledWith('user-2'); expect(mockGetPublicAuthUserByCode).not.toHaveBeenCalled(); }); test('mobile discover recommend feed only rotates the card closest to screen center', async () => { 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(); renderStatefulLoggedOutHomeView({ latestEntries: [firstEntry, secondEntry], }); act(() => { screen.getByRole('button', { name: '发现' }).click(); }); 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; renderStatefulLoggedOutHomeView({ latestEntries: [yesterdayEntry, updatedTodayEntry, todayEntry], }); await user.click(screen.getByRole('button', { name: '发现' })); await user.click(screen.getByRole('button', { name: '今日' })); const discoverPanel = document.getElementById('platform-tab-panel-category'); if (!discoverPanel) { throw new Error('缺少发现面板'); } expect( within(discoverPanel).getByRole('button', { name: /今日新游/u }), ).toBeTruthy(); expect(within(discoverPanel).queryByText('昨日旧作')).toBeNull(); expect(within(discoverPanel).queryByText('今日更新旧作')).toBeNull(); }); test('desktop logged in 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; 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, }} > , ); expect(screen.getByText('今日游戏')).toBeTruthy(); expect(screen.getAllByText('推荐').length).toBeGreaterThan(0); 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 } = renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry], }); expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull(); expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull(); await user.click(screen.getByRole('button', { name: '发现' })); 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(); renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry, hotRankEntry], }); await user.click(screen.getByRole('button', { name: '发现' })); 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], }); await user.click(screen.getByRole('button', { name: '发现' })); 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: '发现' })); 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(); });