mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
improve navigation in plugin
This commit is contained in:
parent
676a0ec97b
commit
5a8918b904
3 changed files with 730 additions and 146 deletions
|
|
@ -1,6 +1,7 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
|
|
@ -455,3 +456,167 @@ func TestDepsViewActions(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newDepsNavEnv(t *testing.T, blockers int, allTasks int, depends int, laneColumns []int) *DepsController {
|
||||
t.Helper()
|
||||
|
||||
taskStore := store.NewInMemoryStore()
|
||||
contextID := "TIKI-CTXNAV0"
|
||||
contextDepends := make([]string, 0, depends)
|
||||
for i := 0; i < depends; i++ {
|
||||
contextDepends = append(contextDepends, fmt.Sprintf("TIKI-DEP%03d", i))
|
||||
}
|
||||
if err := taskStore.CreateTask(&task.Task{
|
||||
ID: contextID,
|
||||
Title: "Context",
|
||||
Status: task.StatusReady,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
DependsOn: contextDepends,
|
||||
}); err != nil {
|
||||
t.Fatalf("create context: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < depends; i++ {
|
||||
if err := taskStore.CreateTask(&task.Task{
|
||||
ID: fmt.Sprintf("TIKI-DEP%03d", i),
|
||||
Title: "Depends",
|
||||
Status: task.StatusReady,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}); err != nil {
|
||||
t.Fatalf("create depends task: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < blockers; i++ {
|
||||
if err := taskStore.CreateTask(&task.Task{
|
||||
ID: fmt.Sprintf("TIKI-BLK%03d", i),
|
||||
Title: "Blocker",
|
||||
Status: task.StatusReady,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
DependsOn: []string{contextID},
|
||||
}); err != nil {
|
||||
t.Fatalf("create blocker task: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < allTasks; i++ {
|
||||
if err := taskStore.CreateTask(&task.Task{
|
||||
ID: fmt.Sprintf("TIKI-ALL%03d", i),
|
||||
Title: "All",
|
||||
Status: task.StatusReady,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}); err != nil {
|
||||
t.Fatalf("create all lane task: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "Dependency:" + contextID, ConfigIndex: -1, Type: "tiki"},
|
||||
TaskID: contextID,
|
||||
Lanes: []plugin.TikiLane{{Name: "Blocks"}, {Name: "All"}, {Name: "Depends"}},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("Dependency")
|
||||
pluginConfig.SetLaneLayout(laneColumns, nil)
|
||||
|
||||
nav := newMockNavigationController()
|
||||
return NewDepsController(taskStore, pluginConfig, pluginDef, nav, nil)
|
||||
}
|
||||
|
||||
func TestDepsController_NavRightAdjacentNonEmptyPreservesRow(t *testing.T) {
|
||||
dc := newDepsNavEnv(t, 2, 4, 3, []int{1, 2, 1})
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
||||
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 3) // row 1, col 1
|
||||
dc.pluginConfig.SetScrollOffsetForLane(depsLaneAll, 1) // row offset in viewport = 0
|
||||
dc.pluginConfig.SetScrollOffsetForLane(depsLaneDepends, 1)
|
||||
|
||||
if !dc.HandleAction(ActionNavRight) {
|
||||
t.Fatal("expected nav right to succeed")
|
||||
}
|
||||
if got := dc.pluginConfig.GetSelectedLane(); got != depsLaneDepends {
|
||||
t.Fatalf("expected lane %d, got %d", depsLaneDepends, got)
|
||||
}
|
||||
if got := dc.pluginConfig.GetSelectedIndexForLane(depsLaneDepends); got != 1 {
|
||||
t.Fatalf("expected selected index 1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepsController_NavLeftAdjacentNonEmptyLandsRightmostPartial(t *testing.T) {
|
||||
dc := newDepsNavEnv(t, 6, 4, 2, []int{4, 2, 1})
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
||||
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 2) // row 1, col 0
|
||||
dc.pluginConfig.SetScrollOffsetForLane(depsLaneAll, 1) // row offset in viewport = 0
|
||||
dc.pluginConfig.SetScrollOffsetForLane(depsLaneBlocks, 1)
|
||||
|
||||
if !dc.HandleAction(ActionNavLeft) {
|
||||
t.Fatal("expected nav left to succeed")
|
||||
}
|
||||
if got := dc.pluginConfig.GetSelectedLane(); got != depsLaneBlocks {
|
||||
t.Fatalf("expected lane %d, got %d", depsLaneBlocks, got)
|
||||
}
|
||||
// lane 0 has 6 tasks with 4 columns; row 1 is partial => index 5
|
||||
if got := dc.pluginConfig.GetSelectedIndexForLane(depsLaneBlocks); got != 5 {
|
||||
t.Fatalf("expected selected index 5, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepsController_NavSkipEmptyKeepsTraversalAndLandsByTargetViewport(t *testing.T) {
|
||||
dc := newDepsNavEnv(t, 3, 0, 2, []int{1, 2, 1})
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
|
||||
dc.pluginConfig.SetSelectedIndexForLane(depsLaneBlocks, 2)
|
||||
dc.pluginConfig.SetScrollOffsetForLane(depsLaneDepends, 1)
|
||||
|
||||
if !dc.HandleAction(ActionNavRight) {
|
||||
t.Fatal("expected nav right to skip empty all lane and succeed")
|
||||
}
|
||||
if got := dc.pluginConfig.GetSelectedLane(); got != depsLaneDepends {
|
||||
t.Fatalf("expected lane %d, got %d", depsLaneDepends, got)
|
||||
}
|
||||
if got := dc.pluginConfig.GetSelectedIndexForLane(depsLaneDepends); got != 1 {
|
||||
t.Fatalf("expected selected index 1 from depends viewport row, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepsController_VerticalStaleIndexRecoveryIsShared(t *testing.T) {
|
||||
dc := newDepsNavEnv(t, 1, 1, 1, []int{1, 2, 1})
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
||||
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 99)
|
||||
|
||||
callbacks := 0
|
||||
listenerID := dc.pluginConfig.AddSelectionListener(func() { callbacks++ })
|
||||
defer dc.pluginConfig.RemoveSelectionListener(listenerID)
|
||||
|
||||
if !dc.HandleAction(ActionNavDown) {
|
||||
t.Fatal("expected stale vertical action to heal selection")
|
||||
}
|
||||
if got := dc.pluginConfig.GetSelectedIndexForLane(depsLaneAll); got != 0 {
|
||||
t.Fatalf("expected healed index 0, got %d", got)
|
||||
}
|
||||
if callbacks != 1 {
|
||||
t.Fatalf("expected 1 selection callback, got %d", callbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepsController_SuccessfulSwitchPersistsClampedTargetScroll(t *testing.T) {
|
||||
dc := newDepsNavEnv(t, 2, 3, 1, []int{1, 2, 1})
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
|
||||
dc.pluginConfig.SetSelectedIndexForLane(depsLaneBlocks, 0)
|
||||
dc.pluginConfig.SetScrollOffsetForLane(depsLaneAll, 99)
|
||||
|
||||
if !dc.HandleAction(ActionNavRight) {
|
||||
t.Fatal("expected nav right to succeed")
|
||||
}
|
||||
if got := dc.pluginConfig.GetSelectedLane(); got != depsLaneAll {
|
||||
t.Fatalf("expected lane %d, got %d", depsLaneAll, got)
|
||||
}
|
||||
// all lane has 3 tasks with 2 columns => max row 1, row-start index 2
|
||||
if got := dc.pluginConfig.GetSelectedIndexForLane(depsLaneAll); got != 2 {
|
||||
t.Fatalf("expected selected index 2, got %d", got)
|
||||
}
|
||||
if got := dc.pluginConfig.GetScrollOffsetForLane(depsLaneAll); got != 1 {
|
||||
t.Fatalf("expected clamped scroll offset 1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,52 +27,230 @@ func (pb *pluginBase) GetPluginName() string { return pb.pluginDef.
|
|||
func (pb *pluginBase) handleNav(direction string, filteredTasks func(int) []*task.Task) bool {
|
||||
lane := pb.pluginConfig.GetSelectedLane()
|
||||
tasks := filteredTasks(lane)
|
||||
if direction == "left" || direction == "right" {
|
||||
if pb.pluginConfig.MoveSelection(direction, len(tasks)) {
|
||||
return true
|
||||
}
|
||||
return pb.handleLaneSwitch(direction, filteredTasks)
|
||||
}
|
||||
return pb.pluginConfig.MoveSelection(direction, len(tasks))
|
||||
}
|
||||
|
||||
func (pb *pluginBase) handleLaneSwitch(direction string, filteredTasks func(int) []*task.Task) bool {
|
||||
currentLane := pb.pluginConfig.GetSelectedLane()
|
||||
nextLane := currentLane
|
||||
switch direction {
|
||||
case "left":
|
||||
nextLane--
|
||||
case "right":
|
||||
nextLane++
|
||||
case "up", "down":
|
||||
return pb.handleVerticalNav(direction, lane, tasks)
|
||||
case "left", "right":
|
||||
return pb.handleHorizontalNav(direction, lane, tasks, filteredTasks)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for nextLane >= 0 && nextLane < len(pb.pluginDef.Lanes) {
|
||||
tasks := filteredTasks(nextLane)
|
||||
if len(tasks) > 0 {
|
||||
pb.pluginConfig.SetSelectedLane(nextLane)
|
||||
// select the task at top of viewport (scroll offset) rather than keeping stale index
|
||||
scrollOffset := pb.pluginConfig.GetScrollOffsetForLane(nextLane)
|
||||
if scrollOffset >= len(tasks) {
|
||||
scrollOffset = len(tasks) - 1
|
||||
}
|
||||
if scrollOffset < 0 {
|
||||
scrollOffset = 0
|
||||
}
|
||||
pb.pluginConfig.SetSelectedIndexForLane(nextLane, scrollOffset)
|
||||
func (pb *pluginBase) handleVerticalNav(direction string, lane int, tasks []*task.Task) bool {
|
||||
if len(tasks) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
storedIndex := pb.pluginConfig.GetSelectedIndexForLane(lane)
|
||||
clampedIndex := clampTaskIndex(storedIndex, len(tasks))
|
||||
if storedIndex != clampedIndex {
|
||||
columns := normalizeColumns(pb.pluginConfig.GetColumnsForLane(lane))
|
||||
finalIndex := moveVerticalIndex(direction, clampedIndex, columns, len(tasks))
|
||||
if storedIndex != finalIndex {
|
||||
pb.pluginConfig.SetSelectedIndexForLane(lane, finalIndex)
|
||||
return true
|
||||
}
|
||||
switch direction {
|
||||
case "left":
|
||||
nextLane--
|
||||
case "right":
|
||||
nextLane++
|
||||
return false
|
||||
}
|
||||
|
||||
return pb.pluginConfig.MoveSelection(direction, len(tasks))
|
||||
}
|
||||
|
||||
func (pb *pluginBase) handleHorizontalNav(direction string, lane int, tasks []*task.Task, filteredTasks func(int) []*task.Task) bool {
|
||||
if len(tasks) > 0 {
|
||||
storedIndex := pb.pluginConfig.GetSelectedIndexForLane(lane)
|
||||
clampedIndex := clampTaskIndex(storedIndex, len(tasks))
|
||||
columns := normalizeColumns(pb.pluginConfig.GetColumnsForLane(lane))
|
||||
if moved, targetIndex := moveHorizontalIndex(direction, clampedIndex, columns, len(tasks)); moved {
|
||||
pb.pluginConfig.SetSelectedIndexForLane(lane, targetIndex)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return pb.handleLaneSwitch(direction, filteredTasks)
|
||||
}
|
||||
|
||||
func (pb *pluginBase) handleLaneSwitch(direction string, filteredTasks func(int) []*task.Task) bool {
|
||||
if pb.pluginDef == nil {
|
||||
return false
|
||||
}
|
||||
currentLane := pb.pluginConfig.GetSelectedLane()
|
||||
step, ok := laneDirectionStep(direction)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
nextLane := currentLane + step
|
||||
if nextLane < 0 || nextLane >= len(pb.pluginDef.Lanes) {
|
||||
return false
|
||||
}
|
||||
|
||||
sourceTasks := filteredTasks(currentLane)
|
||||
rowOffsetInViewport := 0
|
||||
if len(sourceTasks) > 0 {
|
||||
sourceColumns := normalizeColumns(pb.pluginConfig.GetColumnsForLane(currentLane))
|
||||
sourceIndex := clampTaskIndex(pb.pluginConfig.GetSelectedIndexForLane(currentLane), len(sourceTasks))
|
||||
sourceRow := sourceIndex / sourceColumns
|
||||
maxSourceRow := maxRowIndex(len(sourceTasks), sourceColumns)
|
||||
sourceScroll := clampInt(pb.pluginConfig.GetScrollOffsetForLane(currentLane), maxSourceRow)
|
||||
rowOffsetInViewport = sourceRow - sourceScroll
|
||||
}
|
||||
|
||||
adjacentTasks := filteredTasks(nextLane)
|
||||
if len(adjacentTasks) > 0 {
|
||||
return pb.applyLaneSwitch(nextLane, adjacentTasks, direction, rowOffsetInViewport, true)
|
||||
}
|
||||
|
||||
// preserve existing skip-empty traversal order when adjacent lane is empty
|
||||
scanLane := nextLane + step
|
||||
for scanLane >= 0 && scanLane < len(pb.pluginDef.Lanes) {
|
||||
tasks := filteredTasks(scanLane)
|
||||
if len(tasks) > 0 {
|
||||
// skip-empty landing uses target viewport row semantics (no source row carry-over)
|
||||
return pb.applyLaneSwitch(scanLane, tasks, direction, 0, false)
|
||||
}
|
||||
scanLane += step
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (pb *pluginBase) applyLaneSwitch(targetLane int, targetTasks []*task.Task, direction string, rowOffsetInViewport int, preserveRow bool) bool {
|
||||
if len(targetTasks) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
targetColumns := normalizeColumns(pb.pluginConfig.GetColumnsForLane(targetLane))
|
||||
maxTargetRow := maxRowIndex(len(targetTasks), targetColumns)
|
||||
targetScroll := clampInt(pb.pluginConfig.GetScrollOffsetForLane(targetLane), maxTargetRow)
|
||||
targetRow := targetScroll
|
||||
if preserveRow {
|
||||
targetRow = clampInt(targetScroll+rowOffsetInViewport, maxTargetRow)
|
||||
}
|
||||
|
||||
targetIndex := rowDirectionalIndex(direction, targetRow, targetColumns, len(targetTasks))
|
||||
pb.pluginConfig.SetScrollOffsetForLane(targetLane, targetScroll)
|
||||
pb.pluginConfig.SetSelectedLaneAndIndex(targetLane, targetIndex)
|
||||
return true
|
||||
}
|
||||
|
||||
func laneDirectionStep(direction string) (int, bool) {
|
||||
switch direction {
|
||||
case "left":
|
||||
return -1, true
|
||||
case "right":
|
||||
return 1, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeColumns(columns int) int {
|
||||
if columns <= 0 {
|
||||
return 1
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
func clampTaskIndex(index int, taskCount int) int {
|
||||
if taskCount <= 0 {
|
||||
return 0
|
||||
}
|
||||
if index < 0 {
|
||||
return 0
|
||||
}
|
||||
if index >= taskCount {
|
||||
return taskCount - 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func maxRowIndex(taskCount int, columns int) int {
|
||||
if taskCount <= 0 {
|
||||
return 0
|
||||
}
|
||||
columns = normalizeColumns(columns)
|
||||
return (taskCount - 1) / columns
|
||||
}
|
||||
|
||||
func clampInt(value int, maxValue int) int {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > maxValue {
|
||||
return maxValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func moveVerticalIndex(direction string, index int, columns int, taskCount int) int {
|
||||
if taskCount <= 0 {
|
||||
return 0
|
||||
}
|
||||
columns = normalizeColumns(columns)
|
||||
index = clampTaskIndex(index, taskCount)
|
||||
|
||||
switch direction {
|
||||
case "up":
|
||||
next := index - columns
|
||||
if next >= 0 {
|
||||
return next
|
||||
}
|
||||
case "down":
|
||||
next := index + columns
|
||||
if next < taskCount {
|
||||
return next
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func moveHorizontalIndex(direction string, index int, columns int, taskCount int) (bool, int) {
|
||||
if taskCount <= 0 {
|
||||
return false, 0
|
||||
}
|
||||
columns = normalizeColumns(columns)
|
||||
index = clampTaskIndex(index, taskCount)
|
||||
col := index % columns
|
||||
|
||||
switch direction {
|
||||
case "left":
|
||||
if col > 0 {
|
||||
return true, index - 1
|
||||
}
|
||||
case "right":
|
||||
if col < columns-1 && index+1 < taskCount {
|
||||
return true, index + 1
|
||||
}
|
||||
}
|
||||
return false, index
|
||||
}
|
||||
|
||||
func rowDirectionalIndex(direction string, row int, columns int, taskCount int) int {
|
||||
if taskCount <= 0 {
|
||||
return 0
|
||||
}
|
||||
columns = normalizeColumns(columns)
|
||||
maxRow := maxRowIndex(taskCount, columns)
|
||||
row = clampInt(row, maxRow)
|
||||
rowStart := row * columns
|
||||
if rowStart >= taskCount {
|
||||
return taskCount - 1
|
||||
}
|
||||
|
||||
switch direction {
|
||||
case "left":
|
||||
rowEnd := rowStart + columns - 1
|
||||
if rowEnd >= taskCount {
|
||||
rowEnd = taskCount - 1
|
||||
}
|
||||
return rowEnd
|
||||
case "right":
|
||||
return rowStart
|
||||
default:
|
||||
return rowStart
|
||||
}
|
||||
}
|
||||
|
||||
func (pb *pluginBase) getSelectedTaskID(filteredTasks func(int) []*task.Task) string {
|
||||
lane := pb.pluginConfig.GetSelectedLane()
|
||||
tasks := filteredTasks(lane)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,54 @@ import (
|
|||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
type navHarness struct {
|
||||
pb *pluginBase
|
||||
config *model.PluginConfig
|
||||
byLane map[int][]*task.Task
|
||||
getLane func(int) []*task.Task
|
||||
}
|
||||
|
||||
func newNavHarness(columns []int, counts []int) *navHarness {
|
||||
lanes := make([]plugin.TikiLane, len(columns))
|
||||
for i := range columns {
|
||||
lanes[i] = plugin.TikiLane{Name: fmt.Sprintf("Lane-%d", i)}
|
||||
}
|
||||
|
||||
config := model.NewPluginConfig("TestPlugin")
|
||||
config.SetLaneLayout(columns, nil)
|
||||
|
||||
byLane := make(map[int][]*task.Task, len(counts))
|
||||
for lane, count := range counts {
|
||||
tasks := make([]*task.Task, count)
|
||||
for i := 0; i < count; i++ {
|
||||
tasks[i] = &task.Task{
|
||||
ID: fmt.Sprintf("T-%d-%d", lane, i),
|
||||
Title: "Task",
|
||||
Status: task.StatusReady,
|
||||
Type: task.TypeStory,
|
||||
}
|
||||
}
|
||||
byLane[lane] = tasks
|
||||
}
|
||||
|
||||
pb := &pluginBase{
|
||||
pluginConfig: config,
|
||||
pluginDef: &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: lanes,
|
||||
},
|
||||
}
|
||||
|
||||
return &navHarness{
|
||||
pb: pb,
|
||||
config: config,
|
||||
byLane: byLane,
|
||||
getLane: func(lane int) []*task.Task {
|
||||
return byLane[lane]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureFirstNonEmptyLaneSelectionSelectsFirstTask(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
if err := taskStore.CreateTask(&task.Task{
|
||||
|
|
@ -137,128 +185,321 @@ func TestEnsureFirstNonEmptyLaneSelectionNoTasks(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLaneSwitchSelectsTopOfViewport(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
// Create tasks for two lanes
|
||||
for i := 1; i <= 10; i++ {
|
||||
status := task.StatusReady
|
||||
if i > 5 {
|
||||
status = task.StatusInProgress
|
||||
}
|
||||
if err := taskStore.CreateTask(&task.Task{
|
||||
ID: fmt.Sprintf("T-%d", i),
|
||||
Title: "Task",
|
||||
Status: status,
|
||||
Type: task.TypeStory,
|
||||
}); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
func TestLaneSwitchAdjacentNonEmptyPreservesViewportRow_RightLandsLeftmost(t *testing.T) {
|
||||
h := newNavHarness([]int{2, 3}, []int{8, 12})
|
||||
h.config.SetSelectedLane(0)
|
||||
h.config.SetSelectedIndexForLane(0, 5) // row 2, col 1 (right edge)
|
||||
h.config.SetScrollOffsetForLane(0, 1) // source row offset in viewport = 1
|
||||
h.config.SetScrollOffsetForLane(1, 2)
|
||||
|
||||
if !h.pb.handleNav("right", h.getLane) {
|
||||
t.Fatal("expected right lane switch to succeed")
|
||||
}
|
||||
|
||||
readyFilter, err := filter.ParseFilter("status = 'ready'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
}
|
||||
inProgressFilter, err := filter.ParseFilter("status = 'in_progress'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
if got := h.config.GetSelectedLane(); got != 1 {
|
||||
t.Fatalf("expected selected lane 1, got %d", got)
|
||||
}
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{
|
||||
Name: "TestPlugin",
|
||||
},
|
||||
Lanes: []plugin.TikiLane{
|
||||
{Name: "Ready", Columns: 1, Filter: readyFilter},
|
||||
{Name: "InProgress", Columns: 1, Filter: inProgressFilter},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1, 1}, nil)
|
||||
|
||||
// Start in lane 0 (Ready), with selection at index 2
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetSelectedIndexForLane(0, 2)
|
||||
|
||||
// Simulate that lane 1 has been scrolled to offset 3
|
||||
pluginConfig.SetScrollOffsetForLane(1, 3)
|
||||
|
||||
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil, nil)
|
||||
|
||||
// Navigate right to lane 1
|
||||
pc.HandleAction(ActionNavRight)
|
||||
|
||||
// Should be in lane 1
|
||||
if pluginConfig.GetSelectedLane() != 1 {
|
||||
t.Fatalf("expected selected lane 1, got %d", pluginConfig.GetSelectedLane())
|
||||
}
|
||||
|
||||
// Selection should be at scroll offset (top of viewport), not stale index
|
||||
if pluginConfig.GetSelectedIndexForLane(1) != 3 {
|
||||
t.Errorf("expected selection at scroll offset 3, got %d", pluginConfig.GetSelectedIndexForLane(1))
|
||||
// target row: 2(scroll) + 1(offset) = 3, moving right lands at row start
|
||||
if got := h.config.GetSelectedIndexForLane(1); got != 9 {
|
||||
t.Fatalf("expected selected index 9, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaneSwitchClampsScrollOffsetToTaskCount(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
// Create 3 tasks in lane 1 only
|
||||
for i := 1; i <= 3; i++ {
|
||||
if err := taskStore.CreateTask(&task.Task{
|
||||
ID: fmt.Sprintf("T-%d", i),
|
||||
Title: "Task",
|
||||
Status: task.StatusInProgress,
|
||||
Type: task.TypeStory,
|
||||
}); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
func TestLaneSwitchAdjacentNonEmptyPreservesViewportRow_LeftLandsRightmostPopulated(t *testing.T) {
|
||||
h := newNavHarness([]int{4, 3}, []int{6, 8})
|
||||
h.config.SetSelectedLane(1)
|
||||
h.config.SetSelectedIndexForLane(1, 3) // row 1, col 0 (left edge)
|
||||
h.config.SetScrollOffsetForLane(1, 1) // source row offset in viewport = 0
|
||||
h.config.SetScrollOffsetForLane(0, 1)
|
||||
|
||||
if !h.pb.handleNav("left", h.getLane) {
|
||||
t.Fatal("expected left lane switch to succeed")
|
||||
}
|
||||
|
||||
// Lane 0 is empty, lane 1 has 3 tasks
|
||||
emptyFilter, err := filter.ParseFilter("status = 'ready'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
}
|
||||
inProgressFilter, err := filter.ParseFilter("status = 'in_progress'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
if got := h.config.GetSelectedLane(); got != 0 {
|
||||
t.Fatalf("expected selected lane 0, got %d", got)
|
||||
}
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{
|
||||
Name: "TestPlugin",
|
||||
},
|
||||
Lanes: []plugin.TikiLane{
|
||||
{Name: "Empty", Columns: 1, Filter: emptyFilter},
|
||||
{Name: "InProgress", Columns: 1, Filter: inProgressFilter},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1, 1}, nil)
|
||||
|
||||
// Start in lane 1
|
||||
pluginConfig.SetSelectedLane(1)
|
||||
pluginConfig.SetSelectedIndexForLane(1, 0)
|
||||
|
||||
// Set a stale scroll offset that exceeds the task count
|
||||
pluginConfig.SetScrollOffsetForLane(1, 10)
|
||||
|
||||
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil, nil)
|
||||
|
||||
// Navigate left (to empty lane, will skip to... well, nowhere)
|
||||
// Then try to go right from a fresh setup
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetScrollOffsetForLane(1, 10) // stale offset > task count
|
||||
|
||||
pc.HandleAction(ActionNavRight)
|
||||
|
||||
// Should be in lane 1
|
||||
if pluginConfig.GetSelectedLane() != 1 {
|
||||
t.Fatalf("expected selected lane 1, got %d", pluginConfig.GetSelectedLane())
|
||||
}
|
||||
|
||||
// Selection should be clamped to last valid index (2, since 3 tasks)
|
||||
selectedIdx := pluginConfig.GetSelectedIndexForLane(1)
|
||||
if selectedIdx < 0 || selectedIdx >= 3 {
|
||||
t.Errorf("expected selection clamped to valid range [0,2], got %d", selectedIdx)
|
||||
// target row 1 in a partial row (indices 4..5), moving left lands on 5
|
||||
if got := h.config.GetSelectedIndexForLane(0); got != 5 {
|
||||
t.Fatalf("expected selected index 5, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaneSwitchSkipEmptyDoesNotCarrySourceRowOffset_Right(t *testing.T) {
|
||||
h := newNavHarness([]int{2, 2, 3}, []int{8, 0, 9})
|
||||
h.config.SetSelectedLane(0)
|
||||
h.config.SetSelectedIndexForLane(0, 7) // row 3, col 1
|
||||
h.config.SetScrollOffsetForLane(0, 0) // large source row offset should be ignored
|
||||
h.config.SetScrollOffsetForLane(2, 1)
|
||||
|
||||
if !h.pb.handleNav("right", h.getLane) {
|
||||
t.Fatal("expected skip-empty right lane switch to succeed")
|
||||
}
|
||||
|
||||
if got := h.config.GetSelectedLane(); got != 2 {
|
||||
t.Fatalf("expected selected lane 2, got %d", got)
|
||||
}
|
||||
|
||||
// skip-empty landing uses target viewport row only: row 1 start in 3 columns => index 3
|
||||
if got := h.config.GetSelectedIndexForLane(2); got != 3 {
|
||||
t.Fatalf("expected selected index 3, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaneSwitchSkipEmptyDoesNotCarrySourceRowOffset_Left(t *testing.T) {
|
||||
h := newNavHarness([]int{4, 1, 2}, []int{6, 0, 5})
|
||||
h.config.SetSelectedLane(2)
|
||||
h.config.SetSelectedIndexForLane(2, 4) // row 2, col 0
|
||||
h.config.SetScrollOffsetForLane(2, 0) // source row offset should be ignored
|
||||
h.config.SetScrollOffsetForLane(0, 1)
|
||||
|
||||
if !h.pb.handleNav("left", h.getLane) {
|
||||
t.Fatal("expected skip-empty left lane switch to succeed")
|
||||
}
|
||||
|
||||
if got := h.config.GetSelectedLane(); got != 0 {
|
||||
t.Fatalf("expected selected lane 0, got %d", got)
|
||||
}
|
||||
|
||||
// row 1 in lane 0 is partial (indices 4..5), moving left lands on index 5
|
||||
if got := h.config.GetSelectedIndexForLane(0); got != 5 {
|
||||
t.Fatalf("expected selected index 5, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaneSwitchMultiEmptyChainPreservesTraversalOrder(t *testing.T) {
|
||||
h := newNavHarness([]int{1, 1, 1, 2}, []int{3, 0, 0, 4})
|
||||
h.config.SetSelectedLane(0)
|
||||
h.config.SetSelectedIndexForLane(0, 2)
|
||||
h.config.SetScrollOffsetForLane(3, 1)
|
||||
|
||||
if !h.pb.handleNav("right", h.getLane) {
|
||||
t.Fatal("expected right lane switch to succeed across empty chain")
|
||||
}
|
||||
|
||||
if got := h.config.GetSelectedLane(); got != 3 {
|
||||
t.Fatalf("expected selected lane 3, got %d", got)
|
||||
}
|
||||
|
||||
// skip-empty landing uses lane 3 viewport row 1, direction right => row start index 2
|
||||
if got := h.config.GetSelectedIndexForLane(3); got != 2 {
|
||||
t.Fatalf("expected selected index 2, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaneSwitchNoReachableTargetIsStrictNoOp(t *testing.T) {
|
||||
h := newNavHarness([]int{2, 2, 2}, []int{5, 0, 0})
|
||||
h.config.SetSelectedLane(0)
|
||||
h.config.SetSelectedIndexForLane(0, 4)
|
||||
h.config.SetScrollOffsetForLane(0, 3)
|
||||
h.config.SetScrollOffsetForLane(1, 7)
|
||||
h.config.SetScrollOffsetForLane(2, 8)
|
||||
|
||||
callbacks := 0
|
||||
listenerID := h.config.AddSelectionListener(func() { callbacks++ })
|
||||
defer h.config.RemoveSelectionListener(listenerID)
|
||||
|
||||
if h.pb.handleNav("right", h.getLane) {
|
||||
t.Fatal("expected right action to be a no-op with no reachable lane")
|
||||
}
|
||||
|
||||
if got := h.config.GetSelectedLane(); got != 0 {
|
||||
t.Fatalf("expected lane 0, got %d", got)
|
||||
}
|
||||
if got := h.config.GetSelectedIndexForLane(0); got != 4 {
|
||||
t.Fatalf("expected selected index to remain 4, got %d", got)
|
||||
}
|
||||
if got := h.config.GetScrollOffsetForLane(0); got != 3 {
|
||||
t.Fatalf("expected lane 0 scroll offset 3, got %d", got)
|
||||
}
|
||||
if got := h.config.GetScrollOffsetForLane(1); got != 7 {
|
||||
t.Fatalf("expected lane 1 scroll offset 7, got %d", got)
|
||||
}
|
||||
if got := h.config.GetScrollOffsetForLane(2); got != 8 {
|
||||
t.Fatalf("expected lane 2 scroll offset 8, got %d", got)
|
||||
}
|
||||
if callbacks != 0 {
|
||||
t.Fatalf("expected 0 selection callbacks, got %d", callbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNavVerticalStaleIndexRecoveryNotifiesOnce(t *testing.T) {
|
||||
t.Run("stale index at down boundary is healed", func(t *testing.T) {
|
||||
h := newNavHarness([]int{2}, []int{6})
|
||||
h.config.SetSelectedLane(0)
|
||||
h.config.SetSelectedIndexForLane(0, 99)
|
||||
|
||||
callbacks := 0
|
||||
listenerID := h.config.AddSelectionListener(func() { callbacks++ })
|
||||
defer h.config.RemoveSelectionListener(listenerID)
|
||||
|
||||
if !h.pb.handleNav("down", h.getLane) {
|
||||
t.Fatal("expected stale down action to heal selection")
|
||||
}
|
||||
if got := h.config.GetSelectedIndexForLane(0); got != 5 {
|
||||
t.Fatalf("expected healed index 5, got %d", got)
|
||||
}
|
||||
if callbacks != 1 {
|
||||
t.Fatalf("expected 1 selection callback, got %d", callbacks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale negative index at up boundary is healed", func(t *testing.T) {
|
||||
h := newNavHarness([]int{2}, []int{6})
|
||||
h.config.SetSelectedLane(0)
|
||||
h.config.SetSelectedIndexForLane(0, -5)
|
||||
|
||||
callbacks := 0
|
||||
listenerID := h.config.AddSelectionListener(func() { callbacks++ })
|
||||
defer h.config.RemoveSelectionListener(listenerID)
|
||||
|
||||
if !h.pb.handleNav("up", h.getLane) {
|
||||
t.Fatal("expected stale up action to heal selection")
|
||||
}
|
||||
if got := h.config.GetSelectedIndexForLane(0); got != 0 {
|
||||
t.Fatalf("expected healed index 0, got %d", got)
|
||||
}
|
||||
if callbacks != 1 {
|
||||
t.Fatalf("expected 1 selection callback, got %d", callbacks)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleNavVerticalInRangeBoundaryNoOpHasNoNotification(t *testing.T) {
|
||||
h := newNavHarness([]int{2}, []int{6})
|
||||
h.config.SetSelectedLane(0)
|
||||
h.config.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
callbacks := 0
|
||||
listenerID := h.config.AddSelectionListener(func() { callbacks++ })
|
||||
defer h.config.RemoveSelectionListener(listenerID)
|
||||
|
||||
if h.pb.handleNav("up", h.getLane) {
|
||||
t.Fatal("expected up at top boundary to return false")
|
||||
}
|
||||
if got := h.config.GetSelectedIndexForLane(0); got != 0 {
|
||||
t.Fatalf("expected index 0, got %d", got)
|
||||
}
|
||||
if callbacks != 0 {
|
||||
t.Fatalf("expected 0 callbacks, got %d", callbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNavHorizontalStaleIndexNoTargetDoesNotPersistNormalization(t *testing.T) {
|
||||
h := newNavHarness([]int{2}, []int{3})
|
||||
h.config.SetSelectedLane(0)
|
||||
h.config.SetSelectedIndexForLane(0, 99)
|
||||
|
||||
callbacks := 0
|
||||
listenerID := h.config.AddSelectionListener(func() { callbacks++ })
|
||||
defer h.config.RemoveSelectionListener(listenerID)
|
||||
|
||||
if h.pb.handleNav("right", h.getLane) {
|
||||
t.Fatal("expected right with no reachable target to return false")
|
||||
}
|
||||
if got := h.config.GetSelectedIndexForLane(0); got != 99 {
|
||||
t.Fatalf("expected stale index to remain 99 on strict no-op, got %d", got)
|
||||
}
|
||||
if callbacks != 0 {
|
||||
t.Fatalf("expected 0 callbacks, got %d", callbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNavHorizontalStaleIndexInLaneMovePersistsFinalIndex(t *testing.T) {
|
||||
h := newNavHarness([]int{3}, []int{5})
|
||||
h.config.SetSelectedLane(0)
|
||||
h.config.SetSelectedIndexForLane(0, 99)
|
||||
|
||||
callbacks := 0
|
||||
listenerID := h.config.AddSelectionListener(func() { callbacks++ })
|
||||
defer h.config.RemoveSelectionListener(listenerID)
|
||||
|
||||
if !h.pb.handleNav("left", h.getLane) {
|
||||
t.Fatal("expected left move from stale index to succeed in-lane")
|
||||
}
|
||||
if got := h.config.GetSelectedIndexForLane(0); got != 3 {
|
||||
t.Fatalf("expected index 3 after clamped in-lane move, got %d", got)
|
||||
}
|
||||
if callbacks != 1 {
|
||||
t.Fatalf("expected 1 callback, got %d", callbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaneSwitchClampsTargetScrollAndPersistsClampedValue(t *testing.T) {
|
||||
h := newNavHarness([]int{1, 2}, []int{1, 3})
|
||||
h.config.SetSelectedLane(0)
|
||||
h.config.SetSelectedIndexForLane(0, 0)
|
||||
h.config.SetScrollOffsetForLane(1, 10)
|
||||
|
||||
if !h.pb.handleNav("right", h.getLane) {
|
||||
t.Fatal("expected right lane switch to succeed")
|
||||
}
|
||||
|
||||
if got := h.config.GetSelectedLane(); got != 1 {
|
||||
t.Fatalf("expected lane 1, got %d", got)
|
||||
}
|
||||
// lane 1 has max row 1, moving right lands at row-start index 2
|
||||
if got := h.config.GetSelectedIndexForLane(1); got != 2 {
|
||||
t.Fatalf("expected selected index 2, got %d", got)
|
||||
}
|
||||
if got := h.config.GetScrollOffsetForLane(1); got != 1 {
|
||||
t.Fatalf("expected clamped and persisted scroll offset 1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaneSwitchClampsStaleSourceScrollBeforeRowMath(t *testing.T) {
|
||||
h := newNavHarness([]int{2, 2}, []int{6, 6})
|
||||
h.config.SetSelectedLane(0)
|
||||
h.config.SetSelectedIndexForLane(0, 3) // row 1, col 1 (right edge)
|
||||
h.config.SetScrollOffsetForLane(0, -5) // stale source scroll should clamp to 0
|
||||
h.config.SetScrollOffsetForLane(1, 0)
|
||||
|
||||
if !h.pb.handleNav("right", h.getLane) {
|
||||
t.Fatal("expected right lane switch to succeed")
|
||||
}
|
||||
if got := h.config.GetSelectedLane(); got != 1 {
|
||||
t.Fatalf("expected lane 1, got %d", got)
|
||||
}
|
||||
// source row 1 with clamped source scroll 0 => row offset 1 => target row 1 => index 2
|
||||
if got := h.config.GetSelectedIndexForLane(1); got != 2 {
|
||||
t.Fatalf("expected selected index 2, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaneSwitchSuccessfulActionKeepsUnrelatedLaneScrollOffsets(t *testing.T) {
|
||||
h := newNavHarness([]int{1, 1, 1}, []int{1, 1, 2})
|
||||
h.config.SetSelectedLane(0)
|
||||
h.config.SetSelectedIndexForLane(0, 0)
|
||||
h.config.SetScrollOffsetForLane(2, 5)
|
||||
|
||||
if !h.pb.handleNav("right", h.getLane) {
|
||||
t.Fatal("expected right lane switch to succeed")
|
||||
}
|
||||
if got := h.config.GetSelectedLane(); got != 1 {
|
||||
t.Fatalf("expected lane 1, got %d", got)
|
||||
}
|
||||
if got := h.config.GetScrollOffsetForLane(2); got != 5 {
|
||||
t.Fatalf("expected unrelated lane scroll offset 5, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaneSwitchFromEmptySourceUsesTopViewportContext(t *testing.T) {
|
||||
h := newNavHarness([]int{2, 2}, []int{0, 6})
|
||||
h.config.SetSelectedLane(0)
|
||||
h.config.SetSelectedIndexForLane(0, 42)
|
||||
h.config.SetScrollOffsetForLane(1, 2)
|
||||
|
||||
if !h.pb.handleNav("right", h.getLane) {
|
||||
t.Fatal("expected right lane switch from empty source to succeed")
|
||||
}
|
||||
|
||||
if got := h.config.GetSelectedLane(); got != 1 {
|
||||
t.Fatalf("expected lane 1, got %d", got)
|
||||
}
|
||||
// empty source forces row offset 0, so landed row is target scroll row (2)
|
||||
if got := h.config.GetSelectedIndexForLane(1); got != 4 {
|
||||
t.Fatalf("expected selected index 4, got %d", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue