项目文件夹

文件
2026-07-13 13:00:08 +08:00

964 行
28 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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)
}
}