Files
Cyrene_v2/internal/services/gateway/mood_test.go
T
AskaEth 1a85de555d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(emotion): A1 情绪系统接线 + refactor: 拆分 channelbridge.go
A1 情绪接线:
- loop 回合边界钩子(TurnEnd/OnTurnEnd/settleTurn, Result.Mood); 每轮一次非每步
- persona/emotion: React(valence五档) + Machine.Move(BFS最短合法路径一步) + Observer(observe/decay/好感度,仅变化才emit); 好感度步长读角色配置(cyrene delta=10, 0..700)
- 落库 Mood(web session_persist.res.Mood + IM channelbridge/persist; user消息不带mood); WS mood事件(BroadcastMood,仅变化); per-persona隔离; 群聊/陌生人只读不写不发
- cmd/cyrene/emotion.go 生产接线 + wire.go 注入 loop.Options.OnTurnEnd
- 22 测试(状态机被loop调用/Mood非空落库/mood帧shape/只变化才发/群聊只读) + 真二进制冒烟
- Docs: api-client v0.32 §3.7 情绪事件 + 开源对照清单(companion 应用对照)
- 诚实: 本机无LLM凭据→未活体验证'模型返回后完整轮次'一环

拆分 channelbridge.go 816→257(纯搬移零逻辑): connect/inbound/outbound/blacklist/helpers.go; A1 的 IM mood 调用点落在新 inbound.go, 故与本提交同批

回滚 6 个 gofmt 顺手格式化文件(capacity_loop_test/flow/source_test/findchannel_test/admin_channels_platform_test/history) 缩 diff, 含 history.go 注释被转弯引号
2026-09-30 08:51:38 +08:00

104 lines
3.6 KiB
Go

package gateway
import (
"context"
"io"
"log/slog"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// moodRunner is a TurnRunner that settles one turn with a fixed result, so the
// test can assert the mood the loop reported is what gets persisted.
type moodRunner struct {
mood string
}
func (m moodRunner) Run(_ context.Context, _ string, _ []model.Message, _ loop.SessionEnv) (*loop.Result, error) {
return &loop.Result{Text: "在的呀♪", Steps: 1, StopReason: "stop", Mood: m.mood}, nil
}
// TestOrchestratorPersistsTurnMood locks the mood column end to end on the Web
// path: the mood the loop's turn-boundary hook reported lands on the persisted
// assistant message (design/16 §3.3 `mode/mood/thinking`), so the DTO,
// `message_persisted` and history replay carry real data instead of an always
// empty mood. The user message keeps an empty mood (design/16 §3.3 assigns the
// mood to the assistant reply).
func TestOrchestratorPersistsTurnMood(t *testing.T) {
t.Parallel()
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{
Run: moodRunner{mood: "happy"}.Run,
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
History: hist,
})
sr, res := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("谢谢你")}, loop.OwnerEnv())
if !sr.Accepted || res == nil {
t.Fatalf("submit = %+v res=%v, want accepted with a result", sr, res)
}
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
}
if recs[1].Role != history.RoleAssistant || recs[1].Mood != "happy" {
t.Fatalf("assistant record = %+v, want Mood happy", recs[1])
}
wire := toHistoryMessage(recs[1])
if wire.Mood != "happy" {
t.Fatalf("wire mood = %q, want happy (message_persisted payload)", wire.Mood)
}
if recs[0].Mood != "" {
t.Fatalf("user message mood = %q, want empty", recs[0].Mood)
}
}
// TestOrchestratorPersistsEmptyMoodWhenUntracked locks the honest degrade: with no
// emotion tracking (no hook wired, so Result.Mood is empty) the stored mood stays
// empty rather than being fabricated.
func TestOrchestratorPersistsEmptyMoodWhenUntracked(t *testing.T) {
t.Parallel()
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{
Run: moodRunner{mood: ""}.Run,
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
History: hist,
})
if _, res := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("你好")}, loop.OwnerEnv()); res == nil {
t.Fatal("sync result is nil")
}
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2", len(recs))
}
if recs[1].Mood != "" {
t.Fatalf("assistant mood = %q, want empty", recs[1].Mood)
}
}
// TestServerBroadcastMood locks the `mood` frame the frontend's mood handler reads
// (design/07 §3): the type is `mood`, data.mood is the label, and the persona id /
// affection ride along as extra detail.
func TestServerBroadcastMood(t *testing.T) {
t.Parallel()
env := newEnv(t, nil, false)
c := newFakeConn(env.srv.hub, 32)
env.srv.hub.register(c)
env.srv.BroadcastMood("cyrene", "happy", 60)
ev := readFakeFrame(t, c)
if ev.Type != "mood" {
t.Fatalf("frame type = %q, want mood (frame=%+v)", ev.Type, ev)
}
data, ok := ev.Data.(map[string]any)
if !ok || data["mood"] != "happy" || data["personaId"] != "cyrene" {
t.Fatalf("frame data = %+v, want {mood:happy,personaId:cyrene}", ev.Data)
}
if got, ok := data["affection"].(float64); !ok || got != 60 {
t.Fatalf("frame affection = %v, want 60", data["affection"])
}
}