/* @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 | null; }, options?: { closeAfterSync?: boolean }, ) => void; }) => (
角色资产工坊:{role.name}
), })); const detailById: Record = { '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 => 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( {}} 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( {}} 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( {}} onRefresh={() => {}} onSubmitMessage={() => {}} onExecuteAction={onExecuteAction} />, ); await waitFor(() => { expect(screen.getAllByText('动作已就绪').length).toBeGreaterThan(0); }); });