package web
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
// --- fixtures ----------------------------------------------------------------
// beadsTables is a schema fingerprint that Applies 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
//
// The issues page deliberately orders its columns id,title,status,priority,...
// with is_blocked LAST so column-name mapping (not positional) is exercised.
func beadsFixture() *fakeSession {
issues := &browse.RowPage{
// Column order chosen so nothing is at a "natural" index; is_blocked is last
// and close_reason sits mid-row so name (not positional) mapping is exercised.
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).
// i-open is depended on by i-blocked → i-open.Blocks == 1.
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,
},
}
}
// laneBySlug finds a lane in a built board by its slug.
func laneBySlug(d *BeadsData, slug string) *BeadsLane {
for i := range d.Lanes {
if d.Lanes[i].Slug == slug {
return &d.Lanes[i]
}
}
return nil
}
// cardIDs lists the ids of a lane's cards.
func cardIDs(l *BeadsLane) []string {
if l == nil {
return nil
}
out := make([]string, len(l.Issues))
for i, c := range l.Issues {
out[i] = c.ID
}
return out
}
// --- Applies -----------------------------------------------------------------
func TestBeadsApplies(t *testing.T) {
v := &beadsView{}
if !v.Applies(beadsTables()) {
t.Fatalf("Applies should be true when issues+dependencies (with id+status) present")
}
// Missing dependencies → not a beads DB.
if v.Applies([]browse.TableInfo{
{Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}, {Name: "status"}}},
}) {
t.Fatalf("Applies should be false without a dependencies table")
}
// issues present but lacking status column → guard rejects.
if v.Applies([]browse.TableInfo{
{Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}}},
{Name: "dependencies"},
}) {
t.Fatalf("Applies should be false when issues lacks a status column")
}
// Unrelated schema.
if v.Applies([]browse.TableInfo{{Name: "widgets"}}) {
t.Fatalf("Applies should be false for an unrelated schema")
}
}
// --- board mode --------------------------------------------------------------
func TestBeadsBuildBoardLanes(t *testing.T) {
v := &beadsView{}
got, err := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "alice", Name: "db"}, "main", url.Values{})
if err != nil {
t.Fatalf("Build: %v", err)
}
d, ok := got.(*BeadsData)
if !ok {
t.Fatalf("Build returned %T, want *BeadsData", got)
}
if d.Mode != "board" {
t.Fatalf("Mode = %q, want board", d.Mode)
}
checks := map[string][]string{
"rolling": {"i-prog"},
"lined-up": {"i-open"},
"stalled": {"i-blocked"},
"past-stand": {"i-done"},
}
for slug, want := range checks {
got := cardIDs(laneBySlug(d, slug))
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("lane %s = %v, want %v", slug, got, want)
}
}
if d.Counts.Rolling != 1 || d.Counts.LinedUp != 1 || d.Counts.Stalled != 1 || d.Counts.PastStand != 1 {
t.Errorf("counts = %+v, want 1 each", d.Counts)
}
if d.Counts.Total != 4 || d.Total != 4 {
t.Errorf("total = %d/%d, want 4", d.Counts.Total, d.Total)
}
}
func TestBeadsBuildBoardCounts(t *testing.T) {
v := &beadsView{}
got, _ := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", url.Values{})
d := got.(*BeadsData)
// i-blocked depends on i-open → i-blocked.BlockedBy==1, i-open.Blocks==1.
blocked := laneBySlug(d, "stalled").Issues[0]
if blocked.ID != "i-blocked" || blocked.BlockedBy != 1 || blocked.Blocks != 0 {
t.Errorf("i-blocked = %+v, want BlockedBy=1 Blocks=0", blocked)
}
open := laneBySlug(d, "lined-up").Issues[0]
if open.ID != "i-open" || open.Blocks != 1 || open.BlockedBy != 0 {
t.Errorf("i-open = %+v, want Blocks=1 BlockedBy=0", open)
}
// Labels attach by issue_id.
if strings.Join(open.Labels, ",") != "backend,urgent" {
t.Errorf("i-open labels = %v, want [backend urgent]", open.Labels)
}
}
// TestBeadsBlockedByDepOnly proves the dependency-derived block signal works
// even when is_blocked is not set: an issue with a "blocks" dep to a non-closed
// target lands in Stalled; the same dep to a CLOSED target does not.
func TestBeadsBlockedByDepOnly(t *testing.T) {
sess := &fakeSession{
tables: beadsTables(),
rowsByTable: map[string]*browse.RowPage{
"issues": {
Columns: []string{"id", "status", "is_blocked"},
Rows: [][]string{
{"a", "open", "0"}, // blocked by open b → Stalled
{"b", "open", "0"}, // ready → Lined Up
{"c", "open", "0"}, // "blocked" by closed d → NOT stalled → Lined Up
{"d", "closed", "0"}, // Past Stand
},
Total: 4,
},
"dependencies": {
Columns: []string{"issue_id", "depends_on_issue_id", "type"},
Rows: [][]string{
{"a", "b", "blocks"},
{"c", "d", "blocks"},
},
Total: 2,
},
},
}
v := &beadsView{}
got, err := v.Build(context.Background(), sess, &core.Repo{OwnerName: "a", Name: "b"}, "main", url.Values{})
if err != nil {
t.Fatalf("Build: %v", err)
}
d := got.(*BeadsData)
if ids := cardIDs(laneBySlug(d, "stalled")); strings.Join(ids, ",") != "a" {
t.Errorf("stalled = %v, want [a] (blocked by open dep only)", ids)
}
if ids := cardIDs(laneBySlug(d, "lined-up")); strings.Join(ids, ",") != "b,c" {
t.Errorf("lined-up = %v, want [b c] (c's blocker is closed)", ids)
}
}
// --- detail mode -------------------------------------------------------------
func TestBeadsBuildDetail(t *testing.T) {
v := &beadsView{}
q := url.Values{}
q.Set("issue", "i-open")
got, err := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", q)
if err != nil {
t.Fatalf("Build: %v", err)
}
d := got.(*BeadsData)
if d.Mode != "detail" {
t.Fatalf("Mode = %q, want detail", d.Mode)
}
if d.Issue == nil || d.Issue.ID != "i-open" || d.Issue.Title != "Ready to roll" {
t.Fatalf("Issue = %+v, want i-open/Ready to roll", d.Issue)
}
if strings.Join(d.Issue.Labels, ",") != "backend,urgent" {
t.Errorf("labels = %v", d.Issue.Labels)
}
// i-open is depended on by i-blocked (incoming), and depends on nothing.
if len(d.DependsOn) != 0 {
t.Errorf("DependsOn = %v, want none", d.DependsOn)
}
if len(d.DependedOnBy) != 1 || d.DependedOnBy[0].IssueID != "i-blocked" {
t.Errorf("DependedOnBy = %+v, want [i-blocked]", d.DependedOnBy)
}
// Only i-open's comment shows in its thread.
if len(d.Comments) != 1 || d.Comments[0].Author != "alice" || d.Comments[0].Text != "first!" {
t.Errorf("Comments = %+v, want single alice comment", d.Comments)
}
}
func TestBeadsBuildDetailOutgoingEdge(t *testing.T) {
v := &beadsView{}
q := url.Values{}
q.Set("issue", "i-blocked")
got, _ := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", q)
d := got.(*BeadsData)
if len(d.DependsOn) != 1 || d.DependsOn[0].IssueID != "i-open" || d.DependsOn[0].Title != "Ready to roll" {
t.Fatalf("DependsOn = %+v, want [i-open/Ready to roll]", d.DependsOn)
}
if d.DependsOn[0].Type != "blocks" || d.DependsOn[0].Closed {
t.Errorf("edge = %+v, want type=blocks not-closed", d.DependsOn[0])
}
}
// --- 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)
}
}
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 close reason surfaces through the History
// tab's `closed` event, not a standalone block (which was removed).
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()
for _, want := range []string{"closed the issue", "Fixed in commit abc123"} {
if !strings.Contains(body, want) {
t.Errorf("closed-issue history missing %q; body=%s", want, body)
}
}
// The old standalone block is gone; the reason lives only in the timeline.
if strings.Contains(body, "<h4>Close reason</h4>") {
t.Errorf("standalone Close reason block should be removed; body=%s", body)
}
}
// --- epic mode + history -----------------------------------------------------
// beadsEpicFixture models an epic (i-epic) with three parent-child children —
// one closed, one open, one in-progress — plus a comment and three audit events
// on the epic, so both the subtask rollup and the merged history are exercised.
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,
}
// Each child is the "from" side of a parent-child edge pointing at the epic.
deps := &browse.RowPage{
Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
Rows: [][]string{
{"d1", "i-c1", "i-epic", "parent-child"},
{"d2", "i-c2", "i-epic", "parent-child"},
{"d3", "i-c3", "i-epic", "parent-child"},
},
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,
},
}
}
func TestBeadsEpicMode(t *testing.T) {
v := &beadsView{}
raw, err := v.Build(context.Background(), beadsEpicFixture(), nil, "main", url.Values{"issue": {"i-epic"}})
if err != nil {
t.Fatalf("build: %v", err)
}
d := raw.(*BeadsData)
if d.Mode != "epic" {
t.Fatalf("Mode = %q, want epic", d.Mode)
}
if d.SubtaskTotal != 3 || d.SubtaskDone != 1 {
t.Errorf("rollup = %d/%d, want 1/3", d.SubtaskDone, d.SubtaskTotal)
}
if d.SubtaskPct() != 33 {
t.Errorf("pct = %d, want 33", d.SubtaskPct())
}
// Sorted open-work-first (in_progress p0, then open p2), closed sinks last.
gotIDs := []string{d.Subtasks[0].ID, d.Subtasks[1].ID, d.Subtasks[2].ID}
wantIDs := []string{"i-c3", "i-c1", "i-c2"}
for i := range wantIDs {
if gotIDs[i] != wantIDs[i] {
t.Errorf("subtask order = %v, want %v", gotIDs, wantIDs)
break
}
}
if d.Subtasks[2].Category != "closed" {
t.Errorf("last subtask category = %q, want closed", d.Subtasks[2].Category)
}
}
func TestBeadsHistoryMerge(t *testing.T) {
v := &beadsView{}
raw, err := v.Build(context.Background(), beadsEpicFixture(), nil, "main", url.Values{"issue": {"i-epic"}})
if err != nil {
t.Fatalf("build: %v", err)
}
d := raw.(*BeadsData)
// Only the epic's own comment shows in the Comments tab (not i-c1's).
if len(d.Comments) != 1 || d.Comments[0].Text != "kickoff" {
t.Fatalf("comments = %+v, want just the epic's kickoff", d.Comments)
}
// History merges the epic's 1 comment + 4 events (i-c1's are excluded), time-sorted.
if len(d.History) != 5 {
t.Fatalf("history len = %d, want 5: %+v", len(d.History), d.History)
}
for i := 1; i < len(d.History); i++ {
if d.History[i-1].CreatedAt > d.History[i].CreatedAt {
t.Errorf("history not time-sorted at %d: %q > %q", i, d.History[i-1].CreatedAt, d.History[i].CreatedAt)
}
}
if d.History[0].Kind != "event" || d.History[0].Summary != "created the issue" {
t.Errorf("first history = %+v, want created event", d.History[0])
}
last := d.History[len(d.History)-1]
if last.Kind != "comment" || last.Text != "kickoff" {
t.Errorf("last history = %+v, want the kickoff comment", last)
}
// Humanized change lines, and a label event collapsed to one line with no
// redundant "Added label:" body.
var sawStatus, sawUpdate, sawLabel bool
for _, a := range d.History {
switch a.Summary {
case "changed status to in_progress":
sawStatus = true
case "updated priority to 0":
sawUpdate = true
case "added label milestone:m3":
sawLabel = true
if a.Text != "" {
t.Errorf("label event should have no body, got %q", a.Text)
}
}
if strings.Contains(a.Text, "Added label:") {
t.Errorf("label note leaked into a history body: %+v", a)
}
}
if !sawStatus || !sawUpdate || !sawLabel {
t.Errorf("history missing humanized lines; status=%v update=%v label=%v", sawStatus, sawUpdate, sawLabel)
}
}
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)
}
}
}