package tui
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"harness/internal/skills"
)
// The menu belongs to a slash command being typed, and to nothing else: a
// message that merely mentions one must not open it.
func TestMenuQuery(t *testing.T) {
cases := []struct {
in string
want string
wantOpen bool
}{
{"/", "", true},
{"/ef", "ef", true},
{"/EF", "ef", true},
{"", "", false},
{"hello", "", false},
{"see /help for that", "", false},
{"/effort high", "", false}, // the name is settled; the menu is done
{"/help\nmore", "", false}, // a second line means a message, not a command
{" /help", "", false},
}
for _, tc := range cases {
got, open := menuQuery(tc.in)
if open != tc.wantOpen || got != tc.want {
t.Errorf("menuQuery(%q) = %q, %v; want %q, %v", tc.in, got, open, tc.want, tc.wantOpen)
}
}
}
func names(cmds []command) []string {
var out []string
for _, c := range cmds {
out = append(out, c.name)
}
return out
}
// Typing narrows the list, and a name that starts with what was typed beats
// one that merely contains it.
func TestMatchCommandsRanksPrefixesFirst(t *testing.T) {
all := []command{
{name: "allowed"},
{name: "clear"},
{name: "effort"},
{name: "reformat-everything"},
}
cases := map[string][]string{
"": {"allowed", "clear", "effort", "reformat-everything"},
"e": {"effort", "allowed", "clear", "reformat-everything"},
"ef": {"effort", "reformat-everything"},
"format": {"reformat-everything"},
"zz": nil,
}
for typed, want := range cases {
got := names(matchCommands(all, typed))
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("matchCommands(%q) = %v, want %v", typed, got, want)
}
}
}
// The catalogue is the union of what the TUI answers itself and what the
// session discovered, so a skill is reachable from the menu by name.
func TestCommandsListBuiltinsAndSkills(t *testing.T) {
m := newTestModel(t)
m.opts.Skills = []skills.Skill{
{Name: "brainstorming", Description: "explore an idea", Source: "personal"},
}
got := names(m.commands())
for _, want := range []string{"help", "effort", "skills", "exit", "brainstorming"} {
if !strings.Contains(strings.Join(got, ","), want) {
t.Errorf("command %q missing from the catalogue %v", want, got)
}
}
for _, c := range m.commands() {
if c.desc == "" {
t.Errorf("command /%s has no description to show in the menu", c.name)
}
}
}
// Every name the menu offers must be one submit actually answers; otherwise
// the menu is advertising commands that report themselves as unknown.
func TestEveryOfferedCommandIsHandled(t *testing.T) {
catalogue := newTestModel(t).commands()
for _, c := range catalogue {
m := newTestModel(t)
m.opts.Skills = []skills.Skill{{Name: "brainstorming", Description: "explore an idea"}}
m.submit("/" + c.name)
if out := m.tr.render(); strings.Contains(out, "unknown command") {
t.Errorf("/%s is offered by the menu but not handled: %q", c.name, out)
}
}
}
// Moving and accepting: the highlighted name lands in the prompt box, and
// accepting is not sending — the turn starts on the next enter.
func TestMenuCompletesWithoutSending(t *testing.T) {
m := newTestModel(t)
typeIn(m, "/")
if !m.menuOpen() {
t.Fatal("typing a slash did not open the menu")
}
first := m.menu.items[0].name
m.Update(key("down"))
second := m.menu.items[m.menu.idx].name
if second == first {
t.Fatal("down did not move the highlight")
}
m.Update(key("enter"))
if m.menuOpen() {
t.Error("the menu stayed open after accepting")
}
if got := m.input.Value(); !strings.HasPrefix(got, "/"+second) {
t.Errorf("input = %q, want it completed to /%s", got, second)
}
if m.phase != phaseIdle {
t.Error("accepting a completion started a turn; it should only fill the box")
}
}
// A command that takes arguments is completed with the space already typed;
// one that does not is completed bare and the menu does not re-open on it.
func TestCompletionTrailingSpaceFollowsArguments(t *testing.T) {
m := newTestModel(t)
m.opts.Skills = []skills.Skill{{Name: "brainstorming", Description: "explore an idea"}}
typeIn(m, "/brainstorm")
m.Update(key("tab"))
if got := m.input.Value(); got != "/brainstorming " {
t.Errorf("input = %q, want a trailing space for a command that takes arguments", got)
}
m2 := newTestModel(t)
typeIn(m2, "/clear")
m2.Update(key("tab"))
if got := m2.input.Value(); got != "/clear" {
t.Errorf("input = %q, want no trailing space on an argument-free command", got)
}
if m2.menuOpen() {
t.Error("the menu re-opened on the text it had just completed")
}
}
// Escape is for dismissing the menu, and only that: the text survives.
func TestEscapeClosesMenuKeepingText(t *testing.T) {
m := newTestModel(t)
typeIn(m, "/help")
if !m.menuOpen() {
t.Fatal("menu not open")
}
m.Update(key("esc"))
if m.menuOpen() {
t.Error("escape did not close the menu")
}
if got := m.input.Value(); got != "/help" {
t.Errorf("input = %q, want the typed text left alone", got)
}
// With the menu gone, enter sends what was typed.
m.Update(key("enter"))
if !strings.Contains(m.tr.render(), "commands:") {
t.Error("enter after dismissing the menu did not run the command")
}
}
// Typing past the last match closes the menu rather than showing an empty box.
func TestMenuClosesWhenNothingMatches(t *testing.T) {
m := newTestModel(t)
typeIn(m, "/zzzq")
if m.menuOpen() {
t.Errorf("menu open with no matches: %v", names(m.menu.items))
}
}
// The menu sits in the bottom chrome, which must still leave the transcript
// somewhere to live on a short terminal.
func TestMenuFitsTheTerminal(t *testing.T) {
for _, h := range []int{10, 24, 60} {
m := newTestModel(t)
m.Update(tea.WindowSizeMsg{Width: 80, Height: h})
typeIn(m, "/")
if !m.menuOpen() {
t.Fatalf("height %d: menu did not open", h)
}
view := m.View()
if got := strings.Count(view, "\n") + 1; got > h {
t.Errorf("height %d: view is %d lines, one row of the menu too many", h, got)
}
if !strings.Contains(view, "/help") {
t.Errorf("height %d: the menu rendered nothing", h)
}
}
}
// While the agent has the turn, enter is refused and esc means interrupt, so
// a menu opened under it would offer keys that do not do what it says.
func TestMenuStaysShutWhileTheAgentWorks(t *testing.T) {
m := newTestModel(t)
m.phase = phaseRunning
typeIn(m, "/he")
if m.menuOpen() {
t.Error("the menu opened during a turn")
}
m.Update(key("esc"))
if !m.interrupting {
t.Error("esc was eaten by the menu instead of interrupting the turn")
}
}