Merge remote-tracking branch 'origin/master'
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -16,9 +16,9 @@ export function RpgEntryBrandLogo({
|
||||
className={`platform-brand-logo ${className}`.trim()}
|
||||
role={decorative ? undefined : 'img'}
|
||||
aria-hidden={decorative || undefined}
|
||||
aria-label={decorative ? undefined : '陶泥 GENARRATIVE'}
|
||||
aria-label={decorative ? undefined : '百梦 GENARRATIVE'}
|
||||
>
|
||||
<span className="platform-brand-logo__title">陶泥</span>
|
||||
<span className="platform-brand-logo__title">百梦</span>
|
||||
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -56,12 +56,16 @@ import {
|
||||
import {
|
||||
getPuzzleGalleryDetail,
|
||||
listPuzzleGallery,
|
||||
remixPuzzleGalleryWork,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import {
|
||||
advanceLocalPuzzleNextLevel,
|
||||
advancePuzzleNextLevel,
|
||||
getPuzzleRun,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
updatePuzzleRunPause,
|
||||
usePuzzleRuntimeProp,
|
||||
} from '../../services/puzzle-runtime';
|
||||
import {
|
||||
dragLocalPuzzlePiece,
|
||||
@@ -96,6 +100,7 @@ import {
|
||||
recordRpgEntryWorldGalleryPlay,
|
||||
remixRpgEntryWorldGallery,
|
||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import { type CustomWorldProfile, WorldType } from '../../types';
|
||||
import {
|
||||
AuthUiContext,
|
||||
type PlatformSettingsSection,
|
||||
@@ -210,6 +215,7 @@ vi.mock('../../services/puzzle-works', () => ({
|
||||
vi.mock('../../services/puzzle-gallery', () => ({
|
||||
getPuzzleGalleryDetail: vi.fn(),
|
||||
listPuzzleGallery: vi.fn(),
|
||||
remixPuzzleGalleryWork: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-runtime', () => ({
|
||||
@@ -553,6 +559,7 @@ const mockAuthUser: AuthUser = {
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
function buildMockPuzzleRun(
|
||||
@@ -732,22 +739,38 @@ function buildMockMatch3DAgentSession(
|
||||
|
||||
function buildMockRpgGalleryDetail(
|
||||
entry: CustomWorldGalleryCard,
|
||||
): CustomWorldLibraryEntry {
|
||||
): CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
return {
|
||||
...entry,
|
||||
profile: {
|
||||
id: entry.profileId,
|
||||
settingText: entry.summaryText,
|
||||
name: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summary: entry.summaryText,
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清旧案。',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
attributeSchema: {
|
||||
id: `${entry.profileId}-attribute-schema`,
|
||||
worldId: entry.profileId,
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: entry.worldName,
|
||||
settingSummary: entry.summaryText,
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
conflictCore: '雾潮正在逼近港口',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
majorFactions: ['守灯会'],
|
||||
coreConflicts: ['雾潮正在逼近港口'],
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
} as never,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1616,6 +1639,7 @@ beforeEach(() => {
|
||||
vi.mocked(listBigFishGallery).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(recordBigFishPlay).mockResolvedValue(undefined);
|
||||
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
|
||||
session: null,
|
||||
});
|
||||
@@ -1682,9 +1706,21 @@ beforeEach(() => {
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(remixPuzzleGalleryWork).mockRejectedValue(
|
||||
new Error('未启用拼图 remix'),
|
||||
);
|
||||
vi.mocked(advancePuzzleNextLevel).mockImplementation(async (runId) => ({
|
||||
run: buildMockPuzzleRun(`${runId}-next-profile`, '后端推荐下一关'),
|
||||
}));
|
||||
vi.mocked(getPuzzleRun).mockImplementation(async (runId) => ({
|
||||
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
|
||||
}));
|
||||
vi.mocked(updatePuzzleRunPause).mockImplementation(async (runId) => ({
|
||||
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
|
||||
}));
|
||||
vi.mocked(usePuzzleRuntimeProp).mockImplementation(async (runId) => ({
|
||||
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
|
||||
}));
|
||||
vi.mocked(submitPuzzleLeaderboard).mockImplementation(
|
||||
async (runId, payload) => ({
|
||||
run: {
|
||||
@@ -2100,6 +2136,114 @@ test('clicking a public work while logged out opens public detail without starti
|
||||
expect(recordRpgEntryWorldGalleryPlay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('logged out public detail gates puzzle start and remix before real actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: null,
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '星桥机关',
|
||||
summary: '旋转碎片并接通星桥机关。',
|
||||
themeTags: ['机关', '星桥'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
playCount: 3,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [publishedPuzzleWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: publishedPuzzleWork,
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const workCards = screen.getAllByRole('button', { name: /星桥机关/u });
|
||||
await user.click(workCards[0]!);
|
||||
expect(await screen.findByText('详情')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(startPuzzleRun).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '作品改造' }));
|
||||
expect(requireAuth).toHaveBeenCalledTimes(2);
|
||||
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('logged out public detail gates big fish start before local runtime', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
const bigFishWork: BigFishWorkSummary = {
|
||||
workId: 'big-fish-work-public-1',
|
||||
sourceSessionId: 'big-fish-session-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
authorDisplayName: '大鱼作者',
|
||||
title: '机械深海 大鱼吃小鱼',
|
||||
subtitle: '机械微生物吞并进化',
|
||||
summary: '从微光孢子一路吞并成长到深海巨鲲。',
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishReady: true,
|
||||
levelCount: 8,
|
||||
levelMainImageReadyCount: 8,
|
||||
levelMotionReadyCount: 16,
|
||||
backgroundReady: true,
|
||||
};
|
||||
|
||||
vi.mocked(listBigFishGallery).mockResolvedValue({
|
||||
items: [bigFishWork],
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
await user.type(searchInput, 'BF-NPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
expect(await screen.findByText('详情')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||||
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(startLocalBigFishRuntimeRun).not.toHaveBeenCalled();
|
||||
expect(recordBigFishPlay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('creation hub clears all private work shelves immediately after logout state', async () => {
|
||||
const user = userEvent.setup();
|
||||
const loggedInAuth = createAuthValue();
|
||||
@@ -2793,7 +2937,7 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
@@ -2801,8 +2945,8 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
|
||||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith({
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelId: null,
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2866,7 +3010,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
@@ -2909,7 +3053,7 @@ test('public code search opens a published big fish work by BF code', async () =
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
await user.type(searchInput, 'BF-NPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
@@ -2963,7 +3107,7 @@ test('public code search opens a published Match3D work by M3 code and starts ru
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
await user.type(searchInput, 'M3-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
@@ -4082,6 +4226,31 @@ test('authenticated users with save archives default into the saves tab', async
|
||||
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
|
||||
expect(screen.queryByText('ARCHIVE')).toBeNull();
|
||||
expect(screen.queryByText('最近存档')).toBeNull();
|
||||
});
|
||||
|
||||
test('puzzle save archive highlights work title and level subtitle', async () => {
|
||||
vi.mocked(listProfileSaveArchives).mockResolvedValue([
|
||||
{
|
||||
worldKey: 'puzzle:puzzle-profile-1',
|
||||
ownerUserId: 'user-2',
|
||||
profileId: 'puzzle-profile-1',
|
||||
worldType: 'PUZZLE',
|
||||
worldName: '雨夜猫塔',
|
||||
subtitle: '第 2 关 · 星桥机关',
|
||||
summaryText: '拼图进行中',
|
||||
coverImageSrc: '/generated-puzzle-assets/puzzle-1/level-2.png',
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
expect((await screen.findAllByText('雨夜猫塔')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('第 2 关 · 星桥机关').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('ARCHIVE')).toBeNull();
|
||||
expect(screen.queryByText('最近存档')).toBeNull();
|
||||
});
|
||||
|
||||
test('manual tab switch is preserved after platform bootstrap requests finish', async () => {
|
||||
|
||||
@@ -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: /奇幻拼图,拼图,20游玩,5改造,12点赞/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', {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user