diff --git a/package.json b/package.json index 05e8ade..1fdbfad 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "src/server.js", "type": "module", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "vitest", + "test:coverage": "vitest run --coverage", "start": "node .", "dev": "nodemon . --ignore 'src/assets/js/*.js' --ignore 'cypress/**/*.js' --ignore 'test/**/*.js'", "css": "npx tailwindcss -i ./src/tailwind.css -o ./src/assets/css/index.css --watch", @@ -57,5 +58,15 @@ "supertest": "^6.3.3", "tailwindcss": "^3.1.8", "vitest": "^3.0.7" + }, + "vitest": { + "include": [ + "src/assets/js/__tests__/**/*.test.js" + ], + "environment": "node", + "globals": true, + "transformMode": { + "web": [".js", ".jsx"] + } } -} +} \ No newline at end of file diff --git a/src/assets/js/__tests__/colorContrastValidator.test.js b/src/assets/js/__tests__/colorContrastValidator.test.js new file mode 100644 index 0000000..3da2ad8 --- /dev/null +++ b/src/assets/js/__tests__/colorContrastValidator.test.js @@ -0,0 +1,67 @@ +import ColorContrastValidator from '../colorContrastValidator.js'; + +describe('ColorContrastValidator', () => { + describe('hexToRgb', () => { + test('converts 6-digit hex to RGB', () => { + const rgb = ColorContrastValidator.hexToRgb('#FF0000'); + expect(rgb).toEqual({ r: 255, g: 0, b: 0 }); + }); + + test('converts 3-digit hex to RGB', () => { + const rgb = ColorContrastValidator.hexToRgb('#F00'); + expect(rgb).toEqual({ r: 255, g: 0, b: 0 }); + }); + }); + + describe('calculateRelativeLuminance', () => { + test('calculates luminance correctly', () => { + const white = ColorContrastValidator.calculateRelativeLuminance({ r: 255, g: 255, b: 255 }); + const black = ColorContrastValidator.calculateRelativeLuminance({ r: 0, g: 0, b: 0 }); + + expect(white).toBeCloseTo(1); + expect(black).toBeCloseTo(0); + }); + }); + + describe('calculateContrastRatio', () => { + test('calculates contrast ratio between black and white', () => { + const contrastRatio = ColorContrastValidator.calculateContrastRatio('#FFFFFF', '#000000'); + expect(contrastRatio).toBeCloseTo(21); + }); + + test('calculates contrast ratio between similar colors', () => { + const contrastRatio = ColorContrastValidator.calculateContrastRatio('#888888', '#A0A0A0'); + expect(contrastRatio).toBeGreaterThan(1); + expect(contrastRatio).toBeLessThan(5); + }); + }); + + describe('meetsContrastRequirements', () => { + test('validates high contrast colors', () => { + expect(ColorContrastValidator.meetsContrastRequirements('#FFFFFF', '#000000')).toBe(true); + expect(ColorContrastValidator.meetsContrastRequirements('#000000', '#FFFFFF')).toBe(true); + }); + + test('invalidates low contrast colors', () => { + expect(ColorContrastValidator.meetsContrastRequirements('#888888', '#A0A0A0')).toBe(false); + }); + }); + + describe('findCompliantColor', () => { + test('finds a compliant color from a palette', () => { + const palette = ['#FF0000', '#00FF00', '#0000FF', '#FFFFFF']; + const backgroundColor = '#000000'; + + const compliantColor = ColorContrastValidator.findCompliantColor('#888888', backgroundColor, palette); + expect(compliantColor).toBe('#FFFFFF'); + }); + + test('returns null if no compliant color found', () => { + const palette = ['#888888', '#999999', '#AAAAAA']; + const backgroundColor = '#000000'; + + const compliantColor = ColorContrastValidator.findCompliantColor('#888888', backgroundColor, palette); + expect(compliantColor).toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/src/assets/js/colorContrastValidator.js b/src/assets/js/colorContrastValidator.js new file mode 100644 index 0000000..d1b01a3 --- /dev/null +++ b/src/assets/js/colorContrastValidator.js @@ -0,0 +1,88 @@ +/** + * Color Contrast Validation Utility + * Implements WCAG 2.1 Level AA contrast ratio calculation and validation + */ +class ColorContrastValidator { + /** + * Convert a hex color to RGB + * @param {string} hex - Hex color code + * @returns {Object} RGB color object + */ + static hexToRgb(hex) { + // Remove # if present + hex = hex.replace(/^#/, ''); + + // Handle 3-digit and 6-digit hex codes + const bigint = parseInt(hex, 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + + return { r, g, b }; + } + + /** + * Calculate relative luminance of a color + * @param {Object} rgb - RGB color object + * @returns {number} Relative luminance value + */ + static calculateRelativeLuminance(rgb) { + const { r, g, b } = rgb; + const sRGB = [r, g, b].map(color => { + const sRGBValue = color / 255; + return sRGBValue <= 0.03928 + ? sRGBValue / 12.92 + : Math.pow((sRGBValue + 0.055) / 1.055, 2.4); + }); + + return 0.2126 * sRGB[0] + 0.7152 * sRGB[1] + 0.0722 * sRGB[2]; + } + + /** + * Calculate contrast ratio between two colors + * @param {string} color1 - First color hex code + * @param {string} color2 - Second color hex code + * @returns {number} Contrast ratio + */ + static calculateContrastRatio(color1, color2) { + const rgb1 = this.hexToRgb(color1); + const rgb2 = this.hexToRgb(color2); + + const l1 = this.calculateRelativeLuminance(rgb1); + const l2 = this.calculateRelativeLuminance(rgb2); + + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + + return (lighter + 0.05) / (darker + 0.05); + } + + /** + * Check if colors meet WCAG 2.1 Level AA contrast requirements + * @param {string} foreground - Foreground color hex code + * @param {string} background - Background color hex code + * @returns {boolean} Whether colors meet contrast requirements + */ + static meetsContrastRequirements(foreground, background) { + const contrastRatio = this.calculateContrastRatio(foreground, background); + return contrastRatio >= 4.5; // WCAG 2.1 Level AA standard + } + + /** + * Find a compliant alternative color + * @param {string} originalColor - Original color hex code + * @param {string} backgroundColor - Background color hex code + * @param {string[]} colorPalette - Array of potential alternative colors + * @returns {string|null} A compliant color or null + */ + static findCompliantColor(originalColor, backgroundColor, colorPalette) { + for (const color of colorPalette) { + if (this.meetsContrastRequirements(color, backgroundColor)) { + return color; + } + } + return null; + } +} + +export default ColorContrastValidator; \ No newline at end of file