-
Notifications
You must be signed in to change notification settings - Fork 14.5k
Expand file tree
/
Copy pathuseInputHistoryStore.test.ts
More file actions
393 lines (313 loc) · 11.4 KB
/
Copy pathuseInputHistoryStore.test.ts
File metadata and controls
393 lines (313 loc) · 11.4 KB
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
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { act, StrictMode } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { useInputHistoryStore } from './useInputHistoryStore.js';
import { debugLogger } from '@google/gemini-cli-core';
describe('useInputHistoryStore', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should initialize with empty input history', async () => {
const { result } = await renderHook(() => useInputHistoryStore());
expect(result.current.inputHistory).toEqual([]);
});
it('should add input to history', async () => {
const { result } = await renderHook(() => useInputHistoryStore());
act(() => {
result.current.addInput('test message 1');
});
expect(result.current.inputHistory).toEqual(['test message 1']);
act(() => {
result.current.addInput('test message 2');
});
expect(result.current.inputHistory).toEqual([
'test message 1',
'test message 2',
]);
});
it('should not add empty or whitespace-only inputs', async () => {
const { result } = await renderHook(() => useInputHistoryStore());
act(() => {
result.current.addInput('');
});
expect(result.current.inputHistory).toEqual([]);
act(() => {
result.current.addInput(' ');
});
expect(result.current.inputHistory).toEqual([]);
});
it('should deduplicate consecutive identical messages', async () => {
const { result } = await renderHook(() => useInputHistoryStore());
act(() => {
result.current.addInput('test message');
});
act(() => {
result.current.addInput('test message'); // Same as previous
});
expect(result.current.inputHistory).toEqual(['test message']);
act(() => {
result.current.addInput('different message');
});
act(() => {
result.current.addInput('test message'); // Same as first, but not consecutive
});
expect(result.current.inputHistory).toEqual([
'test message',
'different message',
'test message',
]);
});
it('should initialize from logger successfully', async () => {
const mockLogger = {
getPreviousUserMessages: vi
.fn()
.mockResolvedValue(['newest', 'middle', 'oldest']),
};
const { result } = await renderHook(() => useInputHistoryStore());
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
// Should reverse the order to oldest first
expect(result.current.inputHistory).toEqual(['oldest', 'middle', 'newest']);
expect(mockLogger.getPreviousUserMessages).toHaveBeenCalledTimes(1);
});
it('should handle logger initialization failure gracefully', async () => {
const mockLogger = {
getPreviousUserMessages: vi
.fn()
.mockRejectedValue(new Error('Logger error')),
};
const consoleSpy = vi
.spyOn(debugLogger, 'warn')
.mockImplementation(() => {});
const { result } = await renderHook(() => useInputHistoryStore());
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
expect(result.current.inputHistory).toEqual([]);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to initialize input history from logger:',
expect.any(Error),
);
consoleSpy.mockRestore();
});
it('should initialize only once', async () => {
const mockLogger = {
getPreviousUserMessages: vi
.fn()
.mockResolvedValue(['message1', 'message2']),
};
const { result } = await renderHook(() => useInputHistoryStore());
// Call initializeFromLogger twice
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
// Should be called only once
expect(mockLogger.getPreviousUserMessages).toHaveBeenCalledTimes(1);
expect(result.current.inputHistory).toEqual(['message2', 'message1']);
});
it('should handle null logger gracefully', async () => {
const { result } = await renderHook(() => useInputHistoryStore());
await act(async () => {
await result.current.initializeFromLogger(null);
});
expect(result.current.inputHistory).toEqual([]);
});
it('should trim input before adding to history', async () => {
const { result } = await renderHook(() => useInputHistoryStore());
act(() => {
result.current.addInput(' test message ');
});
expect(result.current.inputHistory).toEqual(['test message']);
});
describe('deduplication logic from previous implementation', () => {
it('should deduplicate consecutive messages from past sessions during initialization', async () => {
const mockLogger = {
getPreviousUserMessages: vi
.fn()
.mockResolvedValue([
'message1',
'message1',
'message2',
'message2',
'message3',
]), // newest first with duplicates
};
const { result } = await renderHook(() => useInputHistoryStore());
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
// Should deduplicate consecutive messages and reverse to oldest first
expect(result.current.inputHistory).toEqual([
'message3',
'message2',
'message1',
]);
});
it('should deduplicate across session boundaries', async () => {
const mockLogger = {
getPreviousUserMessages: vi.fn().mockResolvedValue(['old2', 'old1']), // newest first
};
const { result } = await renderHook(() => useInputHistoryStore());
// Initialize with past session
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
// Add current session inputs
act(() => {
result.current.addInput('old2'); // Same as last past session message
});
// Should deduplicate across session boundary
expect(result.current.inputHistory).toEqual(['old1', 'old2']);
act(() => {
result.current.addInput('new1');
});
expect(result.current.inputHistory).toEqual(['old1', 'old2', 'new1']);
});
it('should preserve non-consecutive duplicates', async () => {
const mockLogger = {
getPreviousUserMessages: vi
.fn()
.mockResolvedValue(['message2', 'message1', 'message2']), // newest first with non-consecutive duplicate
};
const { result } = await renderHook(() => useInputHistoryStore());
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
// Non-consecutive duplicates should be preserved
expect(result.current.inputHistory).toEqual([
'message2',
'message1',
'message2',
]);
});
it('should handle complex deduplication with current session', async () => {
const { result } = await renderHook(() => useInputHistoryStore());
// Add multiple messages with duplicates
act(() => {
result.current.addInput('hello');
});
act(() => {
result.current.addInput('hello'); // consecutive duplicate
});
act(() => {
result.current.addInput('world');
});
act(() => {
result.current.addInput('world'); // consecutive duplicate
});
act(() => {
result.current.addInput('hello'); // non-consecutive duplicate
});
// Should have deduplicated consecutive ones
expect(result.current.inputHistory).toEqual(['hello', 'world', 'hello']);
});
it('should maintain oldest-first order in final output', async () => {
const mockLogger = {
getPreviousUserMessages: vi
.fn()
.mockResolvedValue(['newest', 'middle', 'oldest']), // newest first
};
const { result } = await renderHook(() => useInputHistoryStore());
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
// Add current session messages
act(() => {
result.current.addInput('current1');
});
act(() => {
result.current.addInput('current2');
});
// Should maintain oldest-first order
expect(result.current.inputHistory).toEqual([
'oldest',
'middle',
'newest',
'current1',
'current2',
]);
});
});
describe('state updates stay outside updater functions', () => {
// React may call an updater more than once for a single update (StrictMode
// double-invoke, replays under batching). addInput used to nest
// setPastSessionMessages inside the setCurrentSessionMessages updater and
// run recalculateHistory - itself a setState - from there. The resulting
// history happened to be idempotent, so the tests below pin both the
// history and the render count, which is what the nesting actually cost.
it('does not schedule extra renders per submit under StrictMode', async () => {
const renders: string[][] = [];
const { result } = await renderHook(
() => {
const store = useInputHistoryStore();
renders.push(store.inputHistory);
return store;
},
{ wrapper: StrictMode as never },
);
const before = renders.length;
act(() => {
result.current.addInput('only once');
});
const rendersForOneSubmit = renders.length - before;
expect(result.current.inputHistory).toEqual(['only once']);
// The nested-updater version queued a redundant past-messages update on
// top of the history update, costing an extra render pass per submit.
expect(rendersForOneSubmit).toBeLessThanOrEqual(2);
});
it('keeps every submit when several are batched into one update', async () => {
const { result } = await renderHook(() => useInputHistoryStore(), {
wrapper: StrictMode as never,
});
// Same act() means all three updates are queued together, which is what
// makes an updater-nested update fire once per queued outer update.
act(() => {
result.current.addInput('a');
result.current.addInput('b');
result.current.addInput('c');
});
expect(result.current.inputHistory).toEqual(['a', 'b', 'c']);
});
it('keeps past messages visible after batched submits', async () => {
const mockLogger = {
getPreviousUserMessages: vi.fn().mockResolvedValue(['past2', 'past1']),
};
const { result } = await renderHook(() => useInputHistoryStore(), {
wrapper: StrictMode as never,
});
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
act(() => {
result.current.addInput('new1');
result.current.addInput('new2');
});
expect(result.current.inputHistory).toEqual([
'past1',
'past2',
'new1',
'new2',
]);
});
it('still deduplicates consecutive duplicates across batched submits', async () => {
const { result } = await renderHook(() => useInputHistoryStore(), {
wrapper: StrictMode as never,
});
act(() => {
result.current.addInput('same');
result.current.addInput('same');
result.current.addInput('other');
});
expect(result.current.inputHistory).toEqual(['same', 'other']);
});
});
});