75b067ac51
Two merge fixes on top of the branch: - migrations: keep both new steps, snooze stays #8, the routine columns become #9. Both agents had numbered theirs #8. - accepting no longer takes a reminder id, on the web surface too. The web accept path had the same one-shot-reminder bug the voice path did, so both now just flip the status and let the tick loop schedule. The test that asserted "accept creates a reminder and links it" asserted the bug. It now asserts that accepting creates no reminder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
269 lines
7.3 KiB
Go
269 lines
7.3 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestCreateAndAcceptProposedRoutine(t *testing.T) {
|
|
s := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
|
|
// Create
|
|
id, err := s.CreateProposedRoutine(ctx, "refill", "cat_water", 7.0, now)
|
|
if err != nil {
|
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
|
}
|
|
if id == 0 {
|
|
t.Fatal("expected non-zero id")
|
|
}
|
|
|
|
// Duplicate should fail
|
|
_, err = s.CreateProposedRoutine(ctx, "refill", "cat_water", 7.0, now)
|
|
if !errors.Is(err, ErrProposedRoutineExists) {
|
|
t.Fatalf("want ErrProposedRoutineExists, got %v", err)
|
|
}
|
|
|
|
// Lookup
|
|
r, err := s.LookupProposedRoutine(ctx, "refill", "cat_water")
|
|
if err != nil {
|
|
t.Fatalf("LookupProposedRoutine: %v", err)
|
|
}
|
|
if r == nil {
|
|
t.Fatal("want non-nil routine")
|
|
}
|
|
if r.Action != "refill" || r.Object != "cat_water" || r.Status != "proposed" {
|
|
t.Fatalf("got %+v", r)
|
|
}
|
|
if r.IntervalDays != 7.0 {
|
|
t.Fatalf("want interval_days=7.0, got %f", r.IntervalDays)
|
|
}
|
|
|
|
// Accept
|
|
if err := s.AcceptProposedRoutine(ctx, id, now); err != nil {
|
|
t.Fatalf("AcceptProposedRoutine: %v", err)
|
|
}
|
|
|
|
// Verify accepted
|
|
r, err = s.LookupProposedRoutine(ctx, "refill", "cat_water")
|
|
if err != nil {
|
|
t.Fatalf("LookupProposedRoutine: %v", err)
|
|
}
|
|
if r.Status != "accepted" {
|
|
t.Fatalf("want status=accepted, got %s", r.Status)
|
|
}
|
|
if r.AcceptedTs == nil || !r.AcceptedTs.Equal(now.Truncate(time.Millisecond)) {
|
|
t.Fatalf("want accepted_ts=%v, got %v", now, r.AcceptedTs)
|
|
}
|
|
if r.LastFiredTs != nil {
|
|
t.Fatalf("a freshly accepted routine has not fired yet, got %v", r.LastFiredTs)
|
|
}
|
|
}
|
|
|
|
// TestAcceptedRoutineFiredTimestamp — the tick loop's two reads: the accepted
|
|
// list, and the last-fired stamp it writes back after a nudge.
|
|
func TestAcceptedRoutineFiredTimestamp(t *testing.T) {
|
|
s := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC().Truncate(time.Millisecond)
|
|
|
|
id, err := s.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, now)
|
|
if err != nil {
|
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
|
}
|
|
if err := s.AcceptProposedRoutine(ctx, id, now); err != nil {
|
|
t.Fatalf("AcceptProposedRoutine: %v", err)
|
|
}
|
|
|
|
list, err := s.ListAcceptedRoutines(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListAcceptedRoutines: %v", err)
|
|
}
|
|
if len(list) != 1 || list[0].ID != id {
|
|
t.Fatalf("want the one accepted routine, got %+v", list)
|
|
}
|
|
if list[0].IntervalDays != 3.0 {
|
|
t.Fatalf("want interval_days=3, got %v", list[0].IntervalDays)
|
|
}
|
|
|
|
fired := now.Add(3 * 24 * time.Hour)
|
|
if err := s.MarkRoutineFired(ctx, id, fired); err != nil {
|
|
t.Fatalf("MarkRoutineFired: %v", err)
|
|
}
|
|
list, err = s.ListAcceptedRoutines(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListAcceptedRoutines: %v", err)
|
|
}
|
|
if list[0].LastFiredTs == nil || !list[0].LastFiredTs.Equal(fired) {
|
|
t.Fatalf("want last_fired_ts=%v, got %v", fired, list[0].LastFiredTs)
|
|
}
|
|
}
|
|
|
|
func TestDismissProposedRoutine(t *testing.T) {
|
|
s := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
|
|
id, err := s.CreateProposedRoutine(ctx, "feed", "cat", 1.0, now)
|
|
if err != nil {
|
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
|
}
|
|
|
|
if err := s.DismissProposedRoutine(ctx, id); err != nil {
|
|
t.Fatalf("DismissProposedRoutine: %v", err)
|
|
}
|
|
|
|
r, err := s.LookupProposedRoutine(ctx, "feed", "cat")
|
|
if err != nil {
|
|
t.Fatalf("LookupProposedRoutine: %v", err)
|
|
}
|
|
if r.Status != "dismissed" {
|
|
t.Fatalf("want status=dismissed, got %s", r.Status)
|
|
}
|
|
}
|
|
|
|
func TestListProposedRoutines(t *testing.T) {
|
|
s := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
|
|
// No routines yet
|
|
list, err := s.ListProposedRoutines(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListProposedRoutines: %v", err)
|
|
}
|
|
if len(list) != 0 {
|
|
t.Fatalf("want 0, got %d", len(list))
|
|
}
|
|
|
|
// Create two
|
|
_, err = s.CreateProposedRoutine(ctx, "refill", "water", 7.0, now)
|
|
if err != nil {
|
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
|
}
|
|
_, err = s.CreateProposedRoutine(ctx, "feed", "cat", 1.0, now.Add(time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
|
}
|
|
|
|
list, err = s.ListProposedRoutines(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListProposedRoutines: %v", err)
|
|
}
|
|
if len(list) != 2 {
|
|
t.Fatalf("want 2, got %d", len(list))
|
|
}
|
|
|
|
// Dismiss one
|
|
_ = s.DismissProposedRoutine(ctx, list[0].ID)
|
|
list, err = s.ListProposedRoutines(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListProposedRoutines: %v", err)
|
|
}
|
|
if len(list) != 1 {
|
|
t.Fatalf("want 1 proposed after dismissing one, got %d", len(list))
|
|
}
|
|
}
|
|
|
|
// A dismissed routine must never be proposed again. The detector will keep
|
|
// finding the same pattern; the store is what stops maven nagging about it.
|
|
func TestDismissedProposedRoutineStaysDismissed(t *testing.T) {
|
|
s := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
|
|
id, err := s.CreateProposedRoutine(ctx, "clean", "litter_box", 3.0, now)
|
|
if err != nil {
|
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
|
}
|
|
if err := s.DismissProposedRoutine(ctx, id); err != nil {
|
|
t.Fatalf("DismissProposedRoutine: %v", err)
|
|
}
|
|
|
|
// The detector re-proposes the same pattern.
|
|
_, err = s.CreateProposedRoutine(ctx, "clean", "litter_box", 3.0, now.Add(24*time.Hour))
|
|
if !errors.Is(err, ErrProposedRoutineExists) {
|
|
t.Fatalf("want ErrProposedRoutineExists on re-propose, got %v", err)
|
|
}
|
|
|
|
// And it must not reappear on the review page.
|
|
list, err := s.ListProposedRoutines(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListProposedRoutines: %v", err)
|
|
}
|
|
if len(list) != 0 {
|
|
t.Fatalf("want 0 proposed, got %d", len(list))
|
|
}
|
|
|
|
// Dismissing again is a no-op, and accepting is refused.
|
|
if err := s.DismissProposedRoutine(ctx, id); err != nil {
|
|
t.Fatalf("second DismissProposedRoutine: %v", err)
|
|
}
|
|
if err := s.AcceptProposedRoutine(ctx, id, time.Now().UTC()); !errors.Is(err, ErrProposedRoutineNotFound) {
|
|
t.Fatalf("want ErrProposedRoutineNotFound accepting a dismissed routine, got %v", err)
|
|
}
|
|
r, err := s.LookupProposedRoutine(ctx, "clean", "litter_box")
|
|
if err != nil {
|
|
t.Fatalf("LookupProposedRoutine: %v", err)
|
|
}
|
|
if r.Status != RoutineDismissed {
|
|
t.Fatalf("want status=dismissed, got %s", r.Status)
|
|
}
|
|
}
|
|
|
|
func TestListProposedRoutinesByStatus(t *testing.T) {
|
|
s := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
|
|
keep, err := s.CreateProposedRoutine(ctx, "water", "plants", 4.0, now)
|
|
if err != nil {
|
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
|
}
|
|
drop, err := s.CreateProposedRoutine(ctx, "walk", "dog", 1.0, now.Add(time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
|
}
|
|
if err := s.AcceptProposedRoutine(ctx, keep, now); err != nil {
|
|
t.Fatalf("AcceptProposedRoutine: %v", err)
|
|
}
|
|
if err := s.DismissProposedRoutine(ctx, drop); err != nil {
|
|
t.Fatalf("DismissProposedRoutine: %v", err)
|
|
}
|
|
|
|
cases := []struct {
|
|
status string
|
|
want int
|
|
}{
|
|
{RoutineProposed, 0},
|
|
{RoutineAccepted, 1},
|
|
{RoutineDismissed, 1},
|
|
{"", 2}, // empty status ⇒ every row
|
|
}
|
|
for _, c := range cases {
|
|
list, err := s.ListProposedRoutinesByStatus(ctx, c.status)
|
|
if err != nil {
|
|
t.Fatalf("ListProposedRoutinesByStatus(%q): %v", c.status, err)
|
|
}
|
|
if len(list) != c.want {
|
|
t.Fatalf("status %q: want %d, got %d", c.status, c.want, len(list))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLookupMissingProposedRoutine(t *testing.T) {
|
|
s := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
r, err := s.LookupProposedRoutine(ctx, "nonexistent", "nothing")
|
|
if err != nil {
|
|
t.Fatalf("LookupProposedRoutine: %v", err)
|
|
}
|
|
if r != nil {
|
|
t.Fatal("want nil for missing routine")
|
|
}
|
|
}
|