@akinon/pz-theme 2.0.30-beta.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +6 -3
  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,117 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+
4
+ // Mock nested ThemeBlock (default export) so designer-mode rendering is isolated.
5
+ jest.mock('../../theme-block', () => ({
6
+ __esModule: true,
7
+ default: ({ block }: any) => <div data-testid="tb" data-id={block.id} />
8
+ }));
9
+
10
+ import ImageGalleryBlock from '../image-gallery-block';
11
+
12
+ const makeImage = (id: string, value: string, extra: any = {}) => ({
13
+ id,
14
+ type: 'image',
15
+ value,
16
+ ...extra
17
+ });
18
+
19
+ const baseProps = {
20
+ placeholderId: 'ph-1',
21
+ sectionId: 'sec-1',
22
+ isDesigner: false,
23
+ selectedBlockId: null,
24
+ currentBreakpoint: 'desktop'
25
+ } as any;
26
+
27
+ describe('ImageGalleryBlock', () => {
28
+ it('renders a thumbnail button per image child, ordered and filtered by type', () => {
29
+ const block = {
30
+ id: 'gallery-1',
31
+ blocks: [
32
+ makeImage('img-b', 'https://cdn/b.jpg', { order: 2 }),
33
+ makeImage('img-a', 'https://cdn/a.jpg', { order: 1 }),
34
+ { id: 'text-1', type: 'text', value: 'ignored', order: 0 }
35
+ ]
36
+ };
37
+
38
+ const { container } = render(
39
+ <ImageGalleryBlock {...baseProps} block={block} />
40
+ );
41
+
42
+ const buttons = screen.getAllByRole('button');
43
+ expect(buttons).toHaveLength(2);
44
+
45
+ // Sorted by order: a (order 1) before b (order 2).
46
+ // alt is empty -> imgs carry the "presentation" role, so query the DOM directly.
47
+ const imgs = Array.from(container.querySelectorAll('img'));
48
+ expect(imgs).toHaveLength(2);
49
+ expect(imgs[0]).toHaveAttribute('src', 'https://cdn/a.jpg');
50
+ expect(imgs[1]).toHaveAttribute('src', 'https://cdn/b.jpg');
51
+
52
+ // Default aria-label when alt missing.
53
+ expect(
54
+ screen.getByRole('button', { name: 'Open image 1' })
55
+ ).toBeInTheDocument();
56
+ });
57
+
58
+ it('clicking a thumbnail opens the (mocked) Modal showing the active image', () => {
59
+ const block = {
60
+ id: 'gallery-1',
61
+ blocks: [
62
+ makeImage('img-a', 'https://cdn/a.jpg', {
63
+ order: 1,
64
+ properties: { alt: 'Alpha' }
65
+ }),
66
+ makeImage('img-b', 'https://cdn/b.jpg', {
67
+ order: 2,
68
+ properties: { alt: 'Bravo' }
69
+ })
70
+ ]
71
+ };
72
+
73
+ render(<ImageGalleryBlock {...baseProps} block={block} />);
74
+
75
+ // Modal closed -> Prev/Next controls not present, only the thumbnail img for Bravo.
76
+ expect(screen.queryByRole('button', { name: 'Next' })).toBeNull();
77
+ expect(screen.getAllByAltText('Bravo')).toHaveLength(1);
78
+
79
+ fireEvent.click(screen.getByRole('button', { name: 'Bravo' }));
80
+
81
+ // Modal mock renders children when open -> nav buttons appear.
82
+ expect(
83
+ screen.getByRole('button', { name: 'Previous' })
84
+ ).toBeInTheDocument();
85
+ expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument();
86
+
87
+ // Active image (Bravo) is now also rendered inside the modal -> two matches.
88
+ expect(screen.getAllByAltText('Bravo')).toHaveLength(2);
89
+ });
90
+
91
+ it('renders empty-state message when there are no image children', () => {
92
+ const block = { id: 'gallery-empty', blocks: [] };
93
+ render(<ImageGalleryBlock {...baseProps} block={block} />);
94
+ expect(screen.getByText('No images uploaded')).toBeInTheDocument();
95
+ expect(screen.queryAllByRole('button')).toHaveLength(0);
96
+ });
97
+
98
+ it('in designer mode renders nested ThemeBlock for each child instead of thumbnails', () => {
99
+ const block = {
100
+ id: 'gallery-1',
101
+ blocks: [
102
+ makeImage('img-a', 'https://cdn/a.jpg', { order: 1 }),
103
+ makeImage('img-b', 'https://cdn/b.jpg', { order: 2 })
104
+ ]
105
+ };
106
+
107
+ render(
108
+ <ImageGalleryBlock {...baseProps} block={block} isDesigner={true} />
109
+ );
110
+
111
+ const tbs = screen.getAllByTestId('tb');
112
+ expect(tbs).toHaveLength(2);
113
+ expect(tbs[0]).toHaveAttribute('data-id', 'img-a');
114
+ // No interactive thumbnail buttons in designer mode.
115
+ expect(screen.queryByRole('button')).toBeNull();
116
+ });
117
+ });
@@ -0,0 +1,184 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+
4
+ // Recursive child renderer — stub the default export so we can observe which
5
+ // child blocks LinkBlock forwards, in what order.
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
+ // Override the hooks mock locally so per-test localization values can drive the
14
+ // locale-prefix branches in formattedHref. urlSchemes / urlLocaleMatcherRegex /
15
+ // LocaleUrlStrategy keep coming from the global moduleNameMapper mocks.
16
+ const DEFAULT_LOCALIZATION = {
17
+ t: (p: string) => p,
18
+ setLocale: () => {},
19
+ setCurrency: () => {},
20
+ locale: 'en-us',
21
+ currency: 'USD',
22
+ locales: [{ value: 'en-us' }, { value: 'tr-tr' }],
23
+ currencies: ['USD'],
24
+ defaultLocaleValue: 'en-us',
25
+ defaultCurrencyCode: 'USD',
26
+ localeUrlStrategy: 'hide-default-locale'
27
+ };
28
+ let localizationValue: Record<string, unknown> = { ...DEFAULT_LOCALIZATION };
29
+ jest.mock('@akinon/next/hooks', () => ({
30
+ __esModule: true,
31
+ useLocalization: () => localizationValue
32
+ }));
33
+
34
+ import LinkBlock from '../link-block';
35
+
36
+ const props = (over: Record<string, unknown> = {}) =>
37
+ ({
38
+ block: { id: 'lnk', type: 'link', label: 'lnk' },
39
+ placeholderId: 'p',
40
+ sectionId: 's',
41
+ isDesigner: false,
42
+ currentBreakpoint: 'desktop',
43
+ ...over
44
+ } as any);
45
+
46
+ const child = (over: Record<string, unknown> = {}) => ({
47
+ id: 'c',
48
+ type: 'text',
49
+ label: 'c',
50
+ ...over
51
+ });
52
+
53
+ beforeEach(() => {
54
+ localizationValue = { ...DEFAULT_LOCALIZATION };
55
+ });
56
+
57
+ const anchor = (container: HTMLElement) =>
58
+ container.querySelector('a') as HTMLAnchorElement;
59
+
60
+ describe('LinkBlock', () => {
61
+ it('renders an anchor (next/link -> <a>)', () => {
62
+ const { container } = render(<LinkBlock {...props()} />);
63
+ expect(anchor(container)).toBeInTheDocument();
64
+ });
65
+
66
+ it('renders the empty-block placeholder and defaults href to "#" when no blocks/href', () => {
67
+ const { container } = render(<LinkBlock {...props()} />);
68
+ expect(screen.getByText('Empty link block')).toBeInTheDocument();
69
+ expect(anchor(container)).toHaveAttribute('href', '#');
70
+ });
71
+
72
+ it('keeps absolute http(s) urls untouched (urlSchemes match)', () => {
73
+ const { container } = render(
74
+ <LinkBlock {...props({ block: { id: 'l', properties: { href: 'https://example.com/x' } } })} />
75
+ );
76
+ expect(anchor(container)).toHaveAttribute('href', 'https://example.com/x');
77
+ });
78
+
79
+ it('keeps mailto: scheme as-is', () => {
80
+ const { container } = render(
81
+ <LinkBlock {...props({ block: { id: 'l', properties: { href: 'mailto:a@b.com' } } })} />
82
+ );
83
+ expect(anchor(container)).toHaveAttribute('href', 'mailto:a@b.com');
84
+ });
85
+
86
+ it('falls back to "#" when an http-scheme href is not a valid URL', () => {
87
+ const { container } = render(
88
+ <LinkBlock {...props({ block: { id: 'l', properties: { href: 'http://' } } })} />
89
+ );
90
+ expect(anchor(container)).toHaveAttribute('href', '#');
91
+ });
92
+
93
+ it('leaves a default-locale relative href unprefixed (HideDefaultLocale, locale === default)', () => {
94
+ const { container } = render(
95
+ <LinkBlock {...props({ block: { id: 'l', properties: { href: '/foo' } } })} />
96
+ );
97
+ expect(anchor(container)).toHaveAttribute('href', '/foo');
98
+ });
99
+
100
+ it('prefixes the active locale for relative hrefs under ShowAllLocales', () => {
101
+ localizationValue.localeUrlStrategy = 'show-all-locales';
102
+ const { container } = render(
103
+ <LinkBlock {...props({ block: { id: 'l', properties: { href: '/foo' } } })} />
104
+ );
105
+ expect(anchor(container)).toHaveAttribute('href', '/en-us/foo');
106
+ });
107
+
108
+ it('strips an existing locale prefix via urlLocaleMatcherRegex before re-prefixing', () => {
109
+ localizationValue.localeUrlStrategy = 'show-all-locales';
110
+ const { container } = render(
111
+ <LinkBlock {...props({ block: { id: 'l', properties: { href: '/tr-tr/foo' } } })} />
112
+ );
113
+ expect(anchor(container)).toHaveAttribute('href', '/en-us/foo');
114
+ });
115
+
116
+ it('prefixes a non-default locale under HideDefaultLocale', () => {
117
+ localizationValue.locale = 'tr-tr';
118
+ const { container } = render(
119
+ <LinkBlock {...props({ block: { id: 'l', properties: { href: '/foo' } } })} />
120
+ );
121
+ expect(anchor(container)).toHaveAttribute('href', '/tr-tr/foo');
122
+ });
123
+
124
+ it('sets target/rel for _blank targets', () => {
125
+ const { container } = render(
126
+ <LinkBlock
127
+ {...props({
128
+ block: {
129
+ id: 'l',
130
+ properties: { href: 'https://x.com', target: '_blank' }
131
+ }
132
+ })}
133
+ />
134
+ );
135
+ const a = anchor(container);
136
+ expect(a).toHaveAttribute('target', '_blank');
137
+ expect(a).toHaveAttribute('rel', 'noopener noreferrer');
138
+ });
139
+
140
+ it('filters hidden children and renders the rest in order via ThemeBlock', () => {
141
+ const { container } = render(
142
+ <LinkBlock
143
+ {...props({
144
+ block: {
145
+ id: 'l',
146
+ properties: { href: '#' },
147
+ blocks: [
148
+ child({ id: 'b', order: 2 }),
149
+ child({ id: 'hid', order: 1, hidden: true }),
150
+ child({ id: 'a', order: 0 })
151
+ ]
152
+ }
153
+ })}
154
+ />
155
+ );
156
+ const ids = Array.from(container.querySelectorAll('[data-testid="tb"]')).map(
157
+ (el) => el.getAttribute('data-id')
158
+ );
159
+ expect(ids).toEqual(['a', 'b']);
160
+ });
161
+
162
+ it('keeps hidden children and neutralizes href to "#" in designer mode', () => {
163
+ const { container } = render(
164
+ <LinkBlock
165
+ {...props({
166
+ isDesigner: true,
167
+ block: {
168
+ id: 'l',
169
+ properties: { href: '/foo' },
170
+ blocks: [
171
+ child({ id: 'a', order: 0 }),
172
+ child({ id: 'hid', order: 1, hidden: true })
173
+ ]
174
+ }
175
+ })}
176
+ />
177
+ );
178
+ const ids = Array.from(container.querySelectorAll('[data-testid="tb"]')).map(
179
+ (el) => el.getAttribute('data-id')
180
+ );
181
+ expect(ids).toEqual(['a', 'hid']);
182
+ expect(anchor(container)).toHaveAttribute('href', '#');
183
+ });
184
+ });
@@ -0,0 +1,165 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import MapBlock from '../map-block';
4
+
5
+ const props = (over: Record<string, unknown> = {}) =>
6
+ ({
7
+ block: {
8
+ id: 'm',
9
+ type: 'map',
10
+ label: 'map',
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('MapBlock', () => {
24
+ it('renders the placeholder prompt when value is undefined', () => {
25
+ const { container } = render(
26
+ <MapBlock
27
+ {...props({
28
+ block: { id: 'm', type: 'map', value: undefined, properties: {} }
29
+ })}
30
+ />
31
+ );
32
+
33
+ expect(
34
+ screen.getByText('Enter an address to display the map')
35
+ ).toBeInTheDocument();
36
+ // No iframe rendered in the placeholder state.
37
+ expect(container.querySelector('iframe')).toBeNull();
38
+ });
39
+
40
+ it('renders the placeholder prompt when value is an empty string', () => {
41
+ render(
42
+ <MapBlock {...props({ block: { id: 'm', type: 'map', value: '', properties: {} } })} />
43
+ );
44
+
45
+ expect(
46
+ screen.getByText('Enter an address to display the map')
47
+ ).toBeInTheDocument();
48
+ });
49
+
50
+ it('resolves the localized value, preferring en over tr', () => {
51
+ const { container } = render(
52
+ <MapBlock
53
+ {...props({
54
+ block: {
55
+ id: 'm',
56
+ type: 'map',
57
+ value: { tr: 'Istanbul', en: 'London' },
58
+ properties: {}
59
+ }
60
+ })}
61
+ />
62
+ );
63
+
64
+ const iframe = container.querySelector('iframe') as HTMLIFrameElement;
65
+ expect(iframe).not.toBeNull();
66
+ // en wins over tr.
67
+ expect(iframe.getAttribute('src')).toContain('q=London');
68
+ expect(iframe.getAttribute('src')).not.toContain('Istanbul');
69
+ });
70
+
71
+ it('falls back to the tr value when en is absent', () => {
72
+ const { container } = render(
73
+ <MapBlock
74
+ {...props({
75
+ block: {
76
+ id: 'm',
77
+ type: 'map',
78
+ value: { tr: 'Ankara' },
79
+ properties: {}
80
+ }
81
+ })}
82
+ />
83
+ );
84
+
85
+ const iframe = container.querySelector('iframe') as HTMLIFrameElement;
86
+ expect(iframe.getAttribute('src')).toContain('q=Ankara');
87
+ });
88
+
89
+ it('renders an iframe with a default zoom of 14 and roadmap map type (t=m) for a valid address', () => {
90
+ const { container } = render(
91
+ <MapBlock
92
+ {...props({
93
+ block: {
94
+ id: 'm',
95
+ type: 'map',
96
+ value: 'Eiffel Tower, Paris',
97
+ properties: {}
98
+ }
99
+ })}
100
+ />
101
+ );
102
+
103
+ const iframe = container.querySelector('iframe') as HTMLIFrameElement;
104
+ expect(iframe).not.toBeNull();
105
+ expect(iframe).toHaveAttribute('title', 'Google Maps');
106
+ expect(iframe).toHaveAttribute('loading', 'lazy');
107
+
108
+ const src = iframe.getAttribute('src') || '';
109
+ expect(src).toContain('https://maps.google.com/maps?');
110
+ expect(src).toContain('q=Eiffel%20Tower%2C%20Paris');
111
+ // Default zoom resolved by getResponsiveValue fallback.
112
+ expect(src).toContain('z=14');
113
+ // roadmap (default) maps to t=m in getMapEmbedUrl.
114
+ expect(src).toContain('t=m');
115
+ expect(src).toContain('output=embed');
116
+ });
117
+
118
+ it('maps the satellite map type to t=k and honors a custom zoom', () => {
119
+ const { container } = render(
120
+ <MapBlock
121
+ {...props({
122
+ block: {
123
+ id: 'm',
124
+ type: 'map',
125
+ value: 'Tokyo',
126
+ properties: { zoom: 8, mapType: 'satellite' }
127
+ }
128
+ })}
129
+ />
130
+ );
131
+
132
+ const src =
133
+ (container.querySelector('iframe') as HTMLIFrameElement).getAttribute(
134
+ 'src'
135
+ ) || '';
136
+ expect(src).toContain('z=8');
137
+ expect(src).toContain('t=k');
138
+ });
139
+
140
+ it('resolves zoom and mapType from the current breakpoint', () => {
141
+ const { container } = render(
142
+ <MapBlock
143
+ {...props({
144
+ block: {
145
+ id: 'm',
146
+ type: 'map',
147
+ value: 'Berlin',
148
+ properties: {
149
+ zoom: { desktop: 10, mobile: 18 },
150
+ mapType: { desktop: 'roadmap', mobile: 'satellite' }
151
+ }
152
+ },
153
+ currentBreakpoint: 'mobile'
154
+ })}
155
+ />
156
+ );
157
+
158
+ const src =
159
+ (container.querySelector('iframe') as HTMLIFrameElement).getAttribute(
160
+ 'src'
161
+ ) || '';
162
+ expect(src).toContain('z=18');
163
+ expect(src).toContain('t=k');
164
+ });
165
+ });
@@ -0,0 +1,148 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ // react-multi-carousel is an ESM/DOM-heavy dependency. Replace its default
5
+ // export with a passthrough that simply renders its children so we can assert
6
+ // on the slides the SliderBlock produces.
7
+ jest.mock('react-multi-carousel', () => ({
8
+ __esModule: true,
9
+ default: ({ children }: { children: React.ReactNode }) => (
10
+ <div data-testid="carousel">{children}</div>
11
+ )
12
+ }));
13
+
14
+ // Stub the recursive ThemeBlock (default export) with a countable marker so we
15
+ // can verify which child blocks become slides (and in what order).
16
+ jest.mock('../../theme-block', () => ({
17
+ __esModule: true,
18
+ default: ({ block }: { block: { id: string } }) => (
19
+ <div data-testid="tb" data-id={block.id} />
20
+ )
21
+ }));
22
+
23
+ import SliderBlock from '../slider-block';
24
+
25
+ const child = (
26
+ id: string,
27
+ over: Record<string, unknown> = {}
28
+ ): any => ({
29
+ id,
30
+ type: 'text',
31
+ label: id,
32
+ value: '',
33
+ properties: {},
34
+ styles: {},
35
+ order: 0,
36
+ hidden: false,
37
+ ...over
38
+ });
39
+
40
+ const props = (over: Record<string, unknown> = {}) =>
41
+ ({
42
+ block: {
43
+ id: 'slider',
44
+ type: 'slider',
45
+ label: 'slider',
46
+ value: '',
47
+ properties: {},
48
+ styles: {},
49
+ order: 0,
50
+ hidden: false,
51
+ blocks: []
52
+ },
53
+ placeholderId: 'p',
54
+ sectionId: 's',
55
+ isDesigner: false,
56
+ ...over
57
+ } as any);
58
+
59
+ const markers = (container: HTMLElement) =>
60
+ Array.from(container.querySelectorAll('[data-testid="tb"]')).map((el) =>
61
+ el.getAttribute('data-id')
62
+ );
63
+
64
+ describe('SliderBlock — child filtering, ordering and empty state', () => {
65
+ it('renders "No items available" when there are no slide children', () => {
66
+ const { container, getByText } = render(<SliderBlock {...props()} />);
67
+ expect(getByText('No items available')).toBeInTheDocument();
68
+ expect(container.querySelector('[data-testid="carousel"]')).toBeNull();
69
+ });
70
+
71
+ it('treats an all-icon block list as empty (icons are not slides)', () => {
72
+ const { getByText } = render(
73
+ <SliderBlock
74
+ {...props({
75
+ block: {
76
+ ...props().block,
77
+ blocks: [
78
+ child('left', { type: 'icon', label: 'Left Arrow Icon' }),
79
+ child('right', { type: 'icon', label: 'Right Arrow Icon' })
80
+ ]
81
+ }
82
+ })}
83
+ />
84
+ );
85
+ expect(getByText('No items available')).toBeInTheDocument();
86
+ });
87
+
88
+ it('excludes type==="icon" children from the rendered slides', () => {
89
+ const { container } = render(
90
+ <SliderBlock
91
+ {...props({
92
+ block: {
93
+ ...props().block,
94
+ blocks: [
95
+ child('left', { type: 'icon', label: 'Left Arrow Icon' }),
96
+ child('s1'),
97
+ child('right', { type: 'icon', label: 'Right Arrow Icon' }),
98
+ child('s2')
99
+ ]
100
+ }
101
+ })}
102
+ />
103
+ );
104
+ expect(markers(container)).toEqual(['s1', 's2']);
105
+ });
106
+
107
+ it('drops hidden children in production but keeps them in the designer', () => {
108
+ const blocks = [child('v1'), child('h1', { hidden: true })];
109
+
110
+ const prod = render(
111
+ <SliderBlock
112
+ {...props({
113
+ isDesigner: false,
114
+ block: { ...props().block, blocks }
115
+ })}
116
+ />
117
+ );
118
+ expect(markers(prod.container)).toEqual(['v1']);
119
+
120
+ const designer = render(
121
+ <SliderBlock
122
+ {...props({
123
+ isDesigner: true,
124
+ block: { ...props().block, blocks }
125
+ })}
126
+ />
127
+ );
128
+ expect(markers(designer.container)).toEqual(['v1', 'h1']);
129
+ });
130
+
131
+ it('orders slides by the numeric `order` field', () => {
132
+ const { container } = render(
133
+ <SliderBlock
134
+ {...props({
135
+ block: {
136
+ ...props().block,
137
+ blocks: [
138
+ child('b', { order: 2 }),
139
+ child('a', { order: 1 }),
140
+ child('c', { order: 3 })
141
+ ]
142
+ }
143
+ })}
144
+ />
145
+ );
146
+ expect(markers(container)).toEqual(['a', 'b', 'c']);
147
+ });
148
+ });
@@ -0,0 +1,87 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import TextBlock from '../text-block';
4
+
5
+ // The mocked useLocalization (jest/__mocks__/akinon-next-hooks.js) returns
6
+ // locale: 'en-us'. process.env.NEXT_PUBLIC_DEFAULT_LOCALE is unset in the
7
+ // jest env, so the component's defaultLocale falls back to 'en'.
8
+
9
+ const renderBlock = (value: unknown, styles?: Record<string, unknown>) => {
10
+ const block: any = { id: 'b1', type: 'text', value, styles };
11
+ return render(<TextBlock block={block} />);
12
+ };
13
+
14
+ describe('TextBlock value-shape resolution', () => {
15
+ it('parses a JSON-string value and renders the locale entry', () => {
16
+ const { container } = renderBlock(
17
+ '{"en-us":"<b>Hi</b>","tr-tr":"Merhaba"}'
18
+ );
19
+ const div = container.querySelector('div') as HTMLDivElement;
20
+ expect(div.innerHTML).toBe('<b>Hi</b>');
21
+ });
22
+
23
+ it('reads the active locale from a localized object value', () => {
24
+ const { container } = renderBlock({ 'en-us': 'A', 'tr-tr': 'B' });
25
+ expect((container.querySelector('div') as HTMLDivElement).innerHTML).toBe(
26
+ 'A'
27
+ );
28
+ });
29
+
30
+ it('falls back to defaultLocale ("en") when active locale is absent', () => {
31
+ const { container } = renderBlock({ en: 'EN', fr: 'FR' });
32
+ expect((container.querySelector('div') as HTMLDivElement).innerHTML).toBe(
33
+ 'EN'
34
+ );
35
+ });
36
+
37
+ it('falls back to the first object value when neither locale matches', () => {
38
+ const { container } = renderBlock({ de: 'DE', fr: 'FR' });
39
+ expect((container.querySelector('div') as HTMLDivElement).innerHTML).toBe(
40
+ 'DE'
41
+ );
42
+ });
43
+
44
+ it('renders a plain (non-JSON) string value verbatim', () => {
45
+ const { container } = renderBlock('<p>plain</p>');
46
+ expect((container.querySelector('div') as HTMLDivElement).innerHTML).toBe(
47
+ '<p>plain</p>'
48
+ );
49
+ });
50
+
51
+ it('renders empty when value is undefined', () => {
52
+ const { container } = renderBlock(undefined);
53
+ expect((container.querySelector('div') as HTMLDivElement).innerHTML).toBe(
54
+ ''
55
+ );
56
+ });
57
+
58
+ it('returns a malformed JSON-like string as-is (parse failure path)', () => {
59
+ // Starts with "{" so JSON.parse is attempted, throws, and the catch
60
+ // returns the original string unchanged -> rendered as raw HTML text.
61
+ const { container } = renderBlock('{not valid json');
62
+ expect((container.querySelector('div') as HTMLDivElement).innerHTML).toBe(
63
+ '{not valid json'
64
+ );
65
+ });
66
+ });
67
+
68
+ describe('TextBlock rendering', () => {
69
+ it('injects content via dangerouslySetInnerHTML WITHOUT sanitization', () => {
70
+ // Pin current behavior: arbitrary markup is written directly into the DOM.
71
+ const { container } = renderBlock(
72
+ '<span data-testid="raw"><img src="x" onerror="alert(1)"></span>'
73
+ );
74
+ const raw = container.querySelector('[data-testid="raw"]');
75
+ expect(raw).not.toBeNull();
76
+ const img = container.querySelector('img');
77
+ expect(img).not.toBeNull();
78
+ expect(img?.getAttribute('onerror')).toBe('alert(1)');
79
+ });
80
+
81
+ it('renders a single wrapping div with no styles by default', () => {
82
+ const { container } = renderBlock('hello');
83
+ const div = container.querySelector('div') as HTMLDivElement;
84
+ expect(div).not.toBeNull();
85
+ expect(div.getAttribute('style')).toBeNull();
86
+ });
87
+ });