From 7ff467f6a577b0b0f6e8bb8c5e39acdca7adabe5 Mon Sep 17 00:00:00 2001 From: "dkoosis@gmail.com" Date: Thu, 20 Aug 2026 00:45:08 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(counts):=20sd-3wp.2=20=E2=80=94=20epic?= =?UTF-8?q?=20titles=20and=20the=20current=20epic's=20roadmap=20slot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wrap's rewritten session banner names epics and states "epic 2 of 7". counts.json could supply neither: epics[] carried ids without titles, and its index counts only live epics, so it cannot express a position among all of them. Two additive fields, from reads computeRow already makes: - EpicRow.title — bd's own epic title, via the EpicStatus read the buckets derive from (bd.EpicRef gains Title to carry it). - Row.roadmap {k, n} — the current epic's 1-based slot among ALL roadmap ids, closed included, so k/n reads as progress through the project. Explicit null when there is no roadmap or no live epic — the same two conditions that null epics. Additive only, same rule as #112: every consumer decodes by name into its own struct, so a key it never mentions cannot affect it. Verified live against sdlc: roadmap {k:2,n:7}, seven epic titles rendered. --- internal/bd/client.go | 5 +- internal/counts/counts.go | 67 +++++++++++++++++++++++---- internal/counts/counts_test.go | 84 +++++++++++++++++++++++++++++++--- 3 files changed, 139 insertions(+), 17 deletions(-) diff --git a/internal/bd/client.go b/internal/bd/client.go index 3a49751..7db2208 100644 --- a/internal/bd/client.go +++ b/internal/bd/client.go @@ -246,9 +246,12 @@ type EpicStatus struct { ClosedChildren int `json:"closed_children"` } -// EpicRef is the epic identity inside an EpicStatus row. +// EpicRef is the epic identity inside an EpicStatus row. Title is bd's own epic +// title, carried so a consumer naming an epic doesn't re-read the DAG for it — +// counts' epics[] rows render it (sd-3wp.2). type EpicRef struct { ID string `json:"id"` + Title string `json:"title"` Status Status `json:"status"` } diff --git a/internal/counts/counts.go b/internal/counts/counts.go index c6ffb0c..c960ee2 100644 --- a/internal/counts/counts.go +++ b/internal/counts/counts.go @@ -57,6 +57,19 @@ type Row struct { // bead Next names when Next.Reason == "claimed"), or nil when nothing is // in_progress. Claimed *Ref `json:"claimed"` + // Roadmap is the current epic's position among ALL roadmap ids, closed + // included — the "epic 3 of 7" a banner states. nil when there is no roadmap + // or no live epic (the same two conditions that null Epics). + Roadmap *RoadmapPos `json:"roadmap"` +} + +// RoadmapPos is Epics[0]'s 1-based position K among the roadmap's N ordered epic +// ids. N counts every id the roadmap names, including closed epics, so K/N reads +// as progress through the project — Epics[] indexes only the live ones and cannot +// express it. +type RoadmapPos struct { + K int `json:"k"` + N int `json:"n"` } // issueTypeEpic is bd's issue_type value for an epic — used to exclude epics @@ -70,10 +83,14 @@ const issueTypeEpic = "epic" // — mirroring Row's own repo-level bw rule. type EpicRow struct { ID string `json:"id"` - BH int `json:"bh"` - BO int `json:"bo"` - BW int `json:"bw"` - BB int `json:"bb"` + // Title is bd's epic title, from the same EpicStatus read the buckets derive + // from — so a consumer renders the epic by name without a second bd fork. + // Empty when bd omitted it. + Title string `json:"title"` + BH int `json:"bh"` + BO int `json:"bo"` + BW int `json:"bw"` + BB int `json:"bb"` } // Next is the what's-next cascade's pick: the bead id/title to work next, and which @@ -130,13 +147,16 @@ func computeRow(ctx context.Context, src source, root string) (Row, error) { lanes := insight.Lanes(issues, deps) bh, bo, bb := laneCounts(lanes) + roadmap := strandmd.Roadmap(root) var epicRows []EpicRow var next *Next var claimed *Ref + var pos *RoadmapPos if epics, err := src.EpicStatus(ctx); err == nil { - liveEpics := liveRoadmapEpics(strandmd.Roadmap(root), epics) - epicRows = epicBuckets(liveEpics, issues, lanes) + liveEpics := liveRoadmapEpics(roadmap, epics) + epicRows = epicBuckets(liveEpics, epicTitles(epics), issues, lanes) next, claimed = pickNext(issues, lanes, currentEpicID(liveEpics), liveEpics) + pos = roadmapPos(roadmap, currentEpicID(liveEpics)) } else { // EpicStatus read failed: degrade epics to nil (no epic data to bucket) and // run the cascade with no epic info — rungs 1 and 3 don't need it, so next @@ -148,7 +168,8 @@ func computeRow(ctx context.Context, src source, root string) (Row, error) { BH: bh, BO: bo, BW: stats.InProgress, BB: bb, BCl: stats.Closed, BDf: stats.Deferred, Epics: epicRows, Next: next, Claimed: claimed, - TS: lastTouched(root), + Roadmap: pos, + TS: lastTouched(root), }, nil } @@ -204,6 +225,34 @@ func currentEpicID(liveEpics []string) string { return liveEpics[0] } +// epicTitles maps epic id → bd's title for the whole EpicStatus set (not only the +// live ones) — the lookup epicBuckets fills EpicRow.Title from. An epic bd gave no +// title for maps to "", which renders as an absent title rather than an error. +func epicTitles(epics []bd.EpicStatus) map[string]string { + titles := make(map[string]string, len(epics)) + for _, e := range epics { + titles[e.Epic.ID] = e.Epic.Title + } + return titles +} + +// roadmapPos locates currentEpic among ALL roadmap ids — closed epics included, so +// K/N reads as progress through the project rather than through what happens to be +// live. nil when there is no roadmap, no current epic, or the current epic somehow +// is not on the roadmap (impossible today: liveRoadmapEpics only ever returns ids +// it walked the roadmap to find, but the guard keeps the two independent). +func roadmapPos(roadmap []string, currentEpic string) *RoadmapPos { + if len(roadmap) == 0 || currentEpic == "" { + return nil + } + for i, id := range roadmap { + if id == currentEpic { + return &RoadmapPos{K: i + 1, N: len(roadmap)} + } + } + return nil +} + // epicBuckets builds one ◆○◐● bucket row per live roadmap epic, in liveEpics' order // (roadmap order), over each epic's DIRECT children only (Issue.Parent == the // epic's id) — a nested epic child (issue_type=="epic") never counts toward its @@ -211,14 +260,14 @@ func currentEpicID(liveEpics []string) string { // children, overlapping bh for a gated in-progress child (mirrors laneCounts' own // repo-level bw rule). nil when liveEpics is empty, matching the JSON schema's // "epics: null" for a repo with no live roadmap epic. -func epicBuckets(liveEpics []string, issues []bd.Issue, lanes map[string]insight.Lane) []EpicRow { +func epicBuckets(liveEpics []string, titles map[string]string, issues []bd.Issue, lanes map[string]insight.Lane) []EpicRow { if len(liveEpics) == 0 { return nil } rows := make([]EpicRow, len(liveEpics)) idx := make(map[string]int, len(liveEpics)) for i, id := range liveEpics { - rows[i] = EpicRow{ID: id} + rows[i] = EpicRow{ID: id, Title: titles[id]} idx[id] = i } for i := range issues { diff --git a/internal/counts/counts_test.go b/internal/counts/counts_test.go index 7d11604..3067d9f 100644 --- a/internal/counts/counts_test.go +++ b/internal/counts/counts_test.go @@ -301,7 +301,8 @@ func TestEpicBuckets(t *testing.T) { {ID: "e3.open", Parent: "e3", Status: bd.StatusOpen}, // e3 not in liveEpics: must be absent } lanes := insight.Lanes(issues, nil) - got := epicBuckets([]string{"e1", "e2"}, issues, lanes) + titles := map[string]string{"e1": "First epic", "e3": "Not live"} + got := epicBuckets([]string{"e1", "e2"}, titles, issues, lanes) if len(got) != 2 { t.Fatalf("epicBuckets returned %d rows, want 2 (roadmap order, e3 excluded): %+v", len(got), got) } @@ -314,12 +315,20 @@ func TestEpicBuckets(t *testing.T) { if e2 := got[1]; e2.BH != 0 || e2.BO != 1 || e2.BW != 0 || e2.BB != 0 { t.Errorf("e2 buckets = %+v, want bo=1 only", e2) } + // Title comes from the EpicStatus lookup, and an epic bd gave no title for + // renders as empty — not as a missing row. + if got[0].Title != "First epic" { + t.Errorf("e1 title = %q, want %q", got[0].Title, "First epic") + } + if got[1].Title != "" { + t.Errorf("e2 title = %q, want empty — no title in the lookup", got[1].Title) + } } // TestEpicBucketsNilForNoLiveEpics: nil roadmap (or an all-ghost/all-closed one) → // nil, matching the JSON schema's "epics: null" for a repo with no roadmap. func TestEpicBucketsNilForNoLiveEpics(t *testing.T) { - if got := epicBuckets(nil, nil, nil); got != nil { + if got := epicBuckets(nil, nil, nil, nil); got != nil { t.Errorf("epicBuckets(nil roadmap) = %v, want nil", got) } } @@ -378,6 +387,52 @@ func TestCurrentEpicID(t *testing.T) { // TestComputeRowAssemblesEpicsNextAndClaimed: a real ROADMAP.md resolves through // strandmd.Roadmap, the epic buckets and the cascade both derive from the SAME // issues/lanes computeRow already fetched (zero new bd execs). +// TestRoadmapPos pins K/N against ALL roadmap ids — a closed epic still occupies a +// slot, which is the whole reason this field exists rather than an epics[] index. +func TestRoadmapPos(t *testing.T) { + roadmap := []string{"e1", "e2", "e3"} // e1 closed: still counted, still shifts e2 to slot 2 + tests := []struct { + name string + roadmap []string + currentEpic string + want *RoadmapPos + }{ + {"current epic mid-roadmap", roadmap, "e2", &RoadmapPos{K: 2, N: 3}}, + {"first slot", roadmap, "e1", &RoadmapPos{K: 1, N: 3}}, + {"last slot", roadmap, "e3", &RoadmapPos{K: 3, N: 3}}, + {"no roadmap", nil, "e2", nil}, + {"no current epic", roadmap, "", nil}, + {"current epic off the roadmap", roadmap, "e9", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := roadmapPos(tt.roadmap, tt.currentEpic) + switch { + case tt.want == nil && got != nil: + t.Fatalf("roadmapPos = %+v, want nil", got) + case tt.want != nil && got == nil: + t.Fatalf("roadmapPos = nil, want %+v", tt.want) + case tt.want != nil && (got.K != tt.want.K || got.N != tt.want.N): + t.Errorf("roadmapPos = %+v, want %+v", got, tt.want) + } + }) + } +} + +// TestEpicTitlesCoversWholeSet: the lookup carries every epic bd reported, not only +// the live ones — a closed epic's title stays reachable for any consumer that wants +// to name it. +func TestEpicTitlesCoversWholeSet(t *testing.T) { + got := epicTitles([]bd.EpicStatus{ + {Epic: bd.EpicRef{ID: "e1", Title: "Closed one", Status: bd.StatusClosed}}, + {Epic: bd.EpicRef{ID: "e2", Title: "Live one", Status: bd.StatusOpen}}, + {Epic: bd.EpicRef{ID: "e3", Status: bd.StatusOpen}}, + }) + if len(got) != 3 || got["e1"] != "Closed one" || got["e2"] != "Live one" || got["e3"] != "" { + t.Errorf("epicTitles = %+v, want all three ids with e3 empty", got) + } +} + func TestComputeRowAssemblesEpicsNextAndClaimed(t *testing.T) { src := &fakeSource{ issues: []bd.Issue{ @@ -385,10 +440,12 @@ func TestComputeRowAssemblesEpicsNextAndClaimed(t *testing.T) { {ID: "e2.ip", Parent: "e2", Status: bd.StatusInProgress, Priority: new(0)}, // rung 1 winner }, epics: []bd.EpicStatus{ - {Epic: bd.EpicRef{ID: "e2", Status: bd.StatusOpen}, TotalChildren: 2, ClosedChildren: 0}, + {Epic: bd.EpicRef{ID: "e2", Title: "Second epic", Status: bd.StatusOpen}, TotalChildren: 2, ClosedChildren: 0}, }, } - root := writeRoadmapDir(t, "## Epics\n1. Title → e2\n") + // e1 is on the roadmap but absent from the EpicStatus set (a ghost id), so the + // live list starts at e2 while the roadmap position still counts e1: 2 of 2. + root := writeRoadmapDir(t, "## Epics\n1. First → e1\n2. Title → e2\n") row, err := computeRow(context.Background(), src, root) if err != nil { t.Fatalf("computeRow: %v", err) @@ -396,6 +453,12 @@ func TestComputeRowAssemblesEpicsNextAndClaimed(t *testing.T) { if len(row.Epics) != 1 || row.Epics[0].ID != "e2" || row.Epics[0].BO != 1 { t.Errorf("Epics = %+v, want one e2 row with bo=1", row.Epics) } + if len(row.Epics) == 1 && row.Epics[0].Title != "Second epic" { + t.Errorf("Epics[0].Title = %q, want %q — carried from the EpicStatus read", row.Epics[0].Title, "Second epic") + } + if row.Roadmap == nil || row.Roadmap.K != 2 || row.Roadmap.N != 2 { + t.Errorf("Roadmap = %+v, want k=2 n=2 — the current epic's slot among ALL roadmap ids", row.Roadmap) + } if row.Next == nil || row.Next.ID != "e2.ip" || row.Next.Reason != "claimed" { t.Errorf("Next = %+v, want e2.ip/claimed", row.Next) } @@ -426,6 +489,9 @@ func TestComputeRowEpicStatusFailureDegradesEpicsAndNext(t *testing.T) { if row.Next == nil || row.Next.ID != "b1" || row.Next.Reason != "waiting-on-dk" { t.Errorf("Next = %+v, want b1/waiting-on-dk — rung 3 is still reachable without epic info", row.Next) } + if row.Roadmap != nil { + t.Errorf("Roadmap = %+v, want nil on EpicStatus failure — no current epic to place", row.Roadmap) + } } // --- JSON shape: additive fields, explicit null, existing keys untouched --- @@ -443,7 +509,7 @@ func TestRowJSONShapeExplicitNulls(t *testing.T) { if err := json.Unmarshal(b, &m); err != nil { t.Fatalf("unmarshal: %v", err) } - for _, key := range []string{"epics", "next", "claimed"} { + for _, key := range []string{"epics", "next", "claimed", "roadmap"} { raw, ok := m[key] if !ok { t.Fatalf("key %q missing from JSON, want present as explicit null", key) @@ -469,9 +535,10 @@ func TestRowJSONShapeExplicitNulls(t *testing.T) { // TestRowJSONShapePopulated: a populated Row round-trips epics/next/claimed intact. func TestRowJSONShapePopulated(t *testing.T) { row := Row{ - Epics: []EpicRow{{ID: "e1", BH: 1}}, + Epics: []EpicRow{{ID: "e1", Title: "Epic one", BH: 1}}, Next: &Next{ID: "n1", Title: "t", Reason: "claimed"}, Claimed: &Ref{ID: "n1", Title: "t"}, + Roadmap: &RoadmapPos{K: 2, N: 7}, } b, err := json.Marshal(row) if err != nil { @@ -481,9 +548,12 @@ func TestRowJSONShapePopulated(t *testing.T) { if err := json.Unmarshal(b, &got); err != nil { t.Fatalf("unmarshal: %v", err) } - if len(got.Epics) != 1 || got.Epics[0].ID != "e1" { + if len(got.Epics) != 1 || got.Epics[0].ID != "e1" || got.Epics[0].Title != "Epic one" { t.Errorf("Epics round-trip = %+v", got.Epics) } + if got.Roadmap == nil || got.Roadmap.K != 2 || got.Roadmap.N != 7 { + t.Errorf("Roadmap round-trip = %+v, want k=2 n=7", got.Roadmap) + } if got.Next == nil || got.Next.Reason != "claimed" { t.Errorf("Next round-trip = %+v", got.Next) } From f7060a290533bbb4b5e034600942ab63d9cc5e62 Mon Sep 17 00:00:00 2001 From: "dkoosis@gmail.com" Date: Thu, 20 Aug 2026 00:49:32 -0400 Subject: [PATCH 2/2] feat(counts): carry bd's closed/total child roll-up per epic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four lane buckets partition only LIVE children — bd list omits closed — so epics[] could not answer "how far through this epic are we", which the banner's ✓ tally and progress bar both need. EpicRow gains bcl (closed children) and n (total children), taken verbatim from the EpicStatus read epicBuckets already walks. They are bd's own roll-up, not a re-derivation: a nested epic child counts there and not in the buckets, and the comment says so. epicTitles becomes epicMeta — the lookup now carries the whole EpicStatus row rather than just the title. Live against sdlc: sd-ev2 bcl 12 / n 13, matching bd epic status. --- internal/counts/counts.go | 31 +++++++++++++------- internal/counts/counts_test.go | 52 +++++++++++++++++++++++++--------- 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/internal/counts/counts.go b/internal/counts/counts.go index c960ee2..c2431bf 100644 --- a/internal/counts/counts.go +++ b/internal/counts/counts.go @@ -91,6 +91,14 @@ type EpicRow struct { BO int `json:"bo"` BW int `json:"bw"` BB int `json:"bb"` + // BCl and N are bd's OWN child roll-up for this epic — closed children and + // total children, straight from the EpicStatus read. The four lane buckets + // above partition only LIVE children (bd list omits closed), so they cannot + // express "how far through this epic are we"; these two can, and a consumer + // renders ✓/a progress bar from them without a second bd fork. They are bd's + // count, not ours: a nested epic child counts here and not in the buckets. + BCl int `json:"bcl"` + N int `json:"n"` } // Next is the what's-next cascade's pick: the bead id/title to work next, and which @@ -154,7 +162,7 @@ func computeRow(ctx context.Context, src source, root string) (Row, error) { var pos *RoadmapPos if epics, err := src.EpicStatus(ctx); err == nil { liveEpics := liveRoadmapEpics(roadmap, epics) - epicRows = epicBuckets(liveEpics, epicTitles(epics), issues, lanes) + epicRows = epicBuckets(liveEpics, epicMeta(epics), issues, lanes) next, claimed = pickNext(issues, lanes, currentEpicID(liveEpics), liveEpics) pos = roadmapPos(roadmap, currentEpicID(liveEpics)) } else { @@ -225,15 +233,17 @@ func currentEpicID(liveEpics []string) string { return liveEpics[0] } -// epicTitles maps epic id → bd's title for the whole EpicStatus set (not only the -// live ones) — the lookup epicBuckets fills EpicRow.Title from. An epic bd gave no -// title for maps to "", which renders as an absent title rather than an error. -func epicTitles(epics []bd.EpicStatus) map[string]string { - titles := make(map[string]string, len(epics)) +// epicMeta maps epic id → the EpicStatus facts EpicRow carries beyond its own lane +// buckets: bd's title and bd's closed/total child roll-up. Built over the WHOLE +// EpicStatus set, not only the live epics, so a closed epic stays reachable for any +// consumer that wants to name it. An epic bd reported no title for maps to "", +// which renders as an absent title rather than an error. +func epicMeta(epics []bd.EpicStatus) map[string]bd.EpicStatus { + meta := make(map[string]bd.EpicStatus, len(epics)) for _, e := range epics { - titles[e.Epic.ID] = e.Epic.Title + meta[e.Epic.ID] = e } - return titles + return meta } // roadmapPos locates currentEpic among ALL roadmap ids — closed epics included, so @@ -260,14 +270,15 @@ func roadmapPos(roadmap []string, currentEpic string) *RoadmapPos { // children, overlapping bh for a gated in-progress child (mirrors laneCounts' own // repo-level bw rule). nil when liveEpics is empty, matching the JSON schema's // "epics: null" for a repo with no live roadmap epic. -func epicBuckets(liveEpics []string, titles map[string]string, issues []bd.Issue, lanes map[string]insight.Lane) []EpicRow { +func epicBuckets(liveEpics []string, meta map[string]bd.EpicStatus, issues []bd.Issue, lanes map[string]insight.Lane) []EpicRow { if len(liveEpics) == 0 { return nil } rows := make([]EpicRow, len(liveEpics)) idx := make(map[string]int, len(liveEpics)) for i, id := range liveEpics { - rows[i] = EpicRow{ID: id, Title: titles[id]} + m := meta[id] + rows[i] = EpicRow{ID: id, Title: m.Epic.Title, BCl: m.ClosedChildren, N: m.TotalChildren} idx[id] = i } for i := range issues { diff --git a/internal/counts/counts_test.go b/internal/counts/counts_test.go index 3067d9f..ba161ef 100644 --- a/internal/counts/counts_test.go +++ b/internal/counts/counts_test.go @@ -301,8 +301,11 @@ func TestEpicBuckets(t *testing.T) { {ID: "e3.open", Parent: "e3", Status: bd.StatusOpen}, // e3 not in liveEpics: must be absent } lanes := insight.Lanes(issues, nil) - titles := map[string]string{"e1": "First epic", "e3": "Not live"} - got := epicBuckets([]string{"e1", "e2"}, titles, issues, lanes) + meta := map[string]bd.EpicStatus{ + "e1": {Epic: bd.EpicRef{ID: "e1", Title: "First epic"}, TotalChildren: 9, ClosedChildren: 3}, + "e3": {Epic: bd.EpicRef{ID: "e3", Title: "Not live"}}, + } + got := epicBuckets([]string{"e1", "e2"}, meta, issues, lanes) if len(got) != 2 { t.Fatalf("epicBuckets returned %d rows, want 2 (roadmap order, e3 excluded): %+v", len(got), got) } @@ -323,6 +326,14 @@ func TestEpicBuckets(t *testing.T) { if got[1].Title != "" { t.Errorf("e2 title = %q, want empty — no title in the lookup", got[1].Title) } + // bcl/n are bd's own roll-up, carried verbatim — not re-derived from the + // lane buckets, which see only live children. + if got[0].BCl != 3 || got[0].N != 9 { + t.Errorf("e1 roll-up = bcl %d n %d, want 3/9 — bd's own closed/total children", got[0].BCl, got[0].N) + } + if got[1].BCl != 0 || got[1].N != 0 { + t.Errorf("e2 roll-up = bcl %d n %d, want 0/0 — absent from the lookup", got[1].BCl, got[1].N) + } } // TestEpicBucketsNilForNoLiveEpics: nil roadmap (or an all-ghost/all-closed one) → @@ -419,17 +430,26 @@ func TestRoadmapPos(t *testing.T) { } } -// TestEpicTitlesCoversWholeSet: the lookup carries every epic bd reported, not only -// the live ones — a closed epic's title stays reachable for any consumer that wants -// to name it. -func TestEpicTitlesCoversWholeSet(t *testing.T) { - got := epicTitles([]bd.EpicStatus{ - {Epic: bd.EpicRef{ID: "e1", Title: "Closed one", Status: bd.StatusClosed}}, - {Epic: bd.EpicRef{ID: "e2", Title: "Live one", Status: bd.StatusOpen}}, +// TestEpicMetaCoversWholeSet: the lookup carries every epic bd reported, not only +// the live ones — a closed epic's title and roll-up stay reachable for any consumer +// that wants to name it. +func TestEpicMetaCoversWholeSet(t *testing.T) { + got := epicMeta([]bd.EpicStatus{ + {Epic: bd.EpicRef{ID: "e1", Title: "Closed one", Status: bd.StatusClosed}, TotalChildren: 4, ClosedChildren: 4}, + {Epic: bd.EpicRef{ID: "e2", Title: "Live one", Status: bd.StatusOpen}, TotalChildren: 3, ClosedChildren: 1}, {Epic: bd.EpicRef{ID: "e3", Status: bd.StatusOpen}}, }) - if len(got) != 3 || got["e1"] != "Closed one" || got["e2"] != "Live one" || got["e3"] != "" { - t.Errorf("epicTitles = %+v, want all three ids with e3 empty", got) + if len(got) != 3 { + t.Fatalf("epicMeta = %+v, want all three ids", got) + } + if got["e1"].Epic.Title != "Closed one" || got["e1"].ClosedChildren != 4 { + t.Errorf("e1 = %+v, want the closed epic's title and roll-up", got["e1"]) + } + if got["e2"].TotalChildren != 3 || got["e2"].ClosedChildren != 1 { + t.Errorf("e2 roll-up = %+v, want 1/3", got["e2"]) + } + if got["e3"].Epic.Title != "" { + t.Errorf("e3 title = %q, want empty", got["e3"].Epic.Title) } } @@ -440,7 +460,7 @@ func TestComputeRowAssemblesEpicsNextAndClaimed(t *testing.T) { {ID: "e2.ip", Parent: "e2", Status: bd.StatusInProgress, Priority: new(0)}, // rung 1 winner }, epics: []bd.EpicStatus{ - {Epic: bd.EpicRef{ID: "e2", Title: "Second epic", Status: bd.StatusOpen}, TotalChildren: 2, ClosedChildren: 0}, + {Epic: bd.EpicRef{ID: "e2", Title: "Second epic", Status: bd.StatusOpen}, TotalChildren: 5, ClosedChildren: 3}, }, } // e1 is on the roadmap but absent from the EpicStatus set (a ghost id), so the @@ -456,6 +476,9 @@ func TestComputeRowAssemblesEpicsNextAndClaimed(t *testing.T) { if len(row.Epics) == 1 && row.Epics[0].Title != "Second epic" { t.Errorf("Epics[0].Title = %q, want %q — carried from the EpicStatus read", row.Epics[0].Title, "Second epic") } + if len(row.Epics) == 1 && (row.Epics[0].BCl != 3 || row.Epics[0].N != 5) { + t.Errorf("Epics[0] roll-up = bcl %d n %d, want 3/5", row.Epics[0].BCl, row.Epics[0].N) + } if row.Roadmap == nil || row.Roadmap.K != 2 || row.Roadmap.N != 2 { t.Errorf("Roadmap = %+v, want k=2 n=2 — the current epic's slot among ALL roadmap ids", row.Roadmap) } @@ -535,7 +558,7 @@ func TestRowJSONShapeExplicitNulls(t *testing.T) { // TestRowJSONShapePopulated: a populated Row round-trips epics/next/claimed intact. func TestRowJSONShapePopulated(t *testing.T) { row := Row{ - Epics: []EpicRow{{ID: "e1", Title: "Epic one", BH: 1}}, + Epics: []EpicRow{{ID: "e1", Title: "Epic one", BH: 1, BCl: 4, N: 9}}, Next: &Next{ID: "n1", Title: "t", Reason: "claimed"}, Claimed: &Ref{ID: "n1", Title: "t"}, Roadmap: &RoadmapPos{K: 2, N: 7}, @@ -548,7 +571,8 @@ func TestRowJSONShapePopulated(t *testing.T) { if err := json.Unmarshal(b, &got); err != nil { t.Fatalf("unmarshal: %v", err) } - if len(got.Epics) != 1 || got.Epics[0].ID != "e1" || got.Epics[0].Title != "Epic one" { + if len(got.Epics) != 1 || got.Epics[0].ID != "e1" || got.Epics[0].Title != "Epic one" || + got.Epics[0].BCl != 4 || got.Epics[0].N != 9 { t.Errorf("Epics round-trip = %+v", got.Epics) } if got.Roadmap == nil || got.Roadmap.K != 2 || got.Roadmap.N != 7 {