merge: official v1.5.6 with multimodal storyboard enhancements

This commit is contained in:
Codex
2026-09-20 13:05:42 +08:00
199 changed files with 11465 additions and 2971 deletions

View File

@@ -57,7 +57,7 @@ test("model reference limits use compact rows only inside the admin editor", asy
expect(numberField).toContain("align-items: center;");
const switches = sourceSection(css, ".admin-model-editor-references .admin-capability-boolean-field label {", "@media (min-width: 601px)");
expect(switches).toContain("display: flex;");
expect(compactSource(css)).toContain(".admin-model-editor-references .admin-capability-reference-grid { align-items: start;");
expect(compactSource(css)).toContain(".admin-model-editor-references .admin-capability-reference-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: start;");
expect(compactSource(css)).toContain(".admin-model-editor-modal .admin-capability-reference-grid { grid-template-columns: minmax(0, 1fr);");
});
@@ -115,33 +115,20 @@ test("channel model manager supports bounded atomic batch deletion", async () =>
expect(component).toContain("批量删除");
});
test("analytics keeps fixed range presets distinct and uses enabled channel models for pricing", async () => {
test("analytics keeps range presets and uses order finances without a separate pricing editor", async () => {
const source = compactSource(await Bun.file(new URL("../src/pages/admin/components/analytics-panel.tsx", import.meta.url)).text());
expect(source).toContain('type RangePreset = "7d" | "30d" | "60d"');
expect(source).toContain('["60d", "60 天"]');
expect(source).toContain('next.set("rangePreset", rangePreset)');
expect(source).toContain("setRangePreset(undefined)");
expect(source).toContain('placeholder={pricingModelOptions.length ? "选择已启用模型" : "暂无已启用模型"}');
expect(source).toContain("onValuesChange={handlePricingValuesChange}");
expect(source).toContain("onChange={handlePricingModelChange}");
expect(source).toContain('hasOwnProperty.call(changedValues, "model")');
expect(source).toContain('if (matchingChannels.length) form.setFieldValue("channelId", matchingChannels[0].id)');
expect(source).toContain("const sourceChannels = channels.filter(");
expect(source).toContain('Form.useWatch("channelId", form)');
expect(source).toContain("pricingChannelId");
expect(source).toContain("channel.id === pricingChannelId");
expect(source).toContain('inputMode="decimal"');
expect(source).toContain('className="admin-analytics-price-input"');
expect(source).toContain('className="admin-analytics-price-field"');
expect(source).toContain('rootClassName="admin-modal-root admin-analytics-pricing-modal"');
expect(source).toContain("zIndex={1200}");
expect(source).toContain("setPricingWorkspaceOpen(false)");
expect(source).toContain("validator: validatePriceInput");
expect(source).toContain("请输入非负价格,最多 6 位小数");
expect(source).toContain("function formatPriceInput(micros: number)");
expect(source).toContain("function toMicros(value?: string | number)");
expect(source).not.toContain("<InputNumber");
expect(source).not.toContain("模型价格配置");
expect(source).not.toContain("listAdminModelPricings");
expect(source).toContain("finance.revenueMicrocredits");
expect(source).toContain("finance.profitMicrocredits");
expect(source).toContain("finance.costedOrders < finance.settledOrders");
expect(source).toContain("...analyticsFinanceColumns");
expect(source).toContain("后端未返回完整财务统计");
});
test("storage settings keep generic S3 controls and connection validation", async () => {
@@ -277,17 +264,17 @@ test("request logs display user credit billing independently from upstream cost"
]);
const billingSummary = sourceSection(listSource, "function BillingSummary", "function MediaResult");
expect(listSource).toContain('title: "积分计费"');
expect(listSource).toContain('title: "积分计算"');
expect(listSource).toContain('title: "请求阶段 / 状态"');
expect(listSource).toContain('description="模型生成与结果下载记录;仅计费调用扣除积分"');
expect(billingSummary).toContain("billingAmountMicrocredits");
expect(billingSummary).toContain("billingAvailable");
expect(billingSummary).toContain("!log.billable");
expect(billingSummary).toContain("不计费");
expect(billingSummary).toContain("未扣积分");
expect(billingSummary).not.toContain("costAvailable");
expect(detailSource).toContain('["请求阶段", requestKindText(log.requestKind)]');
expect(detailSource).toContain('["计费属性", log.billable ? "计费调用" : "不计费"]');
expect(detailSource).toContain('["积分计费", billingText(log)]');
expect(detailSource).toContain('["销售价格(积分)", billingText(log)]');
expect(detailSource).toContain('["上游成本", log.costAvailable');
expect(apiSource).toContain("billingAmountMicrocredits: number");
expect(apiSource).toContain("billingAvailable: boolean");
@@ -356,7 +343,7 @@ test("banner announcement editor keeps title styles through edit, save and statu
// emoji 面板:默认收起(Popover 点击触发),插入后不自动关闭,方便连续插入。
expect(emojiPickerSource).toContain('trigger="click"');
expect(emojiPickerSource).toContain('onPick(item.char)');
expect(emojiPickerSource).toContain("onPick(item.char)");
expect(noticeSource).toContain("BANNER_NOTICE_EMOJI_GROUPS");
expect(noticeSource).not.toContain("DEFAULT_ICON");

View File

@@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import type { AgentEvent, CreateAgentRunInput } from "../src/services/api/agent";
// Exercise the production protocol, isolating only transport/storage dependencies.
@@ -12,7 +13,7 @@ const requestPath = join(dir, "request.ts");
writeFileSync(requestPath, 'export const apiBaseURL = "https://agent.invalid/api"; export const http = { post: async () => { throw new Error("unexpected POST"); } };');
writeFileSync(join(dir, "agent.ts"), readFileSync(new URL("services/api/agent.ts", root), "utf8")
.replace('"@/services/api/request"', JSON.stringify(requestPath))
.replace('"@/services/api/task-text-stream"', JSON.stringify(new URL("services/api/task-text-stream.ts", root).pathname)));
.replace('"@/services/api/task-text-stream"', JSON.stringify(fileURLToPath(new URL("services/api/task-text-stream.ts", root)))));
const api: typeof import("../src/services/api/agent") = await import(join(dir, "agent.ts"));
const transport = await import(requestPath);
const storagePath = join(dir, "storage.ts");
@@ -21,7 +22,7 @@ writeFileSync(join(dir, "scope.ts"), 'export const getActiveUserScope = () => "t
writeFileSync(join(dir, "conversations.ts"), readFileSync(new URL("services/cloud-agent-conversations.ts", root), "utf8")
.replace('"@/lib/localforage-storage"', JSON.stringify(storagePath))
.replace('"@/lib/user-scope"', JSON.stringify(join(dir, "scope.ts")))
.replace('"@/lib/markdown-plain-text"', JSON.stringify(new URL("lib/markdown-plain-text.ts", root).pathname)));
.replace('"@/lib/markdown-plain-text"', JSON.stringify(fileURLToPath(new URL("lib/markdown-plain-text.ts", root)))));
const conversations: typeof import("../src/services/cloud-agent-conversations") = await import(join(dir, "conversations.ts"));
const storage = await import(storagePath);
const nativeFetch = globalThis.fetch;

View File

@@ -0,0 +1,49 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { AgentWelcome } from "@/components/canvas/canvas-agent-welcome";
import { AgentChatComposer } from "@/components/canvas/canvas-cloud-agent-chat-ui";
import { agentPermissionVisual } from "@/components/canvas/canvas-cloud-agent-settings";
import { canvasThemes } from "@/lib/canvas-theme";
const noop = () => {};
test("welcome uses the configured brand and escapes it as text", () => {
const html = renderToStaticMarkup(<AgentWelcome brandName="我的<工作室>" nodeCount={2} onChooseSkill={noop} onDraftPrompt={noop} />);
expect(html).toContain("在这里,和我的&lt;工作室&gt;让灵感,慢慢成形");
expect(html).toContain("2 个节点");
expect(html.match(/type="button"/g)).toHaveLength(3);
expect(html).not.toContain('disabled=""');
});
test("empty canvas disables only the canvas-analysis shortcut", () => {
const html = renderToStaticMarkup(<AgentWelcome brandName="影策" nodeCount={0} onChooseSkill={noop} onDraftPrompt={noop} />);
expect(html.match(/disabled=""/g)).toHaveLength(1);
expect(html).toContain("添加节点后,一起梳理创作思路");
expect(html).not.toContain("生成前由你确认");
});
test("permission modes have distinct icons, not just distinct colors", () => {
const icons = (["read_only", "request_approval", "auto"] as const).map((mode) => {
const Icon = agentPermissionVisual(mode).icon;
return renderToStaticMarkup(<Icon />);
});
expect(new Set(icons).size).toBe(3);
expect(icons[0]).toContain("lucide-lock-keyhole");
expect(icons[1]).toContain("lucide-shield-check");
expect(icons[2]).toContain("lucide-sparkles");
});
test("compact send button preserves empty-draft and sending guards", () => {
for (const [prompt, sending, disabled] of [
["", false, true],
["故事灵感", false, false],
["故事灵感", true, true],
] as const) {
const html = renderToStaticMarkup(<AgentChatComposer prompt={prompt} sending={sending} placeholder="聊聊想法" theme={canvasThemes.dark} onPromptChange={noop} onSubmit={noop} />);
const button = html.match(/<button\b[^>]*class="agent-composer-send [^"]*"[^>]*>/)?.[0];
expect(button).toBeDefined();
expect(button).toContain("size-7");
expect(button?.includes('disabled=""')).toBe(disabled);
expect(button).not.toContain("box-shadow:");
}
});

View File

@@ -0,0 +1,42 @@
import { expect, test } from "bun:test";
import type { AdminAnalytics, AnalyticsFinance } from "../src/services/api/auth";
import { analyticsFinanceColumns } from "../src/pages/admin/components/analytics-finance";
function renderFinanceCells(finance?: AnalyticsFinance | null) {
const row: AdminAnalytics["models"][number] = {
model: "example",
capability: "text",
tasks: 1,
requests: 1,
uniqueUsers: 1,
taskSuccessRate: 100,
requestSuccessRate: 100,
p50DurationMs: 100,
p95DurationMs: 100,
inputTokens: 10,
outputTokens: 10,
cachedTokens: 0,
usageAvailable: true,
mediaCount: 0,
videoSeconds: 0,
...(finance === undefined ? {} : { finance }),
};
return analyticsFinanceColumns.map((column) => ("render" in column ? column.render?.(undefined, row, 0) : undefined));
}
test("model finance cells tolerate missing and null backend finance", () => {
expect(renderFinanceCells()).toEqual(["--", "--", "--", "--"]);
expect(renderFinanceCells(null)).toEqual(["--", "--", "--", "--"]);
});
test("model finance cells preserve real zero values and unknown margins", () => {
expect(renderFinanceCells({ settledOrders: 1, costedOrders: 1, revenueMicrocredits: 0, costMicrocredits: 0, profitMicrocredits: 0, profitMargin: null })).toEqual(["0", "0", "0", "--"]);
});
test("model finance cells show partial costs without inventing profits", () => {
expect(renderFinanceCells({ settledOrders: 2, costedOrders: 1, revenueMicrocredits: 1500000, costMicrocredits: 250000, profitMicrocredits: null, profitMargin: null })).toEqual(["1.5", "0.25(部分)", "--", "--"]);
});
test("model finance cells display known losses", () => {
expect(renderFinanceCells({ settledOrders: 1, costedOrders: 1, revenueMicrocredits: 1000000, costMicrocredits: 1500000, profitMicrocredits: -500000, profitMargin: -50 })).toEqual(["1", "1.5", "-0.5", "-50.0%"]);
});

View File

@@ -6,7 +6,6 @@ test("initial HTML stays brand neutral until the public appearance is resolved",
const [html, mainSource] = await Promise.all([Bun.file(new URL("../index.html", import.meta.url)).text(), Bun.file(new URL("../src/main.tsx", import.meta.url)).text()]);
expect(html).not.toContain("影策");
expect(html).not.toContain("/logo.svg");
expect(html).toContain("<title>正在加载</title>");
expect(mainSource.indexOf("bootstrapAppearance()")).toBeLessThan(mainSource.indexOf('import("./application")'));
});
@@ -100,7 +99,7 @@ test("appearance management exposes light and dark logo uploads plus the frame s
expect(pageSource).toContain("深浅模式 Logo 预览");
expect(pageSource).toContain("登录页视频自动播放");
expect(pageSource).toContain("authVideoAutoplay");
expect(brandSource).toContain("useThemeStore");
expect(brandSource).toContain("useActiveTheme");
expect(brandSource).toContain("data-logo-frame-enabled");
expect(brandSource).toContain("failedSource === source");
expect(brandSource).toContain('aria-hidden="true"');

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
DEFAULT_CANVAS_BACKGROUND_MODE,
@@ -14,8 +14,10 @@ import {
} from "../src/lib/canvas/canvas-appearance";
const values = new Map<string, string>();
let originalWindow: PropertyDescriptor | undefined;
beforeEach(() => {
originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
values.clear();
Object.defineProperty(globalThis, "window", {
configurable: true,
@@ -29,6 +31,11 @@ beforeEach(() => {
});
});
afterEach(() => {
if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow);
else Reflect.deleteProperty(globalThis, "window");
});
describe("canvas custom appearance", () => {
test("uses point grid as the default for new canvases", () => {
expect(DEFAULT_CANVAS_BACKGROUND_MODE).toBe("dots");

View File

@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test";
import { filterCanvasDisplayConnections } from "@/lib/canvas/canvas-connection-visibility";
import { CanvasNodeType, type CanvasDisplayConnection } from "@/types/canvas";
function connection(id: string, fromNodeId: string, toNodeId: string): CanvasDisplayConnection {
const node = (nodeId: string) => ({ id: nodeId, type: CanvasNodeType.Text, title: nodeId, position: { x: 0, y: 0 }, width: 100, height: 80, metadata: {} });
return { connection: { id, fromNodeId, toNodeId }, from: node(fromNodeId), to: node(toNodeId) };
}
describe("canvas connection visibility", () => {
const connections = [connection("a", "one", "two"), connection("b", "two", "three"), connection("c", "four", "five")];
test("keeps all connections when disabled", () => {
expect(filterCanvasDisplayConnections(connections, { enabled: false })).toEqual(connections);
});
test("shows only direct connections for hovered and selected nodes", () => {
expect(filterCanvasDisplayConnections(connections, { enabled: true, hoveredNodeId: "three", selectedNodeIds: new Set(["one"]) }).map((item) => item.connection.id)).toEqual(["a", "b"]);
});
test("keeps an explicitly selected connection visible", () => {
expect(filterCanvasDisplayConnections(connections, { enabled: true, selectedConnectionId: "c" }).map((item) => item.connection.id)).toEqual(["c"]);
});
});

View File

@@ -0,0 +1,32 @@
import { expect, test } from "bun:test";
test("画布文件上传占位样式由组件独立加载", async () => {
const [component, globals, styles] = await Promise.all([
Bun.file(new URL("../src/components/canvas/canvas-file-upload-content.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/styles/globals.css", import.meta.url)).text(),
Bun.file(new URL("../src/components/canvas/canvas-file-upload.css", import.meta.url)).text(),
]);
expect(component).toContain('import "./canvas-file-upload.css";');
expect(globals).not.toContain(".canvas-file-upload {");
expect(globals).not.toContain("@keyframes canvas-file-upload-drift");
expect(styles).toContain(".canvas-file-upload {");
expect(styles).toContain(".canvas-file-upload-retry:focus-visible");
expect(styles).toContain("@keyframes canvas-file-upload-drift");
expect(styles).toContain("@media (prefers-reduced-motion: reduce)");
});
test("React Aria 浮层动画保留全局入口,但不再占用 globals.css", async () => {
const [application, globals, styles] = await Promise.all([
Bun.file(new URL("../src/application.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/styles/globals.css", import.meta.url)).text(),
Bun.file(new URL("../src/styles/shared/overlays.css", import.meta.url)).text(),
]);
expect(application).toContain('import "./styles/shared/overlays.css";');
expect(globals).not.toContain("@keyframes ra-pop-in");
expect(globals).not.toContain(".ra-pop-in {");
expect(styles).toContain("@keyframes ra-pop-in");
expect(styles).toContain('.ra-pop-in[data-placement^="bottom"]');
expect(styles).toContain("prefers-reduced-motion: reduce");
});

View File

@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-policy";
import { assertCanvasImageReferenceLimit, buildGenerationConfig, canvasImageReferenceLimitError, resolveCanvasGenerationModel } from "../src/lib/canvas/canvas-project-generation";
import { readNodeGenerationSpec, synchronizeGenerationSpec } from "../src/lib/canvas/generation-contract";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import { groupModelsByDisplayName, inferVideoOperation, modelCompatibilityError, modelGroupReferenceLimits, modelPromptLengthError, resolveCompatibleModel, resolveModelGenerationDefaults, resolveModelVideoBooleanOptions } from "../src/lib/model-selection";
import { defaultConfig, normalizeModelOptionValue, type AiConfig, type ModelChannel } from "../src/stores/use-config-store";
@@ -53,6 +54,87 @@ function node(id: string, type: CanvasNodeType, generationMode?: "image" | "vide
}
describe("逻辑模型选择", () => {
test("generationSpec 的显式 false 和 0 覆盖陈旧镜像及全局默认", () => {
const config = policyConfig();
config.videoSeconds = "9";
config.videoGenerateAudio = "true";
config.videoWatermark = "true";
const videoNode: CanvasNodeData = {
...node("video", CanvasNodeType.Video),
metadata: {
model: "relay::cinema-image",
seconds: "15",
generateAudio: "true",
watermark: "true",
generationSpec: {
version: 1,
mode: "video",
prompt: "canonical prompt",
modelSelection: { kind: "channel", channelId: "relay", modelKey: "cinema-text" },
options: { durationSeconds: 0, generateAudio: false, watermark: false },
referenceBindings: [],
textInputMode: "prompt-only",
},
},
};
const generation = buildGenerationConfig(config, videoNode, "video");
expect(generation.model).toBe("relay::cinema-text");
expect(generation.videoSeconds).toBe("1");
expect(generation.videoGenerateAudio).toBe("false");
expect(generation.videoWatermark).toBe("false");
});
test("编辑兼容字段时原子更新 canonical 合同和全部镜像", () => {
const original: CanvasNodeData = {
...node("video", CanvasNodeType.Video),
metadata: {
seconds: "6",
generateAudio: "false",
generationSpec: {
version: 1,
mode: "video",
prompt: "before",
options: { durationSeconds: 6, generateAudio: false },
referenceBindings: [],
textInputMode: "prompt-only",
},
},
};
const edited = synchronizeGenerationSpec(original, { seconds: "9", generateAudio: "true", composerContent: "after" });
const spec = readNodeGenerationSpec(edited);
expect(spec?.prompt).toBe("after");
expect(spec?.options.durationSeconds).toBe(9);
expect(spec?.options.generateAudio).toBe(true);
expect(edited.metadata?.seconds).toBe("9");
expect(edited.metadata?.generateAudio).toBe("true");
});
test("逻辑模型合同不会被旧 metadata.model 覆盖", () => {
const config = policyConfig();
config.channels[0]!.modelCosts![0]!.logicalModelId = "cinema-logical";
const videoNode: CanvasNodeData = {
...node("video", CanvasNodeType.Video),
metadata: {
model: "relay::cinema-image",
generationSpec: {
version: 1,
mode: "video",
prompt: "logical selection",
modelSelection: { kind: "logical", logicalModelId: "cinema-logical" },
options: {},
referenceBindings: [],
textInputMode: "prompt-only",
},
},
};
expect(buildGenerationConfig(config, videoNode, "video").model).toBe("relay::cinema-text");
});
test("技能上下文展开后按最终视频模型字符上限拒绝提交", () => {
const config = policyConfig();
const model = config.videoModels[0]!;

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import { buildNodeGenerationContext } from "../src/components/canvas/canvas-node-generation";
import { buildCanvasResourceReferences } from "../src/lib/canvas/canvas-resource-references";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
function node(id: string, type: CanvasNodeType, content: string): CanvasNodeData {
@@ -32,6 +33,28 @@ function connection(fromNodeId: string): CanvasConnection {
}
describe("canvas node generation position mentions", () => {
test("Agent 保存的素材引用块在编辑器和再次提交时保持相同编号", () => {
const target = targetNode();
const character = node("character", CanvasNodeType.Image, "data:image/png;base64,a");
const wig = node("wig", CanvasNodeType.Image, "data:image/png;base64,b");
const voice = node("voice", CanvasNodeType.Audio, "data:audio/mpeg;base64,c");
const note = node("note", CanvasNodeType.Markdown, "导演方案");
const prompt = "一镜到底\n\n【资产参考】\n人物:@图片1\n假发:@图片2\n声音:@音频1";
target.metadata = { composerContent: prompt, prompt, referenceNodeIds: [character.id, voice.id, wig.id] };
const nodes = [target, wig, voice, note, character];
const connections = [character, voice, wig, note].map((source) => connection(source.id));
const references = buildCanvasResourceReferences(nodes, connections, target.id).filter((reference) => reference.active);
expect(Object.fromEntries(references.map((reference) => [reference.nodeId, reference.label]))).toEqual({
character: "图片1", wig: "图片2", voice: "音频1", note: "文本1",
});
const context = buildNodeGenerationContext(target.id, nodes, connections, prompt, [], true);
expect(context.referenceImages.map((reference) => reference.id)).toEqual([character.id, wig.id]);
expect(context.referenceAudios.map((reference) => reference.id)).toEqual([voice.id]);
expect(context.prompt).toBe(prompt);
expect(context.textCount).toBe(0);
});
test("已有图片节点显式引用自身时作为图生图参考图提交", () => {
const source = node("image-self", CanvasNodeType.Image, "data:image/png;base64,a");
source.metadata.composerContent = "将 @图片1 图片变清晰";

View File

@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import { canvasTextFontSize } from "@/lib/canvas/canvas-text-scale";
describe("canvas text font scaling", () => {
test("keeps the configured base size at the default node size", () => {
expect(canvasTextFontSize(340, 240, 14)).toBe(14);
});
test("scales proportionally when the node grows in both directions", () => {
expect(canvasTextFontSize(680, 480, 14)).toBeCloseTo(28);
});
test("responds when only one dimension changes", () => {
expect(canvasTextFontSize(680, 240, 14)).toBeCloseTo(14 * Math.sqrt(2));
});
test("uses metadata font size as the base and clamps extremes", () => {
expect(canvasTextFontSize(340, 240, 20)).toBe(20);
expect(canvasTextFontSize(1, 1, 2)).toBe(8);
expect(canvasTextFontSize(10_000, 10_000, 20)).toBe(72);
});
});

View File

@@ -27,6 +27,24 @@ function createMainContext(canvasTool: "move" | "box-select" = "box-select"): To
}
describe("canvas toolbar mode switch", () => {
test.each(["default", "compact"] as const)("renders %s canvas modes as an icon-only sliding switch", (size) => {
const entries = resolveToolbarEntries("main", createMainContext(), null);
const modeSwitch = entries.find((entry) => entry.kind === "switch");
if (!modeSwitch) throw new Error("Missing canvas mode switch");
const html = renderToStaticMarkup(<FloatingDock size={size} items={[modeSwitch]} />);
const diameter = size === "compact" ? 24 : 26;
expect(html).toContain(`width:${diameter}px;height:${diameter}px`);
expect(html.match(/aceternity-dock-switch-thumb/g)).toHaveLength(1);
expect(html).toContain("absolute top-1/2 rounded-full");
expect(html).toContain("grid size-4 shrink-0 place-items-center");
expect(html).not.toContain("is-labeled");
expect(html).not.toContain("选择</span>");
expect(html).not.toContain("移动</span>");
expect(html).toContain('aria-label="区域选择"');
expect(html).toContain('aria-label="抓手工具"');
});
test("renders grab and box-select as one dock switch", () => {
const entries = resolveToolbarEntries("main", createMainContext("box-select"), null);
const modeSwitch = entries.find((entry) => entry.kind === "switch" && entry.id === CANVAS_MODE_TOOL_ID);

View File

@@ -83,11 +83,12 @@ describe("pricing write validation", () => {
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();
test("rejects removed video conditions and accepts visible operation or resolution matches", () => {
for (const hiddenCondition of [{ videoGenerateAudio: "false" }, { videoSeconds: 5 }, { imageCount: 2 }]) {
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), billingMode: "token", ...hiddenCondition }] })).toThrow("匹配条件");
}
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), videoGenerateAudio: "yes" }] })).toThrow("有声或无声");
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), operation: "image_to_video" }] })).not.toThrow();
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), resolution: "1080p" }] })).not.toThrow();
});
});

View File

@@ -70,21 +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" });
expect(priceTierToForm({ ...base, selector: { videoGenerateAudio: "false" } })).toMatchObject({ matchMode: "default", videoGenerateAudio: "false" });
});
test("preserves explicit silent and audible selectors but omits the wildcard", () => {
test("keeps video matching limited to operation and resolution", () => {
for (const videoGenerateAudio of ["true", "false"]) {
const tier = { ...defaultPriceTier("advanced"), videoGenerateAudio };
expect(skuSelectorFromForm("video", tier)).toEqual({ videoGenerateAudio });
expect(skuSelectorFromForm("image", tier)).toEqual({});
const tier = { ...defaultPriceTier("advanced"), operation: "image_to_video", resolution: "1080p", videoSeconds: 10, videoGenerateAudio, imageCount: 2 };
expect(skuSelectorFromForm("video", tier)).toEqual({ operation: "image_to_video", vquality: "1080p" });
expect(priceTierVideoSecondsFromForm("video", tier)).toBe(0);
expect(skuSelectorFromForm("image", { ...defaultPriceTier("advanced"), videoSeconds: 10, videoGenerateAudio, imageCount: 2 })).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("video", tier, "seedance")).toMatchObject({ selector: {}, videoSeconds: 0, 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,29 @@
import { expect, test } from "bun:test";
const html = await Bun.file(new URL("../index.html", import.meta.url)).text();
const layerOrder = "@layer theme, base, components, utilities;";
test("HTML 在任何外部样式或脚本之前固定 CSS 层顺序", () => {
const firstStyle = html.match(/<style\b[^>]*>([\s\S]*?)<\/style>/i);
expect(firstStyle).not.toBeNull();
const css = firstStyle![1].replace(/\/\*[\s\S]*?\*\//g, "").trim();
expect(css.startsWith(layerOrder)).toBe(true);
const firstLoad = html.search(/<script\b|<link\b[^>]*\brel=["'](?:stylesheet|modulepreload)["']/i);
expect(firstLoad).toBeGreaterThan(html.indexOf(layerOrder));
});
test("入口层顺序与 Tailwind 一致,不依赖插件和全局 CSS 的加载先后", async () => {
const tailwind = await Bun.file(new URL("../node_modules/tailwindcss/index.css", import.meta.url)).text();
const editor = await Bun.file(new URL("../src/lib/plugins/builtin/editor/editor-shell.css", import.meta.url)).text();
const firstStyle = html.match(/<style\b[^>]*>([\s\S]*?)<\/style>/i)![1];
expect(tailwind.trimStart().startsWith(layerOrder)).toBe(true);
expect(editor).toContain("@layer utilities {");
// CSS layers retain their first declared order, even when a later chunk repeats them.
const layersIn = (css: string) => [...new Set([...css.matchAll(/@layer\s+([^;{]+)[;{]/g)].flatMap((match) => match[1].split(",").map((name) => name.trim())))];
expect(layersIn(editor + tailwind).indexOf("utilities")).toBeLessThan(layersIn(editor + tailwind).indexOf("base"));
for (const chunks of [editor + tailwind, tailwind + editor]) {
expect(layersIn(firstStyle + chunks)).toEqual(["theme", "base", "components", "utilities"]);
}
});

View File

@@ -0,0 +1,27 @@
import { expect, test } from "bun:test";
test("ModelPicker 样式独立加载,并保留模型列表的视口边界", async () => {
const [application, globals, pickerStyles] = await Promise.all([
Bun.file(new URL("../src/application.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/styles/globals.css", import.meta.url)).text(),
Bun.file(new URL("../src/styles/shared/model-picker.css", import.meta.url)).text(),
]);
expect(application).toContain('import "./styles/shared/model-picker.css";');
expect(globals).not.toContain("canvas-model-picker");
expect(pickerStyles).toContain(".canvas-model-picker-menu {");
expect(pickerStyles).toContain("max-height: min(420px, calc(100vh - 32px));");
const creationMenu = pickerStyles.match(/\.creation-model-picker-menu \{([\s\S]*?)\}/)?.[1] || "";
expect(creationMenu).toContain("max-height: min(460px, calc(100vh - 24px));");
expect(creationMenu).toContain("overflow-y: auto;");
expect(pickerStyles).toContain(".app-user-workspace .creation-model-picker-menu.is-brand-list");
expect(pickerStyles).toContain(".creation-model-picker-surface .creation-model-picker-menu.is-model-list");
expect(pickerStyles).toContain(".creation-model-picker-surface .creation-model-picker-menu.is-brand-list");
const modelList = pickerStyles.match(/\.creation-model-picker-surface \.creation-model-picker-menu\.is-model-list \{([\s\S]*?)\}/)?.[1] || "";
expect(modelList).toContain("max-height: min(460px, calc(100vh - 24px)) !important;");
expect(modelList).toContain("overflow-y: auto !important;");
expect(pickerStyles).not.toContain(".app-user-workspace .creation-model-picker-menu {");
expect(pickerStyles).not.toContain(".creation-model-picker-surface .creation-model-picker-menu {");
});

View File

@@ -0,0 +1,43 @@
import { expect, test } from "bun:test";
import { registerPlugin, unregisterPlugin } from "../src/lib/plugins/plugin-registry";
import type { PluginManifest } from "../src/lib/plugins/plugin-types";
test("plugin upload caller preserves the installation promise", async () => {
const page = await Bun.file(new URL("../src/pages/admin/plugins/plugins-page.tsx", import.meta.url)).text();
expect(page).toContain("onUpload={upload}");
expect(page).not.toContain("onUpload={(file) => void upload(file)}");
});
test("plugin upload guards concurrent events and exposes installation failures", async () => {
const modal = await Bun.file(new URL("../src/pages/plugins/plugin-documentation-modals.tsx", import.meta.url)).text();
expect(modal).toContain("if (uploadInFlight.current) return;");
expect(modal.indexOf("uploadInFlight.current = true")).toBeLessThan(modal.indexOf("await onUpload(file)"));
expect(modal).toContain("uploadInFlight.current = false");
expect(modal).toContain("closable={!uploading}");
expect(modal).toContain('message.error(error instanceof Error ? error.message : "安装插件失败")');
expect(modal).toContain('aria-pressed={activeTab === "install"}');
expect(modal).toContain('aria-pressed={activeTab === "guide"}');
expect(modal).toContain("if (!navigator.clipboard) throw new Error");
expect(modal).toContain("clearTimeout(copyTimer.current)");
});
test("copied guide manifest is accepted by the real plugin registry", async () => {
const guide = await Bun.file(new URL("../src/pages/plugins/plugin-development-guide.md", import.meta.url)).text();
const example = guide.match(/```json\s*([\s\S]*?)```/)?.[1].trim();
expect(example).toBeTruthy();
const manifest = { ...(JSON.parse(example!) as PluginManifest), id: "merge-test-manifest-example" };
try {
expect(() => registerPlugin({ manifest })).not.toThrow();
} finally {
unregisterPlugin(manifest.id);
}
});
test("reduced motion preserves modal completion events", async () => {
const css = await Bun.file(new URL("../src/pages/admin/theme/admin-chrome.css", import.meta.url)).text();
const reducedMotion = css.slice(css.lastIndexOf("@media (prefers-reduced-motion: reduce)"));
expect(reducedMotion).toContain("animation-duration: 0.01ms !important");
expect(reducedMotion).toContain("transition-duration: 0.01ms !important");
expect(reducedMotion).not.toContain("animation: none");
expect(reducedMotion).not.toContain("transition: none");
});

View File

@@ -0,0 +1,32 @@
import { expect, test } from "bun:test";
test("滚动条样式按复用边界拆分,并保持全局入口可用", async () => {
const [application, globals, shared, creation, creationWorkspace, editor, editorShell] = await Promise.all([
Bun.file(new URL("../src/application.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/styles/globals.css", import.meta.url)).text(),
Bun.file(new URL("../src/styles/shared/scrollbars.css", import.meta.url)).text(),
Bun.file(new URL("../src/pages/create/creation-scrollbars.css", import.meta.url)).text(),
Bun.file(new URL("../src/pages/create/creation-workspace.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/lib/plugins/builtin/editor/editor-shell.css", import.meta.url)).text(),
Bun.file(new URL("../src/lib/plugins/builtin/editor/editor-shell.tsx", import.meta.url)).text(),
]);
expect(application).toContain('import "./styles/shared/scrollbars.css";');
expect(globals).not.toContain(".hide-scrollbar {");
expect(globals).not.toContain(".thin-scrollbar {");
expect(globals).not.toContain(".storyboard-scrollbar {");
expect(globals).not.toContain(".hover-scrollbar {");
expect(globals).not.toContain(".creation-scrollbar {");
expect(globals).not.toContain(".director-scroll {");
expect(globals).not.toContain(".editor-slider {");
expect(shared).toContain(".hide-scrollbar {");
expect(shared).toContain(".thin-scrollbar {");
expect(shared).toContain(".storyboard-scrollbar {");
expect(shared).toContain(".hover-scrollbar {");
expect(creation).toContain(".creation-scrollbar {");
expect(creationWorkspace).toContain('import "./creation-scrollbars.css";');
expect(editor).toContain(".director-scroll {");
expect(editor).toContain(".editor-slider {");
expect(editorShell).toContain('import "./editor-shell.css";');
});

View File

@@ -161,7 +161,7 @@ describe("site appearance and editable skin library", () => {
expect(globalStyles).toContain("--ant-tooltip-overlay-color: var(--popover-foreground) !important");
expect(globalStyles).toContain(":where(.ant-tooltip-container, .ant-tooltip-inner)");
expect(globalStyles).toContain("color: var(--popover-foreground) !important");
expect(adminStyles).toContain("--admin-status-warning: var(--palette-status-warning)");
expect(adminStyles).toContain("var(--admin-status-warning)");
expect(adminStyles).toContain("border-radius: var(--menu-radius);");
});
});

View File

@@ -49,7 +49,6 @@ describe("task cancellation policy", () => {
expect(page).not.toContain("cancelGenerationTask");
expect(page).not.toContain('runAction(detailTask.id, "cancel")');
expect(page).toContain('if (task.status === "queued" || task.status === "running")');
expect(page).toContain("任务正在执行,不能删除本机记录");
});
test("batch stop only applies to items still waiting locally", () => {

View File

@@ -0,0 +1,92 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { ConfigProvider, Select } from "antd";
import { useContext } from "react";
import { CollectionToolbar } from "@/components/layout/collection-toolbar";
import { getWorkspaceAntThemeConfig } from "@/lib/app-theme";
import { assetGridCardMinWidth, assetGridDensityOptions, parseAssetGridDensity } from "@/pages/assets/asset-grid-density";
test("density restores all saved choices and defaults invalid preferences to standard", () => {
for (const value of [6, 8, 10] as const) {
expect(parseAssetGridDensity(String(value))).toBe(value);
expect(parseAssetGridDensity(value)).toBe(value);
}
for (const value of [null, undefined, "", "broken", "12"]) {
expect(parseAssetGridDensity(value)).toBe(8);
}
});
test("each density has a distinct usable card size at normal library widths", () => {
expect(assetGridDensityOptions.map(({ value }) => value)).toEqual([6, 8, 10]);
const widths = [6, 8, 10].map((density) => assetGridCardMinWidth[parseAssetGridDensity(density)]);
expect(widths).toEqual([272, 208, 160]);
// CSS auto-fill: n columns fit when n * minimum + (n - 1) * gap <= width.
for (const available of [720, 900, 1100]) {
const columns = widths.map((minimum) => Math.floor((available + 16) / (minimum + 16)));
expect(columns[0]).toBeLessThan(columns[1]);
expect(columns[1]).toBeLessThan(columns[2]);
}
});
test("real collection toolbar supplies filled selects without changing their labels or value", () => {
function PopupConfigProbe() {
const { select } = useContext(ConfigProvider.ConfigContext);
return <span>{JSON.stringify(select?.classNames)}</span>;
}
const html = renderToStaticMarkup(
<CollectionToolbar>
<Select aria-label="素材显示密度" value={10} options={assetGridDensityOptions} />
<PopupConfigProbe />
</CollectionToolbar>,
);
expect(html).toContain("ant-select-filled");
expect(html).toContain("素材显示密度");
expect(html).toContain("紧凑");
expect(html).toContain('role="combobox"');
expect(html).toContain("workspace-quiet-popup");
expect(html).toContain('data-input-modality="keyboard"');
});
test("collection select focus uses a thin keyboard ring, not a persistent pointer outline", () => {
const css = readFileSync(new URL("../src/styles/workspace-product.css", import.meta.url), "utf8");
const toolbar = readFileSync(new URL("../src/components/layout/collection-toolbar.tsx", import.meta.url), "utf8");
expect(css).toContain(".collection-toolbar .ant-select {\n outline: none !important;");
expect(css).toContain('[data-input-modality="keyboard"] .ant-select:not(.ant-select-disabled):focus-within');
expect(css).toContain("outline: 1px solid var(--control-focus-ring) !important;");
expect(toolbar).toContain('onPointerDownCapture={() => setInputModality("pointer")}');
expect(toolbar).toContain("onKeyDownCapture=");
expect(toolbar).toContain("event.currentTarget.contains(event.relatedTarget)");
expect(toolbar).not.toContain(".blur()");
});
test("workspace menu tokens are shadow-free and use theme-aware surfaces", () => {
const { components } = getWorkspaceAntThemeConfig();
for (const name of ["Select", "Dropdown", "Popover"] as const) {
expect(components?.[name]?.boxShadowSecondary).toBe("none");
expect(components?.[name]?.colorBgElevated).toBe("var(--user-surface-raised)");
}
expect(components?.Select?.optionSelectedBg).toBe("var(--user-surface-hover)");
});
test("real grid consumes density rather than the old fixed column calculation", () => {
const page = readFileSync(new URL("../src/pages/assets/index.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/styles/workspace-product.css", import.meta.url), "utf8");
const globals = readFileSync(new URL("../src/styles/globals.css", import.meta.url), "utf8");
expect(page).toContain('"--collection-grid-min-width": `${assetGridCardMinWidth[gridDensity]}px`');
expect(css).toContain("minmax(min(100%, var(--collection-grid-min-width, 224px)), 1fr)");
expect(globals).not.toContain("--assets-grid-columns");
expect(css).not.toContain(".assets-library-page .assets-library-grid { grid-template-columns: repeat(2");
});
test("menu surfaces are explicitly scoped and old account inner overrides are removed", () => {
const css = readFileSync(new URL("../src/styles/workspace-menus.css", import.meta.url), "utf8");
const globals = readFileSync(new URL("../src/styles/globals.css", import.meta.url), "utf8");
expect(css).toContain("@layer utilities");
expect(css).toContain(".workspace-account-popover .ant-popover-container");
expect(css).toContain("border: 0 !important");
expect(css).toContain("box-shadow: none !important");
expect(css).not.toContain(".ant-modal");
expect(globals).not.toContain(".workspace-account-popover .ant-popover-inner");
expect(globals).toContain(".ant-select:has(input:focus-visible)");
});