946c101630
- src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts:applyQueuedGeneration 返回 false 时不再静默复位成 idle(用户会以为没提交过、再点一次就是又一次已扣费的外部生成),改为写回失败态与明确文案 - src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts:新增 MODEL3D_QUEUE_HANDOFF_FAILED_MESSAGE,与「服务端没返回任务状态」区分开 - src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx:原「回到空闲态」用例改为断言失败态
436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { act, renderHook } from '@testing-library/react';
|
|
import { useState } from 'react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { ExternalGenerationJobStatusRecord } from '../../../../packages/shared/src/contracts/externalGeneration';
|
|
import { ApiClientError } from '../../../services/apiClient';
|
|
import type {
|
|
EditorCanvasGenerationCompletionInput,
|
|
Model3dGenerationSubmissionResponse,
|
|
} from '../../../services/image-editor/editorProjectClient';
|
|
import type { Model3dPricingConfig } from '../../../services/image-editor/editorProjectClient';
|
|
import type {
|
|
CanvasGenerationDialogState,
|
|
GenerateDialogState,
|
|
} from '../ImageCanvasEditorTypes';
|
|
import {
|
|
applyEditorGenerationPricingConfig,
|
|
EDITOR_MODEL_MUD_POINT_CONFIG,
|
|
} from '../ImageCanvasGenerationModel';
|
|
import {
|
|
createModel3dGenerationDraftFields,
|
|
MODEL3D_PRICING_UNAVAILABLE_REASON,
|
|
} from './Model3dGenerationFormModel';
|
|
import {
|
|
MODEL3D_QUEUE_HANDOFF_FAILED_MESSAGE,
|
|
MODEL3D_SUBMISSION_MISSING_QUEUE_STATE_MESSAGE,
|
|
} from './Model3dGenerationSubmission';
|
|
import { useModel3dGenerationTask } from './useModel3dGenerationTask';
|
|
|
|
const submitModel3dTextToModelRequestMock = vi.hoisted(() => vi.fn());
|
|
const submitModel3dImageToModelRequestMock = 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,
|
|
submitModel3dTextToModelRequest: submitModel3dTextToModelRequestMock,
|
|
submitModel3dImageToModelRequest: submitModel3dImageToModelRequestMock,
|
|
};
|
|
});
|
|
|
|
/** 两个端点各一个客户端函数,用例按当前端点取对应那个 mock。 */
|
|
function submitRequestMock(endpoint: 'text-to-model' | 'image-to-model') {
|
|
return endpoint === 'text-to-model'
|
|
? submitModel3dTextToModelRequestMock
|
|
: submitModel3dImageToModelRequestMock;
|
|
}
|
|
|
|
const PRICING: Model3dPricingConfig = {
|
|
basePrices: {
|
|
'text-to-model': { 'v3.1-20260211': { noTexture: 100, texture: 200 } },
|
|
'image-to-model': { 'v3.1-20260211': { noTexture: 150, texture: 250 } },
|
|
},
|
|
addOnPrices: {
|
|
hdTexture: 10,
|
|
ultraTexture: 20,
|
|
hdGeometry: 30,
|
|
quadMesh: 40,
|
|
smartLowPoly: 50,
|
|
generateParts: 60,
|
|
},
|
|
};
|
|
|
|
const QUEUED_STATUS: ExternalGenerationJobStatusRecord = {
|
|
operationId: 'task-1',
|
|
status: 'queued',
|
|
phaseLabel: '排队中',
|
|
phaseDetail: '等待执行',
|
|
progress: 0,
|
|
updatedAtMicros: 1,
|
|
};
|
|
|
|
const PLACEHOLDER: NonNullable<
|
|
EditorCanvasGenerationCompletionInput['placeholder']
|
|
> = {
|
|
x: 0,
|
|
y: 0,
|
|
width: 1024,
|
|
height: 1024,
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
};
|
|
|
|
function createDialog(
|
|
overrides: Partial<GenerateDialogState> = {},
|
|
): GenerateDialogState {
|
|
return {
|
|
id: 'dialog-3d',
|
|
...createModel3dGenerationDraftFields('model3d-text-to-model'),
|
|
prompt: '一把木椅',
|
|
placeholder: PLACEHOLDER,
|
|
...overrides,
|
|
} as GenerateDialogState;
|
|
}
|
|
|
|
function renderTask({
|
|
dialog,
|
|
projectId = 'project-1',
|
|
assetFolderId,
|
|
applyQueuedGeneration = vi.fn(async () => true),
|
|
onWalletBalanceMayHaveChanged = vi.fn(),
|
|
}: {
|
|
dialog: GenerateDialogState;
|
|
projectId?: string | null;
|
|
assetFolderId?: string | null;
|
|
applyQueuedGeneration?: (...args: unknown[]) => Promise<boolean>;
|
|
onWalletBalanceMayHaveChanged?: () => void;
|
|
}) {
|
|
const onQueuedGenerationTask = vi.fn();
|
|
const view = renderHook(() => {
|
|
const [dialogs, setDialogs] = useState<CanvasGenerationDialogState[]>([
|
|
dialog as CanvasGenerationDialogState,
|
|
]);
|
|
const { submitModel3dGeneration } = useModel3dGenerationTask({
|
|
projectId,
|
|
assetFolderId,
|
|
updateCanvasGenerationDialogById: (dialogId, updater) => {
|
|
setDialogs((currentDialogs) =>
|
|
currentDialogs.map((currentDialog) =>
|
|
currentDialog.id === dialogId
|
|
? (updater(currentDialog) ?? currentDialog)
|
|
: currentDialog,
|
|
),
|
|
);
|
|
},
|
|
getGeneratingDialogPlaceholder: (currentDialog) =>
|
|
currentDialog.placeholder ?? null,
|
|
applyQueuedGeneration: applyQueuedGeneration as never,
|
|
onQueuedGenerationTask,
|
|
onWalletBalanceMayHaveChanged,
|
|
});
|
|
return {
|
|
submitModel3dGeneration,
|
|
// 用例里只会有一个对话框,收窄成非空便于断言。
|
|
dialog: dialogs[0] as GenerateDialogState,
|
|
};
|
|
});
|
|
return { ...view, onQueuedGenerationTask, onWalletBalanceMayHaveChanged };
|
|
}
|
|
|
|
describe('useModel3dGenerationTask', () => {
|
|
beforeEach(() => {
|
|
submitModel3dTextToModelRequestMock.mockReset();
|
|
submitModel3dImageToModelRequestMock.mockReset();
|
|
applyEditorGenerationPricingConfig({
|
|
models: EDITOR_MODEL_MUD_POINT_CONFIG,
|
|
model3d: PRICING,
|
|
});
|
|
});
|
|
|
|
it('提交成功后交给既有排队链路,并以对话框 id 结算画布占位', async () => {
|
|
const submitResponse: Model3dGenerationSubmissionResponse = {
|
|
operationId: 'task-1',
|
|
jobKind: 'model3d_text_to_model',
|
|
status: QUEUED_STATUS,
|
|
statusUrl: '/api/runtime/external-generation/jobs/task-1',
|
|
};
|
|
submitModel3dTextToModelRequestMock.mockResolvedValue(submitResponse);
|
|
const applyQueuedGeneration = vi.fn(async () => true);
|
|
const { result, onQueuedGenerationTask } = renderTask({
|
|
dialog: createDialog(),
|
|
assetFolderId: 'project',
|
|
applyQueuedGeneration,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.submitModel3dGeneration(createDialog());
|
|
});
|
|
|
|
expect(submitModel3dTextToModelRequestMock).toHaveBeenCalledTimes(1);
|
|
const request = submitModel3dTextToModelRequestMock.mock.calls[0][0];
|
|
expect(request.idempotencyKey).toMatch(/^[\x21-\x7e]{1,128}$/);
|
|
expect(request.body).toMatchObject({
|
|
generation: {
|
|
prompt: '一把木椅',
|
|
model: 'v3.1-20260211',
|
|
texture: true,
|
|
textureQuality: 'standard',
|
|
pbr: false,
|
|
geometryQuality: 'standard',
|
|
quad: false,
|
|
smartLowPoly: false,
|
|
generateParts: false,
|
|
},
|
|
projectId: 'project-1',
|
|
assetFolderId: 'project',
|
|
assetLabel: '一把木椅',
|
|
});
|
|
|
|
expect(applyQueuedGeneration).toHaveBeenCalledTimes(1);
|
|
const [, queuedProjectId, , onQueued, , , completionDialogId] =
|
|
applyQueuedGeneration.mock.calls[0] as unknown as unknown[];
|
|
expect(queuedProjectId).toBe('project-1');
|
|
expect(completionDialogId).toBe('dialog-3d');
|
|
expect(onQueued).toBe(onQueuedGenerationTask);
|
|
expect(result.current.dialog.status).toBe('generating');
|
|
expect(result.current.dialog.composerOpen).toBe(false);
|
|
});
|
|
|
|
it('缺代次的老快照兜底铸一个并写回,幂等键跟着这个代次走', async () => {
|
|
submitModel3dTextToModelRequestMock.mockResolvedValue({
|
|
operationId: 'task-1',
|
|
jobKind: 'model3d_text_to_model',
|
|
status: QUEUED_STATUS,
|
|
statusUrl: '/api/runtime/external-generation/jobs/task-1',
|
|
});
|
|
const legacyDialog = createDialog({ model3dAttemptNonce: undefined });
|
|
const { result } = renderTask({ dialog: legacyDialog });
|
|
|
|
await act(async () => {
|
|
await result.current.submitModel3dGeneration(legacyDialog);
|
|
});
|
|
|
|
const request = submitModel3dTextToModelRequestMock.mock.calls[0][0];
|
|
const persistedNonce = result.current.dialog.model3dAttemptNonce;
|
|
expect(persistedNonce).toBeTruthy();
|
|
expect(request.idempotencyKey.startsWith(`${persistedNonce}-`)).toBe(true);
|
|
});
|
|
|
|
it('排队链路没接手时不静默复位成空闲态', async () => {
|
|
submitModel3dTextToModelRequestMock.mockResolvedValue({
|
|
operationId: 'task-1',
|
|
jobKind: 'model3d_text_to_model',
|
|
status: QUEUED_STATUS,
|
|
statusUrl: '/api/runtime/external-generation/jobs/task-1',
|
|
});
|
|
const { result } = renderTask({
|
|
dialog: createDialog(),
|
|
applyQueuedGeneration: vi.fn(async () => false),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.submitModel3dGeneration(createDialog());
|
|
});
|
|
|
|
expect(result.current.dialog.status).toBe('failed');
|
|
expect(result.current.dialog.errorMessage).toBe(
|
|
MODEL3D_QUEUE_HANDOFF_FAILED_MESSAGE,
|
|
);
|
|
expect(result.current.dialog.composerOpen).toBe(true);
|
|
});
|
|
|
|
it('2xx 但没有任务状态时给出失败提示,而不是静默复位', async () => {
|
|
// requestJson 在空 body 时返回 null,这里覆盖「成功但没有 status」的形态。
|
|
submitModel3dTextToModelRequestMock.mockResolvedValue(null);
|
|
const applyQueuedGeneration = vi.fn(async () => true);
|
|
const { result } = renderTask({
|
|
dialog: createDialog(),
|
|
applyQueuedGeneration,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.submitModel3dGeneration(createDialog());
|
|
});
|
|
|
|
expect(applyQueuedGeneration).not.toHaveBeenCalled();
|
|
expect(result.current.dialog.status).toBe('failed');
|
|
expect(result.current.dialog.errorMessage).toBe(
|
|
MODEL3D_SUBMISSION_MISSING_QUEUE_STATE_MESSAGE,
|
|
);
|
|
});
|
|
|
|
it('请求失败时留在面板上展示失败原因', async () => {
|
|
submitModel3dTextToModelRequestMock.mockRejectedValue(
|
|
new ApiClientError({
|
|
message: '3D 模型生成失败,请稍后重试。',
|
|
status: 502,
|
|
code: 'tripo-upstream-error',
|
|
}),
|
|
);
|
|
const { result } = renderTask({ dialog: createDialog() });
|
|
|
|
await act(async () => {
|
|
await result.current.submitModel3dGeneration(createDialog());
|
|
});
|
|
|
|
expect(result.current.dialog.status).toBe('failed');
|
|
expect(result.current.dialog.errorMessage).toBe(
|
|
'3D 模型生成失败,请稍后重试。',
|
|
);
|
|
expect(result.current.dialog.composerOpen).toBe(true);
|
|
});
|
|
|
|
it('构造提交计划时抛的错也落到失败态,而不是漏成未捕获的 rejection', async () => {
|
|
// 老快照缺字段:resolveModel3dResultTitle 会读 dialog.prompt.trim(),在计划构造前就抛。
|
|
const { result } = renderTask({
|
|
dialog: createDialog({ prompt: undefined }),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.submitModel3dGeneration(
|
|
createDialog({ prompt: undefined }),
|
|
);
|
|
});
|
|
|
|
expect(result.current.dialog.status).toBe('failed');
|
|
expect(result.current.dialog.composerOpen).toBe(true);
|
|
expect(submitModel3dTextToModelRequestMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('提交失败与「2xx 无任务状态」都按可能已扣费刷新钱包', async () => {
|
|
submitModel3dTextToModelRequestMock.mockRejectedValue(
|
|
new ApiClientError({
|
|
message: '3D 模型生成失败,请稍后重试。',
|
|
status: 502,
|
|
code: 'tripo-upstream-error',
|
|
}),
|
|
);
|
|
const failed = renderTask({ dialog: createDialog() });
|
|
|
|
await act(async () => {
|
|
await failed.result.current.submitModel3dGeneration(createDialog());
|
|
});
|
|
|
|
expect(failed.onWalletBalanceMayHaveChanged).toHaveBeenCalledTimes(1);
|
|
|
|
submitModel3dTextToModelRequestMock.mockResolvedValue(null);
|
|
const emptyBody = renderTask({ dialog: createDialog() });
|
|
|
|
await act(async () => {
|
|
await emptyBody.result.current.submitModel3dGeneration(createDialog());
|
|
});
|
|
|
|
expect(emptyBody.onWalletBalanceMayHaveChanged).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('定价段缺失时不发请求,只把不可提交的原因写回面板', async () => {
|
|
applyEditorGenerationPricingConfig({
|
|
models: EDITOR_MODEL_MUD_POINT_CONFIG,
|
|
});
|
|
const applyQueuedGeneration = vi.fn(async () => true);
|
|
const { result } = renderTask({
|
|
dialog: createDialog(),
|
|
applyQueuedGeneration,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.submitModel3dGeneration(createDialog());
|
|
});
|
|
|
|
expect(submitModel3dTextToModelRequestMock).not.toHaveBeenCalled();
|
|
expect(applyQueuedGeneration).not.toHaveBeenCalled();
|
|
expect(result.current.dialog.status).toBe('failed');
|
|
expect(result.current.dialog.errorMessage).toBe(
|
|
MODEL3D_PRICING_UNAVAILABLE_REASON,
|
|
);
|
|
});
|
|
|
|
it('没有任何落点(既没有工程也没有素材夹)时不发请求', async () => {
|
|
const { result } = renderTask({ dialog: createDialog(), projectId: null });
|
|
|
|
await act(async () => {
|
|
await result.current.submitModel3dGeneration(createDialog());
|
|
});
|
|
|
|
expect(submitModel3dTextToModelRequestMock).not.toHaveBeenCalled();
|
|
expect(result.current.dialog.status).toBe('failed');
|
|
expect(result.current.dialog.errorMessage).toContain('保存当前画布工程');
|
|
});
|
|
|
|
it('没有画布工程时照样提交:只发素材夹,不发 projectId 与 canvasCompletion', async () => {
|
|
const submitResponse: Model3dGenerationSubmissionResponse = {
|
|
operationId: 'task-2',
|
|
jobKind: 'model3d_text_to_model',
|
|
status: QUEUED_STATUS,
|
|
statusUrl: '/api/runtime/external-generation/jobs/task-2',
|
|
};
|
|
submitModel3dTextToModelRequestMock.mockResolvedValue(submitResponse);
|
|
const { result } = renderTask({
|
|
dialog: createDialog(),
|
|
projectId: null,
|
|
assetFolderId: 'project',
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.submitModel3dGeneration(createDialog());
|
|
});
|
|
|
|
expect(submitModel3dTextToModelRequestMock).toHaveBeenCalledTimes(1);
|
|
const request = submitModel3dTextToModelRequestMock.mock.calls[0][0];
|
|
expect(request.body).not.toHaveProperty('projectId');
|
|
expect(request.body).not.toHaveProperty('canvasCompletion');
|
|
expect(request.body).toMatchObject({ assetFolderId: 'project' });
|
|
});
|
|
|
|
it('图生 3D 走图生客户端函数:端点与提交体不会错配', async () => {
|
|
const imageMock = submitRequestMock('image-to-model');
|
|
imageMock.mockResolvedValue({
|
|
operationId: 'task-3',
|
|
jobKind: 'model3d_image_to_model',
|
|
status: QUEUED_STATUS,
|
|
statusUrl: '/api/runtime/external-generation/jobs/task-3',
|
|
});
|
|
const dialog = createDialog({
|
|
mode: 'model3d-image-to-model',
|
|
generationReferences: [
|
|
{
|
|
id: 'reference-1',
|
|
label: '参考图',
|
|
src: 'data:image/png;base64,1',
|
|
resourceId: 'resource-1',
|
|
},
|
|
],
|
|
});
|
|
const { result } = renderTask({ dialog });
|
|
|
|
await act(async () => {
|
|
await result.current.submitModel3dGeneration(dialog);
|
|
});
|
|
|
|
expect(imageMock).toHaveBeenCalledTimes(1);
|
|
expect(submitModel3dTextToModelRequestMock).not.toHaveBeenCalled();
|
|
expect(imageMock.mock.calls[0]?.[0].body).toMatchObject({
|
|
source: { kind: 'resource', resourceId: 'resource-1' },
|
|
});
|
|
});
|
|
|
|
it('非 3D 对话框不做任何事', async () => {
|
|
const { result } = renderTask({
|
|
dialog: createDialog({ mode: 'generate' }),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.submitModel3dGeneration(
|
|
createDialog({ mode: 'generate' }),
|
|
);
|
|
});
|
|
|
|
expect(submitModel3dTextToModelRequestMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|