700 lines
22 KiB
TypeScript
700 lines
22 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
|
|
|
|
const {
|
|
connectivityError,
|
|
fetchMock,
|
|
requestChatMessageContentMock,
|
|
requestPlainTextCompletionMock,
|
|
streamPlainTextCompletionMock,
|
|
timeoutError,
|
|
} = vi.hoisted(() => ({
|
|
connectivityError: new Error('LLM unavailable'),
|
|
fetchMock: vi.fn(),
|
|
requestChatMessageContentMock: vi.fn(),
|
|
requestPlainTextCompletionMock: vi.fn(),
|
|
streamPlainTextCompletionMock: vi.fn(),
|
|
timeoutError: new Error('LLM timed out'),
|
|
}));
|
|
|
|
vi.mock('./llmClient', () => ({
|
|
CUSTOM_WORLD_REQUEST_TIMEOUT_MS: 120000,
|
|
isLlmConnectivityError: (error: unknown) => error === connectivityError,
|
|
isLlmTimeoutError: (error: unknown) => error === timeoutError,
|
|
requestChatMessageContent: requestChatMessageContentMock,
|
|
requestPlainTextCompletion: requestPlainTextCompletionMock,
|
|
streamPlainTextCompletion: streamPlainTextCompletionMock,
|
|
}));
|
|
|
|
import type {
|
|
Character,
|
|
Encounter,
|
|
SceneMonster,
|
|
StoryMoment,
|
|
StoryOption,
|
|
} from '../types';
|
|
import { AnimationState, WorldType } from '../types';
|
|
import {
|
|
generateCharacterPanelChatSuggestions,
|
|
generateCustomWorldProfile,
|
|
generateCustomWorldSceneImage,
|
|
generateInitialStory,
|
|
streamCharacterPanelChatReply,
|
|
streamNpcRecruitDialogue,
|
|
} from './ai';
|
|
import {
|
|
buildOfflineCharacterPanelChatReply,
|
|
buildOfflineCharacterPanelChatSuggestions,
|
|
buildOfflineNpcRecruitDialogue,
|
|
} from './aiFallbacks';
|
|
import type { StoryGenerationContext } from './aiTypes';
|
|
import type { CharacterChatTargetStatus } from './characterChatPrompt';
|
|
|
|
const [
|
|
BACKSTORY_UNLOCK_AFFINITY_EASED,
|
|
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
|
BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
|
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
|
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
|
|
|
function createCharacter(overrides: Partial<Character> = {}): Character {
|
|
return {
|
|
id: 'hero',
|
|
name: 'Lin',
|
|
title: 'Wanderer',
|
|
description: 'A cautious traveler.',
|
|
backstory: 'Walked out of the northern mountains.',
|
|
avatar: '/avatars/lin.png',
|
|
portrait: '/portraits/lin.png',
|
|
assetFolder: 'lin',
|
|
assetVariant: 'default',
|
|
attributes: {
|
|
strength: 10,
|
|
agility: 8,
|
|
intelligence: 7,
|
|
spirit: 9,
|
|
},
|
|
personality: 'Calm, observant, and steady.',
|
|
skills: [],
|
|
adventureOpenings: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createStoryOption(overrides: Partial<StoryOption> = {}): StoryOption {
|
|
return {
|
|
functionId: 'rest',
|
|
actionText: 'Pause and recover.',
|
|
text: 'Pause and recover.',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createContext(
|
|
overrides: Partial<StoryGenerationContext> = {},
|
|
): StoryGenerationContext {
|
|
return {
|
|
playerHp: 30,
|
|
playerMaxHp: 40,
|
|
playerMana: 12,
|
|
playerMaxMana: 20,
|
|
inBattle: false,
|
|
playerX: 0,
|
|
playerFacing: 'right',
|
|
playerAnimation: AnimationState.IDLE,
|
|
skillCooldowns: {},
|
|
sceneId: null,
|
|
sceneName: 'Forest Trail',
|
|
sceneDescription: 'A quiet mountain path.',
|
|
pendingSceneEncounter: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createTargetStatus(
|
|
overrides: Partial<CharacterChatTargetStatus> = {},
|
|
): CharacterChatTargetStatus {
|
|
return {
|
|
roleLabel: 'Companion',
|
|
hp: 18,
|
|
maxHp: 20,
|
|
mana: 9,
|
|
maxMana: 12,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
|
return {
|
|
npcName: 'Lan',
|
|
npcDescription: 'A sharp-eyed scout.',
|
|
npcAvatar: '/avatars/lan.png',
|
|
context: 'Campfire',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createPlayableNpc(index: number) {
|
|
return {
|
|
name: `角色${index + 1}`,
|
|
title: `身份${index + 1}`,
|
|
role: `世界职责${index + 1}`,
|
|
description: `角色描述${index + 1}`,
|
|
backstory: `角色背景${index + 1}`,
|
|
personality: `角色性格${index + 1}`,
|
|
motivation: `角色动机${index + 1}`,
|
|
combatStyle: `战斗风格${index + 1}`,
|
|
initialAffinity: 18,
|
|
relationshipHooks: [`接触点${index + 1}`],
|
|
tags: [`标签${index + 1}`],
|
|
backstoryReveal: {
|
|
publicSummary: `公开背景${index + 1}`,
|
|
chapters: [
|
|
{
|
|
id: `surface-${index + 1}`,
|
|
title: '表层来意',
|
|
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
|
teaser: `提示${index + 1}-1`,
|
|
content: `内容${index + 1}-1`,
|
|
contextSnippet: `摘要${index + 1}-1`,
|
|
},
|
|
{
|
|
id: `scar-${index + 1}`,
|
|
title: '旧事裂痕',
|
|
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
|
teaser: `提示${index + 1}-2`,
|
|
content: `内容${index + 1}-2`,
|
|
contextSnippet: `摘要${index + 1}-2`,
|
|
},
|
|
{
|
|
id: `hidden-${index + 1}`,
|
|
title: '隐藏执念',
|
|
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
|
teaser: `提示${index + 1}-3`,
|
|
content: `内容${index + 1}-3`,
|
|
contextSnippet: `摘要${index + 1}-3`,
|
|
},
|
|
{
|
|
id: `final-${index + 1}`,
|
|
title: '最终底牌',
|
|
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
|
teaser: `提示${index + 1}-4`,
|
|
content: `内容${index + 1}-4`,
|
|
contextSnippet: `摘要${index + 1}-4`,
|
|
},
|
|
],
|
|
},
|
|
skills: [
|
|
{ name: `技能${index + 1}-1`, summary: '技能说明1', style: '起手压制' },
|
|
{ name: `技能${index + 1}-2`, summary: '技能说明2', style: '机动周旋' },
|
|
{ name: `技能${index + 1}-3`, summary: '技能说明3', style: '爆发终结' },
|
|
],
|
|
initialItems: [
|
|
{ name: `物品${index + 1}-1`, category: '武器', quantity: 1, rarity: 'rare', description: '物品说明1', tags: ['物品标签1'] },
|
|
{ name: `物品${index + 1}-2`, category: '消耗品', quantity: 2, rarity: 'uncommon', description: '物品说明2', tags: ['物品标签2'] },
|
|
{ name: `物品${index + 1}-3`, category: '专属物品', quantity: 1, rarity: 'rare', description: '物品说明3', tags: ['物品标签3'] },
|
|
],
|
|
};
|
|
}
|
|
|
|
function createStoryNpc(index: number) {
|
|
return {
|
|
name: `世界NPC${index + 1}`,
|
|
title: `头衔${index + 1}`,
|
|
role: `职责${index + 1}`,
|
|
description: `世界NPC描述${index + 1}`,
|
|
backstory: `世界NPC背景${index + 1}`,
|
|
personality: `世界NPC性格${index + 1}`,
|
|
motivation: `世界NPC动机${index + 1}`,
|
|
combatStyle: `世界NPC战斗风格${index + 1}`,
|
|
initialAffinity: index % 4 === 0 ? -10 : 6,
|
|
relationshipHooks: [`关系${index + 1}`],
|
|
tags: [`线索${index + 1}`],
|
|
backstoryReveal: {
|
|
publicSummary: `世界公开背景${index + 1}`,
|
|
chapters: [
|
|
{
|
|
id: `surface-story-${index + 1}`,
|
|
title: '表层来意',
|
|
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
|
teaser: `提示${index + 1}-1`,
|
|
content: `内容${index + 1}-1`,
|
|
contextSnippet: `摘要${index + 1}-1`,
|
|
},
|
|
{
|
|
id: `scar-story-${index + 1}`,
|
|
title: '旧事裂痕',
|
|
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
|
teaser: `提示${index + 1}-2`,
|
|
content: `内容${index + 1}-2`,
|
|
contextSnippet: `摘要${index + 1}-2`,
|
|
},
|
|
{
|
|
id: `hidden-story-${index + 1}`,
|
|
title: '隐藏执念',
|
|
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
|
teaser: `提示${index + 1}-3`,
|
|
content: `内容${index + 1}-3`,
|
|
contextSnippet: `摘要${index + 1}-3`,
|
|
},
|
|
{
|
|
id: `final-story-${index + 1}`,
|
|
title: '最终底牌',
|
|
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
|
teaser: `提示${index + 1}-4`,
|
|
content: `内容${index + 1}-4`,
|
|
contextSnippet: `摘要${index + 1}-4`,
|
|
},
|
|
],
|
|
},
|
|
skills: [
|
|
{ name: `世界技能${index + 1}-1`, summary: '技能说明1', style: '起手压制' },
|
|
{ name: `世界技能${index + 1}-2`, summary: '技能说明2', style: '机动周旋' },
|
|
{ name: `世界技能${index + 1}-3`, summary: '技能说明3', style: '爆发终结' },
|
|
],
|
|
initialItems: [
|
|
{ name: `世界物品${index + 1}-1`, category: '武器', quantity: 1, rarity: 'rare', description: '物品说明1', tags: ['物品标签1'] },
|
|
{ name: `世界物品${index + 1}-2`, category: '消耗品', quantity: 2, rarity: 'uncommon', description: '物品说明2', tags: ['物品标签2'] },
|
|
{ name: `世界物品${index + 1}-3`, category: '专属物品', quantity: 1, rarity: 'rare', description: '物品说明3', tags: ['物品标签3'] },
|
|
],
|
|
};
|
|
}
|
|
|
|
function createLandmark(
|
|
index: number,
|
|
options?: {
|
|
storyNpcNames?: string[];
|
|
landmarkCount?: number;
|
|
},
|
|
) {
|
|
const landmarkCount = options?.landmarkCount ?? 10;
|
|
const nextName = `场景${((index + 1) % landmarkCount) + 1}`;
|
|
const prevName = `场景${((index - 1 + landmarkCount) % landmarkCount) + 1}`;
|
|
|
|
return {
|
|
name: `场景${index + 1}`,
|
|
description: `场景描述${index + 1}`,
|
|
dangerLevel: 'high',
|
|
sceneNpcNames: options?.storyNpcNames ?? [
|
|
`世界NPC${index + 1}`,
|
|
`世界NPC${index + 2}`,
|
|
`世界NPC${index + 3}`,
|
|
],
|
|
connections:
|
|
landmarkCount > 1
|
|
? [
|
|
{
|
|
targetLandmarkName: nextName,
|
|
relativePosition: 'forward',
|
|
summary: `沿主路可到${nextName}`,
|
|
},
|
|
{
|
|
targetLandmarkName: prevName,
|
|
relativePosition: 'back',
|
|
summary: `回身可返${prevName}`,
|
|
},
|
|
]
|
|
: [],
|
|
};
|
|
}
|
|
|
|
function createCustomWorldResponse(
|
|
overrides: Partial<{
|
|
name: string;
|
|
subtitle: string;
|
|
summary: string;
|
|
tone: string;
|
|
playerGoal: string;
|
|
templateWorldType: 'WUXIA' | 'XIANXIA';
|
|
playableNpcs: ReturnType<typeof createPlayableNpc>[];
|
|
storyNpcs: ReturnType<typeof createStoryNpc>[];
|
|
landmarks: ReturnType<typeof createLandmark>[];
|
|
items: Array<Record<string, unknown>>;
|
|
}> = {},
|
|
) {
|
|
const storyNpcs =
|
|
overrides.storyNpcs ??
|
|
Array.from({ length: 25 }, (_, index) => createStoryNpc(index));
|
|
const landmarks =
|
|
overrides.landmarks ??
|
|
Array.from({ length: 10 }, (_, index) =>
|
|
createLandmark(index, {
|
|
landmarkCount: 10,
|
|
storyNpcNames: [
|
|
storyNpcs[index % storyNpcs.length]?.name ?? `世界NPC${index + 1}`,
|
|
storyNpcs[(index + 1) % storyNpcs.length]?.name ??
|
|
`世界NPC${index + 2}`,
|
|
storyNpcs[(index + 2) % storyNpcs.length]?.name ??
|
|
`世界NPC${index + 3}`,
|
|
],
|
|
}),
|
|
);
|
|
|
|
return {
|
|
name: '测试世界',
|
|
subtitle: '副标题',
|
|
summary: '概述',
|
|
tone: '基调',
|
|
playerGoal: '目标',
|
|
templateWorldType: 'WUXIA' as const,
|
|
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
|
createPlayableNpc(index),
|
|
),
|
|
storyNpcs,
|
|
landmarks,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('ai orchestration fallbacks', () => {
|
|
const playerCharacter = createCharacter();
|
|
const targetCharacter = createCharacter({
|
|
id: 'ally',
|
|
name: 'Lan',
|
|
title: 'Scout',
|
|
personality: 'Dry, practical, and quietly protective.',
|
|
});
|
|
const context = createContext();
|
|
const targetStatus = createTargetStatus();
|
|
const monsters: SceneMonster[] = [];
|
|
const storyHistory: StoryMoment[] = [];
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
fetchMock.mockReset();
|
|
requestChatMessageContentMock.mockReset();
|
|
requestPlainTextCompletionMock.mockReset();
|
|
streamPlainTextCompletionMock.mockReset();
|
|
});
|
|
|
|
it('falls back to the offline story response when story generation loses connectivity', async () => {
|
|
const availableOptions = [createStoryOption()];
|
|
requestChatMessageContentMock.mockRejectedValue(connectivityError);
|
|
|
|
const response = await generateInitialStory(
|
|
WorldType.WUXIA,
|
|
playerCharacter,
|
|
monsters,
|
|
context,
|
|
{ availableOptions },
|
|
);
|
|
|
|
expect(response.options).toEqual(availableOptions);
|
|
expect(response.options).not.toBe(availableOptions);
|
|
expect(response.storyText.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('returns offline character chat suggestions when the plain-text client reports connectivity errors', async () => {
|
|
requestPlainTextCompletionMock.mockRejectedValue(connectivityError);
|
|
|
|
const suggestions = await generateCharacterPanelChatSuggestions(
|
|
WorldType.WUXIA,
|
|
playerCharacter,
|
|
targetCharacter,
|
|
storyHistory,
|
|
context,
|
|
[],
|
|
'',
|
|
targetStatus,
|
|
);
|
|
|
|
expect(suggestions).toEqual(
|
|
buildOfflineCharacterPanelChatSuggestions(targetCharacter),
|
|
);
|
|
});
|
|
|
|
it('streams the offline character chat reply and forwards it to onUpdate when connectivity fails', async () => {
|
|
const onUpdate = vi.fn();
|
|
const playerMessage = 'Tell me what you are really worried about.';
|
|
const conversationSummary = 'Lan has started to trust the player more.';
|
|
const fallbackReply = buildOfflineCharacterPanelChatReply(
|
|
targetCharacter,
|
|
playerMessage,
|
|
conversationSummary,
|
|
);
|
|
streamPlainTextCompletionMock.mockRejectedValue(connectivityError);
|
|
|
|
const reply = await streamCharacterPanelChatReply(
|
|
WorldType.WUXIA,
|
|
playerCharacter,
|
|
targetCharacter,
|
|
storyHistory,
|
|
context,
|
|
[],
|
|
conversationSummary,
|
|
playerMessage,
|
|
targetStatus,
|
|
{ onUpdate },
|
|
);
|
|
|
|
expect(reply).toBe(fallbackReply);
|
|
expect(onUpdate).toHaveBeenCalledOnce();
|
|
expect(onUpdate).toHaveBeenCalledWith(fallbackReply);
|
|
});
|
|
|
|
it('uses the extracted NPC recruit fallback when recruit dialogue streaming loses connectivity', async () => {
|
|
const onUpdate = vi.fn();
|
|
const encounter = createEncounter();
|
|
const fallbackReply = buildOfflineNpcRecruitDialogue(encounter);
|
|
streamPlainTextCompletionMock.mockRejectedValue(connectivityError);
|
|
|
|
const reply = await streamNpcRecruitDialogue(
|
|
WorldType.WUXIA,
|
|
playerCharacter,
|
|
encounter,
|
|
monsters,
|
|
storyHistory,
|
|
context,
|
|
'Join us.',
|
|
'The party is ready to travel together.',
|
|
{ onUpdate },
|
|
);
|
|
|
|
expect(reply).toBe(fallbackReply);
|
|
expect(onUpdate).toHaveBeenCalledOnce();
|
|
expect(onUpdate).toHaveBeenCalledWith(fallbackReply);
|
|
});
|
|
|
|
it('rejects custom world output when the model does not generate enough NPCs and scenes', async () => {
|
|
requestPlainTextCompletionMock.mockResolvedValue(
|
|
JSON.stringify(
|
|
createCustomWorldResponse({
|
|
storyNpcs: Array.from({ length: 10 }, (_, index) =>
|
|
createStoryNpc(index),
|
|
),
|
|
landmarks: Array.from({ length: 4 }, (_, index) =>
|
|
createLandmark(index, { landmarkCount: 4 }),
|
|
),
|
|
}),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
generateCustomWorldProfile('一个需要很多角色和场景的世界'),
|
|
).rejects.toThrow(
|
|
/requires at least 30 unique NPCs|requires at least 10 generated scenes|did not return enough non-playable NPCs|至少产出 30 名唯一角色|至少产出 10 个场景|至少需要 25 名场景角色/i,
|
|
);
|
|
});
|
|
|
|
it('keeps the generated custom world dossier item-free when the model output is valid', async () => {
|
|
requestPlainTextCompletionMock.mockResolvedValue(
|
|
JSON.stringify(
|
|
createCustomWorldResponse({
|
|
items: [
|
|
{
|
|
name: '不应保留的物品',
|
|
category: '材料',
|
|
rarity: 'rare',
|
|
description: '这个字段应该被清空',
|
|
tags: ['测试'],
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
|
|
const profile =
|
|
await generateCustomWorldProfile('一个需要很多角色和场景的世界');
|
|
|
|
expect(profile.playableNpcs).toHaveLength(5);
|
|
expect(profile.storyNpcs).toHaveLength(25);
|
|
expect(profile.landmarks).toHaveLength(10);
|
|
expect(
|
|
profile.landmarks.every((landmark) => landmark.sceneNpcIds.length >= 3),
|
|
).toBe(true);
|
|
expect(
|
|
profile.landmarks.every((landmark) => landmark.connections.length > 0),
|
|
).toBe(true);
|
|
expect(profile.items).toEqual([]);
|
|
});
|
|
|
|
it('generates custom worlds through a framework stage plus segmented narrative and dossier batches', async () => {
|
|
requestPlainTextCompletionMock.mockResolvedValue(
|
|
JSON.stringify(createCustomWorldResponse()),
|
|
);
|
|
|
|
await generateCustomWorldProfile('一个需要拆分生成的世界');
|
|
|
|
const debugLabels = requestPlainTextCompletionMock.mock.calls.map(
|
|
(call) => (call[2] as { debugLabel?: string } | undefined)?.debugLabel,
|
|
);
|
|
|
|
expect(debugLabels).toContain('custom-world-framework');
|
|
expect(debugLabels).toContain('custom-world-playable-outline-batch-1');
|
|
expect(debugLabels).toContain('custom-world-story-outline-batch-1');
|
|
expect(debugLabels).toContain('custom-world-landmark-seed-batch-1');
|
|
expect(debugLabels).toContain('custom-world-landmark-network-batch-1');
|
|
expect(debugLabels).toContain('custom-world-playable-narrative-batch-1');
|
|
expect(debugLabels).toContain('custom-world-playable-dossier-batch-1');
|
|
expect(debugLabels).toContain('custom-world-story-narrative-batch-1');
|
|
expect(debugLabels).toContain('custom-world-story-dossier-batch-1');
|
|
});
|
|
|
|
it('retries custom world generation with a longer timeout after the first timeout attempt', async () => {
|
|
requestPlainTextCompletionMock
|
|
.mockRejectedValueOnce(timeoutError)
|
|
.mockResolvedValue(
|
|
JSON.stringify(
|
|
createCustomWorldResponse({
|
|
name: '重试世界',
|
|
}),
|
|
),
|
|
);
|
|
|
|
const profile = await generateCustomWorldProfile('一个生成很慢的世界');
|
|
|
|
expect(profile.name).toBe('重试世界');
|
|
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.any(String),
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
timeoutMs: 120000,
|
|
debugLabel: 'custom-world-framework',
|
|
}),
|
|
);
|
|
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.any(String),
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
timeoutMs: 180000,
|
|
debugLabel: 'custom-world-framework-retry-2',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('repairs invalid custom world json through a follow-up formatting request', async () => {
|
|
requestPlainTextCompletionMock
|
|
.mockResolvedValueOnce(
|
|
`{
|
|
"name": "修复世界",
|
|
"subtitle": "副标题",
|
|
"summary": "概述",
|
|
"tone": "基调",
|
|
"playerGoal": "目标",
|
|
"templateWorldType": "WUXIA",
|
|
"playableNpcs": [{ name: "角色1" }],
|
|
"storyNpcs": [],
|
|
"landmarks": []
|
|
}`,
|
|
)
|
|
.mockResolvedValue(
|
|
JSON.stringify(
|
|
createCustomWorldResponse({
|
|
name: '修复世界',
|
|
}),
|
|
),
|
|
);
|
|
|
|
const profile = await generateCustomWorldProfile('一个格式容易损坏的世界');
|
|
|
|
expect(profile.name).toBe('修复世界');
|
|
expect(profile.playableNpcs).toHaveLength(5);
|
|
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.stringContaining('你是 JSON 修复器'),
|
|
expect.stringContaining('不要输出 playableNpcs、storyNpcs、landmarks、items'),
|
|
expect.objectContaining({
|
|
debugLabel: 'custom-world-framework-json-repair',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('generates a custom world scene image through the local proxy and returns the saved asset path', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
text: async () =>
|
|
JSON.stringify({
|
|
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
|
|
assetId: 'custom-scene-1',
|
|
model: 'wan2.2-t2i-flash',
|
|
size: '1280*720',
|
|
taskId: 'task-123',
|
|
prompt: '用于测试的提示词',
|
|
actualPrompt: '扩写后的提示词',
|
|
}),
|
|
} as Response);
|
|
|
|
const result = await generateCustomWorldSceneImage({
|
|
profile: {
|
|
id: 'custom-world-1',
|
|
name: '测试世界',
|
|
subtitle: '副标题',
|
|
summary: '世界概述',
|
|
tone: '世界基调',
|
|
playerGoal: '核心目标',
|
|
settingText: '原始设定',
|
|
},
|
|
landmark: {
|
|
id: 'landmark-1',
|
|
name: '雾潮码头',
|
|
description: '被潮雾与旧升降机包围的码头。',
|
|
dangerLevel: 'high',
|
|
},
|
|
prompt: '用于测试的提示词',
|
|
negativePrompt: '文字,水印',
|
|
size: '1280*720',
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/custom-world/scene-image',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
);
|
|
expect(result).toEqual({
|
|
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
|
|
assetId: 'custom-scene-1',
|
|
model: 'wan2.2-t2i-flash',
|
|
size: '1280*720',
|
|
taskId: 'task-123',
|
|
prompt: '用于测试的提示词',
|
|
actualPrompt: '扩写后的提示词',
|
|
});
|
|
});
|
|
|
|
it('surfaces proxy error messages when scene image generation fails', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: false,
|
|
text: async () =>
|
|
JSON.stringify({
|
|
error: {
|
|
message: 'DashScope API key 无效。',
|
|
},
|
|
}),
|
|
} as Response);
|
|
|
|
await expect(
|
|
generateCustomWorldSceneImage({
|
|
profile: {
|
|
id: 'custom-world-1',
|
|
name: '测试世界',
|
|
subtitle: '副标题',
|
|
summary: '世界概述',
|
|
tone: '世界基调',
|
|
playerGoal: '核心目标',
|
|
settingText: '原始设定',
|
|
},
|
|
landmark: {
|
|
id: 'landmark-1',
|
|
name: '雾潮码头',
|
|
description: '被潮雾与旧升降机包围的码头。',
|
|
dangerLevel: 'high',
|
|
},
|
|
}),
|
|
).rejects.toThrow('DashScope API key 无效。');
|
|
});
|
|
});
|