@webex/internal-plugin-voicea 3.12.0-llmrefactor.7 → 3.12.0-llmrefactor.8

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.
@@ -8,6 +8,8 @@ import Mercury from '@webex/internal-plugin-mercury';
8
8
  import {VoiceaChannel} from '../../../src/voicea';
9
9
  import {EVENT_TRIGGERS, TOGGLE_MANUAL_CAPTION_STATUS} from '../../../src/constants';
10
10
 
11
+ const flushPromises = () => new Promise(setImmediate);
12
+
11
13
  /**
12
14
  * Creates a mock LLM channel for testing
13
15
  * @param {Object} [options] - Options for the mock channel
@@ -28,6 +30,7 @@ function createMockLLMChannel(options = {}) {
28
30
  isDataChannelTokenEnabled: sinon.stub().resolves(true),
29
31
  on: sinon.stub(),
30
32
  off: sinon.stub(),
33
+ once: sinon.stub(),
31
34
  socket: mockWebSocket,
32
35
  };
33
36
  }
@@ -65,9 +68,15 @@ describe('plugin-voicea', () => {
65
68
  assert.equal(voiceaChannel.getCaptionStatus(), 'idle');
66
69
  });
67
70
 
68
- it('should subscribe to relay events', () => {
71
+ it('should subscribe to relay events when llmChannel is provided', () => {
69
72
  assert.calledWith(mockLLMChannel.on, 'event:relay.event', sinon.match.func);
70
73
  });
74
+
75
+ it('should not subscribe to events when llmChannel is undefined', () => {
76
+ const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
77
+ // No llmChannel means no subscription, just verify it doesn't throw
78
+ assert.equal(channelWithoutLLM.getAnnounceStatus(), 'idle');
79
+ });
71
80
  });
72
81
 
73
82
  describe('#sendAnnouncement', () => {
@@ -193,11 +202,24 @@ describe('plugin-voicea', () => {
193
202
  });
194
203
 
195
204
  describe('#deregisterEvents', () => {
196
-
197
205
  beforeEach(async () => {
198
206
  voiceaChannel.keepTranscriptionSubscribed = true;
199
207
  });
200
208
 
209
+ it('works when llmChannel is undefined', () => {
210
+ const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
211
+ channelWithoutLLM.areCaptionsEnabled = true;
212
+ channelWithoutLLM.keepTranscriptionSubscribed = true;
213
+ channelWithoutLLM.hasSubscribedToEvents = true;
214
+
215
+ // Should not throw
216
+ channelWithoutLLM.deregisterEvents();
217
+
218
+ assert.equal(channelWithoutLLM.areCaptionsEnabled, false);
219
+ assert.equal(channelWithoutLLM.keepTranscriptionSubscribed, false);
220
+ assert.equal(channelWithoutLLM.hasSubscribedToEvents, false);
221
+ });
222
+
201
223
  it('deregisters voicea channel and resets state', () => {
202
224
  voiceaChannel.areCaptionsEnabled = true;
203
225
  voiceaChannel.captionServiceId = 'ws';
@@ -322,6 +344,11 @@ describe('plugin-voicea', () => {
322
344
  mockLLMChannel.isConnected.returns(false);
323
345
  assert.equal(voiceaChannel.isLLMConnected(), false);
324
346
  });
347
+
348
+ it('returns false when the LLM channel is undefined', () => {
349
+ const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
350
+ assert.equal(channelWithoutLLM.isLLMConnected(), false);
351
+ });
325
352
  });
326
353
 
327
354
  describe('#getKeepTranscriptionSubscribed', () => {
@@ -388,10 +415,25 @@ describe('plugin-voicea', () => {
388
415
  assert.equal(result, undefined);
389
416
  });
390
417
 
418
+ it('returns undefined when already enabled', async () => {
419
+ voiceaChannel.captionStatus = 'enabled';
420
+
421
+ const result = await voiceaChannel.turnOnCaptions();
422
+
423
+ assert.equal(result, undefined);
424
+ assert.notCalled(requestStub);
425
+ });
426
+
391
427
  it('throws error on request failure', async () => {
392
- requestStub.rejects(new Error('Request failed'));
428
+ const requestError = new Error('Request failed');
429
+ requestStub.rejects(requestError);
430
+
431
+ const error = await assert.isRejected(
432
+ voiceaChannel.turnOnCaptions(),
433
+ 'turn on captions fail'
434
+ );
393
435
 
394
- await assert.isRejected(voiceaChannel.turnOnCaptions(), 'turn on captions fail');
436
+ assert.equal(error.cause, requestError);
395
437
  });
396
438
 
397
439
  it('resets caption status to idle on error', async () => {
@@ -1019,6 +1061,50 @@ describe('plugin-voicea', () => {
1019
1061
  });
1020
1062
  });
1021
1063
 
1064
+ it('resolves after caption restoration completes', async () => {
1065
+ let resolveRequest;
1066
+ requestStub.returns(
1067
+ new Promise((resolve) => {
1068
+ resolveRequest = resolve;
1069
+ })
1070
+ );
1071
+ voiceaChannel.keepTranscriptionSubscribed = true;
1072
+
1073
+ const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
1074
+ const switchPromise = voiceaChannel.switchLLMChannel(newMockLLMChannel);
1075
+ let hasSwitchResolved = false;
1076
+ switchPromise.then(() => {
1077
+ hasSwitchResolved = true;
1078
+ });
1079
+
1080
+ await flushPromises();
1081
+
1082
+ assert.isFalse(hasSwitchResolved);
1083
+
1084
+ resolveRequest();
1085
+ await switchPromise;
1086
+
1087
+ assert.isTrue(hasSwitchResolved);
1088
+ });
1089
+
1090
+ it('skips switch when already on the same channel', async () => {
1091
+ // Enable captions to verify they are NOT re-enabled on no-op switch
1092
+ voiceaChannel.keepTranscriptionSubscribed = true;
1093
+ voiceaChannel.currentSpokenLanguage = 'es';
1094
+
1095
+ // Switch to the same channel that voiceaChannel is already using
1096
+ await voiceaChannel.switchLLMChannel(mockLLMChannel);
1097
+
1098
+ // Should NOT have unsubscribed (no-op)
1099
+ assert.notCalled(mockLLMChannel.off);
1100
+
1101
+ // Should NOT have re-subscribed
1102
+ // The 'on' was already called in the constructor, but no NEW calls should happen
1103
+ // Since constructor already called 'on', we check it wasn't called again after construction
1104
+ // The mock was created fresh, so we just verify no additional sends
1105
+ assert.notCalled(mockLLMChannel.socket.send);
1106
+ });
1107
+
1022
1108
  it('does not turn on captions if they were not on before switching', async () => {
1023
1109
  // Captions are off
1024
1110
  voiceaChannel.keepTranscriptionSubscribed = false;
@@ -1055,6 +1141,154 @@ describe('plugin-voicea', () => {
1055
1141
  // Should have subscribed to new channel
1056
1142
  assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
1057
1143
  });
1144
+
1145
+ it('switches from undefined llmChannel to a valid one', async () => {
1146
+ // Create a channel without llmChannel
1147
+ const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
1148
+
1149
+ const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
1150
+
1151
+ await channelWithoutLLM.switchLLMChannel(newMockLLMChannel);
1152
+
1153
+ // Should have subscribed to new channel
1154
+ assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
1155
+
1156
+ // isLLMConnected should now return true
1157
+ assert.equal(channelWithoutLLM.isLLMConnected(), true);
1158
+ });
1159
+
1160
+ it('defers caption restoration when new channel is not connected', async () => {
1161
+ // Enable captions
1162
+ voiceaChannel.keepTranscriptionSubscribed = true;
1163
+ voiceaChannel.currentSpokenLanguage = 'fr';
1164
+
1165
+ // Create a new channel that is NOT connected yet
1166
+ const newMockLLMChannel = createMockLLMChannel({
1167
+ isConnected: false,
1168
+ locusUrl: 'newLocusUrl',
1169
+ });
1170
+
1171
+ await voiceaChannel.switchLLMChannel(newMockLLMChannel);
1172
+
1173
+ // Should have subscribed to new channel events
1174
+ assert.calledWith(newMockLLMChannel.on, 'event:relay.event', sinon.match.func);
1175
+
1176
+ // Should have registered a 'once' listener for 'online' event
1177
+ assert.calledWith(newMockLLMChannel.once, 'online', sinon.match.func);
1178
+
1179
+ // Should NOT have sent any messages yet (waiting for connection)
1180
+ assert.notCalled(newMockLLMChannel.socket.send);
1181
+ });
1182
+
1183
+ it('restores captions when deferred channel comes online', async () => {
1184
+ // Enable captions
1185
+ voiceaChannel.keepTranscriptionSubscribed = true;
1186
+ voiceaChannel.currentSpokenLanguage = 'de';
1187
+
1188
+ // Create a new channel that is NOT connected yet
1189
+ const newMockLLMChannel = createMockLLMChannel({
1190
+ isConnected: false,
1191
+ locusUrl: 'newLocusUrl',
1192
+ });
1193
+
1194
+ await voiceaChannel.switchLLMChannel(newMockLLMChannel);
1195
+
1196
+ // Get the 'online' listener that was registered
1197
+ const onlineListener = newMockLLMChannel.once.getCall(0).args[1];
1198
+
1199
+ // Simulate channel coming online
1200
+ newMockLLMChannel.isConnected.returns(true);
1201
+ onlineListener();
1202
+
1203
+ // Wait for async turnOnCaptions to complete
1204
+ await flushPromises();
1205
+
1206
+ // Now it should have sent messages (announcement + subchannel subscription)
1207
+ assert.calledTwice(newMockLLMChannel.socket.send);
1208
+ });
1209
+
1210
+ it('removes pending online listener when deregisterEvents is called', async () => {
1211
+ // Enable captions
1212
+ voiceaChannel.keepTranscriptionSubscribed = true;
1213
+
1214
+ // Create a new channel that is NOT connected yet
1215
+ const newMockLLMChannel = createMockLLMChannel({
1216
+ isConnected: false,
1217
+ locusUrl: 'newLocusUrl',
1218
+ });
1219
+
1220
+ await voiceaChannel.switchLLMChannel(newMockLLMChannel);
1221
+
1222
+ // Get the 'online' listener that was registered
1223
+ const onlineListener = newMockLLMChannel.once.getCall(0).args[1];
1224
+
1225
+ // Now deregister events
1226
+ voiceaChannel.deregisterEvents();
1227
+
1228
+ // Should have removed the 'online' listener
1229
+ assert.calledWith(newMockLLMChannel.off, 'online', onlineListener);
1230
+ });
1231
+
1232
+ it('removes old pending online listener when switching channels again', async () => {
1233
+ // Enable captions
1234
+ voiceaChannel.keepTranscriptionSubscribed = true;
1235
+
1236
+ // Create first new channel that is NOT connected
1237
+ const firstNewChannel = createMockLLMChannel({isConnected: false, locusUrl: 'firstUrl'});
1238
+
1239
+ await voiceaChannel.switchLLMChannel(firstNewChannel);
1240
+
1241
+ // Get the 'online' listener registered on first channel
1242
+ const firstOnlineListener = firstNewChannel.once.getCall(0).args[1];
1243
+
1244
+ // Now switch to another channel (also not connected)
1245
+ const secondNewChannel = createMockLLMChannel({isConnected: false, locusUrl: 'secondUrl'});
1246
+
1247
+ await voiceaChannel.switchLLMChannel(secondNewChannel);
1248
+
1249
+ // Should have removed the 'online' listener from first channel
1250
+ assert.calledWith(firstNewChannel.off, 'online', firstOnlineListener);
1251
+
1252
+ // Should have registered a new 'online' listener on second channel
1253
+ assert.calledWith(secondNewChannel.once, 'online', sinon.match.func);
1254
+ });
1255
+
1256
+ it('does not duplicate caption HTTP requests on concurrent same-channel switches', async () => {
1257
+ // Enable captions
1258
+ voiceaChannel.keepTranscriptionSubscribed = true;
1259
+ voiceaChannel.currentSpokenLanguage = 'en';
1260
+
1261
+ const newMockLLMChannel = createMockLLMChannel({locusUrl: 'newLocusUrl'});
1262
+
1263
+ // Simulate concurrent switches to the same channel
1264
+ const switch1 = voiceaChannel.switchLLMChannel(newMockLLMChannel);
1265
+ const switch2 = voiceaChannel.switchLLMChannel(newMockLLMChannel);
1266
+
1267
+ await Promise.all([switch1, switch2]);
1268
+
1269
+ // Wait for fire-and-forget turnOnCaptions to complete
1270
+ await new Promise((resolve) => setTimeout(resolve, 0));
1271
+
1272
+ // Should only have sent 2 messages total (announcement + subchannel subscription)
1273
+ // NOT 4 messages (which would indicate duplicate switches)
1274
+ assert.calledTwice(newMockLLMChannel.socket.send);
1275
+ });
1276
+ });
1277
+
1278
+ describe('#requireLLMChannel', () => {
1279
+ it('throws when llmChannel is undefined', () => {
1280
+ const channelWithoutLLM = new VoiceaChannel(undefined, requestStub);
1281
+
1282
+ assert.throws(
1283
+ () => channelWithoutLLM.sendAnnouncement(),
1284
+ 'VoiceaChannel: LLM channel not available'
1285
+ );
1286
+ });
1287
+
1288
+ it('does not throw when llmChannel is defined', () => {
1289
+ // voiceaChannel has a mockLLMChannel, so this should not throw
1290
+ assert.doesNotThrow(() => voiceaChannel.sendAnnouncement());
1291
+ });
1058
1292
  });
1059
1293
  });
1060
1294
  });