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"]) } }