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

476 lines
15 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 { describe, expect, it, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime';
import { type CustomWorldProfile, WorldType } from '../../types';
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
function buildProfile(params: {
id: string;
name: string;
imageSrc: string;
}): CustomWorldProfile {
return {
id: params.id,
settingText: params.name,
name: params.name,
subtitle: params.name,
summary: params.name,
tone: '测试',
playerGoal: '测试',
templateWorldType: WorldType.WUXIA,
compatibilityTemplateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: `${params.id}-attribute-schema`,
worldId: params.id,
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: params.name,
settingSummary: params.name,
tone: '测试',
conflictCore: '测试',
},
slots: [],
},
playableNpcs: [
{
id: `${params.id}-role`,
name: '可扮演角色',
title: '测试角色',
role: '主角',
description: '测试角色',
backstory: '测试背景',
personality: '测试性格',
motivation: '测试动机',
combatStyle: '测试战斗风格',
initialAffinity: 18,
relationshipHooks: [],
tags: [],
backstoryReveal: {
publicSummary: '测试角色',
privateChatUnlockAffinity: 60,
chapters: [],
},
skills: [],
initialItems: [],
imageSrc: params.imageSrc,
},
],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
};
}
function buildSession(
stage: CustomWorldAgentSessionSnapshot['stage'] = 'ready_to_publish',
): CustomWorldAgentSessionSnapshot {
return {
sessionId: 'session-1',
currentTurn: 1,
anchorContent: {
worldPromise: null,
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: null,
hiddenLines: null,
iconicElements: null,
},
progressPercent: 100,
lastAssistantReply: '',
stage,
focusCardId: null,
creatorIntent: null,
creatorIntentReadiness: {
isReady: true,
completedKeys: [],
missingKeys: [],
},
anchorPack: null,
lockState: null,
draftProfile: null,
messages: [],
draftCards: [],
pendingClarifications: [],
suggestedActions: [],
recommendedReplies: [],
qualityFindings: [],
assetCoverage: {
roleAssets: [],
sceneAssets: [],
allRoleAssetsReady: true,
allSceneAssetsReady: true,
},
resultPreview: null,
updatedAt: '2026-04-25T00:00:00.000Z',
};
}
function buildResultView(params: {
stage?: CustomWorldAgentSessionSnapshot['stage'];
profile: CustomWorldProfile | null;
canEnterWorld?: boolean;
}): RpgCreationResultView {
const stage = params.stage ?? 'ready_to_publish';
const profileRecord = params.profile
? (structuredClone(params.profile) as unknown as CustomWorldProfileRecord)
: null;
return {
session: buildSession(stage),
profile: profileRecord,
profileSource: profileRecord ? 'result_preview' : 'none',
targetStage: 'custom-world-result',
generationViewSource: null,
resultViewSource: profileRecord ? 'agent-draft' : null,
canAutosaveLibrary: true,
canSyncResultProfile: stage !== 'published',
publishReady: true,
canEnterWorld: params.canEnterWorld ?? stage === 'published',
blockerCount: 0,
recoveryAction: 'open_result',
};
}
describe('useRpgCreationEnterWorld', () => {
it('Agent 草稿测试进入游戏时优先使用结果页当前 profile而不是回退到会话快照', async () => {
const resultProfile = buildProfile({
id: 'draft-profile',
name: '结果页真相源',
imageSrc: '/generated-characters/draft-role/portrait.png',
});
const handleCustomWorldSelect = vi.fn();
const setGeneratedCustomWorldProfile = vi.fn();
const executePublishWorld = vi.fn(async () => buildSession());
const syncAgentCreationResultView = vi.fn();
const syncAgentDraftResultProfile = vi.fn(async () => ({
profile: resultProfile,
view: null,
}));
function Harness() {
const { enterWorldForTestFromCurrentResult } = useRpgCreationEnterWorld({
isAgentDraftResultView: true,
activeAgentSessionId: 'session-1',
generatedCustomWorldProfile: resultProfile,
handleCustomWorldSelect,
syncAgentDraftResultProfile,
executePublishWorld,
syncAgentCreationResultView,
setGeneratedCustomWorldProfile,
});
return (
<button
type="button"
onClick={() => void enterWorldForTestFromCurrentResult()}
>
</button>
);
}
const { getByText } = render(<Harness />);
await act(async () => {
getByText('进入').click();
});
expect(executePublishWorld).not.toHaveBeenCalled();
expect(handleCustomWorldSelect).toHaveBeenCalledWith(resultProfile, {
mode: 'play',
disablePersistence: true,
returnStage: 'custom-world-result',
});
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledWith(resultProfile);
expect(
handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc,
).toBe('/generated-characters/draft-role/portrait.png');
});
it('Agent 草稿发布时先保存当前结果页 profile再发送 publish_world 并回读结果页', async () => {
const resultProfile = buildProfile({
id: 'draft-profile',
name: '发布前填写内容',
imageSrc: '/generated-characters/draft-role/portrait.png',
});
const syncedProfile = buildProfile({
id: 'draft-profile',
name: '已保存的填写内容',
imageSrc: '/generated-characters/draft-role/synced.png',
});
const publishedProfile = buildProfile({
id: 'draft-profile',
name: '已发布世界',
imageSrc: '/generated-characters/draft-role/published.png',
});
const callOrder: string[] = [];
const handleCustomWorldSelect = vi.fn();
const setGeneratedCustomWorldProfile = vi.fn();
const syncAgentDraftResultProfile = vi.fn(async () => {
callOrder.push('save');
return {
profile: syncedProfile,
view: buildResultView({
stage: 'ready_to_publish',
profile: syncedProfile,
canEnterWorld: false,
}),
};
});
const executePublishWorld = vi.fn(async () => {
callOrder.push('publish');
return buildSession('published');
});
const syncAgentCreationResultView = vi.fn(async () => {
callOrder.push('reload');
return buildResultView({
stage: 'published',
profile: publishedProfile,
canEnterWorld: true,
});
});
function Harness() {
const { publishCurrentResult } = useRpgCreationEnterWorld({
isAgentDraftResultView: true,
activeAgentSessionId: 'session-1',
currentAgentSessionStage: 'ready_to_publish',
generatedCustomWorldProfile: resultProfile,
handleCustomWorldSelect,
syncAgentDraftResultProfile,
executePublishWorld,
syncAgentCreationResultView,
setGeneratedCustomWorldProfile,
});
return (
<button type="button" onClick={() => void publishCurrentResult()}>
</button>
);
}
const { getByText } = render(<Harness />);
await act(async () => {
getByText('发布').click();
});
expect(callOrder).toEqual(['save', 'publish', 'reload']);
expect(syncAgentDraftResultProfile).toHaveBeenCalledWith(resultProfile);
expect(executePublishWorld).toHaveBeenCalledTimes(1);
expect(syncAgentCreationResultView).toHaveBeenCalledWith('session-1');
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledWith(syncedProfile);
expect(
setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.id,
).toBe('draft-profile');
expect(
setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.playableNpcs[0]
?.imageSrc,
).toBe('/generated-characters/draft-role/published.png');
expect(handleCustomWorldSelect).not.toHaveBeenCalled();
});
it('Agent 会话已发布后点击进入世界不再重复发送 publish_world', async () => {
const resultProfile = buildProfile({
id: 'published-profile',
name: '已发布世界',
imageSrc: '/generated-characters/published-role/portrait.png',
});
const publishedView = buildResultView({
stage: 'published',
profile: resultProfile,
canEnterWorld: true,
});
const handleCustomWorldSelect = vi.fn();
const setGeneratedCustomWorldProfile = vi.fn();
const executePublishWorld = vi.fn(async () => buildSession('published'));
const syncAgentCreationResultView = vi.fn(async () => publishedView);
const syncAgentDraftResultProfile = vi.fn(async () => ({
profile: resultProfile,
view: null,
}));
function Harness() {
const { enterWorldFromCurrentResult } = useRpgCreationEnterWorld({
isAgentDraftResultView: true,
activeAgentSessionId: 'session-1',
currentAgentSessionStage: 'published',
generatedCustomWorldProfile: resultProfile,
handleCustomWorldSelect,
syncAgentDraftResultProfile,
executePublishWorld,
syncAgentCreationResultView,
setGeneratedCustomWorldProfile,
});
return (
<button
type="button"
onClick={() => void enterWorldFromCurrentResult()}
>
</button>
);
}
const { getByText } = render(<Harness />);
await act(async () => {
getByText('进入世界').click();
});
expect(syncAgentDraftResultProfile).not.toHaveBeenCalled();
expect(executePublishWorld).not.toHaveBeenCalled();
expect(syncAgentCreationResultView).toHaveBeenCalledWith('session-1');
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledTimes(1);
expect(setGeneratedCustomWorldProfile.mock.calls[0]?.[0]?.id).toBe(
'published-profile',
);
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
expect(handleCustomWorldSelect.mock.calls[0]?.[0]?.id).toBe(
'published-profile',
);
});
it('正式进入世界回读结果页字段更少时不降级当前完整 profile', async () => {
const resultProfile = {
...buildProfile({
id: 'draft-profile-rich-assets',
name: '星砂废都',
imageSrc: '/generated-characters/draft-role/portrait.png',
}),
cover: {
sourceType: 'generated' as const,
imageSrc: '/generated-custom-world-covers/star-waste/cover.webp',
characterRoleIds: ['draft-profile-rich-assets-role'],
},
openingCg: {
id: 'opening-cg-stardust',
status: 'ready' as const,
storyboardImageSrc: '/generated-custom-world-scenes/opening/storyboard.png',
videoSrc: '/generated-custom-world-scenes/opening/opening.mp4',
imageModel: 'gpt-image-2' as const,
videoModel: 'doubao-seedance-2-0-fast-260128',
aspectRatio: '16:9' as const,
imageSize: '2k' as const,
videoResolution: '480p' as const,
durationSeconds: 15 as const,
pointCost: 80 as const,
estimatedWaitMinutes: 10 as const,
updatedAt: '2026-05-21T00:00:00.000Z',
},
sceneChapterBlueprints: [
{
id: 'scene-chapter-stardust',
sceneId: 'landmark-stardust',
title: '钟楼第一夜',
summary: '钟楼第一夜。',
sceneTaskDescription: '进入钟楼。',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-stardust'],
acts: [
{
id: 'act-stardust-opening',
sceneId: 'landmark-stardust',
title: '第一幕',
summary: '砂眠带玩家进入坠星钟楼。',
stageCoverage: ['opening' as const],
backgroundImageSrc:
'/assets/custom-world/act-stardust-opening.png',
backgroundAssetId: 'asset-act-stardust-opening',
encounterNpcIds: ['draft-profile-rich-assets-role'],
primaryNpcId: 'draft-profile-rich-assets-role',
oppositeNpcId: 'draft-profile-rich-assets-role',
eventDescription: '钟楼旧铃忽然自鸣。',
linkedThreadIds: [],
advanceRule: 'after_primary_contact' as const,
actGoal: '进入钟楼。',
transitionHook: '星砂开始倒流。',
},
],
},
],
} satisfies CustomWorldProfile;
const stalePublishedProfile = {
...resultProfile,
name: '星砂废都',
cover: null,
openingCg: null,
playableNpcs: [],
sceneChapterBlueprints: null,
} satisfies CustomWorldProfile;
const handleCustomWorldSelect = vi.fn();
const setGeneratedCustomWorldProfile = vi.fn();
const syncAgentDraftResultProfile = vi.fn(async () => ({
profile: resultProfile,
view: buildResultView({
stage: 'ready_to_publish',
profile: resultProfile,
canEnterWorld: false,
}),
}));
const executePublishWorld = vi.fn(async () => buildSession('published'));
const syncAgentCreationResultView = vi.fn(async () =>
buildResultView({
stage: 'published',
profile: stalePublishedProfile,
canEnterWorld: true,
}),
);
function Harness() {
const { enterWorldFromCurrentResult } = useRpgCreationEnterWorld({
isAgentDraftResultView: true,
activeAgentSessionId: 'session-1',
currentAgentSessionStage: 'ready_to_publish',
generatedCustomWorldProfile: resultProfile,
handleCustomWorldSelect,
syncAgentDraftResultProfile,
executePublishWorld,
syncAgentCreationResultView,
setGeneratedCustomWorldProfile,
});
return (
<button
type="button"
onClick={() => void enterWorldFromCurrentResult()}
>
</button>
);
}
const { getByText } = render(<Harness />);
await act(async () => {
getByText('进入世界').click();
});
const launchedProfile = handleCustomWorldSelect.mock.calls[0]?.[0];
expect(launchedProfile?.id).toBe('draft-profile-rich-assets');
expect(launchedProfile?.cover?.imageSrc).toBe(
'/generated-custom-world-covers/star-waste/cover.webp',
);
expect(launchedProfile?.openingCg?.videoSrc).toBe(
'/generated-custom-world-scenes/opening/opening.mp4',
);
expect(launchedProfile?.playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/draft-role/portrait.png',
);
expect(
launchedProfile?.sceneChapterBlueprints?.[0]?.acts[0]
?.backgroundImageSrc,
).toBe('/assets/custom-world/act-stardust-opening.png');
});
});