主人裁定「先做 A(静默降频)再转向 C」;背景实测:静默期**思考频率不变**(恒定 900s 心跳),只拦打扰 ⇒ 成本照付。 - 新键 THINK_QUIET_INTERVAL_SEC(默认 3600):窗口内用该间隔、窗外仍用 THINK_INTERVAL_SEC;**≤0 = 关闭降频**(不许出现「半夜完全不思考」暗坑);窗口关闭/钟点非法 ⇒ 不降频 - 心跳**每拍按时刻选间隔**(放到循环外就永远不生效——变异实测能打红) - 🔴 不变量:**事件驱动不被降频影响**(TestBackthink_EventTrigger_NotSlowedByQuietInterval 钉住) - 事实卡 9→12 条(+思考总开关/普通间隔/静默间隔,标「改需重启」);契约形状未变、未升版 - ⚠️ 如实登记:ENABLE_BACKGROUND_THINKING 是**死配置**(全仓无消费者),已在 env/面板标签/契约三处标注,**是否接线待主人拍** - 变异 5 条全红;C 的缺口清单(唤醒面 7 处)已回给主会话排期
288 lines
11 KiB
Go
288 lines
11 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|