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.
215 lines
7.4 KiB
Go
215 lines
7.4 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{})
|
|
// With the dashed section header gone, pad-edit is the first row;
|
|
// pad-rename-form follows, with destructive pad-delete last in the
|
|
// Focused section.
|
|
if i, _ := findItem(p, "pad-edit"); i != 0 {
|
|
t.Fatalf("pad-edit at %d; want 0", 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 i, _ := findItem(p, "pad-delete"); i < 0 {
|
|
t.Fatalf("pad-delete 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) {
|
|
// Two children so there's still a non-focused switch entry to compare
|
|
// against (the focused child is suppressed from the Open section).
|
|
focused := makeFakeChild("pid", "devserver", KindCommand)
|
|
other := makeFakeChild("oid", "worker", KindCommand)
|
|
p := newPalette([]*Child{focused, other}, "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)
|
|
}
|
|
}
|