Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions hooks/__tests__/useMobileQrScanner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { act, renderHook } from '@testing-library/react';
import jsQR from 'jsqr';
import { useMobileQrScanner } from '@/hooks/useMobileQrScanner';

jest.mock('jsqr', () => ({ __esModule: true, default: jest.fn() }));

const mockedJsQr = jsQR as jest.MockedFunction<typeof jsQR>;
const stopTrack = jest.fn();
const stream = {
getTracks: () => [{ stop: stopTrack }],
} as unknown as MediaStream;
const context = {
drawImage: jest.fn(),
getImageData: jest.fn(() => ({
data: new Uint8ClampedArray(16),
width: 2,
height: 2,
})),
};

function videoElement(): HTMLVideoElement {
const video = document.createElement('video');
Object.defineProperty(video, 'readyState', {
configurable: true,
value: HTMLMediaElement.HAVE_CURRENT_DATA,
});
Object.defineProperty(video, 'videoWidth', { configurable: true, value: 2 });
Object.defineProperty(video, 'videoHeight', { configurable: true, value: 2 });
Object.defineProperty(video, 'srcObject', {
configurable: true,
writable: true,
value: null,
});
video.play = jest.fn().mockResolvedValue(undefined);
return video;
}

describe('useMobileQrScanner', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia: jest.fn().mockResolvedValue(stream) },
});
jest
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockReturnValue(context as unknown as CanvasRenderingContext2D);
});

afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
jest.restoreAllMocks();
});

it('requests the rear camera with iOS-safe constraints', async () => {
const { result } = renderHook(() =>
useMobileQrScanner({ autoStart: false }),
);
const video = videoElement();
result.current.videoRef.current = video;

await act(async () => result.current.startScanning());

expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({
audio: false,
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
expect(video.getAttribute('playsinline')).toBe('true');
expect(result.current.permission).toBe('granted');
expect(result.current.isScanning).toBe(true);
});

it('decodes one frame per second and stops after a result', async () => {
mockedJsQr.mockReturnValueOnce({
data: 'handoff-123',
binaryData: [],
chunks: [],
location: {} as ReturnType<typeof jsQR> extends infer R
? R extends { location: infer L }
? L
: never
: never,
version: 1,
});
const onDecode = jest.fn();
const { result } = renderHook(() =>
useMobileQrScanner({ autoStart: false, onDecode }),
);
result.current.videoRef.current = videoElement();
await act(async () => result.current.startScanning());

act(() => {
jest.advanceTimersByTime(999);
});
expect(mockedJsQr).not.toHaveBeenCalled();

act(() => {
jest.advanceTimersByTime(1);
});

expect(result.current.data).toBe('handoff-123');
expect(onDecode).toHaveBeenCalledWith('handoff-123');
expect(result.current.isScanning).toBe(false);
expect(stopTrack).toHaveBeenCalledTimes(1);
});

it('reports a denied camera permission', async () => {
const denied = new DOMException('Denied', 'NotAllowedError');
jest
.mocked(navigator.mediaDevices.getUserMedia)
.mockRejectedValueOnce(denied);
const { result } = renderHook(() =>
useMobileQrScanner({ autoStart: false }),
);
result.current.videoRef.current = videoElement();

await act(async () => result.current.startScanning());

expect(result.current.permission).toBe('denied');
expect(result.current.error).toBe('Camera permission was denied.');
});

it('reports unsupported camera APIs without throwing', async () => {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: undefined,
});
const { result } = renderHook(() =>
useMobileQrScanner({ autoStart: false }),
);

await act(async () => result.current.startScanning());

expect(result.current.permission).toBe('unavailable');
expect(result.current.error).toContain('not available');
});

it('stops every media track on unmount', async () => {
const { result, unmount } = renderHook(() =>
useMobileQrScanner({ autoStart: false }),
);
result.current.videoRef.current = videoElement();
await act(async () => result.current.startScanning());

unmount();

expect(stopTrack).toHaveBeenCalledTimes(1);
});
});
168 changes: 168 additions & 0 deletions hooks/__tests__/useSignatureCapture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { act, renderHook } from '@testing-library/react';
import { useSignatureCapture } from '@/hooks/useSignatureCapture';

const context = {
beginPath: jest.fn(),
clearRect: jest.fn(),
closePath: jest.fn(),
lineTo: jest.fn(),
moveTo: jest.fn(),
stroke: jest.fn(),
lineCap: '',
lineJoin: '',
lineWidth: 0,
strokeStyle: '',
};

function createCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = 400;
canvas.height = 160;
jest.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
top: 0,
left: 0,
right: 400,
bottom: 160,
width: 400,
height: 160,
toJSON: () => ({}),
});
return canvas;
}

function renderSignatureHook() {
const canvas = createCanvas();
const hook = renderHook(() => {
const signature = useSignatureCapture();
signature.canvasRef.current = canvas;
return signature;
});
return { canvas, hook };
}

function touchEvent(type: string, x: number, y: number): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'touches', {
value: type === 'touchend' ? [] : [{ clientX: x, clientY: y }],
});
Object.defineProperty(event, 'changedTouches', {
value: [{ clientX: x, clientY: y }],
});
return event;
}

describe('useSignatureCapture', () => {
beforeEach(() => {
jest.clearAllMocks();
jest
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockReturnValue(context as unknown as CanvasRenderingContext2D);
jest
.spyOn(HTMLCanvasElement.prototype, 'toDataURL')
.mockReturnValue('data:image/png;base64,c2lnbmF0dXJl');
});

afterEach(() => {
jest.restoreAllMocks();
});

it('tracks mouse drawing state and ink', () => {
const { canvas, hook } = renderSignatureHook();
const { result } = hook;

act(() => {
canvas.dispatchEvent(
new MouseEvent('mousedown', { button: 0, clientX: 10, clientY: 20 }),
);
});
expect(result.current.isDrawing).toBe(true);

act(() => {
canvas.dispatchEvent(
new MouseEvent('mousemove', { clientX: 40, clientY: 50 }),
);
window.dispatchEvent(new MouseEvent('mouseup'));
});

expect(result.current.isDrawing).toBe(false);
expect(result.current.isEmpty()).toBe(false);
expect(context.lineTo).toHaveBeenCalledWith(40, 50);
});

it('supports touch drawing without scrolling the page', () => {
const { canvas, hook } = renderSignatureHook();
const { result } = hook;
const start = touchEvent('touchstart', 12, 18);
const move = touchEvent('touchmove', 30, 45);

act(() => {
canvas.dispatchEvent(start);
canvas.dispatchEvent(move);
window.dispatchEvent(touchEvent('touchend', 30, 45));
});

expect(start.defaultPrevented).toBe(true);
expect(move.defaultPrevented).toBe(true);
expect(context.stroke).toHaveBeenCalled();
expect(result.current.isEmpty()).toBe(false);
});

it('returns a PNG only after ink has been drawn', () => {
const { canvas, hook } = renderSignatureHook();
const { result } = hook;

expect(result.current.save()).toBeNull();
act(() => {
canvas.dispatchEvent(
new MouseEvent('mousedown', { button: 0, clientX: 1, clientY: 1 }),
);
canvas.dispatchEvent(
new MouseEvent('mousemove', { clientX: 2, clientY: 2 }),
);
window.dispatchEvent(new MouseEvent('mouseup'));
});

expect(result.current.save()).toBe('data:image/png;base64,c2lnbmF0dXJl');
expect(canvas.toDataURL).toHaveBeenCalledWith('image/png');
});

it('clears the bitmap and resets state', () => {
const { canvas, hook } = renderSignatureHook();
const { result } = hook;
act(() => {
canvas.dispatchEvent(
new MouseEvent('mousedown', { button: 0, clientX: 1, clientY: 1 }),
);
canvas.dispatchEvent(
new MouseEvent('mousemove', { clientX: 2, clientY: 2 }),
);
result.current.clear();
});

expect(context.clearRect).toHaveBeenCalledWith(0, 0, 400, 160);
expect(result.current.isEmpty()).toBe(true);
expect(result.current.isDrawing).toBe(false);
});

it('removes event listeners when the hook unmounts', () => {
const removeCanvasListener = jest.spyOn(
HTMLCanvasElement.prototype,
'removeEventListener',
);
const removeWindowListener = jest.spyOn(window, 'removeEventListener');
const { hook } = renderSignatureHook();

hook.unmount();

expect(removeCanvasListener).toHaveBeenCalledWith(
'mousedown',
expect.any(Function),
);
expect(removeWindowListener).toHaveBeenCalledWith(
'touchend',
expect.any(Function),
);
});
});
Loading