Vectrum
Sign In
141 lines · 4.9 KB · Go
package tui

import (
	"strings"
	"testing"

	"github.com/charmbracelet/lipgloss"

	"codey/internal/skills"
)

// helpWidth is the narrowest terminal /help is meant to be read on. Eighty is
// where a wrapped line stops being a line, and the help is the one screen you
// are on precisely because you are already lost.
const helpWidth = 80

func TestHelpFitsANarrowTerminal(t *testing.T) {
	m := newTestModel(t)
	for _, line := range strings.Split(m.helpText(), "\n") {
		if w := lipgloss.Width(line); w > helpWidth {
			t.Errorf("help line is %d cells wide, over the %d a narrow terminal has:\n%s", w, helpWidth, line)
		}
	}
}

// The command list is generated from the catalogue rather than written out
// beside it, and this is what says so: a command added to builtins and to
// nothing else still turns up here.
func TestHelpNamesEveryBuiltIn(t *testing.T) {
	m := newTestModel(t)
	help := m.helpText()
	for _, c := range builtins {
		if !strings.Contains(help, "/"+c.name) {
			t.Errorf("/%s is a command and is not in /help", c.name)
		}
	}
}

// The descriptions line up in a column, which is what makes the list
// scannable. The column is measured from the longest name, so this is really a
// test that a long argument list cannot quietly push one description out of
// line with the rest.
func TestHelpDescriptionsLineUp(t *testing.T) {
	m := newTestModel(t)
	// Only the command list, which is the part laid out in this column. The
	// sections after the first blank line have a narrower one of their own,
	// since a key is shorter than a command with arguments.
	commands, _, _ := strings.Cut(m.helpText(), "\n\n")

	col := -1
	for _, line := range strings.Split(commands, "\n") {
		if !strings.HasPrefix(line, "  /") {
			continue
		}
		gap := strings.Index(line[3:], "  ")
		if gap < 0 {
			t.Fatalf("no gap between the name and its description: %q", line)
		}
		rest := line[3+gap:]
		start := 3 + gap + len(rest) - len(strings.TrimLeft(rest, " "))
		if col == -1 {
			col = start
			continue
		}
		if start != col {
			t.Errorf("description starts at column %d, want %d:\n%s", start, col, line)
		}
	}
	if col == -1 {
		t.Fatal("no command rows in /help at all")
	}
}

// The keys people ask about most are the ones with nothing on screen to
// suggest them. If one of these ever leaves the help, it has effectively left
// the program.
func TestHelpMentionsTheKeysNothingElseAdvertises(t *testing.T) {
	m := newTestModel(t)
	help := m.helpText()
	for _, want := range []string{"↑ ↓", "ctrl+j", "shift+tab", "ctrl+b", "ctrl+t", "! $"} {
		if !strings.Contains(help, want) {
			t.Errorf("/help does not mention %q", want)
		}
	}
}

func TestHelpCountsDiscoveredSkills(t *testing.T) {
	m := newTestModel(t)
	m.opts.Skills = []skills.Skill{
		{Name: "brainstorming", Description: "explore an idea"},
		{Name: "debugging", Description: "find the cause"},
	}
	if !strings.Contains(m.helpText(), "2 discovered skills") {
		t.Error("/help does not say how many skills were found")
	}
}

// /skills groups by where a skill came from, because the list stopped being
// four lines when Claude Code's skills could be read too.
func TestSkillsListIsGroupedAndColumned(t *testing.T) {
	m := newTestModel(t)
	m.opts.Skills = []skills.Skill{
		{Name: "commit-helper", Description: "Use when writing commit messages", Source: "personal"},
		{Name: "brainstorming", Description: "Use when exploring an idea", Source: "claude:superpowers"},
		{Name: "pdf", Description: "Extract text from PDFs", Source: "claude"},
	}
	out := m.skillsText()

	if !strings.HasPrefix(out, "skills: 3") {
		t.Errorf("the list does not open with a count:\n%s", out)
	}
	// Codey's own first, whatever the groups sort to.
	personal := strings.Index(out, "\n  personal")
	claude := strings.Index(out, "\n  claude\n")
	plugin := strings.Index(out, "\n  claude:superpowers")
	if personal < 0 || claude < 0 || plugin < 0 {
		t.Fatalf("a group is missing from the list:\n%s", out)
	}
	if !(personal < claude && claude < plugin) {
		t.Errorf("groups are out of order — personal %d, claude %d, plugin %d:\n%s", personal, claude, plugin, out)
	}
	// The source is said once per group rather than once per skill.
	if n := strings.Count(out, "claude:superpowers"); n != 1 {
		t.Errorf("the plugin is named %d times, want once — that is what grouping is for", n)
	}
	for _, line := range strings.Split(out, "\n") {
		if w := lipgloss.Width(line); w > m.mainWidth() {
			t.Errorf("line is %d cells wide, over the %d available: %q", w, m.mainWidth(), line)
		}
	}
}

// A skill with no description still gets a row: it is loaded, so leaving it
// out of the list would be the one lie this screen can tell.
func TestSkillsListNamesADescriptionlessSkill(t *testing.T) {
	m := newTestModel(t)
	m.opts.Skills = []skills.Skill{{Name: "mystery", Source: "personal"}}
	if !strings.Contains(m.skillsText(), "/mystery") {
		t.Errorf("a skill with no description is missing from the list:\n%s", m.skillsText())
	}
}