feat(*): 生图与视频工作台 - 新增独立生成流程并整合平台配置
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
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, updateChannelModelUpstreamCapabilities, validateChannelModelPrices, validateChannelModelProtocol } from "../src/pages/admin/components/channel-model-editor-form";
|
||||
import { changeChannelModelCapability, duplicateChannelModelValues, 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";
|
||||
import type { ChannelModel, ChannelModelPriceTier } from "../src/services/api/wallet";
|
||||
|
||||
const definition = (value: string, capability: ModelProtocolDefinition["capability"], enabled = true): ModelProtocolDefinition => ({ value, capability, enabled, label: value, create: "POST /test", contentType: "application/json", media: "url" });
|
||||
const protocols = [definition("disabled-text", "text", false), definition("text", "text"), definition("image", "image"), definition("video", "video")];
|
||||
@@ -26,6 +27,67 @@ describe("channel model editor drafts", () => {
|
||||
expect(second.modelKey).toBe("");
|
||||
expect(second.tags).toEqual([]);
|
||||
});
|
||||
test("duplicate drafts clear model IDs and inherited tier IDs while preserving custom routing and configuration", () => {
|
||||
const tier = (id: string, providerModelKey: string, unitPriceMicrocredits: number): ChannelModelPriceTier => ({
|
||||
id,
|
||||
channelModelId: "source-id",
|
||||
selector: {},
|
||||
selectorKey: "{}",
|
||||
resolution: "*",
|
||||
videoSeconds: 0,
|
||||
providerModelKey,
|
||||
billingMode: "fixed_request",
|
||||
unitPriceMicrocredits,
|
||||
inputTokenPriceMicrocredits: 0,
|
||||
outputTokenPriceMicrocredits: 0,
|
||||
cachedTokenPriceMicrocredits: 0,
|
||||
priceConfigured: true,
|
||||
enabled: true,
|
||||
priceVersion: 1,
|
||||
createdAt: "2026-08-29T00:00:00Z",
|
||||
updatedAt: "2026-08-29T00:00:00Z",
|
||||
});
|
||||
const item: ChannelModel = {
|
||||
id: "source-id",
|
||||
channelId: "channel-id",
|
||||
modelKey: "product-video",
|
||||
providerModelKey: "models/vendor-video",
|
||||
displayName: "视频模型组",
|
||||
channelLabel: "原渠道",
|
||||
tags: [{ text: "稳定", color: "green" }],
|
||||
description: "视频生成配置",
|
||||
icon: "",
|
||||
capability: "video",
|
||||
protocol: "video",
|
||||
billingMode: "fixed_request",
|
||||
unitPriceMicrocredits: 1_000_000,
|
||||
inputTokenPriceMicrocredits: 0,
|
||||
outputTokenPriceMicrocredits: 0,
|
||||
cachedTokenPriceMicrocredits: 0,
|
||||
priceConfigured: true,
|
||||
enabled: false,
|
||||
priceVersion: 1,
|
||||
priceTiers: [tier("default-tier", "vendor-video", 5_000_000), tier("custom-tier", "vendor-video-hq", 8_000_000)],
|
||||
createdAt: "2026-08-29T00:00:00Z",
|
||||
updatedAt: "2026-08-29T00:00:00Z",
|
||||
};
|
||||
|
||||
const draft = duplicateChannelModelValues(item, protocols);
|
||||
|
||||
expect(draft).toMatchObject({
|
||||
modelKey: "",
|
||||
providerModelKey: "",
|
||||
displayName: "视频模型组",
|
||||
channelLabel: "原渠道",
|
||||
tags: [{ text: "稳定", color: "green" }],
|
||||
description: "视频生成配置",
|
||||
capability: "video",
|
||||
protocol: "video",
|
||||
enabled: false,
|
||||
priceTiers: [{ providerModelKey: "", unitPrice: 5 }, { providerModelKey: "vendor-video-hq", unitPrice: 8 }],
|
||||
});
|
||||
expect(draft.tags).not.toBe(item.tags);
|
||||
});
|
||||
test("missing catalogs do not invent a protocol", () => {
|
||||
expect(initialChannelModelValues(null, []).protocol).toBeUndefined();
|
||||
expect(() => validateChannelModelProtocol("text", "text", [])).toThrow("已启用");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ModelLabel, ModelPicker } from "../src/components/model-picker";
|
||||
import { canvasThemes } from "../src/lib/canvas-theme";
|
||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
import { requestCreditCost } from "../src/lib/model-pricing";
|
||||
import type { ModelRequirements } from "../src/lib/model-selection";
|
||||
import { createModelChannel, defaultConfig, normalizeConfigSnapshot } from "../src/stores/use-config-store";
|
||||
@@ -70,6 +71,35 @@ test("model rows preserve multiple promotional labels and colors without alterin
|
||||
expect(markup).not.toContain('aria-label="模型标签"');
|
||||
});
|
||||
|
||||
test("video capability tag is derived from limits and precedes editable model tags", () => {
|
||||
const config = fixture();
|
||||
const cost = config.channels[0].modelCosts![0];
|
||||
cost.capabilityConfig = defaultModelCapabilityConfig();
|
||||
Object.assign(cost.capabilityConfig.video!.references, { maxImages: 30, maxVideos: 10, maxAudios: 10 });
|
||||
cost.tags = [{ text: "限时特价", color: "purple" }, { text: "按次收费", color: "blue" }];
|
||||
|
||||
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={canvasThemes.light} creationVariant showConfiguredModelName={false} showPrice showDescription />);
|
||||
|
||||
expect(markup).toContain('data-color="green">图×30 · 视×10 · 音×10');
|
||||
expect(markup.indexOf("图×30 · 视×10 · 音×10")).toBeLessThan(markup.indexOf("限时特价"));
|
||||
expect(markup.indexOf("限时特价")).toBeLessThan(markup.indexOf("按次收费"));
|
||||
});
|
||||
|
||||
test("video capability tag omits zero limits while preserving editable model tags", () => {
|
||||
const config = fixture();
|
||||
const cost = config.channels[0].modelCosts![0];
|
||||
cost.capabilityConfig = defaultModelCapabilityConfig();
|
||||
Object.assign(cost.capabilityConfig.video!.references, { maxImages: 0, maxVideos: 0, maxAudios: 0 });
|
||||
cost.tags = [{ text: "按次收费", color: "blue" }];
|
||||
|
||||
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={canvasThemes.light} creationVariant showConfiguredModelName={false} showPrice showDescription />);
|
||||
|
||||
expect(markup).not.toContain("图×");
|
||||
expect(markup).not.toContain("视×");
|
||||
expect(markup).not.toContain("音×");
|
||||
expect(markup).toContain('data-color="blue">按次收费');
|
||||
});
|
||||
|
||||
test("the selected model and request estimate still require an exact specification match", () => {
|
||||
const config = fixture();
|
||||
const render = (value: ModelRequirements) => renderToStaticMarkup(<ModelPicker config={config} value={config.model} capability="video" requirements={value} onChange={() => {}} />);
|
||||
|
||||
21
web/test/model-tags.test.ts
Normal file
21
web/test/model-tags.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { videoReferenceCapabilityTag } from "../src/lib/model-tags";
|
||||
|
||||
test("formats video reference limits in image, video, audio order", () => {
|
||||
const examples = [
|
||||
[{ maxImages: 30, maxVideos: 10, maxAudios: 10 }, "图×30 · 视×10 · 音×10"],
|
||||
[{ maxImages: 9, maxVideos: 3, maxAudios: 3 }, "图×9 · 视×3 · 音×3"],
|
||||
[{ maxImages: 30, maxVideos: 0, maxAudios: 0 }, "图×30"],
|
||||
[{ maxImages: 30, maxVideos: 0, maxAudios: 10 }, "图×30 · 音×10"],
|
||||
] as const;
|
||||
|
||||
for (const [references, text] of examples) {
|
||||
expect(videoReferenceCapabilityTag(references)).toEqual({ text, color: "green" });
|
||||
}
|
||||
});
|
||||
|
||||
test("omits zero, negative, and non-finite reference limits", () => {
|
||||
expect(videoReferenceCapabilityTag({ maxImages: 0, maxVideos: 0, maxAudios: 0 })).toBeUndefined();
|
||||
expect(videoReferenceCapabilityTag({ maxImages: -1, maxVideos: Number.NaN, maxAudios: Number.POSITIVE_INFINITY })).toBeUndefined();
|
||||
expect(videoReferenceCapabilityTag()).toBeUndefined();
|
||||
});
|
||||
8
web/test/workbench-features.test.ts
Normal file
8
web/test/workbench-features.test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import { defaultFeatureAvailability } from "./use-user-store";
|
||||
|
||||
test("new workbench entries stay closed until the backend explicitly opens them", () => {
|
||||
expect(defaultFeatureAvailability.imageWorkbenchEnabled).toBe(false);
|
||||
expect(defaultFeatureAvailability.videoWorkbenchEnabled).toBe(false);
|
||||
});
|
||||
54
web/test/workbench-tools.test.ts
Normal file
54
web/test/workbench-tools.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { filterWorkbenchTools, workbenchTaskSnapshot } from "./image-workbench-page";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import type { WorkbenchTool } from "@/services/api/workbench";
|
||||
|
||||
const tools: WorkbenchTool[] = [
|
||||
{ id: "face", kind: "image", categoryId: "people", name: "脸部三视图", subtitle: "生成正脸、侧脸", description: "角色参考", sortOrder: 0, enabled: true, supportedModes: ["image"], presets: [] },
|
||||
{ id: "product", kind: "image", categoryId: "product", name: "商品白底图", subtitle: "生成商品图", description: "电商产品", sortOrder: 1, enabled: true, supportedModes: ["image"], presets: [] },
|
||||
];
|
||||
|
||||
describe("workbenchTaskSnapshot", () => {
|
||||
test("restores video settings and reference identity from safe task details", () => {
|
||||
const task = {
|
||||
inputJson: JSON.stringify({
|
||||
metadata: {
|
||||
source: "workbench",
|
||||
workbenchKind: "video",
|
||||
workbenchToolId: "tool-video",
|
||||
workbenchPresetId: "preset-video",
|
||||
workbench: {
|
||||
kind: "video",
|
||||
parameters: { seconds: 8, ratio: "16:9", resolution: "1080p", generateAudio: true, watermark: false },
|
||||
references: [{ resourceId: "resource-1", kind: "image", role: "first_frame", slotKey: "subject" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as GenerationTask;
|
||||
|
||||
expect(workbenchTaskSnapshot(task)).toEqual({
|
||||
kind: "video",
|
||||
toolId: "tool-video",
|
||||
presetId: "preset-video",
|
||||
parameters: { seconds: 8, ratio: "16:9", resolution: "1080p", generateAudio: true, watermark: false },
|
||||
references: [{ resourceId: "resource-1", kind: "image", role: "first_frame", slotKey: "subject" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not treat a task-list summary as loaded restore data", () => {
|
||||
expect(workbenchTaskSnapshot({ id: "summary" } as GenerationTask)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterWorkbenchTools", () => {
|
||||
test("combines category and case-insensitive text search", () => {
|
||||
expect(filterWorkbenchTools(tools, "people", "侧脸").map((tool) => tool.id)).toEqual(["face"]);
|
||||
expect(filterWorkbenchTools(tools, "people", "商品")).toEqual([]);
|
||||
});
|
||||
|
||||
test("searches tool description and returns all tools for blank query", () => {
|
||||
expect(filterWorkbenchTools(tools, "all", "电商").map((tool) => tool.id)).toEqual(["product"]);
|
||||
expect(filterWorkbenchTools(tools, "all", " ")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user