Files
Genarrative/src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx
2026-05-21 20:20:06 +08:00

280 lines
7.3 KiB
TypeScript

/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import {
buildCustomWorldPlayableCharacters,
} from '../../data/characterPresets';
import {
type Character,
type CustomWorldProfile,
WorldType,
} from '../../types';
import { RpgEntryCharacterSelectView } from './RpgEntryCharacterSelectView';
vi.mock('../../data/characterPresets', () => ({
ROLE_TEMPLATE_CHARACTERS: [
{
id: 'fallback-hero',
name: '兜底侠',
title: '默认角色',
description: '兜底角色',
backstory: '兜底背景',
personality: '冷静 果断',
gender: 'unknown',
portrait: '/portraits/fallback.png',
attributes: {
strength: 8,
agility: 8,
intelligence: 8,
spirit: 8,
},
attributeProfile: {
schemaId: 'schema:custom:fallback',
values: {
axis_a: 8,
axis_b: 8,
axis_c: 8,
axis_d: 8,
axis_e: 8,
axis_f: 8,
},
evidence: [],
},
attributeProfiles: {
CUSTOM: {
schemaId: 'schema:custom:fallback',
values: {
axis_a: 8,
axis_b: 8,
axis_c: 8,
axis_d: 8,
axis_e: 8,
axis_f: 8,
},
evidence: [],
},
},
skills: [],
},
],
buildCustomWorldPlayableCharacters: vi.fn(),
}));
vi.mock('../CharacterAnimator', () => ({
CharacterAnimator: ({ character }: { character: Character }) => (
<div>{character.name}</div>
),
}));
vi.mock('../CharacterDetailModal', () => ({
CharacterDetailModal: () => null,
}));
vi.mock('../SelectionCustomizationModals', () => ({
CharacterDraftModal: () => null,
}));
function createCharacter(name: string, title: string): Character {
return {
id: '',
name,
title,
description: `${name}的定位描述`,
backstory: `${name}的背景故事`,
personality: `${name} 冷静 果断`,
gender: 'female',
portrait: `/portraits/${name}.png`,
attributes: {
strength: 10,
agility: 11,
intelligence: 12,
spirit: 13,
},
skills: [],
} as unknown as Character;
}
afterEach(() => {
vi.restoreAllMocks();
});
test('custom world character selection stays stable when character ids are empty', async () => {
const user = userEvent.setup();
const handleConfirm = vi.fn();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
vi.mocked(buildCustomWorldPlayableCharacters).mockReturnValue([
createCharacter('沈砺', '潮锋斥候'),
createCharacter('闻潮', '雾海哨兵'),
]);
HTMLElement.prototype.scrollTo = function scrollTo(
this: HTMLElement,
options?: ScrollToOptions | number,
) {
if (typeof options === 'object' && options) {
if (typeof options.left === 'number') {
this.scrollLeft = options.left;
}
if (typeof options.top === 'number') {
this.scrollTop = options.top;
}
}
this.dispatchEvent(new Event('scroll'));
};
vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function mockGetBoundingClientRect(this: HTMLElement) {
if ((this as HTMLElement).dataset.carouselCard === 'true') {
return {
width: 240,
height: 360,
top: 0,
right: 240,
bottom: 360,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
}
return {
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
});
render(
<RpgEntryCharacterSelectView
worldType={WorldType.CUSTOM}
customWorldProfile={{
attributeSchema: {
id: 'schema:custom:test',
worldId: 'custom:test',
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
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: '回澜',
},
],
},
} as unknown as CustomWorldProfile}
onBack={() => {}}
onConfirm={handleConfirm}
/>,
);
expect(screen.getByText(/:/u)).toBeTruthy();
expect(screen.queryByText(/:/u)).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: //u }));
expect(handleConfirm).toHaveBeenCalledWith(
expect.objectContaining({
name: '闻潮',
title: '雾海哨兵',
}),
);
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('custom world character selection falls back instead of rendering a blank screen when profile characters are malformed', () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.mocked(buildCustomWorldPlayableCharacters).mockImplementation(() => {
throw new TypeError('profile.playableNpcs is not iterable');
});
render(
<RpgEntryCharacterSelectView
worldType={WorldType.CUSTOM}
customWorldProfile={{
id: 'broken-profile',
name: '坏数据',
attributeSchema: {
id: 'schema:custom:fallback',
worldId: 'broken-profile',
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
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: '玄息' },
],
},
} as unknown as CustomWorldProfile}
onBack={() => {}}
onConfirm={() => {}}
/>,
);
expect(screen.getByText('选择你的角色')).toBeTruthy();
expect(screen.getAllByText('兜底侠').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});