@fogpipe/forma-react 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fogpipe/forma-react",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Headless React form renderer for Forma specifications",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -30,7 +30,7 @@
30
30
  "test:coverage": "vitest run --coverage"
31
31
  },
32
32
  "dependencies": {
33
- "@fogpipe/forma-core": "^0.8.2"
33
+ "@fogpipe/forma-core": "^0.9.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "react": "^18.0.0 || ^19.0.0"
@@ -0,0 +1,776 @@
1
+ /**
2
+ * Tests for the diabetes clinical trial enrollment form wizard flow
3
+ *
4
+ * This test suite documents expected wizard behavior for a complex multi-page
5
+ * form with conditional page visibility based on computed eligibility fields.
6
+ *
7
+ * The form has:
8
+ * - 10 pages with conditional visibility
9
+ * - Complex computed field dependency chains for eligibility determination
10
+ * - Conditional signature flows (participant vs LAR, optional witness)
11
+ */
12
+
13
+ import { describe, it, expect } from "vitest";
14
+ import { renderHook, act } from "@testing-library/react";
15
+ import { useForma } from "../useForma.js";
16
+ import type { Forma } from "@fogpipe/forma-core";
17
+
18
+ // Simplified version of the diabetes trial spec for focused testing
19
+ function createDiabetesTrialSpec(): Forma {
20
+ return {
21
+ version: "1.0",
22
+ meta: {
23
+ id: "diabetes-trial-enrollment",
24
+ title: "Diabetes Medication Clinical Trial Enrollment Form",
25
+ },
26
+ schema: {
27
+ type: "object",
28
+ properties: {
29
+ // Study info (pre-filled)
30
+ studyName: { type: "string" },
31
+ protocolNumber: { type: "string" },
32
+ // Participant info
33
+ screeningId: { type: "string" },
34
+ participantInitials: { type: "string" },
35
+ dateOfBirth: { type: "string", format: "date" },
36
+ // Inclusion criteria
37
+ inclusionAge: { type: "boolean" },
38
+ inclusionDiagnosis: { type: "boolean" },
39
+ inclusionVisits: { type: "boolean" },
40
+ inclusionConsent: { type: "boolean" },
41
+ // Exclusion criteria
42
+ exclusionPregnant: { type: "boolean" },
43
+ exclusionAllergy: { type: "boolean" },
44
+ exclusionRecentStudy: { type: "boolean" },
45
+ exclusionKidney: { type: "boolean" },
46
+ exclusionSglt2: { type: "boolean" },
47
+ // Ineligibility
48
+ ineligibilityReason: { type: "string" },
49
+ ineligibilityNotes: { type: "string" },
50
+ // Consents
51
+ consentPurpose: { type: "boolean" },
52
+ consentProcedures: { type: "boolean" },
53
+ consentRisks: { type: "boolean" },
54
+ consentBenefits: { type: "boolean" },
55
+ consentVoluntary: { type: "boolean" },
56
+ consentDataHandling: { type: "boolean" },
57
+ // Optional consents
58
+ optionalSamples: { type: "boolean" },
59
+ optionalContact: { type: "boolean" },
60
+ optionalGenetic: { type: "boolean" },
61
+ // Signatures
62
+ signingOnBehalf: { type: "boolean" },
63
+ participantSignatureName: { type: "string" },
64
+ participantSignatureDateTime: { type: "string" },
65
+ larName: { type: "string" },
66
+ larRelationship: { type: "string" },
67
+ larSignatureDateTime: { type: "string" },
68
+ researcherName: { type: "string" },
69
+ researcherConfirm: { type: "boolean" },
70
+ researcherSignatureDateTime: { type: "string" },
71
+ witnessRequired: { type: "boolean" },
72
+ witnessName: { type: "string" },
73
+ witnessSignatureDateTime: { type: "string" },
74
+ // Enrollment
75
+ enrollmentDate: { type: "string" },
76
+ randomizationNumber: { type: "string" },
77
+ treatmentGroup: { type: "string" },
78
+ },
79
+ },
80
+ fields: {
81
+ studyName: { type: "text", label: "Study Name", enabledWhen: "false" },
82
+ protocolNumber: { type: "text", label: "Protocol Number", enabledWhen: "false" },
83
+ screeningId: { type: "text", label: "Screening ID" },
84
+ participantInitials: { type: "text", label: "Participant Initials" },
85
+ dateOfBirth: { type: "date", label: "Date of Birth" },
86
+ inclusionAge: { type: "boolean", label: "Age between 18-65 years" },
87
+ inclusionDiagnosis: { type: "boolean", label: "Confirmed Type 2 diabetes diagnosis" },
88
+ inclusionVisits: { type: "boolean", label: "Willing to attend all study visits" },
89
+ inclusionConsent: { type: "boolean", label: "Can give informed consent" },
90
+ exclusionPregnant: { type: "boolean", label: "Pregnant or planning to become pregnant" },
91
+ exclusionAllergy: { type: "boolean", label: "Allergy to metformin or similar drugs" },
92
+ exclusionRecentStudy: { type: "boolean", label: "Participated in another drug study in last 30 days" },
93
+ exclusionKidney: { type: "boolean", label: "History of severe kidney disease" },
94
+ exclusionSglt2: { type: "boolean", label: "Currently taking SGLT2 inhibitors" },
95
+ ineligibilityReason: { type: "textarea", label: "Ineligibility Reason", enabledWhen: "false" },
96
+ ineligibilityNotes: { type: "textarea", label: "Additional Notes" },
97
+ consentPurpose: { type: "boolean", label: "I have read and understand the study purpose" },
98
+ consentProcedures: { type: "boolean", label: "I understand the procedures I will undergo" },
99
+ consentRisks: { type: "boolean", label: "I understand the risks involved" },
100
+ consentBenefits: { type: "boolean", label: "I understand the potential benefits" },
101
+ consentVoluntary: { type: "boolean", label: "I understand participation is voluntary" },
102
+ consentDataHandling: { type: "boolean", label: "I understand how my data will be handled" },
103
+ optionalSamples: { type: "boolean", label: "I consent to storing blood samples" },
104
+ optionalContact: { type: "boolean", label: "I consent to being contacted about other studies" },
105
+ optionalGenetic: { type: "boolean", label: "I consent to genetic analysis" },
106
+ signingOnBehalf: { type: "boolean", label: "Is someone signing on behalf of the participant?" },
107
+ participantSignatureName: {
108
+ type: "text",
109
+ label: "Participant Signature Name",
110
+ visibleWhen: "signingOnBehalf = false",
111
+ requiredWhen: "signingOnBehalf = false",
112
+ },
113
+ participantSignatureDateTime: {
114
+ type: "datetime",
115
+ label: "Participant Signature Date and Time",
116
+ visibleWhen: "signingOnBehalf = false",
117
+ requiredWhen: "signingOnBehalf = false",
118
+ },
119
+ larName: {
120
+ type: "text",
121
+ label: "Legally Authorized Representative Name",
122
+ visibleWhen: "signingOnBehalf = true",
123
+ requiredWhen: "signingOnBehalf = true",
124
+ },
125
+ larRelationship: {
126
+ type: "text",
127
+ label: "Relationship to Participant",
128
+ visibleWhen: "signingOnBehalf = true",
129
+ requiredWhen: "signingOnBehalf = true",
130
+ },
131
+ larSignatureDateTime: {
132
+ type: "datetime",
133
+ label: "Representative Signature Date and Time",
134
+ visibleWhen: "signingOnBehalf = true",
135
+ requiredWhen: "signingOnBehalf = true",
136
+ },
137
+ researcherName: { type: "text", label: "Researcher Name" },
138
+ researcherConfirm: { type: "boolean", label: "I confirm I have properly explained the study" },
139
+ researcherSignatureDateTime: { type: "datetime", label: "Researcher Signature Date and Time" },
140
+ witnessRequired: { type: "boolean", label: "Is a witness signature required?" },
141
+ witnessName: {
142
+ type: "text",
143
+ label: "Witness Name",
144
+ visibleWhen: "witnessRequired = true",
145
+ requiredWhen: "witnessRequired = true",
146
+ },
147
+ witnessSignatureDateTime: {
148
+ type: "datetime",
149
+ label: "Witness Signature Date and Time",
150
+ visibleWhen: "witnessRequired = true",
151
+ requiredWhen: "witnessRequired = true",
152
+ },
153
+ enrollmentDate: { type: "date", label: "Enrollment Date" },
154
+ randomizationNumber: { type: "text", label: "Randomization Number" },
155
+ treatmentGroup: {
156
+ type: "select",
157
+ label: "Treatment Group",
158
+ options: [
159
+ { value: "group-a", label: "Group A - Active Treatment" },
160
+ { value: "group-b", label: "Group B - Placebo" },
161
+ { value: "group-c", label: "Group C - Active Comparator" },
162
+ ],
163
+ },
164
+ },
165
+ fieldOrder: [
166
+ "studyName", "protocolNumber",
167
+ "screeningId", "participantInitials", "dateOfBirth",
168
+ "inclusionAge", "inclusionDiagnosis", "inclusionVisits", "inclusionConsent",
169
+ "exclusionPregnant", "exclusionAllergy", "exclusionRecentStudy", "exclusionKidney", "exclusionSglt2",
170
+ "ineligibilityReason", "ineligibilityNotes",
171
+ "consentPurpose", "consentProcedures", "consentRisks", "consentBenefits", "consentVoluntary", "consentDataHandling",
172
+ "optionalSamples", "optionalContact", "optionalGenetic",
173
+ "signingOnBehalf", "participantSignatureName", "participantSignatureDateTime",
174
+ "larName", "larRelationship", "larSignatureDateTime",
175
+ "researcherName", "researcherConfirm", "researcherSignatureDateTime",
176
+ "witnessRequired", "witnessName", "witnessSignatureDateTime",
177
+ "enrollmentDate", "randomizationNumber", "treatmentGroup",
178
+ ],
179
+ computed: {
180
+ // Inclusion checks
181
+ allInclusionAnswered: {
182
+ expression: "(inclusionAge = true or inclusionAge = false) and (inclusionDiagnosis = true or inclusionDiagnosis = false) and (inclusionVisits = true or inclusionVisits = false) and (inclusionConsent = true or inclusionConsent = false)",
183
+ },
184
+ allInclusionMet: {
185
+ expression: "inclusionAge = true and inclusionDiagnosis = true and inclusionVisits = true and inclusionConsent = true",
186
+ },
187
+ // Exclusion checks
188
+ allExclusionAnswered: {
189
+ expression: "(exclusionPregnant = true or exclusionPregnant = false) and (exclusionAllergy = true or exclusionAllergy = false) and (exclusionRecentStudy = true or exclusionRecentStudy = false) and (exclusionKidney = true or exclusionKidney = false) and (exclusionSglt2 = true or exclusionSglt2 = false)",
190
+ },
191
+ anyExclusionMet: {
192
+ expression: "exclusionPregnant = true or exclusionAllergy = true or exclusionRecentStudy = true or exclusionKidney = true or exclusionSglt2 = true",
193
+ },
194
+ // Eligibility determination
195
+ eligibilityDetermined: {
196
+ expression: "computed.allInclusionAnswered = true and computed.allExclusionAnswered = true",
197
+ },
198
+ eligible: {
199
+ expression: "computed.eligibilityDetermined = true and computed.allInclusionMet = true and computed.anyExclusionMet = false",
200
+ },
201
+ ineligible: {
202
+ expression: "computed.eligibilityDetermined = true and (computed.allInclusionMet = false or computed.anyExclusionMet = true)",
203
+ },
204
+ // Consent checks
205
+ allMainConsentsSigned: {
206
+ expression: "consentPurpose = true and consentProcedures = true and consentRisks = true and consentBenefits = true and consentVoluntary = true and consentDataHandling = true",
207
+ },
208
+ // Signature checks - using null-safe patterns
209
+ hasParticipantSignature: {
210
+ expression: "signingOnBehalf != true and participantSignatureName != null and participantSignatureDateTime != null",
211
+ },
212
+ hasLarSignature: {
213
+ expression: "signingOnBehalf = true and larName != null and larRelationship != null and larSignatureDateTime != null",
214
+ },
215
+ hasValidSignature: {
216
+ expression: "computed.hasParticipantSignature = true or computed.hasLarSignature = true",
217
+ },
218
+ hasResearcherSignature: {
219
+ expression: "researcherName != null and researcherConfirm = true and researcherSignatureDateTime != null",
220
+ },
221
+ hasWitnessSignature: {
222
+ expression: "witnessRequired != true or (witnessName != null and witnessSignatureDateTime != null)",
223
+ },
224
+ allSignaturesComplete: {
225
+ expression: "computed.hasValidSignature = true and computed.hasResearcherSignature = true and computed.hasWitnessSignature = true",
226
+ },
227
+ // Final enrollment gate
228
+ canEnroll: {
229
+ expression: "computed.eligible = true and computed.allMainConsentsSigned = true and computed.allSignaturesComplete = true",
230
+ },
231
+ },
232
+ pages: [
233
+ {
234
+ id: "study-information",
235
+ title: "Study Information",
236
+ fields: ["studyName", "protocolNumber"],
237
+ },
238
+ {
239
+ id: "participant-information",
240
+ title: "Participant Information",
241
+ fields: ["screeningId", "participantInitials", "dateOfBirth"],
242
+ },
243
+ {
244
+ id: "inclusion-criteria",
245
+ title: "Eligibility Screening - Inclusion Criteria",
246
+ fields: ["inclusionAge", "inclusionDiagnosis", "inclusionVisits", "inclusionConsent"],
247
+ },
248
+ {
249
+ id: "exclusion-criteria",
250
+ title: "Eligibility Screening - Exclusion Criteria",
251
+ fields: ["exclusionPregnant", "exclusionAllergy", "exclusionRecentStudy", "exclusionKidney", "exclusionSglt2"],
252
+ },
253
+ {
254
+ id: "ineligibility-documentation",
255
+ title: "Ineligibility Documentation",
256
+ fields: ["ineligibilityReason", "ineligibilityNotes"],
257
+ visibleWhen: "computed.ineligible = true",
258
+ },
259
+ {
260
+ id: "main-consents",
261
+ title: "Informed Consent - Main Consents",
262
+ fields: ["consentPurpose", "consentProcedures", "consentRisks", "consentBenefits", "consentVoluntary", "consentDataHandling"],
263
+ visibleWhen: "computed.eligible = true",
264
+ },
265
+ {
266
+ id: "optional-consents",
267
+ title: "Optional Consents",
268
+ fields: ["optionalSamples", "optionalContact", "optionalGenetic"],
269
+ visibleWhen: "computed.eligible = true",
270
+ },
271
+ {
272
+ id: "participant-signature",
273
+ title: "Participant Signature",
274
+ fields: ["signingOnBehalf", "participantSignatureName", "participantSignatureDateTime", "larName", "larRelationship", "larSignatureDateTime"],
275
+ visibleWhen: "computed.eligible = true and computed.allMainConsentsSigned = true",
276
+ },
277
+ {
278
+ id: "researcher-witness-signatures",
279
+ title: "Researcher and Witness Signatures",
280
+ fields: ["researcherName", "researcherConfirm", "researcherSignatureDateTime", "witnessRequired", "witnessName", "witnessSignatureDateTime"],
281
+ visibleWhen: "computed.eligible = true and computed.allMainConsentsSigned = true and computed.hasValidSignature = true",
282
+ },
283
+ {
284
+ id: "enrollment-details",
285
+ title: "Enrollment Details",
286
+ fields: ["enrollmentDate", "randomizationNumber", "treatmentGroup"],
287
+ visibleWhen: "computed.canEnroll = true",
288
+ },
289
+ ],
290
+ };
291
+ }
292
+
293
+ describe("diabetes trial enrollment wizard", () => {
294
+ describe("initial page visibility", () => {
295
+ it("should show first 4 pages unconditionally", () => {
296
+ const spec = createDiabetesTrialSpec();
297
+ const { result } = renderHook(() => useForma({ spec }));
298
+
299
+ const pages = result.current.wizard?.pages;
300
+ expect(pages).toHaveLength(10);
301
+
302
+ // First 4 pages always visible
303
+ expect(pages?.[0].id).toBe("study-information");
304
+ expect(pages?.[0].visible).toBe(true);
305
+
306
+ expect(pages?.[1].id).toBe("participant-information");
307
+ expect(pages?.[1].visible).toBe(true);
308
+
309
+ expect(pages?.[2].id).toBe("inclusion-criteria");
310
+ expect(pages?.[2].visible).toBe(true);
311
+
312
+ expect(pages?.[3].id).toBe("exclusion-criteria");
313
+ expect(pages?.[3].visible).toBe(true);
314
+ });
315
+
316
+ it("should hide conditional pages initially", () => {
317
+ const spec = createDiabetesTrialSpec();
318
+ const { result } = renderHook(() => useForma({ spec }));
319
+
320
+ const pages = result.current.wizard?.pages;
321
+
322
+ // Conditional pages hidden initially
323
+ expect(pages?.[4].id).toBe("ineligibility-documentation");
324
+ expect(pages?.[4].visible).toBe(false);
325
+
326
+ expect(pages?.[5].id).toBe("main-consents");
327
+ expect(pages?.[5].visible).toBe(false);
328
+
329
+ expect(pages?.[6].id).toBe("optional-consents");
330
+ expect(pages?.[6].visible).toBe(false);
331
+
332
+ expect(pages?.[7].id).toBe("participant-signature");
333
+ expect(pages?.[7].visible).toBe(false);
334
+
335
+ expect(pages?.[8].id).toBe("researcher-witness-signatures");
336
+ expect(pages?.[8].visible).toBe(false);
337
+
338
+ expect(pages?.[9].id).toBe("enrollment-details");
339
+ expect(pages?.[9].visible).toBe(false);
340
+ });
341
+ });
342
+
343
+ describe("eligibility determination", () => {
344
+ it("should determine eligibility only after all criteria answered", () => {
345
+ const spec = createDiabetesTrialSpec();
346
+ const { result } = renderHook(() => useForma({ spec }));
347
+
348
+ // Initially not determined
349
+ expect(result.current.computed?.eligibilityDetermined).toBeFalsy();
350
+
351
+ // Fill inclusion criteria
352
+ act(() => {
353
+ result.current.setFieldValue("inclusionAge", true);
354
+ result.current.setFieldValue("inclusionDiagnosis", true);
355
+ result.current.setFieldValue("inclusionVisits", true);
356
+ result.current.setFieldValue("inclusionConsent", true);
357
+ });
358
+
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
372
+ expect(result.current.computed?.eligibilityDetermined).toBe(true);
373
+ });
374
+
375
+ it("should mark eligible when all inclusion met and no exclusion met", () => {
376
+ const spec = createDiabetesTrialSpec();
377
+ const { result } = renderHook(() => useForma({ spec }));
378
+
379
+ // Fill all criteria - eligible scenario
380
+ act(() => {
381
+ // All inclusion = true
382
+ result.current.setFieldValue("inclusionAge", true);
383
+ result.current.setFieldValue("inclusionDiagnosis", true);
384
+ result.current.setFieldValue("inclusionVisits", true);
385
+ result.current.setFieldValue("inclusionConsent", true);
386
+ // All exclusion = false
387
+ result.current.setFieldValue("exclusionPregnant", false);
388
+ result.current.setFieldValue("exclusionAllergy", false);
389
+ result.current.setFieldValue("exclusionRecentStudy", false);
390
+ result.current.setFieldValue("exclusionKidney", false);
391
+ result.current.setFieldValue("exclusionSglt2", false);
392
+ });
393
+
394
+ expect(result.current.computed?.eligible).toBe(true);
395
+ expect(result.current.computed?.ineligible).toBe(false);
396
+ });
397
+
398
+ it("should mark ineligible when any inclusion criterion is false", () => {
399
+ const spec = createDiabetesTrialSpec();
400
+ const { result } = renderHook(() => useForma({ spec }));
401
+
402
+ act(() => {
403
+ // One inclusion = false
404
+ result.current.setFieldValue("inclusionAge", false);
405
+ result.current.setFieldValue("inclusionDiagnosis", true);
406
+ result.current.setFieldValue("inclusionVisits", true);
407
+ result.current.setFieldValue("inclusionConsent", true);
408
+ // All exclusion = false
409
+ result.current.setFieldValue("exclusionPregnant", false);
410
+ result.current.setFieldValue("exclusionAllergy", false);
411
+ result.current.setFieldValue("exclusionRecentStudy", false);
412
+ result.current.setFieldValue("exclusionKidney", false);
413
+ result.current.setFieldValue("exclusionSglt2", false);
414
+ });
415
+
416
+ expect(result.current.computed?.eligible).toBe(false);
417
+ expect(result.current.computed?.ineligible).toBe(true);
418
+ });
419
+
420
+ it("should mark ineligible when any exclusion criterion is true", () => {
421
+ const spec = createDiabetesTrialSpec();
422
+ const { result } = renderHook(() => useForma({ spec }));
423
+
424
+ act(() => {
425
+ // All inclusion = true
426
+ result.current.setFieldValue("inclusionAge", true);
427
+ result.current.setFieldValue("inclusionDiagnosis", true);
428
+ result.current.setFieldValue("inclusionVisits", true);
429
+ result.current.setFieldValue("inclusionConsent", true);
430
+ // One exclusion = true (pregnant)
431
+ result.current.setFieldValue("exclusionPregnant", true);
432
+ result.current.setFieldValue("exclusionAllergy", false);
433
+ result.current.setFieldValue("exclusionRecentStudy", false);
434
+ result.current.setFieldValue("exclusionKidney", false);
435
+ result.current.setFieldValue("exclusionSglt2", false);
436
+ });
437
+
438
+ expect(result.current.computed?.eligible).toBe(false);
439
+ expect(result.current.computed?.ineligible).toBe(true);
440
+ });
441
+ });
442
+
443
+ describe("eligible flow - page visibility", () => {
444
+ function setupEligibleParticipant(setFieldValue: (field: string, value: unknown) => void) {
445
+ // All inclusion = true
446
+ setFieldValue("inclusionAge", true);
447
+ setFieldValue("inclusionDiagnosis", true);
448
+ setFieldValue("inclusionVisits", true);
449
+ setFieldValue("inclusionConsent", true);
450
+ // All exclusion = false
451
+ setFieldValue("exclusionPregnant", false);
452
+ setFieldValue("exclusionAllergy", false);
453
+ setFieldValue("exclusionRecentStudy", false);
454
+ setFieldValue("exclusionKidney", false);
455
+ setFieldValue("exclusionSglt2", false);
456
+ }
457
+
458
+ it("should show consent pages after eligibility confirmed", () => {
459
+ const spec = createDiabetesTrialSpec();
460
+ const { result } = renderHook(() => useForma({ spec }));
461
+
462
+ act(() => {
463
+ setupEligibleParticipant(result.current.setFieldValue);
464
+ });
465
+
466
+ const pages = result.current.wizard?.pages;
467
+ expect(pages?.[5].id).toBe("main-consents");
468
+ expect(pages?.[5].visible).toBe(true);
469
+
470
+ expect(pages?.[6].id).toBe("optional-consents");
471
+ expect(pages?.[6].visible).toBe(true);
472
+ });
473
+
474
+ it("should show participant signature page after consents signed", () => {
475
+ const spec = createDiabetesTrialSpec();
476
+ const { result } = renderHook(() => useForma({ spec }));
477
+
478
+ act(() => {
479
+ setupEligibleParticipant(result.current.setFieldValue);
480
+ });
481
+
482
+ // Signature page not yet visible
483
+ expect(result.current.wizard?.pages?.[7].visible).toBe(false);
484
+
485
+ // Sign all main consents
486
+ act(() => {
487
+ result.current.setFieldValue("consentPurpose", true);
488
+ result.current.setFieldValue("consentProcedures", true);
489
+ result.current.setFieldValue("consentRisks", true);
490
+ result.current.setFieldValue("consentBenefits", true);
491
+ result.current.setFieldValue("consentVoluntary", true);
492
+ result.current.setFieldValue("consentDataHandling", true);
493
+ });
494
+
495
+ expect(result.current.computed?.allMainConsentsSigned).toBe(true);
496
+ expect(result.current.wizard?.pages?.[7].visible).toBe(true);
497
+ });
498
+
499
+ it("should show researcher signature page after valid participant signature", () => {
500
+ const spec = createDiabetesTrialSpec();
501
+ const { result } = renderHook(() => useForma({ spec }));
502
+
503
+ act(() => {
504
+ setupEligibleParticipant(result.current.setFieldValue);
505
+ // Sign consents
506
+ result.current.setFieldValue("consentPurpose", true);
507
+ result.current.setFieldValue("consentProcedures", true);
508
+ result.current.setFieldValue("consentRisks", true);
509
+ result.current.setFieldValue("consentBenefits", true);
510
+ result.current.setFieldValue("consentVoluntary", true);
511
+ result.current.setFieldValue("consentDataHandling", true);
512
+ });
513
+
514
+ // Researcher signature page not yet visible
515
+ expect(result.current.wizard?.pages?.[8].visible).toBe(false);
516
+
517
+ // Provide participant signature (not signing on behalf)
518
+ act(() => {
519
+ result.current.setFieldValue("signingOnBehalf", false);
520
+ result.current.setFieldValue("participantSignatureName", "John Doe");
521
+ result.current.setFieldValue("participantSignatureDateTime", "2024-01-15T10:00:00");
522
+ });
523
+
524
+ expect(result.current.computed?.hasValidSignature).toBe(true);
525
+ expect(result.current.wizard?.pages?.[8].visible).toBe(true);
526
+ });
527
+
528
+ it("should show enrollment page only after all signatures complete", () => {
529
+ const spec = createDiabetesTrialSpec();
530
+ const { result } = renderHook(() => useForma({ spec }));
531
+
532
+ act(() => {
533
+ setupEligibleParticipant(result.current.setFieldValue);
534
+ // Sign consents
535
+ result.current.setFieldValue("consentPurpose", true);
536
+ result.current.setFieldValue("consentProcedures", true);
537
+ result.current.setFieldValue("consentRisks", true);
538
+ result.current.setFieldValue("consentBenefits", true);
539
+ result.current.setFieldValue("consentVoluntary", true);
540
+ result.current.setFieldValue("consentDataHandling", true);
541
+ // Participant signature
542
+ result.current.setFieldValue("signingOnBehalf", false);
543
+ result.current.setFieldValue("participantSignatureName", "John Doe");
544
+ result.current.setFieldValue("participantSignatureDateTime", "2024-01-15T10:00:00");
545
+ });
546
+
547
+ // Enrollment page not yet visible
548
+ expect(result.current.wizard?.pages?.[9].visible).toBe(false);
549
+
550
+ // Add researcher signature
551
+ act(() => {
552
+ result.current.setFieldValue("researcherName", "Dr. Smith");
553
+ result.current.setFieldValue("researcherConfirm", true);
554
+ result.current.setFieldValue("researcherSignatureDateTime", "2024-01-15T10:30:00");
555
+ result.current.setFieldValue("witnessRequired", false);
556
+ });
557
+
558
+ expect(result.current.computed?.canEnroll).toBe(true);
559
+ expect(result.current.wizard?.pages?.[9].visible).toBe(true);
560
+ });
561
+ });
562
+
563
+ describe("ineligible flow - page visibility", () => {
564
+ it("should show ineligibility documentation when participant is ineligible", () => {
565
+ const spec = createDiabetesTrialSpec();
566
+ const { result } = renderHook(() => useForma({ spec }));
567
+
568
+ act(() => {
569
+ // One inclusion = false (not eligible)
570
+ result.current.setFieldValue("inclusionAge", false);
571
+ result.current.setFieldValue("inclusionDiagnosis", true);
572
+ result.current.setFieldValue("inclusionVisits", true);
573
+ result.current.setFieldValue("inclusionConsent", true);
574
+ // All exclusion answered
575
+ result.current.setFieldValue("exclusionPregnant", false);
576
+ result.current.setFieldValue("exclusionAllergy", false);
577
+ result.current.setFieldValue("exclusionRecentStudy", false);
578
+ result.current.setFieldValue("exclusionKidney", false);
579
+ result.current.setFieldValue("exclusionSglt2", false);
580
+ });
581
+
582
+ const pages = result.current.wizard?.pages;
583
+
584
+ // Ineligibility page visible
585
+ expect(pages?.[4].id).toBe("ineligibility-documentation");
586
+ expect(pages?.[4].visible).toBe(true);
587
+
588
+ // Consent pages hidden
589
+ expect(pages?.[5].visible).toBe(false);
590
+ expect(pages?.[6].visible).toBe(false);
591
+ });
592
+
593
+ it("should hide eligible-only pages when ineligible", () => {
594
+ const spec = createDiabetesTrialSpec();
595
+ const { result } = renderHook(() => useForma({ spec }));
596
+
597
+ act(() => {
598
+ // All inclusion = true
599
+ result.current.setFieldValue("inclusionAge", true);
600
+ result.current.setFieldValue("inclusionDiagnosis", true);
601
+ result.current.setFieldValue("inclusionVisits", true);
602
+ result.current.setFieldValue("inclusionConsent", true);
603
+ // One exclusion = true (pregnant - ineligible)
604
+ result.current.setFieldValue("exclusionPregnant", true);
605
+ result.current.setFieldValue("exclusionAllergy", false);
606
+ result.current.setFieldValue("exclusionRecentStudy", false);
607
+ result.current.setFieldValue("exclusionKidney", false);
608
+ result.current.setFieldValue("exclusionSglt2", false);
609
+ });
610
+
611
+ const pages = result.current.wizard?.pages;
612
+
613
+ // Ineligibility visible
614
+ expect(pages?.[4].visible).toBe(true);
615
+
616
+ // All eligible-only pages hidden
617
+ expect(pages?.[5].visible).toBe(false); // main-consents
618
+ expect(pages?.[6].visible).toBe(false); // optional-consents
619
+ expect(pages?.[7].visible).toBe(false); // participant-signature
620
+ expect(pages?.[8].visible).toBe(false); // researcher-witness-signatures
621
+ expect(pages?.[9].visible).toBe(false); // enrollment-details
622
+ });
623
+ });
624
+
625
+ describe("signature flow variations", () => {
626
+ function setupReadyForSignature(setFieldValue: (field: string, value: unknown) => void) {
627
+ // Eligible
628
+ setFieldValue("inclusionAge", true);
629
+ setFieldValue("inclusionDiagnosis", true);
630
+ setFieldValue("inclusionVisits", true);
631
+ setFieldValue("inclusionConsent", true);
632
+ setFieldValue("exclusionPregnant", false);
633
+ setFieldValue("exclusionAllergy", false);
634
+ setFieldValue("exclusionRecentStudy", false);
635
+ setFieldValue("exclusionKidney", false);
636
+ setFieldValue("exclusionSglt2", false);
637
+ // Consents signed
638
+ setFieldValue("consentPurpose", true);
639
+ setFieldValue("consentProcedures", true);
640
+ setFieldValue("consentRisks", true);
641
+ setFieldValue("consentBenefits", true);
642
+ setFieldValue("consentVoluntary", true);
643
+ setFieldValue("consentDataHandling", true);
644
+ }
645
+
646
+ it("should show participant fields when not signing on behalf", () => {
647
+ const spec = createDiabetesTrialSpec();
648
+ const { result } = renderHook(() => useForma({ spec }));
649
+
650
+ act(() => {
651
+ setupReadyForSignature(result.current.setFieldValue);
652
+ result.current.setFieldValue("signingOnBehalf", false);
653
+ });
654
+
655
+ // Participant fields visible
656
+ expect(result.current.visibility.participantSignatureName).toBe(true);
657
+ expect(result.current.visibility.participantSignatureDateTime).toBe(true);
658
+
659
+ // LAR fields hidden
660
+ expect(result.current.visibility.larName).toBe(false);
661
+ expect(result.current.visibility.larRelationship).toBe(false);
662
+ expect(result.current.visibility.larSignatureDateTime).toBe(false);
663
+ });
664
+
665
+ it("should show LAR fields when signing on behalf", () => {
666
+ const spec = createDiabetesTrialSpec();
667
+ const { result } = renderHook(() => useForma({ spec }));
668
+
669
+ act(() => {
670
+ setupReadyForSignature(result.current.setFieldValue);
671
+ result.current.setFieldValue("signingOnBehalf", true);
672
+ });
673
+
674
+ // LAR fields visible
675
+ expect(result.current.visibility.larName).toBe(true);
676
+ expect(result.current.visibility.larRelationship).toBe(true);
677
+ expect(result.current.visibility.larSignatureDateTime).toBe(true);
678
+
679
+ // Participant fields hidden
680
+ expect(result.current.visibility.participantSignatureName).toBe(false);
681
+ expect(result.current.visibility.participantSignatureDateTime).toBe(false);
682
+ });
683
+
684
+ it("should show witness fields when witness required", () => {
685
+ const spec = createDiabetesTrialSpec();
686
+ const { result } = renderHook(() => useForma({ spec }));
687
+
688
+ act(() => {
689
+ setupReadyForSignature(result.current.setFieldValue);
690
+ result.current.setFieldValue("witnessRequired", true);
691
+ });
692
+
693
+ expect(result.current.visibility.witnessName).toBe(true);
694
+ expect(result.current.visibility.witnessSignatureDateTime).toBe(true);
695
+ });
696
+
697
+ it("should hide witness fields when witness not required", () => {
698
+ const spec = createDiabetesTrialSpec();
699
+ const { result } = renderHook(() => useForma({ spec }));
700
+
701
+ act(() => {
702
+ setupReadyForSignature(result.current.setFieldValue);
703
+ result.current.setFieldValue("witnessRequired", false);
704
+ });
705
+
706
+ expect(result.current.visibility.witnessName).toBe(false);
707
+ expect(result.current.visibility.witnessSignatureDateTime).toBe(false);
708
+ });
709
+
710
+ it("should accept LAR signature as valid signature", () => {
711
+ const spec = createDiabetesTrialSpec();
712
+ const { result } = renderHook(() => useForma({ spec }));
713
+
714
+ act(() => {
715
+ setupReadyForSignature(result.current.setFieldValue);
716
+ result.current.setFieldValue("signingOnBehalf", true);
717
+ result.current.setFieldValue("larName", "Jane Doe");
718
+ result.current.setFieldValue("larRelationship", "Spouse");
719
+ result.current.setFieldValue("larSignatureDateTime", "2024-01-15T10:00:00");
720
+ });
721
+
722
+ expect(result.current.computed?.hasLarSignature).toBe(true);
723
+ expect(result.current.computed?.hasValidSignature).toBe(true);
724
+ });
725
+ });
726
+
727
+ describe("wizard navigation", () => {
728
+ it("should navigate through visible pages correctly", () => {
729
+ const spec = createDiabetesTrialSpec();
730
+ const { result } = renderHook(() => useForma({ spec }));
731
+
732
+ // Start on page 0
733
+ expect(result.current.wizard?.currentPageIndex).toBe(0);
734
+ expect(result.current.wizard?.currentPage?.id).toBe("study-information");
735
+
736
+ // Navigate forward
737
+ act(() => {
738
+ result.current.wizard?.nextPage();
739
+ });
740
+ expect(result.current.wizard?.currentPageIndex).toBe(1);
741
+ expect(result.current.wizard?.currentPage?.id).toBe("participant-information");
742
+
743
+ // Continue to inclusion
744
+ act(() => {
745
+ result.current.wizard?.nextPage();
746
+ });
747
+ expect(result.current.wizard?.currentPage?.id).toBe("inclusion-criteria");
748
+
749
+ // Continue to exclusion
750
+ act(() => {
751
+ result.current.wizard?.nextPage();
752
+ });
753
+ expect(result.current.wizard?.currentPage?.id).toBe("exclusion-criteria");
754
+ });
755
+
756
+ it("should skip hidden pages during navigation", () => {
757
+ const spec = createDiabetesTrialSpec();
758
+ const { result } = renderHook(() => useForma({ spec }));
759
+
760
+ // Go to exclusion page (index 3)
761
+ act(() => {
762
+ result.current.wizard?.goToPage(3);
763
+ });
764
+ expect(result.current.wizard?.currentPage?.id).toBe("exclusion-criteria");
765
+
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
769
+ const visiblePages = result.current.wizard?.pages.filter(p => p.visible);
770
+ expect(visiblePages?.length).toBe(4);
771
+
772
+ // Should be on last visible page
773
+ expect(result.current.wizard?.isLastPage).toBe(true);
774
+ });
775
+ });
776
+ });
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Tests documenting FEEL null handling behavior in visibility expressions
3
+ *
4
+ * These tests document the behavior where FEEL expressions that evaluate to `null`
5
+ * (due to undefined field references) are silently converted to `false` by
6
+ * the forma-core engine. This can cause unexpected behavior where pages and fields
7
+ * become invisible not because the condition is false, but because it couldn't
8
+ * be evaluated.
9
+ *
10
+ * Root cause: forma-core's evaluateBoolean() converts null to false without warning.
11
+ */
12
+
13
+ import { describe, it, expect } from "vitest";
14
+ import { renderHook, act } from "@testing-library/react";
15
+ import { useForma } from "../useForma.js";
16
+ import { createTestSpec } from "./test-utils.js";
17
+
18
+ describe("FEEL null handling in visibility expressions", () => {
19
+ describe("boolean field null checks", () => {
20
+ it("should treat undefined boolean as not matching '= true'", () => {
21
+ const spec = createTestSpec({
22
+ fields: {
23
+ accepted: { type: "boolean", label: "Accept terms" },
24
+ details: {
25
+ type: "text",
26
+ label: "Details",
27
+ visibleWhen: "accepted = true",
28
+ },
29
+ },
30
+ });
31
+
32
+ const { result } = renderHook(() => useForma({ spec }));
33
+
34
+ // Field is undefined initially
35
+ expect(result.current.data.accepted).toBeUndefined();
36
+
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
40
+ expect(result.current.visibility.details).toBe(false);
41
+
42
+ // When user explicitly sets to true, field becomes visible
43
+ act(() => {
44
+ result.current.setFieldValue("accepted", true);
45
+ });
46
+ expect(result.current.visibility.details).toBe(true);
47
+ });
48
+
49
+ it("should treat undefined boolean as not matching '= false'", () => {
50
+ const spec = createTestSpec({
51
+ fields: {
52
+ signingOnBehalf: { type: "boolean", label: "Signing on behalf?" },
53
+ participantName: {
54
+ type: "text",
55
+ label: "Participant Name",
56
+ visibleWhen: "signingOnBehalf = false",
57
+ },
58
+ },
59
+ });
60
+
61
+ const { result } = renderHook(() => useForma({ spec }));
62
+
63
+ // Field is undefined initially
64
+ expect(result.current.data.signingOnBehalf).toBeUndefined();
65
+
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);
70
+
71
+ // Only becomes visible when explicitly set to false
72
+ act(() => {
73
+ result.current.setFieldValue("signingOnBehalf", false);
74
+ });
75
+ expect(result.current.visibility.participantName).toBe(true);
76
+ });
77
+
78
+ it("should demonstrate the != null pattern also returns null on undefined", () => {
79
+ const spec = createTestSpec({
80
+ fields: {
81
+ accepted: { type: "boolean", label: "Accept" },
82
+ },
83
+ computed: {
84
+ hasAnswered: {
85
+ expression: "accepted != null",
86
+ },
87
+ },
88
+ });
89
+
90
+ const { result } = renderHook(() => useForma({ spec }));
91
+
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();
96
+
97
+ // Note: computed values that are null may show as null or undefined
98
+ // depending on how forma-core handles them
99
+ 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);
102
+ });
103
+ });
104
+
105
+ describe("computed field dependency chains", () => {
106
+ it("should propagate null through computed field chains", () => {
107
+ const spec = createTestSpec({
108
+ fields: {
109
+ age: { type: "number", label: "Age" },
110
+ income: { type: "number", label: "Income" },
111
+ },
112
+ computed: {
113
+ // First level - checks if age is provided
114
+ hasAge: { expression: "age != null" },
115
+ // Second level - depends on hasAge
116
+ canProceed: { expression: "computed.hasAge = true" },
117
+ },
118
+ });
119
+
120
+ const { result } = renderHook(() => useForma({ spec }));
121
+
122
+ // Both fields undefined
123
+ expect(result.current.data.age).toBeUndefined();
124
+
125
+ // The chain: age is undefined → hasAge evaluates to null → canProceed evaluates to null
126
+ // Both computed values should be null or false due to the dependency chain
127
+ const computed = result.current.computed;
128
+ expect(computed?.hasAge === null || computed?.hasAge === false).toBe(true);
129
+ expect(computed?.canProceed === null || computed?.canProceed === false).toBe(true);
130
+
131
+ // When we provide a value, the chain resolves
132
+ act(() => {
133
+ result.current.setFieldValue("age", 25);
134
+ });
135
+
136
+ expect(result.current.computed?.hasAge).toBe(true);
137
+ expect(result.current.computed?.canProceed).toBe(true);
138
+ });
139
+
140
+ it("should cause page visibility issues when computed chains return null", () => {
141
+ const spec = createTestSpec({
142
+ fields: {
143
+ accepted: { type: "boolean", label: "Accepted" },
144
+ details: { type: "text", label: "Details" },
145
+ },
146
+ computed: {
147
+ isAccepted: { expression: "accepted = true" },
148
+ },
149
+ pages: [
150
+ { id: "page1", title: "Accept", fields: ["accepted"] },
151
+ {
152
+ id: "page2",
153
+ title: "Details",
154
+ fields: ["details"],
155
+ visibleWhen: "computed.isAccepted = true",
156
+ },
157
+ ],
158
+ });
159
+
160
+ const { result } = renderHook(() => useForma({ spec }));
161
+
162
+ // With undefined accepted, computed.isAccepted is null
163
+ // Page visibility "computed.isAccepted = true" → null = true → null → false
164
+ const pages = result.current.wizard?.pages;
165
+ expect(pages?.[0].visible).toBe(true); // First page always visible
166
+ expect(pages?.[1].visible).toBe(false); // Second page hidden due to null chain
167
+
168
+ // After accepting, the page becomes visible
169
+ act(() => {
170
+ result.current.setFieldValue("accepted", true);
171
+ });
172
+
173
+ expect(result.current.wizard?.pages?.[1].visible).toBe(true);
174
+ });
175
+ });
176
+
177
+ describe("string function null handling", () => {
178
+ it("should handle string length on undefined values", () => {
179
+ const spec = createTestSpec({
180
+ fields: {
181
+ name: { type: "text", label: "Name" },
182
+ greeting: {
183
+ type: "text",
184
+ label: "Greeting",
185
+ // This pattern is unsafe - string length(undefined) returns null
186
+ visibleWhen: "string length(name) > 0",
187
+ },
188
+ },
189
+ });
190
+
191
+ const { result } = renderHook(() => useForma({ spec }));
192
+
193
+ // name is undefined
194
+ expect(result.current.data.name).toBeUndefined();
195
+
196
+ // string length(undefined) returns null in FEEL
197
+ // null > 0 returns null
198
+ // evaluateBoolean converts null to false
199
+ expect(result.current.visibility.greeting).toBe(false);
200
+
201
+ // Even empty string returns false (correct behavior)
202
+ act(() => {
203
+ result.current.setFieldValue("name", "");
204
+ });
205
+ expect(result.current.visibility.greeting).toBe(false);
206
+
207
+ // Non-empty string works correctly
208
+ act(() => {
209
+ result.current.setFieldValue("name", "John");
210
+ });
211
+ expect(result.current.visibility.greeting).toBe(true);
212
+ });
213
+
214
+ it("should work with null-safe string length pattern", () => {
215
+ const spec = createTestSpec({
216
+ fields: {
217
+ name: { type: "text", label: "Name" },
218
+ greeting: {
219
+ type: "text",
220
+ label: "Greeting",
221
+ // Null-safe pattern: check for null before using string length
222
+ visibleWhen: "name != null and string length(name) > 0",
223
+ },
224
+ },
225
+ });
226
+
227
+ const { result } = renderHook(() => useForma({ spec }));
228
+
229
+ // name is undefined - first part of AND fails, but returns null not false
230
+ // The behavior is the same as the unsafe pattern in this case
231
+ expect(result.current.visibility.greeting).toBe(false);
232
+
233
+ act(() => {
234
+ result.current.setFieldValue("name", "John");
235
+ });
236
+ expect(result.current.visibility.greeting).toBe(true);
237
+ });
238
+ });
239
+
240
+ describe("alternative patterns for null safety", () => {
241
+ it("should use '!= true' pattern for safer boolean checks", () => {
242
+ const spec = createTestSpec({
243
+ fields: {
244
+ signingOnBehalf: { type: "boolean", label: "Signing on behalf?" },
245
+ participantFields: {
246
+ type: "text",
247
+ label: "Participant Name",
248
+ // Using != true instead of = false
249
+ // When undefined: undefined != true → should work better
250
+ visibleWhen: "signingOnBehalf != true",
251
+ },
252
+ },
253
+ });
254
+
255
+ const { result } = renderHook(() => useForma({ spec }));
256
+
257
+ // Test if this pattern is actually safer
258
+ // Note: In FEEL, undefined != true may still return null
259
+ // This test documents the actual behavior
260
+ const visibility = result.current.visibility.participantFields;
261
+
262
+ // Document what actually happens - this may still be false due to null handling
263
+ expect(typeof visibility).toBe("boolean");
264
+ });
265
+
266
+ it("should use explicit 'or' pattern for boolean checks", () => {
267
+ const spec = createTestSpec({
268
+ fields: {
269
+ accepted: { type: "boolean", label: "Accepted" },
270
+ },
271
+ computed: {
272
+ // Safer pattern: explicitly check for both true and false
273
+ hasAnswered: {
274
+ expression: "accepted = true or accepted = false",
275
+ },
276
+ },
277
+ });
278
+
279
+ const { result } = renderHook(() => useForma({ spec }));
280
+
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();
285
+
286
+ // Set to true
287
+ act(() => {
288
+ result.current.setFieldValue("accepted", true);
289
+ });
290
+ expect(result.current.computed?.hasAnswered).toBe(true);
291
+
292
+ // Set to false
293
+ act(() => {
294
+ result.current.setFieldValue("accepted", false);
295
+ });
296
+ expect(result.current.computed?.hasAnswered).toBe(true);
297
+ });
298
+ });
299
+ });
300
+
301
+ describe("page navigation with conditional visibility", () => {
302
+ it("should handle complex eligibility determination pattern", () => {
303
+ // This test reproduces the diabetes trial enrollment pattern
304
+ const spec = createTestSpec({
305
+ fields: {
306
+ // Inclusion criteria (all must be true to be eligible)
307
+ ageOk: { type: "boolean", label: "Age between 18-65" },
308
+ diagnosisOk: { type: "boolean", label: "Has diabetes diagnosis" },
309
+ // Exclusion criteria (all must be false to be eligible)
310
+ pregnant: { type: "boolean", label: "Is pregnant" },
311
+ allergy: { type: "boolean", label: "Has drug allergy" },
312
+ // Consent page field
313
+ consent: { type: "boolean", label: "I consent" },
314
+ },
315
+ computed: {
316
+ // Check if all inclusion answered
317
+ allInclusionAnswered: {
318
+ expression: "ageOk != null and diagnosisOk != null",
319
+ },
320
+ // Check if all exclusion answered
321
+ allExclusionAnswered: {
322
+ expression: "pregnant != null and allergy != null",
323
+ },
324
+ // Eligibility determined when all criteria answered
325
+ eligibilityDetermined: {
326
+ expression:
327
+ "computed.allInclusionAnswered = true and computed.allExclusionAnswered = true",
328
+ },
329
+ // All inclusion criteria met
330
+ allInclusionMet: {
331
+ expression: "ageOk = true and diagnosisOk = true",
332
+ },
333
+ // Any exclusion criteria met
334
+ anyExclusionMet: {
335
+ expression: "pregnant = true or allergy = true",
336
+ },
337
+ // Final eligibility
338
+ eligible: {
339
+ expression:
340
+ "computed.eligibilityDetermined = true and computed.allInclusionMet = true and computed.anyExclusionMet = false",
341
+ },
342
+ },
343
+ pages: [
344
+ { id: "inclusion", title: "Inclusion Criteria", fields: ["ageOk", "diagnosisOk"] },
345
+ { id: "exclusion", title: "Exclusion Criteria", fields: ["pregnant", "allergy"] },
346
+ {
347
+ id: "consent",
348
+ title: "Consent",
349
+ fields: ["consent"],
350
+ visibleWhen: "computed.eligible = true",
351
+ },
352
+ ],
353
+ });
354
+
355
+ const { result } = renderHook(() => useForma({ spec }));
356
+
357
+ // Initially, all computed values are null due to undefined fields
358
+ const wizard = result.current.wizard;
359
+ expect(wizard?.pages[0].visible).toBe(true); // Inclusion always visible
360
+ expect(wizard?.pages[1].visible).toBe(true); // Exclusion always visible
361
+ expect(wizard?.pages[2].visible).toBe(false); // Consent hidden (computed.eligible is null)
362
+
363
+ // Fill inclusion criteria (both true)
364
+ act(() => {
365
+ result.current.setFieldValue("ageOk", true);
366
+ result.current.setFieldValue("diagnosisOk", true);
367
+ });
368
+
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);
380
+ expect(result.current.computed?.allInclusionMet).toBe(true);
381
+ expect(result.current.computed?.anyExclusionMet).toBe(false);
382
+ expect(result.current.computed?.eligible).toBe(true);
383
+ expect(result.current.wizard?.pages[2].visible).toBe(true);
384
+ });
385
+
386
+ it("should show ineligibility when exclusion criteria met", () => {
387
+ const spec = createTestSpec({
388
+ fields: {
389
+ ageOk: { type: "boolean", label: "Age OK" },
390
+ pregnant: { type: "boolean", label: "Pregnant" },
391
+ details: { type: "text", label: "Details" },
392
+ ineligibilityReason: { type: "textarea", label: "Reason" },
393
+ },
394
+ computed: {
395
+ eligibilityDetermined: {
396
+ expression: "(ageOk = true or ageOk = false) and (pregnant = true or pregnant = false)",
397
+ },
398
+ eligible: {
399
+ expression: "computed.eligibilityDetermined = true and ageOk = true and pregnant = false",
400
+ },
401
+ ineligible: {
402
+ expression: "computed.eligibilityDetermined = true and (ageOk = false or pregnant = true)",
403
+ },
404
+ },
405
+ pages: [
406
+ { id: "screening", title: "Screening", fields: ["ageOk", "pregnant"] },
407
+ {
408
+ id: "eligible-flow",
409
+ title: "Continue",
410
+ fields: ["details"],
411
+ visibleWhen: "computed.eligible = true",
412
+ },
413
+ {
414
+ id: "ineligible-flow",
415
+ title: "Ineligible",
416
+ fields: ["ineligibilityReason"],
417
+ visibleWhen: "computed.ineligible = true",
418
+ },
419
+ ],
420
+ });
421
+
422
+ const { result } = renderHook(() => useForma({ spec }));
423
+
424
+ // Mark as pregnant (exclusion criterion met)
425
+ act(() => {
426
+ result.current.setFieldValue("ageOk", true);
427
+ result.current.setFieldValue("pregnant", true);
428
+ });
429
+
430
+ // Should show ineligible path, not eligible path
431
+ expect(result.current.computed?.eligible).toBe(false);
432
+ expect(result.current.computed?.ineligible).toBe(true);
433
+ expect(result.current.wizard?.pages[1].visible).toBe(false); // eligible-flow hidden
434
+ expect(result.current.wizard?.pages[2].visible).toBe(true); // ineligible-flow visible
435
+ });
436
+ });