Files
Genarrative/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx
2026-05-22 03:14:11 +08:00

721 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** @vitest-environment jsdom */
import { act, render } from '@testing-library/react';
import { useEffect, useState } from '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 type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import {
executeRpgCreationAction,
getRpgCreationOperation,
upsertRpgWorldProfile,
} from '../../services/rpg-creation';
import { type CustomWorldProfile, WorldType } from '../../types';
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 buildLibraryEntry(
profile: CustomWorldProfile,
): CustomWorldLibraryEntry<CustomWorldProfile> {
return {
ownerUserId: 'user-1',
profileId: profile.id,
publicWorkCode: null,
authorPublicUserCode: null,
profile,
visibility: 'published' as const,
publishedAt: '2026-04-25T00:00:00.000Z',
updatedAt: '2026-04-25T00:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: profile.name,
subtitle: profile.subtitle,
summaryText: profile.summary,
coverImageSrc: null,
themeMode: 'tide' as const,
playableNpcCount: profile.playableNpcs.length,
landmarkCount: profile.landmarks.length,
likeCount: 0,
};
}
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(),
setPlatformTabToDraft: 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('作品详情已加载完整编辑资产时列表摘要不能覆盖 selectedDetailEntry', async () => {
const fullProfile: CustomWorldProfile = {
...buildProfile('星砂废都'),
id: 'profile-stardust',
cover: {
sourceType: 'default',
imageSrc: null,
characterRoleIds: ['playable-shamian'],
},
playableNpcs: [
{
id: 'playable-shamian',
name: '砂眠',
title: '废都引路人',
role: '主角代理',
description: '追查旧约的人。',
backstory: '从星砂潮汐里醒来。',
personality: '冷静。',
motivation: '找到旧约。',
combatStyle: '踏砂突进。',
initialAffinity: 45,
relationshipHooks: [],
tags: [],
relations: [],
backstoryReveal: {
publicSummary: '废都引路人。',
privateChatUnlockAffinity: 60,
chapters: [],
},
skills: [
{
id: 'skill-star-step',
name: '星砂步',
summary: '踏砂突进。',
style: 'mobility',
actionPreviewConfig: {
folder: 'characters/shamian',
prefix: 'skill_',
frames: 8,
basePath: '/assets/custom-world/shamian/skill',
previewVideoPath: '/assets/custom-world/shamian/skill.mp4',
},
},
],
initialItems: [
{
id: 'item-sand-compass',
name: '星砂罗盘',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '能指出旧约埋藏方向。',
tags: [],
iconSrc: '/assets/custom-world/items/sand-compass.png',
},
],
imageSrc: '/assets/custom-world/playable-shamian.png',
attributeProfile: {
schemaId: 'schema-星砂废都',
values: { axis_a: 8 },
topTraits: ['星砂共鸣'],
evidence: [
{ slotId: 'axis_a', reason: '能听见星砂潮汐。' },
],
},
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-clocktower',
sceneId: 'landmark-clocktower',
title: '钟楼第一夜',
summary: '钟楼第一夜。',
sceneTaskDescription: '进入钟楼。',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-clocktower'],
acts: [
{
id: 'act-clocktower-opening',
sceneId: 'landmark-clocktower',
title: '第一幕',
summary: '砂眠带玩家进入坠星钟楼。',
stageCoverage: ['opening'],
backgroundImageSrc:
'/assets/custom-world/act-clocktower-opening.png',
backgroundAssetId: 'asset-act-clocktower-opening',
encounterNpcIds: ['playable-shamian'],
primaryNpcId: 'playable-shamian',
oppositeNpcId: 'playable-shamian',
eventDescription: '钟楼旧铃忽然自鸣。',
linkedThreadIds: ['thread-old-vow'],
advanceRule: 'after_primary_contact',
actGoal: '进入钟楼。',
transitionHook: '星砂开始倒流。',
},
],
},
],
};
const summaryProfile: CustomWorldProfile = {
...fullProfile,
cover: null,
playableNpcs: [
{
...fullProfile.playableNpcs[0]!,
skills: [
{
id: 'skill-star-step',
name: '星砂步',
summary: '踏砂突进。',
style: 'mobility',
},
],
initialItems: [
{
...fullProfile.playableNpcs[0]!.initialItems[0]!,
iconSrc: undefined,
},
],
imageSrc: undefined,
attributeProfile: undefined,
},
],
sceneChapterBlueprints: [
{
...fullProfile.sceneChapterBlueprints![0]!,
acts: [
{
...fullProfile.sceneChapterBlueprints![0]!.acts[0]!,
backgroundImageSrc: undefined,
backgroundAssetId: undefined,
linkedThreadIds: [],
actGoal: '',
transitionHook: '',
},
],
},
],
};
const detailEntry = buildLibraryEntry(fullProfile);
const summaryEntry = buildLibraryEntry(summaryProfile);
const selectedEntries: CustomWorldLibraryEntry<CustomWorldProfile>[] = [];
function Harness() {
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
CustomWorldLibraryEntry<CustomWorldProfile> | null
>(detailEntry);
useEffect(() => {
if (selectedDetailEntry) {
selectedEntries.push(selectedDetailEntry);
}
}, [selectedDetailEntry]);
useRpgEntryLibraryDetail({
userId: 'user-1',
selectedDetailEntry,
setSelectedDetailEntry,
savedCustomWorldEntries: [summaryEntry],
setSavedCustomWorldEntries: vi.fn(),
setGeneratedCustomWorldProfile: vi.fn(),
setCustomWorldError: vi.fn(),
setCustomWorldAutoSaveError: vi.fn(),
setCustomWorldAutoSaveState: vi.fn(),
setCustomWorldGenerationViewSource: vi.fn(),
setCustomWorldResultViewSource: vi.fn(),
setSelectionStage: vi.fn(),
setPlatformTabToCreate: vi.fn(),
setPlatformTabToDraft: vi.fn(),
setPlatformError: vi.fn(),
appendBrowseHistoryEntry: vi.fn(async () => {}),
refreshCustomWorldWorks: vi.fn(async () => []),
refreshPublishedGallery: vi.fn(async () => []),
persistAgentUiState: vi.fn(),
syncAgentCreationResultView: vi.fn(),
buildDraftResultProfile: () => null,
suppressAgentDraftResultAutoOpen: vi.fn(),
releaseAgentDraftResultAutoOpenSuppression: vi.fn(),
resetAutoSaveTrackingToIdle: vi.fn(),
markAutoSavedProfile: vi.fn(),
});
return null;
}
render(<Harness />);
await act(async () => {});
const lastSelected = selectedEntries.at(-1);
expect(lastSelected?.profile.cover?.characterRoleIds).toEqual([
'playable-shamian',
]);
expect(
lastSelected?.profile.playableNpcs[0]?.skills[0]?.actionPreviewConfig
?.previewVideoPath,
).toBe('/assets/custom-world/shamian/skill.mp4');
expect(lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe(
'/assets/custom-world/items/sand-compass.png',
);
expect(lastSelected?.profile.playableNpcs[0]?.attributeProfile).toBeTruthy();
expect(
lastSelected?.profile.sceneChapterBlueprints?.[0]?.acts[0]
?.backgroundImageSrc,
).toBe('/assets/custom-world/act-clocktower-opening.png');
});
it('默认封面和角色编辑结构差异也不能被列表摘要覆盖', async () => {
const fullRole = {
id: 'playable-shamian',
name: '砂眠',
title: '废都引路人',
role: '主角代理',
description: '追查旧约的人。',
backstory: '从星砂潮汐里醒来。',
personality: '冷静。',
motivation: '找到旧约。',
combatStyle: '踏砂突进。',
initialAffinity: 45,
relationshipHooks: [],
tags: [],
relations: [],
backstoryReveal: {
publicSummary: '废都引路人。',
privateChatUnlockAffinity: 60,
chapters: [],
},
skills: [
{
id: 'skill-star-step',
name: '星砂步',
summary: '踏砂突进。',
style: 'mobility',
actionPreviewConfig: {
folder: 'characters/shamian',
prefix: 'skill_',
frames: 8,
basePath: '/assets/custom-world/shamian/skill',
previewVideoPath: '/assets/custom-world/shamian/skill.mp4',
},
},
],
initialItems: [
{
id: 'item-sand-compass',
name: '星砂罗盘',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '能指出旧约埋藏方向。',
tags: [],
iconSrc: '/assets/custom-world/items/sand-compass.png',
},
],
attributeProfile: {
schemaId: 'schema-星砂废都',
values: { axis_a: 8 },
topTraits: ['星砂共鸣'],
evidence: [
{ slotId: 'axis_a', reason: '能听见星砂潮汐。' },
],
},
} satisfies CustomWorldProfile['playableNpcs'][number];
const fullProfile: CustomWorldProfile = {
...buildProfile('星砂废都'),
id: 'profile-stardust-structure',
cover: {
sourceType: 'default',
imageSrc: null,
characterRoleIds: ['playable-shamian'],
},
playableNpcs: [fullRole],
};
const summaryProfile: CustomWorldProfile = {
...fullProfile,
cover: null,
playableNpcs: [
{
...fullRole,
skills: [
{
id: 'skill-star-step',
name: '星砂步',
summary: '踏砂突进。',
style: 'mobility',
},
],
initialItems: [
{
...fullRole.initialItems[0]!,
iconSrc: undefined,
},
],
attributeProfile: undefined,
},
],
};
const detailEntry = buildLibraryEntry(fullProfile);
const summaryEntry = buildLibraryEntry(summaryProfile);
const selectedEntries: CustomWorldLibraryEntry<CustomWorldProfile>[] = [];
function Harness() {
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
CustomWorldLibraryEntry<CustomWorldProfile> | null
>(detailEntry);
useEffect(() => {
if (selectedDetailEntry) {
selectedEntries.push(selectedDetailEntry);
}
}, [selectedDetailEntry]);
useRpgEntryLibraryDetail({
userId: 'user-1',
selectedDetailEntry,
setSelectedDetailEntry,
savedCustomWorldEntries: [summaryEntry],
setSavedCustomWorldEntries: vi.fn(),
setGeneratedCustomWorldProfile: vi.fn(),
setCustomWorldError: vi.fn(),
setCustomWorldAutoSaveError: vi.fn(),
setCustomWorldAutoSaveState: vi.fn(),
setCustomWorldGenerationViewSource: vi.fn(),
setCustomWorldResultViewSource: vi.fn(),
setSelectionStage: vi.fn(),
setPlatformTabToCreate: vi.fn(),
setPlatformTabToDraft: vi.fn(),
setPlatformError: vi.fn(),
appendBrowseHistoryEntry: vi.fn(async () => {}),
refreshCustomWorldWorks: vi.fn(async () => []),
refreshPublishedGallery: vi.fn(async () => []),
persistAgentUiState: vi.fn(),
syncAgentCreationResultView: vi.fn(),
buildDraftResultProfile: () => null,
suppressAgentDraftResultAutoOpen: vi.fn(),
releaseAgentDraftResultAutoOpenSuppression: vi.fn(),
resetAutoSaveTrackingToIdle: vi.fn(),
markAutoSavedProfile: vi.fn(),
});
return null;
}
render(<Harness />);
await act(async () => {});
const lastSelected = selectedEntries.at(-1);
expect(lastSelected?.profile.cover?.characterRoleIds).toEqual([
'playable-shamian',
]);
expect(
lastSelected?.profile.playableNpcs[0]?.skills[0]?.actionPreviewConfig
?.previewVideoPath,
).toBe('/assets/custom-world/shamian/skill.mp4');
expect(lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe(
'/assets/custom-world/items/sand-compass.png',
);
expect(lastSelected?.profile.playableNpcs[0]?.attributeProfile?.values).toEqual(
{ axis_a: 8 },
);
});
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',
},
);
});
});