a9a5fba92b
- ImageCanvasProjectAssetPickerDialog.tsx:打开时的重置 effect 只依赖 `open`,父级每次渲染重建 `selectedAssetIds` 不再清空进行中的搜索 / 筛选 / 选择 - ImageCanvasProjectAssetPickerDialog.tsx:素材卡媒体渲染抽成 `AssetCardMedia`,顺带去掉该处嵌套三元 - projectAssetReferencePickerModel.ts:`image-sequence` 单列一类(新增「序列帧」页签),不再混进「图片」——参考图链路只接受 `mediaType === 'image'`,混进去就是「按图片选中、按图片文案拒掉」;缩略图口径由 `projectAssetPickerUsesThumbnail` 收口 - 用例:父级重渲染不清空在选状态;序列帧分类归属与其被参考图链路过滤掉的事实
279 lines
8.7 KiB
TypeScript
279 lines
8.7 KiB
TypeScript
/* @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>): 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(
|
||
<ImageCanvasProjectAssetPickerDialog
|
||
open
|
||
assets={ASSETS}
|
||
selectedAssetIds={[]}
|
||
onCancel={() => {}}
|
||
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(
|
||
<ImageCanvasProjectAssetPickerDialog
|
||
open
|
||
assets={ASSETS}
|
||
selectedAssetIds={['asset-bgm']}
|
||
onCancel={onCancel}
|
||
onConfirm={onConfirm}
|
||
/>,
|
||
);
|
||
|
||
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(
|
||
<ImageCanvasProjectAssetPickerDialog
|
||
open
|
||
assets={ASSETS}
|
||
selectedAssetIds={['asset-hero', 'asset-town']}
|
||
onCancel={() => {}}
|
||
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(
|
||
<ImageCanvasProjectAssetPickerDialog
|
||
open
|
||
assets={ASSETS}
|
||
selectedAssetIds={[]}
|
||
onCancel={() => {}}
|
||
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(
|
||
<ImageCanvasProjectAssetPickerDialog
|
||
open
|
||
assets={ASSETS}
|
||
selectedAssetIds={['asset-hero']}
|
||
onCancel={() => {}}
|
||
onConfirm={() => {}}
|
||
/>,
|
||
);
|
||
fireEvent.click(screen.getByRole('button', { name: '清空' }));
|
||
expect(screen.getByText('已选 0 个')).toBeTruthy();
|
||
|
||
rerender(
|
||
<ImageCanvasProjectAssetPickerDialog
|
||
open={false}
|
||
assets={ASSETS}
|
||
selectedAssetIds={['asset-hero']}
|
||
onCancel={() => {}}
|
||
onConfirm={() => {}}
|
||
/>,
|
||
);
|
||
rerender(
|
||
<ImageCanvasProjectAssetPickerDialog
|
||
open
|
||
assets={ASSETS}
|
||
selectedAssetIds={['asset-hero']}
|
||
onCancel={() => {}}
|
||
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(
|
||
<ImageCanvasProjectAssetPickerDialog
|
||
open
|
||
assets={ASSETS}
|
||
selectedAssetIds={[]}
|
||
onCancel={() => {}}
|
||
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(
|
||
<ImageCanvasProjectAssetPickerDialog
|
||
open
|
||
assets={ASSETS}
|
||
selectedAssetIds={[...[]]}
|
||
onCancel={() => {}}
|
||
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(
|
||
<ImageCanvasProjectAssetPickerDialog
|
||
open
|
||
assets={[]}
|
||
selectedAssetIds={[]}
|
||
onCancel={() => {}}
|
||
onConfirm={() => {}}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getByText('当前项目还没有已登记素材')).toBeTruthy();
|
||
});
|
||
|
||
/**
|
||
* 禁用候选的「变灰」不能压在禁用原因上。
|
||
*
|
||
* 「分类不同」这类小字是用户唯一能看到的解释,整卡 `opacity-60` 会让它几乎读不出来;
|
||
* 视觉上的禁用感由缩略图与名称承担(这里用类名契约钉住分工,jsdom 不渲染真实对比度)。
|
||
*
|
||
* 变异验证:把 `opacity-60` 挪回整卡 `<button>` 上,本用例必须失败。
|
||
*/
|
||
it('keeps the disabled reason at full contrast instead of dimming the whole card', () => {
|
||
render(
|
||
<ImageCanvasProjectAssetPickerDialog
|
||
open
|
||
assets={ASSETS}
|
||
selectedAssetIds={[]}
|
||
assetBlockedReasons={{ 'asset-town': '分类不同' }}
|
||
onCancel={() => {}}
|
||
onConfirm={() => {}}
|
||
/>,
|
||
);
|
||
|
||
const option = screen.getByRole('option', { name: '选择参考图小镇背景' });
|
||
const media = option.firstElementChild as HTMLElement;
|
||
const reason = screen.getByText('分类不同');
|
||
|
||
// 整卡不再压透明度,只保留不可点的光标语义。
|
||
expect(option.className).toContain('cursor-not-allowed');
|
||
expect(option.className).not.toMatch(/opacity-\d/u);
|
||
// 变灰只压在缩略图上。
|
||
expect(media.className).toMatch(/opacity-\d/u);
|
||
expect(reason.className).not.toMatch(/opacity-\d/u);
|
||
expect(reason.className).toContain('font-medium');
|
||
expect(reason.className).toContain('var(--platform-text-strong)');
|
||
// 禁用项仍然渲染(不隐藏),原因与可点性都在。
|
||
expect((option as HTMLButtonElement).disabled).toBe(true);
|
||
});
|
||
});
|