Files
Cyrene_v2/internal/services/gateway/persona_read_test.go
T
AskaEth 2aa3233f84
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(persona,web): 人格读侧(thinking 三态 + 角色级 mood)+ 面板表单 + mood 播种
按 design/04 §3.2(thinking 语义,showToUser 三态)+ design/01 §2.1 + 主人两条定论(心情跟角色不跟会话;mood 初值走口径 ①:由角色读端点暴露、前端加载播种)。
- 管理侧 GET /admin/personas 的 PersonaSummary 增 thinking(budgetTokens/effort/mode/showToUser)——**showToUser 保持三态**(未配置 ≠ false)
- 用户侧 GET /personas 增**角色级 mood**(取自 emotion 层该角色当前状态,非从消息推断)→ 前端加载播种 currentMood,消除「刷新后心情徽标短暂为空」
- 前端 Personas 页加 thinking 四控件(预算/effort/模式/showToUser 三态):只做输入校验,**provider 是否真支持由后端 WARN 决定,前端不假装能力**
- 先拆 useAppStore 选择器(原 494/500)到独立模块再做,避免越线
- Docs/api-client 版本号 +1(两处契约 + 三态与角色级 mood 语义)
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + 前端 build 绿 + personaSession/messageSync 逻辑测试通过
2026-10-03 20:40:44 +08:00

208 lines
7.8 KiB
Go

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)
}
}