/* @vitest-environment jsdom */ import { cleanup, render, screen, waitFor, within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { afterEach, expect, test, vi } from 'vitest'; import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService'; import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient'; import type { CustomWorldNpc, CustomWorldPlayableNpc, CustomWorldProfile, SceneActBlueprint, SceneChapterBlueprint, } from '../types'; import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog'; import { type RpgCreationEditorTarget, RpgCreationEntityEditorModal, } from './rpg-creation-editor/RpgCreationEntityEditorModal'; afterEach(() => { cleanup(); }); vi.mock('../data/characterPresets', async () => { const actual = await vi.importActual< typeof import('../data/characterPresets') >('../data/characterPresets'); return { ...actual, buildCustomWorldPlayableCharacters: vi.fn(() => []), buildCustomWorldRuntimeCharacters: vi.fn(() => []), createCharacterSkillCooldowns: vi.fn(() => ({})), getCharacterMaxHp: vi.fn(() => 180), getCharacterMaxMana: vi.fn(() => 60), setRuntimeCharacterOverrides: vi.fn(), }; }); vi.mock('./CharacterAnimator', () => ({ CharacterAnimator: () =>
角色预览
, })); vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => { const generateSceneImage = vi.fn(); const generateSceneNpc = vi.fn(); return { rpgCreationAssetClient: { generateSceneImage, generateSceneNpc, }, generateCustomWorldSceneImage: generateSceneImage, generateCustomWorldSceneNpc: generateSceneNpc, }; }); const mockedRpgCreationAssetClient = vi.mocked( rpgCreationAssetClient.rpgCreationAssetClient, ); vi.mock('./CustomWorldNpcVisualEditor', () => ({ CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
{npc.name}
), CustomWorldNpcVisualEditor: () =>
预设形象编辑器
, })); vi.mock('../hooks/useResolvedAssetReadUrl', () => ({ useResolvedAssetReadUrl: (source: string | null | undefined) => ({ resolvedUrl: source?.trim() ?? '', isResolving: false, shouldResolve: false, }), })); vi.mock('./rpg-runtime-shell', () => ({ RpgRuntimeShell: ({ session, chrome, }: { session: { gameState: { currentScenePreset?: { id?: string; name?: string } | null; playerCharacter?: { name?: string } | null; runtimeSessionId?: string | null; runtimeMode?: string; runtimePersistenceDisabled?: boolean; }; currentStory?: { text?: string } | null; }; chrome?: { hidePlayerLevelBadge?: boolean }; }) => (
幕预览运行时
{chrome?.hidePlayerLevelBadge ?
隐藏等级徽标
: null}
{session.gameState.currentScenePreset?.name ?? '未进入场景'}
{session.gameState.currentScenePreset?.id ?? '未进入场景ID'}
{session.gameState.playerCharacter ? '已选择预览角色' : '未选择角色'}
{session.gameState.runtimeSessionId ?? '未设置预览会话'}
{session.gameState.runtimeMode ?? '未设置运行模式'}
{session.gameState.runtimePersistenceDisabled ? '预览禁用持久化' : '预览允许持久化'}
{session.currentStory?.text ?? '未生成当前故事'}
), })); vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({ fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }), saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined), resolveCharacterRoleAssetWorkflow: vi.fn(({ role }) => Promise.resolve({ ok: true, cache: null, workflow: { role, defaultPromptBundle: { visualPromptText: '', animationPromptText: '', scenePromptText: '', }, visualPromptText: '', animationPromptText: '', animationPromptTextByKey: {}, visualDrafts: [], selectedVisualDraftId: '', selectedAnimation: 'idle', }, }), ), putCharacterRoleAssetWorkflow: vi.fn().mockResolvedValue({ ok: true, cache: null, }), generateCharacterVisualCandidates: vi.fn(), publishCharacterVisualAsset: vi.fn(), generateCharacterAnimationDraft: vi.fn(), publishCharacterAnimationAssets: vi.fn(), })); vi.mock('../services/customWorldCoverAssetService', () => ({ generateCustomWorldCoverImage: vi.fn(), uploadCustomWorldCoverImage: vi.fn(), })); 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: [], initialItems: [], }; } function createStoryRole(id: string, name: string): CustomWorldNpc { return { ...createPlayableRole(id, name), initialAffinity: 6, visual: undefined, }; } function createProfile(): CustomWorldProfile { return { id: 'world-1', settingText: '潮雾群岛上的禁制与旧航道正在一起失衡。', name: '潮雾群岛', subtitle: '旧航道与沉钟回响', summary: '一座正在被旧誓与新利益共同撕扯的群岛世界。', tone: '压抑、潮湿、带着未解旧伤。', playerGoal: '找到能让群岛重新稳定的关键节点。', templateWorldType: 'WUXIA', majorFactions: ['守潮盟', '沉钟会'], coreConflicts: ['旧航道归属', '沉钟遗产争夺'], attributeSchema: { id: 'schema-1', worldId: 'world-1', schemaVersion: 1, generatedFrom: { worldType: 'WUXIA', worldName: '潮雾群岛', settingSummary: '潮雾群岛上的禁制与旧航道正在一起失衡。', tone: '压抑、潮湿、带着未解旧伤。', conflictCore: '旧航道归属', }, slots: [ { slotId: 'axis_a', name: '骨势', }, { slotId: 'axis_b', name: '身法', }, { slotId: 'axis_c', name: '眼脉', }, { slotId: 'axis_d', name: '心焰', }, { slotId: 'axis_e', name: '尘缘', }, { slotId: 'axis_f', name: '玄息', }, ], }, playableNpcs: [createPlayableRole('playable-1', '沈砺')], storyNpcs: [createStoryRole('story-1', '顾潮音')], items: [], camp: { name: '潮灯居', description: '玩家最初落脚的旧灯塔内院。', }, landmarks: [], creatorIntent: null, anchorPack: null, lockState: null, ownedSettingLayers: null, generationMode: 'full', generationStatus: 'complete', } as unknown as CustomWorldProfile; } function createProfileWithLandmark(): CustomWorldProfile { return { ...createProfile(), storyNpcs: [ createStoryRole('story-1', '顾潮音'), createStoryRole('story-2', '闻雪汀'), createStoryRole('story-3', '谢孤灯'), createStoryRole('story-4', '陆听潮'), ], landmarks: [ { id: 'landmark-1', name: '沉钟栈桥', description: '旧钟与潮声常年相撞的码头栈桥。', imageSrc: '/generated-custom-world-scenes/original-scene.png', sceneNpcIds: ['story-1', 'story-2', 'story-3'], connections: [], }, ], } as unknown as CustomWorldProfile; } function createProfileWithTwoLandmarks(): CustomWorldProfile { return { ...createProfileWithLandmark(), landmarks: [ { id: 'landmark-1', name: '沉钟栈桥', description: '旧钟与潮声常年相撞的码头栈桥。', imageSrc: '/generated-custom-world-scenes/original-scene.png', sceneNpcIds: ['story-1', 'story-2', 'story-3'], connections: [], }, { id: 'landmark-2', name: '雾灯塔', description: '雾中仍在闪烁的旧灯塔。', sceneNpcIds: ['story-1', 'story-2', 'story-3'], connections: [], }, ], } as unknown as CustomWorldProfile; } function createSceneAct( sceneId: string, index: number, imageSrc: string, ): SceneActBlueprint { return { id: `${sceneId}-act-${index + 1}`, sceneId, title: `第${index + 1}幕`, summary: `第${index + 1}幕摘要`, stageCoverage: index === 0 ? ['opening'] : ['expansion'], backgroundPromptText: '', backgroundImageSrc: imageSrc, encounterNpcIds: ['story-1'], primaryNpcId: 'story-1', oppositeNpcId: 'story-1', eventDescription: `第${index + 1}幕事件`, linkedThreadIds: [], advanceRule: index === 0 ? 'after_primary_contact' : index >= 2 ? 'after_chapter_resolution' : 'after_active_step_complete', actGoal: `第${index + 1}幕目标`, transitionHook: '', }; } function createSceneChapter( sceneId: string, sceneName: string, imagePrefix: string, ): SceneChapterBlueprint { return { id: `${sceneId}-chapter`, sceneId, title: sceneName, summary: `${sceneName}章节`, sceneTaskDescription: `${sceneName}任务`, linkedThreadIds: [], linkedLandmarkIds: [sceneId], acts: [0, 1, 2].map((index) => createSceneAct(sceneId, index, `${imagePrefix}-act-${index + 1}.png`), ), }; } function createProfileWithSceneChapters(): CustomWorldProfile { return { ...createProfileWithLandmark(), camp: { id: 'custom-scene-camp', name: '潮灯居', description: '玩家最初落脚的旧灯塔内院。', imageSrc: '/generated-custom-world-scenes/camp-main.png', sceneNpcIds: ['story-1'], connections: [], }, sceneChapterBlueprints: [ createSceneChapter( 'custom-scene-camp', '潮灯居', '/generated-custom-world-scenes/camp', ), createSceneChapter( 'landmark-1', '沉钟栈桥', '/generated-custom-world-scenes/landmark', ), ], } as unknown as CustomWorldProfile; } function LandmarkEditorFlowHarness() { const [profile, setProfile] = useState(createProfileWithLandmark()); const [target, setTarget] = useState({ kind: 'landmark', mode: 'edit', id: 'landmark-1', }); return ( <>
        {JSON.stringify(profile)}
      
{}} onEditTarget={setTarget} onProfileChange={setProfile} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} /> setTarget(null)} onProfileChange={setProfile} /> ); } function TwoLandmarkEditorFlowHarness() { const [profile, setProfile] = useState(createProfileWithTwoLandmarks()); const [target, setTarget] = useState({ kind: 'landmark', mode: 'edit', id: 'landmark-1', }); return ( setTarget(null)} onProfileChange={setProfile} /> ); } function LandmarkEditorNoNpcFlowHarness() { const [profile, setProfile] = useState(() => ({ ...createProfileWithLandmark(), storyNpcs: [], landmarks: [ { id: 'landmark-1', name: '空港栈桥', description: '暂时没有角色驻留的场景。', imageSrc: '/generated-custom-world-scenes/empty-scene.png', sceneNpcIds: [], connections: [], }, ], sceneChapterBlueprints: [ { ...createSceneChapter( 'landmark-1', '空港栈桥', '/generated-custom-world-scenes/empty', ), acts: [ { ...createSceneAct( 'landmark-1', 0, '/generated-custom-world-scenes/empty-act-1.png', ), encounterNpcIds: [], primaryNpcId: '', oppositeNpcId: '', }, { ...createSceneAct( 'landmark-1', 1, '/generated-custom-world-scenes/empty-act-2.png', ), encounterNpcIds: [], primaryNpcId: '', oppositeNpcId: '', }, ], }, ], })); const [target, setTarget] = useState({ kind: 'landmark', mode: 'edit', id: 'landmark-1', }); return ( setTarget(null)} onProfileChange={setProfile} /> ); } function readLandmarkHarnessProfile() { const content = screen.getByTestId('landmark-profile-json').textContent; return JSON.parse(content || '{}') as CustomWorldProfile; } function getSceneActCard(index: number) { const card = screen.getAllByTestId('scene-act-card')[index]; if (!card) { throw new Error(`未找到第 ${index + 1} 个幕卡片`); } return card; } function CampEditorFlowHarness() { const [profile, setProfile] = useState({ ...createProfileWithLandmark(), camp: { id: 'custom-scene-camp', name: '潮灯居', description: '玩家最初落脚的旧灯塔内院。', imageSrc: '/generated-custom-world-scenes/original-camp.png', sceneNpcIds: ['story-1', 'story-2', 'story-3'], connections: [ { targetLandmarkId: 'landmark-1', relativePosition: 'north', summary: '北侧通往沉钟栈桥。', }, ], }, }); const [target, setTarget] = useState({ kind: 'camp', }); return ( <>
        {JSON.stringify(profile)}
      
{}} onEditTarget={setTarget} onProfileChange={setProfile} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} /> setTarget(null)} onProfileChange={setProfile} /> ); } function CoverEditorFlowHarness() { const [profile, setProfile] = useState({ ...createProfileWithLandmark(), cover: { sourceType: 'default', imageSrc: null, characterRoleIds: ['playable-1'], }, }); const [target, setTarget] = useState({ kind: 'cover', }); return ( <>
        {JSON.stringify(profile)}
      
setTarget(null)} onProfileChange={setProfile} /> ); } function readCoverHarnessProfile() { const content = screen.getByTestId('cover-profile-json').textContent; return JSON.parse(content || '{}') as CustomWorldProfile; } function findNearestClassName(element: HTMLElement, className: string) { let current: HTMLElement | null = element; while (current) { if (current.className.includes(className)) { return current.className; } current = current.parentElement; } return ''; } function readCampHarnessProfile() { const content = screen.getByTestId('camp-profile-json').textContent; return JSON.parse(content || '{}') as CustomWorldProfile; } test('playable角色打开AI工坊后不会自动关闭', async () => { const user = userEvent.setup(); const handleClose = vi.fn(); render( , ); await user.click(screen.getByRole('button', { name: 'AI生成' })); await waitFor(() => { expect(screen.getByText('AI角色形象生成')).toBeTruthy(); }); expect(handleClose).not.toHaveBeenCalled(); }); test('可扮演角色技能动作状态复用暗色平台胶囊标签', () => { const profile = createProfile(); profile.playableNpcs = [ { ...profile.playableNpcs[0]!, imageSrc: '/generated-custom-world-roles/playable-1.png', generatedVisualAssetId: 'visual-playable-1', generatedAnimationSetId: 'animation-playable-1', initialItems: [ { id: 'starter-token', name: '起始信物', category: '道具', quantity: 1, rarity: 'rare', description: '角色开局携带的测试道具。', tags: ['开局', '信物'], }, ], skills: [ { id: 'tide-slash', name: '潮刃突进', summary: '向前突进并斩击。', style: 'burst', }, ], }, ]; render( , ); const actionStatusBadge = screen.getByText('待生成动作'); const visualStatusBadge = screen.getByText('已应用主图'); const animationStatusBadge = screen.getByText('已应用动作'); const initialItemTagBadge = screen.getByText('开局'); const rolePreviewFrame = screen .getByRole('img', { name: '沈砺' }) .closest('.platform-media-frame'); const skillPreviewFrame = screen .getByRole('img', { name: '潮刃突进' }) .closest('.platform-media-frame'); const sectionPanels = ['背景故事', '与其他角色的关系', '技能', '物品'].map( (title) => screen.getByText(title).closest('section'), ); expect(actionStatusBadge.className).toContain('rounded-full'); expect(actionStatusBadge.className).toContain('font-black'); expect(actionStatusBadge.className).toContain('bg-black/20'); expect(actionStatusBadge.className).toContain('text-zinc-400'); expect(visualStatusBadge.className).toContain('rounded-full'); expect(visualStatusBadge.className).toContain('font-black'); expect(visualStatusBadge.className).toContain('bg-emerald-500/10'); expect(animationStatusBadge.className).toContain('rounded-full'); expect(animationStatusBadge.className).toContain('font-black'); expect(animationStatusBadge.className).toContain('bg-amber-500/10'); expect(initialItemTagBadge.className).toContain('rounded-full'); expect(initialItemTagBadge.className).toContain('font-black'); expect(initialItemTagBadge.className).toContain('bg-black/20'); expect(rolePreviewFrame?.className).toContain('platform-media-frame'); expect(rolePreviewFrame?.className).toContain('aspect-square'); expect(skillPreviewFrame?.className).toContain('platform-media-frame'); expect(skillPreviewFrame?.className).toContain('rounded-none'); for (const sectionPanel of sectionPanels) { expect(sectionPanel?.className).toContain('border-white/10'); expect(sectionPanel?.className).toContain('bg-black/25'); expect(sectionPanel?.className).toContain('rounded-[1.25rem]'); } }); test('可扮演角色空态复用暗色平台空态', () => { render( , ); for (const label of [ '还没有配置与其他角色的关系。', '还没有配置角色技能。', '还没有配置角色物品。', ]) { const emptyState = screen.getByText(label); expect(emptyState.className).toContain('platform-empty-state'); expect(emptyState.className).toContain('border-dashed'); expect(emptyState.className).toContain('bg-black/20'); } }); test('场景角色打开AI工坊后不会自动关闭', async () => { const user = userEvent.setup(); const handleClose = vi.fn(); render( , ); await user.click(screen.getByRole('button', { name: 'AI生成' })); await waitFor(() => { expect(screen.getByText('AI角色形象生成')).toBeTruthy(); }); expect(handleClose).not.toHaveBeenCalled(); }); test('可扮演角色未修改时右上角关闭不会弹确认', async () => { const user = userEvent.setup(); const handleClose = vi.fn(); render( , ); await user.click(screen.getByRole('button', { name: '关闭' })); expect(handleClose).toHaveBeenCalledTimes(1); expect(screen.queryByText('确认关闭')).toBeNull(); }); test('可扮演角色修改后右上角关闭才弹确认', async () => { const user = userEvent.setup(); const handleClose = vi.fn(); render( , ); const nameInput = screen.getByDisplayValue('沈砺'); await user.clear(nameInput); await user.type(nameInput, '沈砺·改'); await user.click(screen.getByRole('button', { name: '关闭' })); expect(handleClose).not.toHaveBeenCalled(); const dialog = screen.getByRole('dialog', { name: '确认关闭' }); expect( within(dialog).getByText('当前修改尚未保存,确认关闭吗?'), ).toBeTruthy(); await user.click(within(dialog).getByRole('button', { name: '继续编辑' })); expect(handleClose).not.toHaveBeenCalled(); expect(screen.queryByRole('dialog', { name: '确认关闭' })).toBeNull(); await user.click(screen.getByRole('button', { name: '关闭' })); await user.click(screen.getByRole('button', { name: '确认关闭' })); expect(handleClose).toHaveBeenCalledTimes(1); }); test('可扮演角色至少保留一个背景章节时使用统一提示弹窗', async () => { const user = userEvent.setup(); const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); render( , ); const deleteChapterButtons = () => screen.getAllByRole('button', { name: '删除章节' }); await user.click(deleteChapterButtons()[0]!); await user.click(deleteChapterButtons()[0]!); await user.click(deleteChapterButtons()[0]!); expect(deleteChapterButtons()).toHaveLength(1); await user.click(deleteChapterButtons()[0]!); const dialog = await screen.findByRole('dialog', { name: '提示' }); expect(within(dialog).getByText('至少保留一个背景章节。')).toBeTruthy(); expect(alertSpy).not.toHaveBeenCalled(); expect(screen.getByText('编辑角色:沈砺')).toBeTruthy(); await user.click(within(dialog).getByRole('button', { name: '知道了' })); await waitFor(() => { expect(screen.queryByRole('dialog', { name: '提示' })).toBeNull(); }); alertSpy.mockRestore(); }); test('场景角色未修改时右上角关闭不会弹确认', async () => { const user = userEvent.setup(); const handleClose = vi.fn(); render( , ); await user.click(screen.getByRole('button', { name: '关闭' })); expect(handleClose).toHaveBeenCalledTimes(1); expect(screen.queryByText('确认关闭')).toBeNull(); }); test('场景角色修改后右上角关闭才弹确认', async () => { const user = userEvent.setup(); const handleClose = vi.fn(); render( , ); const nameInput = screen.getByDisplayValue('顾潮音'); await user.clear(nameInput); await user.type(nameInput, '顾潮音·改'); await user.click(screen.getByRole('button', { name: '关闭' })); expect(handleClose).not.toHaveBeenCalled(); const dialog = screen.getByRole('dialog', { name: '确认关闭' }); expect( within(dialog).getByText('当前修改尚未保存,确认关闭吗?'), ).toBeTruthy(); await user.click(within(dialog).getByRole('button', { name: '继续编辑' })); expect(handleClose).not.toHaveBeenCalled(); expect(screen.queryByRole('dialog', { name: '确认关闭' })).toBeNull(); await user.click(screen.getByRole('button', { name: '关闭' })); await user.click(screen.getByRole('button', { name: '确认关闭' })); expect(handleClose).toHaveBeenCalledTimes(1); }); test('世界页基本设定编辑按钮打开基本设定编辑目标', async () => { const user = userEvent.setup(); const handleEditTarget = vi.fn(); render( {}} onEditTarget={handleEditTarget} onProfileChange={() => {}} />, ); const editButtons = screen.getAllByRole('button', { name: '编辑' }); const foundationEditButton = editButtons[1]; expect(foundationEditButton).toBeDefined(); await user.click(foundationEditButton as HTMLElement); expect(handleEditTarget).toHaveBeenCalledWith({ kind: 'foundation' }); }); test('基本设定用分号拆分成标签展示', () => { const profile = { ...createProfile(), anchorContent: { worldPromise: '机械微生物吞并进化;角色被迫寄生改造;在失控系统里求生', playerFantasy: null, themeBoundary: null, playerEntryPoint: null, coreConflict: null, keyRelationships: null, hiddenLines: null, iconicElements: null, }, } as CustomWorldProfile; render( {}} onEditTarget={() => {}} onProfileChange={() => {}} />, ); const foundationSection = screen.getByText('世界承诺').closest('div'); expect(foundationSection).not.toBeNull(); expect(screen.getByText('机械微生物吞并进化')).toBeTruthy(); expect(screen.getByText('角色被迫寄生改造')).toBeTruthy(); expect(screen.getByText('在失控系统里求生')).toBeTruthy(); }); test('基本设定目标打开独立编辑面板', () => { render( , ); expect(screen.getByText('编辑基本设定')).toBeTruthy(); expect(screen.queryByText('编辑世界信息')).toBeNull(); }); test('基本设定面板只编辑六个角色维度名称', async () => { const user = userEvent.setup(); const savedProfileRef: { current: CustomWorldProfile | null } = { current: null, }; render( {}} onProfileChange={(profile) => { savedProfileRef.current = profile; }} />, ); expect(screen.getByText('角色维度')).toBeTruthy(); const nameInputs = screen.getAllByLabelText('维度名称'); await user.clear(nameInputs[0]!); await user.type(nameInputs[0]!, '潮骨'); expect(screen.queryByLabelText('定义')).toBeNull(); expect(screen.queryByLabelText('正向信号')).toBeNull(); expect(screen.queryByLabelText('战斗体现')).toBeNull(); await user.click(screen.getByRole('button', { name: /保存修改/u })); expect(savedProfileRef.current?.attributeSchema.slots[0]?.name).toBe('潮骨'); }); test('可扮演角色列表使用缩略卡片并点击进入编辑', async () => { const user = userEvent.setup(); const handleEditTarget = vi.fn(); render( {}} onEditTarget={handleEditTarget} onProfileChange={vi.fn()} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} />, ); expect(screen.queryByText(/公开背景/u)).toBeNull(); const playableCard = screen.getByRole('button', { name: /沈砺/u }); const playableMediaFrame = Array.from( playableCard.querySelectorAll('.platform-subpanel'), ).find((element) => element.className.includes('h-[4.75rem]')); expect(playableCard.tagName).toBe('BUTTON'); expect(playableCard.className).toContain('platform-subpanel'); expect(playableCard.className).toContain('rounded-[1.3rem]'); expect(playableMediaFrame).toBeTruthy(); expect(playableMediaFrame?.className).toContain('platform-subpanel'); expect(playableMediaFrame?.className).toContain('rounded-[1rem]'); expect(playableMediaFrame?.className).toContain('p-0'); await user.click(playableCard); expect(handleEditTarget).toHaveBeenCalledWith({ kind: 'playable', mode: 'edit', id: 'playable-1', }); }); test('实体目录批量选择卡片和选择徽标复用平台公共组件 chrome', async () => { const user = userEvent.setup(); render( {}} onEditTarget={() => {}} onProfileChange={() => {}} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} />, ); await user.click(screen.getByRole('button', { name: '批量删除' })); const storyCard = screen.getByRole('button', { name: /顾潮音/u }); const idleBadge = within(storyCard).getByText('选择'); expect(storyCard.tagName).toBe('BUTTON'); expect(storyCard.className).toContain('platform-subpanel'); expect(storyCard.className).toContain('rounded-[1.3rem]'); expect(idleBadge.className).toContain('bg-[var(--platform-subpanel-fill)]'); expect(idleBadge.className).toContain('text-[var(--platform-text-soft)]'); await user.click(storyCard); const selectedStoryCard = screen.getByRole('button', { name: /顾潮音/u }); const selectedBadge = within(selectedStoryCard).getByText('已选'); expect(selectedStoryCard.className).not.toContain('platform-subpanel'); expect(selectedStoryCard.className).toContain( 'border-[var(--platform-button-danger-border)]', ); expect(selectedStoryCard.className).toContain( 'bg-[var(--platform-button-danger-fill)]', ); expect(selectedBadge.className).toContain( 'border-[var(--platform-button-danger-border)]', ); }); test('实体目录场景图片框复用平台媒体框 chrome', () => { render( {}} onEditTarget={() => {}} onProfileChange={() => {}} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} />, ); const sceneImage = screen.getByRole('img', { name: '沉钟栈桥' }); const mediaFrame = sceneImage.closest('div.relative'); expect(mediaFrame?.className).toContain('aspect-[16/9]'); expect(mediaFrame?.className).toContain( 'border-[var(--platform-subpanel-border)]', ); expect(mediaFrame?.className).toContain('radial-gradient'); expect(sceneImage.className).toContain('object-cover'); }); test('实体目录搜索框和空态复用平台公共组件 chrome', async () => { const user = userEvent.setup(); render( {}} onEditTarget={() => {}} onProfileChange={() => {}} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} />, ); const searchInput = screen.getByPlaceholderText('搜索角色名称、称号、标签'); expect(searchInput.className).toContain( 'border-[var(--platform-subpanel-border)]', ); expect(searchInput.className).toContain('focus:ring-2'); await user.type(searchInput, '没有这个角色'); const emptyState = screen .getByText('当前没有符合搜索条件的可扮演角色。') .closest('div.rounded-2xl'); expect(emptyState?.className).toContain('border-dashed'); expect(emptyState?.className).toContain('bg-white/52'); }); test('世界页统计和基本设定复用平台公共组件 chrome', () => { render( {}} onEditTarget={() => {}} onProfileChange={() => {}} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} />, ); const archiveScaleSection = screen .getByText('档案规模') .closest('.platform-surface'); const playableStatCard = within(archiveScaleSection as HTMLElement).getByText( '可扮演角色', ).parentElement; const worldTonePanel = screen .getByText(/世界基调:/u) .closest('div.rounded-2xl'); const roleDimensionPanel = screen .getByText('角色维度') .closest('div.rounded-2xl'); const foundationPanel = screen .getByText('玩家幻想') .closest('div.rounded-2xl'); expect(playableStatCard?.className).toContain('platform-subpanel'); expect(playableStatCard?.className).toContain('bg-white/68'); expect(playableStatCard?.className).toContain('rounded-xl'); expect(worldTonePanel?.className).toContain('platform-subpanel'); expect(roleDimensionPanel?.className).toContain('platform-subpanel'); expect(foundationPanel?.className).toContain('platform-subpanel'); expect(screen.getByText('角色维度').className).toContain('tracking-[0.18em]'); }); test('可扮演角色删除改用统一确认弹窗', async () => { const user = userEvent.setup(); const handleProfileChange = vi.fn(); render( {}} onEditTarget={() => {}} onProfileChange={handleProfileChange} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} />, ); await user.click(screen.getAllByRole('button', { name: '删除' })[0]!); const dialog = screen.getByRole('dialog', { name: '删除角色' }); expect( within(dialog).getByText('确认删除可扮演角色「沈砺」吗?'), ).toBeTruthy(); await user.click(within(dialog).getByRole('button', { name: '取消' })); expect(handleProfileChange).not.toHaveBeenCalled(); await user.click(screen.getAllByRole('button', { name: '删除' })[0]!); await user.click( screen.getByRole('button', { name: '确认删除', }), ); expect(handleProfileChange).toHaveBeenCalledTimes(1); expect(handleProfileChange.mock.calls[0]?.[0].playableNpcs).toHaveLength(1); expect(handleProfileChange.mock.calls[0]?.[0].playableNpcs[0]?.name).toBe( '闻潮', ); }); test('最后一个可扮演角色不可删除时使用统一提示弹窗', async () => { const user = userEvent.setup(); const handleProfileChange = vi.fn(); render( {}} onEditTarget={() => {}} onProfileChange={handleProfileChange} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} />, ); await user.click(screen.getByRole('button', { name: '删除' })); const dialog = screen.getByRole('dialog', { name: '无法删除' }); expect( within(dialog).getByText( '至少保留一个可扮演角色,才能正常进入自定义世界。', ), ).toBeTruthy(); await user.click(within(dialog).getByRole('button', { name: '知道了' })); expect(handleProfileChange).not.toHaveBeenCalled(); expect(screen.queryByRole('dialog', { name: '无法删除' })).toBeNull(); }); test('批量删除场景角色使用统一确认弹窗', async () => { const user = userEvent.setup(); const handleDeleteStoryNpcs = vi.fn(); render( {}} onEditTarget={() => {}} onProfileChange={() => {}} onDeleteStoryNpcs={handleDeleteStoryNpcs} onDeleteLandmarks={() => {}} />, ); await user.click(screen.getByRole('button', { name: '批量删除' })); await user.click(screen.getByRole('button', { name: /顾潮音/u })); await user.click(screen.getByRole('button', { name: /闻雪汀/u })); await user.click(screen.getByRole('button', { name: '删除选中' })); const dialog = screen.getByRole('dialog', { name: '批量删除' }); expect( within(dialog).getByText('确认批量删除 2 个场景角色吗?'), ).toBeTruthy(); expect(handleDeleteStoryNpcs).not.toHaveBeenCalled(); await user.click(within(dialog).getByRole('button', { name: '确认删除' })); expect(handleDeleteStoryNpcs).toHaveBeenCalledWith(['story-1', 'story-2']); expect(screen.queryByRole('button', { name: '删除选中' })).toBeNull(); }); test('实体目录在空 id 列表项下不会触发重复 key 警告', () => { const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => undefined); render( {}} onEditTarget={() => {}} onProfileChange={vi.fn()} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} />, ); expect(screen.getByRole('button', { name: /沈砺/u })).toBeTruthy(); expect(screen.getByRole('button', { name: /闻潮/u })).toBeTruthy(); const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) => call.some( (arg) => typeof arg === 'string' && arg.includes('Encountered two children with the same key'), ), ); expect(duplicateKeyCalls).toHaveLength(0); }); test('场景图片保存后会同步更新编辑页和场景列表', async () => { mockedRpgCreationAssetClient.generateSceneImage.mockClear(); mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({ imageSrc: '/generated-custom-world-scenes/updated-scene.png', assetId: 'asset-1', model: 'wan2.2-t2i-flash', size: '1280*720', taskId: 'task-1', prompt: '更新后的场景图', }); const user = userEvent.setup(); render(); const initialListImage = screen.getByRole('img', { name: '沉钟栈桥' }); expect(initialListImage.getAttribute('src')).toBe( '/generated-custom-world-scenes/original-scene.png', ); const firstActCard = getSceneActCard(0); await user.click( within(firstActCard).getByRole('button', { name: '配置背景' }), ); await waitFor(() => { expect(screen.getByText('配置幕背景:第1幕')).toBeTruthy(); }); expect(screen.queryByText('场景图片')).toBeNull(); expect(screen.queryByText('场景内 NPC')).toBeNull(); await user.click(screen.getByRole('button', { name: 'AI生成' })); await waitFor(() => { expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: '开始生成' })); await waitFor(() => { expect( mockedRpgCreationAssetClient.generateSceneImage, ).toHaveBeenCalledTimes(1); }); await waitFor(() => { const generatedStatus = screen.getByText('已生成完毕,请保存后再退出页面'); expect(generatedStatus.className).toContain('platform-status-message'); expect(generatedStatus.className).toContain('text-emerald-50'); }); await user.click(screen.getByRole('button', { name: '保存' })); await waitFor(() => { expect(screen.queryByText('智能生成:沉钟栈桥')).toBeNull(); }); await waitFor(() => { expect( screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src'), ).toBe('/generated-custom-world-scenes/updated-scene.png'); }); await user.click(screen.getByRole('button', { name: '保存背景' })); await waitFor(() => { expect(screen.queryByText('配置幕背景:第1幕')).toBeNull(); }); await user.click(screen.getByRole('button', { name: /保存修改/u })); await waitFor(() => { expect( screen.getByRole('img', { name: '沉钟栈桥' }).getAttribute('src'), ).toBe('/generated-custom-world-scenes/updated-scene.png'); }); const savedProfile = readLandmarkHarnessProfile(); expect(savedProfile.landmarks[0]?.imageSrc).toBe( '/generated-custom-world-scenes/updated-scene.png', ); const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find( (entry) => entry.sceneId === 'landmark-1', ); expect(savedSceneChapter?.acts[0]?.backgroundImageSrc).toBe( '/generated-custom-world-scenes/updated-scene.png', ); expect(savedSceneChapter?.acts[1]?.backgroundImageSrc).not.toBe( '/generated-custom-world-scenes/updated-scene.png', ); expect(savedSceneChapter?.acts[2]?.backgroundImageSrc).not.toBe( '/generated-custom-world-scenes/updated-scene.png', ); }); test('幕背景生成未保存时退出使用统一确认弹窗', async () => { mockedRpgCreationAssetClient.generateSceneImage.mockClear(); mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({ imageSrc: '/generated-custom-world-scenes/unsaved-scene.png', assetId: 'asset-unsaved-1', model: 'wan2.2-t2i-flash', size: '1280*720', taskId: 'task-unsaved-1', prompt: '未保存的场景图', }); const user = userEvent.setup(); render(); await user.click( within(getSceneActCard(0)).getByRole('button', { name: '配置背景' }), ); await waitFor(() => { expect(screen.getByText('配置幕背景:第1幕')).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: 'AI生成' })); await waitFor(() => { expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: '开始生成' })); await waitFor(() => { expect( mockedRpgCreationAssetClient.generateSceneImage, ).toHaveBeenCalledTimes(1); }); const closeButtons = screen.getAllByRole('button', { name: '关闭' }); await user.click(closeButtons[closeButtons.length - 1]!); const exitDialog = screen.getByRole('dialog', { name: '确认退出' }); expect( within(exitDialog).getByText( '当前生成画面还未保存,退出后将丢失这次生成结果,仍然退出吗?', ), ).toBeTruthy(); await user.click( within(exitDialog).getByRole('button', { name: '继续编辑' }), ); expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy(); const reopenedCloseButtons = screen.getAllByRole('button', { name: '关闭' }); await user.click(reopenedCloseButtons[reopenedCloseButtons.length - 1]!); await user.click(screen.getByRole('button', { name: '仍然退出' })); await waitFor(() => { expect(screen.queryByText('智能生成:沉钟栈桥')).toBeNull(); }); }); test('开局场景图片保存后会同步更新编辑页和场景列表', async () => { mockedRpgCreationAssetClient.generateSceneImage.mockClear(); mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({ imageSrc: '/generated-custom-world-scenes/updated-camp.png', assetId: 'asset-camp-1', model: 'wan2.2-t2i-flash', size: '1280*720', taskId: 'task-camp-1', prompt: '更新后的开局场景图', }); const user = userEvent.setup(); render(); const initialListImage = screen.getByRole('img', { name: '潮灯居' }); expect(initialListImage.getAttribute('src')).toBe( '/generated-custom-world-scenes/original-camp.png', ); const firstActCard = getSceneActCard(0); await user.click( within(firstActCard).getByRole('button', { name: '配置背景' }), ); await waitFor(() => { expect(screen.getByText('配置幕背景:第1幕')).toBeTruthy(); }); expect(screen.queryByText('场景图片')).toBeNull(); expect(screen.queryByText('场景内 NPC')).toBeNull(); await user.click(screen.getByRole('button', { name: 'AI生成' })); await waitFor(() => { expect(screen.getByText('智能生成:潮灯居')).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: '开始生成' })); await waitFor(() => { expect( mockedRpgCreationAssetClient.generateSceneImage, ).toHaveBeenCalledTimes(1); }); await user.click(screen.getByRole('button', { name: '保存' })); await waitFor(() => { expect(screen.queryByText('智能生成:潮灯居')).toBeNull(); }); await waitFor(() => { expect( screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src'), ).toBe('/generated-custom-world-scenes/updated-camp.png'); }); await user.click(screen.getByRole('button', { name: '保存背景' })); await waitFor(() => { expect(screen.queryByText('配置幕背景:第1幕')).toBeNull(); }); await user.click(screen.getByRole('button', { name: /保存修改/u })); await waitFor(() => { expect( screen.getByRole('img', { name: '潮灯居' }).getAttribute('src'), ).toBe('/generated-custom-world-scenes/updated-camp.png'); }); const savedProfile = readCampHarnessProfile(); expect(savedProfile.camp?.imageSrc).toBe( '/generated-custom-world-scenes/updated-camp.png', ); const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find( (entry) => entry.sceneId === 'custom-scene-camp', ); expect(savedSceneChapter?.acts[0]?.backgroundImageSrc).toBe( '/generated-custom-world-scenes/updated-camp.png', ); expect(savedSceneChapter?.acts[1]?.backgroundImageSrc).not.toBe( '/generated-custom-world-scenes/updated-camp.png', ); expect(savedSceneChapter?.acts[2]?.backgroundImageSrc).not.toBe( '/generated-custom-world-scenes/updated-camp.png', ); }); test('开局场景在场景配置面板中与普通场景使用同级参数并可保存', async () => { const user = userEvent.setup(); render(); expect(screen.getByText('多幕配置')).toBeTruthy(); expect(screen.getByText('场景连接关系')).toBeTruthy(); expect(screen.queryByText('场景图片')).toBeNull(); expect(screen.queryByText('场景内 NPC')).toBeNull(); expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3); const firstActCard = getSceneActCard(0); await user.click( within(firstActCard).getAllByTestId('scene-act-slot-button')[0]!, ); await waitFor(() => { expect(screen.getByText('配置角色:第1幕 · 主角色槽位')).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: /闻雪汀/u })); await user.click(screen.getByRole('button', { name: '保存角色' })); await waitFor(() => { expect(screen.queryByText('配置角色:第1幕 · 主角色槽位')).toBeNull(); }); await user.click(screen.getByRole('button', { name: /保存修改/u })); await waitFor(() => { expect(screen.queryByText('编辑场景:潮灯居')).toBeNull(); }); const savedProfile = readCampHarnessProfile(); const openingSceneChapter = savedProfile.sceneChapterBlueprints?.find( (entry) => entry.sceneId === 'custom-scene-camp', ); expect(savedProfile.camp?.sceneNpcIds).toContain('story-2'); expect(savedProfile.camp?.connections).toEqual([ { targetLandmarkId: 'landmark-1', relativePosition: 'north', summary: '北侧通往沉钟栈桥。', }, ]); expect(openingSceneChapter).toBeTruthy(); expect(openingSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-2'); expect(openingSceneChapter?.acts[1]?.encounterNpcIds[0]).not.toBe('story-2'); expect(openingSceneChapter?.acts[1]?.primaryNpcId).not.toBe('story-2'); expect(openingSceneChapter?.acts[2]?.encounterNpcIds[0]).not.toBe('story-2'); expect(openingSceneChapter?.acts[2]?.primaryNpcId).not.toBe('story-2'); expect(openingSceneChapter?.linkedLandmarkIds).toContain('custom-scene-camp'); }); test('开局场景列表与详情幕预览复用同一套幕级图片', async () => { const profile = createProfileWithSceneChapters(); profile.sceneChapterBlueprints![0]!.acts[1]!.backgroundPromptText = '第二幕专属背景提示'; const user = userEvent.setup(); render( <> {}} onEditTarget={() => {}} onProfileChange={() => {}} onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} /> {}} onProfileChange={() => {}} /> , ); expect( screen.getByRole('img', { name: '潮灯居-第2幕' }).getAttribute('src'), ).toBe('/generated-custom-world-scenes/camp-act-2.png'); const campSecondActPreview = screen .getByLabelText('潮灯居-第2幕预览') .closest('div'); expect(campSecondActPreview?.className).toContain('platform-subpanel'); expect(campSecondActPreview?.className).toContain('rounded-xl'); expect(campSecondActPreview?.className).toContain('p-0'); expect( screen.getByRole('img', { name: '沉钟栈桥-第2幕' }).getAttribute('src'), ).toBe('/generated-custom-world-scenes/landmark-act-2.png'); expect( screen.getByRole('img', { name: '第2幕幕背景' }).getAttribute('src'), ).toBe('/generated-custom-world-scenes/camp-act-2.png'); await user.click( within(getSceneActCard(1)).getByRole('button', { name: '配置背景' }), ); await waitFor(() => { expect(screen.getByText('配置幕背景:第2幕')).toBeTruthy(); }); expect( screen.getByRole('img', { name: '第2幕背景预览' }).getAttribute('src'), ).toBe('/generated-custom-world-scenes/camp-act-2.png'); }); test('开局场景幕背景智能生成复用当前幕图片和幕级提示词', async () => { mockedRpgCreationAssetClient.generateSceneImage.mockClear(); mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({ imageSrc: '/generated-custom-world-scenes/camp-act-2-ai.png', assetId: 'asset-camp-act-2', model: 'wan2.2-t2i-flash', size: '1280*720', taskId: 'task-camp-act-2', prompt: '第二幕专属背景提示', }); const profile = createProfileWithSceneChapters(); profile.sceneChapterBlueprints![0]!.acts[1]!.backgroundPromptText = '第二幕专属背景提示'; const user = userEvent.setup(); render( {}} onProfileChange={() => {}} />, ); await user.click( within(getSceneActCard(1)).getByRole('button', { name: '配置背景' }), ); await waitFor(() => { expect(screen.getByText('配置幕背景:第2幕')).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: 'AI生成' })); await waitFor(() => { expect(screen.getByText('智能生成:潮灯居')).toBeTruthy(); }); expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe( '/generated-custom-world-scenes/camp-act-2.png', ); await user.click(screen.getByRole('button', { name: '开始生成' })); await waitFor(() => { expect( mockedRpgCreationAssetClient.generateSceneImage, ).toHaveBeenCalledTimes(1); }); const payload = mockedRpgCreationAssetClient.generateSceneImage.mock.calls[0]?.[0]; expect(payload?.userPrompt).toBe('第二幕专属背景提示'); }); test('普通场景世界地图会包含开局场景并高亮当前场景', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: '查看世界地图' })); await waitFor(() => { expect(screen.getByText('世界地图')).toBeTruthy(); }); expect(screen.getAllByText('潮灯居').length).toBeGreaterThan(0); expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0); const currentBadge = screen.getByText('当前'); expect(currentBadge.className).toContain('rounded-full'); expect(currentBadge.className).toContain( 'bg-[var(--platform-subpanel-fill)]', ); }); test('世界地图会展示当前未保存的场景连接', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByText('北')); await user.click(screen.getByRole('button', { name: /雾灯塔/u })); await waitFor(() => { expect(screen.queryByText('北侧连接')).toBeNull(); }); await user.click(screen.getByRole('button', { name: '查看世界地图' })); await waitFor(() => { expect(screen.getByText('世界地图')).toBeTruthy(); }); expect(screen.getAllByText('雾灯塔').length).toBeGreaterThan(0); expect(screen.getAllByText('北').length).toBeGreaterThan(0); }); test('场景连接缺少可连接目标时使用统一提示弹窗', async () => { const user = userEvent.setup(); const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); render(); await user.click(screen.getByText('北')); const dialog = await screen.findByRole('dialog', { name: '提示' }); expect( within(dialog).getByText('请先保留至少一个其他场景,才能配置连接关系。'), ).toBeTruthy(); expect(alertSpy).not.toHaveBeenCalled(); await user.click(within(dialog).getByRole('button', { name: '知道了' })); await waitFor(() => { expect(screen.queryByRole('dialog', { name: '提示' })).toBeNull(); }); alertSpy.mockRestore(); }); test('场景编辑器会在场景内展示槽位化多幕配置并保存', async () => { const user = userEvent.setup(); render(); expect(screen.getByText('多幕配置')).toBeTruthy(); const connectionPanel = screen.getByText('场景连接关系').closest('section'); expect(connectionPanel).toBeTruthy(); expect(connectionPanel?.className).toContain('bg-black/25'); expect(connectionPanel?.className).toContain('rounded-[1.25rem]'); expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3); expect(screen.queryByText('幕标题')).toBeNull(); expect(screen.queryByText('幕摘要')).toBeNull(); expect(screen.queryByText('幕目标')).toBeNull(); expect(screen.queryByText('过渡铺垫')).toBeNull(); const firstActCard = getSceneActCard(0); expect( within(firstActCard).getAllByTestId('scene-act-slot-button'), ).toHaveLength(3); await user.click( within(firstActCard).getByRole('button', { name: '配置背景' }), ); await waitFor(() => { expect(screen.getByText('配置幕背景:第1幕')).toBeTruthy(); }); const presetImage = screen.getByRole('img', { name: '幕背景预设 1' }); const presetSrc = presetImage.getAttribute('src'); const presetFrame = presetImage.closest('.platform-media-frame'); const presetPanel = screen.getByText('预设背景').closest('section'); const presetButton = presetImage.closest('button'); expect(presetPanel?.className).toContain('bg-black/25'); expect(presetPanel?.className).toContain('rounded-[1.25rem]'); expect(presetFrame?.className).toContain('aspect-[16/9]'); expect(presetFrame?.className).toContain('rounded-none'); expect(presetButton).toBeTruthy(); if (!presetButton) { throw new Error('未找到幕背景预设按钮'); } await user.click(presetButton); await user.click(screen.getByRole('button', { name: '保存背景' })); await waitFor(() => { expect(screen.queryByText('配置幕背景:第1幕')).toBeNull(); }); await user.click( within(getSceneActCard(0)).getAllByTestId('scene-act-slot-button')[0]!, ); await waitFor(() => { expect(screen.getByText('配置角色:第1幕 · 主角色槽位')).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: /闻雪汀/u })); await user.click(screen.getByRole('button', { name: '保存角色' })); await waitFor(() => { expect(screen.queryByText('配置角色:第1幕 · 主角色槽位')).toBeNull(); }); expect( within(getSceneActCard(0)).getByRole('button', { name: '配置第1个角色:闻雪汀', }), ).toBeTruthy(); await user.click(screen.getByRole('button', { name: /保存修改/u })); await waitFor(() => { expect(screen.queryByText('编辑场景:沉钟栈桥')).toBeNull(); }); const savedProfile = readLandmarkHarnessProfile(); const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find( (entry) => entry.sceneId === 'landmark-1', ); expect(savedSceneChapter).toBeTruthy(); expect(savedSceneChapter?.acts[0]?.backgroundImageSrc).toBe(presetSrc); expect(savedSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-2'); expect(savedSceneChapter?.acts[0]?.primaryNpcId).toBe('story-2'); }); test('场景多幕支持新增删除和调序', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: '新增一幕' })); expect(screen.getAllByTestId('scene-act-card')).toHaveLength(4); const secondActCard = getSceneActCard(1); await user.click( within(secondActCard).getAllByTestId('scene-act-slot-button')[0]!, ); await waitFor(() => { expect(screen.getByText('配置角色:第2幕 · 主角色槽位')).toBeTruthy(); }); await user.click(screen.getByRole('button', { name: /谢孤灯/u })); await user.click(screen.getByRole('button', { name: '保存角色' })); await user.click(within(secondActCard).getByRole('button', { name: '下移' })); const fourthActCard = getSceneActCard(3); await user.click(within(fourthActCard).getByRole('button', { name: '删除' })); expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3); await user.click(screen.getByRole('button', { name: /保存修改/u })); await waitFor(() => { expect(screen.queryByText('编辑场景:沉钟栈桥')).toBeNull(); }); const savedProfile = readLandmarkHarnessProfile(); const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find( (entry) => entry.sceneId === 'landmark-1', ); expect(savedSceneChapter?.acts).toHaveLength(3); expect(savedSceneChapter?.acts[2]?.primaryNpcId).toBe('story-3'); }); test('每幕角色槽位可以从当前世界所有 NPC 中选择', async () => { const user = userEvent.setup(); render(); await user.click( within(getSceneActCard(0)).getAllByTestId('scene-act-slot-button')[0]!, ); await waitFor(() => { expect(screen.getByText('配置角色:第1幕 · 主角色槽位')).toBeTruthy(); }); const npcOption = screen.getByRole('button', { name: /陆听潮/u }); const selectBadge = within(npcOption).getByText('选择'); expect(npcOption).toBeTruthy(); expect(selectBadge.className).toContain('rounded-full'); expect(selectBadge.className).toContain('font-black'); expect(selectBadge.className).toContain('bg-black/20'); expect(selectBadge.className).toContain('text-zinc-400'); await user.click(npcOption); await user.click(screen.getByRole('button', { name: '保存角色' })); await waitFor(() => { expect(screen.queryByText('配置角色:第1幕 · 主角色槽位')).toBeNull(); }); await user.click(screen.getByRole('button', { name: /保存修改/u })); await waitFor(() => { expect(screen.queryByText('编辑场景:沉钟栈桥')).toBeNull(); }); const savedProfile = readLandmarkHarnessProfile(); const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find( (entry) => entry.sceneId === 'landmark-1', ); expect(savedSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-4'); expect(savedProfile.landmarks[0]?.sceneNpcIds).toContain('story-4'); }); test('作品封面来源状态复用暗色平台胶囊标签', () => { render(); const sourceBadge = screen.getByText('当前为默认封面'); expect(sourceBadge.className).toContain('rounded-full'); expect(sourceBadge.className).toContain('font-black'); expect(sourceBadge.className).toContain('bg-black/20'); }); test('场景保存缺少主角色时使用统一提示弹窗', async () => { const user = userEvent.setup(); const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); render(); await user.click(screen.getByRole('button', { name: /保存修改/u })); const dialog = await screen.findByRole('dialog', { name: '提示' }); expect(within(dialog).getByText('请至少为一幕配置主角色。')).toBeTruthy(); expect(alertSpy).not.toHaveBeenCalled(); expect(screen.getByText('编辑场景:空港栈桥')).toBeTruthy(); alertSpy.mockRestore(); }); test('场景幕预览会打开当前幕运行时面板', async () => { const user = userEvent.setup(); render(); await user.click( within(getSceneActCard(0)).getByRole('button', { name: '幕预览' }), ); await waitFor(() => { expect(screen.getByText('幕预览运行时')).toBeTruthy(); }); expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0); expect(screen.getByText('隐藏等级徽标')).toBeTruthy(); expect(screen.getByText('已选择预览角色')).toBeTruthy(); expect(screen.getByText('runtime-scene-act-preview')).toBeTruthy(); expect(screen.getByText('landmark-1')).toBeTruthy(); expect(screen.getByText('play')).toBeTruthy(); expect(screen.getByText('预览禁用持久化')).toBeTruthy(); expect(screen.getByText(/顾潮音已经在沉钟栈桥等你/u)).toBeTruthy(); expect(screen.queryByText('正在载入这一幕的游戏流程...')).toBeNull(); await user.click(screen.getByRole('button', { name: '结束预览' })); await waitFor(() => { expect(screen.queryByText('幕预览运行时')).toBeNull(); }); }); test('作品封面上传会先进入 16:9 裁剪面板再提交到后端', async () => { const uploadedCover = { imageSrc: '/generated-custom-world-covers/world-1/uploaded/cover.webp', assetId: 'custom-cover-upload-1', sourceType: 'uploaded' as const, }; let resolveUpload!: (value: typeof uploadedCover) => void; const uploadPromise = new Promise((resolve) => { resolveUpload = resolve; }); const uploadMock = vi .mocked(customWorldCoverAssetService.uploadCustomWorldCoverImage) .mockReturnValue(uploadPromise); class MockFileReader { result: string | null = null; error: Error | null = null; onload: null | (() => void) = null; onerror: null | (() => void) = null; readAsDataURL() { this.result = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII='; this.onload?.(); } } class MockImage { onload: null | (() => void) = null; onerror: null | (() => void) = null; naturalWidth = 1920; naturalHeight = 1080; set src(_value: string) { this.onload?.(); } } vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader); vi.stubGlobal('Image', MockImage as unknown as typeof Image); const user = userEvent.setup(); render(); const input = document.querySelector( 'input[type="file"]', ) as HTMLInputElement | null; expect(input).toBeTruthy(); if (!input) { throw new Error('未找到封面上传输入框'); } const file = new File(['cover'], 'cover.png', { type: 'image/png' }); await user.upload(input, file); await waitFor(() => { expect(screen.getByText('裁剪上传封面')).toBeTruthy(); }); const cropPreviewFrame = screen .getByRole('img', { name: '上传封面裁剪预览' }) .closest('.platform-media-frame'); const cropResultFrame = screen .getByRole('img', { name: '上传封面裁剪结果预览' }) .closest('.platform-media-frame'); expect(cropPreviewFrame?.className).toContain('bg-black/40'); expect(cropResultFrame?.className).toContain('aspect-[16/9]'); expect( screen.getByRole('button', { name: '拖拽右下角裁剪边界' }), ).toBeTruthy(); expect(screen.queryByText('左右位置')).toBeNull(); expect(screen.queryByText('上下位置')).toBeNull(); await user.click(screen.getByRole('button', { name: '确认裁剪并上传' })); const uploadSavingPanelClassName = findNearestClassName( await screen.findByText('正在保存封面资源,请稍候。'), 'bg-sky-500/8', ); expect(uploadSavingPanelClassName).toContain('border-sky-400/18'); expect(uploadSavingPanelClassName).toContain('rounded-[1rem]'); await waitFor(() => { expect(uploadMock).toHaveBeenCalledTimes(1); }); const uploadPayload = uploadMock.mock.calls[0]?.[0]; expect(uploadPayload?.worldName).toBe('潮雾群岛'); expect(uploadPayload?.cropRect.width).toBeGreaterThan(0); expect(uploadPayload?.cropRect.height).toBeGreaterThan(0); resolveUpload(uploadedCover); await waitFor(() => { expect(screen.queryByText('裁剪上传封面')).toBeNull(); }); await user.click(screen.getByRole('button', { name: /保存/u })); await waitFor(() => { expect(screen.queryByText('编辑作品封面')).toBeNull(); }); const savedProfile = readCoverHarnessProfile(); expect(savedProfile.cover?.sourceType).toBe('uploaded'); expect(savedProfile.cover?.imageSrc).toBe( '/generated-custom-world-covers/world-1/uploaded/cover.webp', ); });