返回 DeepSeek-Reasonix
transcript_test.go
根目录 / internal / cli / transcript_test.go
1 package cli
2
3 import (
4 "errors"
5 "strings"
6 "testing"
7
8 "github.com/charmbracelet/colorprofile"
9
10 "github.com/charmbracelet/x/ansi"
11
12 "reasonix/internal/provider"
13 )
14
15 func TestAssistantMarkdownHasIdentityAndIndentedBody(t *testing.T) {
16 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
17 activeColorProfile = colorprofile.NoTTY
18 configureCLITheme("dark")
19
20 rendered := renderAssistantMarkdown("A concise answer that wraps across the available width.", 32)
21 lines := strings.Split(ansi.Strip(rendered), "\n")
22 if len(lines) < 4 {
23 t.Fatalf("assistant block should contain a header, gap, and wrapped body:\n%s", rendered)
24 }
25 if lines[0] != " ◆ Reasonix" {
26 t.Fatalf("assistant header = %q, want %q", lines[0], " ◆ Reasonix")
27 }
28 if lines[1] != "" {
29 t.Fatalf("assistant header/body separator = %q, want blank row", lines[1])
30 }
31 for i, line := range lines[2:] {
32 if line != "" && !strings.HasPrefix(line, assistantTranscriptIndent) {
33 t.Fatalf("assistant body row %d lacks the two-cell gutter: %q", i+2, line)
34 }
35 if width := visibleWidth(line); width > 32 {
36 t.Fatalf("assistant row %d width = %d, want <= 32: %q", i+2, width, line)
37 }
38 }
39 }
40
41 func TestReplaySectionsKeepAssistantIdentity(t *testing.T) {
42 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
43 activeColorProfile = colorprofile.NoTTY
44 configureCLITheme("dark")
45
46 sections := replaySectionsFor([]provider.Message{
47 {Role: provider.RoleUser, Content: "Which version?"},
48 {Role: provider.RoleAssistant, Content: "Version 1.2.3"},
49 }, 48)
50 if len(sections) != 2 {
51 t.Fatalf("replay sections = %d, want user and assistant", len(sections))
52 }
53 if plain := ansi.Strip(sections[1]); !strings.HasPrefix(plain, " ◆ Reasonix\n\n Version 1.2.3") {
54 t.Fatalf("replayed assistant answer lost its identity: %q", plain)
55 }
56 }
57
58 func TestReplaySectionsRestoreInterruptedLocalOutput(t *testing.T) {
59 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
60 activeColorProfile = colorprofile.NoTTY
61 configureCLITheme("dark")
62
63 sections := replaySectionsFor([]provider.Message{
64 {Role: provider.RoleUser, Content: "change config"},
65 {
66 Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
67 LocalOnly: true, Content: "partial answer", ReasoningContent: "checking config",
68 ToolCalls: []provider.ToolCall{{ID: "p1", Name: "write_file"}},
69 InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true},
70 },
71 }, 64)
72 plain := ansi.Strip(strings.Join(sections, ""))
73 for _, want := range []string{"change config", "checking config", "partial answer", "Write", "bounded recovery summary"} {
74 if !strings.Contains(plain, want) {
75 t.Fatalf("replayed interrupted history missing %q:\n%s", want, plain)
76 }
77 }
78 }
79
80 func TestScrollbarThumb(t *testing.T) {
81 if _, size := scrollbarThumb(10, 0, 5); size != 0 {
82 t.Errorf("content within viewport should have no thumb, got size %d", size)
83 }
84 if start, _ := scrollbarThumb(10, 0, 100); start != 0 {
85 t.Errorf("at top the thumb starts at row 0, got %d", start)
86 }
87 const h, total = 10, 100
88 if start, size := scrollbarThumb(h, total-h, total); start+size != h {
89 t.Errorf("at bottom the thumb reaches the last row: start=%d size=%d h=%d", start, size, h)
90 }
91 }
92
93 func TestEdgeScrollDir(t *testing.T) {
94 const h = 10
95 if got := edgeScrollDir(0, h); got != -1 {
96 t.Errorf("top edge dir = %d, want -1", got)
97 }
98 if got := edgeScrollDir(h-1, h); got != 1 {
99 t.Errorf("bottom edge dir = %d, want 1", got)
100 }
101 if got := edgeScrollDir(h/2, h); got != 0 {
102 t.Errorf("middle dir = %d, want 0", got)
103 }
104 }
105
106 func TestSelSpan(t *testing.T) {
107 start, end, cw := selPos{line: 1, col: 3}, selPos{line: 3, col: 5}, 20
108 for _, tc := range []struct {
109 idx int
110 wantOK bool
111 wantLo, wHi int
112 }{
113 {0, false, 0, 0}, // above
114 {1, true, 3, cw}, // first line: anchor col → right edge
115 {2, true, 0, cw}, // middle line: full width
116 {3, true, 0, 5}, // last line: left edge → head col
117 {4, false, 0, 0}, // below
118 } {
119 lo, hi, ok := selSpan(tc.idx, start, end, cw)
120 if ok != tc.wantOK || (ok && (lo != tc.wantLo || hi != tc.wHi)) {
121 t.Errorf("selSpan(%d) = (%d,%d,%v), want (%d,%d,%v)", tc.idx, lo, hi, ok, tc.wantLo, tc.wHi, tc.wantOK)
122 }
123 }
124 }
125
126 func TestSelectedTextMultiLine(t *testing.T) {
127 m := newTestChatTUI()
128 m.wrappedLines = []string{"hello world", "second line", "third row"}
129 m.sel = selection{active: true, anchor: selPos{line: 0, col: 6}, head: selPos{line: 2, col: 5}}
130
131 if got, want := m.selectedText(), "world\nsecond line\nthird"; got != want {
132 t.Errorf("selectedText() = %q, want %q", got, want)
133 }
134
135 // A zero-width selection (plain click) copies nothing.
136 m.sel = selection{active: true, anchor: selPos{line: 0, col: 3}, head: selPos{line: 0, col: 3}}
137 if got := m.selectedText(); got != "" {
138 t.Errorf("empty selection should yield no text, got %q", got)
139 }
140 }
141
142 func TestSelectedTextRestoresMathWithoutReusingRawColumns(t *testing.T) {
143 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
144 activeColorProfile = colorprofile.NoTTY
145 configureCLITheme("dark")
146
147 m := newTestChatTUI()
148 m.width = 80
149 contentWidth := transcriptContentWidth(m.width, m.nativeScrollback)
150 m.viewport.SetWidth(contentWidth)
151 source := transcriptSource{kind: transcriptSourceMarkdown, raw: `before $\alpha$ after`}
152 rendered := m.renderTranscriptSource(source, m.width)
153 m.transcript = []string{rendered}
154 m.transcriptSources = []transcriptSource{source}
155 m.wrappedLines = strings.Split(wrapTranscript(rendered, contentWidth), "\n")
156
157 lineIndex := -1
158 for i, line := range m.wrappedLines {
159 if strings.Contains(ansi.Strip(line), "before α after") {
160 lineIndex = i
161 break
162 }
163 }
164 if lineIndex < 0 {
165 t.Fatalf("rendered transcript did not contain the math line:\n%s", ansi.Strip(rendered))
166 }
167
168 plain := ansi.Strip(m.wrappedLines[lineIndex])
169 formulaByte := strings.Index(plain, "α")
170 afterByte := strings.Index(plain, "after")
171 if formulaByte < 0 || afterByte < 0 {
172 t.Fatalf("math line = %q", plain)
173 }
174 formulaCol := ansi.StringWidth(plain[:formulaByte])
175 afterCol := ansi.StringWidth(plain[:afterByte])
176
177 m.sel = selection{
178 active: true,
179 anchor: selPos{line: lineIndex, col: formulaCol},
180 head: selPos{line: lineIndex, col: formulaCol + ansi.StringWidth("α")},
181 }
182 if got, want := m.selectedText(), `$\alpha$`; got != want {
183 t.Fatalf("formula selection = %q, want %q", got, want)
184 }
185
186 m.sel = selection{
187 active: true,
188 anchor: selPos{line: lineIndex, col: afterCol},
189 head: selPos{line: lineIndex, col: afterCol + ansi.StringWidth("after")},
190 }
191 if got, want := m.selectedText(), "after"; got != want {
192 t.Fatalf("text after formula = %q, want %q", got, want)
193 }
194 }
195
196 func TestSelectedTextRestoresMathFromReplayBundle(t *testing.T) {
197 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
198 activeColorProfile = colorprofile.NoTTY
199 configureCLITheme("dark")
200
201 m := newTestChatTUI()
202 m.width = 80
203 contentWidth := transcriptContentWidth(m.width, m.nativeScrollback)
204 m.viewport.SetWidth(contentWidth)
205 source := transcriptSource{
206 kind: transcriptSourceReplayBundle,
207 history: []provider.Message{
208 {Role: provider.RoleAssistant, Content: `before $\alpha$ after`},
209 {LocalOnly: true, Content: `local $\beta$ recovery`},
210 },
211 }
212 rendered := m.renderTranscriptSource(source, m.width)
213 m.transcript = []string{rendered}
214 m.transcriptSources = []transcriptSource{source}
215 m.wrappedLines = strings.Split(wrapTranscript(rendered, contentWidth), "\n")
216
217 lineIndex := -1
218 formulaCol := -1
219 for i, line := range m.wrappedLines {
220 plain := ansi.Strip(line)
221 formulaByte := strings.Index(plain, "α")
222 if formulaByte < 0 {
223 continue
224 }
225 lineIndex = i
226 formulaCol = ansi.StringWidth(plain[:formulaByte])
227 break
228 }
229 if lineIndex < 0 {
230 t.Fatalf("rendered replay bundle did not contain the formula:\n%s", ansi.Strip(rendered))
231 }
232
233 m.sel = selection{
234 active: true,
235 anchor: selPos{line: lineIndex, col: formulaCol},
236 head: selPos{line: lineIndex, col: formulaCol + ansi.StringWidth("α")},
237 }
238 if got, want := m.selectedText(), `$\alpha$`; got != want {
239 t.Fatalf("replayed formula selection = %q, want %q", got, want)
240 }
241
242 copyLines, ok := m.copyTranscriptLines()
243 if !ok {
244 t.Fatal("copy rendition diverged from the displayed replay bundle")
245 }
246 sourcesByID := make(map[string]string)
247 for _, line := range copyLines {
248 for _, span := range line.math {
249 if source, exists := sourcesByID[span.id]; exists && source != span.source {
250 t.Fatalf("formula marker %q reused for %q and %q", span.id, source, span.source)
251 }
252 sourcesByID[span.id] = span.source
253 }
254 }
255 if len(sourcesByID) != 2 {
256 t.Fatalf("replay formula markers = %v, want two unique formulas", sourcesByID)
257 }
258 foundSources := make(map[string]bool)
259 for _, source := range sourcesByID {
260 foundSources[source] = true
261 }
262 for _, want := range []string{`$\alpha$`, `$\beta$`} {
263 if !foundSources[want] {
264 t.Fatalf("replay formula markers = %v, missing %q", sourcesByID, want)
265 }
266 }
267 }
268
269 func TestSelectedTextPreservesProseAroundMath(t *testing.T) {
270 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
271 activeColorProfile = colorprofile.NoTTY
272 configureCLITheme("dark")
273
274 m := newTestChatTUI()
275 m.width = 80
276 contentWidth := transcriptContentWidth(m.width, m.nativeScrollback)
277 m.viewport.SetWidth(contentWidth)
278 source := transcriptSource{kind: transcriptSourceMarkdown, raw: `before $\frac{1}{2}$ after`}
279 rendered := m.renderTranscriptSource(source, m.width)
280 m.transcript = []string{rendered}
281 m.transcriptSources = []transcriptSource{source}
282 m.wrappedLines = strings.Split(wrapTranscript(rendered, contentWidth), "\n")
283
284 for i, line := range m.wrappedLines {
285 plain := ansi.Strip(line)
286 startByte := strings.Index(plain, "before")
287 endByte := strings.Index(plain, " after")
288 if startByte < 0 || endByte < 0 {
289 continue
290 }
291 startCol := ansi.StringWidth(plain[:startByte])
292 endCol := ansi.StringWidth(plain[:endByte+len(" after")])
293 m.sel = selection{
294 active: true,
295 anchor: selPos{line: i, col: startCol},
296 head: selPos{line: i, col: endCol},
297 }
298 if got, want := m.selectedText(), `before $\frac{1}{2}$ after`; got != want {
299 t.Fatalf("mixed selection = %q, want %q", got, want)
300 }
301 return
302 }
303 t.Fatalf("rendered transcript did not contain the expected mixed line:\n%s", ansi.Strip(rendered))
304 }
305
306 func TestSelectedTextRestoresMathWrappedAcrossDisplayLinesOnce(t *testing.T) {
307 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
308 activeColorProfile = colorprofile.NoTTY
309 configureCLITheme("dark")
310
311 m := newTestChatTUI()
312 m.width = 10
313 contentWidth := transcriptContentWidth(m.width, m.nativeScrollback)
314 m.viewport.SetWidth(contentWidth)
315 const latex = `\alpha+\beta+\gamma+\delta+\epsilon+\zeta`
316 source := transcriptSource{kind: transcriptSourceMarkdown, raw: `$` + latex + `$`}
317 rendered := m.renderTranscriptSource(source, m.width)
318 m.transcript = []string{rendered}
319 m.transcriptSources = []transcriptSource{source}
320 m.wrappedLines = strings.Split(wrapTranscript(rendered, contentWidth), "\n")
321
322 copyLines, ok := m.copyTranscriptLines()
323 if !ok {
324 t.Fatal("copy rendition diverged from the displayed transcript")
325 }
326 firstLine, lastLine := -1, -1
327 firstCol, lastCol := 0, 0
328 for i, line := range copyLines {
329 if len(line.math) == 0 {
330 continue
331 }
332 if firstLine < 0 {
333 firstLine = i
334 firstCol = line.math[0].start
335 }
336 lastLine = i
337 lastCol = line.math[len(line.math)-1].end
338 }
339 if firstLine < 0 || lastLine <= firstLine {
340 t.Fatalf("expected formula to wrap across lines:\n%s", ansi.Strip(rendered))
341 }
342
343 m.sel = selection{
344 active: true,
345 anchor: selPos{line: firstLine, col: firstCol},
346 head: selPos{line: lastLine, col: lastCol},
347 }
348 if got, want := m.selectedText(), `$`+latex+`$`; got != want {
349 t.Fatalf("wrapped formula selection = %q, want %q", got, want)
350 }
351 }
352
353 func TestCopyToClipboard(t *testing.T) {
354 t.Setenv("SSH_CONNECTION", "")
355 t.Setenv("SSH_CLIENT", "")
356 t.Setenv("SSH_TTY", "")
357 previous := writeNativeClipboardText
358 t.Cleanup(func() { writeNativeClipboardText = previous })
359
360 var written string
361 writeNativeClipboardText = func(text string) error {
362 written = text
363 return nil
364 }
365 message := copyToClipboard("hello")()
366 got, ok := message.(clipboardCopyMsg)
367 if !ok {
368 t.Fatalf("copyToClipboard returned %T, want clipboardCopyMsg", message)
369 }
370 if written != "hello" || got.text != "hello" || got.err != nil || got.osc52 {
371 t.Fatalf("native clipboard result = %+v, written %q", got, written)
372 }
373
374 wantErr := errors.New("clipboard unavailable")
375 writeNativeClipboardText = func(string) error { return wantErr }
376 got = copyToClipboard("fallback")().(clipboardCopyMsg)
377 if !errors.Is(got.err, wantErr) || got.osc52 {
378 t.Fatalf("failed native clipboard result = %+v", got)
379 }
380
381 t.Setenv("SSH_CONNECTION", "host 22 client 1234")
382 writeNativeClipboardText = func(string) error {
383 t.Fatal("SSH copy must not write the remote host's native clipboard")
384 return nil
385 }
386 got = copyToClipboard("remote")().(clipboardCopyMsg)
387 if !got.osc52 || got.text != "remote" {
388 t.Fatalf("SSH clipboard result = %+v, want OSC 52", got)
389 }
390 }
391
391 lines GO