render_test.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. package render
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "errors"
  6. "os"
  7. "path/filepath"
  8. "regexp"
  9. "strings"
  10. "testing"
  11. "github.com/kotoyuuko/zenmux-usage-cli/internal/api"
  12. )
  13. var ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*m`)
  14. func stripANSI(s string) string { return ansiRE.ReplaceAllString(s, "") }
  15. func loadSample(t *testing.T) *api.Response {
  16. t.Helper()
  17. data, err := os.ReadFile(filepath.Join("..", "api", "testdata", "sample.json"))
  18. if err != nil {
  19. t.Fatalf("read sample: %v", err)
  20. }
  21. var r api.Response
  22. if err := json.Unmarshal(data, &r); err != nil {
  23. t.Fatalf("parse sample: %v", err)
  24. }
  25. return &r
  26. }
  27. func TestProgressBar(t *testing.T) {
  28. cases := []struct {
  29. pct float64
  30. want string
  31. }{
  32. {0, strings.Repeat(barEmpty, barWidth)},
  33. {1, strings.Repeat(barFilled, barWidth)},
  34. {0.5, strings.Repeat(barFilled, 15) + strings.Repeat(barEmpty, 15)},
  35. {-0.1, strings.Repeat(barEmpty, barWidth)},
  36. {1.5, strings.Repeat(barFilled, barWidth)},
  37. }
  38. for _, tc := range cases {
  39. got := ProgressBar(tc.pct, barWidth)
  40. if got != tc.want {
  41. t.Errorf("ProgressBar(%v) got %q want %q", tc.pct, got, tc.want)
  42. }
  43. }
  44. }
  45. func TestBandColor(t *testing.T) {
  46. cases := []struct {
  47. pct float64
  48. want string
  49. }{
  50. {0.10, "green"},
  51. {0.59, "green"},
  52. {0.60, "yellow"},
  53. {0.85, "yellow"},
  54. {0.86, "red"},
  55. {1.00, "red"},
  56. }
  57. for _, tc := range cases {
  58. attr := BandColor(tc.pct)
  59. var got string
  60. switch attr {
  61. case 32: // FgGreen
  62. got = "green"
  63. case 33: // FgYellow
  64. got = "yellow"
  65. case 31: // FgRed
  66. got = "red"
  67. }
  68. if got != tc.want {
  69. t.Errorf("BandColor(%v) got %s want %s", tc.pct, got, tc.want)
  70. }
  71. }
  72. }
  73. func TestRenderAccount_Snapshot(t *testing.T) {
  74. resp := loadSample(t)
  75. var buf bytes.Buffer
  76. setGlobalColor(false)
  77. RenderAccount(&buf, "personal", resp, false)
  78. got := stripANSI(buf.String())
  79. wants := []string{
  80. "━━━ personal ━━━",
  81. "Ultra plan ($200/mo)",
  82. "healthy",
  83. "$0.03283/flow",
  84. "5 hour",
  85. "7 day",
  86. "7.15%",
  87. "6.73%",
  88. "57.2 / 800 flows",
  89. "416.11 / 6182 flows",
  90. "$1.88 / $26.26",
  91. "Tokens consumed (estimated USD value): $13.66",
  92. "Monthly cap: 34560 flows · $1134.33",
  93. }
  94. for _, w := range wants {
  95. if !strings.Contains(got, w) {
  96. t.Errorf("output missing %q\n---\n%s\n---", w, got)
  97. }
  98. }
  99. // The old monthly row must be gone from the bar region.
  100. barRegion := got
  101. if idx := strings.Index(got, "Tokens consumed"); idx >= 0 {
  102. barRegion = got[:idx]
  103. }
  104. for _, forbidden := range []string{"month ", " n/a", "— / ", "— /"} {
  105. if strings.Contains(barRegion, forbidden) {
  106. t.Errorf("bar region still contains %q\n---\n%s\n---", forbidden, barRegion)
  107. }
  108. }
  109. }
  110. func TestRenderAll_MultiAccountWithError(t *testing.T) {
  111. resp := loadSample(t)
  112. results := []AccountResult{
  113. {Name: "personal", Response: resp},
  114. {Name: "work", Err: errors.New("authentication rejected: HTTP 401")},
  115. }
  116. var buf bytes.Buffer
  117. RenderAll(&buf, results, false)
  118. got := stripANSI(buf.String())
  119. if !strings.Contains(got, "━━━ personal ━━━") {
  120. t.Error("missing personal header")
  121. }
  122. if !strings.Contains(got, "━━━ work ━━━") {
  123. t.Error("missing work header")
  124. }
  125. if !strings.Contains(got, "error: authentication rejected") {
  126. t.Error("missing error line for work account")
  127. }
  128. // Personal block comes before work block.
  129. pIdx := strings.Index(got, "personal")
  130. wIdx := strings.Index(got, "work")
  131. if pIdx < 0 || wIdx < 0 || pIdx >= wIdx {
  132. t.Errorf("account ordering wrong: personal@%d work@%d", pIdx, wIdx)
  133. }
  134. }
  135. func TestRenderAccount_UsesColorWhenEnabled(t *testing.T) {
  136. resp := loadSample(t)
  137. var buf bytes.Buffer
  138. setGlobalColor(true)
  139. defer setGlobalColor(false)
  140. RenderAccount(&buf, "personal", resp, true)
  141. if !ansiRE.MatchString(buf.String()) {
  142. t.Error("expected ANSI escape sequences when useColor=true")
  143. }
  144. }
  145. func TestRenderAccount_NoColorWhenDisabled(t *testing.T) {
  146. resp := loadSample(t)
  147. var buf bytes.Buffer
  148. setGlobalColor(false)
  149. RenderAccount(&buf, "personal", resp, false)
  150. if ansiRE.MatchString(buf.String()) {
  151. t.Errorf("expected no ANSI when useColor=false, got %q", buf.String())
  152. }
  153. }
  154. func TestBandColor_HighUsageRed(t *testing.T) {
  155. // Sanity: a 90% usage window should render with a red bar/percent.
  156. resp := &api.Response{
  157. Success: true,
  158. Data: api.Data{
  159. Plan: api.Plan{Tier: "pro", AmountUSD: 50},
  160. AccountStatus: "healthy",
  161. EffectiveUSDPerFlow: 0.01,
  162. Quota5Hour: api.QuotaWindow{
  163. UsagePercentage: 0.92, MaxFlows: 100, UsedFlows: 92,
  164. UsedValueUSD: 0.92, MaxValueUSD: 1.00,
  165. },
  166. Quota7Day: api.QuotaWindow{
  167. UsagePercentage: 0.70, MaxFlows: 1000, UsedFlows: 700,
  168. UsedValueUSD: 7.00, MaxValueUSD: 10.00,
  169. },
  170. QuotaMonthly: api.QuotaMonthly{MaxFlows: 4000, MaxValueUSD: 40},
  171. },
  172. }
  173. var buf bytes.Buffer
  174. setGlobalColor(true)
  175. defer setGlobalColor(false)
  176. RenderAccount(&buf, "n", resp, true)
  177. out := buf.String()
  178. // 31 = FgRed, 33 = FgYellow. Both should appear.
  179. if !strings.Contains(out, "\x1b[31m") {
  180. t.Error("expected red ANSI somewhere in output for 92% bar")
  181. }
  182. if !strings.Contains(out, "\x1b[33m") {
  183. t.Error("expected yellow ANSI somewhere in output for 70% bar")
  184. }
  185. }