@syntrologie/adapt-content 2.3.0 → 2.4.0-canary.10
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/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/EditorPanelShell.test.js +20 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/selectorGenerator.test.d.ts +2 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/selectorGenerator.test.d.ts.map +1 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/selectorGenerator.test.js +257 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/useShowWhenStatus.test.d.ts +2 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/useShowWhenStatus.test.d.ts.map +1 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/useShowWhenStatus.test.js +1015 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/EditorPanelShell.js +1 -1
- package/package.json +3 -3
|
@@ -22,4 +22,24 @@ describe('EditorPanelShell', () => {
|
|
|
22
22
|
const panel = container.querySelector('[data-syntro-editor-panel]');
|
|
23
23
|
expect(panel).toBeNull();
|
|
24
24
|
});
|
|
25
|
+
it('renders with se-left-0 class when position is left', () => {
|
|
26
|
+
const { container } = render(_jsx(EditorPanelShell, { isOpen: true, onToggle: () => { }, position: "left", children: _jsx("div", { children: "Content" }) }));
|
|
27
|
+
const panel = container.querySelector('[data-syntro-editor-panel]');
|
|
28
|
+
expect(panel).toBeTruthy();
|
|
29
|
+
expect(panel.className).toContain('se-left-0');
|
|
30
|
+
expect(panel.className).not.toContain('se-right-0');
|
|
31
|
+
});
|
|
32
|
+
it('renders with se-right-0 class when position is right', () => {
|
|
33
|
+
const { container } = render(_jsx(EditorPanelShell, { isOpen: true, onToggle: () => { }, position: "right", children: _jsx("div", { children: "Content" }) }));
|
|
34
|
+
const panel = container.querySelector('[data-syntro-editor-panel]');
|
|
35
|
+
expect(panel).toBeTruthy();
|
|
36
|
+
expect(panel.className).toContain('se-right-0');
|
|
37
|
+
expect(panel.className).not.toContain('se-left-0');
|
|
38
|
+
});
|
|
39
|
+
it('has 480px width class', () => {
|
|
40
|
+
const { container } = render(_jsx(EditorPanelShell, { isOpen: true, onToggle: () => { }, position: "right", children: _jsx("div", { children: "Content" }) }));
|
|
41
|
+
const panel = container.querySelector('[data-syntro-editor-panel]');
|
|
42
|
+
expect(panel).toBeTruthy();
|
|
43
|
+
expect(panel.className).toContain('se-w-[480px]');
|
|
44
|
+
});
|
|
25
45
|
});
|
package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/selectorGenerator.test.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"selectorGenerator.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/selectorGenerator.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
// Shim CSS.escape for jsdom (not available by default)
|
|
3
|
+
if (typeof CSS === 'undefined') {
|
|
4
|
+
globalThis.CSS = { escape: (str) => str.replace(/([^\w-])/g, '\\$1') };
|
|
5
|
+
}
|
|
6
|
+
else if (!CSS.escape) {
|
|
7
|
+
CSS.escape = (str) => str.replace(/([^\w-])/g, '\\$1');
|
|
8
|
+
}
|
|
9
|
+
import { generateSelector, getElementDescription, validateSelector, } from '../utils/selectorGenerator';
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Mock css-selector-generator
|
|
12
|
+
// =============================================================================
|
|
13
|
+
vi.mock('css-selector-generator', () => ({
|
|
14
|
+
getCssSelector: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
import { getCssSelector } from 'css-selector-generator';
|
|
17
|
+
const mockGetCssSelector = vi.mocked(getCssSelector);
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// HELPERS
|
|
20
|
+
// =============================================================================
|
|
21
|
+
function createElement(tag, attrs = {}) {
|
|
22
|
+
const el = document.createElement(tag);
|
|
23
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
24
|
+
if (key === 'textContent') {
|
|
25
|
+
el.textContent = value;
|
|
26
|
+
}
|
|
27
|
+
else if (key === 'className') {
|
|
28
|
+
el.className = value;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
el.setAttribute(key, value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
document.body.appendChild(el);
|
|
35
|
+
return el;
|
|
36
|
+
}
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// generateSelector
|
|
39
|
+
// =============================================================================
|
|
40
|
+
describe('generateSelector', () => {
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.clearAllMocks();
|
|
43
|
+
document.body.innerHTML = '';
|
|
44
|
+
});
|
|
45
|
+
it('calls getCssSelector with element and default options', () => {
|
|
46
|
+
const el = createElement('div');
|
|
47
|
+
mockGetCssSelector.mockReturnValue('div');
|
|
48
|
+
generateSelector(el);
|
|
49
|
+
expect(mockGetCssSelector).toHaveBeenCalledWith(el, expect.objectContaining({
|
|
50
|
+
includeTag: true,
|
|
51
|
+
maxCombinations: 100,
|
|
52
|
+
}));
|
|
53
|
+
});
|
|
54
|
+
it('returns the selector from getCssSelector', () => {
|
|
55
|
+
const el = createElement('div', { id: 'my-id' });
|
|
56
|
+
mockGetCssSelector.mockReturnValue('#my-id');
|
|
57
|
+
const result = generateSelector(el);
|
|
58
|
+
expect(result).toBe('#my-id');
|
|
59
|
+
});
|
|
60
|
+
it('passes custom options through', () => {
|
|
61
|
+
const el = createElement('div');
|
|
62
|
+
mockGetCssSelector.mockReturnValue('div');
|
|
63
|
+
generateSelector(el, { includeTag: false, maxCombinations: 50 });
|
|
64
|
+
expect(mockGetCssSelector).toHaveBeenCalledWith(el, expect.objectContaining({
|
|
65
|
+
includeTag: false,
|
|
66
|
+
maxCombinations: 50,
|
|
67
|
+
}));
|
|
68
|
+
});
|
|
69
|
+
it('includes attribute selectors when preferTestIds is true (default)', () => {
|
|
70
|
+
const el = createElement('div');
|
|
71
|
+
mockGetCssSelector.mockReturnValue('div');
|
|
72
|
+
generateSelector(el);
|
|
73
|
+
const call = mockGetCssSelector.mock.calls[0];
|
|
74
|
+
const options = call[1];
|
|
75
|
+
expect(options.selectors).toContain('attribute');
|
|
76
|
+
});
|
|
77
|
+
it('excludes attribute selectors when preferTestIds is false', () => {
|
|
78
|
+
const el = createElement('div');
|
|
79
|
+
mockGetCssSelector.mockReturnValue('div');
|
|
80
|
+
generateSelector(el, { preferTestIds: false });
|
|
81
|
+
const call = mockGetCssSelector.mock.calls[0];
|
|
82
|
+
const options = call[1];
|
|
83
|
+
expect(options.selectors).not.toContain('attribute');
|
|
84
|
+
});
|
|
85
|
+
it('includes blacklist patterns for dynamic classes', () => {
|
|
86
|
+
const el = createElement('div');
|
|
87
|
+
mockGetCssSelector.mockReturnValue('div');
|
|
88
|
+
generateSelector(el);
|
|
89
|
+
const call = mockGetCssSelector.mock.calls[0];
|
|
90
|
+
const options = call[1];
|
|
91
|
+
expect(options.blacklist).toBeDefined();
|
|
92
|
+
expect(Array.isArray(options.blacklist)).toBe(true);
|
|
93
|
+
expect(options.blacklist.length).toBeGreaterThan(0);
|
|
94
|
+
});
|
|
95
|
+
it('includes whitelist for stable attributes', () => {
|
|
96
|
+
const el = createElement('div');
|
|
97
|
+
mockGetCssSelector.mockReturnValue('div');
|
|
98
|
+
generateSelector(el);
|
|
99
|
+
const call = mockGetCssSelector.mock.calls[0];
|
|
100
|
+
const options = call[1];
|
|
101
|
+
expect(options.whitelist).toBeDefined();
|
|
102
|
+
expect(Array.isArray(options.whitelist)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
it('falls back to generateFallbackSelector when getCssSelector throws', () => {
|
|
105
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
106
|
+
const parent = document.createElement('div');
|
|
107
|
+
parent.id = 'parent';
|
|
108
|
+
document.body.appendChild(parent);
|
|
109
|
+
const child = document.createElement('span');
|
|
110
|
+
parent.appendChild(child);
|
|
111
|
+
mockGetCssSelector.mockImplementation(() => {
|
|
112
|
+
throw new Error('Failed');
|
|
113
|
+
});
|
|
114
|
+
const result = generateSelector(child);
|
|
115
|
+
// Fallback should produce a path-based selector
|
|
116
|
+
expect(result).toContain('#parent');
|
|
117
|
+
expect(result).toContain('span');
|
|
118
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
119
|
+
document.body.removeChild(parent);
|
|
120
|
+
});
|
|
121
|
+
describe('fallback selector generation', () => {
|
|
122
|
+
it('uses ID when element has an ID', () => {
|
|
123
|
+
const el = document.createElement('div');
|
|
124
|
+
el.id = 'unique-element';
|
|
125
|
+
document.body.appendChild(el);
|
|
126
|
+
mockGetCssSelector.mockImplementation(() => {
|
|
127
|
+
throw new Error('Failed');
|
|
128
|
+
});
|
|
129
|
+
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
130
|
+
const result = generateSelector(el);
|
|
131
|
+
expect(result).toContain('#unique-element');
|
|
132
|
+
document.body.removeChild(el);
|
|
133
|
+
});
|
|
134
|
+
it('uses nth-child for elements without ID', () => {
|
|
135
|
+
const parent = document.createElement('div');
|
|
136
|
+
document.body.appendChild(parent);
|
|
137
|
+
const child1 = document.createElement('p');
|
|
138
|
+
const child2 = document.createElement('p');
|
|
139
|
+
parent.appendChild(child1);
|
|
140
|
+
parent.appendChild(child2);
|
|
141
|
+
mockGetCssSelector.mockImplementation(() => {
|
|
142
|
+
throw new Error('Failed');
|
|
143
|
+
});
|
|
144
|
+
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
145
|
+
const result = generateSelector(child2);
|
|
146
|
+
expect(result).toContain('nth-child');
|
|
147
|
+
document.body.removeChild(parent);
|
|
148
|
+
});
|
|
149
|
+
it('stops at element with ID (shortest unique path)', () => {
|
|
150
|
+
const grandparent = document.createElement('div');
|
|
151
|
+
grandparent.id = 'root';
|
|
152
|
+
document.body.appendChild(grandparent);
|
|
153
|
+
const parent = document.createElement('div');
|
|
154
|
+
grandparent.appendChild(parent);
|
|
155
|
+
const child = document.createElement('span');
|
|
156
|
+
parent.appendChild(child);
|
|
157
|
+
mockGetCssSelector.mockImplementation(() => {
|
|
158
|
+
throw new Error('Failed');
|
|
159
|
+
});
|
|
160
|
+
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
161
|
+
const result = generateSelector(child);
|
|
162
|
+
// Should start with #root
|
|
163
|
+
expect(result.startsWith('#root')).toBe(true);
|
|
164
|
+
document.body.removeChild(grandparent);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
// =============================================================================
|
|
169
|
+
// validateSelector
|
|
170
|
+
// =============================================================================
|
|
171
|
+
describe('validateSelector', () => {
|
|
172
|
+
afterEach(() => {
|
|
173
|
+
document.body.innerHTML = '';
|
|
174
|
+
});
|
|
175
|
+
it('returns true when selector matches the expected element', () => {
|
|
176
|
+
const el = createElement('div', { id: 'target' });
|
|
177
|
+
expect(validateSelector('#target', el)).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
it('returns false when selector matches a different element', () => {
|
|
180
|
+
const el1 = createElement('div', { id: 'first' });
|
|
181
|
+
createElement('div', { id: 'second' });
|
|
182
|
+
expect(validateSelector('#second', el1)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
it('returns false when selector matches nothing', () => {
|
|
185
|
+
const el = createElement('div');
|
|
186
|
+
expect(validateSelector('#nonexistent', el)).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
it('returns false for invalid selector syntax', () => {
|
|
189
|
+
const el = createElement('div');
|
|
190
|
+
expect(validateSelector('[[[invalid', el)).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
// =============================================================================
|
|
194
|
+
// getElementDescription
|
|
195
|
+
// =============================================================================
|
|
196
|
+
describe('getElementDescription', () => {
|
|
197
|
+
afterEach(() => {
|
|
198
|
+
document.body.innerHTML = '';
|
|
199
|
+
});
|
|
200
|
+
it('includes tag name', () => {
|
|
201
|
+
const el = createElement('button');
|
|
202
|
+
expect(getElementDescription(el)).toContain('button');
|
|
203
|
+
});
|
|
204
|
+
it('includes ID when present', () => {
|
|
205
|
+
const el = createElement('div', { id: 'my-btn' });
|
|
206
|
+
expect(getElementDescription(el)).toContain('#my-btn');
|
|
207
|
+
});
|
|
208
|
+
it('includes static class names', () => {
|
|
209
|
+
const el = createElement('div', { className: 'header main-content' });
|
|
210
|
+
const desc = getElementDescription(el);
|
|
211
|
+
expect(desc).toContain('.header');
|
|
212
|
+
expect(desc).toContain('.main-content');
|
|
213
|
+
});
|
|
214
|
+
it('excludes dynamic class names', () => {
|
|
215
|
+
const el = createElement('div', { className: 'css-1abc23 my-class' });
|
|
216
|
+
const desc = getElementDescription(el);
|
|
217
|
+
// Dynamic class css-1abc23 should be filtered out
|
|
218
|
+
expect(desc).not.toContain('css-1abc23');
|
|
219
|
+
expect(desc).toContain('my-class');
|
|
220
|
+
});
|
|
221
|
+
it('includes text content preview (truncated at 30 chars)', () => {
|
|
222
|
+
const el = createElement('p', {
|
|
223
|
+
textContent: 'This is a very long text that should be truncated',
|
|
224
|
+
});
|
|
225
|
+
const desc = getElementDescription(el);
|
|
226
|
+
expect(desc).toContain('"');
|
|
227
|
+
expect(desc).toContain('...');
|
|
228
|
+
});
|
|
229
|
+
it('includes full text for short content', () => {
|
|
230
|
+
const el = createElement('span', { textContent: 'Hello' });
|
|
231
|
+
const desc = getElementDescription(el);
|
|
232
|
+
expect(desc).toContain('"Hello"');
|
|
233
|
+
expect(desc).not.toContain('...');
|
|
234
|
+
});
|
|
235
|
+
it('handles elements with no text content', () => {
|
|
236
|
+
const el = createElement('img');
|
|
237
|
+
const desc = getElementDescription(el);
|
|
238
|
+
expect(desc).toBe('img');
|
|
239
|
+
});
|
|
240
|
+
it('limits class names to 2', () => {
|
|
241
|
+
const el = createElement('div', { className: 'cls-a cls-b cls-c cls-d' });
|
|
242
|
+
const desc = getElementDescription(el);
|
|
243
|
+
// Should include at most 2 classes
|
|
244
|
+
expect(desc).toContain('.cls-a');
|
|
245
|
+
expect(desc).toContain('.cls-b');
|
|
246
|
+
expect(desc).not.toContain('.cls-c');
|
|
247
|
+
});
|
|
248
|
+
it('handles SVGElement className (not a string)', () => {
|
|
249
|
+
const svgNS = 'http://www.w3.org/2000/svg';
|
|
250
|
+
const svg = document.createElementNS(svgNS, 'svg');
|
|
251
|
+
document.body.appendChild(svg);
|
|
252
|
+
// SVGElement.className is an SVGAnimatedString, not a string
|
|
253
|
+
const desc = getElementDescription(svg);
|
|
254
|
+
expect(desc).toContain('svg');
|
|
255
|
+
document.body.removeChild(svg);
|
|
256
|
+
});
|
|
257
|
+
});
|
package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/useShowWhenStatus.test.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useShowWhenStatus.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/useShowWhenStatus.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,1015 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { useShowWhenStatus } from '../hooks/useShowWhenStatus';
|
|
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('useShowWhenStatus', () => {
|
|
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(() => useShowWhenStatus([]));
|
|
55
|
+
expect(result.current.size).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
it('returns null status for items without showWhen', () => {
|
|
58
|
+
setRuntime(createMockRuntime());
|
|
59
|
+
const items = [{ id: 'item-1' }];
|
|
60
|
+
const { result } = renderHook(() => useShowWhenStatus(items));
|
|
61
|
+
expect(result.current.get('item-1')).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
it('returns fallback status for non-rules type showWhen', () => {
|
|
64
|
+
setRuntime(createMockRuntime());
|
|
65
|
+
const items = [
|
|
66
|
+
{
|
|
67
|
+
id: 'item-1',
|
|
68
|
+
showWhen: { type: 'always', default: true },
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
const { result } = renderHook(() => useShowWhenStatus(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
|
+
showWhen: { type: 'rules', rules: [], default: false },
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
const { result } = renderHook(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
223
|
+
type: 'rules',
|
|
224
|
+
rules: [{ conditions: [{ type: 'page_url', url: '/products' }], value: true }],
|
|
225
|
+
default: false,
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
];
|
|
229
|
+
const { result } = renderHook(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
245
|
+
type: 'rules',
|
|
246
|
+
rules: [{ conditions: [{ type: 'page_url', url: '/products/*' }], value: true }],
|
|
247
|
+
default: false,
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
];
|
|
251
|
+
const { result } = renderHook(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
267
|
+
type: 'rules',
|
|
268
|
+
rules: [{ conditions: [{ type: 'page_url', url: '/products/*' }], value: true }],
|
|
269
|
+
default: false,
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
];
|
|
273
|
+
const { result } = renderHook(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
290
|
+
type: 'rules',
|
|
291
|
+
rules: [{ conditions: [{ type: 'page_url', url: '/products' }], value: true }],
|
|
292
|
+
default: false,
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
const { result } = renderHook(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
317
|
+
type: 'rules',
|
|
318
|
+
rules: [{ conditions: [{ type: 'route', routeId: 'products' }], value: true }],
|
|
319
|
+
default: false,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
];
|
|
323
|
+
const { result } = renderHook(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
339
|
+
type: 'rules',
|
|
340
|
+
rules: [{ conditions: [{ type: 'route', routeId: 'home' }], value: true }],
|
|
341
|
+
default: false,
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
];
|
|
345
|
+
const { result } = renderHook(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
541
|
+
type: 'rules',
|
|
542
|
+
rules: [{ conditions: [{ type: 'viewport', minWidth: 800 }], value: true }],
|
|
543
|
+
default: false,
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
];
|
|
547
|
+
const { result } = renderHook(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
563
|
+
type: 'rules',
|
|
564
|
+
rules: [{ conditions: [{ type: 'viewport', maxWidth: 1200 }], value: true }],
|
|
565
|
+
default: false,
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
];
|
|
569
|
+
const { result } = renderHook(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
585
|
+
type: 'rules',
|
|
586
|
+
rules: [{ conditions: [{ type: 'viewport', minHeight: 600 }], value: true }],
|
|
587
|
+
default: false,
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
];
|
|
591
|
+
const { result } = renderHook(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
607
|
+
type: 'rules',
|
|
608
|
+
rules: [{ conditions: [{ type: 'viewport', maxHeight: 900 }], value: true }],
|
|
609
|
+
default: false,
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
];
|
|
613
|
+
const { result } = renderHook(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
915
|
+
type: 'rules',
|
|
916
|
+
rules: [{ conditions: [{ type: 'page_url', url: '/products' }], value: true }],
|
|
917
|
+
default: true,
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
];
|
|
921
|
+
const { result } = renderHook(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
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(() => useShowWhenStatus(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
|
+
showWhen: {
|
|
984
|
+
type: 'rules',
|
|
985
|
+
rules: [{ conditions: [{ type: 'page_url', url: '/products' }], value: true }],
|
|
986
|
+
default: false,
|
|
987
|
+
},
|
|
988
|
+
},
|
|
989
|
+
];
|
|
990
|
+
const { result } = renderHook(() => useShowWhenStatus(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(() => useShowWhenStatus([]));
|
|
1011
|
+
unmount();
|
|
1012
|
+
expect(unsubCalled).toBe(true);
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
1015
|
+
});
|
|
@@ -106,7 +106,7 @@ function DraggableFab({ isOpen, onToggle, initialRight, zIndex, portalTarget })
|
|
|
106
106
|
export function EditorPanelShell({ isOpen, onToggle, position = 'right', panelId, zIndex = 2147483647, portalTarget, children, }) {
|
|
107
107
|
const isRight = position === 'right';
|
|
108
108
|
const resolvedPortalTarget = portalTarget ?? (typeof document !== 'undefined' ? document.body : null);
|
|
109
|
-
return (_jsxs(_Fragment, { children: [resolvedPortalTarget && (_jsx(DraggableFab, { isOpen: isOpen, onToggle: onToggle, initialRight: isRight, zIndex: zIndex + 1, portalTarget: resolvedPortalTarget })), isOpen && (_jsx("div", { ...(panelId ? { id: panelId } : {}), "data-syntro-editor-panel": true, "data-syntro-editor-ui": "panel", className: cn('syntro-editor-scope se-fixed se-top-0 se-w-[
|
|
109
|
+
return (_jsxs(_Fragment, { children: [resolvedPortalTarget && (_jsx(DraggableFab, { isOpen: isOpen, onToggle: onToggle, initialRight: isRight, zIndex: zIndex + 1, portalTarget: resolvedPortalTarget })), isOpen && (_jsx("div", { ...(panelId ? { id: panelId } : {}), "data-syntro-editor-panel": true, "data-syntro-editor-ui": "panel", className: cn('syntro-editor-scope se-fixed se-top-0 se-w-[480px] se-h-screen se-flex se-flex-col se-text-text-primary se-backdrop-blur-xl se-font-sans se-antialiased', isRight
|
|
110
110
|
? 'se-right-0 se-border-l se-border-border-primary'
|
|
111
111
|
: 'se-left-0 se-border-r se-border-border-primary'), style: {
|
|
112
112
|
zIndex,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syntrologie/adapt-content",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0-canary.10",
|
|
4
4
|
"description": "Adaptive Content app - DOM manipulation actions for text, attributes, and styles",
|
|
5
5
|
"license": "Proprietary",
|
|
6
6
|
"private": false,
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@syntrologie/shared-editor-ui": "*",
|
|
53
53
|
"css-selector-generator": "^3.8.0",
|
|
54
|
-
"data-urls": "^5.0.0"
|
|
55
|
-
"@syntrologie/sdk-contracts": "*"
|
|
54
|
+
"data-urls": "^5.0.0"
|
|
56
55
|
},
|
|
57
56
|
"devDependencies": {
|
|
58
57
|
"@floating-ui/dom": "^1.7.5",
|
|
58
|
+
"@syntrologie/sdk-contracts": "*",
|
|
59
59
|
"@testing-library/react": "^16.3.2",
|
|
60
60
|
"@types/react": "^19.2.0",
|
|
61
61
|
"@types/react-dom": "^19.2.0",
|