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

Close reason

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