Files
Genarrative/src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx
T
k88936 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:原「回到空闲态」用例改为断言失败态
2026-09-24 17:32:13 +08:00

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