964 lines
28 KiB
Go
964 lines
28 KiB
Go
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)
|
||
}
|
||
}
|