feat(*): 画布创作体验 - 完善 Agent 引导、生成防重、搜索快捷键、素材多选与分辨率兼容
This commit is contained in:
1 parent
913cf4b304
commit
ab89c05362
52 files changed
+2344
-500
No files matched your search
@@ -0,0 +1,75 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { assetPickerItemsToInsertPayloads } from "@/components/canvas/asset-picker-modal";
|
||||
import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import type { ImageAsset, TextAsset } from "@/stores/use-asset-store";
|
||||
|
||||
const timestamp = "2026-08-28T00:00:00.000Z";
|
||||
|
||||
describe("canvas asset picker", () => {
|
||||
test("maps multiple local and external selections in the requested order", () => {
|
||||
const image: ImageAsset = {
|
||||
id: "image-1",
|
||||
kind: "image",
|
||||
title: "场景图",
|
||||
coverUrl: "",
|
||||
tags: [],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
data: { dataUrl: "data:image/png;base64,AAAA", storageKey: "resource:image-1", width: 1280, height: 720, bytes: 1024, mimeType: "image/png" },
|
||||
};
|
||||
const text: TextAsset = {
|
||||
id: "text-1",
|
||||
kind: "text",
|
||||
title: "旁白",
|
||||
coverUrl: "",
|
||||
tags: [],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
data: { content: "夜色渐深。" },
|
||||
};
|
||||
const items: AssetLibraryPickerItem[] = [
|
||||
{ id: image.id, title: image.title, category: "environment", kindLabel: "图片", asset: image },
|
||||
{ id: text.id, title: text.title, category: "other", kindLabel: "文本", asset: text },
|
||||
{
|
||||
id: "external-video",
|
||||
title: "外部视频",
|
||||
category: "external:eagle",
|
||||
kindLabel: "视频",
|
||||
external: {
|
||||
sourceId: "eagle",
|
||||
sourceName: "Eagle",
|
||||
item: { id: "video-1", title: "外部视频", kind: "video", fileUrl: "http://127.0.0.1/video.mp4", width: 1920, height: 1080, bytes: 2048, mimeType: "video/mp4" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(assetPickerItemsToInsertPayloads(["external-video", image.id, text.id], items)).toEqual([
|
||||
{ kind: "video", url: "http://127.0.0.1/video.mp4", title: "外部视频", width: 1920, height: 1080, bytes: 2048, mimeType: "video/mp4", assetId: "external:eagle:video-1" },
|
||||
{ kind: "image", dataUrl: "data:image/png;base64,AAAA", storageKey: "resource:image-1", title: "场景图", width: 1280, height: 720, bytes: 1024, mimeType: "image/png", assetId: "image-1" },
|
||||
{ kind: "text", content: "夜色渐深。", title: "旁白", assetId: "text-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects stale selections instead of silently dropping them", () => {
|
||||
expect(() => assetPickerItemsToInsertPayloads(["missing"], [])).toThrow("所选素材已不存在");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas context menu motion", () => {
|
||||
test("keeps hover feedback stationary while retaining spotlight surfaces", () => {
|
||||
const styles = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
|
||||
const menu = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-context-menu.tsx"), "utf8");
|
||||
const menuRule = styles.match(/\.canvas-menu-item\s*\{([\s\S]*?)\}/)?.[1] || "";
|
||||
|
||||
expect(menuRule).not.toContain("transform");
|
||||
expect(styles).not.toContain(".canvas-menu-item:not(:disabled):hover {");
|
||||
expect(styles).not.toContain(".canvas-menu-item:not(:disabled):active");
|
||||
expect(menu).not.toContain("group-hover:translate-x");
|
||||
expect(menu).not.toContain("scale: 0.97");
|
||||
expect(menu).toContain("<SpotlightSurface");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce } from "@/lib/canvas/canvas-generation-submission";
|
||||
|
||||
function fingerprint(overrides: Partial<Parameters<typeof canvasGenerationRequestFingerprint>[0]> = {}) {
|
||||
return canvasGenerationRequestFingerprint({
|
||||
nodeId: "node-1",
|
||||
mode: "video",
|
||||
prompt: "夜晚的城市",
|
||||
model: "video-model",
|
||||
options: { size: "16:9", videoSeconds: 10, vquality: "768p" },
|
||||
context: {
|
||||
referenceImages: [{ id: "image-1", name: "reference.png", type: "image/png", dataUrl: "", storageKey: "resource:image-1" }],
|
||||
referenceVideos: [],
|
||||
referenceAudios: [],
|
||||
characterReferences: [],
|
||||
resolvedCharacterVersions: [],
|
||||
resolvedCharacterVoices: [],
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("canvas generation submission", () => {
|
||||
test("同一节点的并发提交只执行一次", async () => {
|
||||
const locks = new Map<string, Promise<unknown>>();
|
||||
let executions = 0;
|
||||
let duplicates = 0;
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const run = () =>
|
||||
runCanvasGenerationSubmissionOnce(
|
||||
locks,
|
||||
"node-1",
|
||||
async () => {
|
||||
executions += 1;
|
||||
await pending;
|
||||
return "done";
|
||||
},
|
||||
() => {
|
||||
duplicates += 1;
|
||||
},
|
||||
);
|
||||
|
||||
const submissions = [run(), run(), run()];
|
||||
await Promise.resolve();
|
||||
expect(executions).toBe(1);
|
||||
expect(duplicates).toBe(2);
|
||||
release();
|
||||
expect(await Promise.all(submissions)).toEqual(["done", "done", "done"]);
|
||||
|
||||
await run();
|
||||
expect(executions).toBe(2);
|
||||
});
|
||||
|
||||
test("相同输入生成稳定指纹,关键内容变化会改变指纹", () => {
|
||||
expect(fingerprint({ options: { vquality: "768p", videoSeconds: 10, size: "16:9" } })).toBe(fingerprint());
|
||||
expect(fingerprint({ prompt: "白天的城市" })).not.toBe(fingerprint());
|
||||
expect(fingerprint({ model: "another-model" })).not.toBe(fingerprint());
|
||||
expect(fingerprint({ options: { size: "9:16", videoSeconds: 10, vquality: "768p" } })).not.toBe(fingerprint());
|
||||
expect(
|
||||
fingerprint({
|
||||
context: {
|
||||
referenceImages: [{ id: "image-2", name: "other.png", type: "image/png", dataUrl: "", storageKey: "resource:image-2" }],
|
||||
referenceVideos: [],
|
||||
referenceAudios: [],
|
||||
characterReferences: [],
|
||||
resolvedCharacterVersions: [],
|
||||
resolvedCharacterVoices: [],
|
||||
},
|
||||
}),
|
||||
).not.toBe(fingerprint());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import { hasCanvasTextSelection } from "../src/pages/canvas/use-canvas-keyboard";
|
||||
|
||||
test("Canvas copy shortcut yields to a real browser text selection", () => {
|
||||
expect(hasCanvasTextSelection(null)).toBe(false);
|
||||
expect(hasCanvasTextSelection({ isCollapsed: true, rangeCount: 1, toString: () => "Agent 文本" })).toBe(false);
|
||||
expect(hasCanvasTextSelection({ isCollapsed: false, rangeCount: 0, toString: () => "Agent 文本" })).toBe(false);
|
||||
expect(hasCanvasTextSelection({ isCollapsed: false, rangeCount: 1, toString: () => "" })).toBe(false);
|
||||
expect(hasCanvasTextSelection({ isCollapsed: false, rangeCount: 1, toString: () => "Agent 文本" })).toBe(true);
|
||||
});
|
||||
|
||||
test("Canvas keyboard keeps node copy as the fallback when no text is selected", async () => {
|
||||
const source = await Bun.file(new URL("../src/pages/canvas/use-canvas-keyboard.ts", import.meta.url)).text();
|
||||
expect(source).toContain("if (hasCanvasTextSelection(window.getSelection())) return;");
|
||||
expect(source).toContain("event.preventDefault();\n copySelectedNodes();");
|
||||
});
|
||||
@@ -230,6 +230,27 @@ test("Canvas launch mode auto-connects after rejected legacy deep-link secrets a
|
||||
expect(shouldAutoConnect(new URLSearchParams("agentUrl=http://127.0.0.1:17371"))).toBe(false);
|
||||
});
|
||||
|
||||
test("Canvas local Agent setup generates exact-origin commands for Unix and Windows", async () => {
|
||||
const module = await import("../src/lib/canvas/local-agent-setup");
|
||||
const unix = module.buildLocalAgentSetupCommands("https://ddcat.pronhubcn.com", "unix");
|
||||
const windows = module.buildLocalAgentSetupCommands("https://ddcat.pronhubcn.com", "windows");
|
||||
|
||||
expect(unix.install).toContain("git clone https://github.com/ddcat-ai/open-ai-canvas.git");
|
||||
expect(unix.start).toContain("FRAMEFIELD_TRUSTED_WEB_ORIGINS='https://ddcat.pronhubcn.com'");
|
||||
expect(unix.start).toContain("node dist/index.js");
|
||||
expect(windows.start).toContain("$env:FRAMEFIELD_TRUSTED_WEB_ORIGINS='https://ddcat.pronhubcn.com'");
|
||||
expect(windows.start).toContain("node .\\dist\\index.js");
|
||||
expect(module.detectLocalAgentSetupPlatform("Windows NT 10.0")).toBe("windows");
|
||||
expect(module.detectLocalAgentSetupPlatform("Macintosh Intel Mac OS X")).toBe("unix");
|
||||
});
|
||||
|
||||
test("Canvas local Agent tutorial replaces the conversation only while disconnected", async () => {
|
||||
const source = await Bun.file(new URL("../src/components/canvas/canvas-local-agent-panel.tsx", import.meta.url)).text();
|
||||
expect(source).toContain('!connected ? (\n <AgentConnectView');
|
||||
expect(source).toContain("连接成功后,这里会自动恢复原对话");
|
||||
expect(source).toContain("当前支持范围");
|
||||
});
|
||||
|
||||
test("Canvas reconnect wait resolves quietly when component cleanup aborts", async () => {
|
||||
const module = await import("../src/lib/canvas/local-runtime-connection");
|
||||
const waitForReconnect = (
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { buildCanvasMediaDownloadFileName, canvasMediaFileExtension } from "@/lib/canvas/canvas-media-download";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
function mediaNode(overrides: Partial<CanvasNodeData> = {}): CanvasNodeData {
|
||||
return {
|
||||
id: "image-1",
|
||||
type: CanvasNodeType.Image,
|
||||
title: "女明星角色三视图",
|
||||
position: { x: 0, y: 0 },
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
metadata: { content: "data:image/png;base64,aW1hZ2U=", mimeType: "image/png", status: "success" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("canvas media download", () => {
|
||||
test("按画布名、节点名和本地日期生成文件名", () => {
|
||||
expect(buildCanvasMediaDownloadFileName("写给阿妈的情书", mediaNode(), new Date(2026, 7, 28, 12))).toBe("写给阿妈的情书_女明星角色三视图_20260828.png");
|
||||
});
|
||||
|
||||
test("清理跨平台非法字符且保留中文可读名称", () => {
|
||||
const node = mediaNode({ title: "女明星/角色:三视图?" });
|
||||
expect(buildCanvasMediaDownloadFileName("写给阿妈的情书|终稿", node, new Date(2026, 7, 28, 12))).toBe("写给阿妈的情书_终稿_女明星_角色_三视图_20260828.png");
|
||||
});
|
||||
|
||||
test("截断长名称后不会留下 Windows 不接受的结尾句点", () => {
|
||||
const node = mediaNode({ title: `${"a".repeat(79)}.extra` });
|
||||
expect(buildCanvasMediaDownloadFileName("画布", node, new Date(2026, 7, 28, 12))).toBe(`画布_${"a".repeat(79)}_20260828.png`);
|
||||
});
|
||||
|
||||
test("优先按媒体 MIME 类型确定视频扩展名", () => {
|
||||
expect(canvasMediaFileExtension(mediaNode({ type: CanvasNodeType.Video, metadata: { content: "https://example.com/video", mimeType: "video/webm" } }))).toBe("webm");
|
||||
expect(canvasMediaFileExtension(mediaNode({ type: CanvasNodeType.Video, metadata: { content: "https://example.com/video", mimeType: "video/quicktime" } }))).toBe("mov");
|
||||
});
|
||||
|
||||
test("MIME 类型不明确时从远程资源 URL 识别扩展名", () => {
|
||||
expect(canvasMediaFileExtension(mediaNode({ metadata: { content: "https://example.com/result.jpeg?token=hidden", mimeType: "image/*" } }))).toBe("jpg");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy";
|
||||
|
||||
describe("canvas node copy title", () => {
|
||||
test("首次复制使用 copy1,后续按已有最大序号递增", () => {
|
||||
expect(nextCopiedNodeTitle("女明星角色三视图", ["女明星角色三视图"])).toBe("女明星角色三视图_copy1");
|
||||
expect(nextCopiedNodeTitle("女明星角色三视图", ["女明星角色三视图", "女明星角色三视图_copy1"])).toBe("女明星角色三视图_copy2");
|
||||
expect(nextCopiedNodeTitle("女明星角色三视图", ["女明星角色三视图_copy1", "女明星角色三视图_copy3"])).toBe("女明星角色三视图_copy4");
|
||||
});
|
||||
|
||||
test("复制已有副本时仍使用原始名称计算下一序号", () => {
|
||||
expect(nextCopiedNodeTitle("女明星角色三视图_copy1", ["女明星角色三视图", "女明星角色三视图_copy1"])).toBe("女明星角色三视图_copy2");
|
||||
});
|
||||
|
||||
test("兼容旧版空格 Copy 后缀", () => {
|
||||
expect(nextCopiedNodeTitle("女明星角色三视图 Copy", ["女明星角色三视图", "女明星角色三视图 Copy"])).toBe("女明星角色三视图_copy1");
|
||||
});
|
||||
|
||||
test("同一批粘贴可以通过预留标题连续分配序号", () => {
|
||||
const titles = new Set(["节点", "节点_copy1"]);
|
||||
const first = nextCopiedNodeTitle("节点", titles);
|
||||
titles.add(first);
|
||||
const second = nextCopiedNodeTitle("节点", titles);
|
||||
expect([first, second]).toEqual(["节点_copy2", "节点_copy3"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes, searchCanvasNodes } from "@/lib/canvas/canvas-node-search";
|
||||
import { normalizeCanvasNodeTimestamps, stampCanvasNodeChanges } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
function node(id: string, patch: Partial<CanvasNodeData> = {}): CanvasNodeData {
|
||||
return {
|
||||
id,
|
||||
type: CanvasNodeType.Image,
|
||||
title: id,
|
||||
position: { x: 0, y: 0 },
|
||||
width: 320,
|
||||
height: 180,
|
||||
metadata: {},
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("canvas node timestamps", () => {
|
||||
test("legacy nodes receive the project timestamp baseline", () => {
|
||||
const normalized = normalizeCanvasNodeTimestamps([node("legacy")], {
|
||||
createdAt: "2026-08-01T01:00:00.000Z",
|
||||
updatedAt: "2026-08-20T02:00:00.000Z",
|
||||
});
|
||||
expect(normalized[0]?.createdAt).toBe("2026-08-01T01:00:00.000Z");
|
||||
expect(normalized[0]?.updatedAt).toBe("2026-08-20T02:00:00.000Z");
|
||||
});
|
||||
|
||||
test("new nodes and meaningful edits receive timestamps without touching unchanged nodes", () => {
|
||||
const created = stampCanvasNodeChanges([], [node("new")], "2026-08-28T01:00:00.000Z");
|
||||
expect(created[0]).toMatchObject({ createdAt: "2026-08-28T01:00:00.000Z", updatedAt: "2026-08-28T01:00:00.000Z" });
|
||||
|
||||
const unchanged = created[0]!;
|
||||
const edited = { ...unchanged, title: "新标题" };
|
||||
const updated = stampCanvasNodeChanges(created, [edited], "2026-08-28T02:00:00.000Z");
|
||||
expect(updated[0]?.createdAt).toBe("2026-08-28T01:00:00.000Z");
|
||||
expect(updated[0]?.updatedAt).toBe("2026-08-28T02:00:00.000Z");
|
||||
});
|
||||
|
||||
test("media hydration does not pretend to be a user edit", () => {
|
||||
const previous = node("image", {
|
||||
createdAt: "2026-08-28T01:00:00.000Z",
|
||||
updatedAt: "2026-08-28T01:00:00.000Z",
|
||||
metadata: { storageKey: "resource:1", content: "resource:1" },
|
||||
});
|
||||
const hydrated = { ...previous, metadata: { ...previous.metadata, content: "blob:preview", naturalWidth: 1920, naturalHeight: 1080 } };
|
||||
const updated = stampCanvasNodeChanges([previous], [hydrated], "2026-08-28T02:00:00.000Z");
|
||||
expect(updated[0]?.updatedAt).toBe("2026-08-28T01:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas node search", () => {
|
||||
test("defaults to recently edited order and searches model or tags", () => {
|
||||
const older = node("older", { updatedAt: "2026-08-20T01:00:00.000Z", metadata: { model: "wan-video", assetTags: ["夜景"] } });
|
||||
const newer = node("newer", { updatedAt: "2026-08-28T01:00:00.000Z", metadata: { model: "seedance" } });
|
||||
expect(searchCanvasNodes([older, newer], "").map((item) => item.id)).toEqual(["newer", "older"]);
|
||||
expect(searchCanvasNodes([older, newer], "夜景").map((item) => item.id)).toEqual(["older"]);
|
||||
expect(searchCanvasNodes([older, newer], "seedance").map((item) => item.id)).toEqual(["newer"]);
|
||||
});
|
||||
|
||||
test("summaries expose useful media metadata without leaking data URLs into context", () => {
|
||||
const image = node("image", {
|
||||
createdAt: "2026-08-28T01:00:00.000Z",
|
||||
updatedAt: "2026-08-28T02:00:00.000Z",
|
||||
metadata: { content: "data:image/png;base64,AAAA", naturalWidth: 1920, naturalHeight: 1080, bytes: 2 * 1024 * 1024, model: "image-model" },
|
||||
});
|
||||
expect(canvasNodeMaterialSummary(image)).toContain("1920×1080");
|
||||
expect(canvasNodeSearchContext(image)).toBe("图片节点");
|
||||
expect(canvasNodeSearchTimes(image)).toMatchObject({ createdAt: image.createdAt, updatedAt: image.updatedAt });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { CANVAS_SHORTCUTS, filterCanvasShortcuts } from "../src/lib/canvas/canvas-shortcuts";
|
||||
|
||||
describe("canvas shortcuts", () => {
|
||||
test("uses Ctrl/Cmd + F for search and Shift + Ctrl/Cmd + F for focus mode", () => {
|
||||
expect(CANVAS_SHORTCUTS.find((shortcut) => shortcut.id === "search")?.keys).toEqual([["Ctrl / Cmd", "F"]]);
|
||||
expect(CANVAS_SHORTCUTS.find((shortcut) => shortcut.id === "focus")?.keys).toEqual([["Shift", "Ctrl / Cmd", "F"]]);
|
||||
});
|
||||
|
||||
test("searches titles, descriptions, keys and keywords", () => {
|
||||
expect(filterCanvasShortcuts("粘贴").map((shortcut) => shortcut.id)).toContain("paste");
|
||||
expect(filterCanvasShortcuts("缩放").map((shortcut) => shortcut.id)).toEqual(expect.arrayContaining(["zoom-wheel", "zoom-controls", "zoom-presets"]));
|
||||
expect(filterCanvasShortcuts("Alt L").map((shortcut) => shortcut.id)).toContain("batch-connect");
|
||||
});
|
||||
|
||||
test("filters by category without losing the full catalog", () => {
|
||||
expect(filterCanvasShortcuts("", "common").every((shortcut) => shortcut.category === "common")).toBe(true);
|
||||
expect(filterCanvasShortcuts("", "all")).toHaveLength(CANVAS_SHORTCUTS.length);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CanvasVideoSettingsPopover } from "../src/components/canvas/canvas-video-settings-popover";
|
||||
import { VideoSettingsPanel } from "../src/components/video-settings-panel";
|
||||
import { canvasThemes } from "../src/lib/canvas-theme";
|
||||
import { mergeFetchedChannelModelCosts, type ChannelModelCatalogItem } from "../src/lib/channel-model-catalog";
|
||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
import { ChannelModelSettings } from "../src/pages/settings/channel-video-pricing";
|
||||
@@ -328,4 +330,32 @@ describe("public channel model catalog", () => {
|
||||
expect(body.seconds).toBe("8");
|
||||
expect(body.size).toBe("1280x720");
|
||||
});
|
||||
|
||||
test("preserves provider resolution enums with direction suffixes across Canvas display and requests", async () => {
|
||||
let body: Record<string, string> = {};
|
||||
axios.post = (async (_url: string, requestBody: unknown) => {
|
||||
body = formEntries(requestBody);
|
||||
return { data: { id: "synthetic-directional" } };
|
||||
}) as typeof axios.post;
|
||||
const catalog: ChannelModelCatalogItem = {
|
||||
...omniCatalog,
|
||||
id: "directional-video",
|
||||
displayName: "Directional Video",
|
||||
defaultParameters: { aspectRatio: "16:9", durationSeconds: "8", resolution: "768p竖" },
|
||||
options: {
|
||||
...omniCatalog.options,
|
||||
durationSeconds: [{ value: "8" }],
|
||||
resolution: [{ value: "480p竖" }, { value: "768p竖" }, { value: "480p横" }, { value: "768p横" }],
|
||||
},
|
||||
};
|
||||
const config = configForCatalog([catalog], { videoSeconds: "8", size: "16:9", vquality: "768P竖" });
|
||||
|
||||
const html = renderToStaticMarkup(React.createElement(CanvasVideoSettingsPopover, { config, onConfigChange: () => undefined }));
|
||||
const panelHtml = renderToStaticMarkup(React.createElement(VideoSettingsPanel, { config, onConfigChange: () => undefined, theme: canvasThemes.dark }));
|
||||
await createVideoGenerationTask(config, "synthetic prompt");
|
||||
|
||||
expect(html).toContain("768P竖 · 16:9 · 8s");
|
||||
expect(panelHtml).toMatch(/aria-pressed="true"[^>]*>768P竖<\/button>/);
|
||||
expect(body.resolution_name).toBe("768p竖");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { modelQuoteRequest, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing";
|
||||
import { modelQuoteRequest, normalizeTierResolution, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing";
|
||||
import type { ModelRequirements } from "../src/lib/model-selection";
|
||||
import { createModelChannel, defaultConfig, normalizeConfigSnapshot, resolveModelChannel, type AiConfig } from "../src/stores/use-config-store";
|
||||
|
||||
@@ -58,6 +58,11 @@ const textVideoRequirements: ModelRequirements = {
|
||||
};
|
||||
|
||||
describe("model request pricing", () => {
|
||||
test("preserves provider-specific resolution enums when matching price tiers", () => {
|
||||
expect(normalizeTierResolution("768P竖")).toBe("768p竖");
|
||||
expect(normalizeTierResolution("HD_Portrait")).toBe("hd_portrait");
|
||||
});
|
||||
|
||||
test("matches the current Agnes resolution and duration tier and totals per-second credits", () => {
|
||||
const config = systemConfig({
|
||||
tiers: [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
import { normalizeVideoResolution, VIDEO_RESOLUTION_CAPABILITY_OPTIONS, VIDEO_RESOLUTION_OPTIONS } from "../src/lib/video-generation-options";
|
||||
import { formatVideoResolutionLabel, normalizeVideoResolution, videoResolutionComparisonKey, VIDEO_RESOLUTION_CAPABILITY_OPTIONS, VIDEO_RESOLUTION_OPTIONS } from "../src/lib/video-generation-options";
|
||||
|
||||
describe("video generation resolution options", () => {
|
||||
test("统一档位包含 1440P 与 4K,并识别常见别名", () => {
|
||||
@@ -14,6 +14,10 @@ describe("video generation resolution options", () => {
|
||||
|
||||
test("保留模型声明的非标准档位而不是静默降级", () => {
|
||||
expect(normalizeVideoResolution("768p")).toBe("768");
|
||||
expect(normalizeVideoResolution("768p竖")).toBe("768p竖");
|
||||
expect(normalizeVideoResolution("HD_Portrait")).toBe("HD_Portrait");
|
||||
expect(videoResolutionComparisonKey("768P竖")).toBe("768p竖");
|
||||
expect(formatVideoResolutionLabel("768p竖")).toBe("768P竖");
|
||||
});
|
||||
|
||||
test("按协议限制实际可选档位", () => {
|
||||
|
||||
Reference in new issue
Block a user