summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts
blob: 496e13d33108841aed73fadabdee9d6e314de14c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useLoadingIndicator } from './useLoadingIndicator.js';
import { StreamingState } from '../types.js';
import {
  WITTY_LOADING_PHRASES,
  PHRASE_CHANGE_INTERVAL_MS,
} from '../constants.js';

describe('useLoadingIndicator', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it('should initialize with default values when not responding', () => {
    const { result } = renderHook(() =>
      useLoadingIndicator(StreamingState.Idle, false),
    );
    expect(result.current.elapsedTime).toBe(0);
    expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[0]);
    expect(result.current.shouldShowSpinner).toBe(true);
  });

  describe('when streamingState is Responding', () => {
    it('should increment elapsedTime and cycle phrases when not paused', () => {
      const { result } = renderHook(() =>
        useLoadingIndicator(StreamingState.Responding, false),
      );
      expect(result.current.shouldShowSpinner).toBe(true);
      expect(result.current.currentLoadingPhrase).toBe(
        WITTY_LOADING_PHRASES[0],
      );

      act(() => {
        vi.advanceTimersByTime(1000);
      });
      expect(result.current.elapsedTime).toBe(1);

      act(() => {
        vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
      });
      expect(result.current.currentLoadingPhrase).toBe(
        WITTY_LOADING_PHRASES[1],
      );
      expect(result.current.elapsedTime).toBe(
        1 + PHRASE_CHANGE_INTERVAL_MS / 1000,
      );
    });

    it('should pause elapsedTime, show specific phrase, and hide spinner when paused', () => {
      const { result, rerender } = renderHook(
        ({ isPaused }) =>
          useLoadingIndicator(StreamingState.Responding, isPaused),
        { initialProps: { isPaused: false } },
      );

      act(() => {
        vi.advanceTimersByTime(2000);
      });
      expect(result.current.elapsedTime).toBe(2);
      expect(result.current.shouldShowSpinner).toBe(true);

      rerender({ isPaused: true });

      expect(result.current.currentLoadingPhrase).toBe(
        'Waiting for user confirmation...',
      );
      expect(result.current.shouldShowSpinner).toBe(false);

      // Time should not advance while paused
      const timeBeforePauseAdv = result.current.elapsedTime;
      act(() => {
        vi.advanceTimersByTime(3000);
      });
      expect(result.current.elapsedTime).toBe(timeBeforePauseAdv);

      // Unpause
      rerender({ isPaused: false });
      expect(result.current.shouldShowSpinner).toBe(true);
      // Phrase should reset to the beginning of witty phrases
      expect(result.current.currentLoadingPhrase).toBe(
        WITTY_LOADING_PHRASES[0],
      );

      act(() => {
        vi.advanceTimersByTime(1000);
      });
      // Elapsed time should resume from where it left off
      expect(result.current.elapsedTime).toBe(timeBeforePauseAdv + 1);
    });

    it('should reset timer and phrase when streamingState changes from Responding to Idle', () => {
      const { result, rerender } = renderHook(
        ({ streamingState }) => useLoadingIndicator(streamingState, false),
        { initialProps: { streamingState: StreamingState.Responding } },
      );

      act(() => {
        vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS + 1000);
      });
      expect(result.current.elapsedTime).toBe(
        PHRASE_CHANGE_INTERVAL_MS / 1000 + 1,
      );
      expect(result.current.currentLoadingPhrase).toBe(
        WITTY_LOADING_PHRASES[1],
      );

      rerender({ streamingState: StreamingState.Idle });

      expect(result.current.elapsedTime).toBe(0);
      // When idle, the phrase interval should be cleared, but the last phrase might persist
      // until the next "Responding" state. The important part is that the timer is reset.
      // Depending on exact timing, it might be the last witty phrase or the first.
      // For this test, we'll ensure it's one of them.
      expect(WITTY_LOADING_PHRASES).toContain(
        result.current.currentLoadingPhrase,
      );
    });
  });

  it('should clear intervals on unmount', () => {
    const { unmount } = renderHook(() =>
      useLoadingIndicator(StreamingState.Responding, false),
    );
    const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
    unmount();
    // Expecting two intervals (elapsedTime and phraseInterval) to be cleared.
    expect(clearIntervalSpy).toHaveBeenCalledTimes(2);
  });

  it('should reset to initial witty phrase when unpaused', () => {
    const { result, rerender } = renderHook(
      ({ isPaused }) =>
        useLoadingIndicator(StreamingState.Responding, isPaused),
      { initialProps: { isPaused: false } },
    );

    // Advance to the second witty phrase
    act(() => {
      vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS);
    });
    expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[1]);

    // Pause
    rerender({ isPaused: true });
    expect(result.current.currentLoadingPhrase).toBe(
      'Waiting for user confirmation...',
    );

    // Unpause
    rerender({ isPaused: false });
    expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[0]);
  });
});