594063a1e0
- 按 vitest 0.34 的参数元组口径改写 mock 泛型 - 修正 noUncheckedIndexedAccess 下的下标访问 - 补齐与当前类型脱节的测试桩字段 - 按 scripts/*.mjs 的运行时契约补局部类型声明
163 lines
5.3 KiB
TypeScript
163 lines
5.3 KiB
TypeScript
/** @vitest-environment jsdom */
|
||
import {
|
||
cleanup,
|
||
fireEvent,
|
||
render,
|
||
screen,
|
||
within,
|
||
} from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import type { SpriteAsset } from '../src/features/ui-editor/types/SpriteAsset';
|
||
import { ImageAssetSelector } from '../src/view/ui-editor/components/Inspector/Components/ImageAssetSelector';
|
||
|
||
const sprites: Record<string, SpriteAsset> = {
|
||
hero: {
|
||
asset_id: 'hero',
|
||
metadata: { name: '主角', asset_type: 'sprite' },
|
||
path: 'assets/hero.png',
|
||
pixel_size: [32, 32] as [number, number],
|
||
pixels_per_unit: 1,
|
||
border: { left: 0, right: 0, top: 0, bottom: 0 },
|
||
},
|
||
enemy: {
|
||
asset_id: 'enemy',
|
||
metadata: { name: '敌人', asset_type: 'sprite' },
|
||
path: 'assets/enemy.png',
|
||
pixel_size: [32, 32] as [number, number],
|
||
pixels_per_unit: 1,
|
||
border: { left: 0, right: 0, top: 0, bottom: 0 },
|
||
},
|
||
};
|
||
|
||
function renderSelector(
|
||
value: string | null = null,
|
||
onChange = vi.fn(),
|
||
previewUrls: Record<string, string> = {},
|
||
selectorSprites: typeof sprites = sprites,
|
||
) {
|
||
return render(
|
||
<ImageAssetSelector
|
||
value={value}
|
||
sprites={selectorSprites}
|
||
previewUrls={previewUrls}
|
||
readOnly={false}
|
||
onChange={onChange}
|
||
/>,
|
||
);
|
||
}
|
||
|
||
describe('ImageAssetSelector', () => {
|
||
afterEach(() => cleanup());
|
||
|
||
it('selects a sprite and clears the selection from the modal', async () => {
|
||
const user = userEvent.setup();
|
||
const onChange = vi.fn();
|
||
renderSelector(null, onChange);
|
||
|
||
await user.click(screen.getByRole('button', { name: '选择素材' }));
|
||
expect(screen.getByRole('dialog', { name: '选择图片素材' })).toBeTruthy();
|
||
await user.click(screen.getByRole('button', { name: /主角/ }));
|
||
expect(onChange).toHaveBeenCalledWith('hero');
|
||
expect(screen.queryByRole('dialog')).toBeNull();
|
||
|
||
await user.click(screen.getByRole('button', { name: '选择素材' }));
|
||
await user.click(screen.getByRole('button', { name: /清除选择/ }));
|
||
expect(onChange).toHaveBeenLastCalledWith(null);
|
||
});
|
||
|
||
it('shows preview images, placeholders, and invalid references', async () => {
|
||
const user = userEvent.setup();
|
||
renderSelector('missing', vi.fn(), { hero: 'data:image/png;base64,abc' });
|
||
|
||
expect(screen.getByText('素材不存在(missing)')).toBeTruthy();
|
||
await user.click(screen.getByRole('button', { name: '更换素材' }));
|
||
const dialog = screen.getByRole('dialog', { name: '选择图片素材' });
|
||
expect(within(dialog).getByAltText('主角')).toBeTruthy();
|
||
expect(within(dialog).getByText('素材不存在')).toBeTruthy();
|
||
expect(
|
||
within(dialog).getByRole('button', { name: /清除选择/ }),
|
||
).toBeTruthy();
|
||
});
|
||
|
||
it('cannot open while read only', () => {
|
||
render(
|
||
<ImageAssetSelector
|
||
value="hero"
|
||
sprites={sprites}
|
||
previewUrls={{}}
|
||
readOnly
|
||
onChange={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getByRole('button', { name: '更换素材' })).toHaveProperty(
|
||
'disabled',
|
||
true,
|
||
);
|
||
});
|
||
|
||
it('retries a preview when its URL is regenerated', async () => {
|
||
const user = userEvent.setup();
|
||
const view = renderSelector(null, vi.fn(), {
|
||
hero: 'data:image/png;base64,first',
|
||
});
|
||
|
||
await user.click(screen.getByRole('button', { name: '选择素材' }));
|
||
const dialog = screen.getByRole('dialog', { name: '选择图片素材' });
|
||
const image = within(dialog).getByAltText('主角');
|
||
fireEvent.error(image);
|
||
expect(within(dialog).queryByAltText('主角')).toBeNull();
|
||
|
||
view.rerender(
|
||
<ImageAssetSelector
|
||
value={null}
|
||
sprites={sprites}
|
||
previewUrls={{ hero: 'data:image/png;base64,regenerated' }}
|
||
readOnly={false}
|
||
onChange={vi.fn()}
|
||
/>,
|
||
);
|
||
|
||
expect(
|
||
within(screen.getByRole('dialog', { name: '选择图片素材' })).getByAltText(
|
||
'主角',
|
||
),
|
||
).toBeTruthy();
|
||
});
|
||
|
||
it('supports keyboard activation for sprite cards', async () => {
|
||
const user = userEvent.setup();
|
||
const onChange = vi.fn();
|
||
renderSelector(null, onChange);
|
||
await user.click(screen.getByRole('button', { name: '选择素材' }));
|
||
const hero = screen.getByRole('button', { name: /主角/ });
|
||
hero.focus();
|
||
await user.keyboard('{Enter}');
|
||
expect(onChange).toHaveBeenCalledWith('hero');
|
||
});
|
||
|
||
it('closes when the modal backdrop is clicked', async () => {
|
||
const user = userEvent.setup();
|
||
renderSelector();
|
||
await user.click(screen.getByRole('button', { name: '选择素材' }));
|
||
const dialog = screen.getByRole('dialog', { name: '选择图片素材' });
|
||
const overlay = dialog.parentElement;
|
||
expect(overlay).toBeTruthy();
|
||
await user.click(overlay!);
|
||
expect(screen.queryByRole('dialog', { name: '选择图片素材' })).toBeNull();
|
||
});
|
||
|
||
it('shows an empty state when no sprites are available', async () => {
|
||
const user = userEvent.setup();
|
||
renderSelector(null, vi.fn(), {}, {});
|
||
await user.click(screen.getByRole('button', { name: '选择素材' }));
|
||
const dialog = screen.getByRole('dialog', { name: '选择图片素材' });
|
||
expect(within(dialog).getByText('暂无可用素材')).toBeTruthy();
|
||
expect(
|
||
within(dialog).getByRole('button', { name: /清除选择/ }),
|
||
).toBeTruthy();
|
||
});
|
||
});
|