Merge branch 'codex/profile-mobile-ui-reference'
This commit is contained in:
@@ -413,6 +413,11 @@ const originalUserAgent = navigator.userAgent;
|
||||
const originalMaxTouchPoints = navigator.maxTouchPoints;
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
const DEFAULT_PROFILE_CREATED_AT = '2026-04-01T00:00:00.000Z';
|
||||
|
||||
function buildFreshProfileCreatedAt() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function dispatchPointerEvent(
|
||||
target: HTMLElement,
|
||||
@@ -670,6 +675,33 @@ function mockWechatMobileLayout() {
|
||||
});
|
||||
}
|
||||
|
||||
function mockNarrowMobileLayout() {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit Mobile',
|
||||
});
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => {
|
||||
const normalizedQuery = query.replace(/\s/g, '');
|
||||
return {
|
||||
matches:
|
||||
normalizedQuery.includes('max-width:767px') ||
|
||||
normalizedQuery.includes('max-width:768px'),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfileView(
|
||||
onRechargeSuccess = vi.fn(),
|
||||
profileDashboardOverrides: Partial<
|
||||
@@ -690,7 +722,7 @@ function renderProfileView(
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: DEFAULT_PROFILE_CREATED_AT,
|
||||
...userOverrides,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
@@ -1056,7 +1088,7 @@ afterEach(() => {
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: DEFAULT_PROFILE_CREATED_AT,
|
||||
});
|
||||
mockQrCodeToDataUrl.mockResolvedValue('data:image/png;base64,QR');
|
||||
mockRedirectToPaymentUrl.mockReset();
|
||||
@@ -1094,7 +1126,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(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
|
||||
@@ -1760,19 +1794,21 @@ test('profile native qr confirmation refreshes only after server reports paid',
|
||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('non-wechat profile shows reward code instead of recharge entry', async () => {
|
||||
test('non-wechat profile opens reward code from recharge-shaped entry', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderProfileView();
|
||||
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
within(shortcutRegion).queryByRole('button', { name: /充值/u }),
|
||||
).toBeNull();
|
||||
within(shortcutRegion).getByRole('button', { name: /泥点充值/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /兑换码/u }),
|
||||
).toBeTruthy();
|
||||
await user.click(within(shortcutRegion).getByRole('button', { name: /兑换码/u }));
|
||||
await user.click(
|
||||
within(shortcutRegion).getByRole('button', { name: /泥点充值/u }),
|
||||
);
|
||||
expect(await screen.findByPlaceholderText('输入兑换码')).toBeTruthy();
|
||||
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1821,7 +1857,7 @@ test('profile played works card shows count unit', () => {
|
||||
});
|
||||
|
||||
const playedCard = screen.getByRole('button', {
|
||||
name: /玩过\s*1个/u,
|
||||
name: /已玩游戏数量\s*1个/u,
|
||||
});
|
||||
|
||||
expect(within(playedCard).getByText('1个')).toBeTruthy();
|
||||
@@ -1832,18 +1868,120 @@ test('profile stats cards are centered without update timestamp', () => {
|
||||
updatedAt: '2026-05-03T08:01:00Z',
|
||||
});
|
||||
|
||||
const walletCard = screen.getByRole('button', { name: /泥点\s*0/u });
|
||||
const playTimeCard = screen.getByRole('button', { name: /游戏时长/u });
|
||||
const playedCard = screen.getByRole('button', { name: /玩过\s*0个/u });
|
||||
const walletCard = screen.getByRole('button', {
|
||||
name: /泥点余额\s*0/u,
|
||||
});
|
||||
const playTimeCard = screen.getByRole('button', { name: /游戏时长|累计游戏时长/u });
|
||||
const playedCard = screen.getByRole('button', { name: /已玩游戏数量\s*0个/u });
|
||||
|
||||
for (const card of [walletCard, playTimeCard, playedCard]) {
|
||||
expect(card.className).toContain('items-center');
|
||||
expect(card.className).toContain('justify-center');
|
||||
expect(card.className).toContain('platform-profile-stat-card');
|
||||
expect(card.className).toContain('text-center');
|
||||
}
|
||||
expect(screen.queryByText(/更新于/u)).toBeNull();
|
||||
});
|
||||
|
||||
test('mobile profile page matches the reference layout sections', async () => {
|
||||
mockWechatMobileLayout();
|
||||
|
||||
const { container } = renderProfileView(vi.fn(), {
|
||||
walletBalance: 70,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
}, { createdAt: buildFreshProfileCreatedAt() });
|
||||
|
||||
const profilePage = container.querySelector('.platform-profile-page');
|
||||
expect(profilePage).toBeTruthy();
|
||||
expect(profilePage?.classList.contains('platform-page-stage')).toBe(true);
|
||||
expect(profilePage?.querySelector('.platform-profile-scene-decor')).toBeTruthy();
|
||||
expect(profilePage?.classList.contains('platform-profile-page')).toBe(true);
|
||||
expect(profilePage?.getAttribute('style') ?? '').not.toContain('overflow: hidden');
|
||||
|
||||
const membershipCard = screen.getByRole('button', { name: '查看权益' });
|
||||
expect(membershipCard.className).toContain('platform-profile-membership-card');
|
||||
expect(
|
||||
within(membershipCard).getByText('普通用户').className,
|
||||
).toContain('platform-profile-membership-card__title');
|
||||
expect(within(membershipCard).getByText('普通用户')).toBeTruthy();
|
||||
expect(within(membershipCard).getByText('升级会员,享专属特权与福利')).toBeTruthy();
|
||||
|
||||
const statPanel = screen.getByRole('region', { name: '我的数据' });
|
||||
expect(statPanel.className).toContain('platform-profile-stats-panel');
|
||||
expect(statPanel.querySelector('.platform-profile-stats-grid')).toBeTruthy();
|
||||
expect(within(statPanel).getByRole('button', { name: /泥点余额\s*70/u })).toBeTruthy();
|
||||
expect(within(statPanel).getByRole('button', { name: /累计游戏时长\s*0小时/u })).toBeTruthy();
|
||||
expect(within(statPanel).getByRole('button', { name: /已玩游戏数量\s*0个/u })).toBeTruthy();
|
||||
expect(
|
||||
within(statPanel).getByRole('button', { name: /泥点余额\s*70/u }).className,
|
||||
).toContain('platform-profile-stat-card');
|
||||
|
||||
const dailyTask = screen.getByRole('button', { name: /每日任务/u });
|
||||
expect(dailyTask.className).toContain('platform-profile-daily-task-card');
|
||||
expect(dailyTask.querySelector('.platform-profile-daily-task-card__title')).toBeTruthy();
|
||||
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();
|
||||
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
shortcutRegion.querySelector('.platform-profile-shortcut-grid'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
shortcutRegion.querySelectorAll('.platform-profile-shortcut-button'),
|
||||
).toHaveLength(5);
|
||||
expect(
|
||||
shortcutRegion
|
||||
.querySelector('.platform-profile-shortcut-grid')
|
||||
?.classList.contains('platform-profile-shortcut-grid'),
|
||||
).toBe(true);
|
||||
for (const label of [
|
||||
'泥点充值',
|
||||
'邀请好友',
|
||||
'兑换码',
|
||||
'玩家社区',
|
||||
'反馈与建议',
|
||||
]) {
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: new RegExp(label, 'u') }),
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
|
||||
for (const label of ['主题设置', '账号与安全', '通用设置']) {
|
||||
expect(
|
||||
within(settingsRegion).getByRole('button', { name: new RegExp(label, 'u') }),
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
const secondaryShortcuts = screen.getByRole('region', {
|
||||
name: '次级入口',
|
||||
});
|
||||
expect(
|
||||
within(secondaryShortcuts).getByRole('button', { name: /存档/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
await within(secondaryShortcuts).findByRole('button', {
|
||||
name: /填邀请码/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
const profileHeader = profilePage?.querySelector('.platform-profile-header');
|
||||
expect(profileHeader).toBeTruthy();
|
||||
expect(profileHeader?.querySelector('.platform-profile-header__identity-row')).toBeTruthy();
|
||||
expect(profileHeader?.querySelector('.platform-profile-header__name')).toBeTruthy();
|
||||
expect(profileHeader?.querySelector('.platform-profile-header__code')).toBeTruthy();
|
||||
|
||||
const legalRegion = screen.getByRole('region', { name: '法律信息' });
|
||||
expect(legalRegion.className).toContain('platform-profile-legal-strip');
|
||||
expect(legalRegion.textContent).toContain('用户协议');
|
||||
expect(legalRegion.textContent).toContain('隐私政策');
|
||||
expect(legalRegion.textContent).toContain('免责声明');
|
||||
expect(legalRegion.textContent).toContain(ICP_RECORD_NUMBER);
|
||||
expect(legalRegion.textContent).toContain('2026025677');
|
||||
expect(legalRegion.querySelector('.platform-profile-legal-strip__link')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('desktop account entry uses saved avatar image when available', () => {
|
||||
mockDesktopLayout();
|
||||
const avatarUrl = 'data:image/png;base64,AAAA';
|
||||
@@ -1886,27 +2024,33 @@ test('wallet ledger modal shows empty and error states', async () => {
|
||||
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
|
||||
|
||||
renderProfileView();
|
||||
await user.click(screen.getByRole('button', { name: /泥点\s*0/u }));
|
||||
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: /泥点余额\s*0/u }));
|
||||
expect(await screen.findByText('泥点账单')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('暂无账单记录')).toBeTruthy();
|
||||
});
|
||||
|
||||
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(await screen.findByText('泥点账单')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('加载失败')).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText('重新加载')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile invite shortcut shows reward subtitle and invited users', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderProfileView();
|
||||
renderProfileView(vi.fn(), {}, { createdAt: buildFreshProfileCreatedAt() });
|
||||
|
||||
const inviteButton = screen.getByRole('button', { name: /邀请好友/u });
|
||||
expect(within(inviteButton).getByText('双方得30')).toBeTruthy();
|
||||
expect(within(inviteButton).getByText('双方得 30 泥点')).toBeTruthy();
|
||||
|
||||
const communityButton = screen.getByRole('button', { name: /玩家社区/u });
|
||||
expect(within(communityButton).getByText('每日领福利')).toBeTruthy();
|
||||
expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy();
|
||||
|
||||
await user.click(inviteButton);
|
||||
|
||||
@@ -1922,21 +2066,25 @@ test('profile invite shortcut shows reward subtitle and invited users', async ()
|
||||
});
|
||||
|
||||
test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => {
|
||||
renderProfileView();
|
||||
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: '次级入口',
|
||||
});
|
||||
|
||||
expect(inviteButton).toBeTruthy();
|
||||
expect(communityButton).toBeTruthy();
|
||||
expect(
|
||||
inviteButton.compareDocumentPosition(redeemButton) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
redeemButton.compareDocumentPosition(communityButton) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
within(secondaryShortcuts).getByRole('button', { name: /填邀请码/u }),
|
||||
).toBeTruthy();
|
||||
expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy();
|
||||
});
|
||||
@@ -2006,7 +2154,11 @@ test('profile redeem invite modal submits code and hides shortcut after success'
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
renderProfileView(
|
||||
onRechargeSuccess,
|
||||
{},
|
||||
{ createdAt: buildFreshProfileCreatedAt() },
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /填邀请码/u }));
|
||||
const input = await screen.findByLabelText('邀请码');
|
||||
@@ -2050,11 +2202,10 @@ test('profile page shows legal entries and ICP record link', async () => {
|
||||
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'),
|
||||
shortcutRegion
|
||||
.querySelector('.platform-profile-shortcut-grid')
|
||||
?.classList.contains('platform-profile-shortcut-grid'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /每日任务/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /邀请好友/u }),
|
||||
).toBeTruthy();
|
||||
@@ -2064,6 +2215,24 @@ test('profile page shows legal entries and ICP record link', async () => {
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /反馈/u }),
|
||||
).toBeTruthy();
|
||||
const dailyTask = screen.getByRole('button', { name: /每日任务/u });
|
||||
expect(dailyTask).toBeTruthy();
|
||||
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
|
||||
|
||||
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
|
||||
expect(
|
||||
within(settingsRegion).getByRole('button', { name: /存档/u }),
|
||||
).toBeTruthy();
|
||||
|
||||
const secondaryShortcuts = screen.getByRole('region', {
|
||||
name: '次级入口',
|
||||
});
|
||||
expect(
|
||||
within(secondaryShortcuts).getByRole('button', { name: /存档/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(secondaryShortcuts).queryByRole('button', { name: /填邀请码/u }),
|
||||
).toBeNull();
|
||||
|
||||
const legalRegion = screen.getByRole('region', { name: '法律信息' });
|
||||
expect(
|
||||
@@ -2138,6 +2307,83 @@ test('logged in draft bottom tab shows unread marker', () => {
|
||||
expect(draftButton.querySelector('.platform-nav-unread-dot')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('logged in create tab shows real wallet balance beside the brand', () => {
|
||||
mockNarrowMobileLayout();
|
||||
|
||||
const { container } = render(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: DEFAULT_PROFILE_CREATED_AT,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: (action) => action(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: 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="create"
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
saveEntries={[]}
|
||||
saveError={null}
|
||||
featuredEntries={[]}
|
||||
latestEntries={[]}
|
||||
myEntries={[]}
|
||||
historyEntries={[]}
|
||||
profileDashboard={{
|
||||
walletBalance: 1234,
|
||||
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()}
|
||||
createTabContent={<div>创作内容</div>}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
const topbar = container.querySelector('.platform-mobile-topbar');
|
||||
expect(topbar).toBeTruthy();
|
||||
expect(
|
||||
topbar?.querySelector('.platform-mobile-create-wallet-chip'),
|
||||
).toBeTruthy();
|
||||
expect(topbar?.textContent).toContain('陶泥儿');
|
||||
expect(topbar?.textContent).toContain('1,234泥点');
|
||||
});
|
||||
|
||||
test('mobile discover search submits public work code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
@@ -2248,6 +2494,15 @@ test('mobile discover keeps edutainment works in the last dedicated channel only
|
||||
throw new Error('缺少发现面板');
|
||||
}
|
||||
|
||||
const discoverStage = discoverPanel.querySelector(
|
||||
'.platform-mobile-home-stage',
|
||||
);
|
||||
expect(discoverStage).toBeTruthy();
|
||||
expect(discoverStage?.classList.contains('platform-remap-surface')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(discoverStage?.classList.contains('platform-page-stage')).toBe(false);
|
||||
|
||||
const channels = Array.from(
|
||||
discoverPanel.querySelectorAll('.platform-mobile-home-channel'),
|
||||
).map((button) => button.textContent);
|
||||
@@ -3135,7 +3390,6 @@ test('desktop logged in home syncs mobile home modules without square or latest
|
||||
expect(screen.queryByText('作品广场')).toBeNull();
|
||||
expect(screen.queryByText('公开作品')).toBeNull();
|
||||
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
|
||||
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('1777110165.990127Z')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user