mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
665 lines
22 KiB
TypeScript
665 lines
22 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test, { type TestContext } from 'node:test';
|
|
|
|
import { SUBTITLE_DEFAULT_CONFIG } from '../../config/definitions/defaults-subtitle';
|
|
import { createRendererState } from '../state';
|
|
import { createMouseHandlers } from './mouse.js';
|
|
import {
|
|
HACHIDORI_HOST_SELECTOR,
|
|
HACHIDORI_POPUP_HIDDEN_EVENT,
|
|
HACHIDORI_POPUP_SELECTOR,
|
|
HACHIDORI_POPUP_SHOWN_EVENT,
|
|
YOMITAN_LOOKUP_EVENT,
|
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
|
YOMITAN_POPUP_HOST_SELECTOR,
|
|
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
|
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
|
|
YOMITAN_POPUP_SHOWN_EVENT,
|
|
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
|
} from '../yomitan-popup.js';
|
|
|
|
function createClassList() {
|
|
const classes = new Set<string>();
|
|
return {
|
|
add: (...tokens: string[]) => tokens.forEach((token) => classes.add(token)),
|
|
remove: (...tokens: string[]) => tokens.forEach((token) => classes.delete(token)),
|
|
toggle: (token: string, force?: boolean) => {
|
|
const next = force ?? !classes.has(token);
|
|
if (next) classes.add(token);
|
|
else classes.delete(token);
|
|
return next;
|
|
},
|
|
contains: (token: string) => classes.has(token),
|
|
};
|
|
}
|
|
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((nextResolve) => {
|
|
resolve = nextResolve;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
function waitForNextTick(): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
type Listener = (event: unknown) => void;
|
|
type IgnoreCall = { ignore: boolean; forward?: boolean };
|
|
type HoverTarget = 'primary' | 'secondary' | null;
|
|
|
|
const MOCKED_GLOBALS = ['window', 'document', 'MutationObserver', 'Node'] as const;
|
|
|
|
/**
|
|
* Installs window/document/MutationObserver/Node stubs (restored via `t.after`) and builds
|
|
* mouse handlers with no-op defaults. `hoverPause`, `popupPause`, `hovered` and
|
|
* `popupHostVisible` are mutable mid-test; `fire` dispatches to the registered listeners.
|
|
* With `hachidori`, the popup host is a Hachidori shadow host: `popupHostVisible` is its
|
|
* attention flag, `popupPaneOpen` its popup pane, and `popupHostAttached` its presence.
|
|
*/
|
|
function createMouseHarness(
|
|
t: TestContext,
|
|
options: {
|
|
platform?: 'windows' | 'macos';
|
|
hoverPause?: boolean;
|
|
popupPause?: boolean;
|
|
paused?: boolean;
|
|
getPlaybackPaused?: () => Promise<boolean | null>;
|
|
popupHostVisible?: boolean;
|
|
hovered?: HoverTarget;
|
|
hachidori?: boolean;
|
|
} = {},
|
|
) {
|
|
const overlayClassList = createClassList();
|
|
const bodyClassList = createClassList();
|
|
const focusCalls = { mainWindow: 0, window: 0, overlay: 0 };
|
|
const ctx = {
|
|
dom: {
|
|
overlay: {
|
|
classList: overlayClassList,
|
|
focus: () => {
|
|
focusCalls.overlay += 1;
|
|
},
|
|
},
|
|
subtitleRoot: { classList: createClassList() },
|
|
subtitleContainer: {
|
|
classList: createClassList(),
|
|
style: { cursor: '' },
|
|
addEventListener: () => {},
|
|
},
|
|
secondarySubContainer: {
|
|
classList: createClassList(),
|
|
addEventListener: () => {},
|
|
},
|
|
},
|
|
platform: {
|
|
shouldToggleMouseIgnore: true,
|
|
isLinuxPlatform: false,
|
|
isMacOSPlatform: options.platform === 'macos',
|
|
},
|
|
state: { ...createRendererState(), primaryVisibleOnYomitanPopup: false },
|
|
};
|
|
|
|
const listeners = {
|
|
window: new Map<string, Listener[]>(),
|
|
document: new Map<string, Listener[]>(),
|
|
};
|
|
const addListener = (target: keyof typeof listeners) => (type: string, listener: Listener) => {
|
|
listeners[target].set(type, [...(listeners[target].get(type) ?? []), listener]);
|
|
};
|
|
const ignoreCalls: IgnoreCall[] = [];
|
|
const mpvCommands: Array<(string | number)[]> = [];
|
|
const popupPane = {
|
|
get hidden() {
|
|
return !harness.popupPaneOpen;
|
|
},
|
|
};
|
|
const popupHost = {
|
|
tagName: options.hachidori ? 'HACHIDORI-HOST' : 'DIV',
|
|
getAttribute: (name: string) =>
|
|
name === 'data-subminer-yomitan-popup-visible' ? String(harness.popupHostVisible) : null,
|
|
shadowRoot: {
|
|
querySelectorAll: (selector: string) =>
|
|
selector === HACHIDORI_POPUP_SELECTOR ? [popupPane] : [],
|
|
},
|
|
};
|
|
const hostSelectors = options.hachidori
|
|
? [HACHIDORI_HOST_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR]
|
|
: [YOMITAN_POPUP_HOST_SELECTOR];
|
|
|
|
const harness = {
|
|
ctx,
|
|
ignoreCalls,
|
|
mpvCommands,
|
|
focusCalls,
|
|
bodyClassList,
|
|
hoverPause: options.hoverPause ?? false,
|
|
popupPause: options.popupPause ?? false,
|
|
hovered: options.hovered ?? null,
|
|
popupHostVisible: options.popupHostVisible ?? false,
|
|
popupHostAttached: options.hachidori ?? false,
|
|
popupPaneOpen: false,
|
|
handlers: null as unknown as ReturnType<typeof createMouseHandlers>,
|
|
fire: (target: keyof typeof listeners, type: string, event: unknown = {}) => {
|
|
for (const listener of listeners[target].get(type) ?? []) listener(event);
|
|
},
|
|
/** Moves the tracked pointer onto `hovered` and fires a document mousemove. */
|
|
move: (hovered: HoverTarget, clientX = 120, clientY = 240) => {
|
|
harness.hovered = hovered;
|
|
harness.fire('document', 'mousemove', { clientX, clientY });
|
|
},
|
|
isInteractive: () => overlayClassList.contains('interactive'),
|
|
isSecondaryHoverActive: () =>
|
|
ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'),
|
|
};
|
|
|
|
const stubs: Record<(typeof MOCKED_GLOBALS)[number], unknown> = {
|
|
window: {
|
|
addEventListener: addListener('window'),
|
|
electronAPI: {
|
|
setIgnoreMouseEvents: (ignore: boolean, ignoreOptions?: { forward?: boolean }) => {
|
|
ignoreCalls.push({ ignore, forward: ignoreOptions?.forward });
|
|
},
|
|
focusMainWindow: () => {
|
|
focusCalls.mainWindow += 1;
|
|
},
|
|
},
|
|
focus: () => {
|
|
focusCalls.window += 1;
|
|
},
|
|
getComputedStyle: () => ({ visibility: 'visible', display: 'block', opacity: '1' }),
|
|
innerHeight: 1000,
|
|
getSelection: () => null,
|
|
setTimeout,
|
|
clearTimeout,
|
|
},
|
|
document: {
|
|
visibilityState: 'visible',
|
|
body: { classList: bodyClassList },
|
|
addEventListener: addListener('document'),
|
|
querySelector: () => null,
|
|
querySelectorAll: (selector: string) => {
|
|
if (selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) {
|
|
return harness.popupHostVisible ? [popupHost] : [];
|
|
}
|
|
return hostSelectors.includes(selector) &&
|
|
(harness.popupHostVisible || harness.popupHostAttached)
|
|
? [popupHost]
|
|
: [];
|
|
},
|
|
elementFromPoint: () =>
|
|
harness.hovered === 'primary'
|
|
? ctx.dom.subtitleContainer
|
|
: harness.hovered === 'secondary'
|
|
? ctx.dom.secondarySubContainer
|
|
: null,
|
|
},
|
|
MutationObserver: class {
|
|
observe() {}
|
|
},
|
|
Node: class {
|
|
static ELEMENT_NODE = 1;
|
|
},
|
|
};
|
|
|
|
const previous = MOCKED_GLOBALS.map(
|
|
(key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
|
|
);
|
|
for (const key of MOCKED_GLOBALS) {
|
|
Object.defineProperty(globalThis, key, {
|
|
configurable: true,
|
|
writable: true,
|
|
value: stubs[key],
|
|
});
|
|
}
|
|
t.after(() => {
|
|
for (const [key, descriptor] of previous) {
|
|
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
|
else delete (globalThis as Record<string, unknown>)[key];
|
|
}
|
|
});
|
|
|
|
harness.handlers = createMouseHandlers(ctx as never, {
|
|
modalStateReader: {
|
|
isAnySettingsModalOpen: () => false,
|
|
isAnyModalOpen: () => false,
|
|
},
|
|
applyYPercent: () => {},
|
|
getCurrentYPercent: () => 10,
|
|
persistSubtitlePositionPatch: () => {},
|
|
getSubtitleHoverAutoPauseEnabled: () => harness.hoverPause,
|
|
getYomitanPopupAutoPauseEnabled: () => harness.popupPause,
|
|
getPlaybackPaused: options.getPlaybackPaused ?? (async () => options.paused ?? false),
|
|
sendMpvCommand: (command) => {
|
|
mpvCommands.push(command);
|
|
},
|
|
});
|
|
|
|
return harness;
|
|
}
|
|
|
|
const PAUSE = ['set_property', 'pause', 'yes'];
|
|
const RESUME = ['set_property', 'pause', 'no'];
|
|
|
|
// Subtitle hover auto-pause
|
|
|
|
for (const c of [
|
|
{
|
|
name: 'secondary hover pauses on enter, reveals secondary subtitle, and resumes on leave',
|
|
target: 'secondary',
|
|
revealsSecondary: true,
|
|
},
|
|
{
|
|
name: 'primary hover pauses on enter without revealing secondary subtitle, and resumes on leave',
|
|
target: 'primary',
|
|
revealsSecondary: false,
|
|
},
|
|
] as const) {
|
|
test(c.name, async (t) => {
|
|
const h = createMouseHarness(t, { hoverPause: true });
|
|
const { handlers } = h;
|
|
const [enter, leave] =
|
|
c.target === 'secondary'
|
|
? [handlers.handleSecondaryMouseEnter, handlers.handleSecondaryMouseLeave]
|
|
: [handlers.handlePrimaryMouseEnter, handlers.handlePrimaryMouseLeave];
|
|
|
|
await enter();
|
|
assert.equal(h.isSecondaryHoverActive(), c.revealsSecondary);
|
|
await leave();
|
|
assert.equal(h.isSecondaryHoverActive(), false);
|
|
|
|
assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]);
|
|
});
|
|
}
|
|
|
|
test('moving between primary and secondary subtitle containers keeps the hover pause active', async (t) => {
|
|
const h = createMouseHarness(t, { hoverPause: true });
|
|
const { subtitleContainer, secondarySubContainer } = h.ctx.dom;
|
|
|
|
await h.handlers.handleSecondaryMouseEnter();
|
|
await h.handlers.handleSecondaryMouseLeave({
|
|
relatedTarget: subtitleContainer,
|
|
} as unknown as MouseEvent);
|
|
assert.equal(h.ctx.state.isOverSubtitle, false);
|
|
assert.equal(h.isSecondaryHoverActive(), false);
|
|
|
|
await h.handlers.handlePrimaryMouseEnter({
|
|
relatedTarget: secondarySubContainer,
|
|
} as unknown as MouseEvent);
|
|
|
|
assert.equal(h.ctx.state.isOverSubtitle, true);
|
|
assert.deepEqual(h.mpvCommands, [PAUSE]);
|
|
});
|
|
|
|
for (const c of [
|
|
{ name: 'playback is already paused', hoverPause: true, paused: true },
|
|
{ name: 'disabled in config', hoverPause: false, paused: false },
|
|
]) {
|
|
test(`auto-pause on subtitle hover is skipped when ${c.name}`, async (t) => {
|
|
const h = createMouseHarness(t, { hoverPause: c.hoverPause, paused: c.paused });
|
|
|
|
await h.handlers.handleMouseEnter();
|
|
await h.handlers.handleMouseLeave();
|
|
|
|
assert.deepEqual(h.mpvCommands, []);
|
|
});
|
|
}
|
|
|
|
test('pending hover pause check is ignored when mouse leaves before pause state resolves', async (t) => {
|
|
const deferred = createDeferred<boolean | null>();
|
|
const h = createMouseHarness(t, { hoverPause: true, getPlaybackPaused: () => deferred.promise });
|
|
|
|
const enterPromise = h.handlers.handleMouseEnter();
|
|
await h.handlers.handleMouseLeave();
|
|
deferred.resolve(false);
|
|
await enterPromise;
|
|
|
|
assert.deepEqual(h.mpvCommands, []);
|
|
});
|
|
|
|
test('subtitle leave restores passthrough while embedded sidebar is open but not hovered', async (t) => {
|
|
const h = createMouseHarness(t);
|
|
h.ctx.state.isOverSubtitle = true;
|
|
h.ctx.state.subtitleSidebarModalOpen = true;
|
|
h.ctx.state.subtitleSidebarConfig = {
|
|
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
|
|
layout: 'embedded',
|
|
};
|
|
|
|
await h.handlers.handlePrimaryMouseLeave();
|
|
|
|
assert.equal(h.isInteractive(), false);
|
|
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: true, forward: true });
|
|
});
|
|
|
|
// Yomitan popup interaction
|
|
|
|
test('hover pause resumes immediately on subtitle leave even when yomitan popup is visible', async (t) => {
|
|
const h = createMouseHarness(t, { hoverPause: true });
|
|
h.handlers.setupYomitanObserver();
|
|
h.fire('window', YOMITAN_POPUP_SHOWN_EVENT);
|
|
|
|
await h.handlers.handleMouseEnter();
|
|
await h.handlers.handleMouseLeave();
|
|
|
|
assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]);
|
|
});
|
|
|
|
test('popup open pauses and popup close resumes when yomitan popup auto-pause is enabled', async (t) => {
|
|
const h = createMouseHarness(t, { hoverPause: true, popupPause: true });
|
|
h.handlers.setupYomitanObserver();
|
|
|
|
h.fire('window', YOMITAN_POPUP_SHOWN_EVENT);
|
|
await waitForNextTick();
|
|
h.fire('window', YOMITAN_POPUP_HIDDEN_EVENT);
|
|
|
|
assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]);
|
|
});
|
|
|
|
for (const c of [
|
|
{
|
|
name: 'nested popup close reasserts interactive state and focus when another popup remains visible on Windows',
|
|
platform: 'windows',
|
|
event: YOMITAN_POPUP_HIDDEN_EVENT,
|
|
reclaimsFocus: true,
|
|
},
|
|
{
|
|
name: 'window blur reclaims overlay focus while a yomitan popup remains visible on Windows',
|
|
platform: 'windows',
|
|
event: 'blur',
|
|
reclaimsFocus: true,
|
|
},
|
|
{
|
|
name: 'window blur on macOS keeps yomitan popup interactive without stealing click-away focus',
|
|
platform: 'macos',
|
|
event: 'blur',
|
|
reclaimsFocus: false,
|
|
},
|
|
] as const) {
|
|
test(c.name, async (t) => {
|
|
const h = createMouseHarness(t, { platform: c.platform, popupHostVisible: true });
|
|
h.handlers.setupYomitanObserver();
|
|
assert.equal(h.ctx.state.yomitanPopupVisible, true);
|
|
assert.equal(h.isInteractive(), true);
|
|
h.ignoreCalls.length = 0;
|
|
|
|
h.fire('window', c.event);
|
|
// Blur reconciles in a microtask.
|
|
await Promise.resolve();
|
|
|
|
assert.equal(h.ctx.state.yomitanPopupVisible, true);
|
|
assert.equal(h.isInteractive(), true);
|
|
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: false, forward: undefined });
|
|
const expectedFocus = c.reclaimsFocus ? 1 : 0;
|
|
assert.deepEqual(h.focusCalls, {
|
|
mainWindow: expectedFocus,
|
|
window: expectedFocus,
|
|
overlay: expectedFocus,
|
|
});
|
|
});
|
|
}
|
|
|
|
// Hachidori publishes one attention signal for an open popup and for a press on
|
|
// subtitle text that may become a selection, so its popup pane is the source of
|
|
// truth for what is on screen.
|
|
|
|
test('Hachidori press on subtitle text does not pause before a popup opens', async (t) => {
|
|
const h = createMouseHarness(t, { popupPause: true, hachidori: true });
|
|
h.handlers.setupYomitanObserver();
|
|
|
|
h.popupHostVisible = true;
|
|
h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT);
|
|
await waitForNextTick();
|
|
h.popupHostVisible = false;
|
|
h.fire('window', HACHIDORI_POPUP_HIDDEN_EVENT);
|
|
await waitForNextTick();
|
|
|
|
assert.deepEqual(h.mpvCommands, []);
|
|
});
|
|
|
|
test('Hachidori press before any lookup does not pause while its host is unattached', async (t) => {
|
|
const h = createMouseHarness(t, { popupPause: true, hachidori: true });
|
|
h.handlers.setupYomitanObserver();
|
|
|
|
// Hachidori attaches its host on the first lookup, so a press anywhere on
|
|
// the overlay before that claims attention with nothing in the DOM.
|
|
h.popupHostAttached = false;
|
|
h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT);
|
|
await waitForNextTick();
|
|
h.fire('window', HACHIDORI_POPUP_HIDDEN_EVENT);
|
|
await waitForNextTick();
|
|
|
|
assert.deepEqual(h.mpvCommands, []);
|
|
});
|
|
|
|
test('Hachidori selection lookup pauses once its popup opens and resumes on close', async (t) => {
|
|
const h = createMouseHarness(t, { popupPause: true, hachidori: true });
|
|
h.handlers.setupYomitanObserver();
|
|
|
|
h.popupHostVisible = true;
|
|
h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT);
|
|
await waitForNextTick();
|
|
assert.deepEqual(h.mpvCommands, []);
|
|
|
|
// The drag's attention carries over to its lookup, so no second shown event arrives.
|
|
h.popupPaneOpen = true;
|
|
h.fire('window', YOMITAN_LOOKUP_EVENT);
|
|
await waitForNextTick();
|
|
assert.deepEqual(h.mpvCommands, [PAUSE]);
|
|
|
|
h.popupPaneOpen = false;
|
|
h.popupHostVisible = false;
|
|
h.fire('window', HACHIDORI_POPUP_HIDDEN_EVENT);
|
|
assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]);
|
|
});
|
|
|
|
test('Hachidori hover popup pauses when shown', async (t) => {
|
|
const h = createMouseHarness(t, { popupPause: true, hachidori: true });
|
|
h.handlers.setupYomitanObserver();
|
|
|
|
h.popupPaneOpen = true;
|
|
h.popupHostVisible = true;
|
|
h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT);
|
|
await waitForNextTick();
|
|
|
|
assert.deepEqual(h.mpvCommands, [PAUSE]);
|
|
});
|
|
|
|
test('popup shown reclaims overlay focus on macOS and captures click-away', (t) => {
|
|
const h = createMouseHarness(t, { platform: 'macos' });
|
|
h.handlers.setupYomitanObserver();
|
|
|
|
h.fire('window', YOMITAN_POPUP_SHOWN_EVENT);
|
|
|
|
assert.equal(h.ctx.state.yomitanPopupVisible, true);
|
|
assert.equal(h.isInteractive(), true);
|
|
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: false, forward: undefined });
|
|
assert.deepEqual(h.focusCalls, { mainWindow: 1, window: 1, overlay: 1 });
|
|
});
|
|
|
|
test('popup mouse enter and leave on macOS keep click-away captured while popup remains visible', (t) => {
|
|
const h = createMouseHarness(t, { platform: 'macos', popupHostVisible: true });
|
|
h.handlers.setupYomitanObserver();
|
|
|
|
h.fire('window', YOMITAN_POPUP_MOUSE_ENTER_EVENT);
|
|
assert.equal(h.ctx.state.yomitanPopupVisible, true);
|
|
assert.equal(h.ctx.state.isOverYomitanPopup, true);
|
|
assert.equal(h.isInteractive(), true);
|
|
|
|
h.ignoreCalls.length = 0;
|
|
h.fire('window', YOMITAN_POPUP_MOUSE_LEAVE_EVENT);
|
|
|
|
assert.equal(h.ctx.state.yomitanPopupVisible, true);
|
|
assert.equal(h.ctx.state.isOverYomitanPopup, false);
|
|
assert.equal(h.isInteractive(), true);
|
|
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: false, forward: undefined });
|
|
});
|
|
|
|
test('popup hidden on macOS releases click-away capture back to mpv', (t) => {
|
|
const h = createMouseHarness(t, { platform: 'macos', popupHostVisible: true });
|
|
h.handlers.setupYomitanObserver();
|
|
assert.equal(h.isInteractive(), true);
|
|
|
|
h.popupHostVisible = false;
|
|
h.fire('window', YOMITAN_POPUP_HIDDEN_EVENT);
|
|
|
|
assert.equal(h.ctx.state.yomitanPopupVisible, false);
|
|
assert.equal(h.isInteractive(), false);
|
|
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: true, forward: true });
|
|
});
|
|
|
|
test('a pointer leaving a popup that just closed does not mark it open again', (t) => {
|
|
const h = createMouseHarness(t, { platform: 'macos', popupHostVisible: true });
|
|
h.handlers.setupYomitanObserver();
|
|
h.fire('window', YOMITAN_POPUP_MOUSE_ENTER_EVENT);
|
|
|
|
// The popup hides under the pointer: hidden, then the leave it causes.
|
|
h.popupHostVisible = false;
|
|
h.fire('window', YOMITAN_POPUP_HIDDEN_EVENT);
|
|
h.fire('window', YOMITAN_POPUP_MOUSE_LEAVE_EVENT);
|
|
|
|
assert.equal(h.ctx.state.yomitanPopupVisible, false);
|
|
assert.equal(h.ctx.state.isOverYomitanPopup, false);
|
|
assert.equal(h.isInteractive(), false);
|
|
});
|
|
|
|
test('yomitan popup visibility marks primary subtitle hover hold while enabled', (t) => {
|
|
const h = createMouseHarness(t);
|
|
h.ctx.state.primaryVisibleOnYomitanPopup = true;
|
|
h.handlers.setupYomitanObserver();
|
|
|
|
h.fire('window', YOMITAN_POPUP_SHOWN_EVENT);
|
|
assert.equal(h.bodyClassList.contains('primary-sub-visible-on-yomitan-popup'), true);
|
|
|
|
h.fire('window', YOMITAN_POPUP_HIDDEN_EVENT);
|
|
assert.equal(h.bodyClassList.contains('primary-sub-visible-on-yomitan-popup'), false);
|
|
});
|
|
|
|
// Pointer tracking and interaction recovery
|
|
|
|
test('pointer tracking enables overlay interaction as soon as the cursor reaches subtitles', (t) => {
|
|
const h = createMouseHarness(t);
|
|
h.handlers.setupPointerTracking();
|
|
|
|
h.move('primary');
|
|
|
|
assert.equal(h.ctx.state.isOverSubtitle, true);
|
|
assert.equal(h.isInteractive(), true);
|
|
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: false, forward: undefined });
|
|
});
|
|
|
|
test('pointer tracking restores click-through after the cursor leaves subtitles', (t) => {
|
|
const h = createMouseHarness(t);
|
|
h.handlers.setupPointerTracking();
|
|
|
|
h.move('primary');
|
|
h.move(null, 640, 360);
|
|
|
|
assert.equal(h.ctx.state.isOverSubtitle, false);
|
|
assert.equal(h.isInteractive(), false);
|
|
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: true, forward: true });
|
|
});
|
|
|
|
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', (t) => {
|
|
const h = createMouseHarness(t);
|
|
h.handlers.setupPointerTracking();
|
|
h.move('primary');
|
|
|
|
h.handlers.restorePointerInteractionState();
|
|
|
|
assert.equal(h.ctx.state.isOverSubtitle, true);
|
|
assert.equal(h.isInteractive(), true);
|
|
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: false, forward: undefined });
|
|
});
|
|
|
|
test('restorePointerInteractionState keeps overlay interactive until first real pointer move can resync hover', (t) => {
|
|
const h = createMouseHarness(t);
|
|
h.handlers.setupPointerTracking();
|
|
|
|
h.handlers.restorePointerInteractionState();
|
|
|
|
assert.equal(h.ctx.state.isOverSubtitle, false);
|
|
assert.equal(h.isInteractive(), true);
|
|
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: false, forward: undefined });
|
|
|
|
h.move(null, 24, 48);
|
|
|
|
assert.equal(h.ctx.state.isOverSubtitle, false);
|
|
assert.equal(h.isInteractive(), false);
|
|
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: true, forward: true });
|
|
});
|
|
|
|
test('restorePointerInteractionState reapplies the secondary hover class from pointer location', async (t) => {
|
|
const h = createMouseHarness(t, { hovered: 'secondary' });
|
|
h.handlers.setupPointerTracking();
|
|
await h.handlers.handleSecondaryMouseEnter({ clientX: 10, clientY: 20 } as MouseEvent);
|
|
|
|
h.handlers.restorePointerInteractionState();
|
|
h.move('secondary', 10, 20);
|
|
|
|
assert.equal(h.ctx.state.isOverSubtitle, true);
|
|
assert.equal(h.isSecondaryHoverActive(), true);
|
|
});
|
|
|
|
test('visibility recovery re-enables subtitle hover without needing a fresh pointer move', (t) => {
|
|
const h = createMouseHarness(t);
|
|
h.handlers.setupPointerTracking();
|
|
h.move('primary');
|
|
h.ctx.state.isOverSubtitle = false;
|
|
h.ctx.dom.overlay.classList.remove('interactive');
|
|
|
|
h.fire('document', 'visibilitychange');
|
|
|
|
assert.equal(h.ctx.state.isOverSubtitle, true);
|
|
assert.equal(h.isInteractive(), true);
|
|
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: false, forward: undefined });
|
|
});
|
|
|
|
test('visibility recovery keeps overlay click-through when pointer is not over subtitles', (t) => {
|
|
const h = createMouseHarness(t);
|
|
h.handlers.setupPointerTracking();
|
|
h.move(null, 320, 180);
|
|
h.ctx.dom.overlay.classList.add('interactive');
|
|
|
|
h.fire('document', 'visibilitychange');
|
|
|
|
assert.equal(h.ctx.state.isOverSubtitle, false);
|
|
assert.equal(h.isInteractive(), false);
|
|
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: true, forward: true });
|
|
});
|
|
|
|
for (const c of [
|
|
{ name: 'visibility recovery', target: 'document', event: 'visibilitychange' },
|
|
{ name: 'window resize', target: 'window', event: 'resize' },
|
|
] as const) {
|
|
test(`${c.name} ignores synthetic subtitle enter until the pointer moves again`, async (t) => {
|
|
const h = createMouseHarness(t);
|
|
h.handlers.setupPointerTracking();
|
|
h.handlers.setupResizeHandler();
|
|
h.move('primary');
|
|
await waitForNextTick();
|
|
|
|
h.hoverPause = true;
|
|
h.fire(c.target, c.event);
|
|
await h.handlers.handlePrimaryMouseEnter();
|
|
assert.deepEqual(h.mpvCommands, []);
|
|
|
|
h.move(null, 32, 48);
|
|
h.move('primary');
|
|
await waitForNextTick();
|
|
|
|
assert.deepEqual(h.mpvCommands, [PAUSE]);
|
|
});
|
|
}
|
|
|
|
test('window resize allows primary hover pause from a real mouseenter over subtitles', async (t) => {
|
|
const h = createMouseHarness(t, { hoverPause: true, hovered: 'primary' });
|
|
h.handlers.setupResizeHandler();
|
|
h.fire('window', 'resize');
|
|
|
|
await h.handlers.handlePrimaryMouseEnter({ clientX: 120, clientY: 240 } as MouseEvent);
|
|
|
|
assert.deepEqual(h.mpvCommands, [PAUSE]);
|
|
});
|