menu_test.go

  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}