1package modes
2
3import (
4 "errors"
5 "slices"
6 "strings"
7 "testing"
8
9 "github.com/bnema/zut/packages/agent/skills"
10 "github.com/bnema/zut/packages/tui"
11 "github.com/mattn/go-runewidth"
12)
13
14func reviewSnapshot() []*skills.Skill {
15 return []*skills.Skill{{Name: "review", Body: "Inspect carefully."}}
16}
17
18func pinKey(r rune) tui.Key { return tui.Key{Kind: tui.KeyRune, Rune: r} }
19
20func TestSkillPinStateConsumesOnceAndRearms(t *testing.T) {
21 load := func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }
22 s := newSkillPinState(load, nil, reviewSnapshot)
23 s.arm(true, "/w", nil)
24 if got := stripANSIBytes(strings.Join(s.notice(tui.Theme{}, 80), "")); !strings.Contains(got, "review (global)") {
25 t.Fatalf("notice = %q", got)
26 }
27 first := s.consume("first")
28 if !strings.Contains(first, "Inspect carefully.") || !strings.HasSuffix(first, "first") {
29 t.Fatal(first)
30 }
31 if got := s.consume("second"); got != "second" {
32 t.Fatal(got)
33 }
34 if len(s.notice(tui.Theme{}, 80)) != 0 {
35 t.Fatal("notice survived consumption")
36 }
37 // /clear and /cd re-arm.
38 s.arm(true, "/w", nil)
39 if !strings.Contains(s.consume("after clear"), "Inspect carefully.") {
40 t.Fatal("re-arm did not restore pins")
41 }
42 // Resume/import/fork disarm even after arming.
43 s.arm(true, "/w", nil)
44 s.arm(false, "/w", nil)
45 if got := s.consume("resumed"); got != "resumed" {
46 t.Fatal("resume reinjected pins")
47 }
48 // Refresh (model swap, /skills) never re-arms a consumed state.
49 s.refresh("/w", nil)
50 if got := s.consume("later"); got != "later" {
51 t.Fatal("refresh re-armed pins")
52 }
53 // No loader means nothing is ever pending.
54 none := newSkillPinState(nil, nil, reviewSnapshot)
55 none.arm(true, "/w", nil)
56 if got := none.consume("x"); got != "x" {
57 t.Fatal(got)
58 }
59}
60
61func TestSkillPinStateTogglesAndSaveFailure(t *testing.T) {
62 pins := skills.Pins{}
63 fail := false
64 flip := func(names []string, name string) []string {
65 if slices.Contains(names, name) {
66 return nil
67 }
68 return []string{name}
69 }
70 toggle := func(_, name string, global bool) error {
71 if fail {
72 return errors.New("read-only preferences")
73 }
74 if global {
75 pins.Global = flip(pins.Global, name)
76 } else {
77 pins.Project = flip(pins.Project, name)
78 }
79 return nil
80 }
81 s := newSkillPinState(func(string) (skills.Pins, error) { return pins, nil }, toggle, reviewSnapshot)
82 d := newSkillsDialog()
83 d.Open(reviewSnapshot())
84 s.arm(true, "/w", d)
85
86 for _, r := range []rune{'p', 'g'} {
87 handled, ok, errText, _ := s.handleKey(pinKey(r), "/w", d)
88 if !handled || errText != "" || ok == "" {
89 t.Fatalf("key %c: handled=%v ok=%q err=%q", r, handled, ok, errText)
90 }
91 }
92 if rendered := strings.Join(d.Render(tui.Theme{}, 100), "\n"); !strings.Contains(rendered, "[pg]") {
93 t.Fatal(rendered)
94 }
95 if len(s.selected) != 1 {
96 t.Fatal("scope union duplicated skill")
97 }
98 s.handleKey(pinKey('p'), "/w", d)
99 if len(s.selected) != 1 || len(d.pins.Project) != 0 {
100 t.Fatal("project unpin removed global pin")
101 }
102 fail = true
103 handled, _, errText, _ := s.handleKey(pinKey('g'), "/w", d)
104 if !handled || !strings.Contains(errText, "read-only") || len(d.pins.Global) != 1 {
105 t.Fatalf("save failure not preserved: %q", errText)
106 }
107 // Body view and other keys are not pin keys.
108 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
109 if handled, _, _, _ := s.handleKey(pinKey('g'), "/w", d); handled {
110 t.Fatal("body view toggled a pin")
111 }
112 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
113 if handled, _, _, _ := s.handleKey(pinKey('x'), "/w", d); handled {
114 t.Fatal("unrelated key handled")
115 }
116}
117
118func TestSkillPinStateReadOnlyHost(t *testing.T) {
119 s := newSkillPinState(func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }, nil, reviewSnapshot)
120 d := newSkillsDialog()
121 d.Open(reviewSnapshot())
122 s.arm(true, "/w", d)
123 if handled, _, _, _ := s.handleKey(pinKey('p'), "/w", d); handled {
124 t.Fatal("pin key handled without ToggleSkillPin")
125 }
126 rendered := strings.Join(d.Render(tui.Theme{}, 100), "\n")
127 if strings.Contains(rendered, "[-g]") || strings.Contains(rendered, "p: project pin") {
128 t.Fatalf("read-only host shows pin controls: %s", rendered)
129 }
130 // The pending notice still informs the user.
131 if len(s.notice(tui.Theme{}, 80)) == 0 {
132 t.Fatal("pending notice missing")
133 }
134}
135
136func TestSkillPinStateNoticeShowsScopesOnce(t *testing.T) {
137 load := func(string) (skills.Pins, error) {
138 return skills.Pins{Project: []string{"local", "shared"}, Global: []string{"everywhere", "shared"}}, nil
139 }
140 snap := func() []*skills.Skill {
141 return []*skills.Skill{{Name: "local"}, {Name: "everywhere"}, {Name: "shared"}}
142 }
143 s := newSkillPinState(load, nil, snap)
144 s.arm(true, "/w", nil)
145 text := stripANSIBytes(strings.Join(s.notice(tui.Theme{}, 120), "\n"))
146 for _, want := range []string{"local (project)", "everywhere (global)", "shared (project + global)"} {
147 if !strings.Contains(text, want) {
148 t.Errorf("notice missing %q: %q", want, text)
149 }
150 }
151 if strings.Count(text, "shared") != 1 {
152 t.Fatalf("both scopes duplicated the skill: %q", text)
153 }
154}
155
156func TestSkillPinStateWarningsAndNoticeWidth(t *testing.T) {
157 load := func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"missing", "review"}}, nil }
158 s := newSkillPinState(load, nil, reviewSnapshot)
159 warnings := s.arm(true, "/w", nil)
160 if len(warnings) != 1 || !strings.Contains(warnings[0], "missing") {
161 t.Fatalf("warnings = %v", warnings)
162 }
163 bad := newSkillPinState(func(string) (skills.Pins, error) { return skills.Pins{}, errors.New("invalid skill-pins.json") }, nil, reviewSnapshot)
164 if w := bad.arm(true, "/w", nil); len(w) != 1 || !strings.Contains(w[0], "invalid") {
165 t.Fatalf("malformed warning = %v", w)
166 }
167 s.selected = []*skills.Skill{{Name: strings.Repeat("界", 30)}}
168 for _, width := range []int{1, 4, 12, 30, 80} {
169 for _, row := range s.notice(tui.Theme{}, width) {
170 if got := runewidth.StringWidth(stripANSIBytes(row)); got > width {
171 t.Fatalf("width %d exceeds %d", got, width)
172 }
173 }
174 }
175}