@akinon/pz-theme 2.0.30-rc.0 → 2.0.30
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/CHANGELOG.md +9 -29
- package/jest/__mocks__/akinon-next-hooks.js +15 -0
- package/jest/__mocks__/akinon-next-image.js +22 -0
- package/jest/__mocks__/akinon-next-loader-spinner.js +7 -0
- package/jest/__mocks__/akinon-next-localization.js +10 -0
- package/jest/__mocks__/akinon-next-modal.js +11 -0
- package/jest/__mocks__/akinon-next-price.js +9 -0
- package/jest/__mocks__/akinon-next-rtk.js +30 -0
- package/jest/__mocks__/akinon-next-utils.js +8 -0
- package/jest/__mocks__/next-link.js +11 -0
- package/jest/__mocks__/settings.js +13 -0
- package/jest/setup.ts +33 -0
- package/jest.config.js +24 -3
- package/package.json +6 -3
- package/src/__tests__/theme-block.test.tsx +46 -0
- package/src/blocks/__tests__/accordion-block.test.tsx +178 -0
- package/src/blocks/__tests__/button-block.test.tsx +99 -0
- package/src/blocks/__tests__/counter-block.test.tsx +57 -0
- package/src/blocks/__tests__/embed-block.test.tsx +89 -0
- package/src/blocks/__tests__/group-block.test.tsx +175 -0
- package/src/blocks/__tests__/hotspot-block.test.tsx +93 -0
- package/src/blocks/__tests__/icon-block.test.tsx +88 -0
- package/src/blocks/__tests__/image-block.test.tsx +139 -0
- package/src/blocks/__tests__/image-gallery-block.test.tsx +117 -0
- package/src/blocks/__tests__/link-block.test.tsx +184 -0
- package/src/blocks/__tests__/map-block.test.tsx +165 -0
- package/src/blocks/__tests__/slider-block.test.tsx +148 -0
- package/src/blocks/__tests__/text-block.test.tsx +87 -0
- package/src/blocks/__tests__/trivial-blocks.test.tsx +59 -0
- package/src/blocks/__tests__/video-block.test.tsx +196 -0
- package/src/blocks/counter-block.tsx +1 -1
- package/src/blocks/group-block.tsx +1 -1
- package/src/blocks/icon-block.tsx +11 -6
- package/src/blocks/video-block.tsx +1 -1
- package/src/sections/__tests__/before-after-section.test.tsx +139 -0
- package/src/sections/__tests__/countdown-campaign-banner-section.test.tsx +188 -0
- package/src/sections/__tests__/coupon-banner-section.test.tsx +188 -0
- package/src/sections/__tests__/divider-section.test.tsx +161 -0
- package/src/sections/__tests__/hover-showcase-section.test.tsx +193 -0
- package/src/sections/__tests__/image-hotspot-section.test.tsx +110 -0
- package/src/sections/__tests__/notification-banner-section.test.tsx +144 -0
- package/src/sections/__tests__/section-wrapper.test.tsx +164 -0
- package/src/sections/__tests__/stats-counter-section.test.tsx +245 -0
- package/src/sections/__tests__/tabs-section.test.tsx +115 -0
- package/src/sections/coupon-banner-section.tsx +1 -4
- package/src/theme-block.tsx +3 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import DividerBlock from '../divider-block';
|
|
4
|
+
import TabBlock from '../tab-block';
|
|
5
|
+
|
|
6
|
+
const props = (over: Record<string, unknown> = {}) =>
|
|
7
|
+
({
|
|
8
|
+
block: {
|
|
9
|
+
id: 'd',
|
|
10
|
+
type: 'divider',
|
|
11
|
+
label: 'd',
|
|
12
|
+
styles: {},
|
|
13
|
+
properties: {},
|
|
14
|
+
order: 0,
|
|
15
|
+
hidden: false
|
|
16
|
+
},
|
|
17
|
+
placeholderId: 'p',
|
|
18
|
+
sectionId: 's',
|
|
19
|
+
isDesigner: false,
|
|
20
|
+
...over
|
|
21
|
+
} as any);
|
|
22
|
+
|
|
23
|
+
describe('DividerBlock', () => {
|
|
24
|
+
it('renders a span with the dangerous defaults when no styles are set', () => {
|
|
25
|
+
const { container } = render(<DividerBlock {...props()} />);
|
|
26
|
+
const span = container.querySelector('span');
|
|
27
|
+
expect(span).not.toBeNull();
|
|
28
|
+
expect(span!.style.display).toBe('block');
|
|
29
|
+
expect(span!.style.height).toBe('1px');
|
|
30
|
+
expect(span!.style.width).toBe('100%');
|
|
31
|
+
expect(span!.getAttribute('style')).toMatch(/e0e0e0|224, 224, 224/i);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('resolves a responsive height for the active breakpoint', () => {
|
|
35
|
+
const { container } = render(
|
|
36
|
+
<DividerBlock
|
|
37
|
+
{...props({
|
|
38
|
+
block: {
|
|
39
|
+
id: 'd',
|
|
40
|
+
type: 'divider',
|
|
41
|
+
styles: { height: { desktop: '4px', mobile: '2px' } },
|
|
42
|
+
properties: {},
|
|
43
|
+
order: 0,
|
|
44
|
+
hidden: false
|
|
45
|
+
},
|
|
46
|
+
currentBreakpoint: 'mobile'
|
|
47
|
+
})}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
expect(container.querySelector('span')!.style.height).toBe('2px');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('TabBlock', () => {
|
|
55
|
+
it('renders nothing (panes are rendered by TabsSection)', () => {
|
|
56
|
+
const { container } = render(<TabBlock {...props()} />);
|
|
57
|
+
expect(container.innerHTML).toBe('');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import VideoBlock from '../video-block';
|
|
4
|
+
|
|
5
|
+
const renderBlock = (value: any, properties?: any) =>
|
|
6
|
+
render(<VideoBlock block={{ value, properties } as any} />);
|
|
7
|
+
|
|
8
|
+
describe('VideoBlock', () => {
|
|
9
|
+
describe('YouTube URLs -> iframe', () => {
|
|
10
|
+
it('renders an iframe for a youtube.com watch URL', () => {
|
|
11
|
+
const { container } = renderBlock(
|
|
12
|
+
'https://www.youtube.com/watch?v=abc123'
|
|
13
|
+
);
|
|
14
|
+
const iframe = container.querySelector('iframe');
|
|
15
|
+
expect(iframe).toBeInTheDocument();
|
|
16
|
+
expect(iframe).toHaveAttribute(
|
|
17
|
+
'src',
|
|
18
|
+
'https://www.youtube.com/embed/abc123'
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders an iframe for a youtu.be short URL', () => {
|
|
23
|
+
const { container } = renderBlock('https://youtu.be/xyz789?t=10');
|
|
24
|
+
const iframe = container.querySelector('iframe');
|
|
25
|
+
expect(iframe).toHaveAttribute(
|
|
26
|
+
'src',
|
|
27
|
+
'https://www.youtube.com/embed/xyz789'
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('renders an iframe for an embed URL', () => {
|
|
32
|
+
const { container } = renderBlock(
|
|
33
|
+
'https://www.youtube.com/embed/embedId?foo=bar'
|
|
34
|
+
);
|
|
35
|
+
const iframe = container.querySelector('iframe');
|
|
36
|
+
expect(iframe).toHaveAttribute(
|
|
37
|
+
'src',
|
|
38
|
+
'https://www.youtube.com/embed/embedId'
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('falls back to placeholder when youtube videoId cannot be parsed', () => {
|
|
43
|
+
// matches "youtube.com" but no watch?v=/youtu.be/embed pattern -> videoId '' -> null
|
|
44
|
+
renderBlock('https://www.youtube.com/');
|
|
45
|
+
expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('Vimeo URLs -> iframe', () => {
|
|
50
|
+
it('renders an iframe for a vimeo URL', () => {
|
|
51
|
+
const { container } = renderBlock('https://vimeo.com/123456789?h=abc');
|
|
52
|
+
const iframe = container.querySelector('iframe');
|
|
53
|
+
expect(iframe).toHaveAttribute(
|
|
54
|
+
'src',
|
|
55
|
+
'https://player.vimeo.com/video/123456789'
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('Direct video file -> <video>', () => {
|
|
61
|
+
it('renders a <video> for an .mp4 URL', () => {
|
|
62
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4');
|
|
63
|
+
const video = container.querySelector('video');
|
|
64
|
+
expect(video).toBeInTheDocument();
|
|
65
|
+
expect(video).toHaveAttribute('src', 'https://cdn.example.com/clip.mp4');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders a <video> for a .webm URL', () => {
|
|
69
|
+
const { container } = renderBlock('https://cdn.example.com/clip.webm');
|
|
70
|
+
expect(container.querySelector('video')).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('renders a <video> for an .ogg URL (case-insensitive .OGG)', () => {
|
|
74
|
+
const { container } = renderBlock('https://cdn.example.com/clip.OGG');
|
|
75
|
+
expect(container.querySelector('video')).toBeInTheDocument();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('Invalid / empty -> placeholder', () => {
|
|
80
|
+
it('shows "No video URL provided" for empty string', () => {
|
|
81
|
+
const { container } = renderBlock('');
|
|
82
|
+
expect(screen.getByText('No video URL provided')).toBeInTheDocument();
|
|
83
|
+
expect(container.querySelector('iframe')).toBeNull();
|
|
84
|
+
expect(container.querySelector('video')).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('shows "Invalid video URL" for an unrecognized URL', () => {
|
|
88
|
+
renderBlock('https://example.com/not-a-video');
|
|
89
|
+
expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('YouTube property flags', () => {
|
|
94
|
+
it('appends autoplay=1', () => {
|
|
95
|
+
const { container } = renderBlock(
|
|
96
|
+
'https://www.youtube.com/watch?v=abc123',
|
|
97
|
+
{ autoplay: true }
|
|
98
|
+
);
|
|
99
|
+
const iframe = container.querySelector('iframe');
|
|
100
|
+
expect(iframe?.getAttribute('src')).toContain('autoplay=1');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('appends loop=1 and playlist=<videoId>', () => {
|
|
104
|
+
const { container } = renderBlock(
|
|
105
|
+
'https://www.youtube.com/watch?v=abc123',
|
|
106
|
+
{ loop: true }
|
|
107
|
+
);
|
|
108
|
+
const src = container.querySelector('iframe')?.getAttribute('src') || '';
|
|
109
|
+
expect(src).toContain('loop=1');
|
|
110
|
+
expect(src).toContain('playlist=abc123');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('appends mute=1 when muted', () => {
|
|
114
|
+
const { container } = renderBlock(
|
|
115
|
+
'https://www.youtube.com/watch?v=abc123',
|
|
116
|
+
{ muted: true }
|
|
117
|
+
);
|
|
118
|
+
expect(
|
|
119
|
+
container.querySelector('iframe')?.getAttribute('src')
|
|
120
|
+
).toContain('mute=1');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('appends controls=0 only when controls === false', () => {
|
|
124
|
+
const { container } = renderBlock(
|
|
125
|
+
'https://www.youtube.com/watch?v=abc123',
|
|
126
|
+
{ controls: false }
|
|
127
|
+
);
|
|
128
|
+
expect(
|
|
129
|
+
container.querySelector('iframe')?.getAttribute('src')
|
|
130
|
+
).toContain('controls=0');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('does not append controls when controls is true', () => {
|
|
134
|
+
const { container } = renderBlock(
|
|
135
|
+
'https://www.youtube.com/watch?v=abc123',
|
|
136
|
+
{ controls: true }
|
|
137
|
+
);
|
|
138
|
+
expect(
|
|
139
|
+
container.querySelector('iframe')?.getAttribute('src')
|
|
140
|
+
).not.toContain('controls');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('Vimeo property flags', () => {
|
|
145
|
+
it('appends autoplay=1, loop=1, muted=1, controls=0', () => {
|
|
146
|
+
const { container } = renderBlock('https://vimeo.com/123456789', {
|
|
147
|
+
autoplay: true,
|
|
148
|
+
loop: true,
|
|
149
|
+
muted: true,
|
|
150
|
+
controls: false
|
|
151
|
+
});
|
|
152
|
+
const src = container.querySelector('iframe')?.getAttribute('src') || '';
|
|
153
|
+
expect(src).toContain('autoplay=1');
|
|
154
|
+
expect(src).toContain('loop=1');
|
|
155
|
+
expect(src).toContain('muted=1');
|
|
156
|
+
expect(src).toContain('controls=0');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('<video> flags', () => {
|
|
161
|
+
it('defaults controls to true (controls !== false)', () => {
|
|
162
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4');
|
|
163
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
164
|
+
expect(video.controls).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('disables controls when controls === false', () => {
|
|
168
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
169
|
+
controls: false
|
|
170
|
+
});
|
|
171
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
172
|
+
expect(video.controls).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('reflects autoplay / loop / muted flags', () => {
|
|
176
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
177
|
+
autoplay: true,
|
|
178
|
+
loop: true,
|
|
179
|
+
muted: true
|
|
180
|
+
});
|
|
181
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
182
|
+
expect(video.autoplay).toBe(true);
|
|
183
|
+
expect(video.loop).toBe(true);
|
|
184
|
+
// muted is set via the `muted` prop; React reflects it on the DOM property
|
|
185
|
+
expect(video).toHaveAttribute('loop');
|
|
186
|
+
expect(video).toHaveAttribute('autoplay');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('non-string value (BUG-4 fix)', () => {
|
|
191
|
+
it('renders the invalid placeholder instead of crashing on a non-string value', () => {
|
|
192
|
+
renderBlock(123 as any);
|
|
193
|
+
expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -281,7 +281,7 @@ const CounterBlock = ({
|
|
|
281
281
|
|
|
282
282
|
const renderedBlocks = cloneAndInjectCounterValues(
|
|
283
283
|
block.blocks
|
|
284
|
-
.filter((b) => !b.hidden)
|
|
284
|
+
.filter((b) => (isDesigner ? true : !b.hidden))
|
|
285
285
|
.sort((a, b) => (a.order || 0) - (b.order || 0)),
|
|
286
286
|
values
|
|
287
287
|
);
|
|
@@ -11,7 +11,7 @@ const GroupBlock = ({
|
|
|
11
11
|
selectedBlockId,
|
|
12
12
|
currentBreakpoint = 'desktop'
|
|
13
13
|
}: BlockRendererProps) => {
|
|
14
|
-
const tag = getResponsiveValue(block.properties?.tag,
|
|
14
|
+
const tag = getResponsiveValue(block.properties?.tag, currentBreakpoint, 'div');
|
|
15
15
|
const Tag = tag as keyof React.JSX.IntrinsicElements;
|
|
16
16
|
const href = tag === 'a' ? block.properties?.href : undefined;
|
|
17
17
|
|
|
@@ -15,14 +15,19 @@ const isValidUrlValue = (value: string): boolean => {
|
|
|
15
15
|
}
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
const IconBlock = ({
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
const IconBlock = ({
|
|
19
|
+
block,
|
|
20
|
+
currentBreakpoint = 'desktop'
|
|
21
|
+
}: BlockRendererProps) => {
|
|
22
|
+
const iconValue = getResponsiveValue(block.value, currentBreakpoint, '');
|
|
23
|
+
const iconSize = getResponsiveValue(
|
|
24
|
+
block.styles?.size,
|
|
25
|
+
currentBreakpoint,
|
|
26
|
+
'24'
|
|
27
|
+
) as string | number;
|
|
23
28
|
const iconColor = getResponsiveValue(
|
|
24
29
|
block.styles?.color,
|
|
25
|
-
|
|
30
|
+
currentBreakpoint,
|
|
26
31
|
'currentColor'
|
|
27
32
|
) as string;
|
|
28
33
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
// ThemeBlock is a DEFAULT export rendered for the matched before/after blocks.
|
|
5
|
+
// Mock it so we can assert which block (by id) gets rendered.
|
|
6
|
+
jest.mock('../../theme-block', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
9
|
+
<div data-testid="tb" data-id={block.id} />
|
|
10
|
+
)
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import BeforeAfterSection from '../before-after-section';
|
|
14
|
+
|
|
15
|
+
const makeSection = (over: Record<string, unknown> = {}) =>
|
|
16
|
+
({
|
|
17
|
+
id: 'sec-1',
|
|
18
|
+
blocks: [],
|
|
19
|
+
styles: {},
|
|
20
|
+
properties: {},
|
|
21
|
+
...over
|
|
22
|
+
} as any);
|
|
23
|
+
|
|
24
|
+
const beforeImageBlock = { id: 'before-1', label: 'Before Image', type: 'image' };
|
|
25
|
+
const afterImageBlock = { id: 'after-1', label: 'After Image', type: 'image' };
|
|
26
|
+
|
|
27
|
+
describe('BeforeAfterSection — image by label', () => {
|
|
28
|
+
it('renders the before/after ThemeBlocks matched by label', () => {
|
|
29
|
+
const { getAllByTestId, queryByText } = render(
|
|
30
|
+
<BeforeAfterSection
|
|
31
|
+
section={makeSection({ blocks: [beforeImageBlock, afterImageBlock] })}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const ids = getAllByTestId('tb').map((el) => el.getAttribute('data-id'));
|
|
36
|
+
expect(ids).toEqual(expect.arrayContaining(['before-1', 'after-1']));
|
|
37
|
+
expect(ids).toHaveLength(2);
|
|
38
|
+
|
|
39
|
+
// No placeholders when both blocks are present.
|
|
40
|
+
expect(queryByText('No Before Image')).toBeNull();
|
|
41
|
+
expect(queryByText('No After Image')).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('only matches blocks with the exact "Before Image" / "After Image" labels', () => {
|
|
45
|
+
const { queryAllByTestId, getByText } = render(
|
|
46
|
+
<BeforeAfterSection
|
|
47
|
+
section={makeSection({
|
|
48
|
+
blocks: [{ id: 'wrong-1', label: 'Some Other Image', type: 'image' }]
|
|
49
|
+
})}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Non-matching label -> no ThemeBlock, both placeholders shown.
|
|
54
|
+
expect(queryAllByTestId('tb')).toHaveLength(0);
|
|
55
|
+
expect(getByText('No Before Image')).toBeInTheDocument();
|
|
56
|
+
expect(getByText('No After Image')).toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('BeforeAfterSection — placeholder fallback', () => {
|
|
61
|
+
it('shows "No After Image" when after block is missing', () => {
|
|
62
|
+
const { getByText, queryByText } = render(
|
|
63
|
+
<BeforeAfterSection section={makeSection({ blocks: [beforeImageBlock] })} />
|
|
64
|
+
);
|
|
65
|
+
expect(getByText('No After Image')).toBeInTheDocument();
|
|
66
|
+
expect(queryByText('No Before Image')).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('shows "No Before Image" when before block is missing', () => {
|
|
70
|
+
const { getByText, queryByText } = render(
|
|
71
|
+
<BeforeAfterSection section={makeSection({ blocks: [afterImageBlock] })} />
|
|
72
|
+
);
|
|
73
|
+
expect(getByText('No Before Image')).toBeInTheDocument();
|
|
74
|
+
expect(queryByText('No After Image')).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('shows both placeholders with no blocks at all', () => {
|
|
78
|
+
const { getByText } = render(
|
|
79
|
+
<BeforeAfterSection section={makeSection({ blocks: [] })} />
|
|
80
|
+
);
|
|
81
|
+
expect(getByText('No Before Image')).toBeInTheDocument();
|
|
82
|
+
expect(getByText('No After Image')).toBeInTheDocument();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('BeforeAfterSection — slider default position', () => {
|
|
87
|
+
it('defaults the slider/clip to 50%', () => {
|
|
88
|
+
const { container } = render(
|
|
89
|
+
<BeforeAfterSection section={makeSection()} />
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// The clip layer width and the divider handle left both derive from
|
|
93
|
+
// the default sliderPosition state (50).
|
|
94
|
+
const clip = container.querySelector('.border-r') as HTMLElement;
|
|
95
|
+
expect(clip).not.toBeNull();
|
|
96
|
+
expect(clip.style.width).toBe('50%');
|
|
97
|
+
|
|
98
|
+
const handle = container.querySelector('.cursor-ew-resize') as HTMLElement;
|
|
99
|
+
expect(handle).not.toBeNull();
|
|
100
|
+
expect(handle.style.left).toBe('50%');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('BeforeAfterSection — max-width handling', () => {
|
|
105
|
+
it('uses w-full and no inline maxWidth for max-width "full"', () => {
|
|
106
|
+
const { container } = render(
|
|
107
|
+
<BeforeAfterSection
|
|
108
|
+
section={makeSection({ styles: { 'max-width': 'full' } })}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
const root = container.firstChild as HTMLElement;
|
|
112
|
+
expect(root.className).toContain('w-full');
|
|
113
|
+
expect(root.className).not.toContain('max-w-7xl');
|
|
114
|
+
expect(root.className).not.toContain('max-w-4xl');
|
|
115
|
+
// hasMaxWidth is false for "full" -> no inline maxWidth.
|
|
116
|
+
expect(root.style.maxWidth).toBe('');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('sets inline maxWidth "none" for max-width "none"', () => {
|
|
120
|
+
const { container } = render(
|
|
121
|
+
<BeforeAfterSection
|
|
122
|
+
section={makeSection({ styles: { 'max-width': 'none' } })}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
const root = container.firstChild as HTMLElement;
|
|
126
|
+
expect(root.style.maxWidth).toBe('none');
|
|
127
|
+
expect(root.className).not.toContain('max-w-7xl');
|
|
128
|
+
expect(root.className).not.toContain('w-full');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('defaults to the "normal" max-width class (max-w-7xl)', () => {
|
|
132
|
+
const { container } = render(
|
|
133
|
+
<BeforeAfterSection section={makeSection()} />
|
|
134
|
+
);
|
|
135
|
+
const root = container.firstChild as HTMLElement;
|
|
136
|
+
expect(root.className).toContain('max-w-7xl');
|
|
137
|
+
expect(root.style.maxWidth).toBe('');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, act } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
// Mock ThemeBlock so we exercise only this section's logic.
|
|
5
|
+
jest.mock('../../theme-block', () => ({
|
|
6
|
+
__esModule: true,
|
|
7
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
8
|
+
<div data-testid="tb" data-id={block.id} />
|
|
9
|
+
)
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import CountdownCampaignBannerSection from '../countdown-campaign-banner-section';
|
|
13
|
+
|
|
14
|
+
// Minimal section factory matching the Section shape used by the component.
|
|
15
|
+
const makeSection = (overrides: Partial<any> = {}) => ({
|
|
16
|
+
id: 'sec-1',
|
|
17
|
+
type: 'countdown-campaign-banner',
|
|
18
|
+
properties: {},
|
|
19
|
+
styles: {},
|
|
20
|
+
blocks: [],
|
|
21
|
+
...overrides
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const renderSection = (props: any = {}) => {
|
|
25
|
+
let utils: ReturnType<typeof render>;
|
|
26
|
+
act(() => {
|
|
27
|
+
utils = render(
|
|
28
|
+
<CountdownCampaignBannerSection section={makeSection(props.section)} {...props} />
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
// @ts-expect-error assigned inside act
|
|
32
|
+
return utils;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Fixed "now" so countdown math is deterministic.
|
|
36
|
+
const NOW = new Date('2026-06-26T00:00:00.000Z').getTime();
|
|
37
|
+
// now + 1d 2h 3m 4s
|
|
38
|
+
const DELTA_MS = ((((1 * 24 + 2) * 60) + 3) * 60 + 4) * 1000;
|
|
39
|
+
const END_MS = NOW + DELTA_MS;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
jest.useFakeTimers();
|
|
43
|
+
jest.setSystemTime(NOW);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
jest.runOnlyPendingTimers();
|
|
48
|
+
jest.useRealTimers();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('CountdownCampaignBannerSection - parseEndDate', () => {
|
|
52
|
+
it('parses a millisecond epoch number into the correct remaining time', () => {
|
|
53
|
+
renderSection({ section: { properties: { 'end-date': END_MS } } });
|
|
54
|
+
|
|
55
|
+
expect(screen.queryByText('Campaign ended')).not.toBeInTheDocument();
|
|
56
|
+
expect(screen.getByText('01')).toBeInTheDocument(); // days
|
|
57
|
+
expect(screen.getByText('02')).toBeInTheDocument(); // hours
|
|
58
|
+
expect(screen.getByText('03')).toBeInTheDocument(); // minutes
|
|
59
|
+
expect(screen.getByText('04')).toBeInTheDocument(); // seconds
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('parses a second epoch number (< 10_000_000_000) the same as the ms form', () => {
|
|
63
|
+
const endSeconds = Math.floor(END_MS / 1000);
|
|
64
|
+
expect(endSeconds).toBeLessThan(10_000_000_000);
|
|
65
|
+
|
|
66
|
+
renderSection({ section: { properties: { 'end-date': endSeconds } } });
|
|
67
|
+
|
|
68
|
+
expect(screen.getByText('01')).toBeInTheDocument();
|
|
69
|
+
expect(screen.getByText('02')).toBeInTheDocument();
|
|
70
|
+
expect(screen.getByText('03')).toBeInTheDocument();
|
|
71
|
+
expect(screen.getByText('04')).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('parses an ISO date string', () => {
|
|
75
|
+
const iso = new Date(END_MS).toISOString();
|
|
76
|
+
renderSection({ section: { properties: { 'end-date': iso } } });
|
|
77
|
+
|
|
78
|
+
expect(screen.getByText('01')).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByText('02')).toBeInTheDocument();
|
|
80
|
+
expect(screen.getByText('03')).toBeInTheDocument();
|
|
81
|
+
expect(screen.getByText('04')).toBeInTheDocument();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('parses a numeric string as a second epoch', () => {
|
|
85
|
+
const endSeconds = String(Math.floor(END_MS / 1000));
|
|
86
|
+
renderSection({ section: { properties: { 'end-date': endSeconds } } });
|
|
87
|
+
|
|
88
|
+
expect(screen.getByText('01')).toBeInTheDocument();
|
|
89
|
+
expect(screen.getByText('04')).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('CountdownCampaignBannerSection - expired', () => {
|
|
94
|
+
it('renders the default expired message for a past date', () => {
|
|
95
|
+
renderSection({ section: { properties: { 'end-date': '2000-01-01T00:00:00.000Z' } } });
|
|
96
|
+
|
|
97
|
+
expect(screen.getByText('Campaign ended')).toBeInTheDocument();
|
|
98
|
+
// No timer cells when expired.
|
|
99
|
+
expect(screen.queryByText('Days')).not.toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('renders a custom expired message when provided', () => {
|
|
103
|
+
renderSection({
|
|
104
|
+
section: {
|
|
105
|
+
properties: {
|
|
106
|
+
'end-date': '2000-01-01T00:00:00.000Z',
|
|
107
|
+
'expired-message': 'Bitti'
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(screen.getByText('Bitti')).toBeInTheDocument();
|
|
113
|
+
expect(screen.queryByText('Campaign ended')).not.toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('an empty / missing end-date is NOT treated as expired (renders the zeroed timer)', () => {
|
|
117
|
+
renderSection({ section: { properties: {} } });
|
|
118
|
+
|
|
119
|
+
expect(screen.queryByText('Campaign ended')).not.toBeInTheDocument();
|
|
120
|
+
expect(screen.getByText('Days')).toBeInTheDocument();
|
|
121
|
+
// All units zeroed.
|
|
122
|
+
expect(screen.getAllByText('00').length).toBe(4);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('CountdownCampaignBannerSection - showSeconds toggle', () => {
|
|
127
|
+
it('shows four units (incl. Seconds) by default', () => {
|
|
128
|
+
renderSection({ section: { properties: { 'end-date': END_MS } } });
|
|
129
|
+
|
|
130
|
+
expect(screen.getByText('Days')).toBeInTheDocument();
|
|
131
|
+
expect(screen.getByText('Hours')).toBeInTheDocument();
|
|
132
|
+
expect(screen.getByText('Minutes')).toBeInTheDocument();
|
|
133
|
+
expect(screen.getByText('Seconds')).toBeInTheDocument();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('hides the Seconds unit when show-seconds is false', () => {
|
|
137
|
+
renderSection({
|
|
138
|
+
section: { properties: { 'end-date': END_MS, 'show-seconds': false } }
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(screen.getByText('Days')).toBeInTheDocument();
|
|
142
|
+
expect(screen.getByText('Hours')).toBeInTheDocument();
|
|
143
|
+
expect(screen.getByText('Minutes')).toBeInTheDocument();
|
|
144
|
+
expect(screen.queryByText('Seconds')).not.toBeInTheDocument();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('accepts the string "false" for show-seconds', () => {
|
|
148
|
+
renderSection({
|
|
149
|
+
section: { properties: { 'end-date': END_MS, 'show-seconds': 'false' } }
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(screen.queryByText('Seconds')).not.toBeInTheDocument();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('CountdownCampaignBannerSection - timer ticks', () => {
|
|
157
|
+
it('decrements the seconds cell after one interval tick', () => {
|
|
158
|
+
renderSection({ section: { properties: { 'end-date': END_MS } } });
|
|
159
|
+
|
|
160
|
+
expect(screen.getByText('04')).toBeInTheDocument();
|
|
161
|
+
|
|
162
|
+
act(() => {
|
|
163
|
+
jest.advanceTimersByTime(1000);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// seconds 04 -> 03 (minutes cell is also 03 at this point, so expect two)
|
|
167
|
+
expect(screen.queryByText('04')).not.toBeInTheDocument();
|
|
168
|
+
expect(screen.getAllByText('03').length).toBe(2);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('CountdownCampaignBannerSection - block layout', () => {
|
|
173
|
+
it('renders non-image foreground blocks via ThemeBlock', () => {
|
|
174
|
+
renderSection({
|
|
175
|
+
section: {
|
|
176
|
+
properties: { 'end-date': END_MS },
|
|
177
|
+
blocks: [
|
|
178
|
+
{ id: 'b1', type: 'text', order: 1 },
|
|
179
|
+
{ id: 'b2', type: 'button', order: 2 }
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const blocks = screen.getAllByTestId('tb');
|
|
185
|
+
const ids = blocks.map((el) => el.getAttribute('data-id'));
|
|
186
|
+
expect(ids).toEqual(expect.arrayContaining(['b1', 'b2']));
|
|
187
|
+
});
|
|
188
|
+
});
|