UNPKG

mttwm

Version:

Automated CSS-in-JS to Tailwind CSS migration tool for React applications

750 lines (617 loc) 25.6 kB
import { ThemeMapper } from '../../src/mappings/theme-mapper.js'; import type { ThemeReference } from '../../src/types.js'; describe('ThemeMapper', () => { let themeMapper: ThemeMapper; beforeEach(() => { themeMapper = new ThemeMapper(); }); describe('constructor', () => { it('should initialize with default theme mapping', () => { const mapper = new ThemeMapper(); expect(mapper).toBeInstanceOf(ThemeMapper); }); it('should merge custom mapping with defaults', () => { const customMapping = { colors: { 'custom.myColor': 'my-custom-color', }, }; const mapper = new ThemeMapper(customMapping); expect(mapper).toBeInstanceOf(ThemeMapper); }); }); describe('resolveThemeReference', () => { describe('custom theme properties', () => { it('should resolve custom background colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'builderBackgroundPrimary'] }; const result = themeMapper.resolveThemeReference(themeRef, 'backgroundColor'); expect(result).toEqual(['bg-builder-primary']); }); it('should resolve custom text colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'builderContentPrimary'] }; const result = themeMapper.resolveThemeReference(themeRef, 'color'); expect(result).toEqual(['text-builder-content-primary']); }); it('should resolve custom border colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'builderBorderPrimary'] }; const result = themeMapper.resolveThemeReference(themeRef, 'borderColor'); expect(result).toEqual(['border-builder-border-primary']); }); it('should resolve custom border radius', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'radiusMD'] }; const result = themeMapper.resolveThemeReference(themeRef, 'borderRadius'); expect(result).toEqual(['rounded-lg']); }); it('should throw error for unmapped custom properties', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'unknownProperty'] }; expect(() => themeMapper.resolveThemeReference(themeRef, 'color')).toThrow( /Unknown theme property.*theme\.custom\.unknownProperty/ ); }); }); describe('palette references', () => { it('should resolve primary palette for background color', () => { const themeRef: ThemeReference = { type: 'theme', path: ['palette', 'primary', 'main'] }; const result = themeMapper.resolveThemeReference(themeRef, 'backgroundColor'); expect(result).toEqual(['bg-primary']); }); it('should resolve secondary palette for text color', () => { const themeRef: ThemeReference = { type: 'theme', path: ['palette', 'secondary', 'main'] }; const result = themeMapper.resolveThemeReference(themeRef, 'color'); expect(result).toEqual(['text-secondary']); }); it('should resolve primary palette for border color', () => { const themeRef: ThemeReference = { type: 'theme', path: ['palette', 'primary', 'main'] }; const result = themeMapper.resolveThemeReference(themeRef, 'borderColor'); expect(result).toEqual(['border-primary']); }); it('should fallback to CSS variable for unmapped palette colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['palette', 'tertiary', 'light'] }; const result = themeMapper.resolveThemeReference(themeRef, 'backgroundColor'); expect(result).toEqual(['bg-[var(--palette-tertiary-light)]']); }); }); describe('spacing references', () => { it('should resolve spacing for padding', () => { const themeRef: ThemeReference = { type: 'theme', path: ['spacing', 'md'] }; const result = themeMapper.resolveThemeReference(themeRef, 'padding'); expect(result).toEqual(['p-6']); }); it('should resolve spacing for margin', () => { const themeRef: ThemeReference = { type: 'theme', path: ['spacing', 'lg'] }; const result = themeMapper.resolveThemeReference(themeRef, 'margin'); expect(result).toEqual(['m-8']); }); it('should resolve spacing for gap', () => { const themeRef: ThemeReference = { type: 'theme', path: ['spacing', 'xl'] }; const result = themeMapper.resolveThemeReference(themeRef, 'gap'); expect(result).toEqual(['gap-12']); }); it('should handle custom spacing values', () => { const themeRef: ThemeReference = { type: 'theme', path: ['spacing', '14'] }; const result = themeMapper.resolveThemeReference(themeRef, 'padding'); expect(result).toEqual(['p-14']); }); it('should fallback to CSS variable for non-spacing properties', () => { const themeRef: ThemeReference = { type: 'theme', path: ['spacing', 'md'] }; const result = themeMapper.resolveThemeReference(themeRef, 'width'); expect(result).toEqual(['w-[var(--spacing-md)px]']); }); }); describe('breakpoint references', () => { it('should return comment for breakpoints (handled elsewhere)', () => { const themeRef: ThemeReference = { type: 'theme', path: ['breakpoints', 'up', 'md'] }; const result = themeMapper.resolveThemeReference(themeRef, 'width'); expect(result).toEqual(['/* breakpoint: breakpoints.up.md */']); }); }); describe('fallback behavior', () => { it('should return CSS variable for unknown theme paths', () => { const themeRef: ThemeReference = { type: 'theme', path: ['unknown', 'property', 'path'] }; const result = themeMapper.resolveThemeReference(themeRef, 'color'); expect(result).toEqual(['text-[var(--theme-unknown-property-path)]']); }); it('should handle dots in theme paths correctly', () => { const themeRef: ThemeReference = { type: 'theme', path: ['theme', 'deeply.nested.property'] }; const result = themeMapper.resolveThemeReference(themeRef, 'color'); expect(result).toEqual(['text-[var(--theme-theme-deeply-nested-property)]']); }); }); }); describe('custom background color mapping', () => { it('should map builder theme background colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'builderBackgroundSecondary'] }; const result = themeMapper.resolveThemeReference(themeRef, 'backgroundColor'); expect(result).toEqual(['bg-builder-secondary']); }); it('should map action background colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'builderActionPrimary'] }; const result = themeMapper.resolveThemeReference(themeRef, 'backgroundColor'); expect(result).toEqual(['bg-builder-action-primary']); }); it('should map status background colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'builderGreen'] }; const result = themeMapper.resolveThemeReference(themeRef, 'backgroundColor'); expect(result).toEqual(['bg-green-500']); }); it('should map special background colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'transparentBg'] }; const result = themeMapper.resolveThemeReference(themeRef, 'backgroundColor'); expect(result).toEqual(['bg-white/6']); }); it('should throw error for unmapped background colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'unmappedBackgroundColor'] }; expect(() => themeMapper.resolveThemeReference(themeRef, 'backgroundColor')).toThrow( /Unknown theme property.*theme\.custom\.unmappedBackgroundColor/ ); }); }); describe('custom text color mapping', () => { it('should map builder content text colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'builderContentSecondary'] }; const result = themeMapper.resolveThemeReference(themeRef, 'color'); expect(result).toEqual(['text-builder-content-secondary']); }); it('should map action text colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'builderButtonPrimary'] }; const result = themeMapper.resolveThemeReference(themeRef, 'color'); expect(result).toEqual(['text-builder-button-primary']); }); it('should map status text colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'builderRed'] }; const result = themeMapper.resolveThemeReference(themeRef, 'color'); expect(result).toEqual(['text-red-500']); }); it('should map general text colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'textSecondary'] }; const result = themeMapper.resolveThemeReference(themeRef, 'color'); expect(result).toEqual(['text-muted-foreground']); }); }); describe('custom border color mapping', () => { it('should map builder border colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'builderBorderSecondary'] }; const result = themeMapper.resolveThemeReference(themeRef, 'borderColor'); expect(result).toEqual(['border-builder-border-secondary']); }); it('should map status border colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'builderRed'] }; const result = themeMapper.resolveThemeReference(themeRef, 'borderColor'); expect(result).toEqual(['border-red-500']); }); it('should map general border colors', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'divider'] }; const result = themeMapper.resolveThemeReference(themeRef, 'borderColor'); expect(result).toEqual(['border-border']); }); }); describe('custom border radius mapping', () => { it('should map all radius sizes', () => { const radiusTests = [ { path: ['custom', 'radiusXS'], expected: ['rounded-sm'] }, { path: ['custom', 'radiusSM'], expected: ['rounded'] }, { path: ['custom', 'radiusMD'], expected: ['rounded-lg'] }, { path: ['custom', 'radiusLG'], expected: ['rounded-lg'] }, { path: ['custom', 'radiusXL'], expected: ['rounded-xl'] }, { path: ['custom', 'radius2XL'], expected: ['rounded-2xl'] }, { path: ['custom', 'radius3XL'], expected: ['rounded-3xl'] }, ]; radiusTests.forEach(({ path, expected }) => { const themeRef: ThemeReference = { type: 'theme', path }; const result = themeMapper.resolveThemeReference(themeRef, 'borderRadius'); expect(result).toEqual(expected); }); }); it('should throw error for unmapped radius', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'customRadius'] }; expect(() => themeMapper.resolveThemeReference(themeRef, 'borderRadius')).toThrow( /Unknown theme property.*theme\.custom\.customRadius/ ); }); }); describe('spacing resolution', () => { it('should map named spacing values', () => { const spacingTests = [ { value: 'xs', expected: '2' }, { value: 'sm', expected: '4' }, { value: 'md', expected: '6' }, { value: 'lg', expected: '8' }, { value: 'xl', expected: '12' }, { value: '2xl', expected: '16' }, ]; spacingTests.forEach(({ value, expected }) => { const themeRef: ThemeReference = { type: 'theme', path: ['spacing', value] }; const result = themeMapper.resolveThemeReference(themeRef, 'padding'); expect(result).toEqual([`p-${expected}`]); }); }); it('should use numeric values as-is', () => { const themeRef: ThemeReference = { type: 'theme', path: ['spacing', '10'] }; const result = themeMapper.resolveThemeReference(themeRef, 'margin'); expect(result).toEqual(['m-10']); }); }); describe('generateCSSVariables', () => { it('should generate CSS variables for theme references', () => { const themeRefs = new Set([ 'custom.myColor', 'palette.tertiary.main', 'spacing.custom' ]); const result = themeMapper.generateCSSVariables(themeRefs); expect(result).toContain(':root {'); expect(result).toContain('--theme-custom-myColor: /* TODO: Define theme value for custom.myColor */;'); expect(result).toContain('--theme-palette-tertiary-main: /* TODO: Define theme value for palette.tertiary.main */;'); expect(result).toContain('--theme-spacing-custom: /* TODO: Define theme value for spacing.custom */;'); expect(result).toContain('}'); }); it('should return empty string for empty theme references', () => { const themeRefs = new Set<string>(); const result = themeMapper.generateCSSVariables(themeRefs); expect(result).toBe(''); }); it('should handle dots in theme references correctly', () => { const themeRefs = new Set(['theme.deeply.nested.property']); const result = themeMapper.generateCSSVariables(themeRefs); expect(result).toContain('--theme-theme-deeply-nested-property'); }); }); describe('edge cases and error handling', () => { it('should handle empty theme reference paths', () => { const themeRef: ThemeReference = { type: 'theme', path: [] }; const result = themeMapper.resolveThemeReference(themeRef, 'color'); expect(result).toEqual(['text-[var(--theme-)]']); }); it('should throw error for single-element custom paths', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom'] }; expect(() => themeMapper.resolveThemeReference(themeRef, 'color')).toThrow( /Unknown theme property.*theme\.custom/ ); }); it('should throw error for unknown CSS properties for custom themes', () => { const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'someProperty'] }; expect(() => themeMapper.resolveThemeReference(themeRef, 'unknownProperty')).toThrow( /Unknown theme property.*theme\.custom\.someProperty/ ); }); it('should handle palette with missing shade', () => { const themeRef: ThemeReference = { type: 'theme', path: ['palette', 'primary'] }; const result = themeMapper.resolveThemeReference(themeRef, 'backgroundColor'); expect(result).toEqual(['bg-[var(--palette-primary-undefined)]']); }); it('should handle empty spacing path', () => { const themeRef: ThemeReference = { type: 'theme', path: ['spacing'] }; const result = themeMapper.resolveThemeReference(themeRef, 'padding'); expect(result).toEqual(['p-undefined']); }); }); describe('integration with custom mapping', () => { it('should use custom mapping when provided', () => { const customMapping = { colors: { 'custom.myCustomColor': 'my-special-color', }, }; const mapper = new ThemeMapper(customMapping); // This test verifies that custom mappings are merged, // but since the actual resolution is complex and depends on internal logic, // we mainly test that the mapper is created successfully with custom config expect(mapper).toBeInstanceOf(ThemeMapper); }); it('should preserve default mappings when custom mapping is provided', () => { const customMapping = { colors: { 'custom.newColor': 'new-color', }, }; const mapper = new ThemeMapper(customMapping); // Test that default mappings still work const themeRef: ThemeReference = { type: 'theme', path: ['palette', 'primary', 'main'] }; const result = mapper.resolveThemeReference(themeRef, 'backgroundColor'); expect(result).toEqual(['bg-primary']); }); }); describe('config-based theme conversion', () => { describe('customThemeMapping support', () => { it('should use customThemeMapping for theme.custom properties', () => { const customMapping = { customThemeMapping: { 'theme.custom.secondary': 'text-blue-600', 'theme.custom.primary': ['bg-red-500', 'text-white'] } }; const mapper = new ThemeMapper(customMapping); // Test single class mapping const themeRef1: ThemeReference = { type: 'theme', path: ['custom', 'secondary'] }; const result1 = mapper.resolveThemeReference(themeRef1, 'color'); expect(result1).toEqual(['text-blue-600']); // Test multiple class mapping const themeRef2: ThemeReference = { type: 'theme', path: ['custom', 'primary'] }; const result2 = mapper.resolveThemeReference(themeRef2, 'backgroundColor'); expect(result2).toEqual(['bg-red-500']); }); it('should apply property-aware prefixes for custom mappings', () => { const customMapping = { customThemeMapping: { 'theme.custom.main': 'main' } }; const mapper = new ThemeMapper(customMapping); const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'main'] }; // Test color property gets text- prefix (direct class name for custom values) const colorResult = mapper.resolveThemeReference(themeRef, 'color'); expect(colorResult).toEqual(['text-main']); // Test backgroundColor property gets bg- prefix const bgResult = mapper.resolveThemeReference(themeRef, 'backgroundColor'); expect(bgResult).toEqual(['bg-main']); // Test borderColor property gets border- prefix const borderResult = mapper.resolveThemeReference(themeRef, 'borderColor'); expect(borderResult).toEqual(['border-main']); }); it('should preserve existing Tailwind prefixes in custom mappings', () => { const customMapping = { customThemeMapping: { 'theme.custom.primary': 'bg-blue-500', 'theme.custom.secondary': 'text-gray-600' } }; const mapper = new ThemeMapper(customMapping); // Test that bg-blue-500 is preserved as-is for any property const themeRef1: ThemeReference = { type: 'theme', path: ['custom', 'primary'] }; const result1 = mapper.resolveThemeReference(themeRef1, 'color'); expect(result1).toEqual(['bg-blue-500']); // Test that text-gray-600 is preserved as-is for any property const themeRef2: ThemeReference = { type: 'theme', path: ['custom', 'secondary'] }; const result2 = mapper.resolveThemeReference(themeRef2, 'backgroundColor'); expect(result2).toEqual(['text-gray-600']); }); it('should use customThemeMapping for theme.superCustom properties', () => { const customMapping = { customThemeMapping: { 'theme.superCustom.main': 'border-green-400' } }; const mapper = new ThemeMapper(customMapping); const themeRef: ThemeReference = { type: 'theme', path: ['superCustom', 'main'] }; const result = mapper.resolveThemeReference(themeRef, 'borderColor'); expect(result).toEqual(['border-green-400']); }); }); describe('error handling for unknown theme properties', () => { it('should throw helpful error for unknown theme.custom property without mapping', () => { const mapper = new ThemeMapper(); const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'unknownProperty'], isOptional: false }; expect(() => mapper.resolveThemeReference(themeRef, 'color')).toThrow( /Unknown theme property.*theme\.custom\.unknownProperty/ ); expect(() => mapper.resolveThemeReference(themeRef, 'color')).toThrow( /customThemeMapping/ ); expect(() => mapper.resolveThemeReference(themeRef, 'color')).toThrow( /mttwm\.config\.js/ ); }); it('should throw helpful error for optional chaining without mapping', () => { const mapper = new ThemeMapper(); const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'secondary'], isOptional: true }; expect(() => mapper.resolveThemeReference(themeRef, 'color')).toThrow( /Unknown theme property.*theme\.custom\.secondary\?/ ); expect(() => mapper.resolveThemeReference(themeRef, 'color')).toThrow( /Create a config file to map this property/ ); }); it('should throw helpful error for theme.superCustom property without mapping', () => { const mapper = new ThemeMapper(); const themeRef: ThemeReference = { type: 'theme', path: ['superCustom', 'bg'], isOptional: false }; expect(() => mapper.resolveThemeReference(themeRef, 'backgroundColor')).toThrow( /Unknown theme property.*theme\.superCustom\.bg/ ); expect(() => mapper.resolveThemeReference(themeRef, 'backgroundColor')).toThrow( /theme\.superCustom\.bg.*your-tailwind-class-here/ ); }); it('should include practical examples in error message', () => { const mapper = new ThemeMapper(); const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'main'], isOptional: false }; expect(() => mapper.resolveThemeReference(themeRef, 'color')).toThrow( /theme\.custom\.main.*bg-blue-500.*background color/ ); expect(() => mapper.resolveThemeReference(themeRef, 'color')).toThrow( /theme\.custom\.main.*text-gray-800.*text color/ ); expect(() => mapper.resolveThemeReference(themeRef, 'color')).toThrow( /theme\.custom\.main.*border-red-400.*border color/ ); }); it('should reference documentation in error message', () => { const mapper = new ThemeMapper(); const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'test'], isOptional: true }; expect(() => mapper.resolveThemeReference(themeRef, 'color')).toThrow( /See README\.md for more configuration examples/ ); }); }); describe('config priority and fallback behavior', () => { it('should prioritize customThemeMapping over built-in mappings', () => { // This test would require modifying the resolveCustomTheme method to check customThemeMapping first // For now, we test that when customThemeMapping is provided, it works as expected const customMapping = { customThemeMapping: { 'theme.custom.builderBackgroundPrimary': 'bg-custom-override' } }; const mapper = new ThemeMapper(customMapping); const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'builderBackgroundPrimary'] }; // Should use customThemeMapping instead of built-in mapping const result = mapper.resolveThemeReference(themeRef, 'backgroundColor'); expect(result).toEqual(['bg-custom-override']); }); it('should fall back to built-in mapping when no custom mapping exists', () => { const mapper = new ThemeMapper({}); const themeRef: ThemeReference = { type: 'theme', path: ['custom', 'builderBackgroundPrimary'] }; // Should use built-in mapping const result = mapper.resolveThemeReference(themeRef, 'backgroundColor'); expect(result).toEqual(['bg-builder-primary']); }); }); }); });