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): { 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; 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']); });