fontdue-js 3.6.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -4
  3. package/dist/__generated__/AddToCartBannerQuery.graphql.d.ts +1 -1
  4. package/dist/__generated__/AddToCartBannerQuery.graphql.js +16 -10
  5. package/dist/__generated__/AddToCartBanner_item.graphql.d.ts +4 -1
  6. package/dist/__generated__/AddToCartBanner_item.graphql.js +7 -2
  7. package/dist/__generated__/AddToCartBanner_order.graphql.d.ts +4 -1
  8. package/dist/__generated__/AddToCartBanner_order.graphql.js +6 -1
  9. package/dist/__generated__/BuyButtonIDQuery.graphql.d.ts +3 -3
  10. package/dist/__generated__/BuyButtonIDQuery.graphql.js +31 -16
  11. package/dist/__generated__/BuyButtonPackIDQuery.graphql.d.ts +21 -0
  12. package/dist/__generated__/BuyButtonPackIDQuery.graphql.js +129 -0
  13. package/dist/__generated__/BuyButtonPackSlugQuery.graphql.d.ts +25 -0
  14. package/dist/__generated__/BuyButtonPackSlugQuery.graphql.js +167 -0
  15. package/dist/__generated__/BuyButtonSlugQuery.graphql.d.ts +3 -3
  16. package/dist/__generated__/BuyButtonSlugQuery.graphql.js +45 -17
  17. package/dist/__generated__/BuyButton_product.graphql.d.ts +27 -0
  18. package/dist/__generated__/BuyButton_product.graphql.js +72 -0
  19. package/dist/__generated__/CartItemProduct_product.graphql.d.ts +1 -1
  20. package/dist/__generated__/CartItemProduct_product.graphql.js +7 -2
  21. package/dist/__generated__/CartOrderCompleteOrderMutation.graphql.d.ts +1 -1
  22. package/dist/__generated__/CartOrderCompleteOrderMutation.graphql.js +26 -20
  23. package/dist/__generated__/CartOrderRemoveDiscountMutation.graphql.d.ts +1 -1
  24. package/dist/__generated__/CartOrderRemoveDiscountMutation.graphql.js +26 -20
  25. package/dist/__generated__/CartOrderUpdateMutation.graphql.d.ts +1 -1
  26. package/dist/__generated__/CartOrderUpdateMutation.graphql.js +26 -20
  27. package/dist/__generated__/CartQuery.graphql.d.ts +1 -1
  28. package/dist/__generated__/CartQuery.graphql.js +26 -20
  29. package/dist/__generated__/CartStateUpdateMutation.graphql.d.ts +1 -1
  30. package/dist/__generated__/CartStateUpdateMutation.graphql.js +26 -20
  31. package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.d.ts +1 -1
  32. package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.js +26 -20
  33. package/dist/__generated__/CheckoutUpdateOrderMutation.graphql.d.ts +1 -1
  34. package/dist/__generated__/CheckoutUpdateOrderMutation.graphql.js +26 -20
  35. package/dist/__generated__/CollectionAa_Query.graphql.d.ts +1 -1
  36. package/dist/__generated__/CollectionAa_Query.graphql.js +20 -14
  37. package/dist/__generated__/CollectionAa_product.graphql.d.ts +7 -1
  38. package/dist/__generated__/CollectionAa_product.graphql.js +22 -16
  39. package/dist/__generated__/FontFamiliesQuery.graphql.d.ts +4 -1
  40. package/dist/__generated__/FontFamiliesQuery.graphql.js +46 -39
  41. package/dist/__generated__/NodePasswordFormElementIDQuery.graphql.d.ts +3 -3
  42. package/dist/__generated__/NodePasswordFormElementIDQuery.graphql.js +9 -9
  43. package/dist/__generated__/NodePasswordFormElementPackSlugQuery.graphql.d.ts +25 -0
  44. package/dist/__generated__/NodePasswordFormElementPackSlugQuery.graphql.js +108 -0
  45. package/dist/__generated__/PrecartAddToCartMutation.graphql.d.ts +1 -1
  46. package/dist/__generated__/PrecartAddToCartMutation.graphql.js +32 -26
  47. package/dist/__generated__/PriceBar_node.graphql.d.ts +4 -1
  48. package/dist/__generated__/PriceBar_node.graphql.js +23 -16
  49. package/dist/__generated__/StoreModalCartQuery.graphql.d.ts +1 -1
  50. package/dist/__generated__/StoreModalCartQuery.graphql.js +37 -31
  51. package/dist/__generated__/StoreModalFamily_collection.graphql.d.ts +3 -2
  52. package/dist/__generated__/StoreModalFamily_collection.graphql.js +3 -3
  53. package/dist/__generated__/StoreModalIndexPackItem_fontPack.graphql.d.ts +23 -0
  54. package/dist/__generated__/StoreModalIndexPackItem_fontPack.graphql.js +58 -0
  55. package/dist/__generated__/StoreModalIndexPaginationQuery.graphql.d.ts +23 -0
  56. package/dist/__generated__/StoreModalIndexPaginationQuery.graphql.js +303 -0
  57. package/dist/__generated__/StoreModalIndexQuery.graphql.d.ts +6 -3
  58. package/dist/__generated__/StoreModalIndexQuery.graphql.js +218 -117
  59. package/dist/__generated__/StoreModalIndex_viewer.graphql.d.ts +24 -0
  60. package/dist/__generated__/StoreModalIndex_viewer.graphql.js +134 -0
  61. package/dist/__generated__/StoreModalPackButton_fontPack.graphql.d.ts +33 -0
  62. package/dist/__generated__/StoreModalPackButton_fontPack.graphql.js +119 -0
  63. package/dist/__generated__/StoreModalPack_fontPack.graphql.d.ts +45 -0
  64. package/dist/__generated__/StoreModalPack_fontPack.graphql.js +150 -0
  65. package/dist/__generated__/StoreModalProductContent_collection.graphql.d.ts +6 -2
  66. package/dist/__generated__/StoreModalProductContent_collection.graphql.js +43 -19
  67. package/dist/__generated__/{StoreModalProductLicenseSelection_collection.graphql.d.ts → StoreModalProductLicenseSelection_licenseable.graphql.d.ts} +7 -6
  68. package/dist/__generated__/StoreModalProductLicenseSelection_licenseable.graphql.js +43 -0
  69. package/dist/__generated__/StoreModalProductQuery.graphql.d.ts +1 -1
  70. package/dist/__generated__/StoreModalProductQuery.graphql.js +199 -142
  71. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.d.ts +1 -1
  72. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.js +190 -133
  73. package/dist/__generated__/StoreModalProductSlugQuery.graphql.d.ts +5 -1
  74. package/dist/__generated__/StoreModalProductSlugQuery.graphql.js +254 -176
  75. package/dist/__generated__/StoreModalProductSummaryRefetchQuery.graphql.d.ts +1 -1
  76. package/dist/__generated__/StoreModalProductSummaryRefetchQuery.graphql.js +9 -4
  77. package/dist/__generated__/StoreModalProductSummary_viewer.graphql.d.ts +7 -1
  78. package/dist/__generated__/StoreModalProductSummary_viewer.graphql.js +7 -2
  79. package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.d.ts +1 -1
  80. package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.js +26 -20
  81. package/dist/__generated__/TypeTestersIDQuery.graphql.d.ts +1 -1
  82. package/dist/__generated__/TypeTestersIDQuery.graphql.js +17 -10
  83. package/dist/__generated__/TypeTestersRefetchQuery.graphql.d.ts +1 -1
  84. package/dist/__generated__/TypeTestersRefetchQuery.graphql.js +17 -10
  85. package/dist/__generated__/TypeTestersSlugQuery.graphql.d.ts +1 -1
  86. package/dist/__generated__/TypeTestersSlugQuery.graphql.js +17 -10
  87. package/dist/__generated__/productState_Query.graphql.d.ts +99 -1
  88. package/dist/__generated__/productState_Query.graphql.js +43 -4
  89. package/dist/__generated__/productState_fontPack.graphql.d.ts +111 -0
  90. package/dist/__generated__/productState_fontPack.graphql.js +117 -0
  91. package/dist/__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.d.ts +41 -0
  92. package/dist/__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.js +65 -0
  93. package/dist/__tests__/bundleSelectionOrder.test.js +94 -0
  94. package/dist/__tests__/collectionBundleSelection.test.js +443 -1446
  95. package/dist/__tests__/fontPackSelection.test.js +435 -0
  96. package/dist/__tests__/infiniteScroll.test.js +180 -0
  97. package/dist/__tests__/licenseExclusions.test.js +4 -1
  98. package/dist/__tests__/storeModalPackButton.test.js +102 -0
  99. package/dist/__tests__/storeModalPackUnlisted.test.js +112 -0
  100. package/dist/__tests__/storeModalRoutes.test.js +9 -0
  101. package/dist/__tests__/storefrontEvents.test.js +167 -0
  102. package/dist/__tests__/typeTesterAutofitFeatures.test.js +138 -0
  103. package/dist/components/AddToCartBanner/index.js +1 -1
  104. package/dist/components/BuyButton/index.d.ts +29 -9
  105. package/dist/components/BuyButton/index.js +123 -26
  106. package/dist/components/BuyButton/index.server.d.ts +4 -2
  107. package/dist/components/BuyButton/index.server.js +24 -3
  108. package/dist/components/Cart/CartItem/CartItemProduct.js +3 -3
  109. package/dist/components/Cart/Checkout.js +11 -1
  110. package/dist/components/Cart/storefrontEvents.d.ts +34 -0
  111. package/dist/components/Cart/storefrontEvents.js +138 -0
  112. package/dist/components/CollectionAa/index.js +2 -2
  113. package/dist/components/FontFamilies/index.js +4 -3
  114. package/dist/components/NodePasswordForm/NodePasswordFormElement.d.ts +1 -1
  115. package/dist/components/NodePasswordForm/NodePasswordFormElement.js +45 -21
  116. package/dist/components/NodePasswordForm/index.d.ts +7 -4
  117. package/dist/components/NodePasswordForm/index.js +6 -6
  118. package/dist/components/Precart/index.js +6 -0
  119. package/dist/components/PriceBar/index.js +5 -3
  120. package/dist/components/Root/productState.d.ts +2 -8
  121. package/dist/components/Root/productState.js +100 -61
  122. package/dist/components/SKUPrice/index.js +7 -8
  123. package/dist/components/StoreModal/StoreModalFamily.d.ts +7 -0
  124. package/dist/components/StoreModal/StoreModalFamily.js +11 -7
  125. package/dist/components/StoreModal/StoreModalIndex.js +58 -7
  126. package/dist/components/StoreModal/StoreModalIndexEntry.d.ts +13 -0
  127. package/dist/components/StoreModal/StoreModalIndexEntry.js +43 -0
  128. package/dist/components/StoreModal/StoreModalIndexItem.js +7 -30
  129. package/dist/components/StoreModal/StoreModalIndexPackItem.d.ts +7 -0
  130. package/dist/components/StoreModal/StoreModalIndexPackItem.js +21 -0
  131. package/dist/components/StoreModal/StoreModalPack.d.ts +9 -0
  132. package/dist/components/StoreModal/StoreModalPack.js +79 -0
  133. package/dist/components/StoreModal/StoreModalPackButton.d.ts +8 -0
  134. package/dist/components/StoreModal/StoreModalPackButton.js +53 -0
  135. package/dist/components/StoreModal/StoreModalProduct.js +49 -19
  136. package/dist/components/StoreModalProductLicenseSelection/StoreModalProductLicense.d.ts +2 -1
  137. package/dist/components/StoreModalProductLicenseSelection/StoreModalProductLicense.js +8 -3
  138. package/dist/components/StoreModalProductLicenseSelection/index.d.ts +3 -3
  139. package/dist/components/StoreModalProductLicenseSelection/index.js +7 -6
  140. package/dist/components/StoreModalProductSummary/index.js +1 -1
  141. package/dist/components/TypeTester/useTypeTesterStyler.js +1 -0
  142. package/dist/components/elements/StoreModalContainer/StoreModalContainerContext.d.ts +1 -0
  143. package/dist/components/elements/StoreModalContainer/index.js +9 -4
  144. package/dist/components/elements/StoreModalFamily/index.d.ts +1 -1
  145. package/dist/components/elements/StoreModalFamily/index.js +1 -1
  146. package/dist/components/elements/StoreModalIndexLoadMore/index.d.ts +8 -0
  147. package/dist/components/elements/StoreModalIndexLoadMore/index.js +21 -0
  148. package/dist/components/elements/StoreModalUnifiedCheckout.js +12 -0
  149. package/dist/components/useInfiniteScroll.d.ts +28 -0
  150. package/dist/components/useInfiniteScroll.js +50 -0
  151. package/dist/fontdue.css +39 -0
  152. package/dist/hooks/useAutofit.d.ts +2 -1
  153. package/dist/hooks/useAutofit.js +10 -4
  154. package/dist/reducer.d.ts +12 -5
  155. package/dist/reducer.js +18 -87
  156. package/dist/selection.d.ts +44 -0
  157. package/dist/selection.js +539 -0
  158. package/dist/storeModalRoutes.js +5 -4
  159. package/dist/utils.d.ts +1 -12
  160. package/dist/utils.js +1 -36
  161. package/dist/version.js +1 -1
  162. package/package.json +1 -1
  163. package/dist/__generated__/BuyButton_collection.graphql.d.ts +0 -25
  164. package/dist/__generated__/BuyButton_collection.graphql.js +0 -49
  165. package/dist/__generated__/StoreModalProductLicenseSelection_collection.graphql.js +0 -40
  166. package/fontdue.css +0 -2733
  167. package/fontdue.css.map +0 -1
@@ -5,21 +5,32 @@ vi.mock('react-relay', () => ({
5
5
  graphql: () => null,
6
6
  fetchQuery: vi.fn()
7
7
  }));
8
- import { collectionSkuIdsDifferences, collContainsSkuId, isSelected } from '../utils.js';
9
- import { collectionSkuIdWithDiscount } from '../reducer.js';
10
- import reducer from '../reducer.js';
8
+ import reducer, { isSelected } from '../reducer.js';
9
+ import { additionalCost, cheapestSkuIds } from '../selection.js';
10
+
11
+ // A catalog as the selection sees it: each SKU's [price, styles it grants],
12
+ // the price undefined when not known.
11
13
 
12
- // Helper to build a minimal FontdueState for testing
13
14
  function makeState() {
14
- let overrides = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
15
+ let catalog = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
16
+ let overrides = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
17
+ const skuStyleIds = {};
18
+ const skuPrices = {};
19
+ for (const [skuId, [price, styleIds]] of Object.entries(catalog)) {
20
+ skuStyleIds[skuId] = styleIds;
21
+ if (price !== undefined) skuPrices[skuId] = price;
22
+ }
15
23
  return {
16
24
  cartOpen: false,
17
25
  precartOpen: false,
18
26
  selectedSkuIds: {},
27
+ requestedSkuIds: [],
19
28
  licenseOptions: [],
20
- collectionStyleSkus: {},
29
+ skuStyleIds,
21
30
  collectionSkus: {},
22
- skuPrices: {},
31
+ productSkuIds: {},
32
+ viewedProductId: null,
33
+ skuPrices,
23
34
  fetchedCollectionIds: [],
24
35
  storeModalRoute: {
25
36
  name: 'index'
@@ -30,739 +41,162 @@ function makeState() {
30
41
  ...overrides
31
42
  };
32
43
  }
44
+ const select = function (state) {
45
+ for (var _len = arguments.length, skuIds = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
46
+ skuIds[_key - 1] = arguments[_key];
47
+ }
48
+ return skuIds.reduce((current, skuId) => reducer(current, {
49
+ type: 'SELECT_SKU_ID',
50
+ skuId,
51
+ selected: true
52
+ }), state);
53
+ };
54
+ const deselect = (state, skuId) => reducer(state, {
55
+ type: 'SELECT_SKU_ID',
56
+ skuId,
57
+ selected: false
58
+ });
59
+ const selected = state => Object.keys(state.selectedSkuIds).filter(skuId => state.selectedSkuIds[skuId] === true).sort();
33
60
 
34
61
  // ---------------------------------------------------------------------------
35
- // Shared fixtures — reusable across tests that need the full
36
- // "superfamily + 3 families + 3 bundles" topology
62
+ // Shared fixture – a superfamily of three families, each with a variable
63
+ // upright and italic, and a collection bundle of the family and its variables
64
+ // priced like the family alone.
37
65
  // ---------------------------------------------------------------------------
38
- function makeThunderState() {
39
- let overrides = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
40
- return makeState({
41
- collectionStyleSkus: {
42
- // Superfamily
43
- 'super-sku': {
44
- fontStyleSkuIds: ['s1', 's2', 'ds1', 'ds2', 'cs1', 'cs2', 'vs1', 'vis1', 'dvs1', 'dvis1', 'cvs1', 'cvis1'],
45
- fontStyleIds: ['f1', 'f2', 'df1', 'df2', 'cf1', 'cf2', 'vf1', 'vif1', 'dvf1', 'dvif1', 'cvf1', 'cvif1'],
46
- childrenSkuIds: ['text-sku', 'display-sku', 'caption-sku', 'text-var-sku', 'text-var-italic-sku', 'display-var-sku', 'display-var-italic-sku', 'caption-var-sku', 'caption-var-italic-sku', 'text-bundle-sku', 'display-bundle-sku', 'caption-bundle-sku'],
47
- name: 'Thunder Text Collection'
48
- },
49
- // Static families
50
- 'text-sku': {
51
- fontStyleSkuIds: ['s1', 's2'],
52
- fontStyleIds: ['f1', 'f2'],
53
- childrenSkuIds: [],
54
- name: 'Thunder Text'
55
- },
56
- 'display-sku': {
57
- fontStyleSkuIds: ['ds1', 'ds2'],
58
- fontStyleIds: ['df1', 'df2'],
59
- childrenSkuIds: [],
60
- name: 'Thunder Display'
61
- },
62
- 'caption-sku': {
63
- fontStyleSkuIds: ['cs1', 'cs2'],
64
- fontStyleIds: ['cf1', 'cf2'],
65
- childrenSkuIds: [],
66
- name: 'Thunder Caption'
67
- },
68
- // Variable families
69
- 'text-var-sku': {
70
- fontStyleSkuIds: ['vs1'],
71
- fontStyleIds: ['vf1'],
72
- childrenSkuIds: [],
73
- name: 'Thunder Text Variable'
74
- },
75
- 'text-var-italic-sku': {
76
- fontStyleSkuIds: ['vis1'],
77
- fontStyleIds: ['vif1'],
78
- childrenSkuIds: [],
79
- name: 'Thunder Text Italic Variable'
80
- },
81
- 'display-var-sku': {
82
- fontStyleSkuIds: ['dvs1'],
83
- fontStyleIds: ['dvf1'],
84
- childrenSkuIds: [],
85
- name: 'Thunder Display Variable'
86
- },
87
- 'display-var-italic-sku': {
88
- fontStyleSkuIds: ['dvis1'],
89
- fontStyleIds: ['dvif1'],
90
- childrenSkuIds: [],
91
- name: 'Thunder Display Italic Variable'
92
- },
93
- 'caption-var-sku': {
94
- fontStyleSkuIds: ['cvs1'],
95
- fontStyleIds: ['cvf1'],
96
- childrenSkuIds: [],
97
- name: 'Thunder Caption Variable'
98
- },
99
- 'caption-var-italic-sku': {
100
- fontStyleSkuIds: ['cvis1'],
101
- fontStyleIds: ['cvif1'],
102
- childrenSkuIds: [],
103
- name: 'Thunder Caption Italic Variable'
104
- },
105
- // Collection bundles
106
- 'text-bundle-sku': {
107
- fontStyleSkuIds: ['s1', 's2', 'vs1', 'vis1'],
108
- fontStyleIds: ['f1', 'f2', 'vf1', 'vif1'],
109
- childrenSkuIds: ['text-sku', 'text-var-sku', 'text-var-italic-sku'],
110
- name: 'Thunder Text Family + Variable'
111
- },
112
- 'display-bundle-sku': {
113
- fontStyleSkuIds: ['ds1', 'ds2', 'dvs1', 'dvis1'],
114
- fontStyleIds: ['df1', 'df2', 'dvf1', 'dvif1'],
115
- childrenSkuIds: ['display-sku', 'display-var-sku', 'display-var-italic-sku'],
116
- name: 'Thunder Display Family + Variable'
117
- },
118
- 'caption-bundle-sku': {
119
- fontStyleSkuIds: ['cs1', 'cs2', 'cvs1', 'cvis1'],
120
- fontStyleIds: ['cf1', 'cf2', 'cvf1', 'cvif1'],
121
- childrenSkuIds: ['caption-sku', 'caption-var-sku', 'caption-var-italic-sku'],
122
- name: 'Thunder Caption Family + Variable'
123
- }
124
- },
125
- skuPrices: {
126
- 'super-sku': 750,
127
- 'text-sku': 500,
128
- 'display-sku': 500,
129
- 'caption-sku': 500,
130
- 'text-var-sku': 300,
131
- 'text-var-italic-sku': 300,
132
- 'display-var-sku': 300,
133
- 'display-var-italic-sku': 300,
134
- 'caption-var-sku': 300,
135
- 'caption-var-italic-sku': 300,
136
- 'text-bundle-sku': 500,
137
- 'display-bundle-sku': 500,
138
- 'caption-bundle-sku': 500,
139
- s1: 40,
140
- s2: 40,
141
- ds1: 40,
142
- ds2: 40,
143
- cs1: 40,
144
- cs2: 40,
145
- vs1: 40,
146
- vis1: 40,
147
- dvs1: 40,
148
- dvis1: 40,
149
- cvs1: 40,
150
- cvis1: 40
151
- },
152
- ...overrides
153
- });
154
- }
155
-
156
- // =========================================================================
157
- // collContainsSkuId
158
- // =========================================================================
159
- describe('collContainsSkuId', () => {
160
- it('returns true when skuId is in childrenSkuIds', () => {
161
- const coll = {
162
- fontStyleSkuIds: [],
163
- fontStyleIds: [],
164
- childrenSkuIds: ['child-sku-1', 'child-sku-2'],
165
- name: 'Test'
166
- };
167
- expect(collContainsSkuId(coll, 'child-sku-1')).toBe(true);
168
- });
169
- it('returns true when skuId is in fontStyleSkuIds', () => {
170
- const coll = {
171
- fontStyleSkuIds: ['style-sku-1'],
172
- fontStyleIds: [],
173
- childrenSkuIds: [],
174
- name: 'Test'
175
- };
176
- expect(collContainsSkuId(coll, 'style-sku-1')).toBe(true);
177
- });
178
- it('returns true when id is in fontStyleIds', () => {
179
- const coll = {
180
- fontStyleSkuIds: [],
181
- fontStyleIds: ['style-id-1'],
182
- childrenSkuIds: [],
183
- name: 'Test'
184
- };
185
- expect(collContainsSkuId(coll, 'style-id-1')).toBe(true);
186
- });
187
- it('returns false for unknown skuId', () => {
188
- const coll = {
189
- fontStyleSkuIds: ['style-sku-1'],
190
- fontStyleIds: ['style-1'],
191
- childrenSkuIds: ['child-sku-1'],
192
- name: 'Test'
193
- };
194
- expect(collContainsSkuId(coll, 'unknown-sku')).toBe(false);
195
- });
196
- it('returns false for undefined collection', () => {
197
- expect(collContainsSkuId(undefined, 'any-sku')).toBe(false);
198
- });
199
- });
66
+ const THUNDER = {
67
+ 'super-sku': [750, ['f1', 'f2', 'df1', 'df2', 'cf1', 'cf2', 'vf1', 'vif1', 'dvf1', 'dvif1', 'cvf1', 'cvif1']],
68
+ 'text-sku': [500, ['f1', 'f2']],
69
+ 'display-sku': [500, ['df1', 'df2']],
70
+ 'caption-sku': [500, ['cf1', 'cf2']],
71
+ 'text-var-sku': [300, ['vf1']],
72
+ 'text-var-italic-sku': [300, ['vif1']],
73
+ 'display-var-sku': [300, ['dvf1']],
74
+ 'display-var-italic-sku': [300, ['dvif1']],
75
+ 'caption-var-sku': [300, ['cvf1']],
76
+ 'caption-var-italic-sku': [300, ['cvif1']],
77
+ 'text-bundle-sku': [500, ['f1', 'f2', 'vf1', 'vif1']],
78
+ 'display-bundle-sku': [500, ['df1', 'df2', 'dvf1', 'dvif1']],
79
+ 'caption-bundle-sku': [500, ['cf1', 'cf2', 'cvf1', 'cvif1']],
80
+ s1: [40, ['f1']],
81
+ s2: [40, ['f2']],
82
+ ds1: [40, ['df1']],
83
+ ds2: [40, ['df2']],
84
+ cs1: [40, ['cf1']],
85
+ cs2: [40, ['cf2']],
86
+ vs1: [40, ['vf1']],
87
+ vis1: [40, ['vif1']],
88
+ dvs1: [40, ['dvf1']],
89
+ dvis1: [40, ['dvif1']],
90
+ cvs1: [40, ['cvf1']],
91
+ cvis1: [40, ['cvif1']]
92
+ };
93
+ const makeThunderState = function () {
94
+ let prices = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
95
+ return makeState(Object.fromEntries(Object.entries(THUNDER).map(_ref => {
96
+ let [skuId, [price, styleIds]] = _ref;
97
+ return [skuId, [prices[skuId] ?? price, styleIds]];
98
+ })));
99
+ };
100
+
101
+ // A family of five uprights and five italics, sold whole, as upright and
102
+ // italic packages, or by the style.
103
+ const FAMILY_WITH_PACKAGES = {
104
+ 'family-sku': [500, ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5']],
105
+ 'pkg-upright-sku': [250, ['f1', 'f2', 'f3', 'f4', 'f5']],
106
+ 'pkg-italic-sku': [250, ['fi1', 'fi2', 'fi3', 'fi4', 'fi5']],
107
+ s1: [100, ['f1']],
108
+ s2: [100, ['f2']],
109
+ s3: [100, ['f3']],
110
+ s4: [100, ['f4']],
111
+ s5: [100, ['f5']],
112
+ si1: [100, ['fi1']],
113
+ si2: [100, ['fi2']],
114
+ si3: [100, ['fi3']],
115
+ si4: [100, ['fi4']],
116
+ si5: [100, ['fi5']]
117
+ };
200
118
 
201
119
  // =========================================================================
202
120
  // isSelected
203
121
  // =========================================================================
204
122
  describe('isSelected', () => {
205
123
  it('returns true when the SKU is directly selected', () => {
206
- const state = makeState({
124
+ const state = makeState({}, {
207
125
  selectedSkuIds: {
208
126
  'sku-1': true
209
127
  }
210
128
  });
211
129
  expect(isSelected('sku-1')(state)).toBe(true);
212
130
  });
213
- it('returns false when the SKU is explicitly deselected (false)', () => {
214
- const state = makeState({
215
- selectedSkuIds: {
216
- 'sku-1': false
217
- }
218
- });
219
- expect(isSelected('sku-1')(state)).toBe(false);
131
+ it('returns false when the SKU is not selected', () => {
132
+ expect(isSelected('sku-1')(makeState())).toBe(false);
220
133
  });
221
- it('returns true when a parent collection is selected', () => {
134
+ it('returns true when every style it grants is granted by the selection', () => {
222
135
  const state = makeState({
136
+ 'bundle-sku': [300, ['f1', 'f2']],
137
+ 'family-sku': [500, ['f1']],
138
+ s1: [100, ['f1']]
139
+ }, {
223
140
  selectedSkuIds: {
224
141
  'bundle-sku': true
225
- },
226
- collectionStyleSkus: {
227
- 'bundle-sku': {
228
- fontStyleSkuIds: ['s1'],
229
- fontStyleIds: ['f1'],
230
- childrenSkuIds: ['family-sku'],
231
- name: 'Bundle'
232
- }
233
142
  }
234
143
  });
235
- // Style SKU contained via fontStyleSkuIds
236
144
  expect(isSelected('s1')(state)).toBe(true);
237
- // Family SKU contained via childrenSkuIds
238
145
  expect(isSelected('family-sku')(state)).toBe(true);
239
- // Font style ID contained via fontStyleIds
146
+ // A style with no SKU of its own, by its style id
240
147
  expect(isSelected('f1')(state)).toBe(true);
241
148
  });
242
- it('returns false when a parent collection is deselected', () => {
149
+ it('returns false when only some of its styles are selected', () => {
243
150
  const state = makeState({
151
+ 'family-sku': [500, ['f1', 'f2']],
152
+ s1: [100, ['f1']]
153
+ }, {
244
154
  selectedSkuIds: {
245
- 'bundle-sku': false,
246
- 's1': false
247
- },
248
- collectionStyleSkus: {
249
- 'bundle-sku': {
250
- fontStyleSkuIds: ['s1'],
251
- fontStyleIds: ['f1'],
252
- childrenSkuIds: ['family-sku'],
253
- name: 'Bundle'
254
- }
155
+ s1: true
255
156
  }
256
157
  });
257
- expect(isSelected('s1')(state)).toBe(false);
258
158
  expect(isSelected('family-sku')(state)).toBe(false);
259
159
  });
260
160
  it('returns false for null skuId', () => {
261
- const state = makeState();
262
- expect(isSelected(null)(state)).toBe(false);
263
- });
264
- it('returns true for member when bundle is selected, even if member is explicitly false', () => {
265
- // After auto-selection, members are set to false but should still appear
266
- // selected because the parent bundle is true
267
- const state = makeState({
268
- selectedSkuIds: {
269
- 'bundle-sku': true,
270
- 'family-sku': false,
271
- 'var-sku': false
272
- },
273
- collectionStyleSkus: {
274
- 'bundle-sku': {
275
- fontStyleSkuIds: ['s1', 'vs1'],
276
- fontStyleIds: ['f1', 'vf1'],
277
- childrenSkuIds: ['family-sku', 'var-sku'],
278
- name: 'Bundle'
279
- }
280
- }
281
- });
282
- expect(isSelected('family-sku')(state)).toBe(true);
283
- expect(isSelected('var-sku')(state)).toBe(true);
284
- expect(isSelected('s1')(state)).toBe(true);
285
- });
286
- });
287
-
288
- // =========================================================================
289
- // collectionSkuIdsDifferences
290
- // =========================================================================
291
- describe('collectionSkuIdsDifferences', () => {
292
- it('includes self when skuId matches a collection', () => {
293
- const state = makeState({
294
- collectionStyleSkus: {
295
- 'family-sku': {
296
- fontStyleSkuIds: ['s1', 's2'],
297
- fontStyleIds: ['f1', 'f2'],
298
- childrenSkuIds: [],
299
- name: 'Family'
300
- }
301
- },
302
- skuPrices: {
303
- 'family-sku': 500,
304
- s1: 40,
305
- s2: 40
306
- }
307
- });
308
- const diffs = collectionSkuIdsDifferences(state, 'family-sku');
309
- expect(diffs).toHaveProperty('family-sku');
310
- expect(diffs['family-sku']).toBe(500);
311
- });
312
- it('includes parent collections that contain the skuId', () => {
313
- const state = makeState({
314
- collectionStyleSkus: {
315
- 'family-sku': {
316
- fontStyleSkuIds: ['s1', 's2'],
317
- fontStyleIds: ['f1', 'f2'],
318
- childrenSkuIds: [],
319
- name: 'Family'
320
- },
321
- 'bundle-sku': {
322
- fontStyleSkuIds: ['s1', 's2', 'vs1'],
323
- fontStyleIds: ['f1', 'f2', 'vf1'],
324
- childrenSkuIds: ['family-sku', 'var-family-sku'],
325
- name: 'Bundle'
326
- }
327
- },
328
- skuPrices: {
329
- 'family-sku': 500,
330
- 'bundle-sku': 500,
331
- 'var-family-sku': 300,
332
- s1: 40,
333
- s2: 40,
334
- vs1: 40
335
- }
336
- });
337
- const diffs = collectionSkuIdsDifferences(state, 'family-sku');
338
- expect(diffs).toHaveProperty('family-sku');
339
- expect(diffs).toHaveProperty('bundle-sku');
340
- });
341
- it('subtracts already-selected items from the difference', () => {
342
- const state = makeState({
343
- selectedSkuIds: {
344
- 'family-sku': true
345
- },
346
- collectionStyleSkus: {
347
- 'family-sku': {
348
- fontStyleSkuIds: ['s1', 's2'],
349
- fontStyleIds: ['f1', 'f2'],
350
- childrenSkuIds: [],
351
- name: 'Family'
352
- },
353
- 'bundle-sku': {
354
- fontStyleSkuIds: ['s1', 's2', 'vs1'],
355
- fontStyleIds: ['f1', 'f2', 'vf1'],
356
- childrenSkuIds: ['family-sku', 'var-family-sku'],
357
- name: 'Bundle'
358
- }
359
- },
360
- skuPrices: {
361
- 'family-sku': 500,
362
- 'bundle-sku': 500,
363
- 'var-family-sku': 300
364
- }
365
- });
366
- const diffs = collectionSkuIdsDifferences(state, 'var-family-sku');
367
- expect(diffs['bundle-sku']).toBe(0);
368
- });
369
- it('treats missing prices as 0 rather than producing NaN', () => {
370
- const state = makeState({
371
- selectedSkuIds: {
372
- 'orphan-sku': true
373
- },
374
- collectionStyleSkus: {
375
- 'family-sku': {
376
- fontStyleSkuIds: ['orphan-sku'],
377
- fontStyleIds: [],
378
- childrenSkuIds: [],
379
- name: 'Family'
380
- }
381
- },
382
- skuPrices: {
383
- 'family-sku': 500
384
- // 'orphan-sku' deliberately missing
385
- }
386
- });
387
- const diffs = collectionSkuIdsDifferences(state, 'orphan-sku');
388
- // Missing price treated as 0, so difference = 500 - 0 = 500
389
- expect(diffs['family-sku']).toBe(500);
390
- });
391
- it('only counts items that are selected true, not false', () => {
392
- const state = makeState({
393
- selectedSkuIds: {
394
- 'family-sku': false
395
- },
396
- collectionStyleSkus: {
397
- 'bundle-sku': {
398
- fontStyleSkuIds: [],
399
- fontStyleIds: [],
400
- childrenSkuIds: ['family-sku'],
401
- name: 'Bundle'
402
- },
403
- 'family-sku': {
404
- fontStyleSkuIds: [],
405
- fontStyleIds: [],
406
- childrenSkuIds: [],
407
- name: 'Family'
408
- }
409
- },
410
- skuPrices: {
411
- 'bundle-sku': 500,
412
- 'family-sku': 300
413
- }
414
- });
415
- const diffs = collectionSkuIdsDifferences(state, 'family-sku');
416
- // family-sku is false, not true, so it shouldn't be subtracted
417
- expect(diffs['bundle-sku']).toBe(500);
418
- });
419
- it('returns empty object when no collections contain the skuId', () => {
420
- const state = makeState({
421
- collectionStyleSkus: {
422
- 'other-sku': {
423
- fontStyleSkuIds: ['x1'],
424
- fontStyleIds: ['xf1'],
425
- childrenSkuIds: [],
426
- name: 'Other'
427
- }
428
- },
429
- skuPrices: {
430
- 'other-sku': 100,
431
- 'lonely-style': 40
432
- }
433
- });
434
- const diffs = collectionSkuIdsDifferences(state, 'lonely-style');
435
- expect(Object.keys(diffs)).toHaveLength(0);
161
+ expect(isSelected(null)(makeState())).toBe(false);
436
162
  });
437
163
  });
438
164
 
439
165
  // =========================================================================
440
- // collectionSkuIdWithDiscount
166
+ // additionalCost – the “+$X” next to what isn't selected
441
167
  // =========================================================================
442
- describe('collectionSkuIdWithDiscount', () => {
443
- it('returns null when no collection is a better deal', () => {
444
- const state = makeState({
445
- collectionStyleSkus: {
446
- 'family-sku': {
447
- fontStyleSkuIds: ['s1'],
448
- fontStyleIds: ['f1'],
449
- childrenSkuIds: [],
450
- name: 'Family'
451
- }
452
- },
453
- skuPrices: {
454
- s1: 40,
455
- 'family-sku': 500
456
- }
457
- });
458
- const [collId, amount] = collectionSkuIdWithDiscount(state, 's1');
459
- expect(collId).toBe(null);
460
- expect(amount).toBe(null);
461
- });
462
- it('returns parent collection when it is cheaper for individual style', () => {
463
- const state = makeState({
464
- selectedSkuIds: {
465
- s1: true,
466
- s2: true,
467
- s3: true,
468
- s4: true
469
- },
470
- collectionStyleSkus: {
471
- 'family-sku': {
472
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5'],
473
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5'],
474
- childrenSkuIds: [],
475
- name: 'Family'
476
- }
477
- },
478
- skuPrices: {
479
- 'family-sku': 200,
480
- s1: 40,
481
- s2: 40,
482
- s3: 40,
483
- s4: 40,
484
- s5: 40
485
- }
486
- });
487
- const [collId, amount] = collectionSkuIdWithDiscount(state, 's5');
488
- expect(collId).toBe('family-sku');
489
- expect(amount).toBe(40);
490
- });
491
- it('prefers collection bundle over self when prices are equal', () => {
492
- const state = makeState({
493
- collectionStyleSkus: {
494
- 'family-sku': {
495
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5'],
496
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5'],
497
- childrenSkuIds: [],
498
- name: 'Thunder Text'
499
- },
500
- 'var-family-sku': {
501
- fontStyleSkuIds: ['vs1'],
502
- fontStyleIds: ['vf1'],
503
- childrenSkuIds: [],
504
- name: 'Thunder Text Variable'
505
- },
506
- 'var-italic-sku': {
507
- fontStyleSkuIds: ['vis1'],
508
- fontStyleIds: ['vif1'],
509
- childrenSkuIds: [],
510
- name: 'Thunder Text Italic Variable'
511
- },
512
- 'bundle-sku': {
513
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5', 'vs1', 'vis1'],
514
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5', 'vf1', 'vif1'],
515
- childrenSkuIds: ['family-sku', 'var-family-sku', 'var-italic-sku'],
516
- name: 'Thunder Text Family + Variable'
517
- }
518
- },
519
- skuPrices: {
520
- 'family-sku': 500,
521
- 'bundle-sku': 500,
522
- 'var-family-sku': 300,
523
- 'var-italic-sku': 300,
524
- s1: 40,
525
- s2: 40,
526
- s3: 40,
527
- s4: 40,
528
- s5: 40,
529
- vs1: 40,
530
- vis1: 40
531
- }
532
- });
533
- const [collId, amount] = collectionSkuIdWithDiscount(state, 'family-sku');
534
- expect(collId).toBe('bundle-sku');
535
- expect(amount).toBe(500);
536
- });
537
- it('prefers collection bundle regardless of object key order', () => {
538
- const state = makeState({
539
- collectionStyleSkus: {
540
- 'bundle-sku': {
541
- fontStyleSkuIds: ['s1', 's2', 'vs1'],
542
- fontStyleIds: ['f1', 'f2', 'vf1'],
543
- childrenSkuIds: ['family-sku', 'var-family-sku'],
544
- name: 'Bundle'
545
- },
546
- 'family-sku': {
547
- fontStyleSkuIds: ['s1', 's2'],
548
- fontStyleIds: ['f1', 'f2'],
549
- childrenSkuIds: [],
550
- name: 'Family'
551
- }
552
- },
553
- skuPrices: {
554
- 'family-sku': 500,
555
- 'bundle-sku': 500,
556
- 'var-family-sku': 300
557
- }
558
- });
559
- const [collId] = collectionSkuIdWithDiscount(state, 'family-sku');
560
- expect(collId).toBe('bundle-sku');
561
- });
562
- it('still returns self for display pricing (superfamily with no parent)', () => {
563
- const state = makeState({
564
- selectedSkuIds: {
565
- 'family-sku': true
566
- },
567
- collectionStyleSkus: {
568
- 'superfamily-sku': {
569
- fontStyleSkuIds: ['s1', 's2', 'ds1', 'ds2'],
570
- fontStyleIds: ['f1', 'f2', 'df1', 'df2'],
571
- childrenSkuIds: ['family-sku', 'display-family-sku'],
572
- name: 'Superfamily'
573
- },
574
- 'family-sku': {
575
- fontStyleSkuIds: ['s1', 's2'],
576
- fontStyleIds: ['f1', 'f2'],
577
- childrenSkuIds: [],
578
- name: 'Family'
579
- },
580
- 'display-family-sku': {
581
- fontStyleSkuIds: ['ds1', 'ds2'],
582
- fontStyleIds: ['df1', 'df2'],
583
- childrenSkuIds: [],
584
- name: 'Display'
585
- }
586
- },
587
- skuPrices: {
588
- 'superfamily-sku': 750,
589
- 'family-sku': 500,
590
- 'display-family-sku': 500
591
- }
592
- });
593
- const [collId, amount] = collectionSkuIdWithDiscount(state, 'superfamily-sku');
594
- expect(collId).toBe('superfamily-sku');
595
- expect(amount).toBe(250);
596
- });
597
- it('prefers bundle at $0 when family is already selected', () => {
598
- const state = makeState({
599
- selectedSkuIds: {
600
- 'family-sku': true
601
- },
602
- collectionStyleSkus: {
603
- 'family-sku': {
604
- fontStyleSkuIds: ['s1', 's2'],
605
- fontStyleIds: ['f1', 'f2'],
606
- childrenSkuIds: [],
607
- name: 'Family'
608
- },
609
- 'var-family-sku': {
610
- fontStyleSkuIds: ['vs1'],
611
- fontStyleIds: ['vf1'],
612
- childrenSkuIds: [],
613
- name: 'Variable'
614
- },
615
- 'bundle-sku': {
616
- fontStyleSkuIds: ['s1', 's2', 'vs1'],
617
- fontStyleIds: ['f1', 'f2', 'vf1'],
618
- childrenSkuIds: ['family-sku', 'var-family-sku'],
619
- name: 'Bundle'
620
- }
621
- },
622
- skuPrices: {
623
- 'family-sku': 500,
624
- 'var-family-sku': 300,
625
- 'bundle-sku': 500
626
- }
627
- });
628
- const [collId, amount] = collectionSkuIdWithDiscount(state, 'var-family-sku');
629
- expect(collId).toBe('bundle-sku');
630
- expect(amount).toBe(0);
168
+ describe('additionalCost', () => {
169
+ const FIVE_STYLES = {
170
+ 'family-sku': [150, ['f1', 'f2', 'f3', 'f4', 'f5']],
171
+ s1: [40, ['f1']],
172
+ s2: [40, ['f2']],
173
+ s3: [40, ['f3']],
174
+ s4: [40, ['f4']],
175
+ s5: [40, ['f5']]
176
+ };
177
+ it('is the SKU’s own price when nothing cheaper holds it', () => {
178
+ expect(additionalCost(select(makeState(FIVE_STYLES), 's1'), 's2')).toBe(40);
631
179
  });
632
- it('does not upgrade when collection bundle is more expensive', () => {
633
- const state = makeState({
634
- collectionStyleSkus: {
635
- 'family-sku': {
636
- fontStyleSkuIds: ['s1', 's2'],
637
- fontStyleIds: ['f1', 'f2'],
638
- childrenSkuIds: [],
639
- name: 'Family'
640
- },
641
- 'bundle-sku': {
642
- fontStyleSkuIds: ['s1', 's2', 'vs1'],
643
- fontStyleIds: ['f1', 'f2', 'vf1'],
644
- childrenSkuIds: ['family-sku', 'var-family-sku'],
645
- name: 'Bundle'
646
- }
647
- },
648
- skuPrices: {
649
- 'family-sku': 500,
650
- 'bundle-sku': 600
651
- }
652
- });
653
- const [collId] = collectionSkuIdWithDiscount(state, 'family-sku');
654
- expect(collId).toBe('family-sku');
180
+ it('is what completing a cheaper bundle adds', () => {
181
+ // Three styles selected (120); the family (150) holds a fourth for 30 more
182
+ expect(additionalCost(select(makeState(FIVE_STYLES), 's1', 's2', 's3'), 's4')).toBe(30);
655
183
  });
656
- it('returns null when the selected SKU has no price (undefined)', () => {
657
- // If skuPrices[skuId] is undefined, the filter comparison
658
- // `difference <= undefined` is always false → no collection found
659
- const state = makeState({
660
- collectionStyleSkus: {
661
- 'family-sku': {
662
- fontStyleSkuIds: ['s1'],
663
- fontStyleIds: ['f1'],
664
- childrenSkuIds: [],
665
- name: 'Family'
666
- }
667
- },
668
- skuPrices: {
669
- 'family-sku': 500
670
- } // s1 deliberately missing
671
- });
672
- const [collId, amount] = collectionSkuIdWithDiscount(state, 's1');
673
- expect(collId).toBe(null);
674
- expect(amount).toBe(null);
184
+ it('is zero for what the selection already holds', () => {
185
+ expect(additionalCost(select(makeState(FIVE_STYLES), 'family-sku'), 's1')).toBe(0);
675
186
  });
676
- it('treats collections with missing price as price 0', () => {
677
- // A collection with no price entry is treated as $0, meaning its
678
- // difference = 0 - sum_selected. With nothing selected, diff = 0.
187
+ it('is null when the SKU’s price or styles are unknown', () => {
679
188
  const state = makeState({
680
- collectionStyleSkus: {
681
- 'ghost-bundle': {
682
- fontStyleSkuIds: ['s1'],
683
- fontStyleIds: ['f1'],
684
- childrenSkuIds: ['family-sku'],
685
- name: 'Ghost Bundle'
686
- },
687
- 'family-sku': {
688
- fontStyleSkuIds: ['s1'],
689
- fontStyleIds: ['f1'],
690
- childrenSkuIds: [],
691
- name: 'Family'
692
- }
693
- },
694
- skuPrices: {
695
- 'family-sku': 500,
696
- s1: 40
697
- }
698
- // 'ghost-bundle' price deliberately missing → treated as 0
189
+ 'unpriced-sku': [undefined, ['f1']]
699
190
  });
700
- const [collId, amount] = collectionSkuIdWithDiscount(state, 'family-sku');
701
- // ghost-bundle diff = 0, family diff = 500. ghost-bundle is cheaper.
702
- // 0 <= 500 ✓ → ghost-bundle wins
703
- expect(collId).toBe('ghost-bundle');
704
- expect(amount).toBe(0);
191
+ expect(additionalCost(state, 'unpriced-sku')).toBeNull();
192
+ expect(additionalCost(state, 'unknown-sku')).toBeNull();
705
193
  });
706
- it('handles zero-price SKUs without errors', () => {
194
+ it('handles zero-price SKUs', () => {
707
195
  const state = makeState({
708
- collectionStyleSkus: {
709
- 'free-family': {
710
- fontStyleSkuIds: ['fs1'],
711
- fontStyleIds: ['ff1'],
712
- childrenSkuIds: [],
713
- name: 'Free Family'
714
- },
715
- 'free-bundle': {
716
- fontStyleSkuIds: ['fs1', 'fvs1'],
717
- fontStyleIds: ['ff1', 'fvf1'],
718
- childrenSkuIds: ['free-family', 'free-var'],
719
- name: 'Free Bundle'
720
- }
721
- },
722
- skuPrices: {
723
- 'free-family': 0,
724
- 'free-bundle': 0,
725
- 'free-var': 0,
726
- fs1: 0,
727
- fvs1: 0
728
- }
196
+ 'free-sku': [0, ['f1']],
197
+ s2: [40, ['f2']]
729
198
  });
730
-
731
- // Both have diff = 0, filter: 0 <= 0 ✓, tie-break prefers bundle
732
- const [collId, amount] = collectionSkuIdWithDiscount(state, 'free-family');
733
- expect(collId).toBe('free-bundle');
734
- expect(amount).toBe(0);
735
- });
736
- it('handles negative difference (collection cheaper than sum of selected)', () => {
737
- // Unusual but possible: if prices change or a collection is deeply discounted
738
- const state = makeState({
739
- selectedSkuIds: {
740
- s1: true,
741
- s2: true,
742
- s3: true
743
- },
744
- collectionStyleSkus: {
745
- 'family-sku': {
746
- fontStyleSkuIds: ['s1', 's2', 's3', 's4'],
747
- fontStyleIds: ['f1', 'f2', 'f3', 'f4'],
748
- childrenSkuIds: [],
749
- name: 'Family'
750
- }
751
- },
752
- skuPrices: {
753
- 'family-sku': 100,
754
- s1: 40,
755
- s2: 40,
756
- s3: 40,
757
- s4: 40
758
- }
759
- });
760
-
761
- // Family costs $100, 3 styles selected = $120, diff = $100 - $120 = -$20
762
- // This means the collection is $20 cheaper than what's already selected
763
- const [collId, amount] = collectionSkuIdWithDiscount(state, 's4');
764
- expect(collId).toBe('family-sku');
765
- expect(amount).toBe(-20);
199
+ expect(additionalCost(select(state, 's2'), 'free-sku')).toBe(0);
766
200
  });
767
201
  });
768
202
 
@@ -771,46 +205,25 @@ describe('collectionSkuIdWithDiscount', () => {
771
205
  // =========================================================================
772
206
  describe('reducer SELECT_SKU_ID with collection bundles', () => {
773
207
  it('auto-selects collection bundle when selecting a family at the same price', () => {
774
- const state = makeThunderState();
775
- const newState = reducer(state, {
776
- type: 'SELECT_SKU_ID',
777
- skuId: 'text-sku',
778
- selected: true
779
- });
780
- expect(newState.selectedSkuIds['text-bundle-sku']).toBe(true);
781
- expect(newState.selectedSkuIds['text-sku']).toBe(false);
782
- expect(newState.selectedSkuIds['text-var-sku']).toBe(false);
783
- expect(newState.selectedSkuIds['text-var-italic-sku']).toBe(false);
208
+ expect(selected(select(makeThunderState(), 'text-sku'))).toEqual(['text-bundle-sku']);
784
209
  });
785
210
  it('does not auto-select bundle when bundle is more expensive', () => {
786
211
  const state = makeThunderState({
787
- skuPrices: {
788
- ...makeThunderState().skuPrices,
789
- 'text-bundle-sku': 600 // more expensive than text-sku ($500)
790
- }
212
+ 'text-bundle-sku': 600
791
213
  });
792
- const newState = reducer(state, {
793
- type: 'SELECT_SKU_ID',
794
- skuId: 'text-sku',
795
- selected: true
796
- });
797
- expect(newState.selectedSkuIds['text-sku']).toBe(true);
798
- expect(newState.selectedSkuIds['text-bundle-sku']).toBeUndefined();
214
+ expect(selected(select(state, 'text-sku'))).toEqual(['text-sku']);
799
215
  });
800
216
  it('auto-selects bundle when clicking variable family with static already selected', () => {
217
+ expect(selected(select(makeThunderState(), 'text-sku', 'text-var-sku'))).toEqual(['text-bundle-sku']);
218
+ });
219
+ it('never splits a pick into cheaper parts', () => {
220
+ // The styles cost 80 together, but a family bought is not the same
221
+ // purchase as today's styles bought one by one
801
222
  const state = makeThunderState({
802
- selectedSkuIds: {
803
- 'text-sku': true
804
- }
223
+ 'text-bundle-sku': 600,
224
+ 'super-sku': 2000
805
225
  });
806
- const newState = reducer(state, {
807
- type: 'SELECT_SKU_ID',
808
- skuId: 'text-var-sku',
809
- selected: true
810
- });
811
- expect(newState.selectedSkuIds['text-bundle-sku']).toBe(true);
812
- expect(newState.selectedSkuIds['text-sku']).toBe(false);
813
- expect(newState.selectedSkuIds['text-var-sku']).toBe(false);
226
+ expect(selected(select(state, 'text-sku'))).toEqual(['text-sku']);
814
227
  });
815
228
  });
816
229
 
@@ -819,41 +232,45 @@ describe('reducer SELECT_SKU_ID with collection bundles', () => {
819
232
  // =========================================================================
820
233
  describe('reducer SELECT_SKU_ID with multiple bundles', () => {
821
234
  it('only selects the correct bundle for the family being clicked', () => {
822
- const state = makeThunderState();
823
- const newState = reducer(state, {
824
- type: 'SELECT_SKU_ID',
825
- skuId: 'display-sku',
826
- selected: true
827
- });
828
-
829
- // Display bundle should be selected
830
- expect(newState.selectedSkuIds['display-bundle-sku']).toBe(true);
831
- expect(newState.selectedSkuIds['display-sku']).toBe(false);
832
- // Text and caption bundles should be untouched
833
- expect(newState.selectedSkuIds['text-bundle-sku']).toBeUndefined();
834
- expect(newState.selectedSkuIds['caption-bundle-sku']).toBeUndefined();
235
+ expect(selected(select(makeThunderState(), 'display-sku'))).toEqual(['display-bundle-sku']);
835
236
  });
836
237
  it('upgrades to superfamily when selecting a second family bundle', () => {
837
- // Selecting two families ($500+$500) costs more than the superfamily ($750),
838
- // so the system correctly upgrades to the superfamily on the second click
839
- const state = makeThunderState();
840
- let newState = reducer(state, {
841
- type: 'SELECT_SKU_ID',
842
- skuId: 'text-sku',
843
- selected: true
844
- });
845
- expect(newState.selectedSkuIds['text-bundle-sku']).toBe(true);
846
- newState = reducer(newState, {
847
- type: 'SELECT_SKU_ID',
848
- skuId: 'display-sku',
849
- selected: true
850
- });
238
+ // Two families ($500 + $500) cost more than the superfamily ($750)
239
+ expect(selected(select(makeThunderState(), 'text-sku', 'display-sku'))).toEqual(['super-sku']);
240
+ });
241
+ });
851
242
 
852
- // Superfamily ($750) beats two bundles ($500 + $500 = $1000)
853
- expect(newState.selectedSkuIds['super-sku']).toBe(true);
854
- // Previous bundle should be deselected (subsumed by superfamily)
855
- expect(newState.selectedSkuIds['text-bundle-sku']).toBe(false);
856
- expect(newState.selectedSkuIds['display-bundle-sku']).toBe(false);
243
+ // =========================================================================
244
+ // reducer SELECT_SKU_ID — order independence (FD-1238)
245
+ // =========================================================================
246
+ describe('reducer selection does not depend on click order', () => {
247
+ // Overlapping style bundles: every pair of the three uprights, each upright
248
+ // with its italic, and the three uprights together
249
+ const OVERLAPPING = {
250
+ light: [90, ['light']],
251
+ regular: [90, ['regular']],
252
+ bold: [90, ['bold']],
253
+ italic: [90, ['italic']],
254
+ 'bold-italic': [90, ['bold-italic']],
255
+ 'light+regular': [153, ['light', 'regular']],
256
+ 'light+bold': [153, ['light', 'bold']],
257
+ 'regular+bold': [153, ['regular', 'bold']],
258
+ 'regular+italic': [144, ['regular', 'italic']],
259
+ 'bold+bold-italic': [144, ['bold', 'bold-italic']],
260
+ 'light+regular+bold': [202.5, ['light', 'regular', 'bold']]
261
+ };
262
+ it('reaches a triple that every pair inside it would otherwise snap to first', () => {
263
+ const state = makeState(OVERLAPPING);
264
+ expect(selected(select(state, 'bold', 'regular', 'light'))).toEqual(['light+regular+bold']);
265
+ expect(selected(select(state, 'light', 'regular', 'bold'))).toEqual(['light+regular+bold']);
266
+ });
267
+ it('re-partitions a pair when a cheaper split appears', () => {
268
+ const state = makeState(OVERLAPPING);
269
+ expect(selected(select(state, 'bold', 'regular', 'bold-italic', 'italic'))).toEqual(['bold+bold-italic', 'regular+italic']);
270
+ });
271
+ it('deselecting a style keeps the rest of its bundle', () => {
272
+ const state = select(makeState(OVERLAPPING), 'bold', 'regular', 'light');
273
+ expect(selected(deselect(state, 'regular'))).toEqual(['light+bold']);
857
274
  });
858
275
  });
859
276
 
@@ -862,161 +279,61 @@ describe('reducer SELECT_SKU_ID with multiple bundles', () => {
862
279
  // =========================================================================
863
280
  describe('reducer deselection', () => {
864
281
  it('deselecting a bundle member deselects the entire bundle', () => {
865
- // Start with the text bundle selected (members are false)
866
- const state = makeThunderState({
867
- selectedSkuIds: {
868
- 'text-bundle-sku': true,
869
- 'text-sku': false,
870
- 'text-var-sku': false,
871
- 'text-var-italic-sku': false,
872
- s1: false,
873
- s2: false,
874
- vs1: false,
875
- vis1: false
876
- }
877
- });
878
-
879
- // User clicks to deselect "Thunder Text Variable"
880
- const newState = reducer(state, {
881
- type: 'SELECT_SKU_ID',
882
- skuId: 'text-var-sku',
883
- selected: false
884
- });
282
+ const state = select(makeThunderState(), 'text-sku');
283
+ expect(selected(state)).toEqual(['text-bundle-sku']);
885
284
 
886
- // The bundle should be deselected
887
- expect(newState.selectedSkuIds['text-bundle-sku']).toBe(false);
285
+ // The variable comes free with the bundle, so taking it out alone would
286
+ // change nothing: the bundle goes
287
+ expect(selected(deselect(state, 'text-var-sku'))).toEqual([]);
888
288
  });
889
289
  it('deselecting a font style deselects the parent bundle', () => {
890
- const state = makeThunderState({
891
- selectedSkuIds: {
892
- 'text-bundle-sku': true,
893
- 'text-sku': false,
894
- 'text-var-sku': false,
895
- 'text-var-italic-sku': false,
896
- s1: false,
897
- s2: false,
898
- vs1: false,
899
- vis1: false
900
- }
901
- });
902
-
903
- // User clicks to deselect individual style "s1"
904
- const newState = reducer(state, {
905
- type: 'SELECT_SKU_ID',
906
- skuId: 's1',
907
- selected: false
908
- });
909
- expect(newState.selectedSkuIds['text-bundle-sku']).toBe(false);
290
+ const state = select(makeThunderState(), 'text-sku');
291
+ const after = deselect(state, 's1');
292
+ expect(selected(after)).toEqual([]);
293
+ expect(isSelected('s1')(after)).toBe(false);
294
+ });
295
+ it('deselecting a style a cheaper family still holds deselects the family', () => {
296
+ const catalog = {
297
+ 'family-sku': [200, ['f1', 'f2', 'f3', 'f4', 'f5']],
298
+ s1: [50, ['f1']],
299
+ s2: [50, ['f2']],
300
+ s3: [50, ['f3']],
301
+ s4: [50, ['f4']],
302
+ s5: [50, ['f5']]
303
+ };
304
+ const state = select(makeState(catalog), 's1', 's2', 's3', 's4', 's5');
305
+ expect(selected(state)).toEqual(['family-sku']);
306
+ // Without s5 the family still costs no more than four styles, so it would
307
+ // stay selected – the click takes the family out instead
308
+ expect(selected(deselect(state, 's5'))).toEqual([]);
910
309
  });
911
310
  it('deselecting a family bundle deselects the parent collection bundle (transitive)', () => {
912
- // Collection bundle is selected. A family bundle (style package) within
913
- // it is shown as selected via transitive isSelected. Clicking to deselect
914
- // it should find and deselect the collection bundle.
915
- const state = makeState({
916
- selectedSkuIds: {
917
- 'coll-bundle-sku': true,
918
- 'family-sku': false,
919
- 'var-sku': false,
920
- 'pkg-upright-sku': false,
921
- 'pkg-italic-sku': false,
922
- s1: false,
923
- s2: false,
924
- si1: false,
925
- si2: false,
926
- vs1: false
927
- },
928
- collectionStyleSkus: {
929
- 'coll-bundle-sku': {
930
- fontStyleSkuIds: ['s1', 's2', 'si1', 'si2', 'vs1'],
931
- fontStyleIds: ['f1', 'f2', 'fi1', 'fi2', 'vf1'],
932
- childrenSkuIds: ['family-sku', 'var-sku'],
933
- name: 'Text + Variable'
934
- },
935
- 'family-sku': {
936
- fontStyleSkuIds: ['s1', 's2', 'si1', 'si2'],
937
- fontStyleIds: ['f1', 'f2', 'fi1', 'fi2'],
938
- childrenSkuIds: ['pkg-upright-sku', 'pkg-italic-sku'],
939
- name: 'Text Family'
940
- },
941
- 'var-sku': {
942
- fontStyleSkuIds: ['vs1'],
943
- fontStyleIds: ['vf1'],
944
- childrenSkuIds: [],
945
- name: 'Variable'
946
- },
947
- 'pkg-upright-sku': {
948
- fontStyleSkuIds: ['s1', 's2'],
949
- fontStyleIds: ['f1', 'f2'],
950
- childrenSkuIds: [],
951
- name: 'Package Text'
952
- },
953
- 'pkg-italic-sku': {
954
- fontStyleSkuIds: ['si1', 'si2'],
955
- fontStyleIds: ['fi1', 'fi2'],
956
- childrenSkuIds: [],
957
- name: 'Package Italic'
958
- }
959
- },
960
- skuPrices: {
961
- 'coll-bundle-sku': 500,
962
- 'family-sku': 500,
963
- 'var-sku': 300,
964
- 'pkg-upright-sku': 250,
965
- 'pkg-italic-sku': 250,
966
- s1: 100,
967
- s2: 100,
968
- si1: 100,
969
- si2: 100,
970
- vs1: 100
971
- }
972
- });
973
-
974
- // Bundle shows as selected via transitive isSelected
311
+ const state = select(makeState({
312
+ 'coll-bundle-sku': [500, ['f1', 'f2', 'fi1', 'fi2', 'vf1']],
313
+ 'family-sku': [500, ['f1', 'f2', 'fi1', 'fi2']],
314
+ 'var-sku': [300, ['vf1']],
315
+ 'pkg-upright-sku': [250, ['f1', 'f2']],
316
+ 'pkg-italic-sku': [250, ['fi1', 'fi2']],
317
+ s1: [100, ['f1']],
318
+ s2: [100, ['f2']],
319
+ si1: [100, ['fi1']],
320
+ si2: [100, ['fi2']],
321
+ vs1: [100, ['vf1']]
322
+ }), 'family-sku');
323
+ expect(selected(state)).toEqual(['coll-bundle-sku']);
975
324
  expect(isSelected('pkg-upright-sku')(state)).toBe(true);
976
-
977
- // Click to deselect the bundle
978
- const newState = reducer(state, {
979
- type: 'SELECT_SKU_ID',
980
- skuId: 'pkg-upright-sku',
981
- selected: false
982
- });
983
-
984
- // Collection bundle should be deselected
985
- expect(newState.selectedSkuIds['coll-bundle-sku']).toBe(false);
986
- // Nothing should be selected anymore
987
- expect(isSelected('pkg-upright-sku')(newState)).toBe(false);
988
- expect(isSelected('family-sku')(newState)).toBe(false);
989
- expect(isSelected('s1')(newState)).toBe(false);
325
+ const after = deselect(state, 'pkg-upright-sku');
326
+ expect(selected(after)).toEqual([]);
327
+ expect(isSelected('pkg-upright-sku')(after)).toBe(false);
328
+ expect(isSelected('family-sku')(after)).toBe(false);
329
+ expect(isSelected('s1')(after)).toBe(false);
990
330
  });
991
331
  it('deselecting a bundle does not affect other bundles', () => {
992
- const state = makeThunderState({
993
- selectedSkuIds: {
994
- 'text-bundle-sku': true,
995
- 'text-sku': false,
996
- 'text-var-sku': false,
997
- 'text-var-italic-sku': false,
998
- s1: false,
999
- s2: false,
1000
- vs1: false,
1001
- vis1: false,
1002
- 'display-bundle-sku': true,
1003
- 'display-sku': false,
1004
- 'display-var-sku': false,
1005
- 'display-var-italic-sku': false,
1006
- ds1: false,
1007
- ds2: false,
1008
- dvs1: false,
1009
- dvis1: false
1010
- }
1011
- });
1012
- const newState = reducer(state, {
1013
- type: 'SELECT_SKU_ID',
1014
- skuId: 'text-var-sku',
1015
- selected: false
1016
- });
1017
- expect(newState.selectedSkuIds['text-bundle-sku']).toBe(false);
1018
- // Display bundle should be unaffected
1019
- expect(newState.selectedSkuIds['display-bundle-sku']).toBe(true);
332
+ const state = select(makeThunderState({
333
+ 'super-sku': 2000
334
+ }), 'text-sku', 'display-sku');
335
+ expect(selected(state)).toEqual(['display-bundle-sku', 'text-bundle-sku']);
336
+ expect(selected(deselect(state, 'text-var-sku'))).toEqual(['display-bundle-sku']);
1020
337
  });
1021
338
  });
1022
339
 
@@ -1025,37 +342,10 @@ describe('reducer deselection', () => {
1025
342
  // =========================================================================
1026
343
  describe('reducer re-selection cycle', () => {
1027
344
  it('re-selecting family after bundle was deselected re-selects the bundle', () => {
1028
- // 1. Start with bundle selected
1029
- const state = makeThunderState({
1030
- selectedSkuIds: {
1031
- 'text-bundle-sku': true,
1032
- 'text-sku': false,
1033
- 'text-var-sku': false,
1034
- 'text-var-italic-sku': false,
1035
- s1: false,
1036
- s2: false,
1037
- vs1: false,
1038
- vis1: false
1039
- }
1040
- });
1041
-
1042
- // 2. Deselect a member → bundle goes away
1043
- const afterDeselect = reducer(state, {
1044
- type: 'SELECT_SKU_ID',
1045
- skuId: 'text-var-sku',
1046
- selected: false
1047
- });
1048
- expect(afterDeselect.selectedSkuIds['text-bundle-sku']).toBe(false);
1049
-
1050
- // 3. Re-select the family → bundle should come back
1051
- const afterReselect = reducer(afterDeselect, {
1052
- type: 'SELECT_SKU_ID',
1053
- skuId: 'text-sku',
1054
- selected: true
1055
- });
1056
- expect(afterReselect.selectedSkuIds['text-bundle-sku']).toBe(true);
1057
- expect(afterReselect.selectedSkuIds['text-sku']).toBe(false);
1058
- expect(afterReselect.selectedSkuIds['text-var-sku']).toBe(false);
345
+ const state = select(makeThunderState(), 'text-sku');
346
+ const afterDeselect = deselect(state, 'text-var-sku');
347
+ expect(selected(afterDeselect)).toEqual([]);
348
+ expect(selected(select(afterDeselect, 'text-sku'))).toEqual(['text-bundle-sku']);
1059
349
  });
1060
350
  });
1061
351
 
@@ -1064,37 +354,12 @@ describe('reducer re-selection cycle', () => {
1064
354
  // =========================================================================
1065
355
  describe('reducer superfamily upgrade', () => {
1066
356
  it('upgrades to superfamily when cumulative selections make it cheaper', () => {
1067
- // With two bundles selected ($500 each = $1000 total), the superfamily
1068
- // at $750 should be a better deal when selecting the third family
1069
- const state = makeThunderState({
1070
- selectedSkuIds: {
1071
- 'text-bundle-sku': true,
1072
- 'text-sku': false,
1073
- 'text-var-sku': false,
1074
- 'text-var-italic-sku': false,
1075
- s1: false,
1076
- s2: false,
1077
- vs1: false,
1078
- vis1: false,
1079
- 'display-bundle-sku': true,
1080
- 'display-sku': false,
1081
- 'display-var-sku': false,
1082
- 'display-var-italic-sku': false,
1083
- ds1: false,
1084
- ds2: false,
1085
- dvs1: false,
1086
- dvis1: false
1087
- }
1088
- });
1089
- const newState = reducer(state, {
1090
- type: 'SELECT_SKU_ID',
1091
- skuId: 'caption-sku',
1092
- selected: true
1093
- });
1094
-
1095
- // Superfamily ($750) - two bundles ($500+$500) = -$250 difference
1096
- // Caption-sku costs $500, so -$250 <= $500 → superfamily is preferred
1097
- expect(newState.selectedSkuIds['super-sku']).toBe(true);
357
+ const state = select(makeThunderState({
358
+ 'super-sku': 1200
359
+ }), 'text-sku', 'display-sku');
360
+ expect(selected(state)).toEqual(['display-bundle-sku', 'text-bundle-sku']);
361
+ // A third $500 bundle would make $1,500
362
+ expect(selected(select(state, 'caption-sku'))).toEqual(['super-sku']);
1098
363
  });
1099
364
  });
1100
365
 
@@ -1102,77 +367,21 @@ describe('reducer superfamily upgrade', () => {
1102
367
  // reducer SELECT_SKU_ID — individual style to family upgrade
1103
368
  // =========================================================================
1104
369
  describe('reducer individual style upgrade', () => {
370
+ const FAMILY = {
371
+ 'family-sku': [200, ['f1', 'f2', 'f3', 'f4', 'f5']],
372
+ s1: [40, ['f1']],
373
+ s2: [40, ['f2']],
374
+ s3: [40, ['f3']],
375
+ s4: [40, ['f4']],
376
+ s5: [40, ['f5']]
377
+ };
1105
378
  it('upgrades to family when enough styles are individually selected', () => {
1106
- const state = makeState({
1107
- selectedSkuIds: {
1108
- s1: true,
1109
- s2: true,
1110
- s3: true,
1111
- s4: true
1112
- },
1113
- collectionStyleSkus: {
1114
- 'family-sku': {
1115
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5'],
1116
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5'],
1117
- childrenSkuIds: [],
1118
- name: 'Family'
1119
- }
1120
- },
1121
- skuPrices: {
1122
- 'family-sku': 200,
1123
- s1: 40,
1124
- s2: 40,
1125
- s3: 40,
1126
- s4: 40,
1127
- s5: 40
1128
- }
1129
- });
1130
-
1131
- // Family is $200, 4 selected = $160, diff = $40. Style s5 costs $40.
1132
- // $40 <= $40 → family is a better deal
1133
- const newState = reducer(state, {
1134
- type: 'SELECT_SKU_ID',
1135
- skuId: 's5',
1136
- selected: true
1137
- });
1138
- expect(newState.selectedSkuIds['family-sku']).toBe(true);
1139
- // Individual styles should be deselected (included via family)
1140
- expect(newState.selectedSkuIds['s1']).toBe(false);
1141
- expect(newState.selectedSkuIds['s5']).toBe(false);
379
+ const state = select(makeState(FAMILY), 's1', 's2', 's3', 's4', 's5');
380
+ expect(selected(state)).toEqual(['family-sku']);
381
+ expect(isSelected('s1')(state)).toBe(true);
1142
382
  });
1143
383
  it('does not upgrade to family when not yet cost-effective', () => {
1144
- const state = makeState({
1145
- selectedSkuIds: {
1146
- s1: true
1147
- },
1148
- collectionStyleSkus: {
1149
- 'family-sku': {
1150
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5'],
1151
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5'],
1152
- childrenSkuIds: [],
1153
- name: 'Family'
1154
- }
1155
- },
1156
- skuPrices: {
1157
- 'family-sku': 200,
1158
- s1: 40,
1159
- s2: 40,
1160
- s3: 40,
1161
- s4: 40,
1162
- s5: 40
1163
- }
1164
- });
1165
-
1166
- // Family is $200, 1 selected = $40, diff = $160. Style s2 costs $40.
1167
- // $160 > $40 → not worth upgrading
1168
- const newState = reducer(state, {
1169
- type: 'SELECT_SKU_ID',
1170
- skuId: 's2',
1171
- selected: true
1172
- });
1173
- expect(newState.selectedSkuIds['family-sku']).toBeUndefined();
1174
- expect(newState.selectedSkuIds['s1']).toBe(true);
1175
- expect(newState.selectedSkuIds['s2']).toBe(true);
384
+ expect(selected(select(makeState(FAMILY), 's1', 's2'))).toEqual(['s1', 's2']);
1176
385
  });
1177
386
  });
1178
387
 
@@ -1180,81 +389,32 @@ describe('reducer individual style upgrade', () => {
1180
389
  // reducer SELECT_SKU_ID — edge cases with missing data
1181
390
  // =========================================================================
1182
391
  describe('reducer edge cases', () => {
1183
- it('handles selection when collectionStyleSkus is empty', () => {
1184
- const state = makeState({
392
+ it('selects a SKU whose styles aren’t known as it is', () => {
393
+ const state = makeState({}, {
1185
394
  skuPrices: {
1186
395
  'some-sku': 100
1187
396
  }
1188
397
  });
1189
- const newState = reducer(state, {
1190
- type: 'SELECT_SKU_ID',
1191
- skuId: 'some-sku',
1192
- selected: true
1193
- });
1194
- expect(newState.selectedSkuIds['some-sku']).toBe(true);
398
+ expect(selected(select(state, 'some-sku'))).toEqual(['some-sku']);
1195
399
  });
1196
- it('handles selection when skuPrices is empty', () => {
400
+ it('selects a SKU whose price isn’t known as it is', () => {
1197
401
  const state = makeState({
1198
- collectionStyleSkus: {
1199
- 'family-sku': {
1200
- fontStyleSkuIds: ['s1'],
1201
- fontStyleIds: ['f1'],
1202
- childrenSkuIds: [],
1203
- name: 'Family'
1204
- }
1205
- }
402
+ 'family-sku': [undefined, ['f1']],
403
+ s1: [undefined, ['f1']]
1206
404
  });
1207
-
1208
- // No prices → no collection upgrade possible, just select directly
1209
- const newState = reducer(state, {
1210
- type: 'SELECT_SKU_ID',
1211
- skuId: 's1',
1212
- selected: true
1213
- });
1214
- expect(newState.selectedSkuIds['s1']).toBe(true);
1215
- expect(newState.selectedSkuIds['family-sku']).toBeUndefined();
405
+ expect(selected(select(state, 's1'))).toEqual(['s1']);
1216
406
  });
1217
- it('re-selecting a bundle member may upgrade to superfamily', () => {
1218
- // When the text bundle is selected and the user clicks text-sku (a member),
1219
- // the system re-evaluates: the superfamily sees the text bundle ($500) as
1220
- // already selected, making its difference only $250 — cheaper than text-sku
1221
- // at $500. So it upgrades to the superfamily.
1222
- //
1223
- // This is a documented consequence of the greedy price-optimization: every
1224
- // SELECT_SKU_ID re-evaluates the cheapest collection, even for members
1225
- // that are already effectively selected.
1226
- const state = makeThunderState({
1227
- selectedSkuIds: {
1228
- 'text-bundle-sku': true,
1229
- 'text-sku': false,
1230
- 'text-var-sku': false,
1231
- 'text-var-italic-sku': false,
1232
- s1: false,
1233
- s2: false,
1234
- vs1: false,
1235
- vis1: false
1236
- }
1237
- });
1238
- const newState = reducer(state, {
1239
- type: 'SELECT_SKU_ID',
1240
- skuId: 'text-sku',
1241
- selected: true
1242
- });
1243
- expect(newState.selectedSkuIds['super-sku']).toBe(true);
1244
- expect(newState.selectedSkuIds['text-bundle-sku']).toBe(false);
407
+ it('re-picking something already held changes nothing', () => {
408
+ const state = select(makeThunderState(), 'text-sku');
409
+ expect(select(state, 'text-sku').selectedSkuIds).toBe(state.selectedSkuIds);
410
+ expect(selected(select(state, 's1'))).toEqual(['text-bundle-sku']);
1245
411
  });
1246
412
  it('UNSELECT_SKUS clears all selections cleanly', () => {
1247
- const state = makeThunderState({
1248
- selectedSkuIds: {
1249
- 'text-bundle-sku': true,
1250
- 'text-sku': false,
1251
- 'text-var-sku': false
1252
- }
1253
- });
1254
- const newState = reducer(state, {
413
+ const state = reducer(select(makeThunderState(), 'text-sku'), {
1255
414
  type: 'UNSELECT_SKUS'
1256
415
  });
1257
- expect(Object.keys(newState.selectedSkuIds)).toHaveLength(0);
416
+ expect(selected(state)).toEqual([]);
417
+ expect(state.requestedSkuIds).toEqual([]);
1258
418
  });
1259
419
  });
1260
420
 
@@ -1262,371 +422,208 @@ describe('reducer edge cases', () => {
1262
422
  // isSelected — family bundles (style packages within a family)
1263
423
  // =========================================================================
1264
424
  describe('isSelected with family bundles (style packages)', () => {
1265
- // Helper: a family with two style bundles (upright + italic packages)
1266
- function makeFamilyWithBundlesState() {
1267
- let overrides = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
1268
- return makeState({
1269
- collectionStyleSkus: {
1270
- 'family-sku': {
1271
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5', 'si1', 'si2', 'si3', 'si4', 'si5'],
1272
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5'],
1273
- childrenSkuIds: ['pkg-upright-sku', 'pkg-italic-sku'],
1274
- name: 'Thunder Text Family'
1275
- },
1276
- 'pkg-upright-sku': {
1277
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5'],
1278
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5'],
1279
- childrenSkuIds: [],
1280
- name: 'Package Text'
1281
- },
1282
- 'pkg-italic-sku': {
1283
- fontStyleSkuIds: ['si1', 'si2', 'si3', 'si4', 'si5'],
1284
- fontStyleIds: ['fi1', 'fi2', 'fi3', 'fi4', 'fi5'],
1285
- childrenSkuIds: [],
1286
- name: 'Package Text Italic'
1287
- }
1288
- },
1289
- skuPrices: {
1290
- 'family-sku': 500,
1291
- 'pkg-upright-sku': 250,
1292
- 'pkg-italic-sku': 250,
1293
- s1: 100,
1294
- s2: 100,
1295
- s3: 100,
1296
- s4: 100,
1297
- s5: 100,
1298
- si1: 100,
1299
- si2: 100,
1300
- si3: 100,
1301
- si4: 100,
1302
- si5: 100
1303
- },
1304
- ...overrides
1305
- });
1306
- }
425
+ const WITH_COLLECTION_BUNDLE = {
426
+ ...FAMILY_WITH_PACKAGES,
427
+ 'coll-bundle-sku': [500, ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5', 'vf1']],
428
+ 'var-sku': [300, ['vf1']],
429
+ vs1: [100, ['vf1']]
430
+ };
1307
431
  it('bundles show selected when family is directly selected', () => {
1308
- const state = makeFamilyWithBundlesState({
1309
- selectedSkuIds: {
1310
- 'family-sku': true,
1311
- 'pkg-upright-sku': false,
1312
- 'pkg-italic-sku': false,
1313
- s1: false,
1314
- s2: false,
1315
- s3: false,
1316
- s4: false,
1317
- s5: false,
1318
- si1: false,
1319
- si2: false,
1320
- si3: false,
1321
- si4: false,
1322
- si5: false
1323
- }
1324
- });
432
+ const state = select(makeState(FAMILY_WITH_PACKAGES), 'family-sku');
433
+ expect(selected(state)).toEqual(['family-sku']);
1325
434
  expect(isSelected('pkg-upright-sku')(state)).toBe(true);
1326
435
  expect(isSelected('pkg-italic-sku')(state)).toBe(true);
1327
436
  expect(isSelected('s1')(state)).toBe(true);
1328
437
  expect(isSelected('si1')(state)).toBe(true);
1329
438
  });
1330
439
  it('bundles show selected when collection bundle is selected (transitive)', () => {
1331
- // Collection bundle is selected → family is false → family bundles should
1332
- // still show selected because the collection bundle covers all styles
1333
- const state = makeFamilyWithBundlesState({
1334
- collectionStyleSkus: {
1335
- // Collection bundle: groups family + variable family
1336
- 'coll-bundle-sku': {
1337
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5', 'si1', 'si2', 'si3', 'si4', 'si5', 'vs1'],
1338
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5', 'vf1'],
1339
- childrenSkuIds: ['family-sku', 'var-sku'],
1340
- name: 'Thunder Text + Variable'
1341
- },
1342
- 'family-sku': {
1343
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5', 'si1', 'si2', 'si3', 'si4', 'si5'],
1344
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5'],
1345
- childrenSkuIds: ['pkg-upright-sku', 'pkg-italic-sku'],
1346
- name: 'Thunder Text Family'
1347
- },
1348
- 'var-sku': {
1349
- fontStyleSkuIds: ['vs1'],
1350
- fontStyleIds: ['vf1'],
1351
- childrenSkuIds: [],
1352
- name: 'Thunder Text Variable'
1353
- },
1354
- 'pkg-upright-sku': {
1355
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5'],
1356
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5'],
1357
- childrenSkuIds: [],
1358
- name: 'Package Text'
1359
- },
1360
- 'pkg-italic-sku': {
1361
- fontStyleSkuIds: ['si1', 'si2', 'si3', 'si4', 'si5'],
1362
- fontStyleIds: ['fi1', 'fi2', 'fi3', 'fi4', 'fi5'],
1363
- childrenSkuIds: [],
1364
- name: 'Package Text Italic'
1365
- }
1366
- },
1367
- selectedSkuIds: {
1368
- 'coll-bundle-sku': true,
1369
- 'family-sku': false,
1370
- 'var-sku': false,
1371
- 'pkg-upright-sku': false,
1372
- 'pkg-italic-sku': false,
1373
- s1: false,
1374
- s2: false,
1375
- s3: false,
1376
- s4: false,
1377
- s5: false,
1378
- si1: false,
1379
- si2: false,
1380
- si3: false,
1381
- si4: false,
1382
- si5: false,
1383
- vs1: false
1384
- }
1385
- });
1386
-
1387
- // Family shows selected (direct child of collection bundle)
440
+ const state = select(makeState(WITH_COLLECTION_BUNDLE), 'family-sku');
441
+ expect(selected(state)).toEqual(['coll-bundle-sku']);
1388
442
  expect(isSelected('family-sku')(state)).toBe(true);
1389
- // Bundles should show selected (grandchildren — family is child of
1390
- // collection bundle, bundles are children of family)
1391
443
  expect(isSelected('pkg-upright-sku')(state)).toBe(true);
1392
444
  expect(isSelected('pkg-italic-sku')(state)).toBe(true);
1393
- // Individual styles should also show selected
1394
445
  expect(isSelected('s1')(state)).toBe(true);
1395
446
  });
1396
447
  it('selecting one bundle then another upgrades to family via reducer', () => {
1397
- const state = makeFamilyWithBundlesState();
448
+ let state = select(makeState(FAMILY_WITH_PACKAGES), 'pkg-upright-sku');
449
+ expect(selected(state)).toEqual(['pkg-upright-sku']);
1398
450
 
1399
- // Select first bundle
1400
- let newState = reducer(state, {
1401
- type: 'SELECT_SKU_ID',
1402
- skuId: 'pkg-upright-sku',
1403
- selected: true
1404
- });
1405
- expect(newState.selectedSkuIds['pkg-upright-sku']).toBe(true);
1406
-
1407
- // Select second bundle — should upgrade to family
1408
- newState = reducer(newState, {
1409
- type: 'SELECT_SKU_ID',
1410
- skuId: 'pkg-italic-sku',
1411
- selected: true
1412
- });
1413
-
1414
- // Family should be selected since family ($500) = two bundles ($250+$250)
1415
- expect(newState.selectedSkuIds['family-sku']).toBe(true);
1416
- // Both bundles should report as selected
1417
- expect(isSelected('pkg-upright-sku')(newState)).toBe(true);
1418
- expect(isSelected('pkg-italic-sku')(newState)).toBe(true);
451
+ // Family ($500) = two packages ($250 + $250): one SKU wins the tie
452
+ state = select(state, 'pkg-italic-sku');
453
+ expect(selected(state)).toEqual(['family-sku']);
454
+ expect(isSelected('pkg-upright-sku')(state)).toBe(true);
455
+ expect(isSelected('pkg-italic-sku')(state)).toBe(true);
1419
456
  });
1420
457
  it('auto-upgrades transitively: bundle → family → collection bundle', () => {
1421
- // When a collection bundle exists at the same price as the family,
1422
- // selecting both bundles should upgrade all the way to the collection
1423
- // bundle (not stop at the family)
1424
- const state = makeFamilyWithBundlesState({
1425
- collectionStyleSkus: {
1426
- 'coll-bundle-sku': {
1427
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5', 'si1', 'si2', 'si3', 'si4', 'si5', 'vs1'],
1428
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5', 'vf1'],
1429
- childrenSkuIds: ['family-sku', 'var-sku'],
1430
- name: 'Thunder Text + Variable'
1431
- },
1432
- 'family-sku': {
1433
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5', 'si1', 'si2', 'si3', 'si4', 'si5'],
1434
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5'],
1435
- childrenSkuIds: ['pkg-upright-sku', 'pkg-italic-sku'],
1436
- name: 'Thunder Text Family'
1437
- },
1438
- 'var-sku': {
1439
- fontStyleSkuIds: ['vs1'],
1440
- fontStyleIds: ['vf1'],
1441
- childrenSkuIds: [],
1442
- name: 'Thunder Text Variable'
1443
- },
1444
- 'pkg-upright-sku': {
1445
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5'],
1446
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5'],
1447
- childrenSkuIds: [],
1448
- name: 'Package Text'
1449
- },
1450
- 'pkg-italic-sku': {
1451
- fontStyleSkuIds: ['si1', 'si2', 'si3', 'si4', 'si5'],
1452
- fontStyleIds: ['fi1', 'fi2', 'fi3', 'fi4', 'fi5'],
1453
- childrenSkuIds: [],
1454
- name: 'Package Text Italic'
1455
- }
1456
- },
1457
- skuPrices: {
1458
- 'coll-bundle-sku': 500,
1459
- 'family-sku': 500,
1460
- 'var-sku': 300,
1461
- 'pkg-upright-sku': 250,
1462
- 'pkg-italic-sku': 250,
1463
- s1: 100,
1464
- s2: 100,
1465
- s3: 100,
1466
- s4: 100,
1467
- s5: 100,
1468
- si1: 100,
1469
- si2: 100,
1470
- si3: 100,
1471
- si4: 100,
1472
- si5: 100,
1473
- vs1: 100
1474
- },
1475
- selectedSkuIds: {
1476
- 'pkg-upright-sku': true
1477
- }
1478
- });
1479
- const newState = reducer(state, {
1480
- type: 'SELECT_SKU_ID',
1481
- skuId: 'pkg-italic-sku',
1482
- selected: true
1483
- });
1484
-
1485
- // Should transitively upgrade: bundle → family → collection bundle
1486
- expect(newState.selectedSkuIds['coll-bundle-sku']).toBe(true);
1487
- expect(newState.selectedSkuIds['family-sku']).toBe(false);
1488
- expect(newState.selectedSkuIds['pkg-upright-sku']).toBe(false);
1489
- expect(newState.selectedSkuIds['pkg-italic-sku']).toBe(false);
1490
-
1491
- // All items should report as selected via isSelected
1492
- expect(isSelected('family-sku')(newState)).toBe(true);
1493
- expect(isSelected('pkg-upright-sku')(newState)).toBe(true);
1494
- expect(isSelected('pkg-italic-sku')(newState)).toBe(true);
1495
- expect(isSelected('s1')(newState)).toBe(true);
1496
- expect(isSelected('vs1')(newState)).toBe(true);
458
+ const state = select(makeState(WITH_COLLECTION_BUNDLE), 'pkg-upright-sku', 'pkg-italic-sku');
459
+ expect(selected(state)).toEqual(['coll-bundle-sku']);
460
+ expect(isSelected('family-sku')(state)).toBe(true);
461
+ expect(isSelected('pkg-upright-sku')(state)).toBe(true);
462
+ expect(isSelected('s1')(state)).toBe(true);
463
+ expect(isSelected('vs1')(state)).toBe(true);
1497
464
  });
1498
- it('prefers collection bundle over family when family has duplicate fontStyleSkuIds from bundles', () => {
1499
- // In production data, flattenSkuData produces a family entry whose
1500
- // fontStyleSkuIds includes BOTH the family's own styles AND the
1501
- // overlapping styles from its child bundles, creating duplicates.
1502
- // The tie-breaker must use non-self preference (not raw array length)
1503
- // to avoid the family's inflated count beating the collection bundle.
1504
- const state = makeState({
1505
- collectionStyleSkus: {
1506
- 'family-sku': {
1507
- // 10 unique styles, but listed 20 times (family's own + bundle overlap)
1508
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5', 'si1', 'si2', 'si3', 'si4', 'si5', 's1', 's2', 's3', 's4', 's5', 'si1', 'si2', 'si3', 'si4', 'si5'],
1509
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5'],
1510
- childrenSkuIds: ['pkg-upright-sku', 'pkg-italic-sku'],
1511
- name: 'Thunder Caption Family'
1512
- },
1513
- 'coll-bundle-sku': {
1514
- // 12 unique styles (family 10 + variable 2)
1515
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5', 'si1', 'si2', 'si3', 'si4', 'si5', 'vs1', 'vs2'],
1516
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5', 'vf1', 'vf2'],
1517
- childrenSkuIds: ['family-sku', 'var-sku'],
1518
- name: 'Thunder Caption Family + Variable'
1519
- },
1520
- 'var-sku': {
1521
- fontStyleSkuIds: ['vs1', 'vs2'],
1522
- fontStyleIds: ['vf1', 'vf2'],
1523
- childrenSkuIds: [],
1524
- name: 'Thunder Caption Variable'
1525
- },
1526
- 'pkg-upright-sku': {
1527
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5'],
1528
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5'],
1529
- childrenSkuIds: [],
1530
- name: 'Package Caption'
1531
- },
1532
- 'pkg-italic-sku': {
1533
- fontStyleSkuIds: ['si1', 'si2', 'si3', 'si4', 'si5'],
1534
- fontStyleIds: ['fi1', 'fi2', 'fi3', 'fi4', 'fi5'],
1535
- childrenSkuIds: [],
1536
- name: 'Package Caption Italic'
1537
- }
1538
- },
1539
- skuPrices: {
1540
- 'family-sku': 500,
1541
- 'coll-bundle-sku': 500,
1542
- 'var-sku': 500,
1543
- 'pkg-upright-sku': 300,
1544
- 'pkg-italic-sku': 300,
1545
- s1: 100,
1546
- s2: 100,
1547
- s3: 100,
1548
- s4: 100,
1549
- s5: 100,
1550
- si1: 100,
1551
- si2: 100,
1552
- si3: 100,
1553
- si4: 100,
1554
- si5: 100,
1555
- vs1: 100,
1556
- vs2: 100
1557
- }
1558
- });
465
+ it('transitive upgrade prefers the collection with the most font styles', () => {
466
+ const state = select(makeState({
467
+ ...FAMILY_WITH_PACKAGES,
468
+ 'small-bundle-sku': [500, ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5']],
469
+ 'big-bundle-sku': [500, ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5', 'vf1']],
470
+ 'var-sku': [300, ['vf1']]
471
+ }), 'family-sku');
472
+ expect(selected(state)).toEqual(['big-bundle-sku']);
473
+ });
474
+ });
1559
475
 
1560
- // family-sku fontStyleSkuIds.length is 20 (duplicates), coll-bundle is 12.
1561
- // A naive .length comparison would pick the family. The non-self
1562
- // preference must win so the collection bundle is auto-selected.
1563
- const [collId] = collectionSkuIdWithDiscount(state, 'family-sku');
1564
- expect(collId).toBe('coll-bundle-sku');
476
+ // =========================================================================
477
+ // POPULATE_PRODUCT_STATE
478
+ // =========================================================================
479
+ describe('POPULATE_PRODUCT_STATE', () => {
480
+ const sku = (id, amount) => ({
481
+ id,
482
+ price: {
483
+ amount
484
+ }
485
+ });
486
+ const style = (id, skuId) => ({
487
+ id,
488
+ sku: skuId ? sku(skuId, 40) : null
489
+ });
490
+ const textFamily = {
491
+ id: 'text',
492
+ name: 'Text',
493
+ sku: sku('text-sku', 500),
494
+ fontStyles: [style('f1', 's1'), style('f2', 's2')],
495
+ bundles: [{
496
+ id: 'upright',
497
+ name: 'Upright',
498
+ sku: sku('upright-sku', 60),
499
+ fontStyles: [style('f1', 's1')]
500
+ }]
501
+ };
502
+ const textVariable = {
503
+ id: 'text-var',
504
+ name: 'Text Variable',
505
+ sku: sku('text-var-sku', 300),
506
+ fontStyles: [style('vf1', null)],
507
+ bundles: []
508
+ };
509
+ const superfamily = {
510
+ node: {
511
+ __typename: 'FontCollection',
512
+ id: 'super',
513
+ name: 'Thunder',
514
+ sku: sku('super-sku', 750),
515
+ fontStyles: [],
516
+ bundles: [],
517
+ collectionBundles: [{
518
+ id: 'text-bundle',
519
+ name: 'Text + Variable',
520
+ sku: sku('text-bundle-sku', 500),
521
+ memberCollections: [textFamily, textVariable]
522
+ }],
523
+ children: [textFamily, textVariable]
524
+ }
525
+ };
526
+ it('records the styles every SKU grants', () => {
527
+ const state = reducer(makeState(), {
528
+ type: 'POPULATE_PRODUCT_STATE',
529
+ data: superfamily
530
+ });
531
+ expect(state.skuStyleIds).toEqual({
532
+ 'super-sku': ['f1', 'f2', 'vf1'],
533
+ 'text-bundle-sku': ['f1', 'f2', 'vf1'],
534
+ 'text-sku': ['f1', 'f2'],
535
+ 'text-var-sku': ['vf1'],
536
+ 'upright-sku': ['f1'],
537
+ s1: ['f1'],
538
+ s2: ['f2']
539
+ });
540
+ });
541
+ it('prices a SKU picked before its product loaded once it has', () => {
542
+ const picked = select(makeState(), 'text-sku');
543
+ expect(selected(picked)).toEqual(['text-sku']);
544
+ const loaded = reducer(picked, {
545
+ type: 'POPULATE_PRODUCT_STATE',
546
+ data: superfamily
547
+ });
548
+ expect(selected(loaded)).toEqual(['text-bundle-sku']);
1565
549
  });
1566
- it('transitive upgrade prefers the collection with the most font styles', () => {
1567
- // When two collections have the same price difference, prefer the one
1568
- // covering more font styles
1569
- const state = makeFamilyWithBundlesState({
1570
- collectionStyleSkus: {
1571
- 'small-bundle-sku': {
1572
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5', 'si1', 'si2', 'si3', 'si4', 'si5'],
1573
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5'],
1574
- childrenSkuIds: ['family-sku'],
1575
- name: 'Small Bundle'
1576
- },
1577
- 'big-bundle-sku': {
1578
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5', 'si1', 'si2', 'si3', 'si4', 'si5', 'vs1'],
1579
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5', 'vf1'],
1580
- childrenSkuIds: ['family-sku', 'var-sku'],
1581
- name: 'Big Bundle'
1582
- },
1583
- 'family-sku': {
1584
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5', 'si1', 'si2', 'si3', 'si4', 'si5'],
1585
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5'],
1586
- childrenSkuIds: ['pkg-upright-sku', 'pkg-italic-sku'],
1587
- name: 'Thunder Text Family'
1588
- },
1589
- 'var-sku': {
1590
- fontStyleSkuIds: ['vs1'],
1591
- fontStyleIds: ['vf1'],
1592
- childrenSkuIds: [],
1593
- name: 'Variable'
1594
- },
1595
- 'pkg-upright-sku': {
1596
- fontStyleSkuIds: ['s1', 's2', 's3', 's4', 's5'],
1597
- fontStyleIds: ['f1', 'f2', 'f3', 'f4', 'f5'],
1598
- childrenSkuIds: [],
1599
- name: 'Package Text'
1600
- },
1601
- 'pkg-italic-sku': {
1602
- fontStyleSkuIds: ['si1', 'si2', 'si3', 'si4', 'si5'],
1603
- fontStyleIds: ['fi1', 'fi2', 'fi3', 'fi4', 'fi5'],
1604
- childrenSkuIds: [],
1605
- name: 'Package Text Italic'
1606
- }
1607
- },
1608
- skuPrices: {
1609
- 'small-bundle-sku': 500,
1610
- 'big-bundle-sku': 500,
1611
- 'family-sku': 500,
1612
- 'var-sku': 300,
1613
- 'pkg-upright-sku': 250,
1614
- 'pkg-italic-sku': 250,
1615
- s1: 100,
1616
- s2: 100,
1617
- s3: 100,
1618
- s4: 100,
1619
- s5: 100,
1620
- si1: 100,
1621
- si2: 100,
1622
- si3: 100,
1623
- si4: 100,
1624
- si5: 100,
1625
- vs1: 100
1626
- }
1627
- });
1628
- const [collId] = collectionSkuIdWithDiscount(state, 'family-sku');
1629
- // Both bundles have the same price, but big-bundle has more styles
1630
- expect(collId).toBe('big-bundle-sku');
550
+ });
551
+
552
+ // =========================================================================
553
+ // Random catalogs – the selection is the cheapest possible, whatever the order
554
+ // =========================================================================
555
+ describe('selection on random catalogs', () => {
556
+ // mulberry32: a small seeded PRNG so failures reproduce
557
+ const random = seed => () => {
558
+ seed = seed + 0x6d2b79f5 | 0;
559
+ let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
560
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
561
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
562
+ };
563
+
564
+ // Two families of three styles, their family SKUs and a superfamily, plus a
565
+ // few bundles – some within a family, some across both.
566
+ const randomCatalog = rand => {
567
+ const pick = items => items[Math.floor(rand() * items.length)];
568
+ const styles = ['a1', 'a2', 'a3', 'b1', 'b2', 'b3'];
569
+ const catalog = {};
570
+ for (const styleId of styles) catalog[`s:${styleId}`] = [10 + Math.floor(rand() * 10), [styleId]];
571
+ const priceFor = styleIds => Math.round(styleIds.length * (6 + rand() * 12));
572
+ catalog['family:a'] = [priceFor(styles.slice(0, 3)), styles.slice(0, 3)];
573
+ catalog['family:b'] = [priceFor(styles.slice(3)), styles.slice(3)];
574
+ catalog['super'] = [priceFor(styles), styles];
575
+ for (let i = 0; i < 4; i++) {
576
+ const pool = pick([styles.slice(0, 3), styles.slice(3), styles]);
577
+ const styleIds = [...new Set([pick(pool), pick(pool), pick(pool)])];
578
+ if (styleIds.length > 1) catalog[`bundle:${i}`] = [priceFor(styleIds), styleIds];
579
+ }
580
+ return catalog;
581
+ };
582
+
583
+ // Every subset of SKUs, keeping the cheapest in which each pick sits inside
584
+ // one SKU granting all its styles.
585
+ const bruteForceCost = (catalog, picks) => {
586
+ const skuIds = Object.keys(catalog);
587
+ let best = Infinity;
588
+ for (let subset = 0; subset < 1 << skuIds.length; subset++) {
589
+ const chosen = skuIds.filter((_, i) => subset & 1 << i);
590
+ const holds = pick => chosen.some(skuId => catalog[pick][1].every(s => catalog[skuId][1].includes(s)));
591
+ if (!picks.every(holds)) continue;
592
+ best = Math.min(best, chosen.reduce((sum, skuId) => sum + catalog[skuId][0], 0));
593
+ }
594
+ return best;
595
+ };
596
+ const shuffled = (items, rand) => items.map(item => [rand(), item]).sort((a, b) => a[0] - b[0]).map(_ref2 => {
597
+ let [, item] = _ref2;
598
+ return item;
599
+ });
600
+ it('matches brute force and ignores click order', () => {
601
+ const rand = random(1238);
602
+ for (let run = 0; run < 150; run++) {
603
+ const catalog = randomCatalog(rand);
604
+ const state = makeState(catalog);
605
+ const picks = shuffled(Object.keys(catalog), rand).slice(0, 1 + Math.floor(rand() * 4));
606
+ const cost = cheapestSkuIds(picks, state).reduce((sum, skuId) => sum + catalog[skuId][0], 0);
607
+ expect(cost, JSON.stringify({
608
+ catalog,
609
+ picks
610
+ })).toBe(bruteForceCost(catalog, picks));
611
+ const orders = [picks, shuffled(picks, rand), [...picks].reverse()];
612
+ const results = orders.map(order => selected(select(state, ...order)));
613
+ expect(results[1], JSON.stringify({
614
+ catalog,
615
+ orders
616
+ })).toEqual(results[0]);
617
+ expect(results[2], JSON.stringify({
618
+ catalog,
619
+ orders
620
+ })).toEqual(results[0]);
621
+ const chosen = select(state, ...picks);
622
+ for (const skuId of Object.keys(catalog)) {
623
+ const added = additionalCost(chosen, skuId);
624
+ expect(added).toBeGreaterThanOrEqual(0);
625
+ expect(added).toBeLessThanOrEqual(catalog[skuId][0]);
626
+ }
627
+ }
1631
628
  });
1632
629
  });