Files
Genarrative/src/components/image-editor/model3d-generation/Model3dGenerationFormModel.test.ts
T
k88936 9840f17189 模型版本判据不再认下原型链上的键
- src/components/image-editor/model3d-generation/Model3dGenerationValidation.ts:isModel3dModelVersion 改用 Object.hasOwn,`toString` / `__proto__` 这类继承键不再被判成契约版本(之后查表会拿到 undefined)
- src/components/image-editor/model3d-generation/Model3dGenerationFormModel.test.ts:补原型链键回落默认版本的用例
2026-09-24 17:26:49 +08:00

605 lines
18 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { Model3dTextureQuality } from '../../../../packages/shared/src/contracts/model3d';
import type {
Model3dAddOnPriceKey,
Model3dPricingConfig,
} from '../../../services/image-editor/editorProjectClient';
import type { GenerateDialogState } from '../ImageCanvasEditorTypes';
import {
applyModel3dModelChange,
applyModel3dPromptChange,
applyModel3dReferenceRemoval,
applyModel3dSwitchChange,
applyModel3dTierChange,
createModel3dGenerationDraftFields,
DEFAULT_MODEL3D_MODEL_VERSION,
mintModel3dAttemptNonce,
MODEL3D_PRICING_UNAVAILABLE_REASON,
type Model3dGenerationMode,
normalizeModel3dModelVersion,
refreshModel3dAttemptNonce,
resolveModel3dAddOnKeys,
resolveModel3dGenerationParams,
resolveModel3dGenerationQuote,
resolveModel3dModelOptions,
resolveModel3dModelVersion,
resolveModel3dPrice,
} from './Model3dGenerationFormModel';
function createDialog(
overrides: Partial<GenerateDialogState> = {},
): GenerateDialogState {
return {
id: 'dialog-3d',
...createModel3dGenerationDraftFields('model3d-text-to-model'),
...overrides,
} as GenerateDialogState;
}
/**
* 与后端 `api-server::tripo3d::pricing::model3d_add_ons` 逐条对齐的期望值:
* 前端只有这一份映射,价格差异必须在这里被用例挡住。
*/
function expectedAddOns({
texture,
textureQuality,
geometryQuality,
quad,
smartLowPoly,
generateParts,
}: {
texture: boolean;
textureQuality: Model3dTextureQuality;
geometryQuality: 'standard' | 'detailed';
quad: boolean;
smartLowPoly: boolean;
generateParts: boolean;
}): Model3dAddOnPriceKey[] {
return [
...(texture && textureQuality === 'detailed' ? ['hdTexture' as const] : []),
...(texture && textureQuality === 'extreme'
? ['ultraTexture' as const]
: []),
...(geometryQuality === 'detailed' ? ['hdGeometry' as const] : []),
...(quad ? ['quadMesh' as const] : []),
...(smartLowPoly ? ['smartLowPoly' as const] : []),
...(generateParts ? ['generateParts' as const] : []),
];
}
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,
},
};
describe('resolveModel3dGenerationParams', () => {
it('每个档位映射成完整参数,档位与开关不留给 provider 隐式默认', () => {
expect(
resolveModel3dGenerationParams({
model: DEFAULT_MODEL3D_MODEL_VERSION,
tier: 'textured-standard',
quad: false,
smartLowPoly: false,
generateParts: false,
}),
).toEqual({
ok: true,
params: {
texture: true,
textureQuality: 'standard',
geometryQuality: 'standard',
quad: false,
smartLowPoly: false,
generateParts: false,
pbr: false,
},
});
expect(
resolveModel3dGenerationParams({
model: DEFAULT_MODEL3D_MODEL_VERSION,
tier: 'textured-detailed',
quad: true,
smartLowPoly: true,
generateParts: false,
}),
).toEqual({
ok: true,
params: {
texture: true,
textureQuality: 'detailed',
geometryQuality: 'detailed',
quad: true,
smartLowPoly: true,
generateParts: false,
pbr: false,
},
});
});
it('纯几何档位没有贴图档位,pbr 显式为 false', () => {
const result = resolveModel3dGenerationParams({
model: DEFAULT_MODEL3D_MODEL_VERSION,
tier: 'geometry-only',
quad: false,
smartLowPoly: false,
generateParts: true,
});
expect(result.ok).toBe(true);
expect(result.ok && result.params).toMatchObject({
texture: false,
textureQuality: null,
geometryQuality: 'standard',
generateParts: true,
pbr: false,
});
});
it('贴图档位不允许分件,这是后端也会拒的组合', () => {
expect(
resolveModel3dGenerationParams({
model: DEFAULT_MODEL3D_MODEL_VERSION,
tier: 'textured-standard',
quad: false,
smartLowPoly: false,
generateParts: true,
}).ok,
).toBe(false);
expect(
resolveModel3dGenerationParams({
model: DEFAULT_MODEL3D_MODEL_VERSION,
tier: 'textured-detailed',
quad: false,
smartLowPoly: false,
generateParts: true,
}).ok,
).toBe(false);
});
it('分件不允许与方形布线 / 智能低多边形同时开,这是后端也会拒的组合', () => {
const conflictingSwitches: Array<{ quad: boolean; smartLowPoly: boolean }> =
[
{ quad: true, smartLowPoly: false },
{ quad: false, smartLowPoly: true },
];
for (const switches of conflictingSwitches) {
expect(
resolveModel3dGenerationParams({
model: DEFAULT_MODEL3D_MODEL_VERSION,
tier: 'geometry-only',
quad: switches.quad,
smartLowPoly: switches.smartLowPoly,
generateParts: true,
}),
).toEqual({
ok: false,
reason: '分件生成不能与方形布线或智能低多边形同时使用',
});
}
});
});
describe('resolveModel3dAddOnKeys', () => {
it('与后端 add-on 判定在全部档位与开关组合上一致', () => {
for (const tier of [
'textured-standard',
'textured-detailed',
'geometry-only',
] as const) {
for (const quad of [false, true]) {
for (const smartLowPoly of [false, true]) {
for (const generateParts of [false, true]) {
const result = resolveModel3dGenerationParams({
model: DEFAULT_MODEL3D_MODEL_VERSION,
tier,
quad,
smartLowPoly,
generateParts,
});
if (!result.ok) {
continue;
}
expect(resolveModel3dAddOnKeys(result.params)).toEqual(
expectedAddOns({
texture: result.params.texture,
textureQuality: result.params.textureQuality ?? 'standard',
geometryQuality: result.params.geometryQuality,
quad,
smartLowPoly,
generateParts,
}),
);
}
}
}
}
});
});
const MULTI_VERSION_PRICING: Model3dPricingConfig = {
basePrices: {
'text-to-model': {
'v3.1-20260211': { noTexture: 100, texture: 200 },
'v2.5-20250123': { noTexture: 60, texture: 120 },
'P2-20260801': { noTexture: 80, texture: 160 },
},
'image-to-model': {
'v3.1-20260211': { noTexture: 150, texture: 250 },
'P2-20260801': { noTexture: 170, texture: 270 },
},
},
addOnPrices: {
hdTexture: 10,
ultraTexture: 20,
hdGeometry: 30,
quadMesh: 40,
smartLowPoly: 50,
generateParts: 60,
},
};
describe('模型版本', () => {
it('缺字段与非法取值都回落到默认版本', () => {
expect(resolveModel3dModelVersion(undefined)).toBe(
DEFAULT_MODEL3D_MODEL_VERSION,
);
expect(normalizeModel3dModelVersion(' P2-20260801 ')).toBe('P2-20260801');
// 老快照里的退役版本不发给服务端。
expect(normalizeModel3dModelVersion('v1.0-20190101')).toBe(
DEFAULT_MODEL3D_MODEL_VERSION,
);
expect(normalizeModel3dModelVersion(null)).toBe(
DEFAULT_MODEL3D_MODEL_VERSION,
);
// 原型链上的键(`in` 会命中)不是契约版本,同样按默认版本处理。
for (const inheritedKey of ['toString', 'constructor', '__proto__']) {
expect(normalizeModel3dModelVersion(inheritedKey)).toBe(
DEFAULT_MODEL3D_MODEL_VERSION,
);
}
});
it('可选项只给当前端点有底价的版本,顺序按契约声明', () => {
expect(
resolveModel3dModelOptions({
pricing: MULTI_VERSION_PRICING,
endpoint: 'text-to-model',
}),
).toEqual([
{ value: DEFAULT_MODEL3D_MODEL_VERSION, label: 'v3.1' },
{ value: 'v2.5-20250123', label: 'v2.5' },
{ value: 'P2-20260801', label: 'P2' },
]);
expect(
resolveModel3dModelOptions({
pricing: MULTI_VERSION_PRICING,
endpoint: 'image-to-model',
}),
).toEqual([
{ value: DEFAULT_MODEL3D_MODEL_VERSION, label: 'v3.1' },
{ value: 'P2-20260801', label: 'P2' },
]);
expect(
resolveModel3dModelOptions({ pricing: null, endpoint: 'text-to-model' }),
).toEqual([]);
});
it('报价按对话框选中的版本取底价', () => {
expect(
resolveModel3dGenerationQuote(
createDialog({ model3dModel: 'P2-20260801' }),
MULTI_VERSION_PRICING,
),
).toEqual({ ok: true, price: 160, endpoint: 'text-to-model' });
// 定价段里没有这个版本 → 不可提交,不回落默认版本的价。
expect(
resolveModel3dGenerationQuote(
createDialog({ model3dModel: 'P1-20260311' }),
MULTI_VERSION_PRICING,
).ok,
).toBe(false);
});
it('v2.5 的贴图档位不带 textureQuality,其它版本照常给出', () => {
expect(
resolveModel3dGenerationParams({
model: 'v2.5-20250123',
tier: 'textured-standard',
quad: false,
smartLowPoly: false,
generateParts: false,
}),
).toMatchObject({
ok: true,
params: { texture: true, textureQuality: null },
});
expect(
resolveModel3dGenerationParams({
model: DEFAULT_MODEL3D_MODEL_VERSION,
tier: 'textured-standard',
quad: false,
smartLowPoly: false,
generateParts: false,
}),
).toMatchObject({
ok: true,
params: { texture: true, textureQuality: 'standard' },
});
});
it('版本不支持的能力组合直接判非法,不留给服务端拒', () => {
expect(
resolveModel3dGenerationParams({
model: 'P1-20260311',
tier: 'textured-detailed',
quad: false,
smartLowPoly: false,
generateParts: false,
}),
).toEqual({ ok: false, reason: '高清几何只支持 v3.1 / v3.0' });
expect(
resolveModel3dGenerationParams({
model: 'P2-20260801',
tier: 'textured-standard',
quad: false,
smartLowPoly: true,
generateParts: false,
}),
).toEqual({ ok: false, reason: '智能低多边形只支持 v3.1 / v3.0' });
expect(
resolveModel3dGenerationParams({
model: 'v2.5-20250123',
tier: 'geometry-only',
quad: true,
smartLowPoly: false,
generateParts: false,
}),
).toEqual({ ok: false, reason: '方形布线只支持 v3.1 / v3.0 / P2' });
});
it('换版本时收敛档位与开关并换代次', () => {
const detailed = createDialog({
model3dTier: 'textured-detailed',
model3dQuad: true,
model3dSmartLowPoly: true,
});
const p2 = applyModel3dModelChange(detailed, 'P2-20260801');
expect(p2.model3dModel).toBe('P2-20260801');
// 高清贴图只 v3.x 有,降到标准贴图而不是直接掉到纯几何。
expect(p2.model3dTier).toBe('textured-standard');
expect(p2.model3dQuad).toBe(true);
expect(p2.model3dSmartLowPoly).toBe(false);
expect(p2.model3dAttemptNonce).not.toBe(detailed.model3dAttemptNonce);
const v25 = applyModel3dModelChange(detailed, 'v2.5-20250123');
expect(v25.model3dTier).toBe('textured-standard');
expect(v25.model3dQuad).toBe(false);
expect(v25.model3dSmartLowPoly).toBe(false);
expect(v25.model3dGenerateParts).toBe(false);
});
it('换版本清掉失败态,切档位也按当前版本收敛', () => {
const failed = createDialog({
status: 'failed',
errorMessage: '3D 模型生成失败,请稍后重试。',
model3dModel: 'P2-20260801',
});
expect(applyModel3dModelChange(failed, 'P2-20260801')).toMatchObject({
status: 'idle',
errorMessage: undefined,
});
// P2 没有高清贴图档位:切过去只能落在标准贴图。
expect(
applyModel3dTierChange(failed, 'textured-detailed').model3dTier,
).toBe('textured-standard');
// 纯几何 + 分件在 v3.x 上仍然成立。
expect(
applyModel3dTierChange(
createDialog({ model3dGenerateParts: true }),
'geometry-only',
).model3dGenerateParts,
).toBe(true);
});
});
describe('resolveModel3dPrice', () => {
const paramsOf = (
tier: 'textured-standard' | 'textured-detailed' | 'geometry-only',
switches: {
quad?: boolean;
smartLowPoly?: boolean;
generateParts?: boolean;
} = {},
) => {
const result = resolveModel3dGenerationParams({
model: DEFAULT_MODEL3D_MODEL_VERSION,
tier,
quad: switches.quad ?? false,
smartLowPoly: switches.smartLowPoly ?? false,
generateParts: switches.generateParts ?? false,
});
if (!result.ok) {
throw new Error('档位组合必须合法');
}
return result.params;
};
it('底价按端点与是否有贴图取,add-on 逐项相加', () => {
expect(
resolveModel3dPrice({
pricing: PRICING,
endpoint: 'image-to-model',
modelVersion: 'v3.1-20260211',
params: paramsOf('textured-standard'),
}),
).toEqual({ ok: true, price: 250 });
expect(
resolveModel3dPrice({
pricing: PRICING,
endpoint: 'text-to-model',
modelVersion: 'v3.1-20260211',
params: paramsOf('geometry-only'),
}),
).toEqual({ ok: true, price: 100 });
});
it('缺段、缺底价、缺 add-on 一律不可提交,不按 0 放行', () => {
expect(
resolveModel3dPrice({
pricing: null,
endpoint: 'text-to-model',
params: paramsOf('textured-standard'),
modelVersion: 'v3.1-20260211',
}),
).toEqual({ ok: false, reason: MODEL3D_PRICING_UNAVAILABLE_REASON });
expect(
resolveModel3dPrice({
pricing: { ...PRICING, basePrices: null },
endpoint: 'text-to-model',
modelVersion: 'v3.1-20260211',
params: paramsOf('textured-standard'),
}).ok,
).toBe(false);
expect(
resolveModel3dPrice({
// 纯几何 + 分件只叠 generateParts 一个 add-on,缺的就是它。
pricing: { ...PRICING, addOnPrices: { quadMesh: 40 } },
endpoint: 'text-to-model',
modelVersion: 'v3.1-20260211',
params: paramsOf('geometry-only', { generateParts: true }),
}),
).toEqual({ ok: false, reason: '3D 模型定价缺少 generateParts 价格' });
expect(
resolveModel3dPrice({
pricing: PRICING,
endpoint: 'text-to-model',
modelVersion: 'v3.0-20250812',
params: paramsOf('textured-standard'),
}).ok,
).toBe(false);
});
});
describe('resolveModel3dGenerationQuote', () => {
it('文生与图生各取自己的端点价格', () => {
expect(resolveModel3dGenerationQuote(createDialog(), PRICING)).toEqual({
ok: true,
price: 200,
endpoint: 'text-to-model',
});
expect(
resolveModel3dGenerationQuote(
createDialog({ mode: 'model3d-image-to-model' }),
PRICING,
),
).toEqual({ ok: true, price: 250, endpoint: 'image-to-model' });
});
it('定价段缺失时两个子项都拿不到价格', () => {
for (const mode of [
'model3d-text-to-model',
'model3d-image-to-model',
] as Model3dGenerationMode[]) {
expect(
resolveModel3dGenerationQuote(createDialog({ mode }), null),
).toEqual({ ok: false, reason: MODEL3D_PRICING_UNAVAILABLE_REASON });
}
});
});
describe('3D 生成面板的参数变更', () => {
it('换档位时贴图档位强制关掉分件,并换代次', () => {
const geometryDialog = createDialog({
model3dTier: 'geometry-only',
model3dGenerateParts: true,
});
const textured = applyModel3dTierChange(
geometryDialog,
'textured-detailed',
);
expect(textured.model3dGenerateParts).toBe(false);
expect(textured.model3dAttemptNonce).not.toBe(
geometryDialog.model3dAttemptNonce,
);
const back = applyModel3dTierChange(textured, 'geometry-only');
expect(back.model3dTier).toBe('geometry-only');
expect(back.model3dAttemptNonce).not.toBe(textured.model3dAttemptNonce);
});
it('改开关、改提示词、主动重试都会换代次,并清掉失败态', () => {
const failedDialog = createDialog({
status: 'failed',
errorMessage: '3D 模型生成失败,请稍后重试。',
});
expect(
applyModel3dSwitchChange(failedDialog, { model3dQuad: true }),
).toMatchObject({ model3dQuad: true, status: 'idle' });
expect(
applyModel3dSwitchChange(failedDialog, { model3dQuad: true })
.model3dAttemptNonce,
).not.toBe(failedDialog.model3dAttemptNonce);
expect(applyModel3dPromptChange(failedDialog, '一把木椅')).toMatchObject({
prompt: '一把木椅',
status: 'idle',
errorMessage: undefined,
});
expect(
applyModel3dPromptChange(failedDialog, '一把木椅').model3dAttemptNonce,
).not.toBe(failedDialog.model3dAttemptNonce);
expect(refreshModel3dAttemptNonce(failedDialog)).toMatchObject({
status: 'idle',
errorMessage: undefined,
});
expect(
refreshModel3dAttemptNonce(failedDialog).model3dAttemptNonce,
).not.toBe(failedDialog.model3dAttemptNonce);
});
it('删掉参考图回到空槽位并清掉失败态', () => {
const dialog = createDialog({
mode: 'model3d-image-to-model',
status: 'failed',
errorMessage: '3D 模型生成失败,请稍后重试。',
generationReferences: [
{ id: 'reference-1', label: '参考图', src: 'data:image/png;base64,1' },
{ id: 'reference-2', label: '参考图', src: 'data:image/png;base64,2' },
],
});
const next = applyModel3dReferenceRemoval(dialog, 'reference-1');
expect(next.generationReferences?.map((reference) => reference.id)).toEqual(
['reference-2'],
);
expect(next).toMatchObject({ status: 'idle', errorMessage: undefined });
expect(
applyModel3dReferenceRemoval(next, 'reference-2').generationReferences,
).toEqual([]);
});
it('每次铸造的代次互不相同', () => {
const nonces = new Set(
Array.from({ length: 20 }, () => mintModel3dAttemptNonce()),
);
expect(nonces.size).toBe(20);
for (const nonce of nonces) {
expect(nonce).toMatch(/^model3d-[0-9a-zA-Z-]+$/);
}
});
});