@capillarytech/creatives-library 8.0.208 → 8.0.209

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 (77) hide show
  1. package/assets/Android.png +0 -0
  2. package/assets/iOS.png +0 -0
  3. package/config/app.js +1 -2
  4. package/package.json +16 -2
  5. package/services/api.js +0 -2
  6. package/v2Components/HtmlEditor/HTMLEditor.js +508 -0
  7. package/v2Components/HtmlEditor/__tests__/HTMLEditor.test.js +1809 -0
  8. package/v2Components/HtmlEditor/__tests__/index.lazy.test.js +532 -0
  9. package/v2Components/HtmlEditor/_htmlEditor.scss +304 -0
  10. package/v2Components/HtmlEditor/_index.lazy.scss +26 -0
  11. package/v2Components/HtmlEditor/components/CodeEditorPane/_codeEditorPane.scss +376 -0
  12. package/v2Components/HtmlEditor/components/CodeEditorPane/index.js +331 -0
  13. package/v2Components/HtmlEditor/components/DeviceToggle/__tests__/index.test.js +314 -0
  14. package/v2Components/HtmlEditor/components/DeviceToggle/_deviceToggle.scss +244 -0
  15. package/v2Components/HtmlEditor/components/DeviceToggle/index.js +111 -0
  16. package/v2Components/HtmlEditor/components/EditorToolbar/PreviewModeGroup.js +72 -0
  17. package/v2Components/HtmlEditor/components/EditorToolbar/__tests__/PreviewModeGroup.test.js +1594 -0
  18. package/v2Components/HtmlEditor/components/EditorToolbar/_editorToolbar.scss +113 -0
  19. package/v2Components/HtmlEditor/components/EditorToolbar/_previewModeGroup.scss +82 -0
  20. package/v2Components/HtmlEditor/components/EditorToolbar/index.js +115 -0
  21. package/v2Components/HtmlEditor/components/FullscreenModal/_fullscreenModal.scss +57 -0
  22. package/v2Components/HtmlEditor/components/InAppPreviewPane/ContentOverlay.js +90 -0
  23. package/v2Components/HtmlEditor/components/InAppPreviewPane/DeviceFrame.js +60 -0
  24. package/v2Components/HtmlEditor/components/InAppPreviewPane/LayoutSelector.js +58 -0
  25. package/v2Components/HtmlEditor/components/InAppPreviewPane/__tests__/ContentOverlay.test.js +389 -0
  26. package/v2Components/HtmlEditor/components/InAppPreviewPane/__tests__/DeviceFrame.test.js +424 -0
  27. package/v2Components/HtmlEditor/components/InAppPreviewPane/__tests__/LayoutSelector.test.js +248 -0
  28. package/v2Components/HtmlEditor/components/InAppPreviewPane/_inAppPreviewPane.scss +253 -0
  29. package/v2Components/HtmlEditor/components/InAppPreviewPane/constants.js +104 -0
  30. package/v2Components/HtmlEditor/components/InAppPreviewPane/index.js +179 -0
  31. package/v2Components/HtmlEditor/components/PreviewPane/_previewPane.scss +220 -0
  32. package/v2Components/HtmlEditor/components/PreviewPane/index.js +229 -0
  33. package/v2Components/HtmlEditor/components/SplitContainer/SplitContainer.js +276 -0
  34. package/v2Components/HtmlEditor/components/SplitContainer/__tests__/SplitContainer.test.js +295 -0
  35. package/v2Components/HtmlEditor/components/SplitContainer/_splitContainer.scss +257 -0
  36. package/v2Components/HtmlEditor/components/SplitContainer/index.js +7 -0
  37. package/v2Components/HtmlEditor/components/ValidationErrorDisplay/__tests__/index.test.js +152 -0
  38. package/v2Components/HtmlEditor/components/ValidationErrorDisplay/_validationErrorDisplay.scss +31 -0
  39. package/v2Components/HtmlEditor/components/ValidationErrorDisplay/index.js +70 -0
  40. package/v2Components/HtmlEditor/components/ValidationPanel/__tests__/index.test.js +98 -0
  41. package/v2Components/HtmlEditor/components/ValidationPanel/_validationPanel.scss +311 -0
  42. package/v2Components/HtmlEditor/components/ValidationPanel/index.js +297 -0
  43. package/v2Components/HtmlEditor/components/ValidationPanel/messages.js +57 -0
  44. package/v2Components/HtmlEditor/components/common/EditorContext.js +84 -0
  45. package/v2Components/HtmlEditor/components/common/__tests__/EditorContext.test.js +660 -0
  46. package/v2Components/HtmlEditor/constants.js +241 -0
  47. package/v2Components/HtmlEditor/hooks/__tests__/useEditorContent.test.js +450 -0
  48. package/v2Components/HtmlEditor/hooks/__tests__/useInAppContent.test.js +785 -0
  49. package/v2Components/HtmlEditor/hooks/__tests__/useLayoutState.test.js +580 -0
  50. package/v2Components/HtmlEditor/hooks/__tests__/useValidation.enhanced.test.js +768 -0
  51. package/v2Components/HtmlEditor/hooks/__tests__/useValidation.test.js +590 -0
  52. package/v2Components/HtmlEditor/hooks/useEditorContent.js +274 -0
  53. package/v2Components/HtmlEditor/hooks/useInAppContent.js +407 -0
  54. package/v2Components/HtmlEditor/hooks/useLayoutState.js +247 -0
  55. package/v2Components/HtmlEditor/hooks/useValidation.js +325 -0
  56. package/v2Components/HtmlEditor/index.js +29 -0
  57. package/v2Components/HtmlEditor/index.lazy.js +114 -0
  58. package/v2Components/HtmlEditor/messages.js +389 -0
  59. package/v2Components/HtmlEditor/utils/__tests__/contentSanitizer.test.js +741 -0
  60. package/v2Components/HtmlEditor/utils/__tests__/htmlValidator.enhanced.test.js +1042 -0
  61. package/v2Components/HtmlEditor/utils/__tests__/liquidTemplateSupport.test.js +515 -0
  62. package/v2Components/HtmlEditor/utils/__tests__/properSyntaxHighlighting.test.js +473 -0
  63. package/v2Components/HtmlEditor/utils/__tests__/simplePerformance.test.js +1109 -0
  64. package/v2Components/HtmlEditor/utils/__tests__/validationAdapter.test.js +240 -0
  65. package/v2Components/HtmlEditor/utils/contentSanitizer.js +433 -0
  66. package/v2Components/HtmlEditor/utils/htmlValidator.js +508 -0
  67. package/v2Components/HtmlEditor/utils/liquidTemplateSupport.js +524 -0
  68. package/v2Components/HtmlEditor/utils/properSyntaxHighlighting.js +163 -0
  69. package/v2Components/HtmlEditor/utils/simplePerformance.js +145 -0
  70. package/v2Components/HtmlEditor/utils/validationAdapter.js +130 -0
  71. package/v2Containers/CreativesContainer/SlideBoxContent.js +0 -2
  72. package/v2Containers/EmailWrapper/components/HTMLEditorTesting.js +200 -0
  73. package/v2Containers/EmailWrapper/components/__tests__/HTMLEditorTesting.test.js +545 -0
  74. package/v2Containers/EmailWrapper/index.js +8 -1
  75. package/v2Containers/Templates/constants.js +8 -0
  76. package/v2Containers/Templates/index.js +56 -28
  77. package/v2Containers/Templates/tests/__snapshots__/index.test.js.snap +5 -14
@@ -0,0 +1,785 @@
1
+ /**
2
+ * useInAppContent Hook Tests
3
+ *
4
+ * Tests for the useInAppContent custom hook that manages device-specific content
5
+ */
6
+
7
+ import React from 'react';
8
+ import { render, screen, act } from '@testing-library/react';
9
+ import '@testing-library/jest-dom';
10
+ import { useInAppContent } from '../useInAppContent';
11
+ import { DEVICE_TYPES } from '../../constants';
12
+
13
+ // Test wrapper component
14
+ const TestComponent = ({ initialContent, options, onStateChange }) => {
15
+ const inAppState = useInAppContent(initialContent, options);
16
+
17
+ React.useEffect(() => {
18
+ if (onStateChange) {
19
+ onStateChange(inAppState);
20
+ }
21
+ });
22
+
23
+ return (
24
+ <div>
25
+ <div data-testid="active-device">{inAppState.activeDevice}</div>
26
+ <div data-testid="current-content">{inAppState.content}</div>
27
+ <div data-testid="android-content">{inAppState.deviceContent[DEVICE_TYPES.ANDROID]}</div>
28
+ <div data-testid="ios-content">{inAppState.deviceContent[DEVICE_TYPES.IOS]}</div>
29
+ <div data-testid="keep-same">{String(inAppState.keepContentSame)}</div>
30
+ <div data-testid="is-dirty">{String(inAppState.isDirty)}</div>
31
+ <div data-testid="has-content">{String(inAppState.hasContent)}</div>
32
+ <div data-testid="content-size">{inAppState.getContentSize()}</div>
33
+
34
+ <button onClick={() => inAppState.updateContent('Updated')} data-testid="update-btn">
35
+ Update
36
+ </button>
37
+ <button onClick={() => inAppState.switchDevice(DEVICE_TYPES.IOS)} data-testid="switch-ios">
38
+ Switch to iOS
39
+ </button>
40
+ <button onClick={() => inAppState.switchDevice(DEVICE_TYPES.ANDROID)} data-testid="switch-android">
41
+ Switch to Android
42
+ </button>
43
+ <button onClick={() => inAppState.toggleContentSync(true)} data-testid="enable-sync">
44
+ Enable Sync
45
+ </button>
46
+ <button onClick={() => inAppState.toggleContentSync(false)} data-testid="disable-sync">
47
+ Disable Sync
48
+ </button>
49
+ <button onClick={() => inAppState.markAsSaved()} data-testid="mark-saved">
50
+ Mark Saved
51
+ </button>
52
+ </div>
53
+ );
54
+ };
55
+
56
+ describe('useInAppContent', () => {
57
+ beforeEach(() => {
58
+ jest.useFakeTimers();
59
+ });
60
+
61
+ afterEach(() => {
62
+ jest.runOnlyPendingTimers();
63
+ jest.useRealTimers();
64
+ });
65
+
66
+ describe('Initial State', () => {
67
+ it('initializes with default content for both devices', () => {
68
+ render(<TestComponent />);
69
+
70
+ expect(screen.getByTestId('active-device')).toHaveTextContent(DEVICE_TYPES.ANDROID);
71
+ expect(screen.getByTestId('android-content')).toBeInTheDocument();
72
+ expect(screen.getByTestId('ios-content')).toBeInTheDocument();
73
+ expect(screen.getByTestId('keep-same')).toHaveTextContent('false');
74
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('false');
75
+ });
76
+
77
+ it('initializes with custom content for Android', () => {
78
+ const customContent = {
79
+ [DEVICE_TYPES.ANDROID]: '<p>Custom Android</p>'
80
+ };
81
+
82
+ render(<TestComponent initialContent={customContent} />);
83
+
84
+ expect(screen.getByTestId('android-content')).toHaveTextContent('<p>Custom Android</p>');
85
+ });
86
+
87
+ it('initializes with custom content for iOS', () => {
88
+ const customContent = {
89
+ [DEVICE_TYPES.IOS]: '<p>Custom iOS</p>'
90
+ };
91
+
92
+ render(<TestComponent initialContent={customContent} />);
93
+
94
+ expect(screen.getByTestId('ios-content')).toHaveTextContent('<p>Custom iOS</p>');
95
+ });
96
+
97
+ it('initializes with custom content for both devices', () => {
98
+ const customContent = {
99
+ [DEVICE_TYPES.ANDROID]: '<p>Android</p>',
100
+ [DEVICE_TYPES.IOS]: '<p>iOS</p>'
101
+ };
102
+
103
+ render(<TestComponent initialContent={customContent} />);
104
+
105
+ expect(screen.getByTestId('android-content')).toHaveTextContent('<p>Android</p>');
106
+ expect(screen.getByTestId('ios-content')).toHaveTextContent('<p>iOS</p>');
107
+ });
108
+
109
+ it('starts with Android as active device', () => {
110
+ render(<TestComponent />);
111
+
112
+ expect(screen.getByTestId('active-device')).toHaveTextContent(DEVICE_TYPES.ANDROID);
113
+ });
114
+
115
+ it('has content by default', () => {
116
+ render(<TestComponent />);
117
+
118
+ expect(screen.getByTestId('has-content')).toHaveTextContent('true');
119
+ });
120
+ });
121
+
122
+ describe('Device Switching', () => {
123
+ it('switches from Android to iOS', () => {
124
+ render(<TestComponent />);
125
+
126
+ expect(screen.getByTestId('active-device')).toHaveTextContent(DEVICE_TYPES.ANDROID);
127
+
128
+ act(() => {
129
+ screen.getByTestId('switch-ios').click();
130
+ });
131
+
132
+ expect(screen.getByTestId('active-device')).toHaveTextContent(DEVICE_TYPES.IOS);
133
+ });
134
+
135
+ it('switches from iOS to Android', () => {
136
+ let inAppState;
137
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
138
+
139
+ act(() => {
140
+ inAppState.switchDevice(DEVICE_TYPES.IOS);
141
+ });
142
+
143
+ expect(screen.getByTestId('active-device')).toHaveTextContent(DEVICE_TYPES.IOS);
144
+
145
+ act(() => {
146
+ screen.getByTestId('switch-android').click();
147
+ });
148
+
149
+ expect(screen.getByTestId('active-device')).toHaveTextContent(DEVICE_TYPES.ANDROID);
150
+ });
151
+
152
+ it('updates current content when switching devices', () => {
153
+ const customContent = {
154
+ [DEVICE_TYPES.ANDROID]: '<p>Android Content</p>',
155
+ [DEVICE_TYPES.IOS]: '<p>iOS Content</p>'
156
+ };
157
+
158
+ render(<TestComponent initialContent={customContent} />);
159
+
160
+ expect(screen.getByTestId('current-content')).toHaveTextContent('<p>Android Content</p>');
161
+
162
+ act(() => {
163
+ screen.getByTestId('switch-ios').click();
164
+ });
165
+
166
+ expect(screen.getByTestId('current-content')).toHaveTextContent('<p>iOS Content</p>');
167
+ });
168
+
169
+ it('ignores invalid device types', () => {
170
+ let inAppState;
171
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
172
+
173
+ const initialDevice = screen.getByTestId('active-device').textContent;
174
+
175
+ act(() => {
176
+ inAppState.switchDevice('InvalidDevice');
177
+ });
178
+
179
+ expect(screen.getByTestId('active-device')).toHaveTextContent(initialDevice);
180
+ });
181
+
182
+ it('does not switch if already on target device', () => {
183
+ render(<TestComponent />);
184
+
185
+ expect(screen.getByTestId('active-device')).toHaveTextContent(DEVICE_TYPES.ANDROID);
186
+
187
+ act(() => {
188
+ screen.getByTestId('switch-android').click();
189
+ });
190
+
191
+ expect(screen.getByTestId('active-device')).toHaveTextContent(DEVICE_TYPES.ANDROID);
192
+ });
193
+ });
194
+
195
+ describe('Content Updates', () => {
196
+ it('updates content for current device', () => {
197
+ let inAppState;
198
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
199
+
200
+ act(() => {
201
+ inAppState.updateContent('<p>New Content</p>');
202
+ });
203
+
204
+ expect(screen.getByTestId('current-content')).toHaveTextContent('<p>New Content</p>');
205
+ });
206
+
207
+ it('marks content as dirty after update', () => {
208
+ let inAppState;
209
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
210
+
211
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('false');
212
+
213
+ act(() => {
214
+ inAppState.updateContent('<p>Updated</p>');
215
+ });
216
+
217
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('true');
218
+ });
219
+
220
+ it('updates only current device when sync is disabled', () => {
221
+ const customContent = {
222
+ [DEVICE_TYPES.ANDROID]: '<p>Android</p>',
223
+ [DEVICE_TYPES.IOS]: '<p>iOS</p>'
224
+ };
225
+
226
+ let inAppState;
227
+ render(<TestComponent
228
+ initialContent={customContent}
229
+ onStateChange={(state) => { inAppState = state; }}
230
+ />);
231
+
232
+ // Currently on Android
233
+ act(() => {
234
+ inAppState.updateContent('<p>Updated Android</p>');
235
+ });
236
+
237
+ expect(screen.getByTestId('android-content')).toHaveTextContent('<p>Updated Android</p>');
238
+ expect(screen.getByTestId('ios-content')).toHaveTextContent('<p>iOS</p>');
239
+ });
240
+
241
+ it('calls onChange callback when content is updated', () => {
242
+ const mockOnChange = jest.fn();
243
+ let inAppState;
244
+
245
+ render(<TestComponent
246
+ options={{ onChange: mockOnChange }}
247
+ onStateChange={(state) => { inAppState = state; }}
248
+ />);
249
+
250
+ act(() => {
251
+ inAppState.updateContent('<p>New</p>');
252
+ });
253
+
254
+ expect(mockOnChange).toHaveBeenCalled();
255
+ expect(mockOnChange).toHaveBeenCalledWith(
256
+ expect.objectContaining({
257
+ [DEVICE_TYPES.ANDROID]: '<p>New</p>'
258
+ }),
259
+ DEVICE_TYPES.ANDROID
260
+ );
261
+ });
262
+ });
263
+
264
+ describe('Content Sync', () => {
265
+ it('enables content sync', () => {
266
+ render(<TestComponent />);
267
+
268
+ expect(screen.getByTestId('keep-same')).toHaveTextContent('false');
269
+
270
+ act(() => {
271
+ screen.getByTestId('enable-sync').click();
272
+ });
273
+
274
+ expect(screen.getByTestId('keep-same')).toHaveTextContent('true');
275
+ });
276
+
277
+ it('disables content sync', () => {
278
+ let inAppState;
279
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
280
+
281
+ act(() => {
282
+ inAppState.toggleContentSync(true);
283
+ });
284
+
285
+ expect(screen.getByTestId('keep-same')).toHaveTextContent('true');
286
+
287
+ act(() => {
288
+ screen.getByTestId('disable-sync').click();
289
+ });
290
+
291
+ expect(screen.getByTestId('keep-same')).toHaveTextContent('false');
292
+ });
293
+
294
+ it('syncs content to both devices when sync is enabled', () => {
295
+ const customContent = {
296
+ [DEVICE_TYPES.ANDROID]: '<p>Android</p>',
297
+ [DEVICE_TYPES.IOS]: '<p>iOS</p>'
298
+ };
299
+
300
+ render(<TestComponent initialContent={customContent} />);
301
+
302
+ // Initially different content
303
+ expect(screen.getByTestId('android-content')).toHaveTextContent('<p>Android</p>');
304
+ expect(screen.getByTestId('ios-content')).toHaveTextContent('<p>iOS</p>');
305
+
306
+ act(() => {
307
+ screen.getByTestId('enable-sync').click();
308
+ });
309
+
310
+ // After enabling sync, both should have Android content (current device)
311
+ const syncedContent = screen.getByTestId('android-content').textContent;
312
+ expect(screen.getByTestId('ios-content')).toHaveTextContent(syncedContent);
313
+ });
314
+
315
+ it('updates both devices when content is updated with sync enabled', () => {
316
+ let inAppState;
317
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
318
+
319
+ act(() => {
320
+ inAppState.toggleContentSync(true);
321
+ });
322
+
323
+ act(() => {
324
+ inAppState.updateContent('<p>Synced Content</p>');
325
+ });
326
+
327
+ expect(screen.getByTestId('android-content')).toHaveTextContent('<p>Synced Content</p>');
328
+ expect(screen.getByTestId('ios-content')).toHaveTextContent('<p>Synced Content</p>');
329
+ });
330
+
331
+ it('marks as dirty when sync is enabled', () => {
332
+ render(<TestComponent />);
333
+
334
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('false');
335
+
336
+ act(() => {
337
+ screen.getByTestId('enable-sync').click();
338
+ });
339
+
340
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('true');
341
+ });
342
+ });
343
+
344
+ describe('Save Management', () => {
345
+ it('marks content as saved', () => {
346
+ let inAppState;
347
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
348
+
349
+ act(() => {
350
+ inAppState.updateContent('<p>New</p>');
351
+ });
352
+
353
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('true');
354
+
355
+ act(() => {
356
+ screen.getByTestId('mark-saved').click();
357
+ });
358
+
359
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('false');
360
+ });
361
+
362
+ it('calls onSave callback when marked as saved', () => {
363
+ const mockOnSave = jest.fn();
364
+ let inAppState;
365
+
366
+ render(<TestComponent
367
+ options={{ onSave: mockOnSave }}
368
+ onStateChange={(state) => { inAppState = state; }}
369
+ />);
370
+
371
+ act(() => {
372
+ inAppState.markAsSaved();
373
+ });
374
+
375
+ expect(mockOnSave).toHaveBeenCalled();
376
+ });
377
+
378
+ it('triggers auto-save after interval', async () => {
379
+ const mockOnSave = jest.fn();
380
+ let inAppState;
381
+
382
+ render(<TestComponent
383
+ options={{ autoSave: true, autoSaveInterval: 5000, onSave: mockOnSave }}
384
+ onStateChange={(state) => { inAppState = state; }}
385
+ />);
386
+
387
+ await act(async () => {
388
+ inAppState.updateContent('<p>Auto Save Test</p>');
389
+ });
390
+
391
+ expect(mockOnSave).not.toHaveBeenCalled();
392
+
393
+ await act(async () => {
394
+ jest.advanceTimersByTime(5000);
395
+ });
396
+
397
+ expect(mockOnSave).toHaveBeenCalled();
398
+ });
399
+
400
+ it('disables auto-save when autoSave is false', () => {
401
+ const mockOnSave = jest.fn();
402
+ let inAppState;
403
+
404
+ render(<TestComponent
405
+ options={{ autoSave: false, autoSaveInterval: 5000, onSave: mockOnSave }}
406
+ onStateChange={(state) => { inAppState = state; }}
407
+ />);
408
+
409
+ act(() => {
410
+ inAppState.updateContent('<p>No Auto Save</p>');
411
+ });
412
+
413
+ act(() => {
414
+ jest.advanceTimersByTime(10000);
415
+ });
416
+
417
+ expect(mockOnSave).not.toHaveBeenCalled();
418
+ });
419
+
420
+ it('cancels previous auto-save timer on new update', async () => {
421
+ const mockOnSave = jest.fn();
422
+ let inAppState;
423
+
424
+ render(<TestComponent
425
+ options={{ autoSave: true, autoSaveInterval: 5000, onSave: mockOnSave }}
426
+ onStateChange={(state) => { inAppState = state; }}
427
+ />);
428
+
429
+ await act(async () => {
430
+ inAppState.updateContent('<p>First</p>');
431
+ });
432
+
433
+ await act(async () => {
434
+ jest.advanceTimersByTime(3000);
435
+ });
436
+
437
+ await act(async () => {
438
+ inAppState.updateContent('<p>Second</p>');
439
+ });
440
+
441
+ await act(async () => {
442
+ jest.advanceTimersByTime(3000);
443
+ });
444
+
445
+ // Should not have saved yet (timer was reset)
446
+ expect(mockOnSave).not.toHaveBeenCalled();
447
+
448
+ await act(async () => {
449
+ jest.advanceTimersByTime(2000);
450
+ });
451
+
452
+ // Should save after 5 seconds from last update
453
+ expect(mockOnSave).toHaveBeenCalledTimes(1);
454
+ });
455
+ });
456
+
457
+ describe('Content Queries', () => {
458
+ it('reports hasContent as true for non-empty content', () => {
459
+ const customContent = {
460
+ [DEVICE_TYPES.ANDROID]: '<p>Content</p>'
461
+ };
462
+
463
+ render(<TestComponent initialContent={customContent} />);
464
+
465
+ expect(screen.getByTestId('has-content')).toHaveTextContent('true');
466
+ });
467
+
468
+ it('reports hasContent as false for empty content', () => {
469
+ const customContent = {
470
+ [DEVICE_TYPES.ANDROID]: '',
471
+ [DEVICE_TYPES.IOS]: ''
472
+ };
473
+
474
+ let inAppState;
475
+ render(<TestComponent
476
+ initialContent={customContent}
477
+ onStateChange={(state) => { inAppState = state; }}
478
+ />);
479
+
480
+ // Explicitly update to empty string to override any default
481
+ act(() => {
482
+ inAppState.updateContent('');
483
+ });
484
+
485
+ expect(screen.getByTestId('has-content')).toHaveTextContent('false');
486
+ });
487
+
488
+ it('calculates content size correctly', () => {
489
+ const customContent = {
490
+ [DEVICE_TYPES.ANDROID]: '12345'
491
+ };
492
+
493
+ render(<TestComponent initialContent={customContent} />);
494
+
495
+ expect(screen.getByTestId('content-size')).toHaveTextContent('5');
496
+ });
497
+
498
+ it('returns 0 size for empty content', () => {
499
+ const customContent = {
500
+ [DEVICE_TYPES.ANDROID]: '',
501
+ [DEVICE_TYPES.IOS]: ''
502
+ };
503
+
504
+ let inAppState;
505
+ render(<TestComponent
506
+ initialContent={customContent}
507
+ onStateChange={(state) => { inAppState = state; }}
508
+ />);
509
+
510
+ // Explicitly update to empty string
511
+ act(() => {
512
+ inAppState.updateContent('');
513
+ });
514
+
515
+ expect(screen.getByTestId('content-size')).toHaveTextContent('0');
516
+ });
517
+
518
+ it('getDeviceContent returns content for specific device', () => {
519
+ const customContent = {
520
+ [DEVICE_TYPES.ANDROID]: '<p>Android</p>',
521
+ [DEVICE_TYPES.IOS]: '<p>iOS</p>'
522
+ };
523
+
524
+ let inAppState;
525
+ render(<TestComponent
526
+ initialContent={customContent}
527
+ onStateChange={(state) => { inAppState = state; }}
528
+ />);
529
+
530
+ expect(inAppState.getDeviceContent(DEVICE_TYPES.ANDROID)).toBe('<p>Android</p>');
531
+ expect(inAppState.getDeviceContent(DEVICE_TYPES.IOS)).toBe('<p>iOS</p>');
532
+ });
533
+
534
+ it('getDeviceContent returns empty string for missing device', () => {
535
+ let inAppState;
536
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
537
+
538
+ expect(inAppState.getDeviceContent('InvalidDevice')).toBe('');
539
+ });
540
+ });
541
+
542
+ describe('setDeviceContent Method', () => {
543
+ it('sets content for specific device', () => {
544
+ let inAppState;
545
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
546
+
547
+ act(() => {
548
+ inAppState.setDeviceContent(DEVICE_TYPES.IOS, '<p>New iOS Content</p>');
549
+ });
550
+
551
+ expect(screen.getByTestId('ios-content')).toHaveTextContent('<p>New iOS Content</p>');
552
+ });
553
+
554
+ it('marks as dirty when setting device content', () => {
555
+ let inAppState;
556
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
557
+
558
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('false');
559
+
560
+ act(() => {
561
+ inAppState.setDeviceContent(DEVICE_TYPES.ANDROID, '<p>New</p>');
562
+ });
563
+
564
+ expect(screen.getByTestId('is-dirty')).toHaveTextContent('true');
565
+ });
566
+
567
+ it('updates both devices when sync is enabled', () => {
568
+ let inAppState;
569
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
570
+
571
+ act(() => {
572
+ inAppState.toggleContentSync(true);
573
+ });
574
+
575
+ act(() => {
576
+ inAppState.setDeviceContent(DEVICE_TYPES.ANDROID, '<p>Synced via setDeviceContent</p>');
577
+ });
578
+
579
+ expect(screen.getByTestId('android-content')).toHaveTextContent('<p>Synced via setDeviceContent</p>');
580
+ expect(screen.getByTestId('ios-content')).toHaveTextContent('<p>Synced via setDeviceContent</p>');
581
+ });
582
+
583
+ it('ignores invalid device types', () => {
584
+ let inAppState;
585
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
586
+
587
+ // Get initial content lengths to compare (not full text due to default content)
588
+ const initialAndroidLength = screen.getByTestId('android-content').textContent.length;
589
+ const initialIOSLength = screen.getByTestId('ios-content').textContent.length;
590
+
591
+ act(() => {
592
+ inAppState.setDeviceContent('InvalidDevice', '<p>Should not update</p>');
593
+ });
594
+
595
+ // Content should remain unchanged
596
+ expect(screen.getByTestId('android-content').textContent.length).toBe(initialAndroidLength);
597
+ expect(screen.getByTestId('ios-content').textContent.length).toBe(initialIOSLength);
598
+ });
599
+ });
600
+
601
+ describe('Cleanup', () => {
602
+ it('clears auto-save timer on unmount', () => {
603
+ const mockOnSave = jest.fn();
604
+ let inAppState;
605
+
606
+ const { unmount } = render(<TestComponent
607
+ options={{ autoSave: true, autoSaveInterval: 5000, onSave: mockOnSave }}
608
+ onStateChange={(state) => { inAppState = state; }}
609
+ />);
610
+
611
+ act(() => {
612
+ inAppState.updateContent('<p>Test</p>');
613
+ });
614
+
615
+ act(() => {
616
+ jest.advanceTimersByTime(3000);
617
+ });
618
+
619
+ unmount();
620
+
621
+ act(() => {
622
+ jest.advanceTimersByTime(5000);
623
+ });
624
+
625
+ // Should not save after unmount
626
+ expect(mockOnSave).not.toHaveBeenCalled();
627
+ });
628
+ });
629
+
630
+ describe('Edge Cases', () => {
631
+ it('handles undefined initialContent gracefully', () => {
632
+ expect(() => {
633
+ render(<TestComponent initialContent={undefined} />);
634
+ }).not.toThrow();
635
+ });
636
+
637
+ it('handles null initialContent gracefully', () => {
638
+ expect(() => {
639
+ render(<TestComponent initialContent={null} />);
640
+ }).not.toThrow();
641
+ });
642
+
643
+ it('handles empty options object', () => {
644
+ expect(() => {
645
+ render(<TestComponent options={{}} />);
646
+ }).not.toThrow();
647
+ });
648
+
649
+ it('handles undefined options', () => {
650
+ expect(() => {
651
+ render(<TestComponent options={undefined} />);
652
+ }).not.toThrow();
653
+ });
654
+
655
+ it('handles very large content', () => {
656
+ const largeContent = 'a'.repeat(100000);
657
+ const customContent = {
658
+ [DEVICE_TYPES.ANDROID]: largeContent
659
+ };
660
+
661
+ let inAppState;
662
+ render(<TestComponent
663
+ initialContent={customContent}
664
+ onStateChange={(state) => { inAppState = state; }}
665
+ />);
666
+
667
+ expect(inAppState.getContentSize()).toBe(100000);
668
+ });
669
+
670
+ it('handles rapid device switching', () => {
671
+ let inAppState;
672
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
673
+
674
+ act(() => {
675
+ for (let i = 0; i < 10; i++) {
676
+ inAppState.switchDevice(i % 2 === 0 ? DEVICE_TYPES.IOS : DEVICE_TYPES.ANDROID);
677
+ }
678
+ });
679
+
680
+ expect(screen.getByTestId('active-device')).toBeInTheDocument();
681
+ });
682
+
683
+ it('handles multiple rapid content updates', () => {
684
+ let inAppState;
685
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
686
+
687
+ act(() => {
688
+ for (let i = 0; i < 5; i++) {
689
+ inAppState.updateContent(`<p>Update ${i}</p>`);
690
+ }
691
+ });
692
+
693
+ expect(screen.getByTestId('current-content')).toHaveTextContent('<p>Update 4</p>');
694
+ });
695
+ });
696
+
697
+ describe('Integration Scenarios', () => {
698
+ it('switches device and updates content', () => {
699
+ const customContent = {
700
+ [DEVICE_TYPES.ANDROID]: '<p>Android</p>',
701
+ [DEVICE_TYPES.IOS]: '<p>iOS</p>'
702
+ };
703
+
704
+ let inAppState;
705
+ render(<TestComponent
706
+ initialContent={customContent}
707
+ onStateChange={(state) => { inAppState = state; }}
708
+ />);
709
+
710
+ // Switch to iOS
711
+ act(() => {
712
+ inAppState.switchDevice(DEVICE_TYPES.IOS);
713
+ });
714
+
715
+ // Update iOS content
716
+ act(() => {
717
+ inAppState.updateContent('<p>Updated iOS</p>');
718
+ });
719
+
720
+ expect(screen.getByTestId('ios-content')).toHaveTextContent('<p>Updated iOS</p>');
721
+ expect(screen.getByTestId('android-content')).toHaveTextContent('<p>Android</p>');
722
+ });
723
+
724
+ it('enables sync, updates content, then disables sync', () => {
725
+ let inAppState;
726
+ render(<TestComponent onStateChange={(state) => { inAppState = state; }} />);
727
+
728
+ // Enable sync
729
+ act(() => {
730
+ inAppState.toggleContentSync(true);
731
+ });
732
+
733
+ // Update content (should update both)
734
+ act(() => {
735
+ inAppState.updateContent('<p>Synced</p>');
736
+ });
737
+
738
+ expect(screen.getByTestId('android-content')).toHaveTextContent('<p>Synced</p>');
739
+ expect(screen.getByTestId('ios-content')).toHaveTextContent('<p>Synced</p>');
740
+
741
+ // Disable sync
742
+ act(() => {
743
+ inAppState.toggleContentSync(false);
744
+ });
745
+
746
+ // Update Android only
747
+ act(() => {
748
+ inAppState.updateContent('<p>Android Only</p>');
749
+ });
750
+
751
+ expect(screen.getByTestId('android-content')).toHaveTextContent('<p>Android Only</p>');
752
+ expect(screen.getByTestId('ios-content')).toHaveTextContent('<p>Synced</p>');
753
+ });
754
+
755
+ it('updates content with auto-save and manual save', () => {
756
+ const mockOnSave = jest.fn();
757
+ let inAppState;
758
+
759
+ render(<TestComponent
760
+ options={{ autoSave: true, autoSaveInterval: 5000, onSave: mockOnSave }}
761
+ onStateChange={(state) => { inAppState = state; }}
762
+ />);
763
+
764
+ // Update content
765
+ act(() => {
766
+ inAppState.updateContent('<p>Test</p>');
767
+ });
768
+
769
+ // Manual save before auto-save
770
+ act(() => {
771
+ inAppState.markAsSaved();
772
+ });
773
+
774
+ expect(mockOnSave).toHaveBeenCalledTimes(1);
775
+
776
+ // Auto-save should not trigger after manual save
777
+ act(() => {
778
+ jest.advanceTimersByTime(10000);
779
+ });
780
+
781
+ expect(mockOnSave).toHaveBeenCalledTimes(1);
782
+ });
783
+ });
784
+ });
785
+