Files
magpie/internal/gui/api_settings_test.go
T
Comic Chang fe78829092 Per-model preferences: the machinery three changes share (#288)
Three changes let a user say something about one model the catalogue does
not carry: what it costs, how large it is, and what the provider calls it
upstream. They are separate pull requests and none needs another's
feature, but all three need the two things here, so this lands first.

**CheckModelKey** — those keys are `"<provider id>/<model id>"`, and
`"<provider id>/*"` covers every model of a provider. A model id may contain
slashes of its own, so only the part before the first is the provider's. A
key that does not parse is one no lookup will ever match, which is worth
refusing where the entry is written rather than leaving as a silent no-op.

**One convention instead of two lists.** Settings saves by decoding the
whole struct out of a request body and copying back the fields it knows
about; renaming a provider moves the keys of the maps it knows about. Both
were hand-maintained lists, and a per-model map added outside either was
not saved by the first and not moved by the second.

`settings` now walks its own fields for the convention — a field named
`Model*` of type `map[string]X` — and does both jobs from it:
`RenamePerModel` for a provider that changes its id, `CarryPerModel` for the
settings page. A map added later needs no line in either file. A field whose
keys are not a model's is not one of these and is not named `Model*`:
`Visible`, which is by agent id, and `HiddenModels`, which is by agent and
model, among them.

`TestRenameMovesEveryPerModelMap` populates every per-model map at once and
renames once, so it fails both for a map left out and for a map that never
ran. `TestSettingsPageSaveKeepsWhatOtherPagesSet` drives the route that
saves rather than a copy of it: with three per-model maps a hand-written
list and this walk do the same thing, so only a helper that stops working
is distinguishable today — the test earns its keep the day a fourth map
appears and someone goes back to writing the list out.
2026-09-30 19:14:55 +08:00

151 lines
5.8 KiB
Go

package gui
import (
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/yetone/magpie/internal/settings"
)
// The Settings page sends only its own choices. What the settings keep per
// model — every name, level, price and image answer the user gave of a model
// on a page of its own — is set elsewhere, so a save from this page has to
// carry it over. This drives the real handler: POST /api/settings with a body
// naming nothing but the theme.
//
// The tables are the settings' own — settings.PerModelKeys, the walk the
// save itself goes by — each seeded with an entry saying which table it was
// written for, so a table added to the settings later is carried and checked
// here without this test being changed.
func TestSettingsPageSaveCarriesEveryPerModelTable(t *testing.T) {
h := t.TempDir()
t.Setenv("HOME", h)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
seed := settings.Load()
if seedPerModelTables(t, &seed) == 0 {
t.Fatal("the settings hold no per-model map: there is nothing here for a save to carry over")
}
if err := settings.Save(seed); err != nil {
t.Fatal(err)
}
// what the settings say once written and read back, so what is compared
// after the request is what really reached the disk, not what was set
before := perModelTables(t)
rec := httptest.NewRecorder()
Handler(nil, nil).ServeHTTP(rec, httptest.NewRequest("POST", "/api/settings", strings.NewReader(`{"theme":"dark"}`)))
if rec.Code != http.StatusOK {
t.Fatalf("saving the theme: %d %s", rec.Code, rec.Body)
}
if s := settings.Load(); s.Theme != "dark" {
t.Fatalf("the theme was saved as %q, not dark", s.Theme)
}
now := perModelTables(t)
for name, was := range before {
got, ok := now[name]
if !ok {
t.Fatalf("%s is not one of the settings' per-model maps any more", name)
}
if !reflect.DeepEqual(was.Interface(), got.Interface()) {
t.Errorf("%s did not survive the Settings page's save:\n\tsaved %v\n\tnow %v", name, was.Interface(), got.Interface())
}
}
}
// CarryPerModel walks the Model* tables only. Which models an agent is shown
// is keyed by the agent, not by "<provider>/<model>", so Visible and
// HiddenModels are not among them and the save has to name them itself — the
// one place this page still lists what it keeps. A table added to that list
// later without a line here is dropped by a save; this says so rather than
// leaving it to be found.
func TestSettingsPageSaveKeepsWhichModelsAnAgentIsShown(t *testing.T) {
h := t.TempDir()
t.Setenv("HOME", h)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
seed := settings.Load()
seed.Visible = map[string][]string{"claude": {"p/m"}}
seed.HiddenModels = map[string][]string{"codex": {"p/n", "group/g"}}
if err := settings.Save(seed); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
Handler(nil, nil).ServeHTTP(rec, httptest.NewRequest("POST", "/api/settings", strings.NewReader(`{"theme":"dark"}`)))
if rec.Code != http.StatusOK {
t.Fatalf("saving the theme: %d %s", rec.Code, rec.Body)
}
got := settings.Load()
if !reflect.DeepEqual(got.Visible, map[string][]string{"claude": {"p/m"}}) {
t.Errorf("Visible after a save of the Settings page: %v; want map[claude:[p/m]]", got.Visible)
}
if !reflect.DeepEqual(got.HiddenModels, map[string][]string{"codex": {"p/n", "group/g"}}) {
t.Errorf("HiddenModels after a save of the Settings page: %v; want map[codex:[p/n group/g]]", got.HiddenModels)
}
}
// perModelTables are the settings' per-model maps as they stand now, by
// name: the settings' own walk of them (settings.PerModelKeys, which is the
// walk the save goes by too) rather than a list written out here, which a
// table added to the settings later would not be in.
func perModelTables(t *testing.T) map[string]reflect.Value {
t.Helper()
s := settings.Load()
out := map[string]reflect.Value{}
settings.PerModelKeys(&s, func(name string, m reflect.Value) { out[name] = m })
return out
}
// seedPerModelTables puts an entry into every per-model map of s, saying
// which table it was written for, so a table that came back empty, or with
// another's entry in it, is told apart. It counts the tables it seeded.
func seedPerModelTables(t *testing.T, s *settings.Settings) int {
t.Helper()
var n int
settings.PerModelKeys(s, func(name string, m reflect.Value) {
m.Set(reflect.MakeMap(m.Type()))
m.SetMapIndex(reflect.ValueOf("p/"+name).Convert(m.Type().Key()), perModelValue(m.Type().Elem(), name))
n++
})
return n
}
// perModelValue is a value of a per-model table's own type, saying which
// table it was written for, so a table that came back empty, or with another's
// entry in it, is told apart. A type with nothing said of it is left zero:
// the key alone gives the table away.
func perModelValue(typ reflect.Type, table string) reflect.Value {
switch typ.Kind() {
case reflect.String:
return reflect.ValueOf("said of " + table).Convert(typ)
case reflect.Bool:
return reflect.ValueOf(true).Convert(typ)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return reflect.ValueOf(int64(len(table))).Convert(typ)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return reflect.ValueOf(uint64(len(table))).Convert(typ)
case reflect.Float32, reflect.Float64:
return reflect.ValueOf(float64(len(table))).Convert(typ)
case reflect.Slice:
out := reflect.MakeSlice(typ, 1, 1)
out.Index(0).Set(perModelValue(typ.Elem(), table))
return out
case reflect.Array:
out := reflect.New(typ).Elem()
out.Index(0).Set(perModelValue(typ.Elem(), table))
return out
case reflect.Map:
out := reflect.MakeMap(typ)
out.SetMapIndex(reflect.ValueOf(table).Convert(typ.Key()), perModelValue(typ.Elem(), table))
return out
}
return reflect.Zero(typ)
}