feat(assets): 文件夹主题 - 新增多主题皮肤并同步主线
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
findFrameDropTarget,
|
||||
isCanvasFolderNode,
|
||||
} from "@/lib/canvas/canvas-frame";
|
||||
import { resolveCanvasFolderTheme, resolveCanvasFolderThemeCover } from "@/lib/canvas/canvas-folder-theme";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
function folder(id: string, linked = false): CanvasNodeData {
|
||||
@@ -41,6 +42,13 @@ function image(id: string, content = "resource:image"): CanvasNodeData {
|
||||
}
|
||||
|
||||
describe("canvas folders", () => {
|
||||
test("文件夹样式与主题皮肤独立解析", () => {
|
||||
expect(resolveCanvasFolderTheme("ember")).toBe("ember");
|
||||
expect(resolveCanvasFolderTheme("unknown")).toBe("aurora");
|
||||
expect(resolveCanvasFolderThemeCover("obsidian")).toContain("folder-theme-obsidian.png");
|
||||
expect(resolveCanvasFolderThemeCover("pearl", "resource:custom-cover")).toBe("resource:custom-cover");
|
||||
});
|
||||
|
||||
test("识别文件夹并允许非容器节点进入本地文件夹", () => {
|
||||
const target = folder("folder");
|
||||
expect(isCanvasFolderNode(target)).toBe(true);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-policy";
|
||||
import { buildGenerationConfig, resolveCanvasGenerationModel } from "../src/lib/canvas/canvas-project-generation";
|
||||
import { assertCanvasImageReferenceLimit, buildGenerationConfig, canvasImageReferenceLimitError, resolveCanvasGenerationModel } from "../src/lib/canvas/canvas-project-generation";
|
||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
import { groupModelsByDisplayName, modelCompatibilityError, modelGroupReferenceLimits, resolveCompatibleModel } from "../src/lib/model-selection";
|
||||
import { defaultConfig, type AiConfig, type ModelChannel } from "../src/stores/use-config-store";
|
||||
@@ -124,3 +124,20 @@ describe("画布连线能力", () => {
|
||||
expect(canvasConnectionError(config, nodes, [], { fromNodeId: "character", toNodeId: "target" })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("图片参考图上限", () => {
|
||||
test("超出当前模型上限时保留全部输入并返回明确错误", () => {
|
||||
const config = policyConfig();
|
||||
const modelCost = config.channels[0]?.modelCosts?.[0];
|
||||
if (!modelCost?.capabilityConfig?.image) throw new Error("缺少图片能力配置");
|
||||
modelCost.capabilityConfig.image.references.maxImages = 1;
|
||||
const references = [
|
||||
{ id: "image-a", name: "image-a.png", type: "image/png", dataUrl: "data:image/png;base64,YQ==" },
|
||||
{ id: "image-b", name: "image-b.png", type: "image/png", dataUrl: "data:image/png;base64,Yg==" },
|
||||
];
|
||||
|
||||
expect(canvasImageReferenceLimitError(config, references)).toContain("最多支持 1 张参考图,当前已连接 2 张");
|
||||
expect(() => assertCanvasImageReferenceLimit(config, references)).toThrow("请移除多余连线后重试");
|
||||
expect(references).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
320
web/test/channel-model-catalog.test.ts
Normal file
320
web/test/channel-model-catalog.test.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { App } from "antd";
|
||||
import axios from "axios";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CanvasVideoSettingsPopover } from "../src/components/canvas/canvas-video-settings-popover";
|
||||
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";
|
||||
import { fetchChannelModels } from "../src/services/api/image";
|
||||
import { createVideoGenerationTask } from "../src/services/api/video";
|
||||
import { createModelChannel, defaultConfig, modelDisplayName, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability, type AiConfig } from "../src/stores/use-config-store";
|
||||
|
||||
const originalAxiosPost = axios.post;
|
||||
|
||||
afterEach(() => {
|
||||
axios.post = originalAxiosPost;
|
||||
});
|
||||
|
||||
const omniCatalog: ChannelModelCatalogItem = {
|
||||
id: "omni",
|
||||
displayName: "Omni Flash",
|
||||
modelType: "video",
|
||||
supportsImages: false,
|
||||
minImages: 0,
|
||||
maxImages: 0,
|
||||
defaultParameters: { aspectRatio: "16:9", durationSeconds: "10" },
|
||||
options: {
|
||||
aspectRatio: [{ value: "16:9" }, { value: "9:16" }],
|
||||
durationSeconds: [{ value: "8" }, { value: "10" }],
|
||||
},
|
||||
};
|
||||
|
||||
function configForCatalog(catalog: ChannelModelCatalogItem[], input: Partial<AiConfig> = {}) {
|
||||
const channel = createModelChannel({
|
||||
id: "flow",
|
||||
name: "Flow2API",
|
||||
baseUrl: "https://flow.example",
|
||||
apiKey: "synthetic-test-key",
|
||||
apiFormat: "openai",
|
||||
models: catalog.map((item) => item.id),
|
||||
});
|
||||
const configured = { ...channel, modelCosts: mergeFetchedChannelModelCosts(channel, catalog) };
|
||||
const normalized = normalizeConfigSnapshot({
|
||||
config: {
|
||||
...defaultConfig,
|
||||
...input,
|
||||
channels: [configured],
|
||||
model: `flow::${catalog[0]!.id}`,
|
||||
videoModel: `flow::${catalog[0]!.id}`,
|
||||
},
|
||||
}).config;
|
||||
return input.vquality ? { ...normalized, vquality: input.vquality } : normalized;
|
||||
}
|
||||
|
||||
function formEntries(body: unknown) {
|
||||
expect(body).toBeInstanceOf(FormData);
|
||||
return Object.fromEntries(Array.from((body as FormData).entries()).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
||||
}
|
||||
|
||||
describe("public channel model catalog", () => {
|
||||
test("preserves six public capabilities without expanding compatibility IDs", async () => {
|
||||
axios.post = (async () => ({
|
||||
data: {
|
||||
code: 0,
|
||||
data: {
|
||||
models: [
|
||||
{ id: "image-fast", displayName: "Image Fast", modelType: "image" },
|
||||
{ id: "image-quality", displayName: "Image Quality", modelType: "image" },
|
||||
{ ...omniCatalog, compatibilityMap: [{ modelId: "omni_portrait_10s" }] },
|
||||
{ id: "veo-lite", displayName: "Veo Lite", modelType: "video" },
|
||||
{ id: "veo-fast", displayName: "Veo Fast", modelType: "video" },
|
||||
{ id: "veo-quality", displayName: "Veo Quality", modelType: "video" },
|
||||
],
|
||||
},
|
||||
},
|
||||
})) as typeof axios.post;
|
||||
const channel = createModelChannel({ baseUrl: "https://flow.example", apiKey: "synthetic-test-key", models: [] });
|
||||
|
||||
const result = await fetchChannelModels(channel, true);
|
||||
|
||||
expect(result.models).toHaveLength(6);
|
||||
expect(result.models).toContain("omni");
|
||||
expect(result.models).not.toContain("omni_portrait_10s");
|
||||
expect(result.catalog.find((item) => item.id === "omni")).toEqual(omniCatalog);
|
||||
expect(result.catalog.find((item) => item.id === "omni")).not.toHaveProperty("compatibilityMap");
|
||||
});
|
||||
|
||||
test("maps Omni metadata to friendly video/NewAPI configuration with 8/10 seconds and both ratios", () => {
|
||||
const config = configForCatalog([omniCatalog]);
|
||||
const model = "flow::omni";
|
||||
const cost = config.channels[0]!.modelCosts![0]!;
|
||||
|
||||
expect(modelDisplayName(config, model)).toBe("Omni Flash");
|
||||
expect(selectableModelsByCapability(config, "video")).toEqual([model]);
|
||||
expect(selectableModelsByCapability(config, "text")).toEqual([]);
|
||||
expect(resolveModelRequestConfig(config, model).interfaceType).toBe("newapi");
|
||||
expect(cost.capabilityConfig?.video).toMatchObject({
|
||||
duration: { selection: "enum", values: [8, 10], default: 10 },
|
||||
ratios: ["16:9", "9:16"],
|
||||
defaultRatio: "16:9",
|
||||
resolutions: [],
|
||||
defaultResolution: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("treats endpoint-only video metadata as authoritative without inventing 720P", async () => {
|
||||
const catalog: ChannelModelCatalogItem = {
|
||||
id: "endpoint-video",
|
||||
supportedEndpointTypes: ["openai-video"],
|
||||
};
|
||||
const config = configForCatalog([catalog], { videoSeconds: "6", size: "16:9", vquality: "720" });
|
||||
const model = "flow::endpoint-video";
|
||||
const cost = config.channels[0]!.modelCosts![0]!;
|
||||
|
||||
expect(cost.capability).toBe("video");
|
||||
expect(cost.protocol).toBe("newapi-channel-2");
|
||||
expect(cost.capabilityConfig?.video?.resolutions).toEqual([]);
|
||||
expect(cost.capabilityConfig?.video?.defaultResolution).toBe("");
|
||||
expect(selectableModelsByCapability(config, "video")).toEqual([model]);
|
||||
expect(resolveModelRequestConfig(config, model).interfaceType).toBe("newapi-channel-2");
|
||||
|
||||
const canvasHtml = renderToStaticMarkup(React.createElement(CanvasVideoSettingsPopover, { config, onConfigChange: () => undefined }));
|
||||
expect(canvasHtml).not.toContain("720P");
|
||||
|
||||
const createSource = await Bun.file(new URL("../src/pages/create/index.tsx", import.meta.url)).text();
|
||||
expect(createSource).toContain('const videoResolutionSupported = props.mode === "video" && resolutions.length > 0;');
|
||||
expect(createSource).toContain("...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])");
|
||||
|
||||
let requestBody: Record<string, unknown> = {};
|
||||
axios.post = (async (_url: string, body: Record<string, unknown>) => {
|
||||
requestBody = body;
|
||||
return { data: { id: "endpoint-video-task" } };
|
||||
}) as typeof axios.post;
|
||||
await createVideoGenerationTask(config, "synthetic prompt");
|
||||
expect(requestBody).not.toHaveProperty("resolution");
|
||||
});
|
||||
|
||||
test("preserves a manually configured capability profile when a catalog only returns an ID", () => {
|
||||
const channel = createModelChannel({
|
||||
id: "manual",
|
||||
name: "Manual",
|
||||
baseUrl: "https://manual.example",
|
||||
apiKey: "synthetic-test-key",
|
||||
apiFormat: "openai",
|
||||
models: ["manual-video"],
|
||||
});
|
||||
const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video");
|
||||
capabilityConfig.video!.resolutions = ["1440p"];
|
||||
capabilityConfig.video!.defaultResolution = "1440p";
|
||||
channel.modelCosts = [{ model: "manual-video", displayName: "Manual Video", capability: "video", protocol: "newapi", billingMode: "fixed_request", unitPriceMicrocredits: 0, capabilityConfig }];
|
||||
|
||||
const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video" }]);
|
||||
|
||||
expect(costs[0]?.capabilityConfig).toEqual(capabilityConfig);
|
||||
expect(costs[0]?.displayName).toBe("Manual Video");
|
||||
});
|
||||
|
||||
test("preserves an ID-only manual model cost when the per-model protocol is missing", () => {
|
||||
const channel = createModelChannel({
|
||||
id: "manual-text-channel",
|
||||
name: "Manual Text Channel",
|
||||
baseUrl: "https://manual.example",
|
||||
apiKey: "synthetic-test-key",
|
||||
apiFormat: "openai",
|
||||
interfaceType: "chat-completion",
|
||||
models: ["manual-video"],
|
||||
});
|
||||
const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video");
|
||||
capabilityConfig.video!.resolutions = ["1440p"];
|
||||
capabilityConfig.video!.defaultResolution = "1440p";
|
||||
const existing = {
|
||||
model: "manual-video",
|
||||
displayName: "Manual Video",
|
||||
capability: "video" as const,
|
||||
billingMode: "per_second" as const,
|
||||
unitPriceMicrocredits: 4321,
|
||||
inputTokenPriceMicrocredits: 11,
|
||||
outputTokenPriceMicrocredits: 22,
|
||||
cachedTokenPriceMicrocredits: 3,
|
||||
capabilityConfig,
|
||||
};
|
||||
channel.modelCosts = [existing];
|
||||
|
||||
const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video", displayName: "Catalog Video" }]);
|
||||
|
||||
expect(costs[0]).toEqual({ ...existing, displayName: "Catalog Video" });
|
||||
});
|
||||
|
||||
test("uses modelType only for capability and preserves an existing manual protocol and profile", () => {
|
||||
const channel = createModelChannel({
|
||||
id: "manual-partial",
|
||||
name: "Manual Partial",
|
||||
baseUrl: "https://manual.example",
|
||||
apiKey: "synthetic-test-key",
|
||||
apiFormat: "openai",
|
||||
interfaceType: "chat-completion",
|
||||
models: ["video-x"],
|
||||
});
|
||||
const capabilityConfig = defaultModelCapabilityConfig("gemini-veo", "video-x");
|
||||
capabilityConfig.video!.resolutions = ["1440p"];
|
||||
capabilityConfig.video!.defaultResolution = "1440p";
|
||||
channel.modelCosts = [{ model: "video-x", capability: "video", protocol: "gemini-veo", billingMode: "per_second", unitPriceMicrocredits: 7654, capabilityConfig }];
|
||||
|
||||
const costs = mergeFetchedChannelModelCosts(channel, [{ id: "video-x", modelType: "video" }]);
|
||||
|
||||
expect(costs).toEqual(channel.modelCosts);
|
||||
});
|
||||
|
||||
test("requires the declared minimum image count and selects image-to-video", async () => {
|
||||
const catalog: ChannelModelCatalogItem = {
|
||||
id: "image-required-video",
|
||||
modelType: "video",
|
||||
minImages: 1,
|
||||
maxImages: 2,
|
||||
};
|
||||
const config = configForCatalog([catalog]);
|
||||
const profile = config.channels[0]!.modelCosts![0]!.capabilityConfig!.video!;
|
||||
|
||||
expect(profile.references).toMatchObject({ minImages: 1, maxImages: 2 });
|
||||
expect(profile.operations).toEqual(["image_to_video"]);
|
||||
expect(profile.defaultOperation).toBe("image_to_video");
|
||||
expect(createVideoGenerationTask(config, "synthetic prompt")).rejects.toThrow("至少需要 1 张参考图");
|
||||
});
|
||||
|
||||
test("preserves an ID-only manual video model cost when both protocol sources are missing", () => {
|
||||
const channel = createModelChannel({
|
||||
id: "manual-no-protocol",
|
||||
name: "Manual No Protocol",
|
||||
baseUrl: "https://manual.example",
|
||||
apiKey: "synthetic-test-key",
|
||||
apiFormat: "openai",
|
||||
models: ["manual-video"],
|
||||
});
|
||||
const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video");
|
||||
capabilityConfig.video!.resolutions = ["1440p"];
|
||||
capabilityConfig.video!.defaultResolution = "1440p";
|
||||
const existing = {
|
||||
model: "manual-video",
|
||||
displayName: "Manual Video",
|
||||
capability: "video" as const,
|
||||
billingMode: "per_second" as const,
|
||||
unitPriceMicrocredits: 8765,
|
||||
inputTokenPriceMicrocredits: 31,
|
||||
outputTokenPriceMicrocredits: 47,
|
||||
cachedTokenPriceMicrocredits: 5,
|
||||
capabilityConfig,
|
||||
};
|
||||
channel.modelCosts = [existing];
|
||||
|
||||
const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video", displayName: "Catalog Video" }]);
|
||||
|
||||
expect(costs).toEqual([{ ...existing, displayName: "Catalog Video" }]);
|
||||
});
|
||||
|
||||
test("does not show a synthetic 720P label in the Canvas summary when the model declares no resolutions", () => {
|
||||
const config = configForCatalog([omniCatalog], { videoSeconds: "10", size: "16:9", vquality: "720" });
|
||||
|
||||
const html = renderToStaticMarkup(React.createElement(CanvasVideoSettingsPopover, { config, onConfigChange: () => undefined }));
|
||||
|
||||
expect(html).toContain("横屏 · 10s");
|
||||
expect(html).not.toContain("720P");
|
||||
});
|
||||
|
||||
test("shows the public display name in channel model settings instead of only the internal ID", () => {
|
||||
const config = configForCatalog([omniCatalog]);
|
||||
|
||||
const html = renderToStaticMarkup(React.createElement(App, null, React.createElement(ChannelModelSettings, { channel: config.channels[0]!, onChange: () => undefined })));
|
||||
|
||||
expect(html).toContain("Omni Flash");
|
||||
});
|
||||
|
||||
test("omits resolution_name for Omni and for auto instead of inventing 720p", async () => {
|
||||
const bodies: Record<string, string>[] = [];
|
||||
axios.post = (async (_url: string, body: unknown) => {
|
||||
bodies.push(formEntries(body));
|
||||
return { data: { id: `synthetic-${bodies.length}` } };
|
||||
}) as typeof axios.post;
|
||||
|
||||
const omniConfig = configForCatalog([omniCatalog], { videoSeconds: "10", size: "16:9", vquality: "720" });
|
||||
await createVideoGenerationTask(omniConfig, "synthetic prompt");
|
||||
|
||||
const declaredResolution: ChannelModelCatalogItem = {
|
||||
...omniCatalog,
|
||||
id: "veo-public",
|
||||
displayName: "Veo Public",
|
||||
defaultParameters: { ...omniCatalog.defaultParameters, durationSeconds: "8", resolution: "1080p" },
|
||||
options: { ...omniCatalog.options, durationSeconds: [{ value: "8" }], resolution: [{ value: "720p" }, { value: "1080p" }] },
|
||||
};
|
||||
const autoConfig = configForCatalog([declaredResolution], { videoSeconds: "8", size: "16:9", vquality: "auto" });
|
||||
await createVideoGenerationTask(autoConfig, "synthetic prompt");
|
||||
|
||||
expect(bodies[0]).not.toHaveProperty("resolution_name");
|
||||
expect(bodies[1]).not.toHaveProperty("resolution_name");
|
||||
});
|
||||
|
||||
test("sends a declared compatible HD resolution for a video capability", async () => {
|
||||
let body: Record<string, string> = {};
|
||||
axios.post = (async (_url: string, requestBody: unknown) => {
|
||||
body = formEntries(requestBody);
|
||||
return { data: { id: "synthetic-hd" } };
|
||||
}) as typeof axios.post;
|
||||
const catalog: ChannelModelCatalogItem = {
|
||||
...omniCatalog,
|
||||
id: "veo-public",
|
||||
displayName: "Veo Public",
|
||||
defaultParameters: { aspectRatio: "16:9", durationSeconds: "8", resolution: "1080p" },
|
||||
options: { ...omniCatalog.options, durationSeconds: [{ value: "8" }], resolution: [{ value: "1080p" }] },
|
||||
};
|
||||
const config = configForCatalog([catalog], { videoSeconds: "8", size: "16:9", vquality: "1080" });
|
||||
|
||||
await createVideoGenerationTask(config, "synthetic prompt");
|
||||
|
||||
expect(body.resolution_name).toBe("1080p");
|
||||
expect(body.model).toBe("veo-public");
|
||||
expect(body.seconds).toBe("8");
|
||||
expect(body.size).toBe("1280x720");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { createGenerationTaskSubscriptionService, type GenerationTask } from "../src/services/api/task-center";
|
||||
import { updateCreationConversationSnapshot } from "../src/services/creation-conversation-store";
|
||||
import { removeCreationConversationSnapshot, updateCreationConversationSnapshot } from "../src/services/creation-conversation-store";
|
||||
|
||||
test("Create exposes one accessible copy action beside each displayed user prompt", async () => {
|
||||
const source = await Bun.file(new URL("../src/pages/create/index.tsx", import.meta.url)).text();
|
||||
@@ -35,6 +35,17 @@ test("Create keeps optimistic messages when the first local task binds immediate
|
||||
expect(bound[0].messages[1]).toMatchObject({ id: "assistant-0001", taskIds: ["local:dreamina-cli:task-0001"] });
|
||||
});
|
||||
|
||||
test("Create history deletion removes only the selected conversation snapshot", () => {
|
||||
const assets = [{ id: "asset-0001", storageKey: "resource:asset-0001" }];
|
||||
const conversations = [
|
||||
{ id: "conversation-0001", messages: [{ id: "message-0001", resultUrls: ["resource:asset-0001"] }] },
|
||||
{ id: "conversation-0002", messages: [{ id: "message-0002", attachments: [{ storageKey: "resource:asset-0001" }] }] },
|
||||
];
|
||||
|
||||
expect(removeCreationConversationSnapshot(conversations, "conversation-0001")).toEqual([conversations[1]]);
|
||||
expect(assets).toEqual([{ id: "asset-0001", storageKey: "resource:asset-0001" }]);
|
||||
});
|
||||
|
||||
test("Create refresh subscriptions share one durable scheduler observation without page polling", async () => {
|
||||
let queryCalls = 0;
|
||||
let waitCalls = 0;
|
||||
|
||||
@@ -14,9 +14,16 @@ describe("creation library button", () => {
|
||||
|
||||
expect(dockStart).toBeGreaterThanOrEqual(0);
|
||||
expect(dockEnd).toBeGreaterThan(dockStart);
|
||||
expect(compactSource(source.slice(dockStart, dockEnd))).toMatch(
|
||||
/<ModePicker mode=\{props\.mode\} onModeChange=\{props\.onModeChange\} \/> <Tooltip\b[^>]*>\s*<button(?=[^>]*className="creation-chat-control")(?=[^>]*onClick=\{props\.onOpenLibrary\})(?=[^>]*disabled=\{props\.busy \|\| !referencesSupported\})(?=[^>]*aria-label="打开素材库选择参考内容")[^>]*>\s*<FolderOpen \/>\s*<span>素材库<\/span>\s*<\/button>\s*<\/Tooltip>/,
|
||||
);
|
||||
const dockSource = compactSource(source.slice(dockStart, dockEnd));
|
||||
const modePickerIndex = dockSource.indexOf("<ModePicker mode={props.mode}");
|
||||
const attachmentIndex = dockSource.indexOf('aria-label="从本机上传附件"');
|
||||
const libraryIndex = dockSource.indexOf('aria-label="打开素材库选择参考内容"');
|
||||
|
||||
expect(modePickerIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(attachmentIndex).toBeGreaterThan(modePickerIndex);
|
||||
expect(libraryIndex).toBeGreaterThan(attachmentIndex);
|
||||
expect(dockSource).toContain("onClick={props.onOpenLibrary}");
|
||||
expect(dockSource).toContain("disabled={props.busy || !referencesSupported}");
|
||||
});
|
||||
|
||||
test("uploads from the library without adding a reference before confirmation", () => {
|
||||
@@ -39,10 +46,20 @@ describe("creation library button", () => {
|
||||
const createSource = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8");
|
||||
const canvasSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-node-prompt-panel.tsx"), "utf8");
|
||||
|
||||
expect(createSource).toContain('className="creation-chat-attachment-preview"');
|
||||
expect(createSource).toContain('className="creation-user-message-attachments"');
|
||||
expect(createSource).toContain('setPreviewType(kind === "video" ? "video" : "image")');
|
||||
expect(createSource).toContain("<CreationMediaPreviewModal url={previewUrl} type={previewType}");
|
||||
expect(canvasSource).toContain("canPreview ? setImagePreview(reference) : onInsert(reference)");
|
||||
expect(canvasSource).toContain("<AntImage");
|
||||
expect(canvasSource).toContain("onClick={() => onInsert(reference)}");
|
||||
});
|
||||
|
||||
test("promotes the first reference into the primary reference slot", () => {
|
||||
const source = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8");
|
||||
|
||||
expect(source).toContain("const [primaryAttachment, ...secondaryAttachments] = props.attachments");
|
||||
expect(source).toContain("<CreationAttachmentThumbnail item={primaryAttachment} primary");
|
||||
expect(source).toContain("secondaryAttachments.map((item) => <CreationAttachmentThumbnail");
|
||||
expect(source).toContain('className={primary ? "creation-chat-reference is-paper creation-chat-reference-media" : "creation-chat-attachment"}');
|
||||
});
|
||||
});
|
||||
|
||||
63
web/test/creation-references.test.ts
Normal file
63
web/test/creation-references.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { canvasResourceMentionToken } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { creationAttachmentKind, creationFileAccepted, creationMediaAspectRatio, creationUploadAccept, type CreationAttachment } from "../src/pages/create/creation-assets";
|
||||
import { buildCreationMentionReferences, reconcileCreationAttachmentLimit, removeCreationReferenceTokens } from "../src/pages/create/creation-references";
|
||||
|
||||
function imageAttachment(id: string): CreationAttachment {
|
||||
return {
|
||||
id,
|
||||
name: `${id}.png`,
|
||||
type: "image/png",
|
||||
dataUrl: `data:image/png;base64,${id}`,
|
||||
previewUrl: `data:image/png;base64,${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("creation references", () => {
|
||||
test("removes attachments and prompt tokens beyond the current model limit", () => {
|
||||
const attachments = [imageAttachment("first"), imageAttachment("second"), imageAttachment("third")];
|
||||
const references = buildCreationMentionReferences([], attachments);
|
||||
const result = reconcileCreationAttachmentLimit(attachments, references, 1);
|
||||
const prompt = references.map(canvasResourceMentionToken).join(" ");
|
||||
const nextPrompt = removeCreationReferenceTokens(prompt, result.removedReferences);
|
||||
|
||||
expect(result.attachments).toEqual([attachments[0]]);
|
||||
expect(result.removedReferences.map((reference) => reference.attachmentId)).toEqual(["second", "third"]);
|
||||
expect(nextPrompt).toContain(canvasResourceMentionToken(references[0]));
|
||||
expect(nextPrompt).not.toContain(canvasResourceMentionToken(references[1]));
|
||||
expect(nextPrompt).not.toContain(canvasResourceMentionToken(references[2]));
|
||||
});
|
||||
|
||||
test("returns the original attachment list when it is already within the limit", () => {
|
||||
const attachments = [imageAttachment("first")];
|
||||
const result = reconcileCreationAttachmentLimit(attachments, buildCreationMentionReferences([], attachments), 1);
|
||||
|
||||
expect(result.attachments).toBe(attachments);
|
||||
expect(result.removedReferences).toEqual([]);
|
||||
});
|
||||
|
||||
test("文本创作允许媒体和常用文档,图片创作仍只接受图片", () => {
|
||||
expect(creationFileAccepted("text", { name: "story.pdf", type: "application/pdf" })).toBe(true);
|
||||
expect(creationFileAccepted("text", { name: "clip.mp4", type: "video/mp4" })).toBe(true);
|
||||
expect(creationFileAccepted("image", { name: "story.pdf", type: "application/pdf" })).toBe(false);
|
||||
expect(creationUploadAccept("text")).toContain(".docx");
|
||||
});
|
||||
|
||||
test("文档附件会作为文本资源参与引用", () => {
|
||||
const attachment: CreationAttachment = { id: "document", name: "script.pdf", type: "application/pdf", url: "https://example.com/script.pdf", storageKey: "resource:document", bytes: 1024, previewUrl: "" };
|
||||
const [reference] = buildCreationMentionReferences([], [attachment]);
|
||||
|
||||
expect(creationAttachmentKind(attachment)).toBe("file");
|
||||
expect(reference.kind).toBe("text");
|
||||
expect(reference.label).toBe("文件1");
|
||||
});
|
||||
|
||||
test("媒体占位按本次选择的画幅展示并为异常值提供模式回退", () => {
|
||||
expect(creationMediaAspectRatio("16:9", "video")).toBe("16 / 9");
|
||||
expect(creationMediaAspectRatio("1:1", "image")).toBe("1 / 1");
|
||||
expect(creationMediaAspectRatio("1920x1080", "image")).toBe("1920 / 1080");
|
||||
expect(creationMediaAspectRatio("auto", "video")).toBe("16 / 9");
|
||||
expect(creationMediaAspectRatio("auto", "image")).toBe("1 / 1");
|
||||
});
|
||||
});
|
||||
77
web/test/text-reasoning-stream.test.ts
Normal file
77
web/test/text-reasoning-stream.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { GenerationToolCard } from "../src/components/ai/generation-tool-card";
|
||||
import type { ModelProtocol } from "../src/lib/model-protocols";
|
||||
import { requestImageQuestion } from "../src/services/api/image";
|
||||
import { createModelChannel, defaultConfig, type AiConfig } from "../src/stores/use-config-store";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function reasoningConfig(protocol: ModelProtocol | undefined, apiFormat: "openai" | "gemini" = "openai"): AiConfig {
|
||||
const channel = createModelChannel({
|
||||
id: "reasoning",
|
||||
name: "Reasoning",
|
||||
baseUrl: "https://reasoning.example/v1",
|
||||
apiKey: "synthetic-test-key",
|
||||
apiFormat,
|
||||
interfaceType: apiFormat === "gemini" ? undefined : protocol,
|
||||
models: ["reasoner"],
|
||||
modelCosts: [{ model: "reasoner", capability: "text", ...(protocol ? { protocol } : {}), billingMode: "fixed_request", unitPriceMicrocredits: 0 }],
|
||||
});
|
||||
return { ...defaultConfig, channels: [channel], model: "reasoning::reasoner", textModel: "reasoning::reasoner" };
|
||||
}
|
||||
|
||||
function streamResponse(events: unknown[]) {
|
||||
const body = `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n`;
|
||||
return new Response(body, { headers: { "Content-Type": "text/event-stream" } });
|
||||
}
|
||||
|
||||
async function requestWithReasoning(config: AiConfig, events: unknown[]) {
|
||||
globalThis.fetch = (async () => streamResponse(events)) as typeof fetch;
|
||||
let content = "";
|
||||
let reasoning = "";
|
||||
const result = await requestImageQuestion(config, [{ role: "user", content: "请回答" }], (value) => { content = value; }, { onReasoning: (value) => { reasoning = value; } });
|
||||
return { result, content, reasoning };
|
||||
}
|
||||
|
||||
describe("text reasoning streams", () => {
|
||||
test("separates Responses reasoning summaries from answer text", async () => {
|
||||
const output = await requestWithReasoning(reasoningConfig("openai-response"), [
|
||||
{ type: "response.reasoning_summary_text.delta", delta: "先分析" },
|
||||
{ type: "response.output_text.delta", delta: "最终答案" },
|
||||
]);
|
||||
|
||||
expect(output).toEqual({ result: "最终答案", content: "最终答案", reasoning: "先分析" });
|
||||
});
|
||||
|
||||
test("separates Chat Completions reasoning_content from answer text", async () => {
|
||||
const output = await requestWithReasoning(reasoningConfig("chat-completion"), [
|
||||
{ choices: [{ delta: { reasoning_content: "推理过程" } }] },
|
||||
{ choices: [{ delta: { content: "回答内容" } }] },
|
||||
]);
|
||||
|
||||
expect(output).toEqual({ result: "回答内容", content: "回答内容", reasoning: "推理过程" });
|
||||
});
|
||||
|
||||
test("does not duplicate Gemini thought parts into visible content", async () => {
|
||||
const output = await requestWithReasoning(reasoningConfig(undefined, "gemini"), [
|
||||
{ candidates: [{ content: { parts: [{ text: "内部思考", thought: true }, { text: "可见回答" }] } }] },
|
||||
]);
|
||||
|
||||
expect(output).toEqual({ result: "可见回答", content: "可见回答", reasoning: "内部思考" });
|
||||
});
|
||||
|
||||
test("keeps single results open and collapses completed bulk results on restore", () => {
|
||||
const single = renderToStaticMarkup(React.createElement(GenerationToolCard, { status: "completed", heading: "图像生成" }, React.createElement("span", null, "单图结果")));
|
||||
const bulk = renderToStaticMarkup(React.createElement(GenerationToolCard, { status: "completed", isBulk: true, heading: "图像生成" }, React.createElement("span", null, "批量结果")));
|
||||
|
||||
expect(single).toContain("单图结果");
|
||||
expect(bulk).not.toContain("批量结果");
|
||||
});
|
||||
});
|
||||
25
web/test/video-generation-options.test.ts
Normal file
25
web/test/video-generation-options.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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";
|
||||
|
||||
describe("video generation resolution options", () => {
|
||||
test("统一档位包含 1440P 与 4K,并识别常见别名", () => {
|
||||
expect(VIDEO_RESOLUTION_OPTIONS).toEqual([480, 720, 1080, 1440, 2160]);
|
||||
expect(VIDEO_RESOLUTION_CAPABILITY_OPTIONS).toEqual(["480p", "720p", "1080p", "1440p", "2160p"]);
|
||||
expect(normalizeVideoResolution("2k")).toBe("1440");
|
||||
expect(normalizeVideoResolution("1440p")).toBe("1440");
|
||||
expect(normalizeVideoResolution("4K")).toBe("2160");
|
||||
});
|
||||
|
||||
test("保留模型声明的非标准档位而不是静默降级", () => {
|
||||
expect(normalizeVideoResolution("768p")).toBe("768");
|
||||
});
|
||||
|
||||
test("按协议限制实际可选档位", () => {
|
||||
expect(defaultModelCapabilityConfig("newapi-channel-2").video?.resolutions).toEqual(["480p", "720p", "1080p", "1440p", "2160p"]);
|
||||
expect(defaultModelCapabilityConfig("volcengine-ark-video").video?.resolutions).toEqual(["480p", "720p", "1080p"]);
|
||||
expect(defaultModelCapabilityConfig("volcengine-jimeng-video").video?.resolutions).toEqual(["720p"]);
|
||||
expect(defaultModelCapabilityConfig("gemini-veo").video?.resolutions).toEqual(["720p", "1080p"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user