@akinon/pz-theme 2.0.36-rc.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 +8 -31
  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
@@ -1,9 +1,11 @@
1
1
  import {
2
+ applyVisibilityRulesToSections,
2
3
  evaluateVisibilityRules,
3
4
  normalizeVisibilityRules,
4
5
  VisibilityRuleContext
5
6
  } from './visibility-rules';
6
7
  import { resetDevWarnCacheForTests } from './dev-warn';
8
+ import type { Section } from '../theme-section';
7
9
 
8
10
  const makeContext = (
9
11
  overrides: Partial<VisibilityRuleContext> = {}
@@ -156,6 +158,583 @@ describe('evaluateVisibilityRules', () => {
156
158
  });
157
159
  });
158
160
 
161
+ describe('data conditions', () => {
162
+ const pageContext = {
163
+ product: {
164
+ sku: 'TS-1',
165
+ stock: 3,
166
+ inStock: true,
167
+ attributes: { gender: 'Women', category: 'tops' },
168
+ tags: ['sale', 'new-season'],
169
+ images: [{ url: 'https://cdn.example.com/a.jpg' }]
170
+ },
171
+ listing: { totalCount: 0 },
172
+ category: { slug: 'kadin-ust-giyim' }
173
+ };
174
+
175
+ const evaluate = (
176
+ conditions: unknown,
177
+ overrides: Partial<VisibilityRuleContext> = {},
178
+ extraRules: Record<string, unknown> = {}
179
+ ) =>
180
+ evaluateVisibilityRules(
181
+ { enabled: true, dataConditions: conditions, ...extraRules },
182
+ makeContext({ pageContext, ...overrides })
183
+ );
184
+
185
+ it('the size guide picks its partial by gender AND category (default match mode)', () => {
186
+ expect(
187
+ evaluate([
188
+ { path: 'product.attributes.gender', operator: 'equals', value: 'women' },
189
+ { path: 'product.attributes.category', operator: 'equals', value: 'tops' }
190
+ ])
191
+ ).toBe(true);
192
+
193
+ expect(
194
+ evaluate([
195
+ { path: 'product.attributes.gender', operator: 'equals', value: 'women' },
196
+ { path: 'product.attributes.category', operator: 'equals', value: 'shoes' }
197
+ ])
198
+ ).toBe(false);
199
+ });
200
+
201
+ it('matches text case-insensitively, since the API says "Women"', () => {
202
+ expect(
203
+ evaluate([
204
+ { path: 'product.attributes.gender', operator: 'equals', value: 'WOMEN' }
205
+ ])
206
+ ).toBe(true);
207
+ });
208
+
209
+ it('drives the no-results page off a numeric zero', () => {
210
+ expect(
211
+ evaluate([{ path: 'listing.totalCount', operator: 'equals', value: '0' }])
212
+ ).toBe(true);
213
+ expect(
214
+ evaluate([{ path: 'listing.totalCount', operator: 'gt', value: '0' }])
215
+ ).toBe(false);
216
+ });
217
+
218
+ it('compares numbers as numbers, not as text', () => {
219
+ // '3' < '10' is false as text; the block depends on it being true.
220
+ expect(
221
+ evaluate([{ path: 'product.stock', operator: 'lt', value: '10' }])
222
+ ).toBe(true);
223
+ expect(
224
+ evaluate([{ path: 'product.stock', operator: 'gte', value: '3' }])
225
+ ).toBe(true);
226
+ expect(
227
+ evaluate([{ path: 'product.stock', operator: 'lte', value: '2' }])
228
+ ).toBe(false);
229
+ });
230
+
231
+ it('a numeric comparison against non-numeric data hides rather than guesses', () => {
232
+ expect(
233
+ evaluate([{ path: 'product.sku', operator: 'gt', value: '5' }])
234
+ ).toBe(false);
235
+ expect(
236
+ evaluate([{ path: 'product.stock', operator: 'gt', value: 'many' }])
237
+ ).toBe(false);
238
+ });
239
+
240
+ it('drives attribute layout off existence, including array membership', () => {
241
+ expect(
242
+ evaluate([{ path: 'product.attributes.gender', operator: 'exists' }])
243
+ ).toBe(true);
244
+ expect(
245
+ evaluate([{ path: 'product.attributes.fit', operator: 'exists' }])
246
+ ).toBe(false);
247
+ expect(
248
+ evaluate([{ path: 'product.tags', operator: 'contains', value: 'sale' }])
249
+ ).toBe(true);
250
+ expect(
251
+ evaluate([{ path: 'product.tags', operator: 'contains', value: 'outlet' }])
252
+ ).toBe(false);
253
+ });
254
+
255
+ it('walks indexes the same way bindings do', () => {
256
+ expect(
257
+ evaluate([{ path: 'product.images[0].url', operator: 'contains', value: 'cdn' }])
258
+ ).toBe(true);
259
+ expect(
260
+ evaluate([{ path: 'product.images[4].url', operator: 'exists' }])
261
+ ).toBe(false);
262
+ });
263
+
264
+ describe('[] — the shape the field picker offers for an array', () => {
265
+ // The picker walks arrays as `listing.products[].price`, so the evaluator
266
+ // has to answer for that shape or every array field it lists is a rule
267
+ // that hides the block forever.
268
+ const listingContext = {
269
+ listing: {
270
+ products: [
271
+ { sku: 'A', stock: 0, badge: '', price: { value: 10 } },
272
+ { sku: 'B', stock: 4, price: { value: 20 } }
273
+ ]
274
+ },
275
+ product: {
276
+ images: [
277
+ { url: 'https://cdn.example.com/a.jpg' },
278
+ { url: 'https://cdn.example.com/b.jpg' }
279
+ ],
280
+ tags: ['sale', 'new-season']
281
+ }
282
+ };
283
+
284
+ const evaluateOn = (
285
+ conditions: unknown,
286
+ pageContextOverride: Record<string, unknown> = listingContext,
287
+ extraRules: Record<string, unknown> = {}
288
+ ) =>
289
+ evaluateVisibilityRules(
290
+ { enabled: true, dataConditions: conditions, ...extraRules },
291
+ makeContext({ pageContext: pageContextOverride })
292
+ );
293
+
294
+ it('means "any element matches" for a comparison', () => {
295
+ expect(
296
+ evaluateOn([
297
+ { path: 'listing.products[].stock', operator: 'gt', value: '0' }
298
+ ])
299
+ ).toBe(true);
300
+ expect(
301
+ evaluateOn([
302
+ { path: 'listing.products[].stock', operator: 'gt', value: '99' }
303
+ ])
304
+ ).toBe(false);
305
+ expect(
306
+ evaluateOn([
307
+ { path: 'product.images[].url', operator: 'contains', value: 'cdn' }
308
+ ])
309
+ ).toBe(true);
310
+ expect(
311
+ evaluateOn([{ path: 'product.tags[]', operator: 'equals', value: 'sale' }])
312
+ ).toBe(true);
313
+ });
314
+
315
+ it('means "any element has it" for exists, and its exact dual for notExists', () => {
316
+ expect(
317
+ evaluateOn([{ path: 'listing.products[].price.value', operator: 'exists' }])
318
+ ).toBe(true);
319
+ // Only the first product carries `badge`, and it is empty: no element has one.
320
+ expect(
321
+ evaluateOn([{ path: 'listing.products[].badge', operator: 'exists' }])
322
+ ).toBe(false);
323
+ expect(
324
+ evaluateOn([{ path: 'listing.products[].badge', operator: 'notExists' }])
325
+ ).toBe(true);
326
+ expect(
327
+ evaluateOn([{ path: 'listing.products[].sku', operator: 'notExists' }])
328
+ ).toBe(false);
329
+ });
330
+
331
+ it('hides when the array is empty or missing', () => {
332
+ expect(
333
+ evaluateOn([{ path: 'listing.products[].sku', operator: 'exists' }], {
334
+ listing: { products: [] }
335
+ })
336
+ ).toBe(false);
337
+ expect(
338
+ evaluateOn([{ path: 'listing.products[].sku', operator: 'exists' }], {
339
+ listing: {}
340
+ })
341
+ ).toBe(false);
342
+ });
343
+
344
+ it('walks nested arrays', () => {
345
+ expect(
346
+ evaluateOn(
347
+ [{ path: 'listing.rows[][].sku', operator: 'equals', value: 'B' }],
348
+ { listing: { rows: [[{ sku: 'A' }], [{ sku: 'B' }]] } }
349
+ )
350
+ ).toBe(true);
351
+ });
352
+
353
+ it('reaches the same answer on the server and after client serialization', () => {
354
+ const rules = {
355
+ enabled: true,
356
+ dataConditions: [
357
+ { path: 'listing.products[].stock', operator: 'gt', value: '0' },
358
+ { path: 'listing.products[].badge', operator: 'notExists' }
359
+ ]
360
+ };
361
+
362
+ const serverAnswer = evaluateVisibilityRules(
363
+ rules,
364
+ makeContext({ pageContext: listingContext })
365
+ );
366
+ const clientAnswer = evaluateVisibilityRules(
367
+ rules,
368
+ makeContext({
369
+ pageContext: JSON.parse(JSON.stringify(listingContext))
370
+ })
371
+ );
372
+
373
+ expect(serverAnswer).toBe(true);
374
+ expect(clientAnswer).toBe(serverAnswer);
375
+ });
376
+ });
377
+
378
+ describe('a path the evaluator cannot read at all', () => {
379
+ beforeEach(() => {
380
+ resetDevWarnCacheForTests();
381
+ jest.spyOn(console, 'warn').mockImplementation(() => undefined);
382
+ });
383
+
384
+ afterEach(() => {
385
+ (console.warn as jest.Mock).mockRestore();
386
+ });
387
+
388
+ const unreadable = [
389
+ 'product.images[*].url',
390
+ 'product.attributes.',
391
+ '.product.sku',
392
+ 'product..sku',
393
+ 'product[attributes].gender',
394
+ 'product.__proto__.polluted',
395
+ 'product.constructor.name'
396
+ ];
397
+
398
+ it('never satisfies a condition — not even notExists', () => {
399
+ unreadable.forEach(path => {
400
+ expect(evaluate([{ path, operator: 'exists' }])).toBe(false);
401
+ expect(evaluate([{ path, operator: 'notExists' }])).toBe(false);
402
+ expect(evaluate([{ path, operator: 'equals', value: 'women' }])).toBe(
403
+ false
404
+ );
405
+ });
406
+ });
407
+
408
+ it('says so in development instead of failing silently', () => {
409
+ evaluate([{ path: 'product.images[*].url', operator: 'exists' }]);
410
+
411
+ expect(console.warn).toHaveBeenCalledWith(
412
+ '[pz-theme:visibilityRules] Cannot read the field path "product.images[*].url"; the condition never passes (content stays hidden)'
413
+ );
414
+ });
415
+ });
416
+
417
+ describe('a path that reaches past the payload onto the prototype', () => {
418
+ // Naming __proto__/constructor/prototype is a blocklist, and the rest of
419
+ // Object.prototype answers the same way: every one of these resolves to a
420
+ // function, which is neither null nor empty, so `exists` would pass on
421
+ // every page ever rendered.
422
+ const inherited = [
423
+ 'product.toString',
424
+ 'product.valueOf',
425
+ 'product.hasOwnProperty',
426
+ 'product.isPrototypeOf',
427
+ 'product.toLocaleString',
428
+ 'product.propertyIsEnumerable'
429
+ ];
430
+
431
+ it('reads as absent rather than as a value present on every page', () => {
432
+ inherited.forEach(path => {
433
+ expect(evaluate([{ path, operator: 'exists' }])).toBe(false);
434
+ // Absent, not unreadable: the shape is fine, the payload lacks it.
435
+ expect(evaluate([{ path, operator: 'notExists' }])).toBe(true);
436
+ expect(evaluate([{ path, operator: 'equals', value: 'x' }])).toBe(false);
437
+ });
438
+ });
439
+
440
+ it('ignores a value inherited from a prototype the server would not serialize', () => {
441
+ const product = Object.create({ leaked: 'yes' }) as Record<string, unknown>;
442
+ product.sku = 'TS-1';
443
+
444
+ expect(
445
+ evaluateVisibilityRules(
446
+ {
447
+ enabled: true,
448
+ dataConditions: [{ path: 'product.leaked', operator: 'exists' }]
449
+ },
450
+ makeContext({ pageContext: { product } })
451
+ )
452
+ ).toBe(false);
453
+ expect(
454
+ evaluateVisibilityRules(
455
+ {
456
+ enabled: true,
457
+ dataConditions: [{ path: 'product.sku', operator: 'exists' }]
458
+ },
459
+ makeContext({ pageContext: { product } })
460
+ )
461
+ ).toBe(true);
462
+ });
463
+
464
+ it('still reads own members that share a name with a built-in', () => {
465
+ expect(
466
+ evaluateVisibilityRules(
467
+ {
468
+ enabled: true,
469
+ dataConditions: [
470
+ { path: 'product.images.length', operator: 'equals', value: '2' }
471
+ ]
472
+ },
473
+ makeContext({ pageContext: { product: { images: ['a', 'b'] } } })
474
+ )
475
+ ).toBe(true);
476
+ });
477
+ });
478
+
479
+ describe('a path that does not resolve', () => {
480
+ it('hides for every comparison operator — never "visible by accident"', () => {
481
+ const operators = ['equals', 'contains', 'gt', 'gte', 'lt', 'lte'];
482
+ operators.forEach(operator => {
483
+ expect(
484
+ evaluate([{ path: 'product.attributes.gendr', operator, value: '1' }])
485
+ ).toBe(false);
486
+ });
487
+ });
488
+
489
+ it('hides for notEquals too: absence is not a different value', () => {
490
+ expect(
491
+ evaluate([
492
+ { path: 'product.attributes.gendr', operator: 'notEquals', value: 'men' }
493
+ ])
494
+ ).toBe(false);
495
+ });
496
+
497
+ it('answers true only for notExists, the operator that asks about absence', () => {
498
+ expect(
499
+ evaluate([{ path: 'product.sizeChart', operator: 'notExists' }])
500
+ ).toBe(true);
501
+ });
502
+
503
+ it('treats an empty object as absent — no attributes means no attribute', () => {
504
+ expect(
505
+ evaluateVisibilityRules(
506
+ {
507
+ enabled: true,
508
+ dataConditions: [{ path: 'product.attributes', operator: 'exists' }]
509
+ },
510
+ makeContext({ pageContext: { product: { attributes: {} } } })
511
+ )
512
+ ).toBe(false);
513
+ });
514
+
515
+ it('treats an empty string and an empty array as absent', () => {
516
+ expect(
517
+ evaluateVisibilityRules(
518
+ {
519
+ enabled: true,
520
+ dataConditions: [{ path: 'product.badge', operator: 'exists' }]
521
+ },
522
+ makeContext({ pageContext: { product: { badge: ' ', tags: [] } } })
523
+ )
524
+ ).toBe(false);
525
+ expect(
526
+ evaluateVisibilityRules(
527
+ {
528
+ enabled: true,
529
+ dataConditions: [{ path: 'product.tags', operator: 'notExists' }]
530
+ },
531
+ makeContext({ pageContext: { product: { badge: '', tags: [] } } })
532
+ )
533
+ ).toBe(true);
534
+ });
535
+
536
+ it('hides when there is no page context at all (wrong page type)', () => {
537
+ expect(
538
+ evaluateVisibilityRules(
539
+ {
540
+ enabled: true,
541
+ dataConditions: [
542
+ { path: 'product.attributes.gender', operator: 'equals', value: 'women' }
543
+ ]
544
+ },
545
+ makeContext()
546
+ )
547
+ ).toBe(false);
548
+ });
549
+ });
550
+
551
+ it('supports any-of for per-category banners', () => {
552
+ const conditions = [
553
+ { path: 'category.slug', operator: 'equals', value: 'erkek' },
554
+ { path: 'category.slug', operator: 'equals', value: 'kadin-ust-giyim' }
555
+ ];
556
+
557
+ expect(evaluate(conditions, {}, { dataMatchMode: 'any' })).toBe(true);
558
+ expect(evaluate(conditions)).toBe(false);
559
+ });
560
+
561
+ it('ignores an unfinished row instead of blanking the block', () => {
562
+ expect(evaluate([{ path: ' ', operator: 'equals', value: 'women' }])).toBe(
563
+ true
564
+ );
565
+ expect(evaluate([{ operator: 'exists' }, 'nonsense', null])).toBe(true);
566
+ });
567
+
568
+ it('is skipped on the designer canvas so a hidden block stays styleable', () => {
569
+ const rules = {
570
+ enabled: true,
571
+ dataConditions: [
572
+ { path: 'product.attributes.gender', operator: 'equals', value: 'men' }
573
+ ]
574
+ };
575
+
576
+ expect(evaluateVisibilityRules(rules, makeContext({ pageContext }))).toBe(
577
+ false
578
+ );
579
+ expect(
580
+ evaluateVisibilityRules(
581
+ rules,
582
+ makeContext({ pageContext, isDesigner: true })
583
+ )
584
+ ).toBe(true);
585
+ });
586
+
587
+ it('still applies visitor conditions on the canvas', () => {
588
+ expect(
589
+ evaluateVisibilityRules(
590
+ {
591
+ enabled: true,
592
+ allowedBreakpoints: ['mobile'],
593
+ dataConditions: [{ path: 'product.sku', operator: 'exists' }]
594
+ },
595
+ makeContext({ pageContext, isDesigner: true, breakpoint: 'desktop' })
596
+ )
597
+ ).toBe(false);
598
+ });
599
+
600
+ it('reaches the same answer on the server and after client serialization', () => {
601
+ const rules = {
602
+ enabled: true,
603
+ dataMatchMode: 'all',
604
+ dataConditions: [
605
+ { path: 'product.attributes.gender', operator: 'equals', value: 'women' },
606
+ { path: 'product.stock', operator: 'gt', value: '0' },
607
+ { path: 'product.tags', operator: 'contains', value: 'sale' },
608
+ { path: 'product.sizeChart', operator: 'notExists' }
609
+ ]
610
+ };
611
+
612
+ // The client receives the page context as serialized props; a rule that
613
+ // answered differently after that round trip would hydrate a different tree.
614
+ const serverAnswer = evaluateVisibilityRules(
615
+ rules,
616
+ makeContext({ pageContext })
617
+ );
618
+ const clientAnswer = evaluateVisibilityRules(
619
+ rules,
620
+ makeContext({ pageContext: JSON.parse(JSON.stringify(pageContext)) })
621
+ );
622
+
623
+ expect(serverAnswer).toBe(true);
624
+ expect(clientAnswer).toBe(serverAnswer);
625
+ });
626
+
627
+ it('normalizes conditions to a stable shape', () => {
628
+ const rules = normalizeVisibilityRules({
629
+ enabled: true,
630
+ dataMatchMode: 'weird',
631
+ dataConditions: [
632
+ { path: ' product.sku ', operator: 'unsupported', value: 12 },
633
+ { path: '' }
634
+ ]
635
+ });
636
+
637
+ expect(rules.dataMatchMode).toBe('all');
638
+ expect(rules.dataConditions).toEqual([
639
+ { path: 'product.sku', operator: 'exists', value: '12' }
640
+ ]);
641
+ });
642
+ });
643
+
644
+ describe('nothing that renders today changes', () => {
645
+ const legacyRuleShapes: unknown[] = [
646
+ undefined,
647
+ null,
648
+ {},
649
+ { enabled: false, authState: 'authenticated' },
650
+ { enabled: true },
651
+ { enabled: true, authState: 'guest' },
652
+ { enabled: true, authState: 'authenticated' },
653
+ { enabled: true, allowedBreakpoints: ['mobile'] },
654
+ { enabled: true, allowedBreakpoints: ['desktop'] },
655
+ { enabled: true, allowedLocales: ['en'] },
656
+ { enabled: true, allowedLocales: ['tr'] },
657
+ { enabled: true, allowedCurrencies: ['usd'] },
658
+ { enabled: true, allowedCurrencies: ['TRY'] },
659
+ { enabled: true, pathnameMatchType: 'equals', pathnameValue: '/' },
660
+ { enabled: true, pathnameMatchType: 'startsWith', pathnameValue: '/account' },
661
+ { enabled: true, pathnameMatchType: 'contains', pathnameValue: 'x' },
662
+ { enabled: true, pathnameMatchType: 'equals', pathnameValue: '' },
663
+ { enabled: true, queryParamKey: 'campaign' },
664
+ { enabled: true, queryParamKey: 'campaign', queryParamValue: 'x' },
665
+ 'malformed',
666
+ [1, 2, 3]
667
+ ];
668
+
669
+ it('a rule authored before data conditions answers the same with data present', () => {
670
+ // Page context and designer mode are new inputs; for every rule shape that
671
+ // exists in the wild today they must be inert.
672
+ legacyRuleShapes.forEach(rules => {
673
+ const before = evaluateVisibilityRules(rules, makeContext());
674
+ const withData = evaluateVisibilityRules(
675
+ rules,
676
+ makeContext({
677
+ pageContext: { product: { sku: 'X' }, listing: { totalCount: 12 } }
678
+ })
679
+ );
680
+ const withDesigner = evaluateVisibilityRules(
681
+ rules,
682
+ makeContext({
683
+ pageContext: { product: { sku: 'X' } },
684
+ isDesigner: true
685
+ })
686
+ );
687
+
688
+ expect([withData, withDesigner]).toEqual([before, before]);
689
+ });
690
+ });
691
+
692
+ it('leaves the section tree byte-identical when no rule opts in', () => {
693
+ const sections: Section[] = [
694
+ {
695
+ id: 'section-1',
696
+ type: 'default',
697
+ name: 'hero',
698
+ label: 'Hero',
699
+ order: 0,
700
+ hidden: false,
701
+ properties: {},
702
+ styles: {},
703
+ blocks: [
704
+ {
705
+ id: 'block-1',
706
+ type: 'text',
707
+ label: 'Title',
708
+ order: 0,
709
+ hidden: false,
710
+ properties: {},
711
+ styles: {},
712
+ blocks: [
713
+ {
714
+ id: 'block-1-1',
715
+ type: 'text',
716
+ label: 'Nested',
717
+ order: 0,
718
+ hidden: true,
719
+ properties: {},
720
+ styles: {},
721
+ blocks: []
722
+ }
723
+ ]
724
+ }
725
+ ]
726
+ }
727
+ ] as unknown as Section[];
728
+
729
+ const applied = applyVisibilityRulesToSections(
730
+ sections,
731
+ makeContext({ pageContext: { product: { sku: 'X' } } })
732
+ );
733
+
734
+ expect(JSON.stringify(applied)).toBe(JSON.stringify(sections));
735
+ });
736
+ });
737
+
159
738
  describe('normalizeVisibilityRules — malformed input', () => {
160
739
  let warnSpy: jest.SpyInstance;
161
740