Files
ttstd-infinite-canvas/web/test/image-size-presets.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

280 lines
19 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 { ImageSizePicker } from "../src/components/image-size-picker";
import { applyImageSizeSelection } from "../src/components/image-settings-panel";
import { ImageSizePresetsEditor } from "../src/components/image-size-presets-editor";
import { ModelCapabilityEditor } from "../src/components/model-capability-editor";
import { defaultImageCapabilityConfig, normalizeImageValue, normalizeModelCapabilityConfig } from "../src/lib/model-capabilities";
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio, imagePresetValue, imageQualityForSelection, imageQualityForTier, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "../src/lib/image-size-presets";
import { imageQualityLabel } from "../src/lib/image-quality";
import { buildImageResolutionOptions } from "../src/lib/image-resolution-tiers";
import { resolveImageRequestSize, validateImageSize } from "../src/services/api/image-validation";
import { buildGeminiImageGenerationConfig } from "../src/lib/gemini-image";
describe("统一图片分辨率与宽高比", () => {
test("画质公共映射覆盖创作与画布使用的 xhigh、max", () => {
expect(imageQualityLabel("xhigh")).toBe("超高");
expect(imageQualityLabel("MAX")).toBe("最高");
});
test("Seedream 5.0 shows 1.5K and retains the exact official 2K wide size", () => {
const profile = defaultImageCapabilityConfig("km-kemei-seedream", "doubao-seedream-5-0-pro-260628");
const intermediate = renderToStaticMarkup(<ImageSizePicker profile={profile} size="1536x1536" onChange={() => {}} />);
expect(intermediate).toMatch(/aria-pressed="true"[^>]*>1\.5K/);
expect(intermediate).toContain("1536 × 1536 px");
expect(intermediate).not.toMatch(/<button[^>]*>4K/);
const wide = renderToStaticMarkup(<ImageSizePicker profile={profile} size="2816x1584" onChange={() => {}} />);
expect(wide).toMatch(/aria-pressed="true"[^>]*>2K/);
expect(wide).toContain("2816 × 1584 px");
const reopened = normalizeModelCapabilityConfig({ version: 1, image: profile });
expect(reopened.image?.size.presets?.filter((p) => p.tier === "1.5k")).toHaveLength(8);
expect(resolveImageRequestSize(profile, "auto", "1536x1536")?.value).toBe("1536x1536");
});
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)));
expect(profile.quality.supported).toBe(true);
expect(imageTierAvailable(profile, "4k")).toBe(true);
expect(imageTierAvailable(profile, "1k")).toBe(false);
const picker = renderToStaticMarkup(<ImageSizePicker profile={profile} size="16:9" quality="high" onChange={() => {}} />);
expect(picker).toMatch(/aria-pressed="true"[^>]*>4K/);
expect(picker).not.toMatch(/<button[^>]*>1K/);
});
test("无质量参数的固定 4K 模型保留管理员档位,重开及自定义比例不回退 1K", () => {
const profile = defaultImageCapabilityConfig("gemini-image", "nano-banana-pro-4k");
profile.quality = { supported: false, values: [], default: "auto" };
profile.size = imageSizeConfigWithPresets(profile, IMAGE_RATIOS.map((ratio) => imagePresetForRatio("4k", ratio)));
expect(imageTierAvailable(profile, "4k")).toBe(true);
expect(imageTierAvailable(profile, "1k")).toBe(false);
expect(imageQualityForTier(profile, "4k")).toBeUndefined();
const picker = renderToStaticMarkup(<ImageSizePicker profile={profile} size="16:9" onChange={() => {}} />);
expect(picker).toMatch(/aria-pressed="true"[^>]*>4K/);
expect(picker).not.toMatch(/<button[^>]*>[12]K/);
expect(picker).toContain("3840 × 2160 px");
expect(normalizeImageValue(profile, { size: "16:9", quality: "auto" }).quality).toBe("4k");
expect(resolveImageRequestSize(profile, undefined, "16:9")).toEqual({ parameter: "aspect_ratio", value: "16:9" });
profile.size.allowCustom = true;
const custom = renderToStaticMarkup(<ImageSizePicker profile={profile} size="17:11" onChange={() => {}} />);
expect(custom).toMatch(/aria-pressed="true"[^>]*>4K/);
expect(custom).toContain("17:11 ·");
});
test("无质量参数时管理员启用的 1K 和 4K 都对用户开放,未启用的 2K 仍隐藏", () => {
const profile = defaultImageCapabilityConfig("gemini-image", "test");
profile.quality = { supported: false, values: [], default: "auto" };
profile.size = imageSizeConfigWithPresets(profile, [...IMAGE_RATIOS.flatMap((ratio) => [imagePresetForRatio("1k", ratio), imagePresetForRatio("4k", ratio)])]);
expect(imageTierAvailable(profile, "1k")).toBe(true);
expect(imageTierAvailable(profile, "4k")).toBe(true);
expect(imageTierAvailable(profile, "2k")).toBe(false);
expect(imageQualityForSelection(profile, "4k")).toBe("4k");
expect(normalizeImageValue(profile, { size: "16:9", quality: "4k" }).quality).toBe("4k");
expect(buildGeminiImageGenerationConfig("16:9", "4k").imageConfig).toEqual({ aspectRatio: "16:9", imageSize: "4K" });
const picker = renderToStaticMarkup(<ImageSizePicker profile={profile} size="16:9" onChange={() => {}} />);
expect(picker).toMatch(/<button[^>]*>1K/);
expect(picker).toMatch(/<button[^>]*>4K/);
expect(picker).not.toMatch(/<button[^>]*>2K/);
expect(picker).not.toContain("当前协议未配置独立分辨率");
const selected = renderToStaticMarkup(<ImageSizePicker profile={profile} size="16:9" quality="4k" onChange={() => {}} />);
expect(selected).toMatch(/aria-pressed="true"[^>]*>4K/);
expect(selected).not.toMatch(/aria-pressed="true"[^>]*>1K/);
expect(selected).toContain("3840 × 2160 px");
const editor = renderToStaticMarkup(<ImageSizePresetsEditor profile={profile} onChange={() => {}} />);
expect(editor).not.toContain("需先配置此档位对应的质量值,用户端才会开放。");
});
test("真实图片质量字段不被分辨率预设推断覆盖", () => {
const profile = defaultImageCapabilityConfig("gemini-image", "test");
profile.quality = { supported: true, values: ["auto", "low", "medium", "high"], default: "auto" };
profile.size = imageSizeConfigWithPresets(profile, [imagePresetForRatio("2k", "16:9"), imagePresetForRatio("4k", "16:9")]);
expect(normalizeImageValue(profile, { size: "16:9", quality: "auto" }).quality).toBe("auto");
expect(normalizeImageValue(profile, { size: "16:9", quality: "high" }).quality).toBe("high");
});
test("比例协议切换 4K 时同时提交尺寸和真实质量档位", () => {
const changes: Array<[string, string]> = [];
applyImageSizeSelection((key, value) => changes.push([key, value]), "16:9", "high");
expect(changes).toEqual([["size", "16:9"], ["quality", "high"]]);
});
test("像素协议从旧比例配置切换 4K 后发送真实像素,不回退到 1K", () => {
const profile = defaultImageCapabilityConfig();
profile.size = { parameter: "size", values: ["16:9"], default: "16:9", allowCustom: true };
const value = imagePresetValue(profile, imagePresetForRatio("4k", "16:9"));
expect(value).toBe("3840x2160");
expect(resolveImageRequestSize(profile, "auto", value)).toEqual({ parameter: "size", value: "3840x2160" });
const legacy = renderToStaticMarkup(<ImageSizePicker profile={profile} size="16:9" onChange={() => {}} />);
expect(legacy).toContain("1824 × 1024 px");
const reopened = renderToStaticMarkup(<ImageSizePicker profile={profile} size="3840x1920" onChange={() => {}} />);
expect(reopened).not.toMatch(/<button[^>]*>4K/);
expect(reopened).toContain("2:1 · 3840 × 1920 px");
});
test("常用比例保持明确尺寸,等价比例归一", () => {
expect(imagePresetForRatio("1k", "16:9").size).toBe("1824x1024");
expect(imagePresetForRatio("4k", "9:16").size).toBe("2160x3840");
expect(imagePresetForRatio("1k", "32:18")).toEqual(imagePresetForRatio("1k", "16:9"));
expect(imagePresetForRatio("2k", "7:3").ratio).toBe("21:9");
expect(imagePresetForRatio("2k", "34:22").ratio).toBe("17:11");
});
test("三档预设与任意合理比例都满足像素范围", () => {
for (const tier of IMAGE_RESOLUTIONS)
for (const ratio of [...IMAGE_RATIOS, "17:11", "3:1", "1:3", "99999:99998"]) {
const preset = imagePresetForRatio(tier, ratio);
expect(() => validateImageSize(preset.width, preset.height, false)).not.toThrow();
}
for (const ratio of ["0:9", "4:1", "1:4", "NaN:1", "1.5:1", "999999999:1"]) {
expect(() => imagePresetForRatio("1k", ratio)).toThrow();
}
});
test("固定比例编辑器包含 4:5 和 5:4 三档尺寸", () => {
expect(IMAGE_RATIOS).toContain("4:5");
expect(IMAGE_RATIOS).toContain("5:4");
expect(imagePresetForRatio("1k", "4:5").size).toBe("1024x1280");
expect(imagePresetForRatio("2k", "4:5").size).toBe("1792x2240");
expect(imagePresetForRatio("4k", "4:5").size).toBe("2560x3200");
expect(imagePresetForRatio("1k", "5:4").size).toBe("1280x1024");
expect(imagePresetForRatio("2k", "5:4").size).toBe("2240x1792");
expect(imagePresetForRatio("4k", "5:4").size).toBe("3200x2560");
});
test("管理员设置同步支持值、默认值,并保持精确尺寸", () => {
const profile = defaultImageCapabilityConfig();
profile.size = { parameter: "size", values: ["1920x1080"], default: "1920x1080", allowCustom: false };
const existing = imageSizePresets(profile);
const added = imagePresetForRatio("1k", "17:11");
profile.size = imageSizeConfigWithPresets(profile, [...existing, added]);
expect(profile.size.values).toEqual(["1920x1080", added.size]);
expect(profile.size.default).toBe("1920x1080");
expect(resolveImageRequestSize(profile, undefined, "1920x1080")?.value).toBe("1920x1080");
expect(normalizeModelCapabilityConfig({ version: 1, image: profile }).image?.size.presets).toEqual(profile.size.presets);
expect(imageSizeConfigWithPresets(profile, [added]).default).toBe(added.size);
});
test("非标准精确像素不会从选项中消失", () => {
expect(buildImageResolutionOptions(["1360x880"])[0]).toMatchObject({ size: "1360x880", ratio: "17:11" });
expect(buildImageResolutionOptions(["0x1024", "1024x0"])).toEqual([]);
});
test("比例协议分别发送比例和分辨率,不把自定义权限当作 4K 能力", () => {
const grok = defaultImageCapabilityConfig("grok-image", "grok-imagine-image");
grok.size.allowCustom = true;
expect(imageTierAvailable(grok, "4k")).toBe(false);
expect(imageSizePresets(grok).some((preset) => preset.tier === "4k")).toBe(false);
const gemini = defaultImageCapabilityConfig("gemini-image", "test");
const preset = imagePresetForRatio("4k", "16:9");
expect(buildGeminiImageGenerationConfig(imagePresetValue(gemini, preset), imageQualityForTier(gemini, "4k")).imageConfig).toEqual({ aspectRatio: "16:9", imageSize: "4K" });
gemini.size.allowCustom = true;
const reopened = renderToStaticMarkup(<ImageSizePicker profile={gemini} size="17:11" quality="high" onChange={() => {}} />);
expect(reopened).toMatch(/aria-pressed="true"[^>]*>4K/);
expect(reopened).toContain("17:11");
});
test("比例与档位的组合必须在该档位配置中,允许自定义则放开比例", () => {
const profile = defaultImageCapabilityConfig("gemini-image", "test");
profile.size = imageSizeConfigWithPresets(profile, [imagePresetForRatio("1k", "1:1"), imagePresetForRatio("2k", "16:9")]);
expect(() => resolveImageRequestSize(profile, "medium", "1:1")).toThrow("当前分辨率");
expect(resolveImageRequestSize(profile, "medium", "16:9")).toEqual({ parameter: "aspect_ratio", value: "16:9" });
profile.size.allowCustom = true;
expect(resolveImageRequestSize(profile, "medium", "1:1")?.value).toBe("1:1");
});
test("管理员固定显示三档,用户端隐藏未支持档位,不发送尺寸时隐藏", () => {
const profile = defaultImageCapabilityConfig("grok-image", "grok-imagine-image");
profile.size.allowCustom = true;
const editor = renderToStaticMarkup(<ImageSizePresetsEditor profile={profile} onChange={() => {}} />);
for (const label of ["1K", "2K", "4K"]) expect(editor).toMatch(new RegExp(`<strong[^>]*>${label}</strong>`));
const picker = renderToStaticMarkup(<ImageSizePicker profile={profile} size="16:9" quality="2k" onChange={() => {}} />);
expect(picker).not.toMatch(/<button[^>]*>4K/);
expect(picker).toContain("换算参考");
profile.size.parameter = "none";
expect(renderToStaticMarkup(<ImageSizePicker profile={profile} size="auto" onChange={() => {}} />)).toContain("分辨率");
profile.resolution.supported = false;
expect(renderToStaticMarkup(<ImageSizePicker profile={profile} size="auto" onChange={() => {}} />)).toBe("");
});
test("空档位始终隐藏,允许自定义也不显示;旧尺寸不导致比例区域为空", () => {
for (const protocol of ["openai-image", "gemini-image"]) {
const profile = defaultImageCapabilityConfig(protocol, "test");
profile.size = imageSizeConfigWithPresets(profile, [imagePresetForRatio("1k", "1:1"), imagePresetForRatio("4k", "16:9")]);
for (const allowCustom of [false, true]) {
profile.size.allowCustom = allowCustom;
const picker = renderToStaticMarkup(<ImageSizePicker profile={profile} size={profile.size.parameter === "aspect_ratio" ? "16:9" : "2752x1536"} quality="medium" onChange={() => {}} />);
expect(picker).toMatch(/<button[^>]*>1K/);
expect(picker).toMatch(/<button[^>]*>4K/);
expect(picker).not.toMatch(/<button[^>]*>2K/);
expect(picker).not.toContain("未配置比例");
expect(picker).toContain('title="1:1 · 1024 × 1024"');
expect(picker).not.toMatch(/aria-pressed="true"[^>]*>1K/);
}
}
});
test("仅配置一档时只显示该档;全空时保留自动尺寸与自定义入口", () => {
const profile = defaultImageCapabilityConfig();
profile.size = imageSizeConfigWithPresets(profile, [imagePresetForRatio("4k", "16:9")]);
const picker = renderToStaticMarkup(<ImageSizePicker profile={profile} size="3840x2160" onChange={() => {}} />);
expect(picker).not.toMatch(/<button[^>]*>[12]K/);
expect(picker).toMatch(/aria-pressed="true"[^>]*>4K/);
profile.size = imageSizeConfigWithPresets(profile, []);
profile.size.allowCustom = true;
const empty = renderToStaticMarkup(<ImageSizePicker profile={profile} size="auto" onChange={() => {}} />);
expect(empty).not.toContain('class="image-size-tiers"');
expect(empty).not.toContain('class="image-size-label"');
expect(empty).toContain("自动尺寸");
expect(empty).toContain("自定义比例或尺寸");
});
test("管理员比例直接点选和输入,默认输出不再使用下拉框", () => {
const profile = defaultImageCapabilityConfig();
profile.size = imageSizeConfigWithPresets(profile, [imagePresetForRatio("1k", "16:9")]);
const editor = renderToStaticMarkup(<ImageSizePresetsEditor profile={profile} onChange={() => {}} />);
expect(editor).not.toContain('role="combobox"');
expect(editor).not.toContain("ant-select");
for (const tier of ["1K", "2K", "4K"]) {
expect(editor).toContain(`aria-label="${tier} 自定义比例"`);
expect(editor).toContain(`aria-label="添加 ${tier} 比例"`);
expect(editor).toContain(`aria-label="${tier} 16:9"`);
}
expect(editor).toContain('aria-label="默认输出"');
expect(editor).not.toContain("全不选");
expect(editor).not.toContain("全选");
expect(editor).toContain('aria-label="启用 1K 规格"');
expect(editor).toContain('aria-label="启用 2K 规格"');
expect(editor).toContain('aria-label="启用 4K 规格"');
const readOnly = renderToStaticMarkup(<ImageSizePresetsEditor profile={profile} disabled onChange={() => {}} />);
const buttons = [...readOnly.matchAll(/<button\b[^>]*>/g)];
expect(buttons.length).toBeGreaterThan(24);
expect(buttons.every(([button]) => button.includes('disabled=""'))).toBe(true);
});
test("图片协议尺寸独占整行且先于辅助参数,视频布局不受影响", () => {
const image = renderToStaticMarkup(<ModelCapabilityEditor capability="image" section="protocol" />);
expect(image).toContain("admin-image-protocol-grid");
expect(image).toContain("admin-image-size-card");
expect(image.indexOf("尺寸参数")).toBeLessThan(image.indexOf("输出数量"));
const video = renderToStaticMarkup(<ModelCapabilityEditor capability="video" section="protocol" />);
expect(video).not.toContain("admin-image-protocol-grid");
});
});