package web import ( "errors" "fmt" "html" "net/http" "net/url" "regexp" "strings" "testing" "sourcecraft.dev/bigbes/sr-ht-dolt/beads" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) // The projection these tests drive — the fingerprint, the lanes, the ready rule, // the detail/epic assembly — is tested in the beads package. What is left here // is what only this package can answer: that beads.html renders what the // projection produced, over the real router and template set. // --- fixtures ---------------------------------------------------------------- // beadsTables is a schema fingerprint the beads view 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 func beadsFixture() *fakeSession { issues := &browse.RowPage{ 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). 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{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, tables: beadsTables(), rowsByTable: map[string]*browse.RowPage{ "issues": issues, "dependencies": deps, "labels": labels, "custom_statuses": statuses, "comments": comments, "events": events, }, } } // beadsEpicFixture models an epic (i-epic) with three parent-child children — // one closed, one open, one in-progress — plus a comment and audit events on the // epic, so both the subtask rollup and the merged history reach the template. 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, } 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{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, tables: beadsTables(), rowsByTable: map[string]*browse.RowPage{ "issues": issues, "dependencies": deps, "custom_statuses": statuses, "comments": comments, "events": events, }, } } // beadsClippedFixture is a tracker of beads.Max+5 issues as a capped read hands // it to the projection: the first beads.Max rows, and a Total saying the rest // exists. Ids run i-0000 upwards in table order, so i-2000 and up are the tail // no page here ever sees; beadsClippedTail names one. // // The rows are built whole and then clipped, rather than a short page given a // large Total by hand: the tail id has to be *genuinely* absent from what the // projection reads, or a test asserts the wording while never producing the // situation the wording is about. The clip is applied here because this // package's fake session answers every read whole — the store applies it at // limit, and beads/truncation_test.go drives the same fixtures through a seam // that does. func beadsClippedFixture() *fakeSession { statuses := &browse.RowPage{ Columns: []string{"name", "category"}, Rows: [][]string{ {"open", "open"}, {"in_progress", "in_progress"}, {"closed", "closed"}, }, Total: 3, } return &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, tables: beadsTables(), rowsByTable: map[string]*browse.RowPage{ "issues": clipPage(manyIssues(beads.Max+5), beads.Max), "custom_statuses": statuses, "dependencies": { Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}, Rows: [][]string{{"d1", "i-0007", "i-0001", "blocks"}}, Total: 1, }, }, } } // manyIssues builds n issue rows, ids i-0000 upwards, cycling the three status // categories so every lane is populated. func manyIssues(n int) *browse.RowPage { statuses := []string{"open", "in_progress", "closed"} rows := make([][]string, 0, n) for i := range n { rows = append(rows, []string{ fmt.Sprintf("i-%04d", i), fmt.Sprintf("Issue %d", i), statuses[i%len(statuses)], "1", "task", "alice", "2024-01-01", "0", }) } return &browse.RowPage{ Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"}, Rows: rows, Total: n, } } // clipPage is the store's own answer to a read of limit rows: the first limit of // them, and the table's true row count beside them. func clipPage(p *browse.RowPage, limit int) *browse.RowPage { rows := p.Rows if len(rows) > limit { rows = rows[:limit] } return &browse.RowPage{Columns: p.Columns, Rows: rows, Total: len(p.Rows)} } // beadsClippedTail is an id of beadsClippedFixture that exists in the tracker and // sits past the rows any read of it returns. const beadsClippedTail = "i-2003" // beadsClippedLabelsFixture is beadsFixture with one table past the cap: labels. // Four issues, every one of them read, every lane and count exactly as the // complete fixture renders them — and beads.Max+3 label rows of which the read // returns the first beads.Max. // // That invariance is the case itself. A clipped labels table takes the pills off // the cards and the options out of the label filter while leaving "N issues" // true, so a count-shaped flag has nothing to say about it and the board is // degraded in silence. func beadsClippedLabelsFixture() *fakeSession { sess := beadsFixture() sess.rowsByTable["labels"] = clipPage(manyLabels(beads.Max+3), beads.Max) return sess } // beadsClippedDepsFixture is beadsFixture with the dependencies table past the // cap and nothing else: the issue set is whole, so the board's count line has // nothing to report and the dependencies entry carries the whole message. func beadsClippedDepsFixture() *fakeSession { sess := beadsFixture() sess.rowsByTable["dependencies"] = clipPage(manyDeps(beads.Max+1), beads.Max) return sess } // manyLabels builds n label rows on i-open, named label-0000 upwards. func manyLabels(n int) *browse.RowPage { rows := make([][]string, 0, n) for i := range n { rows = append(rows, []string{"i-open", fmt.Sprintf("label-%04d", i)}) } return &browse.RowPage{Columns: []string{"issue_id", "label"}, Rows: rows, Total: n} } // manyDeps builds n "blocks" edges from i-blocked to i-open. func manyDeps(n int) *browse.RowPage { rows := make([][]string, 0, n) for i := range n { rows = append(rows, []string{fmt.Sprintf("d%d", i), "i-blocked", "i-open", "blocks"}) } return &browse.RowPage{ Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}, Rows: rows, Total: n, } } // --- end to end -------------------------------------------------------------- func TestBeadsHandleViewBoard(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = beadsFixture() setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{"Rolling", "Lined Up", "Stalled", "Past Stand", "Ready to roll", "beads-summary"} { if !strings.Contains(body, want) { t.Errorf("board body missing %q", want) } } // The Tables tab must remain reachable from the view. if !strings.Contains(body, "/~alice/db/tree/") { t.Errorf("board missing Tables tab link; body=%s", body) } // The filter bar renders with option lists drawn from the data, plus the // Ready-only toggle; i-open is ready, so a ready dot renders on the board. for _, want := range []string{`class="beads-filter"`, "All types", ">feature<", "All priorities", "Ready only", "ready-dot"} { if !strings.Contains(body, want) { t.Errorf("board missing control %q", want) } } } func TestBeadsDepTreeRender(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, tables: beadsTables(), rowsByTable: map[string]*browse.RowPage{ "issues": {Columns: []string{"id", "title", "status"}, Rows: [][]string{{"a", "Aye", "open"}, {"b", "Bee", "open"}, {"c", "Cee", "open"}}, 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}, }, } setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads?issue=a", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("detail: got %d; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() // The transitive chain section appears with the depth-1 node c and an indent. for _, want := range []string{"Prerequisite chain", "dep-tree", "--depth: 1", ">c<"} { if !strings.Contains(body, want) { t.Errorf("dep-tree render missing %q; body=%s", want, body) } } } func TestBeadsBoardFilterRender(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = beadsFixture() setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads?type=feature", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("filtered board: got %d; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() // The active type is preselected and a Clear link appears. if !strings.Contains(body, `value="feature" selected`) { t.Errorf("type filter not preselected; body=%s", body) } if !strings.Contains(body, "beads-filter-clear") { t.Errorf("Clear link missing when a filter is active") } // Only feature issues on the board; the bug (i-prog) is filtered out. if !strings.Contains(body, "i-open") || strings.Contains(body, "i-prog") { t.Errorf("filtered board should show features only; body=%s", body) } } func TestBeadsHandleViewDetail(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = beadsFixture() setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads?issue=i-blocked", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "Waiting") { t.Errorf("detail missing issue title; body=%s", body) } if !strings.Contains(body, "Depends on") || !strings.Contains(body, "i-open") { t.Errorf("detail missing dependency edge; body=%s", body) } if !strings.Contains(body, "Back to the parade") { t.Errorf("detail missing back link; body=%s", body) } } // A closed issue's detail pane must surface its close reason (and the closed // timestamp), so the resolution recorded by `bd close -r` is not lost. // TestBeadsDetailShowsCloseReason: the reason appears twice by design — once in // the Comments tab (which has no closed event) as a Close reason block, and once // in the History tab as the humanized `closed` event. func TestBeadsDetailShowsCloseReason(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) h.browse.sess = beadsFixture() setViews(t, h, &beadsView{}) rec := h.do("GET", "/~alice/db/view/beads?issue=i-done", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() // Comments-tab block. if !strings.Contains(body, "
labels table was clipped at 2000 of 2003 rows") {
t.Errorf("the board says nothing about a clipped labels read; body=%s", body)
}
if !strings.Contains(body, "the label filter offers only the labels that were read") {
t.Errorf("the board names the table without saying what it cost; body=%s", body)
}
// And it must not borrow the count line: every issue was read, so "the first
// N of M issues" would be false.
if strings.Contains(body, "Showing the first") {
t.Errorf("the issue set is whole; the count line has nothing to report; body=%s", body)
}
// The degradation itself, on the page: the labels past the cap reach neither
// a card nor the filter's options.
if strings.Contains(body, "label-2002") {
t.Errorf("a label past the cap cannot be on the page; body=%s", body)
}
}
// custom_statuses decides which lane every card stands in, so it is the board's
// too.
func TestBeadsBoardReportsAClippedStatusesRead(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
sess := beadsFixture()
statuses := sess.rowsByTable["custom_statuses"]
rows := statuses.Rows
for i := len(rows); i < beads.Max+1; i++ {
rows = append(rows, []string{fmt.Sprintf("status-%04d", i), "open"})
}
sess.rowsByTable["custom_statuses"] = clipPage(
&browse.RowPage{Columns: statuses.Columns, Rows: rows, Total: len(rows)}, beads.Max)
h.browse.sess = sess
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "custom_statuses table was clipped at 2000 of 2001 rows") {
t.Errorf("the board says nothing about a clipped custom_statuses read; body=%s", body)
}
if !strings.Contains(body, "a card may be in the wrong lane") {
t.Errorf("the board names the table without saying what it cost; body=%s", body)
}
}
// dependencies has always flipped the flag, and the flag has always printed the
// count line — which says "the first 4 of 4 issues" when the issue set is whole.
// The table now says the thing that is actually true of this read.
func TestBeadsBoardReportsAClippedDependenciesRead(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsClippedDepsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "dependencies table was clipped at 2000 of 2001 rows") {
t.Errorf("the board says nothing about a clipped dependencies read; body=%s", body)
}
if strings.Contains(body, "Showing the first") {
t.Errorf("every issue was read, so the count line must stay silent; body=%s", body)
}
}
// And a board over complete reads stays quiet: the lines are facts about a read,
// not decoration on every board.
func TestBeadsBoardOnACompleteReadReportsNoClip(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, unwanted := range []string{"was clipped at", "Showing the first"} {
if strings.Contains(body, unwanted) {
t.Errorf("nothing was clipped, so the board must not say %q; body=%s", unwanted, body)
}
}
}
// rowsText is every cell of a page as one string, for asserting that an id is
// nowhere in the rows a fixture hands over.
func rowsText(p *browse.RowPage) string {
var b strings.Builder
for _, r := range p.Rows {
for _, c := range r {
b.WriteString(c)
b.WriteByte(' ')
}
}
return b.String()
}
// --- the stream layout -------------------------------------------------------
func TestBeadsStreamRender(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?layout=stream", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("stream: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// One column of sections, the board's lanes gone, and the same cards in it.
for _, want := range []string{
`class="beads-stream"`, `class="stream-head"`, `class="stream-body"`,
"Rolling", "Lined Up", "Stalled", "Past Stand",
"Ready to roll", "ready-dot", `class="bead-row"`,
} {
if !strings.Contains(body, want) {
t.Errorf("stream body missing %q", want)
}
}
if strings.Contains(body, `class="beads-lanes"`) {
t.Errorf("stream body should not render the board's lane row; body=%s", body)
}
// Past Stand is the one collapsed section: a that follows
// the named field label.
func fieldBody(t *testing.T, body, label string) string {
t.Helper()
head := `` + label + ``
i := strings.Index(body, head)
if i < 0 {
t.Fatalf("no %s body in the page: %s", label, body)
}
rest := body[i+len(head):]
j := strings.Index(rest, "")
if j < 0 {
t.Fatalf("unterminated %s body", label)
}
return rest[:j]
}
// --- the stored rows section ---------------------------------------------------
// beadsNullFixture is one issue carrying, in one row, the three states a raw
// view may never conflate: a cell that holds no value (closed_at), a cell that
// stores the four characters "NULL" (assignee), and a cell that stores the empty
// string (notes). The mask is what tells the first two apart, so it is set here
// exactly as browse fills it for a real read.
func beadsNullFixture() *fakeSession {
issues := &browse.RowPage{
Columns: []string{"id", "title", "status", "assignee", "closed_at", "notes"},
Rows: [][]string{
{"i-null", "Three states", "open", "NULL", "NULL", ""},
},
Nulls: [][]bool{
{false, false, false, false, true, false},
},
Total: 1,
}
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
tables: beadsTables(),
rowsByTable: map[string]*browse.RowPage{
"issues": issues,
"dependencies": {
Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
Nulls: [][]bool{},
Total: 0,
},
},
}
}
// rawSection returns the markup of the collapsed stored-rows block.
func rawSection(t *testing.T, body string) string {
t.Helper()
i := strings.Index(body, ``)
if i < 0 {
t.Fatalf("no stored-rows section in the page: %s", body)
}
rest := body[i:]
j := strings.Index(rest, "")
if j < 0 {
t.Fatalf("unterminated stored-rows section")
}
return rest[:j]
}
// rawTableBlock returns one table's markup inside the stored-rows section.
func rawTableBlock(t *testing.T, body, table string) string {
t.Helper()
sec := rawSection(t, body)
head := ``
i := strings.Index(sec, head)
if i < 0 {
t.Fatalf("no %s rows in the stored-rows section: %s", table, sec)
}
rest := sec[i+len(head):]
if j := strings.Index(rest, `= 0 {
rest = rest[:j]
}
return rest
}
// rawCellValue returns the rendered value cell for one column of one table's
// first row in the stored-rows section.
func rawCellValue(t *testing.T, body, table, column string) string {
t.Helper()
block := rawTableBlock(t, body, table)
head := `` + column + ` `
i := strings.Index(block, head)
if i < 0 {
t.Fatalf("no %s.%s cell in the stored-rows section: %s", table, column, block)
}
rest := block[i+len(head):]
open := ``
k := strings.Index(rest, open)
if k < 0 {
t.Fatalf("no value cell after %s.%s", table, column)
}
rest = rest[k+len(open):]
j := strings.Index(rest, " ")
if j < 0 {
t.Fatalf("unterminated value cell for %s.%s", table, column)
}
return rest[:j]
}
// The detail pane carries the rows it was built from, and carries them closed:
// this is a tool for checking the rendering, not the reason a reader opened the
// page, so it is a with no open attribute — the stream layout's Past
// Stand idiom.
func TestBeadsDetailShowsTheStoredRowsCollapsed(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-open", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, ``) {
t.Fatalf("no stored-rows section on the detail pane; body=%s", body)
}
if strings.Contains(body, `Ready to roll` {
t.Errorf("issues.title rendered as %q", got)
}
if got := rawCellValue(t, body, "comments", "text"); got != `first!` {
t.Errorf("comments.text rendered as %q", got)
}
}
// The three states, on the page. A cell that holds no value renders as a NULL
// chip, a cell storing those four characters renders as the text it stores, and
// a cell storing the empty string says it is empty — the rendering used to make
// the first two identical and the last two indistinguishable.
func TestBeadsDetailStoredRowsSeparateNullFromEmpty(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsNullFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-null", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
absent := rawCellValue(t, body, "issues", "closed_at")
stored := rawCellValue(t, body, "issues", "assignee")
empty := rawCellValue(t, body, "issues", "notes")
if absent != `NULL` {
t.Errorf("a cell holding no value rendered as %q", absent)
}
if stored != `NULL` {
t.Errorf("a cell storing the text \"NULL\" rendered as %q", stored)
}
if empty != `` {
t.Errorf("a cell storing the empty string rendered as %q", empty)
}
if absent == stored || absent == empty || stored == empty {
t.Errorf("the three states are not three renderings: %q / %q / %q", absent, stored, empty)
}
}
// Stored values are escaped, and they are not linkified. A script tag stored in
// a description reaches this section as text, and the ids in it stay the
// characters that are stored — the bodies above the section link them, but a raw
// view whose values have been rewritten is no longer showing what is stored.
func TestBeadsDetailStoredRowsEscapeAndDoNotLink(t *testing.T) {
h, _, _ := linkHarness(t)
rec := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
desc := rawCellValue(t, body, "issues", "description")
if strings.Contains(desc, ""
got := html.UnescapeString(strings.TrimSuffix(strings.TrimPrefix(desc, ``), ""))
if got != want {
t.Errorf("the stored description does not render as stored: %q", got)
}
}
// The board renders no stored rows: it is not a detail pane, and a board that
// carried every row behind every card would be the table browser with lanes
// drawn on it.
func TestBeadsBoardHasNoStoredRowsSection(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, notWant := range []string{`class="bead-raw"`, "Stored rows", ``} {
if strings.Contains(body, notWant) {
t.Errorf("the board rendered the stored-rows section (%q); body=%s", notWant, body)
}
}
}
func TestBeadsEpicViewRender(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsEpicFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-epic", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("epic view: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"Subtasks", "1 of 3 done", "epic-progress", "Child three", "History", "changed status to in_progress"} {
if !strings.Contains(body, want) {
t.Errorf("epic render missing %q", want)
}
}
}