424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { render, screen } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { afterEach, expect, test, vi } from 'vitest';
|
|
|
|
import { AuthUiContext } from '../auth/AuthUiContext';
|
|
import {
|
|
RpgEntryHomeView,
|
|
type RpgEntryHomeViewProps,
|
|
} from './RpgEntryHomeView';
|
|
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
|
|
|
|
const { mockGetRpgProfileWalletLedger } = vi.hoisted(() => ({
|
|
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',
|
|
},
|
|
],
|
|
})),
|
|
}));
|
|
|
|
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
|
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
|
|
getRpgProfileRechargeCenter: 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,
|
|
})),
|
|
createRpgProfileRechargeOrder: vi.fn(async () => ({
|
|
order: {
|
|
orderId: 'order-1',
|
|
productId: 'points_60',
|
|
productTitle: '60叙世币',
|
|
kind: 'points',
|
|
amountCents: 600,
|
|
status: 'paid',
|
|
paymentChannel: 'mock',
|
|
paidAt: '2026-04-25T10:00:00Z',
|
|
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,
|
|
},
|
|
})),
|
|
}));
|
|
|
|
vi.mock('../ResolvedAssetImage', () => ({
|
|
ResolvedAssetImage: () => null,
|
|
}));
|
|
|
|
const originalMatchMedia = window.matchMedia;
|
|
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: ['奇幻'],
|
|
likeCount: 12,
|
|
visibility: 'published',
|
|
publishedAt: '1777110165.990127Z',
|
|
updatedAt: '2026-04-25T10:00:00.000Z',
|
|
} satisfies PlatformPublicGalleryCard;
|
|
|
|
function mockDesktopLayout() {
|
|
Object.defineProperty(window, 'matchMedia', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: vi.fn().mockImplementation(() => ({
|
|
matches: true,
|
|
media: '(min-width: 1024px)',
|
|
onchange: null,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})),
|
|
});
|
|
}
|
|
|
|
function renderProfileView(onRechargeSuccess = vi.fn()) {
|
|
return render(
|
|
<AuthUiContext.Provider
|
|
value={{
|
|
user: {
|
|
id: 'user-1',
|
|
publicUserCode: '100001',
|
|
username: 'tester',
|
|
displayName: '测试玩家',
|
|
phoneNumberMasked: null,
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
},
|
|
canAccessProtectedData: true,
|
|
openLoginModal: vi.fn(),
|
|
requireAuth: (action) => action(),
|
|
openSettingsModal: vi.fn(),
|
|
openAccountModal: 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,
|
|
}}
|
|
isLoadingPlatform={false}
|
|
isLoadingDashboard={false}
|
|
isResumingSaveWorldKey={null}
|
|
platformError={null}
|
|
dashboardError={null}
|
|
onContinueGame={vi.fn()}
|
|
onResumeSave={vi.fn()}
|
|
onOpenCreateWorld={vi.fn()}
|
|
onOpenCreateTypePicker={vi.fn()}
|
|
onOpenGalleryDetail={vi.fn()}
|
|
onOpenLibraryDetail={vi.fn()}
|
|
onSearchPublicCode={vi.fn()}
|
|
onRechargeSuccess={onRechargeSuccess}
|
|
/>
|
|
</AuthUiContext.Provider>,
|
|
);
|
|
}
|
|
|
|
function renderLoggedOutHomeView(
|
|
openLoginModal = vi.fn(),
|
|
overrides: Partial<
|
|
Pick<
|
|
RpgEntryHomeViewProps,
|
|
| 'featuredEntries'
|
|
| 'latestEntries'
|
|
| 'onOpenGalleryDetail'
|
|
| 'onSearchPublicCode'
|
|
>
|
|
> = {},
|
|
) {
|
|
return render(
|
|
<AuthUiContext.Provider
|
|
value={{
|
|
user: null,
|
|
canAccessProtectedData: false,
|
|
openLoginModal,
|
|
requireAuth: vi.fn(),
|
|
openSettingsModal: vi.fn(),
|
|
openAccountModal: 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={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()}
|
|
onOpenLibraryDetail={vi.fn()}
|
|
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
|
|
/>
|
|
</AuthUiContext.Provider>,
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
Object.defineProperty(window, 'matchMedia', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: originalMatchMedia,
|
|
});
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: undefined,
|
|
});
|
|
});
|
|
|
|
test('opens wallet ledger modal from narrative coin card', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
renderProfileView();
|
|
await user.click(screen.getByText('剩余叙世币'));
|
|
|
|
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('wallet ledger modal shows empty and error states', async () => {
|
|
const user = userEvent.setup();
|
|
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
|
|
|
|
renderProfileView();
|
|
await user.click(screen.getByText('剩余叙世币'));
|
|
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
|
|
|
|
await user.click(screen.getByLabelText('关闭叙世币账单'));
|
|
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
|
|
await user.click(screen.getByText('剩余叙世币'));
|
|
|
|
expect(await screen.findByText('加载失败')).toBeTruthy();
|
|
expect(screen.getByText('重新加载')).toBeTruthy();
|
|
});
|
|
|
|
test('shows a reachable login entry in logged out mobile shell', async () => {
|
|
const user = userEvent.setup();
|
|
const openLoginModal = vi.fn();
|
|
|
|
renderLoggedOutHomeView(openLoginModal);
|
|
await user.click(screen.getByRole('button', { name: '登录' }));
|
|
|
|
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('mobile home search submits public work code', async () => {
|
|
const user = userEvent.setup();
|
|
const onSearchPublicCode = vi.fn();
|
|
|
|
render(
|
|
<AuthUiContext.Provider
|
|
value={{
|
|
user: null,
|
|
canAccessProtectedData: false,
|
|
openLoginModal: vi.fn(),
|
|
requireAuth: vi.fn(),
|
|
openSettingsModal: vi.fn(),
|
|
openAccountModal: 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={[]}
|
|
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={onSearchPublicCode}
|
|
/>
|
|
</AuthUiContext.Provider>,
|
|
);
|
|
|
|
const searchInput = screen.getByPlaceholderText(
|
|
'输入 SY / CW / BF / PZ 编号',
|
|
);
|
|
await user.type(searchInput, 'PZ-PROFILE1{enter}');
|
|
|
|
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
|
|
});
|
|
|
|
test('public gallery cards hide work code until detail is opened', async () => {
|
|
const user = userEvent.setup();
|
|
const onOpenGalleryDetail = vi.fn();
|
|
|
|
renderLoggedOutHomeView(vi.fn(), {
|
|
latestEntries: [puzzlePublicEntry],
|
|
onOpenGalleryDetail,
|
|
});
|
|
|
|
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('desktop trending list shows kind instead of work code or timestamp text', () => {
|
|
mockDesktopLayout();
|
|
|
|
renderLoggedOutHomeView(vi.fn(), {
|
|
latestEntries: [puzzlePublicEntry],
|
|
});
|
|
|
|
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
|
|
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
|
|
expect(screen.queryByText('1777110165.990127Z')).toBeNull();
|
|
});
|