fontdue-js 3.6.0 → 3.7.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 (71) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/__generated__/FontFamiliesQuery.graphql.d.ts +4 -1
  3. package/dist/__generated__/FontFamiliesQuery.graphql.js +46 -39
  4. package/dist/__generated__/PriceBar_node.graphql.d.ts +4 -1
  5. package/dist/__generated__/PriceBar_node.graphql.js +23 -16
  6. package/dist/__generated__/StoreModalFamily_collection.graphql.d.ts +3 -2
  7. package/dist/__generated__/StoreModalFamily_collection.graphql.js +3 -3
  8. package/dist/__generated__/StoreModalIndexPaginationQuery.graphql.d.ts +23 -0
  9. package/dist/__generated__/StoreModalIndexPaginationQuery.graphql.js +303 -0
  10. package/dist/__generated__/StoreModalIndexQuery.graphql.d.ts +4 -9
  11. package/dist/__generated__/StoreModalIndexQuery.graphql.js +90 -53
  12. package/dist/__generated__/StoreModalIndex_viewer.graphql.d.ts +24 -0
  13. package/dist/__generated__/StoreModalIndex_viewer.graphql.js +134 -0
  14. package/dist/__generated__/StoreModalProductContent_collection.graphql.d.ts +4 -1
  15. package/dist/__generated__/StoreModalProductContent_collection.graphql.js +12 -5
  16. package/dist/__generated__/StoreModalProductLicenseSelection_collection.graphql.d.ts +2 -1
  17. package/dist/__generated__/StoreModalProductLicenseSelection_collection.graphql.js +27 -24
  18. package/dist/__generated__/StoreModalProductQuery.graphql.d.ts +1 -1
  19. package/dist/__generated__/StoreModalProductQuery.graphql.js +78 -77
  20. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.d.ts +1 -1
  21. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.js +54 -53
  22. package/dist/__generated__/StoreModalProductSlugQuery.graphql.d.ts +1 -1
  23. package/dist/__generated__/StoreModalProductSlugQuery.graphql.js +78 -77
  24. package/dist/__generated__/TypeTestersIDQuery.graphql.d.ts +1 -1
  25. package/dist/__generated__/TypeTestersIDQuery.graphql.js +17 -10
  26. package/dist/__generated__/TypeTestersRefetchQuery.graphql.d.ts +1 -1
  27. package/dist/__generated__/TypeTestersRefetchQuery.graphql.js +17 -10
  28. package/dist/__generated__/TypeTestersSlugQuery.graphql.d.ts +1 -1
  29. package/dist/__generated__/TypeTestersSlugQuery.graphql.js +17 -10
  30. package/dist/__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.d.ts +41 -0
  31. package/dist/__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.js +65 -0
  32. package/dist/__tests__/bundleSelectionOrder.test.js +92 -0
  33. package/dist/__tests__/collectionBundleSelection.test.js +441 -1446
  34. package/dist/__tests__/infiniteScroll.test.js +180 -0
  35. package/dist/__tests__/licenseExclusions.test.js +2 -1
  36. package/dist/__tests__/storefrontEvents.test.js +167 -0
  37. package/dist/__tests__/typeTesterAutofitFeatures.test.js +138 -0
  38. package/dist/components/Cart/Checkout.js +11 -1
  39. package/dist/components/Cart/storefrontEvents.d.ts +34 -0
  40. package/dist/components/Cart/storefrontEvents.js +138 -0
  41. package/dist/components/FontFamilies/index.js +4 -3
  42. package/dist/components/Precart/index.js +6 -0
  43. package/dist/components/PriceBar/index.js +5 -3
  44. package/dist/components/Root/productState.d.ts +2 -8
  45. package/dist/components/Root/productState.js +46 -57
  46. package/dist/components/SKUPrice/index.js +7 -8
  47. package/dist/components/StoreModal/StoreModalFamily.js +4 -2
  48. package/dist/components/StoreModal/StoreModalIndex.js +48 -7
  49. package/dist/components/StoreModal/StoreModalProduct.js +16 -9
  50. package/dist/components/StoreModalProductLicenseSelection/StoreModalProductLicense.d.ts +2 -1
  51. package/dist/components/StoreModalProductLicenseSelection/StoreModalProductLicense.js +8 -3
  52. package/dist/components/StoreModalProductLicenseSelection/index.js +3 -2
  53. package/dist/components/TypeTester/useTypeTesterStyler.js +1 -0
  54. package/dist/components/elements/StoreModalContainer/StoreModalContainerContext.d.ts +1 -0
  55. package/dist/components/elements/StoreModalContainer/index.js +9 -4
  56. package/dist/components/elements/StoreModalIndexLoadMore/index.d.ts +8 -0
  57. package/dist/components/elements/StoreModalIndexLoadMore/index.js +21 -0
  58. package/dist/components/elements/StoreModalUnifiedCheckout.js +12 -0
  59. package/dist/components/useInfiniteScroll.d.ts +28 -0
  60. package/dist/components/useInfiniteScroll.js +50 -0
  61. package/dist/fontdue.css +33 -0
  62. package/dist/hooks/useAutofit.d.ts +2 -1
  63. package/dist/hooks/useAutofit.js +10 -4
  64. package/dist/reducer.d.ts +5 -5
  65. package/dist/reducer.js +9 -87
  66. package/dist/selection.d.ts +40 -0
  67. package/dist/selection.js +494 -0
  68. package/dist/utils.d.ts +1 -12
  69. package/dist/utils.js +1 -36
  70. package/dist/version.js +1 -1
  71. package/package.json +1 -1
@@ -5,21 +5,30 @@ 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
+ skuPrices,
23
32
  fetchedCollectionIds: [],
24
33
  storeModalRoute: {
25
34
  name: 'index'
@@ -30,739 +39,162 @@ function makeState() {
30
39
  ...overrides
31
40
  };
32
41
  }
42
+ const select = function (state) {
43
+ for (var _len = arguments.length, skuIds = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
44
+ skuIds[_key - 1] = arguments[_key];
45
+ }
46
+ return skuIds.reduce((current, skuId) => reducer(current, {
47
+ type: 'SELECT_SKU_ID',
48
+ skuId,
49
+ selected: true
50
+ }), state);
51
+ };
52
+ const deselect = (state, skuId) => reducer(state, {
53
+ type: 'SELECT_SKU_ID',
54
+ skuId,
55
+ selected: false
56
+ });
57
+ const selected = state => Object.keys(state.selectedSkuIds).filter(skuId => state.selectedSkuIds[skuId] === true).sort();
33
58
 
34
59
  // ---------------------------------------------------------------------------
35
- // Shared fixtures reusable across tests that need the full
36
- // "superfamily + 3 families + 3 bundles" topology
60
+ // Shared fixture a superfamily of three families, each with a variable
61
+ // upright and italic, and a collection bundle of the family and its variables
62
+ // priced like the family alone.
37
63
  // ---------------------------------------------------------------------------
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
- });
64
+ const THUNDER = {
65
+ 'super-sku': [750, ['f1', 'f2', 'df1', 'df2', 'cf1', 'cf2', 'vf1', 'vif1', 'dvf1', 'dvif1', 'cvf1', 'cvif1']],
66
+ 'text-sku': [500, ['f1', 'f2']],
67
+ 'display-sku': [500, ['df1', 'df2']],
68
+ 'caption-sku': [500, ['cf1', 'cf2']],
69
+ 'text-var-sku': [300, ['vf1']],
70
+ 'text-var-italic-sku': [300, ['vif1']],
71
+ 'display-var-sku': [300, ['dvf1']],
72
+ 'display-var-italic-sku': [300, ['dvif1']],
73
+ 'caption-var-sku': [300, ['cvf1']],
74
+ 'caption-var-italic-sku': [300, ['cvif1']],
75
+ 'text-bundle-sku': [500, ['f1', 'f2', 'vf1', 'vif1']],
76
+ 'display-bundle-sku': [500, ['df1', 'df2', 'dvf1', 'dvif1']],
77
+ 'caption-bundle-sku': [500, ['cf1', 'cf2', 'cvf1', 'cvif1']],
78
+ s1: [40, ['f1']],
79
+ s2: [40, ['f2']],
80
+ ds1: [40, ['df1']],
81
+ ds2: [40, ['df2']],
82
+ cs1: [40, ['cf1']],
83
+ cs2: [40, ['cf2']],
84
+ vs1: [40, ['vf1']],
85
+ vis1: [40, ['vif1']],
86
+ dvs1: [40, ['dvf1']],
87
+ dvis1: [40, ['dvif1']],
88
+ cvs1: [40, ['cvf1']],
89
+ cvis1: [40, ['cvif1']]
90
+ };
91
+ const makeThunderState = function () {
92
+ let prices = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
93
+ return makeState(Object.fromEntries(Object.entries(THUNDER).map(_ref => {
94
+ let [skuId, [price, styleIds]] = _ref;
95
+ return [skuId, [prices[skuId] ?? price, styleIds]];
96
+ })));
97
+ };
98
+
99
+ // A family of five uprights and five italics, sold whole, as upright and
100
+ // italic packages, or by the style.
101
+ const FAMILY_WITH_PACKAGES = {
102
+ 'family-sku': [500, ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5']],
103
+ 'pkg-upright-sku': [250, ['f1', 'f2', 'f3', 'f4', 'f5']],
104
+ 'pkg-italic-sku': [250, ['fi1', 'fi2', 'fi3', 'fi4', 'fi5']],
105
+ s1: [100, ['f1']],
106
+ s2: [100, ['f2']],
107
+ s3: [100, ['f3']],
108
+ s4: [100, ['f4']],
109
+ s5: [100, ['f5']],
110
+ si1: [100, ['fi1']],
111
+ si2: [100, ['fi2']],
112
+ si3: [100, ['fi3']],
113
+ si4: [100, ['fi4']],
114
+ si5: [100, ['fi5']]
115
+ };
200
116
 
201
117
  // =========================================================================
202
118
  // isSelected
203
119
  // =========================================================================
204
120
  describe('isSelected', () => {
205
121
  it('returns true when the SKU is directly selected', () => {
206
- const state = makeState({
122
+ const state = makeState({}, {
207
123
  selectedSkuIds: {
208
124
  'sku-1': true
209
125
  }
210
126
  });
211
127
  expect(isSelected('sku-1')(state)).toBe(true);
212
128
  });
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);
129
+ it('returns false when the SKU is not selected', () => {
130
+ expect(isSelected('sku-1')(makeState())).toBe(false);
220
131
  });
221
- it('returns true when a parent collection is selected', () => {
132
+ it('returns true when every style it grants is granted by the selection', () => {
222
133
  const state = makeState({
134
+ 'bundle-sku': [300, ['f1', 'f2']],
135
+ 'family-sku': [500, ['f1']],
136
+ s1: [100, ['f1']]
137
+ }, {
223
138
  selectedSkuIds: {
224
139
  'bundle-sku': true
225
- },
226
- collectionStyleSkus: {
227
- 'bundle-sku': {
228
- fontStyleSkuIds: ['s1'],
229
- fontStyleIds: ['f1'],
230
- childrenSkuIds: ['family-sku'],
231
- name: 'Bundle'
232
- }
233
140
  }
234
141
  });
235
- // Style SKU contained via fontStyleSkuIds
236
142
  expect(isSelected('s1')(state)).toBe(true);
237
- // Family SKU contained via childrenSkuIds
238
143
  expect(isSelected('family-sku')(state)).toBe(true);
239
- // Font style ID contained via fontStyleIds
144
+ // A style with no SKU of its own, by its style id
240
145
  expect(isSelected('f1')(state)).toBe(true);
241
146
  });
242
- it('returns false when a parent collection is deselected', () => {
147
+ it('returns false when only some of its styles are selected', () => {
243
148
  const state = makeState({
149
+ 'family-sku': [500, ['f1', 'f2']],
150
+ s1: [100, ['f1']]
151
+ }, {
244
152
  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
- }
153
+ s1: true
255
154
  }
256
155
  });
257
- expect(isSelected('s1')(state)).toBe(false);
258
156
  expect(isSelected('family-sku')(state)).toBe(false);
259
157
  });
260
158
  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);
159
+ expect(isSelected(null)(makeState())).toBe(false);
436
160
  });
437
161
  });
438
162
 
439
163
  // =========================================================================
440
- // collectionSkuIdWithDiscount
164
+ // additionalCost – the “+$X” next to what isn't selected
441
165
  // =========================================================================
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);
166
+ describe('additionalCost', () => {
167
+ const FIVE_STYLES = {
168
+ 'family-sku': [150, ['f1', 'f2', 'f3', 'f4', 'f5']],
169
+ s1: [40, ['f1']],
170
+ s2: [40, ['f2']],
171
+ s3: [40, ['f3']],
172
+ s4: [40, ['f4']],
173
+ s5: [40, ['f5']]
174
+ };
175
+ it('is the SKU’s own price when nothing cheaper holds it', () => {
176
+ expect(additionalCost(select(makeState(FIVE_STYLES), 's1'), 's2')).toBe(40);
631
177
  });
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');
178
+ it('is what completing a cheaper bundle adds', () => {
179
+ // Three styles selected (120); the family (150) holds a fourth for 30 more
180
+ expect(additionalCost(select(makeState(FIVE_STYLES), 's1', 's2', 's3'), 's4')).toBe(30);
655
181
  });
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);
182
+ it('is zero for what the selection already holds', () => {
183
+ expect(additionalCost(select(makeState(FIVE_STYLES), 'family-sku'), 's1')).toBe(0);
675
184
  });
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.
185
+ it('is null when the SKU’s price or styles are unknown', () => {
679
186
  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
187
+ 'unpriced-sku': [undefined, ['f1']]
699
188
  });
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);
189
+ expect(additionalCost(state, 'unpriced-sku')).toBeNull();
190
+ expect(additionalCost(state, 'unknown-sku')).toBeNull();
705
191
  });
706
- it('handles zero-price SKUs without errors', () => {
192
+ it('handles zero-price SKUs', () => {
707
193
  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
- }
194
+ 'free-sku': [0, ['f1']],
195
+ s2: [40, ['f2']]
729
196
  });
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);
197
+ expect(additionalCost(select(state, 's2'), 'free-sku')).toBe(0);
766
198
  });
767
199
  });
768
200
 
@@ -771,46 +203,25 @@ describe('collectionSkuIdWithDiscount', () => {
771
203
  // =========================================================================
772
204
  describe('reducer SELECT_SKU_ID with collection bundles', () => {
773
205
  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);
206
+ expect(selected(select(makeThunderState(), 'text-sku'))).toEqual(['text-bundle-sku']);
784
207
  });
785
208
  it('does not auto-select bundle when bundle is more expensive', () => {
786
209
  const state = makeThunderState({
787
- skuPrices: {
788
- ...makeThunderState().skuPrices,
789
- 'text-bundle-sku': 600 // more expensive than text-sku ($500)
790
- }
210
+ 'text-bundle-sku': 600
791
211
  });
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();
212
+ expect(selected(select(state, 'text-sku'))).toEqual(['text-sku']);
799
213
  });
800
214
  it('auto-selects bundle when clicking variable family with static already selected', () => {
215
+ expect(selected(select(makeThunderState(), 'text-sku', 'text-var-sku'))).toEqual(['text-bundle-sku']);
216
+ });
217
+ it('never splits a pick into cheaper parts', () => {
218
+ // The styles cost 80 together, but a family bought is not the same
219
+ // purchase as today's styles bought one by one
801
220
  const state = makeThunderState({
802
- selectedSkuIds: {
803
- 'text-sku': true
804
- }
221
+ 'text-bundle-sku': 600,
222
+ 'super-sku': 2000
805
223
  });
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);
224
+ expect(selected(select(state, 'text-sku'))).toEqual(['text-sku']);
814
225
  });
815
226
  });
816
227
 
@@ -819,41 +230,45 @@ describe('reducer SELECT_SKU_ID with collection bundles', () => {
819
230
  // =========================================================================
820
231
  describe('reducer SELECT_SKU_ID with multiple bundles', () => {
821
232
  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();
233
+ expect(selected(select(makeThunderState(), 'display-sku'))).toEqual(['display-bundle-sku']);
835
234
  });
836
235
  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
- });
236
+ // Two families ($500 + $500) cost more than the superfamily ($750)
237
+ expect(selected(select(makeThunderState(), 'text-sku', 'display-sku'))).toEqual(['super-sku']);
238
+ });
239
+ });
851
240
 
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);
241
+ // =========================================================================
242
+ // reducer SELECT_SKU_ID — order independence (FD-1238)
243
+ // =========================================================================
244
+ describe('reducer selection does not depend on click order', () => {
245
+ // Overlapping style bundles: every pair of the three uprights, each upright
246
+ // with its italic, and the three uprights together
247
+ const OVERLAPPING = {
248
+ light: [90, ['light']],
249
+ regular: [90, ['regular']],
250
+ bold: [90, ['bold']],
251
+ italic: [90, ['italic']],
252
+ 'bold-italic': [90, ['bold-italic']],
253
+ 'light+regular': [153, ['light', 'regular']],
254
+ 'light+bold': [153, ['light', 'bold']],
255
+ 'regular+bold': [153, ['regular', 'bold']],
256
+ 'regular+italic': [144, ['regular', 'italic']],
257
+ 'bold+bold-italic': [144, ['bold', 'bold-italic']],
258
+ 'light+regular+bold': [202.5, ['light', 'regular', 'bold']]
259
+ };
260
+ it('reaches a triple that every pair inside it would otherwise snap to first', () => {
261
+ const state = makeState(OVERLAPPING);
262
+ expect(selected(select(state, 'bold', 'regular', 'light'))).toEqual(['light+regular+bold']);
263
+ expect(selected(select(state, 'light', 'regular', 'bold'))).toEqual(['light+regular+bold']);
264
+ });
265
+ it('re-partitions a pair when a cheaper split appears', () => {
266
+ const state = makeState(OVERLAPPING);
267
+ expect(selected(select(state, 'bold', 'regular', 'bold-italic', 'italic'))).toEqual(['bold+bold-italic', 'regular+italic']);
268
+ });
269
+ it('deselecting a style keeps the rest of its bundle', () => {
270
+ const state = select(makeState(OVERLAPPING), 'bold', 'regular', 'light');
271
+ expect(selected(deselect(state, 'regular'))).toEqual(['light+bold']);
857
272
  });
858
273
  });
859
274
 
@@ -862,161 +277,61 @@ describe('reducer SELECT_SKU_ID with multiple bundles', () => {
862
277
  // =========================================================================
863
278
  describe('reducer deselection', () => {
864
279
  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
- });
280
+ const state = select(makeThunderState(), 'text-sku');
281
+ expect(selected(state)).toEqual(['text-bundle-sku']);
885
282
 
886
- // The bundle should be deselected
887
- expect(newState.selectedSkuIds['text-bundle-sku']).toBe(false);
283
+ // The variable comes free with the bundle, so taking it out alone would
284
+ // change nothing: the bundle goes
285
+ expect(selected(deselect(state, 'text-var-sku'))).toEqual([]);
888
286
  });
889
287
  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);
288
+ const state = select(makeThunderState(), 'text-sku');
289
+ const after = deselect(state, 's1');
290
+ expect(selected(after)).toEqual([]);
291
+ expect(isSelected('s1')(after)).toBe(false);
292
+ });
293
+ it('deselecting a style a cheaper family still holds deselects the family', () => {
294
+ const catalog = {
295
+ 'family-sku': [200, ['f1', 'f2', 'f3', 'f4', 'f5']],
296
+ s1: [50, ['f1']],
297
+ s2: [50, ['f2']],
298
+ s3: [50, ['f3']],
299
+ s4: [50, ['f4']],
300
+ s5: [50, ['f5']]
301
+ };
302
+ const state = select(makeState(catalog), 's1', 's2', 's3', 's4', 's5');
303
+ expect(selected(state)).toEqual(['family-sku']);
304
+ // Without s5 the family still costs no more than four styles, so it would
305
+ // stay selected – the click takes the family out instead
306
+ expect(selected(deselect(state, 's5'))).toEqual([]);
910
307
  });
911
308
  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
309
+ const state = select(makeState({
310
+ 'coll-bundle-sku': [500, ['f1', 'f2', 'fi1', 'fi2', 'vf1']],
311
+ 'family-sku': [500, ['f1', 'f2', 'fi1', 'fi2']],
312
+ 'var-sku': [300, ['vf1']],
313
+ 'pkg-upright-sku': [250, ['f1', 'f2']],
314
+ 'pkg-italic-sku': [250, ['fi1', 'fi2']],
315
+ s1: [100, ['f1']],
316
+ s2: [100, ['f2']],
317
+ si1: [100, ['fi1']],
318
+ si2: [100, ['fi2']],
319
+ vs1: [100, ['vf1']]
320
+ }), 'family-sku');
321
+ expect(selected(state)).toEqual(['coll-bundle-sku']);
975
322
  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);
323
+ const after = deselect(state, 'pkg-upright-sku');
324
+ expect(selected(after)).toEqual([]);
325
+ expect(isSelected('pkg-upright-sku')(after)).toBe(false);
326
+ expect(isSelected('family-sku')(after)).toBe(false);
327
+ expect(isSelected('s1')(after)).toBe(false);
990
328
  });
991
329
  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);
330
+ const state = select(makeThunderState({
331
+ 'super-sku': 2000
332
+ }), 'text-sku', 'display-sku');
333
+ expect(selected(state)).toEqual(['display-bundle-sku', 'text-bundle-sku']);
334
+ expect(selected(deselect(state, 'text-var-sku'))).toEqual(['display-bundle-sku']);
1020
335
  });
1021
336
  });
1022
337
 
@@ -1025,37 +340,10 @@ describe('reducer deselection', () => {
1025
340
  // =========================================================================
1026
341
  describe('reducer re-selection cycle', () => {
1027
342
  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);
343
+ const state = select(makeThunderState(), 'text-sku');
344
+ const afterDeselect = deselect(state, 'text-var-sku');
345
+ expect(selected(afterDeselect)).toEqual([]);
346
+ expect(selected(select(afterDeselect, 'text-sku'))).toEqual(['text-bundle-sku']);
1059
347
  });
1060
348
  });
1061
349
 
@@ -1064,37 +352,12 @@ describe('reducer re-selection cycle', () => {
1064
352
  // =========================================================================
1065
353
  describe('reducer superfamily upgrade', () => {
1066
354
  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);
355
+ const state = select(makeThunderState({
356
+ 'super-sku': 1200
357
+ }), 'text-sku', 'display-sku');
358
+ expect(selected(state)).toEqual(['display-bundle-sku', 'text-bundle-sku']);
359
+ // A third $500 bundle would make $1,500
360
+ expect(selected(select(state, 'caption-sku'))).toEqual(['super-sku']);
1098
361
  });
1099
362
  });
1100
363
 
@@ -1102,77 +365,21 @@ describe('reducer superfamily upgrade', () => {
1102
365
  // reducer SELECT_SKU_ID — individual style to family upgrade
1103
366
  // =========================================================================
1104
367
  describe('reducer individual style upgrade', () => {
368
+ const FAMILY = {
369
+ 'family-sku': [200, ['f1', 'f2', 'f3', 'f4', 'f5']],
370
+ s1: [40, ['f1']],
371
+ s2: [40, ['f2']],
372
+ s3: [40, ['f3']],
373
+ s4: [40, ['f4']],
374
+ s5: [40, ['f5']]
375
+ };
1105
376
  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);
377
+ const state = select(makeState(FAMILY), 's1', 's2', 's3', 's4', 's5');
378
+ expect(selected(state)).toEqual(['family-sku']);
379
+ expect(isSelected('s1')(state)).toBe(true);
1142
380
  });
1143
381
  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);
382
+ expect(selected(select(makeState(FAMILY), 's1', 's2'))).toEqual(['s1', 's2']);
1176
383
  });
1177
384
  });
1178
385
 
@@ -1180,81 +387,32 @@ describe('reducer individual style upgrade', () => {
1180
387
  // reducer SELECT_SKU_ID — edge cases with missing data
1181
388
  // =========================================================================
1182
389
  describe('reducer edge cases', () => {
1183
- it('handles selection when collectionStyleSkus is empty', () => {
1184
- const state = makeState({
390
+ it('selects a SKU whose styles aren’t known as it is', () => {
391
+ const state = makeState({}, {
1185
392
  skuPrices: {
1186
393
  'some-sku': 100
1187
394
  }
1188
395
  });
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);
396
+ expect(selected(select(state, 'some-sku'))).toEqual(['some-sku']);
1195
397
  });
1196
- it('handles selection when skuPrices is empty', () => {
398
+ it('selects a SKU whose price isn’t known as it is', () => {
1197
399
  const state = makeState({
1198
- collectionStyleSkus: {
1199
- 'family-sku': {
1200
- fontStyleSkuIds: ['s1'],
1201
- fontStyleIds: ['f1'],
1202
- childrenSkuIds: [],
1203
- name: 'Family'
1204
- }
1205
- }
400
+ 'family-sku': [undefined, ['f1']],
401
+ s1: [undefined, ['f1']]
1206
402
  });
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();
403
+ expect(selected(select(state, 's1'))).toEqual(['s1']);
1216
404
  });
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);
405
+ it('re-picking something already held changes nothing', () => {
406
+ const state = select(makeThunderState(), 'text-sku');
407
+ expect(select(state, 'text-sku').selectedSkuIds).toBe(state.selectedSkuIds);
408
+ expect(selected(select(state, 's1'))).toEqual(['text-bundle-sku']);
1245
409
  });
1246
410
  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, {
411
+ const state = reducer(select(makeThunderState(), 'text-sku'), {
1255
412
  type: 'UNSELECT_SKUS'
1256
413
  });
1257
- expect(Object.keys(newState.selectedSkuIds)).toHaveLength(0);
414
+ expect(selected(state)).toEqual([]);
415
+ expect(state.requestedSkuIds).toEqual([]);
1258
416
  });
1259
417
  });
1260
418
 
@@ -1262,371 +420,208 @@ describe('reducer edge cases', () => {
1262
420
  // isSelected — family bundles (style packages within a family)
1263
421
  // =========================================================================
1264
422
  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
- }
423
+ const WITH_COLLECTION_BUNDLE = {
424
+ ...FAMILY_WITH_PACKAGES,
425
+ 'coll-bundle-sku': [500, ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5', 'vf1']],
426
+ 'var-sku': [300, ['vf1']],
427
+ vs1: [100, ['vf1']]
428
+ };
1307
429
  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
- });
430
+ const state = select(makeState(FAMILY_WITH_PACKAGES), 'family-sku');
431
+ expect(selected(state)).toEqual(['family-sku']);
1325
432
  expect(isSelected('pkg-upright-sku')(state)).toBe(true);
1326
433
  expect(isSelected('pkg-italic-sku')(state)).toBe(true);
1327
434
  expect(isSelected('s1')(state)).toBe(true);
1328
435
  expect(isSelected('si1')(state)).toBe(true);
1329
436
  });
1330
437
  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)
438
+ const state = select(makeState(WITH_COLLECTION_BUNDLE), 'family-sku');
439
+ expect(selected(state)).toEqual(['coll-bundle-sku']);
1388
440
  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
441
  expect(isSelected('pkg-upright-sku')(state)).toBe(true);
1392
442
  expect(isSelected('pkg-italic-sku')(state)).toBe(true);
1393
- // Individual styles should also show selected
1394
443
  expect(isSelected('s1')(state)).toBe(true);
1395
444
  });
1396
445
  it('selecting one bundle then another upgrades to family via reducer', () => {
1397
- const state = makeFamilyWithBundlesState();
446
+ let state = select(makeState(FAMILY_WITH_PACKAGES), 'pkg-upright-sku');
447
+ expect(selected(state)).toEqual(['pkg-upright-sku']);
1398
448
 
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);
449
+ // Family ($500) = two packages ($250 + $250): one SKU wins the tie
450
+ state = select(state, 'pkg-italic-sku');
451
+ expect(selected(state)).toEqual(['family-sku']);
452
+ expect(isSelected('pkg-upright-sku')(state)).toBe(true);
453
+ expect(isSelected('pkg-italic-sku')(state)).toBe(true);
1419
454
  });
1420
455
  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);
456
+ const state = select(makeState(WITH_COLLECTION_BUNDLE), 'pkg-upright-sku', 'pkg-italic-sku');
457
+ expect(selected(state)).toEqual(['coll-bundle-sku']);
458
+ expect(isSelected('family-sku')(state)).toBe(true);
459
+ expect(isSelected('pkg-upright-sku')(state)).toBe(true);
460
+ expect(isSelected('s1')(state)).toBe(true);
461
+ expect(isSelected('vs1')(state)).toBe(true);
1497
462
  });
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
- });
463
+ it('transitive upgrade prefers the collection with the most font styles', () => {
464
+ const state = select(makeState({
465
+ ...FAMILY_WITH_PACKAGES,
466
+ 'small-bundle-sku': [500, ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5']],
467
+ 'big-bundle-sku': [500, ['f1', 'f2', 'f3', 'f4', 'f5', 'fi1', 'fi2', 'fi3', 'fi4', 'fi5', 'vf1']],
468
+ 'var-sku': [300, ['vf1']]
469
+ }), 'family-sku');
470
+ expect(selected(state)).toEqual(['big-bundle-sku']);
471
+ });
472
+ });
1559
473
 
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');
474
+ // =========================================================================
475
+ // POPULATE_PRODUCT_STATE
476
+ // =========================================================================
477
+ describe('POPULATE_PRODUCT_STATE', () => {
478
+ const sku = (id, amount) => ({
479
+ id,
480
+ price: {
481
+ amount
482
+ }
483
+ });
484
+ const style = (id, skuId) => ({
485
+ id,
486
+ sku: skuId ? sku(skuId, 40) : null
487
+ });
488
+ const textFamily = {
489
+ id: 'text',
490
+ name: 'Text',
491
+ sku: sku('text-sku', 500),
492
+ fontStyles: [style('f1', 's1'), style('f2', 's2')],
493
+ bundles: [{
494
+ id: 'upright',
495
+ name: 'Upright',
496
+ sku: sku('upright-sku', 60),
497
+ fontStyles: [style('f1', 's1')]
498
+ }]
499
+ };
500
+ const textVariable = {
501
+ id: 'text-var',
502
+ name: 'Text Variable',
503
+ sku: sku('text-var-sku', 300),
504
+ fontStyles: [style('vf1', null)],
505
+ bundles: []
506
+ };
507
+ const superfamily = {
508
+ node: {
509
+ __typename: 'FontCollection',
510
+ id: 'super',
511
+ name: 'Thunder',
512
+ sku: sku('super-sku', 750),
513
+ fontStyles: [],
514
+ bundles: [],
515
+ collectionBundles: [{
516
+ id: 'text-bundle',
517
+ name: 'Text + Variable',
518
+ sku: sku('text-bundle-sku', 500),
519
+ memberCollections: [textFamily, textVariable]
520
+ }],
521
+ children: [textFamily, textVariable]
522
+ }
523
+ };
524
+ it('records the styles every SKU grants', () => {
525
+ const state = reducer(makeState(), {
526
+ type: 'POPULATE_PRODUCT_STATE',
527
+ data: superfamily
528
+ });
529
+ expect(state.skuStyleIds).toEqual({
530
+ 'super-sku': ['f1', 'f2', 'vf1'],
531
+ 'text-bundle-sku': ['f1', 'f2', 'vf1'],
532
+ 'text-sku': ['f1', 'f2'],
533
+ 'text-var-sku': ['vf1'],
534
+ 'upright-sku': ['f1'],
535
+ s1: ['f1'],
536
+ s2: ['f2']
537
+ });
538
+ });
539
+ it('prices a SKU picked before its product loaded once it has', () => {
540
+ const picked = select(makeState(), 'text-sku');
541
+ expect(selected(picked)).toEqual(['text-sku']);
542
+ const loaded = reducer(picked, {
543
+ type: 'POPULATE_PRODUCT_STATE',
544
+ data: superfamily
545
+ });
546
+ expect(selected(loaded)).toEqual(['text-bundle-sku']);
1565
547
  });
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');
548
+ });
549
+
550
+ // =========================================================================
551
+ // Random catalogs – the selection is the cheapest possible, whatever the order
552
+ // =========================================================================
553
+ describe('selection on random catalogs', () => {
554
+ // mulberry32: a small seeded PRNG so failures reproduce
555
+ const random = seed => () => {
556
+ seed = seed + 0x6d2b79f5 | 0;
557
+ let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
558
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
559
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
560
+ };
561
+
562
+ // Two families of three styles, their family SKUs and a superfamily, plus a
563
+ // few bundles – some within a family, some across both.
564
+ const randomCatalog = rand => {
565
+ const pick = items => items[Math.floor(rand() * items.length)];
566
+ const styles = ['a1', 'a2', 'a3', 'b1', 'b2', 'b3'];
567
+ const catalog = {};
568
+ for (const styleId of styles) catalog[`s:${styleId}`] = [10 + Math.floor(rand() * 10), [styleId]];
569
+ const priceFor = styleIds => Math.round(styleIds.length * (6 + rand() * 12));
570
+ catalog['family:a'] = [priceFor(styles.slice(0, 3)), styles.slice(0, 3)];
571
+ catalog['family:b'] = [priceFor(styles.slice(3)), styles.slice(3)];
572
+ catalog['super'] = [priceFor(styles), styles];
573
+ for (let i = 0; i < 4; i++) {
574
+ const pool = pick([styles.slice(0, 3), styles.slice(3), styles]);
575
+ const styleIds = [...new Set([pick(pool), pick(pool), pick(pool)])];
576
+ if (styleIds.length > 1) catalog[`bundle:${i}`] = [priceFor(styleIds), styleIds];
577
+ }
578
+ return catalog;
579
+ };
580
+
581
+ // Every subset of SKUs, keeping the cheapest in which each pick sits inside
582
+ // one SKU granting all its styles.
583
+ const bruteForceCost = (catalog, picks) => {
584
+ const skuIds = Object.keys(catalog);
585
+ let best = Infinity;
586
+ for (let subset = 0; subset < 1 << skuIds.length; subset++) {
587
+ const chosen = skuIds.filter((_, i) => subset & 1 << i);
588
+ const holds = pick => chosen.some(skuId => catalog[pick][1].every(s => catalog[skuId][1].includes(s)));
589
+ if (!picks.every(holds)) continue;
590
+ best = Math.min(best, chosen.reduce((sum, skuId) => sum + catalog[skuId][0], 0));
591
+ }
592
+ return best;
593
+ };
594
+ const shuffled = (items, rand) => items.map(item => [rand(), item]).sort((a, b) => a[0] - b[0]).map(_ref2 => {
595
+ let [, item] = _ref2;
596
+ return item;
597
+ });
598
+ it('matches brute force and ignores click order', () => {
599
+ const rand = random(1238);
600
+ for (let run = 0; run < 150; run++) {
601
+ const catalog = randomCatalog(rand);
602
+ const state = makeState(catalog);
603
+ const picks = shuffled(Object.keys(catalog), rand).slice(0, 1 + Math.floor(rand() * 4));
604
+ const cost = cheapestSkuIds(picks, state).reduce((sum, skuId) => sum + catalog[skuId][0], 0);
605
+ expect(cost, JSON.stringify({
606
+ catalog,
607
+ picks
608
+ })).toBe(bruteForceCost(catalog, picks));
609
+ const orders = [picks, shuffled(picks, rand), [...picks].reverse()];
610
+ const results = orders.map(order => selected(select(state, ...order)));
611
+ expect(results[1], JSON.stringify({
612
+ catalog,
613
+ orders
614
+ })).toEqual(results[0]);
615
+ expect(results[2], JSON.stringify({
616
+ catalog,
617
+ orders
618
+ })).toEqual(results[0]);
619
+ const chosen = select(state, ...picks);
620
+ for (const skuId of Object.keys(catalog)) {
621
+ const added = additionalCost(chosen, skuId);
622
+ expect(added).toBeGreaterThanOrEqual(0);
623
+ expect(added).toBeLessThanOrEqual(catalog[skuId][0]);
624
+ }
625
+ }
1631
626
  });
1632
627
  });