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) } }