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