diff --git a/component/barchart/braille_test.go b/component/barchart/braille_test.go new file mode 100644 index 0000000..2c69758 --- /dev/null +++ b/component/barchart/braille_test.go @@ -0,0 +1,235 @@ +package barchart + +import ( + "testing" +) + +func TestValueToBrailleHeightEdgeCases(t *testing.T) { + tests := []struct { + name string + value float64 + maxValue float64 + chartHeight int + want int + }{ + {"zero chartHeight", 50, 100, 0, 0}, + {"zero maxValue", 50, 0, 10, 0}, + {"zero value", 0, 100, 10, 0}, + {"small value clamps to 1", 0.1, 100, 10, 1}, + {"full bar value equals maxValue", 100, 100, 10, 40}, + {"value exceeds maxValue clamps", 200, 100, 10, 40}, + {"half value", 50, 100, 10, 20}, + {"negative value treated as zero", -5, 100, 10, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := valueToBrailleHeight(tt.value, tt.maxValue, tt.chartHeight) + if got != tt.want { + t.Errorf("valueToBrailleHeight(%v, %v, %v) = %v, want %v", + tt.value, tt.maxValue, tt.chartHeight, got, tt.want) + } + }) + } +} + +func TestBrailleUnitsForRowEdgeCases(t *testing.T) { + tests := []struct { + name string + totalUnits int + row int + want int + }{ + {"zero totalUnits", 0, 0, 0}, + {"negative totalUnits", -1, 0, 0}, + {"row above filled area", 4, 1, 0}, + {"partial last row", 5, 1, 1}, + {"full row capped at 4", 8, 0, 4}, + {"first row full", 4, 0, 4}, + {"row 0 partial", 2, 0, 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := brailleUnitsForRow(tt.totalUnits, tt.row) + if got != tt.want { + t.Errorf("brailleUnitsForRow(%d, %d) = %d, want %d", + tt.totalUnits, tt.row, got, tt.want) + } + }) + } +} + +func TestBrailleColumnMaskLevels(t *testing.T) { + tests := []struct { + name string + level int + rightColumn bool + want uint8 + }{ + {"zero level left", 0, false, 0}, + {"zero level right", 0, true, 0}, + // left column dots: 0x40 (dot 7), 0x04 (dot 3), 0x02 (dot 2), 0x01 (dot 1) — bottom to top + {"left level 2", 2, false, 0x40 | 0x04}, + {"left level 3", 3, false, 0x40 | 0x04 | 0x02}, + {"left level 4 all dots", 4, false, 0x40 | 0x04 | 0x02 | 0x01}, + {"left level 5 clamps to 4", 5, false, 0x40 | 0x04 | 0x02 | 0x01}, + // right column dots: 0x80 (dot 8), 0x20 (dot 6), 0x10 (dot 5), 0x08 (dot 4) — bottom to top + {"right level 2", 2, true, 0x80 | 0x20}, + {"right level 4 all dots", 4, true, 0x80 | 0x20 | 0x10 | 0x08}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := brailleColumnMask(tt.level, tt.rightColumn) + if got != tt.want { + t.Errorf("brailleColumnMask(%d, %v) = 0x%02X, want 0x%02X", + tt.level, tt.rightColumn, got, tt.want) + } + }) + } +} + +func TestBrailleRuneForCountsValues(t *testing.T) { + tests := []struct { + name string + leftCount int + rightCount int + want rune + }{ + {"both zero empty braille", 0, 0, 0x2800}, + {"both full all dots", 4, 4, 0x28FF}, + // left=1 → mask 0x40; right=0 → mask 0x00; rune = 0x2840 + {"left 1 right 0", 1, 0, 0x2840}, + // left=0 → 0x00; right=1 → 0x80; rune = 0x2880 + {"left 0 right 1", 0, 1, 0x2880}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := brailleRuneForCounts(tt.leftCount, tt.rightCount) + if got != tt.want { + t.Errorf("brailleRuneForCounts(%d, %d) = U+%04X, want U+%04X", + tt.leftCount, tt.rightCount, got, tt.want) + } + }) + } +} + +func TestClampRowIndex(t *testing.T) { + tests := []struct { + name string + rowIndex int + total int + want int + }{ + {"zero total", 5, 0, 0}, + {"negative total", 5, -1, 0}, + {"negative index clamps to 0", -3, 10, 0}, + {"index equals total clamps to total-1", 10, 10, 9}, + {"index exceeds total clamps", 15, 10, 9}, + {"valid index unchanged", 5, 10, 5}, + {"zero index valid", 0, 10, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := clampRowIndex(tt.rowIndex, tt.total) + if got != tt.want { + t.Errorf("clampRowIndex(%d, %d) = %d, want %d", + tt.rowIndex, tt.total, got, tt.want) + } + }) + } +} + +func TestDominantBarForCell(t *testing.T) { + // heights slice used in tests: index 0 = left, index 1 = right + heights := []int{8, 12} + + tests := []struct { + name string + leftIndex int + rightIndex int + leftUnits int + rightUnits int + leftCount int + rightCount int + row int + heights []int + wantIndex int // -1 means no bar + }{ + { + name: "both counts zero", + leftIndex: 0, rightIndex: 1, + leftUnits: 8, rightUnits: 12, + leftCount: 0, rightCount: 0, + row: 0, heights: heights, + wantIndex: -1, + }, + { + name: "right count greater than left", + leftIndex: 0, rightIndex: 1, + leftUnits: 8, rightUnits: 12, + leftCount: 2, rightCount: 4, + row: 0, heights: heights, + wantIndex: 1, + }, + { + name: "left count greater than right", + leftIndex: 0, rightIndex: 1, + leftUnits: 8, rightUnits: 12, + leftCount: 4, rightCount: 2, + row: 0, heights: heights, + wantIndex: 0, + }, + { + name: "tie right units greater favors right", + leftIndex: 0, rightIndex: 1, + leftUnits: 4, rightUnits: 8, + leftCount: 3, rightCount: 3, + row: 0, heights: heights, + wantIndex: 1, + }, + { + name: "tie left units greater or equal favors left", + leftIndex: 0, rightIndex: 1, + leftUnits: 8, rightUnits: 4, + leftCount: 3, rightCount: 3, + row: 0, heights: heights, + wantIndex: 0, + }, + { + // rightCount > leftCount but rightIndex OOB → falls through default → + // rightUnits > leftUnits but rightIndex still OOB → returns leftIndex + name: "right index out of bounds falls through to left", + leftIndex: 0, rightIndex: 99, + leftUnits: 4, rightUnits: 8, + leftCount: 2, rightCount: 4, + row: 0, heights: heights, + wantIndex: 0, + }, + { + name: "left index out of bounds right wins", + leftIndex: 99, rightIndex: 1, + leftUnits: 4, rightUnits: 8, + leftCount: 2, rightCount: 4, + row: 0, heights: heights, + wantIndex: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotIndex, _, _ := dominantBarForCell( + tt.leftIndex, tt.rightIndex, + tt.leftUnits, tt.rightUnits, + tt.leftCount, tt.rightCount, + tt.row, tt.heights, + ) + if gotIndex != tt.wantIndex { + t.Errorf("dominantBarForCell() barIndex = %d, want %d", gotIndex, tt.wantIndex) + } + }) + } +} diff --git a/component/barchart/util_test.go b/component/barchart/util_test.go new file mode 100644 index 0000000..fefcd02 --- /dev/null +++ b/component/barchart/util_test.go @@ -0,0 +1,169 @@ +package barchart + +import ( + "testing" +) + +func TestValueToHeightEdgeCases(t *testing.T) { + tests := []struct { + name string + value float64 + maxValue float64 + chartHeight int + want int + }{ + {"zero chartHeight", 50, 100, 0, 0}, + {"negative chartHeight", 50, 100, -1, 0}, + {"zero maxValue", 50, 0, 10, 0}, + {"negative maxValue", 50, -1, 10, 0}, + {"zero value", 0, 100, 10, 0}, + {"small value rounds to 0 clamps to 1", 0.4, 100, 10, 1}, + {"value equals maxValue", 100, 100, 10, 10}, + {"value exceeds maxValue clamps", 200, 100, 10, 10}, + {"mid-range", 50, 100, 10, 5}, + {"negative value treated as zero", -5, 100, 10, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := valueToHeight(tt.value, tt.maxValue, tt.chartHeight) + if got != tt.want { + t.Errorf("valueToHeight(%v, %v, %v) = %v, want %v", + tt.value, tt.maxValue, tt.chartHeight, got, tt.want) + } + }) + } +} + +func TestComputeBarLayoutEdgeCases(t *testing.T) { + tests := []struct { + name string + totalWidth int + barCount int + desiredBarWidth int + desiredGap int + wantBarWidth int + wantGapWidth int + wantContentWidth int + }{ + {"zero totalWidth", 0, 5, 2, 1, 0, 0, 0}, + {"negative totalWidth", -1, 5, 2, 1, 0, 0, 0}, + {"zero barCount", 80, 0, 2, 1, 0, 0, 0}, + {"content fits exactly", 80, 5, 2, 1, 2, 1, 14}, + {"single bar no gap needed", 80, 1, 2, 1, 2, 1, 2}, + {"negative desiredGap clamped to 0", 80, 5, 2, -3, 2, 0, 10}, + {"barWidth below 1 clamps to 1", 80, 5, 0, 1, 1, 1, 9}, + {"bars too wide shrinks barWidth", 10, 5, 10, 0, 2, 0, 10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotBar, gotGap, gotContent := computeBarLayout(tt.totalWidth, tt.barCount, tt.desiredBarWidth, tt.desiredGap) + if gotBar != tt.wantBarWidth || gotGap != tt.wantGapWidth || gotContent != tt.wantContentWidth { + t.Errorf("computeBarLayout(%d, %d, %d, %d) = (%d, %d, %d), want (%d, %d, %d)", + tt.totalWidth, tt.barCount, tt.desiredBarWidth, tt.desiredGap, + gotBar, gotGap, gotContent, + tt.wantBarWidth, tt.wantGapWidth, tt.wantContentWidth) + } + }) + } +} + +func TestComputeMaxVisibleBarsEdgeCases(t *testing.T) { + tests := []struct { + name string + totalWidth int + barWidth int + gapWidth int + want int + }{ + {"zero totalWidth", 0, 3, 1, 0}, + {"negative totalWidth", -1, 3, 1, 0}, + {"zero barWidth", 80, 0, 1, 0}, + {"negative barWidth", 80, -1, 1, 0}, + {"normal case", 80, 3, 1, 20}, + {"barWidth larger than totalWidth returns 1", 5, 10, 1, 1}, + {"zero gap", 12, 3, 0, 4}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeMaxVisibleBars(tt.totalWidth, tt.barWidth, tt.gapWidth) + if got != tt.want { + t.Errorf("computeMaxVisibleBars(%d, %d, %d) = %d, want %d", + tt.totalWidth, tt.barWidth, tt.gapWidth, got, tt.want) + } + }) + } +} + +func TestMaxBarValue(t *testing.T) { + tests := []struct { + name string + bars []Bar + want float64 + }{ + {"empty slice", []Bar{}, 0.0}, + {"all zeros", []Bar{{Value: 0}, {Value: 0}}, 0.0}, + {"single bar", []Bar{{Value: 42}}, 42.0}, + {"multiple bars", []Bar{{Value: 10}, {Value: 30}, {Value: 20}}, 30.0}, + {"negative values ignored", []Bar{{Value: -5}, {Value: -1}}, 0.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := maxBarValue(tt.bars) + if got != tt.want { + t.Errorf("maxBarValue() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTruncateRunes(t *testing.T) { + tests := []struct { + name string + text string + width int + want string + }{ + {"zero width", "hello", 0, ""}, + {"negative width", "hello", -1, ""}, + {"shorter than width unchanged", "hi", 10, "hi"}, + {"exact width unchanged", "hello", 5, "hello"}, + {"truncated at boundary", "hello", 3, "hel"}, + {"multi-byte rune boundary", "héllo", 3, "hél"}, + {"empty string", "", 5, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateRunes(tt.text, tt.width) + if got != tt.want { + t.Errorf("truncateRunes(%q, %d) = %q, want %q", tt.text, tt.width, got, tt.want) + } + }) + } +} + +func TestHasLabels(t *testing.T) { + tests := []struct { + name string + bars []Bar + want bool + }{ + {"empty slice", []Bar{}, false}, + {"all empty labels", []Bar{{Label: ""}, {Label: ""}}, false}, + {"one non-empty label", []Bar{{Label: ""}, {Label: "X"}}, true}, + {"all non-empty labels", []Bar{{Label: "A"}, {Label: "B"}}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := hasLabels(tt.bars) + if got != tt.want { + t.Errorf("hasLabels() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/integration/deps_editor_test.go b/integration/deps_editor_test.go new file mode 100644 index 0000000..0803904 --- /dev/null +++ b/integration/deps_editor_test.go @@ -0,0 +1,292 @@ +package integration + +import ( + "slices" + "testing" + + "github.com/boolean-maybe/tiki/model" + taskpkg "github.com/boolean-maybe/tiki/task" + "github.com/boolean-maybe/tiki/testutil" + + "github.com/gdamore/tcell/v2" +) + +// openDepsEditor navigates: Kanban → Enter (task detail) → Ctrl+D (deps editor) +// The task selected on the Kanban board becomes the context task. +func openDepsEditor(ta *testutil.TestApp) { + ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil) + ta.Draw() + ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone) + ta.Draw() + ta.SendKey(tcell.KeyCtrlD, 0, tcell.ModCtrl) + ta.Draw() +} + +// TestDepsEditor_OpenFromTaskDetail verifies Ctrl+D on task detail pushes the deps plugin view. +func TestDepsEditor_OpenFromTaskDetail(t *testing.T) { + ta := testutil.NewTestApp(t) + defer ta.Cleanup() + + contextID := "TIKI-CTXA01" + if err := testutil.CreateTestTask(ta.TaskDir, contextID, "Context Task", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { + t.Fatalf("create context task: %v", err) + } + if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-FREE01", "Free Task", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { + t.Fatalf("create free task: %v", err) + } + if err := ta.TaskStore.Reload(); err != nil { + t.Fatalf("reload: %v", err) + } + + openDepsEditor(ta) + + wantViewID := model.MakePluginViewID("deps:" + contextID) + current := ta.NavController.CurrentView() + if current.ViewID != wantViewID { + ta.DumpScreen() + t.Fatalf("current view = %v, want %v", current.ViewID, wantViewID) + } + + for _, label := range []string{"Blocks", "All", "Depends"} { + if found, _, _ := ta.FindText(label); !found { + ta.DumpScreen() + t.Errorf("lane label %q not found on screen", label) + } + } +} + +// TestDepsEditor_LanesShowCorrectTasks verifies each lane contains the expected tasks. +func TestDepsEditor_LanesShowCorrectTasks(t *testing.T) { + ta := testutil.NewTestApp(t) + defer ta.Cleanup() + + contextID := "TIKI-CTXA02" + depID := "TIKI-DEP002" + blockerID := "TIKI-BLK002" + freeID := "TIKI-FRE002" + + // context depends on dep; blocker depends on context + if err := testutil.CreateTestTaskWithDeps(ta.TaskDir, contextID, "Context Task", taskpkg.StatusReady, taskpkg.TypeStory, []string{depID}); err != nil { + t.Fatalf("create context: %v", err) + } + if err := testutil.CreateTestTask(ta.TaskDir, depID, "Dep Task", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { + t.Fatalf("create dep: %v", err) + } + if err := testutil.CreateTestTaskWithDeps(ta.TaskDir, blockerID, "Blocker Task", taskpkg.StatusReady, taskpkg.TypeStory, []string{contextID}); err != nil { + t.Fatalf("create blocker: %v", err) + } + if err := testutil.CreateTestTask(ta.TaskDir, freeID, "Free Task", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { + t.Fatalf("create free: %v", err) + } + if err := ta.TaskStore.Reload(); err != nil { + t.Fatalf("reload: %v", err) + } + + // navigate to context task then open deps editor + ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil) + ta.Draw() + + // context task should be first in the Ready lane — press Enter to open detail + ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone) + ta.Draw() + + // confirm we're on the right task detail + if found, _, _ := ta.FindText(contextID); !found { + ta.DumpScreen() + t.Fatalf("context task %s not selected on board", contextID) + } + + ta.SendKey(tcell.KeyCtrlD, 0, tcell.ModCtrl) + ta.Draw() + + // Blocker task belongs in Blocks lane (it depends on context) + if found, _, _ := ta.FindText("Blocker Task"); !found { + ta.DumpScreen() + t.Errorf("Blocker Task not visible (expected in Blocks lane)") + } + + // Dep task belongs in Depends lane (context depends on it) + if found, _, _ := ta.FindText("Dep Task"); !found { + ta.DumpScreen() + t.Errorf("Dep Task not visible (expected in Depends lane)") + } + + // Free task belongs in All lane + if found, _, _ := ta.FindText("Free Task"); !found { + ta.DumpScreen() + t.Errorf("Free Task not visible (expected in All lane)") + } + + // Context task must not appear anywhere in the deps view + if found, _, _ := ta.FindText("Context Task"); found { + ta.DumpScreen() + t.Errorf("Context Task should not be visible in deps editor") + } +} + +// TestDepsEditor_MoveTask_AllToDepends_PersistsOnDisk verifies moving a task from All → Depends +// updates DependsOn in memory and persists on disk after reload. +func TestDepsEditor_MoveTask_AllToDepends_PersistsOnDisk(t *testing.T) { + ta := testutil.NewTestApp(t) + defer ta.Cleanup() + + contextID := "TIKI-CTXA03" + freeID := "TIKI-FRE003" + + if err := testutil.CreateTestTask(ta.TaskDir, contextID, "Context Task", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { + t.Fatalf("create context: %v", err) + } + if err := testutil.CreateTestTask(ta.TaskDir, freeID, "Free Task", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { + t.Fatalf("create free: %v", err) + } + if err := ta.TaskStore.Reload(); err != nil { + t.Fatalf("reload: %v", err) + } + + openDepsEditor(ta) + + // verify we're in the deps editor + wantViewID := model.MakePluginViewID("deps:" + contextID) + if ta.NavController.CurrentView().ViewID != wantViewID { + ta.DumpScreen() + t.Fatalf("not in deps editor, got %v", ta.NavController.CurrentView().ViewID) + } + + // Blocks lane is empty, so selection should land on All lane automatically. + // Shift+Right moves selected task from All → Depends. + ta.SendKey(tcell.KeyRight, 0, tcell.ModShift) + ta.Draw() + + // verify in-memory state + updated := ta.TaskStore.GetTask(contextID) + if updated == nil { + t.Fatalf("context task not found in store") + } + if !slices.Contains(updated.DependsOn, freeID) { + t.Errorf("DependsOn = %v, want it to contain %s", updated.DependsOn, freeID) + } + + // verify persisted to disk + if err := ta.TaskStore.Reload(); err != nil { + t.Fatalf("reload after move: %v", err) + } + reloaded := ta.TaskStore.GetTask(contextID) + if reloaded == nil { + t.Fatalf("context task not found after reload") + } + if !slices.Contains(reloaded.DependsOn, freeID) { + t.Errorf("after reload: DependsOn = %v, want it to contain %s", reloaded.DependsOn, freeID) + } +} + +// TestDepsEditor_MoveTask_DependsToAll_RemovesDep verifies moving a task from Depends → All +// removes it from DependsOn in memory and on disk. +func TestDepsEditor_MoveTask_DependsToAll_RemovesDep(t *testing.T) { + ta := testutil.NewTestApp(t) + defer ta.Cleanup() + + contextID := "TIKI-CTXA04" + depID := "TIKI-DEP004" + freeID := "TIKI-FRE004" + + if err := testutil.CreateTestTaskWithDeps(ta.TaskDir, contextID, "Context Task", taskpkg.StatusReady, taskpkg.TypeStory, []string{depID}); err != nil { + t.Fatalf("create context: %v", err) + } + if err := testutil.CreateTestTask(ta.TaskDir, depID, "Dep Task", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { + t.Fatalf("create dep: %v", err) + } + // a free task is needed so All lane is non-empty — handleLaneSwitch skips empty lanes, + // so without it Shift+H from Depends has nowhere to land and becomes a no-op. + if err := testutil.CreateTestTask(ta.TaskDir, freeID, "Free Task", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { + t.Fatalf("create free: %v", err) + } + if err := ta.TaskStore.Reload(); err != nil { + t.Fatalf("reload: %v", err) + } + + openDepsEditor(ta) + + wantViewID := model.MakePluginViewID("deps:" + contextID) + if ta.NavController.CurrentView().ViewID != wantViewID { + ta.DumpScreen() + t.Fatalf("not in deps editor, got %v", ta.NavController.CurrentView().ViewID) + } + + // EnsureFirstNonEmptyLaneSelection picks the first non-empty lane: Blocks is empty, + // All has the free task, so selection starts on All (lane 1). + // Navigate right once to reach Depends lane. + ta.SendKey(tcell.KeyRight, 0, tcell.ModNone) + ta.Draw() + + // Shift+Left moves selected task from Depends → All + ta.SendKey(tcell.KeyLeft, 0, tcell.ModShift) + ta.Draw() + + // verify in-memory state + updated := ta.TaskStore.GetTask(contextID) + if updated == nil { + t.Fatalf("context task not found in store") + } + if slices.Contains(updated.DependsOn, depID) { + t.Errorf("DependsOn = %v, should not contain %s after removal", updated.DependsOn, depID) + } + + // verify persisted to disk + if err := ta.TaskStore.Reload(); err != nil { + t.Fatalf("reload after move: %v", err) + } + reloaded := ta.TaskStore.GetTask(contextID) + if reloaded == nil { + t.Fatalf("context task not found after reload") + } + if slices.Contains(reloaded.DependsOn, depID) { + t.Errorf("after reload: DependsOn = %v, should not contain %s", reloaded.DependsOn, depID) + } +} + +// TestDepsEditor_ReopenIsSameView verifies that opening the deps editor for the same task +// a second time reuses the existing plugin entry (idempotency). +func TestDepsEditor_ReopenIsSameView(t *testing.T) { + ta := testutil.NewTestApp(t) + defer ta.Cleanup() + + contextID := "TIKI-CTXA05" + if err := testutil.CreateTestTask(ta.TaskDir, contextID, "Context Task", taskpkg.StatusReady, taskpkg.TypeStory); err != nil { + t.Fatalf("create context: %v", err) + } + if err := ta.TaskStore.Reload(); err != nil { + t.Fatalf("reload: %v", err) + } + + wantViewID := model.MakePluginViewID("deps:" + contextID) + + // first open + openDepsEditor(ta) + if ta.NavController.CurrentView().ViewID != wantViewID { + ta.DumpScreen() + t.Fatalf("first open: not in deps editor, got %v", ta.NavController.CurrentView().ViewID) + } + + // go back to task detail + ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) + ta.Draw() + if ta.NavController.CurrentView().ViewID != model.TaskDetailViewID { + ta.DumpScreen() + t.Fatalf("expected task detail after Esc, got %v", ta.NavController.CurrentView().ViewID) + } + + // second open — should reuse existing plugin, not create a duplicate + ta.SendKey(tcell.KeyCtrlD, 0, tcell.ModCtrl) + ta.Draw() + + if ta.NavController.CurrentView().ViewID != wantViewID { + ta.DumpScreen() + t.Fatalf("second open: not in deps editor, got %v", ta.NavController.CurrentView().ViewID) + } + + // verify the deps view still renders correctly (plugin wiring intact) + if found, _, _ := ta.FindText("All"); !found { + ta.DumpScreen() + t.Errorf("lane label 'All' not found on second open") + } +} diff --git a/internal/viewer/input_extra_test.go b/internal/viewer/input_extra_test.go new file mode 100644 index 0000000..eb6c75d --- /dev/null +++ b/internal/viewer/input_extra_test.go @@ -0,0 +1,360 @@ +package viewer + +import ( + "errors" + "testing" +) + +// --- GitHub URL variants --- + +func TestParseViewerInputGitHubBlobURL(t *testing.T) { + spec, ok, err := ParseViewerInput( + []string{"github.com/owner/repo/blob/main/docs/README.md"}, + map[string]struct{}{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected viewer mode") + } + if spec.Kind != InputGitHub { + t.Fatalf("expected github input, got %s", spec.Kind) + } + want := "https://raw.githubusercontent.com/owner/repo/main/docs/README.md" + if len(spec.Candidates) != 1 || spec.Candidates[0] != want { + t.Fatalf("unexpected candidates: %v", spec.Candidates) + } +} + +func TestParseViewerInputGitHubTreeURL(t *testing.T) { + spec, ok, err := ParseViewerInput( + []string{"github.com/owner/repo/tree/v1.2.3"}, + map[string]struct{}{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected viewer mode") + } + // tree with no file path defaults to README.md + want := "https://raw.githubusercontent.com/owner/repo/v1.2.3/README.md" + if len(spec.Candidates) != 1 || spec.Candidates[0] != want { + t.Fatalf("unexpected candidates: %v", spec.Candidates) + } +} + +func TestParseViewerInputGitHubHTTPS(t *testing.T) { + spec, ok, err := ParseViewerInput( + []string{"https://github.com/owner/repo"}, + map[string]struct{}{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected viewer mode") + } + // no explicit ref → two fallback candidates + if len(spec.Candidates) != 2 { + t.Fatalf("expected 2 candidates, got %d: %v", len(spec.Candidates), spec.Candidates) + } +} + +func TestParseViewerInputGitHubDotGitStripped(t *testing.T) { + spec, ok, err := ParseViewerInput( + []string{"github.com/owner/repo.git"}, + map[string]struct{}{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected viewer mode") + } + for _, c := range spec.Candidates { + if len(c) > 0 && c[len(c)-4:] == ".git" { + t.Fatalf(".git suffix not stripped from candidate: %s", c) + } + } + // candidates should reference "repo" not "repo.git" + want0 := "https://raw.githubusercontent.com/owner/repo/main/README.md" + if len(spec.Candidates) < 1 || spec.Candidates[0] != want0 { + t.Fatalf("unexpected first candidate: %v", spec.Candidates) + } +} + +func TestParseViewerInputGitHubSingleSegmentFallsThrough(t *testing.T) { + // only one path segment — not a valid github repo URL + spec, ok, err := ParseViewerInput( + []string{"github.com/owner"}, + map[string]struct{}{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected viewer mode (treated as file path)") + } + if spec.Kind != InputFile { + t.Fatalf("single-segment github path should fall through to file, got %s", spec.Kind) + } +} + +// --- GitLab URL variants --- + +func TestParseViewerInputGitLabBasic(t *testing.T) { + spec, ok, err := ParseViewerInput( + []string{"gitlab.com/group/repo"}, + map[string]struct{}{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected viewer mode") + } + if spec.Kind != InputGitLab { + t.Fatalf("expected gitlab input, got %s", spec.Kind) + } + if len(spec.Candidates) != 2 { + t.Fatalf("expected 2 fallback candidates, got %d", len(spec.Candidates)) + } +} + +func TestParseViewerInputGitLabBlobURL(t *testing.T) { + spec, ok, err := ParseViewerInput( + []string{"gitlab.com/group/repo/-/blob/main/README.md"}, + map[string]struct{}{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected viewer mode") + } + want := "https://gitlab.com/group/repo/-/raw/main/README.md" + if len(spec.Candidates) != 1 || spec.Candidates[0] != want { + t.Fatalf("unexpected candidates: %v", spec.Candidates) + } +} + +func TestParseViewerInputGitLabRawWithSubgroup(t *testing.T) { + spec, ok, err := ParseViewerInput( + []string{"gitlab.com/group/subgroup/repo/-/raw/v2/docs/guide.md"}, + map[string]struct{}{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected viewer mode") + } + want := "https://gitlab.com/group/subgroup/repo/-/raw/v2/docs/guide.md" + if len(spec.Candidates) != 1 || spec.Candidates[0] != want { + t.Fatalf("unexpected candidates: %v", spec.Candidates) + } +} + +func TestParseViewerInputGitLabHTTPS(t *testing.T) { + spec, ok, err := ParseViewerInput( + []string{"https://gitlab.com/owner/repo"}, + map[string]struct{}{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected viewer mode") + } + if spec.Kind != InputGitLab { + t.Fatalf("expected gitlab input, got %s", spec.Kind) + } +} + +func TestParseViewerInputGitLabSingleSegmentFallsThrough(t *testing.T) { + spec, ok, err := ParseViewerInput( + []string{"gitlab.com/singlegroup"}, + map[string]struct{}{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected viewer mode (file path fallthrough)") + } + if spec.Kind != InputFile { + t.Fatalf("single-segment gitlab path should fall through to file, got %s", spec.Kind) + } +} + +// --- collectPositionalArgs edge cases --- + +func TestCollectPositionalArgsDoubleDashTerminator(t *testing.T) { + got, err := collectPositionalArgs([]string{"--", "file.md"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 || got[0] != "file.md" { + t.Fatalf("expected [file.md], got %v", got) + } +} + +func TestCollectPositionalArgsLogLevelInlineForm(t *testing.T) { + got, err := collectPositionalArgs([]string{"--log-level=debug", "file.md"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 || got[0] != "file.md" { + t.Fatalf("expected [file.md], got %v", got) + } +} + +func TestCollectPositionalArgsVersionFlag(t *testing.T) { + for _, flag := range []string{"-v", "--version"} { + got, err := collectPositionalArgs([]string{flag, "file.md"}) + if err != nil { + t.Fatalf("%s: unexpected error: %v", flag, err) + } + if len(got) != 1 || got[0] != "file.md" { + t.Fatalf("%s: expected [file.md], got %v", flag, got) + } + } +} + +func TestCollectPositionalArgsLogLevelInvalidValueNotSkipped(t *testing.T) { + // --log-level followed by non-log-level value — next arg should NOT be skipped + got, err := collectPositionalArgs([]string{"--log-level", "notavalidlevel", "file.md"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // "notavalidlevel" is not a valid log level so it is treated as a positional arg + if len(got) != 2 { + t.Fatalf("expected 2 positional args, got %v", got) + } +} + +// --- internal helpers --- + +func TestSplitPath(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"", nil}, + {"/a/b/", []string{"a", "b"}}, + {"a/b/c", []string{"a", "b", "c"}}, + {"/", nil}, + } + for _, tt := range tests { + got := splitPath(tt.input) + if len(got) != len(tt.want) { + t.Errorf("splitPath(%q) = %v, want %v", tt.input, got, tt.want) + continue + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("splitPath(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + } +} + +func TestTrimGitSuffix(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"repo.git", "repo"}, + {"repo", "repo"}, + {"my.repo.git", "my.repo"}, + {"", ""}, + } + for _, tt := range tests { + got := trimGitSuffix(tt.input) + if got != tt.want { + t.Errorf("trimGitSuffix(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestParseRefAndPath(t *testing.T) { + tests := []struct { + name string + segments []string + wantRef string + wantPath string + }{ + {"empty segments", []string{}, "", ""}, + {"blob with ref and path", []string{"blob", "main", "a/b.md"}, "main", "a/b.md"}, + {"tree with ref only", []string{"tree", "v1"}, "v1", ""}, + {"non-blob-tree segments joined as path", []string{"a", "b"}, "", "a/b"}, + {"single non-keyword segment", []string{"docs"}, "", "docs"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRef, gotPath := parseRefAndPath(tt.segments) + if gotRef != tt.wantRef || gotPath != tt.wantPath { + t.Errorf("parseRefAndPath(%v) = (%q, %q), want (%q, %q)", + tt.segments, gotRef, gotPath, tt.wantRef, tt.wantPath) + } + }) + } +} + +func TestParseGitLabSegments(t *testing.T) { + tests := []struct { + name string + segments []string + wantRepoIndex int + wantRef string + wantPath string + }{ + { + name: "no dash returns last index as repo", + segments: []string{"group", "repo"}, + wantRepoIndex: 1, + wantRef: "", + wantPath: "", + }, + { + name: "dash at index 0 invalid", + segments: []string{"-", "blob", "main"}, + wantRepoIndex: -1, + wantRef: "", + wantPath: "", + }, + { + name: "standard blob layout", + segments: []string{"group", "repo", "-", "blob", "main", "README.md"}, + wantRepoIndex: 1, + wantRef: "main", + wantPath: "README.md", + }, + { + name: "subgroup namespace", + segments: []string{"group", "subgroup", "repo", "-", "raw", "v2", "docs/guide.md"}, + wantRepoIndex: 2, + wantRef: "v2", + wantPath: "docs/guide.md", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotIndex, gotRef, gotPath := parseGitLabSegments(tt.segments) + if gotIndex != tt.wantRepoIndex || gotRef != tt.wantRef || gotPath != tt.wantPath { + t.Errorf("parseGitLabSegments(%v) = (%d, %q, %q), want (%d, %q, %q)", + tt.segments, gotIndex, gotRef, gotPath, + tt.wantRepoIndex, tt.wantRef, tt.wantPath) + } + }) + } +} + +func TestCollectPositionalArgsUnknownFlag(t *testing.T) { + _, err := collectPositionalArgs([]string{"--unknown-flag"}) + if !errors.Is(err, ErrUnknownFlag) { + t.Fatalf("expected ErrUnknownFlag, got %v", err) + } +} diff --git a/plugin/sort_test.go b/plugin/sort_test.go new file mode 100644 index 0000000..d847120 --- /dev/null +++ b/plugin/sort_test.go @@ -0,0 +1,171 @@ +package plugin + +import ( + "testing" + "time" + + "github.com/boolean-maybe/tiki/task" +) + +func TestSortTasks_NoRules(t *testing.T) { + tasks := []*task.Task{ + {ID: "TIKI-C", Priority: 3}, + {ID: "TIKI-A", Priority: 1}, + {ID: "TIKI-B", Priority: 2}, + } + SortTasks(tasks, nil) + // original order preserved + if tasks[0].ID != "TIKI-C" || tasks[1].ID != "TIKI-A" || tasks[2].ID != "TIKI-B" { + t.Errorf("expected original order preserved, got %v %v %v", tasks[0].ID, tasks[1].ID, tasks[2].ID) + } +} + +func TestSortTasks_ByField(t *testing.T) { + now := time.Now() + earlier := now.Add(-time.Hour) + later := now.Add(time.Hour) + + tests := []struct { + name string + tasks []*task.Task + rules []SortRule + expectedID []string + }{ + { + name: "priority ASC", + tasks: []*task.Task{ + {ID: "TIKI-C", Priority: 3}, + {ID: "TIKI-A", Priority: 1}, + {ID: "TIKI-B", Priority: 2}, + }, + rules: []SortRule{{Field: "priority", Descending: false}}, + expectedID: []string{"TIKI-A", "TIKI-B", "TIKI-C"}, + }, + { + name: "priority DESC", + tasks: []*task.Task{ + {ID: "TIKI-A", Priority: 1}, + {ID: "TIKI-B", Priority: 2}, + {ID: "TIKI-C", Priority: 3}, + }, + rules: []SortRule{{Field: "priority", Descending: true}}, + expectedID: []string{"TIKI-C", "TIKI-B", "TIKI-A"}, + }, + { + name: "title ASC case-insensitive", + tasks: []*task.Task{ + {ID: "TIKI-Z", Title: "Zebra"}, + {ID: "TIKI-A", Title: "apple"}, + {ID: "TIKI-M", Title: "Mango"}, + }, + rules: []SortRule{{Field: "title", Descending: false}}, + expectedID: []string{"TIKI-A", "TIKI-M", "TIKI-Z"}, + }, + { + name: "points ASC", + tasks: []*task.Task{ + {ID: "TIKI-H", Points: 8}, + {ID: "TIKI-L", Points: 1}, + {ID: "TIKI-M", Points: 5}, + }, + rules: []SortRule{{Field: "points", Descending: false}}, + expectedID: []string{"TIKI-L", "TIKI-M", "TIKI-H"}, + }, + { + name: "assignee ASC", + tasks: []*task.Task{ + {ID: "TIKI-Z", Assignee: "Zara"}, + {ID: "TIKI-A", Assignee: "alice"}, + {ID: "TIKI-M", Assignee: "Bob"}, + }, + rules: []SortRule{{Field: "assignee", Descending: false}}, + expectedID: []string{"TIKI-A", "TIKI-M", "TIKI-Z"}, + }, + { + name: "status ASC", + tasks: []*task.Task{ + {ID: "TIKI-R", Status: "ready"}, + {ID: "TIKI-B", Status: "backlog"}, + {ID: "TIKI-D", Status: "done"}, + }, + rules: []SortRule{{Field: "status", Descending: false}}, + expectedID: []string{"TIKI-B", "TIKI-D", "TIKI-R"}, + }, + { + name: "type ASC", + tasks: []*task.Task{ + {ID: "TIKI-S", Type: task.TypeStory}, + {ID: "TIKI-B", Type: task.TypeBug}, + }, + rules: []SortRule{{Field: "type", Descending: false}}, + expectedID: []string{"TIKI-B", "TIKI-S"}, + }, + { + name: "id ASC", + tasks: []*task.Task{ + {ID: "TIKI-C"}, + {ID: "TIKI-A"}, + {ID: "TIKI-B"}, + }, + rules: []SortRule{{Field: "id", Descending: false}}, + expectedID: []string{"TIKI-A", "TIKI-B", "TIKI-C"}, + }, + { + name: "createdat ASC", + tasks: []*task.Task{ + {ID: "TIKI-L", CreatedAt: later}, + {ID: "TIKI-E", CreatedAt: earlier}, + {ID: "TIKI-N", CreatedAt: now}, + }, + rules: []SortRule{{Field: "createdat", Descending: false}}, + expectedID: []string{"TIKI-E", "TIKI-N", "TIKI-L"}, + }, + { + name: "updatedat DESC", + tasks: []*task.Task{ + {ID: "TIKI-E", UpdatedAt: earlier}, + {ID: "TIKI-L", UpdatedAt: later}, + {ID: "TIKI-N", UpdatedAt: now}, + }, + rules: []SortRule{{Field: "updatedat", Descending: true}}, + expectedID: []string{"TIKI-L", "TIKI-N", "TIKI-E"}, + }, + { + name: "multi-rule: priority ASC then title ASC", + tasks: []*task.Task{ + {ID: "TIKI-B2", Priority: 2, Title: "Beta"}, + {ID: "TIKI-A2", Priority: 2, Title: "Alpha"}, + {ID: "TIKI-A1", Priority: 1, Title: "Zeta"}, + }, + rules: []SortRule{ + {Field: "priority", Descending: false}, + {Field: "title", Descending: false}, + }, + expectedID: []string{"TIKI-A1", "TIKI-A2", "TIKI-B2"}, + }, + { + name: "unknown field — equal comparison, stable order preserved", + tasks: []*task.Task{ + {ID: "TIKI-X"}, + {ID: "TIKI-Y"}, + }, + rules: []SortRule{{Field: "nonexistent", Descending: false}}, + expectedID: []string{"TIKI-X", "TIKI-Y"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + SortTasks(tt.tasks, tt.rules) + + if len(tt.tasks) != len(tt.expectedID) { + t.Fatalf("task count = %d, want %d", len(tt.tasks), len(tt.expectedID)) + } + for i, want := range tt.expectedID { + if tt.tasks[i].ID != want { + t.Errorf("tasks[%d].ID = %q, want %q", i, tt.tasks[i].ID, want) + } + } + }) + } +} diff --git a/store/memory_store_test.go b/store/memory_store_test.go new file mode 100644 index 0000000..874e027 --- /dev/null +++ b/store/memory_store_test.go @@ -0,0 +1,391 @@ +package store + +import ( + "testing" + "time" + + taskpkg "github.com/boolean-maybe/tiki/task" +) + +func TestInMemoryStore_CreateTask(t *testing.T) { + tests := []struct { + name string + inputID string + expected string + }{ + {"normalizes ID to uppercase", "tiki-abc123", "TIKI-ABC123"}, + {"trims whitespace from ID", " TIKI-XYZ ", "TIKI-XYZ"}, + {"already uppercase passthrough", "TIKI-DEF456", "TIKI-DEF456"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := NewInMemoryStore() + task := &taskpkg.Task{ID: tt.inputID, Title: "Test", Type: taskpkg.TypeStory, Status: taskpkg.DefaultStatus()} + + err := s.CreateTask(task) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if task.ID != tt.expected { + t.Errorf("task.ID = %q, want %q", task.ID, tt.expected) + } + if task.CreatedAt.IsZero() { + t.Error("expected non-zero CreatedAt") + } + if task.UpdatedAt.IsZero() { + t.Error("expected non-zero UpdatedAt") + } + got := s.GetTask(tt.expected) + if got == nil { + t.Errorf("GetTask(%q) returned nil after CreateTask", tt.expected) + } + }) + } +} + +func TestInMemoryStore_UpdateTask(t *testing.T) { + t.Run("error when task not found", func(t *testing.T) { + s := NewInMemoryStore() + task := &taskpkg.Task{ID: "TIKI-MISSING", Title: "Ghost"} + err := s.UpdateTask(task) + if err == nil { + t.Error("expected error for non-existent task, got nil") + } + }) + + t.Run("normalizes ID before update", func(t *testing.T) { + s := NewInMemoryStore() + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-ABC123", Title: "Original"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + updated := &taskpkg.Task{ID: "tiki-abc123", Title: "Updated"} + if err := s.UpdateTask(updated); err != nil { + t.Fatalf("UpdateTask() error = %v", err) + } + got := s.GetTask("TIKI-ABC123") + if got == nil || got.Title != "Updated" { + t.Errorf("expected title %q, got %v", "Updated", got) + } + }) + + t.Run("sets UpdatedAt after update", func(t *testing.T) { + s := NewInMemoryStore() + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-UPD001", Title: "Before"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + task := s.GetTask("TIKI-UPD001") + before := task.UpdatedAt + + time.Sleep(time.Millisecond) + task.Title = "After" + if err := s.UpdateTask(task); err != nil { + t.Fatalf("UpdateTask() error = %v", err) + } + if !task.UpdatedAt.After(before) { + t.Errorf("expected UpdatedAt to advance after update") + } + }) + + t.Run("updates task fields roundtrip", func(t *testing.T) { + s := NewInMemoryStore() + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-RT0001", Title: "Old Title"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + got := s.GetTask("TIKI-RT0001") + got.Title = "New Title" + if err := s.UpdateTask(got); err != nil { + t.Fatalf("UpdateTask() error = %v", err) + } + + reloaded := s.GetTask("TIKI-RT0001") + if reloaded.Title != "New Title" { + t.Errorf("title = %q, want %q", reloaded.Title, "New Title") + } + }) +} + +func TestInMemoryStore_DeleteTask(t *testing.T) { + t.Run("removes existing task", func(t *testing.T) { + s := NewInMemoryStore() + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-DEL001", Title: "To Delete"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + s.DeleteTask("TIKI-DEL001") + if s.GetTask("TIKI-DEL001") != nil { + t.Error("expected nil after delete, got task") + } + }) + + t.Run("no panic for non-existent ID", func(t *testing.T) { + s := NewInMemoryStore() + s.DeleteTask("TIKI-GHOST1") // should not panic + }) + + t.Run("normalizes ID for delete", func(t *testing.T) { + s := NewInMemoryStore() + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-LOWER1", Title: "Task"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + s.DeleteTask("tiki-lower1") // lowercase + if s.GetTask("TIKI-LOWER1") != nil { + t.Error("expected nil after delete with lowercase ID") + } + }) +} + +func TestInMemoryStore_AddComment(t *testing.T) { + t.Run("returns false for unknown task", func(t *testing.T) { + s := NewInMemoryStore() + ok := s.AddComment("TIKI-NOEXST", taskpkg.Comment{Text: "hello"}) + if ok { + t.Error("expected false for unknown task, got true") + } + }) + + t.Run("returns true for known task", func(t *testing.T) { + s := NewInMemoryStore() + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-CMT001", Title: "Task"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + ok := s.AddComment("TIKI-CMT001", taskpkg.Comment{Text: "first comment"}) + if !ok { + t.Error("expected true for known task") + } + }) + + t.Run("sets comment CreatedAt", func(t *testing.T) { + s := NewInMemoryStore() + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-CMT002", Title: "Task"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + comment := taskpkg.Comment{Text: "check timestamp"} + s.AddComment("TIKI-CMT002", comment) + + got := s.GetTask("TIKI-CMT002") + if got.Comments[0].CreatedAt.IsZero() { + t.Error("expected non-zero CreatedAt on comment") + } + }) + + t.Run("appends to existing comments", func(t *testing.T) { + s := NewInMemoryStore() + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-CMT003", Title: "Task"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + s.AddComment("TIKI-CMT003", taskpkg.Comment{Text: "one"}) + s.AddComment("TIKI-CMT003", taskpkg.Comment{Text: "two"}) + + got := s.GetTask("TIKI-CMT003") + if len(got.Comments) != 2 { + t.Errorf("expected 2 comments, got %d", len(got.Comments)) + } + }) + + t.Run("normalizes task ID", func(t *testing.T) { + s := NewInMemoryStore() + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-CMT004", Title: "Task"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + ok := s.AddComment("tiki-cmt004", taskpkg.Comment{Text: "lowercase key"}) + if !ok { + t.Error("expected true with lowercase task ID") + } + }) +} + +func TestInMemoryStore_Search(t *testing.T) { + buildStore := func(tb testing.TB) *InMemoryStore { + tb.Helper() + s := NewInMemoryStore() + for _, task := range []*taskpkg.Task{ + {ID: "TIKI-S00001", Title: "Alpha feature", Description: "desc alpha", Tags: []string{"ui", "frontend"}}, + {ID: "TIKI-S00002", Title: "Beta Bug", Description: "beta description", Tags: []string{"backend"}}, + {ID: "TIKI-S00003", Title: "Gamma chore", Description: "third task"}, + } { + if err := s.CreateTask(task); err != nil { + tb.Fatalf("CreateTask() error = %v", err) + } + } + return s + } + + tests := []struct { + name string + query string + filterFunc func(*taskpkg.Task) bool + minResults int + maxResults int + }{ + {"empty query + nil filter returns all", "", nil, 3, 3}, + {"matches ID case-insensitive", "tiki-s00001", nil, 1, 1}, + {"matches title case-insensitive", "alpha", nil, 1, 1}, + {"matches description", "beta description", nil, 1, 1}, + {"matches first tag", "ui", nil, 1, 1}, + {"matches second tag", "backend", nil, 1, 1}, + {"non-matching query returns empty", "zzz-no-match", nil, 0, 0}, + {"filterFunc excludes tasks", "", func(t *taskpkg.Task) bool { return t.ID == "TIKI-S00001" }, 1, 1}, + {"filterFunc + query intersection", "beta", func(t *taskpkg.Task) bool { return t.ID == "TIKI-S00002" }, 1, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := buildStore(t) + results := s.Search(tt.query, tt.filterFunc) + if len(results) < tt.minResults || len(results) > tt.maxResults { + t.Errorf("Search(%q) returned %d results, want [%d, %d]", tt.query, len(results), tt.minResults, tt.maxResults) + } + }) + } +} + +func TestInMemoryStore_Listeners(t *testing.T) { + t.Run("called after CreateTask", func(t *testing.T) { + s := NewInMemoryStore() + called := 0 + s.AddListener(func() { called++ }) + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-LIS001"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if called != 1 { + t.Errorf("listener called %d times, want 1", called) + } + }) + + t.Run("called after UpdateTask", func(t *testing.T) { + s := NewInMemoryStore() + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-LIS002", Title: "orig"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + called := 0 + s.AddListener(func() { called++ }) + task := s.GetTask("TIKI-LIS002") + if err := s.UpdateTask(task); err != nil { + t.Fatalf("UpdateTask() error = %v", err) + } + if called != 1 { + t.Errorf("listener called %d times, want 1", called) + } + }) + + t.Run("called after DeleteTask", func(t *testing.T) { + s := NewInMemoryStore() + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-LIS003"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + called := 0 + s.AddListener(func() { called++ }) + s.DeleteTask("TIKI-LIS003") + if called != 1 { + t.Errorf("listener called %d times, want 1", called) + } + }) + + t.Run("called after AddComment success", func(t *testing.T) { + s := NewInMemoryStore() + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-LIS004"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + called := 0 + s.AddListener(func() { called++ }) + s.AddComment("TIKI-LIS004", taskpkg.Comment{Text: "hi"}) + if called != 1 { + t.Errorf("listener called %d times, want 1", called) + } + }) + + t.Run("not called after RemoveListener", func(t *testing.T) { + s := NewInMemoryStore() + called := 0 + id := s.AddListener(func() { called++ }) + s.RemoveListener(id) + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-LIS005"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if called != 0 { + t.Errorf("removed listener called %d times, want 0", called) + } + }) + + t.Run("multiple listeners all notified", func(t *testing.T) { + s := NewInMemoryStore() + a, b := 0, 0 + s.AddListener(func() { a++ }) + s.AddListener(func() { b++ }) + if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-LIS006"}); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if a != 1 || b != 1 { + t.Errorf("listeners called a=%d b=%d, want both 1", a, b) + } + }) +} + +func TestInMemoryStore_NewTaskTemplate(t *testing.T) { + s := NewInMemoryStore() + tmpl, err := s.NewTaskTemplate() + if err != nil { + t.Fatalf("NewTaskTemplate() error = %v", err) + } + if tmpl.Priority != 7 { + t.Errorf("Priority = %d, want 7", tmpl.Priority) + } + if tmpl.Points != 1 { + t.Errorf("Points = %d, want 1", tmpl.Points) + } + if len(tmpl.Tags) != 1 || tmpl.Tags[0] != "idea" { + t.Errorf("Tags = %v, want [idea]", tmpl.Tags) + } + if tmpl.Status != taskpkg.DefaultStatus() { + t.Errorf("Status = %q, want %q", tmpl.Status, taskpkg.DefaultStatus()) + } + if tmpl.Type != taskpkg.TypeStory { + t.Errorf("Type = %q, want %q", tmpl.Type, taskpkg.TypeStory) + } +} + +func TestInMemoryStore_GetAllTasks(t *testing.T) { + t.Run("empty store returns empty slice", func(t *testing.T) { + s := NewInMemoryStore() + tasks := s.GetAllTasks() + if len(tasks) != 0 { + t.Errorf("got %d tasks, want 0", len(tasks)) + } + }) + + t.Run("3 tasks returns len 3", func(t *testing.T) { + s := NewInMemoryStore() + for _, id := range []string{"TIKI-ALL001", "TIKI-ALL002", "TIKI-ALL003"} { + if err := s.CreateTask(&taskpkg.Task{ID: id}); err != nil { + t.Fatalf("CreateTask(%s) error = %v", id, err) + } + } + tasks := s.GetAllTasks() + if len(tasks) != 3 { + t.Errorf("got %d tasks, want 3", len(tasks)) + } + }) + + t.Run("returns same pointers, not copies", func(t *testing.T) { + s := NewInMemoryStore() + original := &taskpkg.Task{ID: "TIKI-PTR001", Title: "Pointer Task"} + if err := s.CreateTask(original); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + tasks := s.GetAllTasks() + if len(tasks) != 1 { + t.Fatalf("got %d tasks, want 1", len(tasks)) + } + // mutate via pointer returned from GetAllTasks + tasks[0].Title = "Mutated" + reloaded := s.GetTask("TIKI-PTR001") + if reloaded.Title != "Mutated" { + t.Errorf("title = %q, want %q — GetAllTasks should return pointers to stored tasks", reloaded.Title, "Mutated") + } + }) +} diff --git a/store/tikistore/store_test.go b/store/tikistore/store_test.go index 5333459..ffe773c 100644 --- a/store/tikistore/store_test.go +++ b/store/tikistore/store_test.go @@ -744,6 +744,131 @@ func TestSaveTask_Recurrence(t *testing.T) { } } +func TestMatchesQuery(t *testing.T) { + tests := []struct { + name string + task *taskpkg.Task + query string + expected bool + }{ + { + name: "nil task returns false", + task: nil, + query: "foo", + expected: false, + }, + { + name: "empty query returns false", + task: &taskpkg.Task{ID: "TIKI-MQ0001", Title: "Hello"}, + query: "", + expected: false, + }, + { + name: "match by ID case-insensitive", + task: &taskpkg.Task{ID: "TIKI-ABC123"}, + query: "tiki-abc", + expected: true, + }, + { + name: "match by title", + task: &taskpkg.Task{ID: "TIKI-MQ0002", Title: "Hello World"}, + query: "hello", + expected: true, + }, + { + name: "match by description", + task: &taskpkg.Task{ID: "TIKI-MQ0003", Description: "some text here"}, + query: "some", + expected: true, + }, + { + name: "match by first tag", + task: &taskpkg.Task{ID: "TIKI-MQ0004", Tags: []string{"frontend"}}, + query: "frontend", + expected: true, + }, + { + name: "match by second tag", + task: &taskpkg.Task{ID: "TIKI-MQ0005", Tags: []string{"a", "backend"}}, + query: "backend", + expected: true, + }, + { + name: "no match", + task: &taskpkg.Task{ID: "TIKI-X", Title: "foo"}, + query: "zzz", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := matchesQuery(tt.task, tt.query) + if got != tt.expected { + t.Errorf("matchesQuery() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestSearch_WithFilterFunc(t *testing.T) { + buildStore := func() *TikiStore { + return &TikiStore{ + tasks: map[string]*taskpkg.Task{ + "TIKI-F00001": {ID: "TIKI-F00001", Title: "Alpha", Priority: 1}, + "TIKI-F00002": {ID: "TIKI-F00002", Title: "Beta", Priority: 2}, + "TIKI-F00003": {ID: "TIKI-F00003", Title: "Gamma", Priority: 3}, + }, + } + } + + t.Run("filter excludes all returns empty", func(t *testing.T) { + s := buildStore() + results := s.Search("", func(*taskpkg.Task) bool { return false }) + if len(results) != 0 { + t.Errorf("got %d results, want 0", len(results)) + } + }) + + t.Run("filter includes subset returns subset", func(t *testing.T) { + s := buildStore() + results := s.Search("", func(t *taskpkg.Task) bool { return t.ID == "TIKI-F00001" }) + if len(results) != 1 { + t.Errorf("got %d results, want 1", len(results)) + } + if results[0].Task.ID != "TIKI-F00001" { + t.Errorf("got ID %q, want TIKI-F00001", results[0].Task.ID) + } + }) + + t.Run("filter + query intersection", func(t *testing.T) { + s := buildStore() + // filter allows F00001 and F00002, query matches only "Beta" + results := s.Search("beta", func(t *taskpkg.Task) bool { + return t.ID == "TIKI-F00001" || t.ID == "TIKI-F00002" + }) + if len(results) != 1 { + t.Errorf("got %d results, want 1", len(results)) + } + if results[0].Task.ID != "TIKI-F00002" { + t.Errorf("got ID %q, want TIKI-F00002", results[0].Task.ID) + } + }) + + t.Run("nil filter + empty query returns all tasks", func(t *testing.T) { + s := &TikiStore{ + tasks: map[string]*taskpkg.Task{ + "TIKI-G00001": {ID: "TIKI-G00001", Title: "One"}, + "TIKI-G00002": {ID: "TIKI-G00002", Title: "Two"}, + }, + } + results := s.Search("", nil) + if len(results) != 2 { + t.Errorf("got %d results, want 2", len(results)) + } + }) +} + func TestSaveTask_Due(t *testing.T) { tmpDir := t.TempDir() store, err := NewTikiStore(tmpDir) diff --git a/task/validation_test.go b/task/validation_test.go index b296dc7..b1c83d3 100644 --- a/task/validation_test.go +++ b/task/validation_test.go @@ -346,6 +346,14 @@ func TestValidationErrors_Error(t *testing.T) { } } +func TestValidationErrors_Error_Empty(t *testing.T) { + var ve ValidationErrors + got := ve.Error() + if got != "no validation errors" { + t.Errorf("Error() = %q, want %q", got, "no validation errors") + } +} + func TestDueValidator(t *testing.T) { tests := []struct { name string diff --git a/testutil/fixtures.go b/testutil/fixtures.go index 87e96cc..91aefc2 100644 --- a/testutil/fixtures.go +++ b/testutil/fixtures.go @@ -11,21 +11,31 @@ import ( // CreateTestTask creates a markdown task file with YAML frontmatter func CreateTestTask(dir, id, title string, status task.Status, taskType task.Type) error { - // Task files are lowercase (e.g., tiki-1.md) - filename := strings.ToLower(id) + ".md" - filename = strings.ReplaceAll(filename, "-", "-") // already hyphenated - filepath := filepath.Join(dir, filename) + return CreateTestTaskWithDeps(dir, id, title, status, taskType, nil) +} + +// CreateTestTaskWithDeps creates a markdown task file with optional dependsOn IDs in the frontmatter. +func CreateTestTaskWithDeps(dir, id, title string, status task.Status, taskType task.Type, dependsOn []string) error { + filename := strings.ToLower(id) + ".md" + filePath := filepath.Join(dir, filename) + + depsYAML := "" + if len(dependsOn) > 0 { + depsYAML = "dependsOn:\n" + for _, dep := range dependsOn { + depsYAML += fmt.Sprintf(" - %s\n", dep) + } + } - // Build YAML frontmatter content := fmt.Sprintf(`--- title: %s type: %s status: %s priority: 3 points: 1 ---- +%s--- %s -`, title, taskType, status, title) +`, title, taskType, status, depsYAML, title) - return os.WriteFile(filepath, []byte(content), 0644) + return os.WriteFile(filePath, []byte(content), 0644) } diff --git a/testutil/integration_helpers.go b/testutil/integration_helpers.go index f66c743..e06ea96 100644 --- a/testutil/integration_helpers.go +++ b/testutil/integration_helpers.go @@ -26,6 +26,7 @@ type TestApp struct { TaskStore store.Store NavController *controller.NavigationController InputRouter *controller.InputRouter + ViewFactory *view.ViewFactory TaskDir string t *testing.T PluginConfigs map[string]*model.PluginConfig @@ -396,6 +397,13 @@ func (ta *TestApp) LoadPlugins() error { viewFactory := view.NewViewFactory(ta.TaskStore) viewFactory.SetPlugins(pluginConfigs, pluginDefs, pluginControllers) + ta.ViewFactory = viewFactory + + // Wire dynamic plugin registration so openDepsEditor can register deps views at runtime. + // Mirrors bootstrap/init.go:133-135. + ta.InputRouter.SetPluginRegistrar(func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl controller.PluginControllerInterface) { + viewFactory.RegisterPlugin(name, cfg, def, ctrl) + }) // Recreate RootLayout with new view factory headerWidget := header.NewHeaderWidget(ta.headerConfig) diff --git a/util/key_formatter_test.go b/util/key_formatter_test.go new file mode 100644 index 0000000..67ea1a2 --- /dev/null +++ b/util/key_formatter_test.go @@ -0,0 +1,131 @@ +package util + +import ( + "testing" + + "github.com/gdamore/tcell/v2" +) + +func TestFormatKeyBinding(t *testing.T) { + tests := []struct { + name string + key tcell.Key + ch rune + mod tcell.ModMask + expected string + }{ + // rune key path (ch != 0) + { + name: "plain rune", + key: tcell.KeyRune, + ch: 's', + mod: 0, + expected: "s", + }, + { + name: "rune with Ctrl modifier", + key: tcell.KeyRune, + ch: 'r', + mod: tcell.ModCtrl, + expected: "Ctrl+r", + }, + { + name: "rune with Shift modifier", + key: tcell.KeyRune, + ch: 'A', + mod: tcell.ModShift, + expected: "Shift+A", + }, + { + name: "rune with Alt modifier", + key: tcell.KeyRune, + ch: 'x', + mod: tcell.ModAlt, + expected: "Alt+x", + }, + { + name: "rune with Shift+Ctrl modifiers", + key: tcell.KeyRune, + ch: 'p', + mod: tcell.ModShift | tcell.ModCtrl, + expected: "Shift+Ctrl+p", + }, + + // named special key path (in tcell.KeyNames, no modifier prefix in name) + { + name: "Enter key", + key: tcell.KeyEnter, + ch: 0, + mod: 0, + expected: "Enter", + }, + { + name: "Escape key", + key: tcell.KeyEscape, + ch: 0, + mod: 0, + expected: "Esc", + }, + { + name: "Tab key", + key: tcell.KeyTab, + ch: 0, + mod: 0, + expected: "Tab", + }, + { + name: "Backspace key", + key: tcell.KeyBackspace, + ch: 0, + mod: 0, + expected: "Backspace", + }, + { + name: "Enter with Shift modifier", + key: tcell.KeyEnter, + ch: 0, + mod: tcell.ModShift, + expected: "Shift+Enter", + }, + { + name: "F1 key", + key: tcell.KeyF1, + ch: 0, + mod: 0, + expected: "F1", + }, + + // named key that already has modifier in its name (e.g. "Ctrl-A") + { + name: "CtrlA — name already contains Ctrl-", + key: tcell.KeyCtrlA, + ch: 0, + mod: 0, + expected: "Ctrl-A", + }, + + // Ctrl+letter fallback path (keys 1–26 not caught by the named-key guard above) + // KeyCtrlA IS in KeyNames as "Ctrl-A", so the fallback fires only for truly unnamed keys. + // We test a key in range [1,26] that is NOT in KeyNames. + // All KeyCtrl* keys are in KeyNames, so the cleanest way to hit the fallback is + // to construct a raw key value that is in range but has no KeyNames entry. + // In practice this path is defensive; we verify it doesn't panic and returns "Ctrl+?" + // by using a raw key value known to not be in KeyNames. + { + name: "unknown key returns question mark", + key: tcell.Key(9999), + ch: 0, + mod: 0, + expected: "?", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatKeyBinding(tt.key, tt.ch, tt.mod) + if got != tt.expected { + t.Errorf("FormatKeyBinding(%v, %q, %v) = %q, want %q", tt.key, tt.ch, tt.mod, got, tt.expected) + } + }) + } +} diff --git a/util/text_test.go b/util/text_test.go index 7c774a8..67b102a 100644 --- a/util/text_test.go +++ b/util/text_test.go @@ -88,6 +88,12 @@ func TestTruncateTextWithColors(t *testing.T) { maxWidth: 8, expected: "hello...", }, + { + name: "small width floor — returns unchanged", + text: "[red]hi[-]", + maxWidth: 3, + expected: "[red]hi[-]", + }, } for _, tt := range tests { diff --git a/view/grid/padding_test.go b/view/grid/padding_test.go new file mode 100644 index 0000000..6717fc8 --- /dev/null +++ b/view/grid/padding_test.go @@ -0,0 +1,66 @@ +package grid + +import ( + "testing" +) + +func TestPadToFullRows(t *testing.T) { + tests := []struct { + name string + items []int + rowCount int + expectedLen int + expectedTail []int // last N elements after padding + }{ + { + name: "empty slice returns unchanged", + items: []int{}, + rowCount: 3, + expectedLen: 0, + }, + { + name: "already divisible — no padding added", + items: []int{1, 2, 3, 4, 5, 6}, + rowCount: 3, + expectedLen: 6, + }, + { + name: "7 items / rowCount 3 — pads to 9", + items: []int{1, 2, 3, 4, 5, 6, 7}, + rowCount: 3, + expectedLen: 9, + expectedTail: []int{0, 0}, // two zero-value ints appended + }, + { + name: "rowCount 1 — never needs padding", + items: []int{1, 2, 3}, + rowCount: 1, + expectedLen: 3, + expectedTail: []int{3}, + }, + { + name: "1 item / rowCount 4 — pads to 4", + items: []int{42}, + rowCount: 4, + expectedLen: 4, + expectedTail: []int{0, 0, 0}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := PadToFullRows(tt.items, tt.rowCount) + + if len(result) != tt.expectedLen { + t.Errorf("len = %d, want %d", len(result), tt.expectedLen) + } + + for i, want := range tt.expectedTail { + idx := tt.expectedLen - len(tt.expectedTail) + i + if result[idx] != want { + t.Errorf("result[%d] = %v, want %v", idx, result[idx], want) + } + } + }) + } +} diff --git a/view/header/header.go b/view/header/header.go index cd8f6b9..197b1d9 100644 --- a/view/header/header.go +++ b/view/header/header.go @@ -39,12 +39,7 @@ type SetActionsParams struct { const ( HeaderHeight = 6 - HeaderColumnSpacing = 2 // spaces between action columns in ContextHelp - StatsWidth = 30 // fixed width for stats section - ChartWidth = 14 // fixed width for burndown chart - LogoWidth = 25 // fixed width for logo - MinContextWidth = 40 // minimum width for context help to remain readable - ChartSpacing = 10 // spacing between context help and chart when both visible + HeaderColumnSpacing = 2 // spaces between action columns in ContextHelp ) // HeaderWidget displays stats, available actions and burndown chart @@ -167,41 +162,11 @@ func (h *HeaderWidget) Cleanup() { h.headerConfig.RemoveListener(h.listenerID) } -// rebuildLayout recalculates and rebuilds the flex layout based on terminal width +// rebuildLayout recalculates and rebuilds the flex layout based on terminal width. func (h *HeaderWidget) rebuildLayout(width int) { h.lastWidth = width - availableBetween := width - StatsWidth - LogoWidth - if availableBetween < 0 { - availableBetween = 0 - } - - contextHelpWidth := h.contextHelp.GetWidth() - requiredContext := contextHelpWidth - if requiredContext < MinContextWidth && requiredContext > 0 { - requiredContext = MinContextWidth - } - - requiredForChart := requiredContext + ChartSpacing + ChartWidth - chartVisible := availableBetween >= requiredForChart - - contextWidth := contextHelpWidth - if contextWidth < 0 { - contextWidth = 0 - } - - usedForChart := 0 - if chartVisible { - usedForChart = ChartSpacing + ChartWidth - } - - maxContextWidth := availableBetween - usedForChart - if maxContextWidth < 0 { - maxContextWidth = 0 - } - if contextWidth > maxContextWidth { - contextWidth = maxContextWidth - } + layout := CalculateHeaderLayout(width, h.contextHelp.GetWidth()) // rebuild flex to keep the middle group centered between stats and logo, // and to physically remove the chart when hidden. @@ -209,13 +174,13 @@ func (h *HeaderWidget) rebuildLayout(width int) { h.SetDirection(tview.FlexColumn) h.AddItem(h.stats.Primitive(), StatsWidth, 0, false) h.AddItem(h.leftSpacer, 0, 1, false) - h.AddItem(h.contextHelp.Primitive(), contextWidth, 0, false) - if chartVisible { + h.AddItem(h.contextHelp.Primitive(), layout.ContextWidth, 0, false) + if layout.ChartVisible { h.AddItem(h.gap, ChartSpacing, 0, false) h.AddItem(h.chart.Primitive(), ChartWidth, 0, false) } h.AddItem(h.rightSpacer, 0, 1, false) h.AddItem(h.logo, LogoWidth, 0, false) - h.chartVisible = chartVisible + h.chartVisible = layout.ChartVisible } diff --git a/view/header/header_layout_test.go b/view/header/header_layout_test.go index e1d75f6..6f2c86c 100644 --- a/view/header/header_layout_test.go +++ b/view/header/header_layout_test.go @@ -6,12 +6,141 @@ import ( "github.com/boolean-maybe/tiki/model" ) +// --- pure layout function tests --- + +func TestCalculateHeaderLayout_chartVisibleAtThreshold(t *testing.T) { + // availableBetween = 119 - 30 - 25 = 64 + // requiredContext = max(10, 40) = 40 + // required for chart = 40 + 10 + 14 = 64 → exactly fits + layout := CalculateHeaderLayout(119, 10) + if !layout.ChartVisible { + t.Fatal("expected chart visible at width=119, contextHelp=10") + } +} + +func TestCalculateHeaderLayout_chartHiddenJustBelow(t *testing.T) { + // availableBetween = 118 - 30 - 25 = 63 < 64 + layout := CalculateHeaderLayout(118, 10) + if layout.ChartVisible { + t.Fatal("expected chart hidden at width=118, contextHelp=10") + } +} + +func TestCalculateHeaderLayout_chartThresholdGrowsWithContextHelp(t *testing.T) { + // contextHelpWidth=60 already >= MinContextWidth so requiredContext=60 + // required = 60 + 10 + 14 = 84; availableBetween must be >= 84 + // totalWidth = 84 + 30 + 25 = 139 + layout := CalculateHeaderLayout(139, 60) + if !layout.ChartVisible { + t.Fatal("expected chart visible at width=139, contextHelp=60") + } + layout = CalculateHeaderLayout(138, 60) + if layout.ChartVisible { + t.Fatal("expected chart hidden at width=138, contextHelp=60") + } +} + +func TestCalculateHeaderLayout_contextWidthWithChart(t *testing.T) { + // width=200, contextHelp=50 + // availableBetween = 200 - 30 - 25 = 145 + // requiredContext = 50, chart required = 50+10+14 = 74 <= 145 → chart visible + // maxContextWidth = 145 - (10+14) = 121; contextWidth = min(50, 121) = 50 + layout := CalculateHeaderLayout(200, 50) + if !layout.ChartVisible { + t.Fatal("expected chart visible") + } + if layout.ContextWidth != 50 { + t.Errorf("contextWidth = %d, want 50", layout.ContextWidth) + } +} + +func TestCalculateHeaderLayout_contextWidthClampedByAvailable(t *testing.T) { + // width=100, contextHelp=200 (too wide) + // availableBetween = 100 - 30 - 25 = 45 + // requiredContext = 200; chart required = 214 > 45 → chart hidden + // maxContextWidth = 45; contextWidth clamped to 45 + layout := CalculateHeaderLayout(100, 200) + if layout.ChartVisible { + t.Fatal("expected chart hidden when context too wide") + } + if layout.ContextWidth != 45 { + t.Errorf("contextWidth = %d, want 45", layout.ContextWidth) + } +} + +func TestCalculateHeaderLayout_contextWidthFlooredAtMinContextWidth(t *testing.T) { + // contextHelpWidth=10 < MinContextWidth=40, so requiredContext=40 + // but contextWidth itself stays at 10 (the floor only affects chart threshold) + layout := CalculateHeaderLayout(200, 10) + if layout.ContextWidth != 10 { + t.Errorf("contextWidth = %d, want 10 (min floor only affects chart threshold)", layout.ContextWidth) + } +} + +func TestCalculateHeaderLayout_zeroContextHelp(t *testing.T) { + // contextHelpWidth=0: requiredContext stays 0 (guard: > 0 check) + // required for chart = 0 + 10 + 14 = 24 + // availableBetween at width=119 = 64 >= 24 → chart visible + layout := CalculateHeaderLayout(119, 0) + if !layout.ChartVisible { + t.Fatal("expected chart visible with zero-width context help") + } + if layout.ContextWidth != 0 { + t.Errorf("contextWidth = %d, want 0", layout.ContextWidth) + } +} + +func TestCalculateHeaderLayout_negativeContextHelp(t *testing.T) { + layout := CalculateHeaderLayout(200, -5) + if layout.ContextWidth != 0 { + t.Errorf("contextWidth = %d, want 0 for negative input", layout.ContextWidth) + } +} + +func TestCalculateHeaderLayout_veryNarrowTerminal(t *testing.T) { + // width < StatsWidth + LogoWidth → availableBetween clamped to 0 + // chart cannot be visible; contextWidth = 0 + layout := CalculateHeaderLayout(40, 30) + if layout.ChartVisible { + t.Fatal("expected chart hidden on very narrow terminal") + } + if layout.ContextWidth != 0 { + t.Errorf("contextWidth = %d, want 0 on very narrow terminal", layout.ContextWidth) + } +} + +func TestCalculateHeaderLayout_exactlyStatsAndLogo(t *testing.T) { + // width = StatsWidth + LogoWidth = 55 → availableBetween = 0 + layout := CalculateHeaderLayout(StatsWidth+LogoWidth, 10) + if layout.ChartVisible { + t.Fatal("expected chart hidden when no space between stats and logo") + } + if layout.ContextWidth != 0 { + t.Errorf("contextWidth = %d, want 0", layout.ContextWidth) + } +} + +func TestCalculateHeaderLayout_chartHiddenContextFillsAvailable(t *testing.T) { + // chart hidden; contextWidth should use full availableBetween + // width=118, contextHelp=63 + // availableBetween = 63; chart requires 40+24=64 > 63 → hidden + // maxContextWidth = 63; contextWidth = min(63, 63) = 63 + layout := CalculateHeaderLayout(118, 63) + if layout.ChartVisible { + t.Fatal("expected chart hidden") + } + if layout.ContextWidth != 63 { + t.Errorf("contextWidth = %d, want 63", layout.ContextWidth) + } +} + +// --- integration tests (require widget construction) --- + func TestHeaderWidget_chartVisibilityThreshold_default(t *testing.T) { headerConfig := model.NewHeaderConfig() h := NewHeaderWidget(headerConfig) defer h.Cleanup() - // ensure the classic threshold is preserved when context help is small. h.contextHelp.width = 10 h.rebuildLayout(119) if !h.chartVisible { diff --git a/view/help/help.md b/view/help/help.md index fecb54e..d927a40 100644 --- a/view/help/help.md +++ b/view/help/help.md @@ -15,7 +15,7 @@ state of the repo or its git branch. Also, all past versions and deleted items r Board is a simple Kanban-style board where tikis can be moved around with `Shift-Right` and `Shift-Left` As tikis are moved their status changes correspondingly. Statuses are configurable via `workflow.yaml`. -Tikis can be opened for viewing or editing or searched by title and description. +Tikis can be opened for viewing or editing or searched by ID, title, description and tags. To quickly capture an idea - hit `n` in the board or any tiki view, type in the title and press Enter You can also edit its status, type and other fields, or open the source file directly for editing in your favorite editor diff --git a/view/search_helper.go b/view/search_helper.go index a9b44fe..d47bb4a 100644 --- a/view/search_helper.go +++ b/view/search_helper.go @@ -74,6 +74,11 @@ func (sh *SearchHelper) HasFocus() bool { return sh.searchVisible && sh.searchBox.HasFocus() } +// GetFocusSetter returns the focus setter function for transferring focus after layout changes +func (sh *SearchHelper) GetFocusSetter() func(p tview.Primitive) { + return sh.focusSetter +} + // GetSearchBox returns the underlying search box primitive for layout building func (sh *SearchHelper) GetSearchBox() *SearchBox { return sh.searchBox diff --git a/view/task_box_test.go b/view/task_box_test.go new file mode 100644 index 0000000..3a1903c --- /dev/null +++ b/view/task_box_test.go @@ -0,0 +1,170 @@ +package view + +import ( + "strings" + "testing" + + "github.com/boolean-maybe/tiki/config" + taskpkg "github.com/boolean-maybe/tiki/task" +) + +func TestBuildCompactTaskContent(t *testing.T) { + colors := config.GetColors() + + tests := []struct { + name string + task *taskpkg.Task + availableWidth int + contains []string + notContains []string + }{ + { + name: "contains task ID", + task: &taskpkg.Task{ID: "TIKI-ABC123", Type: taskpkg.TypeStory, Priority: 3}, + availableWidth: 40, + contains: []string{"TIKI-ABC123"}, + }, + { + name: "contains title", + task: &taskpkg.Task{ID: "TIKI-TTL001", Title: "My Task", Type: taskpkg.TypeStory, Priority: 3}, + availableWidth: 40, + contains: []string{"My Task"}, + }, + { + name: "title truncated at width", + task: &taskpkg.Task{ID: "TIKI-TRC001", Title: "ABCDEFGHIJ", Type: taskpkg.TypeStory, Priority: 3}, + availableWidth: 7, + contains: []string{"ABCD"}, + notContains: []string{"ABCDEFGHIJ"}, + }, + { + name: "emoji for story type", + task: &taskpkg.Task{ID: "TIKI-EMO001", Type: taskpkg.TypeStory, Priority: 3}, + availableWidth: 40, + contains: []string{taskpkg.TypeEmoji(taskpkg.TypeStory)}, + }, + { + name: "emoji for bug type", + task: &taskpkg.Task{ID: "TIKI-EMO002", Type: taskpkg.TypeBug, Priority: 3}, + availableWidth: 40, + contains: []string{taskpkg.TypeEmoji(taskpkg.TypeBug)}, + }, + { + name: "priority label", + task: &taskpkg.Task{ID: "TIKI-PRI001", Type: taskpkg.TypeStory, Priority: 1}, + availableWidth: 40, + contains: []string{taskpkg.PriorityLabel(1)}, + }, + { + name: "zero points does not panic", + task: &taskpkg.Task{ID: "TIKI-PT0001", Type: taskpkg.TypeStory, Priority: 3, Points: 0}, + availableWidth: 40, + }, + { + name: "empty title does not panic", + task: &taskpkg.Task{ID: "TIKI-NT0001", Type: taskpkg.TypeStory, Priority: 3, Title: ""}, + availableWidth: 40, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildCompactTaskContent(tt.task, colors, tt.availableWidth) + + if result == "" { + t.Error("expected non-empty output") + } + for _, want := range tt.contains { + if !strings.Contains(result, want) { + t.Errorf("expected output to contain %q\noutput: %q", want, result) + } + } + for _, unwanted := range tt.notContains { + if strings.Contains(result, unwanted) { + t.Errorf("expected output NOT to contain %q\noutput: %q", unwanted, result) + } + } + }) + } +} + +func TestBuildExpandedTaskContent(t *testing.T) { + colors := config.GetColors() + + tests := []struct { + name string + task *taskpkg.Task + availableWidth int + contains []string + notContains []string + }{ + { + name: "empty description no panic", + task: &taskpkg.Task{ID: "TIKI-EXP001", Type: taskpkg.TypeStory, Priority: 3, Description: ""}, + availableWidth: 40, + }, + { + name: "single desc line included", + task: &taskpkg.Task{ID: "TIKI-EXP002", Type: taskpkg.TypeStory, Priority: 3, Description: "Line1"}, + availableWidth: 40, + contains: []string{"Line1"}, + }, + { + name: "three desc lines all included", + task: &taskpkg.Task{ID: "TIKI-EXP003", Type: taskpkg.TypeStory, Priority: 3, Description: "L1\nL2\nL3"}, + availableWidth: 40, + contains: []string{"L1", "L2", "L3"}, + }, + { + name: "fourth desc line not included", + task: &taskpkg.Task{ID: "TIKI-EXP004", Type: taskpkg.TypeStory, Priority: 3, Description: "L1\nL2\nL3\nL4"}, + availableWidth: 40, + contains: []string{"L1", "L2", "L3"}, + notContains: []string{"L4"}, + }, + { + name: "empty tags omits tags label", + task: &taskpkg.Task{ID: "TIKI-EXP005", Type: taskpkg.TypeStory, Priority: 3, Tags: []string{}}, + availableWidth: 40, + notContains: []string{"Tags:"}, + }, + { + name: "non-empty tags included", + task: &taskpkg.Task{ID: "TIKI-EXP006", Type: taskpkg.TypeStory, Priority: 3, Tags: []string{"ui", "backend"}}, + availableWidth: 40, + contains: []string{"ui", "backend"}, + }, + { + name: "tag truncated at small width no panic", + task: &taskpkg.Task{ID: "TIKI-EXP007", Type: taskpkg.TypeStory, Priority: 3, Tags: []string{"abcdefghij"}}, + availableWidth: 8, + }, + { + name: "desc line truncated at small width", + task: &taskpkg.Task{ID: "TIKI-EXP008", Type: taskpkg.TypeStory, Priority: 3, Description: "ABCDEFGHIJ"}, + availableWidth: 7, + contains: []string{"ABCD"}, + notContains: []string{"ABCDEFGHIJ"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildExpandedTaskContent(tt.task, colors, tt.availableWidth) + + if result == "" { + t.Error("expected non-empty output") + } + for _, want := range tt.contains { + if !strings.Contains(result, want) { + t.Errorf("expected output to contain %q\noutput: %q", want, result) + } + } + for _, unwanted := range tt.notContains { + if strings.Contains(result, unwanted) { + t.Errorf("expected output NOT to contain %q\noutput: %q", unwanted, result) + } + } + }) + } +} diff --git a/view/tiki_plugin_view.go b/view/tiki_plugin_view.go index 1d311f4..cf5d88b 100644 --- a/view/tiki_plugin_view.go +++ b/view/tiki_plugin_view.go @@ -246,6 +246,11 @@ func (pv *PluginView) HideSearch() { pv.root.Clear() pv.root.AddItem(pv.titleBar, 1, 0, false) pv.root.AddItem(pv.lanes, 0, 1, true) + + // explicitly transfer focus to lanes (clears cursor from removed search box) + if pv.searchHelper.GetFocusSetter() != nil { + pv.searchHelper.GetFocusSetter()(pv.lanes) + } } // IsSearchVisible returns whether the search box is currently visible diff --git a/view/tiki_plugin_view_test.go b/view/tiki_plugin_view_test.go index a895b48..0c9edbf 100644 --- a/view/tiki_plugin_view_test.go +++ b/view/tiki_plugin_view_test.go @@ -73,6 +73,104 @@ func TestPluginViewRefreshResetsNonSelectedLaneScrollOffset(t *testing.T) { } } +func TestPluginViewGridLayout_RowCount(t *testing.T) { + tests := []struct { + name string + numTasks int + columns int + expectedRows int + }{ + {"zero tasks", 0, 1, 0}, + {"6 tasks / 2 cols", 6, 2, 3}, + {"5 tasks / 3 cols", 5, 3, 2}, + {"1 task / 1 col", 1, 1, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + taskStore := store.NewInMemoryStore() + pluginConfig := model.NewPluginConfig("TestPlugin") + pluginConfig.SetLaneLayout([]int{tt.columns}) + + pluginDef := &plugin.TikiPlugin{ + BasePlugin: plugin.BasePlugin{Name: "TestPlugin"}, + Lanes: []plugin.TikiLane{{Name: "Lane", Columns: tt.columns}}, + } + + tasks := make([]*task.Task, tt.numTasks) + for i := range tasks { + tasks[i] = &task.Task{ + ID: fmt.Sprintf("T-%d", i), + Title: fmt.Sprintf("Task %d", i), + Status: task.StatusReady, + Type: task.TypeStory, + } + } + + pv := NewPluginView(taskStore, pluginConfig, pluginDef, func(lane int) []*task.Task { + return tasks + }, nil, controller.PluginViewActions(), true) + + pv.refresh() + + got := len(pv.laneBoxes[0].items) + if got != tt.expectedRows { + t.Errorf("rows = %d, want %d", got, tt.expectedRows) + } + }) + } +} + +func TestPluginViewGridLayout_SelectedRow(t *testing.T) { + tests := []struct { + name string + numTasks int + columns int + selectedIndex int + expectedSelectedRow int + }{ + {"index 0, 2 cols", 4, 2, 0, 0}, + {"index 2, 2 cols", 4, 2, 2, 1}, + {"index 4, 3 cols", 6, 3, 4, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + taskStore := store.NewInMemoryStore() + pluginConfig := model.NewPluginConfig("TestPlugin") + pluginConfig.SetLaneLayout([]int{tt.columns}) + + pluginDef := &plugin.TikiPlugin{ + BasePlugin: plugin.BasePlugin{Name: "TestPlugin"}, + Lanes: []plugin.TikiLane{{Name: "Lane", Columns: tt.columns}}, + } + + tasks := make([]*task.Task, tt.numTasks) + for i := range tasks { + tasks[i] = &task.Task{ + ID: fmt.Sprintf("T-%d", i), + Title: fmt.Sprintf("Task %d", i), + Status: task.StatusReady, + Type: task.TypeStory, + } + } + + pv := NewPluginView(taskStore, pluginConfig, pluginDef, func(lane int) []*task.Task { + return tasks + }, nil, controller.PluginViewActions(), true) + + pluginConfig.SetSelectedLane(0) + pluginConfig.SetSelectedIndexForLane(0, tt.selectedIndex) + pv.refresh() + + got := pv.laneBoxes[0].selectionIndex + if got != tt.expectedSelectedRow { + t.Errorf("selectionIndex = %d, want %d", got, tt.expectedSelectedRow) + } + }) + } +} + func TestPluginViewRefreshPreservesScrollOffset(t *testing.T) { taskStore := store.NewInMemoryStore() pluginConfig := model.NewPluginConfig("TestPlugin")