mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
ca184cff96
* 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
146 lines
6.9 KiB
Go
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")
|
|
}
|
|
}
|