52a4772962
Group 1 of the sweep listed cmd/mavend/ordinal.go, and it was still
picking a position by stem prefix: {"перв", 1}, {"втор", 2}. The lexicon
already carries every form with its position and "последний" as -1, up to
twelve rather than five, so parseOrdinal reads that instead. "вторым" and
"седьмую" were missed before and now land.
A wider set opens one hole the stems did not have. Russian names a half
hour with the genitive ordinal of the hour it is entering, so "в половине
восьмого" would read as the eighth thing she read out. The forms of
"половина" move into the lexicon as half_hour, where the clock rewrite in
internal/router/halfpast.go and this refusal read one copy, and
parseOrdinal skips an ordinal standing behind one.
Six new parseOrdinal cases. cmd/mavend, internal/router, internal/lexicon
and internal/calendar all pass.
141 lines
4.7 KiB
Go
141 lines
4.7 KiB
Go
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)
|
|
}
|