Files
Maven/cmd/mavend/presence_persist_test.go
T
claude b54ccccd0a presence: persist the resolved bucket, so hysteresis has a yesterday (V-532)
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
2026-08-05 01:31:23 +04:00

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)
}
}