mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
Hachidori 0.4.0 closed its popup shadow root, so isHachidoriPopupOpen always returned false. The popup never took over the subtitle hover pause, and moving off the subtitle resumed playback with the popup open. Popup key and controller routing read the same check. The sidebar kept working because its hold follows the attention event instead of the pane. The fork now publishes the pane state on the host element, and the renderer reads that attribute instead of upstream's shadow root and CSS class. The unit stubs modelled an open root and hid this, so test:dictionary:electron now drives the real reader and asserts the attribute follows the pane.
743 lines
25 KiB
TypeScript
743 lines
25 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_POPUP_HIDDEN_EVENT,
|
|
HACHIDORI_POPUP_OPEN_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 popupHost = {
|
|
tagName: options.hachidori ? 'HACHIDORI-HOST' : 'DIV',
|
|
getAttribute: (name: string) =>
|
|
name === 'data-subminer-yomitan-popup-visible' ? String(harness.popupHostVisible) : null,
|
|
};
|
|
|
|
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, isTrusted: true });
|
|
},
|
|
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] : [];
|
|
}
|
|
if (selector === HACHIDORI_POPUP_OPEN_SELECTOR) {
|
|
return harness.popupPaneOpen ? [popupHost] : [];
|
|
}
|
|
return selector === YOMITAN_POPUP_HOST_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]);
|
|
});
|
|
|
|
// The lookup's fallback scan dispatches untrusted mouse events on the selected word, and
|
|
// those bubble to the pointer tracker while the real cursor is elsewhere.
|
|
test('popup close resumes after a fallback scan event lands on the subtitle', async (t) => {
|
|
const h = createMouseHarness(t, { hoverPause: true, popupPause: true });
|
|
h.handlers.setupPointerTracking();
|
|
h.handlers.setupYomitanObserver();
|
|
|
|
h.hovered = 'primary';
|
|
h.fire('document', 'pointermove', { clientX: 120, clientY: 240, isTrusted: false });
|
|
h.fire('document', 'mousemove', { clientX: 120, clientY: 240, isTrusted: false });
|
|
await waitForNextTick();
|
|
assert.equal(h.ctx.state.isOverSubtitle, false);
|
|
|
|
h.fire('window', YOMITAN_POPUP_SHOWN_EVENT);
|
|
await waitForNextTick();
|
|
h.fire('window', YOMITAN_POPUP_HIDDEN_EVENT);
|
|
|
|
assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]);
|
|
});
|
|
|
|
// Controller and keyboard pause toggles change playback without touching the hover state.
|
|
test('popup open pauses when the user resumed playback during a hover pause', async (t) => {
|
|
let paused = false;
|
|
const h = createMouseHarness(t, {
|
|
hoverPause: true,
|
|
popupPause: true,
|
|
getPlaybackPaused: async () => paused,
|
|
});
|
|
h.handlers.setupYomitanObserver();
|
|
|
|
// `paused` stays false: the pause button resumed playback after this hover pause.
|
|
await h.handlers.handleMouseEnter();
|
|
assert.deepEqual(h.mpvCommands, [PAUSE]);
|
|
|
|
// The lookup event lands while the shown event's pause check is still in flight.
|
|
h.popupHostVisible = true;
|
|
h.fire('window', YOMITAN_POPUP_SHOWN_EVENT);
|
|
h.fire('window', YOMITAN_LOOKUP_EVENT);
|
|
await waitForNextTick();
|
|
|
|
assert.deepEqual(h.mpvCommands, [PAUSE, PAUSE]);
|
|
});
|
|
|
|
test('popup taking over a hover pause leaves playback paused without a second command', async (t) => {
|
|
let paused = false;
|
|
const h = createMouseHarness(t, {
|
|
hoverPause: true,
|
|
popupPause: true,
|
|
getPlaybackPaused: async () => paused,
|
|
});
|
|
h.handlers.setupYomitanObserver();
|
|
|
|
await h.handlers.handleMouseEnter();
|
|
paused = true;
|
|
h.fire('window', YOMITAN_POPUP_SHOWN_EVENT);
|
|
await waitForNextTick();
|
|
await h.handlers.handleMouseLeave();
|
|
assert.deepEqual(h.mpvCommands, [PAUSE]);
|
|
|
|
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('leaving the subtitle keeps playback paused while a Hachidori popup is open', async (t) => {
|
|
let paused = false;
|
|
const h = createMouseHarness(t, {
|
|
hoverPause: true,
|
|
popupPause: true,
|
|
hachidori: true,
|
|
getPlaybackPaused: async () => paused,
|
|
});
|
|
h.handlers.setupYomitanObserver();
|
|
|
|
await h.handlers.handleMouseEnter();
|
|
paused = true;
|
|
h.popupPaneOpen = true;
|
|
h.popupHostVisible = true;
|
|
h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT);
|
|
await waitForNextTick();
|
|
await h.handlers.handleMouseLeave();
|
|
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('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]);
|
|
});
|