1package screens
2
3import (
4 "strings"
5 "testing"
6
7 tea "charm.land/bubbletea/v2"
8
9 "git.secluded.site/keld/internal/theme"
10 "git.secluded.site/keld/internal/ui"
11)
12
13// testStyles returns a *theme.Styles for testing. Dark mode is used
14// because it's the session default.
15func testStyles() *theme.Styles {
16 s := theme.New(true)
17 return &s
18}
19
20func testItems() []MenuItem {
21 return []MenuItem{
22 {Label: "backup", Hotkey: 'b'},
23 {Label: "restore", Hotkey: 'r'},
24 {Label: "snapshots", Hotkey: 's'},
25 }
26}
27
28func TestMenuTitle(t *testing.T) {
29 t.Parallel()
30
31 m := NewMenu(testItems(), testStyles())
32 if got := m.Title(); got != "Select a command" {
33 t.Errorf("Title() = %q, want %q", got, "Select a command")
34 }
35}
36
37func TestMenuKeyBindings(t *testing.T) {
38 t.Parallel()
39
40 m := NewMenu(testItems(), testStyles())
41 bindings := m.KeyBindings()
42
43 if len(bindings) == 0 {
44 t.Fatal("KeyBindings() returned no bindings")
45 }
46
47 // Should include bindings for navigation and selection.
48 helpKeys := make(map[string]bool)
49 for _, b := range bindings {
50 helpKeys[b.Help().Key] = true
51 }
52
53 for _, want := range []string{"↑/↓", "enter"} {
54 if !helpKeys[want] {
55 t.Errorf("KeyBindings() missing help key %q, got %v", want, helpKeys)
56 }
57 }
58}
59
60func TestMenuSelectionEmpty(t *testing.T) {
61 t.Parallel()
62
63 m := NewMenu(testItems(), testStyles())
64 if got := m.Selection(); got != "" {
65 t.Errorf("Selection() before interaction = %q, want empty", got)
66 }
67}
68
69func TestMenuEnterSelects(t *testing.T) {
70 t.Parallel()
71
72 m := NewMenu(testItems(), testStyles())
73
74 // Cursor starts at 0 ("backup"). Press enter.
75 updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
76 menu := updated.(*Menu)
77
78 if cmd == nil {
79 t.Fatal("expected DoneCmd on enter")
80 }
81 // Verify the cmd produces a DoneMsg.
82 msg := cmd()
83 if _, ok := msg.(ui.DoneMsg); !ok {
84 t.Errorf("cmd produced %T, want ui.DoneMsg", msg)
85 }
86
87 if got := menu.Selection(); got != "backup" {
88 t.Errorf("Selection() = %q, want %q", got, "backup")
89 }
90}
91
92func TestMenuHotkeySelects(t *testing.T) {
93 t.Parallel()
94
95 m := NewMenu(testItems(), testStyles())
96
97 // Press 'r' — should jump to "restore" and select it.
98 updated, cmd := m.Update(tea.KeyPressMsg{Code: 'r', Text: "r"})
99 menu := updated.(*Menu)
100
101 if cmd == nil {
102 t.Fatal("expected DoneCmd on hotkey")
103 }
104 msg := cmd()
105 if _, ok := msg.(ui.DoneMsg); !ok {
106 t.Errorf("cmd produced %T, want ui.DoneMsg", msg)
107 }
108
109 if got := menu.Selection(); got != "restore" {
110 t.Errorf("Selection() = %q, want %q", got, "restore")
111 }
112}
113
114func TestMenuEscReturnsBack(t *testing.T) {
115 t.Parallel()
116
117 m := NewMenu(testItems(), testStyles())
118
119 _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
120
121 if cmd == nil {
122 t.Fatal("expected BackCmd on Esc")
123 }
124 msg := cmd()
125 if _, ok := msg.(ui.BackMsg); !ok {
126 t.Errorf("cmd produced %T, want ui.BackMsg", msg)
127 }
128}
129
130func TestMenuCursorNavigation(t *testing.T) {
131 t.Parallel()
132
133 m := NewMenu(testItems(), testStyles())
134
135 // Start at 0. Move down twice.
136 updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
137 updated, _ = updated.(*Menu).Update(tea.KeyPressMsg{Code: tea.KeyDown})
138 menu := updated.(*Menu)
139
140 // Select to verify cursor is at index 2 ("snapshots").
141 updated, _ = menu.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
142 menu = updated.(*Menu)
143 if got := menu.Selection(); got != "snapshots" {
144 t.Errorf("after two downs, Selection() = %q, want %q", got, "snapshots")
145 }
146}
147
148func TestMenuCursorClampsAtBounds(t *testing.T) {
149 t.Parallel()
150
151 m := NewMenu(testItems(), testStyles())
152
153 // Try moving up past the top — should clamp at 0.
154 updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
155 menu := updated.(*Menu)
156
157 updated, _ = menu.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
158 menu = updated.(*Menu)
159 if got := menu.Selection(); got != "backup" {
160 t.Errorf("after up at top, Selection() = %q, want %q", got, "backup")
161 }
162}
163
164func TestMenuCursorClampsAtBottom(t *testing.T) {
165 t.Parallel()
166
167 items := []MenuItem{{Label: "one", Hotkey: 'o'}}
168 m := NewMenu(items, testStyles())
169
170 // Move down past the bottom — should clamp at last item.
171 updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
172 menu := updated.(*Menu)
173
174 updated, _ = menu.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
175 menu = updated.(*Menu)
176 if got := menu.Selection(); got != "one" {
177 t.Errorf("after down past end, Selection() = %q, want %q", got, "one")
178 }
179}
180
181func TestMenuViewShowsCursor(t *testing.T) {
182 t.Parallel()
183
184 m := NewMenu(testItems(), testStyles())
185 view := m.View()
186
187 if !strings.Contains(view, theme.Cursor) {
188 t.Errorf("View() should contain cursor %q, got:\n%s", theme.Cursor, view)
189 }
190}
191
192func TestMenuViewNoHelpText(t *testing.T) {
193 t.Parallel()
194
195 m := NewMenu(testItems(), testStyles())
196 view := m.View()
197
198 // The old menu rendered its own help string. The session now
199 // provides the help bar, so the menu view must not include it.
200 if strings.Contains(view, "navigate") {
201 t.Errorf("View() should not contain help text, got:\n%s", view)
202 }
203 if strings.Contains(view, "q to quit") {
204 t.Errorf("View() should not contain quit help, got:\n%s", view)
205 }
206}
207
208func TestMenuItemValueOverridesLabel(t *testing.T) {
209 t.Parallel()
210
211 items := []MenuItem{
212 {Label: "display name", Hotkey: 'd', Value: "actual-value"},
213 }
214 m := NewMenu(items, testStyles())
215
216 updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
217 menu := updated.(*Menu)
218
219 if got := menu.Selection(); got != "actual-value" {
220 t.Errorf("Selection() = %q, want %q (Value should override Label)", got, "actual-value")
221 }
222}
223
224func TestMenuQKeyNoSpecialBehavior(t *testing.T) {
225 t.Parallel()
226
227 // Without a quit item, 'q' should do nothing special — it's not
228 // a hotkey for any of these items.
229 m := NewMenu(testItems(), testStyles())
230
231 updated, cmd := m.Update(tea.KeyPressMsg{Code: 'q', Text: "q"})
232 menu := updated.(*Menu)
233
234 if cmd != nil {
235 t.Error("'q' should not produce any command when not a hotkey")
236 }
237 if menu.Selection() != "" {
238 t.Error("'q' should not select anything when not a hotkey")
239 }
240}
241
242func TestMenuKJNavigation(t *testing.T) {
243 t.Parallel()
244
245 m := NewMenu(testItems(), testStyles())
246
247 // 'j' should move down (vim-style).
248 updated, _ := m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
249 menu := updated.(*Menu)
250
251 updated, _ = menu.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
252 menu = updated.(*Menu)
253 if got := menu.Selection(); got != "restore" {
254 t.Errorf("after 'j', Selection() = %q, want %q", got, "restore")
255 }
256}
257
258func TestMenuRuneHotkey(t *testing.T) {
259 t.Parallel()
260
261 // A hotkey that is a multi-byte UTF-8 rune (like 'ñ') should
262 // still be matched correctly via rune comparison.
263 items := []MenuItem{{Label: "año", Hotkey: 'ñ'}}
264 m := NewMenu(items, testStyles())
265
266 updated, cmd := m.Update(tea.KeyPressMsg{Code: 0, Text: "ñ"})
267 menu := updated.(*Menu)
268
269 if cmd == nil {
270 t.Fatal("expected DoneCmd for multi-byte rune hotkey")
271 }
272 if menu.Selection() != "año" {
273 t.Errorf("Selection() = %q, want %q", menu.Selection(), "año")
274 }
275}