okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
8e3e173
main
1// Quota screen unit tests verify boundary durations, stale/failed standing 2// classifications, bar fill and heat styling thresholds, sparkline column 3// gaps, trend pace projection, and asynchronous generation-guarded state 4// transitions. These catch arithmetic, rounding, and race-ordering bugs that 5// the daemon end-to-end suite cannot drive or inspect. 6package tui 7 8import ( 9"errors" 10"strings" 11"testing" 12"time" 13 14"albedo/cli/internal/daemon" 15"albedo/cli/internal/daemon/protocol" 16 17 tea"charm.land/bubbletea/v2" 18"github.com/charmbracelet/x/ansi" 19) 20 21func TestQuotaSpan (t * testing.T ) { 22cases := []struct { 23name string 24duration time.Duration 25want string 26 }{ 27 { 28name :"durations under one minute render as less than one minute" , 29duration :45 * time .Second , 30want :"<1m" , 31 }, 32 { 33name :"fifty nine minutes renders without hour component" , 34duration :59 * time .Minute , 35want :"59m" , 36 }, 37 { 38name :"exact one hour renders without minute remainder" , 39duration :time .Hour , 40want :"1h" , 41 }, 42 { 43name :"two hours and fourteen minutes renders both units" , 44duration :2 * time .Hour + 14 * time .Minute , 45want :"2h14m" , 46 }, 47 { 48name :"twenty three hours and fifty nine minutes renders below day threshold" , 49duration :23 * time .Hour + 59 * time .Minute , 50want :"23h59m" , 51 }, 52 { 53name :"twenty four hours renders as one day" , 54duration :24 * time .Hour , 55want :"1d" , 56 }, 57 { 58name :"four days and three hours renders day and hour units" , 59duration :4 * 24 * time .Hour + 3 * time .Hour , 60want :"4d3h" , 61 }, 62 { 63name :"seven days renders without remainder hours" , 64duration :7 * 24 * time .Hour , 65want :"7d" , 66 }, 67 { 68name :"negative durations clamp to zero and render as under one minute" , 69duration :- 10 * time .Minute , 70want :"<1m" , 71 }, 72 { 73name :"exact zero duration renders as under one minute" , 74duration :0 , 75want :"<1m" , 76 }, 77 { 78name :"boundary just under one minute renders as under one minute" , 79duration :time .Minute - time .Nanosecond , 80want :"<1m" , 81 }, 82 { 83name :"boundary at exactly one minute renders as one minute" , 84duration :time .Minute , 85want :"1m" , 86 }, 87 { 88name :"boundary just under one hour renders as fifty nine minutes" , 89duration :time .Hour - time .Nanosecond , 90want :"59m" , 91 }, 92 { 93name :"boundary just under twenty four hours renders as twenty three hours fifty nine minutes" , 94duration :24 * time .Hour - time .Nanosecond , 95want :"23h59m" , 96 }, 97 } 98for _ ,tc := range cases { 99t .Run (tc .name ,func (t * testing.T ) { 100got := quotaSpan (tc .duration ) 101if got != tc .want { 102t .Fatalf ("quotaSpan(%v) = %q, want %q" ,tc .duration ,got ,tc .want ) 103 } 104 }) 105 } 106} 107 108func TestQuotaStandingOf (t * testing.T ) { 109testStandingOf (t ) 110} 111 112func TestStandingOf (t * testing.T ) { 113testStandingOf (t ) 114} 115 116func testStandingOf (t * testing.T ) { 117at := quotaNow 118t .Run ("observation with error reports failed standing even when used percentage is present" ,func (t * testing.T ) { 119r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,85 ,"1 hour" ,3600 ,time .Hour ,5 * time .Minute ) 120r .Error = & protocol.SafeReason {Code :"quota_failed" ,Detail :"rejected" } 121if got := standingOf (r ,at );got != quotaFailed { 122t .Fatalf ("standingOf() = %v, want quotaFailed" ,got ) 123 } 124 }) 125 126t .Run ("observation without error and without used percentage reports unknown standing" ,func (t * testing.T ) { 127r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,0 ,"1 hour" ,3600 ,time .Hour ,5 * time .Minute ) 128r .UsedPercent = nil 129if got := standingOf (r ,at );got != quotaUnknown { 130t .Fatalf ("standingOf() = %v, want quotaUnknown" ,got ) 131 } 132 }) 133 134t .Run ("observation older than stale threshold reports old standing" ,func (t * testing.T ) { 135r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"1 hour" ,3600 ,time .Hour ,quotaStale + time .Second ) 136if got := standingOf (r ,at );got != quotaOld { 137t .Fatalf ("standingOf() = %v, want quotaOld" ,got ) 138 } 139 }) 140 141t .Run ("observation just under stale threshold with future reset reports fresh standing" ,func (t * testing.T ) { 142r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"1 hour" ,3600 ,time .Hour ,quotaStale - time .Second ) 143if got := standingOf (r ,at );got != quotaFresh { 144t .Fatalf ("standingOf() = %v, want quotaFresh" ,got ) 145 } 146 }) 147 148t .Run ("observation with reset time in the past reports old standing" ,func (t * testing.T ) { 149r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"1 hour" ,3600 ,- 5 * time .Minute ,5 * time .Minute ) 150if got := standingOf (r ,at );got != quotaOld { 151t .Fatalf ("standingOf() = %v, want quotaOld" ,got ) 152 } 153 }) 154 155t .Run ("observation with reset time exactly equal to clock boundary reports old standing" ,func (t * testing.T ) { 156r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"1 hour" ,3600 ,0 ,5 * time .Minute ) 157if got := standingOf (r ,at );got != quotaOld { 158t .Fatalf ("standingOf() = %v, want quotaOld" ,got ) 159 } 160 }) 161 162t .Run ("observation polled recently with future reset reports fresh standing" ,func (t * testing.T ) { 163r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"1 hour" ,3600 ,time .Hour ,2 * time .Minute ) 164if got := standingOf (r ,at );got != quotaFresh { 165t .Fatalf ("standingOf() = %v, want quotaFresh" ,got ) 166 } 167 }) 168 169t .Run ("observation observed exactly stale threshold ago reports fresh standing" ,func (t * testing.T ) { 170r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"1 hour" ,3600 ,time .Hour ,0 ) 171r .ObservedAt = at .Add (- quotaStale ).Format (time .RFC3339 ) 172if got := standingOf (r ,at );got != quotaFresh { 173t .Fatalf ("standingOf() = %v, want quotaFresh" ,got ) 174 } 175 }) 176 177t .Run ("observation observed exactly one nanosecond under stale threshold reports fresh standing" ,func (t * testing.T ) { 178r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"1 hour" ,3600 ,time .Hour ,0 ) 179r .ObservedAt = at .Add (- quotaStale + time .Nanosecond ).Format (time .RFC3339Nano ) 180if got := standingOf (r ,at );got != quotaFresh { 181t .Fatalf ("standingOf() = %v, want quotaFresh" ,got ) 182 } 183 }) 184 185t .Run ("observation observed exactly one nanosecond over stale threshold reports old standing" ,func (t * testing.T ) { 186r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"1 hour" ,3600 ,time .Hour ,0 ) 187r .ObservedAt = at .Add (- quotaStale - time .Nanosecond ).Format (time .RFC3339Nano ) 188if got := standingOf (r ,at );got != quotaOld { 189t .Fatalf ("standingOf() = %v, want quotaOld" ,got ) 190 } 191 }) 192 193t .Run ("tag for observation observed exactly stale threshold ago contains countdown and omits seen prefix" ,func (t * testing.T ) { 194r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"1 hour" ,3600 ,time .Hour ,0 ) 195r .ObservedAt = at .Add (- quotaStale ).Format (time .RFC3339 ) 196tag := ansi .Strip (quotaTag (r ,at )) 197if strings .Contains (tag ,"seen " ) { 198t .Fatalf ("tag %q should not contain 'seen '" ,tag ) 199 } 200if ! strings .Contains (tag ,"in " ) { 201t .Fatalf ("tag %q should contain 'in '" ,tag ) 202 } 203 }) 204 205t .Run ("tag for observation observed one nanosecond under stale threshold contains countdown and omits seen prefix" ,func (t * testing.T ) { 206r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"1 hour" ,3600 ,time .Hour ,0 ) 207r .ObservedAt = at .Add (- quotaStale + time .Nanosecond ).Format (time .RFC3339Nano ) 208tag := ansi .Strip (quotaTag (r ,at )) 209if strings .Contains (tag ,"seen " ) { 210t .Fatalf ("tag %q should not contain 'seen '" ,tag ) 211 } 212if ! strings .Contains (tag ,"in " ) { 213t .Fatalf ("tag %q should contain 'in '" ,tag ) 214 } 215 }) 216 217t .Run ("tag for observation observed one nanosecond over stale threshold contains seen prefix and omits countdown" ,func (t * testing.T ) { 218r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"1 hour" ,3600 ,time .Hour ,0 ) 219r .ObservedAt = at .Add (- quotaStale - time .Nanosecond ).Format (time .RFC3339Nano ) 220tag := ansi .Strip (quotaTag (r ,at )) 221if ! strings .Contains (tag ,"seen " ) { 222t .Fatalf ("tag %q should contain 'seen '" ,tag ) 223 } 224if strings .Contains (tag ,"in " ) { 225t .Fatalf ("tag %q should not contain 'in '" ,tag ) 226 } 227 }) 228} 229 230func TestQuotaTagAndBar (t * testing.T ) { 231at := quotaNow 232 233t .Run ("zero percent leaves all slots empty" ,func (t * testing.T ) { 234bar := quotaBar (0 ,true ) 235stripped := ansi .Strip (bar ) 236if stripped != strings .Repeat ("▱" ,quotaBarSlots ) { 237t .Fatalf ("quotaBar(0, true) = %q, want ten empty slots" ,stripped ) 238 } 239if width := ansi .StringWidth (stripped );width != quotaBarSlots { 240t .Fatalf ("bar width = %d, want %d" ,width ,quotaBarSlots ) 241 } 242 }) 243 244t .Run ("four percent fills at least one slot minimum" ,func (t * testing.T ) { 245bar := quotaBar (4 ,true ) 246stripped := ansi .Strip (bar ) 247want := "▰" + strings .Repeat ("▱" ,quotaBarSlots - 1 ) 248if stripped != want { 249t .Fatalf ("quotaBar(4, true) = %q, want %q" ,stripped ,want ) 250 } 251 }) 252 253t .Run ("seventy nine point nine percent uses normal brand styling" ,func (t * testing.T ) { 254bar := quotaBar (79.9 ,true ) 255if strings .Contains (bar ,DefaultStyles .Warning .Render ("▰" )) { 256t .Fatal ("79.9% should not have warning style" ) 257 } 258if strings .Contains (bar ,DefaultStyles .Error .Render ("▰" )) { 259t .Fatal ("79.9% should not have error style" ) 260 } 261stripped := ansi .Strip (bar ) 262if strings .Count (stripped ,"▰" )!= 8 { 263t .Fatalf ("slot fill count = %d, want 8" ,strings .Count (stripped ,"▰" )) 264 } 265 }) 266 267t .Run ("eighty percent switches bar fill to warm warning style" ,func (t * testing.T ) { 268bar := quotaBar (80.0 ,true ) 269if ! strings .Contains (bar ,DefaultStyles .Warning .Render ("▰" )) { 270t .Fatal ("80% should contain warning styled fill" ) 271 } 272if strings .Contains (bar ,DefaultStyles .Error .Render ("▰" )) { 273t .Fatal ("80% should not contain error style" ) 274 } 275stripped := ansi .Strip (bar ) 276if strings .Count (stripped ,"▰" )!= 8 { 277t .Fatalf ("slot fill count = %d, want 8" ,strings .Count (stripped ,"▰" )) 278 } 279 }) 280 281t .Run ("ninety four point nine percent stays warm warning style" ,func (t * testing.T ) { 282bar := quotaBar (94.9 ,true ) 283if ! strings .Contains (bar ,DefaultStyles .Warning .Render ("▰" )) { 284t .Fatal ("94.9% should contain warning styled fill" ) 285 } 286if strings .Contains (bar ,DefaultStyles .Error .Render ("▰" )) { 287t .Fatal ("94.9% should not contain error style" ) 288 } 289stripped := ansi .Strip (bar ) 290if strings .Count (stripped ,"▰" )!= 9 { 291t .Fatalf ("slot fill count = %d, want 9" ,strings .Count (stripped ,"▰" )) 292 } 293 }) 294 295t .Run ("ninety five percent switches bar fill to hot error style" ,func (t * testing.T ) { 296bar := quotaBar (95.0 ,true ) 297if ! strings .Contains (bar ,DefaultStyles .Error .Render ("▰" )) { 298t .Fatal ("95% should contain error styled fill" ) 299 } 300if strings .Contains (bar ,DefaultStyles .Warning .Render ("▰" )) { 301t .Fatal ("95% should not contain warning style" ) 302 } 303stripped := ansi .Strip (bar ) 304if strings .Count (stripped ,"▰" )!= 10 { 305t .Fatalf ("slot fill count = %d, want 10" ,strings .Count (stripped ,"▰" )) 306 } 307 }) 308 309t .Run ("one hundred percent fills all slots with hot error style" ,func (t * testing.T ) { 310bar := quotaBar (100.0 ,true ) 311if ! strings .Contains (bar ,DefaultStyles .Error .Render ("▰" )) { 312t .Fatal ("100% should contain error styled fill" ) 313 } 314stripped := ansi .Strip (bar ) 315if stripped != strings .Repeat ("▰" ,quotaBarSlots ) { 316t .Fatalf ("quotaBar(100, true) = %q, want all ten filled slots" ,stripped ) 317 } 318 }) 319 320t .Run ("rendered bar width is exactly ten cells across percentages and states" ,func (t * testing.T ) { 321percents := []float64 {0 ,4 ,15 ,50 ,79.9 ,80 ,94.9 ,95 ,100 } 322for _ ,p := range percents { 323for _ ,current := range []bool {true ,false } { 324bar := quotaBar (p ,current ) 325width := ansi .StringWidth (ansi .Strip (bar )) 326if width != quotaBarSlots { 327t .Fatalf ("quotaBar(%v, %v) width = %d, want %d" ,p ,current ,width ,quotaBarSlots ) 328 } 329 } 330 } 331 }) 332 333t .Run ("stale observation tag shows seen duration and omits reset countdown" ,func (t * testing.T ) { 334r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"1 hour" ,3600 ,time .Hour ,2 * time .Hour ) 335tag := quotaTag (r ,at ) 336stripped := ansi .Strip (tag ) 337if ! strings .Contains (stripped ,"seen 2h" ) { 338t .Fatalf ("stale tag %q should contain seen duration" ,stripped ) 339 } 340if strings .Contains (stripped ,"in " ) { 341t .Fatalf ("stale tag %q should not contain countdown" ,stripped ) 342 } 343 }) 344 345t .Run ("failed observation tag shows no reading text" ,func (t * testing.T ) { 346r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"1 hour" ,3600 ,time .Hour ,5 * time .Minute ) 347r .Error = & protocol.SafeReason {Code :"failed" ,Detail :"rejected" } 348tag := quotaTag (r ,at ) 349stripped := ansi .Strip (tag ) 350if stripped != "no reading" { 351t .Fatalf ("failed tag = %q, want 'no reading'" ,stripped ) 352 } 353 }) 354} 355 356func TestQuotaPace (t * testing.T ) { 357at := quotaNow 358ref := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"5 hour" ,5 * 3600 ,2 * time .Hour ,5 * time .Minute ) 359 360t .Run ("empty history produces no pace projection" ,func (t * testing.T ) { 361if pace := quotaPace (nil ,ref ,at );pace != "" { 362t .Fatalf ("pace = %q, want empty" ,pace ) 363 } 364 }) 365 366t .Run ("single point produces no pace projection" ,func (t * testing.T ) { 367points := []quotaPoint {{At :at .Add (- 10 * time .Minute ),Percent :20 }} 368if pace := quotaPace (points ,ref ,at );pace != "" { 369t .Fatalf ("pace = %q, want empty" ,pace ) 370 } 371 }) 372 373t .Run ("points spanning less than twenty minutes produce no pace projection" ,func (t * testing.T ) { 374points := []quotaPoint { 375 {At :at .Add (- 19 * time .Minute ),Percent :20 }, 376 {At :at ,Percent :30 }, 377 } 378if pace := quotaPace (points ,ref ,at );pace != "" { 379t .Fatalf ("pace = %q, want empty" ,pace ) 380 } 381 }) 382 383t .Run ("rate below half a percent an hour reports holding steady" ,func (t * testing.T ) { 384points := []quotaPoint { 385 {At :at .Add (- 60 * time .Minute ),Percent :20.0 }, 386 {At :at ,Percent :20.2 },// 0.2% in 1 hour < 0.5%/h 387 } 388if pace := quotaPace (points ,ref ,at );pace != "holding steady" { 389t .Fatalf ("pace = %q, want 'holding steady'" ,pace ) 390 } 391 }) 392 393t .Run ("climb that fills before reset projects full duration" ,func (t * testing.T ) { 394// climb from 60% to 80% over 30 minutes: rate = 20% / 0.5h = 40%/h. 395// remaining to 100% is 20%. Time until full is 20 / 40 = 0.5h = 30m. 396// resets in 2 hours -> fills before reset (30m < 2h). 397points := []quotaPoint { 398 {At :at .Add (- 30 * time .Minute ),Percent :60 }, 399 {At :at ,Percent :80 }, 400 } 401r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,80 ,"5 hour" ,5 * 3600 ,2 * time .Hour ,0 ) 402if pace := quotaPace (points ,r ,at );pace != "full in ~30m" { 403t .Fatalf ("pace = %q, want 'full in ~30m'" ,pace ) 404 } 405 }) 406 407t .Run ("climb that ends below full before reset projects ending percentage matching arithmetic" ,func (t * testing.T ) { 408// climb from 40% to 50% over 30 minutes: rate = 10% / 0.5h = 20%/h. 409// remaining to 100% is 50%, until full = 50 / 20 = 2.5h. 410// resets in 1 hour -> ends before full (2.5h >= 1h). 411// predicted end = 50 + 20 * 1.0 = 70%. 412points := []quotaPoint { 413 {At :at .Add (- 30 * time .Minute ),Percent :40 }, 414 {At :at ,Percent :50 }, 415 } 416r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"5 hour" ,5 * 3600 ,time .Hour ,0 ) 417if pace := quotaPace (points ,r ,at );pace != "ends near 70%" { 418t .Fatalf ("pace = %q, want %q" ,pace ,"ends near 70%" ) 419 } 420 }) 421 422t .Run ("window reset decrease inside lookback discards earlier points" ,func (t * testing.T ) { 423// 50m ago: 90% 424// 30m ago: 10% (reset occurred!) 425// now: 30% 426// after reset: climbs from 10% to 30% in 30 minutes = 40%/h. 427// until full = (100 - 30) / 40 = 1.75h = 1h45m. 428// resets in 3 hours -> full in ~1h45m. 429points := []quotaPoint { 430 {At :at .Add (- 50 * time .Minute ),Percent :90 }, 431 {At :at .Add (- 30 * time .Minute ),Percent :10 }, 432 {At :at ,Percent :30 }, 433 } 434r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,30 ,"5 hour" ,5 * 3600 ,3 * time .Hour ,0 ) 435if pace := quotaPace (points ,r ,at );pace != "full in ~1h45m" { 436t .Fatalf ("pace = %q, want %q" ,pace ,"full in ~1h45m" ) 437 } 438 }) 439 440t .Run ("points older than ninety minutes are ignored in lookback" ,func (t * testing.T ) { 441points := []quotaPoint { 442 {At :at .Add (- 100 * time .Minute ),Percent :10 }, 443 {At :at .Add (- 30 * time .Minute ),Percent :40 }, 444 {At :at ,Percent :50 }, 445 } 446r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"5 hour" ,5 * 3600 ,time .Hour ,0 ) 447if pace := quotaPace (points ,r ,at );pace != "ends near 70%" { 448t .Fatalf ("pace = %q, want %q" ,pace ,"ends near 70%" ) 449 } 450 }) 451 452t .Run ("pace without reset timestamp uses an hour wording" ,func (t * testing.T ) { 453points := []quotaPoint { 454 {At :at .Add (- 30 * time .Minute ),Percent :40 }, 455 {At :at ,Percent :50 }, 456 } 457r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"5 hour" ,5 * 3600 ,time .Hour ,0 ) 458r .ResetsAt = nil 459if pace := quotaPace (points ,r ,at );pace != "~20% an hour" { 460t .Fatalf ("pace = %q, want %q" ,pace ,"~20% an hour" ) 461 } 462 }) 463 464t .Run ("duplicate timestamps do not divide by zero or panic" ,func (t * testing.T ) { 465duplicates := []quotaPoint { 466 {At :at ,Percent :20 }, 467 {At :at ,Percent :20 }, 468 {At :at ,Percent :20 }, 469 } 470if pace := quotaPace (duplicates ,ref ,at );pace != "" { 471t .Fatalf ("pace = %q, want empty" ,pace ) 472 } 473 }) 474 475t .Run ("unsorted timestamps do not divide by zero or panic" ,func (t * testing.T ) { 476unsorted := []quotaPoint { 477 {At :at ,Percent :50 }, 478 {At :at .Add (- 40 * time .Minute ),Percent :20 }, 479 {At :at .Add (- 20 * time .Minute ),Percent :30 }, 480 } 481// Must complete safely without panic 482_ = quotaPace (unsorted ,ref ,at ) 483 }) 484} 485 486func TestQuotaSpark (t * testing.T ) { 487from := quotaNow .Add (- 10 * time .Hour ) 488to := quotaNow 489 490t .Run ("width of one cell renders without panic" ,func (t * testing.T ) { 491points := []quotaPoint {{At :to ,Percent :50 }} 492rows := quotaSpark (points ,from ,to ,1 ) 493if len (rows )!= 2 { 494t .Fatalf ("len(rows) = %d, want 2" ,len (rows )) 495 } 496for i ,row := range rows { 497if w := ansi .StringWidth (ansi .Strip (row ));w != 1 { 498t .Fatalf ("row %d width = %d, want 1" ,i ,w ) 499 } 500 } 501 }) 502 503t .Run ("large width renders without panic" ,func (t * testing.T ) { 504points := []quotaPoint { 505 {At :from .Add (time .Hour ),Percent :10 }, 506 {At :to ,Percent :90 }, 507 } 508rows := quotaSpark (points ,from ,to ,160 ) 509if len (rows )!= 2 { 510t .Fatalf ("len(rows) = %d, want 2" ,len (rows )) 511 } 512for i ,row := range rows { 513if w := ansi .StringWidth (ansi .Strip (row ));w != 160 { 514t .Fatalf ("row %d width = %d, want 160" ,i ,w ) 515 } 516 } 517 }) 518 519t .Run ("columns before first reading are blank" ,func (t * testing.T ) { 520// 10 columns over 10 hours: column 0 ends at from + 1h. 521// First point is at from + 3h, so column 0 and column 1 must be space ' '. 522points := []quotaPoint { 523 {At :from .Add (3 * time .Hour ),Percent :50 }, 524 {At :to ,Percent :60 }, 525 } 526rows := quotaSpark (points ,from ,to ,10 ) 527topStrip := ansi .Strip (rows [0 ]) 528bottomStrip := ansi .Strip (rows [1 ]) 529if topStrip [0 ]!= ' ' || bottomStrip [0 ]!= ' ' { 530t .Fatalf ("column 0 before first reading is not blank: top=%q, bottom=%q" ,string (topStrip [0 ]),string (bottomStrip [0 ])) 531 } 532if topStrip [1 ]!= ' ' || bottomStrip [1 ]!= ' ' { 533t .Fatalf ("column 1 before first reading is not blank: top=%q, bottom=%q" ,string (topStrip [1 ]),string (bottomStrip [1 ])) 534 } 535 }) 536 537t .Run ("columns after stale polling gap are blank" ,func (t * testing.T ) { 538// Points at from + 2h and from + 9h. 539// Column 3 ends at from + 4h (2h after reading, which exceeds quotaStale of 30m). 540points := []quotaPoint { 541 {At :from .Add (2 * time .Hour ),Percent :50 }, 542 {At :from .Add (9 * time .Hour ),Percent :80 }, 543 } 544rows := quotaSpark (points ,from ,to ,10 ) 545topRunes := []rune (ansi .Strip (rows [0 ])) 546bottomRunes := []rune (ansi .Strip (rows [1 ])) 547// Column 3 is index 3 548if topRunes [3 ]!= ' ' || bottomRunes [3 ]!= ' ' { 549t .Fatalf ("column 3 after stale gap is not blank: top=%q, bottom=%q" ,string (topRunes [3 ]),string (bottomRunes [3 ])) 550 } 551 }) 552 553t .Run ("last column height reflects sixty two percent with two row blocks" ,func (t * testing.T ) { 554// 62% maps to level 10: bottom '█', top '▂'. 555points := []quotaPoint { 556 {At :to ,Percent :62 }, 557 } 558rows := quotaSpark (points ,from ,to ,10 ) 559topRunes := []rune (ansi .Strip (rows [0 ])) 560bottomRunes := []rune (ansi .Strip (rows [1 ])) 561if topRunes [len (topRunes )- 1 ]!= '▂' { 562t .Fatalf ("last column top rune = %q, want '▂'" ,string (topRunes [len (topRunes )- 1 ])) 563 } 564if bottomRunes [len (bottomRunes )- 1 ]!= '█' { 565t .Fatalf ("last column bottom rune = %q, want '█'" ,string (bottomRunes [len (bottomRunes )- 1 ])) 566 } 567 }) 568 569t .Run ("zero percent draws faint marker rune" ,func (t * testing.T ) { 570points := []quotaPoint { 571 {At :to ,Percent :0 }, 572 } 573rows := quotaSpark (points ,from ,to ,10 ) 574topRunes := []rune (ansi .Strip (rows [0 ])) 575bottomRunes := []rune (ansi .Strip (rows [1 ])) 576if topRunes [len (topRunes )- 1 ]!= ' ' { 577t .Fatalf ("zero percent top rune = %q, want ' '" ,string (topRunes [len (topRunes )- 1 ])) 578 } 579if bottomRunes [len (bottomRunes )- 1 ]!= '▁' { 580t .Fatalf ("zero percent bottom rune = %q, want '▁'" ,string (bottomRunes [len (bottomRunes )- 1 ])) 581 } 582 }) 583 584t .Run ("sparkline output has exactly two rows with matching cell width" ,func (t * testing.T ) { 585points := []quotaPoint { 586 {At :to .Add (- time .Hour ),Percent :40 }, 587 {At :to ,Percent :75 }, 588 } 589width := 25 590rows := quotaSpark (points ,from ,to ,width ) 591if len (rows )!= 2 { 592t .Fatalf ("len(rows) = %d, want 2" ,len (rows )) 593 } 594for i ,r := range rows { 595if w := ansi .StringWidth (ansi .Strip (r ));w != width { 596t .Fatalf ("row %d width = %d, want %d" ,i ,w ,width ) 597 } 598 } 599 }) 600} 601 602func TestQuotaTrailSpan (t * testing.T ) { 603t .Run ("observation without window seconds defaults to twenty four hours" ,func (t * testing.T ) { 604r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"none" ,0 ,time .Hour ,time .Minute ) 605r .WindowSeconds = nil 606if got := quotaTrailSpan (r );got != 24 * time .Hour { 607t .Fatalf ("quotaTrailSpan() = %v, want 24h" ,got ) 608 } 609 }) 610 611t .Run ("five hour window raises to minimum twenty four hours" ,func (t * testing.T ) { 612r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"5 hour" ,5 * 3600 ,time .Hour ,time .Minute ) 613if got := quotaTrailSpan (r );got != 24 * time .Hour { 614t .Fatalf ("quotaTrailSpan() = %v, want 24h" ,got ) 615 } 616 }) 617 618t .Run ("seven day window preserves seven days" ,func (t * testing.T ) { 619r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"7 day" ,7 * 24 * 3600 ,time .Hour ,time .Minute ) 620if got := quotaTrailSpan (r );got != 7 * 24 * time .Hour { 621t .Fatalf ("quotaTrailSpan() = %v, want 7d" ,got ) 622 } 623 }) 624 625t .Run ("thirty day window caps to maximum seven days" ,func (t * testing.T ) { 626r := quotaReading ("anthropic" ,"user" ,"limit" ,"Limit" ,50 ,"30 day" ,30 * 24 * 3600 ,time .Hour ,time .Minute ) 627if got := quotaTrailSpan (r );got != 7 * 24 * time .Hour { 628t .Fatalf ("quotaTrailSpan() = %v, want 7d" ,got ) 629 } 630 }) 631} 632 633func TestQuotaModelFlow (t * testing.T ) { 634t .Run ("quota loaded message with stale generation is ignored and keeps loading" ,func (t * testing.T ) { 635m := NewQuotaModel (nil ) 636m .SetSize (120 ,30 ) 637staleMsg := quotaLoadedMsg { 638Gen :m .Generation + 42 , 639At :quotaNow , 640Readings :quotaFixture (), 641 } 642m ,_ = m .Update (staleMsg ) 643if ! m .Loading { 644t .Fatal ("loading should remain true on stale message" ) 645 } 646if len (m .readings )!= 0 { 647t .Fatalf ("readings length = %d, want 0" ,len (m .readings )) 648 } 649 }) 650 651t .Run ("quota loaded message with current generation clears loading and groups interleaved accounts" ,func (t * testing.T ) { 652m := NewQuotaModel (nil ) 653m .SetSize (120 ,30 ) 654 655// Interleave readings intentionally: Anthropic, Google, Anthropic, Google 656rA1 := quotaReading ("anthropic" ,"dawn@example.com" ,"limit_1" ,"Limit 1" ,20 ,"1h" ,3600 ,time .Hour ,time .Minute ) 657rG1 := quotaReading ("google" ,"dawn@gmail.com" ,"gemini_1" ,"Gemini 1" ,30 ,"1h" ,3600 ,time .Hour ,time .Minute ) 658rA2 := quotaReading ("anthropic" ,"dawn@example.com" ,"limit_2" ,"Limit 2" ,40 ,"1h" ,3600 ,time .Hour ,time .Minute ) 659rG2 := quotaReading ("google" ,"dawn@gmail.com" ,"gemini_2" ,"Gemini 2" ,50 ,"1h" ,3600 ,time .Hour ,time .Minute ) 660 661goodMsg := quotaLoadedMsg { 662Gen :m .Generation , 663At :quotaNow , 664Readings : []daemon.QuotaObservation {rA1 ,rG1 ,rA2 ,rG2 }, 665 } 666m ,_ = m .Update (goodMsg ) 667if m .Loading { 668t .Fatal ("loading should be false after valid load" ) 669 } 670if len (m .readings )!= 4 { 671t .Fatalf ("readings count = %d, want 4" ,len (m .readings )) 672 } 673// Anthropic accounts should be sorted together first 674if m .readings [0 ].Provider != "anthropic" || m .readings [1 ].Provider != "anthropic" { 675t .Fatalf ("first two rows should be anthropic: %s, %s" ,m .readings [0 ].Provider ,m .readings [1 ].Provider ) 676 } 677if m .readings [2 ].Provider != "google" || m .readings [3 ].Provider != "google" { 678t .Fatalf ("last two rows should be google: %s, %s" ,m .readings [2 ].Provider ,m .readings [3 ].Provider ) 679 } 680 }) 681 682t .Run ("a failed load with no rows says it could not read, not that there are no readings" ,func (t * testing.T ) { 683m := NewQuotaModel (nil ) 684m .SetSize (120 ,30 ) 685m ,_ = m .Update (quotaLoadedMsg {Gen :m .Generation ,Err :errors .New ("daemon connection unavailable" )}) 686view := ansi .Strip (m .View ()) 687if ! strings .Contains (view ,"Could not read quota" )|| strings .Contains (view ,"No quota readings yet" ) { 688t .Fatalf ("the empty list should blame the failed read:\n%s" ,view ) 689 } 690 }) 691 692t .Run ("quota loaded message with error sets error text and preserves existing rows" ,func (t * testing.T ) { 693m := quotaModel (120 ,30 ) 694initialCount := len (m .readings ) 695if initialCount == 0 { 696t .Fatal ("fixture should have initial readings" ) 697 } 698 699errMsg := quotaLoadedMsg { 700Gen :m .Generation , 701Err :errors .New ("daemon connection dropped" ), 702 } 703m ,_ = m .Update (errMsg ) 704if m .Error != "daemon connection dropped" { 705t .Fatalf ("m.Error = %q, want 'daemon connection dropped'" ,m .Error ) 706 } 707if len (m .readings )!= initialCount { 708t .Fatalf ("readings count = %d, want %d preserved" ,len (m .readings ),initialCount ) 709 } 710 }) 711 712t .Run ("refresh key while loading does nothing and keeps generation unchanged" ,func (t * testing.T ) { 713m := NewQuotaModel (nil ) 714m .SetSize (120 ,30 ) 715initialGen := m .Generation 716if ! m .Loading { 717t .Fatal ("initial model should be loading" ) 718 } 719 720m ,cmd := m .Update (tea.KeyPressMsg {Code :'r' ,Mod :tea .ModCtrl }) 721if cmd != nil { 722t .Fatal ("ctrl+r while loading should return nil cmd" ) 723 } 724if m .Generation != initialGen { 725t .Fatalf ("generation bumped from %d to %d" ,initialGen ,m .Generation ) 726 } 727 }) 728 729t .Run ("refresh key while not loading bumps generation and starts loading" ,func (t * testing.T ) { 730m := quotaModel (120 ,30 ) 731if m .Loading { 732t .Fatal ("loaded model should not be loading" ) 733 } 734initialGen := m .Generation 735 736m ,cmd := m .Update (tea.KeyPressMsg {Code :'r' ,Mod :tea .ModCtrl }) 737if ! m .Loading { 738t .Fatal ("m.Loading should be true after ctrl+r" ) 739 } 740if m .Generation <= initialGen { 741t .Fatalf ("generation %d should be greater than %d" ,m .Generation ,initialGen ) 742 } 743if cmd == nil { 744t .Fatal ("ctrl+r should return load command" ) 745 } 746 }) 747 748t .Run ("escape key emits cancel message" ,func (t * testing.T ) { 749m := quotaModel (120 ,30 ) 750m ,cmd := m .Update (tea.KeyPressMsg {Code :tea .KeyEscape }) 751if cmd == nil { 752t .Fatal ("escape should return command" ) 753 } 754msg := cmd () 755if _ ,ok := msg .(QuotaCancelMsg );! ok { 756t .Fatalf ("cmd returned %T, want QuotaCancelMsg" ,msg ) 757 } 758 }) 759 760t .Run ("ctrl c key emits cancel message" ,func (t * testing.T ) { 761m := quotaModel (120 ,30 ) 762m ,cmd := m .Update (tea.KeyPressMsg {Code :'c' ,Mod :tea .ModCtrl }) 763if cmd == nil { 764t .Fatal ("ctrl+c should return command" ) 765 } 766msg := cmd () 767if _ ,ok := msg .(QuotaCancelMsg );! ok { 768t .Fatalf ("cmd returned %T, want QuotaCancelMsg" ,msg ) 769 } 770 }) 771 772t .Run ("history message with stale generation is ignored" ,func (t * testing.T ) { 773m := quotaModel (120 ,30 ) 774r := m .readings [0 ] 775key := quotaKey (r ) 776 777staleMsg := quotaHistoryMsg { 778Gen :m .Generation + 99 , 779Key :key , 780Points :quotaTrailFixture (r ), 781 } 782m ,_ = m .Update (staleMsg ) 783if trail := m .trails [key ];len (trail .points )!= 0 || ! trail .pending { 784t .Fatalf ("a stale history reply changed the trail: %+v" ,trail ) 785 } 786 }) 787 788t .Run ("history message with current generation renders sparkline in pane" ,func (t * testing.T ) { 789m := quotaModel (120 ,30 ) 790r := m .readings [0 ] 791key := quotaKey (r ) 792 793goodMsg := quotaHistoryMsg { 794Gen :m .Generation , 795Key :key , 796Points :quotaTrailFixture (r ), 797 } 798m ,_ = m .Update (goodMsg ) 799view := ansi .Strip (m .View ()) 800if ! strings .ContainsAny (view ,"▁▂▃▄▅▆▇█" ) { 801t .Fatalf ("view should contain sparkline block glyphs:\n%s" ,view ) 802 } 803 }) 804 805t .Run ("history message with error displays history unavailable in pane" ,func (t * testing.T ) { 806m := quotaModel (120 ,30 ) 807r := m .readings [0 ] 808key := quotaKey (r ) 809 810errMsg := quotaHistoryMsg { 811Gen :m .Generation , 812Key :key , 813Err :errors .New ("timeout reading history" ), 814 } 815m ,_ = m .Update (errMsg ) 816view := ansi .Strip (m .View ()) 817if ! strings .Contains (view ,"history unavailable" ) { 818t .Fatalf ("view should contain 'history unavailable':\n%s" ,view ) 819 } 820 }) 821 822t .Run ("typing in filter narrows rows and cursor preserves selected key across reload" ,func (t * testing.T ) { 823m := quotaModel (120 ,30 ) 824totalRows := len (m .readings ) 825if totalRows < 3 { 826t .Fatal ("fixture should have at least 3 rows" ) 827 } 828 829// Type text to narrow shown rows 830for _ ,char := range "anthropic" { 831m ,_ = m .Update (tea.KeyPressMsg {Code :char ,Text :string (char )}) 832 } 833 834if len (m .shown )>= totalRows { 835t .Fatalf ("filter should narrow shown rows: shown=%d, total=%d" ,len (m .shown ),totalRows ) 836 } 837 838// Move cursor to the second matching row and record its key 839m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyDown }) 840selectedRow ,ok := m .highlighted () 841if ! ok { 842t .Fatal ("cursor should have selected row" ) 843 } 844expectedKey := selectedRow .key 845 846// Reload with new readings message under same generation 847m ,_ = m .Update (quotaLoadedMsg { 848Gen :m .Generation , 849At :quotaNow , 850Readings :quotaFixture (), 851 }) 852 853afterReload ,ok := m .highlighted () 854if ! ok || afterReload .key != expectedKey { 855t .Fatalf ("cursor lost key after reload: got %v, want %q" ,afterReload .key ,expectedKey ) 856 } 857 }) 858 859t .Run ("reload with advanced time updates countdowns across rendered view" ,func (t * testing.T ) { 860m := quotaModel (120 ,30 ) 861initialView := ansi .Strip (m .View ()) 862if ! strings .Contains (initialView ,"in 2h14m" ) { 863t .Fatalf ("initial view should contain 'in 2h14m':\n%s" ,initialView ) 864 } 865 866// Advance time by 30 minutes: 2h14m - 30m = 1h44m 867advancedAt := quotaNow .Add (30 * time .Minute ) 868m ,_ = m .Update (quotaLoadedMsg { 869Gen :m .Generation , 870At :advancedAt , 871Readings :quotaFixture (), 872 }) 873 874updatedView := ansi .Strip (m .View ()) 875if ! strings .Contains (updatedView ,"in 1h44m" ) { 876t .Fatalf ("updated view should contain 'in 1h44m':\n%s" ,updatedView ) 877 } 878if strings .Contains (updatedView ,"in 2h14m" ) { 879t .Fatalf ("updated view should not contain old countdown 'in 2h14m':\n%s" ,updatedView ) 880 } 881 }) 882 883t .Run ("detail pane for failed reading displays safe error detail without sparkline or pace" ,func (t * testing.T ) { 884m := quotaModel (120 ,30 ) 885 886// Filter directly to the broken deepseek reading 887for _ ,char := range "deepseek" { 888m ,_ = m .Update (tea.KeyPressMsg {Code :char ,Text :string (char )}) 889 } 890 891current ,ok := m .current () 892if ! ok || current .Error == nil { 893t .Fatal ("current reading should be broken deepseek observation" ) 894 } 895 896lines := m .details (current ,80 ) 897detailText := strings .Join (lines ,"\n" ) 898if ! strings .Contains (detailText ,"the provider answered 401: the API key was rejected" ) { 899t .Fatalf ("m.details should contain full error detail:\n%s" ,detailText ) 900 } 901if strings .Contains (detailText ,"pace" )|| strings .Contains (detailText ,"full in" )|| strings .Contains (detailText ,"ends near" )|| strings .Contains (detailText ,"holding steady" ) { 902t .Fatalf ("failed reading details should never contain pace:\n%s" ,detailText ) 903 } 904if strings .Contains (detailText ,"last 24h" )|| strings .Contains (detailText ,"last 7d" )|| strings .ContainsAny (detailText ,"▁▂▃▄▅▆▇█" ) { 905t .Fatalf ("failed reading details should never contain sparkline:\n%s" ,detailText ) 906 } 907 908view := ansi .Strip (m .View ()) 909if ! strings .Contains (view ,"the provider answered 401:" )|| ! strings .Contains (view ,"the API key was rejected" ) { 910t .Fatalf ("view should show error detail fragments:\n%s" ,view ) 911 } 912if strings .Contains (view ,"full in" )|| strings .Contains (view ,"ends near" )|| strings .Contains (view ,"holding steady" ) { 913t .Fatalf ("failed reading view should never show pace:\n%s" ,view ) 914 } 915if strings .Contains (view ,"last 24h" )|| strings .Contains (view ,"last 7d" ) { 916t .Fatalf ("failed reading view should never show sparkline reach header:\n%s" ,view ) 917 } 918 }) 919} 920 921// The trail of the limit under the cursor is asked for once, when the cursor 922// first reaches it: asking again on every key would hammer the daemon, and 923// never asking would leave the pane's sparkline empty. 924func TestQuotaTrailFetch (t * testing.T ) { 925t .Run ("a loaded screen asks for the highlighted limit's trail and marks it pending" ,func (t * testing.T ) { 926m := NewQuotaModel (nil ) 927m .SetSize (120 ,30 ) 928m ,cmd := m .Update (quotaLoadedMsg {Gen :m .Generation ,At :quotaNow ,Readings :quotaFixture ()}) 929if cmd == nil { 930t .Fatal ("loading readings should ask for the highlighted trail" ) 931 } 932r ,_ := m .current () 933if ! m .trails [quotaKey (r )].pending { 934t .Fatalf ("the asked-for trail should be pending: %+v" ,m .trails [quotaKey (r )]) 935 } 936 }) 937 938t .Run ("a trail already asked for is not asked for again" ,func (t * testing.T ) { 939m := quotaModel (120 ,30 ) 940if _ ,cmd := m .Update (tea.KeyPressMsg {Code :tea .KeyUp });cmd != nil {// the cursor stays on the first row 941t .Fatal ("the highlighted trail was already asked for" ) 942 } 943 }) 944 945t .Run ("moving onto another limit asks for that one" ,func (t * testing.T ) { 946m := quotaModel (120 ,30 ) 947first ,_ := m .current () 948m ,cmd := m .Update (tea.KeyPressMsg {Code :tea .KeyDown }) 949second ,_ := m .current () 950if quotaKey (first )== quotaKey (second )|| cmd == nil { 951t .Fatalf ("the cursor should have moved onto another limit and asked for it (cmd nil: %v)" ,cmd == nil ) 952 } 953 }) 954 955t .Run ("a limit whose poll failed has no past to ask for" ,func (t * testing.T ) { 956m := quotaModel (120 ,30 ) 957var failed daemon.QuotaObservation 958for range m .shown { 959if r ,_ := m .current ();r .Error != nil { 960failed = r 961break 962 } 963m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyDown }) 964 } 965if failed .Error == nil { 966t .Fatal ("the cursor never reached the failed reading" ) 967 } 968if trail ,asked := m .trails [quotaKey (failed )];asked { 969t .Fatalf ("a failed reading must not ask for a trail: %+v" ,trail ) 970 } 971 }) 972 973t .Run ("esc is a bare cancel even when the highlighted trail has not been asked for" ,func (t * testing.T ) { 974m := quotaModel (120 ,30 ) 975m .trails = map [string ]quotaTrail {}// nothing asked for yet: a fetch would ride along with any key 976for _ ,key := range []tea.KeyPressMsg {{Code :tea .KeyEscape }, {Code :'c' ,Mod :tea .ModCtrl }} { 977_ ,cmd := m .Update (key ) 978if cmd == nil { 979t .Fatalf ("%v should cancel" ,key ) 980 } 981if _ ,ok := cmd ().(QuotaCancelMsg );! ok { 982t .Fatalf ("%v returned %T, want a bare QuotaCancelMsg (a batch means a fetch rode along)" ,key ,cmd ()) 983 } 984 } 985 }) 986 987t .Run ("a reading that failed but kept a percent still has no past to ask for" ,func (t * testing.T ) { 988both := quotaReading ("hyper" ,"charm-hyper" ,"primary" ,"Primary" ,50 ,"1 hour" ,3600 ,time .Hour ,time .Minute ) 989both .Error = & protocol.SafeReason {Code :"quota_failed" ,Detail :"rejected" } 990m := NewQuotaModel (nil ) 991m .SetSize (120 ,30 ) 992if _ ,cmd := m .Update (quotaLoadedMsg {Gen :m .Generation ,At :quotaNow ,Readings : []daemon.QuotaObservation {both }});cmd != nil { 993t .Fatal ("a failed reading must not ask for a trail even when it carries a percent" ) 994 } 995 }) 996 997t .Run ("a trail is read back as far as the pane draws it" ,func (t * testing.T ) { 998m := quotaModel (120 ,30 ) 999for _ ,r := range m .readings { 1000if got ,want := quotaNow .Sub (m .trailSince (r )),quotaTrailSpan (r );got != want { 1001t .Fatalf ("%s: trail reaches back %v, the pane draws %v" ,quotaKey (r ),got ,want ) 1002 } 1003 } 1004 }) 1005 1006t .Run ("a refresh forgets the trails so the cursor's limit is asked for again" ,func (t * testing.T ) { 1007m := quotaModel (120 ,30 ) 1008r ,_ := m .current () 1009m ,_ = m .Update (quotaHistoryMsg {Gen :m .Generation ,Key :quotaKey (r ),Points :quotaTrailFixture (r )}) 1010m ,_ = m .Update (tea.KeyPressMsg {Code :'r' ,Mod :tea .ModCtrl }) 1011m ,cmd := m .Update (quotaLoadedMsg {Gen :m .Generation ,At :quotaNow ,Readings :quotaFixture ()}) 1012if cmd == nil || ! m .trails [quotaKey (r )].pending { 1013t .Fatalf ("a reload should ask for the trail again (cmd nil: %v, trail %+v)" ,cmd == nil ,m .trails [quotaKey (r )]) 1014 } 1015 }) 1016 1017t .Run ("a trail that cannot be fetched comes back as an error for the same limit" ,func (t * testing.T ) { 1018m := quotaModel (120 ,30 ) 1019r ,_ := m .current () 1020msg := m .trailCmd (r ,m .Generation )() 1021reply ,ok := msg .(quotaHistoryMsg ) 1022if ! ok || reply .Err == nil || reply .Key != quotaKey (r ) { 1023t .Fatalf ("with no connection the reply should be an error for the same key, got %#v" ,msg ) 1024 } 1025 }) 1026} 1027 1028// A trail the daemon could only partly fill must say how far back it reaches, 1029// not the span it was asked for: a busy limit fills the client's row cap 1030// before the window is covered, and a header reading "last 7d" over four days 1031// of picture is a wrong number a person would act on. 1032func TestQuotaTrailFrom (t * testing.T ) { 1033since := quotaNow .Add (- 24 * time .Hour ) 1034point := func (after time.Duration ) []quotaPoint { 1035return []quotaPoint {{At :since .Add (after ),Percent :10 }} 1036 } 1037cases := []struct { 1038name string 1039points []quotaPoint 1040want time.Time 1041 }{ 1042 {"no points keeps the bound" ,nil ,since }, 1043 {"a first reading at the bound keeps it" ,point (0 ),since }, 1044 {"a first reading just inside the stale margin keeps the bound" ,point (quotaStale ),since }, 1045 {"a first reading just past the stale margin starts the picture there" ,point (quotaStale + time .Second ),since .Add (quotaStale + time .Second )}, 1046 {"a trail that starts a day late starts there" ,point (20 * time .Hour ),since .Add (20 * time .Hour )}, 1047 } 1048for _ ,tc := range cases { 1049t .Run (tc .name ,func (t * testing.T ) { 1050if got := quotaTrailFrom (tc .points ,since );! got .Equal (tc .want ) { 1051t .Fatalf ("quotaTrailFrom = %v, want %v" ,got ,tc .want ) 1052 } 1053 }) 1054 } 1055} 1056 1057func TestQuotaPaneLabelsWhatTheTrailCovers (t * testing.T ) { 1058label := func (t * testing.T ,firstReadingAge time.Duration )string { 1059t .Helper () 1060m := quotaModel (120 ,30 ) 1061r ,_ := m .current ()// the 5 hour limit: a 24h trail 1062var points []quotaPoint 1063for at := quotaNow .Add (- firstReadingAge );! at .After (quotaNow );at = at .Add (10 * time .Minute ) { 1064points = append (points ,quotaPoint {At :at ,Percent :20 }) 1065 } 1066m ,_ = m .Update (quotaHistoryMsg {Gen :m .Generation ,Key :quotaKey (r ),Points :points }) 1067return ansi .Strip (m .View ()) 1068 } 1069t .Run ("a trail that reaches the bound says last 24h" ,func (t * testing.T ) { 1070if view := label (t ,24 * time .Hour );! strings .Contains (view ,"last 24h" )|| ! strings .Contains (view ,"24h ago" ) { 1071t .Fatalf ("a full trail should keep its round label:\n%s" ,view ) 1072 } 1073 }) 1074t .Run ("a trail that only reaches back nine hours says so on both labels" ,func (t * testing.T ) { 1075view := label (t ,9 * time .Hour ) 1076if ! strings .Contains (view ,"last 9h" )|| ! strings .Contains (view ,"9h ago" )|| strings .Contains (view ,"24h" ) { 1077t .Fatalf ("a short trail must not claim the span it was asked for:\n%s" ,view ) 1078 } 1079 }) 1080} 1081 1082// The daemon's own labels, not the fixtures': a window label of "5h window" 1083// would read "the 5h window window", and a poll that failed with no limits has 1084// no label at all, which drew an empty highlighted row. 1085func TestQuotaRowsFromTheDaemonsOwnLabels (t * testing.T ) { 1086t .Run ("a window label that already ends in window is not doubled" ,func (t * testing.T ) { 1087r := quotaReading ("anthropic" ,"acct" ,"session" ,"Session" ,24 ,"5h window" ,5 * 3600 ,2 * time .Hour ,time .Minute ) 1088m := quotaModel (120 ,30 ) 1089view := ansi .Strip (strings .Join (m .details (r ,60 ),"\n" )) 1090if ! strings .Contains (view ,"24% of the 5h window" )|| strings .Contains (view ,"window window" ) { 1091t .Fatalf ("the window should be named once:\n%s" ,view ) 1092 } 1093 }) 1094 1095t .Run ("a window label without the word gets it once" ,func (t * testing.T ) { 1096r := quotaReading ("anthropic" ,"acct" ,"weekly" ,"Weekly" ,5 ,"7 days" ,7 * 24 * 3600 ,time .Hour ,time .Minute ) 1097m := quotaModel (120 ,30 ) 1098if view := ansi .Strip (strings .Join (m .details (r ,60 ),"\n" ));! strings .Contains (view ,"of the 7 days window" ) { 1099t .Fatalf ("the window should be named:\n%s" ,view ) 1100 } 1101 }) 1102 1103names := []struct { 1104name string 1105edit func (r * daemon.QuotaObservation ) 1106want string 1107 }{ 1108 {"a label names the row" ,func (r * daemon.QuotaObservation ) {},"Primary" }, 1109 {"a failed poll with no label says what happened" ,func (r * daemon.QuotaObservation ) { 1110r .Label ,r .UsedPercent = "" ,nil 1111r .Error = & protocol.SafeReason {Code :"quota_failed" ,Detail :"HTTP 403" } 1112 },"last poll failed" }, 1113 {"an unlabelled limit is named by its id" ,func (r * daemon.QuotaObservation ) {r .Label = "" },"primary" }, 1114 {"nothing to name it by says so" ,func (r * daemon.QuotaObservation ) {r .Label ,r .LimitID = "" ,"" },"no limits reported" }, 1115 } 1116for _ ,tc := range names { 1117t .Run (tc .name ,func (t * testing.T ) { 1118r := quotaReading ("hyper" ,"charm-hyper" ,"primary" ,"Primary" ,50 ,"1 hour" ,3600 ,time .Hour ,time .Minute ) 1119tc .edit (& r ) 1120if got := quotaName (r );got != tc .want { 1121t .Fatalf ("quotaName = %q, want %q" ,got ,tc .want ) 1122 } 1123 }) 1124 } 1125 1126t .Run ("the highlighted row of a failed account is not blank" ,func (t * testing.T ) { 1127failed := daemon.QuotaObservation { 1128Provider :"alibaba" ,AccountID :"alibaba" ,LimitID :"" ,Status :"" ,Source :"poll" , 1129ObservedAt :quotaNow .Add (- time .Minute ).Format (time .RFC3339 ), 1130Error :& protocol.SafeReason {Code :"quota_failed" ,Detail :"bailian CLI failed (exit 3)" }, 1131 } 1132m := NewQuotaModel (nil ) 1133m .SetSize (120 ,30 ) 1134m ,_ = m .Update (quotaLoadedMsg {Gen :m .Generation ,At :quotaNow ,Readings : []daemon.QuotaObservation {failed }}) 1135view := ansi .Strip (m .View ()) 1136if ! strings .Contains (view ,"last poll failed" )|| ! strings .Contains (view ,"bailian CLI failed" ) { 1137t .Fatalf ("a failed account's row and pane should say what failed:\n%s" ,view ) 1138 } 1139pane := ansi .Strip (strings .Join (m .details (failed ,60 ),"\n" )) 1140if ! strings .HasPrefix (pane ,"last poll failed" ) { 1141t .Fatalf ("the pane should be titled by what happened, not left blank:\n%s" ,pane ) 1142 } 1143 }) 1144}