diff --git a/src/components/__tests__/BlockChip.test.tsx b/src/components/__tests__/BlockChip.test.tsx new file mode 100644 index 0000000..498bab8 --- /dev/null +++ b/src/components/__tests__/BlockChip.test.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; +import { render, fireEvent } from '@testing-library/react-native'; +import BlockChip from '../BlockChip'; +import { useThemeColors } from '../../utils/useThemeColors'; +import { Colors, Spacing, BorderRadius, FontSize } from '../../utils/theme'; + +jest.mock('../../utils/useThemeColors', () => ({ + useThemeColors: jest.fn(), +})); + +const mockUseThemeColors = useThemeColors as jest.Mock; + +describe('BlockChip', () => { + const mockOnPress = jest.fn(); + + const defaultProps = { + name: 'Deep Work', + categoryColor: '#3B82F6', + categoryEmoji: 'briefcase', + durationHours: 2, + done: false, + onPress: mockOnPress, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseThemeColors.mockReturnValue({ colors: Colors.light, isDark: false }); + }); + + const getCategoryIcon = (tree: any) => tree.children[0]; + + const getNameRow = (tree: any) => tree.children[1].children[0]; + + const getDurationText = (tree: any) => tree.children[2]; + + describe('rendering and content', () => { + it('renders block name and formatted duration hours', async () => { + const { getByText } = await render(); + + expect(getByText('Deep Work')).toBeTruthy(); + expect(getByText('2h')).toBeTruthy(); + }); + + it('enforces single line on name', async () => { + const { getByText } = await render(); + const nameElement = getByText('Deep Work'); + + expect(nameElement.props.numberOfLines).toBe(1); + }); + + it('formats decimal duration hours correctly', async () => { + const { getByText } = await render(); + + expect(getByText('1.5h')).toBeTruthy(); + }); + + it('applies container chip styles and category border/background colors', async () => { + const { toJSON } = await render(); + const tree = toJSON() as any; + const chipStyle = StyleSheet.flatten(tree.props.style); + + expect(chipStyle.flexDirection).toBe('row'); + expect(chipStyle.alignItems).toBe('center'); + expect(chipStyle.borderLeftWidth).toBe(4); + expect(chipStyle.borderRadius).toBe(BorderRadius.sm); + expect(chipStyle.paddingVertical).toBe(Spacing.xs); + expect(chipStyle.paddingHorizontal).toBe(Spacing.sm); + expect(chipStyle.marginBottom).toBe(Spacing.xs); + expect(chipStyle.borderLeftColor).toBe('#3B82F6'); + expect(chipStyle.backgroundColor).toBe('#3B82F620'); + expect(chipStyle.opacity).toBe(1); + }); + + it('renders category icon with category color and expected size', async () => { + const { toJSON } = await render(); + const icon = getCategoryIcon(toJSON()); + + expect(icon.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ color: '#3B82F6', fontSize: 16 }), + expect.objectContaining({ fontFamily: 'material-community' }), + ]) + ); + }); + }); + + describe('done state', () => { + it('renders with full opacity, no checkmark, and normal text when done is false', async () => { + const { toJSON, getByText } = await render(); + const tree = toJSON() as any; + const chipStyle = StyleSheet.flatten(tree.props.style); + expect(chipStyle.opacity).toBe(1); + + const nameRow = getNameRow(tree); + expect(nameRow.children.length).toBe(1); + + const nameStyle = StyleSheet.flatten(getByText('Deep Work').props.style); + expect(nameStyle.textDecorationLine).toBeUndefined(); + }); + + it('renders with reduced opacity, check-bold icon, and strikethrough text when done is true', async () => { + const { toJSON, getByText } = await render(); + const tree = toJSON() as any; + const chipStyle = StyleSheet.flatten(tree.props.style); + expect(chipStyle.opacity).toBe(0.5); + + const nameRow = getNameRow(tree); + expect(nameRow.children.length).toBe(2); + + const checkIcon = nameRow.children[0]; + expect(checkIcon.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ color: Colors.light.success, fontSize: 16 }), + expect.objectContaining({ fontFamily: 'material-community' }), + ]) + ); + + const nameStyle = StyleSheet.flatten(getByText('Deep Work').props.style); + expect(nameStyle.textDecorationLine).toBe('line-through'); + }); + }); + + describe('description prop', () => { + it('renders description text with single line limit and italic font style when provided', async () => { + const { getByText } = await render( + + ); + const descElement = getByText('Focused coding sprint'); + + expect(descElement).toBeTruthy(); + expect(descElement.props.numberOfLines).toBe(1); + const descStyle = StyleSheet.flatten(descElement.props.style); + expect(descStyle.fontStyle).toBe('italic'); + expect(descStyle.fontSize).toBe(FontSize.xs); + expect(descStyle.color).toBe(Colors.light.textSecondary); + }); + + it('does not render description when description is omitted', async () => { + const { toJSON } = await render(); + const tree = toJSON() as any; + const nameContainer = tree.children[1]; + + expect(nameContainer.children.length).toBe(1); + }); + + it('does not render description when description is empty string', async () => { + const { toJSON } = await render(); + const tree = toJSON() as any; + const nameContainer = tree.children[1]; + + expect(nameContainer.children.length).toBe(1); + }); + }); + + describe('user interactions', () => { + it('calls onPress when chip is pressed', async () => { + const { root } = await render(); + + await fireEvent.press(root!); + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('calls onPress when pressing the block name text', async () => { + const { getByText } = await render(); + + await fireEvent.press(getByText('Deep Work')); + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + }); + + describe('theming', () => { + it('applies light theme text colors', async () => { + mockUseThemeColors.mockReturnValue({ colors: Colors.light, isDark: false }); + + const { toJSON, getByText } = await render( + + ); + const tree = toJSON() as any; + + const nameStyle = StyleSheet.flatten(getByText('Deep Work').props.style); + expect(nameStyle.color).toBe(Colors.light.text); + + const descStyle = StyleSheet.flatten(getByText('Light task').props.style); + expect(descStyle.color).toBe(Colors.light.textSecondary); + + const durationNode = getDurationText(tree); + const durationStyle = StyleSheet.flatten(durationNode.props.style); + expect(durationStyle.color).toBe(Colors.light.textSecondary); + + const checkIcon = getNameRow(tree).children[0]; + expect(checkIcon.props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ color: Colors.light.success })]) + ); + }); + + it('applies dark theme text colors', async () => { + mockUseThemeColors.mockReturnValue({ colors: Colors.dark, isDark: true }); + + const { toJSON, getByText } = await render( + + ); + const tree = toJSON() as any; + + const nameStyle = StyleSheet.flatten(getByText('Deep Work').props.style); + expect(nameStyle.color).toBe(Colors.dark.text); + + const descStyle = StyleSheet.flatten(getByText('Dark task').props.style); + expect(descStyle.color).toBe(Colors.dark.textSecondary); + + const durationNode = getDurationText(tree); + const durationStyle = StyleSheet.flatten(durationNode.props.style); + expect(durationStyle.color).toBe(Colors.dark.textSecondary); + + const checkIcon = getNameRow(tree).children[0]; + expect(checkIcon.props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ color: Colors.dark.success })]) + ); + }); + }); +});