339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
/** @vitest-environment jsdom */
|
||
|
||
import { act, render } from '@testing-library/react';
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
|
||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||
import { WorldType, type CustomWorldProfile } from '../../types';
|
||
import {
|
||
executeRpgCreationAction,
|
||
getRpgCreationOperation,
|
||
upsertRpgWorldProfile,
|
||
} from '../../services/rpg-creation';
|
||
import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
|
||
import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
|
||
|
||
vi.mock('../../services/rpg-creation', () => ({
|
||
executeRpgCreationAction: vi.fn(),
|
||
getRpgCreationOperation: vi.fn(),
|
||
upsertRpgWorldProfile: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../services/rpg-entry', () => ({
|
||
deleteRpgEntryWorldProfile: vi.fn(),
|
||
getRpgEntryWorldGalleryDetail: vi.fn(),
|
||
listRpgEntryWorldLibrary: vi.fn(),
|
||
publishRpgEntryWorldProfile: vi.fn(),
|
||
unpublishRpgEntryWorldProfile: vi.fn(),
|
||
}));
|
||
|
||
function buildProfile(name: string): CustomWorldProfile {
|
||
return {
|
||
id: `profile-${name}`,
|
||
settingText: name,
|
||
name,
|
||
subtitle: name,
|
||
summary: name,
|
||
tone: '测试',
|
||
playerGoal: '测试',
|
||
templateWorldType: WorldType.WUXIA,
|
||
compatibilityTemplateWorldType: WorldType.WUXIA,
|
||
majorFactions: [],
|
||
coreConflicts: [],
|
||
attributeSchema: {
|
||
id: `schema-${name}`,
|
||
worldId: `profile-${name}`,
|
||
schemaVersion: 1,
|
||
generatedFrom: {
|
||
worldType: WorldType.CUSTOM,
|
||
worldName: name,
|
||
settingSummary: name,
|
||
tone: '测试',
|
||
conflictCore: '测试',
|
||
},
|
||
slots: [],
|
||
},
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
items: [],
|
||
landmarks: [],
|
||
generationMode: 'full',
|
||
generationStatus: 'complete',
|
||
};
|
||
}
|
||
|
||
function buildSession(
|
||
overrides: Partial<CustomWorldAgentSessionSnapshot> = {},
|
||
): CustomWorldAgentSessionSnapshot {
|
||
return {
|
||
sessionId: 'agent-session-1',
|
||
currentTurn: 1,
|
||
anchorContent: {
|
||
worldPromise: null,
|
||
playerFantasy: null,
|
||
themeBoundary: null,
|
||
playerEntryPoint: null,
|
||
coreConflict: null,
|
||
keyRelationships: null,
|
||
hiddenLines: null,
|
||
iconicElements: null,
|
||
},
|
||
progressPercent: 20,
|
||
lastAssistantReply: '继续补齐世界草稿。',
|
||
stage: 'clarifying',
|
||
focusCardId: null,
|
||
creatorIntent: null,
|
||
creatorIntentReadiness: {
|
||
isReady: false,
|
||
completedKeys: [],
|
||
missingKeys: [],
|
||
},
|
||
anchorPack: null,
|
||
lockState: null,
|
||
draftProfile: null,
|
||
messages: [],
|
||
draftCards: [],
|
||
pendingClarifications: [],
|
||
suggestedActions: [],
|
||
recommendedReplies: [],
|
||
qualityFindings: [],
|
||
assetCoverage: {
|
||
roleAssets: [],
|
||
sceneAssets: [],
|
||
allRoleAssetsReady: false,
|
||
allSceneAssetsReady: false,
|
||
},
|
||
resultPreview: null,
|
||
updatedAt: '2026-04-25T00:00:00.000Z',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function buildResultView(
|
||
overrides: Partial<RpgCreationResultView> = {},
|
||
): RpgCreationResultView {
|
||
const session = overrides.session ?? buildSession();
|
||
return {
|
||
session,
|
||
profile: null,
|
||
profileSource: 'none',
|
||
targetStage: 'agent-workspace',
|
||
generationViewSource: null,
|
||
resultViewSource: null,
|
||
canAutosaveLibrary: false,
|
||
canSyncResultProfile: false,
|
||
publishReady: false,
|
||
canEnterWorld: false,
|
||
blockerCount: 0,
|
||
recoveryAction: 'continue_agent',
|
||
recoveryReason: null,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe('RPG Agent 草稿恢复', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('作品摘要已有对象数量但 session 没有 draftProfile 时恢复 Agent 页面', async () => {
|
||
const syncAgentCreationResultView = vi.fn(async () =>
|
||
buildResultView({
|
||
session: buildSession({
|
||
stage: 'clarifying',
|
||
draftProfile: null,
|
||
}),
|
||
targetStage: 'agent-workspace',
|
||
recoveryAction: 'continue_agent',
|
||
}),
|
||
);
|
||
const setSelectionStage = vi.fn();
|
||
const persistAgentUiState = vi.fn();
|
||
const setGeneratedCustomWorldProfile = vi.fn();
|
||
const setCustomWorldResultViewSource = vi.fn();
|
||
const suppressAgentDraftResultAutoOpen = vi.fn();
|
||
let openWork:
|
||
| ((work: CustomWorldWorkSummary) => Promise<void>)
|
||
| null = null;
|
||
|
||
function Harness() {
|
||
openWork = useRpgEntryLibraryDetail({
|
||
userId: 'user-1',
|
||
selectedDetailEntry: null,
|
||
setSelectedDetailEntry: vi.fn(),
|
||
savedCustomWorldEntries: [],
|
||
setSavedCustomWorldEntries: vi.fn(),
|
||
setGeneratedCustomWorldProfile,
|
||
setCustomWorldError: vi.fn(),
|
||
setCustomWorldAutoSaveError: vi.fn(),
|
||
setCustomWorldAutoSaveState: vi.fn(),
|
||
setCustomWorldGenerationViewSource: vi.fn(),
|
||
setCustomWorldResultViewSource,
|
||
setSelectionStage,
|
||
setPlatformTabToCreate: vi.fn(),
|
||
setPlatformError: vi.fn(),
|
||
appendBrowseHistoryEntry: vi.fn(async () => {}),
|
||
refreshCustomWorldWorks: vi.fn(async () => []),
|
||
refreshPublishedGallery: vi.fn(async () => []),
|
||
persistAgentUiState,
|
||
syncAgentCreationResultView,
|
||
buildDraftResultProfile: (view) =>
|
||
(view?.profile as CustomWorldProfile | null) ?? null,
|
||
suppressAgentDraftResultAutoOpen,
|
||
releaseAgentDraftResultAutoOpenSuppression: vi.fn(),
|
||
resetAutoSaveTrackingToIdle: vi.fn(),
|
||
markAutoSavedProfile: vi.fn(),
|
||
}).handleOpenCreationWork;
|
||
|
||
return null;
|
||
}
|
||
|
||
render(<Harness />);
|
||
|
||
await act(async () => {
|
||
await openWork?.({
|
||
workId: 'draft:agent-session-1',
|
||
sourceType: 'agent_session',
|
||
status: 'draft',
|
||
title: '未生成草稿作品',
|
||
subtitle: '',
|
||
summary: '',
|
||
updatedAt: '2026-04-25T00:00:00.000Z',
|
||
stage: 'clarifying',
|
||
stageLabel: '澄清中',
|
||
playableNpcCount: 2,
|
||
landmarkCount: 3,
|
||
sessionId: 'agent-session-1',
|
||
canResume: true,
|
||
canEnterWorld: false,
|
||
});
|
||
});
|
||
|
||
expect(syncAgentCreationResultView).toHaveBeenCalledWith('agent-session-1');
|
||
expect(suppressAgentDraftResultAutoOpen).toHaveBeenCalled();
|
||
expect(persistAgentUiState).toHaveBeenCalledWith('agent-session-1', null);
|
||
expect(setGeneratedCustomWorldProfile).toHaveBeenLastCalledWith(null);
|
||
expect(setCustomWorldResultViewSource).toHaveBeenLastCalledWith(null);
|
||
expect(setSelectionStage).toHaveBeenLastCalledWith('agent-workspace');
|
||
expect(setSelectionStage).not.toHaveBeenCalledWith('custom-world-result');
|
||
});
|
||
|
||
it('Agent 结果页自动保存先回写 session,再保存后端 result-view profile', async () => {
|
||
const oldProfile = buildProfile('旧前端快照');
|
||
const latestProfile = {
|
||
...buildProfile('服务端草稿快照'),
|
||
summary: '自动保存应保存这份 session 最新草稿。',
|
||
};
|
||
const latestSession = buildSession({
|
||
stage: 'object_refining',
|
||
draftProfile: latestProfile as unknown as Record<string, unknown>,
|
||
});
|
||
const syncAgentSessionSnapshot = vi.fn(async () => latestSession);
|
||
const syncAgentCreationResultView = vi.fn(async () =>
|
||
buildResultView({
|
||
session: latestSession,
|
||
profile: latestProfile,
|
||
profileSource: 'result_preview',
|
||
targetStage: 'custom-world-result',
|
||
resultViewSource: 'agent-draft',
|
||
canAutosaveLibrary: true,
|
||
canSyncResultProfile: true,
|
||
recoveryAction: 'open_result',
|
||
}),
|
||
);
|
||
vi.mocked(executeRpgCreationAction).mockResolvedValue({
|
||
operation: {
|
||
operationId: 'operation-sync-result',
|
||
type: 'sync_result_profile',
|
||
status: 'running',
|
||
phaseLabel: '结果页同步中',
|
||
phaseDetail: '正在同步结果页。',
|
||
progress: 50,
|
||
},
|
||
});
|
||
vi.mocked(getRpgCreationOperation).mockResolvedValue({
|
||
operationId: 'operation-sync-result',
|
||
type: 'sync_result_profile',
|
||
status: 'completed',
|
||
phaseLabel: '结果页已同步',
|
||
phaseDetail: '结果页已同步。',
|
||
progress: 100,
|
||
});
|
||
|
||
vi.mocked(upsertRpgWorldProfile).mockResolvedValue({
|
||
entry: {
|
||
ownerUserId: 'user-1',
|
||
profileId: latestProfile.id,
|
||
publicWorkCode: null,
|
||
authorPublicUserCode: null,
|
||
profile: latestProfile,
|
||
visibility: 'draft',
|
||
publishedAt: null,
|
||
updatedAt: '2026-04-25T00:00:00.000Z',
|
||
authorDisplayName: '测试玩家',
|
||
worldName: latestProfile.name,
|
||
subtitle: latestProfile.subtitle,
|
||
summaryText: latestProfile.summary,
|
||
coverImageSrc: null,
|
||
themeMode: 'tide',
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
likeCount: 0,
|
||
},
|
||
entries: [],
|
||
});
|
||
|
||
function Harness() {
|
||
useRpgCreationResultAutosave({
|
||
selectionStage: 'custom-world-result',
|
||
activeAgentSessionId: 'agent-session-1',
|
||
generatedCustomWorldProfile: oldProfile,
|
||
isAgentDraftResultView: true,
|
||
userId: 'user-1',
|
||
setGeneratedCustomWorldProfile: vi.fn(),
|
||
setAgentOperation: vi.fn(),
|
||
setSavedCustomWorldEntries: vi.fn(),
|
||
setSelectedDetailEntry: vi.fn(),
|
||
refreshCustomWorldWorks: vi.fn(async () => []),
|
||
persistAgentUiState: vi.fn(),
|
||
syncAgentSessionSnapshot,
|
||
syncAgentCreationResultView,
|
||
buildDraftResultProfile: (view) =>
|
||
(view?.profile as CustomWorldProfile | null) ?? null,
|
||
});
|
||
|
||
return null;
|
||
}
|
||
|
||
vi.useFakeTimers();
|
||
render(<Harness />);
|
||
|
||
await act(async () => {
|
||
await vi.advanceTimersByTimeAsync(650);
|
||
});
|
||
vi.useRealTimers();
|
||
|
||
expect(syncAgentSessionSnapshot).toHaveBeenCalledWith('agent-session-1');
|
||
expect(syncAgentCreationResultView).toHaveBeenCalledWith('agent-session-1');
|
||
expect(executeRpgCreationAction).toHaveBeenCalledWith('agent-session-1', {
|
||
action: 'sync_result_profile',
|
||
profile: expect.objectContaining({
|
||
id: oldProfile.id,
|
||
name: oldProfile.name,
|
||
}),
|
||
});
|
||
expect(upsertRpgWorldProfile).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
id: latestProfile.id,
|
||
name: latestProfile.name,
|
||
summary: latestProfile.summary,
|
||
}),
|
||
{
|
||
sourceAgentSessionId: 'agent-session-1',
|
||
},
|
||
);
|
||
});
|
||
});
|