Files
SubMiner/src/renderer/handlers/keyboard.test.ts

643 lines
19 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createKeyboardHandlers } from './keyboard.js';
import { createRendererState } from '../state.js';
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js';
type CommandEventDetail = {
type?: string;
visible?: boolean;
key?: string;
code?: string;
repeat?: boolean;
};
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 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 documentListeners = new Map<string, Array<(event: unknown) => void>>();
const commandEvents: CommandEventDetail[] = [];
const mpvCommands: Array<Array<string | number>> = [];
let playbackPausedResponse: boolean | null = false;
let popupVisible = 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: () => {},
addRange: () => {},
};
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);
}
}
Object.defineProperty(globalThis, 'CustomEvent', {
configurable: true,
value: TestCustomEvent,
});
Object.defineProperty(globalThis, 'MouseEvent', {
configurable: true,
value: TestMouseEvent,
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
addEventListener: () => {},
dispatchEvent: (event: Event) => {
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
const detail = (event as Event & { detail?: CommandEventDetail }).detail;
commandEvents.push(detail ?? {});
}
return true;
},
getComputedStyle: () => ({
visibility: 'visible',
display: 'block',
opacity: '1',
}),
getSelection: () => selection,
focus: () => {
windowFocusCalls += 1;
},
electronAPI: {
getKeybindings: async () => [],
sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command);
},
getPlaybackPaused: async () => playbackPausedResponse,
toggleDevTools: () => {},
focusMainWindow: () => {
focusMainWindowCalls += 1;
return Promise.resolve();
},
},
},
});
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: () => {
if (popupVisible) {
return [popupIframe];
}
return [];
},
createRange: () => ({
selectNodeContents: () => {},
}),
body: {},
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: class {
observe() {}
},
});
function dispatchKeydown(event: {
key: string;
code: string;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
repeat?: boolean;
}): void {
const listeners = documentListeners.get('keydown') ?? [];
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: () => {},
target: null,
};
for (const listener of listeners) {
listener(keyboardEvent);
}
}
function dispatchFocusInOnPopup(): void {
const listeners = documentListeners.get('focusin') ?? [];
const focusEvent = {
target: popupIframe,
};
for (const listener of listeners) {
listener(focusEvent);
}
}
function restore() {
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,
});
}
const overlay = {
focus: (options?: { preventScroll?: boolean }) => {
overlayFocusCalls.push(options ?? {});
},
};
return {
commandEvents,
mpvCommands,
overlay,
overlayFocusCalls,
focusMainWindowCalls: () => focusMainWindowCalls,
windowFocusCalls: () => windowFocusCalls,
dispatchKeydown,
dispatchFocusInOnPopup,
setPopupVisible: (value: boolean) => {
popupVisible = value;
},
getPlaybackPaused: async () => playbackPausedResponse,
setPlaybackPausedResponse: (value: boolean | null) => {
playbackPausedResponse = value;
},
restore,
};
}
function createKeyboardHandlerHarness() {
const testGlobals = installKeyboardTestGlobals();
const subtitleRootClassList = createClassList();
const createWordNode = (left: number) => ({
classList: createClassList(),
getBoundingClientRect: () => ({ left, top: 10, width: 30, height: 20 }),
dispatchEvent: () => true,
});
let wordNodes = [createWordNode(10), createWordNode(80), createWordNode(150)];
const ctx = {
dom: {
subtitleRoot: {
classList: subtitleRootClassList,
querySelectorAll: () => wordNodes,
},
subtitleContainer: {
contains: () => false,
},
overlay: testGlobals.overlay,
},
platform: {
shouldToggleMouseIgnore: false,
isMacOSPlatform: false,
overlayLayer: 'always-on-top',
},
state: createRendererState(),
};
const handlers = createKeyboardHandlers(ctx as never, {
handleRuntimeOptionsKeydown: () => false,
handleSubsyncKeydown: () => false,
handleKikuKeydown: () => false,
handleJimakuKeydown: () => false,
handleSessionHelpKeydown: () => false,
openSessionHelpModal: () => {},
appendClipboardVideoToQueue: () => {},
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
});
return {
ctx,
handlers,
testGlobals,
setWordCount: (count: number) => {
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
},
};
}
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: 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: 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('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: 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: 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: 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();
}
});