Files
Maven/internal/loop/feedback_test.go
T
2026-07-03 00:32:48 +02:00

152 lines
5.0 KiB
Go

package loop
import (
"testing"
"time"
"github.com/kami/maven/internal/store"
)
// helper: rule with a known envelope so tested values stay stable even if
// the canonical WaterRule's numbers move.
func tuneRule(name string, base, min, max time.Duration) Rule {
return Rule{
Name: name,
Severity: Sev1,
Cooldown: Cooldown{Base: base, Min: min, Max: max},
}
}
func TestTuneCooldownEmptyReturnsBase(t *testing.T) {
r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour)
if got := TuneCooldown(r, nil); got != r.Cooldown.Base {
t.Fatalf("empty outcomes: want base %v, got %v", r.Cooldown.Base, got)
}
if got := TuneCooldown(r, []string{}); got != r.Cooldown.Base {
t.Fatalf("no outcomes: want base %v, got %v", r.Cooldown.Base, got)
}
}
func TestTuneCooldownThreeIgnoredOneActedLengthens(t *testing.T) {
r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour)
os := []string{store.NudgeIgnored, store.NudgeIgnored, store.NudgeIgnored, store.NudgeActed}
got := TuneCooldown(r, os)
// factor = 1 + 0.5*(.75 - .25) = 1.25 → 37.5m
want := time.Duration(float64(r.Cooldown.Base) * 1.25)
if got != want {
t.Fatalf("3 ignored / 1 acted: want %v, got %v", want, got)
}
}
func TestTuneCooldownMostlyIgnoredLengthens(t *testing.T) {
r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour)
os := repeat(store.NudgeIgnored, 7)
os = append(os, store.NudgeActed)
got := TuneCooldown(r, os)
// factor = 1 + 0.5*(7/8 - 1/8) = 1 + 0.5*0.75 = 1.375 → 41.25m
want := time.Duration(float64(r.Cooldown.Base) * 1.375)
if got != want {
t.Fatalf("mostly ignored: want %v, got %v", want, got)
}
}
func TestTuneCooldownMostlyActedShortens(t *testing.T) {
r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour)
os := repeat(store.NudgeActed, 7)
os = append(os, store.NudgeIgnored)
got := TuneCooldown(r, os)
// factor = 1 + 0.5*(1/8 - 7/8) = 1 - 0.5*0.75 = 0.625 → 18.75m
want := time.Duration(float64(r.Cooldown.Base) * 0.625)
if got != want {
t.Fatalf("mostly acted: want %v, got %v", want, got)
}
}
func TestTuneCooldownClampsAtMax(t *testing.T) {
r := tuneRule("water", 30*time.Minute, 15*time.Minute, 35*time.Minute)
os := repeat(store.NudgeIgnored, 8) // all ignored → factor 1.5 → 45m, clamped to Max 35m
if got := TuneCooldown(r, os); got != 35*time.Minute {
t.Fatalf("all ignored should clamp to Max: want 35m, got %v", got)
}
}
func TestTuneCooldownClampsAtMin(t *testing.T) {
r := tuneRule("water", 30*time.Minute, 20*time.Minute, 6*time.Hour)
os := repeat(store.NudgeActed, 8) // all acted → factor 0.5 → 15m, clamped to Min 20m
if got := TuneCooldown(r, os); got != 20*time.Minute {
t.Fatalf("all acted should clamp to Min: want 20m, got %v", got)
}
}
func TestTuneCooldownSnoozedIsNeutral(t *testing.T) {
// all snoozed → both rates 0 → factor 1 → base unchanged.
r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour)
os := repeat(store.NudgeSnoozed, 8)
if got := TuneCooldown(r, os); got != r.Cooldown.Base {
t.Fatalf("all snoozed should be neutral: want base %v, got %v", r.Cooldown.Base, got)
}
}
func TestFeedbackKeyMatchesRuleName(t *testing.T) {
r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour)
if got := FeedbackKey(r); got != "cooldown:water" {
t.Fatalf("FeedbackKey: want cooldown:water, got %q", got)
}
}
func TestParseCooldownFactRoundTrip(t *testing.T) {
d := 45 * time.Minute
f := store.Fact{
Key: "cooldown:water",
Source: FeedbackSource,
Value: MarshalCooldown(d),
Ts: refTime(),
}
got, ok := ParseCooldownFact(f)
if !ok {
t.Fatalf("ParseCooldownFact: want ok, got false (value %q)", f.Value)
}
if got != d {
t.Fatalf("round trip: want %v, got %v", d, got)
}
}
func TestParseCooldownFactRejectsBadRows(t *testing.T) {
now := refTime()
cases := []struct {
name string
f store.Fact
}{
{"missing value", store.Fact{Key: "cooldown:water", Source: FeedbackSource, Ts: now}},
{"wrong source", store.Fact{Key: "cooldown:water", Source: "tap:water", Value: MarshalCooldown(30 * time.Minute), Ts: now}},
{"non-numeric", store.Fact{Key: "cooldown:water", Source: FeedbackSource, Value: `"45m"`, Ts: now}},
{"negative ns", store.Fact{Key: "cooldown:water", Source: FeedbackSource, Value: "-1000", Ts: now}},
{"zero ts", store.Fact{Key: "cooldown:water", Source: FeedbackSource, Value: MarshalCooldown(30 * time.Minute)}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, ok := ParseCooldownFact(c.f); ok {
t.Fatalf("ParseCooldownFact %q: want not ok", c.name)
}
})
}
}
func TestCooldownForTakesBaseDuration(t *testing.T) {
last := refTime()
got := CooldownFor(45*time.Minute, last)
if got != last.Add(45*time.Minute) {
t.Fatalf("CooldownFor(base, last): want %v, got %v", last.Add(45*time.Minute), got)
}
if got := CooldownFor(45*time.Minute, time.Time{}); !got.IsZero() {
t.Fatalf("CooldownFor zero lastSend: want zero, got %v", got)
}
}
func repeat(v string, n int) []string {
out := make([]string, n)
for i := range out {
out[i] = v
}
return out
}