package tui import ( "strings" "testing" "time" tea "charm.land/bubbletea/v2" ) // TestFormatElapsed checks the compact duration formatting across the second, // minute, and hour ranges. func TestFormatElapsed(t *testing.T) { cases := []struct { d time.Duration want string }{ {5 * time.Second, "5s"}, {59 * time.Second, "59s"}, {114 * time.Second, "1m 54s"}, {60 * time.Minute, "1h 0m"}, {62 * time.Minute, "1h 2m"}, {-3 * time.Second, "0s"}, } for _, c := range cases { if got := formatElapsed(c.d); got != c.want { t.Errorf("formatElapsed(%s) = %q, want %q", c.d, got, c.want) } } } // TestSpinnerViewStats verifies a running spinner renders its verb with an // ellipsis and the elapsed/token/effort stats, and that a stopped spinner // renders nothing. func TestSpinnerViewStats(t *testing.T) { s := newSpinner(DefaultTheme()) s.begin(time.Now().Add(-114*time.Second), "medium") s.chars = 968 // 968/4 = 242 estimated tokens view := stripANSI(s.view(120)) if !strings.Contains(view, s.verb+"…") { t.Errorf("view %q should contain the verb with an ellipsis", view) } for _, want := range []string{"1m 54s", "↓ 242 tokens", "medium effort"} { if !strings.Contains(view, want) { t.Errorf("view %q missing stat %q", view, want) } } s.stop() if got := s.view(120); got != "" { t.Errorf("stopped spinner should render nothing, got %q", got) } } // TestSpinnerPinOverridesVerb verifies a pinned label replaces the random verb // and survives verb re-rolls, and that unpin restores the cycling verb. func TestSpinnerPinOverridesVerb(t *testing.T) { s := newSpinner(DefaultTheme()) s.begin(time.Now(), "") s.pin("Compacting conversation") // Advance well past the re-roll interval: a pinned label must not change. for i := 0; i < verbRerollFrames*2; i++ { s.advance() } view := stripANSI(s.view(120)) if !strings.Contains(view, "Compacting conversation…") { t.Errorf("pinned spinner view %q should show the pinned label", view) } s.unpin() if got := stripANSI(s.view(120)); strings.Contains(got, "Compacting conversation") { t.Errorf("after unpin, view %q should not show the pinned label", got) } } // tokens stream and the effort stat is hidden with no thinking level. func TestSpinnerViewOmitsEmptyStats(t *testing.T) { s := newSpinner(DefaultTheme()) s.begin(time.Now(), "") view := stripANSI(s.view(120)) if strings.Contains(view, "tokens") { t.Errorf("view %q should not show a token stat before any deltas", view) } if strings.Contains(view, "effort") { t.Errorf("view %q should not show an effort stat with no thinking level", view) } } // TestSpinnerAdvanceRerollsVerb verifies the animation frame advances and the // verb is re-picked on the reroll cadence. func TestSpinnerAdvanceRerollsVerb(t *testing.T) { s := newSpinner(DefaultTheme()) s.begin(time.Now(), "") if s.frame != 0 { t.Fatalf("fresh spinner frame = %d, want 0", s.frame) } for i := 0; i < verbRerollFrames; i++ { s.advance() } if s.frame != verbRerollFrames { t.Errorf("frame after %d advances = %d", verbRerollFrames, s.frame) } } // TestModelRunningShowsSpinnerRow verifies that while a run is in flight the // spinner occupies its own row above the input and the shell still fills exactly // the terminal height (relayout shrinks the transcript by the spinner row). func TestModelRunningShowsSpinnerRow(t *testing.T) { m := apply(t, NewModel(Options{Model: "test-model"}), tea.WindowSizeMsg{Width: 60, Height: 10}) m.running = true m.spinner.begin(time.Now(), "medium") m.relayout() view := m.renderContent() if got := strings.Count(view, "\n"); got != 9 { t.Errorf("running newline count = %d, want 9 (10 rows)", got) } if !strings.Contains(stripANSI(view), m.spinner.verb+"…") { t.Errorf("running view should contain the spinner verb, got:\n%s", stripANSI(view)) } }