package web
import (
"context"
"net/http"
"net/url"
"sort"
"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
// and close_reason sits mid-row so name (not positional) mapping is exercised.
Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "closed_at", "close_reason", "is_blocked"},
Rows: [][]string{
{"i-open", "Ready to roll", "open", "1", "feature", "alice", "2024-01-01", "NULL", "NULL", "0"},
{"i-prog", "Under way", "in_progress", "0", "bug", "bob", "2024-01-02", "NULL", "NULL", "0"},
{"i-done", "Finished", "closed", "2", "chore", "carol", "2024-01-03", "2024-01-04", "Fixed in commit abc123", "0"},
{"i-blocked", "Waiting", "open", "1", "feature", "dave", "2024-01-04", "NULL", "NULL", "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,
}
// i-done's closure is recorded as a `closed` audit event carrying the reason
// (the only place the reason now surfaces — there is no standalone block).
events := &browse.RowPage{
Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
Rows: [][]string{
{"e1", "i-done", "closed", "carol", "NULL", "Fixed in commit abc123", "NULL", "2024-01-03 12:00:00"},
},
Total: 1,
}
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,
"events": events,
},
}
}
// 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)
}
}
// boardIDs returns every card id on the board, across all lanes.
func boardIDs(d *BeadsData) []string {
var out []string
for i := range d.Lanes {
out = append(out, cardIDs(&d.Lanes[i])...)
}
sort.Strings(out)
return out
}
func TestBeadsBoardFilters(t *testing.T) {
repo := &core.Repo{OwnerName: "a", Name: "b"}
cases := []struct {
name string
query url.Values
want []string // sorted ids expected on the board
}{
{"type", url.Values{"type": {"feature"}}, []string{"i-blocked", "i-open"}},
{"priority", url.Values{"priority": {"1"}}, []string{"i-blocked", "i-open"}},
{"assignee", url.Values{"assignee": {"bob"}}, []string{"i-prog"}},
{"label", url.Values{"label": {"urgent"}}, []string{"i-open"}},
{"query-title", url.Values{"q": {"ready"}}, []string{"i-open"}},
{"query-id", url.Values{"q": {"i-done"}}, []string{"i-done"}},
{"combined-empty", url.Values{"type": {"feature"}, "assignee": {"bob"}}, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := (&beadsView{}).Build(context.Background(), beadsFixture(), repo, "main", tc.query)
if err != nil {
t.Fatalf("Build: %v", err)
}
d := got.(*BeadsData)
ids := boardIDs(d)
if strings.Join(ids, ",") != strings.Join(tc.want, ",") {
t.Errorf("board ids = %v, want %v", ids, tc.want)
}
if d.Total != len(tc.want) || d.Counts.Total != len(tc.want) {
t.Errorf("total = %d/%d, want %d", d.Total, d.Counts.Total, len(tc.want))
}
if !d.Filter.Active() {
t.Errorf("Filter.Active() = false, want true")
}
})
}
}
func TestBeadsBoardFilterOptions(t *testing.T) {
got, _ := (&beadsView{}).Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", url.Values{})
d := got.(*BeadsData)
o := d.FilterOpts
if strings.Join(o.Types, ",") != "bug,chore,feature" {
t.Errorf("types = %v", o.Types)
}
if strings.Join(o.Priorities, ",") != "0,1,2" {
t.Errorf("priorities = %v", o.Priorities)
}
if strings.Join(o.Assignees, ",") != "alice,bob,carol,dave" {
t.Errorf("assignees = %v", o.Assignees)
}
if strings.Join(o.Labels, ",") != "backend,urgent" {
t.Errorf("labels = %v", o.Labels)
}
if d.Filter.Active() {
t.Errorf("no query set, Filter.Active() should be false")
}
}
// 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)
}
// The filter bar renders with option lists drawn from the data.
for _, want := range []string{`class="beads-filter"`, "All types", ">feature<", "All priorities"} {
if !strings.Contains(body, want) {
t.Errorf("board missing filter control %q", want)
}
}
}
func TestBeadsBoardFilterRender(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?type=feature", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("filtered board: got %d; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// The active type is preselected and a Clear link appears.
if !strings.Contains(body, `value="feature" selected`) {
t.Errorf("type filter not preselected; body=%s", body)
}
if !strings.Contains(body, "beads-filter-clear") {
t.Errorf("Clear link missing when a filter is active")
}
// Only feature issues on the board; the bug (i-prog) is filtered out.
if !strings.Contains(body, "i-open") || strings.Contains(body, "i-prog") {
t.Errorf("filtered board should show features only; 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)
}
}
// A closed issue's detail pane must surface its close reason (and the closed
// timestamp), so the resolution recorded by `bd close -r` is not lost.
// TestBeadsDetailShowsCloseReason: the reason appears twice by design — once in
// the Comments tab (which has no closed event) as a Close reason block, and once
// in the History tab as the humanized `closed` event.
func TestBeadsDetailShowsCloseReason(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-done", 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()
// Comments-tab block.
if !strings.Contains(body, "<h4>Close reason</h4>") {
t.Errorf("Comments tab should show a Close reason block; body=%s", body)
}
// History-tab closed event.
if !strings.Contains(body, "closed the issue") {
t.Errorf("History should carry the closed event; body=%s", body)
}
// The reason text itself is present (both places).
if !strings.Contains(body, "Fixed in commit abc123") {
t.Errorf("close reason text missing; body=%s", body)
}
}
// --- epic mode + history -----------------------------------------------------
// beadsEpicFixture models an epic (i-epic) with three parent-child children —
// one closed, one open, one in-progress — plus a comment and three audit events
// on the epic, so both the subtask rollup and the merged history are exercised.
func beadsEpicFixture() *fakeSession {
issues := &browse.RowPage{
Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"},
Rows: [][]string{
{"i-epic", "Big Epic", "open", "1", "epic", "", "2024-01-01", "0"},
{"i-c1", "Child one", "open", "2", "task", "alice", "2024-01-02", "0"},
{"i-c2", "Child two", "closed", "1", "task", "bob", "2024-01-03", "0"},
{"i-c3", "Child three", "in_progress", "0", "bug", "carol", "2024-01-04", "0"},
},
Total: 4,
}
// Each child is the "from" side of a parent-child edge pointing at the epic;
// created_at/created_by let the view synthesize "added subtask" history.
deps := &browse.RowPage{
Columns: []string{"id", "issue_id", "depends_on_issue_id", "type", "created_at", "created_by"},
Rows: [][]string{
{"d1", "i-c1", "i-epic", "parent-child", "2024-01-01 09:00:00", "Eugene"},
{"d2", "i-c2", "i-epic", "parent-child", "2024-01-01 09:05:00", "Eugene"},
{"d3", "i-c3", "i-epic", "parent-child", "2024-01-01 09:10:00", "Eugene"},
},
Total: 3,
}
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-epic", "alice", "kickoff", "2024-01-05 09:00:00"},
{"i-c1", "bob", "unrelated", "2024-01-06 09:00:00"},
},
Total: 2,
}
events := &browse.RowPage{
Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
Rows: [][]string{
{"e1", "i-epic", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-01 08:00:00"},
{"e2", "i-epic", "status_changed", "Eugene", `{"status":"open"}`, `{"status":"in_progress"}`, "NULL", "2024-01-02 10:00:00"},
{"e3", "i-epic", "updated", "Eugene", "NULL", `{"priority":0}`, "NULL", "2024-01-03 11:00:00"},
{"e4", "i-epic", "label_added", "Eugene", "NULL", "NULL", "Added label: milestone:m3", "2024-01-04 09:00:00"},
{"e9", "i-c1", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-02 08:00:00"},
},
Total: 5,
}
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
tables: beadsTables(),
rowsByTable: map[string]*browse.RowPage{
"issues": issues,
"dependencies": deps,
"custom_statuses": statuses,
"comments": comments,
"events": events,
},
}
}
func TestBeadsEpicMode(t *testing.T) {
v := &beadsView{}
raw, err := v.Build(context.Background(), beadsEpicFixture(), nil, "main", url.Values{"issue": {"i-epic"}})
if err != nil {
t.Fatalf("build: %v", err)
}
d := raw.(*BeadsData)
if d.Mode != "epic" {
t.Fatalf("Mode = %q, want epic", d.Mode)
}
if d.SubtaskTotal != 3 || d.SubtaskDone != 1 {
t.Errorf("rollup = %d/%d, want 1/3", d.SubtaskDone, d.SubtaskTotal)
}
if d.SubtaskPct() != 33 {
t.Errorf("pct = %d, want 33", d.SubtaskPct())
}
// Sorted open-work-first (in_progress p0, then open p2), closed sinks last.
gotIDs := []string{d.Subtasks[0].ID, d.Subtasks[1].ID, d.Subtasks[2].ID}
wantIDs := []string{"i-c3", "i-c1", "i-c2"}
for i := range wantIDs {
if gotIDs[i] != wantIDs[i] {
t.Errorf("subtask order = %v, want %v", gotIDs, wantIDs)
break
}
}
if d.Subtasks[2].Category != "closed" {
t.Errorf("last subtask category = %q, want closed", d.Subtasks[2].Category)
}
}
func TestBeadsHistoryMerge(t *testing.T) {
v := &beadsView{}
raw, err := v.Build(context.Background(), beadsEpicFixture(), nil, "main", url.Values{"issue": {"i-epic"}})
if err != nil {
t.Fatalf("build: %v", err)
}
d := raw.(*BeadsData)
// Only the epic's own comment shows in the Comments tab (not i-c1's).
if len(d.Comments) != 1 || d.Comments[0].Text != "kickoff" {
t.Fatalf("comments = %+v, want just the epic's kickoff", d.Comments)
}
// History merges the epic's 1 comment + 4 events + 3 synthesized subtask-add
// entries (i-c1's own event is excluded), time-sorted.
if len(d.History) != 8 {
t.Fatalf("history len = %d, want 8: %+v", len(d.History), d.History)
}
for i := 1; i < len(d.History); i++ {
if d.History[i-1].CreatedAt > d.History[i].CreatedAt {
t.Errorf("history not time-sorted at %d: %q > %q", i, d.History[i-1].CreatedAt, d.History[i].CreatedAt)
}
}
if d.History[0].Kind != "event" || d.History[0].Summary != "created the issue" {
t.Errorf("first history = %+v, want created event", d.History[0])
}
last := d.History[len(d.History)-1]
if last.Kind != "comment" || last.Text != "kickoff" {
t.Errorf("last history = %+v, want the kickoff comment", last)
}
// Humanized change lines, and a label event collapsed to one line with no
// redundant "Added label:" body.
var sawStatus, sawUpdate, sawLabel, sawSubtask bool
for _, a := range d.History {
switch a.Summary {
case "changed status to in_progress":
sawStatus = true
case "updated priority to 0":
sawUpdate = true
case "added label milestone:m3":
sawLabel = true
if a.Text != "" {
t.Errorf("label event should have no body, got %q", a.Text)
}
case "added subtask i-c1":
sawSubtask = true
if a.Kind != "dep" || a.Actor != "Eugene" {
t.Errorf("subtask-add entry = %+v, want kind=dep actor=Eugene", a)
}
}
if strings.Contains(a.Text, "Added label:") {
t.Errorf("label note leaked into a history body: %+v", a)
}
}
if !sawStatus || !sawUpdate || !sawLabel || !sawSubtask {
t.Errorf("history missing lines; status=%v update=%v label=%v subtask=%v",
sawStatus, sawUpdate, sawLabel, sawSubtask)
}
}
func TestBeadsEpicViewRender(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 = beadsEpicFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-epic", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("epic view: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"Subtasks", "1 of 3 done", "epic-progress", "Child three", "History", "changed status to in_progress"} {
if !strings.Contains(body, want) {
t.Errorf("epic render missing %q", want)
}
}
}