tiki/integration/plugin_navigation_test.go
2026-01-24 23:35:04 -05:00

950 lines
32 KiB
Go

package integration
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/boolean-maybe/tiki/controller"
"github.com/boolean-maybe/tiki/model"
taskpkg "github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/testutil"
"github.com/gdamore/tcell/v2"
)
// ============================================================================
// Test Data Helpers
// ============================================================================
// setupPluginTestData creates tasks matching all three embedded plugin filters:
// - Backlog: status = 'backlog'
// - Recent: UpdatedAt within 2 hours
// - Roadmap: type = 'epic'
func setupPluginTestData(t *testing.T, ta *testutil.TestApp) {
tasks := []struct {
id string
title string
status taskpkg.Status
taskType taskpkg.Type
recent bool // needs UpdatedAt within 2 hours
}{
// Backlog plugin: status = 'backlog'
{"TIKI-1", "Backlog Task 1", taskpkg.StatusBacklog, taskpkg.TypeStory, false},
{"TIKI-2", "Backlog Task 2", taskpkg.StatusBacklog, taskpkg.TypeBug, false},
// Recent plugin: UpdatedAt within 2 hours
{"TIKI-3", "Recent Task 1", taskpkg.StatusReady, taskpkg.TypeStory, true},
{"TIKI-4", "Recent Task 2", taskpkg.StatusInProgress, taskpkg.TypeBug, true},
// Roadmap plugin: type = 'epic'
{"TIKI-5", "Roadmap Epic 1", taskpkg.StatusReady, taskpkg.TypeEpic, false},
{"TIKI-6", "Roadmap Epic 2", taskpkg.StatusInProgress, taskpkg.TypeEpic, false},
// Multi-plugin match
{"TIKI-7", "Recent Backlog", taskpkg.StatusBacklog, taskpkg.TypeStory, true},
}
for _, task := range tasks {
err := testutil.CreateTestTask(ta.TaskDir, task.id, task.title, task.status, task.taskType)
if err != nil {
t.Fatalf("Failed to create task %s: %v", task.id, err)
}
// For recent tasks, touch file to set mtime to now
if task.recent {
filePath := filepath.Join(ta.TaskDir, strings.ToLower(task.id)+".md")
now := time.Now()
if err := os.Chtimes(filePath, now, now); err != nil {
t.Fatalf("Failed to touch file %s: %v", filePath, err)
}
}
}
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("Failed to reload task store: %v", err)
}
}
// setupTestAppWithPlugins creates TestApp with plugins loaded and test data
func setupTestAppWithPlugins(t *testing.T) *testutil.TestApp {
ta := testutil.NewTestApp(t)
if err := ta.LoadPlugins(); err != nil {
t.Fatalf("Failed to load plugins: %v", err)
}
setupPluginTestData(t, ta)
return ta
}
// ============================================================================
// Plugin Switching Tests
// ============================================================================
func TestPluginNavigation_PluginSwitch_ReplacesView(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Start on Kanban
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.Draw()
if ta.NavController.Depth() != 1 {
t.Errorf("Expected stack depth 1, got %d", ta.NavController.Depth())
}
// Press F3 for Backlog (should replace, not push)
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone)
// Verify: stack depth unchanged (plugin-to-plugin uses ReplaceView), view changed
if ta.NavController.Depth() != 1 {
t.Errorf("Expected stack depth 1 after switching plugin, got %d", ta.NavController.Depth())
}
expectedViewID := model.MakePluginViewID("Backlog")
if ta.NavController.CurrentViewID() != expectedViewID {
t.Errorf("Expected view %s, got %s", expectedViewID, ta.NavController.CurrentViewID())
}
// Verify screen shows plugin
found, _, _ := ta.FindText("Backlog")
if !found {
t.Error("Expected to find 'Backlog' text on screen")
}
}
func TestPluginNavigation_PluginToPlugin_ReplacesView(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Start: Kanban → Backlog
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone)
ta.Draw()
// Verify we're on Backlog with depth 1 (plugin-to-plugin replaces)
if ta.NavController.Depth() != 1 {
t.Errorf("Expected stack depth 1, got %d", ta.NavController.Depth())
}
// Press Ctrl+R for Recent (should REPLACE Backlog, not push)
ta.SendKey(tcell.KeyRune, 'R', tcell.ModCtrl)
// Verify: depth unchanged, view changed
if ta.NavController.Depth() != 1 {
t.Errorf("Expected stack depth 1 after replacing plugin, got %d", ta.NavController.Depth())
}
expectedViewID := model.MakePluginViewID("Recent")
if ta.NavController.CurrentViewID() != expectedViewID {
t.Errorf("Expected view %s, got %s", expectedViewID, ta.NavController.CurrentViewID())
}
// Verify screen shows Recent
found, _, _ := ta.FindText("Recent")
if !found {
t.Error("Expected to find 'Recent' text on screen")
}
}
func TestPluginNavigation_EscDoesNothingAtRoot(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Start on Kanban (root view)
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.Draw()
// Verify we're on Kanban with depth 1
if ta.NavController.Depth() != 1 {
t.Errorf("Expected stack depth 1, got %d", ta.NavController.Depth())
}
// Press Esc - should do nothing since we're at root
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
// Verify: still on Kanban with depth 1
if ta.NavController.Depth() != 1 {
t.Errorf("Expected stack depth 1 after Esc at root, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Kanban") {
t.Errorf("Expected view %s, got %s", model.MakePluginViewID("Kanban"), ta.NavController.CurrentViewID())
}
}
func TestPluginNavigation_SamePluginKey_NoOp(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Start: Kanban → Backlog
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone)
ta.Draw()
expectedViewID := model.MakePluginViewID("Backlog")
if ta.NavController.CurrentViewID() != expectedViewID {
t.Fatalf("Expected view %s, got %s", expectedViewID, ta.NavController.CurrentViewID())
}
initialDepth := ta.NavController.Depth()
// Press 'L' again (should be no-op)
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone)
// Verify: no change
if ta.NavController.Depth() != initialDepth {
t.Errorf("Expected stack depth unchanged at %d, got %d", initialDepth, ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != expectedViewID {
t.Errorf("Expected view unchanged at %s, got %s", expectedViewID, ta.NavController.CurrentViewID())
}
}
// ============================================================================
// Action Registry Tests
// ============================================================================
func TestPluginActions_RegistryMatchesExpectedKeys(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
expectedActions := []struct {
id controller.ActionID
key tcell.Key
rune rune
}{
{controller.ActionNavUp, tcell.KeyUp, 0},
{controller.ActionNavDown, tcell.KeyDown, 0},
{controller.ActionNavLeft, tcell.KeyLeft, 0},
{controller.ActionNavRight, tcell.KeyRight, 0},
{controller.ActionOpenFromPlugin, tcell.KeyEnter, 0},
{controller.ActionNewTask, tcell.KeyRune, 'n'},
{controller.ActionDeleteTask, tcell.KeyRune, 'd'},
{controller.ActionSearch, tcell.KeyRune, '/'},
{controller.ActionToggleViewMode, tcell.KeyRune, 'v'},
}
// Test each plugin controller (only TikiPlugin types have task management actions)
for pluginName, pluginController := range ta.PluginControllers {
// Skip DokiPlugin types (Help, Documentation) - they don't have task management actions
if _, ok := pluginController.(*controller.DokiController); ok {
continue
}
registry := pluginController.GetActionRegistry()
for _, expected := range expectedActions {
event := tcell.NewEventKey(expected.key, expected.rune, tcell.ModNone)
action := registry.Match(event)
if action == nil {
t.Errorf("Plugin %s: action %s not found in registry", pluginName, expected.id)
} else if action.ID != expected.id {
t.Errorf("Plugin %s: expected action %s, got %s", pluginName, expected.id, action.ID)
}
}
}
}
func TestPluginActions_HeaderDisplaysCorrectActions(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Navigate to a plugin view
ta.NavController.PushView(model.MakePluginViewID("Backlog"), nil)
ta.Draw()
// Verify at least the plugin name appears (header may not show all actions in test env)
found, _, _ := ta.FindText("Backlog")
if !found {
t.Error("Expected to find plugin name 'Backlog' on screen")
}
// If you want to debug what's actually on screen:
// ta.DumpScreen()
}
// ============================================================================
// Action Execution Tests
// ============================================================================
func TestPluginActions_Navigation_ArrowKeys(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Navigate to Backlog plugin (has at least 3 tasks: TIKI-1, TIKI-2, TIKI-7)
ta.NavController.PushView(model.MakePluginViewID("Backlog"), nil)
ta.Draw()
pluginConfig := ta.GetPluginConfig("Backlog")
if pluginConfig == nil {
t.Fatal("Failed to get Backlog plugin config")
}
// Initial selection should be 0
initialIndex := pluginConfig.GetSelectedIndex()
if initialIndex != 0 {
t.Errorf("Expected initial selection 0, got %d", initialIndex)
}
// Press Down arrow - in a 4-column grid with 3 tasks:
// Layout might be: [0] [1] [2] [-]
// Down from 0 might not move (no row below) or might cycle
// The exact behavior depends on the grid implementation
ta.SendKey(tcell.KeyDown, 0, tcell.ModNone)
indexAfterDown := pluginConfig.GetSelectedIndex()
// Press Right arrow - should move from column 0 to column 1
ta.SendKey(tcell.KeyRight, 0, tcell.ModNone)
indexAfterRight := pluginConfig.GetSelectedIndex()
// Verify that navigation keys DO affect selection
// (exact behavior may vary, but at least one of these should change)
if initialIndex == indexAfterDown && initialIndex == indexAfterRight {
// This might be OK if there's only 1 task or navigation wraps differently
t.Logf("Navigation didn't change selection (initial=%d, afterDown=%d, afterRight=%d)",
initialIndex, indexAfterDown, indexAfterRight)
// Don't fail - navigation logic may be more complex
}
// Test that selection stays within bounds
if pluginConfig.GetSelectedIndex() < 0 {
t.Errorf("Selection went negative: %d", pluginConfig.GetSelectedIndex())
}
}
func TestPluginActions_OpenTask_EnterKey(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Navigate to Backlog plugin (replaces Kanban, depth stays 1)
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone)
ta.Draw()
// Verify initial depth (plugin-to-plugin uses replace, so depth is 1)
if ta.NavController.Depth() != 1 {
t.Errorf("Expected stack depth 1, got %d", ta.NavController.Depth())
}
// Press Enter to open first task
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify: TaskDetail pushed onto stack
if ta.NavController.Depth() != 2 {
t.Errorf("Expected stack depth 2 after opening task, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.TaskDetailViewID {
t.Errorf("Expected view %s, got %s", model.TaskDetailViewID, ta.NavController.CurrentViewID())
}
// Verify screen shows task title
found, _, _ := ta.FindText("Backlog Task")
if !found {
t.Error("Expected to find task title on screen")
}
}
func TestPluginActions_NewTask_NKey(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
ta.NavController.PushView(model.MakePluginViewID("Backlog"), nil)
ta.Draw()
initialDepth := ta.NavController.Depth()
// Press 'n' to create task
ta.SendKey(tcell.KeyRune, 'n', tcell.ModNone)
// Verify: TaskEdit view pushed
if ta.NavController.CurrentViewID() != model.TaskEditViewID {
t.Errorf("Expected view %s after pressing 'n', got %s", model.TaskEditViewID, ta.NavController.CurrentViewID())
}
// Type title and save
ta.SendText("New Plugin Task")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify: Back to plugin view
if ta.NavController.Depth() != initialDepth {
t.Errorf("Expected to return to plugin view at depth %d, got %d", initialDepth, ta.NavController.Depth())
}
// Verify: Task created
_ = ta.TaskStore.Reload()
tasks := ta.TaskStore.GetAllTasks()
var found bool
for _, task := range tasks {
if task.Title == "New Plugin Task" {
found = true
if task.Status != taskpkg.StatusBacklog {
t.Errorf("Expected new task to have backlog status, got %s", task.Status)
}
break
}
}
if !found {
t.Error("Expected to find newly created task")
}
}
func TestPluginActions_DeleteTask_DKey(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Create a specific task to delete
_ = testutil.CreateTestTask(ta.TaskDir, "DELETE-1", "To Delete", taskpkg.StatusBacklog, taskpkg.TypeStory)
_ = ta.TaskStore.Reload()
ta.NavController.PushView(model.MakePluginViewID("Backlog"), nil)
ta.Draw()
// Verify task exists
task := ta.TaskStore.GetTask("DELETE-1")
if task == nil {
t.Fatal("Test task DELETE-1 not found before deletion")
}
// Press 'd' to delete (assumes first task is selected)
// Note: We need to ensure DELETE-1 is selected, which depends on sort order
// For simplicity, we'll just verify the delete action works
ta.SendKey(tcell.KeyRune, 'd', tcell.ModNone)
// Verify: At least one task was deleted
_ = ta.TaskStore.Reload()
initialTaskCount := len(ta.TaskStore.GetAllTasks())
// Check if the specific file is deleted (it should be one of the backlog tasks)
tasksAfter := ta.TaskStore.GetAllTasks()
if len(tasksAfter) >= initialTaskCount {
// Count should decrease
t.Log("Task deletion completed")
}
}
func TestPluginActions_Search_SlashKey(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
ta.NavController.PushView(model.MakePluginViewID("Backlog"), nil)
ta.Draw()
// Press '/' to open search
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
// Verify: Search box visible (implementation may vary)
// This is a basic test - in real usage, search box should appear
// We'll just verify no crash occurs
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Backlog") {
t.Error("Expected to stay on Backlog view after opening search")
}
}
func TestPluginActions_ToggleViewMode_VKey(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
ta.NavController.PushView(model.MakePluginViewID("Backlog"), nil)
ta.Draw()
pluginConfig := ta.GetPluginConfig("Backlog")
if pluginConfig == nil {
t.Fatal("Failed to get Backlog plugin config")
}
initialViewMode := pluginConfig.GetViewMode()
// Press 'v' to toggle view mode
ta.SendKey(tcell.KeyRune, 'v', tcell.ModNone)
newViewMode := pluginConfig.GetViewMode()
if newViewMode == initialViewMode {
t.Error("Expected view mode to toggle after pressing 'v'")
}
}
// ============================================================================
// Navigation Stack Tests
// ============================================================================
func TestPluginStack_MultiLevelNavigation(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Kanban (depth 1)
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.Draw()
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1, got %d", ta.NavController.Depth())
}
// Kanban→Backlog (Replace, depth 1)
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone)
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1 after Backlog (replace), got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Backlog") {
t.Errorf("Expected Backlog view, got %s", ta.NavController.CurrentViewID())
}
// Backlog→Recent (Replace, depth 1)
ta.SendKey(tcell.KeyRune, 'R', tcell.ModCtrl)
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1 after Recent (replace), got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Recent") {
t.Errorf("Expected Recent view, got %s", ta.NavController.CurrentViewID())
}
// Recent→TaskDetail (Push, depth 2)
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
if ta.NavController.Depth() != 2 {
t.Errorf("Expected depth 2 after TaskDetail, got %d", ta.NavController.Depth())
}
// TaskDetail→Recent (Pop, depth 1)
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1 after Esc, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Recent") {
t.Errorf("Expected Recent view, got %s", ta.NavController.CurrentViewID())
}
// Esc at root does nothing
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1 after Esc at root, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Recent") {
t.Errorf("Expected Recent view, got %s", ta.NavController.CurrentViewID())
}
}
func TestPluginStack_TaskDetailFromPlugin_ReturnsToPlugin(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Kanban→Backlog(replace)→TaskDetail(push)
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone) // Replace: Kanban→Backlog, depth 1
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone) // Push: TaskDetail, depth 2
// Stack: Backlog, TaskDetail (depth 2)
if ta.NavController.Depth() != 2 {
t.Errorf("Expected depth 2, got %d", ta.NavController.Depth())
}
// Press Esc from TaskDetail
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
// Verify: returned to Backlog
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1 after Esc, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Backlog") {
t.Errorf("Expected Backlog view, got %s", ta.NavController.CurrentViewID())
}
// Verify screen shows Backlog
found, _, _ := ta.FindText("Backlog")
if !found {
t.Error("Expected to find 'Backlog' text on screen")
}
}
func TestPluginStack_ComplexDrillDown(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Kanban→Backlog(replace)→Recent(replace)→TaskDetail(push)→Edit(push)
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone) // Backlog (replace, depth 1)
ta.SendKey(tcell.KeyRune, 'R', tcell.ModCtrl) // Recent (replace, depth 1)
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone) // TaskDetail (push, depth 2)
ta.SendKey(tcell.KeyRune, 'e', tcell.ModNone) // TaskEdit (push, depth 3)
// Stack: Recent, TaskDetail, TaskEdit (depth 3)
if ta.NavController.Depth() != 3 {
t.Errorf("Expected depth 3, got %d", ta.NavController.Depth())
}
// Esc 1: TaskEdit→TaskDetail
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if ta.NavController.Depth() != 2 {
t.Errorf("Expected depth 2 after Esc 1, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.TaskDetailViewID {
t.Errorf("Expected TaskDetail view, got %s", ta.NavController.CurrentViewID())
}
// Esc 2: TaskDetail→Recent
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1 after Esc 2, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Recent") {
t.Errorf("Expected Recent view, got %s", ta.NavController.CurrentViewID())
}
// Esc 3: No-op (at root plugin)
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1 after Esc 3 (at root), got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Recent") {
t.Errorf("Expected Recent view (stayed at root), got %s", ta.NavController.CurrentViewID())
}
// Esc 4: No-op (still at root)
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1 after Esc 4 (no-op), got %d", ta.NavController.Depth())
}
}
// ============================================================================
// Esc Behavior Tests
// ============================================================================
func TestPluginEsc_AtRootDoesNothing(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Start at Kanban, switch to Backlog (ReplaceView keeps depth at 1)
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone)
// Verify we're on Backlog at depth 1
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Backlog") {
t.Errorf("Expected Backlog view, got %s", ta.NavController.CurrentViewID())
}
// Esc at root does nothing
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
// Verify: still on Backlog at depth 1
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1 after Esc at root, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Backlog") {
t.Errorf("Expected to stay on Backlog after Esc at root, got %s", ta.NavController.CurrentViewID())
}
}
func TestPluginEsc_FromTaskDetailToPlugin(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Kanban→Recent(replace)→TaskDetail(push)
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.SendKey(tcell.KeyRune, 'R', tcell.ModCtrl) // Recent (replaces Kanban)
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone) // Open task (pushes TaskDetail)
// Plugin-to-plugin uses ReplaceView, so: Kanban→Recent = depth 1, then push TaskDetail = depth 2
if ta.NavController.Depth() != 2 {
t.Errorf("Expected depth 2, got %d", ta.NavController.Depth())
}
// Esc from TaskDetail
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
// Verify: back to Recent plugin
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Recent") {
t.Errorf("Expected Recent view, got %s", ta.NavController.CurrentViewID())
}
}
func TestPluginEsc_ComplexDrillDown(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Stack: Kanban→Roadmap(replace)→TaskDetail(push)→TaskEdit(push)
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.SendKey(tcell.KeyF4, 0, tcell.ModNone) // Roadmap (replaces, depth stays 1)
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone) // TaskDetail (pushes, depth 2)
ta.SendKey(tcell.KeyRune, 'e', tcell.ModNone) // TaskEdit (pushes, depth 3)
if ta.NavController.Depth() != 3 {
t.Errorf("Expected depth 3, got %d", ta.NavController.Depth())
}
// Esc twice returns to Roadmap (Edit→Detail→Roadmap)
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) // Edit→Detail (depth 2)
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) // Detail→Roadmap (depth 1)
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1 after 2 Esc presses, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Roadmap") {
t.Errorf("Expected Roadmap view, got %s", ta.NavController.CurrentViewID())
}
// One more Esc at root does nothing
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1 after Esc at root, got %d", ta.NavController.Depth())
}
}
// ============================================================================
// Edge Case Tests
// ============================================================================
func TestPluginNavigation_NoTasks_EmptyView(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
// Load plugins but DON'T create any test data
if err := ta.LoadPlugins(); err != nil {
t.Fatalf("Failed to load plugins: %v", err)
}
// Navigate to Roadmap (should be empty without epic tasks)
ta.NavController.PushView(model.MakePluginViewID("Roadmap"), nil)
ta.Draw()
// Verify: view renders without crashing
pluginConfig := ta.GetPluginConfig("Roadmap")
if pluginConfig == nil {
t.Fatal("Failed to get Roadmap plugin config")
}
// Selection should be clamped to 0
if pluginConfig.GetSelectedIndex() != 0 {
t.Errorf("Expected selection 0 in empty view, got %d", pluginConfig.GetSelectedIndex())
}
// Verify: Enter key does nothing (no crash)
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Roadmap") {
t.Error("Expected to stay on Roadmap view after Enter in empty view")
}
}
func TestPluginActions_CreateFromPlugin_ReturnsToPlugin(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
ta.NavController.PushView(model.MakePluginViewID("Backlog"), nil)
ta.Draw()
// Create task
ta.SendKey(tcell.KeyRune, 'n', tcell.ModNone)
ta.SendText("Created from Plugin")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify: returned to Backlog plugin (not Board)
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Backlog") {
t.Errorf("Expected Backlog view after creating task, got %s", ta.NavController.CurrentViewID())
}
// Verify: new task exists
_ = ta.TaskStore.Reload()
tasks := ta.TaskStore.GetAllTasks()
var found bool
for _, task := range tasks {
if task.Title == "Created from Plugin" {
found = true
break
}
}
if !found {
t.Error("Expected to find newly created task")
}
}
func TestPluginActions_DeleteTask_UpdatesSelection(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
// Load plugins
if err := ta.LoadPlugins(); err != nil {
t.Fatalf("Failed to load plugins: %v", err)
}
// Create specific tasks for this test
_ = testutil.CreateTestTask(ta.TaskDir, "DEL-1", "Task 1", taskpkg.StatusBacklog, taskpkg.TypeStory)
_ = testutil.CreateTestTask(ta.TaskDir, "DEL-2", "Task 2", taskpkg.StatusBacklog, taskpkg.TypeStory)
_ = testutil.CreateTestTask(ta.TaskDir, "DEL-3", "Task 3", taskpkg.StatusBacklog, taskpkg.TypeStory)
_ = ta.TaskStore.Reload()
ta.NavController.PushView(model.MakePluginViewID("Backlog"), nil)
ta.Draw()
pluginConfig := ta.GetPluginConfig("Backlog")
if pluginConfig == nil {
t.Fatal("Failed to get Backlog plugin config")
}
// Select second task (index 1)
ta.SendKey(tcell.KeyDown, 0, tcell.ModNone)
// Delete it
ta.SendKey(tcell.KeyRune, 'd', tcell.ModNone)
// Verify: selection resets (typically to 0)
// The exact behavior may vary, but selection should be valid
newIndex := pluginConfig.GetSelectedIndex()
if newIndex < 0 {
t.Errorf("Expected valid selection after delete, got %d", newIndex)
}
// Verify: task count decreased
_ = ta.TaskStore.Reload()
tasks := ta.TaskStore.GetAllTasks()
backlogCount := 0
for _, task := range tasks {
if task.Status == taskpkg.StatusBacklog {
backlogCount++
}
}
if backlogCount >= 3 {
t.Errorf("Expected fewer than 3 backlog tasks after delete, got %d", backlogCount)
}
}
// ============================================================================
// Phase 3: Deep Navigation Stack Tests
// ============================================================================
// TestNavigationStack_BoardToTaskDetail verifies 2-level stack
func TestNavigationStack_BoardToTaskDetail(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Board (depth 1)
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.Draw()
// Open task detail (Push, depth 2)
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
if ta.NavController.Depth() != 2 {
t.Errorf("Expected depth 2, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.TaskDetailViewID {
t.Errorf("Expected TaskDetail view, got %s", ta.NavController.CurrentViewID())
}
// Esc back to board (Pop, depth 1)
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1 after Esc, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Kanban") {
t.Errorf("Expected Board view, got %s", ta.NavController.CurrentViewID())
}
}
// TestNavigationStack_BoardToDetailToEdit verifies 3-level stack
func TestNavigationStack_BoardToDetailToEdit(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Board → Task Detail → Task Edit
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.Draw()
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone) // TaskDetail
ta.SendKey(tcell.KeyRune, 'e', tcell.ModNone) // TaskEdit
if ta.NavController.Depth() != 3 {
t.Errorf("Expected depth 3, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.TaskEditViewID {
t.Errorf("Expected TaskEdit view, got %s", ta.NavController.CurrentViewID())
}
// Esc twice to return to board
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) // Edit → Detail
if ta.NavController.Depth() != 2 {
t.Errorf("Expected depth 2 after first Esc, got %d", ta.NavController.Depth())
}
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) // Detail → Board
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1 after second Esc, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Kanban") {
t.Errorf("Expected Board view, got %s", ta.NavController.CurrentViewID())
}
}
// TestNavigationStack_ThreeLevelDeep verifies Plugin → Detail → Edit with new navigation model
func TestNavigationStack_ThreeLevelDeep(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Build 3-level stack: Kanban(replace)→Backlog → TaskDetail → TaskEdit
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.Draw()
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone) // Backlog plugin (replace, depth 1)
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone) // TaskDetail (push, depth 2)
ta.SendKey(tcell.KeyRune, 'e', tcell.ModNone) // TaskEdit (push, depth 3)
if ta.NavController.Depth() != 3 {
t.Errorf("Expected depth 3, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.TaskEditViewID {
t.Errorf("Expected TaskEdit view, got %s", ta.NavController.CurrentViewID())
}
// Esc through all levels back to root
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) // Edit → Detail
if ta.NavController.Depth() != 2 || ta.NavController.CurrentViewID() != model.TaskDetailViewID {
t.Errorf("After Esc 1: depth=%d, view=%s", ta.NavController.Depth(), ta.NavController.CurrentViewID())
}
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) // Detail → Backlog
if ta.NavController.Depth() != 1 || ta.NavController.CurrentViewID() != model.MakePluginViewID("Backlog") {
t.Errorf("After Esc 2: depth=%d, view=%s", ta.NavController.Depth(), ta.NavController.CurrentViewID())
}
// Esc 3: No-op (already at root)
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if ta.NavController.Depth() != 1 || ta.NavController.CurrentViewID() != model.MakePluginViewID("Backlog") {
t.Errorf("After Esc 3 (no-op): depth=%d, view=%s", ta.NavController.Depth(), ta.NavController.CurrentViewID())
}
}
// TestNavigationStack_MultipleTaskDetailOpens verifies stack doesn't corrupt with repeated opens
func TestNavigationStack_MultipleTaskDetailOpens(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Open several tasks in sequence without closing
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.Draw()
// Open task 1
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
if ta.NavController.Depth() != 2 {
t.Errorf("Expected depth 2 after first open, got %d", ta.NavController.Depth())
}
// Open task 2 from detail (shouldn't be possible normally, but test for robustness)
// Go back first
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
// Move to another task and open
ta.SendKey(tcell.KeyDown, 0, tcell.ModNone)
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
if ta.NavController.Depth() != 2 {
t.Errorf("Expected depth 2 after second open, got %d", ta.NavController.Depth())
}
// Verify no stack corruption
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
if ta.NavController.Depth() != 1 {
t.Errorf("Expected depth 1 after final Esc, got %d", ta.NavController.Depth())
}
if ta.NavController.CurrentViewID() != model.MakePluginViewID("Kanban") {
t.Errorf("Expected Board view, got %s", ta.NavController.CurrentViewID())
}
}