import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ImageParameterFields, ImageParameterSchemaEditor, ImagePixelMappingEditor } from "../src/components/image-parameter-fields";
import { ModelCapabilityEditor } from "../src/components/model-capability-editor";
import { imagePresetForRatio } from "../src/lib/image-size-presets";
import { imageParameterConstraints, imageParameterRequest, initializeImageRatioDefaults, normalizeImageParameters, projectImageParameters, updateImageParameterSchema, updateImageParameterSize, type ImageParameter } from "../src/lib/image-parameters";
import { defaultImageCapabilityConfig } from "../src/lib/model-capabilities";
import { projectLogicalCapability } from "../src/lib/user-session";
import { capabilitySpecFromChannelModel, mergeCapabilitySpecs, normalizeCapabilitySpecForSources } from "../src/pages/admin/logical-models/model-routing-capabilities";
import { defaultPriceTier, skuSelectorFromForm } from "../src/pages/admin/components/channel-model-price-tier-form";
import { prepareBackendGenerationTask } from "../src/services/api/generation-task";
import { defaultConfig, createModelChannel, encodeChannelModel } from "../src/stores/use-config-store";
import type { ChannelModel } from "../src/services/api/wallet";
import { specFromConfig, generationSpecMetadata, synchronizeGenerationSpec } from "../src/lib/canvas/generation-contract";
import type { CanvasNodeData } from "../src/types/canvas";
const parameters: ImageParameter[] = [
{ name: "imageResolution", label: "分辨率", type: "string", values: ["small", "large"], default: "small", pricing: true },
{ name: "creativeAmount", label: "创意程度", type: "integer", min: 0, max: 200, step: 1, default: 0 },
{ name: "rawMode", label: "原始模式", type: "boolean", default: false },
];
function fixture(fields = parameters) {
const image = projectImageParameters(defaultImageCapabilityConfig(), fields);
const spec = capabilitySpecFromChannelModel({ capability: "image", capabilityConfig: { version: 1, image } } as ChannelModel)!;
const channel = createModelChannel({
id: "test",
models: ["opaque-model"],
modelCosts: [{ model: "opaque-model", capability: "image", protocol: "opaque-plugin", logicalModelId: "logical", logicalCapabilitySpec: spec, capabilityConfig: { version: 1, image }, billingMode: "fixed_request", unitPriceMicrocredits: 1 }],
});
const model = encodeChannelModel(channel.id, "opaque-model");
return { image, spec, config: { ...defaultConfig, channels: [channel], model, imageModel: model, imageResolution: "large", imageParameters: JSON.stringify({ creativeAmount: 0, rawMode: false, oldParameter: 999 }) } };
}
describe("plugin image parameters across model configuration and generation", () => {
test("new resolution selects the six preferred ratios without changing existing choices", () => {
const fields: ImageParameter[] = [
{ name: "size", label: "画幅", type: "string", encoding: "pixels" },
{ name: "imageResolution", label: "分辨率", type: "string", values: ["1k"], default: "1k" },
];
const { image } = fixture(fields);
image.size.presets = [];
const initialized = initializeImageRatioDefaults(image, fields);
expect(initialized.size.presets?.map((p) => p.ratio)).toEqual(["1:1", "4:3", "3:4", "16:9", "9:16", "21:9"]);
const cleared = updateImageParameterSize(initialized, { ...initialized.size, values: ["auto"], default: "auto", presets: [] });
const added = updateImageParameterSchema(
cleared,
cleared.parameters!.map((p) => (p.name === "imageResolution" ? { ...p, values: ["1k", "2k"] } : p)),
fields,
);
expect(added.size.presets?.filter((p) => p.tier === "1k")).toHaveLength(0);
expect(added.size.presets?.filter((p) => p.tier === "2k")).toHaveLength(6);
const reopened = projectImageParameters(JSON.parse(JSON.stringify(added)), added.parameters!);
expect(reopened.size.presets).toEqual(added.size.presets);
});
test("preferred ratios respect plugin limits and retain exact configured sizes", () => {
const fields: ImageParameter[] = [
{ name: "size", label: "画幅", type: "string", values: ["1:1", "9:16", "2:3"], default: "1:1", encoding: "ratio" },
{ name: "imageResolution", label: "分辨率", type: "string", values: ["1k", "2k"], default: "1k" },
];
const { image } = fixture(fields);
const exact = { tier: "1k", ratio: "1:1", width: 1080, height: 1080, size: "1080x1080" };
image.size.presets = [exact];
const initialized = initializeImageRatioDefaults(image, fields);
expect(initialized.size.presets?.[0]).toEqual(exact);
expect(initialized.size.presets?.filter((p) => p.tier === "2k").map((p) => p.ratio)).toEqual(["1:1", "9:16"]);
});
test("declared resolution keeps the existing grouped ratio editor and exact presets", () => {
const fields: ImageParameter[] = [
{ name: "size", label: "画幅", type: "string", values: ["16:9", "1:1"], default: "16:9", encoding: "pixels" },
{ name: "imageResolution", label: "分辨率", type: "string", values: ["2k"], default: "2k" },
];
const { image } = fixture(fields);
image.size.presets = [{ ...imagePresetForRatio("2k", "16:9"), width: 2560, height: 1440, size: "2560x1440" }];
const html = renderToStaticMarkup();
expect(html).toContain('aria-label="2K 支持的宽高比"');
expect(html).toContain("2560");
expect(html).toContain("1440");
expect(html).toContain("默认输出");
expect(html).not.toContain('aria-label="1K 支持的宽高比"');
expect(html).not.toContain("比例对应的像素尺寸");
});
test("ratio-only contract has ratio buttons without invented resolution or pixel inputs", () => {
const fields: ImageParameter[] = [{ name: "size", label: "画幅", type: "string", values: ["7:5", "5:7"], default: "7:5", encoding: "ratio" }];
const { image } = fixture(fields);
const html = renderToStaticMarkup();
expect(html).toContain('aria-label="支持的宽高比"');
expect(html).toContain("7:5");
expect(html).toContain("默认输出");
expect(html).not.toContain("1K");
expect(html).not.toContain("宽度");
expect(html).not.toContain("像素");
expect(html).not.toContain("16:9");
image.size.presets = [imagePresetForRatio("1k", "7:5")];
const panel = renderToStaticMarkup( {}} />);
expect(panel).not.toContain("image-size-output");
expect(panel).not.toContain("未配置独立分辨率");
});
test("saving tier ratios preserves the contract and generation only shows the active tier", () => {
const { image, config } = fixture([
{ name: "size", label: "画幅", type: "string", values: ["1:1", "16:9"], default: "1:1", encoding: "pixels" },
{ name: "imageResolution", label: "分辨率", type: "string", values: ["1k", "2k"], default: "1k" },
]);
const saved = updateImageParameterSize(image, { ...image.size, presets: [imagePresetForRatio("1k", "1:1"), imagePresetForRatio("2k", "16:9")] });
const reopened = projectImageParameters(saved, saved.parameters!);
expect(reopened.size.presets).toHaveLength(2);
const html = renderToStaticMarkup( {}} />);
expect(html).toContain("2752 × 1536");
expect(html).not.toContain("1024 × 1024");
expect(html).not.toContain("1:1");
const singleTier = updateImageParameterSize(saved, { ...saved.size, values: ["16:9"], default: "16:9", presets: [imagePresetForRatio("2k", "16:9")] });
expect(singleTier.resolution.values).toEqual(["1k", "2k"]);
expect(singleTier.resolution.default).toBe("1k");
});
test("size groups follow channel resolutions, not every plugin candidate", () => {
const { image } = fixture([
{ name: "size", label: "画幅", type: "string", values: ["1:1"], default: "1:1", encoding: "pixels" },
{ name: "imageResolution", label: "分辨率", type: "string", values: ["2k"], default: "2k" },
]);
const definitions = image.parameters!.map((p) => (p.name === "imageResolution" ? { ...p, values: ["1k", "2k", "4k"] } : p));
const html = renderToStaticMarkup();
expect(html).toContain('aria-label="2K 支持的宽高比"');
expect(html).not.toContain('aria-label="1K 支持的宽高比"');
expect(html).not.toContain('aria-label="启用 2K 规格"');
});
test("disabled resolution uses one ratio group and restores existing tier presets on enabling", () => {
const { image, config } = fixture([
{ name: "size", label: "画幅", type: "string", values: ["1:1"], default: "1:1", encoding: "pixels" },
{ name: "imageResolution", label: "分辨率", type: "string", values: ["1k", "2k"], default: "2k", enabled: false },
]);
image.size.presets = [imagePresetForRatio("1k", "1:1"), imagePresetForRatio("2k", "1:1")];
const html = renderToStaticMarkup();
expect(html).toContain('aria-label="支持的宽高比"');
expect(html).not.toContain('aria-label="1K 支持的宽高比"');
expect(html).not.toContain('aria-label="2K 支持的宽高比"');
const panel = renderToStaticMarkup( {}} />);
expect(panel).not.toContain("1K");
expect(panel).not.toContain("2K");
expect(panel).toContain("2048");
const reopened = projectImageParameters(
JSON.parse(JSON.stringify(image)),
image.parameters!.map((p) => ({ ...p, enabled: true })),
);
expect(reopened.size.presets).toEqual(image.size.presets);
expect(reopened.resolution.values).toEqual(["1k", "2k"]);
});
test("generation enums display declared choices directly with ratio previews", () => {
const { image, config } = fixture([
{ name: "size", label: "画幅", type: "string", values: ["7:5", "5:7"], default: "7:5", encoding: "ratio" },
{ name: "finish", label: "表面", type: "string", values: ["matte", "gloss"], default: "matte" },
]);
const html = renderToStaticMarkup( {}} />);
expect(html).toContain('role="group" aria-label="画幅"');
expect(html).toContain('aria-pressed="true"');
expect(html).toContain("image-size-ratio-icon");
expect(html).toContain("7:5");
expect(html).toContain("5:7");
expect(html).toContain("gloss");
expect(html).not.toContain('role="combobox"');
expect(html).not.toContain("16:9");
});
test("unconstrained resolution editor offers standard 1K/2K/4K choices", () => {
const field: ImageParameter = { name: "imageResolution", label: "分辨率", type: "string", default: "1k", pricing: true };
const html = renderToStaticMarkup( {}} />);
expect(html).toContain("1K");
expect(html).toContain("2K");
expect(html).toContain("4K");
expect(html).toContain("分辨率快捷选择");
expect(html).toContain("输入后按回车,或点击下方档位");
});
test("resolution-only contract renders no invented quality or pixel-tier controls", () => {
const { image, config } = fixture([parameters[0]]);
const html = renderToStaticMarkup( {}} />);
expect(html).toContain("分辨率");
expect(html).not.toContain("图片质量");
expect(html).not.toContain("像素档位");
expect(html).not.toContain("1k");
const editor = renderToStaticMarkup( {}} />);
expect(editor).toContain("small");
expect(editor).toContain("large");
expect(editor).toContain("可计价");
});
test("an arbitrary plugin field crosses channel, logical model and frontend projection", () => {
const { spec } = fixture();
const merged = mergeCapabilitySpecs("image", [spec]);
const image = projectLogicalCapability(merged, {}).image!;
expect(image.parameters?.map((p) => p.name)).toEqual(parameters.map((p) => p.name));
expect(image.quality.supported).toBe(false);
expect(image.resolution.values).toEqual(["small", "large"]);
expect(imageParameterConstraints(image.parameters!).creativeAmount).toEqual({ min: 0, max: 200, step: 1 });
});
test("channel changes remove stale quality; explicit frontend restrictions remain disabled", () => {
const { spec } = fixture();
const old = { version: 1 as const, capability: "image" as const, options: { quality: { values: ["2k"] } } };
const next = normalizeCapabilitySpecForSources(old, [spec])!;
expect(next.options?.quality).toBeUndefined();
expect(next.options?.creativeAmount).toBeDefined();
const disabled = { ...next, options: { imageResolution: next.options!.imageResolution } };
expect(normalizeCapabilitySpecForSources(disabled, [spec])?.options?.creativeAmount).toBeUndefined();
const withNew = { ...spec, imageParameters: [...spec.imageParameters!, { name: "newField", label: "New", type: "boolean" as const }], options: { ...spec.options, newField: { values: [false, true] } } };
expect(normalizeCapabilitySpecForSources(disabled, [withNew])?.options?.newField).toBeDefined();
});
test("only pricing declarations can enter price selectors", () => {
const { image } = fixture();
const tier = { ...defaultPriceTier("advanced"), imageSelector: { imageResolution: "large", creativeAmount: "100", rawMode: "true", quality: "4k" } };
expect(skuSelectorFromForm("image", tier, { version: 1, image })).toEqual({ imageResolution: "large" });
});
test("zero and false persist while unsupported fields do not cross model switches", () => {
const { image, config } = fixture();
expect(imageParameterRequest(image, config)).toEqual({ imageResolution: "large", creativeAmount: 0, rawMode: false });
expect(normalizeImageParameters([parameters[0]], config.imageParameters)).toEqual({ imageResolution: "small" });
const spec = specFromConfig("image", "portrait", config);
const metadata = generationSpecMetadata(spec);
expect(metadata.imageParameters).toBe(config.imageParameters);
const node = { id: "node", type: "image", metadata } as CanvasNodeData;
const changed = synchronizeGenerationSpec(node, { model: encodeChannelModel("other", "model") });
expect(changed.metadata?.imageParameters).toBeUndefined();
});
test("actual task payload includes typed extensions and no stale quality requirement", async () => {
const { config } = fixture();
const task = await prepareBackendGenerationTask({ mode: "image", prompt: "portrait", config });
const input = task.input as Record;
expect(input.capabilityOptions).toEqual({ imageResolution: "large", creativeAmount: 0, rawMode: false });
expect(input.config.imageParameters).toEqual(input.capabilityOptions);
expect(input.capabilityOptions.quality).toBeUndefined();
});
test("submission rejects explicit invalid fields while display can normalize stored preferences", async () => {
const { image, config } = fixture();
const invalid = { ...config, imageParameters: JSON.stringify({ creativeAmount: 201 }) };
expect(imageParameterRequest(image, invalid)?.creativeAmount).toBe(0);
expect(() => imageParameterRequest(image, invalid, true)).toThrow("创意程度");
await expect(prepareBackendGenerationTask({ mode: "image", prompt: "portrait", config: invalid })).rejects.toThrow("创意程度");
expect(() => imageParameterRequest(image, { ...config, imageParameters: "invalid" }, true)).toThrow("格式无效");
});
test("pixel mapping accepts plugin resolution names without inventing dimensions", () => {
const { image } = fixture([
{ name: "size", label: "画幅", type: "string", values: ["2:1"], default: "2:1", encoding: "pixels" },
{ name: "imageResolution", label: "分辨率", type: "string", values: ["panorama"], default: "panorama" },
]);
const html = renderToStaticMarkup( {}} />);
expect(html).toContain("PANORAMA");
expect(html).toContain('placeholder="宽度"');
expect(html).not.toContain("1024");
expect(html).not.toContain("补齐");
});
});