@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +16 -29
  2. package/jest/__mocks__/akinon-next-hooks.js +15 -0
  3. package/jest/__mocks__/akinon-next-image.js +22 -0
  4. package/jest/__mocks__/akinon-next-loader-spinner.js +7 -0
  5. package/jest/__mocks__/akinon-next-localization.js +10 -0
  6. package/jest/__mocks__/akinon-next-modal.js +11 -0
  7. package/jest/__mocks__/akinon-next-price.js +9 -0
  8. package/jest/__mocks__/akinon-next-rtk.js +30 -0
  9. package/jest/__mocks__/akinon-next-utils.js +8 -0
  10. package/jest/__mocks__/next-link.js +11 -0
  11. package/jest/__mocks__/settings.js +13 -0
  12. package/jest/setup.ts +33 -0
  13. package/jest.config.js +24 -3
  14. package/package.json +6 -3
  15. package/src/__tests__/theme-block.test.tsx +46 -0
  16. package/src/blocks/__tests__/accordion-block.test.tsx +178 -0
  17. package/src/blocks/__tests__/button-block.test.tsx +99 -0
  18. package/src/blocks/__tests__/counter-block.test.tsx +57 -0
  19. package/src/blocks/__tests__/embed-block.test.tsx +89 -0
  20. package/src/blocks/__tests__/group-block.test.tsx +175 -0
  21. package/src/blocks/__tests__/hotspot-block.test.tsx +93 -0
  22. package/src/blocks/__tests__/icon-block.test.tsx +88 -0
  23. package/src/blocks/__tests__/image-block.test.tsx +139 -0
  24. package/src/blocks/__tests__/image-gallery-block.test.tsx +117 -0
  25. package/src/blocks/__tests__/link-block.test.tsx +184 -0
  26. package/src/blocks/__tests__/map-block.test.tsx +165 -0
  27. package/src/blocks/__tests__/slider-block.test.tsx +148 -0
  28. package/src/blocks/__tests__/text-block.test.tsx +87 -0
  29. package/src/blocks/__tests__/trivial-blocks.test.tsx +59 -0
  30. package/src/blocks/__tests__/video-block.test.tsx +196 -0
  31. package/src/blocks/counter-block.tsx +1 -1
  32. package/src/blocks/group-block.tsx +1 -1
  33. package/src/blocks/icon-block.tsx +11 -6
  34. package/src/blocks/video-block.tsx +1 -1
  35. package/src/sections/__tests__/before-after-section.test.tsx +139 -0
  36. package/src/sections/__tests__/countdown-campaign-banner-section.test.tsx +188 -0
  37. package/src/sections/__tests__/coupon-banner-section.test.tsx +188 -0
  38. package/src/sections/__tests__/divider-section.test.tsx +161 -0
  39. package/src/sections/__tests__/hover-showcase-section.test.tsx +193 -0
  40. package/src/sections/__tests__/image-hotspot-section.test.tsx +110 -0
  41. package/src/sections/__tests__/notification-banner-section.test.tsx +144 -0
  42. package/src/sections/__tests__/section-wrapper.test.tsx +164 -0
  43. package/src/sections/__tests__/stats-counter-section.test.tsx +245 -0
  44. package/src/sections/__tests__/tabs-section.test.tsx +115 -0
  45. package/src/sections/coupon-banner-section.tsx +1 -4
  46. package/src/theme-block.tsx +3 -1
@@ -0,0 +1,188 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+
4
+ // Mock the child block renderer so we can observe which blocks land in which
5
+ // role-slot and inspect the (possibly transformed) value handed to it.
6
+ // The default-export shape is what CouponBannerSection imports.
7
+ jest.mock('../../theme-block', () => ({
8
+ __esModule: true,
9
+ default: ({ block }: { block: { id: string; value?: unknown } }) => (
10
+ <div data-testid="tb" data-id={block.id}>
11
+ {typeof block.value === 'string' ? block.value : ''}
12
+ </div>
13
+ )
14
+ }));
15
+
16
+ import CouponBannerSection from '../coupon-banner-section';
17
+
18
+ type AnyBlock = Record<string, unknown>;
19
+
20
+ const makeBlock = (overrides: AnyBlock): AnyBlock => ({
21
+ id: 'block',
22
+ type: 'text',
23
+ label: 'Block',
24
+ order: 0,
25
+ hidden: false,
26
+ properties: {},
27
+ styles: {},
28
+ ...overrides
29
+ });
30
+
31
+ const makeSection = (overrides: AnyBlock = {}): AnyBlock => ({
32
+ id: 'sec',
33
+ type: 'coupon-banner',
34
+ blocks: [],
35
+ properties: {},
36
+ styles: {},
37
+ ...overrides
38
+ });
39
+
40
+ const renderSection = (section: AnyBlock, props: AnyBlock = {}) =>
41
+ render(
42
+ <CouponBannerSection
43
+ section={section as never}
44
+ currentBreakpoint="desktop"
45
+ {...(props as never)}
46
+ />
47
+ );
48
+
49
+ describe('CouponBannerSection — role resolution', () => {
50
+ it('routes a couponBannerRole=coupon-label block to the label slot and drops the default fallback', () => {
51
+ const section = makeSection({
52
+ blocks: [
53
+ makeBlock({ id: 'content-1', type: 'text', order: 1 }),
54
+ makeBlock({
55
+ id: 'label-1',
56
+ type: 'text',
57
+ order: 2,
58
+ properties: { couponBannerRole: 'coupon-label' }
59
+ })
60
+ ]
61
+ });
62
+
63
+ renderSection(section);
64
+
65
+ const ids = screen.getAllByTestId('tb').map((el) => el.getAttribute('data-id'));
66
+ // label block rendered through ThemeBlock (role slot) ...
67
+ expect(ids).toContain('label-1');
68
+ // ... and the plain content block kept in the content column.
69
+ expect(ids).toContain('content-1');
70
+ // The hard-coded "Coupon code" fallback is suppressed when a label block exists.
71
+ expect(screen.queryByText('Coupon code')).not.toBeInTheDocument();
72
+ });
73
+
74
+ it('renders the default "Coupon code" fallback when no coupon-label role block is present', () => {
75
+ const section = makeSection({
76
+ blocks: [makeBlock({ id: 'content-1', type: 'text', order: 1 })]
77
+ });
78
+
79
+ renderSection(section);
80
+
81
+ expect(screen.getByText('Coupon code')).toBeInTheDocument();
82
+ });
83
+
84
+ it('resolves role from a responsive (per-breakpoint) couponBannerRole value', () => {
85
+ const section = makeSection({
86
+ blocks: [
87
+ makeBlock({
88
+ id: 'note-1',
89
+ type: 'text',
90
+ order: 1,
91
+ // responsive object resolved via getResponsiveValue
92
+ properties: { couponBannerRole: { desktop: 'coupon-note-text' } },
93
+ value: 'plain note'
94
+ })
95
+ ]
96
+ });
97
+
98
+ renderSection(section);
99
+
100
+ // A note-role block is rendered through ThemeBlock with an escaped HTML value,
101
+ // so its data-id must be present (it is NOT treated as a generic content block).
102
+ const note = screen
103
+ .getAllByTestId('tb')
104
+ .find((el) => el.getAttribute('data-id') === 'note-1');
105
+ expect(note).toBeTruthy();
106
+ });
107
+ });
108
+
109
+ describe('CouponBannerSection — border-radius is respected (BUG-6 fix)', () => {
110
+ const getBanner = () =>
111
+ document.querySelector('.coupon-banner') as HTMLElement;
112
+
113
+ it('keeps a 12px border-radius (previously collapsed to 0px)', () => {
114
+ renderSection(makeSection({ styles: { 'border-radius': '12px' } }));
115
+ expect(getBanner().style.borderRadius).toBe('12px');
116
+ });
117
+
118
+ it('keeps a non-12 border-radius untouched (8px control case)', () => {
119
+ renderSection(makeSection({ styles: { 'border-radius': '8px' } }));
120
+ expect(getBanner().style.borderRadius).toBe('8px');
121
+ });
122
+
123
+ it('keeps a 12rem border-radius (previously collapsed to 0px)', () => {
124
+ renderSection(makeSection({ styles: { 'border-radius': '12rem' } }));
125
+ expect(getBanner().style.borderRadius).toBe('12rem');
126
+ });
127
+ });
128
+
129
+ describe('CouponBannerSection — escapeHtml on injected coupon text', () => {
130
+ it('escapes HTML in the coupon code before handing it to the code-text block (no raw tags)', () => {
131
+ const section = makeSection({
132
+ properties: {
133
+ 'coupon-code': '<img src=x onerror="alert(1)">'
134
+ },
135
+ blocks: [
136
+ makeBlock({
137
+ id: 'codetext',
138
+ type: 'text',
139
+ order: 1,
140
+ properties: { couponBannerRole: 'coupon-code-text' }
141
+ })
142
+ ]
143
+ });
144
+
145
+ const { container } = renderSection(section);
146
+
147
+ const codeText = screen
148
+ .getAllByTestId('tb')
149
+ .find((el) => el.getAttribute('data-id') === 'codetext') as HTMLElement;
150
+
151
+ // No raw element was injected anywhere (escaped, not parsed as HTML).
152
+ expect(container.querySelector('img')).toBeNull();
153
+ // The dangerous markup survives only in escaped form, wrapped in <p>.
154
+ expect(codeText.textContent).toContain('&lt;img');
155
+ expect(codeText.textContent).toContain('&gt;');
156
+ expect(codeText.textContent).toContain('&quot;');
157
+ // The literal opening tag must NOT appear unescaped.
158
+ expect(codeText.textContent).not.toContain('<img');
159
+ });
160
+ });
161
+
162
+ describe('CouponBannerSection — copy button', () => {
163
+ const originalClipboard = navigator.clipboard;
164
+
165
+ afterEach(() => {
166
+ Object.defineProperty(navigator, 'clipboard', {
167
+ value: originalClipboard,
168
+ configurable: true
169
+ });
170
+ });
171
+
172
+ it('copies the coupon code to the clipboard and swaps the button label', async () => {
173
+ const writeText = jest.fn().mockResolvedValue(undefined);
174
+ Object.defineProperty(navigator, 'clipboard', {
175
+ value: { writeText },
176
+ configurable: true
177
+ });
178
+
179
+ renderSection(makeSection());
180
+
181
+ const button = screen.getByRole('button', { name: 'Copy Code' });
182
+ fireEvent.click(button);
183
+
184
+ // default coupon code is SAVE20
185
+ await screen.findByText('Copied');
186
+ expect(writeText).toHaveBeenCalledWith('SAVE20');
187
+ });
188
+ });
@@ -0,0 +1,161 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import DividerSection from '../divider-section';
4
+ import { ThemeSettingsProvider } from '../../theme-settings-context';
5
+
6
+ // DividerSection renders a <div> container with a single <span /> divider.
7
+ // It uses the REAL ../utils (getResponsiveValue / resolveThemeCssVariables).
8
+ // No ThemeBlock / ThemeSection child rendering happens, so nothing to mock there.
9
+
10
+ const renderDivider = (section: any, props: any = {}, themeSettings: any = null) =>
11
+ render(
12
+ <ThemeSettingsProvider value={themeSettings}>
13
+ <DividerSection section={section} {...props} />
14
+ </ThemeSettingsProvider>
15
+ );
16
+
17
+ const getSpan = (container: HTMLElement) =>
18
+ container.querySelector('span') as HTMLSpanElement;
19
+
20
+ describe('DividerSection', () => {
21
+ describe('default styles', () => {
22
+ it('renders a div containing a single block span', () => {
23
+ const { container } = renderDivider({ id: 's1', styles: {} });
24
+ const div = container.querySelector('div') as HTMLDivElement;
25
+ const span = getSpan(container);
26
+ expect(div).toBeInTheDocument();
27
+ expect(span).toBeInTheDocument();
28
+ expect(span.style.display).toBe('block');
29
+ expect(div.style.position).toBe('relative');
30
+ });
31
+
32
+ it('defaults height to 1px when not provided', () => {
33
+ const { container } = renderDivider({ id: 's2', styles: {} });
34
+ expect(getSpan(container).style.height).toBe('1px');
35
+ });
36
+
37
+ it('defaults backgroundColor to #e0e0e0 when not provided', () => {
38
+ const { container } = renderDivider({ id: 's3', styles: {} });
39
+ expect(getSpan(container).style.backgroundColor).toBe('rgb(224, 224, 224)');
40
+ });
41
+
42
+ it('defaults width to 100% when not provided', () => {
43
+ const { container } = renderDivider({ id: 's4', styles: {} });
44
+ expect(getSpan(container).style.width).toBe('100%');
45
+ });
46
+
47
+ it('uses defaults when section.styles is missing entirely', () => {
48
+ const { container } = renderDivider({ id: 's5' });
49
+ const span = getSpan(container);
50
+ expect(span.style.height).toBe('1px');
51
+ expect(span.style.backgroundColor).toBe('rgb(224, 224, 224)');
52
+ expect(span.style.width).toBe('100%');
53
+ });
54
+ });
55
+
56
+ describe('explicit (non-responsive) styles', () => {
57
+ it('applies plain scalar height / background-color / width values', () => {
58
+ const { container } = renderDivider({
59
+ id: 's6',
60
+ styles: {
61
+ height: '4px',
62
+ 'background-color': '#ff0000',
63
+ width: '50%'
64
+ }
65
+ });
66
+ const span = getSpan(container);
67
+ expect(span.style.height).toBe('4px');
68
+ expect(span.style.backgroundColor).toBe('rgb(255, 0, 0)');
69
+ expect(span.style.width).toBe('50%');
70
+ });
71
+
72
+ it('applies container padding from section styles', () => {
73
+ const { container } = renderDivider({
74
+ id: 's7',
75
+ styles: {
76
+ 'padding-top': '10px',
77
+ 'padding-right': '20px',
78
+ 'padding-bottom': '30px',
79
+ 'padding-left': '40px'
80
+ }
81
+ });
82
+ const div = container.querySelector('div') as HTMLDivElement;
83
+ expect(div.style.paddingTop).toBe('10px');
84
+ expect(div.style.paddingRight).toBe('20px');
85
+ expect(div.style.paddingBottom).toBe('30px');
86
+ expect(div.style.paddingLeft).toBe('40px');
87
+ });
88
+ });
89
+
90
+ describe('resolveThemeCssVariables', () => {
91
+ it('resolves a theme CSS variable to the mapped theme setting color', () => {
92
+ const { container } = renderDivider(
93
+ { id: 's8', styles: { 'background-color': 'var(--theme-primary)' } },
94
+ {},
95
+ { primaryColor: '#123456' }
96
+ );
97
+ expect(getSpan(container).style.backgroundColor).toBe('rgb(18, 52, 86)');
98
+ });
99
+
100
+ it('falls back to the var() fallback value when no theme setting matches', () => {
101
+ const { container } = renderDivider(
102
+ { id: 's9', styles: { 'background-color': 'var(--unknown-var, #00ff00)' } },
103
+ {},
104
+ {}
105
+ );
106
+ expect(getSpan(container).style.backgroundColor).toBe('rgb(0, 255, 0)');
107
+ });
108
+
109
+ it('leaves a non-variable color untouched', () => {
110
+ const { container } = renderDivider(
111
+ { id: 's10', styles: { 'background-color': '#abcdef' } },
112
+ {},
113
+ { primaryColor: '#123456' }
114
+ );
115
+ expect(getSpan(container).style.backgroundColor).toBe('rgb(171, 205, 239)');
116
+ });
117
+ });
118
+
119
+ describe('responsive height', () => {
120
+ it('picks the breakpoint-specific height when currentBreakpoint is mobile', () => {
121
+ const { container } = renderDivider(
122
+ {
123
+ id: 's11',
124
+ styles: { height: { desktop: '8px', mobile: '2px' } }
125
+ },
126
+ { currentBreakpoint: 'mobile' }
127
+ );
128
+ expect(getSpan(container).style.height).toBe('2px');
129
+ });
130
+
131
+ it('uses the desktop value when no entry exists for the breakpoint', () => {
132
+ const { container } = renderDivider(
133
+ {
134
+ id: 's12',
135
+ styles: { height: { desktop: '8px' } }
136
+ },
137
+ { currentBreakpoint: 'tablet' }
138
+ );
139
+ expect(getSpan(container).style.height).toBe('8px');
140
+ });
141
+
142
+ it('defaults to desktop breakpoint height when currentBreakpoint prop omitted', () => {
143
+ const { container } = renderDivider({
144
+ id: 's13',
145
+ styles: { height: { desktop: '6px', mobile: '2px' } }
146
+ });
147
+ expect(getSpan(container).style.height).toBe('6px');
148
+ });
149
+
150
+ it('falls back to 1px when responsive object has no matching or desktop key', () => {
151
+ const { container } = renderDivider(
152
+ {
153
+ id: 's14',
154
+ styles: { height: { mobile: '2px' } }
155
+ },
156
+ { currentBreakpoint: 'tablet' }
157
+ );
158
+ expect(getSpan(container).style.height).toBe('1px');
159
+ });
160
+ });
161
+ });
@@ -0,0 +1,193 @@
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 HoverShowcaseSection from '../hover-showcase-section';
13
+
14
+ const item = (
15
+ id: string,
16
+ title: string,
17
+ over: Record<string, unknown> = {}
18
+ ) => ({
19
+ id,
20
+ type: 'hover-showcase-item',
21
+ label: title,
22
+ value: '',
23
+ properties: { title },
24
+ styles: {},
25
+ order: 0,
26
+ hidden: false,
27
+ ...over
28
+ });
29
+
30
+ const section = (over: Record<string, unknown> = {}) =>
31
+ ({
32
+ id: 'sec',
33
+ type: 'hover-showcase',
34
+ label: 'sec',
35
+ styles: {},
36
+ blocks: [],
37
+ ...over
38
+ } as any);
39
+
40
+ const props = (over: Record<string, unknown> = {}) =>
41
+ ({
42
+ section: section(),
43
+ currentBreakpoint: 'desktop',
44
+ placeholderId: 'ph',
45
+ isDesigner: false,
46
+ selectedBlockId: null,
47
+ ...over
48
+ } as any);
49
+
50
+ const markers = (container: HTMLElement) =>
51
+ Array.from(container.querySelectorAll('[data-testid="tb"]'));
52
+
53
+ describe('HoverShowcaseSection — type filter', () => {
54
+ it('only renders blocks of type "hover-showcase-item" (ignores others)', () => {
55
+ const sec = section({
56
+ blocks: [
57
+ item('a', 'Alpha'),
58
+ { id: 'x', type: 'text', label: 'Gamma', properties: { title: 'Gamma' }, styles: {} },
59
+ item('b', 'Beta')
60
+ ]
61
+ });
62
+ render(<HoverShowcaseSection {...props({ section: sec, isDesigner: true })} />);
63
+
64
+ expect(screen.getByText('Alpha')).toBeInTheDocument();
65
+ expect(screen.getByText('Beta')).toBeInTheDocument();
66
+ expect(screen.queryByText('Gamma')).not.toBeInTheDocument();
67
+ });
68
+ });
69
+
70
+ describe('HoverShowcaseSection — empty state', () => {
71
+ it('shows the designer hint when there are no item blocks and isDesigner', () => {
72
+ const { container } = render(
73
+ <HoverShowcaseSection {...props({ isDesigner: true })} />
74
+ );
75
+ expect(container.textContent).toContain(
76
+ 'Add Hover Showcase Item blocks to see them here.'
77
+ );
78
+ });
79
+
80
+ it('renders nothing (null) for non-designer when empty', () => {
81
+ const { container } = render(
82
+ <HoverShowcaseSection {...props({ isDesigner: false })} />
83
+ );
84
+ expect(container).toBeEmptyDOMElement();
85
+ });
86
+
87
+ it('ignores blocks that are not items (empty -> hint) in designer', () => {
88
+ const sec = section({
89
+ blocks: [{ id: 'x', type: 'text', label: 'X', properties: {}, styles: {} }]
90
+ });
91
+ const { container } = render(
92
+ <HoverShowcaseSection {...props({ section: sec, isDesigner: true })} />
93
+ );
94
+ expect(container.textContent).toContain(
95
+ 'Add Hover Showcase Item blocks to see them here.'
96
+ );
97
+ });
98
+ });
99
+
100
+ describe('HoverShowcaseSection — active item (desktop mouseEnter)', () => {
101
+ it('first item is active by default; mouseEnter switches the active item', () => {
102
+ const sec = section({ blocks: [item('a', 'Alpha'), item('b', 'Beta')] });
103
+ render(
104
+ <HoverShowcaseSection
105
+ {...props({ section: sec, isDesigner: true, currentBreakpoint: 'desktop' })}
106
+ />
107
+ );
108
+
109
+ const alpha = screen.getByText('Alpha') as HTMLElement;
110
+ const beta = screen.getByText('Beta') as HTMLElement;
111
+
112
+ // Index 0 (Alpha) is active by default.
113
+ const activeColor = alpha.style.color;
114
+ expect(beta.style.color).not.toBe(activeColor);
115
+
116
+ // The onMouseEnter handler sits on the h3's parent wrapper div.
117
+ fireEvent.mouseEnter(beta.parentElement as HTMLElement);
118
+
119
+ expect(beta.style.color).toBe(activeColor);
120
+ expect(alpha.style.color).not.toBe(activeColor);
121
+ });
122
+ });
123
+
124
+ describe('HoverShowcaseSection — active item (mobile click)', () => {
125
+ it('click on an item activates it (accordion opens)', () => {
126
+ const sec = section({
127
+ styles: { 'mobile-layout': 'accordion' },
128
+ blocks: [
129
+ item('a', 'Alpha', { blocks: [{ id: 'ca', type: 'text' }] }),
130
+ item('b', 'Beta', { blocks: [{ id: 'cb', type: 'text' }] })
131
+ ]
132
+ });
133
+ const { container } = render(
134
+ <HoverShowcaseSection
135
+ {...props({ section: sec, isDesigner: true, currentBreakpoint: 'mobile' })}
136
+ />
137
+ );
138
+
139
+ const accordionOf = (childId: string) =>
140
+ container
141
+ .querySelector(`[data-id="${childId}"]`)!
142
+ .closest('.overflow-hidden') as HTMLElement;
143
+
144
+ // Index 0 (Alpha) active by default -> open; Beta closed.
145
+ expect(accordionOf('ca').className).toContain('max-h-[600px]');
146
+ expect(accordionOf('cb').className).toContain('max-h-0');
147
+
148
+ // Click Beta's title wrapper (onClick handler).
149
+ fireEvent.click(screen.getByText('Beta').parentElement as HTMLElement);
150
+
151
+ expect(accordionOf('cb').className).toContain('max-h-[600px]');
152
+ expect(accordionOf('ca').className).toContain('max-h-0');
153
+ });
154
+ });
155
+
156
+ describe('HoverShowcaseSection — max-width class', () => {
157
+ const outer = (container: HTMLElement) => container.firstChild as HTMLElement;
158
+
159
+ it('uses max-w-4xl for narrow max-width', () => {
160
+ const sec = section({
161
+ styles: { 'max-width': 'narrow' },
162
+ blocks: [item('a', 'Alpha')]
163
+ });
164
+ const { container } = render(
165
+ <HoverShowcaseSection {...props({ section: sec, isDesigner: true })} />
166
+ );
167
+ expect(outer(container).className).toContain('max-w-4xl');
168
+ });
169
+
170
+ it('uses max-w-7xl for normal max-width (default)', () => {
171
+ const sec = section({
172
+ styles: { 'max-width': 'normal' },
173
+ blocks: [item('a', 'Alpha')]
174
+ });
175
+ const { container } = render(
176
+ <HoverShowcaseSection {...props({ section: sec, isDesigner: true })} />
177
+ );
178
+ expect(outer(container).className).toContain('max-w-7xl');
179
+ });
180
+
181
+ it('applies no max-width class for "full"', () => {
182
+ const sec = section({
183
+ styles: { 'max-width': 'full' },
184
+ blocks: [item('a', 'Alpha')]
185
+ });
186
+ const { container } = render(
187
+ <HoverShowcaseSection {...props({ section: sec, isDesigner: true })} />
188
+ );
189
+ const cls = outer(container).className;
190
+ expect(cls).not.toContain('max-w-4xl');
191
+ expect(cls).not.toContain('max-w-7xl');
192
+ });
193
+ });
@@ -0,0 +1,110 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+
4
+ // Mock ThemeBlock (default export) so we only assert this section's own logic.
5
+ jest.mock('../../theme-block', () => ({
6
+ __esModule: true,
7
+ default: ({ block }: { block: { id: string; type: string } }) => (
8
+ <div data-testid="tb" data-id={block.id} data-type={block.type} />
9
+ )
10
+ }));
11
+
12
+ import ImageHotspotSection from '../image-hotspot-section';
13
+ import type { Section } from '../../theme-section';
14
+
15
+ const makeSection = (overrides: Partial<Section> = {}): Section =>
16
+ ({
17
+ id: 'sec-1',
18
+ type: 'image-hotspot',
19
+ blocks: [],
20
+ styles: {},
21
+ properties: {},
22
+ ...overrides
23
+ } as unknown as Section);
24
+
25
+ describe('ImageHotspotSection', () => {
26
+ it('renders the first image block and all hotspot blocks via ThemeBlock', () => {
27
+ const section = makeSection({
28
+ blocks: [
29
+ { id: 'img-1', type: 'image' },
30
+ { id: 'img-2', type: 'image' },
31
+ { id: 'hs-1', type: 'hotspot' },
32
+ { id: 'hs-2', type: 'hotspot' }
33
+ ] as unknown as Section['blocks']
34
+ });
35
+
36
+ render(<ImageHotspotSection section={section} />);
37
+
38
+ const blocks = screen.getAllByTestId('tb');
39
+ // first image block + 2 hotspot blocks = 3 (img-2 ignored, only find first image)
40
+ expect(blocks).toHaveLength(3);
41
+
42
+ const ids = blocks.map(b => b.getAttribute('data-id'));
43
+ expect(ids).toEqual(['img-1', 'hs-1', 'hs-2']);
44
+
45
+ expect(screen.queryByText('No Image Selected')).not.toBeInTheDocument();
46
+ });
47
+
48
+ it('shows "No Image Selected" placeholder when there is no image block', () => {
49
+ const section = makeSection({
50
+ blocks: [{ id: 'hs-1', type: 'hotspot' }] as unknown as Section['blocks']
51
+ });
52
+
53
+ render(<ImageHotspotSection section={section} />);
54
+
55
+ expect(screen.getByText('No Image Selected')).toBeInTheDocument();
56
+ // only the hotspot ThemeBlock should render
57
+ const blocks = screen.getAllByTestId('tb');
58
+ expect(blocks).toHaveLength(1);
59
+ expect(blocks[0].getAttribute('data-id')).toBe('hs-1');
60
+ });
61
+
62
+ describe('max-width matrix', () => {
63
+ it('full -> w-full class, no inline maxWidth', () => {
64
+ const section = makeSection({
65
+ styles: { 'max-width': 'full' } as unknown as Section['styles']
66
+ });
67
+ const { container } = render(<ImageHotspotSection section={section} />);
68
+ const root = container.firstChild as HTMLElement;
69
+
70
+ expect(root).toHaveClass('w-full');
71
+ expect(root.style.maxWidth).toBe('');
72
+ // hasMaxWidth is false for full
73
+ expect(root).not.toHaveClass('max-w-4xl');
74
+ expect(root).not.toHaveClass('max-w-7xl');
75
+ });
76
+
77
+ it('none -> inline maxWidth "none", no max-width class', () => {
78
+ const section = makeSection({
79
+ styles: { 'max-width': 'none' } as unknown as Section['styles']
80
+ });
81
+ const { container } = render(<ImageHotspotSection section={section} />);
82
+ const root = container.firstChild as HTMLElement;
83
+
84
+ expect(root.style.maxWidth).toBe('none');
85
+ expect(root).not.toHaveClass('w-full');
86
+ expect(root).not.toHaveClass('max-w-4xl');
87
+ expect(root).not.toHaveClass('max-w-7xl');
88
+ });
89
+
90
+ it('narrow -> max-w-4xl', () => {
91
+ const section = makeSection({
92
+ styles: { 'max-width': 'narrow' } as unknown as Section['styles']
93
+ });
94
+ const { container } = render(<ImageHotspotSection section={section} />);
95
+ const root = container.firstChild as HTMLElement;
96
+
97
+ expect(root).toHaveClass('max-w-4xl');
98
+ expect(root.style.maxWidth).toBe('');
99
+ });
100
+
101
+ it('default (no max-width) -> normal -> max-w-7xl', () => {
102
+ const section = makeSection();
103
+ const { container } = render(<ImageHotspotSection section={section} />);
104
+ const root = container.firstChild as HTMLElement;
105
+
106
+ expect(root).toHaveClass('max-w-7xl');
107
+ expect(root.style.maxWidth).toBe('');
108
+ });
109
+ });
110
+ });