mirror of
https://github.com/ollama/ollama.git
synced 2026-01-29 07:12:03 +03:00
914 lines
26 KiB
Go
914 lines
26 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestFilterItems(t *testing.T) {
|
|
items := []selectItem{
|
|
{Name: "llama3.2:latest"},
|
|
{Name: "qwen2.5:7b"},
|
|
{Name: "deepseek-v3:cloud"},
|
|
{Name: "GPT-OSS:20b"},
|
|
}
|
|
|
|
t.Run("EmptyFilter_ReturnsAllItems", func(t *testing.T) {
|
|
result := filterItems(items, "")
|
|
if len(result) != len(items) {
|
|
t.Errorf("expected %d items, got %d", len(items), len(result))
|
|
}
|
|
})
|
|
|
|
t.Run("CaseInsensitive_UppercaseFilterMatchesLowercase", func(t *testing.T) {
|
|
result := filterItems(items, "LLAMA")
|
|
if len(result) != 1 || result[0].Name != "llama3.2:latest" {
|
|
t.Errorf("expected llama3.2:latest, got %v", result)
|
|
}
|
|
})
|
|
|
|
t.Run("CaseInsensitive_LowercaseFilterMatchesUppercase", func(t *testing.T) {
|
|
result := filterItems(items, "gpt")
|
|
if len(result) != 1 || result[0].Name != "GPT-OSS:20b" {
|
|
t.Errorf("expected GPT-OSS:20b, got %v", result)
|
|
}
|
|
})
|
|
|
|
t.Run("PartialMatch", func(t *testing.T) {
|
|
result := filterItems(items, "deep")
|
|
if len(result) != 1 || result[0].Name != "deepseek-v3:cloud" {
|
|
t.Errorf("expected deepseek-v3:cloud, got %v", result)
|
|
}
|
|
})
|
|
|
|
t.Run("NoMatch_ReturnsEmpty", func(t *testing.T) {
|
|
result := filterItems(items, "nonexistent")
|
|
if len(result) != 0 {
|
|
t.Errorf("expected 0 items, got %d", len(result))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSelectState(t *testing.T) {
|
|
items := []selectItem{
|
|
{Name: "item1"},
|
|
{Name: "item2"},
|
|
{Name: "item3"},
|
|
}
|
|
|
|
t.Run("InitialState", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
if s.selected != 0 {
|
|
t.Errorf("expected selected=0, got %d", s.selected)
|
|
}
|
|
if s.filter != "" {
|
|
t.Errorf("expected empty filter, got %q", s.filter)
|
|
}
|
|
if s.scrollOffset != 0 {
|
|
t.Errorf("expected scrollOffset=0, got %d", s.scrollOffset)
|
|
}
|
|
})
|
|
|
|
t.Run("Enter_SelectsCurrentItem", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
done, result, err := s.handleInput(eventEnter, 0)
|
|
if !done || result != "item1" || err != nil {
|
|
t.Errorf("expected (true, item1, nil), got (%v, %v, %v)", done, result, err)
|
|
}
|
|
})
|
|
|
|
t.Run("Enter_WithFilter_SelectsFilteredItem", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
s.filter = "item3"
|
|
done, result, err := s.handleInput(eventEnter, 0)
|
|
if !done || result != "item3" || err != nil {
|
|
t.Errorf("expected (true, item3, nil), got (%v, %v, %v)", done, result, err)
|
|
}
|
|
})
|
|
|
|
t.Run("Enter_EmptyFilteredList_DoesNothing", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
s.filter = "nonexistent"
|
|
done, result, err := s.handleInput(eventEnter, 0)
|
|
if done || result != "" || err != nil {
|
|
t.Errorf("expected (false, '', nil), got (%v, %v, %v)", done, result, err)
|
|
}
|
|
})
|
|
|
|
t.Run("Escape_ReturnsCancelledError", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
done, result, err := s.handleInput(eventEscape, 0)
|
|
if !done || result != "" || err != errCancelled {
|
|
t.Errorf("expected (true, '', errCancelled), got (%v, %v, %v)", done, result, err)
|
|
}
|
|
})
|
|
|
|
t.Run("Down_MovesSelection", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
s.handleInput(eventDown, 0)
|
|
if s.selected != 1 {
|
|
t.Errorf("expected selected=1, got %d", s.selected)
|
|
}
|
|
})
|
|
|
|
t.Run("Down_AtBottom_StaysAtBottom", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
s.selected = 2
|
|
s.handleInput(eventDown, 0)
|
|
if s.selected != 2 {
|
|
t.Errorf("expected selected=2 (stayed at bottom), got %d", s.selected)
|
|
}
|
|
})
|
|
|
|
t.Run("Up_MovesSelection", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
s.selected = 2
|
|
s.handleInput(eventUp, 0)
|
|
if s.selected != 1 {
|
|
t.Errorf("expected selected=1, got %d", s.selected)
|
|
}
|
|
})
|
|
|
|
t.Run("Up_AtTop_StaysAtTop", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
s.handleInput(eventUp, 0)
|
|
if s.selected != 0 {
|
|
t.Errorf("expected selected=0 (stayed at top), got %d", s.selected)
|
|
}
|
|
})
|
|
|
|
t.Run("Char_AppendsToFilter", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
s.handleInput(eventChar, 'i')
|
|
s.handleInput(eventChar, 't')
|
|
s.handleInput(eventChar, 'e')
|
|
s.handleInput(eventChar, 'm')
|
|
s.handleInput(eventChar, '2')
|
|
if s.filter != "item2" {
|
|
t.Errorf("expected filter='item2', got %q", s.filter)
|
|
}
|
|
filtered := s.filtered()
|
|
if len(filtered) != 1 || filtered[0].Name != "item2" {
|
|
t.Errorf("expected [item2], got %v", filtered)
|
|
}
|
|
})
|
|
|
|
t.Run("Char_ResetsSelectionToZero", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
s.selected = 2
|
|
s.handleInput(eventChar, 'x')
|
|
if s.selected != 0 {
|
|
t.Errorf("expected selected=0 after typing, got %d", s.selected)
|
|
}
|
|
})
|
|
|
|
t.Run("Backspace_RemovesLastFilterChar", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
s.filter = "test"
|
|
s.handleInput(eventBackspace, 0)
|
|
if s.filter != "tes" {
|
|
t.Errorf("expected filter='tes', got %q", s.filter)
|
|
}
|
|
})
|
|
|
|
t.Run("Backspace_EmptyFilter_DoesNothing", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
s.handleInput(eventBackspace, 0)
|
|
if s.filter != "" {
|
|
t.Errorf("expected filter='', got %q", s.filter)
|
|
}
|
|
})
|
|
|
|
t.Run("Backspace_ResetsSelectionToZero", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
s.filter = "test"
|
|
s.selected = 2
|
|
s.handleInput(eventBackspace, 0)
|
|
if s.selected != 0 {
|
|
t.Errorf("expected selected=0 after backspace, got %d", s.selected)
|
|
}
|
|
})
|
|
|
|
t.Run("Scroll_DownPastVisibleItems_ScrollsViewport", func(t *testing.T) {
|
|
// maxDisplayedItems is 10, so with 15 items we need to scroll
|
|
manyItems := make([]selectItem, 15)
|
|
for i := range manyItems {
|
|
manyItems[i] = selectItem{Name: string(rune('a' + i))}
|
|
}
|
|
s := newSelectState(manyItems)
|
|
|
|
// move down 12 times (past the 10-item viewport)
|
|
for range 12 {
|
|
s.handleInput(eventDown, 0)
|
|
}
|
|
|
|
if s.selected != 12 {
|
|
t.Errorf("expected selected=12, got %d", s.selected)
|
|
}
|
|
if s.scrollOffset != 3 {
|
|
t.Errorf("expected scrollOffset=3 (12-10+1), got %d", s.scrollOffset)
|
|
}
|
|
})
|
|
|
|
t.Run("Scroll_UpPastScrollOffset_ScrollsViewport", func(t *testing.T) {
|
|
manyItems := make([]selectItem, 15)
|
|
for i := range manyItems {
|
|
manyItems[i] = selectItem{Name: string(rune('a' + i))}
|
|
}
|
|
s := newSelectState(manyItems)
|
|
s.selected = 5
|
|
s.scrollOffset = 5
|
|
|
|
s.handleInput(eventUp, 0)
|
|
|
|
if s.selected != 4 {
|
|
t.Errorf("expected selected=4, got %d", s.selected)
|
|
}
|
|
if s.scrollOffset != 4 {
|
|
t.Errorf("expected scrollOffset=4, got %d", s.scrollOffset)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMultiSelectState(t *testing.T) {
|
|
items := []selectItem{
|
|
{Name: "item1"},
|
|
{Name: "item2"},
|
|
{Name: "item3"},
|
|
}
|
|
|
|
t.Run("InitialState_NoPrechecked", func(t *testing.T) {
|
|
s := newMultiSelectState(items, nil)
|
|
if s.highlighted != 0 {
|
|
t.Errorf("expected highlighted=0, got %d", s.highlighted)
|
|
}
|
|
if s.selectedCount() != 0 {
|
|
t.Errorf("expected 0 selected, got %d", s.selectedCount())
|
|
}
|
|
if s.focusOnButton {
|
|
t.Error("expected focusOnButton=false initially")
|
|
}
|
|
})
|
|
|
|
t.Run("InitialState_WithPrechecked", func(t *testing.T) {
|
|
s := newMultiSelectState(items, []string{"item2", "item3"})
|
|
if s.selectedCount() != 2 {
|
|
t.Errorf("expected 2 selected, got %d", s.selectedCount())
|
|
}
|
|
if !s.checked[1] || !s.checked[2] {
|
|
t.Error("expected item2 and item3 to be checked")
|
|
}
|
|
})
|
|
|
|
t.Run("Prechecked_PreservesSelectionOrder", func(t *testing.T) {
|
|
// order matters: first checked = default model
|
|
s := newMultiSelectState(items, []string{"item3", "item1"})
|
|
if len(s.checkOrder) != 2 {
|
|
t.Fatalf("expected 2 in checkOrder, got %d", len(s.checkOrder))
|
|
}
|
|
if s.checkOrder[0] != 2 || s.checkOrder[1] != 0 {
|
|
t.Errorf("expected checkOrder=[2,0] (item3 first), got %v", s.checkOrder)
|
|
}
|
|
})
|
|
|
|
t.Run("Prechecked_IgnoresInvalidNames", func(t *testing.T) {
|
|
s := newMultiSelectState(items, []string{"item1", "nonexistent"})
|
|
if s.selectedCount() != 1 {
|
|
t.Errorf("expected 1 selected (nonexistent ignored), got %d", s.selectedCount())
|
|
}
|
|
})
|
|
|
|
t.Run("Toggle_ChecksUncheckedItem", func(t *testing.T) {
|
|
s := newMultiSelectState(items, nil)
|
|
s.toggleItem()
|
|
if !s.checked[0] {
|
|
t.Error("expected item1 to be checked after toggle")
|
|
}
|
|
})
|
|
|
|
t.Run("Toggle_UnchecksCheckedItem", func(t *testing.T) {
|
|
s := newMultiSelectState(items, []string{"item1"})
|
|
s.toggleItem()
|
|
if s.checked[0] {
|
|
t.Error("expected item1 to be unchecked after toggle")
|
|
}
|
|
})
|
|
|
|
t.Run("Toggle_RemovesFromCheckOrder", func(t *testing.T) {
|
|
s := newMultiSelectState(items, []string{"item1", "item2", "item3"})
|
|
s.highlighted = 1 // toggle item2
|
|
s.toggleItem()
|
|
|
|
if len(s.checkOrder) != 2 {
|
|
t.Fatalf("expected 2 in checkOrder, got %d", len(s.checkOrder))
|
|
}
|
|
// should be [0, 2] (item1, item3) with item2 removed
|
|
if s.checkOrder[0] != 0 || s.checkOrder[1] != 2 {
|
|
t.Errorf("expected checkOrder=[0,2], got %v", s.checkOrder)
|
|
}
|
|
})
|
|
|
|
t.Run("Enter_TogglesWhenNotOnButton", func(t *testing.T) {
|
|
s := newMultiSelectState(items, nil)
|
|
s.handleInput(eventEnter, 0)
|
|
if !s.checked[0] {
|
|
t.Error("expected item1 to be checked after enter")
|
|
}
|
|
})
|
|
|
|
t.Run("Enter_OnButton_ReturnsSelection", func(t *testing.T) {
|
|
s := newMultiSelectState(items, []string{"item2", "item1"})
|
|
s.focusOnButton = true
|
|
|
|
done, result, err := s.handleInput(eventEnter, 0)
|
|
|
|
if !done || err != nil {
|
|
t.Errorf("expected done=true, err=nil, got done=%v, err=%v", done, err)
|
|
}
|
|
// result should preserve selection order
|
|
if len(result) != 2 || result[0] != "item2" || result[1] != "item1" {
|
|
t.Errorf("expected [item2, item1], got %v", result)
|
|
}
|
|
})
|
|
|
|
t.Run("Enter_OnButton_EmptySelection_DoesNothing", func(t *testing.T) {
|
|
s := newMultiSelectState(items, nil)
|
|
s.focusOnButton = true
|
|
done, result, err := s.handleInput(eventEnter, 0)
|
|
if done || result != nil || err != nil {
|
|
t.Errorf("expected (false, nil, nil), got (%v, %v, %v)", done, result, err)
|
|
}
|
|
})
|
|
|
|
t.Run("Tab_SwitchesToButton_WhenHasSelection", func(t *testing.T) {
|
|
s := newMultiSelectState(items, []string{"item1"})
|
|
s.handleInput(eventTab, 0)
|
|
if !s.focusOnButton {
|
|
t.Error("expected focus on button after tab")
|
|
}
|
|
})
|
|
|
|
t.Run("Tab_DoesNothing_WhenNoSelection", func(t *testing.T) {
|
|
s := newMultiSelectState(items, nil)
|
|
s.handleInput(eventTab, 0)
|
|
if s.focusOnButton {
|
|
t.Error("tab should not focus button when nothing selected")
|
|
}
|
|
})
|
|
|
|
t.Run("Tab_TogglesButtonFocus", func(t *testing.T) {
|
|
s := newMultiSelectState(items, []string{"item1"})
|
|
s.handleInput(eventTab, 0)
|
|
if !s.focusOnButton {
|
|
t.Error("expected focus on button after first tab")
|
|
}
|
|
s.handleInput(eventTab, 0)
|
|
if s.focusOnButton {
|
|
t.Error("expected focus back on list after second tab")
|
|
}
|
|
})
|
|
|
|
t.Run("Escape_ReturnsCancelledError", func(t *testing.T) {
|
|
s := newMultiSelectState(items, []string{"item1"})
|
|
done, result, err := s.handleInput(eventEscape, 0)
|
|
if !done || result != nil || err != errCancelled {
|
|
t.Errorf("expected (true, nil, errCancelled), got (%v, %v, %v)", done, result, err)
|
|
}
|
|
})
|
|
|
|
t.Run("IsDefault_TrueForFirstChecked", func(t *testing.T) {
|
|
s := newMultiSelectState(items, []string{"item2", "item1"})
|
|
if !(len(s.checkOrder) > 0 && s.checkOrder[0] == 1) {
|
|
t.Error("expected item2 (idx 1) to be default (first checked)")
|
|
}
|
|
if len(s.checkOrder) > 0 && s.checkOrder[0] == 0 {
|
|
t.Error("expected item1 (idx 0) to NOT be default")
|
|
}
|
|
})
|
|
|
|
t.Run("IsDefault_FalseWhenNothingChecked", func(t *testing.T) {
|
|
s := newMultiSelectState(items, nil)
|
|
if len(s.checkOrder) > 0 && s.checkOrder[0] == 0 {
|
|
t.Error("expected isDefault=false when nothing checked")
|
|
}
|
|
})
|
|
|
|
t.Run("Down_MovesHighlight", func(t *testing.T) {
|
|
s := newMultiSelectState(items, nil)
|
|
s.handleInput(eventDown, 0)
|
|
if s.highlighted != 1 {
|
|
t.Errorf("expected highlighted=1, got %d", s.highlighted)
|
|
}
|
|
})
|
|
|
|
t.Run("Up_MovesHighlight", func(t *testing.T) {
|
|
s := newMultiSelectState(items, nil)
|
|
s.highlighted = 1
|
|
s.handleInput(eventUp, 0)
|
|
if s.highlighted != 0 {
|
|
t.Errorf("expected highlighted=0, got %d", s.highlighted)
|
|
}
|
|
})
|
|
|
|
t.Run("Arrow_ReturnsFocusFromButton", func(t *testing.T) {
|
|
s := newMultiSelectState(items, []string{"item1"})
|
|
s.focusOnButton = true
|
|
s.handleInput(eventDown, 0)
|
|
if s.focusOnButton {
|
|
t.Error("expected focus to return to list on arrow key")
|
|
}
|
|
})
|
|
|
|
t.Run("Char_AppendsToFilter", func(t *testing.T) {
|
|
s := newMultiSelectState(items, nil)
|
|
s.handleInput(eventChar, 'x')
|
|
if s.filter != "x" {
|
|
t.Errorf("expected filter='x', got %q", s.filter)
|
|
}
|
|
})
|
|
|
|
t.Run("Char_ResetsHighlightAndScroll", func(t *testing.T) {
|
|
manyItems := make([]selectItem, 15)
|
|
for i := range manyItems {
|
|
manyItems[i] = selectItem{Name: string(rune('a' + i))}
|
|
}
|
|
s := newMultiSelectState(manyItems, nil)
|
|
s.highlighted = 10
|
|
s.scrollOffset = 5
|
|
|
|
s.handleInput(eventChar, 'x')
|
|
|
|
if s.highlighted != 0 {
|
|
t.Errorf("expected highlighted=0, got %d", s.highlighted)
|
|
}
|
|
if s.scrollOffset != 0 {
|
|
t.Errorf("expected scrollOffset=0, got %d", s.scrollOffset)
|
|
}
|
|
})
|
|
|
|
t.Run("Backspace_RemovesLastFilterChar", func(t *testing.T) {
|
|
s := newMultiSelectState(items, nil)
|
|
s.filter = "test"
|
|
s.handleInput(eventBackspace, 0)
|
|
if s.filter != "tes" {
|
|
t.Errorf("expected filter='tes', got %q", s.filter)
|
|
}
|
|
})
|
|
|
|
t.Run("Backspace_RemovesFocusFromButton", func(t *testing.T) {
|
|
s := newMultiSelectState(items, []string{"item1"})
|
|
s.filter = "x"
|
|
s.focusOnButton = true
|
|
s.handleInput(eventBackspace, 0)
|
|
if s.focusOnButton {
|
|
t.Error("expected focusOnButton=false after backspace")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestParseInput(t *testing.T) {
|
|
t.Run("Enter", func(t *testing.T) {
|
|
event, char, err := parseInput(bytes.NewReader([]byte{13}))
|
|
if err != nil || event != eventEnter || char != 0 {
|
|
t.Errorf("expected (eventEnter, 0, nil), got (%v, %v, %v)", event, char, err)
|
|
}
|
|
})
|
|
|
|
t.Run("Escape", func(t *testing.T) {
|
|
event, _, err := parseInput(bytes.NewReader([]byte{27}))
|
|
if err != nil || event != eventEscape {
|
|
t.Errorf("expected eventEscape, got %v", event)
|
|
}
|
|
})
|
|
|
|
t.Run("CtrlC_TreatedAsEscape", func(t *testing.T) {
|
|
event, _, err := parseInput(bytes.NewReader([]byte{3}))
|
|
if err != nil || event != eventEscape {
|
|
t.Errorf("expected eventEscape for Ctrl+C, got %v", event)
|
|
}
|
|
})
|
|
|
|
t.Run("Tab", func(t *testing.T) {
|
|
event, _, err := parseInput(bytes.NewReader([]byte{9}))
|
|
if err != nil || event != eventTab {
|
|
t.Errorf("expected eventTab, got %v", event)
|
|
}
|
|
})
|
|
|
|
t.Run("Backspace", func(t *testing.T) {
|
|
event, _, err := parseInput(bytes.NewReader([]byte{127}))
|
|
if err != nil || event != eventBackspace {
|
|
t.Errorf("expected eventBackspace, got %v", event)
|
|
}
|
|
})
|
|
|
|
t.Run("UpArrow", func(t *testing.T) {
|
|
event, _, err := parseInput(bytes.NewReader([]byte{27, 91, 65}))
|
|
if err != nil || event != eventUp {
|
|
t.Errorf("expected eventUp, got %v", event)
|
|
}
|
|
})
|
|
|
|
t.Run("DownArrow", func(t *testing.T) {
|
|
event, _, err := parseInput(bytes.NewReader([]byte{27, 91, 66}))
|
|
if err != nil || event != eventDown {
|
|
t.Errorf("expected eventDown, got %v", event)
|
|
}
|
|
})
|
|
|
|
t.Run("PrintableChars", func(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
char byte
|
|
}{
|
|
{"lowercase", 'a'},
|
|
{"uppercase", 'Z'},
|
|
{"digit", '5'},
|
|
{"space", ' '},
|
|
{"tilde", '~'},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
event, char, err := parseInput(bytes.NewReader([]byte{tt.char}))
|
|
if err != nil || event != eventChar || char != tt.char {
|
|
t.Errorf("expected (eventChar, %q), got (%v, %q)", tt.char, event, char)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRenderSelect(t *testing.T) {
|
|
items := []selectItem{
|
|
{Name: "item1", Description: "first item"},
|
|
{Name: "item2"},
|
|
}
|
|
|
|
t.Run("ShowsPromptAndItems", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
var buf bytes.Buffer
|
|
lineCount := renderSelect(&buf, "Select:", s)
|
|
|
|
output := buf.String()
|
|
if !strings.Contains(output, "Select:") {
|
|
t.Error("expected prompt in output")
|
|
}
|
|
if !strings.Contains(output, "item1") {
|
|
t.Error("expected item1 in output")
|
|
}
|
|
if !strings.Contains(output, "first item") {
|
|
t.Error("expected description in output")
|
|
}
|
|
if !strings.Contains(output, "item2") {
|
|
t.Error("expected item2 in output")
|
|
}
|
|
if lineCount != 3 { // 1 prompt + 2 items
|
|
t.Errorf("expected 3 lines, got %d", lineCount)
|
|
}
|
|
})
|
|
|
|
t.Run("EmptyFilteredList_ShowsNoMatches", func(t *testing.T) {
|
|
s := newSelectState(items)
|
|
s.filter = "xyz"
|
|
var buf bytes.Buffer
|
|
renderSelect(&buf, "Select:", s)
|
|
|
|
if !strings.Contains(buf.String(), "no matches") {
|
|
t.Error("expected 'no matches' message")
|
|
}
|
|
})
|
|
|
|
t.Run("LongList_ShowsRemainingCount", func(t *testing.T) {
|
|
manyItems := make([]selectItem, 15)
|
|
for i := range manyItems {
|
|
manyItems[i] = selectItem{Name: string(rune('a' + i))}
|
|
}
|
|
s := newSelectState(manyItems)
|
|
var buf bytes.Buffer
|
|
renderSelect(&buf, "Select:", s)
|
|
|
|
// 15 items - 10 displayed = 5 more
|
|
if !strings.Contains(buf.String(), "5 more") {
|
|
t.Error("expected '5 more' indicator")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRenderMultiSelect(t *testing.T) {
|
|
items := []selectItem{
|
|
{Name: "item1"},
|
|
{Name: "item2"},
|
|
}
|
|
|
|
t.Run("ShowsCheckboxes", func(t *testing.T) {
|
|
s := newMultiSelectState(items, []string{"item1"})
|
|
var buf bytes.Buffer
|
|
renderMultiSelect(&buf, "Select:", s)
|
|
|
|
output := buf.String()
|
|
if !strings.Contains(output, "[x]") {
|
|
t.Error("expected checked checkbox [x]")
|
|
}
|
|
if !strings.Contains(output, "[ ]") {
|
|
t.Error("expected unchecked checkbox [ ]")
|
|
}
|
|
})
|
|
|
|
t.Run("ShowsDefaultMarker", func(t *testing.T) {
|
|
s := newMultiSelectState(items, []string{"item1"})
|
|
var buf bytes.Buffer
|
|
renderMultiSelect(&buf, "Select:", s)
|
|
|
|
if !strings.Contains(buf.String(), "(default)") {
|
|
t.Error("expected (default) marker for first checked item")
|
|
}
|
|
})
|
|
|
|
t.Run("ShowsSelectedCount", func(t *testing.T) {
|
|
s := newMultiSelectState(items, []string{"item1", "item2"})
|
|
var buf bytes.Buffer
|
|
renderMultiSelect(&buf, "Select:", s)
|
|
|
|
if !strings.Contains(buf.String(), "2 selected") {
|
|
t.Error("expected '2 selected' in output")
|
|
}
|
|
})
|
|
|
|
t.Run("NoSelection_ShowsHelperText", func(t *testing.T) {
|
|
s := newMultiSelectState(items, nil)
|
|
var buf bytes.Buffer
|
|
renderMultiSelect(&buf, "Select:", s)
|
|
|
|
if !strings.Contains(buf.String(), "Select at least one") {
|
|
t.Error("expected 'Select at least one' helper text")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestErrCancelled(t *testing.T) {
|
|
t.Run("NotNil", func(t *testing.T) {
|
|
if errCancelled == nil {
|
|
t.Error("errCancelled should not be nil")
|
|
}
|
|
})
|
|
|
|
t.Run("Message", func(t *testing.T) {
|
|
if errCancelled.Error() != "cancelled" {
|
|
t.Errorf("expected 'cancelled', got %q", errCancelled.Error())
|
|
}
|
|
})
|
|
}
|
|
|
|
// Edge case tests for selector.go
|
|
|
|
// TestSelectState_SingleItem verifies that single item list works without crash.
|
|
// List with only one item should still work.
|
|
func TestSelectState_SingleItem(t *testing.T) {
|
|
items := []selectItem{{Name: "only-one"}}
|
|
|
|
s := newSelectState(items)
|
|
|
|
// Down should do nothing (already at bottom)
|
|
s.handleInput(eventDown, 0)
|
|
if s.selected != 0 {
|
|
t.Errorf("down on single item: expected selected=0, got %d", s.selected)
|
|
}
|
|
|
|
// Up should do nothing (already at top)
|
|
s.handleInput(eventUp, 0)
|
|
if s.selected != 0 {
|
|
t.Errorf("up on single item: expected selected=0, got %d", s.selected)
|
|
}
|
|
|
|
// Enter should select the only item
|
|
done, result, err := s.handleInput(eventEnter, 0)
|
|
if !done || result != "only-one" || err != nil {
|
|
t.Errorf("enter on single item: expected (true, 'only-one', nil), got (%v, %q, %v)", done, result, err)
|
|
}
|
|
}
|
|
|
|
// TestSelectState_ExactlyMaxItems verifies boundary condition at maxDisplayedItems.
|
|
// List with exactly maxDisplayedItems items should not scroll.
|
|
func TestSelectState_ExactlyMaxItems(t *testing.T) {
|
|
items := make([]selectItem, maxDisplayedItems)
|
|
for i := range items {
|
|
items[i] = selectItem{Name: string(rune('a' + i))}
|
|
}
|
|
|
|
s := newSelectState(items)
|
|
|
|
// Move to last item
|
|
for range maxDisplayedItems - 1 {
|
|
s.handleInput(eventDown, 0)
|
|
}
|
|
|
|
if s.selected != maxDisplayedItems-1 {
|
|
t.Errorf("expected selected=%d, got %d", maxDisplayedItems-1, s.selected)
|
|
}
|
|
|
|
// Should not scroll when exactly at max
|
|
if s.scrollOffset != 0 {
|
|
t.Errorf("expected scrollOffset=0 for exactly maxDisplayedItems, got %d", s.scrollOffset)
|
|
}
|
|
|
|
// One more down should do nothing
|
|
s.handleInput(eventDown, 0)
|
|
if s.selected != maxDisplayedItems-1 {
|
|
t.Errorf("down at max: expected selected=%d, got %d", maxDisplayedItems-1, s.selected)
|
|
}
|
|
}
|
|
|
|
// TestFilterItems_RegexSpecialChars verifies that filter is literal, not regex.
|
|
// User typing "model.v1" shouldn't match "modelsv1".
|
|
func TestFilterItems_RegexSpecialChars(t *testing.T) {
|
|
items := []selectItem{
|
|
{Name: "model.v1"},
|
|
{Name: "modelsv1"},
|
|
{Name: "model-v1"},
|
|
}
|
|
|
|
// Filter with dot should only match literal dot
|
|
result := filterItems(items, "model.v1")
|
|
if len(result) != 1 {
|
|
t.Errorf("expected 1 exact match, got %d", len(result))
|
|
}
|
|
if len(result) > 0 && result[0].Name != "model.v1" {
|
|
t.Errorf("expected 'model.v1', got %s", result[0].Name)
|
|
}
|
|
|
|
// Other regex special chars should be literal too
|
|
items2 := []selectItem{
|
|
{Name: "test[0]"},
|
|
{Name: "test0"},
|
|
{Name: "test(1)"},
|
|
}
|
|
|
|
result2 := filterItems(items2, "test[0]")
|
|
if len(result2) != 1 || result2[0].Name != "test[0]" {
|
|
t.Errorf("expected only 'test[0]', got %v", result2)
|
|
}
|
|
}
|
|
|
|
// TestMultiSelectState_DuplicateNames documents handling of duplicate item names.
|
|
// itemIndex uses name as key - duplicates cause collision. This documents
|
|
// the current behavior: the last index for a duplicate name is stored
|
|
func TestMultiSelectState_DuplicateNames(t *testing.T) {
|
|
// Duplicate names - this is an edge case that shouldn't happen in practice
|
|
items := []selectItem{
|
|
{Name: "duplicate"},
|
|
{Name: "duplicate"},
|
|
{Name: "unique"},
|
|
}
|
|
|
|
s := newMultiSelectState(items, nil)
|
|
|
|
// DOCUMENTED BEHAVIOR: itemIndex maps name to LAST index
|
|
// When there are duplicates, only the last occurrence's index is stored
|
|
if s.itemIndex["duplicate"] != 1 {
|
|
t.Errorf("itemIndex should map 'duplicate' to last index (1), got %d", s.itemIndex["duplicate"])
|
|
}
|
|
|
|
// Toggle item at highlighted=0 (first "duplicate")
|
|
// Due to name collision, toggleItem uses itemIndex["duplicate"] = 1
|
|
// So it actually toggles the SECOND duplicate item, not the first
|
|
s.toggleItem()
|
|
|
|
// This documents the potentially surprising behavior:
|
|
// We toggled at highlighted=0, but itemIndex lookup returned 1
|
|
if !s.checked[1] {
|
|
t.Error("toggle should check index 1 (due to name collision in itemIndex)")
|
|
}
|
|
if s.checked[0] {
|
|
t.Log("Note: index 0 is NOT checked, even though highlighted=0 (name collision behavior)")
|
|
}
|
|
}
|
|
|
|
// TestSelectState_FilterReducesBelowSelection verifies selection resets when filter reduces list.
|
|
// Prevents index-out-of-bounds on next keystroke
|
|
func TestSelectState_FilterReducesBelowSelection(t *testing.T) {
|
|
items := []selectItem{
|
|
{Name: "apple"},
|
|
{Name: "banana"},
|
|
{Name: "cherry"},
|
|
}
|
|
|
|
s := newSelectState(items)
|
|
s.selected = 2 // Select "cherry"
|
|
|
|
// Type a filter that removes cherry from results
|
|
s.handleInput(eventChar, 'a') // Filter to "a" - matches "apple" and "banana"
|
|
|
|
// Selection should reset to 0
|
|
if s.selected != 0 {
|
|
t.Errorf("expected selected=0 after filter, got %d", s.selected)
|
|
}
|
|
|
|
filtered := s.filtered()
|
|
if len(filtered) != 2 {
|
|
t.Errorf("expected 2 filtered items, got %d", len(filtered))
|
|
}
|
|
}
|
|
|
|
// TestFilterItems_UnicodeCharacters verifies filtering works with UTF-8.
|
|
// Model names might contain unicode characters
|
|
func TestFilterItems_UnicodeCharacters(t *testing.T) {
|
|
items := []selectItem{
|
|
{Name: "llama-日本語"},
|
|
{Name: "模型-chinese"},
|
|
{Name: "émoji-🦙"},
|
|
{Name: "regular-model"},
|
|
}
|
|
|
|
t.Run("filter japanese", func(t *testing.T) {
|
|
result := filterItems(items, "日本")
|
|
if len(result) != 1 || result[0].Name != "llama-日本語" {
|
|
t.Errorf("expected llama-日本語, got %v", result)
|
|
}
|
|
})
|
|
|
|
t.Run("filter chinese", func(t *testing.T) {
|
|
result := filterItems(items, "模型")
|
|
if len(result) != 1 || result[0].Name != "模型-chinese" {
|
|
t.Errorf("expected 模型-chinese, got %v", result)
|
|
}
|
|
})
|
|
|
|
t.Run("filter emoji", func(t *testing.T) {
|
|
result := filterItems(items, "🦙")
|
|
if len(result) != 1 || result[0].Name != "émoji-🦙" {
|
|
t.Errorf("expected émoji-🦙, got %v", result)
|
|
}
|
|
})
|
|
|
|
t.Run("filter accented char", func(t *testing.T) {
|
|
result := filterItems(items, "émoji")
|
|
if len(result) != 1 || result[0].Name != "émoji-🦙" {
|
|
t.Errorf("expected émoji-🦙, got %v", result)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMultiSelectState_FilterReducesBelowHighlight verifies highlight resets when filter reduces list.
|
|
func TestMultiSelectState_FilterReducesBelowHighlight(t *testing.T) {
|
|
items := []selectItem{
|
|
{Name: "apple"},
|
|
{Name: "banana"},
|
|
{Name: "cherry"},
|
|
}
|
|
|
|
s := newMultiSelectState(items, nil)
|
|
s.highlighted = 2 // Highlight "cherry"
|
|
|
|
// Type a filter that removes cherry
|
|
s.handleInput(eventChar, 'a')
|
|
|
|
if s.highlighted != 0 {
|
|
t.Errorf("expected highlighted=0 after filter, got %d", s.highlighted)
|
|
}
|
|
}
|
|
|
|
// TestMultiSelectState_EmptyItems verifies handling of empty item list.
|
|
// Empty list should be handled gracefully.
|
|
func TestMultiSelectState_EmptyItems(t *testing.T) {
|
|
s := newMultiSelectState([]selectItem{}, nil)
|
|
|
|
// Toggle should not panic on empty list
|
|
s.toggleItem()
|
|
|
|
if s.selectedCount() != 0 {
|
|
t.Errorf("expected 0 selected for empty list, got %d", s.selectedCount())
|
|
}
|
|
|
|
// Render should handle empty list
|
|
var buf bytes.Buffer
|
|
lineCount := renderMultiSelect(&buf, "Select:", s)
|
|
if lineCount == 0 {
|
|
t.Error("renderMultiSelect should produce output even for empty list")
|
|
}
|
|
if !strings.Contains(buf.String(), "no matches") {
|
|
t.Error("expected 'no matches' for empty list")
|
|
}
|
|
}
|
|
|
|
// TestSelectState_RenderWithDescriptions verifies rendering items with descriptions.
|
|
func TestSelectState_RenderWithDescriptions(t *testing.T) {
|
|
items := []selectItem{
|
|
{Name: "item1", Description: "First item description"},
|
|
{Name: "item2", Description: ""},
|
|
{Name: "item3", Description: "Third item"},
|
|
}
|
|
|
|
s := newSelectState(items)
|
|
var buf bytes.Buffer
|
|
renderSelect(&buf, "Select:", s)
|
|
|
|
output := buf.String()
|
|
if !strings.Contains(output, "First item description") {
|
|
t.Error("expected description to be rendered")
|
|
}
|
|
if !strings.Contains(output, "item2") {
|
|
t.Error("expected item without description to be rendered")
|
|
}
|
|
}
|