/* @vitest-environment jsdom */ import { fireEvent, render, screen, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { EditorAsset } from './ImageCanvasEditorTypes'; import { ImageCanvasProjectAssetPickerDialog } from './ImageCanvasProjectAssetPickerDialog'; function createAsset(overrides: Partial): EditorAsset { return { id: 'asset-1', label: '素材 1', src: 'https://example.test/asset-1.png', mediaType: 'image', width: 64, height: 64, folderId: 'folder-1', sourceKind: 'uploaded', sourceType: 'uploaded', persisted: true, ...overrides, }; } const ASSETS: EditorAsset[] = [ createAsset({ id: 'asset-hero', label: '主角立绘' }), createAsset({ id: 'asset-town', label: '小镇背景', mediaType: 'video', src: 'https://example.test/town.mp4', }), createAsset({ id: 'asset-bgm', label: '背景音乐', mediaType: 'audio', src: 'https://example.test/bgm.mp3', }), ]; describe('ImageCanvasProjectAssetPickerDialog', () => { it('selects several assets, shows the count and returns the id snapshot', () => { const onConfirm = vi.fn(); render( {}} onConfirm={onConfirm} />, ); fireEvent.click(screen.getByRole('option', { name: '选择参考图主角立绘' })); fireEvent.click(screen.getByRole('option', { name: '选择参考图小镇背景' })); expect(screen.getByText('已选 2 个')).toBeTruthy(); expect( screen .getByRole('option', { name: '选择参考图主角立绘' }) .getAttribute('aria-selected'), ).toBe('true'); fireEvent.click(screen.getByRole('button', { name: '确认选择参考图' })); expect(onConfirm).toHaveBeenCalledWith(['asset-hero', 'asset-town']); }); it('clears only the current selection and keeps cancel free of side effects', () => { const onCancel = vi.fn(); const onConfirm = vi.fn(); render( , ); expect(screen.getByText('已选 1 个')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '清空' })); expect(screen.getByText('已选 0 个')).toBeTruthy(); expect(onConfirm).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '取消' })); expect(onCancel).toHaveBeenCalledTimes(1); expect(onConfirm).not.toHaveBeenCalled(); }); it('deselects one asset from the selected chips', () => { render( {}} onConfirm={() => {}} />, ); const selectedRow = screen.getByLabelText('已选参考图'); fireEvent.click( within(selectedRow).getByRole('button', { name: '取消参考图主角立绘' }), ); expect(screen.getByText('已选 1 个')).toBeTruthy(); }); it('filters by category and search text with dialog-local state', () => { render( {}} onConfirm={() => {}} />, ); fireEvent.click(screen.getByRole('button', { name: '视频' })); expect( screen.queryByRole('option', { name: '选择参考图主角立绘' }), ).toBeNull(); expect( screen.getByRole('option', { name: '选择参考图小镇背景' }), ).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '全部' })); fireEvent.change(screen.getByLabelText('搜索参考图素材'), { target: { value: '音乐' }, }); expect( screen.getByRole('option', { name: '选择参考图背景音乐' }), ).toBeTruthy(); expect( screen.queryByRole('option', { name: '选择参考图小镇背景' }), ).toBeNull(); }); it('restarts from the caller-provided selection each time it opens', () => { const { rerender } = render( {}} onConfirm={() => {}} />, ); fireEvent.click(screen.getByRole('button', { name: '清空' })); expect(screen.getByText('已选 0 个')).toBeTruthy(); rerender( {}} onConfirm={() => {}} />, ); rerender( {}} onConfirm={() => {}} />, ); expect(screen.getByText('已选 1 个')).toBeTruthy(); }); /** * 弹窗里的搜索 / 分类 / 在选中的选择是用户正在做的工作,不能被父级的一次普通重渲染清掉。 * * 调用方(`useImageCanvasGenerationSurface`)每次渲染都会重算 `selectedAssetIds`, * 把它的身份放进重置 effect 的依赖里,就等于「父级一渲染就清空」。 * * 变异验证:把 `selectedAssetIds` 加回 effect 依赖,本用例必须失败。 */ it('keeps the in-progress search and selection across parent re-renders', () => { const { rerender } = render( {}} onConfirm={() => {}} />, ); fireEvent.change(screen.getByLabelText('搜索参考图素材'), { target: { value: '主角' }, }); fireEvent.click(screen.getByRole('option', { name: '选择参考图主角立绘' })); expect(screen.getByText('已选 1 个')).toBeTruthy(); expect( screen.queryByRole('option', { name: '选择参考图小镇背景' }), ).toBeNull(); // 父级重渲染:`selectedAssetIds` 换了一个新数组身份,内容不变。 rerender( {}} onConfirm={() => {}} />, ); expect( (screen.getByLabelText('搜索参考图素材') as HTMLInputElement).value, ).toBe('主角'); expect(screen.getByText('已选 1 个')).toBeTruthy(); expect( screen .getByRole('option', { name: '选择参考图主角立绘' }) .getAttribute('aria-selected'), ).toBe('true'); expect( screen.queryByRole('option', { name: '选择参考图小镇背景' }), ).toBeNull(); }); it('shows an empty state when the project has no registered assets', () => { render( {}} onConfirm={() => {}} />, ); expect(screen.getByText('当前项目还没有已登记素材')).toBeTruthy(); }); /** * 禁用候选的「变灰」不能压在禁用原因上。 * * 「分类不同」这类小字是用户唯一能看到的解释,整卡 `opacity-60` 会让它几乎读不出来; * 视觉上的禁用感由缩略图与名称承担(这里用类名契约钉住分工,jsdom 不渲染真实对比度)。 * * 变异验证:把 `opacity-60` 挪回整卡 `