a4132616d8
- 删除 fallback_extension 与「未知声明原样放行 + 扩展名兜底」的归一逻辑 - 槽位白名单收敛为唯一映射表(content type ↔ 对象键扩展名),跨槽位类型返回 None - resolve_artifact_content_type 改为返回 Result:字节认不出且声明不在白名单时按 502 失败, 不再把 text/plain 之类的声明连同拼出来的 .glb 扩展名落进 OSS 与 asset_object - 错误体只回显截断到 64 字符的声明值,避免把任意长度的 provider 文本带进响应 - 重写 storage 测试:新增白名单外声明、跨槽位类型与字节认不出三类用例 - 同步技术方案:说明白名单范围与「不做任何兜底」的口径
454 lines
14 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|