5447f08c06
Both Due functions key their last-fired map by routine name, so two routines sharing a name took turns suppressing each other and one of them never fired. Validate now rejects a duplicate name in either package. parseHHMM checked the digits arithmetically, which let a stray character cancel out: window_start of 2 :00 loaded as 04:00 and passed the validation that exists to catch that typo. Each of the four positions is now checked as a digit, which makes the negative bounds unreachable and they are gone. Folded the three copies of the unevidenced-item loop in Evaluate, Outstanding and Due into one helper.
235 lines
7.0 KiB
Go
235 lines
7.0 KiB
Go
package morning
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
func mkRoutine() Routine {
|
|
return Routine{
|
|
Name: "weekday_morning",
|
|
Weekdays: []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday},
|
|
WindowStart: "08:00",
|
|
WindowEnd: "11:00",
|
|
Items: []Item{
|
|
{Key: "medicine", FactKey: "medicine", Label: "лекарство"},
|
|
{Key: "water", FactKey: "water", Label: "вода"},
|
|
{Key: "pets", FactKey: "pets", Label: "кот"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func at(h, m int) time.Time {
|
|
return time.Date(2026, 7, 20, h, m, 0, 0, time.UTC) // 2026-07-20 is a Monday
|
|
}
|
|
|
|
func fact(ts time.Time) store.Fact { return store.Fact{Ts: ts} }
|
|
|
|
func TestValidate(t *testing.T) {
|
|
r := mkRoutine()
|
|
if err := Validate([]Routine{r}); err != nil {
|
|
t.Fatalf("valid routine rejected: %v", err)
|
|
}
|
|
|
|
bad := r
|
|
bad.Items = nil
|
|
if err := Validate([]Routine{bad}); err == nil {
|
|
t.Fatal("expected error for no items")
|
|
}
|
|
|
|
bad = r
|
|
bad.WindowStart = "25:00"
|
|
if err := Validate([]Routine{bad}); err == nil {
|
|
t.Fatal("expected error for bad window_start")
|
|
}
|
|
|
|
bad = r
|
|
bad.WindowStart, bad.WindowEnd = "11:00", "08:00"
|
|
if err := Validate([]Routine{bad}); err == nil {
|
|
t.Fatal("expected error for inverted window")
|
|
}
|
|
|
|
bad = r
|
|
bad.Items = append(bad.Items, Item{Key: "medicine", FactKey: "x"})
|
|
if err := Validate([]Routine{bad}); err == nil {
|
|
t.Fatal("expected error for duplicate item key")
|
|
}
|
|
|
|
// Due keys its once-a-day map by name, so two routines sharing one would
|
|
// suppress each other's nudge instead of both firing.
|
|
if err := Validate([]Routine{r, r}); err == nil {
|
|
t.Fatal("expected error for duplicate routine name")
|
|
}
|
|
|
|
// Arithmetic alone let a stray character cancel out: "2 :00" read as 04:00.
|
|
bad = r
|
|
bad.WindowStart = "2 :00"
|
|
if err := Validate([]Routine{bad}); err == nil {
|
|
t.Fatal("expected error for a non-digit in window_start")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateInactiveOutsideWindow(t *testing.T) {
|
|
r := mkRoutine()
|
|
st := Evaluate(r, nil, at(7, 59))
|
|
if st.Active {
|
|
t.Fatal("expected inactive before window opens")
|
|
}
|
|
st = Evaluate(r, nil, at(11, 0))
|
|
if st.Active {
|
|
t.Fatal("expected inactive at/after window closes")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateInactiveOnWrongWeekday(t *testing.T) {
|
|
r := mkRoutine() // weekdays only
|
|
saturday := time.Date(2026, 7, 25, 9, 0, 0, 0, time.UTC)
|
|
if Evaluate(r, nil, saturday).Active {
|
|
t.Fatal("expected inactive on a weekend day not in Weekdays")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateMissingAndCompleted(t *testing.T) {
|
|
r := mkRoutine()
|
|
facts := map[string]store.Fact{
|
|
"medicine": fact(at(8, 30)),
|
|
}
|
|
st := Evaluate(r, facts, at(9, 0))
|
|
if !st.Active {
|
|
t.Fatal("expected active within window")
|
|
}
|
|
if len(st.Completed) != 1 || st.Completed[0].Key != "medicine" {
|
|
t.Fatalf("expected medicine completed, got %+v", st.Completed)
|
|
}
|
|
if len(st.Missing) != 2 {
|
|
t.Fatalf("expected 2 missing, got %+v", st.Missing)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateEvidenceBeforeWindowDoesNotCount(t *testing.T) {
|
|
r := mkRoutine()
|
|
facts := map[string]store.Fact{
|
|
"medicine": fact(at(7, 0)), // before window opened today
|
|
}
|
|
st := Evaluate(r, facts, at(9, 0))
|
|
for _, it := range st.Completed {
|
|
if it.Key == "medicine" {
|
|
t.Fatal("stale (pre-window) evidence should not count as completion")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDueFiresOnlyAtNudgeTimeWithMissingItems(t *testing.T) {
|
|
r := mkRoutine() // NudgeAt empty -> defaults to WindowEnd (11:00)
|
|
facts := map[string]store.Fact{
|
|
"medicine": fact(at(8, 30)),
|
|
"water": fact(at(8, 40)),
|
|
// pets missing
|
|
}
|
|
last := map[string]time.Time{}
|
|
|
|
if out := Due([]Routine{r}, facts, last, at(9, 0)); len(out) != 0 {
|
|
t.Fatalf("expected no candidate before nudge time, got %+v", out)
|
|
}
|
|
|
|
out := Due([]Routine{r}, facts, last, at(11, 0))
|
|
if len(out) != 1 {
|
|
t.Fatalf("expected 1 candidate at nudge time, got %d", len(out))
|
|
}
|
|
if len(out[0].Missing) != 1 || out[0].Missing[0].Key != "pets" {
|
|
t.Fatalf("expected only pets missing, got %+v", out[0].Missing)
|
|
}
|
|
}
|
|
|
|
func TestDueDoesNotRepeatSameDay(t *testing.T) {
|
|
r := mkRoutine()
|
|
facts := map[string]store.Fact{} // nothing done
|
|
last := map[string]time.Time{}
|
|
|
|
if out := Due([]Routine{r}, facts, last, at(11, 0)); len(out) != 1 {
|
|
t.Fatalf("expected first nudge to fire, got %d", len(out))
|
|
}
|
|
if out := Due([]Routine{r}, facts, last, at(11, 30)); len(out) != 0 {
|
|
t.Fatalf("expected no repeat nudge same day, got %d", len(out))
|
|
}
|
|
}
|
|
|
|
func TestDueFiresAgainNextDay(t *testing.T) {
|
|
r := mkRoutine()
|
|
facts := map[string]store.Fact{}
|
|
last := map[string]time.Time{}
|
|
|
|
Due([]Routine{r}, facts, last, at(11, 0))
|
|
|
|
tomorrow := time.Date(2026, 7, 21, 11, 0, 0, 0, time.UTC) // Tuesday
|
|
if out := Due([]Routine{r}, facts, last, tomorrow); len(out) != 1 {
|
|
t.Fatalf("expected nudge to fire again on a new day, got %d", len(out))
|
|
}
|
|
}
|
|
|
|
func TestDueSkipsWhenAllItemsComplete(t *testing.T) {
|
|
r := mkRoutine()
|
|
facts := map[string]store.Fact{
|
|
"medicine": fact(at(8, 30)),
|
|
"water": fact(at(8, 40)),
|
|
"pets": fact(at(8, 50)),
|
|
}
|
|
last := map[string]time.Time{}
|
|
if out := Due([]Routine{r}, facts, last, at(11, 0)); len(out) != 0 {
|
|
t.Fatalf("expected no nudge when all items complete, got %+v", out)
|
|
}
|
|
}
|
|
|
|
func TestDueRespectsExplicitNudgeAt(t *testing.T) {
|
|
r := mkRoutine()
|
|
r.NudgeAt = "10:00"
|
|
facts := map[string]store.Fact{}
|
|
last := map[string]time.Time{}
|
|
|
|
if out := Due([]Routine{r}, facts, last, at(9, 30)); len(out) != 0 {
|
|
t.Fatalf("expected no candidate before explicit nudge_at, got %+v", out)
|
|
}
|
|
if out := Due([]Routine{r}, facts, last, at(10, 0)); len(out) != 1 {
|
|
t.Fatalf("expected candidate at explicit nudge_at, got %d", len(out))
|
|
}
|
|
}
|
|
|
|
// TestOptionalItemsDoNotEarnANudge — behaviour 1 of #280, which could not hold
|
|
// while every item was implicitly required (Vikunja #473).
|
|
func TestOptionalItemsDoNotEarnANudge(t *testing.T) {
|
|
r := Routine{
|
|
Name: "утро",
|
|
WindowStart: "07:00",
|
|
WindowEnd: "10:00",
|
|
Items: []Item{
|
|
{Key: "meds", FactKey: "meds", Label: "таблетки"},
|
|
{Key: "stretch", FactKey: "stretch", Label: "растяжка", Optional: true},
|
|
},
|
|
}
|
|
now := time.Date(2026, 8, 4, 10, 0, 0, 0, time.UTC)
|
|
took := map[string]store.Fact{"meds": {Key: "meds", Ts: now.Add(-2 * time.Hour)}}
|
|
|
|
// Only the stretch was skipped: nothing to say.
|
|
if due := Due([]Routine{r}, took, map[string]time.Time{}, now); len(due) != 0 {
|
|
t.Fatalf("an optional item alone must not nudge, got %+v", due)
|
|
}
|
|
// The medication was skipped: she says so, and mentions the stretch too.
|
|
due := Due([]Routine{r}, map[string]store.Fact{}, map[string]time.Time{}, now)
|
|
if len(due) != 1 {
|
|
t.Fatalf("a missing required item must nudge, got %+v", due)
|
|
}
|
|
if got := Required(due[0].Missing); len(got) != 1 || got[0].Key != "meds" {
|
|
t.Fatalf("Required = %+v, want the meds item alone", got)
|
|
}
|
|
if got := OptionalOnly(due[0].Missing); len(got) != 1 || got[0].Key != "stretch" {
|
|
t.Fatalf("OptionalOnly = %+v, want the stretch item alone", got)
|
|
}
|
|
// The window still reports it as missing — optional is not invisible.
|
|
st := Evaluate(r, map[string]store.Fact{}, now.Add(-time.Hour))
|
|
if len(st.Missing) != 2 {
|
|
t.Fatalf("Evaluate must still list both, got %+v", st.Missing)
|
|
}
|
|
}
|