2815adee03
Item carried only Key, FactKey and Label, so every checklist entry was implicitly required and behaviour 1 of #280 could not hold at all. It was not thin config — there was no field to set. Item.Optional, `"optional": true` in the routine config, default false, so a routine written before today behaves exactly as it did. Due now fires on a missing required item and not on an optional one, and the optional stragglers still travel in Missing so the one message per day per routine can name them after the required ones, in softer words. Evidence, the window and the day plan treat both kinds alike. A missing optional item is still missing — it just does not earn a nudge, because a checklist where everything is mandatory is one he learns to ignore.
222 lines
6.6 KiB
Go
222 lines
6.6 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")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|