@pdtf/schemas 3.6.0-4 → 3.6.0-41

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 (64) hide show
  1. package/.claude/plans/not-seeing-associated-costs.md +99 -0
  2. package/.claude/settings.local.json +22 -3
  3. package/README.md +52 -33
  4. package/docs/sef25-extensions-ui-spec.md +355 -0
  5. package/index.js +130 -66
  6. package/package.json +4 -2
  7. package/src/examples/v3/exampleTransaction.json +3 -3
  8. package/src/schemas/v3/combined.json +7651 -1672
  9. package/src/schemas/v3/compactSkeleton.txt +365 -14
  10. package/src/schemas/v3/overlays/baspi4.json +163 -18
  11. package/src/schemas/v3/overlays/baspi5.json +168 -20
  12. package/src/schemas/v3/overlays/extensions/as.json +4 -2
  13. package/src/schemas/v3/overlays/extensions/dk.json +32 -0
  14. package/src/schemas/v3/overlays/extensions/dr.json +9 -9
  15. package/src/schemas/v3/overlays/extensions/er.json +4 -2
  16. package/src/schemas/v3/overlays/extensions/fd.json +6 -3
  17. package/src/schemas/v3/overlays/extensions/hi.json +6 -3
  18. package/src/schemas/v3/overlays/extensions/hs.json +4 -2
  19. package/src/schemas/v3/overlays/extensions/ic.json +59 -0
  20. package/src/schemas/v3/overlays/extensions/jk.json +28 -8
  21. package/src/schemas/v3/overlays/extensions/la.json +4 -2
  22. package/src/schemas/v3/overlays/extensions/lc.json +75 -0
  23. package/src/schemas/v3/overlays/extensions/ma.json +6 -3
  24. package/src/schemas/v3/overlays/extensions/mc.json +4 -2
  25. package/src/schemas/v3/overlays/extensions/mi.json +69 -0
  26. package/src/schemas/v3/overlays/extensions/nd.json +43 -0
  27. package/src/schemas/v3/overlays/extensions/oa.json +4 -2
  28. package/src/schemas/v3/overlays/extensions/oc.json +4 -2
  29. package/src/schemas/v3/overlays/extensions/pc.json +37 -0
  30. package/src/schemas/v3/overlays/extensions/ph.json +194 -0
  31. package/src/schemas/v3/overlays/extensions/rw.json +68 -0
  32. package/src/schemas/v3/overlays/extensions/sb.json +4 -2
  33. package/src/schemas/v3/overlays/extensions/sc.json +129 -0
  34. package/src/schemas/v3/overlays/extensions/sd.json +63 -0
  35. package/src/schemas/v3/overlays/extensions/sf.json +4 -2
  36. package/src/schemas/v3/overlays/extensions/sl.json +4 -2
  37. package/src/schemas/v3/overlays/extensions/tf.json +6 -3
  38. package/src/schemas/v3/overlays/extensions/tr.json +73 -0
  39. package/src/schemas/v3/overlays/extensions/wg.json +350 -0
  40. package/src/schemas/v3/overlays/fme1.json +16 -14
  41. package/src/schemas/v3/overlays/lpe1.json +60 -30
  42. package/src/schemas/v3/overlays/nts.json +68 -1
  43. package/src/schemas/v3/overlays/nts2.json +68 -4
  44. package/src/schemas/v3/overlays/ntsl.json +68 -1
  45. package/src/schemas/v3/overlays/ntsl2.json +68 -4
  46. package/src/schemas/v3/overlays/piq.json +38 -1
  47. package/src/schemas/v3/overlays/rds.json +118 -6
  48. package/src/schemas/v3/overlays/sef25.json +1067 -0
  49. package/src/schemas/v3/overlays/ta6.json +46 -16
  50. package/src/schemas/v3/overlays/ta6ed6.json +6498 -0
  51. package/src/schemas/v3/overlays/ta7.json +70 -6
  52. package/src/schemas/v3/overlays/ta7ed5.json +1539 -0
  53. package/src/schemas/v3/pdtf-transaction.json +4361 -676
  54. package/src/schemas/v3/skeleton.json +505 -36
  55. package/src/tests/v3/buyerCircumstances.test.js +543 -0
  56. package/src/tests/v3/extensionOverlays.test.js +911 -0
  57. package/src/tests/v3/offers.test.js +37 -40
  58. package/src/tests/v3/patternProperties.test.js +149 -30
  59. package/src/tests/v3/ta6ed6.test.js +2317 -0
  60. package/src/tests/v3/ta7ed5.test.js +567 -0
  61. package/src/tests/v3/transactionSchema.test.js +31 -0
  62. package/src/utils/extractExtensionOverlays.js +216 -31
  63. package/src/utils/extractOverlay.js +88 -23
  64. package/src/utils/pathSkeleton.js +1 -1
@@ -2,6 +2,7 @@ const {
2
2
  getTransactionSchema,
3
3
  extensionOverlays,
4
4
  getValidator,
5
+ getSubschemaValidator,
5
6
  } = require("../../../index.js");
6
7
 
7
8
  const schemaId =
@@ -142,6 +143,73 @@ describe("Extension Overlays", () => {
142
143
  });
143
144
  });
144
145
 
146
+ describe("SEF25 extension leaf annotations", () => {
147
+ test("wg: every W1.x subsection exposes a yesNo ntsRef", () => {
148
+ const schema = getTransactionSchema(schemaId, ["nts2023", "wg"]);
149
+ const subsections =
150
+ schema.properties?.propertyPack?.properties
151
+ ?.guaranteesWarrantiesAndIndemnityInsurances?.oneOf?.[1]?.properties;
152
+
153
+ expect(subsections).toBeDefined();
154
+
155
+ const discriminated = Object.entries(subsections).filter(
156
+ ([, sub]) => sub?.discriminator?.propertyName === "yesNo"
157
+ );
158
+ expect(discriminated.length).toBeGreaterThan(0);
159
+
160
+ for (const [key, sub] of discriminated) {
161
+ expect(sub.ntsRef).toMatch(/^W1\.\d+$/);
162
+ expect(sub.properties?.yesNo?.ntsRef).toMatch(/^W1\.\d+\.1$/);
163
+ expect(sub.required).toContain("yesNo");
164
+ }
165
+ });
166
+
167
+ test("ic: insuranceClaims exposes a yesNo ntsRef", () => {
168
+ const schema = getTransactionSchema(schemaId, ["nts2023", "ic"]);
169
+ const icProp =
170
+ schema.properties?.propertyPack?.properties?.insurance?.oneOf?.[1]
171
+ ?.properties?.insuranceClaims;
172
+
173
+ expect(icProp).toBeDefined();
174
+ expect(icProp.ntsRef).toBe("I1.1");
175
+ expect(icProp.properties?.yesNo?.ntsRef).toBe("I1.1.1");
176
+ expect(icProp.required).toContain("yesNo");
177
+ });
178
+
179
+ test("nd: neighbourDevelopment N1.1 marker merges with nts yesNo ref", () => {
180
+ // nd extension only carries the parent N1.1 marker; the yesNo leaf
181
+ // annotation comes from the nts overlay (C4.1.1), which is required
182
+ // alongside nd for the field to render.
183
+ const schema = getTransactionSchema(schemaId, ["nts2023", "nd"]);
184
+ const ndProp =
185
+ schema.properties?.propertyPack?.properties?.notices?.properties
186
+ ?.neighbourDevelopment;
187
+
188
+ expect(ndProp).toBeDefined();
189
+ expect(ndProp.ntsRef).toBe("N1.1");
190
+ expect(ndProp.properties?.yesNo?.ntsRef).toBe("C4.1.1");
191
+ expect(ndProp.required).toContain("yesNo");
192
+ });
193
+
194
+ test("mi: otherMaterialIssue exposes yesNo and details ntsRef", () => {
195
+ const schema = getTransactionSchema(schemaId, ["nts2023", "mi"]);
196
+ const miProp =
197
+ schema.properties?.propertyPack?.properties?.additionalInformation
198
+ ?.properties?.otherMaterialIssue;
199
+
200
+ expect(miProp).toBeDefined();
201
+ expect(miProp.ntsRef).toBe("M1.1");
202
+ expect(miProp.properties?.yesNo?.ntsRef).toBe("M1.1.1");
203
+ expect(miProp.required).toContain("yesNo");
204
+
205
+ const yesBranch = miProp.oneOf?.find((branch) =>
206
+ branch.required?.includes("details")
207
+ );
208
+ expect(yesBranch).toBeDefined();
209
+ expect(yesBranch.properties?.details?.ntsRef).toBe("M1.1.2");
210
+ });
211
+ });
212
+
145
213
  describe("Extension overlay availability", () => {
146
214
  test("should export all extension overlays", () => {
147
215
  const expectedKeys = [
@@ -161,6 +229,18 @@ describe("Extension Overlays", () => {
161
229
  "hi",
162
230
  "fd", // Utilities & Services
163
231
  "oc", // Transaction
232
+ "sc",
233
+ "pc",
234
+ "ph", // SEF25
235
+ "dk",
236
+ "rw",
237
+ "sd",
238
+ "lc",
239
+ "wg",
240
+ "ic",
241
+ "nd",
242
+ "mi",
243
+ "tr", // SEF25 (new)
164
244
  ];
165
245
 
166
246
  expect(Object.keys(extensionOverlays).sort()).toEqual(
@@ -469,6 +549,109 @@ describe("Extension Overlays", () => {
469
549
  ).toBeTruthy();
470
550
  });
471
551
 
552
+ test("should merge supply costs extension into mainsWater No branch", () => {
553
+ const schema = getTransactionSchema(schemaId, ["sc"]);
554
+ const mainsWater =
555
+ schema.properties?.propertyPack?.properties?.waterAndDrainage
556
+ ?.properties?.water?.properties?.mainsWater;
557
+
558
+ expect(mainsWater?.oneOf).toBeDefined();
559
+
560
+ // Branch 0 is yesNo="No" — should now contain associatedCost
561
+ const noBranch = mainsWater.oneOf[0];
562
+ const cost = noBranch?.properties?.associatedCost;
563
+ expect(cost).toBeDefined();
564
+ expect(cost.ntsRef).toBe("U1.1");
565
+ expect(cost.required).toEqual(["frequency"]);
566
+ expect(cost.properties.frequency.type).toBe("string");
567
+ // amount now lives inside the frequency-discriminator's "Per month"/"Per year" branch
568
+ const costBranch = cost.oneOf?.find((b) =>
569
+ b.properties?.frequency?.enum?.includes("Per month")
570
+ );
571
+ expect(costBranch?.properties?.amount?.type).toBe("number");
572
+ expect(costBranch?.required).toEqual(["amount"]);
573
+ });
574
+
575
+ test("should merge supply costs extension into mainsFoulDrainage No/Not known branch", () => {
576
+ const schema = getTransactionSchema(schemaId, ["sc"]);
577
+ const mainsFoul =
578
+ schema.properties?.propertyPack?.properties?.waterAndDrainage
579
+ ?.properties?.drainage?.properties?.mainsFoulDrainage;
580
+
581
+ expect(mainsFoul?.oneOf).toBeDefined();
582
+
583
+ // Branch 2 is yesNo="No"/"Not known" — should now contain associatedCost
584
+ const noBranch = mainsFoul.oneOf[2];
585
+ const cost = noBranch?.properties?.associatedCost;
586
+ expect(cost).toBeDefined();
587
+ expect(cost.ntsRef).toBe("U1.2");
588
+ expect(cost.required).toEqual(["frequency"]);
589
+ // amount now lives inside the frequency-discriminator's "Per month"/"Per year" branch
590
+ const costBranch = cost.oneOf?.find((b) =>
591
+ b.properties?.frequency?.enum?.includes("Per month")
592
+ );
593
+ expect(costBranch?.properties?.amount?.type).toBe("number");
594
+ expect(costBranch?.required).toEqual(["amount"]);
595
+ });
596
+
597
+ test("should merge parking permit cost and frequency into controlledParking Yes branch", () => {
598
+ const schema = getTransactionSchema(schemaId, ["pc"]);
599
+ const controlled =
600
+ schema.properties?.propertyPack?.properties?.parking?.properties
601
+ ?.controlledParking;
602
+
603
+ expect(controlled?.oneOf).toBeDefined();
604
+
605
+ // Branch 1 is yesNo="Yes"
606
+ const yesBranch = controlled.oneOf[1];
607
+ const cost = yesBranch?.properties?.costOfPermit;
608
+ expect(cost).toBeDefined();
609
+ expect(cost.ntsRef).toBe("P1.1");
610
+ expect(cost.type).toBe("number");
611
+
612
+ const freq = yesBranch?.properties?.costOfPermitFrequency;
613
+ expect(freq).toBeDefined();
614
+ expect(freq.ntsRef).toBe("P1.2");
615
+ expect(freq.type).toBe("string");
616
+ });
617
+
618
+ test("should merge all 4 property hazard fields into specialistIssues", () => {
619
+ const schema = getTransactionSchema(schemaId, ["ph"]);
620
+ const si =
621
+ schema.properties?.propertyPack?.properties?.specialistIssues
622
+ ?.properties;
623
+
624
+ const expectedFields = [
625
+ { key: "wellsDitchesShaft", ref: "H1.1" },
626
+ { key: "damagedOrExposedElectrics", ref: "H1.2" },
627
+ { key: "damageToFlooringOrStaircases", ref: "H1.3" },
628
+ { key: "knownAreasInPoorCondition", ref: "H1.4" },
629
+ ];
630
+
631
+ expectedFields.forEach(({ key, ref }) => {
632
+ expect(si[key]).toBeDefined();
633
+ expect(si[key].ntsRef).toBe(ref);
634
+ expect(si[key].required).toContain("yesNo");
635
+ expect(si[key].discriminator?.propertyName).toBe("yesNo");
636
+ expect(si[key].oneOf).toHaveLength(2);
637
+
638
+ // "No" branch should not require details
639
+ const noBranch = si[key].oneOf.find((b) =>
640
+ b.properties?.yesNo?.enum?.includes("No")
641
+ );
642
+ expect(noBranch).toBeDefined();
643
+ expect(noBranch.required).toBeUndefined();
644
+
645
+ // "Yes" branch should require details
646
+ const yesBranch = si[key].oneOf.find((b) =>
647
+ b.properties?.yesNo?.enum?.includes("Yes")
648
+ );
649
+ expect(yesBranch).toBeDefined();
650
+ expect(yesBranch.required).toContain("details");
651
+ expect(yesBranch.properties.details.minLength).toBe(1);
652
+ });
653
+ });
654
+
472
655
  test("should not require additional fields when 'No' is selected in extension overlays", () => {
473
656
  // Test the 'as' (additional searches) extension overlay
474
657
  const schema = getTransactionSchema(schemaId, ["as"]);
@@ -522,4 +705,732 @@ describe("Extension Overlays", () => {
522
705
  }
523
706
  });
524
707
  });
708
+
709
+ describe("SEF25 extension validation", () => {
710
+ describe("Supply costs (sc)", () => {
711
+ const overlays = ["baspiV5", "sc"];
712
+
713
+ test("should validate valid private water cost", () => {
714
+ const validator = getSubschemaValidator(
715
+ "/propertyPack/waterAndDrainage/water/mainsWater",
716
+ schemaId,
717
+ overlays
718
+ );
719
+
720
+ const result = validator({
721
+ yesNo: "No",
722
+ details: "Private borehole",
723
+ associatedCost: {
724
+ amount: 50,
725
+ frequency: "Per month",
726
+ },
727
+ });
728
+
729
+ expect(result).toBe(true);
730
+ });
731
+
732
+ test("should validate private water with no associated costs", () => {
733
+ const validator = getSubschemaValidator(
734
+ "/propertyPack/waterAndDrainage/water/mainsWater",
735
+ schemaId,
736
+ overlays
737
+ );
738
+
739
+ const result = validator({
740
+ yesNo: "No",
741
+ details: "Private borehole",
742
+ associatedCost: {
743
+ amount: 0,
744
+ frequency: "Not applicable",
745
+ },
746
+ });
747
+
748
+ expect(result).toBe(true);
749
+ });
750
+
751
+ test("should require associated cost for private water supply", () => {
752
+ const validator = getSubschemaValidator(
753
+ "/propertyPack/waterAndDrainage/water/mainsWater",
754
+ schemaId,
755
+ overlays
756
+ );
757
+
758
+ const result = validator({
759
+ yesNo: "No",
760
+ details: "Private well",
761
+ });
762
+
763
+ // associatedCost is required by the sc extension
764
+ expect(result).toBe(false);
765
+ });
766
+
767
+ test("should reject invalid frequency value for water cost", () => {
768
+ const validator = getSubschemaValidator(
769
+ "/propertyPack/waterAndDrainage/water/mainsWater",
770
+ schemaId,
771
+ overlays
772
+ );
773
+
774
+ const result = validator({
775
+ yesNo: "No",
776
+ details: "Borehole",
777
+ associatedCost: {
778
+ amount: 100,
779
+ frequency: "Weekly",
780
+ },
781
+ });
782
+
783
+ expect(result).toBe(false);
784
+ });
785
+
786
+ test("should reject non-numeric amount for water cost", () => {
787
+ const validator = getSubschemaValidator(
788
+ "/propertyPack/waterAndDrainage/water/mainsWater",
789
+ schemaId,
790
+ overlays
791
+ );
792
+
793
+ const result = validator({
794
+ yesNo: "No",
795
+ details: "Borehole",
796
+ associatedCost: {
797
+ amount: "fifty pounds",
798
+ frequency: "Per month",
799
+ },
800
+ });
801
+
802
+ expect(result).toBe(false);
803
+ });
804
+
805
+ test("should validate valid private sewerage cost", () => {
806
+ // Use sc without baspiV5 to avoid strict offMainsDrainageSystem requirements
807
+ const validator = getSubschemaValidator(
808
+ "/propertyPack/waterAndDrainage/drainage/mainsFoulDrainage",
809
+ schemaId,
810
+ ["sc"]
811
+ );
812
+
813
+ const result = validator({
814
+ yesNo: "No",
815
+ associatedCost: {
816
+ amount: 200,
817
+ frequency: "Per year",
818
+ },
819
+ });
820
+
821
+ expect(result).toBe(true);
822
+ });
823
+
824
+ test("should reject invalid frequency for sewerage cost", () => {
825
+ const validator = getSubschemaValidator(
826
+ "/propertyPack/waterAndDrainage/drainage/mainsFoulDrainage",
827
+ schemaId,
828
+ ["sc"]
829
+ );
830
+
831
+ const result = validator({
832
+ yesNo: "No",
833
+ associatedCost: {
834
+ amount: 200,
835
+ frequency: "Quarterly",
836
+ },
837
+ });
838
+
839
+ expect(result).toBe(false);
840
+ });
841
+
842
+ test("should not allow associated cost when mains water is Yes", () => {
843
+ const validator = getSubschemaValidator(
844
+ "/propertyPack/waterAndDrainage/water/mainsWater",
845
+ schemaId,
846
+ overlays
847
+ );
848
+
849
+ // When yesNo="Yes", associatedCost should cause validation failure
850
+ // because it's only defined in the "No" oneOf branch
851
+ const result = validator({
852
+ yesNo: "Yes",
853
+ supplier: "Thames Water",
854
+ associatedCost: {
855
+ amount: 50,
856
+ frequency: "Per month",
857
+ },
858
+ });
859
+
860
+ expect(result).toBe(false);
861
+ });
862
+ });
863
+
864
+ describe("Parking permit cost (pc)", () => {
865
+ const overlays = ["baspiV5", "pc"];
866
+
867
+ test("should validate valid parking permit with frequency", () => {
868
+ const validator = getSubschemaValidator(
869
+ "/propertyPack/parking/controlledParking",
870
+ schemaId,
871
+ overlays
872
+ );
873
+
874
+ const result = validator({
875
+ yesNo: "Yes",
876
+ costOfPermit: 150,
877
+ costOfPermitFrequency: "Per year",
878
+ });
879
+
880
+ expect(result).toBe(true);
881
+ });
882
+
883
+ test("should validate parking permit with monthly frequency", () => {
884
+ const validator = getSubschemaValidator(
885
+ "/propertyPack/parking/controlledParking",
886
+ schemaId,
887
+ overlays
888
+ );
889
+
890
+ const result = validator({
891
+ yesNo: "Yes",
892
+ costOfPermit: 12.50,
893
+ costOfPermitFrequency: "Per month",
894
+ });
895
+
896
+ expect(result).toBe(true);
897
+ });
898
+
899
+ test("should reject invalid frequency for parking permit", () => {
900
+ const validator = getSubschemaValidator(
901
+ "/propertyPack/parking/controlledParking",
902
+ schemaId,
903
+ overlays
904
+ );
905
+
906
+ const result = validator({
907
+ yesNo: "Yes",
908
+ costOfPermit: 150,
909
+ costOfPermitFrequency: "Per quarter",
910
+ });
911
+
912
+ expect(result).toBe(false);
913
+ });
914
+
915
+ test("should validate No answer without cost fields", () => {
916
+ const validator = getSubschemaValidator(
917
+ "/propertyPack/parking/controlledParking",
918
+ schemaId,
919
+ overlays
920
+ );
921
+
922
+ const result = validator({
923
+ yesNo: "No",
924
+ });
925
+
926
+ expect(result).toBe(true);
927
+ });
928
+ });
929
+
930
+ describe("Property hazards (ph)", () => {
931
+ const overlays = ["ph"];
932
+ const hazardFields = [
933
+ "wellsDitchesShaft",
934
+ "damagedOrExposedElectrics",
935
+ "damageToFlooringOrStaircases",
936
+ "knownAreasInPoorCondition",
937
+ ];
938
+
939
+ hazardFields.forEach((field) => {
940
+ test(`${field}: should validate "No" answer`, () => {
941
+ const validator = getSubschemaValidator(
942
+ `/propertyPack/specialistIssues/${field}`,
943
+ schemaId,
944
+ overlays
945
+ );
946
+
947
+ const result = validator({ yesNo: "No" });
948
+ expect(result).toBe(true);
949
+ });
950
+
951
+ test(`${field}: should validate "Yes" with details`, () => {
952
+ const validator = getSubschemaValidator(
953
+ `/propertyPack/specialistIssues/${field}`,
954
+ schemaId,
955
+ overlays
956
+ );
957
+
958
+ const result = validator({
959
+ yesNo: "Yes",
960
+ details: "Some description of the issue",
961
+ });
962
+ expect(result).toBe(true);
963
+ });
964
+
965
+ test(`${field}: should reject "Yes" without details`, () => {
966
+ const validator = getSubschemaValidator(
967
+ `/propertyPack/specialistIssues/${field}`,
968
+ schemaId,
969
+ overlays
970
+ );
971
+
972
+ const result = validator({ yesNo: "Yes" });
973
+ expect(result).toBe(false);
974
+ });
975
+
976
+ test(`${field}: should reject "Yes" with empty details`, () => {
977
+ const validator = getSubschemaValidator(
978
+ `/propertyPack/specialistIssues/${field}`,
979
+ schemaId,
980
+ overlays
981
+ );
982
+
983
+ const result = validator({ yesNo: "Yes", details: "" });
984
+ expect(result).toBe(false);
985
+ });
986
+
987
+ test(`${field}: should reject invalid yesNo value`, () => {
988
+ const validator = getSubschemaValidator(
989
+ `/propertyPack/specialistIssues/${field}`,
990
+ schemaId,
991
+ overlays
992
+ );
993
+
994
+ const result = validator({ yesNo: "Maybe" });
995
+ expect(result).toBe(false);
996
+ });
997
+ });
998
+ });
999
+
1000
+ describe("Combined SEF25 extensions", () => {
1001
+ test("should load all three SEF25 extensions together", () => {
1002
+ const schema = getTransactionSchema(schemaId, [
1003
+ "baspiV5",
1004
+ "sc",
1005
+ "pc",
1006
+ "ph",
1007
+ ]);
1008
+
1009
+ // Supply costs
1010
+ const waterCost =
1011
+ schema.properties?.propertyPack?.properties?.waterAndDrainage
1012
+ ?.properties?.water?.properties?.mainsWater?.oneOf?.[0]?.properties
1013
+ ?.associatedCost;
1014
+ expect(waterCost).toBeDefined();
1015
+
1016
+ const sewerageCost =
1017
+ schema.properties?.propertyPack?.properties?.waterAndDrainage
1018
+ ?.properties?.drainage?.properties?.mainsFoulDrainage?.oneOf?.[2]
1019
+ ?.properties?.associatedCost;
1020
+ expect(sewerageCost).toBeDefined();
1021
+
1022
+ // Parking
1023
+ const parkingFreq =
1024
+ schema.properties?.propertyPack?.properties?.parking?.properties
1025
+ ?.controlledParking?.oneOf?.[1]?.properties?.costOfPermitFrequency;
1026
+ expect(parkingFreq).toBeDefined();
1027
+
1028
+ // Hazards
1029
+ const si =
1030
+ schema.properties?.propertyPack?.properties?.specialistIssues
1031
+ ?.properties;
1032
+ expect(si?.wellsDitchesShaft).toBeDefined();
1033
+ expect(si?.damagedOrExposedElectrics).toBeDefined();
1034
+ expect(si?.damageToFlooringOrStaircases).toBeDefined();
1035
+ expect(si?.knownAreasInPoorCondition).toBeDefined();
1036
+ });
1037
+
1038
+ test("should not interfere with existing NTS2 extensions", () => {
1039
+ const schema = getTransactionSchema(schemaId, [
1040
+ "nts2023",
1041
+ "jk",
1042
+ "hs",
1043
+ "sc",
1044
+ "pc",
1045
+ "ph",
1046
+ ]);
1047
+
1048
+ const si =
1049
+ schema.properties?.propertyPack?.properties?.specialistIssues;
1050
+
1051
+ // Existing NTS2 extensions still work
1052
+ expect(si?.properties?.japaneseKnotweed?.ntsRef).toBe("A5.3");
1053
+ expect(si?.properties?.ongoingHealthOrSafetyIssue?.ntsRef).toBe(
1054
+ "A5.5"
1055
+ );
1056
+ expect(si?.required).toContain("japaneseKnotweed");
1057
+ expect(si?.required).toContain("ongoingHealthOrSafetyIssue");
1058
+
1059
+ // SEF25 hazards also present (remapped to ntsRef in extension overlays)
1060
+ expect(si?.properties?.wellsDitchesShaft?.ntsRef).toBe("H1.1");
1061
+ expect(si?.properties?.knownAreasInPoorCondition?.ntsRef).toBe(
1062
+ "H1.4"
1063
+ );
1064
+ });
1065
+ });
1066
+
1067
+ describe("Dropped kerb (dk)", () => {
1068
+ test("should validate Yes answer", () => {
1069
+ const validator = getSubschemaValidator(
1070
+ "/propertyPack/parking/droppedKerbAccess",
1071
+ schemaId,
1072
+ ["dk"]
1073
+ );
1074
+ expect(validator({ yesNo: "Yes" })).toBe(true);
1075
+ });
1076
+
1077
+ test("should validate No answer", () => {
1078
+ const validator = getSubschemaValidator(
1079
+ "/propertyPack/parking/droppedKerbAccess",
1080
+ schemaId,
1081
+ ["dk"]
1082
+ );
1083
+ expect(validator({ yesNo: "No" })).toBe(true);
1084
+ });
1085
+
1086
+ test("should reject invalid value", () => {
1087
+ const validator = getSubschemaValidator(
1088
+ "/propertyPack/parking/droppedKerbAccess",
1089
+ schemaId,
1090
+ ["dk"]
1091
+ );
1092
+ expect(validator({ yesNo: "Maybe" })).toBe(false);
1093
+ });
1094
+
1095
+ test("should not be required without dk overlay", () => {
1096
+ const schema = getTransactionSchema(schemaId, ["baspiV5"]);
1097
+ const parking = schema.properties?.propertyPack?.properties?.parking;
1098
+ expect(parking?.required || []).not.toContain("droppedKerbAccess");
1099
+ });
1100
+
1101
+ test("should be required with dk overlay", () => {
1102
+ const schema = getTransactionSchema(schemaId, ["dk"]);
1103
+ const parking = schema.properties?.propertyPack?.properties?.parking;
1104
+ expect(parking?.required).toContain("droppedKerbAccess");
1105
+ });
1106
+ });
1107
+
1108
+ describe("Private right of way (rw)", () => {
1109
+ test("should validate No answer", () => {
1110
+ const validator = getSubschemaValidator(
1111
+ "/propertyPack/rightsAndInformalArrangements/rightsOrArrangements/privateRightOfWay",
1112
+ schemaId,
1113
+ ["rw"]
1114
+ );
1115
+ expect(validator({ yesNo: "No" })).toBe(true);
1116
+ });
1117
+
1118
+ test("should validate Yes with details", () => {
1119
+ const validator = getSubschemaValidator(
1120
+ "/propertyPack/rightsAndInformalArrangements/rightsOrArrangements/privateRightOfWay",
1121
+ schemaId,
1122
+ ["rw"]
1123
+ );
1124
+ expect(validator({ yesNo: "Yes", details: "Neighbour has access through garden" })).toBe(true);
1125
+ });
1126
+
1127
+ test("should reject Yes without details", () => {
1128
+ const validator = getSubschemaValidator(
1129
+ "/propertyPack/rightsAndInformalArrangements/rightsOrArrangements/privateRightOfWay",
1130
+ schemaId,
1131
+ ["rw"]
1132
+ );
1133
+ expect(validator({ yesNo: "Yes" })).toBe(false);
1134
+ });
1135
+
1136
+ test("should reject Yes with empty details", () => {
1137
+ const validator = getSubschemaValidator(
1138
+ "/propertyPack/rightsAndInformalArrangements/rightsOrArrangements/privateRightOfWay",
1139
+ schemaId,
1140
+ ["rw"]
1141
+ );
1142
+ expect(validator({ yesNo: "Yes", details: "" })).toBe(false);
1143
+ });
1144
+
1145
+ test("should not be required without rw overlay", () => {
1146
+ const schema = getTransactionSchema(schemaId, ["baspiV5"]);
1147
+ const rights = schema.properties?.propertyPack?.properties?.rightsAndInformalArrangements
1148
+ ?.properties?.rightsOrArrangements;
1149
+ expect(rights?.required || []).not.toContain("privateRightOfWay");
1150
+ });
1151
+
1152
+ test("should be required with rw overlay", () => {
1153
+ const schema = getTransactionSchema(schemaId, ["rw"]);
1154
+ const rights = schema.properties?.propertyPack?.properties?.rightsAndInformalArrangements
1155
+ ?.properties?.rightsOrArrangements;
1156
+ expect(rights?.required).toContain("privateRightOfWay");
1157
+ });
1158
+ });
1159
+
1160
+ describe("Storm/fire/flood damage (sd)", () => {
1161
+ test("should validate No answer", () => {
1162
+ const validator = getSubschemaValidator(
1163
+ "/propertyPack/environmentalIssues/stormFireFloodDamage",
1164
+ schemaId,
1165
+ ["sd"]
1166
+ );
1167
+ expect(validator({ yesNo: "No" })).toBe(true);
1168
+ });
1169
+
1170
+ test("should validate Yes with details", () => {
1171
+ const validator = getSubschemaValidator(
1172
+ "/propertyPack/environmentalIssues/stormFireFloodDamage",
1173
+ schemaId,
1174
+ ["sd"]
1175
+ );
1176
+ expect(validator({ yesNo: "Yes", details: "Flood damage in 2020" })).toBe(true);
1177
+ });
1178
+
1179
+ test("should reject Yes without details", () => {
1180
+ const validator = getSubschemaValidator(
1181
+ "/propertyPack/environmentalIssues/stormFireFloodDamage",
1182
+ schemaId,
1183
+ ["sd"]
1184
+ );
1185
+ expect(validator({ yesNo: "Yes" })).toBe(false);
1186
+ });
1187
+
1188
+ test("should not be required without sd overlay", () => {
1189
+ const schema = getTransactionSchema(schemaId, ["baspiV5"]);
1190
+ const env = schema.properties?.propertyPack?.properties?.environmentalIssues;
1191
+ expect(env?.required || []).not.toContain("stormFireFloodDamage");
1192
+ });
1193
+
1194
+ test("should be required with sd overlay", () => {
1195
+ const schema = getTransactionSchema(schemaId, ["sd"]);
1196
+ const env = schema.properties?.propertyPack?.properties?.environmentalIssues;
1197
+ expect(env?.required).toContain("stormFireFloodDamage");
1198
+ });
1199
+ });
1200
+
1201
+ describe("Solar lease costs (lc)", () => {
1202
+ test("should validate Not applicable frequency", () => {
1203
+ const validator = getSubschemaValidator(
1204
+ "/propertyPack/electricity/solarPanels/panelsOwnedOutright/associatedCost",
1205
+ schemaId,
1206
+ ["lc"]
1207
+ );
1208
+ expect(validator({ amount: 0, frequency: "Not applicable" })).toBe(true);
1209
+ });
1210
+
1211
+ test("should validate amount and frequency", () => {
1212
+ const validator = getSubschemaValidator(
1213
+ "/propertyPack/electricity/solarPanels/panelsOwnedOutright/associatedCost",
1214
+ schemaId,
1215
+ ["lc"]
1216
+ );
1217
+ expect(validator({ amount: 75, frequency: "Per month" })).toBe(true);
1218
+ });
1219
+
1220
+ test("should reject missing amount", () => {
1221
+ const validator = getSubschemaValidator(
1222
+ "/propertyPack/electricity/solarPanels/panelsOwnedOutright/associatedCost",
1223
+ schemaId,
1224
+ ["lc"]
1225
+ );
1226
+ expect(validator({ frequency: "Per month" })).toBe(false);
1227
+ });
1228
+
1229
+ test("should reject missing frequency", () => {
1230
+ const validator = getSubschemaValidator(
1231
+ "/propertyPack/electricity/solarPanels/panelsOwnedOutright/associatedCost",
1232
+ schemaId,
1233
+ ["lc"]
1234
+ );
1235
+ expect(validator({ amount: 75 })).toBe(false);
1236
+ });
1237
+
1238
+ test("should reject invalid frequency", () => {
1239
+ const validator = getSubschemaValidator(
1240
+ "/propertyPack/electricity/solarPanels/panelsOwnedOutright/associatedCost",
1241
+ schemaId,
1242
+ ["lc"]
1243
+ );
1244
+ expect(validator({ amount: 75, frequency: "Weekly" })).toBe(false);
1245
+ });
1246
+ });
1247
+
1248
+ describe("Warranties and guarantees (wg)", () => {
1249
+ const warrantyCategoryPaths = [
1250
+ { path: "newHomeWarranty", ref: "W1.1" },
1251
+ { path: "roofingWork", ref: "W1.2" },
1252
+ { path: "dampProofingTreatment", ref: "W1.3" },
1253
+ { path: "centralHeatingAndorPlumbing", ref: "W1.4" },
1254
+ { path: "electricalRepairOrInstallation", ref: "W1.5" },
1255
+ { path: "subsidenceWork", ref: "W1.6" },
1256
+ { path: "otherGuarantees", ref: "W1.7" },
1257
+ ];
1258
+
1259
+ test("should have ntsRef on all warranty categories when wg overlay loaded", () => {
1260
+ const schema = getTransactionSchema(schemaId, ["baspiV5", "wg"]);
1261
+ const warranties =
1262
+ schema.properties?.propertyPack?.properties?.guaranteesWarrantiesAndIndemnityInsurances;
1263
+
1264
+ // Warranty fields are in oneOf[1] (hasValidGuaranteesOrWarranties = Yes)
1265
+ const yesBranch = warranties.oneOf[1];
1266
+
1267
+ warrantyCategoryPaths.forEach(({ path, ref }) => {
1268
+ expect(yesBranch.properties[path]?.ntsRef).toBe(ref);
1269
+ });
1270
+ });
1271
+
1272
+ test("should mark warranty categories as required when wg overlay loaded", () => {
1273
+ const schema = getTransactionSchema(schemaId, ["baspiV5", "wg"]);
1274
+ const warranties =
1275
+ schema.properties?.propertyPack?.properties?.guaranteesWarrantiesAndIndemnityInsurances;
1276
+
1277
+ const yesBranch = warranties.oneOf[1];
1278
+ warrantyCategoryPaths.forEach(({ path }) => {
1279
+ expect(yesBranch.required).toContain(path);
1280
+ });
1281
+ });
1282
+
1283
+ test("should not require warranty categories without any overlay", () => {
1284
+ const schema = getTransactionSchema(schemaId, []);
1285
+ const warranties =
1286
+ schema.properties?.propertyPack?.properties?.guaranteesWarrantiesAndIndemnityInsurances;
1287
+
1288
+ const yesBranch = warranties.oneOf[1];
1289
+ // Without any overlay, warranty categories should not be in required
1290
+ expect(yesBranch.required || []).not.toContain("newHomeWarranty");
1291
+ expect(yesBranch.required || []).not.toContain("electricalRepairOrInstallation");
1292
+ });
1293
+ });
1294
+
1295
+ describe("Insurance claims (ic)", () => {
1296
+ test("should mark insuranceClaims as required in isInsured=Yes branch", () => {
1297
+ const schema = getTransactionSchema(schemaId, ["ic"]);
1298
+ const insurance = schema.properties?.propertyPack?.properties?.insurance;
1299
+ // oneOf[1] is isInsured=Yes
1300
+ const yesBranch = insurance.oneOf[1];
1301
+ expect(yesBranch?.required).toContain("insuranceClaims");
1302
+ });
1303
+
1304
+ test("should validate insuranceClaims No answer", () => {
1305
+ const validator = getSubschemaValidator(
1306
+ "/propertyPack/insurance/insuranceClaims",
1307
+ schemaId,
1308
+ ["baspiV5", "ic"]
1309
+ );
1310
+ expect(validator({ yesNo: "No" })).toBe(true);
1311
+ });
1312
+
1313
+ test("should validate insuranceClaims Yes with details", () => {
1314
+ const validator = getSubschemaValidator(
1315
+ "/propertyPack/insurance/insuranceClaims",
1316
+ schemaId,
1317
+ ["baspiV5", "ic"]
1318
+ );
1319
+ expect(validator({ yesNo: "Yes", details: "Storm damage claim in 2019" })).toBe(true);
1320
+ });
1321
+ });
1322
+
1323
+ describe("Neighbour development (nd)", () => {
1324
+ test("should add ntsRef to neighbourDevelopment", () => {
1325
+ const schema = getTransactionSchema(schemaId, ["nd"]);
1326
+ const nd = schema.properties?.propertyPack?.properties?.notices
1327
+ ?.properties?.neighbourDevelopment;
1328
+ expect(nd?.ntsRef).toBe("N1.1");
1329
+ });
1330
+
1331
+ test("should add ntsRef via nd overlay", () => {
1332
+ const schema = getTransactionSchema(schemaId, ["nd"]);
1333
+ const notices = schema.properties?.propertyPack?.properties?.notices;
1334
+ expect(notices?.ntsRef).toBe("N1");
1335
+ });
1336
+
1337
+ test("should validate No answer", () => {
1338
+ const validator = getSubschemaValidator(
1339
+ "/propertyPack/notices/neighbourDevelopment",
1340
+ schemaId,
1341
+ ["baspiV5", "nd"]
1342
+ );
1343
+ expect(validator({ yesNo: "No" })).toBe(true);
1344
+ });
1345
+ });
1346
+
1347
+ describe("Other material issue (mi)", () => {
1348
+ test("should mark otherMaterialIssue as required", () => {
1349
+ const schema = getTransactionSchema(schemaId, ["mi"]);
1350
+ const additional = schema.properties?.propertyPack?.properties?.additionalInformation;
1351
+ expect(additional?.required).toContain("otherMaterialIssue");
1352
+ });
1353
+
1354
+ test("should not require otherMaterialIssue without mi overlay", () => {
1355
+ const schema = getTransactionSchema(schemaId, []);
1356
+ const additional = schema.properties?.propertyPack?.properties?.additionalInformation;
1357
+ expect(additional?.required || []).not.toContain("otherMaterialIssue");
1358
+ });
1359
+
1360
+ test("should validate No answer", () => {
1361
+ const validator = getSubschemaValidator(
1362
+ "/propertyPack/additionalInformation/otherMaterialIssue",
1363
+ schemaId,
1364
+ ["baspiV5", "mi"]
1365
+ );
1366
+ expect(validator({ yesNo: "No" })).toBe(true);
1367
+ });
1368
+
1369
+ test("should validate Yes with details", () => {
1370
+ const validator = getSubschemaValidator(
1371
+ "/propertyPack/additionalInformation/otherMaterialIssue",
1372
+ schemaId,
1373
+ ["baspiV5", "mi"]
1374
+ );
1375
+ expect(validator({ yesNo: "Yes", details: "Previous subsidence issues" })).toBe(true);
1376
+ });
1377
+ });
1378
+
1379
+ describe("Title restrictions (tr)", () => {
1380
+ test("should require titleRestrictions in freehold branch with tr overlay", () => {
1381
+ const schema = getTransactionSchema(schemaId, ["tr"]);
1382
+ const items = schema.properties?.propertyPack?.properties?.ownership
1383
+ ?.properties?.ownershipsToBeTransferred?.items;
1384
+ // oneOf[0] is Freehold
1385
+ const freeholdBranch = items.oneOf[0];
1386
+ expect(freeholdBranch?.properties?.titleRestrictions).toBeDefined();
1387
+ expect(freeholdBranch?.required).toContain("titleRestrictions");
1388
+ });
1389
+
1390
+ test("should not require titleRestrictions without tr overlay", () => {
1391
+ const schema = getTransactionSchema(schemaId, ["baspiV5"]);
1392
+ const items = schema.properties?.propertyPack?.properties?.ownership
1393
+ ?.properties?.ownershipsToBeTransferred?.items;
1394
+ const freeholdBranch = items.oneOf[0];
1395
+ expect(freeholdBranch?.required || []).not.toContain("titleRestrictions");
1396
+ });
1397
+
1398
+ test("should have correct ntsRef", () => {
1399
+ const schema = getTransactionSchema(schemaId, ["tr"]);
1400
+ const items = schema.properties?.propertyPack?.properties?.ownership
1401
+ ?.properties?.ownershipsToBeTransferred?.items;
1402
+ const freeholdBranch = items.oneOf[0];
1403
+ expect(freeholdBranch?.properties?.titleRestrictions?.ntsRef).toBe("T1.1");
1404
+ });
1405
+ });
1406
+
1407
+ describe("All SEF25 extensions combined", () => {
1408
+ const allSef25 = ["sc", "pc", "ph", "dk", "rw", "sd", "lc", "wg", "ic", "nd", "mi", "tr"];
1409
+
1410
+ test("should load all SEF25 extensions together without error", () => {
1411
+ const schema = getTransactionSchema(schemaId, ["baspiV5", ...allSef25]);
1412
+ expect(schema).toBeDefined();
1413
+ expect(schema.properties?.propertyPack).toBeDefined();
1414
+ });
1415
+
1416
+ test("should not interfere with existing NTS2 extensions when combined", () => {
1417
+ const schema = getTransactionSchema(schemaId, ["nts2025", ...allSef25]);
1418
+
1419
+ // NTS2 fields still work
1420
+ const si = schema.properties?.propertyPack?.properties?.specialistIssues;
1421
+ expect(si?.properties?.wellsDitchesShaft?.ntsRef).toBe("H1.1");
1422
+
1423
+ // New SEF25 fields present
1424
+ const parking = schema.properties?.propertyPack?.properties?.parking?.properties;
1425
+ expect(parking?.droppedKerbAccess).toBeDefined();
1426
+
1427
+ const env = schema.properties?.propertyPack?.properties?.environmentalIssues?.properties;
1428
+ expect(env?.stormFireFloodDamage).toBeDefined();
1429
+
1430
+ const rights = schema.properties?.propertyPack?.properties?.rightsAndInformalArrangements
1431
+ ?.properties?.rightsOrArrangements?.properties;
1432
+ expect(rights?.privateRightOfWay).toBeDefined();
1433
+ });
1434
+ });
1435
+ });
525
1436
  });