Skip to content

Commit bfe766e

Browse files
committed
test: add unit and integration tests for utils and hooks
1 parent 2ac73d0 commit bfe766e

7 files changed

Lines changed: 639 additions & 2 deletions

File tree

knip.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
"buffer",
1010
"@element-hq/element-call-embedded",
1111
"@matrix-org/matrix-sdk-crypto-wasm",
12-
"@testing-library/react",
1312
"@testing-library/user-event"
1413
],
1514
"ignoreBinaries": ["knope"],
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Integration tests: renderHook exercises the full React lifecycle including
2+
// useAlive (cleanup on unmount) and retry-race-condition logic.
3+
import { describe, it, expect } from 'vitest';
4+
import { renderHook, act } from '@testing-library/react';
5+
import { useAsyncCallback, AsyncStatus } from './useAsyncCallback';
6+
7+
describe('useAsyncCallback', () => {
8+
it('starts in Idle state', () => {
9+
const { result } = renderHook(() => useAsyncCallback(async () => 'value'));
10+
const [state] = result.current;
11+
expect(state.status).toBe(AsyncStatus.Idle);
12+
});
13+
14+
it('transitions to Success with returned data', async () => {
15+
const { result } = renderHook(() => useAsyncCallback(async () => 42));
16+
17+
await act(async () => {
18+
await result.current[1]();
19+
});
20+
21+
expect(result.current[0]).toEqual({ status: AsyncStatus.Success, data: 42 });
22+
});
23+
24+
it('transitions to Error when the async function throws', async () => {
25+
const boom = new Error('boom');
26+
const { result } = renderHook(() =>
27+
useAsyncCallback(async () => {
28+
throw boom;
29+
})
30+
);
31+
32+
await act(async () => {
33+
await result.current[1]().catch(() => {});
34+
});
35+
36+
expect(result.current[0]).toEqual({ status: AsyncStatus.Error, error: boom });
37+
});
38+
39+
it('ignores the result of a stale (superseded) request', async () => {
40+
// Two calls are made. The first resolves AFTER the second — its result should
41+
// be discarded so the final state reflects only the second call.
42+
let resolveFirst!: (v: string) => void;
43+
let resolveSecond!: (v: string) => void;
44+
let callCount = 0;
45+
46+
const { result } = renderHook(() =>
47+
useAsyncCallback(async () => {
48+
callCount += 1;
49+
if (callCount === 1) {
50+
return new Promise<string>((res) => {
51+
resolveFirst = res;
52+
});
53+
}
54+
return new Promise<string>((res) => {
55+
resolveSecond = res;
56+
});
57+
})
58+
);
59+
60+
// Fire both requests before either resolves
61+
act(() => {
62+
result.current[1]();
63+
});
64+
act(() => {
65+
result.current[1]();
66+
});
67+
68+
// Resolve the stale first request — its result should be ignored
69+
await act(async () => {
70+
resolveFirst('stale');
71+
await Promise.resolve();
72+
});
73+
74+
// Resolve the fresh second request — this should be the final state
75+
await act(async () => {
76+
resolveSecond('fresh');
77+
await Promise.resolve();
78+
});
79+
80+
const successStates = result.current[0];
81+
expect(successStates.status).toBe(AsyncStatus.Success);
82+
if (successStates.status === AsyncStatus.Success) {
83+
expect(successStates.data).toBe('fresh');
84+
}
85+
});
86+
87+
it('does not call setState after the component unmounts', async () => {
88+
let resolveAfterUnmount!: (v: string) => void;
89+
const stateChanges: string[] = [];
90+
91+
const { result, unmount } = renderHook(() =>
92+
useAsyncCallback(
93+
async () =>
94+
new Promise<string>((res) => {
95+
resolveAfterUnmount = res;
96+
})
97+
)
98+
);
99+
100+
// Track state changes via the third returned setter
101+
const [, callback, setState] = result.current;
102+
const originalSetState = setState;
103+
// Patch setState to record calls
104+
result.current[2] = (s) => {
105+
stateChanges.push(typeof s === 'function' ? 'fn' : s.status);
106+
originalSetState(s);
107+
};
108+
109+
act(() => {
110+
callback();
111+
});
112+
113+
unmount();
114+
115+
// Resolve after unmount — alive() returns false, so state should NOT be updated
116+
await act(async () => {
117+
resolveAfterUnmount('late');
118+
await Promise.resolve();
119+
});
120+
121+
// Only the Loading state (queued before unmount) may have been emitted;
122+
// Success must not appear after unmount.
123+
const successCalls = stateChanges.filter((s) => s === AsyncStatus.Success);
124+
expect(successCalls).toHaveLength(0);
125+
});
126+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Integration tests: renders a real React component tree via renderHook.
2+
import { describe, it, expect } from 'vitest';
3+
import { renderHook } from '@testing-library/react';
4+
import { usePreviousValue } from './usePreviousValue';
5+
6+
describe('usePreviousValue', () => {
7+
it('returns the initial value on the first render', () => {
8+
const { result } = renderHook(() => usePreviousValue('current', 'initial'));
9+
expect(result.current).toBe('initial');
10+
});
11+
12+
it('returns the previous value after a prop update', () => {
13+
const { result, rerender } = renderHook(
14+
({ value }: { value: string }) => usePreviousValue(value, 'initial'),
15+
{ initialProps: { value: 'first' } }
16+
);
17+
18+
// Before any update: returns initial
19+
expect(result.current).toBe('initial');
20+
21+
rerender({ value: 'second' });
22+
expect(result.current).toBe('first');
23+
24+
rerender({ value: 'third' });
25+
expect(result.current).toBe('second');
26+
});
27+
28+
it('works with numeric values', () => {
29+
const { result, rerender } = renderHook(({ n }: { n: number }) => usePreviousValue(n, 0), {
30+
initialProps: { n: 1 },
31+
});
32+
33+
expect(result.current).toBe(0);
34+
rerender({ n: 42 });
35+
expect(result.current).toBe(1);
36+
});
37+
38+
it('works with object values (reference equality)', () => {
39+
const a = { x: 1 };
40+
const b = { x: 2 };
41+
42+
const { result, rerender } = renderHook(
43+
({ obj }: { obj: { x: number } }) => usePreviousValue(obj, a),
44+
{ initialProps: { obj: a } }
45+
);
46+
47+
expect(result.current).toBe(a);
48+
rerender({ obj: b });
49+
expect(result.current).toBe(a);
50+
});
51+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Integration tests: uses fake timers to control setTimeout behaviour.
2+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3+
import { renderHook, act } from '@testing-library/react';
4+
import { useTimeoutToggle } from './useTimeoutToggle';
5+
6+
describe('useTimeoutToggle', () => {
7+
beforeEach(() => {
8+
vi.useFakeTimers();
9+
});
10+
11+
afterEach(() => {
12+
vi.useRealTimers();
13+
});
14+
15+
it('starts with the default initial value of false', () => {
16+
const { result } = renderHook(() => useTimeoutToggle());
17+
expect(result.current[0]).toBe(false);
18+
});
19+
20+
it('becomes true after trigger() is called', () => {
21+
const { result } = renderHook(() => useTimeoutToggle());
22+
act(() => {
23+
result.current[1]();
24+
});
25+
expect(result.current[0]).toBe(true);
26+
});
27+
28+
it('resets to false after the default 1500ms duration', () => {
29+
const { result } = renderHook(() => useTimeoutToggle());
30+
31+
act(() => {
32+
result.current[1]();
33+
});
34+
expect(result.current[0]).toBe(true);
35+
36+
act(() => {
37+
vi.advanceTimersByTime(1500);
38+
});
39+
expect(result.current[0]).toBe(false);
40+
});
41+
42+
it('does not reset before the duration has elapsed', () => {
43+
const { result } = renderHook(() => useTimeoutToggle(500));
44+
45+
act(() => {
46+
result.current[1]();
47+
});
48+
49+
act(() => {
50+
vi.advanceTimersByTime(499);
51+
});
52+
expect(result.current[0]).toBe(true);
53+
54+
act(() => {
55+
vi.advanceTimersByTime(1);
56+
});
57+
expect(result.current[0]).toBe(false);
58+
});
59+
60+
it('re-triggering before timeout resets the countdown', () => {
61+
const { result } = renderHook(() => useTimeoutToggle(1000));
62+
63+
act(() => {
64+
result.current[1](); // t=0: trigger
65+
});
66+
67+
act(() => {
68+
vi.advanceTimersByTime(800); // t=800
69+
});
70+
71+
act(() => {
72+
result.current[1](); // t=800: re-trigger, timer resets
73+
});
74+
75+
act(() => {
76+
vi.advanceTimersByTime(800); // t=1600 — only 800ms since re-trigger
77+
});
78+
expect(result.current[0]).toBe(true); // still active
79+
80+
act(() => {
81+
vi.advanceTimersByTime(200); // t=1800 — 1000ms since re-trigger
82+
});
83+
expect(result.current[0]).toBe(false);
84+
});
85+
86+
it('supports a custom initial value of true (inverted toggle)', () => {
87+
const { result } = renderHook(() => useTimeoutToggle(1500, true));
88+
89+
expect(result.current[0]).toBe(true);
90+
91+
act(() => {
92+
result.current[1](); // trigger → false
93+
});
94+
expect(result.current[0]).toBe(false);
95+
96+
act(() => {
97+
vi.advanceTimersByTime(1500); // resets back to true
98+
});
99+
expect(result.current[0]).toBe(true);
100+
});
101+
});

0 commit comments

Comments
 (0)