Skip to content

Commit c8d2a21

Browse files
CopilotCopilot
andcommitted
Add tests for Phase 24 hooks
Add test files for the 5 Phase 24 interaction hooks: - useDndProtocol: drag-and-drop config, state, zone validation, drop effects - useGestureProtocol: gesture registration, touch targets, active gesture - useAnimationProtocol: animation/transition registration, reduced motion - usePageTransitionProtocol: presets, default/current transitions - useComponentAnimation: per-component animation config, partial updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent eedec1f commit c8d2a21

5 files changed

Lines changed: 625 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Tests for useAnimationProtocol – validates animation and transition
3+
* registration, removal, retrieval, and reduced-motion awareness.
4+
*/
5+
import { renderHook, act } from "@testing-library/react-native";
6+
import { useAnimationProtocol, AnimationConfig, TransitionConfig } from "~/hooks/useAnimationProtocol";
7+
8+
const fadeIn: AnimationConfig = {
9+
type: "fade",
10+
duration: 300,
11+
delay: 0,
12+
easing: "ease-in-out",
13+
iterations: 1,
14+
};
15+
16+
const slideUp: TransitionConfig = {
17+
property: "translateY",
18+
from: 100,
19+
to: 0,
20+
duration: 250,
21+
easing: "ease-out",
22+
};
23+
24+
describe("useAnimationProtocol", () => {
25+
it("returns default initial state", () => {
26+
const { result } = renderHook(() => useAnimationProtocol());
27+
28+
expect(result.current.animations.size).toBe(0);
29+
expect(result.current.transitions.size).toBe(0);
30+
expect(result.current.motionConfig).toEqual({
31+
reducedMotion: false,
32+
prefersContrast: false,
33+
animationScale: 1,
34+
});
35+
expect(result.current.isReduced).toBe(false);
36+
});
37+
38+
it("registers and retrieves an animation", () => {
39+
const { result } = renderHook(() => useAnimationProtocol());
40+
41+
act(() => {
42+
result.current.registerAnimation("fadeIn", fadeIn);
43+
});
44+
45+
expect(result.current.animations.size).toBe(1);
46+
expect(result.current.getAnimation("fadeIn")).toEqual(fadeIn);
47+
});
48+
49+
it("removes an animation", () => {
50+
const { result } = renderHook(() => useAnimationProtocol());
51+
52+
act(() => {
53+
result.current.registerAnimation("fadeIn", fadeIn);
54+
});
55+
56+
act(() => {
57+
result.current.removeAnimation("fadeIn");
58+
});
59+
60+
expect(result.current.animations.size).toBe(0);
61+
expect(result.current.getAnimation("fadeIn")).toBeUndefined();
62+
});
63+
64+
it("registers and retrieves a transition", () => {
65+
const { result } = renderHook(() => useAnimationProtocol());
66+
67+
act(() => {
68+
result.current.registerTransition("slideUp", slideUp);
69+
});
70+
71+
expect(result.current.transitions.size).toBe(1);
72+
expect(result.current.getTransition("slideUp")).toEqual(slideUp);
73+
});
74+
75+
it("removes a transition", () => {
76+
const { result } = renderHook(() => useAnimationProtocol());
77+
78+
act(() => {
79+
result.current.registerTransition("slideUp", slideUp);
80+
});
81+
82+
act(() => {
83+
result.current.removeTransition("slideUp");
84+
});
85+
86+
expect(result.current.transitions.size).toBe(0);
87+
expect(result.current.getTransition("slideUp")).toBeUndefined();
88+
});
89+
90+
it("sets motion config and computes isReduced", () => {
91+
const { result } = renderHook(() => useAnimationProtocol());
92+
93+
act(() => {
94+
result.current.setMotionConfig({
95+
reducedMotion: true,
96+
prefersContrast: true,
97+
animationScale: 0.5,
98+
});
99+
});
100+
101+
expect(result.current.isReduced).toBe(true);
102+
expect(result.current.motionConfig.animationScale).toBe(0.5);
103+
});
104+
105+
it("returns undefined for unregistered ids", () => {
106+
const { result } = renderHook(() => useAnimationProtocol());
107+
108+
expect(result.current.getAnimation("nonexistent")).toBeUndefined();
109+
expect(result.current.getTransition("nonexistent")).toBeUndefined();
110+
});
111+
112+
it("replaces an existing animation config", () => {
113+
const { result } = renderHook(() => useAnimationProtocol());
114+
115+
act(() => {
116+
result.current.registerAnimation("fadeIn", fadeIn);
117+
});
118+
119+
const updated: AnimationConfig = { ...fadeIn, duration: 500 };
120+
121+
act(() => {
122+
result.current.registerAnimation("fadeIn", updated);
123+
});
124+
125+
expect(result.current.animations.size).toBe(1);
126+
expect(result.current.getAnimation("fadeIn")!.duration).toBe(500);
127+
});
128+
});
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Tests for useComponentAnimation – validates per-component animation
3+
* registration, removal, partial updates, and computed component list.
4+
*/
5+
import { renderHook, act } from "@testing-library/react-native";
6+
import { useComponentAnimation, ComponentAnimationConfig } from "~/hooks/useComponentAnimation";
7+
8+
const cardConfig: ComponentAnimationConfig = {
9+
componentId: "card-1",
10+
enter: { duration: 200, easing: { type: "ease_in" } },
11+
exit: { duration: 150, easing: { type: "ease_out" } },
12+
};
13+
14+
const buttonConfig: ComponentAnimationConfig = {
15+
componentId: "btn-1",
16+
hover: { duration: 100, easing: { type: "linear" } },
17+
disabled: false,
18+
};
19+
20+
describe("useComponentAnimation", () => {
21+
it("returns empty initial state", () => {
22+
const { result } = renderHook(() => useComponentAnimation());
23+
24+
expect(result.current.componentAnimations.size).toBe(0);
25+
expect(result.current.registeredComponents).toEqual([]);
26+
});
27+
28+
it("sets and retrieves a component animation", () => {
29+
const { result } = renderHook(() => useComponentAnimation());
30+
31+
act(() => {
32+
result.current.setComponentAnimation("card-1", cardConfig);
33+
});
34+
35+
expect(result.current.componentAnimations.size).toBe(1);
36+
expect(result.current.getComponentAnimation("card-1")).toEqual(cardConfig);
37+
expect(result.current.registeredComponents).toEqual(["card-1"]);
38+
});
39+
40+
it("removes a component animation", () => {
41+
const { result } = renderHook(() => useComponentAnimation());
42+
43+
act(() => {
44+
result.current.setComponentAnimation("card-1", cardConfig);
45+
result.current.setComponentAnimation("btn-1", buttonConfig);
46+
});
47+
48+
act(() => {
49+
result.current.removeComponentAnimation("card-1");
50+
});
51+
52+
expect(result.current.componentAnimations.size).toBe(1);
53+
expect(result.current.getComponentAnimation("card-1")).toBeUndefined();
54+
expect(result.current.registeredComponents).toEqual(["btn-1"]);
55+
});
56+
57+
it("updates only the enter animation", () => {
58+
const { result } = renderHook(() => useComponentAnimation());
59+
60+
act(() => {
61+
result.current.setComponentAnimation("card-1", cardConfig);
62+
});
63+
64+
const newEnter = { duration: 400, easing: { type: "spring" as const } };
65+
66+
act(() => {
67+
result.current.updateEnterAnimation("card-1", newEnter);
68+
});
69+
70+
const updated = result.current.getComponentAnimation("card-1")!;
71+
expect(updated.enter).toEqual(newEnter);
72+
expect(updated.exit).toEqual(cardConfig.exit);
73+
});
74+
75+
it("updates only the exit animation", () => {
76+
const { result } = renderHook(() => useComponentAnimation());
77+
78+
act(() => {
79+
result.current.setComponentAnimation("card-1", cardConfig);
80+
});
81+
82+
const newExit = { duration: 500, easing: { type: "ease_in_out" as const } };
83+
84+
act(() => {
85+
result.current.updateExitAnimation("card-1", newExit);
86+
});
87+
88+
const updated = result.current.getComponentAnimation("card-1")!;
89+
expect(updated.exit).toEqual(newExit);
90+
expect(updated.enter).toEqual(cardConfig.enter);
91+
});
92+
93+
it("updateEnterAnimation is a no-op for unregistered component", () => {
94+
const { result } = renderHook(() => useComponentAnimation());
95+
96+
act(() => {
97+
result.current.updateEnterAnimation("unknown", { duration: 100, easing: { type: "linear" } });
98+
});
99+
100+
expect(result.current.componentAnimations.size).toBe(0);
101+
});
102+
103+
it("returns undefined for unregistered component id", () => {
104+
const { result } = renderHook(() => useComponentAnimation());
105+
106+
expect(result.current.getComponentAnimation("nonexistent")).toBeUndefined();
107+
});
108+
109+
it("replaces an existing component config", () => {
110+
const { result } = renderHook(() => useComponentAnimation());
111+
112+
act(() => {
113+
result.current.setComponentAnimation("card-1", cardConfig);
114+
});
115+
116+
const updated: ComponentAnimationConfig = { ...cardConfig, disabled: true };
117+
118+
act(() => {
119+
result.current.setComponentAnimation("card-1", updated);
120+
});
121+
122+
expect(result.current.componentAnimations.size).toBe(1);
123+
expect(result.current.getComponentAnimation("card-1")!.disabled).toBe(true);
124+
});
125+
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* Tests for useDndProtocol – validates drag-and-drop configuration,
3+
* drag state tracking, drop-zone validation, and drop-effect resolution.
4+
*/
5+
import { renderHook, act } from "@testing-library/react-native";
6+
import { useDndProtocol, DndConfig, DragItem } from "~/hooks/useDndProtocol";
7+
8+
const sampleConfig: DndConfig = {
9+
zones: [
10+
{ id: "trash", accepts: ["card", "image"] },
11+
{ id: "favorites", accepts: ["card"] },
12+
],
13+
defaultDropEffect: "move",
14+
};
15+
16+
const sampleItem: DragItem = { id: "c1", type: "card", data: { title: "Hello" } };
17+
18+
describe("useDndProtocol", () => {
19+
it("returns null/empty initial state", () => {
20+
const { result } = renderHook(() => useDndProtocol());
21+
22+
expect(result.current.config).toBeNull();
23+
expect(result.current.activeItem).toBeNull();
24+
expect(result.current.activeZone).toBeNull();
25+
expect(result.current.isDragging).toBe(false);
26+
});
27+
28+
it("sets config", () => {
29+
const { result } = renderHook(() => useDndProtocol());
30+
31+
act(() => {
32+
result.current.setConfig(sampleConfig);
33+
});
34+
35+
expect(result.current.config).toEqual(sampleConfig);
36+
expect(result.current.config!.zones).toHaveLength(2);
37+
});
38+
39+
it("starts and ends a drag operation", () => {
40+
const { result } = renderHook(() => useDndProtocol());
41+
42+
act(() => {
43+
result.current.startDrag(sampleItem);
44+
});
45+
46+
expect(result.current.activeItem).toEqual(sampleItem);
47+
expect(result.current.isDragging).toBe(true);
48+
49+
act(() => {
50+
result.current.endDrag();
51+
});
52+
53+
expect(result.current.activeItem).toBeNull();
54+
expect(result.current.isDragging).toBe(false);
55+
});
56+
57+
it("sets and clears the active zone", () => {
58+
const { result } = renderHook(() => useDndProtocol());
59+
60+
act(() => {
61+
result.current.setActiveZone("trash");
62+
});
63+
64+
expect(result.current.activeZone).toBe("trash");
65+
66+
act(() => {
67+
result.current.setActiveZone(null);
68+
});
69+
70+
expect(result.current.activeZone).toBeNull();
71+
});
72+
73+
it("endDrag clears both activeItem and activeZone", () => {
74+
const { result } = renderHook(() => useDndProtocol());
75+
76+
act(() => {
77+
result.current.startDrag(sampleItem);
78+
result.current.setActiveZone("trash");
79+
});
80+
81+
act(() => {
82+
result.current.endDrag();
83+
});
84+
85+
expect(result.current.activeItem).toBeNull();
86+
expect(result.current.activeZone).toBeNull();
87+
});
88+
89+
it("canDrop returns true when zone accepts item type", () => {
90+
const { result } = renderHook(() => useDndProtocol());
91+
92+
act(() => {
93+
result.current.setConfig(sampleConfig);
94+
});
95+
96+
expect(result.current.canDrop("card", "trash")).toBe(true);
97+
expect(result.current.canDrop("image", "trash")).toBe(true);
98+
expect(result.current.canDrop("card", "favorites")).toBe(true);
99+
});
100+
101+
it("canDrop returns false for unaccepted types or missing config", () => {
102+
const { result } = renderHook(() => useDndProtocol());
103+
104+
// No config set
105+
expect(result.current.canDrop("card", "trash")).toBe(false);
106+
107+
act(() => {
108+
result.current.setConfig(sampleConfig);
109+
});
110+
111+
expect(result.current.canDrop("image", "favorites")).toBe(false);
112+
expect(result.current.canDrop("card", "nonexistent")).toBe(false);
113+
});
114+
115+
it("getDropEffect returns configured effect or none", () => {
116+
const { result } = renderHook(() => useDndProtocol());
117+
118+
// No config → "none"
119+
expect(result.current.getDropEffect("card", "trash")).toBe("none");
120+
121+
act(() => {
122+
result.current.setConfig(sampleConfig);
123+
});
124+
125+
expect(result.current.getDropEffect("card", "trash")).toBe("move");
126+
expect(result.current.getDropEffect("image", "favorites")).toBe("none");
127+
128+
act(() => {
129+
result.current.setConfig({ zones: [{ id: "z", accepts: ["a"] }] });
130+
});
131+
132+
// No defaultDropEffect → falls back to "move"
133+
expect(result.current.getDropEffect("a", "z")).toBe("move");
134+
});
135+
});

0 commit comments

Comments
 (0)