mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
MAESTRO: add web hook tests and cleanup
This commit is contained in:
100
src/__tests__/web/hooks/useKeyboardVisibility.test.ts
Normal file
100
src/__tests__/web/hooks/useKeyboardVisibility.test.ts
Normal file
@@ -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));
|
||||
});
|
||||
});
|
||||
157
src/__tests__/web/hooks/useLongPressMenu.test.ts
Normal file
157
src/__tests__/web/hooks/useLongPressMenu.test.ts
Normal file
@@ -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<HTMLButtonElement> {
|
||||
return {
|
||||
currentTarget: target,
|
||||
touches: [{ clientX: 0, clientY: 0 }],
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.TouchEvent<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
114
src/__tests__/web/hooks/useSlashCommandAutocomplete.test.ts
Normal file
114
src/__tests__/web/hooks/useSlashCommandAutocomplete.test.ts
Normal file
@@ -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<HTMLTextAreaElement>;
|
||||
|
||||
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('');
|
||||
});
|
||||
});
|
||||
171
src/__tests__/web/hooks/useVoiceInput.test.ts
Normal file
171
src/__tests__/web/hooks/useVoiceInput.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
* their device settings.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Device color scheme preferences
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user