package gateway import ( "encoding/json" "net/http" "net/http/httptest" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" ) // moodRegistry builds a real persona registry (the production PersonaStore, which // also owns each role's emotion machine) from the given personas, registering the // matching character too. func moodRegistry(t *testing.T, personas ...*loader.Persona) *personareg.Registry { t.Helper() reg := personareg.New() for _, p := range personas { if err := reg.RegisterPersona(p); err != nil { t.Fatalf("RegisterPersona(%s): %v", p.ID, err) } if err := reg.RegisterCharacter(&personareg.Character{ID: p.ID, Name: p.Name, PersonaID: p.ID}); err != nil { t.Fatalf("RegisterCharacter(%s): %v", p.ID, err) } } return reg } // personaList fetches a persona list endpoint and returns each row as a raw map, // so the test can tell a missing key from a present null/false. func personaList(t *testing.T, ts *httptest.Server, jwt *auth.JWTService, path string) []map[string]any { t.Helper() resp, body := doReq(t, http.MethodGet, ts.URL+path, nil, mustSign(t, jwt)) if resp.StatusCode != http.StatusOK { t.Fatalf("GET %s status = %d, want 200 (body=%s)", path, resp.StatusCode, body) } var rows []map[string]any if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil { t.Fatalf("decode %s: %v", path, err) } return rows } // mustSign mints an owner token for the read endpoints. func mustSign(t *testing.T, jwt *auth.JWTService) string { t.Helper() return signAny(t, jwt, "acct", auth.RoleOwner) } // TestAdminListPersonasThinkingKeepShowToUserTriState pins the read-side contract // of the panel's `thinking` block: an unconfigured persona carries NO `thinking` // key (nil, not an all-zero object), an explicitly false `showToUser` stays // `false`, and an unset one stays `null` — never flattened into false, which is // the distinction the write side keeps with *bool. func TestAdminListPersonasThinkingKeepShowToUserTriState(t *testing.T) { t.Parallel() off := false on := true reg := moodRegistry(t, &loader.Persona{ID: "plain", Name: "素面", Emotion: loader.EmotionParams{InitialState: emotion.Calm}}, &loader.Persona{ID: "full", Name: "全能", Thinking: loader.ThinkingParams{ BudgetTokens: 4096, Effort: "high", ShowToUser: &off, Mode: loader.ThinkingModeOff, }}, &loader.Persona{ID: "budget-only", Name: "只配预算", Thinking: loader.ThinkingParams{BudgetTokens: 2048}}, &loader.Persona{ID: "forced-on", Name: "强制开", Thinking: loader.ThinkingParams{ShowToUser: &on, Mode: loader.ThinkingModeOn}}, ) _, ts, jwt := buildEnv(t, nil, Deps{Personas: reg}) rows := personaList(t, ts, jwt, "/api/v1/admin/personas") byID := map[string]map[string]any{} for _, row := range rows { byID[row["id"].(string)] = row } if len(byID) != 4 { t.Fatalf("rows = %d, want 4", len(byID)) } if _, present := byID["plain"]["thinking"]; present { t.Fatalf("unconfigured persona carries thinking = %#v, want the key absent", byID["plain"]["thinking"]) } full, ok := byID["full"]["thinking"].(map[string]any) if !ok { t.Fatalf("full.thinking = %#v, want an object", byID["full"]["thinking"]) } if full["budgetTokens"] != float64(4096) || full["effort"] != "high" || full["mode"] != "off" { t.Fatalf("full.thinking = %#v, want 4096/high/off", full) } if show, ok := full["showToUser"].(bool); !ok || show { t.Fatalf("full.thinking.showToUser = %#v, want the boolean false", full["showToUser"]) } budgetOnly, ok := byID["budget-only"]["thinking"].(map[string]any) if !ok { t.Fatalf("budget-only.thinking = %#v, want an object", byID["budget-only"]["thinking"]) } show, present := budgetOnly["showToUser"] if !present { t.Fatal("budget-only.thinking has no showToUser key, want an explicit null") } if show != nil { t.Fatalf("budget-only.thinking.showToUser = %#v, want null (unconfigured)", show) } forced, ok := byID["forced-on"]["thinking"].(map[string]any) if !ok { t.Fatalf("forced-on.thinking = %#v, want an object", byID["forced-on"]["thinking"]) } if show, ok := forced["showToUser"].(bool); !ok || !show { t.Fatalf("forced-on.thinking.showToUser = %#v, want the boolean true", forced["showToUser"]) } if forced["mode"] != "on" { t.Fatalf("forced-on.thinking.mode = %#v, want on", forced["mode"]) } } // TestUserListPersonasReportsRoleMoodOnly pins the user-side shape: the role-level // mood rides along (the frontend seeds its badge from it), while the admin-only // thinking knobs stay out of the user contract. func TestUserListPersonasReportsRoleMoodOnly(t *testing.T) { t.Parallel() off := false reg := moodRegistry(t, &loader.Persona{ID: "xilian", Name: "昔涟", Emotion: loader.EmotionParams{InitialState: emotion.Happy}, Thinking: loader.ThinkingParams{BudgetTokens: 1024, ShowToUser: &off}}, &loader.Persona{ID: "yuki", Name: "雪"}, ) _, ts, jwt := buildEnv(t, nil, Deps{Personas: reg}) rows := personaList(t, ts, jwt, "/api/v1/personas") byID := map[string]map[string]any{} for _, row := range rows { byID[row["id"].(string)] = row } if got := byID["xilian"]["mood"]; got != "happy" { t.Fatalf("xilian mood = %#v, want happy", got) } if got := byID["yuki"]["mood"]; got != "" { t.Fatalf("yuki mood = %#v, want the honest empty (no tracked state)", got) } for id, row := range byID { if _, present := row["thinking"]; present { t.Fatalf("%s carries thinking on the user-side list, want the key absent", id) } } } // TestPersonaMoodFollowsTheRole is the "mood is role-level, not per-session" rule: // it reads the live emotion machine, so a transition on one role's machine shows // up for that role and leaves every other role untouched, with no session input // anywhere in the path. func TestPersonaMoodFollowsTheRole(t *testing.T) { t.Parallel() reg := moodRegistry(t, &loader.Persona{ID: "xilian", Name: "昔涟", Emotion: loader.EmotionParams{InitialState: emotion.Calm}}, &loader.Persona{ID: "yuki", Name: "雪", Emotion: loader.EmotionParams{InitialState: emotion.Calm}}, ) _, ts, jwt := buildEnv(t, nil, Deps{Personas: reg}) readMoods := func() (string, string) { rows := personaList(t, ts, jwt, "/api/v1/personas") byID := map[string]string{} for _, row := range rows { byID[row["id"].(string)], _ = row["mood"].(string) } return byID["xilian"], byID["yuki"] } if x, y := readMoods(); x != "calm" || y != "calm" { t.Fatalf("initial moods = (%q, %q), want (calm, calm)", x, y) } // Move only 昔涟's machine (the machine a settled turn's observer would move). if err := reg.Emotions().Transition("xilian", emotion.Happy); err != nil { t.Fatalf("Transition: %v", err) } if x, y := readMoods(); x != "happy" || y != "calm" { t.Fatalf("moods after one role moved = (%q, %q), want (happy, calm)", x, y) } } // TestPersonaMoodEmptyWithoutEmotionStore is the honest degrade: a persona store // that does not own emotion (it only lists personas) reports an empty mood rather // than inventing one. func TestPersonaMoodEmptyWithoutEmotionStore(t *testing.T) { t.Parallel() reg := moodRegistry(t, &loader.Persona{ID: "xilian", Name: "昔涟", Emotion: loader.EmotionParams{InitialState: emotion.Happy}}) _, ts, jwt := buildEnv(t, nil, Deps{Personas: &stubPersonas{list: reg.List()}}) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/personas", nil, mustSign(t, jwt)) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var rows []map[string]any if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil { t.Fatalf("decode: %v", err) } if len(rows) != 1 || rows[0]["mood"] != "" { t.Fatalf("rows = %#v, want one row with an empty mood", rows) } }