Files
readest/apps/readest-app/src/__tests__/utils/hardwareKeys.test.ts
T
Huang Xin 787bbf2103 feat(reader): custom hardware-button page turning (#4177)
* feat(reader): add custom hardware-button page turning (#4139)

Lets users bind hardware remote keys (media keys, D-pad/arrow keys) to
previous/next page via a learn-mode capture UI in reader settings — an
accessibility feature for page-turner remotes.

- New global hardwarePageTurner system setting (enabled + key bindings).
- hardwareKeys.ts: key normalization, matching, and page-turn resolution.
- deviceStore: reference-counted media-key interception + learn mode.
- usePagination: flips pages from bound media keys (native bridge) and
  D-pad/keyboard keys (DOM keydown), scoped to the active book and
  suppressed while the toolbar is visible.
- Page Turner settings section on all platforms; web/desktop bind keys
  via DOM keydown only, native media-key interception stays mobile-only.
- Android: intercept media + learn-mode keys in dispatchKeyEvent.
- iOS: forward media keys via MPRemoteCommandCenter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(i18n): add and translate hardware page turner strings (#4139)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(reader): refine hardware page turner (#4139)

- Handle book-iframe key events (iframe-keydown messages) so custom
  bindings work as soon as a book is open, not only after the settings
  panel has been shown.
- Add Previous/Next Section bindings alongside the page bindings.
- Rename the hardwareKeys util to keybinding.
- Wire the Page Turner section into the settings Reset action.
- Drop the focus ring on the capture buttons; BoxedList gains an
  optional description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(i18n): translate page turner section and key strings (#4139)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:57:33 +02:00

104 lines
3.4 KiB
TypeScript

import { describe, test, expect } from 'vitest';
import {
normalizeNativeKey,
normalizeDomKeyEvent,
matchesBinding,
resolvePageTurn,
} from '@/utils/keybinding';
import { HardwarePageTurnerSettings } from '@/types/settings';
describe('normalizeNativeKey', () => {
test('maps a known native key to a friendly label', () => {
expect(normalizeNativeKey('MediaNext')).toEqual({
source: 'native',
id: 'MediaNext',
label: 'Media Next',
});
});
test('falls back to the raw id for an unknown native key', () => {
expect(normalizeNativeKey('Keycode99')).toEqual({
source: 'native',
id: 'Keycode99',
label: 'Keycode99',
});
});
});
describe('normalizeDomKeyEvent', () => {
test('uses event.code and a friendly label for a known key', () => {
const event = { code: 'ArrowLeft', key: 'ArrowLeft' } as KeyboardEvent;
expect(normalizeDomKeyEvent(event)).toEqual({
source: 'dom',
id: 'ArrowLeft',
label: 'Arrow Left',
});
});
test('falls back to event.key when event.code is empty', () => {
const event = { code: '', key: 'MediaTrackNext' } as KeyboardEvent;
expect(normalizeDomKeyEvent(event)).toEqual({
source: 'dom',
id: 'MediaTrackNext',
label: 'Media Next',
});
});
});
describe('matchesBinding', () => {
const binding = { source: 'native' as const, id: 'MediaNext', label: 'Media Next' };
test('matches same source and id', () => {
expect(matchesBinding(binding, { source: 'native', id: 'MediaNext' })).toBe(true);
});
test('rejects different id', () => {
expect(matchesBinding(binding, { source: 'native', id: 'MediaPrevious' })).toBe(false);
});
test('rejects different source', () => {
expect(matchesBinding(binding, { source: 'dom', id: 'MediaNext' })).toBe(false);
});
test('rejects a null binding', () => {
expect(matchesBinding(null, { source: 'native', id: 'MediaNext' })).toBe(false);
});
});
describe('resolvePageTurn', () => {
const settings: HardwarePageTurnerSettings = {
enabled: true,
bindings: {
pagePrev: { source: 'native', id: 'MediaPrevious', label: 'Media Previous' },
pageNext: { source: 'native', id: 'MediaNext', label: 'Media Next' },
sectionPrev: { source: 'dom', id: 'PageUp', label: 'Page Up' },
sectionNext: { source: 'dom', id: 'PageDown', label: 'Page Down' },
},
};
test('returns "pagePrev" for the pagePrev binding', () => {
expect(resolvePageTurn(settings, { source: 'native', id: 'MediaPrevious' })).toBe('pagePrev');
});
test('returns "pageNext" for the pageNext binding', () => {
expect(resolvePageTurn(settings, { source: 'native', id: 'MediaNext' })).toBe('pageNext');
});
test('returns "sectionPrev" for the sectionPrev binding', () => {
expect(resolvePageTurn(settings, { source: 'dom', id: 'PageUp' })).toBe('sectionPrev');
});
test('returns "sectionNext" for the sectionNext binding', () => {
expect(resolvePageTurn(settings, { source: 'dom', id: 'PageDown' })).toBe('sectionNext');
});
test('returns null for an unbound key', () => {
expect(resolvePageTurn(settings, { source: 'native', id: 'MediaPlayPause' })).toBeNull();
});
test('returns null when the feature is disabled', () => {
const disabled = { ...settings, enabled: false };
expect(resolvePageTurn(disabled, { source: 'native', id: 'MediaNext' })).toBeNull();
});
});