1
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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: /奇幻拼图,拼图,20游玩,5改造,12点赞/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: /奇幻拼图,拼图,20游玩,5改造,12点赞/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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user