package main import ( "context" "testing" "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" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "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" ) // fakeMoodEmitter captures the `mood` broadcasts the composition root forwards, // so tests can assert an emotion change actually reaches the Web UI. type fakeMoodEmitter struct { calls int personaID string mood string affection int } func (f *fakeMoodEmitter) BroadcastMood(personaID, mood string, affection int) { f.calls++ f.personaID = personaID f.mood = mood f.affection = affection } // moodAdapter is a model.Adapter returning fixed text, so a test can drive a real // loop turn without a provider. type moodAdapter struct{ reply string } func (m moodAdapter) Name() string { return "mood-test" } func (m moodAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) { return model.Response{Content: m.reply}, nil } func (m moodAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) { return m.Chat(ctx, req, model.Options{}) } // testEmotionApp builds an app with one registered persona (emotion calm, decay // enabled, affection 50/700 step 10) and the emotion observer wired exactly as // the composition root wires it, plus a capturing mood sink. func testEmotionApp(t *testing.T) (*app, *fakeMoodEmitter) { t.Helper() reg := personareg.New() persona := &loader.Persona{ ID: "cyrene", Name: "昔涟", Emotion: loader.EmotionParams{ InitialState: emotion.Calm, DecayRate: 0.5, }, Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10}, } if err := reg.RegisterPersona(persona); err != nil { t.Fatalf("RegisterPersona: %v", err) } if err := reg.RegisterCharacter(&personareg.Character{ID: "cyrene", Name: "昔涟", Default: true, PersonaID: "cyrene"}); err != nil { t.Fatalf("RegisterCharacter: %v", err) } sink := &fakeMoodEmitter{} a := &app{log: testLog(), personas: reg, moodSink: sink} a.emotion = a.buildEmotionObserver() return a, sink } // testMoodLoop builds a real loop whose turn boundary is the app's hook. func testMoodLoop(t *testing.T, a *app, reply string) *loop.Loop { t.Helper() ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(moodAdapter{reply: reply}, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } return loop.New(loop.Options{Agent: ag, PersonaID: "cyrene", OnTurnEnd: a.onTurnEnd}) } // TestOnTurnEndDrivesEmotionThroughLoop is the A1 end-to-end lock: a real loop // turn is applied to the bound persona's emotion machine, the mood lands on // Result.Mood (which the persistence layer writes onto the assistant message), // affection steps by the persona's configured magnitude, and the change is // broadcast once as a `mood` frame. func TestOnTurnEndDrivesEmotionThroughLoop(t *testing.T) { a, sink := testEmotionApp(t) l := testMoodLoop(t, a, "人家也开心♪") res, err := l.Run(loop.WithSessionEnv(context.Background(), loop.OwnerEnv()), "web", []model.Message{model.TextMessage(model.RoleUser, "今天谢谢你呀,好开心")}) if err != nil { t.Fatalf("Run: %v", err) } if res.Mood != string(emotion.Happy) { t.Fatalf("Result.Mood = %q, want happy", res.Mood) } machine, affection, ok := a.personas.Emotions().Get("cyrene") if !ok { t.Fatal("persona cyrene not registered in the emotion manager") } if machine.Current() != emotion.Happy { t.Fatalf("machine state = %s, want happy", machine.Current()) } if affection.Value() != 60 { t.Fatalf("affection = %d, want 60 (50 + the persona's step 10)", affection.Value()) } if sink.calls != 1 || sink.mood != string(emotion.Happy) || sink.personaID != "cyrene" || sink.affection != 60 { t.Fatalf("mood broadcast = %+v, want one happy/cyrene/60 frame", sink) } } // TestOnTurnEndEvolvesAndDecaysAcrossTurns locks how the emotion walks over a // conversation: each emotional turn moves one legal edge toward the state the cue // aims at (so a strong warm cue reaches excited on the second turn, not the // first), a cue-free turn lets it decay one step toward calm (design/01 §2 // decayRate), and each real move is broadcast once while an unchanged mood // broadcasts nothing. func TestOnTurnEndEvolvesAndDecaysAcrossTurns(t *testing.T) { a, sink := testEmotionApp(t) l := testMoodLoop(t, a, "嗯") ctx := loop.WithSessionEnv(context.Background(), loop.OwnerEnv()) res, err := l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "我太开心了,最喜欢你了")}) if err != nil { t.Fatalf("Run: %v", err) } if res.Mood != string(emotion.Happy) { t.Fatalf("first strong warm turn mood = %q, want the first legal step happy", res.Mood) } // A second strong warm turn escalates to excited. res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "最喜欢你了")}) if err != nil { t.Fatalf("Run: %v", err) } if res.Mood != string(emotion.Excited) { t.Fatalf("second strong warm turn mood = %q, want excited", res.Mood) } if sink.calls != 2 || sink.mood != string(emotion.Excited) { t.Fatalf("broadcasts = %d/%s, want 2 with excited", sink.calls, sink.mood) } // Same mood again: no change, so no third frame. res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "还是最喜欢你")}) if err != nil { t.Fatalf("Run: %v", err) } if res.Mood != string(emotion.Excited) { t.Fatalf("third warm turn mood = %q, want excited unchanged", res.Mood) } if sink.calls != 2 { t.Fatalf("broadcasts = %d, want 2 (unchanged mood is not news)", sink.calls) } // A cue-free turn carries no signal, so the emotion decays excited → happy. res, err = l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "帮我看下这个报错")}) if err != nil { t.Fatalf("Run: %v", err) } if res.Mood != string(emotion.Happy) { t.Fatalf("neutral turn mood = %q, want decayed happy", res.Mood) } if sink.calls != 3 || sink.mood != string(emotion.Happy) { t.Fatalf("broadcasts = %d/%s, want 3 with happy", sink.calls, sink.mood) } } // TestOnTurnEndRestrictedConversationIsReadOnly locks the isolation boundary at // the wiring level: a group/stranger turn never moves the persona's mood or // affection and never broadcasts, matching the read-only memory lane (design/02 // §3, design/11 §2). func TestOnTurnEndRestrictedConversationIsReadOnly(t *testing.T) { a, sink := testEmotionApp(t) l := testMoodLoop(t, a, "你好呀") restricted := loop.SessionEnv{MemWritable: false} ctx := loop.WithSessionEnv(context.Background(), restricted) res, err := l.Run(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, "我好喜欢你")}) if err != nil { t.Fatalf("Run: %v", err) } if res.Mood != "" { t.Fatalf("Result.Mood = %q, want empty for a read-only conversation", res.Mood) } machine, affection, _ := a.personas.Emotions().Get("cyrene") if machine.Current() != emotion.Calm || affection.Value() != 50 { t.Fatalf("restricted turn moved the persona to %s/%d, want calm/50", machine.Current(), affection.Value()) } if sink.calls != 0 { t.Fatalf("broadcasts = %d, want 0 for a restricted conversation", sink.calls) } } // TestOnTurnEndFallsBackToDefaultPersona locks the persona binding: a loop bound // without an explicit persona id advances the library's default persona instead // of silently tracking nothing. func TestOnTurnEndFallsBackToDefaultPersona(t *testing.T) { a, sink := testEmotionApp(t) if got := a.onTurnEnd(loop.TurnEnd{SessionID: "web", UserText: "谢谢你", Env: loop.OwnerEnv()}); got != string(emotion.Happy) { t.Fatalf("mood = %q, want happy on the default persona", got) } if sink.personaID != "cyrene" { t.Fatalf("broadcast persona = %q, want cyrene", sink.personaID) } } // TestOnTurnEndSkipsNonConversationLanes locks the lane gate: only the owner's // conversation (main session and its sub-sessions) moves the persona's mood. // Autonomous / delegated loop calls — background thinking, sub-agent work, // isolated IM sessions — are not conversation turns and must leave her state // alone. func TestOnTurnEndSkipsNonConversationLanes(t *testing.T) { a, sink := testEmotionApp(t) for _, sessionID := range []string{"sess-backthink", "sess-group-abc", "task-42", ""} { if got := a.onTurnEnd(loop.TurnEnd{SessionID: sessionID, UserText: "我好喜欢你", Env: loop.OwnerEnv()}); got != "" { t.Fatalf("mood for session %q = %q, want empty", sessionID, got) } } machine, affection, _ := a.personas.Emotions().Get("cyrene") if machine.Current() != emotion.Calm || affection.Value() != 50 { t.Fatalf("non-conversation lane moved the persona to %s/%d, want calm/50", machine.Current(), affection.Value()) } if sink.calls != 0 { t.Fatalf("broadcasts = %d, want 0", sink.calls) } // The main session and its sub-sessions are the conversation lane. for _, sessionID := range []string{history.MainSessionID, history.MainSessionID + ":abc"} { if got := a.onTurnEnd(loop.TurnEnd{SessionID: sessionID, UserText: "谢谢你", Env: loop.OwnerEnv()}); got != string(emotion.Happy) { t.Fatalf("mood for session %q = %q, want happy", sessionID, got) } } } // TestOnTurnEndNilDependenciesNoPanic locks the degraded boots: no observer and // no gateway sink must not panic (the gateway is built after the loop). func TestOnTurnEndNilDependenciesNoPanic(t *testing.T) { empty := &app{log: testLog()} if got := empty.onTurnEnd(loop.TurnEnd{UserText: "我爱你", Env: loop.OwnerEnv()}); got != "" { t.Fatalf("mood without an observer = %q, want empty", got) } empty.publishMood(emotion.Update{PersonaID: "cyrene", Before: emotion.Calm, After: emotion.Happy}) a, _ := testEmotionApp(t) a.moodSink = nil a.publishMood(emotion.Update{PersonaID: "cyrene", Before: emotion.Calm, After: emotion.Happy}) }