package pricing import ( "os" "path/filepath" "testing" ) // testCapabilitiesJSON 是一个完整的 probe 输出样本,用于测试 JSON 解析. const testCapabilitiesJSON = `{ "schema_version": "1.0", "generated_at": "2026-04-10T12:00:00Z", "models": { "anthropic:claude-sonnet-4-6": { "provider": "anthropic", "model": "claude-sonnet-4-6", "context_window": {"value": 200000, "source": "documented"}, "max_output_tokens": {"value": 16384, "source": "documented"}, "input_price_per_1m": {"value": 3.0, "source": "documented"}, "output_price_per_1m": {"value": 15.0, "source": "documented"}, "cache_read_price_per_1m": {"value": 0.3, "source": "documented"}, "cache_write_price_per_1m": {"value": 3.75, "source": "documented"}, "streaming": {"value": true, "source": "probed"}, "thinking": {"value": true, "source": "documented"}, "tool_use": {"value": true, "source": "probed"}, "caching": {"value": true, "source": "probed"}, "vision": {"value": true, "source": "documented"} } } }` func writeTempCapabilities(t *testing.T, body string) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "capabilities.json") if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatalf("write temp file: %v", err) } return path } func TestDefaultCapabilitiesPath_EnvOverride(t *testing.T) { t.Setenv("FLYTO_CAPABILITIES_PATH", "/tmp/flyto-test-capabilities.json") got := DefaultCapabilitiesPath() if got != "/tmp/flyto-test-capabilities.json" { t.Errorf("expected env override, got %q", got) } } func TestDefaultCapabilitiesPath_Default(t *testing.T) { // 清空环境变量,确保走默认分支. t.Setenv("FLYTO_CAPABILITIES_PATH", "") got := DefaultCapabilitiesPath() // 在正常环境下应该返回非空路径. if got == "" { t.Skip("home dir unresolvable in test environment") } // 路径应包含 .flyto/capabilities/capabilities.json. if !filepath.IsAbs(got) { t.Errorf("expected absolute path, got %q", got) } if filepath.Base(got) != "capabilities.json" { t.Errorf("expected capabilities.json basename, got %q", got) } } func TestLoad_EmptyPath_UsesDefault(t *testing.T) { // 指向一个不存在的路径,确认空字符串降级到 DefaultCapabilitiesPath // 并返回 (nil, nil) 而非错误. t.Setenv("FLYTO_CAPABILITIES_PATH", filepath.Join(t.TempDir(), "nonexistent.json")) report, err := Load("") if err != nil { t.Fatalf("unexpected error: %v", err) } if report != nil { t.Errorf("expected nil report for missing file, got %+v", report) } } func TestLoad_NonexistentFile_ReturnsNilNil(t *testing.T) { path := filepath.Join(t.TempDir(), "does-not-exist.json") report, err := Load(path) if err != nil { t.Fatalf("expected nil error for missing file, got %v", err) } if report != nil { t.Errorf("expected nil report, got %+v", report) } } func TestLoad_ValidJSON(t *testing.T) { path := writeTempCapabilities(t, testCapabilitiesJSON) report, err := Load(path) if err != nil { t.Fatalf("unexpected error: %v", err) } if report == nil { t.Fatal("expected non-nil report") } if report.SchemaVersion != "1.0" { t.Errorf("SchemaVersion: got %q, want 1.0", report.SchemaVersion) } if len(report.Models) != 1 { t.Fatalf("expected 1 model, got %d", len(report.Models)) } m := report.Models["anthropic:claude-sonnet-4-6"] if m == nil { t.Fatal("expected anthropic:claude-sonnet-4-6 model") } if m.Provider != "anthropic" { t.Errorf("Provider: got %q, want anthropic", m.Provider) } if m.Model != "claude-sonnet-4-6" { t.Errorf("Model: got %q, want claude-sonnet-4-6", m.Model) } // JSON 数字默认是 float64,检查 Value 类型. if v, ok := m.ContextWindow.Value.(float64); !ok || v != 200000 { t.Errorf("ContextWindow.Value: got %v (%T), want 200000 (float64)", m.ContextWindow.Value, m.ContextWindow.Value) } if v, ok := m.InputPricePer1M.Value.(float64); !ok || v != 3.0 { t.Errorf("InputPricePer1M.Value: got %v (%T), want 3.0", m.InputPricePer1M.Value, m.InputPricePer1M.Value) } // 布尔字段. if v, ok := m.Caching.Value.(bool); !ok || !v { t.Errorf("Caching.Value: got %v (%T), want true", m.Caching.Value, m.Caching.Value) } // Source 字段保留. if m.ContextWindow.Source != "documented" { t.Errorf("ContextWindow.Source: got %q, want documented", m.ContextWindow.Source) } } // TestLoad_MaxTools_Exhaustive 验证新增的 max_tools.exhaustive 字段三态语义: // nil(老 schema 缺失字段)/ false(已知下界)/ true(确认上限). // // 升华改进(ELEVATED): *bool 指针让 nil 与 false 在 Go 层面可区分. // 老 schema JSON 不含 exhaustive 字段时反序列化得 nil; // 新 schema 显式 false 时反序列化得 *false;true 时得 *true. // 这是后续 wire.CheckToolCount 决定"硬拒绝 vs 软警告"的判断依据. func TestLoad_MaxTools_Exhaustive(t *testing.T) { jsonWith := `{ "schema_version": "1.0", "generated_at": "2026-04-11T00:00:00Z", "models": { "minimax:M2.7": { "provider": "minimax", "model": "MiniMax-M2.7", "context_window": {"value": 205000, "source": "documented"}, "max_output_tokens": {"value": 16000, "source": "documented"}, "input_price_per_1m": {"value": 0.3, "source": "documented"}, "output_price_per_1m": {"value": 1.2, "source": "documented"}, "max_tools": {"value": 128, "source": "probed", "exhaustive": false, "note": "max>=128 (upper bound not reached)"} }, "fake:hard-cap": { "provider": "fake", "model": "hard-cap", "context_window": {"value": 8000, "source": "documented"}, "max_output_tokens": {"value": 1000, "source": "documented"}, "input_price_per_1m": {"value": 1.0, "source": "documented"}, "output_price_per_1m": {"value": 2.0, "source": "documented"}, "max_tools": {"value": 64, "source": "probed", "exhaustive": true, "note": "max=64"} }, "legacy:no-field": { "provider": "legacy", "model": "old-schema", "context_window": {"value": 100000, "source": "documented"}, "max_output_tokens": {"value": 4000, "source": "documented"}, "input_price_per_1m": {"value": 0.5, "source": "documented"}, "output_price_per_1m": {"value": 1.5, "source": "documented"} } } }` path := writeTempCapabilities(t, jsonWith) report, err := Load(path) if err != nil { t.Fatalf("unexpected error: %v", err) } if report == nil { t.Fatal("expected non-nil report") } // 1. exhaustive=false(已知下界) mm := report.Models["minimax:M2.7"] if mm == nil { t.Fatal("expected minimax:M2.7") } if mm.MaxTools.Exhaustive == nil { t.Error("MaxTools.Exhaustive: expected non-nil pointer") } else if *mm.MaxTools.Exhaustive != false { t.Errorf("MaxTools.Exhaustive: got %v, want false", *mm.MaxTools.Exhaustive) } if v, ok := mm.MaxTools.Value.(float64); !ok || v != 128 { t.Errorf("MaxTools.Value: got %v (%T), want 128", mm.MaxTools.Value, mm.MaxTools.Value) } // 2. exhaustive=true(确认上限) hc := report.Models["fake:hard-cap"] if hc == nil { t.Fatal("expected fake:hard-cap") } if hc.MaxTools.Exhaustive == nil { t.Error("MaxTools.Exhaustive: expected non-nil pointer") } else if *hc.MaxTools.Exhaustive != true { t.Errorf("MaxTools.Exhaustive: got %v, want true", *hc.MaxTools.Exhaustive) } // 3. 老 schema:字段不存在 → 整个 Capability 是零值,Exhaustive 为 nil old := report.Models["legacy:no-field"] if old == nil { t.Fatal("expected legacy:no-field") } if old.MaxTools.Exhaustive != nil { t.Errorf("legacy MaxTools.Exhaustive: got %v, want nil", old.MaxTools.Exhaustive) } if old.MaxTools.Value != nil { t.Errorf("legacy MaxTools.Value: got %v, want nil", old.MaxTools.Value) } } func TestLoad_InvalidJSON(t *testing.T) { path := writeTempCapabilities(t, `{this is not json`) report, err := Load(path) if err == nil { t.Fatal("expected error for invalid JSON, got nil") } if report != nil { t.Errorf("expected nil report on error, got %+v", report) } } func TestLoad_NoPathAvailable(t *testing.T) { // 同时禁用环境变量和 HOME,确认 Load("") 报 "no path available". // 精妙之处(CLEVER): os.UserHomeDir 读 $HOME,清空即可模拟不可解析. t.Setenv("FLYTO_CAPABILITIES_PATH", "") t.Setenv("HOME", "") // Unix 下同时清空 USERPROFILE(防止 Windows fallback 路径,虽然 Linux 测试不会走到). t.Setenv("USERPROFILE", "") _, err := Load("") if err == nil { // 某些环境下 os.UserHomeDir 仍可能 fallback,跳过. t.Skip("home dir still resolvable, cannot simulate no-path case") } }