package beads
import (
"context"
"errors"
"fmt"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)
// --- fixtures ----------------------------------------------------------------
// readyNow is the instant the aggregation's clock is pinned to. Nothing about it
// is special beyond being fixed: the TTL is a function of the fixture rather
// than of when the suite ran.
var readyNow = time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
// fakeReadyDB is one database behind the ReadyOpener seam, counting what each
// call actually read. The counters are the point: the head-hash gate is a claim
// about reads not happening, and only a counter can check it — a timing
// measurement would pass on a fast machine whatever the code did.
type fakeReadyDB struct {
branches []browse.Branch
tables []browse.TableInfo
rows map[string]*browse.RowPage
commits []browse.CommitInfo
openErr error
branchesErr error
logErr error
opens int
closes int
rowReads int
tableReads int
logReads int
}
func (f *fakeReadyDB) Branches(context.Context) ([]browse.Branch, error) {
if f.branchesErr != nil {
return nil, f.branchesErr
}
return f.branches, nil
}
func (f *fakeReadyDB) Tables(context.Context, string) ([]browse.TableInfo, error) {
f.tableReads++
return f.tables, nil
}
func (f *fakeReadyDB) Log(_ context.Context, _, _ string, _ int) ([]browse.CommitInfo, string, error) {
f.logReads++
if f.logErr != nil {
return nil, "", f.logErr
}
return f.commits, "", nil
}
func (f *fakeReadyDB) Rows(_ context.Context, _, table string, _, _ int) (*browse.RowPage, error) {
f.rowReads++
if p, ok := f.rows[table]; ok {
return p, nil
}
return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table)
}
func (f *fakeReadyDB) Close() error { f.closes++; return nil }
// readyInstance is a set of databases addressed by repository id, plus the
// opener over them.
type readyInstance struct {
dbs map[int]*fakeReadyDB
}
func (in *readyInstance) open(_ context.Context, d ReadyDatabase) (ReadySession, error) {
f, ok := in.dbs[d.ID]
if !ok {
return nil, fmt.Errorf("no such database: %s", d.Slug())
}
if f.openErr != nil {
return nil, f.openErr
}
f.opens++
return f, nil
}
// readyTracker builds a beads database whose issues page carries the given rows.
// The column order puts is_blocked, is_template and ephemeral last so the
// projection's name-based (not positional) cell mapping is exercised.
func readyTracker(head string, issues [][]string, deps [][]string) *fakeReadyDB {
return &fakeReadyDB{
branches: []browse.Branch{{Name: "main", Head: head}},
tables: beadsTables(),
commits: []browse.CommitInfo{{
Hash: head, Author: "Eugene Blikh", Date: readyNow.Add(-4 * time.Minute),
Message: "bd: create (auto-commit)",
}},
rows: map[string]*browse.RowPage{
"issues": {
Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "is_blocked", "is_template", "ephemeral"},
Rows: issues,
Total: len(issues),
},
"dependencies": {
Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
Rows: deps,
Total: len(deps),
},
},
}
}
// readyFixture is two trackers: one with three ready issues among six, one with
// a single ready issue.
func readyFixture() (*readyInstance, []ReadyDatabase) {
alpha := readyTracker("h-alpha", [][]string{
{"a-1", "Ready, top priority", "open", "0", "task", "alice", "0", "0", "0"},
{"a-2", "Ready, lower", "open", "2", "bug", "bob", "0", "0", "0"},
{"a-3", "Ready, same priority as a-2", "open", "2", "task", "alice", "0", "0", "0"},
{"a-4", "In progress, not ready", "in_progress", "0", "task", "alice", "0", "0", "0"},
{"a-5", "Blocked, not ready", "open", "0", "task", "bob", "1", "0", "0"},
{"a-6", "Closed, not ready", "closed", "0", "task", "bob", "0", "0", "0"},
}, nil)
beta := readyTracker("h-beta", [][]string{
{"b-1", "The one ready thing", "open", "1", "task", "carol", "0", "0", "0"},
{"b-2", "A template scaffold", "open", "0", "task", "carol", "0", "1", "0"},
{"b-3", "An ephemeral scratch", "open", "0", "task", "carol", "0", "0", "1"},
}, nil)
in := &readyInstance{dbs: map[int]*fakeReadyDB{1: alpha, 2: beta}}
return in, []ReadyDatabase{
{ID: 1, OwnerName: "alice", Name: "alpha"},
{ID: 2, OwnerName: "bob", Name: "beta"},
}
}
func across(in *readyInstance, dbs []ReadyDatabase, cache *ReadyCache, f ReadyFilter, now time.Time) *ReadyView {
return ReadyAcross(context.Background(), dbs, in.open, cache, f, now)
}
// --- tests -------------------------------------------------------------------
// The projection: which issues are ready, how the groups are ordered, how the
// cards inside one are, and that each group carries its own database's head.
func TestReadyAcrossGroupsAndOrders(t *testing.T) {
in, dbs := readyFixture()
view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)
require.Len(t, view.Groups, 2)
assert.Equal(t, 4, view.Total)
assert.Equal(t, 2, view.Considered)
assert.False(t, view.Capped)
assert.Empty(t, view.Failed)
// Groups: ready count desc, then name.
assert.Equal(t, "alice/alpha", view.Groups[0].Database.Slug())
assert.Equal(t, "bob/beta", view.Groups[1].Database.Slug())
// Cards: priority, then id. In-progress, blocked, closed, template and
// ephemeral issues are not ready.
var ids []string
for _, c := range view.Groups[0].Cards {
ids = append(ids, c.ID)
assert.True(t, c.Ready, "%s must be marked ready", c.ID)
}
assert.Equal(t, []string{"a-1", "a-2", "a-3"}, ids)
require.Len(t, view.Groups[1].Cards, 1)
assert.Equal(t, "b-1", view.Groups[1].Cards[0].ID)
// Each group carries its own database's head, so "ready" from a store that
// stopped receiving pushes is not claimed silently.
require.NotNil(t, view.Groups[0].Head)
assert.Equal(t, "h-alpha", view.Groups[0].Head.Hash)
assert.Equal(t, "main", view.Groups[0].Ref)
require.NotNil(t, view.Groups[1].Head)
assert.Equal(t, "h-beta", view.Groups[1].Head.Hash)
// The filter options come from the whole ready set.
assert.Equal(t, []string{"alice", "bob", "carol"}, view.Options.Assignees)
assert.Equal(t, []string{"0", "1", "2"}, view.Options.Priorities)
// Every session opened is closed again: this page opens N stores, and one
// leaked handle per request per database is the failure mode it is bounded
// against.
for id, f := range in.dbs {
assert.Equal(t, f.opens, f.closes, "database %d: %d opens, %d closes", id, f.opens, f.closes)
}
}
// A "blocks" edge to a still-open target blocks its source; the same edge to a
// closed one does not. This is the ready rule the board shares, exercised
// through the aggregation rather than restated in it.
func TestReadyAcrossHonoursOpenBlockers(t *testing.T) {
tracker := readyTracker("h1", [][]string{
{"i-1", "Blocked by an open issue", "open", "1", "task", "alice", "0", "0", "0"},
{"i-2", "The open blocker", "open", "1", "task", "alice", "0", "0", "0"},
{"i-3", "Blocked by a closed issue", "open", "1", "task", "alice", "0", "0", "0"},
{"i-4", "The closed blocker", "closed", "1", "task", "alice", "0", "0", "0"},
{"i-5", "A subtask of an open epic", "open", "1", "task", "alice", "0", "0", "0"},
{"i-6", "The epic", "open", "1", "epic", "alice", "0", "0", "0"},
}, [][]string{
{"d1", "i-1", "i-2", "blocks"},
{"d2", "i-3", "i-4", "blocks"},
{"d3", "i-5", "i-6", "parent-child"},
})
in := &readyInstance{dbs: map[int]*fakeReadyDB{1: tracker}}
dbs := []ReadyDatabase{{ID: 1, OwnerName: "alice", Name: "alpha"}}
view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)
require.Len(t, view.Groups, 1)
var ids []string
for _, c := range view.Groups[0].Cards {
ids = append(ids, c.ID)
}
// i-1 waits on an open issue; everything else is actionable — including the
// subtask, because parent-child is hierarchy and not a blocker.
assert.Equal(t, []string{"i-2", "i-3", "i-5", "i-6"}, ids)
}
// The head-hash gate: a second call with unmoved heads reads no rows, no tables
// and no log. Asserted by counting reads on the fake, never by timing.
func TestReadyAcrossHeadHashGateSkipsEveryRead(t *testing.T) {
in, dbs := readyFixture()
cache := NewReadyCache()
first := across(in, dbs, cache, ReadyFilter{}, readyNow)
require.Len(t, first.Groups, 2)
alpha := in.dbs[1]
firstReads := alpha.rowReads
require.Greater(t, firstReads, 0, "the first call must read rows")
second := across(in, dbs, cache, ReadyFilter{}, readyNow.Add(30*time.Second))
assert.Equal(t, firstReads, alpha.rowReads, "a second call with an unmoved head must read no rows")
assert.Equal(t, 1, alpha.tableReads, "nor list tables again")
assert.Equal(t, 1, alpha.logReads, "nor read the log again")
// The session is still opened and its branches listed — that is what the gate
// is gated on, and it is the cheap half.
assert.Equal(t, 2, alpha.opens)
assert.Equal(t, 2, alpha.closes)
// And the answer is the same one.
assert.Equal(t, first.Total, second.Total)
require.Len(t, second.Groups, 2)
assert.Equal(t, first.Groups[0].Cards, second.Groups[0].Cards)
require.NotNil(t, second.Groups[0].Head)
assert.Equal(t, "h-alpha", second.Groups[0].Head.Hash)
}
// A head that moved is a projection that no longer stands: the rows are read
// again even well inside the TTL.
func TestReadyAcrossMovedHeadForcesAReread(t *testing.T) {
in, dbs := readyFixture()
cache := NewReadyCache()
alpha := in.dbs[1]
across(in, dbs, cache, ReadyFilter{}, readyNow)
firstReads := alpha.rowReads
alpha.branches = []browse.Branch{{Name: "main", Head: "h-alpha-2"}}
across(in, dbs, cache, ReadyFilter{}, readyNow.Add(time.Second))
assert.Greater(t, alpha.rowReads, firstReads, "a moved head must be re-read")
// The other database's head did not move, so it was not re-read.
assert.Equal(t, 1, in.dbs[2].tableReads)
}
// The TTL: past it the projection is re-read even though the head has not
// moved, so a cache can never be the reason a reader sees yesterday's answer.
func TestReadyAcrossTTLExpiryForcesAReread(t *testing.T) {
in, dbs := readyFixture()
cache := NewReadyCache()
alpha := in.dbs[1]
across(in, dbs, cache, ReadyFilter{}, readyNow)
firstReads := alpha.rowReads
// One tick short of the TTL: still cached.
across(in, dbs, cache, ReadyFilter{}, readyNow.Add(ReadyCacheTTL-time.Nanosecond))
assert.Equal(t, firstReads, alpha.rowReads, "inside the TTL the projection stands")
// At the TTL: read again.
across(in, dbs, cache, ReadyFilter{}, readyNow.Add(ReadyCacheTTL))
assert.Greater(t, alpha.rowReads, firstReads, "at the TTL the projection is re-read")
}
// The ceiling: more candidates than ReadyMaxDatabases and the view says it was
// capped, having opened exactly the ceiling's worth of stores.
func TestReadyAcrossCeiling(t *testing.T) {
in := &readyInstance{dbs: map[int]*fakeReadyDB{}}
var dbs []ReadyDatabase
for i := 1; i <= ReadyMaxDatabases+3; i++ {
in.dbs[i] = readyTracker(fmt.Sprintf("h%d", i), [][]string{
{fmt.Sprintf("t%d-1", i), "Ready", "open", "1", "task", "alice", "0", "0", "0"},
}, nil)
dbs = append(dbs, ReadyDatabase{ID: i, OwnerName: "alice", Name: fmt.Sprintf("db%02d", i)})
}
view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)
assert.True(t, view.Capped, "a page that hit the ceiling must say so")
assert.Equal(t, ReadyMaxDatabases, view.Considered)
assert.Equal(t, ReadyMaxDatabases, view.Max)
assert.Len(t, view.Groups, ReadyMaxDatabases)
// The three past the ceiling were never opened.
for i := ReadyMaxDatabases + 1; i <= ReadyMaxDatabases+3; i++ {
assert.Zero(t, in.dbs[i].opens, "database %d is past the ceiling", i)
}
}
// A database named by ?db= is never dropped by the ceiling: the filter narrows
// the candidates first.
func TestReadyAcrossCeilingAppliesAfterTheDatabaseFilter(t *testing.T) {
in := &readyInstance{dbs: map[int]*fakeReadyDB{}}
var dbs []ReadyDatabase
for i := 1; i <= ReadyMaxDatabases+3; i++ {
in.dbs[i] = readyTracker(fmt.Sprintf("h%d", i), [][]string{
{fmt.Sprintf("t%d-1", i), "Ready", "open", "1", "task", "alice", "0", "0", "0"},
}, nil)
dbs = append(dbs, ReadyDatabase{ID: i, OwnerName: "alice", Name: fmt.Sprintf("db%02d", i)})
}
last := dbs[len(dbs)-1]
view := across(in, dbs, NewReadyCache(), ReadyFilter{Databases: []string{last.Slug()}}, readyNow)
assert.False(t, view.Capped)
assert.Equal(t, 1, view.Considered)
require.Len(t, view.Groups, 1)
assert.Equal(t, last.Slug(), view.Groups[0].Database.Slug())
}
// A database that cannot be opened, or whose rows cannot be read, costs itself
// only. The error is carried out for the caller's log and the rest of the answer
// stands.
func TestReadyAcrossFailingDatabaseCostsItselfOnly(t *testing.T) {
in, dbs := readyFixture()
in.dbs[1].openErr = errors.New("browse: open store: no such file or directory")
view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)
require.Len(t, view.Failed, 1)
assert.Equal(t, "alice/alpha", view.Failed[0].Database.Slug())
assert.ErrorContains(t, view.Failed[0].Err, "no such file")
require.Len(t, view.Groups, 1)
assert.Equal(t, "bob/beta", view.Groups[0].Database.Slug())
// A store that opens but cannot list its branches fails the same way.
in2, dbs2 := readyFixture()
in2.dbs[1].branchesErr = errors.New("browse: list branches: corrupt chunk")
view2 := across(in2, dbs2, NewReadyCache(), ReadyFilter{}, readyNow)
require.Len(t, view2.Failed, 1)
require.Len(t, view2.Groups, 1)
assert.Equal(t, 1, in2.dbs[1].closes, "a failing database still closes its session")
}
// A database whose tables are not a beads schema is skipped silently: no group,
// no failure, and no second table listing on the next request.
func TestReadyAcrossSkipsNonBeadsDatabases(t *testing.T) {
in, dbs := readyFixture()
plain := &fakeReadyDB{
branches: []browse.Branch{{Name: "main", Head: "h-plain"}},
tables: []browse.TableInfo{{Name: "measurements", Columns: []browse.ColumnInfo{{Name: "id"}}}},
}
in.dbs[3] = plain
dbs = append(dbs, ReadyDatabase{ID: 3, OwnerName: "carol", Name: "sensors"})
cache := NewReadyCache()
view := across(in, dbs, cache, ReadyFilter{}, readyNow)
assert.Len(t, view.Groups, 2)
assert.Empty(t, view.Failed)
assert.Zero(t, plain.rowReads, "a non-beads database is never read for rows")
across(in, dbs, cache, ReadyFilter{}, readyNow.Add(time.Second))
assert.Equal(t, 1, plain.tableReads, "the fingerprint is worth one table listing")
}
// A store with no branches at all — freshly initialised, never pushed to — is
// skipped rather than failing the page.
func TestReadyAcrossSkipsAnEmptyStore(t *testing.T) {
in := &readyInstance{dbs: map[int]*fakeReadyDB{1: {}}}
dbs := []ReadyDatabase{{ID: 1, OwnerName: "alice", Name: "fresh"}}
view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)
assert.Empty(t, view.Groups)
assert.Empty(t, view.Failed)
assert.Equal(t, 1, view.Considered)
}
// A log that cannot be read costs the group its freshness line and nothing else.
func TestReadyAcrossSurvivesAnUnreadableLog(t *testing.T) {
in, dbs := readyFixture()
in.dbs[1].logErr = errors.New("browse: walk commits: corrupt chunk")
view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)
require.Len(t, view.Groups, 2)
assert.Nil(t, view.Groups[0].Head, "no head is renderable")
assert.Len(t, view.Groups[0].Cards, 3, "the ready set is still answered")
}
func TestReadyFilters(t *testing.T) {
in, dbs := readyFixture()
t.Run("q narrows over id and title", func(t *testing.T) {
view := across(in, dbs, NewReadyCache(), ReadyFilter{Query: "TOP priority"}, readyNow)
require.Len(t, view.Groups, 1)
require.Len(t, view.Groups[0].Cards, 1)
assert.Equal(t, "a-1", view.Groups[0].Cards[0].ID)
assert.Equal(t, 1, view.Total)
})
t.Run("assignee is exact", func(t *testing.T) {
view := across(in, dbs, NewReadyCache(), ReadyFilter{Assignee: "alice"}, readyNow)
require.Len(t, view.Groups, 1, "only alpha has alice's ready work")
assert.Equal(t, "alice/alpha", view.Groups[0].Database.Slug())
assert.Equal(t, 2, view.Total)
})
t.Run("priority is exact", func(t *testing.T) {
view := across(in, dbs, NewReadyCache(), ReadyFilter{Priority: "2"}, readyNow)
require.Len(t, view.Groups, 1)
assert.Equal(t, 2, view.Total)
})
t.Run("db selects databases", func(t *testing.T) {
// Its own fixture: this one asserts that a database was never opened, and
// the shared one has been opened by every subtest above.
in, dbs := readyFixture()
view := across(in, dbs, NewReadyCache(), ReadyFilter{Databases: []string{"bob/beta"}}, readyNow)
require.Len(t, view.Groups, 1)
assert.Equal(t, "bob/beta", view.Groups[0].Database.Slug())
assert.Equal(t, 1, view.Considered, "the other database is never opened")
assert.Zero(t, in.dbs[1].opens)
})
t.Run("a filter that matches nothing empties the page", func(t *testing.T) {
view := across(in, dbs, NewReadyCache(), ReadyFilter{Assignee: "nobody"}, readyNow)
assert.Empty(t, view.Groups)
assert.Zero(t, view.Total)
// The options still list the real values, so the reader can pick another.
assert.Equal(t, []string{"alice", "bob", "carol"}, view.Options.Assignees)
})
}
func TestParseReadyFilter(t *testing.T) {
q, err := url.ParseQuery("q=+cache+&assignee=alice&priority=1&db=alice%2Falpha&db=+&db=bob%2Fbeta")
require.NoError(t, err)
f := ParseReadyFilter(q)
assert.Equal(t, "cache", f.Query)
assert.Equal(t, "alice", f.Assignee)
assert.Equal(t, "1", f.Priority)
assert.Equal(t, []string{"alice/alpha", "bob/beta"}, f.Databases, "a blank ?db= is not a database")
assert.True(t, f.Active())
assert.False(t, ParseReadyFilter(url.Values{}).Active())
}
// The cache is bounded by entry count: it is a cache and not a store, and an
// instance with more databases than the ceiling must not grow it without end.
func TestReadyCacheIsBoundedByEntryCount(t *testing.T) {
cache := NewReadyCache()
for i := 1; i <= readyCacheMaxEntries+10; i++ {
cache.store(i, fmt.Sprintf("h%d", i), readyNow, readyEntry{beads: true})
}
assert.LessOrEqual(t, cache.size(), readyCacheMaxEntries)
}