Files
Genarrative/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.test.tsx
T
suzmii a9a5fba92b 修复 PR #316 review(前端 TS/TSX):图片编辑器素材弹窗
- ImageCanvasProjectAssetPickerDialog.tsx:打开时的重置 effect 只依赖 `open`,父级每次渲染重建 `selectedAssetIds` 不再清空进行中的搜索 / 筛选 / 选择
- ImageCanvasProjectAssetPickerDialog.tsx:素材卡媒体渲染抽成 `AssetCardMedia`,顺带去掉该处嵌套三元
- projectAssetReferencePickerModel.ts:`image-sequence` 单列一类(新增「序列帧」页签),不再混进「图片」——参考图链路只接受 `mediaType === 'image'`,混进去就是「按图片选中、按图片文案拒掉」;缩略图口径由 `projectAssetPickerUsesThumbnail` 收口
- 用例:父级重渲染不清空在选状态;序列帧分类归属与其被参考图链路过滤掉的事实
2026-09-12 20:49:37 +08:00

279 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @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);
});
});