@fogpipe/forma-react 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,874 @@
1
+ /**
2
+ * Comprehensive tests for canProceed wizard validation
3
+ *
4
+ * Tests the canProceed property of wizard helpers which determines
5
+ * whether the user can proceed to the next page based on validation.
6
+ */
7
+
8
+ import { describe, it, expect } from "vitest";
9
+ import { renderHook, act } from "@testing-library/react";
10
+ import { useForma } from "../useForma.js";
11
+ import { createTestSpec } from "./test-utils.js";
12
+
13
+ describe("canProceed", () => {
14
+ describe("basic validation logic", () => {
15
+ it("returns true when all required fields on current page are filled", () => {
16
+ const spec = createTestSpec({
17
+ fields: {
18
+ name: { type: "text", label: "Name", required: true },
19
+ email: { type: "email", label: "Email", required: true },
20
+ phone: { type: "text", label: "Phone", required: true },
21
+ },
22
+ pages: [
23
+ { id: "page1", title: "Page 1", fields: ["name", "email"] },
24
+ { id: "page2", title: "Page 2", fields: ["phone"] },
25
+ ],
26
+ });
27
+
28
+ const { result } = renderHook(() =>
29
+ useForma({ spec, initialData: { name: "John", email: "john@example.com" } })
30
+ );
31
+
32
+ expect(result.current.wizard?.canProceed).toBe(true);
33
+ });
34
+
35
+ it("returns false when required field on current page is empty", () => {
36
+ const spec = createTestSpec({
37
+ fields: {
38
+ name: { type: "text", label: "Name", required: true },
39
+ email: { type: "email", label: "Email", required: true },
40
+ },
41
+ pages: [
42
+ { id: "page1", title: "Page 1", fields: ["name", "email"] },
43
+ ],
44
+ });
45
+
46
+ const { result } = renderHook(() =>
47
+ useForma({ spec, initialData: { name: "John" } }) // email is missing
48
+ );
49
+
50
+ expect(result.current.wizard?.canProceed).toBe(false);
51
+ });
52
+
53
+ it("only validates fields on the current page, not other pages", () => {
54
+ const spec = createTestSpec({
55
+ fields: {
56
+ name: { type: "text", label: "Name", required: true },
57
+ email: { type: "email", label: "Email", required: true },
58
+ phone: { type: "text", label: "Phone", required: true },
59
+ },
60
+ pages: [
61
+ { id: "page1", title: "Page 1", fields: ["name"] },
62
+ { id: "page2", title: "Page 2", fields: ["email", "phone"] },
63
+ ],
64
+ });
65
+
66
+ const { result } = renderHook(() =>
67
+ useForma({ spec, initialData: { name: "John" } }) // page 2 fields empty
68
+ );
69
+
70
+ // On page 1, only name is checked (which is filled)
71
+ expect(result.current.wizard?.canProceed).toBe(true);
72
+ });
73
+
74
+ it("updates canProceed when navigating to next page", () => {
75
+ const spec = createTestSpec({
76
+ fields: {
77
+ name: { type: "text", label: "Name", required: true },
78
+ email: { type: "email", label: "Email", required: true },
79
+ },
80
+ pages: [
81
+ { id: "page1", title: "Page 1", fields: ["name"] },
82
+ { id: "page2", title: "Page 2", fields: ["email"] },
83
+ ],
84
+ });
85
+
86
+ const { result } = renderHook(() =>
87
+ useForma({ spec, initialData: { name: "John" } })
88
+ );
89
+
90
+ // Page 1 is valid
91
+ expect(result.current.wizard?.canProceed).toBe(true);
92
+
93
+ // Navigate to page 2
94
+ act(() => {
95
+ result.current.wizard?.nextPage();
96
+ });
97
+
98
+ // Page 2 is invalid (email is empty)
99
+ expect(result.current.wizard?.canProceed).toBe(false);
100
+ });
101
+
102
+ it("handles array item fields (e.g., items[0].name)", () => {
103
+ const spec = createTestSpec({
104
+ fields: {
105
+ items: {
106
+ type: "array",
107
+ label: "Items",
108
+ required: true,
109
+ minItems: 1,
110
+ itemFields: {
111
+ name: { type: "text", label: "Item Name" },
112
+ },
113
+ },
114
+ },
115
+ pages: [
116
+ { id: "page1", title: "Page 1", fields: ["items"] },
117
+ ],
118
+ });
119
+
120
+ const { result } = renderHook(() =>
121
+ useForma({ spec, initialData: { items: [] } })
122
+ );
123
+
124
+ // Array is empty but minItems requires at least 1
125
+ expect(result.current.wizard?.canProceed).toBe(false);
126
+
127
+ // Add an item
128
+ act(() => {
129
+ result.current.setFieldValue("items", [{ name: "Item 1" }]);
130
+ });
131
+
132
+ expect(result.current.wizard?.canProceed).toBe(true);
133
+ });
134
+ });
135
+
136
+ describe("visibility integration", () => {
137
+ it("ignores hidden fields when calculating canProceed", () => {
138
+ const spec = createTestSpec({
139
+ fields: {
140
+ showEmail: { type: "boolean", label: "Show Email" },
141
+ name: { type: "text", label: "Name", required: true },
142
+ email: { type: "email", label: "Email", required: true, visibleWhen: "showEmail = true" },
143
+ },
144
+ pages: [
145
+ { id: "page1", title: "Page 1", fields: ["showEmail", "name", "email"] },
146
+ ],
147
+ });
148
+
149
+ const { result } = renderHook(() =>
150
+ useForma({ spec, initialData: { showEmail: false, name: "John" } })
151
+ );
152
+
153
+ // Email is required but hidden, so it shouldn't block progression
154
+ expect(result.current.wizard?.canProceed).toBe(true);
155
+ });
156
+
157
+ it("updates canProceed when field visibility changes", () => {
158
+ const spec = createTestSpec({
159
+ fields: {
160
+ showEmail: { type: "boolean", label: "Show Email" },
161
+ name: { type: "text", label: "Name", required: true },
162
+ email: { type: "email", label: "Email", required: true, visibleWhen: "showEmail = true" },
163
+ },
164
+ pages: [
165
+ { id: "page1", title: "Page 1", fields: ["showEmail", "name", "email"] },
166
+ ],
167
+ });
168
+
169
+ const { result } = renderHook(() =>
170
+ useForma({ spec, initialData: { showEmail: false, name: "John" } })
171
+ );
172
+
173
+ // Email is hidden
174
+ expect(result.current.wizard?.canProceed).toBe(true);
175
+
176
+ // Show email field
177
+ act(() => {
178
+ result.current.setFieldValue("showEmail", true);
179
+ });
180
+
181
+ // Now email is visible and empty - should block
182
+ expect(result.current.wizard?.canProceed).toBe(false);
183
+
184
+ // Fill email
185
+ act(() => {
186
+ result.current.setFieldValue("email", "john@example.com");
187
+ });
188
+
189
+ expect(result.current.wizard?.canProceed).toBe(true);
190
+ });
191
+ });
192
+
193
+ describe("required field validation", () => {
194
+ it("validates static required fields (schema.required)", () => {
195
+ const spec = createTestSpec({
196
+ fields: {
197
+ name: { type: "text", label: "Name", required: true },
198
+ age: { type: "number", label: "Age" }, // Not required
199
+ },
200
+ pages: [
201
+ { id: "page1", title: "Page 1", fields: ["name", "age"] },
202
+ ],
203
+ });
204
+
205
+ const { result } = renderHook(() =>
206
+ useForma({ spec, initialData: { age: 25 } }) // name missing
207
+ );
208
+
209
+ expect(result.current.wizard?.canProceed).toBe(false);
210
+
211
+ act(() => {
212
+ result.current.setFieldValue("name", "John");
213
+ });
214
+
215
+ expect(result.current.wizard?.canProceed).toBe(true);
216
+ });
217
+
218
+ it("validates conditional required (requiredWhen)", () => {
219
+ const spec = createTestSpec({
220
+ fields: {
221
+ hasSpouse: { type: "boolean", label: "Has Spouse" },
222
+ spouseName: { type: "text", label: "Spouse Name", requiredWhen: "hasSpouse = true" },
223
+ },
224
+ pages: [
225
+ { id: "page1", title: "Page 1", fields: ["hasSpouse", "spouseName"] },
226
+ ],
227
+ });
228
+
229
+ const { result } = renderHook(() =>
230
+ useForma({ spec, initialData: { hasSpouse: false } })
231
+ );
232
+
233
+ // Spouse name not required when hasSpouse is false
234
+ expect(result.current.wizard?.canProceed).toBe(true);
235
+
236
+ // Enable spouse
237
+ act(() => {
238
+ result.current.setFieldValue("hasSpouse", true);
239
+ });
240
+
241
+ // Now spouse name is required but empty
242
+ expect(result.current.wizard?.canProceed).toBe(false);
243
+
244
+ // Fill spouse name
245
+ act(() => {
246
+ result.current.setFieldValue("spouseName", "Jane");
247
+ });
248
+
249
+ expect(result.current.wizard?.canProceed).toBe(true);
250
+ });
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"),
256
+ // use a validation rule: { rule: "value = true", message: "Must accept terms" }
257
+ const spec = createTestSpec({
258
+ fields: {
259
+ hasPets: { type: "boolean", label: "Do you have pets?", required: true },
260
+ },
261
+ pages: [
262
+ { id: "page1", title: "Page 1", fields: ["hasPets"] },
263
+ ],
264
+ });
265
+
266
+ // undefined should be invalid (user hasn't answered)
267
+ const { result: resultUndefined } = renderHook(() =>
268
+ useForma({ spec, initialData: {} })
269
+ );
270
+ expect(resultUndefined.current.wizard?.canProceed).toBe(false);
271
+
272
+ // false should be valid (user answered "no")
273
+ const { result: resultFalse } = renderHook(() =>
274
+ useForma({ spec, initialData: { hasPets: false } })
275
+ );
276
+ expect(resultFalse.current.wizard?.canProceed).toBe(true);
277
+
278
+ // true should be valid (user answered "yes")
279
+ const { result: resultTrue } = renderHook(() =>
280
+ useForma({ spec, initialData: { hasPets: true } })
281
+ );
282
+ expect(resultTrue.current.wizard?.canProceed).toBe(true);
283
+ });
284
+
285
+ it("required select fields - null/undefined detection", () => {
286
+ const spec = createTestSpec({
287
+ fields: {
288
+ country: {
289
+ type: "select",
290
+ label: "Country",
291
+ required: true,
292
+ options: [
293
+ { label: "USA", value: "us" },
294
+ { label: "Canada", value: "ca" },
295
+ ],
296
+ },
297
+ },
298
+ pages: [
299
+ { id: "page1", title: "Page 1", fields: ["country"] },
300
+ ],
301
+ });
302
+
303
+ const { result } = renderHook(() =>
304
+ useForma({ spec, initialData: {} })
305
+ );
306
+
307
+ expect(result.current.wizard?.canProceed).toBe(false);
308
+
309
+ act(() => {
310
+ result.current.setFieldValue("country", "us");
311
+ });
312
+
313
+ expect(result.current.wizard?.canProceed).toBe(true);
314
+ });
315
+ });
316
+
317
+ describe("field types on page", () => {
318
+ it("text fields - empty string detection", () => {
319
+ const spec = createTestSpec({
320
+ fields: {
321
+ name: { type: "text", label: "Name", required: true },
322
+ },
323
+ pages: [
324
+ { id: "page1", title: "Page 1", fields: ["name"] },
325
+ ],
326
+ });
327
+
328
+ // Empty string should be invalid
329
+ const { result: resultEmpty } = renderHook(() =>
330
+ useForma({ spec, initialData: { name: "" } })
331
+ );
332
+ expect(resultEmpty.current.wizard?.canProceed).toBe(false);
333
+
334
+ // Whitespace only should be invalid
335
+ const { result: resultWhitespace } = renderHook(() =>
336
+ useForma({ spec, initialData: { name: " " } })
337
+ );
338
+ expect(resultWhitespace.current.wizard?.canProceed).toBe(false);
339
+
340
+ // Actual value should be valid
341
+ const { result: resultValid } = renderHook(() =>
342
+ useForma({ spec, initialData: { name: "John" } })
343
+ );
344
+ expect(resultValid.current.wizard?.canProceed).toBe(true);
345
+ });
346
+
347
+ it("number fields - null/undefined detection", () => {
348
+ const spec = createTestSpec({
349
+ fields: {
350
+ age: { type: "number", label: "Age", required: true },
351
+ },
352
+ pages: [
353
+ { id: "page1", title: "Page 1", fields: ["age"] },
354
+ ],
355
+ });
356
+
357
+ // null should be invalid
358
+ const { result: resultNull } = renderHook(() =>
359
+ useForma({ spec, initialData: { age: null } })
360
+ );
361
+ expect(resultNull.current.wizard?.canProceed).toBe(false);
362
+
363
+ // undefined should be invalid
364
+ const { result: resultUndefined } = renderHook(() =>
365
+ useForma({ spec, initialData: {} })
366
+ );
367
+ expect(resultUndefined.current.wizard?.canProceed).toBe(false);
368
+
369
+ // 0 should be valid (it's a real number)
370
+ const { result: resultZero } = renderHook(() =>
371
+ useForma({ spec, initialData: { age: 0 } })
372
+ );
373
+ expect(resultZero.current.wizard?.canProceed).toBe(true);
374
+ });
375
+
376
+ it("array fields - minItems validation", () => {
377
+ const spec = createTestSpec({
378
+ fields: {
379
+ items: {
380
+ type: "array",
381
+ label: "Items",
382
+ minItems: 2,
383
+ itemFields: {
384
+ name: { type: "text", label: "Name" },
385
+ },
386
+ },
387
+ },
388
+ pages: [
389
+ { id: "page1", title: "Page 1", fields: ["items"] },
390
+ ],
391
+ });
392
+
393
+ // Empty array with minItems > 0 should be invalid
394
+ const { result: resultEmpty } = renderHook(() =>
395
+ useForma({ spec, initialData: { items: [] } })
396
+ );
397
+ expect(resultEmpty.current.wizard?.canProceed).toBe(false);
398
+
399
+ // 1 item but minItems is 2
400
+ const { result: resultOne } = renderHook(() =>
401
+ useForma({ spec, initialData: { items: [{ name: "Item 1" }] } })
402
+ );
403
+ expect(resultOne.current.wizard?.canProceed).toBe(false);
404
+
405
+ // 2 items should be valid
406
+ const { result: resultTwo } = renderHook(() =>
407
+ useForma({ spec, initialData: { items: [{ name: "Item 1" }, { name: "Item 2" }] } })
408
+ );
409
+ expect(resultTwo.current.wizard?.canProceed).toBe(true);
410
+ });
411
+ });
412
+
413
+ describe("edge cases", () => {
414
+ it("empty page (no fields) - should return true", () => {
415
+ const spec = createTestSpec({
416
+ fields: {
417
+ name: { type: "text", label: "Name", required: true },
418
+ },
419
+ pages: [
420
+ { id: "page1", title: "Empty Page", fields: [] },
421
+ { id: "page2", title: "Page 2", fields: ["name"] },
422
+ ],
423
+ });
424
+
425
+ const { result } = renderHook(() =>
426
+ useForma({ spec, initialData: {} })
427
+ );
428
+
429
+ // Empty page should allow progression
430
+ expect(result.current.wizard?.canProceed).toBe(true);
431
+ });
432
+
433
+ it("all fields hidden on page - should return true", () => {
434
+ const spec = createTestSpec({
435
+ fields: {
436
+ showFields: { type: "boolean", label: "Show Fields" },
437
+ name: { type: "text", label: "Name", required: true, visibleWhen: "showFields = true" },
438
+ email: { type: "email", label: "Email", required: true, visibleWhen: "showFields = true" },
439
+ },
440
+ pages: [
441
+ { id: "page1", title: "Page 1", fields: ["showFields", "name", "email"] },
442
+ ],
443
+ });
444
+
445
+ const { result } = renderHook(() =>
446
+ useForma({ spec, initialData: { showFields: false } })
447
+ );
448
+
449
+ // All required fields are hidden, only showFields is visible (not required)
450
+ expect(result.current.wizard?.canProceed).toBe(true);
451
+ });
452
+
453
+ it("mixed valid/invalid fields across pages", () => {
454
+ const spec = createTestSpec({
455
+ fields: {
456
+ name: { type: "text", label: "Name", required: true },
457
+ email: { type: "email", label: "Email", required: true },
458
+ phone: { type: "text", label: "Phone", required: true },
459
+ },
460
+ pages: [
461
+ { id: "page1", title: "Page 1", fields: ["name"] },
462
+ { id: "page2", title: "Page 2", fields: ["email"] },
463
+ { id: "page3", title: "Page 3", fields: ["phone"] },
464
+ ],
465
+ });
466
+
467
+ const { result } = renderHook(() =>
468
+ useForma({ spec, initialData: { name: "John" } }) // Only page 1 is valid
469
+ );
470
+
471
+ // Page 1 should be valid
472
+ expect(result.current.wizard?.currentPageIndex).toBe(0);
473
+ expect(result.current.wizard?.canProceed).toBe(true);
474
+
475
+ // Navigate to page 2
476
+ act(() => {
477
+ result.current.wizard?.nextPage();
478
+ });
479
+
480
+ // Page 2 should be invalid (email empty)
481
+ expect(result.current.wizard?.currentPageIndex).toBe(1);
482
+ expect(result.current.wizard?.canProceed).toBe(false);
483
+ });
484
+
485
+ it("page with only computed fields (display-only)", () => {
486
+ const spec = createTestSpec({
487
+ fields: {
488
+ price: { type: "number", label: "Price", required: true },
489
+ quantity: { type: "number", label: "Quantity", required: true },
490
+ },
491
+ computed: {
492
+ total: { expression: "price * quantity" },
493
+ },
494
+ pages: [
495
+ { id: "page1", title: "Page 1", fields: ["price", "quantity"] },
496
+ ],
497
+ });
498
+
499
+ const { result } = renderHook(() =>
500
+ useForma({ spec, initialData: { price: 10, quantity: 2 } })
501
+ );
502
+
503
+ // Computed fields don't block - only real fields do
504
+ expect(result.current.wizard?.canProceed).toBe(true);
505
+ expect(result.current.computed.total).toBe(20);
506
+ });
507
+ });
508
+
509
+ describe("integration with navigation methods", () => {
510
+ it("validateCurrentPage correlates with canProceed", () => {
511
+ const spec = createTestSpec({
512
+ fields: {
513
+ name: { type: "text", label: "Name", required: true },
514
+ },
515
+ pages: [
516
+ { id: "page1", title: "Page 1", fields: ["name"] },
517
+ ],
518
+ });
519
+
520
+ const { result } = renderHook(() =>
521
+ useForma({ spec, initialData: {} })
522
+ );
523
+
524
+ // Both should return the same result
525
+ expect(result.current.wizard?.canProceed).toBe(false);
526
+ expect(result.current.wizard?.validateCurrentPage()).toBe(false);
527
+
528
+ // Fill the field
529
+ act(() => {
530
+ result.current.setFieldValue("name", "John");
531
+ });
532
+
533
+ expect(result.current.wizard?.canProceed).toBe(true);
534
+ expect(result.current.wizard?.validateCurrentPage()).toBe(true);
535
+ });
536
+
537
+ it("touchCurrentPageFields reveals errors", () => {
538
+ const spec = createTestSpec({
539
+ fields: {
540
+ name: { type: "text", label: "Name", required: true },
541
+ email: { type: "email", label: "Email", required: true },
542
+ },
543
+ pages: [
544
+ { id: "page1", title: "Page 1", fields: ["name", "email"] },
545
+ ],
546
+ });
547
+
548
+ const { result } = renderHook(() =>
549
+ useForma({ spec, initialData: {}, validateOn: "blur" })
550
+ );
551
+
552
+ // Fields not touched yet - errors exist but not visible to user
553
+ expect(result.current.wizard?.canProceed).toBe(false);
554
+ expect(result.current.touched.name).toBeUndefined();
555
+ expect(result.current.touched.email).toBeUndefined();
556
+
557
+ // Touch all fields on current page
558
+ act(() => {
559
+ result.current.wizard?.touchCurrentPageFields();
560
+ });
561
+
562
+ // Now fields are touched - errors should be displayed
563
+ expect(result.current.touched.name).toBe(true);
564
+ expect(result.current.touched.email).toBe(true);
565
+ });
566
+ });
567
+
568
+ describe("canProceed reactivity", () => {
569
+ it("updates reactively when data changes", () => {
570
+ const spec = createTestSpec({
571
+ fields: {
572
+ name: { type: "text", label: "Name", required: true },
573
+ },
574
+ pages: [
575
+ { id: "page1", title: "Page 1", fields: ["name"] },
576
+ ],
577
+ });
578
+
579
+ const { result } = renderHook(() =>
580
+ useForma({ spec, initialData: {} })
581
+ );
582
+
583
+ expect(result.current.wizard?.canProceed).toBe(false);
584
+
585
+ act(() => {
586
+ result.current.setFieldValue("name", "John");
587
+ });
588
+
589
+ expect(result.current.wizard?.canProceed).toBe(true);
590
+
591
+ act(() => {
592
+ result.current.setFieldValue("name", "");
593
+ });
594
+
595
+ expect(result.current.wizard?.canProceed).toBe(false);
596
+ });
597
+
598
+ it("updates with multiple required fields", () => {
599
+ const spec = createTestSpec({
600
+ fields: {
601
+ firstName: { type: "text", label: "First Name", required: true },
602
+ lastName: { type: "text", label: "Last Name", required: true },
603
+ email: { type: "email", label: "Email", required: true },
604
+ },
605
+ pages: [
606
+ { id: "page1", title: "Page 1", fields: ["firstName", "lastName", "email"] },
607
+ ],
608
+ });
609
+
610
+ const { result } = renderHook(() =>
611
+ useForma({ spec, initialData: {} })
612
+ );
613
+
614
+ expect(result.current.wizard?.canProceed).toBe(false);
615
+
616
+ // Fill one field - still invalid
617
+ act(() => {
618
+ result.current.setFieldValue("firstName", "John");
619
+ });
620
+ expect(result.current.wizard?.canProceed).toBe(false);
621
+
622
+ // Fill second field - still invalid
623
+ act(() => {
624
+ result.current.setFieldValue("lastName", "Doe");
625
+ });
626
+ expect(result.current.wizard?.canProceed).toBe(false);
627
+
628
+ // Fill third field - now valid
629
+ act(() => {
630
+ result.current.setFieldValue("email", "john@example.com");
631
+ });
632
+ expect(result.current.wizard?.canProceed).toBe(true);
633
+ });
634
+ });
635
+
636
+ describe("warnings vs errors", () => {
637
+ it("only errors block canProceed, not warnings", () => {
638
+ // Note: This test requires custom FEEL validation rules that produce warnings
639
+ // For now, we test that the error filtering uses severity === 'error'
640
+ const spec = createTestSpec({
641
+ fields: {
642
+ name: { type: "text", label: "Name", required: true },
643
+ },
644
+ pages: [
645
+ { id: "page1", title: "Page 1", fields: ["name"] },
646
+ ],
647
+ });
648
+
649
+ const { result } = renderHook(() =>
650
+ useForma({ spec, initialData: { name: "John" } })
651
+ );
652
+
653
+ // No errors - should be able to proceed
654
+ expect(result.current.wizard?.canProceed).toBe(true);
655
+ expect(result.current.errors.filter(e => e.severity === "error")).toHaveLength(0);
656
+ });
657
+ });
658
+
659
+ describe("edge cases - hidden pages", () => {
660
+ it("should auto-correct to valid page when current page becomes hidden", () => {
661
+ const spec = createTestSpec({
662
+ fields: {
663
+ showPage2: { type: "boolean", label: "Show Page 2" },
664
+ page1Field: { type: "text", label: "Page 1 Field" },
665
+ page2Field: { type: "text", label: "Page 2 Field", required: true },
666
+ },
667
+ pages: [
668
+ { id: "page1", title: "Page 1", fields: ["showPage2", "page1Field"] },
669
+ { id: "page2", title: "Page 2", fields: ["page2Field"], visibleWhen: "showPage2 = true" },
670
+ ],
671
+ });
672
+
673
+ const { result } = renderHook(() =>
674
+ useForma({ spec, initialData: { showPage2: true } })
675
+ );
676
+
677
+ // Navigate to page 2
678
+ act(() => {
679
+ result.current.wizard?.nextPage();
680
+ });
681
+ expect(result.current.wizard?.currentPageIndex).toBe(1);
682
+ expect(result.current.wizard?.currentPage?.id).toBe("page2");
683
+
684
+ // Hide page 2 - should auto-correct to page 1
685
+ act(() => {
686
+ result.current.setFieldValue("showPage2", false);
687
+ });
688
+
689
+ // Should be back on page 1 (the only visible page now)
690
+ expect(result.current.wizard?.currentPageIndex).toBe(0);
691
+ expect(result.current.wizard?.currentPage?.id).toBe("page1");
692
+ });
693
+
694
+ it("should skip hidden pages when navigating forward", () => {
695
+ const spec = createTestSpec({
696
+ fields: {
697
+ skipMiddle: { type: "boolean", label: "Skip Middle" },
698
+ page1Field: { type: "text", label: "Page 1 Field" },
699
+ page2Field: { type: "text", label: "Page 2 Field" },
700
+ page3Field: { type: "text", label: "Page 3 Field" },
701
+ },
702
+ pages: [
703
+ { id: "page1", title: "Page 1", fields: ["skipMiddle", "page1Field"] },
704
+ { id: "page2", title: "Page 2", fields: ["page2Field"], visibleWhen: "skipMiddle = false" },
705
+ { id: "page3", title: "Page 3", fields: ["page3Field"] },
706
+ ],
707
+ });
708
+
709
+ const { result } = renderHook(() =>
710
+ useForma({ spec, initialData: { skipMiddle: true } })
711
+ );
712
+
713
+ // With skipMiddle=true, page2 is hidden
714
+ // Navigating from page1 should go directly to page3
715
+ act(() => {
716
+ result.current.wizard?.nextPage();
717
+ });
718
+
719
+ // Should be on page3 (which is now index 1 in visible pages)
720
+ expect(result.current.wizard?.currentPage?.id).toBe("page3");
721
+ });
722
+
723
+ it("pages array includes all pages with visible property", () => {
724
+ // Note: wizard.pages returns ALL pages defined in spec, with a visible
725
+ // property indicating current visibility state. Consumers should filter
726
+ // by visible when rendering step indicators.
727
+ const spec = createTestSpec({
728
+ fields: {
729
+ showOptional: { type: "boolean", label: "Show Optional" },
730
+ requiredField: { type: "text", label: "Required", required: true },
731
+ optionalField: { type: "text", label: "Optional" },
732
+ },
733
+ pages: [
734
+ { id: "main", title: "Main", fields: ["showOptional", "requiredField"] },
735
+ { id: "optional", title: "Optional", fields: ["optionalField"], visibleWhen: "showOptional = true" },
736
+ ],
737
+ });
738
+
739
+ const { result } = renderHook(() =>
740
+ useForma({ spec, initialData: { showOptional: false } })
741
+ );
742
+
743
+ // All pages are returned
744
+ expect(result.current.wizard?.pages).toHaveLength(2);
745
+
746
+ // Main page is visible
747
+ expect(result.current.wizard?.pages[0].visible).toBe(true);
748
+ // Optional page is hidden when showOptional is false
749
+ expect(result.current.wizard?.pages[1].visible).toBe(false);
750
+
751
+ // Enable optional page
752
+ act(() => {
753
+ result.current.setFieldValue("showOptional", true);
754
+ });
755
+
756
+ // Optional page is now visible
757
+ expect(result.current.wizard?.pages[1].visible).toBe(true);
758
+ });
759
+ });
760
+
761
+ describe("edge cases - navigation bounds", () => {
762
+ it("should not navigate beyond last page", () => {
763
+ const spec = createTestSpec({
764
+ fields: {
765
+ field1: { type: "text", label: "Field 1" },
766
+ field2: { type: "text", label: "Field 2" },
767
+ },
768
+ pages: [
769
+ { id: "page1", title: "Page 1", fields: ["field1"] },
770
+ { id: "page2", title: "Page 2", fields: ["field2"] },
771
+ ],
772
+ });
773
+
774
+ const { result } = renderHook(() => useForma({ spec }));
775
+
776
+ // Go to last page
777
+ act(() => {
778
+ result.current.wizard?.nextPage();
779
+ });
780
+ expect(result.current.wizard?.currentPageIndex).toBe(1);
781
+ expect(result.current.wizard?.isLastPage).toBe(true);
782
+
783
+ // Try to go beyond - should stay on last page
784
+ act(() => {
785
+ result.current.wizard?.nextPage();
786
+ });
787
+ expect(result.current.wizard?.currentPageIndex).toBe(1);
788
+ });
789
+
790
+ it("should not navigate before first page", () => {
791
+ const spec = createTestSpec({
792
+ fields: {
793
+ field1: { type: "text", label: "Field 1" },
794
+ field2: { type: "text", label: "Field 2" },
795
+ },
796
+ pages: [
797
+ { id: "page1", title: "Page 1", fields: ["field1"] },
798
+ { id: "page2", title: "Page 2", fields: ["field2"] },
799
+ ],
800
+ });
801
+
802
+ const { result } = renderHook(() => useForma({ spec }));
803
+
804
+ expect(result.current.wizard?.currentPageIndex).toBe(0);
805
+ expect(result.current.wizard?.hasPreviousPage).toBe(false);
806
+
807
+ // Try to go before first page - should stay on first
808
+ act(() => {
809
+ result.current.wizard?.previousPage();
810
+ });
811
+ expect(result.current.wizard?.currentPageIndex).toBe(0);
812
+ });
813
+
814
+ it("goToPage clamps out-of-bounds indices to valid range", () => {
815
+ const spec = createTestSpec({
816
+ fields: {
817
+ field1: { type: "text", label: "Field 1" },
818
+ field2: { type: "text", label: "Field 2" },
819
+ },
820
+ pages: [
821
+ { id: "page1", title: "Page 1", fields: ["field1"] },
822
+ { id: "page2", title: "Page 2", fields: ["field2"] },
823
+ ],
824
+ });
825
+
826
+ const { result } = renderHook(() => useForma({ spec }));
827
+
828
+ // goToPage(999) should clamp to last page (index 1)
829
+ act(() => {
830
+ result.current.wizard?.goToPage(999);
831
+ });
832
+ expect(result.current.wizard?.currentPageIndex).toBe(1);
833
+ expect(result.current.wizard?.currentPage?.id).toBe("page2");
834
+
835
+ // goToPage(-5) should clamp to first page (index 0)
836
+ act(() => {
837
+ result.current.wizard?.goToPage(-5);
838
+ });
839
+ expect(result.current.wizard?.currentPageIndex).toBe(0);
840
+ expect(result.current.wizard?.currentPage?.id).toBe("page1");
841
+ });
842
+
843
+ it("goToPage should navigate to valid index", () => {
844
+ const spec = createTestSpec({
845
+ fields: {
846
+ field1: { type: "text", label: "Field 1" },
847
+ field2: { type: "text", label: "Field 2" },
848
+ field3: { type: "text", label: "Field 3" },
849
+ },
850
+ pages: [
851
+ { id: "page1", title: "Page 1", fields: ["field1"] },
852
+ { id: "page2", title: "Page 2", fields: ["field2"] },
853
+ { id: "page3", title: "Page 3", fields: ["field3"] },
854
+ ],
855
+ });
856
+
857
+ const { result } = renderHook(() => useForma({ spec }));
858
+
859
+ // Go directly to page 3 (index 2)
860
+ act(() => {
861
+ result.current.wizard?.goToPage(2);
862
+ });
863
+ expect(result.current.wizard?.currentPageIndex).toBe(2);
864
+ expect(result.current.wizard?.currentPage?.id).toBe("page3");
865
+
866
+ // Go back to page 1 (index 0)
867
+ act(() => {
868
+ result.current.wizard?.goToPage(0);
869
+ });
870
+ expect(result.current.wizard?.currentPageIndex).toBe(0);
871
+ expect(result.current.wizard?.currentPage?.id).toBe("page1");
872
+ });
873
+ });
874
+ });