feat(*): 创作配置 - 支持 Midjourney、多图保存与自定义公告

Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
DAO-QIN
2026-10-04 10:43:31 +08:00
committed by ddcat666
parent 002a45f0c3
commit 853c7d2ce7
107 changed files with 9261 additions and 408 deletions

View File

@@ -0,0 +1,48 @@
import { expect, test } from "bun:test";
import { refreshPublicAppearance } from "../src/services/appearance-bootstrap";
import { DEFAULT_CANVAS_APPEARANCE } from "../src/lib/canvas/agent-appearance";
import { commitPublicAppearance, normalizePublicAppearance, useAppearanceStore } from "../src/stores/use-appearance-store";
test("appearance refresh synchronizes a changed Agent name", async () => {
const saved = normalizePublicAppearance({ canvas: { ...DEFAULT_CANVAS_APPEARANCE, agentName: "星河助手" } });
await refreshPublicAppearance(async () => saved);
expect(useAppearanceStore.getState().appearance.canvas?.agentName).toBe("星河助手");
});
test("failed refresh preserves the last saved identity", async () => {
commitPublicAppearance({ canvas: { ...DEFAULT_CANVAS_APPEARANCE, agentName: "影绘" } });
await refreshPublicAppearance(async () => {
throw new Error("offline");
});
expect(useAppearanceStore.getState().appearance.canvas?.agentName).toBe("影绘");
});
test("focus and visibility refresh share one request", async () => {
let resolve!: (value: ReturnType<typeof normalizePublicAppearance>) => void;
let calls = 0;
const pending = new Promise<ReturnType<typeof normalizePublicAppearance>>((done) => {
resolve = done;
});
const fetchAppearance = async () => {
calls++;
return pending;
};
const first = refreshPublicAppearance(fetchAppearance);
const second = refreshPublicAppearance(fetchAppearance);
expect(calls).toBe(1);
resolve(normalizePublicAppearance({ canvas: { ...DEFAULT_CANVAS_APPEARANCE, agentName: "星河助手" } }));
await Promise.all([first, second]);
expect(useAppearanceStore.getState().appearance.canvas?.agentName).toBe("星河助手");
});
test("a late refresh cannot overwrite a newer admin save", async () => {
let resolve!: (value: ReturnType<typeof normalizePublicAppearance>) => void;
const pending = new Promise<ReturnType<typeof normalizePublicAppearance>>((done) => {
resolve = done;
});
const refresh = refreshPublicAppearance(async () => pending);
commitPublicAppearance({ canvas: { ...DEFAULT_CANVAS_APPEARANCE, agentName: "最新名称" } });
resolve(normalizePublicAppearance({ canvas: { ...DEFAULT_CANVAS_APPEARANCE, agentName: "旧名称" } }));
await refresh;
expect(useAppearanceStore.getState().appearance.canvas?.agentName).toBe("最新名称");
});

View File

@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test";
import { applyGeneratedMediaResultMetadata, applyGenerationTaskResultToNodes, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { applyGeneratedMediaResultMetadata, applyGenerationTaskResultToNodes, generationTaskOutputsApplied, shouldRecoverCanvasImageOutputs, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-task-state";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
function videoNode(assetId: string, storageKey: string): CanvasNodeData {
@@ -16,6 +18,30 @@ function videoNode(assetId: string, storageKey: string): CanvasNodeData {
}
describe("applyGeneratedMediaResultMetadata", () => {
test("旧 Midjourney 单图节点需要补齐结果,新堆叠与普通模型不重复恢复", () => {
const old = { ...videoNode("asset", "resource:old"), type: CanvasNodeType.Image, metadata: { content: "/old.png", status: "success" as const, taskId: "task", producedModel: "CHANNEL::Midjourney v8.2 高速" } };
const task = { id: "task", type: "canvas_image", status: "succeeded" as const, prompt: "", attempts: 1, createdAt: "", updatedAt: "", resultJson: JSON.stringify({ images: Array.from({ length: 4 }, () => ({ dataUrl: "/image.png" })) }) };
expect(shouldRecoverCanvasImageOutputs(old)).toBe(true);
expect(generationTaskOutputsApplied(old, task)).toBe(false);
expect(shouldRecoverCanvasImageOutputs({ ...old, metadata: { ...old.metadata, generationOutputCount: 4 } })).toBe(false);
expect(generationTaskOutputsApplied({ ...old, metadata: { ...old.metadata, generationOutputCount: 4 } }, task)).toBe(true);
expect(shouldRecoverCanvasImageOutputs({ ...old, metadata: { ...old.metadata, batchRootId: "root" } })).toBe(false);
expect(shouldRecoverCanvasImageOutputs({ ...old, metadata: { ...old.metadata, producedModel: "other-model" } })).toBe(false);
expect(resetGenerationTaskMetadata({ generationOutputCount: 4 }).generationOutputCount).toBeUndefined();
});
test("多图提交保留保存期间的移动和改名,重复提交不新增或覆盖图片", () => {
const before = { ...videoNode("", ""), type: CanvasNodeType.Image, metadata: { taskId: "task" } };
const result = { ...before, metadata: { taskId: "task", content: "/new.png", isBatchRoot: true, batchChildIds: ["child"] } };
const child = { ...result, id: "child", position: { x: 440, y: 0 }, metadata: { content: "/child.png", batchRootId: before.id } };
const live = { ...before, title: "改名", position: { x: 500, y: 100 } };
const committed = commitCanvasGenerationResult([live], before, result, "task", [child]);
expect(committed[0]).toMatchObject({ title: "改名", position: { x: 500, y: 100 } });
expect(committed[1].position).toEqual({ x: 940, y: 100 });
const edited = { ...committed[1], title: "我的图片", position: { x: 3000, y: 2000 } };
expect(commitCanvasGenerationResult([committed[0], edited], before, result, "task", [child])).toEqual([committed[0], edited]);
expect(() => commitCanvasGenerationResult([], before, result, "task", [child])).toThrow("已删除");
expect(() => commitCanvasGenerationResult([{ ...live, metadata: { taskId: "new-task" } }], before, result, "task", [child])).toThrow("其他生成任务");
});
test("明确指定的节点已删除时不能根据 taskId 误更新其他节点", async () => {
const other = { ...videoNode("asset", "video:key"), metadata: { taskId: "task" } };
const applied = await applyGenerationTaskResultToNodes([other], { id: "task", type: "canvas_video", status: "succeeded", prompt: "", attempts: 1, createdAt: "", updatedAt: "" }, "deleted-node");

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { cancelIncompleteImageBatch, failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild, retireImageBatchChildren } from "../src/lib/canvas/canvas-image-batch-retry";
import { removeCanvasNodes } from "../src/lib/canvas/canvas-project-domain";
import { applyBatchPrimaryImage, removeCanvasNodes } from "../src/lib/canvas/canvas-project-domain";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeStatus } from "../src/types/canvas";
function imageNode(id: string, status: CanvasNodeStatus, metadata: Partial<NonNullable<CanvasNodeData["metadata"]>> = {}): CanvasNodeData {
@@ -17,6 +17,12 @@ function imageNode(id: string, status: CanvasNodeStatus, metadata: Partial<NonNu
}
describe("canvas image batch retry", () => {
test("切换主图同时切换素材绑定,未绑定子图清除旧素材避免云端资源配对错误", () => {
const root = imageNode("root", "success", { assetId: "old-asset", storageKey: "resource:old" });
const primary = imageNode("second", "success", { assetId: "second-asset", storageKey: "resource:second", content: "/second.png" });
expect(applyBatchPrimaryImage(root, primary).metadata).toMatchObject({ primaryImageId: "second", assetId: "second-asset", storageKey: "resource:second" });
expect(applyBatchPrimaryImage(root, { ...primary, metadata: { ...primary.metadata, assetId: undefined } }).metadata?.assetId).toBeUndefined();
});
test("旧批次索引不能删除属于其他批次的节点", () => {
const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["foreign"] });
const foreign = imageNode("foreign", "loading", { batchRootId: "other-root" });

View File

@@ -12,6 +12,7 @@ import { defaultModelCapabilityConfig, pluginWorkflowCapabilityConfig } from "..
import { ChannelModelSettings } from "../src/pages/settings/channel-video-pricing";
import { fetchChannelModels } from "../src/services/api/image";
import { apiClient } from "../src/services/api/request";
import { fetchPluginProviderCatalog } from "../src/services/api/plugin-catalog";
import { createVideoGenerationTask } from "../src/services/api/video";
import { createModelChannel, defaultConfig, modelDisplayName, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability, type AiConfig } from "../src/stores/use-config-store";
@@ -86,6 +87,34 @@ describe("public channel model catalog", () => {
expect(profile.duration).toEqual({ selection: "enum", values: [5], default: 5 });
});
test("preserves declared provider parameters in the protocol catalog", async () => {
apiClient.request = (async () => ({
data: {
code: 0,
data: {
providers: [
{
id: "cangyuan-midjourney-v82",
version: "1.0.0",
name: "沧元 Midjourney 1K/2K · V8.2",
vendor: "沧元算力 / 影绘",
categories: ["image"],
scopes: ["admin.system-channel", "user.custom-channel", "canvas"],
enabled: true,
parameters: [{ name: "providerOptions", type: "object", mapping: "cangyuan-midjourney-v82.speed/reference" }],
workflows: [],
},
],
},
},
})) as typeof apiClient.request;
const result = await fetchPluginProviderCatalog("admin.system-channel", "image");
expect(result).toHaveLength(1);
expect(result[0]?.parameters).toEqual([{ name: "providerOptions", type: "object", mapping: "cangyuan-midjourney-v82.speed/reference" }]);
expect(result[0]?.workflows).toEqual([]);
});
test("preserves six public capabilities without expanding compatibility IDs", async () => {
apiClient.request = (async () => ({
data: {

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import type { ModelProtocolDefinition } from "../src/lib/model-protocols";
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, validateChannelModelPrices, validateChannelModelProtocol } from "../src/pages/admin/components/channel-model-editor-form";
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, updateChannelModelUpstreamCapabilities, validateChannelModelPrices, validateChannelModelProtocol } from "../src/pages/admin/components/channel-model-editor-form";
import { defaultPriceTier } from "../src/pages/admin/components/channel-model-price-tier-form";
const definition = (value: string, capability: ModelProtocolDefinition["capability"], enabled = true): ModelProtocolDefinition => ({ value, capability, enabled, label: value, create: "POST /test", contentType: "application/json", media: "url" });
@@ -43,6 +43,48 @@ describe("channel model editor drafts", () => {
const draft = initialChannelModelValues(null, protocols);
expect(changeChannelModelCapability({ ...draft, capability: "audio" }, protocols).capabilityConfig).toBeUndefined();
});
test("switching capability to a Midjourney protocol uses its image defaults without changing prices", () => {
const draft = initialChannelModelValues(null, protocols);
const next = changeChannelModelCapability({ ...draft, capability: "image", protocol: "cangyuan-midjourney-v7", modelKey: "midjourney-v7" }, [...protocols, definition("cangyuan-midjourney-v7", "image")]);
expect(next.capabilityConfig?.image).toMatchObject({ references: { promptMaxChars: 4000, maxImages: 5, maskSupported: false }, maxOutputs: 1 });
expect(next.priceTiers[0].unitPrice).toBe(0);
});
test("Kacang image protocol selection fills capabilities without changing prices", () => {
for (const protocol of ["kacang-midjourney-special", "kacang-midjourney-v7", "kacang-midjourney"]) {
const draft = initialChannelModelValues(null, protocols);
draft.priceTiers[0].unitPrice = 12;
const next = changeChannelModelCapability({ ...draft, capability: "image", protocol }, [definition(protocol, "image")]);
expect(next.protocol).toBe(protocol);
expect(next.capabilityConfig?.image?.maxOutputs).toBe(1);
expect(next.capabilityConfig?.image?.references.maxImages).toBe(protocol === "kacang-midjourney" ? 5 : 1);
expect(next.priceTiers[0].unitPrice).toBe(12);
expect(updateChannelModelUpstreamCapabilities(next)).toBe(next);
}
});
test("V8.2 upstream changes only retier presets and preserve administrator settings", () => {
const draft = initialChannelModelValues(null, protocols);
const capabilityConfig = defaultModelCapabilityConfig("cangyuan-midjourney-v82", "midjourney-1k");
capabilityConfig.image!.references.promptMaxChars = 12000;
capabilityConfig.image!.size.default = "6:11";
const next = updateChannelModelUpstreamCapabilities({ ...draft, capability: "image", protocol: "cangyuan-midjourney-v82", modelKey: "midjourney-1k", providerModelKey: "midjourney-2k", capabilityConfig });
expect(new Set(next.capabilityConfig!.image!.size.presets!.map((preset) => preset.tier))).toEqual(new Set(["2k"]));
expect(next.capabilityConfig!.image!.size.values).toEqual(capabilityConfig.image!.size.values);
expect(next.capabilityConfig!.image!.size.default).toBe("6:11");
expect(next.capabilityConfig!.image!.references.promptMaxChars).toBe(12000);
expect(next.priceTiers).toBe(draft.priceTiers);
expect(capabilityConfig.image!.size.presets![0].tier).toBe("1k");
expect(updateChannelModelUpstreamCapabilities(next)).toBe(next);
const back = updateChannelModelUpstreamCapabilities({ ...next, providerModelKey: "midjourney-1k" });
expect(back.capabilityConfig!.image!.size.presets![0].tier).toBe("1k");
});
test("existing model profiles retain saved custom capabilities on reopening", () => {
const capabilityConfig = defaultModelCapabilityConfig("cangyuan-midjourney-v7", "midjourney-v7");
capabilityConfig.image!.references.maxImages = 2;
const item = { modelKey: "midjourney-v7", providerModelKey: "midjourney-v7", capability: "image" as const, protocol: "cangyuan-midjourney-v7", capabilityConfig, enabled: true };
const reopened = initialChannelModelValues(item as Parameters<typeof initialChannelModelValues>[0], [definition("cangyuan-midjourney-v7", "image")]);
expect(reopened.capabilityConfig?.image?.references.maxImages).toBe(2);
expect(updateChannelModelUpstreamCapabilities(reopened)).toBe(reopened);
});
test("validation errors map to their mounted tab", () => {
expect(editorSectionForField(["priceTiers", 2, "unitPrice"])).toBe("pricing");
expect(editorSectionForField(["capabilityConfig"])).toBe("capabilities");

View File

@@ -1,6 +1,24 @@
import { expect, test } from "bun:test";
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race";
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race" | "canvas-multi-output";
test("一次任务四图经真实消费链路堆叠并保存,刷新和重放保留四个独立资源", async () => {
type Node = import("../src/types/canvas").CanvasNodeData;
const result = await runScenario<{ edited: boolean; live: Node[]; restored: Node[]; replayed: Node[] }>("canvas-multi-output");
expect(result.edited).toBe(true);
for (const nodes of [result.live, result.restored, result.replayed]) {
expect(nodes).toHaveLength(9);
const root = nodes.find((node) => node.id === "node-0")!;
expect(root).toMatchObject({ title: "用户改名", position: { x: 700, y: 500 }, metadata: { isBatchRoot: true, imageBatchExpanded: false, generationOutputCount: 4, assetId: "asset-0" } });
expect(root.metadata?.batchChildIds).toHaveLength(4);
const children = nodes.filter((node) => node.metadata?.batchRootId === root.id);
expect(children).toHaveLength(4);
expect(children.map((node) => node.metadata?.storageKey)).toEqual([0, 1, 2, 3].map((index) => `resource:image-${index}`));
expect(children.map((node) => node.metadata?.assetId)).toEqual([0, 1, 2, 3].map((index) => `asset-${index}`));
expect(children.map((node) => node.metadata?.naturalWidth)).toEqual([640, 641, 642, 643]);
expect(nodes.filter((node) => node.metadata?.status === "error")).toHaveLength(3);
}
});
type ScenarioResponse<T> = { ok: true; result: T } | { ok: false; error: string };
test("并发生成结果经真实持久化消费链路及重新读取后保留成功、失败和用户编辑", async () => {

View File

@@ -4,7 +4,7 @@ import { getActiveUserScope, setActiveUserScope } from "../../src/lib/user-scope
type InstanceHook = (storeName: string, key: string, value: unknown) => Promise<void> | void;
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race";
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race" | "canvas-multi-output";
function installStorageHarness() {
const originalCreateInstance = localforage.createInstance.bind(localforage);
@@ -393,7 +393,7 @@ async function runMediaCommitRace(mediaType: "video" | "audio") {
}
}
async function runCanvasBatchCommitRace() {
async function runCanvasBatchCommitRace(multiOutput = false) {
const harness = installStorageHarness();
const previousScope = getActiveUserScope();
const { apiClient } = await import("../../src/services/api/request");
@@ -402,7 +402,7 @@ async function runCanvasBatchCommitRace() {
if (config.method !== "post" || config.url !== "/resources/access") throw new Error(`Unexpected request: ${config.method} ${config.url}`);
const requests = JSON.parse(config.data) as { resourceId: string; variant: string }[];
const items = requests.map(({ resourceId, variant }) => {
if (!["image-0", "image-1"].includes(resourceId)) throw new Error(`Unexpected resource: ${resourceId}`);
if (!["image-0", "image-1", "image-2", "image-3"].includes(resourceId)) throw new Error(`Unexpected resource: ${resourceId}`);
return {
resourceId,
access: {
@@ -438,13 +438,13 @@ async function runCanvasBatchCommitRace() {
width: 320,
height: 240,
metadata: { taskId: `task-${index}`, status: index < 2 ? ("loading" as const) : ("error" as const) },
}));
})).filter((node) => !multiOutput || node.id !== "node-1");
const ref: { current: import("../../src/types/canvas").CanvasNodeData[] } = { current: nodes };
const setNodes = createCanvasStateWriter(ref, () => {});
useCanvasStore.getState().updateProject(projectId, { nodes });
await flushCanvasStorePersistence();
useAssetStore.setState({
assets: [0, 1].map((index) => ({
assets: (multiOutput ? [0, 1, 2, 3] : [0, 1]).map((index) => ({
id: `asset-${index}`,
kind: "image" as const,
title: "generated",
@@ -452,7 +452,7 @@ async function runCanvasBatchCommitRace() {
tags: [],
createdAt: "2026-09-21T00:00:00Z",
updatedAt: "2026-09-21T00:00:00Z",
data: { dataUrl: "/image.png", storageKey: `resource:image-${index}`, width: 640, height: 480, bytes: 100, mimeType: "image/png" },
data: { dataUrl: "/image.png", storageKey: `resource:image-${index}`, width: multiOutput ? 640 + index : 640, height: 480, bytes: 100, mimeType: "image/png" },
})),
});
unregister = registerCanvasGenerationLiveProject({ scope: getActiveUserScope(), projectId, adapter: { read: () => ({ nodes: ref.current, connections: [], chatSessions: [], activeChatId: null }), write: (state) => setNodes(state.nodes) } });
@@ -463,13 +463,24 @@ async function runCanvasBatchCommitRace() {
setNodes((current) => [...current.map((node) => (node.id === "node-0" ? { ...node, title: "用户改名", position: { x: 700, y: 500 } } : node)), { ...nodes[0], id: "new-during-save", metadata: {} }]);
};
await Promise.all(
[0, 1].map((index) =>
(multiOutput ? [0] : [0, 1]).map((index) =>
applyCanvasGenerationTaskNodeEffect({
projectId,
nodeId: `node-${index}`,
nodesRef: ref,
setNodes,
task: { id: `task-${index}`, projectId, type: "canvas_image", status: "succeeded", prompt: "test", attempts: 1, createdAt: "2026-09-21T00:00:00Z", updatedAt: "2026-09-21T00:00:01Z", resultJson: "{}" },
task: {
id: `task-${index}`,
projectId,
type: "canvas_image",
status: "succeeded",
prompt: "test",
attempts: 1,
createdAt: "2026-09-21T00:00:00Z",
updatedAt: "2026-09-21T00:00:01Z",
resultJson: multiOutput ? JSON.stringify({ images: [0, 1, 2, 3].map((i) => ({ dataUrl: "/image.png", storageKey: `resource:image-${i}` })) }) : "{}",
...(multiOutput ? { outputs: [0, 1, 2, 3].map((i) => ({ outputIndex: i, mediaType: "image" as const, materializedAssetId: `asset-${i}` })) } : {}),
},
output: { outputIndex: 0, mediaType: "image", materializedAssetId: `asset-${index}` },
effectKey: `attach:task-${index}:0`,
}),
@@ -478,7 +489,31 @@ async function runCanvasBatchCommitRace() {
useCanvasStore.getState().updateProject(projectId, { nodes: ref.current });
await flushCanvasStorePersistence();
const stored = parseCanvasStorageDocument(await localForageStorageForScope(getActiveUserScope()).getItem(CANVAS_STORE_KEY), []);
return { edited, live: ref.current, restored: stored.state.projects.find((project) => project.id === projectId)?.nodes };
const restored = stored.state.projects.find((project) => project.id === projectId)?.nodes;
const live = ref.current;
if (multiOutput) {
setNodes(restored!);
await applyCanvasGenerationTaskNodeEffect({
projectId,
nodeId: "node-0",
nodesRef: ref,
setNodes,
task: {
id: "task-0",
projectId,
type: "canvas_image",
status: "succeeded",
prompt: "test",
attempts: 1,
createdAt: "",
updatedAt: "",
resultJson: JSON.stringify({ images: [0, 1, 2, 3].map((i) => ({ dataUrl: "/image.png", storageKey: `resource:image-${i}` })) }),
},
output: { outputIndex: 0, mediaType: "image", materializedAssetId: "asset-0" },
effectKey: "attach:task-0:0",
});
}
return { edited, live, restored, replayed: ref.current };
} finally {
unregister?.();
apiClient.defaults.adapter = originalAdapter;
@@ -496,9 +531,11 @@ self.onmessage = async (event: MessageEvent<Scenario>) => {
? await runScopeCleanupAfterSwitch()
: event.data === "scope-cleanup-late-canvas-reference"
? await runScopeCleanupAfterLateCanvasReference()
: event.data === "canvas-batch-commit-race"
? await runCanvasBatchCommitRace()
: await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video");
: event.data === "canvas-multi-output"
? await runCanvasBatchCommitRace(true)
: event.data === "canvas-batch-commit-race"
? await runCanvasBatchCommitRace()
: await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video");
self.postMessage({ ok: true, result });
} catch (error) {
self.postMessage({ ok: false, error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });

View File

@@ -11,6 +11,24 @@ import { resolveImageRequestSize, validateImageSize } from "../src/services/api/
import { buildGeminiImageGenerationConfig } from "../src/lib/gemini-image";
describe("统一图片分辨率与宽高比", () => {
test("管理员档位开关按已有比例启用,部分比例不显示为关闭", () => {
for (const model of ["midjourney-1k", "midjourney-2k"]) {
const profile = defaultImageCapabilityConfig("cangyuan-midjourney-v82", model);
const editor = renderToStaticMarkup(<ImageSizePresetsEditor profile={profile} onChange={() => {}} />);
const active = model === "midjourney-2k" ? "2k" : "1k";
for (const tier of IMAGE_RESOLUTIONS) {
const button = [...editor.matchAll(/<button\b[^>]*>/g)].map((match) => match[0]).find((tag) => tag.includes(`aria-label="启用 ${tier.toUpperCase()} 规格"`));
expect(button).toBeDefined();
expect(button).toContain(`aria-checked="${tier === active}"`);
}
}
const profile = defaultImageCapabilityConfig();
profile.size = imageSizeConfigWithPresets(profile, [imagePresetForRatio("2k", "16:9")]);
const editor = renderToStaticMarkup(<ImageSizePresetsEditor profile={profile} onChange={() => {}} />);
const button = [...editor.matchAll(/<button\b[^>]*>/g)].map((match) => match[0]).find((tag) => tag.includes('aria-label="启用 2K 规格"'));
expect(button).toContain('aria-checked="true"');
});
test("默认 Gemini 质量枚举不能把未启用的 1K 显示出来", () => {
const profile = defaultImageCapabilityConfig("gemini-image", "nano-banana-pro-4k");
profile.size = imageSizeConfigWithPresets(profile, IMAGE_RATIOS.map((ratio) => imagePresetForRatio("4k", ratio)));

View File

@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
import test from "node:test";
// Bun 直接执行 TypeScript 测试时需要保留扩展名;生产 tsconfig 不包含 test/。
import { DEFAULT_VIDEO_PROMPT_MAX_CHARS, defaultModelCapabilityConfig, normalizeVideoValue } from "../src/lib/model-capabilities.ts";
import { DEFAULT_VIDEO_PROMPT_MAX_CHARS, defaultModelCapabilityConfig, normalizeImageValue, normalizeVideoValue } from "../src/lib/model-capabilities.ts";
import { imageTierAvailable } from "../src/lib/image-size-presets.ts";
test("text multimodal capability is not guessed from a model name", () => {
for (const model of ["gpt-4o", "gemini-2.5-pro", "doubao-seed"]) {
@@ -39,3 +40,58 @@ test("raising the video default leaves text and image limits untouched", () => {
assert.equal(profile.text!.references.promptMaxChars, 32000);
assert.equal(profile.image!.references.promptMaxChars, 32000);
});
test("Midjourney protocol defaults expose only supported controls and one submission", () => {
for (const [protocol, model, tier, maxImages, mask, promptMaxChars, ratios] of [
["cangyuan-midjourney-v7", "midjourney-v7", "1k", 5, false, 4000, 10],
["cangyuan-midjourney-v82", "midjourney-1k", "1k", 1, true, 32000, 15],
["cangyuan-midjourney-v82", "midjourney-2k", "2k", 1, true, 32000, 15],
["cangyuan-midjourney-v82", "", "1k", 1, true, 32000, 15],
] as const) {
const image = defaultModelCapabilityConfig(protocol, model).image!;
assert.equal(image.references.maxImages, maxImages);
assert.equal(image.references.maskSupported, mask);
assert.equal(image.references.promptMaxChars, promptMaxChars);
assert.equal(image.maxOutputs, 1);
assert.equal(image.quality.supported, false);
assert.equal(image.transparentBackground.supported, false);
assert.equal(image.responseFormat.supported, false);
assert.equal(image.outputFormat.supported, false);
assert.equal(image.size.parameter, "aspect_ratio");
assert.equal(image.size.default, "auto");
assert.equal(image.size.allowCustom, false);
assert.equal(image.size.presets!.length, ratios);
assert.equal(image.size.values.length, ratios + 1);
assert.equal(imageTierAvailable(image, tier), true);
assert.equal(imageTierAvailable(image, tier === "1k" ? "2k" : "1k"), false);
assert.equal(imageTierAvailable(image, "4k"), false);
assert.deepEqual(normalizeImageValue(image, { size: "16:9", quality: "high", transparentBackground: "true", count: "4" }), { size: "16:9", quality: tier, transparentBackground: "false", count: "1" });
}
});
test("Kacang protocol selection uses the supplier ratio and reference contracts", () => {
for (const [protocol, ratio, maxImages, presets, auto] of [
["kacang-midjourney-special", "16:9", 1, 10, false],
["kacang-midjourney-v7", "16:9", 1, 11, true],
["kacang-midjourney", "9:16", 5, 11, true],
] as const) {
const image = defaultModelCapabilityConfig(protocol).image!;
assert.equal(image.references.maxImages, maxImages);
assert.equal(image.references.maskSupported, false);
assert.equal(image.size.default, ratio);
assert.equal(image.size.values.includes("auto"), auto);
assert.equal(image.size.presets!.length, presets);
for (const preset of image.size.presets!) assert.ok(image.size.values.includes(preset.ratio), `${protocol}: preset ${preset.ratio} must be an upstream-supported value`);
if (auto) assert.ok(image.size.presets!.some((preset) => preset.ratio === "9:21"));
assert.equal(image.maxOutputs, 1);
assert.equal(image.size.allowCustom, false);
assert.equal(image.quality.supported, false);
assert.equal(image.responseFormat.supported, false);
assert.equal(image.transparentBackground.supported, false);
assert.equal(image.outputFormat.supported, false);
assert.equal(imageTierAvailable(image, "1k"), true);
assert.equal(imageTierAvailable(image, "2k"), false);
assert.equal(imageTierAvailable(image, "4k"), false);
assert.deepEqual(normalizeImageValue(image, { size: "bad", quality: "high", transparentBackground: "true", count: "4" }), { size: ratio, quality: "1k", transparentBackground: "false", count: "1" });
}
});

View File

@@ -0,0 +1,70 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { UpdateAnnouncementContent } from "../src/components/layout/update-announcement-content";
import { DEFAULT_UPDATE_ANNOUNCEMENT, moveUpdateItem, validateUpdateAnnouncement, updateAnnouncementVersion, type UpdateAnnouncement } from "../src/lib/update-announcement";
import { normalizePublicAppearance } from "../src/stores/use-appearance-store";
import { refreshPublicAppearance } from "../src/services/appearance-bootstrap";
const custom = (): UpdateAnnouncement => ({
enabled: true,
currentVersion: "影绘 2.0",
releases: [
{
id: "r1",
version: "影绘 2.0",
title: "图文与视频公告",
date: "2026-10-03",
blocks: [
{ id: "text", type: "text", text: "**新功能**\n\n- 内容一\n- 内容二", resourceId: "", caption: "", layout: "full" },
{ id: "image", type: "image", text: "", resourceId: "image1", caption: "功能示意", layout: "half", mediaUrl: "/api/public/appearance/updates/image1" },
{ id: "video", type: "video", text: "", resourceId: "video1", caption: "视频演示", layout: "half", mediaUrl: "/api/public/appearance/updates/video1" },
],
},
],
});
test("switch selects the custom display version and restores the real version", () => {
expect(updateAnnouncementVersion(custom(), "1.6.0")).toBe("影绘 2.0");
expect(updateAnnouncementVersion({ ...custom(), enabled: false }, "v1.6.0")).toBe("v1.6.0");
expect(updateAnnouncementVersion(undefined, "1.6.0")).toBe("v1.6.0");
});
test("old appearance data defaults to the original log", () => {
expect(normalizePublicAppearance({}).updates).toEqual(DEFAULT_UPDATE_ANNOUNCEMENT);
});
test("publication rejects missing versions, duplicate versions, and incomplete media", () => {
expect(validateUpdateAnnouncement(custom())).toBeUndefined();
expect(validateUpdateAnnouncement({ ...DEFAULT_UPDATE_ANNOUNCEMENT, enabled: true })).toBeDefined();
const value = custom();
value.releases[0].blocks[1].resourceId = "";
expect(validateUpdateAnnouncement(value)).toBeDefined();
const duplicate = custom();
duplicate.releases.push({ ...duplicate.releases[0], id: "r2" });
expect(validateUpdateAnnouncement(duplicate)).toBeDefined();
});
test("reorder preserves content identity and does not modify the source", () => {
const blocks = custom().releases[0].blocks;
expect(moveUpdateItem(blocks, 2, -1).map((block) => block.id)).toEqual(["text", "video", "image"]);
expect(blocks.map((block) => block.id)).toEqual(["text", "image", "video"]);
expect(moveUpdateItem(blocks, 0, -1)).toBe(blocks);
});
test("real renderer supports text, images, videos, dates and responsive half layout", () => {
const html = renderToStaticMarkup(<UpdateAnnouncementContent value={custom()} />);
expect(html).toContain("<strong>新功能</strong>");
expect(html).toContain("is-half");
expect(html).toContain('alt="功能示意"');
expect(html).toContain("<video");
expect(html).toContain('controls=""');
expect(html).toMatch(/datetime="2026-10-03"/i);
});
test("renderer escapes raw HTML and blocks inline Markdown images", () => {
const value = custom();
value.releases[0].blocks[0].text = "<script>alert(1)</script>\n\n![hidden](https://example.com/tracker.png)";
const html = renderToStaticMarkup(<UpdateAnnouncementContent value={value} />);
expect(html).not.toContain("<script>");
expect(html).not.toContain("tracker.png");
});
test("appearance refresh carries the saved announcement switch and version", async () => {
const result = await refreshPublicAppearance(async () => normalizePublicAppearance({ updates: custom() }));
expect(result?.updates?.currentVersion).toBe("影绘 2.0");
expect(result?.updates?.enabled).toBe(true);
});

View File

@@ -0,0 +1,83 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ModelCapabilityEditor } from "../src/components/model-capability-editor";
import { defaultModelCapabilityConfig, modelCapabilityConfigFor, normalizeModelCapabilityConfig, normalizeVideoValue } from "../src/lib/model-capabilities";
import type { ModelProtocolWorkflow } from "../src/lib/model-protocols";
import { resolveWorkflowVideoScreenSpec, updateWorkflowVideoScreenSpec } from "../src/lib/video-screen-specs";
const workflow: ModelProtocolWorkflow = {
id: "minimax_h3_zm_u24",
label: "H3 ZM U24",
providerId: "autodl-comfyui",
capability: "video",
parameters: [{ name: "resolution", mapping: "resolution", type: "string", values: ["480p竖", "768p竖", "480p横", "768p横", "480p(1:1)", "768p(1:1)"] }],
defaults: { resolution: "768p竖" },
};
const initial = () => resolveWorkflowVideoScreenSpec(defaultModelCapabilityConfig("autodl-comfyui", workflow.id).video!, workflow);
test("removed switch and draft cannot replace the effective six choices", () => {
for (const customSizeEnabled of [false, true]) {
const original = Object.assign(initial(), {
customSizeEnabled,
customScreenSpec: { ratios: ["2:3"], defaultRatio: "2:3", resolutions: ["invalid"], defaultResolution: "missing" },
});
const resolved = resolveWorkflowVideoScreenSpec(original, workflow);
expect(resolved.resolutions).toEqual(workflow.parameters[0]!.values!);
expect(resolved.defaultResolution).toBe("768p竖");
expect(resolved.ratios).toEqual([]);
expect(resolved.duration).toEqual(original.duration);
expect(resolved.references).toEqual(original.references);
expect(resolved.operations).toEqual(original.operations);
}
});
test("selected presets and default remain active after saving and reloading", () => {
const profile = updateWorkflowVideoScreenSpec(initial(), workflow, { resolutions: [" 480p横 ", "768p(1:1)", "480P横", ""], defaultResolution: " 768P(1:1) " });
const reloaded = normalizeModelCapabilityConfig(JSON.parse(JSON.stringify({ version: 1, video: profile }))).video!;
expect(reloaded.resolutions).toEqual(["480p横", "768p(1:1)"]);
expect(reloaded.defaultResolution).toBe("768p(1:1)");
expect(normalizeVideoValue(reloaded, { seconds: "6", ratio: "16:9", resolution: "768p(1:1)" })).toEqual({ seconds: "6", ratio: "", resolution: "768p(1:1)" });
expect(updateWorkflowVideoScreenSpec(profile, workflow, { resolutions: [], defaultResolution: "" }).resolutions).toEqual([]);
});
test("creation and canvas use configured choices and replace stale cached selections", () => {
const profile = updateWorkflowVideoScreenSpec(initial(), workflow, { resolutions: ["480p横", "768p(1:1)"], defaultResolution: "768p(1:1)" });
const config = { channels: [{ id: "autodl", models: [workflow.id], modelCosts: [{ model: workflow.id, protocol: "autodl-comfyui", capabilityConfig: { version: 1, video: profile } }] }] };
const selected = modelCapabilityConfigFor(config, `autodl::${workflow.id}`).video!;
expect(normalizeVideoValue(selected, { seconds: "6", ratio: "16:9", resolution: "768p竖" })).toEqual({ seconds: "6", ratio: "", resolution: "768p(1:1)" });
expect(normalizeVideoValue(selected, { resolution: "480p横" }).resolution).toBe("480p横");
});
test("switching workflows refreshes the available presets without carrying foreign labels", () => {
const previous = updateWorkflowVideoScreenSpec(initial(), workflow, { resolutions: ["480p横"], defaultResolution: "480p横" });
const next = resolveWorkflowVideoScreenSpec(previous, { ...workflow, id: "z0901", parameters: [{ name: "resolution", type: "string", values: ["768p竖(768*1344)"] }], defaults: { resolution: "768p竖(768*1344)" } });
expect(next.resolutions).toEqual(["768p竖(768*1344)"]);
expect(next.defaultResolution).toBe("768p竖(768*1344)");
});
test("real editor keeps enabled fields and original card copy without switch or explanation", () => {
for (const section of ["all", "protocol"] as const) {
const html = renderToStaticMarkup(<ModelCapabilityEditor capability="video" protocol="autodl-comfyui" model={workflow.id} workflows={[workflow]} section={section} value={{ version: 1, video: initial() }} />);
expect(html).not.toContain("自定义比例分辨率");
expect(html).not.toContain("画幅由完整分辨率标签");
expect(html).not.toContain("使用当前工作流的插件预设");
expect(html).toContain("768p(1:1)");
expect(html).toContain('aria-label="输出分辨率"');
expect(html).toContain('aria-label="默认分辨率"');
expect(html).not.toContain("ant-select-disabled");
expect(html).not.toContain("支持比例");
expect(html).not.toContain("默认比例");
if (section === "protocol") {
expect(html).toContain("画面规格");
expect(html).toContain("控制比例、分辨率及默认输出");
}
}
});
test("other video protocols keep their existing settings and controls", () => {
const profile = defaultModelCapabilityConfig("minimax-video").video!;
expect(resolveWorkflowVideoScreenSpec(profile)).toBe(profile);
const html = renderToStaticMarkup(<ModelCapabilityEditor capability="video" protocol="minimax-video" section="protocol" />);
expect(html).not.toContain("自定义比例分辨率");
expect(html).toContain("支持比例");
});