@coveo/quantic 3.39.0 → 3.40.0

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 (24) hide show
  1. package/force-app/main/default/classes/HeadlessControllerTest.cls +1 -0
  2. package/force-app/main/default/classes/InsightControllerTest.cls +1 -0
  3. package/force-app/main/default/classes/RecommendationsControllerTest.cls +1 -1
  4. package/force-app/main/default/classes/SampleTokenProvider.cls +1 -0
  5. package/force-app/main/default/classes/SampleTokenProviderTest.cls +1 -0
  6. package/force-app/main/default/lwc/quanticGeneratedAnswer/__tests__/quanticGeneratedAnswer.test.js +54 -0
  7. package/force-app/main/default/lwc/quanticGeneratedAnswer/quanticGeneratedAnswer.js +26 -3
  8. package/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.css +5 -0
  9. package/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html +3 -1
  10. package/force-app/main/default/lwc/quanticGeneratedAnswerContent/__tests__/quanticGeneratedAnswerContent.test.js +269 -0
  11. package/force-app/main/default/lwc/quanticGeneratedAnswerContent/quanticGeneratedAnswerContent.js +136 -0
  12. package/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedMarkdownContent.css +10 -0
  13. package/force-app/main/default/lwc/quanticUtils/__tests__/accessibilityUtils.test.js +214 -0
  14. package/force-app/main/default/lwc/quanticUtils/__tests__/facetStoreUtils.test.js +86 -0
  15. package/force-app/main/default/lwc/quanticUtils/__tests__/markdownUtils.test.js +38 -0
  16. package/force-app/main/default/lwc/quanticUtils/accessibilityUtils.js +225 -0
  17. package/force-app/main/default/lwc/quanticUtils/facetStoreUtils.js +65 -0
  18. package/force-app/main/default/lwc/quanticUtils/markdownUtils.js +18 -0
  19. package/force-app/main/default/lwc/quanticUtils/quanticUtils.js +2 -291
  20. package/force-app/main/default/staticresources/coveoheadless/case-assist/headless.js +3 -3
  21. package/force-app/main/default/staticresources/coveoheadless/headless.js +3 -3
  22. package/force-app/main/default/staticresources/coveoheadless/insight/headless.js +3 -3
  23. package/force-app/main/default/staticresources/coveoheadless/recommendation/headless.js +3 -3
  24. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  @isTest
2
2
  private class HeadlessControllerTest {
3
+ // This API key is intentionally public — it belongs to a sample organization used for samples/docs.
3
4
  static final String sampleHeadlessConfiguration = '{"accessToken":"xx564559b1-0045-48e1-953c-3addd1ee4457","organizationId":"searchuisamples"}';
4
5
  @IsTest
5
6
  static void shouldReturnStringifiedConfiguration() {
@@ -1,5 +1,6 @@
1
1
  @isTest
2
2
  private class InsightControllerTest {
3
+ // This API key is intentionally public — it belongs to a sample organization used for samples/docs.
3
4
  static final String sampleHeadlessConfiguration = '{"accessToken":"xx564559b1-0045-48e1-953c-3addd1ee4457","organizationId":"searchuisamples"}';
4
5
  @IsTest
5
6
  static void shouldReturnStringifiedConfiguration() {
@@ -1,6 +1,6 @@
1
1
  @isTest
2
2
  private class RecommendationsControllerTest {
3
- // This is a demo token and it's okay to be public.
3
+ // This API key is intentionally public it belongs to a sample organization used for samples/docs.
4
4
  static final String sampleHeadlessConfiguration = '{"accessToken":"xx564559b1-0045-48e1-953c-3addd1ee4457","organizationId":"searchuisamples"}';
5
5
  @IsTest
6
6
  static void shouldReturnStringifiedConfiguration() {
@@ -10,6 +10,7 @@ global with sharing class SampleTokenProvider implements ITokenProvider {
10
10
  headlessConfiguration.put('organizationId', 'searchuisamples');
11
11
  headlessConfiguration.put(
12
12
  'accessToken',
13
+ // This API key is intentionally public — it belongs to a sample organization used for samples/docs.
13
14
  'xx564559b1-0045-48e1-953c-3addd1ee4457'
14
15
  );
15
16
  return JSON.serialize(headlessConfiguration);
@@ -1,5 +1,6 @@
1
1
  @isTest
2
2
  private class SampleTokenProviderTest {
3
+ // This API key is intentionally public — it belongs to a sample organization used for samples/docs.
3
4
  static final String sampleHeadlessConfiguration = '{"accessToken":"xx564559b1-0045-48e1-953c-3addd1ee4457","organizationId":"searchuisamples"}';
4
5
  @IsTest
5
6
  static void shouldReturnStringifiedConfiguration() {
@@ -22,6 +22,8 @@ const exampleCitations = [
22
22
  uri: 'https://example.com/2',
23
23
  },
24
24
  ];
25
+ const exampleEngineId = 'example engine id';
26
+ const exampleAnswerId = 'example answer id';
25
27
  jest.mock('c/quanticHeadlessLoader');
26
28
  jest.mock('c/quanticUtils', () => ({
27
29
  AriaLiveRegion: jest.fn(() => ({
@@ -50,6 +52,7 @@ jest.mock(
50
52
 
51
53
  /** @type {Object} */
52
54
  const defaultOptions = {
55
+ engineId: exampleEngineId,
53
56
  fieldsToIncludeInCitations: 'sfid,sfkbid,sfkavid,filetype',
54
57
  answerConfigurationId: undefined,
55
58
  withToggle: false,
@@ -348,6 +351,7 @@ describe('c-quantic-generated-answer', () => {
348
351
  isStreaming: true,
349
352
  answer: exampleAnswer,
350
353
  answerContentFormat: exampleAnswerContentFormat,
354
+ answerId: exampleAnswerId,
351
355
  };
352
356
  mockSuccessfulHeadlessInitialization();
353
357
  prepareHeadlessState();
@@ -461,6 +465,8 @@ describe('c-quantic-generated-answer', () => {
461
465
  expect(generatedAnswerContent.answerContentFormat).toBe(
462
466
  exampleAnswerContentFormat
463
467
  );
468
+ expect(generatedAnswerContent.engineId).toBe(exampleEngineId);
469
+ expect(generatedAnswerContent.answerId).toBe(exampleAnswerId);
464
470
  });
465
471
 
466
472
  it('should not display the generated answer actions', async () => {
@@ -784,6 +790,54 @@ describe('c-quantic-generated-answer', () => {
784
790
  expect(generatedAnswerCitations).not.toBeNull();
785
791
  expect(generatedAnswerCitations.disableCitationAnchoring).toBe(false);
786
792
  });
793
+
794
+ describe('when follow-ups are enabled', () => {
795
+ // TODO SFINT-6786: Add test cases to cover the behavior of the component when follow-ups are enabled based on the actual implementation of the follow-up feature in the state.
796
+ it.skip('should render the content section with the scrollable class and ignore the collapsible feature', async () => {
797
+ mockAnswerHeight = defaultAnswerHeight + 100;
798
+ const element = createTestComponent({
799
+ ...defaultOptions,
800
+ collapsible: true,
801
+ });
802
+ await flushPromises();
803
+
804
+ const generatedAnswerBody = element.shadowRoot.querySelector(
805
+ selectors.generatedAnswerBody
806
+ );
807
+
808
+ expect(generatedAnswerBody).not.toBeNull();
809
+ expect(
810
+ generatedAnswerBody.classList.contains(
811
+ 'generated-answer__content--scrollable'
812
+ )
813
+ ).toBe(true);
814
+
815
+ const generatedAnswerCollapseToggle =
816
+ element.shadowRoot.querySelector(
817
+ selectors.generatedAnswerCollapseToggle
818
+ );
819
+
820
+ expect(generatedAnswerCollapseToggle).toBeNull();
821
+ });
822
+ });
823
+
824
+ describe('when follow-ups are not enabled', () => {
825
+ it('should not render the content section with the scrollable class', async () => {
826
+ const element = createTestComponent();
827
+ await flushPromises();
828
+
829
+ const generatedAnswerBody = element.shadowRoot.querySelector(
830
+ selectors.generatedAnswerBody
831
+ );
832
+
833
+ expect(generatedAnswerBody).not.toBeNull();
834
+ expect(
835
+ generatedAnswerBody.classList.contains(
836
+ 'generated-answer__content--scrollable'
837
+ )
838
+ ).toBe(false);
839
+ });
840
+ });
787
841
  });
788
842
 
789
843
  describe('when the answer cannot be generated after a query is executed', () => {
@@ -172,6 +172,8 @@ export default class QuanticGeneratedAnswer extends LightningElement {
172
172
  /** @type {boolean} */
173
173
  hasInitializationError = false;
174
174
  /** @type {boolean} */
175
+ _areFollowUpsEnabled = false;
176
+ /** @type {boolean} */
175
177
  _exceedsMaximumHeight = false;
176
178
  /** @type {boolean} */
177
179
  _liked = false;
@@ -196,7 +198,7 @@ export default class QuanticGeneratedAnswer extends LightningElement {
196
198
 
197
199
  renderedCallback() {
198
200
  initializeWithHeadless(this, this.engineId, this.initialize);
199
- if (this.collapsible) {
201
+ if (this.isCollapsibleEnabled) {
200
202
  this._exceedsMaximumHeight = this.isMaximumHeightExceeded();
201
203
  }
202
204
  }
@@ -259,7 +261,7 @@ export default class QuanticGeneratedAnswer extends LightningElement {
259
261
  this.updateFeedbackState();
260
262
  this.ariaLiveMessage.dispatchMessage(this.getGeneratedAnswerStatus());
261
263
 
262
- if (this.collapsible) {
264
+ if (this.isCollapsibleEnabled) {
263
265
  this.updateGeneratedAnswerCSSVariables();
264
266
  }
265
267
  }
@@ -400,7 +402,7 @@ export default class QuanticGeneratedAnswer extends LightningElement {
400
402
 
401
403
  handleAnswerContentUpdated = (event) => {
402
404
  event.stopPropagation();
403
- if (this.collapsible) {
405
+ if (this.isCollapsibleEnabled) {
404
406
  this._exceedsMaximumHeight = this.isMaximumHeightExceeded();
405
407
  }
406
408
  this.updateGeneratedAnswerCSSVariables();
@@ -456,6 +458,10 @@ export default class QuanticGeneratedAnswer extends LightningElement {
456
458
  return this?.state?.answer;
457
459
  }
458
460
 
461
+ get answerId() {
462
+ return this?.state?.answerId;
463
+ }
464
+
459
465
  get citations() {
460
466
  return this?.state?.citations;
461
467
  }
@@ -481,6 +487,15 @@ export default class QuanticGeneratedAnswer extends LightningElement {
481
487
  return this.state.isVisible;
482
488
  }
483
489
 
490
+ get areFollowUpsEnabled() {
491
+ // TODO SFINT-6786: Modify this getter to return the actual value from the state for follow-up enabled/agentId.
492
+ return this._areFollowUpsEnabled;
493
+ }
494
+
495
+ get isCollapsibleEnabled() {
496
+ return this.collapsible && !this.areFollowUpsEnabled;
497
+ }
498
+
484
499
  get isAnswerCollapsed() {
485
500
  // Answer is considered collapsed only if it exceeds the maximum height and was not expanded.
486
501
  return this._exceedsMaximumHeight && !this.isExpanded;
@@ -501,6 +516,14 @@ export default class QuanticGeneratedAnswer extends LightningElement {
501
516
  return `generated-answer__answer ${collapsedStateClass}`;
502
517
  }
503
518
 
519
+ get contentSectionClass() {
520
+ const baseClass =
521
+ 'generated-answer__content slds-p-top_medium slds-p-horizontal_large';
522
+ return this.areFollowUpsEnabled
523
+ ? `${baseClass} generated-answer__content--scrollable`
524
+ : baseClass;
525
+ }
526
+
504
527
  get hasRetryableError() {
505
528
  return !this?.searchStatusState?.hasError && this.state?.error?.isRetryable;
506
529
  }
@@ -9,6 +9,11 @@
9
9
  word-wrap: break-word;
10
10
  }
11
11
 
12
+ .generated-answer__content--scrollable {
13
+ overflow-y: auto;
14
+ height: var(--quantic-generated-answer-content-fixed-height, 50vh);
15
+ }
16
+
12
17
  .generated-answer__footer {
13
18
  gap: 1rem;
14
19
  }
@@ -27,13 +27,15 @@
27
27
  <template lwc:if={isVisible}>
28
28
  <section
29
29
  data-testid="generated-answer__body"
30
- class="generated-answer__content slds-p-top_medium slds-p-horizontal_large"
30
+ class={contentSectionClass}
31
31
  >
32
32
  <div
33
33
  data-testid="generated-answer__answer"
34
34
  class={generatedAnswerClass}
35
35
  >
36
36
  <c-quantic-generated-answer-content
37
+ engine-id={engineId}
38
+ answer-id={answerId}
37
39
  answer-content-format={answerContentFormat}
38
40
  answer={answer}
39
41
  is-streaming={isStreaming}
@@ -1,8 +1,11 @@
1
+ /* eslint-disable no-import-assign */
1
2
  // @ts-ignore
2
3
  import QuanticGeneratedAnswerContent from 'c/quanticGeneratedAnswerContent';
3
4
  import {createElement} from 'lwc';
4
5
  import {loadMarkdownDependencies} from 'c/quanticUtils';
6
+ import * as mockHeadlessLoader from 'c/quanticHeadlessLoader';
5
7
 
8
+ jest.mock('c/quanticHeadlessLoader');
6
9
  jest.mock('c/quanticUtils', () => ({
7
10
  loadMarkdownDependencies: jest.fn(
8
11
  () =>
@@ -11,6 +14,9 @@ jest.mock('c/quanticUtils', () => ({
11
14
  })
12
15
  ),
13
16
  transformMarkdownToHtml: jest.fn((value) => value),
17
+ LinkUtils: {
18
+ bindAnalyticsToLink: jest.fn(() => jest.fn()),
19
+ },
14
20
  }));
15
21
 
16
22
  const mockMarkedUse = jest.fn();
@@ -23,14 +29,47 @@ global.marked = {
23
29
  const SELECTORS = {
24
30
  textAnswerContainer: 'span.generated-answer-content__answer',
25
31
  markdownAnswerContainer: 'div.generated-answer-content__answer',
32
+ inlineLink: 'a[data-answer-inline-link]',
33
+ inlineLinkIconContainer: 'span.slds-icon_container',
34
+ inlineLinkIconSvg: 'svg.slds-icon',
26
35
  };
27
36
 
37
+ const exampleEngineId = 'example-engine-id';
38
+ const exampleAnswerId = 'example-answer-id';
39
+
28
40
  const defaultOptions = {
29
41
  isStreaming: false,
30
42
  answerContentFormat: 'text/plain',
31
43
  answer: '',
32
44
  };
33
45
 
46
+ const exampleEngine = {id: 'dummy-engine'};
47
+ let isInitialized = false;
48
+
49
+ const mockBuildInteractiveGeneratedAnswerInlineLink = jest.fn(() => ({
50
+ select: jest.fn(),
51
+ beginDelayedSelect: jest.fn(),
52
+ cancelPendingSelect: jest.fn(),
53
+ }));
54
+
55
+ function prepareHeadlessState() {
56
+ // @ts-ignore
57
+ mockHeadlessLoader.getHeadlessBundle = () => ({
58
+ buildInteractiveGeneratedAnswerInlineLink:
59
+ mockBuildInteractiveGeneratedAnswerInlineLink,
60
+ });
61
+ }
62
+
63
+ function mockSuccessfulHeadlessInitialization() {
64
+ // @ts-ignore
65
+ mockHeadlessLoader.initializeWithHeadless = (element, _, initialize) => {
66
+ if (element instanceof QuanticGeneratedAnswerContent && !isInitialized) {
67
+ isInitialized = true;
68
+ initialize(exampleEngine);
69
+ }
70
+ };
71
+ }
72
+
34
73
  function createTestComponent(options = defaultOptions) {
35
74
  const element = createElement('c-quantic-generated-answer-content', {
36
75
  is: QuanticGeneratedAnswerContent,
@@ -43,6 +82,20 @@ function createTestComponent(options = defaultOptions) {
43
82
  return element;
44
83
  }
45
84
 
85
+ /**
86
+ * Builds an HTML string containing anchor tags with the data-answer-inline-link
87
+ * attribute, which is what the component's processInlineLinks method targets.
88
+ * @param {Array<{href: string, text: string}>} links
89
+ * @returns {string}
90
+ */
91
+ function buildAnswerWithInlineLinks(links) {
92
+ return links
93
+ .map(
94
+ ({href, text}) => `<a href="${href}" data-answer-inline-link>${text}</a>`
95
+ )
96
+ .join(' ');
97
+ }
98
+
46
99
  // Helper function to wait until the microtask queue is empty.
47
100
  function flushPromises() {
48
101
  // eslint-disable-next-line @lwc/lwc/no-async-operation
@@ -56,6 +109,7 @@ describe('c-quantic-generated-answer-content', () => {
56
109
  document.body.removeChild(document.body.firstChild);
57
110
  }
58
111
  jest.clearAllMocks();
112
+ isInitialized = false;
59
113
  }
60
114
 
61
115
  afterEach(() => {
@@ -148,4 +202,219 @@ describe('c-quantic-generated-answer-content', () => {
148
202
  expect(loadMarkdownDependencies).toHaveBeenCalled();
149
203
  });
150
204
  });
205
+
206
+ describe('inline links in a markdown answer', () => {
207
+ const exampleLinks = [
208
+ {href: 'https://example.com/1', text: 'Link one'},
209
+ {href: 'https://example.com/2', text: 'Link two'},
210
+ ];
211
+
212
+ beforeEach(() => {
213
+ mockSuccessfulHeadlessInitialization();
214
+ prepareHeadlessState();
215
+ });
216
+
217
+ describe('when the answer contains anchors with data-answer-inline-link', () => {
218
+ it('should set target="_blank" and append an icon span to each inline link anchor', async () => {
219
+ const {LinkUtils} = jest.requireMock('c/quanticUtils');
220
+ LinkUtils.bindAnalyticsToLink.mockReturnValue(jest.fn());
221
+
222
+ const element = createTestComponent({
223
+ ...defaultOptions,
224
+ engineId: exampleEngineId,
225
+ answerId: exampleAnswerId,
226
+ answerContentFormat: 'text/markdown',
227
+ answer: buildAnswerWithInlineLinks(exampleLinks),
228
+ });
229
+ await flushPromises();
230
+
231
+ const answerContainer = element.shadowRoot.querySelector(
232
+ SELECTORS.markdownAnswerContainer
233
+ );
234
+ const anchors = answerContainer.querySelectorAll(SELECTORS.inlineLink);
235
+
236
+ expect(anchors).toHaveLength(exampleLinks.length);
237
+ anchors.forEach((anchor) => {
238
+ expect(anchor.target).toBe('_blank');
239
+ });
240
+ anchors.forEach((anchor) => {
241
+ const iconSpan = anchor.querySelector(
242
+ SELECTORS.inlineLinkIconContainer
243
+ );
244
+ expect(iconSpan).not.toBeNull();
245
+ expect(iconSpan.classList).toContain('slds-icon-utility-new_window');
246
+ expect(iconSpan.classList).toContain('slds-current-color');
247
+ expect(
248
+ iconSpan.querySelector(SELECTORS.inlineLinkIconSvg)
249
+ ).not.toBeNull();
250
+ });
251
+ });
252
+
253
+ it('should call buildInteractiveGeneratedAnswerInlineLink with the correct parameters for each inline link', async () => {
254
+ const {LinkUtils} = jest.requireMock('c/quanticUtils');
255
+ LinkUtils.bindAnalyticsToLink.mockReturnValue(jest.fn());
256
+
257
+ createTestComponent({
258
+ ...defaultOptions,
259
+ engineId: exampleEngineId,
260
+ answerId: exampleAnswerId,
261
+ answerContentFormat: 'text/markdown',
262
+ answer: buildAnswerWithInlineLinks(exampleLinks),
263
+ });
264
+ await flushPromises();
265
+
266
+ expect(
267
+ mockBuildInteractiveGeneratedAnswerInlineLink
268
+ ).toHaveBeenCalledTimes(exampleLinks.length);
269
+ expect(LinkUtils.bindAnalyticsToLink).toHaveBeenCalledTimes(
270
+ exampleLinks.length
271
+ );
272
+
273
+ exampleLinks.forEach((anchor, index) => {
274
+ expect(
275
+ mockBuildInteractiveGeneratedAnswerInlineLink
276
+ ).toHaveBeenNthCalledWith(index + 1, exampleEngine, {
277
+ options: {
278
+ link: {
279
+ linkURL: anchor.href,
280
+ linkText: anchor.text,
281
+ },
282
+ answerId: exampleAnswerId,
283
+ },
284
+ });
285
+ });
286
+ });
287
+
288
+ it('should call LinkUtils.bindAnalyticsToLink for each inline link with the anchor and its controller', async () => {
289
+ const {LinkUtils} = jest.requireMock('c/quanticUtils');
290
+ const mockUnbind = jest.fn();
291
+ LinkUtils.bindAnalyticsToLink.mockReturnValue(mockUnbind);
292
+
293
+ const element = createTestComponent({
294
+ ...defaultOptions,
295
+ engineId: exampleEngineId,
296
+ answerId: exampleAnswerId,
297
+ answerContentFormat: 'text/markdown',
298
+ answer: buildAnswerWithInlineLinks(exampleLinks),
299
+ });
300
+ await flushPromises();
301
+
302
+ expect(LinkUtils.bindAnalyticsToLink).toHaveBeenCalledTimes(
303
+ exampleLinks.length
304
+ );
305
+
306
+ const answerContainer = element.shadowRoot.querySelector(
307
+ SELECTORS.markdownAnswerContainer
308
+ );
309
+ const anchors = Array.from(
310
+ answerContainer.querySelectorAll(SELECTORS.inlineLink)
311
+ );
312
+
313
+ anchors.forEach((anchor, index) => {
314
+ expect(LinkUtils.bindAnalyticsToLink).toHaveBeenNthCalledWith(
315
+ index + 1,
316
+ anchor,
317
+ expect.objectContaining({
318
+ select: expect.any(Function),
319
+ beginDelayedSelect: expect.any(Function),
320
+ cancelPendingSelect: expect.any(Function),
321
+ })
322
+ );
323
+ });
324
+ });
325
+ });
326
+
327
+ describe('when the answer does not contain anchors with data-answer-inline-link', () => {
328
+ it('should not call buildInteractiveGeneratedAnswerInlineLink and LinkUtils.bindAnalyticsToLink', async () => {
329
+ const {LinkUtils} = jest.requireMock('c/quanticUtils');
330
+
331
+ createTestComponent({
332
+ ...defaultOptions,
333
+ engineId: exampleEngineId,
334
+ answerId: exampleAnswerId,
335
+ answerContentFormat: 'text/markdown',
336
+ answer: '<p>No inline links here</p>',
337
+ });
338
+ await flushPromises();
339
+
340
+ expect(
341
+ mockBuildInteractiveGeneratedAnswerInlineLink
342
+ ).not.toHaveBeenCalled();
343
+ expect(LinkUtils.bindAnalyticsToLink).not.toHaveBeenCalled();
344
+ });
345
+ });
346
+
347
+ describe('when the headless bundle does not expose buildInteractiveGeneratedAnswerInlineLink', () => {
348
+ it('should not throw and should not call LinkUtils.bindAnalyticsToLink', async () => {
349
+ // @ts-ignore
350
+ mockHeadlessLoader.getHeadlessBundle = () => ({});
351
+ const {LinkUtils} = jest.requireMock('c/quanticUtils');
352
+
353
+ expect(() => {
354
+ createTestComponent({
355
+ ...defaultOptions,
356
+ engineId: exampleEngineId,
357
+ answerId: exampleAnswerId,
358
+ answerContentFormat: 'text/markdown',
359
+ answer: buildAnswerWithInlineLinks(exampleLinks),
360
+ });
361
+ }).not.toThrow();
362
+
363
+ await flushPromises();
364
+
365
+ expect(LinkUtils.bindAnalyticsToLink).not.toHaveBeenCalled();
366
+ });
367
+ });
368
+
369
+ describe('when the answer is updated', () => {
370
+ it('should clean up previous inline link bindings before processing the new answer', async () => {
371
+ const {LinkUtils} = jest.requireMock('c/quanticUtils');
372
+ const mockUnbind = jest.fn();
373
+ LinkUtils.bindAnalyticsToLink.mockReturnValue(mockUnbind);
374
+
375
+ const element = createTestComponent({
376
+ ...defaultOptions,
377
+ engineId: exampleEngineId,
378
+ answerId: exampleAnswerId,
379
+ answerContentFormat: 'text/markdown',
380
+ answer: buildAnswerWithInlineLinks(exampleLinks),
381
+ });
382
+ await flushPromises();
383
+
384
+ expect(mockUnbind).not.toHaveBeenCalled();
385
+
386
+ const updatedLinks = [
387
+ {href: 'https://example.com/3', text: 'Link three'},
388
+ ];
389
+ element.answer = buildAnswerWithInlineLinks(updatedLinks);
390
+ await flushPromises();
391
+
392
+ expect(mockUnbind).toHaveBeenCalledTimes(exampleLinks.length);
393
+ });
394
+ });
395
+
396
+ describe('when the component is disconnected', () => {
397
+ it('should call the unbind function for each inline link binding', async () => {
398
+ const {LinkUtils} = jest.requireMock('c/quanticUtils');
399
+ const mockUnbind = jest.fn();
400
+ LinkUtils.bindAnalyticsToLink.mockReturnValue(mockUnbind);
401
+
402
+ const element = createTestComponent({
403
+ ...defaultOptions,
404
+ engineId: exampleEngineId,
405
+ answerId: exampleAnswerId,
406
+ answerContentFormat: 'text/markdown',
407
+ answer: buildAnswerWithInlineLinks(exampleLinks),
408
+ });
409
+ await flushPromises();
410
+
411
+ expect(mockUnbind).not.toHaveBeenCalled();
412
+
413
+ document.body.removeChild(element);
414
+ await flushPromises();
415
+
416
+ expect(mockUnbind).toHaveBeenCalledTimes(exampleLinks.length);
417
+ });
418
+ });
419
+ });
151
420
  });