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 *BeadsData, slug string) *BeadsLane { 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 *BeadsLane) []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, BeadsCounts{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 *BeadsData) []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") }