Files
xarmian ca184cff96 feat(needs): sort=importance|simplicity with a score-keyed cursor (TASK-3959, part 2) (#2274)
* feat(needs): sort=importance|simplicity with a score-keyed cursor (TASK-3959, part 2)

GET /me/needs takes sort=importance and sort=simplicity: the primary score
then the secondary, both highest first, then the item id; a row a scorer left
unscored sorts last. The cursor carries the key as score/score2 (absent under
age, so an age cursor is unchanged), and a score cursor missing either, or
out of -1..100, is invalid_cursor. docs/api.md documents the sorts.

Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw

* fix(needs): r1 — differing-order sort test, degraded-page and missing-score docs, comments (TASK-3959, part 2 r1)

Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD

* fix(needs): r1 — range-check every cursor field, pin the as_of clamp (TASK-3959, part 2 r1)

Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD

* fix(needs): a cursor the server minted is always followed; key times clamp to int64 nanoseconds (TASK-3959, part 2 r2)

r2 found that r1's key/created_at range check (> 0, before 2200) refused
cursors the server itself mints: a pre-1970 date, or a zero time from an
unparseable stored created_at, gives a non-positive UnixNano, and paging
dead-ended at that row under every sort. A check may refuse only what the
server could never mint, so key and created_at are no longer checked.

A time outside int64 nanoseconds (a zero time, or past 2262) also did not
round-trip: UnixNano wraps, so an age cursor named a different instant from
the row's key. KeyFor now clamps both key times to that range, so the sort
and the cursor see the same instant.

The table drops the key/created_at 400 rows, keeps negative values as
followed, and adds the age-cursor-with-a-score rows (r2 L2). A new test pages
pre-1970, zero, unparseable and year-3000 boundary rows through every sort
at limits 1 and 2.

Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD

* fix(needs): the cursor docs say only what the server could never mint is refused; the odd-time test runs on Postgres (TASK-3959, part 2 r3)

r3 (both reviewers): docs/api.md still listed the key/created_at range
check removed in r2. The sentence now lists exactly what is refused, says the
key times are positions that any value of follows, and says a key time
outside about 1677 to 2262 is clamped, so such rows tie on it (hunter L3).

The odd-boundary test wrote created_at through the raw *sql.DB with a ?
placeholder, which is not rewritten for Postgres; it now writes literal SQL,
as setCreated does, and passes on both backends. It reports each sort with
Errorf, and its comment says what each sort proves: every sort that the
cursor's key times are followed, and only age that the clamp holds (r3 M2).

Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD

* fix(needs): the cursor docs list the version check; the real-scorer sort test runs on both backends (TASK-3959, part 2 r4)

r4 hunter L1: the sentence listing exactly what a cursor check refuses left
out a cursor of another version (v). r4 hunter L2: the test that sorts by the
real scorers through the router ran on SQLite only; it now runs under
needsBackends, and passes on Postgres.

Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD
2026-10-21 12:38:23 -04:00

146 lines
6.9 KiB
Go

package needs
import (
"strings"
"testing"
"time"
)
type fakeElig map[string]bool
func (f fakeElig) Eligible(userID, _, _ string) bool { return f[userID] }
func TestResolve_Order(t *testing.T) {
t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
day := 24 * time.Hour
mention := func(u string, d int) Mention {
return Mention{UserID: u, CommentID: "c-" + u, By: "Commenter-" + u, At: t0.Add(time.Duration(d) * day)}
}
flagged := t0.Add(5 * day)
cases := []struct {
name string
cand Candidate
owners []string
elig fakeElig
wantRule string
want []string
waiting time.Time
}{
{"asked beats mentioned", Candidate{AwaitsHuman: true, AssigneeUserID: "u-asg", CreatedAt: t0, Mentions: []Mention{mention("u-men", 1)}},
[]string{"u-own"}, fakeElig{"u-asg": true, "u-men": true, "u-own": true}, RuleAsked, []string{"u-asg"}, t0},
{"mentioned beats assigned", Candidate{AssigneeUserID: "u-asg", CreatedAt: t0, AssignedAt: t0, Mentions: []Mention{mention("u-men", 2)}},
[]string{"u-own"}, fakeElig{"u-asg": true, "u-men": true, "u-own": true}, RuleMentioned, []string{"u-men"}, t0.Add(2 * day)},
{"assigned beats owners", Candidate{AssigneeUserID: "u-asg", CreatedAt: t0, AssignedAt: t0.Add(3 * day)},
[]string{"u-own"}, fakeElig{"u-asg": true, "u-own": true}, RuleAssigned, []string{"u-asg"}, t0.Add(3 * day)},
{"owners are the last resort", Candidate{CreatedAt: t0}, []string{"u-own", "u-own2"},
fakeElig{"u-own": true, "u-own2": true}, RuleAdminFallback, []string{"u-own", "u-own2"}, t0},
{"ineligible asked falls through to mentioned", Candidate{AwaitsHuman: true, AssigneeUserID: "u-asg", CreatedAt: t0, Mentions: []Mention{mention("u-men", 1)}},
[]string{"u-own"}, fakeElig{"u-men": true, "u-own": true}, RuleMentioned, []string{"u-men"}, t0.Add(day)},
{"an awaits item with no assignee is not asked", Candidate{AwaitsHuman: true, CreatedAt: t0},
[]string{"u-own"}, fakeElig{"u-own": true}, RuleAdminFallback, []string{"u-own"}, t0},
{"a role-only assignee falls through", Candidate{AssigneeUserID: "", CreatedAt: t0},
[]string{"u-own"}, fakeElig{"u-own": true}, RuleAdminFallback, []string{"u-own"}, t0},
{"only the winning rule's people", Candidate{CreatedAt: t0, Mentions: []Mention{mention("a", 1), mention("b", 2), mention("x", 3)}, AssigneeUserID: "u-asg"},
[]string{"u-own"}, fakeElig{"a": true, "b": true, "u-asg": true, "u-own": true}, RuleMentioned, []string{"a", "b"}, t0.Add(day)},
{"needs_human moves WaitingSince to the earlier claim", Candidate{AssigneeUserID: "u-asg", CreatedAt: t0, AssignedAt: t0.Add(30 * day), NeedsHuman: &flagged},
nil, fakeElig{"u-asg": true}, RuleAssigned, []string{"u-asg"}, flagged},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := Resolve(tc.cand, tc.owners, tc.elig)
if !ok {
t.Fatal("dropped")
}
if got.Rule != tc.wantRule || strings.Join(got.People, ",") != strings.Join(tc.want, ",") {
t.Fatalf("rule %q people %v, want %q %v", got.Rule, got.People, tc.wantRule, tc.want)
}
// The row a person gets is theirs alone: read the first routed person's.
got = got.For(tc.want[0])
if !got.WaitingSince.Equal(tc.waiting) {
t.Fatalf("WaitingSince %v, want %v", got.WaitingSince, tc.waiting)
}
if got.Why.Rule != tc.wantRule || got.Why.Text == "" || got.Why.At == nil {
t.Fatalf("why = %+v", got.Why)
}
for _, skipped := range []string{"u-asg", "u-own", "u-men"} {
if !tc.elig[skipped] && strings.Contains(got.Why.Text, skipped) {
t.Fatalf("why.text names a skipped user %q: %q", skipped, got.Why.Text)
}
}
})
}
t.Run("no eligible person drops the item", func(t *testing.T) {
c := Candidate{AwaitsHuman: true, AssigneeUserID: "u-asg", Mentions: []Mention{mention("u-men", 1)}}
if r, ok := Resolve(c, []string{"u-own"}, fakeElig{}); ok {
t.Fatalf("an item nobody may see was routed to %v", r.People)
}
})
t.Run("because lists the claims", func(t *testing.T) {
got, _ := Resolve(Candidate{AwaitsHuman: true, AssigneeUserID: "u-asg", CreatedAt: t0, NeedsHuman: &flagged}, nil, fakeElig{"u-asg": true})
if len(got.Because) != 2 || got.Because[0].Kind != BecauseHumanTask || got.Because[1].Kind != BecauseNeedsHuman {
t.Fatalf("because = %+v", got.Because)
}
m, _ := Resolve(Candidate{Mentions: []Mention{mention("u-men", 1)}}, nil, fakeElig{"u-men": true})
m = m.For("u-men")
if m.Why.By != "Commenter-u-men" || m.Why.CommentID != "c-u-men" || m.Because[0].Kind != BecauseMention {
t.Fatalf("mention why/because = %+v %+v", m.Why, m.Because)
}
})
}
// Hunter r1 F1: a mentioned person's row is computed from their own mentions
// only; another person's mention must not reach it.
func TestResolve_MentionedRowIsPerPerson(t *testing.T) {
t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
day := 24 * time.Hour
flagged := t0.Add(90 * day)
c := Candidate{CreatedAt: t0, NeedsHuman: &flagged, Mentions: []Mention{
{UserID: "bob", CommentID: "c-bob", By: "Y", At: t0.Add(1 * day)},
{UserID: "alice", CommentID: "c-alice", By: "X", At: t0.Add(5 * day)},
}}
rt, ok := Resolve(c, nil, fakeElig{"alice": true, "bob": true})
if !ok || !rt.Includes("alice") || !rt.Includes("bob") {
t.Fatalf("routing = %+v ok=%v", rt, ok)
}
a := rt.For("alice")
if !a.WaitingSince.Equal(t0.Add(5*day)) || a.Why.By != "X" || a.Why.CommentID != "c-alice" || !a.Why.At.Equal(t0.Add(5*day)) {
t.Fatalf("alice's why/waiting = %+v %v", a.Why, a.WaitingSince)
}
if len(a.Because) != 2 || a.Because[0].CommentID != "c-alice" || a.Because[0].By != "X" || a.Because[1].Kind != BecauseNeedsHuman {
t.Fatalf("alice's because = %+v", a.Because)
}
for _, b := range a.Because {
if b.By == "Y" || b.CommentID == "c-bob" {
t.Fatalf("alice's row carries bob's mention: %+v", b)
}
}
if b := rt.For("bob"); !b.WaitingSince.Equal(t0.Add(day)) || b.Why.By != "Y" {
t.Fatalf("bob's row = %+v %v", b.Why, b.WaitingSince)
}
}
func TestCursor(t *testing.T) {
asOf := time.Date(2024, 5, 1, 12, 0, 0, 0, time.UTC)
r := &Row{ItemID: "i1", WaitingSince: asOf.Add(-time.Hour), Item: ItemInfo{CreatedAt: asOf.Add(-2 * time.Hour)}}
tok := NewCursor(SortAge, "active", "", asOf, r).Encode()
c, err := DecodeCursor(tok, SortAge, "active", "")
if err != nil || c.Key() != KeyOf(r) || !c.At().Equal(asOf) {
t.Fatalf("round trip: %+v %v", c, err)
}
for name, args := range map[string][3]string{"view": {SortAge, "hidden", ""}, "ws": {SortAge, "active", "other"}, "sort": {"x", "active", ""}} {
if _, err := DecodeCursor(tok, args[0], args[1], args[2]); err != ErrInvalidCursor {
t.Fatalf("a cursor for another %s was accepted", name)
}
}
for _, bad := range []string{"", "!!", "e30", "bm90LWpzb24"} {
if _, err := DecodeCursor(bad, SortAge, "active", ""); err != ErrInvalidCursor {
t.Fatalf("%q accepted", bad)
}
}
a, b := Key{Waiting: asOf, Created: asOf, ItemID: "a"}, Key{Waiting: asOf, Created: asOf, ItemID: "b"}
if !a.Less(b) || b.Less(a) || a.Less(a) {
t.Fatal("ties must break by item id")
}
}