diff --git a/src/__tests__/web/hooks/useKeyboardVisibility.test.ts b/src/__tests__/web/hooks/useKeyboardVisibility.test.ts new file mode 100644 index 00000000..2e88b3e2 --- /dev/null +++ b/src/__tests__/web/hooks/useKeyboardVisibility.test.ts @@ -0,0 +1,100 @@ +/** + * Tests for useKeyboardVisibility hook + * + * Covers: + * - Default state when Visual Viewport API is unavailable + * - Keyboard offset calculation when viewport shrinks + * - Event listener registration and cleanup + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useKeyboardVisibility } from '../../../web/hooks/useKeyboardVisibility'; + +type MockViewport = { + height: number; + offsetTop: number; + addEventListener: (event: string, handler: () => void) => void; + removeEventListener: (event: string, handler: () => void) => void; +}; + +function setVisualViewport(mockViewport?: MockViewport) { + if (mockViewport) { + Object.defineProperty(window, 'visualViewport', { + value: mockViewport, + configurable: true, + writable: true, + }); + } else { + Object.defineProperty(window, 'visualViewport', { + value: undefined, + configurable: true, + writable: true, + }); + } +} + +describe('useKeyboardVisibility', () => { + const originalInnerHeight = window.innerHeight; + + beforeEach(() => { + vi.restoreAllMocks(); + setVisualViewport(undefined); + }); + + afterEach(() => { + window.innerHeight = originalInnerHeight; + setVisualViewport(undefined); + }); + + it('returns default state when Visual Viewport API is unavailable', () => { + setVisualViewport(undefined); + const { result } = renderHook(() => useKeyboardVisibility()); + + expect(result.current.keyboardOffset).toBe(0); + expect(result.current.isKeyboardVisible).toBe(false); + }); + + it('calculates keyboard offset from viewport height', async () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + + setVisualViewport({ + height: 600, + offsetTop: 0, + addEventListener, + removeEventListener, + }); + + window.innerHeight = 800; + + const { result } = renderHook(() => useKeyboardVisibility()); + + await waitFor(() => { + expect(result.current.keyboardOffset).toBe(200); + expect(result.current.isKeyboardVisible).toBe(true); + }); + }); + + it('registers and cleans up viewport listeners', () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + + setVisualViewport({ + height: 700, + offsetTop: 0, + addEventListener, + removeEventListener, + }); + + const { unmount } = renderHook(() => useKeyboardVisibility()); + + expect(addEventListener).toHaveBeenCalledWith('resize', expect.any(Function)); + expect(addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + + unmount(); + + expect(removeEventListener).toHaveBeenCalledWith('resize', expect.any(Function)); + expect(removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + }); +}); diff --git a/src/__tests__/web/hooks/useLongPressMenu.test.ts b/src/__tests__/web/hooks/useLongPressMenu.test.ts new file mode 100644 index 00000000..2aa62aa6 --- /dev/null +++ b/src/__tests__/web/hooks/useLongPressMenu.test.ts @@ -0,0 +1,157 @@ +/** + * Tests for useLongPressMenu hook + * + * Covers: + * - Long-press detection and menu opening + * - Canceling long press on touch move + * - Quick action handling + * - Manual menu close + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useLongPressMenu } from '../../../web/hooks/useLongPressMenu'; + +function createTouchEvent(target: HTMLButtonElement): React.TouchEvent { + return { + currentTarget: target, + touches: [{ clientX: 0, clientY: 0 }], + preventDefault: vi.fn(), + } as unknown as React.TouchEvent; +} + +describe('useLongPressMenu', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('opens the menu after long press', () => { + const button = document.createElement('button'); + button.getBoundingClientRect = vi.fn(() => ({ + left: 10, + top: 20, + width: 30, + height: 40, + right: 40, + bottom: 60, + x: 10, + y: 20, + toJSON: () => {}, + })) as unknown as () => DOMRect; + + const { result } = renderHook(() => + useLongPressMenu({ + inputMode: 'ai', + value: 'hello', + }) + ); + + act(() => { + result.current.sendButtonRef.current = button; + }); + + act(() => { + result.current.handleTouchStart(createTouchEvent(button)); + vi.advanceTimersByTime(500); + }); + + expect(result.current.isMenuOpen).toBe(true); + expect(result.current.menuAnchor).toEqual({ x: 25, y: 20 }); + }); + + it('cancels long press on touch move', () => { + const button = document.createElement('button'); + button.getBoundingClientRect = vi.fn(() => ({ + left: 0, + top: 0, + width: 10, + height: 10, + right: 10, + bottom: 10, + x: 0, + y: 0, + toJSON: () => {}, + })) as unknown as () => DOMRect; + + const { result } = renderHook(() => + useLongPressMenu({ + inputMode: 'ai', + value: 'hello', + }) + ); + + act(() => { + result.current.sendButtonRef.current = button; + }); + + act(() => { + result.current.handleTouchStart(createTouchEvent(button)); + result.current.handleTouchMove(); + vi.advanceTimersByTime(500); + }); + + expect(result.current.isMenuOpen).toBe(false); + }); + + it('handles quick action selection', () => { + const onModeToggle = vi.fn(); + const { result } = renderHook(() => + useLongPressMenu({ + inputMode: 'ai', + onModeToggle, + value: 'hello', + }) + ); + + act(() => { + result.current.handleQuickAction('switch_mode'); + }); + + expect(onModeToggle).toHaveBeenCalledWith('terminal'); + }); + + it('closes the menu when requested', () => { + const button = document.createElement('button'); + button.getBoundingClientRect = vi.fn(() => ({ + left: 0, + top: 0, + width: 10, + height: 10, + right: 10, + bottom: 10, + x: 0, + y: 0, + toJSON: () => {}, + })) as unknown as () => DOMRect; + + const { result } = renderHook(() => + useLongPressMenu({ + inputMode: 'ai', + value: 'hello', + }) + ); + + act(() => { + result.current.sendButtonRef.current = button; + }); + + act(() => { + result.current.handleTouchStart(createTouchEvent(button)); + vi.advanceTimersByTime(500); + }); + + expect(result.current.isMenuOpen).toBe(true); + + act(() => { + result.current.closeMenu(); + }); + + expect(result.current.isMenuOpen).toBe(false); + }); +}); diff --git a/src/__tests__/web/hooks/useSlashCommandAutocomplete.test.ts b/src/__tests__/web/hooks/useSlashCommandAutocomplete.test.ts new file mode 100644 index 00000000..5842f962 --- /dev/null +++ b/src/__tests__/web/hooks/useSlashCommandAutocomplete.test.ts @@ -0,0 +1,114 @@ +/** + * Tests for useSlashCommandAutocomplete hook + * + * Covers: + * - Open/close behavior based on input value + * - Manual open + * - Command selection auto-submit flow + * - Close handling for partial slash input + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useSlashCommandAutocomplete } from '../../../web/hooks/useSlashCommandAutocomplete'; + +describe('useSlashCommandAutocomplete', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('opens when input starts with slash and has no spaces', () => { + const { result } = renderHook(() => + useSlashCommandAutocomplete({ + inputValue: '', + isControlled: true, + }) + ); + + act(() => { + result.current.handleInputChange('/he'); + }); + + expect(result.current.isOpen).toBe(true); + + act(() => { + result.current.handleInputChange('/help me'); + }); + + expect(result.current.isOpen).toBe(false); + }); + + it('opens autocomplete manually and resets selection', () => { + const { result } = renderHook(() => + useSlashCommandAutocomplete({ + inputValue: '', + isControlled: true, + }) + ); + + act(() => { + result.current.setSelectedIndex(3); + result.current.openAutocomplete(); + }); + + expect(result.current.isOpen).toBe(true); + expect(result.current.selectedIndex).toBe(0); + }); + + it('handles command selection and auto-submit', () => { + const onChange = vi.fn(); + const onSubmit = vi.fn(); + const focus = vi.fn(); + const inputRef = { current: { focus } } as React.RefObject; + + const { result } = renderHook(() => + useSlashCommandAutocomplete({ + inputValue: '/he', + isControlled: true, + onChange, + onSubmit, + inputRef, + }) + ); + + act(() => { + result.current.handleSelectCommand('/help'); + }); + + expect(onChange).toHaveBeenCalledWith('/help'); + expect(result.current.isOpen).toBe(false); + expect(focus).toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(50); + }); + + expect(onSubmit).toHaveBeenCalledWith('/help'); + expect(onChange).toHaveBeenCalledWith(''); + }); + + it('clears partial slash input on close', () => { + const onChange = vi.fn(); + + const { result } = renderHook(() => + useSlashCommandAutocomplete({ + inputValue: '/hel', + isControlled: true, + onChange, + }) + ); + + act(() => { + result.current.handleClose(); + }); + + expect(result.current.isOpen).toBe(false); + expect(onChange).toHaveBeenCalledWith(''); + }); +}); diff --git a/src/__tests__/web/hooks/useVoiceInput.test.ts b/src/__tests__/web/hooks/useVoiceInput.test.ts new file mode 100644 index 00000000..82567ef1 --- /dev/null +++ b/src/__tests__/web/hooks/useVoiceInput.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for useVoiceInput hook + * + * Covers: + * - Speech recognition support detection + * - Start/stop listening flow + * - Transcription updates from recognition results + * - Cleanup on unmount + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + useVoiceInput, + isSpeechRecognitionSupported, + type SpeechRecognitionEvent, + type SpeechRecognitionResultList, +} from '../../../web/hooks/useVoiceInput'; + +vi.mock('../../../web/utils/logger', () => ({ + webLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +let lastRecognitionInstance: MockSpeechRecognition | null = null; + +class MockSpeechRecognition { + continuous = false; + interimResults = false; + lang = ''; + maxAlternatives = 1; + onaudioend = null; + onaudiostart = null; + onend: ((this: MockSpeechRecognition, ev: Event) => void) | null = null; + onerror = null; + onnomatch = null; + onresult: ((this: MockSpeechRecognition, ev: SpeechRecognitionEvent) => void) | null = null; + onsoundend = null; + onsoundstart = null; + onspeechend = null; + onspeechstart = null; + onstart: ((this: MockSpeechRecognition, ev: Event) => void) | null = null; + + start = vi.fn(() => { + this.onstart?.call(this, new Event('start')); + }); + + stop = vi.fn(() => { + this.onend?.call(this, new Event('end')); + }); + + abort = vi.fn(); + + constructor() { + lastRecognitionInstance = this; + } +} + +function setSpeechRecognitionAvailable() { + Object.defineProperty(window, 'SpeechRecognition', { + value: MockSpeechRecognition, + configurable: true, + writable: true, + }); +} + +function clearSpeechRecognition() { + Object.defineProperty(window, 'SpeechRecognition', { + value: undefined, + configurable: true, + writable: true, + }); + lastRecognitionInstance = null; +} + +describe('useVoiceInput', () => { + beforeEach(() => { + vi.clearAllMocks(); + setSpeechRecognitionAvailable(); + }); + + afterEach(() => { + clearSpeechRecognition(); + }); + + it('detects speech recognition support', () => { + expect(isSpeechRecognitionSupported()).toBe(true); + }); + + it('starts listening and updates transcription', () => { + const onTranscriptionChange = vi.fn(); + + const { result } = renderHook(() => + useVoiceInput({ + currentValue: 'hello', + onTranscriptionChange, + }) + ); + + act(() => { + result.current.startVoiceInput(); + }); + + expect(result.current.isListening).toBe(true); + expect(lastRecognitionInstance?.start).toHaveBeenCalled(); + + const alt = { transcript: 'world', confidence: 0.9 }; + const mockResult = { + isFinal: true, + length: 1, + 0: alt, + item: () => alt, + }; + const mockResults = [mockResult] as unknown as SpeechRecognitionResultList; + (mockResults as { item?: (index: number) => unknown }).item = (index: number) => mockResults[index]; + + act(() => { + lastRecognitionInstance?.onresult?.call(lastRecognitionInstance, { + resultIndex: 0, + results: mockResults, + } as SpeechRecognitionEvent); + }); + + expect(onTranscriptionChange).toHaveBeenCalledWith('hello world'); + }); + + it('stops listening when toggled off', () => { + const onTranscriptionChange = vi.fn(); + + const { result } = renderHook(() => + useVoiceInput({ + currentValue: '', + onTranscriptionChange, + }) + ); + + act(() => { + result.current.startVoiceInput(); + }); + + act(() => { + result.current.stopVoiceInput(); + }); + + expect(lastRecognitionInstance?.stop).toHaveBeenCalled(); + expect(result.current.isListening).toBe(false); + }); + + it('aborts recognition on unmount', () => { + const onTranscriptionChange = vi.fn(); + + const { result, unmount } = renderHook(() => + useVoiceInput({ + currentValue: '', + onTranscriptionChange, + }) + ); + + act(() => { + result.current.startVoiceInput(); + }); + + unmount(); + + expect(lastRecognitionInstance?.abort).toHaveBeenCalled(); + }); +}); diff --git a/src/web/hooks/useDeviceColorScheme.ts b/src/web/hooks/useDeviceColorScheme.ts index 2be99ceb..83cab8a5 100644 --- a/src/web/hooks/useDeviceColorScheme.ts +++ b/src/web/hooks/useDeviceColorScheme.ts @@ -7,7 +7,7 @@ * their device settings. */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; /** * Device color scheme preferences diff --git a/src/web/hooks/useKeyboardVisibility.ts b/src/web/hooks/useKeyboardVisibility.ts index 71ac4543..4a48a401 100644 --- a/src/web/hooks/useKeyboardVisibility.ts +++ b/src/web/hooks/useKeyboardVisibility.ts @@ -66,6 +66,7 @@ export function useKeyboardVisibility(): UseKeyboardVisibilityReturn { * Calculate keyboard offset from viewport dimensions */ const calculateOffset = useCallback(() => { + if (typeof window === 'undefined') return; const viewport = window.visualViewport; if (!viewport) return; @@ -86,6 +87,7 @@ export function useKeyboardVisibility(): UseKeyboardVisibilityReturn { }, []); useEffect(() => { + if (typeof window === 'undefined') return; const viewport = window.visualViewport; if (!viewport) return; diff --git a/src/web/hooks/usePullToRefresh.ts b/src/web/hooks/usePullToRefresh.ts index 37291eb9..6ef13a37 100644 --- a/src/web/hooks/usePullToRefresh.ts +++ b/src/web/hooks/usePullToRefresh.ts @@ -71,6 +71,7 @@ export function usePullToRefresh(options: UsePullToRefreshOptions): UsePullToRef threshold = GESTURE_THRESHOLDS.pullToRefresh, maxPull = 150, enabled = true, + containerRef, } = options; const [pullDistance, setPullDistance] = useState(0); @@ -108,10 +109,10 @@ export function usePullToRefresh(options: UsePullToRefreshOptions): UsePullToRef touchStartX.current = touch.clientX; // Check if we're at the top of the scroll container - const target = e.currentTarget as HTMLElement; + const target = containerRef?.current ?? (e.currentTarget as HTMLElement); isScrolledToTop.current = checkScrollTop(target); }, - [enabled, isRefreshing, checkScrollTop] + [enabled, isRefreshing, checkScrollTop, containerRef] ); /**