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 }