@akinon/pz-theme 2.0.36-beta.0 → 2.0.36

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 (50) hide show
  1. package/CHANGELOG.md +5 -4
  2. package/jest/__mocks__/hookform-resolvers.js +5 -0
  3. package/jest/__mocks__/next-auth-react.js +5 -0
  4. package/jest.config.js +3 -0
  5. package/package.json +4 -4
  6. package/src/blocks/accordion-block.tsx +35 -3
  7. package/src/blocks/commerce-format.ts +220 -0
  8. package/src/blocks/icon-block.tsx +6 -1
  9. package/src/blocks/image-gallery-block.tsx +1 -1
  10. package/src/blocks/link-block.tsx +9 -1
  11. package/src/blocks/slider-block.tsx +2 -1
  12. package/src/blocks/tab-block.tsx +15 -2
  13. package/src/blocks/video-block.tsx +6 -0
  14. package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
  15. package/src/components/action-toolbar.tsx +182 -3
  16. package/src/components/part-state-preview.tsx +92 -0
  17. package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
  18. package/src/hooks/use-designer-features.tsx +16 -1
  19. package/src/hooks/use-overlay.ts +538 -0
  20. package/src/hooks/use-visibility-context.ts +14 -2
  21. package/src/sections/__tests__/divider-section.test.tsx +28 -1
  22. package/src/sections/contact-form-section.tsx +4 -1
  23. package/src/sections/coupon-banner-section.tsx +2 -1
  24. package/src/sections/featured-product-spotlight-section.tsx +4 -4
  25. package/src/sections/find-in-store-section.tsx +2 -1
  26. package/src/sections/hover-showcase-section.tsx +2 -1
  27. package/src/sections/installment-options-section.tsx +2 -1
  28. package/src/sections/order-tracking-lookup-section.tsx +6 -1
  29. package/src/sections/posts-slider-section.tsx +8 -3
  30. package/src/sections/pre-order-launch-banner-section.tsx +2 -1
  31. package/src/sections/tabs-section.tsx +7 -13
  32. package/src/theme-block.tsx +81 -16
  33. package/src/theme-placeholder-client.tsx +8 -1
  34. package/src/theme-placeholder-wrapper.tsx +17 -3
  35. package/src/theme-placeholder.tsx +51 -5
  36. package/src/theme-section.tsx +20 -4
  37. package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
  38. package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
  39. package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
  40. package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
  41. package/src/utils/breakpoints.ts +116 -0
  42. package/src/utils/custom-css.ts +233 -0
  43. package/src/utils/index.ts +353 -42
  44. package/src/utils/iterator-utils.ts +675 -62
  45. package/src/utils/page-context-discovery.test.ts +188 -0
  46. package/src/utils/page-context-discovery.ts +48 -4
  47. package/src/utils/part-styles.ts +229 -0
  48. package/src/utils/row-visibility.ts +273 -0
  49. package/src/utils/visibility-rules.test.ts +579 -0
  50. package/src/utils/visibility-rules.ts +346 -3
@@ -0,0 +1,1178 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import { buildIteratorBlock, resolveBlockBindings } from '../iterator-utils';
4
+ import { generateThemeCSS, getCSSStyles } from '../index';
5
+ import TextBlock from '../../blocks/text-block';
6
+ import type { Block } from '../../theme-block';
7
+
8
+ /**
9
+ * Per-row appearance, nested repeats and interpolated bindings.
10
+ *
11
+ * The first block of tests is the important one: a composition that carries no
12
+ * binding token must come out of the iterator and out of the CSS generator
13
+ * exactly as it does today — same object, same bytes.
14
+ */
15
+
16
+ const block = (overrides: Partial<Block>): Block =>
17
+ ({
18
+ id: 'block',
19
+ type: 'text',
20
+ label: 'Block',
21
+ value: '',
22
+ order: 0,
23
+ hidden: false,
24
+ properties: {},
25
+ styles: {},
26
+ blocks: [],
27
+ ...overrides
28
+ } as Block);
29
+
30
+ const iteratorOf = (template: Block, overrides: Partial<Block> = {}): Block =>
31
+ block({
32
+ id: 'iterator',
33
+ type: 'group',
34
+ label: 'Iterator',
35
+ isIterator: true,
36
+ blocks: [template],
37
+ ...overrides
38
+ });
39
+
40
+ describe('a composition with no bindings is untouched', () => {
41
+ const cardStyles = {
42
+ 'background-color': '#e63946',
43
+ color: '#ffffff',
44
+ 'background-opacity': '40',
45
+ padding: { desktop: '12px', mobile: '4px' },
46
+ 'badge/border-color': '#00ff00'
47
+ };
48
+
49
+ const template = block({
50
+ id: 'card',
51
+ type: 'group',
52
+ label: 'Card',
53
+ styles: cardStyles,
54
+ blocks: [
55
+ block({
56
+ id: 'card-title',
57
+ properties: { dataBinding: 'item.name' },
58
+ styles: { 'font-size': 16 },
59
+ value: 'Fallback'
60
+ })
61
+ ]
62
+ });
63
+
64
+ const expanded = buildIteratorBlock({
65
+ iteratorBlock: iteratorOf(template),
66
+ items: [{ name: 'Sneaker' }, { name: 'Boot' }]
67
+ });
68
+
69
+ it('hands every clone the template style object itself, not a copy of it', () => {
70
+ // Reference identity is the strongest available proof that nothing about
71
+ // the style set changed: no re-serialization, no key reordering, no value
72
+ // rewritten — the clone points at exactly what the template holds.
73
+ (expanded.blocks || []).forEach((clone) => {
74
+ expect(clone.styles).toBe(cardStyles);
75
+ expect((clone.blocks || [])[0].styles).toBe(template.blocks?.[0].styles);
76
+ });
77
+ });
78
+
79
+ it('keeps the template style identity, so one rule still serves every row', () => {
80
+ (expanded.blocks || []).forEach((clone) => {
81
+ expect(clone.styleSourceId).toBe('card');
82
+ });
83
+ });
84
+
85
+ it('still resolves the row values', () => {
86
+ expect(
87
+ (expanded.blocks || []).map((clone) => clone.blocks?.[0].value)
88
+ ).toEqual(['Sneaker', 'Boot']);
89
+ });
90
+
91
+ it('emits byte-identical CSS', () => {
92
+ // Frozen against the output of the committed generator, captured before
93
+ // this feature existed. Any drift in spacing, ordering, alpha maths or
94
+ // part handling breaks this line.
95
+ const section = [
96
+ {
97
+ id: 'sec-1',
98
+ styles: { 'background-color': '#101010' },
99
+ blocks: [{ id: 'card', styleSourceId: 'tpl', styles: cardStyles }]
100
+ }
101
+ ];
102
+
103
+ expect(generateThemeCSS(section as any)).toBe(
104
+ '[data-section-id="sec-1"] {\n' +
105
+ ' background-color: rgba(16, 16, 16, 1);\n' +
106
+ '}\n' +
107
+ '[data-block-id="tpl"] {\n' +
108
+ ' background-color: rgba(230, 57, 70, 0.4);\n' +
109
+ ' color: #ffffff;\n' +
110
+ ' padding: 12px;\n' +
111
+ '}\n' +
112
+ '@media (max-width: 1023px) {\n' +
113
+ ' [data-block-id="tpl"] {\n' +
114
+ ' padding: 4px;\n' +
115
+ ' }\n' +
116
+ '}\n' +
117
+ // The part rule carries an ownership guard so it cannot reach a part
118
+ // of the same name inside a nested block (utils/part-styles.ts). The
119
+ // guard sits in :where(), so the rule's specificity is unchanged.
120
+ '[data-block-id="tpl"] [data-part="badge"]' +
121
+ ':not(:where([data-block-id="tpl"] [data-block-id] *)) {\n' +
122
+ ' border-color: #00ff00 !important;\n' +
123
+ '}\n'
124
+ );
125
+
126
+ expect(generateThemeCSS(section as any, 'mobile')).toBe(
127
+ '[data-section-id="sec-1"] {\n' +
128
+ ' background-color: rgba(16, 16, 16, 1);\n' +
129
+ '}\n' +
130
+ '[data-block-id="tpl"] {\n' +
131
+ ' background-color: rgba(230, 57, 70, 0.4);\n' +
132
+ ' color: #ffffff;\n' +
133
+ ' padding: 4px;\n' +
134
+ '}\n' +
135
+ // The part rule carries an ownership guard so it cannot reach a part
136
+ // of the same name inside a nested block (utils/part-styles.ts). The
137
+ // guard sits in :where(), so the rule's specificity is unchanged.
138
+ '[data-block-id="tpl"] [data-part="badge"]' +
139
+ ':not(:where([data-block-id="tpl"] [data-block-id] *)) {\n' +
140
+ ' border-color: #00ff00 !important;\n' +
141
+ '}\n'
142
+ );
143
+ });
144
+ });
145
+
146
+ describe('a style value can carry a binding', () => {
147
+ const badgeStyles = {
148
+ 'background-color': '{{item.badgeColor}}',
149
+ color: '{{item.badgeTextColor}}',
150
+ 'background-opacity': '{{item.badgeOpacity}}',
151
+ 'border-radius': '4px'
152
+ };
153
+
154
+ const expandFor = (rows: Record<string, unknown>[]) =>
155
+ buildIteratorBlock({
156
+ iteratorBlock: iteratorOf(
157
+ block({
158
+ id: 'card',
159
+ type: 'group',
160
+ label: 'Card',
161
+ blocks: [block({ id: 'badge', label: 'Badge', styles: badgeStyles })]
162
+ })
163
+ ),
164
+ items: rows
165
+ });
166
+
167
+ const badgeOf = (expanded: Block, index: number) =>
168
+ (expanded.blocks || [])[index].blocks?.[0];
169
+
170
+ it('resolves the bound properties per clone', () => {
171
+ const expanded = expandFor([
172
+ { badgeColor: '#00a86b', badgeTextColor: '#ffffff', badgeOpacity: 40 },
173
+ {
174
+ badgeColor: 'rgb(230 57 70)',
175
+ badgeTextColor: '#101010',
176
+ badgeOpacity: 100
177
+ }
178
+ ]);
179
+
180
+ expect(badgeOf(expanded, 0)?.styles).toEqual({
181
+ 'background-color': '#00a86b',
182
+ color: '#ffffff',
183
+ 'background-opacity': '40',
184
+ 'border-radius': '4px'
185
+ });
186
+ expect(badgeOf(expanded, 1)?.styles).toEqual({
187
+ 'background-color': 'rgb(230 57 70)',
188
+ color: '#101010',
189
+ 'background-opacity': '100',
190
+ 'border-radius': '4px'
191
+ });
192
+ });
193
+
194
+ it('leaves the unbound properties alone, so the shared rule still applies', () => {
195
+ const expanded = expandFor([{ badgeColor: '#00a86b' }]);
196
+
197
+ expect(badgeOf(expanded, 0)?.styles['border-radius']).toBe('4px');
198
+ });
199
+
200
+ it('never writes back onto the template', () => {
201
+ expandFor([{ badgeColor: '#00a86b' }]);
202
+
203
+ expect(badgeStyles['background-color']).toBe('{{item.badgeColor}}');
204
+ });
205
+
206
+ it('leaves the property unset when the binding resolves to nothing', () => {
207
+ const styles = badgeOf(expandFor([{ badgeTextColor: '#fff' }]), 0)?.styles;
208
+
209
+ expect('background-color' in styles).toBe(false);
210
+ expect(JSON.stringify(styles)).not.toContain('undefined');
211
+ });
212
+
213
+ it('resolves per breakpoint and keeps the literal breakpoints', () => {
214
+ const expanded = buildIteratorBlock({
215
+ iteratorBlock: iteratorOf(
216
+ block({
217
+ id: 'card',
218
+ styles: {
219
+ 'font-size': { desktop: '{{item.size}}', mobile: '12px' }
220
+ }
221
+ })
222
+ ),
223
+ items: [{ size: '18px' }]
224
+ });
225
+
226
+ expect((expanded.blocks || [])[0].styles['font-size']).toEqual({
227
+ desktop: '18px',
228
+ mobile: '12px'
229
+ });
230
+ });
231
+
232
+ it('interpolates a binding inside a longer CSS value', () => {
233
+ const expanded = buildIteratorBlock({
234
+ iteratorBlock: iteratorOf(
235
+ block({
236
+ id: 'card',
237
+ styles: { padding: '{{item.gap}}px {{item.gap}}px' }
238
+ })
239
+ ),
240
+ items: [{ gap: 8 }]
241
+ });
242
+
243
+ expect((expanded.blocks || [])[0].styles.padding).toBe('8px 8px');
244
+ });
245
+
246
+ it('drops a value where only some tokens resolve', () => {
247
+ // Half a CSS value can change what the shorthand around it means.
248
+ const expanded = buildIteratorBlock({
249
+ iteratorBlock: iteratorOf(
250
+ block({ id: 'card', styles: { margin: '{{item.top}} {{item.side}}' } })
251
+ ),
252
+ items: [{ top: '4px' }]
253
+ });
254
+
255
+ expect('margin' in (expanded.blocks || [])[0].styles).toBe(false);
256
+ });
257
+
258
+ it('leaves the token in place on the designer canvas, where there is no row', () => {
259
+ const expanded = buildIteratorBlock({
260
+ iteratorBlock: iteratorOf(block({ id: 'card', styles: badgeStyles })),
261
+ items: [],
262
+ isDesigner: true
263
+ });
264
+
265
+ expect((expanded.blocks || [])[0].styles['background-color']).toBe(
266
+ '{{item.badgeColor}}'
267
+ );
268
+ });
269
+
270
+ describe('and the resolved value reaches the element', () => {
271
+ const paintBadges = (rows: Record<string, unknown>[]) => {
272
+ const expanded = buildIteratorBlock({
273
+ iteratorBlock: iteratorOf(
274
+ block({
275
+ id: 'badge',
276
+ type: 'text',
277
+ value: 'SALE',
278
+ styles: {
279
+ 'background-color': '{{item.badgeColor}}',
280
+ 'background-opacity': '{{item.badgeOpacity}}',
281
+ 'border-radius': '4px'
282
+ }
283
+ })
284
+ ),
285
+ items: rows
286
+ });
287
+
288
+ return (expanded.blocks || []).map(
289
+ (clone) =>
290
+ render(<TextBlock block={clone as any} />).container.querySelector(
291
+ 'div'
292
+ ) as HTMLDivElement
293
+ );
294
+ };
295
+
296
+ it('paints each row its own colour', () => {
297
+ const painted = paintBadges([
298
+ { badgeColor: '#00a86b', badgeOpacity: 40 },
299
+ { badgeColor: '#e63946', badgeOpacity: 100 }
300
+ ]);
301
+
302
+ // The bound opacity feeds the same alpha maths a typed colour goes
303
+ // through, so the row gets a real rgba rather than a flat swatch.
304
+ expect(painted[0].style.backgroundColor).toBe('rgba(0, 168, 107, 0.4)');
305
+ expect(painted[1].style.backgroundColor).toMatch(/230, 57, 70/);
306
+ // The unbound property is still on every row.
307
+ expect(painted[0].style.borderRadius).toBe('4px');
308
+ });
309
+
310
+ it('paints nothing where the row carries no colour', () => {
311
+ const [painted] = paintBadges([{ name: 'No badge' }]);
312
+
313
+ expect(painted.style.backgroundColor).toBe('');
314
+ expect(painted.getAttribute('style')).not.toContain('undefined');
315
+ });
316
+ });
317
+ });
318
+
319
+ describe('a bound style value is merchant data, so it is treated as untrusted', () => {
320
+ const resolveBadge = (badgeColor: unknown) =>
321
+ (buildIteratorBlock({
322
+ iteratorBlock: iteratorOf(
323
+ block({
324
+ id: 'card',
325
+ styles: { 'background-color': '{{item.badgeColor}}' }
326
+ })
327
+ ),
328
+ items: [{ badgeColor }]
329
+ }).blocks || [])[0].styles;
330
+
331
+ it.each([
332
+ ['a second declaration', '#fff; color: red'],
333
+ ['a second rule', 'red} body { display: none'],
334
+ ['a style tag breakout', '</style><script>alert(1)</script>'],
335
+ ['an escaped breakout', '\\3c /style\\3e '],
336
+ ['an important hijack', '#fff !important'],
337
+ ['a comment', '#fff /* hidden'],
338
+ ['a remote fetch', 'url(//tracker.test/p.gif)'],
339
+ ['a fetch behind a gradient', 'image-set(//tracker.test/p.gif)'],
340
+ ['a legacy script value', 'expression(alert(1))'],
341
+ ['a quoted value', '"#fff"'],
342
+ ['an at-rule', '@import "x"']
343
+ ])('rejects %s', (_label, payload) => {
344
+ expect('background-color' in resolveBadge(payload)).toBe(false);
345
+ });
346
+
347
+ it('accepts the values a colour actually needs', () => {
348
+ expect(resolveBadge('#00a86b')['background-color']).toBe('#00a86b');
349
+ expect(resolveBadge('rebeccapurple')['background-color']).toBe(
350
+ 'rebeccapurple'
351
+ );
352
+ expect(resolveBadge('rgb(230, 57, 70)')['background-color']).toBe(
353
+ 'rgb(230, 57, 70)'
354
+ );
355
+ expect(resolveBadge('rgb(230 57 70 / 40%)')['background-color']).toBe(
356
+ 'rgb(230 57 70 / 40%)'
357
+ );
358
+ });
359
+
360
+ it('rejects a value long enough to be a payload rather than a colour', () => {
361
+ expect('background-color' in resolveBadge(`#${'a'.repeat(200)}`)).toBe(
362
+ false
363
+ );
364
+ });
365
+
366
+ it('never lets an unresolved token reach the stylesheet', () => {
367
+ const css = generateThemeCSS([
368
+ {
369
+ id: 'sec',
370
+ styles: {},
371
+ blocks: [
372
+ {
373
+ id: 'badge',
374
+ styles: {
375
+ 'background-color': '{{item.badgeColor}}',
376
+ 'badge/color': '{{item.badgeTextColor}}',
377
+ padding: { desktop: '{{item.pad}}', mobile: '4px' }
378
+ }
379
+ }
380
+ ]
381
+ }
382
+ ] as any);
383
+
384
+ expect(css).not.toContain('{{');
385
+ expect(css).not.toContain('background-color');
386
+ expect(css).not.toContain('undefined');
387
+ // The breakpoint that is real CSS still lands.
388
+ expect(css).toContain('padding: 4px');
389
+ });
390
+ });
391
+
392
+ describe('a repeat inside a row iterates the row', () => {
393
+ const swatchStrip = block({
394
+ id: 'swatches',
395
+ type: 'group',
396
+ label: 'Swatches',
397
+ isIterator: true,
398
+ iteratorDataPath: 'item.colors',
399
+ properties: { iteratorDataPath: 'item.colors' },
400
+ blocks: [block({ id: 'swatch', properties: { dataBinding: 'item.label' } })]
401
+ });
402
+
403
+ const card = block({
404
+ id: 'card',
405
+ type: 'group',
406
+ label: 'Card',
407
+ blocks: [swatchStrip]
408
+ });
409
+
410
+ // What the section renderer does: expand the outer repeat, then walk into
411
+ // each clone and expand the repeats it finds there.
412
+ const expandNested = (rows: Record<string, unknown>[]) => {
413
+ const outer = buildIteratorBlock({
414
+ iteratorBlock: iteratorOf(card),
415
+ items: rows
416
+ });
417
+
418
+ return (outer.blocks || []).map((clone) =>
419
+ buildIteratorBlock({ iteratorBlock: (clone.blocks || [])[0] })
420
+ );
421
+ };
422
+
423
+ it('draws one element per entry of the row it lives in', () => {
424
+ const nested = expandNested([
425
+ { colors: [{ label: 'Red' }, { label: 'Blue' }, { label: 'Black' }] },
426
+ { colors: [{ label: 'Sand' }] }
427
+ ]);
428
+
429
+ expect(nested[0].blocks).toHaveLength(3);
430
+ expect(nested[1].blocks).toHaveLength(1);
431
+ });
432
+
433
+ it('resolves the nested bindings against the nested entry', () => {
434
+ const nested = expandNested([
435
+ { colors: [{ label: 'Red' }, { label: 'Blue' }] }
436
+ ]);
437
+
438
+ expect((nested[0].blocks || []).map((clone) => clone.value)).toEqual([
439
+ 'Red',
440
+ 'Blue'
441
+ ]);
442
+ });
443
+
444
+ it('leaves a top-level repeat resolving exactly where it did', () => {
445
+ // No row to stand in: `item.colors` is not a section path, so this is the
446
+ // single placeholder clone it has always produced.
447
+ const topLevel = buildIteratorBlock({
448
+ iteratorBlock: iteratorOf(block({ id: 'swatch' }), {
449
+ iteratorDataPath: 'item.colors'
450
+ }),
451
+ sectionDataSource: {
452
+ type: 'page-context',
453
+ details: { pageContext: { pageType: 'product', rootPath: 'product' } }
454
+ },
455
+ pageContext: { colors: [{ label: 'Red' }, { label: 'Blue' }] }
456
+ });
457
+
458
+ expect(topLevel.blocks).toHaveLength(1);
459
+ });
460
+
461
+ it('leaves a nested repeat that points at a section path alone', () => {
462
+ const outer = buildIteratorBlock({
463
+ iteratorBlock: iteratorOf(
464
+ block({
465
+ id: 'card',
466
+ type: 'group',
467
+ blocks: [
468
+ block({
469
+ id: 'campaigns',
470
+ type: 'group',
471
+ isIterator: true,
472
+ iteratorDataPath: 'campaigns',
473
+ properties: { iteratorDataPath: 'campaigns' },
474
+ blocks: [block({ id: 'chip' })]
475
+ })
476
+ ]
477
+ })
478
+ ),
479
+ items: [{ campaigns: [{ label: 'A' }, { label: 'B' }] }]
480
+ });
481
+
482
+ const nestedProperties = (outer.blocks || [])[0].blocks?.[0].properties;
483
+ expect(nestedProperties.iteratorScopedItems).toBeUndefined();
484
+ // Nothing from the row travels with it either.
485
+ expect(JSON.stringify(nestedProperties)).not.toContain('label');
486
+ });
487
+ });
488
+
489
+ describe('a colour strip inside a card', () => {
490
+ // The design case the whole feature exists for: a card repeats products, and
491
+ // inside each card a strip repeats THAT product's colours with each swatch
492
+ // painted its own hex. The strip's template is a descendant of the card, so
493
+ // the outer expansion walks straight through it — and a style token, unlike
494
+ // a `dataBinding`, is consumed the moment it is resolved: whatever the outer
495
+ // row makes of it is final. The nested repeat has to be the one that
496
+ // resolves it.
497
+ const swatch = block({
498
+ id: 'swatch',
499
+ label: 'Swatch',
500
+ properties: { dataBinding: 'item.label' },
501
+ styles: { 'background-color': '{{item.hex}}', 'border-radius': '2px' }
502
+ });
503
+
504
+ const strip = block({
505
+ id: 'strip',
506
+ type: 'group',
507
+ label: 'Colours',
508
+ isIterator: true,
509
+ iteratorDataPath: 'item.colors',
510
+ properties: { iteratorDataPath: 'item.colors' },
511
+ styles: { 'background-color': '{{item.stripBg}}', gap: '4px' },
512
+ blocks: [swatch]
513
+ });
514
+
515
+ const card = block({
516
+ id: 'card',
517
+ type: 'group',
518
+ label: 'Card',
519
+ blocks: [strip]
520
+ });
521
+
522
+ // What the section renderer does: expand the outer repeat, then walk into
523
+ // each clone and expand the repeats it finds there.
524
+ const expandCards = (rows: Record<string, unknown>[]) => {
525
+ const outer = buildIteratorBlock({
526
+ iteratorBlock: iteratorOf(card),
527
+ items: rows
528
+ });
529
+
530
+ return (outer.blocks || []).map((clone) => ({
531
+ unexpandedStrip: (clone.blocks || [])[0],
532
+ strip: buildIteratorBlock({ iteratorBlock: (clone.blocks || [])[0] })
533
+ }));
534
+ };
535
+
536
+ const swatchColours = (expandedStrip: Block) =>
537
+ (expandedStrip.blocks || []).map(
538
+ (clone) => (clone.styles as Record<string, unknown>)['background-color']
539
+ );
540
+
541
+ const colours = [
542
+ { label: 'Red', hex: '#ff0000' },
543
+ { label: 'Blue', hex: '#0000ff' }
544
+ ];
545
+
546
+ it('paints every swatch the colour of its own entry', () => {
547
+ const [{ strip: expanded }] = expandCards([{ colors: colours }]);
548
+
549
+ expect(swatchColours(expanded)).toEqual(['#ff0000', '#0000ff']);
550
+ });
551
+
552
+ it('does not paint the strip with the product row that happens to carry the same field', () => {
553
+ // A dominant-colour attribute on the product is ordinary catalogue data.
554
+ // Resolved by the outer repeat it silently paints every swatch the same
555
+ // wrong colour, which is worse than painting none.
556
+ const [{ strip: expanded }] = expandCards([
557
+ { hex: '#123456', colors: colours }
558
+ ]);
559
+
560
+ expect(swatchColours(expanded)).toEqual(['#ff0000', '#0000ff']);
561
+ expect(JSON.stringify(expanded)).not.toContain('#123456');
562
+ });
563
+
564
+ it('still resolves the strip container against the product row it is drawn in', () => {
565
+ // The strip itself is drawn once per product, so its own styles belong to
566
+ // the outer repeat. Only what is INSIDE it belongs to the nested one.
567
+ const [{ strip: expanded }] = expandCards([
568
+ { stripBg: '#eeeeee', colors: colours }
569
+ ]);
570
+
571
+ expect((expanded.styles as Record<string, unknown>)['background-color']).toBe(
572
+ '#eeeeee'
573
+ );
574
+ expect((expanded.styles as Record<string, unknown>).gap).toBe('4px');
575
+ });
576
+
577
+ it('keeps the per-swatch text the nested repeat already resolved correctly', () => {
578
+ const [{ strip: expanded }] = expandCards([{ colors: colours }]);
579
+
580
+ expect((expanded.blocks || []).map((clone) => clone.value)).toEqual([
581
+ 'Red',
582
+ 'Blue'
583
+ ]);
584
+ });
585
+
586
+ it('leaves the unbound property on the shared rule', () => {
587
+ const [{ strip: expanded }] = expandCards([{ colors: colours }]);
588
+
589
+ (expanded.blocks || []).forEach((clone) => {
590
+ expect((clone.styles as Record<string, unknown>)['border-radius']).toBe(
591
+ '2px'
592
+ );
593
+ });
594
+ });
595
+
596
+ it('resolves a repeat inside a repeat inside a repeat by its own owner', () => {
597
+ const sizeChip = block({
598
+ id: 'size',
599
+ label: 'Size',
600
+ styles: { color: '{{item.tint}}' }
601
+ });
602
+
603
+ const sizes = block({
604
+ id: 'sizes',
605
+ type: 'group',
606
+ label: 'Sizes',
607
+ isIterator: true,
608
+ iteratorDataPath: 'item.sizes',
609
+ properties: { iteratorDataPath: 'item.sizes' },
610
+ blocks: [sizeChip]
611
+ });
612
+
613
+ const deepStrip = block({
614
+ id: 'strip',
615
+ type: 'group',
616
+ label: 'Colours',
617
+ isIterator: true,
618
+ iteratorDataPath: 'item.colors',
619
+ properties: { iteratorDataPath: 'item.colors' },
620
+ blocks: [
621
+ block({ id: 'swatch', type: 'group', label: 'Swatch', blocks: [sizes] })
622
+ ]
623
+ });
624
+
625
+ const outer = buildIteratorBlock({
626
+ iteratorBlock: iteratorOf(
627
+ block({ id: 'card', type: 'group', label: 'Card', blocks: [deepStrip] })
628
+ ),
629
+ items: [
630
+ {
631
+ tint: '#123456',
632
+ colors: [
633
+ { tint: '#654321', sizes: [{ tint: '#00a86b' }, { tint: '#e63946' }] }
634
+ ]
635
+ }
636
+ ]
637
+ });
638
+
639
+ const expandedStrip = buildIteratorBlock({
640
+ iteratorBlock: ((outer.blocks || [])[0].blocks || [])[0]
641
+ });
642
+ const expandedSizes = buildIteratorBlock({
643
+ iteratorBlock: ((expandedStrip.blocks || [])[0].blocks || [])[0]
644
+ });
645
+
646
+ expect(
647
+ (expandedSizes.blocks || []).map(
648
+ (clone) => (clone.styles as Record<string, unknown>).color
649
+ )
650
+ ).toEqual(['#00a86b', '#e63946']);
651
+ });
652
+
653
+ it('draws the row colours and carries nothing else from the row into the page', () => {
654
+ // Whatever the outer row hands the nested iterator rides along in the
655
+ // rendered tree until that iterator expands, and is serialized to the
656
+ // client with it. The strip must draw the colours and ship nothing the
657
+ // composition never displays — cost prices, warehouse notes, internal SKUs.
658
+ const [{ unexpandedStrip, strip: expanded }] = expandCards([
659
+ {
660
+ colors: colours,
661
+ sku: 'ABC-1',
662
+ cost_price: 42.5,
663
+ internal_note: 'do-not-ship'
664
+ }
665
+ ]);
666
+
667
+ expect(swatchColours(expanded)).toEqual(['#ff0000', '#0000ff']);
668
+ expect((expanded.blocks || []).map((clone) => clone.value)).toEqual([
669
+ 'Red',
670
+ 'Blue'
671
+ ]);
672
+
673
+ const shipped = JSON.stringify(unexpandedStrip);
674
+ expect(shipped).not.toContain('cost_price');
675
+ expect(shipped).not.toContain('do-not-ship');
676
+ expect(shipped).not.toContain('ABC-1');
677
+ });
678
+
679
+ it('never prints an outer-row field the swatch itself cannot answer', () => {
680
+ // `internal_note` is a product field, not a colour field. Resolved by the
681
+ // outer repeat it sticks: the nested repeat has nothing to overwrite it
682
+ // with, so a warehouse note the composition never meant to show is printed
683
+ // in every swatch.
684
+ const noteStrip = block({
685
+ id: 'strip',
686
+ type: 'group',
687
+ label: 'Colours',
688
+ isIterator: true,
689
+ iteratorDataPath: 'item.colors',
690
+ properties: { iteratorDataPath: 'item.colors' },
691
+ blocks: [
692
+ block({
693
+ id: 'swatch',
694
+ label: 'Swatch',
695
+ value: 'Colour',
696
+ properties: { dataBinding: 'item.internal_note' }
697
+ })
698
+ ]
699
+ });
700
+
701
+ const outer = buildIteratorBlock({
702
+ iteratorBlock: iteratorOf(
703
+ block({ id: 'card', type: 'group', label: 'Card', blocks: [noteStrip] })
704
+ ),
705
+ items: [{ internal_note: 'do-not-ship', colors: colours }]
706
+ });
707
+
708
+ const expanded = buildIteratorBlock({
709
+ iteratorBlock: ((outer.blocks || [])[0].blocks || [])[0]
710
+ });
711
+
712
+ expect((expanded.blocks || []).map((clone) => clone.value)).toEqual([
713
+ 'Colour',
714
+ 'Colour'
715
+ ]);
716
+ expect(JSON.stringify(expanded)).not.toContain('do-not-ship');
717
+ });
718
+
719
+ it('draws no swatch for a product whose colour list is empty', () => {
720
+ const [{ strip: expanded }] = expandCards([{ colors: [] }]);
721
+
722
+ expect(expanded.blocks).toEqual([]);
723
+ });
724
+
725
+ it('draws no swatch for a product that has no colours at all', () => {
726
+ // Next to cards that do have colours, one empty swatch is a visible
727
+ // artefact — the shopper reads it as a colour.
728
+ const [{ strip: expanded }] = expandCards([{ sku: 'ABC-1' }]);
729
+
730
+ expect(expanded.blocks).toEqual([]);
731
+ });
732
+
733
+ it('still draws the placeholder swatch on the canvas, where no row answered', () => {
734
+ // A card with no data behind it: the strip is inside a row that knows
735
+ // nothing, so nothing has said "this product has no colours" and the
736
+ // designer still needs a swatch to arrange.
737
+ const [{ strip: expanded }] = expandCards([{}]);
738
+
739
+ expect(expanded.blocks).toHaveLength(1);
740
+ });
741
+
742
+ it('still draws the placeholder row where there is no row to ask', () => {
743
+ // The canvas and a page whose data has not landed both reach the iterator
744
+ // with an empty list and no row behind it. That is not "this product has
745
+ // no colours", and it still owes the designer something to arrange.
746
+ const canvas = buildIteratorBlock({
747
+ iteratorBlock: iteratorOf(block({ id: 'swatch' }), {
748
+ iteratorDataPath: 'item.colors'
749
+ }),
750
+ items: []
751
+ });
752
+
753
+ expect(canvas.blocks).toHaveLength(1);
754
+ });
755
+
756
+ it('drops what it handed over once the nested repeat has expanded', () => {
757
+ const [{ strip: expanded }] = expandCards([{ colors: colours }]);
758
+
759
+ expect(JSON.stringify(expanded.properties)).not.toContain('#ff0000');
760
+ expect(expanded.properties.iteratorDataPath).toBe('item.colors');
761
+ });
762
+ });
763
+
764
+ describe('a repeat over a list of plain values', () => {
765
+ // `tags: ['new','sale']` is an ordinary catalogue shape. The repeat draws one
766
+ // clone per entry, so the entry has to be addressable — otherwise every clone
767
+ // prints the authored fallback and the surface is a dead end.
768
+ const expandChips = (
769
+ chip: Block,
770
+ row: Record<string, unknown>,
771
+ dataPath = 'item.tags'
772
+ ) => {
773
+ const chipStrip = block({
774
+ id: 'chips',
775
+ type: 'group',
776
+ label: 'Chips',
777
+ isIterator: true,
778
+ iteratorDataPath: dataPath,
779
+ properties: { iteratorDataPath: dataPath },
780
+ blocks: [chip]
781
+ });
782
+
783
+ const outer = buildIteratorBlock({
784
+ iteratorBlock: iteratorOf(
785
+ block({ id: 'card', type: 'group', label: 'Card', blocks: [chipStrip] })
786
+ ),
787
+ items: [row]
788
+ });
789
+
790
+ return buildIteratorBlock({
791
+ iteratorBlock: ((outer.blocks || [])[0].blocks || [])[0]
792
+ });
793
+ };
794
+
795
+ it('addresses the entry itself with a bare item binding', () => {
796
+ const expanded = expandChips(
797
+ block({ id: 'chip', value: 'Tag', properties: { dataBinding: 'item' } }),
798
+ { tags: ['new', 'sale'] }
799
+ );
800
+
801
+ expect((expanded.blocks || []).map((clone) => clone.value)).toEqual([
802
+ 'new',
803
+ 'sale'
804
+ ]);
805
+ });
806
+
807
+ it('reads the entry inside a longer string', () => {
808
+ const expanded = expandChips(
809
+ block({
810
+ id: 'chip',
811
+ value: 'Tag',
812
+ properties: { dataBinding: '#{{item}}' }
813
+ }),
814
+ { tags: ['new', 'sale'] }
815
+ );
816
+
817
+ expect((expanded.blocks || []).map((clone) => clone.value)).toEqual([
818
+ '#new',
819
+ '#sale'
820
+ ]);
821
+ });
822
+
823
+ it('reads a numeric entry, which carries no keys to count', () => {
824
+ const expanded = expandChips(
825
+ block({ id: 'chip', value: 'Size', properties: { dataBinding: 'item' } }),
826
+ { sizes: [38, 40] },
827
+ 'item.sizes'
828
+ );
829
+
830
+ expect((expanded.blocks || []).map((clone) => clone.value)).toEqual([
831
+ '38',
832
+ '40'
833
+ ]);
834
+ });
835
+
836
+ it('paints the entry when it is the colour itself', () => {
837
+ const expanded = expandChips(
838
+ block({ id: 'chip', styles: { 'background-color': '{{item}}' } }),
839
+ { swatches: ['#ff0000', '#0000ff'] },
840
+ 'item.swatches'
841
+ );
842
+
843
+ expect(
844
+ (expanded.blocks || []).map(
845
+ (clone) => (clone.styles as Record<string, unknown>)['background-color']
846
+ )
847
+ ).toEqual(['#ff0000', '#0000ff']);
848
+ });
849
+ });
850
+
851
+ describe('an iterator keeps the shape it was given', () => {
852
+ it('does not grow a properties key it never had', () => {
853
+ const iteratorBlock = {
854
+ id: 'iterator',
855
+ type: 'group',
856
+ label: 'Iterator',
857
+ order: 0,
858
+ isIterator: true,
859
+ blocks: [{ id: 'row', type: 'text', label: 'Row', order: 0, value: 'x' }]
860
+ } as unknown as Block;
861
+
862
+ const expanded = buildIteratorBlock({ iteratorBlock, items: [{}] });
863
+
864
+ expect('properties' in expanded).toBe(false);
865
+ });
866
+ });
867
+
868
+ describe('a breakpoint never inherits another breakpoint row value', () => {
869
+ const expandBadge = (
870
+ responsiveValue: Record<string, unknown>,
871
+ row: Record<string, unknown>
872
+ ) =>
873
+ (buildIteratorBlock({
874
+ iteratorBlock: iteratorOf(
875
+ block({ id: 'badge', styles: { 'background-color': responsiveValue } })
876
+ ),
877
+ items: [row]
878
+ }).blocks || [])[0];
879
+
880
+ it('unsets the whole property when one breakpoint cannot resolve', () => {
881
+ // `getResponsiveValue` answers a missing breakpoint with the desktop
882
+ // value, so leaving desktop behind paints mobile with the desktop row's
883
+ // colour — the wrong colour, not the unset the author would expect.
884
+ const clone = expandBadge(
885
+ {
886
+ desktop: '{{item.badgeColor}}',
887
+ mobile: '{{item.badgeColorMobile}}'
888
+ },
889
+ { badgeColor: '#00a86b' }
890
+ );
891
+
892
+ expect('background-color' in (clone.styles as Record<string, unknown>)).toBe(
893
+ false
894
+ );
895
+ expect(
896
+ getCSSStyles(clone.styles as Record<string, unknown>, undefined, 'mobile')
897
+ .backgroundColor
898
+ ).toBeUndefined();
899
+ expect(
900
+ getCSSStyles(clone.styles as Record<string, unknown>, undefined, 'desktop')
901
+ .backgroundColor
902
+ ).toBeUndefined();
903
+ });
904
+
905
+ it('keeps a fully resolved responsive value', () => {
906
+ const clone = expandBadge(
907
+ { desktop: '{{item.badgeColor}}', mobile: '{{item.badgeColorMobile}}' },
908
+ { badgeColor: '#00a86b', badgeColorMobile: '#e63946' }
909
+ );
910
+
911
+ expect((clone.styles as Record<string, unknown>)['background-color']).toEqual(
912
+ {
913
+ desktop: '#00a86b',
914
+ mobile: '#e63946'
915
+ }
916
+ );
917
+ });
918
+ });
919
+
920
+ describe('a style token addresses the row only when it says so', () => {
921
+ it('leaves a token that does not address the row to the layer that owns it', () => {
922
+ // `{{brandColor}}` is a section-level spelling. Resolving it off the row
923
+ // means a field that happens to share the name decides a site-wide colour.
924
+ const styles = { 'background-color': '{{brandColor}}' };
925
+ const clone = (buildIteratorBlock({
926
+ iteratorBlock: iteratorOf(block({ id: 'card', styles })),
927
+ items: [{ brandColor: '#00a86b' }]
928
+ }).blocks || [])[0];
929
+
930
+ expect(clone.styles).toBe(styles);
931
+ expect((clone.styles as Record<string, unknown>)['background-color']).toBe(
932
+ '{{brandColor}}'
933
+ );
934
+ });
935
+
936
+ it('unsets a value that mixes a row token with one it cannot resolve', () => {
937
+ const clone = (buildIteratorBlock({
938
+ iteratorBlock: iteratorOf(
939
+ block({ id: 'card', styles: { padding: '{{item.gap}}px {{siteGap}}' } })
940
+ ),
941
+ items: [{ gap: 8, siteGap: '12px' }]
942
+ }).blocks || [])[0];
943
+
944
+ expect('padding' in (clone.styles as Record<string, unknown>)).toBe(false);
945
+ });
946
+ });
947
+
948
+ describe('a binding can sit inside a longer string', () => {
949
+ const expandText = (
950
+ dataBinding: string,
951
+ row: Record<string, unknown>,
952
+ overrides: Partial<Block> = {}
953
+ ) =>
954
+ (buildIteratorBlock({
955
+ iteratorBlock: iteratorOf(
956
+ block({
957
+ id: 'label',
958
+ value: 'Authored',
959
+ properties: { dataBinding },
960
+ ...overrides
961
+ })
962
+ ),
963
+ items: [row]
964
+ }).blocks || [])[0];
965
+
966
+ it('keeps the literal text around each binding', () => {
967
+ expect(
968
+ expandText('Ürün Kodu {{item.base_code}}.{{item.color_code}}', {
969
+ base_code: 'ABC123',
970
+ color_code: '42'
971
+ }).value
972
+ ).toBe('Ürün Kodu ABC123.42');
973
+ });
974
+
975
+ it('reads a binding followed by literal text', () => {
976
+ expect(
977
+ expandText('{{item.colorCount}} Renk', { colorCount: 5 }).value
978
+ ).toBe('5 Renk');
979
+ });
980
+
981
+ it('writes an empty string for the token that has no data', () => {
982
+ expect(
983
+ expandText('{{item.brand}} {{item.model}}', { brand: 'Lacoste' }).value
984
+ ).toBe('Lacoste ');
985
+ });
986
+
987
+ it('keeps the authored value when nothing at all resolves', () => {
988
+ expect(
989
+ expandText('{{item.brand}} {{item.model}}', { other: 1 }).value
990
+ ).toBe('Authored');
991
+ });
992
+
993
+ it('interpolates into a link target too', () => {
994
+ const link = expandText(
995
+ '/p/{{item.slug}}-{{item.pk}}',
996
+ {
997
+ slug: 'sneaker',
998
+ pk: 42
999
+ },
1000
+ { type: 'link' }
1001
+ );
1002
+
1003
+ expect(link.properties.href).toBe('/p/sneaker-42');
1004
+ });
1005
+ });
1006
+
1007
+ describe('a value that is exactly one binding still returns the raw value', () => {
1008
+ const expandBoth = (
1009
+ path: string,
1010
+ row: Record<string, unknown>,
1011
+ type = 'text'
1012
+ ) => {
1013
+ const run = (dataBinding: string) =>
1014
+ (buildIteratorBlock({
1015
+ iteratorBlock: iteratorOf(
1016
+ block({ id: 'target', type, properties: { dataBinding } })
1017
+ ),
1018
+ items: [row]
1019
+ }).blocks || [])[0];
1020
+
1021
+ // The binding string itself is kept verbatim on the clone, so it is the
1022
+ // one thing that legitimately differs between the two spellings.
1023
+ const withoutBinding = (clone: Block) => ({
1024
+ ...clone,
1025
+ properties: { ...clone.properties, dataBinding: undefined }
1026
+ });
1027
+
1028
+ return {
1029
+ bare: withoutBinding(run(path)),
1030
+ wrapped: withoutBinding(run(`{{${path}}}`))
1031
+ };
1032
+ };
1033
+
1034
+ it.each([
1035
+ ['a string', 'item.name', { name: 'Sneaker' }, 'text'],
1036
+ ['a number', 'item.stock', { stock: 0 }, 'text'],
1037
+ ['a boolean', 'item.inStock', { inStock: false }, 'text'],
1038
+ [
1039
+ 'an image object',
1040
+ 'item.image',
1041
+ { image: { url: 'https://cdn.example.com/a.jpg' } },
1042
+ 'image'
1043
+ ],
1044
+ ['a price', 'item.price', { price: 199.9, currency_type: 'TRY' }, 'text']
1045
+ ])('%s behaves the same wrapped as bare', (_label, path, row, type) => {
1046
+ const { bare, wrapped } = expandBoth(
1047
+ path as string,
1048
+ row as Record<string, unknown>,
1049
+ type as string
1050
+ );
1051
+
1052
+ expect(wrapped).toEqual(bare);
1053
+ });
1054
+ });
1055
+
1056
+ describe('section-level bindings interpolate too', () => {
1057
+ const sectionDataSource = {
1058
+ type: 'page-context',
1059
+ details: { pageContext: { pageType: 'product', rootPath: 'product' } }
1060
+ };
1061
+
1062
+ it('mixes literal text with page-context values', () => {
1063
+ const resolved = resolveBlockBindings({
1064
+ block: block({
1065
+ id: 'greeting',
1066
+ value: 'Fallback',
1067
+ properties: {
1068
+ dataBinding: '{{customer.firstName}} için {{product.title}}'
1069
+ }
1070
+ }),
1071
+ sectionDataSource,
1072
+ pageContext: {
1073
+ customer: { firstName: 'Esma' },
1074
+ product: { title: 'Sneaker' }
1075
+ }
1076
+ });
1077
+
1078
+ expect(resolved.value).toBe('Esma için Sneaker');
1079
+ });
1080
+
1081
+ it('leaves a row-scoped template to the iterator', () => {
1082
+ // The iterator has already written the row's value; re-resolving against
1083
+ // the section would blank it back out.
1084
+ const resolved = resolveBlockBindings({
1085
+ block: block({
1086
+ id: 'code',
1087
+ value: 'Ürün Kodu ABC123',
1088
+ properties: { dataBinding: 'Ürün Kodu {{item.base_code}}' }
1089
+ }),
1090
+ sectionDataSource,
1091
+ pageContext: { base_code: 'WRONG' }
1092
+ });
1093
+
1094
+ expect(resolved.value).toBe('Ürün Kodu ABC123');
1095
+ });
1096
+ });
1097
+
1098
+ describe('a mistyped row-scoped list path is diagnosable', () => {
1099
+ const warnSpy = () => jest.spyOn(console, 'warn').mockImplementation(() => undefined);
1100
+
1101
+ const productWithScalarColour = {
1102
+ type: 'collection',
1103
+ details: { collection: { data: [{ title: 'Tee', colour: 'red' }] } }
1104
+ };
1105
+
1106
+ const stripBoundTo = (path: string): Block =>
1107
+ iteratorOf(
1108
+ block({
1109
+ id: 'card',
1110
+ type: 'group',
1111
+ blocks: [
1112
+ block({
1113
+ id: 'strip',
1114
+ type: 'group',
1115
+ isIterator: true,
1116
+ iteratorDataPath: path,
1117
+ blocks: [block({ id: 'swatch', value: 'Colour' })]
1118
+ })
1119
+ ]
1120
+ })
1121
+ );
1122
+
1123
+ /** Same two-pass expansion theme-section performs. */
1124
+ const expand = (root: Block): Block => {
1125
+ const resolved = root.isIterator
1126
+ ? buildIteratorBlock({
1127
+ iteratorBlock: root,
1128
+ sectionDataSource: productWithScalarColour
1129
+ })
1130
+ : root;
1131
+ if (!resolved.blocks?.length) return resolved;
1132
+ return { ...resolved, blocks: resolved.blocks.map(expand) };
1133
+ };
1134
+
1135
+ it('warns when the row answers the path with something that is not a list', () => {
1136
+ // `item.colour` is a string, not `item.colours`. The repeat now draws no
1137
+ // rows at all, so without a warning the author sees an empty strip and no
1138
+ // clue that the path is the problem — the same typo against a SECTION path
1139
+ // has always been reported.
1140
+ const warn = warnSpy();
1141
+ try {
1142
+ expand(stripBoundTo('item.colour'));
1143
+ expect(warn).toHaveBeenCalledWith(
1144
+ expect.stringContaining('item.colour')
1145
+ );
1146
+ } finally {
1147
+ warn.mockRestore();
1148
+ }
1149
+ });
1150
+
1151
+ it('stays silent when the row simply has no such field yet', () => {
1152
+ // Absent is ambiguous with data that has not landed, exactly as the
1153
+ // section-scoped path treats an unresolved lookup.
1154
+ const warn = warnSpy();
1155
+ try {
1156
+ expand(stripBoundTo('item.not_here'));
1157
+ expect(warn).not.toHaveBeenCalled();
1158
+ } finally {
1159
+ warn.mockRestore();
1160
+ }
1161
+ });
1162
+
1163
+ it('stays silent when the row answers with a real list', () => {
1164
+ const warn = warnSpy();
1165
+ try {
1166
+ buildIteratorBlock({
1167
+ iteratorBlock: stripBoundTo('item.colours'),
1168
+ sectionDataSource: {
1169
+ type: 'collection',
1170
+ details: { collection: { data: [{ colours: [{ hex: '#ff0000' }] }] } }
1171
+ }
1172
+ });
1173
+ expect(warn).not.toHaveBeenCalled();
1174
+ } finally {
1175
+ warn.mockRestore();
1176
+ }
1177
+ });
1178
+ });