render_test.go 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  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. "month",
  87. "7.15%",
  88. "6.73%",
  89. " n/a",
  90. "57.2 / 800 flows",
  91. "416.11 / 6182 flows",
  92. "— / 34560 flows",
  93. "$1.88 / $26.26",
  94. "— / $1134.33",
  95. "Tokens consumed (estimated USD value): $13.66",
  96. }
  97. for _, w := range wants {
  98. if !strings.Contains(got, w) {
  99. t.Errorf("output missing %q\n---\n%s\n---", w, got)
  100. }
  101. }
  102. }
  103. func TestRenderAll_MultiAccountWithError(t *testing.T) {
  104. resp := loadSample(t)
  105. results := []AccountResult{
  106. {Name: "personal", Response: resp},
  107. {Name: "work", Err: errors.New("authentication rejected: HTTP 401")},
  108. }
  109. var buf bytes.Buffer
  110. RenderAll(&buf, results, false)
  111. got := stripANSI(buf.String())
  112. if !strings.Contains(got, "━━━ personal ━━━") {
  113. t.Error("missing personal header")
  114. }
  115. if !strings.Contains(got, "━━━ work ━━━") {
  116. t.Error("missing work header")
  117. }
  118. if !strings.Contains(got, "error: authentication rejected") {
  119. t.Error("missing error line for work account")
  120. }
  121. // Personal block comes before work block.
  122. pIdx := strings.Index(got, "personal")
  123. wIdx := strings.Index(got, "work")
  124. if pIdx < 0 || wIdx < 0 || pIdx >= wIdx {
  125. t.Errorf("account ordering wrong: personal@%d work@%d", pIdx, wIdx)
  126. }
  127. }
  128. func TestRenderAccount_UsesColorWhenEnabled(t *testing.T) {
  129. resp := loadSample(t)
  130. var buf bytes.Buffer
  131. setGlobalColor(true)
  132. defer setGlobalColor(false)
  133. RenderAccount(&buf, "personal", resp, true)
  134. if !ansiRE.MatchString(buf.String()) {
  135. t.Error("expected ANSI escape sequences when useColor=true")
  136. }
  137. }
  138. func TestRenderAccount_NoColorWhenDisabled(t *testing.T) {
  139. resp := loadSample(t)
  140. var buf bytes.Buffer
  141. setGlobalColor(false)
  142. RenderAccount(&buf, "personal", resp, false)
  143. if ansiRE.MatchString(buf.String()) {
  144. t.Errorf("expected no ANSI when useColor=false, got %q", buf.String())
  145. }
  146. }
  147. func TestBandColor_HighUsageRed(t *testing.T) {
  148. // Sanity: a 90% usage window should render with a red bar/percent.
  149. resp := &api.Response{
  150. Success: true,
  151. Data: api.Data{
  152. Plan: api.Plan{Tier: "pro", AmountUSD: 50},
  153. AccountStatus: "healthy",
  154. EffectiveUSDPerFlow: 0.01,
  155. Quota5Hour: api.QuotaWindow{
  156. UsagePercentage: 0.92, MaxFlows: 100, UsedFlows: 92,
  157. UsedValueUSD: 0.92, MaxValueUSD: 1.00,
  158. },
  159. Quota7Day: api.QuotaWindow{
  160. UsagePercentage: 0.70, MaxFlows: 1000, UsedFlows: 700,
  161. UsedValueUSD: 7.00, MaxValueUSD: 10.00,
  162. },
  163. QuotaMonthly: api.QuotaMonthly{MaxFlows: 4000, MaxValueUSD: 40},
  164. },
  165. }
  166. var buf bytes.Buffer
  167. setGlobalColor(true)
  168. defer setGlobalColor(false)
  169. RenderAccount(&buf, "n", resp, true)
  170. out := buf.String()
  171. // 31 = FgRed, 33 = FgYellow. Both should appear.
  172. if !strings.Contains(out, "\x1b[31m") {
  173. t.Error("expected red ANSI somewhere in output for 92% bar")
  174. }
  175. if !strings.Contains(out, "\x1b[33m") {
  176. t.Error("expected yellow ANSI somewhere in output for 70% bar")
  177. }
  178. }