合并 master 并修复架构分支回归
合入 master 最新的认证、玩法契约与推荐页改动。 修复拼图草稿生成、推荐页下一关和公开详情访客试玩回归。 修复抓大鹅草稿试玩鉴权与首屏推荐详情测试入口。 补齐相关测试夹具、文档与团队记忆更新。
This commit is contained in:
@@ -3,11 +3,11 @@
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
|
||||
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { AuthGate, setAuthGateReloadForTest } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
@@ -107,6 +107,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState(null, '', '/');
|
||||
setAuthGateReloadForTest(vi.fn());
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
|
||||
authMocks.getStoredAccessToken.mockReturnValue('');
|
||||
@@ -158,6 +159,10 @@ beforeEach(() => {
|
||||
authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setAuthGateReloadForTest(null);
|
||||
});
|
||||
|
||||
async function acceptLegalConsent(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
dialog: HTMLElement,
|
||||
@@ -382,6 +387,8 @@ test('auth gate keeps sms and password entries available when login options requ
|
||||
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAuthenticated = vi.fn();
|
||||
const reload = vi.fn();
|
||||
setAuthGateReloadForTest(reload);
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
@@ -411,6 +418,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
);
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||
expect(onAuthenticated).toHaveBeenCalledTimes(1);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
@@ -636,6 +644,8 @@ test('registration invite modal can skip when invite code is empty', async () =>
|
||||
|
||||
test('auth state refresh keeps mounted platform content and local tab state', async () => {
|
||||
const user = userEvent.setup();
|
||||
const reload = vi.fn();
|
||||
setAuthGateReloadForTest(reload);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
@@ -674,10 +684,13 @@ test('auth state refresh keeps mounted platform content and local tab state', as
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('logout withdraws user context before backend request finishes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const reload = vi.fn();
|
||||
setAuthGateReloadForTest(reload);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
@@ -703,11 +716,14 @@ test('logout withdraws user context before backend request finishes', async () =
|
||||
expect(await screen.findByText('当前用户:未登录')).toBeTruthy();
|
||||
expect(screen.getByText('私有数据:不可读取')).toBeTruthy();
|
||||
expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveLogout();
|
||||
await logoutPromise;
|
||||
});
|
||||
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('auth gate shows sms send feedback in the login modal', async () => {
|
||||
|
||||
@@ -65,6 +65,18 @@ type AuthStatus =
|
||||
|
||||
const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password'];
|
||||
|
||||
let reloadCurrentPageForAuthStateChange = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
export function setAuthGateReloadForTest(handler: (() => void) | null) {
|
||||
reloadCurrentPageForAuthStateChange =
|
||||
handler ??
|
||||
(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function readInviteCodeFromLocation(): string {
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
return (params.get('inviteCode') || params.get('invite_code') || '')
|
||||
@@ -140,6 +152,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const autoOpenedInviteCodeRef = useRef<string | null>(null);
|
||||
const hasRenderedPlatformContentRef = useRef(false);
|
||||
const authHydrateVersionRef = useRef(0);
|
||||
const lastStableAuthPresenceRef = useRef<boolean | null>(null);
|
||||
const pendingAuthStateReloadRef = useRef(false);
|
||||
const canKeepPlatformContentMounted =
|
||||
hasRenderedPlatformContentRef.current &&
|
||||
(status === 'checking' || status === 'recovering');
|
||||
@@ -152,36 +166,64 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
hasRenderedPlatformContentRef.current = true;
|
||||
}
|
||||
|
||||
const markAuthStateReloadIfChanged = useCallback(
|
||||
(
|
||||
nextUser: AuthUser | null,
|
||||
options: { reloadOnChange?: boolean } = {},
|
||||
) => {
|
||||
const nextHasUser = Boolean(nextUser);
|
||||
const previousHasUser = lastStableAuthPresenceRef.current;
|
||||
if (previousHasUser === null) {
|
||||
lastStableAuthPresenceRef.current = nextHasUser;
|
||||
return;
|
||||
}
|
||||
|
||||
lastStableAuthPresenceRef.current = nextHasUser;
|
||||
if (
|
||||
previousHasUser !== nextHasUser &&
|
||||
options.reloadOnChange !== false
|
||||
) {
|
||||
pendingAuthStateReloadRef.current = true;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const activateReadyUser = useCallback((nextUser: AuthUser) => {
|
||||
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
|
||||
authHydrateVersionRef.current += 1;
|
||||
markAuthStateReloadIfChanged(nextUser);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
}, []);
|
||||
}, [markAuthStateReloadIfChanged]);
|
||||
|
||||
const clearLocalAuthenticatedState = useCallback(() => {
|
||||
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
|
||||
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
|
||||
authHydrateVersionRef.current += 1;
|
||||
pendingProtectedActionRef.current = null;
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
setShowLoginModal(false);
|
||||
setShowRegistrationInviteModal(false);
|
||||
setShowSettingsModal(false);
|
||||
setSettingsEntryMode('settings');
|
||||
setInitialSettingsSection(null);
|
||||
setSessions([]);
|
||||
setRevokingSessionIds([]);
|
||||
setAuditLogs([]);
|
||||
setRiskBlocks([]);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setBindCaptchaChallenge(null);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setPendingInviteCode('');
|
||||
setRegistrationInviteError('');
|
||||
setError('');
|
||||
}, []);
|
||||
const clearLocalAuthenticatedState = useCallback(
|
||||
(options: { reloadOnChange?: boolean } = {}) => {
|
||||
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
|
||||
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
|
||||
authHydrateVersionRef.current += 1;
|
||||
markAuthStateReloadIfChanged(null, options);
|
||||
pendingProtectedActionRef.current = null;
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
setShowLoginModal(false);
|
||||
setShowRegistrationInviteModal(false);
|
||||
setShowSettingsModal(false);
|
||||
setSettingsEntryMode('settings');
|
||||
setInitialSettingsSection(null);
|
||||
setSessions([]);
|
||||
setRevokingSessionIds([]);
|
||||
setAuditLogs([]);
|
||||
setRiskBlocks([]);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setBindCaptchaChallenge(null);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setPendingInviteCode('');
|
||||
setRegistrationInviteError('');
|
||||
setError('');
|
||||
},
|
||||
[markAuthStateReloadIfChanged],
|
||||
);
|
||||
|
||||
const restoreAuthSession = useCallback(async () => {
|
||||
const hadLocalAccessToken = Boolean(getStoredAccessToken());
|
||||
@@ -234,7 +276,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}, []);
|
||||
|
||||
const logoutCurrentSession = useCallback(async () => {
|
||||
clearLocalAuthenticatedState();
|
||||
clearLocalAuthenticatedState({ reloadOnChange: false });
|
||||
try {
|
||||
await logoutAuthUser();
|
||||
} catch (logoutError) {
|
||||
@@ -243,11 +285,13 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
? logoutError.message
|
||||
: '退出登录失败,请刷新页面确认状态。',
|
||||
);
|
||||
} finally {
|
||||
reloadCurrentPageForAuthStateChange();
|
||||
}
|
||||
}, [clearLocalAuthenticatedState]);
|
||||
|
||||
const logoutAllSessions = useCallback(async () => {
|
||||
clearLocalAuthenticatedState();
|
||||
clearLocalAuthenticatedState({ reloadOnChange: false });
|
||||
try {
|
||||
await logoutAllAuthSessions();
|
||||
} catch (logoutError) {
|
||||
@@ -256,6 +300,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
? logoutError.message
|
||||
: '退出全部设备失败,请刷新页面确认状态。',
|
||||
);
|
||||
} finally {
|
||||
reloadCurrentPageForAuthStateChange();
|
||||
}
|
||||
}, [clearLocalAuthenticatedState]);
|
||||
|
||||
@@ -386,6 +432,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
markAuthStateReloadIfChanged(null);
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
} catch (optionsError) {
|
||||
@@ -394,6 +441,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
setAvailableLoginMethods(REQUIRED_LOGIN_METHODS);
|
||||
markAuthStateReloadIfChanged(null);
|
||||
setUser(null);
|
||||
// 中文注释:登录方式接口失败时按产品约定保留验证码和密码登录入口;
|
||||
// 这里不展示接口读取错误,避免用户误以为登录本身不可用。
|
||||
@@ -413,6 +461,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
return;
|
||||
}
|
||||
if (restoredSession.kind === 'guest') {
|
||||
markAuthStateReloadIfChanged(null);
|
||||
setAvailableLoginMethods(
|
||||
normalizeAvailableLoginMethods(
|
||||
restoredSession.session?.availableLoginMethods,
|
||||
@@ -423,6 +472,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
const nextSession = restoredSession.session;
|
||||
markAuthStateReloadIfChanged(nextSession.user);
|
||||
setUser(nextSession.user);
|
||||
setAvailableLoginMethods(
|
||||
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
|
||||
@@ -470,19 +520,23 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
window.removeEventListener('hashchange', handleAuthHashChange);
|
||||
};
|
||||
}, [restoreAuthSession]);
|
||||
}, [markAuthStateReloadIfChanged, restoreAuthSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!readyUser) {
|
||||
setShowSettingsModal(false);
|
||||
return;
|
||||
} else {
|
||||
setShowLoginModal(false);
|
||||
|
||||
const pendingAction = pendingProtectedActionRef.current;
|
||||
pendingProtectedActionRef.current = null;
|
||||
pendingAction?.();
|
||||
}
|
||||
|
||||
setShowLoginModal(false);
|
||||
|
||||
const pendingAction = pendingProtectedActionRef.current;
|
||||
pendingProtectedActionRef.current = null;
|
||||
pendingAction?.();
|
||||
if (pendingAuthStateReloadRef.current) {
|
||||
pendingAuthStateReloadRef.current = false;
|
||||
reloadCurrentPageForAuthStateChange();
|
||||
}
|
||||
}, [readyUser]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -335,6 +335,16 @@ export function buildCreationWorkShelfItems(params: {
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'puzzle-clear',
|
||||
buildItems: () =>
|
||||
puzzleClearItems.map((item) =>
|
||||
mapPuzzleClearWorkToShelfItem(item, canDeletePuzzleClear, {
|
||||
onOpen: onOpenPuzzleClearDetail,
|
||||
onDelete: onDeletePuzzleClear,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'puzzle',
|
||||
buildItems: () =>
|
||||
|
||||
@@ -28,14 +28,16 @@ test('跳一跳结果页展示排行榜列表', () => {
|
||||
items: [
|
||||
{
|
||||
rank: 1,
|
||||
playerId: 'player-1',
|
||||
playerId: 'user-secret-1',
|
||||
displayName: '陶泥儿玩家',
|
||||
successfulJumpCount: 12,
|
||||
durationMs: 40123,
|
||||
updatedAt: '2026-05-27T00:00:00Z',
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
playerId: 'player-2',
|
||||
playerId: 'user-secret-2',
|
||||
displayName: '森林玩家',
|
||||
successfulJumpCount: 10,
|
||||
durationMs: 38210,
|
||||
updatedAt: '2026-05-26T00:00:00Z',
|
||||
@@ -60,10 +62,12 @@ test('跳一跳结果页展示排行榜列表', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('排行榜')).toBeTruthy();
|
||||
expect(screen.getByText('player-1')).toBeTruthy();
|
||||
expect(screen.getByText('陶泥儿玩家')).toBeTruthy();
|
||||
expect(screen.queryByText('user-secret-1')).toBeNull();
|
||||
expect(screen.getByText('12 跳')).toBeTruthy();
|
||||
expect(screen.getByText('00:40')).toBeTruthy();
|
||||
expect(screen.getByText('player-2')).toBeTruthy();
|
||||
expect(screen.getByText('森林玩家')).toBeTruthy();
|
||||
expect(screen.queryByText('user-secret-2')).toBeNull();
|
||||
});
|
||||
|
||||
test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => {
|
||||
@@ -83,6 +87,24 @@ test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳结果页根容器允许移动端向下滚动到操作按钮', () => {
|
||||
const { container } = render(
|
||||
<JumpHopResultView
|
||||
profile={buildProfile()}
|
||||
onBack={() => {}}
|
||||
onEdit={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
onPublish={() => {}}
|
||||
onRegenerateTiles={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.className).toContain('overflow-y-auto');
|
||||
expect(root.className).toContain('overscroll-contain');
|
||||
expect(root.className).toContain('safe-area-inset-bottom');
|
||||
});
|
||||
|
||||
test('跳一跳草稿结果页不请求公开排行榜', () => {
|
||||
render(
|
||||
<JumpHopResultView
|
||||
|
||||
@@ -231,7 +231,9 @@ function JumpHopResultLeaderboard({
|
||||
<span className="text-[var(--platform-text-soft)]">
|
||||
{entry.rank}
|
||||
</span>
|
||||
<span className="truncate">{entry.playerId}</span>
|
||||
<span className="truncate">
|
||||
{entry.displayName?.trim() || '玩家'}
|
||||
</span>
|
||||
<span>{entry.successfulJumpCount} 跳</span>
|
||||
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
|
||||
</div>
|
||||
@@ -300,7 +302,7 @@ export function JumpHopResultView({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-y-auto overscroll-contain px-3 pb-[max(1.5rem,env(safe-area-inset-bottom))] pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -327,7 +327,8 @@ test('跳一跳运行态失败后在弹窗中展示排行榜', () => {
|
||||
items: [
|
||||
{
|
||||
rank: 1,
|
||||
playerId: 'player-1',
|
||||
playerId: 'user-secret-1',
|
||||
displayName: '陶泥儿玩家',
|
||||
successfulJumpCount: 8,
|
||||
durationMs: 8123,
|
||||
updatedAt: '2026-05-27T00:00:00Z',
|
||||
@@ -357,7 +358,8 @@ test('跳一跳运行态失败后在弹窗中展示排行榜', () => {
|
||||
expect(screen.getByRole('dialog', { name: '失败' })).toBeTruthy();
|
||||
const leaderboard = screen.getByTestId('jump-hop-runtime-leaderboard');
|
||||
expect(leaderboard).toBeTruthy();
|
||||
expect(within(leaderboard).getByText('player-1')).toBeTruthy();
|
||||
expect(within(leaderboard).getByText('陶泥儿玩家')).toBeTruthy();
|
||||
expect(within(leaderboard).queryByText('user-secret-1')).toBeNull();
|
||||
expect(within(leaderboard).getByText('8 跳')).toBeTruthy();
|
||||
expect(within(leaderboard).getByText('00:08')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -548,7 +548,9 @@ function JumpHopLeaderboardPanel({
|
||||
className="grid grid-cols-[1.5rem_minmax(0,1fr)_auto_auto] items-center gap-2 text-xs font-bold text-slate-700"
|
||||
>
|
||||
<span className="text-slate-400">{entry.rank}</span>
|
||||
<span className="truncate">{entry.playerId}</span>
|
||||
<span className="truncate">
|
||||
{entry.displayName?.trim() || '玩家'}
|
||||
</span>
|
||||
<span>{entry.successfulJumpCount} 跳</span>
|
||||
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
|
||||
</div>
|
||||
|
||||
@@ -115,6 +115,7 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'
|
||||
import {
|
||||
buildPublicWorkStagePath,
|
||||
pushAppHistoryPath,
|
||||
resolvePathForSelectionStage,
|
||||
} from '../../routing/appPageRoutes';
|
||||
import { resolveWorkNotFoundRecoveryAction } from '../../routing/runtimeNotFoundRecovery';
|
||||
import {
|
||||
@@ -371,7 +372,6 @@ import {
|
||||
isPersistedBarkBattleDraftGenerating,
|
||||
} from '../custom-world-home/creationWorkShelf';
|
||||
import {
|
||||
buildPlatformRecommendFeedEntries,
|
||||
selectAdjacentPlatformRecommendEntry,
|
||||
} from '../rpg-entry/rpgEntryPublicGalleryViewModel';
|
||||
import {
|
||||
@@ -493,7 +493,9 @@ import {
|
||||
import {
|
||||
canExposePublicWork,
|
||||
EDUTAINMENT_HIDDEN_MESSAGE,
|
||||
filterGeneralPublicWorks,
|
||||
} from './platformEdutainmentVisibility';
|
||||
import { buildPlatformRecommendedEntries } from './platformRecommendation';
|
||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import {
|
||||
@@ -2872,10 +2874,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
publicGalleryFeeds;
|
||||
const recommendRuntimeEntries = useMemo(
|
||||
() =>
|
||||
buildPlatformRecommendFeedEntries(
|
||||
featuredGalleryEntries,
|
||||
latestGalleryEntries,
|
||||
),
|
||||
buildPlatformRecommendedEntries({
|
||||
featuredEntries: filterGeneralPublicWorks(featuredGalleryEntries),
|
||||
latestEntries: filterGeneralPublicWorks(latestGalleryEntries),
|
||||
}),
|
||||
[featuredGalleryEntries, latestGalleryEntries],
|
||||
);
|
||||
|
||||
@@ -4440,6 +4442,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
activePuzzleBackgroundCompileTask?.session ?? puzzleSession;
|
||||
const puzzleGenerationViewPayload =
|
||||
activePuzzleBackgroundCompileTask?.payload ?? puzzleFormDraftPayload;
|
||||
const puzzleGenerationViewStateRef = useRef(puzzleGenerationViewState);
|
||||
const puzzleGenerationViewPayloadRef = useRef(puzzleGenerationViewPayload);
|
||||
const setPuzzleSessionRef = useRef(puzzleFlow.setSession);
|
||||
useEffect(() => {
|
||||
puzzleGenerationViewStateRef.current = puzzleGenerationViewState;
|
||||
}, [puzzleGenerationViewState]);
|
||||
useEffect(() => {
|
||||
puzzleGenerationViewPayloadRef.current = puzzleGenerationViewPayload;
|
||||
}, [puzzleGenerationViewPayload]);
|
||||
useEffect(() => {
|
||||
setPuzzleSessionRef.current = puzzleFlow.setSession;
|
||||
}, [puzzleFlow.setSession]);
|
||||
const puzzleGenerationViewError =
|
||||
activePuzzleBackgroundCompileTask?.error ?? puzzleError;
|
||||
const isPuzzleGenerationViewBusy =
|
||||
@@ -4835,21 +4849,21 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
if (hasRecoverableGeneratedPuzzleDraft(latestSession)) {
|
||||
const payload =
|
||||
puzzleGenerationViewPayload ??
|
||||
puzzleGenerationViewPayloadRef.current ??
|
||||
buildPuzzleFormPayloadFromSession(latestSession);
|
||||
const generationState =
|
||||
puzzleGenerationViewState ??
|
||||
puzzleGenerationViewStateRef.current ??
|
||||
createPuzzleDraftGenerationStateFromPayload(payload, latestSession);
|
||||
await recoverCompletedPuzzleDraftGeneration({
|
||||
sessionId: latestSession.sessionId,
|
||||
payload,
|
||||
generationState,
|
||||
setSession: setPuzzleSession,
|
||||
setSession: setPuzzleSessionRef.current,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPuzzleSession(latestSession);
|
||||
setPuzzleSessionRef.current(latestSession);
|
||||
setPuzzleBackgroundCompileTasks((current) => {
|
||||
const task = current[activePuzzleGenerationSessionId];
|
||||
if (!task) {
|
||||
@@ -4892,11 +4906,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
};
|
||||
}, [
|
||||
activePuzzleGenerationSessionId,
|
||||
puzzleGenerationViewPayload,
|
||||
puzzleGenerationViewState,
|
||||
recoverCompletedPuzzleDraftGeneration,
|
||||
shouldPollPuzzleGenerationSession,
|
||||
setPuzzleSession,
|
||||
]);
|
||||
|
||||
const match3DGeneratingSessionId =
|
||||
@@ -5258,27 +5269,48 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
setPuzzleOperation(response.operation);
|
||||
const openResult = isViewingPuzzleGeneration(nextSession.sessionId);
|
||||
const readyGenerationState =
|
||||
resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
'ready',
|
||||
{
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 1,
|
||||
},
|
||||
);
|
||||
const isCompileReady = isPuzzleCompileActionReady(response.session);
|
||||
const nextGenerationState = isCompileReady
|
||||
? resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
'ready',
|
||||
{
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 1,
|
||||
},
|
||||
)
|
||||
: mergePuzzleSessionProgressIntoGenerationState(
|
||||
generationState,
|
||||
response.session,
|
||||
);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[nextSession.sessionId]: {
|
||||
session: response.session,
|
||||
payload,
|
||||
generationState: readyGenerationState,
|
||||
generationState: nextGenerationState,
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
if (isViewingPuzzleGeneration(nextSession.sessionId)) {
|
||||
puzzleFlow.setSession(response.session);
|
||||
setPuzzleGenerationState(readyGenerationState);
|
||||
setPuzzleGenerationState(nextGenerationState);
|
||||
}
|
||||
|
||||
if (!isCompileReady) {
|
||||
markDraftGenerating('puzzle', [
|
||||
response.session.sessionId,
|
||||
buildPuzzleResultWorkId(response.session.sessionId),
|
||||
response.session.publishedProfileId,
|
||||
buildPuzzleResultProfileId(response.session.sessionId),
|
||||
]);
|
||||
markPendingDraftGenerating(
|
||||
'puzzle',
|
||||
response.session.sessionId,
|
||||
buildPendingPuzzleDraftMetadata(payload),
|
||||
);
|
||||
void refreshPuzzleShelf();
|
||||
return;
|
||||
}
|
||||
|
||||
const profileId =
|
||||
@@ -6870,10 +6902,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
profileId: targetProfileId,
|
||||
mode: 'play' as const,
|
||||
};
|
||||
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
|
||||
authUi,
|
||||
options.embedded,
|
||||
);
|
||||
const runtimeGuestOptions =
|
||||
options.embedded || workDetail.summary.publishStatus === 'draft'
|
||||
? await buildRecommendRuntimeAuthOptions(authUi, true)
|
||||
: {};
|
||||
const { run } = options.embedded
|
||||
? await startVisualNovelRun(
|
||||
targetProfileId,
|
||||
@@ -8818,7 +8850,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
profile: Match3DWorkProfile | Match3DWorkSummary,
|
||||
returnStage: 'match3d-result' | 'work-detail' = 'match3d-result',
|
||||
mirrorErrorToPublicDetail = false,
|
||||
options: { embedded?: boolean; itemTypeCountOverride?: number } = {},
|
||||
options: {
|
||||
embedded?: boolean;
|
||||
authMode?: PuzzleRuntimeAuthMode;
|
||||
itemTypeCountOverride?: number;
|
||||
} = {},
|
||||
) => {
|
||||
if (isMatch3DBusy) {
|
||||
return false;
|
||||
@@ -8857,10 +8893,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
runtimeProfile.generatedBackgroundAsset,
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
|
||||
authUi,
|
||||
options.embedded,
|
||||
);
|
||||
const runtimeGuestOptions =
|
||||
options.authMode === 'isolated'
|
||||
? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS
|
||||
: await buildRecommendRuntimeAuthOptions(authUi, options.embedded);
|
||||
const runtimeOptions = {
|
||||
...runtimeGuestOptions,
|
||||
...(typeof options.itemTypeCountOverride === 'number'
|
||||
@@ -9657,10 +9693,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
? await buildRecommendRuntimeGuestOptions()
|
||||
: {};
|
||||
const targetProfileId = _target?.profileId?.trim() ?? '';
|
||||
const preferSimilarWork =
|
||||
activeRecommendRuntimeKind === 'puzzle' &&
|
||||
puzzleRuntimeReturnStage === 'platform' &&
|
||||
puzzleRun.nextLevelMode === 'sameWork';
|
||||
if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) {
|
||||
const itemPromise =
|
||||
selectedPuzzleDetail?.profileId === targetProfileId
|
||||
@@ -9700,34 +9732,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
puzzleRuntimeAuthMode === 'isolated'
|
||||
? await advancePuzzleNextLevel(
|
||||
puzzleRun.runId,
|
||||
preferSimilarWork ? { preferSimilarWork: true } : {},
|
||||
{},
|
||||
runtimeGuestOptions,
|
||||
)
|
||||
: await advancePuzzleNextLevel(
|
||||
puzzleRun.runId,
|
||||
preferSimilarWork ? { preferSimilarWork: true } : {},
|
||||
);
|
||||
const nextProfileId = run.currentLevel?.profileId?.trim() ?? '';
|
||||
if (
|
||||
nextProfileId &&
|
||||
selectedPuzzleDetail?.profileId !== nextProfileId
|
||||
) {
|
||||
const item = await getPuzzleGalleryDetail(nextProfileId).then(
|
||||
(response) => response.item,
|
||||
);
|
||||
const nextRecommendEntry = mapPuzzleWorkToPlatformGalleryCard(item);
|
||||
setPuzzleGalleryEntries((current) => {
|
||||
const nextEntries = current.filter(
|
||||
(entry) => entry.profileId !== item.profileId,
|
||||
);
|
||||
nextEntries.push(item);
|
||||
return nextEntries;
|
||||
});
|
||||
setSelectedPuzzleDetail(item);
|
||||
setActiveRecommendEntryKey(
|
||||
getPlatformPublicGalleryEntryKey(nextRecommendEntry),
|
||||
);
|
||||
}
|
||||
: await advancePuzzleNextLevel(puzzleRun.runId, {});
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
|
||||
@@ -9739,12 +9747,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
[
|
||||
isPuzzleBusy,
|
||||
isPuzzleLeaderboardBusy,
|
||||
activeRecommendRuntimeKind,
|
||||
puzzleRun,
|
||||
puzzleRuntimeReturnStage,
|
||||
puzzleRuntimeAuthMode,
|
||||
setActiveRecommendEntryKey,
|
||||
setPuzzleGalleryEntries,
|
||||
resolvePuzzleErrorMessage,
|
||||
selectedPuzzleDetail,
|
||||
setIsPuzzleBusy,
|
||||
@@ -10956,12 +10960,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await jumpHopClient.getWorkDetail(item.profileId);
|
||||
const detail = await jumpHopClient.getWorkDetail(item.profileId, {
|
||||
audience: 'creation',
|
||||
});
|
||||
setJumpHopSession(null);
|
||||
setJumpHopRun(null);
|
||||
setJumpHopWork(detail.item);
|
||||
setJumpHopRuntimeReturnStage('jump-hop-result');
|
||||
enterCreateTab();
|
||||
pushAppHistoryPath(resolvePathForSelectionStage('jump-hop-result'));
|
||||
writeCreationUrlState(
|
||||
buildJumpHopCreationUrlState({ work: detail.item }),
|
||||
);
|
||||
setSelectionStage('jump-hop-result');
|
||||
} catch (error) {
|
||||
setJumpHopError(
|
||||
@@ -11572,6 +11582,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
const started = await startMatch3DRunFromProfile(
|
||||
normalizedProfile,
|
||||
'match3d-result',
|
||||
false,
|
||||
{ authMode: 'isolated' },
|
||||
);
|
||||
if (!started) {
|
||||
setMatch3DProfile(normalizedProfile);
|
||||
@@ -12147,7 +12159,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
let work: JumpHopWorkProfileResponse | null = null;
|
||||
try {
|
||||
if (profileId) {
|
||||
work = (await jumpHopClient.getWorkDetail(profileId)).item;
|
||||
work = (
|
||||
await jumpHopClient.getWorkDetail(profileId, {
|
||||
audience: 'creation',
|
||||
})
|
||||
).item;
|
||||
}
|
||||
} catch {
|
||||
work = null;
|
||||
@@ -12525,6 +12541,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
setActiveRecommendEntryKey(entryKey);
|
||||
setActiveRecommendRuntimeKind(runtimeKind);
|
||||
setActiveRecommendRuntimeError(null);
|
||||
if (
|
||||
runtimeKind === 'puzzle' &&
|
||||
(isPuzzleBusy || puzzleStartInFlightKeyRef.current !== null)
|
||||
) {
|
||||
setIsStartingRecommendEntry(false);
|
||||
return;
|
||||
}
|
||||
setIsStartingRecommendEntry(true);
|
||||
|
||||
try {
|
||||
@@ -12659,6 +12682,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
[
|
||||
activeRecommendEntryKey,
|
||||
barkBattleGalleryEntries,
|
||||
isPuzzleBusy,
|
||||
saveAndExitRecommendPuzzleRuntime,
|
||||
selectedPuzzleDetail,
|
||||
setBarkBattleError,
|
||||
@@ -12700,6 +12724,23 @@ export function PlatformEntryFlowShellImpl({
|
||||
selectRecommendRuntimeEntry,
|
||||
],
|
||||
);
|
||||
const resolveRecommendRuntimeEntryKeyByProfileId = useCallback(
|
||||
(profileId: string | null | undefined) => {
|
||||
const normalizedProfileId = profileId?.trim();
|
||||
if (!normalizedProfileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchedEntry =
|
||||
recommendRuntimeEntries.find(
|
||||
(entry) => entry.profileId === normalizedProfileId,
|
||||
) ?? null;
|
||||
return matchedEntry
|
||||
? getPlatformPublicGalleryEntryKey(matchedEntry)
|
||||
: null;
|
||||
},
|
||||
[recommendRuntimeEntries],
|
||||
);
|
||||
|
||||
const recommendRuntimeContent = useMemo(() => {
|
||||
if (
|
||||
@@ -12861,7 +12902,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
void dragPuzzlePiece(payload);
|
||||
}}
|
||||
onAdvanceNextLevel={(target) => {
|
||||
void advancePuzzleLevel(target);
|
||||
const targetEntryKey = resolveRecommendRuntimeEntryKeyByProfileId(
|
||||
target?.profileId,
|
||||
);
|
||||
void selectAdjacentRecommendRuntimeEntry(
|
||||
1,
|
||||
targetEntryKey ?? activeRecommendEntryKey,
|
||||
);
|
||||
}}
|
||||
onRestartLevel={() => {
|
||||
void restartPuzzleCurrentLevel();
|
||||
@@ -13115,9 +13162,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
squareHoleRun,
|
||||
submitBigFishInput,
|
||||
submitVisualNovelRuntimeAction,
|
||||
advancePuzzleLevel,
|
||||
dragPuzzlePiece,
|
||||
resolveRecommendRuntimeEntryKeyByProfileId,
|
||||
restartPuzzleCurrentLevel,
|
||||
selectAdjacentRecommendRuntimeEntry,
|
||||
setSquareHoleError,
|
||||
swapPuzzlePiecesInRun,
|
||||
syncPuzzleRuntimeTimeout,
|
||||
@@ -13166,7 +13214,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
isLoadingPlatform: platformBootstrap.isLoadingPlatform,
|
||||
entries: recommendRuntimeEntries,
|
||||
activeEntryKey: activeRecommendEntryKey,
|
||||
isStarting: isStartingRecommendEntry,
|
||||
isStarting: isStartingRecommendEntry || isPuzzleBusy,
|
||||
hasStartError: Boolean(activeRecommendRuntimeError),
|
||||
readyState: {
|
||||
activeKind: activeRecommendRuntimeKind,
|
||||
hasBabyObjectMatchDraft: Boolean(babyObjectMatchDraft),
|
||||
@@ -13198,10 +13247,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [
|
||||
activeRecommendEntryKey,
|
||||
activeRecommendRuntimeKind,
|
||||
activeRecommendRuntimeError,
|
||||
babyObjectMatchDraft,
|
||||
bigFishRun,
|
||||
jumpHopRun,
|
||||
isStartingRecommendEntry,
|
||||
isPuzzleBusy,
|
||||
match3dRun,
|
||||
platformBootstrap.isLoadingPlatform,
|
||||
platformBootstrap.platformTab,
|
||||
@@ -13952,8 +14003,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
canDeleteBigFish: isBigFishCreationVisible,
|
||||
canDeleteMatch3D: true,
|
||||
canDeleteSquareHole: isSquareHoleCreationVisible,
|
||||
canDeleteJumpHop: isJumpHopCreationVisible,
|
||||
canDeleteWoodenFish: true,
|
||||
canDeletePuzzle: true,
|
||||
canDeleteBabyObjectMatch: isBabyObjectMatchVisible,
|
||||
canDeleteBarkBattle: true,
|
||||
canDeleteVisualNovel: true,
|
||||
onOpenRpgDraft: (item) => {
|
||||
runProtectedAction(() => {
|
||||
@@ -13993,12 +14047,16 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onDeleteJumpHop: isJumpHopCreationVisible
|
||||
? handleDeleteJumpHopWork
|
||||
: undefined,
|
||||
onOpenWoodenFishDetail: (item) => {
|
||||
runProtectedAction(() => {
|
||||
markCreationFlowReturnToDraftShelf();
|
||||
void openWoodenFishDraft(item);
|
||||
});
|
||||
},
|
||||
onDeleteWoodenFish: handleDeleteWoodenFishWork,
|
||||
onOpenMatch3DDetail: (item) => {
|
||||
runProtectedAction(() => {
|
||||
markCreationFlowReturnToDraftShelf();
|
||||
@@ -14038,6 +14096,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
openBarkBattleDraft(item);
|
||||
});
|
||||
},
|
||||
onDeleteBarkBattle: handleDeleteBarkBattleWork,
|
||||
onOpenVisualNovelDetail: (item) => {
|
||||
runProtectedAction(() => {
|
||||
markCreationFlowReturnToDraftShelf();
|
||||
@@ -14057,11 +14116,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
handleClaimPuzzlePointIncentive,
|
||||
handleDeleteBabyObjectMatchWork,
|
||||
handleDeleteBigFishWork,
|
||||
handleDeleteBarkBattleWork,
|
||||
handleDeleteJumpHopWork,
|
||||
handleDeleteMatch3DWork,
|
||||
handleDeletePublishedWork,
|
||||
handleDeletePuzzleWork,
|
||||
handleDeleteSquareHoleWork,
|
||||
handleDeleteVisualNovelWork,
|
||||
handleDeleteWoodenFishWork,
|
||||
isBabyObjectMatchVisible,
|
||||
isBigFishCreationVisible,
|
||||
isJumpHopCreationVisible,
|
||||
@@ -14884,7 +14946,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
normalizedProfile,
|
||||
'match3d-result',
|
||||
false,
|
||||
options,
|
||||
{
|
||||
...options,
|
||||
authMode:
|
||||
normalizedProfile.publicationStatus === 'draft'
|
||||
? 'isolated'
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,11 @@ import {
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
isBarkBattleGalleryEntry,
|
||||
isCustomWorldGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
isPuzzleClearGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformPublicWorkCode,
|
||||
@@ -57,9 +61,18 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
||||
if ('sourceType' in entry && entry.sourceType === 'puzzle') {
|
||||
return '拼图';
|
||||
}
|
||||
if (isPuzzleClearGalleryEntry(entry)) {
|
||||
return '拼消消';
|
||||
}
|
||||
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
|
||||
return '大鱼吃小鱼';
|
||||
}
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return '跳一跳';
|
||||
}
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return '敲木鱼';
|
||||
}
|
||||
if ('sourceType' in entry && entry.sourceType === 'match3d') {
|
||||
return '抓大鹅';
|
||||
}
|
||||
@@ -75,7 +88,11 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.templateName;
|
||||
}
|
||||
return 'RPG';
|
||||
if (isCustomWorldGalleryEntry(entry)) {
|
||||
return 'RPG';
|
||||
}
|
||||
|
||||
throw new Error('未知公开作品类型。');
|
||||
}
|
||||
|
||||
function getAuthorAvatarLabel(authorDisplayName: string) {
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('platformCreationLaunchModel', () => {
|
||||
test('keeps unknown creation type as a prepared noop', () => {
|
||||
expect(
|
||||
resolvePlatformCreationLaunchIntent({
|
||||
type: 'unknown-template',
|
||||
type: 'unknown-template' as never,
|
||||
isBabyObjectMatchVisible: true,
|
||||
}),
|
||||
).toEqual({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
|
||||
import {
|
||||
derivePlatformCreationTypes,
|
||||
groupVisiblePlatformCreationTypes,
|
||||
@@ -81,7 +82,7 @@ test('database entry config controls visibility open state and display order', (
|
||||
test('visible platform creation types hide invisible cards and put locked cards last', () => {
|
||||
const cards = derivePlatformCreationTypes([
|
||||
{
|
||||
id: 'hidden',
|
||||
id: 'airp',
|
||||
title: '隐藏',
|
||||
subtitle: '隐藏',
|
||||
badge: '隐藏',
|
||||
@@ -95,7 +96,7 @@ test('visible platform creation types hide invisible cards and put locked cards
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'locked',
|
||||
id: 'visual-novel',
|
||||
title: '锁定',
|
||||
subtitle: '锁定',
|
||||
badge: '即将开放',
|
||||
@@ -109,7 +110,7 @@ test('visible platform creation types hide invisible cards and put locked cards
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'open',
|
||||
id: 'rpg',
|
||||
title: '开放',
|
||||
subtitle: '开放',
|
||||
badge: '可创建',
|
||||
@@ -125,13 +126,13 @@ test('visible platform creation types hide invisible cards and put locked cards
|
||||
]);
|
||||
|
||||
expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual(
|
||||
['open', 'locked'],
|
||||
['rpg', 'visual-novel'],
|
||||
);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'hidden')).toBe(false);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'locked')).toBe(false);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'open')).toBe(true);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'airp')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'rpg')).toBe(true);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'airp')).toBe(false);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'visual-novel')).toBe(false);
|
||||
expect(isPlatformCreationTypeOpen(cards, 'rpg')).toBe(true);
|
||||
expect(
|
||||
cards.every((item) =>
|
||||
item.imageSrc.startsWith('/creation-type-references/'),
|
||||
@@ -288,7 +289,7 @@ test('groups visible platform creation types by backend category metadata', () =
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'hidden',
|
||||
id: 'airp',
|
||||
title: '隐藏入口',
|
||||
subtitle: '隐藏',
|
||||
badge: '隐藏',
|
||||
@@ -319,7 +320,7 @@ test('groups visible platform creation types by backend category metadata', () =
|
||||
test('falls back when backend creation type category metadata is missing', () => {
|
||||
const cards = derivePlatformCreationTypes([
|
||||
{
|
||||
id: 'legacy-entry',
|
||||
id: 'creative-agent',
|
||||
title: '历史入口',
|
||||
subtitle: '旧数据缺少分类字段',
|
||||
badge: '可创建',
|
||||
@@ -336,7 +337,7 @@ test('falls back when backend creation type category metadata is missing', () =>
|
||||
|
||||
expect(cards[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'legacy-entry',
|
||||
id: 'creative-agent',
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
}),
|
||||
@@ -348,3 +349,24 @@ test('falls back when backend creation type category metadata is missing', () =>
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('throws when backend sends an unknown creation type id', () => {
|
||||
const unknownEntry = {
|
||||
id: 'unknown-play',
|
||||
title: '未知玩法',
|
||||
subtitle: '未知',
|
||||
badge: '未知',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
} as unknown as CreationEntryTypeConfig;
|
||||
|
||||
expect(() => derivePlatformCreationTypes([unknownEntry])).toThrow(
|
||||
'未知创作类型:unknown-play',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {
|
||||
assertPlatformCreationTypeId,
|
||||
type PlatformCreationTypeId,
|
||||
} from '../../../packages/shared/src/contracts/playTypes';
|
||||
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
|
||||
import { isEdutainmentEntryEnabled } from './platformEdutainmentVisibility';
|
||||
|
||||
export type PlatformCreationTypeId = string;
|
||||
export type { PlatformCreationTypeId };
|
||||
|
||||
export type PlatformCreationTypeCard = {
|
||||
id: PlatformCreationTypeId;
|
||||
@@ -117,21 +121,25 @@ export function derivePlatformCreationTypes(
|
||||
): PlatformCreationTypeCard[] {
|
||||
const orderedCards = [...creationTypes]
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
imageSrc: item.imageSrc,
|
||||
locked: !item.open,
|
||||
categoryId: normalizeCategoryId(item.categoryId),
|
||||
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
|
||||
categorySortOrder: item.categorySortOrder,
|
||||
sortOrder: item.sortOrder,
|
||||
hidden:
|
||||
!item.visible ||
|
||||
(item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
|
||||
}));
|
||||
.map((item) => {
|
||||
const id = assertPlatformCreationTypeId(item.id);
|
||||
|
||||
return {
|
||||
id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
imageSrc: item.imageSrc,
|
||||
locked: !item.open,
|
||||
categoryId: normalizeCategoryId(item.categoryId),
|
||||
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
|
||||
categorySortOrder: item.categorySortOrder,
|
||||
sortOrder: item.sortOrder,
|
||||
hidden:
|
||||
!item.visible ||
|
||||
(id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...orderedCards.filter((item) => !item.hidden && !item.locked),
|
||||
|
||||
@@ -659,6 +659,13 @@ test('platform public gallery flow resolves recommend runtime auto-start gates',
|
||||
isStarting: true,
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
activeEntryKey: getPlatformPublicGalleryEntryKey(entry),
|
||||
hasStartError: true,
|
||||
}),
|
||||
).toEqual({ type: 'noop' });
|
||||
});
|
||||
|
||||
test('platform public gallery flow resolves recommend runtime auto-start target', () => {
|
||||
@@ -695,6 +702,15 @@ test('platform public gallery flow resolves recommend runtime auto-start target'
|
||||
type: 'start',
|
||||
entry: activeEntry,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
readyState: { activeKind: null },
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'start',
|
||||
entry: activeEntry,
|
||||
});
|
||||
expect(
|
||||
resolvePlatformRecommendRuntimeAutoStartDecision({
|
||||
...baseInput,
|
||||
|
||||
@@ -165,6 +165,7 @@ export type PlatformRecommendRuntimeAutoStartInput = {
|
||||
entries: readonly PlatformPublicGalleryCard[];
|
||||
activeEntryKey: string | null;
|
||||
isStarting: boolean;
|
||||
hasStartError?: boolean;
|
||||
readyState: PlatformRecommendRuntimeReadyState;
|
||||
};
|
||||
|
||||
@@ -496,7 +497,11 @@ export function resolvePlatformRecommendRuntimeAutoStartDecision(
|
||||
activeEntry !== null &&
|
||||
isPlatformRecommendRuntimeReadyForEntry(activeEntry, input.readyState);
|
||||
|
||||
if ((activeEntry !== null && isActiveRuntimeReady) || input.isStarting) {
|
||||
if (
|
||||
(activeEntry !== null && isActiveRuntimeReady) ||
|
||||
input.isStarting ||
|
||||
(activeEntry !== null && input.hasStartError)
|
||||
) {
|
||||
return { type: 'noop' };
|
||||
}
|
||||
|
||||
|
||||
@@ -599,7 +599,27 @@ test('platform public work detail flow resolves open strategy', () => {
|
||||
test('platform public work detail flow maps work summaries to detail entries', () => {
|
||||
const rpgEntry = buildRpgLibraryEntry();
|
||||
|
||||
expect(mapRpgGalleryCardToPublicWorkDetail(rpgEntry)).toBe(rpgEntry);
|
||||
expect(mapRpgGalleryCardToPublicWorkDetail(rpgEntry)).toEqual({
|
||||
ownerUserId: rpgEntry.ownerUserId,
|
||||
profileId: rpgEntry.profileId,
|
||||
publicWorkCode: rpgEntry.publicWorkCode,
|
||||
authorPublicUserCode: rpgEntry.authorPublicUserCode,
|
||||
visibility: rpgEntry.visibility,
|
||||
publishedAt: rpgEntry.publishedAt,
|
||||
updatedAt: rpgEntry.updatedAt,
|
||||
authorDisplayName: rpgEntry.authorDisplayName,
|
||||
worldName: rpgEntry.worldName,
|
||||
subtitle: rpgEntry.subtitle,
|
||||
summaryText: rpgEntry.summaryText,
|
||||
coverImageSrc: rpgEntry.coverImageSrc,
|
||||
themeMode: rpgEntry.themeMode,
|
||||
playableNpcCount: rpgEntry.playableNpcCount,
|
||||
landmarkCount: rpgEntry.landmarkCount,
|
||||
playCount: rpgEntry.playCount ?? 0,
|
||||
remixCount: rpgEntry.remixCount ?? 0,
|
||||
likeCount: rpgEntry.likeCount ?? 0,
|
||||
recentPlayCount7d: rpgEntry.recentPlayCount7d ?? 0,
|
||||
});
|
||||
expect(mapPuzzleWorkToPublicWorkDetail(buildPuzzleWork())).toMatchObject({
|
||||
sourceType: 'puzzle',
|
||||
workId: 'puzzle-work',
|
||||
@@ -838,9 +858,20 @@ test('platform public work detail flow resolves like intent', () => {
|
||||
expect(
|
||||
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('match3d')),
|
||||
).toEqual({
|
||||
type: 'like-rpg-gallery',
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'match3d-profile',
|
||||
type: 'unsupported',
|
||||
errorMessage: '作品类型 match3d 暂不支持点赞。',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('jump-hop')),
|
||||
).toEqual({
|
||||
type: 'unsupported',
|
||||
errorMessage: '作品类型 jump-hop 暂不支持点赞。',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('wooden-fish')),
|
||||
).toEqual({
|
||||
type: 'unsupported',
|
||||
errorMessage: '作品类型 wooden-fish 暂不支持点赞。',
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('edutainment')),
|
||||
|
||||
@@ -297,8 +297,28 @@ export function isRpgPublicWorkDetailEntry(
|
||||
|
||||
export function mapRpgGalleryCardToPublicWorkDetail(
|
||||
entry: PlatformRpgPublicWorkDetailEntry,
|
||||
): PlatformPublicGalleryCard {
|
||||
return entry;
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
publicWorkCode: entry.publicWorkCode,
|
||||
authorPublicUserCode: entry.authorPublicUserCode,
|
||||
visibility: entry.visibility,
|
||||
publishedAt: entry.publishedAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: entry.themeMode,
|
||||
playableNpcCount: entry.playableNpcCount,
|
||||
landmarkCount: entry.landmarkCount,
|
||||
playCount: entry.playCount ?? 0,
|
||||
remixCount: entry.remixCount ?? 0,
|
||||
likeCount: entry.likeCount ?? 0,
|
||||
recentPlayCount7d: entry.recentPlayCount7d ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function isRpgPublicWorkLibraryEntry(
|
||||
@@ -689,6 +709,27 @@ export function resolvePlatformPublicWorkLikeIntent(
|
||||
};
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
errorMessage: '作品类型 wooden-fish 暂不支持点赞。',
|
||||
};
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
errorMessage: '作品类型 jump-hop 暂不支持点赞。',
|
||||
};
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
errorMessage: '作品类型 match3d 暂不支持点赞。',
|
||||
};
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
|
||||
178
src/components/platform-entry/platformRecommendation.test.ts
Normal file
178
src/components/platform-entry/platformRecommendation.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { buildPlatformRecommendedEntries } from './platformRecommendation';
|
||||
|
||||
const NOW_MS = Date.parse('2026-06-07T12:00:00.000Z');
|
||||
|
||||
type PublicCardTestParams = {
|
||||
id: string;
|
||||
sourceType?: 'puzzle' | 'match3d' | 'jump-hop';
|
||||
subtitle?: string;
|
||||
summaryText?: string;
|
||||
coverImageSrc?: string | null;
|
||||
themeTags?: string[];
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
publishedAt?: string | null;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
function buildPublicCard(
|
||||
params: PublicCardTestParams,
|
||||
): PlatformPublicGalleryCard {
|
||||
const sourceType = params.sourceType ?? 'puzzle';
|
||||
|
||||
return {
|
||||
sourceType,
|
||||
workId: `${sourceType}-work-${params.id}`,
|
||||
profileId: `${sourceType}-profile-${params.id}`,
|
||||
publicWorkCode: `${sourceType.toUpperCase()}-${params.id}`,
|
||||
ownerUserId: `user-${params.id}`,
|
||||
authorDisplayName: `${params.id} 作者`,
|
||||
worldName: `${params.id} 作品`,
|
||||
subtitle: params.subtitle ?? '公开作品',
|
||||
summaryText: params.summaryText ?? '公开作品摘要。',
|
||||
coverImageSrc: params.coverImageSrc ?? `${params.id}.png`,
|
||||
themeTags: params.themeTags ?? ['推荐'],
|
||||
playCount: params.playCount ?? 0,
|
||||
remixCount: params.remixCount ?? 0,
|
||||
likeCount: params.likeCount ?? 0,
|
||||
recentPlayCount7d: params.recentPlayCount7d ?? 0,
|
||||
visibility: 'published',
|
||||
publishedAt: params.publishedAt ?? '2026-06-01T12:00:00.000Z',
|
||||
updatedAt:
|
||||
params.updatedAt ?? params.publishedAt ?? '2026-06-01T12:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
}
|
||||
|
||||
describe('buildPlatformRecommendedEntries', () => {
|
||||
test('combines heat, freshness and featured boost after de-duplicating works', () => {
|
||||
const coldEntry = buildPublicCard({
|
||||
id: 'cold',
|
||||
playCount: 1,
|
||||
publishedAt: '2026-04-01T12:00:00.000Z',
|
||||
});
|
||||
const hotRecentEntry = buildPublicCard({
|
||||
id: 'hot',
|
||||
playCount: 8,
|
||||
likeCount: 4,
|
||||
recentPlayCount7d: 16,
|
||||
publishedAt: '2026-06-06T12:00:00.000Z',
|
||||
});
|
||||
const curatedEntry = buildPublicCard({
|
||||
id: 'curated',
|
||||
playCount: 0,
|
||||
likeCount: 0,
|
||||
publishedAt: '2026-05-10T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [curatedEntry],
|
||||
latestEntries: [coldEntry, hotRecentEntry, curatedEntry],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
hotRecentEntry.profileId,
|
||||
curatedEntry.profileId,
|
||||
coldEntry.profileId,
|
||||
]);
|
||||
});
|
||||
|
||||
test('interleaves close-score works from different play types', () => {
|
||||
const firstPuzzle = buildPublicCard({
|
||||
id: 'puzzle-a',
|
||||
sourceType: 'puzzle',
|
||||
likeCount: 2,
|
||||
});
|
||||
const secondPuzzle = buildPublicCard({
|
||||
id: 'puzzle-b',
|
||||
sourceType: 'puzzle',
|
||||
likeCount: 2,
|
||||
});
|
||||
const match3d = buildPublicCard({
|
||||
id: 'match3d-a',
|
||||
sourceType: 'match3d',
|
||||
likeCount: 2,
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [],
|
||||
latestEntries: [firstPuzzle, secondPuzzle, match3d],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
firstPuzzle.profileId,
|
||||
match3d.profileId,
|
||||
secondPuzzle.profileId,
|
||||
]);
|
||||
});
|
||||
|
||||
test('separates same-type candidates while alternatives remain', () => {
|
||||
const hotPuzzle = buildPublicCard({
|
||||
id: 'hot-puzzle',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 50,
|
||||
likeCount: 20,
|
||||
});
|
||||
const warmPuzzle = buildPublicCard({
|
||||
id: 'warm-puzzle',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 32,
|
||||
likeCount: 12,
|
||||
});
|
||||
const coldMatch3d = buildPublicCard({
|
||||
id: 'cold-match3d',
|
||||
sourceType: 'match3d',
|
||||
publishedAt: '2026-04-01T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [],
|
||||
latestEntries: [hotPuzzle, warmPuzzle, coldMatch3d],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
hotPuzzle.profileId,
|
||||
coldMatch3d.profileId,
|
||||
warmPuzzle.profileId,
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to same-type adjacency when no other type remains', () => {
|
||||
const firstPuzzle = buildPublicCard({
|
||||
id: 'only-puzzle-a',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 8,
|
||||
});
|
||||
const secondPuzzle = buildPublicCard({
|
||||
id: 'only-puzzle-b',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 4,
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [],
|
||||
latestEntries: [firstPuzzle, secondPuzzle],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
firstPuzzle.profileId,
|
||||
secondPuzzle.profileId,
|
||||
]);
|
||||
});
|
||||
});
|
||||
225
src/components/platform-entry/platformRecommendation.ts
Normal file
225
src/components/platform-entry/platformRecommendation.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
buildPlatformPublicGalleryCardKey,
|
||||
isEdutainmentGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformPublicWorkSourceType,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const FEATURED_BONUS = 14;
|
||||
const MAX_FRESHNESS_SCORE = 12;
|
||||
|
||||
export type PlatformRecommendationOptions = {
|
||||
nowMs?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
type RecommendationCandidate = {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
key: string;
|
||||
sourceType: string;
|
||||
firstSeenIndex: number;
|
||||
isFeatured: boolean;
|
||||
timestampMs: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
type PlatformRecommendationMetricKey =
|
||||
| 'playCount'
|
||||
| 'remixCount'
|
||||
| 'likeCount'
|
||||
| 'recentPlayCount7d';
|
||||
|
||||
function parseRecommendationTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
|
||||
if (numericTimestamp?.[1]) {
|
||||
const rawTimestamp = Number(numericTimestamp[1]);
|
||||
if (Number.isFinite(rawTimestamp)) {
|
||||
const absoluteTimestamp = Math.abs(rawTimestamp);
|
||||
if (absoluteTimestamp >= 1_000_000_000_000_000) {
|
||||
return rawTimestamp / 1000;
|
||||
}
|
||||
if (absoluteTimestamp >= 1_000_000_000_000) {
|
||||
return rawTimestamp;
|
||||
}
|
||||
if (absoluteTimestamp >= 1_000_000_000) {
|
||||
return rawTimestamp * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = new Date(normalized).getTime();
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
function getRecommendationTimestamp(entry: PlatformPublicGalleryCard) {
|
||||
return parseRecommendationTimestamp(entry.publishedAt ?? entry.updatedAt);
|
||||
}
|
||||
|
||||
function getRecommendationMetric(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
key: PlatformRecommendationMetricKey,
|
||||
) {
|
||||
const value = (
|
||||
entry as Partial<Record<PlatformRecommendationMetricKey, number>>
|
||||
)[key];
|
||||
return Math.max(0, Math.round(Number(value ?? 0) || 0));
|
||||
}
|
||||
|
||||
function getRecommendationSourceType(entry: PlatformPublicGalleryCard) {
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return `edutainment:${entry.templateId}`;
|
||||
}
|
||||
|
||||
return resolvePlatformPublicWorkSourceType(entry);
|
||||
}
|
||||
|
||||
function getRecommendationThemeTags(entry: PlatformPublicGalleryCard) {
|
||||
return 'themeTags' in entry && Array.isArray(entry.themeTags)
|
||||
? entry.themeTags
|
||||
: [];
|
||||
}
|
||||
|
||||
function scoreRecommendationCandidate(
|
||||
candidate: Omit<RecommendationCandidate, 'score'>,
|
||||
nowMs: number,
|
||||
) {
|
||||
const entry = candidate.entry;
|
||||
const ageDays =
|
||||
candidate.timestampMs > 0
|
||||
? Math.max(0, (nowMs - candidate.timestampMs) / MS_PER_DAY)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const freshnessScore = Number.isFinite(ageDays)
|
||||
? MAX_FRESHNESS_SCORE / (1 + ageDays / 7)
|
||||
: 0;
|
||||
const coverScore = entry.coverImageSrc ? 1.5 : 0;
|
||||
const tagScore = Math.min(3, getRecommendationThemeTags(entry).length) * 0.6;
|
||||
const summaryScore = entry.summaryText.trim() ? 0.8 : 0;
|
||||
|
||||
return (
|
||||
(candidate.isFeatured ? FEATURED_BONUS : 0) +
|
||||
Math.log1p(getRecommendationMetric(entry, 'recentPlayCount7d')) * 8 +
|
||||
Math.log1p(getRecommendationMetric(entry, 'likeCount')) * 5 +
|
||||
Math.log1p(getRecommendationMetric(entry, 'remixCount')) * 3 +
|
||||
Math.log1p(getRecommendationMetric(entry, 'playCount')) * 2 +
|
||||
freshnessScore +
|
||||
coverScore +
|
||||
tagScore +
|
||||
summaryScore
|
||||
);
|
||||
}
|
||||
|
||||
function compareRecommendationCandidates(
|
||||
left: RecommendationCandidate,
|
||||
right: RecommendationCandidate,
|
||||
) {
|
||||
const scoreDiff = right.score - left.score;
|
||||
if (scoreDiff !== 0) {
|
||||
return scoreDiff;
|
||||
}
|
||||
|
||||
const timeDiff = right.timestampMs - left.timestampMs;
|
||||
if (timeDiff !== 0) {
|
||||
return timeDiff;
|
||||
}
|
||||
|
||||
if (left.firstSeenIndex !== right.firstSeenIndex) {
|
||||
return left.firstSeenIndex - right.firstSeenIndex;
|
||||
}
|
||||
|
||||
return left.key.localeCompare(right.key, 'zh-CN');
|
||||
}
|
||||
|
||||
function diversifyAdjacentSourceTypes(candidates: RecommendationCandidate[]) {
|
||||
const remaining = [...candidates];
|
||||
const result: RecommendationCandidate[] = [];
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const lastSourceType = result[result.length - 1]?.sourceType ?? null;
|
||||
let nextIndex = 0;
|
||||
|
||||
if (lastSourceType) {
|
||||
const alternativeIndex = remaining.findIndex(
|
||||
(candidate) => candidate.sourceType !== lastSourceType,
|
||||
);
|
||||
if (alternativeIndex > 0) {
|
||||
nextIndex = alternativeIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const [nextCandidate] = remaining.splice(nextIndex, 1);
|
||||
if (nextCandidate) {
|
||||
result.push(nextCandidate);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildPlatformRecommendedEntries(
|
||||
params: {
|
||||
featuredEntries: PlatformPublicGalleryCard[];
|
||||
latestEntries: PlatformPublicGalleryCard[];
|
||||
},
|
||||
options: PlatformRecommendationOptions = {},
|
||||
) {
|
||||
const candidateMap = new Map<
|
||||
string,
|
||||
Omit<RecommendationCandidate, 'score'>
|
||||
>();
|
||||
let firstSeenIndex = 0;
|
||||
|
||||
const collectEntries = (
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
source: 'featured' | 'latest',
|
||||
) => {
|
||||
entries.forEach((entry) => {
|
||||
const key = buildPlatformPublicGalleryCardKey(entry);
|
||||
const timestampMs = getRecommendationTimestamp(entry);
|
||||
const existing = candidateMap.get(key);
|
||||
if (existing) {
|
||||
existing.isFeatured = existing.isFeatured || source === 'featured';
|
||||
if (timestampMs >= existing.timestampMs) {
|
||||
existing.entry = entry;
|
||||
existing.timestampMs = timestampMs;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
candidateMap.set(key, {
|
||||
entry,
|
||||
key,
|
||||
sourceType: getRecommendationSourceType(entry),
|
||||
firstSeenIndex,
|
||||
isFeatured: source === 'featured',
|
||||
timestampMs,
|
||||
});
|
||||
firstSeenIndex += 1;
|
||||
});
|
||||
};
|
||||
|
||||
collectEntries(params.featuredEntries, 'featured');
|
||||
collectEntries(params.latestEntries, 'latest');
|
||||
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
const rankedCandidates = Array.from(candidateMap.values())
|
||||
.map((candidate) => ({
|
||||
...candidate,
|
||||
score: scoreRecommendationCandidate(candidate, nowMs),
|
||||
}))
|
||||
.sort(compareRecommendationCandidates);
|
||||
const diversifiedCandidates = diversifyAdjacentSourceTypes(rankedCandidates);
|
||||
const limit =
|
||||
typeof options.limit === 'number' && options.limit > 0
|
||||
? Math.floor(options.limit)
|
||||
: diversifiedCandidates.length;
|
||||
|
||||
return diversifiedCandidates
|
||||
.slice(0, limit)
|
||||
.map((candidate) => candidate.entry);
|
||||
}
|
||||
@@ -21,7 +21,24 @@ describe('isPuzzleCompileActionReady', () => {
|
||||
expect(isPuzzleCompileActionReady(session)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats compile action as ready after the selected cover exists', () => {
|
||||
it('keeps compile action generating when only the selected cover exists', () => {
|
||||
const session = {
|
||||
sessionId: 'puzzle-session-1',
|
||||
draft: {
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
levels: [
|
||||
{
|
||||
generationStatus: 'generating',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as PuzzleAgentSessionSnapshot;
|
||||
|
||||
expect(isPuzzleCompileActionReady(session)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats compile action as ready after all runtime assets exist', () => {
|
||||
const session = {
|
||||
sessionId: 'puzzle-session-1',
|
||||
draft: {
|
||||
@@ -30,6 +47,12 @@ describe('isPuzzleCompileActionReady', () => {
|
||||
{
|
||||
generationStatus: 'ready',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
levelSceneImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-background.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -4,6 +4,13 @@ function hasText(value: string | null | undefined) {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasAssetReference(
|
||||
imageSrc: string | null | undefined,
|
||||
objectKey: string | null | undefined,
|
||||
) {
|
||||
return hasText(imageSrc) || hasText(objectKey);
|
||||
}
|
||||
|
||||
export function isPuzzleCompileActionReady(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
) {
|
||||
@@ -11,10 +18,19 @@ export function isPuzzleCompileActionReady(
|
||||
if (!draft) {
|
||||
return false;
|
||||
}
|
||||
if (hasText(draft.coverImageSrc)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
draft.levels?.some((level) => hasText(level.coverImageSrc)) === true
|
||||
draft.levels?.some(
|
||||
(level) =>
|
||||
(hasText(draft.coverImageSrc) || hasText(level.coverImageSrc)) &&
|
||||
hasAssetReference(level.levelSceneImageSrc, level.levelSceneImageObjectKey) &&
|
||||
hasAssetReference(
|
||||
level.uiSpritesheetImageSrc,
|
||||
level.uiSpritesheetImageObjectKey,
|
||||
) &&
|
||||
hasAssetReference(
|
||||
level.levelBackgroundImageSrc,
|
||||
level.levelBackgroundImageObjectKey,
|
||||
),
|
||||
) === true
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,16 +13,16 @@ import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
BabyObjectMatchDraft,
|
||||
CreateBabyObjectMatchDraftRequest,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
BabyObjectMatchDraft,
|
||||
CreateBabyObjectMatchDraftRequest,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
@@ -42,7 +42,10 @@ import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import type {
|
||||
WoodenFishGalleryCardResponse,
|
||||
WoodenFishWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
@@ -71,7 +74,6 @@ import {
|
||||
submitBigFishInput,
|
||||
} from '../../services/big-fish-runtime';
|
||||
import { listBigFishWorks } from '../../services/big-fish-works';
|
||||
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
||||
import {
|
||||
type CreationEntryConfig,
|
||||
fetchCreationEntryConfig,
|
||||
@@ -91,6 +93,7 @@ import {
|
||||
regenerateBabyObjectMatchDraftAssets,
|
||||
saveBabyObjectMatchDraft,
|
||||
} from '../../services/edutainment-baby-object';
|
||||
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
|
||||
import {
|
||||
@@ -155,6 +158,7 @@ import {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetail as getRpgEntryWorldGalleryDetailFromClient,
|
||||
getRpgEntryWorldGalleryDetailByCode,
|
||||
likeRpgEntryWorldGallery,
|
||||
recordRpgEntryWorldGalleryPlay,
|
||||
remixRpgEntryWorldGallery,
|
||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
@@ -334,10 +338,6 @@ const ISOLATED_RUNTIME_AUTH_OPTIONS = {
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
};
|
||||
const RECOMMEND_RUNTIME_AUTH_OPTIONS = {
|
||||
...ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
};
|
||||
const LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS = ISOLATED_RUNTIME_AUTH_OPTIONS;
|
||||
|
||||
function getPlatformTabPanel(tab: string) {
|
||||
@@ -542,6 +542,7 @@ const rpgEntryLibraryServiceMocks = vi.hoisted(() => ({
|
||||
getRpgEntryWorldGalleryDetail: vi.fn(),
|
||||
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
|
||||
getRpgEntryWorldLibraryDetail: vi.fn(),
|
||||
likeRpgEntryWorldGallery: vi.fn(),
|
||||
listRpgEntryWorldGallery: vi.fn(),
|
||||
listRpgEntryWorldLibrary: vi.fn(),
|
||||
publishRpgEntryWorldProfile: vi.fn(),
|
||||
@@ -2005,6 +2006,18 @@ function buildReadyPuzzleDraft(
|
||||
'/generated-puzzle-assets/puzzle-session-recovered/ui/background.png',
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/puzzle-session-recovered/ui/background.png',
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/puzzle-session-recovered/level-scene.png',
|
||||
levelSceneImageObjectKey:
|
||||
'generated-puzzle-assets/puzzle-session-recovered/level-scene.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/puzzle-session-recovered/ui-spritesheet.png',
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-puzzle-assets/puzzle-session-recovered/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/puzzle-session-recovered/level-background.png',
|
||||
levelBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/puzzle-session-recovered/level-background.png',
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
@@ -5216,6 +5229,101 @@ test('running puzzle draft opens generation progress from draft tab', async () =
|
||||
});
|
||||
});
|
||||
|
||||
test('puzzle text-only form stays generating when compile starts background image without cover', async () => {
|
||||
const user = userEvent.setup();
|
||||
const initialSession = buildMockPuzzleAgentSession({
|
||||
sessionId: 'puzzle-session-text-only',
|
||||
stage: 'collecting_anchors',
|
||||
progressPercent: 0,
|
||||
draft: null,
|
||||
});
|
||||
const generatingDraft = buildReadyPuzzleDraft({
|
||||
workTitle: '文字直创拼图',
|
||||
workDescription: '只输入文字后后台继续生成图片。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating',
|
||||
levels: [
|
||||
{
|
||||
...buildReadyPuzzleDraft().levels![0]!,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
levelSceneImageSrc: null,
|
||||
levelSceneImageObjectKey: null,
|
||||
uiSpritesheetImageSrc: null,
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
levelBackgroundImageSrc: null,
|
||||
levelBackgroundImageObjectKey: null,
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
],
|
||||
});
|
||||
const generatingSession = buildMockPuzzleAgentSession({
|
||||
sessionId: 'puzzle-session-text-only',
|
||||
stage: 'image_refining',
|
||||
progressPercent: 88,
|
||||
draft: generatingDraft,
|
||||
lastAssistantReply: '已编译首关草稿,并启动首关画面和 UI 资产后台生成。',
|
||||
resultPreview: {
|
||||
draft: generatingDraft,
|
||||
blockers: [
|
||||
{
|
||||
id: 'missing-cover-image-puzzle-level-1',
|
||||
code: 'MISSING_COVER_IMAGE',
|
||||
message: '正式拼图图片尚未确定',
|
||||
},
|
||||
],
|
||||
qualityFindings: [],
|
||||
publishReady: false,
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({
|
||||
session: initialSession,
|
||||
});
|
||||
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
|
||||
operation: {
|
||||
operationId: 'compile-puzzle-text-only',
|
||||
type: 'compile_puzzle_draft',
|
||||
status: 'completed',
|
||||
phaseLabel: '首关拼图草稿',
|
||||
phaseDetail: '已编译首关草稿,并启动首关画面和 UI 资产后台生成。',
|
||||
progress: 0.88,
|
||||
},
|
||||
session: generatingSession,
|
||||
});
|
||||
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
|
||||
session: generatingSession,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '拼图图片生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(executePuzzleAgentAction).toHaveBeenCalledWith(
|
||||
'puzzle-session-text-only',
|
||||
expect.objectContaining({ action: 'compile_puzzle_draft' }),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: '生成完成' })).toBeNull();
|
||||
expect(screen.queryByText('请先选择一张正式拼图图片。')).toBeNull();
|
||||
expect(screen.queryByText('拼图结果页')).toBeNull();
|
||||
expect(updatePuzzleWork).not.toHaveBeenCalled();
|
||||
expect(startLocalPuzzleRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('puzzle form checks mud points before creating a draft', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getProfileDashboard).mockResolvedValue({
|
||||
@@ -6523,6 +6631,7 @@ test('clicking a public work while logged out opens public detail without starti
|
||||
/>,
|
||||
);
|
||||
|
||||
await openDiscoverHub(user);
|
||||
const workCards = await screen.findAllByRole('button', {
|
||||
name: /潮雾列岛/u,
|
||||
});
|
||||
@@ -6576,20 +6685,29 @@ test('logged out public detail gates puzzle start and remix before real actions'
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||||
});
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const workCards = screen.getAllByRole('button', { name: /星桥机关/u });
|
||||
await user.click(workCards[0]!);
|
||||
expect(await screen.findByText('详情')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelId: null,
|
||||
},
|
||||
expect.objectContaining({
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(startPuzzleRun).not.toHaveBeenCalled();
|
||||
requireAuth.mockClear();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '作品改造' }));
|
||||
expect(requireAuth).toHaveBeenCalledTimes(2);
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -7369,6 +7487,75 @@ test('home recommendation share opens publish share modal', async () => {
|
||||
.toBeTruthy();
|
||||
});
|
||||
|
||||
test('home recommendation wooden fish like does not call RPG gallery like', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedWoodenFishWork: WoodenFishGalleryCardResponse = {
|
||||
publicWorkCode: 'WF-3A9EC89B',
|
||||
workId: 'wooden-fish-work-like-1',
|
||||
profileId: 'wooden-fish-profile-like-1',
|
||||
ownerUserId: 'wooden-fish-user-1',
|
||||
authorDisplayName: '木鱼作者',
|
||||
workTitle: '莲台木鱼',
|
||||
workDescription: '推荐页里的敲木鱼作品。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['敲木鱼'],
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
generationStatus: 'ready',
|
||||
};
|
||||
|
||||
vi.mocked(woodenFishClient.listGallery).mockResolvedValue({
|
||||
items: [publishedWoodenFishWork],
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(woodenFishClient.startRun).mockResolvedValue({
|
||||
run: {
|
||||
runId: 'wooden-fish-run-like-1',
|
||||
profileId: publishedWoodenFishWork.profileId,
|
||||
ownerUserId: publishedWoodenFishWork.ownerUserId,
|
||||
status: 'playing',
|
||||
totalTapCount: 0,
|
||||
wordCounters: [],
|
||||
startedAtMs: 1,
|
||||
updatedAtMs: 1,
|
||||
finishedAtMs: null,
|
||||
},
|
||||
});
|
||||
vi.mocked(likeRpgEntryWorldGallery).mockResolvedValue(
|
||||
buildMockRpgGalleryDetail({
|
||||
ownerUserId: 'custom-world-user-1',
|
||||
profileId: 'custom-world-profile-1',
|
||||
publicWorkCode: 'CW-00000001',
|
||||
authorPublicUserCode: 'SY-00000001',
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
authorDisplayName: 'RPG 作者',
|
||||
worldName: '不应被点赞的 RPG',
|
||||
subtitle: '错误分流',
|
||||
summaryText: 'WF 点赞不应进入这里。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'mythic',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
likeCount: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const meta = await screen.findByLabelText('莲台木鱼 作品信息');
|
||||
await user.click(within(meta).getByRole('button', { name: '点赞 0' }));
|
||||
|
||||
expect(likeRpgEntryWorldGallery).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await screen.findByText('作品类型 wooden-fish 暂不支持点赞。'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('home recommendation keeps logged-in puzzle start on default auth instead of guest token', async () => {
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-public-2',
|
||||
@@ -7471,12 +7658,6 @@ test('logged out home recommendation next starts the next puzzle work', async ()
|
||||
/>,
|
||||
);
|
||||
|
||||
const recommendNavButton = document.querySelector<HTMLButtonElement>(
|
||||
'.platform-bottom-nav [aria-label="推荐"]',
|
||||
);
|
||||
expect(recommendNavButton).toBeTruthy();
|
||||
await user.click(recommendNavButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -7505,7 +7686,114 @@ test('logged out home recommendation next starts the next puzzle work', async ()
|
||||
});
|
||||
});
|
||||
|
||||
test('home recommendation puzzle next level switches to similar work detail', async () => {
|
||||
test('home recommendation keeps cover while switching during a pending puzzle start', async () => {
|
||||
const user = userEvent.setup();
|
||||
const firstWork = {
|
||||
workId: 'puzzle-work-pending-next-1',
|
||||
profileId: 'puzzle-profile-pending-next-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'puzzle-session-pending-next-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '雨港电路',
|
||||
summary: '第一张公开拼图仍在启动。',
|
||||
themeTags: ['雨港', '拼图'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
publishedAt: '2026-04-25T10:00:00.000Z',
|
||||
playCount: 47,
|
||||
likeCount: 1,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
const secondWork = {
|
||||
...firstWork,
|
||||
workId: 'puzzle-work-pending-next-2',
|
||||
profileId: 'puzzle-profile-pending-next-2',
|
||||
ownerUserId: 'user-3',
|
||||
sourceSessionId: 'puzzle-session-pending-next-2',
|
||||
authorDisplayName: '贝壳作者',
|
||||
levelName: '贝壳潮汐',
|
||||
summary: '第二张公开拼图。',
|
||||
themeTags: ['贝壳', '拼图'],
|
||||
playCount: 1,
|
||||
likeCount: 0,
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
} satisfies PuzzleWorkSummary;
|
||||
let resolveFirstRun!: (value: { run: PuzzleRunSnapshot }) => void;
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [firstWork, secondWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
|
||||
item: profileId === secondWork.profileId ? secondWork : firstWork,
|
||||
}));
|
||||
vi.mocked(startPuzzleRun).mockImplementationOnce(
|
||||
(async () =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirstRun = resolve;
|
||||
})) as typeof startPuzzleRun,
|
||||
);
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: (action) => action(),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
profileId: firstWork.profileId,
|
||||
levelId: null,
|
||||
},
|
||||
expect.objectContaining({
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '下一个' }));
|
||||
|
||||
expect(
|
||||
screen.queryByText('作品暂时无法进入,请稍后再试。'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await screen.findByLabelText('贝壳潮汐 作品信息', undefined, {
|
||||
timeout: 3000,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveFirstRun({
|
||||
run: buildMockPuzzleRun(firstWork.profileId, '后端拼图关卡'),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
profileId: secondWork.profileId,
|
||||
levelId: null,
|
||||
},
|
||||
expect.objectContaining({
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(
|
||||
screen.queryByText('作品暂时无法进入,请稍后再试。'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('home recommendation puzzle next level uses unified recommend switching', async () => {
|
||||
const user = userEvent.setup();
|
||||
const entryWork = {
|
||||
workId: 'puzzle-work-public-guest-1',
|
||||
@@ -7547,17 +7835,17 @@ test('home recommendation puzzle next level switches to similar work detail', as
|
||||
},
|
||||
],
|
||||
} satisfies PuzzleWorkSummary;
|
||||
const similarWork = {
|
||||
const nextRecommendWork = {
|
||||
...entryWork,
|
||||
workId: 'puzzle-work-similar-guest-1',
|
||||
profileId: 'puzzle-profile-similar-guest-1',
|
||||
workId: 'puzzle-work-public-guest-2',
|
||||
profileId: 'puzzle-profile-public-guest-2',
|
||||
levelName: '风塔试炼',
|
||||
summary: '另一套奇幻机关拼图。',
|
||||
summary: '另一套推荐拼图。',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'similar-level-1',
|
||||
levelId: 'next-recommend-level-1',
|
||||
levelName: '风塔试炼',
|
||||
pictureDescription: '相似作品首关。',
|
||||
pictureDescription: '推荐队列下一张拼图。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
@@ -7586,47 +7874,35 @@ test('home recommendation puzzle next level switches to similar work detail', as
|
||||
entryWork.profileId,
|
||||
entryWork.levelName,
|
||||
);
|
||||
const similarRun = {
|
||||
...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName),
|
||||
runId: clearedRun.runId,
|
||||
entryProfileId: entryWork.profileId,
|
||||
currentLevelIndex: 2,
|
||||
currentLevel: {
|
||||
...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName)
|
||||
.currentLevel!,
|
||||
runId: clearedRun.runId,
|
||||
levelIndex: 2,
|
||||
levelId: 'similar-level-1',
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
};
|
||||
const nextRecommendRun = buildMockPuzzleRun(
|
||||
nextRecommendWork.profileId,
|
||||
nextRecommendWork.levelName,
|
||||
);
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [entryWork],
|
||||
items: [entryWork, nextRecommendWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
|
||||
item: profileId === similarWork.profileId ? similarWork : entryWork,
|
||||
item: profileId === nextRecommendWork.profileId ? nextRecommendWork : entryWork,
|
||||
}));
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: {
|
||||
...startedRun,
|
||||
currentLevel: {
|
||||
...startedRun.currentLevel!,
|
||||
startedAtMs: Date.now(),
|
||||
vi.mocked(startPuzzleRun).mockImplementation(async (payload) => {
|
||||
const run =
|
||||
payload.profileId === nextRecommendWork.profileId
|
||||
? nextRecommendRun
|
||||
: startedRun;
|
||||
return {
|
||||
run: {
|
||||
...run,
|
||||
currentLevel: {
|
||||
...run.currentLevel!,
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
|
||||
run: clearedRunWithSameWorkNext,
|
||||
});
|
||||
let resolveAdvancePuzzleNextLevel!: (value: {
|
||||
run: PuzzleRunSnapshot;
|
||||
}) => void;
|
||||
vi.mocked(advancePuzzleNextLevel).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveAdvancePuzzleNextLevel = resolve;
|
||||
}),
|
||||
);
|
||||
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedRun);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
@@ -7655,24 +7931,23 @@ test('home recommendation puzzle next level switches to similar work detail', as
|
||||
await user.click(within(dialog).getByRole('button', { name: '下一关' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(clearedRun.runId, {
|
||||
preferSimilarWork: true,
|
||||
});
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
profileId: nextRecommendWork.profileId,
|
||||
levelId: null,
|
||||
},
|
||||
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
});
|
||||
expect(advancePuzzleNextLevel).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||||
expect(screen.queryByText('加载中...')).toBeNull();
|
||||
|
||||
resolveAdvancePuzzleNextLevel({ run: similarRun });
|
||||
await waitFor(() => {
|
||||
expect(getPuzzleGalleryDetail).toHaveBeenCalledWith(similarWork.profileId);
|
||||
});
|
||||
expect(
|
||||
await screen.findByLabelText('风塔试炼 作品信息', undefined, {
|
||||
timeout: 3000,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText('风塔试炼').length).toBeGreaterThan(0);
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(1);
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('home recommendation Match3D runtime keeps profile generated models when card summary is stale', async () => {
|
||||
@@ -8331,10 +8606,84 @@ test('direct jump hop result route restores work detail by profile id', async ()
|
||||
expect(screen.queryByText('跳一跳草稿未恢复')).toBeNull();
|
||||
expect(jumpHopClient.getWorkDetail).toHaveBeenCalledWith(
|
||||
'jump-hop-profile-restore-1',
|
||||
{ audience: 'creation' },
|
||||
);
|
||||
expect(jumpHopClient.getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('completed unpublished jump hop draft opens result page without starting runtime', async () => {
|
||||
const user = userEvent.setup();
|
||||
const work = buildMockJumpHopWork({
|
||||
summary: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-draft-ready-1',
|
||||
profileId: 'jump-hop-profile-draft-ready-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'jump-hop-session-draft-ready-1',
|
||||
themeText: '未发布跳一跳草稿',
|
||||
workTitle: '未发布跳一跳草稿',
|
||||
workDescription: '已经生成完成,但还没有发布。',
|
||||
themeTags: ['草稿'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-30T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
});
|
||||
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({
|
||||
items: [work.summary],
|
||||
});
|
||||
vi.mocked(jumpHopClient.getWorkDetail).mockResolvedValueOnce({
|
||||
item: work,
|
||||
} satisfies JumpHopWorkDetailResponse);
|
||||
vi.mocked(fetchCreationEntryConfig).mockResolvedValueOnce({
|
||||
...testCreationEntryConfig,
|
||||
creationTypes: [
|
||||
...testCreationEntryConfig.creationTypes,
|
||||
{
|
||||
id: 'jump-hop',
|
||||
title: '跳一跳',
|
||||
subtitle: '主题驱动平台跳跃',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/jump-hop.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 55,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openDraftHub(user);
|
||||
const draftPanel = getPlatformTabPanel('saves');
|
||||
await user.click(
|
||||
await within(draftPanel).findByRole('button', {
|
||||
name: /继续创作《未发布跳一跳草稿》/u,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await screen.findByText('未发布跳一跳草稿')).toBeTruthy();
|
||||
expect(jumpHopClient.getWorkDetail).toHaveBeenCalledWith(
|
||||
'jump-hop-profile-draft-ready-1',
|
||||
{ audience: 'creation' },
|
||||
);
|
||||
expect(jumpHopClient.startRun).not.toHaveBeenCalled();
|
||||
expect(window.location.pathname).toBe('/creation/jump-hop/result');
|
||||
expect(window.location.search).toContain(
|
||||
'profileId=jump-hop-profile-draft-ready-1',
|
||||
);
|
||||
});
|
||||
|
||||
test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { PublicWorkSourceType } from '../../../packages/shared/src/contracts/playTypes';
|
||||
import type {
|
||||
SquareHoleHoleOption,
|
||||
SquareHoleShapeOption,
|
||||
@@ -55,8 +56,12 @@ export const PLATFORM_WORK_TAG_DISPLAY_LIMIT = 4;
|
||||
export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID = 'baby-object-match';
|
||||
export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物';
|
||||
|
||||
export type PlatformCustomWorldGalleryCard = CustomWorldGalleryCard & {
|
||||
sourceType?: 'custom-world';
|
||||
};
|
||||
|
||||
export type PlatformWorldCardLike =
|
||||
| CustomWorldGalleryCard
|
||||
| PlatformCustomWorldGalleryCard
|
||||
| CustomWorldLibraryEntry<CustomWorldProfile>
|
||||
| PlatformBigFishGalleryCard
|
||||
| PlatformMatch3DGalleryCard
|
||||
@@ -319,7 +324,7 @@ export type PlatformBarkBattleGalleryCard = {
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryCard =
|
||||
| CustomWorldGalleryCard
|
||||
| PlatformCustomWorldGalleryCard
|
||||
| PlatformBigFishGalleryCard
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformSquareHoleGalleryCard
|
||||
@@ -343,6 +348,14 @@ export function isLibraryWorldEntry(
|
||||
return 'profile' in entry;
|
||||
}
|
||||
|
||||
export function isCustomWorldGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformCustomWorldGalleryCard {
|
||||
return !isLibraryWorldEntry(entry) && !('sourceType' in entry)
|
||||
? true
|
||||
: 'sourceType' in entry && entry.sourceType === 'custom-world';
|
||||
}
|
||||
|
||||
export function isPuzzleGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformPuzzleGalleryCard {
|
||||
@@ -403,28 +416,62 @@ export function isBarkBattleGalleryEntry(
|
||||
return 'sourceType' in entry && entry.sourceType === 'bark-battle';
|
||||
}
|
||||
|
||||
export function resolvePlatformPublicWorkSourceType(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): PublicWorkSourceType {
|
||||
if (isCustomWorldGalleryEntry(entry)) {
|
||||
return 'custom-world';
|
||||
}
|
||||
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return 'big-fish';
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return 'puzzle';
|
||||
}
|
||||
|
||||
if (isPuzzleClearGalleryEntry(entry)) {
|
||||
return 'puzzle-clear';
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return 'jump-hop';
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return 'wooden-fish';
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return 'match3d';
|
||||
}
|
||||
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
return 'square-hole';
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return 'visual-novel';
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return 'bark-battle';
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return 'edutainment';
|
||||
}
|
||||
|
||||
throw new Error('未知公开作品类型。');
|
||||
}
|
||||
|
||||
export function buildPlatformPublicGalleryCardKey(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
const kind = isBigFishGalleryEntry(entry)
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: isJumpHopGalleryEntry(entry)
|
||||
? 'jump-hop'
|
||||
: isWoodenFishGalleryEntry(entry)
|
||||
? 'wooden-fish'
|
||||
: isMatch3DGalleryEntry(entry)
|
||||
? 'match3d'
|
||||
: isSquareHoleGalleryEntry(entry)
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: isBarkBattleGalleryEntry(entry)
|
||||
? 'bark-battle'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: 'rpg';
|
||||
const kind = isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: resolvePlatformPublicWorkSourceType(entry);
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
@@ -874,7 +921,11 @@ export function resolvePlatformWorldFallbackCoverImage(
|
||||
return '/creation-type-references/bark-battle.webp';
|
||||
}
|
||||
|
||||
return '/creation-type-references/rpg.webp';
|
||||
if (isCustomWorldGalleryEntry(entry) || isLibraryWorldEntry(entry)) {
|
||||
return '/creation-type-references/rpg.webp';
|
||||
}
|
||||
|
||||
throw new Error('未知公开作品类型。');
|
||||
}
|
||||
|
||||
export function resolvePlatformWorldCoverSlides(
|
||||
|
||||
@@ -74,8 +74,7 @@ export function useRpgEntryBootstrap(
|
||||
PlatformBrowseHistoryEntry[]
|
||||
>([]);
|
||||
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>([]);
|
||||
const [platformTab, setPlatformTabState] =
|
||||
useState<PlatformHomeTab>('category');
|
||||
const [platformTab, setPlatformTabState] = useState<PlatformHomeTab>('home');
|
||||
const [platformError, setPlatformError] = useState<string | null>(null);
|
||||
const [dashboardError, setDashboardError] = useState<string | null>(null);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
@@ -351,8 +350,8 @@ export function useRpgEntryBootstrap(
|
||||
!hasInitialAgentSession &&
|
||||
!hasExplicitPlatformTabSelectionRef.current
|
||||
) {
|
||||
// 中文注释:新用户先进入发现页;推荐页可直接进入,真正受保护的动作再单独做登录门禁。
|
||||
setPlatformTabState(isAuthenticated ? 'home' : 'category');
|
||||
// 中文注释:新用户也先进入推荐页;真正受保护的动作再单独做登录门禁。
|
||||
setPlatformTabState('home');
|
||||
}
|
||||
} finally {
|
||||
if (isActive) {
|
||||
@@ -369,7 +368,6 @@ export function useRpgEntryBootstrap(
|
||||
canReadProtectedData,
|
||||
getProfileDashboard,
|
||||
hasInitialAgentSession,
|
||||
isAuthenticated,
|
||||
user,
|
||||
]);
|
||||
|
||||
|
||||
@@ -59,13 +59,15 @@ describe('UnifiedCreationPage', () => {
|
||||
.getByText('敲木鱼工作台')
|
||||
.closest('.unified-creation-page__content')
|
||||
?.className,
|
||||
).toContain('min-h-max');
|
||||
).toContain('flex-1');
|
||||
expect(
|
||||
screen
|
||||
.getByText('敲木鱼工作台')
|
||||
.closest('.unified-creation-page__content')
|
||||
?.className,
|
||||
).not.toContain('min-h-0');
|
||||
).toContain('min-h-0');
|
||||
expect(root?.className).toContain('overflow-y-auto');
|
||||
expect(root?.className).toContain('overscroll-contain');
|
||||
expect(root?.className).toContain('bg-[image:var(--platform-body-fill)]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ export function UnifiedCreationPage({
|
||||
}: UnifiedCreationPageProps) {
|
||||
return (
|
||||
<div
|
||||
className="unified-creation-page platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-y-auto overflow-x-hidden px-3 pt-2 sm:px-4 sm:pt-3"
|
||||
className="unified-creation-page platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-y-auto overflow-x-hidden overscroll-contain bg-[image:var(--platform-body-fill)] px-3 pt-2 sm:px-4 sm:pt-3"
|
||||
data-play-id={spec.playId}
|
||||
data-field-kinds={spec.fields.map((field) => field.kind).join(',')}
|
||||
data-workspace-stage={spec.workspaceStage}
|
||||
@@ -65,7 +65,7 @@ export function UnifiedCreationPage({
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="unified-creation-page__content flex min-h-max flex-col pb-3 sm:pb-4">
|
||||
<div className="unified-creation-page__content flex min-h-0 flex-1 flex-col pb-3 sm:pb-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ describe('unified creation specs', () => {
|
||||
'jump-hop',
|
||||
'match3d',
|
||||
'puzzle',
|
||||
'puzzle-clear',
|
||||
'rpg',
|
||||
'square-hole',
|
||||
'visual-novel',
|
||||
@@ -36,41 +37,54 @@ describe('unified creation specs', () => {
|
||||
|
||||
test('主要链路都映射到统一创作、生成、结果阶段', () => {
|
||||
expect(getUnifiedCreationSpec('rpg')).toMatchObject({
|
||||
title: '文字冒险',
|
||||
workspaceStage: 'agent-workspace',
|
||||
generationStage: 'custom-world-generating',
|
||||
resultStage: 'custom-world-result',
|
||||
});
|
||||
expect(getUnifiedCreationSpec('puzzle')).toMatchObject({
|
||||
title: '拼图',
|
||||
workspaceStage: 'puzzle-agent-workspace',
|
||||
generationStage: 'puzzle-generating',
|
||||
resultStage: 'puzzle-result',
|
||||
});
|
||||
expect(getUnifiedCreationSpec('puzzle-clear')).toMatchObject({
|
||||
workspaceStage: 'puzzle-clear-workspace',
|
||||
generationStage: 'puzzle-clear-generating',
|
||||
resultStage: 'puzzle-clear-result',
|
||||
});
|
||||
expect(getUnifiedCreationSpec('match3d')).toMatchObject({
|
||||
title: '抓大鹅',
|
||||
workspaceStage: 'match3d-agent-workspace',
|
||||
generationStage: 'match3d-generating',
|
||||
resultStage: 'match3d-result',
|
||||
});
|
||||
expect(getUnifiedCreationSpec('jump-hop')).toMatchObject({
|
||||
title: '跳一跳',
|
||||
workspaceStage: 'jump-hop-workspace',
|
||||
generationStage: 'jump-hop-generating',
|
||||
resultStage: 'jump-hop-result',
|
||||
});
|
||||
expect(getUnifiedCreationSpec('wooden-fish')).toMatchObject({
|
||||
title: '敲木鱼',
|
||||
workspaceStage: 'wooden-fish-workspace',
|
||||
generationStage: 'wooden-fish-generating',
|
||||
resultStage: 'wooden-fish-result',
|
||||
});
|
||||
expect(getUnifiedCreationSpec('bark-battle')).toMatchObject({
|
||||
title: '汪汪声浪',
|
||||
workspaceStage: 'bark-battle-workspace',
|
||||
generationStage: 'bark-battle-generating',
|
||||
resultStage: 'bark-battle-result',
|
||||
});
|
||||
expect(getUnifiedCreationSpec('visual-novel')).toMatchObject({
|
||||
title: '视觉小说',
|
||||
workspaceStage: 'visual-novel-agent-workspace',
|
||||
generationStage: 'visual-novel-generating',
|
||||
resultStage: 'visual-novel-result',
|
||||
});
|
||||
expect(getUnifiedCreationSpec('baby-object-match')).toMatchObject({
|
||||
title: '宝贝识物',
|
||||
workspaceStage: 'baby-object-match-workspace',
|
||||
generationStage: 'baby-object-match-generating',
|
||||
resultStage: 'baby-object-match-result',
|
||||
|
||||
@@ -2,11 +2,13 @@ import type {
|
||||
CreationEntryTypeConfig,
|
||||
UnifiedCreationSpec,
|
||||
} from '../../services/creationEntryConfigService';
|
||||
import type { PlatformCreationTypeId } from '../../../packages/shared/src/contracts/playTypes';
|
||||
|
||||
export const UNIFIED_CREATION_PLAY_IDS = [
|
||||
'rpg',
|
||||
'big-fish',
|
||||
'puzzle',
|
||||
'puzzle-clear',
|
||||
'match3d',
|
||||
'jump-hop',
|
||||
'wooden-fish',
|
||||
@@ -15,7 +17,7 @@ export const UNIFIED_CREATION_PLAY_IDS = [
|
||||
'visual-novel',
|
||||
'baby-object-match',
|
||||
'creative-agent',
|
||||
] as const;
|
||||
] as const satisfies readonly PlatformCreationTypeId[];
|
||||
|
||||
export type UnifiedCreationPlayId =
|
||||
(typeof UNIFIED_CREATION_PLAY_IDS)[number];
|
||||
@@ -27,7 +29,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
> = {
|
||||
rpg: {
|
||||
playId: 'rpg',
|
||||
title: '想做个什么玩法?',
|
||||
title: '文字冒险',
|
||||
workspaceStage: 'agent-workspace',
|
||||
generationStage: 'custom-world-generating',
|
||||
resultStage: 'custom-world-result',
|
||||
@@ -42,7 +44,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
},
|
||||
'big-fish': {
|
||||
playId: 'big-fish',
|
||||
title: '想做个什么玩法?',
|
||||
title: '摸鱼',
|
||||
workspaceStage: 'big-fish-agent-workspace',
|
||||
generationStage: 'big-fish-generating',
|
||||
resultStage: 'big-fish-result',
|
||||
@@ -57,7 +59,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
},
|
||||
puzzle: {
|
||||
playId: 'puzzle',
|
||||
title: '想做个什么玩法?',
|
||||
title: '拼图',
|
||||
workspaceStage: 'puzzle-agent-workspace',
|
||||
generationStage: 'puzzle-generating',
|
||||
resultStage: 'puzzle-result',
|
||||
@@ -82,9 +84,36 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
},
|
||||
],
|
||||
},
|
||||
'puzzle-clear': {
|
||||
playId: 'puzzle-clear',
|
||||
title: '想做个什么玩法?',
|
||||
workspaceStage: 'puzzle-clear-workspace',
|
||||
generationStage: 'puzzle-clear-generating',
|
||||
resultStage: 'puzzle-clear-result',
|
||||
fields: [
|
||||
{
|
||||
id: 'title',
|
||||
kind: 'text',
|
||||
label: '作品标题',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'themePrompt',
|
||||
kind: 'text',
|
||||
label: '主题',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'backgroundReferenceImage',
|
||||
kind: 'image',
|
||||
label: '参考图',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
match3d: {
|
||||
playId: 'match3d',
|
||||
title: '想做个什么玩法?',
|
||||
title: '抓大鹅',
|
||||
workspaceStage: 'match3d-agent-workspace',
|
||||
generationStage: 'match3d-generating',
|
||||
resultStage: 'match3d-result',
|
||||
@@ -105,7 +134,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
},
|
||||
'jump-hop': {
|
||||
playId: 'jump-hop',
|
||||
title: '想做个什么玩法?',
|
||||
title: '跳一跳',
|
||||
workspaceStage: 'jump-hop-workspace',
|
||||
generationStage: 'jump-hop-generating',
|
||||
resultStage: 'jump-hop-result',
|
||||
@@ -120,7 +149,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
},
|
||||
'wooden-fish': {
|
||||
playId: 'wooden-fish',
|
||||
title: '想做个什么玩法?',
|
||||
title: '敲木鱼',
|
||||
workspaceStage: 'wooden-fish-workspace',
|
||||
generationStage: 'wooden-fish-generating',
|
||||
resultStage: 'wooden-fish-result',
|
||||
@@ -153,7 +182,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
},
|
||||
'square-hole': {
|
||||
playId: 'square-hole',
|
||||
title: '想做个什么玩法?',
|
||||
title: '方洞',
|
||||
workspaceStage: 'square-hole-agent-workspace',
|
||||
generationStage: 'square-hole-generating',
|
||||
resultStage: 'square-hole-result',
|
||||
@@ -168,7 +197,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
},
|
||||
'bark-battle': {
|
||||
playId: 'bark-battle',
|
||||
title: '想做个什么玩法?',
|
||||
title: '汪汪声浪',
|
||||
workspaceStage: 'bark-battle-workspace',
|
||||
generationStage: 'bark-battle-generating',
|
||||
resultStage: 'bark-battle-result',
|
||||
@@ -213,7 +242,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
},
|
||||
'visual-novel': {
|
||||
playId: 'visual-novel',
|
||||
title: '想做个什么玩法?',
|
||||
title: '视觉小说',
|
||||
workspaceStage: 'visual-novel-agent-workspace',
|
||||
generationStage: 'visual-novel-generating',
|
||||
resultStage: 'visual-novel-result',
|
||||
@@ -234,7 +263,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
},
|
||||
'baby-object-match': {
|
||||
playId: 'baby-object-match',
|
||||
title: '想做个什么玩法?',
|
||||
title: '宝贝识物',
|
||||
workspaceStage: 'baby-object-match-workspace',
|
||||
generationStage: 'baby-object-match-generating',
|
||||
resultStage: 'baby-object-match-result',
|
||||
@@ -255,7 +284,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
},
|
||||
'creative-agent': {
|
||||
playId: 'creative-agent',
|
||||
title: '想做个什么玩法?',
|
||||
title: '智能体创作',
|
||||
workspaceStage: 'creative-agent-workspace',
|
||||
generationStage: 'puzzle-generating',
|
||||
resultStage: 'puzzle-result',
|
||||
|
||||
@@ -78,7 +78,7 @@ test('match3d workspace submits derived entry form payload instead of agent chat
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
|
||||
expect(screen.getByText('抓大鹅')).toBeTruthy();
|
||||
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
|
||||
expect(screen.queryByText('2D素材风格')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '扁平图标' })).toBeNull();
|
||||
@@ -130,7 +130,7 @@ test('match3d workspace can defer visible chrome to the unified creation page',
|
||||
expect(workspace?.className).not.toContain('h-full');
|
||||
expect(workspace?.className).not.toContain('overflow-hidden');
|
||||
expect(workspace?.className).not.toContain('platform-remap-surface');
|
||||
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull();
|
||||
expect(screen.queryByRole('heading', { name: '抓大鹅' })).toBeNull();
|
||||
const themeInput = screen.getByLabelText('想做一个什么题材的抓大鹅?');
|
||||
expect(themeInput).toBeTruthy();
|
||||
expect(themeInput.className).not.toContain('h-full');
|
||||
|
||||
@@ -115,7 +115,7 @@ export function Match3DCreationWorkspace({
|
||||
onCreateFromForm,
|
||||
initialFormPayload = null,
|
||||
showBackButton = true,
|
||||
title = '想做个什么玩法?',
|
||||
title = '抓大鹅',
|
||||
unifiedChrome = false,
|
||||
}: Match3DCreationWorkspaceProps) {
|
||||
const [formState, setFormState] = useState<Match3DFormState>(() =>
|
||||
|
||||
@@ -188,7 +188,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
|
||||
expect(screen.queryByLabelText('作品名称')).toBeNull();
|
||||
expect(screen.queryByLabelText('作品描述')).toBeNull();
|
||||
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
|
||||
expect(screen.getByText('拼图')).toBeTruthy();
|
||||
expect(screen.queryByText('try')).toBeNull();
|
||||
expect(screen.queryByText('Template')).toBeNull();
|
||||
|
||||
@@ -238,7 +238,7 @@ test('puzzle workspace can defer visible chrome to the unified creation page', (
|
||||
expect(workspace?.className).not.toContain('platform-remap-surface');
|
||||
expect(imagePanel?.className).toContain('flex-none');
|
||||
expect(imagePanel?.className).not.toContain('flex-1');
|
||||
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull();
|
||||
expect(screen.queryByRole('heading', { name: '拼图' })).toBeNull();
|
||||
expect(screen.getByLabelText('画面描述')).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ export function PuzzleCreationWorkspace({
|
||||
onAutoSaveForm,
|
||||
initialFormPayload = null,
|
||||
showBackButton = true,
|
||||
title = '想做个什么玩法?',
|
||||
title = '拼图',
|
||||
unifiedChrome = false,
|
||||
}: PuzzleCreationWorkspaceProps) {
|
||||
const [formState, setFormState] = useState<PuzzleFormState>(() =>
|
||||
|
||||
@@ -79,17 +79,19 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
html[data-mobile-keyboard-open='true'],
|
||||
html[data-mobile-keyboard-open='true'] body,
|
||||
html[data-mobile-keyboard-open='true'] #root {
|
||||
background: var(
|
||||
--platform-keyboard-exposed-fill,
|
||||
linear-gradient(180deg, #fffdf9 0%, #fdf9f5 54%, #f8efe7 100%)
|
||||
);
|
||||
}
|
||||
|
||||
.platform-viewport-shell {
|
||||
height: var(--platform-layout-viewport-height, 100vh);
|
||||
max-height: var(--platform-layout-viewport-height, 100vh);
|
||||
min-height: var(--platform-layout-viewport-height, 100vh);
|
||||
transform: translate3d(
|
||||
0,
|
||||
calc(-1 * var(--platform-keyboard-focus-offset, 0px)),
|
||||
0
|
||||
);
|
||||
transform-origin: top center;
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
@@ -220,6 +222,16 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes platform-recommend-runtime-loading {
|
||||
0% {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(240%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes puzzle-clear-card-clear-pop {
|
||||
0% {
|
||||
opacity: 1;
|
||||
@@ -5281,6 +5293,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
.platform-recommend-runtime-viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
isolation: isolate;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--platform-recommend-runtime-fill);
|
||||
@@ -5298,18 +5312,48 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
.platform-recommend-runtime-cover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
z-index: 30;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
background: var(--platform-recommend-runtime-fill);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transition: opacity 420ms ease;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.platform-recommend-runtime-loading {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 0.72rem;
|
||||
left: 1rem;
|
||||
z-index: 4;
|
||||
height: 0.18rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.24);
|
||||
box-shadow: 0 0 18px rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.platform-recommend-runtime-loading::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 42%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.38),
|
||||
rgba(255, 255, 255, 0.92),
|
||||
rgba(255, 180, 97, 0.86)
|
||||
);
|
||||
content: '';
|
||||
transform: translateX(-110%);
|
||||
animation: platform-recommend-runtime-loading 1.15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.platform-recommend-runtime-cover--hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 420ms ease;
|
||||
}
|
||||
|
||||
.platform-recommend-swipe-stage {
|
||||
@@ -5335,6 +5379,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.platform-recommend-swipe-rail--resetting {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.platform-recommend-swipe-page {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -59,6 +59,30 @@ describe('index stylesheet unread dots', () => {
|
||||
expect(css).toContain('::-webkit-scrollbar-thumb');
|
||||
});
|
||||
|
||||
it('uses the platform fill for root background exposed by mobile keyboard shift', () => {
|
||||
const css = readIndexCss();
|
||||
|
||||
const keyboardRootBlock = getCssBlock(
|
||||
css,
|
||||
"html[data-mobile-keyboard-open='true'],\nhtml[data-mobile-keyboard-open='true'] body,\nhtml[data-mobile-keyboard-open='true'] #root",
|
||||
);
|
||||
expect(keyboardRootBlock).toContain('--platform-keyboard-exposed-fill');
|
||||
expect(keyboardRootBlock).toContain('#fffdf9');
|
||||
expect(keyboardRootBlock).not.toContain('#0a0a0a');
|
||||
});
|
||||
|
||||
it('does not globally transform the platform shell while the mobile keyboard is open', () => {
|
||||
const css = readIndexCss();
|
||||
|
||||
const platformShellBlock = getCssBlock(
|
||||
css,
|
||||
'.platform-viewport-shell {\n height',
|
||||
);
|
||||
expect(platformShellBlock).toContain('--platform-layout-viewport-height');
|
||||
expect(platformShellBlock).not.toContain('translate3d');
|
||||
expect(platformShellBlock).not.toContain('--platform-keyboard-focus-offset');
|
||||
});
|
||||
|
||||
it('uses warm brown tokens for draft unread markers instead of red literals', () => {
|
||||
const css = readIndexCss();
|
||||
|
||||
@@ -129,3 +153,43 @@ describe('index stylesheet creation agent hero contrast', () => {
|
||||
expect(hintBlock).toContain('rgba(255, 255, 255, 0.72) !important');
|
||||
});
|
||||
});
|
||||
|
||||
describe('index stylesheet recommend runtime cover', () => {
|
||||
it('keeps the card cover above embedded runtime and only fades it when ready', () => {
|
||||
const css = readIndexCss();
|
||||
|
||||
const viewportBlock = getCssBlock(
|
||||
css,
|
||||
'.platform-recommend-runtime-viewport',
|
||||
);
|
||||
expect(viewportBlock).toContain('z-index: 1;');
|
||||
expect(viewportBlock).toContain('isolation: isolate;');
|
||||
|
||||
const coverBlock = getCssBlock(css, '.platform-recommend-runtime-cover');
|
||||
expect(coverBlock).toContain('z-index: 30;');
|
||||
expect(coverBlock).toContain('isolation: isolate;');
|
||||
expect(coverBlock).not.toContain('transition: opacity');
|
||||
|
||||
const loadingBlock = getCssBlock(
|
||||
css,
|
||||
'.platform-recommend-runtime-loading',
|
||||
);
|
||||
expect(loadingBlock).toContain('position: absolute;');
|
||||
expect(loadingBlock).toContain('z-index: 4;');
|
||||
|
||||
const loadingAnimationBlock = getCssBlock(
|
||||
css,
|
||||
'.platform-recommend-runtime-loading::before',
|
||||
);
|
||||
expect(loadingAnimationBlock).toContain(
|
||||
'animation: platform-recommend-runtime-loading 1.15s ease-in-out infinite;',
|
||||
);
|
||||
expect(css).toContain('@keyframes platform-recommend-runtime-loading');
|
||||
|
||||
const hiddenCoverBlock = getCssBlock(
|
||||
css,
|
||||
'.platform-recommend-runtime-cover--hidden',
|
||||
);
|
||||
expect(hiddenCoverBlock).toContain('transition: opacity 420ms ease;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,59 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
calculateMobileKeyboardFocusShift,
|
||||
isEditableKeyboardTarget,
|
||||
resolveMobileKeyboardExposedFill,
|
||||
resolveMobileKeyboardState,
|
||||
stabilizeMobileViewportKeyboardFocus,
|
||||
} from './mobileViewportKeyboardFocus';
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
const originalInnerHeight = window.innerHeight;
|
||||
const originalMaxTouchPoints = navigator.maxTouchPoints;
|
||||
const originalVisualViewport = window.visualViewport;
|
||||
|
||||
function defineWindowValue<Key extends keyof Window>(key: Key, value: Window[Key]) {
|
||||
Object.defineProperty(window, key, {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function defineNavigatorValue<Key extends keyof Navigator>(
|
||||
key: Key,
|
||||
value: Navigator[Key],
|
||||
) {
|
||||
Object.defineProperty(navigator, key, {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.documentElement.removeAttribute('data-mobile-keyboard-open');
|
||||
document.documentElement.removeAttribute('data-mobile-viewport-keyboard-focus');
|
||||
document.documentElement.removeAttribute('style');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = '';
|
||||
document.documentElement.removeAttribute('data-mobile-keyboard-open');
|
||||
document.documentElement.removeAttribute('data-mobile-viewport-keyboard-focus');
|
||||
document.documentElement.removeAttribute('style');
|
||||
defineWindowValue('matchMedia', originalMatchMedia);
|
||||
defineWindowValue('requestAnimationFrame', originalRequestAnimationFrame);
|
||||
defineWindowValue('cancelAnimationFrame', originalCancelAnimationFrame);
|
||||
defineWindowValue('innerHeight', originalInnerHeight);
|
||||
defineNavigatorValue('maxTouchPoints', originalMaxTouchPoints);
|
||||
defineWindowValue('visualViewport', originalVisualViewport);
|
||||
});
|
||||
|
||||
describe('isEditableKeyboardTarget', () => {
|
||||
it('matches controls that open the mobile keyboard', () => {
|
||||
const input = document.createElement('input');
|
||||
@@ -31,47 +78,125 @@ describe('isEditableKeyboardTarget', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateMobileKeyboardFocusShift', () => {
|
||||
it('moves a bottom input above the visible keyboard area', () => {
|
||||
expect(
|
||||
calculateMobileKeyboardFocusShift({
|
||||
layoutHeight: 800,
|
||||
visualTop: 0,
|
||||
visualHeight: 500,
|
||||
targetTop: 720,
|
||||
targetBottom: 770,
|
||||
currentShift: 0,
|
||||
margin: 20,
|
||||
}),
|
||||
).toBe(290);
|
||||
describe('resolveMobileKeyboardExposedFill', () => {
|
||||
it('uses the active platform shell fill for exposed mini-program keyboard space', () => {
|
||||
document.body.innerHTML = `
|
||||
<div
|
||||
class="platform-viewport-shell"
|
||||
style="--platform-body-fill: linear-gradient(180deg, rgb(255, 253, 249), rgb(248, 239, 231));"
|
||||
></div>
|
||||
`;
|
||||
|
||||
expect(resolveMobileKeyboardExposedFill()).toContain('rgb(255, 253, 249)');
|
||||
});
|
||||
|
||||
it('does not move when the focused input is already visible', () => {
|
||||
expect(
|
||||
calculateMobileKeyboardFocusShift({
|
||||
layoutHeight: 800,
|
||||
visualTop: 0,
|
||||
visualHeight: 500,
|
||||
targetTop: 250,
|
||||
targetBottom: 300,
|
||||
currentShift: 0,
|
||||
margin: 20,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
it('falls back to the light platform fill before the shell mounts', () => {
|
||||
document.body.innerHTML = '';
|
||||
|
||||
it('caps movement to keyboard inset plus safety margin', () => {
|
||||
expect(
|
||||
calculateMobileKeyboardFocusShift({
|
||||
layoutHeight: 800,
|
||||
visualTop: 0,
|
||||
visualHeight: 500,
|
||||
targetTop: 790,
|
||||
targetBottom: 860,
|
||||
currentShift: 0,
|
||||
margin: 20,
|
||||
maxExtraShift: 20,
|
||||
}),
|
||||
).toBe(320);
|
||||
expect(resolveMobileKeyboardExposedFill()).toContain('#fffdf9');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMobileKeyboardState', () => {
|
||||
it('detects the keyboard inset without asking the app shell to shift', () => {
|
||||
expect(
|
||||
resolveMobileKeyboardState({
|
||||
layoutHeight: 800,
|
||||
visualTop: 0,
|
||||
visualHeight: 500,
|
||||
hasEditableTarget: true,
|
||||
}),
|
||||
).toEqual({
|
||||
isOpen: true,
|
||||
insetBottom: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it('stays closed when no editable target is focused', () => {
|
||||
expect(
|
||||
resolveMobileKeyboardState({
|
||||
layoutHeight: 800,
|
||||
visualTop: 0,
|
||||
visualHeight: 500,
|
||||
hasEditableTarget: false,
|
||||
}),
|
||||
).toEqual({
|
||||
isOpen: false,
|
||||
insetBottom: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('accounts for browser visual viewport panning without returning a shell offset', () => {
|
||||
expect(
|
||||
resolveMobileKeyboardState({
|
||||
layoutHeight: 800,
|
||||
visualTop: 120,
|
||||
visualHeight: 500,
|
||||
hasEditableTarget: true,
|
||||
}),
|
||||
).toEqual({
|
||||
isOpen: true,
|
||||
insetBottom: 180,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stabilizeMobileViewportKeyboardFocus', () => {
|
||||
it('marks H5 keyboard state without applying a global shell transform offset', () => {
|
||||
vi.useFakeTimers();
|
||||
document.body.innerHTML = `
|
||||
<div
|
||||
class="platform-viewport-shell"
|
||||
style="--platform-body-fill: linear-gradient(180deg, rgb(255, 253, 249), rgb(248, 239, 231));"
|
||||
>
|
||||
<input id="theme-input" />
|
||||
</div>
|
||||
`;
|
||||
defineNavigatorValue('maxTouchPoints', 1);
|
||||
defineWindowValue(
|
||||
'matchMedia',
|
||||
vi.fn().mockReturnValue({ matches: true }) as unknown as Window['matchMedia'],
|
||||
);
|
||||
defineWindowValue(
|
||||
'requestAnimationFrame',
|
||||
((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
}) as Window['requestAnimationFrame'],
|
||||
);
|
||||
defineWindowValue(
|
||||
'cancelAnimationFrame',
|
||||
vi.fn() as unknown as Window['cancelAnimationFrame'],
|
||||
);
|
||||
defineWindowValue('innerHeight', 800);
|
||||
defineWindowValue('visualViewport', {
|
||||
height: 500,
|
||||
offsetTop: 120,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as VisualViewport);
|
||||
|
||||
stabilizeMobileViewportKeyboardFocus();
|
||||
|
||||
document.getElementById('theme-input')?.focus();
|
||||
document.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(document.documentElement.dataset.mobileKeyboardOpen).toBe('true');
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue(
|
||||
'--platform-keyboard-focus-offset',
|
||||
),
|
||||
).toBe('0px');
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue(
|
||||
'--platform-keyboard-inset-bottom',
|
||||
),
|
||||
).toBe('180px');
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue(
|
||||
'--platform-keyboard-exposed-fill',
|
||||
),
|
||||
).toContain('rgb(255, 253, 249)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,31 +1,43 @@
|
||||
const MOBILE_POINTER_QUERY = '(pointer: coarse)';
|
||||
const KEYBOARD_OPEN_THRESHOLD_PX = 96;
|
||||
const FOCUS_MARGIN_PX = 18;
|
||||
const MIN_LAYOUT_VIEWPORT_HEIGHT_PX = 320;
|
||||
|
||||
const LAYOUT_HEIGHT_VAR = '--platform-layout-viewport-height';
|
||||
const KEYBOARD_FOCUS_OFFSET_VAR = '--platform-keyboard-focus-offset';
|
||||
const KEYBOARD_INSET_VAR = '--platform-keyboard-inset-bottom';
|
||||
const KEYBOARD_EXPOSED_FILL_VAR = '--platform-keyboard-exposed-fill';
|
||||
const KEYBOARD_EXPOSED_FILL_FALLBACK =
|
||||
'linear-gradient(180deg, #fffdf9 0%, #fdf9f5 54%, #f8efe7 100%)';
|
||||
|
||||
type KeyboardFocusShiftInput = {
|
||||
layoutHeight: number;
|
||||
visualTop: number;
|
||||
visualHeight: number;
|
||||
targetTop: number;
|
||||
targetBottom: number;
|
||||
currentShift: number;
|
||||
margin?: number;
|
||||
maxExtraShift?: number;
|
||||
hasEditableTarget: boolean;
|
||||
threshold?: number;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function readVisualViewport() {
|
||||
return typeof window !== 'undefined' ? window.visualViewport : undefined;
|
||||
}
|
||||
|
||||
export function resolveMobileKeyboardExposedFill() {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return KEYBOARD_EXPOSED_FILL_FALLBACK;
|
||||
}
|
||||
|
||||
const platformShell = document.querySelector('.platform-viewport-shell');
|
||||
if (!(platformShell instanceof HTMLElement)) {
|
||||
return KEYBOARD_EXPOSED_FILL_FALLBACK;
|
||||
}
|
||||
|
||||
const platformFill = window
|
||||
.getComputedStyle(platformShell)
|
||||
.getPropertyValue('--platform-body-fill')
|
||||
.trim();
|
||||
return platformFill || KEYBOARD_EXPOSED_FILL_FALLBACK;
|
||||
}
|
||||
|
||||
function readLayoutViewportHeight() {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return MIN_LAYOUT_VIEWPORT_HEIGHT_PX;
|
||||
@@ -110,34 +122,22 @@ export function isEditableKeyboardTarget(
|
||||
]).has(inputType);
|
||||
}
|
||||
|
||||
export function calculateMobileKeyboardFocusShift({
|
||||
export function resolveMobileKeyboardState({
|
||||
layoutHeight,
|
||||
visualTop,
|
||||
visualHeight,
|
||||
targetTop,
|
||||
targetBottom,
|
||||
currentShift,
|
||||
margin = FOCUS_MARGIN_PX,
|
||||
maxExtraShift = FOCUS_MARGIN_PX,
|
||||
hasEditableTarget,
|
||||
threshold = KEYBOARD_OPEN_THRESHOLD_PX,
|
||||
}: KeyboardFocusShiftInput) {
|
||||
const visualBottom = visualTop + visualHeight;
|
||||
const safeTop = visualTop + margin;
|
||||
const safeBottom = visualBottom - margin;
|
||||
const unshiftedTargetTop = targetTop + currentShift;
|
||||
const unshiftedTargetBottom = targetBottom + currentShift;
|
||||
let nextShift = currentShift;
|
||||
const insetBottom = Math.max(0, Math.round(layoutHeight - visualBottom));
|
||||
const isOpen =
|
||||
hasEditableTarget && layoutHeight - visualHeight > threshold;
|
||||
|
||||
if (unshiftedTargetBottom - nextShift > safeBottom) {
|
||||
nextShift = unshiftedTargetBottom - safeBottom;
|
||||
}
|
||||
|
||||
if (unshiftedTargetTop - nextShift < safeTop) {
|
||||
nextShift = Math.max(0, unshiftedTargetTop - safeTop);
|
||||
}
|
||||
|
||||
const keyboardInset = Math.max(0, layoutHeight - visualBottom);
|
||||
const maxShift = keyboardInset + maxExtraShift;
|
||||
return Math.round(clamp(nextShift, 0, Math.max(0, maxShift)));
|
||||
return {
|
||||
isOpen,
|
||||
insetBottom: isOpen ? insetBottom : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function stabilizeMobileViewportKeyboardFocus() {
|
||||
@@ -154,7 +154,6 @@ export function stabilizeMobileViewportKeyboardFocus() {
|
||||
const root = document.documentElement;
|
||||
const visualViewport = readVisualViewport();
|
||||
let stableLayoutHeight = readLayoutViewportHeight();
|
||||
let currentShift = 0;
|
||||
let frameId = 0;
|
||||
|
||||
const setLayoutHeight = (nextHeight: number) => {
|
||||
@@ -167,6 +166,10 @@ export function stabilizeMobileViewportKeyboardFocus() {
|
||||
|
||||
const setKeyboardState = (isOpen: boolean, insetBottom = 0) => {
|
||||
if (isOpen) {
|
||||
root.style.setProperty(
|
||||
KEYBOARD_EXPOSED_FILL_VAR,
|
||||
resolveMobileKeyboardExposedFill(),
|
||||
);
|
||||
root.dataset.mobileKeyboardOpen = 'true';
|
||||
} else {
|
||||
delete root.dataset.mobileKeyboardOpen;
|
||||
@@ -178,9 +181,8 @@ export function stabilizeMobileViewportKeyboardFocus() {
|
||||
);
|
||||
};
|
||||
|
||||
const setFocusShift = (nextShift: number) => {
|
||||
currentShift = Math.max(0, Math.round(nextShift));
|
||||
root.style.setProperty(KEYBOARD_FOCUS_OFFSET_VAR, `${currentShift}px`);
|
||||
const resetFocusShift = () => {
|
||||
root.style.setProperty(KEYBOARD_FOCUS_OFFSET_VAR, '0px');
|
||||
};
|
||||
|
||||
const readActiveTarget = () =>
|
||||
@@ -193,15 +195,16 @@ export function stabilizeMobileViewportKeyboardFocus() {
|
||||
const viewport = readVisualViewport();
|
||||
const visualTop = viewport?.offsetTop ?? 0;
|
||||
const visualHeight = viewport?.height ?? window.innerHeight;
|
||||
const visualBottom = visualTop + visualHeight;
|
||||
const keyboardInset = Math.max(0, stableLayoutHeight - visualBottom);
|
||||
const keyboardOpen =
|
||||
Boolean(activeTarget) &&
|
||||
stableLayoutHeight - visualHeight > KEYBOARD_OPEN_THRESHOLD_PX;
|
||||
const keyboardState = resolveMobileKeyboardState({
|
||||
layoutHeight: stableLayoutHeight,
|
||||
visualTop,
|
||||
visualHeight,
|
||||
hasEditableTarget: Boolean(activeTarget),
|
||||
});
|
||||
|
||||
if (!keyboardOpen || !activeTarget) {
|
||||
if (!keyboardState.isOpen || !activeTarget) {
|
||||
setKeyboardState(false);
|
||||
setFocusShift(0);
|
||||
resetFocusShift();
|
||||
|
||||
if (!activeTarget) {
|
||||
setLayoutHeight(readLayoutViewportHeight());
|
||||
@@ -209,19 +212,10 @@ export function stabilizeMobileViewportKeyboardFocus() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 中文注释:先保持整页布局高度,再只移动画布,让输入框避开键盘。
|
||||
const targetRect = activeTarget.getBoundingClientRect();
|
||||
const nextShift = calculateMobileKeyboardFocusShift({
|
||||
layoutHeight: stableLayoutHeight,
|
||||
visualTop,
|
||||
visualHeight,
|
||||
targetTop: targetRect.top,
|
||||
targetBottom: targetRect.bottom,
|
||||
currentShift,
|
||||
});
|
||||
|
||||
setKeyboardState(true, keyboardInset);
|
||||
setFocusShift(nextShift);
|
||||
// 中文注释:H5 浏览器和小程序 web-view 已会自行处理输入框可见性。
|
||||
// 这里只记录键盘状态、隐藏底部 dock,并给可能露出的宿主区域补浅色背景。
|
||||
setKeyboardState(true, keyboardState.insetBottom);
|
||||
resetFocusShift();
|
||||
};
|
||||
|
||||
const scheduleSync = () => {
|
||||
@@ -243,14 +237,14 @@ export function stabilizeMobileViewportKeyboardFocus() {
|
||||
|
||||
setLayoutHeight(stableLayoutHeight);
|
||||
setKeyboardState(false);
|
||||
setFocusShift(0);
|
||||
resetFocusShift();
|
||||
|
||||
document.addEventListener('focusin', scheduleKeyboardAnimationSync, true);
|
||||
document.addEventListener('focusout', scheduleKeyboardAnimationSync, true);
|
||||
window.addEventListener('resize', scheduleKeyboardAnimationSync);
|
||||
window.addEventListener('orientationchange', () => {
|
||||
setKeyboardState(false);
|
||||
setFocusShift(0);
|
||||
resetFocusShift();
|
||||
window.setTimeout(() => {
|
||||
setLayoutHeight(readLayoutViewportHeight());
|
||||
scheduleSync();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { PlatformCreationTypeId } from '../../packages/shared/src/contracts/playTypes';
|
||||
import { requestJson } from './apiClient';
|
||||
|
||||
/** 后端下发的单个创作类型入口配置,前端只据此展示和分流。 */
|
||||
export type CreationEntryTypeConfig = {
|
||||
id: string;
|
||||
id: PlatformCreationTypeId;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
@@ -27,7 +28,7 @@ export type UnifiedCreationField = {
|
||||
|
||||
/** 统一创作工作台契约,把入口类型映射到工作台、生成页和结果页阶段。 */
|
||||
export type UnifiedCreationSpec = {
|
||||
playId: string;
|
||||
playId: PlatformCreationTypeId;
|
||||
title: string;
|
||||
workspaceStage: string;
|
||||
generationStage: string;
|
||||
|
||||
@@ -188,9 +188,16 @@ export function executeJumpHopCreationAction(
|
||||
.then(normalizeJumpHopActionResponse);
|
||||
}
|
||||
|
||||
export async function getJumpHopWorkDetail(profileId: string) {
|
||||
export async function getJumpHopWorkDetail(
|
||||
profileId: string,
|
||||
options: { audience?: 'creation' | 'runtime' } = {},
|
||||
) {
|
||||
const base =
|
||||
options.audience === 'creation'
|
||||
? JUMP_HOP_WORKS_API_BASE
|
||||
: `${JUMP_HOP_RUNTIME_API_BASE}/works`;
|
||||
const response = await requestJson<JumpHopWorkDetailResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}`,
|
||||
`${base}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取跳一跳作品详情失败',
|
||||
);
|
||||
|
||||
@@ -30,6 +30,7 @@ const leaderboardResponse: JumpHopLeaderboardResponse = {
|
||||
{
|
||||
rank: 1,
|
||||
playerId: 'player-1',
|
||||
displayName: '玩家一号',
|
||||
successfulJumpCount: 10,
|
||||
durationMs: 3210,
|
||||
updatedAt: '2026-05-27T00:00:00Z',
|
||||
|
||||
@@ -530,6 +530,44 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('jump hop generation anchors hide unused style preset fallback', () => {
|
||||
const entries = buildJumpHopGenerationAnchorEntries({
|
||||
sessionId: 'jump-hop-session-style-hidden',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'generating',
|
||||
draft: {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'jump-hop-profile-style-hidden',
|
||||
themeText: '水果',
|
||||
workTitle: '水果跳一跳',
|
||||
workDescription: '水果主题跳一跳。',
|
||||
themeTags: ['水果'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
characterPrompt: '内置默认 3D 角色',
|
||||
tilePrompt: '',
|
||||
endMoodPrompt: null,
|
||||
characterAsset: null,
|
||||
tileAtlasAsset: null,
|
||||
tileAssets: [],
|
||||
path: null,
|
||||
coverComposite: null,
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
createdAt: '2026-06-06T10:00:00.000Z',
|
||||
updatedAt: '2026-06-06T10:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'jump-hop-theme',
|
||||
label: '主题',
|
||||
value: '水果',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('wooden fish draft generation exposes hit object, background and back button pipeline', () => {
|
||||
const state = createMiniGameDraftGenerationState('wooden-fish');
|
||||
|
||||
|
||||
@@ -1163,7 +1163,7 @@ export function buildJumpHopGenerationAnchorEntries(
|
||||
workTitle?: string;
|
||||
themeText?: string;
|
||||
characterPrompt?: string;
|
||||
stylePreset?: string;
|
||||
tilePrompt?: string;
|
||||
} | null;
|
||||
}
|
||||
| null
|
||||
@@ -1187,7 +1187,7 @@ export function buildJumpHopGenerationAnchorEntries(
|
||||
value:
|
||||
formPayload?.tilePrompt?.trim() ||
|
||||
config?.tilePrompt?.trim() ||
|
||||
draft?.stylePreset?.trim() ||
|
||||
draft?.tilePrompt?.trim() ||
|
||||
'',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function dragPuzzlePieceOrGroup(
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入推荐出的下一关。
|
||||
* 进入当前 run 的下一关。
|
||||
*/
|
||||
export async function advancePuzzleNextLevel(
|
||||
runId: string,
|
||||
@@ -101,10 +101,8 @@ export async function advancePuzzleNextLevel(
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const targetProfileId = payload.targetProfileId?.trim() ?? '';
|
||||
const preferSimilarWork = payload.preferSimilarWork === true;
|
||||
const requestPayload = {
|
||||
...(targetProfileId ? { targetProfileId } : {}),
|
||||
...(preferSimilarWork ? { preferSimilarWork: true } : {}),
|
||||
};
|
||||
const hasRequestPayload = Object.keys(requestPayload).length > 0;
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
|
||||
@@ -130,10 +130,10 @@ describe('recommended runtime guest launch clients', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('puzzle next level can carry preferSimilarWork through the runtime guest request', async () => {
|
||||
it('puzzle next level keeps the default current-run handoff without a request body', async () => {
|
||||
await advancePuzzleNextLevel(
|
||||
'run-puzzle-1',
|
||||
{ preferSimilarWork: true },
|
||||
{},
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
);
|
||||
|
||||
@@ -144,11 +144,10 @@ describe('recommended runtime guest launch clients', () => {
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ preferSimilarWork: true }),
|
||||
}),
|
||||
);
|
||||
expect(init.body).toBeUndefined();
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
|
||||
Reference in New Issue
Block a user