1
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
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 type { AuthUser } from '../../services/authService';
|
||||
@@ -113,6 +114,19 @@ function ProtectedActionButton({ onAuthenticated }: { onAuthenticated: () => voi
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformTabStateProbe() {
|
||||
const [tab, setTab] = useState<'home' | 'create'>('home');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>当前Tab:{tab === 'home' ? '首页' : '创作'}</div>
|
||||
<button type="button" onClick={() => setTab('create')}>
|
||||
创作
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
test('auth gate keeps platform content visible when phone login is available', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
@@ -208,6 +222,48 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
expect(screen.queryByRole('dialog', { name: '登录账号' })).toBeNull();
|
||||
});
|
||||
|
||||
test('auth state refresh keeps mounted platform content and local tab state', async () => {
|
||||
const user = userEvent.setup();
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<PlatformTabStateProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前Tab:首页')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
|
||||
|
||||
let resolveToken!: (token: string) => void;
|
||||
const tokenPromise = new Promise<string>((resolve) => {
|
||||
resolveToken = resolve;
|
||||
});
|
||||
authMocks.ensureStoredAccessToken.mockReturnValueOnce(tokenPromise);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('genarrative-auth-state-changed'));
|
||||
});
|
||||
|
||||
expect(screen.queryByText('正在校验登录状态...')).toBeNull();
|
||||
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveToken('jwt-refreshed-token');
|
||||
await tokenPromise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('auth gate shows sms send feedback in the login modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -239,7 +295,7 @@ test('auth gate shows sms send feedback in the login modal', async () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
within(dialog).getByText('验证码已发送,有效期约 5 分钟。'),
|
||||
within(dialog).getByText('短信请求已提交,请留意手机短信。验证码有效期约 5 分钟。'),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -90,10 +90,19 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
|
||||
const readyUser = status === 'ready' ? user : null;
|
||||
const hasRenderedPlatformContentRef = useRef(false);
|
||||
const canKeepPlatformContentMounted =
|
||||
hasRenderedPlatformContentRef.current &&
|
||||
(status === 'checking' || status === 'recovering');
|
||||
const readyUser =
|
||||
status === 'ready' || canKeepPlatformContentMounted ? user : null;
|
||||
const settings = useGameSettings(readyUser?.id ?? null);
|
||||
const platformThemeClass = `platform-theme--${settings.platformTheme}`;
|
||||
|
||||
if (status === 'ready' || status === 'unauthenticated') {
|
||||
hasRenderedPlatformContentRef.current = true;
|
||||
}
|
||||
|
||||
const activateReadyUser = useCallback((nextUser: AuthUser) => {
|
||||
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
|
||||
setUser(nextUser);
|
||||
@@ -380,6 +389,9 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const authUiValue = useMemo(
|
||||
() => ({
|
||||
user: readyUser,
|
||||
// 平台内容在 checking/recovering 阶段可以继续挂载,避免闪烁;
|
||||
// 但受保护请求只能在真实 ready 且存在用户时再启动。
|
||||
canAccessProtectedData: status === 'ready' && Boolean(readyUser),
|
||||
openLoginModal,
|
||||
requireAuth,
|
||||
openSettingsModal,
|
||||
@@ -402,6 +414,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
openSettingsModal,
|
||||
readyUser,
|
||||
requireAuth,
|
||||
status,
|
||||
settings.isHydratingSettings,
|
||||
settings.isPersistingSettings,
|
||||
settings.musicVolume,
|
||||
@@ -412,7 +425,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
],
|
||||
);
|
||||
|
||||
if (status === 'checking') {
|
||||
if (status === 'checking' && !canKeepPlatformContentMounted) {
|
||||
return (
|
||||
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
|
||||
正在校验登录状态...
|
||||
@@ -420,7 +433,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'recovering') {
|
||||
if (status === 'recovering' && !canKeepPlatformContentMounted) {
|
||||
return (
|
||||
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
|
||||
正在自动创建或恢复账号...
|
||||
@@ -485,7 +498,11 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'ready' && status !== 'unauthenticated') {
|
||||
if (
|
||||
status !== 'ready' &&
|
||||
status !== 'unauthenticated' &&
|
||||
!canKeepPlatformContentMounted
|
||||
) {
|
||||
return (
|
||||
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}>
|
||||
<div className="platform-auth-card max-w-md rounded-3xl px-6 py-7 text-center">
|
||||
|
||||
@@ -12,6 +12,7 @@ export type PlatformSettingsSection =
|
||||
|
||||
type AuthUiContextValue = {
|
||||
user: AuthUser | null;
|
||||
canAccessProtectedData: boolean;
|
||||
openLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||
requireAuth: (action: () => void) => void;
|
||||
openSettingsModal: (section?: PlatformSettingsSection) => void;
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { type CreationAgentTheme,CreationAgentWorkspace } from './CreationAgentWorkspace';
|
||||
import {
|
||||
type CreationAgentTheme,
|
||||
CreationAgentWorkspace,
|
||||
} from './CreationAgentWorkspace';
|
||||
|
||||
const testTheme: CreationAgentTheme = {
|
||||
accentTextClass: 'text-emerald-100',
|
||||
@@ -110,3 +113,95 @@ test('creation agent workspace renders streaming assistant text', () => {
|
||||
|
||||
expect(screen.getByText(/那我先顺着这个方向收一下/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation agent workspace hides anchors and primary action before completed progress', () => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: '统一共创',
|
||||
currentTurn: 2,
|
||||
progressPercent: 99,
|
||||
anchors: [
|
||||
{
|
||||
key: 'worldPromise',
|
||||
label: '世界承诺',
|
||||
value: '一个被潮雾改写航线秩序的群岛世界。',
|
||||
status: 'confirmed',
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '我们继续把设定收住。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '生成结果页' })).toBeNull();
|
||||
expect(screen.queryByText('世界承诺')).toBeNull();
|
||||
expect(screen.queryByText('一个被潮雾改写航线秩序的群岛世界。')).toBeNull();
|
||||
});
|
||||
|
||||
test('creation agent workspace shows primary and progress actions at completed progress', () => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: '统一共创',
|
||||
currentTurn: 2,
|
||||
progressPercent: 100,
|
||||
anchors: [],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '设定已经可以进入生成。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
quickActions={[
|
||||
{
|
||||
key: 'summarize',
|
||||
label: '总结当前设定',
|
||||
},
|
||||
{
|
||||
key: 'quickFill',
|
||||
label: '补全剩余设定',
|
||||
minTurn: 2,
|
||||
},
|
||||
]}
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '生成结果页' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '总结当前设定' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '补全剩余设定' })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type CreationAgentProgressCopy,
|
||||
normalizeCreationAgentProgress,
|
||||
resolveCreationAgentProgressHint,
|
||||
resolveCreationAnchorStatusLabel,
|
||||
} from '../../services/creation-agent';
|
||||
|
||||
export type CreationAgentAnchorView = {
|
||||
@@ -209,32 +208,6 @@ function CreationAgentMessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
function CreationAgentAnchorChip({
|
||||
anchor,
|
||||
theme,
|
||||
}: {
|
||||
anchor: CreationAgentAnchorView;
|
||||
theme: CreationAgentTheme;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[1.25rem] border border-white/14 bg-white/8 px-3 py-3 text-left">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={`text-xs font-semibold tracking-[0.18em] ${theme.accentTextClass}`}
|
||||
>
|
||||
{anchor.label}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/12 px-2 py-1 text-[0.68rem] text-white/70">
|
||||
{resolveCreationAnchorStatusLabel(anchor.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-2 text-sm leading-5 text-white/86">
|
||||
{anchor.value || '等待补齐'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldShowQuickAction(
|
||||
action: CreationAgentQuickAction,
|
||||
session: CreationAgentSessionView,
|
||||
@@ -244,10 +217,6 @@ function shouldShowQuickAction(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!action.showWhenComplete && progress >= 100 && action.minProgress !== 100) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof action.minTurn === 'number' && session.currentTurn < action.minTurn) {
|
||||
return false;
|
||||
}
|
||||
@@ -298,6 +267,7 @@ export function CreationAgentWorkspace({
|
||||
}
|
||||
|
||||
const progress = normalizeCreationAgentProgress(session.progressPercent);
|
||||
const canShowPrimaryAction = progress >= 100;
|
||||
const visibleQuickActions = quickActions.filter((action) =>
|
||||
shouldShowQuickAction(action, session, progress),
|
||||
);
|
||||
@@ -330,15 +300,17 @@ export function CreationAgentWorkspace({
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onPrimaryAction}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-bold text-slate-950 shadow-lg disabled:cursor-not-allowed disabled:opacity-50 ${theme.accentButtonClass}`}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{primaryActionLabel}
|
||||
</button>
|
||||
{canShowPrimaryAction ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onPrimaryAction}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-bold text-slate-950 shadow-lg disabled:cursor-not-allowed disabled:opacity-50 ${theme.accentButtonClass}`}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{primaryActionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
@@ -389,18 +361,6 @@ export function CreationAgentWorkspace({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{session.anchors.length > 0 ? (
|
||||
<div className={theme.anchorGridClass || 'grid gap-2 sm:grid-cols-2 xl:grid-cols-4'}>
|
||||
{session.anchors.map((anchor) => (
|
||||
<CreationAgentAnchorChip
|
||||
key={anchor.key}
|
||||
anchor={anchor}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CreationAgentOperationBanner operation={activeOperation} />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.6rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]">
|
||||
|
||||
@@ -160,6 +160,25 @@ test('workspace exposes draft action when progress reaches 100', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('workspace hides draft action before progress reaches 100', () => {
|
||||
render(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={{
|
||||
...baseSession,
|
||||
progressPercent: 99,
|
||||
}}
|
||||
activeOperation={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '生成游戏设定草稿' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('workspace submits recommended reply from thread', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitMessage = vi.fn();
|
||||
|
||||
@@ -23,8 +23,12 @@ type CustomWorldCreationHubProps = {
|
||||
onCreateType: (type: PlatformCreationTypeId) => void;
|
||||
onOpenDraft: (item: CustomWorldWorkSummary) => void;
|
||||
onEnterPublished: (profileId: string) => void;
|
||||
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
|
||||
deletingWorkId?: string | null;
|
||||
onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null;
|
||||
puzzleItems?: PuzzleWorkSummary[];
|
||||
onOpenPuzzleDetail?: (profileId: string) => void;
|
||||
onExperiencePuzzle?: ((profileId: string) => void) | null;
|
||||
};
|
||||
|
||||
function EmptyState({ title }: { title: string }) {
|
||||
@@ -47,8 +51,12 @@ export function CustomWorldCreationHub({
|
||||
onCreateType,
|
||||
onOpenDraft,
|
||||
onEnterPublished,
|
||||
onDeletePublished = null,
|
||||
deletingWorkId = null,
|
||||
onExperienceRpg = null,
|
||||
puzzleItems = [],
|
||||
onOpenPuzzleDetail,
|
||||
onExperiencePuzzle = null,
|
||||
}: CustomWorldCreationHubProps) {
|
||||
const [activeFilter, setActiveFilter] =
|
||||
useState<CustomWorldWorkFilter>('all');
|
||||
@@ -134,7 +142,7 @@ export function CustomWorldCreationHub({
|
||||
<CustomWorldWorkCard
|
||||
key={`${item.kind}-${item.item.workId}`}
|
||||
item={item}
|
||||
onClick={() => {
|
||||
onOpen={() => {
|
||||
if (item.kind === 'puzzle') {
|
||||
onOpenPuzzleDetail?.(item.item.profileId);
|
||||
return;
|
||||
@@ -152,6 +160,29 @@ export function CustomWorldCreationHub({
|
||||
onEnterPublished(item.item.profileId);
|
||||
}
|
||||
}}
|
||||
onExperience={
|
||||
item.kind === 'puzzle'
|
||||
? item.item.publicationStatus === 'published'
|
||||
? () => {
|
||||
onExperiencePuzzle?.(item.item.profileId);
|
||||
}
|
||||
: null
|
||||
: item.item.status === 'published' && item.item.canEnterWorld
|
||||
? () => {
|
||||
onExperienceRpg?.(item.item);
|
||||
}
|
||||
: null
|
||||
}
|
||||
onDelete={
|
||||
item.kind === 'rpg' &&
|
||||
item.item.status === 'published' &&
|
||||
item.item.profileId
|
||||
? () => {
|
||||
onDeletePublished?.(item.item);
|
||||
}
|
||||
: null
|
||||
}
|
||||
deleteBusy={deletingWorkId === item.item.workId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -28,25 +28,31 @@ export type UnifiedCreationWorkItem =
|
||||
|
||||
type CustomWorldWorkCardProps = {
|
||||
item: UnifiedCreationWorkItem;
|
||||
onClick: () => void;
|
||||
onOpen: () => void;
|
||||
onExperience?: (() => void) | null;
|
||||
onDelete?: (() => void) | null;
|
||||
deleteBusy?: boolean;
|
||||
};
|
||||
|
||||
export function CustomWorldWorkCard({
|
||||
item,
|
||||
onClick,
|
||||
onOpen,
|
||||
onExperience = null,
|
||||
onDelete = null,
|
||||
deleteBusy = false,
|
||||
}: CustomWorldWorkCardProps) {
|
||||
const isPuzzle = item.kind === 'puzzle';
|
||||
const isDraft =
|
||||
item.kind === 'puzzle'
|
||||
? item.item.publicationStatus === 'draft'
|
||||
: item.item.status === 'draft';
|
||||
const actionLabel = isPuzzle
|
||||
const openActionLabel = isPuzzle
|
||||
? '查看详情'
|
||||
: isDraft
|
||||
? item.item.playableNpcCount > 0 || item.item.landmarkCount > 0
|
||||
? '继续完善'
|
||||
: '继续创作'
|
||||
: '进入世界';
|
||||
: '查看详情';
|
||||
const title = isPuzzle ? item.item.levelName : item.item.title;
|
||||
const subtitle = isPuzzle ? item.item.authorDisplayName : item.item.subtitle;
|
||||
const summary = item.item.summary;
|
||||
@@ -153,13 +159,34 @@ export function CustomWorldWorkCard({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
{openActionLabel}
|
||||
</button>
|
||||
{onExperience ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExperience}
|
||||
className="platform-button platform-button--secondary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
体验
|
||||
</button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={deleteBusy}
|
||||
className="platform-button platform-button--danger min-h-0 shrink-0 rounded-full px-4 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{deleteBusy ? '删除中...' : '删除'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,9 +45,7 @@ import {
|
||||
getPuzzleAgentSession,
|
||||
streamPuzzleAgentMessage,
|
||||
} from '../../services/puzzle-agent';
|
||||
import {
|
||||
getPuzzleGalleryDetail,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import { getPuzzleGalleryDetail } from '../../services/puzzle-gallery';
|
||||
import {
|
||||
advancePuzzleNextLevel,
|
||||
dragPuzzlePieceOrGroup,
|
||||
@@ -55,6 +53,7 @@ import {
|
||||
swapPuzzlePieces,
|
||||
} from '../../services/puzzle-runtime';
|
||||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
|
||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
@@ -105,9 +104,7 @@ const CustomWorldAgentWorkspace = lazy(async () => {
|
||||
});
|
||||
|
||||
const BigFishAgentWorkspace = lazy(async () => {
|
||||
const module = await import(
|
||||
'../big-fish-creation/BigFishAgentWorkspace'
|
||||
);
|
||||
const module = await import('../big-fish-creation/BigFishAgentWorkspace');
|
||||
return {
|
||||
default: module.BigFishAgentWorkspace,
|
||||
};
|
||||
@@ -148,9 +145,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
}: PlatformEntryFlowShellProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
|
||||
CustomWorldLibraryEntry<CustomWorldProfile> | null
|
||||
>(null);
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
||||
const [bigFishSession, setBigFishSession] =
|
||||
useState<BigFishSessionSnapshotResponse | null>(null);
|
||||
const [bigFishRun, setBigFishRun] =
|
||||
@@ -172,6 +168,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [puzzleError, setPuzzleError] = useState<string | null>(null);
|
||||
const [isPuzzleBusy, setIsPuzzleBusy] = useState(false);
|
||||
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
|
||||
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [streamingPuzzleReplyText, setStreamingPuzzleReplyText] = useState('');
|
||||
const [isStreamingPuzzleReply, setIsStreamingPuzzleReply] = useState(false);
|
||||
const hasInitialAgentSession = Boolean(
|
||||
@@ -180,6 +179,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const platformBootstrap = usePlatformEntryBootstrap({
|
||||
user: authUi?.user,
|
||||
canAccessProtectedData: authUi?.canAccessProtectedData,
|
||||
getProfileDashboard: getPlatformProfileDashboard,
|
||||
handleContinueGame,
|
||||
hasInitialAgentSession,
|
||||
@@ -241,8 +241,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setGeneratedCustomWorldProfile:
|
||||
sessionController.setGeneratedCustomWorldProfile,
|
||||
setCustomWorldError: sessionController.setCustomWorldError,
|
||||
setCustomWorldAutoSaveError: autosaveCoordinator.setCustomWorldAutoSaveError,
|
||||
setCustomWorldAutoSaveState: autosaveCoordinator.setCustomWorldAutoSaveState,
|
||||
setCustomWorldAutoSaveError:
|
||||
autosaveCoordinator.setCustomWorldAutoSaveError,
|
||||
setCustomWorldAutoSaveState:
|
||||
autosaveCoordinator.setCustomWorldAutoSaveState,
|
||||
setCustomWorldGenerationViewSource:
|
||||
sessionController.setCustomWorldGenerationViewSource,
|
||||
setCustomWorldResultViewSource:
|
||||
@@ -261,7 +263,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
sessionController.suppressAgentDraftResultAutoOpen,
|
||||
releaseAgentDraftResultAutoOpenSuppression:
|
||||
sessionController.releaseAgentDraftResultAutoOpenSuppression,
|
||||
resetAutoSaveTrackingToIdle: autosaveCoordinator.resetAutoSaveTrackingToIdle,
|
||||
resetAutoSaveTrackingToIdle:
|
||||
autosaveCoordinator.resetAutoSaveTrackingToIdle,
|
||||
markAutoSavedProfile: autosaveCoordinator.markAutoSavedProfile,
|
||||
});
|
||||
|
||||
@@ -276,7 +279,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
autosaveCoordinator.executeAgentActionAndWait({
|
||||
action: 'publish_world',
|
||||
}),
|
||||
syncAgentDraftResultProfile: autosaveCoordinator.syncAgentDraftResultProfile,
|
||||
syncAgentDraftResultProfile:
|
||||
autosaveCoordinator.syncAgentDraftResultProfile,
|
||||
setGeneratedCustomWorldProfile:
|
||||
sessionController.setGeneratedCustomWorldProfile,
|
||||
});
|
||||
@@ -290,7 +294,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
: [],
|
||||
[sessionController.generatedCustomWorldProfile],
|
||||
);
|
||||
const agentResultPreview = sessionController.agentSession?.resultPreview ?? null;
|
||||
const agentResultPreview =
|
||||
sessionController.agentSession?.resultPreview ?? null;
|
||||
const agentResultPreviewBlockers = useMemo(
|
||||
() => agentResultPreview?.blockers?.map((entry) => entry.message) ?? [],
|
||||
[agentResultPreview],
|
||||
@@ -320,7 +325,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
const creationHubItems =
|
||||
platformBootstrap.customWorldWorkEntries.length > 0
|
||||
? platformBootstrap.customWorldWorkEntries
|
||||
: buildCreationHubFallbackItems(platformBootstrap.savedCustomWorldEntries);
|
||||
: buildCreationHubFallbackItems(
|
||||
platformBootstrap.savedCustomWorldEntries,
|
||||
);
|
||||
const resultViewError =
|
||||
autosaveCoordinator.customWorldAutoSaveError ??
|
||||
sessionController.customWorldError;
|
||||
@@ -346,9 +353,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
}
|
||||
if (selectionStage === 'big-fish-runtime' && !bigFishRun) {
|
||||
setSelectionStage(
|
||||
bigFishSession?.draft ? 'big-fish-result' : 'platform',
|
||||
);
|
||||
setSelectionStage(bigFishSession?.draft ? 'big-fish-result' : 'platform');
|
||||
}
|
||||
}, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]);
|
||||
|
||||
@@ -375,11 +380,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
sessionController.setCreationTypeError(null);
|
||||
return true;
|
||||
}, [
|
||||
handleStartNewGame,
|
||||
hasSavedGame,
|
||||
sessionController,
|
||||
]);
|
||||
}, [handleStartNewGame, hasSavedGame, sessionController]);
|
||||
|
||||
const openCreationTypePicker = useCallback(() => {
|
||||
if (!prepareCreationLaunch()) {
|
||||
@@ -626,11 +627,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsStreamingPuzzleReply(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
isStreamingPuzzleReply,
|
||||
puzzleSession,
|
||||
resolvePuzzleErrorMessage,
|
||||
],
|
||||
[isStreamingPuzzleReply, puzzleSession, resolvePuzzleErrorMessage],
|
||||
);
|
||||
|
||||
const executeBigFishAction = useCallback(
|
||||
@@ -687,13 +684,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
await refreshPuzzleShelf();
|
||||
}
|
||||
|
||||
const { session } = await getPuzzleAgentSession(puzzleSession.sessionId);
|
||||
const { session } = await getPuzzleAgentSession(
|
||||
puzzleSession.sessionId,
|
||||
);
|
||||
setPuzzleSession(session);
|
||||
|
||||
if (payload.action === 'compile_puzzle_draft') {
|
||||
setSelectionStage('puzzle-result');
|
||||
}
|
||||
if (payload.action === 'publish_puzzle_work' && session.publishedProfileId) {
|
||||
if (
|
||||
payload.action === 'publish_puzzle_work' &&
|
||||
session.publishedProfileId
|
||||
) {
|
||||
const galleryDetail = await getPuzzleGalleryDetail(
|
||||
session.publishedProfileId,
|
||||
);
|
||||
@@ -701,9 +703,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('puzzle-gallery-detail');
|
||||
}
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '执行拼图操作失败。'),
|
||||
);
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '执行拼图操作失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
@@ -757,9 +757,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRun(run);
|
||||
setSelectionStage('puzzle-runtime');
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'),
|
||||
);
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
@@ -803,9 +801,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
const { run } = await swapPuzzlePieces(puzzleRun.runId, payload);
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '交换拼图块失败。'),
|
||||
);
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
@@ -830,9 +826,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
const { run } = await dragPuzzlePieceOrGroup(puzzleRun.runId, payload);
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '拖动拼图块失败。'),
|
||||
);
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
@@ -852,9 +846,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
const { run } = await advancePuzzleNextLevel(puzzleRun.runId);
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '进入下一关失败。'),
|
||||
);
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '进入下一关失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
@@ -927,6 +919,68 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
}, [handleCustomWorldSelect, runProtectedAction, selectedDetailEntry]);
|
||||
|
||||
const handleExperienceRpgWork = useCallback(
|
||||
(work: (typeof creationHubItems)[number]) => {
|
||||
if (!work.profileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
const matchedEntry = platformBootstrap.savedCustomWorldEntries.find(
|
||||
(entry) => entry.profileId === work.profileId,
|
||||
);
|
||||
if (!matchedEntry) {
|
||||
platformBootstrap.setPlatformError('未找到可体验的作品,请刷新后重试。');
|
||||
return;
|
||||
}
|
||||
|
||||
handleCustomWorldSelect(matchedEntry.profile);
|
||||
});
|
||||
},
|
||||
[
|
||||
handleCustomWorldSelect,
|
||||
platformBootstrap,
|
||||
platformBootstrap.savedCustomWorldEntries,
|
||||
runProtectedAction,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDeletePublishedWork = useCallback(
|
||||
(work: (typeof creationHubItems)[number]) => {
|
||||
if (!work.profileId || deletingCreationWorkId) {
|
||||
return;
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
const confirmed = window.confirm(
|
||||
`确认删除作品《${work.title}》吗?删除后会从你的作品列表和公开广场中移除。`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingCreationWorkId(work.workId);
|
||||
platformBootstrap.setPlatformError(null);
|
||||
|
||||
void deleteRpgEntryWorldProfile(work.profileId)
|
||||
.then(async (entries) => {
|
||||
platformBootstrap.setSavedCustomWorldEntries(entries);
|
||||
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
|
||||
await platformBootstrap.refreshPublishedGallery().catch(() => []);
|
||||
})
|
||||
.catch((error) => {
|
||||
platformBootstrap.setPlatformError(
|
||||
resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeletingCreationWorkId(null);
|
||||
});
|
||||
});
|
||||
},
|
||||
[deletingCreationWorkId, platformBootstrap, runProtectedAction],
|
||||
);
|
||||
|
||||
const openPuzzleDetail = useCallback(
|
||||
async (profileId: string) => {
|
||||
setIsPuzzleBusy(true);
|
||||
@@ -938,9 +992,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
enterCreateTab();
|
||||
setSelectionStage('puzzle-gallery-detail');
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '读取拼图详情失败。'),
|
||||
);
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
@@ -950,13 +1002,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(platformBootstrap.platformTab === 'create' || selectionStage === 'platform') &&
|
||||
authUi?.user?.id
|
||||
(platformBootstrap.platformTab === 'create' ||
|
||||
selectionStage === 'platform') &&
|
||||
platformBootstrap.canReadProtectedData
|
||||
) {
|
||||
void refreshPuzzleShelf();
|
||||
}
|
||||
}, [
|
||||
authUi?.user?.id,
|
||||
platformBootstrap.canReadProtectedData,
|
||||
platformBootstrap.platformTab,
|
||||
refreshPuzzleShelf,
|
||||
selectionStage,
|
||||
@@ -969,9 +1022,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
error={
|
||||
platformBootstrap.isLoadingPlatform || isPuzzleLoadingLibrary
|
||||
? null
|
||||
: platformBootstrap.platformError ??
|
||||
sessionController.creationTypeError ??
|
||||
puzzleError
|
||||
: (platformBootstrap.platformError ??
|
||||
sessionController.agentWorkspaceRestoreError ??
|
||||
puzzleError)
|
||||
}
|
||||
onRetry={() => {
|
||||
platformBootstrap.setPlatformError(null);
|
||||
@@ -981,9 +1034,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
});
|
||||
}}
|
||||
createError={sessionController.creationTypeError ?? bigFishError ?? puzzleError}
|
||||
createError={
|
||||
sessionController.creationTypeError ?? bigFishError ?? puzzleError
|
||||
}
|
||||
createBusy={
|
||||
sessionController.isCreatingAgentSession || isBigFishBusy || isPuzzleBusy
|
||||
sessionController.isCreatingAgentSession ||
|
||||
isBigFishBusy ||
|
||||
isPuzzleBusy
|
||||
}
|
||||
onCreateType={handleCreationHubCreateType}
|
||||
onOpenDraft={(item) => {
|
||||
@@ -1002,12 +1059,24 @@ export function PlatformEntryFlowShellImpl({
|
||||
void detailNavigation.handleOpenCreationWork(matchedWork);
|
||||
});
|
||||
}}
|
||||
onDeletePublished={(item) => {
|
||||
handleDeletePublishedWork(item);
|
||||
}}
|
||||
deletingWorkId={deletingCreationWorkId}
|
||||
onExperienceRpg={(item) => {
|
||||
handleExperienceRpgWork(item);
|
||||
}}
|
||||
puzzleItems={puzzleWorks}
|
||||
onOpenPuzzleDetail={(profileId) => {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleDetail(profileId);
|
||||
});
|
||||
}}
|
||||
onExperiencePuzzle={(profileId) => {
|
||||
runProtectedAction(() => {
|
||||
void startPuzzleRunFromProfile(profileId);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1040,8 +1109,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
platformError={
|
||||
platformBootstrap.isLoadingPlatform
|
||||
? null
|
||||
: platformBootstrap.platformError ??
|
||||
sessionController.creationTypeError
|
||||
: (platformBootstrap.platformError ??
|
||||
sessionController.agentWorkspaceRestoreError)
|
||||
}
|
||||
dashboardError={
|
||||
platformBootstrap.isLoadingDashboard
|
||||
@@ -1175,7 +1244,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
{sessionController.isLoadingAgentSession
|
||||
? '正在准备 Agent 共创工作区...'
|
||||
: sessionController.creationTypeError || '正在恢复创作工作区...'}
|
||||
: sessionController.agentWorkspaceRestoreError ||
|
||||
'正在恢复创作工作区...'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1472,7 +1542,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
}}
|
||||
readOnly={false}
|
||||
compactAgentResultMode={sessionController.isAgentDraftResultView}
|
||||
compactAgentResultMode={
|
||||
sessionController.isAgentDraftResultView
|
||||
}
|
||||
backLabel={
|
||||
sessionController.isAgentDraftResultView
|
||||
? '返回创作'
|
||||
@@ -1515,10 +1587,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
<PlatformEntryCreationTypeModal
|
||||
isOpen={showCreationTypeModal}
|
||||
isBusy={
|
||||
sessionController.isCreatingAgentSession || isBigFishBusy || isPuzzleBusy
|
||||
sessionController.isCreatingAgentSession ||
|
||||
isBigFishBusy ||
|
||||
isPuzzleBusy
|
||||
}
|
||||
error={
|
||||
sessionController.creationTypeError ?? bigFishError ?? puzzleError
|
||||
bigFishError ?? puzzleError ?? sessionController.creationTypeError
|
||||
}
|
||||
onClose={() => {
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { 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';
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
unpublishRpgEntryWorldProfile,
|
||||
upsertRpgProfileBrowseHistory as upsertProfileBrowseHistory,
|
||||
} from '../../services/rpg-entry';
|
||||
import { createBigFishCreationSession } from '../../services/big-fish-creation';
|
||||
import { createPuzzleAgentSession } from '../../services/puzzle-agent';
|
||||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import type { GameState } from '../../types';
|
||||
import {
|
||||
AuthUiContext,
|
||||
@@ -39,6 +42,7 @@ import {
|
||||
} from '../auth/AuthUiContext';
|
||||
import {
|
||||
RpgEntryFlowShell,
|
||||
type RpgEntryFlowShellProps,
|
||||
type SelectionStage,
|
||||
} from './RpgEntryFlowShell';
|
||||
|
||||
@@ -63,13 +67,20 @@ async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
expect(await screen.findByText('角色扮演 RPG')).toBeTruthy();
|
||||
}
|
||||
|
||||
async function openNewRpgCreation(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
) {
|
||||
async function openNewRpgCreation(user: ReturnType<typeof userEvent.setup>) {
|
||||
await openCreationHub(user);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
}
|
||||
|
||||
function getPlatformTabPanel(tab: string) {
|
||||
const panel = document.getElementById(`platform-tab-panel-${tab}`);
|
||||
if (!panel) {
|
||||
throw new Error(`Missing platform tab panel: ${tab}`);
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
vi.mock('../../services/rpg-creation', () => ({
|
||||
createRpgCreationSession: vi.fn(),
|
||||
executeRpgCreationAction: vi.fn(),
|
||||
@@ -96,6 +107,23 @@ vi.mock('../../services/rpg-entry', () => ({
|
||||
upsertRpgProfileBrowseHistory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-works', () => ({
|
||||
listPuzzleWorks: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/big-fish-creation', () => ({
|
||||
createBigFishCreationSession: vi.fn(),
|
||||
executeBigFishCreationAction: vi.fn(),
|
||||
streamBigFishCreationMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-agent', () => ({
|
||||
createPuzzleAgentSession: vi.fn(),
|
||||
executePuzzleAgentAction: vi.fn(),
|
||||
getPuzzleAgentSession: vi.fn(),
|
||||
streamPuzzleAgentMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
|
||||
CustomWorldAgentWorkspace: ({
|
||||
session,
|
||||
@@ -379,6 +407,7 @@ const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
|
||||
|
||||
type TestAuthValue = {
|
||||
user: AuthUser | null;
|
||||
canAccessProtectedData: boolean;
|
||||
openLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||
requireAuth: (action: () => void) => void;
|
||||
openSettingsModal: (section?: PlatformSettingsSection) => void;
|
||||
@@ -393,9 +422,12 @@ type TestAuthValue = {
|
||||
settingsError: string | null;
|
||||
};
|
||||
|
||||
function createAuthValue(overrides: Partial<TestAuthValue> = {}): TestAuthValue {
|
||||
function createAuthValue(
|
||||
overrides: Partial<TestAuthValue> = {},
|
||||
): TestAuthValue {
|
||||
return {
|
||||
user: mockAuthUser,
|
||||
canAccessProtectedData: true,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: (action) => action(),
|
||||
openSettingsModal: () => {},
|
||||
@@ -416,10 +448,12 @@ function TestWrapper({
|
||||
withAuth = false,
|
||||
authValue,
|
||||
onContinueGame,
|
||||
onSelectWorld,
|
||||
}: {
|
||||
withAuth?: boolean;
|
||||
authValue?: TestAuthValue;
|
||||
onContinueGame?: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
onSelectWorld?: RpgEntryFlowShellProps['handleCustomWorldSelect'];
|
||||
} = {}) {
|
||||
const [selectionStage, setSelectionStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
@@ -433,7 +467,7 @@ function TestWrapper({
|
||||
savedSnapshot={null}
|
||||
handleContinueGame={onContinueGame ?? (() => {})}
|
||||
handleStartNewGame={() => {}}
|
||||
handleCustomWorldSelect={() => {}}
|
||||
handleCustomWorldSelect={onSelectWorld ?? (() => {})}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -442,9 +476,7 @@ function TestWrapper({
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
value={authValue ?? createAuthValue()}
|
||||
>
|
||||
<AuthUiContext.Provider value={authValue ?? createAuthValue()}>
|
||||
{content}
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
@@ -513,8 +545,106 @@ beforeEach(() => {
|
||||
vi.mocked(createRpgCreationSession).mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
vi.mocked(createBigFishCreationSession).mockResolvedValue({
|
||||
session: {
|
||||
sessionId: 'big-fish-session-1',
|
||||
currentTurn: 0,
|
||||
progressPercent: 0,
|
||||
stage: 'clarifying',
|
||||
anchorPack: {
|
||||
gameplayPromise: {
|
||||
key: 'gameplay_promise',
|
||||
label: '核心玩法',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
ecologyVisualTheme: {
|
||||
key: 'ecology_visual_theme',
|
||||
label: '生态视觉',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
growthLadder: {
|
||||
key: 'growth_ladder',
|
||||
label: '成长阶梯',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
riskTempo: {
|
||||
key: 'risk_tempo',
|
||||
label: '风险节奏',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
},
|
||||
draft: null,
|
||||
assetSlots: [],
|
||||
assetCoverage: {
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
requiredLevelCount: 0,
|
||||
publishReady: false,
|
||||
blockers: [],
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: '先说说你想要什么样的大鱼生态。',
|
||||
publishReady: false,
|
||||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||||
},
|
||||
});
|
||||
vi.mocked(createPuzzleAgentSession).mockResolvedValue({
|
||||
session: {
|
||||
sessionId: 'puzzle-session-1',
|
||||
currentTurn: 0,
|
||||
progressPercent: 0,
|
||||
stage: 'clarifying',
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'theme_promise',
|
||||
label: '主题承诺',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visual_subject',
|
||||
label: '视觉主体',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visual_mood',
|
||||
label: '视觉气质',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'composition_hooks',
|
||||
label: '构图钩子',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tags_and_forbidden',
|
||||
label: '标签与禁区',
|
||||
value: '',
|
||||
status: 'missing',
|
||||
},
|
||||
},
|
||||
draft: null,
|
||||
messages: [],
|
||||
lastAssistantReply: '先说一个你最想做成拼图的画面。',
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||||
},
|
||||
});
|
||||
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(listRpgCreationWorks).mockResolvedValue([]);
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(executeRpgCreationAction).mockResolvedValue({
|
||||
operation: {
|
||||
operationId: 'operation-draft-foundation-1',
|
||||
@@ -594,9 +724,7 @@ test('create tab opens compiled agent draft in result refinement page', async ()
|
||||
canEnterWorld: false,
|
||||
},
|
||||
]);
|
||||
vi.mocked(getRpgCreationSession).mockResolvedValue(
|
||||
compiledAgentDraftSession,
|
||||
);
|
||||
vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
@@ -606,12 +734,19 @@ test('create tab opens compiled agent draft in result refinement page', async ()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /继续完善/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('正在加载世界编辑器...')).toBeNull();
|
||||
}, { timeout: 5000 });
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByText('正在加载世界编辑器...')).toBeNull();
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy();
|
||||
expect(screen.queryByText('Agent工作区:custom-world-agent-session-1')).toBeNull();
|
||||
expect(
|
||||
await screen.findByText('世界档案', {}, { timeout: 5000 }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText('Agent工作区:custom-world-agent-session-1'),
|
||||
).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -706,7 +841,7 @@ test('opening a compiled draft with a missing agent session falls back to create
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
within(getPlatformTabPanel('create')).getByText(
|
||||
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
@@ -807,6 +942,88 @@ test('restoring an agent workspace while logged out opens login modal before loa
|
||||
expect(getRpgCreationSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('new creation entry maps raw bearer token errors to user-facing auth copy', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(createRpgCreationSession).mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '缺少 Authorization Bearer Token',
|
||||
status: 401,
|
||||
code: 'UNAUTHORIZED',
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(getPlatformTabPanel('create')).getByText(
|
||||
'当前登录状态已失效,请重新登录后继续。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(listPuzzleWorks).toHaveBeenCalled();
|
||||
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
|
||||
});
|
||||
|
||||
test('big fish creation timeout exits busy state and shows a readable error', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(createBigFishCreationSession).mockRejectedValueOnce(
|
||||
Object.assign(new Error('请求超时:15000ms'), {
|
||||
name: 'TimeoutError',
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
|
||||
const button = screen.getByRole('button', { name: /大鱼吃小鱼/u });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(getPlatformTabPanel('create')).getAllByText(
|
||||
'开启大鱼吃小鱼创作工作台超时,请确认运行时后端已启动后重试。',
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
expect((button as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.queryByText(/正在加载大鱼吃小鱼共创工作区/u)).toBeNull();
|
||||
});
|
||||
|
||||
test('puzzle creation timeout exits busy state and shows a readable error', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
|
||||
Object.assign(new Error('请求超时:15000ms'), {
|
||||
name: 'TimeoutError',
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
|
||||
const button = screen.getByRole('button', { name: /拼图玩法/u });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(getPlatformTabPanel('create')).getAllByText(
|
||||
'开启拼图创作工作台超时,请确认运行时后端已启动后重试。',
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
expect((button as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull();
|
||||
});
|
||||
|
||||
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -851,9 +1068,7 @@ test('existing draft sessions open result page refinement instead of agent dialo
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
vi.mocked(getRpgCreationSession).mockResolvedValue(
|
||||
compiledAgentDraftSession,
|
||||
);
|
||||
vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
@@ -913,9 +1128,7 @@ test('agent result view shows publish blockers and disables publish-enter action
|
||||
|
||||
await openNewRpgCreation(user);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/当前还有 1 个发布阻断项/u),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByText(/当前还有 1 个发布阻断项/u)).toBeTruthy();
|
||||
const actionButton = screen.getByRole('button', {
|
||||
name: /发布并进入世界/u,
|
||||
});
|
||||
@@ -991,7 +1204,7 @@ test('agent draft result publishes before entering world and uses published prev
|
||||
|
||||
return (
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<RpgEntryFlowShell
|
||||
<RpgEntryFlowShell
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
gameState={{} as GameState}
|
||||
@@ -1023,11 +1236,13 @@ test('agent draft result publishes before entering world and uses published prev
|
||||
);
|
||||
});
|
||||
expect(
|
||||
vi.mocked(executeRpgCreationAction).mock.calls.some(
|
||||
([sessionId, payload]) =>
|
||||
sessionId === 'custom-world-agent-session-1' &&
|
||||
payload?.action === 'sync_result_profile',
|
||||
),
|
||||
vi
|
||||
.mocked(executeRpgCreationAction)
|
||||
.mock.calls.some(
|
||||
([sessionId, payload]) =>
|
||||
sessionId === 'custom-world-agent-session-1' &&
|
||||
payload?.action === 'sync_result_profile',
|
||||
),
|
||||
).toBe(false);
|
||||
await waitFor(() => {
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
|
||||
@@ -1219,11 +1434,13 @@ test('agent draft result back button returns to creation hub without redundant s
|
||||
});
|
||||
|
||||
expect(
|
||||
vi.mocked(executeRpgCreationAction).mock.calls.some(
|
||||
([sessionId, payload]) =>
|
||||
sessionId === 'custom-world-agent-session-1' &&
|
||||
payload?.action === 'sync_result_profile',
|
||||
),
|
||||
vi
|
||||
.mocked(executeRpgCreationAction)
|
||||
.mock.calls.some(
|
||||
([sessionId, payload]) =>
|
||||
sessionId === 'custom-world-agent-session-1' &&
|
||||
payload?.action === 'sync_result_profile',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(screen.queryByText('世界档案')).toBeNull();
|
||||
});
|
||||
@@ -1366,7 +1583,9 @@ test('agent draft result auto-save persists the latest profile rebuilt from sync
|
||||
expect(upsertRpgWorldProfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const latestSavedProfile = vi.mocked(upsertRpgWorldProfile).mock.calls.at(-1)?.[0];
|
||||
const latestSavedProfile = vi
|
||||
.mocked(upsertRpgWorldProfile)
|
||||
.mock.calls.at(-1)?.[0];
|
||||
expect(latestSavedProfile?.name).toBe('潮雾列岛·session最新版');
|
||||
expect(latestSavedProfile?.summary).toBe(
|
||||
'作品库应该保存这份同步后的最新快照。',
|
||||
@@ -1391,7 +1610,8 @@ test('agent draft result can open from server result preview without embedded le
|
||||
settingText: '被海雾吞没的旧航路群岛',
|
||||
name: '潮雾列岛·服务端预览',
|
||||
subtitle: '结果页改为优先消费 session.resultPreview',
|
||||
summary: '即使 draft 中没有 legacyResultProfile,也应该正常打开结果页。',
|
||||
summary:
|
||||
'即使 draft 中没有 legacyResultProfile,也应该正常打开结果页。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||||
templateWorldType: 'WUXIA',
|
||||
@@ -1420,9 +1640,7 @@ test('agent draft result can open from server result preview without embedded le
|
||||
await waitFor(
|
||||
async () => {
|
||||
expect(await screen.findByText('世界档案')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('潮雾列岛·服务端预览'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('潮雾列岛·服务端预览')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('结果页改为优先消费 session.resultPreview'),
|
||||
).toBeTruthy();
|
||||
@@ -1454,6 +1672,37 @@ test('authenticated users with save archives default into the saves tab', async
|
||||
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
|
||||
});
|
||||
|
||||
test('manual tab switch is preserved after platform bootstrap requests finish', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let resolveGalleryRequest!: (value: []) => void;
|
||||
const delayedGalleryRequest = new Promise<[]>((resolve) => {
|
||||
resolveGalleryRequest = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(listRpgEntryWorldGallery).mockReturnValueOnce(
|
||||
delayedGalleryRequest as Promise<[]>,
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(await screen.findByText('角色扮演 RPG')).toBeTruthy();
|
||||
|
||||
resolveGalleryRequest([]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(getPlatformTabPanel('create')).getByText('角色扮演 RPG'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe(
|
||||
'false',
|
||||
);
|
||||
expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('true');
|
||||
});
|
||||
|
||||
test('save tab can resume a selected archive directly into the game', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleContinueGame = vi.fn();
|
||||
@@ -1504,7 +1753,7 @@ test('save tab can resume a selected archive directly into the game', async () =
|
||||
});
|
||||
});
|
||||
|
||||
test('owned world detail can delete a work and return to the create tab list', async () => {
|
||||
test('creation hub published work can open detail view before deleting from detail page', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
@@ -1572,7 +1821,7 @@ test('owned world detail can delete a work and return to the create tab list', a
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: /进入世界/u }));
|
||||
await user.click(await screen.findByRole('button', { name: /查看详情/u }));
|
||||
await user.click(await screen.findByRole('button', { name: '删除作品' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1650,9 +1899,168 @@ test('creation hub published work enters existing detail view', async () => {
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: /进入世界/u }));
|
||||
await user.click(await screen.findByRole('button', { name: /查看详情/u }));
|
||||
|
||||
expect(await screen.findByText('世界信息')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy();
|
||||
expect(screen.getByText('已发布')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation hub published work experience button enters world directly', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleCustomWorldSelect = vi.fn();
|
||||
|
||||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||||
{
|
||||
workId: 'published:world-experience-1',
|
||||
sourceType: 'published_profile',
|
||||
status: 'published',
|
||||
title: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '已经发布的群岛世界作品。',
|
||||
coverImageSrc: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||||
stage: null,
|
||||
stageLabel: '已发布',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
roleVisualReadyCount: 1,
|
||||
roleAnimationReadyCount: 0,
|
||||
roleAssetSummaryLabel: null,
|
||||
sessionId: null,
|
||||
profileId: 'world-experience-1',
|
||||
canResume: false,
|
||||
canEnterWorld: true,
|
||||
},
|
||||
]);
|
||||
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'world-experience-1',
|
||||
profile: {
|
||||
id: 'world-experience-1',
|
||||
name: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '已经发布的群岛世界作品。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清群岛旧案。',
|
||||
majorFactions: ['守灯会'],
|
||||
coreConflicts: ['假航灯正在扰乱航线'],
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
landmarks: [],
|
||||
} as never,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||||
authorDisplayName: '测试玩家',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '已经发布的群岛世界作品。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />,
|
||||
);
|
||||
|
||||
await openCreationHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: '体验' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'world-experience-1',
|
||||
name: '潮雾列岛',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByText('世界信息')).toBeNull();
|
||||
});
|
||||
|
||||
test('creation hub published work delete button removes the work directly from card list', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
const publishedWork = {
|
||||
workId: 'published:world-card-delete-1',
|
||||
sourceType: 'published_profile' as const,
|
||||
status: 'published' as const,
|
||||
title: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '用于测试卡片删除流程的作品。',
|
||||
coverImageSrc: null,
|
||||
coverRenderMode: 'image' as const,
|
||||
coverCharacterImageSrcs: [],
|
||||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||||
stage: null,
|
||||
stageLabel: '已发布',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
roleVisualReadyCount: 0,
|
||||
roleAnimationReadyCount: 0,
|
||||
roleAssetSummaryLabel: null,
|
||||
sessionId: null,
|
||||
profileId: 'world-card-delete-1',
|
||||
canResume: false,
|
||||
canEnterWorld: true,
|
||||
};
|
||||
const publishedLibraryEntry = {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'world-card-delete-1',
|
||||
profile: {
|
||||
id: 'world-card-delete-1',
|
||||
name: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '用于测试卡片删除流程的作品。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清旧案。',
|
||||
majorFactions: ['守灯会'],
|
||||
coreConflicts: ['雾潮正在逼近港口'],
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
landmarks: [],
|
||||
} as never,
|
||||
visibility: 'published' as const,
|
||||
publishedAt: '2026-04-16T12:00:00.000Z',
|
||||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||||
authorDisplayName: '测试玩家',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '用于测试卡片删除流程的作品。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide' as const,
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
};
|
||||
|
||||
vi.mocked(listRpgCreationWorks)
|
||||
.mockResolvedValueOnce([publishedWork])
|
||||
.mockResolvedValue([]);
|
||||
vi.mocked(listRpgEntryWorldLibrary)
|
||||
.mockResolvedValueOnce([publishedLibraryEntry])
|
||||
.mockResolvedValue([]);
|
||||
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: '删除' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteRpgEntryWorldProfile).toHaveBeenCalledWith(
|
||||
'world-card-delete-1',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('还没有作品')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import type {
|
||||
CustomWorldWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { ApiClientError, isTimeoutError } from '../../services/apiClient';
|
||||
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
@@ -11,6 +12,28 @@ export function resolveRpgEntryErrorMessage(
|
||||
error: unknown,
|
||||
fallback: string,
|
||||
) {
|
||||
if (isTimeoutError(error)) {
|
||||
if (/拼图/u.test(fallback)) {
|
||||
return '开启拼图创作工作台超时,请确认运行时后端已启动后重试。';
|
||||
}
|
||||
if (/大鱼吃小鱼/u.test(fallback)) {
|
||||
return '开启大鱼吃小鱼创作工作台超时,请确认运行时后端已启动后重试。';
|
||||
}
|
||||
if (/共创工作台/u.test(fallback)) {
|
||||
return '开启创作工作台超时,请确认运行时后端已启动后重试。';
|
||||
}
|
||||
return '请求超时,请稍后重试。';
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof ApiClientError &&
|
||||
error.status === 401 &&
|
||||
(error.code === 'UNAUTHORIZED' ||
|
||||
error.message.includes('Authorization Bearer Token'))
|
||||
) {
|
||||
return '当前登录状态已失效,请重新登录后继续。';
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ export function useRpgCreationSessionController(
|
||||
onSessionOpened,
|
||||
} = params;
|
||||
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
|
||||
const isHydratingInitialAgentWorkspaceRef = useRef(
|
||||
Boolean(initialAgentUiStateRef.current.activeSessionId),
|
||||
);
|
||||
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
|
||||
const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false);
|
||||
const isAgentDraftResultAutoOpenSuppressedRef = useRef(false);
|
||||
@@ -77,6 +80,8 @@ export function useRpgCreationSessionController(
|
||||
const [isStreamingAgentReply, setIsStreamingAgentReply] = useState(false);
|
||||
const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
|
||||
const [creationTypeError, setCreationTypeError] = useState<string | null>(null);
|
||||
const [agentWorkspaceRestoreError, setAgentWorkspaceRestoreError] =
|
||||
useState<string | null>(null);
|
||||
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
|
||||
useState<CustomWorldProfile | null>(null);
|
||||
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
|
||||
@@ -135,6 +140,8 @@ export function useRpgCreationSessionController(
|
||||
setIsLoadingAgentSession(false);
|
||||
setStreamingAgentReplyText('');
|
||||
setIsStreamingAgentReply(false);
|
||||
setAgentWorkspaceRestoreError(null);
|
||||
isHydratingInitialAgentWorkspaceRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -144,16 +151,22 @@ export function useRpgCreationSessionController(
|
||||
setIsLoadingAgentSession(false);
|
||||
setStreamingAgentReplyText('');
|
||||
setIsStreamingAgentReply(false);
|
||||
setAgentWorkspaceRestoreError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const isInitialWorkspaceRestore =
|
||||
isHydratingInitialAgentWorkspaceRef.current &&
|
||||
activeAgentSessionId === initialAgentUiStateRef.current.activeSessionId;
|
||||
setIsLoadingAgentSession(true);
|
||||
|
||||
void syncAgentSessionSnapshot(activeAgentSessionId)
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
setCreationTypeError(null);
|
||||
setAgentWorkspaceRestoreError(null);
|
||||
isHydratingInitialAgentWorkspaceRef.current = false;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -161,13 +174,20 @@ export function useRpgCreationSessionController(
|
||||
return;
|
||||
}
|
||||
|
||||
setCreationTypeError(
|
||||
resolveRpgCreationErrorMessage(error, '读取 Agent 共创工作区失败。'),
|
||||
);
|
||||
// 登录后自动恢复的是“上一次残留的工作区指针”,
|
||||
// 这里失败时应优先静默清理,避免把旧恢复错误冒充成当前登录已失效。
|
||||
if (isInitialWorkspaceRestore) {
|
||||
setAgentWorkspaceRestoreError(null);
|
||||
} else {
|
||||
setAgentWorkspaceRestoreError(
|
||||
resolveRpgCreationErrorMessage(error, '读取 Agent 共创工作区失败。'),
|
||||
);
|
||||
}
|
||||
setAgentSession(null);
|
||||
setAgentOperation(null);
|
||||
setStreamingAgentReplyText('');
|
||||
setIsStreamingAgentReply(false);
|
||||
isHydratingInitialAgentWorkspaceRef.current = false;
|
||||
persistAgentUiState(null, null);
|
||||
enterCreateTab?.();
|
||||
setSelectionStage('platform');
|
||||
@@ -353,6 +373,7 @@ export function useRpgCreationSessionController(
|
||||
const { session } = await createRpgCreationSession(
|
||||
seedText ? { seedText } : {},
|
||||
);
|
||||
isHydratingInitialAgentWorkspaceRef.current = false;
|
||||
setAgentSession(session);
|
||||
setAgentOperation(null);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
@@ -539,6 +560,7 @@ export function useRpgCreationSessionController(
|
||||
isLoadingAgentSession,
|
||||
creationTypeError,
|
||||
setCreationTypeError,
|
||||
agentWorkspaceRestoreError,
|
||||
customWorldError,
|
||||
setCustomWorldError,
|
||||
generatedCustomWorldProfile,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { resolveRpgEntryErrorMessage } from './rpgEntryShared';
|
||||
|
||||
type UseRpgEntryBootstrapParams = {
|
||||
user: AuthUser | null | undefined;
|
||||
canAccessProtectedData?: boolean | undefined;
|
||||
getProfileDashboard: () => Promise<ProfileDashboardSummary | null>;
|
||||
handleContinueGame: (
|
||||
snapshot?: HydratedSavedGameSnapshot | null,
|
||||
@@ -38,12 +39,19 @@ type UseRpgEntryBootstrapParams = {
|
||||
export function useRpgEntryBootstrap(
|
||||
params: UseRpgEntryBootstrapParams,
|
||||
) {
|
||||
const { user, getProfileDashboard, handleContinueGame, hasInitialAgentSession } =
|
||||
params;
|
||||
const {
|
||||
user,
|
||||
canAccessProtectedData = Boolean(user),
|
||||
getProfileDashboard,
|
||||
handleContinueGame,
|
||||
hasInitialAgentSession,
|
||||
} = params;
|
||||
const isAuthenticated = Boolean(user);
|
||||
const canReadProtectedData = Boolean(user) && canAccessProtectedData;
|
||||
const platformTabBootstrapUserIdRef = useRef<string | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const hasExplicitPlatformTabSelectionRef = useRef(false);
|
||||
|
||||
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
|
||||
CustomWorldLibraryEntry<CustomWorldProfile>[]
|
||||
@@ -58,7 +66,7 @@ export function useRpgEntryBootstrap(
|
||||
PlatformBrowseHistoryEntry[]
|
||||
>([]);
|
||||
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>([]);
|
||||
const [platformTab, setPlatformTab] = useState<PlatformHomeTab>('home');
|
||||
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);
|
||||
@@ -71,8 +79,15 @@ export function useRpgEntryBootstrap(
|
||||
const [profileDashboard, setProfileDashboard] =
|
||||
useState<ProfileDashboardSummary | null>(null);
|
||||
|
||||
const setPlatformTab = useCallback((nextTab: PlatformHomeTab) => {
|
||||
// 区分“平台首屏默认落点”和“用户/流程显式切换”。
|
||||
// 一旦显式切过 Tab,就不能再被首屏异步请求回刷成首页或存档。
|
||||
hasExplicitPlatformTabSelectionRef.current = true;
|
||||
setPlatformTabState(nextTab);
|
||||
}, []);
|
||||
|
||||
const refreshProfileDashboard = useCallback(async () => {
|
||||
if (!user) {
|
||||
if (!user || !canReadProtectedData) {
|
||||
setProfileDashboard(null);
|
||||
setDashboardError(null);
|
||||
setIsLoadingDashboard(false);
|
||||
@@ -91,10 +106,10 @@ export function useRpgEntryBootstrap(
|
||||
} finally {
|
||||
setIsLoadingDashboard(false);
|
||||
}
|
||||
}, [getProfileDashboard, user]);
|
||||
}, [canReadProtectedData, getProfileDashboard, user]);
|
||||
|
||||
const refreshCustomWorldWorks = useCallback(async () => {
|
||||
if (!user) {
|
||||
if (!user || !canReadProtectedData) {
|
||||
setCustomWorldWorkEntries([]);
|
||||
return [];
|
||||
}
|
||||
@@ -102,7 +117,7 @@ export function useRpgEntryBootstrap(
|
||||
const nextItems = await listRpgCreationWorks();
|
||||
setCustomWorldWorkEntries(nextItems);
|
||||
return nextItems;
|
||||
}, [user]);
|
||||
}, [canReadProtectedData, user]);
|
||||
|
||||
const refreshPublishedGallery = useCallback(async () => {
|
||||
const nextEntries = await listRpgEntryWorldGallery();
|
||||
@@ -111,7 +126,7 @@ export function useRpgEntryBootstrap(
|
||||
}, []);
|
||||
|
||||
const refreshSavedCustomWorldLibrary = useCallback(async () => {
|
||||
if (!user) {
|
||||
if (!user || !canReadProtectedData) {
|
||||
setSavedCustomWorldEntries([]);
|
||||
return [];
|
||||
}
|
||||
@@ -119,7 +134,7 @@ export function useRpgEntryBootstrap(
|
||||
const nextEntries = await listRpgEntryWorldLibrary();
|
||||
setSavedCustomWorldEntries(nextEntries);
|
||||
return nextEntries;
|
||||
}, [user]);
|
||||
}, [canReadProtectedData, user]);
|
||||
|
||||
const appendBrowseHistoryEntry = useCallback(
|
||||
async (entry: PlatformBrowseHistoryWriteEntry) => {
|
||||
@@ -139,7 +154,7 @@ export function useRpgEntryBootstrap(
|
||||
|
||||
const handleResumeSaveEntry = useCallback(
|
||||
async (entry: ProfileSaveArchiveSummary) => {
|
||||
if (!user || isResumingSaveWorldKey) {
|
||||
if (!user || !canReadProtectedData || isResumingSaveWorldKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,11 +177,20 @@ export function useRpgEntryBootstrap(
|
||||
setIsResumingSaveWorldKey(null);
|
||||
}
|
||||
},
|
||||
[handleContinueGame, isResumingSaveWorldKey, user],
|
||||
[canReadProtectedData, handleContinueGame, isResumingSaveWorldKey, user],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
const nextPlatformBootstrapUserId = user?.id ?? null;
|
||||
const shouldApplyInitialPlatformTab =
|
||||
platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId;
|
||||
|
||||
if (shouldApplyInitialPlatformTab) {
|
||||
// 在请求发出前先占位,避免首屏请求未完成时用户切了 Tab,
|
||||
// 返回结果又被误判成“还没初始化过”并强制跳回默认页。
|
||||
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
setHistoryEntries([]);
|
||||
@@ -174,9 +198,9 @@ export function useRpgEntryBootstrap(
|
||||
setSaveError(null);
|
||||
setIsLoadingPlatform(true);
|
||||
setPlatformError(null);
|
||||
setIsLoadingDashboard(isAuthenticated);
|
||||
setIsLoadingDashboard(canReadProtectedData);
|
||||
setDashboardError(null);
|
||||
if (!isAuthenticated) {
|
||||
if (!canReadProtectedData) {
|
||||
setSavedCustomWorldEntries([]);
|
||||
setCustomWorldWorkEntries([]);
|
||||
setSaveEntries([]);
|
||||
@@ -192,16 +216,20 @@ export function useRpgEntryBootstrap(
|
||||
historyResult,
|
||||
saveArchivesResult,
|
||||
] = await Promise.allSettled([
|
||||
isAuthenticated
|
||||
canReadProtectedData
|
||||
? listRpgEntryWorldLibrary()
|
||||
: Promise.resolve([]),
|
||||
isAuthenticated
|
||||
canReadProtectedData
|
||||
? listRpgCreationWorks()
|
||||
: Promise.resolve([]),
|
||||
listRpgEntryWorldGallery(),
|
||||
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
|
||||
isAuthenticated ? listRpgProfileBrowseHistory() : Promise.resolve([]),
|
||||
isAuthenticated ? listRpgProfileSaveArchives() : Promise.resolve([]),
|
||||
canReadProtectedData ? getProfileDashboard() : Promise.resolve(null),
|
||||
canReadProtectedData
|
||||
? listRpgProfileBrowseHistory()
|
||||
: Promise.resolve([]),
|
||||
canReadProtectedData
|
||||
? listRpgProfileSaveArchives()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
if (!isActive) {
|
||||
@@ -227,8 +255,10 @@ export function useRpgEntryBootstrap(
|
||||
}
|
||||
|
||||
if (
|
||||
(isAuthenticated && libraryEntriesResult.status === 'rejected') ||
|
||||
(isAuthenticated && workEntriesResult.status === 'rejected') ||
|
||||
(canReadProtectedData &&
|
||||
libraryEntriesResult.status === 'rejected') ||
|
||||
(canReadProtectedData &&
|
||||
workEntriesResult.status === 'rejected') ||
|
||||
galleryEntriesResult.status === 'rejected'
|
||||
) {
|
||||
const platformFailure =
|
||||
@@ -246,7 +276,7 @@ export function useRpgEntryBootstrap(
|
||||
|
||||
if (dashboardResult.status === 'fulfilled') {
|
||||
setProfileDashboard(dashboardResult.value);
|
||||
} else if (isAuthenticated) {
|
||||
} else if (canReadProtectedData) {
|
||||
setProfileDashboard(null);
|
||||
setDashboardError(
|
||||
resolveRpgEntryErrorMessage(
|
||||
@@ -258,7 +288,7 @@ export function useRpgEntryBootstrap(
|
||||
|
||||
if (historyResult.status === 'fulfilled') {
|
||||
setHistoryEntries(historyResult.value);
|
||||
} else if (isAuthenticated) {
|
||||
} else if (canReadProtectedData) {
|
||||
setHistoryError(
|
||||
resolveRpgEntryErrorMessage(historyResult.reason, '读取浏览历史失败。'),
|
||||
);
|
||||
@@ -266,7 +296,7 @@ export function useRpgEntryBootstrap(
|
||||
|
||||
if (saveArchivesResult.status === 'fulfilled') {
|
||||
setSaveEntries(saveArchivesResult.value);
|
||||
} else if (isAuthenticated) {
|
||||
} else if (canReadProtectedData) {
|
||||
setSaveEntries([]);
|
||||
setSaveError(
|
||||
resolveRpgEntryErrorMessage(
|
||||
@@ -276,20 +306,19 @@ export function useRpgEntryBootstrap(
|
||||
);
|
||||
}
|
||||
|
||||
const nextPlatformBootstrapUserId = user?.id ?? null;
|
||||
if (
|
||||
platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId
|
||||
shouldApplyInitialPlatformTab &&
|
||||
!hasInitialAgentSession &&
|
||||
!hasExplicitPlatformTabSelectionRef.current
|
||||
) {
|
||||
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
|
||||
if (!hasInitialAgentSession) {
|
||||
setPlatformTab(
|
||||
isAuthenticated &&
|
||||
saveArchivesResult.status === 'fulfilled' &&
|
||||
saveArchivesResult.value.length > 0
|
||||
? 'saves'
|
||||
: 'home',
|
||||
);
|
||||
}
|
||||
setPlatformTabState(
|
||||
isAuthenticated &&
|
||||
canReadProtectedData &&
|
||||
saveArchivesResult.status === 'fulfilled' &&
|
||||
saveArchivesResult.value.length > 0
|
||||
? 'saves'
|
||||
: 'home',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (isActive) {
|
||||
@@ -302,10 +331,17 @@ export function useRpgEntryBootstrap(
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [getProfileDashboard, hasInitialAgentSession, isAuthenticated, user]);
|
||||
}, [
|
||||
canReadProtectedData,
|
||||
getProfileDashboard,
|
||||
hasInitialAgentSession,
|
||||
isAuthenticated,
|
||||
user,
|
||||
]);
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
canReadProtectedData,
|
||||
platformTab,
|
||||
setPlatformTab,
|
||||
savedCustomWorldEntries,
|
||||
|
||||
Reference in New Issue
Block a user