package backthink import ( "bytes" "context" "log/slog" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // 本文件钉住 design/12 §N「静默时段降频」:心跳在安静窗口内降频、窗外回落, // 且降频**只作用于周期性心跳**——事件驱动通路照旧立刻触发(§N A2)。 // // 三条边界必须同时成立,缺一条就是「暗坑」: // 1. 每拍**重算**间隔(不是启动时定死)⇒ 跨窗口边界真的切换; // 2. `QuietInterval ≤ 0` = 关闭降频(回落普通间隔),**不是**「半夜不思考」; // 3. 安静判定与既有三处 `inQuietHours` 同源(不另写一份越走越偏的口径)。 // fakeClock 是可注入的墙钟:测试在运行中挪动它,以驱动「跨窗口边界」。 type fakeClock struct { mu sync.Mutex at time.Time } func (c *fakeClock) now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.at } func (c *fakeClock) set(at time.Time) { c.mu.Lock() c.at = at c.mu.Unlock() } // atHour 造一个本地钟点为 h:m 的时刻(UTC 承载,Hour() 即 h)。 func atHour(h, m int) time.Time { return time.Date(2026, 10, 5, h, m, 0, 0, time.UTC) } // intervalFor 在给定配置上取 at 时刻的心跳间隔(经 normalize 后的真实路径)。 func intervalFor(cfg Config, at time.Time) time.Duration { b := &Backthink{cfg: normalize(cfg)} return b.heartbeatInterval(at) } // TestHeartbeatInterval_SelectsByWindow 覆盖 design/12 §N A6 的 ①~④: // 窗口内用 quiet 间隔、窗口外用普通间隔、`≤0` 回落普通、窗口关闭不降频。 func TestHeartbeatInterval_SelectsByWindow(t *testing.T) { t.Parallel() const normal = 15 * time.Minute const quiet = time.Hour cases := []struct { name string cfg Config at time.Time want time.Duration }{ { name: "①窗口内降频(跨午夜 23:00-07:00 的 23:30)", cfg: Config{Interval: normal, QuietInterval: quiet}, at: atHour(23, 30), want: quiet, }, { name: "②窗口外回落普通间隔(14:00)", cfg: Config{Interval: normal, QuietInterval: quiet}, at: atHour(14, 0), want: normal, }, { name: "窗口起点(23:00)算在窗口内", cfg: Config{Interval: normal, QuietInterval: quiet}, at: atHour(23, 0), want: quiet, }, { name: "窗口终点(07:00)算在窗口外", cfg: Config{Interval: normal, QuietInterval: quiet}, at: atHour(7, 0), want: normal, }, { name: "白天窗口(01:00-05:00)内的 02:00 降频", cfg: Config{Interval: normal, QuietInterval: quiet, QuietStartHour: 1, QuietEndHour: 5}, at: atHour(2, 0), want: quiet, }, { name: "③QuietInterval=0 ⇒ 关闭降频(窗口内也回落)", cfg: Config{Interval: normal, QuietInterval: 0}, at: atHour(23, 30), want: normal, }, { name: "③QuietInterval 为负 ⇒ 同样关闭降频", cfg: Config{Interval: normal, QuietInterval: -time.Minute}, at: atHour(23, 30), want: normal, }, { name: "④窗口关闭(23/23)不降频", cfg: Config{Interval: normal, QuietInterval: quiet, QuietStartHour: 23, QuietEndHour: 23}, at: atHour(23, 30), want: normal, }, { name: "④钟点非法(25/07)不降频(否则可能全时段降频)", cfg: Config{Interval: normal, QuietInterval: quiet, QuietStartHour: 25, QuietEndHour: 7}, at: atHour(0, 30), want: normal, }, { name: "0/0 视为未填 ⇒ 回落默认窗口 23:00-07:00(窗口内降频)", cfg: Config{Interval: normal, QuietInterval: quiet}, at: atHour(23, 30), want: quiet, }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := intervalFor(tc.cfg, tc.at); got != tc.want { t.Fatalf("heartbeatInterval(%s) = %v, want %v", tc.at.Format("15:04"), got, tc.want) } }) } } // TestHeartbeatInterval_SharesWindowSemanticsWithInQuietHours 钉住降频用的是**同一套** // 安静窗口口径(design/12 §N A3:与既有三处逐字一致):静默间隔开启且窗口合法时, // quiet 档位**当且仅当** inQuietHours。另写一份判定(如写成 `h > start && h <= end`) // 会在跨午夜窗口的边界小时上立刻暴露。 func TestHeartbeatInterval_SharesWindowSemanticsWithInQuietHours(t *testing.T) { t.Parallel() const normal = 900 * time.Second const quiet = 3600 * time.Second windows := []struct{ start, end int }{{23, 7}, {1, 5}, {0, 7}, {22, 3}} for _, w := range windows { b := &Backthink{cfg: normalize(Config{ Interval: normal, QuietInterval: quiet, QuietStartHour: w.start, QuietEndHour: w.end, })} for hour := 0; hour < 24; hour++ { at := atHour(hour, 30) inWindow := b.inQuietHours(at) want := normal if inWindow { want = quiet } if got := b.heartbeatInterval(at); got != want { t.Fatalf("窗口 %02d:00-%02d:00 的 %02d:30:inQuietHours=%v 但间隔=%v(want %v)", w.start, w.end, hour, inWindow, got, want) } } } } // TestBackthink_Heartbeat_RecomputesIntervalAcrossWindowBoundary 钉住「每拍重算」 // (design/12 §N A3):注入时钟从窗口内走到窗口外,心跳必须**加速**到普通间隔。 // 若间隔只在启动时算一次,第一拍之后仍是 quiet 间隔 ⇒ 拍数远达不到阈值 ⇒ 红。 func TestBackthink_Heartbeat_RecomputesIntervalAcrossWindowBoundary(t *testing.T) { t.Parallel() const normal = 50 * time.Millisecond const quiet = 500 * time.Millisecond clock := &fakeClock{at: atHour(23, 30)} // 起点在窗口内 thinker := &mockThinker{text: "nothing to report"} b, _ := newBackthink(t, Config{Interval: normal, QuietInterval: quiet}, Deps{ Thinker: thinker, Now: clock.now, }) b.Start(context.Background()) defer func() { _ = b.Stop(context.Background()) }() eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 }) // 启动轮 clock.set(atHour(12, 0)) // 走出窗口 // 阈值取 8 拍:只在启动时算一次(quiet=500ms)的话,3 秒内最多 6 拍; // 每拍重算则 500ms 首拍 + 之后每 50ms 一拍 ⇒ 约 900ms 即达标。 eventually(t, 3*time.Second, func() bool { return thinker.count()-1 >= 8 }) } // TestBackthink_Heartbeat_SlowsWhenEnteringQuietWindow 是上面那条的反向:时钟从窗口外 // 走进窗口内,心跳应放慢到 quiet 间隔(20ms ⇒ 600ms)。 func TestBackthink_Heartbeat_SlowsWhenEnteringQuietWindow(t *testing.T) { t.Parallel() const normal = 20 * time.Millisecond const quiet = 600 * time.Millisecond clock := &fakeClock{at: atHour(12, 0)} // 起点在窗口外 thinker := &mockThinker{text: "nothing to report"} b, _ := newBackthink(t, Config{Interval: normal, QuietInterval: quiet}, Deps{ Thinker: thinker, Now: clock.now, }) b.Start(context.Background()) defer func() { _ = b.Stop(context.Background()) }() eventually(t, 2*time.Second, func() bool { return thinker.count() >= 3 }) // 快节奏已建立 clock.set(atHour(23, 30)) // 走进窗口 base := thinker.count() time.Sleep(300 * time.Millisecond) // 已武装的那一拍最多再多跑 1-2 次,之后应被 quiet 间隔挡住;若降频没生效, // 20ms 的节奏会在 300ms 里再跑约 15 拍。 if got := thinker.count() - base; got > 3 { t.Fatalf("进入安静窗口后 300ms 内又跑了 %d 拍,quiet 间隔未生效", got) } } // TestBackthink_EventTrigger_NotSlowedByQuietInterval 是 design/12 §N A2 / A6-⑤: // 安静窗口只降低**周期性心跳**的频率;事件驱动通路照旧**立刻**触发,不必等下一拍。 // 心跳间隔被刻意设成小时级,事件仍须在秒级内跑完一轮思考。 func TestBackthink_EventTrigger_NotSlowedByQuietInterval(t *testing.T) { t.Parallel() thinker := &mockThinker{text: "washing finished, clothes ready"} bs := bus.New() b, _ := newBackthink(t, Config{Interval: time.Hour, QuietInterval: 4 * time.Hour}, Deps{ Bus: bs, Thinker: thinker, Now: func() time.Time { return nightAt() }, // 当前在安静窗口内 }) b.Start(context.Background()) defer func() { _ = b.Stop(context.Background()) }() eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 }) // 启动轮 base := thinker.count() if err := bs.Publish(context.Background(), SubjectReminderTrigger, "reminder due"); err != nil { t.Fatalf("Publish: %v", err) } eventually(t, time.Second, func() bool { return thinker.count() > base }) } // TestBackthink_StartupLog_ReportsQuietInterval 是 design/12 §N A6-⑥:启动日志必须 // **同时**打印普通间隔与安静间隔(并标明降频是否启用),否则主人无法从日志确认 // 降频到底有没有生效。 func TestBackthink_StartupLog_ReportsQuietInterval(t *testing.T) { t.Parallel() var buf bytes.Buffer thinker := &mockThinker{text: "nothing to report"} b, _ := newBackthink(t, Config{Interval: 15 * time.Minute, QuietInterval: time.Hour}, Deps{ Thinker: thinker, Log: slog.New(slog.NewTextHandler(&buf, nil)), }) b.Start(context.Background()) defer func() { _ = b.Stop(context.Background()) }() out := buf.String() for _, want := range []string{"interval=15m0s", "quiet_interval=1h0m0s", "quiet_interval_enabled=true"} { if !strings.Contains(out, want) { t.Fatalf("启动日志缺少 %q:\n%s", want, out) } } } // TestConfigFromBackground_QuietInterval 钉住 env → backthink 的映射与 ≤0 语义 // (design/12 §N A1/A3):3600 秒 ⇒ 1 小时的降频间隔;`≤0` 必须原样保留成 // 「关闭降频」——normalize **不得**把它改写成默认值,否则主人设的 0 会静默失效 // (那正是 §N A3 要禁的暗坑)。 func TestConfigFromBackground_QuietInterval(t *testing.T) { t.Parallel() on := &Backthink{cfg: normalize(ConfigFromBackground(config.BackgroundConfig{QuietIntervalSec: 3600}))} if on.cfg.QuietInterval != time.Hour { t.Fatalf("QuietIntervalSec=3600 ⇒ QuietInterval=%v, want 1h", on.cfg.QuietInterval) } if !on.quietIntervalEnabled() { t.Fatal("QuietIntervalSec=3600 必须视为降频已启用") } for _, sec := range []int{0, -1} { off := &Backthink{cfg: normalize(ConfigFromBackground(config.BackgroundConfig{QuietIntervalSec: sec}))} if off.quietIntervalEnabled() { t.Fatalf("QuietIntervalSec=%d 必须被视为「关闭降频」", sec) } if got := off.heartbeatInterval(atHour(23, 30)); got != off.cfg.Interval { t.Fatalf("QuietIntervalSec=%d 时窗口内心跳应为普通间隔 %v,得到 %v", sec, off.cfg.Interval, got) } } }