@lobehub/lobehub 2.0.0-next.78 → 2.0.0-next.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,353 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { useChatStore } from '@/store/chat/store';
5
+
6
+ describe('Operation Actions', () => {
7
+ beforeEach(() => {
8
+ useChatStore.setState(useChatStore.getInitialState());
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.clearAllTimers();
13
+ });
14
+
15
+ describe('startOperation', () => {
16
+ it('should create a new operation with correct initial state', () => {
17
+ const { result } = renderHook(() => useChatStore());
18
+
19
+ let operationId: string;
20
+ let abortController: AbortController;
21
+
22
+ act(() => {
23
+ const res = result.current.startOperation({
24
+ type: 'generateAI',
25
+ context: { sessionId: 'session1', topicId: 'topic1', messageId: 'msg1' },
26
+ label: 'Generating...',
27
+ });
28
+ operationId = res.operationId;
29
+ abortController = res.abortController;
30
+ });
31
+
32
+ const operation = result.current.operations[operationId!];
33
+
34
+ expect(operation).toBeDefined();
35
+ expect(operation.type).toBe('generateAI');
36
+ expect(operation.status).toBe('running');
37
+ expect(operation.context.sessionId).toBe('session1');
38
+ expect(operation.context.topicId).toBe('topic1');
39
+ expect(operation.context.messageId).toBe('msg1');
40
+ expect(operation.label).toBe('Generating...');
41
+ expect(operation.abortController).toBe(abortController!);
42
+ });
43
+
44
+ it('should inherit context from parent operation', () => {
45
+ const { result } = renderHook(() => useChatStore());
46
+
47
+ let parentOpId: string;
48
+ let childOpId: string;
49
+
50
+ act(() => {
51
+ // Create parent operation
52
+ const parent = result.current.startOperation({
53
+ type: 'sendMessage',
54
+ context: { sessionId: 'session1', topicId: 'topic1' },
55
+ });
56
+ parentOpId = parent.operationId;
57
+
58
+ // Create child operation (inherits context)
59
+ const child = result.current.startOperation({
60
+ type: 'generateAI',
61
+ context: { messageId: 'msg1' }, // Only override messageId
62
+ parentOperationId: parentOpId,
63
+ });
64
+ childOpId = child.operationId;
65
+ });
66
+
67
+ const childOp = result.current.operations[childOpId!];
68
+
69
+ expect(childOp.context.sessionId).toBe('session1'); // Inherited
70
+ expect(childOp.context.topicId).toBe('topic1'); // Inherited
71
+ expect(childOp.context.messageId).toBe('msg1'); // Overridden
72
+ expect(childOp.parentOperationId).toBe(parentOpId!);
73
+ });
74
+
75
+ it('should fully inherit parent context when child context is undefined', () => {
76
+ const { result } = renderHook(() => useChatStore());
77
+
78
+ let parentOpId: string;
79
+ let childOpId: string;
80
+
81
+ act(() => {
82
+ // Create parent operation with full context
83
+ const parent = result.current.startOperation({
84
+ type: 'sendMessage',
85
+ context: { sessionId: 'session1', topicId: 'topic1', messageId: 'msg1' },
86
+ });
87
+ parentOpId = parent.operationId;
88
+
89
+ // Create child operation without context (undefined)
90
+ const child = result.current.startOperation({
91
+ type: 'generateAI',
92
+ parentOperationId: parentOpId,
93
+ });
94
+ childOpId = child.operationId;
95
+ });
96
+
97
+ const childOp = result.current.operations[childOpId!];
98
+
99
+ // Should fully inherit parent's context
100
+ expect(childOp.context.sessionId).toBe('session1');
101
+ expect(childOp.context.topicId).toBe('topic1');
102
+ expect(childOp.context.messageId).toBe('msg1');
103
+ expect(childOp.parentOperationId).toBe(parentOpId!);
104
+ });
105
+
106
+ it('should update indexes correctly', () => {
107
+ const { result } = renderHook(() => useChatStore());
108
+
109
+ let operationId: string;
110
+
111
+ act(() => {
112
+ operationId = result.current.startOperation({
113
+ type: 'generateAI',
114
+ context: { sessionId: 'session1', topicId: 'topic1', messageId: 'msg1' },
115
+ }).operationId;
116
+ });
117
+
118
+ // Check type index
119
+ expect(result.current.operationsByType.generateAI).toContain(operationId!);
120
+
121
+ // Check message index
122
+ expect(result.current.operationsByMessage.msg1).toContain(operationId!);
123
+
124
+ // Check context index
125
+ const contextKey = 'session1_topic1';
126
+ expect(result.current.operationsByContext[contextKey]).toContain(operationId!);
127
+ });
128
+ });
129
+
130
+ describe('completeOperation', () => {
131
+ it('should mark operation as completed with correct metadata', () => {
132
+ const { result } = renderHook(() => useChatStore());
133
+
134
+ let operationId: string;
135
+
136
+ act(() => {
137
+ operationId = result.current.startOperation({
138
+ type: 'generateAI',
139
+ context: { sessionId: 'session1' },
140
+ }).operationId;
141
+ });
142
+
143
+ const startTime = result.current.operations[operationId!].metadata.startTime;
144
+
145
+ act(() => {
146
+ result.current.completeOperation(operationId!);
147
+ });
148
+
149
+ const operation = result.current.operations[operationId!];
150
+
151
+ expect(operation.status).toBe('completed');
152
+ expect(operation.metadata.endTime).toBeDefined();
153
+ expect(operation.metadata.duration).toBeDefined();
154
+ expect(operation.metadata.duration).toBeGreaterThanOrEqual(0);
155
+ });
156
+ });
157
+
158
+ describe('cancelOperation', () => {
159
+ it('should cancel operation and abort controller', () => {
160
+ const { result } = renderHook(() => useChatStore());
161
+
162
+ let operationId: string;
163
+ let abortController: AbortController;
164
+
165
+ act(() => {
166
+ const res = result.current.startOperation({
167
+ type: 'generateAI',
168
+ context: { sessionId: 'session1' },
169
+ });
170
+ operationId = res.operationId;
171
+ abortController = res.abortController;
172
+ });
173
+
174
+ act(() => {
175
+ result.current.cancelOperation(operationId!, 'User cancelled');
176
+ });
177
+
178
+ const operation = result.current.operations[operationId!];
179
+
180
+ expect(operation.status).toBe('cancelled');
181
+ expect(operation.metadata.cancelReason).toBe('User cancelled');
182
+ expect(abortController!.signal.aborted).toBe(true);
183
+ });
184
+
185
+ it('should recursively cancel child operations', () => {
186
+ const { result } = renderHook(() => useChatStore());
187
+
188
+ let parentOpId: string;
189
+ let child1OpId: string;
190
+ let child2OpId: string;
191
+
192
+ act(() => {
193
+ parentOpId = result.current.startOperation({
194
+ type: 'generateAI',
195
+ context: { sessionId: 'session1' },
196
+ }).operationId;
197
+
198
+ child1OpId = result.current.startOperation({
199
+ type: 'reasoning',
200
+ parentOperationId: parentOpId,
201
+ }).operationId;
202
+
203
+ child2OpId = result.current.startOperation({
204
+ type: 'toolCalling',
205
+ parentOperationId: parentOpId,
206
+ }).operationId;
207
+ });
208
+
209
+ act(() => {
210
+ result.current.cancelOperation(parentOpId!);
211
+ });
212
+
213
+ expect(result.current.operations[parentOpId!].status).toBe('cancelled');
214
+ expect(result.current.operations[child1OpId!].status).toBe('cancelled');
215
+ expect(result.current.operations[child2OpId!].status).toBe('cancelled');
216
+ });
217
+ });
218
+
219
+ describe('failOperation', () => {
220
+ it('should mark operation as failed with error', () => {
221
+ const { result } = renderHook(() => useChatStore());
222
+
223
+ let operationId: string;
224
+
225
+ act(() => {
226
+ operationId = result.current.startOperation({
227
+ type: 'generateAI',
228
+ context: { sessionId: 'session1' },
229
+ }).operationId;
230
+ });
231
+
232
+ act(() => {
233
+ result.current.failOperation(operationId!, {
234
+ type: 'NetworkError',
235
+ message: 'Connection failed',
236
+ code: 'ERR_NETWORK',
237
+ });
238
+ });
239
+
240
+ const operation = result.current.operations[operationId!];
241
+
242
+ expect(operation.status).toBe('failed');
243
+ expect(operation.metadata.error).toEqual({
244
+ type: 'NetworkError',
245
+ message: 'Connection failed',
246
+ code: 'ERR_NETWORK',
247
+ });
248
+ });
249
+ });
250
+
251
+ describe('cancelOperations (batch)', () => {
252
+ it('should cancel operations matching filter', () => {
253
+ const { result } = renderHook(() => useChatStore());
254
+
255
+ let op1: string;
256
+ let op2: string;
257
+ let op3: string;
258
+
259
+ act(() => {
260
+ op1 = result.current.startOperation({
261
+ type: 'generateAI',
262
+ context: { sessionId: 'session1' },
263
+ }).operationId;
264
+
265
+ op2 = result.current.startOperation({
266
+ type: 'generateAI',
267
+ context: { sessionId: 'session1' },
268
+ }).operationId;
269
+
270
+ op3 = result.current.startOperation({
271
+ type: 'reasoning',
272
+ context: { sessionId: 'session1' },
273
+ }).operationId;
274
+ });
275
+
276
+ act(() => {
277
+ const cancelled = result.current.cancelOperations({ type: 'generateAI' });
278
+ expect(cancelled).toHaveLength(2);
279
+ });
280
+
281
+ expect(result.current.operations[op1!].status).toBe('cancelled');
282
+ expect(result.current.operations[op2!].status).toBe('cancelled');
283
+ expect(result.current.operations[op3!].status).toBe('running'); // Not cancelled
284
+ });
285
+ });
286
+
287
+ describe('associateMessageWithOperation', () => {
288
+ it('should create message-operation mapping', () => {
289
+ const { result } = renderHook(() => useChatStore());
290
+
291
+ let operationId: string;
292
+
293
+ act(() => {
294
+ operationId = result.current.startOperation({
295
+ type: 'generateAI',
296
+ context: { sessionId: 'session1' },
297
+ }).operationId;
298
+
299
+ result.current.associateMessageWithOperation('msg1', operationId!);
300
+ });
301
+
302
+ expect(result.current.messageOperationMap.msg1).toBe(operationId!);
303
+ });
304
+ });
305
+
306
+ describe('cleanupCompletedOperations', () => {
307
+ it('should remove old completed operations', () => {
308
+ const { result } = renderHook(() => useChatStore());
309
+
310
+ let op1: string;
311
+ let op2: string;
312
+
313
+ act(() => {
314
+ op1 = result.current.startOperation({
315
+ type: 'generateAI',
316
+ context: { sessionId: 'session1' },
317
+ }).operationId;
318
+
319
+ op2 = result.current.startOperation({
320
+ type: 'generateAI',
321
+ context: { sessionId: 'session1' },
322
+ }).operationId;
323
+ });
324
+
325
+ act(() => {
326
+ result.current.completeOperation(op1!);
327
+ });
328
+
329
+ // Manually set endTime to past
330
+ act(() => {
331
+ useChatStore.setState({
332
+ operations: {
333
+ ...result.current.operations,
334
+ [op1!]: {
335
+ ...result.current.operations[op1!],
336
+ metadata: {
337
+ ...result.current.operations[op1!].metadata,
338
+ endTime: Date.now() - 120000, // 2 minutes ago
339
+ },
340
+ },
341
+ },
342
+ });
343
+ });
344
+
345
+ act(() => {
346
+ result.current.cleanupCompletedOperations(60000); // 1 minute threshold
347
+ });
348
+
349
+ expect(result.current.operations[op1!]).toBeUndefined(); // Cleaned up
350
+ expect(result.current.operations[op2!]).toBeDefined(); // Still running
351
+ });
352
+ });
353
+ });
@@ -0,0 +1,273 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+
4
+ import { useChatStore } from '@/store/chat/store';
5
+
6
+ import { operationSelectors } from '../selectors';
7
+
8
+ describe('Operation Selectors', () => {
9
+ beforeEach(() => {
10
+ useChatStore.setState(useChatStore.getInitialState());
11
+ });
12
+
13
+ describe('getOperationsByType', () => {
14
+ it('should return operations of specific type', () => {
15
+ const { result } = renderHook(() => useChatStore());
16
+
17
+ act(() => {
18
+ result.current.startOperation({
19
+ type: 'generateAI',
20
+ context: { sessionId: 'session1' },
21
+ });
22
+
23
+ result.current.startOperation({
24
+ type: 'generateAI',
25
+ context: { sessionId: 'session1' },
26
+ });
27
+
28
+ result.current.startOperation({
29
+ type: 'reasoning',
30
+ context: { sessionId: 'session1' },
31
+ });
32
+ });
33
+
34
+ const generateOps = operationSelectors.getOperationsByType('generateAI')(result.current);
35
+ const reasoningOps = operationSelectors.getOperationsByType('reasoning')(result.current);
36
+
37
+ expect(generateOps).toHaveLength(2);
38
+ expect(reasoningOps).toHaveLength(1);
39
+ });
40
+ });
41
+
42
+ describe('getCurrentContextOperations', () => {
43
+ it('should return operations for current active session/topic', () => {
44
+ const { result } = renderHook(() => useChatStore());
45
+
46
+ act(() => {
47
+ // Set active session and topic
48
+ useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
49
+
50
+ result.current.startOperation({
51
+ type: 'generateAI',
52
+ context: { sessionId: 'session1', topicId: 'topic1' },
53
+ });
54
+
55
+ result.current.startOperation({
56
+ type: 'reasoning',
57
+ context: { sessionId: 'session1', topicId: 'topic1' },
58
+ });
59
+
60
+ // Operation in different context
61
+ result.current.startOperation({
62
+ type: 'generateAI',
63
+ context: { sessionId: 'session2', topicId: 'topic2' },
64
+ });
65
+ });
66
+
67
+ const currentOps = operationSelectors.getCurrentContextOperations(result.current);
68
+
69
+ expect(currentOps).toHaveLength(2);
70
+ expect(currentOps.every((op) => op.context.sessionId === 'session1')).toBe(true);
71
+ expect(currentOps.every((op) => op.context.topicId === 'topic1')).toBe(true);
72
+ });
73
+ });
74
+
75
+ describe('hasAnyRunningOperation', () => {
76
+ it('should return true if any operation is running', () => {
77
+ const { result } = renderHook(() => useChatStore());
78
+
79
+ expect(operationSelectors.hasAnyRunningOperation(result.current)).toBe(false);
80
+
81
+ let opId: string;
82
+
83
+ act(() => {
84
+ opId = result.current.startOperation({
85
+ type: 'generateAI',
86
+ context: { sessionId: 'session1' },
87
+ }).operationId;
88
+ });
89
+
90
+ expect(operationSelectors.hasAnyRunningOperation(result.current)).toBe(true);
91
+
92
+ act(() => {
93
+ result.current.completeOperation(opId!);
94
+ });
95
+
96
+ expect(operationSelectors.hasAnyRunningOperation(result.current)).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe('hasRunningOperationType', () => {
101
+ it('should return true if specific type is running', () => {
102
+ const { result } = renderHook(() => useChatStore());
103
+
104
+ act(() => {
105
+ result.current.startOperation({
106
+ type: 'generateAI',
107
+ context: { sessionId: 'session1' },
108
+ });
109
+ });
110
+
111
+ expect(operationSelectors.hasRunningOperationType('generateAI')(result.current)).toBe(true);
112
+ expect(operationSelectors.hasRunningOperationType('reasoning')(result.current)).toBe(false);
113
+ });
114
+ });
115
+
116
+ describe('canSendMessage', () => {
117
+ it('should return false when operations are running in current context', () => {
118
+ const { result } = renderHook(() => useChatStore());
119
+
120
+ act(() => {
121
+ useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
122
+ });
123
+
124
+ expect(operationSelectors.canSendMessage(result.current)).toBe(true);
125
+
126
+ act(() => {
127
+ result.current.startOperation({
128
+ type: 'generateAI',
129
+ context: { sessionId: 'session1', topicId: 'topic1' },
130
+ });
131
+ });
132
+
133
+ expect(operationSelectors.canSendMessage(result.current)).toBe(false);
134
+ });
135
+ });
136
+
137
+ describe('canInterrupt', () => {
138
+ it('should return true when operations can be cancelled', () => {
139
+ const { result } = renderHook(() => useChatStore());
140
+
141
+ act(() => {
142
+ useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
143
+ });
144
+
145
+ expect(operationSelectors.canInterrupt(result.current)).toBe(false);
146
+
147
+ act(() => {
148
+ result.current.startOperation({
149
+ type: 'generateAI',
150
+ context: { sessionId: 'session1', topicId: 'topic1' },
151
+ });
152
+ });
153
+
154
+ expect(operationSelectors.canInterrupt(result.current)).toBe(true);
155
+ });
156
+ });
157
+
158
+ describe('getCurrentOperationLabel', () => {
159
+ it('should return label of most recent running operation', () => {
160
+ const { result } = renderHook(() => useChatStore());
161
+
162
+ act(() => {
163
+ useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
164
+
165
+ result.current.startOperation({
166
+ type: 'generateAI',
167
+ context: { sessionId: 'session1', topicId: 'topic1' },
168
+ label: 'Generating response...',
169
+ });
170
+
171
+ // Simulate some time passing
172
+ setTimeout(() => {
173
+ result.current.startOperation({
174
+ type: 'reasoning',
175
+ context: { sessionId: 'session1', topicId: 'topic1' },
176
+ label: 'Thinking...',
177
+ });
178
+ }, 10);
179
+ });
180
+
181
+ // Should return the most recent operation's label
182
+ const label = operationSelectors.getCurrentOperationLabel(result.current);
183
+ expect(label).toBeTruthy();
184
+ });
185
+ });
186
+
187
+ describe('isMessageProcessing', () => {
188
+ it('should return true if message has running operations', () => {
189
+ const { result } = renderHook(() => useChatStore());
190
+
191
+ act(() => {
192
+ result.current.startOperation({
193
+ type: 'generateAI',
194
+ context: { sessionId: 'session1', messageId: 'msg1' },
195
+ });
196
+ });
197
+
198
+ expect(operationSelectors.isMessageProcessing('msg1')(result.current)).toBe(true);
199
+ expect(operationSelectors.isMessageProcessing('msg2')(result.current)).toBe(false);
200
+ });
201
+ });
202
+
203
+ describe('getOperationContextFromMessage', () => {
204
+ it('should return operation context from message ID', () => {
205
+ const { result } = renderHook(() => useChatStore());
206
+
207
+ let opId: string;
208
+
209
+ act(() => {
210
+ opId = result.current.startOperation({
211
+ type: 'generateAI',
212
+ context: { sessionId: 'session1', topicId: 'topic1', messageId: 'msg1' },
213
+ }).operationId;
214
+
215
+ result.current.associateMessageWithOperation('msg1', opId!);
216
+ });
217
+
218
+ const context = operationSelectors.getOperationContextFromMessage('msg1')(result.current);
219
+
220
+ expect(context).toBeDefined();
221
+ expect(context?.sessionId).toBe('session1');
222
+ expect(context?.topicId).toBe('topic1');
223
+ expect(context?.messageId).toBe('msg1');
224
+ });
225
+ });
226
+
227
+ describe('backward compatibility selectors', () => {
228
+ it('isAIGenerating should work', () => {
229
+ const { result } = renderHook(() => useChatStore());
230
+
231
+ expect(operationSelectors.isAIGenerating(result.current)).toBe(false);
232
+
233
+ act(() => {
234
+ result.current.startOperation({
235
+ type: 'generateAI',
236
+ context: { sessionId: 'session1' },
237
+ });
238
+ });
239
+
240
+ expect(operationSelectors.isAIGenerating(result.current)).toBe(true);
241
+ });
242
+
243
+ it('isSendingMessage should work', () => {
244
+ const { result } = renderHook(() => useChatStore());
245
+
246
+ expect(operationSelectors.isSendingMessage(result.current)).toBe(false);
247
+
248
+ act(() => {
249
+ result.current.startOperation({
250
+ type: 'sendMessage',
251
+ context: { sessionId: 'session1' },
252
+ });
253
+ });
254
+
255
+ expect(operationSelectors.isSendingMessage(result.current)).toBe(true);
256
+ });
257
+
258
+ it('isInRAGFlow should work', () => {
259
+ const { result } = renderHook(() => useChatStore());
260
+
261
+ expect(operationSelectors.isInRAGFlow(result.current)).toBe(false);
262
+
263
+ act(() => {
264
+ result.current.startOperation({
265
+ type: 'rag',
266
+ context: { sessionId: 'session1' },
267
+ });
268
+ });
269
+
270
+ expect(operationSelectors.isInRAGFlow(result.current)).toBe(true);
271
+ });
272
+ });
273
+ });