feat: implement item management handlers with corresponding tests

This commit is contained in:
Stevan Freeborn
2025-12-29 13:46:20 -06:00
parent 3d3877ca41
commit 62e6c0e680
10 changed files with 734 additions and 0 deletions
+36
View File
@@ -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()
}
+87
View File
@@ -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")
}
}
+36
View File
@@ -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()
}
+100
View File
@@ -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())
}
}
+37
View File
@@ -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
}
+136
View File
@@ -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")
}
})
}
+42
View File
@@ -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)
}
}
+131
View File
@@ -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())
}
})
}
+36
View File
@@ -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()
}
@@ -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")
}
}