426
src/services/ai.test.ts
Normal file
426
src/services/ai.test.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
connectivityError,
|
||||
fetchMock,
|
||||
requestChatMessageContentMock,
|
||||
requestPlainTextCompletionMock,
|
||||
streamPlainTextCompletionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
connectivityError: new Error('LLM unavailable'),
|
||||
fetchMock: vi.fn(),
|
||||
requestChatMessageContentMock: vi.fn(),
|
||||
requestPlainTextCompletionMock: vi.fn(),
|
||||
streamPlainTextCompletionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./llmClient', () => ({
|
||||
CUSTOM_WORLD_REQUEST_TIMEOUT_MS: 45000,
|
||||
isLlmConnectivityError: (error: unknown) => error === connectivityError,
|
||||
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';
|
||||
|
||||
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}`,
|
||||
description: `角色描述${index + 1}`,
|
||||
backstory: `角色背景${index + 1}`,
|
||||
personality: `角色性格${index + 1}`,
|
||||
combatStyle: `战斗风格${index + 1}`,
|
||||
tags: [`标签${index + 1}`],
|
||||
};
|
||||
}
|
||||
|
||||
function createStoryNpc(index: number) {
|
||||
return {
|
||||
name: `世界NPC${index + 1}`,
|
||||
role: `职责${index + 1}`,
|
||||
description: `世界NPC描述${index + 1}`,
|
||||
motivation: `世界NPC动机${index + 1}`,
|
||||
relationshipHooks: [`关系${index + 1}`],
|
||||
};
|
||||
}
|
||||
|
||||
function createLandmark(index: number) {
|
||||
return {
|
||||
name: `场景${index + 1}`,
|
||||
description: `场景描述${index + 1}`,
|
||||
dangerLevel: 'high',
|
||||
};
|
||||
}
|
||||
|
||||
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({
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
templateWorldType: 'WUXIA',
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs: Array.from({ length: 10 }, (_, index) =>
|
||||
createStoryNpc(index),
|
||||
),
|
||||
landmarks: Array.from({ length: 4 }, (_, index) =>
|
||||
createLandmark(index),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
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 个场景/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the generated custom world dossier item-free when the model output is valid', async () => {
|
||||
requestPlainTextCompletionMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
templateWorldType: 'WUXIA',
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs: Array.from({ length: 25 }, (_, index) =>
|
||||
createStoryNpc(index),
|
||||
),
|
||||
landmarks: Array.from({ length: 10 }, (_, index) =>
|
||||
createLandmark(index),
|
||||
),
|
||||
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.items).toEqual([]);
|
||||
});
|
||||
|
||||
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 无效。');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user