Large changes across frontend, backend and docs to align creation-tab and generation-page behavior with new product UI/UX and Spacetime bindings. Updated hermes decision-log and pitfalls with concrete rules (banner carousel, font sizing, unread-dot tokens, template-card layout, direct card->entry routing, separation of account balance vs prize pools, removal of global page card shell, generation progress milestones and unified circular progress, and background video handling). Added GenerationProgressHero component and media assets, plus generation-related UI/tests updates (CustomWorldGenerationView, BarkBattleGeneratingView, creation hub/cards, platform entry routing, index tests). Backend and contract updates include new category fields in admin API types and admin UI form/list, spacetime-client/module/migration changes and generated bindings script. Misc: many tests adjusted, new docs and plan files added, and several server-rs crate changes to support the updated creation/ generation workflows.
3512 lines
111 KiB
TypeScript
3512 lines
111 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,
|
||
fallbackSrc,
|
||
alt,
|
||
className,
|
||
...rest
|
||
}: {
|
||
src?: string | null;
|
||
fallbackSrc?: string | null;
|
||
alt?: string;
|
||
className?: string;
|
||
}) =>
|
||
src ? (
|
||
<img
|
||
src={src}
|
||
data-fallback-src={fallbackSrc ?? undefined}
|
||
alt={alt ?? ''}
|
||
className={className}
|
||
{...rest}
|
||
/>
|
||
) : null,
|
||
}));
|
||
|
||
const originalMatchMedia = window.matchMedia;
|
||
const originalUserAgent = navigator.userAgent;
|
||
const originalMaxTouchPoints = navigator.maxTouchPoints;
|
||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||
const DEFAULT_PROFILE_CREATED_AT = '2026-04-01T00:00:00.000Z';
|
||
|
||
function buildFreshProfileCreatedAt() {
|
||
return new Date().toISOString();
|
||
}
|
||
|
||
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 mockWechatDesktopLayout() {
|
||
mockDesktopLayout();
|
||
Object.defineProperty(navigator, 'userAgent', {
|
||
configurable: true,
|
||
value:
|
||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 MicroMessenger/8.0',
|
||
});
|
||
}
|
||
|
||
function mockWechatMobileLayout() {
|
||
Object.defineProperty(navigator, 'userAgent', {
|
||
configurable: true,
|
||
value:
|
||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit MicroMessenger/8.0 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(),
|
||
})),
|
||
});
|
||
}
|
||
|
||
function mockNarrowMobileLayout() {
|
||
Object.defineProperty(navigator, 'userAgent', {
|
||
configurable: true,
|
||
value:
|
||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit Mobile',
|
||
});
|
||
Object.defineProperty(window, 'matchMedia', {
|
||
configurable: true,
|
||
writable: true,
|
||
value: vi.fn().mockImplementation((query: string) => {
|
||
const normalizedQuery = query.replace(/\s/g, '');
|
||
return {
|
||
matches:
|
||
normalizedQuery.includes('max-width:767px') ||
|
||
normalizedQuery.includes('max-width:768px'),
|
||
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: DEFAULT_PROFILE_CREATED_AT,
|
||
...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>,
|
||
);
|
||
}
|
||
|
||
async function openRechargeModal(user: ReturnType<typeof userEvent.setup>) {
|
||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||
await user.click(
|
||
within(shortcutRegion).getByRole('button', { name: /充值/u }),
|
||
);
|
||
}
|
||
|
||
function renderLoggedOutHomeView(
|
||
openLoginModal = vi.fn(),
|
||
overrides: Partial<
|
||
Pick<
|
||
RpgEntryHomeViewProps,
|
||
| 'featuredEntries'
|
||
| 'latestEntries'
|
||
| 'onOpenGalleryDetail'
|
||
| 'onOpenRecommendGalleryDetail'
|
||
| 'onOpenChildMotionDemo'
|
||
| '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()}
|
||
onOpenChildMotionDemo={overrides.onOpenChildMotionDemo}
|
||
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'
|
||
| 'onOpenChildMotionDemo'
|
||
| '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()}
|
||
onOpenChildMotionDemo={overrides.onOpenChildMotionDemo}
|
||
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: DEFAULT_PROFILE_CREATED_AT,
|
||
});
|
||
mockQrCodeToDataUrl.mockResolvedValue('data:image/png;base64,QR');
|
||
mockRedirectToPaymentUrl.mockReset();
|
||
Object.defineProperty(window, 'matchMedia', {
|
||
configurable: true,
|
||
writable: true,
|
||
value: originalMatchMedia,
|
||
});
|
||
Object.defineProperty(navigator, 'userAgent', {
|
||
configurable: true,
|
||
value: originalUserAgent,
|
||
});
|
||
Object.defineProperty(navigator, 'maxTouchPoints', {
|
||
configurable: true,
|
||
value: originalMaxTouchPoints,
|
||
});
|
||
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();
|
||
mockWechatDesktopLayout();
|
||
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();
|
||
mockWechatMobileLayout();
|
||
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 trusts per-product first bonus display after points recharge', async () => {
|
||
const user = userEvent.setup();
|
||
mockWechatDesktopLayout();
|
||
mockGetRpgProfileRechargeCenter.mockResolvedValueOnce({
|
||
walletBalance: 60,
|
||
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: [],
|
||
benefits: [],
|
||
latestOrder: null,
|
||
hasPointsRecharged: true,
|
||
});
|
||
|
||
renderProfileView();
|
||
await openRechargeModal(user);
|
||
|
||
const rechargeDialog = await screen.findByText('账户充值');
|
||
expect(rechargeDialog).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: /60泥点/u })).toBeTruthy();
|
||
expect(screen.getByText('首充双倍')).toBeTruthy();
|
||
expect(screen.getByText('60+60泥点')).toBeTruthy();
|
||
});
|
||
|
||
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);
|
||
await openRechargeModal(user);
|
||
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);
|
||
await openRechargeModal(user);
|
||
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();
|
||
await openRechargeModal(user);
|
||
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();
|
||
await openRechargeModal(user);
|
||
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();
|
||
mockWechatDesktopLayout();
|
||
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('non-wechat profile opens reward code from recharge-shaped entry', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
renderProfileView();
|
||
|
||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||
expect(
|
||
within(shortcutRegion).getByRole('button', { name: /泥点充值/u }),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(shortcutRegion).getByRole('button', { name: /兑换码/u }),
|
||
).toBeTruthy();
|
||
await user.click(
|
||
within(shortcutRegion).getByRole('button', { name: /泥点充值/u }),
|
||
);
|
||
expect(await screen.findByPlaceholderText('输入兑换码')).toBeTruthy();
|
||
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
|
||
});
|
||
|
||
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('profile stats cards are centered without update timestamp', () => {
|
||
renderProfileView(vi.fn(), {
|
||
updatedAt: '2026-05-03T08:01:00Z',
|
||
});
|
||
|
||
const walletCard = screen.getByRole('button', {
|
||
name: /泥点余额\s*0/u,
|
||
});
|
||
const playTimeCard = screen.getByRole('button', { name: /游戏时长|累计游戏时长/u });
|
||
const playedCard = screen.getByRole('button', { name: /已玩游戏数量\s*0个/u });
|
||
|
||
for (const card of [walletCard, playTimeCard, playedCard]) {
|
||
expect(card.className).toContain('platform-profile-stat-card');
|
||
expect(card.className).toContain('text-center');
|
||
}
|
||
expect(screen.queryByText(/更新于/u)).toBeNull();
|
||
});
|
||
|
||
test('mobile profile page matches the reference layout sections', async () => {
|
||
mockWechatMobileLayout();
|
||
|
||
const { container } = renderProfileView(vi.fn(), {
|
||
walletBalance: 70,
|
||
totalPlayTimeMs: 0,
|
||
playedWorldCount: 0,
|
||
}, { createdAt: buildFreshProfileCreatedAt() });
|
||
|
||
const profilePage = container.querySelector('.platform-profile-page');
|
||
expect(profilePage).toBeTruthy();
|
||
expect(profilePage?.classList.contains('platform-page-stage')).toBe(true);
|
||
expect(profilePage?.querySelector('.platform-profile-scene-decor')).toBeTruthy();
|
||
expect(profilePage?.classList.contains('platform-profile-page')).toBe(true);
|
||
expect(profilePage?.getAttribute('style') ?? '').not.toContain('overflow: hidden');
|
||
|
||
const membershipCard = screen.getByRole('button', { name: '查看权益' });
|
||
expect(membershipCard.className).toContain('platform-profile-membership-card');
|
||
expect(
|
||
within(membershipCard).getByText('普通用户').className,
|
||
).toContain('platform-profile-membership-card__title');
|
||
expect(within(membershipCard).getByText('普通用户')).toBeTruthy();
|
||
expect(within(membershipCard).getByText('升级会员,享专属特权与福利')).toBeTruthy();
|
||
|
||
const statPanel = screen.getByRole('region', { name: '我的数据' });
|
||
expect(statPanel.className).toContain('platform-profile-stats-panel');
|
||
expect(statPanel.querySelector('.platform-profile-stats-grid')).toBeTruthy();
|
||
expect(within(statPanel).getByRole('button', { name: /泥点余额\s*70/u })).toBeTruthy();
|
||
expect(within(statPanel).getByRole('button', { name: /累计游戏时长\s*0小时/u })).toBeTruthy();
|
||
expect(within(statPanel).getByRole('button', { name: /已玩游戏数量\s*0个/u })).toBeTruthy();
|
||
expect(
|
||
within(statPanel).getByRole('button', { name: /泥点余额\s*70/u }).className,
|
||
).toContain('platform-profile-stat-card');
|
||
|
||
const dailyTask = screen.getByRole('button', { name: /每日任务/u });
|
||
expect(dailyTask.className).toContain('platform-profile-daily-task-card');
|
||
expect(dailyTask.querySelector('.platform-profile-daily-task-card__title')).toBeTruthy();
|
||
expect(dailyTask.querySelector('.platform-profile-daily-task-card__desc')).toBeTruthy();
|
||
expect(dailyTask.querySelector('.platform-profile-daily-task-card__progress')).toBeTruthy();
|
||
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
|
||
expect(within(dailyTask).getByText('0 / 1')).toBeTruthy();
|
||
|
||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||
expect(
|
||
shortcutRegion.querySelector('.platform-profile-shortcut-grid'),
|
||
).toBeTruthy();
|
||
expect(
|
||
shortcutRegion.querySelectorAll('.platform-profile-shortcut-button'),
|
||
).toHaveLength(5);
|
||
expect(
|
||
shortcutRegion
|
||
.querySelector('.platform-profile-shortcut-grid')
|
||
?.classList.contains('platform-profile-shortcut-grid'),
|
||
).toBe(true);
|
||
for (const label of [
|
||
'泥点充值',
|
||
'邀请好友',
|
||
'兑换码',
|
||
'玩家社区',
|
||
'反馈与建议',
|
||
]) {
|
||
expect(
|
||
within(shortcutRegion).getByRole('button', { name: new RegExp(label, 'u') }),
|
||
).toBeTruthy();
|
||
}
|
||
|
||
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
|
||
for (const label of ['主题设置', '账号与安全', '通用设置']) {
|
||
expect(
|
||
within(settingsRegion).getByRole('button', { name: new RegExp(label, 'u') }),
|
||
).toBeTruthy();
|
||
}
|
||
|
||
const secondaryShortcuts = screen.getByRole('region', {
|
||
name: '次级入口',
|
||
});
|
||
expect(
|
||
within(secondaryShortcuts).getByRole('button', { name: /存档/u }),
|
||
).toBeTruthy();
|
||
expect(
|
||
await within(secondaryShortcuts).findByRole('button', {
|
||
name: /填邀请码/u,
|
||
}),
|
||
).toBeTruthy();
|
||
|
||
const profileHeader = profilePage?.querySelector('.platform-profile-header');
|
||
expect(profileHeader).toBeTruthy();
|
||
expect(profileHeader?.querySelector('.platform-profile-header__identity-row')).toBeTruthy();
|
||
expect(profileHeader?.querySelector('.platform-profile-header__name')).toBeTruthy();
|
||
expect(profileHeader?.querySelector('.platform-profile-header__code')).toBeTruthy();
|
||
|
||
const legalRegion = screen.getByRole('region', { name: '法律信息' });
|
||
expect(legalRegion.className).toContain('platform-profile-legal-strip');
|
||
expect(legalRegion.textContent).toContain('用户协议');
|
||
expect(legalRegion.textContent).toContain('隐私政策');
|
||
expect(legalRegion.textContent).toContain('免责声明');
|
||
expect(legalRegion.textContent).toContain(ICP_RECORD_NUMBER);
|
||
expect(legalRegion.textContent).toContain('2026025677');
|
||
expect(legalRegion.querySelector('.platform-profile-legal-strip__link')).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 waitFor(() => {
|
||
expect(screen.getByText('暂无账单记录')).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();
|
||
await waitFor(() => {
|
||
expect(screen.getByText('加载失败')).toBeTruthy();
|
||
});
|
||
expect(screen.getByText('重新加载')).toBeTruthy();
|
||
});
|
||
|
||
test('profile invite shortcut shows reward subtitle and invited users', async () => {
|
||
const user = userEvent.setup();
|
||
|
||
renderProfileView(vi.fn(), {}, { createdAt: buildFreshProfileCreatedAt() });
|
||
|
||
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(
|
||
vi.fn(),
|
||
{},
|
||
{ createdAt: buildFreshProfileCreatedAt() },
|
||
);
|
||
|
||
const inviteButton = screen.getByRole('button', { name: /邀请好友/u });
|
||
const redeemButton = await screen.findByRole('button', {
|
||
name: /填邀请码/u,
|
||
});
|
||
const communityButton = screen.getByRole('button', { name: /玩家社区/u });
|
||
const secondaryShortcuts = screen.getByRole('region', {
|
||
name: '次级入口',
|
||
});
|
||
|
||
expect(inviteButton).toBeTruthy();
|
||
expect(communityButton).toBeTruthy();
|
||
expect(
|
||
within(secondaryShortcuts).getByRole('button', { name: /填邀请码/u }),
|
||
).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,
|
||
{},
|
||
{ createdAt: buildFreshProfileCreatedAt() },
|
||
);
|
||
|
||
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();
|
||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||
await user.click(
|
||
within(shortcutRegion).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('.platform-profile-shortcut-grid')
|
||
?.classList.contains('platform-profile-shortcut-grid'),
|
||
).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();
|
||
const dailyTask = screen.getByRole('button', { name: /每日任务/u });
|
||
expect(dailyTask).toBeTruthy();
|
||
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
|
||
|
||
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
|
||
expect(
|
||
within(settingsRegion).getByRole('button', { name: /存档/u }),
|
||
).toBeTruthy();
|
||
|
||
const secondaryShortcuts = screen.getByRole('region', {
|
||
name: '次级入口',
|
||
});
|
||
expect(
|
||
within(secondaryShortcuts).getByRole('button', { name: /存档/u }),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(secondaryShortcuts).queryByRole('button', { name: /填邀请码/u }),
|
||
).toBeNull();
|
||
|
||
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();
|
||
expect(nav?.classList.contains('platform-bottom-nav')).toBe(true);
|
||
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();
|
||
expect(
|
||
buttons[1]?.querySelector('.platform-bottom-nav__primary-action'),
|
||
).toBeTruthy();
|
||
expect(
|
||
buttons[0]?.querySelector('.platform-bottom-nav__active-mark'),
|
||
).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('logged in create tab shows real wallet balance beside the brand', () => {
|
||
mockNarrowMobileLayout();
|
||
|
||
const { container } = render(
|
||
<AuthUiContext.Provider
|
||
value={{
|
||
user: {
|
||
id: 'user-1',
|
||
publicUserCode: '100001',
|
||
username: 'tester',
|
||
displayName: '测试玩家',
|
||
avatarUrl: null,
|
||
phoneNumberMasked: null,
|
||
loginMethod: 'password',
|
||
bindingStatus: 'active',
|
||
wechatBound: false,
|
||
createdAt: DEFAULT_PROFILE_CREATED_AT,
|
||
},
|
||
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="create"
|
||
onTabChange={vi.fn()}
|
||
hasSavedGame={false}
|
||
savedSnapshot={null}
|
||
saveEntries={[]}
|
||
saveError={null}
|
||
featuredEntries={[]}
|
||
latestEntries={[]}
|
||
myEntries={[]}
|
||
historyEntries={[]}
|
||
profileDashboard={{
|
||
walletBalance: 1234,
|
||
totalPlayTimeMs: 0,
|
||
playedWorldCount: 0,
|
||
updatedAt: 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()}
|
||
createTabContent={<div>创作内容</div>}
|
||
/>
|
||
</AuthUiContext.Provider>,
|
||
);
|
||
|
||
const topbar = container.querySelector('.platform-mobile-topbar');
|
||
expect(topbar).toBeTruthy();
|
||
expect(
|
||
topbar?.querySelector('.platform-mobile-create-wallet-chip'),
|
||
).toBeTruthy();
|
||
expect(topbar?.textContent).toContain('陶泥儿');
|
||
expect(topbar?.textContent).toContain('1,234泥点');
|
||
});
|
||
|
||
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 onOpenChildMotionDemo = 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],
|
||
onOpenChildMotionDemo,
|
||
onSearchPublicCode,
|
||
});
|
||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||
const discoverPanel = document.getElementById('platform-tab-panel-category');
|
||
if (!discoverPanel) {
|
||
throw new Error('缺少发现面板');
|
||
}
|
||
|
||
const discoverStage = discoverPanel.querySelector(
|
||
'.platform-mobile-home-stage',
|
||
);
|
||
expect(discoverStage).toBeTruthy();
|
||
expect(discoverStage?.classList.contains('platform-remap-surface')).toBe(
|
||
true,
|
||
);
|
||
expect(discoverStage?.classList.contains('platform-page-stage')).toBe(false);
|
||
|
||
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();
|
||
const warmupButton = within(discoverPanel).getByRole('button', {
|
||
name: /热身关卡/u,
|
||
});
|
||
expect(warmupButton).toBeTruthy();
|
||
await user.click(warmupButton);
|
||
expect(onOpenChildMotionDemo).toHaveBeenCalledTimes(1);
|
||
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('desktop discover shows child motion demo in edutainment channel', async () => {
|
||
mockDesktopLayout();
|
||
const user = userEvent.setup();
|
||
const onOpenChildMotionDemo = vi.fn();
|
||
|
||
renderStatefulLoggedOutHomeView({
|
||
onOpenChildMotionDemo,
|
||
});
|
||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||
await user.click(screen.getByRole('button', { name: '寓教于乐' }));
|
||
|
||
const warmupButton = screen.getByRole('button', { name: /热身关卡/u });
|
||
expect(warmupButton).toBeTruthy();
|
||
await user.click(warmupButton);
|
||
expect(onOpenChildMotionDemo).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
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 discover recommend feed renders cover fallback for legacy browsers', async () => {
|
||
renderStatefulLoggedOutHomeView({
|
||
latestEntries: [
|
||
{
|
||
...puzzlePublicEntry,
|
||
coverImageSrc:
|
||
'/generated-puzzle-assets/puzzle-session-1/cover/image.png',
|
||
},
|
||
],
|
||
});
|
||
|
||
const discoverPanel = document.getElementById('platform-tab-panel-category');
|
||
if (!discoverPanel) {
|
||
throw new Error('缺少发现面板');
|
||
}
|
||
|
||
const card = within(discoverPanel).getByRole('button', { name: /奇幻拼图/u });
|
||
const cover = card.querySelector('.platform-public-work-card__cover');
|
||
const image = within(card).getByRole('img');
|
||
|
||
expect(cover).toBeTruthy();
|
||
expect(cover?.className).toContain('platform-public-work-card__cover');
|
||
expect(image.getAttribute('src')).toBe(
|
||
'/generated-puzzle-assets/puzzle-session-1/cover/image.png',
|
||
);
|
||
expect(image.getAttribute('data-fallback-src')).toBe(
|
||
'/creation-type-references/puzzle.webp',
|
||
);
|
||
});
|
||
|
||
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.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('mobile game category filter dialog filters by play type', async () => {
|
||
const user = userEvent.setup();
|
||
const match3dEntry = {
|
||
...puzzlePublicEntry,
|
||
sourceType: 'match3d',
|
||
workId: 'match3d-work-category-filter',
|
||
profileId: 'match3d-profile-category-filter',
|
||
publicWorkCode: 'M3D-FILTER',
|
||
worldName: '奇幻抓鹅',
|
||
subtitle: '抓大鹅关卡',
|
||
summaryText: '一组用于筛选的抓大鹅作品。',
|
||
playCount: 6,
|
||
remixCount: 0,
|
||
likeCount: 1,
|
||
publishedAt: '2026-04-26T10:00:00.000Z',
|
||
updatedAt: '2026-04-26T10:00:00.000Z',
|
||
} satisfies PlatformPublicGalleryCard;
|
||
|
||
renderStatefulLoggedOutHomeView({
|
||
latestEntries: [puzzlePublicEntry, match3dEntry],
|
||
});
|
||
|
||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||
await user.click(screen.getByRole('button', { name: '奇幻' }));
|
||
|
||
expect(screen.getByRole('button', { name: /奇幻拼图,试玩/u })).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: /奇幻抓鹅,进入/u })).toBeTruthy();
|
||
|
||
await user.click(screen.getByRole('button', { name: /筛选/u }));
|
||
const filterDialog = await screen.findByRole('dialog', {
|
||
name: '分类筛选',
|
||
});
|
||
|
||
await user.click(within(filterDialog).getByRole('button', { name: '抓鹅' }));
|
||
|
||
expect(screen.queryByRole('button', { name: /奇幻拼图,试玩/u })).toBeNull();
|
||
expect(screen.getByRole('button', { name: /奇幻抓鹅,进入/u })).toBeTruthy();
|
||
});
|
||
|
||
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();
|
||
});
|