Files
magpie/internal/gui/api_settings_test.go
yetone 0361189f9e test: every package's tests pass on Windows, and none writes outside its sandbox (the user: 把 Windows 上那几个失败的测试修掉) — on a Windows box the suite failed in most packages, and the root package hung for 25 minutes. Tests set HOME, which os.UserHomeDir ignores on Windows (it reads USERPROFILE), so they read and wrote the runner's own home; each now sets USERPROFILE beside HOME, and Devin's, Goose's, Crush's and Claude Desktop's tests set APPDATA/LOCALAPPDATA, where those keep their files there. said() read the command's output only once it returned, and a pipe holds a few KB on Windows, so magpie model --help blocked forever; it reads while the command writes. Unix permission bits (the gateway key store's 0600 too), exec bits and symlinks Windows lacks are checked only off Windows; TOML strings hold Windows paths as literal strings, JSON as marshalled ones; a URL's path is compared as one. The provider and usage packages run in a home of their own (TestMain, as the gateway's already does), since a CLI's identity kept by a refresh still going when its test ended landed in the runner's ~/.config/magpie — and on a Mac in the user's own. Two product fixes came out of it: imagemcp took a file:///C:/… root or image for the path /C:/…, which doesn't exist on Windows, and now makes a Windows path of it (a host for a UNC share); and session discovery could keep a new Cowork session hidden on Linux, where a folder's mtime is a kernel tick and its size doesn't change, so an entry added within the tick of the last listing left both as they were — a listing made within 2s of its folder's last change is read again, as git does for racy files. TestFilePath and TestRacyDirectoryListedAgain fail without the change. On Windows 11 every package passes (32 of 32) and a run of each package in a fresh home leaves nothing in home, APPDATA or LOCALAPPDATA; on Linux (WSL) and macOS go test -tags nogui ./... passes. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build pass.
2026-10-01 23:15:07 +08:00

153 lines
5.9 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("USERPROFILE", 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("USERPROFILE", 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)
}