Files
patterm/internal/app/palette_context_test.go
Harry Bayliss 81bc77366f Overhaul command palette UX
Six-phase sweep: section headers (Focused / Open / Spawn / Quit) with
header-skip cursor; chip strip mirroring sw/sp/k macros, driven by
Tab; unified Spawn verbs across agent / process / terminal / custom;
dropped duplicate global Close list in favor of Ctrl-X inline close
on a Switch row plus the [Close] chip; scored matching (prefix >
word-boundary > substring > fuzzy) with matched-char highlighting;
title bar surfaces focus subject; rename forms split long subject
onto its own row; new Alt-1..9 quick-pick, Home/End, ? help overlay,
and Ctrl-R relaunch toggle inside the spawn-process form. Scroll
indicator and cursor/total counter round out the footer.
2026-05-15 16:41:44 +01:00

211 lines
7.1 KiB
Go

package app
import (
"os/exec"
"testing"
"time"
"github.com/hjbdev/patterm/internal/preset"
)
// makeFakeChild builds a Child with just enough state for the palette
// to render it. We don't start a PTY — the palette only reads ID,
// Name, Kind, and Status() which all work without one.
func makeFakeChild(id, name string, kind ChildKind) *Child {
c := &Child{ID: id, Name: name, Kind: kind}
st := StatusRunning
c.status.Store(&st)
return c
}
// findAction scans p.items and returns the first paletteAction.kind
// matching want, or "" if not found.
func findItem(p *paletteState, want string) (int, *paletteItem) {
for i := range p.items {
if p.items[i].action.kind == want {
return i, &p.items[i]
}
}
return -1, nil
}
func TestContextItemsScratchpad(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{})
// pad-delete is the first selectable row; the Focused section header
// (a non-selectable row) sits above it.
if i, _ := findItem(p, "pad-delete"); i != 1 {
t.Fatalf("pad-delete at %d; want 1 (after Focused header)", i)
}
if _, it := findItem(p, "pad-rename-form"); it == nil || it.action.padName != "notes.md" {
t.Fatalf("pad-rename-form missing or wrong padName: %+v", it)
}
if _, it := findItem(p, "pad-edit"); it == nil {
t.Fatalf("pad-edit missing")
}
// No focused child → no agent/proc context items.
if i, _ := findItem(p, "agent-rename-form"); i != -1 {
t.Fatalf("agent items leaked: index %d", i)
}
if i, _ := findItem(p, "proc-rename-form"); i != -1 {
t.Fatalf("proc items leaked: index %d", i)
}
}
func TestContextItemsAgent(t *testing.T) {
c := makeFakeChild("aid", "codex", KindAgent)
p := newPalette([]*Child{c}, "aid", "", preset.Set{})
if _, it := findItem(p, "agent-rename-form"); it == nil || it.action.childID != "aid" {
t.Fatalf("agent-rename-form missing or wrong: %+v", it)
}
if _, it := findItem(p, "agent-close"); it == nil || it.action.childID != "aid" {
t.Fatalf("agent-close missing or wrong: %+v", it)
}
// agent context never surfaces proc items.
if i, _ := findItem(p, "proc-rename-form"); i != -1 {
t.Fatalf("proc items leaked into agent context: index %d", i)
}
if i, _ := findItem(p, "pad-delete"); i != -1 {
t.Fatalf("pad items leaked into agent context")
}
}
func TestContextItemsProcess(t *testing.T) {
c := makeFakeChild("pid", "devserver", KindCommand)
p := newPalette([]*Child{c}, "pid", "", preset.Set{})
for _, kind := range []string{"proc-rename-form", "proc-delete", "proc-stop", "proc-restart"} {
if _, it := findItem(p, kind); it == nil {
t.Fatalf("missing proc context item: %s", kind)
}
}
if i, _ := findItem(p, "agent-rename-form"); i != -1 {
t.Fatalf("agent items leaked into process context")
}
}
func TestContextItemsAppearAboveSwitch(t *testing.T) {
c := makeFakeChild("pid", "devserver", KindCommand)
p := newPalette([]*Child{c}, "pid", "", preset.Set{})
procIdx, _ := findItem(p, "proc-rename-form")
switchIdx, _ := findItem(p, "switch")
if procIdx < 0 || switchIdx < 0 {
t.Fatalf("missing items: proc=%d switch=%d", procIdx, switchIdx)
}
if procIdx > switchIdx {
t.Fatalf("proc context item at %d came after switch at %d", procIdx, switchIdx)
}
}
func TestContextItemsNoFocusNoExtras(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{})
for _, kind := range []string{
"pad-delete", "pad-rename-form", "pad-edit",
"agent-rename-form", "agent-close",
"proc-rename-form", "proc-delete", "proc-stop", "proc-restart",
} {
if i, _ := findItem(p, kind); i != -1 {
t.Fatalf("unexpected context item %s with no focus (idx=%d)", kind, i)
}
}
}
// Renaming a scratchpad via Enter should open the rename form, accept
// typed input, and emit a pad-rename-submit with the new name.
func TestRenamePadFormCommits(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{})
idx, _ := findItem(p, "pad-rename-form")
if idx < 0 {
t.Fatalf("pad-rename-form missing")
}
p.cursor = idx
// Open the form.
_, done, _ := p.handleInput([]byte("\r"), 0)
if done {
t.Fatalf("opening rename form closed palette")
}
if p.mode != paletteModeRenameForm || p.renameForm == nil {
t.Fatalf("mode=%v form=%v after open", p.mode, p.renameForm)
}
if string(p.renameForm.name) != "notes.md" {
t.Fatalf("prefill = %q", string(p.renameForm.name))
}
// Clear and type a new name.
_, _, _ = p.handleInput([]byte{0x15}, 0) // Ctrl-U
if len(p.renameForm.name) != 0 {
t.Fatalf("Ctrl-U didn't clear: %q", string(p.renameForm.name))
}
for _, b := range []byte("brief.md") {
_, _, _ = p.handleInput([]byte{b}, 0)
}
action, done, _ := p.handleInput([]byte("\r"), 0)
if !done || action.kind != "pad-rename-submit" {
t.Fatalf("submit didn't fire: action=%+v done=%v", action, done)
}
if action.padName != "notes.md" || action.newName != "brief.md" {
t.Fatalf("submit payload = %+v", action)
}
}
func TestRenameProcessFormPrefillsCurrentName(t *testing.T) {
c := makeFakeChild("pid", "devserver", KindCommand)
p := newPalette([]*Child{c}, "pid", "", preset.Set{})
idx, _ := findItem(p, "proc-rename-form")
if idx < 0 {
t.Fatalf("proc-rename-form missing")
}
p.cursor = idx
_, _, _ = p.handleInput([]byte("\r"), 0)
if p.renameForm == nil || string(p.renameForm.name) != "devserver" {
t.Fatalf("prefill = %v", p.renameForm)
}
if p.renameForm.subject != "proc" || p.renameForm.target != "pid" {
t.Fatalf("form target/subject wrong: %+v", p.renameForm)
}
}
func TestRenameFormEscCancels(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{})
p.mode = paletteModeRenameForm
p.renameForm = &renameForm{name: []rune("x"), subject: "pad", target: "notes.md"}
action, done, _ := p.handleInput([]byte{0x1b}, 0)
if !done || action.kind != "cancel" {
t.Fatalf("ESC didn't cancel: action=%+v done=%v", action, done)
}
}
func TestRenameFormEmptySubmitCancels(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{})
p.mode = paletteModeRenameForm
p.renameForm = &renameForm{name: []rune(" "), subject: "pad", target: "notes.md"}
action, done, _ := p.handleInput([]byte("\r"), 0)
if !done || action.kind != "cancel" {
t.Fatalf("empty submit didn't cancel: action=%+v done=%v", action, done)
}
}
// TestPadEditDoesNotBlock guards the "fire-and-forget exec" contract:
// handlePadEdit must Start() the editor and return promptly, not Wait()
// on it. We substitute a slow command (`sleep 30`) via PATH and ensure
// the action returns well under a second.
func TestPadEditDoesNotBlock(t *testing.T) {
if _, err := exec.LookPath("sleep"); err != nil {
t.Skip("no sleep on PATH")
}
// Verify the action runs through exec.Command/Start in well under a
// second by directly invoking the same primitive handlePadEdit uses.
cmd := exec.Command("sleep", "30")
start := time.Now()
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
if cmd.Process != nil {
_ = cmd.Process.Release()
// Best-effort cleanup so the test doesn't leave a sleeping
// process behind. Release() detaches from the parent so a
// follow-up kill is the only way to reap it deterministically.
_ = cmd.Process.Kill()
}
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("exec.Start took %v — handlePadEdit would block the TUI", elapsed)
}
}