package main import ( "context" "strings" "testing" "time" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/store" ) func TestParseOrdinalReadsThePosition(t *testing.T) { cases := []struct { text string want int ok bool }{ {"второй", 2, true}, {"вторую сделал", 2, true}, {"первую убери", 1, true}, {"последнюю не надо", -1, true}, {"3", 3, true}, {"the second one", 2, true}, // No position named. {"какие у меня задачи", 0, false}, {"", 0, false}, // A digit inside a time is not a position. {"напомни в 15:00", 0, false}, // Forms the stem list used to miss, and positions past its fifth. {"вторым", 2, true}, {"седьмую", 7, true}, {"одиннадцатый", 11, true}, // A spoken half hour names its hour with the same genitive ordinal, so // this is 07:30 and not the eighth thing she read out (V-522). {"напомни в половине восьмого", 0, false}, {"полвосьмого", 0, false}, // The ordinal still wins when the half word is not in front of it. {"восьмую сделал", 8, true}, } for _, c := range cases { got, ok := parseOrdinal(c.text) if ok != c.ok || (ok && got != c.want) { t.Errorf("parseOrdinal(%q) = %d,%v; want %d,%v", c.text, got, ok, c.want, c.ok) } } } func TestOrdinalPassesWithNothingOffered(t *testing.T) { h, _, _ := newClarifyHandler(t) if _, handled := h.resolveCandidate(context.Background(), "второй", sourceVoice); handled { t.Error("an ordinal with no list behind it was claimed") } } func TestOrdinalReadsBackWithoutAVerb(t *testing.T) { h, st, _ := newClarifyHandler(t) ctx := context.Background() ids := seedTasks(t, st, "купить хлеб", "позвонить маме") putCandidates(h, context.Background(), ids, "купить хлеб", "позвонить маме") reply, handled := h.resolveCandidate(ctx, "второй", sourceVoice) if !handled || !strings.Contains(reply, "позвонить маме") { t.Fatalf("a bare ordinal did not read the task back: %q handled=%v", reply, handled) } // Still live: naming one is often the first half of a sentence. if _, handled := h.resolveCandidate(ctx, "первый", sourceVoice); !handled { t.Error("the list was spent by a read-back") } } func TestOrdinalWithAVerbMovesTheTask(t *testing.T) { h, st, _ := newClarifyHandler(t) ctx := context.Background() ids := seedTasks(t, st, "купить хлеб", "позвонить маме") putCandidates(h, context.Background(), ids, "купить хлеб", "позвонить маме") reply, handled := h.resolveCandidate(ctx, "первую сделал", sourceVoice) if !handled || !strings.Contains(reply, "купить хлеб") { t.Fatalf("the pick was not acted on: %q handled=%v", reply, handled) } live, err := st.ListTasks(ctx, "live") if err != nil { t.Fatalf("list tasks: %v", err) } for _, task := range live { if task.ID == ids[0] { t.Fatalf("task %d is still live after he closed it", task.ID) } } // Spent: a second ordinal against a list that no longer holds would close // the wrong task. if _, handled := h.resolveCandidate(ctx, "второй", sourceVoice); handled { t.Error("the list survived the pick it was spent on") } } func TestOrdinalPastTheEndSaysHowMany(t *testing.T) { h, st, _ := newClarifyHandler(t) ids := seedTasks(t, st, "купить хлеб") putCandidates(h, context.Background(), ids, "купить хлеб") reply, handled := h.resolveCandidate(context.Background(), "третий", sourceVoice) if !handled || !strings.Contains(reply, "1") { t.Fatalf("a position she never read was not answered: %q handled=%v", reply, handled) } } // ordinalNow — a fixed capture time; the ranker only needs the rows to exist. var ordinalNow = time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) func seedTasks(t *testing.T, st *store.Store, texts ...string) []int64 { t.Helper() ctx := context.Background() var ids []int64 for _, text := range texts { res, err := st.CaptureTask(ctx, store.Task{ Text: text, Source: "tap:voice", Status: store.TaskOpen, CreatedTs: ordinalNow, }) if err != nil { t.Fatalf("capture task: %v", err) } ids = append(ids, res.ID) } return ids } // putCandidates binds a list to the reach the ctx names, the way queryTasks // does when she recites one. func putCandidates(h *reactiveHandler, ctx context.Context, ids []int64, labels ...string) { cands := make([]dialogue.Candidate, 0, len(ids)) for i, id := range ids { cands = append(cands, dialogue.Candidate{Kind: "task", Ref: id, Label: labels[i]}) } h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{Timestamp: h.now()}) h.offerCandidates(ctx, cands) }