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