package beads import ( "context" "net/url" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" ) // streamFixture models a parade with several issues per lane, each lane carrying // the distinctions its stream order is supposed to make: // // - Rolling : two different started_at stamps and one issue that never got // one, with the priorities set against the expected order so // the timestamp is what is actually being tested. // - Lined Up : ready and non-ready (a template) issues, two priorities, two // created_at stamps and one missing. // - Stalled : three issues with 1, 2 and 3 blockers, priorities again set // against the expected order. // - Past Stand: two closed_at stamps and one issue closed without one. func streamFixture() *fakeSession { issues := &browse.RowPage{ Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "started_at", "closed_at", "is_blocked", "is_template"}, Rows: [][]string{ // Rolling: started most recently first, unstamped last. {"r-late", "Picked up in March", "in_progress", "3", "task", "alice", "2024-01-01", "2024-03-05 10:00:00", "NULL", "0", "0"}, {"r-early", "Picked up in January", "in_progress", "0", "task", "alice", "2024-01-01", "2024-01-05 10:00:00", "NULL", "0", "0"}, {"r-none", "Never stamped", "in_progress", "0", "task", "alice", "2024-01-01", "NULL", "NULL", "0", "0"}, // Lined Up: ready leads, then priority, then oldest first. {"l-p1-old", "P1 from January", "open", "1", "feature", "bob", "2024-01-01", "NULL", "NULL", "0", "0"}, {"l-p1-new", "P1 from February", "open", "1", "feature", "bob", "2024-02-01", "NULL", "NULL", "0", "0"}, {"l-p2", "P2", "open", "2", "feature", "bob", "2024-01-01", "NULL", "NULL", "0", "0"}, {"l-p3-dated", "P3 with a date", "open", "3", "feature", "bob", "2024-01-02", "NULL", "NULL", "0", "0"}, {"l-p3-nodate", "P3 with no date", "open", "3", "feature", "bob", "NULL", "NULL", "NULL", "0", "0"}, {"l-template", "A P0 scaffold, never ready", "open", "0", "feature", "bob", "2024-01-01", "NULL", "NULL", "0", "1"}, // Stalled: blocked by 1, 2 and 3 open issues (see deps below). {"s-one", "One blocker away", "open", "3", "bug", "carol", "2024-01-01", "NULL", "NULL", "0", "0"}, {"s-two", "Two blockers away", "open", "1", "bug", "carol", "2024-01-01", "NULL", "NULL", "0", "0"}, {"s-three", "Three blockers away", "open", "0", "bug", "carol", "2024-01-01", "NULL", "NULL", "0", "0"}, // Past Stand: most recently closed on top, unstamped last. {"p-new", "Closed in May", "closed", "1", "chore", "dave", "2024-01-01", "NULL", "2024-05-01 08:00:00", "0", "0"}, {"p-old", "Closed in February", "closed", "0", "chore", "dave", "2024-01-01", "NULL", "2024-02-01 08:00:00", "0", "0"}, {"p-none", "Closed without a stamp", "closed", "0", "chore", "dave", "2024-01-01", "NULL", "NULL", "0", "0"}, }, Total: 15, } deps := &browse.RowPage{ Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}, Rows: [][]string{ {"d1", "s-one", "l-p2", "blocks"}, {"d2", "s-two", "l-p2", "blocks"}, {"d3", "s-two", "l-p1-old", "blocks"}, {"d4", "s-three", "l-p2", "blocks"}, {"d5", "s-three", "l-p1-old", "blocks"}, {"d6", "s-three", "l-p1-new", "blocks"}, }, Total: 6, } statuses := &browse.RowPage{ Columns: []string{"name", "category"}, Rows: [][]string{ {"open", "open"}, {"in_progress", "in_progress"}, {"closed", "closed"}, }, Total: 3, } return &fakeSession{ rowsByTable: map[string]*browse.RowPage{ "issues": issues, "dependencies": deps, "custom_statuses": statuses, }, } } // sectionBySlug finds a section of a built stream by its slug. func sectionBySlug(d *Data, slug string) *Section { for i := range d.Sections { if d.Sections[i].Slug == slug { return &d.Sections[i] } } return nil } // sectionIDs lists the ids of a section's cards, in the order they render. func sectionIDs(s *Section) []string { if s == nil { return nil } out := make([]string, len(s.Issues)) for i, c := range s.Issues { out[i] = c.ID } return out } // --- layout selection -------------------------------------------------------- func TestBeadsLayoutSelection(t *testing.T) { cases := []struct { name string query url.Values want string }{ {"absent", url.Values{}, LayoutBoard}, {"empty", url.Values{"layout": {""}}, LayoutBoard}, {"stream", url.Values{"layout": {"stream"}}, LayoutStream}, {"stream-cased", url.Values{"layout": {"Stream"}}, LayoutStream}, {"unknown", url.Values{"layout": {"parade"}}, LayoutBoard}, {"board", url.Values{"layout": {"board"}}, LayoutBoard}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { d, err := Build(context.Background(), streamFixture(), "main", tc.query) require.NoError(t, err) assert.Equal(t, "board", d.Mode, "the layout is a shape of the board, not a mode") assert.Equal(t, tc.want, d.Layout) if tc.want == LayoutStream { assert.Len(t, d.Sections, 4) } else { assert.Empty(t, d.Sections, "the board builds no sections") } // The lanes are built either way: the marquee and the board read them. assert.Len(t, d.Lanes, 4) }) } } // An issue detail wins over any layout — ?issue= short-circuits the board. func TestBeadsStreamDetailWins(t *testing.T) { d, err := Build(context.Background(), streamFixture(), "main", url.Values{"layout": {"stream"}, "issue": {"l-p2"}}) require.NoError(t, err) assert.Equal(t, "detail", d.Mode) assert.Empty(t, d.Sections) require.NotNil(t, d.Issue) assert.Equal(t, "l-p2", d.Issue.ID) } // --- bucketing --------------------------------------------------------------- // The stream is the board's buckets in another shape: for the same filters, each // section holds exactly the issues its lane holds, and both agree with the // marquee counts. func TestBeadsStreamSectionsMatchLanes(t *testing.T) { filters := []struct { name string query url.Values }{ {"no-filter", url.Values{}}, {"type", url.Values{"type": {"bug"}}}, {"assignee", url.Values{"assignee": {"bob"}}}, {"priority", url.Values{"priority": {"0"}}}, {"ready", url.Values{"ready": {"1"}}}, {"query", url.Values{"q": {"closed"}}}, {"combined-empty", url.Values{"type": {"bug"}, "assignee": {"bob"}}}, } for _, tc := range filters { t.Run(tc.name, func(t *testing.T) { board, err := Build(context.Background(), streamFixture(), "main", tc.query) require.NoError(t, err) stream := url.Values{"layout": {"stream"}} for k, vs := range tc.query { stream[k] = vs } s, err := Build(context.Background(), streamFixture(), "main", stream) require.NoError(t, err) require.Len(t, s.Sections, len(board.Lanes)) total := 0 for i, lane := range board.Lanes { sec := sectionBySlug(s, lane.Slug) require.NotNil(t, sec, "section %s missing", lane.Slug) assert.Equal(t, lane.Name, sec.Name) assert.Equal(t, lane.Accent, sec.Accent) assert.ElementsMatch(t, cardIDs(&board.Lanes[i]), sectionIDs(sec), "section %s holds a different set than its lane", lane.Slug) total += len(sec.Issues) } // …and the marquee is the same count, section by section. assert.Equal(t, s.Counts.Rolling, len(sectionBySlug(s, "rolling").Issues)) assert.Equal(t, s.Counts.LinedUp, len(sectionBySlug(s, "lined-up").Issues)) assert.Equal(t, s.Counts.Stalled, len(sectionBySlug(s, "stalled").Issues)) assert.Equal(t, s.Counts.PastStand, len(sectionBySlug(s, "past-stand").Issues)) assert.Equal(t, s.Counts.Total, total) assert.Equal(t, board.Counts, s.Counts) }) } } // Sorting a section must not reorder the lane it came from: the board renders // the lanes, and it renders the same page as before this layout existed. func TestBeadsStreamLeavesLanesInBoardOrder(t *testing.T) { board, err := Build(context.Background(), streamFixture(), "main", url.Values{}) require.NoError(t, err) stream, err := Build(context.Background(), streamFixture(), "main", url.Values{"layout": {"stream"}}) require.NoError(t, err) for i, lane := range board.Lanes { assert.Equal(t, cardIDs(&board.Lanes[i]), cardIDs(&stream.Lanes[i]), "lane %s changed order in stream mode", lane.Slug) } // And the stream really did reorder something, or the check above is vacuous. assert.NotEqual(t, cardIDs(laneBySlug(stream, "rolling")), sectionIDs(sectionBySlug(stream, "rolling"))) } // --- per-section order ------------------------------------------------------- func TestBeadsStreamSectionOrder(t *testing.T) { d, err := Build(context.Background(), streamFixture(), "main", url.Values{"layout": {"stream"}}) require.NoError(t, err) cases := []struct { slug string want []string why string }{ { "rolling", []string{"r-late", "r-early", "r-none"}, "started_at desc; the unstamped issue sorts last despite its P0", }, { "lined-up", []string{"l-p1-old", "l-p1-new", "l-p2", "l-p3-dated", "l-p3-nodate", "l-template"}, "ready first (the P0 template is not ready), then priority, then created_at asc with the undated one last", }, { "stalled", []string{"s-one", "s-two", "s-three"}, "fewest blockers first, against the priorities", }, { "past-stand", []string{"p-new", "p-old", "p-none"}, "closed_at desc; closed without a stamp sorts last", }, } for _, tc := range cases { t.Run(tc.slug, func(t *testing.T) { assert.Equal(t, tc.want, sectionIDs(sectionBySlug(d, tc.slug)), tc.why) }) } } // The timestamps are sort keys read off the issue row; nothing else reads them, // so a wrong column name would be invisible without this. func TestBeadsStreamCardTimestamps(t *testing.T) { d, err := Build(context.Background(), streamFixture(), "main", url.Values{"layout": {"stream"}}) require.NoError(t, err) rolling := sectionBySlug(d, "rolling") require.NotNil(t, rolling) assert.Equal(t, "2024-03-05 10:00:00", rolling.Issues[0].StartedAt) assert.Empty(t, rolling.Issues[2].StartedAt, "a NULL cell reads as unset, not as the string NULL") past := sectionBySlug(d, "past-stand") require.NotNil(t, past) assert.Equal(t, "2024-05-01 08:00:00", past.Issues[0].ClosedAt) assert.Empty(t, past.Issues[2].ClosedAt) } // --- section chrome ---------------------------------------------------------- func TestBeadsStreamCollapsedAndNotes(t *testing.T) { d, err := Build(context.Background(), streamFixture(), "main", url.Values{"layout": {"stream"}}) require.NoError(t, err) collapsed := map[string]bool{} for _, s := range d.Sections { collapsed[s.Slug] = s.Collapsed assert.NotEmpty(t, s.Note, "section %s should state the order it is in", s.Slug) } assert.True(t, collapsed["past-stand"], "Past Stand opens collapsed") for _, slug := range []string{"rolling", "lined-up", "stalled"} { assert.False(t, collapsed[slug], "%s must stay open", slug) } // Parade order, top to bottom. assert.Equal(t, []string{"rolling", "lined-up", "stalled", "past-stand"}, []string{d.Sections[0].Slug, d.Sections[1].Slug, d.Sections[2].Slug, d.Sections[3].Slug}) } // The query is carried through so the layout toggle can rebuild this URL. func TestBeadsStreamCarriesQuery(t *testing.T) { q := url.Values{"layout": {"stream"}, "type": {"bug"}, "ready": {"1"}} d, err := Build(context.Background(), streamFixture(), "main", q) require.NoError(t, err) assert.Equal(t, q, d.Query) }