Files
Genarrative/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts
T
k88936 a4132616d8 3D 产物 content type 只认字节或槽位白名单,去掉兜底
- 删除 fallback_extension 与「未知声明原样放行 + 扩展名兜底」的归一逻辑
- 槽位白名单收敛为唯一映射表(content type ↔ 对象键扩展名),跨槽位类型返回 None
- resolve_artifact_content_type 改为返回 Result:字节认不出且声明不在白名单时按 502 失败,
  不再把 text/plain 之类的声明连同拼出来的 .glb 扩展名落进 OSS 与 asset_object
- 错误体只回显截断到 64 字符的声明值,避免把任意长度的 provider 文本带进响应
- 重写 storage 测试:新增白名单外声明、跨槽位类型与字节认不出三类用例
- 同步技术方案:说明白名单范围与「不做任何兜底」的口径
2026-09-23 16:39:15 +08:00

454 lines
14 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { Model3dPricingConfig } from '../../../services/image-editor/editorProjectClient';
import type {
CharacterReferenceImage,
GenerateDialogState,
} from '../ImageCanvasEditorTypes';
import {
createModel3dGenerationDraftFields,
refreshModel3dAttemptNonce,
} from './Model3dGenerationFormModel';
import {
buildModel3dSubmissionPlan,
MODEL3D_GENERATION_FAILURE_MESSAGE,
MODEL3D_GENERATION_TIMEOUT_MESSAGE,
MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE,
MODEL3D_TARGET_REQUIRED_MESSAGE,
MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE,
resolveModel3dGenerationErrorMessage,
resolveModel3dImageSource,
resolveModel3dRequestKey,
resolveModel3dResultTitle,
} from './Model3dGenerationSubmission';
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 CANVAS_COMPLETION = {
dialogId: 'dialog-3d',
title: '3D 模型',
placeholder: {
x: 1,
y: 2,
width: 1024,
height: 1024,
originalWidth: 1024,
originalHeight: 1024,
},
};
function createDialog(
mode: 'model3d-text-to-model' | 'model3d-image-to-model',
overrides: Partial<GenerateDialogState> = {},
): GenerateDialogState {
return {
id: 'dialog-3d',
...createModel3dGenerationDraftFields(mode),
...overrides,
} as GenerateDialogState;
}
function canvasReference(
overrides: Partial<CharacterReferenceImage> = {},
): CharacterReferenceImage {
return {
id: 'reference-1',
src: '/generated-character-drafts/editor/ref.webp',
label: '参考图',
...overrides,
};
}
describe('resolveModel3dImageSource', () => {
it('画布资源与素材库资产各收敛成契约允许的一种引用', () => {
expect(
resolveModel3dImageSource(
createDialog('model3d-image-to-model', {
generationReferences: [canvasReference({ resourceId: 'resource-1' })],
}),
),
).toEqual({ kind: 'resource', resourceId: 'resource-1' });
expect(
resolveModel3dImageSource(
createDialog('model3d-image-to-model', {
generationReferences: [canvasReference({ sourceAssetId: 'asset-1' })],
}),
),
).toEqual({ kind: 'asset', assetId: 'asset-1' });
});
it('还没登记上传的本地图片不构成 source,空白 id 同样不算', () => {
expect(
resolveModel3dImageSource(
createDialog('model3d-image-to-model', {
generationReferences: [canvasReference()],
}),
),
).toBeNull();
expect(
resolveModel3dImageSource(
createDialog('model3d-image-to-model', {
generationReferences: [
canvasReference({ resourceId: ' ', sourceAssetId: '' }),
],
}),
),
).toBeNull();
expect(
resolveModel3dImageSource(createDialog('model3d-image-to-model')),
).toBeNull();
});
});
describe('buildModel3dSubmissionPlan', () => {
it('文生 3D 的请求体全量给出价格相关参数与落点,并同时落素材库', () => {
const result = buildModel3dSubmissionPlan({
dialog: createDialog('model3d-text-to-model', { prompt: ' 一把木椅 ' }),
projectId: 'project-1',
canvasCompletion: CANVAS_COMPLETION,
assetFolderId: 'project',
assetLabel: ' 一把木椅 ',
pricing: PRICING,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.plan.endpoint).toBe('text-to-model');
expect(result.plan.body).toEqual({
generation: {
model: 'v3.1-20260211',
prompt: '一把木椅',
texture: true,
textureQuality: 'standard',
pbr: false,
geometryQuality: 'standard',
quad: false,
smartLowPoly: false,
generateParts: false,
},
projectId: 'project-1',
// 与其它画布生成工具同形:项目资源与素材库两个落点一起发,结果两边都落。
assetFolderId: 'project',
assetLabel: '一把木椅',
canvasCompletion: CANVAS_COMPLETION,
});
});
it('没有素材夹时只给 projectId:素材库落点与素材名都不进请求体', () => {
const result = buildModel3dSubmissionPlan({
dialog: createDialog('model3d-text-to-model', { prompt: '木椅' }),
projectId: 'project-1',
assetFolderId: ' ',
pricing: PRICING,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.plan.body).toEqual({
generation: {
model: 'v3.1-20260211',
prompt: '木椅',
texture: true,
textureQuality: 'standard',
pbr: false,
geometryQuality: 'standard',
quad: false,
smartLowPoly: false,
generateParts: false,
},
projectId: 'project-1',
});
});
it('没有画布工程时只给素材夹:不带 projectId,也不带 canvasCompletion', () => {
const result = buildModel3dSubmissionPlan({
dialog: createDialog('model3d-text-to-model', { prompt: '木椅' }),
projectId: ' ',
canvasCompletion: CANVAS_COMPLETION,
assetFolderId: 'project',
pricing: PRICING,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.plan.body).not.toHaveProperty('projectId');
expect(result.plan.body).not.toHaveProperty('canvasCompletion');
expect(result.plan.body).toMatchObject({
assetFolderId: 'project',
assetLabel: '木椅',
});
});
it('素材夹落点只 trim,素材名缺省回落本次结果标题', () => {
const result = buildModel3dSubmissionPlan({
dialog: createDialog('model3d-text-to-model', { prompt: '木椅' }),
projectId: 'project-1',
assetFolderId: ' 个人素材夹 ',
pricing: PRICING,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.plan.body).toMatchObject({
assetFolderId: '个人素材夹',
assetLabel: '木椅',
});
});
it('图生 3D 带 source、不带提示词;纯几何档位不带 textureQuality', () => {
const result = buildModel3dSubmissionPlan({
dialog: createDialog('model3d-image-to-model', {
model3dTier: 'geometry-only',
model3dGenerateParts: true,
generationReferences: [canvasReference({ sourceAssetId: ' asset-1 ' })],
}),
projectId: 'project-1',
pricing: PRICING,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.plan.endpoint).toBe('image-to-model');
expect(result.plan.body).toEqual({
source: { kind: 'asset', assetId: 'asset-1' },
generation: {
model: 'v3.1-20260211',
texture: false,
pbr: false,
geometryQuality: 'standard',
quad: false,
smartLowPoly: false,
generateParts: true,
},
projectId: 'project-1',
});
expect(result.plan.body).not.toHaveProperty('prompt');
// 后端显式拒绝 texture=false + textureQuality 的组合,缺位是契约要求。
expect(
result.plan.body.generation as Record<string, unknown>,
).not.toHaveProperty('textureQuality');
});
it('缺落点、缺输入、缺定价都不组装请求', () => {
const textDialog = createDialog('model3d-text-to-model');
expect(
buildModel3dSubmissionPlan({
dialog: { ...textDialog, prompt: '木椅' },
projectId: ' ',
assetFolderId: ' ',
pricing: PRICING,
}),
).toEqual({ ok: false, message: MODEL3D_TARGET_REQUIRED_MESSAGE });
expect(
buildModel3dSubmissionPlan({
dialog: textDialog,
projectId: 'project-1',
pricing: PRICING,
}),
).toEqual({ ok: false, message: MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE });
expect(
buildModel3dSubmissionPlan({
dialog: createDialog('model3d-image-to-model'),
projectId: 'project-1',
pricing: PRICING,
}),
).toEqual({ ok: false, message: MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE });
expect(
buildModel3dSubmissionPlan({
dialog: { ...textDialog, prompt: '木椅' },
projectId: 'project-1',
pricing: null,
}),
).toEqual({ ok: false, message: '3D 模型定价尚未配置' });
});
});
describe('resolveModel3dResultTitle', () => {
it('文生用截断后的描述做标题,图生与空描述用统一标题', () => {
const longPrompt = '圆'.repeat(30);
expect(
resolveModel3dResultTitle(
createDialog('model3d-text-to-model', { prompt: longPrompt }),
),
).toBe('圆'.repeat(24));
expect(
resolveModel3dResultTitle(createDialog('model3d-text-to-model')),
).toBe('3D 模型');
expect(
resolveModel3dResultTitle(
createDialog('model3d-image-to-model', { prompt: '木椅' }),
),
).toBe('3D 模型');
});
});
describe('Idempotency-Key', () => {
const buildKey = (dialog: GenerateDialogState) => {
const result = buildModel3dSubmissionPlan({
dialog,
projectId: 'project-1',
pricing: PRICING,
});
if (!result.ok) {
throw new Error('用例里的请求必须可提交');
}
return result.plan.requestKey;
};
it('同一份内容重复提交继续用同一个键,双击不会开出两个 operation', () => {
const dialog = createDialog('model3d-text-to-model', { prompt: '木椅' });
expect(buildKey(dialog)).toBe(buildKey(dialog));
});
it('键值可以直接当请求头用:可打印 ASCII 且不超长', () => {
const key = buildKey(
createDialog('model3d-text-to-model', { prompt: '木椅' }),
);
expect(key).toMatch(/^[\x21-\x7e]+$/);
expect(key.length).toBeLessThanOrEqual(128);
});
it('内容一变就换键:改提示词、换参考图、换落点都不复用旧键', () => {
const dialog = createDialog('model3d-text-to-model', { prompt: '木椅' });
const original = buildKey(dialog);
expect(buildKey({ ...dialog, prompt: '木凳' })).not.toBe(original);
expect(
buildKey({ ...dialog, model3dAttemptNonce: 'model3d-next' }),
).not.toBe(original);
expect(
buildKey({
...createDialog('model3d-image-to-model', {
generationReferences: [canvasReference({ resourceId: 'resource-1' })],
}),
}),
).not.toBe(
buildKey({
...createDialog('model3d-image-to-model', {
generationReferences: [canvasReference({ resourceId: 'resource-2' })],
}),
}),
);
});
it('同一个代次下换参考图也会换键——参考图是内容的一部分', () => {
const shared = {
id: 'dialog-3d',
...createModel3dGenerationDraftFields('model3d-image-to-model'),
model3dAttemptNonce: 'model3d-fixed',
} as GenerateDialogState;
const before = buildKey({
...shared,
generationReferences: [canvasReference({ resourceId: 'resource-1' })],
});
const after = buildKey({
...shared,
generationReferences: [canvasReference({ resourceId: 'resource-2' })],
});
expect(after).not.toBe(before);
expect(after.startsWith('model3d-fixed-')).toBe(true);
expect(before.startsWith('model3d-fixed-')).toBe(true);
});
it('用户主动重试换新一代,因此换键', () => {
const dialog = createDialog('model3d-text-to-model', {
prompt: '木椅',
status: 'failed',
errorMessage: '3D 模型生成失败,请稍后重试。',
});
const retried = refreshModel3dAttemptNonce(dialog);
expect(buildKey(retried)).not.toBe(buildKey(dialog));
});
it('没有代次(老会话)时现场铸造,键仍然可用', () => {
const key = buildKey({
...createDialog('model3d-text-to-model', { prompt: '木椅' }),
model3dAttemptNonce: undefined,
});
expect(key.startsWith('model3d-')).toBe(true);
expect(
resolveModel3dRequestKey({
attemptNonce: null,
endpoint: 'text-to-model',
body: {
generation: { model: 'v3.1-20260211', prompt: '木椅' },
projectId: 'project-1',
},
}),
).toMatch(/^[\x21-\x7e]{1,128}$/);
});
it('字段顺序不同但内容相同的请求得到同一个指纹', () => {
const left = resolveModel3dRequestKey({
attemptNonce: 'model3d-fixed',
endpoint: 'text-to-model',
body: {
generation: { texture: true, model: 'v3.1-20260211', prompt: '木椅' },
projectId: 'project-1',
},
});
const right = resolveModel3dRequestKey({
attemptNonce: 'model3d-fixed',
endpoint: 'text-to-model',
body: {
generation: { model: 'v3.1-20260211', texture: true, prompt: '木椅' },
projectId: 'project-1',
},
});
expect(right).toBe(left);
});
});
describe('resolveModel3dGenerationErrorMessage', () => {
it('服务端给的文案原样透出', () => {
expect(
resolveModel3dGenerationErrorMessage(
new Error('3D 生成服务当前限流,请稍后重试。'),
),
).toBe('3D 生成服务当前限流,请稍后重试。');
expect(resolveModel3dGenerationErrorMessage('落点不可用')).toBe(
'落点不可用',
);
});
it('传输层英文技术文案换成中文兜底', () => {
expect(
resolveModel3dGenerationErrorMessage(new TypeError('Failed to fetch')),
).toBe(MODEL3D_GENERATION_FAILURE_MESSAGE);
expect(
resolveModel3dGenerationErrorMessage(
new DOMException('The operation was aborted.', 'AbortError'),
),
).toBe(MODEL3D_GENERATION_FAILURE_MESSAGE);
expect(resolveModel3dGenerationErrorMessage(null)).toBe(
MODEL3D_GENERATION_FAILURE_MESSAGE,
);
});
it('超时单独给出可重试的提示', () => {
const timeout = new Error('请求超时:60000ms');
timeout.name = 'TimeoutError';
expect(resolveModel3dGenerationErrorMessage(timeout)).toBe(
MODEL3D_GENERATION_TIMEOUT_MESSAGE,
);
});
});