Files
2026-07-13 13:00:08 +08:00

964 lines
28 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"reasonix/internal/i18n"
"reasonix/internal/skill"
)
func makeTestSkills() []skill.Skill {
return []skill.Skill{
{Name: "review", Description: "Review code changes for correctness", Scope: skill.ScopeProject, Path: "/fake/proj/.reasonix/skills/review/SKILL.md", RunAs: skill.RunSubagent, Body: "# Review\n\nReview code."},
{Name: "explore", Description: "Fast read-only search agent", Scope: skill.ScopeBuiltin, Path: "(builtin)", RunAs: skill.RunSubagent, Body: "# Explore\n\nSearch the codebase."},
{Name: "test", Description: "Run tests and validate behavior", Scope: skill.ScopeProject, Path: "/fake/proj/.reasonix/skills/test.md", RunAs: skill.RunInline, Body: "# Test\n\nRun the tests."},
}
}
func TestSkillPickerRenderSmoke(t *testing.T) {
skills := makeTestSkills()
m := chatTUI{
width: 80,
skills: skills,
skillPick: &skillPicker{
mode: pickerSkills,
skills: skills,
roots: nil,
},
}
out := m.renderSkillPicker()
if out == "" {
t.Fatal("renderSkillPicker returned empty string")
}
if !strings.Contains(out, "Manage skills") {
t.Fatalf("render missing title:\n%s", out)
}
if !strings.Contains(out, "review") {
t.Fatalf("render missing skill name:\n%s", out)
}
if !strings.Contains(out, "explore") {
t.Fatalf("render missing builtin skill:\n%s", out)
}
}
func TestSkillPickerClosed(t *testing.T) {
m := chatTUI{width: 80, skillPick: nil}
if out := m.renderSkillPicker(); out != "" {
t.Fatalf("closed picker rendered %q", out)
}
}
func TestSkillPickerEnterClosesWithoutChanges(t *testing.T) {
skills := makeTestSkills()
m := newTestChatTUI()
m.skills = skills
m.skillPick = &skillPicker{
mode: pickerSkills,
skills: skills,
sel: 0,
enabled: map[string]bool{"review": true},
originalEnabled: map[string]bool{"review": true},
}
m.input.SetValue("old input")
next, _ := m.saveSkillPick()
cm := next.(chatTUI)
if val := cm.input.Value(); val != "old input" {
t.Fatalf("saveSkillPick changed input to %q, want old input", val)
}
if cm.skillPick != nil {
t.Fatal("saveSkillPick did not close the picker")
}
}
func TestSkillsBareOpensPicker(t *testing.T) {
m := newTestChatTUI()
m.width = 80
m.skills = makeTestSkills()
m.runSkillSubcommand("/skills")
if m.skillPick == nil {
t.Fatal("bare /skills should open the interactive picker")
}
}
func TestSkillsManageOpensPicker(t *testing.T) {
m := newTestChatTUI()
m.width = 80
m.skills = makeTestSkills()
m.runSkillSubcommand("/skills manage")
if m.skillPick == nil {
t.Fatal("/skills manage should open the interactive picker")
}
}
func TestSkillsQuestionOpensSubcommandCompletion(t *testing.T) {
m := newTestChatTUI()
m.width = 80
m.skills = makeTestSkills()
m.input.SetValue("/skills?")
m.updateCompletion()
if !m.completion.active || m.completion.kind != compSlashArg {
t.Fatalf("/skills? should open subcommand completion: %+v", m.completion)
}
if hasLabel(m.completion.items, "manage") {
t.Fatalf("redundant manage subcommand should be hidden from /skills? menu: %+v", m.completion.items)
}
if hasLabel(m.completion.items, "list") {
t.Fatalf("redundant list subcommand should be hidden from /skills? menu: %+v", m.completion.items)
}
if !hasLabel(m.completion.items, "show") {
t.Fatalf("/skills? should include useful subcommands: %+v", m.completion.items)
}
m.input.SetValue("/skills ")
m.updateCompletion()
if m.completion.active {
t.Fatalf("/skills <space> should not open subcommand completion: %+v", m.completion)
}
}
func TestSkillsEnterSubmitsExactSlashCommand(t *testing.T) {
m := newTestChatTUI()
m.width = 80
m.skills = makeTestSkills()
m.input.SetValue("/skills")
m.updateCompletion()
if !m.completion.active {
t.Fatal("typing /skills should show slash completion before Enter")
}
if m.completion.kind == compSlashArg {
t.Fatalf("typing exact /skills should not open subcommand completion: %+v", m.completion)
}
next, _ := m.update(tea.KeyPressMsg{Code: tea.KeyEnter})
cm := next.(chatTUI)
if cm.skillPick == nil {
t.Fatal("Enter on exact /skills should open the interactive picker")
}
if got := cm.input.Value(); got != "" {
t.Fatalf("input after submitting /skills = %q, want empty", got)
}
}
func TestSkillsListRendersScrollback(t *testing.T) {
m := newTestChatTUI()
m.width = 80
m.skills = makeTestSkills()
m.runSkillSubcommand("/skills list")
if m.skillPick != nil {
t.Fatal("/skills list should render a static list, not open the picker")
}
if len(m.transcript) == 0 {
t.Fatal("/skills list should commit a list to scrollback")
}
got := strings.Join(m.transcript, "\n")
if !strings.Contains(got, "skills") || !strings.Contains(got, "/review") {
t.Fatalf("/skills list output missing expected content:\n%s", got)
}
}
func TestSkillPickerSearch(t *testing.T) {
skills := makeTestSkills()
m := chatTUI{
width: 80,
skills: skills,
skillPick: &skillPicker{
mode: pickerSkills,
skills: skills,
searchActive: true,
query: "rev",
sel: 0,
},
}
out := m.renderSkillPicker()
if out == "" {
t.Fatal("renderSkillPicker returned empty string")
}
if !strings.Contains(out, "rev") {
t.Fatalf("search mode missing query:\n%s", out)
}
if footer := m.renderMainManagerFooter(); !strings.Contains(footer, "search") && !strings.Contains(footer, "搜索") {
t.Fatalf("search mode missing footer hint:\n%s", footer)
}
if !strings.Contains(out, "review") {
t.Fatalf("search filter should include review:\n%s", out)
}
if strings.Contains(out, "explore") || strings.Contains(out, "test") {
t.Fatalf("search filter should exclude non-matching skills:\n%s", out)
}
}
func TestSkillPickerRenderDialogStyle(t *testing.T) {
i18n.DetectLanguage("en")
t.Cleanup(func() { i18n.DetectLanguage("en") })
var skills []skill.Skill
for i := 0; i < 24; i++ {
skills = append(skills, skill.Skill{
Name: "skill-" + strings.Repeat("x", i%4) + string(rune('a'+i)),
Description: "this long description should stay out of the default picker list",
Scope: skill.ScopeGlobal,
Body: "short body",
})
}
m := chatTUI{
width: 80,
height: 24,
skills: skills,
skillPick: &skillPicker{
mode: pickerSkills,
skills: skills,
sel: 0,
},
}
out := m.renderSkillPicker()
if !strings.Contains(out, "Search skills") {
t.Fatalf("dialog render missing search box:\n%s", out)
}
if !strings.Contains(out, "/ Search skills") {
t.Fatalf("dialog render should use slash search prompt, not a tiny glyph:\n%s", out)
}
if !strings.Contains(out, "more below") {
t.Fatalf("dialog render missing overflow indicator:\n%s", out)
}
if strings.Contains(out, "this long description") {
t.Fatalf("default picker list should not render long descriptions:\n%s", out)
}
}
func TestSkillPickerSearchEmptyResult(t *testing.T) {
skills := makeTestSkills()
m := chatTUI{
width: 80,
skills: skills,
skillPick: &skillPicker{
mode: pickerSkills,
skills: skills,
searchActive: true,
query: "zzz_nonexistent",
sel: 0,
},
}
out := m.renderSkillPicker()
if !strings.Contains(out, "match") && !strings.Contains(out, "匹配") {
t.Fatalf("empty search should show empty state:\n%s", out)
}
}
func TestSkillPickerDetail(t *testing.T) {
skills := makeTestSkills()
m := chatTUI{
width: 80,
skills: skills,
skillPick: &skillPicker{
mode: pickerDetail,
skills: skills,
detailSkill: skills[0],
detailBack: pickerSkills,
},
}
out := m.renderSkillPicker()
if !strings.Contains(out, "subagent") {
t.Fatalf("detail should show subagent tag:\n%s", out)
}
if !strings.Contains(out, "Scope") && !strings.Contains(out, "范围") {
t.Fatalf("detail should show scope:\n%s", out)
}
}
func TestSkillPickerSourceView(t *testing.T) {
skills := makeTestSkills()
roots := []skillRootLine{
{dir: "/fake/proj/.reasonix/skills", scope: skill.ScopeProject, status: skill.StatusOK, skills: 2, diagnostic: true},
{dir: i18nSkillPickerBuiltinSource(), scope: skill.ScopeBuiltin, status: skill.StatusOK, skills: 1, diagnostic: true},
}
m := chatTUI{
width: 80,
skills: skills,
skillPick: &skillPicker{
mode: pickerSources,
skills: skills,
roots: roots,
sourceSel: 0,
},
}
out := m.renderSkillPicker()
if !strings.Contains(out, "Sources") && !strings.Contains(out, "来源") {
t.Fatalf("source view missing title:\n%s", out)
}
if !strings.Contains(out, ".reasonix") {
t.Fatalf("source view should show root path:\n%s", out)
}
}
// i18nSkillPickerBuiltinSource returns SkillPickerBuiltinSource regardless of locale.
// In tests the i18n package is initialized to English by default.
func i18nSkillPickerBuiltinSource() string {
return "builtin"
}
func TestSkillPickerDiagnostics(t *testing.T) {
roots := []skillRootLine{
{dir: "/conf", scope: skill.ScopeCustom, status: skill.StatusOK, skills: 0, configured: true},
{dir: "/proj/.reasonix/skills", scope: skill.ScopeProject, status: skill.StatusOK, skills: 1, diagnostic: true},
{dir: "/proj/.agents/skills", scope: skill.ScopeProject, status: skill.StatusMissing, skills: 0, diagnostic: true},
{dir: "/proj/.agent/skills", scope: skill.ScopeProject, status: skill.StatusMissing, skills: 0, diagnostic: true},
}
p := &skillPicker{mode: pickerSources, roots: roots}
// Default: diagnostics hidden.
visible := p.visibleRoots()
if len(visible) != 2 {
t.Fatalf("default visibleRoots got %d, want 2 (configured custom + active project): %v", len(visible), visible)
}
// Show diagnostics.
p.showDiagnostics = true
visible = p.visibleRoots()
if len(visible) != 4 {
t.Fatalf("with diagnostics visibleRoots got %d, want 4: %v", len(visible), visible)
}
}
func TestFilteredSkills(t *testing.T) {
skills := makeTestSkills()
p := &skillPicker{skills: skills, query: "review"}
filtered := p.filteredSkills()
if len(filtered) != 1 || filtered[0].Name != "review" {
t.Fatalf("filteredSkills(review) = %v", filtered)
}
p.query = "code"
filtered = p.filteredSkills()
if len(filtered) != 1 || filtered[0].Name != "review" {
t.Fatalf("filteredSkills(code) should match description: %v", filtered)
}
p.query = "zzz"
filtered = p.filteredSkills()
if len(filtered) != 0 {
t.Fatalf("filteredSkills(zzz) should be empty: %v", filtered)
}
p.query = ""
filtered = p.filteredSkills()
if len(filtered) != 3 {
t.Fatalf("filteredSkills(empty) should return all: %v", filtered)
}
}
func TestSkillPickerSummaryDefault(t *testing.T) {
skills := makeTestSkills()
p := &skillPicker{skills: skills}
s := skillPickerSummary(p)
if !strings.Contains(s, "available") && !strings.Contains(s, "可用") {
t.Fatalf("summary missing 'available': %q", s)
}
if !strings.Contains(s, "project") && !strings.Contains(s, "项目") {
t.Fatalf("summary missing project count: %q", s)
}
if !strings.Contains(s, "builtin") && !strings.Contains(s, "内置") {
t.Fatalf("summary missing builtin count: %q", s)
}
}
func TestSkillPickerSummarySearch(t *testing.T) {
skills := makeTestSkills()
p := &skillPicker{skills: skills, searchActive: true, query: "rev"}
s := skillPickerSummary(p)
if !strings.Contains(s, "matching") && !strings.Contains(s, "匹配") {
t.Fatalf("search summary missing 'matching': %q", s)
}
// Should mention total count.
if !strings.Contains(s, "3") {
t.Fatalf("search summary missing total count: %q", s)
}
}
func TestSkillSourceSummary(t *testing.T) {
roots := []skillRootLine{
{dir: "/a", skills: 2},
{dir: "/b", skills: 1},
{dir: "/c", skills: 0},
}
s := skillSourceSummary(roots)
if !strings.Contains(s, "active") && !strings.Contains(s, "有效") {
t.Fatalf("source summary missing 'active': %q", s)
}
empty := skillSourceSummary([]skillRootLine{{dir: "/x", skills: 0}})
if empty != "" {
t.Fatalf("source summary with no active roots should be empty: %q", empty)
}
}
func TestSkillPickerEscCloses(t *testing.T) {
skills := makeTestSkills()
m := chatTUI{
width: 80,
skills: skills,
skillPick: &skillPicker{
mode: pickerSkills,
skills: skills,
},
}
next, _ := m.handleSkillPickerKey(tea.KeyPressMsg{Code: 27}) // Esc
cm := next.(chatTUI)
if cm.skillPick != nil {
t.Fatal("Esc did not close skill picker")
}
}
func TestSkillPickerSearchEscExitsSearch(t *testing.T) {
skills := makeTestSkills()
m := chatTUI{
width: 80,
skills: skills,
skillPick: &skillPicker{
mode: pickerSkills,
skills: skills,
searchActive: true,
query: "rev",
},
}
next, _ := m.handleSkillPickerKey(tea.KeyPressMsg{Code: 27}) // Esc
cm := next.(chatTUI)
if cm.skillPick == nil {
t.Fatal("Esc closed picker instead of exiting search")
}
if cm.skillPick.searchActive {
t.Fatal("Esc did not exit search mode")
}
}
func TestSkillPickerSSwitchesToSources(t *testing.T) {
skills := makeTestSkills()
m := chatTUI{
width: 80,
skills: skills,
skillPick: &skillPicker{
mode: pickerSkills,
skills: skills,
},
}
next, _ := m.handleSkillPickerKey(tea.KeyPressMsg{Code: 's'})
cm := next.(chatTUI)
if cm.skillPick.mode != pickerSources {
t.Fatalf("s did not switch to source view, mode=%s", cm.skillPick.mode)
}
}
func TestSkillPickerSourceEnterShowsRootSkills(t *testing.T) {
skills := makeTestSkills()
m := chatTUI{
width: 80,
skills: skills,
skillPick: &skillPicker{
mode: pickerSources,
skills: skills,
roots: []skillRootLine{
{dir: "/fake/proj/.reasonix/skills", scope: skill.ScopeProject, status: skill.StatusOK, skills: 2, diagnostic: true},
},
},
}
next, _ := m.handleSkillPickerKey(tea.KeyPressMsg{Code: tea.KeyEnter})
cm := next.(chatTUI)
if cm.skillPick.mode != pickerSourceSkills {
t.Fatalf("Enter did not open source skill list, mode=%s", cm.skillPick.mode)
}
out := cm.renderSkillPicker()
if !strings.Contains(out, "review") || !strings.Contains(out, "test") {
t.Fatalf("source skill list missing project skills:\n%s", out)
}
if strings.Contains(out, "explore") {
t.Fatalf("source skill list should not include builtin skill:\n%s", out)
}
}
func TestSkillPickerSpaceTogglesEnabled(t *testing.T) {
skills := makeTestSkills()
m := chatTUI{
width: 80,
skills: skills,
skillPick: &skillPicker{
mode: pickerSkills,
skills: skills,
sel: 0,
enabled: map[string]bool{"review": true},
originalEnabled: map[string]bool{"review": true},
},
}
// Space toggles the selected skill off.
next, _ := m.handleSkillPickerKey(tea.KeyPressMsg{Code: ' '})
cm := next.(chatTUI)
if cm.skillPick.skillEnabled("review") {
t.Fatal("Space did not disable the selected skill")
}
// Space toggles it back on.
next2, _ := cm.handleSkillPickerKey(tea.KeyPressMsg{Code: ' '})
cm2 := next2.(chatTUI)
if !cm2.skillPick.skillEnabled("review") {
t.Fatal("second Space did not enable the selected skill")
}
}
func TestSkillPickerDetailDeleteRequiresConfirmation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "review", skill.SkillFile)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("# Review"), 0o644); err != nil {
t.Fatal(err)
}
skills := []skill.Skill{
{Name: "review", Description: "Review code", Scope: skill.ScopeProject, Path: path, RunAs: skill.RunSubagent, Body: "# Review"},
}
p := &skillPicker{
mode: pickerDetail,
skills: skills,
detailSkill: skills[0],
detailBack: pickerSkills,
detailAction: 1,
}
m := chatTUI{width: 80, skills: skills, skillPick: p}
next, _ := m.handleSkillPickerKey(tea.KeyPressMsg{Code: tea.KeyEnter})
cm := next.(chatTUI)
if cm.skillPick.mode != pickerConfirmDelete {
t.Fatalf("delete action should open confirmation, mode=%s", cm.skillPick.mode)
}
if cm.skillPick.confirm != 1 {
t.Fatalf("delete confirmation should default to cancel, got %d", cm.skillPick.confirm)
}
}
func TestSkillPickerDetailShowsActionsBeforeBodyPreview(t *testing.T) {
s := skill.Skill{
Name: "review",
Description: "Review code",
Scope: skill.ScopeProject,
Path: "/proj/.reasonix/skills/review/SKILL.md",
RunAs: skill.RunSubagent,
Body: "# Review\n\n" + strings.Repeat("BODY_LINE\n", 40),
}
m := chatTUI{
width: 80,
skills: []skill.Skill{s},
skillPick: &skillPicker{
mode: pickerDetail,
skills: []skill.Skill{s},
detailSkill: s,
enabled: map[string]bool{s.Name: true},
originalEnabled: map[string]bool{s.Name: true},
},
}
out := m.renderSkillPickerDetail()
actionAt := strings.Index(out, i18n.M.SkillPickerActionToggle)
bodyAt := strings.Index(out, "BODY_LINE")
if actionAt < 0 {
t.Fatalf("detail missing action row:\n%s", out)
}
if bodyAt < 0 {
t.Fatalf("detail missing body preview:\n%s", out)
}
if actionAt > bodyAt {
t.Fatalf("detail action should render before body preview:\n%s", out)
}
}
func TestDeleteSkillPickRemovesDirectoryTarget(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "review", skill.SkillFile)
targetDir := filepath.Dir(path)
if err := os.MkdirAll(targetDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("# Review"), 0o644); err != nil {
t.Fatal(err)
}
s := skill.Skill{Name: "review", Scope: skill.ScopeProject, Path: path, RunAs: skill.RunSubagent, Body: "# Review"}
m := newTestChatTUI()
m.skills = []skill.Skill{s}
m.skillPick = &skillPicker{
mode: pickerConfirmDelete,
skills: []skill.Skill{s},
deleteSkill: s,
enabled: map[string]bool{s.Name: true},
originalEnabled: map[string]bool{s.Name: true},
}
next, _ := m.deleteSkillPick(s)
cm := next.(chatTUI)
if _, err := os.Stat(targetDir); !os.IsNotExist(err) {
t.Fatalf("deleteSkillPick target still exists or unexpected error: %v", err)
}
if cm.skillPick == nil || cm.skillPick.mode != pickerSkills {
t.Fatalf("delete should return to skills list, picker=%v", cm.skillPick)
}
}
func TestSkillDeleteTargetDirectoryAndFlatFile(t *testing.T) {
dir := t.TempDir()
nested := filepath.Join(dir, "review", skill.SkillFile)
if err := os.MkdirAll(filepath.Dir(nested), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nested, []byte("# Review"), 0o644); err != nil {
t.Fatal(err)
}
target, ok, err := skillDeleteTarget(skill.Skill{Name: "review", Scope: skill.ScopeProject, Path: nested})
if err != nil || !ok {
t.Fatalf("skillDeleteTarget directory = %q %v %v", target, ok, err)
}
if target != filepath.Dir(nested) {
t.Fatalf("directory target = %q, want %q", target, filepath.Dir(nested))
}
flat := filepath.Join(dir, "flat.md")
if err := os.WriteFile(flat, []byte("# Flat"), 0o644); err != nil {
t.Fatal(err)
}
target, ok, err = skillDeleteTarget(skill.Skill{Name: "flat", Scope: skill.ScopeProject, Path: flat})
if err != nil || !ok {
t.Fatalf("skillDeleteTarget flat = %q %v %v", target, ok, err)
}
if target != flat {
t.Fatalf("flat target = %q, want %q", target, flat)
}
if _, ok, err := skillDeleteTarget(skill.Skill{Name: "explore", Scope: skill.ScopeBuiltin, Path: "(builtin)"}); err != nil || ok {
t.Fatalf("builtin target ok=%v err=%v, want not removable", ok, err)
}
}
func TestClampSel(t *testing.T) {
items := []int{10, 20, 30}
if got := clampSel(0, items); got != 0 {
t.Fatalf("clampSel(0) = %d", got)
}
if got := clampSel(2, items); got != 2 {
t.Fatalf("clampSel(2) = %d", got)
}
if got := clampSel(5, items); got != 2 {
t.Fatalf("clampSel(5) = %d, want 2", got)
}
if got := clampSel(-1, items); got != 0 {
t.Fatalf("clampSel(-1) = %d, want 0", got)
}
if got := clampSel(0, []int{}); got != 0 {
t.Fatalf("clampSel(0, []) = %d, want 0", got)
}
}
func TestSkillRowLabelHasSubagentTag(t *testing.T) {
s := skill.Skill{Name: "review", Description: "Review code", Scope: skill.ScopeProject, RunAs: skill.RunSubagent}
row := renderSkillRow(1, false, s, true, 80)
if !strings.Contains(row, "subagent") && !strings.Contains(row, "子代理") {
t.Fatalf("subagent skill missing tag: %q", row)
}
if !strings.Contains(row, "review") {
t.Fatalf("missing skill name: %q", row)
}
}
func TestRenderSkillRowSelected(t *testing.T) {
s := skill.Skill{Name: "test", Description: "A test skill", Scope: skill.ScopeGlobal, RunAs: skill.RunInline}
row := renderSkillRow(5, true, s, true, 80)
if !strings.Contains(row, "") {
t.Fatalf("selected row missing arrow: %q", row)
}
// Selected row differs from unselected: has arrow, no dim prefix.
unsel := renderSkillRow(5, false, s, true, 80)
if row == unsel {
t.Fatal("selected and unselected rows should differ")
}
}
func TestRenderSkillRowScopeMeta(t *testing.T) {
s := skill.Skill{Name: "example", Description: "desc", Scope: skill.ScopeGlobal, RunAs: skill.RunInline}
row := renderSkillRow(1, false, s, true, 80)
if !strings.Contains(row, "global") && !strings.Contains(row, "全局") {
t.Fatalf("row missing scope label: %q", row)
}
if !strings.Contains(row, "tok") {
t.Fatalf("row missing approximate token count: %q", row)
}
}
func TestRenderSkillRowLongNameTruncated(t *testing.T) {
s := skill.Skill{
Name: "this-is-a-very-long-skill-name-that-exceeds-26-chars",
Description: "desc",
Scope: skill.ScopeBuiltin,
}
row := renderSkillRow(1, false, s, true, 80)
if !strings.Contains(row, "…") {
t.Fatalf("long name not truncated with …: %q", row)
}
}
func TestRenderSkillRowChineseBadgeFitsWidth(t *testing.T) {
i18n.DetectLanguage("zh")
t.Cleanup(func() { i18n.DetectLanguage("en") })
s := skill.Skill{
Name: "browser-testing-with-devtools",
Description: "Tests in real browsers. Use when building or debugging anything that runs in a browser.",
Scope: skill.ScopeGlobal,
RunAs: skill.RunSubagent,
}
row := renderSkillRow(13, true, s, true, 80)
if got := visibleWidth(row); got > 80 {
t.Fatalf("row visible width = %d, want <= 80:\n%q", got, row)
}
if strings.Contains(row, "\n") {
t.Fatalf("row should stay single-line: %q", row)
}
}
func TestSortedSkills(t *testing.T) {
skills := []skill.Skill{
{Name: "z-builtin", Scope: skill.ScopeBuiltin},
{Name: "a-project", Scope: skill.ScopeProject},
{Name: "b-project", Scope: skill.ScopeProject},
{Name: "c-global", Scope: skill.ScopeGlobal},
{Name: "d-custom", Scope: skill.ScopeCustom},
}
sorted := sortedSkills(skills)
// Project first, then custom, global, builtin; alphabetical within.
want := []string{"a-project", "b-project", "d-custom", "c-global", "z-builtin"}
for i, s := range sorted {
if i >= len(want) || s.Name != want[i] {
t.Fatalf("sorted[%d] = %s, want %s", i, s.Name, want[i])
}
}
}
func TestSkillPickerRendersInMainArea(t *testing.T) {
m := newTestChatTUI()
m.width = 80
m.height = 40
m.skillPick = &skillPicker{
mode: pickerSkills,
skills: []skill.Skill{
{Name: "a", Description: "desc", Scope: skill.ScopeBuiltin},
{Name: "b", Description: "desc", Scope: skill.ScopeBuiltin},
{Name: "c", Description: "desc", Scope: skill.ScopeBuiltin},
},
}
rows := m.bottomRows()
footerRows := strings.Count(m.renderMainManagerFooter(), "\n") + 1
if want := footerRows + 2; rows != want {
t.Fatalf("bottomRows with skill picker open got %d, want %d (footer + status rows)", rows, want)
}
if !m.hideComposer() {
t.Fatal("skill picker should hide the composer")
}
if out := m.renderMainManager(); !strings.Contains(out, "Manage skills") {
t.Fatalf("skill picker should render as a main manager:\n%s", out)
}
}
func TestBottomRowsIncludesResumePicker(t *testing.T) {
m := newTestChatTUI()
m.width = 80
m.height = 40
m.resumePick = &resumePicker{
sessions: nil,
sel: 0,
active: -1,
}
// Not testing exact row count (resumePicker needs sessions for rendering),
// just verifying that bottomRows doesn't panic and returns a non-zero value.
rows := m.bottomRows()
if rows < 3 {
t.Fatalf("bottomRows with resume picker got %d", rows)
}
}
func TestRescanInSearchModeClampsToFiltered(t *testing.T) {
skills := []skill.Skill{
{Name: "alpha", Description: "first", Scope: skill.ScopeBuiltin},
{Name: "beta", Description: "second", Scope: skill.ScopeBuiltin},
{Name: "gamma", Description: "third", Scope: skill.ScopeBuiltin},
}
p := &skillPicker{
mode: pickerSkills,
skills: skills,
searchActive: true,
query: "beta",
sel: 2, // beyond filtered results (only "beta" matches)
}
p.sel = clampSel(p.sel, skills) // simulating old behavior
// Fix: clamp to filtered when search is active.
if p.searchActive && p.query != "" {
p.sel = clampSel(p.sel, p.filteredSkills())
}
if p.sel != 0 {
t.Fatalf("sel after clamp to filtered should be 0, got %d", p.sel)
}
}
func TestPathBoundaryMatching(t *testing.T) {
skills := []skill.Skill{
{Name: "alpha", Scope: skill.ScopeProject, Path: "/proj/.reasonix/skills/alpha/SKILL.md"},
{Name: "beta", Scope: skill.ScopeProject, Path: "/proj/.reasonix/skills-extra/beta/SKILL.md"},
}
lines := []skillRootLine{
{dir: "/proj/.reasonix/skills", scope: skill.ScopeProject, status: skill.StatusOK},
{dir: "/proj/.reasonix/skills-extra", scope: skill.ScopeProject, status: skill.StatusOK},
}
// Count skills per root with directory-boundary match.
for _, s := range skills {
if s.Scope == skill.ScopeBuiltin {
continue
}
cleanPath := filepath.Clean(s.Path)
for i := range lines {
if lines[i].scope != s.Scope {
continue
}
prefix := filepath.Clean(lines[i].dir) + string(filepath.Separator)
if strings.HasPrefix(cleanPath, prefix) {
lines[i].skills++
break
}
}
}
if lines[0].skills != 1 {
t.Fatalf("/skills root should have 1 skill, got %d", lines[0].skills)
}
if lines[1].skills != 1 {
t.Fatalf("/skills-extra root should have 1 skill, got %d", lines[1].skills)
}
}
func TestSourceRowLabelUsesI18n(t *testing.T) {
i18n.DetectLanguage("zh")
t.Cleanup(func() { i18n.DetectLanguage("en") })
r := skillRootLine{
dir: "/proj/.reasonix/skills",
scope: skill.ScopeProject,
status: skill.StatusOK,
skills: 3,
}
label := sourceRowLabel(r, 80)
if !strings.Contains(label, "项目") || strings.Contains(label, "project") {
t.Fatalf("source row should use localized scope label, got %q", label)
}
// Check that skills unit is used
if !strings.Contains(label, "skills") && !strings.Contains(label, "skill") {
t.Fatalf("source row missing skills unit: %q", label)
}
}
func TestStatusLabelI18n(t *testing.T) {
if l := statusLabel(skill.StatusOK); l == "" {
t.Fatal("statusLabel(ok) empty")
}
if l := statusLabel(skill.StatusMissing); l == "" {
t.Fatal("statusLabel(missing) empty")
}
if l := statusLabel(skill.StatusNotDirectory); l == "" {
t.Fatal("statusLabel(not-directory) empty")
}
if l := statusLabel(skill.StatusUnreadable); l == "" {
t.Fatal("statusLabel(unreadable) empty")
}
// Unknown status falls back to raw string.
if l := statusLabel(skill.PathStatus("custom-status")); l != "custom-status" {
t.Fatalf("statusLabel(unknown) = %q, want 'custom-status'", l)
}
}
func TestRenderSkillDetailUsesI18nMeta(t *testing.T) {
s := skill.Skill{
Name: "review",
Description: "Review code",
Scope: skill.ScopeProject,
Path: "/proj/.reasonix/skills/review/SKILL.md",
RunAs: skill.RunSubagent,
Body: "# Review\n\nLine 1\nLine 2",
}
out := renderSkillDetail(s, 80)
// Detail should use i18n meta format (not raw "Scope:" when in Chinese).
if !strings.Contains(out, "Scope") && !strings.Contains(out, "范围") {
t.Fatalf("detail missing scope label:\n%s", out)
}
if !strings.Contains(out, "Run as") && !strings.Contains(out, "运行") {
t.Fatalf("detail missing run-as label:\n%s", out)
}
}
func TestLegacySkillShowUnchanged(t *testing.T) {
s := skill.Skill{
Name: "my-skill",
Description: "A test skill",
Scope: skill.ScopeProject,
Path: "/fake/proj/.reasonix/skills/my-skill.md",
Body: "# My Skill\n\nSome body text.",
}
out := renderSkillShow(80, s, false)
if !strings.Contains(out, "skill:") && !strings.Contains(out, "my-skill") {
t.Fatalf("legacy skill show broken:\n%s", out)
}
}
func TestLegacySkillPathsUnchanged(t *testing.T) {
roots := []skill.Root{
{Dir: "/proj/.reasonix/skills", Scope: skill.ScopeProject, Priority: 0, Status: skill.StatusOK},
}
out := renderSkillPaths(80, roots)
if !strings.Contains(out, "skill paths") {
t.Fatalf("legacy skill paths broken:\n%s", out)
}
}
func TestSkillPickerRenderWidthNarrow(t *testing.T) {
skills := makeTestSkills()
m := chatTUI{
width: 30,
skills: skills,
skillPick: &skillPicker{
mode: pickerSkills,
skills: skills,
},
}
out := m.renderSkillPicker()
if out == "" {
t.Fatal("narrow render returned empty")
}
if !strings.Contains(out, "Manage skills") {
t.Fatalf("narrow render missing title:\n%s", out)
}
}