返回 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/memory"
15 "reasonix/internal/skill"
16 )
17
18 func TestSlashCatalogCachesAcrossKeystrokes(t *testing.T) {
19 ctrl := newOwnedTestController(t, control.Options{})
20 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
21 m.skills = make([]skill.Skill, 0, 50)
22 for i := range 50 {
23 m.skills = append(m.skills, skill.Skill{
24 Name: fmt.Sprintf("skill-%03d", i),
25 Description: strings.Repeat("description text for catalog build ", 20),
26 })
27 }
28 m.commands = []command.Command{{Name: "custom-cmd", Description: "custom"}}
29
30 first := m.slashItems()
31 if len(first) < 50 {
32 t.Fatalf("catalog size = %d, want at least 50 skills", len(first))
33 }
34 // Second call must reuse the same backing slice (immutable snapshot).
35 second := m.slashItems()
36 if &first[0] != &second[0] || len(first) != len(second) {
37 t.Fatal("slashItems must return the cached catalog between keystrokes")
38 }
39 // Explicit invalidation is required after source mutation.
40 m.skills = append(m.skills, skill.Skill{Name: "skill-extra", Description: "extra"})
41 // Without invalidate, cache must stay stale (no hot-path fingerprint).
42 stale := m.slashItems()
43 if len(stale) != len(first) {
44 t.Fatalf("without invalidate catalog mutated on keystroke path: %d → %d", len(first), len(stale))
45 }
46 m.invalidateSlashCatalog()
47 third := m.slashItems()
48 if len(third) != len(first)+1 {
49 t.Fatalf("after invalidate catalog = %d, want %d", len(third), len(first)+1)
50 }
51 }
52
53 func TestCtrlDForwardDeletesWhenComposerNonEmpty(t *testing.T) {
54 ctrl := newOwnedTestController(t, control.Options{})
55 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
56 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
57 m = m0.(chatTUI)
58 m.input.SetValue("hello")
59 m.input.SetCursorColumn(0)
60
61 msg := tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}
62 if msg.String() != "ctrl+d" {
63 t.Fatalf("synthetic key String() = %q, want ctrl+d", msg.String())
64 }
65 out, _ := m.Update(msg)
66 m = out.(chatTUI)
67 if got := m.input.Value(); got != "ello" {
68 t.Fatalf("ctrl+d on non-empty = %q, want ello", got)
69 }
70 if m.state != tuiIdle {
71 t.Fatalf("state = %v, want idle (must not quit)", m.state)
72 }
73 }
74
75 func TestCtrlDForwardDeletesWhitespaceOnly(t *testing.T) {
76 ctrl := newOwnedTestController(t, control.Options{})
77 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
78 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
79 m = m0.(chatTUI)
80 m.input.SetValue(" ")
81 m.input.SetCursorColumn(0)
82 msg := tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}
83 out, _ := m.Update(msg)
84 m = out.(chatTUI)
85 if got := m.input.Value(); got == " " {
86 // At least one space should be deleted; exact remainder depends on
87 // textarea delete-forward at col 0. Unchanged value would mean quit
88 // (or no-op) rather than forward-delete.
89 t.Fatalf("ctrl+d on whitespace-only must forward-delete, not quit; value still %q", got)
90 }
91 if m.state != tuiIdle {
92 t.Fatalf("must not quit on whitespace-only input")
93 }
94 }
95
96 func TestCtrlDQuitsWhenIdleAndEmpty(t *testing.T) {
97 ctrl := newOwnedTestController(t, control.Options{})
98 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
99 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
100 m = m0.(chatTUI)
101 m.input.SetValue("")
102 msg := tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}
103 _, cmd := m.Update(msg)
104 if cmd == nil {
105 t.Fatal("ctrl+d on empty idle composer should request shutdown")
106 }
107 }
108
109 func TestActiveAtTokenFullSpanAndMidCursor(t *testing.T) {
110 val := "see @foo and more"
111 // Cursor mid-token after "@fo" → query is caret-limited "fo", span is full "@foo".
112 cursor := strings.Index(val, "@fo") + len("@fo")
113 at, end, tok, ok := activeAtToken(val, cursor)
114 if !ok || at != strings.Index(val, "@") || tok != "fo" {
115 t.Fatalf("activeAtToken mid-token = (%d,%d,%q,%v), want query fo", at, end, tok, ok)
116 }
117 if val[at:end] != "@foo" {
118 t.Fatalf("replace span = %q, want @foo (full token past caret)", val[at:end])
119 }
120 }
121
122 func TestMCPSurfaceReadyInvalidatesSlashCatalog(t *testing.T) {
123 ctrl := newOwnedTestController(t, control.Options{})
124 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
125 m.skills = []skill.Skill{{Name: "warm", Description: "warm"}}
126 _ = m.slashItems()
127 if m.slashCache == nil || m.slashCache.items == nil {
128 t.Fatal("expected warm catalog")
129 }
130 m.ingestEvent(event.Event{Kind: event.MCPSurfaceReady})
131 if m.slashCache != nil {
132 t.Fatal("MCPSurfaceReady must invalidate slash catalog")
133 }
134 }
135
136 func TestAcceptAtCompletionReplacesFullToken(t *testing.T) {
137 // Manual completion state: proves replaceFrom/replaceTo replace the whole
138 // token and preserve surrounding spaces (the audited "see @foo and more"
139 // regression).
140 m := newTestChatTUI()
141 m.input.SetValue("see @foo and more")
142 // "@foo" spans bytes [4, 8)
143 m.completion = completion{
144 active: true,
145 kind: compAt,
146 items: []compItem{{label: "@foobar.md", insert: "@foobar.md"}},
147 sel: 0,
148 replaceFrom: 4,
149 replaceTo: 8,
150 }
151 m.acceptCompletion()
152 got := m.input.Value()
153 want := "see @foobar.md and more"
154 if got != want {
155 t.Fatalf("accept mid-token = %q, want %q", got, want)
156 }
157 }
158
159 func TestInputCursorByteOffsetSubtractsPrompt(t *testing.T) {
160 ctrl := newOwnedTestController(t, control.Options{})
161 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
162 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
163 m = m0.(chatTUI)
164 // Place "hello!!" and put caret after "hello" (rune index 5).
165 m.input.SetValue("hello!!")
166 m.setComposerCursor(5)
167 // Force layout cache used by inputCursorByteOffset.
168 _ = m.composerRows()
169 got := m.inputCursorByteOffset()
170 if got != 5 {
171 t.Fatalf("inputCursorByteOffset = %d, want 5 (prompt gutter must not add 2)", got)
172 }
173 }
174
175 func TestShiftTabAndBacktabBothAccepted(t *testing.T) {
176 ctrl := newOwnedTestController(t, control.Options{})
177 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
178 m0, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
179 m = m0.(chatTUI)
180 if m.ctrl == nil {
181 t.Fatal("expected controller")
182 }
183
184 // Production uses modeToggleKey for both encodings before the key switch.
185 for _, key := range []string{"shift+tab", "backtab"} {
186 if !modeToggleKey(key) {
187 t.Fatalf("modeToggleKey(%q) = false, want true", key)
188 }
189 }
190 if modeToggleKey("tab") || modeToggleKey("shift+enter") {
191 t.Fatal("modeToggleKey must not accept unrelated keys")
192 }
193
194 // Platform form: KeyTab+ModShift (typically String() == "shift+tab").
195 msg := tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}
196 s := msg.String()
197 if !modeToggleKey(s) {
198 t.Fatalf("KeyTab+ModShift String() = %q is not a mode-toggle key", s)
199 }
200 before := m.ctrl.ToolApprovalMode()
201 out, _ := m.Update(msg)
202 m = out.(chatTUI)
203 if m.ctrl.ToolApprovalMode() == before && !m.planMode {
204 t.Fatalf("%q did not cycle mode via Update", s)
205 }
206
207 // Explicit CSI-Z / legacy "backtab" text encoding through the production
208 // Update path (Key.String returns Text when non-empty).
209 before = m.ctrl.ToolApprovalMode()
210 planBefore := m.planMode
211 out, _ = m.Update(tea.KeyPressMsg{Text: "backtab"})
212 m = out.(chatTUI)
213 if m.ctrl.ToolApprovalMode() == before && m.planMode == planBefore {
214 t.Fatal(`Update(Text:"backtab") did not cycle mode — production path must honor modeToggleKey("backtab")`)
215 }
216 }
217
218 // BenchmarkSlashCompletionKeystroke measures filter+menu update with a large
219 // catalog (1000 skills). Catalog is warmed once; per-op cost must stay low and
220 // allocation-stable (no fingerprint rebuild).
221 func BenchmarkSlashCompletionKeystroke(b *testing.B) {
222 ctrl := newOwnedTestController(b, control.Options{})
223 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
224 m.skills = make([]skill.Skill, 0, 1000)
225 for i := range 1000 {
226 m.skills = append(m.skills, skill.Skill{
227 Name: fmt.Sprintf("bench-skill-%04d", i),
228 Description: "benchmark skill description " + strings.Repeat("x", 80),
229 })
230 }
231 _ = m.slashItems() // warm catalog once
232 b.ReportAllocs()
233 b.ResetTimer()
234 for range b.N {
235 m.input.SetValue("/be")
236 m.updateCompletion()
237 if !m.completion.active {
238 b.Fatal("expected completion menu")
239 }
240 }
241 b.StopTimer()
242 if b.N > 0 {
243 // Informational soft gate; CI machines vary.
244 _ = time.Millisecond
245 }
246 }
247
248 func TestSlashArgDataSnapshotsAcrossKeystrokes(t *testing.T) {
249 isolateUserConfig(t)
250 ctrl := newOwnedTestController(t, control.Options{})
251 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
252 m.skills = []skill.Skill{{Name: "warm", Description: "warm"}}
253 m.modelRef = "prov/model"
254
255 first := m.slashArgDataSnapshot()
256 if len(first.Skills) != 1 || first.Skills[0].Name != "warm" {
257 t.Fatalf("arg data snapshot lost skills: %+v", first)
258 }
259 // Between keystrokes of one popup the snapshot must be served from cache:
260 // mutating the model's own lists stays invisible until a rebuild trigger.
261 m.skills = []skill.Skill{{Name: "changed"}}
262 second := m.slashArgDataSnapshot()
263 if second.Skills[0].Name != "warm" {
264 t.Fatal("arg data rebuilt between keystrokes of the same popup")
265 }
266
267 m.modelRef = "prov/other"
268 third := m.slashArgDataSnapshot()
269 if third.Skills[0].Name != "changed" {
270 t.Fatal("model switch must rebuild the arg data snapshot")
271 }
272 if third.CurrentModel != "prov/other" || third.CurrentProvider != "prov" {
273 t.Fatalf("rebuilt arg data kept stale model identity: %+v", third)
274 }
275
276 m.skills = []skill.Skill{{Name: "again"}}
277 m.invalidateSlashCatalog()
278 fourth := m.slashArgDataSnapshot()
279 if fourth.Skills[0].Name != "again" {
280 t.Fatal("invalidateSlashCatalog must rebuild the arg data snapshot")
281 }
282 }
283
284 func TestSlashArgDataRebuildsWhenPopupReopens(t *testing.T) {
285 isolateUserConfig(t)
286 store := memory.Store{Dir: t.TempDir()}
287 if _, err := store.Save(memory.Memory{Name: "warm", Title: "Warm", Body: "first", Type: memory.TypeProject}); err != nil {
288 t.Fatal(err)
289 }
290 ctrl := newOwnedTestController(t, control.Options{Memory: &memory.Set{Store: store}})
291 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
292 m.input.SetValue("/memory revisions war")
293 m.updateCompletion()
294 if !m.completion.active || m.completion.kind != compSlashArg {
295 t.Fatal("expected first memory argument popup")
296 }
297
298 // Closing the popup ends its snapshot generation. A later popup must see
299 // memory saved while no popup was open.
300 m.dismissCompletion()
301 if _, err := store.Save(memory.Memory{Name: "changed", Title: "Changed", Body: "second", Type: memory.TypeProject}); err != nil {
302 t.Fatal(err)
303 }
304 m.input.SetValue("/memory revisions chang")
305 m.updateCompletion()
306 if !m.completion.active || m.completion.kind != compSlashArg || m.completion.items[0].label != "changed" {
307 t.Fatalf("reopened popup did not refresh memory refs: %+v", m.completion)
308 }
309 }
310
311 func BenchmarkSlashArgCompletionKeystroke(b *testing.B) {
312 root := b.TempDir()
313 b.Setenv("HOME", root)
314 b.Setenv("REASONIX_CREDENTIALS_STORE", "file")
315 b.Setenv("XDG_CONFIG_HOME", root+"/config")
316 b.Chdir(root)
317 ctrl := newOwnedTestController(b, control.Options{})
318 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
319 m.modelRef = "prov/model"
320 m.skills = make([]skill.Skill, 0, 1000)
321 for i := range 1000 {
322 m.skills = append(m.skills, skill.Skill{
323 Name: fmt.Sprintf("bench-skill-%04d", i),
324 Description: "benchmark skill description " + strings.Repeat("x", 80),
325 })
326 }
327 _ = m.slashItems()
328 m.input.SetValue("/language ")
329 m.updateCompletion() // open the popup and warm its snapshot
330 b.ReportAllocs()
331 b.ResetTimer()
332 for range b.N {
333 m.input.SetValue("/language e")
334 m.updateCompletion()
335 if !m.completion.active {
336 b.Fatal("expected completion menu")
337 }
338 }
339 }
340
341 func BenchmarkSlashEffortArgCompletionKeystroke(b *testing.B) {
342 root := b.TempDir()
343 b.Setenv("HOME", root)
344 b.Setenv("REASONIX_CREDENTIALS_STORE", "file")
345 b.Setenv("XDG_CONFIG_HOME", root+"/config")
346 b.Chdir(root)
347 ctrl := newOwnedTestController(b, control.Options{})
348 m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
349 m.modelRef = "deepseek-flash/deepseek-v4-flash"
350 m.input.SetValue("/effort ")
351 m.updateCompletion() // resolve config once for this popup generation
352 if !m.completion.active {
353 b.Fatal("expected initial effort completion menu")
354 }
355 b.ReportAllocs()
356 b.ResetTimer()
357 for range b.N {
358 m.input.SetValue("/effort h")
359 m.updateCompletion()
360 if !m.completion.active {
361 b.Fatal("expected effort completion menu")
362 }
363 }
364 }
365
365 lines GO