package web import ( "context" "net/http" "net/url" "strings" "testing" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) // milestoneFixture: six issues across two milestones. m1 carries the full // hierarchy — a milestone-typed head, an epic with one (closed) subtask, and a // loose bug; m2 has a single loose feature; one issue has no milestone label. func milestoneFixture() *fakeSession { issues := &browse.RowPage{ Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee"}, Rows: [][]string{ {"i-m", "Mike", "open", "0", "milestone", ""}, {"i-e", "Echo", "open", "1", "epic", ""}, {"i-a", "Alpha", "closed", "1", "task", "alice"}, // subtask of i-e {"i-b", "Bravo", "open", "0", "bug", "bob"}, {"i-c", "Charlie", "open", "2", "feature", ""}, {"i-d", "Delta", "open", "1", "task", ""}, // no milestone }, Total: 6, } labels := &browse.RowPage{ Columns: []string{"issue_id", "label"}, Rows: [][]string{ {"i-m", "milestone:m1"}, {"i-e", "milestone:m1"}, {"i-a", "milestone:m1"}, {"i-b", "milestone:m1"}, {"i-c", "milestone:m2"}, {"i-b", "backend"}, // non-milestone label ignored by grouping }, Total: 6, } deps := &browse.RowPage{ Columns: []string{"issue_id", "depends_on_issue_id", "type"}, Rows: [][]string{ {"i-a", "i-e", "parent-child"}, {"i-b", "i-a", "blocks"}, // non-hierarchy edge ignored by nesting }, Total: 2, } statuses := &browse.RowPage{ Columns: []string{"name", "category"}, Rows: [][]string{{"open", "open"}, {"closed", "closed"}}, Total: 2, } return &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, tables: beadsTables(), rowsByTable: map[string]*browse.RowPage{ "issues": issues, "labels": labels, "dependencies": deps, "custom_statuses": statuses, }, } } func TestMilestonesApplies(t *testing.T) { // The milestone tab appears exactly where the beads tab does. if (&milestonesView{}).Applies(beadsTables()) != (&beadsView{}).Applies(beadsTables()) { t.Fatalf("milestones Applies should mirror beads Applies") } if (&milestonesView{}).Applies([]browse.TableInfo{{Name: "widgets"}}) { t.Fatalf("milestones should not apply to an unrelated schema") } } func TestMilestonesBuild(t *testing.T) { got, err := (&milestonesView{}).Build(context.Background(), milestoneFixture(), nil, "main", url.Values{}) if err != nil { t.Fatalf("Build: %v", err) } d := got.(*MilestoneView) if len(d.Milestones) != 2 { t.Fatalf("milestones = %d, want 2: %+v", len(d.Milestones), d.Milestones) } if d.Unlabeled != 1 || d.Total != 6 { t.Errorf("unlabeled=%d total=%d, want 1 and 6", d.Unlabeled, d.Total) } m1 := d.Milestones[0] if m1.Name != "m1" || m1.Total != 4 || m1.Done != 1 || m1.Open != 3 || m1.Pct() != 25 { t.Errorf("m1 = %+v, want name m1 total 4 done 1 open 3 pct 25", m1) } // Hierarchy: the milestone-typed issue heads the list, the epic nests its // subtask, and the bug (whose only dep edge is "blocks") stays loose. if len(m1.Heads) != 1 || m1.Heads[0].ID != "i-m" { t.Errorf("m1 heads = %+v, want [i-m]", m1.Heads) } if len(m1.Epics) != 1 || m1.Epics[0].Card.ID != "i-e" { t.Fatalf("m1 epics = %+v, want [i-e]", m1.Epics) } epic := m1.Epics[0] if len(epic.Children) != 1 || epic.Children[0].ID != "i-a" || epic.Done != 1 || epic.Total != 1 { t.Errorf("epic = %+v, want child i-a and rollup 1/1", epic) } if epic.Children[0].Category != "closed" { t.Errorf("i-a should be closed: %+v", epic.Children[0]) } if len(m1.Loose) != 1 || m1.Loose[0].ID != "i-b" { t.Errorf("m1 loose = %+v, want [i-b]", m1.Loose) } m2 := d.Milestones[1] if m2.Name != "m2" || m2.Total != 1 || m2.Done != 0 { t.Errorf("m2 = %+v", m2) } if len(m2.Heads) != 0 || len(m2.Epics) != 0 || len(m2.Loose) != 1 || m2.Loose[0].ID != "i-c" { t.Errorf("m2 hierarchy = %+v, want only loose [i-c]", m2) } } func TestMilestonesRender(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 = milestoneFixture() setViews(t, h, &beadsView{}, &milestonesView{}) rec := h.do("GET", "/~alice/db/view/milestones", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("milestones view: got %d; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{ "· milestones", // page heading "ms-title\">m1<", // milestone name "1/4 done", // rollup counts "ms-issue head", // the milestone-typed issue leads the list "ms-issue epic", // the epic row "ms-issue child done", // its subtask, nested and closed `class="r-sub">1/1<`, // the epic's subtask rollup "Charlie", // an issue under m2 "carry no milestone", // unlabeled footnote `view/milestones">Milestones`, // the tab link "nav-link active", // the active tab marker } { if !strings.Contains(body, want) { t.Errorf("milestones render missing %q", want) } } } // TestMilestonesEmpty: a beads DB with no milestone labels still gets the tab, // showing an empty state rather than erroring. func TestMilestonesEmpty(t *testing.T) { got, err := (&milestonesView{}).Build(context.Background(), beadsFixture(), nil, "main", url.Values{}) if err != nil { t.Fatalf("Build: %v", err) } d := got.(*MilestoneView) if len(d.Milestones) != 0 { t.Errorf("expected no milestones, got %+v", d.Milestones) } if d.Unlabeled != d.Total { t.Errorf("with no milestone labels all issues are unlabeled; got %d/%d", d.Unlabeled, d.Total) } }