test: cut low-value tests, rewrite the rest around behavior, parallelize lanes (#285)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-05 00:07:44 -07:00
1 parent 4b6842d034
commit b48083ddd8
368 files changed
+18045 -60331

No files matched your search

+61 -368
View File
@@ -2,16 +2,26 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createRendererRecoveryController } from './error-recovery.js';
import {
YOMITAN_POPUP_HOST_SELECTOR,
YOMITAN_POPUP_IFRAME_SELECTOR,
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
hasYomitanPopupIframe,
isYomitanPopupIframe,
isYomitanPopupVisible,
} from './yomitan-popup.js';
import { scrollActiveRuntimeOptionIntoView } from './modals/runtime-options.js';
import { resolvePlatformInfo } from './utils/platform.js';
type RecoveryDeps = Parameters<typeof createRendererRecoveryController>[0];
function makeDeps(overrides: Partial<RecoveryDeps> = {}): RecoveryDeps {
return {
dismissActiveUi: () => {},
restoreOverlayInteraction: () => {},
showToast: () => {},
getSnapshot: () => ({
activeModal: null,
subtitlePreview: '',
secondarySubtitlePreview: '',
isOverlayInteractive: false,
isOverSubtitle: false,
overlayLayer: 'visible',
}),
logError: () => {},
...overrides,
};
}
test('handleError logs context and recovers overlay state', () => {
const payloads: unknown[] = [];
@@ -19,28 +29,30 @@ test('handleError logs context and recovers overlay state', () => {
let restored = 0;
const shown: string[] = [];
const controller = createRendererRecoveryController({
dismissActiveUi: () => {
dismissed += 1;
},
restoreOverlayInteraction: () => {
restored += 1;
},
showToast: (message) => {
shown.push(message);
},
getSnapshot: () => ({
activeModal: 'jimaku',
subtitlePreview: '字幕テキスト',
secondarySubtitlePreview: 'secondary',
isOverlayInteractive: true,
isOverSubtitle: true,
overlayLayer: 'visible',
const controller = createRendererRecoveryController(
makeDeps({
dismissActiveUi: () => {
dismissed += 1;
},
restoreOverlayInteraction: () => {
restored += 1;
},
showToast: (message) => {
shown.push(message);
},
getSnapshot: () => ({
activeModal: 'jimaku',
subtitlePreview: '字幕テキスト',
secondarySubtitlePreview: 'secondary',
isOverlayInteractive: true,
isOverSubtitle: true,
overlayLayer: 'visible',
}),
logError: (payload) => {
payloads.push(payload);
},
}),
logError: (payload) => {
payloads.push(payload);
},
});
);
controller.handleError(new Error('renderer boom'), {
source: 'callback',
@@ -69,23 +81,13 @@ test('handleError logs context and recovers overlay state', () => {
test('handleError normalizes non-Error values', () => {
const payloads: unknown[] = [];
const controller = createRendererRecoveryController({
dismissActiveUi: () => {},
restoreOverlayInteraction: () => {},
showToast: () => {},
getSnapshot: () => ({
activeModal: null,
subtitlePreview: '',
secondarySubtitlePreview: '',
isOverlayInteractive: false,
isOverSubtitle: false,
overlayLayer: 'visible',
const controller = createRendererRecoveryController(
makeDeps({
logError: (payload) => {
payloads.push(payload);
},
}),
logError: (payload) => {
payloads.push(payload);
},
});
);
controller.handleError({ code: 500, reason: 'timeout' }, { source: 'callback', action: 'modal' });
@@ -97,29 +99,21 @@ test('handleError normalizes non-Error values', () => {
test('nested recovery errors are ignored while current recovery is active', () => {
const payloads: unknown[] = [];
let restored = 0;
let controllerRef: ReturnType<typeof createRendererRecoveryController> | null = null;
const controller = createRendererRecoveryController({
dismissActiveUi: () => {
controllerRef?.handleError(new Error('nested'), { source: 'callback', action: 'nested' });
},
restoreOverlayInteraction: () => {
restored += 1;
},
showToast: () => {},
getSnapshot: () => ({
activeModal: 'runtime-options',
subtitlePreview: '',
secondarySubtitlePreview: '',
isOverlayInteractive: true,
isOverSubtitle: false,
overlayLayer: 'visible',
const controller = createRendererRecoveryController(
makeDeps({
dismissActiveUi: () => {
controllerRef?.handleError(new Error('nested'), { source: 'callback', action: 'nested' });
},
restoreOverlayInteraction: () => {
restored += 1;
},
logError: (payload) => {
payloads.push(payload);
},
}),
logError: (payload) => {
payloads.push(payload);
},
});
);
controllerRef = controller;
controller.handleError(new Error('outer'), { source: 'callback', action: 'outer' });
@@ -127,304 +121,3 @@ test('nested recovery errors are ignored while current recovery is active', () =
assert.equal(payloads.length, 1);
assert.equal(restored, 1);
});
test('resolvePlatformInfo prefers query layer over preload layer', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getOverlayLayer: () => 'modal',
},
location: { search: '?layer=visible' },
},
});
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
platform: 'MacIntel',
userAgent: 'Mozilla/5.0 (Macintosh)',
},
});
try {
const info = resolvePlatformInfo();
assert.equal(info.overlayLayer, 'visible');
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: previousNavigator,
});
}
});
test('resolvePlatformInfo ignores legacy secondary layer and falls back to visible', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getOverlayLayer: () => 'secondary',
},
location: { search: '' },
},
});
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
platform: 'MacIntel',
userAgent: 'Mozilla/5.0 (Macintosh)',
},
});
try {
const info = resolvePlatformInfo();
assert.equal(info.overlayLayer, 'visible');
assert.equal(info.shouldToggleMouseIgnore, true);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: previousNavigator,
});
}
});
test('resolvePlatformInfo supports modal layer and disables mouse-ignore toggles', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getOverlayLayer: () => 'modal',
},
location: { search: '' },
},
});
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
platform: 'MacIntel',
userAgent: 'Mozilla/5.0 (Macintosh)',
},
});
try {
const info = resolvePlatformInfo();
assert.equal(info.overlayLayer, 'modal');
assert.equal(info.isModalLayer, true);
assert.equal(info.shouldToggleMouseIgnore, false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: previousNavigator,
});
}
});
test('resolvePlatformInfo flags Windows platforms', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getOverlayLayer: () => 'visible',
},
location: { search: '' },
},
});
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
platform: 'Win32',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
},
});
try {
const info = resolvePlatformInfo();
assert.equal(info.isWindowsPlatform, true);
assert.equal(info.isMacOSPlatform, false);
assert.equal(info.isLinuxPlatform, false);
assert.equal(info.shouldToggleMouseIgnore, true);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: previousNavigator,
});
}
});
test('isYomitanPopupIframe matches modern popup class and legacy id prefix', () => {
const createElement = (options: {
tagName: string;
id?: string;
classNames?: string[];
}): Element =>
({
tagName: options.tagName,
id: options.id ?? '',
classList: {
contains: (className: string) => (options.classNames ?? []).includes(className),
},
}) as unknown as Element;
assert.equal(
isYomitanPopupIframe(
createElement({
tagName: 'IFRAME',
classNames: ['yomitan-popup'],
}),
),
true,
);
assert.equal(
isYomitanPopupIframe(
createElement({
tagName: 'IFRAME',
id: 'yomitan-popup-123',
}),
),
true,
);
assert.equal(
isYomitanPopupIframe(
createElement({
tagName: 'IFRAME',
id: 'something-else',
}),
),
false,
);
});
test('hasYomitanPopupIframe queries for modern + legacy selector', () => {
let selector = '';
const root = {
querySelector: (value: string) => {
selector = value;
return {};
},
} as unknown as ParentNode;
assert.equal(hasYomitanPopupIframe(root), true);
assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR);
});
test('hasYomitanPopupIframe falls back to popup host selector for shadow-hosted popups', () => {
const selectors: string[] = [];
const root = {
querySelector: (value: string) => {
selectors.push(value);
if (value === YOMITAN_POPUP_HOST_SELECTOR) {
return {};
}
return null;
},
} as unknown as ParentNode;
assert.equal(hasYomitanPopupIframe(root), true);
assert.deepEqual(selectors, [YOMITAN_POPUP_IFRAME_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR]);
});
test('isYomitanPopupVisible requires visible iframe geometry', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const selectors: string[] = [];
const visibleFrame = {
getBoundingClientRect: () => ({ width: 320, height: 180 }),
} as unknown as HTMLIFrameElement;
const hiddenFrame = {
getBoundingClientRect: () => ({ width: 320, height: 180 }),
} as unknown as HTMLIFrameElement;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
getComputedStyle: (element: Element) => {
if (element === hiddenFrame) {
return { visibility: 'hidden', display: 'block', opacity: '1' } as CSSStyleDeclaration;
}
return { visibility: 'visible', display: 'block', opacity: '1' } as CSSStyleDeclaration;
},
},
});
try {
const root = {
querySelectorAll: (value: string) => {
selectors.push(value);
if (
value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
value === YOMITAN_POPUP_HOST_SELECTOR
) {
return [];
}
return [hiddenFrame, visibleFrame];
},
} as unknown as ParentNode;
assert.equal(isYomitanPopupVisible(root), true);
assert.deepEqual(selectors, [
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
YOMITAN_POPUP_IFRAME_SELECTOR,
]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
test('isYomitanPopupVisible detects visible shadow-hosted popup marker without iframe access', () => {
let selector = '';
const root = {
querySelectorAll: (value: string) => {
selector = value;
if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) {
return [{ getAttribute: () => 'true' }];
}
return [];
},
} as unknown as ParentNode;
assert.equal(isYomitanPopupVisible(root), true);
assert.equal(selector, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
});
test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => {
const calls: Array<{ block?: ScrollLogicalPosition }> = [];
const activeItem = {
scrollIntoView: (options?: ScrollIntoViewOptions) => {
calls.push({ block: options?.block });
},
};
const list = {
querySelector: (selector: string) => {
assert.equal(selector, '.runtime-options-item.active');
return activeItem as unknown as Element;
},
};
scrollActiveRuntimeOptionIntoView(list);
assert.deepEqual(calls, [{ block: 'nearest' }]);
});
test('scrollActiveRuntimeOptionIntoView no-ops without active option', () => {
const list = {
querySelector: () => null,
};
assert.doesNotThrow(() => {
scrollActiveRuntimeOptionIntoView(list);
});
});
File diff suppressed because it is too large. Load diff
+6 -323
View File
@@ -1,6 +1,4 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { createKeyboardHandlers } from './keyboard.js';
@@ -460,8 +458,8 @@ function installKeyboardTestGlobals() {
setMarkWatchedKey: (value: string) => {
markWatchedKey = value;
},
setConfiguredShortcuts: (value: typeof configuredShortcuts) => {
configuredShortcuts = value;
setConfiguredShortcuts: (overrides: Partial<typeof configuredShortcuts>) => {
configuredShortcuts = { ...configuredShortcuts, ...overrides };
},
setGetMpvInputBindings: (value: typeof getMpvInputBindings) => {
getMpvInputBindings = value;
@@ -585,19 +583,6 @@ function createKeyboardHandlerHarness() {
};
}
test('renderer installs keyboard forwarding before startup subtitle IPC awaits', () => {
const source = fs.readFileSync(
path.join(process.cwd(), 'src', 'renderer', 'renderer.ts'),
'utf8',
);
const keyboardSetupIndex = source.indexOf('await keyboardHandlers.setupMpvInputForwarding();');
const subtitleRequestIndex = source.indexOf('await window.electronAPI.getCurrentSubtitle();');
assert.notEqual(keyboardSetupIndex, -1);
assert.notEqual(subtitleRequestIndex, -1);
assert.equal(keyboardSetupIndex < subtitleRequestIndex, true);
});
test('primary subtitle visibility key cycles modes with primary OSD without mpv sub-visibility', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -1303,67 +1288,6 @@ test('visible-layer configured overlay toggle dispatches mpv plugin toggle', asy
}
});
test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.setConfiguredShortcuts({
copySubtitle: '',
copySubtitleMultiple: '',
updateLastCardFromClipboard: '',
triggerFieldGrouping: '',
triggerSubsync: 'Ctrl+Alt+S',
mineSentence: '',
mineSentenceMultiple: '',
multiCopyTimeoutMs: 3333,
toggleSecondarySub: '',
markAudioCard: '',
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openSessionHelp: 'CommandOrControl+Shift+H',
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: '',
toggleNotificationHistory: '',
toggleVisibleOverlayGlobal: '',
});
testGlobals.setStatsToggleKey('');
testGlobals.setMarkWatchedKey('');
await handlers.refreshConfiguredShortcuts();
assert.equal(ctx.state.sessionActionTimeoutMs, 3333);
assert.equal(ctx.state.statsToggleKey, '');
assert.equal(ctx.state.markWatchedKey, '');
} 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: configured controller select binding opens locally without dispatching a session action', async () => {
const { testGlobals, handlers, openControllerSelectCount } = createKeyboardHandlerHarness();
@@ -1393,36 +1317,6 @@ test('keyboard mode: configured controller select binding opens locally without
}
});
test('keyboard mode: configured controller debug binding opens locally without dispatching a session action', async () => {
const { testGlobals, handlers, openControllerDebugCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
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: configured controller debug binding is not swallowed while popup is visible', async () => {
const { ctx, testGlobals, handlers, openControllerDebugCount } = createKeyboardHandlerHarness();
@@ -1455,34 +1349,6 @@ test('keyboard mode: configured controller debug binding is not swallowed while
}
});
test('keyboard mode: former fixed Alt+Shift+C does nothing when controller debug is remapped', async () => {
const { testGlobals, handlers } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
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: 'C',
code: 'KeyC',
altKey: true,
shiftKey: true,
});
assert.deepEqual(testGlobals.sessionActions, []);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => {
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } =
createKeyboardHandlerHarness();
@@ -1530,49 +1396,6 @@ test('media timing review modal handles keys before later modal handlers', async
}
});
test('keyboard mode: playlist browser modal handles arrow keys before yomitan popup', async () => {
const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } =
createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.playlistBrowserModalOpen = true;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
assert.equal(playlistBrowserKeydownCount(), 1);
assert.equal(
testGlobals.commandEvents.some(
(event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
),
false,
);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: playlist browser modal handles h before lookup controls', async () => {
const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } =
createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.playlistBrowserModalOpen = true;
ctx.state.keyboardSelectedWordIndex = 2;
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
assert.equal(playlistBrowserKeydownCount(), 1);
assert.equal(ctx.state.keyboardSelectedWordIndex, 2);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: changelog modal handles h/l fold keys before lookup controls', async () => {
const { ctx, testGlobals, handlers, changelogKeydownCount } = createKeyboardHandlerHarness();
@@ -1594,29 +1417,6 @@ test('keyboard mode: changelog modal handles h/l fold keys before lookup control
}
});
test('keyboard mode: changelog modal handles arrow keys before yomitan popup', async () => {
const { ctx, testGlobals, handlers, changelogKeydownCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.changelogModalOpen = true;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
assert.equal(changelogKeydownCount(), 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();
@@ -1634,15 +1434,18 @@ test('keyboard mode: configured stats toggle works even while popup is open', as
});
test('refreshConfiguredShortcuts updates refreshed stats and mark-watched keys', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
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' });
@@ -1714,76 +1517,6 @@ test('session binding: Ctrl+Alt+S dispatches subsync action locally', async () =
}
});
test('session binding: Ctrl+Shift+G dispatches subtitle generation with the sidebar closed', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.openSubtitleGeneration',
originalKey: 'Ctrl+Shift+G',
key: { code: 'KeyG', modifiers: ['ctrl', 'shift'] },
actionType: 'session-action',
actionId: 'openSubtitleGeneration',
},
]);
testGlobals.dispatchKeydown({ key: 'G', code: 'KeyG', ctrlKey: true, shiftKey: true });
assert.deepEqual(testGlobals.sessionActions, [
{ actionId: 'openSubtitleGeneration', payload: undefined },
]);
} finally {
testGlobals.restore();
}
});
test('session binding: Ctrl+Shift+J dispatches jimaku action locally', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.openJimaku',
originalKey: 'Ctrl+Shift+J',
key: { code: 'KeyJ', modifiers: ['ctrl', 'shift'] },
actionType: 'session-action',
actionId: 'openJimaku',
},
] as never);
testGlobals.dispatchKeydown({ key: 'J', code: 'KeyJ', ctrlKey: true, shiftKey: true });
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'openJimaku', payload: undefined }]);
} finally {
testGlobals.restore();
}
});
test('session binding: Ctrl+Shift+O dispatches runtime options locally', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.openRuntimeOptions',
originalKey: 'CommandOrControl+Shift+O',
key: { code: 'KeyO', modifiers: ['ctrl', 'shift'] },
actionType: 'session-action',
actionId: 'openRuntimeOptions',
},
] as never);
testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', ctrlKey: true, shiftKey: true });
assert.deepEqual(testGlobals.sessionActions, [
{ actionId: 'openRuntimeOptions', payload: undefined },
]);
} finally {
testGlobals.restore();
}
});
test('session binding: remapped mark watched dispatches locally with modifiers', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -2327,56 +2060,6 @@ test('keyboard mode: edge jump while paused re-applies paused state after subtit
}
});
test('keyboard mode: left 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 = 0;
handlers.syncKeyboardTokenSelection();
testGlobals.setPlaybackPausedResponse(true);
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
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: h 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 = 0;
handlers.syncKeyboardTokenSelection();
testGlobals.setPlaybackPausedResponse(true);
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
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();
File diff suppressed because it is too large. Load diff
-35
View File
@@ -1,35 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { prepareForKikuFieldGroupingOpen } from './kiku-open';
test('prepareForKikuFieldGroupingOpen closes lookup popup before pausing playback', () => {
const calls: string[] = [];
prepareForKikuFieldGroupingOpen({
closeLookupWindow: () => {
calls.push('close');
return true;
},
pausePlayback: () => {
calls.push('pause');
},
});
assert.deepEqual(calls, ['close', 'pause']);
});
test('prepareForKikuFieldGroupingOpen still pauses playback when no popup is open', () => {
const calls: string[] = [];
prepareForKikuFieldGroupingOpen({
closeLookupWindow: () => {
calls.push('close');
return false;
},
pausePlayback: () => {
calls.push('pause');
},
});
assert.deepEqual(calls, ['close', 'pause']);
});
File diff suppressed because it is too large. Load diff
+196 -179
View File
@@ -1,109 +1,105 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ResolvedControllerConfig } from '../../types';
import { createControllerConfigForm } from './controller-config-form.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
type ActionId = keyof ResolvedControllerConfig['bindings'];
const BINDINGS: ResolvedControllerConfig['bindings'] = {
toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
mineCard: { kind: 'button', buttonIndex: 2 },
quitMpv: { kind: 'button', buttonIndex: 6 },
previousAudio: { kind: 'none' },
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
};
/** Minimal DOM element: enough for the form's createElement/appendChild/click usage. */
class FakeElement {
className = '';
textContent = '';
title = '';
type = '';
children: FakeElement[] = [];
private readonly listeners = new Map<string, Array<(event: Event) => void>>();
private readonly attributes = new Map<string, string>();
readonly classList = {
add: (...tokens: string[]) => {
this.className = [...new Set([...this.classes(), ...tokens])].join(' ');
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
toggle: (entry: string, force?: boolean) => {
if (force === undefined) {
if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
return tokens.has(entry);
}
if (force) tokens.add(entry);
else tokens.delete(entry);
return force;
},
contains: (entry: string) => tokens.has(entry),
contains: (token: string) => this.classes().includes(token),
};
set innerHTML(value: string) {
if (value === '') this.children = [];
}
appendChild(child: FakeElement): FakeElement {
this.children.push(child);
return child;
}
addEventListener(type: string, listener: (event: Event) => void): void {
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
}
click(): void {
const event = { stopPropagation: () => {}, preventDefault: () => {} } as unknown as Event;
for (const listener of this.listeners.get('click') ?? []) listener(event);
}
setAttribute(name: string, value: string): void {
this.attributes.set(name, value);
}
getAttribute(name: string): string | null {
return this.attributes.get(name) ?? null;
}
private classes(): string[] {
return this.className.split(' ').filter(Boolean);
}
}
function createFakeElement() {
const attributes = new Map<string, string>();
const el = {
className: '',
textContent: '',
_innerHTML: '',
value: '',
disabled: false,
selected: false,
type: '',
children: [] as any[],
listeners: new Map<string, Array<(e?: any) => void>>(),
classList: createClassList(),
appendChild(child: any) {
this.children.push(child);
return child;
},
addEventListener(type: string, listener: (e?: any) => void) {
const existing = this.listeners.get(type) ?? [];
existing.push(listener);
this.listeners.set(type, existing);
},
dispatch(type: string) {
const fakeEvent = { stopPropagation: () => {}, preventDefault: () => {} };
for (const listener of this.listeners.get(type) ?? []) {
listener(fakeEvent);
}
},
setAttribute(name: string, value: string) {
attributes.set(name, value);
},
getAttribute(name: string) {
return attributes.get(name) ?? null;
},
};
Object.defineProperty(el, 'innerHTML', {
get() {
return el._innerHTML;
},
set(v: string) {
el._innerHTML = v;
if (v === '') el.children.length = 0;
},
});
return el;
function findAll(root: FakeElement, className: string): FakeElement[] {
return root.children.flatMap((child) => [
...(child.classList.contains(className) ? [child] : []),
...findAll(child, className),
]);
}
test('controller config form renders rows and dispatches learn clear reset callbacks', () => {
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
function findOne(root: FakeElement, className: string, text?: string): FakeElement {
const match = findAll(root, className).find(
(el) => text === undefined || el.textContent === text,
);
assert.ok(match, `missing .${className}${text === undefined ? '' : ` "${text}"`}`);
return match;
}
/** Renders the form into a fake container with `document` stubbed for the duration. */
function renderForm(learningActionId: ActionId | null) {
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createFakeElement(),
},
value: { createElement: () => new FakeElement() },
});
try {
const calls: string[] = [];
const container = createFakeElement();
const form = createControllerConfigForm({
container: container as never,
getBindings: () =>
({
toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
mineCard: { kind: 'button', buttonIndex: 2 },
quitMpv: { kind: 'button', buttonIndex: 6 },
previousAudio: { kind: 'none' },
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
}) as never,
getLearningActionId: () => 'toggleLookup',
const container = new FakeElement();
createControllerConfigForm({
container: container as unknown as HTMLElement,
getBindings: () => BINDINGS,
getLearningActionId: () => learningActionId,
getDpadLearningActionId: () => null,
onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
onClear: (actionId) => calls.push(`clear:${actionId}`),
@@ -111,102 +107,123 @@ test('controller config form renders rows and dispatches learn clear reset callb
onDpadLearn: (actionId) => calls.push(`dpadLearn:${actionId}`),
onDpadClear: (actionId) => calls.push(`dpadClear:${actionId}`),
onDpadReset: (actionId) => calls.push(`dpadReset:${actionId}`),
});
}).render();
form.render();
// In the new compact list layout, children are:
// [0] group header, [1] first binding row (auto-expanded because learning), [2] edit panel, [3] next row, ...
const firstRow = container.children[1];
assert.equal(firstRow.classList.contains('expanded'), true);
// After expanding, the edit panel is inserted after the row:
// [0] group header, [1] row, [2] edit panel, [3] next row, ...
const editPanel = container.children[2];
// editPanel > inner > actions > learnButton
const inner = editPanel.children[0];
const actions = inner.children[1];
const learnButton = actions.children[0];
learnButton.dispatch('click');
actions.children[1].dispatch('click');
actions.children[2].dispatch('click');
assert.deepEqual(calls, [
'learn:toggleLookup:discrete',
'clear:toggleLookup',
'reset:toggleLookup',
]);
const row = (label: string) => {
const match = findAll(container, 'controller-config-row').find(
(el) => findOne(el, 'controller-config-label').textContent === label,
);
assert.ok(match, `missing row "${label}"`);
return match;
};
return { calls, container, row };
} finally {
if (previousDocumentDescriptor) {
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
if (previousDocument) {
Object.defineProperty(globalThis, 'document', previousDocument);
} else {
Reflect.deleteProperty(globalThis, 'document');
}
}
}
test('controller config form renders grouped rows with friendly binding labels', () => {
const { container } = renderForm(null);
assert.deepEqual(
findAll(container, 'controller-config-group').map((el) => el.textContent),
['Lookup', 'Playback', 'Popup Audio', 'Navigation'],
);
assert.deepEqual(
findAll(container, 'controller-config-row').map((row) => [
findOne(row, 'controller-config-label').textContent,
findOne(row, 'controller-config-badge').textContent,
]),
[
['Toggle Lookup', 'A / Cross'],
['Close Lookup', 'B / Circle'],
['Mine Card', 'X / Square'],
['Toggle Keyboard-Only Mode', 'Y / Triangle'],
['Toggle MPV Pause', 'R3 / RS'],
['Quit MPV', 'Back / Select'],
['Previous Audio', 'None'],
['Next Audio', 'RB / R1'],
['Play Current Audio', 'LB / L1'],
['Token Move (Stick)', 'Left Stick X'],
['Token Move (D-pad)', 'D-pad ↔'],
['Popup Scroll (Stick)', 'Left Stick Y'],
['Popup Scroll (D-pad)', 'D-pad ↕'],
['Alt Horizontal (Stick)', 'Right Stick X'],
['Alt Horizontal (D-pad)', 'None'],
['Popup Jump (Stick)', 'Right Stick Y'],
['Popup Jump (D-pad)', 'None'],
],
);
assert.deepEqual(
findAll(container, 'disabled').map((badge) => badge.textContent),
['None', 'None', 'None'],
);
assert.deepEqual(findAll(container, 'expanded'), []);
assert.deepEqual(findAll(container, 'controller-config-edit-panel'), []);
});
test('controller config form starts learn from badge or edit and resets from row button', () => {
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createFakeElement(),
},
test('controller config form expands the learning row with a listening edit panel', () => {
const { container, row } = renderForm('toggleLookup');
assert.deepEqual(
findAll(container, 'expanded').map((el) => findOne(el, 'controller-config-label').textContent),
['Toggle Lookup'],
);
// The edit panel is inserted directly after the expanded row.
const rowIndex = container.children.indexOf(row('Toggle Lookup'));
const panel = container.children[rowIndex + 1];
assert.ok(panel);
assert.equal(panel.classList.contains('controller-config-edit-panel'), true);
const hint = findOne(panel, 'controller-config-edit-hint');
assert.equal(hint.textContent, 'Press a button, trigger, or move a stick…');
assert.equal(hint.classList.contains('learning'), true);
assert.equal(findOne(panel, 'btn-learn').textContent, 'Listening…');
});
type Locate = (row: FakeElement, panel: FakeElement) => FakeElement;
for (const c of [
{
control: 'row badge',
locate: (row) => findOne(row, 'controller-config-badge'),
expected: 'learn:toggleLookup:discrete',
},
{
control: 'row reset icon',
locate: (row) => findOne(row, 'controller-config-reset-icon'),
expected: 'reset:toggleLookup',
},
{
control: 'row edit icon',
locate: (row) => findOne(row, 'controller-config-edit-icon'),
expected: 'learn:toggleLookup:discrete',
},
{
control: 'panel Learn',
locate: (_row, panel) => findOne(panel, 'btn-learn'),
expected: 'learn:toggleLookup:discrete',
},
{
control: 'panel Clear',
locate: (_row, panel) => findOne(panel, 'btn-secondary', 'Clear'),
expected: 'clear:toggleLookup',
},
{
control: 'panel Reset',
locate: (_row, panel) => findOne(panel, 'btn-secondary', 'Reset'),
expected: 'reset:toggleLookup',
},
] satisfies Array<{ control: string; locate: Locate; expected: string }>) {
test(`controller config form ${c.control} dispatches ${c.expected}`, () => {
const { calls, container, row } = renderForm('toggleLookup');
c.locate(row('Toggle Lookup'), findOne(container, 'controller-config-edit-panel')).click();
assert.deepEqual(calls, [c.expected]);
});
try {
const calls: string[] = [];
const container = createFakeElement();
const form = createControllerConfigForm({
container: container as never,
getBindings: () =>
({
toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
mineCard: { kind: 'button', buttonIndex: 2 },
quitMpv: { kind: 'button', buttonIndex: 6 },
previousAudio: { kind: 'none' },
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
}) as never,
getLearningActionId: () => null,
getDpadLearningActionId: () => null,
onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
onClear: (actionId) => calls.push(`clear:${actionId}`),
onReset: (actionId) => calls.push(`reset:${actionId}`),
onDpadLearn: (actionId) => calls.push(`dpadLearn:${actionId}`),
onDpadClear: (actionId) => calls.push(`dpadClear:${actionId}`),
onDpadReset: (actionId) => calls.push(`dpadReset:${actionId}`),
});
form.render();
const firstRow = container.children[1];
const right = firstRow.children[1];
const badge = right.children[0];
const resetButton = right.children[1];
const editButton = right.children[2];
badge.dispatch('click');
resetButton.dispatch('click');
editButton.dispatch('click');
assert.deepEqual(calls, [
'learn:toggleLookup:discrete',
'reset:toggleLookup',
'learn:toggleLookup:discrete',
]);
} finally {
if (previousDocumentDescriptor) {
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'document');
}
}
});
}
@@ -301,31 +301,6 @@ function setupPlaylistBrowserModalTest(options?: {
};
}
test('playlist browser test cleanup must delete injected globals that were originally absent', () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
try {
Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'document');
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
const env = setupPlaylistBrowserModalTest();
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), true);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), true);
env.restore();
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
assert.equal(typeof globalThis.window, 'undefined');
assert.equal(typeof globalThis.document, 'undefined');
} finally {
restoreGlobalDescriptor('window', previousWindowDescriptor);
restoreGlobalDescriptor('document', previousDocumentDescriptor);
}
});
test('playlist browser modal opens with playlist-focused current item selection', async () => {
const notifications: string[] = [];
const env = setupPlaylistBrowserModalTest({
@@ -354,37 +329,6 @@ test('playlist browser modal opens with playlist-focused current item selection'
}
});
test('playlist browser modal action buttons stop double-click propagation', async () => {
const env = setupPlaylistBrowserModalTest();
try {
const modal = env.createModal();
await modal.openPlaylistBrowserModal();
const row = env.dom.playlistBrowserDirectoryList.children[0] as
| ReturnType<typeof createPlaylistRow>
| undefined;
const trailing = row?.children?.[1] as ReturnType<typeof createPlaylistRow> | undefined;
const button = trailing?.children?.at(-1) as
| { listeners?: Map<string, Array<(event?: unknown) => void>> }
| undefined;
const dblclickHandler = button?.listeners?.get('dblclick')?.[0];
assert.equal(typeof dblclickHandler, 'function');
let stopped = false;
dblclickHandler?.({
stopPropagation: () => {
stopped = true;
},
});
assert.equal(stopped, true);
} finally {
env.restore();
}
});
test('playlist browser preserves prior selection across mutation snapshots', async () => {
const env = setupPlaylistBrowserModalTest({
electronApi: {
+11 -1
View File
@@ -3,7 +3,7 @@ import test from 'node:test';
import type { ElectronAPI, RuntimeOptionState } from '../../types';
import { createRendererState } from '../state.js';
import { createRuntimeOptionsModal } from './runtime-options.js';
import { createRuntimeOptionsModal, scrollActiveRuntimeOptionIntoView } from './runtime-options.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
@@ -219,3 +219,13 @@ test('openRuntimeOptionsModal keeps modal visible when loading fails', async ()
},
);
});
test('scrollActiveRuntimeOptionIntoView no-ops without active option', () => {
const list = {
querySelector: () => null,
};
assert.doesNotThrow(() => {
scrollActiveRuntimeOptionIntoView(list);
});
});
-12
View File
@@ -1,6 +1,4 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { createRendererState } from '../state.js';
@@ -35,16 +33,6 @@ test('session help normalizes configured modifier aliases', () => {
assert.equal(formatSessionHelpKeybinding('CommandOrControl+KeyS'), 'Cmd/Ctrl + S');
});
test('session help imports browser-safe special command constants', () => {
const source = fs.readFileSync(
path.join(process.cwd(), 'src', 'renderer', 'modals', 'session-help-sections.ts'),
'utf8',
);
assert.match(source, /from ['"]\.\.\/\.\.\/config\/definitions\/shared['"]/);
assert.doesNotMatch(source, /from ['"]\.\.\/\.\.\/config\/definitions['"]/);
});
test('session help builds rows from canonical session bindings and fixed overlay affordances', () => {
const sections = buildSessionHelpSections({
sessionBindings: [
File diff suppressed because it is too large. Load diff
+2 -24
View File
@@ -1,6 +1,4 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import type { TsukihimeSubtitleFile, ElectronAPI } from '../../types';
@@ -249,26 +247,18 @@ function createModalHarness(
};
}
test('TsukiHime language tabs expose tab semantics in the renderer markup', () => {
const html = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer', 'index.html'), 'utf8');
const tabs = html.match(/<div class="tsukihime-tabs"[\s\S]*?<\/div>/)?.[0];
assert.ok(tabs);
assert.match(tabs, /<div class="tsukihime-tabs" role="tablist">/);
assert.match(tabs, /id="tsukihimeTabSecondary"[\s\S]*?role="tab"[\s\S]*?aria-selected="true"/);
assert.match(tabs, /id="tsukihimeTabPrimary"[\s\S]*?role="tab"[\s\S]*?aria-selected="false"/);
});
test('switching TsukiHime language tabs synchronizes aria-selected', () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
pressKey(harness, 'ArrowRight');
assert.equal(harness.state.tsukihimeActiveTab, 'primary');
assert.equal(harness.secondaryTab.attributes.get('aria-selected'), 'false');
assert.equal(harness.primaryTab.attributes.get('aria-selected'), 'true');
pressKey(harness, 'ArrowLeft');
assert.equal(harness.state.tsukihimeActiveTab, 'secondary');
assert.equal(harness.secondaryTab.attributes.get('aria-selected'), 'true');
assert.equal(harness.primaryTab.attributes.get('aria-selected'), 'false');
} finally {
@@ -532,18 +522,6 @@ test('a slow release response does not overwrite the newly selected release', as
}
});
test('ArrowLeft switches back to the secondary-language tab', () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
pressKey(harness, 'ArrowRight');
assert.equal(harness.state.tsukihimeActiveTab, 'primary');
pressKey(harness, 'ArrowLeft');
assert.equal(harness.state.tsukihimeActiveTab, 'secondary');
} finally {
harness.restoreGlobals();
}
});
test('searching reports TsukiHime as the backend and lists sublangs per release', async () => {
const harness = createModalHarness([], {
searchEntries: async () => ({
@@ -92,17 +92,6 @@ function restoreGlobalProp<K extends keyof typeof globalThis>(
Reflect.deleteProperty(globalThis, key);
}
function restoreGlobalDescriptor<K extends keyof typeof globalThis>(
key: K,
descriptor: PropertyDescriptor | undefined,
) {
if (descriptor) {
Object.defineProperty(globalThis, key, descriptor);
return;
}
Reflect.deleteProperty(globalThis, key);
}
function setupYoutubePickerTestEnv(options?: {
windowValue?: YoutubePickerTestWindow;
customEventValue?: unknown;
@@ -163,41 +152,11 @@ function setupYoutubePickerTestEnv(options?: {
};
}
test('youtube picker test env restore deletes injected globals that were originally absent', () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
try {
Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'document');
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
const env = setupYoutubePickerTestEnv();
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), true);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), true);
env.restore();
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
assert.equal(typeof globalThis.window, 'undefined');
assert.equal(typeof globalThis.document, 'undefined');
} finally {
restoreGlobalDescriptor('window', previousWindowDescriptor);
restoreGlobalDescriptor('document', previousDocumentDescriptor);
}
});
test('youtube track picker close restores focus and mouse-ignore state', () => {
const overlayFocusCalls: number[] = [];
const windowFocusCalls: number[] = [];
const focusMainWindowCalls: number[] = [];
let overlayFocused = false;
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const notifications: string[] = [];
const frontendCommands: unknown[] = [];
const syncCalls: string[] = [];
class TestCustomEvent extends Event {
detail: unknown;
@@ -214,17 +173,13 @@ test('youtube track picker close restores focus and mouse-ignore state', () => {
frontendCommands.push(event.detail ?? null);
return true;
},
focus: () => {
windowFocusCalls.push(1);
},
focus: () => {},
electronAPI: {
notifyOverlayModalOpened: () => {},
notifyOverlayModalClosed: (modal: string) => {
notifications.push(modal);
},
focusMainWindow: async () => {
focusMainWindowCalls.push(1);
},
focusMainWindow: async () => {},
youtubePickerResolve: async () => ({ ok: true, message: '' }),
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
@@ -238,7 +193,7 @@ test('youtube track picker close restores focus and mouse-ignore state', () => {
const state = createRendererState();
const dom = createYoutubePickerDomFixture();
dom.overlay.focus = () => {
overlayFocusCalls.push(1);
overlayFocused = true;
};
const { overlay } = dom;
@@ -252,12 +207,8 @@ test('youtube track picker close restores focus and mouse-ignore state', () => {
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
restorePointerInteractionState: () => {
syncCalls.push('restore-pointer');
},
syncSettingsModalSubtitleSuppression: () => {
syncCalls.push('sync');
},
restorePointerInteractionState: () => {},
syncSettingsModalSubtitleSuppression: () => {},
},
);
@@ -272,13 +223,10 @@ test('youtube track picker close restores focus and mouse-ignore state', () => {
modal.closeYoutubePickerModal();
assert.equal(state.youtubePickerModalOpen, false);
assert.deepEqual(syncCalls, ['sync', 'sync', 'restore-pointer']);
assert.deepEqual(notifications, ['youtube-track-picker']);
assert.deepEqual(frontendCommands, [{ type: 'refreshOptions' }]);
assert.equal(overlay.classList.contains('interactive'), false);
assert.equal(focusMainWindowCalls.length > 0, true);
assert.equal(overlayFocusCalls.length > 0, true);
assert.equal(windowFocusCalls.length > 0, true);
assert.equal(overlayFocused, true);
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
} finally {
env.restore();
+172 -221
View File
@@ -1,18 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { OverlayContentMeasurement, OverlayContentRect } from '../types';
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
function makeElement(textContent: string, rect: DOMRect): HTMLElement {
type FakeElement = {
textContent: string;
children: unknown[];
getBoundingClientRect: () => DOMRect;
};
type DomKey =
| 'subtitleRoot'
| 'subtitleContainer'
| 'secondarySubRoot'
| 'secondarySubContainer'
| 'subtitleSidebarContent'
| 'overlayNotificationStack'
| 'overlayNotificationHistory';
const ZERO: OverlayContentRect = { x: 0, y: 0, width: 0, height: 0 };
function el(
rect: OverlayContentRect = ZERO,
{ text = '', children = 0 }: { text?: string; children?: number } = {},
): FakeElement {
const domRect = { left: rect.x, top: rect.y, width: rect.width, height: rect.height } as DOMRect;
return {
textContent,
getBoundingClientRect: () => rect,
} as unknown as HTMLElement;
textContent: text,
children: Array.from({ length: children }, () => ({})),
getBoundingClientRect: () => domRect,
};
}
test('overlay measurement reports primary and secondary subtitle bars as separate interactive rects', () => {
// Runs emitNow() once against a fake `window` and zero-rect empty DOM defaults.
// Returns the reported measurement, or undefined when nothing was reported.
function measure(
dom: Partial<Record<DomKey, FakeElement>> = {},
state: { subtitleSidebarModalOpen?: boolean; notificationHistoryOpen?: boolean } = {},
overlayLayer = 'visible',
): Omit<OverlayContentMeasurement, 'measuredAtMs'> | undefined {
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const reports: unknown[] = [];
const reports: OverlayContentMeasurement[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
@@ -20,63 +48,26 @@ test('overlay measurement reports primary and secondary subtitle bars as separat
innerWidth: 1920,
innerHeight: 1080,
electronAPI: {
reportOverlayContentBounds: (payload: unknown) => {
reports.push(payload);
},
reportOverlayContentBounds: (payload: OverlayContentMeasurement) => reports.push(payload),
},
},
});
try {
const reporter = createOverlayContentMeasurementReporter({
platform: { overlayLayer: 'visible' },
dom: {
subtitleRoot: makeElement('primary', {
left: 810,
top: 910,
width: 300,
height: 48,
} as DOMRect),
subtitleContainer: makeElement('primary', {
left: 760,
top: 890,
width: 400,
height: 92,
} as DOMRect),
secondarySubRoot: makeElement('English', {
left: 850,
top: 50,
width: 220,
height: 34,
} as DOMRect),
secondarySubContainer: makeElement('English', {
left: 700,
top: 40,
width: 520,
height: 70,
} as DOMRect),
},
} as never);
reporter.emitNow();
const measuredAtMs = (reports[0] as { measuredAtMs?: unknown } | undefined)?.measuredAtMs;
if (typeof measuredAtMs !== 'number') {
assert.fail('Expected report timestamp.');
}
assert.deepEqual(reports, [
{
layer: 'visible',
measuredAtMs,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 700, y: 40, width: 520, height: 942 },
interactiveRects: [
{ x: 760, y: 890, width: 400, height: 92 },
{ x: 700, y: 40, width: 520, height: 70 },
],
},
]);
const defaults: Record<DomKey, FakeElement> = {
subtitleRoot: el(),
subtitleContainer: el(),
secondarySubRoot: el(),
secondarySubContainer: el(),
subtitleSidebarContent: el(),
overlayNotificationStack: el(),
overlayNotificationHistory: el(),
};
createOverlayContentMeasurementReporter({
platform: { overlayLayer },
state,
dom: { ...defaults, ...dom },
} as never).emitNow();
} finally {
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
@@ -84,170 +75,130 @@ test('overlay measurement reports primary and secondary subtitle bars as separat
delete (globalThis as { window?: unknown }).window;
}
}
});
test('overlay measurement includes open subtitle sidebar bounds as an interactive rect', () => {
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const reports: unknown[] = [];
assert.ok(reports.length <= 1);
const report = reports[0];
if (!report) return undefined;
assert.equal(typeof report.measuredAtMs, 'number');
const { measuredAtMs: _measuredAtMs, ...rest } = report;
return rest;
}
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
innerWidth: 1920,
innerHeight: 1080,
electronAPI: {
reportOverlayContentBounds: (payload: unknown) => {
reports.push(payload);
},
},
function expected(contentRect: OverlayContentRect | null, interactiveRects: OverlayContentRect[]) {
return {
layer: 'visible' as const,
viewport: { width: 1920, height: 1080 },
contentRect,
interactiveRects,
};
}
const PRIMARY = { x: 760, y: 890, width: 400, height: 92 };
const SECONDARY = { x: 700, y: 40, width: 520, height: 70 };
const SIDEBAR = { x: 1500, y: 60, width: 380, height: 900 };
const NOTIFICATIONS = { x: 1540, y: 16, width: 360, height: 220 };
const HISTORY = { x: 1400, y: 100, width: 480, height: 600 };
const cases: Array<{
name: string;
dom?: Partial<Record<DomKey, FakeElement>>;
state?: { subtitleSidebarModalOpen?: boolean; notificationHistoryOpen?: boolean };
layer?: string;
report: ReturnType<typeof measure>;
}> = [
{
name: 'reports primary and secondary subtitle containers as separate rects and unions them',
dom: {
subtitleRoot: el({ x: 810, y: 910, width: 300, height: 48 }, { text: 'primary' }),
subtitleContainer: el(PRIMARY),
secondarySubRoot: el({ x: 850, y: 50, width: 220, height: 34 }, { text: 'English' }),
secondarySubContainer: el(SECONDARY),
},
});
try {
const reporter = createOverlayContentMeasurementReporter({
platform: { overlayLayer: 'visible' },
state: { subtitleSidebarModalOpen: true },
dom: {
subtitleRoot: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
subtitleContainer: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
secondarySubRoot: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
secondarySubContainer: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
subtitleSidebarContent: makeElement('sidebar', {
left: 1500,
top: 60,
width: 380,
height: 900,
} as DOMRect),
},
} as never);
reporter.emitNow();
const measuredAtMs = (reports[0] as { measuredAtMs?: unknown } | undefined)?.measuredAtMs;
if (typeof measuredAtMs !== 'number') {
assert.fail('Expected report timestamp.');
}
assert.deepEqual(reports, [
{
layer: 'visible',
measuredAtMs,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 1500, y: 60, width: 380, height: 900 },
interactiveRects: [{ x: 1500, y: 60, width: 380, height: 900 }],
},
]);
} finally {
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
test('overlay measurement includes overlay notification stack bounds as an interactive rect', () => {
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const reports: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
innerWidth: 1920,
innerHeight: 1080,
electronAPI: {
reportOverlayContentBounds: (payload: unknown) => {
reports.push(payload);
},
},
report: expected({ x: 700, y: 40, width: 520, height: 942 }, [PRIMARY, SECONDARY]),
},
{
name: 'excludes a subtitle container whose root has only whitespace text',
dom: {
subtitleRoot: el(ZERO, { text: ' \n ' }),
subtitleContainer: el(PRIMARY),
secondarySubRoot: el(ZERO, { text: 'English' }),
secondarySubContainer: el(SECONDARY),
},
report: expected(SECONDARY, [SECONDARY]),
},
{
name: 'drops zero-area rects',
dom: {
subtitleRoot: el(ZERO, { text: 'primary' }),
subtitleContainer: el({ x: 760, y: 890, width: 400, height: 0 }),
},
report: expected(null, []),
},
{
name: 'drops non-finite rects',
dom: {
subtitleRoot: el(ZERO, { text: 'primary' }),
subtitleContainer: el({ x: Number.NaN, y: 890, width: 400, height: 92 }),
secondarySubRoot: el(ZERO, { text: 'English' }),
secondarySubContainer: el({ x: 700, y: 40, width: Number.POSITIVE_INFINITY, height: 70 }),
},
report: expected(null, []),
},
{
name: 'rounds rect coordinates to two decimals',
dom: {
subtitleRoot: el(ZERO, { text: 'primary' }),
subtitleContainer: el({ x: 760.123, y: 890.456, width: 400.789, height: 92.001 }),
},
report: expected({ x: 760.12, y: 890.46, width: 400.79, height: 92 }, [
{ x: 760.12, y: 890.46, width: 400.79, height: 92 },
]),
},
{
name: 'includes the open subtitle sidebar',
dom: { subtitleSidebarContent: el(SIDEBAR) },
state: { subtitleSidebarModalOpen: true },
report: expected(SIDEBAR, [SIDEBAR]),
},
{
name: 'ignores the sidebar when it is closed',
dom: { subtitleSidebarContent: el(SIDEBAR) },
state: { subtitleSidebarModalOpen: false },
report: expected(null, []),
},
{
name: 'includes the notification stack when it has cards',
dom: { overlayNotificationStack: el(NOTIFICATIONS, { children: 2 }) },
report: expected(NOTIFICATIONS, [NOTIFICATIONS]),
},
{
name: 'ignores an empty notification stack',
dom: { overlayNotificationStack: el(NOTIFICATIONS) },
report: expected(null, []),
},
{
name: 'includes the open notification history panel',
dom: { overlayNotificationHistory: el(HISTORY) },
state: { notificationHistoryOpen: true },
report: expected(HISTORY, [HISTORY]),
},
{
name: 'reports a null content rect when nothing is measurable',
report: expected(null, []),
},
{
name: 'emits nothing for a non-visible overlay layer',
dom: {
subtitleRoot: el(ZERO, { text: 'primary' }),
subtitleContainer: el(PRIMARY),
},
layer: 'modal',
report: undefined,
},
];
for (const c of cases) {
test(`overlay measurement ${c.name}`, () => {
assert.deepEqual(measure(c.dom, c.state, c.layer), c.report);
});
try {
const reporter = createOverlayContentMeasurementReporter({
platform: { overlayLayer: 'visible' },
state: { subtitleSidebarModalOpen: false },
dom: {
subtitleRoot: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
subtitleContainer: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
secondarySubRoot: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
secondarySubContainer: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
overlayNotificationStack: {
children: [{}, {}],
getBoundingClientRect: () =>
({
left: 1540,
top: 16,
width: 360,
height: 220,
}) as DOMRect,
},
},
} as never);
reporter.emitNow();
const measuredAtMs = (reports[0] as { measuredAtMs?: unknown } | undefined)?.measuredAtMs;
if (typeof measuredAtMs !== 'number') {
assert.fail('Expected report timestamp.');
}
assert.deepEqual(reports, [
{
layer: 'visible',
measuredAtMs,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 1540, y: 16, width: 360, height: 220 },
interactiveRects: [{ x: 1540, y: 16, width: 360, height: 220 }],
},
]);
} finally {
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
}
@@ -1,60 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
function readWorkspaceFile(relativePath: string): string {
return fs.readFileSync(path.join(process.cwd(), relativePath), 'utf8');
}
test('keyboard chord map no longer emits legacy invisible overlay script messages', () => {
const keyboardSource = readWorkspaceFile('src/renderer/handlers/keyboard.ts');
assert.doesNotMatch(keyboardSource, /subminer-toggle-invisible/);
assert.doesNotMatch(keyboardSource, /subminer-show-invisible/);
assert.doesNotMatch(keyboardSource, /subminer-hide-invisible/);
});
test('overlay layer contracts no longer advertise invisible renderer layer', () => {
const typesSource = readWorkspaceFile('src/types.ts');
assert.doesNotMatch(typesSource, /export type OverlayLayer = 'visible' \| 'invisible'/);
assert.doesNotMatch(
typesSource,
/getOverlayLayer:\s*\(\)\s*=>\s*'visible'\s*\|\s*'invisible'\s*\|\s*'modal'\s*\|\s*null/,
);
});
test('renderer stylesheet no longer contains invisible-layer selectors', () => {
const cssSource = readWorkspaceFile('src/renderer/style.css');
assert.doesNotMatch(cssSource, /body\.layer-invisible/);
});
test('renderer stylesheet only hides visible focus chrome on top-level overlay focus targets', () => {
const cssSource = readWorkspaceFile('src/renderer/style.css');
assert.match(
cssSource,
/html:focus-visible,\s*body:focus-visible,\s*#overlay:focus-visible\s*\{[^}]*outline:\s*none;/s,
);
assert.doesNotMatch(
cssSource,
/html:focus,\s*body:focus,\s*#overlay:focus\s*\{[^}]*outline:\s*none;/s,
);
});
test('subtitle sidebar stylesheet keeps quoted font fallbacks and generic family', () => {
const cssSource = readWorkspaceFile('src/renderer/style.css');
const sidebarContentBlock = cssSource.match(
/\.subtitle-sidebar-content\s*\{(?<body>[\s\S]*?)\s*\}/,
)?.groups?.body;
assert.ok(sidebarContentBlock);
assert.match(sidebarContentBlock, /'Hiragino Sans'/);
assert.match(sidebarContentBlock, /'M PLUS 1'/);
assert.match(sidebarContentBlock, /'Source Han Sans JP'/);
assert.match(sidebarContentBlock, /'Noto Sans CJK JP'/);
assert.match(sidebarContentBlock, /sans-serif/);
});
test('top-level readme avoids stale overlay-layers wording', () => {
const readmeSource = readWorkspaceFile('README.md');
assert.doesNotMatch(readmeSource, /overlay layers/i);
});
+119 -336
View File
@@ -1,27 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
import { createRendererState, type RendererState } from './state.js';
import { YOMITAN_POPUP_VISIBLE_HOST_SELECTOR } from './yomitan-popup.js';
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),
};
type IgnoreCall = { ignore: boolean; forward?: boolean };
type RunOptions = {
// `toggle` matches Windows/macOS (renderer toggles ignore), `linux` matches the cursor-poll path.
platform: 'toggle' | 'linux';
state?: Partial<RendererState>;
// Popup host marked visible in the DOM, independent of the cached `yomitanPopupVisible` flag.
popupHostVisible?: boolean;
sidebarSelecting?: boolean;
};
function makeState(overrides: Partial<RendererState> = {}): RendererState {
return { ...createRendererState(), ...overrides };
}
function replaceGlobalProperty(key: 'window' | 'document', value: unknown): () => void {
const original = Object.getOwnPropertyDescriptor(globalThis, key);
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
return () => {
if (original) {
Object.defineProperty(globalThis, key, original);
@@ -31,342 +31,125 @@ function replaceGlobalProperty(key: 'window' | 'document', value: unknown): () =
};
}
test('idle visible overlay starts click-through on platforms that toggle mouse ignore', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
shouldToggleMouseIgnore: true,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: false,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), false);
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
} finally {
restoreWindow();
}
});
test('youtube picker keeps overlay interactive even when subtitle hover is inactive', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
shouldToggleMouseIgnore: true,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: true,
kikuModalOpen: false,
runtimeOptionsModalOpen: false,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
restoreWindow();
}
});
test('visible yomitan popup host keeps overlay interactive even when cached popup state is false', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
getComputedStyle: () => ({
visibility: 'visible',
display: 'block',
opacity: '1',
}),
});
const restoreDocument = replaceGlobalProperty('document', {
querySelectorAll: (selector: string) =>
selector ===
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
? [{ getAttribute: () => 'true' }]
: [],
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
shouldToggleMouseIgnore: true,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: false,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
restoreDocument();
restoreWindow();
}
});
test('visible yomitan popup host on macOS keeps overlay interactive so click-away reaches popup', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
getComputedStyle: () => ({
visibility: 'visible',
display: 'block',
opacity: '1',
}),
});
const restoreDocument = replaceGlobalProperty('document', {
querySelectorAll: (selector: string) =>
selector ===
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
? [{ getAttribute: () => 'true' }]
: [],
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
isMacOSPlatform: true,
shouldToggleMouseIgnore: true,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
isOverYomitanPopup: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: false,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
restoreDocument();
restoreWindow();
}
});
test('macOS pointer over a visible yomitan popup keeps the overlay interactive', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
isMacOSPlatform: true,
shouldToggleMouseIgnore: true,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
isOverYomitanPopup: true,
yomitanPopupVisible: true,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: false,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
restoreWindow();
}
});
test('Linux subtitle hover keeps root passive and does not report whole-window interactive hint', () => {
const classList = createClassList();
function run({ platform, state, popupHostVisible = false, sidebarSelecting = false }: RunOptions) {
const classes = new Set<string>();
const ignoreCalls: IgnoreCall[] = [];
const interactiveHints: boolean[] = [];
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
reportOverlayInteractive: (interactive: boolean) => {
interactiveHints.push(interactive);
},
},
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
isLinuxPlatform: true,
shouldToggleMouseIgnore: false,
},
state: {
isOverSubtitle: true,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: false,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), false);
assert.deepEqual(interactiveHints, [false]);
} finally {
restoreWindow();
}
});
test('Linux modal state reports whole-window interactive hint', () => {
const classList = createClassList();
const interactiveHints: boolean[] = [];
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
reportOverlayInteractive: (interactive: boolean) => {
interactiveHints.push(interactive);
},
},
const restoreDocument = replaceGlobalProperty('document', {
querySelectorAll: (selector: string) =>
popupHostVisible && selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ? [{}] : [],
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
isLinuxPlatform: true,
shouldToggleMouseIgnore: false,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: true,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
overlay: {
classList: {
add: (token: string) => classes.add(token),
remove: (token: string) => classes.delete(token),
},
},
subtitleSidebarList: { dataset: sidebarSelecting ? { selecting: 'true' } : {} },
},
platform:
platform === 'linux'
? { isLinuxPlatform: true, shouldToggleMouseIgnore: false }
: { isLinuxPlatform: false, shouldToggleMouseIgnore: true },
state: makeState(state),
} as never);
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(interactiveHints, [true]);
} finally {
restoreDocument();
restoreWindow();
}
});
return { interactive: classes.has('interactive'), ignoreCalls, interactiveHints };
}
const CLICK_THROUGH: IgnoreCall[] = [{ ignore: true, forward: true }];
const CAPTURE: IgnoreCall[] = [{ ignore: false, forward: undefined }];
const toggleCases: Array<{
name: string;
options: Omit<RunOptions, 'platform'>;
capture: boolean;
}> = [
{ name: 'idle overlay starts click-through', options: {}, capture: false },
{
name: 'youtube picker keeps overlay interactive even when subtitle hover is inactive',
options: { state: { youtubePickerModalOpen: true } },
capture: true,
},
{
name: 'visible yomitan popup host keeps overlay interactive even when cached popup state is false',
options: { popupHostVisible: true, state: { yomitanPopupVisible: false } },
capture: true,
},
{
name: 'pointer over the yomitan popup keeps overlay interactive',
options: { state: { isOverYomitanPopup: true } },
capture: true,
},
{
name: 'pointer over an overlay notification keeps overlay interactive',
options: { state: { isOverOverlayNotification: true } },
capture: true,
},
{
name: 'pointer over notification history keeps overlay interactive',
options: { state: { isOverNotificationHistory: true } },
capture: true,
},
{
name: 'subtitle sidebar drag selection keeps overlay interactive',
options: { sidebarSelecting: true },
capture: true,
},
];
for (const c of toggleCases) {
test(c.name, () => {
const result = run({ platform: 'toggle', ...c.options });
assert.equal(result.interactive, c.capture);
assert.deepEqual(result.ignoreCalls, c.capture ? CAPTURE : CLICK_THROUGH);
assert.deepEqual(result.interactiveHints, []);
});
}
// Linux never toggles ignore itself; it only reports a whole-window hint for popups/modals.
const linuxCases: Array<{ name: string; state: Partial<RendererState>; interactive: boolean }> = [
{
name: 'Linux subtitle hover keeps root passive and does not report whole-window interactive hint',
state: { isOverSubtitle: true },
interactive: false,
},
{
name: 'Linux modal state reports whole-window interactive hint',
state: { runtimeOptionsModalOpen: true },
interactive: true,
},
{
name: 'Linux visible yomitan popup reports whole-window interactive hint',
state: { yomitanPopupVisible: true },
interactive: true,
},
];
for (const c of linuxCases) {
test(c.name, () => {
const result = run({ platform: 'linux', state: c.state });
assert.equal(result.interactive, c.interactive);
assert.deepEqual(result.interactiveHints, [c.interactive]);
assert.deepEqual(result.ignoreCalls, []);
});
}
+100 -295
View File
@@ -1,13 +1,10 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import {
createOverlayNotificationRenderer,
createOverlayNotificationStore,
handleOverlayNotificationEvent,
overlayNotificationPositionClass,
} from './overlay-notifications';
function createClassList(initialTokens: string[] = []) {
@@ -105,10 +102,67 @@ function findChildByClass(element: FakeElement, className: string): FakeElement
return null;
}
const overlayNotificationCss = readFileSync(
path.join(__dirname, '..', 'renderer', 'style.css'),
'utf8',
);
type FakeWindow = {
clearTimeout: (id: number) => void;
setTimeout: (callback: () => void, delayMs: number) => number;
electronAPI?: Record<string, unknown>;
};
// Installs a fake `document`/`window`, builds a renderer over a fake stack, runs `fn`, then
// restores the globals.
function withRenderer(
windowOverrides: Partial<FakeWindow>,
fn: (env: {
renderer: ReturnType<typeof createOverlayNotificationRenderer>;
stack: FakeElement;
}) => void,
): void {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const fakeWindow: FakeWindow = {
clearTimeout: () => undefined,
setTimeout: () => 1,
...windowOverrides,
};
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: { createElement: (tagName: string) => createFakeElement(tagName) },
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: fakeWindow,
});
try {
const stack = createFakeElement();
const renderer = createOverlayNotificationRenderer({
dom: { overlayNotificationStack: stack },
state: { isOverOverlayNotification: false },
} as never);
fn({ renderer, stack });
} finally {
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument);
} else {
delete (globalThis as { document?: unknown }).document;
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
}
function requireChild(element: FakeElement | undefined, className?: string): FakeElement {
const match = element && className ? findChildByClass(element, className) : element;
if (!match) {
assert.fail(`Expected element${className ? ` .${className}` : ''}.`);
}
return match;
}
test('overlay notification store caps transient notifications and keeps pinned jobs visible', () => {
const store = createOverlayNotificationStore({ maxVisible: 3 });
@@ -141,13 +195,6 @@ test('overlay notification store caps transient notifications and keeps pinned j
);
});
test('overlay notification positions map to stack alignment classes', () => {
assert.equal(overlayNotificationPositionClass(undefined), 'position-top-right');
assert.equal(overlayNotificationPositionClass('top-left'), 'position-top-left');
assert.equal(overlayNotificationPositionClass('top'), 'position-top');
assert.equal(overlayNotificationPositionClass('top-right'), 'position-top-right');
});
test('overlay notification event handler dismisses notifications by id', () => {
const calls: string[] = [];
@@ -168,27 +215,7 @@ test('overlay notification event handler dismisses notifications by id', () => {
});
test('overlay notification renderer shows thumbnail image from payload', () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const stack = createFakeElement();
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: (tagName: string) => createFakeElement(tagName),
},
});
try {
const renderer = createOverlayNotificationRenderer({
dom: {
overlayNotificationStack: stack,
},
state: {
isOverOverlayNotification: false,
},
} as never);
withRenderer({}, ({ renderer, stack }) => {
renderer.show({
title: 'Anki Card Updated',
body: 'Updated card: 食べる',
@@ -197,70 +224,26 @@ test('overlay notification renderer shows thumbnail image from payload', () => {
persistent: true,
});
const card = stack.children[0];
if (!card) {
assert.fail('Expected overlay notification card.');
}
const image = findChildByClass(card, 'overlay-notification-image');
if (!image) {
assert.fail('Expected overlay notification image.');
}
const image = requireChild(stack.children[0], 'overlay-notification-image');
assert.equal(image.tagName, 'IMG');
assert.equal(image.src, 'file:///tmp/subminer-notification-icon.png');
assert.equal(image.alt, '');
} finally {
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument);
} else {
delete (globalThis as { document?: unknown }).document;
}
}
});
});
test('overlay notification action buttons send action ids', () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const stack = createFakeElement();
const sentActions: Array<{ notificationId: string; actionId: string; noteId?: number }> = [];
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: (tagName: string) => createFakeElement(tagName),
const electronAPI = {
sendOverlayNotificationAction: (
notificationId: string,
actionId: string,
options?: { noteId?: number },
) => {
sentActions.push({ notificationId, actionId, noteId: options?.noteId });
},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
clearTimeout: () => undefined,
setTimeout: () => {
return 1;
},
electronAPI: {
sendOverlayNotificationAction: (
notificationId: string,
actionId: string,
options?: { noteId?: number },
) => {
sentActions.push({ notificationId, actionId, noteId: options?.noteId });
},
},
},
});
try {
const renderer = createOverlayNotificationRenderer({
dom: {
overlayNotificationStack: stack,
},
state: {
isOverOverlayNotification: false,
},
} as never);
};
withRenderer({ electronAPI }, ({ renderer, stack }) => {
renderer.show({
id: 'subminer-update-available',
title: 'SubMiner update available',
@@ -269,71 +252,23 @@ test('overlay notification action buttons send action ids', () => {
actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }],
});
const card = stack.children[0];
if (!card) {
assert.fail('Expected overlay notification card.');
}
const button = findChildByClass(card, 'overlay-notification-action');
if (!button) {
assert.fail('Expected overlay notification action button.');
}
button.dispatchEventType('click');
requireChild(stack.children[0], 'overlay-notification-action').dispatchEventType('click');
assert.deepEqual(sentActions, [
{ notificationId: 'subminer-update-available', actionId: 'open-anki-card', noteId: 42 },
]);
} finally {
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument);
} else {
delete (globalThis as { document?: unknown }).document;
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
});
test('overlay notification keepOpen actions leave the card on screen', () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const stack = createFakeElement();
const sentActions: string[] = [];
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: (tagName: string) => createFakeElement(tagName),
const electronAPI = {
sendOverlayNotificationAction: (_notificationId: string, actionId: string) => {
sentActions.push(actionId);
},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
clearTimeout: () => undefined,
setTimeout: () => 1,
electronAPI: {
sendOverlayNotificationAction: (_notificationId: string, actionId: string) => {
sentActions.push(actionId);
},
},
},
});
try {
const renderer = createOverlayNotificationRenderer({
dom: {
overlayNotificationStack: stack,
},
state: {
isOverOverlayNotification: false,
},
} as never);
};
withRenderer({ electronAPI }, ({ renderer, stack }) => {
renderer.show({
id: 'subminer-update-available',
title: 'SubMiner update available',
@@ -345,12 +280,9 @@ test('overlay notification keepOpen actions leave the card on screen', () => {
],
});
const card = stack.children[0];
if (!card) {
assert.fail('Expected overlay notification card.');
}
const buttons: typeof card.children = [];
const collect = (node: typeof card): void => {
const card = requireChild(stack.children[0]);
const buttons: FakeElement[] = [];
const collect = (node: FakeElement): void => {
if (node.className === 'overlay-notification-action') buttons.push(node);
for (const child of node.children) collect(child);
};
@@ -365,53 +297,11 @@ test('overlay notification keepOpen actions leave the card on screen', () => {
buttons[0]?.dispatchEventType('click');
assert.deepEqual(sentActions, ['view-changelog', 'install-update']);
assert.equal(card.classList.contains('leaving'), true);
} finally {
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument);
} else {
delete (globalThis as { document?: unknown }).document;
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
});
test('overlay notification renderer updates same-id progress without replacing the spinner', () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const stack = createFakeElement();
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: (tagName: string) => createFakeElement(tagName),
},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
clearTimeout: () => undefined,
setTimeout: () => {
return 1;
},
},
});
try {
const renderer = createOverlayNotificationRenderer({
dom: {
overlayNotificationStack: stack,
},
state: {
isOverOverlayNotification: false,
},
} as never);
withRenderer({}, ({ renderer, stack }) => {
renderer.show({
id: 'subsync-status',
title: 'Subsync',
@@ -420,16 +310,10 @@ test('overlay notification renderer updates same-id progress without replacing t
persistent: true,
});
const card = stack.children[0];
if (!card) {
assert.fail('Expected overlay notification card.');
}
const card = requireChild(stack.children[0]);
assert.equal(stack.appendCalls, 1);
assert.equal(card.classList.contains('entering'), true);
const spinner = findChildByClass(card, 'overlay-notification-icon');
if (!spinner) {
assert.fail('Expected overlay notification spinner.');
}
const spinner = requireChild(card, 'overlay-notification-icon');
const cardReplacements = card.replaceChildrenCalls;
renderer.show({
@@ -452,60 +336,25 @@ test('overlay notification renderer updates same-id progress without replacing t
card.dispatchEventType('animationend', { animationName: 'overlay-notification-enter-right' });
assert.equal(card.classList.contains('entering'), false);
} finally {
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument);
} else {
delete (globalThis as { document?: unknown }).document;
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
});
test('overlay notification renderer auto-dismisses same-id terminal update after persistent progress', () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const stack = createFakeElement();
let nextTimerId = 1;
const timers = new Map<number, { callback: () => void; delayMs: number }>();
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: (tagName: string) => createFakeElement(tagName),
const fakeTimers: Partial<FakeWindow> = {
clearTimeout: (id) => {
timers.delete(id);
},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
clearTimeout: (id: number) => {
timers.delete(id);
},
setTimeout: (callback: () => void, delayMs: number) => {
const id = nextTimerId;
nextTimerId += 1;
timers.set(id, { callback, delayMs });
return id;
},
setTimeout: (callback, delayMs) => {
const id = nextTimerId;
nextTimerId += 1;
timers.set(id, { callback, delayMs });
return id;
},
});
try {
const renderer = createOverlayNotificationRenderer({
dom: {
overlayNotificationStack: stack,
},
state: {
isOverOverlayNotification: false,
},
} as never);
};
withRenderer(fakeTimers, ({ renderer, stack }) => {
renderer.show({
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
@@ -513,10 +362,7 @@ test('overlay notification renderer auto-dismisses same-id terminal update after
variant: 'progress',
persistent: true,
});
const card = stack.children[0];
if (!card) {
assert.fail('Expected overlay notification card.');
}
const card = requireChild(stack.children[0]);
renderer.show({
id: 'youtube-subtitles-status',
@@ -538,46 +384,5 @@ test('overlay notification renderer auto-dismisses same-id terminal update after
autoDismissTimer.callback();
assert.equal(card.classList.contains('leaving'), true);
} finally {
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument);
} else {
delete (globalThis as { document?: unknown }).document;
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
test('overlay notification cards use larger display dimensions', () => {
assert.match(
overlayNotificationCss,
/\.overlay-notification-stack\s*\{[^}]*width:\s*min\(420px,\s*calc\(100vw - 32px\)\);/s,
);
assert.match(
overlayNotificationCss,
/\.overlay-notification-stack\s*\{[^}]*z-index:\s*2147483647\s*!important;/s,
);
assert.match(overlayNotificationCss, /\.overlay-notification-card\s*\{[^}]*min-height:\s*72px;/s);
assert.match(
overlayNotificationCss,
/\.overlay-notification-card\.has-image\s*\{[^}]*min-height:\s*88px;/s,
);
// The has-image card reserves a real grid track for the thumbnail so it
// cannot overlap the text, and the image shrinks to fit within that track.
assert.match(
overlayNotificationCss,
/\.overlay-notification-card\.has-image\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*100px\)\s+minmax\(0,\s*1fr\)\s+22px;/s,
);
assert.match(
overlayNotificationCss,
/\.overlay-notification-image\s*\{[^}]*max-width:\s*100px;/s,
);
assert.match(
overlayNotificationCss,
/\.overlay-notification-image\s*\{[^}]*aspect-ratio:\s*100 \/ 56;/s,
);
});
});
File diff suppressed because it is too large. Load diff
+92
View File
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolvePlatformInfo, type PlatformInfo } from './platform.js';
// Installs fake `window` and `navigator` globals for the duration of `run`, then restores them.
function withGlobals(globals: { window: unknown; navigator: unknown }, run: () => void): void {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
Object.defineProperty(globalThis, 'window', { configurable: true, value: globals.window });
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: globals.navigator });
try {
run();
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: previousNavigator,
});
}
}
const MAC = { platform: 'MacIntel', userAgent: 'Mozilla/5.0 (Macintosh)' };
const WINDOWS = { platform: 'Win32', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' };
const MAC_VISIBLE: PlatformInfo = {
overlayLayer: 'visible',
isModalLayer: false,
isLinuxPlatform: false,
isMacOSPlatform: true,
isWindowsPlatform: false,
shouldToggleMouseIgnore: true,
};
const cases: Array<{
name: string;
search: string;
preloadLayer: string;
navigator: { platform: string; userAgent: string };
expected: PlatformInfo;
}> = [
{
name: 'prefers query layer over preload layer',
search: '?layer=visible',
preloadLayer: 'modal',
navigator: MAC,
expected: MAC_VISIBLE,
},
{
name: 'ignores legacy secondary layer and falls back to visible',
search: '',
preloadLayer: 'secondary',
navigator: MAC,
expected: MAC_VISIBLE,
},
{
name: 'supports modal layer and disables mouse-ignore toggles',
search: '',
preloadLayer: 'modal',
navigator: MAC,
expected: {
...MAC_VISIBLE,
overlayLayer: 'modal',
isModalLayer: true,
shouldToggleMouseIgnore: false,
},
},
{
name: 'flags Windows platforms',
search: '',
preloadLayer: 'visible',
navigator: WINDOWS,
expected: { ...MAC_VISIBLE, isMacOSPlatform: false, isWindowsPlatform: true },
},
];
for (const c of cases) {
test(`resolvePlatformInfo ${c.name}`, () => {
withGlobals(
{
window: {
electronAPI: { getOverlayLayer: () => c.preloadLayer },
location: { search: c.search },
},
navigator: c.navigator,
},
() => {
assert.deepEqual(resolvePlatformInfo(), c.expected);
},
);
});
}
+114 -11
View File
@@ -1,27 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
YOMITAN_LOOKUP_EVENT,
YOMITAN_POPUP_HOST_SELECTOR,
YOMITAN_POPUP_IFRAME_SELECTOR,
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
hasYomitanPopupIframe,
isYomitanPopupIframe,
isYomitanPopupVisible,
registerYomitanLookupListener,
} from './yomitan-popup.js';
test('registerYomitanLookupListener forwards the SubMiner Yomitan lookup event', () => {
const target = new EventTarget();
const calls: string[] = [];
type FakeStyle = Pick<CSSStyleDeclaration, 'visibility' | 'display' | 'opacity'>;
type FakeFrame = Element & { fakeStyle: FakeStyle };
const dispose = registerYomitanLookupListener(target, () => {
calls.push('lookup');
const VISIBLE_STYLE: FakeStyle = { visibility: 'visible', display: 'block', opacity: '1' };
function frame(options: { width?: number; height?: number; style?: Partial<FakeStyle> } = {}) {
return {
getBoundingClientRect: () => ({ width: options.width ?? 320, height: options.height ?? 180 }),
fakeStyle: { ...VISIBLE_STYLE, ...options.style },
} as unknown as FakeFrame;
}
function host(visibleAttribute: string | null): Element {
return { getAttribute: () => visibleAttribute } as unknown as Element;
}
// Root whose querySelectorAll answers each popup selector with the given elements.
function popupRoot(elements: { visibleHosts?: Element[]; iframes?: Element[]; hosts?: Element[] }) {
const bySelector: Record<string, Element[]> = {
[YOMITAN_POPUP_VISIBLE_HOST_SELECTOR]: elements.visibleHosts ?? [],
[YOMITAN_POPUP_IFRAME_SELECTOR]: elements.iframes ?? [],
[YOMITAN_POPUP_HOST_SELECTOR]: elements.hosts ?? [],
};
return {
querySelectorAll: (selector: string) => bySelector[selector] ?? [],
} as unknown as ParentNode;
}
function withComputedStyle(run: () => void): void {
const previousWindow = (globalThis as { window?: unknown }).window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { getComputedStyle: (element: FakeFrame) => element.fakeStyle },
});
try {
run();
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
}
target.dispatchEvent(new CustomEvent(YOMITAN_LOOKUP_EVENT));
dispose();
target.dispatchEvent(new CustomEvent(YOMITAN_LOOKUP_EVENT));
test('isYomitanPopupIframe matches modern popup class and legacy id prefix', () => {
const element = (options: { id?: string; classNames?: string[] }): Element =>
({
tagName: 'IFRAME',
id: options.id ?? '',
classList: {
contains: (className: string) => (options.classNames ?? []).includes(className),
},
}) as unknown as Element;
assert.deepEqual(calls, ['lookup']);
assert.equal(isYomitanPopupIframe(element({ classNames: ['yomitan-popup'] })), true);
assert.equal(isYomitanPopupIframe(element({ id: 'yomitan-popup-123' })), true);
assert.equal(isYomitanPopupIframe(element({ id: 'something-else' })), false);
});
test('hasYomitanPopupIframe detects popup iframes or shadow-hosted popup hosts', () => {
const rootWith = (matches: string[]) =>
({
querySelector: (selector: string) => (matches.includes(selector) ? {} : null),
}) as unknown as ParentNode;
assert.equal(hasYomitanPopupIframe(rootWith([YOMITAN_POPUP_IFRAME_SELECTOR])), true);
assert.equal(hasYomitanPopupIframe(rootWith([YOMITAN_POPUP_HOST_SELECTOR])), true);
assert.equal(hasYomitanPopupIframe(rootWith([])), false);
});
const visibilityCases: Array<{
name: string;
root: () => ParentNode;
expected: boolean;
}> = [
{
name: 'detects a host marked visible without iframe access',
root: () => popupRoot({ visibleHosts: [host('true')] }),
expected: true,
},
{
name: 'detects a visible iframe among hidden ones',
root: () => popupRoot({ iframes: [frame({ style: { visibility: 'hidden' } }), frame()] }),
expected: true,
},
{
name: 'ignores iframes that are hidden, undisplayed, transparent, or zero-sized',
root: () =>
popupRoot({
iframes: [
frame({ style: { visibility: 'hidden' } }),
frame({ style: { display: 'none' } }),
frame({ style: { opacity: '0' } }),
frame({ width: 0 }),
frame({ height: 0 }),
],
}),
expected: false,
},
{
name: 'detects a popup host carrying the visible attribute',
root: () => popupRoot({ hosts: [host('false'), host('true')] }),
expected: true,
},
{
name: 'ignores popup hosts without the visible attribute',
root: () => popupRoot({ hosts: [host(null), host('false')] }),
expected: false,
},
];
for (const c of visibilityCases) {
test(`isYomitanPopupVisible ${c.name}`, () => {
withComputedStyle(() => {
assert.equal(isYomitanPopupVisible(c.root()), c.expected);
});
});
}
test('isYomitanPopupVisible falls back to querySelector when querySelectorAll is unavailable', () => {
const root = {
querySelector: (selector: string) =>