Skip to content

Commit eec72d9

Browse files
committed
Add formal test policy and reach 80% statement coverage
- Create CONTRIBUTING.md with formal test policy requiring tests for new functionality - Add coverageThreshold (80% statements) to package.json Jest config - Update CI workflow to run tests with --coverage - Add/expand tests across 10 test files (1660 total tests, 80.75% coverage)
1 parent 250cb73 commit eec72d9

13 files changed

Lines changed: 979 additions & 35 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,4 @@ jobs:
3131
run: npm run build
3232

3333
- name: Run tests
34-
run: npm test -- --passWithNoTests
34+
run: npm test -- --passWithNoTests --coverage --watchAll=false

CONTRIBUTING.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Contributing to Vision Condition Visualizer
2+
3+
We welcome contributions! This document outlines the process for contributing to this project and the test requirements all contributions must meet.
4+
5+
## Getting Started
6+
7+
1. **Fork the repository**
8+
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
9+
3. **Make your changes** (see guidelines below)
10+
4. **Ensure all tests pass**: `npm test -- --watchAll=false`
11+
5. **Commit your changes**: `git commit -m 'Add amazing feature'`
12+
6. **Push to the branch**: `git push origin feature/amazing-feature`
13+
7. **Open a Pull Request**
14+
15+
## Test Policy
16+
17+
**As major new functionality is added, tests for the new functionality MUST be added to an automated test suite.** This is a hard requirement for all pull requests that introduce or modify behavior.
18+
19+
### What requires tests
20+
21+
- **New features and components**: Any new React component, hook, or utility function must have corresponding unit tests.
22+
- **Bug fixes**: A regression test must accompany each bug fix to prevent recurrence.
23+
- **New visual effects or overlays**: Effect creation functions must be tested for element creation, cleanup, and edge cases (disabled/undefined input).
24+
- **New famous people utilities**: Any new mapping or parsing logic in `famousPeopleUtils` must be tested.
25+
- **Data transformations**: Functions that transform, filter, or aggregate data must have tests covering expected inputs and edge cases.
26+
27+
### What does NOT require tests
28+
29+
- **Documentation-only changes**: Updates to README, CONTRIBUTING, or other markdown files.
30+
- **CSS-only changes**: Pure styling changes with no logic.
31+
- **Translation files**: Adding or updating locale JSON files in `src/locales/`.
32+
- **Static data additions**: Adding entries to data arrays (e.g., a new person in `src/data/famousPeople/`) that use existing, already-tested code paths.
33+
- **Asset additions**: Adding images, fonts, or other static assets.
34+
35+
### Test conventions
36+
37+
- **Directory structure**: All tests live in `src/__tests__/`, mirroring the source directory structure:
38+
- `src/__tests__/components/` for component tests
39+
- `src/__tests__/hooks/` for hook tests
40+
- `src/__tests__/utils/` for utility tests
41+
- `src/__tests__/overlays/` for overlay/visual effect tests
42+
- **Test utilities**: Use `@testing-library/react` for component tests and `@testing-library/user-event` for user interaction simulation.
43+
- **Mocking**: CRA enables `resetMocks` by default, so all `jest.fn()` mocks are automatically reset between tests. Module mocks (`jest.mock(...)`) must be declared at the top of the file, before imports of the mocked module.
44+
- **Naming**: Test files should be named `<ModuleName>.test.ts` or `<ModuleName>.test.tsx`.
45+
- **Coverage threshold**: The project enforces a minimum of 80% statement coverage in CI. Run `npm test -- --watchAll=false --coverage` locally to verify before submitting.
46+
47+
## Code Style
48+
49+
This project follows the [Create React App ESLint configuration](https://www.npmjs.com/package/eslint-config-react-app) with additional rules defined in `package.json`. TypeScript strict mode provides further enforcement. CI treats all ESLint warnings as errors (`CI=true`), so contributions must pass linting to be merged. Suppress intentional console statements with `// eslint-disable-next-line no-console`.
50+
51+
## Areas for Contribution
52+
53+
- **New vision conditions**: Add more realistic simulations
54+
- **Additional famous people**: Expand the educational content
55+
- **Accessibility improvements**: Enhance screen reader support
56+
- **Performance optimization**: Improve rendering speed
57+
- **Mobile experience**: Enhance touch interactions
58+
- **New languages**: Add translation files for more locales

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@
5454
"no-var": "error"
5555
}
5656
},
57+
"jest": {
58+
"coverageThreshold": {
59+
"global": {
60+
"statements": 80
61+
}
62+
}
63+
},
5764
"browserslist": {
5865
"production": [
5966
">0.2%",

readme.md

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -247,28 +247,7 @@ Deployed to **Cloudflare Pages** at [theblind.spot](https://theblind.spot). The
247247

248248
## Contributing
249249

250-
We welcome contributions! Here's how you can help:
251-
252-
1. **Fork the repository**
253-
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
254-
3. **Add tests** for new functionality or bug fixes in `src/__tests__/`
255-
4. **Ensure all tests pass**: `npm test -- --watchAll=false`
256-
5. **Commit your changes**: `git commit -m 'Add amazing feature'`
257-
6. **Push to the branch**: `git push origin feature/amazing-feature`
258-
7. **Open a Pull Request**
259-
260-
### Code Style
261-
262-
This project follows the [Create React App ESLint configuration](https://www.npmjs.com/package/eslint-config-react-app) with additional rules defined in `package.json`. TypeScript strict mode provides further enforcement. CI treats all ESLint warnings as errors, so contributions must pass linting to be merged.
263-
264-
### Areas for Contribution
265-
266-
- **New vision conditions**: Add more realistic simulations
267-
- **Additional famous people**: Expand the educational content
268-
- **Accessibility improvements**: Enhance screen reader support
269-
- **Performance optimization**: Improve rendering speed
270-
- **Mobile experience**: Enhance touch interactions
271-
- **New languages**: Add translation files for more locales
250+
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, including our **test policy** requiring tests for all new functionality.
272251

273252
## License
274253

src/__tests__/colorVisionFilters.test.ts

Lines changed: 199 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getColorVisionFilter, getColorVisionMatrix, cleanupAllDOMFilters, isColorVisionCondition } from '../utils/colorVisionFilters';
1+
import { getColorVisionFilter, getColorVisionMatrix, cleanupAllDOMFilters, isColorVisionCondition, getColorVisionFilterData, _resetMobileDetection, isMobileBrowser, getColorVisionDescription, getColorVisionPrevalence } from '../utils/colorVisionFilters';
22
import { ConditionType } from '../types/visualEffects';
33

44
describe('getColorVisionFilter', () => {
@@ -125,6 +125,134 @@ describe('isColorVisionCondition', () => {
125125
});
126126
});
127127

128+
describe('getColorVisionFilterData', () => {
129+
test('returns filter data for protanopia', () => {
130+
const data = getColorVisionFilterData('protanopia' as ConditionType, 1.0);
131+
expect(data).not.toBeNull();
132+
expect(data!.filterId).toBe('cvd-protanopia');
133+
expect(data!.matrixValues).toBeTruthy();
134+
});
135+
136+
test('returns null for monochromacy', () => {
137+
const data = getColorVisionFilterData('monochromacy' as ConditionType, 1.0);
138+
expect(data).toBeNull();
139+
});
140+
141+
test('returns null for monochromatic', () => {
142+
const data = getColorVisionFilterData('monochromatic' as ConditionType, 1.0);
143+
expect(data).toBeNull();
144+
});
145+
146+
test('returns null at zero intensity', () => {
147+
const data = getColorVisionFilterData('protanopia' as ConditionType, 0);
148+
expect(data).toBeNull();
149+
});
150+
151+
test('matrix values contain 20 numbers for 5x4 matrix', () => {
152+
const data = getColorVisionFilterData('deuteranopia' as ConditionType, 0.7);
153+
expect(data).not.toBeNull();
154+
const nums = data!.matrixValues.split(' ');
155+
expect(nums).toHaveLength(20);
156+
});
157+
158+
test('partial intensity produces blended matrix', () => {
159+
const dataFull = getColorVisionFilterData('tritanopia' as ConditionType, 1.0);
160+
const dataHalf = getColorVisionFilterData('tritanopia' as ConditionType, 0.5);
161+
expect(dataFull).not.toBeNull();
162+
expect(dataHalf).not.toBeNull();
163+
expect(dataFull!.matrixValues).not.toBe(dataHalf!.matrixValues);
164+
});
165+
});
166+
167+
describe('_resetMobileDetection', () => {
168+
test('can be called without error', () => {
169+
expect(() => _resetMobileDetection()).not.toThrow();
170+
});
171+
});
172+
173+
describe('isMobileBrowser', () => {
174+
afterEach(() => {
175+
_resetMobileDetection();
176+
});
177+
178+
test('returns false in JSDOM environment', () => {
179+
_resetMobileDetection();
180+
expect(isMobileBrowser()).toBe(false);
181+
});
182+
183+
test('result is cached after first call', () => {
184+
_resetMobileDetection();
185+
const first = isMobileBrowser();
186+
const second = isMobileBrowser();
187+
expect(first).toBe(second);
188+
});
189+
});
190+
191+
describe('getColorVisionFilter mobile path', () => {
192+
const originalMatchMedia = window.matchMedia;
193+
const originalUserAgent = Object.getOwnPropertyDescriptor(navigator, 'userAgent');
194+
195+
afterEach(() => {
196+
_resetMobileDetection();
197+
cleanupAllDOMFilters();
198+
window.matchMedia = originalMatchMedia;
199+
if (originalUserAgent) {
200+
Object.defineProperty(navigator, 'userAgent', originalUserAgent);
201+
}
202+
});
203+
204+
test('returns CSS filter string on mobile for protanopia', () => {
205+
_resetMobileDetection();
206+
// Simulate mobile UA
207+
Object.defineProperty(navigator, 'userAgent', {
208+
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)',
209+
configurable: true,
210+
});
211+
window.matchMedia = jest.fn().mockReturnValue({ matches: false }) as any;
212+
213+
const filter = getColorVisionFilter('protanopia' as ConditionType, 1.0);
214+
expect(filter).toContain('sepia');
215+
expect(filter).toContain('hue-rotate');
216+
});
217+
218+
test('returns scaled CSS filter at partial intensity on mobile', () => {
219+
_resetMobileDetection();
220+
Object.defineProperty(navigator, 'userAgent', {
221+
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)',
222+
configurable: true,
223+
});
224+
window.matchMedia = jest.fn().mockReturnValue({ matches: false }) as any;
225+
226+
const filter = getColorVisionFilter('deuteranopia' as ConditionType, 0.5);
227+
expect(filter).toContain('sepia');
228+
expect(filter).toContain('saturate');
229+
});
230+
});
231+
232+
describe('cleanupAllDOMFilters', () => {
233+
beforeEach(() => {
234+
_resetMobileDetection();
235+
});
236+
237+
test('removes all injected SVG filter elements', () => {
238+
// Ensure we're not in mobile mode
239+
_resetMobileDetection();
240+
getColorVisionFilter('protanopia' as ConditionType, 1.0);
241+
const container = document.getElementById('cvd-svg-filters');
242+
// On desktop, SVG filters should be created
243+
if (container) {
244+
cleanupAllDOMFilters();
245+
expect(document.getElementById('cvd-svg-filters')).toBeNull();
246+
expect(document.getElementById('cvd-protanopia')).toBeNull();
247+
}
248+
});
249+
250+
test('is safe to call when no filters exist', () => {
251+
cleanupAllDOMFilters();
252+
expect(() => cleanupAllDOMFilters()).not.toThrow();
253+
});
254+
});
255+
128256
describe('getColorVisionMatrix', () => {
129257
test('returns identity matrix for unknown condition', () => {
130258
const matrix = getColorVisionMatrix('unknownCondition' as ConditionType);
@@ -150,6 +278,19 @@ describe('getColorVisionMatrix', () => {
150278
expect(matrix).toHaveLength(9);
151279
});
152280

281+
test('monochromatic returns achromatopsia matrix', () => {
282+
const matrix = getColorVisionMatrix('monochromatic' as ConditionType, 1.0);
283+
const identity = [1, 0, 0, 0, 1, 0, 0, 0, 1];
284+
expect(matrix).not.toEqual(identity);
285+
expect(matrix).toHaveLength(9);
286+
});
287+
288+
test('monochromacy returns same matrix as monochromatic', () => {
289+
const monochromatic = getColorVisionMatrix('monochromatic' as ConditionType, 1.0);
290+
const monochromacy = getColorVisionMatrix('monochromacy' as ConditionType, 1.0);
291+
expect(monochromatic).toEqual(monochromacy);
292+
});
293+
153294
test('anomaly interpolation produces intermediate values', () => {
154295
const at0 = getColorVisionMatrix('protanomaly' as ConditionType, 0.0);
155296
const at05 = getColorVisionMatrix('protanomaly' as ConditionType, 0.5);
@@ -158,3 +299,60 @@ describe('getColorVisionMatrix', () => {
158299
expect(at05).not.toEqual(at1);
159300
});
160301
});
302+
303+
describe('getColorVisionDescription', () => {
304+
test('returns description for protanopia', () => {
305+
const desc = getColorVisionDescription('protanopia' as ConditionType);
306+
expect(desc).toContain('red-blindness');
307+
expect(desc).toContain('L-cones');
308+
});
309+
310+
test('returns description for deuteranopia', () => {
311+
const desc = getColorVisionDescription('deuteranopia' as ConditionType);
312+
expect(desc).toContain('green-blindness');
313+
});
314+
315+
test('returns description for tritanopia', () => {
316+
const desc = getColorVisionDescription('tritanopia' as ConditionType);
317+
expect(desc).toContain('blue-blindness');
318+
});
319+
320+
test('returns description for anomaly types', () => {
321+
expect(getColorVisionDescription('protanomaly' as ConditionType)).toContain('red-weakness');
322+
expect(getColorVisionDescription('deuteranomaly' as ConditionType)).toContain('green-weakness');
323+
expect(getColorVisionDescription('tritanomaly' as ConditionType)).toContain('blue-weakness');
324+
});
325+
326+
test('returns description for monochromacy', () => {
327+
const desc = getColorVisionDescription('monochromacy' as ConditionType);
328+
expect(desc).toContain('achromatopsia');
329+
});
330+
331+
test('returns fallback for unknown type', () => {
332+
const desc = getColorVisionDescription('unknownType' as ConditionType);
333+
expect(desc).toBe('Color vision deficiency simulation');
334+
});
335+
});
336+
337+
describe('getColorVisionPrevalence', () => {
338+
test('returns prevalence for protanopia', () => {
339+
const prev = getColorVisionPrevalence('protanopia' as ConditionType);
340+
expect(prev).toContain('males');
341+
expect(prev).toContain('females');
342+
});
343+
344+
test('returns prevalence for deuteranomaly as most common', () => {
345+
const prev = getColorVisionPrevalence('deuteranomaly' as ConditionType);
346+
expect(prev).toContain('6%');
347+
});
348+
349+
test('returns prevalence for monochromacy', () => {
350+
const prev = getColorVisionPrevalence('monochromacy' as ConditionType);
351+
expect(prev).toContain('30,000');
352+
});
353+
354+
test('returns unknown for unrecognized type', () => {
355+
const prev = getColorVisionPrevalence('unknownType' as ConditionType);
356+
expect(prev).toBe('Unknown prevalence');
357+
});
358+
});

0 commit comments

Comments
 (0)