merge: official v1.5.6 with multimodal storyboard enhancements
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
49
web/test/agent-panel-presentation.test.tsx
Normal file
49
web/test/agent-panel-presentation.test.tsx
Normal 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("在这里,和我的<工作室>让灵感,慢慢成形");
|
||||
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:");
|
||||
}
|
||||
});
|
||||
42
web/test/analytics-finance.test.ts
Normal file
42
web/test/analytics-finance.test.ts
Normal 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%"]);
|
||||
});
|
||||
@@ -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"');
|
||||
|
||||
@@ -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");
|
||||
|
||||
25
web/test/canvas-connection-visibility.test.ts
Normal file
25
web/test/canvas-connection-visibility.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
32
web/test/canvas-file-upload-style.test.ts
Normal file
32
web/test/canvas-file-upload-style.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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]!;
|
||||
|
||||
@@ -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 图片变清晰";
|
||||
|
||||
23
web/test/canvas-text-scale.test.ts
Normal file
23
web/test/canvas-text-scale.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
29
web/test/css-layer-order.test.ts
Normal file
29
web/test/css-layer-order.test.ts
Normal 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"]);
|
||||
}
|
||||
});
|
||||
27
web/test/model-picker-style-source.test.ts
Normal file
27
web/test/model-picker-style-source.test.ts
Normal 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 {");
|
||||
});
|
||||
43
web/test/plugin-upload-merge.test.ts
Normal file
43
web/test/plugin-upload-merge.test.ts
Normal 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");
|
||||
});
|
||||
32
web/test/scrollbar-style-source.test.ts
Normal file
32
web/test/scrollbar-style-source.test.ts
Normal 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";');
|
||||
});
|
||||
@@ -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);");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
92
web/test/workspace-menu-density.test.tsx
Normal file
92
web/test/workspace-menu-density.test.tsx
Normal 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)");
|
||||
});
|
||||
Reference in New Issue
Block a user