tiki/component/task_list_test.go
2026-04-10 16:07:34 -04:00

287 lines
7.1 KiB
Go

package component
import (
"strings"
"testing"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/task"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func makeTasks(ids ...string) []*task.Task {
tasks := make([]*task.Task, len(ids))
for i, id := range ids {
tasks[i] = &task.Task{
ID: id,
Title: "Task " + id,
Status: task.StatusBacklog,
}
}
return tasks
}
func TestNewTaskList(t *testing.T) {
tl := NewTaskList(5)
if tl == nil {
t.Fatal("NewTaskList returned nil")
return
}
if tl.maxVisibleRows != 5 {
t.Errorf("Expected maxVisibleRows=5, got %d", tl.maxVisibleRows)
}
if tl.selectionIndex != 0 {
t.Errorf("Expected initial selectionIndex=0, got %d", tl.selectionIndex)
}
colors := config.GetColors()
if tl.idGradient != colors.TaskBoxIDColor {
t.Error("Expected ID gradient from config")
}
if tl.idFallback != config.GetColors().FallbackTaskIDColor {
t.Error("Expected ID fallback from config")
}
if tl.titleColor != colors.TaskBoxTitleColor {
t.Error("Expected title color from config")
}
}
func TestSetTasks_RecomputesIDColumnWidth(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(makeTasks("AB", "ABCDE", "XY"))
if tl.idColumnWidth != 5 {
t.Errorf("Expected idColumnWidth=5, got %d", tl.idColumnWidth)
}
tl.SetTasks(makeTasks("A"))
if tl.idColumnWidth != 1 {
t.Errorf("Expected idColumnWidth=1, got %d", tl.idColumnWidth)
}
}
func TestSetTasks_EmptyList(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(nil)
if tl.idColumnWidth != 0 {
t.Errorf("Expected idColumnWidth=0, got %d", tl.idColumnWidth)
}
if tl.selectionIndex != 0 {
t.Errorf("Expected selectionIndex=0, got %d", tl.selectionIndex)
}
}
func TestSelection_ClampsBounds(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(makeTasks("A", "B", "C"))
tl.SetSelection(-5)
if tl.selectionIndex != 0 {
t.Errorf("Expected clamped to 0, got %d", tl.selectionIndex)
}
tl.SetSelection(100)
if tl.selectionIndex != 2 {
t.Errorf("Expected clamped to 2, got %d", tl.selectionIndex)
}
tl.SetSelection(1)
if tl.selectionIndex != 1 {
t.Errorf("Expected 1, got %d", tl.selectionIndex)
}
}
func TestGetSelectedTask(t *testing.T) {
tl := NewTaskList(10)
tasks := makeTasks("A", "B", "C")
tl.SetTasks(tasks)
tl.SetSelection(1)
selected := tl.GetSelectedTask()
if selected == nil || selected.ID != "B" {
t.Errorf("Expected task B, got %v", selected)
}
}
func TestGetSelectedTask_EmptyList(t *testing.T) {
tl := NewTaskList(10)
if tl.GetSelectedTask() != nil {
t.Error("Expected nil for empty task list")
}
}
func TestScrollDown(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(makeTasks("A", "B", "C"))
tl.ScrollDown()
if tl.selectionIndex != 1 {
t.Errorf("Expected 1 after ScrollDown, got %d", tl.selectionIndex)
}
tl.ScrollDown()
if tl.selectionIndex != 2 {
t.Errorf("Expected 2 after second ScrollDown, got %d", tl.selectionIndex)
}
// Should not go past last item
tl.ScrollDown()
if tl.selectionIndex != 2 {
t.Errorf("Expected 2 (clamped), got %d", tl.selectionIndex)
}
}
func TestScrollUp(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(makeTasks("A", "B", "C"))
tl.SetSelection(2)
tl.ScrollUp()
if tl.selectionIndex != 1 {
t.Errorf("Expected 1 after ScrollUp, got %d", tl.selectionIndex)
}
tl.ScrollUp()
if tl.selectionIndex != 0 {
t.Errorf("Expected 0 after second ScrollUp, got %d", tl.selectionIndex)
}
// Should not go below 0
tl.ScrollUp()
if tl.selectionIndex != 0 {
t.Errorf("Expected 0 (clamped), got %d", tl.selectionIndex)
}
}
func TestScrollDown_EmptyList(t *testing.T) {
tl := NewTaskList(10)
// Should not panic
tl.ScrollDown()
tl.ScrollUp()
}
func TestFewerItemsThanViewport(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(makeTasks("A", "B"))
// scrollOffset should stay at 0 since all items fit
if tl.scrollOffset != 0 {
t.Errorf("Expected scrollOffset=0, got %d", tl.scrollOffset)
}
tl.ScrollDown()
if tl.scrollOffset != 0 {
t.Errorf("Expected scrollOffset=0 with fewer items, got %d", tl.scrollOffset)
}
}
func TestSetIDColors(t *testing.T) {
tl := NewTaskList(10)
g := config.Gradient{Start: [3]int{255, 0, 0}, End: [3]int{0, 255, 0}}
fb := config.NewColor(tcell.ColorRed)
result := tl.SetIDColors(g, fb)
if result != tl {
t.Error("SetIDColors should return self for chaining")
}
if tl.idGradient != g {
t.Error("Expected custom gradient")
}
if tl.idFallback != fb {
t.Error("Expected custom fallback")
}
}
func TestSetTitleColor(t *testing.T) {
tl := NewTaskList(10)
c := config.NewColor(tcell.ColorRed)
result := tl.SetTitleColor(c)
if result != tl {
t.Error("SetTitleColor should return self for chaining")
}
if tl.titleColor != c {
t.Errorf("Expected color red, got %v", tl.titleColor)
}
}
func TestSetTasks_ClampsSelectionOnShrink(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(makeTasks("A", "B", "C", "D", "E"))
tl.SetSelection(4)
// Shrink list — selection should clamp
tl.SetTasks(makeTasks("A", "B"))
if tl.selectionIndex != 1 {
t.Errorf("Expected selectionIndex clamped to 1, got %d", tl.selectionIndex)
}
}
func TestGetSelectedIndex(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(makeTasks("A", "B", "C"))
tl.SetSelection(2)
if tl.GetSelectedIndex() != 2 {
t.Errorf("Expected 2, got %d", tl.GetSelectedIndex())
}
}
func TestBuildRow(t *testing.T) {
tl := NewTaskList(10)
pendingTask := &task.Task{ID: "TIKI-ABC001", Title: "My pending task", Status: task.StatusBacklog}
doneTask := &task.Task{ID: "TIKI-ABC002", Title: "My done task", Status: task.StatusDone}
// set tasks so idColumnWidth is computed
tl.SetTasks([]*task.Task{pendingTask, doneTask})
width := 80
t.Run("pending task shows circle", func(t *testing.T) {
row := tl.buildRow(pendingTask, false, width)
if !strings.Contains(row, "\u25CB") {
t.Error("pending task row should contain circle (○)")
}
})
t.Run("done task shows checkmark", func(t *testing.T) {
row := tl.buildRow(doneTask, false, width)
if !strings.Contains(row, "\u2713") {
t.Error("done task row should contain checkmark (✓)")
}
})
t.Run("contains task ID", func(t *testing.T) {
row := tl.buildRow(pendingTask, false, width)
if !strings.Contains(row, pendingTask.ID) {
t.Errorf("row should contain task ID %q", pendingTask.ID)
}
})
t.Run("contains title", func(t *testing.T) {
row := tl.buildRow(pendingTask, false, width)
escaped := tview.Escape(pendingTask.Title)
if !strings.Contains(row, escaped) {
t.Errorf("row should contain escaped title %q", escaped)
}
})
t.Run("selected row has selection color prefix", func(t *testing.T) {
row := tl.buildRow(pendingTask, true, width)
selTag := tl.selectionColor.Tag().WithBg(tl.selectionBgColor).String()
if !strings.HasPrefix(row, selTag) {
t.Errorf("selected row should start with selection color %q", selTag)
}
})
t.Run("unselected row has no selection prefix", func(t *testing.T) {
row := tl.buildRow(pendingTask, false, width)
selTag := tl.selectionColor.Tag().WithBg(tl.selectionBgColor).String()
if strings.HasPrefix(row, selTag) {
t.Error("unselected row should not start with selection color")
}
})
}