package web import ( "fmt" "net/http" "strings" "testing" "sourcecraft.dev/bigbes/sr-ht-dolt/beads" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) // The grouping itself is tested in the beads package; what is left here is the // pairing with the Beads tab and that milestones.html renders the rollup. // 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, }, } } // milestoneClippedFixture is that same tracker grown past the cap: the six rows // above, filler up to beads.Max, and five more — among them i-tail, which // carries milestone:m1 and sits in the tail no read here reaches. The read hands // over the first beads.Max rows and a Total saying 2005 exist. // // So m1 rolls up four of its five members, and every number on the page is // arithmetic over a partial read while looking exactly like arithmetic over the // tracker. The rows are built whole and then clipped, rather than a short page // given a large Total by hand: the missing member has to be genuinely absent // from what the projection reads, or the test asserts the notice while never // producing the situation the notice is about. func milestoneClippedFixture() *fakeSession { sess := milestoneFixture() issues := sess.rowsByTable["issues"] rows := append([][]string{}, issues.Rows...) for i := len(rows); i < beads.Max; i++ { rows = append(rows, []string{fmt.Sprintf("i-%04d", i), fmt.Sprintf("Filler %d", i), "open", "1", "task", ""}) } rows = append(rows, []string{"i-tail", "Tail task", "open", "1", "task", "alice"}) for i := len(rows); i < beads.Max+5; i++ { rows = append(rows, []string{fmt.Sprintf("i-%04d", i), fmt.Sprintf("Filler %d", i), "open", "1", "task", ""}) } sess.rowsByTable["issues"] = clipPage( &browse.RowPage{Columns: issues.Columns, Rows: rows, Total: len(rows)}, beads.Max) labels := sess.rowsByTable["labels"] labels.Rows = append(labels.Rows, []string{"i-tail", "milestone:m1"}) labels.Total = len(labels.Rows) return sess } // milestoneClippedLabelsFixture leaves every issue readable and clips the table // that decides milestone membership instead: the six real label rows lead, so // the rollup still renders, and beads.Max+3 rows exist of which the read returns // beads.Max. Nothing about the issue count is wrong here, which is why the // notice's second sentence has to be conditional. func milestoneClippedLabelsFixture() *fakeSession { sess := milestoneFixture() labels := sess.rowsByTable["labels"] rows := append([][]string{}, labels.Rows...) for i := len(rows); i < beads.Max+3; i++ { rows = append(rows, []string{"i-d", fmt.Sprintf("label-%04d", i)}) } sess.rowsByTable["labels"] = clipPage( &browse.RowPage{Columns: labels.Columns, Rows: rows, Total: len(rows)}, beads.Max) return sess } 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 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) } } // Nothing was clipped here, so the page makes no claim about a partial read. if strings.Contains(body, "This read was clipped") { t.Errorf("a complete read must not be dressed up as a clipped one; body=%s", body) } } // --- a read that was clipped ------------------------------------------------- // Every count on this page is a rollup: "1/4 done", "N of M carry no milestone // label". Over a clipped read they are arithmetic over the rows that were read // and they render identically to arithmetic over the tracker, so the page has to // say which one it is — in the detail pane's words, for the same fact. func TestMilestonesReportAClippedRead(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 = milestoneClippedFixture() 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() if !strings.Contains(body, "This read was clipped") { t.Errorf("the rollup is over a partial read and the page says nothing; body=%s", body) } // The number is the tracker's true size, which is what makes the line // checkable rather than a bare warning. if !strings.Contains(body, "The tracker holds 2005 issues and only the first of them were read.") { t.Errorf("the page does not name the tracker's true size; body=%s", body) } // And the arithmetic the notice is about: m1 has five members and the page // counts four, because the fifth is past the cap. if !strings.Contains(body, "1/4 done") { t.Errorf("m1's rollup is the members that were read; body=%s", body) } if strings.Contains(body, "Tail task") { t.Errorf("the tail member cannot be on the page; body=%s", body) } } // A clip in labels — the table that decides membership — with every issue read. // The notice appears; its second sentence does not, because the issue count is // not the thing that went short. func TestMilestonesReportAClippedLabelsRead(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 = milestoneClippedLabelsFixture() 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() if !strings.Contains(body, "This read was clipped") { t.Errorf("membership itself was read in part and the page says nothing; body=%s", body) } if strings.Contains(body, "The tracker holds") { t.Errorf("every issue was read, so the page must not claim otherwise; body=%s", body) } } // The page with no milestones at all still carries the notice: an empty rollup // over a clipped read is the one most easily mistaken for a fact about the // tracker. func TestMilestonesReportAClippedReadWithNoMilestones(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) sess := milestoneClippedFixture() sess.rowsByTable["labels"] = &browse.RowPage{Columns: []string{"issue_id", "label"}} h.browse.sess = sess 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() if !strings.Contains(body, "No milestones.") { t.Fatalf("the fixture is meant to produce an empty rollup; body=%s", body) } if !strings.Contains(body, "This read was clipped") { t.Errorf("\"no milestones\" over a clipped read is not an answer about the tracker; body=%s", body) } }