功能增加
This commit is contained in:
15
tests/a4-billing.test.ts
Normal file
15
tests/a4-billing.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeBillingParameters } from "@/lib/server/billing-service";
|
||||
import { DEFAULT_BILLING_PRICE_RULES } from "@/lib/server/billing-catalog";
|
||||
|
||||
describe("A4 billing metadata", () => {
|
||||
it.each([[1680, 2376, "70:99"], [2376, 1680, "99:70"], [848, 1200, "70:99"], [1200, 848, "99:70"]])("recognizes %s × %s without changing the markup", (width, height, aspectRatio) => {
|
||||
const dimensions = { width, height };
|
||||
for (const payload of [dimensions, { input: dimensions }, { settings: dimensions }, { providerPayload: dimensions }]) {
|
||||
expect(normalizeBillingParameters(payload)).toMatchObject({ aspectRatio, size: `${width}*${height}` });
|
||||
}
|
||||
const rule = DEFAULT_BILLING_PRICE_RULES.find((rule) => rule.id === "base-evolink-gpt-image-2")!;
|
||||
const dimension = rule.parameterDimensions!.find((dimension) => dimension.key === "aspectRatio")!;
|
||||
expect(dimension.tiers.find((tier) => tier.value === aspectRatio)).toMatchObject({ standardFactor: 1, markupMultiplier: 1.2, enabled: true });
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,9 @@ describe("create studio template interaction", () => {
|
||||
it("offers the shared 3:4 portrait preset in creation and template settings", async () => {
|
||||
const source = await readFile(createStudioUrl, "utf8");
|
||||
|
||||
expect(source).toContain('{ label: "3:4", width: 1728, height: 2304 }');
|
||||
const presets = await readFile(new URL("../lib/image-size-presets.ts", import.meta.url), "utf8");
|
||||
expect(presets).toContain('{ label: "3:4", width: 1728, height: 2304 }');
|
||||
expect(source).toContain('from "@/lib/image-size-presets"');
|
||||
expect(source.match(/imageSizePresets\.map/g)?.length || 0).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
|
||||
70
tests/data-store-minimax-billing.test.ts
Normal file
70
tests/data-store-minimax-billing.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { queryDatabase } = vi.hoisted(() => ({ queryDatabase: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/server/database", () => ({
|
||||
isPostgresBackend: () => true,
|
||||
queryDatabase,
|
||||
withDatabaseTransaction: vi.fn()
|
||||
}));
|
||||
|
||||
import { getGenerationJob } from "@/lib/server/data-store";
|
||||
|
||||
describe("generation job MiniMax billing mapping", () => {
|
||||
beforeEach(() => queryDatabase.mockReset());
|
||||
|
||||
it("retains MiniMax actual usage when a PostgreSQL job is read", async () => {
|
||||
queryDatabase.mockResolvedValueOnce({
|
||||
rows: [{
|
||||
id: "job-h3",
|
||||
owner_id: "account-1",
|
||||
capability: "video.generate",
|
||||
provider: "minimax",
|
||||
req_key: "MiniMax-H3",
|
||||
status: "succeeded",
|
||||
input_asset_ids: [],
|
||||
input_urls: [],
|
||||
output_asset_ids: ["asset-1"],
|
||||
request_payload: {},
|
||||
billing: {
|
||||
priceRuleId: "base-minimax-h3-2k",
|
||||
provider: "minimax",
|
||||
capability: "video.generate",
|
||||
reqKey: "MiniMax-H3",
|
||||
unit: "video_second",
|
||||
quantity: 5,
|
||||
standardUnitPriceFen: 80,
|
||||
markupMultiplier: 1.2,
|
||||
amountFen: 480,
|
||||
currency: "CNY",
|
||||
status: "charged",
|
||||
settlementStatus: "settled",
|
||||
providerUsage: {
|
||||
model: "MiniMax-H3",
|
||||
resolution: "2K",
|
||||
outputSeconds: 5,
|
||||
inputVideoSeconds: 0,
|
||||
inputImageCount: 1,
|
||||
videoPriceFenPerSecond: 80
|
||||
}
|
||||
},
|
||||
created_at: new Date("2026-09-04T01:00:00.000Z"),
|
||||
updated_at: new Date("2026-09-04T01:05:00.000Z")
|
||||
}]
|
||||
});
|
||||
|
||||
const job = await getGenerationJob("job-h3");
|
||||
|
||||
expect(job?.billing?.providerUsage).toEqual({
|
||||
resolution: "2K",
|
||||
model: "MiniMax-H3",
|
||||
completionTokens: undefined,
|
||||
inputVideo: undefined,
|
||||
tokenPriceFenPerMillion: undefined,
|
||||
outputSeconds: 5,
|
||||
inputVideoSeconds: 0,
|
||||
inputImageCount: 1,
|
||||
videoPriceFenPerSecond: 80
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,9 @@ describe("PostgreSQL readiness contract", () => {
|
||||
]) {
|
||||
expect(source).toContain(`('${table}',`);
|
||||
}
|
||||
expect(source).toContain("billing_price_rules_provider_check");
|
||||
expect(source).toContain("position('seedream'");
|
||||
expect(source).toContain("position('minimax'");
|
||||
expect(source).toContain("claim_generation_jobs(text,integer,integer)");
|
||||
expect(source).toContain("billing_post_wallet_entry(text,text,text,text,text,bigint,text,text,text,jsonb)");
|
||||
});
|
||||
|
||||
42
tests/image-size-presets.test.ts
Normal file
42
tests/image-size-presets.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { imageDimensionsForEngine, imageSizePresets, imageSizePresetFromDimensions } from "@/lib/image-size-presets";
|
||||
import { buildEvolinkImagePayload } from "@/lib/evolink/image-client";
|
||||
import { validateBailianImageSize } from "@/lib/bailian/client";
|
||||
|
||||
describe("A4 generation presets", () => {
|
||||
it("retains the five existing presets unchanged", () => {
|
||||
expect(imageSizePresets.slice(0, 5)).toEqual([
|
||||
{ label: "1:1", width: 2048, height: 2048 },
|
||||
{ label: "4:3", width: 2304, height: 1728 },
|
||||
{ label: "3:4", width: 1728, height: 2304 },
|
||||
{ label: "16:9", width: 2560, height: 1440 },
|
||||
{ label: "9:16", width: 1440, height: 2560 }
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(imageSizePresets.filter((size) => size.paper === "a4"))("round trips $label through template dimensions for every regular engine", (size) => {
|
||||
expect(Math.min(size.width, size.height) / Math.max(size.width, size.height)).toBeCloseTo(210 / 297, 8);
|
||||
for (const engine of ["jimeng", "bailian", "evolink"]) {
|
||||
const dimensions = imageDimensionsForEngine(size, engine);
|
||||
expect(imageSizePresetFromDimensions(dimensions.width, dimensions.height)).toEqual(size);
|
||||
if (engine === "bailian") {
|
||||
expect(() => validateBailianImageSize(dimensions.width, dimensions.height, true)).not.toThrow();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each(imageSizePresets.filter((size) => size.paper === "a4"))("keeps $label on EvoLink's 1K budget rather than sending 4MP custom pixels", (size) => {
|
||||
const dimensions = imageDimensionsForEngine(size, "evolink");
|
||||
expect(dimensions.width % 16).toBe(0);
|
||||
expect(dimensions.height % 16).toBe(0);
|
||||
expect(dimensions.width * dimensions.height).toBeGreaterThanOrEqual(655360);
|
||||
expect(dimensions.width * dimensions.height).toBeLessThanOrEqual(1024 ** 2);
|
||||
expect(Math.min(dimensions.width, dimensions.height) / Math.max(dimensions.width, dimensions.height)).toBeCloseTo(210 / 297, 3);
|
||||
// Both a new frontend payload and an older template's canonical dimensions
|
||||
// are adapted before sending to the upstream.
|
||||
for (const input of [size, dimensions]) {
|
||||
const payload = buildEvolinkImagePayload("image.generate", { prompt: "A4 行程单", ...input }, { baseUrl: "https://api.evolink.ai", model: "gpt-image-2" });
|
||||
expect(payload).toMatchObject({ size: `${dimensions.width}x${dimensions.height}`, resolution: "1K" });
|
||||
}
|
||||
});
|
||||
});
|
||||
11
tests/minimax-billing.test.ts
Normal file
11
tests/minimax-billing.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { calculateMinimaxH3ActualAmountFen, minimaxH3VideoPriceFenPerSecond } from "@/lib/server/minimax-billing";
|
||||
|
||||
describe("MiniMax H3 billing", () => {
|
||||
it("uses official 768P and 2K rates with the platform 1.2 markup", () => {
|
||||
expect(minimaxH3VideoPriceFenPerSecond("768P")).toBe(50);
|
||||
expect(minimaxH3VideoPriceFenPerSecond("2k")).toBe(80);
|
||||
expect(calculateMinimaxH3ActualAmountFen({ resolution: "768P", outputSeconds: 5, inputImageCount: 1, markupMultiplier: 1.2 })).toBe(300);
|
||||
expect(calculateMinimaxH3ActualAmountFen({ resolution: "2K", outputSeconds: 5, inputImageCount: 1, markupMultiplier: 1.2 })).toBe(480);
|
||||
});
|
||||
});
|
||||
50
tests/minimax-client.test.ts
Normal file
50
tests/minimax-client.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildMinimaxH3Payload, createMinimaxH3Task, extractMinimaxH3Usage, queryMinimaxH3Task } from "@/lib/minimax/client";
|
||||
|
||||
describe("MiniMax H3 V2 client", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
delete process.env.MINIMAX_BASE_URL;
|
||||
});
|
||||
|
||||
it("builds text and single first-frame payloads with bounded parameters", () => {
|
||||
expect(buildMinimaxH3Payload({ prompt: "move", materials: [], settings: { duration: 5, resolution: "2K", ratio: "3:4" }, origin: "https://app.test" })).toMatchObject({
|
||||
model: "MiniMax-H3", duration: 5, resolution: "2K", ratio: "3:4", aigc_watermark: false,
|
||||
content: [{ type: "text", text: "move" }]
|
||||
});
|
||||
const image = buildMinimaxH3Payload({
|
||||
prompt: "animate", materials: [{ type: "image", url: "/asset.png", label: "@图片1" }],
|
||||
settings: { duration: 8, resolution: "768p", ratio: "16:9" }, origin: "https://app.test"
|
||||
});
|
||||
expect(image).toMatchObject({
|
||||
resolution: "768P", ratio: "adaptive",
|
||||
content: [
|
||||
{ type: "text", text: "animate" },
|
||||
{ type: "image_url", image_url: { url: "https://app.test/asset.png" }, role: "first_frame" }
|
||||
]
|
||||
});
|
||||
expect(() => buildMinimaxH3Payload({ prompt: "bad", materials: [{ type: "video", url: "/a.mp4" }], settings: {}, origin: "https://app.test" })).toThrow("只上传 1 张图片");
|
||||
});
|
||||
|
||||
it("submits and queries the official asynchronous endpoints", async () => {
|
||||
process.env.MINIMAX_API_KEY = "test-key";
|
||||
process.env.MINIMAX_BASE_URL = "https://minimax.test";
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ task_id: "task-h3" }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ task: { id: "task-h3", status: "succeeded", content: { url: "https://cdn.test/video.mp4" }, usage: { total_seconds: 5, input_seconds: 0, output_seconds: 5, input_image_count: 1 } } }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const payload = buildMinimaxH3Payload({ prompt: "move", materials: [], settings: {}, origin: "https://app.test" });
|
||||
await expect(createMinimaxH3Task(payload)).resolves.toMatchObject({ providerTaskId: "task-h3" });
|
||||
await expect(queryMinimaxH3Task("task-h3")).resolves.toMatchObject({
|
||||
status: "succeeded", resultUrl: "https://cdn.test/video.mp4", usage: { outputSeconds: 5, inputImageCount: 1 }
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe("https://minimax.test/v2/video_generation");
|
||||
expect(String(fetchMock.mock.calls[1][0])).toBe("https://minimax.test/v2/query/video_generation/task-h3");
|
||||
});
|
||||
|
||||
it("extracts billable usage", () => {
|
||||
expect(extractMinimaxH3Usage({ total_seconds: 8, input_seconds: 0, output_seconds: 8, input_image_count: 1 })).toEqual({ totalSeconds: 8, inputSeconds: 0, outputSeconds: 8, inputImageCount: 1 });
|
||||
expect(extractMinimaxH3Usage({ output_seconds: 0 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||
describe("Go provider adapter contract", () => {
|
||||
it("freezes bounded adapters and safe errors", async () => {
|
||||
const contract = JSON.parse(await readFile(new URL("../contracts/providers/http-v1.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(contract.providers)).toEqual(["volcengine-visual", "evolink", "bailian", "seedance", "seedream"]);
|
||||
expect(Object.keys(contract.providers)).toEqual(["volcengine-visual", "evolink", "bailian", "seedance", "seedream", "minimax"]);
|
||||
expect(contract.providers["volcengine-visual"]).toMatchObject({
|
||||
model: "jimeng_seedream46_cvtob",
|
||||
submitAction: "JimengSeedream46CVToBSubmitTask",
|
||||
@@ -36,6 +36,15 @@ describe("Go provider adapter contract", () => {
|
||||
outputFormats: ["png", "jpeg"],
|
||||
optimizeModes: ["standard", "fast"]
|
||||
});
|
||||
expect(contract.providers.minimax).toMatchObject({
|
||||
model: "MiniMax-H3",
|
||||
submit: "/v2/video_generation",
|
||||
query: "/v2/query/video_generation/{id}",
|
||||
modes: ["text-to-video", "first-frame-to-video"],
|
||||
maxMaterials: 1,
|
||||
durationSeconds: { min: 4, max: 15 },
|
||||
resolutions: ["768P", "2K"]
|
||||
});
|
||||
expect(contract.errors).toEqual({ generic: true, secretSafe: true });
|
||||
expect(contract.liveCallsInTests).toBe(false);
|
||||
});
|
||||
|
||||
49
tests/seedream-viewport.test.ts
Normal file
49
tests/seedream-viewport.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canvasAnnotationPoint, constrainCanvasView, fitCanvasImage, FIT_CANVAS_VIEW, zoomCanvasAt } from "@/lib/client/seedream-viewport";
|
||||
import { buildSeedreamInteractivePrompt } from "@/lib/seedream/creation";
|
||||
|
||||
describe("Seedream canvas view geometry", () => {
|
||||
const viewport = { width: 800, height: 500 };
|
||||
|
||||
it.each([{ width: 1000, height: 2000 }, { width: 2000, height: 1000 }])("fits a $width × $height image without stretching or inner scrollbars", (image) => {
|
||||
const fitted = fitCanvasImage(image, viewport);
|
||||
expect(fitted.width).toBeLessThanOrEqual(780);
|
||||
expect(fitted.height).toBeLessThanOrEqual(480);
|
||||
expect(fitted.width / fitted.height).toBeCloseTo(image.width / image.height);
|
||||
});
|
||||
|
||||
it("keeps the cursor over the same image point during zoom", () => {
|
||||
const fitted = fitCanvasImage({ width: 1000, height: 1000 }, viewport);
|
||||
const before = { zoom: 2, x: 30, y: -20 };
|
||||
const anchor = { x: 100, y: 80 };
|
||||
const after = zoomCanvasAt(before, 3, anchor, fitted, viewport);
|
||||
expect((anchor.x - after.x) / after.zoom).toBeCloseTo((anchor.x - before.x) / before.zoom);
|
||||
expect((anchor.y - after.y) / after.zoom).toBeCloseTo((anchor.y - before.y) / before.zoom);
|
||||
});
|
||||
|
||||
it("constrains pan at image edges and restores a centered fit", () => {
|
||||
const fitted = { width: 240, height: 480 };
|
||||
expect(constrainCanvasView({ zoom: 3, x: 900, y: -9999 }, fitted, viewport)).toEqual({ zoom: 3, x: 0, y: -470 });
|
||||
expect(constrainCanvasView(FIT_CANVAS_VIEW, fitted, viewport)).toEqual(FIT_CANVAS_VIEW);
|
||||
expect(constrainCanvasView({ zoom: 99, x: 0, y: 0 }, fitted, viewport).zoom).toBe(8);
|
||||
expect(constrainCanvasView({ zoom: 0.01, x: 0, y: 0 }, fitted, viewport).zoom).toBe(0.25);
|
||||
});
|
||||
|
||||
it("maps zoomed and panned box, point and brush coordinates back to the original image", () => {
|
||||
const normalized = [{ x: 100, y: 200 }, { x: 456, y: 321 }, { x: 700, y: 800 }];
|
||||
for (const bounds of [
|
||||
{ left: 100, top: 50, width: 400, height: 600 },
|
||||
{ left: -720, top: -380, width: 1600, height: 2400 },
|
||||
{ left: 320, top: 120, width: 800, height: 1200 }
|
||||
]) {
|
||||
const points = normalized.map((point) => canvasAnnotationPoint({ x: bounds.left + bounds.width * point.x / 1000, y: bounds.top + bounds.height * point.y / 1000 }, bounds));
|
||||
expect(points).toEqual(normalized);
|
||||
expect(buildSeedreamInteractivePrompt("修改 @标注1", [{ id: "point", index: 1, kind: "point", point: points[1]! }])).toBe("修改 图1<point>456 321</point>");
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps drawing outside the image and ignores an unloaded image", () => {
|
||||
expect(canvasAnnotationPoint({ x: -1, y: 999 }, { left: 0, top: 0, width: 100, height: 100 })).toEqual({ x: 0, y: 1000 });
|
||||
expect(canvasAnnotationPoint({ x: 0, y: 0 }, { left: 0, top: 0, width: 0, height: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,25 @@ describe("Seedream workspace layout contract", () => {
|
||||
expect(viewport).toMatch(/--seedream-canvas-height\s*:\s*clamp\(280px,\s*38vh,\s*460px\)/);
|
||||
expect(viewport).toMatch(/height\s*:\s*var\(--seedream-canvas-height\)/);
|
||||
expect(viewport).toMatch(/flex\s*:\s*0\s+0\s+auto/);
|
||||
expect(image).toMatch(/max-height\s*:\s*calc\(var\(--seedream-canvas-height\)\s*-\s*22px\)/);
|
||||
expect(image).toMatch(/width\s*:\s*100%/);
|
||||
expect(image).toMatch(/height\s*:\s*100%/);
|
||||
expect(workspaceSource).toContain("fitCanvasImage");
|
||||
expect(workspaceSource).toContain("width: fittedImage.width, height: fittedImage.height");
|
||||
});
|
||||
|
||||
it("uses the same editor in native fullscreen and keeps panning inside a clipped viewport", () => {
|
||||
expect(workspaceSource).toContain("workspaceRef.current.requestFullscreen()");
|
||||
expect(workspaceSource).not.toContain("createPortal");
|
||||
expect(cssRule(".seedream-workspace:fullscreen")).toMatch(/overflow\s*:\s*hidden/);
|
||||
expect(cssRule(".seedream-workspace:fullscreen .seedream-canvas-viewport")).toMatch(/flex\s*:\s*1\s+1\s+auto/);
|
||||
expect(workspaceSource).toContain('addEventListener("wheel", handleWheel, { passive: false })');
|
||||
expect(workspaceSource).toContain('removeEventListener("wheel", handleWheel)');
|
||||
});
|
||||
|
||||
it("measures cached images that finish loading before hydration", () => {
|
||||
expect(workspaceSource).toContain("image?.complete && image.naturalWidth > 0");
|
||||
expect(workspaceSource).toContain("ref={imageRef}");
|
||||
expect(workspaceSource).toContain("key: sourceKey, width: image.naturalWidth, height: image.naturalHeight");
|
||||
});
|
||||
|
||||
it("does not render the redundant source picker for single-image layer decomposition", () => {
|
||||
|
||||
Reference in New Issue
Block a user