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. Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"}, Rows: [][]string{ {"i-open", "Ready to roll", "open", "1", "feature", "alice", "2024-01-01", "0"}, {"i-prog", "Under way", "in_progress", "0", "bug", "bob", "2024-01-02", "0"}, {"i-done", "Finished", "closed", "2", "chore", "carol", "2024-01-03", "0"}, {"i-blocked", "Waiting", "open", "1", "feature", "dave", "2024-01-04", "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, } 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, }, } } // 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) } }