@akinon/pz-theme 2.0.30-rc.0 → 2.0.31-beta.0
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 +16 -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,57 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
// Stub the recursive ThemeBlock (default export) with a countable marker.
|
|
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 CounterBlock from '../counter-block';
|
|
13
|
+
|
|
14
|
+
const child = (id: string, hidden = false, order = 0) => ({
|
|
15
|
+
id,
|
|
16
|
+
type: 'text',
|
|
17
|
+
label: id,
|
|
18
|
+
value: '',
|
|
19
|
+
properties: {},
|
|
20
|
+
styles: {},
|
|
21
|
+
order,
|
|
22
|
+
hidden
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const props = (over: Record<string, unknown> = {}) =>
|
|
26
|
+
({
|
|
27
|
+
block: {
|
|
28
|
+
id: 'c',
|
|
29
|
+
type: 'counter',
|
|
30
|
+
label: 'c',
|
|
31
|
+
value: '',
|
|
32
|
+
properties: {}, // no endAt -> no timers in this test
|
|
33
|
+
styles: {},
|
|
34
|
+
order: 0,
|
|
35
|
+
hidden: false,
|
|
36
|
+
blocks: [child('c1', false), child('c2', true)]
|
|
37
|
+
},
|
|
38
|
+
placeholderId: 'p',
|
|
39
|
+
sectionId: 's',
|
|
40
|
+
isDesigner: false,
|
|
41
|
+
...over
|
|
42
|
+
} as any);
|
|
43
|
+
|
|
44
|
+
const markerCount = (container: HTMLElement) =>
|
|
45
|
+
container.querySelectorAll('[data-testid="tb"]').length;
|
|
46
|
+
|
|
47
|
+
describe('CounterBlock — hidden children (BUG-1 fix)', () => {
|
|
48
|
+
it('drops hidden children in production', () => {
|
|
49
|
+
const { container } = render(<CounterBlock {...props({ isDesigner: false })} />);
|
|
50
|
+
expect(markerCount(container)).toBe(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps hidden children in the designer so they remain selectable', () => {
|
|
54
|
+
const { container } = render(<CounterBlock {...props({ isDesigner: true })} />);
|
|
55
|
+
expect(markerCount(container)).toBe(2);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import EmbedBlock from '../embed-block';
|
|
4
|
+
|
|
5
|
+
const props = (over: Record<string, unknown> = {}) =>
|
|
6
|
+
({
|
|
7
|
+
block: {
|
|
8
|
+
id: 'e',
|
|
9
|
+
type: 'embed',
|
|
10
|
+
label: 'e',
|
|
11
|
+
value: '',
|
|
12
|
+
properties: {},
|
|
13
|
+
styles: {},
|
|
14
|
+
order: 0,
|
|
15
|
+
hidden: false,
|
|
16
|
+
...((over as any).block || {})
|
|
17
|
+
},
|
|
18
|
+
placeholderId: 'p',
|
|
19
|
+
sectionId: 's',
|
|
20
|
+
isDesigner: false,
|
|
21
|
+
...over
|
|
22
|
+
} as any);
|
|
23
|
+
|
|
24
|
+
describe('EmbedBlock', () => {
|
|
25
|
+
it('renders the placeholder when value is undefined/empty', () => {
|
|
26
|
+
const { container } = render(<EmbedBlock {...props({ block: { value: undefined } })} />);
|
|
27
|
+
expect(screen.getByText('Enter an embed URL or paste iframe code')).toBeInTheDocument();
|
|
28
|
+
expect(
|
|
29
|
+
screen.getByText(/Supported: Twitter, Spotify, CodePen/)
|
|
30
|
+
).toBeInTheDocument();
|
|
31
|
+
expect(container.querySelector('iframe')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('resolves a localized value object (en preferred) into an iframe', () => {
|
|
35
|
+
const { container } = render(
|
|
36
|
+
<EmbedBlock
|
|
37
|
+
{...props({
|
|
38
|
+
block: {
|
|
39
|
+
value: {
|
|
40
|
+
en: 'https://open.spotify.com/track/abc123',
|
|
41
|
+
tr: 'https://open.spotify.com/track/turkish'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
})}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
const iframe = container.querySelector('iframe');
|
|
48
|
+
expect(iframe).not.toBeNull();
|
|
49
|
+
// 'en' key wins over 'tr'
|
|
50
|
+
expect(iframe!.getAttribute('src')).toBe('https://open.spotify.com/embed/track/abc123');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('converts a known platform URL (Twitter) into the platform embed iframe', () => {
|
|
54
|
+
const { container } = render(
|
|
55
|
+
<EmbedBlock
|
|
56
|
+
{...props({ block: { value: 'https://twitter.com/user/status/12345' } })}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
const iframe = container.querySelector('iframe');
|
|
60
|
+
expect(iframe).not.toBeNull();
|
|
61
|
+
expect(iframe!.getAttribute('src')).toBe(
|
|
62
|
+
'https://platform.twitter.com/embed/Tweet.html?id=12345'
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('shows the invalid message for an unparseable embed value', () => {
|
|
67
|
+
const { container } = render(
|
|
68
|
+
<EmbedBlock {...props({ block: { value: 'not a url at all' } })} />
|
|
69
|
+
);
|
|
70
|
+
expect(screen.getByText(/Invalid embed URL or code/)).toBeInTheDocument();
|
|
71
|
+
expect(
|
|
72
|
+
screen.getByText('Please check the URL format or paste a valid iframe code')
|
|
73
|
+
).toBeInTheDocument();
|
|
74
|
+
expect(container.querySelector('iframe')).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('emits the sandbox attribute on the rendered iframe', () => {
|
|
78
|
+
const { container } = render(
|
|
79
|
+
<EmbedBlock {...props({ block: { value: 'https://example.com/widget' } })} />
|
|
80
|
+
);
|
|
81
|
+
const iframe = container.querySelector('iframe');
|
|
82
|
+
expect(iframe).not.toBeNull();
|
|
83
|
+
expect(iframe!.getAttribute('sandbox')).toBe(
|
|
84
|
+
'allow-scripts allow-same-origin allow-popups allow-forms'
|
|
85
|
+
);
|
|
86
|
+
// generic https URL passes through unchanged
|
|
87
|
+
expect(iframe!.getAttribute('src')).toBe('https://example.com/widget');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
// Stub the recursive ThemeBlock (default export) with a countable marker.
|
|
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 GroupBlock from '../group-block';
|
|
13
|
+
|
|
14
|
+
const child = (id: string, hidden = false, order = 0) => ({
|
|
15
|
+
id,
|
|
16
|
+
type: 'text',
|
|
17
|
+
label: id,
|
|
18
|
+
value: '',
|
|
19
|
+
properties: {},
|
|
20
|
+
styles: {},
|
|
21
|
+
order,
|
|
22
|
+
hidden
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const props = (over: Record<string, unknown> = {}) =>
|
|
26
|
+
({
|
|
27
|
+
block: {
|
|
28
|
+
id: 'g',
|
|
29
|
+
type: 'group',
|
|
30
|
+
label: 'g',
|
|
31
|
+
value: '',
|
|
32
|
+
properties: {},
|
|
33
|
+
styles: {},
|
|
34
|
+
order: 0,
|
|
35
|
+
hidden: false,
|
|
36
|
+
blocks: [child('c1', false, 0), child('c2', true, 0)]
|
|
37
|
+
},
|
|
38
|
+
placeholderId: 'p',
|
|
39
|
+
sectionId: 's',
|
|
40
|
+
isDesigner: false,
|
|
41
|
+
...over
|
|
42
|
+
} as any);
|
|
43
|
+
|
|
44
|
+
const markers = (container: HTMLElement) =>
|
|
45
|
+
Array.from(container.querySelectorAll('[data-testid="tb"]'));
|
|
46
|
+
|
|
47
|
+
describe('GroupBlock — children visibility', () => {
|
|
48
|
+
it('drops hidden children in production', () => {
|
|
49
|
+
const { container } = render(<GroupBlock {...props({ isDesigner: false })} />);
|
|
50
|
+
expect(markers(container)).toHaveLength(1);
|
|
51
|
+
expect(markers(container)[0]).toHaveAttribute('data-id', 'c1');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('keeps hidden children in the designer so they remain selectable', () => {
|
|
55
|
+
const { container } = render(<GroupBlock {...props({ isDesigner: true })} />);
|
|
56
|
+
expect(markers(container)).toHaveLength(2);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('GroupBlock — ordering', () => {
|
|
61
|
+
it('sorts children by ascending order before rendering ThemeBlock', () => {
|
|
62
|
+
const block = {
|
|
63
|
+
...props().block,
|
|
64
|
+
blocks: [
|
|
65
|
+
child('a', false, 2),
|
|
66
|
+
child('b', false, 0),
|
|
67
|
+
child('c', false, 1)
|
|
68
|
+
]
|
|
69
|
+
};
|
|
70
|
+
const { container } = render(<GroupBlock {...props({ block, isDesigner: true })} />);
|
|
71
|
+
const ids = markers(container).map((m) => m.getAttribute('data-id'));
|
|
72
|
+
expect(ids).toEqual(['b', 'c', 'a']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('treats missing order as 0', () => {
|
|
76
|
+
const block = {
|
|
77
|
+
...props().block,
|
|
78
|
+
blocks: [
|
|
79
|
+
child('withOrder', false, 1),
|
|
80
|
+
{ ...child('noOrder', false), order: undefined }
|
|
81
|
+
]
|
|
82
|
+
};
|
|
83
|
+
const { container } = render(<GroupBlock {...props({ block, isDesigner: true })} />);
|
|
84
|
+
const ids = markers(container).map((m) => m.getAttribute('data-id'));
|
|
85
|
+
expect(ids).toEqual(['noOrder', 'withOrder']);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('GroupBlock — empty states', () => {
|
|
90
|
+
it('renders the "Empty group block" placeholder when there are no children', () => {
|
|
91
|
+
const block = { ...props().block, blocks: [] };
|
|
92
|
+
const { container } = render(<GroupBlock {...props({ block })} />);
|
|
93
|
+
expect(container.textContent).toContain('Empty group block');
|
|
94
|
+
expect(markers(container)).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('renders an empty tag (no placeholder text) for visual-utility groups', () => {
|
|
98
|
+
const block = {
|
|
99
|
+
...props().block,
|
|
100
|
+
blocks: [],
|
|
101
|
+
properties: { shippingRole: 'progress-fill' }
|
|
102
|
+
};
|
|
103
|
+
const { container } = render(<GroupBlock {...props({ block })} />);
|
|
104
|
+
expect(container.textContent).not.toContain('Empty group block');
|
|
105
|
+
expect(container.querySelector('.contents')).toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('GroupBlock — dynamic Tag', () => {
|
|
110
|
+
it('defaults to <div> when no tag property is set', () => {
|
|
111
|
+
const { container } = render(<GroupBlock {...props()} />);
|
|
112
|
+
const wrapper = container.querySelector('.contents');
|
|
113
|
+
expect(wrapper?.tagName).toBe('DIV');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('renders an <a> with href when tag is "a"', () => {
|
|
117
|
+
const block = {
|
|
118
|
+
...props().block,
|
|
119
|
+
properties: { tag: 'a', href: '/foo' }
|
|
120
|
+
};
|
|
121
|
+
const { container } = render(<GroupBlock {...props({ block })} />);
|
|
122
|
+
const wrapper = container.querySelector('.contents') as HTMLAnchorElement;
|
|
123
|
+
expect(wrapper.tagName).toBe('A');
|
|
124
|
+
expect(wrapper).toHaveAttribute('href', '/foo');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('renders a <form> when tag is "form" and submits via the inner submit button', () => {
|
|
128
|
+
const block = {
|
|
129
|
+
...props().block,
|
|
130
|
+
properties: { tag: 'form' }
|
|
131
|
+
};
|
|
132
|
+
const { container } = render(<GroupBlock {...props({ block })} />);
|
|
133
|
+
const form = container.querySelector('form') as HTMLFormElement;
|
|
134
|
+
expect(form).toBeInTheDocument();
|
|
135
|
+
|
|
136
|
+
// The onSubmit handler prevents default and clicks the inner submit button.
|
|
137
|
+
const button = document.createElement('button');
|
|
138
|
+
button.type = 'submit';
|
|
139
|
+
const clickSpy = jest.fn();
|
|
140
|
+
button.addEventListener('click', clickSpy);
|
|
141
|
+
form.appendChild(button);
|
|
142
|
+
|
|
143
|
+
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
|
|
144
|
+
form.dispatchEvent(submitEvent);
|
|
145
|
+
|
|
146
|
+
expect(submitEvent.defaultPrevented).toBe(true);
|
|
147
|
+
expect(clickSpy).toHaveBeenCalledTimes(1);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('GroupBlock — responsive tag honors the active breakpoint (BUG-5 fix)', () => {
|
|
152
|
+
it('uses the mobile tag value on the mobile breakpoint', () => {
|
|
153
|
+
const block = {
|
|
154
|
+
...props().block,
|
|
155
|
+
properties: { tag: { desktop: 'div', mobile: 'a' }, href: '/m' }
|
|
156
|
+
};
|
|
157
|
+
const { container } = render(
|
|
158
|
+
<GroupBlock {...props({ block, currentBreakpoint: 'mobile' })} />
|
|
159
|
+
);
|
|
160
|
+
const wrapper = container.querySelector('.contents') as HTMLAnchorElement;
|
|
161
|
+
expect(wrapper.tagName).toBe('A');
|
|
162
|
+
expect(wrapper).toHaveAttribute('href', '/m');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('uses the desktop tag value on the desktop breakpoint', () => {
|
|
166
|
+
const block = {
|
|
167
|
+
...props().block,
|
|
168
|
+
properties: { tag: { desktop: 'div', mobile: 'a' }, href: '/m' }
|
|
169
|
+
};
|
|
170
|
+
const { container } = render(
|
|
171
|
+
<GroupBlock {...props({ block, currentBreakpoint: 'desktop' })} />
|
|
172
|
+
);
|
|
173
|
+
expect(container.querySelector('.contents')?.tagName).toBe('DIV');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
// Stub the recursive ThemeBlock (default export) with a countable marker.
|
|
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 HotspotBlock from '../hotspot-block';
|
|
13
|
+
|
|
14
|
+
const props = (over: Record<string, unknown> = {}) =>
|
|
15
|
+
({
|
|
16
|
+
block: {
|
|
17
|
+
id: 'h',
|
|
18
|
+
type: 'hotspot',
|
|
19
|
+
label: 'h',
|
|
20
|
+
value: '',
|
|
21
|
+
properties: {},
|
|
22
|
+
styles: {},
|
|
23
|
+
order: 0,
|
|
24
|
+
hidden: false
|
|
25
|
+
},
|
|
26
|
+
placeholderId: 'p',
|
|
27
|
+
sectionId: 's',
|
|
28
|
+
isDesigner: false,
|
|
29
|
+
...over
|
|
30
|
+
} as any);
|
|
31
|
+
|
|
32
|
+
describe('HotspotBlock', () => {
|
|
33
|
+
it('defaults top/left to 50%/50% when no positioning styles are set', () => {
|
|
34
|
+
const { container } = render(<HotspotBlock {...props()} />);
|
|
35
|
+
const root = container.querySelector('.absolute.group') as HTMLElement;
|
|
36
|
+
expect(root).toBeInTheDocument();
|
|
37
|
+
expect(root.style.top).toBe('50%');
|
|
38
|
+
expect(root.style.left).toBe('50%');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('opens the popup with title and description on button click', () => {
|
|
42
|
+
render(
|
|
43
|
+
<HotspotBlock
|
|
44
|
+
{...props({
|
|
45
|
+
block: {
|
|
46
|
+
id: 'h',
|
|
47
|
+
type: 'hotspot',
|
|
48
|
+
properties: { title: 'Hot Title', description: 'Hot Desc' },
|
|
49
|
+
styles: {}
|
|
50
|
+
}
|
|
51
|
+
})}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Popup content is not present before the click.
|
|
56
|
+
expect(screen.queryByText('Hot Title')).not.toBeInTheDocument();
|
|
57
|
+
expect(screen.queryByText('Hot Desc')).not.toBeInTheDocument();
|
|
58
|
+
|
|
59
|
+
fireEvent.click(screen.getByRole('button', { name: 'Hot Title' }));
|
|
60
|
+
|
|
61
|
+
expect(screen.getByText('Hot Title')).toBeInTheDocument();
|
|
62
|
+
expect(screen.getByText('Hot Desc')).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('recurses into nested child blocks via ThemeBlock once the popup is open', () => {
|
|
66
|
+
const { container } = render(
|
|
67
|
+
<HotspotBlock
|
|
68
|
+
{...props({
|
|
69
|
+
block: {
|
|
70
|
+
id: 'h',
|
|
71
|
+
type: 'hotspot',
|
|
72
|
+
properties: { title: 'T' },
|
|
73
|
+
styles: {},
|
|
74
|
+
blocks: [
|
|
75
|
+
{ id: 'c1', type: 'text' },
|
|
76
|
+
{ id: 'c2', type: 'text' }
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
})}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Children are not rendered while the popup is closed.
|
|
84
|
+
expect(container.querySelectorAll('[data-testid="tb"]').length).toBe(0);
|
|
85
|
+
|
|
86
|
+
fireEvent.click(screen.getByRole('button', { name: 'T' }));
|
|
87
|
+
|
|
88
|
+
const markers = container.querySelectorAll('[data-testid="tb"]');
|
|
89
|
+
expect(markers.length).toBe(2);
|
|
90
|
+
expect(markers[0].getAttribute('data-id')).toBe('c1');
|
|
91
|
+
expect(markers[1].getAttribute('data-id')).toBe('c2');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import IconBlock from '../icon-block';
|
|
4
|
+
|
|
5
|
+
const props = (over: Record<string, unknown> = {}) =>
|
|
6
|
+
({
|
|
7
|
+
block: {
|
|
8
|
+
id: 'i',
|
|
9
|
+
type: 'icon',
|
|
10
|
+
label: 'i',
|
|
11
|
+
value: '',
|
|
12
|
+
properties: {},
|
|
13
|
+
styles: {},
|
|
14
|
+
order: 0,
|
|
15
|
+
hidden: false
|
|
16
|
+
},
|
|
17
|
+
placeholderId: 'p',
|
|
18
|
+
sectionId: 's',
|
|
19
|
+
isDesigner: false,
|
|
20
|
+
...over
|
|
21
|
+
} as any);
|
|
22
|
+
|
|
23
|
+
describe('IconBlock — breakpoint resolution (BUG-2 fix)', () => {
|
|
24
|
+
it('uses the mobile value and size on the mobile breakpoint', () => {
|
|
25
|
+
const { container } = render(
|
|
26
|
+
<IconBlock
|
|
27
|
+
{...props({
|
|
28
|
+
block: {
|
|
29
|
+
id: 'i',
|
|
30
|
+
type: 'icon',
|
|
31
|
+
value: { desktop: '/d.svg', mobile: '/m.svg' },
|
|
32
|
+
styles: { size: { desktop: '24', mobile: '48' } },
|
|
33
|
+
properties: {},
|
|
34
|
+
order: 0,
|
|
35
|
+
hidden: false
|
|
36
|
+
},
|
|
37
|
+
currentBreakpoint: 'mobile'
|
|
38
|
+
})}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
const img = container.querySelector('img');
|
|
42
|
+
expect(img).not.toBeNull();
|
|
43
|
+
expect(img!.getAttribute('src')).toBe('/m.svg');
|
|
44
|
+
expect(img!.getAttribute('width')).toBe('48');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('falls back to the desktop value when there is no mobile override', () => {
|
|
48
|
+
const { container } = render(
|
|
49
|
+
<IconBlock
|
|
50
|
+
{...props({
|
|
51
|
+
block: {
|
|
52
|
+
id: 'i',
|
|
53
|
+
type: 'icon',
|
|
54
|
+
value: { desktop: '/d.svg' },
|
|
55
|
+
styles: { size: { desktop: '24' } },
|
|
56
|
+
properties: {},
|
|
57
|
+
order: 0,
|
|
58
|
+
hidden: false
|
|
59
|
+
},
|
|
60
|
+
currentBreakpoint: 'mobile'
|
|
61
|
+
})}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
expect(container.querySelector('img')!.getAttribute('src')).toBe('/d.svg');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('IconBlock — inline SVG is NOT sanitized (pinned; sanitization is Tier 5)', () => {
|
|
69
|
+
it('injects raw inline SVG via dangerouslySetInnerHTML (documents current behavior)', () => {
|
|
70
|
+
const { container } = render(
|
|
71
|
+
<IconBlock
|
|
72
|
+
{...props({
|
|
73
|
+
block: {
|
|
74
|
+
id: 'i',
|
|
75
|
+
type: 'icon',
|
|
76
|
+
value: '<svg onload="globalThis.__x=1"><rect /></svg>',
|
|
77
|
+
styles: {},
|
|
78
|
+
properties: {},
|
|
79
|
+
order: 0,
|
|
80
|
+
hidden: false
|
|
81
|
+
}
|
|
82
|
+
})}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
86
|
+
expect(container.innerHTML).toContain('onload');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import ImageBlock from '../image-block';
|
|
5
|
+
import type { BlockRendererProps } from '../block-renderer-registry';
|
|
6
|
+
|
|
7
|
+
// ImageBlock renders a native <img> / <div> directly (no child ThemeBlock /
|
|
8
|
+
// ThemeSection), so no extra component mock is required.
|
|
9
|
+
// Note: the rendered <img> has alt="" by default, which gives it an ARIA
|
|
10
|
+
// "presentation" role, so it is queried via the DOM instead of getByRole.
|
|
11
|
+
|
|
12
|
+
const renderBlock = (
|
|
13
|
+
block: Record<string, unknown>,
|
|
14
|
+
currentBreakpoint?: string
|
|
15
|
+
) =>
|
|
16
|
+
render(
|
|
17
|
+
<ImageBlock
|
|
18
|
+
{...({ block } as unknown as BlockRendererProps)}
|
|
19
|
+
{...(currentBreakpoint ? { currentBreakpoint } : {})}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const getImg = (container: HTMLElement) =>
|
|
24
|
+
container.querySelector('img') as HTMLImageElement | null;
|
|
25
|
+
|
|
26
|
+
describe('ImageBlock - pickImageSource', () => {
|
|
27
|
+
it('renders an <img> with src from a plain string value', () => {
|
|
28
|
+
const { container } = renderBlock({
|
|
29
|
+
id: 'b1',
|
|
30
|
+
value: 'https://example.com/a.jpg'
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const img = getImg(container);
|
|
34
|
+
expect(img).not.toBeNull();
|
|
35
|
+
expect(img?.getAttribute('src')).toBe('https://example.com/a.jpg');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('renders an <img> with src from an object { url } value', () => {
|
|
39
|
+
const { container } = renderBlock({
|
|
40
|
+
id: 'b2',
|
|
41
|
+
value: { url: 'https://example.com/b.jpg' }
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(getImg(container)?.getAttribute('src')).toBe(
|
|
45
|
+
'https://example.com/b.jpg'
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('resolves a responsive { desktop, mobile } value by breakpoint', () => {
|
|
50
|
+
const value = {
|
|
51
|
+
desktop: 'https://example.com/desktop.jpg',
|
|
52
|
+
mobile: 'https://example.com/mobile.jpg'
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// default breakpoint is "desktop"
|
|
56
|
+
const desktopRender = renderBlock({ id: 'b3', value });
|
|
57
|
+
expect(getImg(desktopRender.container)?.getAttribute('src')).toBe(
|
|
58
|
+
'https://example.com/desktop.jpg'
|
|
59
|
+
);
|
|
60
|
+
desktopRender.unmount();
|
|
61
|
+
|
|
62
|
+
const mobileRender = renderBlock({ id: 'b3b', value }, 'mobile');
|
|
63
|
+
expect(getImg(mobileRender.container)?.getAttribute('src')).toBe(
|
|
64
|
+
'https://example.com/mobile.jpg'
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('falls back to the first string value when the breakpoint match is non-string', () => {
|
|
69
|
+
// To reach the first-string fallback, getResponsiveValue must return a
|
|
70
|
+
// NON-string (here the "desktop" key is a number), otherwise it returns
|
|
71
|
+
// its '' string fallback and short-circuits. See "surprises".
|
|
72
|
+
const { container } = renderBlock({
|
|
73
|
+
id: 'b4',
|
|
74
|
+
value: { desktop: 123, foo: 'https://example.com/first.jpg' }
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(getImg(container)?.getAttribute('src')).toBe(
|
|
78
|
+
'https://example.com/first.jpg'
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('SURPRISE: a plain { foo: url } object never reaches the first-string scan', () => {
|
|
83
|
+
// getResponsiveValue returns its '' string fallback (no desktop/breakpoint
|
|
84
|
+
// key), pickImageSource returns '' before scanning values -> placeholder.
|
|
85
|
+
const { container } = renderBlock({
|
|
86
|
+
id: 'b5',
|
|
87
|
+
value: { foo: 'https://example.com/first.jpg' }
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(getImg(container)).toBeNull();
|
|
91
|
+
expect(screen.getByText('No image uploaded')).toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('ImageBlock - empty / invalid sources', () => {
|
|
96
|
+
it('shows "No image uploaded" placeholder for an empty string value', () => {
|
|
97
|
+
const { container } = renderBlock({ id: 'e1', value: '' });
|
|
98
|
+
|
|
99
|
+
expect(getImg(container)).toBeNull();
|
|
100
|
+
expect(screen.getByText('No image uploaded')).toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('shows "No image uploaded" placeholder for a missing value', () => {
|
|
104
|
+
const { container } = renderBlock({ id: 'e2' });
|
|
105
|
+
|
|
106
|
+
expect(getImg(container)).toBeNull();
|
|
107
|
+
expect(screen.getByText('No image uploaded')).toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('shows "No image uploaded" when a relative source cannot be normalized', () => {
|
|
111
|
+
// Without NEXT_PUBLIC_IMAGE_CLOUD_NAME a bare relative path normalizes to ''
|
|
112
|
+
delete process.env.NEXT_PUBLIC_IMAGE_CLOUD_NAME;
|
|
113
|
+
const { container } = renderBlock({ id: 'e3', value: 'foo.jpg' });
|
|
114
|
+
|
|
115
|
+
expect(getImg(container)).toBeNull();
|
|
116
|
+
expect(screen.getByText('No image uploaded')).toBeInTheDocument();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('ImageBlock - loading strategy', () => {
|
|
121
|
+
it('defaults loading to "lazy" when no strategy is provided', () => {
|
|
122
|
+
const { container } = renderBlock({
|
|
123
|
+
id: 'l1',
|
|
124
|
+
value: 'https://example.com/a.jpg'
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(getImg(container)?.getAttribute('loading')).toBe('lazy');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('uses the provided loadingStrategy when valid', () => {
|
|
131
|
+
const { container } = renderBlock({
|
|
132
|
+
id: 'l2',
|
|
133
|
+
value: 'https://example.com/a.jpg',
|
|
134
|
+
properties: { loadingStrategy: 'eager' }
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(getImg(container)?.getAttribute('loading')).toBe('eager');
|
|
138
|
+
});
|
|
139
|
+
});
|