拆分图片画布上传工作流
新增图片文件读取模型和上传工作流 hook 把上传目标分发、登录续传、占位卡片和画布建层从主视图抽出 补充上传工作流单测并更新拆分计划和进度记录
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
/* @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 ?? '-'}`
|
||||
: '-'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => workflow.addUploadedFiles([createTestFile()])}
|
||||
>
|
||||
上传素材
|
||||
</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={() => workflow.setUploadTarget('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('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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user