@flusys/nestjs-form-builder 6.0.2 → 6.1.1

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.
@@ -0,0 +1,1201 @@
1
+ import { calculateComputedFields } from './computed-field.utils';
2
+ function directField(overrides = {}) {
3
+ return {
4
+ id: 'cf-1',
5
+ name: 'Computed',
6
+ key: 'total',
7
+ valueType: 'number',
8
+ rules: [
9
+ {
10
+ id: 'rule-1',
11
+ computation: {
12
+ type: 'direct',
13
+ config: {
14
+ type: 'direct',
15
+ value: 42
16
+ }
17
+ }
18
+ }
19
+ ],
20
+ ...overrides
21
+ };
22
+ }
23
+ describe('calculateComputedFields', ()=>{
24
+ it('returns an empty object when there are no computed fields', ()=>{
25
+ expect(calculateComputedFields({}, undefined)).toEqual({});
26
+ expect(calculateComputedFields({}, [])).toEqual({});
27
+ });
28
+ it('keys the result by the field.key property, not field.id', ()=>{
29
+ const field = directField({
30
+ id: 'cf-id',
31
+ key: 'my_key'
32
+ });
33
+ const result = calculateComputedFields({}, [
34
+ field
35
+ ]);
36
+ expect(result).toEqual({
37
+ my_key: 42
38
+ });
39
+ });
40
+ it('evaluates multiple computed fields independently', ()=>{
41
+ const fields = [
42
+ directField({
43
+ key: 'a'
44
+ }),
45
+ directField({
46
+ key: 'b',
47
+ rules: [
48
+ {
49
+ id: 'r2',
50
+ computation: {
51
+ type: 'direct',
52
+ config: {
53
+ type: 'direct',
54
+ value: 'hello'
55
+ }
56
+ }
57
+ }
58
+ ],
59
+ valueType: 'string'
60
+ })
61
+ ];
62
+ const result = calculateComputedFields({}, fields);
63
+ expect(result).toEqual({
64
+ a: 42,
65
+ b: 'hello'
66
+ });
67
+ });
68
+ // ==========================================================================
69
+ // Rule matching / condition evaluation
70
+ // ==========================================================================
71
+ describe('condition evaluation', ()=>{
72
+ it('uses the first matching rule and ignores subsequent rules', ()=>{
73
+ const field = {
74
+ id: 'cf-1',
75
+ name: 'Total',
76
+ key: 'total',
77
+ valueType: 'string',
78
+ rules: [
79
+ {
80
+ id: 'rule-1',
81
+ condition: {
82
+ operator: 'AND',
83
+ conditions: [
84
+ {
85
+ fieldId: 'x',
86
+ comparison: 'equals',
87
+ value: 1
88
+ }
89
+ ]
90
+ },
91
+ computation: {
92
+ type: 'direct',
93
+ config: {
94
+ type: 'direct',
95
+ value: 'first'
96
+ }
97
+ }
98
+ },
99
+ {
100
+ id: 'rule-2',
101
+ computation: {
102
+ type: 'direct',
103
+ config: {
104
+ type: 'direct',
105
+ value: 'second'
106
+ }
107
+ }
108
+ }
109
+ ]
110
+ };
111
+ const result = calculateComputedFields({
112
+ x: 1
113
+ }, [
114
+ field
115
+ ]);
116
+ expect(result.total).toBe('first');
117
+ });
118
+ it('falls through to the default value when no rule matches', ()=>{
119
+ const field = {
120
+ id: 'cf-1',
121
+ name: 'Total',
122
+ key: 'total',
123
+ valueType: 'string',
124
+ defaultValue: 'fallback',
125
+ rules: [
126
+ {
127
+ id: 'rule-1',
128
+ condition: {
129
+ operator: 'AND',
130
+ conditions: [
131
+ {
132
+ fieldId: 'x',
133
+ comparison: 'equals',
134
+ value: 1
135
+ }
136
+ ]
137
+ },
138
+ computation: {
139
+ type: 'direct',
140
+ config: {
141
+ type: 'direct',
142
+ value: 'matched'
143
+ }
144
+ }
145
+ }
146
+ ]
147
+ };
148
+ const result = calculateComputedFields({
149
+ x: 2
150
+ }, [
151
+ field
152
+ ]);
153
+ expect(result.total).toBe('fallback');
154
+ });
155
+ it('returns null when no rule matches and no default value is set', ()=>{
156
+ const field = {
157
+ id: 'cf-1',
158
+ name: 'Total',
159
+ key: 'total',
160
+ valueType: 'string',
161
+ rules: [
162
+ {
163
+ id: 'rule-1',
164
+ condition: {
165
+ operator: 'AND',
166
+ conditions: [
167
+ {
168
+ fieldId: 'x',
169
+ comparison: 'equals',
170
+ value: 1
171
+ }
172
+ ]
173
+ },
174
+ computation: {
175
+ type: 'direct',
176
+ config: {
177
+ type: 'direct',
178
+ value: 'matched'
179
+ }
180
+ }
181
+ }
182
+ ]
183
+ };
184
+ const result = calculateComputedFields({
185
+ x: 2
186
+ }, [
187
+ field
188
+ ]);
189
+ expect(result.total).toBeNull();
190
+ });
191
+ it('treats a missing/empty condition group as always-true', ()=>{
192
+ const field = directField({
193
+ key: 'total'
194
+ });
195
+ delete field.rules[0].condition;
196
+ const result = calculateComputedFields({}, [
197
+ field
198
+ ]);
199
+ expect(result.total).toBe(42);
200
+ });
201
+ it('AND operator requires all conditions to match', ()=>{
202
+ const field = {
203
+ id: 'cf-1',
204
+ name: 'Total',
205
+ key: 'total',
206
+ valueType: 'string',
207
+ defaultValue: 'no-match',
208
+ rules: [
209
+ {
210
+ id: 'rule-1',
211
+ condition: {
212
+ operator: 'AND',
213
+ conditions: [
214
+ {
215
+ fieldId: 'a',
216
+ comparison: 'equals',
217
+ value: 1
218
+ },
219
+ {
220
+ fieldId: 'b',
221
+ comparison: 'equals',
222
+ value: 2
223
+ }
224
+ ]
225
+ },
226
+ computation: {
227
+ type: 'direct',
228
+ config: {
229
+ type: 'direct',
230
+ value: 'matched'
231
+ }
232
+ }
233
+ }
234
+ ]
235
+ };
236
+ expect(calculateComputedFields({
237
+ a: 1,
238
+ b: 2
239
+ }, [
240
+ field
241
+ ]).total).toBe('matched');
242
+ expect(calculateComputedFields({
243
+ a: 1,
244
+ b: 99
245
+ }, [
246
+ field
247
+ ]).total).toBe('no-match');
248
+ });
249
+ it('OR operator matches when at least one condition matches', ()=>{
250
+ const field = {
251
+ id: 'cf-1',
252
+ name: 'Total',
253
+ key: 'total',
254
+ valueType: 'string',
255
+ defaultValue: 'no-match',
256
+ rules: [
257
+ {
258
+ id: 'rule-1',
259
+ condition: {
260
+ operator: 'OR',
261
+ conditions: [
262
+ {
263
+ fieldId: 'a',
264
+ comparison: 'equals',
265
+ value: 1
266
+ },
267
+ {
268
+ fieldId: 'b',
269
+ comparison: 'equals',
270
+ value: 2
271
+ }
272
+ ]
273
+ },
274
+ computation: {
275
+ type: 'direct',
276
+ config: {
277
+ type: 'direct',
278
+ value: 'matched'
279
+ }
280
+ }
281
+ }
282
+ ]
283
+ };
284
+ expect(calculateComputedFields({
285
+ a: 1,
286
+ b: 99
287
+ }, [
288
+ field
289
+ ]).total).toBe('matched');
290
+ expect(calculateComputedFields({
291
+ a: 99,
292
+ b: 99
293
+ }, [
294
+ field
295
+ ]).total).toBe('no-match');
296
+ });
297
+ it('normalizes lower-case / unknown operator strings to AND by default', ()=>{
298
+ const field = {
299
+ id: 'cf-1',
300
+ name: 'Total',
301
+ key: 'total',
302
+ valueType: 'string',
303
+ defaultValue: 'no-match',
304
+ rules: [
305
+ {
306
+ id: 'rule-1',
307
+ condition: {
308
+ operator: 'unknown-operator',
309
+ conditions: [
310
+ {
311
+ fieldId: 'a',
312
+ comparison: 'equals',
313
+ value: 1
314
+ },
315
+ {
316
+ fieldId: 'b',
317
+ comparison: 'equals',
318
+ value: 2
319
+ }
320
+ ]
321
+ },
322
+ computation: {
323
+ type: 'direct',
324
+ config: {
325
+ type: 'direct',
326
+ value: 'matched'
327
+ }
328
+ }
329
+ }
330
+ ]
331
+ };
332
+ // Falls back to AND semantics, so a partial match should NOT trigger the rule.
333
+ expect(calculateComputedFields({
334
+ a: 1,
335
+ b: 99
336
+ }, [
337
+ field
338
+ ]).total).toBe('no-match');
339
+ });
340
+ it('normalizes lower-case "or" to OR semantics', ()=>{
341
+ const field = {
342
+ id: 'cf-1',
343
+ name: 'Total',
344
+ key: 'total',
345
+ valueType: 'string',
346
+ defaultValue: 'no-match',
347
+ rules: [
348
+ {
349
+ id: 'rule-1',
350
+ condition: {
351
+ operator: 'or',
352
+ conditions: [
353
+ {
354
+ fieldId: 'a',
355
+ comparison: 'equals',
356
+ value: 1
357
+ }
358
+ ]
359
+ },
360
+ computation: {
361
+ type: 'direct',
362
+ config: {
363
+ type: 'direct',
364
+ value: 'matched'
365
+ }
366
+ }
367
+ }
368
+ ]
369
+ };
370
+ expect(calculateComputedFields({
371
+ a: 1
372
+ }, [
373
+ field
374
+ ]).total).toBe('matched');
375
+ });
376
+ });
377
+ // ==========================================================================
378
+ // Comparison operators
379
+ // ==========================================================================
380
+ describe('comparison operators', ()=>{
381
+ function withComparison(comparison, value) {
382
+ const field = {
383
+ id: 'cf-1',
384
+ name: 'Total',
385
+ key: 'total',
386
+ valueType: 'string',
387
+ defaultValue: 'no-match',
388
+ rules: [
389
+ {
390
+ id: 'rule-1',
391
+ condition: {
392
+ operator: 'AND',
393
+ conditions: [
394
+ {
395
+ fieldId: 'x',
396
+ comparison,
397
+ value
398
+ }
399
+ ]
400
+ },
401
+ computation: {
402
+ type: 'direct',
403
+ config: {
404
+ type: 'direct',
405
+ value: 'matched'
406
+ }
407
+ }
408
+ }
409
+ ]
410
+ };
411
+ return field;
412
+ }
413
+ it('equals matches by strict or stringified equality', ()=>{
414
+ const field = withComparison('equals', '5');
415
+ expect(calculateComputedFields({
416
+ x: 5
417
+ }, [
418
+ field
419
+ ]).total).toBe('matched');
420
+ expect(calculateComputedFields({
421
+ x: 6
422
+ }, [
423
+ field
424
+ ]).total).toBe('no-match');
425
+ });
426
+ it('not_equals is the inverse of equals', ()=>{
427
+ const field = withComparison('not_equals', '5');
428
+ expect(calculateComputedFields({
429
+ x: 6
430
+ }, [
431
+ field
432
+ ]).total).toBe('matched');
433
+ expect(calculateComputedFields({
434
+ x: 5
435
+ }, [
436
+ field
437
+ ]).total).toBe('no-match');
438
+ expect(calculateComputedFields({
439
+ x: '5'
440
+ }, [
441
+ field
442
+ ]).total).toBe('no-match');
443
+ });
444
+ it('is_empty treats null, undefined, empty string and empty array as empty', ()=>{
445
+ const field = withComparison('is_empty', null);
446
+ expect(calculateComputedFields({
447
+ x: null
448
+ }, [
449
+ field
450
+ ]).total).toBe('matched');
451
+ expect(calculateComputedFields({
452
+ x: undefined
453
+ }, [
454
+ field
455
+ ]).total).toBe('matched');
456
+ expect(calculateComputedFields({
457
+ x: ''
458
+ }, [
459
+ field
460
+ ]).total).toBe('matched');
461
+ expect(calculateComputedFields({
462
+ x: []
463
+ }, [
464
+ field
465
+ ]).total).toBe('matched');
466
+ expect(calculateComputedFields({
467
+ x: 0
468
+ }, [
469
+ field
470
+ ]).total).toBe('no-match');
471
+ expect(calculateComputedFields({}, [
472
+ field
473
+ ]).total).toBe('matched');
474
+ });
475
+ it('is_not_empty is the inverse of is_empty', ()=>{
476
+ const field = withComparison('is_not_empty', null);
477
+ expect(calculateComputedFields({
478
+ x: 'value'
479
+ }, [
480
+ field
481
+ ]).total).toBe('matched');
482
+ expect(calculateComputedFields({
483
+ x: ''
484
+ }, [
485
+ field
486
+ ]).total).toBe('no-match');
487
+ });
488
+ it('contains / not_contains perform substring checks', ()=>{
489
+ const contains = withComparison('contains', 'ell');
490
+ expect(calculateComputedFields({
491
+ x: 'hello'
492
+ }, [
493
+ contains
494
+ ]).total).toBe('matched');
495
+ expect(calculateComputedFields({
496
+ x: 'world'
497
+ }, [
498
+ contains
499
+ ]).total).toBe('no-match');
500
+ const notContains = withComparison('not_contains', 'ell');
501
+ expect(calculateComputedFields({
502
+ x: 'world'
503
+ }, [
504
+ notContains
505
+ ]).total).toBe('matched');
506
+ expect(calculateComputedFields({
507
+ x: 'hello'
508
+ }, [
509
+ notContains
510
+ ]).total).toBe('no-match');
511
+ });
512
+ it('starts_with / ends_with perform prefix/suffix checks', ()=>{
513
+ const startsWith = withComparison('starts_with', 'hel');
514
+ expect(calculateComputedFields({
515
+ x: 'hello'
516
+ }, [
517
+ startsWith
518
+ ]).total).toBe('matched');
519
+ expect(calculateComputedFields({
520
+ x: 'shell'
521
+ }, [
522
+ startsWith
523
+ ]).total).toBe('no-match');
524
+ const endsWith = withComparison('ends_with', 'llo');
525
+ expect(calculateComputedFields({
526
+ x: 'hello'
527
+ }, [
528
+ endsWith
529
+ ]).total).toBe('matched');
530
+ expect(calculateComputedFields({
531
+ x: 'llothing'
532
+ }, [
533
+ endsWith
534
+ ]).total).toBe('no-match');
535
+ });
536
+ it('numeric comparisons coerce operands with Number()', ()=>{
537
+ expect(calculateComputedFields({
538
+ x: 10
539
+ }, [
540
+ withComparison('greater_than', 5)
541
+ ]).total).toBe('matched');
542
+ expect(calculateComputedFields({
543
+ x: 3
544
+ }, [
545
+ withComparison('greater_than', 5)
546
+ ]).total).toBe('no-match');
547
+ expect(calculateComputedFields({
548
+ x: 3
549
+ }, [
550
+ withComparison('less_than', 5)
551
+ ]).total).toBe('matched');
552
+ expect(calculateComputedFields({
553
+ x: '5'
554
+ }, [
555
+ withComparison('greater_or_equal', 5)
556
+ ]).total).toBe('matched');
557
+ expect(calculateComputedFields({
558
+ x: 5
559
+ }, [
560
+ withComparison('less_or_equal', 5)
561
+ ]).total).toBe('matched');
562
+ expect(calculateComputedFields({
563
+ x: 6
564
+ }, [
565
+ withComparison('less_or_equal', 5)
566
+ ]).total).toBe('no-match');
567
+ });
568
+ it('is_before / is_after compare Date values', ()=>{
569
+ const before = withComparison('is_before', '2024-06-01');
570
+ expect(calculateComputedFields({
571
+ x: '2024-01-01'
572
+ }, [
573
+ before
574
+ ]).total).toBe('matched');
575
+ expect(calculateComputedFields({
576
+ x: '2024-12-01'
577
+ }, [
578
+ before
579
+ ]).total).toBe('no-match');
580
+ const after = withComparison('is_after', '2024-06-01');
581
+ expect(calculateComputedFields({
582
+ x: '2024-12-01'
583
+ }, [
584
+ after
585
+ ]).total).toBe('matched');
586
+ expect(calculateComputedFields({
587
+ x: '2024-01-01'
588
+ }, [
589
+ after
590
+ ]).total).toBe('no-match');
591
+ });
592
+ it('is_checked treats true, "true" and 1 as checked', ()=>{
593
+ const field = withComparison('is_checked', null);
594
+ expect(calculateComputedFields({
595
+ x: true
596
+ }, [
597
+ field
598
+ ]).total).toBe('matched');
599
+ expect(calculateComputedFields({
600
+ x: 'true'
601
+ }, [
602
+ field
603
+ ]).total).toBe('matched');
604
+ expect(calculateComputedFields({
605
+ x: 1
606
+ }, [
607
+ field
608
+ ]).total).toBe('matched');
609
+ expect(calculateComputedFields({
610
+ x: false
611
+ }, [
612
+ field
613
+ ]).total).toBe('no-match');
614
+ expect(calculateComputedFields({
615
+ x: 0
616
+ }, [
617
+ field
618
+ ]).total).toBe('no-match');
619
+ });
620
+ it('is_not_checked treats false, "false", 0 and falsy as unchecked', ()=>{
621
+ const field = withComparison('is_not_checked', null);
622
+ expect(calculateComputedFields({
623
+ x: false
624
+ }, [
625
+ field
626
+ ]).total).toBe('matched');
627
+ expect(calculateComputedFields({
628
+ x: 'false'
629
+ }, [
630
+ field
631
+ ]).total).toBe('matched');
632
+ expect(calculateComputedFields({
633
+ x: 0
634
+ }, [
635
+ field
636
+ ]).total).toBe('matched');
637
+ expect(calculateComputedFields({
638
+ x: undefined
639
+ }, [
640
+ field
641
+ ]).total).toBe('matched');
642
+ expect(calculateComputedFields({
643
+ x: true
644
+ }, [
645
+ field
646
+ ]).total).toBe('no-match');
647
+ });
648
+ it('is_any_of / in match against an array of allowed values', ()=>{
649
+ const field = withComparison('is_any_of', [
650
+ 'a',
651
+ 'b',
652
+ 'c'
653
+ ]);
654
+ expect(calculateComputedFields({
655
+ x: 'b'
656
+ }, [
657
+ field
658
+ ]).total).toBe('matched');
659
+ expect(calculateComputedFields({
660
+ x: 'z'
661
+ }, [
662
+ field
663
+ ]).total).toBe('no-match');
664
+ const inField = withComparison('in', [
665
+ 'a',
666
+ 'b',
667
+ 'c'
668
+ ]);
669
+ expect(calculateComputedFields({
670
+ x: 'a'
671
+ }, [
672
+ inField
673
+ ]).total).toBe('matched');
674
+ });
675
+ it('is_any_of returns false (no match) when compareValue is not an array', ()=>{
676
+ const field = withComparison('is_any_of', 'not-an-array');
677
+ expect(calculateComputedFields({
678
+ x: 'not-an-array'
679
+ }, [
680
+ field
681
+ ]).total).toBe('no-match');
682
+ });
683
+ it('is_none_of / not_in match when value is absent from the array', ()=>{
684
+ const field = withComparison('is_none_of', [
685
+ 'a',
686
+ 'b',
687
+ 'c'
688
+ ]);
689
+ expect(calculateComputedFields({
690
+ x: 'z'
691
+ }, [
692
+ field
693
+ ]).total).toBe('matched');
694
+ expect(calculateComputedFields({
695
+ x: 'a'
696
+ }, [
697
+ field
698
+ ]).total).toBe('no-match');
699
+ const notIn = withComparison('not_in', [
700
+ 'a',
701
+ 'b',
702
+ 'c'
703
+ ]);
704
+ expect(calculateComputedFields({
705
+ x: 'z'
706
+ }, [
707
+ notIn
708
+ ]).total).toBe('matched');
709
+ });
710
+ it('is_none_of treats a non-array compareValue as always matching', ()=>{
711
+ const field = withComparison('is_none_of', 'not-an-array');
712
+ expect(calculateComputedFields({
713
+ x: 'anything'
714
+ }, [
715
+ field
716
+ ]).total).toBe('matched');
717
+ });
718
+ it('returns false for unknown comparison operators', ()=>{
719
+ const field = withComparison('some_unknown_operator', 1);
720
+ expect(calculateComputedFields({
721
+ x: 1
722
+ }, [
723
+ field
724
+ ]).total).toBe('no-match');
725
+ });
726
+ });
727
+ // ==========================================================================
728
+ // Computation types
729
+ // ==========================================================================
730
+ describe('direct computation', ()=>{
731
+ it('coerces the direct value to a number when valueType is number', ()=>{
732
+ const field = directField({
733
+ valueType: 'number'
734
+ });
735
+ field.rules[0].computation = {
736
+ type: 'direct',
737
+ config: {
738
+ type: 'direct',
739
+ value: '42'
740
+ }
741
+ };
742
+ expect(calculateComputedFields({}, [
743
+ field
744
+ ]).total).toBe(42);
745
+ });
746
+ it('coerces the direct value to a string when valueType is string', ()=>{
747
+ const field = directField({
748
+ valueType: 'string'
749
+ });
750
+ field.rules[0].computation = {
751
+ type: 'direct',
752
+ config: {
753
+ type: 'direct',
754
+ value: 42
755
+ }
756
+ };
757
+ expect(calculateComputedFields({}, [
758
+ field
759
+ ]).total).toBe('42');
760
+ });
761
+ });
762
+ describe('field_reference computation', ()=>{
763
+ it('reads the referenced field value and coerces per valueType', ()=>{
764
+ const field = directField({
765
+ valueType: 'number'
766
+ });
767
+ field.rules[0].computation = {
768
+ type: 'field_reference',
769
+ config: {
770
+ type: 'field_reference',
771
+ fieldId: 'age'
772
+ }
773
+ };
774
+ expect(calculateComputedFields({
775
+ age: '30'
776
+ }, [
777
+ field
778
+ ]).total).toBe(30);
779
+ });
780
+ it('returns null when the referenced field is null or undefined', ()=>{
781
+ const field = directField({
782
+ valueType: 'number'
783
+ });
784
+ field.rules[0].computation = {
785
+ type: 'field_reference',
786
+ config: {
787
+ type: 'field_reference',
788
+ fieldId: 'age'
789
+ }
790
+ };
791
+ expect(calculateComputedFields({
792
+ age: null
793
+ }, [
794
+ field
795
+ ]).total).toBeNull();
796
+ expect(calculateComputedFields({}, [
797
+ field
798
+ ]).total).toBeNull();
799
+ });
800
+ it('coerces the referenced field to a string when valueType is string', ()=>{
801
+ const field = directField({
802
+ valueType: 'string'
803
+ });
804
+ field.rules[0].computation = {
805
+ type: 'field_reference',
806
+ config: {
807
+ type: 'field_reference',
808
+ fieldId: 'age'
809
+ }
810
+ };
811
+ expect(calculateComputedFields({
812
+ age: 30
813
+ }, [
814
+ field
815
+ ]).total).toBe('30');
816
+ });
817
+ });
818
+ describe('unknown / malformed computation configs', ()=>{
819
+ it('returns null for an unrecognized computation type', ()=>{
820
+ const field = directField();
821
+ field.rules[0].computation = {
822
+ type: 'not-a-real-type',
823
+ config: {}
824
+ };
825
+ expect(calculateComputedFields({}, [
826
+ field
827
+ ]).total).toBeNull();
828
+ });
829
+ it('returns null when computation.type does not match its config shape', ()=>{
830
+ const field = directField();
831
+ // type says "direct" but config is missing the "value" property expected by the type guard
832
+ field.rules[0].computation = {
833
+ type: 'direct',
834
+ config: {
835
+ type: 'field_reference',
836
+ fieldId: 'x'
837
+ }
838
+ };
839
+ expect(calculateComputedFields({}, [
840
+ field
841
+ ]).total).toBeNull();
842
+ });
843
+ });
844
+ // ==========================================================================
845
+ // Arithmetic computation
846
+ // ==========================================================================
847
+ describe('arithmetic computation', ()=>{
848
+ function arithmeticField(config) {
849
+ return {
850
+ id: 'cf-1',
851
+ name: 'Total',
852
+ key: 'total',
853
+ valueType: 'number',
854
+ rules: [
855
+ {
856
+ id: 'rule-1',
857
+ computation: {
858
+ type: 'arithmetic',
859
+ config
860
+ }
861
+ }
862
+ ]
863
+ };
864
+ }
865
+ it('sum adds all operand values (fields + constants)', ()=>{
866
+ const field = arithmeticField({
867
+ type: 'arithmetic',
868
+ operation: 'sum',
869
+ operands: [
870
+ {
871
+ type: 'field',
872
+ fieldId: 'a'
873
+ },
874
+ {
875
+ type: 'field',
876
+ fieldId: 'b'
877
+ },
878
+ {
879
+ type: 'constant',
880
+ value: 10
881
+ }
882
+ ]
883
+ });
884
+ expect(calculateComputedFields({
885
+ a: 5,
886
+ b: 3
887
+ }, [
888
+ field
889
+ ]).total).toBe(18);
890
+ });
891
+ it('increment behaves the same as sum', ()=>{
892
+ const field = arithmeticField({
893
+ type: 'arithmetic',
894
+ operation: 'increment',
895
+ operands: [
896
+ {
897
+ type: 'constant',
898
+ value: 1
899
+ },
900
+ {
901
+ type: 'constant',
902
+ value: 2
903
+ }
904
+ ]
905
+ });
906
+ expect(calculateComputedFields({}, [
907
+ field
908
+ ]).total).toBe(3);
909
+ });
910
+ it('subtract subtracts subsequent operands from the first', ()=>{
911
+ const field = arithmeticField({
912
+ type: 'arithmetic',
913
+ operation: 'subtract',
914
+ operands: [
915
+ {
916
+ type: 'constant',
917
+ value: 10
918
+ },
919
+ {
920
+ type: 'constant',
921
+ value: 3
922
+ },
923
+ {
924
+ type: 'constant',
925
+ value: 2
926
+ }
927
+ ]
928
+ });
929
+ expect(calculateComputedFields({}, [
930
+ field
931
+ ]).total).toBe(5);
932
+ });
933
+ it('decrement behaves the same as subtract', ()=>{
934
+ const field = arithmeticField({
935
+ type: 'arithmetic',
936
+ operation: 'decrement',
937
+ operands: [
938
+ {
939
+ type: 'constant',
940
+ value: 10
941
+ },
942
+ {
943
+ type: 'constant',
944
+ value: 4
945
+ }
946
+ ]
947
+ });
948
+ expect(calculateComputedFields({}, [
949
+ field
950
+ ]).total).toBe(6);
951
+ });
952
+ it('multiply multiplies all operand values', ()=>{
953
+ const field = arithmeticField({
954
+ type: 'arithmetic',
955
+ operation: 'multiply',
956
+ operands: [
957
+ {
958
+ type: 'constant',
959
+ value: 2
960
+ },
961
+ {
962
+ type: 'constant',
963
+ value: 3
964
+ },
965
+ {
966
+ type: 'constant',
967
+ value: 4
968
+ }
969
+ ]
970
+ });
971
+ expect(calculateComputedFields({}, [
972
+ field
973
+ ]).total).toBe(24);
974
+ });
975
+ it('divide divides the first operand by each subsequent operand in order', ()=>{
976
+ const field = arithmeticField({
977
+ type: 'arithmetic',
978
+ operation: 'divide',
979
+ operands: [
980
+ {
981
+ type: 'constant',
982
+ value: 100
983
+ },
984
+ {
985
+ type: 'constant',
986
+ value: 2
987
+ },
988
+ {
989
+ type: 'constant',
990
+ value: 5
991
+ }
992
+ ]
993
+ });
994
+ expect(calculateComputedFields({}, [
995
+ field
996
+ ]).total).toBe(10);
997
+ });
998
+ it('divide safely skips division by zero operands (guards against Infinity/NaN)', ()=>{
999
+ const field = arithmeticField({
1000
+ type: 'arithmetic',
1001
+ operation: 'divide',
1002
+ operands: [
1003
+ {
1004
+ type: 'constant',
1005
+ value: 100
1006
+ },
1007
+ {
1008
+ type: 'constant',
1009
+ value: 0
1010
+ }
1011
+ ]
1012
+ });
1013
+ expect(calculateComputedFields({}, [
1014
+ field
1015
+ ]).total).toBe(100);
1016
+ });
1017
+ it('average computes the mean of all operand values', ()=>{
1018
+ const field = arithmeticField({
1019
+ type: 'arithmetic',
1020
+ operation: 'average',
1021
+ operands: [
1022
+ {
1023
+ type: 'constant',
1024
+ value: 2
1025
+ },
1026
+ {
1027
+ type: 'constant',
1028
+ value: 4
1029
+ },
1030
+ {
1031
+ type: 'constant',
1032
+ value: 6
1033
+ }
1034
+ ]
1035
+ });
1036
+ expect(calculateComputedFields({}, [
1037
+ field
1038
+ ]).total).toBe(4);
1039
+ });
1040
+ it('min / max compute the extremes among operand values', ()=>{
1041
+ const operands = [
1042
+ {
1043
+ type: 'constant',
1044
+ value: 5
1045
+ },
1046
+ {
1047
+ type: 'constant',
1048
+ value: 1
1049
+ },
1050
+ {
1051
+ type: 'constant',
1052
+ value: 9
1053
+ }
1054
+ ];
1055
+ expect(calculateComputedFields({}, [
1056
+ arithmeticField({
1057
+ type: 'arithmetic',
1058
+ operation: 'min',
1059
+ operands
1060
+ })
1061
+ ]).total).toBe(1);
1062
+ expect(calculateComputedFields({}, [
1063
+ arithmeticField({
1064
+ type: 'arithmetic',
1065
+ operation: 'max',
1066
+ operands
1067
+ })
1068
+ ]).total).toBe(9);
1069
+ });
1070
+ it('treats missing field operand values as 0', ()=>{
1071
+ const field = arithmeticField({
1072
+ type: 'arithmetic',
1073
+ operation: 'sum',
1074
+ operands: [
1075
+ {
1076
+ type: 'field',
1077
+ fieldId: 'missing'
1078
+ },
1079
+ {
1080
+ type: 'constant',
1081
+ value: 5
1082
+ }
1083
+ ]
1084
+ });
1085
+ expect(calculateComputedFields({}, [
1086
+ field
1087
+ ]).total).toBe(5);
1088
+ });
1089
+ it('treats a constant operand with an undefined value as 0', ()=>{
1090
+ const field = arithmeticField({
1091
+ type: 'arithmetic',
1092
+ operation: 'sum',
1093
+ operands: [
1094
+ {
1095
+ type: 'constant'
1096
+ },
1097
+ {
1098
+ type: 'constant',
1099
+ value: 5
1100
+ }
1101
+ ]
1102
+ });
1103
+ expect(calculateComputedFields({}, [
1104
+ field
1105
+ ]).total).toBe(5);
1106
+ });
1107
+ it('returns null for an unknown arithmetic operation', ()=>{
1108
+ const field = arithmeticField({
1109
+ type: 'arithmetic',
1110
+ operation: 'unknown-op',
1111
+ operands: [
1112
+ {
1113
+ type: 'constant',
1114
+ value: 1
1115
+ }
1116
+ ]
1117
+ });
1118
+ expect(calculateComputedFields({}, [
1119
+ field
1120
+ ]).total).toBeNull();
1121
+ });
1122
+ it('returns null when operands array is empty', ()=>{
1123
+ const field = arithmeticField({
1124
+ type: 'arithmetic',
1125
+ operation: 'sum',
1126
+ operands: []
1127
+ });
1128
+ expect(calculateComputedFields({}, [
1129
+ field
1130
+ ]).total).toBeNull();
1131
+ });
1132
+ it('does not treat an arithmetic config as valid when operands is not an array', ()=>{
1133
+ const field = directField();
1134
+ field.rules[0].computation = {
1135
+ type: 'arithmetic',
1136
+ config: {
1137
+ type: 'arithmetic',
1138
+ operation: 'sum',
1139
+ operands: 'not-an-array'
1140
+ }
1141
+ };
1142
+ // Fails the isArithmeticConfig type guard -> falls through to "unknown computation type" -> null
1143
+ expect(calculateComputedFields({}, [
1144
+ field
1145
+ ]).total).toBeNull();
1146
+ });
1147
+ });
1148
+ // ==========================================================================
1149
+ // Error handling / malformed field defensive fallback
1150
+ // ==========================================================================
1151
+ describe('defensive error handling', ()=>{
1152
+ it('falls back to defaultValue when field.rules is malformed and calculation throws', ()=>{
1153
+ const field = {
1154
+ id: 'cf-1',
1155
+ name: 'Total',
1156
+ key: 'total',
1157
+ valueType: 'number',
1158
+ defaultValue: -1,
1159
+ rules: undefined
1160
+ };
1161
+ const result = calculateComputedFields({}, [
1162
+ field
1163
+ ]);
1164
+ expect(result.total).toBe(-1);
1165
+ });
1166
+ it('falls back to null when field.rules is malformed and no defaultValue is set', ()=>{
1167
+ const field = {
1168
+ id: 'cf-1',
1169
+ name: 'Total',
1170
+ key: 'total',
1171
+ valueType: 'number',
1172
+ rules: undefined
1173
+ };
1174
+ const result = calculateComputedFields({}, [
1175
+ field
1176
+ ]);
1177
+ expect(result.total).toBeNull();
1178
+ });
1179
+ it('isolates failures per field — one bad field does not break the others', ()=>{
1180
+ const badField = {
1181
+ id: 'cf-bad',
1182
+ name: 'Bad',
1183
+ key: 'bad',
1184
+ valueType: 'number',
1185
+ defaultValue: 0,
1186
+ rules: undefined
1187
+ };
1188
+ const goodField = directField({
1189
+ key: 'good'
1190
+ });
1191
+ const result = calculateComputedFields({}, [
1192
+ badField,
1193
+ goodField
1194
+ ]);
1195
+ expect(result).toEqual({
1196
+ bad: 0,
1197
+ good: 42
1198
+ });
1199
+ });
1200
+ });
1201
+ });