improve test coverage

This commit is contained in:
booleanmaybe 2026-03-19 14:11:03 -04:00
parent 1192aa3924
commit 3c9a42cf4a
20 changed files with 2395 additions and 51 deletions

View file

@ -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)
}
})
}
}

View file

@ -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)
}
})
}
}

View file

@ -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")
}
}

View file

@ -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)
}
}

171
plugin/sort_test.go Normal file
View file

@ -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)
}
}
})
}
}

391
store/memory_store_test.go Normal file
View file

@ -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")
}
})
}

View file

@ -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)

View file

@ -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

View file

@ -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)
}

View file

@ -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)

131
util/key_formatter_test.go Normal file
View file

@ -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 126 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)
}
})
}
}

View file

@ -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 {

66
view/grid/padding_test.go Normal file
View file

@ -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)
}
}
})
}
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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

View file

@ -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

170
view/task_box_test.go Normal file
View file

@ -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)
}
}
})
}
}

View file

@ -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

View file

@ -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")