This commit is contained in:
2026-05-01 20:29:09 +08:00
parent 8718472dbd
commit 87fbf41fab
137 changed files with 2922 additions and 989 deletions

View File

@@ -1,11 +1,15 @@
/* @vitest-environment jsdom */
import { act, render, screen, within } from '@testing-library/react';
import { act, 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 } from '../../services/authService';
import type {
AuthUser,
PublicUserSummary,
} from '../../../packages/shared/src/contracts/auth';
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
import { AuthUiContext } from '../auth/AuthUiContext';
import {
RpgEntryHomeView,
@@ -13,29 +17,122 @@ import {
} from './RpgEntryHomeView';
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
const { mockGetRpgProfileWalletLedger } = vi.hoisted(() => ({
mockGetRpgProfileWalletLedger: vi.fn(async () => ({
entries: [
const {
mockBuildReferralCenter,
mockGetRpgProfileReferralInviteCenter,
mockGetRpgProfileWalletLedger,
mockRedeemRpgProfileReferralInviteCode,
} = vi.hoisted(() => {
const buildReferralCenter = (
overrides: Partial<ProfileReferralInviteCenterResponse> = {},
): ProfileReferralInviteCenterResponse => ({
inviteCode: 'SY12345678',
inviteLinkPath: '/?inviteCode=SY12345678',
invitedCount: 1,
rewardedInviteCount: 1,
todayInviterRewardCount: 1,
todayInviterRewardRemaining: 9,
rewardPoints: 30,
invitedUsers: [
{
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',
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,
});
return {
mockBuildReferralCenter: buildReferralCenter,
mockGetRpgProfileReferralInviteCenter: vi.fn(async () =>
buildReferralCenter(),
),
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,
}));
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,
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
getRpgProfileRechargeCenter: vi.fn(async () => ({
walletBalance: 0,
membership: {
@@ -48,14 +145,14 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
pointProducts: [
{
productId: 'points_60',
title: '60陶泥币',
title: '60光点',
priceCents: 600,
kind: 'points',
pointsAmount: 60,
bonusPoints: 60,
durationDays: 0,
badgeLabel: '首充双倍',
description: '首充送60陶泥币',
description: '首充送60光点',
tier: 'normal',
},
],
@@ -75,7 +172,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
],
benefits: [
{
benefitName: '免陶泥币回合数',
benefitName: '免光点回合数',
normalValue: '30',
monthValue: '100',
seasonValue: '100',
@@ -89,7 +186,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
order: {
orderId: 'order-1',
productId: 'points_60',
productTitle: '60陶泥币',
productTitle: '60光点',
kind: 'points',
amountCents: 600,
status: 'paid',
@@ -278,6 +375,7 @@ function renderProfileView(
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
...userOverrides,
},
canAccessProtectedData: true,
@@ -457,6 +555,21 @@ function renderStatefulLoggedOutHomeView(
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
mockGetRpgProfileReferralInviteCenter.mockResolvedValue(
mockBuildReferralCenter(),
);
mockUpdateAuthProfile.mockResolvedValue({
id: 'user-1',
publicUserCode: '100001',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
@@ -482,9 +595,9 @@ 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 }));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('陶泥币账单')).toBeTruthy();
expect(await screen.findByText('光点账单')).toBeTruthy();
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
expect(screen.getByText('资产操作消耗')).toBeTruthy();
expect(screen.getByText('-1')).toBeTruthy();
@@ -534,17 +647,116 @@ test('wallet ledger modal shows empty and error states', async () => {
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
renderProfileView();
await user.click(screen.getByRole('button', { name: /\s*0/u }));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
await user.click(screen.getByLabelText('关闭陶泥币账单'));
await user.click(screen.getByLabelText('关闭光点账单'));
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('加载失败')).toBeTruthy();
expect(screen.getByText('重新加载')).toBeTruthy();
});
test('profile invite shortcut shows reward subtitle and invited users', async () => {
const user = userEvent.setup();
renderProfileView();
const inviteButton = screen.getByRole('button', { name: //u });
expect(within(inviteButton).getByText('双方得30')).toBeTruthy();
const communityButton = screen.getByRole('button', { name: //u });
expect(within(communityButton).getByText('每日领福利')).toBeTruthy();
await user.click(inviteButton);
expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1);
expect(
await screen.findByText('邀请一个用户注册双方都可以获得30光点。'),
).toBeTruthy();
expect(screen.getByText('每日最多获得十次邀请奖励。')).toBeTruthy();
expect(screen.getByText('成功邀请')).toBeTruthy();
expect(screen.getByText('被邀请玩家')).toBeTruthy();
expect(screen.queryByText('已奖')).toBeNull();
expect(screen.queryByText('今日')).toBeNull();
});
test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => {
renderProfileView();
const inviteButton = screen.getByRole('button', { name: //u });
const redeemButton = await screen.findByRole('button', {
name: //u,
});
const communityButton = screen.getByRole('button', { name: //u });
expect(
inviteButton.compareDocumentPosition(redeemButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
redeemButton.compareDocumentPosition(communityButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy();
});
test('profile redeem invite shortcut hides after redeemed or one day old', async () => {
const user = userEvent.setup();
mockGetRpgProfileReferralInviteCenter.mockResolvedValueOnce(
mockBuildReferralCenter({
invitedUsers: [],
hasRedeemedCode: true,
boundInviterUserId: 'user-2',
boundAt: '2026-05-01T08:00:00Z',
}),
);
const { unmount } = renderProfileView();
await user.click(screen.getByRole('button', { name: //u }));
await screen.findByText('成功邀请');
const firstShortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
within(firstShortcutRegion).queryByRole('button', { name: //u }),
).toBeNull();
unmount();
renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' });
const expiredShortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
within(expiredShortcutRegion).queryByRole('button', {
name: //u,
}),
).toBeNull();
});
test('profile redeem invite modal submits code and hides shortcut after success', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess);
await user.click(await screen.findByRole('button', { name: //u }));
const input = await screen.findByLabelText('邀请码');
await user.type(input, 'spring-2026');
await user.click(screen.getByRole('button', { name: '提交' }));
await waitFor(() => {
expect(mockRedeemRpgProfileReferralInviteCode).toHaveBeenCalledWith(
'SPRING2026',
);
});
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
expect(await screen.findByText('已填写')).toBeTruthy();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
within(shortcutRegion).queryByRole('button', {
name: //u,
}),
).toBeNull();
});
test('opens reward code modal from profile action on mobile', async () => {
const user = userEvent.setup();
@@ -620,13 +832,86 @@ test('mobile home search submits public work code', async () => {
);
const searchInput = screen.getByPlaceholderText(
'输入 SY / CW / BF / M3 / PZ 编号',
'搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'PZ-PROFILE1{enter}');
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
});
test('home 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[];
renderLoggedOutHomeView(vi.fn(), {
latestEntries: entries,
onOpenGalleryDetail,
onSearchPublicCode,
});
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
await user.type(searchInput, 'MOON01{enter}');
expect(await screen.findByText('搜索结果')).toBeTruthy();
expect(screen.getByText('月井机关')).toBeTruthy();
expect(screen.queryByText('火桥谜图')).toBeNull();
expect(onSearchPublicCode).not.toHaveBeenCalled();
await user.clear(searchInput);
await user.type(searchInput, '火桥{enter}');
expect(await screen.findByText('火桥谜图')).toBeTruthy();
expect(screen.queryByText('月井机关')).toBeNull();
await user.clear(searchInput);
await user.type(searchInput, '月井守望{enter}');
expect(await screen.findByText('月井机关')).toBeTruthy();
expect(screen.queryByText('火桥谜图')).toBeNull();
await user.clear(searchInput);
await user.type(searchInput, '熔岩断桥{enter}');
expect(await screen.findByText('火桥谜图')).toBeTruthy();
expect(screen.queryByText('月井机关')).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
expect(onOpenGalleryDetail).toHaveBeenCalledWith(entries[1]);
});
test('home search keeps public code fallback when local works do not match', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
onSearchPublicCode,
});
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();
@@ -686,6 +971,35 @@ test('mobile public work cards render cover, author, kind and cover stats', () =
).toBe('推荐');
});
test('public work cards load 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],
});
const card = screen.getByRole('button', {
name: /20512/u,
});
await waitFor(() => {
expect(
card
.querySelector('.platform-public-work-card__author-avatar-image')
?.getAttribute('src'),
).toBe('data:image/png;base64,AUTHOR');
});
expect(mockGetPublicAuthUserById).toHaveBeenCalledTimes(1);
expect(mockGetPublicAuthUserById).toHaveBeenCalledWith('user-2');
expect(mockGetPublicAuthUserByCode).not.toHaveBeenCalled();
});
test('mobile home feed only rotates the card closest to screen center', () => {
vi.useFakeTimers();
Object.defineProperty(window, 'requestAnimationFrame', {