Files
patterm/internal/app/palette_context_test.go
2026-05-21 15:45:01 +01:00

234 lines
8.0 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 TestContextItemsTerminalUsesCloseNotStop(t *testing.T) {
c := makeFakeChild("tid", "terminal", KindTerminal)
p := newPalette([]*Child{c}, "tid", "", preset.Set{})
if _, it := findItem(p, "proc-stop"); it == nil || it.label != "Close" {
t.Fatalf("terminal close row missing or mislabelled: %+v", it)
}
if _, it := findItem(p, "proc-restart"); it == nil {
t.Fatalf("terminal restart row missing")
}
if i, _ := findItem(p, "proc-delete"); i != -1 {
t.Fatalf("terminal should not show a separate delete/close row, found at %d", i)
}
for i, it := range p.items {
if it.label == "Stop" {
t.Fatalf("terminal should not show Stop row, found at %d", i)
}
}
}
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)
}
}