package web
import (
"net/http"
"strings"
"testing"
"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,
},
}
}
// --- 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, "<h4>Close reason</h4>") {
t.Errorf("Comments tab should show a Close reason block; body=%s", body)
}
// History-tab closed event.
if !strings.Contains(body, "closed the issue") {
t.Errorf("History should carry the closed event; body=%s", body)
}
// The reason text itself is present (both places).
if !strings.Contains(body, "Fixed in commit abc123") {
t.Errorf("close reason text missing; body=%s", body)
}
}
// --- 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 <details> with no open attribute.
if !strings.Contains(body, `<details class="beads-section past-stand">`) {
t.Errorf("Past Stand should render as a collapsed <details>; body=%s", body)
}
if strings.Contains(body, "<details open") {
t.Errorf("no section should render as an open <details>; body=%s", body)
}
// The filter form must carry the layout, or filtering would drop back to the
// board; the toggle marks Stream as current and links to the board.
if !strings.Contains(body, `<input type="hidden" name="layout" value="stream">`) {
t.Errorf("stream form missing the layout carrier; body=%s", body)
}
if !strings.Contains(body, `<span class="current" aria-current="page">Stream</span>`) {
t.Errorf("toggle should mark Stream as current; body=%s", body)
}
if !strings.Contains(body, `href="/~alice/db/view/beads">Board</a>`) {
t.Errorf("toggle should link back to an unfiltered board; body=%s", body)
}
// No JavaScript is added by this layout.
if strings.Contains(body, "<script") {
t.Errorf("the stream layout must add no script; body=%s", body)
}
}
// The board is what an unknown ?layout= renders — a stale link answers with the
// default page, not an error.
func TestBeadsUnknownLayoutRendersBoard(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=parade", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("unknown layout: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, `class="beads-lanes"`) {
t.Errorf("an unknown layout should render the board; body=%s", body)
}
if strings.Contains(body, `class="beads-stream"`) {
t.Errorf("an unknown layout rendered the stream; body=%s", body)
}
// The toggle offers the stream, carrying the unknown value along untouched —
// the query is rebuilt with one key replaced, not rewritten.
if !strings.Contains(body, `href="/~alice/db/view/beads?layout=stream"`) {
t.Errorf("board toggle should link to the stream; body=%s", body)
}
}
// Switching layouts must keep the page pointed at the same issues: every active
// filter survives the toggle, in both directions.
func TestBeadsLayoutToggleKeepsFilters(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{})
// Board → Stream: the toggle adds layout=stream to everything already set.
rec := h.do("GET", "/~alice/db/view/beads?q=ready&type=feature&priority=1&assignee=alice&label=urgent&ready=1&ref=main", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("filtered board: got %d; body=%s", rec.Code, rec.Body.String())
}
wantStream := `href="/~alice/db/view/beads?assignee=alice&label=urgent&layout=stream&priority=1&q=ready&ready=1&ref=main&type=feature"`
if !strings.Contains(rec.Body.String(), wantStream) {
t.Errorf("stream link should carry every filter, want %s; body=%s", wantStream, rec.Body.String())
}
// Stream → Board: the same query with layout dropped, nothing else touched.
rec = h.do("GET", "/~alice/db/view/beads?q=ready&type=feature&priority=1&assignee=alice&label=urgent&ready=1&ref=main&layout=stream", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("filtered stream: got %d; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
wantBoard := `href="/~alice/db/view/beads?assignee=alice&label=urgent&priority=1&q=ready&ready=1&ref=main&type=feature"`
if !strings.Contains(body, wantBoard) {
t.Errorf("board link should carry every filter and drop the layout, want %s; body=%s", wantBoard, body)
}
// The filters are still applied and still sticky in the form.
if !strings.Contains(body, `value="feature" selected`) || !strings.Contains(body, `name="q" value="ready"`) {
t.Errorf("filtered stream lost its sticky form state; body=%s", body)
}
// Clearing filters keeps the layout: it is how the page is read, not a filter.
if !strings.Contains(body, `href="/~alice/db/view/beads?ref=main&layout=stream">Clear</a>`) {
t.Errorf("Clear should keep ref and layout; body=%s", body)
}
}
// ?issue= wins over any layout: a link to a card is a link to a card.
func TestBeadsStreamIssueDetailWins(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&issue=i-blocked", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail under layout=stream: got %d; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Waiting") || !strings.Contains(body, "Back to the parade") {
t.Errorf("layout=stream should not disturb the detail pane; body=%s", body)
}
if strings.Contains(body, `class="beads-stream"`) {
t.Errorf("the detail pane rendered a stream; body=%s", 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)
}
}
}