import React from "react";
import { act, renderHook } from "@testing-library/react-native";
import type * as TestingLibrary from "@testing-library/react-native";
import {
androidTokens,
darkAndroidTokens,
darkIosTokens,
iosTokens,
} from "@jobber/design";
import merge from "lodash/merge";
import {
type ColorSchemeName,
type Platform as PlatformModule,
useColorScheme,
} from "react-native";
import {
AtlantisThemeContextProvider,
useAtlantisTheme,
} from "./AtlantisThemeContext";
import type * as ThemeContextModule from "./AtlantisThemeContext";
import type {
AtlantisThemeContextProviderProps,
EffectiveTheme,
} from "./types";
jest.mock("react-native", () => {
const actual = jest.requireActual("react-native");
Object.defineProperty(actual, "useColorScheme", {
configurable: true,
value: jest.fn(),
});
return actual;
});
const expectedDarkTokens = merge({}, iosTokens, darkIosTokens);
const expectedLightTokens = iosTokens;
const mockUseColorScheme = useColorScheme as jest.MockedFunction<
typeof useColorScheme
>;
function Wrapper({
children,
theme,
onThemeChange,
dangerouslyOverrideTheme,
}: AtlantisThemeContextProviderProps) {
return (
{children}
);
}
function WrapperWithOverride({
children,
dangerouslyOverrideTheme,
}: AtlantisThemeContextProviderProps) {
return (
{children}
);
}
describe("ThemeContext", () => {
beforeEach(() => {
mockUseColorScheme.mockReturnValue("light");
});
it("defaults to the light theme", () => {
const { result } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
),
});
expect(result.current.theme).toBe("light");
expect(result.current.effectiveTheme).toBe("light");
expect(result.current.tokens).toEqual(expectedLightTokens);
});
it("updates the theme and tokens", async () => {
const { result } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
),
});
await act(async () => result.current.setTheme("dark"));
expect(result.current.theme).toBe("dark");
expect(result.current.effectiveTheme).toBe("dark");
expect(result.current.tokens).toEqual(expectedDarkTokens);
await act(async () => result.current.setTheme("light"));
expect(result.current.theme).toBe("light");
expect(result.current.effectiveTheme).toBe("light");
expect(result.current.tokens).toEqual(expectedLightTokens);
});
it.each(["shadow-low", "shadow-base", "shadow-high"] as const)(
"uses the dark platform-native %s token in dark mode",
async shadowToken => {
const { result } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
),
});
await act(async () => result.current.setTheme("dark"));
expect(result.current.tokens[shadowToken]).toEqual(
darkIosTokens[shadowToken],
);
expect(result.current.tokens[shadowToken]).not.toEqual(
iosTokens[shadowToken],
);
expect(typeof result.current.tokens[shadowToken]).toBe("object");
},
);
describe("on android", () => {
let android!: typeof TestingLibrary & typeof ThemeContextModule;
// The provider picks platform tokens at import time, so it is loaded in an
// isolated registry after Platform is switched to android. This runs while
// collecting tests because the testing library registers cleanup hooks.
jest.isolateModules(() => {
const {
Platform,
}: { Platform: typeof PlatformModule } = require("react-native");
jest.replaceProperty(Platform, "OS", "android");
jest.spyOn(Platform, "select").mockImplementation(spec => spec.android);
android = {
...require("@testing-library/react-native"),
...require("./AtlantisThemeContext"),
};
});
it.each(["shadow-low", "shadow-base", "shadow-high"] as const)(
"uses the android %s token in light and dark mode",
async shadowToken => {
const { result } = android.renderHook(android.useAtlantisTheme, {
wrapper: ({ children }: AtlantisThemeContextProviderProps) => (
{children}
),
});
expect(result.current.tokens[shadowToken]).toEqual(
androidTokens[shadowToken],
);
await android.act(async () => result.current.setTheme("dark"));
expect(result.current.tokens[shadowToken]).toEqual(
darkAndroidTokens[shadowToken],
);
},
);
});
it("resolves the system theme from the OS color scheme", async () => {
mockUseColorScheme.mockReturnValue("dark");
const { result, rerender } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
),
});
await act(async () => result.current.setTheme("system"));
expect(result.current.theme).toBe("system");
expect(result.current.effectiveTheme).toBe("dark");
expect(result.current.tokens).toEqual(expectedDarkTokens);
mockUseColorScheme.mockReturnValue("light");
rerender({});
expect(result.current.theme).toBe("system");
expect(result.current.effectiveTheme).toBe("light");
expect(result.current.tokens).toEqual(expectedLightTokens);
});
it("uses a controlled selected theme", () => {
mockUseColorScheme.mockReturnValue("dark");
const { result } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
),
});
expect(result.current.theme).toBe("system");
expect(result.current.effectiveTheme).toBe("dark");
expect(result.current.tokens).toEqual(expectedDarkTokens);
});
it("reports selected theme changes from a controlled provider", async () => {
const onThemeChange = jest.fn();
const { result } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
),
});
await act(async () => result.current.setTheme("system"));
expect(onThemeChange).toHaveBeenCalledWith("system");
expect(result.current.theme).toBe("light");
expect(result.current.effectiveTheme).toBe("light");
expect(result.current.tokens).toEqual(expectedLightTokens);
});
it("falls back to light when system color scheme is unavailable", async () => {
mockUseColorScheme.mockReturnValue(
null as unknown as ReturnType,
);
const { result } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
),
});
await act(async () => result.current.setTheme("system"));
expect(result.current.theme).toBe("system");
expect(result.current.effectiveTheme).toBe("light");
expect(result.current.tokens).toEqual(expectedLightTokens);
});
describe("when theme is forced for provider", () => {
it("preserves the forced effective theme when the selected theme changes", async () => {
const { result } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
),
});
// Update the selected theme through the forced subtree.
await act(async () => result.current.setTheme("dark"));
expect(result.current.theme).toBe("dark");
expect(result.current.effectiveTheme).toBe("light");
expect(result.current.tokens).toEqual(expectedLightTokens);
});
it("preserves the parent selected theme while forcing the effective theme", () => {
mockUseColorScheme.mockReturnValue("dark");
const { result } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
),
});
expect(result.current.theme).toBe("system");
expect(result.current.effectiveTheme).toBe("light");
expect(result.current.tokens).toEqual(expectedLightTokens);
});
it("forwards selected theme changes through a forced subtree", async () => {
const { result } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
),
});
await act(async () => result.current.setTheme("dark"));
expect(result.current.theme).toBe("dark");
expect(result.current.effectiveTheme).toBe("light");
expect(result.current.tokens).toEqual(expectedLightTokens);
});
it.each([
{
defaultTheme: "light",
colorScheme: "dark",
expectedEffectiveTheme: "light",
expectedTokens: expectedLightTokens,
},
{
defaultTheme: "dark",
colorScheme: "light",
expectedEffectiveTheme: "dark",
expectedTokens: expectedDarkTokens,
},
] as {
defaultTheme: EffectiveTheme;
colorScheme: ColorSchemeName;
expectedEffectiveTheme: EffectiveTheme;
expectedTokens: typeof iosTokens;
}[])(
"provides the dangerouslyOverrideTheme $defaultTheme tokens",
({
defaultTheme,
colorScheme,
expectedEffectiveTheme,
expectedTokens,
}) => {
mockUseColorScheme.mockReturnValue(colorScheme);
const { result } = renderHook(useAtlantisTheme, {
wrapper: (props: AtlantisThemeContextProviderProps) => (
),
});
expect(result.current.theme).toBe("light");
expect(result.current.effectiveTheme).toBe(expectedEffectiveTheme);
expect(result.current.tokens).toEqual(expectedTokens);
},
);
});
});