7830d201c4
Move editor generation pricing to backend: remove priceMudPoints from public request schemas and docs, require backend to compute model prices and perform pre-charge via execute_billable_asset_operation_with_cost. Update character animation and video handlers to use authenticated owner IDs and wrap generation requests in billing; remove client-side price validation. Adjust OpenAPI, project-memory docs, pricing management docs, and related tests to reflect that front-end only displays prices and must not submit priceMudPoints. Also document new user registration reward and wallet refund outbox behavior. Code and test changes applied across api-server handlers, shared contracts, and image-editor client/tests.
226 lines
8.0 KiB
TypeScript
226 lines
8.0 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
import { createRef, useState } from 'react';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import type {
|
|
GenerateDialogState,
|
|
SpecGenerationType,
|
|
UploadTarget,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
|
|
import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel';
|
|
|
|
function createDialog(
|
|
patch: Partial<GenerateDialogState> = {},
|
|
): GenerateDialogState {
|
|
return {
|
|
mode: 'character',
|
|
prompt: '旧角色设定',
|
|
status: 'idle',
|
|
characterSpecReference: {
|
|
id: 'spec-a',
|
|
label: '角色规范A',
|
|
src: 'data:image/png;base64,c3BlYw==',
|
|
},
|
|
characterReferences: [
|
|
{
|
|
id: 'ref-a',
|
|
label: '参考图A',
|
|
src: 'data:image/png;base64,cmVm',
|
|
},
|
|
],
|
|
...patch,
|
|
};
|
|
}
|
|
|
|
function CharacterGenerationHarness({
|
|
initialDialog = createDialog(),
|
|
onOpenSpecDialog = vi.fn(),
|
|
onRequestUpload = vi.fn(),
|
|
onSubmit = vi.fn(),
|
|
onRememberImageModel = vi.fn(),
|
|
}: {
|
|
initialDialog?: GenerateDialogState;
|
|
onOpenSpecDialog?: (specType: SpecGenerationType) => void;
|
|
onRequestUpload?: (target: UploadTarget) => void;
|
|
onSubmit?: (dialog: GenerateDialogState) => void;
|
|
onRememberImageModel?: (model: string) => void;
|
|
}) {
|
|
const [dialog, setDialog] = useState<GenerateDialogState | null>(
|
|
initialDialog,
|
|
);
|
|
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] =
|
|
useState(false);
|
|
const [
|
|
isCharacterReferenceMenuOpen,
|
|
setIsCharacterReferenceMenuOpen,
|
|
] = useState(false);
|
|
const [isPickingSpec, setIsPickingSpec] = useState(false);
|
|
const [isPickingReference, setIsPickingReference] = useState(false);
|
|
const characterSpecButtonRef = createRef<HTMLButtonElement>();
|
|
const characterReferenceButtonRef = createRef<HTMLButtonElement>();
|
|
|
|
return dialog ? (
|
|
<div>
|
|
<ImageCanvasCharacterGenerationComposerView
|
|
dialog={dialog}
|
|
style={{ left: 10, top: 20 }}
|
|
characterSpecButtonRef={characterSpecButtonRef}
|
|
characterReferenceButtonRef={characterReferenceButtonRef}
|
|
isCharacterSpecMenuOpen={isCharacterSpecMenuOpen}
|
|
isCharacterReferenceMenuOpen={isCharacterReferenceMenuOpen}
|
|
setGenerateDialog={setDialog}
|
|
setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen}
|
|
setIsCharacterReferenceMenuOpen={setIsCharacterReferenceMenuOpen}
|
|
setIsPickingCharacterSpecFromCanvas={setIsPickingSpec}
|
|
setIsPickingCharacterReferenceFromCanvas={setIsPickingReference}
|
|
renderEditorPortal={(node) => node}
|
|
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
|
|
onOpenSpecDialog={onOpenSpecDialog}
|
|
onRequestUpload={onRequestUpload}
|
|
onRememberImageModel={onRememberImageModel}
|
|
onSubmit={onSubmit}
|
|
/>
|
|
<output aria-label="当前角色设定">{dialog.prompt}</output>
|
|
<output aria-label="当前状态">{dialog.status}</output>
|
|
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
|
<output aria-label="选择角色规范">{String(isPickingSpec)}</output>
|
|
<output aria-label="选择常规参考图">{String(isPickingReference)}</output>
|
|
</div>
|
|
) : null;
|
|
}
|
|
|
|
describe('ImageCanvasCharacterGenerationComposerView', () => {
|
|
it('keeps the prompt as a borderless single text input with a question placeholder', () => {
|
|
render(<CharacterGenerationHarness />);
|
|
|
|
const panel = screen.getByRole('dialog', { name: '生成角色形象' });
|
|
const prompt = screen.getByRole('textbox', { name: '角色设定' });
|
|
|
|
expect(
|
|
Array.from(panel.querySelectorAll('.image-canvas-editor__field-title'))
|
|
.map((node) => node.textContent)
|
|
.join(''),
|
|
).not.toContain('角色设定');
|
|
expect(prompt.getAttribute('placeholder')).toBe('你希望角色如何设计?');
|
|
expect(prompt.className).toContain(
|
|
'image-canvas-editor__generation-prompt--borderless',
|
|
);
|
|
});
|
|
|
|
it('updates character prompt, clears failed state and submits', () => {
|
|
const submitCharacter = vi.fn();
|
|
render(
|
|
<CharacterGenerationHarness
|
|
initialDialog={createDialog({
|
|
status: 'failed',
|
|
errorMessage: '角色失败',
|
|
})}
|
|
onSubmit={submitCharacter}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.change(screen.getByLabelText('角色设定'), {
|
|
target: { value: '新的角色设定' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
|
|
|
expect(screen.getByLabelText('当前角色设定').textContent).toBe(
|
|
'新的角色设定',
|
|
);
|
|
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
|
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
|
expect(submitCharacter).toHaveBeenCalledWith(
|
|
expect.objectContaining({ prompt: '新的角色设定', status: 'idle' }),
|
|
);
|
|
});
|
|
|
|
it('updates the submit price when switching the character image model', () => {
|
|
const rememberImageModel = vi.fn();
|
|
render(
|
|
<CharacterGenerationHarness
|
|
initialDialog={createDialog({
|
|
imageModel: 'gemini-3.1-flash-image-preview',
|
|
aspectRatio: '1:1',
|
|
imageSize: '1K',
|
|
})}
|
|
onRememberImageModel={rememberImageModel}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
|
|
'生成12泥点',
|
|
);
|
|
fireEvent.click(
|
|
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' }));
|
|
|
|
expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2);
|
|
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
|
|
'生成3泥点',
|
|
);
|
|
});
|
|
|
|
it('routes character reference menus to canvas picking, spec creation and upload', () => {
|
|
const openSpecDialog = vi.fn();
|
|
const requestUpload = vi.fn();
|
|
render(
|
|
<CharacterGenerationHarness
|
|
onOpenSpecDialog={openSpecDialog}
|
|
onRequestUpload={requestUpload}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '角色规范A' }));
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
|
|
|
|
expect(screen.getByLabelText('选择角色规范').textContent).toBe('true');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '角色规范A' }));
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '新建角色规范' }));
|
|
|
|
expect(openSpecDialog).toHaveBeenCalledWith('character');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '角色规范A' }));
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
|
|
|
|
expect(requestUpload).toHaveBeenCalledWith('character-spec');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '上传常规参考图' }));
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
|
|
|
|
expect(screen.getByLabelText('选择常规参考图').textContent).toBe('true');
|
|
});
|
|
|
|
it('removes bound character spec and regular references from their delete buttons', () => {
|
|
render(<CharacterGenerationHarness />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '删除角色规范' }));
|
|
expect(screen.getByRole('button', { name: '角色规范' })).toBeTruthy();
|
|
expect(screen.queryByRole('button', { name: '角色规范A' })).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '删除参考图A' }));
|
|
expect(screen.queryByText('参考图A')).toBeNull();
|
|
});
|
|
|
|
it('clicks the parent character panel to collapse reference menus', () => {
|
|
render(
|
|
<CharacterGenerationHarness
|
|
initialDialog={createDialog()}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '角色规范A' }));
|
|
expect(screen.getByRole('menu', { name: '角色规范来源' })).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('textbox', { name: '角色设定' }));
|
|
|
|
expect(
|
|
screen.queryByRole('menu', { name: '角色规范来源' }),
|
|
).toBeNull();
|
|
});
|
|
});
|