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