Files
ttstd-infinite-canvas/web/test/image-parameters.test.tsx
huiku 7466119867 feat(image): 以插件声明统一图片参数、尺寸选择与定价合同 (#760)
* feat(image): 图片参数 - 统一插件声明配置与生成合同

* docs(image): 图片参数 - 说明声明合同与验证边界

* fix(plugins): 图片声明 - 保留内联素材支持并同步制品文档

* fix(image): 参数合同 - 对齐审批意图和尺寸预检调用方

* fix(image): 调用链 - 保留公开意图并接通独立分辨率

* fix(image): 图片参数 - 补齐分辨率接线与目录价格投影
2026-10-13 19:18:21 +08:00

237 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(<ModelCapabilityEditor capability="image" section="protocol" value={{ version: 1, image }} imageParameters={fields} />);
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(<ModelCapabilityEditor capability="image" section="protocol" value={{ version: 1, image }} imageParameters={fields} />);
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(<ImageParameterFields profile={image} config={{ ...defaultConfig, size: "7:5" }} onChange={() => {}} />);
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(<ImageParameterFields profile={reopened} config={{ ...config, size: "16:9", imageResolution: "2k" }} onChange={() => {}} />);
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(<ModelCapabilityEditor capability="image" section="protocol" value={{ version: 1, image }} imageParameters={definitions} />);
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(<ModelCapabilityEditor capability="image" section="protocol" value={{ version: 1, image }} imageParameters={image.parameters} />);
expect(html).toContain('aria-label="支持的宽高比"');
expect(html).not.toContain('aria-label="1K 支持的宽高比"');
expect(html).not.toContain('aria-label="2K 支持的宽高比"');
const panel = renderToStaticMarkup(<ImageParameterFields profile={image} config={{ ...config, size: "1:1" }} onChange={() => {}} />);
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(<ImageParameterFields profile={image} config={{ ...config, size: "5:7" }} onChange={() => {}} />);
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("resolution-only contract renders no invented quality or pixel-tier controls", () => {
const { image, config } = fixture([parameters[0]]);
const html = renderToStaticMarkup(<ImageParameterFields profile={image} config={config} onChange={() => {}} />);
expect(html).toContain("分辨率");
expect(html).not.toContain("图片质量");
expect(html).not.toContain("像素档位");
expect(html).not.toContain("1k");
const editor = renderToStaticMarkup(<ImageParameterSchemaEditor definitions={[parameters[0]]} parameters={[parameters[0]]} onChange={() => {}} />);
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<string, any>;
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(<ImagePixelMappingEditor profile={image} onChange={() => {}} />);
expect(html).toContain("PANORAMA");
expect(html).toContain('placeholder="宽度"');
expect(html).not.toContain("1024");
expect(html).not.toContain("补齐");
});
});