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, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js'; type CommandEventDetail = { type?: string; visible?: boolean; key?: string; code?: string; repeat?: boolean; direction?: number; deltaX?: number; deltaY?: number; }; function createClassList() { const classes = new Set(); 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 { 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 void>>(); const windowListeners = new Map void>>(); const commandEvents: CommandEventDetail[] = []; const mpvCommands: Array> = []; let playbackPausedResponse: boolean | null = false; let statsToggleKey = 'Backquote'; let markWatchedKey = 'KeyW'; let markActiveVideoWatchedResult = true; let markActiveVideoWatchedCalls = 0; let statsToggleOverlayCalls = 0; let selectionClearCount = 0; let selectionAddCount = 0; 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: () => { 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); } } Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: TestCustomEvent, }); Object.defineProperty(globalThis, 'MouseEvent', { configurable: true, value: TestMouseEvent, }); 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: { getKeybindings: async () => [], sendMpvCommand: (command: Array) => { mpvCommands.push(command); }, 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(); }, }, }, }); 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 dispatchWindowEvent(type: string): void { const listeners = windowListeners.get(type) ?? []; for (const listener of listeners) { listener(new Event(type)); } } 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, dispatchWindowEvent, setPopupVisible: (value: boolean) => { popupVisible = value; }, setStatsToggleKey: (value: string) => { statsToggleKey = value; }, setMarkWatchedKey: (value: string) => { markWatchedKey = value; }, setMarkActiveVideoWatchedResult: (value: boolean) => { markActiveVideoWatchedResult = value; }, markActiveVideoWatchedCalls: () => markActiveVideoWatchedCalls, statsToggleOverlayCalls: () => statsToggleOverlayCalls, getPlaybackPaused: async () => playbackPausedResponse, setPlaybackPausedResponse: (value: boolean | null) => { playbackPausedResponse = value; }, selectionClearCount: () => selectionClearCount, selectionAddCount: () => selectionAddCount, restore, }; } function createKeyboardHandlerHarness() { const testGlobals = installKeyboardTestGlobals(); const subtitleRootClassList = createClassList(); let controllerSelectOpenCount = 0; let controllerDebugOpenCount = 0; let controllerSelectKeydownCount = 0; 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, isModalLayer: false, overlayLayer: 'always-on-top', }, state: createRendererState(), }; const handlers = createKeyboardHandlers(ctx as never, { handleRuntimeOptionsKeydown: () => false, handleSubsyncKeydown: () => false, handleKikuKeydown: () => false, handleJimakuKeydown: () => false, handleControllerSelectKeydown: () => { controllerSelectKeydownCount += 1; return true; }, handleControllerDebugKeydown: () => false, handleYoutubePickerKeydown: () => false, handleSessionHelpKeydown: () => false, openSessionHelpModal: () => {}, appendClipboardVideoToQueue: () => {}, getPlaybackPaused: () => testGlobals.getPlaybackPaused(), openControllerSelectModal: () => { controllerSelectOpenCount += 1; }, openControllerDebugModal: () => { controllerDebugOpenCount += 1; }, }); return { ctx, handlers, testGlobals, controllerSelectOpenCount: () => controllerSelectOpenCount, controllerDebugOpenCount: () => controllerDebugOpenCount, controllerSelectKeydownCount: () => controllerSelectKeydownCount, 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('popup-visible mpv keybindings still fire for bound keys', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); try { await handlers.setupMpvInputForwarding(); handlers.updateKeybindings([ { key: 'Space', command: ['cycle', 'pause'], }, { key: 'KeyQ', 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('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); try { await handlers.setupMpvInputForwarding(); ctx.state.yomitanPopupVisible = true; testGlobals.setPopupVisible(true); assert.equal(handlers.playCurrentAudioForController(), true); assert.equal(handlers.cyclePopupAudioSourceForController(1), true); assert.equal(handlers.scrollPopupByController(48, -24), true); assert.deepEqual(testGlobals.commandEvents.slice(-3), [ { type: 'playCurrentAudio' }, { type: 'cycleAudioSource', direction: 1 }, { type: 'scrollBy', deltaX: 48, deltaY: -24 }, ]); } finally { testGlobals.restore(); } }); test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => { const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness(); try { await handlers.setupMpvInputForwarding(); testGlobals.dispatchKeydown({ key: 'C', code: 'KeyC', altKey: true, shiftKey: true, }); assert.equal(controllerDebugOpenCount(), 1); } finally { testGlobals.restore(); } }); test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => { const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness(); try { await handlers.setupMpvInputForwarding(); ctx.state.yomitanPopupVisible = true; testGlobals.dispatchKeydown({ key: 'C', code: 'KeyC', altKey: true, shiftKey: true, }); assert.equal(controllerDebugOpenCount(), 1); } 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('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('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 Ctrl+Shift+Y still toggles via renderer keydown', 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, true); testGlobals.dispatchKeydown({ key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true }); 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('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(); } });