improve navigation in plugin

This commit is contained in:
booleanmaybe 2026-04-07 23:10:12 -04:00
parent 676a0ec97b
commit 5a8918b904
3 changed files with 730 additions and 146 deletions

View file

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

View file

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

View file

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