refactor: extract runtime dependency builders from main

This commit is contained in:
2026-02-19 23:38:23 -08:00
parent 0d7b65ec88
commit df380ed1ca
19 changed files with 1221 additions and 212 deletions

View File

@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ReloadConfigStrictResult } from '../../config';
import type { ResolvedConfig } from '../../types';
import {
createBuildConfigHotReloadAppliedMainDepsHandler,
createBuildConfigHotReloadRuntimeMainDepsHandler,
createWatchConfigPathHandler,
} from './config-hot-reload-main-deps';
test('watch config path handler watches file directly when config exists', () => {
const calls: string[] = [];
const watchConfigPath = createWatchConfigPathHandler({
fileExists: () => true,
dirname: (path) => path.split('/').slice(0, -1).join('/'),
watchPath: (targetPath, nextListener) => {
calls.push(`watch:${targetPath}`);
nextListener('change', 'ignored');
return { close: () => calls.push('close') };
},
});
const watcher = watchConfigPath('/tmp/config.jsonc', () => calls.push('change'));
watcher.close();
assert.deepEqual(calls, ['watch:/tmp/config.jsonc', 'change', 'close']);
});
test('watch config path handler filters directory events to config files only', () => {
const calls: string[] = [];
const watchConfigPath = createWatchConfigPathHandler({
fileExists: () => false,
dirname: (path) => path.split('/').slice(0, -1).join('/'),
watchPath: (_targetPath, nextListener) => {
nextListener('change', 'foo.txt');
nextListener('change', 'config.json');
nextListener('change', 'config.jsonc');
nextListener('change', null);
return { close: () => {} };
},
});
watchConfigPath('/tmp/config.jsonc', () => calls.push('change'));
assert.deepEqual(calls, ['change', 'change', 'change']);
});
test('config hot reload applied main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildConfigHotReloadAppliedMainDepsHandler({
setKeybindings: () => calls.push('keybindings'),
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'),
setSecondarySubMode: () => calls.push('set-secondary'),
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'),
})();
deps.setKeybindings([]);
deps.refreshGlobalAndOverlayShortcuts();
deps.setSecondarySubMode('hover');
deps.broadcastToOverlayWindows('config:hot-reload', {});
deps.applyAnkiRuntimeConfigPatch({ ai: {} as never });
assert.deepEqual(calls, [
'keybindings',
'refresh-shortcuts',
'set-secondary',
'broadcast:config:hot-reload',
'apply-anki',
]);
});
test('config hot reload runtime main deps builder maps runtime callbacks', () => {
const calls: string[] = [];
const deps = createBuildConfigHotReloadRuntimeMainDepsHandler({
getCurrentConfig: () => ({ id: 1 } as never as ResolvedConfig),
reloadConfigStrict: () =>
({
ok: true,
config: { id: 1 } as never as ResolvedConfig,
warnings: [],
path: '/tmp/config.jsonc',
}) as ReloadConfigStrictResult,
watchConfigPath: (_configPath, _onChange) => ({ close: () => calls.push('close') }),
setTimeout: (callback) => {
callback();
return 1 as never;
},
clearTimeout: () => calls.push('clear-timeout'),
debounceMs: 250,
onHotReloadApplied: () => calls.push('hot-reload'),
onRestartRequired: () => calls.push('restart-required'),
onInvalidConfig: () => calls.push('invalid-config'),
onValidationWarnings: () => calls.push('validation-warnings'),
})();
assert.deepEqual(deps.getCurrentConfig(), { id: 1 });
assert.deepEqual(deps.reloadConfigStrict(), {
ok: true,
config: { id: 1 },
warnings: [],
path: '/tmp/config.jsonc',
});
assert.equal(deps.debounceMs, 250);
deps.onHotReloadApplied({} as never, {} as never);
deps.onRestartRequired([]);
deps.onInvalidConfig('bad');
deps.onValidationWarnings('/tmp/config.jsonc', []);
assert.deepEqual(calls, ['hot-reload', 'restart-required', 'invalid-config', 'validation-warnings']);
});

View File

@@ -0,0 +1,79 @@
import type {
ConfigHotReloadDiff,
ConfigHotReloadRuntimeDeps,
} from '../../core/services/config-hot-reload';
import type { ReloadConfigStrictResult } from '../../config';
import type { ConfigHotReloadPayload, ConfigValidationWarning, ResolvedConfig, SecondarySubMode } from '../../types';
type ConfigWatchListener = (eventType: string, filename: string | null) => void;
export function createWatchConfigPathHandler(deps: {
fileExists: (path: string) => boolean;
dirname: (path: string) => string;
watchPath: (targetPath: string, listener: ConfigWatchListener) => { close: () => void };
}) {
return (configPath: string, onChange: () => void): { close: () => void } => {
const watchTarget = deps.fileExists(configPath) ? configPath : deps.dirname(configPath);
const watcher = deps.watchPath(watchTarget, (_eventType, filename) => {
if (watchTarget === configPath) {
onChange();
return;
}
const normalized =
typeof filename === 'string' ? filename : filename ? String(filename) : undefined;
if (!normalized || normalized === 'config.json' || normalized === 'config.jsonc') {
onChange();
}
});
return {
close: () => watcher.close(),
};
};
}
export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => void;
}) {
return () => ({
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
deps.setKeybindings(keybindings),
refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(),
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
broadcastToOverlayWindows: (channel: string, payload: unknown) =>
deps.broadcastToOverlayWindows(channel, payload),
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) =>
deps.applyAnkiRuntimeConfigPatch(patch),
});
}
export function createBuildConfigHotReloadRuntimeMainDepsHandler(deps: {
getCurrentConfig: () => ResolvedConfig;
reloadConfigStrict: () => ReloadConfigStrictResult;
watchConfigPath: ConfigHotReloadRuntimeDeps['watchConfigPath'];
setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout;
clearTimeout: (timeout: NodeJS.Timeout) => void;
debounceMs: number;
onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void;
onRestartRequired: (fields: string[]) => void;
onInvalidConfig: (message: string) => void;
onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) => void;
}) {
return (): ConfigHotReloadRuntimeDeps => ({
getCurrentConfig: () => deps.getCurrentConfig(),
reloadConfigStrict: () => deps.reloadConfigStrict(),
watchConfigPath: (configPath, onChange) => deps.watchConfigPath(configPath, onChange),
setTimeout: (callback: () => void, delayMs: number) => deps.setTimeout(callback, delayMs),
clearTimeout: (timeout: NodeJS.Timeout) => deps.clearTimeout(timeout),
debounceMs: deps.debounceMs,
onHotReloadApplied: (diff, config) => deps.onHotReloadApplied(diff, config),
onRestartRequired: (fields: string[]) => deps.onRestartRequired(fields),
onInvalidConfig: (message: string) => deps.onInvalidConfig(message),
onValidationWarnings: (configPath: string, warnings: ConfigValidationWarning[]) =>
deps.onValidationWarnings(configPath, warnings),
});
}

View File

@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildDictionaryRootsMainHandler,
createBuildFrequencyDictionaryRootsMainHandler,
createBuildFrequencyDictionaryRuntimeMainDepsHandler,
createBuildJlptDictionaryRuntimeMainDepsHandler,
} from './dictionary-runtime-main-deps';
test('dictionary roots main handler returns expected root list', () => {
const roots = createBuildDictionaryRootsMainHandler({
dirname: '/repo/dist/main',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
userDataPath: '/Users/a/.config/SubMiner',
appUserDataPath: '/Users/a/Library/Application Support/SubMiner',
homeDir: '/Users/a',
cwd: '/repo',
joinPath: (...parts) => parts.join('/'),
})();
assert.equal(roots.length, 11);
assert.equal(roots[0], '/repo/dist/main/../../vendor/yomitan-jlpt-vocab');
assert.equal(roots[10], '/repo');
});
test('jlpt dictionary runtime main deps builder maps search paths and log prefix', () => {
const calls: string[] = [];
const deps = createBuildJlptDictionaryRuntimeMainDepsHandler({
isJlptEnabled: () => true,
getDictionaryRoots: () => ['/root/a'],
getJlptDictionarySearchPaths: ({ getDictionaryRoots }) => getDictionaryRoots().map((path) => `${path}/jlpt`),
setJlptLevelLookup: () => calls.push('set-lookup'),
logInfo: (message) => calls.push(`log:${message}`),
})();
assert.equal(deps.isJlptEnabled(), true);
assert.deepEqual(deps.getSearchPaths(), ['/root/a/jlpt']);
deps.setJlptLevelLookup(() => null);
deps.log('loaded');
assert.deepEqual(calls, ['set-lookup', 'log:[JLPT] loaded']);
});
test('frequency dictionary roots main handler returns expected root list', () => {
const roots = createBuildFrequencyDictionaryRootsMainHandler({
dirname: '/repo/dist/main',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
userDataPath: '/Users/a/.config/SubMiner',
appUserDataPath: '/Users/a/Library/Application Support/SubMiner',
homeDir: '/Users/a',
cwd: '/repo',
joinPath: (...parts) => parts.join('/'),
})();
assert.equal(roots.length, 15);
assert.equal(roots[0], '/repo/dist/main/../../vendor/jiten_freq_global');
assert.equal(roots[14], '/repo');
});
test('frequency dictionary runtime main deps builder maps search paths/source and log prefix', () => {
const calls: string[] = [];
const deps = createBuildFrequencyDictionaryRuntimeMainDepsHandler({
isFrequencyDictionaryEnabled: () => true,
getDictionaryRoots: () => ['/root/a', ''],
getFrequencyDictionarySearchPaths: ({ getDictionaryRoots, getSourcePath }) => [
...getDictionaryRoots().map((path) => `${path}/freq`),
getSourcePath() || '',
],
getSourcePath: () => '/custom/freq.json',
setFrequencyRankLookup: () => calls.push('set-rank'),
logInfo: (message) => calls.push(`log:${message}`),
})();
assert.equal(deps.isFrequencyDictionaryEnabled(), true);
assert.deepEqual(deps.getSearchPaths(), ['/root/a/freq', '/custom/freq.json']);
deps.setFrequencyRankLookup(() => null);
deps.log('loaded');
assert.deepEqual(calls, ['set-rank', 'log:[Frequency] loaded']);
});

View File

@@ -0,0 +1,95 @@
export function createBuildDictionaryRootsMainHandler(deps: {
dirname: string;
appPath: string;
resourcesPath: string;
userDataPath: string;
appUserDataPath: string;
homeDir: string;
cwd: string;
joinPath: (...parts: string[]) => string;
}) {
return () =>
[
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'yomitan-jlpt-vocab'),
deps.joinPath(deps.appPath, 'vendor', 'yomitan-jlpt-vocab'),
deps.joinPath(deps.resourcesPath, 'yomitan-jlpt-vocab'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'yomitan-jlpt-vocab'),
deps.userDataPath,
deps.appUserDataPath,
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
deps.joinPath(deps.homeDir, '.config', 'subminer'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
deps.cwd,
];
}
export function createBuildFrequencyDictionaryRootsMainHandler(deps: {
dirname: string;
appPath: string;
resourcesPath: string;
userDataPath: string;
appUserDataPath: string;
homeDir: string;
cwd: string;
joinPath: (...parts: string[]) => string;
}) {
return () => [
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'jiten_freq_global'),
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'frequency-dictionary'),
deps.joinPath(deps.appPath, 'vendor', 'jiten_freq_global'),
deps.joinPath(deps.appPath, 'vendor', 'frequency-dictionary'),
deps.joinPath(deps.resourcesPath, 'jiten_freq_global'),
deps.joinPath(deps.resourcesPath, 'frequency-dictionary'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'jiten_freq_global'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'),
deps.userDataPath,
deps.appUserDataPath,
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
deps.joinPath(deps.homeDir, '.config', 'subminer'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
deps.cwd,
];
}
export function createBuildJlptDictionaryRuntimeMainDepsHandler(deps: {
isJlptEnabled: () => boolean;
getDictionaryRoots: () => string[];
getJlptDictionarySearchPaths: (deps: { getDictionaryRoots: () => string[] }) => string[];
setJlptLevelLookup: (lookup: unknown) => void;
logInfo: (message: string) => void;
}) {
return () => ({
isJlptEnabled: () => deps.isJlptEnabled(),
getSearchPaths: () =>
deps.getJlptDictionarySearchPaths({
getDictionaryRoots: () => deps.getDictionaryRoots(),
}),
setJlptLevelLookup: (lookup: unknown) => deps.setJlptLevelLookup(lookup),
log: (message: string) => deps.logInfo(`[JLPT] ${message}`),
});
}
export function createBuildFrequencyDictionaryRuntimeMainDepsHandler(deps: {
isFrequencyDictionaryEnabled: () => boolean;
getDictionaryRoots: () => string[];
getFrequencyDictionarySearchPaths: (deps: {
getDictionaryRoots: () => string[];
getSourcePath: () => string | undefined;
}) => string[];
getSourcePath: () => string | undefined;
setFrequencyRankLookup: (lookup: unknown) => void;
logInfo: (message: string) => void;
}) {
return () => ({
isFrequencyDictionaryEnabled: () => deps.isFrequencyDictionaryEnabled(),
getSearchPaths: () =>
deps.getFrequencyDictionarySearchPaths({
getDictionaryRoots: () => deps.getDictionaryRoots().filter((dictionaryRoot) => dictionaryRoot),
getSourcePath: () => deps.getSourcePath(),
}),
setFrequencyRankLookup: (lookup: unknown) => deps.setFrequencyRankLookup(lookup),
log: (message: string) => deps.logInfo(`[Frequency] ${message}`),
});
}

View File

@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildFieldGroupingOverlayMainDepsHandler } from './field-grouping-overlay-main-deps';
test('field grouping overlay main deps builder maps window visibility and resolver wiring', () => {
const calls: string[] = [];
const modalSet = new Set<'runtime-options'>();
const resolver = (choice: unknown) => calls.push(`resolver:${choice}`);
const deps = createBuildFieldGroupingOverlayMainDepsHandler({
getMainWindow: () => ({ id: 'main' }),
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
setInvisibleOverlayVisible: (visible) => calls.push(`invisible:${visible}`),
getResolver: () => resolver,
setResolver: (nextResolver) => {
calls.push(`set-resolver:${nextResolver ? 'set' : 'null'}`);
},
getRestoreVisibleOverlayOnModalClose: () => modalSet,
sendToActiveOverlayWindow: (channel, payload) => {
calls.push(`send:${channel}:${String(payload)}`);
return true;
},
})();
assert.deepEqual(deps.getMainWindow(), { id: 'main' });
assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getInvisibleOverlayVisible(), false);
assert.equal(deps.getResolver(), resolver);
assert.equal(deps.getRestoreVisibleOverlayOnModalClose(), modalSet);
deps.setVisibleOverlayVisible(true);
deps.setInvisibleOverlayVisible(false);
deps.setResolver(null);
assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true);
assert.deepEqual(calls, [
'visible:true',
'invisible:false',
'set-resolver:null',
'send:kiku:open:1',
]);
});

View File

@@ -0,0 +1,34 @@
export function createBuildFieldGroupingOverlayMainDepsHandler<
TModal extends string,
TChoice,
>(deps: {
getMainWindow: () => unknown | null;
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: TChoice) => void) | null;
setResolver: (resolver: ((choice: TChoice) => void) | null) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<TModal>;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: TModal },
) => boolean;
}) {
return () => ({
getMainWindow: () => deps.getMainWindow() as never,
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(),
setVisibleOverlayVisible: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
getResolver: () => deps.getResolver() as never,
setResolver: (resolver: ((choice: TChoice) => void) | null) => deps.setResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(),
sendToVisibleOverlay: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: TModal },
) => deps.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
});
}

View File

@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler,
createBuildHandleJellyfinRemotePlayMainDepsHandler,
createBuildHandleJellyfinRemotePlaystateMainDepsHandler,
createBuildReportJellyfinRemoteProgressMainDepsHandler,
createBuildReportJellyfinRemoteStoppedMainDepsHandler,
} from './jellyfin-remote-main-deps';
test('jellyfin remote play main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildHandleJellyfinRemotePlayMainDepsHandler({
getConfiguredSession: () => ({ id: 1 }) as never,
getClientInfo: () => ({ id: 2 }) as never,
getJellyfinConfig: () => ({ id: 3 }),
playJellyfinItem: async () => {
calls.push('play');
},
logWarn: (message) => calls.push(`warn:${message}`),
})();
assert.deepEqual(deps.getConfiguredSession(), { id: 1 });
assert.deepEqual(deps.getClientInfo(), { id: 2 });
assert.deepEqual(deps.getJellyfinConfig(), { id: 3 });
await deps.playJellyfinItem({} as never);
deps.logWarn('missing');
assert.deepEqual(calls, ['play', 'warn:missing']);
});
test('jellyfin remote playstate main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildHandleJellyfinRemotePlaystateMainDepsHandler({
getMpvClient: () => ({ id: 1 }),
sendMpvCommand: () => calls.push('send'),
reportJellyfinRemoteProgress: async () => {
calls.push('progress');
},
reportJellyfinRemoteStopped: async () => {
calls.push('stopped');
},
jellyfinTicksToSeconds: (ticks) => ticks / 10,
})();
assert.deepEqual(deps.getMpvClient(), { id: 1 });
deps.sendMpvCommand({} as never, ['stop']);
await deps.reportJellyfinRemoteProgress(true);
await deps.reportJellyfinRemoteStopped();
assert.equal(deps.jellyfinTicksToSeconds(100), 10);
assert.deepEqual(calls, ['send', 'progress', 'stopped']);
});
test('jellyfin remote general command main deps builder maps callbacks', async () => {
const calls: string[] = [];
const playback = { itemId: 'abc', playMethod: 'DirectPlay' as const };
const deps = createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler({
getMpvClient: () => ({ id: 1 }),
sendMpvCommand: () => calls.push('send'),
getActivePlayback: () => playback,
reportJellyfinRemoteProgress: async () => {
calls.push('progress');
},
logDebug: (message) => calls.push(`debug:${message}`),
})();
assert.deepEqual(deps.getMpvClient(), { id: 1 });
deps.sendMpvCommand({} as never, ['set_property', 'sid', 1]);
assert.deepEqual(deps.getActivePlayback(), playback);
await deps.reportJellyfinRemoteProgress(true);
deps.logDebug('ignore');
assert.deepEqual(calls, ['send', 'progress', 'debug:ignore']);
});
test('jellyfin remote progress main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildReportJellyfinRemoteProgressMainDepsHandler({
getActivePlayback: () => ({ itemId: 'abc', playMethod: 'DirectPlay' }),
clearActivePlayback: () => calls.push('clear'),
getSession: () => ({ id: 1, isConnected: () => true }) as never,
getMpvClient: () => ({ id: 2, requestProperty: async () => 0 }) as never,
getNow: () => 123,
getLastProgressAtMs: () => 10,
setLastProgressAtMs: () => calls.push('set-last'),
progressIntervalMs: 2500,
ticksPerSecond: 10000000,
logDebug: (message) => calls.push(`debug:${message}`),
})();
assert.equal(deps.getNow(), 123);
assert.equal(deps.getLastProgressAtMs(), 10);
deps.setLastProgressAtMs(5);
assert.equal(deps.progressIntervalMs, 2500);
assert.equal(deps.ticksPerSecond, 10000000);
deps.clearActivePlayback();
deps.logDebug('x', null);
assert.deepEqual(calls, ['set-last', 'clear', 'debug:x']);
});
test('jellyfin remote stopped main deps builder maps callbacks', () => {
const calls: string[] = [];
const session = { id: 1, isConnected: () => true };
const deps = createBuildReportJellyfinRemoteStoppedMainDepsHandler({
getActivePlayback: () => ({ itemId: 'abc', playMethod: 'DirectPlay' }),
clearActivePlayback: () => calls.push('clear'),
getSession: () => session as never,
logDebug: (message) => calls.push(`debug:${message}`),
})();
assert.deepEqual(deps.getActivePlayback(), { itemId: 'abc', playMethod: 'DirectPlay' });
deps.clearActivePlayback();
assert.equal(deps.getSession(), session);
deps.logDebug('stopped', null);
assert.deepEqual(calls, ['clear', 'debug:stopped']);
});

View File

@@ -0,0 +1,73 @@
import type {
JellyfinRemoteGeneralCommandHandlerDeps,
JellyfinRemotePlayHandlerDeps,
JellyfinRemotePlaystateHandlerDeps,
} from './jellyfin-remote-commands';
import type {
JellyfinRemoteProgressReporterDeps,
JellyfinRemoteStoppedReporterDeps,
} from './jellyfin-remote-playback';
export function createBuildHandleJellyfinRemotePlayMainDepsHandler(
deps: JellyfinRemotePlayHandlerDeps,
) {
return (): JellyfinRemotePlayHandlerDeps => ({
getConfiguredSession: () => deps.getConfiguredSession(),
getClientInfo: () => deps.getClientInfo(),
getJellyfinConfig: () => deps.getJellyfinConfig(),
playJellyfinItem: (params) => deps.playJellyfinItem(params),
logWarn: (message: string) => deps.logWarn(message),
});
}
export function createBuildHandleJellyfinRemotePlaystateMainDepsHandler(
deps: JellyfinRemotePlaystateHandlerDeps,
) {
return (): JellyfinRemotePlaystateHandlerDeps => ({
getMpvClient: () => deps.getMpvClient(),
sendMpvCommand: (client, command) => deps.sendMpvCommand(client, command),
reportJellyfinRemoteProgress: (force: boolean) => deps.reportJellyfinRemoteProgress(force),
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
jellyfinTicksToSeconds: (ticks: number) => deps.jellyfinTicksToSeconds(ticks),
});
}
export function createBuildHandleJellyfinRemoteGeneralCommandMainDepsHandler(
deps: JellyfinRemoteGeneralCommandHandlerDeps,
) {
return (): JellyfinRemoteGeneralCommandHandlerDeps => ({
getMpvClient: () => deps.getMpvClient(),
sendMpvCommand: (client, command) => deps.sendMpvCommand(client, command),
getActivePlayback: () => deps.getActivePlayback(),
reportJellyfinRemoteProgress: (force: boolean) => deps.reportJellyfinRemoteProgress(force),
logDebug: (message: string) => deps.logDebug(message),
});
}
export function createBuildReportJellyfinRemoteProgressMainDepsHandler(
deps: JellyfinRemoteProgressReporterDeps,
) {
return (): JellyfinRemoteProgressReporterDeps => ({
getActivePlayback: () => deps.getActivePlayback(),
clearActivePlayback: () => deps.clearActivePlayback(),
getSession: () => deps.getSession(),
getMpvClient: () => deps.getMpvClient(),
getNow: () => deps.getNow(),
getLastProgressAtMs: () => deps.getLastProgressAtMs(),
setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value),
progressIntervalMs: deps.progressIntervalMs,
ticksPerSecond: deps.ticksPerSecond,
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
});
}
export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
deps: JellyfinRemoteStoppedReporterDeps,
) {
return (): JellyfinRemoteStoppedReporterDeps => ({
getActivePlayback: () => deps.getActivePlayback(),
clearActivePlayback: () => deps.clearActivePlayback(),
getSession: () => deps.getSession(),
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
});
}

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildMediaRuntimeMainDepsHandler } from './media-runtime-main-deps';
test('media runtime main deps builder maps state and subtitle broadcast channel', () => {
const calls: string[] = [];
let currentPath: string | null = '/tmp/a.mkv';
let subtitlePosition: unknown = null;
let currentTitle: string | null = 'Title';
const deps = createBuildMediaRuntimeMainDepsHandler({
isRemoteMediaPath: (mediaPath) => mediaPath.startsWith('http'),
loadSubtitlePosition: () => ({ x: 1 }) as never,
getCurrentMediaPath: () => currentPath,
getPendingSubtitlePosition: () => null,
getSubtitlePositionsDir: () => '/tmp/subs',
setCurrentMediaPath: (mediaPath) => {
currentPath = mediaPath;
calls.push(`path:${String(mediaPath)}`);
},
clearPendingSubtitlePosition: () => calls.push('clear-pending'),
setSubtitlePosition: (position) => {
subtitlePosition = position;
calls.push('set-position');
},
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${JSON.stringify(payload)}`),
getCurrentMediaTitle: () => currentTitle,
setCurrentMediaTitle: (title) => {
currentTitle = title;
calls.push(`title:${String(title)}`);
},
})();
assert.equal(deps.isRemoteMediaPath('http://x'), true);
assert.equal(deps.getCurrentMediaPath(), '/tmp/a.mkv');
assert.equal(deps.getSubtitlePositionsDir(), '/tmp/subs');
assert.deepEqual(deps.loadSubtitlePosition(), { x: 1 });
deps.setCurrentMediaPath('/tmp/b.mkv');
deps.clearPendingSubtitlePosition();
deps.setSubtitlePosition({ line: 1 } as never);
deps.broadcastSubtitlePosition({ line: 1 } as never);
deps.setCurrentMediaTitle('Next');
assert.equal(currentPath, '/tmp/b.mkv');
assert.deepEqual(subtitlePosition, { line: 1 });
assert.equal(currentTitle, 'Next');
assert.deepEqual(calls, [
'path:/tmp/b.mkv',
'clear-pending',
'set-position',
'broadcast:subtitle-position:set:{"line":1}',
'title:Next',
]);
});

View File

@@ -0,0 +1,30 @@
import type { SubtitlePosition } from '../../types';
export function createBuildMediaRuntimeMainDepsHandler(deps: {
isRemoteMediaPath: (mediaPath: string) => boolean;
loadSubtitlePosition: () => SubtitlePosition | null;
getCurrentMediaPath: () => string | null;
getPendingSubtitlePosition: () => SubtitlePosition | null;
getSubtitlePositionsDir: () => string;
setCurrentMediaPath: (mediaPath: string | null) => void;
clearPendingSubtitlePosition: () => void;
setSubtitlePosition: (position: SubtitlePosition | null) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
getCurrentMediaTitle: () => string | null;
setCurrentMediaTitle: (title: string | null) => void;
}) {
return () => ({
isRemoteMediaPath: (mediaPath: string) => deps.isRemoteMediaPath(mediaPath),
loadSubtitlePosition: () => deps.loadSubtitlePosition(),
getCurrentMediaPath: () => deps.getCurrentMediaPath(),
getPendingSubtitlePosition: () => deps.getPendingSubtitlePosition(),
getSubtitlePositionsDir: () => deps.getSubtitlePositionsDir(),
setCurrentMediaPath: (nextPath: string | null) => deps.setCurrentMediaPath(nextPath),
clearPendingSubtitlePosition: () => deps.clearPendingSubtitlePosition(),
setSubtitlePosition: (position: SubtitlePosition | null) => deps.setSubtitlePosition(position),
broadcastSubtitlePosition: (position: SubtitlePosition | null) =>
deps.broadcastToOverlayWindows('subtitle-position:set', position),
getCurrentMediaTitle: () => deps.getCurrentMediaTitle(),
setCurrentMediaTitle: (title: string | null) => deps.setCurrentMediaTitle(title),
});
}

View File

@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './overlay-shortcuts-runtime-main-deps';
test('overlay shortcuts runtime main deps builder maps lifecycle and action callbacks', async () => {
const calls: string[] = [];
let shortcutsRegistered = false;
const deps = createBuildOverlayShortcutsRuntimeMainDepsHandler({
getConfiguredShortcuts: () => ({ copySubtitle: 's' } as never),
getShortcutsRegistered: () => shortcutsRegistered,
setShortcutsRegistered: (registered) => {
shortcutsRegistered = registered;
calls.push(`registered:${registered}`);
},
isOverlayRuntimeInitialized: () => true,
showMpvOsd: (text) => calls.push(`osd:${text}`),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJimaku: () => calls.push('jimaku'),
markAudioCard: async () => {
calls.push('mark-audio');
},
copySubtitleMultiple: (timeoutMs) => calls.push(`copy-multi:${timeoutMs}`),
copySubtitle: () => calls.push('copy'),
toggleSecondarySubMode: () => calls.push('toggle-sub'),
updateLastCardFromClipboard: async () => {
calls.push('update-last-card');
},
triggerFieldGrouping: async () => {
calls.push('field-grouping');
},
triggerSubsyncFromConfig: async () => {
calls.push('subsync');
},
mineSentenceCard: async () => {
calls.push('mine');
},
mineSentenceMultiple: (timeoutMs) => calls.push(`mine-multi:${timeoutMs}`),
cancelPendingMultiCopy: () => calls.push('cancel-copy'),
cancelPendingMineSentenceMultiple: () => calls.push('cancel-mine'),
})();
assert.equal(deps.isOverlayRuntimeInitialized(), true);
assert.equal(deps.getShortcutsRegistered(), false);
deps.setShortcutsRegistered(true);
assert.equal(shortcutsRegistered, true);
deps.showMpvOsd('x');
deps.openRuntimeOptionsPalette();
deps.openJimaku();
await deps.markAudioCard();
deps.copySubtitleMultiple(5000);
deps.copySubtitle();
deps.toggleSecondarySubMode();
await deps.updateLastCardFromClipboard();
await deps.triggerFieldGrouping();
await deps.triggerSubsyncFromConfig();
await deps.mineSentenceCard();
deps.mineSentenceMultiple(3000);
deps.cancelPendingMultiCopy();
deps.cancelPendingMineSentenceMultiple();
assert.deepEqual(calls, [
'registered:true',
'osd:x',
'runtime-options',
'jimaku',
'mark-audio',
'copy-multi:5000',
'copy',
'toggle-sub',
'update-last-card',
'field-grouping',
'subsync',
'mine',
'mine-multi:3000',
'cancel-copy',
'cancel-mine',
]);
});

View File

@@ -0,0 +1,26 @@
import type { OverlayShortcutRuntimeServiceInput } from '../overlay-shortcuts-runtime';
export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
deps: OverlayShortcutRuntimeServiceInput,
) {
return (): OverlayShortcutRuntimeServiceInput => ({
getConfiguredShortcuts: () => deps.getConfiguredShortcuts(),
getShortcutsRegistered: () => deps.getShortcutsRegistered(),
setShortcutsRegistered: (registered: boolean) => deps.setShortcutsRegistered(registered),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openJimaku: () => deps.openJimaku(),
markAudioCard: () => deps.markAudioCard(),
copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs),
copySubtitle: () => deps.copySubtitle(),
toggleSecondarySubMode: () => deps.toggleSecondarySubMode(),
updateLastCardFromClipboard: () => deps.updateLastCardFromClipboard(),
triggerFieldGrouping: () => deps.triggerFieldGrouping(),
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
mineSentenceCard: () => deps.mineSentenceCard(),
mineSentenceMultiple: (timeoutMs: number) => deps.mineSentenceMultiple(timeoutMs),
cancelPendingMultiCopy: () => deps.cancelPendingMultiCopy(),
cancelPendingMineSentenceMultiple: () => deps.cancelPendingMineSentenceMultiple(),
});
}

View File

@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildOverlayVisibilityRuntimeMainDepsHandler } from './overlay-visibility-runtime-main-deps';
test('overlay visibility runtime main deps builder maps state and geometry callbacks', () => {
const calls: string[] = [];
let trackerNotReadyWarningShown = false;
const mainWindow = { id: 'main' } as never;
const invisibleWindow = { id: 'invisible' } as never;
const deps = createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => mainWindow,
getInvisibleWindow: () => invisibleWindow,
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
getWindowTracker: () => ({ id: 'tracker' }),
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown) => {
trackerNotReadyWarningShown = shown;
calls.push(`tracker-warning:${shown}`);
},
updateVisibleOverlayBounds: () => calls.push('visible-bounds'),
updateInvisibleOverlayBounds: () => calls.push('invisible-bounds'),
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
enforceOverlayLayerOrder: () => calls.push('enforce-order'),
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
})();
assert.equal(deps.getMainWindow(), mainWindow);
assert.equal(deps.getInvisibleWindow(), invisibleWindow);
assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getInvisibleOverlayVisible(), false);
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
deps.setTrackerNotReadyWarningShown(true);
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
deps.updateInvisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
deps.ensureOverlayWindowLevel(mainWindow);
deps.enforceOverlayLayerOrder();
deps.syncOverlayShortcuts();
assert.equal(trackerNotReadyWarningShown, true);
assert.deepEqual(calls, [
'tracker-warning:true',
'visible-bounds',
'invisible-bounds',
'ensure-level',
'enforce-order',
'sync-shortcuts',
]);
});

View File

@@ -0,0 +1,34 @@
import type { BrowserWindow } from 'electron';
import type { WindowGeometry } from '../../types';
import type { OverlayVisibilityRuntimeDeps } from '../overlay-visibility-runtime';
export function createBuildOverlayVisibilityRuntimeMainDepsHandler(deps: {
getMainWindow: () => BrowserWindow | null;
getInvisibleWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
getWindowTracker: () => unknown | null;
getTrackerNotReadyWarningShown: () => boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
}) {
return (): OverlayVisibilityRuntimeDeps => ({
getMainWindow: () => deps.getMainWindow(),
getInvisibleWindow: () => deps.getInvisibleWindow(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(),
getWindowTracker: () => deps.getWindowTracker() as never,
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
updateVisibleOverlayBounds: (geometry: WindowGeometry) => deps.updateVisibleOverlayBounds(geometry),
updateInvisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateInvisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
});
}

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildSubtitleProcessingControllerMainDepsHandler } from './subtitle-processing-main-deps';
test('subtitle processing main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildSubtitleProcessingControllerMainDepsHandler({
tokenizeSubtitle: async (text) => {
calls.push(`tokenize:${text}`);
return { text, tokens: null };
},
emitSubtitle: (payload) => calls.push(`emit:${payload.text}`),
logDebug: (message) => calls.push(`log:${message}`),
now: () => 42,
})();
const tokenized = await deps.tokenizeSubtitle('line');
deps.emitSubtitle({ text: 'line', tokens: null });
deps.logDebug?.('ok');
assert.equal(deps.now?.(), 42);
assert.deepEqual(tokenized, { text: 'line', tokens: null });
assert.deepEqual(calls, ['tokenize:line', 'emit:line', 'log:ok']);
});
test('subtitle processing main deps builder preserves optional callbacks when absent', () => {
const deps = createBuildSubtitleProcessingControllerMainDepsHandler({
tokenizeSubtitle: async () => null,
emitSubtitle: () => {},
})();
assert.equal(deps.logDebug, undefined);
assert.equal(deps.now, undefined);
});

View File

@@ -0,0 +1,12 @@
import type { SubtitleProcessingControllerDeps } from '../../core/services/subtitle-processing-controller';
export function createBuildSubtitleProcessingControllerMainDepsHandler(
deps: SubtitleProcessingControllerDeps,
) {
return (): SubtitleProcessingControllerDeps => ({
tokenizeSubtitle: (text: string) => deps.tokenizeSubtitle(text),
emitSubtitle: (payload) => deps.emitSubtitle(payload),
logDebug: deps.logDebug,
now: deps.now,
});
}