package beads
import (
"context"
"fmt"
"net/url"
"sort"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)
// --- fixtures ----------------------------------------------------------------
// fakeSession is a BrowseSession over canned per-table pages — the whole seam
// this package reads through. A table absent from rowsByTable is reported as
// ErrTableNotFound, mirroring the store, so the optional-table degradation in
// readRowsOptional is exercised rather than assumed.
type fakeSession struct {
rowsByTable map[string]*browse.RowPage
}
func (s *fakeSession) Rows(_ context.Context, _, table string, _, _ int) (*browse.RowPage, error) {
if p, ok := s.rowsByTable[table]; ok {
return p, nil
}
return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table)
}
// beadsTables is a schema fingerprint that Applies should accept: issues (with
// id + status) + dependencies both present.
func beadsTables() []browse.TableInfo {
return []browse.TableInfo{
{Name: "issues", Columns: []browse.ColumnInfo{
{Name: "id", PrimaryKey: true}, {Name: "status"},
}},
{Name: "dependencies", Columns: []browse.ColumnInfo{{Name: "id", PrimaryKey: true}}},
{Name: "labels"},
}
}
// beadsFixture wires a fakeSession whose per-table Rows model a small parade:
// - i-open : open, ready → Lined Up
// - i-prog : in_progress → Rolling
// - i-done : closed → Past Stand
// - i-blocked: open, blocked by i-open (a "blocks" dep to a non-closed target)
// and also carries is_blocked=1 → Stalled
//
// The issues page deliberately orders its columns id,title,status,priority,...
// with is_blocked LAST so column-name mapping (not positional) is exercised.
func beadsFixture() *fakeSession {
issues := &browse.RowPage{
// Column order chosen so nothing is at a "natural" index; is_blocked is last
// and close_reason sits mid-row so name (not positional) mapping is exercised.
Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "closed_at", "close_reason", "is_blocked"},
Rows: [][]string{
{"i-open", "Ready to roll", "open", "1", "feature", "alice", "2024-01-01", "NULL", "NULL", "0"},
{"i-prog", "Under way", "in_progress", "0", "bug", "bob", "2024-01-02", "NULL", "NULL", "0"},
{"i-done", "Finished", "closed", "2", "chore", "carol", "2024-01-03", "2024-01-04", "Fixed in commit abc123", "0"},
{"i-blocked", "Waiting", "open", "1", "feature", "dave", "2024-01-04", "NULL", "NULL", "1"},
},
Total: 4,
}
// i-blocked depends on i-open (blocks, target open → keeps it Stalled).
// i-open is depended on by i-blocked → i-open.Blocks == 1.
deps := &browse.RowPage{
Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
Rows: [][]string{
{"d1", "i-blocked", "i-open", "blocks"},
},
Total: 1,
}
labels := &browse.RowPage{
Columns: []string{"issue_id", "label"},
Rows: [][]string{
{"i-open", "backend"},
{"i-open", "urgent"},
},
Total: 2,
}
statuses := &browse.RowPage{
Columns: []string{"name", "category"},
Rows: [][]string{
{"open", "open"},
{"in_progress", "in_progress"},
{"closed", "closed"},
},
Total: 3,
}
comments := &browse.RowPage{
Columns: []string{"issue_id", "author", "text", "created_at"},
Rows: [][]string{
{"i-open", "alice", "first!", "2024-01-05"},
{"i-prog", "bob", "not this one", "2024-01-06"},
},
Total: 2,
}
// i-done's closure is recorded as a `closed` audit event carrying the reason
// (the only place the reason now surfaces — there is no standalone block).
events := &browse.RowPage{
Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
Rows: [][]string{
{"e1", "i-done", "closed", "carol", "NULL", "Fixed in commit abc123", "NULL", "2024-01-03 12:00:00"},
},
Total: 1,
}
return &fakeSession{
rowsByTable: map[string]*browse.RowPage{
"issues": issues,
"dependencies": deps,
"labels": labels,
"custom_statuses": statuses,
"comments": comments,
"events": events,
},
}
}
// laneBySlug finds a lane in a built board by its slug.
func laneBySlug(d *Data, slug string) *Lane {
for i := range d.Lanes {
if d.Lanes[i].Slug == slug {
return &d.Lanes[i]
}
}
return nil
}
// cardIDs lists the ids of a lane's cards.
func cardIDs(l *Lane) []string {
if l == nil {
return nil
}
out := make([]string, len(l.Issues))
for i, c := range l.Issues {
out[i] = c.ID
}
return out
}
// --- Applies -----------------------------------------------------------------
func TestBeadsApplies(t *testing.T) {
assert.True(t, Applies(beadsTables()),
"Applies should be true when issues+dependencies (with id+status) present")
// Missing dependencies → not a beads DB.
assert.False(t, Applies([]browse.TableInfo{
{Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}, {Name: "status"}}},
}), "Applies should be false without a dependencies table")
// issues present but lacking status column → guard rejects.
assert.False(t, Applies([]browse.TableInfo{
{Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}}},
{Name: "dependencies"},
}), "Applies should be false when issues lacks a status column")
// Unrelated schema.
assert.False(t, Applies([]browse.TableInfo{{Name: "widgets"}}),
"Applies should be false for an unrelated schema")
}
// --- board mode --------------------------------------------------------------
func TestBeadsBuildBoardLanes(t *testing.T) {
d, err := Build(context.Background(), beadsFixture(), "main", url.Values{})
require.NoError(t, err)
require.Equal(t, "board", d.Mode)
checks := map[string][]string{
"rolling": {"i-prog"},
"lined-up": {"i-open"},
"stalled": {"i-blocked"},
"past-stand": {"i-done"},
}
for slug, want := range checks {
assert.Equal(t, want, cardIDs(laneBySlug(d, slug)), "lane %s", slug)
}
assert.Equal(t, Counts{Rolling: 1, LinedUp: 1, Stalled: 1, PastStand: 1, Total: 4}, d.Counts)
assert.Equal(t, 4, d.Total)
}
func TestBeadsBuildBoardCounts(t *testing.T) {
d, err := Build(context.Background(), beadsFixture(), "main", url.Values{})
require.NoError(t, err)
// i-blocked depends on i-open → i-blocked.BlockedBy==1, i-open.Blocks==1.
blocked := laneBySlug(d, "stalled").Issues[0]
assert.Equal(t, "i-blocked", blocked.ID)
assert.Equal(t, 1, blocked.BlockedBy)
assert.Equal(t, 0, blocked.Blocks)
open := laneBySlug(d, "lined-up").Issues[0]
assert.Equal(t, "i-open", open.ID)
assert.Equal(t, 1, open.Blocks)
assert.Equal(t, 0, open.BlockedBy)
// Labels attach by issue_id.
assert.Equal(t, []string{"backend", "urgent"}, open.Labels)
}
// boardIDs returns every card id on the board, across all lanes.
func boardIDs(d *Data) []string {
var out []string
for i := range d.Lanes {
out = append(out, cardIDs(&d.Lanes[i])...)
}
sort.Strings(out)
return out
}
func TestBeadsBoardFilters(t *testing.T) {
cases := []struct {
name string
query url.Values
want []string // sorted ids expected on the board
}{
{"type", url.Values{"type": {"feature"}}, []string{"i-blocked", "i-open"}},
{"priority", url.Values{"priority": {"1"}}, []string{"i-blocked", "i-open"}},
{"assignee", url.Values{"assignee": {"bob"}}, []string{"i-prog"}},
{"label", url.Values{"label": {"urgent"}}, []string{"i-open"}},
{"query-title", url.Values{"q": {"ready"}}, []string{"i-open"}},
{"query-id", url.Values{"q": {"i-done"}}, []string{"i-done"}},
{"combined-empty", url.Values{"type": {"feature"}, "assignee": {"bob"}}, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
d, err := Build(context.Background(), beadsFixture(), "main", tc.query)
require.NoError(t, err)
assert.Equal(t, tc.want, boardIDs(d))
assert.Equal(t, len(tc.want), d.Total)
assert.Equal(t, len(tc.want), d.Counts.Total)
assert.True(t, d.Filter.Active(), "Filter.Active() should be true when a filter is set")
})
}
}
func TestBeadsBoardFilterOptions(t *testing.T) {
d, err := Build(context.Background(), beadsFixture(), "main", url.Values{})
require.NoError(t, err)
o := d.FilterOpts
assert.Equal(t, []string{"bug", "chore", "feature"}, o.Types)
assert.Equal(t, []string{"0", "1", "2"}, o.Priorities)
assert.Equal(t, []string{"alice", "bob", "carol", "dave"}, o.Assignees)
assert.Equal(t, []string{"backend", "urgent"}, o.Labels)
assert.False(t, d.Filter.Active(), "no query set, Filter.Active() should be false")
}
func TestBeadsReady(t *testing.T) {
d, err := Build(context.Background(), beadsFixture(), "main", url.Values{})
require.NoError(t, err)
ready := map[string]bool{}
for i := range d.Lanes {
for _, c := range d.Lanes[i].Issues {
ready[c.ID] = c.Ready
}
}
// Only i-open is actionable: open + unblocked. i-prog is in-progress, i-done
// closed, i-blocked blocked.
assert.True(t, ready["i-open"], "i-open should be ready")
for _, id := range []string{"i-prog", "i-done", "i-blocked"} {
assert.False(t, ready[id], "%s should not be ready", id)
}
// The ready filter narrows the board to the actionable set.
f, err := Build(context.Background(), beadsFixture(), "main", url.Values{"ready": {"1"}})
require.NoError(t, err)
assert.Equal(t, []string{"i-open"}, boardIDs(f))
}
func TestBeadsDepTree(t *testing.T) {
// Chain: a --blocks--> b --blocks--> c (c closed). a depends on b depends on c.
sess := &fakeSession{
rowsByTable: map[string]*browse.RowPage{
"issues": {
Columns: []string{"id", "title", "status"},
Rows: [][]string{{"a", "Aye", "open"}, {"b", "Bee", "open"}, {"c", "Cee", "closed"}},
Total: 3,
},
"dependencies": {
Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
Rows: [][]string{{"d1", "a", "b", "blocks"}, {"d2", "b", "c", "blocks"}},
Total: 2,
},
},
}
// From a: transitive prerequisites b(0) → c(1); c is closed.
d, err := Build(context.Background(), sess, "main", url.Values{"issue": {"a"}})
require.NoError(t, err)
require.Len(t, d.DependsTree, 2, "DependsTree = %+v, want [b(0) c(1)]", d.DependsTree)
assert.Equal(t, "b", d.DependsTree[0].ID)
assert.Equal(t, 0, d.DependsTree[0].Depth)
assert.Equal(t, "c", d.DependsTree[1].ID)
assert.Equal(t, 1, d.DependsTree[1].Depth)
assert.True(t, d.DependsTree[1].Closed, "c is closed")
// From the leaf c: transitive dependents b(0) → a(1); no prerequisites.
dc, err := Build(context.Background(), sess, "main", url.Values{"issue": {"c"}})
require.NoError(t, err)
require.Len(t, dc.DependentTree, 2, "DependentTree = %+v, want [b(0) a(1)]", dc.DependentTree)
assert.Equal(t, "b", dc.DependentTree[0].ID)
assert.Equal(t, "a", dc.DependentTree[1].ID)
assert.Empty(t, dc.DependsTree, "c has no prerequisites")
}
// TestBeadsBlockedByDepOnly proves the dependency-derived block signal works
// even when is_blocked is not set: an issue with a "blocks" dep to a non-closed
// target lands in Stalled; the same dep to a CLOSED target does not.
func TestBeadsBlockedByDepOnly(t *testing.T) {
sess := &fakeSession{
rowsByTable: map[string]*browse.RowPage{
"issues": {
Columns: []string{"id", "status", "is_blocked"},
Rows: [][]string{
{"a", "open", "0"}, // blocked by open b → Stalled
{"b", "open", "0"}, // ready → Lined Up
{"c", "open", "0"}, // "blocked" by closed d → NOT stalled → Lined Up
{"d", "closed", "0"}, // Past Stand
},
Total: 4,
},
"dependencies": {
Columns: []string{"issue_id", "depends_on_issue_id", "type"},
Rows: [][]string{
{"a", "b", "blocks"},
{"c", "d", "blocks"},
},
Total: 2,
},
},
}
d, err := Build(context.Background(), sess, "main", url.Values{})
require.NoError(t, err)
assert.Equal(t, []string{"a"}, cardIDs(laneBySlug(d, "stalled")),
"stalled should hold only a (blocked by an open dep)")
assert.Equal(t, []string{"b", "c"}, cardIDs(laneBySlug(d, "lined-up")),
"lined-up should hold b and c (c's blocker is closed)")
}
// --- detail mode -------------------------------------------------------------
func TestBeadsBuildDetail(t *testing.T) {
q := url.Values{}
q.Set("issue", "i-open")
d, err := Build(context.Background(), beadsFixture(), "main", q)
require.NoError(t, err)
require.Equal(t, "detail", d.Mode)
require.NotNil(t, d.Issue)
assert.Equal(t, "i-open", d.Issue.ID)
assert.Equal(t, "Ready to roll", d.Issue.Title)
assert.Equal(t, []string{"backend", "urgent"}, d.Issue.Labels)
// i-open is depended on by i-blocked (incoming), and depends on nothing.
assert.Empty(t, d.DependsOn, "i-open depends on nothing")
require.Len(t, d.DependedOnBy, 1)
assert.Equal(t, "i-blocked", d.DependedOnBy[0].IssueID)
// Only i-open's comment shows in its thread.
require.Len(t, d.Comments, 1)
assert.Equal(t, "alice", d.Comments[0].Author)
assert.Equal(t, "first!", d.Comments[0].Text)
}
func TestBeadsBuildDetailOutgoingEdge(t *testing.T) {
q := url.Values{}
q.Set("issue", "i-blocked")
d, err := Build(context.Background(), beadsFixture(), "main", q)
require.NoError(t, err)
require.Len(t, d.DependsOn, 1)
assert.Equal(t, "i-open", d.DependsOn[0].IssueID)
assert.Equal(t, "Ready to roll", d.DependsOn[0].Title)
assert.Equal(t, "blocks", d.DependsOn[0].Type)
assert.False(t, d.DependsOn[0].Closed, "i-open is open")
}
// --- epic mode + history -----------------------------------------------------
// beadsEpicFixture models an epic (i-epic) with three parent-child children —
// one closed, one open, one in-progress — plus a comment and three audit events
// on the epic, so both the subtask rollup and the merged history are exercised.
func beadsEpicFixture() *fakeSession {
issues := &browse.RowPage{
Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"},
Rows: [][]string{
{"i-epic", "Big Epic", "open", "1", "epic", "", "2024-01-01", "0"},
{"i-c1", "Child one", "open", "2", "task", "alice", "2024-01-02", "0"},
{"i-c2", "Child two", "closed", "1", "task", "bob", "2024-01-03", "0"},
{"i-c3", "Child three", "in_progress", "0", "bug", "carol", "2024-01-04", "0"},
},
Total: 4,
}
// Each child is the "from" side of a parent-child edge pointing at the epic;
// created_at/created_by let the view synthesize "added subtask" history.
deps := &browse.RowPage{
Columns: []string{"id", "issue_id", "depends_on_issue_id", "type", "created_at", "created_by"},
Rows: [][]string{
{"d1", "i-c1", "i-epic", "parent-child", "2024-01-01 09:00:00", "Eugene"},
{"d2", "i-c2", "i-epic", "parent-child", "2024-01-01 09:05:00", "Eugene"},
{"d3", "i-c3", "i-epic", "parent-child", "2024-01-01 09:10:00", "Eugene"},
},
Total: 3,
}
statuses := &browse.RowPage{
Columns: []string{"name", "category"},
Rows: [][]string{
{"open", "open"}, {"in_progress", "in_progress"}, {"closed", "closed"},
},
Total: 3,
}
comments := &browse.RowPage{
Columns: []string{"issue_id", "author", "text", "created_at"},
Rows: [][]string{
{"i-epic", "alice", "kickoff", "2024-01-05 09:00:00"},
{"i-c1", "bob", "unrelated", "2024-01-06 09:00:00"},
},
Total: 2,
}
events := &browse.RowPage{
Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
Rows: [][]string{
{"e1", "i-epic", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-01 08:00:00"},
{"e2", "i-epic", "status_changed", "Eugene", `{"status":"open"}`, `{"status":"in_progress"}`, "NULL", "2024-01-02 10:00:00"},
{"e3", "i-epic", "updated", "Eugene", "NULL", `{"priority":0}`, "NULL", "2024-01-03 11:00:00"},
{"e4", "i-epic", "label_added", "Eugene", "NULL", "NULL", "Added label: milestone:m3", "2024-01-04 09:00:00"},
{"e9", "i-c1", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-02 08:00:00"},
},
Total: 5,
}
return &fakeSession{
rowsByTable: map[string]*browse.RowPage{
"issues": issues,
"dependencies": deps,
"custom_statuses": statuses,
"comments": comments,
"events": events,
},
}
}
func TestBeadsEpicMode(t *testing.T) {
d, err := Build(context.Background(), beadsEpicFixture(), "main", url.Values{"issue": {"i-epic"}})
require.NoError(t, err)
require.Equal(t, "epic", d.Mode)
assert.Equal(t, 3, d.SubtaskTotal)
assert.Equal(t, 1, d.SubtaskDone)
assert.Equal(t, 33, d.SubtaskPct())
// Sorted open-work-first (in_progress p0, then open p2), closed sinks last.
require.Len(t, d.Subtasks, 3)
gotIDs := []string{d.Subtasks[0].ID, d.Subtasks[1].ID, d.Subtasks[2].ID}
assert.Equal(t, []string{"i-c3", "i-c1", "i-c2"}, gotIDs, "subtask order")
assert.Equal(t, "closed", d.Subtasks[2].Category)
}
func TestBeadsHistoryMerge(t *testing.T) {
d, err := Build(context.Background(), beadsEpicFixture(), "main", url.Values{"issue": {"i-epic"}})
require.NoError(t, err)
// Only the epic's own comment shows in the Comments tab (not i-c1's).
require.Len(t, d.Comments, 1, "comments = %+v, want just the epic's kickoff", d.Comments)
assert.Equal(t, "kickoff", d.Comments[0].Text)
// History merges the epic's 1 comment + 4 events + 3 synthesized subtask-add
// entries (i-c1's own event is excluded), time-sorted.
require.Len(t, d.History, 8, "history = %+v", d.History)
for i := 1; i < len(d.History); i++ {
assert.LessOrEqual(t, d.History[i-1].CreatedAt, d.History[i].CreatedAt,
"history not time-sorted at %d", i)
}
assert.Equal(t, "event", d.History[0].Kind)
assert.Equal(t, "created the issue", d.History[0].Summary)
last := d.History[len(d.History)-1]
assert.Equal(t, "comment", last.Kind)
assert.Equal(t, "kickoff", last.Text)
// Humanized change lines, and a label event collapsed to one line with no
// redundant "Added label:" body.
var sawStatus, sawUpdate, sawLabel, sawSubtask bool
for _, a := range d.History {
switch a.Summary {
case "changed status to in_progress":
sawStatus = true
case "updated priority to 0":
sawUpdate = true
case "added label milestone:m3":
sawLabel = true
assert.Empty(t, a.Text, "label event should have no body")
case "added subtask i-c1":
sawSubtask = true
assert.Equal(t, "dep", a.Kind)
assert.Equal(t, "Eugene", a.Actor)
}
assert.NotContains(t, a.Text, "Added label:", "label note leaked into a history body: %+v", a)
}
assert.True(t, sawStatus, "history missing the status line")
assert.True(t, sawUpdate, "history missing the update line")
assert.True(t, sawLabel, "history missing the label line")
assert.True(t, sawSubtask, "history missing the subtask-add line")
}