mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-13 15:13:32 -07:00
462 lines
15 KiB
TypeScript
462 lines
15 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
applyLinuxOverlayInputShape,
|
|
applyLinuxOverlayPointerInteractionMousePassthrough,
|
|
type LinuxOverlayPointerInteractionDeps,
|
|
isCursorOverSubtitle,
|
|
type ForegroundSuppressionGraceState,
|
|
mapOverlayMeasurementForPointerInteraction,
|
|
resolveDesiredOverlayInteractive,
|
|
resolveForegroundSuppressionWithGrace,
|
|
shouldSuppressPointerInteractionForForegroundWindow,
|
|
tickLinuxOverlayPointerInteraction,
|
|
} from './linux-overlay-pointer-interaction';
|
|
|
|
const BOUNDS = { x: 100, y: 100, width: 1920, height: 1080 };
|
|
const MEASUREMENT = {
|
|
viewport: { width: 1920, height: 1080 },
|
|
contentRect: { x: 800, y: 900, width: 320, height: 80 },
|
|
};
|
|
|
|
test('isCursorOverSubtitle hit-tests the subtitle rect in screen coords (1:1 scale)', () => {
|
|
// Subtitle rect maps to screen [900..1220] x [1000..1080] (+100 window origin).
|
|
assert.equal(isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, MEASUREMENT), true);
|
|
assert.equal(isCursorOverSubtitle({ x: 500, y: 1040 }, BOUNDS, MEASUREMENT), false);
|
|
assert.equal(isCursorOverSubtitle({ x: 1000, y: 500 }, BOUNDS, MEASUREMENT), false);
|
|
});
|
|
|
|
test('isCursorOverSubtitle scales viewport px to window px', () => {
|
|
// Window is 2x the reported viewport → rect doubles.
|
|
const scaled = { ...BOUNDS, width: 3840, height: 2160 };
|
|
// contentRect.x*2=1600 +100 origin → left ~1700; a point at 1700,1900 is inside.
|
|
assert.equal(isCursorOverSubtitle({ x: 1700, y: 1900 }, scaled, MEASUREMENT), true);
|
|
});
|
|
|
|
test('isCursorOverSubtitle returns false without a content rect', () => {
|
|
assert.equal(
|
|
isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, {
|
|
viewport: MEASUREMENT.viewport,
|
|
contentRect: null,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, null), false);
|
|
});
|
|
|
|
test('isCursorOverSubtitle falls back to content rect when interactive rects are empty', () => {
|
|
assert.equal(
|
|
isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, {
|
|
...MEASUREMENT,
|
|
interactiveRects: [],
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
function makeDeps(overrides: Partial<LinuxOverlayPointerInteractionDeps>): {
|
|
deps: LinuxOverlayPointerInteractionDeps;
|
|
state: { active: boolean };
|
|
} {
|
|
const state = { active: false };
|
|
const deps: LinuxOverlayPointerInteractionDeps = {
|
|
getVisibleOverlayVisible: () => true,
|
|
getMainWindow: () => ({
|
|
isDestroyed: () => false,
|
|
isVisible: () => true,
|
|
getBounds: () => BOUNDS,
|
|
}),
|
|
getCursorScreenPoint: () => ({ x: 1000, y: 1040 }),
|
|
getSubtitleMeasurement: () => MEASUREMENT,
|
|
getRendererInteractiveHint: () => false,
|
|
shouldSuspend: () => false,
|
|
getInteractionActive: () => state.active,
|
|
setInteractionActive: (active) => {
|
|
state.active = active;
|
|
},
|
|
...overrides,
|
|
};
|
|
return { deps, state };
|
|
}
|
|
|
|
test('resolveDesiredOverlayInteractive: interactive over subtitle, passthrough off it', () => {
|
|
assert.equal(resolveDesiredOverlayInteractive(makeDeps({}).deps), true);
|
|
assert.equal(
|
|
resolveDesiredOverlayInteractive(
|
|
makeDeps({ getCursorScreenPoint: () => ({ x: 200, y: 200 }) }).deps,
|
|
),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('resolveDesiredOverlayInteractive: renderer hint keeps it interactive off the rect', () => {
|
|
const { deps } = makeDeps({
|
|
getCursorScreenPoint: () => ({ x: 200, y: 200 }),
|
|
getRendererInteractiveHint: () => true,
|
|
});
|
|
assert.equal(resolveDesiredOverlayInteractive(deps), true);
|
|
});
|
|
|
|
test('resolveDesiredOverlayInteractive: hit-tests separate subtitle bars without blocking between them', () => {
|
|
const measurement = {
|
|
viewport: { width: 1920, height: 1080 },
|
|
contentRect: { x: 700, y: 40, width: 520, height: 940 },
|
|
interactiveRects: [
|
|
{ x: 700, y: 40, width: 520, height: 80 },
|
|
{ x: 760, y: 900, width: 400, height: 80 },
|
|
],
|
|
} as unknown as ReturnType<LinuxOverlayPointerInteractionDeps['getSubtitleMeasurement']>;
|
|
|
|
assert.equal(
|
|
resolveDesiredOverlayInteractive(
|
|
makeDeps({
|
|
getCursorScreenPoint: () => ({ x: 900, y: 300 }),
|
|
getSubtitleMeasurement: () => measurement,
|
|
}).deps,
|
|
),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
resolveDesiredOverlayInteractive(
|
|
makeDeps({
|
|
getCursorScreenPoint: () => ({ x: 900, y: 1060 }),
|
|
getSubtitleMeasurement: () => measurement,
|
|
}).deps,
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
resolveDesiredOverlayInteractive(
|
|
makeDeps({
|
|
getCursorScreenPoint: () => ({ x: 900, y: 180 }),
|
|
getSubtitleMeasurement: () => measurement,
|
|
}).deps,
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('mapOverlayMeasurementForPointerInteraction preserves renderer interactive rects', () => {
|
|
const mapped = mapOverlayMeasurementForPointerInteraction({
|
|
layer: 'visible',
|
|
measuredAtMs: 1,
|
|
viewport: { width: 1920, height: 1080 },
|
|
contentRect: { x: 700, y: 40, width: 520, height: 940 },
|
|
interactiveRects: [
|
|
{ x: 700, y: 40, width: 520, height: 80 },
|
|
{ x: 760, y: 900, width: 400, height: 80 },
|
|
],
|
|
});
|
|
|
|
assert.deepEqual(mapped, {
|
|
viewport: { width: 1920, height: 1080 },
|
|
contentRect: { x: 700, y: 40, width: 520, height: 940 },
|
|
interactiveRects: [
|
|
{ x: 700, y: 40, width: 520, height: 80 },
|
|
{ x: 760, y: 900, width: 400, height: 80 },
|
|
],
|
|
});
|
|
});
|
|
|
|
test('shouldSuppressPointerInteractionForForegroundWindow suppresses hover when another app is foreground', () => {
|
|
assert.equal(
|
|
shouldSuppressPointerInteractionForForegroundWindow({
|
|
hasForegroundSeparateWindow: false,
|
|
isTrackingMpvWindow: true,
|
|
isMpvWindowFocused: false,
|
|
isOverlayWindowFocused: false,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
shouldSuppressPointerInteractionForForegroundWindow({
|
|
hasForegroundSeparateWindow: false,
|
|
isTrackingMpvWindow: true,
|
|
isMpvWindowFocused: true,
|
|
isOverlayWindowFocused: false,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
shouldSuppressPointerInteractionForForegroundWindow({
|
|
hasForegroundSeparateWindow: false,
|
|
isTrackingMpvWindow: true,
|
|
isMpvWindowFocused: false,
|
|
isOverlayWindowFocused: true,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('resolveForegroundSuppressionWithGrace ignores a transient startup focus blip', () => {
|
|
// Regression: right after playback starts the overlay can briefly become the X11 active
|
|
// window, so the tracker reports mpv unfocused. Suppressing immediately leaves subtitles
|
|
// inert for ~1s. The grace must hold interaction available until the loss is *stable*.
|
|
const state: ForegroundSuppressionGraceState = { lossSinceMs: null };
|
|
const base = {
|
|
hasForegroundSeparateWindow: false,
|
|
isTrackingMpvWindow: true,
|
|
isMpvWindowFocused: false,
|
|
isOverlayWindowFocused: false,
|
|
graceMs: 500,
|
|
state,
|
|
};
|
|
|
|
// Blip starts: not yet suppressed.
|
|
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_000 }), false);
|
|
// Still within grace.
|
|
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_400 }), false);
|
|
// mpv regains focus before the grace elapses → reset, never suppressed.
|
|
assert.equal(
|
|
resolveForegroundSuppressionWithGrace({ ...base, isMpvWindowFocused: true, nowMs: 1_450 }),
|
|
false,
|
|
);
|
|
assert.equal(state.lossSinceMs, null);
|
|
});
|
|
|
|
test('resolveForegroundSuppressionWithGrace suppresses once foreground loss is stable', () => {
|
|
const state: ForegroundSuppressionGraceState = { lossSinceMs: null };
|
|
const base = {
|
|
hasForegroundSeparateWindow: false,
|
|
isTrackingMpvWindow: true,
|
|
isMpvWindowFocused: false,
|
|
isOverlayWindowFocused: false,
|
|
graceMs: 500,
|
|
state,
|
|
};
|
|
|
|
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_000 }), false);
|
|
// A real app stays foreground past the grace → suppress.
|
|
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_500 }), true);
|
|
});
|
|
|
|
test('resolveForegroundSuppressionWithGrace defers to a separate window immediately', () => {
|
|
const state: ForegroundSuppressionGraceState = { lossSinceMs: 1_000 };
|
|
assert.equal(
|
|
resolveForegroundSuppressionWithGrace({
|
|
hasForegroundSeparateWindow: true,
|
|
isTrackingMpvWindow: true,
|
|
isMpvWindowFocused: true,
|
|
isOverlayWindowFocused: false,
|
|
nowMs: 2_000,
|
|
graceMs: 500,
|
|
state,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(state.lossSinceMs, null);
|
|
});
|
|
|
|
test('shouldSuppressPointerInteractionForForegroundWindow suppresses hover for separate app windows', () => {
|
|
assert.equal(
|
|
shouldSuppressPointerInteractionForForegroundWindow({
|
|
hasForegroundSeparateWindow: true,
|
|
isTrackingMpvWindow: true,
|
|
isMpvWindowFocused: true,
|
|
isOverlayWindowFocused: false,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('resolveDesiredOverlayInteractive: false when overlay hidden, null when suspended/no window', () => {
|
|
assert.equal(
|
|
resolveDesiredOverlayInteractive(makeDeps({ getVisibleOverlayVisible: () => false }).deps),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
resolveDesiredOverlayInteractive(makeDeps({ shouldSuspend: () => true }).deps),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
resolveDesiredOverlayInteractive(makeDeps({ getMainWindow: () => null }).deps),
|
|
null,
|
|
);
|
|
});
|
|
|
|
test('tick only writes interaction state on change', () => {
|
|
const calls: boolean[] = [];
|
|
const { deps, state } = makeDeps({
|
|
setInteractionActive: (active) => {
|
|
calls.push(active);
|
|
state.active = active;
|
|
},
|
|
});
|
|
tickLinuxOverlayPointerInteraction(deps); // off→on
|
|
tickLinuxOverlayPointerInteraction(deps); // no change
|
|
assert.deepEqual(calls, [true]);
|
|
});
|
|
|
|
test('tick does not flip state when suspended (returns null)', () => {
|
|
const calls: boolean[] = [];
|
|
const { deps } = makeDeps({
|
|
getInteractionActive: () => true,
|
|
shouldSuspend: () => true,
|
|
setInteractionActive: (active) => calls.push(active),
|
|
});
|
|
tickLinuxOverlayPointerInteraction(deps);
|
|
assert.deepEqual(calls, []);
|
|
});
|
|
|
|
test('tick clears active hover while a separate SubMiner window suppresses overlay interaction', () => {
|
|
const calls: boolean[] = [];
|
|
const { deps, state } = makeDeps({
|
|
getInteractionActive: () => true,
|
|
shouldSuppressInteraction: () => true,
|
|
setInteractionActive: (active) => {
|
|
calls.push(active);
|
|
state.active = active;
|
|
},
|
|
});
|
|
|
|
state.active = true;
|
|
tickLinuxOverlayPointerInteraction(deps);
|
|
assert.deepEqual(calls, [false]);
|
|
});
|
|
|
|
test('tick skips cursor-driven mouse-ignore toggles when Linux input shape owns hit rects', () => {
|
|
const calls: boolean[] = [];
|
|
const { deps } = makeDeps({
|
|
getInteractionActive: () => false,
|
|
shouldUseInputShape: () => true,
|
|
setInteractionActive: (active) => calls.push(active),
|
|
});
|
|
|
|
tickLinuxOverlayPointerInteraction(deps);
|
|
assert.deepEqual(calls, []);
|
|
});
|
|
|
|
test('applyLinuxOverlayInputShape shapes measured subtitle rects and enables mouse input', () => {
|
|
const calls: string[] = [];
|
|
const window = {
|
|
isDestroyed: () => false,
|
|
isVisible: () => true,
|
|
getBounds: () => ({ ...BOUNDS, width: 3840, height: 2160 }),
|
|
setShape: (rects: Array<{ x: number; y: number; width: number; height: number }>) => {
|
|
calls.push(`shape:${JSON.stringify(rects)}`);
|
|
},
|
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
|
calls.push(`ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
|
},
|
|
};
|
|
|
|
assert.deepEqual(
|
|
applyLinuxOverlayInputShape({
|
|
getVisibleOverlayVisible: () => true,
|
|
getMainWindow: () => window,
|
|
getSubtitleMeasurement: () => MEASUREMENT,
|
|
getRendererInteractiveHint: () => false,
|
|
shouldSuspend: () => false,
|
|
shouldSuppressInteraction: () => false,
|
|
}),
|
|
{ handled: true, active: true },
|
|
);
|
|
assert.deepEqual(calls, [
|
|
'shape:[{"x":1594,"y":1794,"width":652,"height":172}]',
|
|
'ignore:false:plain',
|
|
]);
|
|
});
|
|
|
|
test('applyLinuxOverlayInputShape uses the full window while renderer reports off-rect interaction', () => {
|
|
const calls: string[] = [];
|
|
|
|
assert.deepEqual(
|
|
applyLinuxOverlayInputShape({
|
|
getVisibleOverlayVisible: () => true,
|
|
getMainWindow: () => ({
|
|
isDestroyed: () => false,
|
|
isVisible: () => true,
|
|
getBounds: () => BOUNDS,
|
|
setShape: (rects: Array<{ x: number; y: number; width: number; height: number }>) => {
|
|
calls.push(`shape:${JSON.stringify(rects)}`);
|
|
},
|
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
|
calls.push(`ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
|
},
|
|
}),
|
|
getSubtitleMeasurement: () => null,
|
|
getRendererInteractiveHint: () => true,
|
|
shouldSuspend: () => false,
|
|
}),
|
|
{ handled: true, active: true },
|
|
);
|
|
assert.deepEqual(calls, [
|
|
'shape:[{"x":0,"y":0,"width":1920,"height":1080}]',
|
|
'ignore:false:plain',
|
|
]);
|
|
});
|
|
|
|
test('applyLinuxOverlayInputShape falls back when setShape is unavailable', () => {
|
|
const calls: string[] = [];
|
|
|
|
assert.deepEqual(
|
|
applyLinuxOverlayInputShape({
|
|
getVisibleOverlayVisible: () => true,
|
|
getMainWindow: () => ({
|
|
isDestroyed: () => false,
|
|
isVisible: () => true,
|
|
getBounds: () => BOUNDS,
|
|
setIgnoreMouseEvents: () => {
|
|
calls.push('ignore');
|
|
},
|
|
}),
|
|
getSubtitleMeasurement: () => MEASUREMENT,
|
|
getRendererInteractiveHint: () => false,
|
|
shouldSuspend: () => false,
|
|
}),
|
|
{ handled: false, active: false },
|
|
);
|
|
assert.deepEqual(calls, []);
|
|
});
|
|
|
|
test('applyLinuxOverlayPointerInteractionMousePassthrough toggles mouse input without full visibility refresh', () => {
|
|
const calls: string[] = [];
|
|
const window = {
|
|
isDestroyed: () => false,
|
|
isVisible: () => true,
|
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
|
calls.push(`ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
|
},
|
|
};
|
|
|
|
assert.equal(
|
|
applyLinuxOverlayPointerInteractionMousePassthrough({
|
|
active: true,
|
|
getVisibleOverlayVisible: () => true,
|
|
getMainWindow: () => window,
|
|
shouldSuspend: () => false,
|
|
shouldSuppressInteraction: () => false,
|
|
updateVisibleOverlayVisibility: () => {
|
|
calls.push('full-refresh');
|
|
},
|
|
}),
|
|
true,
|
|
);
|
|
assert.deepEqual(calls, ['ignore:false:plain']);
|
|
});
|
|
|
|
test('applyLinuxOverlayPointerInteractionMousePassthrough falls back when pointer interaction is suppressed', () => {
|
|
const calls: string[] = [];
|
|
|
|
assert.equal(
|
|
applyLinuxOverlayPointerInteractionMousePassthrough({
|
|
active: false,
|
|
getVisibleOverlayVisible: () => true,
|
|
getMainWindow: () => ({
|
|
isDestroyed: () => false,
|
|
isVisible: () => true,
|
|
setIgnoreMouseEvents: () => {
|
|
calls.push('mouse-ignore');
|
|
},
|
|
}),
|
|
shouldSuspend: () => false,
|
|
shouldSuppressInteraction: () => true,
|
|
updateVisibleOverlayVisibility: () => {
|
|
calls.push('full-refresh');
|
|
},
|
|
}),
|
|
false,
|
|
);
|
|
assert.deepEqual(calls, ['full-refresh']);
|
|
});
|