mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 16:46:06 -08:00
test: cut low-value tests, rewrite the rest around behavior, parallelize lanes (#285)
This commit is contained in:
1 parent
4b6842d034
commit
b48083ddd8
368 files changed
+18045
-60331
No files matched your search
@@ -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
@@ -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();
|
||||
|
||||
|
||||
+409
-1769
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
+140
-1018
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user