package memory import ( "context" "testing" "time" ) // 本文件锁定**分时段的动态频率**(主人 2026-10-07:「频率必须调 …… 或者动态分配, // 比如白天间隔短一点,晚上到了静默时期稍微长一点」): // // ① 非安静时段用 intervalSec、安静时段用 quietIntervalSec(intervalAt); // ② 缺省(quietIntervalSec 未配)时两者相等 —— 也就是**行为与改动前一致**; // ③ 间隔在**每一轮跑完之后按当时时段重算**(不是在启动时算一次定死): // 否则白天/晚上的切换不会生效; // ④ 到点判据(dueNow)用**当时时段**的周期,与 nextDelay 同源,不会各说各话; // ⑤ 安静时段不再是「唯一可跑时段」:窗口外也按 intervalSec 跑(①的直接推论, // 由 ③ 的用例端到端证明)。 // quietWindowCfg 造一份分档周期配置:安静 22:00-02:00(跨午夜), // 白天 2h 一次、夜里 6h 一次。 func quietWindowCfg(t *testing.T) TidyConfig { t.Helper() cfg := tidyTestCfg(0.80) cfg.IntervalSec = 2 * 3600 cfg.QuietIntervalSec = 6 * 3600 cfg.QuietStartHour, cfg.QuietEndHour = 22, 2 return cfg } // TestTidyConfigIntervalAt_SwitchesByPeriod 钉住 intervalAt 的取档口径与 // inQuietHours 逐字同源(含跨午夜、边界、窗口关闭)。 func TestTidyConfigIntervalAt_SwitchesByPeriod(t *testing.T) { t.Parallel() cfg := normalizeTidyConfig(quietWindowCfg(t)) at := func(hour, min int) time.Time { return time.Date(2026, 10, 7, hour, min, 0, 0, time.Local) } cases := []struct { name string at time.Time want time.Duration }{ {"白天(14:00)用 intervalSec", at(14, 0), 2 * time.Hour}, {"窗口前一分钟(21:59)仍用 intervalSec", at(21, 59), 2 * time.Hour}, {"窗口起点(22:00)用 quietIntervalSec", at(22, 0), 6 * time.Hour}, {"跨午夜的半夜(00:30)用 quietIntervalSec", at(0, 30), 6 * time.Hour}, {"窗口终点前(01:59)用 quietIntervalSec", at(1, 59), 6 * time.Hour}, {"窗口终点(02:00)回到 intervalSec", at(2, 0), 2 * time.Hour}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { got := cfg.intervalAt(tc.at) if got != tc.want { t.Fatalf("intervalAt(%s) = %s, want %s", tc.at.Format("15:04"), got, tc.want) } // 与既有安静判定**同源**:落在哪一档当且仅当 inQuietHours。 want := cfg.Interval if cfg.inQuietHours(tc.at) { want = cfg.QuietInterval } if got != want { t.Fatalf("intervalAt 与 inQuietHours 不同源:%s interval=%s, 期望 %s", tc.at.Format("15:04"), got, want) } }) } // 缺省(quietIntervalSec 未配)⇒ 两档相等 = 行为与本次改动之前逐字一致。 plain := normalizeTidyConfig(tidyTestCfg(0.80)) if plain.QuietIntervalSec != plain.IntervalSec || plain.QuietInterval != plain.Interval { t.Fatalf("缺省时两档必须相等:interval=%s quiet=%s", plain.Interval, plain.QuietInterval) } if got := plain.intervalAt(at(23, 30)); got != plain.Interval { t.Fatalf("缺省时分档不得改变频率:得到 %s, want %s", got, plain.Interval) } // 窗口被显式关闭(两端相等且非 0)⇒ 不换档:关闭窗口的意思是「没有低峰这回事」, // 而不是「全天都算低峰」。 closed := normalizeTidyConfig(quietWindowCfg(t)) closed.QuietStartHour, closed.QuietEndHour = 5, 5 for _, h := range []int{0, 5, 12, 23} { if got := closed.intervalAt(at(h, 0)); got != closed.Interval { t.Fatalf("窗口关闭时 %02d:00 的间隔 = %s, want %s", h, got, closed.Interval) } } } // TestTidyServiceDueNowUsesPeriodInterval 钉住「到点」判据用的是**当时时段**的周期。 // // 用例刻意让「按白天档会判到点、按夜里档不会」:安静 23:00-07:00,白天 1h、 // 夜里 8h。上次跑在 23:05,此刻 01:00 ⇒ 距上次 1h55m:若拿白天的 1h 去比会判 // 「到点」(那是错的),拿夜里的 8h 才判「不到点」。 func TestTidyServiceDueNowUsesPeriodInterval(t *testing.T) { t.Parallel() store := newTestStore(t) cfg := tidyTestCfg(0.80) cfg.IntervalSec = 3600 cfg.QuietIntervalSec = 8 * 3600 cfg.QuietStartHour, cfg.QuietEndHour = 23, 7 svc := newTidyServiceForTest(t, store, cfg, nil, nil, nil) set := func(now, last time.Time) { svc.now = func() time.Time { return now } svc.lastRun = last } last := time.Date(2026, 10, 7, 23, 5, 0, 0, time.Local) set(time.Date(2026, 10, 8, 1, 0, 0, 0, time.Local), last) if due, reason := svc.dueNow(); due { t.Fatalf("夜里距上次 1h55m 不该到点(夜里的档是 8h),得到 due=true(%s)", reason) } set(time.Date(2026, 10, 9, 1, 0, 0, 0, time.Local), last) if due, _ := svc.dueNow(); !due { t.Fatal("夜里距上次已 25h55m 必须到点(≥ 8h)") } // 白天档:上次跑在 09:00,此刻 10:30 ⇒ 1.5h ≥ 1h ⇒ 到点。 set(time.Date(2026, 10, 7, 10, 30, 0, 0, time.Local), time.Date(2026, 10, 7, 9, 0, 0, 0, time.Local)) if due, _ := svc.dueNow(); !due { t.Fatal("白天距上次 1.5h 必须到点(白天的档是 1h)") } } // TestTidyServiceRecomputesIntervalAfterEachRound 是**动态频率**的端到端证明: // 安静 22:00-02:00(跨午夜),白天 2h、夜里 6h。20:00 从零启动(无历史)⇒ // 首次落在窗口起点 22:00;此后: // // 22:00 跑完 ⇒ 按**当时**(窗口内)的档 = 6h ⇒ 下一拍 04:00(已在窗口外) // 04:00 跑完 ⇒ 按白天的档 = 2h ⇒ 06:00 ⇒ 08:00 // // 断言的是**留痕的时刻序列**与其间隔:22:00 → 04:00 是 6h(夜里的档), // 04:00 → 06:00 → 08:00 都是 2h(白天的档)。若把间隔在启动时算一次定死 // (或全程按同一个周期跑),这段序列不可能是这个形状 —— 变异用例钉住了它。 func TestTidyServiceRecomputesIntervalAfterEachRound(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9)) clock := &schedClock{at: time.Date(2026, 10, 7, 20, 0, 0, 0, time.Local)} probe := newTidyWaitProbe(clock, 4) // 4 拍之后退出(心跳 #5 返回 false) svc := newTidyServiceForTest(t, store, quietWindowCfg(t), nil, clock.now, nil) svc.wait = probe.wait svc.Start(ctx) probe.awaitCalls(t, 5) if err := svc.Stop(ctx); err != nil { t.Fatalf("Stop: %v", err) } first := time.Date(2026, 10, 7, 22, 0, 0, 0, time.Local) want := []time.Time{ first.Add(10 * time.Hour), // 08:00(次日) first.Add(8 * time.Hour), // 06:00 first.Add(6 * time.Hour), // 04:00(窗口外) first, // 22:00(窗口起点) } gotRuns := tidyRunTimes(t, store) if len(gotRuns) != len(want) { t.Fatalf("运行留痕 = %v, want %d 轮", gotRuns, len(want)) } for i, w := range want { if gotRuns[i] != w.UTC().Format(time.RFC3339) { t.Fatalf("第 %d 轮留痕 = %s, want %s(全部:%v)", i+1, gotRuns[i], w.UTC().Format(time.RFC3339), gotRuns) } } // 间隔必须**分档**:第一段(夜里)6h,其后(白天)2h。这一条同时证明窗口外也跑。 // want 是「最新在前」,因此这里按时间顺序取差。 gaps := []time.Duration{ want[2].Sub(want[3]), // 22:00 → 04:00:跨过窗口终点,用的是夜里的档 want[1].Sub(want[2]), // 04:00 → 06:00:窗口外,用白天的档 want[0].Sub(want[1]), // 06:00 → 08:00 } if gaps[0] != 6*time.Hour { t.Fatalf("夜里那一轮之后的间隔 = %s, want 6h(quietIntervalSec)", gaps[0]) } if gaps[1] != 2*time.Hour || gaps[2] != 2*time.Hour { t.Fatalf("白天间隔 = %s / %s, want 2h(intervalSec)", gaps[1], gaps[2]) } }