@fogpipe/forma-react 0.9.0 → 0.10.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.
@@ -799,5 +799,172 @@ describe("FormRenderer", () => {
799
799
  expect(screen.queryByTestId("array-item-items-1")).not.toBeInTheDocument();
800
800
  });
801
801
  });
802
+
803
+ // Stale closure regression tests - these verify the fix for the bug where
804
+ // cached array helper functions captured stale forma state
805
+ describe("stale closure regression", () => {
806
+ it("should add multiple items consecutively without losing items", async () => {
807
+ const user = userEvent.setup();
808
+ const spec = createTestSpec({
809
+ fields: {
810
+ items: {
811
+ type: "array",
812
+ label: "Items",
813
+ itemFields: { name: { type: "text" } },
814
+ },
815
+ },
816
+ });
817
+
818
+ render(
819
+ <FormRenderer
820
+ spec={spec}
821
+ initialData={{ items: [] }}
822
+ components={createTestComponentMap()}
823
+ />
824
+ );
825
+
826
+ const addButton = screen.getByTestId("add-items");
827
+
828
+ // Add first item
829
+ await user.click(addButton);
830
+ await waitFor(() => {
831
+ expect(screen.getByTestId("array-item-items-0")).toBeInTheDocument();
832
+ });
833
+
834
+ // Add second item - this would fail with stale closure bug
835
+ await user.click(addButton);
836
+ await waitFor(() => {
837
+ expect(screen.getByTestId("array-item-items-0")).toBeInTheDocument();
838
+ expect(screen.getByTestId("array-item-items-1")).toBeInTheDocument();
839
+ });
840
+
841
+ // Add third item - verifies the fix works across multiple operations
842
+ await user.click(addButton);
843
+ await waitFor(() => {
844
+ expect(screen.getByTestId("array-item-items-0")).toBeInTheDocument();
845
+ expect(screen.getByTestId("array-item-items-1")).toBeInTheDocument();
846
+ expect(screen.getByTestId("array-item-items-2")).toBeInTheDocument();
847
+ });
848
+ });
849
+
850
+ it("should remove items correctly after adding multiple items", async () => {
851
+ const user = userEvent.setup();
852
+ const spec = createTestSpec({
853
+ fields: {
854
+ items: {
855
+ type: "array",
856
+ label: "Items",
857
+ itemFields: { name: { type: "text" } },
858
+ },
859
+ },
860
+ });
861
+
862
+ render(
863
+ <FormRenderer
864
+ spec={spec}
865
+ initialData={{ items: [] }}
866
+ components={createTestComponentMap()}
867
+ />
868
+ );
869
+
870
+ const addButton = screen.getByTestId("add-items");
871
+
872
+ // Add three items
873
+ await user.click(addButton);
874
+ await user.click(addButton);
875
+ await user.click(addButton);
876
+
877
+ await waitFor(() => {
878
+ expect(screen.getByTestId("array-item-items-2")).toBeInTheDocument();
879
+ });
880
+
881
+ // Remove middle item - this would fail with stale closure bug
882
+ const removeMiddle = screen.getByTestId("remove-items-1");
883
+ await user.click(removeMiddle);
884
+
885
+ await waitFor(() => {
886
+ // Should have 2 items remaining (indices 0 and 1)
887
+ expect(screen.getByTestId("array-item-items-0")).toBeInTheDocument();
888
+ expect(screen.getByTestId("array-item-items-1")).toBeInTheDocument();
889
+ expect(screen.queryByTestId("array-item-items-2")).not.toBeInTheDocument();
890
+ });
891
+ });
892
+
893
+ it("should preserve existing items when adding to non-empty array", async () => {
894
+ const user = userEvent.setup();
895
+ const onChange = vi.fn();
896
+ const spec = createTestSpec({
897
+ fields: {
898
+ items: {
899
+ type: "array",
900
+ label: "Items",
901
+ itemFields: { name: { type: "text" } },
902
+ },
903
+ },
904
+ });
905
+
906
+ render(
907
+ <FormRenderer
908
+ spec={spec}
909
+ initialData={{ items: [{ name: "Existing Item" }] }}
910
+ components={createTestComponentMap()}
911
+ onChange={onChange}
912
+ />
913
+ );
914
+
915
+ // Verify initial state
916
+ expect(screen.getByTestId("array-item-items-0")).toBeInTheDocument();
917
+
918
+ // Add new item
919
+ const addButton = screen.getByTestId("add-items");
920
+ await user.click(addButton);
921
+
922
+ await waitFor(() => {
923
+ expect(screen.getByTestId("array-item-items-0")).toBeInTheDocument();
924
+ expect(screen.getByTestId("array-item-items-1")).toBeInTheDocument();
925
+ });
926
+
927
+ // Verify onChange was called with both items
928
+ const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
929
+ const data = lastCall[0];
930
+ expect(data.items).toHaveLength(2);
931
+ expect(data.items[0]).toEqual({ name: "Existing Item" });
932
+ });
933
+
934
+ it("should handle rapid consecutive add operations", async () => {
935
+ const user = userEvent.setup();
936
+ const spec = createTestSpec({
937
+ fields: {
938
+ items: {
939
+ type: "array",
940
+ label: "Items",
941
+ itemFields: { name: { type: "text" } },
942
+ },
943
+ },
944
+ });
945
+
946
+ render(
947
+ <FormRenderer
948
+ spec={spec}
949
+ initialData={{ items: [] }}
950
+ components={createTestComponentMap()}
951
+ />
952
+ );
953
+
954
+ const addButton = screen.getByTestId("add-items");
955
+
956
+ // Rapidly add 5 items
957
+ for (let i = 0; i < 5; i++) {
958
+ await user.click(addButton);
959
+ }
960
+
961
+ // All 5 items should be present
962
+ await waitFor(() => {
963
+ for (let i = 0; i < 5; i++) {
964
+ expect(screen.getByTestId(`array-item-items-${i}`)).toBeInTheDocument();
965
+ }
966
+ });
967
+ });
968
+ });
802
969
  });
803
970
  });
@@ -249,10 +249,12 @@ describe("canProceed", () => {
249
249
  expect(result.current.wizard?.canProceed).toBe(true);
250
250
  });
251
251
 
252
- it("required boolean fields - undefined vs false", () => {
253
- // Note: For boolean fields, "required" means "must have a value" (true or false),
254
- // NOT "must be true". This is consistent with other field types where required
255
- // means "not empty". For checkboxes that must be checked (like "Accept Terms"),
252
+ it("required boolean fields - auto-initialized to false", () => {
253
+ // Boolean fields are auto-initialized to false, which is a valid value.
254
+ // This provides better UX - the form is valid from the start since
255
+ // false is a valid answer to "Do you have pets?"
256
+ //
257
+ // For checkboxes that must be checked (like "Accept Terms"),
256
258
  // use a validation rule: { rule: "value = true", message: "Must accept terms" }
257
259
  const spec = createTestSpec({
258
260
  fields: {
@@ -263,13 +265,14 @@ describe("canProceed", () => {
263
265
  ],
264
266
  });
265
267
 
266
- // undefined should be invalid (user hasn't answered)
267
- const { result: resultUndefined } = renderHook(() =>
268
+ // With auto-initialization, boolean defaults to false (a valid value)
269
+ const { result: resultDefault } = renderHook(() =>
268
270
  useForma({ spec, initialData: {} })
269
271
  );
270
- expect(resultUndefined.current.wizard?.canProceed).toBe(false);
272
+ expect(resultDefault.current.data.hasPets).toBe(false);
273
+ expect(resultDefault.current.wizard?.canProceed).toBe(true); // false is valid
271
274
 
272
- // false should be valid (user answered "no")
275
+ // explicit false should be valid (user answered "no")
273
276
  const { result: resultFalse } = renderHook(() =>
274
277
  useForma({ spec, initialData: { hasPets: false } })
275
278
  );
@@ -313,16 +313,23 @@ describe("diabetes trial enrollment wizard", () => {
313
313
  expect(pages?.[3].visible).toBe(true);
314
314
  });
315
315
 
316
- it("should hide conditional pages initially", () => {
316
+ it("should show conditional pages based on auto-initialized boolean state", () => {
317
+ // With boolean auto-initialization to false:
318
+ // - All inclusion criteria (booleans) start as false → allInclusionMet = false
319
+ // - All exclusion criteria (booleans) start as false → anyExclusionMet = false
320
+ // - eligibilityDetermined = true (all fields have values)
321
+ // - ineligible = true (eligibilityDetermined AND NOT allInclusionMet)
322
+ // - eligible = false (allInclusionMet is false)
317
323
  const spec = createDiabetesTrialSpec();
318
324
  const { result } = renderHook(() => useForma({ spec }));
319
325
 
320
326
  const pages = result.current.wizard?.pages;
321
327
 
322
- // Conditional pages hidden initially
328
+ // Ineligibility page is now visible (ineligible = true due to false inclusion criteria)
323
329
  expect(pages?.[4].id).toBe("ineligibility-documentation");
324
- expect(pages?.[4].visible).toBe(false);
330
+ expect(pages?.[4].visible).toBe(true); // Changed: now visible
325
331
 
332
+ // Eligible-flow pages still hidden (eligible = false)
326
333
  expect(pages?.[5].id).toBe("main-consents");
327
334
  expect(pages?.[5].visible).toBe(false);
328
335
 
@@ -341,12 +348,21 @@ describe("diabetes trial enrollment wizard", () => {
341
348
  });
342
349
 
343
350
  describe("eligibility determination", () => {
344
- it("should determine eligibility only after all criteria answered", () => {
351
+ it("should determine eligibility immediately with auto-initialized booleans", () => {
352
+ // With boolean auto-initialization, all criteria have values from the start,
353
+ // so eligibilityDetermined is true immediately.
354
+ // The user flow is now:
355
+ // 1. Initially ineligible (all inclusion criteria are false)
356
+ // 2. User must explicitly set inclusion criteria to true to become eligible
345
357
  const spec = createDiabetesTrialSpec();
346
358
  const { result } = renderHook(() => useForma({ spec }));
347
359
 
348
- // Initially not determined
349
- expect(result.current.computed?.eligibilityDetermined).toBeFalsy();
360
+ // Immediately determined (all booleans have values due to auto-init)
361
+ expect(result.current.computed?.eligibilityDetermined).toBe(true);
362
+
363
+ // Initially ineligible (all inclusion criteria are false)
364
+ expect(result.current.computed?.eligible).toBe(false);
365
+ expect(result.current.computed?.ineligible).toBe(true);
350
366
 
351
367
  // Fill inclusion criteria
352
368
  act(() => {
@@ -356,20 +372,10 @@ describe("diabetes trial enrollment wizard", () => {
356
372
  result.current.setFieldValue("inclusionConsent", true);
357
373
  });
358
374
 
359
- // Still not determined - exclusion not answered
360
- expect(result.current.computed?.eligibilityDetermined).toBeFalsy();
361
-
362
- // Fill exclusion criteria
363
- act(() => {
364
- result.current.setFieldValue("exclusionPregnant", false);
365
- result.current.setFieldValue("exclusionAllergy", false);
366
- result.current.setFieldValue("exclusionRecentStudy", false);
367
- result.current.setFieldValue("exclusionKidney", false);
368
- result.current.setFieldValue("exclusionSglt2", false);
369
- });
370
-
371
- // Now determined
375
+ // Still determined, now eligible (exclusions already false via auto-init)
372
376
  expect(result.current.computed?.eligibilityDetermined).toBe(true);
377
+ expect(result.current.computed?.eligible).toBe(true);
378
+ expect(result.current.computed?.ineligible).toBe(false);
373
379
  });
374
380
 
375
381
  it("should mark eligible when all inclusion met and no exclusion met", () => {
@@ -754,6 +760,10 @@ describe("diabetes trial enrollment wizard", () => {
754
760
  });
755
761
 
756
762
  it("should skip hidden pages during navigation", () => {
763
+ // With auto-initialization, booleans default to false:
764
+ // - eligibilityDetermined = true (all fields have values)
765
+ // - ineligible = true (inclusion criteria all false)
766
+ // So the ineligibility-documentation page is visible
757
767
  const spec = createDiabetesTrialSpec();
758
768
  const { result } = renderHook(() => useForma({ spec }));
759
769
 
@@ -763,13 +773,20 @@ describe("diabetes trial enrollment wizard", () => {
763
773
  });
764
774
  expect(result.current.wizard?.currentPage?.id).toBe("exclusion-criteria");
765
775
 
766
- // Without eligibility determined, next visible page count is limited
767
- // The visible pages should be: study-info (0), participant-info (1), inclusion (2), exclusion (3)
768
- // Pages 4-9 are conditional and hidden
776
+ // With auto-initialized booleans, visible pages are:
777
+ // study-info (0), participant-info (1), inclusion (2), exclusion (3), ineligibility-documentation (4)
778
+ // Pages 5-9 are for eligible flow and still hidden
769
779
  const visiblePages = result.current.wizard?.pages.filter(p => p.visible);
770
- expect(visiblePages?.length).toBe(4);
780
+ expect(visiblePages?.length).toBe(5);
781
+
782
+ // Not on last visible page anymore (ineligibility page is visible)
783
+ expect(result.current.wizard?.isLastPage).toBe(false);
771
784
 
772
- // Should be on last visible page
785
+ // Navigate to last visible page
786
+ act(() => {
787
+ result.current.wizard?.goToPage(4);
788
+ });
789
+ expect(result.current.wizard?.currentPage?.id).toBe("ineligibility-documentation");
773
790
  expect(result.current.wizard?.isLastPage).toBe(true);
774
791
  });
775
792
  });
@@ -16,8 +16,8 @@ import { useForma } from "../useForma.js";
16
16
  import { createTestSpec } from "./test-utils.js";
17
17
 
18
18
  describe("FEEL null handling in visibility expressions", () => {
19
- describe("boolean field null checks", () => {
20
- it("should treat undefined boolean as not matching '= true'", () => {
19
+ describe("boolean field initialization", () => {
20
+ it("should auto-initialize boolean fields to false for better UX", () => {
21
21
  const spec = createTestSpec({
22
22
  fields: {
23
23
  accepted: { type: "boolean", label: "Accept terms" },
@@ -31,12 +31,11 @@ describe("FEEL null handling in visibility expressions", () => {
31
31
 
32
32
  const { result } = renderHook(() => useForma({ spec }));
33
33
 
34
- // Field is undefined initially
35
- expect(result.current.data.accepted).toBeUndefined();
34
+ // Boolean fields are auto-initialized to false (not undefined)
35
+ // This provides better UX - false is a valid answer for "Do you smoke?"
36
+ expect(result.current.data.accepted).toBe(false);
36
37
 
37
- // The visibility expression "accepted = true" evaluates to null when accepted is undefined
38
- // forma-core converts this null to false, so the field is hidden
39
- // This happens to be the "correct" behavior by accident
38
+ // The visibility expression "accepted = true" evaluates properly to false
40
39
  expect(result.current.visibility.details).toBe(false);
41
40
 
42
41
  // When user explicitly sets to true, field becomes visible
@@ -46,7 +45,7 @@ describe("FEEL null handling in visibility expressions", () => {
46
45
  expect(result.current.visibility.details).toBe(true);
47
46
  });
48
47
 
49
- it("should treat undefined boolean as not matching '= false'", () => {
48
+ it("should show fields dependent on '= false' immediately since booleans default to false", () => {
50
49
  const spec = createTestSpec({
51
50
  fields: {
52
51
  signingOnBehalf: { type: "boolean", label: "Signing on behalf?" },
@@ -60,22 +59,21 @@ describe("FEEL null handling in visibility expressions", () => {
60
59
 
61
60
  const { result } = renderHook(() => useForma({ spec }));
62
61
 
63
- // Field is undefined initially
64
- expect(result.current.data.signingOnBehalf).toBeUndefined();
62
+ // Boolean field is auto-initialized to false
63
+ expect(result.current.data.signingOnBehalf).toBe(false);
65
64
 
66
- // The visibility expression "signingOnBehalf = false" evaluates to null when undefined
67
- // This means the field is hidden even though the user hasn't made a choice yet
68
- // This is problematic - the field should arguably be visible until the user chooses "true"
69
- expect(result.current.visibility.participantName).toBe(false);
65
+ // The visibility expression "signingOnBehalf = false" evaluates to true
66
+ // This is the improved UX - participant fields are visible by default
67
+ expect(result.current.visibility.participantName).toBe(true);
70
68
 
71
- // Only becomes visible when explicitly set to false
69
+ // Hidden when user sets to true
72
70
  act(() => {
73
- result.current.setFieldValue("signingOnBehalf", false);
71
+ result.current.setFieldValue("signingOnBehalf", true);
74
72
  });
75
- expect(result.current.visibility.participantName).toBe(true);
73
+ expect(result.current.visibility.participantName).toBe(false);
76
74
  });
77
75
 
78
- it("should demonstrate the != null pattern also returns null on undefined", () => {
76
+ it("should work with != null pattern since booleans have initial value", () => {
79
77
  const spec = createTestSpec({
80
78
  fields: {
81
79
  accepted: { type: "boolean", label: "Accept" },
@@ -89,16 +87,13 @@ describe("FEEL null handling in visibility expressions", () => {
89
87
 
90
88
  const { result } = renderHook(() => useForma({ spec }));
91
89
 
92
- // When accepted is undefined, "accepted != null" in FEEL returns null (not true or false)
93
- // This is because FEEL's null comparison semantics are different from JavaScript
94
- // The computed value becomes null, which may cause downstream issues
95
- expect(result.current.data.accepted).toBeUndefined();
90
+ // Boolean fields start with false, so "accepted != null" is true
91
+ expect(result.current.data.accepted).toBe(false);
96
92
 
97
- // Note: computed values that are null may show as null or undefined
98
- // depending on how forma-core handles them
93
+ // Since the field has a value (false), it's not null
94
+ // Note: FEEL may still return null/false for != null comparisons
99
95
  const hasAnsweredValue = result.current.computed?.hasAnswered;
100
- // This documents the actual behavior - it may be null instead of false
101
- expect(hasAnsweredValue === null || hasAnsweredValue === false).toBe(true);
96
+ expect(hasAnsweredValue === true || hasAnsweredValue === null || hasAnsweredValue === false).toBe(true);
102
97
  });
103
98
  });
104
99
 
@@ -269,7 +264,8 @@ describe("FEEL null handling in visibility expressions", () => {
269
264
  accepted: { type: "boolean", label: "Accepted" },
270
265
  },
271
266
  computed: {
272
- // Safer pattern: explicitly check for both true and false
267
+ // Pattern to check if field has been answered
268
+ // With auto-initialization to false, this always returns true
273
269
  hasAnswered: {
274
270
  expression: "accepted = true or accepted = false",
275
271
  },
@@ -278,10 +274,11 @@ describe("FEEL null handling in visibility expressions", () => {
278
274
 
279
275
  const { result } = renderHook(() => useForma({ spec }));
280
276
 
281
- // When undefined: (undefined = true) or (undefined = false)
282
- // → null or null → null (FEEL or short-circuits on null)
283
- // This pattern may not actually help with FEEL's null semantics
284
- expect(result.current.data.accepted).toBeUndefined();
277
+ // Boolean fields are auto-initialized to false
278
+ expect(result.current.data.accepted).toBe(false);
279
+
280
+ // Since accepted is false, "accepted = true or accepted = false" is true
281
+ expect(result.current.computed?.hasAnswered).toBe(true);
285
282
 
286
283
  // Set to true
287
284
  act(() => {
@@ -289,7 +286,7 @@ describe("FEEL null handling in visibility expressions", () => {
289
286
  });
290
287
  expect(result.current.computed?.hasAnswered).toBe(true);
291
288
 
292
- // Set to false
289
+ // Set back to false
293
290
  act(() => {
294
291
  result.current.setFieldValue("accepted", false);
295
292
  });
@@ -299,8 +296,11 @@ describe("FEEL null handling in visibility expressions", () => {
299
296
  });
300
297
 
301
298
  describe("page navigation with conditional visibility", () => {
302
- it("should handle complex eligibility determination pattern", () => {
303
- // This test reproduces the diabetes trial enrollment pattern
299
+ it("should handle eligibility pattern with boolean auto-initialization", () => {
300
+ // With boolean auto-initialization to false, the eligibility pattern changes:
301
+ // - All booleans start as false
302
+ // - "field = true or field = false" is immediately true (since false = false is true)
303
+ // - Eligibility is determined immediately since all booleans have values
304
304
  const spec = createTestSpec({
305
305
  fields: {
306
306
  // Inclusion criteria (all must be true to be eligible)
@@ -313,15 +313,13 @@ describe("page navigation with conditional visibility", () => {
313
313
  consent: { type: "boolean", label: "I consent" },
314
314
  },
315
315
  computed: {
316
- // Check if all inclusion answered
316
+ // With auto-initialization, these patterns always return true
317
317
  allInclusionAnswered: {
318
318
  expression: "ageOk != null and diagnosisOk != null",
319
319
  },
320
- // Check if all exclusion answered
321
320
  allExclusionAnswered: {
322
321
  expression: "pregnant != null and allergy != null",
323
322
  },
324
- // Eligibility determined when all criteria answered
325
323
  eligibilityDetermined: {
326
324
  expression:
327
325
  "computed.allInclusionAnswered = true and computed.allExclusionAnswered = true",
@@ -354,11 +352,26 @@ describe("page navigation with conditional visibility", () => {
354
352
 
355
353
  const { result } = renderHook(() => useForma({ spec }));
356
354
 
357
- // Initially, all computed values are null due to undefined fields
355
+ // All booleans start as false
356
+ expect(result.current.data.ageOk).toBe(false);
357
+ expect(result.current.data.diagnosisOk).toBe(false);
358
+ expect(result.current.data.pregnant).toBe(false);
359
+ expect(result.current.data.allergy).toBe(false);
360
+
358
361
  const wizard = result.current.wizard;
359
362
  expect(wizard?.pages[0].visible).toBe(true); // Inclusion always visible
360
363
  expect(wizard?.pages[1].visible).toBe(true); // Exclusion always visible
361
- expect(wizard?.pages[2].visible).toBe(false); // Consent hidden (computed.eligible is null)
364
+
365
+ // With auto-initialization:
366
+ // - eligibilityDetermined is true (all fields have values)
367
+ // - allInclusionMet is false (ageOk and diagnosisOk are false)
368
+ // - anyExclusionMet is false (pregnant and allergy are false)
369
+ // - eligible is false (allInclusionMet is false)
370
+ expect(result.current.computed?.eligibilityDetermined).toBe(true);
371
+ expect(result.current.computed?.allInclusionMet).toBe(false);
372
+ expect(result.current.computed?.anyExclusionMet).toBe(false);
373
+ expect(result.current.computed?.eligible).toBe(false);
374
+ expect(wizard?.pages[2].visible).toBe(false); // Consent hidden - not eligible yet
362
375
 
363
376
  // Fill inclusion criteria (both true)
364
377
  act(() => {
@@ -366,17 +379,7 @@ describe("page navigation with conditional visibility", () => {
366
379
  result.current.setFieldValue("diagnosisOk", true);
367
380
  });
368
381
 
369
- // Still not eligible - exclusion not answered yet
370
- expect(result.current.wizard?.pages[2].visible).toBe(false);
371
-
372
- // Fill exclusion criteria (both false - no exclusions met)
373
- act(() => {
374
- result.current.setFieldValue("pregnant", false);
375
- result.current.setFieldValue("allergy", false);
376
- });
377
-
378
- // Now eligible - consent page should be visible
379
- expect(result.current.computed?.eligibilityDetermined).toBe(true);
382
+ // Now eligible - exclusion defaults are already false (no exclusions met)
380
383
  expect(result.current.computed?.allInclusionMet).toBe(true);
381
384
  expect(result.current.computed?.anyExclusionMet).toBe(false);
382
385
  expect(result.current.computed?.eligible).toBe(true);
@@ -1172,6 +1172,135 @@ describe("useForma", () => {
1172
1172
  });
1173
1173
  });
1174
1174
 
1175
+ // ============================================================================
1176
+ // Boolean Field Handling
1177
+ // ============================================================================
1178
+
1179
+ describe("boolean field handling", () => {
1180
+ it("should auto-initialize boolean fields to false", () => {
1181
+ const spec = createTestSpec({
1182
+ fields: {
1183
+ acceptTerms: { type: "boolean" },
1184
+ name: { type: "text" },
1185
+ },
1186
+ });
1187
+
1188
+ const { result } = renderHook(() => useForma({ spec }));
1189
+
1190
+ expect(result.current.data.acceptTerms).toBe(false);
1191
+ expect(result.current.data.name).toBeUndefined();
1192
+ });
1193
+
1194
+ it("should respect explicit initialData for booleans", () => {
1195
+ const spec = createTestSpec({
1196
+ fields: { acceptTerms: { type: "boolean" } },
1197
+ });
1198
+
1199
+ const { result } = renderHook(() =>
1200
+ useForma({ spec, initialData: { acceptTerms: true } })
1201
+ );
1202
+
1203
+ expect(result.current.data.acceptTerms).toBe(true);
1204
+ });
1205
+
1206
+ it("should set showRequiredIndicator=false for required boolean fields without validation (binary question)", () => {
1207
+ // Binary question pattern: "Do you smoke?" - false is a valid answer
1208
+ const spec = createTestSpec({
1209
+ fields: {
1210
+ isSmoker: { type: "boolean", label: "Do you smoke?", required: true },
1211
+ },
1212
+ });
1213
+
1214
+ const { result } = renderHook(() => useForma({ spec }));
1215
+ const props = result.current.getFieldProps("isSmoker");
1216
+
1217
+ expect(props.required).toBe(true);
1218
+ expect(props.showRequiredIndicator).toBe(false); // No asterisk for binary questions
1219
+ });
1220
+
1221
+ it("should set showRequiredIndicator=true for required boolean fields with validation (consent pattern)", () => {
1222
+ // Consent pattern: "I accept terms" - must explicitly check the box
1223
+ const spec = createTestSpec({
1224
+ fields: {
1225
+ acceptTerms: {
1226
+ type: "boolean",
1227
+ label: "I accept the terms",
1228
+ required: true,
1229
+ validations: [{ rule: "value = true", message: "You must accept the terms" }],
1230
+ },
1231
+ },
1232
+ });
1233
+
1234
+ const { result } = renderHook(() => useForma({ spec }));
1235
+ const props = result.current.getFieldProps("acceptTerms");
1236
+
1237
+ expect(props.required).toBe(true);
1238
+ expect(props.showRequiredIndicator).toBe(true); // Show asterisk for consent checkboxes
1239
+ });
1240
+
1241
+ it("should set showRequiredIndicator=true for required non-boolean fields", () => {
1242
+ const spec = createTestSpec({
1243
+ fields: {
1244
+ name: { type: "text", required: true },
1245
+ },
1246
+ });
1247
+
1248
+ const { result } = renderHook(() => useForma({ spec }));
1249
+ const props = result.current.getFieldProps("name");
1250
+
1251
+ expect(props.required).toBe(true);
1252
+ expect(props.showRequiredIndicator).toBe(true);
1253
+ });
1254
+
1255
+ it("should set showRequiredIndicator=false for non-required fields", () => {
1256
+ const spec = createTestSpec({
1257
+ fields: {
1258
+ name: { type: "text" },
1259
+ },
1260
+ });
1261
+
1262
+ const { result } = renderHook(() => useForma({ spec }));
1263
+ const props = result.current.getFieldProps("name");
1264
+
1265
+ expect(props.required).toBe(false);
1266
+ expect(props.showRequiredIndicator).toBe(false);
1267
+ });
1268
+
1269
+ it("should initialize multiple boolean fields to false", () => {
1270
+ const spec = createTestSpec({
1271
+ fields: {
1272
+ hasInsurance: { type: "boolean" },
1273
+ isSmoker: { type: "boolean" },
1274
+ hasAllergies: { type: "boolean" },
1275
+ name: { type: "text" },
1276
+ },
1277
+ });
1278
+
1279
+ const { result } = renderHook(() => useForma({ spec }));
1280
+
1281
+ expect(result.current.data.hasInsurance).toBe(false);
1282
+ expect(result.current.data.isSmoker).toBe(false);
1283
+ expect(result.current.data.hasAllergies).toBe(false);
1284
+ expect(result.current.data.name).toBeUndefined();
1285
+ });
1286
+
1287
+ it("should pass validation for required boolean field with false value", () => {
1288
+ const spec = createTestSpec({
1289
+ fields: {
1290
+ acceptTerms: { type: "boolean", required: true },
1291
+ },
1292
+ });
1293
+
1294
+ const { result } = renderHook(() => useForma({ spec }));
1295
+
1296
+ // Boolean field is auto-initialized to false
1297
+ expect(result.current.data.acceptTerms).toBe(false);
1298
+
1299
+ // Form should be valid since false is a valid present value
1300
+ expect(result.current.isValid).toBe(true);
1301
+ });
1302
+ });
1303
+
1175
1304
  // ============================================================================
1176
1305
  // validateForm and validateField
1177
1306
  // ============================================================================