Files
Genarrative/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx
高物 05a47816b0 支持规范参考图输入
为角色形象规范、UI素材规范、自定义规范面板新增参考图上传入口。

生成规范时携带参考图并自动追加参考图生成规范语义。

补充生成流程和上传流程回归测试。

更新画板角色形象生成入口设计文档。
2026-06-17 14:20:23 +08:00

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();
});
});