mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
574 lines
15 KiB
Go
574 lines
15 KiB
Go
package integration
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/boolean-maybe/tiki/controller"
|
|
"github.com/boolean-maybe/tiki/model"
|
|
"github.com/boolean-maybe/tiki/task"
|
|
"github.com/boolean-maybe/tiki/testutil"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
)
|
|
|
|
const inputActionWorkflow = `views:
|
|
plugins:
|
|
- name: InputTest
|
|
key: "F4"
|
|
lanes:
|
|
- name: All
|
|
columns: 1
|
|
filter: select where status = "backlog" order by id
|
|
actions:
|
|
- key: "A"
|
|
label: "Assign to..."
|
|
action: update where id = id() set assignee=input()
|
|
input: string
|
|
- key: "t"
|
|
label: "Add tag"
|
|
action: update where id = id() set tags=tags+[input()]
|
|
input: string
|
|
- key: "p"
|
|
label: "Set points"
|
|
action: update where id = id() set points=input()
|
|
input: int
|
|
- key: "b"
|
|
label: "Add to board"
|
|
action: update where id = id() set status="ready"
|
|
`
|
|
|
|
func setupInputActionTest(t *testing.T) *testutil.TestApp {
|
|
t.Helper()
|
|
tmpDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(tmpDir, "workflow.yaml"), []byte(inputActionWorkflow), 0644); err != nil {
|
|
t.Fatalf("failed to write workflow.yaml: %v", err)
|
|
}
|
|
origDir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("failed to get cwd: %v", err)
|
|
}
|
|
if err := os.Chdir(tmpDir); err != nil {
|
|
t.Fatalf("failed to chdir: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(origDir) })
|
|
|
|
ta := testutil.NewTestApp(t)
|
|
if err := ta.LoadPlugins(); err != nil {
|
|
t.Fatalf("failed to load plugins: %v", err)
|
|
}
|
|
|
|
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test Task", task.StatusBacklog, task.TypeStory); err != nil {
|
|
t.Fatalf("failed to create task: %v", err)
|
|
}
|
|
if err := ta.TaskStore.Reload(); err != nil {
|
|
t.Fatalf("failed to reload: %v", err)
|
|
}
|
|
|
|
ta.NavController.PushView(model.MakePluginViewID("InputTest"), nil)
|
|
ta.Draw()
|
|
|
|
return ta
|
|
}
|
|
|
|
func getActiveInputableView(ta *testutil.TestApp) controller.InputableView {
|
|
v := ta.NavController.GetActiveView()
|
|
iv, _ := v.(controller.InputableView)
|
|
return iv
|
|
}
|
|
|
|
func TestInputAction_KeyOpensPrompt(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
iv := getActiveInputableView(ta)
|
|
if iv == nil {
|
|
t.Fatal("active view does not implement InputableView")
|
|
}
|
|
if iv.IsInputBoxVisible() {
|
|
t.Fatal("input box should not be visible initially")
|
|
}
|
|
|
|
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
|
|
|
if !iv.IsInputBoxVisible() {
|
|
t.Fatal("input box should be visible after pressing 'A'")
|
|
}
|
|
if !iv.IsInputBoxFocused() {
|
|
t.Fatal("input box should be focused after pressing 'A'")
|
|
}
|
|
}
|
|
|
|
func TestInputAction_EnterAppliesMutation(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
|
ta.SendText("alice")
|
|
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
|
|
|
iv := getActiveInputableView(ta)
|
|
if iv.IsInputBoxVisible() {
|
|
t.Fatal("input box should be hidden after valid submit")
|
|
}
|
|
|
|
if err := ta.TaskStore.Reload(); err != nil {
|
|
t.Fatalf("failed to reload: %v", err)
|
|
}
|
|
updated := ta.TaskStore.GetTask("TIKI-1")
|
|
if updated == nil {
|
|
t.Fatal("task not found")
|
|
}
|
|
if updated.Assignee != "alice" {
|
|
t.Fatalf("expected assignee=alice, got %q", updated.Assignee)
|
|
}
|
|
}
|
|
|
|
func TestInputAction_EscCancelsWithoutMutation(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
|
ta.SendText("bob")
|
|
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
|
|
|
iv := getActiveInputableView(ta)
|
|
if iv.IsInputBoxVisible() {
|
|
t.Fatal("input box should be hidden after Esc")
|
|
}
|
|
|
|
if err := ta.TaskStore.Reload(); err != nil {
|
|
t.Fatalf("failed to reload: %v", err)
|
|
}
|
|
updated := ta.TaskStore.GetTask("TIKI-1")
|
|
if updated == nil {
|
|
t.Fatal("task not found")
|
|
}
|
|
if updated.Assignee != "" {
|
|
t.Fatalf("expected empty assignee after cancel, got %q", updated.Assignee)
|
|
}
|
|
}
|
|
|
|
func TestInputAction_NonInputActionStillWorks(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
// 'b' is a non-input action — should execute immediately without prompt
|
|
ta.SendKey(tcell.KeyRune, 'b', tcell.ModNone)
|
|
|
|
iv := getActiveInputableView(ta)
|
|
if iv.IsInputBoxVisible() {
|
|
t.Fatal("non-input action should not open input box")
|
|
}
|
|
|
|
if err := ta.TaskStore.Reload(); err != nil {
|
|
t.Fatalf("failed to reload: %v", err)
|
|
}
|
|
updated := ta.TaskStore.GetTask("TIKI-1")
|
|
if updated == nil {
|
|
t.Fatal("task not found")
|
|
}
|
|
if updated.Status != task.StatusReady {
|
|
t.Fatalf("expected status ready, got %v", updated.Status)
|
|
}
|
|
}
|
|
|
|
func TestInputAction_ModalBlocksOtherActions(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
// open action-input prompt
|
|
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
|
iv := getActiveInputableView(ta)
|
|
if !iv.IsInputBoxFocused() {
|
|
t.Fatal("input box should be focused")
|
|
}
|
|
|
|
// while modal, 'b' should NOT execute the non-input action
|
|
ta.SendKey(tcell.KeyRune, 'b', tcell.ModNone)
|
|
|
|
if err := ta.TaskStore.Reload(); err != nil {
|
|
t.Fatalf("failed to reload: %v", err)
|
|
}
|
|
updated := ta.TaskStore.GetTask("TIKI-1")
|
|
if updated.Status != task.StatusBacklog {
|
|
t.Fatalf("expected status backlog (action should be blocked while modal), got %v", updated.Status)
|
|
}
|
|
|
|
// cancel and verify box closes
|
|
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
|
if iv.IsInputBoxVisible() {
|
|
t.Fatal("input box should be hidden after Esc")
|
|
}
|
|
}
|
|
|
|
func TestInputAction_SearchPassiveBlocksNewSearch(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
iv := getActiveInputableView(ta)
|
|
|
|
// open search
|
|
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
|
if !iv.IsInputBoxFocused() {
|
|
t.Fatal("search box should be focused after '/'")
|
|
}
|
|
|
|
// type and submit search
|
|
ta.SendText("Test")
|
|
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
|
|
|
// should be in passive mode: visible but not focused
|
|
if !iv.IsInputBoxVisible() {
|
|
t.Fatal("search box should remain visible in passive mode")
|
|
}
|
|
if iv.IsInputBoxFocused() {
|
|
t.Fatal("search box should not be focused in passive mode")
|
|
}
|
|
if !iv.IsSearchPassive() {
|
|
t.Fatal("expected search-passive state")
|
|
}
|
|
|
|
// pressing '/' again should NOT re-enter search editing
|
|
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
|
if iv.IsInputBoxFocused() {
|
|
t.Fatal("'/' should be blocked while search is passive — user must Esc first")
|
|
}
|
|
|
|
// Esc clears search and closes
|
|
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
|
if iv.IsInputBoxVisible() {
|
|
t.Fatal("input box should be hidden after Esc from passive mode")
|
|
}
|
|
}
|
|
|
|
func TestInputAction_PassiveSearchReplacedByActionInput(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
iv := getActiveInputableView(ta)
|
|
|
|
// set up passive search
|
|
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
|
ta.SendText("Test")
|
|
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
|
if !iv.IsSearchPassive() {
|
|
t.Fatal("expected search-passive state")
|
|
}
|
|
|
|
// action-input should temporarily replace the passive search
|
|
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
|
if !iv.IsInputBoxFocused() {
|
|
t.Fatal("action-input should be focused, replacing passive search")
|
|
}
|
|
if iv.IsSearchPassive() {
|
|
t.Fatal("should no longer be in search-passive while action-input is active")
|
|
}
|
|
|
|
// submit action input
|
|
ta.SendText("carol")
|
|
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
|
|
|
// should restore passive search
|
|
if !iv.IsInputBoxVisible() {
|
|
t.Fatal("passive search should be restored after action-input closes")
|
|
}
|
|
if !iv.IsSearchPassive() {
|
|
t.Fatal("should be back in search-passive mode")
|
|
}
|
|
|
|
// verify the mutation happened
|
|
if err := ta.TaskStore.Reload(); err != nil {
|
|
t.Fatalf("failed to reload: %v", err)
|
|
}
|
|
updated := ta.TaskStore.GetTask("TIKI-1")
|
|
if updated == nil {
|
|
t.Fatal("task not found")
|
|
}
|
|
if updated.Assignee != "carol" {
|
|
t.Fatalf("expected assignee=carol, got %q", updated.Assignee)
|
|
}
|
|
}
|
|
|
|
func TestInputAction_ActionInputEscRestoresPassiveSearch(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
iv := getActiveInputableView(ta)
|
|
|
|
// set up passive search
|
|
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
|
ta.SendText("Test")
|
|
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
|
|
|
// open action-input
|
|
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
|
ta.SendText("dave")
|
|
|
|
// Esc should restore passive search, not clear it
|
|
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
|
|
|
if !iv.IsSearchPassive() {
|
|
t.Fatal("Esc from action-input should restore passive search")
|
|
}
|
|
|
|
// verify no mutation
|
|
if err := ta.TaskStore.Reload(); err != nil {
|
|
t.Fatalf("failed to reload: %v", err)
|
|
}
|
|
updated := ta.TaskStore.GetTask("TIKI-1")
|
|
if updated.Assignee != "" {
|
|
t.Fatalf("expected empty assignee after cancel, got %q", updated.Assignee)
|
|
}
|
|
}
|
|
|
|
func TestInputAction_SearchEditingBlocksPluginActions(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
iv := getActiveInputableView(ta)
|
|
|
|
// open search
|
|
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
|
if !iv.IsInputBoxFocused() {
|
|
t.Fatal("search box should be focused")
|
|
}
|
|
|
|
// while search editing is active, 'b' (non-input action) should be blocked
|
|
ta.SendKey(tcell.KeyRune, 'b', tcell.ModNone)
|
|
|
|
if err := ta.TaskStore.Reload(); err != nil {
|
|
t.Fatalf("failed to reload: %v", err)
|
|
}
|
|
updated := ta.TaskStore.GetTask("TIKI-1")
|
|
if updated.Status != task.StatusBacklog {
|
|
t.Fatalf("expected status backlog (action blocked during search editing), got %v", updated.Status)
|
|
}
|
|
|
|
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
|
}
|
|
|
|
func TestInputAction_EmptySearchEnterIsNoOp(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
iv := getActiveInputableView(ta)
|
|
|
|
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
|
if !iv.IsInputBoxFocused() {
|
|
t.Fatal("search box should be focused")
|
|
}
|
|
|
|
// Enter on empty text should keep editing open
|
|
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
|
if !iv.IsInputBoxFocused() {
|
|
t.Fatal("empty search Enter should keep box focused (no-op)")
|
|
}
|
|
if iv.IsSearchPassive() {
|
|
t.Fatal("empty search Enter should not transition to passive")
|
|
}
|
|
|
|
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
|
}
|
|
|
|
func TestInputAction_PaletteOpensDuringModal(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
// open action-input
|
|
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
|
iv := getActiveInputableView(ta)
|
|
if !iv.IsInputBoxFocused() {
|
|
t.Fatal("input box should be focused")
|
|
}
|
|
|
|
// Ctrl+A should open the palette even while input box is focused
|
|
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
|
|
if !ta.GetPaletteConfig().IsVisible() {
|
|
t.Fatal("palette should open when Ctrl+A is pressed with input box focused")
|
|
}
|
|
|
|
// clean up
|
|
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
|
}
|
|
|
|
func TestInputAction_PaletteDispatchOpensPrompt(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
iv := getActiveInputableView(ta)
|
|
|
|
// simulate palette dispatch: call HandleAction directly with the input-backed action ID
|
|
actionID := controller.ActionID("plugin_action:A")
|
|
ta.InputRouter.HandleAction(actionID, ta.NavController.CurrentView())
|
|
ta.Draw()
|
|
|
|
if !iv.IsInputBoxVisible() {
|
|
t.Fatal("palette-dispatched input action should open the prompt")
|
|
}
|
|
if !iv.IsInputBoxFocused() {
|
|
t.Fatal("prompt should be focused after palette dispatch")
|
|
}
|
|
|
|
// type and submit
|
|
ta.SendText("eve")
|
|
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
|
|
|
if iv.IsInputBoxVisible() {
|
|
t.Fatal("prompt should close after valid submit")
|
|
}
|
|
|
|
if err := ta.TaskStore.Reload(); err != nil {
|
|
t.Fatalf("failed to reload: %v", err)
|
|
}
|
|
updated := ta.TaskStore.GetTask("TIKI-1")
|
|
if updated.Assignee != "eve" {
|
|
t.Fatalf("expected assignee=eve via palette dispatch, got %q", updated.Assignee)
|
|
}
|
|
}
|
|
|
|
func TestInputAction_InvalidInputKeepsPromptOpen(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
iv := getActiveInputableView(ta)
|
|
|
|
originalTask := ta.TaskStore.GetTask("TIKI-1")
|
|
originalPoints := originalTask.Points
|
|
|
|
// open int input (points)
|
|
ta.SendKey(tcell.KeyRune, 'p', tcell.ModNone)
|
|
if !iv.IsInputBoxFocused() {
|
|
t.Fatal("prompt should be focused")
|
|
}
|
|
|
|
// type non-numeric text and submit
|
|
ta.SendText("abc")
|
|
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
|
|
|
// prompt should stay open — invalid input
|
|
if !iv.IsInputBoxFocused() {
|
|
t.Fatal("prompt should remain focused after invalid input")
|
|
}
|
|
if !iv.IsInputBoxVisible() {
|
|
t.Fatal("prompt should remain visible after invalid input")
|
|
}
|
|
|
|
// verify no mutation
|
|
if err := ta.TaskStore.Reload(); err != nil {
|
|
t.Fatalf("failed to reload: %v", err)
|
|
}
|
|
updated := ta.TaskStore.GetTask("TIKI-1")
|
|
if updated.Points != originalPoints {
|
|
t.Fatalf("expected points=%d (unchanged), got %d", originalPoints, updated.Points)
|
|
}
|
|
|
|
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
|
}
|
|
|
|
func TestInputAction_PreflightNoTaskSelected_NoPrompt(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// workflow with an empty lane (no tasks will match)
|
|
workflow := `views:
|
|
plugins:
|
|
- name: EmptyTest
|
|
key: "F4"
|
|
lanes:
|
|
- name: Empty
|
|
columns: 1
|
|
filter: select where status = "nonexistent" order by id
|
|
actions:
|
|
- key: "A"
|
|
label: "Assign to..."
|
|
action: update where id = id() set assignee=input()
|
|
input: string
|
|
`
|
|
if err := os.WriteFile(filepath.Join(tmpDir, "workflow.yaml"), []byte(workflow), 0644); err != nil {
|
|
t.Fatalf("failed to write workflow.yaml: %v", err)
|
|
}
|
|
origDir, _ := os.Getwd()
|
|
_ = os.Chdir(tmpDir)
|
|
t.Cleanup(func() { _ = os.Chdir(origDir) })
|
|
|
|
ta := testutil.NewTestApp(t)
|
|
if err := ta.LoadPlugins(); err != nil {
|
|
t.Fatalf("failed to load plugins: %v", err)
|
|
}
|
|
defer ta.Cleanup()
|
|
|
|
// create a task, but it won't match the filter
|
|
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test", task.StatusBacklog, task.TypeStory); err != nil {
|
|
t.Fatalf("failed to create task: %v", err)
|
|
}
|
|
if err := ta.TaskStore.Reload(); err != nil {
|
|
t.Fatalf("failed to reload: %v", err)
|
|
}
|
|
|
|
ta.NavController.PushView(model.MakePluginViewID("EmptyTest"), nil)
|
|
ta.Draw()
|
|
|
|
iv := getActiveInputableView(ta)
|
|
|
|
// press 'A' — no task selected, preflight should fail, no prompt
|
|
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
|
if iv != nil && iv.IsInputBoxVisible() {
|
|
t.Fatal("input prompt should not open when no task is selected")
|
|
}
|
|
}
|
|
|
|
func TestInputAction_DraftSearchSurvivesRefresh(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
iv := getActiveInputableView(ta)
|
|
|
|
// open search and type (but don't submit)
|
|
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
|
ta.SendText("draft")
|
|
|
|
if !iv.IsInputBoxFocused() {
|
|
t.Fatal("search box should be focused")
|
|
}
|
|
|
|
// simulate a store refresh (which triggers view rebuild)
|
|
if err := ta.TaskStore.Reload(); err != nil {
|
|
t.Fatalf("failed to reload: %v", err)
|
|
}
|
|
ta.Draw()
|
|
|
|
// search box should still be visible after refresh
|
|
if !iv.IsInputBoxVisible() {
|
|
t.Fatal("draft search should survive store refresh/rebuild")
|
|
}
|
|
|
|
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
|
}
|
|
|
|
func TestInputAction_AddTagMutation(t *testing.T) {
|
|
ta := setupInputActionTest(t)
|
|
defer ta.Cleanup()
|
|
|
|
ta.SendKey(tcell.KeyRune, 't', tcell.ModNone)
|
|
ta.SendText("urgent")
|
|
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
|
|
|
if err := ta.TaskStore.Reload(); err != nil {
|
|
t.Fatalf("failed to reload: %v", err)
|
|
}
|
|
updated := ta.TaskStore.GetTask("TIKI-1")
|
|
if updated == nil {
|
|
t.Fatal("task not found")
|
|
}
|
|
found := false
|
|
for _, tag := range updated.Tags {
|
|
if tag == "urgent" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected 'urgent' tag, got %v", updated.Tags)
|
|
}
|
|
}
|