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 { 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-]+$/); } }); });