@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.
- package/README.md +344 -0
- package/dist/browser.bundled.d.ts +1280 -0
- package/dist/browser.d.ts +10 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/config-builder.d.ts +134 -0
- package/dist/config-builder.d.ts.map +1 -0
- package/dist/errors.d.ts +41 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/factory.d.ts +36 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/index.bundled.d.ts +2572 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10199 -0
- package/dist/index.js.map +7 -0
- package/dist/recog-client-sdk.browser.d.ts +10 -0
- package/dist/recog-client-sdk.browser.d.ts.map +1 -0
- package/dist/recog-client-sdk.browser.js +5746 -0
- package/dist/recog-client-sdk.browser.js.map +7 -0
- package/dist/recognition-client.d.ts +128 -0
- package/dist/recognition-client.d.ts.map +1 -0
- package/dist/recognition-client.types.d.ts +271 -0
- package/dist/recognition-client.types.d.ts.map +1 -0
- package/dist/simplified-vgf-recognition-client.d.ts +178 -0
- package/dist/simplified-vgf-recognition-client.d.ts.map +1 -0
- package/dist/utils/audio-ring-buffer.d.ts +69 -0
- package/dist/utils/audio-ring-buffer.d.ts.map +1 -0
- package/dist/utils/message-handler.d.ts +45 -0
- package/dist/utils/message-handler.d.ts.map +1 -0
- package/dist/utils/url-builder.d.ts +28 -0
- package/dist/utils/url-builder.d.ts.map +1 -0
- package/dist/vgf-recognition-mapper.d.ts +66 -0
- package/dist/vgf-recognition-mapper.d.ts.map +1 -0
- package/dist/vgf-recognition-state.d.ts +91 -0
- package/dist/vgf-recognition-state.d.ts.map +1 -0
- package/package.json +74 -0
- package/src/browser.ts +24 -0
- package/src/config-builder.spec.ts +265 -0
- package/src/config-builder.ts +240 -0
- package/src/errors.ts +84 -0
- package/src/factory.spec.ts +215 -0
- package/src/factory.ts +47 -0
- package/src/index.ts +127 -0
- package/src/recognition-client.spec.ts +889 -0
- package/src/recognition-client.ts +844 -0
- package/src/recognition-client.types.ts +338 -0
- package/src/simplified-vgf-recognition-client.integration.spec.ts +718 -0
- package/src/simplified-vgf-recognition-client.spec.ts +1525 -0
- package/src/simplified-vgf-recognition-client.ts +524 -0
- package/src/utils/audio-ring-buffer.spec.ts +335 -0
- package/src/utils/audio-ring-buffer.ts +170 -0
- package/src/utils/message-handler.spec.ts +311 -0
- package/src/utils/message-handler.ts +131 -0
- package/src/utils/url-builder.spec.ts +252 -0
- package/src/utils/url-builder.ts +92 -0
- package/src/vgf-recognition-mapper.spec.ts +78 -0
- package/src/vgf-recognition-mapper.ts +232 -0
- 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
|
+
});
|