@syntrologie/adapt-content 2.4.0 → 2.5.1

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 (54) hide show
  1. package/dist/editor.d.ts.map +1 -1
  2. package/dist/editor.js +59 -3
  3. package/dist/runtime.d.ts.map +1 -1
  4. package/dist/runtime.js +67 -12
  5. package/dist/summarize.d.ts.map +1 -1
  6. package/dist/summarize.js +12 -1
  7. package/dist/types.d.ts +9 -42
  8. package/dist/types.d.ts.map +1 -1
  9. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/AnchorPicker.test.d.ts +2 -0
  10. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/AnchorPicker.test.d.ts.map +1 -0
  11. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/AnchorPicker.test.js +224 -0
  12. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/ConditionStatusLine.test.js +102 -0
  13. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/DetectionBadge.test.js +58 -6
  14. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/DismissedSection.test.js +18 -0
  15. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/EditorCard.test.js +61 -2
  16. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/EditorPanelShell.test.js +478 -7
  17. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/ElementHighlight.test.js +54 -0
  18. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/selectorGenerator.test.d.ts +2 -0
  19. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/selectorGenerator.test.d.ts.map +1 -0
  20. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/selectorGenerator.test.js +257 -0
  21. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/useTriggerWhenStatus.test.d.ts +2 -0
  22. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/useTriggerWhenStatus.test.d.ts.map +1 -0
  23. package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/useTriggerWhenStatus.test.js +1015 -0
  24. package/node_modules/@syntrologie/shared-editor-ui/dist/components/AnchorPicker.js +1 -1
  25. package/node_modules/@syntrologie/shared-editor-ui/dist/components/ConditionStatusLine.d.ts +4 -4
  26. package/node_modules/@syntrologie/shared-editor-ui/dist/components/ConditionStatusLine.d.ts.map +1 -1
  27. package/node_modules/@syntrologie/shared-editor-ui/dist/components/ConditionStatusLine.js +2 -2
  28. package/node_modules/@syntrologie/shared-editor-ui/dist/components/DetectionBadge.d.ts +2 -1
  29. package/node_modules/@syntrologie/shared-editor-ui/dist/components/DetectionBadge.d.ts.map +1 -1
  30. package/node_modules/@syntrologie/shared-editor-ui/dist/components/DetectionBadge.js +20 -3
  31. package/node_modules/@syntrologie/shared-editor-ui/dist/components/EditorPanelShell.d.ts +10 -8
  32. package/node_modules/@syntrologie/shared-editor-ui/dist/components/EditorPanelShell.d.ts.map +1 -1
  33. package/node_modules/@syntrologie/shared-editor-ui/dist/components/EditorPanelShell.js +350 -87
  34. package/node_modules/@syntrologie/shared-editor-ui/dist/components/ElementHighlight.js +1 -1
  35. package/node_modules/@syntrologie/shared-editor-ui/dist/components/TriggerJourney.d.ts +3 -3
  36. package/node_modules/@syntrologie/shared-editor-ui/dist/components/TriggerJourney.d.ts.map +1 -1
  37. package/node_modules/@syntrologie/shared-editor-ui/dist/components/TriggerJourney.js +1 -1
  38. package/node_modules/@syntrologie/shared-editor-ui/dist/formatConditionLabel.d.ts +1 -1
  39. package/node_modules/@syntrologie/shared-editor-ui/dist/formatConditionLabel.d.ts.map +1 -1
  40. package/node_modules/@syntrologie/shared-editor-ui/dist/formatConditionLabel.js +5 -2
  41. package/node_modules/@syntrologie/shared-editor-ui/dist/hooks/useTriggerWhenStatus.d.ts +24 -0
  42. package/node_modules/@syntrologie/shared-editor-ui/dist/hooks/useTriggerWhenStatus.d.ts.map +1 -0
  43. package/node_modules/@syntrologie/shared-editor-ui/dist/hooks/{useShowWhenStatus.js → useTriggerWhenStatus.js} +18 -15
  44. package/node_modules/@syntrologie/shared-editor-ui/dist/index.d.ts +3 -3
  45. package/node_modules/@syntrologie/shared-editor-ui/dist/index.d.ts.map +1 -1
  46. package/node_modules/@syntrologie/shared-editor-ui/dist/index.js +1 -1
  47. package/package.json +4 -5
  48. package/node_modules/@syntrologie/sdk-contracts/dist/index.d.ts +0 -26
  49. package/node_modules/@syntrologie/sdk-contracts/dist/index.js +0 -13
  50. package/node_modules/@syntrologie/sdk-contracts/dist/schemas.d.ts +0 -1428
  51. package/node_modules/@syntrologie/sdk-contracts/dist/schemas.js +0 -142
  52. package/node_modules/@syntrologie/sdk-contracts/package.json +0 -33
  53. package/node_modules/@syntrologie/shared-editor-ui/dist/hooks/useShowWhenStatus.d.ts +0 -24
  54. package/node_modules/@syntrologie/shared-editor-ui/dist/hooks/useShowWhenStatus.d.ts.map +0 -1
@@ -0,0 +1,1015 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { useTriggerWhenStatus } from '../hooks/useTriggerWhenStatus';
4
+ function createMockRuntime(overrides = {}) {
5
+ return {
6
+ context: {
7
+ get: () => ({
8
+ page: { url: '/products', routeId: 'products' },
9
+ viewport: { width: 1024, height: 768 },
10
+ anchors: [],
11
+ }),
12
+ },
13
+ accumulator: {
14
+ getCount: () => 0,
15
+ subscribe: () => () => { },
16
+ },
17
+ events: {
18
+ hasRecentEvent: () => false,
19
+ },
20
+ state: {
21
+ isDismissed: () => false,
22
+ isCooldownActive: () => false,
23
+ getFrequencyCount: () => 0,
24
+ getSessionMetric: () => 0,
25
+ },
26
+ ...overrides,
27
+ };
28
+ }
29
+ function setRuntime(runtime) {
30
+ if (runtime) {
31
+ window.SynOS = { handle: { runtime } };
32
+ }
33
+ else {
34
+ delete window.SynOS;
35
+ }
36
+ }
37
+ // =============================================================================
38
+ // TESTS
39
+ // =============================================================================
40
+ describe('useTriggerWhenStatus', () => {
41
+ beforeEach(() => {
42
+ vi.useFakeTimers();
43
+ setRuntime(null);
44
+ });
45
+ afterEach(() => {
46
+ vi.useRealTimers();
47
+ setRuntime(null);
48
+ });
49
+ // ===========================================================================
50
+ // Basic behavior
51
+ // ===========================================================================
52
+ it('returns empty map when no runtime is available', () => {
53
+ setRuntime(null);
54
+ const { result } = renderHook(() => useTriggerWhenStatus([]));
55
+ expect(result.current.size).toBe(0);
56
+ });
57
+ it('returns null status for items without triggerWhen', () => {
58
+ setRuntime(createMockRuntime());
59
+ const items = [{ id: 'item-1' }];
60
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
61
+ expect(result.current.get('item-1')).toBeNull();
62
+ });
63
+ it('returns fallback status for non-rules type triggerWhen', () => {
64
+ setRuntime(createMockRuntime());
65
+ const items = [
66
+ {
67
+ id: 'item-1',
68
+ triggerWhen: { type: 'always', default: true },
69
+ },
70
+ ];
71
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
72
+ const status = result.current.get('item-1');
73
+ expect(status?.visible).toBe(true);
74
+ expect(status?.isFallback).toBe(true);
75
+ expect(status?.conditions).toEqual([]);
76
+ });
77
+ it('returns fallback status for rules type with no rules', () => {
78
+ setRuntime(createMockRuntime());
79
+ const items = [
80
+ {
81
+ id: 'item-1',
82
+ triggerWhen: { type: 'rules', rules: [], default: false },
83
+ },
84
+ ];
85
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
86
+ const status = result.current.get('item-1');
87
+ expect(status?.visible).toBe(false);
88
+ expect(status?.isFallback).toBe(true);
89
+ });
90
+ // ===========================================================================
91
+ // event_count condition
92
+ // ===========================================================================
93
+ describe('event_count condition', () => {
94
+ it('passes when count >= target (gte)', () => {
95
+ setRuntime(createMockRuntime({
96
+ accumulator: {
97
+ getCount: (key) => (key === 'page-views' ? 5 : 0),
98
+ subscribe: () => () => { },
99
+ },
100
+ }));
101
+ const items = [
102
+ {
103
+ id: 'item-1',
104
+ triggerWhen: {
105
+ type: 'rules',
106
+ rules: [
107
+ {
108
+ conditions: [{ type: 'event_count', key: 'page-views', operator: 'gte', count: 3 }],
109
+ value: true,
110
+ },
111
+ ],
112
+ default: false,
113
+ },
114
+ },
115
+ ];
116
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
117
+ const status = result.current.get('item-1');
118
+ expect(status?.visible).toBe(true);
119
+ expect(status?.isFallback).toBe(false);
120
+ expect(status?.conditions[0].passed).toBe(true);
121
+ });
122
+ it('fails when count < target (gte)', () => {
123
+ setRuntime(createMockRuntime({
124
+ accumulator: {
125
+ getCount: () => 2,
126
+ subscribe: () => () => { },
127
+ },
128
+ }));
129
+ const items = [
130
+ {
131
+ id: 'item-1',
132
+ triggerWhen: {
133
+ type: 'rules',
134
+ rules: [
135
+ {
136
+ conditions: [{ type: 'event_count', key: 'clicks', operator: 'gte', count: 5 }],
137
+ value: true,
138
+ },
139
+ ],
140
+ default: false,
141
+ },
142
+ },
143
+ ];
144
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
145
+ const status = result.current.get('item-1');
146
+ expect(status?.visible).toBe(false);
147
+ expect(status?.isFallback).toBe(true);
148
+ });
149
+ it.each([
150
+ { operator: 'lte', count: 5, actual: 3, expected: true },
151
+ { operator: 'lte', count: 3, actual: 5, expected: false },
152
+ { operator: 'eq', count: 3, actual: 3, expected: true },
153
+ { operator: 'eq', count: 3, actual: 4, expected: false },
154
+ { operator: 'gt', count: 3, actual: 4, expected: true },
155
+ { operator: 'gt', count: 3, actual: 3, expected: false },
156
+ { operator: 'lt', count: 3, actual: 2, expected: true },
157
+ { operator: 'lt', count: 3, actual: 3, expected: false },
158
+ ])('event_count $operator: actual=$actual vs target=$count => $expected', ({ operator, count, actual, expected, }) => {
159
+ setRuntime(createMockRuntime({
160
+ accumulator: {
161
+ getCount: () => actual,
162
+ subscribe: () => () => { },
163
+ },
164
+ }));
165
+ const items = [
166
+ {
167
+ id: 'item-1',
168
+ triggerWhen: {
169
+ type: 'rules',
170
+ rules: [
171
+ {
172
+ conditions: [{ type: 'event_count', key: 'events', operator, count }],
173
+ value: true,
174
+ },
175
+ ],
176
+ default: false,
177
+ },
178
+ },
179
+ ];
180
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
181
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(expected);
182
+ });
183
+ it('skips event_count when accumulator is missing', () => {
184
+ setRuntime(createMockRuntime({
185
+ accumulator: undefined,
186
+ }));
187
+ const items = [
188
+ {
189
+ id: 'item-1',
190
+ triggerWhen: {
191
+ type: 'rules',
192
+ rules: [
193
+ {
194
+ conditions: [{ type: 'event_count', key: 'clicks', operator: 'gte', count: 1 }],
195
+ value: true,
196
+ },
197
+ ],
198
+ default: false,
199
+ },
200
+ },
201
+ ];
202
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
203
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(false);
204
+ });
205
+ });
206
+ // ===========================================================================
207
+ // page_url condition
208
+ // ===========================================================================
209
+ describe('page_url condition', () => {
210
+ it('passes for exact URL match', () => {
211
+ setRuntime(createMockRuntime({
212
+ context: {
213
+ get: () => ({
214
+ page: { url: '/products' },
215
+ viewport: { width: 1024, height: 768 },
216
+ }),
217
+ },
218
+ }));
219
+ const items = [
220
+ {
221
+ id: 'item-1',
222
+ triggerWhen: {
223
+ type: 'rules',
224
+ rules: [{ conditions: [{ type: 'page_url', url: '/products' }], value: true }],
225
+ default: false,
226
+ },
227
+ },
228
+ ];
229
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
230
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
231
+ });
232
+ it('passes for single-star wildcard URL match (single segment)', () => {
233
+ setRuntime(createMockRuntime({
234
+ context: {
235
+ get: () => ({
236
+ page: { url: '/products/shoes' },
237
+ viewport: { width: 1024, height: 768 },
238
+ }),
239
+ },
240
+ }));
241
+ const items = [
242
+ {
243
+ id: 'item-1',
244
+ triggerWhen: {
245
+ type: 'rules',
246
+ rules: [{ conditions: [{ type: 'page_url', url: '/products/*' }], value: true }],
247
+ default: false,
248
+ },
249
+ },
250
+ ];
251
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
252
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
253
+ });
254
+ it('single-star wildcard does not match multi-segment paths', () => {
255
+ setRuntime(createMockRuntime({
256
+ context: {
257
+ get: () => ({
258
+ page: { url: '/products/shoes/running' },
259
+ viewport: { width: 1024, height: 768 },
260
+ }),
261
+ },
262
+ }));
263
+ const items = [
264
+ {
265
+ id: 'item-1',
266
+ triggerWhen: {
267
+ type: 'rules',
268
+ rules: [{ conditions: [{ type: 'page_url', url: '/products/*' }], value: true }],
269
+ default: false,
270
+ },
271
+ },
272
+ ];
273
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
274
+ // Single * only matches one path segment
275
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(false);
276
+ });
277
+ it('fails for non-matching URL', () => {
278
+ setRuntime(createMockRuntime({
279
+ context: {
280
+ get: () => ({
281
+ page: { url: '/about' },
282
+ viewport: { width: 1024, height: 768 },
283
+ }),
284
+ },
285
+ }));
286
+ const items = [
287
+ {
288
+ id: 'item-1',
289
+ triggerWhen: {
290
+ type: 'rules',
291
+ rules: [{ conditions: [{ type: 'page_url', url: '/products' }], value: true }],
292
+ default: false,
293
+ },
294
+ },
295
+ ];
296
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
297
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(false);
298
+ });
299
+ });
300
+ // ===========================================================================
301
+ // route condition
302
+ // ===========================================================================
303
+ describe('route condition', () => {
304
+ it('passes when routeId matches', () => {
305
+ setRuntime(createMockRuntime({
306
+ context: {
307
+ get: () => ({
308
+ page: { url: '/products', routeId: 'products' },
309
+ viewport: { width: 1024, height: 768 },
310
+ }),
311
+ },
312
+ }));
313
+ const items = [
314
+ {
315
+ id: 'item-1',
316
+ triggerWhen: {
317
+ type: 'rules',
318
+ rules: [{ conditions: [{ type: 'route', routeId: 'products' }], value: true }],
319
+ default: false,
320
+ },
321
+ },
322
+ ];
323
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
324
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
325
+ });
326
+ it('fails when routeId does not match', () => {
327
+ setRuntime(createMockRuntime({
328
+ context: {
329
+ get: () => ({
330
+ page: { url: '/about', routeId: 'about' },
331
+ viewport: { width: 1024, height: 768 },
332
+ }),
333
+ },
334
+ }));
335
+ const items = [
336
+ {
337
+ id: 'item-1',
338
+ triggerWhen: {
339
+ type: 'rules',
340
+ rules: [{ conditions: [{ type: 'route', routeId: 'home' }], value: true }],
341
+ default: false,
342
+ },
343
+ },
344
+ ];
345
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
346
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(false);
347
+ });
348
+ });
349
+ // ===========================================================================
350
+ // anchor_visible condition
351
+ // ===========================================================================
352
+ describe('anchor_visible condition', () => {
353
+ it('passes for visible anchor', () => {
354
+ setRuntime(createMockRuntime({
355
+ context: {
356
+ get: () => ({
357
+ page: { url: '/' },
358
+ viewport: { width: 1024, height: 768 },
359
+ anchors: [{ anchorId: 'hero', visible: true, present: true }],
360
+ }),
361
+ },
362
+ }));
363
+ const items = [
364
+ {
365
+ id: 'item-1',
366
+ triggerWhen: {
367
+ type: 'rules',
368
+ rules: [
369
+ {
370
+ conditions: [{ type: 'anchor_visible', anchorId: 'hero', state: 'visible' }],
371
+ value: true,
372
+ },
373
+ ],
374
+ default: false,
375
+ },
376
+ },
377
+ ];
378
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
379
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
380
+ });
381
+ it('passes for present anchor', () => {
382
+ setRuntime(createMockRuntime({
383
+ context: {
384
+ get: () => ({
385
+ page: { url: '/' },
386
+ viewport: { width: 1024, height: 768 },
387
+ anchors: [{ anchorId: 'sidebar', visible: false, present: true }],
388
+ }),
389
+ },
390
+ }));
391
+ const items = [
392
+ {
393
+ id: 'item-1',
394
+ triggerWhen: {
395
+ type: 'rules',
396
+ rules: [
397
+ {
398
+ conditions: [{ type: 'anchor_visible', anchorId: 'sidebar', state: 'present' }],
399
+ value: true,
400
+ },
401
+ ],
402
+ default: false,
403
+ },
404
+ },
405
+ ];
406
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
407
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
408
+ });
409
+ it('passes for absent anchor', () => {
410
+ setRuntime(createMockRuntime({
411
+ context: {
412
+ get: () => ({
413
+ page: { url: '/' },
414
+ viewport: { width: 1024, height: 768 },
415
+ anchors: [],
416
+ }),
417
+ },
418
+ }));
419
+ const items = [
420
+ {
421
+ id: 'item-1',
422
+ triggerWhen: {
423
+ type: 'rules',
424
+ rules: [
425
+ {
426
+ conditions: [
427
+ { type: 'anchor_visible', anchorId: 'deleted-section', state: 'absent' },
428
+ ],
429
+ value: true,
430
+ },
431
+ ],
432
+ default: false,
433
+ },
434
+ },
435
+ ];
436
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
437
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
438
+ });
439
+ });
440
+ // ===========================================================================
441
+ // event_occurred condition
442
+ // ===========================================================================
443
+ describe('event_occurred condition', () => {
444
+ it('passes when recent event exists', () => {
445
+ setRuntime(createMockRuntime({
446
+ events: { hasRecentEvent: (name) => name === 'click_cta' },
447
+ }));
448
+ const items = [
449
+ {
450
+ id: 'item-1',
451
+ triggerWhen: {
452
+ type: 'rules',
453
+ rules: [
454
+ {
455
+ conditions: [{ type: 'event_occurred', eventName: 'click_cta', withinMs: 5000 }],
456
+ value: true,
457
+ },
458
+ ],
459
+ default: false,
460
+ },
461
+ },
462
+ ];
463
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
464
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
465
+ });
466
+ it('falls back to false when events service is missing', () => {
467
+ setRuntime(createMockRuntime({
468
+ events: undefined,
469
+ }));
470
+ const items = [
471
+ {
472
+ id: 'item-1',
473
+ triggerWhen: {
474
+ type: 'rules',
475
+ rules: [
476
+ {
477
+ conditions: [{ type: 'event_occurred', eventName: 'click_cta' }],
478
+ value: true,
479
+ },
480
+ ],
481
+ default: false,
482
+ },
483
+ },
484
+ ];
485
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
486
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(false);
487
+ });
488
+ });
489
+ // ===========================================================================
490
+ // viewport condition
491
+ // ===========================================================================
492
+ describe('viewport condition', () => {
493
+ it('passes when viewport matches all constraints', () => {
494
+ setRuntime(createMockRuntime({
495
+ context: {
496
+ get: () => ({
497
+ page: { url: '/' },
498
+ viewport: { width: 1024, height: 768 },
499
+ }),
500
+ },
501
+ }));
502
+ const items = [
503
+ {
504
+ id: 'item-1',
505
+ triggerWhen: {
506
+ type: 'rules',
507
+ rules: [
508
+ {
509
+ conditions: [
510
+ {
511
+ type: 'viewport',
512
+ minWidth: 800,
513
+ maxWidth: 1200,
514
+ minHeight: 600,
515
+ maxHeight: 900,
516
+ },
517
+ ],
518
+ value: true,
519
+ },
520
+ ],
521
+ default: false,
522
+ },
523
+ },
524
+ ];
525
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
526
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
527
+ });
528
+ it('fails when width is below minWidth', () => {
529
+ setRuntime(createMockRuntime({
530
+ context: {
531
+ get: () => ({
532
+ page: { url: '/' },
533
+ viewport: { width: 500, height: 768 },
534
+ }),
535
+ },
536
+ }));
537
+ const items = [
538
+ {
539
+ id: 'item-1',
540
+ triggerWhen: {
541
+ type: 'rules',
542
+ rules: [{ conditions: [{ type: 'viewport', minWidth: 800 }], value: true }],
543
+ default: false,
544
+ },
545
+ },
546
+ ];
547
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
548
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(false);
549
+ });
550
+ it('fails when width exceeds maxWidth', () => {
551
+ setRuntime(createMockRuntime({
552
+ context: {
553
+ get: () => ({
554
+ page: { url: '/' },
555
+ viewport: { width: 2000, height: 768 },
556
+ }),
557
+ },
558
+ }));
559
+ const items = [
560
+ {
561
+ id: 'item-1',
562
+ triggerWhen: {
563
+ type: 'rules',
564
+ rules: [{ conditions: [{ type: 'viewport', maxWidth: 1200 }], value: true }],
565
+ default: false,
566
+ },
567
+ },
568
+ ];
569
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
570
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(false);
571
+ });
572
+ it('fails when height is below minHeight', () => {
573
+ setRuntime(createMockRuntime({
574
+ context: {
575
+ get: () => ({
576
+ page: { url: '/' },
577
+ viewport: { width: 1024, height: 300 },
578
+ }),
579
+ },
580
+ }));
581
+ const items = [
582
+ {
583
+ id: 'item-1',
584
+ triggerWhen: {
585
+ type: 'rules',
586
+ rules: [{ conditions: [{ type: 'viewport', minHeight: 600 }], value: true }],
587
+ default: false,
588
+ },
589
+ },
590
+ ];
591
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
592
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(false);
593
+ });
594
+ it('fails when height exceeds maxHeight', () => {
595
+ setRuntime(createMockRuntime({
596
+ context: {
597
+ get: () => ({
598
+ page: { url: '/' },
599
+ viewport: { width: 1024, height: 1500 },
600
+ }),
601
+ },
602
+ }));
603
+ const items = [
604
+ {
605
+ id: 'item-1',
606
+ triggerWhen: {
607
+ type: 'rules',
608
+ rules: [{ conditions: [{ type: 'viewport', maxHeight: 900 }], value: true }],
609
+ default: false,
610
+ },
611
+ },
612
+ ];
613
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
614
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(false);
615
+ });
616
+ });
617
+ // ===========================================================================
618
+ // session_metric condition
619
+ // ===========================================================================
620
+ describe('session_metric condition', () => {
621
+ it.each([
622
+ { operator: 'gte', threshold: 3, actual: 5, expected: true },
623
+ { operator: 'gte', threshold: 5, actual: 3, expected: false },
624
+ { operator: 'lte', threshold: 5, actual: 3, expected: true },
625
+ { operator: 'eq', threshold: 3, actual: 3, expected: true },
626
+ { operator: 'gt', threshold: 3, actual: 4, expected: true },
627
+ { operator: 'lt', threshold: 3, actual: 2, expected: true },
628
+ ])('session_metric $operator: $actual vs $threshold => $expected', ({ operator, threshold, actual, expected, }) => {
629
+ setRuntime(createMockRuntime({
630
+ state: {
631
+ isDismissed: () => false,
632
+ isCooldownActive: () => false,
633
+ getFrequencyCount: () => 0,
634
+ getSessionMetric: (key) => (key === 'engagement' ? actual : 0),
635
+ },
636
+ }));
637
+ const items = [
638
+ {
639
+ id: 'item-1',
640
+ triggerWhen: {
641
+ type: 'rules',
642
+ rules: [
643
+ {
644
+ conditions: [{ type: 'session_metric', key: 'engagement', operator, threshold }],
645
+ value: true,
646
+ },
647
+ ],
648
+ default: false,
649
+ },
650
+ },
651
+ ];
652
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
653
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(expected);
654
+ });
655
+ });
656
+ // ===========================================================================
657
+ // dismissed condition
658
+ // ===========================================================================
659
+ describe('dismissed condition', () => {
660
+ it('passes when item is dismissed', () => {
661
+ setRuntime(createMockRuntime({
662
+ state: {
663
+ isDismissed: (key) => key === 'tooltip-1',
664
+ isCooldownActive: () => false,
665
+ getFrequencyCount: () => 0,
666
+ getSessionMetric: () => 0,
667
+ },
668
+ }));
669
+ const items = [
670
+ {
671
+ id: 'item-1',
672
+ triggerWhen: {
673
+ type: 'rules',
674
+ rules: [
675
+ {
676
+ conditions: [{ type: 'dismissed', key: 'tooltip-1' }],
677
+ value: true,
678
+ },
679
+ ],
680
+ default: false,
681
+ },
682
+ },
683
+ ];
684
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
685
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
686
+ });
687
+ it('passes inverted dismissed (not dismissed)', () => {
688
+ setRuntime(createMockRuntime({
689
+ state: {
690
+ isDismissed: () => false,
691
+ isCooldownActive: () => false,
692
+ getFrequencyCount: () => 0,
693
+ getSessionMetric: () => 0,
694
+ },
695
+ }));
696
+ const items = [
697
+ {
698
+ id: 'item-1',
699
+ triggerWhen: {
700
+ type: 'rules',
701
+ rules: [
702
+ {
703
+ conditions: [{ type: 'dismissed', key: 'tooltip-1', inverted: true }],
704
+ value: true,
705
+ },
706
+ ],
707
+ default: false,
708
+ },
709
+ },
710
+ ];
711
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
712
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
713
+ });
714
+ });
715
+ // ===========================================================================
716
+ // cooldown_active condition
717
+ // ===========================================================================
718
+ describe('cooldown_active condition', () => {
719
+ it('passes when cooldown is active', () => {
720
+ setRuntime(createMockRuntime({
721
+ state: {
722
+ isDismissed: () => false,
723
+ isCooldownActive: () => true,
724
+ getFrequencyCount: () => 0,
725
+ getSessionMetric: () => 0,
726
+ },
727
+ }));
728
+ const items = [
729
+ {
730
+ id: 'item-1',
731
+ triggerWhen: {
732
+ type: 'rules',
733
+ rules: [
734
+ {
735
+ conditions: [{ type: 'cooldown_active', key: 'tip-1' }],
736
+ value: true,
737
+ },
738
+ ],
739
+ default: false,
740
+ },
741
+ },
742
+ ];
743
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
744
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
745
+ });
746
+ it('passes inverted cooldown (cooldown not active)', () => {
747
+ setRuntime(createMockRuntime({
748
+ state: {
749
+ isDismissed: () => false,
750
+ isCooldownActive: () => false,
751
+ getFrequencyCount: () => 0,
752
+ getSessionMetric: () => 0,
753
+ },
754
+ }));
755
+ const items = [
756
+ {
757
+ id: 'item-1',
758
+ triggerWhen: {
759
+ type: 'rules',
760
+ rules: [
761
+ {
762
+ conditions: [{ type: 'cooldown_active', key: 'tip-1', inverted: true }],
763
+ value: true,
764
+ },
765
+ ],
766
+ default: false,
767
+ },
768
+ },
769
+ ];
770
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
771
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
772
+ });
773
+ });
774
+ // ===========================================================================
775
+ // frequency_limit condition
776
+ // ===========================================================================
777
+ describe('frequency_limit condition', () => {
778
+ it('passes when frequency count >= limit', () => {
779
+ setRuntime(createMockRuntime({
780
+ state: {
781
+ isDismissed: () => false,
782
+ isCooldownActive: () => false,
783
+ getFrequencyCount: () => 5,
784
+ getSessionMetric: () => 0,
785
+ },
786
+ }));
787
+ const items = [
788
+ {
789
+ id: 'item-1',
790
+ triggerWhen: {
791
+ type: 'rules',
792
+ rules: [
793
+ {
794
+ conditions: [{ type: 'frequency_limit', key: 'tip-1', limit: 3 }],
795
+ value: true,
796
+ },
797
+ ],
798
+ default: false,
799
+ },
800
+ },
801
+ ];
802
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
803
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
804
+ });
805
+ it('passes inverted frequency limit (below limit)', () => {
806
+ setRuntime(createMockRuntime({
807
+ state: {
808
+ isDismissed: () => false,
809
+ isCooldownActive: () => false,
810
+ getFrequencyCount: () => 1,
811
+ getSessionMetric: () => 0,
812
+ },
813
+ }));
814
+ const items = [
815
+ {
816
+ id: 'item-1',
817
+ triggerWhen: {
818
+ type: 'rules',
819
+ rules: [
820
+ {
821
+ conditions: [{ type: 'frequency_limit', key: 'tip-1', limit: 3, inverted: true }],
822
+ value: true,
823
+ },
824
+ ],
825
+ default: false,
826
+ },
827
+ },
828
+ ];
829
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
830
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
831
+ });
832
+ });
833
+ // ===========================================================================
834
+ // Multi-condition rules
835
+ // ===========================================================================
836
+ describe('multi-condition rules', () => {
837
+ it('requires ALL conditions to pass for a rule to match', () => {
838
+ setRuntime(createMockRuntime({
839
+ context: {
840
+ get: () => ({
841
+ page: { url: '/products', routeId: 'products' },
842
+ viewport: { width: 1024, height: 768 },
843
+ }),
844
+ },
845
+ accumulator: {
846
+ getCount: () => 5,
847
+ subscribe: () => () => { },
848
+ },
849
+ }));
850
+ const items = [
851
+ {
852
+ id: 'item-1',
853
+ triggerWhen: {
854
+ type: 'rules',
855
+ rules: [
856
+ {
857
+ conditions: [
858
+ { type: 'page_url', url: '/products' },
859
+ { type: 'event_count', key: 'views', operator: 'gte', count: 3 },
860
+ ],
861
+ value: true,
862
+ },
863
+ ],
864
+ default: false,
865
+ },
866
+ },
867
+ ];
868
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
869
+ expect(result.current.get('item-1')?.visible).toBe(true);
870
+ });
871
+ it('falls through to next rule when first rule fails', () => {
872
+ setRuntime(createMockRuntime({
873
+ context: {
874
+ get: () => ({
875
+ page: { url: '/about', routeId: 'about' },
876
+ viewport: { width: 1024, height: 768 },
877
+ }),
878
+ },
879
+ }));
880
+ const items = [
881
+ {
882
+ id: 'item-1',
883
+ triggerWhen: {
884
+ type: 'rules',
885
+ rules: [
886
+ {
887
+ conditions: [{ type: 'page_url', url: '/products' }],
888
+ value: true,
889
+ },
890
+ {
891
+ conditions: [{ type: 'page_url', url: '/about' }],
892
+ value: true,
893
+ },
894
+ ],
895
+ default: false,
896
+ },
897
+ },
898
+ ];
899
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
900
+ expect(result.current.get('item-1')?.visible).toBe(true);
901
+ });
902
+ it('falls to default when no rules match', () => {
903
+ setRuntime(createMockRuntime({
904
+ context: {
905
+ get: () => ({
906
+ page: { url: '/contact' },
907
+ viewport: { width: 1024, height: 768 },
908
+ }),
909
+ },
910
+ }));
911
+ const items = [
912
+ {
913
+ id: 'item-1',
914
+ triggerWhen: {
915
+ type: 'rules',
916
+ rules: [{ conditions: [{ type: 'page_url', url: '/products' }], value: true }],
917
+ default: true,
918
+ },
919
+ },
920
+ ];
921
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
922
+ const status = result.current.get('item-1');
923
+ expect(status?.visible).toBe(true);
924
+ expect(status?.isFallback).toBe(true);
925
+ });
926
+ });
927
+ // ===========================================================================
928
+ // Reactive updates
929
+ // ===========================================================================
930
+ describe('reactive updates', () => {
931
+ it('subscribes to accumulator and re-evaluates on changes', () => {
932
+ let subscribeCb = null;
933
+ let currentCount = 0;
934
+ setRuntime(createMockRuntime({
935
+ accumulator: {
936
+ getCount: () => currentCount,
937
+ subscribe: (cb) => {
938
+ subscribeCb = cb;
939
+ return () => {
940
+ subscribeCb = null;
941
+ };
942
+ },
943
+ },
944
+ }));
945
+ const items = [
946
+ {
947
+ id: 'item-1',
948
+ triggerWhen: {
949
+ type: 'rules',
950
+ rules: [
951
+ {
952
+ conditions: [{ type: 'event_count', key: 'clicks', operator: 'gte', count: 3 }],
953
+ value: true,
954
+ },
955
+ ],
956
+ default: false,
957
+ },
958
+ },
959
+ ];
960
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
961
+ // Initially: count=0, should fail
962
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(false);
963
+ // Update count and trigger subscription
964
+ currentCount = 5;
965
+ act(() => {
966
+ subscribeCb?.();
967
+ });
968
+ expect(result.current.get('item-1')?.conditions[0].passed).toBe(true);
969
+ });
970
+ it('re-evaluates on polling interval', () => {
971
+ let currentUrl = '/home';
972
+ setRuntime(createMockRuntime({
973
+ context: {
974
+ get: () => ({
975
+ page: { url: currentUrl },
976
+ viewport: { width: 1024, height: 768 },
977
+ }),
978
+ },
979
+ }));
980
+ const items = [
981
+ {
982
+ id: 'item-1',
983
+ triggerWhen: {
984
+ type: 'rules',
985
+ rules: [{ conditions: [{ type: 'page_url', url: '/products' }], value: true }],
986
+ default: false,
987
+ },
988
+ },
989
+ ];
990
+ const { result } = renderHook(() => useTriggerWhenStatus(items));
991
+ // Initially: URL is /home, should fail
992
+ expect(result.current.get('item-1')?.visible).toBe(false);
993
+ // Change URL and advance timer
994
+ currentUrl = '/products';
995
+ act(() => {
996
+ vi.advanceTimersByTime(2000);
997
+ });
998
+ expect(result.current.get('item-1')?.visible).toBe(true);
999
+ });
1000
+ it('cleans up subscription and interval on unmount', () => {
1001
+ let unsubCalled = false;
1002
+ setRuntime(createMockRuntime({
1003
+ accumulator: {
1004
+ getCount: () => 0,
1005
+ subscribe: () => () => {
1006
+ unsubCalled = true;
1007
+ },
1008
+ },
1009
+ }));
1010
+ const { unmount } = renderHook(() => useTriggerWhenStatus([]));
1011
+ unmount();
1012
+ expect(unsubCalled).toBe(true);
1013
+ });
1014
+ });
1015
+ });