@akinon/pz-theme 2.0.96-rc.0 → 2.0.97-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.
@@ -0,0 +1,186 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ // Stub the recursive ThemeBlock so a child is a countable marker.
5
+ jest.mock('../../theme-block', () => ({
6
+ __esModule: true,
7
+ default: ({ block }: { block: { id: string } }) => (
8
+ <span data-testid="tb" data-id={block.id} />
9
+ )
10
+ }));
11
+
12
+ import IconGroupBlock from '../icon-group-block';
13
+ import IconGroupColoredBlock from '../icon-group-colored-block';
14
+ import { ThemeSettingsProvider } from '../../theme-settings-context';
15
+
16
+ const child = (id: string, hidden = false, order = 0) => ({
17
+ id,
18
+ type: 'icon',
19
+ label: id,
20
+ value: '',
21
+ properties: {},
22
+ styles: {},
23
+ order,
24
+ hidden
25
+ });
26
+
27
+ const storedBlock = (over: Record<string, unknown> = {}) => ({
28
+ id: 'ig-1',
29
+ type: 'icon-group',
30
+ label: 'Icon Group',
31
+ value: '',
32
+ // The descriptor declares `properties: []`; only the shared ones are stored.
33
+ properties: { htmlId: '', actionType: 'none' },
34
+ styles: { size: { desktop: '24' } },
35
+ order: 0,
36
+ hidden: false,
37
+ blocks: [child('c1'), child('c2')],
38
+ ...over
39
+ });
40
+
41
+ const props = (over: Record<string, unknown> = {}) =>
42
+ ({
43
+ block: storedBlock(),
44
+ placeholderId: 'p',
45
+ sectionId: 's',
46
+ isDesigner: false,
47
+ ...over
48
+ } as any);
49
+
50
+ const markers = (container: HTMLElement) =>
51
+ Array.from(container.querySelectorAll('[data-testid="tb"]'));
52
+
53
+ describe('IconGroupBlock — children', () => {
54
+ it('renders its children through the shared composed-children path', () => {
55
+ const { container } = render(<IconGroupBlock {...props()} />);
56
+
57
+ expect(markers(container).map((m) => m.getAttribute('data-id'))).toEqual([
58
+ 'c1',
59
+ 'c2'
60
+ ]);
61
+ });
62
+
63
+ it('drops a hidden child for the shopper and keeps it in the designer', () => {
64
+ const block = storedBlock({ blocks: [child('c1'), child('c2', true)] });
65
+
66
+ expect(
67
+ markers(render(<IconGroupBlock {...props({ block })} />).container)
68
+ ).toHaveLength(1);
69
+ expect(
70
+ markers(
71
+ render(<IconGroupBlock {...props({ block, isDesigner: true })} />)
72
+ .container
73
+ )
74
+ ).toHaveLength(2);
75
+ });
76
+
77
+ it('stays transparent to layout so the wrapper gap reaches the icons', () => {
78
+ const { container } = render(<IconGroupBlock {...props()} />);
79
+ const host = container.querySelector('[data-icon-group]') as HTMLElement;
80
+
81
+ expect(host.getAttribute('style')).toContain('display: contents');
82
+ });
83
+ });
84
+
85
+ describe('IconGroupBlock — the size control', () => {
86
+ it('sizes the icons underneath it, scoped to this block alone', () => {
87
+ const { container } = render(<IconGroupBlock {...props()} />);
88
+ const css = container.querySelector('style')!.textContent!;
89
+
90
+ expect(css).toContain('[data-icon-group-id="ig-1"] svg');
91
+ expect(css).toContain('[data-icon-group-id="ig-1"] img');
92
+ expect(css).toContain('width:24px');
93
+ expect(css).toContain('height:24px');
94
+ });
95
+
96
+ it('falls back to the descriptor default when the control is untouched', () => {
97
+ const { container } = render(
98
+ <IconGroupBlock {...props({ block: storedBlock({ styles: {} }) })} />
99
+ );
100
+
101
+ expect(container.querySelector('style')!.textContent).toContain(
102
+ 'width:18px'
103
+ );
104
+ });
105
+
106
+ it('reads the size bucket of the current breakpoint', () => {
107
+ const { container } = render(
108
+ <IconGroupBlock
109
+ {...props({
110
+ block: storedBlock({
111
+ styles: { size: { desktop: '24', mobile: '16' } }
112
+ }),
113
+ currentBreakpoint: 'mobile'
114
+ })}
115
+ />
116
+ );
117
+
118
+ expect(container.querySelector('style')!.textContent).toContain(
119
+ 'width:16px'
120
+ );
121
+ });
122
+ });
123
+
124
+ describe('IconGroupBlock — empty state', () => {
125
+ it('shows a labelled band in the designer when the group holds nothing', () => {
126
+ const { container } = render(
127
+ <IconGroupBlock
128
+ {...props({ block: storedBlock({ blocks: [] }), isDesigner: true })}
129
+ />
130
+ );
131
+
132
+ expect(container.textContent).toContain('Icon group (no icons)');
133
+ });
134
+
135
+ it('renders nothing for a shopper when the group holds nothing', () => {
136
+ const { container } = render(
137
+ <IconGroupBlock {...props({ block: storedBlock({ blocks: [] }) })} />
138
+ );
139
+
140
+ expect(container.firstChild).toBeNull();
141
+ });
142
+ });
143
+
144
+ describe('IconGroupColoredBlock — the tint', () => {
145
+ const coloredBlock = (color: unknown) =>
146
+ storedBlock({
147
+ id: 'ig-1',
148
+ type: 'icon-group-colored',
149
+ styles: { size: { desktop: '24' }, color }
150
+ });
151
+
152
+ it('turns a theme token into a real colour the icons can inherit', () => {
153
+ const { container } = render(
154
+ <ThemeSettingsProvider value={{ primaryColor: '#0a7a5a' }}>
155
+ <IconGroupColoredBlock
156
+ {...props({
157
+ block: coloredBlock({ desktop: 'var(--theme-primary)' })
158
+ })}
159
+ />
160
+ </ThemeSettingsProvider>
161
+ );
162
+
163
+ const host = container.querySelector('[data-icon-group]') as HTMLElement;
164
+ expect(host.style.color).toBe('rgb(10, 122, 90)');
165
+ });
166
+
167
+ it('leaves a literal colour to the generated rule on the wrapper', () => {
168
+ const { container } = render(
169
+ <IconGroupColoredBlock
170
+ {...props({ block: coloredBlock({ desktop: '#ff0000' }) })}
171
+ />
172
+ );
173
+
174
+ const host = container.querySelector('[data-icon-group]') as HTMLElement;
175
+ expect(host.style.color).toBe('');
176
+ });
177
+
178
+ it('is the plain group plus a tint — same markup when there is no colour', () => {
179
+ const plain = render(<IconGroupBlock {...props()} />).container.innerHTML;
180
+ const colored = render(
181
+ <IconGroupColoredBlock {...props({ block: coloredBlock(undefined) })} />
182
+ ).container.innerHTML;
183
+
184
+ expect(colored).toBe(plain);
185
+ });
186
+ });
@@ -0,0 +1,226 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import {
4
+ ICON_GLYPHS,
5
+ IconGlyph,
6
+ isInlineSvgMarkup,
7
+ resolveIconSource
8
+ } from '../icon-source';
9
+ import IconBlock from '../icon-block';
10
+
11
+ /**
12
+ * The keys the editor's picker offers with no upload behind them, copied from
13
+ * pz-dynamic-widget `src/pages/settings/constants.ts` (`DEFAULT_ICON_CATEGORIES`).
14
+ * The repos cannot import from each other, so this list is the seam: when the
15
+ * editor gains a category entry, this test is what says the storefront has
16
+ * nothing to draw for it.
17
+ */
18
+ const EDITOR_DEFAULT_ICON_KEYS = [
19
+ // navigation
20
+ 'home',
21
+ 'back-arrow',
22
+ 'right-arrow',
23
+ 'down-arrow',
24
+ 'up-arrow',
25
+ // actions
26
+ 'add',
27
+ 'remove',
28
+ 'close',
29
+ 'check',
30
+ 'circle-check',
31
+ 'filter-icon',
32
+ 'sort-icon',
33
+ 'picker-icon',
34
+ 'spinner',
35
+ // interface
36
+ 'search',
37
+ 'basket',
38
+ 'favourite-empty',
39
+ 'favourite-full',
40
+ 'gift',
41
+ 'share',
42
+ 'barcode',
43
+ 'image',
44
+ // visibility
45
+ 'eye',
46
+ 'eye-blocked',
47
+ 'user',
48
+ // miscellaneous
49
+ 'hamburger',
50
+ 'category',
51
+ 'trash',
52
+ 'globe'
53
+ ];
54
+
55
+ const CLOUD = 'cdn.akinoncloud.com';
56
+
57
+ describe('inline icon catalogue', () => {
58
+ it('covers every default key the editor picker offers', () => {
59
+ const missing = EDITOR_DEFAULT_ICON_KEYS.filter((key) => !ICON_GLYPHS[key]);
60
+ expect(missing).toEqual([]);
61
+ });
62
+
63
+ it('draws every glyph to the same recipe', () => {
64
+ EDITOR_DEFAULT_ICON_KEYS.forEach((key) => {
65
+ const { container, unmount } = render(<IconGlyph name={key} />);
66
+ const svg = container.querySelector('svg');
67
+
68
+ expect(svg).not.toBeNull();
69
+ expect(svg!.getAttribute('viewBox')).toBe('0 0 24 24');
70
+ expect(svg!.getAttribute('fill')).toBe('none');
71
+ expect(svg!.getAttribute('stroke')).toBe('currentColor');
72
+ expect(svg!.getAttribute('stroke-width')).toBe('1.5');
73
+ expect(svg!.getAttribute('aria-hidden')).toBe('true');
74
+ // A key present in the table but drawing nothing is the failure this set
75
+ // is most likely to ship: it looks fine until it is on a live header.
76
+ expect(svg!.children.length).toBeGreaterThan(0);
77
+
78
+ unmount();
79
+ });
80
+ });
81
+
82
+ it('leaves fill to the one glyph whose meaning depends on it', () => {
83
+ const filled = EDITOR_DEFAULT_ICON_KEYS.filter((key) => {
84
+ const { container, unmount } = render(<IconGlyph name={key} />);
85
+ const hasFill = !!container.querySelector('svg > [fill], svg > * [fill]');
86
+ unmount();
87
+ return hasFill;
88
+ });
89
+
90
+ expect(filled).toEqual(['favourite-full']);
91
+ });
92
+
93
+ it('accepts a size and renders nothing for an unknown name', () => {
94
+ const { container } = render(<IconGlyph name="basket" size={18} />);
95
+ expect(container.querySelector('svg')!.getAttribute('width')).toBe('18');
96
+
97
+ const unknown = render(<IconGlyph name="not-an-icon" />);
98
+ expect(unknown.container.querySelector('svg')).toBeNull();
99
+ });
100
+ });
101
+
102
+ describe('resolveIconSource', () => {
103
+ const originalCloud = process.env.NEXT_PUBLIC_IMAGE_CLOUD_NAME;
104
+
105
+ beforeEach(() => {
106
+ process.env.NEXT_PUBLIC_IMAGE_CLOUD_NAME = CLOUD;
107
+ });
108
+
109
+ afterAll(() => {
110
+ process.env.NEXT_PUBLIC_IMAGE_CLOUD_NAME = originalCloud;
111
+ });
112
+
113
+ it('answers with inline markup first', () => {
114
+ expect(resolveIconSource('<svg><rect /></svg>')).toEqual({
115
+ kind: 'svg',
116
+ markup: '<svg><rect /></svg>'
117
+ });
118
+ expect(isInlineSvgMarkup('<?xml version="1.0"?><svg />')).toBe(true);
119
+ });
120
+
121
+ it('passes through values that already address something', () => {
122
+ expect(resolveIconSource('data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=')).toEqual(
123
+ { kind: 'url', url: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=' }
124
+ );
125
+ expect(resolveIconSource('https://example.com/gift.svg')).toEqual({
126
+ kind: 'url',
127
+ url: 'https://example.com/gift.svg'
128
+ });
129
+ // A root-relative path is served by the app itself and must not be sent
130
+ // to the image CDN.
131
+ expect(resolveIconSource('/icons/basket.svg')).toEqual({
132
+ kind: 'url',
133
+ url: '/icons/basket.svg'
134
+ });
135
+ });
136
+
137
+ it("prefers the brand's uploaded icon over the built-in glyph", () => {
138
+ const themeSettings = {
139
+ icons: { basket: '<svg id="brand-basket"></svg>' }
140
+ };
141
+
142
+ expect(resolveIconSource('basket', themeSettings)).toEqual({
143
+ kind: 'svg',
144
+ markup: '<svg id="brand-basket"></svg>'
145
+ });
146
+ });
147
+
148
+ it('falls through to the catalogue when the upload is missing or unusable', () => {
149
+ const glyph = resolveIconSource('basket', { icons: { search: null } });
150
+ expect(glyph.kind).toBe('glyph');
151
+ expect(glyph).toMatchObject({ key: 'basket' });
152
+
153
+ // A theme-settings entry that is itself a bare key resolves nothing, so
154
+ // the catalogue still gets its turn.
155
+ const recursiveKey = resolveIconSource('gift', { icons: { gift: 'gift' } });
156
+ expect(recursiveKey.kind).toBe('glyph');
157
+ });
158
+
159
+ it('never turns a catalogue key into a CDN request', () => {
160
+ EDITOR_DEFAULT_ICON_KEYS.forEach((key) => {
161
+ const source = resolveIconSource(key);
162
+ expect(source.kind).toBe('glyph');
163
+ });
164
+ });
165
+
166
+ it('still sends a genuine media path to the image CDN', () => {
167
+ expect(resolveIconSource('media/icons/free-shipping.svg')).toEqual({
168
+ kind: 'url',
169
+ url: `https://${CLOUD}/media/icons/free-shipping.svg`
170
+ });
171
+ // A bare file name is a path too — it has an extension.
172
+ expect(resolveIconSource('free-shipping.png')).toEqual({
173
+ kind: 'url',
174
+ url: `https://${CLOUD}/free-shipping.png`
175
+ });
176
+ });
177
+
178
+ it('answers nothing when there is nothing to draw', () => {
179
+ expect(resolveIconSource('')).toEqual({ kind: 'none' });
180
+ expect(resolveIconSource(null)).toEqual({ kind: 'none' });
181
+ expect(resolveIconSource(undefined)).toEqual({ kind: 'none' });
182
+ expect(resolveIconSource({})).toEqual({ kind: 'none' });
183
+ // Not a key, not a path: prefixing the CDN host would only produce a
184
+ // broken image.
185
+ expect(resolveIconSource('some-removed-key')).toEqual({ kind: 'none' });
186
+
187
+ delete process.env.NEXT_PUBLIC_IMAGE_CLOUD_NAME;
188
+ expect(resolveIconSource('media/icons/gift.svg')).toEqual({ kind: 'none' });
189
+ });
190
+
191
+ it('unwraps the object shapes published JSON has carried', () => {
192
+ expect(resolveIconSource({ url: 'https://example.com/a.svg' })).toEqual({
193
+ kind: 'url',
194
+ url: 'https://example.com/a.svg'
195
+ });
196
+ expect(resolveIconSource({ tr: 'basket' }).kind).toBe('glyph');
197
+ });
198
+ });
199
+
200
+ const iconProps = (value: unknown) =>
201
+ ({
202
+ block: {
203
+ id: 'i',
204
+ type: 'icon',
205
+ label: 'i',
206
+ value,
207
+ properties: {},
208
+ styles: {},
209
+ order: 0,
210
+ hidden: false
211
+ },
212
+ placeholderId: 'p',
213
+ sectionId: 's',
214
+ isDesigner: false,
215
+ currentBreakpoint: 'desktop'
216
+ } as never);
217
+
218
+ describe('IconBlock reading through the resolver', () => {
219
+ it('draws a picked catalogue key inline instead of a broken image', () => {
220
+ process.env.NEXT_PUBLIC_IMAGE_CLOUD_NAME = CLOUD;
221
+ const { container } = render(<IconBlock {...iconProps('basket')} />);
222
+
223
+ expect(container.querySelector('svg')).not.toBeNull();
224
+ expect(container.querySelector('img')).toBeNull();
225
+ });
226
+ });
@@ -0,0 +1,77 @@
1
+ import {
2
+ EMPTY_RESPONSIVE_ARRAY,
3
+ hasResponsiveArray,
4
+ readResponsiveArray
5
+ } from '../responsive-array';
6
+
7
+ describe('readResponsiveArray', () => {
8
+ it('passes a flat array through by identity', () => {
9
+ const stored = [{ label: 'Free shipping' }, { label: 'Store pickup' }];
10
+ expect(readResponsiveArray(stored)).toBe(stored);
11
+ expect(readResponsiveArray(stored, 'mobile')).toBe(stored);
12
+ });
13
+
14
+ it('reads the bucket for the current breakpoint', () => {
15
+ const stored = {
16
+ desktop: [{ label: 'Complete the look' }],
17
+ mobile: [{ label: 'In store' }, { label: 'Instalments' }]
18
+ };
19
+
20
+ expect(readResponsiveArray(stored, 'desktop')).toHaveLength(1);
21
+ expect(readResponsiveArray(stored, 'mobile')).toHaveLength(2);
22
+ });
23
+
24
+ it('lets tablet inherit mobile before desktop, like the emitted cascade', () => {
25
+ const withMobile = {
26
+ desktop: [{ label: 'desktop' }],
27
+ mobile: [{ label: 'mobile' }]
28
+ };
29
+ expect(readResponsiveArray<{ label: string }>(withMobile, 'tablet')[0]).toEqual(
30
+ { label: 'mobile' }
31
+ );
32
+
33
+ const desktopOnly = { desktop: [{ label: 'desktop' }] };
34
+ expect(
35
+ readResponsiveArray<{ label: string }>(desktopOnly, 'tablet')[0]
36
+ ).toEqual({ label: 'desktop' });
37
+ });
38
+
39
+ it('falls back to desktop when the breakpoint has nothing authored', () => {
40
+ const stored = { desktop: [{ label: 'Instalment table' }] };
41
+ expect(readResponsiveArray(stored, 'mobile')).toHaveLength(1);
42
+ });
43
+
44
+ it('returns the same empty array every time nothing is stored', () => {
45
+ const first = readResponsiveArray(undefined);
46
+ const second = readResponsiveArray(null);
47
+
48
+ expect(first).toBe(second);
49
+ expect(first).toBe(EMPTY_RESPONSIVE_ARRAY);
50
+ expect(first).toHaveLength(0);
51
+ });
52
+
53
+ it('returns the shared empty for values that are not lists', () => {
54
+ expect(readResponsiveArray('two items')).toBe(EMPTY_RESPONSIVE_ARRAY);
55
+ expect(readResponsiveArray(3)).toBe(EMPTY_RESPONSIVE_ARRAY);
56
+ expect(readResponsiveArray(true)).toBe(EMPTY_RESPONSIVE_ARRAY);
57
+ // A per-breakpoint object whose bucket is not a list is still "no list".
58
+ expect(readResponsiveArray({ desktop: 'nope' })).toBe(
59
+ EMPTY_RESPONSIVE_ARRAY
60
+ );
61
+ expect(readResponsiveArray({ somethingElse: [1] })).toBe(
62
+ EMPTY_RESPONSIVE_ARRAY
63
+ );
64
+ });
65
+
66
+ it('is frozen, so a caller cannot poison the next reader', () => {
67
+ expect(() => readResponsiveArray<number>(undefined).push(1)).toThrow();
68
+ });
69
+
70
+ it('answers hasResponsiveArray per breakpoint', () => {
71
+ const stored = { desktop: [{ label: 'Gift wrap' }], mobile: [] };
72
+
73
+ expect(hasResponsiveArray(stored, 'desktop')).toBe(true);
74
+ expect(hasResponsiveArray(stored, 'mobile')).toBe(false);
75
+ expect(hasResponsiveArray(undefined)).toBe(false);
76
+ });
77
+ });
@@ -0,0 +1,108 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import {
4
+ PLATFORM_LABELS,
5
+ SOCIAL_GLYPHS,
6
+ platformLabel
7
+ } from '../social-glyphs';
8
+ import SocialLinksBlock from '../social-links-block';
9
+
10
+ /**
11
+ * The table moved out of `social-links-block` so more than one renderer can
12
+ * draw a platform mark. These pin the move: the same 19 keys, the same labels,
13
+ * and the block still drawing the glyph it drew before.
14
+ */
15
+ const EXPECTED_PLATFORMS = [
16
+ 'facebook',
17
+ 'instagram',
18
+ 'twitter',
19
+ 'youtube',
20
+ 'linkedin',
21
+ 'tiktok',
22
+ 'pinterest',
23
+ 'whatsapp',
24
+ 'telegram',
25
+ 'discord',
26
+ 'snapchat',
27
+ 'reddit',
28
+ 'twitch',
29
+ 'spotify',
30
+ 'apple',
31
+ 'google',
32
+ 'email',
33
+ 'phone',
34
+ 'globe'
35
+ ];
36
+
37
+ describe('social glyph table', () => {
38
+ it('carries every platform the picker stores, and nothing else', () => {
39
+ expect(Object.keys(SOCIAL_GLYPHS).sort()).toEqual(
40
+ [...EXPECTED_PLATFORMS].sort()
41
+ );
42
+ });
43
+
44
+ it('keeps the hand-written labels and title-cases the rest', () => {
45
+ expect(PLATFORM_LABELS.twitter).toBe('Twitter / X');
46
+ expect(platformLabel('twitter')).toBe('Twitter / X');
47
+ expect(platformLabel('globe')).toBe('Website');
48
+ expect(platformLabel('facebook')).toBe('Facebook');
49
+ expect(platformLabel('')).toBe('Link');
50
+ });
51
+ });
52
+
53
+ const socialProps = (items: unknown[]) =>
54
+ ({
55
+ block: {
56
+ id: 'b',
57
+ type: 'social-links',
58
+ label: 'Social',
59
+ value: '',
60
+ properties: { items },
61
+ styles: {},
62
+ order: 0,
63
+ hidden: false
64
+ },
65
+ placeholderId: 'p',
66
+ sectionId: 's',
67
+ isDesigner: false,
68
+ currentBreakpoint: 'desktop'
69
+ } as never);
70
+
71
+ describe('social links block after the table moved', () => {
72
+ it('still draws the glyph for a stored entry', () => {
73
+ const { container } = render(
74
+ <SocialLinksBlock
75
+ {...socialProps([
76
+ { icon: 'facebook', link: 'https://facebook.com/lulu' }
77
+ ])}
78
+ />
79
+ );
80
+
81
+ const link = container.querySelector('[data-social-link="facebook"]');
82
+ expect(link).not.toBeNull();
83
+ expect(link!.getAttribute('aria-label')).toBe('Facebook');
84
+ expect(link!.querySelector('svg path')).not.toBeNull();
85
+ });
86
+
87
+ it('falls back to the globe glyph for an unknown platform', () => {
88
+ const { container } = render(
89
+ <SocialLinksBlock
90
+ {...socialProps([{ icon: 'myspace', link: 'https://example.com' }])}
91
+ />
92
+ );
93
+
94
+ const link = container.querySelector('[data-social-link="myspace"]');
95
+ expect(link).not.toBeNull();
96
+ // The globe is three shapes; an unknown key that resolved to nothing would
97
+ // have drawn an empty <svg>.
98
+ expect(link!.querySelectorAll('svg > *').length).toBe(3);
99
+ });
100
+
101
+ it('draws the designer placeholder when there are no entries', () => {
102
+ const { container } = render(
103
+ <SocialLinksBlock {...socialProps([])} isDesigner />
104
+ );
105
+ expect(container.querySelector('[data-social-links]')).not.toBeNull();
106
+ expect(container.textContent).toContain('Social links');
107
+ });
108
+ });