okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: replace the generic /quota page with a screen of latest limits, bars and per-limit trails8e3e173

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