import { describe, expect, test } from "bun:test"; import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; import type { ModelProtocolDefinition } from "../src/lib/model-protocols"; import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, validateChannelModelPrices, validateChannelModelProtocol } from "../src/pages/admin/components/channel-model-editor-form"; import { defaultPriceTier } from "../src/pages/admin/components/channel-model-price-tier-form"; const definition = (value: string, capability: ModelProtocolDefinition["capability"], enabled = true): ModelProtocolDefinition => ({ value, capability, enabled, label: value, create: "POST /test", contentType: "application/json", media: "url" }); const protocols = [definition("disabled-text", "text", false), definition("text", "text"), definition("image", "image"), definition("video", "video")]; const protocolsWithAudio = [...protocols, definition("audio", "audio")]; describe("channel model editor drafts", () => { test("new drafts select an enabled protocol and never share price state", () => { const first = initialChannelModelValues(null, protocols); first.priceTiers[0].unitPrice = 42; first.tags.push({ text: "限时特价", color: "purple" }); const second = initialChannelModelValues(null, protocols); expect(second.protocol).toBe("text"); expect(second.priceTiers[0].unitPrice).toBe(0); expect(second.modelKey).toBe(""); expect(second.tags).toEqual([]); }); test("missing catalogs do not invent a protocol", () => { expect(initialChannelModelValues(null, []).protocol).toBeUndefined(); expect(() => validateChannelModelProtocol("text", "text", [])).toThrow("已启用"); }); test("rejects disabled, missing and capability-mismatched protocols", () => { for (const protocol of [undefined, "missing", "disabled-text", "image"]) { expect(() => validateChannelModelProtocol("text", protocol, protocols)).toThrow(); } expect(() => validateChannelModelProtocol("text", "text", protocols)).not.toThrow(); }); test("capability changes clear old selectors but preserve money and billing units", () => { const draft = initialChannelModelValues(null, protocols); const tier = { ...defaultPriceTier("advanced"), billingMode: "per_second" as const, unitPrice: 2.5, operation: "text_to_video", resolution: "1080p", videoSeconds: 5, videoGenerateAudio: "false", imageCount: 2 }; const next = changeChannelModelCapability({ ...draft, capability: "image", protocol: "video", providerModelKey: "gpt-image-2", priceTiers: [tier] }, protocols); expect(next.protocol).toBe("image"); expect(next.capabilityConfig).toEqual(defaultModelCapabilityConfig("image", "gpt-image-2")); expect(next.priceTiers[0]).toMatchObject({ unitPrice: 2.5, billingMode: "per_second", operation: "*", resolution: "*", videoSeconds: 0, videoGenerateAudio: "*", imageCount: 0 }); expect(tier.operation).toBe("text_to_video"); expect(() => validateChannelModelPrices(next)).toThrow("按秒"); }); test("audio does not retain image/video configuration", () => { const draft = initialChannelModelValues(null, protocols); expect(changeChannelModelCapability({ ...draft, capability: "audio" }, protocols).capabilityConfig).toBeUndefined(); }); test("validation errors map to their mounted tab", () => { expect(editorSectionForField(["priceTiers", 2, "unitPrice"])).toBe("pricing"); expect(editorSectionForField(["capabilityConfig"])).toBe("capabilities"); expect(editorSectionForField(["protocol"])).toBe("identity"); }); }); describe("pricing write validation", () => { const draft = initialChannelModelValues(null, protocols); test("accepts an explicit free/default price", () => expect(() => validateChannelModelPrices(draft)).not.toThrow()); test("accepts audio per-second pricing", () => { const audioDraft = { ...initialChannelModelValues(null, protocolsWithAudio), capability: "audio" as const, protocol: "audio", priceTiers: [{ ...defaultPriceTier(), billingMode: "per_second" as const, unitPrice: 1 }], }; expect(() => validateChannelModelPrices(audioDraft)).not.toThrow(); }); test("requires prices and exactly one or zero fallback tiers", () => { expect(() => validateChannelModelPrices({ ...draft, priceTiers: [] })).toThrow("至少"); expect(() => validateChannelModelPrices({ ...draft, priceTiers: [defaultPriceTier(), defaultPriceTier()] })).toThrow("只能"); }); test("rejects empty or stale advanced selectors", () => { expect(() => validateChannelModelPrices({ ...draft, priceTiers: [defaultPriceTier("advanced")] })).toThrow("匹配条件"); expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier("advanced"), operation: "text_to_video" }] })).toThrow("不匹配"); }); test("rejects invalid active prices rather than silently sending zero", () => { for (const unitPrice of [NaN, Infinity, -1, 1000001, null, undefined]) { expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier(), unitPrice: unitPrice as number }] })).toThrow("有效数值"); } }); test("saves video Token prices with newapi-channel-2 and other video protocols without changing billing units", () => { for (const protocol of ["newapi-channel-2", "volcengine-jimeng-video", "video"]) { const priceTiers = [{ ...defaultPriceTier(), billingMode: "token" as const, outputTokenPrice: 8 }]; expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol, priceTiers })).not.toThrow(); expect(priceTiers[0].billingMode).toBe("token"); expect(priceTiers[0].outputTokenPrice).toBe(8); } }); test("rejects Token pricing after switching to image or audio", () => { for (const capability of ["image", "audio"] as const) { expect(() => validateChannelModelPrices({ ...draft, capability, priceTiers: [{ ...defaultPriceTier(), billingMode: "token", outputTokenPrice: 8 }] })).toThrow("不支持 Token"); } }); test("validates all text Token prices and accepts free video Token pricing", () => { expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier(), billingMode: "token", inputTokenPrice: NaN }] })).toThrow(); const video = { ...draft, capability: "video" as const, protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier(), billingMode: "token" as const }] }; expect(() => validateChannelModelPrices(video)).not.toThrow(); video.priceTiers[0].outputTokenPrice = 0.000001; expect(() => validateChannelModelPrices(video)).not.toThrow(); }); test("rejects removed video conditions and accepts visible operation or resolution matches", () => { for (const hiddenCondition of [{ videoGenerateAudio: "false" }, { videoSeconds: 5 }, { imageCount: 2 }]) { expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), billingMode: "token", ...hiddenCondition }] })).toThrow("匹配条件"); } expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), operation: "image_to_video" }] })).not.toThrow(); expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), resolution: "1080p" }] })).not.toThrow(); }); }); test("admin lazy routes share a persistent boundary instead of replaying page loaders", async () => { const router = await Bun.file(new URL("../src/router.tsx", import.meta.url)).text(); const shell = await Bun.file(new URL("../src/pages/admin/components/admin-shell.tsx", import.meta.url)).text(); const pages = await Bun.file(new URL("../src/pages/admin/admin-route-pages.tsx", import.meta.url)).text(); const css = await Bun.file(new URL("../src/styles/admin-ui.css", import.meta.url)).text(); const admin = router.slice(router.indexOf('path: "/admin"'), router.indexOf('{ path: "*"')); expect(admin.match(/deferred\(/g)).toHaveLength(1); expect(shell).toMatch(/\s*<\/Suspense>/); expect(pages).not.toContain("