Files
ttstd-infinite-canvas/backend/internal/app/image_size_presets_test.go
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

313 lines
14 KiB
Go
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.
package app
import (
"encoding/json"
"strings"
"testing"
"yingce/backend/internal/model"
)
func TestImageSizePresetsValidationAndRoundTrip(t *testing.T) {
profile := DefaultImageCapabilityConfig("openai-image", "test")
profile.Size = ImageSizeConfig{Parameter: "size", Values: []string{"1824x1024", "1920x1080"}, Default: "1824x1024", Presets: []ImageSizePreset{
{Tier: "1k", Ratio: "16:9", Size: "1824x1024", Width: 1824, Height: 1024},
{Tier: "2k", Ratio: "16:9", Size: "1920x1080", Width: 1920, Height: 1080},
}}
if err := validateImageCapabilityConfig(profile); err != nil {
t.Fatal(err)
}
data, err := json.Marshal(profile)
if err != nil {
t.Fatal(err)
}
var restored ImageCapabilityConfig
if err := json.Unmarshal(data, &restored); err != nil || len(restored.Size.Presets) != 2 || restored.Size.Presets[1].Size != "1920x1080" {
t.Fatalf("presets did not survive JSON round trip: %v", err)
}
for _, tc := range []struct {
name string
edit func(*ImageCapabilityConfig)
want string
}{
{"invalid tier", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Tier = "8k" }, "档位"},
{"zero ratio", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Ratio = "0:9" }, "比例"},
{"mismatched ratio", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Ratio = "9:16" }, "不一致"},
{"tiny size", func(p *ImageCapabilityConfig) {
p.Size.Presets[0].Width = 16
p.Size.Presets[0].Height = 16
p.Size.Presets[0].Size = "16x16"
}, "像素"},
{"size mismatch", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Size = "1024x1024" }, "像素"},
{"not supported", func(p *ImageCapabilityConfig) { p.Size.Values = []string{"1824x1024"} }, "支持值"},
{"duplicate equivalent ratio", func(p *ImageCapabilityConfig) { p.Size.Presets[1].Tier = "1k"; p.Size.Presets[1].Ratio = "32:18" }, "重复"},
} {
t.Run(tc.name, func(t *testing.T) {
var p ImageCapabilityConfig
if err := json.Unmarshal(data, &p); err != nil {
t.Fatal(err)
}
tc.edit(&p)
if err := validateImageCapabilityConfig(&p); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected %s, got %v", tc.want, err)
}
})
}
}
func TestImageQualityDoesNotSelectResolutionPresets(t *testing.T) {
profile := DefaultImageCapabilityConfig("openai-image", "test")
profile.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1"}, Default: "1:1", Presets: []ImageSizePreset{
{Tier: "1k", Ratio: "1:1", Size: "1024x1024", Width: 1024, Height: 1024},
}}
profile.Resolution = ImageResolutionConfig{Supported: false, Default: "auto"}
profile.Quality = ImageQualityConfig{Supported: true, Values: []string{"medium", "high", "max"}, Default: "medium"}
if err := validateImageCapabilityConfig(profile); err != nil {
t.Fatalf("quality-only capability rejected: %v", err)
}
for _, quality := range profile.Quality.Values {
input := canvasGenerationInput{Mode: "image", Prompt: "local test", Config: providerConfig{InterfaceType: "openai-image", Model: "test", Size: "1:1", Quality: quality, Count: "1"}}
raw := map[string]any{"config": map[string]any{"size": "1:1", "quality": quality}}
if err := validateImageTaskWithResolvedSize(raw, &input, profile); err != nil {
t.Fatalf("quality %q rejected: %v", quality, err)
}
if input.Config.Size != "1024x1024" || input.Config.Quality != quality {
t.Fatalf("quality changed preset or value: %+v", input.Config)
}
}
profile.Resolution.Supported = true
if err := validateImagePresetSelection(profile, "2k", "1:1"); err == nil {
t.Fatal("independent resolution must still reject an undeclared combination")
}
}
func TestResolveImageTaskSizeUsesRouteEncodingForDeclaredAlias(t *testing.T) {
profile := &ImageCapabilityConfig{
Size: ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"16:9"}, Default: "16:9", Presets: []ImageSizePreset{
{Tier: "1k", Ratio: "16:9", Size: "1824x1024"},
}},
Quality: ImageQualityConfig{Supported: true, Values: []string{"1k"}, Default: "1k"},
}
if got := resolveImageTaskSize(profile, string(model.ChannelInterfaceGrokImage), "1824x1024", "1k"); got != "16:9" {
t.Fatalf("aspect-ratio route got %q, want canonical ratio 16:9", got)
}
if got := resolveImageTaskSize(profile, string(model.ChannelInterfaceOpenAIImage), "32:18", "1k"); got != "1824x1024" {
t.Fatalf("pixel route got %q, want declared pixel size 1824x1024", got)
}
}
func TestImageSizePresetsEnforceResolutionRatioPair(t *testing.T) {
profile := DefaultImageCapabilityConfig("grok-image", "grok-imagine-image")
profile.Resolution.Default = "1k"
profile.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "16:9"}, Default: "1:1", Presets: []ImageSizePreset{
{Tier: "1k", Ratio: "1:1", Size: "1024x1024", Width: 1024, Height: 1024},
{Tier: "2k", Ratio: "16:9", Size: "2752x1536", Width: 2752, Height: 1536},
}}
if err := validateImageCapabilityConfig(profile); err != nil {
t.Fatal(err)
}
profile.Resolution.Default = "2k"
if err := validateImageCapabilityConfig(profile); err == nil || !strings.Contains(err.Error(), "默认图片分辨率") {
t.Fatalf("expected invalid default pair error, got %v", err)
}
profile.Resolution.Default = "1k"
input := canvasGenerationInput{Config: providerConfig{Size: "1:1", ImageResolution: "2k"}}
if err := validateImageTask(profile, input); err == nil || !strings.Contains(err.Error(), "当前分辨率") {
t.Fatalf("expected pair validation error, got %v", err)
}
input.Config.Size = "16:9"
if err := validateImageTask(profile, input); err != nil {
t.Fatal(err)
}
profile.Size.AllowCustom = true
input.Config.Size = "1:1"
if err := validateImageTask(profile, input); err != nil {
t.Fatal(err)
}
}
func pixelSizeTestProfile() *ImageCapabilityConfig {
profile := DefaultImageCapabilityConfig("openai-image", "gpt-image-2.5-4k")
profile.Size = ImageSizeConfig{Parameter: "size", Values: []string{"2048x2048", "2752x1536", "2880x2880", "3840x2160"}, Default: "2752x1536", Presets: []ImageSizePreset{
{Tier: "2k", Ratio: "1:1", Size: "2048x2048", Width: 2048, Height: 2048},
{Tier: "2k", Ratio: "16:9", Size: "2752x1536", Width: 2752, Height: 1536},
{Tier: "4k", Ratio: "1:1", Size: "2880x2880", Width: 2880, Height: 2880},
{Tier: "4k", Ratio: "16:9", Size: "3840x2160", Width: 3840, Height: 2160},
}}
profile.Quality = ImageQualityConfig{Supported: true, Values: []string{"2k", "4k"}, Default: "2k"}
return profile
}
func TestResolveImageTaskSizeUsesTierRatioPreset(t *testing.T) {
profile := pixelSizeTestProfile()
for _, tc := range []struct {
name, size, quality, want string
}{
{"2k ratio preset", "16:9", "2k", "2752x1536"},
{"4k ratio preset", "1:1", "4k", "2880x2880"},
{"quality alias", "16:9", "medium", "2752x1536"},
{"fallback to default tier", "1:1", "", "2048x2048"},
{"pixel passthrough", "2048x2048", "2k", "2048x2048"},
{"auto passthrough", "auto", "2k", "auto"},
{"undeclared ratio stays for validation", "3:2", "2k", "3:2"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := resolveImageTaskSize(profile, "openai-image", tc.size, tc.quality); got != tc.want {
t.Fatalf("resolveImageTaskSize(%q, %q) = %q; want %q", tc.size, tc.quality, got, tc.want)
}
})
}
aspectProfile := DefaultImageCapabilityConfig("grok-image", "grok-imagine-image")
aspectProfile.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "16:9"}, Default: "1:1"}
if got := resolveImageTaskSize(aspectProfile, "grok-image", "16:9", "2k"); got != "16:9" {
t.Fatalf("aspect_ratio profile size changed: %q", got)
}
if got := resolveImageTaskSize(nil, "openai-image", "16:9", "2k"); got != "16:9" {
t.Fatalf("nil profile size changed: %q", got)
}
}
func TestValidateTaskCapabilityRewritesRatioSizeToPixelPreset(t *testing.T) {
config := DefaultModelCapabilityConfigForModel("openai-image", "gpt-image-2.5-4k")
config.Image = pixelSizeTestProfile()
config.Image.Resolution = ImageResolutionConfig{Supported: true, Values: []string{"2k", "4k"}, Default: "2k"}
config.Image.Quality = ImageQualityConfig{Supported: false}
for _, tc := range []struct {
name, size, resolution, wantSize string
}{
{"2k preset", "16:9", "2k", "2752x1536"},
{"4k preset", "1:1", "4k", "2880x2880"},
} {
t.Run(tc.name, func(t *testing.T) {
input := map[string]any{
"mode": "image", "prompt": "A simple gray circle on a white background.",
"config": map[string]any{"interfaceType": "openai-image", "model": "gpt-image-2.5-4k", "size": tc.size, "imageResolution": tc.resolution, "count": "1", "capabilityConfig": config},
}
data, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
var normalized map[string]any
if err := json.Unmarshal(data, &normalized); err != nil {
t.Fatal(err)
}
if err := (&Service{}).ValidateTaskCapability(normalized); err != nil {
t.Fatalf("ValidateTaskCapability() error = %v", err)
}
rewritten := normalized["config"].(map[string]any)["size"]
if rewritten != tc.wantSize {
t.Fatalf("config.size = %v; want %q", rewritten, tc.wantSize)
}
})
}
t.Run("undeclared ratio fails validation locally", func(t *testing.T) {
input := map[string]any{
"mode": "image", "prompt": "A simple gray circle on a white background.",
"config": map[string]any{"interfaceType": "openai-image", "model": "gpt-image-2.5-4k", "size": "3:2", "imageResolution": "2k", "count": "1", "capabilityConfig": config},
}
data, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
var normalized map[string]any
if err := json.Unmarshal(data, &normalized); err != nil {
t.Fatal(err)
}
err = (&Service{}).ValidateTaskCapability(normalized)
if err == nil || !strings.Contains(err.Error(), "支持范围") {
t.Fatalf("expected local size validation error, got %v", err)
}
})
}
func TestValidateTaskCapabilityRewritesOpenAIAspectPresetToPixelSize(t *testing.T) {
config := DefaultModelCapabilityConfigForModel("openai-image", "gpt-image-2.5-4k")
config.Image.Size = ImageSizeConfig{
Parameter: "aspect_ratio",
Values: []string{"9:16"},
Default: "9:16",
Presets: []ImageSizePreset{
{Tier: "2k", Ratio: "9:16", Size: "1536x2752", Width: 1536, Height: 2752},
{Tier: "4k", Ratio: "9:16", Size: "2160x3840", Width: 2160, Height: 3840},
},
}
config.Image.Resolution = ImageResolutionConfig{Supported: true, Values: []string{"2k", "4k"}, Default: "2k"}
config.Image.Quality = ImageQualityConfig{Supported: false}
input := map[string]any{
"mode": "image",
"prompt": "A simple gray circle on a white background.",
"config": map[string]any{
"interfaceType": "openai-image",
"model": "gpt-image-2.5-4k",
"size": "9:16",
"imageResolution": "2k",
"count": "1",
"capabilityConfig": config,
},
}
data, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
var normalized map[string]any
if err := json.Unmarshal(data, &normalized); err != nil {
t.Fatal(err)
}
if err := (&Service{}).ValidateTaskCapability(normalized); err != nil {
t.Fatalf("ValidateTaskCapability() error = %v", err)
}
if got := normalized["config"].(map[string]any)["size"]; got != "1536x2752" {
t.Fatalf("config.size = %v; want 1536x2752", got)
}
}
// 比例型线路在任务输入边界把画布比例换算成 preset 像素值后,执行前的 provider 预检会用
// 同一份能力画像再校验一次;换算结果必须继续被认成合法尺寸,否则任务会在发请求前被本地
// 拒绝(线上表现为「图片尺寸不在当前模型支持范围内」,出站记录为 provider-preflight)。
func TestValidateImageTaskAcceptsResolvedPresetPixelSize(t *testing.T) {
profile := DefaultImageCapabilityConfig("openai-image", "gpt-image-2.5")
profile.Size = ImageSizeConfig{
Parameter: "aspect_ratio",
Values: []string{"1:1", "4:3"},
Default: "1:1",
Presets: []ImageSizePreset{
{Tier: "1k", Ratio: "1:1", Size: "1024x1024", Width: 1024, Height: 1024},
{Tier: "1k", Ratio: "4:3", Size: "1360x1024", Width: 1360, Height: 1024},
{Tier: "2k", Ratio: "4:3", Size: "2304x1728", Width: 2304, Height: 1728},
},
}
profile.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "1k"}
raw := map[string]any{"config": map[string]any{"size": "4:3", "quality": "1k"}}
taskInput := canvasGenerationInput{Mode: "image", Prompt: "a simple circle", Config: providerConfig{InterfaceType: "openai-image", Model: "gpt-image-2.5", Size: "4:3", Quality: "1k", Count: "1"}}
if err := validateImageTaskWithResolvedSize(raw, &taskInput, profile); err != nil {
t.Fatalf("resolve step error = %v", err)
}
if taskInput.Config.Size != "1360x1024" {
t.Fatalf("resolved size = %q; want 1360x1024", taskInput.Config.Size)
}
if got := raw["config"].(map[string]any)["size"]; got != "1360x1024" {
t.Fatalf("persisted config.size = %v; want 1360x1024", got)
}
// 执行前预检用同一份画像再校验已换算的像素值,必须通过。
if err := validateImageTask(profile, taskInput); err != nil {
t.Fatalf("provider preflight rejected the resolved pixel size: %v", err)
}
// 未声明的像素值仍然拒绝。
undeclared := taskInput
undeclared.Config.Size = "2000x2000"
if err := validateImageTask(profile, undeclared); err == nil || !strings.Contains(err.Error(), "图片尺寸") {
t.Fatalf("undeclared pixel size should stay rejected, got %v", err)
}
// 其他档位的 preset 像素值不能借低档质量通过。
wrongTier := taskInput
wrongTier.Config.Size = "2304x1728"
if err := validateImageTask(profile, wrongTier); err == nil || !strings.Contains(err.Error(), "图片尺寸") {
t.Fatalf("other-tier pixel size should stay rejected, got %v", err)
}
}