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)
}