import React from "react"; import { AccessibilityInfo } from "react-native"; import { render } from "@testing-library/react-native"; import { useReducedMotion } from "react-native-reanimated"; import type { ReactTestInstance } from "react-test-renderer"; import { ActivityIndicator } from "./index"; // `useReducedMotion` is mocked globally in src/__mocks__/__mocks.ts and // defaults to returning `false`. Tests that exercise the reduced-motion // branch override the return value below. const testId = "ActivityIndicator"; describe("ActivityIndicator", () => { beforeEach(() => { (useReducedMotion as jest.Mock).mockReturnValue(false); jest.mocked(AccessibilityInfo.announceForAccessibility).mockClear(); }); describe("default rendering", () => { it("renders with the default testID", () => { const { getByTestId } = render(); expect(getByTestId(testId)).toBeTruthy(); }); it("renders with the localized 'Loading' label by default", () => { const { getByTestId } = render(); expect(getByTestId(testId).props.accessibilityLabel).toBe("Loading"); }); it("renders with the progressbar accessibility role and busy state", () => { const { getByTestId } = render(); const root = getByTestId(testId); expect(root.props.accessibilityRole).toBe("progressbar"); expect(root.props.accessibilityState).toEqual({ busy: true }); }); it("does not set accessibilityLiveRegion", () => { const { getByTestId } = render(); expect(getByTestId(testId).props.accessibilityLiveRegion).toBeUndefined(); }); it("renders at the base (44px) size by default", () => { const { getByTestId } = render(); const root = getByTestId(testId).children[0] as ReactTestInstance; const flattenedStyle = Array.isArray(root.props.style) ? Object.assign({}, ...root.props.style.flat(Infinity).filter(Boolean)) : root.props.style; expect(flattenedStyle.width).toBe(44); expect(flattenedStyle.height).toBe(44); }); }); describe("size variants", () => { it("renders the small ring at 28px", () => { const { getByTestId } = render(); const root = getByTestId(testId).children[0] as ReactTestInstance; const flattenedStyle = Array.isArray(root.props.style) ? Object.assign({}, ...root.props.style.flat(Infinity).filter(Boolean)) : root.props.style; expect(flattenedStyle.width).toBe(28); expect(flattenedStyle.height).toBe(28); }); it("renders the base ring at 44px when size is explicit", () => { const { getByTestId } = render(); const root = getByTestId(testId).children[0] as ReactTestInstance; const flattenedStyle = Array.isArray(root.props.style) ? Object.assign({}, ...root.props.style.flat(Infinity).filter(Boolean)) : root.props.style; expect(flattenedStyle.width).toBe(44); expect(flattenedStyle.height).toBe(44); }); it("uses a fixed-length arc for small (no Layer 2/3 wrappers)", () => { const { getByTestId } = render(); const outer = getByTestId(testId).children[0] as ReactTestInstance; const outerChildren = React.Children.toArray(outer.props.children); function typeName(node: unknown): string { if (!node || typeof node !== "object") return ""; const element = node as { type?: { displayName?: string; name?: string }; }; return element.type?.displayName || element.type?.name || ""; } // Small: outer rotation wrapper → Svg directly (no inner Layer 2 view). expect( outerChildren.some(child => typeName(child).startsWith("Svg")), ).toBe(true); function findArcCircle(node: unknown): ReactTestInstance | undefined { if (!node || typeof node !== "object") return undefined; const element = node as ReactTestInstance; const name = typeName(element); if ( (name === "Circle" || name === "RNSVGCircle") && element.props.strokeDasharray === "40, 200" ) { return element; } const kids = React.Children.toArray(element.props?.children ?? []); for (const kid of kids) { const found = findArcCircle(kid); if (found) return found; } return undefined; } const arc = findArcCircle(outer); expect(arc).toBeTruthy(); expect(arc?.props.strokeDashoffset).toBe(0); }); }); describe('deprecated size="large" alias', () => { let warnSpy: jest.SpyInstance; beforeEach(() => { warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined); }); afterEach(() => { warnSpy.mockRestore(); }); it('maps size="large" to size="base" visually', () => { const { getByTestId } = render(); const root = getByTestId(testId).children[0] as ReactTestInstance; const flattenedStyle = Array.isArray(root.props.style) ? Object.assign({}, ...root.props.style.flat(Infinity).filter(Boolean)) : root.props.style; expect(flattenedStyle.width).toBe(44); expect(flattenedStyle.height).toBe(44); }); it('warns once when size="large" is passed (in development)', () => { render(); expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('size="large" is deprecated'), ); }); it('does not warn for size="small" or size="base"', () => { render(); render(); render(); expect(warnSpy).not.toHaveBeenCalled(); }); }); describe("accessibility", () => { it("uses a custom accessibilityLabel when supplied", () => { const { getByTestId } = render( , ); expect(getByTestId(testId).props.accessibilityLabel).toBe( "Uploading file", ); }); it("announces the resolved label on mount", () => { render(); expect(AccessibilityInfo.announceForAccessibility).toHaveBeenCalledTimes( 1, ); expect(AccessibilityInfo.announceForAccessibility).toHaveBeenCalledWith( "Loading", ); }); it("announces a custom label on mount", () => { render(); expect(AccessibilityInfo.announceForAccessibility).toHaveBeenCalledWith( "Uploading file", ); }); it("re-announces when the label prop changes", () => { const { rerender } = render( , ); jest.mocked(AccessibilityInfo.announceForAccessibility).mockClear(); rerender(); expect(AccessibilityInfo.announceForAccessibility).toHaveBeenCalledWith( "Loading jobs", ); }); }); describe("style passthrough", () => { it("merges a supplied style onto the root view", () => { const { getByTestId } = render( , ); const root = getByTestId(testId); const flattenedStyle = Array.isArray(root.props.style) ? Object.assign({}, ...root.props.style.flat(Infinity).filter(Boolean)) : root.props.style; expect(flattenedStyle.marginTop).toBe(8); }); }); describe("custom testID", () => { it("uses the supplied testID on the root", () => { const { getByTestId } = render(); expect(getByTestId("my-loader")).toBeTruthy(); }); }); describe("reduced motion", () => { it("renders the reduced-motion ring when the OS setting is on", () => { (useReducedMotion as jest.Mock).mockReturnValue(true); const { getByTestId } = render(); const root = getByTestId(testId).children[0] as ReactTestInstance; // The reduced-motion presentation renders a single static // wrapped in one Animated.View — the full-motion presentation renders // three nested Animated.Views. We can distinguish by the children // count on the root. // The root in both cases is an Animated.View, so we assert by // examining the SVG depth: reduced-motion has SVG as a direct child. // Easiest: rely on absence of the nested rotation wrappers. const directChildren = React.Children.toArray(root.props.children); // ReducedMotionRing → root contains the directly. // FullMotionRing → root contains an Animated.View (outer rotation) // that itself contains another Animated.View, then the . // Walk the tree to confirm. function hasDirectSvgChild(node: unknown): boolean { if (!node || typeof node !== "object") return false; const element = node as { type?: { displayName?: string; name?: string }; }; const typeName = element.type?.displayName || element.type?.name || ""; return typeName === "Svg" || typeName === "SvgRoot"; } expect(directChildren.some(hasDirectSvgChild)).toBe(true); }); it("still announces the label in reduced-motion mode", () => { (useReducedMotion as jest.Mock).mockReturnValue(true); render(); expect(AccessibilityInfo.announceForAccessibility).toHaveBeenCalledWith( "Saving", ); }); it("preserves the accessibility contract in reduced-motion mode", () => { (useReducedMotion as jest.Mock).mockReturnValue(true); const { getByTestId } = render(); const root = getByTestId(testId); expect(root.props.accessibilityRole).toBe("progressbar"); expect(root.props.accessibilityState).toEqual({ busy: true }); expect(root.props.accessibilityLabel).toBe("Loading"); }); }); });