package backthink import ( "context" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) func TestParseProactive_WithMarkers(t *testing.T) { t.Parallel() conclusion := "[我决定:push]\n[渠道:kook]\n我想提醒你明天有体检" msg, hint, push := parseProactive(conclusion) if !push { t.Fatalf("push = false, want true (decision said push)") } if hint != "kook" { t.Fatalf("channel hint = %q, want kook", hint) } if msg != "我想提醒你明天有体检" { t.Fatalf("message = %q, want the clean body", msg) } } func TestParseProactive_NoPushMarker(t *testing.T) { t.Parallel() conclusion := "[我决定:no_push]\n[渠道:telegram]\n暂时没有想说的" msg, hint, push := parseProactive(conclusion) if push { t.Fatalf("push = true, want false (decision said no_push)") } if hint != "telegram" { t.Fatalf("channel hint = %q, want telegram", hint) } if msg != "暂时没有想说的" { t.Fatalf("message = %q, want the clean body", msg) } } func TestParseProactive_ChannelColonWidth(t *testing.T) { t.Parallel() // Tolerate a full-width colon and trailing spaces, and uppercase channel value. conclusion := "[我决定:push] [渠道:WEB] 晚上早点睡" msg, hint, push := parseProactive(conclusion) if !push { t.Fatalf("push = false, want true") } if hint != "web" { t.Fatalf("channel hint = %q, want web (lowercased)", hint) } if msg != "晚上早点睡" { t.Fatalf("message = %q, want the clean body", msg) } } func TestParseProactive_NothingVariants(t *testing.T) { t.Parallel() for _, c := range []string{ "无", "无。", "nothing to report", "Nothing to report.", "nothing", "暂无", } { if _, _, push := parseProactive(c); push { t.Fatalf("parseProactive(%q) push = true, want false", c) } } } func TestParseProactive_NoMarkerDefaultPush(t *testing.T) { t.Parallel() conclusion := "洗衣机洗好了,衣服可以收了" msg, hint, push := parseProactive(conclusion) if !push { t.Fatalf("push = false, want true (no marker, not a nothing phrase)") } if hint != "" { t.Fatalf("channel hint = %q, want empty", hint) } if msg != conclusion { t.Fatalf("message = %q, want the whole conclusion", msg) } } func TestParseProactive_MalformedFailsSafe(t *testing.T) { t.Parallel() cases := []string{ "[我决定:maybe]", // decision value outside allow-list "[我决定]", // bracket without a value "[我决定:push", // unterminated bracket "[渠道:web]", // channel marker alone, no decision → but no decision marker "[我决定:push", // deliberate ambiguity } for _, c := range cases { if _, _, push := parseProactive(c); push { t.Fatalf("parseProactive(%q) push = true, want false (fail safe on ambiguity)", c) } } } func TestParseProactive_MarkerWithoutBody(t *testing.T) { t.Parallel() // A lone push marker with no body must not push an empty message (fail safe). if _, _, push := parseProactive("[我决定:push]"); push { t.Fatalf("push = true for a marker with no body, want false") } // A push marker whose body says "nothing" is contradictory → fail safe. if _, _, push := parseProactive("[我决定:push]\n无"); push { t.Fatalf("push = true for a push marker with nothing body, want false") } } func TestThink_ParsesStructuredDecision_EndToEnd(t *testing.T) { t.Parallel() conclusion := "[我决定:push]\n[渠道:kook]\n我想提醒你明天有体检" thinker := &mockThinker{text: conclusion} active := &mockActive{n: 1} im := &mockIMAdapter{} b, _ := newBackthink(t, Config{}, Deps{ Thinker: thinker, State: &mockState{state: UserState{}}, Active: active, IM: im, Gate: &mockGate{allow: true}, Now: func() time.Time { return dayAt() }, }) b.think(context.Background(), heartbeatTrigger()) // The channel hint "kook" routes to the IM bridge (a specific platform), not the // active client. if active.calls != 0 { t.Fatalf("active calls = %d, want 0 (specific platform routes to IM)", active.calls) } if im.count() != 1 { t.Fatalf("im calls = %d, want 1", im.count()) } if im.last.Content != "我想提醒你明天有体检" { t.Fatalf("im message content = %q, want the clean body", im.last.Content) } if im.last.Platform != "kook" { t.Fatalf("im message platform = %q, want kook", im.last.Platform) } recs, err := b.List(context.Background(), 10) if err != nil { t.Fatalf("List: %v", err) } if len(recs) == 0 { t.Fatal("no record persisted") } if !recs[0].ShouldPush { t.Fatalf("ShouldPush = false, want true") } if recs[0].Conclusion != "我想提醒你明天有体检" { t.Fatalf("record Conclusion = %q, want the clean body", recs[0].Conclusion) } if recs[0].PushResult.Route != RouteIMBridge { t.Fatalf("record Route = %q, want %q", recs[0].PushResult.Route, RouteIMBridge) } if !strings.Contains(recs[0].PushResult.Detail, "channel_hint=kook") { t.Fatalf("record Detail = %q, want it to include the kook hint", recs[0].PushResult.Detail) } if !recs[0].PushResult.Pushed { t.Fatalf("record Pushed = false, want true") } } func TestThink_NoMarkerDefaultRoute_ActiveFirst(t *testing.T) { t.Parallel() // No channel marker → auto → active client first (the pre-P2 behavior). conclusion := "[我决定:push]\n洗衣机洗好了,衣服可以收了" thinker := &mockThinker{text: conclusion} active := &mockActive{n: 2} im := &mockIMAdapter{} b, _ := newBackthink(t, Config{}, Deps{ Thinker: thinker, State: &mockState{state: UserState{}}, Active: active, IM: im, Gate: &mockGate{allow: true}, Now: func() time.Time { return dayAt() }, }) b.think(context.Background(), heartbeatTrigger()) if active.calls != 1 { t.Fatalf("active calls = %d, want 1", active.calls) } if im.count() != 0 { t.Fatalf("im calls = %d, want 0 (active preferred)", im.count()) } recs, err := b.List(context.Background(), 10) if err != nil { t.Fatalf("List: %v", err) } if recs[0].PushResult.Route != RouteActiveClient { t.Fatalf("record Route = %q, want %q", recs[0].PushResult.Route, RouteActiveClient) } if recs[0].PushResult.Pushed != true { t.Fatalf("record Pushed = %v, want true", recs[0].PushResult.Pushed) } } func TestThink_ConcludedNoPush_NotPushed(t *testing.T) { t.Parallel() conclusion := "[我决定:no_push]\n今晚就不打扰她了" thinker := &mockThinker{text: conclusion} active := &mockActive{n: 1} im := &mockIMAdapter{} b, _ := newBackthink(t, Config{}, Deps{ Thinker: thinker, State: &mockState{state: UserState{}}, Active: active, IM: im, Gate: &mockGate{allow: true}, Now: func() time.Time { return dayAt() }, }) b.think(context.Background(), heartbeatTrigger()) if active.calls != 0 || im.count() != 0 { t.Fatalf("push attempted despite no_push: active=%d im=%d", active.calls, im.count()) } recs, err := b.List(context.Background(), 10) if err != nil { t.Fatalf("List: %v", err) } if recs[0].ShouldPush { t.Fatalf("ShouldPush = true, want false") } if recs[0].PushResult.Route != RouteNone { t.Fatalf("record Route = %q, want %q", recs[0].PushResult.Route, RouteNone) } } func TestThink_DoNotDisturbBlocksPush(t *testing.T) { t.Parallel() // She wants to push, but the user is asleep → the hard safety gate refuses, // regardless of her decision. conclusion := "[我决定:push]\n想跟你说晚安" thinker := &mockThinker{text: conclusion} b, _ := newBackthink(t, Config{}, Deps{ Thinker: thinker, State: &mockState{state: UserState{Sleeping: true}}, Pusher: &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}, Now: func() time.Time { return nightAt() }, }) b.think(context.Background(), heartbeatTrigger()) recs, err := b.List(context.Background(), 10) if err != nil { t.Fatalf("List: %v", err) } if recs[0].ShouldPush { t.Fatalf("ShouldPush = true, want false (asleep blocks push)") } if recs[0].PushResult.Route != RouteNone { t.Fatalf("record Route = %q, want %q", recs[0].PushResult.Route, RouteNone) } } func TestBuildProactiveMessage_DefaultPlatform(t *testing.T) { t.Parallel() b := &Backthink{now: dayAt} msg := b.buildProactiveMessage("晚安", "") if msg.Platform != defaultProactivePlatform { t.Fatalf("Platform = %q, want default %q", msg.Platform, defaultProactivePlatform) } if msg.Content != "晚安" { t.Fatalf("Content = %q, want 晚安", msg.Content) } if msg.ChannelType != adapter.ChannelDirect || msg.ContentType != adapter.ContentText { t.Fatalf("message kind = %q/%q, want direct/text", msg.ChannelType, msg.ContentType) } } func TestBuildProactiveMessage_SpecificPlatform(t *testing.T) { t.Parallel() b := &Backthink{now: dayAt} msg := b.buildProactiveMessage("晚安", "telegram") if msg.Platform != "telegram" { t.Fatalf("Platform = %q, want telegram", msg.Platform) } } func TestPusher_SpecificPlatform_NoIM_FallsBackToActive(t *testing.T) { t.Parallel() // When a specific platform is requested but no IM sink is wired, the pusher // falls back to the default route so the owner still receives the message, and // records the requested hint. active := &mockActive{n: 1} p := newPusher(active, nil, &mockGate{allow: true}, discardLog()) res := p.Push(context.Background(), PushRequest{ Message: adapter.UnifiedMessage{}, ChannelHint: "kook", }) if !res.Pushed || res.Route != RouteActiveClient { t.Fatalf("push = %+v, want pushed active_client (fallback)", res) } if !strings.Contains(res.Detail, "channel_hint=kook") { t.Fatalf("Detail = %q, want it to include the kook hint", res.Detail) } }