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); }, ); }); });