1package modes
2
3import (
4 "strings"
5 "testing"
6
7 "github.com/patriceckhart/zot/packages/agent/skills"
8 "github.com/patriceckhart/zot/packages/tui"
9)
10
11// openSkillBody opens the /skills picker on one skill and enters its body
12// view, the part of the dialog that renders a description, a source path,
13// and prose that routinely exceed one terminal row.
14func openSkillBody(t *testing.T, s *skills.Skill) *skillsDialog {
15 t.Helper()
16 d := newSkillsDialog()
17 d.Open([]*skills.Skill{s})
18 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
19 if d.viewing == nil {
20 t.Fatal("enter did not open the body view")
21 }
22 return d
23}
24
25func TestSkillsDialogBodyWrapsLongLines(t *testing.T) {
26 const width = 64
27 description := "A deliberately long skill description that cannot fit on a single row of a narrow terminal window."
28 path := "/home/someone/.local/state/zot/skills/engineering/deliberately-long-skill-name/SKILL.md"
29 body := "# Deliberately long\n\n" +
30 strings.Repeat("skill body prose that has to fold instead of running off the edge. ", 16) +
31 "and one final tail sentence."
32
33 d := openSkillBody(t, &skills.Skill{
34 Name: "wide",
35 Description: description,
36 Body: body,
37 Path: path,
38 Source: "global",
39 })
40
41 rows := d.Render(tui.Theme{}, width)
42 assertRowsFitWidth(t, rows, width)
43
44 plain := strings.Join(rows, "\n")
45 for _, word := range strings.Fields(description) {
46 if !strings.Contains(plain, word) {
47 t.Fatalf("wrapped description lost %q:\n%s", word, stripANSIBytes(plain))
48 }
49 }
50 // The source path has no spaces, so it survives only when the dialog
51 // folds it rather than handing an over-wide row to the renderer. A
52 // path longer than one row is split mid-token, so compare it with the
53 // whitespace the fold introduces removed.
54 got := strings.NewReplacer(" ", "", "\n", "").Replace(stripANSIBytes(plain))
55 if !strings.Contains(got, path) {
56 t.Fatalf("wrapped source line lost its path:\n%s", stripANSIBytes(plain))
57 }
58
59 // Folding makes the body taller than the 16-row viewport, so the tail
60 // must still be reachable by scrolling.
61 for range 20 {
62 d.HandleKey(tui.Key{Kind: tui.KeyPageDown})
63 }
64 scrolled := d.Render(tui.Theme{}, width)
65 assertRowsFitWidth(t, scrolled, width)
66 if got := stripANSIBytes(strings.Join(scrolled, "\n")); !strings.Contains(got, "final tail sentence.") {
67 t.Fatalf("scrolled body lost its tail:\n%s", got)
68 }
69}
70
71func TestSkillsDialogShortTerminalKeepsMetadataAndBodyReachable(t *testing.T) {
72 const width, height, otherRows = 44, 24, 7
73 d := openSkillBody(t, &skills.Skill{
74 Name: "wide",
75 Description: strings.Repeat("description that wraps repeatedly ", 18),
76 Source: "global",
77 Path: "/long/skill/path/SKILL.md",
78 Body: "first body line\nsecond body line\nlast body line",
79 })
80 d.fitBodyRows(height, otherRows)
81 render := func() string {
82 rows := padDialogFrame(d.Render(tui.Theme{}, width))
83 // Account for the editor/status band and the renderer's bottom margin.
84 if len(rows)+otherRows+1 > height {
85 t.Fatalf("dialog has %d rows with %d reserved, terminal has %d", len(rows), otherRows+1, height)
86 }
87 assertRowsFitWidth(t, rows, width)
88 return stripANSIBytes(strings.Join(rows, "\n"))
89 }
90 if got := render(); !strings.Contains(got, "description that wraps") {
91 t.Fatalf("metadata start not visible: %s", got)
92 }
93 foundPath, foundBody := false, false
94 for range 40 {
95 visible := render()
96 foundPath = foundPath || strings.Contains(visible, "/long/skill/path/SKILL.md")
97 foundBody = foundBody || strings.Contains(visible, "first body line")
98 if foundPath && foundBody {
99 break
100 }
101 d.HandleKey(tui.Key{Kind: tui.KeyDown})
102 }
103 if !foundPath || !foundBody {
104 t.Fatalf("source and first body line must both be reachable, source=%t body=%t", foundPath, foundBody)
105 }
106 for range 40 {
107 d.HandleKey(tui.Key{Kind: tui.KeyUp})
108 }
109 if got := render(); !strings.Contains(got, "description that wraps") {
110 t.Fatalf("metadata start unreachable after scrolling: %s", got)
111 }
112}
113
114func TestSkillsDialogWrapsHintLines(t *testing.T) {
115 const width = 44
116 d := newSkillsDialog()
117 d.canPin = true
118 d.Open(nil)
119
120 rows := d.Render(tui.Theme{}, width)
121 assertRowsFitWidth(t, rows, width)
122
123 plain := stripANSIBytes(strings.Join(rows, "\n"))
124 for _, want := range []string{"project pin", "toggle", "no user skills loaded", "$ZOT_HOME/skills", ".agents/skills"} {
125 if !strings.Contains(plain, want) {
126 t.Fatalf("wrapped hint lost %q:\n%s", want, plain)
127 }
128 }
129}