Files
SubMiner/src/renderer/handlers/keyboard.test.ts
T
sudacode c2dac537da fix(overlay): keep popup pause working with Hachidori 0.4.0
Hachidori 0.4.0 closed its popup shadow root, so isHachidoriPopupOpen always
returned false. The popup never took over the subtitle hover pause, and moving
off the subtitle resumed playback with the popup open. Popup key and controller
routing read the same check. The sidebar kept working because its hold follows
the attention event instead of the pane.

The fork now publishes the pane state on the host element, and the renderer
reads that attribute instead of upstream's shadow root and CSS class. The unit
stubs modelled an open root and hid this, so test:dictionary:electron now drives
the real reader and asserts the attribute follows the pane.
2026-11-04 22:46:25 -08:00

2758 lines
90 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createKeyboardHandlers } from './keyboard.js';
import { createRendererState } from '../state.js';
import { HACHIDORI_HOST_SELECTOR, HACHIDORI_POPUP_OPEN_SELECTOR } from '../yomitan-popup.js';
import type { CompiledSessionBinding } from '../../types';
import type { MpvInputBindingsSnapshot } from '../../types/session-bindings';
import { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions';
import { compileSessionBindings } from '../../core/services/session-bindings';
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js';
type CommandEventDetail = {
type?: string;
visible?: boolean;
key?: string;
code?: string;
modifiers?: string[];
repeat?: boolean;
direction?: number;
deltaX?: number;
deltaY?: number;
};
function createClassList() {
const classes = new Set<string>();
return {
add: (...tokens: string[]) => {
for (const token of tokens) {
classes.add(token);
}
},
remove: (...tokens: string[]) => {
for (const token of tokens) {
classes.delete(token);
}
},
contains: (token: string) => classes.has(token),
};
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function eventFromKeyString(keyString: string): {
key: string;
code: string;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
} {
const parts = keyString.split('+');
const code = parts.pop() ?? '';
return {
key: code === 'Space' ? ' ' : code,
code,
ctrlKey: parts.includes('Ctrl'),
metaKey: parts.includes('Meta'),
altKey: parts.includes('Alt'),
shiftKey: parts.includes('Shift'),
};
}
function countedJsonValues(values: unknown[]): Array<[string, number]> {
const counts = new Map<string, number>();
for (const value of values) {
const key = JSON.stringify(value);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return [...counts.entries()].sort(([left], [right]) => left.localeCompare(right));
}
function createEmptyShortcuts(): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 3000,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
appendClipboardVideoToQueue: null,
};
}
function installKeyboardTestGlobals() {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document;
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
const previousCustomEvent = (globalThis as { CustomEvent?: unknown }).CustomEvent;
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
const previousElement = (globalThis as { Element?: unknown }).Element;
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
const commandEvents: CommandEventDetail[] = [];
const mpvCommands: Array<Array<string | number>> = [];
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
const interactionActivations: string[] = [];
let sessionBindings: CompiledSessionBinding[] = [];
let getMpvInputBindings: () => Promise<MpvInputBindingsSnapshot> = async () => ({
keys: [],
blockedKeys: [],
});
let getSessionBindingsImpl: () => Promise<CompiledSessionBinding[]> = async () => sessionBindings;
let playbackPausedResponse: boolean | null = false;
let statsToggleKey = 'Backquote';
let markWatchedKey = 'KeyW';
let configuredShortcuts = {
copySubtitle: '',
copySubtitleMultiple: '',
updateLastCardFromClipboard: '',
triggerFieldGrouping: '',
triggerSubsync: 'Ctrl+Alt+S',
mineSentence: '',
mineSentenceMultiple: '',
multiCopyTimeoutMs: 1000,
toggleSecondarySub: '',
markAudioCard: '',
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openSessionHelp: 'CommandOrControl+Shift+H',
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: '',
toggleNotificationHistory: '',
toggleVisibleOverlayGlobal: '',
};
let markActiveVideoWatchedResult = true;
let markActiveVideoWatchedCalls = 0;
let statsToggleOverlayCalls = 0;
const openedModalNotifications: string[] = [];
let selectionClearCount = 0;
let selectionAddCount = 0;
let mutationRecordsTaken = 0;
let popupVisible = false;
let hachidoriPaneOpen = false;
const popupIframe = {
tagName: 'IFRAME',
classList: {
contains: (token: string) => token === 'yomitan-popup',
},
id: 'yomitan-popup-1',
getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
};
const selection = {
removeAllRanges: () => {
selectionClearCount += 1;
},
addRange: () => {
selectionAddCount += 1;
},
};
const overlayFocusCalls: Array<{ preventScroll?: boolean }> = [];
let focusMainWindowCalls = 0;
let windowFocusCalls = 0;
class TestCustomEvent extends Event {
detail: unknown;
constructor(type: string, init?: { detail?: unknown }) {
super(type);
this.detail = init?.detail;
}
}
class TestMouseEvent extends Event {
constructor(type: string) {
super(type);
}
}
class TestElement {
tagName = 'DIV';
closest(_selector: string): unknown {
return null;
}
}
Object.defineProperty(globalThis, 'CustomEvent', {
configurable: true,
value: TestCustomEvent,
});
Object.defineProperty(globalThis, 'MouseEvent', {
configurable: true,
value: TestMouseEvent,
});
Object.defineProperty(globalThis, 'Element', {
configurable: true,
value: TestElement,
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
addEventListener: (type: string, listener: (event: unknown) => void) => {
const listeners = windowListeners.get(type) ?? [];
listeners.push(listener);
windowListeners.set(type, listeners);
},
dispatchEvent: (event: Event) => {
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
const detail = (event as Event & { detail?: CommandEventDetail }).detail;
commandEvents.push(detail ?? {});
}
const listeners = windowListeners.get(event.type) ?? [];
for (const listener of listeners) {
listener(event);
}
return true;
},
getComputedStyle: () => ({
visibility: 'visible',
display: 'block',
opacity: '1',
}),
getSelection: () => selection,
focus: () => {
windowFocusCalls += 1;
},
electronAPI: {
getMpvInputBindings: () => getMpvInputBindings(),
getSessionBindings: () => getSessionBindingsImpl(),
getConfiguredShortcuts: async () => configuredShortcuts,
sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command);
},
dispatchSessionAction: async (actionId: string, payload?: unknown) => {
sessionActions.push({ actionId, payload });
},
getPlaybackPaused: async () => playbackPausedResponse,
getStatsToggleKey: async () => statsToggleKey,
getMarkWatchedKey: async () => markWatchedKey,
markActiveVideoWatched: async () => {
markActiveVideoWatchedCalls += 1;
return markActiveVideoWatchedResult;
},
toggleDevTools: () => {},
toggleStatsOverlay: () => {
statsToggleOverlayCalls += 1;
},
focusMainWindow: () => {
focusMainWindowCalls += 1;
return Promise.resolve();
},
activatePlaybackWindowForOverlayInteraction: async () => {
interactionActivations.push('activate-playback-window');
return true;
},
notifyOverlayModalOpened: (modal: string) => {
openedModalNotifications.push(modal);
},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
addEventListener: (type: string, listener: (event: unknown) => void) => {
const listeners = documentListeners.get(type) ?? [];
listeners.push(listener);
documentListeners.set(type, listeners);
},
querySelectorAll: (selector: string) => {
if (selector === HACHIDORI_POPUP_OPEN_SELECTOR) {
return hachidoriPaneOpen ? [{}] : [];
}
if (popupVisible) {
return [popupIframe];
}
return [];
},
createRange: () => ({
selectNodeContents: () => {},
}),
body: {},
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: class {
observe() {}
takeRecords() {
mutationRecordsTaken += 1;
return [];
}
},
});
function dispatchKeydown(event: {
key: string;
code: string;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
repeat?: boolean;
target?: unknown;
}): boolean {
const listeners = documentListeners.get('keydown') ?? [];
let prevented = false;
const keyboardEvent = {
key: event.key,
code: event.code,
ctrlKey: event.ctrlKey ?? false,
metaKey: event.metaKey ?? false,
altKey: event.altKey ?? false,
shiftKey: event.shiftKey ?? false,
repeat: event.repeat ?? false,
preventDefault: () => {
prevented = true;
},
target: event.target ?? null,
};
for (const listener of listeners) {
listener(keyboardEvent);
}
return prevented;
}
function dispatchMousedown(event: {
button: number;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
target?: unknown;
type?: 'mousedown' | 'mouseup';
}): void {
const listeners = documentListeners.get(event.type ?? 'mousedown') ?? [];
const mouseEvent = {
button: event.button,
ctrlKey: event.ctrlKey ?? false,
metaKey: event.metaKey ?? false,
altKey: event.altKey ?? false,
shiftKey: event.shiftKey ?? false,
preventDefault: () => {},
target: event.target ?? null,
};
for (const listener of listeners) {
listener(mouseEvent);
}
}
const dispatchDocumentMouseDown = dispatchMousedown;
function dispatchWheel(event: {
deltaY: number;
deltaX?: number;
ctrlKey?: boolean;
shiftKey?: boolean;
target?: unknown;
}): boolean {
let prevented = false;
const wheelEvent = {
deltaX: event.deltaX ?? 0,
deltaY: event.deltaY,
deltaMode: 0,
ctrlKey: event.ctrlKey ?? false,
metaKey: false,
altKey: false,
shiftKey: event.shiftKey ?? false,
get defaultPrevented() {
return prevented;
},
preventDefault: () => {
prevented = true;
},
target: event.target ?? null,
};
for (const listener of documentListeners.get('wheel') ?? []) {
listener(wheelEvent);
}
return prevented;
}
function dispatchFocusInOnPopup(): void {
const listeners = documentListeners.get('focusin') ?? [];
const focusEvent = {
target: popupIframe,
};
for (const listener of listeners) {
listener(focusEvent);
}
}
function dispatchWindowEvent(type: string): void {
const listeners = windowListeners.get(type) ?? [];
for (const listener of listeners) {
listener(new Event(type));
}
}
function restore() {
dispatchWindowEvent('beforeunload');
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: previousMutationObserver,
});
Object.defineProperty(globalThis, 'CustomEvent', {
configurable: true,
value: previousCustomEvent,
});
Object.defineProperty(globalThis, 'MouseEvent', {
configurable: true,
value: previousMouseEvent,
});
Object.defineProperty(globalThis, 'Element', {
configurable: true,
value: previousElement,
});
}
const overlay = {
focus: (options?: { preventScroll?: boolean }) => {
overlayFocusCalls.push(options ?? {});
},
};
return {
commandEvents,
mpvCommands,
sessionActions,
overlay,
interactionActivations,
overlayFocusCalls,
focusMainWindowCalls: () => focusMainWindowCalls,
windowFocusCalls: () => windowFocusCalls,
dispatchKeydown,
dispatchDocumentMouseDown,
dispatchMousedown,
dispatchWheel,
dispatchFocusInOnPopup,
dispatchWindowEvent,
setPopupVisible: (value: boolean) => {
popupVisible = value;
},
setHachidoriPaneOpen: (value: boolean) => {
hachidoriPaneOpen = value;
},
setStatsToggleKey: (value: string) => {
statsToggleKey = value;
},
setMarkWatchedKey: (value: string) => {
markWatchedKey = value;
},
setConfiguredShortcuts: (overrides: Partial<typeof configuredShortcuts>) => {
configuredShortcuts = { ...configuredShortcuts, ...overrides };
},
setGetMpvInputBindings: (value: typeof getMpvInputBindings) => {
getMpvInputBindings = value;
},
setSessionBindings: (value: CompiledSessionBinding[]) => {
sessionBindings = value;
},
// A plain overlay element over playback, outside any SubMiner UI.
createPlaybackTarget: () => new TestElement(),
createInteractiveTarget: () => {
const target = new TestElement();
target.closest = (selector: string) => (selector.includes('.modal') ? target : null);
return target;
},
// Events inside Hachidori's shadow root reach the document retargeted to its host.
createDictionaryPopupHostTarget: () => {
const target = new TestElement();
target.closest = (selector: string) =>
selector
.split(',')
.map((part) => part.trim())
.includes(HACHIDORI_HOST_SELECTOR)
? target
: null;
return target;
},
createTextInputTarget: () => {
const target = new TestElement();
target.tagName = 'INPUT';
target.closest = (selector: string) => (selector.includes('input') ? target : null);
return target;
},
setGetSessionBindings: (value: () => Promise<CompiledSessionBinding[]>) => {
getSessionBindingsImpl = value;
},
setMarkActiveVideoWatchedResult: (value: boolean) => {
markActiveVideoWatchedResult = value;
},
markActiveVideoWatchedCalls: () => markActiveVideoWatchedCalls,
statsToggleOverlayCalls: () => statsToggleOverlayCalls,
openedModalNotifications,
getPlaybackPaused: async () => playbackPausedResponse,
setPlaybackPausedResponse: (value: boolean | null) => {
playbackPausedResponse = value;
},
selectionClearCount: () => selectionClearCount,
selectionAddCount: () => selectionAddCount,
mutationRecordsTaken: () => mutationRecordsTaken,
restore,
};
}
function createKeyboardHandlerHarness() {
const testGlobals = installKeyboardTestGlobals();
const subtitleRootClassList = createClassList();
const subtitleContainerClassList = createClassList();
let mediaTimingReviewKeydownCount = 0;
let controllerSelectKeydownCount = 0;
let openControllerSelectCount = 0;
let openControllerDebugCount = 0;
let playlistBrowserKeydownCount = 0;
let changelogKeydownCount = 0;
let jimakuKeydownCount = 0;
// Synthetic pointer/mouse events dispatched on words: the lookup's fallback scan.
let wordEventCount = 0;
const createWordNode = (left: number, textContent = 'ab') => ({
classList: createClassList(),
textContent,
getBoundingClientRect: () => ({ left, top: 10, width: 30, height: 20 }),
dispatchEvent: () => {
wordEventCount += 1;
return true;
},
});
let wordNodes = [createWordNode(10), createWordNode(80), createWordNode(150)];
// Records the character cursor instead of wrapping text in a real DOM.
const characterCursorLog: string[] = [];
const characterCursor = {
getLength: (word: { textContent: string }) => Array.from(word.textContent).length,
paint: (word: unknown, charIndex: number) => {
characterCursorLog.push(`paint:${wordNodes.indexOf(word as never)}:${charIndex}`);
return {
getBoundingClientRect: () => ({ left: 0, top: 10, width: 10, height: 20 }),
dispatchEvent: () => true,
};
},
clear: () => false,
rangeToEnd: (_marker: unknown, lastWord: unknown) => {
characterCursorLog.push(`rangeToEnd:${wordNodes.indexOf(lastWord as never)}`);
return {};
},
};
const ctx = {
dom: {
subtitleRoot: {
classList: subtitleRootClassList,
querySelectorAll: () => wordNodes,
},
subtitleContainer: {
classList: subtitleContainerClassList,
contains: () => false,
},
overlay: testGlobals.overlay,
},
platform: {
isLinuxPlatform: false,
shouldToggleMouseIgnore: false,
isMacOSPlatform: false,
isModalLayer: false,
overlayLayer: 'always-on-top',
},
state: createRendererState(),
};
const handlers = createKeyboardHandlers(ctx as never, {
handleRuntimeOptionsKeydown: () => false,
handleCharacterDictionaryKeydown: () => false,
handleSubsyncKeydown: () => false,
handleKikuKeydown: () => false,
handleJimakuKeydown: () => {
jimakuKeydownCount += 1;
return false;
},
handleTsukihimeKeydown: () => false,
handleMediaTimingReviewKeydown: () => {
mediaTimingReviewKeydownCount += 1;
return false;
},
handleControllerSelectKeydown: () => {
controllerSelectKeydownCount += 1;
return true;
},
handleControllerDebugKeydown: () => false,
handleYoutubePickerKeydown: () => false,
handlePlaylistBrowserKeydown: () => {
playlistBrowserKeydownCount += 1;
return true;
},
handleSessionHelpKeydown: () => false,
handleChangelogKeydown: () => {
changelogKeydownCount += 1;
return true;
},
handlePluginPanelKeydown: () => false,
openSessionHelpModal: () => {},
openControllerSelectModal: () => {
openControllerSelectCount += 1;
},
openControllerDebugModal: () => {
openControllerDebugCount += 1;
},
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
characterCursor: characterCursor as never,
});
return {
ctx,
handlers,
testGlobals,
characterCursorLog,
setWordTexts: (texts: string[]) => {
wordNodes = texts.map((text, index) => createWordNode(10 + index * 70, text));
},
wordNodes: () => wordNodes,
mediaTimingReviewKeydownCount: () => mediaTimingReviewKeydownCount,
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
openControllerSelectCount: () => openControllerSelectCount,
openControllerDebugCount: () => openControllerDebugCount,
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
changelogKeydownCount: () => changelogKeydownCount,
jimakuKeydownCount: () => jimakuKeydownCount,
wordEventCount: () => wordEventCount,
setWordCount: (count: number) => {
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
},
};
}
test('primary subtitle visibility key cycles modes with primary OSD without mpv sub-visibility', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: 'v', code: 'KeyV' });
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hidden'), false);
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hover'), true);
assert.equal(ctx.state.primarySubtitleMode, 'hover');
testGlobals.dispatchKeydown({ key: 'v', code: 'KeyV' });
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hidden'), true);
assert.equal(ctx.state.primarySubtitleMode, 'hidden');
testGlobals.dispatchKeydown({ key: 'v', code: 'KeyV' });
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hidden'), false);
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hover'), false);
assert.equal(ctx.state.primarySubtitleMode, 'visible');
assert.equal(
testGlobals.mpvCommands.some((command) => command.includes('sub-visibility')),
false,
);
assert.deepEqual(
testGlobals.mpvCommands.filter((command) => command[0] === 'show-text'),
[
['show-text', 'Primary subtitle: hover', '1500'],
['show-text', 'Primary subtitle: hidden', '1500'],
['show-text', 'Primary subtitle: visible', '1500'],
],
);
} finally {
testGlobals.restore();
}
});
test('mpv input forwarding installs local key handling when session binding IPC stalls', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetSessionBindings(() => new Promise<CompiledSessionBinding[]>(() => {}));
const setupResult = await Promise.race([
handlers.setupMpvInputForwarding().then(() => 'resolved'),
wait(75).then(() => 'pending'),
]);
assert.equal(setupResult, 'resolved');
testGlobals.dispatchKeydown({ key: '`', code: 'Backquote' });
assert.equal(testGlobals.statsToggleOverlayCalls(), 1);
} finally {
testGlobals.restore();
}
});
test('mpv input forwarding waits for session bindings before resolving setup', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetSessionBindings(async () => {
await wait(20);
return [
{
sourcePath: 'keybindings[0].key',
originalKey: 'KeyH',
key: { code: 'KeyH', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'],
},
] as CompiledSessionBinding[];
});
await handlers.setupMpvInputForwarding();
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
bindingKey: 'KeyK',
fallbackUsed: true,
fallbackUnavailable: false,
});
} finally {
testGlobals.restore();
}
});
test('right-clicking non-interactive overlay content raises playback window before toggling pause', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchDocumentMouseDown({ button: 2 });
await wait(0);
assert.deepEqual(testGlobals.interactionActivations, ['activate-playback-window']);
assert.deepEqual(testGlobals.mpvCommands.slice(-1), [['cycle', 'pause']]);
} finally {
testGlobals.restore();
}
});
test('right-clicking interactive overlay controls does not raise playback window or toggle pause', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
const interactiveTarget = testGlobals.createInteractiveTarget();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchDocumentMouseDown({ button: 2, target: interactiveTarget });
await wait(0);
assert.deepEqual(testGlobals.interactionActivations, []);
assert.deepEqual(testGlobals.mpvCommands, []);
} finally {
testGlobals.restore();
}
});
test('right-clicking inside a dictionary popup host does not raise playback window or toggle pause', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
const popupHost = testGlobals.createDictionaryPopupHostTarget();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchDocumentMouseDown({ button: 2, target: popupHost });
await wait(0);
assert.deepEqual(testGlobals.interactionActivations, []);
assert.deepEqual(testGlobals.mpvCommands, []);
} finally {
testGlobals.restore();
}
});
test('mpv input forwarding retries a transient keyboard config IPC failure', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
let calls = 0;
try {
testGlobals.setGetSessionBindings(async () => {
calls += 1;
if (calls === 1) {
throw new Error('transient');
}
return [];
});
await handlers.setupMpvInputForwarding();
await wait(25);
assert.equal(calls, 2);
} finally {
testGlobals.restore();
}
});
test('session help chord resolver follows remapped session bindings', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
bindingKey: 'KeyH',
fallbackUsed: false,
fallbackUnavailable: false,
});
handlers.updateSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'KeyH',
key: { code: 'KeyH', modifiers: [] },
actionType: 'session-action',
actionId: 'openJimaku',
},
{
sourcePath: 'keybindings[1].key',
originalKey: 'KeyJ',
key: { code: 'KeyJ', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'],
},
] as never);
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
bindingKey: 'KeyK',
fallbackUsed: true,
fallbackUnavailable: false,
});
handlers.updateSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'KeyH',
key: { code: 'KeyH', modifiers: [] },
actionType: 'session-action',
actionId: 'openSessionHelp',
},
{
sourcePath: 'keybindings[1].key',
originalKey: 'KeyK',
key: { code: 'KeyK', modifiers: [] },
actionType: 'session-action',
actionId: 'openControllerSelect',
},
] as never);
assert.deepEqual(handlers.getSessionHelpOpeningInfo(), {
bindingKey: 'KeyK',
fallbackUsed: true,
fallbackUnavailable: true,
});
} finally {
testGlobals.restore();
}
});
test('numeric selection ignores non-digit keys instead of falling through to other shortcuts', async () => {
const { handlers, testGlobals, ctx } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.beginSessionNumericSelection('copySubtitleMultiple');
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' });
assert.equal(ctx.state.chordPending, false);
assert.deepEqual(testGlobals.sessionActions, []);
assert.equal(
testGlobals.commandEvents.some((event) => event.type === 'forwardKeyDown'),
false,
);
testGlobals.dispatchKeydown({ key: 'Escape', code: 'Escape' });
} finally {
testGlobals.restore();
}
});
test('numeric selection start focuses overlay for follow-up digit keys', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.beginSessionNumericSelection('copySubtitleMultiple');
assert.equal(testGlobals.focusMainWindowCalls() > 0, true);
assert.equal(testGlobals.windowFocusCalls() > 0, true);
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
} finally {
testGlobals.dispatchKeydown({ key: 'Escape', code: 'Escape' });
testGlobals.restore();
}
});
test('keyboard mode: left and right move token selection while popup remains open', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
assert.equal(ctx.state.keyboardSelectedWordIndex, 2);
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
assert.equal(ctx.state.keyboardSelectedWordIndex, 1);
await wait(0);
const closeEvents = testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' && event.visible === false,
);
assert.equal(closeEvents.length, 0);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: C switches between moving by word and by character', async () => {
const { ctx, handlers, testGlobals, characterCursorLog, setWordTexts, wordNodes } =
createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
setWordTexts(['もっとハ', 'キ', 'ハキ']);
handlers.handleKeyboardModeToggleRequested();
assert.equal(testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' }), true);
assert.equal(ctx.state.keyboardCharacterNavigation, true);
assert.equal(ctx.dom.subtitleRoot.classList.contains('keyboard-character-navigation'), true);
assert.equal(wordNodes()[0]!.classList.contains('keyboard-selected'), false);
// The box colour is the only sign: no message on screen.
assert.deepEqual(testGlobals.mpvCommands, []);
assert.equal(characterCursorLog.at(-1), 'paint:0:0');
// Marking the character edits the subtitle, which the subtitle observer answers with
// another sync: its own edits must be dropped, or the two loop forever.
assert.ok(testGlobals.mutationRecordsTaken() > 0);
// The cursor walks the characters, crossing into the next word at its end.
for (let i = 0; i < 4; i += 1) testGlobals.dispatchKeydown({ key: 'l', code: 'KeyL' });
assert.deepEqual(
[ctx.state.keyboardSelectedWordIndex, ctx.state.keyboardSelectedCharIndex],
[1, 0],
);
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
assert.equal(characterCursorLog.at(-1), 'paint:0:3');
// Back to words: the box covers the word that holds the character.
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
assert.equal(ctx.state.keyboardCharacterNavigation, false);
assert.equal(wordNodes()[0]!.classList.contains('keyboard-selected'), true);
assert.equal(ctx.dom.subtitleRoot.classList.contains('keyboard-character-navigation'), false);
assert.deepEqual(testGlobals.mpvCommands, []);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: character navigation lasts until toggled, even across keyboard mode', async () => {
const { ctx, handlers, testGlobals, characterCursorLog, setWordTexts } =
createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
setWordTexts(['もっとハ', 'キ', 'ハキ']);
handlers.handleKeyboardModeToggleRequested();
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
// Leave the mode with the cursor in the middle of the first word.
for (let i = 0; i < 3; i += 1) testGlobals.dispatchKeydown({ key: 'l', code: 'KeyL' });
assert.equal(characterCursorLog.at(-1), 'paint:0:3');
handlers.handleKeyboardModeToggleRequested();
assert.equal(ctx.dom.subtitleRoot.classList.contains('keyboard-character-navigation'), false);
// Coming back starts at the beginning of the line, as word mode does.
characterCursorLog.length = 0;
handlers.handleKeyboardModeToggleRequested();
assert.equal(ctx.state.keyboardCharacterNavigation, true);
assert.equal(characterCursorLog.at(-1), 'paint:0:0');
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('C only switches navigation in keyboard mode, without modifiers or a pending chord', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
assert.equal(ctx.state.keyboardCharacterNavigation, false, 'outside keyboard mode');
handlers.handleKeyboardModeToggleRequested();
testGlobals.dispatchKeydown({ key: 'C', code: 'KeyC', shiftKey: true });
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC', ctrlKey: true });
assert.equal(ctx.state.keyboardCharacterNavigation, false, 'with a modifier');
// `C` is also the second key of a chord.
ctx.state.chordPending = true;
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
assert.equal(ctx.state.keyboardCharacterNavigation, false, 'during a chord');
assert.equal(ctx.state.chordPending, false);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: a lookup on a character searches from it to the end of the line', async () => {
const { ctx, handlers, testGlobals, characterCursorLog, setWordTexts } =
createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
setWordTexts(['もっとハ', 'キ', 'ハキ']);
handlers.handleKeyboardModeToggleRequested();
testGlobals.dispatchKeydown({ key: 'c', code: 'KeyC' });
for (let i = 0; i < 3; i += 1) testGlobals.dispatchKeydown({ key: 'l', code: 'KeyL' });
const added = testGlobals.selectionAddCount();
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
assert.deepEqual(characterCursorLog.slice(-2), ['paint:0:3', 'rangeToEnd:2']);
assert.equal(testGlobals.selectionAddCount(), added + 1);
assert.equal(testGlobals.commandEvents.at(-1)?.type, 'scanSelectedText');
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: up/down/j/k do not open or close lookup when popup is closed', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
testGlobals.dispatchKeydown({ key: 'ArrowUp', code: 'ArrowUp' });
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
testGlobals.dispatchKeydown({ key: 'j', code: 'KeyJ' });
testGlobals.dispatchKeydown({ key: 'k', code: 'KeyK' });
await wait(0);
const openEvents = testGlobals.commandEvents.filter(
(event) => event.type === 'scanSelectedText',
);
assert.equal(openEvents.length, 0);
const closeEvents = testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' && event.visible === false,
);
assert.equal(closeEvents.length, 0);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: up/down/j/k forward keydown to yomitan popup when open', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'ArrowUp', code: 'ArrowUp' });
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
testGlobals.dispatchKeydown({ key: 'j', code: 'KeyJ' });
testGlobals.dispatchKeydown({ key: 'k', code: 'KeyK' });
const forwarded = testGlobals.commandEvents.filter((event) => event.type === 'forwardKeyDown');
assert.equal(forwarded.length, 4);
assert.equal(
forwarded.some((event) => event.code === 'ArrowUp'),
true,
);
assert.equal(
forwarded.some((event) => event.code === 'ArrowDown'),
true,
);
assert.equal(
forwarded.some((event) => event.code === 'KeyJ'),
true,
);
assert.equal(
forwarded.some((event) => event.code === 'KeyK'),
true,
);
const openEvents = testGlobals.commandEvents.filter(
(event) => event.type === 'scanSelectedText',
);
assert.equal(openEvents.length, 0);
const closeEvents = testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' && event.visible === false,
);
assert.equal(closeEvents.length, 0);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: popup keybinds take precedence over configured session bindings', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'KeyJ',
key: { code: 'KeyJ', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'sid'],
},
] as never);
handlers.handleKeyboardModeToggleRequested();
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'j', code: 'KeyJ' });
assert.deepEqual(testGlobals.mpvCommands, []);
assert.deepEqual(
testGlobals.commandEvents
.filter((event) => event.type === 'forwardKeyDown')
.map((event) => event.code),
['KeyJ'],
);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: repeated popup navigation keys are forwarded while popup is open', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'j', code: 'KeyJ', repeat: true });
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown', repeat: true });
const forwarded = testGlobals.commandEvents.filter((event) => event.type === 'forwardKeyDown');
assert.equal(forwarded.length, 2);
assert.deepEqual(
forwarded.map((event) => ({ code: event.code, repeat: event.repeat })),
[
{ code: 'KeyJ', repeat: true },
{ code: 'ArrowDown', repeat: true },
],
);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('popup-visible mpv keybindings still fire for bound keys', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'Space',
key: { code: 'Space', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'],
},
{
sourcePath: 'keybindings[1].key',
originalKey: 'KeyQ',
key: { code: 'KeyQ', modifiers: [] },
actionType: 'mpv-command',
command: ['quit'],
},
] as never);
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: ' ', code: 'Space' });
testGlobals.dispatchKeydown({ key: 'q', code: 'KeyQ' });
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [['cycle', 'pause'], ['quit']]);
} finally {
testGlobals.restore();
}
});
test('default keybindings dispatch through overlay keyboard handling', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
const specialActionIds: Record<string, string> = {
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
[SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE]: 'playNextSubtitle',
};
const compiled = compileSessionBindings({
shortcuts: createEmptyShortcuts(),
keybindings: DEFAULT_KEYBINDINGS,
platform: 'linux',
});
try {
assert.deepEqual(compiled.warnings, []);
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings(compiled.bindings);
for (const binding of DEFAULT_KEYBINDINGS) {
testGlobals.dispatchKeydown(eventFromKeyString(binding.key));
}
await wait(0);
const expectedMpvCommands = DEFAULT_KEYBINDINGS.filter(
(binding) => !specialActionIds[String(binding.command?.[0])],
).map((binding) => binding.command);
const expectedSessionActions = DEFAULT_KEYBINDINGS.map(
(binding) => specialActionIds[String(binding.command?.[0])],
).filter(Boolean);
assert.deepEqual(
countedJsonValues(testGlobals.mpvCommands),
countedJsonValues(expectedMpvCommands),
);
assert.deepEqual(
testGlobals.sessionActions.map((action) => action.actionId).sort(),
expectedSessionActions.sort(),
);
testGlobals.dispatchKeydown({ key: 'Escape', code: 'Escape' });
} finally {
testGlobals.restore();
}
});
test('paused configured subtitle-jump keybinding re-applies pause after backward seek', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'Shift+KeyH',
key: { code: 'KeyH', modifiers: ['shift'] },
actionType: 'mpv-command',
command: ['sub-seek', -1],
},
] as never);
testGlobals.setPlaybackPausedResponse(true);
testGlobals.dispatchKeydown({ key: 'H', code: 'KeyH', shiftKey: true });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
['sub-seek', -1],
['set_property', 'pause', 'yes'],
]);
} finally {
testGlobals.restore();
}
});
test('configured mouse button keybinding dispatches through overlay mouse handling', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'MBTN_BACK',
key: { code: 'MBTN_BACK', modifiers: [] },
actionType: 'mpv-command',
command: ['sub-seek', -1],
},
] as never);
testGlobals.setPlaybackPausedResponse(false);
testGlobals.dispatchMousedown({ button: 3 });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.slice(-1), [['sub-seek', -1]]);
} finally {
testGlobals.restore();
}
});
test('configured wheel keybinding fires once per whole notch', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'Ctrl+WHEEL_UP',
key: { code: 'WHEEL_UP', modifiers: ['ctrl'] },
actionType: 'mpv-command',
command: ['add', 'sub-scale', 0.1],
},
] as never);
assert.equal(testGlobals.dispatchWheel({ deltaY: -240, ctrlKey: true }), true);
testGlobals.dispatchWheel({ deltaY: -60, ctrlKey: true });
testGlobals.dispatchWheel({ deltaY: -60, ctrlKey: true });
testGlobals.dispatchWheel({ deltaY: -120 });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands, [
['add', 'sub-scale', 0.1],
['add', 'sub-scale', 0.1],
['add', 'sub-scale', 0.1],
]);
} finally {
testGlobals.restore();
}
});
test('imported mpv wheel bindings forward from the overlay but not over modals', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(async () => ({
keys: ['WHEEL_UP', 'WHEEL_DOWN'],
blockedKeys: [],
}));
await handlers.setupMpvInputForwarding();
await wait(0);
testGlobals.dispatchWheel({ deltaY: 120 });
testGlobals.dispatchWheel({ deltaY: -120, target: testGlobals.createInteractiveTarget() });
// A Hachidori popup scrolls itself; its events arrive retargeted to its host.
const popupScrolled = !testGlobals.dispatchWheel({
deltaY: -120,
target: testGlobals.createDictionaryPopupHostTarget(),
});
assert.equal(popupScrolled, true);
assert.deepEqual(testGlobals.mpvCommands, [['keypress', 'WHEEL_DOWN', 1]]);
} finally {
testGlobals.restore();
}
});
test('imported mpv mouse buttons forward only after SubMiner mouse handling', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(async () => ({
keys: ['MBTN_LEFT_DBL', 'MBTN_RIGHT', 'MBTN_BACK'],
blockedKeys: [],
}));
testGlobals.setSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'MBTN_BACK',
key: { code: 'MBTN_BACK', modifiers: [] },
actionType: 'mpv-command',
command: ['sub-seek', -1],
},
]);
await handlers.setupMpvInputForwarding();
await wait(0);
testGlobals.dispatchMousedown({ button: 0 });
testGlobals.dispatchMousedown({ button: 0, type: 'mouseup' });
testGlobals.dispatchMousedown({ button: 0, target: testGlobals.createInteractiveTarget() });
testGlobals.dispatchMousedown({ button: 2 });
testGlobals.dispatchMousedown({ button: 3 });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands, [
['keydown', 'MBTN_LEFT'],
['keyup', 'MBTN_LEFT'],
['sub-seek', -1],
['cycle', 'pause'],
]);
} finally {
testGlobals.restore();
}
});
test('Hachidori attention without an open popup pane still forwards imported mpv input', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(async () => ({
keys: ['0', 'MBTN_LEFT_DBL'],
blockedKeys: [],
}));
await handlers.setupMpvInputForwarding();
await wait(0);
// Hachidori claims attention for any press that may start a selection.
ctx.state.hachidoriReaderSeen = true;
ctx.state.yomitanPopupVisible = true;
testGlobals.dispatchKeydown({ key: '0', code: 'Digit0' });
testGlobals.dispatchMousedown({ button: 0 });
testGlobals.dispatchMousedown({ button: 0, type: 'mouseup' });
assert.deepEqual(testGlobals.commandEvents, []);
assert.deepEqual(testGlobals.mpvCommands, [
['keydown', '0'],
['keydown', 'MBTN_LEFT'],
['keyup', 'MBTN_LEFT'],
]);
} finally {
testGlobals.restore();
}
});
test('an open subtitle sidebar only keeps imported mpv input aimed at the sidebar', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(async () => ({
keys: ['0', 'MBTN_LEFT_DBL'],
blockedKeys: [],
}));
await handlers.setupMpvInputForwarding();
await wait(0);
ctx.state.subtitleSidebarModalOpen = true;
const sidebarTarget = testGlobals.createInteractiveTarget();
const playbackTarget = testGlobals.createPlaybackTarget();
// Sidebar input runs first, while no key or button is held, so a held key cannot
// hide a forwarded press. Its release has no matching press to forward.
testGlobals.dispatchKeydown({ key: '0', code: 'Digit0', target: sidebarTarget });
testGlobals.dispatchMousedown({ button: 0, target: sidebarTarget });
testGlobals.dispatchMousedown({ button: 0, type: 'mouseup', target: sidebarTarget });
assert.deepEqual(testGlobals.mpvCommands, []);
testGlobals.dispatchKeydown({ key: '0', code: 'Digit0', target: playbackTarget });
testGlobals.dispatchMousedown({ button: 0, target: playbackTarget });
testGlobals.dispatchMousedown({ button: 0, type: 'mouseup', target: playbackTarget });
assert.deepEqual(testGlobals.mpvCommands, [
['keydown', '0'],
['keydown', 'MBTN_LEFT'],
['keyup', 'MBTN_LEFT'],
]);
} finally {
ctx.state.subtitleSidebarModalOpen = false;
testGlobals.restore();
}
});
test('keyboard mode: lookup toggle opens a lookup during Hachidori attention without a pane', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
ctx.state.hachidoriReaderSeen = true;
ctx.state.yomitanPopupVisible = true;
handlers.handleLookupWindowToggleRequested();
await wait(0);
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), true);
assert.deepEqual(
testGlobals.commandEvents.filter((event) => event.type === 'setVisible'),
[],
);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: lookup toggle closes an open Hachidori pane after attention is released', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
// A selection drag leaves the pane open while the Yomitan-shaped visible marker clears.
ctx.state.hachidoriReaderSeen = true;
ctx.state.yomitanPopupVisible = false;
testGlobals.setHachidoriPaneOpen(true);
handlers.handleLookupWindowToggleRequested();
await wait(0);
assert.deepEqual(
testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
),
[{ type: 'setVisible', visible: false }, { type: 'clearActiveTextSource' }],
);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('configured subtitle-jump keybinding preserves pause when pause state is unknown', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'Shift+KeyH',
key: { code: 'KeyH', modifiers: ['shift'] },
actionType: 'mpv-command',
command: ['sub-seek', -1],
},
] as never);
testGlobals.setPlaybackPausedResponse(null);
testGlobals.dispatchKeydown({ key: 'H', code: 'KeyH', shiftKey: true });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
['sub-seek', -1],
['set_property', 'pause', 'yes'],
]);
} finally {
testGlobals.restore();
}
});
test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' });
testGlobals.dispatchKeydown({ key: 't', code: 'KeyT' });
assert.equal(
testGlobals.mpvCommands.some(
(command) => command[0] === 'script-message' && command[1] === 'subminer-toggle',
),
true,
);
} finally {
testGlobals.restore();
}
});
test('visible-layer configured overlay toggle dispatches mpv plugin toggle', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.toggleVisibleOverlayGlobal',
originalKey: 'Alt+Shift+O',
key: { code: 'KeyO', modifiers: ['alt', 'shift'] },
actionType: 'session-action',
actionId: 'toggleVisibleOverlay',
},
] as never);
testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', altKey: true, shiftKey: true });
assert.equal(
testGlobals.mpvCommands.some(
(command) => command[0] === 'script-message' && command[1] === 'subminer-toggle',
),
true,
);
assert.equal(
testGlobals.sessionActions.some((action) => action.actionId === 'toggleVisibleOverlay'),
false,
);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: configured controller select binding opens locally without dispatching a session action', async () => {
const { testGlobals, handlers, openControllerSelectCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.openControllerSelect',
originalKey: 'Alt+D',
key: { code: 'KeyD', modifiers: ['alt'] },
actionType: 'session-action',
actionId: 'openControllerSelect',
},
] as never);
testGlobals.dispatchKeydown({
key: 'd',
code: 'KeyD',
altKey: true,
});
assert.equal(openControllerSelectCount(), 1);
assert.deepEqual(testGlobals.sessionActions, []);
assert.deepEqual(testGlobals.openedModalNotifications, []);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: configured controller debug binding is not swallowed while popup is visible', async () => {
const { ctx, testGlobals, handlers, openControllerDebugCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.openControllerDebug',
originalKey: 'Alt+Shift+D',
key: { code: 'KeyD', modifiers: ['alt', 'shift'] },
actionType: 'session-action',
actionId: 'openControllerDebug',
},
] as never);
testGlobals.dispatchKeydown({
key: 'D',
code: 'KeyD',
altKey: true,
shiftKey: true,
});
assert.equal(openControllerDebugCount(), 1);
assert.deepEqual(testGlobals.sessionActions, []);
assert.deepEqual(testGlobals.openedModalNotifications, []);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => {
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } =
createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.controllerSelectModalOpen = true;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
assert.equal(controllerSelectKeydownCount(), 1);
assert.equal(
testGlobals.commandEvents.some(
(event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
),
false,
);
} finally {
testGlobals.restore();
}
});
test('media timing review modal handles keys before later modal handlers', async () => {
const {
ctx,
testGlobals,
handlers,
mediaTimingReviewKeydownCount,
controllerSelectKeydownCount,
} = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.mediaTimingReviewModalOpen = true;
ctx.state.controllerSelectModalOpen = true;
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
assert.equal(mediaTimingReviewKeydownCount(), 1);
assert.equal(controllerSelectKeydownCount(), 0);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: changelog modal handles h/l fold keys before lookup controls', async () => {
const { ctx, testGlobals, handlers, changelogKeydownCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.changelogModalOpen = true;
ctx.state.keyboardSelectedWordIndex = 2;
// H and L fold/unfold changelog entries; they must not move the subtitle
// word selection or seek mpv behind the open modal.
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
testGlobals.dispatchKeydown({ key: 'l', code: 'KeyL' });
assert.equal(changelogKeydownCount(), 2);
assert.equal(ctx.state.keyboardSelectedWordIndex, 2);
} finally {
testGlobals.restore();
}
});
const KEYBOARD_MODE_LOOKUP_KEYS = [
{ key: 'h', code: 'KeyH' },
{ key: 'l', code: 'KeyL' },
{ key: 'ArrowLeft', code: 'ArrowLeft' },
{ key: 'ArrowRight', code: 'ArrowRight' },
];
test('keyboard mode: an open modal receives h/l/arrows instead of lookup controls', async () => {
const { ctx, testGlobals, handlers, jimakuKeydownCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.jimakuModalOpen = true;
ctx.state.keyboardSelectedWordIndex = 1;
const titleInput = testGlobals.createTextInputTarget();
for (const key of KEYBOARD_MODE_LOOKUP_KEYS) {
const prevented = testGlobals.dispatchKeydown({ ...key, target: titleInput });
assert.equal(prevented, false, `${key.code} must reach the focused modal input`);
}
await wait(0);
assert.equal(jimakuKeydownCount(), KEYBOARD_MODE_LOOKUP_KEYS.length);
assert.equal(ctx.state.keyboardSelectedWordIndex, 1);
assert.deepEqual(testGlobals.mpvCommands, []);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: h/l/arrows typed into a text field are not consumed by lookup controls', async () => {
const { testGlobals, handlers, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
// No words means lookup controls would seek mpv to the adjacent subtitle.
setWordCount(0);
const input = testGlobals.createTextInputTarget();
for (const key of KEYBOARD_MODE_LOOKUP_KEYS) {
const prevented = testGlobals.dispatchKeydown({ ...key, target: input });
assert.equal(prevented, false, `${key.code} must reach the text field`);
}
await wait(0);
assert.deepEqual(testGlobals.mpvCommands, []);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setPopupVisible(true);
testGlobals.setStatsToggleKey('KeyG');
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
assert.equal(testGlobals.statsToggleOverlayCalls(), 1);
} finally {
testGlobals.restore();
}
});
test('refreshConfiguredShortcuts updates refreshed stats and mark-watched keys', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.setConfiguredShortcuts({ multiCopyTimeoutMs: 3333 });
testGlobals.setStatsToggleKey('KeyG');
testGlobals.setMarkWatchedKey('KeyM');
await handlers.refreshConfiguredShortcuts();
assert.equal(ctx.state.sessionActionTimeoutMs, 3333);
const beforeMarkWatchedCalls = testGlobals.markActiveVideoWatchedCalls();
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
testGlobals.dispatchKeydown({ key: 'm', code: 'KeyM' });
await wait(10);
assert.equal(testGlobals.statsToggleOverlayCalls(), 1);
assert.equal(testGlobals.markActiveVideoWatchedCalls(), beforeMarkWatchedCalls + 1);
} finally {
testGlobals.restore();
}
});
test('youtube picker: unhandled keys still dispatch mpv keybindings', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'Space',
key: { code: 'Space', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'],
},
{
sourcePath: 'keybindings[1].key',
originalKey: 'KeyQ',
key: { code: 'KeyQ', modifiers: [] },
actionType: 'mpv-command',
command: ['quit'],
},
] as never);
ctx.state.youtubePickerModalOpen = true;
testGlobals.dispatchKeydown({ key: ' ', code: 'Space' });
testGlobals.dispatchKeydown({ key: 'q', code: 'KeyQ' });
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [['cycle', 'pause'], ['quit']]);
} finally {
testGlobals.restore();
}
});
test('session binding: Ctrl+Alt+S dispatches subsync action locally', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.triggerSubsync',
originalKey: 'Ctrl+Alt+S',
key: { code: 'KeyS', modifiers: ['ctrl', 'alt'] },
actionType: 'session-action',
actionId: 'triggerSubsync',
},
] as never);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS', ctrlKey: true, altKey: true });
assert.deepEqual(testGlobals.sessionActions, [
{ actionId: 'triggerSubsync', payload: undefined },
]);
} finally {
testGlobals.restore();
}
});
test('session binding: remapped mark watched dispatches locally with modifiers', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'stats.markWatchedKey',
originalKey: 'Ctrl+Shift+KeyW',
key: { code: 'KeyW', modifiers: ['ctrl', 'shift'] },
actionType: 'session-action',
actionId: 'markWatched',
},
] as never);
testGlobals.dispatchKeydown({ key: 'W', code: 'KeyW', ctrlKey: true, shiftKey: true });
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'markWatched', payload: undefined }]);
assert.equal(testGlobals.markActiveVideoWatchedCalls(), 0);
} finally {
testGlobals.restore();
}
});
test('session binding: copy subtitle multiple captures follow-up digit locally', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.copySubtitleMultiple',
originalKey: 'Ctrl+M',
key: { code: 'KeyM', modifiers: ['ctrl'] },
actionType: 'session-action',
actionId: 'copySubtitleMultiple',
},
] as never);
testGlobals.dispatchKeydown({ key: 'm', code: 'KeyM', ctrlKey: true });
testGlobals.dispatchKeydown({ key: '3', code: 'Digit3' });
assert.deepEqual(testGlobals.sessionActions, [
{ actionId: 'copySubtitleMultiple', payload: { count: 3 } },
]);
} finally {
testGlobals.restore();
}
});
test('session binding: mine sentence multiple captures modified follow-up digit locally', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.mineSentenceMultiple',
originalKey: 'Ctrl+Shift+S',
key: { code: 'KeyS', modifiers: ['ctrl', 'shift'] },
actionType: 'session-action',
actionId: 'mineSentenceMultiple',
},
] as never);
testGlobals.dispatchKeydown({ key: 'S', code: 'KeyS', ctrlKey: true, shiftKey: true });
testGlobals.dispatchKeydown({ key: '#', code: 'Digit3', ctrlKey: true, shiftKey: true });
assert.deepEqual(testGlobals.sessionActions, [
{ actionId: 'mineSentenceMultiple', payload: { count: 3 } },
]);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: h moves left when popup is closed', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 2;
ctx.state.yomitanPopupVisible = false;
testGlobals.setPopupVisible(false);
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
assert.equal(ctx.state.keyboardSelectedWordIndex, 1);
const closeEvents = testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' && event.visible === false,
);
assert.equal(closeEvents.length, 0);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: h moves left while popup is open and keeps lookup active', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 2;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
await wait(80);
assert.equal(ctx.state.keyboardSelectedWordIndex, 1);
const openEvents = testGlobals.commandEvents.filter(
(event) => event.type === 'scanSelectedText',
);
assert.equal(openEvents.length > 0, true);
const closeEvents = testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' && event.visible === false,
);
assert.equal(closeEvents.length, 0);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: opening lookup restores overlay keyboard focus', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
await wait(0);
assert.equal(testGlobals.focusMainWindowCalls() > 0, true);
assert.equal(testGlobals.windowFocusCalls() > 0, true);
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: visible-layer Ctrl+Shift+Y should not be toggled by renderer keydown', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.platform.isModalLayer = false;
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
handlers.handleKeyboardModeToggleRequested();
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: modal layer never enables keyboard mode', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.platform.isModalLayer = true;
testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
// Controller toggle path.
handlers.handleKeyboardModeToggleRequested();
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: turning mode off clears selected token highlight', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
handlers.handleKeyboardModeToggleRequested();
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
assert.equal(ctx.state.keyboardSelectedWordIndex, null);
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: popup hidden after mode off clears stale selected token highlight', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.syncKeyboardTokenSelection();
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
handlers.handleKeyboardModeToggleRequested();
ctx.state.yomitanPopupVisible = false;
testGlobals.setPopupVisible(false);
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('Yomitan popup dismissal and subtitle updates preserve selection outside the overlay subtitle', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
let cleared = false;
try {
Object.defineProperty(window, 'getSelection', {
configurable: true,
value: () => ({
anchorNode: {},
removeAllRanges: () => {
cleared = true;
},
}),
});
Object.assign(ctx.dom.subtitleRoot, { contains: () => false });
await handlers.setupMpvInputForwarding();
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
handlers.syncKeyboardTokenSelection();
assert.equal(cleared, false);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: closing lookup keeps controller selection but clears native text selection', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
handlers.handleLookupWindowToggleRequested();
await wait(0);
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), true);
assert.equal(testGlobals.selectionAddCount() > 0, true);
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.closeLookupWindow();
ctx.state.yomitanPopupVisible = false;
testGlobals.setPopupVisible(false);
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
await wait(0);
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
assert.equal(testGlobals.selectionClearCount() > 0, true);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: closing lookup clears yomitan active text source so same token can reopen immediately', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
handlers.handleLookupWindowToggleRequested();
await wait(0);
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.handleLookupWindowToggleRequested();
await wait(0);
const closeCommands = testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
);
assert.deepEqual(closeCommands.slice(-2), [
{ type: 'setVisible', visible: false },
{ type: 'clearActiveTextSource' },
]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('subtitle refresh outside keyboard mode clears yomitan active text source', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
handlers.handleSubtitleContentUpdated();
await wait(0);
const clearCommands = testGlobals.commandEvents.filter(
(event) => event.type === 'clearActiveTextSource',
);
assert.deepEqual(clearCommands, [{ type: 'clearActiveTextSource' }]);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
ctx.state.yomitanPopupVisible = false;
testGlobals.setPopupVisible(true);
handlers.handleLookupWindowToggleRequested();
await wait(0);
const closeCommands = testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
);
assert.deepEqual(closeCommands.slice(-2), [
{ type: 'setVisible', visible: false },
{ type: 'clearActiveTextSource' },
]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(3);
ctx.state.keyboardSelectedWordIndex = 2;
handlers.syncKeyboardTokenSelection();
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
setWordCount(2);
handlers.syncKeyboardTokenSelection();
assert.equal(ctx.state.keyboardSelectedWordIndex, 0);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: moving left beyond start jumps previous subtitle and sets selector to end', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(3);
ctx.state.keyboardSelectedWordIndex = 0;
handlers.syncKeyboardTokenSelection();
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
setWordCount(4);
handlers.syncKeyboardTokenSelection();
assert.equal(ctx.state.keyboardSelectedWordIndex, 3);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: empty subtitle gap left and right still seek adjacent subtitle lines', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(0);
handlers.syncKeyboardTokenSelection();
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('controller mode: empty subtitle gap horizontal move still seeks adjacent subtitle lines', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(0);
handlers.syncKeyboardTokenSelection();
assert.equal(handlers.moveSelectionForController(1), true);
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
assert.equal(handlers.moveSelectionForController(-1), true);
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(2);
ctx.state.keyboardSelectedWordIndex = 1;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.syncKeyboardTokenSelection();
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
setWordCount(3);
handlers.syncKeyboardTokenSelection();
await wait(80);
assert.equal(ctx.state.keyboardSelectedWordIndex, 0);
const openEvents = testGlobals.commandEvents.filter(
(event) => event.type === 'scanSelectedText',
);
assert.equal(openEvents.length > 0, true);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: natural subtitle advance resets selector to the start of the new line', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(3);
ctx.state.keyboardSelectedWordIndex = 2;
handlers.syncKeyboardTokenSelection();
handlers.handleSubtitleContentUpdated();
setWordCount(4);
handlers.syncKeyboardTokenSelection();
assert.equal(ctx.state.keyboardSelectedWordIndex, 0);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(2);
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
testGlobals.setPlaybackPausedResponse(true);
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
['sub-seek', 1],
['set_property', 'pause', 'yes'],
]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: edge jump with unknown pause state re-applies pause conservatively', async () => {
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
setWordCount(2);
ctx.state.keyboardSelectedWordIndex = 1;
handlers.syncKeyboardTokenSelection();
testGlobals.setPlaybackPausedResponse(null);
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
await wait(0);
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
['sub-seek', 1],
['set_property', 'pause', 'yes'],
]);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: popup iframe focusin reclaims overlay keyboard focus', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
testGlobals.setPopupVisible(true);
const before = testGlobals.focusMainWindowCalls();
testGlobals.dispatchFocusInOnPopup();
await wait(260);
assert.equal(testGlobals.focusMainWindowCalls() > before, true);
assert.equal(testGlobals.windowFocusCalls() > 0, true);
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('mark-watched keybinding calls markActiveVideoWatched and sends mpv commands', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
const beforeCalls = testGlobals.markActiveVideoWatchedCalls();
const beforeMpvCount = testGlobals.mpvCommands.length;
testGlobals.dispatchKeydown({ key: 'w', code: 'KeyW' });
await wait(10);
assert.equal(testGlobals.markActiveVideoWatchedCalls(), beforeCalls + 1);
const newMpvCommands = testGlobals.mpvCommands.slice(beforeMpvCount);
assert.deepEqual(newMpvCommands, [
['show-text', 'Marked as watched', '1500'],
['playlist-next', 'force'],
]);
} finally {
testGlobals.restore();
}
});
test('mark-watched keybinding does not send mpv commands when no active session', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.setMarkActiveVideoWatchedResult(false);
const beforeMpvCount = testGlobals.mpvCommands.length;
testGlobals.dispatchKeydown({ key: 'w', code: 'KeyW' });
await wait(10);
assert.equal(testGlobals.markActiveVideoWatchedCalls() > 0, true);
const newMpvCommands = testGlobals.mpvCommands.slice(beforeMpvCount);
assert.deepEqual(newMpvCommands, []);
} finally {
testGlobals.restore();
}
});
test('discovered mpv keys only run after SubMiner controls and stay out of session help', async () => {
const { handlers, testGlobals, ctx } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(async () => ({
keys: ['r', 'SPACE', 'y', 'v'],
blockedKeys: [],
}));
testGlobals.setSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'Space',
key: { code: 'Space', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'],
},
]);
await handlers.setupMpvInputForwarding();
await wait(0);
testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR' });
testGlobals.dispatchKeydown({ key: ' ', code: 'Space' });
assert.deepEqual(testGlobals.mpvCommands, [
['keydown', 'r'],
['cycle', 'pause'],
]);
assert.equal(ctx.state.sessionBindings.length, 1);
testGlobals.dispatchWindowEvent('blur');
const before = testGlobals.mpvCommands.length;
ctx.state.playlistBrowserModalOpen = true;
testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR' });
ctx.state.playlistBrowserModalOpen = false;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR' });
ctx.state.yomitanPopupVisible = false;
testGlobals.setPopupVisible(false);
testGlobals.dispatchKeydown({ key: 'r', code: 'KeyR', target: { closest: () => ({}) } });
assert.equal(testGlobals.mpvCommands.length, before);
} finally {
testGlobals.restore();
}
});
test('stalled mpv discovery does not delay configured overlay controls', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetMpvInputBindings(() => new Promise(() => {}));
testGlobals.setSessionBindings([
{
sourcePath: 'keybindings[0].key',
originalKey: 'Space',
key: { code: 'Space', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'],
},
]);
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: ' ', code: 'Space' });
assert.deepEqual(testGlobals.mpvCommands, [['cycle', 'pause']]);
} finally {
testGlobals.restore();
}
});
test('session binding: g-s opens subtitle selection only after the complete sequence', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.openSubtitleSelection',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
},
]);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
assert.deepEqual(testGlobals.sessionActions, []);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.deepEqual(testGlobals.sessionActions, [
{ actionId: 'openSubtitleSelection', payload: undefined },
]);
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
handlers.updateSessionBindings([]);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.equal(testGlobals.sessionActions.length, 1);
} finally {
testGlobals.restore();
}
});
test('single-key actions run immediately even if a conflicting sequence reaches the renderer', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'sequence',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
},
{
sourcePath: 'single',
originalKey: 'g',
key: { code: 'KeyG', modifiers: [] },
actionType: 'mpv-command',
command: ['show-text', 'single'],
},
]);
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
assert.deepEqual(testGlobals.mpvCommands, [['show-text', 'single']]);
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.deepEqual(testGlobals.sessionActions, []);
} finally {
testGlobals.restore();
}
});
test('an unfinished built-in y chord cannot start a configured sequence', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'sequence',
originalKey: 'g-s',
key: { code: 'KeyG-KeyS', modifiers: [] },
actionType: 'session-action',
actionId: 'openSubtitleSelection',
},
]);
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' });
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.deepEqual(testGlobals.sessionActions, []);
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
assert.equal(testGlobals.sessionActions.length, 1);
} finally {
testGlobals.restore();
}
});
test('controller popup entry navigation forwards Alt+Up / Alt+Down only while the popup is open', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
assert.equal(handlers.movePopupEntryForController(1), false);
assert.equal(
testGlobals.commandEvents.some((event) => event.type === 'forwardKeyDown'),
false,
);
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
assert.equal(handlers.movePopupEntryForController(1), true);
assert.equal(handlers.movePopupEntryForController(-1), true);
const forwarded = testGlobals.commandEvents
.filter((event) => event.type === 'forwardKeyDown')
.map((event) => [event.key, event.code, event.modifiers]);
assert.deepEqual(forwarded, [
['ArrowDown', 'ArrowDown', ['alt']],
['ArrowUp', 'ArrowUp', ['alt']],
]);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: leaving the mode cancels a lookup whose popup has not appeared yet', async () => {
const { ctx, handlers, testGlobals, wordEventCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
// Without a popup, the lookup falls back to a synthetic scan shortly after.
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
await wait(100);
const afterFallback = wordEventCount();
assert.ok(afterFallback > 0, 'the fallback scan runs');
// Leaving the mode before that fallback runs must cancel it.
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
handlers.handleKeyboardModeToggleRequested();
await wait(100);
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
assert.equal(wordEventCount(), afterFallback, 'no scan after the mode is off');
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});
test('keyboard mode: leaving the mode closes an open popup', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
const closeEvents = () =>
testGlobals.commandEvents.filter(
(event) => event.type === 'setVisible' && event.visible === false,
);
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
handlers.handleKeyboardModeToggleRequested();
await wait(0);
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
assert.equal(closeEvents().length, 1);
// Turning the mode on again leaves a popup alone.
handlers.handleKeyboardModeToggleRequested();
await wait(0);
assert.equal(closeEvents().length, 1);
} finally {
ctx.state.keyboardDrivenModeEnabled = false;
testGlobals.restore();
}
});