b54ccccd0a
SavePresenceState had no caller outside tests. GatherState computed the score, resolved the bucket against the last one and threw the result away, so the singleton row was never written at all. Two things were broken by the one missing write. Hysteresis was dead. lastBucket read the cold-start Away every tick, so store.Resolve only ever took the `last == Away` arm and demanded a full PresenceEnter score to say he is at the desk. The 0.30-0.55 hold band the function exists to provide never applied once — with a 60s desk poster and tau=8min, presence dropped at about four minutes of idle instead of holding to the exit threshold at about nine. And every readout lied. /dash and ipc.Presence read this row, so they showed "away — score 0.00 (never)" while desk_active facts arrived every sixty seconds from workpc. The write goes in the tick, not in GatherState: that method holds a read-only transaction on purpose, one consistent snapshot per tick, and a write inside it would either break that guarantee or quietly upgrade the transaction. A failure logs and the tick continues, because the gate reads the in-memory bucket — which is why nudge routing kept working through all of this, and why the defect lived long enough to be found by looking at a dashboard. The existing hysteresis test scores the pure function and passed throughout, which is why nobody caught it. The new tests assert the round trip instead: the tick writes what gather resolved, a later write overwrites rather than appends, and the persisted bucket is what makes the hold band apply. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
96 lines
3.2 KiB
Go
96 lines
3.2 KiB
Go
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)
|
|
}
|
|
}
|