@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.
- package/assets/Android.png +0 -0
- package/assets/iOS.png +0 -0
- package/config/app.js +1 -2
- package/package.json +16 -2
- package/services/api.js +0 -2
- package/v2Components/HtmlEditor/HTMLEditor.js +508 -0
- package/v2Components/HtmlEditor/__tests__/HTMLEditor.test.js +1809 -0
- package/v2Components/HtmlEditor/__tests__/index.lazy.test.js +532 -0
- package/v2Components/HtmlEditor/_htmlEditor.scss +304 -0
- package/v2Components/HtmlEditor/_index.lazy.scss +26 -0
- package/v2Components/HtmlEditor/components/CodeEditorPane/_codeEditorPane.scss +376 -0
- package/v2Components/HtmlEditor/components/CodeEditorPane/index.js +331 -0
- package/v2Components/HtmlEditor/components/DeviceToggle/__tests__/index.test.js +314 -0
- package/v2Components/HtmlEditor/components/DeviceToggle/_deviceToggle.scss +244 -0
- package/v2Components/HtmlEditor/components/DeviceToggle/index.js +111 -0
- package/v2Components/HtmlEditor/components/EditorToolbar/PreviewModeGroup.js +72 -0
- package/v2Components/HtmlEditor/components/EditorToolbar/__tests__/PreviewModeGroup.test.js +1594 -0
- package/v2Components/HtmlEditor/components/EditorToolbar/_editorToolbar.scss +113 -0
- package/v2Components/HtmlEditor/components/EditorToolbar/_previewModeGroup.scss +82 -0
- package/v2Components/HtmlEditor/components/EditorToolbar/index.js +115 -0
- package/v2Components/HtmlEditor/components/FullscreenModal/_fullscreenModal.scss +57 -0
- package/v2Components/HtmlEditor/components/InAppPreviewPane/ContentOverlay.js +90 -0
- package/v2Components/HtmlEditor/components/InAppPreviewPane/DeviceFrame.js +60 -0
- package/v2Components/HtmlEditor/components/InAppPreviewPane/LayoutSelector.js +58 -0
- package/v2Components/HtmlEditor/components/InAppPreviewPane/__tests__/ContentOverlay.test.js +389 -0
- package/v2Components/HtmlEditor/components/InAppPreviewPane/__tests__/DeviceFrame.test.js +424 -0
- package/v2Components/HtmlEditor/components/InAppPreviewPane/__tests__/LayoutSelector.test.js +248 -0
- package/v2Components/HtmlEditor/components/InAppPreviewPane/_inAppPreviewPane.scss +253 -0
- package/v2Components/HtmlEditor/components/InAppPreviewPane/constants.js +104 -0
- package/v2Components/HtmlEditor/components/InAppPreviewPane/index.js +179 -0
- package/v2Components/HtmlEditor/components/PreviewPane/_previewPane.scss +220 -0
- package/v2Components/HtmlEditor/components/PreviewPane/index.js +229 -0
- package/v2Components/HtmlEditor/components/SplitContainer/SplitContainer.js +276 -0
- package/v2Components/HtmlEditor/components/SplitContainer/__tests__/SplitContainer.test.js +295 -0
- package/v2Components/HtmlEditor/components/SplitContainer/_splitContainer.scss +257 -0
- package/v2Components/HtmlEditor/components/SplitContainer/index.js +7 -0
- package/v2Components/HtmlEditor/components/ValidationErrorDisplay/__tests__/index.test.js +152 -0
- package/v2Components/HtmlEditor/components/ValidationErrorDisplay/_validationErrorDisplay.scss +31 -0
- package/v2Components/HtmlEditor/components/ValidationErrorDisplay/index.js +70 -0
- package/v2Components/HtmlEditor/components/ValidationPanel/__tests__/index.test.js +98 -0
- package/v2Components/HtmlEditor/components/ValidationPanel/_validationPanel.scss +311 -0
- package/v2Components/HtmlEditor/components/ValidationPanel/index.js +297 -0
- package/v2Components/HtmlEditor/components/ValidationPanel/messages.js +57 -0
- package/v2Components/HtmlEditor/components/common/EditorContext.js +84 -0
- package/v2Components/HtmlEditor/components/common/__tests__/EditorContext.test.js +660 -0
- package/v2Components/HtmlEditor/constants.js +241 -0
- package/v2Components/HtmlEditor/hooks/__tests__/useEditorContent.test.js +450 -0
- package/v2Components/HtmlEditor/hooks/__tests__/useInAppContent.test.js +785 -0
- package/v2Components/HtmlEditor/hooks/__tests__/useLayoutState.test.js +580 -0
- package/v2Components/HtmlEditor/hooks/__tests__/useValidation.enhanced.test.js +768 -0
- package/v2Components/HtmlEditor/hooks/__tests__/useValidation.test.js +590 -0
- package/v2Components/HtmlEditor/hooks/useEditorContent.js +274 -0
- package/v2Components/HtmlEditor/hooks/useInAppContent.js +407 -0
- package/v2Components/HtmlEditor/hooks/useLayoutState.js +247 -0
- package/v2Components/HtmlEditor/hooks/useValidation.js +325 -0
- package/v2Components/HtmlEditor/index.js +29 -0
- package/v2Components/HtmlEditor/index.lazy.js +114 -0
- package/v2Components/HtmlEditor/messages.js +389 -0
- package/v2Components/HtmlEditor/utils/__tests__/contentSanitizer.test.js +741 -0
- package/v2Components/HtmlEditor/utils/__tests__/htmlValidator.enhanced.test.js +1042 -0
- package/v2Components/HtmlEditor/utils/__tests__/liquidTemplateSupport.test.js +515 -0
- package/v2Components/HtmlEditor/utils/__tests__/properSyntaxHighlighting.test.js +473 -0
- package/v2Components/HtmlEditor/utils/__tests__/simplePerformance.test.js +1109 -0
- package/v2Components/HtmlEditor/utils/__tests__/validationAdapter.test.js +240 -0
- package/v2Components/HtmlEditor/utils/contentSanitizer.js +433 -0
- package/v2Components/HtmlEditor/utils/htmlValidator.js +508 -0
- package/v2Components/HtmlEditor/utils/liquidTemplateSupport.js +524 -0
- package/v2Components/HtmlEditor/utils/properSyntaxHighlighting.js +163 -0
- package/v2Components/HtmlEditor/utils/simplePerformance.js +145 -0
- package/v2Components/HtmlEditor/utils/validationAdapter.js +130 -0
- package/v2Containers/CreativesContainer/SlideBoxContent.js +0 -2
- package/v2Containers/EmailWrapper/components/HTMLEditorTesting.js +200 -0
- package/v2Containers/EmailWrapper/components/__tests__/HTMLEditorTesting.test.js +545 -0
- package/v2Containers/EmailWrapper/index.js +8 -1
- package/v2Containers/Templates/constants.js +8 -0
- package/v2Containers/Templates/index.js +56 -28
- 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
|
+
|