diff options
Diffstat (limited to 'internal/ui/table_test.go')
| -rw-r--r-- | internal/ui/table_test.go | 434 |
1 files changed, 434 insertions, 0 deletions
diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index bb9d0e1..2b86659 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -11,6 +11,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" ) func TestAnnotateHotkey(t *testing.T) { @@ -624,6 +625,21 @@ func setupUltraTaskSet(t *testing.T, tmp string) string { return taskPath } +func setupUltraSearchTaskSet(t *testing.T, tmp string) string { + taskPath := filepath.Join(tmp, "task") + script := "#!/bin/sh\n" + + "if echo \"$@\" | grep -q export; then\n" + + " echo '{\"id\":1,\"uuid\":\"1\",\"description\":\"alpha\",\"project\":\"home\",\"tags\":[\"blue\"],\"status\":\"pending\",\"entry\":\"\",\"priority\":\"H\",\"urgency\":0,\"annotations\":[{\"entry\":\"\",\"description\":\"project note\"}]}'\n" + + " echo '{\"id\":2,\"uuid\":\"2\",\"description\":\"beta bravo\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" + + " echo '{\"id\":3,\"uuid\":\"3\",\"description\":\"charlie delta\",\"tags\":[\"home\"],\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" + + " exit 0\n" + + "fi\n" + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return taskPath +} + func setupUltraReloadTaskSet(t *testing.T, tmp string) (string, string) { taskPath := filepath.Join(tmp, "task") phaseFile := filepath.Join(tmp, "phase") @@ -653,6 +669,33 @@ func setupUltraReloadTaskSet(t *testing.T, tmp string) (string, string) { return taskPath, phaseFile } +func setupUltraSearchReloadTaskSet(t *testing.T, tmp string) (string, string) { + taskPath := filepath.Join(tmp, "task") + phaseFile := filepath.Join(tmp, "phase") + if err := os.WriteFile(phaseFile, []byte("1"), 0o644); err != nil { + t.Fatal(err) + } + + script := fmt.Sprintf("#!/bin/sh\n"+ + "phase=$(cat %q)\n"+ + "if echo \"$@\" | grep -q export; then\n"+ + " if [ \"$phase\" = \"1\" ]; then\n"+ + " echo '{\"id\":1,\"uuid\":\"1\",\"description\":\"alpha current\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"H\",\"urgency\":2}'\n"+ + " echo '{\"id\":2,\"uuid\":\"2\",\"description\":\"beta current\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"L\",\"urgency\":1}'\n"+ + " else\n"+ + " echo '{\"id\":1,\"uuid\":\"1\",\"description\":\"omega current\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"H\",\"urgency\":2}'\n"+ + " echo '{\"id\":2,\"uuid\":\"2\",\"description\":\"alpha moved\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"L\",\"urgency\":1}'\n"+ + " fi\n"+ + " exit 0\n"+ + "fi\n", phaseFile) + + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + return taskPath, phaseFile +} + func setupBasicTask(t *testing.T, tmp string) string { taskPath := filepath.Join(tmp, "task") script := "#!/bin/sh\n" + @@ -667,6 +710,20 @@ func setupBasicTask(t *testing.T, tmp string) string { return taskPath } +func setupSharedSearchTaskSet(t *testing.T, tmp string) string { + taskPath := filepath.Join(tmp, "task") + script := "#!/bin/sh\n" + + "if echo \"$@\" | grep -q export; then\n" + + " echo '{\"id\":1,\"uuid\":\"x\",\"description\":\"shared alpha\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" + + " echo '{\"id\":2,\"uuid\":\"y\",\"description\":\"shared beta\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" + + " exit 0\n" + + "fi\n" + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return taskPath +} + func setupEnv(t *testing.T, taskPath string) { origPath := os.Getenv("PATH") os.Setenv("PATH", filepath.Dir(taskPath)+":"+origPath) @@ -705,6 +762,114 @@ func TestEscClosesHelp(t *testing.T) { } } +func TestUltraHelpUsesUltraBindingsAndClosesBeforeLeavingUltra(t *testing.T) { + tmp := t.TempDir() + taskPath := setupBasicTask(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + mv, _ := (&m).Update(tea.WindowSizeMsg{Width: 120, Height: 24}) + m = *mv.(*Model) + + mv, cmd := (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + if cmd != nil { + t.Fatalf("u unexpectedly returned a command") + } + m = *mv.(*Model) + if !m.showUltra { + t.Fatalf("u did not enter ultra mode") + } + + mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'H', Text: "H"}) + if cmd != nil { + t.Fatalf("H unexpectedly returned a command") + } + m = *mv.(*Model) + if !m.showHelp { + t.Fatalf("H did not open help in ultra mode") + } + if !m.showUltra { + t.Fatalf("opening help unexpectedly exited ultra mode") + } + + view := ansi.Strip(m.activeHelpContent()) + if !strings.Contains(view, "search ultra cards") { + t.Fatalf("ultra help content missing ultra search binding: %q", view) + } + if !strings.Contains(view, "exit ultra mode") { + t.Fatalf("ultra help content missing ultra exit binding: %q", view) + } + if strings.Contains(view, "open URL from description") { + t.Fatalf("ultra help rendered normal-mode binding: %q", view) + } + if strings.Contains(view, "edit current field") { + t.Fatalf("ultra help rendered normal-only inline edit binding: %q", view) + } + + mv, cmd = (&m).Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd != nil { + t.Fatalf("esc while ultra help is open unexpectedly returned a command") + } + m = *mv.(*Model) + if m.showHelp { + t.Fatalf("esc did not close ultra help") + } + if !m.showUltra { + t.Fatalf("esc while ultra help is open unexpectedly exited ultra mode") + } + + mv, cmd = (&m).Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd != nil { + t.Fatalf("second esc in ultra mode unexpectedly returned a command") + } + m = *mv.(*Model) + if m.showUltra { + t.Fatalf("second esc did not exit ultra mode") + } +} + +func TestUltraHelpSearchUsesUltraHelpLines(t *testing.T) { + tmp := t.TempDir() + taskPath := setupBasicTask(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + step := func(msg tea.Msg) { + t.Helper() + mv, _ := (&m).Update(msg) + m = *mv.(*Model) + } + + step(tea.WindowSizeMsg{Width: 120, Height: 24}) + step(tea.KeyPressMsg{Code: 'u', Text: "u"}) + step(tea.KeyPressMsg{Code: 'H', Text: "H"}) + step(tea.KeyPressMsg{Code: '/', Text: "/"}) + for _, r := range "URL" { + step(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + step(tea.KeyPressMsg{Code: tea.KeyEnter}) + if got := len(m.helpSearchMatches); got != 0 { + t.Fatalf("ultra help search matched normal help content, got %d matches", got) + } + + step(tea.KeyPressMsg{Code: '/', Text: "/"}) + for _, r := range "ultra" { + step(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + step(tea.KeyPressMsg{Code: tea.KeyEnter}) + if got := len(m.helpSearchMatches); got == 0 { + t.Fatalf("ultra help search did not match ultra-specific help content") + } +} + func TestUltraExitHotkeysClearUltraState(t *testing.T) { tmp := t.TempDir() taskPath := setupBasicTask(t, tmp) @@ -786,6 +951,163 @@ func TestUltraExitHotkeysClearUltraState(t *testing.T) { } } +func TestUltraSearchFiltersNavigatesAndHighlights(t *testing.T) { + tmp := t.TempDir() + taskPath := setupUltraSearchTaskSet(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + var expected []int + for i, tsk := range m.tasks { + if tsk.Project == "home" || strings.Contains(strings.Join(tsk.Tags, " "), "home") { + expected = append(expected, i) + } + } + if len(expected) != 2 { + t.Fatalf("test setup failed: expected 2 matching tasks, got %d", len(expected)) + } + + step := func(msg tea.KeyPressMsg) { + t.Helper() + mv, _ := (&m).Update(msg) + m = *mv.(*Model) + } + + step(tea.KeyPressMsg{Code: 'u', Text: "u"}) + if !m.showUltra { + t.Fatalf("u did not enter ultra mode") + } + + step(tea.KeyPressMsg{Code: '/', Text: "/"}) + if !m.ultraSearching { + t.Fatalf("/ did not start ultra search") + } + for _, r := range "home" { + step(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + step(tea.KeyPressMsg{Code: tea.KeyEnter}) + + if m.ultraSearching { + t.Fatalf("enter did not close ultra search") + } + if m.ultraSearchRegex == nil { + t.Fatalf("enter did not compile ultra search regex") + } + if !reflect.DeepEqual(m.ultraFiltered, expected) { + t.Fatalf("unexpected ultraFiltered: got %#v want %#v", m.ultraFiltered, expected) + } + if m.ultraCursor != 0 || m.ultraOffset != 0 { + t.Fatalf("search did not reset cursor/offset, got cursor=%d offset=%d", m.ultraCursor, m.ultraOffset) + } + if structured := m.ultraSearchText(m.tasks[0]); !regexp.MustCompile(`(?m)^project:`).MatchString(structured) { + t.Fatalf("ultra search text lost card line structure: %q", structured) + } + + filtered := m.ultraTaskList() + if len(filtered) != 2 { + t.Fatalf("unexpected filtered task count: %d", len(filtered)) + } + plain := m.renderUltraCard(filtered[0], 80, true, nil) + highlighted := m.renderUltraCard(filtered[0], 80, true, m.ultraSearchRegex) + if plain == highlighted { + t.Fatalf("search highlighting did not change rendered card") + } + if ansi.Strip(plain) != ansi.Strip(highlighted) { + t.Fatalf("search highlighting changed visible text") + } + labelHighlighted := m.renderUltraCard(filtered[0], 80, true, regexp.MustCompile(`project:`)) + if labelHighlighted == plain { + t.Fatalf("label search did not change rendered card") + } + if ansi.Strip(labelHighlighted) != ansi.Strip(plain) { + t.Fatalf("label search highlighting changed visible text") + } + combinedHighlighted := m.renderUltraCard(filtered[0], 80, true, regexp.MustCompile(`project: home`)) + if combinedHighlighted == plain { + t.Fatalf("combined meta search did not change rendered card") + } + if ansi.Strip(combinedHighlighted) != ansi.Strip(plain) { + t.Fatalf("combined meta search highlighting changed visible text") + } + priorityHighlighted := m.renderUltraCard(filtered[0], 80, true, regexp.MustCompile(`H`)) + if priorityHighlighted == plain { + t.Fatalf("priority search did not change rendered card") + } + if ansi.Strip(priorityHighlighted) != ansi.Strip(plain) { + t.Fatalf("priority search highlighting changed visible text") + } + + step(tea.KeyPressMsg{Code: 'n', Text: "n"}) + if got := m.ultraTaskList()[m.ultraCursor].ID; got != filtered[1].ID { + t.Fatalf("n did not move to next filtered task: got %d want %d", got, filtered[1].ID) + } + step(tea.KeyPressMsg{Code: 'N', Text: "N"}) + if got := m.ultraTaskList()[m.ultraCursor].ID; got != filtered[0].ID { + t.Fatalf("N did not move to previous filtered task: got %d want %d", got, filtered[0].ID) + } + + prevFiltered := append([]int(nil), m.ultraFiltered...) + prevRegex := m.ultraSearchRegex + step(tea.KeyPressMsg{Code: '/', Text: "/"}) + step(tea.KeyPressMsg{Code: '[', Text: "["}) + if !m.ultraSearching { + t.Fatalf("invalid regex should keep ultra search open") + } + step(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !reflect.DeepEqual(m.ultraFiltered, prevFiltered) { + t.Fatalf("invalid regex changed filtered tasks: got %#v want %#v", m.ultraFiltered, prevFiltered) + } + if m.ultraSearchRegex != prevRegex { + t.Fatalf("invalid regex changed active regex: got %#v want %#v", m.ultraSearchRegex, prevRegex) + } + step(tea.KeyPressMsg{Code: tea.KeyEsc}) + + step(tea.KeyPressMsg{Code: '/', Text: "/"}) + for _, r := range "annotation" { + step(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + step(tea.KeyPressMsg{Code: tea.KeyEnter}) + if got := len(m.ultraTaskList()); got != 0 { + t.Fatalf("search matched invisible annotation label, got %d tasks", got) + } + + step(tea.KeyPressMsg{Code: '/', Text: "/"}) + for _, r := range "beta bravo" { + step(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + step(tea.KeyPressMsg{Code: tea.KeyEnter}) + if got := len(m.ultraTaskList()); got != 1 { + t.Fatalf("multi-word ultra search match count = %d, want 1", got) + } + if got := m.ultraTaskList()[0].ID; got != 2 { + t.Fatalf("multi-word ultra search matched task %d, want 2", got) + } + mv, _ := (&m).Update(tea.WindowSizeMsg{Width: 24, Height: 24}) + m = *mv.(*Model) + if got := len(m.ultraTaskList()); got != 1 { + t.Fatalf("resize changed multi-word ultra search match count = %d, want 1", got) + } + if got := m.ultraTaskList()[0].ID; got != 2 { + t.Fatalf("resize changed multi-word ultra search match to task %d, want 2", got) + } + + step(tea.KeyPressMsg{Code: '/', Text: "/"}) + step(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.ultraSearchRegex != nil { + t.Fatalf("empty ultra search did not clear regex") + } + if m.ultraFiltered != nil { + t.Fatalf("empty ultra search did not clear filtered tasks") + } + if got := len(m.ultraTaskList()); got != len(m.tasks) { + t.Fatalf("empty ultra search did not restore all tasks, got %d want %d", got, len(m.tasks)) + } +} + func TestUltraFocusedIDLifecycleAcrossNormalEditEntryAndReload(t *testing.T) { tmp := t.TempDir() taskPath := setupBasicTask(t, tmp) @@ -855,6 +1177,61 @@ func TestUltraFocusedIDLifecycleAcrossNormalEditEntryAndReload(t *testing.T) { } } +func TestUltraSearchReloadRebuildsMatches(t *testing.T) { + tmp := t.TempDir() + taskPath, phaseFile := setupUltraSearchReloadTaskSet(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + step := func(msg tea.KeyPressMsg) { + t.Helper() + mv, _ := (&m).Update(msg) + m = *mv.(*Model) + } + + step(tea.KeyPressMsg{Code: 'u', Text: "u"}) + step(tea.KeyPressMsg{Code: '/', Text: "/"}) + for _, r := range "alpha" { + step(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + step(tea.KeyPressMsg{Code: tea.KeyEnter}) + + if got := len(m.ultraTaskList()); got != 1 { + t.Fatalf("initial search match count = %d, want 1", got) + } + if got := m.ultraTaskList()[0].ID; got != 1 { + t.Fatalf("initial search matched task %d, want 1", got) + } + + if err := os.WriteFile(phaseFile, []byte("2"), 0o644); err != nil { + t.Fatalf("WriteFile phase: %v", err) + } + if err := m.reload(); err != nil { + t.Fatalf("reload: %v", err) + } + + expectedRow := m.taskIndexByID(2) + if expectedRow < 0 { + t.Fatalf("reloaded tasks missing task 2") + } + if !reflect.DeepEqual(m.ultraFiltered, []int{expectedRow}) { + t.Fatalf("reload did not rebuild search matches: got %#v want %#v", m.ultraFiltered, []int{expectedRow}) + } + if got := len(m.ultraTaskList()); got != 1 { + t.Fatalf("reloaded search match count = %d, want 1", got) + } + if got := m.ultraTaskList()[m.ultraCursor].ID; got != 2 { + t.Fatalf("reload kept stale search match %d, want 2", got) + } + if got := m.tbl.Cursor(); got != expectedRow { + t.Fatalf("table cursor after search reload = %d, want %d", got, expectedRow) + } +} + func TestUltraEntryResizeAndNavigationBindings(t *testing.T) { tmp := t.TempDir() taskPath := setupUltraTaskSet(t, tmp) @@ -1232,3 +1609,60 @@ func TestSearchExitHotkeys(t *testing.T) { t.Fatalf("q did not clear search") } } + +func TestUltraResizeSyncRefreshesNormalSearchSelection(t *testing.T) { + tmp := t.TempDir() + taskPath := setupSharedSearchTaskSet(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + step := func(msg tea.Msg) { + t.Helper() + mv, _ := (&m).Update(msg) + m = *mv.(*Model) + } + + step(tea.WindowSizeMsg{Width: 120, Height: 24}) + step(tea.KeyPressMsg{Code: '/', Text: "/"}) + for _, r := range "shared" { + step(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + step(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.searchRegex == nil { + t.Fatalf("normal search regex not set") + } + if got := m.tbl.Cursor(); got != 0 { + t.Fatalf("initial search cursor = %d, want 0", got) + } + if got := m.tbl.ColumnCursor(); got != 8 { + t.Fatalf("initial search column = %d, want 8", got) + } + + step(tea.KeyPressMsg{Code: 'u', Text: "u"}) + if !m.showUltra { + t.Fatalf("u did not enter ultra mode") + } + step(tea.KeyPressMsg{Code: 'j', Text: "j"}) + if got := m.ultraCursor; got != 1 { + t.Fatalf("ultra cursor = %d, want 1", got) + } + + step(tea.WindowSizeMsg{Width: 100, Height: 24}) + if got := m.tbl.Cursor(); got != 1 { + t.Fatalf("hidden table cursor after ultra resize = %d, want 1", got) + } + + rows := m.tbl.Rows() + wantPrev := m.taskToRowSearch(m.tasks[0], m.searchRegex, m.tblStyles, -1) + wantNew := m.taskToRowSearch(m.tasks[1], m.searchRegex, m.tblStyles, m.tbl.ColumnCursor()) + if !reflect.DeepEqual(rows[0], wantPrev) { + t.Fatalf("previous row retained stale search selection after ultra resize") + } + if !reflect.DeepEqual(rows[1], wantNew) { + t.Fatalf("new row did not receive refreshed search selection after ultra resize") + } +} |
