Skip to content

Commit 5924511

Browse files
test(utils): add tests for stripHash and isValidHex (JhaSourav07#568)
## Description Fixes JhaSourav07#349 Added unit tests for `stripHash` and `isValidHex` in `app/customize/utils.test.ts`. ### Changes made Added test coverage for: • `stripHash` with hash prefix • `stripHash` without hash prefix • `stripHash` with empty string • `isValidHex` with valid hex values • `isValidHex` with invalid characters • `isValidHex` with short hex values • `isValidHex` with hash prefixed values Reviewed utility behavior to ensure consistency with existing sanitizer logic and verified that all tests pass successfully. ## Pillar ☐ 🎨 Pillar 1 New Theme Design ☐ 📐 Pillar 2 Geometric SVG Improvement ☐ 🕐 Pillar 3 Timezone Logic Optimization ☑ 🛠️ Other Bug fix refactoring docs ## Visual Preview N/A test and utility improvement only. ## Checklist before requesting a review ☑ I have read the `CONTRIBUTING.md` file. ☑ I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). ☑ I have run `npm run format` and `npm run lint` locally and resolved all errors. ☑ My commits follow the Conventional Commits format. ☑ I have updated `README.md` if required. ☑ I have starred the repo. ☑ I have made sure that I have only one commit in this PR. ☑ The SVG output matches the CommitPulse premium quality aesthetic standard. ☑ I joined the CommitPulse Discord community for contributor discussions and PR support.
1 parent dbb676c commit 5924511

5 files changed

Lines changed: 173 additions & 30 deletions

File tree

app/customize/utils.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { stripHash, isValidHex } from './utils';
3+
4+
describe('Customize Utils', () => {
5+
describe('stripHash', () => {
6+
it('removes leading # from hex color', () => {
7+
expect(stripHash('#ff0000')).toBe('ff0000');
8+
});
9+
10+
it('returns unchanged string if no # prefix', () => {
11+
expect(stripHash('ff0000')).toBe('ff0000');
12+
});
13+
14+
it('handles empty string', () => {
15+
expect(stripHash('')).toBe('');
16+
});
17+
18+
it('only removes leading #, not all occurrences', () => {
19+
expect(stripHash('##ff0000')).toBe('#ff0000');
20+
});
21+
22+
it('handles just # character', () => {
23+
expect(stripHash('#')).toBe('');
24+
});
25+
});
26+
27+
describe('isValidHex', () => {
28+
describe('valid 6-digit hex colors', () => {
29+
it('accepts lowercase hex without #', () => {
30+
expect(isValidHex('ffffff')).toBe(true);
31+
expect(isValidHex('000000')).toBe(true);
32+
expect(isValidHex('ff0000')).toBe(true);
33+
});
34+
35+
it('accepts uppercase hex without #', () => {
36+
expect(isValidHex('FFFFFF')).toBe(true);
37+
expect(isValidHex('FF0000')).toBe(true);
38+
});
39+
40+
it('accepts mixed case hex without #', () => {
41+
expect(isValidHex('FfFfFf')).toBe(true);
42+
expect(isValidHex('aAbBcC')).toBe(true);
43+
});
44+
45+
it('accepts 6-digit hex with # prefix', () => {
46+
expect(isValidHex('#ffffff')).toBe(true);
47+
expect(isValidHex('#000000')).toBe(true);
48+
});
49+
});
50+
51+
describe('invalid hex colors', () => {
52+
it('rejects non-hex characters', () => {
53+
expect(isValidHex('zzzzzz')).toBe(false);
54+
expect(isValidHex('gggggg')).toBe(false);
55+
expect(isValidHex('ff@0000')).toBe(false);
56+
});
57+
58+
it('rejects wrong length', () => {
59+
expect(isValidHex('f')).toBe(false);
60+
expect(isValidHex('ff')).toBe(false);
61+
expect(isValidHex('fff')).toBe(false);
62+
expect(isValidHex('fffff')).toBe(false);
63+
expect(isValidHex('fffffff')).toBe(false);
64+
expect(isValidHex('ffffffff')).toBe(false);
65+
});
66+
67+
it('rejects hex with # but invalid length', () => {
68+
expect(isValidHex('#fff')).toBe(false);
69+
expect(isValidHex('#fffff')).toBe(false);
70+
});
71+
72+
it('rejects empty string', () => {
73+
expect(isValidHex('')).toBe(false);
74+
});
75+
76+
it('rejects hex with invalid characters and #', () => {
77+
expect(isValidHex('#zzzzzz')).toBe(false);
78+
expect(isValidHex('#ff@000')).toBe(false);
79+
});
80+
});
81+
});
82+
});

app/customize/utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@ import type { ExportFormat } from './types';
22

33
const BADGE_BASE_URL = 'https://commitpulse.vercel.app/api/streak';
44

5+
/**
6+
* Removes the leading # from a hex color string.
7+
* Used specifically for color picker handling in the customize interface.
8+
*/
59
export function stripHash(val: string): string {
610
return val.replace(/^#/, '');
711
}
812

13+
/**
14+
* Validates if a string is a valid 6-digit hex color for the color picker.
15+
* Intentionally strict (6-digit only) for color customization.
16+
* Note: lib/svg/sanitizer.ts has a more flexible version supporting 3,4,6,8 digits for SVG theming.
17+
*/
918
export function isValidHex(value: string): boolean {
1019
return /^[0-9a-fA-F]{6}$/.test(stripHash(value));
1120
}

hooks/useRecentSearches.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,35 +16,24 @@ export function useRecentSearches() {
1616
const [state, setState] = useState<State>({ searches: [], mounted: false });
1717

1818
useEffect(() => {
19-
let saved: string[] = [];
20-
try {
21-
const stored = localStorage.getItem(STORAGE_KEY);
22-
if (stored) saved = JSON.parse(stored) as string[];
23-
} catch {
24-
// ignore malformed storage
25-
}
2619
// Single setState call — reads external system (localStorage) and syncs
2720
// React state in one update, which is exactly what effects are for.
2821
// eslint-disable-next-line react-hooks/set-state-in-effect
29-
setState({ searches: saved, mounted: true });
22+
setState({ searches: loadFromStorage(), mounted: true });
3023
}, []);
3124

3225
const addSearch = (query: string) => {
3326
if (!query.trim()) return;
3427
setState((prev) => {
3528
const deduped = [query, ...prev.searches.filter((s) => s !== query)].slice(0, MAX_SEARCHES);
36-
try {
37-
localStorage.setItem(STORAGE_KEY, JSON.stringify(deduped));
38-
} catch {}
29+
writeStorage(deduped);
3930
return { ...prev, searches: deduped };
4031
});
4132
};
4233

4334
const clearSearches = () => {
4435
setState((prev) => ({ ...prev, searches: [] }));
45-
try {
46-
localStorage.removeItem(STORAGE_KEY);
47-
} catch {}
36+
writeStorage(null);
4837
};
4938

5039
// Return empty searches until after hydration to prevent SSR/client mismatch.

0 commit comments

Comments
 (0)