This commit is contained in:
2026-05-08 20:48:29 +08:00
parent abf1f1ebea
commit 94975e4735
82 changed files with 7786 additions and 1012 deletions

View File

@@ -74,6 +74,10 @@ import {
listPuzzleGallery,
remixPuzzleGalleryWork,
} from '../../services/puzzle-gallery';
import {
generatePuzzleOnboardingWork,
savePuzzleOnboardingWork,
} from '../../services/puzzle-onboarding';
import {
advancePuzzleNextLevel,
dragPuzzlePieceOrGroup,
@@ -161,7 +165,7 @@ async function clickFirstAsyncButtonByName(
async function openCreateTemplateHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '创作');
expect(
await screen.findByText('10分钟创作一个精品互动玩法'),
await screen.findByRole('tablist', { name: '选择模板' }),
).toBeTruthy();
expect(screen.getByRole('tab', { name: '拼图' })).toBeTruthy();
expect(screen.getByText('拼图工作区missing-session')).toBeTruthy();
@@ -390,6 +394,11 @@ vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => {
};
});
vi.mock('../../services/puzzle-onboarding', () => ({
generatePuzzleOnboardingWork: vi.fn(),
savePuzzleOnboardingWork: vi.fn(),
}));
vi.mock('../../services/puzzle-agent', () => ({
createPuzzleAgentSession: vi.fn(),
executePuzzleAgentAction: vi.fn(),
@@ -2080,6 +2089,107 @@ beforeEach(() => {
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [],
});
vi.mocked(generatePuzzleOnboardingWork).mockResolvedValue({
item: {
workId: 'onboarding-work-1',
profileId: 'onboarding-profile-1',
ownerUserId: 'onboarding-guest',
sourceSessionId: null,
authorDisplayName: '百梦主',
workTitle: '梦境拼图',
workDescription: '我想飞上天',
levelName: '云上飞行',
summary: '我想飞上天',
themeTags: ['新手引导', '拼图'],
coverImageSrc: 'data:image/svg+xml;utf8,onboarding',
coverAssetId: 'onboarding-asset-1',
publicationStatus: 'draft',
updatedAt: '2026-05-05T12:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: true,
levels: [],
},
level: {
levelId: 'onboarding-level-1',
levelName: '云上飞行',
pictureDescription: '我想飞上天',
pictureReference: null,
candidates: [
{
candidateId: 'onboarding-candidate-1',
imageSrc: 'data:image/svg+xml;utf8,onboarding',
assetId: 'onboarding-asset-1',
prompt: '我想飞上天',
actualPrompt: '我想飞上天',
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'onboarding-candidate-1',
coverImageSrc: 'data:image/svg+xml;utf8,onboarding',
coverAssetId: 'onboarding-asset-1',
generationStatus: 'ready',
},
});
vi.mocked(savePuzzleOnboardingWork).mockResolvedValue({
item: {
workId: 'onboarding-work-saved',
profileId: 'onboarding-profile-saved',
ownerUserId: mockAuthUser.id,
sourceSessionId: 'puzzle-session-onboarding',
authorDisplayName: mockAuthUser.displayName,
workTitle: '梦境拼图',
workDescription: '我想飞上天',
levelName: '云上飞行',
summary: '我想飞上天',
themeTags: ['新手引导', '拼图'],
coverImageSrc: 'data:image/svg+xml;utf8,onboarding',
coverAssetId: 'onboarding-asset-1',
publicationStatus: 'draft',
updatedAt: '2026-05-05T12:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: true,
levels: [],
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '新手引导',
status: 'confirmed',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '云上飞行',
status: 'confirmed',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '明亮',
status: 'confirmed',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '天空',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '拼图',
status: 'confirmed',
},
},
},
});
vi.mocked(remixPuzzleGalleryWork).mockRejectedValue(
new Error('未启用拼图 remix'),
);
@@ -3262,7 +3372,7 @@ test('puzzle draft result back button returns to creation hub', async () => {
await user.click(screen.getByRole('button', { name: '返回' }));
expect(
await screen.findByText('10分钟创作一个精品互动玩法'),
await screen.findByRole('tablist', { name: '选择模板' }),
).toBeTruthy();
expect(screen.getByText('雨夜里有一只会发光的猫站在遗迹台阶上。')).toBeTruthy();
expect(screen.queryByText('拼图结果页')).toBeNull();
@@ -3312,6 +3422,82 @@ test('published puzzle work card restores its source session for editing', async
expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy();
});
test('first launch puzzle onboarding can be skipped from top right', async () => {
const user = userEvent.setup();
window.localStorage.removeItem(
'genarrative.puzzle-onboarding.first-visit.v1',
);
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth: () => {},
})}
/>,
);
expect(await screen.findByText('待定待定待定')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '跳过' }));
await waitFor(() => {
expect(screen.queryByText('待定待定待定')).toBeNull();
});
expect(
window.localStorage.getItem(
'genarrative.puzzle-onboarding.first-visit.v1',
),
).toBe('1');
expect(generatePuzzleOnboardingWork).not.toHaveBeenCalled();
});
test('first launch puzzle onboarding falls back to local run when generate route is missing', async () => {
const user = userEvent.setup();
window.localStorage.removeItem(
'genarrative.puzzle-onboarding.first-visit.v1',
);
vi.mocked(generatePuzzleOnboardingWork).mockRejectedValueOnce(
new ApiClientError({
message: '资源不存在',
status: 404,
code: 'NOT_FOUND',
}),
);
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth: () => {},
})}
/>,
);
await user.type(
await screen.findByPlaceholderText('把你的梦讲给我听吧'),
'我想飞上天',
);
await user.click(screen.getByRole('button', { name: '生成' }));
expect(
await screen.findByTestId('puzzle-board', undefined, { timeout: 3000 }),
).toBeTruthy();
expect(generatePuzzleOnboardingWork).toHaveBeenCalledWith({
promptText: '我想飞上天',
});
expect(screen.queryByText('资源不存在')).toBeNull();
expect(startPuzzleRun).not.toHaveBeenCalled();
expect(
window.localStorage.getItem(
'genarrative.puzzle-onboarding.first-visit.v1',
),
).toBe('1');
});
test('formal puzzle runtime uses frontend move merge logic and backend leaderboard next level', async () => {
const user = userEvent.setup();
const clearedFirstLevel = buildClearedPuzzleRun({
@@ -4717,7 +4903,7 @@ test('agent draft result back button returns to creation hub without syncing res
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByText('10分钟创作一个精品互动玩法')).toBeTruthy();
expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy();
});
expect(
@@ -5041,16 +5227,16 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
await clickFirstButtonByName(user, '创作');
expect(
await screen.findByText('10分钟创作一个精品互动玩法'),
await screen.findByRole('tablist', { name: '选择模板' }),
).toBeTruthy();
resolveGalleryRequest([]);
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getByText(
'10分钟创作一个精品互动玩法',
),
within(getPlatformTabPanel('create')).getByRole('tablist', {
name: '选择模板',
}),
).toBeTruthy();
});

View File

@@ -508,6 +508,11 @@ function renderLoggedOutHomeView(
| 'latestEntries'
| 'onOpenGalleryDetail'
| 'onSearchPublicCode'
| 'recommendRuntimeContent'
| 'activeRecommendEntryKey'
| 'isStartingRecommendEntry'
| 'recommendRuntimeError'
| 'onSelectRecommendEntry'
>
> = {},
) {
@@ -553,6 +558,15 @@ function renderLoggedOutHomeView(
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
recommendRuntimeContent={
overrides.recommendRuntimeContent ?? (
<div data-testid="recommend-runtime"></div>
)
}
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
recommendRuntimeError={overrides.recommendRuntimeError}
onSelectRecommendEntry={overrides.onSelectRecommendEntry}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
/>
@@ -562,7 +576,13 @@ function renderLoggedOutHomeView(
function renderStatefulLoggedOutHomeView(
overrides: Partial<
Pick<RpgEntryHomeViewProps, 'featuredEntries' | 'latestEntries'>
Pick<
RpgEntryHomeViewProps,
| 'featuredEntries'
| 'latestEntries'
| 'onOpenGalleryDetail'
| 'onSearchPublicCode'
>
> = {},
) {
function StatefulLoggedOutHomeView() {
@@ -610,9 +630,10 @@ function renderStatefulLoggedOutHomeView(
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
recommendRuntimeContent={<div data-testid="recommend-runtime" />}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
/>
</AuthUiContext.Provider>
);
@@ -956,57 +977,12 @@ test('logged out bottom nav keeps creation centered with recommend icon', () =>
expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
});
test('mobile home search submits public work code', async () => {
test('mobile discover search submits public work code', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();
render(
<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="home"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
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={onSearchPublicCode}
/>
</AuthUiContext.Provider>,
);
renderStatefulLoggedOutHomeView({ onSearchPublicCode });
await user.click(screen.getByRole('button', { name: '发现' }));
const searchInput = screen.getByPlaceholderText(
'搜索作品号、名称、作者、描述',
@@ -1016,7 +992,7 @@ test('mobile home search submits public work code', async () => {
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
});
test('home search fuzzy matches public work id, name, author and description', async () => {
test('discover search fuzzy matches public work id, name, author and description', async () => {
const user = userEvent.setup();
const onOpenGalleryDetail = vi.fn();
const onSearchPublicCode = vi.fn();
@@ -1041,46 +1017,52 @@ test('home search fuzzy matches public work id, name, author and description', a
},
] satisfies PlatformPublicGalleryCard[];
renderLoggedOutHomeView(vi.fn(), {
renderStatefulLoggedOutHomeView({
latestEntries: entries,
onOpenGalleryDetail,
onSearchPublicCode,
});
await user.click(screen.getByRole('button', { name: '发现' }));
const discoverPanel = document.getElementById('platform-tab-panel-category');
if (!discoverPanel) {
throw new Error('缺少发现面板');
}
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
await user.type(searchInput, 'MOON01{enter}');
expect(await screen.findByText('搜索结果')).toBeTruthy();
expect(screen.getByText('月井机关')).toBeTruthy();
expect(screen.queryByText('火桥谜图')).toBeNull();
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
expect(within(discoverPanel).getByText('月井机关')).toBeTruthy();
expect(within(discoverPanel).queryByText('火桥谜图')).toBeNull();
expect(onSearchPublicCode).not.toHaveBeenCalled();
await user.clear(searchInput);
await user.type(searchInput, '火桥{enter}');
expect(await screen.findByText('火桥谜图')).toBeTruthy();
expect(screen.queryByText('月井机关')).toBeNull();
expect(await within(discoverPanel).findByText('火桥谜图')).toBeTruthy();
expect(within(discoverPanel).queryByText('月井机关')).toBeNull();
await user.clear(searchInput);
await user.type(searchInput, '月井守望{enter}');
expect(await screen.findByText('月井机关')).toBeTruthy();
expect(screen.queryByText('火桥谜图')).toBeNull();
expect(await within(discoverPanel).findByText('月井机关')).toBeTruthy();
expect(within(discoverPanel).queryByText('火桥谜图')).toBeNull();
await user.clear(searchInput);
await user.type(searchInput, '熔岩断桥{enter}');
expect(await screen.findByText('火桥谜图')).toBeTruthy();
expect(screen.queryByText('月井机关')).toBeNull();
expect(await within(discoverPanel).findByText('火桥谜图')).toBeTruthy();
expect(within(discoverPanel).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 () => {
test('discover search keeps public code fallback when local works do not match', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();
renderLoggedOutHomeView(vi.fn(), {
renderStatefulLoggedOutHomeView({
latestEntries: [puzzlePublicEntry],
onSearchPublicCode,
});
await user.click(screen.getByRole('button', { name: '发现' }));
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
await user.type(searchInput, 'CW-REMOTE-ONLY{enter}');
@@ -1093,10 +1075,11 @@ test('public gallery cards hide work code until detail is opened', async () => {
const user = userEvent.setup();
const onOpenGalleryDetail = vi.fn();
renderLoggedOutHomeView(vi.fn(), {
renderStatefulLoggedOutHomeView({
latestEntries: [puzzlePublicEntry],
onOpenGalleryDetail,
});
await user.click(screen.getByRole('button', { name: '发现' }));
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
expect(
@@ -1108,47 +1091,54 @@ test('public gallery cards hide work code until detail is opened', async () => {
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
});
test('mobile public work cards render cover, author, kind and cover stats', () => {
const { container } = renderLoggedOutHomeView(vi.fn(), {
test('mobile recommend page renders runtime viewport and bottom switcher', () => {
const onSelectRecommendEntry = vi.fn();
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
onSelectRecommendEntry,
});
const card = screen.getByRole('button', {
name: /20512/u,
});
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
expect(screen.queryByText('一张用于公开分享的拼图作品。')).toBeNull();
expect(
card.querySelector('.platform-public-work-card__cover.aspect-video'),
).toBeTruthy();
expect(
card.querySelector('.platform-public-work-card__cover-stats'),
).toBeTruthy();
expect(
card.querySelectorAll('.platform-public-work-card__cover-stat'),
).toHaveLength(3);
expect(
card.querySelector('.platform-public-work-card__kind')?.textContent,
).toBe('拼图');
expect(
card.querySelector('.platform-public-work-card__author-avatar')
?.textContent,
).toBe('拼');
expect(screen.getByText('奇幻拼图')).toBeTruthy();
document.querySelector('.platform-public-work-card__cover'),
).toBeNull();
expect(screen.getByText('拼图玩家')).toBeTruthy();
expect(screen.getByText('一张用于公开分享的拼图作品。')).toBeTruthy();
expect(screen.getByText('奇幻')).toBeTruthy();
expect(screen.getByText('20')).toBeTruthy();
expect(screen.getByText('5')).toBeTruthy();
expect(screen.getByText('12')).toBeTruthy();
expect(card.querySelector('.platform-pill--warm')?.textContent).not.toBe(
'推荐',
);
expect(
container.querySelector('.platform-mobile-home-channel--active')
?.textContent,
).toBe('推荐');
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
expect(screen.getAllByText('20').length).toBeGreaterThan(0);
expect(screen.getAllByText('12').length).toBeGreaterThan(0);
const switchButton = screen.getByRole('button', {
name: '切换到 奇幻拼图',
});
expect(switchButton.getAttribute('aria-pressed')).toBe('true');
});
test('public work cards load real author avatar from public user summary', async () => {
test('mobile recommend switcher selects a different public work', async () => {
const user = userEvent.setup();
const onSelectRecommendEntry = vi.fn();
const secondEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-second',
profileId: 'puzzle-profile-second',
publicWorkCode: 'PZ-SECOND',
worldName: '第二拼图',
} satisfies PlatformPublicGalleryCard;
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry, secondEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
onSelectRecommendEntry,
});
await user.click(screen.getByRole('button', { name: '切换到 第二拼图' }));
expect(onSelectRecommendEntry).toHaveBeenCalledWith(secondEntry);
});
test('mobile recommend meta loads real author avatar from public user summary', async () => {
mockGetPublicAuthUserById.mockResolvedValueOnce({
id: 'user-2',
publicUserCode: 'SY-00000002',
@@ -1159,16 +1149,13 @@ test('public work cards load real author avatar from public user summary', async
renderLoggedOutHomeView(vi.fn(), {
featuredEntries: [puzzlePublicEntry],
latestEntries: [puzzlePublicEntry],
});
const card = screen.getByRole('button', {
name: /20512/u,
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
});
await waitFor(() => {
expect(
card
.querySelector('.platform-public-work-card__author-avatar-image')
document
.querySelector('.platform-recommend-work-meta__avatar img')
?.getAttribute('src'),
).toBe('data:image/png;base64,AUTHOR');
});
@@ -1177,7 +1164,7 @@ test('public work cards load real author avatar from public user summary', async
expect(mockGetPublicAuthUserByCode).not.toHaveBeenCalled();
});
test('mobile home feed only rotates the card closest to screen center', () => {
test('mobile discover recommend feed only rotates the card closest to screen center', async () => {
vi.useFakeTimers();
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
@@ -1199,9 +1186,12 @@ test('mobile home feed only rotates the card closest to screen center', () => {
);
const cardRects = new Map<string, DOMRect>();
renderLoggedOutHomeView(vi.fn(), {
renderStatefulLoggedOutHomeView({
latestEntries: [firstEntry, secondEntry],
});
act(() => {
screen.getByRole('button', { name: '发现' }).click();
});
const tabPanel = document.querySelector('.platform-tab-panel--active');
const firstCard = screen.getByRole('button', { name: //u });
@@ -1340,15 +1330,22 @@ test('mobile today channel only shows newly published works from today', async (
updatedAt: todayPublishedAt,
} satisfies PlatformPublicGalleryCard;
renderLoggedOutHomeView(vi.fn(), {
renderStatefulLoggedOutHomeView({
latestEntries: [yesterdayEntry, updatedTodayEntry, todayEntry],
});
await user.click(screen.getByRole('button', { name: '今日游戏' }));
await user.click(screen.getByRole('button', { name: '发现' }));
await user.click(screen.getByRole('button', { name: '今日' }));
const discoverPanel = document.getElementById('platform-tab-panel-category');
if (!discoverPanel) {
throw new Error('缺少发现面板');
}
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByText('昨日旧作')).toBeNull();
expect(screen.queryByText('今日更新旧作')).toBeNull();
expect(
within(discoverPanel).getByRole('button', { name: //u }),
).toBeTruthy();
expect(within(discoverPanel).queryByText('昨日旧作')).toBeNull();
expect(within(discoverPanel).queryByText('今日更新旧作')).toBeNull();
});
test('desktop home syncs mobile home modules without square or latest labels', () => {
@@ -1369,7 +1366,7 @@ test('desktop home syncs mobile home modules without square or latest labels', (
});
expect(screen.getByText('今日游戏')).toBeTruthy();
expect(screen.getByText('推荐')).toBeTruthy();
expect(screen.getAllByText('推荐').length).toBeGreaterThan(0);
expect(screen.getByText('作品分类')).toBeTruthy();
expect(screen.getAllByText('桌面今日新游').length).toBeGreaterThan(0);
expect(screen.queryByText('趋势关注')).toBeNull();
@@ -1383,16 +1380,17 @@ test('desktop home syncs mobile home modules without square or latest labels', (
test('mobile home moves category shelf into game category channel', async () => {
const user = userEvent.setup();
const { container } = renderLoggedOutHomeView(vi.fn(), {
const { container } = renderStatefulLoggedOutHomeView({
latestEntries: [puzzlePublicEntry],
});
expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull();
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
await user.click(screen.getByRole('button', { name: '游戏分类' }));
await user.click(screen.getByRole('button', { name: '发现' }));
await user.click(screen.getByRole('button', { name: '分类' }));
expect(screen.getAllByText('游戏分类').length).toBeGreaterThan(0);
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();
@@ -1407,11 +1405,12 @@ test('mobile home moves category shelf into game category channel', async () =>
test('mobile game category list orders works by composite public metric', async () => {
const user = userEvent.setup();
renderLoggedOutHomeView(vi.fn(), {
renderStatefulLoggedOutHomeView({
latestEntries: [puzzlePublicEntry, hotRankEntry],
});
await user.click(screen.getByRole('button', { name: '游戏分类' }));
await user.click(screen.getByRole('button', { name: '发现' }));
await user.click(screen.getByRole('button', { name: '分类' }));
await user.click(screen.getByRole('button', { name: '奇幻' }));
const gameItems = Array.from(
@@ -1427,8 +1426,7 @@ test('bottom category tab becomes ranking and switches ranking metrics', async (
latestEntries: [remixRankEntry, hotRankEntry, newRankEntry],
});
expect(screen.queryByRole('button', { name: '分类' })).toBeNull();
await user.click(screen.getByRole('button', { name: '发现' }));
await user.click(screen.getByRole('button', { name: '排行' }));
expect(await screen.findByRole('tab', { name: '热门榜' })).toBeTruthy();
@@ -1462,6 +1460,7 @@ test('ranking rows limit displayed work name and show two short tags on the thir
latestEntries: [longTextRankEntry],
});
await user.click(screen.getByRole('button', { name: '发现' }));
await user.click(screen.getByRole('button', { name: '排行' }));
const rankingPanel = document.getElementById('platform-tab-panel-category');

View File

@@ -118,6 +118,11 @@ export interface RpgEntryHomeViewProps {
onOpenCreateWorld: () => void;
onOpenCreateTypePicker: () => void;
onOpenGalleryDetail: (entry: PlatformPublicGalleryCard) => void;
recommendRuntimeContent?: ReactNode;
activeRecommendEntryKey?: string | null;
isStartingRecommendEntry?: boolean;
recommendRuntimeError?: string | null;
onSelectRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onOpenLibraryDetail: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void;
@@ -656,6 +661,131 @@ function CreationLibraryCard({
);
}
function RecommendRuntimeMeta({
entry,
authorAvatarUrl,
onOpenDetail,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
onOpenDetail: () => void;
}) {
const playCount = getPlatformWorldPlayCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const likeCount = getPlatformWorldLikeCount(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const statItems = [
{ label: '游玩', value: playCount, icon: Gamepad2 },
{ label: '点赞', value: likeCount, icon: Heart },
{ label: '改造', value: remixCount, icon: MessageCircle },
];
return (
<section
className="platform-recommend-work-meta"
aria-label={`${entry.worldName} 作品信息`}
>
<div className="platform-recommend-work-meta__stats">
{statItems.map(({ label, value, icon: Icon }) => (
<span
key={label}
className="platform-recommend-work-meta__stat"
aria-label={`${label} ${formatCompactCount(value)}`}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span>{formatCompactCount(value)}</span>
</span>
))}
</div>
<div className="platform-recommend-work-meta__row">
<button
type="button"
onClick={onOpenDetail}
className="platform-recommend-work-meta__identity"
aria-label={`打开 ${entry.worldName} 详情`}
>
<span
className="platform-recommend-work-meta__avatar"
aria-hidden="true"
>
{normalizedAuthorAvatarUrl ? (
<img
src={normalizedAuthorAvatarUrl}
alt=""
className="h-full w-full rounded-full object-cover"
/>
) : (
authorAvatarLabel
)}
</span>
<span className="platform-recommend-work-meta__text">
<span className="platform-recommend-work-meta__author">
{authorName}
</span>
<span className="platform-recommend-work-meta__title">
{displayName}
</span>
</span>
</button>
<button
type="button"
onClick={onOpenDetail}
className="platform-recommend-work-meta__detail-button"
aria-label={`查看 ${entry.worldName} 详情`}
title="详情"
>
<ArrowRight className="h-4 w-4" />
</button>
</div>
</section>
);
}
function RecommendWorkSwitchItem({
entry,
active,
onSelect,
}: {
entry: PlatformPublicGalleryCard;
active: boolean;
onSelect: () => void;
}) {
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const typeLabel = describePublicGalleryCardKind(entry);
const playCount = getPlatformWorldPlayCount(entry);
const likeCount = getPlatformWorldLikeCount(entry);
return (
<button
type="button"
onClick={onSelect}
aria-label={`切换到 ${entry.worldName}`}
aria-pressed={active}
className={`platform-recommend-switch-card ${active ? 'platform-recommend-switch-card--active' : ''}`}
>
<span className="platform-recommend-switch-card__kind">{typeLabel}</span>
<span className="platform-recommend-switch-card__title">
{displayName}
</span>
<span className="platform-recommend-switch-card__stats">
<span>
<Gamepad2 className="h-3 w-3" aria-hidden="true" />
{formatCompactCount(playCount)}
</span>
<span>
<Heart className="h-3 w-3" aria-hidden="true" />
{formatCompactCount(likeCount)}
</span>
</span>
</button>
);
}
function SaveArchiveCard({
entry,
onClick,
@@ -2727,6 +2857,11 @@ export function RpgEntryHomeView({
onResumeSave,
onOpenCreateTypePicker,
onOpenGalleryDetail,
recommendRuntimeContent,
activeRecommendEntryKey = null,
isStartingRecommendEntry = false,
recommendRuntimeError = null,
onSelectRecommendEntry,
onOpenLibraryDetail,
onDeleteLibraryEntry,
deletingLibraryEntryId = null,
@@ -2796,7 +2931,6 @@ export function RpgEntryHomeView({
);
const [discoverChannel, setDiscoverChannel] =
useState<DiscoverChannel>('recommend');
const mobileRecommendFeedRef = useRef<HTMLElement | null>(null);
const mobileDiscoverFeedRef = useRef<HTMLElement | null>(null);
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
string | null
@@ -3494,19 +3628,15 @@ export function RpgEntryHomeView({
}, [discoverChannel, latestEntries, recommendedFeedEntries]);
const mobileFeedCarouselEnabled =
!isDesktopLayout &&
((activeTab === 'home' && recommendedFeedEntries.length > 0) ||
(activeTab === 'category' &&
(discoverChannel === 'recommend' || discoverChannel === 'today')));
activeTab === 'category' &&
(discoverChannel === 'recommend' || discoverChannel === 'today');
useEffect(() => {
if (!mobileFeedCarouselEnabled) {
setMobileCenteredCardKey(null);
return undefined;
}
const feedElement =
activeTab === 'home'
? mobileRecommendFeedRef.current
: mobileDiscoverFeedRef.current;
const feedElement = mobileDiscoverFeedRef.current;
const scrollElement = feedElement?.closest('.platform-tab-panel');
if (!feedElement || !scrollElement) {
setMobileCenteredCardKey(null);
@@ -3577,13 +3707,7 @@ export function RpgEntryHomeView({
scrollElement.removeEventListener('scroll', scheduleUpdate);
window.removeEventListener('resize', scheduleUpdate);
};
}, [
discoverChannel,
discoverFeedEntries,
activeTab,
mobileFeedCarouselEnabled,
recommendedFeedEntries,
]);
}, [discoverChannel, discoverFeedEntries, activeTab, mobileFeedCarouselEnabled]);
const activeRankingConfig = PLATFORM_RANKING_TABS.find(
(tab) => tab.id === activeRankingTab,
) as (typeof PLATFORM_RANKING_TABS)[number];
@@ -3592,6 +3716,12 @@ export function RpgEntryHomeView({
buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30),
[activeRankingTab, publicEntries],
);
const activeRecommendEntry =
recommendedFeedEntries.find(
(entry) => buildPublicGalleryCardKey(entry) === activeRecommendEntryKey,
) ??
recommendedFeedEntries[0] ??
null;
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
const openLeadPublicEntry = () => {
if (leadPublicEntry) {
@@ -3663,36 +3793,75 @@ export function RpgEntryHomeView({
</div>
) : null}
<section
ref={mobileRecommendFeedRef}
className="platform-mobile-home-feed platform-mobile-recommend-feed"
>
<section className="platform-recommend-runtime-panel">
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : recommendedFeedEntries.length > 0 ? (
<div className="grid min-w-0 gap-4">
{recommendedFeedEntries.map((entry) => {
const cardKey = buildPublicGalleryCardKey(entry);
return (
<WorldCard
key={`${cardKey}:mobile-recommend`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
feedCardKey={cardKey}
enableCoverCarousel={mobileFeedCarouselEnabled}
isCoverCarouselActive={mobileCenteredCardKey === cardKey}
variant="immersive"
/>
);
})}
<div className="platform-recommend-runtime-state">
...
</div>
) : recommendRuntimeError ? (
<button
type="button"
onClick={() =>
activeRecommendEntry
? onOpenGalleryDetail(activeRecommendEntry)
: undefined
}
className="platform-recommend-runtime-state platform-recommend-runtime-state--button"
>
{recommendRuntimeError}
</button>
) : isStartingRecommendEntry || !recommendRuntimeContent ? (
<div className="platform-recommend-runtime-state">...</div>
) : (
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
<div className="platform-recommend-runtime-viewport">
{recommendRuntimeContent}
</div>
)}
</section>
{activeRecommendEntry ? (
<RecommendRuntimeMeta
entry={activeRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
onOpenDetail={() => onOpenGalleryDetail(activeRecommendEntry)}
/>
) : null}
{recommendedFeedEntries.length > 0 ? (
<section
className="platform-recommend-switcher"
aria-label="推荐作品"
>
{recommendedFeedEntries.map((entry) => {
const cardKey = buildPublicGalleryCardKey(entry);
const active =
activeRecommendEntryKey === cardKey ||
Boolean(
!activeRecommendEntryKey &&
activeRecommendEntry &&
buildPublicGalleryCardKey(activeRecommendEntry) === cardKey,
);
return (
<RecommendWorkSwitchItem
key={`${cardKey}:recommend-switch`}
entry={entry}
active={active}
onSelect={() => {
if (onSelectRecommendEntry) {
onSelectRecommendEntry(entry);
return;
}
onOpenGalleryDetail(entry);
}}
/>
);
})}
</section>
) : !isLoadingPlatform ? (
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
) : null}
</div>
);