@webex/contact-center 3.12.0-task-refactor.3 → 3.12.0-task-refactor.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/services/task/TaskManager.js +9 -2
  2. package/dist/services/task/TaskManager.js.map +1 -1
  3. package/dist/services/task/state-machine/TaskStateMachine.js +51 -9
  4. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
  5. package/dist/services/task/state-machine/actions.js +11 -6
  6. package/dist/services/task/state-machine/actions.js.map +1 -1
  7. package/dist/services/task/state-machine/constants.js +20 -1
  8. package/dist/services/task/state-machine/constants.js.map +1 -1
  9. package/dist/services/task/state-machine/guards.js +27 -8
  10. package/dist/services/task/state-machine/guards.js.map +1 -1
  11. package/dist/services/task/state-machine/uiControlsComputer.js +45 -12
  12. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
  13. package/dist/services/task/types.js.map +1 -1
  14. package/dist/services/task/voice/Voice.js +16 -5
  15. package/dist/services/task/voice/Voice.js.map +1 -1
  16. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +52 -4
  17. package/dist/types/services/task/state-machine/constants.d.ts +13 -0
  18. package/dist/types/services/task/state-machine/guards.d.ts +6 -1
  19. package/dist/types/services/task/types.d.ts +2 -0
  20. package/dist/types/services/task/voice/Voice.d.ts +18 -17
  21. package/dist/webex.js +1 -1
  22. package/package.json +1 -1
  23. package/src/services/task/TaskManager.ts +9 -3
  24. package/src/services/task/state-machine/TaskStateMachine.ts +79 -10
  25. package/src/services/task/state-machine/actions.ts +19 -10
  26. package/src/services/task/state-machine/constants.ts +19 -0
  27. package/src/services/task/state-machine/guards.ts +34 -7
  28. package/src/services/task/state-machine/uiControlsComputer.ts +45 -17
  29. package/src/services/task/types.ts +2 -0
  30. package/src/services/task/voice/Voice.ts +20 -11
  31. package/test/unit/spec/services/task/TaskManager.ts +26 -0
  32. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +164 -0
  33. package/test/unit/spec/services/task/state-machine/guards.ts +103 -0
  34. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +237 -1
  35. package/test/unit/spec/services/task/voice/Voice.ts +24 -0
  36. package/umd/contact-center.min.js +2 -2
  37. package/umd/contact-center.min.js.map +1 -1
@@ -444,6 +444,109 @@ describe('Task state machine', () => {
444
444
  });
445
445
  });
446
446
 
447
+ describe('CONF_INITIATING state event handlers', () => {
448
+ it('transitions to CONFERENCING on CONFERENCE_START', () => {
449
+ const service = startMachine();
450
+ const taskData = createTaskData({consultingAgentId: 'agent-1'});
451
+
452
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
453
+ service.send({type: TaskEvent.ASSIGN, taskData});
454
+ service.send({
455
+ type: TaskEvent.CONSULT,
456
+ destination: 'agent-42',
457
+ destinationType: 'agent',
458
+ });
459
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
460
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
461
+ expect(service.getSnapshot().value).toBe(TaskState.CONF_INITIATING);
462
+
463
+ service.send({type: TaskEvent.CONFERENCE_START, taskData});
464
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
465
+ });
466
+
467
+ it('transitions to WRAPPING_UP on CONSULT_END with isTerminated during CONF_INITIATING', () => {
468
+ const service = startMachine();
469
+ const taskData = createTaskData({consultingAgentId: 'agent-1'});
470
+
471
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
472
+ service.send({type: TaskEvent.ASSIGN, taskData});
473
+ service.send({
474
+ type: TaskEvent.CONSULT,
475
+ destination: 'agent-42',
476
+ destinationType: 'agent',
477
+ });
478
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
479
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
480
+ expect(service.getSnapshot().value).toBe(TaskState.CONF_INITIATING);
481
+
482
+ const terminatedTaskData = createTaskData({
483
+ consultingAgentId: 'agent-1',
484
+ interaction: {
485
+ isTerminated: true,
486
+ owner: 'agent-1',
487
+ } as any,
488
+ });
489
+ service.send({type: TaskEvent.CONSULT_END, taskData: terminatedTaskData});
490
+ expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
491
+ });
492
+
493
+ it('transitions to CONNECTED on CONSULT_END without isTerminated during CONF_INITIATING', () => {
494
+ const service = startMachine();
495
+ const taskData = createTaskData({consultingAgentId: 'agent-1'});
496
+
497
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
498
+ service.send({type: TaskEvent.ASSIGN, taskData});
499
+ service.send({
500
+ type: TaskEvent.CONSULT,
501
+ destination: 'agent-42',
502
+ destinationType: 'agent',
503
+ });
504
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
505
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
506
+ expect(service.getSnapshot().value).toBe(TaskState.CONF_INITIATING);
507
+
508
+ service.send({type: TaskEvent.CONSULT_END, taskData});
509
+ expect(service.getSnapshot().value).toBe(TaskState.CONNECTED);
510
+ });
511
+
512
+ });
513
+
514
+ describe('CONFERENCING state CONSULT_END with terminated interaction', () => {
515
+ it('transitions to WRAPPING_UP when CONSULT_END arrives with isTerminated in CONFERENCING', () => {
516
+ const service = startMachine();
517
+ const taskData = createTaskData({
518
+ consultingAgentId: 'agent-1',
519
+ interaction: {
520
+ owner: 'agent-1',
521
+ state: 'conference',
522
+ } as any,
523
+ });
524
+
525
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
526
+ service.send({type: TaskEvent.ASSIGN, taskData});
527
+ service.send({
528
+ type: TaskEvent.CONSULT,
529
+ destination: 'agent-42',
530
+ destinationType: 'agent',
531
+ });
532
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
533
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
534
+ service.send({type: TaskEvent.CONFERENCE_START, taskData});
535
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
536
+
537
+ const terminatedTaskData = createTaskData({
538
+ consultingAgentId: 'agent-1',
539
+ interaction: {
540
+ isTerminated: true,
541
+ owner: 'agent-1',
542
+ state: 'conference',
543
+ } as any,
544
+ });
545
+ service.send({type: TaskEvent.CONSULT_END, taskData: terminatedTaskData});
546
+ expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
547
+ });
548
+ });
549
+
447
550
  describe('OFFERED state event handlers', () => {
448
551
  it('transitions to TERMINATED when customer disconnects before agent answers', () => {
449
552
  const service = startMachine();
@@ -470,4 +573,65 @@ describe('Task state machine', () => {
470
573
  expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
471
574
  });
472
575
  });
576
+
577
+ describe('OUTBOUND_FAILED handling', () => {
578
+ it('transitions from IDLE to TERMINATED on OUTBOUND_FAILED (race condition)', () => {
579
+ const service = startMachine();
580
+ expect(service.getSnapshot().value).toBe(TaskState.IDLE);
581
+
582
+ const taskData = createTaskData({
583
+ interaction: {
584
+ outboundType: 'OUTDIAL',
585
+ isTerminated: true,
586
+ } as any,
587
+ });
588
+
589
+ service.send({type: TaskEvent.OUTBOUND_FAILED, taskData, reason: 'CUSTOMER_BUSY'});
590
+ expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
591
+ });
592
+
593
+ it('transitions from OFFERED to TERMINATED on OUTBOUND_FAILED without wrapup', () => {
594
+ const service = startMachine();
595
+ const offerTaskData = createTaskData({
596
+ interaction: {
597
+ outboundType: 'OUTDIAL',
598
+ } as any,
599
+ });
600
+
601
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: offerTaskData});
602
+ expect(service.getSnapshot().value).toBe(TaskState.OFFERED);
603
+
604
+ const failedTaskData = createTaskData({
605
+ interaction: {
606
+ outboundType: 'OUTDIAL',
607
+ isTerminated: true,
608
+ } as any,
609
+ });
610
+ service.send({type: TaskEvent.OUTBOUND_FAILED, taskData: failedTaskData, reason: 'CUSTOMER_BUSY'});
611
+ expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
612
+ });
613
+
614
+ it('transitions from OFFERED to WRAPPING_UP on OUTBOUND_FAILED when wrapup is required', () => {
615
+ const service = startMachine();
616
+ const offerTaskData = createTaskData({
617
+ interaction: {
618
+ outboundType: 'OUTDIAL',
619
+ } as any,
620
+ });
621
+
622
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: offerTaskData});
623
+ expect(service.getSnapshot().value).toBe(TaskState.OFFERED);
624
+
625
+ const failedTaskData = createTaskData({
626
+ agentId: 'agent-1',
627
+ agentsPendingWrapUp: ['agent-1'],
628
+ interaction: {
629
+ outboundType: 'OUTDIAL',
630
+ isTerminated: true,
631
+ } as any,
632
+ });
633
+ service.send({type: TaskEvent.OUTBOUND_FAILED, taskData: failedTaskData, reason: 'CUSTOMER_BUSY'});
634
+ expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
635
+ });
636
+ });
473
637
  });
@@ -238,6 +238,109 @@ describe('State Machine Guards', () => {
238
238
  });
239
239
  });
240
240
 
241
+ describe('Hydration Guards - isInteractionConsulting', () => {
242
+ it('returns true when interaction state is consulting', () => {
243
+ const ctx = createContext();
244
+ const taskData = createTaskData({
245
+ interaction: {
246
+ ...createTaskData().interaction,
247
+ state: 'consulting',
248
+ },
249
+ });
250
+ expect(
251
+ guards.isInteractionConsulting(createParams(ctx, createEventWithTaskData(taskData)))
252
+ ).toBe(true);
253
+ });
254
+
255
+ it('returns true for EP_DN consulted agent (state=connected, CPD relationshipType=consult)', () => {
256
+ const ctx = createContext();
257
+ const taskData = createTaskData({
258
+ interaction: {
259
+ ...createTaskData().interaction,
260
+ state: 'connected',
261
+ callProcessingDetails: {
262
+ ...createTaskData().interaction!.callProcessingDetails,
263
+ relationshipType: 'consult',
264
+ },
265
+ },
266
+ });
267
+ expect(
268
+ guards.isInteractionConsulting(createParams(ctx, createEventWithTaskData(taskData)))
269
+ ).toBe(true);
270
+ });
271
+
272
+ it('returns true when post_call with active consult (consultState=consulting + consult media)', () => {
273
+ const ctx = createContext();
274
+ const taskData = createTaskData({
275
+ interaction: {
276
+ ...createTaskData().interaction,
277
+ state: 'post_call',
278
+ participants: {
279
+ 'agent-123': {
280
+ ...createParticipant('agent-123', 'Agent'),
281
+ consultState: 'consulting',
282
+ },
283
+ },
284
+ media: {
285
+ [INTERACTION_ID]: {
286
+ mediaResourceId: INTERACTION_ID,
287
+ mediaType: 'telephony',
288
+ mediaMgr: 'media-mgr',
289
+ participants: ['agent-123'],
290
+ mType: 'mainCall',
291
+ isHold: false,
292
+ holdTimestamp: null,
293
+ },
294
+ 'consult-media': {
295
+ mediaResourceId: 'consult-media',
296
+ mediaType: 'telephony',
297
+ mediaMgr: 'media-mgr',
298
+ participants: ['agent-123', 'agent-2'],
299
+ mType: 'consult',
300
+ isHold: false,
301
+ holdTimestamp: null,
302
+ },
303
+ },
304
+ },
305
+ });
306
+ expect(
307
+ guards.isInteractionConsulting(createParams(ctx, createEventWithTaskData(taskData)))
308
+ ).toBe(true);
309
+ });
310
+
311
+ it('returns false for post_call without consult media', () => {
312
+ const ctx = createContext();
313
+ const taskData = createTaskData({
314
+ interaction: {
315
+ ...createTaskData().interaction,
316
+ state: 'post_call',
317
+ participants: {
318
+ 'agent-123': {
319
+ ...createParticipant('agent-123', 'Agent'),
320
+ consultState: undefined,
321
+ },
322
+ },
323
+ },
324
+ });
325
+ expect(
326
+ guards.isInteractionConsulting(createParams(ctx, createEventWithTaskData(taskData)))
327
+ ).toBe(false);
328
+ });
329
+
330
+ it('returns false for plain connected state without consult CPD', () => {
331
+ const ctx = createContext();
332
+ const taskData = createTaskData({
333
+ interaction: {
334
+ ...createTaskData().interaction,
335
+ state: 'connected',
336
+ },
337
+ });
338
+ expect(
339
+ guards.isInteractionConsulting(createParams(ctx, createEventWithTaskData(taskData)))
340
+ ).toBe(false);
341
+ });
342
+ });
343
+
241
344
  describe('Server State Guards', () => {
242
345
  it('isPrimaryMediaOnHold returns true when media isHold is true', () => {
243
346
  const ctx = createContext();
@@ -27,7 +27,7 @@ function createConsultTaskData() {
27
27
  currentState: 'consulting',
28
28
  isConsulted: true,
29
29
  },
30
- 'customer-1': {id: 'customer-1', pType: 'CUSTOMER', hasLeft: false},
30
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
31
31
  } as any,
32
32
  media: {
33
33
  'interaction-1': {
@@ -145,3 +145,239 @@ describe('uiControlsComputer consult initiator controls', () => {
145
145
  expect(uiControls.consult).toEqual(getDefaultUIControls().consult);
146
146
  });
147
147
  });
148
+
149
+ describe('uiControlsComputer outdial accept/decline controls', () => {
150
+ function createOutdialContext(voiceVariant: 'webrtc' | 'pstn' = 'webrtc'): TaskContext {
151
+ const taskData = createTaskData({
152
+ interaction: {
153
+ outboundType: 'OUTDIAL',
154
+ state: 'new',
155
+ isTerminated: false,
156
+ } as any,
157
+ });
158
+ return {
159
+ taskData,
160
+ consultInitiator: false,
161
+ exitingConference: false,
162
+ consultFromConference: false,
163
+ transferConferenceRequested: false,
164
+ consultDestinationType: null,
165
+ consultDestinationAgentId: null,
166
+ consultDestinationAgentJoined: false,
167
+ consultCallHeld: false,
168
+ recordingControlsAvailable: false,
169
+ recordingInProgress: false,
170
+ uiControlConfig: {
171
+ isEndTaskEnabled: true,
172
+ isEndConsultEnabled: true,
173
+ channelType: TASK_CHANNEL_TYPE.VOICE,
174
+ isRecordingEnabled: false,
175
+ agentId: 'agent-1',
176
+ voiceVariant,
177
+ },
178
+ uiControls: getDefaultUIControls(),
179
+ };
180
+ }
181
+
182
+ it('accept is visible but disabled for WebRTC outdial in OFFERED state', () => {
183
+ const context = createOutdialContext('webrtc');
184
+ const uiControls = computeUIControls(TaskState.OFFERED, context, context.taskData);
185
+
186
+ expect(uiControls.main.accept).toEqual({isVisible: true, isEnabled: false});
187
+ });
188
+
189
+ it('decline is visible but disabled for WebRTC outdial in OFFERED state', () => {
190
+ const context = createOutdialContext('webrtc');
191
+ const uiControls = computeUIControls(TaskState.OFFERED, context, context.taskData);
192
+
193
+ expect(uiControls.main.decline).toEqual({isVisible: true, isEnabled: false});
194
+ });
195
+ });
196
+
197
+ describe('uiControlsComputer conference controls', () => {
198
+ function createConferenceTaskData(participantCount: number) {
199
+ const participants: Record<string, any> = {
200
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasJoined: true, hasLeft: false},
201
+ 'agent-1': {
202
+ id: 'agent-1',
203
+ pType: 'Agent',
204
+ hasJoined: true,
205
+ hasLeft: false,
206
+ consultState: 'conferencing',
207
+ },
208
+ };
209
+ const mainCallParticipants = ['customer-1', 'agent-1'];
210
+
211
+ if (participantCount > 1) {
212
+ participants['agent-2'] = {
213
+ id: 'agent-2',
214
+ pType: 'Agent',
215
+ hasJoined: true,
216
+ hasLeft: false,
217
+ consultState: 'conferencing',
218
+ };
219
+ mainCallParticipants.push('agent-2');
220
+ }
221
+
222
+ return createTaskData({
223
+ agentId: 'agent-1',
224
+ mediaResourceId: 'interaction-1',
225
+ consultMediaResourceId: '' as any,
226
+ interaction: {
227
+ interactionId: 'interaction-1',
228
+ mainInteractionId: 'interaction-1',
229
+ state: 'conference',
230
+ participants,
231
+ media: {
232
+ 'interaction-1': {
233
+ mediaResourceId: 'interaction-1',
234
+ mType: 'mainCall',
235
+ isHold: false,
236
+ participants: mainCallParticipants,
237
+ },
238
+ },
239
+ } as any,
240
+ });
241
+ }
242
+
243
+ function createConferenceContext(participantCount: number, overrides: Partial<TaskContext> = {}): TaskContext {
244
+ return {
245
+ taskData: createConferenceTaskData(participantCount),
246
+ consultInitiator: true,
247
+ exitingConference: false,
248
+ consultFromConference: false,
249
+ transferConferenceRequested: false,
250
+ consultDestinationType: null,
251
+ consultDestinationAgentId: null,
252
+ consultDestinationAgentJoined: false,
253
+ consultCallHeld: false,
254
+ recordingControlsAvailable: true,
255
+ recordingInProgress: true,
256
+ uiControlConfig: {
257
+ isEndTaskEnabled: true,
258
+ isEndConsultEnabled: true,
259
+ channelType: TASK_CHANNEL_TYPE.VOICE,
260
+ isRecordingEnabled: true,
261
+ agentId: 'agent-1',
262
+ },
263
+ uiControls: getDefaultUIControls(),
264
+ ...overrides,
265
+ };
266
+ }
267
+
268
+ it('pending conference (participantCount <= 1): transfer/recording enabled, consult disabled, exitConference hidden', () => {
269
+ const context = createConferenceContext(1);
270
+
271
+ const uiControls = computeUIControls(TaskState.CONFERENCING, context, context.taskData);
272
+
273
+ expect(uiControls.main.transfer).toEqual({isVisible: true, isEnabled: true});
274
+ expect(uiControls.main.recording).toEqual({isVisible: true, isEnabled: true});
275
+ expect(uiControls.main.consult).toEqual({isVisible: true, isEnabled: false});
276
+ expect(uiControls.main.exitConference).toEqual({isVisible: false, isEnabled: false});
277
+ expect(uiControls.main.end).toEqual({isVisible: true, isEnabled: true});
278
+ });
279
+
280
+ it('real conference (participantCount > 1): transfer/recording hidden, consult enabled, exitConference visible', () => {
281
+ const context = createConferenceContext(2);
282
+
283
+ const uiControls = computeUIControls(TaskState.CONFERENCING, context, context.taskData);
284
+
285
+ expect(uiControls.main.transfer).toEqual({isVisible: false, isEnabled: false});
286
+ expect(uiControls.main.recording).toEqual({isVisible: false, isEnabled: false});
287
+ expect(uiControls.main.consult).toEqual({isVisible: true, isEnabled: true});
288
+ expect(uiControls.main.exitConference).toEqual({isVisible: true, isEnabled: true});
289
+ expect(uiControls.main.end).toEqual({isVisible: true, isEnabled: true});
290
+ });
291
+
292
+ it('hold button shows visible but disabled in conference', () => {
293
+ const context = createConferenceContext(2);
294
+
295
+ const uiControls = computeUIControls(TaskState.CONFERENCING, context, context.taskData);
296
+
297
+ expect(uiControls.main.hold).toEqual({isVisible: true, isEnabled: false});
298
+ });
299
+ });
300
+
301
+ describe('uiControlsComputer post-call consult controls (customer left)', () => {
302
+ function createPostCallConsultTaskData() {
303
+ return createTaskData({
304
+ agentId: 'agent-1',
305
+ mediaResourceId: 'interaction-1',
306
+ consultMediaResourceId: 'consult-media',
307
+ consultingAgentId: 'agent-1',
308
+ destAgentId: 'agent-2',
309
+ interaction: {
310
+ interactionId: 'interaction-1',
311
+ mainInteractionId: 'interaction-1',
312
+ state: 'post_call',
313
+ isTerminated: false,
314
+ participants: {
315
+ 'agent-1': {
316
+ id: 'agent-1',
317
+ pType: 'Agent',
318
+ hasJoined: true,
319
+ hasLeft: false,
320
+ consultState: 'consulting',
321
+ },
322
+ 'agent-2': {
323
+ id: 'agent-2',
324
+ pType: 'Agent',
325
+ hasLeft: false,
326
+ consultState: 'consulting',
327
+ isConsulted: true,
328
+ },
329
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasJoined: true, hasLeft: true},
330
+ } as any,
331
+ media: {
332
+ 'interaction-1': {
333
+ mediaResourceId: 'interaction-1',
334
+ mType: 'mainCall',
335
+ isHold: false,
336
+ participants: ['agent-1'],
337
+ },
338
+ 'consult-media': {
339
+ mediaResourceId: 'consult-media',
340
+ mType: 'consult',
341
+ isHold: false,
342
+ participants: ['agent-1', 'agent-2'],
343
+ },
344
+ } as any,
345
+ } as any,
346
+ });
347
+ }
348
+
349
+ it('consult controls are visible but disabled when customer has left', () => {
350
+ const taskData = createPostCallConsultTaskData();
351
+ const context: TaskContext = {
352
+ taskData,
353
+ consultInitiator: true,
354
+ exitingConference: false,
355
+ consultFromConference: false,
356
+ transferConferenceRequested: false,
357
+ consultDestinationType: null,
358
+ consultDestinationAgentId: null,
359
+ consultDestinationAgentJoined: true,
360
+ consultCallHeld: false,
361
+ recordingControlsAvailable: true,
362
+ recordingInProgress: true,
363
+ uiControlConfig: {
364
+ isEndTaskEnabled: true,
365
+ isEndConsultEnabled: true,
366
+ channelType: TASK_CHANNEL_TYPE.VOICE,
367
+ isRecordingEnabled: true,
368
+ agentId: 'agent-1',
369
+ },
370
+ uiControls: getDefaultUIControls(),
371
+ };
372
+
373
+ const uiControls = computeUIControls(TaskState.CONSULTING, context, context.taskData);
374
+
375
+ // Consult leg: transfer/conference/merge/switch should be visible but disabled
376
+ expect(uiControls.consult.transfer).toEqual({isVisible: true, isEnabled: false});
377
+ expect(uiControls.consult.conference).toEqual({isVisible: true, isEnabled: false});
378
+ expect(uiControls.consult.switch).toEqual({isVisible: true, isEnabled: false});
379
+
380
+ // Main leg end should be visible but disabled
381
+ expect(uiControls.main.end).toEqual({isVisible: true, isEnabled: false});
382
+ });
383
+ });
@@ -72,6 +72,30 @@ describe('Voice Task', () => {
72
72
  jest.clearAllMocks();
73
73
  });
74
74
 
75
+ describe('emitTaskOutdialFailed', () => {
76
+ it('emits the failure reason string instead of the Task object', () => {
77
+ const taskData = createBaseData({
78
+ interaction: {
79
+ outboundType: 'OUTDIAL',
80
+ } as any,
81
+ });
82
+ const voice = new Voice(dummyContact, taskData, {});
83
+ const emitSpy = jest.spyOn(voice, 'emit');
84
+
85
+ voice.sendStateMachineEvent({
86
+ type: TaskEvent.OUTBOUND_FAILED,
87
+ taskData,
88
+ reason: 'CUSTOMER_BUSY',
89
+ });
90
+
91
+ const outdialFailedCall = emitSpy.mock.calls.find(
92
+ (call) => call[0] === 'task:outdialFailed'
93
+ );
94
+ expect(outdialFailedCall).toBeDefined();
95
+ expect(outdialFailedCall![1]).toBe('CUSTOMER_BUSY');
96
+ });
97
+ });
98
+
75
99
  it('hides end and endConsult when disabled', () => {
76
100
  const voice = new Voice(dummyContact, createBaseData(), {
77
101
  isEndTaskEnabled: false,