Skip to content

Commit b2696f4

Browse files
committed
test: add tests for regex, mimeTypes, pronouns, useDebounce, useThrottle
1 parent bfe766e commit b2696f4

5 files changed

Lines changed: 454 additions & 0 deletions

File tree

src/app/hooks/useDebounce.test.tsx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { renderHook, act } from '@testing-library/react';
3+
import { useDebounce } from './useDebounce';
4+
5+
beforeEach(() => {
6+
vi.useFakeTimers();
7+
});
8+
9+
afterEach(() => {
10+
vi.useRealTimers();
11+
});
12+
13+
describe('useDebounce', () => {
14+
it('does not call callback before wait time elapses', () => {
15+
const fn = vi.fn();
16+
const { result } = renderHook(() => useDebounce(fn, { wait: 200 }));
17+
18+
act(() => {
19+
result.current('a');
20+
});
21+
22+
act(() => {
23+
vi.advanceTimersByTime(199);
24+
});
25+
26+
expect(fn).not.toHaveBeenCalled();
27+
});
28+
29+
it('calls callback after wait time elapses', () => {
30+
const fn = vi.fn();
31+
const { result } = renderHook(() => useDebounce(fn, { wait: 200 }));
32+
33+
act(() => {
34+
result.current('a');
35+
});
36+
37+
act(() => {
38+
vi.advanceTimersByTime(200);
39+
});
40+
41+
expect(fn).toHaveBeenCalledOnce();
42+
expect(fn).toHaveBeenCalledWith('a');
43+
});
44+
45+
it('resets the timer on each successive call', () => {
46+
const fn = vi.fn();
47+
const { result } = renderHook(() => useDebounce(fn, { wait: 200 }));
48+
49+
act(() => {
50+
result.current('first');
51+
});
52+
53+
act(() => {
54+
vi.advanceTimersByTime(150);
55+
result.current('second');
56+
});
57+
58+
// 150ms into the reset timer — should not have fired yet
59+
act(() => {
60+
vi.advanceTimersByTime(150);
61+
});
62+
63+
expect(fn).not.toHaveBeenCalled();
64+
65+
// Complete the 200ms wait after the second call
66+
act(() => {
67+
vi.advanceTimersByTime(50);
68+
});
69+
70+
expect(fn).toHaveBeenCalledOnce();
71+
expect(fn).toHaveBeenCalledWith('second');
72+
});
73+
74+
it('only fires once after rapid successive calls', () => {
75+
const fn = vi.fn();
76+
const { result } = renderHook(() => useDebounce(fn, { wait: 100 }));
77+
78+
act(() => {
79+
result.current(1);
80+
result.current(2);
81+
result.current(3);
82+
vi.advanceTimersByTime(100);
83+
});
84+
85+
expect(fn).toHaveBeenCalledOnce();
86+
expect(fn).toHaveBeenCalledWith(3);
87+
});
88+
89+
it('fires immediately on first call when immediate option is set', () => {
90+
const fn = vi.fn();
91+
const { result } = renderHook(() => useDebounce(fn, { wait: 200, immediate: true }));
92+
93+
act(() => {
94+
result.current('go');
95+
});
96+
97+
expect(fn).toHaveBeenCalledOnce();
98+
expect(fn).toHaveBeenCalledWith('go');
99+
});
100+
});

src/app/hooks/useThrottle.test.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { renderHook, act } from '@testing-library/react';
3+
import { useThrottle } from './useThrottle';
4+
5+
beforeEach(() => {
6+
vi.useFakeTimers();
7+
});
8+
9+
afterEach(() => {
10+
vi.useRealTimers();
11+
});
12+
13+
describe('useThrottle', () => {
14+
it('fires once after the wait period even when called multiple times', () => {
15+
const fn = vi.fn();
16+
const { result } = renderHook(() => useThrottle(fn, { wait: 200 }));
17+
18+
act(() => {
19+
result.current('a');
20+
result.current('b');
21+
result.current('c');
22+
});
23+
24+
expect(fn).not.toHaveBeenCalled();
25+
26+
act(() => {
27+
vi.advanceTimersByTime(200);
28+
});
29+
30+
expect(fn).toHaveBeenCalledOnce();
31+
});
32+
33+
it('fires with the latest args when called multiple times within the wait', () => {
34+
const fn = vi.fn();
35+
const { result } = renderHook(() => useThrottle(fn, { wait: 200 }));
36+
37+
act(() => {
38+
result.current('first');
39+
result.current('second');
40+
result.current('third');
41+
});
42+
43+
act(() => {
44+
vi.advanceTimersByTime(200);
45+
});
46+
47+
expect(fn).toHaveBeenCalledWith('third');
48+
});
49+
50+
it('does not fire before the wait period ends', () => {
51+
const fn = vi.fn();
52+
const { result } = renderHook(() => useThrottle(fn, { wait: 300 }));
53+
54+
act(() => {
55+
result.current('x');
56+
vi.advanceTimersByTime(299);
57+
});
58+
59+
expect(fn).not.toHaveBeenCalled();
60+
});
61+
62+
it('allows a new invocation after the wait period resets', () => {
63+
const fn = vi.fn();
64+
const { result } = renderHook(() => useThrottle(fn, { wait: 100 }));
65+
66+
act(() => {
67+
result.current('first-burst');
68+
vi.advanceTimersByTime(100);
69+
});
70+
71+
act(() => {
72+
result.current('second-burst');
73+
vi.advanceTimersByTime(100);
74+
});
75+
76+
expect(fn).toHaveBeenCalledTimes(2);
77+
expect(fn).toHaveBeenNthCalledWith(1, 'first-burst');
78+
expect(fn).toHaveBeenNthCalledWith(2, 'second-burst');
79+
});
80+
81+
it('fires immediately on first call when immediate option is set', () => {
82+
const fn = vi.fn();
83+
const { result } = renderHook(() => useThrottle(fn, { wait: 200, immediate: true }));
84+
85+
act(() => {
86+
result.current('now');
87+
});
88+
89+
expect(fn).toHaveBeenCalledOnce();
90+
expect(fn).toHaveBeenCalledWith('now');
91+
});
92+
});

src/app/utils/mimeTypes.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
getBlobSafeMimeType,
4+
mimeTypeToExt,
5+
getFileNameExt,
6+
getFileNameWithoutExt,
7+
FALLBACK_MIMETYPE,
8+
} from './mimeTypes';
9+
10+
describe('getBlobSafeMimeType', () => {
11+
it('passes through known image types', () => {
12+
expect(getBlobSafeMimeType('image/jpeg')).toBe('image/jpeg');
13+
expect(getBlobSafeMimeType('image/png')).toBe('image/png');
14+
expect(getBlobSafeMimeType('image/webp')).toBe('image/webp');
15+
});
16+
17+
it('passes through known video and audio types', () => {
18+
expect(getBlobSafeMimeType('video/mp4')).toBe('video/mp4');
19+
expect(getBlobSafeMimeType('audio/mpeg')).toBe('audio/mpeg');
20+
});
21+
22+
it('converts video/quicktime to video/mp4', () => {
23+
expect(getBlobSafeMimeType('video/quicktime')).toBe('video/mp4');
24+
});
25+
26+
it('returns fallback for unknown mime types', () => {
27+
expect(getBlobSafeMimeType('application/x-unknown')).toBe(FALLBACK_MIMETYPE);
28+
expect(getBlobSafeMimeType('image/bmp')).toBe(FALLBACK_MIMETYPE);
29+
});
30+
31+
it('strips charset parameter before checking allowlist', () => {
32+
expect(getBlobSafeMimeType('text/plain; charset=utf-8')).toBe('text/plain');
33+
});
34+
35+
it('returns fallback for non-string input', () => {
36+
// @ts-expect-error — testing runtime safety for external data
37+
expect(getBlobSafeMimeType(null)).toBe(FALLBACK_MIMETYPE);
38+
// @ts-expect-error
39+
expect(getBlobSafeMimeType(42)).toBe(FALLBACK_MIMETYPE);
40+
});
41+
});
42+
43+
describe('mimeTypeToExt', () => {
44+
it.each([
45+
['image/jpeg', 'jpeg'],
46+
['image/png', 'png'],
47+
['video/mp4', 'mp4'],
48+
['audio/ogg', 'ogg'],
49+
['application/pdf', 'pdf'],
50+
['text/plain', 'plain'],
51+
])('%s → %s', (mimeType, expected) => {
52+
expect(mimeTypeToExt(mimeType)).toBe(expected);
53+
});
54+
});
55+
56+
describe('getFileNameExt', () => {
57+
it.each([
58+
['photo.jpg', 'jpg'],
59+
['archive.tar.gz', 'gz'],
60+
['readme.MD', 'MD'],
61+
// No dot: lastIndexOf returns -1, slice(0) returns the full string
62+
['noextension', 'noextension'],
63+
])('%s → "%s"', (filename, expected) => {
64+
expect(getFileNameExt(filename)).toBe(expected);
65+
});
66+
});
67+
68+
describe('getFileNameWithoutExt', () => {
69+
it.each([
70+
['photo.jpg', 'photo'],
71+
['archive.tar.gz', 'archive.tar'],
72+
['noextension', 'noextension'],
73+
['.gitignore', '.gitignore'],
74+
['.hidden.txt', '.hidden'],
75+
])('%s → "%s"', (filename, expected) => {
76+
expect(getFileNameWithoutExt(filename)).toBe(expected);
77+
});
78+
});

src/app/utils/pronouns.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { parsePronounsInput, filterPronounsByLanguage } from './pronouns';
3+
4+
describe('parsePronounsInput', () => {
5+
it('parses a single pronoun without a language prefix', () => {
6+
expect(parsePronounsInput('he/him')).toEqual([{ summary: 'he/him', language: 'en' }]);
7+
});
8+
9+
it('parses multiple comma-separated pronouns', () => {
10+
expect(parsePronounsInput('he/him,she/her')).toEqual([
11+
{ summary: 'he/him', language: 'en' },
12+
{ summary: 'she/her', language: 'en' },
13+
]);
14+
});
15+
16+
it('parses a pronoun with a language prefix', () => {
17+
expect(parsePronounsInput('de:er/ihm')).toEqual([{ language: 'de', summary: 'er/ihm' }]);
18+
});
19+
20+
it('trims whitespace around entries', () => {
21+
expect(parsePronounsInput(' he/him , she/her ')).toEqual([
22+
{ summary: 'he/him', language: 'en' },
23+
{ summary: 'she/her', language: 'en' },
24+
]);
25+
});
26+
27+
it('truncates summary to 16 characters', () => {
28+
const longSummary = 'this/is/way/too/long';
29+
const result = parsePronounsInput(longSummary);
30+
expect(result[0]?.summary).toHaveLength(16);
31+
expect(result[0]?.summary).toBe('this/is/way/too/');
32+
});
33+
34+
it('falls back to "en" when language prefix is empty', () => {
35+
expect(parsePronounsInput(':he/him')).toEqual([{ language: 'en', summary: 'he/him' }]);
36+
});
37+
38+
it('returns empty array for empty string', () => {
39+
expect(parsePronounsInput('')).toEqual([]);
40+
});
41+
42+
it.each([null, undefined, 42 as unknown as string])(
43+
'returns empty array for non-string input: %s',
44+
(input) => {
45+
expect(parsePronounsInput(input as string)).toEqual([]);
46+
}
47+
);
48+
});
49+
50+
describe('filterPronounsByLanguage', () => {
51+
const pronouns = [
52+
{ summary: 'he/him', language: 'en' },
53+
{ summary: 'er/ihm', language: 'de' },
54+
{ summary: 'il/lui', language: 'fr' },
55+
];
56+
57+
it('returns all pronouns when filtering is disabled', () => {
58+
const result = filterPronounsByLanguage(pronouns, false, ['en']);
59+
expect(result).toHaveLength(3);
60+
});
61+
62+
it('filters to matching language when enabled', () => {
63+
const result = filterPronounsByLanguage(pronouns, true, ['de']);
64+
expect(result).toHaveLength(1);
65+
expect(result[0]?.language).toBe('de');
66+
});
67+
68+
it('returns all pronouns when no entries match (fallthrough)', () => {
69+
const result = filterPronounsByLanguage(pronouns, true, ['ja']);
70+
expect(result).toHaveLength(3);
71+
});
72+
73+
it('matches multiple languages', () => {
74+
const result = filterPronounsByLanguage(pronouns, true, ['en', 'fr']);
75+
expect(result).toHaveLength(2);
76+
expect(result.map((p) => p.language)).toEqual(['en', 'fr']);
77+
});
78+
79+
it('is case-insensitive for language matching', () => {
80+
const result = filterPronounsByLanguage(pronouns, true, ['EN']);
81+
expect(result).toHaveLength(1);
82+
expect(result[0]?.language).toBe('en');
83+
});
84+
85+
it('returns empty array for non-array input', () => {
86+
// @ts-expect-error — testing runtime safety
87+
expect(filterPronounsByLanguage(null, true, ['en'])).toEqual([]);
88+
});
89+
});

0 commit comments

Comments
 (0)