package app import ( "strings" "testing" "github.com/hjbdev/patterm/internal/preset" ) // -- Phase 1: naming & dropped global Close list --------------------- func TestPaletteVerbsAreUnified(t *testing.T) { procs := []*preset.Preset{{Name: "dev"}} agents := []*preset.Preset{{Name: "claude"}} p := newPalette(nil, "", "", preset.Set{Agents: agents, Processes: procs}) gotLabels := make([]string, 0, len(p.items)) for _, it := range p.items { if it.action.kind == "header" { continue } gotLabels = append(gotLabels, it.label) } joined := strings.Join(gotLabels, "\n") mustContain := []string{ "Spawn agent: claude", "Spawn process: dev", "Spawn terminal", "Spawn process… (custom)", } for _, want := range mustContain { if !strings.Contains(joined, want) { t.Errorf("missing unified-verb label %q in:\n%s", want, joined) } } // The pre-overhaul verb forms must not appear anywhere. mustNotContain := []string{"Run process:", "New Terminal", "Spawn process… (custom)"} for _, bad := range mustNotContain { if strings.Contains(joined, bad) { t.Errorf("leftover legacy verb %q present in:\n%s", bad, joined) } } } func TestPaletteDropsGlobalCloseList(t *testing.T) { c1 := makeFakeChild("a", "claude", KindAgent) c2 := makeFakeChild("b", "dev", KindCommand) p := newPalette([]*Child{c1, c2}, "", "", preset.Set{}) // No focus → no Focused context, so no "kill" / "agent-close" / // "proc-stop" rows should exist at all. for _, kind := range []string{"kill", "agent-close", "proc-stop", "proc-delete"} { if i, _ := findItem(p, kind); i != -1 { t.Fatalf("kind %q present at %d; global Close list should be gone", kind, i) } } } // -- Phase 2: section headers and cursor skip ------------------------ func TestPaletteSectionHeadersPresent(t *testing.T) { c := makeFakeChild("a", "claude", KindAgent) p := newPalette([]*Child{c}, "a", "", preset.Set{Agents: []*preset.Preset{{Name: "codex"}}}) wantSections := []string{"Focused", "Open", "Spawn", "Quit"} for _, w := range wantSections { found := false for _, it := range p.items { if it.action.kind == "header" && strings.Contains(it.label, w) { found = true break } } if !found { t.Errorf("section header %q missing from items", w) } } } func TestPaletteCursorSkipsHeaders(t *testing.T) { pr := []*preset.Preset{{Name: "a"}, {Name: "b"}} p := newPalette(nil, "", "", preset.Set{Agents: pr}) // Initial cursor must land on a selectable row, never a header. if p.items[p.cursor].action.kind == "header" { t.Fatalf("initial cursor sits on a header: %+v", p.items[p.cursor]) } // Walk to the end with cursorDown; every stop must be selectable. for i := 0; i < len(p.items)*2; i++ { p.cursorDown() if p.items[p.cursor].action.kind == "header" { t.Fatalf("cursorDown landed on a header at index %d", p.cursor) } } // Walk back to top. for i := 0; i < len(p.items)*2; i++ { p.cursorUp() if p.items[p.cursor].action.kind == "header" { t.Fatalf("cursorUp landed on a header at index %d", p.cursor) } } } func TestPaletteEnterOnHeaderIsNoOp(t *testing.T) { pr := []*preset.Preset{{Name: "a"}} p := newPalette(nil, "", "", preset.Set{Agents: pr}) // Force the cursor onto a header. for i, it := range p.items { if it.action.kind == "header" { p.cursor = i break } } _, done, _ := p.handleInput([]byte("\r"), 0) if done { t.Fatalf("Enter on header closed palette; expected no-op") } } // -- Phase 3: filter chips & macro coexistence ----------------------- func TestPaletteTabCyclesChip(t *testing.T) { p := newTestPalette() // All → Open _, _, _ = p.handleInput([]byte{'\t'}, 0) if string(p.query) != "sw " { t.Fatalf("Tab #1: query %q, want %q", string(p.query), "sw ") } // Open → Spawn _, _, _ = p.handleInput([]byte{'\t'}, 0) if string(p.query) != "sp " { t.Fatalf("Tab #2: query %q, want %q", string(p.query), "sp ") } // Spawn → Close _, _, _ = p.handleInput([]byte{'\t'}, 0) if string(p.query) != "k " { t.Fatalf("Tab #3: query %q, want %q", string(p.query), "k ") } // Close → All (wraps) _, _, _ = p.handleInput([]byte{'\t'}, 0) if string(p.query) != "" { t.Fatalf("Tab #4 wrap: query %q, want empty", string(p.query)) } } func TestPaletteShiftTabCyclesBackwards(t *testing.T) { p := newTestPalette() // Shift-Tab via legacy CSI Z: All → Close _, _, _ = p.handleInput([]byte("\x1b[Z"), 0) if string(p.query) != "k " { t.Fatalf("Shift-Tab: query %q, want %q", string(p.query), "k ") } } func TestPaletteBackspaceThroughTrailingMacro(t *testing.T) { p := newTestPalette() p.query = []rune("sw ") p.rebuild() p.backspace() if string(p.query) != "" { t.Fatalf("backspace through 'sw ' left %q; want empty", string(p.query)) } } func TestPaletteMacroPreservesQueryCase(t *testing.T) { // Tab cycling shouldn't downcase the user-typed search text. p := newTestPalette() p.query = []rune("Foo") p.rebuild() _, _, _ = p.handleInput([]byte{'\t'}, 0) if string(p.query) != "sw Foo" { t.Fatalf("query after Tab over 'Foo' = %q; want 'sw Foo'", string(p.query)) } } // -- Phase 4: scored matching ---------------------------------------- func TestFuzzyScorePrefixBeatsBoundaryBeatsSubstring(t *testing.T) { prefix, _ := fuzzyScore("spawn agent: foo", "", "spa") boundary, _ := fuzzyScore("hello spam", "", "spa") substring, _ := fuzzyScore("escapade", "", "spa") if !(prefix > boundary && boundary > substring) { t.Fatalf("score ordering wrong: prefix=%d boundary=%d substring=%d", prefix, boundary, substring) } } func TestFuzzyScoreReturnsMatchPositions(t *testing.T) { _, pos := fuzzyScore("spawn process: dev", "", "dev") want := []int{15, 16, 17} if len(pos) != len(want) { t.Fatalf("positions = %v, want %v", pos, want) } for i, p := range pos { if p != want[i] { t.Fatalf("pos[%d] = %d, want %d (full %v)", i, p, want[i], pos) } } } func TestPaletteScoredResultsDropHeaders(t *testing.T) { pr := []*preset.Preset{{Name: "claude"}, {Name: "codex"}} p := newPalette(nil, "", "", preset.Set{Agents: pr}) // Type a needle that matches both. p.query = []rune("c") p.rebuild() for _, it := range p.items { if it.action.kind == "header" { t.Fatalf("scored mode should not emit header rows; got %+v", it) } } } func TestPaletteScoringFloatsPrefixMatchToTop(t *testing.T) { // "x" is a prefix of "xtest" preset; it's a scattered-fuzzy match // against many other rows. Scoring should land the prefix match at // the top regardless of group order. pr := []*preset.Preset{ {Name: "alpha"}, {Name: "xtest"}, {Name: "beta"}, } p := newPalette(nil, "", "", preset.Set{Agents: pr}) p.query = []rune("xt") p.rebuild() if len(p.items) == 0 { t.Fatalf("no scored items for needle 'xt'") } if !strings.Contains(p.items[0].label, "xtest") { t.Fatalf("expected xtest at top of scored list, got %q", p.items[0].label) } } // -- Phase 5: power-user accelerators -------------------------------- func TestPaletteCtrlXOnSwitchKills(t *testing.T) { c := makeFakeChild("a", "claude", KindAgent) p := newPalette([]*Child{c}, "", "", preset.Set{}) // Cursor should already be on the switch row (it's the first // selectable item with no Focused section). idx, _ := findItem(p, "switch") if idx < 0 { t.Fatalf("no switch item in palette") } p.cursor = idx action, done, _ := p.handleInput([]byte{0x18}, 0) if !done { t.Fatalf("Ctrl-X on switch row didn't close palette: action=%+v", action) } if action.kind != "kill" || action.childID != "a" { t.Fatalf("Ctrl-X action = %+v, want kill of 'a'", action) } } func TestPaletteCtrlXOnNonSwitchIsNoOp(t *testing.T) { p := newPalette(nil, "", "", preset.Set{}) // Cursor parks on Quit or Spawn entries — neither is a switch row. _, done, _ := p.handleInput([]byte{0x18}, 0) if done { t.Fatalf("Ctrl-X on non-switch closed palette") } } func TestPaletteHelpToggle(t *testing.T) { p := newTestPalette() // `?` with empty query opens help. _, done, _ := p.handleInput([]byte("?"), 0) if done { t.Fatalf("? closed palette") } if !p.showHelp { t.Fatalf("? didn't open help") } // Next keystroke dismisses. _, _, _ = p.handleInput([]byte("a"), 0) if p.showHelp { t.Fatalf("help still showing after dismissing keystroke") } } func TestPaletteHelpDoesNotInterceptInQuery(t *testing.T) { p := newTestPalette() p.query = []rune("dev") p.rebuild() _, _, _ = p.handleInput([]byte("?"), 0) if p.showHelp { t.Fatalf("? with non-empty query incorrectly opened help") } if string(p.query) != "dev?" { t.Fatalf("? with non-empty query failed to append: %q", string(p.query)) } } func TestPaletteHomeEndJumpsOverHeaders(t *testing.T) { pr := []*preset.Preset{{Name: "a"}, {Name: "b"}} p := newPalette(nil, "", "", preset.Set{Agents: pr}) // End jumps to last selectable. p.cursorEnd() if p.items[p.cursor].action.kind == "header" { t.Fatalf("End landed on header: %+v", p.items[p.cursor]) } if p.items[p.cursor].action.kind != "quit" { t.Fatalf("End on simple palette should park on Quit; got %+v", p.items[p.cursor]) } // Home returns to first selectable. p.cursorHome() if p.items[p.cursor].action.kind == "header" { t.Fatalf("Home landed on header: %+v", p.items[p.cursor]) } } func TestPaletteAltDigitQuickPick(t *testing.T) { pr := []*preset.Preset{{Name: "first"}, {Name: "second"}} p := newPalette(nil, "", "", preset.Set{Agents: pr}) // Alt-1 picks the first selectable item (Spawn agent: first). action, done, adv := p.handleInput([]byte("\x1b1"), 0) if adv != 2 { t.Fatalf("Alt-1 advance %d, want 2", adv) } if !done { t.Fatalf("Alt-1 didn't close palette") } if action.kind != "spawn-agent" || action.preset == nil || action.preset.Name != "first" { t.Fatalf("Alt-1 action = %+v, want spawn-agent first", action) } } func TestPaletteFormCtrlRTogglesRelaunchFromCommandField(t *testing.T) { p := newPalette(nil, "", "", preset.Set{}) p.mode = paletteModeSpawnForm p.form = &spawnProcessForm{} // Type without leaving the command field, then Ctrl-R. for _, b := range []byte("xyz") { _, _, _ = p.handleInput([]byte{b}, 0) } if p.form.field != 0 { t.Fatalf("field jumped to %d", p.form.field) } _, _, _ = p.handleInput([]byte{0x12}, 0) if !p.form.relaunch { t.Fatalf("Ctrl-R didn't toggle relaunch from command field") } // Second press toggles back. _, _, _ = p.handleInput([]byte{0x12}, 0) if p.form.relaunch { t.Fatalf("second Ctrl-R didn't toggle off") } } // -- Phase 6: counter / scroll indicator ----------------------------- func TestPaletteFooterCounter(t *testing.T) { pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}} p := newPalette(nil, "", "", preset.Set{Agents: pr}) total := p.visibleSelectableCount() if total < 4 { // 3 spawn-agents + terminal + custom + quit t.Fatalf("expected ≥4 selectables; got %d", total) } idx := p.selectableIndex() if idx <= 0 { t.Fatalf("selectable index = %d on freshly-built palette; want ≥1", idx) } }