refactor: extract main runtime lifecycle helper builders

This commit is contained in:
2026-02-19 19:57:18 -08:00
parent c9605345bb
commit 45c326db6d
17 changed files with 1249 additions and 247 deletions

View File

@@ -0,0 +1,83 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createBuildCliCommandContextDepsHandler } from './cli-command-context-deps';
test('build cli command context deps maps handlers and values', () => {
const calls: string[] = [];
const buildDeps = createBuildCliCommandContextDepsHandler({
getSocketPath: () => '/tmp/mpv.sock',
setSocketPath: (socketPath) => calls.push(`socket:${socketPath}`),
getMpvClient: () => null,
showOsd: (text) => calls.push(`osd:${text}`),
texthookerService: { start: () => null, status: () => ({ running: false }) } as never,
getTexthookerPort: () => 5174,
setTexthookerPort: (port) => calls.push(`port:${port}`),
shouldOpenBrowser: () => true,
openExternal: async (url) => calls.push(`open:${url}`),
logBrowserOpenError: (url) => calls.push(`open-error:${url}`),
isOverlayInitialized: () => true,
initializeOverlay: () => calls.push('init'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
toggleInvisibleOverlay: () => calls.push('toggle-invisible'),
setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`),
setInvisibleOverlay: (visible) => calls.push(`set-invisible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy'),
startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`),
mineSentenceCard: async () => {
calls.push('mine');
},
startPendingMineSentenceMultiple: (ms) => calls.push(`mine-multi:${ms}`),
updateLastCardFromClipboard: async () => {
calls.push('update');
},
refreshKnownWordCache: async () => {
calls.push('refresh');
},
triggerFieldGrouping: async () => {
calls.push('group');
},
triggerSubsyncFromConfig: async () => {
calls.push('subsync');
},
markLastCardAsAudioCard: async () => {
calls.push('mark');
},
getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => calls.push('clear-token'),
openAnilistSetup: () => calls.push('anilist'),
openJellyfinSetup: () => calls.push('jellyfin'),
getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},
openYomitanSettings: () => calls.push('yomitan'),
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
printHelp: () => calls.push('help'),
stopApp: () => calls.push('stop'),
hasMainWindow: () => true,
getMultiCopyTimeoutMs: () => 5000,
schedule: (fn) => {
fn();
return setTimeout(() => {}, 0);
},
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
});
const deps = buildDeps();
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
assert.equal(deps.getTexthookerPort(), 5174);
assert.equal(deps.shouldOpenBrowser(), true);
assert.equal(deps.isOverlayInitialized(), true);
assert.equal(deps.hasMainWindow(), true);
assert.equal(deps.getMultiCopyTimeoutMs(), 5000);
deps.setSocketPath('/tmp/next.sock');
deps.showOsd('hello');
deps.setTexthookerPort(5175);
deps.printHelp();
assert.deepEqual(calls, ['socket:/tmp/next.sock', 'osd:hello', 'port:5175', 'help']);
});

View File

@@ -0,0 +1,94 @@
import type { CliArgs } from '../../cli/args';
import type { CliCommandContextFactoryDeps } from './cli-command-context';
export function createBuildCliCommandContextDepsHandler(deps: {
getSocketPath: () => string;
setSocketPath: (socketPath: string) => void;
getMpvClient: CliCommandContextFactoryDeps['getMpvClient'];
showOsd: (text: string) => void;
texthookerService: CliCommandContextFactoryDeps['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: CliCommandContextFactoryDeps['getAnilistStatus'];
clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken'];
openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup'];
openJellyfinSetup: CliCommandContextFactoryDeps['openJellyfinSetup'];
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandContextFactoryDeps['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;
}) {
return (): CliCommandContextFactoryDeps => ({
getSocketPath: deps.getSocketPath,
setSocketPath: deps.setSocketPath,
getMpvClient: deps.getMpvClient,
showOsd: deps.showOsd,
texthookerService: deps.texthookerService,
getTexthookerPort: deps.getTexthookerPort,
setTexthookerPort: deps.setTexthookerPort,
shouldOpenBrowser: deps.shouldOpenBrowser,
openExternal: deps.openExternal,
logBrowserOpenError: deps.logBrowserOpenError,
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,
logInfo: deps.logInfo,
logWarn: deps.logWarn,
logError: deps.logError,
});
}

View File

@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createHandleMpvCommandFromIpcHandler,
createRunSubsyncManualFromIpcHandler,
} from './ipc-bridge-actions';
test('handle mpv command handler forwards command and built deps', () => {
const calls: string[] = [];
const deps = {
triggerSubsyncFromConfig: () => {},
openRuntimeOptionsPalette: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
playNextSubtitle: () => {},
sendMpvCommand: () => {},
isMpvConnected: () => true,
hasRuntimeOptionsManager: () => true,
};
const handle = createHandleMpvCommandFromIpcHandler({
handleMpvCommandFromIpcRuntime: (command, nextDeps) => {
calls.push(`command:${command.join(':')}`);
assert.equal(nextDeps, deps);
},
buildMpvCommandDeps: () => deps,
});
handle(['show-text', 'hello']);
assert.deepEqual(calls, ['command:show-text:hello']);
});
test('run subsync manual handler forwards request and result', async () => {
const calls: string[] = [];
const run = createRunSubsyncManualFromIpcHandler({
runManualFromIpc: async (request: { id: string }) => {
calls.push(`request:${request.id}`);
return { ok: true as const };
},
});
const result = await run({ id: 'job-1' });
assert.deepEqual(result, { ok: true });
assert.deepEqual(calls, ['request:job-1']);
});

View File

@@ -0,0 +1,21 @@
import type { MpvCommandFromIpcRuntimeDeps } from '../ipc-mpv-command';
export function createHandleMpvCommandFromIpcHandler(deps: {
handleMpvCommandFromIpcRuntime: (
command: (string | number)[],
options: MpvCommandFromIpcRuntimeDeps,
) => void;
buildMpvCommandDeps: () => MpvCommandFromIpcRuntimeDeps;
}) {
return (command: (string | number)[]): void => {
deps.handleMpvCommandFromIpcRuntime(command, deps.buildMpvCommandDeps());
};
}
export function createRunSubsyncManualFromIpcHandler<TRequest, TResult>(deps: {
runManualFromIpc: (request: TRequest) => Promise<TResult>;
}) {
return async (request: TRequest): Promise<TResult> => {
return deps.runManualFromIpc(request);
};
}

View File

@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createAppendClipboardVideoToQueueHandler,
createHandleOverlayModalClosedHandler,
createSetOverlayVisibleHandler,
createToggleOverlayHandler,
} from './overlay-main-actions';
test('set overlay visible handler delegates to visible overlay setter', () => {
const calls: string[] = [];
const setOverlayVisible = createSetOverlayVisibleHandler({
setVisibleOverlayVisible: (visible) => calls.push(`set:${visible}`),
});
setOverlayVisible(true);
assert.deepEqual(calls, ['set:true']);
});
test('toggle overlay handler delegates to visible toggle', () => {
const calls: string[] = [];
const toggleOverlay = createToggleOverlayHandler({
toggleVisibleOverlay: () => calls.push('toggle'),
});
toggleOverlay();
assert.deepEqual(calls, ['toggle']);
});
test('overlay modal closed handler delegates to runtime handler', () => {
const calls: string[] = [];
const handleClosed = createHandleOverlayModalClosedHandler({
handleOverlayModalClosedRuntime: (modal) => calls.push(`closed:${modal}`),
});
handleClosed('runtime-options');
assert.deepEqual(calls, ['closed:runtime-options']);
});
test('append clipboard queue handler forwards runtime deps and result', () => {
const calls: string[] = [];
const mpvClient = { connected: true };
const appendClipboardVideoToQueue = createAppendClipboardVideoToQueueHandler({
appendClipboardVideoToQueueRuntime: (options) => {
assert.equal(options.getMpvClient(), mpvClient);
assert.equal(options.readClipboardText(), '/tmp/video.mkv');
options.showMpvOsd('queued');
options.sendMpvCommand(['loadfile', '/tmp/video.mkv', 'append']);
return { ok: true, message: 'ok' };
},
getMpvClient: () => mpvClient,
readClipboardText: () => '/tmp/video.mkv',
showMpvOsd: (text) => calls.push(`osd:${text}`),
sendMpvCommand: (command) => calls.push(`mpv:${command.join(':')}`),
});
const result = appendClipboardVideoToQueue();
assert.deepEqual(result, { ok: true, message: 'ok' });
assert.deepEqual(calls, ['osd:queued', 'mpv:loadfile:/tmp/video.mkv:append']);
});

View File

@@ -0,0 +1,47 @@
import type { OverlayHostedModal } from '../overlay-runtime';
import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue';
export function createSetOverlayVisibleHandler(deps: {
setVisibleOverlayVisible: (visible: boolean) => void;
}) {
return (visible: boolean): void => {
deps.setVisibleOverlayVisible(visible);
};
}
export function createToggleOverlayHandler(deps: {
toggleVisibleOverlay: () => void;
}) {
return (): void => {
deps.toggleVisibleOverlay();
};
}
export function createHandleOverlayModalClosedHandler(deps: {
handleOverlayModalClosedRuntime: (modal: OverlayHostedModal) => void;
}) {
return (modal: OverlayHostedModal): void => {
deps.handleOverlayModalClosedRuntime(modal);
};
}
export function createAppendClipboardVideoToQueueHandler(deps: {
appendClipboardVideoToQueueRuntime: (
options: AppendClipboardVideoToQueueRuntimeDeps,
) => { ok: boolean; message: string };
getMpvClient: () => AppendClipboardVideoToQueueRuntimeDeps['getMpvClient'] extends () => infer T
? T
: never;
readClipboardText: () => string;
showMpvOsd: (text: string) => void;
sendMpvCommand: (command: (string | number)[]) => void;
}) {
return (): { ok: boolean; message: string } => {
return deps.appendClipboardVideoToQueueRuntime({
getMpvClient: () => deps.getMpvClient(),
readClipboardText: deps.readClipboardText,
showMpvOsd: deps.showMpvOsd,
sendMpvCommand: deps.sendMpvCommand,
});
};
}

View File

@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options';
test('build initialize overlay runtime options maps dependencies', () => {
const calls: string[] = [];
const buildOptions = createBuildInitializeOverlayRuntimeOptionsHandler({
getBackendOverride: () => 'x11',
getInitialInvisibleOverlayVisibility: () => true,
createMainWindow: () => calls.push('create-main'),
createInvisibleWindow: () => calls.push('create-invisible'),
registerGlobalShortcuts: () => calls.push('register-shortcuts'),
updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'),
updateInvisibleOverlayBounds: () => calls.push('update-invisible-bounds'),
isVisibleOverlayVisible: () => true,
isInvisibleOverlayVisible: () => false,
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
updateInvisibleOverlayVisibility: () => calls.push('update-invisible'),
getOverlayWindows: () => [],
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
setWindowTracker: () => calls.push('set-tracker'),
getResolvedConfig: () => ({}),
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getMpvSocketPath: () => '/tmp/mpv.sock',
getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => calls.push('set-anki'),
showDesktopNotification: () => calls.push('notify'),
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
const options = buildOptions();
assert.equal(options.backendOverride, 'x11');
assert.equal(options.getInitialInvisibleOverlayVisibility(), true);
assert.equal(options.isVisibleOverlayVisible(), true);
assert.equal(options.isInvisibleOverlayVisible(), false);
assert.equal(options.getMpvSocketPath(), '/tmp/mpv.sock');
assert.equal(options.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
options.createMainWindow();
options.createInvisibleWindow();
options.registerGlobalShortcuts();
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
options.updateInvisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
options.updateVisibleOverlayVisibility();
options.updateInvisibleOverlayVisibility();
options.syncOverlayShortcuts();
options.setWindowTracker(null);
options.setAnkiIntegration(null);
options.showDesktopNotification('title', {});
assert.deepEqual(calls, [
'create-main',
'create-invisible',
'register-shortcuts',
'update-visible-bounds',
'update-invisible-bounds',
'update-visible',
'update-invisible',
'sync-shortcuts',
'set-tracker',
'set-anki',
'notify',
]);
});

View File

@@ -0,0 +1,93 @@
import type {
AnkiConnectConfig,
KikuFieldGroupingChoice,
KikuFieldGroupingRequestData,
WindowGeometry,
} from '../../types';
import type { BrowserWindow } from 'electron';
type OverlayRuntimeOptions = {
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: unknown | null) => void;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
getMpvSocketPath: () => string;
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;
};
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
getBackendOverride: () => 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: unknown | null) => void;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
getMpvSocketPath: () => string;
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;
}) {
return (): OverlayRuntimeOptions => ({
backendOverride: deps.getBackendOverride(),
getInitialInvisibleOverlayVisibility: deps.getInitialInvisibleOverlayVisibility,
createMainWindow: deps.createMainWindow,
createInvisibleWindow: deps.createInvisibleWindow,
registerGlobalShortcuts: deps.registerGlobalShortcuts,
updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds,
updateInvisibleOverlayBounds: deps.updateInvisibleOverlayBounds,
isVisibleOverlayVisible: deps.isVisibleOverlayVisible,
isInvisibleOverlayVisible: deps.isInvisibleOverlayVisible,
updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility,
updateInvisibleOverlayVisibility: deps.updateInvisibleOverlayVisibility,
getOverlayWindows: deps.getOverlayWindows,
syncOverlayShortcuts: deps.syncOverlayShortcuts,
setWindowTracker: deps.setWindowTracker,
getResolvedConfig: deps.getResolvedConfig,
getSubtitleTimingTracker: deps.getSubtitleTimingTracker,
getMpvClient: deps.getMpvClient,
getMpvSocketPath: deps.getMpvSocketPath,
getRuntimeOptionsManager: deps.getRuntimeOptionsManager,
setAnkiIntegration: deps.setAnkiIntegration,
showDesktopNotification: deps.showDesktopNotification,
createFieldGroupingCallback: deps.createFieldGroupingCallback,
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
});
}

View File

@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createCreateInvisibleWindowHandler,
createCreateMainWindowHandler,
createCreateOverlayWindowHandler,
} from './overlay-window-factory';
test('create overlay window handler forwards options and kind', () => {
const calls: string[] = [];
const window = { id: 1 };
const createOverlayWindow = createCreateOverlayWindowHandler({
createOverlayWindowCore: (kind, options) => {
calls.push(`kind:${kind}`);
assert.equal(options.isDev, true);
assert.equal(options.overlayDebugVisualizationEnabled, false);
assert.equal(options.isOverlayVisible('visible'), true);
assert.equal(options.isOverlayVisible('invisible'), false);
options.onRuntimeOptionsChanged();
options.setOverlayDebugVisualizationEnabled(true);
options.onWindowClosed(kind);
return window;
},
isDev: true,
getOverlayDebugVisualizationEnabled: () => false,
ensureOverlayWindowLevel: () => {},
onRuntimeOptionsChanged: () => calls.push('runtime-options'),
setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`),
isOverlayVisible: (kind) => kind === 'visible',
tryHandleOverlayShortcutLocalFallback: () => false,
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
});
assert.equal(createOverlayWindow('visible'), window);
assert.deepEqual(calls, ['kind:visible', 'runtime-options', 'debug:true', 'closed:visible']);
});
test('create main window handler stores visible window', () => {
const calls: string[] = [];
const visibleWindow = { id: 'visible' };
const createMainWindow = createCreateMainWindowHandler({
createOverlayWindow: (kind) => {
calls.push(`create:${kind}`);
return visibleWindow;
},
setMainWindow: (window) => calls.push(`set:${(window as { id: string }).id}`),
});
assert.equal(createMainWindow(), visibleWindow);
assert.deepEqual(calls, ['create:visible', 'set:visible']);
});
test('create invisible window handler stores invisible window', () => {
const calls: string[] = [];
const invisibleWindow = { id: 'invisible' };
const createInvisibleWindow = createCreateInvisibleWindowHandler({
createOverlayWindow: (kind) => {
calls.push(`create:${kind}`);
return invisibleWindow;
},
setInvisibleWindow: (window) => calls.push(`set:${(window as { id: string }).id}`),
});
assert.equal(createInvisibleWindow(), invisibleWindow);
assert.deepEqual(calls, ['create:invisible', 'set:invisible']);
});

View File

@@ -0,0 +1,60 @@
type OverlayWindowKind = 'visible' | 'invisible';
export function createCreateOverlayWindowHandler<TWindow>(deps: {
createOverlayWindowCore: (
kind: OverlayWindowKind,
options: {
isDev: boolean;
overlayDebugVisualizationEnabled: boolean;
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
},
) => TWindow;
isDev: boolean;
getOverlayDebugVisualizationEnabled: () => boolean;
ensureOverlayWindowLevel: (window: TWindow) => void;
onRuntimeOptionsChanged: () => void;
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
}) {
return (kind: OverlayWindowKind): TWindow => {
return deps.createOverlayWindowCore(kind, {
isDev: deps.isDev,
overlayDebugVisualizationEnabled: deps.getOverlayDebugVisualizationEnabled(),
ensureOverlayWindowLevel: deps.ensureOverlayWindowLevel,
onRuntimeOptionsChanged: deps.onRuntimeOptionsChanged,
setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled,
isOverlayVisible: deps.isOverlayVisible,
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
onWindowClosed: deps.onWindowClosed,
});
};
}
export function createCreateMainWindowHandler<TWindow>(deps: {
createOverlayWindow: (kind: OverlayWindowKind) => TWindow;
setMainWindow: (window: TWindow | null) => void;
}) {
return (): TWindow => {
const window = deps.createOverlayWindow('visible');
deps.setMainWindow(window);
return window;
};
}
export function createCreateInvisibleWindowHandler<TWindow>(deps: {
createOverlayWindow: (kind: OverlayWindowKind) => TWindow;
setInvisibleWindow: (window: TWindow | null) => void;
}) {
return (): TWindow => {
const window = deps.createOverlayWindow('invisible');
deps.setInvisibleWindow(window);
return window;
};
}

View File

@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createBuildTrayMenuTemplateHandler,
createResolveTrayIconPathHandler,
} from './tray-main-actions';
test('resolve tray icon path handler forwards runtime dependencies', () => {
const calls: string[] = [];
const resolveTrayIconPath = createResolveTrayIconPathHandler({
resolveTrayIconPathRuntime: (options) => {
calls.push(`platform:${options.platform}`);
calls.push(`resources:${options.resourcesPath}`);
calls.push(`app:${options.appPath}`);
calls.push(`dir:${options.dirname}`);
calls.push(`join:${options.joinPath('a', 'b')}`);
calls.push(`exists:${options.fileExists('/tmp/icon.png')}`);
return '/tmp/icon.png';
},
platform: 'darwin',
resourcesPath: '/resources',
appPath: '/app',
dirname: '/dir',
joinPath: (...parts) => parts.join('/'),
fileExists: () => true,
});
assert.equal(resolveTrayIconPath(), '/tmp/icon.png');
assert.deepEqual(calls, [
'platform:darwin',
'resources:/resources',
'app:/app',
'dir:/dir',
'join:a/b',
'exists:true',
]);
});
test('build tray template handler wires actions and init guards', () => {
const calls: string[] = [];
let initialized = false;
const buildTemplate = createBuildTrayMenuTemplateHandler({
buildTrayMenuTemplateRuntime: (handlers) => {
handlers.openOverlay();
handlers.openYomitanSettings();
handlers.openRuntimeOptions();
handlers.openJellyfinSetup();
handlers.openAnilistSetup();
handlers.quitApp();
return [{ label: 'ok' }] as never;
},
initializeOverlayRuntime: () => {
initialized = true;
calls.push('init');
},
isOverlayRuntimeInitialized: () => initialized,
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
openAnilistSetupWindow: () => calls.push('anilist'),
quitApp: () => calls.push('quit'),
});
const template = buildTemplate();
assert.deepEqual(template, [{ label: 'ok' }]);
assert.deepEqual(calls, [
'init',
'visible:true',
'yomitan',
'runtime-options',
'jellyfin',
'anilist',
'quit',
]);
});

View File

@@ -0,0 +1,75 @@
export function createResolveTrayIconPathHandler(deps: {
resolveTrayIconPathRuntime: (options: {
platform: string;
resourcesPath: string;
appPath: string;
dirname: string;
joinPath: (...parts: string[]) => string;
fileExists: (path: string) => boolean;
}) => string | null;
platform: string;
resourcesPath: string;
appPath: string;
dirname: string;
joinPath: (...parts: string[]) => string;
fileExists: (path: string) => boolean;
}) {
return (): string | null => {
return deps.resolveTrayIconPathRuntime({
platform: deps.platform,
resourcesPath: deps.resourcesPath,
appPath: deps.appPath,
dirname: deps.dirname,
joinPath: deps.joinPath,
fileExists: deps.fileExists,
});
};
}
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
openOverlay: () => void;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
openAnilistSetup: () => void;
quitApp: () => void;
}) => TMenuItem[];
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
openAnilistSetupWindow: () => void;
quitApp: () => void;
}) {
return (): TMenuItem[] => {
return deps.buildTrayMenuTemplateRuntime({
openOverlay: () => {
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
deps.setVisibleOverlayVisible(true);
},
openYomitanSettings: () => {
deps.openYomitanSettings();
},
openRuntimeOptions: () => {
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
deps.openRuntimeOptionsPalette();
},
openJellyfinSetup: () => {
deps.openJellyfinSetupWindow();
},
openAnilistSetup: () => {
deps.openAnilistSetupWindow();
},
quitApp: () => {
deps.quitApp();
},
});
};
}

View File

@@ -0,0 +1,86 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createEnsureYomitanExtensionLoadedHandler,
createLoadYomitanExtensionHandler,
} from './yomitan-extension-loader';
test('load yomitan extension handler forwards parser state dependencies', async () => {
const calls: string[] = [];
const parserWindow = {} as never;
const extension = { id: 'ext' } as never;
const loadYomitanExtension = createLoadYomitanExtensionHandler({
loadYomitanExtensionCore: async (options) => {
calls.push(`path:${options.userDataPath}`);
assert.equal(options.getYomitanParserWindow(), parserWindow);
options.setYomitanParserWindow(null);
options.setYomitanParserReadyPromise(null);
options.setYomitanParserInitPromise(null);
options.setYomitanExtension(extension);
return extension;
},
userDataPath: '/tmp/subminer',
getYomitanParserWindow: () => parserWindow,
setYomitanParserWindow: () => calls.push('set-window'),
setYomitanParserReadyPromise: () => calls.push('set-ready'),
setYomitanParserInitPromise: () => calls.push('set-init'),
setYomitanExtension: () => calls.push('set-ext'),
});
assert.equal(await loadYomitanExtension(), extension);
assert.deepEqual(calls, ['path:/tmp/subminer', 'set-window', 'set-ready', 'set-init', 'set-ext']);
});
test('ensure yomitan loader returns existing extension when available', async () => {
const extension = { id: 'ext' } as never;
const ensureLoaded = createEnsureYomitanExtensionLoadedHandler({
getYomitanExtension: () => extension,
getLoadInFlight: () => null,
setLoadInFlight: () => {
throw new Error('unexpected');
},
loadYomitanExtension: async () => {
throw new Error('unexpected');
},
});
assert.equal(await ensureLoaded(), extension);
});
test('ensure yomitan loader reuses in-flight promise', async () => {
const extension = { id: 'ext' } as never;
const inflight = Promise.resolve(extension);
const ensureLoaded = createEnsureYomitanExtensionLoadedHandler({
getYomitanExtension: () => null,
getLoadInFlight: () => inflight,
setLoadInFlight: () => {
throw new Error('unexpected');
},
loadYomitanExtension: async () => {
throw new Error('unexpected');
},
});
assert.equal(await ensureLoaded(), extension);
});
test('ensure yomitan loader starts load and clears in-flight when done', async () => {
const calls: string[] = [];
let inFlight: Promise<any> | null = null;
const extension = { id: 'ext' } as never;
const ensureLoaded = createEnsureYomitanExtensionLoadedHandler({
getYomitanExtension: () => null,
getLoadInFlight: () => inFlight,
setLoadInFlight: (promise) => {
inFlight = promise;
calls.push(promise ? 'set:promise' : 'set:null');
},
loadYomitanExtension: async () => {
calls.push('load');
return extension;
},
});
assert.equal(await ensureLoaded(), extension);
assert.deepEqual(calls, ['load', 'set:promise', 'set:null']);
});

View File

@@ -0,0 +1,48 @@
import type { Extension } from 'electron';
import type { YomitanExtensionLoaderDeps } from '../../core/services/yomitan-extension-loader';
export function createLoadYomitanExtensionHandler(deps: {
loadYomitanExtensionCore: (options: YomitanExtensionLoaderDeps) => Promise<Extension | null>;
userDataPath: YomitanExtensionLoaderDeps['userDataPath'];
getYomitanParserWindow: YomitanExtensionLoaderDeps['getYomitanParserWindow'];
setYomitanParserWindow: YomitanExtensionLoaderDeps['setYomitanParserWindow'];
setYomitanParserReadyPromise: YomitanExtensionLoaderDeps['setYomitanParserReadyPromise'];
setYomitanParserInitPromise: YomitanExtensionLoaderDeps['setYomitanParserInitPromise'];
setYomitanExtension: YomitanExtensionLoaderDeps['setYomitanExtension'];
}) {
return async (): Promise<Extension | null> => {
return deps.loadYomitanExtensionCore({
userDataPath: deps.userDataPath,
getYomitanParserWindow: deps.getYomitanParserWindow,
setYomitanParserWindow: deps.setYomitanParserWindow,
setYomitanParserReadyPromise: deps.setYomitanParserReadyPromise,
setYomitanParserInitPromise: deps.setYomitanParserInitPromise,
setYomitanExtension: deps.setYomitanExtension,
});
};
}
export function createEnsureYomitanExtensionLoadedHandler(deps: {
getYomitanExtension: () => Extension | null;
getLoadInFlight: () => Promise<Extension | null> | null;
setLoadInFlight: (promise: Promise<Extension | null> | null) => void;
loadYomitanExtension: () => Promise<Extension | null>;
}) {
return async (): Promise<Extension | null> => {
const existing = deps.getYomitanExtension();
if (existing) {
return existing;
}
const inFlight = deps.getLoadInFlight();
if (inFlight) {
return inFlight;
}
const promise = deps.loadYomitanExtension().finally(() => {
deps.setLoadInFlight(null);
});
deps.setLoadInFlight(promise);
return promise;
};
}