Files
Genarrative/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx

3046 lines
95 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @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> = {},
): 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> = {},
): 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<CreateProfileRechargeOrderResponse> => ({
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<ConfirmWechatProfileRechargeOrderResponse> => ({
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<PublicUserSummary> => ({
id: `id-${code}`,
publicUserCode: code,
displayName: '公开作者',
avatarUrl: null,
}),
),
mockGetPublicAuthUserById: vi.fn(
async (userId: string): Promise<PublicUserSummary> => ({
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 ? (
<img src={src} alt={alt ?? ''} className={className} {...rest} />
) : 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<PlatformPuzzleGalleryCard> = {},
) {
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<PlatformEdutainmentGalleryCard> = {},
) {
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<RpgEntryHomeViewProps['profileDashboard']>
> = {},
userOverrides: Partial<AuthUser> = {},
) {
return render(
<AuthUiContext.Provider
value={{
user: {
id: 'user-1',
publicUserCode: '100001',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
...userOverrides,
},
canAccessProtectedData: true,
openLoginModal: vi.fn(),
requireAuth: (action) => 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,
}}
>
<RpgEntryHomeView
activeTab="profile"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
latestEntries={[]}
myEntries={[]}
historyEntries={[]}
profileDashboard={{
walletBalance: 0,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: null,
...profileDashboardOverrides,
}}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={vi.fn()}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
onRechargeSuccess={onRechargeSuccess}
/>
</AuthUiContext.Provider>,
);
}
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(
<AuthUiContext.Provider
value={{
user: null,
canAccessProtectedData: false,
openLoginModal,
requireAuth: vi.fn(),
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,
}}
>
<RpgEntryHomeView
activeTab={activeTab}
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={overrides.featuredEntries ?? []}
latestEntries={overrides.latestEntries ?? []}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
onOpenRecommendGalleryDetail={overrides.onOpenRecommendGalleryDetail}
recommendRuntimeContent={
overrides.recommendRuntimeContent ?? (
<div data-testid="recommend-runtime"></div>
)
}
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
recommendRuntimeError={overrides.recommendRuntimeError}
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
onSelectPreviousRecommendEntry={
overrides.onSelectPreviousRecommendEntry
}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
/>
</AuthUiContext.Provider>,
);
}
function renderLoggedInHomeView(
overrides: Partial<
Pick<
RpgEntryHomeViewProps,
'activeTab' | 'hasUnreadDraftUpdate' | 'draftTabContent'
>
> = {},
) {
return render(
<AuthUiContext.Provider
value={{
user: {
id: 'user-1',
publicUserCode: '100001',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
},
canAccessProtectedData: true,
openLoginModal: vi.fn(),
requireAuth: (action) => 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,
}}
>
<RpgEntryHomeView
activeTab={overrides.activeTab ?? 'saves'}
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
latestEntries={[]}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={vi.fn()}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
hasUnreadDraftUpdate={overrides.hasUnreadDraftUpdate ?? false}
draftTabContent={overrides.draftTabContent}
/>
</AuthUiContext.Provider>,
);
}
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<RpgEntryHomeViewProps['activeTab']>('category');
return (
<AuthUiContext.Provider
value={{
user: null,
canAccessProtectedData: false,
openLoginModal: authSpies.openLoginModal,
requireAuth: vi.fn(),
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,
}}
>
<RpgEntryHomeView
activeTab={activeTab}
onTabChange={setActiveTab}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={overrides.featuredEntries ?? []}
latestEntries={overrides.latestEntries ?? []}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
onOpenRecommendGalleryDetail={overrides.onOpenRecommendGalleryDetail}
recommendRuntimeContent={
overrides.recommendRuntimeContent ?? (
<div data-testid="recommend-runtime" />
)
}
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
onSelectPreviousRecommendEntry={
overrides.onSelectPreviousRecommendEntry
}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
/>
</AuthUiContext.Provider>
);
}
return {
...render(<StatefulLoggedOutHomeView />),
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<HTMLScriptElement>(
'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: <div>稿</div>,
});
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(
<AuthUiContext.Provider
value={{
user: {
id: 'user-1',
publicUserCode: '100001',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
},
canAccessProtectedData: true,
openLoginModal: vi.fn(),
requireAuth: (action) => 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,
}}
>
<RpgEntryHomeView
activeTab="home"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
latestEntries={[puzzlePublicEntry]}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={onOpenGalleryDetail}
onOpenRecommendGalleryDetail={onOpenRecommendGalleryDetail}
recommendRuntimeError="作品暂时无法进入,请稍后再试。"
activeRecommendEntryKey="puzzle:user-2:puzzle-profile-public-1"
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
/>
</AuthUiContext.Provider>,
);
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(
<AuthUiContext.Provider
value={{
user: {
id: 'user-1',
publicUserCode: '100001',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
},
canAccessProtectedData: true,
openLoginModal: vi.fn(),
requireAuth: (action) => 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,
}}
>
<RpgEntryHomeView
activeTab="home"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
latestEntries={[firstEntry, secondEntry, thirdEntry]}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={vi.fn()}
recommendRuntimeContent={<div data-testid="recommend-runtime" />}
activeRecommendEntryKey="puzzle:user-feed-1:puzzle-profile-feed-1"
onSelectNextRecommendEntry={onSelectNextRecommendEntry}
onSelectPreviousRecommendEntry={onSelectPreviousRecommendEntry}
onLikeRecommendEntry={onLikeRecommendEntry}
onRemixRecommendEntry={onRemixRecommendEntry}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
/>
</AuthUiContext.Provider>,
);
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<string, DOMRect>();
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(
<AuthUiContext.Provider
value={{
user: {
id: 'user-1',
publicUserCode: '100001',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
},
canAccessProtectedData: true,
openLoginModal: vi.fn(),
requireAuth: (action) => 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,
}}
>
<RpgEntryHomeView
activeTab="home"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
latestEntries={[puzzlePublicEntry, todayEntry]}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={vi.fn()}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
/>
</AuthUiContext.Provider>,
);
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();
});