为角色形象规范、UI素材规范、自定义规范面板新增参考图上传入口。 生成规范时携带参考图并自动追加参考图生成规范语义。 补充生成流程和上传流程回归测试。 更新画板角色形象生成入口设计文档。
412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { useRef, useState } from 'react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { ApiClientError } from '../../services/apiClient';
|
|
import type {
|
|
CanvasLayer,
|
|
CanvasTool,
|
|
EditorAsset,
|
|
EditorAssetFolder,
|
|
GenerateDialogState,
|
|
SidebarPanel,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
|
|
|
|
const createEditorAssetMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import('../../services/image-editor/editorProjectClient')
|
|
>('../../services/image-editor/editorProjectClient');
|
|
return {
|
|
...actual,
|
|
createEditorAsset: createEditorAssetMock,
|
|
};
|
|
});
|
|
|
|
function createDefaultFolder(): EditorAssetFolder {
|
|
return {
|
|
id: 'project',
|
|
label: '项目素材',
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
persisted: true,
|
|
};
|
|
}
|
|
|
|
function createTestFile(name = '上传素材.png') {
|
|
return new File(['image'], name, { type: 'image/png' });
|
|
}
|
|
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
|
resolve = promiseResolve;
|
|
reject = promiseReject;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function UploadWorkflowHarness({
|
|
canAccessProtectedData = true,
|
|
openEditorLoginModal = vi.fn(),
|
|
activeTool = 'select',
|
|
}: {
|
|
canAccessProtectedData?: boolean;
|
|
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
|
|
activeTool?: CanvasTool;
|
|
}) {
|
|
const [assetFolders, setAssetFolders] = useState<EditorAssetFolder[]>([
|
|
createDefaultFolder(),
|
|
]);
|
|
const [assets, setAssets] = useState<EditorAsset[]>([]);
|
|
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
|
const [generateDialog, setGenerateDialog] =
|
|
useState<GenerateDialogState | null>(null);
|
|
const [activeSidebarPanel, setActiveSidebarPanel] =
|
|
useState<SidebarPanel | null>('assets');
|
|
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
|
|
const uploadIndexRef = useRef(0);
|
|
|
|
const workflow = useImageCanvasUploadWorkflow({
|
|
canAccessProtectedData,
|
|
openEditorLoginModal,
|
|
assetFolders,
|
|
activeUploadFolderId: 'project',
|
|
canvasSize: { width: 900, height: 640 },
|
|
viewport: { x: 10, y: 20, scale: 2 },
|
|
activeTool,
|
|
allocateUploadIndex: () => {
|
|
uploadIndexRef.current += 1;
|
|
return uploadIndexRef.current;
|
|
},
|
|
setAssetFolders,
|
|
setAssets,
|
|
setLayers,
|
|
setGenerateDialog,
|
|
setActiveSidebarPanel,
|
|
appendCanvasLayersWithResources: (nextLayers) => {
|
|
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
|
|
},
|
|
selectSingleLayer: setSelectedLayerId,
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<input
|
|
ref={workflow.uploadInputRef}
|
|
type="file"
|
|
aria-label="上传图片文件"
|
|
multiple
|
|
onChange={workflow.handleUploadInputChange}
|
|
/>
|
|
<span data-testid="assets">
|
|
{assets
|
|
.map(
|
|
(asset) =>
|
|
`${asset.id}:${asset.label}:${asset.folderId}:${asset.uploadStatus ?? 'ready'}:${asset.uploadMessage ?? '-'}`,
|
|
)
|
|
.join('|')}
|
|
</span>
|
|
<span data-testid="folders">
|
|
{assetFolders
|
|
.map(
|
|
(folder) =>
|
|
`${folder.id}:${folder.collapsed ? 'collapsed' : 'open'}`,
|
|
)
|
|
.join('|')}
|
|
</span>
|
|
<span data-testid="layers">
|
|
{layers
|
|
.map(
|
|
(layer) =>
|
|
`${layer.id}:${layer.title}:${layer.sourceAssetId}:${layer.x}:${layer.y}`,
|
|
)
|
|
.join('|')}
|
|
</span>
|
|
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
|
|
<span data-testid="selected-layer">{selectedLayerId ?? '-'}</span>
|
|
<span data-testid="dialog">
|
|
{generateDialog
|
|
? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}:${generateDialog.specReference?.label ?? '-'}`
|
|
: '-'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.addUploadedFiles([createTestFile()])}
|
|
>
|
|
上传素材
|
|
</button>
|
|
<button type="button" onClick={() => workflow.requestUpload('asset')}>
|
|
请求素材上传
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.addUploadedFiles([createTestFile('画布素材.png')], {
|
|
addToCanvas: true,
|
|
canvasPoint: { x: 110, y: 120 },
|
|
})
|
|
}
|
|
>
|
|
上传到画布
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setGenerateDialog({
|
|
mode: 'character',
|
|
prompt: '',
|
|
status: 'failed',
|
|
errorMessage: '旧错误',
|
|
})
|
|
}
|
|
>
|
|
准备角色生成
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setGenerateDialog({
|
|
mode: 'spec',
|
|
specType: 'ui',
|
|
prompt: '',
|
|
status: 'failed',
|
|
errorMessage: '旧错误',
|
|
})
|
|
}
|
|
>
|
|
准备规范生成
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.setUploadTarget('spec-reference')}
|
|
>
|
|
选择规范参考图
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.setUploadTarget('character-spec')}
|
|
>
|
|
选择角色规范
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.requestUpload('character-spec')}
|
|
>
|
|
请求角色规范上传
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe('useImageCanvasUploadWorkflow', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
createEditorAssetMock.mockImplementation(async (input) => ({
|
|
assetId: `persisted-${input.label}`,
|
|
folderId: input.folderId,
|
|
label: input.label,
|
|
imageSrc: input.imageSrc,
|
|
width: input.width,
|
|
height: input.height,
|
|
sourceType: input.sourceType,
|
|
objectKey: 'object-key-uploaded',
|
|
assetObjectId: 'asset-object-uploaded',
|
|
}));
|
|
});
|
|
|
|
it('opens login before creating placeholders and resumes the same upload after login', async () => {
|
|
const openEditorLoginModal = vi.fn();
|
|
const { rerender } = render(
|
|
<UploadWorkflowHarness
|
|
canAccessProtectedData={false}
|
|
openEditorLoginModal={openEditorLoginModal}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '上传素材' }));
|
|
|
|
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
|
|
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
|
expect(screen.getByTestId('assets').textContent).toBe('');
|
|
|
|
const resumeUpload = openEditorLoginModal.mock.calls[0]?.[0];
|
|
rerender(
|
|
<UploadWorkflowHarness
|
|
canAccessProtectedData
|
|
openEditorLoginModal={openEditorLoginModal}
|
|
/>,
|
|
);
|
|
act(() => {
|
|
(resumeUpload as () => void)();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(createEditorAssetMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('assets').textContent).toContain(
|
|
'persisted-上传素材.png:上传素材.png:project:ready:-',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('opens login instead of the asset file picker when protected data is unavailable', () => {
|
|
const openEditorLoginModal = vi.fn();
|
|
render(
|
|
<UploadWorkflowHarness
|
|
canAccessProtectedData={false}
|
|
openEditorLoginModal={openEditorLoginModal}
|
|
/>,
|
|
);
|
|
const uploadInput = screen.getByLabelText('上传图片文件') as HTMLInputElement;
|
|
const clickUploadInput = vi.spyOn(uploadInput, 'click');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '请求素材上传' }));
|
|
|
|
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
|
|
expect(clickUploadInput).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps generation reference uploads local and opens the file picker without login', () => {
|
|
const openEditorLoginModal = vi.fn();
|
|
render(
|
|
<UploadWorkflowHarness
|
|
canAccessProtectedData={false}
|
|
openEditorLoginModal={openEditorLoginModal}
|
|
/>,
|
|
);
|
|
const uploadInput = screen.getByLabelText('上传图片文件') as HTMLInputElement;
|
|
const clickUploadInput = vi.spyOn(uploadInput, 'click');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '请求角色规范上传' }));
|
|
|
|
expect(openEditorLoginModal).not.toHaveBeenCalled();
|
|
expect(clickUploadInput).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('creates an uploading asset card, adds a canvas layer, and patches the layer with the persisted asset id', async () => {
|
|
const deferredAsset = createDeferred<{
|
|
assetId: string;
|
|
folderId: string;
|
|
label: string;
|
|
imageSrc: string;
|
|
width: number;
|
|
height: number;
|
|
sourceType: 'uploaded';
|
|
objectKey: string;
|
|
assetObjectId: string;
|
|
}>();
|
|
createEditorAssetMock.mockReturnValueOnce(deferredAsset.promise);
|
|
render(<UploadWorkflowHarness />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '上传到画布' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('assets').textContent).toContain(
|
|
'upload-1:画布素材.png:project:uploading:上传中',
|
|
);
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-upload-1:画布素材.png:upload-1:-160:-107.5',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
|
|
expect(screen.getByTestId('selected-layer').textContent).toBe(
|
|
'layer-upload-1',
|
|
);
|
|
|
|
deferredAsset.resolve({
|
|
assetId: 'asset-persisted-canvas',
|
|
folderId: 'project',
|
|
label: '画布素材.png',
|
|
imageSrc: 'data:image/png;base64,Y2FudmFz',
|
|
width: 420,
|
|
height: 315,
|
|
sourceType: 'uploaded',
|
|
objectKey: 'object-key-canvas',
|
|
assetObjectId: 'asset-object-canvas',
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('assets').textContent).toContain(
|
|
'asset-persisted-canvas:画布素材.png:project:ready:-',
|
|
);
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-upload-1:画布素材.png:asset-persisted-canvas:-160:-107.5',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('marks upload cards as failed and reopens login on auth errors returned by asset creation', async () => {
|
|
const openEditorLoginModal = vi.fn();
|
|
createEditorAssetMock.mockRejectedValueOnce(
|
|
new ApiClientError({
|
|
message: '未授权访问',
|
|
status: 401,
|
|
code: 'UNAUTHORIZED',
|
|
}),
|
|
);
|
|
render(
|
|
<UploadWorkflowHarness openEditorLoginModal={openEditorLoginModal} />,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '上传素材' }));
|
|
|
|
await waitFor(() => {
|
|
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
|
|
expect(screen.getByTestId('assets').textContent).toContain(
|
|
'upload-1:上传素材.png:project:failed:请先登录',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('dispatches file input uploads to generation references and resets failed state', async () => {
|
|
render(<UploadWorkflowHarness />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '准备角色生成' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '选择角色规范' }));
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('dialog').textContent).toBe(
|
|
'character:failed:-:0:-:-',
|
|
);
|
|
});
|
|
|
|
fireEvent.change(screen.getByLabelText('上传图片文件'), {
|
|
target: {
|
|
files: [createTestFile('角色规范.png')],
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('dialog').textContent).toContain(
|
|
'character:idle:角色规范.png:0:-:-',
|
|
);
|
|
});
|
|
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('dispatches file input uploads to spec reference images', async () => {
|
|
render(<UploadWorkflowHarness />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '准备规范生成' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '选择规范参考图' }));
|
|
fireEvent.change(screen.getByLabelText('上传图片文件'), {
|
|
target: {
|
|
files: [createTestFile('UI参考.png')],
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('dialog').textContent).toContain(
|
|
'spec:idle:-:0:-:UI参考.png',
|
|
);
|
|
});
|
|
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|