This commit is contained in:
2026-05-09 17:15:23 +08:00
parent 80a4183b45
commit a0ed128bde
43 changed files with 2573 additions and 381 deletions

View File

@@ -210,6 +210,12 @@ async function openExistingRpgDraft(
await user.click(await screen.findByRole('button', { name: actionName }));
}
const ISOLATED_RUNTIME_AUTH_OPTIONS = {
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
};
function getPlatformTabPanel(tab: string) {
const panel = document.getElementById(`platform-tab-panel-${tab}`);
if (!panel) {
@@ -3054,11 +3060,7 @@ test('home recommendation starts embedded puzzle without global auth reset on lo
profileId: 'puzzle-profile-public-1',
levelId: null,
},
{
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
});
@@ -3673,10 +3675,13 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
await waitFor(() => {
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
});
expect(startPuzzleRun).toHaveBeenCalledWith({
profileId: 'puzzle-profile-public-1',
levelId: null,
});
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: 'puzzle-profile-public-1',
levelId: null,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
await user.click(document.querySelector('[data-piece-id="piece-0"]')!);
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
@@ -3695,6 +3700,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
elapsedMs: 18_000,
nickname: '测试玩家',
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
@@ -3711,6 +3717,8 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
await waitFor(() => {
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedFirstLevel.runId,
{},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(
@@ -3875,6 +3883,7 @@ test('formal puzzle similar work keeps current run level progression', async ()
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedThirdLevel.runId,
{ targetProfileId: 'puzzle-profile-similar-2' },
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(startPuzzleRun).not.toHaveBeenCalled();

View File

@@ -310,16 +310,6 @@ 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',
@@ -534,6 +524,7 @@ function renderLoggedOutHomeView(
| 'onSelectPreviousRecommendEntry'
>
> = {},
activeTab: RpgEntryHomeViewProps['activeTab'] = 'home',
) {
return render(
<AuthUiContext.Provider
@@ -556,7 +547,7 @@ function renderLoggedOutHomeView(
}}
>
<RpgEntryHomeView
activeTab="home"
activeTab={activeTab}
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
@@ -602,19 +593,27 @@ function renderStatefulLoggedOutHomeView(
| 'latestEntries'
| 'onOpenGalleryDetail'
| 'onSearchPublicCode'
| 'recommendRuntimeContent'
| 'activeRecommendEntryKey'
| 'onSelectNextRecommendEntry'
| 'onSelectPreviousRecommendEntry'
>
> = {},
) {
const authSpies = {
openLoginModal: vi.fn(),
};
function StatefulLoggedOutHomeView() {
const [activeTab, setActiveTab] =
useState<RpgEntryHomeViewProps['activeTab']>('home');
useState<RpgEntryHomeViewProps['activeTab']>('category');
return (
<AuthUiContext.Provider
value={{
user: null,
canAccessProtectedData: false,
openLoginModal: vi.fn(),
openLoginModal: authSpies.openLoginModal,
requireAuth: vi.fn(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
@@ -651,7 +650,14 @@ function renderStatefulLoggedOutHomeView(
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
recommendRuntimeContent={<div data-testid="recommend-runtime" />}
recommendRuntimeContent={
overrides.recommendRuntimeContent ?? (
<div data-testid="recommend-runtime" />
)
}
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
onSelectPreviousRecommendEntry={overrides.onSelectPreviousRecommendEntry}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
/>
@@ -659,7 +665,10 @@ function renderStatefulLoggedOutHomeView(
);
}
return render(<StatefulLoggedOutHomeView />);
return {
...render(<StatefulLoggedOutHomeView />),
openLoginModal: authSpies.openLoginModal,
};
}
afterEach(() => {
@@ -1111,35 +1120,80 @@ test('public gallery cards hide work code until detail is opened', async () => {
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
});
test('mobile recommend page renders runtime viewport without bottom work cards', () => {
test('logged out mobile shell defaults to discover tab', () => {
const { container } = renderStatefulLoggedOutHomeView({
latestEntries: [puzzlePublicEntry],
});
const activePanel = container.querySelector('.platform-tab-panel--active');
expect(activePanel?.id).toBe('platform-tab-panel-category');
expect(screen.getByPlaceholderText('搜索作品号、名称、作者、描述')).toBeTruthy();
});
test('logged out recommend tab opens login modal and shows cover only', async () => {
const user = userEvent.setup();
const { container, openLoginModal } = renderStatefulLoggedOutHomeView({
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
});
const bottomNav = container.querySelector('.platform-bottom-nav');
if (!bottomNav) {
throw new Error('缺少底部导航');
}
await user.click(
within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
);
expect(openLoginModal).toHaveBeenCalledTimes(1);
expect(container.querySelector('.platform-recommend-cover-only')).toBeTruthy();
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
expect(screen.queryByLabelText('奇幻拼图 作品信息')).toBeNull();
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
});
test('logged out recommend cover opens login modal again', async () => {
const user = userEvent.setup();
const onOpenGalleryDetail = vi.fn();
renderLoggedOutHomeView(vi.fn(), {
const { openLoginModal } = renderStatefulLoggedOutHomeView({
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
onOpenGalleryDetail,
});
const bottomNav = document.querySelector('.platform-bottom-nav');
if (!bottomNav) {
throw new Error('缺少底部导航');
}
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();
await user.click(
within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
);
await user.click(screen.getByRole('button', { name: / /u }));
expect(openLoginModal).toHaveBeenCalledTimes(2);
expect(openLoginModal).toHaveBeenLastCalledWith(expect.any(Function));
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
test('logged out mobile recommend page renders cover instead of runtime', () => {
const onOpenGalleryDetail = vi.fn();
renderLoggedOutHomeView(
vi.fn(),
{
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
onOpenGalleryDetail,
},
'home',
);
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
expect(
document.querySelector('.platform-public-work-card__cover'),
).toBeNull();
expect(screen.getByText('拼图玩家')).toBeTruthy();
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
expect(screen.getAllByText('20').length).toBeGreaterThan(0);
expect(screen.getAllByText('12').length).toBeGreaterThan(0);
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('奇幻拼图 作品信息'));
fireEvent.click(screen.getByRole('button', { name: / /u }));
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
@@ -1151,38 +1205,15 @@ test('mobile recommend loading state is themed instead of hardcoded black', () =
recommendRuntimeContent: null,
});
const loadingState = screen.getByText('加载中...');
expect(loadingState.className).toContain('platform-recommend-runtime-state');
expect(loadingState.className).not.toContain('bg-black');
expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
});
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 () => {
test('logged out active recommend bottom tab selects next work without login', async () => {
const user = userEvent.setup();
const onSelectNextRecommendEntry = vi.fn();
const openLoginModal = vi.fn();
renderLoggedOutHomeView(vi.fn(), {
renderLoggedOutHomeView(openLoginModal, {
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
onSelectNextRecommendEntry,
@@ -1191,6 +1222,7 @@ test('active recommend bottom tab selects next work instead of navigating', asyn
await user.click(screen.getByRole('button', { name: '下一个' }));
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
expect(openLoginModal).not.toHaveBeenCalled();
});
test('mobile recommend meta loads real author avatar from public user summary', async () => {
@@ -1210,7 +1242,7 @@ test('mobile recommend meta loads real author avatar from public user summary',
await waitFor(() => {
expect(
document
.querySelector('.platform-recommend-work-meta__avatar img')
.querySelector('.platform-recommend-cover-only__author img')
?.getAttribute('src'),
).toBe('data:image/png;base64,AUTHOR');
});

View File

@@ -561,6 +561,66 @@ function WorldCard({
);
}
function RecommendCoverOnlyCard({
entry,
authorAvatarUrl,
onClick,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
onClick: () => void;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const typeLabel = describePublicGalleryCardKind(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
return (
<button
type="button"
onClick={onClick}
aria-label={`登录后游玩 ${entry.worldName}`}
className="platform-recommend-cover-only"
>
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.04),rgba(0,0,0,0.42))]" />
<div className="platform-recommend-cover-only__body">
<span className="platform-public-work-card__kind">{typeLabel}</span>
<span className="platform-recommend-cover-only__title">
{displayName}
</span>
<span className="platform-recommend-cover-only__author">
<span
aria-hidden="true"
className="platform-public-work-card__author-avatar"
>
{normalizedAuthorAvatarUrl ? (
<img
src={normalizedAuthorAvatarUrl}
alt=""
className="platform-public-work-card__author-avatar-image"
/>
) : (
authorAvatarLabel
)}
</span>
<span className="truncate">{authorName}</span>
</span>
</div>
</button>
);
}
function CreationLibraryCard({
entry,
onClick,
@@ -3049,9 +3109,9 @@ export function RpgEntryHomeView({
useEffect(() => {
if (!visibleTabs.includes(activeTab)) {
onTabChange('home');
onTabChange(isAuthenticated ? 'home' : 'category');
}
}, [activeTab, onTabChange, visibleTabs]);
}, [activeTab, isAuthenticated, onTabChange, visibleTabs]);
useEffect(() => {
setVisitedTabs((currentTabs) => {
@@ -3705,6 +3765,18 @@ export function RpgEntryHomeView({
) ??
recommendedFeedEntries[0] ??
null;
const openActiveRecommendEntry = useCallback(() => {
if (!activeRecommendEntry) {
return;
}
if (!isAuthenticated) {
authUi?.openLoginModal(() => onOpenGalleryDetail(activeRecommendEntry));
return;
}
onOpenGalleryDetail(activeRecommendEntry);
}, [activeRecommendEntry, authUi, isAuthenticated, onOpenGalleryDetail]);
const selectNextRecommendEntry = useCallback(() => {
onSelectNextRecommendEntry?.();
}, [onSelectNextRecommendEntry]);
@@ -3787,6 +3859,12 @@ export function RpgEntryHomeView({
<div className="platform-recommend-runtime-state">
...
</div>
) : !isAuthenticated && activeRecommendEntry ? (
<RecommendCoverOnlyCard
entry={activeRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
onClick={openActiveRecommendEntry}
/>
) : recommendRuntimeError ? (
<button
type="button"
@@ -3808,7 +3886,7 @@ export function RpgEntryHomeView({
)}
</section>
{activeRecommendEntry ? (
{activeRecommendEntry && isAuthenticated ? (
<RecommendRuntimeMeta
entry={activeRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
@@ -4685,6 +4763,12 @@ export function RpgEntryHomeView({
return;
}
if (!isAuthenticated && tab === 'home') {
onTabChange(tab);
authUi?.openLoginModal();
return;
}
onTabChange(tab);
}}
/>
@@ -4818,7 +4902,15 @@ export function RpgEntryHomeView({
label={tabLabels[tab]}
icon={tabIcons[tab]}
emphasized={tab === 'create'}
onClick={() => onTabChange(tab)}
onClick={() => {
if (!isAuthenticated && tab === 'home') {
onTabChange(tab);
authUi?.openLoginModal();
return;
}
onTabChange(tab);
}}
/>
))}
</aside>

View File

@@ -66,7 +66,8 @@ export function useRpgEntryBootstrap(
PlatformBrowseHistoryEntry[]
>([]);
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>([]);
const [platformTab, setPlatformTabState] = useState<PlatformHomeTab>('home');
const [platformTab, setPlatformTabState] =
useState<PlatformHomeTab>('category');
const [platformError, setPlatformError] = useState<string | null>(null);
const [dashboardError, setDashboardError] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
@@ -329,8 +330,8 @@ export function useRpgEntryBootstrap(
!hasInitialAgentSession &&
!hasExplicitPlatformTabSelectionRef.current
) {
// 中文注释:saves 现在承载草稿列表,存档入口已并入“我的-玩过”,默认仍回到推荐页
setPlatformTabState('home');
// 中文注释:新用户先进入发现页;推荐页只在用户主动点击后作为登录门禁入口
setPlatformTabState(isAuthenticated ? 'home' : 'category');
}
} finally {
if (isActive) {