mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
improve test coverage
This commit is contained in:
parent
1192aa3924
commit
3c9a42cf4a
20 changed files with 2395 additions and 51 deletions
235
component/barchart/braille_test.go
Normal file
235
component/barchart/braille_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
169
component/barchart/util_test.go
Normal file
169
component/barchart/util_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
292
integration/deps_editor_test.go
Normal file
292
integration/deps_editor_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
360
internal/viewer/input_extra_test.go
Normal file
360
internal/viewer/input_extra_test.go
Normal 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
171
plugin/sort_test.go
Normal 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
391
store/memory_store_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
131
util/key_formatter_test.go
Normal 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 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
66
view/grid/padding_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
170
view/task_box_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue