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