3046 lines
95 KiB
TypeScript
3046 lines
95 KiB
TypeScript
/* @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();
|
||
});
|