merge: official v1.5.4 release

This commit is contained in:
Codex
2026-09-19 15:36:02 +08:00
189 changed files with 13222 additions and 8821 deletions

View 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();
});
});

View File

@@ -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"],

View File

@@ -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("行提示词");
});
});

View File

@@ -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();
});
});

View File

@@ -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 });

View File

@@ -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");
});

View File

@@ -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 () => {

View File

@@ -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);
});
});

View 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);
});

View 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([]);
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View 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("火山引擎公式");
});

View File

@@ -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("删除这套主题");

View 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);
}
});
});