mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
622 lines
20 KiB
Go
622 lines
20 KiB
Go
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/boolean-maybe/tiki/model"
|
|
"github.com/boolean-maybe/tiki/plugin"
|
|
"github.com/boolean-maybe/tiki/store"
|
|
"github.com/boolean-maybe/tiki/task"
|
|
)
|
|
|
|
const (
|
|
testCtxID = "TIKI-AACTX0"
|
|
testBlkID = "TIKI-AABLK0"
|
|
testDepID = "TIKI-AADEP0"
|
|
testFreeID = "TIKI-AAFRE0"
|
|
)
|
|
|
|
// newDepsTestEnv sets up a deps editor test environment with:
|
|
// - contextTask whose dependsOn contains testDepID
|
|
// - blockerTask whose dependsOn contains testCtxID
|
|
// - dependsTask with no deps
|
|
// - freeTask with no dependency relationship
|
|
func newDepsTestEnv(t *testing.T) (*DepsController, store.Store) {
|
|
t.Helper()
|
|
taskStore := store.NewInMemoryStore()
|
|
|
|
tasks := []*task.Task{
|
|
{ID: testCtxID, Title: "Context", Status: task.StatusReady, Type: task.TypeStory, Priority: 3, DependsOn: []string{testDepID}},
|
|
{ID: testBlkID, Title: "Blocker", Status: task.StatusReady, Type: task.TypeStory, Priority: 3, DependsOn: []string{testCtxID}},
|
|
{ID: testDepID, Title: "Depends", Status: task.StatusReady, Type: task.TypeStory, Priority: 3},
|
|
{ID: testFreeID, Title: "Free", Status: task.StatusReady, Type: task.TypeStory, Priority: 3},
|
|
}
|
|
for _, tt := range tasks {
|
|
if err := taskStore.CreateTask(tt); err != nil {
|
|
t.Fatalf("create task %s: %v", tt.ID, err)
|
|
}
|
|
}
|
|
|
|
pluginDef := &plugin.TikiPlugin{
|
|
BasePlugin: plugin.BasePlugin{Name: "Dependency:" + testCtxID, ConfigIndex: -1, Type: "tiki"},
|
|
TaskID: testCtxID,
|
|
Lanes: []plugin.TikiLane{{Name: "Blocks"}, {Name: "All"}, {Name: "Depends"}},
|
|
}
|
|
pluginConfig := model.NewPluginConfig("Dependency")
|
|
pluginConfig.SetLaneLayout([]int{1, 2, 1}, nil)
|
|
|
|
nav := newMockNavigationController()
|
|
dc := NewDepsController(taskStore, pluginConfig, pluginDef, nav, nil)
|
|
return dc, taskStore
|
|
}
|
|
|
|
func taskIDs(tasks []*task.Task) []string {
|
|
ids := make([]string, len(tasks))
|
|
for i, t := range tasks {
|
|
ids[i] = t.ID
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func TestDepsController_GetFilteredTasksForLane(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
|
|
t.Run("all lane excludes context, blocks, and depends", func(t *testing.T) {
|
|
all := dc.GetFilteredTasksForLane(depsLaneAll)
|
|
ids := taskIDs(all)
|
|
if slices.Contains(ids, testCtxID) {
|
|
t.Error("all lane should not contain context task")
|
|
}
|
|
if slices.Contains(ids, testBlkID) {
|
|
t.Error("all lane should not contain blocker task")
|
|
}
|
|
if slices.Contains(ids, testDepID) {
|
|
t.Error("all lane should not contain depends task")
|
|
}
|
|
if !slices.Contains(ids, testFreeID) {
|
|
t.Error("all lane should contain free task")
|
|
}
|
|
})
|
|
|
|
t.Run("blocks lane contains tasks that depend on context", func(t *testing.T) {
|
|
blocks := dc.GetFilteredTasksForLane(depsLaneBlocks)
|
|
ids := taskIDs(blocks)
|
|
if !slices.Contains(ids, testBlkID) {
|
|
t.Error("blocks lane should contain blocker task")
|
|
}
|
|
if len(ids) != 1 {
|
|
t.Errorf("blocks lane should have exactly 1 task, got %d: %v", len(ids), ids)
|
|
}
|
|
})
|
|
|
|
t.Run("depends lane contains context task dependencies", func(t *testing.T) {
|
|
depends := dc.GetFilteredTasksForLane(depsLaneDepends)
|
|
ids := taskIDs(depends)
|
|
if !slices.Contains(ids, testDepID) {
|
|
t.Error("depends lane should contain depends task")
|
|
}
|
|
if len(ids) != 1 {
|
|
t.Errorf("depends lane should have exactly 1 task, got %d: %v", len(ids), ids)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid lane returns nil", func(t *testing.T) {
|
|
if dc.GetFilteredTasksForLane(-1) != nil {
|
|
t.Error("invalid lane should return nil")
|
|
}
|
|
if dc.GetFilteredTasksForLane(3) != nil {
|
|
t.Error("out of range lane should return nil")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDepsController_MoveTask_AllToBlocks(t *testing.T) {
|
|
dc, taskStore := newDepsTestEnv(t)
|
|
|
|
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
|
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
|
|
|
|
if !dc.handleMoveTask(-1) {
|
|
t.Fatal("move should succeed")
|
|
}
|
|
|
|
// free task should now have context task in its dependsOn
|
|
free := taskStore.GetTask(testFreeID)
|
|
if !slices.Contains(free.DependsOn, testCtxID) {
|
|
t.Errorf("free.DependsOn should contain %s, got %v", testCtxID, free.DependsOn)
|
|
}
|
|
}
|
|
|
|
func TestDepsController_MoveTask_AllToDepends(t *testing.T) {
|
|
dc, taskStore := newDepsTestEnv(t)
|
|
|
|
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
|
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
|
|
|
|
if !dc.handleMoveTask(1) {
|
|
t.Fatal("move should succeed")
|
|
}
|
|
|
|
// context task should now have free task in its dependsOn
|
|
ctx := taskStore.GetTask(testCtxID)
|
|
if !slices.Contains(ctx.DependsOn, testFreeID) {
|
|
t.Errorf("ctx.DependsOn should contain %s, got %v", testFreeID, ctx.DependsOn)
|
|
}
|
|
}
|
|
|
|
func TestDepsController_MoveTask_BlocksToAll(t *testing.T) {
|
|
dc, taskStore := newDepsTestEnv(t)
|
|
|
|
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
|
|
dc.pluginConfig.SetSelectedIndexForLane(depsLaneBlocks, 0)
|
|
|
|
if !dc.handleMoveTask(1) {
|
|
t.Fatal("move should succeed")
|
|
}
|
|
|
|
blk := taskStore.GetTask(testBlkID)
|
|
if slices.Contains(blk.DependsOn, testCtxID) {
|
|
t.Errorf("blk.DependsOn should not contain %s after move, got %v", testCtxID, blk.DependsOn)
|
|
}
|
|
}
|
|
|
|
func TestDepsController_MoveTask_DependsToAll(t *testing.T) {
|
|
dc, taskStore := newDepsTestEnv(t)
|
|
|
|
dc.pluginConfig.SetSelectedLane(depsLaneDepends)
|
|
dc.pluginConfig.SetSelectedIndexForLane(depsLaneDepends, 0)
|
|
|
|
if !dc.handleMoveTask(-1) {
|
|
t.Fatal("move should succeed")
|
|
}
|
|
|
|
ctx := taskStore.GetTask(testCtxID)
|
|
if slices.Contains(ctx.DependsOn, testDepID) {
|
|
t.Errorf("ctx.DependsOn should not contain %s after move, got %v", testDepID, ctx.DependsOn)
|
|
}
|
|
}
|
|
|
|
func TestDepsController_MoveTask_OutOfBounds(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
|
|
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
|
|
dc.pluginConfig.SetSelectedIndexForLane(depsLaneBlocks, 0)
|
|
|
|
if dc.handleMoveTask(-1) {
|
|
t.Error("move left from lane 0 should fail")
|
|
}
|
|
|
|
dc.pluginConfig.SetSelectedLane(depsLaneDepends)
|
|
dc.pluginConfig.SetSelectedIndexForLane(depsLaneDepends, 0)
|
|
|
|
if dc.handleMoveTask(1) {
|
|
t.Error("move right from lane 2 should fail")
|
|
}
|
|
}
|
|
|
|
func TestDepsController_MoveTask_RejectsMultiLaneJump(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
|
|
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
|
|
dc.pluginConfig.SetSelectedIndexForLane(depsLaneBlocks, 0)
|
|
|
|
if dc.handleMoveTask(2) {
|
|
t.Error("offset=2 should be rejected")
|
|
}
|
|
if dc.handleMoveTask(-2) {
|
|
t.Error("offset=-2 should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestDepsController_HandleSearch(t *testing.T) {
|
|
t.Run("empty query is no-op", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
dc.HandleSearch("")
|
|
if dc.pluginConfig.GetSearchResults() != nil {
|
|
t.Error("empty query should not set search results")
|
|
}
|
|
})
|
|
|
|
t.Run("matching query", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
dc.HandleSearch("Free")
|
|
results := dc.pluginConfig.GetSearchResults()
|
|
if results == nil {
|
|
t.Fatal("expected search results, got nil")
|
|
}
|
|
found := false
|
|
for _, sr := range results {
|
|
if sr.Task.ID == testFreeID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected search results to contain %s", testFreeID)
|
|
}
|
|
})
|
|
|
|
t.Run("non-matching query", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
dc.HandleSearch("zzzzz")
|
|
results := dc.pluginConfig.GetSearchResults()
|
|
if results == nil {
|
|
t.Fatal("expected empty search results slice, got nil")
|
|
}
|
|
if len(results) != 0 {
|
|
t.Errorf("expected 0 results, got %d", len(results))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDepsController_HandleAction(t *testing.T) {
|
|
t.Run("nav down changes index", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
|
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
|
|
// All lane has only free task, so nav down should return false (can't go past end)
|
|
dc.HandleAction(ActionNavDown)
|
|
// just verify it doesn't panic and returns a bool
|
|
})
|
|
|
|
t.Run("nav left from All switches to Blocks", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
|
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
|
|
result := dc.HandleAction(ActionNavLeft)
|
|
if !result {
|
|
t.Error("nav left from All should succeed (Blocks has tasks)")
|
|
}
|
|
if dc.pluginConfig.GetSelectedLane() != depsLaneBlocks {
|
|
t.Errorf("expected lane %d, got %d", depsLaneBlocks, dc.pluginConfig.GetSelectedLane())
|
|
}
|
|
})
|
|
|
|
t.Run("nav right from All switches to Depends", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
|
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
|
|
result := dc.HandleAction(ActionNavRight)
|
|
if !result {
|
|
t.Error("nav right from All should succeed (Depends has tasks)")
|
|
}
|
|
if dc.pluginConfig.GetSelectedLane() != depsLaneDepends {
|
|
t.Errorf("expected lane %d, got %d", depsLaneDepends, dc.pluginConfig.GetSelectedLane())
|
|
}
|
|
})
|
|
|
|
t.Run("toggle view mode", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
before := dc.pluginConfig.GetViewMode()
|
|
result := dc.HandleAction(ActionToggleViewMode)
|
|
if !result {
|
|
t.Error("toggle view mode should return true")
|
|
}
|
|
after := dc.pluginConfig.GetViewMode()
|
|
if before == after {
|
|
t.Error("view mode should change after toggle")
|
|
}
|
|
})
|
|
|
|
t.Run("open task pushes detail view", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
|
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
|
|
result := dc.HandleAction(ActionOpenFromPlugin)
|
|
if !result {
|
|
t.Error("open should succeed when a task is selected")
|
|
}
|
|
top := dc.navController.navState.currentView()
|
|
if top == nil || top.ViewID != model.TaskDetailViewID {
|
|
t.Error("expected TaskDetailViewID to be pushed")
|
|
}
|
|
})
|
|
|
|
t.Run("new task pushes edit view", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
result := dc.HandleAction(ActionNewTask)
|
|
if !result {
|
|
t.Error("new task should succeed")
|
|
}
|
|
top := dc.navController.navState.currentView()
|
|
if top == nil || top.ViewID != model.TaskEditViewID {
|
|
t.Error("expected TaskEditViewID to be pushed")
|
|
}
|
|
})
|
|
|
|
t.Run("delete task removes from store", func(t *testing.T) {
|
|
dc, taskStore := newDepsTestEnv(t)
|
|
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
|
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
|
|
|
|
// free task should be in the All lane
|
|
allTasks := dc.GetFilteredTasksForLane(depsLaneAll)
|
|
if len(allTasks) == 0 {
|
|
t.Fatal("expected at least one task in All lane")
|
|
}
|
|
deletedID := allTasks[0].ID
|
|
|
|
result := dc.HandleAction(ActionDeleteTask)
|
|
if !result {
|
|
t.Error("delete should succeed when a task is selected")
|
|
}
|
|
if taskStore.GetTask(deletedID) != nil {
|
|
t.Errorf("task %s should have been deleted", deletedID)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid action returns false", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
if dc.HandleAction("nonexistent_action") {
|
|
t.Error("unknown action should return false")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDepsController_HandleLaneSwitch(t *testing.T) {
|
|
t.Run("right from Blocks lands on All", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
|
|
result := dc.handleLaneSwitch("right", dc.GetFilteredTasksForLane)
|
|
if !result {
|
|
t.Error("should succeed — All lane has tasks")
|
|
}
|
|
if dc.pluginConfig.GetSelectedLane() != depsLaneAll {
|
|
t.Errorf("expected lane %d, got %d", depsLaneAll, dc.pluginConfig.GetSelectedLane())
|
|
}
|
|
})
|
|
|
|
t.Run("left from All lands on Blocks", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
|
result := dc.handleLaneSwitch("left", dc.GetFilteredTasksForLane)
|
|
if !result {
|
|
t.Error("should succeed — Blocks lane has tasks")
|
|
}
|
|
if dc.pluginConfig.GetSelectedLane() != depsLaneBlocks {
|
|
t.Errorf("expected lane %d, got %d", depsLaneBlocks, dc.pluginConfig.GetSelectedLane())
|
|
}
|
|
})
|
|
|
|
t.Run("left from Blocks returns false (boundary)", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
|
|
if dc.handleLaneSwitch("left", dc.GetFilteredTasksForLane) {
|
|
t.Error("should fail — no lane to the left of Blocks")
|
|
}
|
|
})
|
|
|
|
t.Run("right from Depends returns false (boundary)", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
dc.pluginConfig.SetSelectedLane(depsLaneDepends)
|
|
if dc.handleLaneSwitch("right", dc.GetFilteredTasksForLane) {
|
|
t.Error("should fail — no lane to the right of Depends")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDepsController_EnsureFirstNonEmptyLaneSelection(t *testing.T) {
|
|
t.Run("current lane has tasks — no change", func(t *testing.T) {
|
|
dc, _ := newDepsTestEnv(t)
|
|
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
|
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
|
|
if dc.EnsureFirstNonEmptyLaneSelection() {
|
|
t.Error("should return false when current lane has tasks")
|
|
}
|
|
if dc.pluginConfig.GetSelectedLane() != depsLaneAll {
|
|
t.Error("lane should not change")
|
|
}
|
|
})
|
|
|
|
t.Run("current lane empty — switches to first non-empty", func(t *testing.T) {
|
|
dc, taskStore := newDepsTestEnv(t)
|
|
// move free task into depends so All lane becomes empty
|
|
free := taskStore.GetTask(testFreeID)
|
|
free.DependsOn = []string{testCtxID}
|
|
if err := taskStore.UpdateTask(free); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
|
result := dc.EnsureFirstNonEmptyLaneSelection()
|
|
if !result {
|
|
t.Error("should return true when lane was empty and switch occurred")
|
|
}
|
|
newLane := dc.pluginConfig.GetSelectedLane()
|
|
if newLane == depsLaneAll {
|
|
t.Error("should have switched away from empty All lane")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDepsViewActions(t *testing.T) {
|
|
registry := DepsViewActions()
|
|
actions := registry.GetActions()
|
|
|
|
required := map[ActionID]bool{
|
|
ActionNavUp: false,
|
|
ActionNavDown: false,
|
|
ActionMoveTaskLeft: false,
|
|
ActionMoveTaskRight: false,
|
|
ActionOpenFromPlugin: false,
|
|
ActionNewTask: false,
|
|
ActionDeleteTask: false,
|
|
ActionToggleViewMode: false,
|
|
ActionSearch: false,
|
|
}
|
|
for _, a := range actions {
|
|
if _, ok := required[a.ID]; ok {
|
|
required[a.ID] = true
|
|
}
|
|
}
|
|
for id, found := range required {
|
|
if !found {
|
|
t.Errorf("DepsViewActions missing required action %s", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|