Vectrum
Sign In
222 lines · 6.5 KB · Go
package tui

import (
	"strings"
	"testing"

	tea "github.com/charmbracelet/bubbletea"

	"codey/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")
	}
}