@fogpipe/forma-react 0.12.0 → 0.14.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.
@@ -0,0 +1,752 @@
1
+ /**
2
+ * Event system tests for @fogpipe/forma-react
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from "vitest";
6
+ import { renderHook, act } from "@testing-library/react";
7
+ import { useForma } from "../useForma.js";
8
+ import { FormaEventEmitter } from "../events.js";
9
+ import type { FormaEventMap } from "../events.js";
10
+ import { createTestSpec } from "./test-utils.js";
11
+
12
+ // ============================================================================
13
+ // FormaEventEmitter unit tests
14
+ // ============================================================================
15
+
16
+ describe("FormaEventEmitter", () => {
17
+ let emitter: FormaEventEmitter;
18
+
19
+ beforeEach(() => {
20
+ emitter = new FormaEventEmitter();
21
+ });
22
+
23
+ it("should register listener and fire event with correct payload", () => {
24
+ const listener = vi.fn();
25
+ emitter.on("fieldChanged", listener);
26
+
27
+ const payload: FormaEventMap["fieldChanged"] = {
28
+ path: "name",
29
+ value: "John",
30
+ previousValue: "",
31
+ source: "user",
32
+ };
33
+ emitter.fire("fieldChanged", payload);
34
+
35
+ expect(listener).toHaveBeenCalledWith(payload);
36
+ });
37
+
38
+ it("should fire multiple listeners in registration order", () => {
39
+ const calls: number[] = [];
40
+ emitter.on("fieldChanged", () => { calls.push(1); });
41
+ emitter.on("fieldChanged", () => { calls.push(2); });
42
+ emitter.on("fieldChanged", () => { calls.push(3); });
43
+
44
+ emitter.fire("fieldChanged", {
45
+ path: "x",
46
+ value: 1,
47
+ previousValue: 0,
48
+ source: "user",
49
+ });
50
+
51
+ expect(calls).toEqual([1, 2, 3]);
52
+ });
53
+
54
+ it("should unsubscribe only the target listener", () => {
55
+ const listenerA = vi.fn();
56
+ const listenerB = vi.fn();
57
+ const unsub = emitter.on("fieldChanged", listenerA);
58
+ emitter.on("fieldChanged", listenerB);
59
+
60
+ unsub();
61
+
62
+ emitter.fire("fieldChanged", {
63
+ path: "x",
64
+ value: 1,
65
+ previousValue: 0,
66
+ source: "user",
67
+ });
68
+
69
+ expect(listenerA).not.toHaveBeenCalled();
70
+ expect(listenerB).toHaveBeenCalled();
71
+ });
72
+
73
+ it("should report hasListeners correctly", () => {
74
+ expect(emitter.hasListeners("fieldChanged")).toBe(false);
75
+
76
+ const unsub = emitter.on("fieldChanged", () => {});
77
+ expect(emitter.hasListeners("fieldChanged")).toBe(true);
78
+
79
+ unsub();
80
+ expect(emitter.hasListeners("fieldChanged")).toBe(false);
81
+ });
82
+
83
+ it("should not throw when firing with no listeners", () => {
84
+ expect(() => {
85
+ emitter.fire("fieldChanged", {
86
+ path: "x",
87
+ value: 1,
88
+ previousValue: 0,
89
+ source: "user",
90
+ });
91
+ }).not.toThrow();
92
+ });
93
+
94
+ it("should catch listener errors without breaking other listeners", () => {
95
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
96
+ const goodListener = vi.fn();
97
+
98
+ emitter.on("fieldChanged", () => {
99
+ throw new Error("boom");
100
+ });
101
+ emitter.on("fieldChanged", goodListener);
102
+
103
+ emitter.fire("fieldChanged", {
104
+ path: "x",
105
+ value: 1,
106
+ previousValue: 0,
107
+ source: "user",
108
+ });
109
+
110
+ expect(goodListener).toHaveBeenCalled();
111
+ expect(consoleSpy).toHaveBeenCalled();
112
+ consoleSpy.mockRestore();
113
+ });
114
+
115
+ it("should await async listeners sequentially in fireAsync", async () => {
116
+ const order: number[] = [];
117
+
118
+ emitter.on("preSubmit", async () => {
119
+ await new Promise((r) => setTimeout(r, 10));
120
+ order.push(1);
121
+ });
122
+ emitter.on("preSubmit", async () => {
123
+ order.push(2);
124
+ });
125
+
126
+ await emitter.fireAsync("preSubmit", {
127
+ data: {},
128
+ computed: {},
129
+ });
130
+
131
+ expect(order).toEqual([1, 2]);
132
+ });
133
+
134
+ it("should clear all listeners", () => {
135
+ emitter.on("fieldChanged", () => {});
136
+ emitter.on("preSubmit", () => {});
137
+ emitter.clear();
138
+
139
+ expect(emitter.hasListeners("fieldChanged")).toBe(false);
140
+ expect(emitter.hasListeners("preSubmit")).toBe(false);
141
+ });
142
+ });
143
+
144
+ // ============================================================================
145
+ // fieldChanged event tests
146
+ // ============================================================================
147
+
148
+ describe("fieldChanged event", () => {
149
+ it("should fire with correct payload on setFieldValue", async () => {
150
+ const listener = vi.fn();
151
+ const spec = createTestSpec({
152
+ fields: { name: { type: "text" } },
153
+ });
154
+
155
+ const { result } = renderHook(() =>
156
+ useForma({
157
+ spec,
158
+ initialData: { name: "old" },
159
+ on: { fieldChanged: listener },
160
+ }),
161
+ );
162
+
163
+ act(() => {
164
+ result.current.setFieldValue("name", "new");
165
+ });
166
+
167
+ // fieldChanged fires in useEffect (after render)
168
+ expect(listener).toHaveBeenCalledWith({
169
+ path: "name",
170
+ value: "new",
171
+ previousValue: "old",
172
+ source: "user",
173
+ });
174
+ });
175
+
176
+ it("should have source 'setValues' for setValues", () => {
177
+ const listener = vi.fn();
178
+ const spec = createTestSpec({
179
+ fields: {
180
+ name: { type: "text" },
181
+ age: { type: "number" },
182
+ },
183
+ });
184
+
185
+ const { result } = renderHook(() =>
186
+ useForma({
187
+ spec,
188
+ initialData: { name: "old", age: 20 },
189
+ on: { fieldChanged: listener },
190
+ }),
191
+ );
192
+
193
+ act(() => {
194
+ result.current.setValues({ name: "new", age: 30 });
195
+ });
196
+
197
+ expect(listener).toHaveBeenCalledWith(
198
+ expect.objectContaining({ path: "name", source: "setValues" }),
199
+ );
200
+ expect(listener).toHaveBeenCalledWith(
201
+ expect.objectContaining({ path: "age", source: "setValues" }),
202
+ );
203
+ });
204
+
205
+ it("should have source 'reset' for resetForm", () => {
206
+ const listener = vi.fn();
207
+ const spec = createTestSpec({
208
+ fields: { name: { type: "text" } },
209
+ });
210
+
211
+ const { result } = renderHook(() =>
212
+ useForma({
213
+ spec,
214
+ initialData: { name: "initial" },
215
+ on: { fieldChanged: listener },
216
+ }),
217
+ );
218
+
219
+ // Change value first
220
+ act(() => {
221
+ result.current.setFieldValue("name", "changed");
222
+ });
223
+ listener.mockClear();
224
+
225
+ // Reset
226
+ act(() => {
227
+ result.current.resetForm();
228
+ });
229
+
230
+ expect(listener).toHaveBeenCalledWith(
231
+ expect.objectContaining({
232
+ path: "name",
233
+ value: "initial",
234
+ previousValue: "changed",
235
+ source: "reset",
236
+ }),
237
+ );
238
+ });
239
+
240
+ it("should NOT fire on initial mount", () => {
241
+ const listener = vi.fn();
242
+ const spec = createTestSpec({
243
+ fields: { name: { type: "text" } },
244
+ });
245
+
246
+ renderHook(() =>
247
+ useForma({
248
+ spec,
249
+ initialData: { name: "hello" },
250
+ on: { fieldChanged: listener },
251
+ }),
252
+ );
253
+
254
+ expect(listener).not.toHaveBeenCalled();
255
+ });
256
+
257
+ it("should NOT fire when value is set to same value", () => {
258
+ const listener = vi.fn();
259
+ const spec = createTestSpec({
260
+ fields: { name: { type: "text" } },
261
+ });
262
+
263
+ const { result } = renderHook(() =>
264
+ useForma({
265
+ spec,
266
+ initialData: { name: "same" },
267
+ on: { fieldChanged: listener },
268
+ }),
269
+ );
270
+
271
+ act(() => {
272
+ result.current.setFieldValue("name", "same");
273
+ });
274
+
275
+ expect(listener).not.toHaveBeenCalled();
276
+ });
277
+
278
+ it("should work with imperative forma.on()", () => {
279
+ const listener = vi.fn();
280
+ const spec = createTestSpec({
281
+ fields: { name: { type: "text" } },
282
+ });
283
+
284
+ const { result } = renderHook(() =>
285
+ useForma({ spec, initialData: { name: "old" } }),
286
+ );
287
+
288
+ // Register imperatively
289
+ let unsub: () => void;
290
+ act(() => {
291
+ unsub = result.current.on("fieldChanged", listener);
292
+ });
293
+
294
+ act(() => {
295
+ result.current.setFieldValue("name", "new");
296
+ });
297
+
298
+ expect(listener).toHaveBeenCalledWith(
299
+ expect.objectContaining({ path: "name", value: "new" }),
300
+ );
301
+
302
+ // Unsubscribe
303
+ listener.mockClear();
304
+ act(() => {
305
+ unsub();
306
+ });
307
+
308
+ act(() => {
309
+ result.current.setFieldValue("name", "newer");
310
+ });
311
+
312
+ expect(listener).not.toHaveBeenCalled();
313
+ });
314
+
315
+ it("should not cause infinite loop when setFieldValue called inside handler", () => {
316
+ const spec = createTestSpec({
317
+ fields: {
318
+ name: { type: "text" },
319
+ mirror: { type: "text" },
320
+ },
321
+ });
322
+
323
+ const calls: string[] = [];
324
+
325
+ const { result } = renderHook(() =>
326
+ useForma({
327
+ spec,
328
+ initialData: { name: "", mirror: "" },
329
+ on: {
330
+ fieldChanged: (event) => {
331
+ calls.push(event.path);
332
+ // This would cause infinite loop without recursion guard
333
+ if (event.path === "name") {
334
+ result.current.setFieldValue("mirror", event.value);
335
+ }
336
+ },
337
+ },
338
+ }),
339
+ );
340
+
341
+ act(() => {
342
+ result.current.setFieldValue("name", "hello");
343
+ });
344
+
345
+ // Only "name" fires (mirror is blocked by recursion guard)
346
+ expect(calls).toEqual(["name"]);
347
+ });
348
+ });
349
+
350
+ // ============================================================================
351
+ // preSubmit event tests
352
+ // ============================================================================
353
+
354
+ describe("preSubmit event", () => {
355
+ it("should fire before validation with current data", async () => {
356
+ const listener = vi.fn();
357
+ const onSubmit = vi.fn();
358
+ const spec = createTestSpec({
359
+ fields: { name: { type: "text" } },
360
+ });
361
+
362
+ const { result } = renderHook(() =>
363
+ useForma({
364
+ spec,
365
+ initialData: { name: "John" },
366
+ onSubmit,
367
+ on: { preSubmit: listener },
368
+ }),
369
+ );
370
+
371
+ await act(async () => {
372
+ await result.current.submitForm();
373
+ });
374
+
375
+ expect(listener).toHaveBeenCalledWith({
376
+ data: expect.objectContaining({ name: "John" }),
377
+ computed: expect.any(Object),
378
+ });
379
+ expect(listener).toHaveBeenCalledBefore(onSubmit);
380
+ });
381
+
382
+ it("should allow mutating data before validation/submit", async () => {
383
+ const onSubmit = vi.fn();
384
+ const spec = createTestSpec({
385
+ fields: { name: { type: "text" } },
386
+ });
387
+
388
+ const { result } = renderHook(() =>
389
+ useForma({
390
+ spec,
391
+ initialData: { name: "John" },
392
+ onSubmit,
393
+ on: {
394
+ preSubmit: (event) => {
395
+ event.data.token = "injected-token";
396
+ },
397
+ },
398
+ }),
399
+ );
400
+
401
+ await act(async () => {
402
+ await result.current.submitForm();
403
+ });
404
+
405
+ expect(onSubmit).toHaveBeenCalledWith(
406
+ expect.objectContaining({ token: "injected-token" }),
407
+ );
408
+ });
409
+
410
+ it("should await async handler", async () => {
411
+ const onSubmit = vi.fn();
412
+ const spec = createTestSpec({
413
+ fields: { name: { type: "text" } },
414
+ });
415
+
416
+ const { result } = renderHook(() =>
417
+ useForma({
418
+ spec,
419
+ initialData: { name: "John" },
420
+ onSubmit,
421
+ on: {
422
+ preSubmit: async (event) => {
423
+ event.data.asyncToken = await Promise.resolve("async-value");
424
+ },
425
+ },
426
+ }),
427
+ );
428
+
429
+ await act(async () => {
430
+ await result.current.submitForm();
431
+ });
432
+
433
+ expect(onSubmit).toHaveBeenCalledWith(
434
+ expect.objectContaining({ asyncToken: "async-value" }),
435
+ );
436
+ });
437
+
438
+ it("should work without preSubmit listener (backward compat)", async () => {
439
+ const onSubmit = vi.fn();
440
+ const spec = createTestSpec({
441
+ fields: { name: { type: "text" } },
442
+ });
443
+
444
+ const { result } = renderHook(() =>
445
+ useForma({ spec, initialData: { name: "John" }, onSubmit }),
446
+ );
447
+
448
+ await act(async () => {
449
+ await result.current.submitForm();
450
+ });
451
+
452
+ expect(onSubmit).toHaveBeenCalledWith(
453
+ expect.objectContaining({ name: "John" }),
454
+ );
455
+ });
456
+ });
457
+
458
+ // ============================================================================
459
+ // postSubmit event tests
460
+ // ============================================================================
461
+
462
+ describe("postSubmit event", () => {
463
+ it("should fire with success:true after successful submit", async () => {
464
+ const listener = vi.fn();
465
+ const spec = createTestSpec({
466
+ fields: { name: { type: "text" } },
467
+ });
468
+
469
+ const { result } = renderHook(() =>
470
+ useForma({
471
+ spec,
472
+ initialData: { name: "John" },
473
+ onSubmit: async () => {},
474
+ on: { postSubmit: listener },
475
+ }),
476
+ );
477
+
478
+ await act(async () => {
479
+ await result.current.submitForm();
480
+ });
481
+
482
+ expect(listener).toHaveBeenCalledWith(
483
+ expect.objectContaining({ success: true }),
484
+ );
485
+ });
486
+
487
+ it("should fire with success:false and error when onSubmit throws", async () => {
488
+ const listener = vi.fn();
489
+ const spec = createTestSpec({
490
+ fields: { name: { type: "text" } },
491
+ });
492
+
493
+ const { result } = renderHook(() =>
494
+ useForma({
495
+ spec,
496
+ initialData: { name: "John" },
497
+ onSubmit: async () => {
498
+ throw new Error("submit failed");
499
+ },
500
+ on: { postSubmit: listener },
501
+ }),
502
+ );
503
+
504
+ await act(async () => {
505
+ await result.current.submitForm();
506
+ });
507
+
508
+ expect(listener).toHaveBeenCalledWith(
509
+ expect.objectContaining({
510
+ success: false,
511
+ error: expect.any(Error),
512
+ }),
513
+ );
514
+ });
515
+
516
+ it("should fire with validationErrors when validation fails", async () => {
517
+ const listener = vi.fn();
518
+ const spec = createTestSpec({
519
+ fields: { name: { type: "text", required: true } },
520
+ });
521
+
522
+ const { result } = renderHook(() =>
523
+ useForma({
524
+ spec,
525
+ onSubmit: async () => {},
526
+ on: { postSubmit: listener },
527
+ }),
528
+ );
529
+
530
+ await act(async () => {
531
+ await result.current.submitForm();
532
+ });
533
+
534
+ expect(listener).toHaveBeenCalledWith(
535
+ expect.objectContaining({
536
+ success: false,
537
+ validationErrors: expect.any(Array),
538
+ }),
539
+ );
540
+ });
541
+ });
542
+
543
+ // ============================================================================
544
+ // formReset event tests
545
+ // ============================================================================
546
+
547
+ describe("formReset event", () => {
548
+ it("should fire after resetForm()", () => {
549
+ const listener = vi.fn();
550
+ const spec = createTestSpec({
551
+ fields: { name: { type: "text" } },
552
+ });
553
+
554
+ const { result } = renderHook(() =>
555
+ useForma({
556
+ spec,
557
+ initialData: { name: "initial" },
558
+ on: { formReset: listener },
559
+ }),
560
+ );
561
+
562
+ // Change something first to make form dirty
563
+ act(() => {
564
+ result.current.setFieldValue("name", "changed");
565
+ });
566
+
567
+ act(() => {
568
+ result.current.resetForm();
569
+ });
570
+
571
+ expect(listener).toHaveBeenCalled();
572
+ });
573
+
574
+ it("should fire fieldChanged before formReset (ordering)", () => {
575
+ const events: string[] = [];
576
+ const spec = createTestSpec({
577
+ fields: { name: { type: "text" } },
578
+ });
579
+
580
+ const { result } = renderHook(() =>
581
+ useForma({
582
+ spec,
583
+ initialData: { name: "initial" },
584
+ on: {
585
+ fieldChanged: () => { events.push("fieldChanged"); },
586
+ formReset: () => { events.push("formReset"); },
587
+ },
588
+ }),
589
+ );
590
+
591
+ act(() => {
592
+ result.current.setFieldValue("name", "changed");
593
+ });
594
+ events.length = 0; // clear initial fieldChanged
595
+
596
+ act(() => {
597
+ result.current.resetForm();
598
+ });
599
+
600
+ expect(events).toEqual(["fieldChanged", "formReset"]);
601
+ });
602
+ });
603
+
604
+ // ============================================================================
605
+ // pageChanged event tests
606
+ // ============================================================================
607
+
608
+ describe("pageChanged event", () => {
609
+ const wizardSpec = createTestSpec({
610
+ fields: {
611
+ name: { type: "text" },
612
+ age: { type: "number" },
613
+ email: { type: "text" },
614
+ },
615
+ pages: [
616
+ { id: "page1", title: "Page 1", fields: ["name"] },
617
+ { id: "page2", title: "Page 2", fields: ["age"] },
618
+ { id: "page3", title: "Page 3", fields: ["email"] },
619
+ ],
620
+ });
621
+
622
+ it("should fire on nextPage()", () => {
623
+ const listener = vi.fn();
624
+
625
+ const { result } = renderHook(() =>
626
+ useForma({ spec: wizardSpec, on: { pageChanged: listener } }),
627
+ );
628
+
629
+ act(() => {
630
+ result.current.wizard!.nextPage();
631
+ });
632
+
633
+ expect(listener).toHaveBeenCalledWith(
634
+ expect.objectContaining({
635
+ fromIndex: 0,
636
+ toIndex: 1,
637
+ }),
638
+ );
639
+ });
640
+
641
+ it("should fire on previousPage()", () => {
642
+ const listener = vi.fn();
643
+
644
+ const { result } = renderHook(() =>
645
+ useForma({ spec: wizardSpec, on: { pageChanged: listener } }),
646
+ );
647
+
648
+ // Go to page 2 first
649
+ act(() => {
650
+ result.current.wizard!.nextPage();
651
+ });
652
+ listener.mockClear();
653
+
654
+ act(() => {
655
+ result.current.wizard!.previousPage();
656
+ });
657
+
658
+ expect(listener).toHaveBeenCalledWith(
659
+ expect.objectContaining({
660
+ fromIndex: 1,
661
+ toIndex: 0,
662
+ }),
663
+ );
664
+ });
665
+
666
+ it("should fire on goToPage()", () => {
667
+ const listener = vi.fn();
668
+
669
+ const { result } = renderHook(() =>
670
+ useForma({ spec: wizardSpec, on: { pageChanged: listener } }),
671
+ );
672
+
673
+ act(() => {
674
+ result.current.wizard!.goToPage(2);
675
+ });
676
+
677
+ expect(listener).toHaveBeenCalledWith(
678
+ expect.objectContaining({
679
+ fromIndex: 0,
680
+ toIndex: 2,
681
+ }),
682
+ );
683
+ });
684
+
685
+ it("should NOT fire on initial render", () => {
686
+ const listener = vi.fn();
687
+
688
+ renderHook(() =>
689
+ useForma({ spec: wizardSpec, on: { pageChanged: listener } }),
690
+ );
691
+
692
+ expect(listener).not.toHaveBeenCalled();
693
+ });
694
+
695
+ it("should include page state in payload", () => {
696
+ const listener = vi.fn();
697
+
698
+ const { result } = renderHook(() =>
699
+ useForma({ spec: wizardSpec, on: { pageChanged: listener } }),
700
+ );
701
+
702
+ act(() => {
703
+ result.current.wizard!.nextPage();
704
+ });
705
+
706
+ expect(listener).toHaveBeenCalledWith(
707
+ expect.objectContaining({
708
+ page: expect.objectContaining({
709
+ id: "page2",
710
+ title: "Page 2",
711
+ }),
712
+ }),
713
+ );
714
+ });
715
+ });
716
+
717
+ // ============================================================================
718
+ // Backward compatibility
719
+ // ============================================================================
720
+
721
+ describe("backward compatibility", () => {
722
+ it("should work identically without on option", () => {
723
+ const spec = createTestSpec({
724
+ fields: { name: { type: "text" } },
725
+ });
726
+
727
+ const { result } = renderHook(() =>
728
+ useForma({ spec, initialData: { name: "test" } }),
729
+ );
730
+
731
+ // Basic operations should work
732
+ act(() => {
733
+ result.current.setFieldValue("name", "changed");
734
+ });
735
+ expect(result.current.data.name).toBe("changed");
736
+
737
+ act(() => {
738
+ result.current.resetForm();
739
+ });
740
+ expect(result.current.data.name).toBe("test");
741
+ });
742
+
743
+ it("should expose on() method on return object", () => {
744
+ const spec = createTestSpec({
745
+ fields: { name: { type: "text" } },
746
+ });
747
+
748
+ const { result } = renderHook(() => useForma({ spec }));
749
+
750
+ expect(typeof result.current.on).toBe("function");
751
+ });
752
+ });