feat: surface platform errors in copyable dialogs
This commit is contained in:
@@ -1826,11 +1826,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();
|
||||
@@ -1847,6 +1854,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 () => {
|
||||
@@ -1909,7 +1917,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,
|
||||
});
|
||||
@@ -1920,9 +1928,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,
|
||||
});
|
||||
@@ -1932,9 +1941,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',
|
||||
});
|
||||
@@ -1950,6 +1960,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 () => {
|
||||
@@ -2007,7 +2018,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(
|
||||
@@ -2101,7 +2112,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';
|
||||
|
||||
@@ -2111,6 +2122,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 () => {
|
||||
@@ -2184,7 +2196,7 @@ test('profile invite shortcut shows reward subtitle and invited users', async ()
|
||||
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(),
|
||||
{},
|
||||
@@ -2192,20 +2204,16 @@ test('profile redeem invite shortcut sits between invite and community for fresh
|
||||
);
|
||||
|
||||
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(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 () => {
|
||||
@@ -2226,6 +2234,7 @@ test('profile redeem invite shortcut hides after redeemed or one day old', async
|
||||
expect(
|
||||
within(firstShortcutRegion).queryByRole('button', { name: /填邀请码/u }),
|
||||
).toBeNull();
|
||||
await screen.findByText('1 / 1');
|
||||
unmount();
|
||||
|
||||
renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' });
|
||||
@@ -2237,6 +2246,7 @@ test('profile redeem invite shortcut hides after redeemed or one day old', async
|
||||
name: /填邀请码/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
await screen.findByText('1 / 1');
|
||||
});
|
||||
|
||||
test('invite query opens login modal for logged out users', async () => {
|
||||
@@ -2269,9 +2279,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,
|
||||
@@ -2279,9 +2290,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(() => {
|
||||
@@ -2291,12 +2300,7 @@ 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('opens reward code modal from profile action on mobile', async () => {
|
||||
|
||||
@@ -255,18 +255,25 @@ const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
|
||||
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
|
||||
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<ProfileTaskItem['status'], number> = {
|
||||
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<
|
||||
ProfileTaskItem['status'],
|
||||
number
|
||||
> = {
|
||||
claimable: 2,
|
||||
incomplete: 1,
|
||||
disabled: 0,
|
||||
claimed: -1,
|
||||
};
|
||||
const PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS = 10;
|
||||
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
|
||||
|
||||
function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
|
||||
return tasks
|
||||
.map((task, index) => ({ task, index }))
|
||||
.filter(({ task }) => task.status === 'claimable' || task.status === 'incomplete')
|
||||
.filter(
|
||||
({ task }) =>
|
||||
task.status === 'claimable' || task.status === 'incomplete',
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] -
|
||||
@@ -277,6 +284,37 @@ function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
|
||||
.map(({ task }) => task);
|
||||
}
|
||||
|
||||
function selectProfileTaskCardTask(tasks: ProfileTaskItem[]) {
|
||||
return (
|
||||
selectProfileTaskCenterTasks(tasks)[0] ??
|
||||
tasks.find((task) => task.status === 'claimed') ??
|
||||
tasks.find((task) => task.status !== 'disabled') ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) {
|
||||
const task = selectProfileTaskCardTask(center?.tasks ?? []);
|
||||
const threshold = Math.max(1, task?.threshold ?? 1);
|
||||
const progressCount = Math.min(task?.progressCount ?? 0, threshold);
|
||||
const rewardPoints =
|
||||
task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS;
|
||||
const actionLabel =
|
||||
task?.status === 'claimable'
|
||||
? '领取'
|
||||
: task?.status === 'claimed'
|
||||
? '已完成'
|
||||
: '去完成';
|
||||
|
||||
return {
|
||||
actionLabel,
|
||||
progressCount,
|
||||
progressPercent: Math.round((progressCount / threshold) * 100),
|
||||
rewardPoints,
|
||||
threshold,
|
||||
};
|
||||
}
|
||||
|
||||
type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
|
||||
type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
|
||||
type BarcodeDetectorLike = {
|
||||
@@ -2449,42 +2487,6 @@ function ProfileSettingsRow({
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSecondaryShortcutButton({
|
||||
label,
|
||||
subLabel,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
subLabel?: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const Icon = icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="platform-profile-secondary-shortcut inline-flex items-center gap-2 rounded-full px-3 py-2 text-left"
|
||||
>
|
||||
<span className="platform-profile-secondary-shortcut__icon">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[13px] font-semibold text-[var(--platform-text-strong)]">
|
||||
{label}
|
||||
</span>
|
||||
{subLabel ? (
|
||||
<span className="mt-0.5 block truncate text-[11px] font-medium text-[var(--platform-text-soft)]">
|
||||
{subLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileLegalSection({
|
||||
onOpenDocument,
|
||||
}: {
|
||||
@@ -4218,12 +4220,10 @@ export function RpgEntryHomeView({
|
||||
profileDashboard?.totalPlayTimeMs ?? 0,
|
||||
);
|
||||
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
|
||||
const canShowReferralRedeemShortcut =
|
||||
isAuthenticated &&
|
||||
isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt) &&
|
||||
isReferralCenterInitialized &&
|
||||
Boolean(referralCenter) &&
|
||||
referralCenter?.hasRedeemedCode !== true;
|
||||
const profileTaskCardSummary = useMemo(
|
||||
() => buildProfileTaskCardSummary(taskCenter),
|
||||
[taskCenter],
|
||||
);
|
||||
const tabIcons: Record<
|
||||
PlatformHomeTab,
|
||||
ComponentType<{ className?: string }>
|
||||
@@ -4776,7 +4776,7 @@ export function RpgEntryHomeView({
|
||||
document.removeEventListener('visibilitychange', handleResume);
|
||||
};
|
||||
}, [handleWechatPayResult]);
|
||||
const loadTaskCenter = () => {
|
||||
const loadTaskCenter = useCallback(() => {
|
||||
setTaskCenterError(null);
|
||||
setIsLoadingTaskCenter(true);
|
||||
void getRpgProfileTasks()
|
||||
@@ -4788,11 +4788,24 @@ export function RpgEntryHomeView({
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingTaskCenter(false));
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'profile' || !isAuthenticated) {
|
||||
setTaskCenter(null);
|
||||
setTaskCenterError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
loadTaskCenter();
|
||||
}, [activeTab, isAuthenticated, loadTaskCenter]);
|
||||
|
||||
const openTaskCenterPanel = () => {
|
||||
setIsTaskCenterOpen(true);
|
||||
setTaskClaimSuccess(null);
|
||||
loadTaskCenter();
|
||||
if (!taskCenter) {
|
||||
loadTaskCenter();
|
||||
}
|
||||
};
|
||||
const openQrScannerPanel = () => {
|
||||
if (!authUi?.user) {
|
||||
@@ -6185,14 +6198,24 @@ export function RpgEntryHomeView({
|
||||
每日任务
|
||||
</span>
|
||||
<span className="platform-profile-daily-task-card__desc mt-4 block text-[13px] font-medium text-[var(--platform-text-base)]">
|
||||
完成任务可领取 <span className="text-[#c45b2a]">10</span> 泥点
|
||||
完成任务可领取{' '}
|
||||
<span className="text-[#c45b2a]">
|
||||
{profileTaskCardSummary.rewardPoints}
|
||||
</span>{' '}
|
||||
泥点
|
||||
</span>
|
||||
<span className="platform-profile-daily-task-card__progress mt-4 flex items-center gap-3">
|
||||
<span className="platform-profile-daily-task-card__progress-value text-[14px] font-semibold text-[#dc3f0e]">
|
||||
0 / 1
|
||||
{profileTaskCardSummary.progressCount} /{' '}
|
||||
{profileTaskCardSummary.threshold}
|
||||
</span>
|
||||
<span className="platform-profile-daily-task-card__track">
|
||||
<span className="platform-profile-daily-task-card__bar" />
|
||||
<span
|
||||
className="platform-profile-daily-task-card__bar"
|
||||
style={{
|
||||
width: `${profileTaskCardSummary.progressPercent}%`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
@@ -6202,7 +6225,7 @@ export function RpgEntryHomeView({
|
||||
className="platform-profile-daily-task-card__mascot"
|
||||
/>
|
||||
<span className="platform-profile-daily-task-card__action">
|
||||
去完成
|
||||
{profileTaskCardSummary.actionLabel}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -6267,20 +6290,6 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
</section>
|
||||
|
||||
{canShowReferralRedeemShortcut ? (
|
||||
<section
|
||||
className="platform-profile-secondary-shortcuts"
|
||||
aria-label="次级入口"
|
||||
>
|
||||
<ProfileSecondaryShortcutButton
|
||||
label="填邀请码"
|
||||
subLabel="新用户奖励"
|
||||
icon={Ticket}
|
||||
onClick={() => openProfilePopupPanel('redeem')}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
|
||||
</>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user