/* @vitest-environment jsdom */ import { fireEvent, render, screen, within } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { AnimationState, type Character, type CompanionRenderState, type Encounter, type EquipmentLoadout, type GameState, WorldType, } from '../types'; import { AdventureEntityModal } from './AdventureEntityModal'; vi.mock('./CharacterAnimator', () => ({ CharacterAnimator: () =>
, })); vi.mock('./MedievalNpcAnimator', () => ({ MedievalNpcAnimator: () =>
, })); vi.mock('./HostileNpcAnimator', () => ({ HostileNpcAnimator: () =>
, })); function createGameState(overrides: Partial = {}): GameState { return { worldType: WorldType.WUXIA, customWorldProfile: null, playerCharacter: null, runtimeStats: { playTimeMs: 0, lastPlayTickAt: null, hostileNpcsDefeated: 0, questsAccepted: 0, itemsUsed: 0, scenesTraveled: 0, }, currentScene: 'test-scene', storyHistory: [], characterChats: {}, animationState: AnimationState.IDLE, currentEncounter: null, npcInteractionActive: false, currentScenePreset: null, sceneHostileNpcs: [], playerX: 0, playerOffsetY: 0, playerFacing: 'right', playerActionMode: 'idle', scrollWorld: false, inBattle: false, playerHp: 100, playerMaxHp: 100, playerMana: 30, playerMaxMana: 30, playerSkillCooldowns: {}, activeCombatEffects: [], playerCurrency: 0, playerInventory: [], playerEquipment: {} as EquipmentLoadout, npcStates: {}, quests: [], roster: [], companions: [], currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, ...overrides, } as GameState; } function createEncounter(overrides: Partial = {}): Encounter { return { id: 'runtime-npc', kind: 'npc', npcName: '雾中来客', npcDescription: '带着临时生成形象的相遇者', npcAvatar: '/avatar.png', context: '桥边试探', ...overrides, }; } function createPlayerCharacter(): Character { return { id: 'player-1', name: '潮刃客', title: '试剑者', description: '测试主角', backstory: '测试背景', personality: '冷静', avatar: '', portrait: '', assetFolder: '', assetVariant: '', attributes: { strength: 5, agility: 5, intelligence: 5, spirit: 5, }, skills: [ { id: 'tide-slash', name: '潮刃突进', animation: AnimationState.ATTACK, damage: 16, manaCost: 5, cooldownTurns: 2, range: 1, style: 'burst', buildBuffs: [ { id: 'wet-mark', sourceType: 'skill', sourceId: 'tide-slash', name: '潮湿', tags: ['控制', '潮汐'], durationTurns: 2, }, ], }, ], adventureOpenings: {}, }; } function createCompanionRenderState( character: Character, ): CompanionRenderState { return { npcId: 'companion-1', character, hp: 100, maxHp: 100, mana: 20, maxMana: 20, skillCooldowns: {}, animationState: AnimationState.IDLE, slot: 'upper', }; } afterEach(() => { vi.restoreAllMocks(); }); test('NPC 详情立绘优先展示遭遇实例形象,而不是 characterId 对应预设', () => { const encounter = createEncounter({ characterId: 'sword-princess', imageSrc: '/runtime-npc-preview.png', }); render( undefined} />, ); const portrait = screen.getByAltText('雾中来客'); expect(portrait.getAttribute('src')).toBe('/runtime-npc-preview.png'); expect(screen.queryByTestId('character-portrait')).toBeNull(); }); test('NPC 背包物品空 id 会被规范成稳定渲染 id', () => { const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(() => undefined); const encounter = createEncounter(); render( undefined} />, ); expect(screen.getAllByTitle(/裂纹石片 x/)).toHaveLength(2); expect( consoleErrorSpy.mock.calls.some((call) => call.some( (arg) => typeof arg === 'string' && arg.includes('Encountered two children with the same key'), ), ), ).toBe(false); }); test('物品空态复用暗色 PlatformEmptyState chrome', () => { render( undefined} />, ); const emptyState = screen.getByText('暂无物品'); const attributeSection = screen.getByText('属性').closest('section'); const itemSection = screen.getByText('物品').closest('section'); expect(emptyState.className).toContain('platform-empty-state'); expect(emptyState.className).toContain('border-dashed'); expect(emptyState.className).toContain('bg-black/20'); expect(attributeSection?.className).toContain('border-white/10'); expect(attributeSection?.className).toContain('bg-black/25'); expect(itemSection?.className).toContain('border-white/10'); expect(itemSection?.className).toContain('bg-black/25'); const levelPanel = screen.getByTestId('player-level-panel'); expect(levelPanel.className).toContain('border-amber-300/18'); expect(levelPanel.className).toContain('bg-amber-500/8'); expect(levelPanel.className).toContain('rounded-xl'); }); test('最近回响纯展示小卡复用暗色 PlatformSubpanel chrome', () => { render( undefined} />, ); [ 'recent-consequence-echo', 'recent-chronicle-echo', 'recent-carrier-echo', 'recent-scene-residue-echo', ].forEach((testId) => { const panel = screen.getByTestId(testId); expect(panel.className).toContain('border-white/10'); expect(panel.className).toContain('bg-black/25'); expect(panel.className).toContain('rounded-xl'); }); }); test('私聊和队友收束复用暗色 tint PlatformSubpanel chrome', () => { const companionCharacter = createPlayerCharacter(); render( undefined} onOpenCharacterChat={() => undefined} />, ); const privateChatPanel = screen.getByTestId('private-chat-panel'); const companionResolutionEcho = screen.getByTestId( 'companion-resolution-echo', ); expect(privateChatPanel.className).toContain('border-sky-400/18'); expect(privateChatPanel.className).toContain('bg-sky-500/8'); expect(privateChatPanel.className).toContain('rounded-[1.35rem]'); expect(companionResolutionEcho.className).toContain('border-emerald-400/18'); expect(companionResolutionEcho.className).toContain('bg-emerald-500/8'); expect(companionResolutionEcho.className).toContain('rounded-xl'); }); test('技能详情静态标签复用暗色 PlatformPillBadge chrome', () => { render( undefined} />, ); fireEvent.click(screen.getByRole('button', { name: /潮刃突进/u })); const skillPanel = screen .getByText('技能详情') .closest('.pixel-modal-shell') as HTMLElement; const deliveryBadge = within(skillPanel).getAllByText('近战')[0]!; const styleBadge = within(skillPanel).getAllByText('爆发')[0]!; const buffSummaryBadge = within(skillPanel).getByText('附带 1 个状态标签'); const buffBadge = within(skillPanel).getByText('潮湿 / 控制、潮汐 / 2 回合'); const damagePanel = within(skillPanel) .getByText('伤害') .closest('section') as HTMLElement; const descriptionPanel = within(skillPanel) .getByText(/潮刃突进 属于爆发路线/u) .closest('section') as HTMLElement; const buffPanel = within(skillPanel) .getByText('附带状态标签') .closest('section') as HTMLElement; expect(deliveryBadge.className).toContain('bg-white/6'); expect(styleBadge.className).toContain('bg-sky-500/10'); expect(buffSummaryBadge.className).toContain('bg-emerald-500/10'); expect(buffBadge.className).toContain('rounded-full'); expect(buffBadge.className).toContain('bg-sky-500/10'); expect(damagePanel.className).toContain('bg-black/25'); expect(damagePanel.className).toContain('border-white/10'); expect(descriptionPanel.className).toContain('bg-black/25'); expect(buffPanel.className).toContain('bg-black/25'); });