test(hooks): enhance focus-trap mutation score to 85.25% and finalize release acceptance
- Increase use-focus-trap mutation score from 42.62% to 85.25% (exceeds 50% target) - Add 40 new test cases: focus cycling, Edge Cases, Focusable Elements Detection - Fix jsdom offsetParent limitation via prototype-level mock - Remove temporary debug file (__debug.test.tsx) - Update test-strategy-plan.md to v1.9 with mutation score and debt tracking - Update README.md with final acceptance progress - Quality gates: TypeScript 0 errors, ESLint 0 errors, 121 suites/1549 tests passed - Coverage: 73.62% stmts / 82.52% branches (all thresholds met)
This commit is contained in:
@@ -3,11 +3,16 @@ import { renderHook, render, screen, fireEvent } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { useFocusTrap } from './use-focus-trap';
|
||||
|
||||
|
||||
describe('useFocusTrap', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
document.body.style.overflow = 'unset';
|
||||
// Mock offsetParent at prototype level for jsdom compatibility
|
||||
// jsdom 不支持 layout,offsetParent 始终返回 null,导致 getFocusableElements 过滤失效
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetParent', {
|
||||
get: () => document.body,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Initial State', () => {
|
||||
@@ -98,6 +103,7 @@ describe('useFocusTrap', () => {
|
||||
|
||||
it('should cycle focus from last to first on Tab', () => {
|
||||
render(<TrapComponent isActive />);
|
||||
|
||||
const lastButton = screen.getByTestId('last-btn');
|
||||
|
||||
// Focus the last button
|
||||
@@ -107,8 +113,7 @@ describe('useFocusTrap', () => {
|
||||
// Tab should cycle to first button
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
|
||||
// jsdom may not move focus, but the handler should call firstElement.focus()
|
||||
// At minimum, the handler should not crash
|
||||
// The handler should call preventDefault and firstElement.focus()
|
||||
expect(() => {
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
}).not.toThrow();
|
||||
@@ -116,6 +121,7 @@ describe('useFocusTrap', () => {
|
||||
|
||||
it('should cycle focus from first to last on Shift+Tab', () => {
|
||||
render(<TrapComponent isActive />);
|
||||
|
||||
const firstButton = screen.getByTestId('first-btn');
|
||||
|
||||
// Focus the first button
|
||||
@@ -134,7 +140,7 @@ describe('useFocusTrap', () => {
|
||||
return (
|
||||
<div>
|
||||
<button>outside</button>
|
||||
<div ref={ref}>
|
||||
<div ref={ref} data-testid="trap-3btn">
|
||||
<button data-testid="first-btn">first</button>
|
||||
<button data-testid="middle-btn">middle</button>
|
||||
<button data-testid="last-btn">last</button>
|
||||
@@ -143,6 +149,7 @@ describe('useFocusTrap', () => {
|
||||
);
|
||||
}
|
||||
render(<ThreeButtonTrap />);
|
||||
|
||||
const middleButton = screen.getByTestId('middle-btn');
|
||||
|
||||
middleButton.focus();
|
||||
@@ -154,24 +161,85 @@ describe('useFocusTrap', () => {
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should call preventDefault on Tab key when active', () => {
|
||||
// Tab key triggers preventDefault (even if focus cycling doesn't match)
|
||||
// because the handler is called with the Tab key
|
||||
it('should call preventDefault on Tab when focus is on last element', () => {
|
||||
const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault');
|
||||
render(<TrapComponent isActive />);
|
||||
|
||||
const lastButton = screen.getByTestId('last-btn');
|
||||
lastButton.focus();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
|
||||
// Tab key should trigger the handler and check for focus cycling
|
||||
// The handler is at least called without crashing
|
||||
expect(() => {
|
||||
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
|
||||
}).not.toThrow();
|
||||
|
||||
// The handler should call preventDefault because activeElement === lastElement
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
preventDefaultSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
it('should call preventDefault on Shift+Tab when focus is on first element', () => {
|
||||
const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault');
|
||||
render(<TrapComponent isActive />);
|
||||
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
|
||||
|
||||
// The handler should call preventDefault because activeElement === firstElement
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
preventDefaultSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not call preventDefault on Tab when focus is on middle element', () => {
|
||||
function ThreeButtonTrap() {
|
||||
const ref = useFocusTrap<HTMLDivElement>(true);
|
||||
return (
|
||||
<div>
|
||||
<button>outside</button>
|
||||
<div ref={ref} data-testid="trap-3btn">
|
||||
<button data-testid="first-btn">first</button>
|
||||
<button data-testid="middle-btn">middle</button>
|
||||
<button data-testid="last-btn">last</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault');
|
||||
render(<ThreeButtonTrap />);
|
||||
|
||||
const middleButton = screen.getByTestId('middle-btn');
|
||||
middleButton.focus();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
|
||||
// Middle element is not the last, so preventDefault should NOT be called
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
preventDefaultSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not call preventDefault on Shift+Tab when focus is on last element', () => {
|
||||
function ThreeButtonTrap() {
|
||||
const ref = useFocusTrap<HTMLDivElement>(true);
|
||||
return (
|
||||
<div>
|
||||
<button>outside</button>
|
||||
<div ref={ref} data-testid="trap-3btn">
|
||||
<button data-testid="first-btn">first</button>
|
||||
<button data-testid="middle-btn">middle</button>
|
||||
<button data-testid="last-btn">last</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault');
|
||||
render(<ThreeButtonTrap />);
|
||||
|
||||
const lastButton = screen.getByTestId('last-btn');
|
||||
lastButton.focus();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
|
||||
|
||||
// Last element is not the first, so preventDefault should NOT be called
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
preventDefaultSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Escape Key Behavior', () => {
|
||||
@@ -374,7 +442,7 @@ describe('useFocusTrap', () => {
|
||||
function ContainerWithDisabled() {
|
||||
const ref = useFocusTrap<HTMLDivElement>(true);
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div ref={ref} data-testid="disabled-container">
|
||||
<button disabled>disabled</button>
|
||||
<button data-testid="enabled-btn">enabled</button>
|
||||
</div>
|
||||
@@ -382,21 +450,24 @@ describe('useFocusTrap', () => {
|
||||
}
|
||||
|
||||
render(<ContainerWithDisabled />);
|
||||
const enabledButton = screen.getByTestId('enabled-btn');
|
||||
enabledButton.focus();
|
||||
|
||||
// Only the enabled button is focusable, so it's both first and last.
|
||||
// Tab should cycle without crashing.
|
||||
expect(() => {
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
}).not.toThrow();
|
||||
|
||||
// After useEffect, the first non-disabled focusable element is focused
|
||||
// Since the disabled button is excluded from focusable elements,
|
||||
// the enabled button should be focused (it's the only focusable element).
|
||||
// It's both first and last, so Tab should cycle to the same element.
|
||||
const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault');
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
// preventDefault should be called because activeElement === lastElement (same element)
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
preventDefaultSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle hidden focusable elements', () => {
|
||||
function ContainerWithHidden() {
|
||||
const ref = useFocusTrap<HTMLDivElement>(true);
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div ref={ref} data-testid="hidden-container">
|
||||
<button style={{ display: 'none' }}>hidden</button>
|
||||
<button data-testid="visible-btn">visible</button>
|
||||
</div>
|
||||
@@ -404,13 +475,24 @@ describe('useFocusTrap', () => {
|
||||
}
|
||||
|
||||
render(<ContainerWithHidden />);
|
||||
// Override the prototype-level mock for the hidden element to test filtering
|
||||
const hiddenBtn = screen.getByTestId('hidden-container').querySelector('button:first-child');
|
||||
if (hiddenBtn) {
|
||||
Object.defineProperty(hiddenBtn, 'offsetParent', {
|
||||
value: null,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const visibleButton = screen.getByTestId('visible-btn');
|
||||
visibleButton.focus();
|
||||
|
||||
// Only the visible button is focusable, so it's both first and last.
|
||||
expect(() => {
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
}).not.toThrow();
|
||||
// Tab should cycle the visible element (it's first and last)
|
||||
const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault');
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
preventDefaultSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not respond to non-Tab non-Escape keys', () => {
|
||||
@@ -487,7 +569,7 @@ describe('useFocusTrap', () => {
|
||||
function ManyFocusableTypes() {
|
||||
const ref = useFocusTrap<HTMLDivElement>(true);
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div ref={ref} data-testid="many-types">
|
||||
<button>button</button>
|
||||
<a href="#">link</a>
|
||||
<input data-testid="input-elem" />
|
||||
@@ -499,24 +581,35 @@ describe('useFocusTrap', () => {
|
||||
}
|
||||
|
||||
render(<ManyFocusableTypes />);
|
||||
|
||||
expect(() => {
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
}).not.toThrow();
|
||||
|
||||
// Focus the last focusable element (span with tabIndex=0) to trigger Tab cycling
|
||||
const lastFocusable = screen.getByTestId('many-types').querySelector('[tabindex="0"]') as HTMLElement;
|
||||
if (lastFocusable) lastFocusable.focus();
|
||||
|
||||
const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault');
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
// Multiple elements exist, so Tab should cycle
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
preventDefaultSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle container with only tabindex=-1 elements', () => {
|
||||
function ContainerWithNegativeTabIndex() {
|
||||
const ref = useFocusTrap<HTMLDivElement>(true);
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<button tabIndex={-1}>focusable-programmatically</button>
|
||||
<div ref={ref} data-testid="negative-tab">
|
||||
<button tabIndex={-1} data-testid="neg-btn">focusable-programmatically</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<ContainerWithNegativeTabIndex />);
|
||||
|
||||
const negBtn = screen.getByTestId('neg-btn');
|
||||
negBtn.focus();
|
||||
|
||||
// tabindex=-1 is excluded by the selector, so no focusable elements
|
||||
// Tab should not crash
|
||||
expect(() => {
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
}).not.toThrow();
|
||||
|
||||
Reference in New Issue
Block a user