WellNuo/contexts/__tests__/ThemeContext.test.tsx
Sergei 610104090a Add dark mode support with theme toggle
- 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>
2026-02-01 10:01:07 -08:00

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