1
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
@@ -302,6 +309,17 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
|
||||
function dispatchClientYPointerEvent(
|
||||
target: HTMLElement,
|
||||
type: string,
|
||||
clientY: number,
|
||||
) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.assign(event, { clientY });
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
const puzzlePublicEntry = {
|
||||
sourceType: 'puzzle',
|
||||
workId: 'puzzle-work-public-1',
|
||||
@@ -512,7 +530,8 @@ function renderLoggedOutHomeView(
|
||||
| 'activeRecommendEntryKey'
|
||||
| 'isStartingRecommendEntry'
|
||||
| 'recommendRuntimeError'
|
||||
| 'onSelectRecommendEntry'
|
||||
| 'onSelectNextRecommendEntry'
|
||||
| 'onSelectPreviousRecommendEntry'
|
||||
>
|
||||
> = {},
|
||||
) {
|
||||
@@ -566,7 +585,8 @@ function renderLoggedOutHomeView(
|
||||
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
|
||||
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
|
||||
recommendRuntimeError={overrides.recommendRuntimeError}
|
||||
onSelectRecommendEntry={overrides.onSelectRecommendEntry}
|
||||
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
|
||||
onSelectPreviousRecommendEntry={overrides.onSelectPreviousRecommendEntry}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
|
||||
/>
|
||||
@@ -960,7 +980,7 @@ test('shows a reachable login entry in logged out mobile shell', async () => {
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('logged out bottom nav keeps creation centered with recommend icon', () => {
|
||||
test('logged out bottom nav turns active recommend tab into next action', () => {
|
||||
const { container } = renderLoggedOutHomeView(vi.fn());
|
||||
|
||||
const nav = container.querySelector('.platform-bottom-nav');
|
||||
@@ -968,11 +988,11 @@ test('logged out bottom nav keeps creation centered with recommend icon', () =>
|
||||
const buttons = within(nav as HTMLElement).getAllByRole('button');
|
||||
|
||||
expect(buttons.map((button) => button.textContent)).toEqual([
|
||||
'推荐',
|
||||
'下一个',
|
||||
'创作',
|
||||
'发现',
|
||||
]);
|
||||
expect(buttons[0]?.querySelector('.lucide-gamepad-2')).toBeTruthy();
|
||||
expect(buttons[0]?.querySelector('.lucide-chevron-down')).toBeTruthy();
|
||||
expect(buttons[1]?.querySelector('.lucide-sparkles')).toBeTruthy();
|
||||
expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
|
||||
});
|
||||
@@ -1091,16 +1111,18 @@ test('public gallery cards hide work code until detail is opened', async () => {
|
||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
|
||||
});
|
||||
|
||||
test('mobile recommend page renders runtime viewport and bottom switcher', () => {
|
||||
const onSelectRecommendEntry = vi.fn();
|
||||
|
||||
test('mobile recommend page renders runtime viewport without bottom work cards', () => {
|
||||
const onOpenGalleryDetail = vi.fn();
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||
onSelectRecommendEntry,
|
||||
onOpenGalleryDetail,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
||||
const runtimePanel = document.querySelector('.platform-recommend-runtime-panel');
|
||||
expect(runtimePanel).toBeTruthy();
|
||||
expect(runtimePanel?.className).not.toContain('bg-black');
|
||||
expect(screen.queryByText('一张用于公开分享的拼图作品。')).toBeNull();
|
||||
expect(
|
||||
document.querySelector('.platform-public-work-card__cover'),
|
||||
@@ -1109,33 +1131,66 @@ test('mobile recommend page renders runtime viewport and bottom switcher', () =>
|
||||
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');
|
||||
expect(screen.queryByRole('button', { name: '切换到 奇幻拼图' })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '查看 奇幻拼图 详情' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '打开 奇幻拼图 详情' }),
|
||||
).toBeNull();
|
||||
expect(document.querySelector('.platform-recommend-switcher')).toBeNull();
|
||||
fireEvent.click(screen.getByLabelText('奇幻拼图 作品信息'));
|
||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
test('mobile recommend loading state is themed instead of hardcoded black', () => {
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry, secondEntry],
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||
onSelectRecommendEntry,
|
||||
isStartingRecommendEntry: true,
|
||||
recommendRuntimeContent: null,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '切换到 第二拼图' }));
|
||||
const loadingState = screen.getByText('加载中...');
|
||||
expect(loadingState.className).toContain('platform-recommend-runtime-state');
|
||||
expect(loadingState.className).not.toContain('bg-black');
|
||||
});
|
||||
|
||||
expect(onSelectRecommendEntry).toHaveBeenCalledWith(secondEntry);
|
||||
test('mobile recommend meta swipes between public works', () => {
|
||||
const onSelectNextRecommendEntry = vi.fn();
|
||||
const onSelectPreviousRecommendEntry = vi.fn();
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||
onSelectNextRecommendEntry,
|
||||
onSelectPreviousRecommendEntry,
|
||||
});
|
||||
|
||||
const meta = screen.getByLabelText('奇幻拼图 作品信息');
|
||||
dispatchClientYPointerEvent(meta, 'pointerdown', 240);
|
||||
dispatchClientYPointerEvent(meta, 'pointerup', 180);
|
||||
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
|
||||
expect(onSelectPreviousRecommendEntry).not.toHaveBeenCalled();
|
||||
|
||||
dispatchClientYPointerEvent(meta, 'pointerdown', 180);
|
||||
dispatchClientYPointerEvent(meta, 'pointerup', 240);
|
||||
expect(onSelectPreviousRecommendEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('active recommend bottom tab selects next work instead of navigating', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectNextRecommendEntry = vi.fn();
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||
onSelectNextRecommendEntry,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '下一个' }));
|
||||
|
||||
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('mobile recommend meta loads real author avatar from public user summary', async () => {
|
||||
|
||||
@@ -122,7 +122,8 @@ export interface RpgEntryHomeViewProps {
|
||||
activeRecommendEntryKey?: string | null;
|
||||
isStartingRecommendEntry?: boolean;
|
||||
recommendRuntimeError?: string | null;
|
||||
onSelectRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onSelectNextRecommendEntry?: () => void;
|
||||
onSelectPreviousRecommendEntry?: () => void;
|
||||
onOpenLibraryDetail: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => void;
|
||||
@@ -149,6 +150,8 @@ const HERO_SURFACE_CLASS =
|
||||
'platform-surface platform-surface--hero platform-interactive-card min-w-0';
|
||||
const MOBILE_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
|
||||
const MOBILE_RECOMMEND_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage min-w-0 space-y-4 overflow-hidden pb-2';
|
||||
const DESKTOP_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface min-w-0 space-y-5 pb-4';
|
||||
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
|
||||
@@ -165,6 +168,7 @@ const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
|
||||
const PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000;
|
||||
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
|
||||
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
|
||||
|
||||
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
||||
type DiscoverChannel = 'recommend' | 'today' | 'category' | 'ranking';
|
||||
@@ -664,12 +668,15 @@ function CreationLibraryCard({
|
||||
function RecommendRuntimeMeta({
|
||||
entry,
|
||||
authorAvatarUrl,
|
||||
onOpenDetail,
|
||||
onSelectNext,
|
||||
onSelectPrevious,
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
authorAvatarUrl?: string | null;
|
||||
onOpenDetail: () => void;
|
||||
onSelectNext?: () => void;
|
||||
onSelectPrevious?: () => void;
|
||||
}) {
|
||||
const swipeStartYRef = useRef<number | null>(null);
|
||||
const playCount = getPlatformWorldPlayCount(entry);
|
||||
const remixCount = getPlatformWorldRemixCount(entry);
|
||||
const likeCount = getPlatformWorldLikeCount(entry);
|
||||
@@ -682,11 +689,37 @@ function RecommendRuntimeMeta({
|
||||
{ label: '点赞', value: likeCount, icon: Heart },
|
||||
{ label: '改造', value: remixCount, icon: MessageCircle },
|
||||
];
|
||||
const handlePointerEnd = (clientY: number) => {
|
||||
const startY = swipeStartYRef.current;
|
||||
swipeStartYRef.current = null;
|
||||
if (startY === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaY = clientY - startY;
|
||||
if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaY < 0) {
|
||||
onSelectNext?.();
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectPrevious?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="platform-recommend-work-meta"
|
||||
aria-label={`${entry.worldName} 作品信息`}
|
||||
onPointerDown={(event) => {
|
||||
swipeStartYRef.current = event.clientY;
|
||||
}}
|
||||
onPointerUp={(event) => handlePointerEnd(event.clientY)}
|
||||
onPointerCancel={() => {
|
||||
swipeStartYRef.current = null;
|
||||
}}
|
||||
>
|
||||
<div className="platform-recommend-work-meta__stats">
|
||||
{statItems.map(({ label, value, icon: Icon }) => (
|
||||
@@ -702,11 +735,8 @@ function RecommendRuntimeMeta({
|
||||
</div>
|
||||
|
||||
<div className="platform-recommend-work-meta__row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenDetail}
|
||||
<div
|
||||
className="platform-recommend-work-meta__identity"
|
||||
aria-label={`打开 ${entry.worldName} 详情`}
|
||||
>
|
||||
<span
|
||||
className="platform-recommend-work-meta__avatar"
|
||||
@@ -730,62 +760,12 @@ function RecommendRuntimeMeta({
|
||||
{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>
|
||||
</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,
|
||||
@@ -2861,7 +2841,8 @@ export function RpgEntryHomeView({
|
||||
activeRecommendEntryKey = null,
|
||||
isStartingRecommendEntry = false,
|
||||
recommendRuntimeError = null,
|
||||
onSelectRecommendEntry,
|
||||
onSelectNextRecommendEntry,
|
||||
onSelectPreviousRecommendEntry,
|
||||
onOpenLibraryDetail,
|
||||
onDeleteLibraryEntry,
|
||||
deletingLibraryEntryId = null,
|
||||
@@ -3722,6 +3703,12 @@ export function RpgEntryHomeView({
|
||||
) ??
|
||||
recommendedFeedEntries[0] ??
|
||||
null;
|
||||
const selectNextRecommendEntry = useCallback(() => {
|
||||
onSelectNextRecommendEntry?.();
|
||||
}, [onSelectNextRecommendEntry]);
|
||||
const selectPreviousRecommendEntry = useCallback(() => {
|
||||
onSelectPreviousRecommendEntry?.();
|
||||
}, [onSelectPreviousRecommendEntry]);
|
||||
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
|
||||
const openLeadPublicEntry = () => {
|
||||
if (leadPublicEntry) {
|
||||
@@ -3785,7 +3772,7 @@ export function RpgEntryHomeView({
|
||||
|
||||
const mobileRecommendContent: ReactNode = (
|
||||
<div
|
||||
className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage platform-mobile-recommend-stage`}
|
||||
className={`${MOBILE_RECOMMEND_PAGE_STAGE_CLASS} platform-mobile-home-stage platform-mobile-recommend-stage`}
|
||||
>
|
||||
{platformError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||||
@@ -3823,42 +3810,9 @@ export function RpgEntryHomeView({
|
||||
<RecommendRuntimeMeta
|
||||
entry={activeRecommendEntry}
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
|
||||
onOpenDetail={() => onOpenGalleryDetail(activeRecommendEntry)}
|
||||
onSelectNext={selectNextRecommendEntry}
|
||||
onSelectPrevious={selectPreviousRecommendEntry}
|
||||
/>
|
||||
) : 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}
|
||||
@@ -4706,10 +4660,25 @@ export function RpgEntryHomeView({
|
||||
<PlatformTabButton
|
||||
key={tab}
|
||||
active={activeTab === tab}
|
||||
label={tabLabels[tab]}
|
||||
icon={tabIcons[tab]}
|
||||
label={
|
||||
activeTab === 'home' && tab === 'home'
|
||||
? '下一个'
|
||||
: tabLabels[tab]
|
||||
}
|
||||
icon={
|
||||
activeTab === 'home' && tab === 'home'
|
||||
? ChevronDown
|
||||
: tabIcons[tab]
|
||||
}
|
||||
emphasized={tab === 'create'}
|
||||
onClick={() => onTabChange(tab)}
|
||||
onClick={() => {
|
||||
if (activeTab === 'home' && tab === 'home') {
|
||||
selectNextRecommendEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
onTabChange(tab);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user