refactor: split main runtime wrappers into focused modules

This commit is contained in:
2026-02-19 19:08:53 -08:00
parent 1efc0f8650
commit aaa19a33c5
35 changed files with 2347 additions and 263 deletions

View File

@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createMarkLastCardAsAudioCardHandler,
createMineSentenceCardHandler,
createRefreshKnownWordCacheHandler,
createTriggerFieldGroupingHandler,
createUpdateLastCardFromClipboardHandler,
} from './anki-actions';
test('update last card handler forwards integration/clipboard/osd deps', async () => {
const calls: string[] = [];
const integration = {};
const updateLastCard = createUpdateLastCardFromClipboardHandler({
getAnkiIntegration: () => integration,
readClipboardText: () => 'clipboard-value',
showMpvOsd: (text) => calls.push(`osd:${text}`),
updateLastCardFromClipboardCore: async (options) => {
assert.equal(options.ankiIntegration, integration);
assert.equal(options.readClipboardText(), 'clipboard-value');
options.showMpvOsd('ok');
calls.push('core');
},
});
await updateLastCard();
assert.deepEqual(calls, ['osd:ok', 'core']);
});
test('refresh known word cache handler throws when Anki integration missing', async () => {
const refresh = createRefreshKnownWordCacheHandler({
getAnkiIntegration: () => null,
missingIntegrationMessage: 'AnkiConnect integration not enabled',
});
await assert.rejects(() => refresh(), /AnkiConnect integration not enabled/);
});
test('trigger and mark handlers delegate to core services', async () => {
const calls: string[] = [];
const integration = {};
const triggerFieldGrouping = createTriggerFieldGroupingHandler({
getAnkiIntegration: () => integration,
showMpvOsd: (text) => calls.push(`osd:${text}`),
triggerFieldGroupingCore: async (options) => {
assert.equal(options.ankiIntegration, integration);
options.showMpvOsd('group');
calls.push('group-core');
},
});
const markAudio = createMarkLastCardAsAudioCardHandler({
getAnkiIntegration: () => integration,
showMpvOsd: (text) => calls.push(`osd:${text}`),
markLastCardAsAudioCardCore: async (options) => {
assert.equal(options.ankiIntegration, integration);
options.showMpvOsd('mark');
calls.push('mark-core');
},
});
await triggerFieldGrouping();
await markAudio();
assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']);
});
test('mine sentence handler records mined cards only when core returns true', async () => {
const calls: string[] = [];
const integration = {};
const mpvClient = {};
let created = false;
const mineSentenceCard = createMineSentenceCardHandler({
getAnkiIntegration: () => integration,
getMpvClient: () => mpvClient,
showMpvOsd: (text) => calls.push(`osd:${text}`),
mineSentenceCardCore: async (options) => {
assert.equal(options.ankiIntegration, integration);
assert.equal(options.mpvClient, mpvClient);
options.showMpvOsd('mine');
return created;
},
recordCardsMined: (count) => calls.push(`cards:${count}`),
});
created = false;
await mineSentenceCard();
created = true;
await mineSentenceCard();
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']);
});

View File

@@ -0,0 +1,90 @@
type AnkiIntegrationLike = {
refreshKnownWordCache: () => Promise<void>;
};
export function createUpdateLastCardFromClipboardHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
updateLastCardFromClipboardCore: (options: {
ankiIntegration: TAnki;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
}) {
return async (): Promise<void> => {
await deps.updateLastCardFromClipboardCore({
ankiIntegration: deps.getAnkiIntegration(),
readClipboardText: deps.readClipboardText,
showMpvOsd: deps.showMpvOsd,
});
};
}
export function createRefreshKnownWordCacheHandler(deps: {
getAnkiIntegration: () => AnkiIntegrationLike | null;
missingIntegrationMessage: string;
}) {
return async (): Promise<void> => {
const anki = deps.getAnkiIntegration();
if (!anki) {
throw new Error(deps.missingIntegrationMessage);
}
await anki.refreshKnownWordCache();
};
}
export function createTriggerFieldGroupingHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
showMpvOsd: (text: string) => void;
triggerFieldGroupingCore: (options: {
ankiIntegration: TAnki;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
}) {
return async (): Promise<void> => {
await deps.triggerFieldGroupingCore({
ankiIntegration: deps.getAnkiIntegration(),
showMpvOsd: deps.showMpvOsd,
});
};
}
export function createMarkLastCardAsAudioCardHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
showMpvOsd: (text: string) => void;
markLastCardAsAudioCardCore: (options: {
ankiIntegration: TAnki;
showMpvOsd: (text: string) => void;
}) => Promise<void>;
}) {
return async (): Promise<void> => {
await deps.markLastCardAsAudioCardCore({
ankiIntegration: deps.getAnkiIntegration(),
showMpvOsd: deps.showMpvOsd,
});
};
}
export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
getAnkiIntegration: () => TAnki;
getMpvClient: () => TMpv;
showMpvOsd: (text: string) => void;
mineSentenceCardCore: (options: {
ankiIntegration: TAnki;
mpvClient: TMpv;
showMpvOsd: (text: string) => void;
}) => Promise<boolean>;
recordCardsMined: (count: number) => void;
}) {
return async (): Promise<void> => {
const created = await deps.mineSentenceCardCore({
ankiIntegration: deps.getAnkiIntegration(),
mpvClient: deps.getMpvClient(),
showMpvOsd: deps.showMpvOsd,
});
if (created) {
deps.recordCardsMined(1);
}
};
}

View File

@@ -0,0 +1,96 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCliCommandContext } from './cli-command-context';
function createDeps() {
let socketPath = '/tmp/mpv.sock';
const logs: string[] = [];
const browserErrors: string[] = [];
return {
deps: {
getSocketPath: () => socketPath,
setSocketPath: (value: string) => {
socketPath = value;
},
getMpvClient: () => null,
showOsd: () => {},
texthookerService: {} as never,
getTexthookerPort: () => 6677,
setTexthookerPort: () => {},
shouldOpenBrowser: () => true,
openExternal: async () => {},
logBrowserOpenError: (url: string) => browserErrors.push(url),
isOverlayInitialized: () => true,
initializeOverlay: () => {},
toggleVisibleOverlay: () => {},
toggleInvisibleOverlay: () => {},
setVisibleOverlay: () => {},
setInvisibleOverlay: () => {},
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},
mineSentenceCard: async () => {},
startPendingMineSentenceMultiple: () => {},
updateLastCardFromClipboard: async () => {},
refreshKnownWordCache: async () => {},
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
getAnilistStatus: () => ({} as never),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
openJellyfinSetup: () => {},
getAnilistQueueStatus: () => ({} as never),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
runJellyfinCommand: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
stopApp: () => {},
hasMainWindow: () => true,
getMultiCopyTimeoutMs: () => 1000,
schedule: (fn: () => void) => setTimeout(fn, 0),
logInfo: (message: string) => {
logs.push(`i:${message}`);
},
logWarn: (message: string) => {
logs.push(`w:${message}`);
},
logError: (message: string) => {
logs.push(`e:${message}`);
},
},
getLogs: () => logs,
getBrowserErrors: () => browserErrors,
};
}
test('cli command context proxies socket path getters/setters', () => {
const { deps } = createDeps();
const context = createCliCommandContext(deps);
assert.equal(context.getSocketPath(), '/tmp/mpv.sock');
context.setSocketPath('/tmp/next.sock');
assert.equal(context.getSocketPath(), '/tmp/next.sock');
});
test('cli command context openInBrowser reports failures', async () => {
const { deps, getBrowserErrors } = createDeps();
deps.openExternal = async () => {
throw new Error('no browser');
};
const context = createCliCommandContext(deps);
context.openInBrowser('https://example.com');
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(getBrowserErrors(), ['https://example.com']);
});
test('cli command context log methods map to deps loggers', () => {
const { deps, getLogs } = createDeps();
const context = createCliCommandContext(deps);
context.log('info');
context.warn('warn');
context.error('error', new Error('x'));
assert.deepEqual(getLogs(), ['i:info', 'w:warn', 'e:error']);
});

View File

@@ -0,0 +1,106 @@
import type { CliArgs } from '../../cli/args';
import type {
CliCommandRuntimeServiceContext,
CliCommandRuntimeServiceContextHandlers,
} from '../cli-runtime';
type MpvClientLike = CliCommandRuntimeServiceContext['getClient'] extends () => infer T ? T : never;
export type CliCommandContextFactoryDeps = {
getSocketPath: () => string;
setSocketPath: (socketPath: string) => void;
getMpvClient: () => MpvClientLike;
showOsd: (text: string) => void;
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void;
shouldOpenBrowser: () => boolean;
openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void;
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
toggleInvisibleOverlay: () => void;
setVisibleOverlay: (visible: boolean) => void;
setInvisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken'];
openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup'];
openJellyfinSetup: CliCommandRuntimeServiceContext['openJellyfinSetup'];
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
stopApp: () => void;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
};
export function createCliCommandContext(
deps: CliCommandContextFactoryDeps,
): CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers {
return {
getSocketPath: deps.getSocketPath,
setSocketPath: deps.setSocketPath,
getClient: deps.getMpvClient,
showOsd: deps.showOsd,
texthookerService: deps.texthookerService,
getTexthookerPort: deps.getTexthookerPort,
setTexthookerPort: deps.setTexthookerPort,
shouldOpenBrowser: deps.shouldOpenBrowser,
openInBrowser: (url: string) => {
void deps.openExternal(url).catch((error) => {
deps.logBrowserOpenError(url, error);
});
},
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
toggleInvisibleOverlay: deps.toggleInvisibleOverlay,
setVisibleOverlay: deps.setVisibleOverlay,
setInvisibleOverlay: deps.setInvisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy,
mineSentenceCard: deps.mineSentenceCard,
startPendingMineSentenceMultiple: deps.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: deps.updateLastCardFromClipboard,
refreshKnownWordCache: deps.refreshKnownWordCache,
triggerFieldGrouping: deps.triggerFieldGrouping,
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
getAnilistStatus: deps.getAnilistStatus,
clearAnilistToken: deps.clearAnilistToken,
openAnilistSetup: deps.openAnilistSetup,
openJellyfinSetup: deps.openJellyfinSetup,
getAnilistQueueStatus: deps.getAnilistQueueStatus,
retryAnilistQueueNow: deps.retryAnilistQueueNow,
runJellyfinCommand: deps.runJellyfinCommand,
openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
stopApp: deps.stopApp,
hasMainWindow: deps.hasMainWindow,
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
schedule: deps.schedule,
log: deps.logInfo,
warn: deps.logWarn,
error: deps.logError,
};
}

View File

@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createGetConfiguredShortcutsHandler,
createRefreshGlobalAndOverlayShortcutsHandler,
createRegisterGlobalShortcutsHandler,
} from './global-shortcuts';
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
function createShortcuts(): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
toggleInvisibleOverlayGlobal: 'CommandOrControl+Shift+I',
copySubtitle: 's',
copySubtitleMultiple: 'CommandOrControl+s',
updateLastCardFromClipboard: 'c',
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: 'q',
mineSentenceMultiple: 'w',
multiCopyTimeoutMs: 5000,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
};
}
test('configured shortcuts handler resolves from current config', () => {
const calls: string[] = [];
const config = {} as never;
const defaultConfig = {} as never;
const shortcuts = createShortcuts();
const getConfiguredShortcuts = createGetConfiguredShortcutsHandler({
getResolvedConfig: () => config,
defaultConfig,
resolveConfiguredShortcuts: (nextConfig, nextDefaultConfig) => {
calls.push('resolve');
assert.equal(nextConfig, config);
assert.equal(nextDefaultConfig, defaultConfig);
return shortcuts;
},
});
assert.equal(getConfiguredShortcuts(), shortcuts);
assert.deepEqual(calls, ['resolve']);
});
test('register global shortcuts handler passes through callbacks and shortcuts', () => {
const calls: string[] = [];
const shortcuts = createShortcuts();
const mainWindow = {} as never;
const registerGlobalShortcuts = createRegisterGlobalShortcutsHandler({
getConfiguredShortcuts: () => shortcuts,
registerGlobalShortcutsCore: (options) => {
calls.push('register');
assert.equal(options.shortcuts, shortcuts);
assert.equal(options.isDev, true);
assert.equal(options.getMainWindow(), mainWindow);
options.onToggleVisibleOverlay();
options.onToggleInvisibleOverlay();
options.onOpenYomitanSettings();
},
onToggleVisibleOverlay: () => calls.push('toggle-visible'),
onToggleInvisibleOverlay: () => calls.push('toggle-invisible'),
onOpenYomitanSettings: () => calls.push('open-yomitan'),
isDev: true,
getMainWindow: () => mainWindow,
});
registerGlobalShortcuts();
assert.deepEqual(calls, ['register', 'toggle-visible', 'toggle-invisible', 'open-yomitan']);
});
test('refresh global and overlay shortcuts unregisters then re-registers', () => {
const calls: string[] = [];
const refresh = createRefreshGlobalAndOverlayShortcutsHandler({
unregisterAllGlobalShortcuts: () => calls.push('unregister'),
registerGlobalShortcuts: () => calls.push('register'),
syncOverlayShortcuts: () => calls.push('sync-overlay'),
});
refresh();
assert.deepEqual(calls, ['unregister', 'register', 'sync-overlay']);
});

View File

@@ -0,0 +1,48 @@
import type { Config } from '../../types';
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
import type { RegisterGlobalShortcutsServiceOptions } from '../../core/services/shortcut';
export function createGetConfiguredShortcutsHandler(deps: {
getResolvedConfig: () => Config;
defaultConfig: Config;
resolveConfiguredShortcuts: (
config: Config,
defaultConfig: Config,
) => ConfiguredShortcuts;
}) {
return (): ConfiguredShortcuts =>
deps.resolveConfiguredShortcuts(deps.getResolvedConfig(), deps.defaultConfig);
}
export function createRegisterGlobalShortcutsHandler(deps: {
getConfiguredShortcuts: () => RegisterGlobalShortcutsServiceOptions['shortcuts'];
registerGlobalShortcutsCore: (options: RegisterGlobalShortcutsServiceOptions) => void;
onToggleVisibleOverlay: () => void;
onToggleInvisibleOverlay: () => void;
onOpenYomitanSettings: () => void;
isDev: boolean;
getMainWindow: RegisterGlobalShortcutsServiceOptions['getMainWindow'];
}) {
return (): void => {
deps.registerGlobalShortcutsCore({
shortcuts: deps.getConfiguredShortcuts(),
onToggleVisibleOverlay: deps.onToggleVisibleOverlay,
onToggleInvisibleOverlay: deps.onToggleInvisibleOverlay,
onOpenYomitanSettings: deps.onOpenYomitanSettings,
isDev: deps.isDev,
getMainWindow: deps.getMainWindow,
});
};
}
export function createRefreshGlobalAndOverlayShortcutsHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
registerGlobalShortcuts: () => void;
syncOverlayShortcuts: () => void;
}) {
return (): void => {
deps.unregisterAllGlobalShortcuts();
deps.registerGlobalShortcuts();
deps.syncOverlayShortcuts();
};
}

View File

@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createCopyCurrentSubtitleHandler,
createHandleMineSentenceDigitHandler,
createHandleMultiCopyDigitHandler,
} from './mining-actions';
test('multi-copy digit handler forwards tracker/clipboard/osd deps', () => {
const calls: string[] = [];
const tracker = {};
const handleMultiCopyDigit = createHandleMultiCopyDigitHandler({
getSubtitleTimingTracker: () => tracker,
writeClipboardText: (text) => calls.push(`clipboard:${text}`),
showMpvOsd: (text) => calls.push(`osd:${text}`),
handleMultiCopyDigitCore: (count, options) => {
assert.equal(count, 3);
assert.equal(options.subtitleTimingTracker, tracker);
options.writeClipboardText('copied');
options.showMpvOsd('done');
},
});
handleMultiCopyDigit(3);
assert.deepEqual(calls, ['clipboard:copied', 'osd:done']);
});
test('copy current subtitle handler forwards tracker/clipboard/osd deps', () => {
const calls: string[] = [];
const tracker = {};
const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({
getSubtitleTimingTracker: () => tracker,
writeClipboardText: (text) => calls.push(`clipboard:${text}`),
showMpvOsd: (text) => calls.push(`osd:${text}`),
copyCurrentSubtitleCore: (options) => {
assert.equal(options.subtitleTimingTracker, tracker);
options.writeClipboardText('subtitle');
options.showMpvOsd('copied');
},
});
copyCurrentSubtitle();
assert.deepEqual(calls, ['clipboard:subtitle', 'osd:copied']);
});
test('mine sentence digit handler forwards all dependencies', () => {
const calls: string[] = [];
const tracker = {};
const integration = {};
const handleMineSentenceDigit = createHandleMineSentenceDigitHandler({
getSubtitleTimingTracker: () => tracker,
getAnkiIntegration: () => integration,
getCurrentSecondarySubText: () => 'secondary',
showMpvOsd: (text) => calls.push(`osd:${text}`),
logError: (message) => calls.push(`err:${message}`),
onCardsMined: (count) => calls.push(`cards:${count}`),
handleMineSentenceDigitCore: (count, options) => {
assert.equal(count, 4);
assert.equal(options.subtitleTimingTracker, tracker);
assert.equal(options.ankiIntegration, integration);
assert.equal(options.getCurrentSecondarySubText(), 'secondary');
options.showMpvOsd('mine');
options.logError('boom', new Error('x'));
options.onCardsMined(2);
},
});
handleMineSentenceDigit(4);
assert.deepEqual(calls, ['osd:mine', 'err:boom', 'cards:2']);
});

View File

@@ -0,0 +1,73 @@
export function createHandleMultiCopyDigitHandler<TSubtitleTimingTracker>(deps: {
getSubtitleTimingTracker: () => TSubtitleTimingTracker;
writeClipboardText: (text: string) => void;
showMpvOsd: (text: string) => void;
handleMultiCopyDigitCore: (
count: number,
options: {
subtitleTimingTracker: TSubtitleTimingTracker;
writeClipboardText: (text: string) => void;
showMpvOsd: (text: string) => void;
},
) => void;
}) {
return (count: number): void => {
deps.handleMultiCopyDigitCore(count, {
subtitleTimingTracker: deps.getSubtitleTimingTracker(),
writeClipboardText: deps.writeClipboardText,
showMpvOsd: deps.showMpvOsd,
});
};
}
export function createCopyCurrentSubtitleHandler<TSubtitleTimingTracker>(deps: {
getSubtitleTimingTracker: () => TSubtitleTimingTracker;
writeClipboardText: (text: string) => void;
showMpvOsd: (text: string) => void;
copyCurrentSubtitleCore: (options: {
subtitleTimingTracker: TSubtitleTimingTracker;
writeClipboardText: (text: string) => void;
showMpvOsd: (text: string) => void;
}) => void;
}) {
return (): void => {
deps.copyCurrentSubtitleCore({
subtitleTimingTracker: deps.getSubtitleTimingTracker(),
writeClipboardText: deps.writeClipboardText,
showMpvOsd: deps.showMpvOsd,
});
};
}
export function createHandleMineSentenceDigitHandler<TSubtitleTimingTracker, TAnkiIntegration>(
deps: {
getSubtitleTimingTracker: () => TSubtitleTimingTracker;
getAnkiIntegration: () => TAnkiIntegration;
getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void;
onCardsMined: (count: number) => void;
handleMineSentenceDigitCore: (
count: number,
options: {
subtitleTimingTracker: TSubtitleTimingTracker;
ankiIntegration: TAnkiIntegration;
getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void;
onCardsMined: (count: number) => void;
},
) => void;
},
) {
return (count: number): void => {
deps.handleMineSentenceDigitCore(count, {
subtitleTimingTracker: deps.getSubtitleTimingTracker(),
ankiIntegration: deps.getAnkiIntegration(),
getCurrentSecondarySubText: deps.getCurrentSecondarySubText,
showMpvOsd: deps.showMpvOsd,
logError: deps.logError,
onCardsMined: deps.onCardsMined,
});
};
}

View File

@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createBindMpvClientEventHandlers,
createHandleMpvConnectionChangeHandler,
createHandleMpvSubtitleTimingHandler,
} from './mpv-client-event-bindings';
test('mpv connection handler reports stop and quits when disconnect guard passes', () => {
const calls: string[] = [];
const handler = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
hasInitialJellyfinPlayArg: () => true,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => true,
scheduleQuitCheck: (callback) => {
calls.push('schedule');
callback();
},
isMpvConnected: () => false,
quitApp: () => calls.push('quit'),
});
handler({ connected: false });
assert.deepEqual(calls, ['report-stop', 'schedule', 'quit']);
});
test('mpv subtitle timing handler ignores blank subtitle lines', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleTimingHandler({
recordImmersionSubtitleLine: () => calls.push('immersion'),
hasSubtitleTimingTracker: () => true,
recordSubtitleTiming: () => calls.push('timing'),
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('post-watch');
},
logError: () => calls.push('error'),
});
handler({ text: ' ', start: 1, end: 2 });
assert.deepEqual(calls, []);
});
test('mpv event bindings register all expected events', () => {
const seenEvents: string[] = [];
const bindHandlers = createBindMpvClientEventHandlers({
onConnectionChange: () => {},
onSubtitleChange: () => {},
onSubtitleAssChange: () => {},
onSecondarySubtitleChange: () => {},
onSubtitleTiming: () => {},
onMediaPathChange: () => {},
onMediaTitleChange: () => {},
onTimePosChange: () => {},
onPauseChange: () => {},
onSubtitleMetricsChange: () => {},
onSecondarySubtitleVisibility: () => {},
});
bindHandlers({
on: (event) => {
seenEvents.push(event);
},
});
assert.deepEqual(seenEvents, [
'connection-change',
'subtitle-change',
'subtitle-ass-change',
'secondary-subtitle-change',
'subtitle-timing',
'media-path-change',
'media-title-change',
'time-pos-change',
'pause-change',
'subtitle-metrics-change',
'secondary-subtitle-visibility',
]);
});

View File

@@ -0,0 +1,84 @@
type MpvBindingEventName =
| 'connection-change'
| 'subtitle-change'
| 'subtitle-ass-change'
| 'secondary-subtitle-change'
| 'subtitle-timing'
| 'media-path-change'
| 'media-title-change'
| 'time-pos-change'
| 'pause-change'
| 'subtitle-metrics-change'
| 'secondary-subtitle-visibility';
type MpvEventClient = {
on: <K extends MpvBindingEventName>(event: K, handler: (payload: any) => void) => void;
};
export function createHandleMpvConnectionChangeHandler(deps: {
reportJellyfinRemoteStopped: () => void;
hasInitialJellyfinPlayArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean;
scheduleQuitCheck: (callback: () => void) => void;
isMpvConnected: () => boolean;
quitApp: () => void;
}) {
return ({ connected }: { connected: boolean }): void => {
if (connected) return;
deps.reportJellyfinRemoteStopped();
if (!deps.hasInitialJellyfinPlayArg()) return;
if (deps.isOverlayRuntimeInitialized()) return;
if (!deps.isQuitOnDisconnectArmed()) return;
deps.scheduleQuitCheck(() => {
if (deps.isMpvConnected()) return;
deps.quitApp();
});
};
}
export function createHandleMpvSubtitleTimingHandler(deps: {
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
hasSubtitleTimingTracker: () => boolean;
recordSubtitleTiming: (text: string, start: number, end: number) => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
logError: (message: string, error: unknown) => void;
}) {
return ({ text, start, end }: { text: string; start: number; end: number }): void => {
if (!text.trim()) return;
deps.recordImmersionSubtitleLine(text, start, end);
if (!deps.hasSubtitleTimingTracker()) return;
deps.recordSubtitleTiming(text, start, end);
void deps.maybeRunAnilistPostWatchUpdate().catch((error) => {
deps.logError('AniList post-watch update failed unexpectedly', error);
});
};
}
export function createBindMpvClientEventHandlers(deps: {
onConnectionChange: (payload: { connected: boolean }) => void;
onSubtitleChange: (payload: { text: string }) => void;
onSubtitleAssChange: (payload: { text: string }) => void;
onSecondarySubtitleChange: (payload: { text: string }) => void;
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
onMediaPathChange: (payload: { path: string }) => void;
onMediaTitleChange: (payload: { title: string }) => void;
onTimePosChange: (payload: { time: number }) => void;
onPauseChange: (payload: { paused: boolean }) => void;
onSubtitleMetricsChange: (payload: { patch: Record<string, unknown> }) => void;
onSecondarySubtitleVisibility: (payload: { visible: boolean }) => void;
}) {
return (mpvClient: MpvEventClient): void => {
mpvClient.on('connection-change', deps.onConnectionChange);
mpvClient.on('subtitle-change', deps.onSubtitleChange);
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange);
mpvClient.on('subtitle-timing', deps.onSubtitleTiming);
mpvClient.on('media-path-change', deps.onMediaPathChange);
mpvClient.on('media-title-change', deps.onMediaTitleChange);
mpvClient.on('time-pos-change', deps.onTimePosChange);
mpvClient.on('pause-change', deps.onPauseChange);
mpvClient.on('subtitle-metrics-change', deps.onSubtitleMetricsChange);
mpvClient.on('secondary-subtitle-visibility', deps.onSecondarySubtitleVisibility);
};
}

View File

@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMpvClientRuntimeServiceFactory } from './mpv-client-runtime-service';
test('mpv runtime service factory constructs client, binds handlers, and connects', () => {
const calls: string[] = [];
let constructedSocketPath = '';
class FakeClient {
connect(): void {
calls.push('connect');
}
constructor(socketPath: string) {
constructedSocketPath = socketPath;
calls.push('construct');
}
}
const createRuntimeService = createMpvClientRuntimeServiceFactory({
createClient: FakeClient,
socketPath: '/tmp/mpv.sock',
options: {
getResolvedConfig: () => ({}),
autoStartOverlay: true,
setOverlayVisible: () => {},
shouldBindVisibleOverlayToMpvSubVisibility: () => false,
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
bindEventHandlers: () => {
calls.push('bind');
},
});
const client = createRuntimeService();
assert.ok(client instanceof FakeClient);
assert.equal(constructedSocketPath, '/tmp/mpv.sock');
assert.deepEqual(calls, ['construct', 'bind', 'connect']);
});

View File

@@ -0,0 +1,35 @@
type MpvClientCtorBaseOptions = {
getResolvedConfig: () => unknown;
autoStartOverlay: boolean;
setOverlayVisible: (visible: boolean) => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
};
type MpvClientLike = {
connect: () => void;
};
type MpvClientCtor<TClient extends MpvClientLike, TOptions extends MpvClientCtorBaseOptions> = new (
socketPath: string,
options: TOptions,
) => TClient;
export function createMpvClientRuntimeServiceFactory<
TClient extends MpvClientLike,
TOptions extends MpvClientCtorBaseOptions,
>(deps: {
createClient: MpvClientCtor<TClient, TOptions>;
socketPath: string;
options: TOptions;
bindEventHandlers: (client: TClient) => void;
}) {
return (): TClient => {
const mpvClient = new deps.createClient(deps.socketPath, deps.options);
deps.bindEventHandlers(mpvClient);
mpvClient.connect();
return mpvClient;
};
}

View File

@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createAppendToMpvLogHandler, createShowMpvOsdHandler } from './mpv-osd-log';
test('append mpv log writes timestamped message', () => {
const calls: string[] = [];
const appendToMpvLog = createAppendToMpvLogHandler({
logPath: '/tmp/subminer/mpv.log',
dirname: (targetPath) => {
calls.push(`dirname:${targetPath}`);
return '/tmp/subminer';
},
mkdirSync: (targetPath) => {
calls.push(`mkdir:${targetPath}`);
},
appendFileSync: (_targetPath, data) => {
calls.push(`append:${data.trimEnd()}`);
},
now: () => new Date('2026-02-20T00:00:00.000Z'),
});
appendToMpvLog('hello');
assert.deepEqual(calls, [
'dirname:/tmp/subminer/mpv.log',
'mkdir:/tmp/subminer',
'append:[2026-02-20T00:00:00.000Z] hello',
]);
});
test('append mpv log swallows filesystem errors', () => {
const appendToMpvLog = createAppendToMpvLogHandler({
logPath: '/tmp/subminer/mpv.log',
dirname: () => '/tmp/subminer',
mkdirSync: () => {
throw new Error('disk error');
},
appendFileSync: () => {
throw new Error('should not reach');
},
now: () => new Date('2026-02-20T00:00:00.000Z'),
});
assert.doesNotThrow(() => appendToMpvLog('hello'));
});
test('show mpv osd logs marker and forwards fallback logging', () => {
const calls: string[] = [];
const client = { connected: false, send: () => {} } as never;
const showMpvOsd = createShowMpvOsdHandler({
appendToMpvLog: (message) => calls.push(`append:${message}`),
showMpvOsdRuntime: (_client, text, fallbackLog) => {
calls.push(`show:${text}`);
fallbackLog('fallback-line');
},
getMpvClient: () => client,
logInfo: (line) => calls.push(`info:${line}`),
});
showMpvOsd('subtitle copied');
assert.deepEqual(calls, [
'append:[OSD] subtitle copied',
'show:subtitle copied',
'info:fallback-line',
]);
});

View File

@@ -0,0 +1,42 @@
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
export function createAppendToMpvLogHandler(deps: {
logPath: string;
dirname: (targetPath: string) => string;
mkdirSync: (targetPath: string, options: { recursive: boolean }) => void;
appendFileSync: (
targetPath: string,
data: string,
options: { encoding: 'utf8' },
) => void;
now: () => Date;
}) {
return (message: string): void => {
try {
deps.mkdirSync(deps.dirname(deps.logPath), { recursive: true });
deps.appendFileSync(deps.logPath, `[${deps.now().toISOString()}] ${message}\n`, {
encoding: 'utf8',
});
} catch {
// best-effort logging
}
};
}
export function createShowMpvOsdHandler(deps: {
appendToMpvLog: (message: string) => void;
showMpvOsdRuntime: (
mpvClient: MpvRuntimeClientLike | null,
text: string,
fallbackLog: (line: string) => void,
) => void;
getMpvClient: () => MpvRuntimeClientLike | null;
logInfo: (line: string) => void;
}) {
return (text: string): void => {
deps.appendToMpvLog(`[OSD] ${text}`);
deps.showMpvOsdRuntime(deps.getMpvClient(), text, (line) => {
deps.logInfo(line);
});
};
}

View File

@@ -0,0 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createUpdateMpvSubtitleRenderMetricsHandler } from './mpv-subtitle-render-metrics';
import type { MpvSubtitleRenderMetrics } from '../../types';
const BASE_METRICS: MpvSubtitleRenderMetrics = {
subPos: 100,
subFontSize: 36,
subScale: 1,
subMarginY: 0,
subMarginX: 0,
subFont: '',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 0,
subShadowOffset: 0,
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 0,
osdDimensions: null,
};
test('subtitle render metrics handler no-ops when patch does not change state', () => {
let metrics = { ...BASE_METRICS };
let broadcasts = 0;
const updateMetrics = createUpdateMpvSubtitleRenderMetricsHandler({
getCurrentMetrics: () => metrics,
setCurrentMetrics: (next) => {
metrics = next;
},
applyPatch: (current) => ({ next: current, changed: false }),
broadcastMetrics: () => {
broadcasts += 1;
},
});
updateMetrics({});
assert.equal(broadcasts, 0);
});
test('subtitle render metrics handler updates and broadcasts when changed', () => {
let metrics = { ...BASE_METRICS };
let broadcasts = 0;
const updateMetrics = createUpdateMpvSubtitleRenderMetricsHandler({
getCurrentMetrics: () => metrics,
setCurrentMetrics: (next) => {
metrics = next;
},
applyPatch: (current, patch) => ({
next: { ...current, ...patch },
changed: true,
}),
broadcastMetrics: () => {
broadcasts += 1;
},
});
updateMetrics({ subPos: 80 });
assert.equal(metrics.subPos, 80);
assert.equal(broadcasts, 1);
});

View File

@@ -0,0 +1,18 @@
import type { MpvSubtitleRenderMetrics } from '../../types';
export function createUpdateMpvSubtitleRenderMetricsHandler(deps: {
getCurrentMetrics: () => MpvSubtitleRenderMetrics;
setCurrentMetrics: (metrics: MpvSubtitleRenderMetrics) => void;
applyPatch: (
current: MpvSubtitleRenderMetrics,
patch: Partial<MpvSubtitleRenderMetrics>,
) => { next: MpvSubtitleRenderMetrics; changed: boolean };
broadcastMetrics: (metrics: MpvSubtitleRenderMetrics) => void;
}) {
return (patch: Partial<MpvSubtitleRenderMetrics>): void => {
const { next, changed } = deps.applyPatch(deps.getCurrentMetrics(), patch);
if (!changed) return;
deps.setCurrentMetrics(next);
deps.broadcastMetrics(next);
};
}

View File

@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createCancelNumericShortcutSessionHandler,
createStartNumericShortcutSessionHandler,
} from './numeric-shortcut-session-handlers';
test('cancel numeric shortcut session handler cancels active session', () => {
const calls: string[] = [];
const cancel = createCancelNumericShortcutSessionHandler({
session: {
start: () => {},
cancel: () => calls.push('cancel'),
},
});
cancel();
assert.deepEqual(calls, ['cancel']);
});
test('start numeric shortcut session handler forwards timeout, messages, and onDigit', () => {
const calls: string[] = [];
const start = createStartNumericShortcutSessionHandler({
session: {
cancel: () => {},
start: ({ timeoutMs, onDigit, messages }) => {
calls.push(`timeout:${timeoutMs}`);
calls.push(`prompt:${messages.prompt}`);
onDigit(3);
},
},
onDigit: (digit) => calls.push(`digit:${digit}`),
messages: {
prompt: 'Prompt',
timeout: 'Timeout',
cancelled: 'Cancelled',
},
});
start(1200);
assert.deepEqual(calls, ['timeout:1200', 'prompt:Prompt', 'digit:3']);
});

View File

@@ -0,0 +1,31 @@
import type {
NumericShortcutSessionMessages,
NumericShortcutSessionStartParams,
} from '../../core/services/numeric-shortcut';
type NumericShortcutSessionLike = {
start: (params: NumericShortcutSessionStartParams) => void;
cancel: () => void;
};
export function createCancelNumericShortcutSessionHandler(deps: {
session: NumericShortcutSessionLike;
}) {
return (): void => {
deps.session.cancel();
};
}
export function createStartNumericShortcutSessionHandler(deps: {
session: NumericShortcutSessionLike;
onDigit: (digit: number) => void;
messages: NumericShortcutSessionMessages;
}) {
return (timeoutMs: number): void => {
deps.session.start({
timeoutMs,
onDigit: deps.onDigit,
messages: deps.messages,
});
};
}

View File

@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createInitializeOverlayRuntimeHandler } from './overlay-runtime-bootstrap';
test('overlay runtime bootstrap no-ops when already initialized', () => {
let coreCalls = 0;
const initialize = createInitializeOverlayRuntimeHandler({
isOverlayRuntimeInitialized: () => true,
initializeOverlayRuntimeCore: () => {
coreCalls += 1;
return { invisibleOverlayVisible: false };
},
buildOptions: () => ({} as never),
setInvisibleOverlayVisible: () => {},
setOverlayRuntimeInitialized: () => {},
startBackgroundWarmups: () => {},
});
initialize();
assert.equal(coreCalls, 0);
});
test('overlay runtime bootstrap runs core init and applies post-init state', () => {
const calls: string[] = [];
let initialized = false;
const initialize = createInitializeOverlayRuntimeHandler({
isOverlayRuntimeInitialized: () => initialized,
initializeOverlayRuntimeCore: () => {
calls.push('core');
return { invisibleOverlayVisible: true };
},
buildOptions: () => {
calls.push('options');
return {} as never;
},
setInvisibleOverlayVisible: (visible) => {
calls.push(`invisible:${visible ? 'yes' : 'no'}`);
},
setOverlayRuntimeInitialized: (value) => {
initialized = value;
calls.push(`initialized:${value ? 'yes' : 'no'}`);
},
startBackgroundWarmups: () => {
calls.push('warmups');
},
});
initialize();
assert.equal(initialized, true);
assert.deepEqual(calls, ['options', 'core', 'invisible:yes', 'initialized:yes', 'warmups']);
});

View File

@@ -0,0 +1,55 @@
import type { BrowserWindow } from 'electron';
import type { BaseWindowTracker } from '../../window-trackers';
import type {
AnkiConnectConfig,
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
WindowGeometry,
} from '../../types';
type InitializeOverlayRuntimeCore = (options: {
backendOverride: string | null;
getInitialInvisibleOverlayVisibility: () => boolean;
createMainWindow: () => void;
createInvisibleWindow: () => void;
registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
isInvisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
updateInvisibleOverlayVisibility: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
getMpvSocketPath: () => string;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
getRuntimeOptionsManager: () => {
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
} | null;
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
}) => { invisibleOverlayVisible: boolean };
export function createInitializeOverlayRuntimeHandler(deps: {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntimeCore: InitializeOverlayRuntimeCore;
buildOptions: () => Parameters<InitializeOverlayRuntimeCore>[0];
setInvisibleOverlayVisible: (visible: boolean) => void;
setOverlayRuntimeInitialized: (initialized: boolean) => void;
startBackgroundWarmups: () => void;
}) {
return (): void => {
if (deps.isOverlayRuntimeInitialized()) return;
const result = deps.initializeOverlayRuntimeCore(deps.buildOptions());
deps.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
deps.setOverlayRuntimeInitialized(true);
deps.startBackgroundWarmups();
};
}

View File

@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createRefreshOverlayShortcutsHandler,
createRegisterOverlayShortcutsHandler,
createSyncOverlayShortcutsHandler,
createUnregisterOverlayShortcutsHandler,
} from './overlay-shortcuts-lifecycle';
function createRuntime(calls: string[]) {
return {
registerOverlayShortcuts: () => calls.push('register'),
unregisterOverlayShortcuts: () => calls.push('unregister'),
syncOverlayShortcuts: () => calls.push('sync'),
refreshOverlayShortcuts: () => calls.push('refresh'),
};
}
test('register overlay shortcuts handler delegates to runtime', () => {
const calls: string[] = [];
createRegisterOverlayShortcutsHandler({
overlayShortcutsRuntime: createRuntime(calls),
})();
assert.deepEqual(calls, ['register']);
});
test('unregister overlay shortcuts handler delegates to runtime', () => {
const calls: string[] = [];
createUnregisterOverlayShortcutsHandler({
overlayShortcutsRuntime: createRuntime(calls),
})();
assert.deepEqual(calls, ['unregister']);
});
test('sync overlay shortcuts handler delegates to runtime', () => {
const calls: string[] = [];
createSyncOverlayShortcutsHandler({
overlayShortcutsRuntime: createRuntime(calls),
})();
assert.deepEqual(calls, ['sync']);
});
test('refresh overlay shortcuts handler delegates to runtime', () => {
const calls: string[] = [];
createRefreshOverlayShortcutsHandler({
overlayShortcutsRuntime: createRuntime(calls),
})();
assert.deepEqual(calls, ['refresh']);
});

View File

@@ -0,0 +1,38 @@
type OverlayShortcutsRuntimeLike = {
registerOverlayShortcuts: () => void;
unregisterOverlayShortcuts: () => void;
syncOverlayShortcuts: () => void;
refreshOverlayShortcuts: () => void;
};
export function createRegisterOverlayShortcutsHandler(deps: {
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
}) {
return (): void => {
deps.overlayShortcutsRuntime.registerOverlayShortcuts();
};
}
export function createUnregisterOverlayShortcutsHandler(deps: {
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
}) {
return (): void => {
deps.overlayShortcutsRuntime.unregisterOverlayShortcuts();
};
}
export function createSyncOverlayShortcutsHandler(deps: {
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
}) {
return (): void => {
deps.overlayShortcutsRuntime.syncOverlayShortcuts();
};
}
export function createRefreshOverlayShortcutsHandler(deps: {
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
}) {
return (): void => {
deps.overlayShortcutsRuntime.refreshOverlayShortcuts();
};
}

View File

@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createEnforceOverlayLayerOrderHandler,
createEnsureOverlayWindowLevelHandler,
createUpdateInvisibleOverlayBoundsHandler,
createUpdateVisibleOverlayBoundsHandler,
} from './overlay-window-layout';
test('visible bounds handler writes visible layer geometry', () => {
const calls: string[] = [];
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
setOverlayWindowBounds: (layer) => calls.push(layer),
});
handleVisible({ x: 0, y: 0, width: 100, height: 50 });
assert.deepEqual(calls, ['visible']);
});
test('invisible bounds handler writes invisible layer geometry', () => {
const calls: string[] = [];
const handleInvisible = createUpdateInvisibleOverlayBoundsHandler({
setOverlayWindowBounds: (layer) => calls.push(layer),
});
handleInvisible({ x: 0, y: 0, width: 100, height: 50 });
assert.deepEqual(calls, ['invisible']);
});
test('ensure overlay window level handler delegates to core', () => {
const calls: string[] = [];
const ensureLevel = createEnsureOverlayWindowLevelHandler({
ensureOverlayWindowLevelCore: () => calls.push('core'),
});
ensureLevel({});
assert.deepEqual(calls, ['core']);
});
test('enforce overlay layer order handler forwards resolved state', () => {
const calls: string[] = [];
const enforce = createEnforceOverlayLayerOrderHandler({
enforceOverlayLayerOrderCore: (params) => {
calls.push(params.visibleOverlayVisible ? 'visible-on' : 'visible-off');
calls.push(params.invisibleOverlayVisible ? 'invisible-on' : 'invisible-off');
params.ensureOverlayWindowLevel({});
},
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
getMainWindow: () => ({}),
getInvisibleWindow: () => ({}),
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
});
enforce();
assert.deepEqual(calls, ['visible-on', 'invisible-off', 'ensure-level']);
});

View File

@@ -0,0 +1,50 @@
import type { WindowGeometry } from '../../types';
export function createUpdateVisibleOverlayBoundsHandler(deps: {
setOverlayWindowBounds: (layer: 'visible' | 'invisible', geometry: WindowGeometry) => void;
}) {
return (geometry: WindowGeometry): void => {
deps.setOverlayWindowBounds('visible', geometry);
};
}
export function createUpdateInvisibleOverlayBoundsHandler(deps: {
setOverlayWindowBounds: (layer: 'visible' | 'invisible', geometry: WindowGeometry) => void;
}) {
return (geometry: WindowGeometry): void => {
deps.setOverlayWindowBounds('invisible', geometry);
};
}
export function createEnsureOverlayWindowLevelHandler(deps: {
ensureOverlayWindowLevelCore: (window: unknown) => void;
}) {
return (window: unknown): void => {
deps.ensureOverlayWindowLevelCore(window);
};
}
export function createEnforceOverlayLayerOrderHandler(deps: {
enforceOverlayLayerOrderCore: (params: {
visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean;
mainWindow: unknown;
invisibleWindow: unknown;
ensureOverlayWindowLevel: (window: unknown) => void;
}) => void;
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
getMainWindow: () => unknown;
getInvisibleWindow: () => unknown;
ensureOverlayWindowLevel: (window: unknown) => void;
}) {
return (): void => {
deps.enforceOverlayLayerOrderCore({
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
invisibleOverlayVisible: deps.getInvisibleOverlayVisible(),
mainWindow: deps.getMainWindow(),
invisibleWindow: deps.getInvisibleWindow(),
ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel,
});
};
}

View File

@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createLaunchBackgroundWarmupTaskHandler,
createStartBackgroundWarmupsHandler,
} from './startup-warmups';
test('launchBackgroundWarmupTask logs completion timing', async () => {
const debugLogs: string[] = [];
const launchTask = createLaunchBackgroundWarmupTaskHandler({
now: (() => {
let tick = 0;
return () => ++tick * 10;
})(),
logDebug: (message) => debugLogs.push(message),
logWarn: () => {},
});
launchTask('demo', async () => {});
await Promise.resolve();
assert.ok(debugLogs.some((line) => line.includes('[startup-warmup] demo completed in')));
});
test('startBackgroundWarmups no-ops when already started', () => {
let launches = 0;
const startWarmups = createStartBackgroundWarmupsHandler({
getStarted: () => true,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
launchTask: () => {
launches += 1;
},
createMecabTokenizerAndCheck: async () => {},
ensureYomitanExtensionLoaded: async () => {},
prewarmSubtitleDictionaries: async () => {},
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
});
startWarmups();
assert.equal(launches, 0);
});
test('startBackgroundWarmups schedules base warmups and optional jellyfin warmup', () => {
const labels: string[] = [];
let started = false;
const startWarmups = createStartBackgroundWarmupsHandler({
getStarted: () => started,
setStarted: (value) => {
started = value;
},
isTexthookerOnlyMode: () => false,
launchTask: (label) => {
labels.push(label);
},
createMecabTokenizerAndCheck: async () => {},
ensureYomitanExtensionLoaded: async () => {},
prewarmSubtitleDictionaries: async () => {},
shouldAutoConnectJellyfinRemote: () => true,
startJellyfinRemoteSession: async () => {},
});
startWarmups();
assert.equal(started, true);
assert.deepEqual(labels, [
'mecab',
'yomitan-extension',
'subtitle-dictionaries',
'jellyfin-remote-session',
]);
});

View File

@@ -0,0 +1,50 @@
export function createLaunchBackgroundWarmupTaskHandler(deps: {
now: () => number;
logDebug: (message: string) => void;
logWarn: (message: string) => void;
}) {
return (label: string, task: () => Promise<void>): void => {
const startedAtMs = deps.now();
void task()
.then(() => {
deps.logDebug(`[startup-warmup] ${label} completed in ${deps.now() - startedAtMs}ms`);
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
deps.logWarn(`[startup-warmup] ${label} failed: ${message}`);
});
};
}
export function createStartBackgroundWarmupsHandler(deps: {
getStarted: () => boolean;
setStarted: (started: boolean) => void;
isTexthookerOnlyMode: () => boolean;
launchTask: (label: string, task: () => Promise<void>) => void;
createMecabTokenizerAndCheck: () => Promise<void>;
ensureYomitanExtensionLoaded: () => Promise<void>;
prewarmSubtitleDictionaries: () => Promise<void>;
shouldAutoConnectJellyfinRemote: () => boolean;
startJellyfinRemoteSession: () => Promise<void>;
}) {
return (): void => {
if (deps.getStarted()) return;
if (deps.isTexthookerOnlyMode()) return;
deps.setStarted(true);
deps.launchTask('mecab', async () => {
await deps.createMecabTokenizerAndCheck();
});
deps.launchTask('yomitan-extension', async () => {
await deps.ensureYomitanExtensionLoaded();
});
deps.launchTask('subtitle-dictionaries', async () => {
await deps.prewarmSubtitleDictionaries();
});
if (deps.shouldAutoConnectJellyfinRemote()) {
deps.launchTask('jellyfin-remote-session', async () => {
await deps.startJellyfinRemoteSession();
});
}
};
}

View File

@@ -0,0 +1,119 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDestroyTrayHandler, createEnsureTrayHandler } from './tray-lifecycle';
test('ensure tray updates menu when tray already exists', () => {
const calls: string[] = [];
const tray = {
setContextMenu: () => calls.push('set-menu'),
setToolTip: () => calls.push('set-tooltip'),
on: () => calls.push('bind-click'),
destroy: () => calls.push('destroy'),
};
const ensureTray = createEnsureTrayHandler({
getTray: () => tray,
setTray: () => calls.push('set-tray'),
buildTrayMenu: () => ({}),
resolveTrayIconPath: () => null,
createImageFromPath: () =>
({
isEmpty: () => false,
resize: () => {
throw new Error('should not resize');
},
setTemplateImage: () => {},
}) as never,
createEmptyImage: () =>
({
isEmpty: () => true,
resize: () => {
throw new Error('should not resize');
},
setTemplateImage: () => {},
}) as never,
createTray: () => tray as never,
trayTooltip: 'SubMiner',
platform: 'darwin',
logWarn: () => calls.push('warn'),
ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'),
});
ensureTray();
assert.deepEqual(calls, ['set-menu']);
});
test('ensure tray creates new tray and binds click handler', () => {
const calls: string[] = [];
let trayRef: unknown = null;
const ensureTray = createEnsureTrayHandler({
getTray: () => null,
setTray: (tray) => {
trayRef = tray;
calls.push('set-tray');
},
buildTrayMenu: () => ({ id: 'menu' }),
resolveTrayIconPath: () => '/tmp/icon.png',
createImageFromPath: () =>
({
isEmpty: () => false,
resize: (options: { width: number; height: number; quality?: 'best' | 'better' | 'good' }) => {
calls.push(`resize:${options.width}x${options.height}`);
return {
isEmpty: () => false,
resize: () => {
throw new Error('unexpected');
},
setTemplateImage: () => calls.push('template'),
};
},
setTemplateImage: () => calls.push('template'),
}) as never,
createEmptyImage: () =>
({
isEmpty: () => true,
resize: () => {
throw new Error('unexpected');
},
setTemplateImage: () => {},
}) as never,
createTray: () =>
({
setContextMenu: () => calls.push('set-menu'),
setToolTip: () => calls.push('set-tooltip'),
on: (_event: 'click', _handler: () => void) => {
calls.push('bind-click');
},
destroy: () => calls.push('destroy'),
}) as never,
trayTooltip: 'SubMiner',
platform: 'darwin',
logWarn: () => calls.push('warn'),
ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'),
});
ensureTray();
assert.ok(trayRef);
assert.ok(calls.includes('set-tray'));
assert.ok(calls.includes('set-menu'));
assert.ok(calls.includes('bind-click'));
});
test('destroy tray handler destroys active tray and clears ref', () => {
const calls: string[] = [];
let tray: { destroy: () => void } | null = {
destroy: () => calls.push('destroy'),
};
const destroyTray = createDestroyTrayHandler({
getTray: () => tray as never,
setTray: (next) => {
tray = next as never;
calls.push('set-null');
},
});
destroyTray();
assert.deepEqual(calls, ['destroy', 'set-null']);
assert.equal(tray, null);
});

View File

@@ -0,0 +1,67 @@
type TrayIconLike = {
isEmpty: () => boolean;
resize: (options: { width: number; height: number; quality?: 'best' | 'better' | 'good' }) => TrayIconLike;
setTemplateImage: (enabled: boolean) => void;
};
type TrayLike = {
setContextMenu: (menu: any) => void;
setToolTip: (tooltip: string) => void;
on: (event: 'click', handler: () => void) => void;
destroy: () => void;
};
export function createEnsureTrayHandler(deps: {
getTray: () => TrayLike | null;
setTray: (tray: TrayLike | null) => void;
buildTrayMenu: () => any;
resolveTrayIconPath: () => string | null;
createImageFromPath: (iconPath: string) => TrayIconLike;
createEmptyImage: () => TrayIconLike;
createTray: (icon: TrayIconLike) => TrayLike;
trayTooltip: string;
platform: string;
logWarn: (message: string) => void;
ensureOverlayVisibleFromTrayClick: () => void;
}) {
return (): void => {
const existingTray = deps.getTray();
if (existingTray) {
existingTray.setContextMenu(deps.buildTrayMenu());
return;
}
const iconPath = deps.resolveTrayIconPath();
let trayIcon = iconPath ? deps.createImageFromPath(iconPath) : deps.createEmptyImage();
if (trayIcon.isEmpty()) {
deps.logWarn('Tray icon asset not found; using empty icon placeholder.');
}
if (deps.platform === 'darwin' && !trayIcon.isEmpty()) {
trayIcon = trayIcon.resize({ width: 18, height: 18, quality: 'best' });
trayIcon.setTemplateImage(true);
}
if (deps.platform === 'linux' && !trayIcon.isEmpty()) {
trayIcon = trayIcon.resize({ width: 20, height: 20 });
}
const tray = deps.createTray(trayIcon);
tray.setToolTip(deps.trayTooltip);
tray.setContextMenu(deps.buildTrayMenu());
tray.on('click', () => {
deps.ensureOverlayVisibleFromTrayClick();
});
deps.setTray(tray);
};
}
export function createDestroyTrayHandler(deps: {
getTray: () => TrayLike | null;
setTray: (tray: TrayLike | null) => void;
}) {
return (): void => {
const tray = deps.getTray();
if (!tray) return;
tray.destroy();
deps.setTray(null);
};
}

View File

@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildTrayMenuTemplateRuntime, resolveTrayIconPathRuntime } from './tray-runtime';
test('resolve tray icon picks template icon first on darwin', () => {
const path = resolveTrayIconPathRuntime({
platform: 'darwin',
resourcesPath: '/res',
appPath: '/app',
dirname: '/dist/main',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) => candidate.endsWith('/res/assets/SubMinerTemplate.png'),
});
assert.equal(path, '/res/assets/SubMinerTemplate.png');
});
test('resolve tray icon returns null when no asset exists', () => {
const path = resolveTrayIconPathRuntime({
platform: 'linux',
resourcesPath: '/res',
appPath: '/app',
dirname: '/dist/main',
joinPath: (...parts) => parts.join('/'),
fileExists: () => false,
});
assert.equal(path, null);
});
test('tray menu template contains expected entries and handlers', () => {
const calls: string[] = [];
const template = buildTrayMenuTemplateRuntime({
openOverlay: () => calls.push('overlay'),
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptions: () => calls.push('runtime'),
openJellyfinSetup: () => calls.push('jellyfin'),
openAnilistSetup: () => calls.push('anilist'),
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 7);
template[0].click?.();
template[5].type === 'separator' ? calls.push('separator') : calls.push('bad');
template[6].click?.();
assert.deepEqual(calls, ['overlay', 'separator', 'quit']);
});

View File

@@ -0,0 +1,73 @@
export function resolveTrayIconPathRuntime(deps: {
platform: string;
resourcesPath: string;
appPath: string;
dirname: string;
joinPath: (...parts: string[]) => string;
fileExists: (path: string) => boolean;
}): string | null {
const iconNames =
deps.platform === 'darwin'
? ['SubMinerTemplate.png', 'SubMinerTemplate@2x.png', 'SubMiner.png']
: ['SubMiner.png'];
const baseDirs = [
deps.joinPath(deps.resourcesPath, 'assets'),
deps.joinPath(deps.appPath, 'assets'),
deps.joinPath(deps.dirname, '..', 'assets'),
deps.joinPath(deps.dirname, '..', '..', 'assets'),
];
for (const baseDir of baseDirs) {
for (const iconName of iconNames) {
const candidate = deps.joinPath(baseDir, iconName);
if (deps.fileExists(candidate)) {
return candidate;
}
}
}
return null;
}
export type TrayMenuActionHandlers = {
openOverlay: () => void;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
openAnilistSetup: () => void;
quitApp: () => void;
};
export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): Array<{
label?: string;
type?: 'separator';
click?: () => void;
}> {
return [
{
label: 'Open Overlay',
click: handlers.openOverlay,
},
{
label: 'Open Yomitan Settings',
click: handlers.openYomitanSettings,
},
{
label: 'Open Runtime Options',
click: handlers.openRuntimeOptions,
},
{
label: 'Configure Jellyfin',
click: handlers.openJellyfinSetup,
},
{
label: 'Configure AniList',
click: handlers.openAnilistSetup,
},
{ type: 'separator' },
{
label: 'Quit',
click: handlers.quitApp,
},
];
}

View File

@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createOpenYomitanSettingsHandler } from './yomitan-settings-opener';
test('yomitan opener warns when extension cannot be loaded', async () => {
const logs: string[] = [];
const openSettings = createOpenYomitanSettingsHandler({
ensureYomitanExtensionLoaded: async () => null,
openYomitanSettingsWindow: () => {
throw new Error('should not open');
},
getExistingWindow: () => null,
setWindow: () => {},
logWarn: (message) => logs.push(message),
logError: () => logs.push('error'),
});
openSettings();
await Promise.resolve();
await Promise.resolve();
assert.ok(logs.includes('Unable to open Yomitan settings: extension failed to load.'));
});
test('yomitan opener opens settings window when extension is available', async () => {
let opened = false;
const openSettings = createOpenYomitanSettingsHandler({
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
openYomitanSettingsWindow: () => {
opened = true;
},
getExistingWindow: () => null,
setWindow: () => {},
logWarn: () => {},
logError: () => {},
});
openSettings();
await Promise.resolve();
await Promise.resolve();
assert.equal(opened, true);
});

View File

@@ -0,0 +1,32 @@
type YomitanExtensionLike = unknown;
type BrowserWindowLike = unknown;
export function createOpenYomitanSettingsHandler(deps: {
ensureYomitanExtensionLoaded: () => Promise<YomitanExtensionLike | null>;
openYomitanSettingsWindow: (params: {
yomitanExt: YomitanExtensionLike;
getExistingWindow: () => BrowserWindowLike | null;
setWindow: (window: BrowserWindowLike | null) => void;
}) => void;
getExistingWindow: () => BrowserWindowLike | null;
setWindow: (window: BrowserWindowLike | null) => void;
logWarn: (message: string) => void;
logError: (message: string, error: unknown) => void;
}) {
return (): void => {
void (async () => {
const extension = await deps.ensureYomitanExtensionLoaded();
if (!extension) {
deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
return;
}
deps.openYomitanSettingsWindow({
yomitanExt: extension,
getExistingWindow: deps.getExistingWindow,
setWindow: deps.setWindow,
});
})().catch((error) => {
deps.logError('Failed to open Yomitan settings window.', error);
});
};
}