Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d8fcf42f3 | |||
| 9e2af3286b |
@@ -166,9 +166,6 @@ func (l *lockedAPI) ListProposedRoutines(ctx context.Context) ([]ipc.ProposedRou
|
|||||||
return nil, errLocked
|
return nil, errLocked
|
||||||
}
|
}
|
||||||
func (l *lockedAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return errLocked }
|
func (l *lockedAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return errLocked }
|
||||||
func (l *lockedAPI) AcceptProposedRoutine(ctx context.Context, id, remID int64) error {
|
|
||||||
return errLocked
|
|
||||||
}
|
|
||||||
func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) {
|
func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) {
|
||||||
return ipc.Tool{}, errLocked
|
return ipc.Tool{}, errLocked
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -918,126 +918,3 @@ func TestHandleTools_ListToolsError_502(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String())
|
t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- handleRoutines ---
|
|
||||||
|
|
||||||
// routineCore is a fakeCore that also answers the proposed-routine calls.
|
|
||||||
type routineCore struct {
|
|
||||||
fakeCore
|
|
||||||
|
|
||||||
routines []ipc.ProposedRoutine
|
|
||||||
dismissed int64
|
|
||||||
acceptedID int64
|
|
||||||
acceptedRe int64
|
|
||||||
remCron string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *routineCore) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
|
|
||||||
return c.routines, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *routineCore) DismissProposedRoutine(_ context.Context, id int64) error {
|
|
||||||
c.dismissed = id
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *routineCore) AcceptProposedRoutine(_ context.Context, id, remID int64) error {
|
|
||||||
c.acceptedID, c.acceptedRe = id, remID
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *routineCore) CreateReminder(_ context.Context, _ time.Time, _, cron string) (int64, error) {
|
|
||||||
c.remCron = cron
|
|
||||||
return 77, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func weeklyRoutineCore() *routineCore {
|
|
||||||
return &routineCore{routines: []ipc.ProposedRoutine{{
|
|
||||||
ID: 3, Action: "refill", Object: "cat_water", IntervalDays: 7,
|
|
||||||
Status: "proposed", CreatedTs: time.Now().Add(-2 * time.Hour).UnixMilli(),
|
|
||||||
}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func postRoutine(action, id string) *http.Request {
|
|
||||||
return postForm(action, url.Values{"id": {id}})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleRoutines_GET_ShowsMavensPhrase(t *testing.T) {
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handleRoutines(rr, httptest.NewRequest(http.MethodGet, "/routines", nil), weeklyRoutineCore(), nil, false)
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", rr.Code)
|
|
||||||
}
|
|
||||||
body := rr.Body.String()
|
|
||||||
if !strings.Contains(body, "заправляешь") {
|
|
||||||
t.Fatalf("want maven's phrasing in the page, got: %s", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "class=scroll") {
|
|
||||||
t.Fatal("table must be wrapped in <div class=scroll> so it pans on a phone")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accepting hands the loop a new reason to speak, so it needs step-up.
|
|
||||||
func TestHandleRoutines_Accept_RequiresStepUp(t *testing.T) {
|
|
||||||
core := weeklyRoutineCore()
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handleRoutines(rr, postRoutine("accept", "3"), core, webauthn.NewPasskeySession(5*time.Minute), false)
|
|
||||||
if rr.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("status = %d, want 403", rr.Code)
|
|
||||||
}
|
|
||||||
if core.acceptedID != 0 {
|
|
||||||
t.Fatal("accepted without step-up")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleRoutines_Accept_CreatesReminderAndLinksIt(t *testing.T) {
|
|
||||||
core := weeklyRoutineCore()
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handleRoutines(rr, postRoutine("accept", "3"), core, stepUpSession(), false)
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
|
||||||
}
|
|
||||||
if core.acceptedID != 3 || core.acceptedRe != 77 {
|
|
||||||
t.Fatalf("accepted id=%d reminder=%d, want 3 and 77", core.acceptedID, core.acceptedRe)
|
|
||||||
}
|
|
||||||
if core.remCron == "" {
|
|
||||||
t.Fatal("a weekly pattern should get a cron expression")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dismiss only ever removes a reason to speak, so it is not step-up gated.
|
|
||||||
func TestHandleRoutines_Dismiss_NoStepUpNeeded(t *testing.T) {
|
|
||||||
core := weeklyRoutineCore()
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handleRoutines(rr, postRoutine("dismiss", "3"), core, webauthn.NewPasskeySession(5*time.Minute), false)
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
|
||||||
}
|
|
||||||
if core.dismissed != 3 {
|
|
||||||
t.Fatalf("dismissed = %d, want 3", core.dismissed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleRoutines_UnknownAction_400(t *testing.T) {
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handleRoutines(rr, postRoutine("frobnicate", "3"), weeklyRoutineCore(), stepUpSession(), false)
|
|
||||||
if rr.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400", rr.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleRoutines_BadID_400(t *testing.T) {
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handleRoutines(rr, postRoutine("dismiss", "nope"), weeklyRoutineCore(), stepUpSession(), false)
|
|
||||||
if rr.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400", rr.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleRoutines_NilCore_503(t *testing.T) {
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handleRoutines(rr, httptest.NewRequest(http.MethodGet, "/routines", nil), nil, nil, false)
|
|
||||||
if rr.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Fatalf("status = %d, want 503", rr.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+15
-101
@@ -24,7 +24,6 @@ import (
|
|||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/pattern"
|
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
"github.com/kami/maven/internal/webauthn"
|
"github.com/kami/maven/internal/webauthn"
|
||||||
)
|
)
|
||||||
@@ -396,6 +395,9 @@ func main() {
|
|||||||
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleReminders(w, r, core)
|
handleReminders(w, r, core)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleRoutines(w, r, core)
|
||||||
|
})
|
||||||
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleMorning(w, r, core)
|
handleMorning(w, r, core)
|
||||||
})
|
})
|
||||||
@@ -448,11 +450,6 @@ func main() {
|
|||||||
mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleTools(w, r, core, stepUpSession, *requireStepUp)
|
handleTools(w, r, core, stepUpSession, *requireStepUp)
|
||||||
})
|
})
|
||||||
// /routines — the authed accept surface. Registered here, next to /tools,
|
|
||||||
// because accepting shares the same step-up gate.
|
|
||||||
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
|
|
||||||
})
|
|
||||||
|
|
||||||
// /api/revert voids the latest fact for a key — a store mutation, so it
|
// /api/revert voids the latest fact for a key — a store mutation, so it
|
||||||
// sits behind the same passkey step-up as tool enable (nil session ⇒
|
// sits behind the same passkey step-up as tool enable (nil session ⇒
|
||||||
@@ -683,24 +680,22 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
|||||||
</section>
|
</section>
|
||||||
{{template "shellBottom"}}`
|
{{template "shellBottom"}}`
|
||||||
|
|
||||||
// routinesHTML — proposed routine review surface. One row per thing maven
|
// routinesHTML — proposed routine review surface. Lists detected patterns
|
||||||
// noticed, in her words, with at most two actions: accept or dismiss.
|
// awaiting human confirmation, with accept (→ reminder) and dismiss buttons.
|
||||||
const routinesHTML = `{{template "shellTop" "routines"}}
|
const routinesHTML = `{{template "shellTop" "routines"}}
|
||||||
<h1>Routines</h1>
|
<h1>Routines</h1>
|
||||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||||
<section class=card>
|
<section class=card>
|
||||||
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
|
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
||||||
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
|
{{if .Proposed}}<div class=scroll><table><tr><th>action</th><th>object</th><th>every</th><th></th></tr>
|
||||||
{{range .Proposed}}<tr>
|
{{range .Proposed}}<tr>
|
||||||
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
|
<td><code>{{.Action}}</code></td><td><code>{{.Object}}</code></td><td>{{.IntervalDays}} days</td>
|
||||||
<td><form method=post action=/routines class=inline-form>
|
<td>
|
||||||
<input type=hidden name=id value="{{.ID}}">
|
<form method=post action=/routines class=inline-form>
|
||||||
<input type=hidden name=action value=accept>
|
|
||||||
<button class=btn>accept</button></form></td>
|
|
||||||
<td><form method=post action=/routines class=inline-form>
|
|
||||||
<input type=hidden name=id value="{{.ID}}">
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
<input type=hidden name=action value=dismiss>
|
<input type=hidden name=action value=dismiss>
|
||||||
<button class="btn btn-muted">dismiss</button></form></td>
|
<button class="btn btn-muted">dismiss</button></form>
|
||||||
|
</td>
|
||||||
</tr>{{end}}</table></div>
|
</tr>{{end}}</table></div>
|
||||||
{{else}}<div class=empty>
|
{{else}}<div class=empty>
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
|
||||||
@@ -790,23 +785,7 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// routineRow is one line on the page: what maven noticed, in her words, and
|
func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
// how long ago she noticed it.
|
|
||||||
type routineRow struct {
|
|
||||||
ID int64
|
|
||||||
Phrase string
|
|
||||||
Noticed string
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleRoutines serves the routine review surface (GET) and answers a
|
|
||||||
// proposal (POST id + action=accept|dismiss).
|
|
||||||
//
|
|
||||||
// Accept is gated at step-up, the same tier as enabling a tool: saying yes
|
|
||||||
// hands the trigger loop a new standing reason to speak to the human, so it
|
|
||||||
// moves the boundary and only an authed surface may do it. Dismiss is not
|
|
||||||
// gated — it only ever removes a reason to speak, so the worst a weaker caller
|
|
||||||
// can do is make maven quieter.
|
|
||||||
func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
|
||||||
if core == nil {
|
if core == nil {
|
||||||
http.Error(w, "routines disabled (no -core)", http.StatusServiceUnavailable)
|
http.Error(w, "routines disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
@@ -822,17 +801,6 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch action {
|
switch action {
|
||||||
case "accept":
|
|
||||||
if !stepUpOK(session, requireStepUp) {
|
|
||||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := acceptRoutine(ctx, core, rid); err != nil {
|
|
||||||
log.Printf("routines: accept %d: %v", rid, err)
|
|
||||||
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msg = "accepted routine — maven will remind you"
|
|
||||||
case "dismiss":
|
case "dismiss":
|
||||||
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
||||||
log.Printf("routines: dismiss %d: %v", rid, err)
|
log.Printf("routines: dismiss %d: %v", rid, err)
|
||||||
@@ -854,66 +822,12 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
|||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := routinesTmpl.Execute(w, struct {
|
if err := routinesTmpl.Execute(w, struct {
|
||||||
Msg string
|
Msg string
|
||||||
Proposed []routineRow
|
Proposed []ipc.ProposedRoutine
|
||||||
}{msg, routineRows(proposed)}); err != nil {
|
}{msg, proposed}); err != nil {
|
||||||
log.Printf("routines render: %v", err)
|
log.Printf("routines render: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// routineRows turns the wire rows into display rows. The phrase comes from
|
|
||||||
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
|
|
||||||
func routineRows(rs []ipc.ProposedRoutine) []routineRow {
|
|
||||||
out := make([]routineRow, 0, len(rs))
|
|
||||||
for _, r := range rs {
|
|
||||||
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
|
|
||||||
noticed := "just now"
|
|
||||||
if r.CreatedTs > 0 {
|
|
||||||
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
|
|
||||||
}
|
|
||||||
out = append(out, routineRow{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// acceptRoutine creates the recurring reminder for a proposal, then marks the
|
|
||||||
// proposal accepted and links the reminder to it. Weekly patterns get a cron
|
|
||||||
// expression; any other interval fires once.
|
|
||||||
//
|
|
||||||
// TODO(vikunja#46): this mirrors the voice accept path in cmd/mavend/voice.go.
|
|
||||||
// When the tick loop learns to read accepted proposals directly, both callers
|
|
||||||
// should hand off to one place in core instead of each building a reminder.
|
|
||||||
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
|
||||||
proposed, err := core.ListProposedRoutines(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var found *ipc.ProposedRoutine
|
|
||||||
for i := range proposed {
|
|
||||||
if proposed[i].ID == id {
|
|
||||||
found = &proposed[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if found == nil {
|
|
||||||
return errors.New("no such proposed routine")
|
|
||||||
}
|
|
||||||
|
|
||||||
fire := time.Now().Add(time.Duration(found.IntervalDays * 24 * float64(time.Hour)))
|
|
||||||
cron := ""
|
|
||||||
if found.IntervalDays >= 6.5 && found.IntervalDays <= 7.5 {
|
|
||||||
cron = fmt.Sprintf("0 %d * * %d", fire.Hour(), int(fire.Weekday()))
|
|
||||||
}
|
|
||||||
payload, err := json.Marshal(map[string]string{"text": found.Action + " " + found.Object})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
remID, err := core.CreateReminder(ctx, fire, string(payload), cron)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return core.AcceptProposedRoutine(ctx, id, remID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
if core == nil {
|
if core == nil {
|
||||||
http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable)
|
http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
|
|||||||
@@ -446,9 +446,6 @@ func (r *recordingAPI) RevertFact(_ context.Context, _ string) (int64, error) {
|
|||||||
func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
|
func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (r *recordingAPI) AcceptProposedRoutine(_ context.Context, _, _ int64) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
|
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,11 +237,6 @@ type dismissProposedRoutineReq struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type acceptProposedRoutineReq struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
ReminderID int64 `json:"reminder_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
||||||
// - the in-process store adapter (server.go storeAPI) — used by the daemon
|
// - the in-process store adapter (server.go storeAPI) — used by the daemon
|
||||||
// for modules that live in-process for now (router, delivery) and by tests,
|
// for modules that live in-process for now (router, delivery) and by tests,
|
||||||
@@ -291,9 +286,6 @@ type CoreAPI interface {
|
|||||||
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
||||||
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
||||||
DismissProposedRoutine(ctx context.Context, id int64) error
|
DismissProposedRoutine(ctx context.Context, id int64) error
|
||||||
// AcceptProposedRoutine flips a proposed routine to 'accepted' and links
|
|
||||||
// the reminder that will fire it. The caller creates the reminder first.
|
|
||||||
AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error
|
|
||||||
|
|
||||||
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
||||||
// this after every tick; the store adapter returns an error (trace is not
|
// this after every tick; the store adapter returns an error (trace is not
|
||||||
|
|||||||
@@ -430,10 +430,6 @@ func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
|
|||||||
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
|
|
||||||
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id, ReminderID: reminderID}, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
|
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
|
||||||
var r chatResp
|
var r chatResp
|
||||||
if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil {
|
if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil {
|
||||||
|
|||||||
@@ -478,9 +478,6 @@ func (a *chatTestAPI) DeleteTool(ctx context.Context, name string) error {
|
|||||||
func (a *chatTestAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
func (a *chatTestAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||||
return nil, ErrUnknownMethod
|
return nil, ErrUnknownMethod
|
||||||
}
|
}
|
||||||
func (a *chatTestAPI) AcceptProposedRoutine(ctx context.Context, id, remID int64) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||||
return ErrUnknownMethod
|
return ErrUnknownMethod
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,10 +253,6 @@ func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
|||||||
return mapErr(a.s.DismissProposedRoutine(ctx, id))
|
return mapErr(a.s.DismissProposedRoutine(ctx, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
|
|
||||||
return mapErr(a.s.AcceptProposedRoutine(ctx, id, reminderID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func toTool(t store.Tool) Tool {
|
func toTool(t store.Tool) Tool {
|
||||||
return Tool{
|
return Tool{
|
||||||
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
|
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
|
||||||
@@ -778,13 +774,6 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
|||||||
}
|
}
|
||||||
return marshalResult(nil), api.DismissProposedRoutine(ctx, p.ID)
|
return marshalResult(nil), api.DismissProposedRoutine(ctx, p.ID)
|
||||||
|
|
||||||
case MethodAcceptProposedRoutine:
|
|
||||||
var p acceptProposedRoutineReq
|
|
||||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return marshalResult(nil), api.AcceptProposedRoutine(ctx, p.ID, p.ReminderID)
|
|
||||||
|
|
||||||
case MethodRevertFact:
|
case MethodRevertFact:
|
||||||
var p struct {
|
var p struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const (
|
|||||||
MethodDeleteTool Method = "delete_tool"
|
MethodDeleteTool Method = "delete_tool"
|
||||||
MethodListProposedRoutines Method = "list_proposed_routines"
|
MethodListProposedRoutines Method = "list_proposed_routines"
|
||||||
MethodDismissProposedRoutine Method = "dismiss_proposed_routine"
|
MethodDismissProposedRoutine Method = "dismiss_proposed_routine"
|
||||||
MethodAcceptProposedRoutine Method = "accept_proposed_routine"
|
|
||||||
MethodRevertFact Method = "revert_fact"
|
MethodRevertFact Method = "revert_fact"
|
||||||
MethodTickTrace Method = "tick_trace"
|
MethodTickTrace Method = "tick_trace"
|
||||||
MethodMorningStatus Method = "morning_status"
|
MethodMorningStatus Method = "morning_status"
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package loop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tests for the universal restraint gate.
|
||||||
|
//
|
||||||
|
// DESIGN.md § Trigger model: "the gate is universal, applied by the loop, never
|
||||||
|
// per-rule — quiet-hours, presence, cooldown, snooze, calendar-busy all live in
|
||||||
|
// one fires()." These tests pin the CONSERVATIVE side of that: the cases where
|
||||||
|
// Maven must stay quiet. They exist so nobody loosens the gate by accident.
|
||||||
|
//
|
||||||
|
// Where the code does not yet do what DESIGN.md promises, the test is written to
|
||||||
|
// show the gap and then skipped, with the file and line to fix. Behaviour is not
|
||||||
|
// changed to make a test pass.
|
||||||
|
|
||||||
|
// testRule — a rule at the given severity that always wants to fire, so the
|
||||||
|
// only thing under test is the gate.
|
||||||
|
func testRule(name string, sev Severity) Rule {
|
||||||
|
return Rule{
|
||||||
|
Name: name,
|
||||||
|
Severity: sev,
|
||||||
|
Cooldown: Cooldown{Base: 30 * time.Minute, Min: time.Minute, Max: time.Hour},
|
||||||
|
Predicate: func(State) bool { return true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------- quiet hours ------------------------------------
|
||||||
|
|
||||||
|
// Quiet hours silence care and leave ops alone. A failed backup at 2am matters;
|
||||||
|
// a water nudge at 2am does not.
|
||||||
|
func TestGateQuietHoursSuppressesCareOnly(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
sev Severity
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{Sev1, false},
|
||||||
|
{Sev2, false},
|
||||||
|
{Sev3, true},
|
||||||
|
{Sev4, true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
s := State{Now: refTime(), Presence: store.Present, QuietHours: true}
|
||||||
|
if got := Gate(s, testRule("r", c.sev)); got != c.want {
|
||||||
|
t.Errorf("quiet hours sev%d: want fire=%v, got %v", c.sev, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------- presence ---------------------------------------
|
||||||
|
|
||||||
|
// DESIGN.md § Delivery: "sev <= 2 drops on away, sev >= 3 holds: a missed water
|
||||||
|
// nudge is noise, a missed backup failure isn't."
|
||||||
|
func TestGateAwayDropsCareHoldsOps(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
sev Severity
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{Sev1, false},
|
||||||
|
{Sev2, false},
|
||||||
|
{Sev3, true},
|
||||||
|
{Sev4, true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
s := State{Now: refTime(), Presence: store.Away}
|
||||||
|
if got := Gate(s, testRule("r", c.sev)); got != c.want {
|
||||||
|
t.Errorf("away sev%d: want fire=%v, got %v", c.sev, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Care nudges are allowed through when the user is actually there and nothing
|
||||||
|
// else is suppressing. Without this the "quiet" tests above could pass on a
|
||||||
|
// gate that simply never fires.
|
||||||
|
func TestGateAllowsCareWhenPresentAndClear(t *testing.T) {
|
||||||
|
s := State{Now: refTime(), Presence: store.Present}
|
||||||
|
if !Gate(s, testRule("r", Sev1)) {
|
||||||
|
t.Fatal("present and clear: care nudge should be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------- calendar busy ----------------------------------
|
||||||
|
|
||||||
|
// "Don't nag mid-meeting" is an env predicate in the gate, not the LLM's call.
|
||||||
|
// Ops still gets through — a service being down mid-meeting is worth the
|
||||||
|
// interruption.
|
||||||
|
func TestGateCalendarBusySuppressesCareOnly(t *testing.T) {
|
||||||
|
care := State{Now: refTime(), Presence: store.Present, CalendarBusy: true}
|
||||||
|
if Gate(care, testRule("r", Sev2)) {
|
||||||
|
t.Error("calendar busy: care nudge should be suppressed")
|
||||||
|
}
|
||||||
|
if !Gate(care, testRule("r", Sev4)) {
|
||||||
|
t.Error("calendar busy: ops hard should still fire")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------- cooldown ---------------------------------------
|
||||||
|
|
||||||
|
// Cooldown holds for every severity — it is the anti-nag knob, so ops cannot
|
||||||
|
// buy its way past it either.
|
||||||
|
func TestGateCooldownHoldsForAllSeverities(t *testing.T) {
|
||||||
|
now := refTime()
|
||||||
|
for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} {
|
||||||
|
s := State{
|
||||||
|
Now: now,
|
||||||
|
Presence: store.Present,
|
||||||
|
CooldownUntil: map[string]time.Time{"r": now.Add(10 * time.Minute)},
|
||||||
|
}
|
||||||
|
if Gate(s, testRule("r", sev)) {
|
||||||
|
t.Errorf("cooldown sev%d: should be suppressed", sev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cooldown is per-rule: one rule cooling down must not mute another.
|
||||||
|
func TestGateCooldownIsPerRule(t *testing.T) {
|
||||||
|
now := refTime()
|
||||||
|
s := State{
|
||||||
|
Now: now,
|
||||||
|
Presence: store.Present,
|
||||||
|
CooldownUntil: map[string]time.Time{"water": now.Add(10 * time.Minute)},
|
||||||
|
}
|
||||||
|
if Gate(s, testRule("water", Sev1)) {
|
||||||
|
t.Error("water is cooling down and should be suppressed")
|
||||||
|
}
|
||||||
|
if !Gate(s, testRule("meal", Sev1)) {
|
||||||
|
t.Error("meal has no cooldown and should be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The moment the cooldown expires the rule is free again — the gate compares
|
||||||
|
// with Before, so "until" itself is already clear.
|
||||||
|
func TestGateCooldownExpires(t *testing.T) {
|
||||||
|
now := refTime()
|
||||||
|
s := State{
|
||||||
|
Now: now,
|
||||||
|
Presence: store.Present,
|
||||||
|
CooldownUntil: map[string]time.Time{"r": now},
|
||||||
|
}
|
||||||
|
if !Gate(s, testRule("r", Sev1)) {
|
||||||
|
t.Fatal("cooldown at exactly now should already be clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------- snooze -----------------------------------------
|
||||||
|
|
||||||
|
// Snooze is the user saying "not about this". It beats everything, including
|
||||||
|
// ops hard.
|
||||||
|
func TestGateSnoozeHoldsForAllSeverities(t *testing.T) {
|
||||||
|
now := refTime()
|
||||||
|
for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} {
|
||||||
|
s := State{
|
||||||
|
Now: now,
|
||||||
|
Presence: store.Present,
|
||||||
|
SnoozeUntil: map[string]time.Time{"r": now.Add(time.Hour)},
|
||||||
|
}
|
||||||
|
if Gate(s, testRule("r", sev)) {
|
||||||
|
t.Errorf("snooze sev%d: should be suppressed", sev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------- no-data backstop -------------------------------
|
||||||
|
|
||||||
|
// The gate enforces no-data inertness a second time, for any rule that declared
|
||||||
|
// the keys it needs. A predicate that forgets the check still cannot fire.
|
||||||
|
func TestGateNoDataBackstopBeatsAnEagerPredicate(t *testing.T) {
|
||||||
|
now := refTime()
|
||||||
|
eager := Rule{
|
||||||
|
Name: "eager",
|
||||||
|
Severity: Sev4, // even ops hard does not get past missing data
|
||||||
|
Predicate: func(State) bool { return true },
|
||||||
|
InertWhenNoData: []string{"water", "meal"},
|
||||||
|
}
|
||||||
|
// one of the two keys present is not enough.
|
||||||
|
s := State{
|
||||||
|
Now: now,
|
||||||
|
Presence: store.Present,
|
||||||
|
Facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, time.Hour)},
|
||||||
|
}
|
||||||
|
if Gate(s, eager) {
|
||||||
|
t.Fatal("a rule missing one of its keys must stay inert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------- one nudge per tick -----------------------------
|
||||||
|
|
||||||
|
// All five default rules want to fire at once. The tick must still emit exactly
|
||||||
|
// one candidate, the loudest — never a dogpile.
|
||||||
|
func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
|
||||||
|
now := refTime()
|
||||||
|
s := State{
|
||||||
|
Now: now,
|
||||||
|
Presence: store.Present,
|
||||||
|
Facts: map[string]store.Fact{
|
||||||
|
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
|
||||||
|
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
|
||||||
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||||
|
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
|
||||||
|
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
||||||
|
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// sanity: every rule really does want to fire, so the pick is a real choice.
|
||||||
|
for _, r := range DefaultRules() {
|
||||||
|
if !r.Predicate(s) {
|
||||||
|
t.Fatalf("setup: rule %q does not want to fire", r.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := Tick(s, DefaultRules())
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("all rules firing: want one candidate, got nil")
|
||||||
|
}
|
||||||
|
if got.Rule.Name != "service_down" || got.Severity != Sev4 {
|
||||||
|
t.Fatalf("want the loudest (service_down/sev4), got %s/sev%d", got.Rule.Name, got.Severity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick returns a single Candidate by type, so "one per tick" cannot be violated
|
||||||
|
// by count — what can drift is WHICH one. Equal severities tie-break by name so
|
||||||
|
// the choice is deterministic across ticks.
|
||||||
|
func TestTickTieBreaksByNameForDeterminism(t *testing.T) {
|
||||||
|
s := State{Now: refTime(), Presence: store.Present}
|
||||||
|
rules := []Rule{testRule("zebra", Sev2), testRule("apple", Sev2), testRule("mango", Sev2)}
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
got := Tick(s, rules)
|
||||||
|
if got == nil || got.Rule.Name != "apple" {
|
||||||
|
t.Fatalf("tie-break: want apple every time, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The loudest candidate wins even when the quiet one is listed first.
|
||||||
|
func TestTickOrderOfRulesDoesNotMatter(t *testing.T) {
|
||||||
|
s := State{Now: refTime(), Presence: store.Present}
|
||||||
|
first := Tick(s, []Rule{testRule("care", Sev1), testRule("ops", Sev4)})
|
||||||
|
second := Tick(s, []Rule{testRule("ops", Sev4), testRule("care", Sev1)})
|
||||||
|
if first == nil || second == nil {
|
||||||
|
t.Fatal("want a candidate from both orderings")
|
||||||
|
}
|
||||||
|
if first.Rule.Name != "ops" || second.Rule.Name != "ops" {
|
||||||
|
t.Fatalf("order changed the pick: %s then %s", first.Rule.Name, second.Rule.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------- reminders bypass the gate ----------------------
|
||||||
|
|
||||||
|
// DESIGN.md § User reminders: "bypasses the restraint gate — 'wake me 7' fires
|
||||||
|
// in quiet hours; that's the point." Every suppressor set at once, and the
|
||||||
|
// reminder still comes through.
|
||||||
|
func TestRemindersBypassEverySuppressor(t *testing.T) {
|
||||||
|
now := refTime()
|
||||||
|
s := State{
|
||||||
|
Now: now,
|
||||||
|
Presence: store.Away,
|
||||||
|
QuietHours: true,
|
||||||
|
CalendarBusy: true,
|
||||||
|
CooldownUntil: map[string]time.Time{"reminder": now.Add(time.Hour)},
|
||||||
|
}
|
||||||
|
due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}}
|
||||||
|
got := RemindDecisions(s, due)
|
||||||
|
if len(got) != 1 || got[0].Reminder.ID != 7 {
|
||||||
|
t.Fatalf("reminder must bypass the gate, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GAP — DESIGN.md § User reminders ends "Snooze still applies." RemindDecisions
|
||||||
|
// passes every due reminder straight through with no snooze check, so a snoozed
|
||||||
|
// reminder fires anyway. The test below is what the contract asks for.
|
||||||
|
func TestRemindersStillHonourSnooze(t *testing.T) {
|
||||||
|
t.Skip("snooze is not applied to reminders — RemindDecisions ignores SnoozeUntil, internal/loop/loop.go:120")
|
||||||
|
|
||||||
|
now := refTime()
|
||||||
|
s := State{
|
||||||
|
Now: now,
|
||||||
|
Presence: store.Present,
|
||||||
|
SnoozeUntil: map[string]time.Time{"reminder:7": now.Add(time.Hour)},
|
||||||
|
}
|
||||||
|
due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}}
|
||||||
|
if got := RemindDecisions(s, due); len(got) != 0 {
|
||||||
|
t.Fatalf("snoozed reminder should not be delivered, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GAP — the gate reads State.SnoozeUntil, but the Gatherer hard-codes it to nil
|
||||||
|
// (internal/loop/gather.go:153), so snooze is dead in the running daemon: the
|
||||||
|
// unit tests above pass while nothing can ever populate the map. This asserts
|
||||||
|
// the Gatherer actually produces a snooze map.
|
||||||
|
func TestGathererPopulatesSnoozeUntil(t *testing.T) {
|
||||||
|
t.Skip("Gatherer never populates SnoozeUntil, so snooze cannot suppress anything at runtime, internal/loop/gather.go:153")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
st, err := store.Open(ctx, t.TempDir()+"/m.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
g := NewGatherer(st, DefaultRules())
|
||||||
|
snap, _, err := g.GatherState(ctx, refTime())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if snap.SnoozeUntil == nil {
|
||||||
|
t.Fatal("Gatherer returned a nil SnoozeUntil map")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
package loop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direct tests for the five default rule predicates.
|
||||||
|
//
|
||||||
|
// A predicate is pure — (State) -> bool, no I/O — so these need no store and no
|
||||||
|
// daemon. They test the predicate ALONE: the restraint gate is tested in
|
||||||
|
// loop_test.go and gate_test.go, never here.
|
||||||
|
//
|
||||||
|
// Every rule gets the same three questions plus its own edges:
|
||||||
|
// - does it fire when it should?
|
||||||
|
// - does it stay quiet when it should?
|
||||||
|
// - is it silent when the key it needs has no data at all?
|
||||||
|
//
|
||||||
|
// The last one is load-bearing. DESIGN.md: "since(key)==null → don't fire.
|
||||||
|
// Silence on no-data is 'shuts up when uncertain'."
|
||||||
|
|
||||||
|
// stateWith builds a snapshot at refTime() holding just the given facts.
|
||||||
|
// Presence and the env flags are left zero — the predicate must not read them.
|
||||||
|
func stateWith(facts map[string]store.Fact) State {
|
||||||
|
return State{Now: refTime(), Facts: facts}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ago is a fact for key written `d` before refTime().
|
||||||
|
func ago(key, source, value string, d time.Duration) store.Fact {
|
||||||
|
return factAt(key, source, value, refTime().Add(-d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------- since-based care rules -------------------------
|
||||||
|
|
||||||
|
// The three care rules share one shape: "fire when it has been at least N since
|
||||||
|
// the last fact for key". One table drives all of them.
|
||||||
|
func TestCareRulePredicates(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rule Rule
|
||||||
|
facts map[string]store.Fact
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// water — threshold 3h.
|
||||||
|
{
|
||||||
|
name: "water fires at 4h",
|
||||||
|
rule: WaterRule(),
|
||||||
|
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour)},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "water fires exactly at the 3h threshold",
|
||||||
|
rule: WaterRule(),
|
||||||
|
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour)},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "water quiet just under 3h",
|
||||||
|
rule: WaterRule(),
|
||||||
|
facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour-time.Minute)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "water quiet on no data",
|
||||||
|
rule: WaterRule(),
|
||||||
|
facts: nil,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "water quiet on a zero-timestamp fact",
|
||||||
|
rule: WaterRule(),
|
||||||
|
facts: map[string]store.Fact{"water": {Key: "water", Source: "tap:water", Value: `"250ml"`}},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "water quiet when the only fact is for another key",
|
||||||
|
rule: WaterRule(),
|
||||||
|
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 9*time.Hour)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// meal — threshold 6h.
|
||||||
|
{
|
||||||
|
name: "meal fires at 7h",
|
||||||
|
rule: MealRule(),
|
||||||
|
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour)},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "meal fires exactly at the 6h threshold",
|
||||||
|
rule: MealRule(),
|
||||||
|
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour)},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "meal quiet just under 6h",
|
||||||
|
rule: MealRule(),
|
||||||
|
facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour-time.Minute)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "meal quiet on no data",
|
||||||
|
rule: MealRule(),
|
||||||
|
facts: nil,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// break — needs BOTH anchors: at the desk now, and no break for 90min.
|
||||||
|
{
|
||||||
|
name: "break fires when at desk and no break for 2h",
|
||||||
|
rule: BreakRule(),
|
||||||
|
facts: map[string]store.Fact{
|
||||||
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||||
|
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "break fires exactly at both thresholds",
|
||||||
|
rule: BreakRule(),
|
||||||
|
facts: map[string]store.Fact{
|
||||||
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 2*time.Minute),
|
||||||
|
"break": ago("break", "voice", `"walk"`, 90*time.Minute),
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "break quiet when the desk signal is stale (user left)",
|
||||||
|
rule: BreakRule(),
|
||||||
|
facts: map[string]store.Fact{
|
||||||
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 10*time.Minute),
|
||||||
|
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "break quiet when the last break was recent",
|
||||||
|
rule: BreakRule(),
|
||||||
|
facts: map[string]store.Fact{
|
||||||
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||||
|
"break": ago("break", "voice", `"walk"`, 20*time.Minute),
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "break quiet with only the desk anchor",
|
||||||
|
rule: BreakRule(),
|
||||||
|
facts: map[string]store.Fact{
|
||||||
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "break quiet with only the break anchor",
|
||||||
|
rule: BreakRule(),
|
||||||
|
facts: map[string]store.Fact{
|
||||||
|
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "break quiet on no data",
|
||||||
|
rule: BreakRule(),
|
||||||
|
facts: nil,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := c.rule.Predicate(stateWith(c.facts)); got != c.want {
|
||||||
|
t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------- ops rules --------------------------------------
|
||||||
|
|
||||||
|
// The two ops rules match on a value AND on which poller wrote it. DESIGN.md:
|
||||||
|
// "a compromised poller must not be able to forge a trigger." Half of this
|
||||||
|
// table is forgery attempts; all of them must be refused.
|
||||||
|
func TestOpsRulePredicates(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rule Rule
|
||||||
|
facts map[string]store.Fact
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// service_down — only poll:uptimekuma may say a service is down.
|
||||||
|
{
|
||||||
|
name: "service_down fires on a kuma down fact",
|
||||||
|
rule: ServiceDownRule(),
|
||||||
|
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute)},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "service_down quiet when kuma says up",
|
||||||
|
rule: ServiceDownRule(),
|
||||||
|
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"up"`, time.Minute)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "service_down quiet on no data",
|
||||||
|
rule: ServiceDownRule(),
|
||||||
|
facts: nil,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "service_down quiet on a zero-timestamp fact",
|
||||||
|
rule: ServiceDownRule(),
|
||||||
|
facts: map[string]store.Fact{"service_down": {Key: "service_down", Source: "poll:uptimekuma", Value: `"down"`}},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
// forgery attempts — right value, wrong writer.
|
||||||
|
{
|
||||||
|
name: "service_down refuses a forgery from the netdata poller",
|
||||||
|
rule: ServiceDownRule(),
|
||||||
|
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:netdata", `"down"`, time.Minute)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "service_down refuses a forgery from ambient audio",
|
||||||
|
rule: ServiceDownRule(),
|
||||||
|
facts: map[string]store.Fact{"service_down": ago("service_down", "ambient:other", `"down"`, time.Minute)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "service_down refuses a forgery from the user's own voice",
|
||||||
|
rule: ServiceDownRule(),
|
||||||
|
facts: map[string]store.Fact{"service_down": ago("service_down", "voice", `"down"`, time.Minute)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "service_down refuses a source that only looks like kuma",
|
||||||
|
rule: ServiceDownRule(),
|
||||||
|
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma-staging", `"down"`, time.Minute)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "service_down refuses an unquoted down value",
|
||||||
|
rule: ServiceDownRule(),
|
||||||
|
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `down`, time.Minute)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// netdata_critical — only poll:netdata may raise a critical alarm.
|
||||||
|
{
|
||||||
|
name: "netdata_critical fires on a netdata critical alarm",
|
||||||
|
rule: NetdataCriticalRule(),
|
||||||
|
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute)},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "netdata_critical quiet on a warning alarm",
|
||||||
|
rule: NetdataCriticalRule(),
|
||||||
|
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"warning"`, time.Minute)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "netdata_critical quiet on a cleared alarm",
|
||||||
|
rule: NetdataCriticalRule(),
|
||||||
|
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"clear"`, time.Minute)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "netdata_critical quiet on no data",
|
||||||
|
rule: NetdataCriticalRule(),
|
||||||
|
facts: nil,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "netdata_critical quiet on a zero-timestamp fact",
|
||||||
|
rule: NetdataCriticalRule(),
|
||||||
|
facts: map[string]store.Fact{"netdata_alarm": {Key: "netdata_alarm", Source: "poll:netdata", Value: `"critical"`}},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "netdata_critical refuses a forgery from the kuma poller",
|
||||||
|
rule: NetdataCriticalRule(),
|
||||||
|
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:uptimekuma", `"critical"`, time.Minute)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "netdata_critical refuses a forgery from ambient audio",
|
||||||
|
rule: NetdataCriticalRule(),
|
||||||
|
facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "ambient:other", `"critical"`, time.Minute)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "netdata_critical reads netdata_alarm, not netdata_critical",
|
||||||
|
rule: NetdataCriticalRule(),
|
||||||
|
facts: map[string]store.Fact{"netdata_critical": ago("netdata_critical", "poll:netdata", `"critical"`, time.Minute)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := c.rule.Predicate(stateWith(c.facts)); got != c.want {
|
||||||
|
t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------- rule metadata ----------------------------------
|
||||||
|
|
||||||
|
// Every default rule must declare the keys it needs. The gate uses that list as
|
||||||
|
// a second no-data backstop, so a rule that forgets it loses the safety net
|
||||||
|
// even if its predicate happens to check.
|
||||||
|
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
|
||||||
|
for _, r := range DefaultRules() {
|
||||||
|
if len(r.InertWhenNoData) == 0 {
|
||||||
|
t.Errorf("rule %q declares no InertWhenNoData keys", r.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A no-data snapshot must make EVERY default rule quiet, predicate alone, with
|
||||||
|
// the gate out of the picture. This is the whole-set version of the per-rule
|
||||||
|
// no-data cases above.
|
||||||
|
func TestNoDefaultRuleFiresOnEmptyState(t *testing.T) {
|
||||||
|
empty := stateWith(nil)
|
||||||
|
for _, r := range DefaultRules() {
|
||||||
|
if r.Predicate(empty) {
|
||||||
|
t.Errorf("rule %q fires on an empty snapshot", r.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Severities are the delivery contract (DESIGN.md § Delivery / channel
|
||||||
|
// routing): care is sev1-2 and drops when away, ops is sev3-4 and holds. Pin
|
||||||
|
// them so a change to a rule's insistence has to be deliberate.
|
||||||
|
func TestDefaultRuleSeverities(t *testing.T) {
|
||||||
|
want := map[string]Severity{
|
||||||
|
"water": Sev1,
|
||||||
|
"meal": Sev1,
|
||||||
|
"break": Sev2,
|
||||||
|
"service_down": Sev4,
|
||||||
|
"netdata_critical": Sev3,
|
||||||
|
}
|
||||||
|
got := map[string]Severity{}
|
||||||
|
for _, r := range DefaultRules() {
|
||||||
|
got[r.Name] = r.Severity
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("rule count changed: want %d, got %d", len(want), len(got))
|
||||||
|
}
|
||||||
|
for name, sev := range want {
|
||||||
|
if got[name] != sev {
|
||||||
|
t.Errorf("rule %q severity: want %d, got %d", name, sev, got[name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cooldown bounds keep the feedback tuner honest — DESIGN.md wants
|
||||||
|
// `cooldown in [min,max]` "so a weird week can't mutate Maven silent or
|
||||||
|
// stalker". A base outside its own envelope would make that meaningless.
|
||||||
|
func TestDefaultRuleCooldownsAreBounded(t *testing.T) {
|
||||||
|
for _, r := range DefaultRules() {
|
||||||
|
c := r.Cooldown
|
||||||
|
if c.Min <= 0 || c.Base <= 0 || c.Max <= 0 {
|
||||||
|
t.Errorf("rule %q has a non-positive cooldown: %+v", r.Name, c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.Base < c.Min || c.Base > c.Max {
|
||||||
|
t.Errorf("rule %q base %v outside envelope [%v, %v]", r.Name, c.Base, c.Min, c.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A predicate must read only the snapshot it is handed. Same snapshot twice
|
||||||
|
// (and a snapshot shared between two rules) must give the same answer — no
|
||||||
|
// hidden state, no clock reads.
|
||||||
|
func TestPredicatesArePure(t *testing.T) {
|
||||||
|
s := stateWith(map[string]store.Fact{
|
||||||
|
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
|
||||||
|
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
|
||||||
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||||
|
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||||
|
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
||||||
|
})
|
||||||
|
for _, r := range DefaultRules() {
|
||||||
|
first := r.Predicate(s)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if again := r.Predicate(s); again != first {
|
||||||
|
t.Fatalf("rule %q predicate is not pure: %v then %v", r.Name, first, again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,14 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The three states a proposal can be in. A proposal starts 'proposed' and
|
|
||||||
// moves once, either way, and never moves again.
|
|
||||||
const (
|
|
||||||
RoutineProposed = "proposed"
|
|
||||||
RoutineAccepted = "accepted"
|
|
||||||
RoutineDismissed = "dismissed"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProposedRoutine — a detected pattern the system wants to turn into a
|
// ProposedRoutine — a detected pattern the system wants to turn into a
|
||||||
// recurring reminder. Status 'proposed' means awaiting human confirmation;
|
// recurring reminder. Status 'proposed' means awaiting human confirmation;
|
||||||
// 'accepted' means the human confirmed and a reminder was created (reminder_id
|
// 'accepted' means the human confirmed and a reminder was created (reminder_id
|
||||||
@@ -38,15 +30,6 @@ var (
|
|||||||
// CreateProposedRoutine inserts a new proposed routine. Returns
|
// CreateProposedRoutine inserts a new proposed routine. Returns
|
||||||
// ErrProposedRoutineExists if one already exists for this action+object (any
|
// ErrProposedRoutineExists if one already exists for this action+object (any
|
||||||
// status) — the pattern detector should only propose once per pair.
|
// status) — the pattern detector should only propose once per pair.
|
||||||
//
|
|
||||||
// action+object is the "same routine" key. It is UNIQUE in the table, so a
|
|
||||||
// routine the human already dismissed can never come back: the detector will
|
|
||||||
// keep finding the pattern, and every re-propose is refused here. Maven is not
|
|
||||||
// a nag.
|
|
||||||
//
|
|
||||||
// TODO(vikunja#46): the detector currently only writes here from the voice
|
|
||||||
// path. Once digestion runs the detector on its own tick, that tick should
|
|
||||||
// call this too, so a pattern gets noticed even with nobody at the mic.
|
|
||||||
func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string, intervalDays float64, ts time.Time) (int64, error) {
|
func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string, intervalDays float64, ts time.Time) (int64, error) {
|
||||||
res, err := s.db.ExecContext(ctx,
|
res, err := s.db.ExecContext(ctx,
|
||||||
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts)
|
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts)
|
||||||
@@ -87,29 +70,14 @@ func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string
|
|||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListProposedRoutines returns the routines still waiting for an answer,
|
// ListProposedRoutines returns all proposed routines with status='proposed',
|
||||||
// newest first. This is what the /routines page shows.
|
// newest first.
|
||||||
func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||||
return s.ListProposedRoutinesByStatus(ctx, RoutineProposed)
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
}
|
SELECT id, action, object, interval_days, status, created_ts, reminder_id
|
||||||
|
FROM proposed_routines
|
||||||
// ListProposedRoutinesByStatus returns routines in one status, newest first.
|
WHERE status = 'proposed'
|
||||||
// An empty status returns every row.
|
ORDER BY created_ts DESC, id DESC`)
|
||||||
//
|
|
||||||
// TODO(vikunja#46): the tick loop should read the accepted ones from here so a
|
|
||||||
// routine the human said yes to has a home the loop can see, instead of only
|
|
||||||
// the reminder row that accepting happened to create.
|
|
||||||
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string) ([]ProposedRoutine, error) {
|
|
||||||
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id
|
|
||||||
FROM proposed_routines`
|
|
||||||
var args []any
|
|
||||||
if status != "" {
|
|
||||||
q += ` WHERE status = ?`
|
|
||||||
args = append(args, status)
|
|
||||||
}
|
|
||||||
q += ` ORDER BY created_ts DESC, id DESC`
|
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list proposed routines: %w", err)
|
return nil, fmt.Errorf("list proposed routines: %w", err)
|
||||||
}
|
}
|
||||||
@@ -125,19 +93,8 @@ func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string)
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status changes below are an in-place UPDATE, on purpose. Facts are
|
|
||||||
// append-only (a correction writes a new row and sets voids_id) because a fact
|
|
||||||
// is a claim about the world and the old claim is still history worth keeping.
|
|
||||||
// A proposal is not a claim, it is a question with one answer, and the same
|
|
||||||
// shape already exists for tools (tools.status flips in place). The guard
|
|
||||||
// `AND status = 'proposed'` makes the move one-way: an answered proposal can
|
|
||||||
// never be answered again.
|
|
||||||
//
|
|
||||||
// AcceptProposedRoutine flips status to 'accepted', links a reminder_id.
|
// AcceptProposedRoutine flips status to 'accepted', links a reminder_id.
|
||||||
// Returns error if not in 'proposed' status.
|
// Returns error if not in 'proposed' status.
|
||||||
//
|
|
||||||
// TODO(vikunja#46): the /routines page calls this through ipc to flip status
|
|
||||||
// from the authed surface.
|
|
||||||
func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
|
func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
|
||||||
res, err := s.db.ExecContext(ctx,
|
res, err := s.db.ExecContext(ctx,
|
||||||
`UPDATE proposed_routines SET status = 'accepted', reminder_id = ? WHERE id = ? AND status = 'proposed'`,
|
`UPDATE proposed_routines SET status = 'accepted', reminder_id = ? WHERE id = ? AND status = 'proposed'`,
|
||||||
|
|||||||
@@ -131,96 +131,6 @@ func TestListProposedRoutines(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, 1); !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)
|
|
||||||
}
|
|
||||||
remID, err := s.CreateReminder(ctx, now.Add(4*24*time.Hour), `{"text":"water plants"}`, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateReminder: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.AcceptProposedRoutine(ctx, keep, remID); 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) {
|
func TestLookupMissingProposedRoutine(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
Reference in New Issue
Block a user