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, "