返回 DeepSeek-Reasonix
status_footer_test.go
根目录 / internal / cli / status_footer_test.go
1 package cli
2
3 import (
4 "strings"
5 "testing"
6
7 "github.com/charmbracelet/colorprofile"
8
9 "github.com/charmbracelet/x/ansi"
10
11 "reasonix/internal/billing"
12 "reasonix/internal/control"
13 "reasonix/internal/event"
14 "reasonix/internal/i18n"
15 "reasonix/internal/provider"
16 )
17
18 func TestTurnReceiptKeepsCompletePerTurnBreakdown(t *testing.T) {
19 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
20 defer i18n.DetectLanguage("en")
21 activeColorProfile = colorprofile.NoTTY
22 configureCLITheme("dark")
23 i18n.DetectLanguage("zh")
24
25 u := &provider.Usage{
26 PromptTokens: 13_625,
27 CompletionTokens: 392,
28 TotalTokens: 14_017,
29 CacheHitTokens: 13_184,
30 CacheMissTokens: 441,
31 ReasoningTokens: 24,
32 }
33 p := &provider.Pricing{CacheHit: .1, Input: 1, Output: 2}
34 got := renderTurnReceipt(u, p, nil)
35 for _, want := range []string{
36 "本轮", "14.0K tok", "in 13.6K", "cached 13.2K", "new 441",
37 "out 392", "reasoning 24", "¥0.0025",
38 } {
39 if !strings.Contains(got, want) {
40 t.Fatalf("turn receipt %q missing %q", got, want)
41 }
42 }
43 if strings.Contains(got, "\033[") {
44 t.Fatalf("NO_COLOR turn receipt contains escapes: %q", got)
45 }
46 }
47
48 func TestTurnReceiptShowsOccurrenceRateBand(t *testing.T) {
49 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
50 defer i18n.DetectLanguage("en")
51 activeColorProfile = colorprofile.NoTTY
52 configureCLITheme("dark")
53 i18n.DetectLanguage("zh")
54 u := &provider.Usage{PromptTokens: 1, TotalTokens: 1}
55 q := &billing.CostQuote{Original: billing.Money{Amount: "0.01", Currency: "CNY"}, CostComplete: true, RateBand: billing.RateBandOffPeak}
56 if got := ansi.Strip(renderQuotedTurnReceipt(u, q, nil)); !strings.Contains(got, "低峰") {
57 t.Fatalf("receipt = %q, want low-rate label", got)
58 }
59 q.RateBand = ""
60 if got := ansi.Strip(renderQuotedTurnReceipt(u, q, nil)); strings.Contains(got, "低峰") || strings.Contains(got, "高峰") {
61 t.Fatalf("legacy receipt invented a rate band: %q", got)
62 }
63 }
64
65 func TestSessionCostStatusAggregatesRateBandsWithoutRepricing(t *testing.T) {
66 defer i18n.DetectLanguage("en")
67 i18n.DetectLanguage("zh")
68 quote := func(amount, band string) *billing.CostQuote {
69 selected := billing.Money{Amount: amount, Currency: "CNY"}
70 return &billing.CostQuote{
71 Original: selected, Selected: &selected, Valuations: map[string]billing.Valuation{
72 "CNY": {Money: selected, Basis: billing.BasisIdentity},
73 },
74 CostComplete: true, DisplayComplete: true, Complete: true, RateBand: band,
75 }
76 }
77 var m chatTUI
78 m.addSessionCostQuote(quote("1", billing.RateBandPeak))
79 m.addSessionCostQuote(quote("2", billing.RateBandOffPeak))
80 if got := ansi.Strip(m.sessionCostStatus()); !strings.Contains(got, "¥3.0000") || !strings.Contains(got, "混合档位") {
81 t.Fatalf("session cost = %q", got)
82 }
83 m.addSessionCostQuote(quote("1", ""))
84 if got := ansi.Strip(m.sessionCostStatus()); strings.Contains(got, "混合档位") || strings.Contains(got, "高峰") || strings.Contains(got, "低峰") {
85 t.Fatalf("unknown member retained a claimed band: %q", got)
86 }
87 }
88
89 func TestTurnReceiptFallsBackToDerivedFreshTokensAndWrapsCleanly(t *testing.T) {
90 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
91 defer i18n.DetectLanguage("en")
92 activeColorProfile = colorprofile.ANSI256
93 configureCLITheme("dark")
94 i18n.DetectLanguage("en")
95
96 got := renderTurnReceipt(&provider.Usage{
97 PromptTokens: 1_200, CompletionTokens: 80, TotalTokens: 1_280, CacheHitTokens: 900,
98 }, nil, &event.CacheDiagnostics{PrefixChanged: true, PrefixChangeReasons: []string{"tools"}})
99 plain := ansi.Strip(got)
100 for _, want := range []string{"TURN", "cached 900", "new 300", "cache prefix changed: tools"} {
101 if !strings.Contains(plain, want) {
102 t.Fatalf("turn receipt %q missing %q", plain, want)
103 }
104 }
105 for i, line := range strings.Split(wrapTranscript(got, 32), "\n") {
106 if width := visibleWidth(line); width > 32 {
107 t.Fatalf("wrapped turn receipt row %d width = %d, want <= 32: %q", i, width, line)
108 }
109 }
110 }
111
112 func TestTurnReceiptIgnoresEmptyUsage(t *testing.T) {
113 if got := renderTurnReceipt(nil, nil, nil); got != "" {
114 t.Fatalf("nil usage receipt = %q, want empty", got)
115 }
116 if got := renderTurnReceipt(&provider.Usage{}, nil, nil); got != "" {
117 t.Fatalf("empty usage receipt = %q, want empty", got)
118 }
119 }
120
121 func TestTurnReceiptMarksEstimatedUsage(t *testing.T) {
122 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
123 defer i18n.DetectLanguage("en")
124 activeColorProfile = colorprofile.NoTTY
125 configureCLITheme("dark")
126 i18n.DetectLanguage("en")
127
128 got := renderTurnReceipt(&provider.Usage{TotalTokens: 1_024, Estimated: true}, nil, nil)
129 for _, want := range []string{"≈1.0K tok", "estimated"} {
130 if !strings.Contains(got, want) {
131 t.Fatalf("estimated turn receipt %q missing %q", got, want)
132 }
133 }
134 }
135
136 func TestTurnReceiptBandUsesSingleQuietBoundary(t *testing.T) {
137 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
138 activeColorProfile = colorprofile.NoTTY
139 configureCLITheme("dark")
140
141 band := renderTurnReceiptBand(" TURN 14.0K tok · in 13.6K", 48)
142 lines := strings.Split(band, "\n")
143 if len(lines) != 2 {
144 t.Fatalf("turn receipt band rows = %d, want top rule and receipt:\n%s", len(lines), band)
145 }
146 if strings.Trim(lines[0], "─ ") != "" {
147 t.Fatalf("turn receipt band boundary is not a rule:\n%s", band)
148 }
149 if got := visibleWidth(lines[0]); got != 48 {
150 t.Fatalf("receipt rule width = %d, want 48: %q", got, lines[0])
151 }
152 if !strings.Contains(lines[1], "TURN 14.0K tok") {
153 t.Fatalf("receipt body missing from quiet band:\n%s", band)
154 }
155 }
156
157 func TestTurnReceiptAdaptsContrastAcrossThemes(t *testing.T) {
158 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
159 defer i18n.DetectLanguage("en")
160 activeColorProfile = colorprofile.ANSI256
161 i18n.DetectLanguage("en")
162
163 for _, tt := range []struct {
164 mode, borderSGR, labelSGR, valueSGR string
165 }{
166 {mode: "dark", borderSGR: "\033[38;5;237m", labelSGR: "\033[38;5;248m", valueSGR: "\033[38;5;251m"},
167 {mode: "light", borderSGR: "\033[38;5;252m", labelSGR: "\033[38;5;241m", valueSGR: "\033[38;5;239m"},
168 } {
169 t.Run(tt.mode, func(t *testing.T) {
170 configureCLITheme(tt.mode)
171 receipt := renderTurnReceipt(&provider.Usage{
172 PromptTokens: 900, CompletionTokens: 100, TotalTokens: 1_000,
173 }, nil, nil)
174 band := renderTurnReceiptBand(receipt, 80)
175 for _, want := range []string{tt.borderSGR + "─", tt.labelSGR + "TURN", tt.valueSGR + "1.0K tok"} {
176 if !strings.Contains(band, want) {
177 t.Fatalf("%s receipt %q missing semantic style %q", tt.mode, band, want)
178 }
179 }
180 if strings.Count(ansi.Strip(band), "\n") != 1 {
181 t.Fatalf("%s receipt should keep one rule and one body row: %q", tt.mode, ansi.Strip(band))
182 }
183 })
184 }
185 }
186
187 func TestStatusFooterSemanticPaletteAcrossThemes(t *testing.T) {
188 t.Setenv("REASONIX_THEME", "")
189 t.Setenv("REASONIX_THEME_STYLE", "")
190 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
191 activeColorProfile = colorprofile.ANSI256
192
193 for _, tt := range []struct {
194 mode, labelSGR, valueSGR, infoSGR string
195 }{
196 {mode: "dark", labelSGR: "\033[38;5;248m", valueSGR: "\033[38;5;251m", infoSGR: "\033[38;5;80m"},
197 {mode: "light", labelSGR: "\033[38;5;241m", valueSGR: "\033[38;5;239m", infoSGR: "\033[38;5;25m"},
198 } {
199 t.Run(tt.mode, func(t *testing.T) {
200 configureCLITheme(tt.mode)
201 m := newTestChatTUI()
202 m.label = "deepseek-v4-flash"
203 m.effortLevel = "auto"
204 got := m.statusModelWorkGroup(80)
205 for _, want := range []string{
206 tt.labelSGR + "MODEL",
207 tt.infoSGR + "deepseek-v4-flash",
208 tt.labelSGR + "EFFORT",
209 tt.valueSGR + "auto",
210 } {
211 if !strings.Contains(got, want) {
212 t.Fatalf("model/work group %q missing semantic style %q", got, want)
213 }
214 }
215 primary := m.primaryStatusLine(" Auto ", false, false)
216 if !strings.Contains(primary, tt.valueSGR+i18n.M.ChatStatusIdle) ||
217 !strings.Contains(primary, tt.labelSGR+i18n.M.ChatStatusCycleHintCompact) {
218 t.Fatalf("%s interaction hints should use readable semantic contrast: %q", tt.mode, primary)
219 }
220 })
221 }
222 }
223
224 func TestStatusFooterThemesKeepIdenticalGeometry(t *testing.T) {
225 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
226
227 m := newTestChatTUI()
228 m.ctrl = newOwnedTestController(t, control.Options{})
229 m.label = "deepseek-v4-flash"
230 m.effortLevel = "max"
231 m.balance = "¥12.34"
232 m.gitStatus = gitStatus{Repo: "DeepSeek-Reasonix", Branch: "feature/theme-footer", Added: 3}
233
234 render := func(mode string, profile colorprofile.Profile) string {
235 activeColorProfile = profile
236 configureCLITheme(mode)
237 primary := m.primaryStatusLine(" Auto ", false, false)
238 return ansi.Strip(m.renderStatusBlock(primary, 132))
239 }
240 dark := render("dark", colorprofile.ANSI256)
241 light := render("light", colorprofile.ANSI256)
242 plain := render("dark", colorprofile.NoTTY)
243 if dark != light || dark != plain {
244 t.Fatalf("theme modes changed footer geometry:\ndark:\n%s\nlight:\n%s\nplain:\n%s", dark, light, plain)
245 }
246 }
247
248 func TestStatusFooterGitAndDividerAdaptToTheme(t *testing.T) {
249 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
250 activeColorProfile = colorprofile.ANSI256
251
252 for _, tt := range []struct {
253 mode, gitSGR, borderSGR string
254 }{
255 {mode: "dark", gitSGR: "\033[38;5;179m", borderSGR: "\033[38;5;237m"},
256 {mode: "light", gitSGR: "\033[38;5;136m", borderSGR: "\033[38;5;252m"},
257 } {
258 t.Run(tt.mode, func(t *testing.T) {
259 configureCLITheme(tt.mode)
260 m := newTestChatTUI()
261 m.gitStatus = gitStatus{Repo: "DeepSeek-Reasonix", Branch: "db4be5e6", Detached: true}
262 git := m.layoutGitTelemetry(80)
263 if !strings.Contains(git, tt.gitSGR+"DeepSeek-Reasonix") {
264 t.Fatalf("%s Git identity should use warm semantic colour: %q", tt.mode, git)
265 }
266 divider := statusFooterDivider(40)
267 if !strings.Contains(divider, tt.borderSGR) || visibleWidth(divider) != 40 {
268 t.Fatalf("%s divider should use border token at full width: %q", tt.mode, divider)
269 }
270 })
271 }
272 }
273
274 func TestContextFooterColorsOnlyValuesByUrgency(t *testing.T) {
275 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
276 activeColorProfile = colorprofile.ANSI256
277 configureCLITheme("dark")
278
279 normal := strings.Join(renderContextStatusGroups(10, 100, .8), " ")
280 if !strings.Contains(normal, "\033[38;5;248mCTX") || !strings.Contains(normal, "\033[38;5;251m10 (10%)") {
281 t.Fatalf("normal context should use subtle label and neutral value: %q", normal)
282 }
283
284 warning := strings.Join(renderContextStatusGroups(75, 100, .8), " ")
285 if !strings.Contains(warning, "\033[38;5;248mCOMPACT") || !strings.Contains(warning, "\033[38;5;179m5%") {
286 t.Fatalf("near-threshold context should warn only on values: %q", warning)
287 }
288
289 critical := strings.Join(renderContextStatusGroups(80, 100, .8), " ")
290 if !strings.Contains(critical, "\033[38;5;179m80 (80%)") || !strings.Contains(critical, "\033[38;5;167m0%") {
291 t.Fatalf("critical context should keep warning/danger hierarchy: %q", critical)
292 }
293 }
294
295 func TestStatusFooterNoColorKeepsSemanticLabels(t *testing.T) {
296 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
297 activeColorProfile = colorprofile.NoTTY
298 configureCLITheme("dark")
299
300 m := newTestChatTUI()
301 m.label = "deepseek-v4-flash"
302 m.effortLevel = "auto"
303 m.balance = "¥12.34"
304 block := m.renderStatusBlock(m.primaryStatusLine(" Auto ", false, false), 120)
305 if strings.Contains(block, "\033[") {
306 t.Fatalf("NO_COLOR footer contains escapes: %q", block)
307 }
308 for _, want := range []string{"MODEL deepseek-v4-flash", "EFFORT auto", "BAL ¥12.34"} {
309 if !strings.Contains(block, want) {
310 t.Fatalf("NO_COLOR footer missing %q:\n%s", want, block)
311 }
312 }
313 }
314
315 func TestStatusFooterUsesReadableLocalizedHintAndWrapsCleanly(t *testing.T) {
316 defer i18n.DetectLanguage("en")
317 for _, tt := range []struct {
318 lang, compact, session string
319 }{
320 {lang: "en", compact: "Shift+Tab read-only/workspace/YOLO/plan · Ctrl+Y YOLO", session: "MODEL deepseek-v4-flash EFFORT auto"},
321 {lang: "zh", compact: "Shift+Tab 仅可查看/工作区内修改/YOLO/计划 · Ctrl+Y YOLO", session: "模型 deepseek-v4-flash 强度 auto"},
322 {lang: "zh-TW", compact: "Shift+Tab 僅可查看/工作區內修改/YOLO/計畫 · Ctrl+Y YOLO", session: "模型 deepseek-v4-flash 強度 auto"},
323 } {
324 t.Run(tt.lang, func(t *testing.T) {
325 i18n.DetectLanguage(tt.lang)
326 m := newTestChatTUI()
327 m.ctrl = newOwnedTestController(t, control.Options{})
328 m.label = "deepseek-v4-flash"
329 m.effortLevel = "auto"
330
331 primary := m.primaryStatusLine(" Auto ", false, false)
332 block := ansi.Strip(m.renderStatusBlock(primary, 80))
333 lines := strings.Split(block, "\n")
334 if len(lines) != 2 {
335 t.Fatalf("localized footer rows = %d, want wrapped primary/session rows without an empty data band:\n%s", len(lines), block)
336 }
337 if !strings.Contains(lines[0], tt.compact) || !strings.Contains(lines[1], tt.session) {
338 t.Fatalf("localized footer did not keep readable shortcut and session groups:\n%s", block)
339 }
340 if strings.Contains(block, "WORK") || strings.Contains(block, "模式") {
341 t.Fatalf("localized footer should not show execution-mode UI:\n%s", block)
342 }
343 if strings.Contains(block, "⇧Tab") || strings.Contains(block, "^Y") {
344 t.Fatalf("localized footer fell back to symbolic shortcut notation:\n%s", block)
345 }
346 for row, line := range lines {
347 if width := visibleWidth(line); width > 80 {
348 t.Fatalf("localized footer row %d width = %d, want <= 80: %q", row, width, line)
349 }
350 }
351
352 narrow := ansi.Strip(m.renderStatusBlock(primary, 24))
353 if strings.Contains(narrow, "Shift+Tab") {
354 t.Fatalf("shortcut help should yield when readable key names cannot fit:\n%s", narrow)
355 }
356 if !strings.Contains(narrow, ansi.Strip(footerValue(i18n.M.ChatStatusIdle))) {
357 t.Fatalf("narrow footer should preserve the idle state:\n%s", narrow)
358 }
359 })
360 }
361 }
362
363 func TestStatusFooterLocalizesMetricLabelsAndKeepsNarrowRows(t *testing.T) {
364 defer i18n.DetectLanguage("en")
365 for _, tt := range []struct {
366 lang string
367 session string
368 telemetry []string
369 }{
370 {
371 lang: "zh",
372 session: "模型 deepseek-v4-flash 强度 auto",
373 telemetry: []string{"缓存", "上下文", "压缩", "任务", "余额"},
374 },
375 {
376 lang: "zh-TW",
377 session: "模型 deepseek-v4-flash 強度 auto",
378 telemetry: []string{"快取", "上下文", "壓縮", "任務", "餘額"},
379 },
380 } {
381 t.Run(tt.lang, func(t *testing.T) {
382 i18n.DetectLanguage(tt.lang)
383 m := newTestChatTUI()
384 m.label = "deepseek-v4-flash"
385 m.effortLevel = "auto"
386 if got := ansi.Strip(m.statusModelWorkGroup(80)); got != tt.session {
387 t.Fatalf("localized session metrics = %q, want %q", got, tt.session)
388 }
389
390 groups := []string{
391 footerMetric(i18n.M.ChatStatusCacheLabel, footerValue("90%")),
392 }
393 groups = append(groups, renderContextStatusGroups(75, 100, .8)...)
394 groups = append(groups,
395 footerMetric(i18n.M.ChatStatusJobsLabel, footerInfo("2")),
396 footerMetric(i18n.M.ChatStatusBalanceLabel, footerValue("¥12.34")),
397 )
398 packed := ansi.Strip(packStatusGroups(groups, 22))
399 for _, label := range tt.telemetry {
400 if !strings.Contains(packed, label+" ") {
401 t.Fatalf("localized telemetry missing %q:\n%s", label, packed)
402 }
403 }
404 for row, line := range strings.Split(packed, "\n") {
405 if width := visibleWidth(line); width > 22 {
406 t.Fatalf("localized telemetry row %d width = %d, want <= 22: %q", row, width, line)
407 }
408 }
409 })
410 }
411 }
412
413 func TestStatusFooterSwapsModelAndGitGroups(t *testing.T) {
414 i18n.DetectLanguage("en")
415
416 m := newTestChatTUI()
417 m.ctrl = newOwnedTestController(t, control.Options{})
418 m.label = "deepseek-v4-flash"
419 m.effortLevel = "auto"
420 m.balance = "¥12.34"
421 m.gitStatus = gitStatus{
422 Repo: "DeepSeek-Reasonix",
423 Branch: "feature/responsive-footer",
424 Added: 1199,
425 Removed: 244,
426 Untracked: 3,
427 }
428
429 primary := m.primaryStatusLine(" Auto ", false, false)
430 lines := strings.Split(ansi.Strip(m.renderStatusBlock(primary, 160)), "\n")
431 if len(lines) != 3 {
432 t.Fatalf("wide status block lines = %d, want two data rows plus divider:\n%s", len(lines), strings.Join(lines, "\n"))
433 }
434 if !strings.Contains(lines[0], "MODEL deepseek-v4-flash EFFORT auto") {
435 t.Fatalf("first row should keep model and effort in one session group:\n%s", strings.Join(lines, "\n"))
436 }
437 if strings.Contains(lines[0], "DeepSeek-Reasonix@") {
438 t.Fatalf("first row should not contain Git identity:\n%s", strings.Join(lines, "\n"))
439 }
440 if strings.Trim(lines[1], "─ ") != "" {
441 t.Fatalf("middle row should be a divider:\n%s", strings.Join(lines, "\n"))
442 }
443 if !strings.Contains(lines[2], "DeepSeek-Reasonix@feature/responsive-footer") || strings.Contains(lines[2], "…") {
444 t.Fatalf("second row should preserve the full Git identity when it fits:\n%s", strings.Join(lines, "\n"))
445 }
446 if !strings.Contains(lines[2], "+1199 -244 ?3") || !strings.HasSuffix(lines[2], "BAL ¥12.34") {
447 t.Fatalf("second row should preserve Git changes and right-anchor telemetry:\n%s", strings.Join(lines, "\n"))
448 }
449 for i, line := range lines {
450 if got := visibleWidth(line); got > 160 {
451 t.Fatalf("row %d width = %d, want <= 160: %q", i, got, line)
452 }
453 }
454 }
455
456 func TestStatusFooterWithoutGitLeftAlignsTelemetry(t *testing.T) {
457 defer i18n.DetectLanguage("en")
458 i18n.DetectLanguage("en")
459
460 m := newTestChatTUI()
461 m.balance = "¥12.34"
462 line := ansi.Strip(m.layoutGitTelemetry(120))
463 if !strings.HasPrefix(line, statusFooterIndent+"BAL ¥12.34") {
464 t.Fatalf("non-Git telemetry should be left aligned, got %q", line)
465 }
466 if visibleWidth(line) >= 120 {
467 t.Fatalf("non-Git telemetry unexpectedly retained right-alignment padding: %q", line)
468 }
469 }
470
471 func TestStatusFooterOmitsEmptyDataBand(t *testing.T) {
472 m := newTestChatTUI()
473 primary := " Auto · ready"
474 block := ansi.Strip(m.renderStatusBlock(primary, 120))
475 if block != primary {
476 t.Fatalf("empty Git/telemetry status block = %q, want only %q", block, primary)
477 }
478 if strings.Contains(block, "─") {
479 t.Fatalf("empty Git/telemetry status block retained a divider: %q", block)
480 }
481 }
482
483 func TestStatusFooterMediumLayoutLeftAlignsModelWork(t *testing.T) {
484 i18n.DetectLanguage("en")
485
486 m := newTestChatTUI()
487 m.ctrl = newOwnedTestController(t, control.Options{})
488 m.label = "deepseek-v4-flash"
489 m.effortLevel = "auto"
490
491 primary := m.primaryStatusLine(" Auto ", false, false)
492 lines := strings.Split(ansi.Strip(m.renderStatusBlock(primary, 82)), "\n")
493 if len(lines) != 2 {
494 t.Fatalf("medium footer rows = %d, want primary plus model/effort without an empty data band:\n%s", len(lines), strings.Join(lines, "\n"))
495 }
496 modelRow := lines[1]
497 if !strings.HasPrefix(modelRow, statusFooterIndent+"MODEL deepseek-v4-flash") ||
498 !strings.Contains(modelRow, "EFFORT auto") {
499 t.Fatalf("medium model/effort row should be left aligned, got %q:\n%s", modelRow, strings.Join(lines, "\n"))
500 }
501 if strings.Count(strings.TrimLeft(modelRow, " "), "MODEL") != 1 {
502 t.Fatalf("medium model/work row should remain a single semantic group: %q", modelRow)
503 }
504 }
505
506 func TestStatusFooterStacksGitAndTelemetryWithoutFloatingContinuation(t *testing.T) {
507 i18n.DetectLanguage("en")
508
509 m := newTestChatTUI()
510 m.gitStatus = gitStatus{
511 Repo: "DeepSeek-Reasonix", Branch: "feature/responsive-footer", Added: 20, Removed: 4,
512 }
513 m.balance = "¥123.45"
514
515 lines := strings.Split(ansi.Strip(m.layoutGitTelemetry(56)), "\n")
516 if len(lines) != 2 {
517 t.Fatalf("stacked Git/telemetry rows = %d, want 2:\n%s", len(lines), strings.Join(lines, "\n"))
518 }
519 if !strings.HasPrefix(lines[0], statusFooterIndent+"DeepSeek-Reasonix@") || !strings.Contains(lines[0], "+20 -4") {
520 t.Fatalf("Git should own the complete first row:\n%s", strings.Join(lines, "\n"))
521 }
522 if !strings.HasPrefix(lines[1], statusFooterIndent+"BAL ¥123.45") {
523 t.Fatalf("stacked telemetry should be left aligned, got %q", lines[1])
524 }
525 }
526
527 func TestStatusFooterNarrowLayoutBreaksBetweenGroups(t *testing.T) {
528 i18n.DetectLanguage("en")
529
530 m := newTestChatTUI()
531 m.ctrl = newOwnedTestController(t, control.Options{})
532 m.label = "provider/" + strings.Repeat("long-model-", 8)
533 m.balance = "¥123.45"
534 m.gitStatus = gitStatus{
535 Repo: "DeepSeek-Reasonix-Workspace",
536 Branch: "feature/" + strings.Repeat("long-branch-", 8),
537 Added: 20,
538 Removed: 4,
539 }
540
541 primary := m.primaryStatusLine(" Auto ", false, false)
542 block := ansi.Strip(m.renderStatusBlock(primary, 40))
543 lines := strings.Split(block, "\n")
544 if len(lines) <= 2 {
545 t.Fatalf("narrow status block lines = %d, want semantic wrapping:\n%s", len(lines), block)
546 }
547 for i, line := range lines {
548 if got := visibleWidth(line); got > 40 {
549 t.Fatalf("row %d width = %d, want <= 40: %q", i, got, line)
550 }
551 }
552 if !strings.Contains(block, "@") || !strings.Contains(block, "+20 -4") || !strings.Contains(block, "¥123.45") {
553 t.Fatalf("narrow layout dropped required information:\n%s", block)
554 }
555 }
556
557 func TestStatusFooterCustomLineStillReplacesBuiltInData(t *testing.T) {
558 i18n.DetectLanguage("en")
559
560 m := newTestChatTUI()
561 m.ctrl = newOwnedTestController(t, control.Options{})
562 m.label = "deepseek-v4-flash"
563 m.balance = "¥12.34"
564 m.statuslineCmd = "custom-status"
565 m.statuslineOut = "custom telemetry"
566 m.gitStatus = gitStatus{Repo: "Reasonix", Branch: "main"}
567
568 primary := m.primaryStatusLine(" Auto ", false, false)
569 block := ansi.Strip(m.renderStatusBlock(primary, 120))
570 if strings.Contains(block, "deepseek-v4-flash") || strings.Contains(block, "¥12.34") {
571 t.Fatalf("custom statusline should replace built-in data fields:\n%s", block)
572 }
573 if !strings.Contains(block, "Reasonix@main") || !strings.Contains(block, "custom telemetry") {
574 t.Fatalf("custom statusline should coexist with Git identity:\n%s", block)
575 }
576 }
577
578 func TestStatusFooterHeightCountUsesRenderedLayout(t *testing.T) {
579 i18n.DetectLanguage("en")
580
581 m := newTestChatTUI()
582 m.ctrl = newOwnedTestController(t, control.Options{})
583 m.width = 34
584 m.label = "provider/" + strings.Repeat("long-model-", 6)
585 m.gitStatus = gitStatus{Repo: "VeryLongWorkspaceName", Branch: strings.Repeat("branch/", 8)}
586 m.balance = "¥12.34"
587
588 modeTag := " " + m.modeTagText() + " "
589 primary := m.primaryStatusLine(modeTag, false, false)
590 want := strings.Count(m.renderStatusBlock(primary, m.width), "\n") + 1
591 if got := m.computeStatusLineCount(m.width); got != want {
592 t.Fatalf("computed status rows = %d, rendered rows = %d", got, want)
593 }
594 }
595
595 lines GO