主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
208 lines
7.7 KiB
Go
208 lines
7.7 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 从给定人格构建真实人格注册表(生产
|
|
// PersonaStore,它同时持有每个角色的情绪机),并一并注册
|
|
// 匹配的角色。
|
|
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 拉取人格列表端点并把每行作为原始 map 返回,
|
|
// 使测试能区分键缺失与存在且为 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 为这些读端点铸造一个 owner token。
|
|
func mustSign(t *testing.T, jwt *auth.JWTService) string {
|
|
t.Helper()
|
|
return signAny(t, jwt, "acct", auth.RoleOwner)
|
|
}
|
|
|
|
// TestAdminListPersonasThinkingKeepShowToUserTriState 钉住面板 `thinking`
|
|
// 块的读侧契约:未配置的人格不携带 `thinking`
|
|
// 键(nil,而不是全零对象),显式为 false 的 `showToUser` 保持
|
|
// `false`,未设置的保持 `null` —— 绝不被压平成 false,这正是
|
|
// 写侧用 *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 钉住用户侧形状:角色级
|
|
// mood 一并带上(前端从它给角标播种),而仅管理的
|
|
// thinking 开关留在用户契约之外。
|
|
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 是「mood 是角色级、不是每会话」规则:
|
|
// 它读取活的情绪机,因此一个角色机器上的转移会为该角色
|
|
// 体现出来,且不触碰其他任何角色,路径中
|
|
// 任何地方都没有会话输入。
|
|
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)
|
|
}
|
|
// 只移动昔涟的机器(一个已结算回合的观察者会移动的机器)。
|
|
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 是诚实的退化:不持有情绪
|
|
// 的人格存储(它只列出人格)报告空 mood,而不是
|
|
// 编造一个。
|
|
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)
|
|
}
|
|
}
|