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</b>/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)
}
}