From 62e6c0e680f924ffdb5dfc16fa80131c6e7163a8 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 29 Dec 2025 13:46:20 -0600 Subject: [PATCH] feat: implement item management handlers with corresponding tests --- internal/handlers/addItemHandler.go | 36 ++++++ internal/handlers/addItemHandler_test.go | 87 +++++++++++++ internal/handlers/deleteItemHandler.go | 36 ++++++ internal/handlers/deleteItemHandler_test.go | 100 ++++++++++++++ internal/handlers/selectItemHandler.go | 37 ++++++ internal/handlers/selectItemHandler_test.go | 136 ++++++++++++++++++++ internal/handlers/textInputHandler.go | 42 ++++++ internal/handlers/textInputHandler_test.go | 131 +++++++++++++++++++ internal/handlers/updateItemHandler.go | 36 ++++++ internal/handlers/updateItemHandler_test.go | 93 +++++++++++++ 10 files changed, 734 insertions(+) create mode 100644 internal/handlers/addItemHandler.go create mode 100644 internal/handlers/addItemHandler_test.go create mode 100644 internal/handlers/deleteItemHandler.go create mode 100644 internal/handlers/deleteItemHandler_test.go create mode 100644 internal/handlers/selectItemHandler.go create mode 100644 internal/handlers/selectItemHandler_test.go create mode 100644 internal/handlers/textInputHandler.go create mode 100644 internal/handlers/textInputHandler_test.go create mode 100644 internal/handlers/updateItemHandler.go create mode 100644 internal/handlers/updateItemHandler_test.go diff --git a/internal/handlers/addItemHandler.go b/internal/handlers/addItemHandler.go new file mode 100644 index 0000000..08f5535 --- /dev/null +++ b/internal/handlers/addItemHandler.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "slices" + + "github.com/StevanFreeborn/term-tier/internal/state" + tea "github.com/charmbracelet/bubbletea" +) + +type addItemHandler struct { + keys []string + description string +} + +func NewAddItemHandler() KeyMsgHandler { + return addItemHandler{ + keys: []string{"n"}, + description: "Add new item", + } +} + +func (h addItemHandler) KeysHandled() []string { + return h.keys +} + +func (h addItemHandler) Description() string { + return h.description +} + +func (h addItemHandler) Match(s state.State, msg tea.KeyMsg) bool { + return s.Mode() == state.NavigationMode && slices.Contains(h.keys, msg.String()) +} + +func (h addItemHandler) Handle(s state.State, msg tea.KeyMsg) tea.Cmd { + return s.AddItem() +} diff --git a/internal/handlers/addItemHandler_test.go b/internal/handlers/addItemHandler_test.go new file mode 100644 index 0000000..3048f4e --- /dev/null +++ b/internal/handlers/addItemHandler_test.go @@ -0,0 +1,87 @@ +package handlers_test + +import ( + "testing" + + "github.com/StevanFreeborn/term-tier/internal/handlers" + "github.com/StevanFreeborn/term-tier/internal/state" + tea "github.com/charmbracelet/bubbletea" +) + +func TestNewAddItemHandler(t *testing.T) { + h := handlers.NewAddItemHandler() + + if h.Description() != "Add new item" { + t.Errorf("Expected description 'Add new item', got %q", h.Description()) + } + + keys := h.KeysHandled() + + if len(keys) != 1 || keys[0] != "n" { + t.Errorf("Expected keys ['n'], got %v", keys) + } +} + +func TestAddItemHandler_Match(t *testing.T) { + h := handlers.NewAddItemHandler() + + tests := []struct { + name string + mode state.Mode + key string + expected bool + }{ + { + name: "Matches 'n' in Navigation Mode", + mode: state.NavigationMode, + key: "n", + expected: true, + }, + { + name: "Ignores 'n' in Input Mode", + mode: state.InputMode, + key: "n", + expected: false, + }, + { + name: "Ignores 'q' in Navigation Mode", + mode: state.NavigationMode, + key: "q", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := state.New(state.WithMode(tt.mode)) + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)} + + if got := h.Match(s, msg); got != tt.expected { + t.Errorf("Match() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestAddItemHandler_Handle(t *testing.T) { + h := handlers.NewAddItemHandler() + + s := state.New(state.WithMode(state.NavigationMode)) + + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")} + cmd := h.Handle(s, msg) + + if s.Mode() != state.InputMode { + t.Errorf("Expected state to switch to InputMode, got %v", s.Mode()) + } + + expectedTitle := "Add new item" + + if s.InputTitle() != expectedTitle { + t.Errorf("Expected input title %q, got %q", expectedTitle, s.InputTitle()) + } + + if cmd == nil { + t.Error("Expected Handle to return a tea.Cmd (cursor blink), got nil") + } +} diff --git a/internal/handlers/deleteItemHandler.go b/internal/handlers/deleteItemHandler.go new file mode 100644 index 0000000..e943126 --- /dev/null +++ b/internal/handlers/deleteItemHandler.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "slices" + + "github.com/StevanFreeborn/term-tier/internal/state" + tea "github.com/charmbracelet/bubbletea" +) + +type deleteItemHandler struct { + keys []string + description string +} + +func NewDeleteItemHandler() KeyMsgHandler { + return deleteItemHandler{ + keys: []string{"d", "backspace"}, + description: "Delete item", + } +} + +func (h deleteItemHandler) KeysHandled() []string { + return h.keys +} + +func (h deleteItemHandler) Description() string { + return h.description +} + +func (h deleteItemHandler) Match(s state.State, msg tea.KeyMsg) bool { + return s.Mode() == state.NavigationMode && slices.Contains(h.keys, msg.String()) +} + +func (h deleteItemHandler) Handle(s state.State, msg tea.KeyMsg) tea.Cmd { + return s.DeleteItem() +} diff --git a/internal/handlers/deleteItemHandler_test.go b/internal/handlers/deleteItemHandler_test.go new file mode 100644 index 0000000..fe859eb --- /dev/null +++ b/internal/handlers/deleteItemHandler_test.go @@ -0,0 +1,100 @@ +package handlers_test + +import ( + "slices" + "testing" + + "github.com/StevanFreeborn/term-tier/internal/core" + "github.com/StevanFreeborn/term-tier/internal/handlers" + "github.com/StevanFreeborn/term-tier/internal/state" + tea "github.com/charmbracelet/bubbletea" +) + +func TestNewDeleteItemHandler(t *testing.T) { + h := handlers.NewDeleteItemHandler() + + if h.Description() != "Delete item" { + t.Errorf("Expected description 'Delete item', got %q", h.Description()) + } + + keys := h.KeysHandled() + + if !slices.Contains(keys, "d") || !slices.Contains(keys, "backspace") { + t.Errorf("Expected keys ['d', 'backspace'], got %v", keys) + } +} + +func TestDeleteItemHandler_Match(t *testing.T) { + h := handlers.NewDeleteItemHandler() + + tests := []struct { + name string + mode state.Mode + key string + expected bool + }{ + { + name: "Matches 'd' in Navigation Mode", + mode: state.NavigationMode, + key: "d", + expected: true, + }, + { + name: "Matches 'backspace' in Navigation Mode", + mode: state.NavigationMode, + key: "backspace", + expected: true, + }, + { + name: "Ignores 'd' in Input Mode", + mode: state.InputMode, + key: "d", + expected: false, + }, + { + name: "Ignores other keys in Navigation Mode", + mode: state.NavigationMode, + key: "x", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := state.New(state.WithMode(tt.mode)) + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)} + + if tt.key == "backspace" { + msg = tea.KeyMsg{Type: tea.KeyBackspace} + } + + if got := h.Match(s, msg); got != tt.expected { + t.Errorf("Match() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestDeleteItemHandler_Handle(t *testing.T) { + h := handlers.NewDeleteItemHandler() + + tiers := make([]core.Tier, 6) + tiers[0] = core.Tier{Name: "S", Items: []core.Item{"Delete Me"}} + + s := state.New( + state.WithTiers(tiers), + state.WithCursor(0, 0), + state.WithMode(state.NavigationMode), + ) + + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")} + h.Handle(s, msg) + + if len(s.Tiers()[0].Items) != 0 { + t.Errorf("Expected item to be deleted, but %d items remain", len(s.Tiers()[0].Items)) + } + + if s.StatusMsg() != "Item Deleted" { + t.Errorf("Expected status 'Item Deleted', got %q", s.StatusMsg()) + } +} diff --git a/internal/handlers/selectItemHandler.go b/internal/handlers/selectItemHandler.go new file mode 100644 index 0000000..8d3e82e --- /dev/null +++ b/internal/handlers/selectItemHandler.go @@ -0,0 +1,37 @@ +package handlers + +import ( + "slices" + + "github.com/StevanFreeborn/term-tier/internal/state" + tea "github.com/charmbracelet/bubbletea" +) + +type selectItemHandler struct { + keys []string + description string +} + +func NewSelectItemHandler() KeyMsgHandler { + return selectItemHandler{ + keys: []string{"enter", "space", " "}, + description: "Select item", + } +} + +func (h selectItemHandler) KeysHandled() []string { + return h.keys +} + +func (h selectItemHandler) Description() string { + return h.description +} + +func (h selectItemHandler) Match(s state.State, msg tea.KeyMsg) bool { + return s.Mode() == state.NavigationMode && slices.Contains(h.keys, msg.String()) +} + +func (h selectItemHandler) Handle(s state.State, msg tea.KeyMsg) tea.Cmd { + s.SelectItem() + return nil +} diff --git a/internal/handlers/selectItemHandler_test.go b/internal/handlers/selectItemHandler_test.go new file mode 100644 index 0000000..71ee90f --- /dev/null +++ b/internal/handlers/selectItemHandler_test.go @@ -0,0 +1,136 @@ +package handlers_test + +import ( + "slices" + "testing" + + "github.com/StevanFreeborn/term-tier/internal/core" + "github.com/StevanFreeborn/term-tier/internal/handlers" + "github.com/StevanFreeborn/term-tier/internal/state" + tea "github.com/charmbracelet/bubbletea" +) + +func TestNewSelectItemHandler(t *testing.T) { + h := handlers.NewSelectItemHandler() + + if h.Description() != "Select item" { + t.Errorf("Expected description 'Select item', got %q", h.Description()) + } + + keys := h.KeysHandled() + + if !slices.Contains(keys, "enter") || !slices.Contains(keys, "space") { + t.Errorf("Expected keys ['enter', 'space'], got %v", keys) + } +} + +func TestSelectItemHandler_Match(t *testing.T) { + h := handlers.NewSelectItemHandler() + + tests := []struct { + name string + mode state.Mode + key string + expected bool + }{ + { + name: "Matches 'enter' in Navigation Mode", + mode: state.NavigationMode, + key: "enter", + expected: true, + }, + { + name: "Matches 'space' in Navigation Mode", + mode: state.NavigationMode, + key: " ", + expected: true, + }, + { + name: "Matches 'space' in Navigation Mode", + mode: state.NavigationMode, + key: "space", + expected: true, + }, + { + name: "Ignores 'enter' in Input Mode", + mode: state.InputMode, + key: "enter", + expected: false, + }, + { + name: "Ignores 'space' in Input Mode", + mode: state.InputMode, + key: " ", + expected: false, + }, + { + name: "Ignores 'space' in Input Mode", + mode: state.InputMode, + key: "space", + expected: false, + }, + { + name: "Ignores other keys", + mode: state.NavigationMode, + key: "a", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := state.New(state.WithMode(tt.mode)) + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)} + + switch tt.key { + case "enter": + msg = tea.KeyMsg{Type: tea.KeyEnter} + case " ": + msg = tea.KeyMsg{Type: tea.KeySpace} + } + + if got := h.Match(s, msg); got != tt.expected { + t.Errorf("Match() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestSelectItemHandler_Handle(t *testing.T) { + h := handlers.NewSelectItemHandler() + + tiers := []core.Tier{{Name: "S", Items: []core.Item{"Item A"}}} + + s := state.New( + state.WithTiers(tiers), + state.WithCursor(0, 0), + state.WithMode(state.NavigationMode), + ) + + t.Run("Starts Dragging", func(t *testing.T) { + msg := tea.KeyMsg{Type: tea.KeyEnter} + cmd := h.Handle(s, msg) + + if !s.Dragging() { + t.Error("Expected dragging to be true after selecting item") + } + + if cmd != nil { + t.Error("Expected nil command") + } + }) + + t.Run("Stops Dragging", func(t *testing.T) { + if !s.Dragging() { + s.SelectItem() + } + + msg := tea.KeyMsg{Type: tea.KeyEnter} + + h.Handle(s, msg) + + if s.Dragging() { + t.Error("Expected dragging to be false after selecting again") + } + }) +} diff --git a/internal/handlers/textInputHandler.go b/internal/handlers/textInputHandler.go new file mode 100644 index 0000000..2516c9b --- /dev/null +++ b/internal/handlers/textInputHandler.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "github.com/StevanFreeborn/term-tier/internal/state" + tea "github.com/charmbracelet/bubbletea" +) + +type textInputHandler struct { + keys []string + description string +} + +func NewTextInputHandler() KeyMsgHandler { + return textInputHandler{ + keys: []string{}, + description: "", + } +} + +func (h textInputHandler) KeysHandled() []string { + return h.keys +} + +func (h textInputHandler) Description() string { + return h.description +} + +func (h textInputHandler) Match(s state.State, msg tea.KeyMsg) bool { + return s.Mode() == state.InputMode +} + +func (h textInputHandler) Handle(s state.State, msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "esc": + s.ExitInputMode() + return nil + case "enter": + return s.SubmitInput() + default: + return s.UpdateInput(msg) + } +} diff --git a/internal/handlers/textInputHandler_test.go b/internal/handlers/textInputHandler_test.go new file mode 100644 index 0000000..7b5a3b4 --- /dev/null +++ b/internal/handlers/textInputHandler_test.go @@ -0,0 +1,131 @@ +package handlers_test + +import ( + "testing" + + "github.com/StevanFreeborn/term-tier/internal/handlers" + "github.com/StevanFreeborn/term-tier/internal/state" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +func TestNewTextInputHandler(t *testing.T) { + h := handlers.NewTextInputHandler() + + if len(h.KeysHandled()) != 0 { + t.Errorf("Expected 0 keys handled, got %d", len(h.KeysHandled())) + } + + if h.Description() != "" { + t.Errorf("Expected empty description, got %q", h.Description()) + } +} + +func TestTextInputHandler_Match(t *testing.T) { + h := handlers.NewTextInputHandler() + + tests := []struct { + name string + mode state.Mode + msg tea.KeyMsg + expected bool + }{ + { + name: "Matches in Input Mode", + mode: state.InputMode, + expected: true, + }, + { + name: "Ignores in Navigation Mode", + mode: state.NavigationMode, + expected: false, + }, + { + name: "Ignores in Help Mode", + mode: state.HelpMode, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := state.New(state.WithMode(tt.mode)) + + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")} + + if got := h.Match(s, msg); got != tt.expected { + t.Errorf("Match() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestTextInputHandler_Handle(t *testing.T) { + h := handlers.NewTextInputHandler() + + t.Run("Handle Escape (Exit Input Mode)", func(t *testing.T) { + s := state.New(state.WithMode(state.InputMode)) + + msg := tea.KeyMsg{Type: tea.KeyEsc} + cmd := h.Handle(s, msg) + + if s.Mode() != state.NavigationMode { + t.Errorf("Expected mode to switch to NavigationMode, got %v", s.Mode()) + } + + if cmd != nil { + t.Error("Expected nil command on escape") + } + }) + + t.Run("Handle Enter (Submit Input)", func(t *testing.T) { + s := state.New(state.WithMode(state.InputMode)) + + submitted := false + + s.StartInputMode("Test", func(val string, s state.State) tea.Cmd { + submitted = true + return nil + }) + + msg := tea.KeyMsg{Type: tea.KeyEnter} + + h.Handle(s, msg) + + if !submitted { + t.Error("Expected Enter to submit input and trigger callback") + } + + if s.Mode() != state.NavigationMode { + t.Error("Expected mode to reset to NavigationMode after submit") + } + }) + + t.Run("Handle Typing (Update Input)", func(t *testing.T) { + ti := textinput.New() + ti.Focus() + + s := state.New( + state.WithMode(state.InputMode), + state.WithInput(ti, "Test Input"), + ) + + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")} + cmd := h.Handle(s, msg) + + if s.Input().Value() != "a" { + t.Errorf("Expected input value 'a', got %q", s.Input().Value()) + } + + if cmd == nil { + t.Error("Expected command (blink) from text input update") + } + + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")} + h.Handle(s, msg) + + if s.Input().Value() != "ab" { + t.Errorf("Expected input value 'ab', got %q", s.Input().Value()) + } + }) +} diff --git a/internal/handlers/updateItemHandler.go b/internal/handlers/updateItemHandler.go new file mode 100644 index 0000000..4b176a4 --- /dev/null +++ b/internal/handlers/updateItemHandler.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "slices" + + "github.com/StevanFreeborn/term-tier/internal/state" + tea "github.com/charmbracelet/bubbletea" +) + +type updateItemHandler struct { + keys []string + description string +} + +func NewUpdateItemHandler() KeyMsgHandler { + return updateItemHandler{ + keys: []string{"r"}, + description: "Update item", + } +} + +func (h updateItemHandler) KeysHandled() []string { + return h.keys +} + +func (h updateItemHandler) Description() string { + return h.description +} + +func (h updateItemHandler) Match(s state.State, msg tea.KeyMsg) bool { + return s.Mode() == state.NavigationMode && slices.Contains(h.keys, msg.String()) +} + +func (h updateItemHandler) Handle(s state.State, msg tea.KeyMsg) tea.Cmd { + return s.UpdateItem() +} diff --git a/internal/handlers/updateItemHandler_test.go b/internal/handlers/updateItemHandler_test.go new file mode 100644 index 0000000..5bec339 --- /dev/null +++ b/internal/handlers/updateItemHandler_test.go @@ -0,0 +1,93 @@ +package handlers_test + +import ( + "slices" + "testing" + + "github.com/StevanFreeborn/term-tier/internal/core" + "github.com/StevanFreeborn/term-tier/internal/handlers" + "github.com/StevanFreeborn/term-tier/internal/state" + tea "github.com/charmbracelet/bubbletea" +) + +func TestNewUpdateItemHandler(t *testing.T) { + h := handlers.NewUpdateItemHandler() + + if h.Description() != "Update item" { + t.Errorf("Expected description 'Update item', got %q", h.Description()) + } + + keys := h.KeysHandled() + + if !slices.Contains(keys, "r") { + t.Errorf("Expected keys ['r'], got %v", keys) + } +} + +func TestUpdateItemHandler_Match(t *testing.T) { + h := handlers.NewUpdateItemHandler() + + tests := []struct { + name string + mode state.Mode + key string + expected bool + }{ + { + name: "Matches 'r' in Navigation Mode", + mode: state.NavigationMode, + key: "r", + expected: true, + }, + { + name: "Ignores 'r' in Input Mode", + mode: state.InputMode, + key: "r", + expected: false, + }, + { + name: "Ignores other keys", + mode: state.NavigationMode, + key: "a", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := state.New(state.WithMode(tt.mode)) + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)} + + if got := h.Match(s, msg); got != tt.expected { + t.Errorf("Match() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestUpdateItemHandler_Handle(t *testing.T) { + h := handlers.NewUpdateItemHandler() + + tiers := []core.Tier{{Name: "S", Items: []core.Item{"EditMe"}}} + + s := state.New( + state.WithTiers(tiers), + state.WithCursor(0, 0), + state.WithMode(state.NavigationMode), + ) + + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")} + cmd := h.Handle(s, msg) + + if s.Mode() != state.InputMode { + t.Errorf("Expected state to switch to InputMode, got %v", s.Mode()) + } + + if s.Input().Value() != "EditMe" { + t.Errorf("Expected input to be pre-filled with 'EditMe', got %q", s.Input().Value()) + } + + if cmd == nil { + t.Error("Expected Handle to return a command, got nil") + } +}