- Create ThemeContext with light/dark/system mode support - Add DarkColors palette for dark mode UI - Extend Colors object with full dark theme variants - Update useThemeColor hook to use ThemeContext - Add useThemeColors, useResolvedTheme, useIsDarkMode hooks - Update RootLayout (native and web) with ThemeProvider - Add theme toggle UI in ProfileDrawer settings - Theme preference persisted to AsyncStorage - Add comprehensive tests for ThemeContext and hooks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
285 lines
8.9 KiB
TypeScript
285 lines
8.9 KiB
TypeScript
/**
|
|
* ThemeContext Tests
|
|
*
|
|
* Tests for dark mode support functionality
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { renderHook, act, waitFor } from '@testing-library/react-native';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import { ThemeProvider, useTheme, useThemeOptional, type ThemeMode } from '../ThemeContext';
|
|
|
|
// Mock AsyncStorage
|
|
jest.mock('@react-native-async-storage/async-storage', () => ({
|
|
getItem: jest.fn(() => Promise.resolve(null)),
|
|
setItem: jest.fn(() => Promise.resolve()),
|
|
}));
|
|
|
|
// Mock useColorScheme hook via the hook path
|
|
let mockSystemColorScheme: 'light' | 'dark' | null = 'light';
|
|
jest.mock('react-native/Libraries/Utilities/useColorScheme', () => ({
|
|
default: () => mockSystemColorScheme,
|
|
}));
|
|
|
|
describe('ThemeContext', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockSystemColorScheme = 'light';
|
|
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
|
|
});
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
<ThemeProvider>{children}</ThemeProvider>
|
|
);
|
|
|
|
describe('ThemeProvider', () => {
|
|
it('should provide default theme values', async () => {
|
|
const { result } = renderHook(() => useTheme(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.themeMode).toBe('system');
|
|
expect(result.current.resolvedTheme).toBe('light');
|
|
expect(result.current.isDark).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('should resolve to dark theme when system is dark', async () => {
|
|
mockSystemColorScheme = 'dark';
|
|
|
|
const { result } = renderHook(() => useTheme(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.resolvedTheme).toBe('dark');
|
|
expect(result.current.isDark).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('should load saved theme preference from storage', async () => {
|
|
(AsyncStorage.getItem as jest.Mock).mockResolvedValue('dark');
|
|
|
|
const { result } = renderHook(() => useTheme(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.themeMode).toBe('dark');
|
|
expect(result.current.resolvedTheme).toBe('dark');
|
|
expect(result.current.isDark).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('should load light theme preference from storage', async () => {
|
|
(AsyncStorage.getItem as jest.Mock).mockResolvedValue('light');
|
|
|
|
const { result } = renderHook(() => useTheme(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.themeMode).toBe('light');
|
|
expect(result.current.resolvedTheme).toBe('light');
|
|
expect(result.current.isDark).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('setThemeMode', () => {
|
|
it('should change theme mode to dark', async () => {
|
|
const { result } = renderHook(() => useTheme(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.themeMode).toBe('system');
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.setThemeMode('dark');
|
|
});
|
|
|
|
expect(result.current.themeMode).toBe('dark');
|
|
expect(result.current.resolvedTheme).toBe('dark');
|
|
expect(result.current.isDark).toBe(true);
|
|
expect(AsyncStorage.setItem).toHaveBeenCalledWith('@wellnuo:theme_preference', 'dark');
|
|
});
|
|
|
|
it('should change theme mode to light', async () => {
|
|
(AsyncStorage.getItem as jest.Mock).mockResolvedValue('dark');
|
|
|
|
const { result } = renderHook(() => useTheme(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.themeMode).toBe('dark');
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.setThemeMode('light');
|
|
});
|
|
|
|
expect(result.current.themeMode).toBe('light');
|
|
expect(result.current.resolvedTheme).toBe('light');
|
|
expect(result.current.isDark).toBe(false);
|
|
expect(AsyncStorage.setItem).toHaveBeenCalledWith('@wellnuo:theme_preference', 'light');
|
|
});
|
|
|
|
it('should change theme mode to system', async () => {
|
|
(AsyncStorage.getItem as jest.Mock).mockResolvedValue('dark');
|
|
|
|
const { result } = renderHook(() => useTheme(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.themeMode).toBe('dark');
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.setThemeMode('system');
|
|
});
|
|
|
|
expect(result.current.themeMode).toBe('system');
|
|
expect(result.current.resolvedTheme).toBe('light'); // system is light
|
|
expect(AsyncStorage.setItem).toHaveBeenCalledWith('@wellnuo:theme_preference', 'system');
|
|
});
|
|
});
|
|
|
|
describe('toggleTheme', () => {
|
|
it('should toggle from light to dark', async () => {
|
|
(AsyncStorage.getItem as jest.Mock).mockResolvedValue('light');
|
|
|
|
const { result } = renderHook(() => useTheme(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.resolvedTheme).toBe('light');
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.toggleTheme();
|
|
});
|
|
|
|
expect(result.current.themeMode).toBe('dark');
|
|
expect(result.current.resolvedTheme).toBe('dark');
|
|
});
|
|
|
|
it('should toggle from dark to light', async () => {
|
|
(AsyncStorage.getItem as jest.Mock).mockResolvedValue('dark');
|
|
|
|
const { result } = renderHook(() => useTheme(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.resolvedTheme).toBe('dark');
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.toggleTheme();
|
|
});
|
|
|
|
expect(result.current.themeMode).toBe('light');
|
|
expect(result.current.resolvedTheme).toBe('light');
|
|
});
|
|
|
|
it('should toggle from system (light) to dark', async () => {
|
|
mockSystemColorScheme = 'light';
|
|
|
|
const { result } = renderHook(() => useTheme(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.themeMode).toBe('system');
|
|
expect(result.current.resolvedTheme).toBe('light');
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.toggleTheme();
|
|
});
|
|
|
|
expect(result.current.themeMode).toBe('dark');
|
|
expect(result.current.resolvedTheme).toBe('dark');
|
|
});
|
|
|
|
it('should toggle from system (dark) to light', async () => {
|
|
mockSystemColorScheme = 'dark';
|
|
|
|
const { result } = renderHook(() => useTheme(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.themeMode).toBe('system');
|
|
expect(result.current.resolvedTheme).toBe('dark');
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.toggleTheme();
|
|
});
|
|
|
|
expect(result.current.themeMode).toBe('light');
|
|
expect(result.current.resolvedTheme).toBe('light');
|
|
});
|
|
});
|
|
|
|
describe('useThemeOptional', () => {
|
|
it('should return null when used outside provider', () => {
|
|
const { result } = renderHook(() => useThemeOptional());
|
|
expect(result.current).toBeNull();
|
|
});
|
|
|
|
it('should return theme context when used inside provider', async () => {
|
|
const { result } = renderHook(() => useThemeOptional(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current).not.toBeNull();
|
|
expect(result.current?.themeMode).toBe('system');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useTheme error handling', () => {
|
|
it('should throw error when used outside provider', () => {
|
|
// Suppress console.error for this test
|
|
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
expect(() => {
|
|
renderHook(() => useTheme());
|
|
}).toThrow('useTheme must be used within a ThemeProvider');
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should handle AsyncStorage read error gracefully', async () => {
|
|
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
(AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('Storage error'));
|
|
|
|
const { result } = renderHook(() => useTheme(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
// Should fallback to default 'system' mode
|
|
expect(result.current.themeMode).toBe('system');
|
|
});
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should handle AsyncStorage write error gracefully', async () => {
|
|
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
(AsyncStorage.setItem as jest.Mock).mockRejectedValue(new Error('Storage error'));
|
|
|
|
const { result } = renderHook(() => useTheme(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.themeMode).toBe('system');
|
|
});
|
|
|
|
// Should still update state even if storage fails
|
|
await act(async () => {
|
|
await result.current.setThemeMode('dark');
|
|
});
|
|
|
|
expect(result.current.themeMode).toBe('dark');
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should ignore invalid stored theme values', async () => {
|
|
(AsyncStorage.getItem as jest.Mock).mockResolvedValue('invalid_theme');
|
|
|
|
const { result } = renderHook(() => useTheme(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
// Should fallback to default 'system' mode
|
|
expect(result.current.themeMode).toBe('system');
|
|
});
|
|
});
|
|
});
|
|
});
|