package main import ( "context" "testing" "time" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/store" ) // The row is the whole mechanism, and nothing wrote it (Vikunja #532). // // The existing hysteresis test in internal/store scores the pure function and // passed throughout, which is exactly why this went unnoticed: Resolve was // always correct and was always handed the cold-start Away. So this test asserts // the round trip — the tick writes what gather resolved, and the next load // reads it back — rather than re-testing the function. func TestTickPersistsTheResolvedBucket(t *testing.T) { ctx := context.Background() st := newTestStore(t) now := time.Now() // Cold start: no row, so a load must say Away and the zero time. b, score, updated, err := st.LoadPresenceState(ctx) if err != nil { t.Fatalf("load before: %v", err) } if b != store.Away || score != 0 || !updated.IsZero() { t.Fatalf("cold start = %s/%v/%v, want away/0/zero", b, score, updated) } tl := &tickLoop{store: st} tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, now) b, score, updated, err = st.LoadPresenceState(ctx) if err != nil { t.Fatalf("load after: %v", err) } if b != store.Present { t.Errorf("bucket = %s, want present", b) } if score != 0.9 { t.Errorf("score = %v, want 0.9", score) } if updated.IsZero() { t.Error("updated_ts was not written, so /dash still reads (never)") } } // The singleton stays a singleton, and a later tick overwrites rather than // accumulating. A row per tick would make LoadPresenceState's single-row query // return whichever one SQLite felt like. func TestPresenceStateIsOverwrittenNotAppended(t *testing.T) { ctx := context.Background() st := newTestStore(t) tl := &tickLoop{store: st} now := time.Now() tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, now) tl.savePresence(ctx, loop.State{Presence: store.Away, PresenceScore: 0.1}, now.Add(time.Minute)) b, score, _, err := st.LoadPresenceState(ctx) if err != nil { t.Fatalf("load: %v", err) } if b != store.Away || score != 0.1 { t.Fatalf("got %s/%v, want the second write (away/0.1)", b, score) } } // What the persisted row buys: the hold band. A score sitting between Exit and // Enter holds Present when the last bucket was Present, and stays Away when it // was Away. Before the write existed the second arm was the only one that could // ever run, so presence dropped at roughly four minutes of idle instead of // holding to the exit threshold at about nine. func TestPersistedBucketIsWhatFeedsHysteresis(t *testing.T) { ctx := context.Background() st := newTestStore(t) tl := &tickLoop{store: st} mid := (store.PresenceExit + store.PresenceEnter) / 2 if mid <= store.PresenceExit || mid >= store.PresenceEnter { t.Fatalf("%v is not inside the hold band", mid) } tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, time.Now()) last, _, _, err := st.LoadPresenceState(ctx) if err != nil { t.Fatalf("load: %v", err) } if got := store.Resolve(mid, last); got != store.Present { t.Errorf("Resolve(%v, %s) = %s, want present — the hold band did not apply", mid, last, got) } }