c836ac604d
Handle character-action generation results as real video assets: use backend previewVideoPath as layer src, set mediaType="video" and assetKind="character-animation", and store the first frame only as thumbnail/poster. Update docs to document the new behavior and preserve backend frame extraction/green-screen workflows. Update UI/UX: compact parameter labels (e.g. "16:9·1K"), quick-edit treats original image as an implicit last reference (hidden from the reference bar) and allows editable bottom capsules, and ensure exports preserve true media file types. Change upload toolbar icon to Upload and add tests to cover the icon and video-layer behavior; also add serialization/hydration test for video thumbnails and adjust many integration/unit tests to reflect new labels, sizes and generation semantics. Files touched include multiple docs under docs/, edited components and tests under src/components/image-editor/, and minor stylesheet updates.
214 lines
7.2 KiB
TypeScript
214 lines
7.2 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
import { createRef, useState } from 'react';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
|
|
import type {
|
|
GenerateDialogState,
|
|
UploadTarget,
|
|
} from './ImageCanvasEditorTypes';
|
|
|
|
function createDialog(
|
|
patch: Partial<GenerateDialogState> = {},
|
|
): GenerateDialogState {
|
|
return {
|
|
mode: 'generate',
|
|
prompt: '初始提示',
|
|
status: 'idle',
|
|
generationReferences: [],
|
|
imageModel: 'gemini-3.1-flash-image-preview',
|
|
aspectRatio: '16:9',
|
|
imageSize: '1K',
|
|
...patch,
|
|
};
|
|
}
|
|
|
|
function BasicGenerationHarness({
|
|
initialDialog = createDialog(),
|
|
onRequestUpload = vi.fn(),
|
|
onSubmit = vi.fn(),
|
|
}: {
|
|
initialDialog?: GenerateDialogState;
|
|
onRequestUpload?: (target: UploadTarget) => void;
|
|
onSubmit?: (dialog: GenerateDialogState) => void;
|
|
}) {
|
|
const [dialog, setDialog] = useState<GenerateDialogState | null>(
|
|
initialDialog,
|
|
);
|
|
const [isReferenceMenuOpen, setIsReferenceMenuOpen] = useState(false);
|
|
const [isPickingReference, setIsPickingReference] = useState(false);
|
|
const generationReferenceButtonRef = createRef<HTMLButtonElement>();
|
|
|
|
return dialog ? (
|
|
<div>
|
|
<ImageCanvasBasicGenerationComposerView
|
|
dialog={dialog}
|
|
style={{ left: 10, top: 20 }}
|
|
setGenerateDialog={setDialog}
|
|
generationReferenceButtonRef={generationReferenceButtonRef}
|
|
isGenerationReferenceMenuOpen={isReferenceMenuOpen}
|
|
setIsGenerationReferenceMenuOpen={setIsReferenceMenuOpen}
|
|
setIsPickingGenerationReferenceFromCanvas={setIsPickingReference}
|
|
renderEditorPortal={(node) => node}
|
|
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
|
|
onRequestUpload={onRequestUpload}
|
|
onToggleReferenceMenu={() => setIsReferenceMenuOpen((open) => !open)}
|
|
onSubmit={onSubmit}
|
|
/>
|
|
<output aria-label="当前提示词">{dialog.prompt}</output>
|
|
<output aria-label="当前比例">{dialog.aspectRatio}</output>
|
|
<output aria-label="当前尺寸">{dialog.imageSize}</output>
|
|
<output aria-label="当前模型">{dialog.imageModel}</output>
|
|
<output aria-label="当前状态">{dialog.status}</output>
|
|
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
|
<output aria-label="选择参考图">{String(isPickingReference)}</output>
|
|
</div>
|
|
) : null;
|
|
}
|
|
|
|
describe('ImageCanvasBasicGenerationComposerView', () => {
|
|
it('updates prompt, clears failed state, uploads references and submits', () => {
|
|
const requestUpload = vi.fn();
|
|
const submitGeneration = vi.fn();
|
|
render(
|
|
<BasicGenerationHarness
|
|
initialDialog={createDialog({
|
|
status: 'failed',
|
|
errorMessage: '生成失败',
|
|
})}
|
|
onRequestUpload={requestUpload}
|
|
onSubmit={submitGeneration}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
|
target: { value: '新的提示' },
|
|
});
|
|
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
|
fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' }));
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
|
|
|
expect(screen.getByLabelText('当前提示词').textContent).toBe('新的提示');
|
|
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
|
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
|
expect(requestUpload).toHaveBeenCalledWith('generation-reference');
|
|
expect(submitGeneration).toHaveBeenCalledWith(
|
|
expect.objectContaining({ prompt: '新的提示', status: 'idle' }),
|
|
);
|
|
});
|
|
|
|
it('keeps references on the first row and appends a new add-reference entry', () => {
|
|
render(
|
|
<BasicGenerationHarness
|
|
initialDialog={createDialog({
|
|
generationReferences: [
|
|
{
|
|
id: 'ref-a',
|
|
label: '参考图A',
|
|
src: 'data:image/png;base64,cmVmQQ==',
|
|
},
|
|
{
|
|
id: 'ref-b',
|
|
label: '参考图B',
|
|
src: 'data:image/png;base64,cmVmQg==',
|
|
},
|
|
],
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
|
const strip = panel.querySelector('.image-canvas-editor__reference-strip');
|
|
|
|
expect(strip).toBeTruthy();
|
|
expect(within(strip as HTMLElement).getByText('参考图A')).toBeTruthy();
|
|
expect(within(strip as HTMLElement).getByText('参考图B')).toBeTruthy();
|
|
expect(
|
|
within(strip as HTMLElement).getByRole('button', { name: '添加参考图' }),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('removes an existing reference from its hover delete button', () => {
|
|
render(
|
|
<BasicGenerationHarness
|
|
initialDialog={createDialog({
|
|
generationReferences: [
|
|
{
|
|
id: 'ref-a',
|
|
label: '参考图A',
|
|
src: 'data:image/png;base64,cmVmQQ==',
|
|
},
|
|
],
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '删除参考图A' }));
|
|
|
|
expect(screen.queryByText('参考图A')).toBeNull();
|
|
});
|
|
|
|
it('shows only current image options and opens upward option panels', () => {
|
|
render(<BasicGenerationHarness />);
|
|
|
|
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
|
fireEvent.click(
|
|
within(panel).getByRole('button', {
|
|
name: '生成图片尺寸 16:9·1K',
|
|
}),
|
|
);
|
|
const dimensionPanel = screen.getByRole('menu', {
|
|
name: '生成图片尺寸选项',
|
|
});
|
|
expect(within(dimensionPanel).getByText('比例')).toBeTruthy();
|
|
fireEvent.click(within(dimensionPanel).getByRole('button', { name: '2K' }));
|
|
|
|
expect(screen.getByLabelText('当前尺寸').textContent).toBe('2K');
|
|
|
|
fireEvent.click(
|
|
within(panel).getByRole('button', {
|
|
name: '生成图片模型 nanobanana2',
|
|
}),
|
|
);
|
|
const modelPanel = screen.getByRole('menu', { name: '生成图片模型选项' });
|
|
fireEvent.click(
|
|
within(modelPanel).getByRole('button', { name: /gpt-image-2/ }),
|
|
);
|
|
|
|
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
|
|
const submitButton = within(panel).getByRole('button', { name: '生成' });
|
|
expect(submitButton.textContent).toBe('生成20泥点');
|
|
});
|
|
|
|
it('clicks the parent generation panel to collapse an opened option panel', () => {
|
|
render(<BasicGenerationHarness />);
|
|
|
|
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
|
fireEvent.click(
|
|
within(panel).getByRole('button', {
|
|
name: '生成图片尺寸 16:9·1K',
|
|
}),
|
|
);
|
|
expect(
|
|
screen.getByRole('menu', { name: '生成图片尺寸选项' }),
|
|
).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('textbox', { name: '生成提示词' }));
|
|
|
|
expect(
|
|
screen.queryByRole('menu', { name: '生成图片尺寸选项' }),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('does not render a standalone close button', () => {
|
|
render(<BasicGenerationHarness />);
|
|
|
|
expect(
|
|
screen.queryByRole('button', { name: '关闭生成图片' }),
|
|
).toBeNull();
|
|
});
|
|
});
|