Files
Cyrene_v2/internal/persona/emotion/observer_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

166 lines
6.1 KiB
Go

package emotion
import "testing"
// newTestObserver builds an observer over one persona whose affection step is 10,
// capturing every emitted update.
func newTestObserver(t *testing.T, id string, decay float64, initial EmotionState) (*Observer, *Machine, *Affection, *[]Update) {
t.Helper()
machine, err := NewMachine(Params{InitialState: initial, DecayRate: decay})
if err != nil {
t.Fatalf("NewMachine: %v", err)
}
affection, err := NewAffection(50, 0, 700)
if err != nil {
t.Fatalf("NewAffection: %v", err)
}
mgr := NewManager()
mgr.Set(id, machine, affection)
emitted := &[]Update{}
obs := NewObserver(mgr, func(string) int { return 10 }, func(u Update) {
*emitted = append(*emitted, u)
})
return obs, machine, affection, emitted
}
// TestObserveMovesEmotionAndAffection locks the turn rule: a warm turn raises the
// mood toward happy/excited and steps affection up by the persona's configured
// magnitude, a cold turn lowers both, and the change is reported for broadcast.
func TestObserveMovesEmotionAndAffection(t *testing.T) {
t.Parallel()
obs, machine, affection, emitted := newTestObserver(t, "cyrene", 0.5, Calm)
if got := obs.Observe("cyrene", "今天谢谢你呀", true); got != Happy {
t.Fatalf("warm turn mood = %s, want happy", got)
}
if affection.Value() != 60 {
t.Fatalf("affection after warm turn = %d, want 60 (50+10)", affection.Value())
}
if got := obs.Observe("cyrene", "我今天好累,有点难过", true); got != Sad {
t.Fatalf("down turn mood = %s, want sad", got)
}
if affection.Value() != 50 {
t.Fatalf("affection after down turn = %d, want 50 (60-10)", affection.Value())
}
// A cue-free turn carries no signal: the emotion decays toward calm instead of
// jumping, so sad → calm.
if got := obs.Observe("cyrene", "帮我看下这个报错", true); got != Calm {
t.Fatalf("neutral turn mood = %s, want decayed calm", got)
}
if len(*emitted) != 3 {
t.Fatalf("emitted = %d, want 3 (one per real mood change)", len(*emitted))
}
if machine.Current() != Calm {
t.Fatalf("machine current = %s, want calm", machine.Current())
}
}
// TestObserveEmitsOnlyOnChange locks the noise guard: a turn that leaves the mood
// where it already was emits nothing, while a turn that moves it emits once.
func TestObserveEmitsOnlyOnChange(t *testing.T) {
t.Parallel()
obs, _, _, emitted := newTestObserver(t, "cyrene", 0.5, Calm)
// calm + neutral → calm: no change, no event.
if got := obs.Observe("cyrene", "把这段代码格式化一下", true); got != Calm {
t.Fatalf("neutral turn mood = %s, want calm", got)
}
if len(*emitted) != 0 {
t.Fatalf("emitted = %d, want 0 for an unchanged mood", len(*emitted))
}
// calm + warm → happy: one event.
obs.Observe("cyrene", "谢谢你", true)
// happy + warm → happy: unchanged, no second event.
obs.Observe("cyrene", "辛苦啦", true)
if len(*emitted) != 1 {
t.Fatalf("emitted = %d, want 1 (only the real change)", len(*emitted))
}
}
// TestObserveDecayDisabledByRate locks the persona's decayRate knob: with decay
// off, a cue-free turn leaves the mood untouched.
func TestObserveDecayDisabledByRate(t *testing.T) {
t.Parallel()
obs, machine, _, emitted := newTestObserver(t, "cyrene", 0, Happy)
if got := obs.Observe("cyrene", "帮我把日志改成 debug", true); got != Happy {
t.Fatalf("mood with decay disabled = %s, want happy unchanged", got)
}
if machine.Current() != Happy {
t.Fatalf("machine current = %s, want happy", machine.Current())
}
if len(*emitted) != 0 {
t.Fatalf("emitted = %d, want 0", len(*emitted))
}
}
// TestObserveRestrictedConversationIsReadOnly locks the isolation boundary: a
// group-chat/stranger turn (writable=false) never moves the persona's mood or
// affection, mirroring the read-only memory lane (design/02 §3, design/11 §2).
func TestObserveRestrictedConversationIsReadOnly(t *testing.T) {
t.Parallel()
obs, machine, affection, emitted := newTestObserver(t, "cyrene", 0.5, Calm)
if got := obs.Observe("cyrene", "我很喜欢你", false); got != "" {
t.Fatalf("restricted turn mood = %q, want empty", got)
}
if machine.Current() != Calm || affection.Value() != 50 {
t.Fatalf("restricted turn changed state to %s/%d, want calm/50", machine.Current(), affection.Value())
}
if len(*emitted) != 0 {
t.Fatalf("emitted = %d, want 0 for a restricted turn", len(*emitted))
}
}
// TestObserveUnknownPersonaIsNoop locks the persona-scoping guard: an unregistered
// persona id yields no mood and no panic.
func TestObserveUnknownPersonaIsNoop(t *testing.T) {
t.Parallel()
obs, machine, _, _ := newTestObserver(t, "cyrene", 0.5, Calm)
if got := obs.Observe("someone-else", "我爱你", true); got != "" {
t.Fatalf("unknown persona mood = %q, want empty", got)
}
if machine.Current() != Calm {
t.Fatalf("unknown persona id moved the registered machine to %s", machine.Current())
}
if got := obs.Observe("", "我爱你", true); got != "" {
t.Fatalf("empty persona mood = %q, want empty", got)
}
}
// TestObserveKeepsPersonasSeparate locks the per-persona isolation the whole
// mechanism rests on (design/01 §2: emotion follows the persona): a turn applied
// to one persona leaves every other persona untouched.
func TestObserveKeepsPersonasSeparate(t *testing.T) {
t.Parallel()
aMachine, err := NewMachine(Params{InitialState: Calm, DecayRate: 0.5})
if err != nil {
t.Fatalf("NewMachine: %v", err)
}
aAffection, err := NewAffection(50, 0, 700)
if err != nil {
t.Fatalf("NewAffection: %v", err)
}
bMachine, err := NewMachine(Params{InitialState: Calm, DecayRate: 0.5})
if err != nil {
t.Fatalf("NewMachine: %v", err)
}
bAffection, err := NewAffection(50, 0, 700)
if err != nil {
t.Fatalf("NewAffection: %v", err)
}
mgr := NewManager()
mgr.Set("a", aMachine, aAffection)
mgr.Set("b", bMachine, bAffection)
obs := NewObserver(mgr, func(string) int { return 10 }, nil)
if got := obs.Observe("a", "我爱你", true); got != Happy {
t.Fatalf("persona a mood = %s, want happy", got)
}
if bMachine.Current() != Calm || bAffection.Value() != 50 {
t.Fatalf("persona b = %s/%d, want calm/50 untouched", bMachine.Current(), bAffection.Value())
}
}