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 注释被转弯引号
166 lines
6.1 KiB
Go
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())
|
|
}
|
|
}
|