test: cut low-value tests, rewrite the rest around behavior, parallelize lanes (#285)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-05 00:07:44 -07:00
1 parent 4b6842d034
commit b48083ddd8
368 files changed
+18045 -60331

No files matched your search

@@ -1,79 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildEnsureAnilistMediaGuessMainDepsHandler,
createBuildMaybeProbeAnilistDurationMainDepsHandler,
} from './anilist-media-guess-main-deps';
test('maybe probe anilist duration main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildMaybeProbeAnilistDurationMainDepsHandler({
getState: () => ({
mediaKey: 'm',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
}),
setState: () => calls.push('set-state'),
durationRetryIntervalMs: 1000,
now: () => 42,
requestMpvDuration: async () => 3600,
logWarn: (message) => calls.push(`warn:${message}`),
})();
assert.equal(deps.durationRetryIntervalMs, 1000);
assert.equal(deps.now(), 42);
assert.equal(await deps.requestMpvDuration(), 3600);
deps.setState({
mediaKey: 'm',
mediaDurationSec: 100,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
});
deps.logWarn('oops', null);
assert.deepEqual(calls, ['set-state', 'warn:oops']);
});
test('ensure anilist media guess main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildEnsureAnilistMediaGuessMainDepsHandler({
getState: () => ({
mediaKey: 'm',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
}),
setState: () => calls.push('set-state'),
resolveMediaPathForJimaku: (path) => {
calls.push('resolve');
return path;
},
getCurrentMediaPath: () => '/tmp/video.mkv',
getCurrentMediaTitle: () => 'title',
guessAnilistMediaInfo: async () => {
calls.push('guess');
return { title: 'title', season: null, episode: 1, source: 'fallback' };
},
})();
assert.equal(deps.getCurrentMediaPath(), '/tmp/video.mkv');
assert.equal(deps.getCurrentMediaTitle(), 'title');
assert.equal(deps.resolveMediaPathForJimaku('/tmp/video.mkv'), '/tmp/video.mkv');
assert.deepEqual(await deps.guessAnilistMediaInfo('/tmp/video.mkv', 'title'), {
title: 'title',
season: null,
episode: 1,
source: 'fallback',
});
deps.setState({
mediaKey: 'm',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
});
assert.deepEqual(calls, ['resolve', 'guess', 'set-state']);
});
@@ -1,102 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
createBuildRecordAnilistMediaDurationMainDepsHandler,
createBuildResetAnilistMediaGuessStateMainDepsHandler,
createBuildResetAnilistMediaTrackingMainDepsHandler,
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
} from './anilist-media-state-main-deps';
test('get current anilist media key main deps builder maps callbacks', () => {
const deps = createBuildGetCurrentAnilistMediaKeyMainDepsHandler({
getCurrentMediaPath: () => '/tmp/video.mkv',
})();
assert.equal(deps.getCurrentMediaPath(), '/tmp/video.mkv');
});
test('reset anilist media tracking main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildResetAnilistMediaTrackingMainDepsHandler({
setMediaKey: () => calls.push('key'),
setMediaDurationSec: () => calls.push('duration'),
setMediaGuess: () => calls.push('guess'),
setMediaGuessPromise: () => calls.push('promise'),
setLastDurationProbeAtMs: () => calls.push('probe'),
})();
deps.setMediaKey(null);
deps.setMediaDurationSec(null);
deps.setMediaGuess(null);
deps.setMediaGuessPromise(null);
deps.setLastDurationProbeAtMs(0);
assert.deepEqual(calls, ['key', 'duration', 'guess', 'promise', 'probe']);
});
test('get/set anilist media guess runtime state main deps builders map callbacks', () => {
const getter = createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler({
getMediaKey: () => '/tmp/video.mkv',
getMediaDurationSec: () => 24,
getMediaGuess: () => ({ title: 'X' }) as never,
getMediaGuessPromise: () => Promise.resolve(null) as never,
getLastDurationProbeAtMs: () => 123,
})();
assert.equal(getter.getMediaKey(), '/tmp/video.mkv');
assert.equal(getter.getMediaDurationSec(), 24);
assert.equal(getter.getLastDurationProbeAtMs(), 123);
const calls: string[] = [];
const setter = createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler({
setMediaKey: () => calls.push('key'),
setMediaDurationSec: () => calls.push('duration'),
setMediaGuess: () => calls.push('guess'),
setMediaGuessPromise: () => calls.push('promise'),
setLastDurationProbeAtMs: () => calls.push('probe'),
})();
setter.setMediaKey(null);
setter.setMediaDurationSec(null);
setter.setMediaGuess(null);
setter.setMediaGuessPromise(null);
setter.setLastDurationProbeAtMs(0);
assert.deepEqual(calls, ['key', 'duration', 'guess', 'promise', 'probe']);
});
test('reset anilist media guess state main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildResetAnilistMediaGuessStateMainDepsHandler({
setMediaGuess: () => calls.push('guess'),
setMediaGuessPromise: () => calls.push('promise'),
})();
deps.setMediaGuess(null);
deps.setMediaGuessPromise(null);
assert.deepEqual(calls, ['guess', 'promise']);
});
test('record anilist media duration main deps builder maps callbacks', () => {
const calls: string[] = [];
const state = {
mediaKey: '/tmp/video.mkv',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
};
const deps = createBuildRecordAnilistMediaDurationMainDepsHandler({
getCurrentMediaKey: () => {
calls.push('key');
return '/tmp/video.mkv';
},
getState: () => {
calls.push('get');
return state;
},
setState: () => {
calls.push('set');
},
})();
assert.equal(deps.getCurrentMediaKey(), '/tmp/video.mkv');
deps.getState();
deps.setState(state);
assert.deepEqual(calls, ['key', 'get', 'set']);
});
@@ -1,12 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createGetAnilistMediaGuessRuntimeStateHandler,
createGetCurrentAnilistMediaKeyHandler,
createRecordAnilistMediaDurationHandler,
createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler,
createSetAnilistMediaGuessRuntimeStateHandler,
} from './anilist-media-state';
test('get current anilist media key trims and normalizes empty path', () => {
@@ -66,118 +63,6 @@ test('reset anilist media tracking clears duration/guess/probe state', () => {
assert.equal(lastDurationProbeAtMs, 0);
});
test('reset anilist media tracking is idempotent', () => {
const state = {
mediaKey: 'old' as string | null,
mediaDurationSec: 123 as number | null,
mediaGuess: { title: 'guess' } as { title: string } | null,
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
lastDurationProbeAtMs: 999,
};
const reset = createResetAnilistMediaTrackingHandler({
setMediaKey: (value) => {
state.mediaKey = value;
},
setMediaDurationSec: (value) => {
state.mediaDurationSec = value;
},
setMediaGuess: (value) => {
state.mediaGuess = value as { title: string } | null;
},
setMediaGuessPromise: (value) => {
state.mediaGuessPromise = value;
},
setLastDurationProbeAtMs: (value) => {
state.lastDurationProbeAtMs = value;
},
});
reset('/new/media');
const afterFirstReset = { ...state };
reset('/new/media');
assert.deepEqual(state, afterFirstReset);
});
test('get/set anilist media guess runtime state round-trips fields', () => {
let state = {
mediaKey: null as string | null,
mediaDurationSec: null as number | null,
mediaGuess: null as { title: string } | null,
mediaGuessPromise: null as Promise<unknown> | null,
lastDurationProbeAtMs: 0,
};
const setState = createSetAnilistMediaGuessRuntimeStateHandler({
setMediaKey: (value) => {
state.mediaKey = value;
},
setMediaDurationSec: (value) => {
state.mediaDurationSec = value;
},
setMediaGuess: (value) => {
state.mediaGuess = value as { title: string } | null;
},
setMediaGuessPromise: (value) => {
state.mediaGuessPromise = value;
},
setLastDurationProbeAtMs: (value) => {
state.lastDurationProbeAtMs = value;
},
});
const getState = createGetAnilistMediaGuessRuntimeStateHandler({
getMediaKey: () => state.mediaKey,
getMediaDurationSec: () => state.mediaDurationSec,
getMediaGuess: () => state.mediaGuess as never,
getMediaGuessPromise: () => state.mediaGuessPromise as never,
getLastDurationProbeAtMs: () => state.lastDurationProbeAtMs,
});
const nextPromise = Promise.resolve(null);
setState({
mediaKey: '/tmp/video.mkv',
mediaDurationSec: 24,
mediaGuess: { title: 'Title' } as never,
mediaGuessPromise: nextPromise as never,
lastDurationProbeAtMs: 321,
});
const roundTrip = getState();
assert.equal(roundTrip.mediaKey, '/tmp/video.mkv');
assert.equal(roundTrip.mediaDurationSec, 24);
assert.deepEqual(roundTrip.mediaGuess, { title: 'Title' });
assert.equal(roundTrip.mediaGuessPromise, nextPromise);
assert.equal(roundTrip.lastDurationProbeAtMs, 321);
});
test('reset anilist media guess state clears guess and in-flight promise', () => {
const state = {
mediaKey: '/tmp/video.mkv' as string | null,
mediaDurationSec: 240 as number | null,
mediaGuess: { title: 'guess' } as { title: string } | null,
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
lastDurationProbeAtMs: 321,
};
const resetGuessState = createResetAnilistMediaGuessStateHandler({
setMediaGuess: (value) => {
state.mediaGuess = value as { title: string } | null;
},
setMediaGuessPromise: (value) => {
state.mediaGuessPromise = value;
},
});
resetGuessState();
assert.equal(state.mediaGuess, null);
assert.equal(state.mediaGuessPromise, null);
assert.equal(state.mediaKey, '/tmp/video.mkv');
assert.equal(state.mediaDurationSec, 240);
assert.equal(state.lastDurationProbeAtMs, 321);
});
test('record anilist media duration stores observed mpv duration for current media', () => {
const existingPromise = Promise.resolve(null);
let state = {
@@ -1,132 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler,
createBuildProcessNextAnilistRetryUpdateMainDepsHandler,
} from './anilist-post-watch-main-deps';
test('process next anilist retry update main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildProcessNextAnilistRetryUpdateMainDepsHandler({
nextReady: () => ({ key: 'k', title: 't', season: null, episode: 1 }),
refreshRetryQueueState: () => calls.push('refresh'),
setLastAttemptAt: () => calls.push('attempt'),
setLastError: () => calls.push('error'),
refreshAnilistClientSecretState: async () => 'token',
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
status: 'updated',
message: `ok:${season}`,
}),
markSuccess: () => calls.push('success'),
rememberAttemptedUpdateKey: () => calls.push('remember'),
markFailure: () => calls.push('failure'),
logInfo: (message) => calls.push(`info:${message}`),
now: () => 7,
})();
assert.deepEqual(deps.nextReady(), { key: 'k', title: 't', season: null, episode: 1 });
deps.refreshRetryQueueState();
deps.setLastAttemptAt(1);
deps.setLastError('x');
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), {
status: 'updated',
message: 'ok:2',
});
deps.markSuccess('k');
deps.rememberAttemptedUpdateKey('k');
deps.markFailure('k', 'bad');
deps.logInfo('hello');
assert.equal(deps.now(), 7);
assert.deepEqual(calls, [
'refresh',
'attempt',
'error',
'success',
'remember',
'failure',
'info:hello',
]);
});
test('maybe run anilist post watch update main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler({
getInFlight: () => false,
setInFlight: () => calls.push('in-flight'),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => 'media',
hasMpvClient: () => true,
getTrackedMediaKey: () => 'media',
resetTrackedMedia: () => calls.push('reset'),
getWatchedSeconds: () => 100,
maybeProbeAnilistDuration: async (_mediaKey, options) => {
calls.push(`probe:${options?.force === true}`);
return 120;
},
ensureAnilistMediaGuess: async () => ({ title: 'x', season: null, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: (_key, _title, _episode, season) => calls.push(`enqueue:${season}`),
markRetryFailure: () => calls.push('retry-fail'),
markRetrySuccess: () => calls.push('retry-ok'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
status: 'updated',
message: `done:${season}`,
}),
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: () => calls.push('osd'),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 5,
minWatchRatio: 0.5,
})();
assert.equal(deps.getInFlight(), false);
deps.setInFlight(true);
assert.equal(deps.isAnilistTrackingEnabled(deps.getResolvedConfig()), true);
assert.equal(deps.getCurrentMediaKey(), 'media');
assert.equal(deps.hasMpvClient(), true);
assert.equal(deps.getTrackedMediaKey(), 'media');
deps.resetTrackedMedia('media');
assert.equal(deps.getWatchedSeconds(), 100);
assert.equal(await deps.maybeProbeAnilistDuration('media', { force: true }), 120);
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), {
title: 'x',
season: null,
episode: 1,
});
assert.equal(deps.hasAttemptedUpdateKey('k'), false);
assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' });
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
deps.enqueueRetry('k', 't', 1, 2);
deps.markRetryFailure('k', 'bad');
deps.markRetrySuccess('k');
deps.refreshRetryQueueState();
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), {
status: 'updated',
message: 'done:2',
});
deps.rememberAttemptedUpdateKey('k');
deps.showMpvOsd('ok');
deps.logInfo('x');
deps.logWarn('y');
assert.equal(deps.minWatchSeconds, 5);
assert.equal(deps.minWatchRatio, 0.5);
assert.deepEqual(calls, [
'in-flight',
'reset',
'probe:true',
'enqueue:2',
'retry-fail',
'retry-ok',
'refresh',
'remember',
'osd',
'info:x',
'warn:y',
]);
});
+225 -465
View File
@@ -7,6 +7,99 @@ import {
rememberAnilistAttemptedUpdateKey,
} from './anilist-post-watch';
type PostWatchDeps = Parameters<typeof createMaybeRunAnilistPostWatchUpdateHandler>[0];
type RetryDeps = Parameters<typeof createProcessNextAnilistRetryUpdateHandler>[0];
type ProbeOptions = Parameters<PostWatchDeps['maybeProbeAnilistDuration']>[1];
type ProgressCall = {
title: string;
episode: number;
season?: number | null;
mediaId?: number | null;
};
const MEDIA_KEY = '/tmp/video.mkv';
function emptyOutcome() {
return {
inFlight: [] as boolean[],
resets: [] as Array<string | null>,
probes: [] as ProbeOptions[],
guesses: 0,
retryRuns: 0,
tokenRefreshes: 0,
updates: [] as ProgressCall[],
enqueued: [] as Array<ProgressCall & { key: string }>,
remembered: [] as string[],
retrySuccesses: [] as string[],
retryFailures: [] as Array<{ key: string; message: string }>,
queueRefreshes: 0,
osd: [] as string[],
info: [] as string[],
warn: [] as string[],
};
}
// Defaults model a fully watched local episode 1 with a valid token. Default deps record
// into `outcome`; an overridden dep records nothing unless the test does it itself.
function makeDeps(overrides: Partial<PostWatchDeps> = {}) {
const outcome = emptyOutcome();
const attemptedKeys = new Set<string>();
let inFlight = false;
const deps: PostWatchDeps = {
getInFlight: () => inFlight,
setInFlight: (value) => {
inFlight = value;
outcome.inFlight.push(value);
},
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => MEDIA_KEY,
hasMpvClient: () => true,
getTrackedMediaKey: () => MEDIA_KEY,
resetTrackedMedia: (mediaKey) => outcome.resets.push(mediaKey),
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async (_mediaKey, options) => {
outcome.probes.push(options);
return 1000;
},
ensureAnilistMediaGuess: async () => {
outcome.guesses += 1;
return { title: 'Show', season: null, episode: 1 };
},
hasAttemptedUpdateKey: (key) => attemptedKeys.has(key),
processNextAnilistRetryUpdate: async () => {
outcome.retryRuns += 1;
return { ok: true, message: 'noop' };
},
refreshAnilistClientSecretState: async () => {
outcome.tokenRefreshes += 1;
return 'token';
},
enqueueRetry: (key, title, episode, season, mediaId) =>
outcome.enqueued.push({ key, title, episode, season, mediaId }),
markRetryFailure: (key, message) => outcome.retryFailures.push({ key, message }),
markRetrySuccess: (key) => outcome.retrySuccesses.push(key),
refreshRetryQueueState: () => {
outcome.queueRefreshes += 1;
},
updateAnilistPostWatchProgress: async (_accessToken, title, episode, season, mediaId) => {
outcome.updates.push({ title, episode, season, mediaId });
return { status: 'updated', message: 'updated ok' };
},
rememberAttemptedUpdateKey: (key) => {
attemptedKeys.add(key);
outcome.remembered.push(key);
},
showMpvOsd: (message) => outcome.osd.push(message),
logInfo: (message) => outcome.info.push(message),
logWarn: (message) => outcome.warn.push(message),
minWatchSeconds: 600,
minWatchRatio: 0.85,
...overrides,
};
return { outcome, attemptedKeys, run: createMaybeRunAnilistPostWatchUpdateHandler(deps) };
}
test('buildAnilistAttemptKey formats media and episode', () => {
assert.equal(buildAnilistAttemptKey('/tmp/video.mkv', 3), '/tmp/video.mkv::3');
assert.equal(
@@ -29,554 +122,221 @@ test('post-watch rejects empty media identities before attempted keys or update
'https://[invalid',
]) {
assert.equal(buildAnilistAttemptKey(mediaKey, 3), null);
const calls: string[] = [];
const unexpected = () => assert.fail('invalid identity reached update side effects');
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
const { outcome, run } = makeDeps({
getCurrentMediaKey: () => mediaKey,
hasMpvClient: () => true,
getTrackedMediaKey: () => mediaKey,
resetTrackedMedia: unexpected,
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
hasAttemptedUpdateKey: unexpected,
processNextAnilistRetryUpdate: unexpected,
refreshAnilistClientSecretState: unexpected,
enqueueRetry: unexpected,
markRetryFailure: unexpected,
markRetrySuccess: unexpected,
refreshRetryQueueState: unexpected,
updateAnilistPostWatchProgress: unexpected,
rememberAttemptedUpdateKey: unexpected,
showMpvOsd: unexpected,
logInfo: unexpected,
logWarn: unexpected,
minWatchSeconds: 600,
minWatchRatio: 0.85,
hasAttemptedUpdateKey: () => assert.fail('invalid identity reached attempted-key lookup'),
});
await run();
await run({ force: true });
// Only gating ran: no retry, token, update, queue, or notification side effects.
assert.deepEqual(outcome, {
...emptyOutcome(),
inFlight: [true, false, true, false],
probes: [{ force: false }],
guesses: 2,
});
await handler();
await handler({ force: true });
assert.deepEqual(calls, ['inflight:true', 'inflight:false', 'inflight:true', 'inflight:false']);
}
});
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
const calls: string[] = [];
const handler = createProcessNextAnilistRetryUpdateHandler({
nextReady: () => ({ key: 'k1', title: 'Show', season: 2, episode: 1 }),
refreshRetryQueueState: () => calls.push('refresh'),
setLastAttemptAt: () => calls.push('attempt'),
setLastError: (value) => calls.push(`error:${value ?? 'null'}`),
test('createProcessNextAnilistRetryUpdateHandler forwards the queued item and marks success', async () => {
const updates: ProgressCall[] = [];
const successes: string[] = [];
const remembered: string[] = [];
const errors: Array<string | null> = [];
const deps: RetryDeps = {
nextReady: () => ({ key: 'k1', title: 'Show', season: 2, mediaId: 108489, episode: 1 }),
refreshRetryQueueState: () => {},
setLastAttemptAt: () => {},
setLastError: (value) => errors.push(value),
refreshAnilistClientSecretState: async () => 'token',
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
status: 'updated',
message: `updated ok:${season}`,
}),
markSuccess: () => calls.push('success'),
rememberAttemptedUpdateKey: () => calls.push('remember'),
markFailure: () => calls.push('failure'),
logInfo: () => calls.push('info'),
now: () => 1,
});
const result = await handler();
assert.deepEqual(result, { ok: true, message: 'updated ok:2' });
assert.ok(calls.includes('success'));
assert.ok(calls.includes('remember'));
});
test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', async () => {
const calls: string[] = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => null,
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
assert.ok(calls.includes('enqueue'));
assert.ok(calls.includes('mark-failure'));
assert.ok(calls.includes('osd:AniList: access token not configured'));
assert.ok(calls.includes('inflight:true'));
assert.ok(calls.includes('inflight:false'));
});
test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched updates below threshold', async () => {
const calls: string[] = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => false,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 0,
maybeProbeAnilistDuration: async () => {
calls.push('probe');
return 1000;
},
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => {
calls.push('update');
updateAnilistPostWatchProgress: async (_accessToken, title, episode, season, mediaId) => {
updates.push({ title, episode, season, mediaId });
return { status: 'updated', message: 'updated ok' };
},
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
markSuccess: (key) => successes.push(key),
rememberAttemptedUpdateKey: (key) => remembered.push(key),
markFailure: () => assert.fail('successful retry marked as failure'),
logInfo: () => {},
now: () => 1,
};
const result = await createProcessNextAnilistRetryUpdateHandler(deps)();
assert.deepEqual(result, { ok: true, message: 'updated ok' });
assert.deepEqual(updates, [{ title: 'Show', episode: 1, season: 2, mediaId: 108489 }]);
assert.deepEqual(successes, ['k1']);
assert.deepEqual(remembered, ['k1']);
assert.deepEqual(errors, [null]);
});
for (const c of [
{ name: 'queues when token missing', pinnedMediaId: null },
{ name: 'queues the pinned media id for retry', pinnedMediaId: 108489 },
]) {
test(`createMaybeRunAnilistPostWatchUpdateHandler ${c.name}`, async () => {
const key = `${MEDIA_KEY}::1`;
const { outcome, run } = makeDeps({
refreshAnilistClientSecretState: async () => null,
resolvePinnedAnilistMediaId: async () => c.pinnedMediaId,
});
await run();
assert.deepEqual(outcome.updates, []);
assert.deepEqual(outcome.enqueued, [
{ key, title: 'Show', episode: 1, season: null, mediaId: c.pinnedMediaId },
]);
assert.deepEqual(outcome.retryFailures, [
{ key, message: 'cannot authenticate without anilist.accessToken' },
]);
assert.deepEqual(outcome.osd, ['AniList: access token not configured']);
assert.deepEqual(outcome.inFlight, [true, false]);
});
}
test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched updates below threshold', async () => {
const { outcome, run } = makeDeps({
hasMpvClient: () => false,
getWatchedSeconds: () => 0,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
});
await handler({ force: true });
await run({ force: true });
assert.equal(calls.includes('probe'), false);
assert.ok(calls.includes('update'));
assert.ok(calls.includes('remember'));
assert.ok(calls.includes('osd:updated ok'));
assert.deepEqual(outcome.probes, []);
assert.equal(outcome.updates.length, 1);
assert.deepEqual(outcome.remembered, [`${MEDIA_KEY}::3`]);
assert.deepEqual(outcome.osd, ['updated ok']);
});
test('createMaybeRunAnilistPostWatchUpdateHandler shows permanent AniList update errors without queueing retry', async () => {
const calls: string[] = [];
const attemptedKeys = new Set<string>();
const message =
'AniList update not possible: Show is not in your AniList Planning or Watching list.';
let updateCalls = 0;
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
const { outcome, run } = makeDeps({
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 2 }),
hasAttemptedUpdateKey: (key) => attemptedKeys.has(key),
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => {
updateCalls += 1;
return {
status: 'error',
retryable: false,
message:
'AniList update not possible: Show is not in your AniList Planning or Watching list.',
};
return { status: 'error', retryable: false, message };
},
rememberAttemptedUpdateKey: (key) => {
attemptedKeys.add(key);
calls.push(`remember:${key}`);
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
await handler();
await run();
await run();
assert.equal(updateCalls, 1);
assert.equal(calls.includes('enqueue'), false);
assert.equal(calls.includes('mark-failure'), false);
assert.ok(calls.some((call) => call.startsWith('remember:')));
assert.ok(calls.includes('refresh'));
assert.ok(calls.some((call) => call.startsWith('osd:AniList update not possible')));
assert.ok(calls.some((call) => call.startsWith('warn:AniList update not possible')));
assert.deepEqual(outcome.enqueued, []);
assert.deepEqual(outcome.retryFailures, []);
assert.deepEqual(outcome.remembered, [`${MEDIA_KEY}::2`]);
assert.equal(outcome.queueRefreshes, 1);
assert.deepEqual(outcome.osd, [message]);
assert.deepEqual(outcome.warn, [message]);
});
test('createMaybeRunAnilistPostWatchUpdateHandler uses provided watched seconds from time-position events', async () => {
const calls: string[] = [];
let durationProbeOptions: unknown = null;
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
const { outcome, run } = makeDeps({
getWatchedSeconds: () => 0,
maybeProbeAnilistDuration: async (_mediaKey, options) => {
durationProbeOptions = options;
return 1000;
},
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 2, episode: 8 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => {
calls.push(`update:${season}`);
return { status: 'updated', message: 'updated ok' };
},
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler({ watchedSeconds: 850 });
await run({ watchedSeconds: 850 });
assert.deepEqual(durationProbeOptions, { force: true });
assert.ok(calls.includes('update:2'));
assert.ok(calls.includes('remember'));
assert.ok(calls.includes('osd:updated ok'));
assert.deepEqual(outcome.probes, [{ force: true }]);
assert.deepEqual(outcome.updates, [{ title: 'Show', episode: 8, season: 2, mediaId: null }]);
assert.deepEqual(outcome.remembered, [`${MEDIA_KEY}::8`]);
assert.deepEqual(outcome.osd, ['updated ok']);
});
test('createMaybeRunAnilistPostWatchUpdateHandler blocks concurrent runs before async gating', async () => {
const calls: string[] = [];
let inFlight = false;
let probes = 0;
let resolveDuration!: (duration: number) => void;
const durationPromise = new Promise<number>((resolve) => {
resolveDuration = resolve;
});
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => inFlight,
setInFlight: (value) => {
inFlight = value;
calls.push(`inflight:${value}`);
},
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
const { outcome, run } = makeDeps({
maybeProbeAnilistDuration: async () => {
calls.push('probe');
probes += 1;
return await durationPromise;
},
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => {
calls.push('update');
return { status: 'updated', message: 'updated ok' };
},
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
const firstRun = handler();
assert.deepEqual(calls, ['inflight:true', 'probe']);
// The in-flight flag must be set synchronously, before the first await.
const firstRun = run();
assert.deepEqual(outcome.inFlight, [true]);
assert.equal(probes, 1);
await handler();
assert.deepEqual(calls, ['inflight:true', 'probe']);
await run();
assert.deepEqual(outcome.inFlight, [true]);
assert.equal(probes, 1);
resolveDuration(1000);
await firstRun;
assert.equal(calls.filter((call) => call === 'update').length, 1);
assert.equal(calls.at(-1), 'inflight:false');
assert.equal(outcome.updates.length, 1);
assert.deepEqual(outcome.inFlight, [true, false]);
});
test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirely', async () => {
const calls: string[] = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
const { outcome, run } = makeDeps({
getCurrentMediaKey: () => 'https://www.youtube.com/watch?v=abc123',
hasMpvClient: () => true,
getTrackedMediaKey: () => 'https://www.youtube.com/watch?v=abc123',
resetTrackedMedia: () => calls.push('reset'),
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => {
calls.push('probe');
return 1000;
},
ensureAnilistMediaGuess: async () => {
calls.push('guess');
return { title: 'Show', season: null, episode: 1 };
},
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => {
calls.push('process-retry');
return { ok: true, message: 'noop' };
},
refreshAnilistClientSecretState: async () => {
calls.push('refresh-token');
return 'token';
},
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => {
calls.push('update');
return { status: 'updated', message: 'ok' };
},
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
assert.deepEqual(calls, []);
await run();
assert.deepEqual(outcome, emptyOutcome());
});
test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already handled current attempt key', async () => {
const calls: string[] = [];
const attemptedKeys = new Set<string>();
const mediaKey = '/tmp/video.mkv';
const attemptKey = buildAnilistAttemptKey(mediaKey, 1);
assert.ok(attemptKey);
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => mediaKey,
hasMpvClient: () => true,
getTrackedMediaKey: () => mediaKey,
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }),
hasAttemptedUpdateKey: (key) => attemptedKeys.has(key),
const { outcome, attemptedKeys, run } = makeDeps({
processNextAnilistRetryUpdate: async () => {
attemptedKeys.add(attemptKey);
calls.push('process-retry');
attemptedKeys.add(`${MEDIA_KEY}::1`);
return { ok: true, message: 'retry ok' };
},
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => {
calls.push('update');
return { status: 'updated', message: 'updated ok' };
},
rememberAttemptedUpdateKey: (key) => {
attemptedKeys.add(key);
calls.push(`remember:${key}`);
},
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
await run();
assert.equal(calls.includes('update'), false);
assert.equal(calls.includes('enqueue'), false);
assert.equal(calls.includes('mark-failure'), false);
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'osd:retry ok', 'inflight:false']);
assert.deepEqual(outcome.osd, ['retry ok']);
assert.equal(outcome.tokenRefreshes, 0);
assert.deepEqual(outcome.updates, []);
assert.deepEqual(outcome.enqueued, []);
assert.deepEqual(outcome.retryFailures, []);
assert.deepEqual(outcome.inFlight, [true, false]);
});
test('createMaybeRunAnilistPostWatchUpdateHandler passes the pinned override media id', async () => {
const updateArgs: Array<number | null | undefined> = [];
const pinInputs: Array<{ mediaPath: string | null; mediaTitle: string | null; guess: unknown }> =
const guess = { title: 'Show', season: 3, episode: 1 };
const pinInputs: Array<Parameters<NonNullable<PostWatchDeps['resolvePinnedAnilistMediaId']>>[0]> =
[];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: () => {},
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => {},
const { outcome, run } = makeDeps({
ensureAnilistMediaGuess: async () => guess,
getCurrentMediaTitle: () => 'Show S03E01.mkv',
resolvePinnedAnilistMediaId: async (input) => {
pinInputs.push(input);
return 108489;
},
markRetryFailure: () => {},
markRetrySuccess: () => {},
refreshRetryQueueState: () => {},
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
updateArgs.push(mediaId);
return { status: 'updated', message: 'ok' };
},
rememberAttemptedUpdateKey: () => {},
showMpvOsd: () => {},
logInfo: () => {},
logWarn: () => {},
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
assert.deepEqual(updateArgs, [108489]);
await run();
assert.deepEqual(outcome.updates, [{ title: 'Show', episode: 1, season: 3, mediaId: 108489 }]);
// Resolved against the media this run captured, not whatever is playing now.
assert.equal(pinInputs.length, 1);
assert.equal(pinInputs[0]!.mediaPath, '/tmp/video.mkv');
assert.equal(pinInputs[0]!.mediaTitle, 'Show S03E01.mkv');
assert.deepEqual(pinInputs[0]!.guess, { title: 'Show', season: 3, episode: 1 });
});
test('createMaybeRunAnilistPostWatchUpdateHandler queues the pinned media id for retry', async () => {
const enqueued: Array<number | null | undefined> = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: () => {},
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => null,
enqueueRetry: (_key, _title, _episode, _season, mediaId) => {
enqueued.push(mediaId);
},
resolvePinnedAnilistMediaId: async () => 108489,
markRetryFailure: () => {},
markRetrySuccess: () => {},
refreshRetryQueueState: () => {},
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
rememberAttemptedUpdateKey: () => {},
showMpvOsd: () => {},
logInfo: () => {},
logWarn: () => {},
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
assert.deepEqual(enqueued, [108489]);
assert.deepEqual(pinInputs, [{ mediaPath: MEDIA_KEY, mediaTitle: 'Show S03E01.mkv', guess }]);
});
test('createMaybeRunAnilistPostWatchUpdateHandler still updates when the override lookup throws', async () => {
const updateArgs: Array<number | null | undefined> = [];
const warnings: string[] = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: () => {},
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => {},
const { outcome, run } = makeDeps({
resolvePinnedAnilistMediaId: async () => {
throw new Error('store unreadable');
},
markRetryFailure: () => {},
markRetrySuccess: () => {},
refreshRetryQueueState: () => {},
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
updateArgs.push(mediaId);
return { status: 'updated', message: 'ok' };
},
rememberAttemptedUpdateKey: () => {},
showMpvOsd: () => {},
logInfo: () => {},
logWarn: (message) => warnings.push(message),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
assert.deepEqual(updateArgs, [null]);
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /override lookup failed/i);
});
await run();
test('createProcessNextAnilistRetryUpdateHandler forwards the queued media id', async () => {
const received: Array<number | null | undefined> = [];
const handler = createProcessNextAnilistRetryUpdateHandler({
nextReady: () => ({ key: 'k1', title: 'Show', season: 3, mediaId: 108489, episode: 1 }),
refreshRetryQueueState: () => {},
setLastAttemptAt: () => {},
setLastError: () => {},
refreshAnilistClientSecretState: async () => 'token',
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
received.push(mediaId);
return { status: 'updated', message: 'ok' };
},
markSuccess: () => {},
rememberAttemptedUpdateKey: () => {},
markFailure: () => {},
logInfo: () => {},
now: () => 1,
});
await handler();
assert.deepEqual(received, [108489]);
assert.deepEqual(
outcome.updates.map((update) => update.mediaId),
[null],
);
assert.equal(outcome.warn.length, 1);
assert.match(outcome.warn[0]!, /override lookup failed/i);
});
@@ -1,102 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler,
createBuildHandleAnilistSetupProtocolUrlMainDepsHandler,
createBuildNotifyAnilistSetupMainDepsHandler,
createBuildRegisterSubminerProtocolClientMainDepsHandler,
} from './anilist-setup-protocol-main-deps';
test('notify anilist setup main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildNotifyAnilistSetupMainDepsHandler({
hasMpvClient: () => true,
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title) => calls.push(`notify:${title}`),
logInfo: (message) => calls.push(`log:${message}`),
})();
assert.equal(deps.hasMpvClient(), true);
deps.showMpvOsd('ok');
deps.showDesktopNotification('SubMiner', { body: 'x' });
deps.logInfo('done');
assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']);
});
test('notify anilist setup main deps builder preserves optional notification callbacks', () => {
const deps = createBuildNotifyAnilistSetupMainDepsHandler({
hasMpvClient: () => true,
showMpvOsd: () => {},
showDesktopNotification: () => {},
logInfo: () => {},
})();
assert.equal(deps.getNotificationType, undefined);
assert.equal(deps.showOverlayNotification, undefined);
});
test('consume anilist setup token main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({
consumeAnilistSetupCallbackUrl: () => true,
saveToken: () => {
calls.push('save');
return true;
},
setCachedToken: () => calls.push('cache'),
setResolvedState: () => calls.push('resolved'),
setSetupPageOpened: () => calls.push('opened'),
onSuccess: () => calls.push('success'),
closeWindow: () => calls.push('close'),
})();
assert.equal(
deps.consumeAnilistSetupCallbackUrl({
rawUrl: 'subminer://anilist-setup',
saveToken: () => true,
setCachedToken: () => {},
setResolvedState: () => {},
setSetupPageOpened: () => {},
onSuccess: () => {},
closeWindow: () => {},
}),
true,
);
deps.saveToken('token');
deps.setCachedToken('token');
deps.setResolvedState(Date.now());
deps.setSetupPageOpened(true);
deps.onSuccess();
deps.closeWindow();
assert.deepEqual(calls, ['save', 'cache', 'resolved', 'opened', 'success', 'close']);
});
test('handle anilist setup protocol url main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildHandleAnilistSetupProtocolUrlMainDepsHandler({
consumeAnilistSetupTokenFromUrl: () => true,
logWarn: (message) => calls.push(`warn:${message}`),
})();
assert.equal(deps.consumeAnilistSetupTokenFromUrl('subminer://anilist-setup'), true);
deps.logWarn('missing', null);
assert.deepEqual(calls, ['warn:missing']);
});
test('register subminer protocol client main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildRegisterSubminerProtocolClientMainDepsHandler({
isDefaultApp: () => true,
getArgv: () => ['electron', 'entry.js'],
execPath: '/tmp/electron',
resolvePath: (value) => `/abs/${value}`,
setAsDefaultProtocolClient: () => true,
logDebug: (message) => calls.push(`debug:${message}`),
})();
assert.equal(deps.isDefaultApp(), true);
assert.deepEqual(deps.getArgv(), ['electron', 'entry.js']);
assert.equal(deps.execPath, '/tmp/electron');
assert.equal(deps.resolvePath('entry.js'), '/abs/entry.js');
assert.equal(deps.setAsDefaultProtocolClient('subminer'), true);
});
@@ -1,7 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createConsumeAnilistSetupTokenFromUrlHandler,
createHandleAnilistSetupProtocolUrlHandler,
createNotifyAnilistSetupHandler,
createRegisterSubminerProtocolClientHandler,
@@ -37,20 +36,6 @@ test('createNotifyAnilistSetupHandler routes through configured notification sur
]);
});
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {
const consume = createConsumeAnilistSetupTokenFromUrlHandler({
consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'),
saveToken: () => true,
setCachedToken: () => {},
setResolvedState: () => {},
setSetupPageOpened: () => {},
onSuccess: () => {},
closeWindow: () => {},
});
assert.equal(consume('subminer://anilist-setup?access_token=ok'), true);
assert.equal(consume('subminer://anilist-setup'), false);
});
test('createHandleAnilistSetupProtocolUrlHandler validates scheme and logs missing token', () => {
const warnings: string[] = [];
const handleProtocolUrl = createHandleAnilistSetupProtocolUrlHandler({
@@ -1,52 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildOpenAnilistSetupWindowMainDepsHandler } from './anilist-setup-window-main-deps';
test('open anilist setup window main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildOpenAnilistSetupWindowMainDepsHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => ({}) as never,
buildAuthorizeUrl: () => 'https://anilist.co/auth',
consumeCallbackUrl: () => true,
openSetupInBrowser: (url) => calls.push(`browser:${url}`),
loadManualTokenEntry: () => calls.push('manual'),
redirectUri: 'subminer://anilist-auth',
developerSettingsUrl: 'https://anilist.co/settings/developer',
isAllowedExternalUrl: () => true,
isAllowedNavigationUrl: () => true,
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
clearSetupWindow: () => calls.push('clear'),
setSetupPageOpened: (opened) => calls.push(`opened:${String(opened)}`),
setSetupWindow: () => calls.push('window'),
openExternal: (url) => calls.push(`external:${url}`),
})();
assert.equal(deps.maybeFocusExistingSetupWindow(), false);
assert.equal(deps.buildAuthorizeUrl(), 'https://anilist.co/auth');
assert.equal(deps.consumeCallbackUrl('subminer://anilist-setup?access_token=x'), true);
assert.equal(deps.redirectUri, 'subminer://anilist-auth');
assert.equal(deps.developerSettingsUrl, 'https://anilist.co/settings/developer');
assert.equal(deps.isAllowedExternalUrl('https://anilist.co'), true);
assert.equal(deps.isAllowedNavigationUrl('https://anilist.co/oauth'), true);
deps.openSetupInBrowser('https://anilist.co/auth');
deps.loadManualTokenEntry({} as never, 'https://anilist.co/auth');
deps.logWarn('warn');
deps.logError('error', null);
deps.clearSetupWindow();
deps.setSetupPageOpened(true);
deps.setSetupWindow({} as never);
deps.openExternal('https://anilist.co');
assert.deepEqual(calls, [
'browser:https://anilist.co/auth',
'manual',
'warn:warn',
'error:error',
'clear',
'opened:true',
'window',
'external:https://anilist.co',
]);
});
@@ -1,12 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createHandleAnilistSetupWindowClosedHandler,
createMaybeFocusExistingAnilistSetupWindowHandler,
createHandleAnilistSetupWindowOpenedHandler,
createAnilistSetupDidFailLoadHandler,
createAnilistSetupDidFinishLoadHandler,
createAnilistSetupDidNavigateHandler,
createAnilistSetupFallbackHandler,
createAnilistSetupWillNavigateHandler,
createAnilistSetupWillRedirectHandler,
@@ -32,20 +27,6 @@ test('manual anilist setup submission forwards access token to callback consumer
assert.ok(consumed[0]!.includes('https://anilist.subminer.moe/#access_token=abc123'));
});
test('maybe focus anilist setup window focuses existing window', () => {
let focused = false;
const handler = createMaybeFocusExistingAnilistSetupWindowHandler({
getSetupWindow: () => ({
focus: () => {
focused = true;
},
}),
});
const handled = handler();
assert.equal(handled, true);
assert.equal(focused, true);
});
test('manual anilist setup submission warns on missing token', () => {
const warnings: string[] = [];
const handleSubmission = createHandleManualAnilistSetupSubmissionHandler({
@@ -153,35 +134,6 @@ test('anilist setup will-redirect handler prevents callback redirects', () => {
assert.equal(prevented, true);
});
test('anilist setup did-navigate handler consumes callback url', () => {
const seen: string[] = [];
const handler = createAnilistSetupDidNavigateHandler({
consumeCallbackUrl: (url) => {
seen.push(url);
return true;
},
});
handler('https://anilist.subminer.moe/#access_token=abc');
assert.deepEqual(seen, ['https://anilist.subminer.moe/#access_token=abc']);
});
test('anilist setup did-fail-load handler forwards details', () => {
const seen: Array<{ errorCode: number; errorDescription: string; validatedURL: string }> = [];
const handler = createAnilistSetupDidFailLoadHandler({
onLoadFailure: (details) => seen.push(details),
});
handler({
errorCode: -3,
errorDescription: 'timeout',
validatedURL: 'https://anilist.co/api/v2/oauth/authorize',
});
assert.equal(seen.length, 1);
assert.equal(seen[0]!.errorCode, -3);
});
test('anilist setup did-finish-load handler triggers fallback on blank page', () => {
const calls: string[] = [];
const handler = createAnilistSetupDidFinishLoadHandler({
@@ -204,28 +156,6 @@ test('anilist setup did-finish-load handler no-ops on non-blank page', () => {
assert.equal(calls.length, 0);
});
test('anilist setup window closed handler clears references', () => {
const calls: string[] = [];
const handler = createHandleAnilistSetupWindowClosedHandler({
clearSetupWindow: () => calls.push('clear-window'),
setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`),
});
handler();
assert.deepEqual(calls, ['clear-window', 'opened:no']);
});
test('anilist setup window opened handler sets references', () => {
const calls: string[] = [];
const handler = createHandleAnilistSetupWindowOpenedHandler({
setSetupWindow: () => calls.push('set-window'),
setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`),
});
handler();
assert.deepEqual(calls, ['set-window', 'opened:yes']);
});
test('open anilist setup handler no-ops when existing setup window focused', () => {
const calls: string[] = [];
const handler = createOpenAnilistSetupWindowHandler({
-33
View File
@@ -116,39 +116,6 @@ test('consumeAnilistSetupCallbackUrl persists token and closes window for callba
}
});
test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL', () => {
const originalDateNow = Date.now;
const events: string[] = [];
try {
Date.now = () => 120_000;
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
saveToken: (value: string) => {
events.push(`save:${value}`);
return true;
},
setCachedToken: (value: string) => events.push(`cache:${value}`),
setResolvedState: (timestampMs: number) =>
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
onSuccess: () => events.push('success'),
closeWindow: () => events.push('close'),
});
assert.equal(handled, true);
assert.deepEqual(events, [
'save:saved-token',
'cache:saved-token',
'state:ok',
'opened:false',
'success',
'close',
]);
} finally {
Date.now = originalDateNow;
}
});
test('consumeAnilistSetupCallbackUrl keeps setup open when token persistence fails', () => {
const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({
@@ -1,34 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildRefreshAnilistClientSecretStateMainDepsHandler } from './anilist-token-refresh-main-deps';
test('refresh anilist client secret state main deps builder maps callbacks', () => {
const calls: string[] = [];
const config = { anilist: { accessToken: 'token' } };
const deps = createBuildRefreshAnilistClientSecretStateMainDepsHandler({
getResolvedConfig: () => config as never,
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => 'cached',
setCachedAccessToken: () => calls.push('set-cache'),
saveStoredToken: () => calls.push('save'),
loadStoredToken: () => 'stored',
setClientSecretState: () => calls.push('set-state'),
getAnilistSetupPageOpened: () => false,
setAnilistSetupPageOpened: () => calls.push('set-opened'),
openAnilistSetupWindow: () => calls.push('open-window'),
now: () => 123,
})();
assert.equal(deps.getResolvedConfig(), config);
assert.equal(deps.isAnilistTrackingEnabled(config as never), true);
assert.equal(deps.getCachedAccessToken(), 'cached');
deps.setCachedAccessToken(null);
deps.saveStoredToken('x');
assert.equal(deps.loadStoredToken(), 'stored');
deps.setClientSecretState({} as never);
assert.equal(deps.getAnilistSetupPageOpened(), false);
deps.setAnilistSetupPageOpened(true);
deps.openAnilistSetupWindow();
assert.equal(deps.now(), 123);
assert.deepEqual(calls, ['set-cache', 'save', 'set-state', 'set-opened', 'open-window']);
});
@@ -1,85 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildMarkLastCardAsAudioCardMainDepsHandler,
createBuildMineSentenceCardMainDepsHandler,
createBuildRefreshKnownWordCacheMainDepsHandler,
createBuildTriggerFieldGroupingMainDepsHandler,
createBuildUpdateLastCardFromClipboardMainDepsHandler,
} from './anki-actions-main-deps';
test('anki action main deps builders map callbacks', async () => {
const calls: string[] = [];
const update = createBuildUpdateLastCardFromClipboardMainDepsHandler({
getAnkiIntegration: () => ({ enabled: true }),
readClipboardText: () => 'clip',
showMpvOsd: (text) => calls.push(`osd:${text}`),
updateLastCardFromClipboardCore: async () => {
calls.push('update');
},
})();
assert.deepEqual(update.getAnkiIntegration(), { enabled: true });
assert.equal(update.readClipboardText(), 'clip');
update.showMpvOsd('x');
await update.updateLastCardFromClipboardCore({
ankiIntegration: { enabled: true },
readClipboardText: () => '',
showMpvOsd: () => {},
});
const refresh = createBuildRefreshKnownWordCacheMainDepsHandler({
getAnkiIntegration: () => null,
missingIntegrationMessage: 'missing',
})();
assert.equal(refresh.getAnkiIntegration(), null);
assert.equal(refresh.missingIntegrationMessage, 'missing');
const fieldGrouping = createBuildTriggerFieldGroupingMainDepsHandler({
getAnkiIntegration: () => ({ enabled: true }),
showMpvOsd: (text) => calls.push(`fg:${text}`),
triggerFieldGroupingCore: async () => {
calls.push('trigger');
},
})();
fieldGrouping.showMpvOsd('fg');
await fieldGrouping.triggerFieldGroupingCore({
ankiIntegration: { enabled: true },
showMpvOsd: () => {},
});
const markAudio = createBuildMarkLastCardAsAudioCardMainDepsHandler({
getAnkiIntegration: () => ({ enabled: true }),
showMpvOsd: (text) => calls.push(`audio:${text}`),
markLastCardAsAudioCardCore: async () => {
calls.push('mark');
},
})();
markAudio.showMpvOsd('a');
await markAudio.markLastCardAsAudioCardCore({
ankiIntegration: { enabled: true },
showMpvOsd: () => {},
});
const mine = createBuildMineSentenceCardMainDepsHandler({
getAnkiIntegration: () => ({ enabled: true }),
getMpvClient: () => ({ connected: true }),
getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }),
showMpvOsd: (text) => calls.push(`mine:${text}`),
mineSentenceCardCore: async () => true,
})();
assert.deepEqual(mine.getMpvClient(), { connected: true });
assert.deepEqual(mine.getPrimarySubtitle?.(), {
text: '正式な字幕',
startTime: 1,
endTime: 3,
});
mine.showMpvOsd('m');
await mine.mineSentenceCardCore({
ankiIntegration: { enabled: true },
mpvClient: { connected: true },
showMpvOsd: () => {},
});
assert.deepEqual(calls, ['osd:x', 'update', 'fg:fg', 'trigger', 'audio:a', 'mark', 'mine:m']);
});
+1 -77
View File
@@ -1,31 +1,6 @@
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']);
});
import { createMineSentenceCardHandler, createRefreshKnownWordCacheHandler } from './anki-actions';
test('refresh known word cache handler throws when Anki integration missing', async () => {
const refresh = createRefreshKnownWordCacheHandler({
@@ -36,57 +11,6 @@ test('refresh known word cache handler throws when Anki integration missing', as
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 leaves card counting to card creation', 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;
},
});
created = false;
await mineSentenceCard();
created = true;
await mineSentenceCard();
assert.deepEqual(calls, ['osd:mine', 'osd:mine']);
});
test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => {
const primarySubtitle = { text: '正式な字幕', startTime: 1, endTime: 3 };
const mineSentenceCard = createMineSentenceCardHandler({
+85 -140
View File
@@ -3,10 +3,62 @@ import assert from 'node:assert/strict';
import {
createForceQuitHandler,
createOnWillQuitCleanupHandler,
createRestoreWindowsOnActivateHandler,
createShouldRestoreWindowsOnActivateHandler,
} from './app-lifecycle-actions';
type CleanupDeps = Parameters<typeof createOnWillQuitCleanupHandler>[0];
// Every cleanup step records its own dep name into `calls` unless overridden.
function makeCleanupDeps(calls: string[], overrides: Partial<CleanupDeps> = {}): CleanupDeps {
const record = (name: string) => () => {
calls.push(name);
};
return {
destroyTray: record('destroyTray'),
stopConfigHotReload: record('stopConfigHotReload'),
restorePreviousSecondarySubVisibility: record('restorePreviousSecondarySubVisibility'),
restoreMpvSubVisibility: record('restoreMpvSubVisibility'),
unregisterAllGlobalShortcuts: record('unregisterAllGlobalShortcuts'),
stopSubtitleWebsocket: record('stopSubtitleWebsocket'),
stopTexthookerService: record('stopTexthookerService'),
stopSyncAutoScheduler: record('stopSyncAutoScheduler'),
clearWindowsVisibleOverlayForegroundPollLoop: record(
'clearWindowsVisibleOverlayForegroundPollLoop',
),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: record(
'clearLinuxMpvFullscreenOverlayRefreshTimeouts',
),
destroyMainOverlayWindow: record('destroyMainOverlayWindow'),
destroyModalOverlayWindow: record('destroyModalOverlayWindow'),
destroyYomitanParserWindow: record('destroyYomitanParserWindow'),
clearYomitanParserState: record('clearYomitanParserState'),
stopWindowTracker: record('stopWindowTracker'),
flushMpvLog: record('flushMpvLog'),
destroyMpvSocket: record('destroyMpvSocket'),
clearReconnectTimer: record('clearReconnectTimer'),
destroySubtitleTimingTracker: record('destroySubtitleTimingTracker'),
stopStatsServer: record('stopStatsServer'),
destroyImmersionTracker: record('destroyImmersionTracker'),
destroyAnkiIntegration: record('destroyAnkiIntegration'),
destroyAnilistSetupWindow: record('destroyAnilistSetupWindow'),
clearAnilistSetupWindow: record('clearAnilistSetupWindow'),
destroyJellyfinSetupWindow: record('destroyJellyfinSetupWindow'),
clearJellyfinSetupWindow: record('clearJellyfinSetupWindow'),
destroyFirstRunSetupWindow: record('destroyFirstRunSetupWindow'),
clearFirstRunSetupWindow: record('clearFirstRunSetupWindow'),
destroyYomitanSettingsWindow: record('destroyYomitanSettingsWindow'),
clearYomitanSettingsWindow: record('clearYomitanSettingsWindow'),
stopJellyfinRemoteSession: record('stopJellyfinRemoteSession'),
cleanupInternalSubtitleTrackCache: record('cleanupInternalSubtitleTrackCache'),
cleanupYoutubeSubtitleTempDirs: record('cleanupYoutubeSubtitleTempDirs'),
cleanupYoutubeMediaCache: record('cleanupYoutubeMediaCache'),
cleanupRemoteMediaWindows: record('cleanupRemoteMediaWindows'),
cleanupJellyfinSubtitleCache: record('cleanupJellyfinSubtitleCache'),
stopDiscordPresenceService: record('stopDiscordPresenceService'),
...overrides,
};
}
test('forced quit finalizes stats before exiting, even when finalization throws', async () => {
for (const fails of [false, true]) {
const calls: string[] = [];
@@ -26,86 +78,44 @@ test('forced quit finalizes stats before exiting, even when finalization throws'
}
});
test('on will quit cleanup handler runs all cleanup steps', async () => {
test('on will quit cleanup handler flushes before teardown and awaits stats before dependents', async () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => {
calls.push('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-poll'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
calls.push('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'),
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
stopWindowTracker: () => calls.push('stop-tracker'),
flushMpvLog: () => calls.push('flush-mpv-log'),
destroyMpvSocket: () => calls.push('destroy-socket'),
clearReconnectTimer: () => calls.push('clear-reconnect'),
destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'),
stopStatsServer: async () => {
calls.push('stop-stats-server-start');
await Promise.resolve();
calls.push('stop-stats-server-complete');
},
destroyImmersionTracker: async () => {
await Promise.resolve();
calls.push('destroy-immersion');
},
destroyAnkiIntegration: () => calls.push('destroy-anki'),
destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'),
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'),
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
destroyFirstRunSetupWindow: () => calls.push('destroy-first-run-window'),
clearFirstRunSetupWindow: () => calls.push('clear-first-run-window'),
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
const cleanup = createOnWillQuitCleanupHandler(
makeCleanupDeps(calls, {
stopStatsServer: async () => {
await Promise.resolve();
calls.push('stopStatsServer:complete');
},
destroyImmersionTracker: async () => {
await Promise.resolve();
calls.push('destroyImmersionTracker');
},
}),
);
await cleanup();
assert.equal(calls.length, 38);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
assert.ok(calls.includes('cleanup-internal-subtitles'));
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('cleanup-youtube-media'));
assert.ok(calls.includes('cleanup-remote-media-windows'));
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
assert.ok(calls.indexOf('stop-stats-server-complete') < calls.indexOf('destroy-immersion'));
assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('destroy-anki'));
assert.ok(calls.indexOf('flushMpvLog') < calls.indexOf('destroyMpvSocket'));
assert.ok(calls.indexOf('stopStatsServer:complete') < calls.indexOf('destroyImmersionTracker'));
assert.ok(calls.indexOf('destroyImmersionTracker') < calls.indexOf('destroyAnkiIntegration'));
});
test('forced quit waits for asynchronous stats finalization', async () => {
test('on will quit cleanup handler cleans subtitle caches when stopping remote session fails', async () => {
const calls: string[] = [];
await createForceQuitHandler({
destroyImmersionTracker: async () => {
await Promise.resolve();
calls.push('finalized');
},
logError: () => calls.push('error'),
exit: () => calls.push('exit'),
})();
assert.deepEqual(calls, ['finalized', 'exit']);
const cleanup = createOnWillQuitCleanupHandler(
makeCleanupDeps(calls, {
stopJellyfinRemoteSession: () => {
calls.push('stopJellyfinRemoteSession');
throw new Error('stop failed');
},
}),
);
await assert.rejects(cleanup(), /stop failed/);
assert.deepEqual(calls.slice(calls.indexOf('stopJellyfinRemoteSession')), [
'stopJellyfinRemoteSession',
'cleanupJellyfinSubtitleCache',
'cleanupInternalSubtitleTrackCache',
]);
});
test('forced quit exits when asynchronous stats finalization never settles', async () => {
@@ -121,59 +131,6 @@ test('forced quit exits when asynchronous stats finalization never settles', asy
assert.deepEqual(calls, ['timeout', 'exit']);
});
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', async () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {},
destroyModalOverlayWindow: () => {},
destroyYomitanParserWindow: () => {},
clearYomitanParserState: () => {},
stopWindowTracker: () => {},
flushMpvLog: () => {},
destroyMpvSocket: () => {},
clearReconnectTimer: () => {},
destroySubtitleTimingTracker: () => {},
stopStatsServer: () => {},
destroyImmersionTracker: () => {},
destroyAnkiIntegration: () => {},
destroyAnilistSetupWindow: () => {},
clearAnilistSetupWindow: () => {},
destroyJellyfinSetupWindow: () => {},
clearJellyfinSetupWindow: () => {},
destroyFirstRunSetupWindow: () => {},
clearFirstRunSetupWindow: () => {},
destroyYomitanSettingsWindow: () => {},
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {
calls.push('stop-jellyfin-remote');
throw new Error('stop failed');
},
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
await assert.rejects(cleanup(), /stop failed/);
assert.deepEqual(calls, [
'stop-jellyfin-remote',
'cleanup-jellyfin-subtitles',
'cleanup-internal-subtitles',
]);
});
test('should restore windows on activate requires initialized runtime and no windows', () => {
let initialized = false;
let windowCount = 1;
@@ -188,15 +145,3 @@ test('should restore windows on activate requires initialized runtime and no win
windowCount = 0;
assert.equal(shouldRestore(), true);
});
test('restore windows on activate recreates windows then syncs visibility', () => {
const calls: string[] = [];
const restore = createRestoreWindowsOnActivateHandler({
createMainWindow: () => calls.push('main'),
updateVisibleOverlayVisibility: () => calls.push('visible-sync'),
syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'),
});
restore();
assert.deepEqual(calls, ['main', 'visible-sync', 'mpv-sync']);
});
@@ -1,30 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildRestoreWindowsOnActivateMainDepsHandler,
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
} from './app-lifecycle-main-activate';
test('should restore windows on activate deps builder maps visibility state checks', () => {
const deps = createBuildShouldRestoreWindowsOnActivateMainDepsHandler({
isOverlayRuntimeInitialized: () => true,
getAllWindowCount: () => 0,
})();
assert.equal(deps.isOverlayRuntimeInitialized(), true);
assert.equal(deps.getAllWindowCount(), 0);
});
test('restore windows on activate deps builder maps all restoration callbacks', () => {
const calls: string[] = [];
const deps = createBuildRestoreWindowsOnActivateMainDepsHandler({
createMainWindow: () => calls.push('main'),
updateVisibleOverlayVisibility: () => calls.push('visible'),
syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'),
})();
deps.createMainWindow();
deps.updateVisibleOverlayVisibility();
deps.syncOverlayMpvSubtitleSuppression();
assert.deepEqual(calls, ['main', 'visible', 'mpv-sync']);
});
@@ -3,231 +3,134 @@ import test from 'node:test';
import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup';
import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions';
test('cleanup deps builder returns handlers that guard optional runtime objects', async () => {
const calls: string[] = [];
let reconnectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {}, 60_000);
let immersionTracker: { destroy: () => Promise<void> } | null = {
destroy: async () => {
await Promise.resolve();
calls.push('destroy-immersion');
},
type BuilderDeps = Parameters<typeof createBuildOnWillQuitCleanupDepsHandler>[0];
// Defaults model an app that is ready but has no live runtime objects.
function buildCleanupDeps(overrides: Partial<BuilderDeps> = {}) {
const noop = () => {};
const none = () => null;
return createBuildOnWillQuitCleanupDepsHandler({
destroyTray: noop,
stopConfigHotReload: noop,
restorePreviousSecondarySubVisibility: noop,
restoreMpvSubVisibility: noop,
isAppReady: () => true,
unregisterAllGlobalShortcuts: noop,
stopSubtitleWebsocket: noop,
stopTexthookerService: noop,
stopSyncAutoScheduler: noop,
clearWindowsVisibleOverlayForegroundPollLoop: noop,
clearLinuxMpvFullscreenOverlayRefreshTimeouts: noop,
getMainOverlayWindow: none,
clearMainOverlayWindow: noop,
getModalOverlayWindow: none,
clearModalOverlayWindow: noop,
getYomitanParserWindow: none,
clearYomitanParserState: noop,
getWindowTracker: none,
flushMpvLog: noop,
getMpvSocket: none,
getReconnectTimer: none,
clearReconnectTimerRef: noop,
getSubtitleTimingTracker: none,
getImmersionTracker: none,
stopStatsServer: noop,
clearImmersionTracker: noop,
getAnkiIntegration: none,
getAnilistSetupWindow: none,
clearAnilistSetupWindow: noop,
getJellyfinSetupWindow: none,
clearJellyfinSetupWindow: noop,
getFirstRunSetupWindow: none,
clearFirstRunSetupWindow: noop,
getYomitanSettingsWindow: none,
clearYomitanSettingsWindow: noop,
stopJellyfinRemoteSession: noop,
cleanupInternalSubtitleTrackCache: noop,
cleanupYoutubeSubtitleTempDirs: noop,
cleanupYoutubeMediaCache: noop,
cleanupRemoteMediaWindows: noop,
cleanupJellyfinSubtitleCache: noop,
stopDiscordPresenceService: noop,
...overrides,
})();
}
function fakeWindow(calls: string[], destroyed: boolean) {
return {
isDestroyed: () => destroyed,
destroy: () => calls.push('destroy'),
};
}
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => calls.push('stop-config'),
restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
isAppReady: () => true,
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
stopSyncAutoScheduler: () => {
calls.push('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
calls.push('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'),
getMainOverlayWindow: () => ({
isDestroyed: () => false,
destroy: () => calls.push('destroy-main-overlay-window'),
}),
clearMainOverlayWindow: () => calls.push('clear-main-overlay-window'),
getModalOverlayWindow: () => ({
isDestroyed: () => false,
destroy: () => calls.push('destroy-modal-overlay-window'),
}),
clearModalOverlayWindow: () => calls.push('clear-modal-overlay-window'),
getYomitanParserWindow: () => ({
isDestroyed: () => false,
destroy: () => calls.push('destroy-yomitan-window'),
}),
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
getWindowTracker: () => ({ stop: () => calls.push('stop-tracker') }),
flushMpvLog: () => calls.push('flush-mpv-log'),
getMpvSocket: () => ({ destroy: () => calls.push('destroy-socket') }),
getReconnectTimer: () => reconnectTimer,
clearReconnectTimerRef: () => {
reconnectTimer = null;
calls.push('clear-reconnect-ref');
},
getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }),
getImmersionTracker: () => immersionTracker,
stopStatsServer: () => {
calls.push('stop-stats-server');
},
clearImmersionTracker: () => {
immersionTracker = null;
calls.push('clear-immersion-ref');
},
getAnkiIntegration: () => ({ destroy: () => calls.push('destroy-anki') }),
getAnilistSetupWindow: () => ({ destroy: () => calls.push('destroy-anilist-window') }),
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
getJellyfinSetupWindow: () => ({ destroy: () => calls.push('destroy-jellyfin-window') }),
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
getFirstRunSetupWindow: () => ({ destroy: () => calls.push('destroy-first-run-window') }),
clearFirstRunSetupWindow: () => calls.push('clear-first-run-window'),
getYomitanSettingsWindow: () => ({
destroy: () => calls.push('destroy-yomitan-settings-window'),
}),
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
await cleanup();
assert.ok(calls.includes('destroy-tray'));
assert.ok(calls.includes('destroy-main-overlay-window'));
assert.ok(calls.includes('clear-main-overlay-window'));
assert.ok(calls.includes('destroy-modal-overlay-window'));
assert.ok(calls.includes('clear-modal-overlay-window'));
assert.ok(calls.includes('destroy-yomitan-window'));
assert.ok(calls.includes('flush-mpv-log'));
assert.ok(calls.includes('destroy-socket'));
assert.ok(calls.includes('clear-reconnect-ref'));
assert.ok(calls.includes('destroy-immersion'));
assert.ok(calls.includes('clear-immersion-ref'));
assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('clear-immersion-ref'));
assert.ok(calls.includes('destroy-first-run-window'));
assert.ok(calls.includes('destroy-yomitan-settings-window'));
assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('cleanup-internal-subtitles'));
assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('cleanup-youtube-media'));
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
assert.ok(calls.includes('stop-discord-presence'));
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.equal(reconnectTimer, null);
assert.equal(immersionTracker, null);
test('cleanup deps builder runs the full quit cleanup with no runtime objects', async () => {
await createOnWillQuitCleanupHandler(buildCleanupDeps())();
});
test('cleanup deps builder skips destroyed yomitan window', () => {
const calls: string[] = [];
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
isAppReady: () => true,
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => ({
isDestroyed: () => true,
destroy: () => calls.push('destroy-main-overlay-window'),
}),
clearMainOverlayWindow: () => calls.push('clear-main-overlay-window'),
getModalOverlayWindow: () => ({
isDestroyed: () => true,
destroy: () => calls.push('destroy-modal-overlay-window'),
}),
clearModalOverlayWindow: () => calls.push('clear-modal-overlay-window'),
getYomitanParserWindow: () => ({
isDestroyed: () => true,
destroy: () => calls.push('destroy-yomitan-window'),
}),
clearYomitanParserState: () => {},
getWindowTracker: () => null,
flushMpvLog: () => {},
getMpvSocket: () => null,
getReconnectTimer: () => null,
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
stopStatsServer: () => {},
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
clearAnilistSetupWindow: () => {},
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
getFirstRunSetupWindow: () => null,
clearFirstRunSetupWindow: () => {},
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
for (const destroyed of [false, true]) {
test(`cleanup deps builder ${destroyed ? 'skips' : 'destroys and clears'} ${destroyed ? 'already destroyed' : 'live'} overlay windows`, () => {
const calls: string[] = [];
const deps = buildCleanupDeps({
getMainOverlayWindow: () => fakeWindow(calls, destroyed),
clearMainOverlayWindow: () => calls.push('clear'),
getModalOverlayWindow: () => fakeWindow(calls, destroyed),
clearModalOverlayWindow: () => calls.push('clear'),
getYomitanParserWindow: () => fakeWindow(calls, destroyed),
});
deps.destroyMainOverlayWindow();
deps.destroyModalOverlayWindow();
deps.destroyYomitanParserWindow();
assert.deepEqual(calls, destroyed ? [] : ['destroy', 'clear', 'destroy', 'clear', 'destroy']);
});
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
cleanup();
assert.deepEqual(calls, []);
});
}
test('cleanup deps builder skips global shortcut cleanup before app ready', () => {
const calls: string[] = [];
const depsFactory = createBuildOnWillQuitCleanupDepsHandler({
destroyTray: () => calls.push('destroy-tray'),
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
const deps = buildCleanupDeps({
isAppReady: () => false,
unregisterAllGlobalShortcuts: () => {
throw new Error('globalShortcut cannot be used before the app is ready');
},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null,
clearMainOverlayWindow: () => {},
getModalOverlayWindow: () => null,
clearModalOverlayWindow: () => {},
getYomitanParserWindow: () => null,
clearYomitanParserState: () => {},
getWindowTracker: () => null,
flushMpvLog: () => {},
getMpvSocket: () => null,
getReconnectTimer: () => null,
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
stopStatsServer: () => {},
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
clearAnilistSetupWindow: () => {},
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
getFirstRunSetupWindow: () => null,
clearFirstRunSetupWindow: () => {},
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
});
const cleanup = createOnWillQuitCleanupHandler(depsFactory());
cleanup();
deps.unregisterAllGlobalShortcuts();
});
assert.deepEqual(calls, ['destroy-tray']);
test('cleanup deps builder clears the reconnect timer and its reference', () => {
let reconnectTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
assert.fail('reconnect timer should have been cleared');
}, 0);
const deps = buildCleanupDeps({
getReconnectTimer: () => reconnectTimer,
clearReconnectTimerRef: () => {
reconnectTimer = null;
},
});
deps.clearReconnectTimer();
assert.equal(reconnectTimer, null);
});
test('cleanup deps builder clears the immersion tracker only after destroy settles', async () => {
const calls: string[] = [];
let immersionTracker: { destroy: () => Promise<void> } | null = {
destroy: async () => {
await Promise.resolve();
calls.push('destroy');
},
};
const deps = buildCleanupDeps({
getImmersionTracker: () => immersionTracker,
clearImmersionTracker: () => {
immersionTracker = null;
calls.push('clear');
},
});
await deps.destroyImmersionTracker();
assert.deepEqual(calls, ['destroy', 'clear']);
assert.equal(immersionTracker, null);
});
@@ -1,95 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildAppReadyRuntimeMainDepsHandler } from './app-ready-main-deps';
test('app-ready main deps builder returns mapped app-ready runtime deps', async () => {
const calls: string[] = [];
const onReady = createBuildAppReadyRuntimeMainDepsHandler({
ensureDefaultConfigBootstrap: () => calls.push('bootstrap-config'),
loadSubtitlePosition: () => calls.push('load-subtitle-position'),
resolveKeybindings: () => calls.push('resolve-keybindings'),
createMpvClient: () => calls.push('create-mpv-client'),
reloadConfig: () => calls.push('reload-config'),
getResolvedConfig: () => ({ websocket: {} }),
getConfigWarnings: () => [],
logConfigWarning: () => calls.push('log-config-warning'),
initRuntimeOptionsManager: () => calls.push('init-runtime-options'),
setSecondarySubMode: () => calls.push('set-secondary-sub-mode'),
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
defaultAnnotationWebsocketPort: 6678,
defaultTexthookerPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => calls.push('start-ws'),
startAnnotationWebsocket: () => calls.push('start-annotation-ws'),
startTexthooker: () => calls.push('start-texthooker'),
log: () => calls.push('log'),
setLogLevel: () => calls.push('set-log-level'),
createMecabTokenizerAndCheck: async () => {
calls.push('create-mecab');
},
createSubtitleTimingTracker: () => calls.push('create-subtitle-tracker'),
createImmersionTracker: () => calls.push('create-immersion'),
startJellyfinRemoteSession: async () => {
calls.push('start-jellyfin');
},
loadYomitanExtension: async () => {
calls.push('load-yomitan');
},
ensureYomitanExtensionLoaded: async () => {
calls.push('ensure-yomitan');
},
handleFirstRunSetup: async () => {
calls.push('handle-first-run-setup');
},
prewarmSubtitleDictionaries: async () => {
calls.push('prewarm-dicts');
},
startBackgroundWarmups: () => calls.push('start-warmups'),
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true,
setVisibleOverlayVisible: () => calls.push('set-visible-overlay'),
initializeOverlayRuntime: () => calls.push('init-overlay'),
handleInitialArgs: () => calls.push('handle-initial-args'),
onCriticalConfigErrors: () => {
throw new Error('should not call');
},
logDebug: () => calls.push('debug'),
now: () => 123,
})();
assert.equal(onReady.defaultSecondarySubMode, 'hover');
assert.equal(onReady.defaultWebsocketPort, 5174);
assert.equal(onReady.defaultAnnotationWebsocketPort, 6678);
assert.equal(onReady.defaultTexthookerPort, 5174);
assert.equal(onReady.texthookerOnlyMode, false);
assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true);
assert.equal(onReady.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.(), true);
assert.equal(onReady.now?.(), 123);
onReady.loadSubtitlePosition();
onReady.resolveKeybindings();
onReady.createMpvClient();
await onReady.createMecabTokenizerAndCheck();
await onReady.loadYomitanExtension();
await onReady.ensureYomitanExtensionLoaded?.();
await onReady.handleFirstRunSetup();
await onReady.prewarmSubtitleDictionaries?.();
onReady.startBackgroundWarmups();
onReady.startTexthooker(5174);
onReady.setVisibleOverlayVisible(true);
assert.deepEqual(calls, [
'load-subtitle-position',
'resolve-keybindings',
'create-mpv-client',
'create-mecab',
'load-yomitan',
'ensure-yomitan',
'handle-first-run-setup',
'prewarm-dicts',
'start-warmups',
'start-texthooker',
'set-visible-overlay',
]);
});
+1 -82
View File
@@ -1,11 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildDestroyTrayMainDepsHandler,
createBuildEnsureTrayMainDepsHandler,
createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler,
createBuildOpenYomitanSettingsMainDepsHandler,
} from './app-runtime-main-deps';
import { createBuildEnsureTrayMainDepsHandler } from './app-runtime-main-deps';
test('ensure tray main deps trigger overlay bootstrap on tray click when runtime not initialized', () => {
const calls: string[] = [];
@@ -28,79 +23,3 @@ test('ensure tray main deps trigger overlay bootstrap on tray click when runtime
deps.ensureOverlayVisibleFromTrayClick();
assert.deepEqual(calls, ['init-overlay', 'set-visible:true']);
});
test('destroy tray main deps map passthrough getters/setters', () => {
let tray: unknown = { id: 'tray' };
const deps = createBuildDestroyTrayMainDepsHandler({
getTray: () => tray,
setTray: (next) => {
tray = next;
},
})();
assert.deepEqual(deps.getTray(), { id: 'tray' });
deps.setTray(null);
assert.equal(tray, null);
});
test('initialize overlay runtime main deps map build options and callbacks', () => {
const calls: string[] = [];
const options = { id: 'opts' };
const deps = createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler({
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntimeCore: (value) => {
calls.push(`core:${JSON.stringify(value)}`);
},
buildOptions: () => options,
setOverlayRuntimeInitialized: (initialized) => calls.push(`set-initialized:${initialized}`),
startBackgroundWarmups: () => calls.push('warmups'),
})();
assert.equal(deps.isOverlayRuntimeInitialized(), false);
assert.equal(deps.buildOptions(), options);
assert.equal(deps.initializeOverlayRuntimeCore(options), undefined);
deps.setOverlayRuntimeInitialized(true);
deps.startBackgroundWarmups();
assert.deepEqual(calls, ['core:{"id":"opts"}', 'set-initialized:true', 'warmups']);
});
test('open yomitan settings main deps map async open callbacks', async () => {
const calls: string[] = [];
let currentWindow: unknown = null;
const extension = { id: 'ext' };
const startupLoad = Promise.resolve(extension);
const yomitanSession = { id: 'session' };
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
ensureYomitanExtensionLoaded: async () => extension,
getYomitanExtension: () => extension,
getYomitanExtensionLoadInFlight: () => startupLoad,
openYomitanSettingsWindow: ({ yomitanExt, yomitanSession: forwardedSession }) =>
calls.push(
`open:${(yomitanExt as { id: string }).id}:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`,
),
getExistingWindow: () => currentWindow,
setWindow: (window) => {
currentWindow = window;
calls.push('set-window');
},
getYomitanSession: () => yomitanSession,
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
})();
assert.equal(await deps.ensureYomitanExtensionLoaded(), extension);
assert.equal(deps.getYomitanExtension?.(), extension);
assert.equal(deps.getYomitanExtensionLoadInFlight?.(), startupLoad);
assert.equal(deps.getExistingWindow(), null);
deps.setWindow({ id: 'win' });
deps.openYomitanSettingsWindow({
yomitanExt: extension,
getExistingWindow: () => deps.getExistingWindow(),
setWindow: (window) => deps.setWindow(window),
yomitanSession: deps.getYomitanSession(),
});
deps.logWarn('warn');
deps.logError('error', new Error('boom'));
assert.deepEqual(calls, ['set-window', 'open:ext:session', 'warn:warn', 'error:error']);
assert.deepEqual(currentWindow, { id: 'win' });
});
+165 -523
View File
@@ -1,620 +1,262 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAutoplayReadyGate } from './autoplay-ready-gate';
import type { MergedToken } from '../../types';
import {
createAutoplayReadyGate,
type AutoplayReadyGateDeps,
type AutoplayReadySignal,
} from './autoplay-ready-gate';
test('autoplay ready gate suppresses duplicate media signals for the same media', async () => {
const commands: Array<Array<string | boolean>> = [];
type MpvCommand = Array<string | boolean>;
const PLUGIN_READY: MpvCommand = ['script-message', 'subminer-autoplay-ready'];
const FAKE_TIMER = 1 as unknown as ReturnType<typeof setTimeout>;
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
const isUnpause = (command: MpvCommand): boolean =>
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false;
// Builds a gate against a connected fake mpv that starts paused. An unpause command
// flips `mpv.paused`; scheduled retries queue in `scheduled` until run explicitly.
function createGate(overrides: Partial<AutoplayReadyGateDeps> = {}) {
const commands: MpvCommand[] = [];
const scheduled: Array<() => void> = [];
const mpv = { paused: true };
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
getPlaybackPaused: () => mpv.paused,
getMpvClient: () => ({
connected: true,
requestProperty: async () => mpv.paused,
send: ({ command }) => {
commands.push(command);
if (isUnpause(command)) mpv.paused = false;
},
}),
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
commands.push(PLUGIN_READY);
},
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
return FAKE_TIMER;
},
logDebug: () => {},
...overrides,
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
return {
gate,
commands,
scheduled,
mpv,
pluginSignals: () => commands.filter((command) => command[0] === 'script-message'),
unpauseCount: () => commands.filter(isUnpause).length,
// Delivers a forced-while-paused subtitle signal and lets async release attempts settle.
signal: async (text: string, tokens: MergedToken[] | null = null) => {
gate.maybeSignalPluginAutoplayReady({ text, tokens }, { forceWhilePaused: true });
await settle();
},
// Runs queued retries one at a time, including retries they schedule, until none remain.
runScheduled: async () => {
for (let callback = scheduled.shift(); callback; callback = scheduled.shift()) {
callback();
await settle();
}
},
};
}
await new Promise((resolve) => setTimeout(resolve, 0));
const firstScheduled = scheduled.shift();
firstScheduled?.();
await new Promise((resolve) => setTimeout(resolve, 0));
test('autoplay ready gate signals the plugin once per media across duplicate signals and release retries', async () => {
const h = createGate();
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.ok(
commands.some(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
),
);
await h.signal('字幕');
await h.signal('字幕');
await h.runScheduled();
assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]);
assert.equal(h.unpauseCount(), 1);
});
test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
for (const callback of scheduled.splice(0, 3)) {
callback();
await new Promise((resolve) => setTimeout(resolve, 0));
}
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.equal(
commands.filter(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length > 0,
true,
);
});
test('autoplay ready gate requests overlay pointer recovery when media readiness is signaled', async () => {
const commands: Array<Array<string | boolean>> = [];
test('autoplay ready gate requests overlay pointer recovery once per released media', async () => {
let pointerRecoveryRequests = 0;
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
const h = createGate({
requestOverlayPointerRecovery: () => {
pointerRecoveryRequests += 1;
},
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
gate.maybeSignalPluginAutoplayReady(
{ text: '字幕その2', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
await h.signal('字幕');
await h.signal('字幕その2');
assert.equal(pointerRecoveryRequests, 1);
});
test('autoplay ready gate reports the released autoplay signal once', async () => {
const releasedSignals: string[] = [];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: () => {},
}) as never,
signalPluginAutoplayReady: () => {},
const h = createGate({
onAutoplayReadyReleased: (signal) => {
releasedSignals.push(signal.payload.text);
},
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
gate.maybeSignalPluginAutoplayReady(
{ text: '次の字幕', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
await h.signal('__warm__');
await h.signal('次の字幕');
assert.deepEqual(releasedSignals, ['__warm__']);
});
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
const commands: Array<Array<string | boolean>> = [];
let playbackPaused = true;
const manualPauseCases: Array<{
name: string;
afterManualPause: (h: ReturnType<typeof createGate>) => Promise<void>;
}> = [
{
name: 'autoplay ready gate does not unpause again after a later manual pause on the same media',
afterManualPause: (h) => h.signal('字幕その2'),
},
{
name: 'autoplay ready gate cancels release retries after playback is paused again',
afterManualPause: (h) => h.runScheduled(),
},
];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => playbackPaused,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => playbackPaused,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) {
playbackPaused = false;
}
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
for (const c of manualPauseCases) {
test(c.name, async () => {
const h = createGate();
await h.signal('字幕');
assert.equal(h.unpauseCount(), 1);
h.mpv.paused = true;
await c.afterManualPause(h);
assert.equal(h.unpauseCount(), 1);
assert.equal(h.mpv.paused, true);
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
playbackPaused = true;
gate.maybeSignalPluginAutoplayReady(
{ text: '字幕その2', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
commands.filter(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length,
1,
);
});
test('autoplay ready gate cancels release retries after playback is paused again', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
let playbackPaused = true;
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => playbackPaused,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => playbackPaused,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) {
playbackPaused = false;
}
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
playbackPaused = true;
const retry = scheduled.shift();
retry?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
commands.filter(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length,
1,
);
});
}
test('autoplay ready gate suppresses release after manual current-media dismissal', async () => {
const commands: Array<Array<string | boolean>> = [];
const h = createGate();
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
h.gate.markCurrentMediaAutoplayReady();
await h.signal('字幕');
gate.markCurrentMediaAutoplayReady();
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
assert.deepEqual(h.commands, []);
});
test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => {
const commands: Array<Array<string | boolean>> = [];
let targetReady = false;
const deferredReleaseCases: Array<{
name: string;
release: (h: ReturnType<typeof createGate>) => Promise<void>;
}> = [
{
name: 'autoplay ready gate defers plugin readiness until the signal target is ready',
release: async (h) => {
h.gate.flushPendingAutoplayReadySignal();
await settle();
},
},
{
name: 'autoplay ready gate retries deferred readiness without an external flush event',
release: (h) => h.runScheduled(),
},
];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: () => targetReady,
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
for (const c of deferredReleaseCases) {
test(c.name, async () => {
let targetReady = false;
const h = createGate({ isSignalTargetReady: () => targetReady });
await h.signal('字幕');
assert.deepEqual(h.commands, []);
assert.equal(h.scheduled.length, 1);
targetReady = true;
await c.release(h);
assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]);
assert.equal(h.unpauseCount(), 1);
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
targetReady = true;
gate.flushPendingAutoplayReadySignal();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.equal(
commands.some(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
),
true,
);
});
test('autoplay ready gate retries deferred readiness without an external flush event', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
let targetReady = false;
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: () => targetReady,
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
assert.equal(scheduled.length, 1);
targetReady = true;
scheduled.shift()?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.equal(
commands.some(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
),
true,
);
});
}
test('autoplay ready gate keeps deferred startup readiness retries active for cold starts', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
const h = createGate({ isSignalTargetReady: () => false });
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: () => false,
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
await h.signal('__warm__');
for (let attempt = 1; attempt <= 100; attempt += 1) {
assert.equal(scheduled.length, 1, `missing deferred readiness retry ${attempt}`);
scheduled.shift()?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(h.scheduled.length, 1, `missing deferred readiness retry ${attempt}`);
h.scheduled.shift()?.();
await settle();
}
assert.deepEqual(commands, []);
assert.deepEqual(h.commands, []);
});
test('autoplay ready gate drops deferred readiness after media changes before flush', async () => {
const commands: Array<Array<string | boolean>> = [];
let targetReady = false;
let currentMediaPath = '/media/video-1.mkv';
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
const h = createGate({
getCurrentMediaPath: () => currentMediaPath,
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: () => targetReady,
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
await h.signal('字幕');
currentMediaPath = '/media/video-2.mkv';
targetReady = true;
gate.flushPendingAutoplayReadySignal();
await new Promise((resolve) => setTimeout(resolve, 0));
h.gate.flushPendingAutoplayReadySignal();
await settle();
assert.deepEqual(commands, []);
assert.deepEqual(h.commands, []);
});
test('autoplay ready gate passes the pending subtitle signal to the readiness predicate', async () => {
const commands: Array<Array<string | boolean>> = [];
const observed: Array<Pick<AutoplayReadySignal, 'requestedAtMs'> & { text: string }> = [];
let targetReadyText: string | null = null;
let observedText: string | null = null;
let observedRequestedAtMs: number | null = null;
let now = 1_000;
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: ((signal: { payload: { text: string }; requestedAtMs: number }) => {
observedText = signal.payload.text;
observedRequestedAtMs = signal.requestedAtMs;
const h = createGate({
isSignalTargetReady: (signal) => {
observed.push({ text: signal.payload.text, requestedAtMs: signal.requestedAtMs });
return targetReadyText === signal.payload.text;
}) as never,
now: () => now,
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
now: () => now,
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
await h.signal('字幕');
assert.equal(observedText, '字幕');
assert.equal(observedRequestedAtMs, 1_000);
assert.deepEqual(commands, []);
assert.deepEqual(observed, [{ text: '字幕', requestedAtMs: 1_000 }]);
assert.deepEqual(h.commands, []);
now = 2_000;
targetReadyText = '字幕';
gate.flushPendingAutoplayReadySignal();
await new Promise((resolve) => setTimeout(resolve, 0));
h.gate.flushPendingAutoplayReadySignal();
await settle();
assert.equal(observedRequestedAtMs, 1_000);
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
// The flushed signal keeps its original request time instead of the flush time.
assert.deepEqual(observed.at(-1), { text: '字幕', requestedAtMs: 1_000 });
assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]);
});
function createTokenizationGateHarness(deps: { isTokenizationReady: () => boolean }) {
const commands: Array<Array<string | boolean>> = [];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isTokenizationReady: deps.isTokenizationReady,
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
return { gate, commands };
}
test('autoplay ready gate ignores untokenized signals until tokenization warmup is ready', async () => {
let tokenizationReady = false;
const h = createGate({ isTokenizationReady: () => tokenizationReady });
test('autoplay ready gate ignores untokenized signals while tokenization warmup is pending', async () => {
const { gate, commands } = createTokenizationGateHarness({
isTokenizationReady: () => false,
});
await h.signal('字幕');
assert.deepEqual(h.commands, []);
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
tokenizationReady = true;
await h.signal('字幕');
assert.deepEqual(commands, []);
assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]);
});
test('autoplay ready gate releases tokenized signals while tokenization warmup is pending', async () => {
const { gate, commands } = createTokenizationGateHarness({
isTokenizationReady: () => false,
});
const h = createGate({ isTokenizationReady: () => false });
gate.maybeSignalPluginAutoplayReady(
{ text: '字幕', tokens: [{ surface: '字幕' }] as never },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
await h.signal('字幕', [{ surface: '字幕' }] as unknown as MergedToken[]);
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
});
test('autoplay ready gate releases untokenized signals once tokenization warmup is ready', async () => {
let tokenizationReady = false;
const { gate, commands } = createTokenizationGateHarness({
isTokenizationReady: () => tokenizationReady,
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
tokenizationReady = true;
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]);
});
@@ -3,35 +3,7 @@ import test from 'node:test';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller';
import type { SubtitleData } from '../../types';
import {
createAutoplaySubtitlePrimingRuntime,
setMpvCurrentSecondarySubText,
} from './autoplay-subtitle-priming-runtime';
test('setMpvCurrentSecondarySubText uses client setter when available', () => {
const calls: string[] = [];
const client = {
currentSecondarySubText: '',
setCurrentSecondarySubText: (text: string) => {
calls.push(text);
},
};
setMpvCurrentSecondarySubText(client, 'secondary');
assert.deepEqual(calls, ['secondary']);
assert.equal(client.currentSecondarySubText, '');
});
test('setMpvCurrentSecondarySubText updates client property when setter is unavailable', () => {
const client = {
currentSecondarySubText: '',
};
setMpvCurrentSecondarySubText(client, 'secondary');
assert.equal(client.currentSecondarySubText, 'secondary');
});
import { createAutoplaySubtitlePrimingRuntime } from './autoplay-subtitle-priming-runtime';
test('scheduleSubtitlePrefetchRefresh logs refresh failures from timer callback', async () => {
const logs: string[] = [];
@@ -1,11 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
CHANGELOG_REQUEST_TIMEOUT_MS,
createChangelogRuntime,
withRequestTimeout,
} from './changelog-runtime';
import { createChangelogRuntime, withRequestTimeout } from './changelog-runtime';
import type { FetchLike, FetchResponseLike } from '../update/release-assets';
import { createCurlFetch } from '../update/fetch-adapter';
@@ -115,8 +111,3 @@ test('changelog timeout reaches the curl transport, not just global fetch', asyn
assert.equal(killed, 'SIGKILL', 'the stalled curl process is killed at the changelog deadline');
});
test('changelog request timeout is finite', () => {
assert.ok(Number.isFinite(CHANGELOG_REQUEST_TIMEOUT_MS));
assert.ok(CHANGELOG_REQUEST_TIMEOUT_MS > 0);
});
File diff suppressed because it is too large. Load diff
@@ -1,20 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getCharacterDictionaryDisabledReason,
isCharacterDictionaryRuntimeEnabled,
} from './character-dictionary-availability';
test('character dictionary runtime is enabled when external Yomitan profile is not configured', () => {
assert.equal(isCharacterDictionaryRuntimeEnabled(''), true);
assert.equal(isCharacterDictionaryRuntimeEnabled(' '), true);
assert.equal(getCharacterDictionaryDisabledReason(''), null);
});
test('character dictionary runtime is disabled when external Yomitan profile is configured', () => {
assert.equal(isCharacterDictionaryRuntimeEnabled('/tmp/gsm-profile'), false);
assert.equal(
getCharacterDictionaryDisabledReason('/tmp/gsm-profile'),
'Character dictionary is disabled while yomitan.externalProfilePath is configured.',
);
});
@@ -1,109 +0,0 @@
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}`),
getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678',
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'),
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
openFirstRunSetup: () => calls.push('setup'),
setVisibleOverlay: (visible) => calls.push(`set-visible:${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');
},
dispatchSessionAction: async () => {},
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' }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-1.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 10,
}),
runStatsCommand: async () => {
calls.push('run-stats');
},
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},
runUpdateCommand: async () => {
calls.push('run-update');
},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
calls.push('run-ensure-linux-runtime-plugin-assets');
},
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('config-settings'),
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
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}`),
logDebug: (message) => calls.push(`debug:${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.getTexthookerWebsocketUrl(), 'ws://127.0.0.1:6678');
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']);
});
@@ -1,102 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createCliCommandContextFactory } from './cli-command-context-factory';
test('cli command context factory composes main deps and context handlers', () => {
const calls: string[] = [];
const appState = {
mpvSocketPath: '/tmp/mpv.sock',
mpvClient: null,
texthookerPort: 5174,
overlayRuntimeInitialized: false,
};
const createContext = createCliCommandContextFactory({
appState,
texthookerService: { isRunning: () => false, start: () => null },
getResolvedConfig: () => ({
texthooker: { openBrowser: true },
annotationWebsocket: { enabled: true, port: 6678 },
}),
defaultWebsocketPort: 6677,
defaultAnnotationWebsocketPort: 6678,
hasMpvWebsocketPlugin: () => false,
openExternal: async () => {},
logBrowserOpenError: () => {},
showMpvOsd: (text) => calls.push(`osd:${text}`),
initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
openFirstRunSetupWindow: () => calls.push('setup'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'),
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
mineSentenceCard: async () => {},
startPendingMineSentenceMultiple: () => {},
updateLastCardFromClipboard: async () => {},
refreshKnownWordCache: async () => {},
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
dispatchSessionAction: async () => {},
getAnilistStatus: () => ({
tokenStatus: 'resolved',
tokenSource: 'literal',
tokenMessage: null,
tokenResolvedAt: null,
tokenErrorAt: null,
queuePending: 0,
queueReady: 0,
queueDeadLetter: 0,
queueLastAttemptAt: null,
queueLastError: null,
}),
clearAnilistToken: () => {},
openAnilistSetupWindow: () => {},
openJellyfinSetupWindow: () => {},
getAnilistQueueStatus: () => ({
pending: 0,
ready: 0,
deadLetter: 0,
lastAttemptAt: null,
lastError: null,
}),
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-1.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 10,
}),
runStatsCommand: async () => {},
runJellyfinCommand: async () => {},
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
stopApp: () => {},
hasMainWindow: () => true,
getMultiCopyTimeoutMs: () => 5000,
schedule: (fn) => setTimeout(fn, 0),
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
logError: () => {},
});
const context = createContext();
context.setSocketPath('/tmp/new.sock');
context.showOsd('hello');
context.setVisibleOverlay(true);
context.toggleVisibleOverlay();
assert.equal(appState.mpvSocketPath, '/tmp/new.sock');
assert.deepEqual(calls, ['osd:hello', 'set-visible:true', 'toggle-visible']);
});
@@ -2,62 +2,41 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps';
test('cli command context main deps builder maps state and callbacks', async () => {
const calls: string[] = [];
const appState = {
mpvSocketPath: '/tmp/mpv.sock',
mpvClient: null,
texthookerPort: 5174,
overlayRuntimeInitialized: false,
};
type MainDeps = Parameters<typeof createBuildCliCommandContextMainDepsHandler>[0];
const build = createBuildCliCommandContextMainDepsHandler({
appState,
onMpvSocketPathChanged: (next, previous) => calls.push(`socket:${previous}->${next}`),
function buildContextDeps(overrides: Partial<MainDeps> = {}) {
const noop = () => {};
const noopAsync = async () => {};
return createBuildCliCommandContextMainDepsHandler({
appState: {
mpvSocketPath: '/tmp/mpv.sock',
mpvClient: null,
texthookerPort: 5174,
overlayRuntimeInitialized: false,
},
texthookerService: { isRunning: () => false, start: () => null },
getResolvedConfig: () => ({
texthooker: { openBrowser: true },
annotationWebsocket: { enabled: true, port: 6678 },
}),
getResolvedConfig: () => ({}),
defaultWebsocketPort: 6677,
defaultAnnotationWebsocketPort: 6678,
hasMpvWebsocketPlugin: () => false,
openExternal: async (url) => {
calls.push(`open:${url}`);
},
logBrowserOpenError: (url) => calls.push(`open-error:${url}`),
showMpvOsd: (text) => calls.push(`osd:${text}`),
initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
openFirstRunSetupWindow: (force?: boolean) =>
calls.push(`open-setup:${force === true ? 'force' : 'default'}`),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'),
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),
mineSentenceCard: async () => {
calls.push('mine');
},
startPendingMineSentenceMultiple: (timeoutMs) => calls.push(`mine-multi:${timeoutMs}`),
updateLastCardFromClipboard: async () => {
calls.push('update-last-card');
},
refreshKnownWordCache: async () => {
calls.push('refresh-known');
},
triggerFieldGrouping: async () => {
calls.push('field-grouping');
},
triggerSubsyncFromConfig: async () => {
calls.push('subsync');
},
markLastCardAsAudioCard: async () => {
calls.push('mark-audio');
},
dispatchSessionAction: async () => {},
openExternal: noopAsync,
logBrowserOpenError: noop,
showMpvOsd: noop,
initializeOverlayRuntime: noop,
toggleVisibleOverlay: noop,
togglePrimarySubtitleBar: noop,
openFirstRunSetupWindow: noop,
setVisibleOverlayVisible: noop,
copyCurrentSubtitle: noop,
startPendingMultiCopy: noop,
mineSentenceCard: noopAsync,
startPendingMineSentenceMultiple: noop,
updateLastCardFromClipboard: noopAsync,
refreshKnownWordCache: noopAsync,
triggerFieldGrouping: noopAsync,
triggerSubsyncFromConfig: noopAsync,
markLastCardAsAudioCard: noopAsync,
dispatchSessionAction: noopAsync,
getAnilistStatus: () => ({
tokenStatus: 'resolved',
tokenSource: 'literal',
@@ -70,11 +49,11 @@ test('cli command context main deps builder maps state and callbacks', async ()
queueLastAttemptAt: null,
queueLastError: null,
}),
clearAnilistToken: () => calls.push('clear-token'),
openAnilistSetupWindow: () => calls.push('open-anilist-setup'),
openJellyfinSetupWindow: () => calls.push('open-jellyfin-setup'),
clearAnilistToken: noop,
openAnilistSetupWindow: noop,
openJellyfinSetupWindow: noop,
getAnilistQueueStatus: () => ({
pending: 1,
pending: 0,
ready: 0,
deadLetter: 0,
lastAttemptAt: null,
@@ -88,72 +67,89 @@ test('cli command context main deps builder maps state and callbacks', async ()
mediaTitle: 'Test',
entryCount: 10,
}),
runStatsCommand: async () => {
calls.push('run-stats');
},
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},
runUpdateCommand: async () => {
calls.push('run-update');
},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
calls.push('run-ensure-linux-runtime-plugin-assets');
},
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettingsWindow: () => calls.push('open-config-settings'),
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
printHelp: () => calls.push('help'),
stopApp: () => calls.push('stop-app'),
runStatsCommand: noopAsync,
runJellyfinCommand: noopAsync,
runUpdateCommand: noopAsync,
runEnsureLinuxRuntimePluginAssetsCommand: noopAsync,
runYoutubePlaybackFlow: noopAsync,
openYomitanSettings: noop,
openConfigSettingsWindow: noop,
openSyncUiWindow: noop,
openYoutubeBrowserWindow: noop,
cycleSecondarySubMode: noop,
openRuntimeOptionsPalette: noop,
printHelp: noop,
stopApp: noop,
hasMainWindow: () => true,
getMultiCopyTimeoutMs: () => 5000,
schedule: (fn) => {
fn();
return setTimeout(() => {}, 0);
},
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
schedule: (fn) => setTimeout(fn, 0),
logInfo: noop,
logDebug: noop,
logWarn: noop,
logError: noop,
...overrides,
})();
}
test('cli command context setSocketPath notifies only when the socket path changes', () => {
const changes: string[] = [];
const appState = {
mpvSocketPath: '/tmp/mpv.sock',
mpvClient: null,
texthookerPort: 5174,
overlayRuntimeInitialized: false,
};
const deps = buildContextDeps({
appState,
onMpvSocketPathChanged: (next, previous) => changes.push(`${previous}->${next}`),
});
const deps = build();
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
deps.setSocketPath('/tmp/next.sock');
deps.setSocketPath('/tmp/next.sock');
assert.equal(appState.mpvSocketPath, '/tmp/next.sock');
assert.deepEqual(calls, ['socket:/tmp/mpv.sock->/tmp/next.sock']);
deps.setSocketPath('/tmp/next.sock');
assert.deepEqual(calls, ['socket:/tmp/mpv.sock->/tmp/next.sock']);
calls.length = 0;
assert.equal(deps.getTexthookerPort(), 5174);
deps.setTexthookerPort(5175);
assert.equal(appState.texthookerPort, 5175);
assert.equal(deps.getTexthookerWebsocketUrl(), 'ws://127.0.0.1:6678');
assert.equal(deps.shouldOpenBrowser(), true);
deps.showOsd('hello');
deps.initializeOverlay();
deps.openFirstRunSetup(true);
deps.setVisibleOverlay(true);
deps.openConfigSettingsWindow();
deps.printHelp();
await deps.runUpdateCommand({ update: true } as never, 'initial');
assert.deepEqual(calls, [
'osd:hello',
'init-overlay',
'open-setup:force',
'set-visible:true',
'open-config-settings',
'help',
'run-update',
]);
const retry = await deps.retryAnilistQueueNow();
assert.deepEqual(retry, { ok: true, message: 'ok' });
assert.equal(deps.getSocketPath(), '/tmp/next.sock');
assert.deepEqual(changes, ['/tmp/mpv.sock->/tmp/next.sock']);
});
for (const c of [
{ openBrowser: undefined, expected: true },
{ openBrowser: true, expected: true },
{ openBrowser: false, expected: false },
]) {
test(`cli command context shouldOpenBrowser is ${c.expected} when texthooker.openBrowser is ${c.openBrowser}`, () => {
const deps = buildContextDeps({
getResolvedConfig: () => ({ texthooker: { openBrowser: c.openBrowser } }),
});
assert.equal(deps.shouldOpenBrowser(), c.expected);
});
}
for (const c of [
{
name: 'uses the annotation websocket port when annotations are enabled',
config: { annotationWebsocket: { enabled: true } },
hasPlugin: true,
expected: 'ws://127.0.0.1:6678',
},
{
name: 'falls back to the default websocket port without the mpv plugin',
config: { annotationWebsocket: { enabled: false } },
hasPlugin: false,
expected: 'ws://127.0.0.1:6677',
},
{
name: 'returns no websocket url when the mpv plugin owns the websocket',
config: { annotationWebsocket: { enabled: false } },
hasPlugin: true,
expected: undefined,
},
]) {
test(`cli command context texthooker websocket url ${c.name}`, () => {
const deps = buildContextDeps({
getResolvedConfig: () => c.config,
hasMpvWebsocketPlugin: () => c.hasPlugin,
});
assert.equal(deps.getTexthookerWebsocketUrl(), c.expected);
});
}
+6 -37
View File
@@ -3,16 +3,12 @@ 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;
},
getSocketPath: () => '/tmp/mpv.sock',
setSocketPath: () => {},
getMpvClient: () => null,
showOsd: () => {},
texthookerService: {} as never,
@@ -67,32 +63,15 @@ function createDeps() {
hasMainWindow: () => true,
getMultiCopyTimeoutMs: () => 1000,
schedule: (fn: () => void) => setTimeout(fn, 0),
logInfo: (message: string) => {
logs.push(`i:${message}`);
},
logDebug: (message: string) => {
logs.push(`d:${message}`);
},
logWarn: (message: string) => {
logs.push(`w:${message}`);
},
logError: (message: string) => {
logs.push(`e:${message}`);
},
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
logError: () => {},
},
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 () => {
@@ -104,13 +83,3 @@ test('cli command context openInBrowser reports failures', async () => {
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.logDebug('debug');
context.warn('warn');
context.error('error', new Error('x'));
assert.deepEqual(getLogs(), ['i:info', 'd:debug', 'w:warn', 'e:error']);
});
@@ -1,23 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler } from './cli-command-prechecks-main-deps';
test('cli prechecks main deps builder maps transition handlers', () => {
const calls: string[] = [];
const deps = createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler({
isTexthookerOnlyMode: () => true,
setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`),
commandNeedsOverlayStartupPrereqs: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
startBackgroundWarmups: () => calls.push('warmups'),
logInfo: (message) => calls.push(`info:${message}`),
})();
assert.equal(deps.isTexthookerOnlyMode(), true);
assert.equal(deps.commandNeedsOverlayStartupPrereqs({} as never), true);
deps.setTexthookerOnlyMode(false);
deps.ensureOverlayStartupPrereqs();
deps.startBackgroundWarmups();
deps.logInfo('x');
assert.deepEqual(calls, ['set:false', 'prereqs', 'warmups', 'info:x']);
});
@@ -1,57 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { composeAnilistSetupHandlers } from './anilist-setup-composer';
test('composeAnilistSetupHandlers returns callable setup handlers', () => {
const calls: string[] = [];
const composed = composeAnilistSetupHandlers({
notifyDeps: {
hasMpvClient: () => false,
showMpvOsd: () => {},
showDesktopNotification: (title, opts) => {
calls.push(`notify:${opts.body}`);
},
logInfo: () => {},
},
consumeTokenDeps: {
consumeAnilistSetupCallbackUrl: () => false,
saveToken: () => true,
setCachedToken: () => {},
setResolvedState: () => {},
setSetupPageOpened: () => {},
onSuccess: () => {},
closeWindow: () => {},
},
handleProtocolDeps: {
consumeAnilistSetupTokenFromUrl: () => false,
logWarn: () => {},
},
registerProtocolClientDeps: {
isDefaultApp: () => false,
getArgv: () => [],
execPath: process.execPath,
resolvePath: (value) => value,
setAsDefaultProtocolClient: () => true,
logDebug: () => {},
},
});
assert.equal(typeof composed.notifyAnilistSetup, 'function');
assert.equal(typeof composed.consumeAnilistSetupTokenFromUrl, 'function');
assert.equal(typeof composed.handleAnilistSetupProtocolUrl, 'function');
assert.equal(typeof composed.registerSubminerProtocolClient, 'function');
// notifyAnilistSetup forwards to showDesktopNotification when no MPV client
composed.notifyAnilistSetup('Setup complete');
assert.deepEqual(calls, ['notify:Setup complete']);
// handleAnilistSetupProtocolUrl returns false for non-subminer URLs
const handled = composed.handleAnilistSetupProtocolUrl('https://other.example.com/');
assert.equal(handled, false);
// handleAnilistSetupProtocolUrl returns true for subminer:// URLs
const handledProtocol = composed.handleAnilistSetupProtocolUrl(
'subminer://anilist-setup?code=abc',
);
assert.equal(handledProtocol, true);
});
@@ -1,259 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AnilistMediaGuess } from '../../../core/services/anilist/anilist-updater';
import { composeAnilistTrackingHandlers } from './anilist-tracking-composer';
test('composeAnilistTrackingHandlers returns callable handlers and forwards calls to deps', async () => {
const refreshSavedTokens: string[] = [];
let refreshCachedToken: string | null = null;
let mediaKeyState: string | null = 'media-key';
let mediaDurationSecState: number | null = null;
let mediaGuessState: AnilistMediaGuess | null = null;
let mediaGuessPromiseState: Promise<AnilistMediaGuess | null> | null = null;
let lastDurationProbeAtMsState = 0;
let requestMpvDurationCalls = 0;
let guessAnilistMediaInfoCalls = 0;
let retryUpdateCalls = 0;
let maybeRunUpdateCalls = 0;
const composed = composeAnilistTrackingHandlers({
refreshClientSecretMainDeps: {
getResolvedConfig: () => ({ anilist: { accessToken: 'refresh-token' } }),
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => refreshCachedToken,
setCachedAccessToken: (token) => {
refreshCachedToken = token;
},
saveStoredToken: (token) => {
refreshSavedTokens.push(token);
},
loadStoredToken: () => null,
setClientSecretState: () => {},
getAnilistSetupPageOpened: () => false,
setAnilistSetupPageOpened: () => {},
openAnilistSetupWindow: () => {},
now: () => 100,
},
getCurrentMediaKeyMainDeps: {
getCurrentMediaPath: () => ' media-key ',
},
resetMediaTrackingMainDeps: {
setMediaKey: (value) => {
mediaKeyState = value;
},
setMediaDurationSec: (value) => {
mediaDurationSecState = value;
},
setMediaGuess: (value) => {
mediaGuessState = value;
},
setMediaGuessPromise: (value) => {
mediaGuessPromiseState = value;
},
setLastDurationProbeAtMs: (value) => {
lastDurationProbeAtMsState = value;
},
},
getMediaGuessRuntimeStateMainDeps: {
getMediaKey: () => mediaKeyState,
getMediaDurationSec: () => mediaDurationSecState,
getMediaGuess: () => mediaGuessState,
getMediaGuessPromise: () => mediaGuessPromiseState,
getLastDurationProbeAtMs: () => lastDurationProbeAtMsState,
},
setMediaGuessRuntimeStateMainDeps: {
setMediaKey: (value) => {
mediaKeyState = value;
},
setMediaDurationSec: (value) => {
mediaDurationSecState = value;
},
setMediaGuess: (value) => {
mediaGuessState = value;
},
setMediaGuessPromise: (value) => {
mediaGuessPromiseState = value;
},
setLastDurationProbeAtMs: (value) => {
lastDurationProbeAtMsState = value;
},
},
recordMediaDurationMainDeps: {
getCurrentMediaKey: () => 'media-key',
getState: () => ({
mediaKey: mediaKeyState,
mediaDurationSec: mediaDurationSecState,
mediaGuess: mediaGuessState,
mediaGuessPromise: mediaGuessPromiseState,
lastDurationProbeAtMs: lastDurationProbeAtMsState,
}),
setState: (state) => {
mediaKeyState = state.mediaKey;
mediaDurationSecState = state.mediaDurationSec;
mediaGuessState = state.mediaGuess;
mediaGuessPromiseState = state.mediaGuessPromise;
lastDurationProbeAtMsState = state.lastDurationProbeAtMs;
},
},
resetMediaGuessStateMainDeps: {
setMediaGuess: (value) => {
mediaGuessState = value;
},
setMediaGuessPromise: (value) => {
mediaGuessPromiseState = value;
},
},
maybeProbeDurationMainDeps: {
getState: () => ({
mediaKey: mediaKeyState,
mediaDurationSec: mediaDurationSecState,
mediaGuess: mediaGuessState,
mediaGuessPromise: mediaGuessPromiseState,
lastDurationProbeAtMs: lastDurationProbeAtMsState,
}),
setState: (state) => {
mediaKeyState = state.mediaKey;
mediaDurationSecState = state.mediaDurationSec;
mediaGuessState = state.mediaGuess;
mediaGuessPromiseState = state.mediaGuessPromise;
lastDurationProbeAtMsState = state.lastDurationProbeAtMs;
},
durationRetryIntervalMs: 0,
now: () => 1000,
requestMpvDuration: async () => {
requestMpvDurationCalls += 1;
return 120;
},
logWarn: () => {},
},
ensureMediaGuessMainDeps: {
getState: () => ({
mediaKey: mediaKeyState,
mediaDurationSec: mediaDurationSecState,
mediaGuess: mediaGuessState,
mediaGuessPromise: mediaGuessPromiseState,
lastDurationProbeAtMs: lastDurationProbeAtMsState,
}),
setState: (state) => {
mediaKeyState = state.mediaKey;
mediaDurationSecState = state.mediaDurationSec;
mediaGuessState = state.mediaGuess;
mediaGuessPromiseState = state.mediaGuessPromise;
lastDurationProbeAtMsState = state.lastDurationProbeAtMs;
},
resolveMediaPathForJimaku: (value) => value,
getCurrentMediaPath: () => '/tmp/media.mkv',
getCurrentMediaTitle: () => 'Episode title',
guessAnilistMediaInfo: async () => {
guessAnilistMediaInfoCalls += 1;
return { title: 'Episode title', season: null, episode: 7, source: 'guessit' };
},
},
processNextRetryUpdateMainDeps: {
nextReady: () => ({ key: 'retry-key', title: 'Retry title', season: null, episode: 1 }),
refreshRetryQueueState: () => {},
setLastAttemptAt: () => {},
setLastError: () => {},
refreshAnilistClientSecretState: async () => 'retry-token',
updateAnilistPostWatchProgress: async () => {
retryUpdateCalls += 1;
return { status: 'updated', message: 'ok' };
},
markSuccess: () => {},
rememberAttemptedUpdateKey: () => {},
markFailure: () => {},
logInfo: () => {},
now: () => 1,
},
maybeRunPostWatchUpdateMainDeps: {
getInFlight: () => false,
setInFlight: () => {},
getResolvedConfig: () => ({ tracking: true }),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => 'media-key',
hasMpvClient: () => true,
getTrackedMediaKey: () => 'media-key',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 500,
maybeProbeAnilistDuration: async () => 600,
ensureAnilistMediaGuess: async () => ({
title: 'Episode title',
season: null,
episode: 2,
source: 'guessit',
}),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
refreshAnilistClientSecretState: async () => 'run-token',
enqueueRetry: () => {},
markRetryFailure: () => {},
markRetrySuccess: () => {},
refreshRetryQueueState: () => {},
updateAnilistPostWatchProgress: async () => {
maybeRunUpdateCalls += 1;
return { status: 'updated', message: 'updated from maybeRun' };
},
rememberAttemptedUpdateKey: () => {},
showMpvOsd: () => {},
logInfo: () => {},
logWarn: () => {},
minWatchSeconds: 10,
minWatchRatio: 0.5,
},
});
assert.equal(typeof composed.refreshAnilistClientSecretState, 'function');
assert.equal(typeof composed.getCurrentAnilistMediaKey, 'function');
assert.equal(typeof composed.resetAnilistMediaTracking, 'function');
assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function');
assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function');
assert.equal(typeof composed.recordAnilistMediaDuration, 'function');
assert.equal(typeof composed.resetAnilistMediaGuessState, 'function');
assert.equal(typeof composed.maybeProbeAnilistDuration, 'function');
assert.equal(typeof composed.ensureAnilistMediaGuess, 'function');
assert.equal(typeof composed.processNextAnilistRetryUpdate, 'function');
assert.equal(typeof composed.maybeRunAnilistPostWatchUpdate, 'function');
const refreshed = await composed.refreshAnilistClientSecretState({ force: true });
assert.equal(refreshed, 'refresh-token');
assert.deepEqual(refreshSavedTokens, ['refresh-token']);
assert.equal(composed.getCurrentAnilistMediaKey(), 'media-key');
composed.resetAnilistMediaTracking('next-key');
assert.equal(mediaKeyState, 'next-key');
assert.equal(mediaDurationSecState, null);
composed.setAnilistMediaGuessRuntimeState({
mediaKey: 'media-key',
mediaDurationSec: 90,
mediaGuess: { title: 'Known', season: null, episode: 3, source: 'fallback' },
mediaGuessPromise: null,
lastDurationProbeAtMs: 11,
});
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90);
composed.recordAnilistMediaDuration(180);
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 180);
composed.resetAnilistMediaGuessState();
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null);
mediaKeyState = 'media-key';
mediaDurationSecState = null;
const probedDuration = await composed.maybeProbeAnilistDuration('media-key');
assert.equal(probedDuration, 120);
assert.equal(requestMpvDurationCalls, 1);
mediaGuessState = null;
await composed.ensureAnilistMediaGuess('media-key');
assert.equal(guessAnilistMediaInfoCalls, 1);
const retryResult = await composed.processNextAnilistRetryUpdate();
assert.deepEqual(retryResult, { ok: true, message: 'ok' });
assert.equal(retryUpdateCalls, 1);
await composed.maybeRunAnilistPostWatchUpdate();
assert.equal(maybeRunUpdateCalls, 1);
});
@@ -1,91 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeAppReadyRuntime } from './app-ready-composer';
test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => {
const calls: string[] = [];
const composed = composeAppReadyRuntime({
reloadConfigMainDeps: {
reloadConfigStrict: () => {
calls.push('reloadConfigStrict');
return { ok: true, path: '/tmp/config.jsonc', warnings: [] };
},
logInfo: () => {},
logDebug: () => {},
logWarning: () => {},
showDesktopNotification: () => {},
startConfigHotReload: () => {},
refreshAnilistClientSecretState: async () => {},
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
criticalConfigErrorMainDeps: {
getConfigPath: () => '/tmp/config.jsonc',
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
appReadyRuntimeMainDeps: {
ensureDefaultConfigBootstrap: () => {},
loadSubtitlePosition: () => {},
resolveKeybindings: () => {},
createMpvClient: () => {},
getResolvedConfig: () => ({}) as never,
getConfigWarnings: () => [],
logConfigWarning: () => {},
setLogLevel: () => {},
initRuntimeOptionsManager: () => {},
setSecondarySubMode: () => {},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
defaultAnnotationWebsocketPort: 6678,
defaultTexthookerPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
startAnnotationWebsocket: () => {},
startTexthooker: () => {},
log: () => {},
createMecabTokenizerAndCheck: async () => {},
createSubtitleTimingTracker: () => {},
loadYomitanExtension: async () => {},
handleFirstRunSetup: async () => {},
startJellyfinRemoteSession: async () => {},
prewarmSubtitleDictionaries: async () => {},
startBackgroundWarmups: () => {},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: () => {},
initializeOverlayRuntime: () => {},
handleInitialArgs: () => {},
logDebug: () => {},
now: () => Date.now(),
},
immersionTrackerStartupMainDeps: {
getResolvedConfig: () => ({}) as never,
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
createTrackerService: () =>
({
startSession: () => {},
}) as never,
setTracker: () => {},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => {},
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
},
});
assert.equal(typeof composed.reloadConfig, 'function');
assert.equal(typeof composed.criticalConfigError, 'function');
assert.equal(typeof composed.appReadyRuntimeRunner, 'function');
// reloadConfig invokes the injected reloadConfigStrict dep
composed.reloadConfig();
assert.deepEqual(calls, ['reloadConfigStrict']);
});
@@ -1,107 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CliArgs } from '../../../cli/args';
import { composeCliStartupHandlers } from './cli-startup-composer';
test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
const calls: string[] = [];
const handlers = composeCliStartupHandlers({
cliCommandContextMainDeps: {
appState: {} as never,
setLogLevel: () => {},
texthookerService: {} as never,
getResolvedConfig: () => ({}) as never,
defaultWebsocketPort: 6677,
defaultAnnotationWebsocketPort: 6678,
hasMpvWebsocketPlugin: () => false,
openExternal: async () => {},
logBrowserOpenError: () => {},
showMpvOsd: () => {},
initializeOverlayRuntime: () => {},
toggleVisibleOverlay: () => {},
togglePrimarySubtitleBar: () => {},
openFirstRunSetupWindow: () => {},
setVisibleOverlayVisible: () => {},
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},
mineSentenceCard: async () => {},
startPendingMineSentenceMultiple: () => {},
updateLastCardFromClipboard: async () => {},
refreshKnownWordCache: async () => {},
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
dispatchSessionAction: async () => {},
getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {},
openAnilistSetupWindow: () => {},
openJellyfinSetupWindow: () => {},
getAnilistQueueStatus: () => ({}) as never,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/test.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 1,
}),
runJellyfinCommand: async () => {},
runStatsCommand: async () => {},
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
stopApp: () => {},
hasMainWindow: () => false,
getMultiCopyTimeoutMs: () => 0,
schedule: () => 0 as never,
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
logError: () => {},
},
cliCommandRuntimeHandlerMainDeps: {
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => false,
ensureOverlayStartupPrereqs: () => {},
setTexthookerOnlyMode: () => {},
commandNeedsOverlayStartupPrereqs: () => false,
startBackgroundWarmups: () => {},
logInfo: () => {},
},
handleCliCommandRuntimeServiceWithContext: (args, _source, _ctx) => {
calls.push(`handleCommand:${(args as { command?: string }).command ?? 'unknown'}`);
},
},
initialArgsRuntimeHandlerMainDeps: {
getInitialArgs: () => null,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
},
});
assert.equal(typeof handlers.createCliCommandContext, 'function');
assert.equal(typeof handlers.handleCliCommand, 'function');
assert.equal(typeof handlers.handleInitialArgs, 'function');
// handleCliCommand routes to the injected handleCliCommandRuntimeServiceWithContext dep
handlers.handleCliCommand({ command: 'start' } as unknown as CliArgs);
assert.deepEqual(calls, ['handleCommand:start']);
});
@@ -1,98 +0,0 @@
import type { ComposerInputs } from './contracts';
import type { IpcRuntimeComposerOptions } from './ipc-runtime-composer';
import type { JellyfinRemoteComposerOptions } from './jellyfin-remote-composer';
import type { MpvRuntimeComposerOptions } from './mpv-runtime-composer';
import type { AnilistSetupComposerOptions } from './anilist-setup-composer';
type Assert<T extends true> = T;
type IsAssignable<From, To> = [From] extends [To] ? true : false;
type FakeMpvClient = {
on: (...args: unknown[]) => unknown;
connect: () => void;
};
type FakeTokenizerDeps = { isKnownWord: (text: string) => boolean };
type FakeTokenizedSubtitle = { text: string };
type RequiredAnilistSetupInputKeys = keyof ComposerInputs<AnilistSetupComposerOptions>;
type RequiredJellyfinInputKeys = keyof ComposerInputs<JellyfinRemoteComposerOptions>;
type RequiredIpcInputKeys = keyof ComposerInputs<IpcRuntimeComposerOptions>;
type RequiredMpvInputKeys = keyof ComposerInputs<
MpvRuntimeComposerOptions<FakeMpvClient, FakeTokenizerDeps, FakeTokenizedSubtitle>
>;
const contractAssertions = [
true as Assert<IsAssignable<'notifyDeps', RequiredAnilistSetupInputKeys>>,
true as Assert<IsAssignable<'getMpvClient', RequiredJellyfinInputKeys>>,
true as Assert<IsAssignable<'registration', RequiredIpcInputKeys>>,
true as Assert<IsAssignable<'tokenizer', RequiredMpvInputKeys>>,
];
void contractAssertions;
// @ts-expect-error missing required notifyDeps should fail compile-time contract
const anilistMissingRequired: AnilistSetupComposerOptions = {
consumeTokenDeps: {} as AnilistSetupComposerOptions['consumeTokenDeps'],
handleProtocolDeps: {} as AnilistSetupComposerOptions['handleProtocolDeps'],
registerProtocolClientDeps: {} as AnilistSetupComposerOptions['registerProtocolClientDeps'],
};
// @ts-expect-error missing required getMpvClient should fail compile-time contract
const jellyfinMissingRequired: JellyfinRemoteComposerOptions = {
getConfiguredSession: {} as JellyfinRemoteComposerOptions['getConfiguredSession'],
getClientInfo: {} as JellyfinRemoteComposerOptions['getClientInfo'],
getJellyfinConfig: {} as JellyfinRemoteComposerOptions['getJellyfinConfig'],
playJellyfinItem: {} as JellyfinRemoteComposerOptions['playJellyfinItem'],
logWarn: {} as JellyfinRemoteComposerOptions['logWarn'],
sendMpvCommand: {} as JellyfinRemoteComposerOptions['sendMpvCommand'],
jellyfinTicksToSeconds: {} as JellyfinRemoteComposerOptions['jellyfinTicksToSeconds'],
getActivePlayback: {} as JellyfinRemoteComposerOptions['getActivePlayback'],
clearActivePlayback: {} as JellyfinRemoteComposerOptions['clearActivePlayback'],
getSession: {} as JellyfinRemoteComposerOptions['getSession'],
getNow: {} as JellyfinRemoteComposerOptions['getNow'],
getLastProgressAtMs: {} as JellyfinRemoteComposerOptions['getLastProgressAtMs'],
setLastProgressAtMs: {} as JellyfinRemoteComposerOptions['setLastProgressAtMs'],
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: {} as JellyfinRemoteComposerOptions['logDebug'],
};
// @ts-expect-error missing required registration should fail compile-time contract
const ipcMissingRequired: IpcRuntimeComposerOptions = {
mpvCommandMainDeps: {} as IpcRuntimeComposerOptions['mpvCommandMainDeps'],
handleMpvCommandFromIpcRuntime: {} as IpcRuntimeComposerOptions['handleMpvCommandFromIpcRuntime'],
runSubsyncManualFromIpc: {} as IpcRuntimeComposerOptions['runSubsyncManualFromIpc'],
};
// @ts-expect-error missing required tokenizer should fail compile-time contract
const mpvMissingRequired: MpvRuntimeComposerOptions<
FakeMpvClient,
FakeTokenizerDeps,
FakeTokenizedSubtitle
> = {
bindMpvMainEventHandlersMainDeps: {} as MpvRuntimeComposerOptions<
FakeMpvClient,
FakeTokenizerDeps,
FakeTokenizedSubtitle
>['bindMpvMainEventHandlersMainDeps'],
mpvClientRuntimeServiceFactoryMainDeps: {} as MpvRuntimeComposerOptions<
FakeMpvClient,
FakeTokenizerDeps,
FakeTokenizedSubtitle
>['mpvClientRuntimeServiceFactoryMainDeps'],
updateMpvSubtitleRenderMetricsMainDeps: {} as MpvRuntimeComposerOptions<
FakeMpvClient,
FakeTokenizerDeps,
FakeTokenizedSubtitle
>['updateMpvSubtitleRenderMetricsMainDeps'],
warmups: {} as MpvRuntimeComposerOptions<
FakeMpvClient,
FakeTokenizerDeps,
FakeTokenizedSubtitle
>['warmups'],
};
void anilistMissingRequired;
void jellyfinMissingRequired;
void ipcMissingRequired;
void mpvMissingRequired;
@@ -1,66 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CliArgs } from '../../../cli/args';
import { composeHeadlessStartupHandlers } from './headless-startup-composer';
test('composeHeadlessStartupHandlers returns startup bootstrap handlers', () => {
const calls: string[] = [];
const handlers = composeHeadlessStartupHandlers<
CliArgs,
{ mode: string },
{ startAppLifecycle: (args: CliArgs) => void }
>({
startupRuntimeHandlersDeps: {
appLifecycleRuntimeRunnerMainDeps: {
app: { on: () => {} } as never,
platform: 'darwin',
shouldStartApp: () => true,
parseArgs: () => ({}) as never,
handleCliCommand: () => {},
printHelp: () => {},
logNoRunningInstance: () => {},
onReady: async () => {},
onWillQuitCleanup: () => {},
shouldRestoreWindowsOnActivate: () => false,
restoreWindowsOnActivate: () => {},
shouldQuitOnWindowAllClosed: () => false,
},
createAppLifecycleRuntimeRunner: () => (args) => {
calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`);
},
buildStartupBootstrapMainDeps: (startAppLifecycle) => ({
argv: ['node', 'main.js'],
parseArgs: () => ({ command: 'start' }) as never,
setLogLevel: () => {},
forceX11Backend: () => {},
enforceUnsupportedWaylandMode: () => {},
shouldStartApp: () => true,
getDefaultSocketPath: () => '/tmp/mpv.sock',
defaultTexthookerPort: 5174,
configDir: '/tmp/config',
defaultConfig: {} as never,
generateConfigTemplate: () => 'template',
generateDefaultConfigFile: async () => 0,
setExitCode: () => {},
quitApp: () => {},
logGenerateConfigError: () => {},
startAppLifecycle: (args) => startAppLifecycle(args as never),
}),
createStartupBootstrapRuntimeDeps: (deps) => ({
startAppLifecycle: deps.startAppLifecycle,
}),
runStartupBootstrapRuntime: (deps) => {
deps.startAppLifecycle({ command: 'start' } as unknown as CliArgs);
return { mode: 'started' };
},
applyStartupState: (state) => {
calls.push(`apply:${state.mode}`);
},
},
});
assert.equal(typeof handlers.runAndApplyStartupState, 'function');
assert.deepEqual(handlers.runAndApplyStartupState(), { mode: 'started' });
assert.deepEqual(calls, ['lifecycle:start', 'apply:started']);
});
@@ -1,134 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeIpcRuntimeHandlers } from './ipc-runtime-composer';
test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => {
let registered = false;
let receivedReferenceTrackId: number | null | undefined;
const composed = composeIpcRuntimeHandlers({
mpvCommandMainDeps: {
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openJimaku: () => {},
openTsukihime: () => {},
openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: true }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
playNextSubtitle: () => {},
sendMpvCommand: () => {},
getMpvClient: () => null,
isMpvConnected: () => false,
hasRuntimeOptionsManager: () => true,
},
handleMpvCommandFromIpcRuntime: () => {},
runSubsyncManualFromIpc: async (request) => {
receivedReferenceTrackId = request.referenceTrackId;
return {
ok: true,
message: 'ok',
};
},
registration: {
runtimeOptions: {
getRuntimeOptionsManager: () => null,
showMpvOsd: () => {},
},
mainDeps: {
getMainWindow: () => null,
getVisibleOverlayVisibility: () => false,
focusMainWindow: () => {},
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getPlaybackPaused: () => null,
getSubtitlePosition: () => ({}) as never,
getSubtitleStyle: () => ({}) as never,
saveSubtitlePosition: () => {},
getMecabTokenizer: () => null,
getKeybindings: () => [],
getSessionBindings: () => [],
getConfiguredShortcuts: () => ({}) as never,
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => ({}) as never,
saveControllerConfig: () => {},
saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover' as never,
getMpvClient: () => null,
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({}) as never,
clearAnilistToken: () => {},
openAnilistSetup: () => {},
getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
getPlaylistBrowserSnapshot: async () =>
({
directoryPath: null,
directoryAvailable: false,
directoryStatus: '',
directoryItems: [],
playlistItems: [],
playingIndex: null,
currentFilePath: null,
}) as never,
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
},
ankiJimakuDeps: {
patchAnkiConnectEnabled: () => {},
getResolvedConfig: () => ({}) as never,
getRuntimeOptionsManager: () => null,
getSubtitleTimingTracker: () => null,
getMpvClient: () => null,
getAnkiIntegration: () => null,
setAnkiIntegration: () => {},
getKnownWordCacheStatePath: () => '',
showDesktopNotification: () => {},
createFieldGroupingCallback: () => (() => {}) as never,
broadcastRuntimeOptionsChanged: () => {},
getFieldGroupingResolver: () => null,
setFieldGroupingResolver: () => {},
parseMediaInfo: () => ({}) as never,
getCurrentMediaPath: () => null,
jimakuFetchJson: async () => ({ data: null }) as never,
getJimakuMaxEntryResults: () => 0,
getJimakuLanguagePreference: () => 'ja' as never,
resolveJimakuApiKey: async () => null,
isRemoteMediaPath: () => false,
downloadToFile: async () => ({ ok: true, path: '/tmp/file' }),
},
registerIpcRuntimeServices: () => {
registered = true;
},
},
});
assert.equal(typeof composed.handleMpvCommandFromIpc, 'function');
assert.equal(typeof composed.runSubsyncManualFromIpc, 'function');
assert.equal(typeof composed.registerIpcRuntimeHandlers, 'function');
const result = await composed.runSubsyncManualFromIpc({
engine: 'alass',
referenceTrackId: 7,
});
assert.deepEqual(result, { ok: true, message: 'ok' });
assert.equal(receivedReferenceTrackId, 7);
composed.registerIpcRuntimeHandlers();
assert.equal(registered, true);
});
@@ -1,52 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer';
test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', async () => {
let lastProgressAt = 0;
let activePlayback: unknown = {
itemId: 'item-1',
mediaSourceId: 'src-1',
playMethod: 'DirectPlay',
audioStreamIndex: null,
subtitleStreamIndex: null,
};
const calls: string[] = [];
const composed = composeJellyfinRemoteHandlers({
getConfiguredSession: () => null,
getClientInfo: () =>
({ clientName: 'SubMiner', clientVersion: 'test', deviceId: 'dev' }) as never,
getJellyfinConfig: () => ({ enabled: false }) as never,
playJellyfinItem: async () => {},
logWarn: () => {},
getMpvClient: () => null,
sendMpvCommand: () => {},
jellyfinTicksToSeconds: () => 0,
getActivePlayback: () => activePlayback as never,
clearActivePlayback: () => {
activePlayback = null;
calls.push('clearActivePlayback');
},
getSession: () => null,
getNow: () => 0,
getLastProgressAtMs: () => lastProgressAt,
setLastProgressAtMs: (next) => {
lastProgressAt = next;
},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
assert.equal(typeof composed.reportJellyfinRemoteProgress, 'function');
assert.equal(typeof composed.reportJellyfinRemoteStopped, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlay, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
// reportJellyfinRemoteStopped clears active playback when there is no connected session
await composed.reportJellyfinRemoteStopped();
assert.equal(activePlayback, null);
assert.deepEqual(calls, ['clearActivePlayback']);
});
@@ -1,9 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
composeJellyfinRuntimeHandlers,
createRestartJellyfinRemoteSessionAfterSetupLoginHandler,
} from './jellyfin-runtime-composer';
import { createRestartJellyfinRemoteSessionAfterSetupLoginHandler } from './jellyfin-runtime-composer';
test('setup login restart uses auto-connect path without an active remote session', async () => {
const startOptions: Array<{ explicit?: boolean } | undefined> = [];
@@ -32,209 +29,3 @@ test('setup login restart explicitly refreshes an active remote session', async
assert.deepEqual(startOptions, [{ explicit: true }]);
});
test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers', () => {
let activePlayback: unknown = null;
let lastProgressAtMs = 0;
const composed = composeJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => ({ jellyfin: { enabled: false, serverUrl: '' } }) as never,
loadStoredSession: () => null,
getEnv: () => undefined,
},
getJellyfinClientInfoMainDeps: {
getResolvedJellyfinConfig: () => ({}) as never,
getHostName: () => 'workstation',
defaultClientName: 'SubMiner',
defaultClientVersion: 'test',
},
waitForMpvConnectedMainDeps: {
getMpvClient: () => null,
now: () => Date.now(),
sleep: async () => {},
},
launchMpvIdleForJellyfinPlaybackMainDeps: {
getMpvExecutablePath: () => 'mpv',
getSocketPath: () => '/tmp/test-mpv.sock',
getLaunchMode: () => 'normal',
platform: 'linux',
execPath: process.execPath,
getDefaultMpvLogPath: () => '/tmp/test-mpv.log',
defaultMpvArgs: [],
removeSocketPath: () => {},
spawnMpv: () => ({ unref: () => {} }) as never,
logWarn: () => {},
logInfo: () => {},
},
ensureMpvConnectedForJellyfinPlaybackMainDeps: {
getMpvClient: () => null,
setMpvClient: () => {},
createMpvClient: () => ({}) as never,
getAutoLaunchInFlight: () => null,
setAutoLaunchInFlight: () => {},
connectTimeoutMs: 10,
autoLaunchTimeoutMs: 10,
},
preloadJellyfinExternalSubtitlesMainDeps: {
listJellyfinSubtitleTracks: async () => [],
getMpvClient: () => null,
sendMpvCommand: () => {},
wait: async () => {},
cacheSubtitleTrack: async () => ({ path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' }),
cleanupCachedSubtitles: () => {},
logDebug: () => {},
},
playJellyfinItemInMpvMainDeps: {
getMpvClient: () => null,
resolvePlaybackPlan: async () => ({
mode: 'direct',
url: 'https://example.test/video.m3u8',
title: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: () => {},
armQuitOnDisconnect: () => {},
schedule: () => undefined,
convertTicksToSeconds: () => 0,
setActivePlayback: (value) => {
activePlayback = value;
},
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
reportPlaying: () => {},
showMpvOsd: () => {},
},
remoteComposerOptions: {
getConfiguredSession: () => null,
logWarn: () => {},
getMpvClient: () => null,
sendMpvCommand: () => {},
jellyfinTicksToSeconds: () => 0,
getActivePlayback: () => activePlayback as never,
clearActivePlayback: () => {
activePlayback = null;
},
getSession: () => null,
getNow: () => Date.now(),
getLastProgressAtMs: () => lastProgressAtMs,
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
},
handleJellyfinAuthCommandsMainDeps: {
patchRawConfig: () => {},
authenticateWithPassword: async () => ({
serverUrl: 'https://example.test',
username: 'user',
accessToken: 'token',
userId: 'id',
}),
saveStoredSession: () => {},
clearStoredSession: () => {},
logInfo: () => {},
},
handleJellyfinListCommandsMainDeps: {
listJellyfinLibraries: async () => [],
listJellyfinItems: async () => [],
listJellyfinSubtitleTracks: async () => [],
writeJellyfinPreviewAuth: () => {},
logInfo: () => {},
},
handleJellyfinPlayCommandMainDeps: {
logWarn: () => {},
},
handleJellyfinRemoteAnnounceCommandMainDeps: {
getRemoteSession: () => null,
logInfo: () => {},
logWarn: () => {},
},
startJellyfinRemoteSessionMainDeps: {
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: () =>
({
start: async () => {},
}) as never,
defaultDeviceId: 'dev',
defaultClientName: 'SubMiner',
defaultClientVersion: 'test',
getHostName: () => 'workstation',
logInfo: () => {},
logWarn: () => {},
},
stopJellyfinRemoteSessionMainDeps: {
getCurrentSession: () => null,
setCurrentSession: () => {},
clearActivePlayback: () => {
activePlayback = null;
},
},
runJellyfinCommandMainDeps: {
defaultServerUrl: 'https://example.test',
},
maybeFocusExistingJellyfinSetupWindowMainDeps: {
getSetupWindow: () => null,
},
openJellyfinSetupWindowMainDeps: {
createSetupWindow: () =>
({
focus: () => {},
webContents: { on: () => {} },
loadURL: () => {},
on: () => {},
isDestroyed: () => false,
close: () => {},
}) as never,
buildSetupFormHtml: (state) => `<html>${state.selectedServerUrl}${state.username}</html>`,
parseSubmissionUrl: () => null,
authenticateWithPassword: async () => ({
serverUrl: 'https://example.test',
username: 'user',
accessToken: 'token',
userId: 'id',
}),
saveStoredSession: () => {},
clearStoredSession: () => {},
patchJellyfinConfig: () => {},
logInfo: () => {},
logError: () => {},
showMpvOsd: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {},
encodeURIComponent,
defaultServerUrl: 'https://example.test',
hasStoredSession: () => false,
},
});
assert.equal(typeof composed.getResolvedJellyfinConfig, 'function');
assert.equal(typeof composed.getJellyfinClientInfo, 'function');
assert.equal(typeof composed.reportJellyfinRemoteProgress, 'function');
assert.equal(typeof composed.reportJellyfinRemoteStopped, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlay, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
assert.equal(typeof composed.playJellyfinItemInMpv, 'function');
assert.equal(typeof composed.cleanupJellyfinSubtitleCache, 'function');
assert.equal(typeof composed.startJellyfinRemoteSession, 'function');
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
assert.equal(typeof composed.runJellyfinCommand, 'function');
assert.equal(typeof composed.openJellyfinSetupWindow, 'function');
// getResolvedJellyfinConfig forwards to the injected getResolvedConfig dep
const jellyfinConfig = composed.getResolvedJellyfinConfig();
assert.equal(jellyfinConfig.enabled, false);
assert.equal(jellyfinConfig.serverUrl, '');
});
@@ -151,172 +151,6 @@ function createDefaultMpvFixture() {
};
}
test('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => {
const calls: string[] = [];
let started = false;
let metrics = BASE_METRICS;
let mecabTokenizer: { id: string } | null = null;
class FakeMpvClient {
connected = false;
constructor(
public socketPath: string,
public options: unknown,
) {
const autoStartOverlay = (options as { autoStartOverlay: boolean }).autoStartOverlay;
calls.push(`create-client:${socketPath}`);
calls.push(`auto-start:${String(autoStartOverlay)}`);
}
on(): void {}
connect(): void {
this.connected = true;
calls.push('client-connect');
}
}
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers<
FakeMpvClient,
{ isKnownWord: (text: string) => boolean },
{ text: string }
>({
...fixture,
mpvClientRuntimeServiceFactoryMainDeps: {
...fixture.mpvClientRuntimeServiceFactoryMainDeps,
createClient: FakeMpvClient,
isAutoStartOverlayEnabled: () => true,
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => metrics,
setCurrentMetrics: (next) => {
metrics = next;
calls.push('set-metrics');
},
applyPatch: (current, patch) => {
calls.push('apply-metrics-patch');
return { next: { ...current, ...patch }, changed: true };
},
broadcastMetrics: () => {
calls.push('broadcast-metrics');
},
},
tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: {
...fixture.tokenizer.buildTokenizerDepsMainDeps,
isKnownWord: (text) => text === 'known',
getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
},
createTokenizerRuntimeDeps: (deps) => {
calls.push('create-tokenizer-runtime-deps');
return { isKnownWord: (text: string) => deps.isKnownWord(text) };
},
tokenizeSubtitle: async (text, deps) => {
calls.push(`tokenize:${text}`);
deps.isKnownWord('known');
return { text };
},
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => mecabTokenizer,
setMecabTokenizer: (next) => {
mecabTokenizer = next as { id: string };
calls.push('set-mecab');
},
createMecabTokenizer: () => {
calls.push('create-mecab');
return { id: 'mecab' };
},
checkAvailability: async () => {
calls.push('check-mecab');
},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {
calls.push('prewarm-jlpt');
},
ensureFrequencyDictionaryLookup: async () => {
calls.push('prewarm-frequency');
},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 100,
logDebug: () => {
calls.push('warmup-debug');
},
logWarn: () => {
calls.push('warmup-warn');
},
},
startBackgroundWarmupsMainDeps: {
...fixture.warmups.startBackgroundWarmupsMainDeps,
getStarted: () => started,
setStarted: (next) => {
started = next;
calls.push(`set-started:${String(next)}`);
},
ensureYomitanExtensionLoaded: async () => {
calls.push('warmup-yomitan');
},
shouldWarmupMecab: () => true,
shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => true,
shouldWarmupJellyfinRemoteSession: () => true,
startJellyfinRemoteSession: async () => {
calls.push('warmup-jellyfin');
},
},
},
});
assert.equal(typeof composed.bindMpvClientEventHandlers, 'function');
assert.equal(typeof composed.createMpvClientRuntimeService, 'function');
assert.equal(typeof composed.updateMpvSubtitleRenderMetrics, 'function');
assert.equal(typeof composed.tokenizeSubtitle, 'function');
assert.equal(typeof composed.createMecabTokenizerAndCheck, 'function');
assert.equal(typeof composed.prewarmSubtitleDictionaries, 'function');
assert.equal(typeof composed.startTokenizationWarmups, 'function');
assert.equal(typeof composed.isTokenizationWarmupReady, 'function');
assert.equal(typeof composed.launchBackgroundWarmupTask, 'function');
assert.equal(typeof composed.startBackgroundWarmups, 'function');
const client = composed.createMpvClientRuntimeService();
assert.equal(client.connected, true);
composed.updateMpvSubtitleRenderMetrics({ subPos: 90 });
assert.equal(composed.isTokenizationWarmupReady(), false);
await composed.startTokenizationWarmups();
assert.equal(composed.isTokenizationWarmupReady(), true);
const tokenized = await composed.tokenizeSubtitle('subtitle text');
await composed.createMecabTokenizerAndCheck();
await composed.prewarmSubtitleDictionaries();
composed.startBackgroundWarmups();
assert.deepEqual(tokenized, { text: 'subtitle text' });
assert.equal(metrics.subPos, 90);
assert.ok(calls.includes('create-client:/tmp/mpv.sock'));
assert.ok(calls.includes('auto-start:true'));
assert.ok(calls.includes('client-connect'));
assert.ok(calls.includes('apply-metrics-patch'));
assert.ok(calls.includes('set-metrics'));
assert.ok(calls.includes('broadcast-metrics'));
assert.ok(calls.includes('create-tokenizer-runtime-deps'));
assert.ok(calls.includes('tokenize:subtitle text'));
assert.ok(calls.includes('create-mecab'));
assert.ok(calls.includes('set-mecab'));
assert.ok(calls.includes('check-mecab'));
assert.ok(calls.includes('prewarm-jlpt'));
assert.ok(calls.includes('prewarm-frequency'));
assert.ok(calls.includes('set-started:true'));
assert.ok(calls.includes('warmup-yomitan'));
assert.ok(calls.indexOf('create-mecab') < calls.indexOf('set-started:true'));
});
test('composeMpvRuntimeHandlers skips MeCab warmup when all POS-dependent annotations are disabled', async () => {
const calls: string[] = [];
let mecabTokenizer: { id: string } | null = null;
@@ -1,56 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeOverlayVisibilityRuntime } from './overlay-visibility-runtime-composer';
test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () => {
const calls: string[] = [];
const composed = composeOverlayVisibilityRuntime({
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => {
calls.push('updateVisibleOverlayVisibility');
},
},
restorePreviousSecondarySubVisibilityMainDeps: {
getMpvClient: () => null,
},
broadcastRuntimeOptionsChangedMainDeps: {
broadcastRuntimeOptionsChangedRuntime: () => {
calls.push('broadcastRuntimeOptionsChangedRuntime');
},
getRuntimeOptionsState: () => [],
broadcastToOverlayWindows: () => {},
},
sendToActiveOverlayWindowMainDeps: {
sendToActiveOverlayWindowRuntime: () => true,
},
setOverlayDebugVisualizationEnabledMainDeps: {
setOverlayDebugVisualizationEnabledRuntime: () => {},
getCurrentEnabled: () => false,
setCurrentEnabled: () => {},
},
openRuntimeOptionsPaletteMainDeps: {
openRuntimeOptionsPaletteRuntime: () => {
calls.push('openRuntimeOptionsPaletteRuntime');
},
},
});
assert.equal(typeof composed.updateVisibleOverlayVisibility, 'function');
assert.equal(typeof composed.restorePreviousSecondarySubVisibility, 'function');
assert.equal(typeof composed.broadcastRuntimeOptionsChanged, 'function');
assert.equal(typeof composed.sendToActiveOverlayWindow, 'function');
assert.equal(typeof composed.setOverlayDebugVisualizationEnabled, 'function');
assert.equal(typeof composed.openRuntimeOptionsPalette, 'function');
// updateVisibleOverlayVisibility passes through to the injected runtime dep
composed.updateVisibleOverlayVisibility();
assert.deepEqual(calls, ['updateVisibleOverlayVisibility']);
// openRuntimeOptionsPalette forwards to the injected runtime dep
composed.openRuntimeOptionsPalette();
assert.deepEqual(calls, ['updateVisibleOverlayVisibility', 'openRuntimeOptionsPaletteRuntime']);
// broadcastRuntimeOptionsChanged forwards to the injected runtime dep
composed.broadcastRuntimeOptionsChanged();
assert.ok(calls.includes('broadcastRuntimeOptionsChangedRuntime'));
});
@@ -1,74 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeShortcutRuntimes } from './shortcuts-runtime-composer';
test('composeShortcutRuntimes returns callable shortcut runtime handlers', () => {
const calls: string[] = [];
const composed = composeShortcutRuntimes({
globalShortcuts: {
getConfiguredShortcutsMainDeps: {
getResolvedConfig: () => ({}) as never,
defaultConfig: {} as never,
resolveConfiguredShortcuts: () => ({}) as never,
},
buildRegisterGlobalShortcutsMainDeps: () => ({
getConfiguredShortcuts: () => ({}) as never,
registerGlobalShortcutsCore: () => {},
toggleVisibleOverlay: () => {},
openYomitanSettings: () => {},
isDev: false,
getMainWindow: () => null,
}),
buildRefreshGlobalAndOverlayShortcutsMainDeps: () => ({
unregisterAllGlobalShortcuts: () => {},
registerGlobalShortcuts: () => {},
syncOverlayShortcuts: () => {},
}),
},
numericShortcutRuntimeMainDeps: {
globalShortcut: {
register: () => true,
unregister: () => {},
},
showMpvOsd: () => {},
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
clearTimer: (timer) => clearTimeout(timer),
},
numericSessions: {
onMultiCopyDigit: () => {},
onMineSentenceDigit: () => {},
},
overlayShortcutsRuntimeMainDeps: {
overlayShortcutsRuntime: {
registerOverlayShortcuts: () => {
calls.push('registerOverlayShortcuts');
},
unregisterOverlayShortcuts: () => {},
syncOverlayShortcuts: () => {
calls.push('syncOverlayShortcuts');
},
refreshOverlayShortcuts: () => {},
},
},
});
assert.equal(typeof composed.getConfiguredShortcuts, 'function');
assert.equal(typeof composed.registerGlobalShortcuts, 'function');
assert.equal(typeof composed.refreshGlobalAndOverlayShortcuts, 'function');
assert.equal(typeof composed.cancelPendingMultiCopy, 'function');
assert.equal(typeof composed.startPendingMultiCopy, 'function');
assert.equal(typeof composed.cancelPendingMineSentenceMultiple, 'function');
assert.equal(typeof composed.startPendingMineSentenceMultiple, 'function');
assert.equal(typeof composed.registerOverlayShortcuts, 'function');
assert.equal(typeof composed.unregisterOverlayShortcuts, 'function');
assert.equal(typeof composed.syncOverlayShortcuts, 'function');
assert.equal(typeof composed.refreshOverlayShortcuts, 'function');
// registerOverlayShortcuts forwards to the injected overlayShortcutsRuntime dep
composed.registerOverlayShortcuts();
assert.deepEqual(calls, ['registerOverlayShortcuts']);
// syncOverlayShortcuts forwards to the injected overlayShortcutsRuntime dep
composed.syncOverlayShortcuts();
assert.deepEqual(calls, ['registerOverlayShortcuts', 'syncOverlayShortcuts']);
});
@@ -1,84 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeStartupLifecycleHandlers } from './startup-lifecycle-composer';
test('composeStartupLifecycleHandlers returns callable startup lifecycle handlers', () => {
const calls: string[] = [];
const composed = composeStartupLifecycleHandlers({
registerProtocolUrlHandlersMainDeps: {
registerOpenUrl: () => {},
registerSecondInstance: () => {},
handleAnilistSetupProtocolUrl: () => false,
findAnilistSetupDeepLinkArgvUrl: () => null,
logUnhandledOpenUrl: () => {},
logUnhandledSecondInstanceUrl: () => {},
},
onWillQuitCleanupMainDeps: {
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
isAppReady: () => true,
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null,
clearMainOverlayWindow: () => {},
getModalOverlayWindow: () => null,
clearModalOverlayWindow: () => {},
getYomitanParserWindow: () => null,
clearYomitanParserState: () => {},
getWindowTracker: () => null,
flushMpvLog: () => {},
getMpvSocket: () => null,
getReconnectTimer: () => null,
clearReconnectTimerRef: () => {},
getSubtitleTimingTracker: () => null,
getImmersionTracker: () => null,
stopStatsServer: () => {},
clearImmersionTracker: () => {},
getAnkiIntegration: () => null,
getAnilistSetupWindow: () => null,
clearAnilistSetupWindow: () => {},
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
getFirstRunSetupWindow: () => null,
clearFirstRunSetupWindow: () => {},
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {},
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupRemoteMediaWindows: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
},
shouldRestoreWindowsOnActivateMainDeps: {
isOverlayRuntimeInitialized: () => false,
getAllWindowCount: () => 0,
},
restoreWindowsOnActivateMainDeps: {
createMainWindow: () => {
calls.push('createMainWindow');
},
updateVisibleOverlayVisibility: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
},
});
assert.equal(typeof composed.registerProtocolUrlHandlers, 'function');
assert.equal(typeof composed.onWillQuitCleanup, 'function');
assert.equal(typeof composed.shouldRestoreWindowsOnActivate, 'function');
assert.equal(typeof composed.restoreWindowsOnActivate, 'function');
// shouldRestoreWindowsOnActivate returns false when overlay runtime is not initialized
assert.equal(composed.shouldRestoreWindowsOnActivate(), false);
// restoreWindowsOnActivate invokes the injected createMainWindow dep
composed.restoreWindowsOnActivate();
assert.deepEqual(calls, ['createMainWindow']);
});
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import {
buildConfigHotReloadPayload,
buildRestartRequiredConfigMessage,
createConfigHotReloadAppliedHandler,
createConfigHotReloadMessageHandler,
} from './config-hot-reload-handlers';
@@ -329,10 +328,3 @@ test('createConfigHotReloadMessageHandler routes message through configured noti
'notify:SubMiner:Config reload failed',
]);
});
test('buildRestartRequiredConfigMessage formats changed fields', () => {
assert.equal(
buildRestartRequiredConfigMessage(['websocket', 'subtitleStyle']),
'Config updated; restart required for: websocket, subtitleStyle',
);
});
@@ -1,14 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ReloadConfigStrictResult } from '../../config';
import type { ResolvedConfig } from '../../types';
import {
createBuildConfigHotReloadMessageMainDepsHandler,
createBuildConfigHotReloadAppliedMainDepsHandler,
createBuildConfigHotReloadRuntimeMainDepsHandler,
createBuildWatchConfigPathMainDepsHandler,
createWatchConfigPathHandler,
} from './config-hot-reload-main-deps';
import { createWatchConfigPathHandler } from './config-hot-reload-main-deps';
test('watch config path handler watches file directly when config exists', () => {
const calls: string[] = [];
@@ -44,119 +36,3 @@ test('watch config path handler filters directory events to config files only',
watchConfigPath('/tmp/config.jsonc', () => calls.push('change'));
assert.deepEqual(calls, ['change', 'change', 'change']);
});
test('watch config path main deps builder maps filesystem callbacks', () => {
const calls: string[] = [];
const deps = createBuildWatchConfigPathMainDepsHandler({
fileExists: () => true,
dirname: (targetPath) => {
calls.push(`dirname:${targetPath}`);
return '/tmp';
},
watchPath: (targetPath, listener) => {
calls.push(`watch:${targetPath}`);
listener('change', 'config.jsonc');
return { close: () => calls.push('close') };
},
})();
assert.equal(deps.fileExists('/tmp/config.jsonc'), true);
assert.equal(deps.dirname('/tmp/config.jsonc'), '/tmp');
const watcher = deps.watchPath('/tmp/config.jsonc', () => calls.push('listener'));
watcher.close();
assert.deepEqual(calls, [
'dirname:/tmp/config.jsonc',
'watch:/tmp/config.jsonc',
'listener',
'close',
]);
});
test('config hot reload message main deps builder maps notifications', () => {
const calls: string[] = [];
const deps = createBuildConfigHotReloadMessageMainDepsHandler({
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title) => calls.push(`notify:${title}`),
})();
deps.showMpvOsd('updated');
deps.showDesktopNotification('SubMiner', { body: 'updated' });
assert.deepEqual(calls, ['osd:updated', 'notify:SubMiner']);
});
test('config hot reload applied main deps builder maps callbacks', () => {
const calls: string[] = [];
const warningCounts: number[] = [];
const buildDeps = createBuildConfigHotReloadAppliedMainDepsHandler({
setKeybindings: () => calls.push('keybindings'),
setSessionBindings: (_sessionBindings, warnings) => {
calls.push('session-bindings');
warningCounts.push(warnings.length);
},
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'),
setSecondarySubMode: () => calls.push('set-secondary'),
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'),
});
const deps = buildDeps();
deps.setKeybindings([]);
deps.setSessionBindings([], []);
deps.refreshGlobalAndOverlayShortcuts();
deps.setSecondarySubMode('hover');
deps.broadcastToOverlayWindows('config:hot-reload', {});
deps.applyAnkiRuntimeConfigPatch({ ai: true });
assert.deepEqual(calls, [
'keybindings',
'session-bindings',
'refresh-shortcuts',
'set-secondary',
'broadcast:config:hot-reload',
'apply-anki',
]);
assert.deepEqual(warningCounts, [0]);
});
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',
]);
});
@@ -6,7 +6,6 @@ import {
getYoutubeFlowStatusNotificationOptions,
notifyConfiguredStatus,
} from './configured-status-notification';
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
test('notifyConfiguredStatus routes both to overlay and system without osd', () => {
const calls: string[] = [];
@@ -30,30 +29,11 @@ test('notifyConfiguredStatus routes both to overlay and system without osd', ()
]);
});
test('notifyConfiguredStatus queues overlay for pre-overlay both status and preserves desktop', () => {
const calls: string[] = [];
notifyConfiguredStatus('Overlay loading...', {
getNotificationType: () => 'both',
isOverlayReady: () => false,
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showOverlayNotification: (payload) =>
calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['overlay::Overlay loading...', 'desktop:SubMiner:Overlay loading...']);
});
test('notifyConfiguredStatus queues overlay for pre-overlay overlay-only status', () => {
test('notifyConfiguredStatus routes overlay-only status to the overlay', () => {
const calls: string[] = [];
notifyConfiguredStatus('Overlay loading...', {
getNotificationType: () => 'overlay',
isOverlayReady: () => false,
showOsd: (message) => {
calls.push(`osd:${message}`);
},
@@ -66,12 +46,11 @@ test('notifyConfiguredStatus queues overlay for pre-overlay overlay-only status'
assert.deepEqual(calls, ['overlay::Overlay loading...']);
});
test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => {
test('notifyConfiguredStatus routes system status to desktop only', () => {
const calls: string[] = [];
notifyConfiguredStatus('Overlay loading...', {
getNotificationType: () => 'system',
isOverlayReady: () => false,
showOsd: (message) => {
calls.push(`osd:${message}`);
},
@@ -330,231 +309,3 @@ test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', (
assert.deepEqual(calls, ['desktop:SubMiner:Overlay unavailable.']);
});
test('overlay notification delivery queues until an overlay window is ready', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
});
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' });
delivery.send({ id: 'character-dictionary-auto-sync', title: 'Dictionary', body: 'Building' });
assert.equal(delivery.getQueuedCount(), 2);
assert.deepEqual(sent, []);
ready = true;
delivery.flush();
assert.equal(delivery.getQueuedCount(), 0);
assert.deepEqual(sent, [
'startup-tokenization:Loading',
'character-dictionary-auto-sync:Building',
]);
});
test('overlay notification delivery upserts queued progress by notification id', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
});
delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '|' });
delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '/' });
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Ready' });
ready = true;
delivery.flush();
assert.deepEqual(sent, ['startup-subtitle-annotations:/', 'startup-tokenization:Ready']);
});
test('overlay notification delivery preserves queued events with distinct history ids', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push(
`${payload.id ?? ''}:${'historyId' in payload ? payload.historyId : ''}:${'body' in payload ? payload.body : ''}`,
),
});
delivery.send({
id: 'character-dictionary-auto-sync',
historyId: 'character-dictionary-auto-sync-checking',
title: 'Character dictionary',
body: 'Checking character dictionary...',
persistent: true,
});
delivery.send({
id: 'character-dictionary-auto-sync',
historyId: 'character-dictionary-auto-sync-building',
title: 'Character dictionary',
body: 'Building character dictionary...',
persistent: true,
});
ready = true;
delivery.flush();
assert.deepEqual(sent, [
'character-dictionary-auto-sync:character-dictionary-auto-sync-checking:Checking character dictionary...',
'character-dictionary-auto-sync:character-dictionary-auto-sync-building:Building character dictionary...',
]);
});
test('overlay notification delivery preserves queued startup progress before terminal update', () => {
const sent: string[] = [];
const scheduled: Array<() => void> = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push(
`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`,
),
scheduleFlushRetry: (callback) => {
scheduled.push(callback);
},
});
delivery.send({
id: 'startup-tokenization',
title: 'Subtitle tokenization',
body: 'Loading subtitle tokenization...',
variant: 'progress',
persistent: true,
});
delivery.send({
id: 'startup-tokenization',
title: 'Subtitle tokenization',
body: 'Subtitle tokenization ready',
variant: 'success',
persistent: false,
});
ready = true;
delivery.flush();
scheduled.shift()?.();
assert.deepEqual(sent, [
'startup-tokenization:Loading subtitle tokenization...:pin',
'startup-tokenization:Subtitle tokenization ready:auto',
]);
});
test('overlay notification delivery defers terminal update after first queued progress paint', () => {
const sent: string[] = [];
const scheduled: Array<() => void> = [];
const delays: number[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push(
`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`,
),
scheduleFlushRetry: (callback, delayMs) => {
scheduled.push(callback);
delays.push(delayMs);
},
terminalUpdateDelayMs: 750,
});
delivery.send({
id: 'startup-subtitle-annotations',
title: 'Subtitle annotations',
body: 'Loading subtitle annotations |',
variant: 'progress',
persistent: true,
});
delivery.send({
id: 'startup-subtitle-annotations',
title: 'Subtitle annotations',
body: 'Subtitle annotations loaded',
variant: 'success',
persistent: false,
});
ready = true;
delivery.flush();
assert.deepEqual(sent, ['startup-subtitle-annotations:Loading subtitle annotations |:pin']);
assert.equal(delivery.getQueuedCount(), 1);
assert.deepEqual(delays, [750]);
scheduled.shift()?.();
assert.equal(delivery.getQueuedCount(), 0);
assert.deepEqual(sent, [
'startup-subtitle-annotations:Loading subtitle annotations |:pin',
'startup-subtitle-annotations:Subtitle annotations loaded:auto',
]);
});
test('overlay notification delivery retries flush when lifecycle fires before window readiness settles', () => {
const sent: string[] = [];
const scheduled: Array<() => void> = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
scheduleFlushRetry: (callback) => {
scheduled.push(callback);
},
});
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' });
delivery.flush();
assert.equal(delivery.getQueuedCount(), 1);
assert.equal(scheduled.length, 1);
assert.deepEqual(sent, []);
ready = true;
scheduled.shift()?.();
assert.equal(delivery.getQueuedCount(), 0);
assert.deepEqual(sent, ['startup-tokenization:Loading']);
});
test('overlay notification delivery drops queued notification when dismissed before flush', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`),
});
delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' });
delivery.send({ id: 'overlay-loading-status', dismiss: true });
ready = true;
delivery.flush();
assert.deepEqual(sent, []);
});
test('overlay notification delivery removes queued notification when dismissed at readiness', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`),
});
delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' });
ready = true;
delivery.send({ id: 'overlay-loading-status', dismiss: true });
delivery.flush();
assert.deepEqual(sent, ['dismiss:overlay-loading-status']);
});
@@ -3,8 +3,6 @@ import test from 'node:test';
import {
createBuildDictionaryRootsMainHandler,
createBuildFrequencyDictionaryRootsMainHandler,
createBuildFrequencyDictionaryRuntimeMainDepsHandler,
createBuildJlptDictionaryRuntimeMainDepsHandler,
} from './dictionary-runtime-main-deps';
test('dictionary roots main handler returns expected root list', () => {
@@ -25,24 +23,6 @@ test('dictionary roots main handler returns expected root list', () => {
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({
platform: 'darwin',
@@ -96,24 +76,3 @@ test('frequency dictionary roots main handler uses APPDATA-style roots on window
assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false);
assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true);
});
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']);
});
@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDiscordRpcClient } from './discord-rpc-client';
test('createDiscordRpcClient forwards rich presence calls through client.user', async () => {
const calls: Array<string> = [];
const rpcClient = createDiscordRpcClient('123456789012345678', {
createClient: () =>
({
login: async () => {
calls.push('login');
},
user: {
setActivity: async () => {
calls.push('setActivity');
},
clearActivity: async () => {
calls.push('clearActivity');
},
},
destroy: async () => {
calls.push('destroy');
},
}) as never,
});
await rpcClient.login();
await rpcClient.setActivity({
details: 'Title',
state: 'Playing 00:01 / 00:02',
startTimestamp: 1_700_000_000,
});
await rpcClient.clearActivity();
await rpcClient.destroy();
assert.deepEqual(calls, ['login', 'setActivity', 'clearActivity', 'destroy']);
});
@@ -1,85 +0,0 @@
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: () => ({
isDestroyed: () => false,
webContents: {
send: () => {},
},
}),
getVisibleOverlayVisible: () => true,
setVisibleOverlayVisible: (visible) => calls.push(`visible:${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.equal(deps.getMainWindow()?.isDestroyed(), false);
assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getResolver(), resolver);
assert.equal(deps.getRestoreVisibleOverlayOnModalClose(), modalSet);
deps.setVisibleOverlayVisible(true);
deps.setResolver(null);
assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true);
assert.deepEqual(calls, ['visible:true', 'set-resolver:null', 'send:kiku:open:1']);
});
test('field grouping overlay main deps builder forwards modal open/teardown/prereq wiring', () => {
// Regression: these are optional on the runtime options, so a missing forward compiled
// silently and left the field grouping modal with no ack/retry, no teardown, and no prereqs —
// the reason the earlier recovery fixes never took effect.
const calls: string[] = [];
const waitForModalOpen = async (modal: 'kiku', timeoutMs: number): Promise<boolean> => {
calls.push(`wait:${modal}:${timeoutMs}`);
return true;
};
const handleOverlayModalClosed = (modal: 'kiku'): void => {
calls.push(`closed:${modal}`);
};
const logWarn = (message: string): void => {
calls.push(`warn:${message}`);
};
const ensureOverlayStartupPrereqs = (): void => {
calls.push('prereqs');
};
const ensureOverlayWindowsReadyForVisibilityActions = (): void => {
calls.push('windows-ready');
};
const deps = createBuildFieldGroupingOverlayMainDepsHandler<'kiku'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: () => null,
setResolver: () => {},
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
waitForModalOpen,
handleOverlayModalClosed,
logWarn,
ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions,
sendToActiveOverlayWindow: () => true,
})();
assert.equal(deps.waitForModalOpen, waitForModalOpen);
assert.equal(deps.handleOverlayModalClosed, handleOverlayModalClosed);
assert.equal(deps.logWarn, logWarn);
assert.equal(deps.ensureOverlayStartupPrereqs, ensureOverlayStartupPrereqs);
assert.equal(
deps.ensureOverlayWindowsReadyForVisibilityActions,
ensureOverlayWindowsReadyForVisibilityActions,
);
});
@@ -1,33 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildGetFieldGroupingResolverMainDepsHandler,
createBuildSetFieldGroupingResolverMainDepsHandler,
} from './field-grouping-resolver-main-deps';
test('get field grouping resolver main deps builder maps callbacks', () => {
const resolver = () => undefined;
const deps = createBuildGetFieldGroupingResolverMainDepsHandler({
getResolver: () => resolver,
})();
assert.equal(deps.getResolver(), resolver);
});
test('set field grouping resolver main deps builder maps callbacks', () => {
const calls: string[] = [];
const wrapped = (choice: unknown) => calls.push(String(choice));
const deps = createBuildSetFieldGroupingResolverMainDepsHandler({
setResolver: (resolver) => {
if (resolver) {
resolver('x' as never);
}
},
nextSequence: () => 2,
getSequence: () => 2,
})();
assert.equal(deps.nextSequence(), 2);
assert.equal(deps.getSequence(), 2);
deps.setResolver(wrapped as never);
assert.deepEqual(calls, ['x']);
});
@@ -1,18 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createGetFieldGroupingResolverHandler,
createSetFieldGroupingResolverHandler,
} from './field-grouping-resolver';
test('get field grouping resolver returns current resolver', () => {
const resolver = () => undefined;
const getResolver = createGetFieldGroupingResolverHandler({
getResolver: () => resolver,
});
assert.equal(getResolver(), resolver);
});
import { createSetFieldGroupingResolverHandler } from './field-grouping-resolver';
test('set field grouping resolver clears resolver when null is provided', () => {
let current: ((choice: unknown) => void) | null = () => undefined;
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
import {
buildFirstRunSetupHtml,
createHandleFirstRunSetupNavigationHandler,
createMaybeFocusExistingFirstRunSetupWindowHandler,
createOpenFirstRunSetupWindowHandler,
parseFirstRunSetupSubmissionUrl,
} from './first-run-setup-window';
@@ -34,78 +33,6 @@ function createCommandLineLauncherSnapshot(
};
}
test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish state', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 0,
canFinish: false,
externalYomitanConfigured: false,
pluginStatus: 'required',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: 'Waiting for dictionaries',
});
assert.match(html, /SubMiner setup/);
assert.doesNotMatch(html, /Install legacy mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
assert.doesNotMatch(html, /mpv runtime plugin/);
assert.doesNotMatch(html, /Bundled ready/);
assert.doesNotMatch(html, /Managed mpv launches use the bundled runtime plugin\./);
assert.match(html, /Open Yomitan Settings/);
assert.match(html, /Open SubMiner Settings/);
assert.match(
html,
/action=open-yomitan-settings'">Open Yomitan Settings<\/button>\s*<button class="ghost" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=refresh'">Refresh status<\/button>\s*<button onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=open-config-settings'">Open SubMiner Settings<\/button>\s*<button class="primary" disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/,
);
assert.match(html, /Finish setup/);
assert.match(html, /disabled/);
assert.match(html, /html,\s*body\s*{\s*min-height:\s*100%;/);
assert.match(html, /min-height:\s*100vh;/);
assert.match(html, /box-sizing:\s*border-box;/);
});
test('buildFirstRunSetupHtml omits bundled mpv plugin readiness when already installed', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
mpvExecutablePath: 'C:\\Program Files\\mpv\\mpv.exe',
mpvExecutablePathStatus: 'configured',
windowsMpvShortcuts: {
supported: true,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: true,
desktopInstalled: false,
status: 'installed',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
});
assert.doesNotMatch(html, /Reinstall mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
assert.doesNotMatch(html, /mpv runtime plugin/);
assert.match(html, /mpv executable path/);
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
assert.match(html, /aria-label="Path to mpv\.exe"/);
assert.doesNotMatch(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
});
test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirmation', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
@@ -169,6 +96,7 @@ test('buildFirstRunSetupHtml marks an invalid configured mpv path as invalid', (
assert.match(html, />Invalid</);
assert.match(html, /Current: C:\\Broken\\mpv\.exe \(invalid; file not found\)/);
assert.match(html, /aria-label="Path to mpv\.exe"/);
});
test('buildFirstRunSetupHtml explains the config blocker when setup is missing config', () => {
@@ -194,6 +122,10 @@ test('buildFirstRunSetupHtml explains the config blocker when setup is missing c
});
assert.match(html, /Create or provide the config file before finishing setup\./);
assert.match(
html,
/<button class="primary" disabled [^>]*action=finish'">Finish setup<\/button>/,
);
});
test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish enabled', () => {
@@ -397,19 +329,6 @@ test('buildFirstRunSetupHtml disables launcher install when no target is install
);
});
test('first-run setup window handler focuses existing window', () => {
const calls: string[] = [];
const maybeFocus = createMaybeFocusExistingFirstRunSetupWindowHandler({
getSetupWindow: () => ({
show: () => calls.push('show'),
focus: () => calls.push('focus'),
}),
});
assert.equal(maybeFocus(), true);
assert.deepEqual(calls, ['show', 'focus']);
});
test('first-run setup navigation handler prevents default and dispatches supported action', async () => {
const calls: string[] = [];
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
@@ -430,13 +349,6 @@ test('first-run setup navigation handler prevents default and dispatches support
assert.deepEqual(calls, ['preventDefault', 'refresh']);
});
test('first-run setup parser rejects legacy global plugin install action', () => {
assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=install-plugin'),
null,
);
});
test('first-run setup navigation handler swallows stale custom-scheme actions', () => {
const calls: string[] = [];
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
@@ -1,67 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildGetConfiguredShortcutsMainDepsHandler,
createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler,
createBuildRegisterGlobalShortcutsMainDepsHandler,
} from './global-shortcuts-main-deps';
test('get configured shortcuts main deps map config resolver inputs', () => {
const config = { shortcuts: { copySubtitle: 's' } } as never;
const defaults = { shortcuts: { copySubtitle: 'c' } } as never;
const build = createBuildGetConfiguredShortcutsMainDepsHandler({
getResolvedConfig: () => config,
defaultConfig: defaults,
resolveConfiguredShortcuts: (nextConfig, nextDefaults) =>
({ nextConfig, nextDefaults }) as never,
});
const deps = build();
assert.equal(deps.getResolvedConfig(), config);
assert.equal(deps.defaultConfig, defaults);
assert.deepEqual(deps.resolveConfiguredShortcuts(config, defaults), {
nextConfig: config,
nextDefaults: defaults,
});
});
test('register global shortcuts main deps map callbacks and flags', () => {
const calls: string[] = [];
const mainWindow = { id: 'main' };
const build = createBuildRegisterGlobalShortcutsMainDepsHandler({
getConfiguredShortcuts: () => ({ copySubtitle: 's' }) as never,
registerGlobalShortcutsCore: () => calls.push('register'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
openYomitanSettings: () => calls.push('open-yomitan'),
isDev: true,
getMainWindow: () => mainWindow as never,
});
const deps = build();
deps.registerGlobalShortcutsCore({
shortcuts: deps.getConfiguredShortcuts(),
onToggleVisibleOverlay: () => undefined,
onOpenYomitanSettings: () => undefined,
isDev: deps.isDev,
getMainWindow: deps.getMainWindow,
});
deps.onToggleVisibleOverlay();
deps.onOpenYomitanSettings();
assert.equal(deps.isDev, true);
assert.deepEqual(deps.getMainWindow(), mainWindow);
assert.deepEqual(calls, ['register', 'toggle-visible', 'open-yomitan']);
});
test('refresh global shortcuts main deps map passthrough handlers', () => {
const calls: string[] = [];
const deps = createBuildRefreshGlobalAndOverlayShortcutsMainDepsHandler({
unregisterAllGlobalShortcuts: () => calls.push('unregister'),
registerGlobalShortcuts: () => calls.push('register'),
syncOverlayShortcuts: () => calls.push('sync'),
})();
deps.unregisterAllGlobalShortcuts();
deps.registerGlobalShortcuts();
deps.syncOverlayShortcuts();
assert.deepEqual(calls, ['unregister', 'register', 'sync']);
});
@@ -1,65 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
import { createGlobalShortcutsRuntimeHandlers } from './global-shortcuts-runtime-handlers';
function createShortcuts(): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O',
copySubtitle: 's',
copySubtitleMultiple: 'CommandOrControl+s',
updateLastCardFromClipboard: 'c',
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: 'q',
mineSentenceMultiple: 'w',
multiCopyTimeoutMs: 5000,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
appendClipboardVideoToQueue: null,
};
}
test('global shortcuts runtime handlers compose get/register/refresh flow', () => {
const calls: string[] = [];
const shortcuts = createShortcuts();
const runtime = createGlobalShortcutsRuntimeHandlers({
getConfiguredShortcutsMainDeps: {
getResolvedConfig: () => ({}) as never,
defaultConfig: {} as never,
resolveConfiguredShortcuts: () => shortcuts,
},
buildRegisterGlobalShortcutsMainDeps: (getConfiguredShortcuts) => ({
getConfiguredShortcuts,
registerGlobalShortcutsCore: (options) => {
calls.push('register');
assert.equal(options.shortcuts, shortcuts);
},
toggleVisibleOverlay: () => calls.push('toggle-visible'),
openYomitanSettings: () => calls.push('open-yomitan'),
isDev: false,
getMainWindow: () => null,
}),
buildRefreshGlobalAndOverlayShortcutsMainDeps: (registerGlobalShortcuts) => ({
unregisterAllGlobalShortcuts: () => calls.push('unregister'),
registerGlobalShortcuts: () => registerGlobalShortcuts(),
syncOverlayShortcuts: () => calls.push('sync-overlay'),
}),
});
assert.equal(runtime.getConfiguredShortcuts(), shortcuts);
runtime.registerGlobalShortcuts();
runtime.refreshGlobalAndOverlayShortcuts();
assert.deepEqual(calls, ['register', 'unregister', 'register', 'sync-overlay']);
});
-92
View File
@@ -1,92 +0,0 @@
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',
copySubtitle: 's',
copySubtitleMultiple: 'CommandOrControl+s',
updateLastCardFromClipboard: 'c',
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: 'q',
mineSentenceMultiple: 'w',
multiCopyTimeoutMs: 5000,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openTsukihime: null,
openSubtitleSelection: null,
openSubtitleGeneration: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
appendClipboardVideoToQueue: 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.onOpenYomitanSettings();
},
onToggleVisibleOverlay: () => calls.push('toggle-visible'),
onOpenYomitanSettings: () => calls.push('open-yomitan'),
isDev: true,
getMainWindow: () => mainWindow,
});
registerGlobalShortcuts();
assert.deepEqual(calls, ['register', 'toggle-visible', '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']);
});
@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildImmersionTrackerStartupMainDepsHandler } from './immersion-startup-main-deps';
test('immersion tracker startup main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildImmersionTrackerStartupMainDepsHandler({
getResolvedConfig: () => ({ immersionTracking: { enabled: true } }) as never,
getConfiguredDbPath: () => '/tmp/immersion.db',
createTrackerService: () => {
calls.push('create');
return { id: 'tracker' };
},
setTracker: () => calls.push('set'),
getMpvClient: () => ({ connected: true, connect: () => {} }),
seedTrackerFromCurrentMedia: () => calls.push('seed'),
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
})();
assert.deepEqual(deps.getResolvedConfig(), { immersionTracking: { enabled: true } });
assert.equal(deps.getConfiguredDbPath(), '/tmp/immersion.db');
assert.deepEqual(
deps.createTrackerService({ dbPath: '/tmp/immersion.db', policy: {} as never }),
{
id: 'tracker',
},
);
deps.setTracker(null);
assert.equal(deps.getMpvClient()?.connected, true);
deps.seedTrackerFromCurrentMedia();
deps.logInfo('i');
deps.logDebug('d');
deps.logWarn('w', null);
assert.deepEqual(calls, ['create', 'set', 'seed', 'info:i', 'debug:d', 'warn:w']);
});
+106 -227
View File
@@ -1,241 +1,120 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseArgs, type CliArgs } from '../../cli/args';
import { createHandleInitialArgsHandler } from './initial-args-handler';
test('initial args handler no-ops without initial args', () => {
let handled = false;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => null,
type HandlerDeps = Parameters<typeof createHandleInitialArgsHandler>[0];
function cliArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return { ...parseArgs([]), start: true, ...overrides };
}
// Records every startup side effect so tests can assert what the handler did.
function runInitialArgs(
overrides: Partial<HandlerDeps> & { mpvClientConnected?: boolean } = {},
): string[] {
const effects: string[] = [];
const { mpvClientConnected, ...depOverrides } = overrides;
const mpvClient =
mpvClientConnected === undefined
? null
: { connected: mpvClientConnected, connect: () => effects.push('connect') };
createHandleInitialArgsHandler({
getInitialArgs: () => cliArgs(),
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
ensureTray: () => effects.push('tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
getMpvClient: () => mpvClient,
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
ensureOverlayStartupPrereqs: () => effects.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {
handled = true;
initializeOverlayRuntime: () => effects.push('init-overlay'),
logInfo: (message) => effects.push(`log:${message}`),
handleCliCommand: (_args, source) => effects.push(`cli:${source}`),
...depOverrides,
})();
return effects;
}
const AUTO_CONNECT_LOG = 'log:Auto-connecting MPV client for immersion tracking';
const cases: Array<{
name: string;
overrides: Parameters<typeof runInitialArgs>[0];
expected: string[];
}> = [
{
name: 'no-ops without initial args',
overrides: { getInitialArgs: () => null },
expected: [],
},
{
name: 'forwards args to cli handler without bootstrapping the overlay',
overrides: {},
expected: ['cli:initial'],
},
{
name: 'ensures tray in background mode',
overrides: { isBackgroundMode: () => true },
expected: ['tray', 'cli:initial'],
},
{
name: 'can ensure tray outside background mode when requested',
overrides: { shouldEnsureTrayOnStartup: () => true },
expected: ['tray', 'cli:initial'],
},
{
name: 'auto-connects mpv for immersion tracking',
overrides: { hasImmersionTracker: () => true, mpvClientConnected: false },
expected: [AUTO_CONNECT_LOG, 'connect', 'cli:initial'],
},
{
name: 'skips mpv auto-connect for stats mode',
overrides: {
getInitialArgs: () => cliArgs({ start: false, stats: true }),
hasImmersionTracker: () => true,
mpvClientConnected: false,
},
expected: ['cli:initial'],
},
{
name: 'bootstraps overlay before initial overlay-runtime commands',
overrides: {
commandNeedsOverlayStartupPrereqs: () => true,
commandNeedsOverlayRuntime: () => true,
},
expected: ['prereqs', 'init-overlay', 'cli:initial'],
},
{
name: 'prepares prereqs but skips eager overlay bootstrap for youtube playback',
overrides: {
getInitialArgs: () => cliArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }),
commandNeedsOverlayStartupPrereqs: () => true,
},
expected: ['prereqs', 'cli:initial'],
},
{
name: 'skips tray, mpv auto-connect, and overlay bootstrap for headless refresh',
overrides: {
getInitialArgs: () => cliArgs({ start: false, refreshKnownWords: true }),
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => true,
shouldRunHeadlessInitialCommand: () => true,
hasImmersionTracker: () => true,
mpvClientConnected: false,
commandNeedsOverlayStartupPrereqs: () => true,
commandNeedsOverlayRuntime: () => true,
},
expected: ['cli:initial'],
},
];
for (const c of cases) {
test(`initial args handler ${c.name}`, () => {
assert.deepEqual(runInitialArgs(c.overrides), c.expected);
});
handleInitialArgs();
assert.equal(handled, false);
});
test('initial args handler ensures tray in background mode', () => {
let ensuredTray = false;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {
ensuredTray = true;
},
isTexthookerOnlyMode: () => true,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});
handleInitialArgs();
assert.equal(ensuredTray, true);
});
test('initial args handler auto-connects mpv when needed', () => {
let connectCalls = 0;
let logged = false;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
getMpvClient: () => ({
connected: false,
connect: () => {
connectCalls += 1;
},
}),
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {
logged = true;
},
handleCliCommand: () => {},
});
handleInitialArgs();
assert.equal(connectCalls, 1);
assert.equal(logged, true);
});
test('initial args handler forwards args to cli handler', () => {
const seenSources: string[] = [];
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {
seenSources.push('prereqs');
},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {
seenSources.push('init-overlay');
},
logInfo: () => {},
handleCliCommand: (_args, source) => {
seenSources.push(source);
},
});
handleInitialArgs();
assert.deepEqual(seenSources, ['initial']);
});
test('initial args handler bootstraps overlay before initial overlay-runtime commands', () => {
const calls: string[] = [];
const args = { settings: true } as never;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => args,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayStartupPrereqs: (inputArgs) => inputArgs === args,
commandNeedsOverlayRuntime: (inputArgs) => inputArgs === args,
ensureOverlayStartupPrereqs: () => {
calls.push('prereqs');
},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {
calls.push('init-overlay');
},
logInfo: () => {},
handleCliCommand: (_args, source) => {
calls.push(`cli:${source}`);
},
});
handleInitialArgs();
assert.deepEqual(calls, ['prereqs', 'init-overlay', 'cli:initial']);
});
test('initial args handler prepares prereqs but skips eager overlay bootstrap for youtube playback', () => {
const calls: string[] = [];
const args = { youtubePlay: 'https://youtube.com/watch?v=abc' } as never;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => args,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayStartupPrereqs: () => true,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {
calls.push('prereqs');
},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {
calls.push('init-overlay');
},
logInfo: () => {},
handleCliCommand: (_args, source) => {
calls.push(`cli:${source}`);
},
});
handleInitialArgs();
assert.deepEqual(calls, ['prereqs', 'cli:initial']);
});
test('initial args handler can ensure tray outside background mode when requested', () => {
let ensuredTray = false;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => true,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {
ensuredTray = true;
},
isTexthookerOnlyMode: () => true,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});
handleInitialArgs();
assert.equal(ensuredTray, true);
});
test('initial args handler skips tray and mpv auto-connect for headless refresh', () => {
let ensuredTray = false;
let connectCalls = 0;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ refreshKnownWords: true }) as never,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => true,
shouldRunHeadlessInitialCommand: () => true,
ensureTray: () => {
ensuredTray = true;
},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
getMpvClient: () => ({
connected: false,
connect: () => {
connectCalls += 1;
},
}),
commandNeedsOverlayStartupPrereqs: () => true,
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});
handleInitialArgs();
assert.equal(ensuredTray, false);
assert.equal(connectCalls, 0);
});
}
@@ -1,44 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildHandleInitialArgsMainDepsHandler } from './initial-args-main-deps';
test('initial args main deps builder maps runtime callbacks and state readers', () => {
const calls: string[] = [];
const args = { start: true } as never;
const mpvClient = { connected: false, connect: () => calls.push('connect') };
const deps = createBuildHandleInitialArgsMainDepsHandler({
getInitialArgs: () => args,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => calls.push('ensure-tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
getMpvClient: () => mpvClient,
commandNeedsOverlayStartupPrereqs: () => true,
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`info:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
})();
assert.equal(deps.getInitialArgs(), args);
assert.equal(deps.isBackgroundMode(), true);
assert.equal(deps.shouldEnsureTrayOnStartup(), false);
assert.equal(deps.shouldRunHeadlessInitialCommand(args), false);
assert.equal(deps.isTexthookerOnlyMode(), false);
assert.equal(deps.hasImmersionTracker(), true);
assert.equal(deps.getMpvClient(), mpvClient);
assert.equal(deps.commandNeedsOverlayStartupPrereqs(args), true);
assert.equal(deps.commandNeedsOverlayRuntime(args), true);
assert.equal(deps.isOverlayRuntimeInitialized(), false);
deps.ensureTray();
deps.ensureOverlayStartupPrereqs();
deps.initializeOverlayRuntime();
deps.logInfo('x');
deps.handleCliCommand(args, 'initial');
assert.deepEqual(calls, ['ensure-tray', 'prereqs', 'init-overlay', 'info:x', 'cli:initial']);
});
@@ -1,92 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createInitialArgsRuntimeHandler } from './initial-args-runtime-handler';
test('initial args runtime handler composes main deps and runs initial command flow', () => {
const calls: string[] = [];
const handleInitialArgs = createInitialArgsRuntimeHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => calls.push('tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
getMpvClient: () => ({
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`log:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
});
handleInitialArgs();
assert.deepEqual(calls, [
'tray',
'log:Auto-connecting MPV client for immersion tracking',
'connect',
'cli:initial',
]);
});
test('initial args runtime handler skips mpv auto-connect for stats mode', () => {
const calls: string[] = [];
const handleInitialArgs = createInitialArgsRuntimeHandler({
getInitialArgs: () => ({ stats: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => calls.push('tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
getMpvClient: () => ({
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`log:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
});
handleInitialArgs();
assert.deepEqual(calls, ['cli:initial']);
});
test('initial args runtime handler skips tray and mpv auto-connect for headless refresh', () => {
const calls: string[] = [];
const handleInitialArgs = createInitialArgsRuntimeHandler({
getInitialArgs: () => ({ refreshKnownWords: true }) as never,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => true,
shouldRunHeadlessInitialCommand: () => true,
ensureTray: () => calls.push('tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
getMpvClient: () => ({
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayStartupPrereqs: () => true,
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`log:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
});
handleInitialArgs();
assert.deepEqual(calls, ['cli:initial']);
});
@@ -1,42 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildHandleMpvCommandFromIpcMainDepsHandler,
createBuildRunSubsyncManualFromIpcMainDepsHandler,
} from './ipc-bridge-actions-main-deps';
test('ipc bridge action main deps builders map callbacks', async () => {
const calls: string[] = [];
const handleMpv = createBuildHandleMpvCommandFromIpcMainDepsHandler({
handleMpvCommandFromIpcRuntime: (command) => calls.push(`mpv:${command.join(':')}`),
buildMpvCommandDeps: () => ({
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openJimaku: () => {},
openTsukihime: () => {},
openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
playNextSubtitle: () => {},
sendMpvCommand: () => {},
getMpvClient: () => null,
isMpvConnected: () => true,
hasRuntimeOptionsManager: () => true,
}),
})();
handleMpv.handleMpvCommandFromIpcRuntime(['show-text', 'hello'], handleMpv.buildMpvCommandDeps());
assert.equal(handleMpv.buildMpvCommandDeps().isMpvConnected(), true);
const runSubsync = createBuildRunSubsyncManualFromIpcMainDepsHandler({
runManualFromIpc: async (request: { id: string }) => {
calls.push(`subsync:${request.id}`);
return { ok: true as const };
},
})();
assert.deepEqual(await runSubsync.runManualFromIpc({ id: 'job-1' }), { ok: true });
assert.deepEqual(calls, ['mpv:show-text:hello', 'subsync:job-1']);
});
@@ -1,50 +0,0 @@
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: () => {},
openJimaku: () => {},
openTsukihime: () => {},
openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
playNextSubtitle: () => {},
sendMpvCommand: () => {},
getMpvClient: () => null,
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']);
});
@@ -1,60 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildMpvCommandFromIpcRuntimeMainDepsHandler } from './ipc-mpv-command-main-deps';
test('ipc mpv command main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildMpvCommandFromIpcRuntimeMainDepsHandler({
triggerSubsyncFromConfig: () => calls.push('subsync'),
openRuntimeOptionsPalette: () => calls.push('palette'),
openJimaku: () => calls.push('jimaku'),
openTsukihime: () => calls.push('tsukihime'),
openYoutubeTrackPicker: () => {
calls.push('youtube-picker');
},
openPlaylistBrowser: () => {
calls.push('playlist-browser');
},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: (text) => calls.push(`osd:${text}`),
showRawMpvOsd: (text) => calls.push(`raw-osd:${text}`),
showPlaybackFeedback: (text) => calls.push(`feedback:${text}`),
replayCurrentSubtitle: () => calls.push('replay'),
playNextSubtitle: () => calls.push('next'),
sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`),
getMpvClient: () => ({ connected: true, requestProperty: async () => null }),
isMpvConnected: () => true,
hasRuntimeOptionsManager: () => false,
})();
deps.triggerSubsyncFromConfig();
deps.openRuntimeOptionsPalette();
deps.openJimaku();
deps.openTsukihime();
void deps.openYoutubeTrackPicker();
void deps.openPlaylistBrowser();
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
deps.showMpvOsd('hello');
deps.showRawMpvOsd?.('delay');
deps.showPlaybackFeedback?.('primary');
deps.replayCurrentSubtitle();
deps.playNextSubtitle();
deps.sendMpvCommand(['show-text', 'ok']);
assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function');
assert.equal(deps.isMpvConnected(), true);
assert.equal(deps.hasRuntimeOptionsManager(), false);
assert.deepEqual(calls, [
'subsync',
'palette',
'jimaku',
'tsukihime',
'youtube-picker',
'playlist-browser',
'osd:hello',
'raw-osd:delay',
'feedback:primary',
'replay',
'next',
'cmd:show-text:ok',
]);
});
@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createIpcRuntimeHandlers } from './ipc-runtime-handlers';
test('ipc runtime handlers wire command and subsync handlers through built deps', async () => {
let receivedCommand: (string | number)[] | null = null;
let receivedCommandDeps: { tag: string } | null = null;
let buildMpvCommandDepsCalls = 0;
let receivedSubsyncRequest: { id: string } | null = null;
const runtime = createIpcRuntimeHandlers({
handleMpvCommandFromIpcDeps: {
handleMpvCommandFromIpcRuntime: (command, deps) => {
receivedCommand = command;
receivedCommandDeps = deps as unknown as { tag: string };
},
buildMpvCommandDeps: () => {
buildMpvCommandDepsCalls += 1;
return { tag: 'mpv-deps' } as never;
},
},
runSubsyncManualFromIpcDeps: {
runManualFromIpc: async (request: { id: string }) => {
receivedSubsyncRequest = request;
return { ok: true, id: request.id };
},
},
});
runtime.handleMpvCommandFromIpc(['set_property', 'pause', 'yes']);
assert.deepEqual(receivedCommand, ['set_property', 'pause', 'yes']);
assert.deepEqual(receivedCommandDeps, { tag: 'mpv-deps' });
assert.equal(buildMpvCommandDepsCalls, 1);
const response = await runtime.runSubsyncManualFromIpc({ id: 'abc' });
assert.deepEqual(receivedSubsyncRequest, { id: 'abc' });
assert.deepEqual(response, { ok: true, id: 'abc' });
});
@@ -140,38 +140,6 @@ test('persistJellyfinAuthSession stores session config and recent servers', () =
});
});
test('persistJellyfinAuthSession does not write generated local device id to config', () => {
let patchPayload: unknown = null;
persistJellyfinAuthSession({
session: {
serverUrl: 'http://localhost:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
},
clientInfo: {
deviceId: 'subminer-local-pc',
clientName: 'SubMiner',
clientVersion: '1.0',
},
existingRecentServers: [],
saveStoredSession: () => {},
patchRawConfig: (patch) => {
patchPayload = patch;
},
});
assert.deepEqual(patchPayload, {
jellyfin: {
enabled: true,
serverUrl: 'http://localhost:8096',
username: 'alice',
recentServers: ['http://localhost:8096'],
},
});
});
test('jellyfin auth handler no-ops when no auth command', async () => {
const handleAuth = createHandleJellyfinAuthCommands({
patchRawConfig: () => {},
@@ -1,110 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildHandleJellyfinAuthCommandsMainDepsHandler,
createBuildHandleJellyfinListCommandsMainDepsHandler,
createBuildHandleJellyfinPlayCommandMainDepsHandler,
createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler,
} from './jellyfin-cli-main-deps';
test('jellyfin auth commands main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildHandleJellyfinAuthCommandsMainDepsHandler({
patchRawConfig: () => calls.push('patch'),
authenticateWithPassword: async () => ({}) as never,
saveStoredSession: () => calls.push('save'),
clearStoredSession: () => calls.push('clear'),
logInfo: (message) => calls.push(`info:${message}`),
})();
deps.patchRawConfig({ jellyfin: {} });
await deps.authenticateWithPassword('', '', '', {
deviceId: '',
clientName: '',
clientVersion: '',
});
deps.saveStoredSession({ accessToken: 'token', userId: 'uid' });
deps.clearStoredSession();
deps.logInfo('ok');
assert.deepEqual(calls, ['patch', 'save', 'clear', 'info:ok']);
});
test('jellyfin list commands main deps builder maps callbacks', async () => {
const calls: string[] = [];
const writes: Array<{
responsePath: string;
payload: { serverUrl: string; accessToken: string; userId: string };
}> = [];
const deps = createBuildHandleJellyfinListCommandsMainDepsHandler({
listJellyfinLibraries: async () => {
calls.push('libraries');
return [];
},
listJellyfinItems: async () => {
calls.push('items');
return [];
},
listJellyfinSubtitleTracks: async () => {
calls.push('subtitles');
return [];
},
writeJellyfinPreviewAuth: (responsePath, payload) => {
writes.push({ responsePath, payload });
},
logInfo: (message) => calls.push(`info:${message}`),
})();
await deps.listJellyfinLibraries({} as never, {} as never);
await deps.listJellyfinItems({} as never, {} as never, { libraryId: '', limit: 1 });
await deps.listJellyfinSubtitleTracks({} as never, {} as never, 'id');
deps.writeJellyfinPreviewAuth('/tmp/jellyfin-preview.json', {
serverUrl: 'https://example.test',
accessToken: 'token',
userId: 'user-id',
});
deps.logInfo('done');
assert.deepEqual(calls, ['libraries', 'items', 'subtitles', 'info:done']);
assert.deepEqual(writes, [
{
responsePath: '/tmp/jellyfin-preview.json',
payload: {
serverUrl: 'https://example.test',
accessToken: 'token',
userId: 'user-id',
},
},
]);
});
test('jellyfin play command main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildHandleJellyfinPlayCommandMainDepsHandler({
playJellyfinItemInMpv: async () => {
calls.push('play');
},
logWarn: (message) => calls.push(`warn:${message}`),
})();
await deps.playJellyfinItemInMpv({} as never);
deps.logWarn('missing');
assert.deepEqual(calls, ['play', 'warn:missing']);
});
test('jellyfin remote announce main deps builder maps callbacks', async () => {
const calls: string[] = [];
const session = { advertiseNow: async () => true };
const deps = createBuildHandleJellyfinRemoteAnnounceCommandMainDepsHandler({
startJellyfinRemoteSession: async (options) => {
calls.push(`start:${options?.explicit ? 'explicit' : 'default'}`);
},
getRemoteSession: () => session,
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
})();
await deps.startJellyfinRemoteSession({ explicit: true });
assert.equal(deps.getRemoteSession(), session);
deps.logInfo('visible');
deps.logWarn('not-visible');
assert.deepEqual(calls, ['start:explicit', 'info:visible', 'warn:not-visible']);
});
@@ -1,33 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildGetJellyfinClientInfoMainDepsHandler,
createBuildGetResolvedJellyfinConfigMainDepsHandler,
} from './jellyfin-client-info-main-deps';
test('get resolved jellyfin config main deps builder maps callbacks', () => {
const resolved = { jellyfin: { url: 'https://example.com' } };
const deps = createBuildGetResolvedJellyfinConfigMainDepsHandler({
getResolvedConfig: () => resolved as never,
loadStoredSession: () => ({ accessToken: 'stored-token', userId: 'uid' }),
getEnv: (key: string) => (key === 'TEST' ? 'x' : undefined),
})();
assert.equal(deps.getResolvedConfig(), resolved);
assert.deepEqual(deps.loadStoredSession(), { accessToken: 'stored-token', userId: 'uid' });
assert.equal(deps.getEnv('TEST'), 'x');
});
test('get jellyfin client info main deps builder maps callbacks', () => {
const configured = { clientName: 'Configured' };
const deps = createBuildGetJellyfinClientInfoMainDepsHandler({
getResolvedJellyfinConfig: () => configured as never,
getHostName: () => 'workstation',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0.0',
})();
assert.equal(deps.getResolvedJellyfinConfig(), configured);
assert.equal(deps.getHostName?.(), 'workstation');
assert.equal(deps.defaultClientName, 'SubMiner');
assert.equal(deps.defaultClientVersion, '1.0.0');
});
@@ -92,43 +92,3 @@ test('jellyfin client info resolves defaults when fields are missing', () => {
deviceId: 'workstation',
});
});
test('jellyfin client info ignores legacy configured client name, device id, and version', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () =>
({
clientName: 'Custom',
clientVersion: '2.3.4',
deviceId: 'custom-device',
}) as never,
getHostName: () => 'Kyle-PC',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0.0',
});
assert.deepEqual(getClientInfo(), {
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'Kyle-PC',
});
});
test('jellyfin client info ignores legacy configured device id and client version', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () =>
({
clientName: 'SubMiner',
clientVersion: '9.9.9',
deviceId: 'custom-device',
}) as never,
getHostName: () => 'media-box',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0.0',
});
assert.deepEqual(getClientInfo(), {
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'media-box',
});
});
@@ -1,72 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CliArgs } from '../../cli/args';
import { createBuildRunJellyfinCommandMainDepsHandler } from './jellyfin-command-dispatch-main-deps';
test('run jellyfin command main deps builder maps callbacks', async () => {
const calls: string[] = [];
const args = { raw: [] } as unknown as CliArgs;
const config = {
serverUrl: 'http://localhost:8096',
accessToken: 'token',
userId: 'uid',
username: 'alice',
};
const clientInfo = { clientName: 'SubMiner' };
const deps = createBuildRunJellyfinCommandMainDepsHandler({
getJellyfinConfig: () => config,
defaultServerUrl: 'http://127.0.0.1:8096',
getJellyfinClientInfo: () => clientInfo,
handleAuthCommands: async () => {
calls.push('auth');
return false;
},
handleRemoteAnnounceCommand: async () => {
calls.push('remote');
return false;
},
handleListCommands: async () => {
calls.push('list');
return false;
},
handlePlayCommand: async () => {
calls.push('play');
return true;
},
})();
assert.equal(deps.getJellyfinConfig(), config);
assert.equal(deps.defaultServerUrl, 'http://127.0.0.1:8096');
assert.equal(deps.getJellyfinClientInfo(config), clientInfo);
await deps.handleAuthCommands({
args,
jellyfinConfig: config,
serverUrl: config.serverUrl,
clientInfo,
});
await deps.handleRemoteAnnounceCommand(args);
await deps.handleListCommands({
args,
session: {
serverUrl: config.serverUrl,
accessToken: config.accessToken,
userId: config.userId,
username: config.username,
},
clientInfo,
jellyfinConfig: config,
});
await deps.handlePlayCommand({
args,
session: {
serverUrl: config.serverUrl,
accessToken: config.accessToken,
userId: config.userId,
username: config.username,
},
clientInfo,
jellyfinConfig: config,
});
assert.deepEqual(calls, ['auth', 'remote', 'list', 'play']);
});
@@ -10,15 +10,6 @@ test('createHostDerivedJellyfinDeviceId uses the hostname as the stable id', ()
assert.equal(createHostDerivedJellyfinDeviceId(''), 'device');
});
test('resolveJellyfinRemoteDeviceName uses hostname by default', () => {
assert.equal(
resolveJellyfinRemoteDeviceName({
hostName: 'kyle-pc',
}),
'kyle-pc',
);
});
test('resolveJellyfinRemoteDeviceName falls back when hostname is empty', () => {
assert.equal(resolveJellyfinRemoteDeviceName({ hostName: '' }), 'device');
});
@@ -1,110 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildPlayJellyfinItemInMpvMainDepsHandler } from './jellyfin-playback-launch-main-deps';
test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildPlayJellyfinItemInMpvMainDepsHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'u',
mode: 'direct',
title: 't',
itemTitle: 't',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => calls.push('defaults'),
showVisibleOverlay: () => calls.push('visible-overlay'),
sendMpvCommand: (command) => calls.push(`cmd:${command[0]}`),
armQuitOnDisconnect: () => calls.push('arm'),
schedule: (_callback, delayMs) => calls.push(`schedule:${delayMs}`),
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {
calls.push('preload');
},
setActivePlayback: () => calls.push('active'),
setLastProgressAtMs: () => calls.push('progress'),
reportPlaying: () => calls.push('report'),
showMpvOsd: (text) => calls.push(`osd:${text}`),
})();
assert.equal(await deps.ensureMpvConnectedForPlayback(), true);
assert.equal(typeof deps.getMpvClient(), 'object');
assert.deepEqual(
await deps.resolvePlaybackPlan({
session: {
serverUrl: 'http://localhost:8096',
accessToken: 'token',
userId: 'uid',
username: 'alice',
},
clientInfo: {
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'did',
},
jellyfinConfig: {},
itemId: 'i',
}),
{
url: 'u',
mode: 'direct',
title: 't',
itemTitle: 't',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
},
);
deps.applyJellyfinMpvDefaults({ connected: true, send: () => {} });
deps.showVisibleOverlay();
deps.sendMpvCommand(['show-text', 'x']);
deps.armQuitOnDisconnect();
deps.schedule(() => {}, 500);
assert.equal(deps.convertTicksToSeconds(20_000_000), 2);
deps.preloadExternalSubtitles({
session: {
serverUrl: 'http://localhost:8096',
accessToken: 'token',
userId: 'uid',
username: 'alice',
},
clientInfo: {
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'did',
},
itemId: 'i',
});
deps.setActivePlayback({ itemId: 'i', mediaSourceId: undefined, playMethod: 'DirectPlay' });
deps.setLastProgressAtMs(0);
deps.reportPlaying({
itemId: 'i',
mediaSourceId: undefined,
playMethod: 'DirectPlay',
eventName: 'start',
});
deps.showMpvOsd('ok');
assert.deepEqual(calls, [
'defaults',
'visible-overlay',
'cmd:show-text',
'arm',
'schedule:500',
'preload',
'active',
'progress',
'report',
'osd:ok',
]);
});
+229 -628
View File
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { JellyfinPlaybackPlan } from '../../core/services/jellyfin';
import { createPlayJellyfinItemInMpvHandler } from './jellyfin-playback-launch';
type Deps = Parameters<typeof createPlayJellyfinItemInMpvHandler>[0];
type PlayParams = Parameters<ReturnType<typeof createPlayJellyfinItemInMpvHandler>>[0];
const baseSession = {
serverUrl: 'http://localhost:8096',
accessToken: 'token',
@@ -15,51 +19,95 @@ const baseClientInfo = {
deviceId: 'did',
};
test('playback handler throws when mpv is not connected', async () => {
const SUPPRESSED_SUBTITLE_OPTIONS =
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no';
function makePlan(overrides: Partial<JellyfinPlaybackPlan> = {}): JellyfinPlaybackPlan {
return {
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
...overrides,
};
}
/**
* Builds the playback handler with recording fakes. `events` is one ordered log
* of everything the handler did, for the few tests where order is the behavior.
*/
function makeHarness(options: { plan?: Partial<JellyfinPlaybackPlan>; deps?: Partial<Deps> } = {}) {
const commands: Array<Array<string | number>> = [];
const events: string[] = [];
const activeStates: Array<Parameters<Deps['setActivePlayback']>[0]> = [];
const reports: Array<Parameters<Deps['reportPlaying']>[0]> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => false,
getMpvClient: () => null,
resolvePlaybackPlan: async () => {
throw new Error('unreachable');
},
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => makePlan(options.plan),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: () => {},
armQuitOnDisconnect: () => {},
showVisibleOverlay: () => events.push('visible-overlay'),
sendMpvCommand: (command) => {
commands.push(command);
events.push(`cmd:${command[0]}`);
},
armQuitOnDisconnect: () => events.push('arm'),
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setActivePlayback: (state) => {
activeStates.push(state);
events.push(`active:${String(state.loadedMediaPath)}`);
},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
reportPlaying: (payload) => reports.push(payload),
showMpvOsd: () => {},
...options.deps,
});
await assert.rejects(
() =>
return {
commands,
events,
activeStates,
reports,
play: (params: Partial<PlayParams> = {}) =>
handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-1',
...params,
}),
/MPV not connected and auto-launch failed/,
);
loadfile: () => commands.find((command) => command[0] === 'loadfile'),
loadedUrl: () => new URL(String(commands.find((command) => command[0] === 'loadfile')?.[1])),
};
}
test('playback handler throws when mpv is not connected', async () => {
const harness = makeHarness({
deps: {
ensureMpvConnectedForPlayback: async () => false,
getMpvClient: () => null,
resolvePlaybackPlan: async () => {
throw new Error('unreachable');
},
},
});
await assert.rejects(() => harness.play(), /MPV not connected and auto-launch failed/);
});
test('playback handler drives mpv commands and playback state', async () => {
const commands: Array<Array<string | number>> = [];
const scheduled: Array<{ delay: number; callback: () => void }> = [];
const calls: string[] = [];
const activeStates: Array<Record<string, unknown>> = [];
const reportPayloads: Array<Record<string, unknown>> = [];
const statsMetadata: Array<Record<string, unknown>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
test('playback handler disables mpv subtitle selection, then loads media and reports playback', async () => {
const statsMetadata: unknown[] = [];
const harness = makeHarness({
plan: {
title: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: 'Show Title',
@@ -68,35 +116,13 @@ test('playback handler drives mpv commands and playback state', async () => {
startTimeTicks: 12_000_000,
audioStreamIndex: 1,
subtitleStreamIndex: 2,
}),
applyJellyfinMpvDefaults: () => calls.push('defaults'),
showVisibleOverlay: () => calls.push('visible-overlay'),
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => calls.push('arm'),
schedule: (callback, delayMs) => {
scheduled.push({ delay: delayMs, callback });
},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {
calls.push('preload');
},
setActivePlayback: (state) => activeStates.push(state as Record<string, unknown>),
setLastProgressAtMs: (value) => calls.push(`progress:${value}`),
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
showMpvOsd: (text) => calls.push(`osd:${text}`),
recordJellyfinPlaybackMetadata: (metadata) => {
statsMetadata.push(metadata as Record<string, unknown>);
},
deps: { recordJellyfinPlaybackMetadata: (metadata) => void statsMetadata.push(metadata) },
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-1',
});
await harness.play();
assert.deepEqual(commands.slice(0, 8), [
assert.deepEqual(harness.commands, [
['set_property', 'sub-auto', 'no'],
['set_property', 'sid', 'no'],
['set_property', 'secondary-sid', 'no'],
@@ -109,33 +135,16 @@ test('playback handler drives mpv commands and playback state', async () => {
'https://stream.example/video.m3u8',
'replace',
-1,
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no,start=1.2',
`${SUPPRESSED_SUBTITLE_OPTIONS},start=1.2`,
],
]);
assert.equal(scheduled.length, 0);
assert.equal(
commands.filter((command) => command[0] === 'set_property' && command[1] === 'sid').length,
1,
);
assert.ok(calls.includes('defaults'));
assert.ok(
calls.indexOf('preload') < calls.indexOf('visible-overlay'),
'visible overlay should be shown after Jellyfin subtitles are selected',
);
assert.ok(calls.includes('visible-overlay'));
assert.ok(calls.includes('arm'));
assert.ok(calls.includes('preload'));
assert.ok(calls.includes('progress:0'));
assert.ok(calls.includes('osd:Jellyfin direct: Episode 1'));
assert.equal(activeStates.length, 1);
assert.equal(activeStates[0]?.playMethod, 'DirectPlay');
assert.equal(activeStates[0]?.lastKnownPositionSeconds, 1.2);
assert.equal(reportPayloads.length, 1);
assert.equal(reportPayloads[0]?.eventName, 'start');
assert.equal(reportPayloads[0]?.positionTicks, 12_000_000);
assert.equal(reportPayloads[0]?.isPaused, false);
assert.equal(harness.activeStates.length, 1);
assert.equal(harness.activeStates[0]?.playMethod, 'DirectPlay');
assert.equal(harness.activeStates[0]?.lastKnownPositionSeconds, 1.2);
assert.equal(harness.reports.length, 1);
assert.equal(harness.reports[0]?.eventName, 'start');
assert.equal(harness.reports[0]?.positionTicks, 12_000_000);
assert.equal(harness.reports[0]?.isPaused, false);
assert.deepEqual(statsMetadata, [
{
mediaPath: 'https://stream.example/video.m3u8',
@@ -147,6 +156,7 @@ test('playback handler drives mpv commands and playback state', async () => {
itemId: 'item-1',
},
]);
assert.ok(harness.events.includes('arm'));
});
test('playback handler waits for Jellyfin subtitle preload before showing visible overlay', async () => {
@@ -155,50 +165,23 @@ test('playback handler waits for Jellyfin subtitle preload before showing visibl
const preloadComplete = new Promise<void>((resolve) => {
resolvePreload = resolve;
});
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: 'Show Title',
seasonNumber: 1,
episodeNumber: 1,
startTimeTicks: 0,
audioStreamIndex: 1,
subtitleStreamIndex: 2,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => calls.push('visible-overlay'),
sendMpvCommand: () => {},
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: async () => {
calls.push('preload-start');
await preloadComplete;
calls.push('preload-done');
const harness = makeHarness({
deps: {
showVisibleOverlay: () => calls.push('visible-overlay'),
preloadExternalSubtitles: async () => {
calls.push('preload-start');
await preloadComplete;
calls.push('preload-done');
},
},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
});
const playback = handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-1',
});
const playback = harness.play();
for (let i = 0; i < 5 && calls.length === 0; i += 1) {
await Promise.resolve();
}
assert.equal(calls.length, 1);
assert.equal(calls[0], 'preload-start');
assert.deepEqual(calls, ['preload-start']);
resolvePreload();
await playback;
@@ -206,541 +189,159 @@ test('playback handler waits for Jellyfin subtitle preload before showing visibl
});
test('playback handler strips Jellyfin subtitle stream from mpv load URL', async () => {
const commands: Array<Array<string | number>> = [];
const reports: Array<Record<string, unknown>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
const harness = makeHarness({
plan: {
url: 'https://jellyfin.local/Videos/ep-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
mode: 'direct',
title: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: 3,
subtitleStreamIndex: 4,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: (payload) => reports.push(payload),
showMpvOsd: () => {},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'ep-1',
});
const loadCommand = commands.find((command) => command[0] === 'loadfile');
assert.ok(loadCommand);
const url = new URL(String(loadCommand[1]));
assert.equal(url.searchParams.get('AudioStreamIndex'), '3');
assert.equal(url.searchParams.has('SubtitleStreamIndex'), false);
assert.equal(reports[0]?.subtitleStreamIndex, 4);
});
test('playback handler starts remote Play from beginning when requested despite saved plan progress', async () => {
const commands: Array<Array<string | number>> = [];
const reportPayloads: Array<Record<string, unknown>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8?api_key=token&StartTimeTicks=35000000',
mode: 'transcode',
title: 'Episode 2',
itemTitle: 'Episode 2',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 35_000_000,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
showMpvOsd: () => {},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-2',
startTimeTicksOverride: 0,
fallbackToPlanStartTimeOnZeroOverride: false,
});
const loadCommand = commands.find((command) => command[0] === 'loadfile');
assert.ok(loadCommand);
const loadedUrl = String(loadCommand[1] ?? '');
const parsed = new URL(loadedUrl);
assert.equal(parsed.searchParams.get('StartTimeTicks'), null);
assert.equal(
commands.some((command) => command[0] === 'seek'),
false,
);
assert.equal(reportPayloads[0]?.positionTicks, 0);
});
test('playback handler disables mpv subtitle selection before Jellyfin media loads', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-1',
});
const loadIndex = commands.findIndex((command) => command[0] === 'loadfile');
assert.ok(loadIndex > 0);
assert.ok(
commands.findIndex(
(command, index) =>
index < loadIndex &&
command[0] === 'script-message' &&
command[1] === 'subminer-managed-subtitles-loading',
) >= 0,
);
assert.ok(
commands.findIndex(
(command, index) =>
index < loadIndex &&
command[0] === 'set_property' &&
command[1] === 'sid' &&
command[2] === 'no',
) >= 0,
);
assert.ok(
commands.findIndex(
(command, index) =>
index < loadIndex &&
command[0] === 'set_property' &&
command[1] === 'secondary-sid' &&
command[2] === 'no',
) >= 0,
);
assert.ok(
commands.findIndex(
(command, index) =>
index < loadIndex &&
command[0] === 'set_property' &&
command[1] === 'sub-visibility' &&
command[2] === 'no',
) >= 0,
);
assert.ok(
commands.findIndex(
(command, index) =>
index < loadIndex &&
command[0] === 'set_property' &&
command[1] === 'secondary-sub-visibility' &&
command[2] === 'no',
) >= 0,
);
assert.equal(
commands[loadIndex]?.[4],
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
);
});
test('playback handler publishes Jellyfin title before loading tokenized stream url', async () => {
const timeline: string[] = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://jellyfin.local/Videos/ep-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1',
mode: 'direct',
title: 'Galaxy Quest S02E07 A New Hope',
itemTitle: 'A New Hope',
seriesTitle: 'Galaxy Quest',
seasonNumber: 2,
episodeNumber: 7,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => timeline.push(`cmd:${command[0]}:${String(command[1] ?? '')}`),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
updateCurrentMediaTitle: (title) => {
timeline.push(`title:${title}`);
},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'ep-1',
await harness.play({ itemId: 'ep-1' });
const url = harness.loadedUrl();
assert.equal(url.searchParams.get('AudioStreamIndex'), '3');
assert.equal(url.searchParams.has('SubtitleStreamIndex'), false);
assert.equal(harness.reports[0]?.subtitleStreamIndex, 4);
});
const START_TICKS_CASES: Array<{
name: string;
planTicks: number;
params: Partial<PlayParams>;
expectedUrlTicks: string | null;
expectedPositionTicks: number;
}> = [
{
name: 'keeps plan resume ticks when no override is given',
planTicks: 35_000_000,
params: {},
expectedUrlTicks: '35000000',
expectedPositionTicks: 35_000_000,
},
{
name: 'applies a positive override to the stream url for remote resume',
planTicks: 0,
params: { startTimeTicksOverride: 55_000_000 },
expectedUrlTicks: '55000000',
expectedPositionTicks: 55_000_000,
},
{
name: 'starts from the beginning on a zero override despite saved plan progress',
planTicks: 35_000_000,
params: { startTimeTicksOverride: 0, fallbackToPlanStartTimeOnZeroOverride: false },
expectedUrlTicks: null,
expectedPositionTicks: 0,
},
{
name: 'keeps plan resume ticks on a zero override when fallback is enabled',
planTicks: 35_000_000,
params: { startTimeTicksOverride: 0, fallbackToPlanStartTimeOnZeroOverride: true },
expectedUrlTicks: '35000000',
expectedPositionTicks: 35_000_000,
},
];
for (const c of START_TICKS_CASES) {
test(`playback handler ${c.name}`, async () => {
const harness = makeHarness({
plan: {
url: `https://stream.example/video.m3u8?api_key=token${
c.planTicks > 0 ? `&StartTimeTicks=${c.planTicks}` : ''
}`,
mode: 'transcode',
startTimeTicks: c.planTicks,
},
});
await harness.play(c.params);
assert.equal(harness.loadedUrl().searchParams.get('StartTimeTicks'), c.expectedUrlTicks);
assert.equal(harness.reports[0]?.positionTicks, c.expectedPositionTicks);
assert.equal(
harness.commands.some((command) => command[0] === 'seek'),
false,
);
});
}
test('playback handler publishes Jellyfin title before loading tokenized stream url', async () => {
const harness = makeHarness({
plan: {
url: 'https://jellyfin.local/Videos/ep-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1',
title: 'Galaxy Quest S02E07 A New Hope',
},
deps: {
updateCurrentMediaTitle: (title) => void harness.events.push(`title:${title}`),
},
});
const titleIndex = timeline.indexOf('title:Galaxy Quest S02E07 A New Hope');
const loadIndex = timeline.findIndex((entry) => entry.startsWith('cmd:loadfile:'));
await harness.play({ itemId: 'ep-1' });
const titleIndex = harness.events.indexOf('title:Galaxy Quest S02E07 A New Hope');
const loadIndex = harness.events.indexOf('cmd:loadfile');
assert.ok(titleIndex >= 0);
assert.ok(loadIndex >= 0);
assert.ok(titleIndex < loadIndex);
const mpvTitleIndex = timeline.indexOf('cmd:set_property:force-media-title');
assert.ok(mpvTitleIndex >= 0 && mpvTitleIndex < loadIndex);
assert.equal(timeline[titleIndex]?.includes('api_key'), false);
const mpvTitleIndex = harness.commands.findIndex((command) => command[1] === 'force-media-title');
assert.ok(
mpvTitleIndex >= 0 && mpvTitleIndex < harness.commands.findIndex((c) => c[0] === 'loadfile'),
);
});
test('playback handler arms unloaded active playback before loading mpv media', async () => {
const timeline: string[] = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => timeline.push(`cmd:${command[0]}`),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: (state) => timeline.push(`active:${String(state.loadedMediaPath)}`),
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
});
const harness = makeHarness();
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-1',
});
await harness.play();
assert.ok(timeline.indexOf('active:null') >= 0);
assert.ok(timeline.indexOf('active:null') < timeline.indexOf('cmd:loadfile'));
const activeIndex = harness.events.indexOf('active:null');
assert.ok(activeIndex >= 0);
assert.ok(activeIndex < harness.events.indexOf('cmd:loadfile'));
});
test('playback handler applies start override to stream url for remote resume', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8?api_key=token',
mode: 'transcode',
title: 'Episode 2',
itemTitle: 'Episode 2',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-2',
startTimeTicksOverride: 55_000_000,
});
const loadCommand = commands.find((command) => command[0] === 'loadfile');
assert.ok(loadCommand);
const loadedUrl = String(loadCommand[1] ?? '');
const parsed = new URL(loadedUrl);
assert.equal(parsed.searchParams.get('StartTimeTicks'), '55000000');
assert.equal(
commands.some((command) => command[0] === 'seek'),
false,
);
});
test('playback handler keeps Jellyfin resume ticks when remote start override is zero', async () => {
const commands: Array<Array<string | number>> = [];
const reportPayloads: Array<Record<string, unknown>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8?api_key=token&StartTimeTicks=35000000',
mode: 'transcode',
title: 'Episode 2',
itemTitle: 'Episode 2',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 35_000_000,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
showMpvOsd: () => {},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-2',
startTimeTicksOverride: 0,
fallbackToPlanStartTimeOnZeroOverride: true,
});
const loadCommand = commands.find((command) => command[0] === 'loadfile');
assert.ok(loadCommand);
const loadedUrl = String(loadCommand[1] ?? '');
const parsed = new URL(loadedUrl);
assert.equal(parsed.searchParams.get('StartTimeTicks'), '35000000');
assert.equal(
commands.some((command) => command[0] === 'seek'),
false,
);
assert.equal(reportPayloads[0]?.positionTicks, 35_000_000);
});
test('playback handler does not let stats metadata failures block playback startup', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 3',
itemTitle: 'Episode 3',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
recordJellyfinPlaybackMetadata: () => {
throw new Error('stats db unavailable');
const HOOK_FAILURE_CASES: Array<{ name: string; deps: Partial<Deps> }> = [
{
name: 'stats metadata failures',
deps: {
recordJellyfinPlaybackMetadata: () => {
throw new Error('stats db unavailable');
},
},
});
},
{
name: 'media title failures',
deps: {
updateCurrentMediaTitle: () => {
throw new Error('title state unavailable');
},
},
},
{
name: 'rejected best-effort hook promises',
deps: {
updateCurrentMediaTitle: async () => {
throw new Error('title async unavailable');
},
recordJellyfinPlaybackMetadata: async () => {
throw new Error('stats async unavailable');
},
},
},
];
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-3',
});
for (const c of HOOK_FAILURE_CASES) {
test(`playback handler does not let ${c.name} block playback startup`, async () => {
const harness = makeHarness({ deps: c.deps });
assert.deepEqual(
commands.find((command) => command[0] === 'loadfile'),
[
await harness.play();
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(harness.loadfile(), [
'loadfile',
'https://stream.example/video.m3u8',
'replace',
-1,
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
],
);
});
test('playback handler does not let media title failures block playback startup', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 4',
itemTitle: 'Episode 4',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
updateCurrentMediaTitle: () => {
throw new Error('title state unavailable');
},
SUPPRESSED_SUBTITLE_OPTIONS,
]);
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-4',
});
assert.deepEqual(
commands.find((command) => command[0] === 'loadfile'),
[
'loadfile',
'https://stream.example/video.m3u8',
'replace',
-1,
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
],
);
});
test('playback handler handles rejected best-effort hook promises', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 5',
itemTitle: 'Episode 5',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
updateCurrentMediaTitle: async () => {
throw new Error('title async unavailable');
},
recordJellyfinPlaybackMetadata: async () => {
throw new Error('stats async unavailable');
},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-5',
});
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(
commands.find((command) => command[0] === 'loadfile'),
[
'loadfile',
'https://stream.example/video.m3u8',
'replace',
-1,
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
],
);
});
}
+128 -149
View File
@@ -6,6 +6,7 @@ import {
createHandleJellyfinRemotePlaystate,
getConfiguredJellyfinSession,
type ActiveJellyfinRemotePlaybackState,
type JellyfinRemotePlayHandlerDeps,
} from './jellyfin-remote-commands';
test('getConfiguredJellyfinSession returns null for incomplete config', () => {
@@ -20,134 +21,17 @@ test('getConfiguredJellyfinSession returns null for incomplete config', () => {
);
});
test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', async () => {
const calls: Array<{
itemId: string;
audio?: number;
subtitle?: number;
start?: number;
fallback?: boolean;
}> = [];
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({ enabled: true }),
playJellyfinItem: async (params) => {
calls.push({
itemId: params.itemId,
audio: params.audioStreamIndex,
subtitle: params.subtitleStreamIndex,
start: params.startTimeTicksOverride,
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
});
},
logWarn: () => {},
});
await handlePlay({
ItemIds: ['item-1'],
AudioStreamIndex: 3,
SubtitleStreamIndex: 7,
StartPositionTicks: 1000,
});
assert.deepEqual(calls, [
{ itemId: 'item-1', audio: 3, subtitle: 7, start: 1000, fallback: true },
]);
});
test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async () => {
const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = [];
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({ enabled: true }),
playJellyfinItem: async (params) => {
calls.push({
itemId: params.itemId,
start: params.startTimeTicksOverride,
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
});
},
logWarn: () => {},
});
await handlePlay({
ItemIds: ['item-2'],
StartPositionTicks: '12345',
});
assert.deepEqual(calls, [{ itemId: 'item-2', start: 12345, fallback: true }]);
});
test('createHandleJellyfinRemotePlay starts from beginning when StartPositionTicks is omitted', async () => {
const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = [];
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({ enabled: true }),
playJellyfinItem: async (params) => {
calls.push({
itemId: params.itemId,
start: params.startTimeTicksOverride,
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
});
},
logWarn: () => {},
});
await handlePlay({
ItemIds: ['item-3'],
});
assert.deepEqual(calls, [{ itemId: 'item-3', start: 0, fallback: false }]);
});
test('createHandleJellyfinRemotePlay lets explicit zero fall back to Jellyfin item progress', async () => {
const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = [];
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({ enabled: true }),
playJellyfinItem: async (params) => {
calls.push({
itemId: params.itemId,
start: params.startTimeTicksOverride,
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
});
},
logWarn: () => {},
});
await handlePlay({
ItemIds: ['item-4'],
StartPositionTicks: 0,
});
assert.deepEqual(calls, [{ itemId: 'item-4', start: 0, fallback: true }]);
});
test('createHandleJellyfinRemotePlay logs and skips payload without item id', async () => {
function makePlayHandler(overrides: Partial<JellyfinRemotePlayHandlerDeps> = {}) {
const played: Array<
Pick<
Parameters<JellyfinRemotePlayHandlerDeps['playJellyfinItem']>[0],
| 'itemId'
| 'audioStreamIndex'
| 'subtitleStreamIndex'
| 'startTimeTicksOverride'
| 'fallbackToPlanStartTimeOnZeroOverride'
>
> = [];
const warnings: string[] = [];
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
@@ -158,40 +42,92 @@ test('createHandleJellyfinRemotePlay logs and skips payload without item id', as
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({}),
playJellyfinItem: async () => {
throw new Error('should not be called');
playJellyfinItem: async (params) => {
played.push({
itemId: params.itemId,
audioStreamIndex: params.audioStreamIndex,
subtitleStreamIndex: params.subtitleStreamIndex,
startTimeTicksOverride: params.startTimeTicksOverride,
fallbackToPlanStartTimeOnZeroOverride: params.fallbackToPlanStartTimeOnZeroOverride,
});
},
logWarn: (message) => warnings.push(message),
...overrides,
});
return { handlePlay, played, warnings };
}
const PLAY_PAYLOAD_CASES: Array<{
name: string;
payload: Record<string, unknown>;
expected: {
audioStreamIndex?: number;
subtitleStreamIndex?: number;
startTimeTicksOverride: number;
fallbackToPlanStartTimeOnZeroOverride: boolean;
};
}> = [
{
name: 'forwards audio, subtitle and numeric StartPositionTicks',
payload: { AudioStreamIndex: 3, SubtitleStreamIndex: 7, StartPositionTicks: 1000 },
expected: {
audioStreamIndex: 3,
subtitleStreamIndex: 7,
startTimeTicksOverride: 1000,
fallbackToPlanStartTimeOnZeroOverride: true,
},
},
{
name: 'parses string StartPositionTicks',
payload: { StartPositionTicks: '12345' },
expected: { startTimeTicksOverride: 12345, fallbackToPlanStartTimeOnZeroOverride: true },
},
{
name: 'starts from beginning when StartPositionTicks is omitted',
payload: {},
expected: { startTimeTicksOverride: 0, fallbackToPlanStartTimeOnZeroOverride: false },
},
{
name: 'lets explicit zero fall back to Jellyfin item progress',
payload: { StartPositionTicks: 0 },
expected: { startTimeTicksOverride: 0, fallbackToPlanStartTimeOnZeroOverride: true },
},
];
for (const c of PLAY_PAYLOAD_CASES) {
test(`createHandleJellyfinRemotePlay ${c.name}`, async () => {
const { handlePlay, played } = makePlayHandler();
await handlePlay({ ItemIds: ['item-1'], ...c.payload });
assert.deepEqual(played, [
{
itemId: 'item-1',
audioStreamIndex: undefined,
subtitleStreamIndex: undefined,
...c.expected,
},
]);
});
}
test('createHandleJellyfinRemotePlay logs and skips payload without item id', async () => {
const { handlePlay, played, warnings } = makePlayHandler();
await handlePlay({ ItemIds: [] });
assert.deepEqual(warnings, ['Ignoring Jellyfin remote Play event without ItemIds.']);
assert.deepEqual(played, []);
});
test('createHandleJellyfinRemotePlay ignores duplicate play for active item', async () => {
let playCalls = 0;
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({}),
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
}),
playJellyfinItem: async () => {
playCalls += 1;
},
logWarn: () => {},
const { handlePlay, played } = makePlayHandler({
getActivePlayback: () => ({ itemId: 'item-1', playMethod: 'DirectPlay' }),
});
await handlePlay({ ItemIds: ['item-1'] });
assert.equal(playCalls, 0);
assert.deepEqual(played, []);
});
test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', async () => {
@@ -222,6 +158,49 @@ test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', asy
assert.deepEqual(calls, ['progress:true', 'progress:true', 'stopped']);
});
test('createHandleJellyfinRemotePlaystate maps Unpause and PlayPause and reports progress', async () => {
const commands: Array<(string | number)[]> = [];
const progress: boolean[] = [];
const handlePlaystate = createHandleJellyfinRemotePlaystate({
getMpvClient: () => ({}),
sendMpvCommand: (_client, command) => commands.push(command),
reportJellyfinRemoteProgress: async (force) => {
progress.push(force);
},
reportJellyfinRemoteStopped: async () => {},
jellyfinTicksToSeconds: (ticks) => ticks / 10,
});
await handlePlaystate({ Command: 'Unpause' });
await handlePlaystate({ Command: 'PlayPause' });
assert.deepEqual(commands, [
['set_property', 'pause', 'no'],
['cycle', 'pause'],
]);
assert.deepEqual(progress, [true, true]);
});
test('createHandleJellyfinRemotePlaystate ignores Seek without a valid tick position', async () => {
const commands: Array<(string | number)[]> = [];
const progress: boolean[] = [];
const handlePlaystate = createHandleJellyfinRemotePlaystate({
getMpvClient: () => ({}),
sendMpvCommand: (_client, command) => commands.push(command),
reportJellyfinRemoteProgress: async (force) => {
progress.push(force);
},
reportJellyfinRemoteStopped: async () => {},
jellyfinTicksToSeconds: (ticks) => ticks / 10,
});
await handlePlaystate({ Command: 'Seek', SeekPositionTicks: 'soon' });
await handlePlaystate({ Command: 'Seek' });
assert.deepEqual(commands, []);
assert.deepEqual(progress, []);
});
test('createHandleJellyfinRemoteGeneralCommand mutates active playback indices', async () => {
const mpvClient = {};
const commands: Array<(string | number)[]> = [];
@@ -1,107 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler,
createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler,
createBuildWaitForMpvConnectedMainDepsHandler,
} from './jellyfin-remote-connection-main-deps';
test('wait for mpv connected main deps builder maps callbacks', async () => {
const calls: string[] = [];
const client = { connected: false, connect: () => calls.push('connect') };
const deps = createBuildWaitForMpvConnectedMainDepsHandler({
getMpvClient: () => client,
now: () => 123,
sleep: async () => {
calls.push('sleep');
},
})();
assert.equal(deps.getMpvClient(), client);
assert.equal(deps.now(), 123);
await deps.sleep(10);
assert.deepEqual(calls, ['sleep']);
});
test('launch mpv for jellyfin main deps builder maps callbacks', () => {
const calls: string[] = [];
const proc = {
on: () => {},
unref: () => {
calls.push('unref');
},
};
const deps = createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler({
getMpvExecutablePath: () => '/usr/local/bin/mpv',
getSocketPath: () => '/tmp/mpv.sock',
getLaunchMode: () => 'fullscreen',
platform: 'darwin',
execPath: '/tmp/subminer',
getRuntimePluginEntrypoint: () => '/tmp/plugin/subminer/main.lua',
getInstalledPluginDetection: () => ({
installed: false,
path: null,
version: null,
source: null,
message: null,
}),
getDefaultMpvLogPath: () => '/tmp/mpv.log',
defaultMpvArgs: ['--no-config'],
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
spawnMpv: (executablePath, args) => {
calls.push(`spawn:${executablePath} ${args.join(' ')}`);
return proc;
},
logWarn: (message) => calls.push(`warn:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
})();
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
assert.equal(deps.getLaunchMode(), 'fullscreen');
assert.equal(deps.platform, 'darwin');
assert.equal(deps.execPath, '/tmp/subminer');
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
assert.equal(deps.getMpvExecutablePath(), '/usr/local/bin/mpv');
assert.equal(deps.getInstalledPluginDetection?.('/usr/local/bin/mpv').installed, false);
assert.equal(deps.getDefaultMpvLogPath(), '/tmp/mpv.log');
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
deps.removeSocketPath('/tmp/mpv.sock');
deps.spawnMpv('/usr/local/bin/mpv', ['--idle=yes']);
deps.logInfo('launched');
deps.logWarn('bad', null);
assert.deepEqual(calls, [
'rm:/tmp/mpv.sock',
'spawn:/usr/local/bin/mpv --idle=yes',
'info:launched',
'warn:bad',
]);
});
test('ensure mpv connected for jellyfin main deps builder maps callbacks', async () => {
const calls: string[] = [];
const client = { connected: true, connect: () => {} };
const waitPromise = Promise.resolve(true);
const inFlight = Promise.resolve(false);
const deps = createBuildEnsureMpvConnectedForJellyfinPlaybackMainDepsHandler({
getMpvClient: () => client,
setMpvClient: () => calls.push('set-client'),
createMpvClient: () => client,
waitForMpvConnected: () => waitPromise,
launchMpvIdleForJellyfinPlayback: () => calls.push('launch'),
getAutoLaunchInFlight: () => inFlight,
setAutoLaunchInFlight: () => calls.push('set-in-flight'),
connectTimeoutMs: 7000,
autoLaunchTimeoutMs: 15000,
})();
assert.equal(deps.getMpvClient(), client);
deps.setMpvClient(client);
assert.equal(deps.createMpvClient(), client);
assert.equal(await deps.waitForMpvConnected(1), true);
deps.launchMpvIdleForJellyfinPlayback();
assert.equal(deps.getAutoLaunchInFlight(), inFlight);
deps.setAutoLaunchInFlight(null);
assert.equal(deps.connectTimeoutMs, 7000);
assert.equal(deps.autoLaunchTimeoutMs, 15000);
assert.deepEqual(calls, ['set-client', 'launch', 'set-in-flight']);
});
@@ -1,118 +0,0 @@
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,
getMpvClient: () => ({ id: 2, currentTimePos: 4 }) as never,
ticksPerSecond: 10_000_000,
logDebug: (message) => calls.push(`debug:${message}`),
})();
assert.deepEqual(deps.getActivePlayback(), { itemId: 'abc', playMethod: 'DirectPlay' });
deps.clearActivePlayback();
assert.equal(deps.getSession(), session);
assert.deepEqual(deps.getMpvClient(), { id: 2, currentTimePos: 4 });
assert.equal(deps.ticksPerSecond, 10_000_000);
deps.logDebug('stopped', null);
assert.deepEqual(calls, ['clear', 'debug:stopped']);
});
+230 -317
View File
@@ -8,6 +8,56 @@ import {
secondsToJellyfinTicks,
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
} from './jellyfin-remote-playback';
import type { ActiveJellyfinRemotePlaybackState } from './jellyfin-remote-commands';
type ProgressDeps = Parameters<typeof createReportJellyfinRemoteProgressHandler>[0];
type StoppedDeps = Parameters<typeof createReportJellyfinRemoteStoppedHandler>[0];
type Session = NonNullable<ReturnType<ProgressDeps['getSession']>>;
const TICKS_PER_SECOND = 10_000_000;
const makePlayback = (
overrides: Partial<ActiveJellyfinRemotePlaybackState> = {},
): ActiveJellyfinRemotePlaybackState => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
...overrides,
});
const makeSession = (overrides: Partial<Session> = {}): Session => ({
isConnected: () => true,
reportProgress: async () => {},
reportStopped: async () => {},
...overrides,
});
function makeProgressDeps(overrides: Partial<ProgressDeps> = {}): ProgressDeps {
return {
getActivePlayback: () => makePlayback(),
clearActivePlayback: () => {},
getSession: () => makeSession(),
getMpvClient: () => ({ requestProperty: async () => 1 }),
getNow: () => 5000,
getLastProgressAtMs: () => 0,
setLastProgressAtMs: () => {},
progressIntervalMs: 3000,
ticksPerSecond: TICKS_PER_SECOND,
logDebug: () => {},
...overrides,
};
}
function makeStoppedDeps(overrides: Partial<StoppedDeps> = {}): StoppedDeps {
return {
getActivePlayback: () => makePlayback({ loadedMediaPath: 'https://stream.example/video.m3u8' }),
clearActivePlayback: () => {},
getSession: () => makeSession(),
getMpvClient: () => ({ currentTimePos: 0 }),
ticksPerSecond: TICKS_PER_SECOND,
logDebug: () => {},
...overrides,
};
}
test('secondsToJellyfinTicks converts seconds and clamps invalid values', () => {
assert.equal(secondsToJellyfinTicks(1.25, 10_000_000), 12_500_000);
@@ -52,38 +102,26 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
let lastProgressAtMs = 0;
const reportPayloads: Array<{ itemId: string; positionTicks: number; isPaused: boolean }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => ({
itemId: 'item-1',
mediaSourceId: undefined,
playMethod: 'DirectPlay',
audioStreamIndex: 1,
subtitleStreamIndex: 2,
}),
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => true,
reportProgress: async (payload) => {
reportPayloads.push({
itemId: payload.itemId,
positionTicks: payload.positionTicks,
isPaused: payload.isPaused,
});
const reportProgress = createReportJellyfinRemoteProgressHandler(
makeProgressDeps({
getSession: () =>
makeSession({
reportProgress: async (payload) => {
reportPayloads.push({
itemId: payload.itemId,
positionTicks: payload.positionTicks,
isPaused: payload.isPaused,
});
},
}),
getMpvClient: () => ({
requestProperty: async (name: string) => (name === 'time-pos' ? 2.5 : true),
}),
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
reportStopped: async () => {},
}),
getMpvClient: () => ({
requestProperty: async (name: string) => (name === 'time-pos' ? 2.5 : true),
}),
getNow: () => 5000,
getLastProgressAtMs: () => lastProgressAtMs,
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
);
await reportProgress(true);
@@ -100,33 +138,24 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
test('createReportJellyfinRemoteProgressHandler reports while remote websocket is disconnected', async () => {
const reportPayloads: Array<{ positionTicks: number; isPaused: boolean }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
const reportProgress = createReportJellyfinRemoteProgressHandler(
makeProgressDeps({
getSession: () =>
makeSession({
isConnected: () => false,
reportProgress: async (payload) => {
reportPayloads.push({
positionTicks: payload.positionTicks,
isPaused: payload.isPaused,
});
},
}),
getMpvClient: () => ({
currentTimePos: 42,
requestProperty: async (name: string) => (name === 'pause' ? false : 42),
}),
}),
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => false,
reportProgress: async (payload) => {
reportPayloads.push({
positionTicks: payload.positionTicks,
isPaused: payload.isPaused,
});
},
reportStopped: async () => {},
}),
getMpvClient: () => ({
currentTimePos: 42,
requestProperty: async (name: string) => (name === 'pause' ? false : 42),
}),
getNow: () => 5000,
getLastProgressAtMs: () => 0,
setLastProgressAtMs: () => {},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
);
await reportProgress(true);
@@ -136,29 +165,19 @@ test('createReportJellyfinRemoteProgressHandler reports while remote websocket i
test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => {
const reportPayloads: Array<{ isPaused: boolean }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
const reportProgress = createReportJellyfinRemoteProgressHandler(
makeProgressDeps({
getSession: () =>
makeSession({
reportProgress: async (payload) => {
reportPayloads.push({ isPaused: payload.isPaused });
},
}),
getMpvClient: () => ({
requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3),
}),
}),
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => true,
reportProgress: async (payload) => {
reportPayloads.push({ isPaused: payload.isPaused });
},
reportStopped: async () => {},
}),
getMpvClient: () => ({
requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3),
}),
getNow: () => 5000,
getLastProgressAtMs: () => 0,
setLastProgressAtMs: () => {},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
);
await reportProgress(true);
@@ -167,29 +186,18 @@ test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', a
test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => {
let called = false;
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
const reportProgress = createReportJellyfinRemoteProgressHandler(
makeProgressDeps({
getSession: () =>
makeSession({
reportProgress: async () => {
called = true;
},
}),
getNow: () => 4000,
getLastProgressAtMs: () => 3500,
}),
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => true,
reportProgress: async () => {
called = true;
},
reportStopped: async () => {},
}),
getMpvClient: () => ({
requestProperty: async () => 1,
}),
getNow: () => 4000,
getLastProgressAtMs: () => 3500,
setLastProgressAtMs: () => {},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
);
await reportProgress(false);
assert.equal(called, false);
@@ -201,35 +209,28 @@ test('createReportJellyfinRemoteProgressHandler reports mpv seek jumps during de
let position = 10;
const reportPayloads: Array<{ positionTicks: number; eventName: string }> = [];
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => ({
itemId: 'item-1',
playMethod: 'DirectPlay',
}),
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => true,
reportProgress: async (payload) => {
reportPayloads.push({
positionTicks: payload.positionTicks,
eventName: payload.eventName,
});
const reportProgress = createReportJellyfinRemoteProgressHandler(
makeProgressDeps({
getSession: () =>
makeSession({
reportProgress: async (payload) => {
reportPayloads.push({
positionTicks: payload.positionTicks,
eventName: payload.eventName,
});
},
}),
getMpvClient: () => ({
currentTimePos: position,
requestProperty: async (name: string) => (name === 'pause' ? false : position),
}),
getNow: () => now,
getLastProgressAtMs: () => lastProgressAtMs,
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
reportStopped: async () => {},
}),
getMpvClient: () => ({
currentTimePos: position,
requestProperty: async (name: string) => (name === 'pause' ? false : position),
}),
getNow: () => now,
getLastProgressAtMs: () => lastProgressAtMs,
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
);
await reportProgress(true);
now = 5500;
@@ -250,37 +251,30 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
positionTicks?: number;
failed?: boolean;
} | null = null;
const reportStopped = createReportJellyfinRemoteStoppedHandler({
getActivePlayback: () => ({
itemId: 'item-2',
mediaSourceId: undefined,
playMethod: 'Transcode',
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
clearActivePlayback: () => {
cleared = true;
},
getSession: () => ({
isConnected: () => true,
reportProgress: async () => {},
reportStopped: async (payload) => {
stoppedPayload = {
itemId: payload.itemId,
positionTicks: payload.positionTicks,
failed: payload.failed,
};
const reportStopped = createReportJellyfinRemoteStoppedHandler(
makeStoppedDeps({
getActivePlayback: () => makePlayback({ itemId: 'item-2', playMethod: 'Transcode' }),
clearActivePlayback: () => {
cleared = true;
},
getSession: () =>
makeSession({
reportStopped: async (payload) => {
stoppedPayload = {
itemId: payload.itemId,
positionTicks: payload.positionTicks,
failed: payload.failed,
};
},
}),
getMpvClient: () => ({
currentTimePos: 12.5,
requestProperty: async () => {
throw new Error('unloaded');
},
}),
}),
getMpvClient: () => ({
currentTimePos: 12.5,
requestProperty: async () => {
throw new Error('unloaded');
},
}),
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
);
await reportStopped();
assert.deepEqual(stoppedPayload, {
@@ -293,29 +287,21 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
test('createReportJellyfinRemoteStoppedHandler clears aborted playback that never loaded', async () => {
let cleared = false;
const reportStopped = createReportJellyfinRemoteStoppedHandler({
getActivePlayback: () => ({
itemId: 'item-2',
mediaSourceId: undefined,
playMethod: 'Transcode',
audioStreamIndex: null,
subtitleStreamIndex: null,
loadedMediaPath: null,
}),
clearActivePlayback: () => {
cleared = true;
},
getSession: () => ({
isConnected: () => true,
reportProgress: async () => {},
reportStopped: async () => {
throw new Error('should not report stopped for unloaded media');
const reportStopped = createReportJellyfinRemoteStoppedHandler(
makeStoppedDeps({
getActivePlayback: () => makePlayback({ loadedMediaPath: null }),
clearActivePlayback: () => {
cleared = true;
},
getSession: () =>
makeSession({
reportStopped: async () => {
throw new Error('should not report stopped for unloaded media');
},
}),
getMpvClient: () => null,
}),
getMpvClient: () => null,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
);
await reportStopped();
@@ -329,40 +315,30 @@ test('createReportJellyfinRemoteStoppedHandler reports stop while remote websock
positionTicks?: number;
failed?: boolean;
} | null = null;
const reportStopped = createReportJellyfinRemoteStoppedHandler({
getActivePlayback: () => ({
itemId: 'item-2',
mediaSourceId: undefined,
playMethod: 'Transcode',
audioStreamIndex: null,
subtitleStreamIndex: null,
loadedMediaPath: 'https://stream.example/video.m3u8',
}),
clearActivePlayback: () => {
cleared = true;
},
getSession: () => ({
isConnected: () => false,
reportProgress: async () => {},
reportStopped: async (payload) => {
stoppedPayload = {
itemId: payload.itemId,
positionTicks: payload.positionTicks,
failed: payload.failed,
};
const reportStopped = createReportJellyfinRemoteStoppedHandler(
makeStoppedDeps({
clearActivePlayback: () => {
cleared = true;
},
getSession: () =>
makeSession({
isConnected: () => false,
reportStopped: async (payload) => {
stoppedPayload = {
itemId: payload.itemId,
positionTicks: payload.positionTicks,
failed: payload.failed,
};
},
}),
getMpvClient: () => ({ currentTimePos: 12.5 }),
}),
getMpvClient: () => ({
currentTimePos: 12.5,
}),
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
);
await reportStopped();
assert.deepEqual(stoppedPayload, {
itemId: 'item-2',
itemId: 'item-1',
positionTicks: 125_000_000,
failed: false,
});
@@ -372,35 +348,27 @@ test('createReportJellyfinRemoteStoppedHandler reports stop while remote websock
test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv unload reset', async () => {
let cleared = false;
const calls: Array<{ event: string; positionTicks?: number }> = [];
const reportStopped = createReportJellyfinRemoteStoppedHandler({
getActivePlayback: () =>
({
itemId: 'item-2',
mediaSourceId: undefined,
playMethod: 'DirectPlay',
audioStreamIndex: null,
subtitleStreamIndex: null,
loadedMediaPath: 'https://stream.example/video.m3u8',
lastKnownPositionSeconds: 72.25,
}) as never,
clearActivePlayback: () => {
cleared = true;
},
getSession: () => ({
isConnected: () => true,
reportProgress: async (payload) => {
calls.push({ event: 'progress', positionTicks: payload.positionTicks });
},
reportStopped: async (payload) => {
calls.push({ event: 'stopped', positionTicks: payload.positionTicks });
const reportStopped = createReportJellyfinRemoteStoppedHandler(
makeStoppedDeps({
getActivePlayback: () =>
makePlayback({
loadedMediaPath: 'https://stream.example/video.m3u8',
lastKnownPositionSeconds: 72.25,
}),
clearActivePlayback: () => {
cleared = true;
},
getSession: () =>
makeSession({
reportProgress: async (payload) => {
calls.push({ event: 'progress', positionTicks: payload.positionTicks });
},
reportStopped: async (payload) => {
calls.push({ event: 'stopped', positionTicks: payload.positionTicks });
},
}),
}),
getMpvClient: () => ({
currentTimePos: 0,
}),
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
);
await reportStopped();
@@ -411,81 +379,32 @@ test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv un
assert.equal(cleared, true);
});
test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback', async () => {
let cleared = false;
let stopped = false;
const reportStopped = createReportJellyfinRemoteStoppedHandler({
getActivePlayback: () =>
({
itemId: 'item-2',
playMethod: 'Transcode',
loadedMediaPath: null,
}) as never,
clearActivePlayback: () => {
cleared = true;
},
getSession: () => ({
isConnected: () => true,
reportProgress: async () => {},
reportStopped: async () => {
stopped = true;
},
}),
getMpvClient: () => ({
currentTimePos: 0,
}),
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportStopped();
assert.equal(stopped, false);
assert.equal(cleared, true);
});
test('createReportJellyfinRemoteProgressHandler caches last nonzero mpv position', async () => {
let position = 42;
let lastProgressAtMs = 0;
const playback = {
itemId: 'item-1',
playMethod: 'DirectPlay' as const,
};
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => playback,
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => true,
reportProgress: async () => {},
reportStopped: async () => {},
const playback = makePlayback();
const reportProgress = createReportJellyfinRemoteProgressHandler(
makeProgressDeps({
getActivePlayback: () => playback,
getMpvClient: () => ({
currentTimePos: position,
requestProperty: async (name: string) => (name === 'pause' ? false : position),
}),
}),
getMpvClient: () => ({
currentTimePos: position,
requestProperty: async (name: string) => (name === 'pause' ? false : position),
}),
getNow: () => 5000,
getLastProgressAtMs: () => lastProgressAtMs,
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
);
await reportProgress(true);
position = 0;
await reportProgress(true);
assert.equal((playback as { lastKnownPositionSeconds?: number }).lastKnownPositionSeconds, 42);
assert.equal(playback.lastKnownPositionSeconds, 42);
});
test('markJellyfinRemotePlaybackLoaded preserves the loaded marker on unload paths', () => {
const playback = {
const playback = makePlayback({
itemId: 'item-2',
playMethod: 'Transcode' as const,
playMethod: 'Transcode',
loadedMediaPath: 'https://stream.example/video.m3u8',
};
});
markJellyfinRemotePlaybackLoaded(playback, '');
markJellyfinRemotePlaybackLoaded(playback, ' ');
@@ -498,31 +417,25 @@ test('markJellyfinRemotePlaybackLoaded preserves the loaded marker on unload pat
test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before grace expires', async () => {
let cleared = false;
let stopped = false;
const reportStopped = createReportJellyfinRemoteStoppedHandler({
getActivePlayback: () =>
({
itemId: 'item-2',
playMethod: 'DirectPlay',
loadedMediaPath: 'https://stream.example/video.m3u8',
stopReportsAfterMs: 20_000,
}) as never,
clearActivePlayback: () => {
cleared = true;
},
getSession: () => ({
isConnected: () => true,
reportProgress: async () => {},
reportStopped: async () => {
stopped = true;
const reportStopped = createReportJellyfinRemoteStoppedHandler(
makeStoppedDeps({
getActivePlayback: () =>
makePlayback({
loadedMediaPath: 'https://stream.example/video.m3u8',
stopReportsAfterMs: 20_000,
}),
clearActivePlayback: () => {
cleared = true;
},
getSession: () =>
makeSession({
reportStopped: async () => {
stopped = true;
},
}),
getNow: () => 12_000,
}),
getMpvClient: () => ({
currentTimePos: 0,
}),
getNow: () => 12_000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
);
await reportStopped();
@@ -5,6 +5,10 @@ import {
createStopJellyfinRemoteSessionHandler,
} from './jellyfin-remote-session-lifecycle';
type StartDeps = Parameters<typeof createStartJellyfinRemoteSessionHandler>[0];
type ServiceOptions = Parameters<StartDeps['createRemoteSessionService']>[0];
type FakeService = ReturnType<StartDeps['createRemoteSessionService']>;
function createConfig(overrides?: Partial<Record<string, unknown>>) {
return {
enabled: true,
@@ -18,310 +22,265 @@ function createConfig(overrides?: Partial<Record<string, unknown>>) {
} as never;
}
test('start handler no-ops when jellyfin integration is disabled', async () => {
let created = false;
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () => createConfig({ enabled: false }),
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: () => {
created = true;
return {
start: () => {},
stop: () => {},
advertiseNow: async () => true,
};
},
defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: () => {},
logWarn: () => {},
});
function makeService(overrides: Partial<FakeService> = {}): FakeService {
return {
start: () => {},
stop: () => {},
advertiseNow: async () => true,
...overrides,
};
}
await startRemote();
assert.equal(created, false);
});
test('start handler no-ops when remote control is disabled', async () => {
let created = false;
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () => createConfig({ remoteControlEnabled: false }),
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: () => {
created = true;
return {
start: () => {},
stop: () => {},
advertiseNow: async () => true,
};
},
defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: () => {},
logWarn: () => {},
});
await startRemote();
assert.equal(created, false);
});
test('start handler respects auto-connect unless explicit start is requested', async () => {
let created = 0;
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () => createConfig({ remoteControlAutoConnect: false }),
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: () => {
created += 1;
return {
start: () => {},
stop: () => {},
advertiseNow: async () => true,
};
},
defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: () => {},
logWarn: () => {},
});
await startRemote();
assert.equal(created, 0);
await startRemote({ explicit: true });
assert.equal(created, 1);
});
test('start handler creates, starts, and stores session', async () => {
let storedSession: {
start: () => void;
stop: () => void;
advertiseNow: () => Promise<boolean>;
} | null = null;
let started = false;
/**
* Builds the start handler with recording fakes. `hostName` also seeds the client
* info device id, mirroring the hostname-derived identity used in production.
*/
function makeHarness(
options: {
config?: Partial<Record<string, unknown>>;
hostName?: string;
existing?: FakeService | null;
service?: Partial<FakeService>;
deps?: Partial<StartDeps>;
} = {},
) {
const hostName = options.hostName ?? 'workstation';
const created: ServiceOptions[] = [];
const infos: string[] = [];
let stateChanges = 0;
const warnings: Array<{ message: string; details?: unknown }> = [];
const state = {
current: options.existing ?? (null as FakeService | null),
stateChanges: 0,
started: 0,
};
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () => createConfig({ clientName: 'Desk' }),
getCurrentSession: () => null,
getJellyfinConfig: () => createConfig(options.config),
getCurrentSession: () => state.current,
setCurrentSession: (session) => {
storedSession = session as never;
state.current = session;
},
createRemoteSessionService: (options) => {
assert.equal(options.deviceName, 'workstation');
return {
createRemoteSessionService: (serviceOptions) => {
created.push(serviceOptions);
return makeService({
start: () => {
started = true;
state.started += 1;
},
stop: () => {},
advertiseNow: async () => true,
};
...options.service,
});
},
defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
getClientInfo: () => ({ deviceId: hostName, clientName: 'SubMiner', clientVersion: '1.0' }),
getHostName: () => hostName,
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: (message) => infos.push(message),
logWarn: () => {},
logWarn: (message, details) => warnings.push({ message, details }),
onSessionStateChanged: () => {
stateChanges += 1;
state.stateChanges += 1;
},
...options.deps,
});
return { startRemote, created, infos, warnings, state };
}
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
const GATING_CASES: Array<{
name: string;
config: Partial<Record<string, unknown>>;
explicitCreates: boolean;
}> = [
{ name: 'jellyfin integration is disabled', config: { enabled: false }, explicitCreates: false },
{
name: 'remote control is disabled',
config: { remoteControlEnabled: false },
explicitCreates: false,
},
{
name: 'auto-connect is off, unless explicit start is requested',
config: { remoteControlAutoConnect: false },
explicitCreates: true,
},
];
for (const c of GATING_CASES) {
test(`start handler no-ops when ${c.name}`, async () => {
const { startRemote, created } = makeHarness({ config: c.config });
await startRemote();
assert.equal(created.length, 0);
await startRemote({ explicit: true });
assert.equal(created.length, c.explicitCreates ? 1 : 0);
});
}
test('start handler creates, starts, and stores session', async () => {
const { startRemote, created, infos, state } = makeHarness();
await startRemote();
assert.equal(started, true);
assert.ok(storedSession);
assert.equal(stateChanges, 1);
assert.equal(created.length, 1);
assert.equal(state.started, 1);
assert.ok(state.current);
assert.equal(state.stateChanges, 1);
assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (workstation).')));
});
test('start handler uses hostname-derived client info and visible device name', async () => {
let createdOptions: {
deviceId: string;
clientName: string;
clientVersion: string;
deviceName: string;
} | null = null;
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () =>
createConfig({
// The visible device name is always the hostname; a configured name is ignored.
const DEVICE_IDENTITY_CASES: Array<{
name: string;
hostName: string;
config: Partial<Record<string, unknown>>;
}> = [
{
name: 'derives client info and device name from the hostname',
hostName: 'kyle-pc',
config: {},
},
{
name: 'ignores configured visible device name',
hostName: 'cachy',
config: { remoteControlDeviceName: 'SubMiner Cachy sudacode' },
},
];
for (const c of DEVICE_IDENTITY_CASES) {
test(`start handler ${c.name}`, async () => {
const { startRemote, created } = makeHarness({ hostName: c.hostName, config: c.config });
await startRemote({ explicit: true });
assert.equal(created.length, 1);
assert.deepEqual(
{
deviceId: created[0]?.deviceId,
clientName: created[0]?.clientName,
clientVersion: created[0]?.clientVersion,
deviceName: created[0]?.deviceName,
},
{
deviceId: c.hostName,
clientName: 'SubMiner',
}),
getClientInfo: () => ({
deviceId: 'kyle-pc',
clientName: 'SubMiner',
clientVersion: '0.1.0',
}),
getHostName: () => 'kyle-pc',
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: (options) => {
createdOptions = {
deviceId: options.deviceId,
clientName: options.clientName,
clientVersion: options.clientVersion,
deviceName: options.deviceName,
};
return {
start: () => {},
stop: () => {},
advertiseNow: async () => true,
};
},
defaultDeviceId: 'subminer',
defaultClientName: 'SubMiner',
defaultClientVersion: '0.1.0',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: () => {},
logWarn: () => {},
clientVersion: '1.0',
deviceName: c.hostName,
},
);
});
await startRemote({ explicit: true });
assert.deepEqual(createdOptions, {
deviceId: 'kyle-pc',
clientName: 'SubMiner',
clientVersion: '0.1.0',
deviceName: 'kyle-pc',
});
});
test('start handler ignores configured visible device name', async () => {
let createdDeviceName = '';
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () =>
createConfig({
remoteControlDeviceName: 'SubMiner Cachy sudacode',
}),
getClientInfo: () => ({
deviceId: 'cachy',
clientName: 'SubMiner',
clientVersion: '0.1.0',
}),
getHostName: () => 'cachy',
getCurrentSession: () => null,
setCurrentSession: () => {},
createRemoteSessionService: (options) => {
createdDeviceName = options.deviceName;
return {
start: () => {},
stop: () => {},
advertiseNow: async () => true,
};
},
defaultDeviceId: 'subminer',
defaultClientName: 'SubMiner',
defaultClientVersion: '0.1.0',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: () => {},
logWarn: () => {},
});
await startRemote({ explicit: true });
assert.equal(createdDeviceName, 'cachy');
});
}
test('start handler stops previous session before replacing', async () => {
let stopCalls = 0;
const oldSession = {
start: () => {},
stop: () => {
stopCalls += 1;
},
advertiseNow: async () => true,
};
let current: typeof oldSession | null = oldSession;
const startRemote = createStartJellyfinRemoteSessionHandler({
getJellyfinConfig: () => createConfig(),
getCurrentSession: () => current,
setCurrentSession: (session) => {
current = session as never;
},
createRemoteSessionService: () => ({
start: () => {},
stop: () => {},
advertiseNow: async () => true,
const { startRemote, state } = makeHarness({
existing: makeService({
stop: () => {
stopCalls += 1;
},
}),
defaultDeviceId: 'default-device',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0',
getClientInfo: () => ({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}),
getHostName: () => 'workstation',
handlePlay: async () => {},
handlePlaystate: async () => {},
handleGeneralCommand: async () => {},
logInfo: () => {},
logWarn: () => {},
});
await startRemote();
assert.equal(stopCalls, 1);
assert.equal(state.started, 1);
});
test('created service announces after connect when autoAnnounce is on', async () => {
const { startRemote, created, infos, warnings } = makeHarness({ config: { autoAnnounce: true } });
await startRemote();
created[0]?.onConnected();
await flushPromises();
assert.ok(infos.includes('Jellyfin cast target is visible to server sessions.'));
assert.deepEqual(warnings, []);
});
test('created service warns when announced device is not visible yet', async () => {
const { startRemote, created, infos, warnings } = makeHarness({
config: { autoAnnounce: true },
service: { advertiseNow: async () => false },
});
await startRemote();
created[0]?.onConnected();
await flushPromises();
assert.deepEqual(
warnings.map((entry) => entry.message),
['Jellyfin remote connected but device not visible in server sessions yet.'],
);
assert.ok(!infos.includes('Jellyfin cast target is visible to server sessions.'));
});
test('created service does not announce on connect when autoAnnounce is off', async () => {
let advertised = 0;
const { startRemote, created } = makeHarness({
service: {
advertiseNow: async () => {
advertised += 1;
return true;
},
},
});
await startRemote();
created[0]?.onConnected();
await flushPromises();
assert.equal(advertised, 0);
});
const EVENT_FAILURE_CASES: Array<{
event: 'Play' | 'Playstate' | 'GeneralCommand';
handler: 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand';
callback: 'onPlay' | 'onPlaystate' | 'onGeneralCommand';
}> = [
{ event: 'Play', handler: 'handlePlay', callback: 'onPlay' },
{ event: 'Playstate', handler: 'handlePlaystate', callback: 'onPlaystate' },
{ event: 'GeneralCommand', handler: 'handleGeneralCommand', callback: 'onGeneralCommand' },
];
for (const c of EVENT_FAILURE_CASES) {
test(`created service logs a warning when the ${c.event} handler rejects`, async () => {
const failure = new Error('boom');
const { startRemote, created, warnings } = makeHarness({
deps: {
[c.handler]: async () => {
throw failure;
},
},
});
await startRemote();
created[0]?.[c.callback]({});
await flushPromises();
assert.deepEqual(warnings, [
{ message: `Failed handling Jellyfin remote ${c.event} event`, details: failure },
]);
});
}
test('stop handler stops active session and clears playback', () => {
let stopCalls = 0;
let clearCalls = 0;
let stateChanges = 0;
let currentSession: { stop: () => void } | null = {
let currentSession: FakeService | null = makeService({
stop: () => {
stopCalls += 1;
},
};
});
const stopRemote = createStopJellyfinRemoteSessionHandler({
getCurrentSession: () => currentSession as never,
getCurrentSession: () => currentSession,
setCurrentSession: (session) => {
currentSession = session as never;
currentSession = session;
},
clearActivePlayback: () => {
clearCalls += 1;
@@ -1,82 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildStartJellyfinRemoteSessionMainDepsHandler,
createBuildStopJellyfinRemoteSessionMainDepsHandler,
} from './jellyfin-remote-session-main-deps';
test('start jellyfin remote session main deps builder maps callbacks', async () => {
const calls: string[] = [];
const session = { start: () => {}, stop: () => {}, advertiseNow: async () => true };
const deps = createBuildStartJellyfinRemoteSessionMainDepsHandler({
getJellyfinConfig: () => ({ serverUrl: 'http://localhost' }) as never,
getCurrentSession: () => null,
setCurrentSession: () => calls.push('set-session'),
createRemoteSessionService: () => session as never,
getClientInfo: () =>
({
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
}) as never,
getHostName: () => 'workstation',
defaultDeviceId: 'device',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0',
handlePlay: async () => {
calls.push('play');
},
handlePlaystate: async () => {
calls.push('playstate');
},
handleGeneralCommand: async () => {
calls.push('general');
},
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
onSessionStateChanged: () => calls.push('state-changed'),
})();
assert.deepEqual(deps.getJellyfinConfig(), { serverUrl: 'http://localhost' });
assert.equal(deps.defaultDeviceId, 'device');
assert.equal(deps.defaultClientName, 'SubMiner');
assert.equal(deps.defaultClientVersion, '1.0');
assert.equal(deps.getHostName(), 'workstation');
assert.deepEqual(deps.getClientInfo(), {
deviceId: 'workstation',
clientName: 'SubMiner',
clientVersion: '1.0',
});
assert.equal(deps.createRemoteSessionService({} as never), session);
await deps.handlePlay({});
await deps.handlePlaystate({});
await deps.handleGeneralCommand({});
deps.logInfo('connected');
deps.logWarn('missing');
deps.onSessionStateChanged?.();
assert.deepEqual(calls, [
'play',
'playstate',
'general',
'info:connected',
'warn:missing',
'state-changed',
]);
});
test('stop jellyfin remote session main deps builder maps callbacks', () => {
const calls: string[] = [];
const session = { start: () => {}, stop: () => {}, advertiseNow: async () => true };
const deps = createBuildStopJellyfinRemoteSessionMainDepsHandler({
getCurrentSession: () => session as never,
setCurrentSession: () => calls.push('set-null'),
clearActivePlayback: () => calls.push('clear'),
onSessionStateChanged: () => calls.push('state-changed'),
})();
assert.equal(deps.getCurrentSession(), session);
deps.setCurrentSession(null);
deps.clearActivePlayback();
deps.onSessionStateChanged?.();
assert.deepEqual(calls, ['set-null', 'clear', 'state-changed']);
});
@@ -1,129 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildOpenJellyfinSetupWindowMainDepsHandler } from './jellyfin-setup-window-main-deps';
test('open jellyfin setup window main deps builder maps callbacks', async () => {
const calls: string[] = [];
const expectedState = {
selectedServerUrl: 'a',
username: 'b',
hasStoredSession: false,
statusMessage: '',
statusKind: 'idle' as const,
};
let capturedBuildState: unknown = null;
let capturedParseUrl = '';
const deps = createBuildOpenJellyfinSetupWindowMainDepsHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => ({}) as never,
getResolvedJellyfinConfig: () => ({ serverUrl: 'http://127.0.0.1:8096', username: 'alice' }),
buildSetupFormHtml: (state) => {
capturedBuildState = state;
return '<html></html>';
},
parseSubmissionUrl: (rawUrl) => {
capturedParseUrl = rawUrl;
return { action: 'login', server: 's', username: 'u', password: 'p' };
},
authenticateWithPassword: async () => ({
serverUrl: 'http://127.0.0.1:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
}),
getJellyfinClientInfo: () => ({
clientName: 'SubMiner',
clientVersion: '1.0',
deviceId: 'dev',
}),
saveStoredSession: () => calls.push('save'),
clearStoredSession: () => calls.push('clear-session'),
patchJellyfinConfig: () => calls.push('patch'),
persistAuthenticatedSession: () => calls.push('persist'),
restartRemoteSession: () => {
calls.push('restart-remote');
},
stopRemoteSession: () => calls.push('stop-remote'),
logInfo: (message) => calls.push(`info:${message}`),
logError: (message) => calls.push(`error:${message}`),
showMpvOsd: (message) => calls.push(`osd:${message}`),
clearSetupWindow: () => calls.push('clear'),
setSetupWindow: () => calls.push('set-window'),
registerSetupIpcHandler: () => {
calls.push('register-ipc');
return () => calls.push('unregister-ipc');
},
encodeURIComponent: (value) => encodeURIComponent(value),
defaultServerUrl: 'http://127.0.0.1:8096',
hasStoredSession: () => true,
})();
assert.equal(deps.maybeFocusExistingSetupWindow(), false);
assert.deepEqual(deps.getResolvedJellyfinConfig(), {
serverUrl: 'http://127.0.0.1:8096',
username: 'alice',
});
assert.equal(deps.buildSetupFormHtml(expectedState), '<html></html>');
assert.deepEqual(capturedBuildState, expectedState);
const setupUrl = 'subminer://jellyfin-setup?x=1';
assert.deepEqual(deps.parseSubmissionUrl(setupUrl), {
action: 'login',
server: 's',
username: 'u',
password: 'p',
});
assert.equal(capturedParseUrl, setupUrl);
assert.deepEqual(
await deps.authenticateWithPassword('s', 'u', 'p', deps.getJellyfinClientInfo()),
{
serverUrl: 'http://127.0.0.1:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
},
);
deps.saveStoredSession({ accessToken: 'token', userId: 'uid' });
deps.clearStoredSession();
deps.patchJellyfinConfig({
serverUrl: 'http://127.0.0.1:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
});
deps.persistAuthenticatedSession?.(
{
serverUrl: 'http://127.0.0.1:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
},
deps.getJellyfinClientInfo(),
);
await deps.restartRemoteSession?.();
deps.stopRemoteSession?.();
deps.logInfo('ok');
deps.logError('bad', null);
deps.showMpvOsd('toast');
deps.clearSetupWindow();
deps.setSetupWindow({} as never);
const unregister = deps.registerSetupIpcHandler?.(async () => ({ handled: true }));
unregister?.();
assert.equal(deps.encodeURIComponent('a b'), 'a%20b');
assert.equal(deps.defaultServerUrl, 'http://127.0.0.1:8096');
assert.equal(deps.hasStoredSession(), true);
assert.deepEqual(calls, [
'save',
'clear-session',
'patch',
'persist',
'restart-remote',
'stop-remote',
'info:ok',
'error:bad',
'osd:toast',
'clear',
'set-window',
'register-ipc',
'unregister-ipc',
]);
});
+69 -166
View File
@@ -4,10 +4,8 @@ import {
buildJellyfinSetupSubmissionUrl,
buildJellyfinSetupFormHtml,
buildJellyfinSetupViewState,
createHandleJellyfinSetupWindowClosedHandler,
createHandleJellyfinSetupNavigationHandler,
createHandleJellyfinSetupSubmissionHandler,
createHandleJellyfinSetupWindowOpenedHandler,
createMaybeFocusExistingJellyfinSetupWindowHandler,
createOpenJellyfinSetupWindowHandler,
normalizeJellyfinSetupIpcSubmission,
@@ -26,8 +24,6 @@ test('buildJellyfinSetupFormHtml escapes default values', () => {
assert.ok(html.includes('user&quot;name'));
assert.ok(html.includes('Ready &quot;now&quot;'));
assert.ok(html.includes('Logout'));
assert.equal(html.includes('Server presets'), false);
assert.equal(html.includes('serverSelect'), false);
assert.ok(html.includes('window.subminerJellyfinSetup'));
assert.ok(html.includes('Logging in to Jellyfin'));
assert.ok(html.includes('subminer://jellyfin-setup?'));
@@ -398,101 +394,47 @@ test('createHandleJellyfinSetupNavigationHandler intercepts setup urls', async (
assert.equal(submittedUrls.length, 1);
});
test('createHandleJellyfinSetupWindowClosedHandler clears setup window ref', () => {
let cleared = false;
const handler = createHandleJellyfinSetupWindowClosedHandler({
clearSetupWindow: () => {
cleared = true;
},
});
handler();
assert.equal(cleared, true);
});
type NavigateHandler = (event: { preventDefault: () => void }, url: string) => void;
test('createHandleJellyfinSetupWindowOpenedHandler sets setup window ref', () => {
let set = false;
const handler = createHandleJellyfinSetupWindowOpenedHandler({
setSetupWindow: () => {
set = true;
},
});
handler();
assert.equal(set, true);
});
test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is focused', () => {
const calls: string[] = [];
const handler = createOpenJellyfinSetupWindowHandler({
maybeFocusExistingSetupWindow: () => {
calls.push('focus-existing');
return true;
},
createSetupWindow: () => {
calls.push('create-window');
throw new Error('should not create');
},
getResolvedJellyfinConfig: () => ({}),
buildSetupFormHtml: () => '<html></html>',
parseSubmissionUrl: () => null,
authenticateWithPassword: async () => {
throw new Error('should not auth');
},
getJellyfinClientInfo: () => ({
clientName: 'SubMiner',
clientVersion: '1.0',
deviceId: 'did',
}),
saveStoredSession: () => {},
patchJellyfinConfig: () => {},
logInfo: () => {},
logError: () => {},
showMpvOsd: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {},
clearStoredSession: () => {},
encodeURIComponent: (value) => value,
defaultServerUrl: 'http://127.0.0.1:8096',
hasStoredSession: () => false,
});
handler();
assert.deepEqual(calls, ['focus-existing']);
});
test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window lifecycle', async () => {
let willNavigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null =
null;
let closedHandler: (() => void) | null = null;
let prevented = false;
const calls: string[] = [];
const fakeWindow = {
function makeFakeWindow(calls: string[], options: { pagePassword?: string } = {}) {
const hooks: { willNavigate?: NavigateHandler; closed?: () => void } = {};
const window = {
focus: () => {},
webContents: {
on: (
event: 'will-navigate',
handler: (event: { preventDefault: () => void }, url: string) => void,
) => {
if (event === 'will-navigate') {
willNavigateHandler = handler;
on: (event: 'will-navigate', handler: NavigateHandler) => {
if (event === 'will-navigate') hooks.willNavigate = handler;
},
executeJavaScript: async () => {
if (options.pagePassword === undefined) {
throw new Error('bridge path should not read from page');
}
return options.pagePassword;
},
executeJavaScript: async () => 'pass',
},
loadURL: (url: string) => {
calls.push(`load:${url.startsWith('data:text/html;charset=utf-8,') ? 'data-url' : 'other'}`);
},
on: (event: 'closed', handler: () => void) => {
if (event === 'closed') {
closedHandler = handler;
}
if (event === 'closed') hooks.closed = handler;
},
isDestroyed: () => false,
close: () => calls.push('close'),
};
return { window, hooks };
}
const handler = createOpenJellyfinSetupWindowHandler({
type FakeWindow = ReturnType<typeof makeFakeWindow>['window'];
type OpenDeps = Parameters<typeof createOpenJellyfinSetupWindowHandler<FakeWindow>>[0];
/** Open-handler deps that record every side effect into `calls`; override per test. */
function makeOpenDeps(
calls: string[],
window: FakeWindow,
overrides: Partial<OpenDeps> = {},
): OpenDeps {
return {
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => fakeWindow,
createSetupWindow: () => window,
getResolvedJellyfinConfig: () => ({
serverUrl: 'http://localhost:8096',
username: 'alice',
@@ -524,21 +466,43 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
setSetupWindow: () => calls.push('set-window'),
encodeURIComponent: (value) => encodeURIComponent(value),
defaultServerUrl: 'http://127.0.0.1:8096',
hasStoredSession: () => true,
});
hasStoredSession: () => false,
...overrides,
};
}
test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is focused', () => {
const calls: string[] = [];
const { window } = makeFakeWindow(calls);
const handler = createOpenJellyfinSetupWindowHandler(
makeOpenDeps(calls, window, {
maybeFocusExistingSetupWindow: () => {
calls.push('focus-existing');
return true;
},
createSetupWindow: () => {
calls.push('create-window');
throw new Error('should not create');
},
}),
);
handler();
assert.ok(willNavigateHandler);
assert.ok(closedHandler);
assert.deepEqual(calls.slice(0, 2), ['load:data-url', 'set-window']);
assert.deepEqual(calls, ['focus-existing']);
});
const navHandler = willNavigateHandler as
| ((event: { preventDefault: () => void }, url: string) => void)
| null;
if (!navHandler) {
throw new Error('missing will-navigate handler');
}
navHandler(
test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window lifecycle', async () => {
const calls: string[] = [];
let prevented = false;
const { window, hooks } = makeFakeWindow(calls, { pagePassword: 'pass' });
const handler = createOpenJellyfinSetupWindowHandler(makeOpenDeps(calls, window));
handler();
assert.deepEqual(calls.slice(0, 2), ['load:data-url', 'set-window']);
assert.ok(hooks.willNavigate);
assert.ok(hooks.closed);
hooks.willNavigate?.(
{
preventDefault: () => {
prevented = true;
@@ -553,81 +517,24 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
assert.ok(calls.includes('save'));
assert.ok(calls.includes('patch'));
assert.ok(calls.includes('osd:Jellyfin login success'));
assert.ok(calls.includes('load:data-url'));
const onClosed = closedHandler as (() => void) | null;
if (!onClosed) {
throw new Error('missing closed handler');
}
onClosed();
hooks.closed?.();
assert.ok(calls.includes('clear-window'));
});
test('createOpenJellyfinSetupWindowHandler handles ipc bridge submissions', async () => {
const bridge: { handler?: (payload: unknown) => Promise<{ handled: boolean }> } = {};
let closedHandler: (() => void) | null = null;
const calls: string[] = [];
const fakeWindow = {
focus: () => {},
webContents: {
on: () => {},
executeJavaScript: async () => {
throw new Error('bridge path should not read from page');
const { window, hooks } = makeFakeWindow(calls);
const handler = createOpenJellyfinSetupWindowHandler(
makeOpenDeps(calls, window, {
registerSetupIpcHandler: (nextHandler) => {
bridge.handler = nextHandler;
calls.push('register-ipc');
return () => calls.push('unregister-ipc');
},
},
loadURL: () => {
calls.push('load');
},
on: (event: 'closed', handler: () => void) => {
if (event === 'closed') {
closedHandler = handler;
}
},
isDestroyed: () => false,
close: () => calls.push('close'),
};
const handler = createOpenJellyfinSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => fakeWindow,
getResolvedJellyfinConfig: () => ({
serverUrl: 'http://localhost:8096',
username: 'alice',
recentServers: [],
}),
buildSetupFormHtml: () => '<html></html>',
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
authenticateWithPassword: async (_server, _username, password) => {
calls.push(`password:${password}`);
return {
serverUrl: 'http://localhost:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
};
},
getJellyfinClientInfo: () => ({
clientName: 'SubMiner',
clientVersion: '1.0',
deviceId: 'did',
}),
saveStoredSession: () => calls.push('save'),
clearStoredSession: () => calls.push('clear'),
patchJellyfinConfig: () => calls.push('patch'),
logInfo: () => calls.push('info'),
logError: () => calls.push('error'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
clearSetupWindow: () => calls.push('clear-window'),
setSetupWindow: () => calls.push('set-window'),
registerSetupIpcHandler: (nextHandler) => {
bridge.handler = nextHandler;
calls.push('register-ipc');
return () => calls.push('unregister-ipc');
},
encodeURIComponent: (value) => encodeURIComponent(value),
defaultServerUrl: 'http://127.0.0.1:8096',
hasStoredSession: () => false,
});
);
handler();
const bridgeHandler = bridge.handler;
@@ -655,11 +562,7 @@ test('createOpenJellyfinSetupWindowHandler handles ipc bridge submissions', asyn
assert.ok(calls.includes('save'));
assert.ok(calls.includes('patch'));
const onClosed = closedHandler as (() => void) | null;
if (!onClosed) {
throw new Error('missing closed handler');
}
onClosed();
hooks.closed?.();
assert.ok(calls.includes('unregister-ipc'));
assert.ok(calls.includes('clear-window'));
});
@@ -1,33 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler } from './jellyfin-subtitle-preload-main-deps';
test('preload jellyfin external subtitles main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler({
listJellyfinSubtitleTracks: async () => {
calls.push('list');
return [];
},
getMpvClient: () => ({ requestProperty: async () => [] }),
sendMpvCommand: () => calls.push('send'),
wait: async () => {
calls.push('wait');
},
cacheSubtitleTrack: async () => {
calls.push('cache');
return { path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' };
},
cleanupCachedSubtitles: () => calls.push('cleanup'),
logDebug: (message) => calls.push(`debug:${message}`),
})();
await deps.listJellyfinSubtitleTracks({} as never, {} as never, 'item');
assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function');
deps.sendMpvCommand(['set_property', 'sid', 'auto']);
await deps.wait(1);
await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' });
deps.cleanupCachedSubtitles(['/tmp/subs']);
deps.logDebug('oops', null);
assert.deepEqual(calls, ['list', 'send', 'wait', 'cache', 'cleanup', 'debug:oops']);
});
@@ -4,6 +4,7 @@ import {
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
updateLinuxMpvFullscreenOverlayRefreshBurst,
scheduleLinuxVisibleOverlayFullscreenRefreshBurst,
type LinuxMpvFullscreenOverlayRefreshDeps,
} from './linux-mpv-fullscreen-overlay-refresh';
const compositorEnvKeys = [
@@ -23,309 +24,142 @@ afterEach(() => {
}
});
for (const { compositorKey, expectedRefreshCalls } of [
// The subject schedules with the global setTimeout (0/50/150/300/600 ms), so these tests run
// on real timers and poll for the expected calls instead of sleeping a fixed time.
async function waitFor(predicate: () => boolean, timeoutMs = 1_500): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate() && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.ok(predicate(), 'timed out waiting for overlay refresh calls');
}
/** The subject only refreshes on linux; clears pending burst timers afterwards. */
async function withLinuxPlatform(run: () => Promise<void>): Promise<void> {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
try {
await run();
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
}
function makeDeps(
calls: string[],
overrides: Partial<LinuxMpvFullscreenOverlayRefreshDeps> = {},
options: { windowVisible?: boolean } = {},
): LinuxMpvFullscreenOverlayRefreshDeps {
const window = {
hide: () => calls.push('hide'),
showInactive: () => calls.push('showInactive'),
isDestroyed: () => false,
isVisible: () => options.windowVisible ?? true,
setIgnoreMouseEvents: (ignore: boolean, mouseOptions?: { forward?: boolean }) =>
calls.push(`mouse:${ignore}:${mouseOptions?.forward === true ? 'forward' : 'plain'}`),
};
return {
overlayManager: {
getMainWindow: () => window,
getVisibleOverlayVisible: () => true,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('visibility'),
},
syncVisibleOverlayMpvFullscreenMode: (fullscreen) => calls.push(`mode:${fullscreen}`),
ensureOverlayWindowLevel: () => calls.push('restack'),
...overrides,
};
}
const count = (calls: string[], entry: string): number =>
calls.filter((call) => call === entry).length;
const REFRESH_TICKS = 5;
for (const { compositorKey, expectedTickCalls } of [
{
// Hyprland restacks in place; hide/show would let it cancel the fullscreen transition.
compositorKey: 'HYPRLAND_INSTANCE_SIGNATURE',
expectedRefreshCalls: ['mode', 'visibility', 'mouse', 'restack'],
expectedTickCalls: ['mode:true', 'visibility', 'mouse:true:forward', 'restack'],
},
{
compositorKey: 'SWAYSOCK',
expectedRefreshCalls: ['mode', 'visibility', 'hide', 'showInactive', 'mouse', 'restack'],
expectedTickCalls: [
'mode:true',
'visibility',
'hide',
'showInactive',
'mouse:true:forward',
'restack',
],
},
]) {
test(`${compositorKey} fullscreen refresh uses compositor-specific restacking`, async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
process.env[compositorKey] = 'fullscreen-refresh-test';
const calls: string[] = [];
try {
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
overlayManager: {
getMainWindow: () => ({
hide: () => calls.push('hide'),
showInactive: () => calls.push('showInactive'),
isDestroyed: () => false,
isVisible: () => true,
setIgnoreMouseEvents: () => calls.push('mouse'),
}),
getVisibleOverlayVisible: () => true,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('visibility'),
},
syncVisibleOverlayMpvFullscreenMode: () => calls.push('mode'),
ensureOverlayWindowLevel: () => calls.push('restack'),
});
await new Promise((resolve) => setTimeout(resolve, 700));
assert.deepEqual(calls, Array.from({ length: 5 }, () => expectedRefreshCalls).flat());
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
await withLinuxPlatform(async () => {
process.env[compositorKey] = 'fullscreen-refresh-test';
const calls: string[] = [];
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, makeDeps(calls));
await waitFor(() => count(calls, 'restack') === REFRESH_TICKS);
// Click-through is restored after the remap and before the window level is reasserted.
assert.deepEqual(
calls,
Array.from({ length: REFRESH_TICKS }, () => expectedTickCalls).flat(),
);
});
});
}
test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work on linux', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
const calls: string[] = [];
try {
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
overlayManager: {
getMainWindow: () =>
({
hide: () => calls.push('hide'),
isFullScreen: () => false,
isDestroyed: () => false,
isVisible: () => true,
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
calls.push(
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
),
showInactive: () => calls.push('showInactive'),
}) as never,
getVisibleOverlayVisible: () => true,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
},
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
calls.push(`sync-overlay-mode:${fullscreen}`),
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
});
const deadline = Date.now() + 200;
while (!calls.includes('updateVisibleOverlayVisibility') && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.ok(calls.includes('updateVisibleOverlayVisibility'));
assert.ok(calls.includes('sync-overlay-mode:true'));
assert.ok(!calls.includes('fullscreen:true'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('showInactive'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensureOverlayWindowLevel'));
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('linux mpv fullscreen overlay refresh remembers mode even when overlay is hidden', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
await withLinuxPlatform(async () => {
const calls: string[] = [];
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
true,
makeDeps(calls, {
overlayManager: {
getMainWindow: () => null,
getVisibleOverlayVisible: () => false,
},
}),
);
await waitFor(() => count(calls, 'mode:true') >= 2);
assert.ok(calls.every((call) => call === 'mode:true'));
});
const calls: string[] = [];
try {
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
overlayManager: {
getMainWindow: () => null,
getVisibleOverlayVisible: () => false,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
},
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
calls.push(`sync-overlay-mode:${fullscreen}`),
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
});
const deadline = Date.now() + 200;
while (!calls.includes('sync-overlay-mode:true') && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.ok(calls.includes('sync-overlay-mode:true'));
assert.ok(!calls.includes('updateVisibleOverlayVisibility'));
assert.ok(!calls.includes('ensureOverlayWindowLevel'));
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('linux mpv fullscreen overlay refresh updates mode without hide/show when fullscreen exits', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
const calls: string[] = [];
try {
const deps = {
overlayManager: {
getMainWindow: () =>
({
hide: () => calls.push('hide'),
isFullScreen: () => true,
isDestroyed: () => false,
isVisible: () => true,
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
calls.push(
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
),
showInactive: () => calls.push('showInactive'),
}) as never,
getVisibleOverlayVisible: () => true,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
},
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
calls.push(`sync-overlay-mode:${fullscreen}`),
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
};
await withLinuxPlatform(async () => {
const calls: string[] = [];
const deps = makeDeps(calls);
// Leaving fullscreen cancels the pending fullscreen-enter burst.
const cancel = updateLinuxMpvFullscreenOverlayRefreshBurst(true, deps, null);
const nextCancel = updateLinuxMpvFullscreenOverlayRefreshBurst(false, deps, cancel);
updateLinuxMpvFullscreenOverlayRefreshBurst(false, deps, cancel);
await new Promise((resolve) => setTimeout(resolve, 80));
await waitFor(() => count(calls, 'mode:false') >= 2);
assert.equal(typeof nextCancel, 'function');
assert.ok(calls.includes('updateVisibleOverlayVisibility'));
assert.ok(calls.includes('sync-overlay-mode:false'));
assert.ok(!calls.includes('fullscreen:false'));
assert.equal(calls.includes('hide'), false);
assert.equal(calls.includes('showInactive'), false);
assert.equal(calls.includes('mouse-ignore:true:forward'), false);
assert.equal(calls.includes('ensureOverlayWindowLevel'), false);
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('linux mpv fullscreen overlay refresh restores click-through after restacking', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
assert.ok(calls.every((call) => call === 'mode:false' || call === 'visibility'));
});
const calls: string[] = [];
try {
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
overlayManager: {
getMainWindow: () =>
({
hide: () => calls.push('hide'),
isFullScreen: () => false,
isDestroyed: () => false,
isVisible: () => true,
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
calls.push(
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
),
showInactive: () => calls.push('showInactive'),
}) as never,
getVisibleOverlayVisible: () => true,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
},
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
calls.push(`sync-overlay-mode:${fullscreen}`),
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
});
const deadline = Date.now() + 200;
while (!calls.includes('mouse-ignore:true:forward') && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
const showIndex = calls.indexOf('showInactive');
const passthroughIndex = calls.indexOf('mouse-ignore:true:forward');
const levelIndex = calls.indexOf('ensureOverlayWindowLevel');
const syncIndex = calls.indexOf('sync-overlay-mode:true');
assert.ok(syncIndex >= 0);
assert.ok(showIndex >= 0);
assert.ok(syncIndex < showIndex);
assert.ok(passthroughIndex > showIndex);
assert.ok(levelIndex > passthroughIndex);
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('linux mpv fullscreen overlay refresh preserves active subtitle interaction after restacking', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
await withLinuxPlatform(async () => {
const calls: string[] = [];
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
true,
makeDeps(calls, { getOverlayInteractionActive: () => true }),
);
await waitFor(() => count(calls, 'restack') >= 1);
assert.ok(calls.indexOf('mouse:false:plain') > calls.indexOf('showInactive'));
assert.equal(calls.includes('mouse:true:forward'), false);
});
const calls: string[] = [];
try {
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
overlayManager: {
getMainWindow: () =>
({
hide: () => calls.push('hide'),
isFullScreen: () => false,
isDestroyed: () => false,
isVisible: () => true,
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
calls.push(
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
),
showInactive: () => calls.push('showInactive'),
}) as never,
getVisibleOverlayVisible: () => true,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
},
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
calls.push(`sync-overlay-mode:${fullscreen}`),
getOverlayInteractionActive: () => true,
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
});
const deadline = Date.now() + 200;
while (!calls.includes('mouse-ignore:false:plain') && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
const showIndex = calls.indexOf('showInactive');
const interactiveIndex = calls.indexOf('mouse-ignore:false:plain');
assert.ok(showIndex >= 0);
assert.ok(interactiveIndex > showIndex);
assert.equal(calls.includes('mouse-ignore:true:forward'), false);
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
-68
View File
@@ -3,7 +3,6 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { writeStoredZip } from '../../shared/stored-zip';
import { exportLogsArchive, maskUsernamesInLogText } from './log-export';
function makeTempDir(): string {
@@ -81,26 +80,6 @@ test('maskUsernamesInLogText redacts linux macOS and Windows home paths', () =>
assert.doesNotMatch(masked, /kyle/);
});
test('maskUsernamesInLogText redacts IP addresses and emails', () => {
const masked = maskUsernamesInLogText(
[
'ffmpeg failed after request from public ip 203.0.113.42',
'connect tcp 192.168.1.25:443: i/o timeout',
'remote addr [2001:db8::1234]:443',
'support email kyle@example.test',
].join('\n'),
);
assert.match(masked, /public ip <ip>/);
assert.match(masked, /tcp <ip>:443/);
assert.match(masked, /remote addr \[<ip>\]:443/);
assert.match(masked, /support email <email>/);
assert.doesNotMatch(masked, /203\.0\.113\.42/);
assert.doesNotMatch(masked, /192\.168\.1\.25/);
assert.doesNotMatch(masked, /2001:db8::1234/);
assert.doesNotMatch(masked, /kyle@example\.test/);
});
test('maskUsernamesInLogText redacts headers and yt-dlp cookie arguments', () => {
const masked = maskUsernamesInLogText(
[
@@ -129,32 +108,6 @@ test('maskUsernamesInLogText redacts headers and yt-dlp cookie arguments', () =>
assert.doesNotMatch(masked, /firefox:default/);
});
test('maskUsernamesInLogText redacts URL credentials and sensitive query values', () => {
const masked = maskUsernamesInLogText(
[
'GET https://alice:secret@example.test/watch?v=abc&access_token=tok123&api_key=key456',
'stream https://video.example.test/file.m3u8?signature=sig789&expire=1777777777',
'callback subminer://anilist-setup?access_token=ani-token&state=ok',
'json {"password":"hunter2","refreshToken":"refresh-token","client_secret":"client-secret"}',
].join('\n'),
);
assert.match(masked, /https:\/\/<credentials>@example\.test/);
assert.match(masked, /access_token=<redacted>/);
assert.match(masked, /api_key=<redacted>/);
assert.match(masked, /signature=<redacted>/);
assert.match(masked, /"password":"<redacted>"/);
assert.match(masked, /"refreshToken":"<redacted>"/);
assert.match(masked, /"client_secret":"<redacted>"/);
assert.doesNotMatch(masked, /alice:secret/);
assert.doesNotMatch(masked, /tok123/);
assert.doesNotMatch(masked, /key456/);
assert.doesNotMatch(masked, /sig789/);
assert.doesNotMatch(masked, /ani-token/);
assert.doesNotMatch(masked, /hunter2/);
assert.match(masked, /state=ok/);
});
test('exportLogsArchive exports current-day logs and masks usernames', () => {
const root = makeTempDir();
const logsDir = path.join(root, 'logs');
@@ -189,27 +142,6 @@ test('exportLogsArchive exports current-day logs and masks usernames', () => {
}
});
test('writeStoredZip rejects names outside ZIP32 limits', () => {
const dir = makeTempDir();
const outputPath = path.join(dir, 'logs.zip');
try {
assert.throws(
() =>
writeStoredZip(outputPath, [
{
name: `${'a'.repeat(0x10000)}.log`,
data: Buffer.from('log\n', 'utf8'),
},
]),
/ZIP entry name too long/,
);
assert.equal(fs.existsSync(outputPath), false);
} finally {
cleanupDir(dir);
}
});
test('exportLogsArchive ignores older dated logs when current-day dated logs exist', () => {
const root = makeTempDir();
const logsDir = path.join(root, 'logs');
@@ -1,54 +0,0 @@
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',
]);
});
@@ -1,72 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildCopyCurrentSubtitleMainDepsHandler,
createBuildHandleMineSentenceDigitMainDepsHandler,
createBuildHandleMultiCopyDigitMainDepsHandler,
} from './mining-actions-main-deps';
test('mining action main deps builders map callbacks', () => {
const calls: string[] = [];
const multiCopy = createBuildHandleMultiCopyDigitMainDepsHandler({
getSubtitleTimingTracker: () => ({ track: true }),
writeClipboardText: (text) => calls.push(`clip:${text}`),
showMpvOsd: (text) => calls.push(`osd:${text}`),
handleMultiCopyDigitCore: () => calls.push('multi-copy'),
})();
assert.deepEqual(multiCopy.getSubtitleTimingTracker(), { track: true });
multiCopy.writeClipboardText('x');
multiCopy.showMpvOsd('y');
multiCopy.handleMultiCopyDigitCore(2, {
subtitleTimingTracker: { track: true },
writeClipboardText: () => {},
showMpvOsd: () => {},
});
const copyCurrent = createBuildCopyCurrentSubtitleMainDepsHandler({
getSubtitleTimingTracker: () => ({ track: true }),
writeClipboardText: (text) => calls.push(`copy:${text}`),
showMpvOsd: (text) => calls.push(`copy-osd:${text}`),
copyCurrentSubtitleCore: () => calls.push('copy-current'),
})();
assert.deepEqual(copyCurrent.getSubtitleTimingTracker(), { track: true });
copyCurrent.writeClipboardText('a');
copyCurrent.showMpvOsd('b');
copyCurrent.copyCurrentSubtitleCore({
subtitleTimingTracker: { track: true },
writeClipboardText: () => {},
showMpvOsd: () => {},
});
const mineDigit = createBuildHandleMineSentenceDigitMainDepsHandler({
getSubtitleTimingTracker: () => ({ track: true }),
getAnkiIntegration: () => ({ enabled: true }),
getCurrentSecondarySubText: () => 'sub',
showMpvOsd: (text) => calls.push(`mine-osd:${text}`),
logError: (message) => calls.push(`err:${message}`),
handleMineSentenceDigitCore: () => calls.push('mine-digit'),
})();
assert.equal(mineDigit.getCurrentSecondarySubText(), 'sub');
mineDigit.showMpvOsd('done');
mineDigit.logError('bad', null);
mineDigit.handleMineSentenceDigitCore(2, {
subtitleTimingTracker: { track: true },
ankiIntegration: { enabled: true },
getCurrentSecondarySubText: () => 'sub',
showMpvOsd: () => {},
logError: () => {},
});
assert.deepEqual(calls, [
'clip:x',
'osd:y',
'multi-copy',
'copy:a',
'copy-osd:b',
'copy-current',
'mine-osd:done',
'err:bad',
'mine-digit',
]);
});
-68
View File
@@ -1,68 +0,0 @@
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}`),
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'));
},
});
handleMineSentenceDigit(4);
assert.deepEqual(calls, ['osd:mine', 'err:boom']);
});
@@ -1,7 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createBindMpvClientEventHandlers,
createHandleMpvConnectionChangeHandler,
createHandleMpvSubtitleTimingHandler,
} from './mpv-client-event-bindings';
@@ -30,13 +29,10 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
test('mpv connection handler syncs overlay subtitle suppression on connect', () => {
const calls: string[] = [];
const deps: Parameters<typeof createHandleMpvConnectionChangeHandler>[0] & {
scheduleCharacterDictionarySync: () => void;
} = {
const deps: Parameters<typeof createHandleMpvConnectionChangeHandler>[0] = {
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
hasInitialPlaybackQuitOnDisconnectArg: () => true,
isOverlayRuntimeInitialized: () => false,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
@@ -182,52 +178,3 @@ test('mpv subtitle timing handler skips invalid cue pairs until timing is comple
'post-watch:956.56',
]);
});
test('mpv event bindings register all expected events', () => {
const seenEvents: string[] = [];
const bindHandlers = createBindMpvClientEventHandlers({
onConnectionChange: () => {},
onSubtitleChange: () => {},
onSubtitleAssChange: () => {},
onSecondarySubtitleChange: () => {},
onSubtitleTrackChange: () => {},
onSecondarySubtitleTrackChange: () => {},
onSecondarySubtitleDelayChange: () => {},
onSubtitleTrackListChange: () => {},
onSubtitleTiming: () => {},
onMediaPathChange: () => {},
onMediaTitleChange: () => {},
onTimePosChange: () => {},
onDurationChange: () => {},
onPauseChange: () => {},
onFullscreenChange: () => {},
onSubtitleMetricsChange: () => {},
onSecondarySubtitleVisibility: () => {},
});
bindHandlers({
on: (event) => {
seenEvents.push(event);
},
});
assert.deepEqual(seenEvents, [
'connection-change',
'subtitle-change',
'subtitle-ass-change',
'secondary-subtitle-change',
'subtitle-track-change',
'secondary-subtitle-track-change',
'secondary-subtitle-delay-change',
'subtitle-track-list-change',
'subtitle-timing',
'media-path-change',
'media-title-change',
'time-pos-change',
'duration-change',
'pause-change',
'fullscreen-change',
'subtitle-metrics-change',
'secondary-subtitle-visibility',
]);
});
@@ -1,45 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from './mpv-client-runtime-service-main-deps';
test('mpv runtime service main deps builder maps state and callbacks', () => {
const calls: string[] = [];
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
class FakeClient {
constructor(
public socketPath: string,
public options: unknown,
) {}
}
const build = createBuildMpvClientRuntimeServiceFactoryDepsHandler({
createClient: FakeClient,
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ mode: 'test' }),
isAutoStartOverlayEnabled: () => true,
setOverlayVisible: (visible) => calls.push(`overlay:${visible}`),
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => reconnectTimer,
setReconnectTimer: (timer) => {
reconnectTimer = timer;
calls.push('set-reconnect');
},
bindEventHandlers: () => calls.push('bind'),
});
const deps = build();
assert.equal(deps.socketPath, '/tmp/mpv.sock');
assert.equal(deps.options.autoStartOverlay, true);
assert.equal(deps.options.isVisibleOverlayVisible(), false);
assert.deepEqual(deps.options.getResolvedConfig(), { mode: 'test' });
deps.options.setOverlayVisible(true);
deps.options.setReconnectTimer(setTimeout(() => {}, 0));
deps.bindEventHandlers(new FakeClient('/tmp/mpv.sock', {}));
assert.ok(calls.includes('overlay:true'));
assert.ok(calls.includes('set-reconnect'));
assert.ok(calls.includes('bind'));
assert.ok(reconnectTimer);
});
@@ -1,39 +0,0 @@
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: () => {},
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']);
});
@@ -1,25 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildApplyJellyfinMpvDefaultsMainDepsHandler,
createBuildGetDefaultSocketPathMainDepsHandler,
} from './mpv-jellyfin-defaults-main-deps';
test('apply jellyfin mpv defaults main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildApplyJellyfinMpvDefaultsMainDepsHandler({
sendMpvCommandRuntime: (_client, command) => calls.push(command.join(':')),
jellyfinLangPref: 'ja,jp',
})();
deps.sendMpvCommandRuntime({ connected: true, send: () => {} }, ['set_property', 'aid', 'auto']);
assert.equal(deps.jellyfinLangPref, 'ja,jp');
assert.deepEqual(calls, ['set_property:aid:auto']);
});
test('get default socket path main deps builder maps platform', () => {
const deps = createBuildGetDefaultSocketPathMainDepsHandler({
platform: 'darwin',
})();
assert.equal(deps.platform, 'darwin');
});
@@ -1,33 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createApplyJellyfinMpvDefaultsHandler,
createGetDefaultSocketPathHandler,
} from './mpv-jellyfin-defaults';
test('apply jellyfin mpv defaults sends expected property commands', () => {
const calls: string[] = [];
const applyDefaults = createApplyJellyfinMpvDefaultsHandler({
sendMpvCommandRuntime: (_client, command) => calls.push(command.join(':')),
jellyfinLangPref: 'ja,jp',
});
applyDefaults({ connected: true, send: () => {} });
assert.deepEqual(calls, [
'set_property:sub-auto:no',
'set_property:aid:auto',
'set_property:sid:no',
'set_property:secondary-sid:no',
'set_property:sub-visibility:no',
'set_property:secondary-sub-visibility:no',
'set_property:alang:ja,jp',
'set_property:slang:ja,jp',
]);
});
test('get default socket path returns platform specific value', () => {
const getWindowsPath = createGetDefaultSocketPathHandler({ platform: 'win32' });
const getUnixPath = createGetDefaultSocketPathHandler({ platform: 'darwin' });
assert.equal(getWindowsPath(), '\\\\.\\pipe\\subminer-socket');
assert.equal(getUnixPath(), '/tmp/subminer-socket');
});
+38 -151
View File
@@ -2,13 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
createHandleMpvMediaPathChangeHandler,
createHandleMpvMediaTitleChangeHandler,
createHandleMpvPauseChangeHandler,
createHandleMpvSecondarySubtitleChangeHandler,
createHandleMpvSecondarySubtitleVisibilityHandler,
createHandleMpvSubtitleAssChangeHandler,
createHandleMpvSubtitleChangeHandler,
createHandleMpvSubtitleMetricsChangeHandler,
createHandleMpvTimePosChangeHandler,
} from './mpv-main-event-actions';
@@ -140,43 +135,37 @@ test('subtitle change handler emits cached annotation after forwarding the subti
]);
});
test('subtitle ass change handler updates state and broadcasts', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleAssChangeHandler({
setCurrentSubAssText: (text) => calls.push(`set:${text}`),
broadcastSubtitleAss: (text) => calls.push(`broadcast:${text}`),
});
type MediaPathDeps = Parameters<typeof createHandleMpvMediaPathChangeHandler>[0];
handler({ text: '{\\an8}line' });
assert.deepEqual(calls, ['set:{\\an8}line', 'broadcast:{\\an8}line']);
});
test('secondary subtitle change handler broadcasts text', () => {
const seen: string[] = [];
const handler = createHandleMpvSecondarySubtitleChangeHandler({
broadcastSecondarySubtitle: (text) => seen.push(text),
});
handler({ text: 'secondary' });
assert.deepEqual(seen, ['secondary']);
});
test('media path change handler reports stop for empty path and probes media key', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
// Required deps record into `calls`; optional hooks are added per test via overrides.
function createMediaPathDeps(
calls: string[],
overrides: Partial<MediaPathDeps> = {},
): MediaPathDeps {
return {
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => 'show:1',
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
refreshDiscordPresence: () => calls.push('presence'),
});
...overrides,
};
}
test('media path change handler reports stop for empty path and probes media key', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler(
createMediaPathDeps(calls, {
getCurrentAnilistMediaKey: () => 'show:1',
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
}),
);
handler({ path: '' });
assert.deepEqual(calls, [
@@ -195,21 +184,13 @@ test('media path change handler reports stop for empty path and probes media key
test('media path change handler signals autoplay readiness from warm media path', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
refreshDiscordPresence: () => calls.push('presence'),
});
const handler = createHandleMpvMediaPathChangeHandler(
createMediaPathDeps(calls, {
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
}),
);
handler({ path: '/tmp/video.mkv' });
@@ -226,19 +207,11 @@ test('media path change handler signals autoplay readiness from warm media path'
test('media path change handler schedules character dictionary once per media path', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
refreshDiscordPresence: () => calls.push('presence'),
});
const handler = createHandleMpvMediaPathChangeHandler(
createMediaPathDeps(calls, {
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
}),
);
handler({ path: '/tmp/video.mkv' });
handler({ path: '/tmp/video.mkv' });
@@ -254,19 +227,11 @@ test('media path change handler schedules character dictionary once per media pa
test('media path change handler marks Jellyfin remote playback loaded from media path', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
markJellyfinRemotePlaybackLoaded: (path) => calls.push(`jellyfin-loaded:${path}`),
refreshDiscordPresence: () => calls.push('presence'),
});
const handler = createHandleMpvMediaPathChangeHandler(
createMediaPathDeps(calls, {
markJellyfinRemotePlaybackLoaded: (path) => calls.push(`jellyfin-loaded:${path}`),
}),
);
handler({ path: 'https://stream.example/video.m3u8' });
@@ -274,60 +239,6 @@ test('media path change handler marks Jellyfin remote playback loaded from media
assert.equal(calls.includes('stopped'), false);
});
test('media title change handler clears guess state without re-scheduling character dictionary sync', () => {
const calls: string[] = [];
const deps: Parameters<typeof createHandleMpvMediaTitleChangeHandler>[0] & {
scheduleCharacterDictionarySync: () => void;
} = {
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
notifyImmersionTitleUpdate: (title) => calls.push(`notify:${title}`),
syncImmersionMediaState: () => calls.push('sync'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
refreshDiscordPresence: () => calls.push('presence'),
};
const handler = createHandleMpvMediaTitleChangeHandler(deps);
handler({ title: 'Episode 1' });
assert.deepEqual(calls, [
'title:Episode 1',
'reset-guess',
'notify:Episode 1',
'sync',
'presence',
]);
});
test('time-pos and pause handlers report progress with correct urgency', () => {
const calls: string[] = [];
const timeHandler = createHandleMpvTimePosChangeHandler({
recordPlaybackPosition: (time) => calls.push(`time:${time}`),
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
refreshDiscordPresence: () => calls.push('presence'),
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('post-watch');
},
logError: () => calls.push('post-watch-error'),
});
const pauseHandler = createHandleMpvPauseChangeHandler({
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
refreshDiscordPresence: () => calls.push('presence'),
});
timeHandler({ time: 12.5 });
pauseHandler({ paused: true });
assert.deepEqual(calls, [
'time:12.5',
'progress:normal',
'presence',
'post-watch',
'pause:yes',
'progress:force',
'presence',
]);
});
test('time-pos handler forces Jellyfin progress when mpv position jumps', () => {
const calls: string[] = [];
const timeHandler = createHandleMpvTimePosChangeHandler({
@@ -433,27 +344,3 @@ test('time-pos handler logs post-watch update rejection without blocking later h
'error:AniList post-watch update failed unexpectedly:boom',
]);
});
test('subtitle metrics change handler forwards patch payload', () => {
let received: Record<string, unknown> | null = null;
const handler = createHandleMpvSubtitleMetricsChangeHandler({
updateSubtitleRenderMetrics: (patch) => {
received = patch;
},
});
const patch = { fontSize: 48 };
handler({ patch });
assert.deepEqual(received, patch);
});
test('secondary subtitle visibility handler stores visibility flag', () => {
const seen: boolean[] = [];
const handler = createHandleMpvSecondarySubtitleVisibilityHandler({
setPreviousSecondarySubVisibility: (visible) => seen.push(visible),
});
handler({ visible: true });
handler({ visible: false });
assert.deepEqual(seen, [true, false]);
});
+75 -194
View File
@@ -4,149 +4,19 @@ import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { createBindMpvMainEventHandlersHandler } from './mpv-main-event-bindings';
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
test('main mpv event binder wires callbacks through to runtime deps', () => {
const handlers = new Map<string, (payload: unknown) => void>();
const calls: string[] = [];
let currentTime = 0;
const seekLiveText = '少しだけ好きになる\n少しだけ好きになる';
const seekCues = parseSubtitleCues(
[
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,少しだけ好きになる',
'Dialogue: 0,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,少しだけ好きになる',
].join('\n'),
'seek-ending.ass',
);
type Deps = Parameters<typeof createBindMpvMainEventHandlersHandler>[0];
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
hasInitialPlaybackQuitOnDisconnectArg: () => false,
isOverlayRuntimeInitialized: () => false,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {
calls.push('schedule-quit-check');
},
isMpvConnected: () => false,
quitApp: () => calls.push('quit-app'),
recordImmersionSubtitleLine: (text) => calls.push(`immersion:${text}`),
hasSubtitleTimingTracker: () => false,
recordSubtitleTiming: () => calls.push('record-timing'),
maybeRunAnilistPostWatchUpdate: async (options) => {
calls.push(`post-watch:${options?.watchedSeconds ?? 'none'}`);
},
logSubtitleTimingError: () => calls.push('subtitle-error'),
resolveSubtitleText: (liveText) =>
resolvePrimarySubtitleText({ liveText, currentTimeSec: currentTime, cues: seekCues }),
getCurrentLiveSubtitleText: () => seekLiveText,
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
getImmediateSubtitlePayload: (text) => ({ text, tokens: [] }),
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
refreshDiscordPresence: () => calls.push('presence-refresh'),
setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`),
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
onSubtitleTrackChange: () => calls.push('subtitle-track-change'),
onSecondarySubtitleTrackChange: () => calls.push('secondary-subtitle-track-change'),
onSecondarySubtitleDelayChange: (delay) =>
calls.push(`secondary-subtitle-delay-change:${delay}`),
onSubtitleTrackListChange: () => calls.push('subtitle-track-list-change'),
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => 'media-key',
resetAnilistMediaTracking: (key) => calls.push(`reset-media:${String(key)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync-immersion'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`),
resetAnilistMediaGuessState: () => calls.push('reset-guess-state'),
notifyImmersionTitleUpdate: (title) => calls.push(`notify-title:${title}`),
recordPlaybackPosition: (time) => calls.push(`time-pos:${time}`),
recordMediaDuration: (duration) => calls.push(`duration:${duration}`),
reportJellyfinRemoteProgress: (forceImmediate) =>
calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`),
onTimePosUpdate: (time) => {
currentTime = time;
},
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
updateSubtitleRenderMetrics: () => calls.push('subtitle-metrics'),
setPreviousSecondarySubVisibility: (visible) =>
calls.push(`secondary-visible:${visible ? 'yes' : 'no'}`),
});
bind({
on: (event, handler) => {
handlers.set(event, handler as (payload: unknown) => void);
},
});
handlers.get('connection-change')?.({ connected: true });
handlers.get('subtitle-change')?.({ text: 'line' });
handlers.get('subtitle-track-change')?.({ sid: 3 });
handlers.get('secondary-subtitle-track-change')?.({ sid: 4 });
handlers.get('secondary-subtitle-delay-change')?.({ delay: 0.5 });
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
handlers.get('media-path-change')?.({ path: '/tmp/video.mkv' });
handlers.get('media-path-change')?.({ path: '' });
handlers.get('media-title-change')?.({ title: 'Episode 1' });
handlers.get('subtitle-timing')?.({ text: 'timed line', start: 899, end: 901 });
handlers.get('subtitle-change')?.({ text: seekLiveText });
handlers.get('time-pos-change')?.({ time: 90 });
assert.ok(calls.includes('set-sub:少しだけ好きになる'));
handlers.get('time-pos-change')?.({ time: 2.5 });
handlers.get('subtitle-change')?.({ text: seekLiveText });
handlers.get('time-pos-change')?.({ time: 90 });
handlers.get('pause-change')?.({ paused: true });
assert.ok(calls.includes('set-sub:line'));
assert.ok(calls.includes('reset-sidebar-layout'));
assert.equal(calls.includes('broadcast-sub:line'), true);
assert.ok(calls.includes('subtitle-change:line'));
assert.ok(calls.includes('subtitle-track-change'));
assert.ok(calls.includes('secondary-subtitle-track-change'));
assert.ok(calls.includes('secondary-subtitle-delay-change:0.5'));
assert.ok(calls.includes('subtitle-track-list-change'));
assert.ok(calls.includes('media-title:Episode 1'));
assert.ok(calls.includes('media-path:/tmp/video.mkv'));
assert.ok(calls.includes('autoplay:/tmp/video.mkv'));
assert.ok(calls.includes('reset-guess-state'));
assert.ok(calls.includes('notify-title:Episode 1'));
assert.ok(calls.includes('post-watch:901'));
assert.ok(calls.includes('progress:normal'));
assert.ok(calls.includes('progress:force'));
assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('flush-playback'));
});
test('main mpv event binder runs mpv-connected callback on connection', () => {
const handlers = new Map<string, (payload: unknown) => void>();
const calls: string[] = [];
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
onMpvConnected: () => calls.push('mpv-connected'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
function createDeps(overrides: Partial<Deps> = {}): Deps {
return {
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
resetSubtitleSidebarEmbeddedLayout: () => {},
hasInitialPlaybackQuitOnDisconnectArg: () => false,
isOverlayRuntimeInitialized: () => false,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
isMpvConnected: () => true,
isMpvConnected: () => false,
quitApp: () => {},
recordImmersionSubtitleLine: () => {},
@@ -157,7 +27,7 @@ test('main mpv event binder runs mpv-connected callback on connection', () => {
setCurrentSubText: () => {},
broadcastSubtitle: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => calls.push('presence-refresh'),
refreshDiscordPresence: () => {},
setCurrentSubAssText: () => {},
broadcastSubtitleAss: () => {},
@@ -182,79 +52,90 @@ test('main mpv event binder runs mpv-connected callback on connection', () => {
updateSubtitleRenderMetrics: () => {},
setPreviousSecondarySubVisibility: () => {},
});
...overrides,
};
}
bind({
/** Binds the main handlers onto a fake mpv client and returns an `emit(event, payload)` driver. */
function bindEvents(overrides: Partial<Deps> = {}) {
const handlers = new Map<string, (payload: unknown) => void>();
createBindMpvMainEventHandlersHandler(createDeps(overrides))({
on: (event, handler) => {
handlers.set(event, handler as (payload: unknown) => void);
},
});
return (event: string, payload: unknown): void => handlers.get(event)?.(payload);
}
handlers.get('connection-change')?.({ connected: true });
test('main mpv event binder re-resolves the live subtitle when time-pos seeks', () => {
const calls: string[] = [];
let currentTime = 0;
const liveText = '少しだけ好きになる\n少しだけ好きになる';
const cues = parseSubtitleCues(
[
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,少しだけ好きになる',
'Dialogue: 0,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,少しだけ好きになる',
].join('\n'),
'seek-ending.ass',
);
const emit = bindEvents({
resolveSubtitleText: (text) =>
resolvePrimarySubtitleText({ liveText: text, currentTimeSec: currentTime, cues }),
getCurrentLiveSubtitleText: () => liveText,
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
onTimePosUpdate: (time) => {
currentTime = time;
},
});
assert.ok(calls.includes('mpv-connected'));
emit('time-pos-change', { time: 2.5 });
calls.length = 0;
// A jump into the cue re-resolves the same live text at the new position.
emit('time-pos-change', { time: 90 });
assert.deepEqual(calls, ['set-sub:少しだけ好きになる']);
// Ordinary playback ticks leave the displayed subtitle alone.
emit('time-pos-change', { time: 90.5 });
assert.deepEqual(calls, ['set-sub:少しだけ好きになる']);
});
test('main mpv event binder resets the sidebar layout on connect', () => {
const calls: string[] = [];
const emit = bindEvents({
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
});
emit('connection-change', { connected: true });
assert.deepEqual(calls, ['reset-sidebar-layout']);
});
test('main mpv event binder runs mpv-connected callback on connection', () => {
const calls: string[] = [];
const emit = bindEvents({ onMpvConnected: () => calls.push('mpv-connected') });
emit('connection-change', { connected: true });
assert.deepEqual(calls, ['mpv-connected']);
});
test('main mpv event binder clears media path on disconnect', () => {
const handlers = new Map<string, (payload: unknown) => void>();
const calls: string[] = [];
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
const emit = bindEvents({
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
hasInitialPlaybackQuitOnDisconnectArg: () => false,
isOverlayRuntimeInitialized: () => true,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
isMpvConnected: () => false,
quitApp: () => {},
recordImmersionSubtitleLine: () => {},
hasSubtitleTimingTracker: () => false,
recordSubtitleTiming: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
setCurrentSubText: () => {},
broadcastSubtitle: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => calls.push('presence-refresh'),
setCurrentSubAssText: () => {},
broadcastSubtitleAss: () => {},
broadcastSecondarySubtitle: () => {},
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
notifyImmersionTitleUpdate: () => {},
recordPlaybackPosition: () => {},
recordMediaDuration: () => {},
reportJellyfinRemoteProgress: () => {},
recordPauseState: () => {},
updateSubtitleRenderMetrics: () => {},
setPreviousSecondarySubVisibility: () => {},
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
});
bind({
on: (event, handler) => {
handlers.set(event, handler as (payload: unknown) => void);
},
});
handlers.get('connection-change')?.({ connected: false });
emit('connection-change', { connected: false });
assert.ok(calls.includes('media-path:'));
assert.ok(calls.includes('remote-stopped'));
assert.ok(calls.includes('presence-refresh'));
assert.equal(calls.includes('reset-sidebar-layout'), false);
});
+134 -476
View File
@@ -3,154 +3,30 @@ import test from 'node:test';
import { parseAssCues } from '../../core/services/subtitle-cue-parser';
import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './mpv-main-event-main-deps';
test('mpv main event main deps map app state updates and delegate callbacks', async () => {
const calls: string[] = [];
const appState = {
initialArgs: { jellyfinPlay: true },
type MainDeps = Parameters<typeof createBuildBindMpvMainEventHandlersMainDepsHandler>[0];
/**
* Builds the handlers with inert deps. `appStateOverrides` is merged over a blank app
* state; the full `appState` is returned so tests can mutate it and assert on it.
*/
function buildMainDeps(
appStateOverrides: Partial<MainDeps['appState']> = {},
depsOverrides: Partial<MainDeps> = {},
) {
const appState: MainDeps['appState'] = {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: {
connected: true,
currentSecondarySubText: 'secondary',
currentTimePos: 12.25,
requestProperty: async () => 18.75,
},
immersionTracker: {
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`),
recordMediaDuration: (durationSec: number) => calls.push(`immersion-duration:${durationSec}`),
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
},
subtitleTimingTracker: {
recordSubtitle: (text: string, _start: number, _end: number, secondaryText?: string) =>
calls.push(`timing:${text}:${secondaryText ?? ''}`),
},
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
...appStateOverrides,
};
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState,
getQuitOnDisconnectArmed: () => true,
scheduleQuitCheck: (callback) => {
calls.push('schedule');
callback();
},
quitApp: () => calls.push('quit'),
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('anilist-post-watch');
},
recordAnilistMediaDuration: (durationSec) => calls.push(`anilist-duration:${durationSec}`),
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`),
onSecondarySubtitleChange: (text) => calls.push(`secondary:${text}`),
onSecondarySubtitleTrackChange: (sid) => calls.push(`secondary-track:${String(sid)}`),
onSecondarySubtitleDelayChange: (delay) => calls.push(`secondary-delay:${delay}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => 'media-key',
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync-immersion'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`),
onFullscreenChange: (fullscreen) => calls.push(`fullscreen:${fullscreen}`),
updateSubtitleRenderMetrics: () => calls.push('metrics'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
})();
assert.equal(deps.hasInitialPlaybackQuitOnDisconnectArg(), true);
assert.equal(deps.isOverlayRuntimeInitialized(), true);
assert.equal(deps.isQuitOnDisconnectArmed(), true);
assert.equal(deps.isMpvConnected(), true);
deps.scheduleQuitCheck(() => calls.push('scheduled-callback'));
deps.quitApp();
deps.reportJellyfinRemoteStopped();
deps.syncOverlayMpvSubtitleSuppression();
deps.recordImmersionSubtitleLine('x', 0, 1);
assert.equal(deps.hasSubtitleTimingTracker(), true);
deps.recordSubtitleTiming('y', 0, 1);
await deps.maybeRunAnilistPostWatchUpdate();
deps.logSubtitleTimingError('err', new Error('boom'));
deps.setCurrentSubText('sub');
deps.broadcastSubtitle({ text: 'sub', tokens: null });
deps.onSubtitleChange('sub');
deps.refreshDiscordPresence();
deps.setCurrentSubAssText('ass');
deps.broadcastSubtitleAss('ass');
deps.broadcastSecondarySubtitle('sec');
deps.onSecondarySubtitleTrackChange?.(4);
deps.onSecondarySubtitleDelayChange?.(0.5);
deps.updateCurrentMediaPath('/tmp/video');
deps.restoreMpvSubVisibility();
deps.resetSubtitleSidebarEmbeddedLayout();
assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key');
deps.resetAnilistMediaTracking('media-key');
deps.maybeProbeAnilistDuration('media-key');
deps.ensureAnilistMediaGuess('media-key');
deps.syncImmersionMediaState();
deps.signalAutoplayReadyIfWarm?.('/tmp/video');
deps.updateCurrentMediaTitle('title');
deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate('title');
deps.recordPlaybackPosition(10);
deps.recordMediaDuration(1234);
deps.reportJellyfinRemoteProgress(true);
deps.onFullscreenChange?.(true);
deps.recordPauseState(true);
deps.updateSubtitleRenderMetrics({});
deps.setPreviousSecondarySubVisibility(true);
deps.flushPlaybackPositionOnMediaPathClear?.('');
await Promise.resolve();
assert.equal(appState.currentSubText, 'sub');
assert.equal(appState.currentSubAssText, 'ass');
assert.equal(appState.playbackPaused, true);
assert.equal(appState.previousSecondarySubVisibility, true);
assert.ok(calls.includes('remote-stopped'));
assert.ok(calls.includes('sync-overlay-mpv-sub'));
assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('timing:y:secondary'));
assert.ok(calls.includes('secondary:sec'));
assert.ok(calls.includes('secondary-track:4'));
assert.ok(calls.includes('secondary-delay:0.5'));
assert.ok(!calls.includes('broadcast:secondary-subtitle:set:sec'));
assert.ok(calls.includes('ensure-immersion'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('autoplay:/tmp/video'));
assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('fullscreen:true'));
assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('reset-sidebar-layout'));
assert.ok(calls.includes('immersion-duration:1234'));
assert.ok(calls.includes('anilist-duration:1234'));
});
test('mpv main event main deps wire subtitle callbacks without suppression gate', () => {
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
@@ -163,7 +39,6 @@ test('mpv main event main deps wire subtitle callbacks without suppression gate'
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
resetSubtitleSidebarEmbeddedLayout: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
@@ -174,49 +49,52 @@ test('mpv main event main deps wire subtitle callbacks without suppression gate'
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
...depsOverrides,
})();
return { handlers, appState };
}
deps.setCurrentSubText('sub');
assert.equal(typeof deps.setCurrentSubText, 'function');
test('secondary subtitle changes go to the injected handler instead of the overlay broadcast', () => {
const calls: string[] = [];
const injected = buildMainDeps(
{},
{
onSecondarySubtitleChange: (text) => calls.push(`secondary:${text}`),
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`),
},
).handlers;
const fallback = buildMainDeps(
{},
{
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`),
},
).handlers;
injected.broadcastSecondarySubtitle('sec');
fallback.broadcastSecondarySubtitle('sec');
assert.deepEqual(calls, ['secondary:sec', 'broadcast:secondary-subtitle:set:sec']);
});
test('media duration is recorded for both immersion and AniList', () => {
const calls: string[] = [];
const { handlers } = buildMainDeps(
{ immersionTracker: { recordMediaDuration: (sec) => calls.push(`immersion:${sec}`) } },
{ recordAnilistMediaDuration: (sec) => calls.push(`anilist:${sec}`) },
);
handlers.recordMediaDuration(1234);
assert.deepEqual(calls, ['immersion:1234', 'anilist:1234']);
});
test('mpv main event main deps treat managed playback as quit-on-disconnect', () => {
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: { managedPlayback: true },
overlayRuntimeInitialized: false,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
},
getQuitOnDisconnectArmed: () => true,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
resetSubtitleSidebarEmbeddedLayout: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
const deps = buildMainDeps({
initialArgs: { managedPlayback: true },
overlayRuntimeInitialized: false,
}).handlers;
assert.equal(deps.hasInitialPlaybackQuitOnDisconnectArg(), true);
assert.equal(deps.shouldQuitOnDisconnectWhenOverlayRuntimeInitialized(), true);
@@ -224,53 +102,21 @@ test('mpv main event main deps treat managed playback as quit-on-disconnect', ()
test('flushPlaybackPositionOnMediaPathClear ignores disconnected mpv time-pos reads', async () => {
const recorded: number[] = [];
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: {
connected: false,
currentTimePos: 42,
requestProperty: async () => {
throw new Error('disconnected');
},
const deps = buildMainDeps({
mpvClient: {
connected: false,
currentTimePos: 42,
requestProperty: async () => {
throw new Error('disconnected');
},
immersionTracker: {
recordPlaybackPosition: (time: number) => {
recorded.push(time);
},
},
subtitleTimingTracker: null,
currentMediaPath: '',
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
resetSubtitleSidebarEmbeddedLayout: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
immersionTracker: {
recordPlaybackPosition: (time: number) => {
recorded.push(time);
},
},
currentMediaPath: '',
}).handlers;
deps.flushPlaybackPositionOnMediaPathClear?.('');
await Promise.resolve();
@@ -280,46 +126,13 @@ test('flushPlaybackPositionOnMediaPathClear ignores disconnected mpv time-pos re
test('media and subtitle-track transitions reset live subtitle-line deduplication', () => {
const recordedStarts: number[] = [];
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: {
recordSubtitleLine: (_text: string, start: number) => recordedStarts.push(start),
},
subtitleTimingTracker: null,
activeParsedSubtitleCues: null,
currentMediaPath: '/video-a.mkv',
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
const handlers = buildMainDeps({
immersionTracker: {
recordSubtitleLine: (_text: string, start: number) => recordedStarts.push(start),
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
resetSubtitleSidebarEmbeddedLayout: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
activeParsedSubtitleCues: null,
currentMediaPath: '/video-a.mkv',
}).handlers;
for (let index = 0; index < 8; index += 1) {
handlers.recordImmersionSubtitleLine('待って', index * 0.04, (index + 1) * 0.04);
@@ -343,47 +156,13 @@ test('media and subtitle-track transitions reset live subtitle-line deduplicatio
test('subtitle-track transitions ignore stale parsed cues until replacement cues arrive', () => {
const recordedStarts: number[] = [];
const appState = {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
const { handlers, appState } = buildMainDeps({
immersionTracker: {
recordSubtitleLine: (_text: string, start: number) => recordedStarts.push(start),
},
subtitleTimingTracker: null,
activeParsedSubtitleCues: [{ startTime: 10, endTime: 14, text: '飛び上がる' }],
currentMediaPath: '/video-a.mkv',
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
};
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState,
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
resetSubtitleSidebarEmbeddedLayout: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
});
handlers.recordImmersionSubtitleLine('飛び上がる', 10, 10.04);
handlers.onSubtitleTrackChange?.(2);
@@ -401,69 +180,39 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
const immersion: Array<{ text: string; start: number; end: number }> = [];
const timing: Array<{ text: string; start: number; end: number }> = [];
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: { currentTimePos: 2 },
immersionTracker: {
recordSubtitleLine: (text: string, start: number, end: number) =>
immersion.push({ text, start, end }),
},
subtitleTimingTracker: {
recordSubtitle: (text: string, start: number, end: number) =>
timing.push({ text, start, end }),
},
activeParsedSubtitleCues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある物差しでは',
source: 'canonical-ass',
},
{
startTime: 3,
endTime: 6,
text: '飛び越えてみたくて',
source: 'canonical-ass',
},
{
startTime: 10,
endTime: 12,
text: 'MaidCafeMaidCafe',
source: 'reconstructed-ass',
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
},
],
currentMediaPath: '/video.mkv',
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
const handlers = buildMainDeps({
mpvClient: { currentTimePos: 2 },
immersionTracker: {
recordSubtitleLine: (text: string, start: number, end: number) =>
immersion.push({ text, start, end }),
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
subtitleTimingTracker: {
recordSubtitle: (text: string, start: number, end: number) =>
timing.push({ text, start, end }),
},
activeParsedSubtitleCues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある物差しでは',
source: 'canonical-ass',
},
{
startTime: 3,
endTime: 6,
text: '飛び越えてみたくて',
source: 'canonical-ass',
},
{
startTime: 10,
endTime: 12,
text: 'MaidCafeMaidCafe',
source: 'reconstructed-ass',
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
},
],
currentMediaPath: '/video.mkv',
}).handlers;
assert.equal(handlers.resolveSubtitleText?.('今\n今\n今\n手\n手\n手'), '今 手にある物差しでは');
handlers.recordImmersionSubtitleLine('今', 0.8, 1.5);
@@ -526,9 +275,7 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer
});
test('subtitle-track changes stop stale canonical cues from substituting immediately', () => {
const appState = {
initialArgs: null,
overlayRuntimeInitialized: true,
const { handlers, appState } = buildMainDeps({
mpvClient: { currentTimePos: 2 },
immersionTracker: { recordSubtitleLine: () => {} },
subtitleTimingTracker: { recordSubtitle: () => {} },
@@ -542,36 +289,7 @@ test('subtitle-track changes stop stale canonical cues from substituting immedia
] as Array<{ startTime: number; endTime: number; text: string; source?: 'canonical-ass' }>,
activeParsedSubtitleSource: 'track-a.ass' as string | null,
currentMediaPath: '/video.mkv',
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
};
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState,
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
});
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今 手にある物差しでは');
@@ -618,42 +336,12 @@ test('subtitle recorders drop ASS furigana events the same way the display does'
const immersion: string[] = [];
const timing: string[] = [];
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: { currentTimePos: 299.7 },
immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) },
subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) },
activeParsedSubtitleCues: cues,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
const handlers = buildMainDeps({
mpvClient: { currentTimePos: 299.7 },
immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) },
subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) },
activeParsedSubtitleCues: cues,
}).handlers;
const liveText = '(平)暗記教科は もう\nかた\nぱし\nとにかく片っ端から覚えるんだよ。';
const expected = '(平)暗記教科は もう\n\nとにかく片っ端から覚えるんだよ。';
@@ -671,51 +359,21 @@ test('a resolved line survives recording while a fragment grid is on screen', ()
// complete line, not raw mpv output, so it must not be fed through that path.
const immersion: string[] = [];
const timing: string[] = [];
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: { currentTimePos: 3.2 },
immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) },
subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) },
activeParsedSubtitleCues: [
{ startTime: 3, endTime: 6, text: '飛び越えてみたくて', source: 'canonical-ass' },
{
startTime: 3,
endTime: 6,
text: 'MaidCafeMaidCafe',
source: 'reconstructed-ass',
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
},
],
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
const handlers = buildMainDeps({
mpvClient: { currentTimePos: 3.2 },
immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) },
subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) },
activeParsedSubtitleCues: [
{ startTime: 3, endTime: 6, text: '飛び越えてみたくて', source: 'canonical-ass' },
{
startTime: 3,
endTime: 6,
text: 'MaidCafeMaidCafe',
source: 'reconstructed-ass',
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
},
],
}).handlers;
// The grid fragment beside the lyric keeps canonical substitution from applying, so
// recording falls to the parsed view -- which is where the whole line is recovered.
@@ -1,57 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildAppendToMpvLogMainDepsHandler,
createBuildShowMpvOsdMainDepsHandler,
} from './mpv-osd-log-main-deps';
test('append to mpv log main deps map filesystem functions and log path', async () => {
const calls: string[] = [];
const deps = createBuildAppendToMpvLogMainDepsHandler({
getLogPath: () => '/tmp/mpv.log',
dirname: (targetPath) => {
calls.push(`dirname:${targetPath}`);
return '/tmp';
},
mkdir: async (targetPath) => {
calls.push(`mkdir:${targetPath}`);
},
appendFile: async (_targetPath, data) => {
calls.push(`append:${data}`);
},
now: () => new Date('2026-02-20T00:00:00.000Z'),
})();
assert.equal(deps.getLogPath(), '/tmp/mpv.log');
assert.equal(deps.dirname('/tmp/mpv.log'), '/tmp');
await deps.mkdir('/tmp', { recursive: true });
await deps.appendFile('/tmp/mpv.log', 'line', { encoding: 'utf8' });
assert.equal(deps.now().toISOString(), '2026-02-20T00:00:00.000Z');
assert.deepEqual(calls, ['dirname:/tmp/mpv.log', 'mkdir:/tmp', 'append:line']);
});
test('show mpv osd main deps map runtime delegates and logging callback', () => {
const calls: string[] = [];
const client = {
connected: true,
send: () => {},
};
const deps = createBuildShowMpvOsdMainDepsHandler({
appendToMpvLog: (message) => calls.push(`append:${message}`),
showMpvOsdRuntime: (_mpvClient, text, fallbackLog) => {
calls.push(`show:${text}`);
fallbackLog('fallback');
return false;
},
getMpvClient: () => client,
logInfo: (line) => calls.push(`info:${line}`),
})();
assert.deepEqual(deps.getMpvClient(), client);
deps.appendToMpvLog('hello');
const shown = deps.showMpvOsdRuntime(deps.getMpvClient(), 'subtitle', (line) =>
deps.logInfo(line),
);
assert.equal(shown, false);
assert.deepEqual(calls, ['append:hello', 'show:subtitle', 'info:fallback']);
});
+1 -24
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createAppendToMpvLogHandler, createShowMpvOsdHandler } from './mpv-osd-log';
import { createAppendToMpvLogHandler } from './mpv-osd-log';
test('append mpv log writes timestamped message', () => {
const writes: string[] = [];
@@ -111,26 +111,3 @@ test('append mpv log swallows async filesystem errors', async () => {
assert.doesNotThrow(() => appendToMpvLog('hello'));
await assert.doesNotReject(async () => flushMpvLog());
});
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');
return false;
},
getMpvClient: () => client,
logInfo: (line) => calls.push(`info:${line}`),
});
const shown = showMpvOsd('subtitle copied');
assert.equal(shown, false);
assert.deepEqual(calls, [
'append:[OSD] subtitle copied',
'show:subtitle copied',
'info:fallback-line',
]);
});
@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMpvOsdRuntimeHandlers } from './mpv-osd-runtime-handlers';
test('mpv osd runtime handlers compose append and osd logging flow', async () => {
const calls: string[] = [];
const runtime = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: {
getLogPath: () => '/tmp/subminer/mpv.log',
dirname: () => '/tmp/subminer',
mkdir: async () => {},
appendFile: async (_targetPath: string, data: string) => {
calls.push(`append:${data.trimEnd()}`);
},
now: () => new Date('2026-02-20T00:00:00.000Z'),
},
buildShowMpvOsdMainDeps: (appendToMpvLog) => ({
appendToMpvLog,
showMpvOsdRuntime: (_client, text, fallbackLog) => {
calls.push(`show:${text}`);
fallbackLog('fallback');
return false;
},
getMpvClient: () => null,
logInfo: (line) => calls.push(`info:${line}`),
}),
});
const shown = runtime.showMpvOsd('hello');
await runtime.flushMpvLog();
assert.equal(shown, false);
assert.deepEqual(calls, [
'show:hello',
'info:fallback',
'append:[2026-02-20T00:00:00.000Z] [OSD] hello',
]);
});
+145 -13
View File
@@ -1,24 +1,156 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { resolveMpvExecutablePath, spawnMpvProcess } from './mpv-process';
import {
createWindowsMpvPathDeps,
getConfiguredWindowsMpvPathStatus,
resolveMpvExecutablePath,
resolveWindowsMpvPath,
spawnMpvProcess,
} from './mpv-process';
const testWindows = process.platform === 'win32' ? test : test.skip;
const testLinux = process.platform === 'linux' ? test : test.skip;
test('mpv process launcher forwards arguments and environment to the child', async () => {
const child = spawnMpvProcess(
process.execPath,
['-e', 'process.exit(Number(process.env.SUBMINER_TEST_EXIT))'],
{ ...process.env, DISPLAY: '', SUBMINER_TEST_EXIT: '17' },
);
try {
const [code] = await once(child, 'exit');
assert.equal(code, 17);
} finally {
if (child.exitCode === null) child.kill();
}
function makeWindowsDeps(options: {
existing?: string[];
env?: Record<string, string>;
where?: { status: number | null; stdout: string };
}) {
const existing = new Set(options.existing ?? []);
const calls: string[] = [];
const deps = createWindowsMpvPathDeps({
getEnv: (name) => options.env?.[name],
fileExists: (candidate) => existing.has(candidate),
runWhere: () => {
calls.push('where');
return options.where ?? { status: 1, stdout: '' };
},
});
return { deps, calls };
}
test('getConfiguredWindowsMpvPathStatus classifies blank, configured and invalid paths', () => {
const exists = (candidate: string) => candidate === 'C:\\mpv\\mpv.exe';
assert.equal(getConfiguredWindowsMpvPathStatus(' ', exists), 'blank');
assert.equal(getConfiguredWindowsMpvPathStatus(' C:\\mpv\\mpv.exe ', exists), 'configured');
assert.equal(getConfiguredWindowsMpvPathStatus('C:\\missing\\mpv.exe', exists), 'invalid');
});
test('resolveWindowsMpvPath prefers a valid configured path and trims it', () => {
const { deps, calls } = makeWindowsDeps({
existing: ['C:\\cfg\\mpv.exe', 'C:\\env\\mpv.exe'],
env: { SUBMINER_MPV_PATH: 'C:\\env\\mpv.exe' },
});
assert.equal(resolveWindowsMpvPath(deps, ' C:\\cfg\\mpv.exe '), 'C:\\cfg\\mpv.exe');
assert.deepEqual(calls, []);
});
test('resolveWindowsMpvPath does not fall back when the configured path is invalid', () => {
const { deps, calls } = makeWindowsDeps({
existing: ['C:\\env\\mpv.exe'],
env: { SUBMINER_MPV_PATH: 'C:\\env\\mpv.exe' },
where: { status: 0, stdout: 'C:\\env\\mpv.exe\r\n' },
});
assert.equal(resolveWindowsMpvPath(deps, 'C:\\missing\\mpv.exe'), '');
assert.deepEqual(calls, []);
});
test('resolveWindowsMpvPath uses SUBMINER_MPV_PATH before searching PATH', () => {
const { deps, calls } = makeWindowsDeps({
existing: ['C:\\env\\mpv.exe'],
env: { SUBMINER_MPV_PATH: ' C:\\env\\mpv.exe ' },
});
assert.equal(resolveWindowsMpvPath(deps), 'C:\\env\\mpv.exe');
assert.deepEqual(calls, []);
});
test('resolveWindowsMpvPath takes the first where.exe line that exists', () => {
const { deps } = makeWindowsDeps({
existing: ['C:\\second\\mpv.exe', 'C:\\third\\mpv.exe'],
env: { SUBMINER_MPV_PATH: 'C:\\stale\\mpv.exe' },
where: {
status: 0,
stdout: '\r\nC:\\first\\mpv.exe\r\n C:\\second\\mpv.exe \r\nC:\\third\\mpv.exe\r\n',
},
});
assert.equal(resolveWindowsMpvPath(deps), 'C:\\second\\mpv.exe');
});
test('resolveWindowsMpvPath returns blank when nothing resolves', () => {
const failed = makeWindowsDeps({ where: { status: 1, stdout: 'C:\\first\\mpv.exe' } });
const blank = makeWindowsDeps({ where: { status: 0, stdout: '\r\n \r\n' } });
const missing = makeWindowsDeps({ where: { status: 0, stdout: 'C:\\first\\mpv.exe' } });
assert.equal(resolveWindowsMpvPath(failed.deps), '');
assert.equal(resolveWindowsMpvPath(blank.deps), '');
assert.equal(resolveWindowsMpvPath(missing.deps), '');
});
const X11_SPAWN_CASES = [
{
name: 'forces the X11 backend for an unsupported Wayland session',
compositorEnv: {},
forcesX11: true,
},
{
name: 'leaves a natively supported compositor session alone',
compositorEnv: { HYPRLAND_INSTANCE_SIGNATURE: 'sig' },
forcesX11: false,
},
];
for (const c of X11_SPAWN_CASES) {
testLinux(`spawnMpvProcess ${c.name}`, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-mpv-process-'));
// The child reports what it received so args and env can be asserted from outside.
const scriptPath = path.join(dir, 'probe.js');
const reportPath = path.join(dir, 'report.json');
fs.writeFileSync(
scriptPath,
`require('node:fs').writeFileSync(process.argv[2], JSON.stringify({
args: process.argv.slice(3),
wayland: process.env.WAYLAND_DISPLAY ?? null,
sessionType: process.env.XDG_SESSION_TYPE ?? null,
}));`,
);
const child = spawnMpvProcess(process.execPath, [scriptPath, reportPath, '--user-arg'], {
DISPLAY: ':0',
WAYLAND_DISPLAY: 'wayland-1',
XDG_SESSION_TYPE: 'wayland',
...c.compositorEnv,
});
try {
await once(child, 'exit');
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
if (c.forcesX11) {
assert.deepEqual(report, {
args: ['--user-arg', '--gpu-context=x11vk,x11egl,x11'],
wayland: null,
sessionType: 'x11',
});
} else {
assert.deepEqual(report, {
args: ['--user-arg'],
wayland: 'wayland-1',
sessionType: 'wayland',
});
}
} finally {
if (child.exitCode === null) child.kill();
fs.rmSync(dir, { recursive: true, force: true });
}
});
}
testWindows('managed Windows playback launches the configured executable', async () => {
const executable = resolveMpvExecutablePath(` ${process.execPath} `);
const child = spawnMpvProcess(executable, ['-e', 'process.exit(17)']);
@@ -1,45 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { MpvSubtitleRenderMetrics } from '../../types';
import { createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler } from './mpv-subtitle-render-metrics-main-deps';
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('mpv subtitle render metrics main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler({
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => calls.push('set'),
applyPatch: (current, patch) => {
calls.push('apply');
return { next: { ...current, ...patch }, changed: true };
},
broadcastMetrics: () => calls.push('broadcast'),
})();
assert.equal(deps.getCurrentMetrics().subPos, 100);
deps.setCurrentMetrics(BASE_METRICS);
const patched = deps.applyPatch(BASE_METRICS, { subPos: 90 });
deps.broadcastMetrics(BASE_METRICS);
assert.equal(patched.changed, true);
assert.equal(patched.next.subPos, 90);
assert.deepEqual(calls, ['set', 'apply', 'broadcast']);
});
@@ -1,29 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveOverlayReadinessNotificationType,
shouldShowDesktop,
shouldShowOverlay,
shouldShowOsd,
} from './notification-routing';
test('notification routing preserves system notification while overlay is not ready', () => {
assert.equal(resolveOverlayReadinessNotificationType('system', false), 'system');
});
test('notification routing preserves both while overlay is not ready', () => {
assert.equal(resolveOverlayReadinessNotificationType('both', false), 'both');
});
test('notification routing preserves overlay-only notification while overlay is not ready', () => {
assert.equal(resolveOverlayReadinessNotificationType('overlay', false), 'overlay');
});
test('notification routing predicates classify delivery channels', () => {
assert.equal(shouldShowOverlay('both'), true);
assert.equal(shouldShowOverlay('system'), false);
assert.equal(shouldShowOsd('osd-system'), true);
assert.equal(shouldShowOsd('both'), false);
assert.equal(shouldShowDesktop('osd-system'), true);
assert.equal(shouldShowDesktop('overlay'), false);
});
@@ -1,35 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildNumericShortcutRuntimeMainDepsHandler } from './numeric-shortcut-runtime-main-deps';
test('numeric shortcut runtime main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildNumericShortcutRuntimeMainDepsHandler({
globalShortcut: {
register: () => true,
unregister: () => {
calls.push('unregister');
},
},
showMpvOsd: (text) => calls.push(`osd:${text}`),
setTimer: (handler) => {
calls.push('timer');
handler();
return 1 as never;
},
clearTimer: () => {
calls.push('clear');
},
})();
assert.equal(
deps.globalShortcut.register('1', () => {}),
true,
);
deps.globalShortcut.unregister('1');
deps.showMpvOsd('x');
deps.setTimer(() => calls.push('tick'), 1000);
deps.clearTimer(1 as never);
assert.deepEqual(calls, ['unregister', 'osd:x', 'timer', 'tick', 'clear']);
});
@@ -1,42 +0,0 @@
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']);
});
@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createBuildCancelNumericShortcutSessionMainDepsHandler,
createBuildStartNumericShortcutSessionMainDepsHandler,
} from './numeric-shortcut-session-main-deps';
test('numeric shortcut session main deps builders map callbacks', () => {
const calls: string[] = [];
const session = {
start: () => calls.push('start'),
cancel: () => calls.push('cancel'),
};
const cancel = createBuildCancelNumericShortcutSessionMainDepsHandler({ session })();
cancel.session.cancel();
const start = createBuildStartNumericShortcutSessionMainDepsHandler({
session,
onDigit: (digit) => calls.push(`digit:${digit}`),
messages: {
prompt: 'prompt',
timeout: 'timeout',
cancelled: 'cancelled',
},
})();
start.session.start({
timeoutMs: 100,
onDigit: () => {},
messages: start.messages,
});
start.onDigit(4);
assert.equal(start.messages.prompt, 'prompt');
assert.equal(start.messages.timeout, 'timeout');
assert.equal(start.messages.cancelled, 'cancelled');
assert.deepEqual(calls, ['cancel', 'start', 'digit:4']);
});
Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff. Show more