返回 DeepSeek-Reasonix
slash_catalog_test.go
根目录 / internal / cli / slash_catalog_test.go
1 package cli
2
3 import (
4 "fmt"
5 "strings"
6 "testing"
7 "time"
8
9 tea "charm.land/bubbletea/v2"
10
11 "reasonix/internal/command"
12 "reasonix/internal/control"
13 "reasonix/internal/event"
14 "reasonix/internal/skill"
15 )
16
17 func TestSlashCatalogCachesAcrossKeystrokes(t *testing.T) {
18 ctrl := control.New(control.Options{})
19 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
20 m.skills = make([]skill.Skill, 0, 50)
21 for i := 0; i < 50; i++ {
22 m.skills = append(m.skills, skill.Skill{
23 Name: fmt.Sprintf("skill-%03d", i),
24 Description: strings.Repeat("description text for catalog build ", 20),
25 })
26 }
27 m.commands = []command.Command{{Name: "custom-cmd", Description: "custom"}}
28
29 first := m.slashItems()
30 if len(first) < 50 {
31 t.Fatalf("catalog size = %d, want at least 50 skills", len(first))
32 }
33 // Second call must reuse the same backing slice (immutable snapshot).
34 second := m.slashItems()
35 if &first[0] != &second[0] || len(first) != len(second) {
36 t.Fatal("slashItems must return the cached catalog between keystrokes")
37 }
38 // Explicit invalidation is required after source mutation.
39 m.skills = append(m.skills, skill.Skill{Name: "skill-extra", Description: "extra"})
40 // Without invalidate, cache must stay stale (no hot-path fingerprint).
41 stale := m.slashItems()
42 if len(stale) != len(first) {
43 t.Fatalf("without invalidate catalog mutated on keystroke path: %d → %d", len(first), len(stale))
44 }
45 m.invalidateSlashCatalog()
46 third := m.slashItems()
47 if len(third) != len(first)+1 {
48 t.Fatalf("after invalidate catalog = %d, want %d", len(third), len(first)+1)
49 }
50 }
51
52 func TestCtrlDForwardDeletesWhenComposerNonEmpty(t *testing.T) {
53 ctrl := control.New(control.Options{})
54 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
55 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
56 m = m0.(chatTUI)
57 m.input.SetValue("hello")
58 m.input.SetCursorColumn(0)
59
60 msg := tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}
61 if msg.String() != "ctrl+d" {
62 t.Fatalf("synthetic key String() = %q, want ctrl+d", msg.String())
63 }
64 out, _ := m.Update(msg)
65 m = out.(chatTUI)
66 if got := m.input.Value(); got != "ello" {
67 t.Fatalf("ctrl+d on non-empty = %q, want ello", got)
68 }
69 if m.state != tuiIdle {
70 t.Fatalf("state = %v, want idle (must not quit)", m.state)
71 }
72 }
73
74 func TestCtrlDForwardDeletesWhitespaceOnly(t *testing.T) {
75 ctrl := control.New(control.Options{})
76 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
77 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
78 m = m0.(chatTUI)
79 m.input.SetValue(" ")
80 m.input.SetCursorColumn(0)
81 msg := tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}
82 out, _ := m.Update(msg)
83 m = out.(chatTUI)
84 if got := m.input.Value(); got == " " {
85 // At least one space should be deleted; exact remainder depends on
86 // textarea delete-forward at col 0. Unchanged value would mean quit
87 // (or no-op) rather than forward-delete.
88 t.Fatalf("ctrl+d on whitespace-only must forward-delete, not quit; value still %q", got)
89 }
90 if m.state != tuiIdle {
91 t.Fatalf("must not quit on whitespace-only input")
92 }
93 }
94
95 func TestCtrlDQuitsWhenIdleAndEmpty(t *testing.T) {
96 ctrl := control.New(control.Options{})
97 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
98 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
99 m = m0.(chatTUI)
100 m.input.SetValue("")
101 msg := tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}
102 _, cmd := m.Update(msg)
103 if cmd == nil {
104 t.Fatal("ctrl+d on empty idle composer should request shutdown")
105 }
106 }
107
108 func TestActiveAtTokenFullSpanAndMidCursor(t *testing.T) {
109 val := "see @foo and more"
110 // Cursor mid-token after "@fo" → query is caret-limited "fo", span is full "@foo".
111 cursor := strings.Index(val, "@fo") + len("@fo")
112 at, end, tok, ok := activeAtToken(val, cursor)
113 if !ok || at != strings.Index(val, "@") || tok != "fo" {
114 t.Fatalf("activeAtToken mid-token = (%d,%d,%q,%v), want query fo", at, end, tok, ok)
115 }
116 if val[at:end] != "@foo" {
117 t.Fatalf("replace span = %q, want @foo (full token past caret)", val[at:end])
118 }
119 }
120
121 func TestMCPSurfaceReadyInvalidatesSlashCatalog(t *testing.T) {
122 ctrl := control.New(control.Options{})
123 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
124 m.skills = []skill.Skill{{Name: "warm", Description: "warm"}}
125 _ = m.slashItems()
126 if !m.slashCatalogOnce {
127 t.Fatal("expected warm catalog")
128 }
129 m.ingestEvent(event.Event{Kind: event.MCPSurfaceReady})
130 if m.slashCatalogOnce {
131 t.Fatal("MCPSurfaceReady must invalidate slash catalog")
132 }
133 }
134
135 func TestAcceptAtCompletionReplacesFullToken(t *testing.T) {
136 // Manual completion state: proves replaceFrom/replaceTo replace the whole
137 // token and preserve surrounding spaces (the audited "see @foo and more"
138 // regression).
139 m := newTestChatTUI()
140 m.input.SetValue("see @foo and more")
141 // "@foo" spans bytes [4, 8)
142 m.completion = completion{
143 active: true,
144 kind: compAt,
145 items: []compItem{{label: "@foobar.md", insert: "@foobar.md"}},
146 sel: 0,
147 replaceFrom: 4,
148 replaceTo: 8,
149 }
150 m.acceptCompletion()
151 got := m.input.Value()
152 want := "see @foobar.md and more"
153 if got != want {
154 t.Fatalf("accept mid-token = %q, want %q", got, want)
155 }
156 }
157
158 func TestInputCursorByteOffsetSubtractsPrompt(t *testing.T) {
159 ctrl := control.New(control.Options{})
160 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
161 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
162 m = m0.(chatTUI)
163 // Place "hello!!" and put caret after "hello" (rune index 5).
164 m.input.SetValue("hello!!")
165 m.setComposerCursor(5)
166 // Force layout cache used by inputCursorByteOffset.
167 _ = m.composerRows()
168 got := m.inputCursorByteOffset()
169 if got != 5 {
170 t.Fatalf("inputCursorByteOffset = %d, want 5 (prompt gutter must not add 2)", got)
171 }
172 }
173
174 func TestShiftTabAndBacktabBothAccepted(t *testing.T) {
175 ctrl := control.New(control.Options{})
176 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
177 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
178 m = m0.(chatTUI)
179 if m.ctrl == nil {
180 t.Fatal("expected controller")
181 }
182
183 // Production uses modeToggleKey for both encodings before the key switch.
184 for _, key := range []string{"shift+tab", "backtab"} {
185 if !modeToggleKey(key) {
186 t.Fatalf("modeToggleKey(%q) = false, want true", key)
187 }
188 }
189 if modeToggleKey("tab") || modeToggleKey("shift+enter") {
190 t.Fatal("modeToggleKey must not accept unrelated keys")
191 }
192
193 // Platform form: KeyTab+ModShift (typically String() == "shift+tab").
194 msg := tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}
195 s := msg.String()
196 if !modeToggleKey(s) {
197 t.Fatalf("KeyTab+ModShift String() = %q is not a mode-toggle key", s)
198 }
199 before := m.ctrl.ToolApprovalMode()
200 out, _ := m.Update(msg)
201 m = out.(chatTUI)
202 if m.ctrl.ToolApprovalMode() == before && !m.planMode {
203 t.Fatalf("%q did not cycle mode via Update", s)
204 }
205
206 // Explicit CSI-Z / legacy "backtab" text encoding through the production
207 // Update path (Key.String returns Text when non-empty).
208 before = m.ctrl.ToolApprovalMode()
209 planBefore := m.planMode
210 out, _ = m.Update(tea.KeyPressMsg{Text: "backtab"})
211 m = out.(chatTUI)
212 if m.ctrl.ToolApprovalMode() == before && m.planMode == planBefore {
213 t.Fatal(`Update(Text:"backtab") did not cycle mode — production path must honor modeToggleKey("backtab")`)
214 }
215 }
216
217 // BenchmarkSlashCompletionKeystroke measures filter+menu update with a large
218 // catalog (1000 skills). Catalog is warmed once; per-op cost must stay low and
219 // allocation-stable (no fingerprint rebuild).
220 func BenchmarkSlashCompletionKeystroke(b *testing.B) {
221 ctrl := control.New(control.Options{})
222 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
223 m.skills = make([]skill.Skill, 0, 1000)
224 for i := 0; i < 1000; i++ {
225 m.skills = append(m.skills, skill.Skill{
226 Name: fmt.Sprintf("bench-skill-%04d", i),
227 Description: "benchmark skill description " + strings.Repeat("x", 80),
228 })
229 }
230 _ = m.slashItems() // warm catalog once
231 b.ReportAllocs()
232 b.ResetTimer()
233 for i := 0; i < b.N; i++ {
234 m.input.SetValue("/be")
235 m.updateCompletion()
236 if !m.completion.active {
237 b.Fatal("expected completion menu")
238 }
239 }
240 b.StopTimer()
241 if b.N > 0 {
242 // Informational soft gate; CI machines vary.
243 _ = time.Millisecond
244 }
245 }
246
246 lines GO