MAESTRO: add web hook tests and cleanup

This commit is contained in:
Pedram Amini
2025-12-21 15:53:49 -06:00
parent 1d42e40932
commit 60cfea1106
7 changed files with 548 additions and 3 deletions

View 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));
});
});

View 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);
});
});

View 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('');
});
});

View 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();
});
});

View File

@@ -7,7 +7,7 @@
* their device settings.
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect } from 'react';
/**
* Device color scheme preferences

View File

@@ -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;

View File

@@ -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]
);
/**