Files
ttstd-infinite-canvas/web/test/channel-model-pricing.test.ts

126 lines
7.1 KiB
TypeScript

import { expect, test } from "bun:test";
import { formatModelMargin, previewModelRepricing, modelRepriceRows, modelRepricePayload, parseSaleInput, saleInputValue } from "../src/pages/admin/components/channel-model-pricing";
import type { ChannelModel } from "../src/services/api/wallet";
const item = {
id: "m",
displayName: "Model",
capability: "text",
priceTiers: [
{
id: "t",
selectorKey: "{}",
enabled: true,
priceConfigured: true,
billingMode: "fixed_request",
unitPriceMicrocredits: 9,
inputTokenPriceMicrocredits: 0,
outputTokenPriceMicrocredits: 0,
cachedTokenPriceMicrocredits: 0,
costPricing: { configured: true, unitPriceMicrocredits: 1_000_000, inputTokenPriceMicrocredits: 2_000_000, outputTokenPriceMicrocredits: 4_000_000, cachedTokenPriceMicrocredits: 0 },
},
],
} as ChannelModel;
test("20% margin uses cost / 0.8, not markup, and leaves inputs unchanged", () => {
const result = previewModelRepricing([item], 20);
expect(result[0]!.priceTiers[0]!.unitPriceMicrocredits).toBe(1_250_000);
expect(item.priceTiers[0]!.unitPriceMicrocredits).toBe(9);
expect(result[0]!.priceTiers[0]!.costPricing).toEqual(item.priceTiers[0]!.costPricing);
});
test("token prices are independent and zero costs remain free", () => {
const token = { ...item, priceTiers: [{ ...item.priceTiers[0]!, billingMode: "token" as const }] };
const tier = previewModelRepricing([token], 20)[0]!.priceTiers[0]!;
expect(tier.inputTokenPriceMicrocredits).toBe(2_500_000);
expect(tier.outputTokenPriceMicrocredits).toBe(5_000_000);
expect(tier.cachedTokenPriceMicrocredits).toBe(0);
expect(formatModelMargin(0, 0)).toBe("—");
});
test("video token adjusts only output; per-second adjusts unit price", () => {
const video = { ...item, capability: "video" as const, priceTiers: [{ ...item.priceTiers[0]!, billingMode: "token" as const }] };
const tier = previewModelRepricing([video], 20)[0]!.priceTiers[0]!;
expect(tier.outputTokenPriceMicrocredits).toBe(5_000_000);
expect(tier.inputTokenPriceMicrocredits).toBe(0);
video.priceTiers[0]!.billingMode = "per_second";
expect(previewModelRepricing([video], 20)[0]!.priceTiers[0]!.unitPriceMicrocredits).toBe(1_250_000);
});
test("half-up rounding at microcredit precision and disabled tiers stay untouched", () => {
const tiny = {
...item,
priceTiers: [
{ ...item.priceTiers[0]!, costPricing: { ...item.priceTiers[0]!.costPricing!, unitPriceMicrocredits: 1 } },
{ ...item.priceTiers[0]!, id: "disabled", enabled: false },
],
};
const result = previewModelRepricing([tiny], 20, 6)[0]!;
expect(result.priceTiers[0]!.unitPriceMicrocredits).toBe(1);
expect(result.priceTiers[1]).toBe(tiny.priceTiers[1]);
expect(previewModelRepricing([tiny], 0, 6)[0]!.priceTiers[0]!.unitPriceMicrocredits).toBe(1);
});
test("selected decimals round half up exactly, including binary floating point ties", () => {
for (const [cost, places, expected] of [
[1_005_000, 2, 1_010_000],
[1_004_999, 2, 1_000_000],
[1_500_000, 0, 2_000_000],
[1_234_500, 3, 1_235_000],
[5_000, 2, 10_000],
[4_999, 2, 0],
]) {
const input = { ...item, priceTiers: [{ ...item.priceTiers[0]!, costPricing: { ...item.priceTiers[0]!.costPricing!, unitPriceMicrocredits: cost! } }] };
expect(previewModelRepricing([input], 0, places)[0]!.priceTiers[0]!.unitPriceMicrocredits).toBe(expected);
}
for (const places of [-1, 7, 1.5]) expect(() => previewModelRepricing([item], 20, places)).toThrow();
});
test("rows are present before a percentage is set and final manual prices survive payload creation", () => {
const input = { ...item, priceVersion: 4, priceTiers: [{ ...item.priceTiers[0]!, priceVersion: 8 }] };
const initial = modelRepriceRows([input]);
expect(initial).toHaveLength(1);
expect(initial[0]!.sale).toBe("0.000009");
const recalculated = modelRepriceRows(previewModelRepricing([input], 20));
expect(recalculated[0]!.sale).toBe("1.25");
recalculated[0]!.sale = "1.37";
recalculated[0]!.manual = true;
expect(modelRepricePayload([input], recalculated)).toEqual([{ modelId: "m", priceVersion: 4, priceTiers: [{ id: "t", priceVersion: 8, prices: { unitPriceMicrocredits: 1_370_000 } }] }]);
expect(modelRepriceRows(previewModelRepricing([input], 50, 2))[0]!.sale).toBe("2");
expect(modelRepriceRows(previewModelRepricing([input], 20, 0))[0]!.sale).toBe("1");
});
test("manual token edits affect only their own field and invalid input never silently becomes zero", () => {
const token = { ...item, priceTiers: [{ ...item.priceTiers[0]!, billingMode: "token" as const }] };
const rows = modelRepriceRows(previewModelRepricing([token], 20));
rows[1]!.sale = "7.123456";
const prices = modelRepricePayload([token], rows)[0]!.priceTiers[0]!.prices;
expect(prices).toEqual({ inputTokenPriceMicrocredits: 2_500_000, outputTokenPriceMicrocredits: 7_123_456, cachedTokenPriceMicrocredits: 0 });
for (const invalid of [null, "", "-1", "1.1234567", "1e3", "NaN", "9007199254.740992"]) expect(() => parseSaleInput(invalid)).toThrow();
for (const value of [0, 1, 10_000_000, Number.MAX_SAFE_INTEGER]) expect(parseSaleInput(saleInputValue(value))).toBe(value);
rows[1]!.sale = null;
expect(() => modelRepricePayload([token], rows)).toThrow();
});
test("reject invalid rates, incomplete costs, invalid amounts and overflow", () => {
for (const margin of [null, NaN, Infinity, -1, 100, 20.001]) expect(() => previewModelRepricing([item], margin)).toThrow();
for (const cost of [undefined, NaN, -1, 1_000_000_000_001]) {
const invalid = { ...item, priceTiers: [{ ...item.priceTiers[0]!, costPricing: { ...item.priceTiers[0]!.costPricing!, unitPriceMicrocredits: cost as number } }] };
expect(() => previewModelRepricing([item, invalid], 20)).toThrow();
}
expect(() => previewModelRepricing([{ ...item, priceTiers: [] }], 20)).toThrow();
expect(() => previewModelRepricing([{ ...item, priceTiers: [{ ...item.priceTiers[0]!, costPricing: undefined }] }], 20)).toThrow();
expect(() => previewModelRepricing([{ ...item, priceTiers: [{ ...item.priceTiers[0]!, priceConfigured: false }] }], 20)).toThrow();
const huge = { ...item, priceTiers: [{ ...item.priceTiers[0]!, costPricing: { ...item.priceTiers[0]!.costPricing!, unitPriceMicrocredits: 1_000_000_000_000 } }] };
expect(() => previewModelRepricing([huge], 99.99)).toThrow();
});
test("manager preserves cross-page selection and API uses one atomic request", async () => {
const source = await Bun.file(new URL("../src/pages/admin/components/channel-model-manager.tsx", import.meta.url)).text();
const api = await Bun.file(new URL("../src/services/api/wallet.ts", import.meta.url)).text();
expect(source).toContain("preserveSelectedRowKeys: true");
expect(source).toContain("items.filter((item) => selectedModelIds.includes(item.id))");
expect(source).toContain("统一调价");
expect(api).toContain("/models/batch-reprice");
});