diff --git a/internal/commands/roadmap.go b/internal/commands/roadmap.go index 15f180f2..afec2cfd 100644 --- a/internal/commands/roadmap.go +++ b/internal/commands/roadmap.go @@ -31,29 +31,38 @@ var ( // roadmapData holds the structured roadmap for JSON output. type roadmapData struct { - Milestones []milestoneGroup `json:"milestones"` + Milestones []milestoneGroup `json:"milestones"` Unscheduled *unscheduledGroup `json:"unscheduled,omitempty"` } // unscheduledGroup represents items not assigned to any milestone. type unscheduledGroup struct { - Epics []epicGroup `json:"epics,omitempty"` - Other []*bean.Bean `json:"other,omitempty"` + Epics []epicGroup `json:"epics,omitempty"` + Features []featureGroup `json:"features,omitempty"` + Other []*bean.Bean `json:"other,omitempty"` } // milestoneGroup represents a milestone and its contents. type milestoneGroup struct { - Milestone *bean.Bean `json:"milestone"` - Epics []epicGroup `json:"epics,omitempty"` - Other []*bean.Bean `json:"other,omitempty"` + Milestone *bean.Bean `json:"milestone"` + Epics []epicGroup `json:"epics,omitempty"` + Features []featureGroup `json:"features,omitempty"` + Other []*bean.Bean `json:"other,omitempty"` } // epicGroup represents an epic and its child items. type epicGroup struct { - Epic *bean.Bean `json:"epic"` - Items []*bean.Bean `json:"items,omitempty"` + Epic *bean.Bean `json:"epic"` + Items []*bean.Bean `json:"items,omitempty"` + Features []featureGroup `json:"features,omitempty"` } +// featureGroup represents a feature and the leaf items found anywhere +// beneath it (leafs below nested features are flattened into this list). +type featureGroup struct { + Feature *bean.Bean `json:"feature"` + Items []*bean.Bean `json:"items,omitempty"` +} var roadmapCmd = &cobra.Command{ Use: "roadmap", @@ -130,7 +139,7 @@ func buildRoadmap(allBeans []*bean.Bean, includeDone bool, statusFilter, noStatu for _, m := range milestones { group := buildMilestoneGroup(m, children, includeDone) // Only include milestones that have visible content - if len(group.Epics) > 0 || len(group.Other) > 0 { + if len(group.Epics) > 0 || len(group.Features) > 0 || len(group.Other) > 0 { milestoneGroups = append(milestoneGroups, group) } } @@ -160,11 +169,9 @@ func buildRoadmap(allBeans []*bean.Bean, includeDone bool, statusFilter, noStatu if underMilestone[b.ID] { continue } - // Build epic group if it has visible children - epicItems := filterChildren(children[b.ID], includeDone) - if len(epicItems) > 0 { - sortByTypeThenStatus(epicItems, cfg) - unscheduledEpics = append(unscheduledEpics, epicGroup{Epic: b, Items: epicItems}) + eg := buildEpicGroup(b, children, includeDone) + if len(eg.Items) > 0 || len(eg.Features) > 0 { + unscheduledEpics = append(unscheduledEpics, eg) } } @@ -173,13 +180,53 @@ func buildRoadmap(allBeans []*bean.Bean, includeDone bool, statusFilter, noStatu return unscheduledEpics[i].Epic.Title < unscheduledEpics[j].Epic.Title }) + // Find unscheduled features: feature-typed beans that are not under a + // milestone or epic (orphan features, e.g. created without --parent). + var unscheduledFeatures []featureGroup + for _, b := range allBeans { + if b.Type != "feature" { + continue + } + if underMilestone[b.ID] { + continue + } + // Skip features that are themselves children of an unscheduled epic + // or of another feature above -- those are already rendered as part + // of that ancestor's Features list / flattened into its Items via + // collectLeafDescendants. (feature-under-feature is rejected by + // ValidateParent via the CLI, but beans are hand-editable markdown -- + // this guard keeps hand-edited data from double-rendering.) + if b.Parent != "" { + if parent, ok := byID[b.Parent]; ok && (parent.Type == "epic" || parent.Type == "feature") { + continue + } + } + fg := buildFeatureGroup(b, children, includeDone) + if len(fg.Items) > 0 { + unscheduledFeatures = append(unscheduledFeatures, fg) + } + } + sort.Slice(unscheduledFeatures, func(i, j int) bool { + return unscheduledFeatures[i].Feature.Title < unscheduledFeatures[j].Feature.Title + }) + // Find orphan items (not milestone, not epic, no parent or parent is not milestone/epic) var orphanItems []*bean.Bean for _, b := range allBeans { - // Skip milestones and epics + // Skip milestones and epics -- always containers, never flat leaves. if b.Type == "milestone" || b.Type == "epic" { continue } + if b.Type == "feature" { + // Features with >=1 leaf descendant are rendered via the + // unscheduledFeatures loop above as a featureGroup; skip them + // here to avoid double-rendering. Childless features (D01, + // beans-n8zw) are not containers -- fall through and treat + // them as a flat leaf like any other orphan item below. + if fg, _ := classifyFeatureChild(b, children, includeDone); fg != nil { + continue + } + } // Skip if already under a milestone if underMilestone[b.ID] { continue @@ -200,10 +247,11 @@ func buildRoadmap(allBeans []*bean.Bean, includeDone bool, statusFilter, noStatu // Build unscheduled group if there's content var unscheduled *unscheduledGroup - if len(unscheduledEpics) > 0 || len(orphanItems) > 0 { + if len(unscheduledEpics) > 0 || len(unscheduledFeatures) > 0 || len(orphanItems) > 0 { unscheduled = &unscheduledGroup{ - Epics: unscheduledEpics, - Other: orphanItems, + Epics: unscheduledEpics, + Features: unscheduledFeatures, + Other: orphanItems, } } @@ -222,47 +270,114 @@ func buildMilestoneGroup(m *bean.Bean, children map[string][]*bean.Bean, include // Separate epics from other items var epics []*bean.Bean - + var rest []*bean.Bean for _, child := range directChildren { if child.Type == "epic" { epics = append(epics, child) + } else { + rest = append(rest, child) } } // Build epic groups for _, epic := range epics { - epicItems := filterChildren(children[epic.ID], includeDone) - // Only include epics that have visible children - if len(epicItems) > 0 { - sortByTypeThenStatus(epicItems, cfg) - group.Epics = append(group.Epics, epicGroup{Epic: epic, Items: epicItems}) + eg := buildEpicGroup(epic, children, includeDone) + if len(eg.Items) > 0 || len(eg.Features) > 0 { + group.Epics = append(group.Epics, eg) } } - // Build "Other" list: direct children that are not epics - // (With single parent enforcement, items can't be both under an epic and directly under the milestone) - var other []*bean.Bean - for _, child := range directChildren { - if child.Type == "epic" { - continue + // Split the milestone's non-epic direct children into leaf items and + // feature-typed children (which need their own recursive resolution). + other, featureChildren := splitByContainerType(rest) + + for _, feature := range featureChildren { + fg, leaf := classifyFeatureChild(feature, children, includeDone) + if fg != nil { + group.Features = append(group.Features, *fg) } + if leaf != nil { + // Childless feature (D01, beans-n8zw): not a container, render + // as a flat leaf alongside the milestone's other direct items. + other = append(other, leaf) + } + } + + // Filter the remaining flat "Other" items by done status. + var filteredOther []*bean.Bean + for _, child := range other { if includeDone || !cfg.IsArchiveStatus(child.Status) { - other = append(other, child) + filteredOther = append(filteredOther, child) } } - // Sort epics by their epic's title + // Sort epics and features by their title sort.Slice(group.Epics, func(i, j int) bool { return group.Epics[i].Epic.Title < group.Epics[j].Epic.Title }) + sort.Slice(group.Features, func(i, j int) bool { + return group.Features[i].Feature.Title < group.Features[j].Feature.Title + }) // Sort other items - sortByTypeThenStatus(other, cfg) - group.Other = other + sortByTypeThenStatus(filteredOther, cfg) + group.Other = filteredOther return group } +// buildEpicGroup builds an epic group: its direct leaf children plus a +// recursively-resolved featureGroup for each direct feature child. +func buildEpicGroup(epic *bean.Bean, children map[string][]*bean.Bean, includeDone bool) epicGroup { + leafs, featureChildren := splitByContainerType(children[epic.ID]) + + eg := epicGroup{Epic: epic} + for _, feature := range featureChildren { + fg, leaf := classifyFeatureChild(feature, children, includeDone) + if fg != nil { + eg.Features = append(eg.Features, *fg) + } + if leaf != nil { + // Childless feature (D01, beans-n8zw): not a container, render + // as a flat leaf alongside the epic's other direct items. + leafs = append(leafs, leaf) + } + } + + leafItems := filterChildren(leafs, includeDone) + sortByTypeThenStatus(leafItems, cfg) + eg.Items = leafItems + + sort.Slice(eg.Features, func(i, j int) bool { + return eg.Features[i].Feature.Title < eg.Features[j].Feature.Title + }) + return eg +} + +// classifyFeatureChild resolves a direct feature-typed child bean per D01 +// (beans-n8zw): a feature is a container IFF it has >=1 leaf descendant +// (collectLeafDescendants, respecting includeDone). If it has descendants, +// the resolved featureGroup is returned for container rendering (existing +// behavior, unchanged). If it has none, the feature bean itself is returned +// as leaf so the caller can fold it into its own flat-leaf list -- and go +// through the exact same archive-status filtering every other leaf in that +// list goes through, instead of being silently dropped. +func classifyFeatureChild(feature *bean.Bean, children map[string][]*bean.Bean, includeDone bool) (fg *featureGroup, leaf *bean.Bean) { + built := buildFeatureGroup(feature, children, includeDone) + if len(built.Items) > 0 { + return &built, nil + } + return nil, feature +} + +// buildFeatureGroup builds a feature group: all leaf descendants found +// anywhere beneath the feature, flattened and sorted. +func buildFeatureGroup(feature *bean.Bean, children map[string][]*bean.Bean, includeDone bool) featureGroup { + items := collectLeafDescendants(feature.ID, children, includeDone) + sortByTypeThenStatus(items, cfg) + return featureGroup{Feature: feature, Items: items} +} + // filterChildren filters children based on done status. func filterChildren(children []*bean.Bean, includeDone bool) []*bean.Bean { if includeDone { @@ -281,6 +396,51 @@ func filterChildren(children []*bean.Bean, includeDone bool) []*bean.Bean { return filtered } +// splitByContainerType separates a bean's direct children into leafs +// (anything that isn't a feature) and feature-typed children. +func splitByContainerType(beans []*bean.Bean) (leafs []*bean.Bean, features []*bean.Bean) { + for _, b := range beans { + if b.Type == "feature" { + features = append(features, b) + } else { + leafs = append(leafs, b) + } + } + return leafs, features +} + +// collectLeafDescendants recursively walks everything below parentID and +// returns the leaf beans found at any depth, flattened. Feature-typed +// descendants are transparent containers: their own children are walked +// too, but the feature bean itself is never included in the result. +// beans.yml's ValidateParent forbids feature-under-feature via the CLI, so +// this only recurses more than one level on hand-edited data -- the +// visited guard exists purely so a hand-authored parent cycle can't crash +// roadmap with a stack overflow (the old, non-recursive code was immune). +func collectLeafDescendants(parentID string, children map[string][]*bean.Bean, includeDone bool) []*bean.Bean { + return collectLeafDescendantsVisited(parentID, children, includeDone, map[string]bool{}) +} + +func collectLeafDescendantsVisited(parentID string, children map[string][]*bean.Bean, includeDone bool, visited map[string]bool) []*bean.Bean { + if visited[parentID] { + return nil + } + visited[parentID] = true + + var leafs []*bean.Bean + for _, child := range children[parentID] { + if child.Type == "feature" { + leafs = append(leafs, collectLeafDescendantsVisited(child.ID, children, includeDone, visited)...) + continue + } + if !includeDone && cfg.IsArchiveStatus(child.Status) { + continue + } + leafs = append(leafs, child) + } + return leafs +} + // containsStatus checks if a status is in the list. func containsStatus(statuses []string, status string) bool { return slices.Contains(statuses, status) diff --git a/internal/commands/roadmap.tmpl b/internal/commands/roadmap.tmpl index 7b0ac4e9..9d3f6658 100644 --- a/internal/commands/roadmap.tmpl +++ b/internal/commands/roadmap.tmpl @@ -2,6 +2,17 @@ - {{typeBadge .}} {{.Title}} {{beanRef .}} {{end -}} +{{- define "featureGroup" -}} +#### Feature: {{.Feature.Title}} {{beanRef .Feature}} +{{with firstParagraph .Feature.Body}} +> {{.}} +{{end}} + +{{range .Items -}} +{{template "beanLine" .}} +{{- end}} +{{- end -}} + {{- define "epicGroup" -}} ### Epic: {{.Epic.Title}} {{beanRef .Epic}} {{with firstParagraph .Epic.Body}} @@ -11,6 +22,9 @@ {{range .Items -}} {{template "beanLine" .}} {{- end}} +{{- range .Features -}} +{{template "featureGroup" .}} +{{- end}} {{- end -}} # Roadmap @@ -22,8 +36,11 @@ {{range .Epics -}} {{template "epicGroup" .}} {{- end}} +{{- range .Features -}} +{{template "featureGroup" .}} +{{- end}} {{- if .Other}} -{{- if len .Epics}} +{{- if or (len .Epics) (len .Features)}} ### Miscellaneous {{end}} @@ -39,8 +56,11 @@ {{- range .Unscheduled.Epics -}} {{template "epicGroup" .}} {{- end}} +{{- range .Unscheduled.Features -}} +{{template "featureGroup" .}} +{{- end}} {{- if .Unscheduled.Other}} -{{- if len .Unscheduled.Epics}} +{{- if or (len .Unscheduled.Epics) (len .Unscheduled.Features)}} ### Miscellaneous {{end}} diff --git a/internal/commands/roadmap_test.go b/internal/commands/roadmap_test.go index ac3d7021..5a6602cf 100644 --- a/internal/commands/roadmap_test.go +++ b/internal/commands/roadmap_test.go @@ -98,6 +98,25 @@ func TestBuildRoadmap(t *testing.T) { wantMilestones: 0, // milestone has no children wantUnscheduledOther: 1, // orphan appears in unscheduled }, + { + name: "leaf nested under feature under epic under milestone is not lost", + beans: []*bean.Bean{ + {ID: "m1", Type: "milestone", Title: "v1.0", Status: "todo", CreatedAt: &now}, + {ID: "e1", Type: "epic", Title: "Auth", Status: "todo", Parent: "m1"}, + {ID: "f1", Type: "feature", Title: "SSO", Status: "todo", Parent: "e1"}, + {ID: "t1", Type: "task", Title: "OIDC login", Status: "todo", Parent: "f1"}, + }, + wantMilestones: 1, + }, + { + name: "milestone with direct feature child and no epic is not dropped", + beans: []*bean.Bean{ + {ID: "m1", Type: "milestone", Title: "v1.0", Status: "todo", CreatedAt: &now}, + {ID: "f1", Type: "feature", Title: "SSO", Status: "todo", Parent: "m1"}, + {ID: "t1", Type: "task", Title: "OIDC login", Status: "todo", Parent: "f1"}, + }, + wantMilestones: 1, + }, } for _, tt := range tests { @@ -254,3 +273,345 @@ func TestStatusFiltering(t *testing.T) { } }) } + +func TestSplitByContainerType(t *testing.T) { + beans := []*bean.Bean{ + {ID: "f1", Type: "feature", Title: "F1"}, + {ID: "t1", Type: "task", Title: "T1"}, + {ID: "b1", Type: "bug", Title: "B1"}, + } + + leafs, features := splitByContainerType(beans) + + if len(leafs) != 2 { + t.Errorf("got %d leafs, want 2", len(leafs)) + } + if len(features) != 1 { + t.Errorf("got %d features, want 1", len(features)) + } + if features[0].ID != "f1" { + t.Errorf("got feature %s, want f1", features[0].ID) + } +} + +func TestCollectLeafDescendants(t *testing.T) { + oldCfg := cfg + defer func() { cfg = oldCfg }() + cfg = config.Default() + + children := map[string][]*bean.Bean{ + "feat1": { + {ID: "t1", Type: "task", Title: "Direct leaf", Status: "todo", Parent: "feat1"}, + {ID: "feat2", Type: "feature", Title: "Nested feature", Status: "todo", Parent: "feat1"}, + }, + "feat2": { + {ID: "t2", Type: "task", Title: "Nested leaf", Status: "todo", Parent: "feat2"}, + {ID: "t3", Type: "task", Title: "Done nested leaf", Status: "completed", Parent: "feat2"}, + }, + } + + t.Run("flattens through nested features, excludes done by default", func(t *testing.T) { + got := collectLeafDescendants("feat1", children, false) + if len(got) != 2 { + t.Fatalf("got %d leafs, want 2 (t1, t2)", len(got)) + } + ids := map[string]bool{got[0].ID: true, got[1].ID: true} + if !ids["t1"] || !ids["t2"] { + t.Errorf("got ids %v, want t1 and t2", ids) + } + }) + + t.Run("includes done when requested", func(t *testing.T) { + got := collectLeafDescendants("feat1", children, true) + if len(got) != 3 { + t.Fatalf("got %d leafs, want 3 (t1, t2, t3)", len(got)) + } + }) + + t.Run("no children returns empty, not nil panic", func(t *testing.T) { + got := collectLeafDescendants("nonexistent", children, false) + if len(got) != 0 { + t.Errorf("got %d leafs, want 0", len(got)) + } + }) + + t.Run("hand-authored parent cycle does not stack-overflow", func(t *testing.T) { + // The CLI's ValidateParent/DetectCycle reject this at write time, but + // beans are hand-editable markdown -- a manually edited cycle must not + // crash roadmap generation. This subtest hangs/panics without the + // visited guard in collectLeafDescendantsVisited. + cyclic := map[string][]*bean.Bean{ + "featA": { + {ID: "featB", Type: "feature", Title: "B", Status: "todo", Parent: "featA"}, + }, + "featB": { + {ID: "featA", Type: "feature", Title: "A", Status: "todo", Parent: "featB"}, + {ID: "t1", Type: "task", Title: "Reachable leaf", Status: "todo", Parent: "featB"}, + }, + } + got := collectLeafDescendants("featA", cyclic, false) + if len(got) != 1 || got[0].ID != "t1" { + t.Errorf("got %v, want exactly [t1]", got) + } + }) +} + +func TestBuildMilestoneGroupResolvesFeatureNesting(t *testing.T) { + oldCfg := cfg + defer func() { cfg = oldCfg }() + cfg = config.Default() + + now := time.Now() + beans := []*bean.Bean{ + {ID: "m1", Type: "milestone", Title: "v1.0", Status: "todo", CreatedAt: &now}, + {ID: "e1", Type: "epic", Title: "Auth", Status: "todo", Parent: "m1"}, + {ID: "f1", Type: "feature", Title: "SSO", Status: "todo", Parent: "e1"}, + {ID: "t1", Type: "task", Title: "OIDC login", Status: "todo", Parent: "f1"}, + {ID: "b1", Type: "bug", Title: "Direct epic bug", Status: "todo", Parent: "e1"}, + } + + result := buildRoadmap(beans, false, nil, nil) + + if len(result.Milestones) != 1 { + t.Fatalf("got %d milestones, want 1", len(result.Milestones)) + } + epics := result.Milestones[0].Epics + if len(epics) != 1 { + t.Fatalf("got %d epics, want 1", len(epics)) + } + epic := epics[0] + if len(epic.Items) != 1 || epic.Items[0].ID != "b1" { + t.Errorf("epic.Items = %v, want [b1]", epic.Items) + } + if len(epic.Features) != 1 { + t.Fatalf("got %d feature groups, want 1", len(epic.Features)) + } + if epic.Features[0].Feature.ID != "f1" { + t.Errorf("feature group is for %s, want f1", epic.Features[0].Feature.ID) + } + if len(epic.Features[0].Items) != 1 || epic.Features[0].Items[0].ID != "t1" { + t.Errorf("feature.Items = %v, want [t1]", epic.Features[0].Items) + } +} + +func TestUnscheduledFeatureResolvesNesting(t *testing.T) { + oldCfg := cfg + defer func() { cfg = oldCfg }() + cfg = config.Default() + + beans := []*bean.Bean{ + {ID: "f1", Type: "feature", Title: "Standalone feature", Status: "todo"}, + {ID: "t1", Type: "task", Title: "Leaf under orphan feature", Status: "todo", Parent: "f1"}, + } + + result := buildRoadmap(beans, false, nil, nil) + + if result.Unscheduled == nil { + t.Fatal("expected Unscheduled to be non-nil") + } + if len(result.Unscheduled.Features) != 1 { + t.Fatalf("got %d unscheduled features, want 1", len(result.Unscheduled.Features)) + } + fg := result.Unscheduled.Features[0] + if fg.Feature.ID != "f1" { + t.Errorf("feature group is for %s, want f1", fg.Feature.ID) + } + if len(fg.Items) != 1 || fg.Items[0].ID != "t1" { + t.Errorf("fg.Items = %v, want [t1]", fg.Items) + } + // The leaf must not also leak into Other (it has a parent, so it's + // handled entirely via the feature group). + if len(result.Unscheduled.Other) != 0 { + t.Errorf("got %d unscheduled other, want 0", len(result.Unscheduled.Other)) + } +} + +func TestUnscheduledEpicWithFeatureNesting(t *testing.T) { + oldCfg := cfg + defer func() { cfg = oldCfg }() + cfg = config.Default() + + beans := []*bean.Bean{ + {ID: "e1", Type: "epic", Title: "Unscheduled epic", Status: "todo"}, + {ID: "f1", Type: "feature", Title: "Feature under unscheduled epic", Status: "todo", Parent: "e1"}, + {ID: "t1", Type: "task", Title: "Leaf", Status: "todo", Parent: "f1"}, + } + + result := buildRoadmap(beans, false, nil, nil) + + if len(result.Unscheduled.Epics) != 1 { + t.Fatalf("got %d unscheduled epics, want 1", len(result.Unscheduled.Epics)) + } + eg := result.Unscheduled.Epics[0] + if len(eg.Features) != 1 || eg.Features[0].Feature.ID != "f1" { + t.Fatalf("eg.Features = %+v, want feature f1", eg.Features) + } + if len(eg.Features[0].Items) != 1 || eg.Features[0].Items[0].ID != "t1" { + t.Errorf("eg.Features[0].Items = %v, want [t1]", eg.Features[0].Items) + } +} + +func TestUnscheduledNestedFeatureNotDoubleRendered(t *testing.T) { + // A feature nested under another orphan feature (hand-edited data -- + // ValidateParent rejects this via the CLI) must be flattened into the + // top feature's Items exactly once, never also appear as its own + // top-level unscheduled feature entry. + oldCfg := cfg + defer func() { cfg = oldCfg }() + cfg = config.Default() + + beans := []*bean.Bean{ + {ID: "f1", Type: "feature", Title: "Top feature", Status: "todo"}, + {ID: "f2", Type: "feature", Title: "Nested feature", Status: "todo", Parent: "f1"}, + {ID: "t1", Type: "task", Title: "Leaf", Status: "todo", Parent: "f2"}, + } + + result := buildRoadmap(beans, false, nil, nil) + + if len(result.Unscheduled.Features) != 1 { + t.Fatalf("got %d unscheduled features, want 1 (f1 only, f2 must not double-render)", len(result.Unscheduled.Features)) + } + fg := result.Unscheduled.Features[0] + if fg.Feature.ID != "f1" { + t.Errorf("unscheduled feature is %s, want f1", fg.Feature.ID) + } + if len(fg.Items) != 1 || fg.Items[0].ID != "t1" { + t.Errorf("fg.Items = %v, want [t1]", fg.Items) + } +} + +// beans-n8zw D01: a Feature is a container IFF it has >=1 leaf descendant +// (respecting includeDone). Childless features must render as flat leaf +// lines instead of vanishing from the roadmap. + +// Ta: orphan feature, 0 children, status todo -> flat leaf in +// Unscheduled.Other, never as a featureGroup, never dropped. +func TestOrphanChildlessFeatureAppearsAsFlatLeaf(t *testing.T) { + oldCfg := cfg + defer func() { cfg = oldCfg }() + cfg = config.Default() + + beans := []*bean.Bean{ + {ID: "f1", Type: "feature", Title: "Lonely", Status: "todo"}, + } + + result := buildRoadmap(beans, false, nil, nil) + + if result.Unscheduled == nil { + t.Fatal("expected Unscheduled to be non-nil") + } + if len(result.Unscheduled.Features) != 0 { + t.Errorf("got %d unscheduled featureGroups, want 0 (childless feature must not become a container)", len(result.Unscheduled.Features)) + } + if len(result.Unscheduled.Other) != 1 || result.Unscheduled.Other[0].ID != "f1" { + t.Errorf("Unscheduled.Other = %v, want [f1]", result.Unscheduled.Other) + } +} + +// Tb: feature under epic, 0 children -> flat leaf in epic.Items, not in +// epic.Features. +func TestChildlessFeatureUnderEpicAppearsInEpicItems(t *testing.T) { + oldCfg := cfg + defer func() { cfg = oldCfg }() + cfg = config.Default() + + now := time.Now() + beans := []*bean.Bean{ + {ID: "m1", Type: "milestone", Title: "v1.0", Status: "todo", CreatedAt: &now}, + {ID: "e1", Type: "epic", Title: "Auth", Status: "todo", Parent: "m1"}, + {ID: "f1", Type: "feature", Title: "Lonely", Status: "todo", Parent: "e1"}, + } + + result := buildRoadmap(beans, false, nil, nil) + + if len(result.Milestones) != 1 { + t.Fatalf("got %d milestones, want 1", len(result.Milestones)) + } + epics := result.Milestones[0].Epics + if len(epics) != 1 { + t.Fatalf("got %d epics, want 1", len(epics)) + } + epic := epics[0] + if len(epic.Features) != 0 { + t.Errorf("got %d epic feature groups, want 0", len(epic.Features)) + } + if len(epic.Items) != 1 || epic.Items[0].ID != "f1" { + t.Errorf("epic.Items = %v, want [f1]", epic.Items) + } +} + +// Tc: feature directly under milestone, 0 children -> flat leaf in +// milestone.Other. +func TestChildlessFeatureDirectUnderMilestoneAppearsInOther(t *testing.T) { + oldCfg := cfg + defer func() { cfg = oldCfg }() + cfg = config.Default() + + now := time.Now() + beans := []*bean.Bean{ + {ID: "m1", Type: "milestone", Title: "v1.0", Status: "todo", CreatedAt: &now}, + {ID: "f1", Type: "feature", Title: "Lonely", Status: "todo", Parent: "m1"}, + } + + result := buildRoadmap(beans, false, nil, nil) + + if len(result.Milestones) != 1 { + t.Fatalf("got %d milestones, want 1", len(result.Milestones)) + } + ms := result.Milestones[0] + if len(ms.Features) != 0 { + t.Errorf("got %d milestone feature groups, want 0", len(ms.Features)) + } + if len(ms.Other) != 1 || ms.Other[0].ID != "f1" { + t.Errorf("milestone.Other = %v, want [f1]", ms.Other) + } +} + +// Td (regression guard): a feature with >=1 live child still renders as a +// featureGroup container, even when a childless sibling feature is present +// in the same epic -- the two code paths must not cross-contaminate. +func TestFeatureWithChildRemainsContainerAlongsideChildlessSibling(t *testing.T) { + oldCfg := cfg + defer func() { cfg = oldCfg }() + cfg = config.Default() + + now := time.Now() + beans := []*bean.Bean{ + {ID: "m1", Type: "milestone", Title: "v1.0", Status: "todo", CreatedAt: &now}, + {ID: "e1", Type: "epic", Title: "Auth", Status: "todo", Parent: "m1"}, + {ID: "f1", Type: "feature", Title: "Childless", Status: "todo", Parent: "e1"}, + {ID: "f2", Type: "feature", Title: "Has a child", Status: "todo", Parent: "e1"}, + {ID: "t1", Type: "task", Title: "OIDC login", Status: "todo", Parent: "f2"}, + } + + result := buildRoadmap(beans, false, nil, nil) + + epic := result.Milestones[0].Epics[0] + if len(epic.Items) != 1 || epic.Items[0].ID != "f1" { + t.Errorf("epic.Items = %v, want [f1] (childless feature flattened)", epic.Items) + } + if len(epic.Features) != 1 || epic.Features[0].Feature.ID != "f2" { + t.Fatalf("epic.Features = %+v, want exactly [f2]", epic.Features) + } + if len(epic.Features[0].Items) != 1 || epic.Features[0].Items[0].ID != "t1" { + t.Errorf("epic.Features[0].Items = %v, want [t1]", epic.Features[0].Items) + } +} + +// Te (edge case): a childless feature with an archive status is dropped by +// the normal archive-status filter, exactly like any other leaf. +func TestChildlessCompletedFeatureDroppedByArchiveFilter(t *testing.T) { + oldCfg := cfg + defer func() { cfg = oldCfg }() + cfg = config.Default() + + beans := []*bean.Bean{ + {ID: "f1", Type: "feature", Title: "Done and lonely", Status: "completed"}, + } + + result := buildRoadmap(beans, false, nil, nil) + + if result.Unscheduled != nil { + t.Errorf("expected Unscheduled to be nil (completed childless feature dropped), got %+v", result.Unscheduled) + } +}