package tui
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/johannSo/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())
}
}