280 lines
7.3 KiB
TypeScript
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();
|
|
});
|