1package tui
2
3import (
4 "fmt"
5 "strings"
6 "testing"
7
8 "github.com/bnema/zut/packages/provider"
9)
10
11func TestStatusBarCompact(t *testing.T) {
12 lines := StatusBar(StatusBarParams{
13 Theme: Dark, Provider: "openai-codex", Model: "gpt-6-astra", Reasoning: "medium", Cols: 120,
14 Usage: provider.Usage{InputTokens: 261_000, OutputTokens: 34_000, CacheReadTokens: 12_000_000,
15 CacheMeasuredPromptTokens: 1000, CacheMeasuredReadTokens: 979, CostUSD: 8.522},
16 Subscription: true, ContextUsed: 155_000, ContextMax: 500_000, WeeklyUsage: "weekly:69%",
17 CWD: "/tmp/x",
18 })
19 if len(lines) != 2 {
20 t.Fatalf("want stats + cwd, got %q", lines)
21 }
22 want := " gpt-6-astra:medium ↑261k ↓34k R12M C98% $8.52 weekly:69% ctx31%/500k"
23 if got := stripANSI(lines[0]); got != want {
24 t.Fatalf("status = %q, want %q", got, want)
25 }
26 if got := stripANSI(lines[1]); got != " /tmp/x" {
27 t.Fatalf("cwd = %q", got)
28 }
29}
30
31func TestStatusBarShowsCacheHitRatio(t *testing.T) {
32 lines := StatusBar(StatusBarParams{
33 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200,
34 Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000,
35 CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000},
36 ContextUsed: 45_152, ContextMax: 272_000,
37 })
38 plain := stripANSI(strings.Join(lines, "\n"))
39 if !strings.Contains(plain, "↑84k ↓1.5k R123k C59%") {
40 t.Fatalf("status bar = %q, want compact cache hit ratio", plain)
41 }
42}
43
44func TestStatusBarOmitsCacheRatioWhenCacheDetailsAreUnavailable(t *testing.T) {
45 lines := StatusBar(StatusBarParams{
46 Theme: Dark, Provider: "compatible", Model: "model", Cols: 200,
47 Usage: provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000},
48 })
49 plain := stripANSI(strings.Join(lines, "\n"))
50 if strings.Contains(plain, "C") {
51 t.Fatalf("status bar = %q, want no cache ratio without measured details", plain)
52 }
53}
54
55func TestStatusBarNarrow(t *testing.T) {
56 for _, cols := range []int{24, 30, 32, 40, 64} {
57 for _, busy := range []string{"", "⠋ Working"} {
58 t.Run(fmt.Sprintf("%d/busy=%t", cols, busy != ""), func(t *testing.T) {
59 lines := StatusBar(StatusBarParams{
60 Theme: Dark, Provider: "openai-codex", Model: "gpt-5.5", Reasoning: "minimum", Cols: cols,
61 BusyPrefix: busy, CWD: "/tmp/x", FastMode: true, WeeklyUsage: "weekly:69%",
62 Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000,
63 CacheWriteTokens: 2_000, CacheMeasuredPromptTokens: 209_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
64 ContextUsed: 209_000, ContextMax: 272_000,
65 })
66 plain := stripANSI(strings.Join(lines, "\n"))
67 for _, want := range []string{"gpt-5.5:minimal", "fast mode", "↑84k", "↓1.5k", "R123k", "C59%", "W2.0k", "$0.53", "weekly:69%", "ctx77%/272k", "/tmp/x"} {
68 if !strings.Contains(plain, want) {
69 t.Fatalf("missing %q in %q", want, plain)
70 }
71 }
72 for _, line := range lines {
73 if width := visibleWidth(line); width > cols {
74 t.Fatalf("line width = %d, want <= %d: %q", width, cols, stripANSI(line))
75 }
76 }
77 if busy != "" && stripANSI(lines[0]) != " "+busy {
78 t.Fatalf("busy prefix should stay on its own row: %q", lines)
79 }
80 })
81 }
82 }
83}
84
85func TestStatusBarVeryNarrowModel(t *testing.T) {
86 for _, busy := range []string{"", "⠋"} {
87 lines := StatusBar(StatusBarParams{Theme: Dark, Model: "模型-with-long-name", Reasoning: "max", BusyPrefix: busy, Cols: 10})
88 for _, line := range lines {
89 if visibleWidth(line) > 10 {
90 t.Fatalf("model overflows narrow terminal: %q", line)
91 }
92 }
93 }
94}
95
96func TestStatusBarShowsActiveGoalAndPlan(t *testing.T) {
97 for _, cols := range []int{100, 20} {
98 lines := StatusBar(StatusBarParams{
99 Theme: Dark, Model: "gpt-test", GoalStatus: "active", PlanCurrent: 2, PlanTotal: 3,
100 CWD: "/tmp/project", Cols: cols,
101 })
102 plain := stripANSI(strings.Join(lines, "\n"))
103 if !strings.Contains(plain, "goal:active") || !strings.Contains(plain, "plan:2/3") {
104 t.Fatalf("status bar = %q, want active goal and plan", lines)
105 }
106 }
107}
108
109func TestStatusBarAlwaysTwoLines(t *testing.T) {
110 lines := StatusBar(StatusBarParams{
111 Theme: Dark, Provider: "anthropic", Model: "claude-opus-4-7", CWD: "/tmp/x",
112 Usage: provider.Usage{InputTokens: 476_000, OutputTokens: 3_400, CostUSD: 1.242},
113 Subscription: true, ContextUsed: 55_000, ContextMax: 1_000_000, Cols: 500,
114 })
115 if len(lines) != 2 || !strings.Contains(lines[0], "claude-opus-4-7") || stripANSI(lines[1]) != " /tmp/x" {
116 t.Fatalf("want model/stats followed by indented cwd: %q", lines)
117 }
118}
119
120func TestStatusBarNoCWD(t *testing.T) {
121 lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.4", Cols: 200})
122 if len(lines) != 1 {
123 t.Fatalf("empty cwd: want 1 line, got %q", lines)
124 }
125}
126
127func TestStatusBarShowsFastMode(t *testing.T) {
128 for _, cols := range []int{200, 20} {
129 lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.6-luna", FastMode: true, CWD: "/tmp/x", Cols: cols})
130 status := strings.Join(lines, "\n")
131 if !strings.Contains(stripANSI(status), "fast mode") || !strings.Contains(status, Dark.FGColor(Dark.Muted, "fast mode")) {
132 t.Fatalf("fast mode should be visible and muted: %q", lines)
133 }
134 }
135}
136
137func TestStatusBarReasoningSuffix(t *testing.T) {
138 for _, tc := range []struct{ level, want string }{
139 {"medium", "gpt-test:medium"}, {"minimum", "gpt-test:minimal"}, {"xhigh", "gpt-test:xhigh"},
140 {"", "gpt-test"}, {"off", "gpt-test"},
141 } {
142 lines := StatusBar(StatusBarParams{Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Reasoning: tc.level, Cols: 200})
143 if got := stripANSI(lines[0]); got != " "+tc.want {
144 t.Fatalf("reasoning %q: got %q, want %q", tc.level, got, tc.want)
145 }
146 }
147}
148
149func TestStatusLabelValueOmitsEmptyStyledSegments(t *testing.T) {
150 want := Dark.FGColor(Dark.Muted, "goal:") + Dark.FGColor(Dark.FG, "paused")
151 if got := statusLabelValue(Dark, "goal:", "paused", Dark.FG); got != want {
152 t.Fatalf("status label/value = %q, want %q", got, want)
153 }
154}
155
156func TestStatusBarHighlightsValuesForDarkAndLightThemes(t *testing.T) {
157 for _, th := range []Theme{Dark, Light} {
158 lines := StatusBar(StatusBarParams{
159 Theme: th, Model: "gpt-5.6-sol", Reasoning: "low", WeeklyUsage: "weekly:16%",
160 GoalStatus: "paused", PlanCurrent: 2, PlanTotal: 3, ContextUsed: 33, ContextMax: 100, Cols: 200,
161 })
162 status := strings.Join(lines, "\n")
163 for _, want := range []string{
164 statusLabelValue(th, ":", "low", th.FG),
165 statusLabelValue(th, "weekly:", "16%", th.FG),
166 statusLabelValue(th, "goal:", "paused", th.FG),
167 statusLabelValue(th, "plan:", "2/3", th.FG),
168 statusLabelValueSuffix(th, "ctx", "33%", "/100", th.FG),
169 } {
170 if !strings.Contains(status, want) {
171 t.Fatalf("status bar omitted highlighted value %q: %q", want, status)
172 }
173 }
174 }
175}
176
177func TestStatusBarPreservesColonBearingModelWhenNarrow(t *testing.T) {
178 th := Dark
179 th.ThinkingMax = Color256(201)
180 lines := StatusBar(StatusBarParams{Theme: th, Model: "anthropic.claude:0", Reasoning: "max", WeeklyUsage: "weekly:16%", Cols: 25})
181 status := strings.Join(lines, "\n")
182 if !strings.Contains(status, th.FGColor(th.Muted, "anthropic.claude:0")) ||
183 !strings.Contains(status, statusLabelValue(th, ":", "max", th.ThinkingMax)) {
184 t.Fatalf("colon-bearing model or reasoning styling was lost: %q", lines)
185 }
186}
187
188func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {
189 th := Dark
190 th.ThinkingMax = Color256(201)
191 for _, cols := range []int{200, 25} {
192 lines := StatusBar(StatusBarParams{Theme: th, Model: "gpt-5.6-sol", Reasoning: "max", Cols: cols, WeeklyUsage: "weekly:0%"})
193 if !strings.Contains(lines[0], "\x1b[38;5;201m") || !strings.Contains(stripANSI(lines[0]), "gpt-5.6-sol:max") {
194 t.Fatalf("max reasoning style missing: %q", lines)
195 }
196 }
197}
198
199func TestStatusBarContextWarningsAndCompaction(t *testing.T) {
200 for _, tc := range []struct {
201 used int
202 color TerminalColor
203 }{{75, Dark.Warning}, {95, Dark.Error}} {
204 lines := StatusBar(StatusBarParams{Theme: Dark, Model: "model", ContextUsed: tc.used, ContextMax: 100, AutoCompacting: true, Cols: 200})
205 want := statusLabelValueSuffix(Dark, "ctx", fmt.Sprintf("%d%%", tc.used), "/100 (auto)", tc.color)
206 if !strings.Contains(lines[0], want) {
207 t.Fatalf("context warning missing: %q", lines)
208 }
209 }
210}
211
212func TestStatusBarNoYoloTagPrecedesCWD(t *testing.T) {
213 lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.5", CWD: "/tmp/x", NoYolo: true, Cols: 200})
214 if len(lines) != 2 || !strings.Contains(stripANSI(lines[1]), "yolo mode disabled - /tmp/x") {
215 t.Fatalf("cwd line should include no-yolo tag: %q", lines)
216 }
217}