倍率问题和图片问题调整
This commit is contained in:
1 parent
f1993eb388
commit
3f29ba716a
20 files changed
+1210
-24
No files matched your search
@@ -0,0 +1,78 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_BILLING_PRICE_RULES, ensureDefaultBillingPriceRules } from "@/lib/server/billing-catalog";
|
||||
import { quoteGenerationCharge } from "@/lib/server/billing-service";
|
||||
import { createBillingPriceRule, getBillingPriceRule, updateBillingPriceTierMultiplier } from "@/lib/server/billing-store";
|
||||
|
||||
let runtimeDir = "";
|
||||
|
||||
describe("default billing catalog refresh", () => {
|
||||
beforeEach(async () => {
|
||||
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-billing-seed-"));
|
||||
vi.stubEnv("ZHINIAN_RUNTIME_DIR", runtimeDir);
|
||||
vi.stubEnv("ZHINIAN_DATA_BACKEND", "local");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await rm(runtimeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("keeps a saved tier multiplier across catalog refresh and quotation", async () => {
|
||||
await ensureDefaultBillingPriceRules();
|
||||
await updateBillingPriceTierMultiplier({
|
||||
ruleId: "base-seedream-5-0-pro",
|
||||
dimensionKey: "size",
|
||||
tierValue: "1.5K",
|
||||
markupMultiplier: 1.3
|
||||
});
|
||||
|
||||
const quote = await quoteGenerationCharge({
|
||||
provider: "seedream",
|
||||
capability: "image.generate",
|
||||
reqKey: "doubao-seedream-5-0-pro-260628",
|
||||
requestPayload: { settings: { size: "1.5K" } },
|
||||
usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" }
|
||||
});
|
||||
expect(quote).toMatchObject({ standardUnitPriceFen: 30, markupMultiplier: 1.3, amountFen: 39 });
|
||||
|
||||
const saved = await getBillingPriceRule("base-seedream-5-0-pro");
|
||||
const tiers = saved?.parameterDimensions?.find((dimension) => dimension.key === "size")?.tiers;
|
||||
expect(tiers?.find((tier) => tier.value === "1.5K")?.markupMultiplier).toBe(1.3);
|
||||
expect(tiers?.find((tier) => tier.value === "2K")?.markupMultiplier).toBe(1.2);
|
||||
});
|
||||
|
||||
it("takes current default definitions while retaining valid matching tier markups", async () => {
|
||||
const candidate = DEFAULT_BILLING_PRICE_RULES.find((rule) => rule.id === "base-seedream-5-0-pro")!;
|
||||
const size = candidate.parameterDimensions!.find((dimension) => dimension.key === "size")!;
|
||||
await createBillingPriceRule({
|
||||
...candidate,
|
||||
standardUnitPriceFen: 5,
|
||||
markupMultiplier: 1.7,
|
||||
parameterDimensions: [{
|
||||
...size,
|
||||
label: "旧分辨率",
|
||||
tiers: [
|
||||
{ ...size.tiers[0], standardFactor: 99, markupMultiplier: 0 },
|
||||
{ ...size.tiers[1], standardFactor: 99, markupMultiplier: 1.4 },
|
||||
{ value: "3K", label: "旧档位", standardFactor: 9, markupMultiplier: 3, enabled: true }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
await ensureDefaultBillingPriceRules();
|
||||
const saved = await getBillingPriceRule(candidate.id!);
|
||||
expect(saved?.standardUnitPriceFen).toBe(30);
|
||||
expect(saved?.markupMultiplier).toBe(1.7);
|
||||
const dimension = saved?.parameterDimensions?.find((item) => item.key === "size");
|
||||
expect(dimension?.label).toBe("分辨率");
|
||||
expect(dimension?.tiers.map((tier) => tier.value)).toEqual(["1K", "1.5K", "2K"]);
|
||||
expect(dimension?.tiers.map((tier) => [tier.standardFactor, tier.markupMultiplier])).toEqual([
|
||||
[1, 1.2],
|
||||
[1, 1.4],
|
||||
[2, 1.2]
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PriceCatalog } from "@/components/billing-manager";
|
||||
import { groupedPriceRuleSections, priceTierEditTarget } from "@/lib/client/billing-price-catalog";
|
||||
import type { BillingParameterDimension, BillingPriceRule } from "@/lib/types";
|
||||
|
||||
const size: BillingParameterDimension = {
|
||||
key: "size",
|
||||
label: "分辨率",
|
||||
baselineValue: "1.5K",
|
||||
tiers: [
|
||||
{ value: "1.5K", label: "1.5K", standardFactor: 1, markupMultiplier: 1.2, enabled: true },
|
||||
{ value: "2K", label: "2K", standardFactor: 2, markupMultiplier: 1.3, enabled: true }
|
||||
]
|
||||
};
|
||||
|
||||
function rule(id: string, overrides: Partial<BillingPriceRule> = {}): BillingPriceRule {
|
||||
return {
|
||||
id,
|
||||
provider: "seedream",
|
||||
capability: "image.generate",
|
||||
reqKey: "doubao-seedream-5-0-pro-260628",
|
||||
unit: "image",
|
||||
standardUnitPriceFen: 30,
|
||||
markupMultiplier: 1.2,
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("billing price catalog grouped rules", () => {
|
||||
it("keeps separate Seedream businesses and their resolution edit targets", () => {
|
||||
const basic = rule("basic", { parameterDimensions: [size] });
|
||||
const layers = rule("layers", {
|
||||
standardUnitPriceFen: 15,
|
||||
conditions: { layerDecomposition: true },
|
||||
parameterDimensions: [size]
|
||||
});
|
||||
const sections = groupedPriceRuleSections([basic, layers]);
|
||||
expect(sections.map(({ label, dimensions }) => [label, dimensions[0]?.key])).toEqual([
|
||||
["基础生图", "size"],
|
||||
["图层拆分", "size"]
|
||||
]);
|
||||
expect(priceTierEditTarget(sections[1].dimensions[0], size.tiers[1], sections[1].label)).toEqual({
|
||||
dimensionKey: "size",
|
||||
tierValue: "2K",
|
||||
label: "图层拆分 · 分辨率 · 2K",
|
||||
markupMultiplier: 1.3
|
||||
});
|
||||
|
||||
expect(sections[0].rule.id).toBe("basic");
|
||||
expect(sections[1].rule.id).toBe("layers");
|
||||
expect(sections.map((section) => section.dimensions[0].tiers.map((tier) => tier.value))).toEqual([
|
||||
["1.5K", "2K"],
|
||||
["1.5K", "2K"]
|
||||
]);
|
||||
const html = renderToStaticMarkup(createElement(PriceCatalog, { rules: [basic, layers], onEdit: () => {}, disabled: false }));
|
||||
expect(html).toContain("基础生图 · 分辨率");
|
||||
expect(html).toContain("图层拆分 · 分辨率");
|
||||
expect(html.match(/>调整倍率<\/button>/g)).toHaveLength(4);
|
||||
expect(html).toContain("¥0.15");
|
||||
expect(html).toContain("¥0.30");
|
||||
});
|
||||
|
||||
it("keeps grouped legacy video rules as independent flat rows", () => {
|
||||
const rules = [
|
||||
rule("720p", { provider: "seedance", capability: "video.generate", reqKey: "seedance-2.0", variantKey: "resolution=720p", unit: "video_second", standardUnitPriceFen: 99 }),
|
||||
rule("1080p", { provider: "seedance", capability: "video.generate", reqKey: "seedance-2.0", variantKey: "resolution=1080p", unit: "video_second", standardUnitPriceFen: 248 })
|
||||
];
|
||||
expect(groupedPriceRuleSections(rules).every((section) => section.dimensions.length === 0)).toBe(true);
|
||||
expect(groupedPriceRuleSections(rules).map((section) => section.label)).toEqual(["resolution=720p", "resolution=1080p"]);
|
||||
const html = renderToStaticMarkup(createElement(PriceCatalog, { rules, onEdit: () => {}, disabled: false }));
|
||||
expect(html).toContain("resolution=720p");
|
||||
expect(html).toContain("resolution=1080p");
|
||||
expect(html.match(/>调整倍率<\/button>/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("retains unnamed dimension and tier identifiers in edit labels", () => {
|
||||
const dimension: BillingParameterDimension = {
|
||||
key: "quality",
|
||||
label: "",
|
||||
baselineValue: "medium",
|
||||
tiers: [{ value: "high", label: "", standardFactor: 1, markupMultiplier: 1.25, enabled: true }]
|
||||
};
|
||||
expect(priceTierEditTarget(dimension, dimension.tiers[0])).toEqual({
|
||||
dimensionKey: "quality",
|
||||
tierValue: "high",
|
||||
label: "quality · high",
|
||||
markupMultiplier: 1.25
|
||||
});
|
||||
const single = rule("evolink", {
|
||||
provider: "evolink",
|
||||
reqKey: "gpt-image-2",
|
||||
parameterDimensions: [dimension]
|
||||
});
|
||||
const singleHtml = renderToStaticMarkup(createElement(PriceCatalog, { rules: [single], onEdit: () => {}, disabled: false }));
|
||||
expect(singleHtml).toContain(">quality</strong>");
|
||||
expect(singleHtml).toContain(">high</strong>");
|
||||
expect(singleHtml.match(/>调整倍率<\/button>/g)).toHaveLength(1);
|
||||
|
||||
const mixedHtml = renderToStaticMarkup(createElement(PriceCatalog, {
|
||||
rules: [single, rule("legacy", { provider: "evolink", reqKey: "gpt-image-2", variantKey: "legacy" })],
|
||||
onEdit: () => {},
|
||||
disabled: false
|
||||
}));
|
||||
expect(mixedHtml).toContain("规则 1 · quality</strong>");
|
||||
expect(mixedHtml).toContain("legacy");
|
||||
expect(mixedHtml.match(/>调整倍率<\/button>/g)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { downloadFilesAsZip } from "@/lib/client/zip-download";
|
||||
|
||||
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]);
|
||||
const jpeg = Uint8Array.from([0xff, 0xd8, 0xff, 0xe0, 3, 4]);
|
||||
const webp = Uint8Array.from([0x52, 0x49, 0x46, 0x46, 4, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20, 5]);
|
||||
const gif = Uint8Array.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 6]);
|
||||
const bmp = Uint8Array.from([0x42, 0x4d, 7]);
|
||||
const ico = Uint8Array.from([0, 0, 1, 0, 8]);
|
||||
|
||||
function zipEntries(data: Uint8Array) {
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
const entries: { name: string; bytes: Uint8Array }[] = [];
|
||||
let offset = 0;
|
||||
while (view.getUint32(offset, true) === 0x04034b50) {
|
||||
const size = view.getUint32(offset + 18, true);
|
||||
const nameSize = view.getUint16(offset + 26, true);
|
||||
const extraSize = view.getUint16(offset + 28, true);
|
||||
const nameStart = offset + 30;
|
||||
const bytesStart = nameStart + nameSize + extraSize;
|
||||
entries.push({
|
||||
name: new TextDecoder().decode(data.subarray(nameStart, nameStart + nameSize)),
|
||||
bytes: data.slice(bytesStart, bytesStart + size)
|
||||
});
|
||||
offset = bytesStart + size;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
describe("ZIP layer downloads", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses image signatures for entry extensions and keeps the original bytes", async () => {
|
||||
const files = [
|
||||
{ name: "portrait-face.image", url: "/png" },
|
||||
{ name: "portrait-face.image", url: "/png-copy" },
|
||||
{ name: "sketch.image", url: "/jpeg" },
|
||||
{ name: "original.JPEG", url: "/jpeg-original" },
|
||||
{ name: "another.JPE", url: "/jpeg-jpe" },
|
||||
{ name: "mask.image", url: "/webp" },
|
||||
{ name: "animated.image", url: "/gif" },
|
||||
{ name: "bitmap.image", url: "/bmp" },
|
||||
{ name: "icon.image", url: "/ico" },
|
||||
{ name: "invalid-webp.image", url: "/invalid-webp" },
|
||||
{ name: "unrecognized.image", url: "/unknown" }
|
||||
];
|
||||
const bytesByUrl = new Map([
|
||||
["/png", png], ["/png-copy", png], ["/jpeg", jpeg],
|
||||
["/jpeg-original", jpeg], ["/jpeg-jpe", jpeg], ["/webp", webp],
|
||||
["/gif", gif], ["/bmp", bmp], ["/ico", ico],
|
||||
["/invalid-webp", Uint8Array.from([0x52, 0x49, 0x46, 0x46, 4, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 5])],
|
||||
["/unknown", Uint8Array.from([1, 2, 3])]
|
||||
]);
|
||||
let archive: Blob | undefined;
|
||||
const anchor = { href: "", download: "", click: vi.fn(), remove: vi.fn() };
|
||||
vi.stubGlobal("fetch", vi.fn(async (url: string) => new Response(bytesByUrl.get(url), { status: 200 })));
|
||||
vi.stubGlobal("document", { createElement: () => anchor, body: { appendChild: vi.fn() } });
|
||||
vi.stubGlobal("window", { setTimeout: vi.fn() });
|
||||
vi.stubGlobal("URL", { createObjectURL: (blob: Blob) => { archive = blob; return "blob:layers"; }, revokeObjectURL: vi.fn() });
|
||||
|
||||
await downloadFilesAsZip(files, "layers.zip");
|
||||
|
||||
expect(anchor.download).toBe("layers.zip");
|
||||
expect(anchor.click).toHaveBeenCalledOnce();
|
||||
expect(archive).toBeDefined();
|
||||
const entries = zipEntries(new Uint8Array(await archive!.arrayBuffer()));
|
||||
expect(entries.map((entry) => entry.name)).toEqual([
|
||||
"portrait-face.png", "portrait-face-2.png", "sketch.jpg", "original.JPEG", "another.JPE",
|
||||
"mask.webp", "animated.gif", "bitmap.bmp", "icon.ico", "invalid-webp.image", "unrecognized.image"
|
||||
]);
|
||||
expect(entries.map((entry) => entry.bytes)).toEqual([
|
||||
png, png, jpeg, jpeg, jpeg, webp, gif, bmp, ico,
|
||||
Uint8Array.from([0x52, 0x49, 0x46, 0x46, 4, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 5]),
|
||||
Uint8Array.from([1, 2, 3])
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user