0e516f6e56
Add ~290 new unit tests covering utils (lru, diff, css, a11y, walk, usage, txt-worker), services (EdgeTTSClient, transformers), and suppress noisy console output across all test files. Rename 27 camelCase test files to dash-case for consistency. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
180 lines
5.5 KiB
TypeScript
180 lines
5.5 KiB
TypeScript
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import type { FoliateView } from '@/types/view';
|
|
|
|
vi.mock('@/utils/throttle', () => ({
|
|
throttle: vi.fn((fn: (...args: unknown[]) => unknown) => fn),
|
|
}));
|
|
vi.mock('@/utils/debounce', () => ({
|
|
debounce: vi.fn((fn: (...args: unknown[]) => unknown) => fn),
|
|
}));
|
|
|
|
const mockObserve = vi.fn();
|
|
const mockDisconnect = vi.fn();
|
|
vi.stubGlobal(
|
|
'IntersectionObserver',
|
|
class {
|
|
constructor(
|
|
public callback: IntersectionObserverCallback,
|
|
public options?: IntersectionObserverInit,
|
|
) {}
|
|
observe = mockObserve;
|
|
disconnect = mockDisconnect;
|
|
unobserve = vi.fn();
|
|
},
|
|
);
|
|
|
|
import { handleA11yNavigation } from '@/utils/a11y';
|
|
|
|
function createMockView() {
|
|
return {
|
|
renderer: {
|
|
addEventListener: vi.fn(),
|
|
},
|
|
getCFI: vi.fn().mockReturnValue('epubcfi(/6/4)'),
|
|
resolveNavigation: vi.fn().mockReturnValue({ index: 0 }),
|
|
};
|
|
}
|
|
|
|
describe('handleA11yNavigation', () => {
|
|
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
mockObserve.mockClear();
|
|
mockDisconnect.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleSpy.mockRestore();
|
|
vi.restoreAllMocks();
|
|
// Clean up skip link from document body between tests
|
|
const existing = document.getElementById('readest-skip-link');
|
|
if (existing) existing.remove();
|
|
});
|
|
|
|
test('returns early when view is null', () => {
|
|
expect(() => {
|
|
handleA11yNavigation(null, document, 0);
|
|
}).not.toThrow();
|
|
});
|
|
|
|
test('sets tabindex="-1" on anchor elements', () => {
|
|
const a1 = document.createElement('a');
|
|
const a2 = document.createElement('a');
|
|
document.body.appendChild(a1);
|
|
document.body.appendChild(a2);
|
|
|
|
const view = createMockView();
|
|
handleA11yNavigation(view as unknown as FoliateView, document, 0);
|
|
|
|
expect(a1.getAttribute('tabindex')).toBe('-1');
|
|
expect(a2.getAttribute('tabindex')).toBe('-1');
|
|
|
|
a1.remove();
|
|
a2.remove();
|
|
});
|
|
|
|
test('creates skip link with correct attributes', () => {
|
|
const callback = vi.fn();
|
|
const view = createMockView();
|
|
|
|
handleA11yNavigation(view as unknown as FoliateView, document, 0, {
|
|
skipToLastPosCallback: callback,
|
|
skipToLastPosLabel: 'Skip to reading position',
|
|
});
|
|
|
|
const skipLink = document.getElementById('readest-skip-link');
|
|
expect(skipLink).not.toBeNull();
|
|
expect(skipLink!.getAttribute('cfi-inert')).toBe('');
|
|
expect(skipLink!.getAttribute('tabindex')).toBe('0');
|
|
expect(skipLink!.getAttribute('aria-hidden')).toBe('false');
|
|
expect(skipLink!.getAttribute('aria-label')).toBe('Skip to reading position');
|
|
// Should be first child of body
|
|
expect(document.body.firstElementChild).toBe(skipLink);
|
|
});
|
|
|
|
test('skip link click calls callback', () => {
|
|
const callback = vi.fn();
|
|
const view = createMockView();
|
|
|
|
handleA11yNavigation(view as unknown as FoliateView, document, 0, {
|
|
skipToLastPosCallback: callback,
|
|
skipToLastPosLabel: 'Skip',
|
|
});
|
|
|
|
const skipLink = document.getElementById('readest-skip-link');
|
|
expect(skipLink).not.toBeNull();
|
|
|
|
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
|
skipLink!.dispatchEvent(clickEvent);
|
|
|
|
expect(callback).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
test('does not duplicate skip link if already exists', () => {
|
|
const view = createMockView();
|
|
|
|
// First call creates the skip link
|
|
handleA11yNavigation(view as unknown as FoliateView, document, 0, {
|
|
skipToLastPosCallback: vi.fn(),
|
|
skipToLastPosLabel: 'First',
|
|
});
|
|
|
|
// Second call should not create another
|
|
handleA11yNavigation(view as unknown as FoliateView, document, 0, {
|
|
skipToLastPosCallback: vi.fn(),
|
|
skipToLastPosLabel: 'Second',
|
|
});
|
|
|
|
const skipLinks = document.querySelectorAll('#readest-skip-link');
|
|
expect(skipLinks.length).toBe(1);
|
|
// Label should still be from the first call
|
|
expect(skipLinks[0]!.getAttribute('aria-label')).toBe('First');
|
|
});
|
|
|
|
test('observes paragraph elements', () => {
|
|
const p1 = document.createElement('p');
|
|
const p2 = document.createElement('p');
|
|
const p3 = document.createElement('p');
|
|
document.body.appendChild(p1);
|
|
document.body.appendChild(p2);
|
|
document.body.appendChild(p3);
|
|
|
|
const view = createMockView();
|
|
handleA11yNavigation(view as unknown as FoliateView, document, 0);
|
|
|
|
expect(mockObserve).toHaveBeenCalledTimes(3);
|
|
expect(mockObserve).toHaveBeenCalledWith(p1);
|
|
expect(mockObserve).toHaveBeenCalledWith(p2);
|
|
expect(mockObserve).toHaveBeenCalledWith(p3);
|
|
|
|
p1.remove();
|
|
p2.remove();
|
|
p3.remove();
|
|
});
|
|
|
|
test('registers scroll and relocate listeners on renderer', () => {
|
|
const view = createMockView();
|
|
handleA11yNavigation(view as unknown as FoliateView, document, 0);
|
|
|
|
expect(view.renderer.addEventListener).toHaveBeenCalledTimes(2);
|
|
|
|
const calls = view.renderer.addEventListener.mock.calls;
|
|
expect(calls[0]![0]).toBe('scroll');
|
|
expect(typeof calls[0]![1]).toBe('function');
|
|
expect(calls[0]![2]).toEqual({ passive: true });
|
|
|
|
expect(calls[1]![0]).toBe('relocate');
|
|
expect(typeof calls[1]![1]).toBe('function');
|
|
});
|
|
|
|
test('skip link aria-label defaults to empty string when no options', () => {
|
|
const view = createMockView();
|
|
handleA11yNavigation(view as unknown as FoliateView, document, 0);
|
|
|
|
const skipLink = document.getElementById('readest-skip-link');
|
|
expect(skipLink).not.toBeNull();
|
|
expect(skipLink!.getAttribute('aria-label')).toBe('');
|
|
});
|
|
});
|