1
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
@@ -46,14 +47,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',
|
||||
},
|
||||
],
|
||||
@@ -73,7 +74,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
],
|
||||
benefits: [
|
||||
{
|
||||
benefitName: '免叙世币回合数',
|
||||
benefitName: '免陶泥币回合数',
|
||||
normalValue: '30',
|
||||
monthValue: '100',
|
||||
seasonValue: '100',
|
||||
@@ -87,7 +88,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
order: {
|
||||
orderId: 'order-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60叙世币',
|
||||
productTitle: '60陶泥币',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'paid',
|
||||
@@ -138,6 +139,64 @@ const puzzlePublicEntry = {
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const remixRankEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-remix-rank',
|
||||
profileId: 'puzzle-profile-remix-rank',
|
||||
publicWorkCode: 'PZ-REMIX1',
|
||||
worldName: '改造高分拼图',
|
||||
playCount: 2,
|
||||
remixCount: 18,
|
||||
likeCount: 1,
|
||||
recentPlayCount7d: 0,
|
||||
publishedAt: '2026-04-25T11:00:00.000Z',
|
||||
updatedAt: '2026-04-25T11:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const hotRankEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-hot-rank',
|
||||
profileId: 'puzzle-profile-hot-rank',
|
||||
publicWorkCode: 'PZ-HOT001',
|
||||
worldName: '热门高分拼图',
|
||||
themeTags: ['奇幻', '机关'],
|
||||
playCount: 40,
|
||||
remixCount: 1,
|
||||
likeCount: 4,
|
||||
recentPlayCount7d: 0,
|
||||
publishedAt: '2026-04-24T10:00:00.000Z',
|
||||
updatedAt: '2026-04-24T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const newRankEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-new-rank',
|
||||
profileId: 'puzzle-profile-new-rank',
|
||||
publicWorkCode: 'PZ-NEW001',
|
||||
worldName: '新品增长拼图',
|
||||
playCount: 1,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 9,
|
||||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const longTextRankEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-long-text-rank',
|
||||
profileId: 'puzzle-profile-long-text-rank',
|
||||
publicWorkCode: 'PZ-LONG01',
|
||||
worldName: '关键词逍遥游拼图关卡',
|
||||
themeTags: ['逍遥游拼图', '古风机关'],
|
||||
playCount: 88,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 0,
|
||||
publishedAt: '2026-04-29T10:00:00.000Z',
|
||||
updatedAt: '2026-04-29T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
function mockDesktopLayout() {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
@@ -155,7 +214,12 @@ function mockDesktopLayout() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfileView(onRechargeSuccess = vi.fn()) {
|
||||
function renderProfileView(
|
||||
onRechargeSuccess = vi.fn(),
|
||||
profileDashboardOverrides: Partial<
|
||||
NonNullable<RpgEntryHomeViewProps['profileDashboard']>
|
||||
> = {},
|
||||
) {
|
||||
return render(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
@@ -164,6 +228,7 @@ function renderProfileView(onRechargeSuccess = vi.fn()) {
|
||||
publicUserCode: '100001',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
@@ -174,6 +239,7 @@ function renderProfileView(onRechargeSuccess = 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(),
|
||||
@@ -200,6 +266,7 @@ function renderProfileView(onRechargeSuccess = vi.fn()) {
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: null,
|
||||
...profileDashboardOverrides,
|
||||
}}
|
||||
isLoadingPlatform={false}
|
||||
isLoadingDashboard={false}
|
||||
@@ -240,6 +307,7 @@ function renderLoggedOutHomeView(
|
||||
requireAuth: vi.fn(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
@@ -279,6 +347,67 @@ function renderLoggedOutHomeView(
|
||||
);
|
||||
}
|
||||
|
||||
function renderStatefulLoggedOutHomeView(
|
||||
overrides: Partial<
|
||||
Pick<RpgEntryHomeViewProps, 'featuredEntries' | 'latestEntries'>
|
||||
> = {},
|
||||
) {
|
||||
function StatefulLoggedOutHomeView() {
|
||||
const [activeTab, setActiveTab] =
|
||||
useState<RpgEntryHomeViewProps['activeTab']>('home');
|
||||
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: vi.fn(),
|
||||
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={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
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={vi.fn()}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return render(<StatefulLoggedOutHomeView />);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
@@ -296,9 +425,9 @@ test('opens wallet ledger modal from narrative coin card', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderProfileView();
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
await user.click(screen.getByText('剩余陶泥币'));
|
||||
|
||||
expect(await screen.findByText('叙世币账单')).toBeTruthy();
|
||||
expect(await screen.findByText('陶泥币账单')).toBeTruthy();
|
||||
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('资产操作消耗')).toBeTruthy();
|
||||
expect(screen.getByText('-1')).toBeTruthy();
|
||||
@@ -306,17 +435,30 @@ test('opens wallet ledger modal from narrative coin card', async () => {
|
||||
expect(screen.getByText('+30')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile total play time card always uses hours', () => {
|
||||
renderProfileView(vi.fn(), {
|
||||
totalPlayTimeMs: 90 * 60 * 1000,
|
||||
});
|
||||
|
||||
const playTimeCard = screen.getByRole('button', {
|
||||
name: /总游戏时长/u,
|
||||
});
|
||||
|
||||
expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
|
||||
expect(within(playTimeCard).queryByText('90分')).toBeNull();
|
||||
});
|
||||
|
||||
test('wallet ledger modal shows empty and error states', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
|
||||
|
||||
renderProfileView();
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
await user.click(screen.getByText('剩余陶泥币'));
|
||||
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByLabelText('关闭叙世币账单'));
|
||||
await user.click(screen.getByLabelText('关闭陶泥币账单'));
|
||||
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
await user.click(screen.getByText('剩余陶泥币'));
|
||||
|
||||
expect(await screen.findByText('加载失败')).toBeTruthy();
|
||||
expect(screen.getByText('重新加载')).toBeTruthy();
|
||||
@@ -345,6 +487,7 @@ test('mobile home search submits public work code', async () => {
|
||||
requireAuth: vi.fn(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
@@ -410,6 +553,82 @@ test('public gallery cards hide work code until detail is opened', async () => {
|
||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
|
||||
});
|
||||
|
||||
test('mobile public work cards render cover, content and like count', () => {
|
||||
const { container } = renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
});
|
||||
|
||||
const card = screen.getByRole('button', {
|
||||
name: /奇幻拼图,12点赞/u,
|
||||
});
|
||||
expect(
|
||||
card.querySelector('.platform-public-work-card__cover.aspect-video'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('奇幻拼图')).toBeTruthy();
|
||||
expect(screen.getByText('一张用于公开分享的拼图作品。')).toBeTruthy();
|
||||
expect(screen.getByText('奇幻')).toBeTruthy();
|
||||
expect(screen.getByText('12')).toBeTruthy();
|
||||
expect(screen.getByText('点赞')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('.platform-mobile-home-channel--active')
|
||||
?.textContent,
|
||||
).toBe('推荐');
|
||||
});
|
||||
|
||||
test('mobile today channel only shows newly published works from today', async () => {
|
||||
const user = userEvent.setup();
|
||||
const now = new Date();
|
||||
const todayPublishedAt = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
10,
|
||||
).toISOString();
|
||||
const yesterdayPublishedAt = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() - 1,
|
||||
10,
|
||||
).toISOString();
|
||||
const todayEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-today',
|
||||
profileId: 'puzzle-profile-today',
|
||||
publicWorkCode: 'PZ-TODAY1',
|
||||
worldName: '今日新游',
|
||||
publishedAt: todayPublishedAt,
|
||||
updatedAt: todayPublishedAt,
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
const yesterdayEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-yesterday',
|
||||
profileId: 'puzzle-profile-yesterday',
|
||||
publicWorkCode: 'PZ-YDAY01',
|
||||
worldName: '昨日旧作',
|
||||
publishedAt: yesterdayPublishedAt,
|
||||
updatedAt: yesterdayPublishedAt,
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
const updatedTodayEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-updated-today',
|
||||
profileId: 'puzzle-profile-updated-today',
|
||||
publicWorkCode: 'PZ-UPDAY1',
|
||||
worldName: '今日更新旧作',
|
||||
publishedAt: yesterdayPublishedAt,
|
||||
updatedAt: todayPublishedAt,
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [yesterdayEntry, updatedTodayEntry, todayEntry],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '今日游戏' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: /今日新游/u })).toBeTruthy();
|
||||
expect(screen.queryByText('昨日旧作')).toBeNull();
|
||||
expect(screen.queryByText('今日更新旧作')).toBeNull();
|
||||
});
|
||||
|
||||
test('desktop trending list shows kind instead of work code or timestamp text', () => {
|
||||
mockDesktopLayout();
|
||||
|
||||
@@ -421,3 +640,96 @@ test('desktop trending list shows kind instead of work code or timestamp text',
|
||||
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('1777110165.990127Z')).toBeNull();
|
||||
});
|
||||
|
||||
test('mobile home moves category shelf into game category channel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '游戏分类' }));
|
||||
|
||||
expect(screen.getAllByText('游戏分类').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: /筛选/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '奇幻' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /奇幻拼图,试玩/u })).toBeTruthy();
|
||||
expect(container.querySelector('.platform-category-game-list')).toBeTruthy();
|
||||
expect(container.querySelector('.platform-category-game-item')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('.platform-category-game-item__action')
|
||||
?.textContent,
|
||||
).toBe('试玩');
|
||||
});
|
||||
|
||||
test('mobile game category list orders works by composite public metric', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry, hotRankEntry],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '游戏分类' }));
|
||||
await user.click(screen.getByRole('button', { name: '奇幻' }));
|
||||
|
||||
const gameItems = Array.from(
|
||||
document.querySelectorAll('.platform-category-game-item__title'),
|
||||
).map((element) => element.textContent);
|
||||
expect(gameItems).toEqual(['热门高分拼图', '奇幻拼图']);
|
||||
});
|
||||
|
||||
test('bottom category tab becomes ranking and switches ranking metrics', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [remixRankEntry, hotRankEntry, newRankEntry],
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: '分类' })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '排行' }));
|
||||
|
||||
expect(await screen.findByRole('tab', { name: '热门榜' })).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '改造榜' })).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '新品榜' })).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '点赞榜' })).toBeTruthy();
|
||||
|
||||
const rankingPanel = document.getElementById('platform-tab-panel-category');
|
||||
expect(rankingPanel?.getAttribute('aria-hidden')).toBe('false');
|
||||
expect(within(rankingPanel!).getByText('热门高分拼图')).toBeTruthy();
|
||||
expect(within(rankingPanel!).getByText('40')).toBeTruthy();
|
||||
expect(within(rankingPanel!).getAllByText('游玩').length).toBeGreaterThan(0);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '改造榜' }));
|
||||
|
||||
expect(within(rankingPanel!).getByText('改造高分拼图')).toBeTruthy();
|
||||
expect(within(rankingPanel!).getByText('18')).toBeTruthy();
|
||||
expect(within(rankingPanel!).getAllByText('改造').length).toBeGreaterThan(0);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '新品榜' }));
|
||||
|
||||
expect(within(rankingPanel!).getByText('新品增长拼图')).toBeTruthy();
|
||||
expect(within(rankingPanel!).getByText('9')).toBeTruthy();
|
||||
expect(within(rankingPanel!).getAllByText('近7日').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('ranking rows limit displayed work name and show two short tags on the third line', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [longTextRankEntry],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '排行' }));
|
||||
|
||||
const rankingPanel = document.getElementById('platform-tab-panel-category');
|
||||
expect(rankingPanel).toBeTruthy();
|
||||
expect(within(rankingPanel!).getByText('关键词逍遥游拼图')).toBeTruthy();
|
||||
expect(within(rankingPanel!).queryByText('关键词逍遥游拼图关卡')).toBeNull();
|
||||
expect(within(rankingPanel!).getByText('逍遥游拼')).toBeTruthy();
|
||||
expect(within(rankingPanel!).getByText('古风机关')).toBeTruthy();
|
||||
expect(within(rankingPanel!).queryByText(/2026-04-29/u)).toBeNull();
|
||||
expect(within(rankingPanel!).queryByText('拼图玩家')).toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user