mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
test: cut low-value tests, rewrite the rest around behavior, parallelize lanes (#285)
This commit is contained in:
1 parent
4b6842d034
commit
b48083ddd8
368 files changed
+18045
-60331
No files matched your search
@@ -5,8 +5,6 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import {
|
||||
APPIMAGE_MOUNT_KEEPALIVE_LABEL,
|
||||
APPIMAGE_MOUNT_KEEPALIVE_SCRIPT,
|
||||
resolveAppImageMountKeepaliveInvocation,
|
||||
resolveMountFsType,
|
||||
} from './appimage-mount-keepalive';
|
||||
@@ -79,32 +77,17 @@ test('resolveMountFsType picks the longest containing mount point', () => {
|
||||
assert.equal(resolveMountFsType('/usr/bin/sh', ''), null);
|
||||
});
|
||||
|
||||
test('resolveAppImageMountKeepaliveInvocation builds sh invocation with AppImage path', () => {
|
||||
const invocation = resolveOnFuse({ APPIMAGE: '/opt/SubMiner.AppImage' }, 'linux');
|
||||
assert.ok(invocation);
|
||||
assert.equal(invocation.command, '/bin/sh');
|
||||
assert.deepEqual(invocation.args, [
|
||||
'-c',
|
||||
APPIMAGE_MOUNT_KEEPALIVE_SCRIPT,
|
||||
APPIMAGE_MOUNT_KEEPALIVE_LABEL,
|
||||
'/opt/SubMiner.AppImage',
|
||||
]);
|
||||
});
|
||||
|
||||
function runKeepaliveScript(
|
||||
appImagePath: string,
|
||||
extraArgs: string[] = [],
|
||||
): Promise<{ status: number }> {
|
||||
// Run the exact invocation the app would spawn, so the argv is exercised too.
|
||||
const invocation = resolveOnFuse({ APPIMAGE: appImagePath }, 'linux');
|
||||
assert.ok(invocation);
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
'/bin/sh',
|
||||
[
|
||||
'-c',
|
||||
APPIMAGE_MOUNT_KEEPALIVE_SCRIPT,
|
||||
APPIMAGE_MOUNT_KEEPALIVE_LABEL,
|
||||
appImagePath,
|
||||
...extraArgs,
|
||||
],
|
||||
invocation.command,
|
||||
[...invocation.args, ...extraArgs],
|
||||
{ timeout: 30_000 },
|
||||
(error) => {
|
||||
if (error && typeof error.code !== 'number') {
|
||||
|
||||
+133
-140
@@ -1,109 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { ConfigStartupParseError } from '../../config';
|
||||
import { createMainBootServices } from './services';
|
||||
|
||||
test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
type MockAppLifecycleApp = {
|
||||
requestSingleInstanceLock: () => boolean;
|
||||
quit: () => void;
|
||||
exit: (code?: number) => void;
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => MockAppLifecycleApp;
|
||||
whenReady: () => Promise<void>;
|
||||
};
|
||||
|
||||
const calls: string[] = [];
|
||||
let setPathValue: string | null = null;
|
||||
const appOnCalls: string[] = [];
|
||||
let secondInstanceHandlerRegistered = false;
|
||||
|
||||
const services = createMainBootServices<
|
||||
{ configDir: string },
|
||||
{ targetPath: string },
|
||||
{ targetPath: string },
|
||||
{ targetPath: string },
|
||||
{ kind: string; payloadMode: 'plain' | 'annotated' },
|
||||
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
||||
{ registry: boolean },
|
||||
{ getMainWindow: () => null; getModalWindow: () => null },
|
||||
{
|
||||
inputState: boolean;
|
||||
getModalInputExclusive: () => boolean;
|
||||
handleModalInputStateChange: (isActive: boolean) => void;
|
||||
},
|
||||
{ measurementStore: boolean },
|
||||
{ modalRuntime: boolean },
|
||||
{ mpvSocketPath: string; texthookerPort: number },
|
||||
MockAppLifecycleApp
|
||||
>({
|
||||
platform: 'linux',
|
||||
// Minimal params whose factories echo their inputs, so tests can assert what each service received.
|
||||
function baseParams() {
|
||||
return {
|
||||
platform: 'linux' as NodeJS.Platform,
|
||||
argv: ['node', 'main.ts'],
|
||||
appDataDir: undefined,
|
||||
xdgConfigHome: undefined,
|
||||
homeDir: '/home/tester',
|
||||
defaultMpvLogFile: '/tmp/default.log',
|
||||
envMpvLog: ' /tmp/custom.log ',
|
||||
envMpvLog: undefined as string | undefined,
|
||||
defaultTexthookerPort: 5174,
|
||||
getDefaultSocketPath: () => '/tmp/subminer.sock',
|
||||
resolveConfigDir: () => '/tmp/subminer-config',
|
||||
existsSync: () => false,
|
||||
mkdirSync: (targetPath) => {
|
||||
calls.push(`mkdir:${targetPath}`);
|
||||
},
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
existsSync: (_targetPath: string) => false,
|
||||
mkdirSync: (_targetPath: string) => {},
|
||||
joinPath: (...parts: string[]) => parts.join('/'),
|
||||
app: {
|
||||
setPath: (_name, value) => {
|
||||
setPathValue = value;
|
||||
},
|
||||
setPath: (_name: string, _value: string) => {},
|
||||
quit: () => {},
|
||||
exit: (code?: number) => {
|
||||
calls.push(`exit:${code ?? 0}`);
|
||||
},
|
||||
on: (event: string) => {
|
||||
appOnCalls.push(event);
|
||||
return {};
|
||||
},
|
||||
exit: (_code?: number) => {},
|
||||
on: (_event: string, _listener: unknown) => ({}),
|
||||
whenReady: async () => {},
|
||||
},
|
||||
shouldBypassSingleInstanceLock: () => false,
|
||||
requestSingleInstanceLockEarly: () => true,
|
||||
registerSecondInstanceHandlerEarly: () => {
|
||||
secondInstanceHandlerRegistered = true;
|
||||
},
|
||||
onConfigStartupParseError: () => {
|
||||
throw new Error('unexpected parse failure');
|
||||
},
|
||||
createConfigService: (configDir) => ({ configDir }),
|
||||
createAnilistTokenStore: (targetPath) => ({ targetPath }),
|
||||
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
|
||||
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
|
||||
createSubtitleWebSocket: (payloadMode) => ({ kind: 'ws', payloadMode }),
|
||||
createLogger: (scope) =>
|
||||
({
|
||||
scope,
|
||||
warn: () => {},
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
}) as const,
|
||||
createMainRuntimeRegistry: () => ({ registry: true }),
|
||||
createOverlayManager: () => ({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
}),
|
||||
registerSecondInstanceHandlerEarly: (_listener: unknown) => {},
|
||||
onConfigStartupParseError: (_error: ConfigStartupParseError) => {},
|
||||
createConfigService: (configDir: string) => ({ configDir }),
|
||||
createAnilistTokenStore: (targetPath: string) => ({ targetPath }),
|
||||
createJellyfinTokenStore: (targetPath: string) => ({ targetPath }),
|
||||
createAnilistUpdateQueue: (targetPath: string) => ({ targetPath }),
|
||||
createSubtitleWebSocket: (payloadMode: 'plain' | 'annotated') => ({ payloadMode }),
|
||||
createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }),
|
||||
createMainRuntimeRegistry: () => ({}),
|
||||
createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }),
|
||||
createOverlayModalInputState: () => ({
|
||||
inputState: true,
|
||||
getModalInputExclusive: () => false,
|
||||
handleModalInputStateChange: () => {},
|
||||
handleModalInputStateChange: (_isActive: boolean) => {},
|
||||
}),
|
||||
createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
|
||||
getSyncOverlayShortcutsForModal: () => () => {},
|
||||
createOverlayContentMeasurementStore: () => ({}),
|
||||
getSyncOverlayShortcutsForModal: () => (_isActive: boolean) => {},
|
||||
getSyncOverlayVisibilityForModal: () => () => {},
|
||||
createOverlayModalRuntime: () => ({ modalRuntime: true }),
|
||||
createAppState: (input) => ({ ...input }),
|
||||
});
|
||||
createOverlayModalRuntime: () => ({}),
|
||||
createAppState: (input: { mpvSocketPath: string; texthookerPort: number }) => input,
|
||||
};
|
||||
}
|
||||
|
||||
function createServices(
|
||||
overrides: Partial<ReturnType<typeof baseParams>> & { configDir?: string },
|
||||
) {
|
||||
return createMainBootServices({ ...baseParams(), ...overrides });
|
||||
}
|
||||
|
||||
test('createMainBootServices derives data paths from the resolved config dir', () => {
|
||||
const services = createServices({});
|
||||
|
||||
assert.equal(services.configDir, '/tmp/subminer-config');
|
||||
assert.equal(services.userDataPath, '/tmp/subminer-config');
|
||||
assert.equal(services.defaultMpvLogPath, '/tmp/custom.log');
|
||||
assert.equal(services.defaultImmersionDbPath, '/tmp/subminer-config/immersion.sqlite');
|
||||
assert.deepEqual(services.configService, { configDir: '/tmp/subminer-config' });
|
||||
assert.deepEqual(services.anilistTokenStore, {
|
||||
@@ -115,79 +72,115 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
assert.deepEqual(services.anilistUpdateQueue, {
|
||||
targetPath: '/tmp/subminer-config/anilist-retry-queue.json',
|
||||
});
|
||||
assert.deepEqual(services.subtitleWsService, { kind: 'ws', payloadMode: 'plain' });
|
||||
assert.deepEqual(services.annotationSubtitleWsService, {
|
||||
kind: 'ws',
|
||||
payloadMode: 'annotated',
|
||||
});
|
||||
assert.deepEqual(services.subtitleWsService, { payloadMode: 'plain' });
|
||||
assert.deepEqual(services.annotationSubtitleWsService, { payloadMode: 'annotated' });
|
||||
assert.deepEqual(services.appState, {
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
texthookerPort: 5174,
|
||||
});
|
||||
assert.equal(
|
||||
services.appLifecycleApp.on('ready', () => {}),
|
||||
services.appLifecycleApp,
|
||||
);
|
||||
assert.equal(
|
||||
services.appLifecycleApp.on('second-instance', () => {}),
|
||||
services.appLifecycleApp,
|
||||
);
|
||||
services.appLifecycleApp.exit(7);
|
||||
assert.deepEqual(appOnCalls, ['ready']);
|
||||
assert.equal(secondInstanceHandlerRegistered, true);
|
||||
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
|
||||
assert.equal(setPathValue, '/tmp/subminer-config');
|
||||
});
|
||||
|
||||
test('createMainBootServices creates the user data dir only when missing and registers it', () => {
|
||||
for (const exists of [false, true]) {
|
||||
const created: string[] = [];
|
||||
const setPaths: Array<[string, string]> = [];
|
||||
createServices({
|
||||
existsSync: () => exists,
|
||||
mkdirSync: (targetPath) => {
|
||||
created.push(targetPath);
|
||||
},
|
||||
app: {
|
||||
...baseParams().app,
|
||||
setPath: (name, value) => {
|
||||
setPaths.push([name, value]);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(created, exists ? [] : ['/tmp/subminer-config']);
|
||||
assert.deepEqual(setPaths, [['userData', '/tmp/subminer-config']]);
|
||||
}
|
||||
});
|
||||
|
||||
test('createMainBootServices uses a trimmed MPV log env path and falls back when blank', () => {
|
||||
const cases = [
|
||||
{ envMpvLog: ' /tmp/custom.log ', expected: '/tmp/custom.log' },
|
||||
{ envMpvLog: ' ', expected: '/tmp/default.log' },
|
||||
{ envMpvLog: undefined, expected: '/tmp/default.log' },
|
||||
];
|
||||
for (const { envMpvLog, expected } of cases) {
|
||||
assert.equal(createServices({ envMpvLog }).defaultMpvLogPath, expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('createMainBootServices honors the profile selected by the early entrypoint', () => {
|
||||
const services = createMainBootServices({
|
||||
platform: 'linux',
|
||||
argv: ['electron', '.', '--dev'],
|
||||
const services = createServices({
|
||||
configDir: '/tmp/SubMiner-dev',
|
||||
appDataDir: undefined,
|
||||
xdgConfigHome: undefined,
|
||||
homeDir: '/home/tester',
|
||||
defaultMpvLogFile: '/tmp/default.log',
|
||||
envMpvLog: undefined,
|
||||
defaultTexthookerPort: 5174,
|
||||
getDefaultSocketPath: () => '/tmp/subminer.sock',
|
||||
resolveConfigDir: () => {
|
||||
throw new Error('early profile should be authoritative');
|
||||
},
|
||||
existsSync: () => false,
|
||||
mkdirSync: () => {},
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
app: {
|
||||
setPath: () => {},
|
||||
quit: () => {},
|
||||
exit: () => {},
|
||||
on: () => ({}),
|
||||
whenReady: async () => {},
|
||||
},
|
||||
shouldBypassSingleInstanceLock: () => false,
|
||||
requestSingleInstanceLockEarly: () => true,
|
||||
registerSecondInstanceHandlerEarly: () => {},
|
||||
onConfigStartupParseError: () => {},
|
||||
createConfigService: (configDir) => ({ configDir }),
|
||||
createAnilistTokenStore: (targetPath) => ({ targetPath }),
|
||||
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
|
||||
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
|
||||
createSubtitleWebSocket: (payloadMode) => ({ payloadMode }),
|
||||
createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }),
|
||||
createMainRuntimeRegistry: () => ({}),
|
||||
createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }),
|
||||
createOverlayModalInputState: () => ({
|
||||
getModalInputExclusive: () => false,
|
||||
handleModalInputStateChange: () => {},
|
||||
}),
|
||||
createOverlayContentMeasurementStore: () => ({}),
|
||||
getSyncOverlayShortcutsForModal: () => () => {},
|
||||
getSyncOverlayVisibilityForModal: () => () => {},
|
||||
createOverlayModalRuntime: () => ({}),
|
||||
createAppState: (input) => input,
|
||||
});
|
||||
|
||||
assert.equal(services.configDir, '/tmp/SubMiner-dev');
|
||||
assert.equal(services.userDataPath, '/tmp/SubMiner-dev');
|
||||
assert.deepEqual(services.configService, { configDir: '/tmp/SubMiner-dev' });
|
||||
});
|
||||
|
||||
test('createMainBootServices routes second-instance listeners to the early handler', () => {
|
||||
const appEvents: string[] = [];
|
||||
const earlyListeners: unknown[] = [];
|
||||
const services = createServices({
|
||||
app: {
|
||||
...baseParams().app,
|
||||
on: (event) => {
|
||||
appEvents.push(event);
|
||||
return {};
|
||||
},
|
||||
},
|
||||
registerSecondInstanceHandlerEarly: (listener) => {
|
||||
earlyListeners.push(listener);
|
||||
},
|
||||
});
|
||||
|
||||
const secondInstanceListener = () => {};
|
||||
services.appLifecycleApp.on('ready', () => {});
|
||||
services.appLifecycleApp.on('second-instance', secondInstanceListener);
|
||||
|
||||
assert.deepEqual(appEvents, ['ready']);
|
||||
assert.deepEqual(earlyListeners, [secondInstanceListener]);
|
||||
});
|
||||
|
||||
test('createMainBootServices skips the single-instance lock when bypassed', () => {
|
||||
for (const bypass of [false, true]) {
|
||||
let lockRequests = 0;
|
||||
const services = createServices({
|
||||
shouldBypassSingleInstanceLock: () => bypass,
|
||||
requestSingleInstanceLockEarly: () => {
|
||||
lockRequests += 1;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(services.appLifecycleApp.requestSingleInstanceLock(), bypass);
|
||||
assert.equal(lockRequests, bypass ? 0 : 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('createMainBootServices reports config parse errors and rethrows', () => {
|
||||
const parseError = new ConfigStartupParseError('/tmp/config.jsonc', 'unexpected token');
|
||||
const reported: ConfigStartupParseError[] = [];
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
createServices({
|
||||
createConfigService: () => {
|
||||
throw parseError;
|
||||
},
|
||||
onConfigStartupParseError: (error) => {
|
||||
reported.push(error);
|
||||
},
|
||||
}),
|
||||
(error) => error === parseError,
|
||||
);
|
||||
assert.deepEqual(reported, [parseError]);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -121,18 +121,3 @@ test('readSnapshot ignores snapshots written with an older format version', asyn
|
||||
|
||||
assert.equal(await readSnapshot(snapshotPath), null);
|
||||
});
|
||||
|
||||
test('readSnapshot ignores v15 snapshots with stale romanized character-name entries', async () => {
|
||||
const outputDir = makeTempDir();
|
||||
const snapshotPath = getSnapshotPath(outputDir, 130298);
|
||||
const staleSnapshot = {
|
||||
...createSnapshot(),
|
||||
formatVersion: 15,
|
||||
termEntries: [['Vanir', 'ばにる', 'name primary', '', 75, ['Vanir'], 0, '']],
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(staleSnapshot), 'utf8');
|
||||
|
||||
assert.equal(await readSnapshot(snapshotPath), null);
|
||||
});
|
||||
@@ -161,6 +161,36 @@ test('getManualSelectionSnapshot hydrates override episode count from searched c
|
||||
}
|
||||
});
|
||||
|
||||
test('getManualSelectionSnapshot falls back to mpv current video path when app media path is not ready', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const mpvPath =
|
||||
'C:\\Videos\\KonoSuba - God’s blessing on this wonderful world!! (2016) - S02E05.mkv';
|
||||
const calls: Array<{ mediaPath: string | null; mediaTitle: string | null }> = [];
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => null,
|
||||
getCurrentVideoPath: () => mpvPath,
|
||||
getCurrentMediaTitle: () => null,
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async (mediaPath, mediaTitle) => {
|
||||
calls.push({ mediaPath, mediaTitle });
|
||||
return {
|
||||
title: 'KonoSuba - God’s blessing on this wonderful world!!',
|
||||
season: 2,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
};
|
||||
},
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const snapshot = await runtime.getManualSelectionSnapshot(undefined, '');
|
||||
|
||||
assert.deepEqual(calls, [{ mediaPath: mpvPath, mediaTitle: null }]);
|
||||
assert.equal(snapshot.guessTitle, 'KonoSuba - God’s blessing on this wonderful world!!');
|
||||
assert.equal(snapshot.candidates.length, 0);
|
||||
});
|
||||
|
||||
test('resolvePinnedMediaId returns the manual override for the current season', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const seasonThreePath = '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv';
|
||||
|
||||
@@ -106,12 +106,14 @@ test('returns null for a media with no cached snapshot', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('key changes when the snapshot content changes', async () => {
|
||||
test('invalidate picks up a snapshot change within the recheck interval and changes the key', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
|
||||
// Frozen clock: only invalidate() can trigger the refresh below.
|
||||
const lookup = createCharacterNameCandidateLookup({
|
||||
outputDir: dir,
|
||||
getCurrentMediaId: () => 1,
|
||||
now: () => 1_000_000,
|
||||
});
|
||||
const first = await waitForRefresh(() => lookup.get());
|
||||
|
||||
@@ -162,31 +164,3 @@ test('does not re-read the snapshot directory on every lookup', async () => {
|
||||
assert.equal(refreshed.forms.length, 4, 'expected a refresh past the interval');
|
||||
});
|
||||
});
|
||||
|
||||
test('invalidate picks up a snapshot change on the next refresh', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
|
||||
let nowMs = 1_000_000;
|
||||
const lookup = createCharacterNameCandidateLookup({
|
||||
outputDir: dir,
|
||||
getCurrentMediaId: () => 1,
|
||||
now: () => nowMs,
|
||||
});
|
||||
|
||||
await waitForRefresh(() => lookup.get());
|
||||
assert.equal(lookup.get()?.forms.length, 2);
|
||||
|
||||
writeSnapshot(dir, 1, [
|
||||
['ミナト', 'みなと'],
|
||||
['アクア', 'あくあ'],
|
||||
]);
|
||||
nowMs += 1;
|
||||
lookup.invalidate();
|
||||
|
||||
const refreshed = await waitForRefresh(() => {
|
||||
const candidates = lookup.get();
|
||||
return candidates && candidates.forms.length === 4 ? candidates : null;
|
||||
});
|
||||
assert.equal(refreshed.forms.length, 4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { TestContext } from 'node:test';
|
||||
|
||||
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
||||
import { ANILIST_GRAPHQL_URL } from './constants';
|
||||
import type { CharacterDictionaryRuntimeDeps, CharacterDictionaryTermEntry } from './types';
|
||||
|
||||
// Shared fixtures for the character dictionary runtime tests: a fake AniList GraphQL + image
|
||||
// server, a runtime factory with Eminence in Shadow defaults, and readers for the built ZIP.
|
||||
|
||||
export const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
export function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-'));
|
||||
}
|
||||
|
||||
type FetchHandler = (url: string, init?: RequestInit) => Promise<Response> | Response;
|
||||
|
||||
/** Replaces `globalThis.fetch` for the duration of the test. */
|
||||
export function installFetch(t: TestContext, handler: FetchHandler): void {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
return handler(url, init);
|
||||
}) as typeof globalThis.fetch;
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
}
|
||||
|
||||
export function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
/** AniList `characters.edges[]` item, as returned by the GraphQL API. */
|
||||
export type FakeAniListCharacterEdge = {
|
||||
role: string;
|
||||
voiceActors?: Array<{
|
||||
id: number;
|
||||
name: { full?: string; native?: string };
|
||||
image?: { large?: string | null; medium?: string | null };
|
||||
}>;
|
||||
node: {
|
||||
id: number;
|
||||
description?: string;
|
||||
image?: { large?: string | null; medium?: string | null } | null;
|
||||
name: {
|
||||
full?: string;
|
||||
native?: string;
|
||||
first?: string | null;
|
||||
last?: string | null;
|
||||
alternative?: string[];
|
||||
};
|
||||
gender?: string;
|
||||
age?: string;
|
||||
dateOfBirth?: { month: number; day: number };
|
||||
bloodType?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type FakeAniListMedia = {
|
||||
id: number;
|
||||
episodes?: number;
|
||||
title: { romaji?: string; english?: string; native?: string };
|
||||
/** Search text that resolves to this media. Unmatched searches resolve to the first media. */
|
||||
search?: string;
|
||||
characters?: FakeAniListCharacterEdge[];
|
||||
};
|
||||
|
||||
export type FakeAniList = {
|
||||
/** Every fetched URL, in request order. */
|
||||
requests: string[];
|
||||
searchCount: number;
|
||||
characterPageCount: number;
|
||||
imageRequests: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Serves AniList media search and character page queries from `media`, and image downloads for
|
||||
* `images` (PNG) and `missingImages` (404). Any other request throws.
|
||||
*/
|
||||
export function installFakeAniList(
|
||||
t: TestContext,
|
||||
options: { media?: FakeAniListMedia[]; images?: string[]; missingImages?: string[] } = {},
|
||||
): FakeAniList {
|
||||
const media = options.media ?? [];
|
||||
const images = new Set(options.images ?? []);
|
||||
const missingImages = new Set(options.missingImages ?? []);
|
||||
const fake: FakeAniList = {
|
||||
requests: [],
|
||||
searchCount: 0,
|
||||
characterPageCount: 0,
|
||||
imageRequests: [],
|
||||
};
|
||||
|
||||
installFetch(t, (url, init) => {
|
||||
fake.requests.push(url);
|
||||
if (url === ANILIST_GRAPHQL_URL) {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||
query?: string;
|
||||
variables?: { search?: string; id?: number };
|
||||
};
|
||||
|
||||
if (body.query?.includes('Page(perPage: 10)') && media.length > 0) {
|
||||
fake.searchCount += 1;
|
||||
const match = media.find((entry) => entry.search === body.variables?.search) ?? media[0]!;
|
||||
return jsonResponse({
|
||||
data: {
|
||||
Page: { media: [{ id: match.id, episodes: match.episodes ?? 12, title: match.title }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const pageMedia = media.find((entry) => entry.id === body.variables?.id);
|
||||
if (body.query?.includes('characters(page: $page') && pageMedia) {
|
||||
fake.characterPageCount += 1;
|
||||
return jsonResponse({
|
||||
data: {
|
||||
Media: {
|
||||
title: pageMedia.title,
|
||||
characters: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
edges: pageMedia.characters ?? [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (images.has(url) || missingImages.has(url)) {
|
||||
fake.imageRequests.push(url);
|
||||
return images.has(url)
|
||||
? new Response(PNG_1X1, { status: 200, headers: { 'content-type': 'image/png' } })
|
||||
: new Response('missing', { status: 404, headers: { 'content-type': 'text/plain' } });
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
|
||||
return fake;
|
||||
}
|
||||
|
||||
export const EMINENCE_MEDIA_ID = 130298;
|
||||
export const EMINENCE_TITLE = {
|
||||
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
|
||||
english: 'The Eminence in Shadow',
|
||||
native: '陰の実力者になりたくて!',
|
||||
};
|
||||
|
||||
export type CharacterDictionaryRuntime = ReturnType<typeof createCharacterDictionaryRuntimeService>;
|
||||
|
||||
/** Creates a runtime watching an Eminence in Shadow episode. AniList pacing sleeps are skipped. */
|
||||
export function createRuntime(overrides: Partial<CharacterDictionaryRuntimeDeps> = {}): {
|
||||
runtime: CharacterDictionaryRuntime;
|
||||
userDataPath: string;
|
||||
outputDir: string;
|
||||
} {
|
||||
const userDataPath = overrides.userDataPath ?? makeTempDir();
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
now: () => 1_700_000_000_000,
|
||||
sleep: async () => undefined,
|
||||
...overrides,
|
||||
userDataPath,
|
||||
});
|
||||
return { runtime, userDataPath, outputDir: path.join(userDataPath, 'character-dictionaries') };
|
||||
}
|
||||
|
||||
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
||||
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
||||
|
||||
export function readStoredZipEntry(zipPath: string, entryName: string): Buffer {
|
||||
const archive = fs.readFileSync(zipPath);
|
||||
let offset = 0;
|
||||
|
||||
while (offset + 4 <= archive.length) {
|
||||
const signature = archive.readUInt32LE(offset);
|
||||
if (
|
||||
signature === CENTRAL_DIRECTORY_SIGNATURE ||
|
||||
signature === END_OF_CENTRAL_DIRECTORY_SIGNATURE
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (signature !== LOCAL_FILE_HEADER_SIGNATURE) {
|
||||
throw new Error(`Unexpected ZIP signature 0x${signature.toString(16)} at offset ${offset}`);
|
||||
}
|
||||
|
||||
const compressionMethod = archive.readUInt16LE(offset + 8);
|
||||
assert.equal(compressionMethod, 0, 'expected stored ZIP entry');
|
||||
const compressedSize = archive.readUInt32LE(offset + 18);
|
||||
const fileNameLength = archive.readUInt16LE(offset + 26);
|
||||
const extraFieldLength = archive.readUInt16LE(offset + 28);
|
||||
const fileNameStart = offset + 30;
|
||||
const fileNameEnd = fileNameStart + fileNameLength;
|
||||
const fileName = archive.subarray(fileNameStart, fileNameEnd).toString('utf8');
|
||||
const dataStart = fileNameEnd + extraFieldLength;
|
||||
const dataEnd = dataStart + compressedSize;
|
||||
|
||||
if (fileName === entryName) {
|
||||
return archive.subarray(dataStart, dataEnd);
|
||||
}
|
||||
|
||||
offset = dataEnd;
|
||||
}
|
||||
|
||||
throw new Error(`ZIP entry not found: ${entryName}`);
|
||||
}
|
||||
|
||||
export function readTermBank(zipPath: string): CharacterDictionaryTermEntry[] {
|
||||
return JSON.parse(
|
||||
readStoredZipEntry(zipPath, 'term_bank_1.json').toString('utf8'),
|
||||
) as CharacterDictionaryTermEntry[];
|
||||
}
|
||||
|
||||
type StructuredNode = { tag?: string; open?: boolean; content?: unknown };
|
||||
|
||||
/** Top-level structured-content children of a term entry's glossary. */
|
||||
export function getGlossaryChildren(entry: CharacterDictionaryTermEntry): StructuredNode[] {
|
||||
const glossary = entry[5][0] as { content: { content: StructuredNode[] } };
|
||||
return glossary.content.content;
|
||||
}
|
||||
|
||||
/** Finds a collapsible `details` section by its summary title. */
|
||||
export function findSection(
|
||||
children: StructuredNode[],
|
||||
title: string,
|
||||
): (StructuredNode & { content: StructuredNode[] }) | undefined {
|
||||
return children.find(
|
||||
(child): child is StructuredNode & { content: StructuredNode[] } =>
|
||||
child.tag === 'details' &&
|
||||
Array.isArray(child.content) &&
|
||||
(child.content[0] as StructuredNode | undefined)?.content === title,
|
||||
);
|
||||
}
|
||||
@@ -1,219 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import test from 'node:test';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
|
||||
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
||||
import { getSnapshotPath, writeSnapshot } from './cache';
|
||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||
import type { CharacterDictionarySnapshot, NameSplitTokenizer } from './types';
|
||||
import { ANILIST_GRAPHQL_URL, CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||
import {
|
||||
EMINENCE_MEDIA_ID,
|
||||
EMINENCE_TITLE,
|
||||
PNG_1X1,
|
||||
createRuntime,
|
||||
installFakeAniList,
|
||||
installFetch,
|
||||
jsonResponse,
|
||||
type FakeAniListCharacterEdge,
|
||||
} from './runtime-test-harness';
|
||||
import type {
|
||||
CharacterDictionaryRuntimeDeps,
|
||||
CharacterDictionarySnapshot,
|
||||
NameSplitTokenizer,
|
||||
} from './types';
|
||||
|
||||
const GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-'));
|
||||
}
|
||||
|
||||
function createSnapshotWithoutImages(): CharacterDictionarySnapshot {
|
||||
function createSnapshotWithoutImages(
|
||||
overrides: Partial<CharacterDictionarySnapshot> = {},
|
||||
): CharacterDictionarySnapshot {
|
||||
return {
|
||||
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
||||
mediaId: 130298,
|
||||
mediaId: EMINENCE_MEDIA_ID,
|
||||
mediaTitle: 'The Eminence in Shadow',
|
||||
entryCount: 1,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
termEntries: [['アレクシア', 'あれくしあ', 'name primary', '', 75, ['Alexia'], 0, '']],
|
||||
images: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('generateForCurrentMedia refreshes same-version snapshots missing images when inline images are enabled', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
await writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchUrls: string[] = [];
|
||||
/** Seeds a cached snapshot, then returns a runtime that will find it for the current episode. */
|
||||
async function createRuntimeWithSnapshot(
|
||||
snapshot: CharacterDictionarySnapshot,
|
||||
overrides: Partial<CharacterDictionaryRuntimeDeps> = {},
|
||||
) {
|
||||
const harness = createRuntime({ now: () => 1_700_000_000_500, ...overrides });
|
||||
await writeSnapshot(getSnapshotPath(harness.outputDir, EMINENCE_MEDIA_ID), snapshot);
|
||||
return harness;
|
||||
}
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
fetchUrls.push(url);
|
||||
function readStoredSnapshot(outputDir: string): CharacterDictionarySnapshot {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(getSnapshotPath(outputDir, EMINENCE_MEDIA_ID), 'utf8'),
|
||||
) as CharacterDictionarySnapshot;
|
||||
}
|
||||
|
||||
if (url === GRAPHQL_URL) {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||
query?: string;
|
||||
};
|
||||
if (body.query?.includes('characters(page: $page')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Media: {
|
||||
title: {
|
||||
english: 'The Eminence in Shadow',
|
||||
},
|
||||
characters: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
edges: [
|
||||
{
|
||||
role: 'SUPPORTING',
|
||||
node: {
|
||||
id: 123,
|
||||
description: 'Alexia Midgar.',
|
||||
image: {
|
||||
large: 'https://cdn.example.com/character-123.png',
|
||||
medium: null,
|
||||
},
|
||||
name: {
|
||||
full: 'Alexia Midgar',
|
||||
native: 'アレクシア・ミドガル',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
function installCharacterPage(t: TestContext, edge: FakeAniListCharacterEdge, images?: string[]) {
|
||||
return installFakeAniList(t, {
|
||||
media: [
|
||||
{ id: EMINENCE_MEDIA_ID, title: { english: EMINENCE_TITLE.english }, characters: [edge] },
|
||||
],
|
||||
images,
|
||||
});
|
||||
}
|
||||
|
||||
if (url === 'https://cdn.example.com/character-123.png') {
|
||||
return new Response(PNG_1X1, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/png' },
|
||||
});
|
||||
}
|
||||
test('generateForCurrentMedia refreshes same-version snapshots missing images when inline images are enabled', async (t) => {
|
||||
const imageUrl = 'https://cdn.example.com/character-123.png';
|
||||
const anilist = installCharacterPage(
|
||||
t,
|
||||
{
|
||||
role: 'SUPPORTING',
|
||||
node: {
|
||||
id: 123,
|
||||
description: 'Alexia Midgar.',
|
||||
image: { large: imageUrl, medium: null },
|
||||
name: { full: 'Alexia Midgar', native: 'アレクシア・ミドガル' },
|
||||
},
|
||||
},
|
||||
[imageUrl],
|
||||
);
|
||||
const { runtime, outputDir } = await createRuntimeWithSnapshot(createSnapshotWithoutImages(), {
|
||||
getNameMatchImagesEnabled: () => true,
|
||||
});
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
getNameMatchImagesEnabled: () => true,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
const refreshedSnapshot = JSON.parse(
|
||||
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
||||
) as CharacterDictionarySnapshot;
|
||||
|
||||
assert.equal(result.fromCache, false);
|
||||
assert.ok(fetchUrls.includes(GRAPHQL_URL));
|
||||
assert.ok(refreshedSnapshot.images.some((image) => image.path === 'img/m130298-c123.png'));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
assert.equal(result.fromCache, false);
|
||||
assert.equal(anilist.characterPageCount, 1);
|
||||
assert.ok(
|
||||
readStoredSnapshot(outputDir).images.some((image) => image.path === 'img/m130298-c123.png'),
|
||||
);
|
||||
});
|
||||
|
||||
async function runNameSplitRefreshScenario(tokenizeJapaneseName: NameSplitTokenizer): Promise<{
|
||||
async function runNameSplitRefreshScenario(
|
||||
t: TestContext,
|
||||
tokenizeJapaneseName: NameSplitTokenizer,
|
||||
): Promise<{
|
||||
characterPageRequests: number;
|
||||
firstResultFromCache: boolean;
|
||||
refreshedNameSplitSource: CharacterDictionarySnapshot['nameSplitSource'];
|
||||
secondResultFromCache: boolean;
|
||||
}> {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
||||
...createSnapshotWithoutImages(),
|
||||
nameSplitSource: 'heuristic',
|
||||
const anilist = installCharacterPage(t, {
|
||||
role: 'SUPPORTING',
|
||||
node: {
|
||||
id: 123,
|
||||
description: 'Alexia Midgar.',
|
||||
image: { large: null, medium: null },
|
||||
name: { first: 'Taro', last: 'Yamada', full: 'Taro Yamada', native: '山田太郎' },
|
||||
},
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
let characterPageRequests = 0;
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
if (url === GRAPHQL_URL) {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as { query?: string };
|
||||
if (body.query?.includes('characters(page: $page')) {
|
||||
characterPageRequests += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Media: {
|
||||
title: { english: 'The Eminence in Shadow' },
|
||||
characters: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
edges: [
|
||||
{
|
||||
role: 'SUPPORTING',
|
||||
node: {
|
||||
id: 123,
|
||||
description: 'Alexia Midgar.',
|
||||
image: { large: null, medium: null },
|
||||
name: {
|
||||
first: 'Taro',
|
||||
last: 'Yamada',
|
||||
full: 'Taro Yamada',
|
||||
native: '山田太郎',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
const { runtime, outputDir } = await createRuntimeWithSnapshot(
|
||||
createSnapshotWithoutImages({ nameSplitSource: 'heuristic' }),
|
||||
{
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
tokenizeJapaneseName,
|
||||
getJapaneseNameTokenizerAvailable: () => true,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const firstResult = await runtime.generateForCurrentMedia();
|
||||
const refreshedSnapshot = JSON.parse(
|
||||
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
||||
) as CharacterDictionarySnapshot;
|
||||
const secondResult = await runtime.generateForCurrentMedia();
|
||||
const firstResult = await runtime.generateForCurrentMedia();
|
||||
const refreshedSnapshot = readStoredSnapshot(outputDir);
|
||||
const secondResult = await runtime.generateForCurrentMedia();
|
||||
|
||||
return {
|
||||
characterPageRequests,
|
||||
firstResultFromCache: firstResult.fromCache,
|
||||
refreshedNameSplitSource: refreshedSnapshot.nameSplitSource,
|
||||
secondResultFromCache: secondResult.fromCache,
|
||||
};
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
return {
|
||||
characterPageRequests: anilist.characterPageCount,
|
||||
firstResultFromCache: firstResult.fromCache,
|
||||
refreshedNameSplitSource: refreshedSnapshot.nameSplitSource,
|
||||
secondResultFromCache: secondResult.fromCache,
|
||||
};
|
||||
}
|
||||
|
||||
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
|
||||
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async (t) => {
|
||||
let tokenizerCalls = 0;
|
||||
const result = await runNameSplitRefreshScenario(async () => {
|
||||
const result = await runNameSplitRefreshScenario(t, async () => {
|
||||
tokenizerCalls += 1;
|
||||
return null;
|
||||
});
|
||||
@@ -225,9 +141,9 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
|
||||
assert.equal(tokenizerCalls, 2);
|
||||
});
|
||||
|
||||
test('generateForCurrentMedia caches completed MeCab refreshes with no resolved splits', async () => {
|
||||
test('generateForCurrentMedia caches completed MeCab refreshes with no resolved splits', async (t) => {
|
||||
let tokenizerCalls = 0;
|
||||
const result = await runNameSplitRefreshScenario(async () => {
|
||||
const result = await runNameSplitRefreshScenario(t, async () => {
|
||||
tokenizerCalls += 1;
|
||||
return [];
|
||||
});
|
||||
@@ -239,129 +155,53 @@ test('generateForCurrentMedia caches completed MeCab refreshes with no resolved
|
||||
assert.equal(tokenizerCalls, 1);
|
||||
});
|
||||
|
||||
test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
||||
...createSnapshotWithoutImages(),
|
||||
nameSplitSource: 'mecab',
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
const keptSnapshotCases: Array<{
|
||||
name: string;
|
||||
snapshot: CharacterDictionarySnapshot;
|
||||
deps: Partial<CharacterDictionaryRuntimeDeps>;
|
||||
}> = [
|
||||
{
|
||||
name: 'keeps mecab-split snapshots when MeCab is available',
|
||||
snapshot: createSnapshotWithoutImages({ nameSplitSource: 'mecab' }),
|
||||
deps: {
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
tokenizeJapaneseName: async () => null,
|
||||
getJapaneseNameTokenizerAvailable: () => true,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
|
||||
assert.equal(result.fromCache, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('generateForCurrentMedia keeps heuristic-split snapshots while MeCab is unavailable', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
||||
...createSnapshotWithoutImages(),
|
||||
nameSplitSource: 'heuristic',
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'keeps heuristic-split snapshots while MeCab is unavailable',
|
||||
snapshot: createSnapshotWithoutImages({ nameSplitSource: 'heuristic' }),
|
||||
deps: {
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
tokenizeJapaneseName: async () => null,
|
||||
getJapaneseNameTokenizerAvailable: () => false,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'keeps same-version snapshots without images when inline images are disabled',
|
||||
snapshot: createSnapshotWithoutImages(),
|
||||
deps: { getNameMatchImagesEnabled: () => false },
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, snapshot, deps } of keptSnapshotCases) {
|
||||
test(`generateForCurrentMedia ${name}`, async (t) => {
|
||||
// No media or images: any AniList request throws.
|
||||
installFakeAniList(t);
|
||||
const { runtime } = await createRuntimeWithSnapshot(snapshot, deps);
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
|
||||
assert.equal(result.fromCache, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('generateForCurrentMedia keeps same-version snapshots without images when inline images are disabled', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
await writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
|
||||
assert.equal(result.fromCache, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('an unresolvable season is not cached as a normal AniList match', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const originalFetch = globalThis.fetch;
|
||||
test('an unresolvable season is not cached as a normal AniList match', async (t) => {
|
||||
let searchCalls = 0;
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
if (url !== GRAPHQL_URL) {
|
||||
installFetch(t, (url, init) => {
|
||||
if (url !== ANILIST_GRAPHQL_URL) {
|
||||
return new Response(PNG_1X1, { status: 200, headers: { 'content-type': 'image/png' } });
|
||||
}
|
||||
|
||||
@@ -371,84 +211,66 @@ test('an unresolvable season is not cached as a normal AniList match', async ()
|
||||
};
|
||||
|
||||
if (body.query?.includes('characters(page: $page')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Media: {
|
||||
title: { english: 'My Teen Romantic Comedy SNAFU' },
|
||||
characters: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
edges: [
|
||||
{
|
||||
role: 'MAIN',
|
||||
node: { id: 1, name: { full: 'Hachiman Hikigaya', native: '比企谷八幡' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
return jsonResponse({
|
||||
data: {
|
||||
Media: {
|
||||
title: { english: 'My Teen Romantic Comedy SNAFU' },
|
||||
characters: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
edges: [
|
||||
{
|
||||
role: 'MAIN',
|
||||
node: { id: 1, name: { full: 'Hachiman Hikigaya', native: '比企谷八幡' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof body.variables?.id === 'number') {
|
||||
// No sequel edges: season 3 is unreachable from the season 1 anchor.
|
||||
return new Response(JSON.stringify({ data: { Media: { relations: { edges: [] } } } }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
return jsonResponse({ data: { Media: { relations: { edges: [] } } } });
|
||||
}
|
||||
|
||||
searchCalls += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Page: {
|
||||
media: [
|
||||
{
|
||||
id: 14813,
|
||||
episodes: 13,
|
||||
format: 'TV',
|
||||
seasonYear: 2013,
|
||||
title: { romaji: null, english: 'My Teen Romantic Comedy SNAFU', native: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
return jsonResponse({
|
||||
data: {
|
||||
Page: {
|
||||
media: [
|
||||
{
|
||||
id: 14813,
|
||||
episodes: 13,
|
||||
format: 'TV',
|
||||
seasonYear: 2013,
|
||||
title: { romaji: null, english: 'My Teen Romantic Comedy SNAFU', native: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const warnings: string[] = [];
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () =>
|
||||
'/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
|
||||
getCurrentMediaTitle: () => null,
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'My Teen Romantic Comedy SNAFU',
|
||||
year: 2013,
|
||||
season: 3,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
}),
|
||||
now: () => 1_700_000_000_000,
|
||||
sleep: async () => {},
|
||||
logWarn: (message) => warnings.push(message),
|
||||
});
|
||||
const { runtime } = createRuntime({
|
||||
getCurrentMediaPath: () => '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
|
||||
getCurrentMediaTitle: () => null,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'My Teen Romantic Comedy SNAFU',
|
||||
year: 2013,
|
||||
season: 3,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
}),
|
||||
logWarn: (message) => warnings.push(message),
|
||||
});
|
||||
|
||||
await runtime.getOrCreateCurrentSnapshot();
|
||||
const searchesAfterFirst = searchCalls;
|
||||
await runtime.getOrCreateCurrentSnapshot();
|
||||
await runtime.getOrCreateCurrentSnapshot();
|
||||
const searchesAfterFirst = searchCalls;
|
||||
await runtime.getOrCreateCurrentSnapshot();
|
||||
|
||||
// Re-resolved rather than served from the resolution cache, and warned both times.
|
||||
assert.ok(searchCalls > searchesAfterFirst);
|
||||
assert.equal(warnings.filter((m) => /could not find season 3/i.test(m)).length, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
// Re-resolved rather than served from the resolution cache, and warned both times.
|
||||
assert.ok(searchCalls > searchesAfterFirst);
|
||||
assert.equal(warnings.filter((m) => /could not find season 3/i.test(m)).length, 2);
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildConfigParseErrorDetails,
|
||||
buildConfigWarningDialogDetails,
|
||||
buildConfigWarningNotificationBody,
|
||||
buildConfigWarningSummary,
|
||||
failStartupFromConfig,
|
||||
@@ -54,29 +52,27 @@ test('buildConfigWarningNotificationBody includes concise warning details', () =
|
||||
);
|
||||
});
|
||||
|
||||
test('buildConfigWarningDialogDetails includes full warning details', () => {
|
||||
const details = buildConfigWarningDialogDetails('/tmp/config.jsonc', [
|
||||
{
|
||||
path: 'ankiConnect.pollingRate',
|
||||
message: 'must be >= 50',
|
||||
value: 10,
|
||||
fallback: 250,
|
||||
},
|
||||
test('buildConfigWarningNotificationBody caps listed warnings and clips long config paths', () => {
|
||||
const longPath = `/home/user/${'nested/'.repeat(10)}config.jsonc`;
|
||||
const warnings = [1, 2, 3, 4, 5].map((n) => ({
|
||||
path: `key${n}`,
|
||||
message: 'invalid',
|
||||
value: n,
|
||||
fallback: 0,
|
||||
}));
|
||||
|
||||
const lines = buildConfigWarningNotificationBody(longPath, warnings).split('\n');
|
||||
|
||||
assert.equal(lines[0], '5 config validation issue(s) detected.');
|
||||
const fileLine = lines.find((line) => line.startsWith('File: '));
|
||||
assert.equal(fileLine, `File: ...${longPath.slice(-45)}`);
|
||||
assert.equal(fileLine?.length, 'File: '.length + 48);
|
||||
assert.deepEqual(lines.slice(-4), [
|
||||
'1. key1: invalid',
|
||||
'2. key2: invalid',
|
||||
'3. key3: invalid',
|
||||
'+2 more issue(s)',
|
||||
]);
|
||||
|
||||
assert.match(details, /SubMiner detected config validation issues\./);
|
||||
assert.match(details, /File: \/tmp\/config\.jsonc/);
|
||||
assert.match(details, /1\. ankiConnect\.pollingRate: must be >= 50/);
|
||||
assert.match(details, /actual=10 fallback=250/);
|
||||
});
|
||||
|
||||
test('buildConfigParseErrorDetails includes path error and restart guidance', () => {
|
||||
const details = buildConfigParseErrorDetails('/tmp/config.jsonc', 'unexpected token at line 1');
|
||||
|
||||
assert.match(details, /Failed to parse config file at:/);
|
||||
assert.match(details, /\/tmp\/config\.jsonc/);
|
||||
assert.match(details, /Error: unexpected token at line 1/);
|
||||
assert.match(details, /Fix the config file and restart SubMiner\./);
|
||||
});
|
||||
|
||||
test('failStartupFromConfig invokes handlers and throws', () => {
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { applyControllerConfigUpdate } from './controller-config-update.js';
|
||||
|
||||
test('SM-012 controller config update path does not use JSON serialize-clone helpers', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src/main/controller-config-update.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
assert.equal(source.includes('JSON.parse(JSON.stringify('), false);
|
||||
});
|
||||
|
||||
test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => {
|
||||
const next = applyControllerConfigUpdate(
|
||||
{
|
||||
|
||||
@@ -15,12 +15,13 @@ function withTempDir(run: (directory: string) => void): void {
|
||||
}
|
||||
|
||||
test('runtime guard major matches the pinned Electron dependency', () => {
|
||||
// A stale constant would make the guard reject the bundled runtime after an Electron bump.
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
|
||||
) as { devDependencies: { electron: string } };
|
||||
const pinnedMajor = Number.parseInt(packageJson.devDependencies.electron.replace(/^\D*/, ''), 10);
|
||||
|
||||
assert.equal(Number.parseInt(packageJson.devDependencies.electron.split('.', 1)[0]!, 10), 43);
|
||||
assert.equal(SUPPORTED_ELECTRON_MAJOR, 43);
|
||||
assert.equal(SUPPORTED_ELECTRON_MAJOR, pinnedMajor);
|
||||
});
|
||||
|
||||
test('runtime guard records the supported Electron major', () => {
|
||||
@@ -155,28 +156,6 @@ test('runtime guard allows a patch downgrade while preserving minor downgrade pr
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard blocks an Electron 42 build from reopening an Electron 43 profile', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||
const previousState = JSON.stringify({
|
||||
highestElectronMajor: 43,
|
||||
lastElectronVersion: '43.7.2',
|
||||
});
|
||||
fs.writeFileSync(statePath, previousState, 'utf8');
|
||||
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '42.11.8',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 42,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.title, 'Electron downgrade blocked');
|
||||
assert.equal(fs.readFileSync(statePath, 'utf8'), previousState);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard fails closed when its safety record is malformed', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
test('main process call sites bypass zero-logic pass-through wrappers', () => {
|
||||
const sourcePath = path.join(process.cwd(), 'src/main.ts');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const sourceFile = ts.createSourceFile(
|
||||
sourcePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) {
|
||||
if (isRuntimePassthrough(statement, statement.body)) {
|
||||
offenders.push(statement.name.text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ts.isVariableStatement(statement)) continue;
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (
|
||||
ts.isIdentifier(declaration.name) &&
|
||||
declaration.initializer &&
|
||||
(ts.isArrowFunction(declaration.initializer) ||
|
||||
ts.isFunctionExpression(declaration.initializer)) &&
|
||||
isRuntimePassthrough(declaration.initializer, declaration.initializer.body)
|
||||
) {
|
||||
offenders.push(declaration.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(offenders, []);
|
||||
});
|
||||
|
||||
function isRuntimePassthrough(
|
||||
callable: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression,
|
||||
body: ts.ConciseBody,
|
||||
): boolean {
|
||||
let expression: ts.Expression | undefined;
|
||||
if (!ts.isBlock(body)) {
|
||||
expression = body;
|
||||
} else {
|
||||
if (body.statements.length !== 1) return false;
|
||||
const statement = body.statements[0]!;
|
||||
if (ts.isExpressionStatement(statement)) expression = statement.expression;
|
||||
if (ts.isReturnStatement(statement)) expression = statement.expression;
|
||||
}
|
||||
if (!expression) return false;
|
||||
if (ts.isAwaitExpression(expression) || ts.isVoidExpression(expression)) {
|
||||
expression = expression.expression;
|
||||
}
|
||||
if (!ts.isCallExpression(expression)) return false;
|
||||
|
||||
const root = getCalleeRoot(expression.expression);
|
||||
if (!root) return false;
|
||||
if (callable.parameters.length !== expression.arguments.length) return false;
|
||||
|
||||
return callable.parameters.every((parameter, index) => {
|
||||
if (!ts.isIdentifier(parameter.name)) return false;
|
||||
const argument = expression.arguments[index];
|
||||
if (!argument) return false;
|
||||
if (parameter.dotDotDotToken) {
|
||||
if (!ts.isSpreadElement(argument)) return false;
|
||||
const spreadExpression = unwrapTransparentExpression(argument.expression);
|
||||
return ts.isIdentifier(spreadExpression) && spreadExpression.text === parameter.name.text;
|
||||
}
|
||||
const argumentExpression = unwrapTransparentExpression(argument);
|
||||
return ts.isIdentifier(argumentExpression) && argumentExpression.text === parameter.name.text;
|
||||
});
|
||||
}
|
||||
|
||||
function unwrapTransparentExpression(expression: ts.Expression): ts.Expression {
|
||||
let current = expression;
|
||||
while (
|
||||
ts.isParenthesizedExpression(current) ||
|
||||
ts.isAsExpression(current) ||
|
||||
ts.isNonNullExpression(current)
|
||||
) {
|
||||
current = current.expression;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function getCalleeRoot(expression: ts.LeftHandSideExpression): string {
|
||||
let current: ts.Expression = expression;
|
||||
while (ts.isPropertyAccessExpression(current) || ts.isCallExpression(current)) {
|
||||
current = current.expression;
|
||||
}
|
||||
return ts.isIdentifier(current) ? current.text : '';
|
||||
}
|
||||
+17
-321
@@ -1,3 +1,7 @@
|
||||
// Interim source-text guards: each test regexes src/main.ts (or a runtime file) to
|
||||
// pin a startup/overlay invariant that main.ts closures make hard to exercise.
|
||||
// Replace each with a behavioral test as the logic moves out of main.ts closures
|
||||
// (see docs/workflow/testing.md, Known Debt). Do not add new tests here.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
@@ -50,16 +54,6 @@ test('media path changes clear rendered subtitle state without clearing same-you
|
||||
);
|
||||
});
|
||||
|
||||
test('media path changes start the YouTube media cache coordinator', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(actionBlock, /youtubeMediaCachePlaybackRuntime\.handleMediaPathChange\(path\);/);
|
||||
});
|
||||
|
||||
test('same media path updates do not reset autoplay ready fallback state', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -104,19 +98,6 @@ test('mpv startup signals start overlay loading OSD before readiness work', () =
|
||||
);
|
||||
});
|
||||
|
||||
test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => {
|
||||
const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||
const dismissBlock = source.match(
|
||||
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(dismissBlock);
|
||||
assert.match(
|
||||
dismissBlock,
|
||||
/sendMpvCommandRuntime\(deps\.getMpvClient\(\), \[\s*'script-message',\s*'subminer-overlay-loading-ready',\s*\]\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('manual visible overlay toggles only release current-media autoplay when hiding', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -157,6 +138,19 @@ test('all visible overlay hide paths clear stale overlay input state', () => {
|
||||
setOverlayBlock,
|
||||
/if \(!visible\) \{[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?resetVisibleOverlayInputState\(\);[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||
);
|
||||
// Hiding must also dismiss the loading OSD, or it lingers over mpv.
|
||||
assert.match(
|
||||
setVisibleBlock,
|
||||
/if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/if \(!nextVisible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
setOverlayBlock,
|
||||
/if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
|
||||
@@ -176,56 +170,6 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
|
||||
);
|
||||
});
|
||||
|
||||
test('remote media keeps parsed cues when the active subtitle source cannot be resolved', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/createRefreshSubtitlePrefetchFromActiveTrackHandler\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/isYoutubeMediaPath\(videoPath\) \|\| \(await detectRemoteMediaPath\(videoPath\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/preloadJellyfinExternalSubtitlesMainDeps:\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/initSubtitlePrefetch: \(sourcePath\) =>\s*subtitlePrefetchRuntime\.refreshSubtitleSidebarFromSource\(sourcePath\),/,
|
||||
);
|
||||
});
|
||||
|
||||
test('update overlay notification action triggers install flow', () => {
|
||||
const source = readMainSource();
|
||||
const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/handleOverlayNotificationAction:\s*\(notificationId,\s*actionId,\s*noteId\)\s*=>/,
|
||||
);
|
||||
assert.match(source, /notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID/);
|
||||
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
|
||||
assert.match(source, /installWhenAvailable:\s*true/);
|
||||
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
|
||||
assert.match(runtimeSource, /deps\.getAnkiIntegration\(\)\?\.openNoteInAnki\(noteId\)/);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/deps\.getRuntimeOptionsManager\(\)\?\.getEffectiveAnkiConnectConfig/,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/,
|
||||
);
|
||||
assert.match(runtimeSource, /fallbackClient\.openNoteInBrowser\(noteId\)/);
|
||||
});
|
||||
|
||||
test('subtitle change pauses prefetch without restarting its run before tokenizing current line', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -263,8 +207,6 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o
|
||||
);
|
||||
assert.match(actionBlock, /if \(cachedPayload\) \{/);
|
||||
assert.match(actionBlock, /emitSubtitlePayload\(cachedPayload\);/);
|
||||
assert.doesNotMatch(actionBlock, /withCurrentSubtitleTiming\(\{ text, tokens: null \}\)/);
|
||||
assert.doesNotMatch(actionBlock, /broadcastToOverlayWindows\('subtitle:set', rawPayload\)/);
|
||||
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('consumeCachedSubtitle(text)') <
|
||||
@@ -272,31 +214,6 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay subtitle prime reuses active parsed cues before synthetic warm release', () => {
|
||||
const source = readMainSource();
|
||||
const runtimeDepsBlock = source.match(
|
||||
/const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
)?.groups?.body;
|
||||
const primeSource = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||
const emptyTextBlock = primeSource.match(
|
||||
/if \(!text\.trim\(\) && isCurrentAutoplayMediaPath\(mediaPath\)\) \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(runtimeDepsBlock);
|
||||
assert.match(
|
||||
runtimeDepsBlock,
|
||||
/getActiveParsedSubtitleCues:\s*\(\) => appState\.activeParsedSubtitleCues/,
|
||||
);
|
||||
|
||||
assert.ok(emptyTextBlock);
|
||||
assert.ok(
|
||||
emptyTextBlock.indexOf('await deps.refreshSubtitlePrefetchFromActiveTrack()') <
|
||||
emptyTextBlock.indexOf(
|
||||
'await primeAutoplaySubtitleFromParsedCues(mediaPath, deps.getActiveParsedSubtitleCues())',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('startup autoplay release is tied to visible overlay measurement readiness', () => {
|
||||
const source = readMainSource();
|
||||
const gateBlock = source.match(
|
||||
@@ -509,20 +426,6 @@ test('subtitle annotation updates invalidate prefetched tokenizations before ref
|
||||
);
|
||||
});
|
||||
|
||||
test('character portrait index readiness refreshes cached subtitle annotations', () => {
|
||||
const source = readMainSource();
|
||||
const lookupDeps = source.match(
|
||||
/const characterDictionaryImageLookup = createCharacterDictionaryImageLookup\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(lookupDeps);
|
||||
assert.match(lookupDeps, /onIndexReady: \(\) => refreshCurrentSubtitleAnnotations\(\),/);
|
||||
assert.match(
|
||||
lookupDeps,
|
||||
/onIndexReadyError: \(error\) =>[\s\S]*?logger\.warn\([\s\S]*?character portrait index became ready\.[\s\S]*?error,/,
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle processing controller resumes prefetch on settle, not on its emits', () => {
|
||||
const source = readMainSource();
|
||||
const depsBlock = source.match(
|
||||
@@ -542,120 +445,6 @@ test('subtitle processing controller resumes prefetch on settle, not on its emit
|
||||
);
|
||||
});
|
||||
|
||||
test('manual visible overlay changes notify mpv plugin visibility state', () => {
|
||||
const source = readMainSource();
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const toggleBlock = source.match(
|
||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(setBlock);
|
||||
assert.ok(toggleBlock);
|
||||
assert.match(setBlock, /notifyMpvPluginVisibleOverlayVisibility\(visible\);/);
|
||||
assert.match(toggleBlock, /const nextVisible = !overlayManager\.getVisibleOverlayVisible\(\);/);
|
||||
assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/);
|
||||
});
|
||||
|
||||
test('manual visible overlay hide dismisses loading OSD', () => {
|
||||
const source = readMainSource();
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const toggleBlock = source.match(
|
||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const setOverlayBlock = source.match(
|
||||
/function setOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(setBlock);
|
||||
assert.ok(toggleBlock);
|
||||
assert.ok(setOverlayBlock);
|
||||
assert.match(setBlock, /if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/if \(!nextVisible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
setOverlayBlock,
|
||||
/if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('configured overlay notifications require visible ready overlay window', () => {
|
||||
const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||
const readinessBlock = source.match(
|
||||
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
const statusBlock = source.match(
|
||||
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(readinessBlock);
|
||||
assert.ok(statusBlock);
|
||||
assert.match(readinessBlock, /deps\.getVisibleOverlayVisible\(\)/);
|
||||
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
|
||||
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
|
||||
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
|
||||
});
|
||||
|
||||
test('YouTube media cache lifecycle routes through configured status notifications', () => {
|
||||
const source = readMainSource();
|
||||
const cacheBlock = source.match(
|
||||
/const youtubeMediaCache = createYoutubeMediaCacheService\(\{(?<body>[\s\S]*?)\n\}\);\nconst waitForYoutubeMpvConnected/,
|
||||
)?.groups?.body;
|
||||
const startCacheBlock = source.match(
|
||||
/startYoutubeMediaCache:\s*\(url\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n runYoutubePlaybackFlow/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(cacheBlock);
|
||||
assert.ok(startCacheBlock);
|
||||
assert.match(
|
||||
cacheBlock,
|
||||
/onDownloadStarted:\s*\(event\)\s*=>\s*\{[\s\S]*showConfiguredStatusNotification\(\s*'YouTube media cache is downloading\.'/,
|
||||
);
|
||||
assert.match(cacheBlock, /id:\s*'youtube-media-cache-status'/);
|
||||
assert.match(cacheBlock, /variant:\s*'progress'/);
|
||||
assert.match(cacheBlock, /persistent:\s*true/);
|
||||
assert.match(
|
||||
cacheBlock,
|
||||
/onReady:\s*\(event\)\s*=>\s*\{[\s\S]*showConfiguredStatusNotification\(\s*'YouTube media cache ready\.'/,
|
||||
);
|
||||
assert.match(cacheBlock, /variant:\s*'success'/);
|
||||
assert.match(cacheBlock, /notifyNoQueued:\s*false/);
|
||||
assert.match(
|
||||
startCacheBlock,
|
||||
/const mediaCacheConfig = configService\.getConfig\(\)\.youtube\.mediaCache;/,
|
||||
);
|
||||
assert.match(startCacheBlock, /mode:\s*mediaCacheConfig\.mode/);
|
||||
assert.match(startCacheBlock, /maxHeight:\s*mediaCacheConfig\.maxHeight/);
|
||||
});
|
||||
|
||||
test('subtitle broadcasts share one frequency options snapshot per emitted payload', () => {
|
||||
const source = readMainSource();
|
||||
const emitBlock = source.match(
|
||||
/function emitSubtitlePayload\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const frequencyOptionsSnapshot = emitBlock?.match(
|
||||
/const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;(?<body>[\s\S]*?)\n \};/,
|
||||
)?.[0];
|
||||
|
||||
assert.ok(emitBlock);
|
||||
assert.ok(frequencyOptionsSnapshot);
|
||||
assert.match(frequencyOptionsSnapshot, /const frequencyOptions = \{/);
|
||||
assert.match(frequencyOptionsSnapshot, /enabled:\s*frequencyDictionary\.enabled/);
|
||||
assert.match(frequencyOptionsSnapshot, /topX:\s*frequencyDictionary\.topX/);
|
||||
assert.match(frequencyOptionsSnapshot, /mode:\s*frequencyDictionary\.mode/);
|
||||
assert.equal((frequencyOptionsSnapshot.match(/configService\.getConfig\(\)/g) ?? []).length, 1);
|
||||
assert.match(emitBlock, /subtitleWsService\.broadcast\(timedPayload, frequencyOptions\);/);
|
||||
assert.match(
|
||||
emitBlock,
|
||||
/annotationSubtitleWsService\.broadcast\(timedPayload, frequencyOptions\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('annotation upgrades skip the duplicate basic websocket event', () => {
|
||||
const source = readMainSource();
|
||||
const emitBlock = source.match(
|
||||
@@ -685,31 +474,6 @@ test('annotation upgrades skip the duplicate basic websocket event', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('websocket frequency options callbacks each read one configuration snapshot', () => {
|
||||
const source = readMainSource();
|
||||
const subtitleBlock = source.match(
|
||||
/startSubtitleWebsocket:\s*\(port: number\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n startAnnotationWebsocket:/,
|
||||
)?.groups?.body;
|
||||
const annotationBlock = source.match(
|
||||
/startAnnotationWebsocket:\s*\(port: number\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n startTexthooker:/,
|
||||
)?.groups?.body;
|
||||
const subtitleFrequencyOptionsCallback = subtitleBlock?.match(
|
||||
/(?<body>\(\) => \{\s+const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;[\s\S]*?\s+return \{[\s\S]*?\s+\};\s+\})/,
|
||||
)?.groups?.body;
|
||||
const annotationFrequencyOptionsCallback = annotationBlock?.match(
|
||||
/(?<body>\(\) => \{\s+const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;[\s\S]*?\s+return \{[\s\S]*?\s+\};\s+\})/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(subtitleFrequencyOptionsCallback);
|
||||
assert.ok(annotationFrequencyOptionsCallback);
|
||||
for (const callback of [subtitleFrequencyOptionsCallback, annotationFrequencyOptionsCallback]) {
|
||||
assert.match(callback, /return \{[\s\S]*enabled:\s*frequencyDictionary\.enabled/);
|
||||
assert.match(callback, /topX:\s*frequencyDictionary\.topX/);
|
||||
assert.match(callback, /mode:\s*frequencyDictionary\.mode/);
|
||||
assert.equal((callback.match(/configService\.getConfig\(\)/g) ?? []).length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('mpv connection flushes queued configured OSD notifications', () => {
|
||||
const source = readMainSource();
|
||||
const connectedBlock = source.match(
|
||||
@@ -721,48 +485,6 @@ test('mpv connection flushes queued configured OSD notifications', () => {
|
||||
assert.match(connectedBlock, /flushQueuedMpvOsdNotifications\(\);/);
|
||||
});
|
||||
|
||||
test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => {
|
||||
const source = readMainSource();
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const toggleBlock = source.match(
|
||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(setBlock);
|
||||
assert.ok(toggleBlock);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void autoplaySubtitlePrimingRuntime\.primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void autoplaySubtitlePrimingRuntime\.primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => {
|
||||
const source = readMainSource();
|
||||
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||
const resetBlock = runtimeSource.match(
|
||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||
)?.groups?.body;
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(resetBlock);
|
||||
assert.ok(setBlock);
|
||||
assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
||||
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux visible overlay startup reapplies passive passthrough after input reset', () => {
|
||||
const pointerSource = readSource('src/main/runtime/linux-overlay-pointer-interaction.ts');
|
||||
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||
@@ -841,16 +563,6 @@ test('Linux visible overlay bounds refresh restores X11 shape after applying mpv
|
||||
);
|
||||
});
|
||||
|
||||
test('main process uses one shared mpv plugin runtime config helper', () => {
|
||||
const source = readMainSource();
|
||||
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
|
||||
assert.equal((source.match(/socketPath: appState\.mpvSocketPath/g) ?? []).length, 1);
|
||||
assert.equal(
|
||||
(source.match(/binaryPath: getResolvedConfig\(\)\.mpv\.subminerBinaryPath/g) ?? []).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active-source parsing', () => {
|
||||
const source = readMainSource();
|
||||
const snapshotBlock = source.match(
|
||||
@@ -866,19 +578,3 @@ test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active
|
||||
snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'),
|
||||
);
|
||||
});
|
||||
|
||||
test('main process extracts internal subtitle tracks without a network-mount guard', () => {
|
||||
const source = readMainSource();
|
||||
const resolverWiring = source.match(
|
||||
/const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(resolverWiring);
|
||||
// Network-mounted files are extracted like local ones; only remote URLs skip
|
||||
// extraction, handled inside the resolver itself.
|
||||
assert.doesNotMatch(resolverWiring, /isRemoteMediaPath/);
|
||||
assert.match(
|
||||
resolverWiring,
|
||||
/extractInternalSubtitleTrack:[\s\S]*cachedInternalSubtitleTrackExtractor\.extract/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { WindowGeometry } from '../types';
|
||||
import {
|
||||
OVERLAY_WINDOW_CONTENT_READY_FLAG,
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
|
||||
} from '../core/services/overlay-window-flags';
|
||||
import {
|
||||
createOverlayModalRuntimeService,
|
||||
type OverlayModalRuntimeOptions,
|
||||
type OverlayWindowResolver,
|
||||
} from './overlay-runtime';
|
||||
|
||||
export const MODAL_URL = 'file:///overlay/index.html?layer=modal';
|
||||
export const MODAL_GEOMETRY: WindowGeometry = { x: 0, y: 0, width: 400, height: 300 };
|
||||
|
||||
export interface FakeOverlayWindowInit {
|
||||
// Prefix for show/hide entries pushed to `log`, e.g. `modal:show`.
|
||||
name?: string;
|
||||
log?: string[];
|
||||
visible?: boolean;
|
||||
focused?: boolean;
|
||||
webContentsFocused?: boolean;
|
||||
ignoreMouseEvents?: boolean;
|
||||
loading?: boolean;
|
||||
url?: string;
|
||||
contentReady?: boolean;
|
||||
documentLoaded?: boolean;
|
||||
}
|
||||
|
||||
// A still-loading window whose document has not been committed yet.
|
||||
export const LOADING_BLANK_WINDOW: FakeOverlayWindowInit = {
|
||||
loading: true,
|
||||
url: '',
|
||||
contentReady: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* BrowserWindow stand-in covering the surface overlay-runtime touches. Defaults to a hidden,
|
||||
* fully loaded, content-ready modal window.
|
||||
*/
|
||||
export class FakeOverlayWindow {
|
||||
destroyed = false;
|
||||
visible = false;
|
||||
focused = false;
|
||||
webContentsFocused = false;
|
||||
ignoreMouseEvents = false;
|
||||
forwardedIgnoreMouseEvents = false;
|
||||
loading = false;
|
||||
url = MODAL_URL;
|
||||
alwaysOnTopCalls: string[] = [];
|
||||
showCount = 0;
|
||||
hideCount = 0;
|
||||
sent: unknown[][] = [];
|
||||
[OVERLAY_WINDOW_CONTENT_READY_FLAG] = true;
|
||||
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] = true;
|
||||
|
||||
private readonly name: string;
|
||||
private readonly log: string[] | undefined;
|
||||
private loadListeners: Array<() => void> = [];
|
||||
private readyToShowListeners: Array<() => void> = [];
|
||||
|
||||
readonly webContents = {
|
||||
isLoading: (): boolean => this.loading,
|
||||
getURL: (): string => this.url,
|
||||
send: (channel: string, ...payload: unknown[]): void => {
|
||||
this.sent.push([channel, ...payload]);
|
||||
},
|
||||
isFocused: (): boolean => this.webContentsFocused,
|
||||
focus: (): void => {
|
||||
this.webContentsFocused = true;
|
||||
},
|
||||
once: (_event: 'did-finish-load', listener: () => void): void => {
|
||||
this.loadListeners.push(listener);
|
||||
},
|
||||
};
|
||||
|
||||
constructor(init: FakeOverlayWindowInit = {}) {
|
||||
const { name = 'window', log, contentReady = true, documentLoaded = true, ...fields } = init;
|
||||
this.name = name;
|
||||
this.log = log;
|
||||
Object.assign(this, fields);
|
||||
this[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady;
|
||||
this[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] = documentLoaded;
|
||||
}
|
||||
|
||||
isDestroyed(): boolean {
|
||||
return this.destroyed;
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return this.visible;
|
||||
}
|
||||
|
||||
isFocused(): boolean {
|
||||
return this.focused;
|
||||
}
|
||||
|
||||
setIgnoreMouseEvents(ignore: boolean, options?: { forward?: boolean }): void {
|
||||
this.ignoreMouseEvents = ignore;
|
||||
this.forwardedIgnoreMouseEvents = options?.forward === true;
|
||||
}
|
||||
|
||||
setAlwaysOnTop(flag: boolean, level?: string, relativeLevel?: number): void {
|
||||
this.alwaysOnTopCalls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`);
|
||||
}
|
||||
|
||||
moveTop(): void {}
|
||||
|
||||
show(): void {
|
||||
this.log?.push(`${this.name}:show`);
|
||||
this.visible = true;
|
||||
this.showCount += 1;
|
||||
}
|
||||
|
||||
showInactive(): void {
|
||||
this.log?.push(`${this.name}:show-inactive`);
|
||||
this.visible = true;
|
||||
this.showCount += 1;
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.log?.push(`${this.name}:hide`);
|
||||
this.visible = false;
|
||||
this.hideCount += 1;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.focused = true;
|
||||
}
|
||||
|
||||
once(_event: 'ready-to-show', listener: () => void): void {
|
||||
this.readyToShowListeners.push(listener);
|
||||
}
|
||||
|
||||
// Commits the modal document and fires the queued did-finish-load listeners.
|
||||
finishLoad(): void {
|
||||
this.loading = false;
|
||||
this.url = MODAL_URL;
|
||||
this[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] = true;
|
||||
for (const listener of this.loadListeners.splice(0)) listener();
|
||||
}
|
||||
|
||||
// Marks renderer content ready and fires the queued ready-to-show listeners.
|
||||
emitReadyToShow(): void {
|
||||
this[OVERLAY_WINDOW_CONTENT_READY_FLAG] = true;
|
||||
for (const listener of this.readyToShowListeners.splice(0)) listener();
|
||||
}
|
||||
}
|
||||
|
||||
export interface OverlayRuntimeHarnessInit {
|
||||
main?: FakeOverlayWindow | null;
|
||||
modal?: FakeOverlayWindow | null;
|
||||
// Called by the runtime's createModalWindow; its result becomes the current modal window.
|
||||
createModal?: () => FakeOverlayWindow | null;
|
||||
// Defaults to linux so behavior does not depend on the host running the tests.
|
||||
platform?: NodeJS.Platform;
|
||||
options?: Omit<OverlayModalRuntimeOptions, 'platform'>;
|
||||
setModalWindowBounds?: OverlayWindowResolver['setModalWindowBounds'];
|
||||
}
|
||||
|
||||
/** Builds the modal runtime over fake windows and records resolver traffic. */
|
||||
export function createHarness(init: OverlayRuntimeHarnessInit = {}) {
|
||||
const harness = {
|
||||
modal: init.modal ?? null,
|
||||
createCalls: 0,
|
||||
boundsCalls: [] as WindowGeometry[],
|
||||
};
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => (init.main ?? null) as never,
|
||||
getModalWindow: () => harness.modal as never,
|
||||
createModalWindow: () => {
|
||||
harness.createCalls += 1;
|
||||
harness.modal = init.createModal?.() ?? null;
|
||||
return harness.modal as never;
|
||||
},
|
||||
getModalGeometry: () => MODAL_GEOMETRY,
|
||||
setModalWindowBounds: (geometry) => {
|
||||
harness.boundsCalls.push(geometry);
|
||||
return init.setModalWindowBounds?.(geometry);
|
||||
},
|
||||
},
|
||||
{ ...init.options, platform: init.platform ?? 'linux' },
|
||||
);
|
||||
return Object.assign(harness, { runtime });
|
||||
}
|
||||
+415
-1135
File diff suppressed because it is too large.
Load diff
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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,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' });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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',
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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"name'));
|
||||
assert.ok(html.includes('Ready "now"'));
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user