/* @vitest-environment jsdom */ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { expect, test, vi } from 'vitest'; import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types'; import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient'; import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView'; vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => { const generatePlayableNpc = vi.fn(); const generateStoryNpc = vi.fn(); const generateLandmark = vi.fn(); const generateSceneImage = vi.fn(); const generateSceneNpc = vi.fn(); return { rpgCreationAssetClient: { generatePlayableNpc, generateStoryNpc, generateLandmark, generateSceneImage, generateSceneNpc, }, generateCustomWorldPlayableNpc: generatePlayableNpc, generateCustomWorldStoryNpc: generateStoryNpc, generateCustomWorldLandmark: generateLandmark, generateCustomWorldSceneImage: generateSceneImage, generateCustomWorldSceneNpc: generateSceneNpc, }; }); const mockedRpgCreationAssetClient = vi.mocked( rpgCreationAssetClient.rpgCreationAssetClient, ); vi.mock('./CharacterAnimator', () => ({ CharacterAnimator: () =>
角色预览
, })); vi.mock('./CustomWorldNpcVisualEditor', () => ({ CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
{npc.name}
), })); vi.mock('../hooks/useResolvedAssetReadUrl', () => ({ useResolvedAssetReadUrl: (source: string | null | undefined) => ({ resolvedUrl: source?.trim() ?? '', isResolving: false, shouldResolve: false, }), })); vi.mock('./rpg-creation-editor/RpgCreationEntityEditorModal', () => ({ RpgCreationEntityEditorModal: () => null, default: () => null, })); function createBackstoryReveal() { return { publicSummary: '公开背景', chapters: [ { id: 'surface', title: '表层来意', affinityRequired: 6, teaser: '表层来意', content: '表层来意内容', contextSnippet: '表层来意摘要', }, { id: 'scar', title: '旧事裂痕', affinityRequired: 12, teaser: '旧事裂痕', content: '旧事裂痕内容', contextSnippet: '旧事裂痕摘要', }, { id: 'hidden', title: '隐藏执念', affinityRequired: 18, teaser: '隐藏执念', content: '隐藏执念内容', contextSnippet: '隐藏执念摘要', }, { id: 'final', title: '最终底牌', affinityRequired: 24, teaser: '最终底牌', content: '最终底牌内容', contextSnippet: '最终底牌摘要', }, ], }; } function createPlayableRole(id: string, name: string): CustomWorldPlayableNpc { return { id, name, title: '同行者', role: '协作战力', description: `${name}的定位描述`, backstory: `${name}的背景`, personality: `${name}的性格`, motivation: `${name}的动机`, combatStyle: `${name}的战斗风格`, initialAffinity: 18, relationshipHooks: ['关系钩子'], relations: [], tags: ['测试'], backstoryReveal: createBackstoryReveal(), skills: [ { id: `${id}-skill-1`, name: '技能一', summary: '技能说明一', style: '起手压制', }, { id: `${id}-skill-2`, name: '技能二', summary: '技能说明二', style: '机动周旋', }, { id: `${id}-skill-3`, name: '技能三', summary: '技能说明三', style: '爆发终结', }, ], initialItems: [ { id: `${id}-item-1`, name: '物品一', category: '武器', quantity: 1, rarity: 'rare', description: '物品说明一', tags: ['测试'], }, { id: `${id}-item-2`, name: '物品二', category: '专属物品', quantity: 1, rarity: 'rare', description: '物品说明二', tags: ['测试'], }, { id: `${id}-item-3`, name: '物品三', category: '消耗品', quantity: 2, rarity: 'uncommon', description: '物品说明三', tags: ['测试'], }, ], }; } const baseProfile = { id: 'world-1', settingText: '潮雾群岛上的禁制与旧航道正在一起失衡。', name: '潮雾群岛', subtitle: '旧航道与沉钟回响', summary: '一座正在被旧誓与新利益共同撕扯的群岛世界。', tone: '压抑、潮湿、带着未解旧伤。', playerGoal: '找到能让群岛重新稳定的关键节点。', templateWorldType: 'WUXIA', majorFactions: ['守潮盟', '沉钟会'], coreConflicts: ['旧航道归属', '沉钟遗产争夺'], attributeSchema: {}, playableNpcs: [createPlayableRole('playable-1', '沈砺')], storyNpcs: [ { ...createPlayableRole('story-1', '顾潮音'), initialAffinity: 6, }, ], items: [], camp: { name: '潮灯居', description: '玩家最初落脚的旧灯塔内院。', }, anchorContent: { worldPromise: '被海雾反复改写航路的群岛世界,旧灯塔与禁航令共同决定谁能活着穿过去,体验压抑、悬疑、潮湿。', playerFantasy: '玩家是被迫返乡的守灯人继承者,追查沉钟异动与失控航路的真相,风险是失去家族留下的最后航路坐标。', themeBoundary: '压抑、悬疑;潮湿群岛、冷雾港口;避免热血少年漫。', playerEntryPoint: '玩家以返乡守灯人继承者身份切入,首夜就撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。', coreConflict: '守潮盟与沉钟会争夺航路解释权,有人借假航灯持续清洗整片群岛的旧证据,玩家回港当夜就被卷进禁航区封锁。', keyRelationships: '玩家与沈砺旧友互疑,沈砺掌握沉船夜的关键视角。', hiddenLines: '沉钟异动和旧案灭口是同一条线,表面看像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。', iconicElements: '假航灯、沉钟回响、旧灯塔、禁航碑;错误航灯会把船引进必死水域。', }, landmarks: [ { id: 'landmark-1', name: '沉钟栈桥', description: '旧钟与潮声常年相撞的码头栈桥。', sceneNpcIds: ['story-1'], connections: [], }, ], sceneChapterBlueprints: [ { id: 'scene-chapter-1', sceneId: 'landmark-1', title: '沉钟栈桥章节', summary: '围绕沉钟栈桥推进的三幕结构。', linkedThreadIds: [], linkedLandmarkIds: ['landmark-1'], acts: [ { id: 'scene-act-1', sceneId: 'landmark-1', title: '潮声逼近', summary: '第一幕先把潮声与旧钟压上来。', stageCoverage: ['opening'], backgroundImageSrc: '/generated-custom-world-scenes/scene-act-1.png', backgroundAssetId: 'scene-asset-1', encounterNpcIds: ['story-1'], primaryNpcId: 'story-1', linkedThreadIds: [], advanceRule: 'after_primary_contact', actGoal: '接住首幕压力', transitionHook: '继续逼近钟楼深处。', }, { id: 'scene-act-2', sceneId: 'landmark-1', title: '钟楼回响', summary: '第二幕把旧钟与暗线证据推到台前。', stageCoverage: ['investigation'], backgroundImageSrc: '/generated-custom-world-scenes/scene-act-2.png', backgroundAssetId: 'scene-asset-2', encounterNpcIds: ['story-1'], primaryNpcId: 'story-1', linkedThreadIds: [], advanceRule: 'after_clue_found', actGoal: '找到旧钟证据', transitionHook: '钟楼深处传来第二次回响。', }, ], }, ], creatorIntent: null, anchorPack: null, lockState: null, ownedSettingLayers: null, generationMode: 'full', generationStatus: 'complete', } as unknown as CustomWorldProfile; function ResultViewHarness() { const [profile, setProfile] = useState(baseProfile); return ( {}} onProfileChange={setProfile} /> ); } test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => { const user = userEvent.setup(); let resolveGeneration: ((value: CustomWorldPlayableNpc) => void) | null = null; mockedRpgCreationAssetClient.generatePlayableNpc.mockImplementation( () => new Promise((resolve) => { resolveGeneration = resolve; }), ); render(); await user.click(screen.getByRole('button', { name: /可扮演角色/u })); await user.click(screen.getByRole('button', { name: '新增可扮演角色' })); expect(screen.getByText('新可扮演角色')).toBeTruthy(); expect(screen.getByText('正在整理世界上下文')).toBeTruthy(); const createButton = screen.getByRole('button', { name: '新增可扮演角色' }); expect((createButton as HTMLButtonElement).disabled).toBe(true); const finishGeneration = resolveGeneration; if (!finishGeneration) { throw new Error('expected pending playable generation resolver'); } (finishGeneration as (value: CustomWorldPlayableNpc) => void)( createPlayableRole('playable-2', '云止'), ); await waitFor(() => { expect(screen.getByRole('button', { name: /云止/u })).toBeTruthy(); }); await waitFor(() => { expect(screen.queryByText('新可扮演角色')).toBeNull(); }); expect(screen.getAllByText('新').length).toBeGreaterThan(0); }); test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', () => { render(); expect(screen.getByText('世界承诺')).toBeTruthy(); expect(screen.getByText('玩家幻想')).toBeTruthy(); expect(screen.getByText('主题边界')).toBeTruthy(); expect(screen.getByText('玩家切入口')).toBeTruthy(); expect(screen.getByText('核心冲突')).toBeTruthy(); expect(screen.getByText('关键关系')).toBeTruthy(); expect(screen.getByText('暗线与揭示')).toBeTruthy(); expect(screen.getByText('标志元素')).toBeTruthy(); expect(screen.queryByText('解析字段')).toBeNull(); expect(screen.queryByText('锚点原文')).toBeNull(); expect(screen.getByText(/被海雾反复改写航路的群岛世界/u)).toBeTruthy(); expect(screen.getByText(/沉钟异动和旧案灭口是同一条线/u)).toBeTruthy(); }); test('playable tab prefers generated portrait over runtime preview placeholder', async () => { const user = userEvent.setup(); const profile = { ...baseProfile, playableNpcs: [ { ...createPlayableRole('playable-portrait', '云止'), imageSrc: '/generated-characters/playable-portrait/master.png', generatedVisualAssetId: 'visual-playable-portrait', }, ], } as CustomWorldProfile; render( {}} onProfileChange={() => {}} />, ); await user.click(screen.getByRole('button', { name: /可扮演角色/u })); const portrait = screen.getByRole('img', { name: '云止' }); expect((portrait as HTMLImageElement).getAttribute('src')).toBe( '/generated-characters/playable-portrait/master.png', ); expect(screen.getByText('已生成主图')).toBeTruthy(); }); test('landmark tab previews every generated act image while keeping chapter details out of list', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: /场景\s*2/u })); expect(screen.queryByText('沉钟栈桥章节')).toBeNull(); expect(screen.queryByText('潮声逼近')).toBeNull(); const sceneImage = screen.getByRole('img', { name: '沉钟栈桥' }); expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe( '/generated-custom-world-scenes/scene-act-1.png', ); expect( (screen.getByRole('img', { name: '沉钟栈桥-潮声逼近', }) as HTMLImageElement).getAttribute('src'), ).toBe('/generated-custom-world-scenes/scene-act-1.png'); expect( (screen.getByRole('img', { name: '沉钟栈桥-钟楼回响', }) as HTMLImageElement).getAttribute('src'), ).toBe('/generated-custom-world-scenes/scene-act-1.png'); }); test('readOnly result view hides edit and create actions for agent preview mode', async () => { const user = userEvent.setup(); render( {}} onProfileChange={() => {}} readOnly compactAgentResultMode />, ); expect(screen.queryByRole('button', { name: /^编辑$/u })).toBeNull(); await user.click(screen.getByRole('button', { name: /可扮演角色/u })); expect(screen.queryByRole('button', { name: '新增可扮演角色' })).toBeNull(); await user.click(screen.getByRole('button', { name: /场景角色/u })); expect(screen.queryByRole('button', { name: /批量删除/u })).toBeNull(); }); test('agent result view shows error when entity generation returns no new profile', async () => { const user = userEvent.setup(); render( {}} onProfileChange={() => {}} compactAgentResultMode onGenerateEntity={async () => {}} />, ); await user.click(screen.getByRole('button', { name: /场景角色/u })); await user.click(screen.getByRole('button', { name: '新增场景角色' })); expect( await screen.findByText(/结果页未收到新增内容/u), ).toBeTruthy(); }); test('agent result view keeps publish-enter action clickable and hides sticky publish hints', () => { render( {}} onProfileChange={() => {}} compactAgentResultMode publishReady={false} publishBlockers={[ '仍有角色缺少正式主图或动作资产,发布前需要先补齐。', '营地还缺少正式场景图资产,发布前需要先确认营地图。', ]} qualityFindings={[ { id: 'role-assets-pending', severity: 'warning', code: 'role_assets_pending', message: '仍有角色资产未完全补齐。', }, ]} previewSourceLabel="服务端预览" enterWorldActionLabel="发布并进入世界" onEnterWorld={() => {}} />, ); const actionButton = screen.getByRole('button', { name: '发布并进入世界', }); expect((actionButton as HTMLButtonElement).disabled).toBe(false); expect(screen.queryByText(/当前结果页数据源:服务端预览/u)).toBeNull(); expect(screen.queryByText(/当前还有 2 个发布阻断项/u)).toBeNull(); }); test('agent result view opens publish blocker dialog only when user clicks publish action', async () => { const user = userEvent.setup(); render( {}} onProfileChange={() => {}} compactAgentResultMode publishReady={false} publishBlockers={[ '仍有角色缺少正式主图或动作资产,发布前需要先补齐。', '营地还缺少正式场景图资产,发布前需要先确认营地图。', ]} previewSourceLabel="服务端预览" enterWorldActionLabel="发布并进入世界" onEnterWorld={() => {}} />, ); await user.click(screen.getByRole('button', { name: '发布并进入世界' })); expect(screen.getByRole('dialog', { name: '发布作品' })).toBeTruthy(); expect(screen.getByText('发布检查')).toBeTruthy(); expect(screen.getByText('封面设置')).toBeTruthy(); expect( screen.getByText(/仍有角色缺少正式主图或动作资产/u), ).toBeTruthy(); }); test('agent result view keeps publish-enter action enabled when publish gate is clear', () => { render( {}} onProfileChange={() => {}} compactAgentResultMode publishReady publishBlockers={[]} qualityFindings={[ { id: 'scene-assets-pending', severity: 'warning', code: 'scene_assets_pending', message: '仍有场景分幕图未补齐。', }, ]} previewSourceLabel="服务端预览" enterWorldActionLabel="发布并进入世界" onEnterWorld={() => {}} />, ); expect(screen.getByText(/发布后仍有 1 条 warning 可继续优化/u)).toBeTruthy(); const actionButton = screen.getByRole('button', { name: '发布并进入世界', }); expect((actionButton as HTMLButtonElement).disabled).toBe(false); });