@volley/recognition-client-sdk-node22 0.1.424

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.
Files changed (58) hide show
  1. package/README.md +344 -0
  2. package/dist/browser.bundled.d.ts +1280 -0
  3. package/dist/browser.d.ts +10 -0
  4. package/dist/browser.d.ts.map +1 -0
  5. package/dist/config-builder.d.ts +134 -0
  6. package/dist/config-builder.d.ts.map +1 -0
  7. package/dist/errors.d.ts +41 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/factory.d.ts +36 -0
  10. package/dist/factory.d.ts.map +1 -0
  11. package/dist/index.bundled.d.ts +2572 -0
  12. package/dist/index.d.ts +16 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +10199 -0
  15. package/dist/index.js.map +7 -0
  16. package/dist/recog-client-sdk.browser.d.ts +10 -0
  17. package/dist/recog-client-sdk.browser.d.ts.map +1 -0
  18. package/dist/recog-client-sdk.browser.js +5746 -0
  19. package/dist/recog-client-sdk.browser.js.map +7 -0
  20. package/dist/recognition-client.d.ts +128 -0
  21. package/dist/recognition-client.d.ts.map +1 -0
  22. package/dist/recognition-client.types.d.ts +271 -0
  23. package/dist/recognition-client.types.d.ts.map +1 -0
  24. package/dist/simplified-vgf-recognition-client.d.ts +178 -0
  25. package/dist/simplified-vgf-recognition-client.d.ts.map +1 -0
  26. package/dist/utils/audio-ring-buffer.d.ts +69 -0
  27. package/dist/utils/audio-ring-buffer.d.ts.map +1 -0
  28. package/dist/utils/message-handler.d.ts +45 -0
  29. package/dist/utils/message-handler.d.ts.map +1 -0
  30. package/dist/utils/url-builder.d.ts +28 -0
  31. package/dist/utils/url-builder.d.ts.map +1 -0
  32. package/dist/vgf-recognition-mapper.d.ts +66 -0
  33. package/dist/vgf-recognition-mapper.d.ts.map +1 -0
  34. package/dist/vgf-recognition-state.d.ts +91 -0
  35. package/dist/vgf-recognition-state.d.ts.map +1 -0
  36. package/package.json +74 -0
  37. package/src/browser.ts +24 -0
  38. package/src/config-builder.spec.ts +265 -0
  39. package/src/config-builder.ts +240 -0
  40. package/src/errors.ts +84 -0
  41. package/src/factory.spec.ts +215 -0
  42. package/src/factory.ts +47 -0
  43. package/src/index.ts +127 -0
  44. package/src/recognition-client.spec.ts +889 -0
  45. package/src/recognition-client.ts +844 -0
  46. package/src/recognition-client.types.ts +338 -0
  47. package/src/simplified-vgf-recognition-client.integration.spec.ts +718 -0
  48. package/src/simplified-vgf-recognition-client.spec.ts +1525 -0
  49. package/src/simplified-vgf-recognition-client.ts +524 -0
  50. package/src/utils/audio-ring-buffer.spec.ts +335 -0
  51. package/src/utils/audio-ring-buffer.ts +170 -0
  52. package/src/utils/message-handler.spec.ts +311 -0
  53. package/src/utils/message-handler.ts +131 -0
  54. package/src/utils/url-builder.spec.ts +252 -0
  55. package/src/utils/url-builder.ts +92 -0
  56. package/src/vgf-recognition-mapper.spec.ts +78 -0
  57. package/src/vgf-recognition-mapper.ts +232 -0
  58. package/src/vgf-recognition-state.ts +102 -0
@@ -0,0 +1,1525 @@
1
+ /**
2
+ * Unit tests for SimplifiedVGFRecognitionClient
3
+ */
4
+
5
+ import { SimplifiedVGFRecognitionClient, createSimplifiedVGFClient } from './simplified-vgf-recognition-client.js';
6
+ import { RealTimeTwoWayWebSocketRecognitionClient } from './recognition-client.js';
7
+ import { ClientState } from './recognition-client.types.js';
8
+ import { AudioEncoding, RecognitionContextTypeV1, RecognitionResultTypeV1 } from '@recog/shared-types';
9
+ import {
10
+ RecordingStatus,
11
+ TranscriptionStatus,
12
+ type RecognitionState
13
+ } from './vgf-recognition-state.js';
14
+
15
+ // Mock the underlying client
16
+ jest.mock('./recognition-client');
17
+
18
+ describe('SimplifiedVGFRecognitionClient', () => {
19
+ let mockClient: jest.Mocked<RealTimeTwoWayWebSocketRecognitionClient>;
20
+ let simplifiedClient: SimplifiedVGFRecognitionClient;
21
+ let stateChangeCallback: jest.Mock;
22
+
23
+ beforeEach(() => {
24
+ // Reset mocks
25
+ jest.clearAllMocks();
26
+
27
+ // Create mock for underlying client
28
+ mockClient = {
29
+ connect: jest.fn().mockResolvedValue(undefined),
30
+ sendAudio: jest.fn(),
31
+ stopRecording: jest.fn().mockResolvedValue(undefined),
32
+ stopAbnormally: jest.fn(),
33
+ getAudioUtteranceId: jest.fn().mockReturnValue('test-uuid'),
34
+ getState: jest.fn().mockReturnValue(ClientState.INITIAL),
35
+ isConnected: jest.fn().mockReturnValue(false),
36
+ isConnecting: jest.fn().mockReturnValue(false),
37
+ isStopping: jest.fn().mockReturnValue(false),
38
+ isTranscriptionFinished: jest.fn().mockReturnValue(false),
39
+ isBufferOverflowing: jest.fn().mockReturnValue(false),
40
+ } as any;
41
+
42
+ // Mock the constructor
43
+ (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>)
44
+ .mockImplementation(() => mockClient as any);
45
+
46
+ stateChangeCallback = jest.fn();
47
+ });
48
+
49
+ describe('Constructor', () => {
50
+ it('should initialize with correct default VGF state', () => {
51
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
52
+ asrRequestConfig: {
53
+ provider: 'deepgram',
54
+ language: 'en',
55
+ sampleRate: 16000,
56
+ encoding: AudioEncoding.LINEAR16
57
+ },
58
+ onStateChange: stateChangeCallback
59
+ });
60
+
61
+ const state = simplifiedClient.getVGFState();
62
+ expect(state.audioUtteranceId).toBeDefined();
63
+ expect(state.startRecordingStatus).toBe(RecordingStatus.READY);
64
+ expect(state.transcriptionStatus).toBe(TranscriptionStatus.NOT_STARTED);
65
+ expect(state.pendingTranscript).toBe('');
66
+ });
67
+
68
+ it('should generate new UUID when initial state has no audioUtteranceId', () => {
69
+ const initialState: RecognitionState = {
70
+ // No audioUtteranceId provided
71
+ startRecordingStatus: RecordingStatus.READY,
72
+ transcriptionStatus: TranscriptionStatus.NOT_STARTED,
73
+ pendingTranscript: ''
74
+ } as RecognitionState;
75
+
76
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
77
+ initialState,
78
+ asrRequestConfig: {
79
+ provider: 'deepgram',
80
+ language: 'en',
81
+ sampleRate: 16000,
82
+ encoding: AudioEncoding.LINEAR16
83
+ },
84
+ onStateChange: stateChangeCallback
85
+ });
86
+
87
+ const state = simplifiedClient.getVGFState();
88
+ // Should have generated a new UUID
89
+ expect(state.audioUtteranceId).toBeDefined();
90
+ expect(state.audioUtteranceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
91
+ // Should preserve other fields
92
+ expect(state.startRecordingStatus).toBe(RecordingStatus.READY);
93
+ expect(state.transcriptionStatus).toBe(TranscriptionStatus.NOT_STARTED);
94
+
95
+ // onStateChange should be called with the new UUID
96
+ expect(stateChangeCallback).toHaveBeenCalledTimes(1);
97
+ const callbackState = stateChangeCallback.mock.calls[0][0];
98
+ expect(callbackState.audioUtteranceId).toBe(state.audioUtteranceId);
99
+ });
100
+
101
+ it('should generate new UUID when initial state has empty audioUtteranceId', () => {
102
+ const initialState: RecognitionState = {
103
+ audioUtteranceId: '', // Empty UUID
104
+ startRecordingStatus: RecordingStatus.READY,
105
+ transcriptionStatus: TranscriptionStatus.NOT_STARTED,
106
+ pendingTranscript: ''
107
+ };
108
+
109
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
110
+ initialState,
111
+ asrRequestConfig: {
112
+ provider: 'deepgram',
113
+ language: 'en',
114
+ sampleRate: 16000,
115
+ encoding: AudioEncoding.LINEAR16
116
+ },
117
+ onStateChange: stateChangeCallback
118
+ });
119
+
120
+ const state = simplifiedClient.getVGFState();
121
+ // Should have generated a new UUID
122
+ expect(state.audioUtteranceId).toBeDefined();
123
+ expect(state.audioUtteranceId).not.toBe('');
124
+ expect(state.audioUtteranceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
125
+ });
126
+
127
+ it('should accept initial state and use its audioUtteranceId', () => {
128
+ const initialState: RecognitionState = {
129
+ audioUtteranceId: 'existing-session-id',
130
+ startRecordingStatus: RecordingStatus.FINISHED,
131
+ finalTranscript: 'Previous transcript',
132
+ pendingTranscript: '', // Required field
133
+ transcriptionStatus: TranscriptionStatus.FINALIZED
134
+ };
135
+
136
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
137
+ initialState,
138
+ onStateChange: stateChangeCallback
139
+ });
140
+
141
+ const state = simplifiedClient.getVGFState();
142
+ // FINALIZED session gets new UUID to prevent server session reuse
143
+ expect(state.audioUtteranceId).not.toBe('existing-session-id');
144
+ expect(state.audioUtteranceId).toBeDefined();
145
+ // finalTranscript is cleared for fresh session
146
+ expect(state.finalTranscript).toBeUndefined();
147
+ // Statuses reset for fresh session
148
+ expect(state.transcriptionStatus).toBe(TranscriptionStatus.NOT_STARTED);
149
+ expect(state.startRecordingStatus).toBe(RecordingStatus.READY);
150
+
151
+ // Verify NEW audioUtteranceId was passed to underlying client
152
+ const constructorCalls = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls;
153
+ expect(constructorCalls[0]?.[0]?.audioUtteranceId).not.toBe('existing-session-id');
154
+ expect(constructorCalls[0]?.[0]?.audioUtteranceId).toBe(state.audioUtteranceId);
155
+ });
156
+
157
+ it('should store ASR config as JSON string', () => {
158
+ const asrConfig = {
159
+ provider: 'deepgram' as const,
160
+ language: 'en',
161
+ model: 'nova-2',
162
+ sampleRate: 16000,
163
+ encoding: AudioEncoding.LINEAR16
164
+ };
165
+
166
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
167
+ asrRequestConfig: asrConfig,
168
+ onStateChange: stateChangeCallback
169
+ });
170
+
171
+ const state = simplifiedClient.getVGFState();
172
+ expect(state.asrConfig).toBe(JSON.stringify(asrConfig));
173
+ });
174
+ });
175
+
176
+ describe('State Management', () => {
177
+ beforeEach(() => {
178
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
179
+ asrRequestConfig: {
180
+ provider: 'deepgram',
181
+ language: 'en',
182
+ sampleRate: 16000,
183
+ encoding: AudioEncoding.LINEAR16
184
+ },
185
+ onStateChange: stateChangeCallback
186
+ });
187
+ });
188
+
189
+ it('should update state to RECORDING when sendAudio is called', () => {
190
+ const audioData = Buffer.from([1, 2, 3, 4]);
191
+ simplifiedClient.sendAudio(audioData);
192
+
193
+ expect(stateChangeCallback).toHaveBeenCalled();
194
+ const updatedState = stateChangeCallback.mock.calls[0][0];
195
+ expect(updatedState.startRecordingStatus).toBe(RecordingStatus.RECORDING);
196
+ expect(updatedState.startRecordingTimestamp).toBeDefined();
197
+ });
198
+
199
+ it('should only set recording timestamp once', () => {
200
+ const audioData = Buffer.from([1, 2, 3, 4]);
201
+
202
+ simplifiedClient.sendAudio(audioData);
203
+ const firstTimestamp = stateChangeCallback.mock.calls[0][0].startRecordingTimestamp;
204
+
205
+ // Clear mock to verify no additional state changes
206
+ stateChangeCallback.mockClear();
207
+
208
+ // Second sendAudio should not trigger state change since already recording
209
+ simplifiedClient.sendAudio(audioData);
210
+ expect(stateChangeCallback).not.toHaveBeenCalled();
211
+
212
+ // Verify timestamp hasn't changed in internal state
213
+ const currentState = simplifiedClient.getVGFState();
214
+ expect(currentState.startRecordingTimestamp).toBe(firstTimestamp);
215
+ });
216
+
217
+ it('should update state to FINISHED when stopRecording is called', async () => {
218
+ await simplifiedClient.stopRecording();
219
+
220
+ expect(stateChangeCallback).toHaveBeenCalled();
221
+ const updatedState = stateChangeCallback.mock.calls[0][0];
222
+ expect(updatedState.startRecordingStatus).toBe(RecordingStatus.FINISHED);
223
+ expect(updatedState.finalRecordingTimestamp).toBeDefined();
224
+ });
225
+ });
226
+
227
+ describe('Transcript Callbacks', () => {
228
+ let onTranscriptCallback: (result: any) => void;
229
+ let onMetadataCallback: (metadata: any) => void;
230
+ let onErrorCallback: (error: any) => void;
231
+
232
+ beforeEach(() => {
233
+ // Capture the callbacks passed to underlying client
234
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
235
+ asrRequestConfig: {
236
+ provider: 'deepgram',
237
+ language: 'en',
238
+ sampleRate: 16000,
239
+ encoding: AudioEncoding.LINEAR16
240
+ },
241
+ onStateChange: stateChangeCallback
242
+ });
243
+
244
+ const constructorCall = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls[0]?.[0];
245
+ onTranscriptCallback = constructorCall?.onTranscript ?? jest.fn();
246
+ onMetadataCallback = constructorCall?.onMetadata ?? jest.fn();
247
+ onErrorCallback = constructorCall?.onError ?? jest.fn();
248
+ });
249
+
250
+ it('should directly copy pending transcript without combining', () => {
251
+ const transcriptResult = {
252
+ finalTranscript: 'Hello',
253
+ pendingTranscript: ' world',
254
+ pendingTranscriptConfidence: 0.85,
255
+ finalTranscriptConfidence: 0.95,
256
+ is_finished: false
257
+ };
258
+
259
+ onTranscriptCallback(transcriptResult);
260
+
261
+ expect(stateChangeCallback).toHaveBeenCalled();
262
+ const updatedState = stateChangeCallback.mock.calls[0][0];
263
+ expect(updatedState.transcriptionStatus).toBe(TranscriptionStatus.IN_PROGRESS);
264
+ // Should be direct copy, NOT combined
265
+ expect(updatedState.pendingTranscript).toBe(' world');
266
+ expect(updatedState.pendingConfidence).toBe(0.85);
267
+ // Final should also be copied when present
268
+ expect(updatedState.finalTranscript).toBe('Hello');
269
+ expect(updatedState.finalConfidence).toBe(0.95);
270
+ });
271
+
272
+ it('should update VGF state with final transcript', () => {
273
+ const transcriptResult = {
274
+ finalTranscript: 'Hello world',
275
+ finalTranscriptConfidence: 0.98,
276
+ is_finished: true
277
+ };
278
+
279
+ onTranscriptCallback(transcriptResult);
280
+
281
+ expect(stateChangeCallback).toHaveBeenCalled();
282
+ const updatedState = stateChangeCallback.mock.calls[0][0];
283
+ expect(updatedState.transcriptionStatus).toBe(TranscriptionStatus.FINALIZED);
284
+ expect(updatedState.finalTranscript).toBe('Hello world');
285
+ expect(updatedState.finalConfidence).toBe(0.98);
286
+ expect(updatedState.pendingTranscript).toBe('');
287
+ expect(updatedState.finalTranscriptionTimestamp).toBeDefined();
288
+ });
289
+
290
+ it('should pass metadata to callback without updating VGF state', () => {
291
+ // Get the actual UUID from the client
292
+ const actualUuid = simplifiedClient.getVGFState().audioUtteranceId;
293
+ const originalOnMetadata = jest.fn();
294
+
295
+ // Create new client with onMetadata callback
296
+ const clientWithMetadata = new SimplifiedVGFRecognitionClient({
297
+ asrRequestConfig: {
298
+ provider: 'deepgram',
299
+ language: 'en',
300
+ sampleRate: 16000,
301
+ encoding: AudioEncoding.LINEAR16
302
+ },
303
+ onStateChange: stateChangeCallback,
304
+ onMetadata: originalOnMetadata
305
+ });
306
+
307
+ const constructorCalls = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls;
308
+ const latestConfig = constructorCalls[constructorCalls.length - 1]?.[0];
309
+ const metadataCallback = latestConfig?.onMetadata;
310
+
311
+ const clientUuid = clientWithMetadata.getVGFState().audioUtteranceId;
312
+ const metadata = {
313
+ type: RecognitionResultTypeV1.METADATA as const,
314
+ audioUtteranceId: clientUuid
315
+ };
316
+
317
+ // Clear previous state changes from client creation
318
+ stateChangeCallback.mockClear();
319
+
320
+ metadataCallback?.(metadata);
321
+
322
+ // Metadata should NOT trigger state change (state management simplified)
323
+ expect(stateChangeCallback).not.toHaveBeenCalled();
324
+
325
+ // But original callback should still be called
326
+ expect(originalOnMetadata).toHaveBeenCalledWith(metadata);
327
+ });
328
+
329
+ it('should handle errors and update state', () => {
330
+ const error = {
331
+ message: 'Recognition failed',
332
+ code: 'RECOGNITION_ERROR'
333
+ };
334
+
335
+ onErrorCallback(error);
336
+
337
+ expect(stateChangeCallback).toHaveBeenCalled();
338
+ const updatedState = stateChangeCallback.mock.calls[0][0];
339
+ expect(updatedState.transcriptionStatus).toBe(TranscriptionStatus.ERROR);
340
+ expect(updatedState.startRecordingStatus).toBe(RecordingStatus.FINISHED);
341
+ });
342
+
343
+ it('should reset isRecordingAudio on error', () => {
344
+ // First start recording
345
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
346
+
347
+ // Then error occurs
348
+ onErrorCallback({ message: 'Error' });
349
+
350
+ // After terminal status (ERROR), no more state changes should be emitted
351
+ // The session is considered over, so sendAudio should not trigger callbacks
352
+ stateChangeCallback.mockClear();
353
+ simplifiedClient.sendAudio(Buffer.from([4, 5, 6]));
354
+
355
+ // No callback should be triggered since we're in terminal state
356
+ expect(stateChangeCallback).not.toHaveBeenCalled();
357
+
358
+ // However, the internal isRecordingAudio flag should still be reset
359
+ // (verified by the fact that the flag was set during the first sendAudio)
360
+ const state = simplifiedClient.getVGFState();
361
+ expect(state.transcriptionStatus).toBe(TranscriptionStatus.ERROR);
362
+ });
363
+ });
364
+
365
+ describe('Method Delegation', () => {
366
+ beforeEach(() => {
367
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
368
+ asrRequestConfig: {
369
+ provider: 'deepgram',
370
+ language: 'en',
371
+ sampleRate: 16000,
372
+ encoding: AudioEncoding.LINEAR16
373
+ }
374
+ });
375
+ });
376
+
377
+ it('should delegate connect() to underlying client', async () => {
378
+ await simplifiedClient.connect();
379
+ expect(mockClient.connect).toHaveBeenCalled();
380
+ });
381
+
382
+ it('should delegate sendAudio() to underlying client', () => {
383
+ const audioData = Buffer.from([1, 2, 3, 4]);
384
+ simplifiedClient.sendAudio(audioData);
385
+ expect(mockClient.sendAudio).toHaveBeenCalledWith(audioData);
386
+ });
387
+
388
+ it('should delegate sendAudio() with Blob to underlying client', () => {
389
+ const blob = new Blob([new Uint8Array([1, 2, 3, 4])]);
390
+ simplifiedClient.sendAudio(blob);
391
+ expect(mockClient.sendAudio).toHaveBeenCalledWith(blob);
392
+ });
393
+
394
+ it('should delegate stopRecording() to underlying client', async () => {
395
+ await simplifiedClient.stopRecording();
396
+ expect(mockClient.stopRecording).toHaveBeenCalled();
397
+ });
398
+
399
+ it('should delegate status check methods', () => {
400
+ simplifiedClient.isConnected();
401
+ expect(mockClient.isConnected).toHaveBeenCalled();
402
+
403
+ simplifiedClient.isConnecting();
404
+ expect(mockClient.isConnecting).toHaveBeenCalled();
405
+
406
+ simplifiedClient.isStopping();
407
+ expect(mockClient.isStopping).toHaveBeenCalled();
408
+
409
+ simplifiedClient.isTranscriptionFinished();
410
+ expect(mockClient.isTranscriptionFinished).toHaveBeenCalled();
411
+
412
+ simplifiedClient.isBufferOverflowing();
413
+ expect(mockClient.isBufferOverflowing).toHaveBeenCalled();
414
+ });
415
+
416
+ it('should delegate getAudioUtteranceId()', () => {
417
+ const id = simplifiedClient.getAudioUtteranceId();
418
+ expect(mockClient.getAudioUtteranceId).toHaveBeenCalled();
419
+ expect(id).toBe('test-uuid');
420
+ });
421
+
422
+ it('should delegate getState()', () => {
423
+ const state = simplifiedClient.getState();
424
+ expect(mockClient.getState).toHaveBeenCalled();
425
+ expect(state).toBe(ClientState.INITIAL);
426
+ });
427
+ });
428
+
429
+ describe('Original Callbacks', () => {
430
+ it('should call original onTranscript callback if provided', () => {
431
+ const originalOnTranscript = jest.fn();
432
+
433
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
434
+ asrRequestConfig: {
435
+ provider: 'deepgram',
436
+ language: 'en',
437
+ sampleRate: 16000,
438
+ encoding: AudioEncoding.LINEAR16
439
+ },
440
+ onTranscript: originalOnTranscript
441
+ });
442
+
443
+ const constructorCall = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls[0]?.[0];
444
+ const wrappedCallback = constructorCall?.onTranscript;
445
+ if (!wrappedCallback) throw new Error('onTranscript callback not found');
446
+
447
+ const result = { finalTranscript: 'test', is_finished: false };
448
+ wrappedCallback(result as any);
449
+
450
+ expect(originalOnTranscript).toHaveBeenCalledWith(result);
451
+ });
452
+
453
+ it('should call original onError callback if provided', () => {
454
+ const originalOnError = jest.fn();
455
+
456
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
457
+ asrRequestConfig: {
458
+ provider: 'deepgram',
459
+ language: 'en',
460
+ sampleRate: 16000,
461
+ encoding: AudioEncoding.LINEAR16
462
+ },
463
+ onError: originalOnError
464
+ });
465
+
466
+ const constructorCall = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls[0]?.[0];
467
+ const wrappedCallback = constructorCall?.onError;
468
+ if (!wrappedCallback) throw new Error('onError callback not found');
469
+
470
+ const error = { message: 'test error' };
471
+ wrappedCallback(error as any);
472
+
473
+ expect(originalOnError).toHaveBeenCalledWith(error);
474
+ });
475
+ });
476
+
477
+ describe('Thin Layer Verification', () => {
478
+ it('should pass transcript result directly to mapper without modification', () => {
479
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
480
+ asrRequestConfig: {
481
+ provider: 'deepgram',
482
+ language: 'en',
483
+ sampleRate: 16000,
484
+ encoding: AudioEncoding.LINEAR16
485
+ },
486
+ onStateChange: stateChangeCallback
487
+ });
488
+
489
+ // Get actual UUID from client
490
+ const actualUuid = simplifiedClient.getVGFState().audioUtteranceId;
491
+
492
+ const transcriptResult = {
493
+ type: 'Transcription',
494
+ audioUtteranceId: actualUuid,
495
+ finalTranscript: 'Final text',
496
+ pendingTranscript: 'Pending text',
497
+ finalTranscriptConfidence: 0.99,
498
+ pendingTranscriptConfidence: 0.88,
499
+ is_finished: false,
500
+ voiceStart: 100,
501
+ voiceDuration: 500,
502
+ voiceEnd: 600,
503
+ extraField: 'should be ignored by VGF state'
504
+ };
505
+
506
+ const constructorCall = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls[0]?.[0];
507
+ const wrappedCallback = constructorCall?.onTranscript;
508
+ if (!wrappedCallback) throw new Error('onTranscript callback not found');
509
+
510
+ wrappedCallback(transcriptResult as any);
511
+
512
+ const updatedState = stateChangeCallback.mock.calls[0][0];
513
+ // Verify direct copy without modification
514
+ expect(updatedState.pendingTranscript).toBe('Pending text');
515
+ expect(updatedState.finalTranscript).toBe('Final text');
516
+ expect(updatedState.pendingConfidence).toBe(0.88);
517
+ expect(updatedState.finalConfidence).toBe(0.99);
518
+ });
519
+
520
+ it('should handle only pending transcript correctly', () => {
521
+ const transcriptResult = {
522
+ pendingTranscript: 'Just pending',
523
+ pendingTranscriptConfidence: 0.75,
524
+ is_finished: false
525
+ };
526
+
527
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
528
+ asrRequestConfig: {
529
+ provider: 'deepgram',
530
+ language: 'en',
531
+ sampleRate: 16000,
532
+ encoding: AudioEncoding.LINEAR16
533
+ },
534
+ onStateChange: stateChangeCallback
535
+ });
536
+
537
+ const constructorCall = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls[0]?.[0];
538
+ const wrappedCallback = constructorCall?.onTranscript;
539
+ if (!wrappedCallback) throw new Error('onTranscript callback not found');
540
+
541
+ wrappedCallback(transcriptResult as any);
542
+
543
+ const updatedState = stateChangeCallback.mock.calls[0][0];
544
+ expect(updatedState.pendingTranscript).toBe('Just pending');
545
+ expect(updatedState.pendingConfidence).toBe(0.75);
546
+ expect(updatedState.finalTranscript).toBeUndefined();
547
+ expect(updatedState.finalConfidence).toBeUndefined();
548
+ });
549
+
550
+ it('should handle only final transcript correctly', () => {
551
+ const transcriptResult = {
552
+ finalTranscript: 'Just final',
553
+ finalTranscriptConfidence: 0.92,
554
+ is_finished: false
555
+ };
556
+
557
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
558
+ asrRequestConfig: {
559
+ provider: 'deepgram',
560
+ language: 'en',
561
+ sampleRate: 16000,
562
+ encoding: AudioEncoding.LINEAR16
563
+ },
564
+ onStateChange: stateChangeCallback
565
+ });
566
+
567
+ const constructorCall = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls[0]?.[0];
568
+ const wrappedCallback = constructorCall?.onTranscript;
569
+ if (!wrappedCallback) throw new Error('onTranscript callback not found');
570
+
571
+ wrappedCallback(transcriptResult as any);
572
+
573
+ const updatedState = stateChangeCallback.mock.calls[0][0];
574
+ expect(updatedState.pendingTranscript).toBe(''); // Empty string when undefined
575
+ expect(updatedState.pendingConfidence).toBeUndefined();
576
+ expect(updatedState.finalTranscript).toBe('Just final');
577
+ expect(updatedState.finalConfidence).toBe(0.92);
578
+ });
579
+
580
+ it('should clear pending when is_finished is true', () => {
581
+ const transcriptResult = {
582
+ finalTranscript: 'Complete transcript',
583
+ pendingTranscript: 'Should be ignored',
584
+ finalTranscriptConfidence: 0.98,
585
+ pendingTranscriptConfidence: 0.77,
586
+ is_finished: true
587
+ };
588
+
589
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
590
+ asrRequestConfig: {
591
+ provider: 'deepgram',
592
+ language: 'en',
593
+ sampleRate: 16000,
594
+ encoding: AudioEncoding.LINEAR16
595
+ },
596
+ onStateChange: stateChangeCallback
597
+ });
598
+
599
+ const constructorCall = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls[0]?.[0];
600
+ const wrappedCallback = constructorCall?.onTranscript;
601
+ if (!wrappedCallback) throw new Error('onTranscript callback not found');
602
+
603
+ wrappedCallback(transcriptResult as any);
604
+
605
+ const updatedState = stateChangeCallback.mock.calls[0][0];
606
+ expect(updatedState.finalTranscript).toBe('Complete transcript');
607
+ expect(updatedState.finalConfidence).toBe(0.98);
608
+ // Pending should be cleared when finished
609
+ expect(updatedState.pendingTranscript).toBe('');
610
+ expect(updatedState.pendingConfidence).toBeUndefined();
611
+ expect(updatedState.transcriptionStatus).toBe(TranscriptionStatus.FINALIZED);
612
+ });
613
+ });
614
+
615
+ describe('Factory Function', () => {
616
+ it('should create SimplifiedVGFRecognitionClient instance', () => {
617
+ const client = createSimplifiedVGFClient({
618
+ asrRequestConfig: {
619
+ provider: 'deepgram',
620
+ language: 'en',
621
+ sampleRate: 16000,
622
+ encoding: AudioEncoding.LINEAR16
623
+ }
624
+ });
625
+
626
+ expect(client).toBeInstanceOf(SimplifiedVGFRecognitionClient);
627
+ expect(client.getVGFState).toBeDefined();
628
+ expect(client.connect).toBeDefined();
629
+ });
630
+
631
+ it('should auto-generate new UUID for ABORTED session and reset fields', () => {
632
+ const stateChangeCallback = jest.fn();
633
+ const abortedState: RecognitionState = {
634
+ audioUtteranceId: 'old-aborted-uuid',
635
+ transcriptionStatus: TranscriptionStatus.ABORTED,
636
+ startRecordingStatus: RecordingStatus.FINISHED,
637
+ pendingTranscript: '',
638
+ finalTranscript: 'old transcript from aborted session'
639
+ };
640
+
641
+ const client = createSimplifiedVGFClient({
642
+ initialState: abortedState,
643
+ onStateChange: stateChangeCallback,
644
+ asrRequestConfig: {
645
+ provider: 'deepgram',
646
+ language: 'en',
647
+ sampleRate: 16000,
648
+ encoding: AudioEncoding.LINEAR16
649
+ }
650
+ });
651
+
652
+ // Should have called callback with new UUID
653
+ expect(stateChangeCallback).toHaveBeenCalledTimes(1);
654
+ const newState = stateChangeCallback.mock.calls[0][0];
655
+
656
+ // New UUID should be different
657
+ expect(newState.audioUtteranceId).not.toBe('old-aborted-uuid');
658
+ expect(newState.audioUtteranceId).toBeDefined();
659
+
660
+ // Status fields should be reset for fresh session
661
+ expect(newState.transcriptionStatus).toBe(TranscriptionStatus.NOT_STARTED);
662
+ expect(newState.startRecordingStatus).toBe(RecordingStatus.READY);
663
+
664
+ // Previous transcript should be cleared
665
+ expect(newState.finalTranscript).toBeUndefined();
666
+
667
+ // Client should use the new UUID
668
+ expect(client.getVGFState().audioUtteranceId).toBe(newState.audioUtteranceId);
669
+ });
670
+
671
+ it('should auto-generate new UUID for FINALIZED session and reset fields', () => {
672
+ const stateChangeCallback = jest.fn();
673
+ const finalizedState: RecognitionState = {
674
+ audioUtteranceId: 'old-finalized-uuid',
675
+ transcriptionStatus: TranscriptionStatus.FINALIZED,
676
+ startRecordingStatus: RecordingStatus.FINISHED,
677
+ pendingTranscript: '',
678
+ finalTranscript: 'completed transcript from previous session'
679
+ };
680
+
681
+ const client = createSimplifiedVGFClient({
682
+ initialState: finalizedState,
683
+ onStateChange: stateChangeCallback,
684
+ asrRequestConfig: {
685
+ provider: 'deepgram',
686
+ language: 'en',
687
+ sampleRate: 16000,
688
+ encoding: AudioEncoding.LINEAR16
689
+ }
690
+ });
691
+
692
+ // Should have generated new UUID
693
+ expect(stateChangeCallback).toHaveBeenCalledTimes(1);
694
+ const newState = stateChangeCallback.mock.calls[0][0];
695
+
696
+ expect(newState.audioUtteranceId).not.toBe('old-finalized-uuid');
697
+ expect(newState.transcriptionStatus).toBe(TranscriptionStatus.NOT_STARTED);
698
+ expect(newState.startRecordingStatus).toBe(RecordingStatus.READY);
699
+ expect(newState.finalTranscript).toBeUndefined();
700
+ });
701
+
702
+ it('should preserve UUID for IN_PROGRESS session (valid resumption)', () => {
703
+ const stateChangeCallback = jest.fn();
704
+ const inProgressState: RecognitionState = {
705
+ audioUtteranceId: 'in-progress-uuid',
706
+ transcriptionStatus: TranscriptionStatus.IN_PROGRESS,
707
+ startRecordingStatus: RecordingStatus.RECORDING,
708
+ pendingTranscript: 'partial text'
709
+ };
710
+
711
+ const client = createSimplifiedVGFClient({
712
+ initialState: inProgressState,
713
+ onStateChange: stateChangeCallback,
714
+ asrRequestConfig: {
715
+ provider: 'deepgram',
716
+ language: 'en',
717
+ sampleRate: 16000,
718
+ encoding: AudioEncoding.LINEAR16
719
+ }
720
+ });
721
+
722
+ // Should NOT generate new UUID (valid reconnection)
723
+ const currentState = client.getVGFState();
724
+ expect(currentState.audioUtteranceId).toBe('in-progress-uuid');
725
+ expect(currentState.transcriptionStatus).toBe(TranscriptionStatus.IN_PROGRESS);
726
+ });
727
+ });
728
+
729
+ describe('PromptSlotMap Integration', () => {
730
+ it('should pass promptSlotMap from initial state to gameContext', () => {
731
+ const initialState: RecognitionState = {
732
+ audioUtteranceId: 'test-123',
733
+ pendingTranscript: '', // Required field
734
+ promptSlotMap: {
735
+ 'entity1': ['value1', 'value2'],
736
+ 'entity2': ['value3']
737
+ }
738
+ };
739
+
740
+ const gameContext = {
741
+ type: RecognitionContextTypeV1.GAME_CONTEXT,
742
+ gameId: 'test-game',
743
+ gamePhase: 'test-phase'
744
+ } as const;
745
+
746
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
747
+ asrRequestConfig: {
748
+ provider: 'deepgram',
749
+ language: 'en',
750
+ sampleRate: 16000,
751
+ encoding: AudioEncoding.LINEAR16
752
+ },
753
+ gameContext,
754
+ initialState
755
+ });
756
+
757
+ const constructorCall = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls[0]?.[0];
758
+
759
+ // Verify useContext was set to true
760
+ expect(constructorCall?.asrRequestConfig?.useContext).toBe(true);
761
+
762
+ // Verify slotMap was added to gameContext
763
+ expect(constructorCall?.gameContext?.slotMap).toEqual({
764
+ 'entity1': ['value1', 'value2'],
765
+ 'entity2': ['value3']
766
+ });
767
+ });
768
+
769
+ it('should warn if promptSlotMap exists but no gameContext provided', () => {
770
+ const logger = jest.fn();
771
+ const initialState: RecognitionState = {
772
+ audioUtteranceId: 'test-123',
773
+ pendingTranscript: '', // Required field
774
+ promptSlotMap: {
775
+ 'entity1': ['value1']
776
+ }
777
+ };
778
+
779
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
780
+ asrRequestConfig: {
781
+ provider: 'deepgram',
782
+ language: 'en',
783
+ sampleRate: 16000,
784
+ encoding: AudioEncoding.LINEAR16
785
+ },
786
+ initialState,
787
+ logger
788
+ });
789
+
790
+ expect(logger).toHaveBeenCalledWith(
791
+ 'warn',
792
+ '[VGF] promptSlotMap found but no gameContext provided. SlotMap will not be sent.'
793
+ );
794
+ });
795
+
796
+ it('should preserve promptSlotMap throughout state changes', () => {
797
+ const initialState: RecognitionState = {
798
+ audioUtteranceId: 'test-123',
799
+ pendingTranscript: '', // Required field
800
+ promptSlotMap: {
801
+ 'slots': ['test']
802
+ }
803
+ };
804
+
805
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
806
+ asrRequestConfig: {
807
+ provider: 'deepgram',
808
+ language: 'en',
809
+ sampleRate: 16000,
810
+ encoding: AudioEncoding.LINEAR16
811
+ },
812
+ initialState,
813
+ onStateChange: stateChangeCallback
814
+ });
815
+
816
+ // Send audio and verify promptSlotMap is preserved
817
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
818
+
819
+ let state = stateChangeCallback.mock.calls[0][0];
820
+ expect(state.promptSlotMap).toEqual({ 'slots': ['test'] });
821
+
822
+ // Simulate transcript and verify preservation
823
+ const constructorCall = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls[0]?.[0];
824
+ const onTranscriptCallback = constructorCall?.onTranscript;
825
+ if (!onTranscriptCallback) throw new Error('onTranscript callback not found');
826
+
827
+ onTranscriptCallback({
828
+ finalTranscript: 'test',
829
+ is_finished: false
830
+ } as any);
831
+
832
+ state = stateChangeCallback.mock.calls[1][0];
833
+ expect(state.promptSlotMap).toEqual({ 'slots': ['test'] });
834
+ });
835
+ });
836
+
837
+ describe('State Immutability', () => {
838
+ it('should return a copy of VGF state, not a reference', () => {
839
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
840
+ asrRequestConfig: {
841
+ provider: 'deepgram',
842
+ language: 'en',
843
+ sampleRate: 16000,
844
+ encoding: AudioEncoding.LINEAR16
845
+ }
846
+ });
847
+
848
+ const state1 = simplifiedClient.getVGFState();
849
+ const state2 = simplifiedClient.getVGFState();
850
+
851
+ expect(state1).not.toBe(state2); // Different object references
852
+ expect(state1).toEqual(state2); // But same content
853
+ });
854
+
855
+ it('should pass a copy of state to onStateChange callback', () => {
856
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
857
+ asrRequestConfig: {
858
+ provider: 'deepgram',
859
+ language: 'en',
860
+ sampleRate: 16000,
861
+ encoding: AudioEncoding.LINEAR16
862
+ },
863
+ onStateChange: stateChangeCallback
864
+ });
865
+
866
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
867
+
868
+ const callbackState = stateChangeCallback.mock.calls[0][0];
869
+ const currentState = simplifiedClient.getVGFState();
870
+
871
+ expect(callbackState).not.toBe(currentState); // Different references
872
+ });
873
+ });
874
+
875
+ describe('stopAbnormally', () => {
876
+ beforeEach(() => {
877
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
878
+ asrRequestConfig: {
879
+ provider: 'deepgram',
880
+ language: 'en',
881
+ sampleRate: 16000,
882
+ encoding: AudioEncoding.LINEAR16
883
+ },
884
+ onStateChange: stateChangeCallback
885
+ });
886
+ });
887
+
888
+ it('should immediately set state to ABORTED and preserve partial transcript', () => {
889
+ // Start recording first
890
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
891
+ jest.clearAllMocks();
892
+
893
+ // Call stopAbnormally
894
+ simplifiedClient.stopAbnormally();
895
+
896
+ // Verify state was updated to ABORTED (not FINALIZED)
897
+ expect(stateChangeCallback).toHaveBeenCalledTimes(1);
898
+ const finalState = stateChangeCallback.mock.calls[0][0];
899
+
900
+ expect(finalState.transcriptionStatus).toBe(TranscriptionStatus.ABORTED);
901
+ // finalTranscript is preserved (not overridden to empty string)
902
+ expect(finalState.startRecordingStatus).toBe(RecordingStatus.FINISHED);
903
+ expect(finalState.finalRecordingTimestamp).toBeDefined();
904
+ expect(finalState.finalTranscriptionTimestamp).toBeDefined();
905
+ });
906
+
907
+ it('should stop recording audio flag', () => {
908
+ // Start recording
909
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
910
+
911
+ // Call stopAbnormally
912
+ simplifiedClient.stopAbnormally();
913
+
914
+ // Send more audio - should not update recording status again
915
+ jest.clearAllMocks();
916
+ simplifiedClient.sendAudio(Buffer.from([4, 5, 6]));
917
+
918
+ // Verify recording status was set in sendAudio
919
+ const state = simplifiedClient.getVGFState();
920
+ expect(state.startRecordingStatus).toBe(RecordingStatus.RECORDING);
921
+ });
922
+
923
+ it('should be idempotent - calling twice does not change state again', () => {
924
+ // Start recording
925
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
926
+ jest.clearAllMocks();
927
+
928
+ // Call stopAbnormally first time
929
+ simplifiedClient.stopAbnormally();
930
+ expect(stateChangeCallback).toHaveBeenCalledTimes(1);
931
+
932
+ const firstCallState = stateChangeCallback.mock.calls[0][0];
933
+ const firstTranscript = firstCallState.finalTranscript;
934
+ jest.clearAllMocks();
935
+
936
+ // Call stopAbnormally second time
937
+ simplifiedClient.stopAbnormally();
938
+
939
+ // Should not trigger state change callback again (already aborted)
940
+ expect(stateChangeCallback).toHaveBeenCalledTimes(0);
941
+
942
+ const currentState = simplifiedClient.getVGFState();
943
+ expect(currentState.transcriptionStatus).toBe(TranscriptionStatus.ABORTED);
944
+ expect(currentState.finalTranscript).toBe(firstTranscript); // Unchanged
945
+ });
946
+
947
+ it('should work even if called before any recording', () => {
948
+ // Call stopAbnormally without ever recording
949
+ simplifiedClient.stopAbnormally();
950
+
951
+ const state = simplifiedClient.getVGFState();
952
+ expect(state.transcriptionStatus).toBe(TranscriptionStatus.ABORTED);
953
+ expect(state.finalTranscript).toBeUndefined(); // No transcript was ever received
954
+ expect(state.startRecordingStatus).toBe(RecordingStatus.FINISHED);
955
+ });
956
+
957
+ it('should preserve existing state fields except for overridden ones', () => {
958
+ // Set up some initial state by sending audio
959
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
960
+
961
+ const initialState = simplifiedClient.getVGFState();
962
+ const audioUtteranceId = initialState.audioUtteranceId;
963
+ const initialTranscript = initialState.finalTranscript;
964
+
965
+ // Call stopAbnormally
966
+ simplifiedClient.stopAbnormally();
967
+
968
+ const finalState = simplifiedClient.getVGFState();
969
+
970
+ // Should preserve audioUtteranceId, finalTranscript and other non-overridden fields
971
+ expect(finalState.audioUtteranceId).toBe(audioUtteranceId);
972
+ expect(finalState.finalTranscript).toBe(initialTranscript); // Preserved
973
+
974
+ // Should override these fields
975
+ expect(finalState.transcriptionStatus).toBe(TranscriptionStatus.ABORTED);
976
+ expect(finalState.startRecordingStatus).toBe(RecordingStatus.FINISHED);
977
+ });
978
+
979
+ it('should set both recording and transcription timestamps', () => {
980
+ const beforeTime = new Date().toISOString();
981
+
982
+ simplifiedClient.stopAbnormally();
983
+
984
+ const state = simplifiedClient.getVGFState();
985
+ const afterTime = new Date().toISOString();
986
+
987
+ // Timestamps should be set and within reasonable range
988
+ expect(state.finalRecordingTimestamp).toBeDefined();
989
+ expect(state.finalTranscriptionTimestamp).toBeDefined();
990
+
991
+ // Basic sanity check that timestamps are ISO strings
992
+ expect(state.finalRecordingTimestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
993
+ expect(state.finalTranscriptionTimestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
994
+
995
+ // Timestamps should be close to current time
996
+ if (state.finalRecordingTimestamp) {
997
+ expect(state.finalRecordingTimestamp >= beforeTime).toBe(true);
998
+ expect(state.finalRecordingTimestamp <= afterTime).toBe(true);
999
+ }
1000
+ });
1001
+
1002
+ it('should call underlying client stopAbnormally for cleanup', () => {
1003
+ simplifiedClient.stopAbnormally();
1004
+
1005
+ // stopAbnormally on underlying client SHOULD be called for WebSocket cleanup
1006
+ expect(mockClient.stopAbnormally).toHaveBeenCalled();
1007
+
1008
+ // stopRecording on underlying client should NOT be called
1009
+ expect(mockClient.stopRecording).not.toHaveBeenCalled();
1010
+ });
1011
+
1012
+ it('should differ from stopRecording behavior', async () => {
1013
+ // Test that stopAbnormally and stopRecording behave differently
1014
+ jest.clearAllMocks();
1015
+
1016
+ // Use the existing simplifiedClient for testing
1017
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
1018
+
1019
+ // Test stopAbnormally - should NOT call underlying client
1020
+ simplifiedClient.stopAbnormally();
1021
+ expect(mockClient.stopRecording).not.toHaveBeenCalled();
1022
+
1023
+ // Create new client to test stopRecording
1024
+ const client2 = new SimplifiedVGFRecognitionClient({
1025
+ asrRequestConfig: {
1026
+ provider: 'deepgram',
1027
+ language: 'en',
1028
+ sampleRate: 16000,
1029
+ encoding: AudioEncoding.LINEAR16
1030
+ },
1031
+ onStateChange: jest.fn()
1032
+ });
1033
+
1034
+ // Clear mocks to isolate client2's behavior
1035
+ jest.clearAllMocks();
1036
+
1037
+ // Test stopRecording - SHOULD call underlying client
1038
+ await client2.stopRecording();
1039
+ expect(mockClient.stopRecording).toHaveBeenCalled();
1040
+ });
1041
+
1042
+ it('should use ABORTED status to distinguish from normal completion', () => {
1043
+ // Test that stopAbnormally uses ABORTED, not FINALIZED
1044
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
1045
+
1046
+ // Abnormal stop - should set to ABORTED
1047
+ simplifiedClient.stopAbnormally();
1048
+ const abortedState = simplifiedClient.getVGFState();
1049
+
1050
+ // Verify ABORTED is used (not FINALIZED)
1051
+ expect(abortedState.transcriptionStatus).toBe(TranscriptionStatus.ABORTED);
1052
+ expect(abortedState.transcriptionStatus).not.toBe(TranscriptionStatus.FINALIZED);
1053
+ // finalTranscript is preserved (whatever partial transcript was received)
1054
+
1055
+ // ABORTED clearly indicates user cancelled, vs FINALIZED which means completed normally
1056
+ });
1057
+
1058
+ describe('state guards', () => {
1059
+ it('should do nothing if already fully stopped', () => {
1060
+ // Setup: finalize state and mark underlying client as stopped
1061
+ mockClient.getState.mockReturnValue(ClientState.STOPPED);
1062
+ simplifiedClient.stopAbnormally();
1063
+
1064
+ // Clear mocks to test second call
1065
+ jest.clearAllMocks();
1066
+
1067
+ // Call again - should return early and not call anything
1068
+ simplifiedClient.stopAbnormally();
1069
+
1070
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1071
+ expect(mockClient.stopAbnormally).not.toHaveBeenCalled();
1072
+ });
1073
+
1074
+ it('should not call underlying client if already in STOPPED state', () => {
1075
+ // Mock underlying client as already stopped
1076
+ mockClient.getState.mockReturnValue(ClientState.STOPPED);
1077
+
1078
+ // But VGF state not finalized yet
1079
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
1080
+ jest.clearAllMocks();
1081
+
1082
+ simplifiedClient.stopAbnormally();
1083
+
1084
+ // Should be blocked completely - no state change, no underlying call
1085
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1086
+ expect(mockClient.stopAbnormally).not.toHaveBeenCalled();
1087
+ });
1088
+
1089
+ it('should not call underlying client if already in FAILED state', () => {
1090
+ // Mock underlying client as failed
1091
+ mockClient.getState.mockReturnValue(ClientState.FAILED);
1092
+
1093
+ simplifiedClient.stopAbnormally();
1094
+
1095
+ // Should NOT update VGF state or call underlying client
1096
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1097
+ expect(mockClient.stopAbnormally).not.toHaveBeenCalled();
1098
+ });
1099
+
1100
+ it('should block if client is in STOPPING state (graceful shutdown in progress)', () => {
1101
+ // Start recording first
1102
+ simplifiedClient.sendAudio(Buffer.from([1, 2, 3]));
1103
+
1104
+ // Get initial state before attempting stopAbnormally
1105
+ const initialState = simplifiedClient.getVGFState();
1106
+ const initialStatus = initialState.transcriptionStatus;
1107
+
1108
+ // Mock underlying client as STOPPING (stopRecording was called)
1109
+ mockClient.getState.mockReturnValue(ClientState.STOPPING);
1110
+ jest.clearAllMocks();
1111
+
1112
+ // Try to call stopAbnormally while graceful shutdown in progress
1113
+ simplifiedClient.stopAbnormally();
1114
+
1115
+ // Should be blocked - no state change, no underlying call
1116
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1117
+ expect(mockClient.stopAbnormally).not.toHaveBeenCalled();
1118
+
1119
+ // VGF state should remain unchanged (not changed to ABORTED)
1120
+ const state = simplifiedClient.getVGFState();
1121
+ expect(state.transcriptionStatus).toBe(initialStatus);
1122
+ expect(state.transcriptionStatus).not.toBe(TranscriptionStatus.ABORTED);
1123
+ });
1124
+
1125
+ it('should only update VGF state if already finalized but client not stopped', () => {
1126
+ // First call - fully stop
1127
+ simplifiedClient.stopAbnormally();
1128
+ const firstCallCount = stateChangeCallback.mock.calls.length;
1129
+
1130
+ // Mock underlying client reconnects (edge case)
1131
+ mockClient.getState.mockReturnValue(ClientState.READY);
1132
+ jest.clearAllMocks();
1133
+
1134
+ // Second call - VGF already finalized but client not stopped
1135
+ simplifiedClient.stopAbnormally();
1136
+
1137
+ // Should NOT update VGF state (already finalized)
1138
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1139
+ // But SHOULD call underlying client (not stopped)
1140
+ expect(mockClient.stopAbnormally).toHaveBeenCalled();
1141
+ });
1142
+ });
1143
+ });
1144
+
1145
+ describe('UUID Change Detection', () => {
1146
+ it('should skip onStateChange callback when UUID changes by default', () => {
1147
+ // Create client with initial state
1148
+ const initialState: RecognitionState = {
1149
+ audioUtteranceId: 'session-123',
1150
+ startRecordingStatus: RecordingStatus.READY,
1151
+ transcriptionStatus: TranscriptionStatus.NOT_STARTED,
1152
+ pendingTranscript: ''
1153
+ };
1154
+
1155
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
1156
+ initialState,
1157
+ asrRequestConfig: {
1158
+ provider: 'deepgram',
1159
+ language: 'en',
1160
+ sampleRate: 16000,
1161
+ encoding: AudioEncoding.LINEAR16
1162
+ },
1163
+ onStateChange: stateChangeCallback
1164
+ });
1165
+
1166
+ // Get the callbacks that were passed to the underlying client
1167
+ const constructorCalls = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls;
1168
+ const clientConfig = constructorCalls[0]?.[0];
1169
+ const onTranscriptCallback = clientConfig?.onTranscript;
1170
+
1171
+ // Simulate transcript with a different UUID (stale callback from previous session)
1172
+ onTranscriptCallback?.({
1173
+ type: 'transcript',
1174
+ is_finished: false,
1175
+ pendingTranscript: 'test transcript',
1176
+ audioUtteranceId: 'different-uuid-456' // Different UUID
1177
+ } as any);
1178
+
1179
+ // State should NOT be updated - callback should be skipped
1180
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1181
+
1182
+ // Internal state should still have original UUID
1183
+ const state = simplifiedClient.getVGFState();
1184
+ expect(state.audioUtteranceId).toBe('session-123');
1185
+ expect(state.pendingTranscript).toBe(''); // Not updated
1186
+ });
1187
+
1188
+
1189
+ it('should process callbacks with matching UUID', () => {
1190
+ // Create client with initial state
1191
+ const initialState: RecognitionState = {
1192
+ audioUtteranceId: 'session-123',
1193
+ startRecordingStatus: RecordingStatus.READY,
1194
+ transcriptionStatus: TranscriptionStatus.NOT_STARTED,
1195
+ pendingTranscript: ''
1196
+ };
1197
+
1198
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
1199
+ initialState,
1200
+ asrRequestConfig: {
1201
+ provider: 'deepgram',
1202
+ language: 'en',
1203
+ sampleRate: 16000,
1204
+ encoding: AudioEncoding.LINEAR16
1205
+ },
1206
+ onStateChange: stateChangeCallback
1207
+ });
1208
+
1209
+ // Get the callbacks that were passed to the underlying client
1210
+ const constructorCalls = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls;
1211
+ const clientConfig = constructorCalls[0]?.[0];
1212
+ const onTranscriptCallback = clientConfig?.onTranscript;
1213
+
1214
+ // Simulate transcript with matching UUID
1215
+ onTranscriptCallback?.({
1216
+ type: 'transcript',
1217
+ is_finished: false,
1218
+ pendingTranscript: 'test transcript',
1219
+ audioUtteranceId: 'session-123' // Same UUID
1220
+ } as any);
1221
+
1222
+ // State should be updated normally
1223
+ expect(stateChangeCallback).toHaveBeenCalledTimes(1);
1224
+ const updatedState = stateChangeCallback.mock.calls[0][0];
1225
+ expect(updatedState.audioUtteranceId).toBe('session-123');
1226
+ expect(updatedState.pendingTranscript).toBe('test transcript');
1227
+ });
1228
+
1229
+ it('should skip metadata callback with different UUID', () => {
1230
+ // Create client with initial state
1231
+ const initialState: RecognitionState = {
1232
+ audioUtteranceId: 'session-123',
1233
+ startRecordingStatus: RecordingStatus.READY,
1234
+ transcriptionStatus: TranscriptionStatus.NOT_STARTED,
1235
+ pendingTranscript: ''
1236
+ };
1237
+
1238
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
1239
+ initialState,
1240
+ asrRequestConfig: {
1241
+ provider: 'deepgram',
1242
+ language: 'en',
1243
+ sampleRate: 16000,
1244
+ encoding: AudioEncoding.LINEAR16
1245
+ },
1246
+ onStateChange: stateChangeCallback
1247
+ });
1248
+
1249
+ // Get the metadata callback
1250
+ const constructorCalls = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls;
1251
+ const clientConfig = constructorCalls[0]?.[0];
1252
+ const onMetadataCallback = clientConfig?.onMetadata;
1253
+
1254
+ // Simulate metadata with different UUID
1255
+ onMetadataCallback?.({
1256
+ type: 'metadata',
1257
+ event: 'recording_stopped',
1258
+ audioUtteranceId: 'different-uuid-456'
1259
+ } as any);
1260
+
1261
+ // Callback should be skipped
1262
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1263
+ });
1264
+
1265
+ it('should skip error callback with different UUID', () => {
1266
+ // Create client with initial state
1267
+ const initialState: RecognitionState = {
1268
+ audioUtteranceId: 'session-123',
1269
+ startRecordingStatus: RecordingStatus.READY,
1270
+ transcriptionStatus: TranscriptionStatus.NOT_STARTED,
1271
+ pendingTranscript: ''
1272
+ };
1273
+
1274
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
1275
+ initialState,
1276
+ asrRequestConfig: {
1277
+ provider: 'deepgram',
1278
+ language: 'en',
1279
+ sampleRate: 16000,
1280
+ encoding: AudioEncoding.LINEAR16
1281
+ },
1282
+ onStateChange: stateChangeCallback
1283
+ });
1284
+
1285
+ // Get the error callback
1286
+ const constructorCalls = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls;
1287
+ const clientConfig = constructorCalls[0]?.[0];
1288
+ const onErrorCallback = clientConfig?.onError;
1289
+
1290
+ // Simulate error with different UUID
1291
+ onErrorCallback?.({
1292
+ type: 'error',
1293
+ error: 'test error',
1294
+ audioUtteranceId: 'different-uuid-456'
1295
+ } as any);
1296
+
1297
+ // Callback should be skipped
1298
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1299
+ });
1300
+
1301
+ it('should track UUID after terminal state regeneration', () => {
1302
+ // Create client with terminal initial state (forces UUID regeneration)
1303
+ const initialState: RecognitionState = {
1304
+ audioUtteranceId: 'old-session-123',
1305
+ startRecordingStatus: RecordingStatus.FINISHED,
1306
+ transcriptionStatus: TranscriptionStatus.FINALIZED, // Terminal state
1307
+ pendingTranscript: '',
1308
+ finalTranscript: 'Previous transcript'
1309
+ };
1310
+
1311
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
1312
+ initialState,
1313
+ asrRequestConfig: {
1314
+ provider: 'deepgram',
1315
+ language: 'en',
1316
+ sampleRate: 16000,
1317
+ encoding: AudioEncoding.LINEAR16
1318
+ },
1319
+ onStateChange: stateChangeCallback
1320
+ });
1321
+
1322
+ // Get the new UUID that was generated
1323
+ const newState = simplifiedClient.getVGFState();
1324
+ const newUuid = newState.audioUtteranceId;
1325
+ expect(newUuid).not.toBe('old-session-123');
1326
+
1327
+ // Get the transcript callback
1328
+ const constructorCalls = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls;
1329
+ const clientConfig = constructorCalls[0]?.[0];
1330
+ const onTranscriptCallback = clientConfig?.onTranscript;
1331
+
1332
+ // Clear initial state change callback from UUID regeneration
1333
+ jest.clearAllMocks();
1334
+
1335
+ // Simulate transcript with the NEW UUID
1336
+ onTranscriptCallback?.({
1337
+ type: 'transcript',
1338
+ is_finished: false,
1339
+ pendingTranscript: 'new transcript',
1340
+ audioUtteranceId: newUuid // New UUID
1341
+ } as any);
1342
+
1343
+ // Should process normally with new UUID
1344
+ expect(stateChangeCallback).toHaveBeenCalledTimes(1);
1345
+ const updatedState = stateChangeCallback.mock.calls[0][0];
1346
+ expect(updatedState.pendingTranscript).toBe('new transcript');
1347
+
1348
+ // Simulate transcript with OLD UUID (stale callback)
1349
+ jest.clearAllMocks();
1350
+ onTranscriptCallback?.({
1351
+ type: 'transcript',
1352
+ is_finished: false,
1353
+ pendingTranscript: 'stale transcript',
1354
+ audioUtteranceId: 'old-session-123' // Old UUID
1355
+ } as any);
1356
+
1357
+ // Should skip callback with old UUID
1358
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1359
+ });
1360
+ });
1361
+
1362
+ describe('Terminal Status Protection', () => {
1363
+ let stateChangeCallback: jest.Mock;
1364
+ let onTranscriptCallback: (result: any) => void;
1365
+ let onErrorCallback: (error: any) => void;
1366
+ let simplifiedClient: SimplifiedVGFRecognitionClient;
1367
+ let clientUuid: string;
1368
+
1369
+ beforeEach(() => {
1370
+ stateChangeCallback = jest.fn();
1371
+ simplifiedClient = new SimplifiedVGFRecognitionClient({
1372
+ asrRequestConfig: {
1373
+ provider: 'deepgram',
1374
+ language: 'en',
1375
+ sampleRate: 16000,
1376
+ encoding: AudioEncoding.LINEAR16
1377
+ },
1378
+ onStateChange: stateChangeCallback
1379
+ });
1380
+
1381
+ const constructorCalls = (RealTimeTwoWayWebSocketRecognitionClient as jest.MockedClass<typeof RealTimeTwoWayWebSocketRecognitionClient>).mock.calls;
1382
+ const latestConfig = constructorCalls[constructorCalls.length - 1]?.[0];
1383
+ onTranscriptCallback = latestConfig?.onTranscript ?? jest.fn();
1384
+ onErrorCallback = latestConfig?.onError ?? jest.fn();
1385
+ clientUuid = simplifiedClient.getVGFState().audioUtteranceId;
1386
+ });
1387
+
1388
+ it('should allow first terminal transcript callback', () => {
1389
+ onTranscriptCallback({
1390
+ type: 'Transcription',
1391
+ audioUtteranceId: clientUuid,
1392
+ finalTranscript: 'hello',
1393
+ finalTranscriptConfidence: 0.9,
1394
+ is_finished: true
1395
+ });
1396
+
1397
+ expect(stateChangeCallback).toHaveBeenCalledTimes(1);
1398
+ expect(stateChangeCallback.mock.calls[0][0].transcriptionStatus).toBe(TranscriptionStatus.FINALIZED);
1399
+ });
1400
+
1401
+ it('should block second terminal transcript callback with same UUID', () => {
1402
+ // First terminal
1403
+ onTranscriptCallback({
1404
+ type: 'Transcription',
1405
+ audioUtteranceId: clientUuid,
1406
+ finalTranscript: 'first',
1407
+ finalTranscriptConfidence: 0.9,
1408
+ is_finished: true
1409
+ });
1410
+
1411
+ stateChangeCallback.mockClear();
1412
+
1413
+ // Second terminal - should be blocked
1414
+ onTranscriptCallback({
1415
+ type: 'Transcription',
1416
+ audioUtteranceId: clientUuid,
1417
+ finalTranscript: 'second',
1418
+ finalTranscriptConfidence: 0.95,
1419
+ is_finished: true
1420
+ });
1421
+
1422
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1423
+ });
1424
+
1425
+ it('should allow first error callback', () => {
1426
+ onErrorCallback({
1427
+ audioUtteranceId: clientUuid,
1428
+ message: 'Error occurred'
1429
+ });
1430
+
1431
+ expect(stateChangeCallback).toHaveBeenCalledTimes(1);
1432
+ expect(stateChangeCallback.mock.calls[0][0].transcriptionStatus).toBe(TranscriptionStatus.ERROR);
1433
+ });
1434
+
1435
+ it('should block second error callback with same UUID', () => {
1436
+ // First error
1437
+ onErrorCallback({
1438
+ audioUtteranceId: clientUuid,
1439
+ message: 'First error'
1440
+ });
1441
+
1442
+ stateChangeCallback.mockClear();
1443
+
1444
+ // Second error - should be blocked
1445
+ onErrorCallback({
1446
+ audioUtteranceId: clientUuid,
1447
+ message: 'Second error'
1448
+ });
1449
+
1450
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1451
+ });
1452
+
1453
+ it('should block transcript after error', () => {
1454
+ // Error first
1455
+ onErrorCallback({
1456
+ audioUtteranceId: clientUuid,
1457
+ message: 'Error'
1458
+ });
1459
+
1460
+ stateChangeCallback.mockClear();
1461
+
1462
+ // Transcript after error - should be blocked
1463
+ onTranscriptCallback({
1464
+ type: 'Transcription',
1465
+ audioUtteranceId: clientUuid,
1466
+ finalTranscript: 'late',
1467
+ finalTranscriptConfidence: 0.9,
1468
+ is_finished: true
1469
+ });
1470
+
1471
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1472
+ });
1473
+
1474
+ it('should block error after terminal transcript', () => {
1475
+ // Terminal transcript first
1476
+ onTranscriptCallback({
1477
+ type: 'Transcription',
1478
+ audioUtteranceId: clientUuid,
1479
+ finalTranscript: 'done',
1480
+ finalTranscriptConfidence: 0.9,
1481
+ is_finished: true
1482
+ });
1483
+
1484
+ stateChangeCallback.mockClear();
1485
+
1486
+ // Error after terminal - should be blocked
1487
+ onErrorCallback({
1488
+ audioUtteranceId: clientUuid,
1489
+ message: 'Late error'
1490
+ });
1491
+
1492
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1493
+ });
1494
+
1495
+ it('should update local state but block callback for duplicate terminal', () => {
1496
+ // First terminal with specific transcript
1497
+ onTranscriptCallback({
1498
+ type: 'Transcription',
1499
+ audioUtteranceId: clientUuid,
1500
+ finalTranscript: 'original',
1501
+ finalTranscriptConfidence: 0.85,
1502
+ is_finished: true
1503
+ });
1504
+
1505
+ stateChangeCallback.mockClear();
1506
+
1507
+ // Second terminal with different transcript
1508
+ onTranscriptCallback({
1509
+ type: 'Transcription',
1510
+ audioUtteranceId: clientUuid,
1511
+ finalTranscript: 'different',
1512
+ finalTranscriptConfidence: 0.99,
1513
+ is_finished: true
1514
+ });
1515
+
1516
+ // Local state should be updated with new values
1517
+ const state = simplifiedClient.getVGFState();
1518
+ expect(state.finalTranscript).toBe('different');
1519
+ expect(state.finalConfidence).toBe(0.99);
1520
+
1521
+ // But callback should NOT have been called for the duplicate
1522
+ expect(stateChangeCallback).not.toHaveBeenCalled();
1523
+ });
1524
+ });
1525
+ });