@defra/forms-engine-plugin 4.0.9 → 4.0.11

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.
@@ -1,7 +1,11 @@
1
1
  import { ComponentType, type NumberFieldComponent } from '@defra/forms-model'
2
2
 
3
3
  import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
4
- import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js'
4
+ import {
5
+ NumberField,
6
+ validateMinimumPrecision,
7
+ validateStringLength
8
+ } from '~/src/server/plugins/engine/components/NumberField.js'
5
9
  import {
6
10
  getAnswer,
7
11
  type Field
@@ -19,6 +23,94 @@ describe('NumberField', () => {
19
23
  })
20
24
  })
21
25
 
26
+ describe('Helper Functions', () => {
27
+ describe('validateStringLength', () => {
28
+ it('returns valid when no constraints provided', () => {
29
+ expect(validateStringLength(123)).toEqual({ isValid: true })
30
+ expect(validateStringLength(123, undefined, undefined)).toEqual({
31
+ isValid: true
32
+ })
33
+ })
34
+
35
+ it('validates minimum length correctly', () => {
36
+ expect(validateStringLength(12, 3)).toEqual({
37
+ isValid: false,
38
+ error: 'minLength'
39
+ })
40
+ expect(validateStringLength(123, 3)).toEqual({ isValid: true })
41
+ expect(validateStringLength(1234, 3)).toEqual({ isValid: true })
42
+ })
43
+
44
+ it('validates maximum length correctly', () => {
45
+ expect(validateStringLength(123456, undefined, 5)).toEqual({
46
+ isValid: false,
47
+ error: 'maxLength'
48
+ })
49
+ expect(validateStringLength(12345, undefined, 5)).toEqual({
50
+ isValid: true
51
+ })
52
+ expect(validateStringLength(123, undefined, 5)).toEqual({
53
+ isValid: true
54
+ })
55
+ })
56
+
57
+ it('validates both min and max length', () => {
58
+ expect(validateStringLength(12, 3, 5)).toEqual({
59
+ isValid: false,
60
+ error: 'minLength'
61
+ })
62
+ expect(validateStringLength(123456, 3, 5)).toEqual({
63
+ isValid: false,
64
+ error: 'maxLength'
65
+ })
66
+ expect(validateStringLength(1234, 3, 5)).toEqual({ isValid: true })
67
+ })
68
+
69
+ it('handles decimal numbers correctly', () => {
70
+ // "52.1" = 4 characters
71
+ expect(validateStringLength(52.1, 3, 5)).toEqual({ isValid: true })
72
+ // "52.123456" = 9 characters
73
+ expect(validateStringLength(52.123456, undefined, 8)).toEqual({
74
+ isValid: false,
75
+ error: 'maxLength'
76
+ })
77
+ })
78
+
79
+ it('handles negative numbers correctly', () => {
80
+ // "-1.5" = 4 characters
81
+ expect(validateStringLength(-1.5, 3, 5)).toEqual({ isValid: true })
82
+ // "-9.1234567" = 10 characters
83
+ expect(validateStringLength(-9.1234567, undefined, 9)).toEqual({
84
+ isValid: false,
85
+ error: 'maxLength'
86
+ })
87
+ })
88
+ })
89
+
90
+ describe('validateMinimumPrecision', () => {
91
+ it('returns false for integers', () => {
92
+ expect(validateMinimumPrecision(52, 1)).toBe(false)
93
+ expect(validateMinimumPrecision(100, 2)).toBe(false)
94
+ })
95
+
96
+ it('validates minimum precision correctly', () => {
97
+ expect(validateMinimumPrecision(52.1, 1)).toBe(true)
98
+ expect(validateMinimumPrecision(52.12, 2)).toBe(true)
99
+ expect(validateMinimumPrecision(52.123, 3)).toBe(true)
100
+ })
101
+
102
+ it('returns false when precision is insufficient', () => {
103
+ expect(validateMinimumPrecision(52.1, 2)).toBe(false)
104
+ expect(validateMinimumPrecision(52.12, 3)).toBe(false)
105
+ })
106
+
107
+ it('handles exact precision requirement', () => {
108
+ expect(validateMinimumPrecision(52.12345, 5)).toBe(true)
109
+ expect(validateMinimumPrecision(52.1234, 5)).toBe(false)
110
+ })
111
+ })
112
+ })
113
+
22
114
  describe('Defaults', () => {
23
115
  let def: NumberFieldComponent
24
116
  let collection: ComponentCollection
@@ -504,17 +596,184 @@ describe('NumberField', () => {
504
596
  ]
505
597
  },
506
598
  {
507
- description: 'Schema min and max',
599
+ description: 'Schema minPrecision (minimum 1 decimal place)',
600
+ component: createPrecisionTestComponent(1),
601
+ assertions: [
602
+ {
603
+ input: getFormData('52'),
604
+ output: {
605
+ value: getFormData(52),
606
+ errors: [
607
+ expect.objectContaining({
608
+ text: 'Example number field must have at least 1 decimal place'
609
+ })
610
+ ]
611
+ }
612
+ },
613
+ {
614
+ input: getFormData('52.0'),
615
+ output: {
616
+ value: getFormData(52),
617
+ errors: [
618
+ expect.objectContaining({
619
+ text: 'Example number field must have at least 1 decimal place'
620
+ })
621
+ ]
622
+ }
623
+ },
624
+ {
625
+ input: getFormData('52.1'),
626
+ output: { value: getFormData(52.1) }
627
+ },
628
+ {
629
+ input: getFormData('52.123456'),
630
+ output: { value: getFormData(52.123456) }
631
+ }
632
+ ]
633
+ },
634
+ {
635
+ description: 'Schema minPrecision (minimum 2 decimal places)',
636
+ component: createPrecisionTestComponent(2),
637
+ assertions: [
638
+ {
639
+ input: getFormData('52.1'),
640
+ output: {
641
+ value: getFormData(52.1),
642
+ errors: [
643
+ expect.objectContaining({
644
+ text: 'Example number field must have at least 2 decimal places'
645
+ })
646
+ ]
647
+ }
648
+ },
649
+ {
650
+ input: getFormData('52.12'),
651
+ output: { value: getFormData(52.12) }
652
+ },
653
+ {
654
+ input: getFormData('52.1234567'),
655
+ output: { value: getFormData(52.1234567) }
656
+ }
657
+ ]
658
+ },
659
+ {
660
+ description: 'Schema minLength (minimum 3 characters)',
661
+ component: createLengthTestComponent(3, undefined),
662
+ assertions: [
663
+ {
664
+ input: getFormData('12'),
665
+ output: {
666
+ value: getFormData(12),
667
+ errors: [
668
+ expect.objectContaining({
669
+ text: 'Example number field must be at least 3 characters'
670
+ })
671
+ ]
672
+ }
673
+ },
674
+ {
675
+ input: getFormData('123'),
676
+ output: { value: getFormData(123) }
677
+ },
678
+ {
679
+ input: getFormData('1234'),
680
+ output: { value: getFormData(1234) }
681
+ }
682
+ ]
683
+ },
684
+ {
685
+ description: 'Schema maxLength (maximum 5 characters)',
686
+ component: createLengthTestComponent(undefined, 5),
687
+ assertions: [
688
+ {
689
+ input: getFormData('123456'),
690
+ output: {
691
+ value: getFormData(123456),
692
+ errors: [
693
+ expect.objectContaining({
694
+ text: 'Example number field must be no more than 5 characters'
695
+ })
696
+ ]
697
+ }
698
+ },
699
+ {
700
+ input: getFormData('12345'),
701
+ output: { value: getFormData(12345) }
702
+ },
703
+ {
704
+ input: getFormData('123'),
705
+ output: { value: getFormData(123) }
706
+ }
707
+ ]
708
+ },
709
+ {
710
+ description:
711
+ 'Schema minLength and maxLength (3-8 characters, like latitude)',
508
712
  component: {
509
- title: 'Example number field',
713
+ title: 'Latitude field',
714
+ shortDescription: 'Latitude',
510
715
  name: 'myComponent',
511
716
  type: ComponentType.NumberField,
512
- options: {},
717
+ options: {
718
+ customValidationMessages: {
719
+ 'number.minPrecision':
720
+ '{{#label}} must have at least {{#minPrecision}} decimal place',
721
+ 'number.minLength':
722
+ '{{#label}} must be between 3 and 10 characters',
723
+ 'number.maxLength':
724
+ '{{#label}} must be between 3 and 10 characters'
725
+ }
726
+ },
727
+ schema: {
728
+ min: 49,
729
+ max: 60,
730
+ precision: 7,
731
+ minPrecision: 1,
732
+ minLength: 3,
733
+ maxLength: 10
734
+ }
735
+ } as NumberFieldComponent,
736
+ assertions: [
737
+ {
738
+ input: getFormData('52'),
739
+ output: {
740
+ value: getFormData(52),
741
+ errors: [
742
+ expect.objectContaining({
743
+ text: 'Latitude must have at least 1 decimal place'
744
+ })
745
+ ]
746
+ }
747
+ },
748
+ {
749
+ input: getFormData('52.12345678'),
750
+ output: {
751
+ value: getFormData(52.12345678),
752
+ errors: [
753
+ expect.objectContaining({
754
+ text: 'Latitude must have 7 or fewer decimal places'
755
+ })
756
+ ]
757
+ }
758
+ },
759
+ {
760
+ input: getFormData('52.1'),
761
+ output: { value: getFormData(52.1) }
762
+ },
763
+ {
764
+ input: getFormData('52.1234'),
765
+ output: { value: getFormData(52.1234) }
766
+ }
767
+ ]
768
+ },
769
+ {
770
+ description: 'Schema min and max',
771
+ component: createNumberComponent({
513
772
  schema: {
514
773
  min: 5,
515
774
  max: 8
516
775
  }
517
- } satisfies NumberFieldComponent,
776
+ }),
518
777
  assertions: [
519
778
  {
520
779
  input: getFormData('4'),
@@ -542,10 +801,7 @@ describe('NumberField', () => {
542
801
  },
543
802
  {
544
803
  description: 'Custom validation message',
545
- component: {
546
- title: 'Example number field',
547
- name: 'myComponent',
548
- type: ComponentType.NumberField,
804
+ component: createNumberComponent({
549
805
  options: {
550
806
  customValidationMessage: 'This is a custom error',
551
807
  customValidationMessages: {
@@ -554,9 +810,8 @@ describe('NumberField', () => {
554
810
  'number.min': 'This is not used',
555
811
  'number.max': 'This is not used'
556
812
  }
557
- },
558
- schema: {}
559
- } satisfies NumberFieldComponent,
813
+ }
814
+ }),
560
815
  assertions: [
561
816
  {
562
817
  input: getFormData(''),
@@ -686,6 +941,45 @@ describe('NumberField', () => {
686
941
  }
687
942
  ]
688
943
  },
944
+ {
945
+ description: 'Custom validation message overrides length validation',
946
+ component: {
947
+ title: 'Example number field',
948
+ name: 'myComponent',
949
+ type: ComponentType.NumberField,
950
+ options: {
951
+ customValidationMessage: 'This is a custom length error'
952
+ },
953
+ schema: {
954
+ minLength: 3,
955
+ maxLength: 5
956
+ }
957
+ } satisfies NumberFieldComponent,
958
+ assertions: [
959
+ {
960
+ input: getFormData('12'),
961
+ output: {
962
+ value: getFormData(12),
963
+ errors: [
964
+ expect.objectContaining({
965
+ text: 'This is a custom length error'
966
+ })
967
+ ]
968
+ }
969
+ },
970
+ {
971
+ input: getFormData('123456'),
972
+ output: {
973
+ value: getFormData(123456),
974
+ errors: [
975
+ expect.objectContaining({
976
+ text: 'This is a custom length error'
977
+ })
978
+ ]
979
+ }
980
+ }
981
+ ]
982
+ },
689
983
  {
690
984
  description: 'Optional field',
691
985
  component: {
@@ -720,4 +1014,202 @@ describe('NumberField', () => {
720
1014
  )
721
1015
  })
722
1016
  })
1017
+
1018
+ describe('Edge cases', () => {
1019
+ let collection: ComponentCollection
1020
+
1021
+ beforeEach(() => {
1022
+ const def = createNumberComponent({
1023
+ schema: {
1024
+ min: -100,
1025
+ max: 100,
1026
+ precision: 2
1027
+ }
1028
+ })
1029
+ collection = new ComponentCollection([def], { model })
1030
+ })
1031
+
1032
+ it('handles negative numbers correctly', () => {
1033
+ const result = collection.validate(getFormData('-50.5'))
1034
+ expect(result).toEqual({
1035
+ value: getFormData(-50.5)
1036
+ })
1037
+ })
1038
+
1039
+ it('handles zero correctly', () => {
1040
+ const result = collection.validate(getFormData('0'))
1041
+ expect(result).toEqual({
1042
+ value: getFormData(0)
1043
+ })
1044
+ })
1045
+
1046
+ it('handles zero with decimal correctly', () => {
1047
+ const result = collection.validate(getFormData('0.0'))
1048
+ expect(result).toEqual({
1049
+ value: getFormData(0)
1050
+ })
1051
+ })
1052
+
1053
+ it('handles negative zero correctly', () => {
1054
+ const result = collection.validate(getFormData('-0'))
1055
+ expect(result).toEqual({
1056
+ value: getFormData(0)
1057
+ })
1058
+ })
1059
+
1060
+ it('handles scientific notation (parsed as number, may fail range)', () => {
1061
+ // JavaScript parses '1e10' as 10000000000, which exceeds max of 100
1062
+ const result = collection.validate(getFormData('1e10'))
1063
+ expect(result).toEqual({
1064
+ value: getFormData(10000000000),
1065
+ errors: [
1066
+ expect.objectContaining({
1067
+ text: 'Example number field must be 100 or lower'
1068
+ })
1069
+ ]
1070
+ })
1071
+ })
1072
+
1073
+ it('handles scientific notation with negative exponent (parsed as number)', () => {
1074
+ // JavaScript parses '1e-5' as 0.00001, which fails precision check (5 decimal places > 2)
1075
+ const result = collection.validate(getFormData('1e-5'))
1076
+ expect(result.value).toEqual(getFormData(0.00001))
1077
+ expect(result.errors).toBeDefined()
1078
+ expect(result.errors?.[0]).toMatchObject({
1079
+ text: 'Example number field must have 2 or fewer decimal places'
1080
+ })
1081
+ })
1082
+
1083
+ it('handles large negative numbers', () => {
1084
+ const result = collection.validate(getFormData('-99.99'))
1085
+ expect(result).toEqual({
1086
+ value: getFormData(-99.99)
1087
+ })
1088
+ })
1089
+
1090
+ it('handles numbers at boundary limits', () => {
1091
+ const maxResult = collection.validate(getFormData('100'))
1092
+ expect(maxResult).toEqual({
1093
+ value: getFormData(100)
1094
+ })
1095
+
1096
+ const minResult = collection.validate(getFormData('-100'))
1097
+ expect(minResult).toEqual({
1098
+ value: getFormData(-100)
1099
+ })
1100
+ })
1101
+
1102
+ describe('with length constraints', () => {
1103
+ beforeEach(() => {
1104
+ const def = createNumberComponent({
1105
+ schema: {
1106
+ min: -9,
1107
+ max: 9,
1108
+ precision: 7,
1109
+ minPrecision: 1,
1110
+ minLength: 2,
1111
+ maxLength: 10
1112
+ },
1113
+ options: {
1114
+ customValidationMessages: {
1115
+ 'number.minPrecision':
1116
+ 'Example number field must have at least {{minPrecision}} decimal place',
1117
+ 'number.precision':
1118
+ 'Example number field must have no more than {{limit}} decimal places',
1119
+ 'number.minLength':
1120
+ 'Example number field must be at least {{minLength}} characters',
1121
+ 'number.maxLength':
1122
+ 'Example number field must be no more than {{maxLength}} characters'
1123
+ }
1124
+ }
1125
+ })
1126
+ collection = new ComponentCollection([def], { model })
1127
+ })
1128
+
1129
+ it('validates negative numbers with decimals', () => {
1130
+ const result = collection.validate(getFormData('-5.1234567'))
1131
+ expect(result).toEqual({
1132
+ value: getFormData(-5.1234567)
1133
+ })
1134
+ })
1135
+
1136
+ it('rejects negative numbers that are too short', () => {
1137
+ const result = collection.validate(getFormData('-5'))
1138
+ expect(result.value).toEqual(getFormData(-5))
1139
+ expect(result.errors).toBeDefined()
1140
+ expect(result.errors?.[0].text).toContain('decimal place')
1141
+ })
1142
+
1143
+ it('rejects numbers with too many characters', () => {
1144
+ const result = collection.validate(getFormData('-5.12345678'))
1145
+ expect(result.value).toEqual(getFormData(-5.12345678))
1146
+ expect(result.errors).toBeDefined()
1147
+ expect(result.errors?.[0].text).toContain('decimal places')
1148
+ })
1149
+ })
1150
+ })
723
1151
  })
1152
+
1153
+ /**
1154
+ * Factory function to create a default NumberField component with optional overrides
1155
+ */
1156
+ function createNumberComponent(
1157
+ overrides: Partial<NumberFieldComponent> = {}
1158
+ ): NumberFieldComponent {
1159
+ const base = {
1160
+ title: 'Example number field',
1161
+ name: 'myComponent',
1162
+ type: ComponentType.NumberField,
1163
+ options: {},
1164
+ schema: {}
1165
+ } satisfies NumberFieldComponent
1166
+
1167
+ // Deep merge for nested objects like options and schema
1168
+ return {
1169
+ ...base,
1170
+ ...overrides,
1171
+ options: { ...base.options, ...(overrides.options ?? {}) },
1172
+ schema: { ...base.schema, ...(overrides.schema ?? {}) }
1173
+ } satisfies NumberFieldComponent
1174
+ }
1175
+
1176
+ /**
1177
+ * Helper for precision validation tests
1178
+ */
1179
+ function createPrecisionTestComponent(
1180
+ minPrecision: number,
1181
+ precision = 7
1182
+ ): NumberFieldComponent {
1183
+ const pluralSuffix = minPrecision > 1 ? 's' : ''
1184
+ return createNumberComponent({
1185
+ options: {
1186
+ customValidationMessages: {
1187
+ 'number.minPrecision': `{{#label}} must have at least {{#minPrecision}} decimal place${pluralSuffix}`
1188
+ }
1189
+ },
1190
+ schema: { precision, minPrecision }
1191
+ })
1192
+ }
1193
+
1194
+ /**
1195
+ * Helper for length validation tests
1196
+ */
1197
+ function createLengthTestComponent(
1198
+ minLength?: number,
1199
+ maxLength?: number
1200
+ ): NumberFieldComponent {
1201
+ const messages: Record<string, string> = {}
1202
+ if (minLength) {
1203
+ messages['number.minLength'] =
1204
+ '{{#label}} must be at least {{#minLength}} characters'
1205
+ }
1206
+ if (maxLength) {
1207
+ messages['number.maxLength'] =
1208
+ '{{#label}} must be no more than {{#maxLength}} characters'
1209
+ }
1210
+
1211
+ return createNumberComponent({
1212
+ options: { customValidationMessages: messages },
1213
+ schema: { minLength, maxLength }
1214
+ })
1215
+ }