refactor(focus): extract stack navigation helpers with tests
Refactor navigateWithinStack into focused helper methods: findCurrentStackIndex, calculateNewStackIndex, and getPaneIDFromStackChild. This improves code clarity and testability. Add comprehensive unit tests for focus manager navigation logic including boundary conditions and invalid indices.
- commit
- 77a7a6d310cc6d51e9ca24f4056a28ca3f94c7f5
- author
- brice · · 2025-12-17 21:07 UTC
- committer
- brice · · 2025-12-17 22:25 UTC
- parent
- 6f9d17f
- signature
- unsigned
2 files · +278 -25
M internal/ui/focus/manager.go +44 -25
| @@ -156,51 +156,71 @@ func (m *Manager) NavigateGeometric( | |||
| 156 | 156 | // navigateWithinStack tries to navigate within a stack. | |
| 157 | 157 | // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a | |
| 158 | 158 | // boundary and navigation should escape the stack. | |
| 159 | - | func (m *Manager) navigateWithinStack(stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection) (bool, entity.PaneID) { | |
| 159 | + | func (m *Manager) navigateWithinStack( | |
| 160 | + | stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection, | |
| 161 | + | ) (bool, entity.PaneID) { | |
| 160 | 162 | if !stackNode.IsStacked || len(stackNode.Children) == 0 { | |
| 161 | 163 | return false, "" | |
| 162 | 164 | } | |
| 163 | 165 | ||
| 164 | - | // Find current index in stack | |
| 165 | - | currentIdx := -1 | |
| 166 | + | currentIdx := m.findCurrentStackIndex(stackNode, currentNode) | |
| 167 | + | if currentIdx < 0 { | |
| 168 | + | return false, "" | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | newIdx, ok := m.calculateNewStackIndex(currentIdx, len(stackNode.Children), direction) | |
| 172 | + | if !ok { | |
| 173 | + | return false, "" | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | stackNode.ActiveStackIndex = newIdx | |
| 177 | + | return m.getPaneIDFromStackChild(stackNode.Children[newIdx]) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // findCurrentStackIndex returns the current pane's index in the stack. | |
| 181 | + | // Uses ActiveStackIndex from domain model, with fallback to pointer search. | |
| 182 | + | func (*Manager) findCurrentStackIndex(stackNode, currentNode *entity.PaneNode) int { | |
| 183 | + | idx := stackNode.ActiveStackIndex | |
| 184 | + | if idx >= 0 && idx < len(stackNode.Children) { | |
| 185 | + | return idx | |
| 186 | + | } | |
| 187 | + | // Fallback to pointer search if ActiveStackIndex is invalid | |
| 166 | 188 | for i, child := range stackNode.Children { | |
| 167 | 189 | if child == currentNode { | |
| 168 | - | currentIdx = i | |
| 169 | - | break | |
| 190 | + | return i | |
| 170 | 191 | } | |
| 171 | 192 | } | |
| 172 | - | if currentIdx < 0 { | |
| 173 | - | return false, "" | |
| 174 | - | } | |
| 193 | + | return -1 | |
| 194 | + | } | |
| 175 | 195 | ||
| 176 | - | var newIdx int | |
| 196 | + | // calculateNewStackIndex computes the new index after navigation. | |
| 197 | + | // Returns (newIndex, ok) where ok is false if at a boundary. | |
| 198 | + | func (*Manager) calculateNewStackIndex( | |
| 199 | + | currentIdx, childCount int, direction usecase.NavigateDirection, | |
| 200 | + | ) (int, bool) { | |
| 177 | 201 | switch direction { | |
| 178 | 202 | case usecase.NavUp: | |
| 179 | - | newIdx = currentIdx - 1 | |
| 180 | - | if newIdx < 0 { | |
| 181 | - | // At top boundary | |
| 182 | - | return false, "" | |
| 203 | + | if currentIdx <= 0 { | |
| 204 | + | return 0, false | |
| 183 | 205 | } | |
| 206 | + | return currentIdx - 1, true | |
| 184 | 207 | case usecase.NavDown: | |
| 185 | - | newIdx = currentIdx + 1 | |
| 186 | - | if newIdx >= len(stackNode.Children) { | |
| 187 | - | // At bottom boundary | |
| 188 | - | return false, "" | |
| 208 | + | if currentIdx >= childCount-1 { | |
| 209 | + | return 0, false | |
| 189 | 210 | } | |
| 211 | + | return currentIdx + 1, true | |
| 190 | 212 | default: | |
| 191 | - | return false, "" | |
| 213 | + | return 0, false | |
| 192 | 214 | } | |
| 215 | + | } | |
| 193 | 216 | ||
| 194 | - | // Update stack active index | |
| 195 | - | stackNode.ActiveStackIndex = newIdx | |
| 196 | - | ||
| 197 | - | // Get the new pane | |
| 198 | - | targetNode := stackNode.Children[newIdx] | |
| 217 | + | // getPaneIDFromStackChild extracts the pane ID from a stack child node. | |
| 218 | + | // If the child is not a leaf, finds the first leaf pane. | |
| 219 | + | func (*Manager) getPaneIDFromStackChild(targetNode *entity.PaneNode) (bool, entity.PaneID) { | |
| 199 | 220 | if targetNode.Pane != nil { | |
| 200 | 221 | return true, targetNode.Pane.ID | |
| 201 | 222 | } | |
| 202 | 223 | ||
| 203 | - | // If the child is not a leaf, find the first leaf | |
| 204 | 224 | var leafPaneID entity.PaneID | |
| 205 | 225 | targetNode.Walk(func(n *entity.PaneNode) bool { | |
| 206 | 226 | if n.IsLeaf() && n.Pane != nil { | |
| @@ -213,6 +233,5 @@ func (m *Manager) navigateWithinStack(stackNode, currentNode *entity.PaneNode, d | |||
| 213 | 233 | if leafPaneID != "" { | |
| 214 | 234 | return true, leafPaneID | |
| 215 | 235 | } | |
| 216 | - | ||
| 217 | 236 | return false, "" | |
| 218 | 237 | } | |
| @@ -156,51 +156,71 @@ func (m *Manager) NavigateGeometric( | |||||
| 156 | // navigateWithinStack tries to navigate within a stack. | 156 | // navigateWithinStack tries to navigate within a stack. | ||
| 157 | // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a | 157 | // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a | ||
| 158 | // boundary and navigation should escape the stack. | 158 | // boundary and navigation should escape the stack. | ||
| 159 | - | func (m *Manager) navigateWithinStack(stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection) (bool, entity.PaneID) { | 159 | + | func (m *Manager) navigateWithinStack( |
| 160 | + | stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection, | |||
| 161 | + | ) (bool, entity.PaneID) { | |||
| 160 | if !stackNode.IsStacked || len(stackNode.Children) == 0 { | 162 | if !stackNode.IsStacked || len(stackNode.Children) == 0 { | ||
| 161 | return false, "" | 163 | return false, "" | ||
| 162 | } | 164 | } | ||
| 163 | 165 | ||||
| 164 | - | // Find current index in stack | 166 | + | currentIdx := m.findCurrentStackIndex(stackNode, currentNode) |
| 165 | - | currentIdx := -1 | 167 | + | if currentIdx < 0 { |
| 168 | + | return false, "" | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | newIdx, ok := m.calculateNewStackIndex(currentIdx, len(stackNode.Children), direction) | |||
| 172 | + | if !ok { | |||
| 173 | + | return false, "" | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | stackNode.ActiveStackIndex = newIdx | |||
| 177 | + | return m.getPaneIDFromStackChild(stackNode.Children[newIdx]) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // findCurrentStackIndex returns the current pane's index in the stack. | |||
| 181 | + | // Uses ActiveStackIndex from domain model, with fallback to pointer search. | |||
| 182 | + | func (*Manager) findCurrentStackIndex(stackNode, currentNode *entity.PaneNode) int { | |||
| 183 | + | idx := stackNode.ActiveStackIndex | |||
| 184 | + | if idx >= 0 && idx < len(stackNode.Children) { | |||
| 185 | + | return idx | |||
| 186 | + | } | |||
| 187 | + | // Fallback to pointer search if ActiveStackIndex is invalid | |||
| 166 | for i, child := range stackNode.Children { | 188 | for i, child := range stackNode.Children { | ||
| 167 | if child == currentNode { | 189 | if child == currentNode { | ||
| 168 | - | currentIdx = i | 190 | + | return i |
| 169 | - | break | |||
| 170 | } | 191 | } | ||
| 171 | } | 192 | } | ||
| 172 | - | if currentIdx < 0 { | 193 | + | return -1 |
| 173 | - | return false, "" | 194 | + | } |
| 174 | - | } | |||
| 175 | 195 | ||||
| 176 | - | var newIdx int | 196 | + | // calculateNewStackIndex computes the new index after navigation. |
| 197 | + | // Returns (newIndex, ok) where ok is false if at a boundary. | |||
| 198 | + | func (*Manager) calculateNewStackIndex( | |||
| 199 | + | currentIdx, childCount int, direction usecase.NavigateDirection, | |||
| 200 | + | ) (int, bool) { | |||
| 177 | switch direction { | 201 | switch direction { | ||
| 178 | case usecase.NavUp: | 202 | case usecase.NavUp: | ||
| 179 | - | newIdx = currentIdx - 1 | 203 | + | if currentIdx <= 0 { |
| 180 | - | if newIdx < 0 { | 204 | + | return 0, false |
| 181 | - | // At top boundary | |||
| 182 | - | return false, "" | |||
| 183 | } | 205 | } | ||
| 206 | + | return currentIdx - 1, true | |||
| 184 | case usecase.NavDown: | 207 | case usecase.NavDown: | ||
| 185 | - | newIdx = currentIdx + 1 | 208 | + | if currentIdx >= childCount-1 { |
| 186 | - | if newIdx >= len(stackNode.Children) { | 209 | + | return 0, false |
| 187 | - | // At bottom boundary | |||
| 188 | - | return false, "" | |||
| 189 | } | 210 | } | ||
| 211 | + | return currentIdx + 1, true | |||
| 190 | default: | 212 | default: | ||
| 191 | - | return false, "" | 213 | + | return 0, false |
| 192 | } | 214 | } | ||
| 215 | + | } | |||
| 193 | 216 | ||||
| 194 | - | // Update stack active index | 217 | + | // getPaneIDFromStackChild extracts the pane ID from a stack child node. |
| 195 | - | stackNode.ActiveStackIndex = newIdx | 218 | + | // If the child is not a leaf, finds the first leaf pane. |
| 196 | - | 219 | + | func (*Manager) getPaneIDFromStackChild(targetNode *entity.PaneNode) (bool, entity.PaneID) { | |
| 197 | - | // Get the new pane | |||
| 198 | - | targetNode := stackNode.Children[newIdx] | |||
| 199 | if targetNode.Pane != nil { | 220 | if targetNode.Pane != nil { | ||
| 200 | return true, targetNode.Pane.ID | 221 | return true, targetNode.Pane.ID | ||
| 201 | } | 222 | } | ||
| 202 | 223 | ||||
| 203 | - | // If the child is not a leaf, find the first leaf | |||
| 204 | var leafPaneID entity.PaneID | 224 | var leafPaneID entity.PaneID | ||
| 205 | targetNode.Walk(func(n *entity.PaneNode) bool { | 225 | targetNode.Walk(func(n *entity.PaneNode) bool { | ||
| 206 | if n.IsLeaf() && n.Pane != nil { | 226 | if n.IsLeaf() && n.Pane != nil { | ||
| @@ -213,6 +233,5 @@ func (m *Manager) navigateWithinStack(stackNode, currentNode *entity.PaneNode, d | |||||
| 213 | if leafPaneID != "" { | 233 | if leafPaneID != "" { | ||
| 214 | return true, leafPaneID | 234 | return true, leafPaneID | ||
| 215 | } | 235 | } | ||
| 216 | - | ||||
| 217 | return false, "" | 236 | return false, "" | ||
| 218 | } | 237 | } | ||
A internal/ui/focus/manager_test.go +234 -0
| @@ -0,0 +1,234 @@ | |||
| 1 | + | package focus | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // TestNavigateWithinStack_UsesActiveStackIndex tests that navigation uses | |
| 13 | + | // ActiveStackIndex from the domain model instead of pointer comparison. | |
| 14 | + | func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) { | |
| 15 | + | // Create a stack with 3 children | |
| 16 | + | pane0 := &entity.Pane{ID: "pane0"} | |
| 17 | + | pane1 := &entity.Pane{ID: "pane1"} | |
| 18 | + | pane2 := &entity.Pane{ID: "pane2"} | |
| 19 | + | ||
| 20 | + | child0 := &entity.PaneNode{ID: "child0", Pane: pane0} | |
| 21 | + | child1 := &entity.PaneNode{ID: "child1", Pane: pane1} | |
| 22 | + | child2 := &entity.PaneNode{ID: "child2", Pane: pane2} | |
| 23 | + | ||
| 24 | + | stackNode := &entity.PaneNode{ | |
| 25 | + | ID: "stack", | |
| 26 | + | IsStacked: true, | |
| 27 | + | Children: []*entity.PaneNode{child0, child1, child2}, | |
| 28 | + | ActiveStackIndex: 1, // Currently at middle pane | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Set parents | |
| 32 | + | child0.Parent = stackNode | |
| 33 | + | child1.Parent = stackNode | |
| 34 | + | child2.Parent = stackNode | |
| 35 | + | ||
| 36 | + | m := NewManager(nil) | |
| 37 | + | ||
| 38 | + | tests := []struct { | |
| 39 | + | name string | |
| 40 | + | activeIndex int | |
| 41 | + | direction usecase.NavigateDirection | |
| 42 | + | wantCanNav bool | |
| 43 | + | wantPaneID entity.PaneID | |
| 44 | + | wantNewIndex int | |
| 45 | + | }{ | |
| 46 | + | { | |
| 47 | + | name: "navigate down from middle", | |
| 48 | + | activeIndex: 1, | |
| 49 | + | direction: usecase.NavDown, | |
| 50 | + | wantCanNav: true, | |
| 51 | + | wantPaneID: "pane2", | |
| 52 | + | wantNewIndex: 2, | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "navigate up from middle", | |
| 56 | + | activeIndex: 1, | |
| 57 | + | direction: usecase.NavUp, | |
| 58 | + | wantCanNav: true, | |
| 59 | + | wantPaneID: "pane0", | |
| 60 | + | wantNewIndex: 0, | |
| 61 | + | }, | |
| 62 | + | { | |
| 63 | + | name: "navigate down from last - boundary", | |
| 64 | + | activeIndex: 2, | |
| 65 | + | direction: usecase.NavDown, | |
| 66 | + | wantCanNav: false, | |
| 67 | + | wantPaneID: "", | |
| 68 | + | wantNewIndex: 2, // unchanged | |
| 69 | + | }, | |
| 70 | + | { | |
| 71 | + | name: "navigate up from first - boundary", | |
| 72 | + | activeIndex: 0, | |
| 73 | + | direction: usecase.NavUp, | |
| 74 | + | wantCanNav: false, | |
| 75 | + | wantPaneID: "", | |
| 76 | + | wantNewIndex: 0, // unchanged | |
| 77 | + | }, | |
| 78 | + | { | |
| 79 | + | name: "navigate down from first", | |
| 80 | + | activeIndex: 0, | |
| 81 | + | direction: usecase.NavDown, | |
| 82 | + | wantCanNav: true, | |
| 83 | + | wantPaneID: "pane1", | |
| 84 | + | wantNewIndex: 1, | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | name: "navigate up from last", | |
| 88 | + | activeIndex: 2, | |
| 89 | + | direction: usecase.NavUp, | |
| 90 | + | wantCanNav: true, | |
| 91 | + | wantPaneID: "pane1", | |
| 92 | + | wantNewIndex: 1, | |
| 93 | + | }, | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | for _, tt := range tests { | |
| 97 | + | t.Run(tt.name, func(t *testing.T) { | |
| 98 | + | // Set up the active index | |
| 99 | + | stackNode.ActiveStackIndex = tt.activeIndex | |
| 100 | + | ||
| 101 | + | // Call the private function through reflection or test it indirectly | |
| 102 | + | // Since navigateWithinStack is private, we test its behavior | |
| 103 | + | canNav, paneID := m.navigateWithinStack(stackNode, stackNode.Children[tt.activeIndex], tt.direction) | |
| 104 | + | ||
| 105 | + | assert.Equal(t, tt.wantCanNav, canNav) | |
| 106 | + | assert.Equal(t, tt.wantPaneID, paneID) | |
| 107 | + | ||
| 108 | + | if canNav { | |
| 109 | + | assert.Equal(t, tt.wantNewIndex, stackNode.ActiveStackIndex) | |
| 110 | + | } | |
| 111 | + | }) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // TestNavigateWithinStack_EmptyStack tests navigation in empty stack returns false. | |
| 116 | + | func TestNavigateWithinStack_EmptyStack(t *testing.T) { | |
| 117 | + | stackNode := &entity.PaneNode{ | |
| 118 | + | ID: "stack", | |
| 119 | + | IsStacked: true, | |
| 120 | + | Children: []*entity.PaneNode{}, | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | m := NewManager(nil) | |
| 124 | + | ||
| 125 | + | canNav, paneID := m.navigateWithinStack(stackNode, nil, usecase.NavDown) | |
| 126 | + | ||
| 127 | + | assert.False(t, canNav) | |
| 128 | + | assert.Empty(t, paneID) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // TestNavigateWithinStack_NotStacked tests navigation on non-stacked node returns false. | |
| 132 | + | func TestNavigateWithinStack_NotStacked(t *testing.T) { | |
| 133 | + | node := &entity.PaneNode{ | |
| 134 | + | ID: "leaf", | |
| 135 | + | IsStacked: false, | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | m := NewManager(nil) | |
| 139 | + | ||
| 140 | + | canNav, paneID := m.navigateWithinStack(node, nil, usecase.NavDown) | |
| 141 | + | ||
| 142 | + | assert.False(t, canNav) | |
| 143 | + | assert.Empty(t, paneID) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // TestNavigateWithinStack_InvalidActiveIndex tests navigation with invalid ActiveStackIndex | |
| 147 | + | // falls back to pointer search. | |
| 148 | + | func TestNavigateWithinStack_InvalidActiveIndexFallback(t *testing.T) { | |
| 149 | + | pane0 := &entity.Pane{ID: "pane0"} | |
| 150 | + | pane1 := &entity.Pane{ID: "pane1"} | |
| 151 | + | ||
| 152 | + | child0 := &entity.PaneNode{ID: "child0", Pane: pane0} | |
| 153 | + | child1 := &entity.PaneNode{ID: "child1", Pane: pane1} | |
| 154 | + | ||
| 155 | + | stackNode := &entity.PaneNode{ | |
| 156 | + | ID: "stack", | |
| 157 | + | IsStacked: true, | |
| 158 | + | Children: []*entity.PaneNode{child0, child1}, | |
| 159 | + | ActiveStackIndex: -1, // Invalid index | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | child0.Parent = stackNode | |
| 163 | + | child1.Parent = stackNode | |
| 164 | + | ||
| 165 | + | m := NewManager(nil) | |
| 166 | + | ||
| 167 | + | // With invalid ActiveStackIndex, should fall back to pointer search | |
| 168 | + | // Pass child0 as current node | |
| 169 | + | canNav, paneID := m.navigateWithinStack(stackNode, child0, usecase.NavDown) | |
| 170 | + | ||
| 171 | + | assert.True(t, canNav) | |
| 172 | + | assert.Equal(t, entity.PaneID("pane1"), paneID) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // TestNavigateWithinStack_InvalidDirection tests navigation with invalid direction returns false. | |
| 176 | + | func TestNavigateWithinStack_InvalidDirection(t *testing.T) { | |
| 177 | + | pane0 := &entity.Pane{ID: "pane0"} | |
| 178 | + | child0 := &entity.PaneNode{ID: "child0", Pane: pane0} | |
| 179 | + | ||
| 180 | + | stackNode := &entity.PaneNode{ | |
| 181 | + | ID: "stack", | |
| 182 | + | IsStacked: true, | |
| 183 | + | Children: []*entity.PaneNode{child0}, | |
| 184 | + | ActiveStackIndex: 0, | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | m := NewManager(nil) | |
| 188 | + | ||
| 189 | + | // NavLeft and NavRight should not work for stack navigation | |
| 190 | + | canNav, paneID := m.navigateWithinStack(stackNode, child0, usecase.NavLeft) | |
| 191 | + | ||
| 192 | + | assert.False(t, canNav) | |
| 193 | + | assert.Empty(t, paneID) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // TestNavigateWithinStack_UpdatesActiveStackIndex tests that navigation properly | |
| 197 | + | // updates the ActiveStackIndex on the stack node. | |
| 198 | + | func TestNavigateWithinStack_UpdatesActiveStackIndex(t *testing.T) { | |
| 199 | + | pane0 := &entity.Pane{ID: "pane0"} | |
| 200 | + | pane1 := &entity.Pane{ID: "pane1"} | |
| 201 | + | pane2 := &entity.Pane{ID: "pane2"} | |
| 202 | + | ||
| 203 | + | child0 := &entity.PaneNode{ID: "child0", Pane: pane0} | |
| 204 | + | child1 := &entity.PaneNode{ID: "child1", Pane: pane1} | |
| 205 | + | child2 := &entity.PaneNode{ID: "child2", Pane: pane2} | |
| 206 | + | ||
| 207 | + | stackNode := &entity.PaneNode{ | |
| 208 | + | ID: "stack", | |
| 209 | + | IsStacked: true, | |
| 210 | + | Children: []*entity.PaneNode{child0, child1, child2}, | |
| 211 | + | ActiveStackIndex: 0, | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | m := NewManager(nil) | |
| 215 | + | ||
| 216 | + | // Navigate down twice | |
| 217 | + | stackNode.ActiveStackIndex = 0 | |
| 218 | + | canNav, _ := m.navigateWithinStack(stackNode, child0, usecase.NavDown) | |
| 219 | + | assert.True(t, canNav) | |
| 220 | + | assert.Equal(t, 1, stackNode.ActiveStackIndex) | |
| 221 | + | ||
| 222 | + | canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavDown) | |
| 223 | + | assert.True(t, canNav) | |
| 224 | + | assert.Equal(t, 2, stackNode.ActiveStackIndex) | |
| 225 | + | ||
| 226 | + | // Navigate up twice | |
| 227 | + | canNav, _ = m.navigateWithinStack(stackNode, child2, usecase.NavUp) | |
| 228 | + | assert.True(t, canNav) | |
| 229 | + | assert.Equal(t, 1, stackNode.ActiveStackIndex) | |
| 230 | + | ||
| 231 | + | canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavUp) | |
| 232 | + | assert.True(t, canNav) | |
| 233 | + | assert.Equal(t, 0, stackNode.ActiveStackIndex) | |
| 234 | + | } | |
| @@ -0,0 +1,234 @@ | |||||
| 1 | + | package focus | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // TestNavigateWithinStack_UsesActiveStackIndex tests that navigation uses | |||
| 13 | + | // ActiveStackIndex from the domain model instead of pointer comparison. | |||
| 14 | + | func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) { | |||
| 15 | + | // Create a stack with 3 children | |||
| 16 | + | pane0 := &entity.Pane{ID: "pane0"} | |||
| 17 | + | pane1 := &entity.Pane{ID: "pane1"} | |||
| 18 | + | pane2 := &entity.Pane{ID: "pane2"} | |||
| 19 | + | ||||
| 20 | + | child0 := &entity.PaneNode{ID: "child0", Pane: pane0} | |||
| 21 | + | child1 := &entity.PaneNode{ID: "child1", Pane: pane1} | |||
| 22 | + | child2 := &entity.PaneNode{ID: "child2", Pane: pane2} | |||
| 23 | + | ||||
| 24 | + | stackNode := &entity.PaneNode{ | |||
| 25 | + | ID: "stack", | |||
| 26 | + | IsStacked: true, | |||
| 27 | + | Children: []*entity.PaneNode{child0, child1, child2}, | |||
| 28 | + | ActiveStackIndex: 1, // Currently at middle pane | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // Set parents | |||
| 32 | + | child0.Parent = stackNode | |||
| 33 | + | child1.Parent = stackNode | |||
| 34 | + | child2.Parent = stackNode | |||
| 35 | + | ||||
| 36 | + | m := NewManager(nil) | |||
| 37 | + | ||||
| 38 | + | tests := []struct { | |||
| 39 | + | name string | |||
| 40 | + | activeIndex int | |||
| 41 | + | direction usecase.NavigateDirection | |||
| 42 | + | wantCanNav bool | |||
| 43 | + | wantPaneID entity.PaneID | |||
| 44 | + | wantNewIndex int | |||
| 45 | + | }{ | |||
| 46 | + | { | |||
| 47 | + | name: "navigate down from middle", | |||
| 48 | + | activeIndex: 1, | |||
| 49 | + | direction: usecase.NavDown, | |||
| 50 | + | wantCanNav: true, | |||
| 51 | + | wantPaneID: "pane2", | |||
| 52 | + | wantNewIndex: 2, | |||
| 53 | + | }, | |||
| 54 | + | { | |||
| 55 | + | name: "navigate up from middle", | |||
| 56 | + | activeIndex: 1, | |||
| 57 | + | direction: usecase.NavUp, | |||
| 58 | + | wantCanNav: true, | |||
| 59 | + | wantPaneID: "pane0", | |||
| 60 | + | wantNewIndex: 0, | |||
| 61 | + | }, | |||
| 62 | + | { | |||
| 63 | + | name: "navigate down from last - boundary", | |||
| 64 | + | activeIndex: 2, | |||
| 65 | + | direction: usecase.NavDown, | |||
| 66 | + | wantCanNav: false, | |||
| 67 | + | wantPaneID: "", | |||
| 68 | + | wantNewIndex: 2, // unchanged | |||
| 69 | + | }, | |||
| 70 | + | { | |||
| 71 | + | name: "navigate up from first - boundary", | |||
| 72 | + | activeIndex: 0, | |||
| 73 | + | direction: usecase.NavUp, | |||
| 74 | + | wantCanNav: false, | |||
| 75 | + | wantPaneID: "", | |||
| 76 | + | wantNewIndex: 0, // unchanged | |||
| 77 | + | }, | |||
| 78 | + | { | |||
| 79 | + | name: "navigate down from first", | |||
| 80 | + | activeIndex: 0, | |||
| 81 | + | direction: usecase.NavDown, | |||
| 82 | + | wantCanNav: true, | |||
| 83 | + | wantPaneID: "pane1", | |||
| 84 | + | wantNewIndex: 1, | |||
| 85 | + | }, | |||
| 86 | + | { | |||
| 87 | + | name: "navigate up from last", | |||
| 88 | + | activeIndex: 2, | |||
| 89 | + | direction: usecase.NavUp, | |||
| 90 | + | wantCanNav: true, | |||
| 91 | + | wantPaneID: "pane1", | |||
| 92 | + | wantNewIndex: 1, | |||
| 93 | + | }, | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | for _, tt := range tests { | |||
| 97 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 98 | + | // Set up the active index | |||
| 99 | + | stackNode.ActiveStackIndex = tt.activeIndex | |||
| 100 | + | ||||
| 101 | + | // Call the private function through reflection or test it indirectly | |||
| 102 | + | // Since navigateWithinStack is private, we test its behavior | |||
| 103 | + | canNav, paneID := m.navigateWithinStack(stackNode, stackNode.Children[tt.activeIndex], tt.direction) | |||
| 104 | + | ||||
| 105 | + | assert.Equal(t, tt.wantCanNav, canNav) | |||
| 106 | + | assert.Equal(t, tt.wantPaneID, paneID) | |||
| 107 | + | ||||
| 108 | + | if canNav { | |||
| 109 | + | assert.Equal(t, tt.wantNewIndex, stackNode.ActiveStackIndex) | |||
| 110 | + | } | |||
| 111 | + | }) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // TestNavigateWithinStack_EmptyStack tests navigation in empty stack returns false. | |||
| 116 | + | func TestNavigateWithinStack_EmptyStack(t *testing.T) { | |||
| 117 | + | stackNode := &entity.PaneNode{ | |||
| 118 | + | ID: "stack", | |||
| 119 | + | IsStacked: true, | |||
| 120 | + | Children: []*entity.PaneNode{}, | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | m := NewManager(nil) | |||
| 124 | + | ||||
| 125 | + | canNav, paneID := m.navigateWithinStack(stackNode, nil, usecase.NavDown) | |||
| 126 | + | ||||
| 127 | + | assert.False(t, canNav) | |||
| 128 | + | assert.Empty(t, paneID) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // TestNavigateWithinStack_NotStacked tests navigation on non-stacked node returns false. | |||
| 132 | + | func TestNavigateWithinStack_NotStacked(t *testing.T) { | |||
| 133 | + | node := &entity.PaneNode{ | |||
| 134 | + | ID: "leaf", | |||
| 135 | + | IsStacked: false, | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | m := NewManager(nil) | |||
| 139 | + | ||||
| 140 | + | canNav, paneID := m.navigateWithinStack(node, nil, usecase.NavDown) | |||
| 141 | + | ||||
| 142 | + | assert.False(t, canNav) | |||
| 143 | + | assert.Empty(t, paneID) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // TestNavigateWithinStack_InvalidActiveIndex tests navigation with invalid ActiveStackIndex | |||
| 147 | + | // falls back to pointer search. | |||
| 148 | + | func TestNavigateWithinStack_InvalidActiveIndexFallback(t *testing.T) { | |||
| 149 | + | pane0 := &entity.Pane{ID: "pane0"} | |||
| 150 | + | pane1 := &entity.Pane{ID: "pane1"} | |||
| 151 | + | ||||
| 152 | + | child0 := &entity.PaneNode{ID: "child0", Pane: pane0} | |||
| 153 | + | child1 := &entity.PaneNode{ID: "child1", Pane: pane1} | |||
| 154 | + | ||||
| 155 | + | stackNode := &entity.PaneNode{ | |||
| 156 | + | ID: "stack", | |||
| 157 | + | IsStacked: true, | |||
| 158 | + | Children: []*entity.PaneNode{child0, child1}, | |||
| 159 | + | ActiveStackIndex: -1, // Invalid index | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | child0.Parent = stackNode | |||
| 163 | + | child1.Parent = stackNode | |||
| 164 | + | ||||
| 165 | + | m := NewManager(nil) | |||
| 166 | + | ||||
| 167 | + | // With invalid ActiveStackIndex, should fall back to pointer search | |||
| 168 | + | // Pass child0 as current node | |||
| 169 | + | canNav, paneID := m.navigateWithinStack(stackNode, child0, usecase.NavDown) | |||
| 170 | + | ||||
| 171 | + | assert.True(t, canNav) | |||
| 172 | + | assert.Equal(t, entity.PaneID("pane1"), paneID) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // TestNavigateWithinStack_InvalidDirection tests navigation with invalid direction returns false. | |||
| 176 | + | func TestNavigateWithinStack_InvalidDirection(t *testing.T) { | |||
| 177 | + | pane0 := &entity.Pane{ID: "pane0"} | |||
| 178 | + | child0 := &entity.PaneNode{ID: "child0", Pane: pane0} | |||
| 179 | + | ||||
| 180 | + | stackNode := &entity.PaneNode{ | |||
| 181 | + | ID: "stack", | |||
| 182 | + | IsStacked: true, | |||
| 183 | + | Children: []*entity.PaneNode{child0}, | |||
| 184 | + | ActiveStackIndex: 0, | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | m := NewManager(nil) | |||
| 188 | + | ||||
| 189 | + | // NavLeft and NavRight should not work for stack navigation | |||
| 190 | + | canNav, paneID := m.navigateWithinStack(stackNode, child0, usecase.NavLeft) | |||
| 191 | + | ||||
| 192 | + | assert.False(t, canNav) | |||
| 193 | + | assert.Empty(t, paneID) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // TestNavigateWithinStack_UpdatesActiveStackIndex tests that navigation properly | |||
| 197 | + | // updates the ActiveStackIndex on the stack node. | |||
| 198 | + | func TestNavigateWithinStack_UpdatesActiveStackIndex(t *testing.T) { | |||
| 199 | + | pane0 := &entity.Pane{ID: "pane0"} | |||
| 200 | + | pane1 := &entity.Pane{ID: "pane1"} | |||
| 201 | + | pane2 := &entity.Pane{ID: "pane2"} | |||
| 202 | + | ||||
| 203 | + | child0 := &entity.PaneNode{ID: "child0", Pane: pane0} | |||
| 204 | + | child1 := &entity.PaneNode{ID: "child1", Pane: pane1} | |||
| 205 | + | child2 := &entity.PaneNode{ID: "child2", Pane: pane2} | |||
| 206 | + | ||||
| 207 | + | stackNode := &entity.PaneNode{ | |||
| 208 | + | ID: "stack", | |||
| 209 | + | IsStacked: true, | |||
| 210 | + | Children: []*entity.PaneNode{child0, child1, child2}, | |||
| 211 | + | ActiveStackIndex: 0, | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | m := NewManager(nil) | |||
| 215 | + | ||||
| 216 | + | // Navigate down twice | |||
| 217 | + | stackNode.ActiveStackIndex = 0 | |||
| 218 | + | canNav, _ := m.navigateWithinStack(stackNode, child0, usecase.NavDown) | |||
| 219 | + | assert.True(t, canNav) | |||
| 220 | + | assert.Equal(t, 1, stackNode.ActiveStackIndex) | |||
| 221 | + | ||||
| 222 | + | canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavDown) | |||
| 223 | + | assert.True(t, canNav) | |||
| 224 | + | assert.Equal(t, 2, stackNode.ActiveStackIndex) | |||
| 225 | + | ||||
| 226 | + | // Navigate up twice | |||
| 227 | + | canNav, _ = m.navigateWithinStack(stackNode, child2, usecase.NavUp) | |||
| 228 | + | assert.True(t, canNav) | |||
| 229 | + | assert.Equal(t, 1, stackNode.ActiveStackIndex) | |||
| 230 | + | ||||
| 231 | + | canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavUp) | |||
| 232 | + | assert.True(t, canNav) | |||
| 233 | + | assert.Equal(t, 0, stackNode.ActiveStackIndex) | |||
| 234 | + | } | |||