merge: official v1.5.4 release
This commit is contained in:
41
web/test/app-theme.test.ts
Normal file
41
web/test/app-theme.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { getAntThemeConfig, getWorkspaceAntThemeConfig } from "../src/lib/app-theme";
|
||||
import { DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition } from "../src/lib/skin-themes";
|
||||
import { getIsolatedAdminAntTheme } from "../src/pages/admin/theme/admin-ant-theme";
|
||||
|
||||
describe("shared action colors and focus feedback", () => {
|
||||
for (const dark of [false, true]) {
|
||||
test(`classic solid tokens remain monochrome beneath the button fill adapter in ${dark ? "dark" : "light"} mode`, () => {
|
||||
const product = getAntThemeConfig(dark, DEFAULT_CLASSIC_SKIN);
|
||||
const admin = getIsolatedAdminAntTheme(dark, DEFAULT_CLASSIC_SKIN);
|
||||
expect(product.components?.Button?.colorPrimary).toBe(dark ? "#f5f5f5" : "#171717");
|
||||
expect(admin.components?.Button?.colorPrimary).toBe(product.components?.Button?.colorPrimary);
|
||||
expect(admin.components?.Button?.primaryColor).toBe(product.components?.Button?.primaryColor);
|
||||
});
|
||||
|
||||
test(`custom primary action states reach product and admin in ${dark ? "dark" : "light"} mode`, () => {
|
||||
const skin = duplicateSkinDefinition(DEFAULT_CLASSIC_SKIN, ["classic"]);
|
||||
Object.assign(skin.tokens[dark ? "dark" : "light"], {
|
||||
primary: "#123456", primaryHover: "#234567", primaryActive: "#345678", primaryForeground: "#abcdef",
|
||||
});
|
||||
for (const theme of [getAntThemeConfig(dark, skin), getIsolatedAdminAntTheme(dark, skin)]) {
|
||||
expect(theme.components?.Button).toMatchObject({
|
||||
colorPrimary: "#123456", colorPrimaryHover: "#234567", colorPrimaryActive: "#345678", primaryColor: "#abcdef",
|
||||
});
|
||||
expect(theme.token?.controlOutlineWidth).toBe(0);
|
||||
expect(theme.components?.Input?.activeShadow).toBe("none");
|
||||
expect(theme.components?.InputNumber?.activeShadow).toBe("none");
|
||||
expect(theme.components?.DatePicker?.activeShadow).toBe("none");
|
||||
expect(theme.components?.Select?.activeOutlineColor).toBe("transparent");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("workspace density overrides inherit action colors and shadow-free focus", () => {
|
||||
const theme = getWorkspaceAntThemeConfig();
|
||||
expect(theme.components?.Button?.colorPrimary).toBeUndefined();
|
||||
expect(theme.components?.Button?.primaryColor).toBeUndefined();
|
||||
expect(theme.components?.Input?.activeShadow).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections } from "@/lib/canvas/canvas-batch-connection";
|
||||
import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy";
|
||||
import { canvasConnectionPath } from "@/components/canvas/canvas-connections";
|
||||
import { defaultConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
@@ -19,6 +20,11 @@ const nodes: CanvasNodeData[] = [
|
||||
const baseConfig = { ...defaultConfig };
|
||||
|
||||
describe("planBatchConnections", () => {
|
||||
it("only accepts image inputs for the batch creation table", () => {
|
||||
expect(canvasConnectionError(baseConfig, nodes, [], { fromNodeId: "image-a", toNodeId: "batch-table" })).toBe("");
|
||||
expect(canvasConnectionError(baseConfig, nodes, [], { fromNodeId: "text-a", toNodeId: "batch-table" })).toContain("批量创作表节点只接受图片输入");
|
||||
});
|
||||
|
||||
it("plans all legal source nodes and preserves the target handle", () => {
|
||||
const result = planBatchConnections({
|
||||
sourceNodeIds: ["text-a", "text-b"],
|
||||
|
||||
@@ -1,95 +1,101 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { batchGridTemplateColumns, batchPromptForRow, batchReferenceHandleAtY, batchReferenceHandleId, batchTextHandleId, batchTextHandleTop, createBatchRowsFromColumns, createBatchRowsFromInputs, CREATIVE_BATCH_PROMPT, moveBatchReferenceCell, reorderBatchReferenceColumns, TRY_ON_BATCH_PROMPT } from "@/lib/canvas/canvas-batch-table";
|
||||
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
describe("batch creation table", () => {
|
||||
test("creates one try-on row per person with the final image as the shared garment", () => {
|
||||
const rows = createBatchRowsFromInputs("try_on", ["person-1", "person-2", "person-3", "garment"]);
|
||||
|
||||
expect(rows.map((row) => row.inputNodeIds)).toEqual([
|
||||
["person-1", "garment"],
|
||||
["person-2", "garment"],
|
||||
["person-3", "garment"],
|
||||
]);
|
||||
expect(rows.every((row) => row.prompt === TRY_ON_BATCH_PROMPT)).toBe(true);
|
||||
});
|
||||
|
||||
test("creates one creative row per connected image", () => {
|
||||
expect(createBatchRowsFromInputs("creative", ["a", "b"]).map((row) => row.inputNodeIds)).toEqual([["a"], ["b"]]);
|
||||
});
|
||||
|
||||
test("broadcasts a singleton reference column across rows", () => {
|
||||
expect(createBatchRowsFromColumns("try_on", [["person-1", "person-2"], ["garment"]]).map((row) => row.inputNodeIds)).toEqual([
|
||||
["person-1", "garment"],
|
||||
["person-2", "garment"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("new canvas node has durable batch defaults", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
|
||||
expect(node.title).toBe("批量创作表");
|
||||
expect(node.metadata?.batchTable).toEqual({ operation: "try_on", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }, { id: "reference-3", label: "参考图 3" }], rows: [] });
|
||||
});
|
||||
test("keeps the third reference handle addressable", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + 112 + 2 * 38)).toBe(batchReferenceHandleId("reference-3"));
|
||||
});
|
||||
test("keeps the second reference handle addressable", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
const referenceColumns = [
|
||||
{ id: "reference-1", label: "参考图 1" },
|
||||
{ id: "reference-2", label: "参考图 2" },
|
||||
];
|
||||
node.metadata = { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, referenceColumns, rows: [] } };
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38)).toBe(batchReferenceHandleId("reference-2"));
|
||||
});
|
||||
|
||||
test("chooses the nearest reference handle when magnetic hit areas overlap", () => {
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
batchPromptForRow,
|
||||
batchReferenceColumns,
|
||||
batchReferenceHandleAtY,
|
||||
batchReferenceHandleId,
|
||||
batchReferenceMentionToken,
|
||||
createBatchRowsFromColumns,
|
||||
createBatchRowsFromInputs,
|
||||
createInheritedBatchRow,
|
||||
moveBatchReferenceCell,
|
||||
promoteLegacyBatchTableSize,
|
||||
removeLastBatchReferenceColumn,
|
||||
reorderBatchReferenceColumns,
|
||||
BATCH_REFERENCE_HANDLE_GAP,
|
||||
BATCH_REFERENCE_HANDLE_TOP,
|
||||
TRY_ON_BATCH_PROMPT,
|
||||
} from "@/lib/canvas/canvas-batch-table";
|
||||
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
describe("batch creation table", () => {
|
||||
test("creates one try-on row per person with the final image as the shared garment", () => {
|
||||
const rows = createBatchRowsFromInputs("try_on", ["person-1", "person-2", "person-3", "garment"]);
|
||||
|
||||
expect(rows.map((row) => row.inputNodeIds)).toEqual([
|
||||
["person-1", "garment"],
|
||||
["person-2", "garment"],
|
||||
["person-3", "garment"],
|
||||
]);
|
||||
expect(rows.every((row) => row.prompt === TRY_ON_BATCH_PROMPT)).toBe(true);
|
||||
});
|
||||
|
||||
test("creates one creative row per connected image", () => {
|
||||
expect(createBatchRowsFromInputs("creative", ["a", "b"]).map((row) => row.inputNodeIds)).toEqual([["a"], ["b"]]);
|
||||
});
|
||||
|
||||
test("broadcasts a singleton reference column across rows", () => {
|
||||
expect(createBatchRowsFromColumns("try_on", [["person-1", "person-2"], ["garment"]]).map((row) => row.inputNodeIds)).toEqual([
|
||||
["person-1", "garment"],
|
||||
["person-2", "garment"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("inherits the previous row references when adding a manual task", () => {
|
||||
const row = createInheritedBatchRow("try_on", [{ id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "自定义提示词" }]);
|
||||
|
||||
expect(row.inputNodeIds).toEqual(["person-1", "garment"]);
|
||||
expect(row.inputNodeIds).not.toBe(createInheritedBatchRow("try_on", [{ id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "自定义提示词" }]).inputNodeIds);
|
||||
expect(row.prompt).toBe(TRY_ON_BATCH_PROMPT);
|
||||
});
|
||||
|
||||
test("syncing connections preserves unmatched manual rows", () => {
|
||||
const manual = { id: "manual-row", enabled: false, inputNodeIds: ["manual-image"], prompt: "保留手工任务" };
|
||||
const rows = createBatchRowsFromColumns("try_on", [["person-1"], ["garment"]], [manual]);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[1]).toEqual(manual);
|
||||
});
|
||||
|
||||
test("syncing unchanged references keeps the row identity and linked output", () => {
|
||||
const existing = { id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "保留提示词", outputNodeId: "output-1" };
|
||||
const [row] = createBatchRowsFromColumns("try_on", [["person-1"], ["garment"]], [existing]);
|
||||
|
||||
expect(row).toEqual(existing);
|
||||
});
|
||||
|
||||
test("uses stable prompt mention tokens for positional references", () => {
|
||||
expect(batchReferenceMentionToken(0)).toBe("@参考图1");
|
||||
expect(batchReferenceMentionToken(1)).toBe("@参考图2");
|
||||
});
|
||||
|
||||
test("new canvas node has durable batch defaults", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38, 90)).toBe(batchReferenceHandleId("reference-2"));
|
||||
expect(node.title).toBe("批量创作表");
|
||||
expect(node.width).toBe(1280);
|
||||
expect(node.height).toBe(560);
|
||||
expect(node.metadata?.batchTable).toEqual({ operation: "try_on", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }, { id: "reference-3", label: "参考图 3" }], rows: [] });
|
||||
});
|
||||
|
||||
test("keeps text handles separated from reference controls", () => {
|
||||
test("promotes the previous 900-wide default without touching resized tables", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
node.metadata = { ...node.metadata, batchTable: { ...node.metadata?.batchTable!, textColumns: [{ id: "text-1", label: "文字 1" }] } };
|
||||
const legacy = { ...node, width: 900, height: 520 };
|
||||
const resized = { ...node, width: 1100, height: 480 };
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + batchTextHandleTop(3))).toBe(batchTextHandleId("text-1"));
|
||||
expect(batchTextHandleTop(3)).toBeGreaterThan(112 + 3 * 38);
|
||||
expect(promoteLegacyBatchTableSize(legacy)).toEqual({ ...legacy, width: 1280, height: 560 });
|
||||
expect(promoteLegacyBatchTableSize(resized)).toBe(resized);
|
||||
});
|
||||
|
||||
test("global prompt overrides a row prompt without changing the row data", () => {
|
||||
test("keeps a single explicitly stored reference column", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
const table = node.metadata?.batchTable!;
|
||||
const row = createBatchRowsFromInputs("creative", ["image-1"])[0];
|
||||
node.metadata = { ...node.metadata, batchTable: { operation: "creative", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }], rows: [] } };
|
||||
|
||||
expect(batchPromptForRow(table, row)).toBe(row.prompt);
|
||||
expect(batchPromptForRow({ ...table, globalPrompt: "统一风格:高级商业摄影" }, row)).toBe("统一风格:高级商业摄影");
|
||||
expect(row.prompt).toBe(CREATIVE_BATCH_PROMPT);
|
||||
expect(batchReferenceColumns(node.metadata.batchTable).map((column) => column.id)).toEqual(["reference-1"]);
|
||||
});
|
||||
|
||||
test("legacy rows without enabled stay enabled by default", () => {
|
||||
const row = createBatchRowsFromInputs("creative", ["image-1"])[0];
|
||||
expect(row.enabled).toBe(true);
|
||||
expect({ ...row, enabled: undefined }.enabled === false).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps the table grid valid when no text columns exist", () => {
|
||||
const withoutText = batchGridTemplateColumns(3, 0);
|
||||
const withText = batchGridTemplateColumns(3, 1);
|
||||
|
||||
expect(withoutText).not.toContain("repeat(0,");
|
||||
expect(withoutText).toContain("68px");
|
||||
expect(withoutText).toContain("repeat(3, 72px)");
|
||||
expect(withText).toContain("repeat(1, minmax(168px, 0.75fr))");
|
||||
});
|
||||
|
||||
test("reorders reference columns and keeps row materials aligned", () => {
|
||||
test("removes the last reference column down to one and trims row inputs", () => {
|
||||
const table = {
|
||||
operation: "creative" as const,
|
||||
concurrency: 10,
|
||||
@@ -98,16 +104,49 @@ describe("batch creation table", () => {
|
||||
{ id: "reference-2", label: "参考图 2" },
|
||||
{ id: "reference-3", label: "参考图 3" },
|
||||
],
|
||||
rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b", "c"], prompt: "" }],
|
||||
rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b", "c"], prompt: "x" }],
|
||||
};
|
||||
const reordered = reorderBatchReferenceColumns(table, "reference-1", "reference-3");
|
||||
|
||||
expect(reordered.referenceColumns?.map((column) => column.label)).toEqual(["参考图 1", "参考图 2", "参考图 3"]);
|
||||
expect(reordered.referenceColumns?.map((column) => column.id)).toEqual(["reference-2", "reference-3", "reference-1"]);
|
||||
expect(reordered.rows[0].inputNodeIds).toEqual(["b", "c", "a"]);
|
||||
const next = removeLastBatchReferenceColumn(table);
|
||||
expect(next?.referenceColumns?.map((column) => column.id)).toEqual(["reference-1", "reference-2"]);
|
||||
expect(next?.rows[0].inputNodeIds).toEqual(["a", "b"]);
|
||||
expect(removeLastBatchReferenceColumn({ ...table, referenceColumns: [{ id: "reference-1", label: "参考图 1" }] })).toBeNull();
|
||||
});
|
||||
|
||||
test("moves a reference image into an empty slot across rows", () => {
|
||||
test("keeps the second reference handle addressable", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
const referenceColumns = [
|
||||
{ id: "reference-1", label: "参考图 1" },
|
||||
{ id: "reference-2", label: "参考图 2" },
|
||||
];
|
||||
node.metadata = { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, referenceColumns, rows: [] } };
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + BATCH_REFERENCE_HANDLE_TOP + BATCH_REFERENCE_HANDLE_GAP)).toBe(batchReferenceHandleId("reference-2"));
|
||||
});
|
||||
|
||||
test("chooses the nearest reference handle when magnetic hit areas overlap", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + BATCH_REFERENCE_HANDLE_TOP + BATCH_REFERENCE_HANDLE_GAP, 90)).toBe(batchReferenceHandleId("reference-2"));
|
||||
});
|
||||
|
||||
test("reorders reference columns and keeps row inputs aligned", () => {
|
||||
const table = {
|
||||
operation: "try_on" as const,
|
||||
concurrency: 10,
|
||||
referenceColumns: [
|
||||
{ id: "reference-1", label: "参考图 1" },
|
||||
{ id: "reference-2", label: "参考图 2" },
|
||||
{ id: "reference-3", label: "参考图 3" },
|
||||
],
|
||||
rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b", "c"], prompt: "x" }],
|
||||
};
|
||||
const next = reorderBatchReferenceColumns(table, "reference-1", "reference-3");
|
||||
expect(next.referenceColumns?.map((column) => column.id)).toEqual(["reference-2", "reference-3", "reference-1"]);
|
||||
expect(next.rows[0].inputNodeIds).toEqual(["b", "c", "a"]);
|
||||
});
|
||||
|
||||
test("swaps reference cells across rows and columns", () => {
|
||||
const table = {
|
||||
operation: "creative" as const,
|
||||
concurrency: 10,
|
||||
@@ -116,26 +155,18 @@ describe("batch creation table", () => {
|
||||
{ id: "reference-2", label: "参考图 2" },
|
||||
],
|
||||
rows: [
|
||||
{ id: "row-1", enabled: true, inputNodeIds: ["a", ""], prompt: "" },
|
||||
{ id: "row-2", enabled: true, inputNodeIds: ["b", ""], prompt: "" },
|
||||
{ id: "row-1", enabled: true, inputNodeIds: ["a", "b"], prompt: "one" },
|
||||
{ id: "row-2", enabled: true, inputNodeIds: ["c", "d"], prompt: "two" },
|
||||
],
|
||||
};
|
||||
|
||||
const moved = moveBatchReferenceCell(table, "row-1", 0, "row-2", 1);
|
||||
|
||||
expect(moved.rows.map((row) => row.inputNodeIds)).toEqual([["", ""], ["b", "a"]]);
|
||||
const next = moveBatchReferenceCell(table, "row-1", 0, "row-2", 1);
|
||||
expect(next.rows[0].inputNodeIds).toEqual(["d", "b"]);
|
||||
expect(next.rows[1].inputNodeIds).toEqual(["c", "a"]);
|
||||
});
|
||||
|
||||
test("swaps reference images when the target slot is occupied", () => {
|
||||
const table = {
|
||||
operation: "creative" as const,
|
||||
concurrency: 10,
|
||||
referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }],
|
||||
rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b"], prompt: "" }, { id: "row-2", enabled: true, inputNodeIds: ["c", "d"], prompt: "" }],
|
||||
};
|
||||
|
||||
const swapped = moveBatchReferenceCell(table, "row-1", 0, "row-2", 1);
|
||||
|
||||
expect(swapped.rows.map((row) => row.inputNodeIds)).toEqual([["d", "b"], ["c", "a"]]);
|
||||
test("uses a non-empty global prompt instead of the row prompt", () => {
|
||||
const table = { operation: "creative" as const, concurrency: 10, globalPrompt: " 全局覆盖 ", rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a"], prompt: "行提示词" }] };
|
||||
expect(batchPromptForRow(table, table.rows[0])).toBe("全局覆盖");
|
||||
expect(batchPromptForRow({ ...table, globalPrompt: " " }, table.rows[0])).toBe("行提示词");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { cancelIncompleteImageBatch, failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild, retireImageBatchChildren } from "../src/lib/canvas/canvas-image-batch-retry";
|
||||
import { removeCanvasNodes } from "../src/lib/canvas/canvas-project-domain";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeStatus } from "../src/types/canvas";
|
||||
|
||||
function imageNode(id: string, status: CanvasNodeStatus, metadata: Partial<NonNullable<CanvasNodeData["metadata"]>> = {}): CanvasNodeData {
|
||||
@@ -108,4 +109,24 @@ describe("canvas image batch retry", () => {
|
||||
expect(cancelled.nodes.find((node) => node.id === "root")?.metadata?.batchChildIds).toBeUndefined();
|
||||
expect(cancelled.nodes.find((node) => node.id === "done")?.metadata?.batchRootId).toBeUndefined();
|
||||
});
|
||||
|
||||
test("删除最后一个失败子图后清除批量根节点的失败状态", () => {
|
||||
const root = imageNode("root", "error", {
|
||||
isBatchRoot: true,
|
||||
batchChildIds: ["failed"],
|
||||
batchFailedCount: 1,
|
||||
errorDetails: "生成失败",
|
||||
generationErrorCode: "upstream_unavailable",
|
||||
});
|
||||
const failed = imageNode("failed", "error", { batchRootId: root.id, errorDetails: "上游失败" });
|
||||
|
||||
const result = removeCanvasNodes([root, failed], new Set([failed.id]));
|
||||
const nextRoot = result.nodes.find((node) => node.id === root.id);
|
||||
|
||||
expect(nextRoot?.metadata).toMatchObject({ status: "idle" });
|
||||
expect(nextRoot?.metadata?.isBatchRoot).toBeUndefined();
|
||||
expect(nextRoot?.metadata?.batchFailedCount).toBeUndefined();
|
||||
expect(nextRoot?.metadata?.errorDetails).toBeUndefined();
|
||||
expect(nextRoot?.metadata?.generationErrorCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { apiClient } from "../src/services/api/request";
|
||||
import { canvasContentHash } from "../src/lib/canvas/canvas-content";
|
||||
import { rebaseCanvasProjects, parseCanvasStorageDocument } from "../src/lib/canvas/canvas-storage-revision";
|
||||
import { readCanvasSyncDrafts } from "../src/services/canvas-sync-drafts";
|
||||
import { applyAgentCanvasPatches, refreshCanvasAfterAgent, initializeRemoteUserDataSession, installRemoteUserDataAutoSync, loadCanvasProjectForEditing, resetRemoteUserDataSync, saveRemoteUserDataNow, syncRemoteUserData } from "../src/services/user-data-sync";
|
||||
import { applyAgentCanvasPatches, deleteCanvasProjectsWithRemoteSync, refreshCanvasAfterAgent, initializeRemoteUserDataSession, installRemoteUserDataAutoSync, loadCanvasProjectForEditing, resetRemoteUserDataSync, saveRemoteUserDataNow, syncRemoteUserData } from "../src/services/user-data-sync";
|
||||
import { createAgentCanvasSync } from "../src/services/agent-canvas-sync";
|
||||
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "../src/stores/canvas/use-canvas-store";
|
||||
import { flushAssetStorePersistence, useAssetStore } from "../src/stores/use-asset-store";
|
||||
@@ -22,6 +22,7 @@ let autoSave: (() => void) | undefined;
|
||||
let remote = new Map<string, CanvasProject>();
|
||||
let requests: Array<{ method: string; id: string; project?: CanvasProject }> = [];
|
||||
let beforePut: (() => Promise<void>) | undefined;
|
||||
let deleteFailureId: string | undefined;
|
||||
|
||||
function canvas(id = "canvas"): CanvasProject {
|
||||
return {
|
||||
@@ -52,6 +53,7 @@ beforeEach(async () => {
|
||||
requests = [];
|
||||
autoSave = undefined;
|
||||
beforePut = undefined;
|
||||
deleteFailureId = undefined;
|
||||
remote = new Map([
|
||||
["canvas", canvas()],
|
||||
["other", canvas("other")],
|
||||
@@ -82,7 +84,19 @@ beforeEach(async () => {
|
||||
let data: unknown;
|
||||
let status = 200;
|
||||
if (id === "snapshot") data = { projects: structuredClone([...remote.values()]), assets: [] };
|
||||
else if (id === "restore" && method === "post") {
|
||||
else if (method === "delete") {
|
||||
if (id === deleteFailureId) status = 403;
|
||||
else {
|
||||
remote.delete(id);
|
||||
data = { id };
|
||||
}
|
||||
} else if (id === "batch" && method === "post") {
|
||||
data = { assets: [{
|
||||
id: "invalid-mime-asset", kind: "image", title: "历史素材", coverUrl: "", tags: [],
|
||||
createdAt: "2026-09-01", updatedAt: "2026-09-01",
|
||||
data: { dataUrl: "https://example.com/image.png", width: 100, height: 100, bytes: 1, mimeType: "image/*" },
|
||||
}] };
|
||||
} else if (id === "restore" && method === "post") {
|
||||
const current = remote.get("canvas")!;
|
||||
if (body.revision !== current.revision) status = 409;
|
||||
else {
|
||||
@@ -119,6 +133,39 @@ afterEach(async () => {
|
||||
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
|
||||
});
|
||||
|
||||
test("deletion skips editing and invalid MIME assets, including an uncached canvas", async () => {
|
||||
const project = canvas();
|
||||
project.nodes[0].metadata = { assetId: "invalid-mime-asset" };
|
||||
remote.set(project.id, project);
|
||||
await initializeRemoteUserDataSession(scope);
|
||||
await expect(loadCanvasProjectForEditing(project.id)).rejects.toThrow("具体 MIME");
|
||||
useCanvasStore.getState().deleteProjects(["other"]);
|
||||
await flushCanvasStorePersistence();
|
||||
requests = [];
|
||||
|
||||
await deleteCanvasProjectsWithRemoteSync([" canvas ", "canvas", "other"]);
|
||||
|
||||
expect(requests.map(({ method, id }) => ({ method, id }))).toEqual([
|
||||
{ method: "delete", id: "canvas" }, { method: "delete", id: "other" },
|
||||
]);
|
||||
expect(remote.size).toBe(0);
|
||||
expect(useCanvasStore.getState().projects).toEqual([]);
|
||||
await useCanvasStore.persist.rehydrate();
|
||||
expect(useCanvasStore.getState().projects).toEqual([]);
|
||||
});
|
||||
|
||||
test("batch deletion retains failed canvases and persists successful deletions", async () => {
|
||||
await initializeRemoteUserDataSession(scope);
|
||||
deleteFailureId = "other";
|
||||
|
||||
await expect(deleteCanvasProjectsWithRemoteSync(["canvas", "other"])).rejects.toThrow();
|
||||
|
||||
expect([...remote.keys()]).toEqual(["other"]);
|
||||
expect(useCanvasStore.getState().projects.map((project) => project.id)).toEqual(["other"]);
|
||||
await useCanvasStore.persist.rehydrate();
|
||||
expect(useCanvasStore.getState().projects.map((project) => project.id)).toEqual(["other"]);
|
||||
});
|
||||
|
||||
test("stale viewport, no-op restore and open do not submit old content", async () => {
|
||||
const old = useCanvasStore.getState().openProject("canvas")!;
|
||||
remote.set("canvas", { ...addNode(canvas(), "afternoon-video"), revision: 2 });
|
||||
|
||||
@@ -121,6 +121,8 @@ describe("canvas resource mention editor", () => {
|
||||
expect(component).toContain("const token = `@[skill:${skill.skillId}] `");
|
||||
expect(component).toContain("slash.start + 1 + slash.query.length");
|
||||
expect(component).toContain("buildSkillMentionReferences(availableSlashSkills)");
|
||||
expect(component).toContain("[/、]([^\\s/、]*)$");
|
||||
expect(source("../src/components/canvas/canvas-cloud-agent-panel.tsx")).toContain("用 / 或 、 引用 Skills");
|
||||
});
|
||||
|
||||
test("skill chips use one colored icon instead of exposing the serialized token", () => {
|
||||
@@ -132,9 +134,11 @@ describe("canvas resource mention editor", () => {
|
||||
expect(component).toContain('prefix.textContent = reference.kind === "skill" ? "✦" : "@"');
|
||||
expect(component).toContain('if (reference.kind !== "skill") chip.appendChild(createInlinePreview(reference));');
|
||||
expect(component).toContain('chip.style.setProperty("--canvas-skill-mention-color", skillMentionColor(reference))');
|
||||
expect(chat).toContain("sendOnEnter={false}");
|
||||
expect(chat).toContain('sendOnEnter={canSubmit ? "both" : false}');
|
||||
expect(chat).toContain("agent-composer-resize-handle");
|
||||
expect(chat).toContain("Enter 换行 · ⌘/Ctrl+Enter 发送");
|
||||
expect(chat).toContain("Enter 发送 · Shift+Enter 换行");
|
||||
expect(css).toContain(".agent-composer-send-hint-full");
|
||||
expect(css).toContain(".agent-composer-send-hint-compact");
|
||||
expect(css).toContain(".agent-composer-prompt-scroll");
|
||||
expect(css).not.toContain(".agent-tool-row:hover");
|
||||
});
|
||||
|
||||
@@ -28,11 +28,11 @@ describe("channel model editor drafts", () => {
|
||||
});
|
||||
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, imageCount: 2 };
|
||||
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, imageCount: 0 });
|
||||
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("按秒");
|
||||
});
|
||||
@@ -63,16 +63,32 @@ describe("pricing write validation", () => {
|
||||
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier(), unitPrice: unitPrice as number }] })).toThrow("有效数值");
|
||||
}
|
||||
});
|
||||
test("a new protocol cannot silently convert Token pricing to per request", () => {
|
||||
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "video", priceTiers: [{ ...defaultPriceTier(), billingMode: "token", outputTokenPrice: 8 }] })).toThrow("不支持 Token");
|
||||
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("validates all text Token prices and the video Token minimum", () => {
|
||||
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)).toThrow("至少为");
|
||||
expect(() => validateChannelModelPrices(video)).not.toThrow();
|
||||
video.priceTiers[0].outputTokenPrice = 0.000001;
|
||||
expect(() => validateChannelModelPrices(video)).not.toThrow();
|
||||
});
|
||||
test("accepts audio-only video price conditions and rejects invalid audio selectors", () => {
|
||||
for (const videoGenerateAudio of ["true", "false"]) {
|
||||
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), billingMode: "token", videoGenerateAudio }] })).not.toThrow();
|
||||
}
|
||||
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), videoGenerateAudio: "yes" }] })).toThrow("有声或无声");
|
||||
});
|
||||
});
|
||||
|
||||
test("admin lazy routes share a persistent boundary instead of replaying page loaders", async () => {
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { defaultPriceTier, priceTierResolutionFromForm, priceTierToForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "../src/pages/admin/components/channel-model-price-tier-form";
|
||||
import { defaultPriceTier, priceTierPayloadFromForm, priceTierResolutionFromForm, priceTierToForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "../src/pages/admin/components/channel-model-price-tier-form";
|
||||
import type { ChannelModelPriceTier } from "../src/services/api/wallet";
|
||||
import { validateChannelModelPrices } from "../src/pages/admin/components/channel-model-editor-form";
|
||||
|
||||
describe("channel model price tier defaults", () => {
|
||||
test("cost edit payload preserves independent sale and cost prices including explicit zero", () => {
|
||||
const tier = { ...defaultPriceTier(), unitPrice: 0.09, costConfigured: true, costUnitPrice: 0.035678 };
|
||||
const payload = priceTierPayloadFromForm("video", tier, "MiniMax-H3");
|
||||
expect(payload.unitPriceMicrocredits).toBe(90_000);
|
||||
expect(payload.costPricing).toMatchObject({ configured: true, unitPriceMicrocredits: 35_678 });
|
||||
expect(priceTierToForm(payload as ChannelModelPriceTier)).toMatchObject({ unitPrice: 0.09, costConfigured: true, costUnitPrice: 0.035678 });
|
||||
expect(priceTierPayloadFromForm("video", { ...tier, costUnitPrice: 0 }, "MiniMax-H3").costPricing).toMatchObject({ configured: true, unitPriceMicrocredits: 0 });
|
||||
expect(defaultPriceTier().costConfigured).toBe(false);
|
||||
for (const costUnitPrice of [-1, NaN, Infinity, 1_000_001]) {
|
||||
expect(() => validateChannelModelPrices({ capability: "video", protocol: "minimax-video", priceTiers: [{ ...tier, costUnitPrice }] })).toThrow("积分成本价");
|
||||
}
|
||||
});
|
||||
|
||||
test("video Token cost uses only output price while text cost keeps separate token rates", () => {
|
||||
const tier = { ...defaultPriceTier(), billingMode: "token" as const, costConfigured: true, costInputTokenPrice: 1.5, costOutputTokenPrice: 3, costCachedTokenPrice: 0.1 };
|
||||
expect(priceTierPayloadFromForm("video", tier, "video").costPricing).toMatchObject({ configured: true, inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 3_000_000, cachedTokenPriceMicrocredits: 0 });
|
||||
expect(priceTierPayloadFromForm("text", tier, "text").costPricing).toMatchObject({ inputTokenPriceMicrocredits: 1_500_000, outputTokenPriceMicrocredits: 3_000_000, cachedTokenPriceMicrocredits: 100_000 });
|
||||
});
|
||||
test("creates a usable all-spec fallback price by default", () => {
|
||||
const tier = defaultPriceTier();
|
||||
|
||||
@@ -20,7 +39,7 @@ describe("channel model price tier defaults", () => {
|
||||
});
|
||||
|
||||
test("drops stale video selectors after switching back to the default price", () => {
|
||||
const tier = { ...defaultPriceTier("advanced"), resolution: "1080p", videoSeconds: 10, imageCount: 2 };
|
||||
const tier = { ...defaultPriceTier("advanced"), resolution: "1080p", videoSeconds: 10, videoGenerateAudio: "false", imageCount: 2 };
|
||||
const defaultTier = { ...tier, matchMode: "default" as const };
|
||||
|
||||
expect(skuSelectorFromForm("video", defaultTier)).toEqual({});
|
||||
@@ -51,5 +70,22 @@ describe("channel model price tier defaults", () => {
|
||||
|
||||
expect(priceTierToForm(base).matchMode).toBe("default");
|
||||
expect(priceTierToForm({ ...base, selector: { quality: "2k" } }).matchMode).toBe("advanced");
|
||||
expect(priceTierToForm({ ...base, selector: { videoGenerateAudio: "false" } })).toMatchObject({ matchMode: "advanced", videoGenerateAudio: "false" });
|
||||
});
|
||||
|
||||
test("preserves explicit silent and audible selectors but omits the wildcard", () => {
|
||||
for (const videoGenerateAudio of ["true", "false"]) {
|
||||
const tier = { ...defaultPriceTier("advanced"), videoGenerateAudio };
|
||||
expect(skuSelectorFromForm("video", tier)).toEqual({ videoGenerateAudio });
|
||||
expect(skuSelectorFromForm("image", tier)).toEqual({});
|
||||
}
|
||||
expect(skuSelectorFromForm("video", defaultPriceTier("advanced"))).toEqual({});
|
||||
});
|
||||
|
||||
test("video Token writes discard hidden text prices without changing the configured video rate", () => {
|
||||
const tier = { ...defaultPriceTier("advanced"), billingMode: "token" as const, videoGenerateAudio: "false", inputTokenPrice: 10, outputTokenPrice: 0.25, cachedTokenPrice: 5 };
|
||||
expect(priceTierPayloadFromForm("video", tier, "seedance")).toMatchObject({ selector: { videoGenerateAudio: "false" }, providerModelKey: "seedance", inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 250_000, cachedTokenPriceMicrocredits: 0 });
|
||||
expect(priceTierPayloadFromForm("text", tier, "text-model")).toMatchObject({ inputTokenPriceMicrocredits: 10_000_000, outputTokenPriceMicrocredits: 250_000, cachedTokenPriceMicrocredits: 5_000_000 });
|
||||
expect(priceTierPayloadFromForm("video", { ...tier, outputTokenPrice: 0 }, "seedance").outputTokenPriceMicrocredits).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
14
web/test/creation-submit-gate.test.ts
Normal file
14
web/test/creation-submit-gate.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import { createCreationSubmitGate } from "../src/pages/create/creation-submit-gate";
|
||||
|
||||
test("creation submit gate rejects a second submit until the first submit releases it", () => {
|
||||
const gate = createCreationSubmitGate();
|
||||
|
||||
expect(gate.tryAcquire()).toBe(true);
|
||||
expect(gate.tryAcquire()).toBe(false);
|
||||
|
||||
gate.release();
|
||||
|
||||
expect(gate.tryAcquire()).toBe(true);
|
||||
});
|
||||
109
web/test/model-picker-groups.test.ts
Normal file
109
web/test/model-picker-groups.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { groupModelsForPicker, modelChannelLabel } from "../src/lib/model-picker-groups";
|
||||
import { modelCompatibilityError, resolveCompatibleModel } from "../src/lib/model-selection";
|
||||
import { modelQuoteRequest, priceTiersForCurrentSelection, priceTierSummaryLabel } from "../src/lib/model-pricing";
|
||||
import { systemChannelModelChannels } from "../src/lib/user-session";
|
||||
import type { PublicChannelCatalog, PublicChannelModel } from "../src/services/api/logical-models";
|
||||
import { defaultConfig, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability } from "../src/stores/use-config-store";
|
||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
|
||||
function model(label = "", price = 300000, modelKey = "seedance-2.0", displayName = modelKey === "seedance-2.0" ? "Seedance 2.0" : modelKey): PublicChannelModel {
|
||||
return {
|
||||
id: `${modelKey}-${label || "default"}`, modelKey, displayName, channelLabel: label,
|
||||
description: label ? `${label}的使用说明` : "",
|
||||
icon: "ByteDance", capability: "video", protocol: "seedance", available: true,
|
||||
pricingMode: "provider", priceLabel: "",
|
||||
capabilityConfig: defaultModelCapabilityConfig("seedance", "seedance-2.0"),
|
||||
priceTiers: [{ id: "tier", selector: {}, resolution: "*", videoSeconds: 0, billingMode: "per_second", unitPriceMicrocredits: price, inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 0, cachedTokenPriceMicrocredits: 0 }],
|
||||
};
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const channels: PublicChannelCatalog[] = [
|
||||
{ id: "a", name: "正常渠道", displayName: "正常渠道", models: [model()] },
|
||||
{ id: "b", name: "渠道 B", displayName: "渠道 B", models: [model("优惠渠道-993")] },
|
||||
{ id: "c", name: "渠道 C", displayName: "渠道 C", models: [model("特惠渠道-730", 200000)] },
|
||||
];
|
||||
return normalizeConfigSnapshot({ config: { ...defaultConfig, channels: systemChannelModelChannels(channels), model: "b::seedance-2.0", videoModel: "b::seedance-2.0" } }).config;
|
||||
}
|
||||
|
||||
test("same model display name groups all channels and preserves their prices", () => {
|
||||
const config = fixture();
|
||||
const groups = groupModelsForPicker(config, selectableModelsByCapability(config, "video"));
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(config.channels.map((channel) => channel.modelCosts![0].description)).toEqual(["", "优惠渠道-993的使用说明", "特惠渠道-730的使用说明"]);
|
||||
expect(groups.map((group) => [group.label, group.kind, group.models.map((item) => [item.label, item.models])])).toEqual([
|
||||
["Seedance 2.0", "product", [["正常渠道", ["a::seedance-2.0"]], ["优惠渠道-993", ["b::seedance-2.0"]], ["特惠渠道-730", ["c::seedance-2.0"]]]],
|
||||
]);
|
||||
expect(config.channels.map((channel) => priceTierSummaryLabel(priceTiersForCurrentSelection(channel.modelCosts![0].logicalPriceTiers!, "video", config)))).toEqual(["0.3 积分/秒", "0.3 积分/秒", "0.2 积分/秒"]);
|
||||
});
|
||||
|
||||
test("grouping uses display name even when model keys differ, without merging channel options", () => {
|
||||
const channels = systemChannelModelChannels([
|
||||
{ id: "mita", name: "秘塔", displayName: "秘塔", models: [model("秘塔(满血渠道)", 90_000, "MiniMax H3", "MiniMax H3")] },
|
||||
{ id: "other", name: "其他供应商", displayName: "其他供应商", models: [model("秘塔(满血渠道)", 80_000, "minimax-h3-alt", "MiniMax H3")] },
|
||||
]);
|
||||
const config = normalizeConfigSnapshot({ config: { ...defaultConfig, channels } }).config;
|
||||
const groups = groupModelsForPicker(config, selectableModelsByCapability(config, "video"));
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].label).toBe("MiniMax H3");
|
||||
expect(groups[0].models.map((item) => item.models)).toEqual([["mita::MiniMax H3"], ["other::minimax-h3-alt"]]);
|
||||
expect(groups[0].models.map((item) => item.label)).toEqual(["秘塔(满血渠道)", "秘塔(满血渠道)"]);
|
||||
});
|
||||
|
||||
test("selection and quote keep the chosen channel even when another channel is cheaper", () => {
|
||||
const config = fixture();
|
||||
const value = "b::seedance-2.0";
|
||||
expect(resolveCompatibleModel(config, value, { capability: "video" })).toBe(value);
|
||||
expect(resolveModelRequestConfig(config, value)).toMatchObject({ channelId: "b", model: "seedance-2.0" });
|
||||
expect(modelQuoteRequest(config, value, "video")).toMatchObject({ channelId: "b", modelKey: "seedance-2.0" });
|
||||
config.channels[1].modelCosts![0].channelLabel = "新展示名";
|
||||
expect(modelChannelLabel(config, value)).toBe("新展示名");
|
||||
expect(resolveModelRequestConfig(config, value)).toMatchObject({ channelId: "b", model: "seedance-2.0" });
|
||||
});
|
||||
|
||||
test("different display names in one channel create distinct first-level groups", () => {
|
||||
const config = fixture();
|
||||
const extra = systemChannelModelChannels([{ id: "volc", name: "火山引擎", displayName: "火山引擎", models: [
|
||||
model("Seedance 2 Mini", 300000, "seedance-2-mini", "Seedance 2 Mini"),
|
||||
model("Seedance 2.0 Fast", 300000, "seedance-2-fast", "Seedance 2.0 Fast"),
|
||||
model("Seedance 2.0", 300000, "seedance-2", "Seedance 2.0"),
|
||||
] }]);
|
||||
const groups = groupModelsForPicker({ ...config, channels: extra }, selectableModelsByCapability({ ...config, channels: extra }, "video"));
|
||||
expect(groups).toHaveLength(3);
|
||||
expect(groups.map((group) => group.label)).toEqual(["Seedance 2 Mini", "Seedance 2.0 Fast", "Seedance 2.0"]);
|
||||
expect(groups.every((group) => group.kind === "product" && group.models.length === 1)).toBe(true);
|
||||
});
|
||||
|
||||
test("group metadata is stable across channel reorder and current selection", () => {
|
||||
const config = fixture();
|
||||
config.channels[1].modelCosts![0].displayName = "不同展示名";
|
||||
config.channels[1].modelCosts![0].icon = "Jimeng";
|
||||
const options = selectableModelsByCapability(config, "video");
|
||||
const first = groupModelsForPicker(config, options).find((group) => group.label === "不同展示名")!;
|
||||
const reordered = groupModelsForPicker({ ...config, channels: [...config.channels].reverse(), model: "c::seedance-2.0" }, options).find((group) => group.label === "不同展示名")!;
|
||||
expect([reordered.label, reordered.icon]).toEqual([first.label, first.icon]);
|
||||
expect(reordered.models[0].models).toEqual(["b::seedance-2.0"]);
|
||||
});
|
||||
|
||||
test("each channel keeps its own capability restrictions", () => {
|
||||
const config = fixture();
|
||||
config.channels[0].modelCosts![0].capabilityConfig!.video!.references.maxImages = 0;
|
||||
config.channels[1].modelCosts![0].capabilityConfig!.video!.references.maxImages = 2;
|
||||
config.channels[1].modelCosts![0].capabilityConfig!.video!.operations = ["image_to_video"];
|
||||
const requirements = { capability: "video" as const, input: { textCount: 1, imageCount: 1, videoCount: 0, audioCount: 0, characterCount: 0 } };
|
||||
expect(modelCompatibilityError(config, "a::seedance-2.0", requirements)).not.toBe("");
|
||||
expect(modelCompatibilityError(config, "b::seedance-2.0", requirements)).toBe("");
|
||||
expect(groupModelsForPicker(config, selectableModelsByCapability(config, "video"))[0].models).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("managed logical models and personal channels never become system product routes", () => {
|
||||
const config = fixture();
|
||||
config.channels[1].id = "managed";
|
||||
config.channels[1].modelCosts![0].logicalModelId = "logical-test";
|
||||
config.channels[2].scope = "user";
|
||||
const groups = groupModelsForPicker(config, ["a::seedance-2.0", "managed::seedance-2.0", "c::seedance-2.0"]);
|
||||
expect(groups.map((item) => item.kind)).toEqual(["product", "channel", "channel"]);
|
||||
expect(groups[0].models).toHaveLength(1);
|
||||
expect(groupModelsForPicker(config, [])).toEqual([]);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { buildGenerationConfig } from "../src/lib/canvas/canvas-project-generati
|
||||
import { modelRequestOptions } from "../src/lib/model-selection";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
|
||||
|
||||
function systemConfig(input: { capability?: "image" | "video"; logicalModelId?: string; tiers: Array<{ selector: Record<string, string>; billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number }> }) {
|
||||
function systemConfig(input: { capability?: "image" | "video"; logicalModelId?: string; tiers: Array<{ selector: Record<string, string>; billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number; outputTokenPriceMicrocredits?: number }> }) {
|
||||
const capability = input.capability || "video";
|
||||
const model = capability === "video" ? "agnes-video-2.5" : "image-model";
|
||||
const channel = createModelChannel({
|
||||
@@ -32,7 +32,7 @@ function systemConfig(input: { capability?: "image" | "video"; logicalModelId?:
|
||||
resolution: tier.selector.vquality || "*",
|
||||
videoSeconds: Number(tier.selector.videoSeconds || 0),
|
||||
inputTokenPriceMicrocredits: 0,
|
||||
outputTokenPriceMicrocredits: 0,
|
||||
outputTokenPriceMicrocredits: tier.outputTokenPriceMicrocredits ?? 0,
|
||||
cachedTokenPriceMicrocredits: 0,
|
||||
})),
|
||||
},
|
||||
@@ -235,4 +235,38 @@ describe("model request pricing", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("matches video audio, resolution and duration from actual request overrides", () => {
|
||||
const config = systemConfig({ tiers: [
|
||||
{ selector: {}, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 1_000_000 },
|
||||
{ selector: { vquality: "1080p", videoSeconds: "10", videoGenerateAudio: "false" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 2_000_000 },
|
||||
{ selector: { vquality: "1080p", videoSeconds: "10", videoGenerateAudio: "true" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 3_000_000 },
|
||||
] });
|
||||
const tiers = resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!;
|
||||
for (const videoGenerateAudio of [false, "false", true, "true"]) {
|
||||
const requirements = { ...textVideoRequirements, videoSeconds: "10", options: { vquality: "1080P", videoSeconds: 5, videoGenerateAudio } };
|
||||
const matched = priceTiersForCurrentSelection(tiers, "video", config, requirements);
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0]?.outputTokenPriceMicrocredits).toBe(videoGenerateAudio === true || videoGenerateAudio === "true" ? 3_000_000 : 2_000_000);
|
||||
expect(requestCreditCost({ channelMode: "remote", modelCosts: resolveModelChannel(config, config.model).modelCosts, model: "agnes-video-2.5", capability: "video", config, requirements })).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the configured video Token rate including free and fractional rates", () => {
|
||||
const config = systemConfig({ tiers: [
|
||||
{ selector: { videoGenerateAudio: "false" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 0 },
|
||||
{ selector: { videoGenerateAudio: "true" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 1 },
|
||||
] });
|
||||
const tiers = resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!;
|
||||
expect(priceTierSummaryLabel(tiers, "video")).toBe("0-0.000001 积分/百万视频 Token");
|
||||
expect(priceTierSummaryLabel(tiers.slice(0, 1), "video")).toBe("0 积分/百万视频 Token");
|
||||
});
|
||||
|
||||
test("builds a direct system-video quote without inventing a logical model ID", () => {
|
||||
const config = systemConfig({ tiers: [{ selector: {}, billingMode: "token", unitPriceMicrocredits: 0 }] });
|
||||
const requirements = { ...textVideoRequirements, options: { videoGenerateAudio: false, vquality: "1080p" } };
|
||||
expect(modelQuoteRequest(config, config.model, "video", requirements)).toMatchObject({ channelId: "system-channel", modelKey: "agnes-video-2.5", intent: { options: { videoGenerateAudio: false, vquality: "1080p" } } });
|
||||
expect(modelQuoteRequest(config, config.model, "video", requirements)?.logicalModelID).toBeUndefined();
|
||||
expect(modelQuoteRequest(config, config.model, "image", requirements)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,21 @@ import { describe, expect, test } from "bun:test";
|
||||
import { modelProtocolSupportsTokenBilling } from "../src/lib/model-protocols";
|
||||
|
||||
describe("model protocol Token billing", () => {
|
||||
test("supports text models and Volcengine Ark video only", () => {
|
||||
test("supports text and every video protocol", () => {
|
||||
expect(modelProtocolSupportsTokenBilling("text", "chat-completion")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "volcengine-ark-video")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "volcengine-ark-agent-plan-video")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "volcengine-jimeng-video")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "newapi")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "volcengine-jimeng-video")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "newapi-channel-2")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "newapi")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "custom-video-protocol")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not enable Token billing for image, audio or unselected capabilities", () => {
|
||||
expect(modelProtocolSupportsTokenBilling("image", "volcengine-ark-image")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling("image", "volcengine-ark-agent-plan-image")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling("audio", "speech")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling(undefined, "volcengine-ark-video")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
66
web/test/model-quote.test.ts
Normal file
66
web/test/model-quote.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { modelQuoteDescription } from "../src/lib/model-pricing";
|
||||
import { quoteModel, type LogicalModelQuote, type ModelRequestIntent } from "../src/services/api/logical-models";
|
||||
import { apiClient } from "../src/services/api/request";
|
||||
|
||||
const intent: ModelRequestIntent = { capability: "video", operation: "text_to_video", inputs: { image: 0, video: 0, audio: 0 }, options: { videoSeconds: 5, videoGenerateAudio: false } };
|
||||
const quote: LogicalModelQuote = { logicalModelId: "", billingMode: "token", quantity: 1, amountMicrocredits: 500_000, estimated: true };
|
||||
|
||||
describe("model quote API targets", () => {
|
||||
test("direct system videos send the actual options and cancellation signal to the catalog quote", async () => {
|
||||
const original = apiClient.request;
|
||||
const calls: unknown[] = [];
|
||||
apiClient.request = (async (config: unknown) => {
|
||||
calls.push(config);
|
||||
return { data: { code: 0, data: { quote }, msg: "ok" }, status: 200, headers: {} };
|
||||
}) as typeof apiClient.request;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
expect(await quoteModel({ channelId: "channel-1", modelKey: "seedance", intent }, controller.signal)).toEqual({ quote });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toMatchObject({ method: "post", url: "/model-catalog/quote", data: { channelId: "channel-1", modelKey: "seedance", intent }, signal: controller.signal });
|
||||
} finally {
|
||||
apiClient.request = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("logical models retain their dedicated quote route", async () => {
|
||||
const original = apiClient.request;
|
||||
const calls: unknown[] = [];
|
||||
apiClient.request = (async (config: unknown) => {
|
||||
calls.push(config);
|
||||
return { data: { code: 0, data: { quote }, msg: "ok" }, status: 200, headers: {} };
|
||||
}) as typeof apiClient.request;
|
||||
try {
|
||||
await quoteModel({ logicalModelID: "logical/video", intent });
|
||||
expect(calls[0]).toMatchObject({ method: "post", url: "/models/logical%2Fvideo/quote", data: intent });
|
||||
} finally {
|
||||
apiClient.request = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("missing quote targets fail before issuing a request", async () => {
|
||||
await expect(quoteModel({ intent })).rejects.toThrow("请选择");
|
||||
});
|
||||
});
|
||||
|
||||
test("video estimates disclose platform reserves, unknown references and final settlement", () => {
|
||||
const description = modelQuoteDescription({
|
||||
...quote,
|
||||
videoTokenEstimate: { formulaTokens: 100_000, reservedTokens: 110_000, outputWidth: 1280, outputHeight: 720, framesPerSecond: 24, outputSeconds: 5, referenceSeconds: 15, referenceDurationEstimated: true, dimensionsEstimated: true, reservationMarginPercent: 10 },
|
||||
});
|
||||
expect(description).toContain("预计消耗 0.5 积分");
|
||||
expect(description).toContain("公式预估 100,000 视频 Token");
|
||||
expect(description).toContain("平台额外预留 10%");
|
||||
expect(description).toContain("15 秒");
|
||||
expect(description).toContain("并非费用上限");
|
||||
expect(description).toContain("未返回用量时按火山引擎公式结算");
|
||||
expect(description).toContain("额外预留不计入公式结算");
|
||||
expect(description).toContain("补扣或退回差额");
|
||||
});
|
||||
|
||||
test("text Token quotes retain upstream usage settlement without a video formula", () => {
|
||||
const description = modelQuoteDescription(quote);
|
||||
expect(description).toContain("实际用量结算");
|
||||
expect(description).not.toContain("火山引擎公式");
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { applySkinTheme, DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition, getSkinA
|
||||
import { normalizePublicAppearance } from "../src/stores/use-appearance-store";
|
||||
|
||||
describe("site appearance and editable skin library", () => {
|
||||
test("classic is immutable and keeps the existing runtime token system unchanged", () => {
|
||||
test("classic preserves foundation tokens and applies only primary button fills", () => {
|
||||
expect(DEFAULT_CLASSIC_SKIN.locked).toBe(true);
|
||||
expect(getSkinAntOverrides(DEFAULT_CLASSIC_SKIN, "light")).toEqual({});
|
||||
expect(getSkinAntOverrides(DEFAULT_CLASSIC_SKIN, "dark")).toEqual({});
|
||||
@@ -22,7 +22,9 @@ describe("site appearance and editable skin library", () => {
|
||||
} as unknown as Document;
|
||||
applySkinTheme(DEFAULT_CLASSIC_SKIN, "light", target);
|
||||
expect(removed.length).toBeGreaterThan(60);
|
||||
expect(assigned.size).toBe(0);
|
||||
expect(assigned.size).toBe(4);
|
||||
expect(assigned.get("--button-primary-bg")).toBe("linear-gradient(115deg, #6554df, #386fbc)");
|
||||
expect(assigned.has("--background")).toBe(false);
|
||||
expect(target.documentElement.dataset.skin).toBe("classic");
|
||||
});
|
||||
|
||||
@@ -142,7 +144,7 @@ describe("site appearance and editable skin library", () => {
|
||||
expect(storeSource).toContain('setMeta(targetDocument, "property", "og:title"');
|
||||
expect(footerSource).toContain("https://beian.miit.gov.cn/");
|
||||
expect(footerSource).toContain('rel="noopener noreferrer"');
|
||||
expect(pageSource).toContain('title="5. 皮肤主题"');
|
||||
expect(pageSource).toContain('title="皮肤主题"');
|
||||
expect(editorSource).toContain("从默认新建");
|
||||
expect(editorSource).toContain("复制当前");
|
||||
expect(editorSource).toContain("删除这套主题");
|
||||
|
||||
55
web/test/skin-button-fill.test.ts
Normal file
55
web/test/skin-button-fill.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { applySkinTheme, DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition, getSkinButtonAppearance, isSkinButtonFill, normalizeSkinDefinition } from "../src/lib/skin-themes";
|
||||
|
||||
describe("theme primary button fills", () => {
|
||||
test("classic uses the reference gradient in both modes without changing selection colors", () => {
|
||||
for (const mode of ["light", "dark"] as const) {
|
||||
expect(getSkinButtonAppearance(DEFAULT_CLASSIC_SKIN, mode)).toEqual({
|
||||
background: "linear-gradient(115deg, #6554df, #386fbc)",
|
||||
hover: "linear-gradient(115deg, #5744cf, #356bbb)",
|
||||
active: "linear-gradient(115deg, #4938b8, #2c5da5)", foreground: "#ffffff",
|
||||
});
|
||||
}
|
||||
expect(DEFAULT_CLASSIC_SKIN.tokens.light.selected).toBe("#e8e8e8");
|
||||
});
|
||||
|
||||
test("copies have independent fills and switching to solid clears every gradient state", () => {
|
||||
const skin = duplicateSkinDefinition(DEFAULT_CLASSIC_SKIN, ["classic"]);
|
||||
skin.tokens.buttons.light.angle = 45;
|
||||
skin.tokens.buttons.light.start = "#123456";
|
||||
expect(DEFAULT_CLASSIC_SKIN.tokens.buttons.light.angle).toBe(115);
|
||||
expect(skin.tokens.buttons.dark.angle).toBe(115);
|
||||
expect(getSkinButtonAppearance(skin, "light").background).toBe("linear-gradient(45deg, #123456, #386fbc)");
|
||||
const values = new Map<string, string>();
|
||||
const doc = { documentElement: { dataset: {}, style: {
|
||||
removeProperty: (key: string) => values.delete(key), setProperty: (key: string, value: string) => values.set(key, value),
|
||||
} } } as unknown as Document;
|
||||
applySkinTheme(skin, "light", doc);
|
||||
skin.tokens.buttons.light.mode = "solid";
|
||||
Object.assign(skin.tokens.light, { primary: "#123456", primaryHover: "#234567", primaryActive: "#345678", primaryForeground: "#ffffff" });
|
||||
applySkinTheme(skin, "light", doc);
|
||||
expect(values.get("--button-primary-bg")).toBe("#123456");
|
||||
expect(values.get("--button-primary-hover-bg")).toBe("#234567");
|
||||
expect(values.get("--button-primary-active-bg")).toBe("#345678");
|
||||
applySkinTheme(DEFAULT_CLASSIC_SKIN, "dark", doc);
|
||||
expect(values.has("--background")).toBe(false);
|
||||
expect(values.get("--button-primary-fg")).toBe("#ffffff");
|
||||
});
|
||||
|
||||
test("legacy custom themes retain solid actions; legacy classic gains its default gradient", () => {
|
||||
for (const original of [DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition(DEFAULT_CLASSIC_SKIN, ["classic"])]) {
|
||||
const legacy = JSON.parse(JSON.stringify(original));
|
||||
delete legacy.tokens.buttons;
|
||||
expect(normalizeSkinDefinition(legacy).tokens.buttons.light.mode).toBe(original.id === "classic" ? "gradient" : "solid");
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid modes, angles and CSS injection cannot become runtime gradients", () => {
|
||||
for (const patch of [{ mode: "url(x)" }, { angle: 361 }, { angle: -1 }, { angle: 1.5 }, { angle: NaN }, { start: "red; background:url(x)" }, { foreground: "white" }]) {
|
||||
const skin = duplicateSkinDefinition(DEFAULT_CLASSIC_SKIN, ["classic"]);
|
||||
Object.assign(skin.tokens.buttons.light, patch);
|
||||
expect(isSkinButtonFill(skin.tokens.buttons.light)).toBe(false);
|
||||
expect(getSkinButtonAppearance(normalizeSkinDefinition(skin), "light").background).toBe(skin.tokens.light.primary);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user