Merge remote-tracking branch 'origin/master' into codex/wechat-mini-program-virtual-payment
# Conflicts: # .hermes/shared-memory/decision-log.md
This commit is contained in:
@@ -702,14 +702,22 @@ function mockNarrowMobileLayout() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfileView(
|
||||
function ProfileHomeViewHarness({
|
||||
onRechargeSuccess = vi.fn(),
|
||||
profileDashboardOverrides: Partial<
|
||||
profileDashboardOverrides = {},
|
||||
userOverrides = {},
|
||||
activeTab = 'profile',
|
||||
profileTaskRefreshKey = 0,
|
||||
}: {
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
profileDashboardOverrides?: Partial<
|
||||
NonNullable<RpgEntryHomeViewProps['profileDashboard']>
|
||||
> = {},
|
||||
userOverrides: Partial<AuthUser> = {},
|
||||
) {
|
||||
return render(
|
||||
>;
|
||||
userOverrides?: Partial<AuthUser>;
|
||||
activeTab?: RpgEntryHomeViewProps['activeTab'];
|
||||
profileTaskRefreshKey?: number;
|
||||
}) {
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: {
|
||||
@@ -742,7 +750,7 @@ function renderProfileView(
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="profile"
|
||||
activeTab={activeTab}
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
@@ -772,8 +780,27 @@ function renderProfileView(
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
onRechargeSuccess={onRechargeSuccess}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderProfileView(
|
||||
onRechargeSuccess = vi.fn(),
|
||||
profileDashboardOverrides: Partial<
|
||||
NonNullable<RpgEntryHomeViewProps['profileDashboard']>
|
||||
> = {},
|
||||
userOverrides: Partial<AuthUser> = {},
|
||||
profileTaskRefreshKey = 0,
|
||||
) {
|
||||
return render(
|
||||
<ProfileHomeViewHarness
|
||||
onRechargeSuccess={onRechargeSuccess}
|
||||
profileDashboardOverrides={profileDashboardOverrides}
|
||||
userOverrides={userOverrides}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1902,11 +1929,18 @@ test('non-wechat profile opens reward code from recharge-shaped entry', async ()
|
||||
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('profile daily task shortcut opens task center and claims reward', async () => {
|
||||
test('profile daily task shortcut reflects task progress and claim updates', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
|
||||
const dailyTask = screen.getByRole('button', { name: /每日任务/u });
|
||||
await waitFor(() => {
|
||||
expect(within(dailyTask).getByText('1 / 1')).toBeTruthy();
|
||||
});
|
||||
expect(within(dailyTask).getByText('领取')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /每日任务/u }));
|
||||
|
||||
expect(await screen.findByText('每日登录')).toBeTruthy();
|
||||
@@ -1923,6 +1957,7 @@ test('profile daily task shortcut opens task center and claims reward', async ()
|
||||
expect(await screen.findByText('已领取 10 泥点')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '已领取' })).toBeNull();
|
||||
expect(screen.getByText('暂无任务')).toBeTruthy();
|
||||
expect(within(dailyTask).getByText('已完成')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile task center keeps only the highest priority actionable task', async () => {
|
||||
@@ -1985,7 +2020,7 @@ test('profile task center keeps only the highest priority actionable task', asyn
|
||||
expect(screen.queryByText('低优先级已完成')).toBeNull();
|
||||
});
|
||||
|
||||
test('profile total play time card always uses hours', () => {
|
||||
test('profile total play time card always uses hours', async () => {
|
||||
renderProfileView(vi.fn(), {
|
||||
totalPlayTimeMs: 90 * 60 * 1000,
|
||||
});
|
||||
@@ -1996,9 +2031,10 @@ test('profile total play time card always uses hours', () => {
|
||||
|
||||
expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
|
||||
expect(within(playTimeCard).queryByText('90分')).toBeNull();
|
||||
await screen.findByText('1 / 1');
|
||||
});
|
||||
|
||||
test('profile played works card shows count unit', () => {
|
||||
test('profile played works card shows count unit', async () => {
|
||||
renderProfileView(vi.fn(), {
|
||||
playedWorldCount: 1,
|
||||
});
|
||||
@@ -2008,9 +2044,10 @@ test('profile played works card shows count unit', () => {
|
||||
});
|
||||
|
||||
expect(within(playedCard).getByText('1个')).toBeTruthy();
|
||||
await screen.findByText('1 / 1');
|
||||
});
|
||||
|
||||
test('profile stats cards are centered without update timestamp', () => {
|
||||
test('profile stats cards are centered without update timestamp', async () => {
|
||||
renderProfileView(vi.fn(), {
|
||||
updatedAt: '2026-05-03T08:01:00Z',
|
||||
});
|
||||
@@ -2026,6 +2063,7 @@ test('profile stats cards are centered without update timestamp', () => {
|
||||
expect(card.className).toContain('text-center');
|
||||
}
|
||||
expect(screen.queryByText(/更新于/u)).toBeNull();
|
||||
await screen.findByText('1 / 1');
|
||||
});
|
||||
|
||||
test('mobile profile page matches the reference layout sections', async () => {
|
||||
@@ -2083,7 +2121,7 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
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();
|
||||
expect(await within(dailyTask).findByText('1 / 1')).toBeTruthy();
|
||||
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
@@ -2091,7 +2129,7 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
shortcutRegion.querySelectorAll('.platform-profile-shortcut-button'),
|
||||
).toHaveLength(5);
|
||||
).toHaveLength(4);
|
||||
expect(
|
||||
shortcutRegion
|
||||
.querySelector('.platform-profile-shortcut-grid')
|
||||
@@ -2099,7 +2137,6 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
).toBe(true);
|
||||
for (const label of [
|
||||
'泥点充值',
|
||||
'邀请好友',
|
||||
'兑换码',
|
||||
'玩家社区',
|
||||
'反馈与建议',
|
||||
@@ -2177,7 +2214,7 @@ test('profile scan action opens camera scanner instead of recharge panel', async
|
||||
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('desktop account entry uses saved avatar image when available', () => {
|
||||
test('desktop account entry uses saved avatar image when available', async () => {
|
||||
mockDesktopLayout();
|
||||
const avatarUrl = 'data:image/png;base64,AAAA';
|
||||
|
||||
@@ -2187,6 +2224,7 @@ test('desktop account entry uses saved avatar image when available', () => {
|
||||
const avatarImage = accountEntry.querySelector('img');
|
||||
expect(avatarImage?.getAttribute('src')).toBe(avatarUrl);
|
||||
expect(within(accountEntry).queryByText('测')).toBeNull();
|
||||
await screen.findByText('1 / 1');
|
||||
});
|
||||
|
||||
test('profile avatar upload uses the shared square crop tool', async () => {
|
||||
@@ -2236,83 +2274,83 @@ test('wallet ledger modal shows empty and error states', async () => {
|
||||
expect(screen.getByText('重新加载')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile invite shortcut shows reward subtitle and invited users', async () => {
|
||||
test('profile community 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();
|
||||
expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull();
|
||||
|
||||
const communityButton = screen.getByRole('button', { name: /玩家社区/u });
|
||||
expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy();
|
||||
|
||||
await user.click(inviteButton);
|
||||
await user.click(communityButton);
|
||||
|
||||
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();
|
||||
expect(screen.getByAltText('玩家社区微信群二维码')).toBeTruthy();
|
||||
expect(screen.getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
|
||||
expect(screen.getByText('微信群')).toBeTruthy();
|
||||
expect(screen.getByText('QQ群')).toBeTruthy();
|
||||
expect(screen.queryByText('成功邀请')).toBeNull();
|
||||
expect(screen.queryByText('被邀请玩家')).toBeNull();
|
||||
});
|
||||
|
||||
test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => {
|
||||
test('profile page hides legacy redeem invite secondary shortcut 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: '次级入口',
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(inviteButton).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull();
|
||||
expect(communityButton).toBeTruthy();
|
||||
expect(
|
||||
within(secondaryShortcuts).getByRole('button', { name: /填邀请码/u }),
|
||||
).toBeTruthy();
|
||||
expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy();
|
||||
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /填邀请码/u })).toBeNull();
|
||||
});
|
||||
|
||||
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();
|
||||
expect(
|
||||
within(firstShortcutRegion).queryByRole('button', { name: /填邀请码/u }),
|
||||
).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
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();
|
||||
expect(
|
||||
within(expiredShortcutRegion).queryByRole('button', {
|
||||
name: /填邀请码/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
test('invite query opens login modal for logged out users', async () => {
|
||||
@@ -2345,9 +2383,10 @@ test('profile redeem invite modal reads query invite code after login', async ()
|
||||
expect((input as HTMLInputElement).value).toBe('SPRING2026');
|
||||
});
|
||||
|
||||
test('profile redeem invite modal submits code and hides shortcut after success', async () => {
|
||||
test('profile redeem invite query modal submits code after login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
window.history.replaceState(null, '', '/?inviteCode=spring-2026');
|
||||
|
||||
renderProfileView(
|
||||
onRechargeSuccess,
|
||||
@@ -2355,9 +2394,7 @@ test('profile redeem invite modal submits code and hides shortcut after success'
|
||||
{ createdAt: buildFreshProfileCreatedAt() },
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /填邀请码/u }));
|
||||
const input = await screen.findByLabelText('邀请码');
|
||||
await user.type(input, 'spring-2026');
|
||||
expect(await screen.findByLabelText('邀请码')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '提交' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -2367,12 +2404,23 @@ test('profile redeem invite modal submits code and hides shortcut after success'
|
||||
});
|
||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText('已填写')).toBeTruthy();
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
within(shortcutRegion).queryByRole('button', {
|
||||
name: /填邀请码/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
|
||||
});
|
||||
|
||||
test('profile task center reloads when refresh key changes', async () => {
|
||||
const { rerender } = renderProfileView();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ProfileHomeViewHarness profileTaskRefreshKey={1} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('opens reward code modal from profile action on mobile', async () => {
|
||||
@@ -2402,8 +2450,8 @@ test('profile page shows legal entries and hides archive shortcuts', async () =>
|
||||
?.classList.contains('platform-profile-shortcut-grid'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /邀请好友/u }),
|
||||
).toBeTruthy();
|
||||
within(shortcutRegion).queryByRole('button', { name: /邀请好友/u }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /玩家社区/u }),
|
||||
).toBeTruthy();
|
||||
@@ -3274,6 +3322,41 @@ test('logged out active recommend bottom tab selects next work without login', a
|
||||
expect(openLoginModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('logged out recommend card supports vertical swipe without login', () => {
|
||||
vi.useFakeTimers();
|
||||
const onSelectNextRecommendEntry = vi.fn();
|
||||
const openLoginModal = vi.fn();
|
||||
|
||||
renderLoggedOutHomeView(openLoginModal, {
|
||||
latestEntries: [
|
||||
puzzlePublicEntry,
|
||||
{
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-guest-next',
|
||||
profileId: 'puzzle-profile-guest-next',
|
||||
ownerUserId: 'user-guest-next',
|
||||
publicWorkCode: 'PZ-GUEST-NEXT',
|
||||
worldName: '访客下一张',
|
||||
},
|
||||
],
|
||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||
onSelectNextRecommendEntry,
|
||||
recommendRuntimeContent: <div data-testid="guest-recommend-runtime" />,
|
||||
});
|
||||
|
||||
const meta = screen.getByLabelText('奇幻拼图 作品信息') as HTMLElement;
|
||||
act(() => {
|
||||
dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 320 });
|
||||
dispatchPointerEvent(meta, 'pointermove', { pointerId: 1, clientY: 220 });
|
||||
dispatchPointerEvent(meta, 'pointerup', { pointerId: 1, clientY: 220 });
|
||||
vi.advanceTimersByTime(180);
|
||||
});
|
||||
|
||||
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
|
||||
expect(openLoginModal).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('mobile recommend meta loads real author avatar from public user summary', async () => {
|
||||
mockGetPublicAuthUserById.mockResolvedValueOnce({
|
||||
id: 'user-2',
|
||||
|
||||
Reference in New Issue
Block a user