Focused-section rows are now bare verbs (Rename, Close, Stop, Restart, Delete, Edit) instead of repeating the focused name. The title bar already carries the subject, and the row hint preserves fuzzy-search matches like "close codex". Section banners are replaced by a single blank spacer row so the verbs themselves carry the visual weight, and the Open section no longer lists "Switch to <current>" for the pane that's already focused.
409 lines
12 KiB
Go
409 lines
12 KiB
Go
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 TestPaletteSectionsSeparatedBySpacers(t *testing.T) {
|
||
// Section-named dashed headers are gone; groups are visually
|
||
// separated by a single non-selectable blank row. Verify that the
|
||
// build emits one such spacer between every pair of adjacent groups
|
||
// and never a leading spacer.
|
||
c := makeFakeChild("a", "claude", KindAgent)
|
||
other := makeFakeChild("b", "worker", KindCommand)
|
||
p := newPalette([]*Child{c, other}, "a", "",
|
||
preset.Set{Agents: []*preset.Preset{{Name: "codex"}}})
|
||
|
||
if len(p.items) == 0 {
|
||
t.Fatalf("palette built no items")
|
||
}
|
||
if p.items[0].action.kind == "header" {
|
||
t.Fatalf("first row is a spacer; should be a selectable item")
|
||
}
|
||
transitions := 0
|
||
prevGroup := p.items[0].group
|
||
for i := 1; i < len(p.items); i++ {
|
||
it := p.items[i]
|
||
if it.group != prevGroup {
|
||
if it.action.kind != "header" || it.label != "" {
|
||
t.Fatalf("group transition at %d not a blank spacer: %+v", i, it)
|
||
}
|
||
transitions++
|
||
// The row immediately after the spacer must be selectable.
|
||
if i+1 >= len(p.items) || p.items[i+1].action.kind == "header" {
|
||
t.Fatalf("spacer at %d not followed by selectable row", i)
|
||
}
|
||
prevGroup = p.items[i+1].group
|
||
}
|
||
// No dashed banners anywhere.
|
||
if it.action.kind == "header" && strings.Contains(it.label, "──") {
|
||
t.Errorf("dashed section header still present at %d: %q", i, it.label)
|
||
}
|
||
}
|
||
if transitions == 0 {
|
||
t.Fatalf("no section transitions found in palette items")
|
||
}
|
||
}
|
||
|
||
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 TestAutoSummaryCadenceCyclesSoloValues(t *testing.T) {
|
||
p := newPalette(nil, "", "", preset.Set{}, defaultSettings())
|
||
p.mode = paletteModeAutoSummary
|
||
for i, row := range autoSummaryRows() {
|
||
if row.key == "cadence" {
|
||
p.cursor = i
|
||
break
|
||
}
|
||
}
|
||
if p.settings.AutoSummary.Cadence != "1m" {
|
||
t.Fatalf("initial cadence = %q", p.settings.AutoSummary.Cadence)
|
||
}
|
||
p.activateAutoSummaryRow()
|
||
if p.settings.AutoSummary.Cadence != "15s" {
|
||
t.Fatalf("first cycle cadence = %q", p.settings.AutoSummary.Cadence)
|
||
}
|
||
p.activateAutoSummaryRow()
|
||
if p.settings.AutoSummary.Cadence != "30s" {
|
||
t.Fatalf("second cycle cadence = %q", p.settings.AutoSummary.Cadence)
|
||
}
|
||
p.activateAutoSummaryRow()
|
||
if p.settings.AutoSummary.Cadence != "1m" {
|
||
t.Fatalf("third cycle cadence = %q", p.settings.AutoSummary.Cadence)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|