/* @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('./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: '玩家最初落脚的旧灯塔内院。', dangerLevel: 'medium', }, anchorContent: { worldPromise: { hook: '被海雾反复改写航路的群岛世界。', differentiator: '旧灯塔与禁航令共同决定谁能活着穿过去。', desiredExperience: '压抑、悬疑、潮湿', }, playerFantasy: { playerRole: '玩家是被迫返乡的守灯人继承者。', corePursuit: '查清沉钟异动与失控航路的真相。', fearOfLoss: '失去家族留下的最后航路坐标。', }, themeBoundary: { toneKeywords: ['压抑', '悬疑'], aestheticDirectives: ['潮湿群岛', '冷雾港口'], forbiddenDirectives: ['热血少年漫'], }, playerEntryPoint: { openingIdentity: '返乡守灯人继承者', openingProblem: '首夜就撞见禁航区假航灯重亮', entryMotivation: '阻止更多船只误入死潮', }, coreConflict: { surfaceConflicts: ['守潮盟与沉钟会争夺航路解释权'], hiddenCrisis: '有人借假航灯持续清洗整片群岛的旧证据', firstTouchedConflict: '玩家回港当夜就被卷进禁航区封锁', }, keyRelationships: [ { pairs: '玩家 vs 沈砺', relationshipType: '旧友互疑', secretOrCost: '他掌握沉船夜的关键视角', }, ], hiddenLines: { hiddenTruths: ['沉钟异动和旧案灭口是同一条线'], misdirectionHints: ['表面看像海雾自然失控'], revealPacing: '先见异常,再见旧案,再见操盘者', }, iconicElements: { iconicMotifs: ['假航灯', '沉钟回响'], institutionsOrArtifacts: ['旧灯塔', '禁航碑'], hardRules: ['错误航灯会把船引进必死水域'], }, }, landmarks: [ { id: 'landmark-1', name: '沉钟栈桥', description: '旧钟与潮声常年相撞的码头栈桥。', dangerLevel: 'medium', 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: '继续逼近钟楼深处。', }, ], }, ], 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 uses first act image as scene card preview and keeps 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', ); }); 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 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(/当前还有 2 个阻断项/u)).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); });