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

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 (78) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,672 @@
1
+ import { buildIteratorBlock } from '../utils/iterator-utils';
2
+ import {
3
+ isVisibleForItem,
4
+ readRowVisibility
5
+ } from '../utils/row-visibility';
6
+ import type { Block } from '../theme-block';
7
+
8
+ const rowWith = (properties: Record<string, unknown>): Block =>
9
+ ({
10
+ id: 'card',
11
+ type: 'group',
12
+ label: 'Card',
13
+ order: 0,
14
+ hidden: false,
15
+ value: '',
16
+ properties: {},
17
+ styles: {},
18
+ blocks: [
19
+ {
20
+ id: 'badge',
21
+ type: 'text',
22
+ label: 'Badge',
23
+ order: 0,
24
+ hidden: false,
25
+ value: '',
26
+ properties,
27
+ styles: {},
28
+ blocks: []
29
+ }
30
+ ]
31
+ } as Block);
32
+
33
+ const expand = (properties: Record<string, unknown>, items: Record<string, unknown>[]) =>
34
+ buildIteratorBlock({
35
+ iteratorBlock: {
36
+ id: 'grid',
37
+ type: 'product-grid',
38
+ label: 'Grid',
39
+ order: 0,
40
+ hidden: false,
41
+ value: '',
42
+ properties: {},
43
+ styles: {},
44
+ isIterator: true,
45
+ blocks: [rowWith(properties)]
46
+ } as Block,
47
+ items,
48
+ isDesigner: false
49
+ });
50
+
51
+ const badgeHidden = (result: ReturnType<typeof expand>) =>
52
+ (result.blocks || []).map((clone) => (clone.blocks || [])[0]?.hidden);
53
+
54
+ describe('reading the rule', () => {
55
+ it('treats an unset or always rule as no rule', () => {
56
+ expect(readRowVisibility({})).toBeNull();
57
+ expect(readRowVisibility({ visibleWhenMode: 'always' })).toBeNull();
58
+ });
59
+
60
+ it('unwraps the responsive wrapper', () => {
61
+ const rule = readRowVisibility({
62
+ visibleWhenMode: { desktop: 'not-empty' },
63
+ visibleWhenField: { desktop: 'item.badge' }
64
+ });
65
+
66
+ expect(rule).toEqual({ mode: 'not-empty', field: 'item.badge', value: '' });
67
+ });
68
+ });
69
+
70
+ describe('evaluating the rule', () => {
71
+ const rule = (mode: string, field = 'item.badge', value?: string) =>
72
+ ({ mode, field, value } as never);
73
+
74
+ it('shows when the field has content', () => {
75
+ expect(isVisibleForItem(rule('not-empty'), { badge: 'OUTLET' })).toBe(true);
76
+ expect(isVisibleForItem(rule('not-empty'), { badge: '' })).toBe(false);
77
+ expect(isVisibleForItem(rule('not-empty'), {})).toBe(false);
78
+ expect(isVisibleForItem(rule('not-empty'), { badge: [] })).toBe(false);
79
+ });
80
+
81
+ it('supports the inverse', () => {
82
+ expect(isVisibleForItem(rule('empty'), { badge: '' })).toBe(true);
83
+ expect(isVisibleForItem(rule('empty'), { badge: 'OUTLET' })).toBe(false);
84
+ });
85
+
86
+ it('reads booleans the way commerce sends them', () => {
87
+ expect(isVisibleForItem(rule('truthy', 'item.hasDiscount'), { hasDiscount: true })).toBe(true);
88
+ expect(isVisibleForItem(rule('truthy', 'item.hasDiscount'), { hasDiscount: false })).toBe(false);
89
+ // Commerce and the editor both stringify booleans in places.
90
+ expect(isVisibleForItem(rule('truthy', 'item.hasDiscount'), { hasDiscount: 'false' })).toBe(false);
91
+ });
92
+
93
+ it('compares a value', () => {
94
+ expect(isVisibleForItem(rule('equals', 'item.type', 'outlet'), { type: 'outlet' })).toBe(true);
95
+ expect(isVisibleForItem(rule('not-equals', 'item.type', 'outlet'), { type: 'outlet' })).toBe(false);
96
+ });
97
+
98
+ it('walks a nested path like the bindings do', () => {
99
+ expect(
100
+ isVisibleForItem(rule('not-empty', 'item.basketOffers[0].label'), {
101
+ basketOffers: [{ label: 'SEPETTE 2.999 TL' }]
102
+ })
103
+ ).toBe(true);
104
+ });
105
+
106
+ it('shows the element when the rule is unusable', () => {
107
+ // A misconfigured condition must never silently empty a page.
108
+ expect(isVisibleForItem(rule('not-empty', ''), {})).toBe(true);
109
+ expect(isVisibleForItem(null, {})).toBe(true);
110
+ });
111
+ });
112
+
113
+ describe('per item inside a repeated row', () => {
114
+ it('hides the element only on the items that fail', () => {
115
+ const result = expand(
116
+ { visibleWhenMode: 'not-empty', visibleWhenField: 'item.badge' },
117
+ [{ badge: 'OUTLET' }, { badge: '' }, { badge: 'ONLINE ÖZEL' }]
118
+ );
119
+
120
+ // The passing rows keep the template's own value, the failing one flips.
121
+ expect(badgeHidden(result)).toEqual([false, true, false]);
122
+ });
123
+
124
+ it('leaves rows untouched when no rule is set', () => {
125
+ const result = expand({}, [{ badge: 'OUTLET' }, { badge: '' }]);
126
+
127
+ expect(badgeHidden(result)).toEqual([false, false]);
128
+ });
129
+
130
+ it('does not hide anything in the designer placeholder rows', () => {
131
+ // With no data every field is empty; hiding would leave the canvas blank
132
+ // and the designer with nothing to arrange.
133
+ const result = expand(
134
+ { visibleWhenMode: 'not-empty', visibleWhenField: 'item.badge' },
135
+ [{}, {}]
136
+ );
137
+
138
+ expect(badgeHidden(result)).toEqual([false, false]);
139
+ });
140
+ });
141
+
142
+ describe('per entry inside a nested repeat', () => {
143
+ // A colour strip inside a product card: the rule on a swatch names a colour
144
+ // field, so it has to be answered by the colour it is drawn for. Answered by
145
+ // the product instead it decides every swatch at once — and once the outer
146
+ // row has written `hidden`, the nested repeat has no way to take it back.
147
+ const swatchStrip = (properties: Record<string, unknown>): Block =>
148
+ ({
149
+ id: 'strip',
150
+ type: 'group',
151
+ label: 'Colours',
152
+ order: 0,
153
+ hidden: false,
154
+ value: '',
155
+ isIterator: true,
156
+ iteratorDataPath: 'item.colors',
157
+ properties: { iteratorDataPath: 'item.colors' },
158
+ styles: {},
159
+ blocks: [
160
+ {
161
+ id: 'swatch',
162
+ type: 'text',
163
+ label: 'Swatch',
164
+ order: 0,
165
+ hidden: false,
166
+ value: '',
167
+ properties,
168
+ styles: {},
169
+ blocks: []
170
+ }
171
+ ]
172
+ } as Block);
173
+
174
+ const expandStrip = (
175
+ properties: Record<string, unknown>,
176
+ row: Record<string, unknown>
177
+ ) => {
178
+ const outer = buildIteratorBlock({
179
+ iteratorBlock: {
180
+ id: 'grid',
181
+ type: 'group',
182
+ label: 'Grid',
183
+ order: 0,
184
+ hidden: false,
185
+ value: '',
186
+ properties: {},
187
+ styles: {},
188
+ isIterator: true,
189
+ blocks: [
190
+ {
191
+ id: 'card',
192
+ type: 'group',
193
+ label: 'Card',
194
+ order: 0,
195
+ hidden: false,
196
+ value: '',
197
+ properties: {},
198
+ styles: {},
199
+ blocks: [swatchStrip(properties)]
200
+ } as Block
201
+ ]
202
+ } as Block,
203
+ items: [row],
204
+ isDesigner: false
205
+ });
206
+
207
+ const expanded = buildIteratorBlock({
208
+ iteratorBlock: ((outer.blocks || [])[0].blocks || [])[0]
209
+ });
210
+
211
+ return (expanded.blocks || []).map((clone) => clone.hidden);
212
+ };
213
+
214
+ it('reads the entry the swatch is drawn for, not the product row', () => {
215
+ expect(
216
+ expandStrip(
217
+ { visibleWhenMode: 'not-empty', visibleWhenField: 'item.stock' },
218
+ { colors: [{ label: 'Red', stock: 3 }, { label: 'Blue' }] }
219
+ )
220
+ ).toEqual([false, true]);
221
+ });
222
+
223
+ it('does not let a product field decide every swatch at once', () => {
224
+ // The product carries `stock` too. Answered by the product, both swatches
225
+ // show — including the colour that is out of stock.
226
+ expect(
227
+ expandStrip(
228
+ { visibleWhenMode: 'not-empty', visibleWhenField: 'item.stock' },
229
+ { stock: 12, colors: [{ label: 'Red', stock: 3 }, { label: 'Blue' }] }
230
+ )
231
+ ).toEqual([false, true]);
232
+ });
233
+ });
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Numeric / field / percentage comparison.
237
+ // ---------------------------------------------------------------------------
238
+
239
+ const numericRule = (
240
+ mode: string,
241
+ value: string,
242
+ extra: Record<string, unknown> = {},
243
+ field = 'item.previousPrice'
244
+ ) => ({ mode, field, value, ...extra } as never);
245
+
246
+ describe('a rule that sets none of the new keys', () => {
247
+ // The default-unchanged proof: every mode that existed before, evaluated
248
+ // through a rule carrying only the three original keys, over the same data.
249
+ // If any new branch leaked into an old mode, one of these flips.
250
+ const legacyCases: [string, string, Record<string, unknown>, boolean][] = [
251
+ ['not-empty', '', { badge: 'OUTLET' }, true],
252
+ ['not-empty', '', { badge: '' }, false],
253
+ ['not-empty', '', { badge: 0 }, true],
254
+ ['empty', '', { badge: '' }, true],
255
+ ['empty', '', { badge: 'OUTLET' }, false],
256
+ ['truthy', '', { badge: '1' }, true],
257
+ ['truthy', '', { badge: '0' }, false],
258
+ ['truthy', '', { badge: 'false' }, false],
259
+ ['falsy', '', { badge: '' }, true],
260
+ ['falsy', '', { badge: 'x' }, false],
261
+ ['equals', '5', { badge: 5 }, true],
262
+ ['equals', '5', { badge: '5' }, true],
263
+ ['equals', '5.0', { badge: 5 }, false],
264
+ ['equals', '', {}, true],
265
+ ['not-equals', '5', { badge: 5 }, false],
266
+ ['not-equals', '5.0', { badge: 5 }, true],
267
+ ['always', '', { badge: '' }, true]
268
+ ];
269
+
270
+ it.each(legacyCases)(
271
+ '%s against %p keeps its result',
272
+ (mode, value, item, expected) => {
273
+ expect(
274
+ isVisibleForItem(
275
+ { mode, field: 'item.badge', value } as never,
276
+ item as Record<string, unknown>
277
+ )
278
+ ).toBe(expected);
279
+ }
280
+ );
281
+
282
+ it('still reads back as exactly the three keys it always had', () => {
283
+ // `equals` on a numeric-looking string stayed a string comparison: '5.0'
284
+ // and 5 differ as text and must keep differing.
285
+ const rule = readRowVisibility({
286
+ visibleWhenMode: 'equals',
287
+ visibleWhenField: 'item.type',
288
+ visibleWhenValue: 'outlet'
289
+ });
290
+
291
+ expect(Object.keys(rule || {}).sort()).toEqual(['field', 'mode', 'value']);
292
+ expect(rule).toEqual({
293
+ mode: 'equals',
294
+ field: 'item.type',
295
+ value: 'outlet'
296
+ });
297
+ });
298
+ });
299
+
300
+ describe('reading the two-operand keys', () => {
301
+ it('carries the field operand and the threshold only when set', () => {
302
+ expect(
303
+ readRowVisibility({
304
+ visibleWhenMode: 'percent-above',
305
+ visibleWhenField: 'item.previousPrice',
306
+ visibleWhenValue: 'item.price',
307
+ visibleWhenCompareTo: 'field',
308
+ visibleWhenThreshold: '5'
309
+ })
310
+ ).toEqual({
311
+ mode: 'percent-above',
312
+ field: 'item.previousPrice',
313
+ value: 'item.price',
314
+ compareTo: 'field',
315
+ threshold: '5'
316
+ });
317
+ });
318
+
319
+ it('ignores a compare-to that is not the field opt-in', () => {
320
+ const rule = readRowVisibility({
321
+ visibleWhenMode: 'greater-than',
322
+ visibleWhenField: 'item.price',
323
+ visibleWhenValue: '100',
324
+ visibleWhenCompareTo: 'value'
325
+ });
326
+
327
+ expect(rule?.compareTo).toBeUndefined();
328
+ });
329
+
330
+ it('accepts a numeric threshold and unwraps it responsively', () => {
331
+ expect(
332
+ readRowVisibility({
333
+ visibleWhenMode: 'percent-above',
334
+ visibleWhenField: 'item.previousPrice',
335
+ visibleWhenValue: 'item.price',
336
+ visibleWhenCompareTo: { desktop: 'field' },
337
+ visibleWhenThreshold: { desktop: 5 }
338
+ })
339
+ ).toMatchObject({ compareTo: 'field', threshold: '5' });
340
+ });
341
+ });
342
+
343
+ describe('comparing numbers', () => {
344
+ it('orders against a typed value', () => {
345
+ expect(isVisibleForItem(numericRule('greater-than', '100'), { previousPrice: 101 })).toBe(true);
346
+ expect(isVisibleForItem(numericRule('greater-than', '100'), { previousPrice: 100 })).toBe(false);
347
+ expect(isVisibleForItem(numericRule('greater-or-equal', '100'), { previousPrice: 100 })).toBe(true);
348
+ expect(isVisibleForItem(numericRule('less-than', '100'), { previousPrice: 99 })).toBe(true);
349
+ expect(isVisibleForItem(numericRule('less-than', '100'), { previousPrice: 100 })).toBe(false);
350
+ expect(isVisibleForItem(numericRule('less-or-equal', '100'), { previousPrice: 100 })).toBe(true);
351
+ });
352
+
353
+ it('refuses to compare when either side is not a number', () => {
354
+ // The whole point: "abc" must not become 0 and switch the row on.
355
+ expect(isVisibleForItem(numericRule('greater-than', '100'), { previousPrice: 'abc' })).toBe(false);
356
+ expect(isVisibleForItem(numericRule('less-than', '100'), { previousPrice: 'abc' })).toBe(false);
357
+ expect(isVisibleForItem(numericRule('greater-than', 'abc'), { previousPrice: 500 })).toBe(false);
358
+ expect(isVisibleForItem(numericRule('less-than', '100'), { previousPrice: '' })).toBe(false);
359
+ expect(isVisibleForItem(numericRule('less-than', '100'), { previousPrice: false })).toBe(false);
360
+ expect(isVisibleForItem(numericRule('less-than', '100'), {})).toBe(false);
361
+ });
362
+
363
+ it('reads the money strings a listing row actually publishes', () => {
364
+ // `price` / `previousPrice` reach the row already formatted, so Number()
365
+ // would call every one of them NaN.
366
+ expect(
367
+ isVisibleForItem(numericRule('greater-than', '1000'), {
368
+ previousPrice: '1.299,90 TL'
369
+ })
370
+ ).toBe(true);
371
+ });
372
+
373
+ it('shows the element while the second box is still empty', () => {
374
+ expect(isVisibleForItem(numericRule('greater-than', ''), { previousPrice: 5 })).toBe(true);
375
+ expect(isVisibleForItem(numericRule('greater-than', ' '), { previousPrice: 5 })).toBe(true);
376
+ });
377
+ });
378
+
379
+ describe('comparing against another field', () => {
380
+ const withField = (mode: string, other: string) =>
381
+ numericRule(mode, other, { compareTo: 'field' });
382
+
383
+ const row = {
384
+ price: '999,90 TL',
385
+ previousPrice: '1.299,90 TL',
386
+ retailPrice: '1.499,90 TL'
387
+ };
388
+
389
+ it('states "previous price is higher than the current price"', () => {
390
+ expect(isVisibleForItem(withField('greater-than', 'item.price'), row)).toBe(true);
391
+ expect(isVisibleForItem(withField('less-than', 'item.price'), row)).toBe(false);
392
+ });
393
+
394
+ it('hides the caption when the named field is missing on that row', () => {
395
+ expect(
396
+ isVisibleForItem(withField('greater-than', 'item.price'), { previousPrice: 10 })
397
+ ).toBe(false);
398
+ });
399
+
400
+ it('does not read the path as a literal', () => {
401
+ // Without the opt-in the same rule compares against the text
402
+ // "item.price", which is not a number — so it refuses.
403
+ expect(
404
+ isVisibleForItem(numericRule('greater-than', 'item.price'), row)
405
+ ).toBe(false);
406
+ });
407
+
408
+ it('also serves the text comparisons', () => {
409
+ expect(
410
+ isVisibleForItem(
411
+ { mode: 'equals', field: 'item.a', value: 'item.b', compareTo: 'field' } as never,
412
+ { a: 'X', b: 'X' }
413
+ )
414
+ ).toBe(true);
415
+ expect(
416
+ isVisibleForItem(
417
+ { mode: 'not-equals', field: 'item.a', value: 'item.b', compareTo: 'field' } as never,
418
+ { a: 'X', b: 'Y' }
419
+ )
420
+ ).toBe(true);
421
+ });
422
+ });
423
+
424
+ describe('comparing by percentage', () => {
425
+ const percentRule = (mode: string, threshold: string) =>
426
+ numericRule(mode, 'item.price', { compareTo: 'field', threshold });
427
+
428
+ it('applies the brand\'s 5% threshold', () => {
429
+ // 1.299,90 is 30% above 999,90 — well past the threshold.
430
+ expect(
431
+ isVisibleForItem(percentRule('percent-above', '5'), {
432
+ price: '999,90 TL',
433
+ previousPrice: '1.299,90 TL'
434
+ })
435
+ ).toBe(true);
436
+
437
+ // 1.020,00 is 2% above 1.000,00 — the caption stays off.
438
+ expect(
439
+ isVisibleForItem(percentRule('percent-above', '5'), {
440
+ price: '1.000,00 TL',
441
+ previousPrice: '1.020,00 TL'
442
+ })
443
+ ).toBe(false);
444
+ });
445
+
446
+ it('measures the gap against the other side, in both directions', () => {
447
+ expect(isVisibleForItem(percentRule('percent-above', '5'), { price: 100, previousPrice: 105 })).toBe(true);
448
+ expect(isVisibleForItem(percentRule('percent-below', '5'), { price: 100, previousPrice: 95 })).toBe(true);
449
+ expect(isVisibleForItem(percentRule('percent-below', '5'), { price: 100, previousPrice: 96 })).toBe(false);
450
+ });
451
+
452
+ it('treats an unset threshold as "by any amount"', () => {
453
+ expect(
454
+ isVisibleForItem(numericRule('percent-above', 'item.price', { compareTo: 'field' }), {
455
+ price: 100,
456
+ previousPrice: 100.5
457
+ })
458
+ ).toBe(true);
459
+ });
460
+
461
+ it('refuses a percentage of zero', () => {
462
+ expect(isVisibleForItem(percentRule('percent-above', '5'), { price: 0, previousPrice: 10 })).toBe(false);
463
+ });
464
+
465
+ it('falls back to "any difference" when the threshold is mistyped', () => {
466
+ // Config the designer has not finished. The rule keeps working — it just
467
+ // loses the bar it could not read — rather than turning into "every row",
468
+ // which would print the caption on a product with no discount at all.
469
+ expect(
470
+ isVisibleForItem(percentRule('percent-above', 'five'), {
471
+ price: 1,
472
+ previousPrice: 100
473
+ })
474
+ ).toBe(true);
475
+
476
+ expect(
477
+ isVisibleForItem(percentRule('percent-above', 'five'), {
478
+ price: 100,
479
+ previousPrice: 100
480
+ })
481
+ ).toBe(false);
482
+ });
483
+ });
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // Boundaries. The table above asserts the middle of each range; a caption that
487
+ // claims a discount is decided at the edges — equality, zero, a negative gap,
488
+ // a zero divisor and a non-numeric side.
489
+ // ---------------------------------------------------------------------------
490
+
491
+ describe('comparison boundaries', () => {
492
+ // Both sides are fields on the row, which is how the shipped panel defaults
493
+ // wire a price caption: field item.retailPrice, compare to field item.price.
494
+ const boundaryRule = (mode: string, threshold?: string) =>
495
+ ({
496
+ mode,
497
+ field: 'item.left',
498
+ value: 'item.right',
499
+ compareTo: 'field',
500
+ ...(threshold === undefined ? {} : { threshold })
501
+ } as never);
502
+
503
+ const evaluate = (
504
+ mode: string,
505
+ left: unknown,
506
+ right: unknown,
507
+ threshold?: string
508
+ ) => isVisibleForItem(boundaryRule(mode, threshold), { left, right });
509
+
510
+ const cases: [string, unknown, unknown, string | undefined, boolean][] = [
511
+ // Equality. `>=` / `<=` include it by name; nothing else may.
512
+ ['greater-than', 100, 100, undefined, false],
513
+ ['greater-or-equal', 100, 100, undefined, true],
514
+ ['less-than', 100, 100, undefined, false],
515
+ ['less-or-equal', 100, 100, undefined, true],
516
+ // A full-price product: commerce sends retail_price === price, and the
517
+ // Percentage box is empty because that is the panel's default state.
518
+ ['percent-above', '₺999,90', '₺999,90', undefined, false],
519
+ ['percent-below', '₺999,90', '₺999,90', undefined, false],
520
+ ['percent-above', 100, 100, '0', false],
521
+ ['percent-below', 100, 100, '0', false],
522
+
523
+ // Zero on either side.
524
+ ['greater-than', 0, 0, undefined, false],
525
+ ['greater-or-equal', 0, 0, undefined, true],
526
+ ['greater-than', 0, -1, undefined, true],
527
+ ['less-than', -5, 0, undefined, true],
528
+ // Nothing is a percentage of zero, in either direction.
529
+ ['percent-above', 10, 0, undefined, false],
530
+ ['percent-below', -10, 0, undefined, false],
531
+ ['percent-above', 0, 0, undefined, false],
532
+ ['percent-below', 0, 0, undefined, false],
533
+ // Zero on the measured side is still a real gap against a non-zero base.
534
+ ['percent-below', 0, 100, undefined, true],
535
+
536
+ // A negative gap must not satisfy the opposite direction.
537
+ ['percent-above', 95, 100, undefined, false],
538
+ ['percent-below', 105, 100, undefined, false],
539
+ ['percent-above', 95, 100, '5', false],
540
+ ['percent-below', 105, 100, '5', false],
541
+ // Negative operands compare on the same axis: -5 is 50% above -10.
542
+ ['percent-above', -5, -10, undefined, true],
543
+ ['percent-below', -10, -5, undefined, true],
544
+
545
+ // The threshold itself, exactly on and just under.
546
+ ['percent-above', 105, 100, '5', true],
547
+ ['percent-above', 104.9, 100, '5', false],
548
+ ['percent-below', 95, 100, '5', true],
549
+ ['percent-below', 95.1, 100, '5', false],
550
+ // "By any amount" still means any real gap, however small.
551
+ ['percent-above', 100.5, 100, undefined, true],
552
+ ['percent-below', 99.5, 100, undefined, true],
553
+
554
+ // A non-numeric operand on either side refuses, it never coerces to 0.
555
+ ['greater-than', 'abc', 100, undefined, false],
556
+ ['greater-than', 100, 'abc', undefined, false],
557
+ ['less-or-equal', '', 100, undefined, false],
558
+ ['less-or-equal', 100, '', undefined, false],
559
+ ['greater-or-equal', false, 100, undefined, false],
560
+ ['greater-or-equal', 100, false, undefined, false],
561
+ ['percent-above', 'abc', 100, undefined, false],
562
+ ['percent-above', 100, 'abc', undefined, false],
563
+ ['percent-below', 'abc', 100, undefined, false],
564
+ ['percent-below', 100, 'abc', undefined, false],
565
+ ['percent-above', undefined, 100, undefined, false],
566
+ ['percent-above', 100, undefined, undefined, false],
567
+ ['percent-below', null, 100, undefined, false],
568
+ ['percent-below', 100, null, undefined, false]
569
+ ];
570
+
571
+ it.each(cases)(
572
+ '%s of %p against %p at threshold %p',
573
+ (mode, left, right, threshold, expected) => {
574
+ expect(evaluate(mode, left, right, threshold)).toBe(expected);
575
+ }
576
+ );
577
+
578
+ it('never satisfies both percent directions at once', () => {
579
+ const pairs: [unknown, unknown][] = [
580
+ ['₺999,90', '₺999,90'],
581
+ [100, 100],
582
+ [0, 0],
583
+ [0, 100],
584
+ [100, 0],
585
+ [105, 100],
586
+ [95, 100],
587
+ [-5, -10],
588
+ [-10, -5],
589
+ ['abc', 100]
590
+ ];
591
+
592
+ for (const [left, right] of pairs) {
593
+ // 'five' is in the list because an unreadable box used to bypass the
594
+ // gap floor entirely and satisfy both directions on a full-price row.
595
+ for (const threshold of [undefined, '0', '5', '-5', 'five']) {
596
+ const above = evaluate('percent-above', left, right, threshold);
597
+ const below = evaluate('percent-below', left, right, threshold);
598
+
599
+ expect(above && below).toBe(false);
600
+ }
601
+ }
602
+ });
603
+
604
+ it('degrades a mistyped threshold to "any difference", not to "always"', () => {
605
+ // An unreadable box is a config problem, but it must not be a licence to
606
+ // print a discount caption on a product whose two prices are identical —
607
+ // which is the same lie the gap floor exists to refuse.
608
+ expect(evaluate('percent-above', 100, 100, 'five')).toBe(false);
609
+ expect(evaluate('percent-below', 100, 100, 'five')).toBe(false);
610
+ // A real gap still shows, so a typo never empties the page.
611
+ expect(evaluate('percent-above', 105, 100, 'five')).toBe(true);
612
+ expect(evaluate('percent-below', 95, 100, 'five')).toBe(true);
613
+ });
614
+
615
+ it('ignores a threshold left behind by an ordering mode', () => {
616
+ // The panel stops rendering the Percentage box once the mode is no longer
617
+ // a percent form, but the stored property survives the switch. A value the
618
+ // comparison never uses must not be able to decide it.
619
+ expect(evaluate('greater-than', 200, 100, 'five')).toBe(true);
620
+ expect(evaluate('greater-than', 100, 200, 'five')).toBe(false);
621
+ expect(evaluate('less-or-equal', 100, 100, 'five')).toBe(true);
622
+ expect(evaluate('greater-than', 100, 100, '99')).toBe(false);
623
+ });
624
+
625
+ it('still needs a real gap when the threshold is negative', () => {
626
+ // A negative "how far apart" is meaningless; it must not become a licence
627
+ // to print the caption on a product whose two prices are identical.
628
+ expect(evaluate('percent-above', 100, 100, '-5')).toBe(false);
629
+ expect(evaluate('percent-below', 100, 100, '-5')).toBe(false);
630
+ expect(evaluate('percent-above', 95, 100, '-5')).toBe(false);
631
+ });
632
+ });
633
+
634
+ describe('the price caption rule, end to end', () => {
635
+ it('prints "lowest price in 30 days" only on the rows that earn it', () => {
636
+ const result = expand(
637
+ {
638
+ visibleWhenMode: 'percent-above',
639
+ visibleWhenField: 'item.previousPrice',
640
+ visibleWhenValue: 'item.price',
641
+ visibleWhenCompareTo: 'field',
642
+ visibleWhenThreshold: '5'
643
+ },
644
+ [
645
+ { price: '999,90 TL', previousPrice: '1.299,90 TL' },
646
+ { price: '1.000,00 TL', previousPrice: '1.020,00 TL' },
647
+ { price: '999,90 TL', previousPrice: '899,90 TL' },
648
+ { price: '999,90 TL' }
649
+ ]
650
+ );
651
+
652
+ expect(badgeHidden(result)).toEqual([false, true, true, true]);
653
+ });
654
+
655
+ it('prints "advantage price" on the mirrored rows', () => {
656
+ const result = expand(
657
+ {
658
+ visibleWhenMode: 'less-than',
659
+ visibleWhenField: 'item.previousPrice',
660
+ visibleWhenValue: 'item.price',
661
+ visibleWhenCompareTo: 'field'
662
+ },
663
+ [
664
+ { price: '999,90 TL', previousPrice: '1.299,90 TL' },
665
+ { price: '999,90 TL', previousPrice: '899,90 TL' },
666
+ { price: '999,90 TL' }
667
+ ]
668
+ );
669
+
670
+ expect(badgeHidden(result)).toEqual([true, false, true]);
671
+ });
672
+ });