7b2b96b957
A task is not a fact and not a note. A fact is a claim about the world that a correction supersedes; a note is something to recall by meaning. A task is work with a lifecycle, and the read that matters is "everything outstanding right now" — which over an append-only log would mean replaying history on every question. So: a tasks table, migration #14, statuses candidate/open/done/dropped that each move forward exactly once. Dedupe is on normalised text among LIVE rows only, via a partial unique index. That is the property the mail side needs: an extractor may call CaptureTask for every message it reads, as often as it likes, without growing the list — while a weekly errand is still capturable again once the last one is done. Three ways in, one seam. ipc.CaptureTaskReq is it: the voice path (router.ParseTaskCapture on an explicit marker — "добавь в задачи …", never "надо бы поспать"), the /tasks form, and the email reader from #246 when it exists. Mail-derived items set Source "email:<account>", Status "candidate" and Evidence to whatever makes the row reviewable; a candidate is inert until he confirms it on /tasks, and Maven names it as unconfirmed when she recites the list rather than putting words in his mouth. No new intent — the router enum is a contract with the relabelling prompt, so capture rides the note intent and the list rides a query source, both matched deterministically like the calendar and plan matchers already are. Nothing here speaks. No tick rule reads tasks; the list is answered when asked about, which is why /tasks POST is not step-up gated the way /tools and /routines are — a task write moves no boundary. Vikunja #130
222 lines
6.7 KiB
Go
222 lines
6.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestCaptureTaskDedupesLiveWork(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
|
|
|
id, created, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !created {
|
|
t.Fatal("first capture must create a row")
|
|
}
|
|
|
|
// Same work, different casing and punctuation — one task, not two.
|
|
again, created, err := st.CaptureTask(ctx, Task{Text: "Купить молоко!", Source: "email:kami", CreatedTs: now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if created {
|
|
t.Error("second capture of the same live work must not create a row")
|
|
}
|
|
if again != id {
|
|
t.Errorf("dedupe returned id %d, want the existing %d", again, id)
|
|
}
|
|
|
|
live, err := st.ListTasks(ctx, "live")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(live) != 1 {
|
|
t.Fatalf("live tasks = %d, want 1", len(live))
|
|
}
|
|
}
|
|
|
|
func TestCaptureTaskAfterDoneIsANewTask(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
|
|
|
id, _, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetTaskStatus(ctx, id, TaskDone, now.Add(time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The dedupe key is free again: a recurring errand must be capturable.
|
|
id2, created, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now.AddDate(0, 0, 7)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !created || id2 == id {
|
|
t.Fatalf("re-capture after done: created=%v id=%d (previous %d)", created, id2, id)
|
|
}
|
|
live, err := st.ListTasks(ctx, "live")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(live) != 1 || live[0].ID != id2 {
|
|
t.Fatalf("live = %+v, want only the new task %d", live, id2)
|
|
}
|
|
}
|
|
|
|
func TestCaptureTaskCandidateKeepsEvidence(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
|
due := now.Add(48 * time.Hour)
|
|
|
|
id, _, err := st.CaptureTask(ctx, Task{
|
|
Text: "продлить страховку",
|
|
Source: "email:kami",
|
|
Evidence: "Re: страховой полис истекает",
|
|
Status: TaskCandidate,
|
|
Due: &due,
|
|
Weight: 2,
|
|
CreatedTs: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := st.LookupTask(ctx, id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Status != TaskCandidate {
|
|
t.Errorf("status = %q, want candidate", got.Status)
|
|
}
|
|
if got.Evidence != "Re: страховой полис истекает" {
|
|
t.Errorf("evidence = %q", got.Evidence)
|
|
}
|
|
if got.Due == nil || !got.Due.Equal(due.UTC()) {
|
|
t.Errorf("due = %v, want %v", got.Due, due.UTC())
|
|
}
|
|
if got.Weight != 2 {
|
|
t.Errorf("weight = %d, want 2", got.Weight)
|
|
}
|
|
if got.ResolvedTs != nil {
|
|
t.Errorf("resolved_ts = %v on a live task, want nil", got.ResolvedTs)
|
|
}
|
|
}
|
|
|
|
func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
|
|
|
cand, _, err := st.CaptureTask(ctx, Task{Text: "записаться к врачу", Source: "email:kami", Status: TaskCandidate, CreatedTs: now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// candidate → done is not a legal move: he has to confirm it first.
|
|
if err := st.SetTaskStatus(ctx, cand, TaskDone, now); !errors.Is(err, ErrTaskNotFound) {
|
|
t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err)
|
|
}
|
|
if err := st.SetTaskStatus(ctx, cand, TaskOpen, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetTaskStatus(ctx, cand, TaskDone, now.Add(time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Already resolved — a second resolve must not move it again.
|
|
if err := st.SetTaskStatus(ctx, cand, TaskDropped, now.Add(2*time.Hour)); !errors.Is(err, ErrTaskNotFound) {
|
|
t.Errorf("second resolve err = %v, want ErrTaskNotFound", err)
|
|
}
|
|
got, err := st.LookupTask(ctx, cand)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Status != TaskDone {
|
|
t.Errorf("status = %q, want done", got.Status)
|
|
}
|
|
if got.ResolvedTs == nil || !got.ResolvedTs.Equal(now.Add(time.Hour).UTC()) {
|
|
t.Errorf("resolved_ts = %v, want %v", got.ResolvedTs, now.Add(time.Hour).UTC())
|
|
}
|
|
}
|
|
|
|
func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
id, _, err := st.CaptureTask(ctx, Task{Text: "что-то", Source: "tap:web"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetTaskStatus(ctx, id, "candidate", time.Now()); !errors.Is(err, ErrTaskStatus) {
|
|
t.Errorf("→candidate err = %v, want ErrTaskStatus", err)
|
|
}
|
|
if err := st.SetTaskStatus(ctx, id, "urgent", time.Now()); !errors.Is(err, ErrTaskStatus) {
|
|
t.Errorf("→urgent err = %v, want ErrTaskStatus", err)
|
|
}
|
|
}
|
|
|
|
func TestCaptureTaskRejectsEmptyText(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
if _, _, err := st.CaptureTask(ctx, Task{Text: " ", Source: "tap:voice"}); !errors.Is(err, ErrTaskEmpty) {
|
|
t.Errorf("err = %v, want ErrTaskEmpty", err)
|
|
}
|
|
}
|
|
|
|
func TestListTasksFiltersByStatus(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
|
|
|
open1, _, _ := st.CaptureTask(ctx, Task{Text: "первая", Source: "tap:voice", CreatedTs: now})
|
|
_, _, _ = st.CaptureTask(ctx, Task{Text: "вторая", Source: "email:kami", Status: TaskCandidate, CreatedTs: now.Add(time.Minute)})
|
|
done, _, _ := st.CaptureTask(ctx, Task{Text: "третья", Source: "tap:voice", CreatedTs: now.Add(2 * time.Minute)})
|
|
if err := st.SetTaskStatus(ctx, done, TaskDone, now.Add(time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cands, err := st.ListTasks(ctx, TaskCandidate)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(cands) != 1 || cands[0].Text != "вторая" {
|
|
t.Fatalf("candidates = %+v", cands)
|
|
}
|
|
opens, err := st.ListTasks(ctx, TaskOpen)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(opens) != 1 || opens[0].ID != open1 {
|
|
t.Fatalf("open = %+v", opens)
|
|
}
|
|
all, err := st.ListTasks(ctx, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(all) != 3 {
|
|
t.Fatalf("all = %d, want 3", len(all))
|
|
}
|
|
// Newest first.
|
|
if all[0].Text != "третья" {
|
|
t.Errorf("first = %q, want newest ('третья')", all[0].Text)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTaskText(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"Купить молоко!", "купить молоко"},
|
|
{" купить МОЛОКО ", "купить молоко"},
|
|
{"позвонить в банк (важно)", "позвонить в банк важно"},
|
|
{"", ""},
|
|
}
|
|
for _, c := range cases {
|
|
if got := NormalizeTaskText(c.in); got != c.want {
|
|
t.Errorf("NormalizeTaskText(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|