152 lines
5.0 KiB
Go
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
|
|
} |