返回 DeepSeek-Reasonix
skill_picker_test.go
根目录 / internal / cli / skill_picker_test.go
1 package cli
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 tea "charm.land/bubbletea/v2"
10
11 "reasonix/internal/i18n"
12 "reasonix/internal/skill"
13 )
14
15 func makeTestSkills() []skill.Skill {
16 return []skill.Skill{
17 {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."},
18 {Name: "explore", Description: "Fast read-only search agent", Scope: skill.ScopeBuiltin, Path: "(builtin)", RunAs: skill.RunSubagent, Body: "# Explore\n\nSearch the codebase."},
19 {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."},
20 }
21 }
22
23 func TestSkillPickerRenderSmoke(t *testing.T) {
24 skills := makeTestSkills()
25 m := chatTUI{
26 width: 80,
27 skills: skills,
28 skillPick: &skillPicker{
29 mode: pickerSkills,
30 skills: skills,
31 roots: nil,
32 },
33 }
34
35 out := m.renderSkillPicker()
36 if out == "" {
37 t.Fatal("renderSkillPicker returned empty string")
38 }
39 if !strings.Contains(out, "Manage skills") {
40 t.Fatalf("render missing title:\n%s", out)
41 }
42 if !strings.Contains(out, "review") {
43 t.Fatalf("render missing skill name:\n%s", out)
44 }
45 if !strings.Contains(out, "explore") {
46 t.Fatalf("render missing builtin skill:\n%s", out)
47 }
48 }
49
50 func TestSkillPickerClosed(t *testing.T) {
51 m := chatTUI{width: 80, skillPick: nil}
52 if out := m.renderSkillPicker(); out != "" {
53 t.Fatalf("closed picker rendered %q", out)
54 }
55 }
56
57 func TestSkillPickerEnterClosesWithoutChanges(t *testing.T) {
58 skills := makeTestSkills()
59 m := newTestChatTUI()
60 m.skills = skills
61 m.skillPick = &skillPicker{
62 mode: pickerSkills,
63 skills: skills,
64 sel: 0,
65 enabled: map[string]bool{"review": true},
66 originalEnabled: map[string]bool{"review": true},
67 }
68 m.input.SetValue("old input")
69
70 next, _ := m.saveSkillPick()
71 cm := next.(chatTUI)
72 if val := cm.input.Value(); val != "old input" {
73 t.Fatalf("saveSkillPick changed input to %q, want old input", val)
74 }
75 if cm.skillPick != nil {
76 t.Fatal("saveSkillPick did not close the picker")
77 }
78 }
79
80 func TestSkillsBareOpensPicker(t *testing.T) {
81 m := newTestChatTUI()
82 m.width = 80
83 m.skills = makeTestSkills()
84
85 m.runSkillSubcommand("/skills")
86 if m.skillPick == nil {
87 t.Fatal("bare /skills should open the interactive picker")
88 }
89 }
90
91 func TestSkillsManageOpensPicker(t *testing.T) {
92 m := newTestChatTUI()
93 m.width = 80
94 m.skills = makeTestSkills()
95
96 m.runSkillSubcommand("/skills manage")
97 if m.skillPick == nil {
98 t.Fatal("/skills manage should open the interactive picker")
99 }
100 }
101
102 func TestSkillsQuestionOpensSubcommandCompletion(t *testing.T) {
103 m := newTestChatTUI()
104 m.width = 80
105 m.skills = makeTestSkills()
106 m.input.SetValue("/skills?")
107 m.updateCompletion()
108 if !m.completion.active || m.completion.kind != compSlashArg {
109 t.Fatalf("/skills? should open subcommand completion: %+v", m.completion)
110 }
111 if hasLabel(m.completion.items, "manage") {
112 t.Fatalf("redundant manage subcommand should be hidden from /skills? menu: %+v", m.completion.items)
113 }
114 if hasLabel(m.completion.items, "list") {
115 t.Fatalf("redundant list subcommand should be hidden from /skills? menu: %+v", m.completion.items)
116 }
117 if !hasLabel(m.completion.items, "show") {
118 t.Fatalf("/skills? should include useful subcommands: %+v", m.completion.items)
119 }
120 m.input.SetValue("/skills ")
121 m.updateCompletion()
122 if m.completion.active {
123 t.Fatalf("/skills <space> should not open subcommand completion: %+v", m.completion)
124 }
125 }
126
127 func TestSkillsEnterSubmitsExactSlashCommand(t *testing.T) {
128 m := newTestChatTUI()
129 m.width = 80
130 m.skills = makeTestSkills()
131 m.input.SetValue("/skills")
132 m.updateCompletion()
133 if !m.completion.active {
134 t.Fatal("typing /skills should show slash completion before Enter")
135 }
136 if m.completion.kind == compSlashArg {
137 t.Fatalf("typing exact /skills should not open subcommand completion: %+v", m.completion)
138 }
139
140 next, _ := m.update(tea.KeyPressMsg{Code: tea.KeyEnter})
141 cm := next.(chatTUI)
142 if cm.skillPick == nil {
143 t.Fatal("Enter on exact /skills should open the interactive picker")
144 }
145 if got := cm.input.Value(); got != "" {
146 t.Fatalf("input after submitting /skills = %q, want empty", got)
147 }
148 }
149
150 func TestSkillsListRendersScrollback(t *testing.T) {
151 m := newTestChatTUI()
152 m.width = 80
153 m.skills = makeTestSkills()
154
155 m.runSkillSubcommand("/skills list")
156 if m.skillPick != nil {
157 t.Fatal("/skills list should render a static list, not open the picker")
158 }
159 if len(m.transcript) == 0 {
160 t.Fatal("/skills list should commit a list to scrollback")
161 }
162 got := strings.Join(m.transcript, "\n")
163 if !strings.Contains(got, "skills") || !strings.Contains(got, "/review") {
164 t.Fatalf("/skills list output missing expected content:\n%s", got)
165 }
166 }
167
168 func TestSkillPickerSearch(t *testing.T) {
169 skills := makeTestSkills()
170 m := chatTUI{
171 width: 80,
172 skills: skills,
173 skillPick: &skillPicker{
174 mode: pickerSkills,
175 skills: skills,
176 searchActive: true,
177 query: "rev",
178 sel: 0,
179 },
180 }
181
182 out := m.renderSkillPicker()
183 if out == "" {
184 t.Fatal("renderSkillPicker returned empty string")
185 }
186 if !strings.Contains(out, "rev") {
187 t.Fatalf("search mode missing query:\n%s", out)
188 }
189 if footer := m.renderMainManagerFooter(); !strings.Contains(footer, "search") && !strings.Contains(footer, "搜索") {
190 t.Fatalf("search mode missing footer hint:\n%s", footer)
191 }
192 if !strings.Contains(out, "review") {
193 t.Fatalf("search filter should include review:\n%s", out)
194 }
195 if strings.Contains(out, "explore") || strings.Contains(out, "test") {
196 t.Fatalf("search filter should exclude non-matching skills:\n%s", out)
197 }
198 }
199
200 func TestSkillPickerRenderDialogStyle(t *testing.T) {
201 i18n.DetectLanguage("en")
202 t.Cleanup(func() { i18n.DetectLanguage("en") })
203
204 var skills []skill.Skill
205 for i := 0; i < 24; i++ {
206 skills = append(skills, skill.Skill{
207 Name: "skill-" + strings.Repeat("x", i%4) + string(rune('a'+i)),
208 Description: "this long description should stay out of the default picker list",
209 Scope: skill.ScopeGlobal,
210 Body: "short body",
211 })
212 }
213 m := chatTUI{
214 width: 80,
215 height: 24,
216 skills: skills,
217 skillPick: &skillPicker{
218 mode: pickerSkills,
219 skills: skills,
220 sel: 0,
221 },
222 }
223
224 out := m.renderSkillPicker()
225 if !strings.Contains(out, "Search skills") {
226 t.Fatalf("dialog render missing search box:\n%s", out)
227 }
228 if !strings.Contains(out, "/ Search skills") {
229 t.Fatalf("dialog render should use slash search prompt, not a tiny glyph:\n%s", out)
230 }
231 if !strings.Contains(out, "more below") {
232 t.Fatalf("dialog render missing overflow indicator:\n%s", out)
233 }
234 if strings.Contains(out, "this long description") {
235 t.Fatalf("default picker list should not render long descriptions:\n%s", out)
236 }
237 }
238
239 func TestSkillPickerSearchEmptyResult(t *testing.T) {
240 skills := makeTestSkills()
241 m := chatTUI{
242 width: 80,
243 skills: skills,
244 skillPick: &skillPicker{
245 mode: pickerSkills,
246 skills: skills,
247 searchActive: true,
248 query: "zzz_nonexistent",
249 sel: 0,
250 },
251 }
252
253 out := m.renderSkillPicker()
254 if !strings.Contains(out, "match") && !strings.Contains(out, "匹配") {
255 t.Fatalf("empty search should show empty state:\n%s", out)
256 }
257 }
258
259 func TestSkillPickerDetail(t *testing.T) {
260 skills := makeTestSkills()
261 m := chatTUI{
262 width: 80,
263 skills: skills,
264 skillPick: &skillPicker{
265 mode: pickerDetail,
266 skills: skills,
267 detailSkill: skills[0],
268 detailBack: pickerSkills,
269 },
270 }
271
272 out := m.renderSkillPicker()
273 if !strings.Contains(out, "subagent") {
274 t.Fatalf("detail should show subagent tag:\n%s", out)
275 }
276 if !strings.Contains(out, "Scope") && !strings.Contains(out, "范围") {
277 t.Fatalf("detail should show scope:\n%s", out)
278 }
279 }
280
281 func TestSkillPickerSourceView(t *testing.T) {
282 skills := makeTestSkills()
283 roots := []skillRootLine{
284 {dir: "/fake/proj/.reasonix/skills", scope: skill.ScopeProject, status: skill.StatusOK, skills: 2, diagnostic: true},
285 {dir: i18nSkillPickerBuiltinSource(), scope: skill.ScopeBuiltin, status: skill.StatusOK, skills: 1, diagnostic: true},
286 }
287 m := chatTUI{
288 width: 80,
289 skills: skills,
290 skillPick: &skillPicker{
291 mode: pickerSources,
292 skills: skills,
293 roots: roots,
294 sourceSel: 0,
295 },
296 }
297
298 out := m.renderSkillPicker()
299 if !strings.Contains(out, "Sources") && !strings.Contains(out, "来源") {
300 t.Fatalf("source view missing title:\n%s", out)
301 }
302 if !strings.Contains(out, ".reasonix") {
303 t.Fatalf("source view should show root path:\n%s", out)
304 }
305 }
306
307 // i18nSkillPickerBuiltinSource returns SkillPickerBuiltinSource regardless of locale.
308 // In tests the i18n package is initialized to English by default.
309 func i18nSkillPickerBuiltinSource() string {
310 return "builtin"
311 }
312
313 func TestSkillPickerDiagnostics(t *testing.T) {
314 roots := []skillRootLine{
315 {dir: "/conf", scope: skill.ScopeCustom, status: skill.StatusOK, skills: 0, configured: true},
316 {dir: "/proj/.reasonix/skills", scope: skill.ScopeProject, status: skill.StatusOK, skills: 1, diagnostic: true},
317 {dir: "/proj/.agents/skills", scope: skill.ScopeProject, status: skill.StatusMissing, skills: 0, diagnostic: true},
318 {dir: "/proj/.agent/skills", scope: skill.ScopeProject, status: skill.StatusMissing, skills: 0, diagnostic: true},
319 }
320
321 p := &skillPicker{mode: pickerSources, roots: roots}
322
323 // Default: diagnostics hidden.
324 visible := p.visibleRoots()
325 if len(visible) != 2 {
326 t.Fatalf("default visibleRoots got %d, want 2 (configured custom + active project): %v", len(visible), visible)
327 }
328
329 // Show diagnostics.
330 p.showDiagnostics = true
331 visible = p.visibleRoots()
332 if len(visible) != 4 {
333 t.Fatalf("with diagnostics visibleRoots got %d, want 4: %v", len(visible), visible)
334 }
335 }
336
337 func TestFilteredSkills(t *testing.T) {
338 skills := makeTestSkills()
339 p := &skillPicker{skills: skills, query: "review"}
340 filtered := p.filteredSkills()
341 if len(filtered) != 1 || filtered[0].Name != "review" {
342 t.Fatalf("filteredSkills(review) = %v", filtered)
343 }
344
345 p.query = "code"
346 filtered = p.filteredSkills()
347 if len(filtered) != 1 || filtered[0].Name != "review" {
348 t.Fatalf("filteredSkills(code) should match description: %v", filtered)
349 }
350
351 p.query = "zzz"
352 filtered = p.filteredSkills()
353 if len(filtered) != 0 {
354 t.Fatalf("filteredSkills(zzz) should be empty: %v", filtered)
355 }
356
357 p.query = ""
358 filtered = p.filteredSkills()
359 if len(filtered) != 3 {
360 t.Fatalf("filteredSkills(empty) should return all: %v", filtered)
361 }
362 }
363
364 func TestSkillPickerSummaryDefault(t *testing.T) {
365 skills := makeTestSkills()
366 p := &skillPicker{skills: skills}
367 s := skillPickerSummary(p)
368 if !strings.Contains(s, "available") && !strings.Contains(s, "可用") {
369 t.Fatalf("summary missing 'available': %q", s)
370 }
371 if !strings.Contains(s, "project") && !strings.Contains(s, "项目") {
372 t.Fatalf("summary missing project count: %q", s)
373 }
374 if !strings.Contains(s, "builtin") && !strings.Contains(s, "内置") {
375 t.Fatalf("summary missing builtin count: %q", s)
376 }
377 }
378
379 func TestSkillPickerSummarySearch(t *testing.T) {
380 skills := makeTestSkills()
381 p := &skillPicker{skills: skills, searchActive: true, query: "rev"}
382 s := skillPickerSummary(p)
383 if !strings.Contains(s, "matching") && !strings.Contains(s, "匹配") {
384 t.Fatalf("search summary missing 'matching': %q", s)
385 }
386 // Should mention total count.
387 if !strings.Contains(s, "3") {
388 t.Fatalf("search summary missing total count: %q", s)
389 }
390 }
391
392 func TestSkillSourceSummary(t *testing.T) {
393 roots := []skillRootLine{
394 {dir: "/a", skills: 2},
395 {dir: "/b", skills: 1},
396 {dir: "/c", skills: 0},
397 }
398 s := skillSourceSummary(roots)
399 if !strings.Contains(s, "active") && !strings.Contains(s, "有效") {
400 t.Fatalf("source summary missing 'active': %q", s)
401 }
402
403 empty := skillSourceSummary([]skillRootLine{{dir: "/x", skills: 0}})
404 if empty != "" {
405 t.Fatalf("source summary with no active roots should be empty: %q", empty)
406 }
407 }
408
409 func TestSkillPickerEscCloses(t *testing.T) {
410 skills := makeTestSkills()
411 m := chatTUI{
412 width: 80,
413 skills: skills,
414 skillPick: &skillPicker{
415 mode: pickerSkills,
416 skills: skills,
417 },
418 }
419
420 next, _ := m.handleSkillPickerKey(tea.KeyPressMsg{Code: 27}) // Esc
421 cm := next.(chatTUI)
422 if cm.skillPick != nil {
423 t.Fatal("Esc did not close skill picker")
424 }
425 }
426
427 func TestSkillPickerSearchEscExitsSearch(t *testing.T) {
428 skills := makeTestSkills()
429 m := chatTUI{
430 width: 80,
431 skills: skills,
432 skillPick: &skillPicker{
433 mode: pickerSkills,
434 skills: skills,
435 searchActive: true,
436 query: "rev",
437 },
438 }
439
440 next, _ := m.handleSkillPickerKey(tea.KeyPressMsg{Code: 27}) // Esc
441 cm := next.(chatTUI)
442 if cm.skillPick == nil {
443 t.Fatal("Esc closed picker instead of exiting search")
444 }
445 if cm.skillPick.searchActive {
446 t.Fatal("Esc did not exit search mode")
447 }
448 }
449
450 func TestSkillPickerSSwitchesToSources(t *testing.T) {
451 skills := makeTestSkills()
452 m := chatTUI{
453 width: 80,
454 skills: skills,
455 skillPick: &skillPicker{
456 mode: pickerSkills,
457 skills: skills,
458 },
459 }
460
461 next, _ := m.handleSkillPickerKey(tea.KeyPressMsg{Code: 's'})
462 cm := next.(chatTUI)
463 if cm.skillPick.mode != pickerSources {
464 t.Fatalf("s did not switch to source view, mode=%s", cm.skillPick.mode)
465 }
466 }
467
468 func TestSkillPickerSourceEnterShowsRootSkills(t *testing.T) {
469 skills := makeTestSkills()
470 m := chatTUI{
471 width: 80,
472 skills: skills,
473 skillPick: &skillPicker{
474 mode: pickerSources,
475 skills: skills,
476 roots: []skillRootLine{
477 {dir: "/fake/proj/.reasonix/skills", scope: skill.ScopeProject, status: skill.StatusOK, skills: 2, diagnostic: true},
478 },
479 },
480 }
481
482 next, _ := m.handleSkillPickerKey(tea.KeyPressMsg{Code: tea.KeyEnter})
483 cm := next.(chatTUI)
484 if cm.skillPick.mode != pickerSourceSkills {
485 t.Fatalf("Enter did not open source skill list, mode=%s", cm.skillPick.mode)
486 }
487 out := cm.renderSkillPicker()
488 if !strings.Contains(out, "review") || !strings.Contains(out, "test") {
489 t.Fatalf("source skill list missing project skills:\n%s", out)
490 }
491 if strings.Contains(out, "explore") {
492 t.Fatalf("source skill list should not include builtin skill:\n%s", out)
493 }
494 }
495
496 func TestSkillPickerSpaceTogglesEnabled(t *testing.T) {
497 skills := makeTestSkills()
498 m := chatTUI{
499 width: 80,
500 skills: skills,
501 skillPick: &skillPicker{
502 mode: pickerSkills,
503 skills: skills,
504 sel: 0,
505 enabled: map[string]bool{"review": true},
506 originalEnabled: map[string]bool{"review": true},
507 },
508 }
509
510 // Space toggles the selected skill off.
511 next, _ := m.handleSkillPickerKey(tea.KeyPressMsg{Code: ' '})
512 cm := next.(chatTUI)
513 if cm.skillPick.skillEnabled("review") {
514 t.Fatal("Space did not disable the selected skill")
515 }
516
517 // Space toggles it back on.
518 next2, _ := cm.handleSkillPickerKey(tea.KeyPressMsg{Code: ' '})
519 cm2 := next2.(chatTUI)
520 if !cm2.skillPick.skillEnabled("review") {
521 t.Fatal("second Space did not enable the selected skill")
522 }
523 }
524
525 func TestSkillPickerDetailDeleteRequiresConfirmation(t *testing.T) {
526 dir := t.TempDir()
527 path := filepath.Join(dir, "review", skill.SkillFile)
528 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
529 t.Fatal(err)
530 }
531 if err := os.WriteFile(path, []byte("# Review"), 0o644); err != nil {
532 t.Fatal(err)
533 }
534 skills := []skill.Skill{
535 {Name: "review", Description: "Review code", Scope: skill.ScopeProject, Path: path, RunAs: skill.RunSubagent, Body: "# Review"},
536 }
537 p := &skillPicker{
538 mode: pickerDetail,
539 skills: skills,
540 detailSkill: skills[0],
541 detailBack: pickerSkills,
542 detailAction: 1,
543 }
544 m := chatTUI{width: 80, skills: skills, skillPick: p}
545
546 next, _ := m.handleSkillPickerKey(tea.KeyPressMsg{Code: tea.KeyEnter})
547 cm := next.(chatTUI)
548 if cm.skillPick.mode != pickerConfirmDelete {
549 t.Fatalf("delete action should open confirmation, mode=%s", cm.skillPick.mode)
550 }
551 if cm.skillPick.confirm != 1 {
552 t.Fatalf("delete confirmation should default to cancel, got %d", cm.skillPick.confirm)
553 }
554 }
555
556 func TestSkillPickerDetailShowsActionsBeforeBodyPreview(t *testing.T) {
557 s := skill.Skill{
558 Name: "review",
559 Description: "Review code",
560 Scope: skill.ScopeProject,
561 Path: "/proj/.reasonix/skills/review/SKILL.md",
562 RunAs: skill.RunSubagent,
563 Body: "# Review\n\n" + strings.Repeat("BODY_LINE\n", 40),
564 }
565 m := chatTUI{
566 width: 80,
567 skills: []skill.Skill{s},
568 skillPick: &skillPicker{
569 mode: pickerDetail,
570 skills: []skill.Skill{s},
571 detailSkill: s,
572 enabled: map[string]bool{s.Name: true},
573 originalEnabled: map[string]bool{s.Name: true},
574 },
575 }
576
577 out := m.renderSkillPickerDetail()
578 actionAt := strings.Index(out, i18n.M.SkillPickerActionToggle)
579 bodyAt := strings.Index(out, "BODY_LINE")
580 if actionAt < 0 {
581 t.Fatalf("detail missing action row:\n%s", out)
582 }
583 if bodyAt < 0 {
584 t.Fatalf("detail missing body preview:\n%s", out)
585 }
586 if actionAt > bodyAt {
587 t.Fatalf("detail action should render before body preview:\n%s", out)
588 }
589 }
590
591 func TestDeleteSkillPickRemovesDirectoryTarget(t *testing.T) {
592 dir := t.TempDir()
593 path := filepath.Join(dir, "review", skill.SkillFile)
594 targetDir := filepath.Dir(path)
595 if err := os.MkdirAll(targetDir, 0o755); err != nil {
596 t.Fatal(err)
597 }
598 if err := os.WriteFile(path, []byte("# Review"), 0o644); err != nil {
599 t.Fatal(err)
600 }
601 s := skill.Skill{Name: "review", Scope: skill.ScopeProject, Path: path, RunAs: skill.RunSubagent, Body: "# Review"}
602 m := newTestChatTUI()
603 m.skills = []skill.Skill{s}
604 m.skillPick = &skillPicker{
605 mode: pickerConfirmDelete,
606 skills: []skill.Skill{s},
607 deleteSkill: s,
608 enabled: map[string]bool{s.Name: true},
609 originalEnabled: map[string]bool{s.Name: true},
610 }
611
612 next, _ := m.deleteSkillPick(s)
613 cm := next.(chatTUI)
614 if _, err := os.Stat(targetDir); !os.IsNotExist(err) {
615 t.Fatalf("deleteSkillPick target still exists or unexpected error: %v", err)
616 }
617 if cm.skillPick == nil || cm.skillPick.mode != pickerSkills {
618 t.Fatalf("delete should return to skills list, picker=%v", cm.skillPick)
619 }
620 }
621
622 func TestSkillDeleteTargetDirectoryAndFlatFile(t *testing.T) {
623 dir := t.TempDir()
624 nested := filepath.Join(dir, "review", skill.SkillFile)
625 if err := os.MkdirAll(filepath.Dir(nested), 0o755); err != nil {
626 t.Fatal(err)
627 }
628 if err := os.WriteFile(nested, []byte("# Review"), 0o644); err != nil {
629 t.Fatal(err)
630 }
631 target, ok, err := skillDeleteTarget(skill.Skill{Name: "review", Scope: skill.ScopeProject, Path: nested})
632 if err != nil || !ok {
633 t.Fatalf("skillDeleteTarget directory = %q %v %v", target, ok, err)
634 }
635 if target != filepath.Dir(nested) {
636 t.Fatalf("directory target = %q, want %q", target, filepath.Dir(nested))
637 }
638
639 flat := filepath.Join(dir, "flat.md")
640 if err := os.WriteFile(flat, []byte("# Flat"), 0o644); err != nil {
641 t.Fatal(err)
642 }
643 target, ok, err = skillDeleteTarget(skill.Skill{Name: "flat", Scope: skill.ScopeProject, Path: flat})
644 if err != nil || !ok {
645 t.Fatalf("skillDeleteTarget flat = %q %v %v", target, ok, err)
646 }
647 if target != flat {
648 t.Fatalf("flat target = %q, want %q", target, flat)
649 }
650
651 if _, ok, err := skillDeleteTarget(skill.Skill{Name: "explore", Scope: skill.ScopeBuiltin, Path: "(builtin)"}); err != nil || ok {
652 t.Fatalf("builtin target ok=%v err=%v, want not removable", ok, err)
653 }
654 }
655
656 func TestClampSel(t *testing.T) {
657 items := []int{10, 20, 30}
658 if got := clampSel(0, items); got != 0 {
659 t.Fatalf("clampSel(0) = %d", got)
660 }
661 if got := clampSel(2, items); got != 2 {
662 t.Fatalf("clampSel(2) = %d", got)
663 }
664 if got := clampSel(5, items); got != 2 {
665 t.Fatalf("clampSel(5) = %d, want 2", got)
666 }
667 if got := clampSel(-1, items); got != 0 {
668 t.Fatalf("clampSel(-1) = %d, want 0", got)
669 }
670 if got := clampSel(0, []int{}); got != 0 {
671 t.Fatalf("clampSel(0, []) = %d, want 0", got)
672 }
673 }
674
675 func TestSkillRowLabelHasSubagentTag(t *testing.T) {
676 s := skill.Skill{Name: "review", Description: "Review code", Scope: skill.ScopeProject, RunAs: skill.RunSubagent}
677 row := renderSkillRow(1, false, s, true, 80)
678 if !strings.Contains(row, "subagent") && !strings.Contains(row, "子代理") {
679 t.Fatalf("subagent skill missing tag: %q", row)
680 }
681 if !strings.Contains(row, "review") {
682 t.Fatalf("missing skill name: %q", row)
683 }
684 }
685
686 func TestRenderSkillRowSelected(t *testing.T) {
687 s := skill.Skill{Name: "test", Description: "A test skill", Scope: skill.ScopeGlobal, RunAs: skill.RunInline}
688 row := renderSkillRow(5, true, s, true, 80)
689 if !strings.Contains(row, "›") {
690 t.Fatalf("selected row missing arrow: %q", row)
691 }
692 // Selected row differs from unselected: has arrow, no dim prefix.
693 unsel := renderSkillRow(5, false, s, true, 80)
694 if row == unsel {
695 t.Fatal("selected and unselected rows should differ")
696 }
697 }
698
699 func TestRenderSkillRowScopeMeta(t *testing.T) {
700 s := skill.Skill{Name: "example", Description: "desc", Scope: skill.ScopeGlobal, RunAs: skill.RunInline}
701 row := renderSkillRow(1, false, s, true, 80)
702 if !strings.Contains(row, "global") && !strings.Contains(row, "全局") {
703 t.Fatalf("row missing scope label: %q", row)
704 }
705 if !strings.Contains(row, "tok") {
706 t.Fatalf("row missing approximate token count: %q", row)
707 }
708 }
709
710 func TestRenderSkillRowLongNameTruncated(t *testing.T) {
711 s := skill.Skill{
712 Name: "this-is-a-very-long-skill-name-that-exceeds-26-chars",
713 Description: "desc",
714 Scope: skill.ScopeBuiltin,
715 }
716 row := renderSkillRow(1, false, s, true, 80)
717 if !strings.Contains(row, "…") {
718 t.Fatalf("long name not truncated with …: %q", row)
719 }
720 }
721
722 func TestRenderSkillRowChineseBadgeFitsWidth(t *testing.T) {
723 i18n.DetectLanguage("zh")
724 t.Cleanup(func() { i18n.DetectLanguage("en") })
725
726 s := skill.Skill{
727 Name: "browser-testing-with-devtools",
728 Description: "Tests in real browsers. Use when building or debugging anything that runs in a browser.",
729 Scope: skill.ScopeGlobal,
730 RunAs: skill.RunSubagent,
731 }
732 row := renderSkillRow(13, true, s, true, 80)
733 if got := visibleWidth(row); got > 80 {
734 t.Fatalf("row visible width = %d, want <= 80:\n%q", got, row)
735 }
736 if strings.Contains(row, "\n") {
737 t.Fatalf("row should stay single-line: %q", row)
738 }
739 }
740
741 func TestSortedSkills(t *testing.T) {
742 skills := []skill.Skill{
743 {Name: "z-builtin", Scope: skill.ScopeBuiltin},
744 {Name: "a-project", Scope: skill.ScopeProject},
745 {Name: "b-project", Scope: skill.ScopeProject},
746 {Name: "c-global", Scope: skill.ScopeGlobal},
747 {Name: "d-custom", Scope: skill.ScopeCustom},
748 }
749 sorted := sortedSkills(skills)
750 // Project first, then custom, global, builtin; alphabetical within.
751 want := []string{"a-project", "b-project", "d-custom", "c-global", "z-builtin"}
752 for i, s := range sorted {
753 if i >= len(want) || s.Name != want[i] {
754 t.Fatalf("sorted[%d] = %s, want %s", i, s.Name, want[i])
755 }
756 }
757 }
758
759 func TestSkillPickerRendersInMainArea(t *testing.T) {
760 m := newTestChatTUI()
761 m.width = 80
762 m.height = 40
763 m.skillPick = &skillPicker{
764 mode: pickerSkills,
765 skills: []skill.Skill{
766 {Name: "a", Description: "desc", Scope: skill.ScopeBuiltin},
767 {Name: "b", Description: "desc", Scope: skill.ScopeBuiltin},
768 {Name: "c", Description: "desc", Scope: skill.ScopeBuiltin},
769 },
770 }
771
772 rows := m.bottomRows()
773 footerRows := strings.Count(m.renderMainManagerFooter(), "\n") + 1
774 if want := footerRows + 2; rows != want {
775 t.Fatalf("bottomRows with skill picker open got %d, want %d (footer + status rows)", rows, want)
776 }
777 if !m.hideComposer() {
778 t.Fatal("skill picker should hide the composer")
779 }
780 if out := m.renderMainManager(); !strings.Contains(out, "Manage skills") {
781 t.Fatalf("skill picker should render as a main manager:\n%s", out)
782 }
783 }
784
785 func TestBottomRowsIncludesResumePicker(t *testing.T) {
786 m := newTestChatTUI()
787 m.width = 80
788 m.height = 40
789 m.resumePick = &resumePicker{
790 sessions: nil,
791 sel: 0,
792 active: -1,
793 }
794
795 // Not testing exact row count (resumePicker needs sessions for rendering),
796 // just verifying that bottomRows doesn't panic and returns a non-zero value.
797 rows := m.bottomRows()
798 if rows < 3 {
799 t.Fatalf("bottomRows with resume picker got %d", rows)
800 }
801 }
802
803 func TestRescanInSearchModeClampsToFiltered(t *testing.T) {
804 skills := []skill.Skill{
805 {Name: "alpha", Description: "first", Scope: skill.ScopeBuiltin},
806 {Name: "beta", Description: "second", Scope: skill.ScopeBuiltin},
807 {Name: "gamma", Description: "third", Scope: skill.ScopeBuiltin},
808 }
809 p := &skillPicker{
810 mode: pickerSkills,
811 skills: skills,
812 searchActive: true,
813 query: "beta",
814 sel: 2, // beyond filtered results (only "beta" matches)
815 }
816 p.sel = clampSel(p.sel, skills) // simulating old behavior
817
818 // Fix: clamp to filtered when search is active.
819 if p.searchActive && p.query != "" {
820 p.sel = clampSel(p.sel, p.filteredSkills())
821 }
822 if p.sel != 0 {
823 t.Fatalf("sel after clamp to filtered should be 0, got %d", p.sel)
824 }
825 }
826
827 func TestPathBoundaryMatching(t *testing.T) {
828 skills := []skill.Skill{
829 {Name: "alpha", Scope: skill.ScopeProject, Path: "/proj/.reasonix/skills/alpha/SKILL.md"},
830 {Name: "beta", Scope: skill.ScopeProject, Path: "/proj/.reasonix/skills-extra/beta/SKILL.md"},
831 }
832 lines := []skillRootLine{
833 {dir: "/proj/.reasonix/skills", scope: skill.ScopeProject, status: skill.StatusOK},
834 {dir: "/proj/.reasonix/skills-extra", scope: skill.ScopeProject, status: skill.StatusOK},
835 }
836
837 // Count skills per root with directory-boundary match.
838 for _, s := range skills {
839 if s.Scope == skill.ScopeBuiltin {
840 continue
841 }
842 cleanPath := filepath.Clean(s.Path)
843 for i := range lines {
844 if lines[i].scope != s.Scope {
845 continue
846 }
847 prefix := filepath.Clean(lines[i].dir) + string(filepath.Separator)
848 if strings.HasPrefix(cleanPath, prefix) {
849 lines[i].skills++
850 break
851 }
852 }
853 }
854
855 if lines[0].skills != 1 {
856 t.Fatalf("/skills root should have 1 skill, got %d", lines[0].skills)
857 }
858 if lines[1].skills != 1 {
859 t.Fatalf("/skills-extra root should have 1 skill, got %d", lines[1].skills)
860 }
861 }
862
863 func TestSourceRowLabelUsesI18n(t *testing.T) {
864 i18n.DetectLanguage("zh")
865 t.Cleanup(func() { i18n.DetectLanguage("en") })
866
867 r := skillRootLine{
868 dir: "/proj/.reasonix/skills",
869 scope: skill.ScopeProject,
870 status: skill.StatusOK,
871 skills: 3,
872 }
873 label := sourceRowLabel(r, 80)
874 if !strings.Contains(label, "项目") || strings.Contains(label, "project") {
875 t.Fatalf("source row should use localized scope label, got %q", label)
876 }
877 // Check that skills unit is used
878 if !strings.Contains(label, "skills") && !strings.Contains(label, "skill") {
879 t.Fatalf("source row missing skills unit: %q", label)
880 }
881 }
882
883 func TestStatusLabelI18n(t *testing.T) {
884 if l := statusLabel(skill.StatusOK); l == "" {
885 t.Fatal("statusLabel(ok) empty")
886 }
887 if l := statusLabel(skill.StatusMissing); l == "" {
888 t.Fatal("statusLabel(missing) empty")
889 }
890 if l := statusLabel(skill.StatusNotDirectory); l == "" {
891 t.Fatal("statusLabel(not-directory) empty")
892 }
893 if l := statusLabel(skill.StatusUnreadable); l == "" {
894 t.Fatal("statusLabel(unreadable) empty")
895 }
896 // Unknown status falls back to raw string.
897 if l := statusLabel(skill.PathStatus("custom-status")); l != "custom-status" {
898 t.Fatalf("statusLabel(unknown) = %q, want 'custom-status'", l)
899 }
900 }
901
902 func TestRenderSkillDetailUsesI18nMeta(t *testing.T) {
903 s := skill.Skill{
904 Name: "review",
905 Description: "Review code",
906 Scope: skill.ScopeProject,
907 Path: "/proj/.reasonix/skills/review/SKILL.md",
908 RunAs: skill.RunSubagent,
909 Body: "# Review\n\nLine 1\nLine 2",
910 }
911 out := renderSkillDetail(s, 80)
912 // Detail should use i18n meta format (not raw "Scope:" when in Chinese).
913 if !strings.Contains(out, "Scope") && !strings.Contains(out, "范围") {
914 t.Fatalf("detail missing scope label:\n%s", out)
915 }
916 if !strings.Contains(out, "Run as") && !strings.Contains(out, "运行") {
917 t.Fatalf("detail missing run-as label:\n%s", out)
918 }
919 }
920
921 func TestLegacySkillShowUnchanged(t *testing.T) {
922 s := skill.Skill{
923 Name: "my-skill",
924 Description: "A test skill",
925 Scope: skill.ScopeProject,
926 Path: "/fake/proj/.reasonix/skills/my-skill.md",
927 Body: "# My Skill\n\nSome body text.",
928 }
929 out := renderSkillShow(80, s, false)
930 if !strings.Contains(out, "skill:") && !strings.Contains(out, "my-skill") {
931 t.Fatalf("legacy skill show broken:\n%s", out)
932 }
933 }
934
935 func TestLegacySkillPathsUnchanged(t *testing.T) {
936 roots := []skill.Root{
937 {Dir: "/proj/.reasonix/skills", Scope: skill.ScopeProject, Priority: 0, Status: skill.StatusOK},
938 }
939 out := renderSkillPaths(80, roots)
940 if !strings.Contains(out, "skill paths") {
941 t.Fatalf("legacy skill paths broken:\n%s", out)
942 }
943 }
944
945 func TestSkillPickerRenderWidthNarrow(t *testing.T) {
946 skills := makeTestSkills()
947 m := chatTUI{
948 width: 30,
949 skills: skills,
950 skillPick: &skillPicker{
951 mode: pickerSkills,
952 skills: skills,
953 },
954 }
955
956 out := m.renderSkillPicker()
957 if out == "" {
958 t.Fatal("narrow render returned empty")
959 }
960 if !strings.Contains(out, "Manage skills") {
961 t.Fatalf("narrow render missing title:\n%s", out)
962 }
963 }
964
964 lines GO