484 lines
14 KiB
TypeScript
484 lines
14 KiB
TypeScript
/* @vitest-environment jsdom */
|
||
|
||
import { render, screen, waitFor } from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { beforeEach, expect, test, vi } from 'vitest';
|
||
|
||
import type {
|
||
CustomWorldAgentSessionSnapshot,
|
||
CustomWorldDraftCardDetail,
|
||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
|
||
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
|
||
|
||
vi.mock('../../services/aiService', () => ({
|
||
getCustomWorldAgentCardDetail: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../CustomWorldRoleAssetStudioModal', () => ({
|
||
CustomWorldRoleAssetStudioModal: ({
|
||
role,
|
||
onPublishSuccess,
|
||
}: {
|
||
role: { name: string };
|
||
onPublishSuccess?: (
|
||
payload: {
|
||
roleId: string;
|
||
portraitPath: string;
|
||
generatedVisualAssetId: string;
|
||
generatedAnimationSetId?: string | null;
|
||
animationMap?: Record<string, unknown> | null;
|
||
},
|
||
options?: { closeAfterSync?: boolean },
|
||
) => void;
|
||
}) => (
|
||
<div>
|
||
<div>角色资产工坊:{role.name}</div>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
onPublishSuccess?.(
|
||
{
|
||
roleId: 'character-1',
|
||
portraitPath: '/generated/character-1.png',
|
||
generatedVisualAssetId: 'visual-character-1',
|
||
generatedAnimationSetId: 'animation-set-character-1',
|
||
animationMap: {
|
||
idle: { basePath: '/generated/character-1/idle' },
|
||
run: { basePath: '/generated/character-1/run' },
|
||
attack: { basePath: '/generated/character-1/attack' },
|
||
hurt: { basePath: '/generated/character-1/hurt' },
|
||
die: { basePath: '/generated/character-1/die' },
|
||
},
|
||
},
|
||
{
|
||
closeAfterSync: true,
|
||
},
|
||
)
|
||
}
|
||
>
|
||
模拟同步角色资产
|
||
</button>
|
||
</div>
|
||
),
|
||
}));
|
||
|
||
const detailById: Record<string, CustomWorldDraftCardDetail> = {
|
||
'world-foundation': {
|
||
id: 'world-foundation',
|
||
kind: 'world',
|
||
title: '潮雾列岛',
|
||
sections: [
|
||
{
|
||
id: 'title',
|
||
label: '标题',
|
||
value: '潮雾列岛',
|
||
},
|
||
{
|
||
id: 'summary',
|
||
label: '摘要',
|
||
value: '这是第一版世界底稿。',
|
||
},
|
||
],
|
||
linkedIds: ['thread-1', 'character-1'],
|
||
locked: false,
|
||
editable: true,
|
||
editableSectionIds: ['title', 'summary'],
|
||
warningMessages: [],
|
||
},
|
||
'character-1': {
|
||
id: 'character-1',
|
||
kind: 'character',
|
||
title: '沈砺',
|
||
sections: [
|
||
{
|
||
id: 'name',
|
||
label: '角色名',
|
||
value: '沈砺',
|
||
},
|
||
{
|
||
id: 'summary',
|
||
label: '角色摘要',
|
||
value: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||
},
|
||
],
|
||
linkedIds: ['thread-1'],
|
||
locked: false,
|
||
editable: true,
|
||
editableSectionIds: ['name', 'summary'],
|
||
warningMessages: [],
|
||
assetStatus: 'missing',
|
||
assetStatusLabel: '待生成主图',
|
||
},
|
||
'character-2': {
|
||
id: 'character-2',
|
||
kind: 'character',
|
||
title: '顾潮音',
|
||
sections: [
|
||
{
|
||
id: 'name',
|
||
label: '角色名',
|
||
value: '顾潮音',
|
||
},
|
||
{
|
||
id: 'summary',
|
||
label: '角色摘要',
|
||
value: '她总像比所有人更早知道海雾会往哪一侧压下来。',
|
||
},
|
||
],
|
||
linkedIds: ['thread-1'],
|
||
locked: false,
|
||
editable: true,
|
||
editableSectionIds: ['name', 'summary'],
|
||
warningMessages: [],
|
||
assetStatus: 'missing',
|
||
assetStatusLabel: '待生成主图',
|
||
},
|
||
};
|
||
|
||
const baseSession: CustomWorldAgentSessionSnapshot = {
|
||
sessionId: 'custom-world-agent-session-1',
|
||
stage: 'object_refining',
|
||
focusCardId: 'world-foundation',
|
||
creatorIntent: {},
|
||
creatorIntentReadiness: {
|
||
isReady: true,
|
||
completedKeys: [
|
||
'world_hook',
|
||
'player_premise',
|
||
'theme_and_tone',
|
||
'core_conflict',
|
||
'relationship_seed',
|
||
'iconic_element',
|
||
],
|
||
missingKeys: [],
|
||
},
|
||
anchorPack: {},
|
||
lockState: {},
|
||
draftProfile: {
|
||
name: '潮雾列岛',
|
||
storyNpcs: [
|
||
{
|
||
id: 'character-1',
|
||
name: '沈砺',
|
||
title: '守灯会旧友',
|
||
role: '航道向导',
|
||
publicMask: '守灯会里最熟悉旧航道的人。',
|
||
hiddenHook: '暗地里正在为沉船商盟引路。',
|
||
relationToPlayer: '旧友兼宿敌',
|
||
threadIds: ['thread-1'],
|
||
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||
},
|
||
],
|
||
},
|
||
messages: [
|
||
{
|
||
id: 'message-1',
|
||
role: 'assistant',
|
||
kind: 'summary',
|
||
text: '当前底稿已经可以继续精修。',
|
||
createdAt: new Date().toISOString(),
|
||
relatedOperationId: null,
|
||
},
|
||
],
|
||
draftCards: [
|
||
{
|
||
id: 'world-foundation',
|
||
kind: 'world',
|
||
title: '潮雾列岛',
|
||
subtitle: '旧灯塔与航道争夺',
|
||
summary: '世界总卡已经生成。',
|
||
status: 'warning',
|
||
linkedIds: ['thread-1', 'character-1'],
|
||
warningCount: 1,
|
||
},
|
||
{
|
||
id: 'character-1',
|
||
kind: 'character',
|
||
title: '沈砺',
|
||
subtitle: '守灯会旧友',
|
||
summary: '他最了解旧航道,也最可能先背叛。',
|
||
status: 'suggested',
|
||
linkedIds: ['thread-1'],
|
||
warningCount: 0,
|
||
},
|
||
],
|
||
pendingClarifications: [],
|
||
suggestedActions: [
|
||
{
|
||
id: 'request-summary',
|
||
type: 'request_summary',
|
||
label: '总结当前世界底稿',
|
||
targetId: null,
|
||
},
|
||
],
|
||
recommendedReplies: [
|
||
'现在开始生成草稿',
|
||
'先总结一下当前设定',
|
||
'我还想再补充一点',
|
||
],
|
||
qualityFindings: [],
|
||
assetCoverage: {
|
||
roleAssets: [
|
||
{
|
||
roleId: 'character-1',
|
||
roleName: '沈砺',
|
||
roleKind: 'story',
|
||
priorityTier: 'featured',
|
||
portraitPath: null,
|
||
generatedVisualAssetId: null,
|
||
generatedAnimationSetId: null,
|
||
status: 'missing',
|
||
missingAnimations: ['idle', 'run', 'attack', 'hurt', 'die'],
|
||
nextPointCost: 20,
|
||
},
|
||
],
|
||
sceneAssets: [],
|
||
allRoleAssetsReady: false,
|
||
allSceneAssetsReady: false,
|
||
},
|
||
updatedAt: '2026-04-14T10:00:00.000Z',
|
||
};
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(getCustomWorldAgentCardDetail).mockImplementation(
|
||
async (_sessionId, cardId): Promise<CustomWorldDraftCardDetail> =>
|
||
detailById[cardId] ?? detailById['world-foundation']!,
|
||
);
|
||
if (!Element.prototype.scrollIntoView) {
|
||
Element.prototype.scrollIntoView = () => {};
|
||
}
|
||
});
|
||
|
||
test('workspace loads detail, saves edits, opens generate actions, and reflects updated drawer cards', async () => {
|
||
const user = userEvent.setup();
|
||
const onExecuteAction = vi.fn();
|
||
|
||
const { rerender } = render(
|
||
<CustomWorldAgentWorkspace
|
||
session={baseSession}
|
||
activeOperation={null}
|
||
onBack={() => {}}
|
||
onRefresh={() => {}}
|
||
onSubmitMessage={() => {}}
|
||
onExecuteAction={onExecuteAction}
|
||
/>,
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(getCustomWorldAgentCardDetail).toHaveBeenCalledWith(
|
||
baseSession.sessionId,
|
||
'world-foundation',
|
||
);
|
||
});
|
||
|
||
expect(screen.getByText('卡片详情')).toBeTruthy();
|
||
expect(screen.queryByPlaceholderText('输入消息')).toBeNull();
|
||
expect(screen.queryByText('当前底稿已经可以继续精修。')).toBeNull();
|
||
|
||
await user.click(screen.getByRole('button', { name: '编辑设定' }));
|
||
const summaryInput = screen.getByLabelText('摘要');
|
||
await user.clear(summaryInput);
|
||
await user.type(summaryInput, '这是更新后的世界摘要。');
|
||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||
|
||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||
action: 'update_draft_card',
|
||
cardId: 'world-foundation',
|
||
sections: [
|
||
{
|
||
sectionId: 'title',
|
||
value: '潮雾列岛',
|
||
},
|
||
{
|
||
sectionId: 'summary',
|
||
value: '这是更新后的世界摘要。',
|
||
},
|
||
],
|
||
});
|
||
|
||
await user.click(screen.getByRole('button', { name: /沈砺/u }));
|
||
await waitFor(() => {
|
||
expect(getCustomWorldAgentCardDetail).toHaveBeenLastCalledWith(
|
||
baseSession.sessionId,
|
||
'character-1',
|
||
);
|
||
});
|
||
|
||
const [generateCharacterButton] = screen.getAllByRole('button', {
|
||
name: '新增角色',
|
||
});
|
||
await user.click(generateCharacterButton!);
|
||
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
|
||
await user.click(screen.getByRole('button', { name: '生成角色' }));
|
||
|
||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||
action: 'generate_characters',
|
||
count: 2,
|
||
promptText: null,
|
||
anchorCardIds: ['character-1'],
|
||
});
|
||
|
||
const [generateLandmarkButton] = screen.getAllByRole('button', {
|
||
name: '新增场景',
|
||
});
|
||
await user.click(generateLandmarkButton!);
|
||
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
|
||
await user.click(screen.getByRole('button', { name: '生成场景' }));
|
||
|
||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||
action: 'generate_landmarks',
|
||
count: 2,
|
||
promptText: null,
|
||
anchorCardIds: ['character-1'],
|
||
});
|
||
|
||
const [openRoleAssetsButton] = screen.getAllByRole('button', {
|
||
name: '角色资产',
|
||
});
|
||
await user.click(openRoleAssetsButton!);
|
||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||
action: 'generate_role_assets',
|
||
roleIds: ['character-1'],
|
||
});
|
||
|
||
rerender(
|
||
<CustomWorldAgentWorkspace
|
||
session={{
|
||
...baseSession,
|
||
stage: 'visual_refining',
|
||
draftCards: [
|
||
...baseSession.draftCards,
|
||
{
|
||
id: 'character-2',
|
||
kind: 'character',
|
||
title: '顾潮音',
|
||
subtitle: '回潮记录员',
|
||
summary: '她会把每一次海雾异常都记到连自己都不愿复看的本子里。',
|
||
status: 'suggested',
|
||
linkedIds: ['thread-1'],
|
||
warningCount: 0,
|
||
},
|
||
],
|
||
updatedAt: '2026-04-14T10:05:00.000Z',
|
||
}}
|
||
activeOperation={{
|
||
operationId: 'operation-role-assets',
|
||
type: 'generate_role_assets',
|
||
status: 'completed',
|
||
phaseLabel: '角色资产工坊已就绪',
|
||
phaseDetail: '可以开始生成角色主图与动作。',
|
||
progress: 100,
|
||
error: null,
|
||
}}
|
||
onBack={() => {}}
|
||
onRefresh={() => {}}
|
||
onSubmitMessage={() => {}}
|
||
onExecuteAction={onExecuteAction}
|
||
/>,
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('顾潮音')).toBeTruthy();
|
||
});
|
||
expect(screen.getByText('角色资产工坊:沈砺')).toBeTruthy();
|
||
|
||
await user.click(screen.getByRole('button', { name: '模拟同步角色资产' }));
|
||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||
action: 'sync_role_assets',
|
||
roleId: 'character-1',
|
||
portraitPath: '/generated/character-1.png',
|
||
generatedVisualAssetId: 'visual-character-1',
|
||
generatedAnimationSetId: 'animation-set-character-1',
|
||
animationMap: {
|
||
idle: { basePath: '/generated/character-1/idle' },
|
||
run: { basePath: '/generated/character-1/run' },
|
||
attack: { basePath: '/generated/character-1/attack' },
|
||
hurt: { basePath: '/generated/character-1/hurt' },
|
||
die: { basePath: '/generated/character-1/die' },
|
||
},
|
||
});
|
||
|
||
rerender(
|
||
<CustomWorldAgentWorkspace
|
||
session={{
|
||
...baseSession,
|
||
stage: 'visual_refining',
|
||
draftCards: [
|
||
{
|
||
...baseSession.draftCards[0]!,
|
||
},
|
||
{
|
||
...baseSession.draftCards[1]!,
|
||
subtitle: '守灯会旧友 / 动作已就绪',
|
||
assetStatus: 'complete',
|
||
assetStatusLabel: '动作已就绪',
|
||
},
|
||
],
|
||
assetCoverage: {
|
||
roleAssets: [
|
||
{
|
||
roleId: 'character-1',
|
||
roleName: '沈砺',
|
||
roleKind: 'story',
|
||
priorityTier: 'featured',
|
||
portraitPath: '/generated/character-1.png',
|
||
generatedVisualAssetId: 'visual-character-1',
|
||
generatedAnimationSetId: 'animation-set-character-1',
|
||
status: 'complete',
|
||
missingAnimations: [],
|
||
nextPointCost: 0,
|
||
},
|
||
],
|
||
sceneAssets: [],
|
||
allRoleAssetsReady: true,
|
||
allSceneAssetsReady: false,
|
||
},
|
||
draftProfile: {
|
||
...baseSession.draftProfile,
|
||
storyNpcs: [
|
||
{
|
||
id: 'character-1',
|
||
name: '沈砺',
|
||
title: '守灯会旧友',
|
||
role: '航道向导',
|
||
publicMask: '守灯会里最熟悉旧航道的人。',
|
||
hiddenHook: '暗地里正在为沉船商盟引路。',
|
||
relationToPlayer: '旧友兼宿敌',
|
||
threadIds: ['thread-1'],
|
||
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||
imageSrc: '/generated/character-1.png',
|
||
generatedVisualAssetId: 'visual-character-1',
|
||
generatedAnimationSetId: 'animation-set-character-1',
|
||
animationMap: {
|
||
idle: { basePath: '/generated/character-1/idle' },
|
||
run: { basePath: '/generated/character-1/run' },
|
||
attack: { basePath: '/generated/character-1/attack' },
|
||
hurt: { basePath: '/generated/character-1/hurt' },
|
||
die: { basePath: '/generated/character-1/die' },
|
||
},
|
||
},
|
||
],
|
||
},
|
||
}}
|
||
activeOperation={{
|
||
operationId: 'operation-sync-role-assets',
|
||
type: 'sync_role_assets',
|
||
status: 'completed',
|
||
phaseLabel: '角色资产已同步',
|
||
phaseDetail: '角色资产已经写回草稿。',
|
||
progress: 100,
|
||
error: null,
|
||
}}
|
||
onBack={() => {}}
|
||
onRefresh={() => {}}
|
||
onSubmitMessage={() => {}}
|
||
onExecuteAction={onExecuteAction}
|
||
/>,
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getAllByText('动作已就绪').length).toBeGreaterThan(0);
|
||
});
|
||
});
|