/* @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');
});