@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.
- package/dist/types/voicea-plugin.d.ts +3 -3
- package/dist/types/voicea.d.ts +45 -7
- package/dist/voicea-plugin.js +2 -2
- package/dist/voicea-plugin.js.map +1 -1
- package/dist/voicea.js +157 -70
- package/dist/voicea.js.map +1 -1
- package/package.json +1 -1
- package/src/voicea-plugin.ts +3 -3
- package/src/voicea.ts +142 -50
- package/test/unit/spec/voicea-plugin.js +12 -0
- package/test/unit/spec/voicea.js +238 -4
package/test/unit/spec/voicea.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
});
|