Files
SubMiner/src/core/services/ipc.test.ts

764 lines
27 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { SubtitleSidebarSnapshot } from '../../types';
interface FakeIpcRegistrar {
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
handle: Map<string, (event: unknown, ...args: unknown[]) => unknown>;
}
function createFakeIpcRegistrar(): {
registrar: {
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void;
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void;
};
handlers: FakeIpcRegistrar;
} {
const handlers: FakeIpcRegistrar = {
on: new Map(),
handle: new Map(),
};
return {
registrar: {
on: (channel, listener) => {
handlers.on.set(channel, listener);
},
handle: (channel, listener) => {
handlers.handle.set(channel, listener);
},
},
handlers,
};
}
function createControllerConfigFixture() {
return {
enabled: true,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto' as const,
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
},
bindings: {
toggleLookup: { kind: 'button' as const, buttonIndex: 0 },
closeLookup: { kind: 'button' as const, buttonIndex: 1 },
toggleKeyboardOnlyMode: { kind: 'button' as const, buttonIndex: 3 },
mineCard: { kind: 'button' as const, buttonIndex: 2 },
quitMpv: { kind: 'button' as const, buttonIndex: 6 },
previousAudio: { kind: 'button' as const, buttonIndex: 4 },
nextAudio: { kind: 'button' as const, buttonIndex: 5 },
playCurrentAudio: { kind: 'button' as const, buttonIndex: 7 },
toggleMpvPause: { kind: 'button' as const, buttonIndex: 6 },
leftStickHorizontal: { kind: 'axis' as const, axisIndex: 0, dpadFallback: 'horizontal' as const },
leftStickVertical: { kind: 'axis' as const, axisIndex: 1, dpadFallback: 'vertical' as const },
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
},
};
}
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
return {
cues: [],
currentSubtitle: { text: '', startTime: null, endTime: null },
config: {
enabled: false,
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 420,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
textColor: '#cad3f5',
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
fontSize: 16,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
};
}
function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServiceDeps {
return {
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => false,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: () => {},
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
saveControllerPreference: async () => {},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
immersionTracker: null,
...overrides,
};
}
function createFakeImmersionTracker(
overrides: Partial<NonNullable<IpcServiceDeps['immersionTracker']>> = {},
): NonNullable<IpcServiceDeps['immersionTracker']> {
return {
recordYomitanLookup: () => {},
getSessionSummaries: async () => [],
getDailyRollups: async () => [],
getMonthlyRollups: async () => [],
getQueryHints: async () => ({
totalSessions: 0,
activeSessions: 0,
episodesToday: 0,
activeAnimeCount: 0,
totalActiveMin: 0,
totalCards: 0,
activeDays: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalTokensSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
}),
getSessionTimeline: async () => [],
getSessionEvents: async () => [],
getVocabularyStats: async () => [],
getKanjiStats: async () => [],
getMediaLibrary: async () => [],
getMediaDetail: async () => null,
getMediaSessions: async () => [],
getMediaDailyRollups: async () => [],
getCoverArt: async () => null,
markActiveVideoWatched: async () => false,
...overrides,
};
}
test('createIpcDepsRuntime wires AniList handlers', async () => {
const calls: string[] = [];
const deps = createIpcDepsRuntime({
getMainWindow: () => null,
getVisibleOverlayVisibility: () => false,
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => true,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: () => {},
getMecabTokenizer: () => null,
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover',
getMpvClient: () => null,
focusMainWindow: () => {},
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => ({}),
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({ tokenStatus: 'resolved' }),
clearAnilistToken: () => {
calls.push('clearAnilistToken');
},
openAnilistSetup: () => {
calls.push('openAnilistSetup');
},
getAnilistQueueStatus: () => ({ pending: 1, ready: 0, deadLetter: 0 }),
retryAnilistQueueNow: async () => {
calls.push('retryAnilistQueueNow');
return { ok: true, message: 'done' };
},
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
});
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
deps.clearAnilistToken();
deps.openAnilistSetup();
assert.deepEqual(deps.getAnilistQueueStatus(), {
pending: 1,
ready: 0,
deadLetter: 0,
});
assert.deepEqual(await deps.retryAnilistQueueNow(), {
ok: true,
message: 'done',
});
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
assert.equal(deps.getPlaybackPaused(), true);
});
test('registerIpcHandlers rejects malformed runtime-option payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: Array<{ id: string; value: unknown }> = [];
const cycles: Array<{ id: string; direction: 1 | -1 }> = [];
registerIpcHandlers(
{
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getPlaybackPaused: () => null,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: () => {},
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
setRuntimeOption: (id, value) => {
calls.push({ id, value });
return { ok: true };
},
cycleRuntimeOption: (id, direction) => {
cycles.push({ id, direction });
return { ok: true };
},
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
},
registrar,
);
const setHandler = handlers.handle.get(IPC_CHANNELS.request.setRuntimeOption);
assert.ok(setHandler);
const invalidIdResult = await setHandler!({}, '__invalid__', true);
assert.deepEqual(invalidIdResult, { ok: false, error: 'Invalid runtime option id' });
const invalidValueResult = await setHandler!({}, 'anki.autoUpdateNewCards', 42);
assert.deepEqual(invalidValueResult, {
ok: false,
error: 'Invalid runtime option value payload',
});
const validResult = await setHandler!({}, 'anki.autoUpdateNewCards', true);
assert.deepEqual(validResult, { ok: true });
const validSubtitleAnnotationResult = await setHandler!({}, 'subtitle.annotation.jlpt', false);
assert.deepEqual(validSubtitleAnnotationResult, { ok: true });
assert.deepEqual(calls, [
{ id: 'anki.autoUpdateNewCards', value: true },
{ id: 'subtitle.annotation.jlpt', value: false },
]);
const cycleHandler = handlers.handle.get(IPC_CHANNELS.request.cycleRuntimeOption);
assert.ok(cycleHandler);
const invalidDirection = await cycleHandler!({}, 'anki.kikuFieldGrouping', 2);
assert.deepEqual(invalidDirection, {
ok: false,
error: 'Invalid runtime option cycle direction',
});
await cycleHandler!({}, 'anki.kikuFieldGrouping', -1);
assert.deepEqual(cycles, [{ id: 'anki.kikuFieldGrouping', direction: -1 }]);
const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused);
assert.ok(getPlaybackPausedHandler);
assert.equal(getPlaybackPausedHandler!({}), null);
const getControllerConfigHandler = handlers.handle.get(IPC_CHANNELS.request.getControllerConfig);
assert.ok(getControllerConfigHandler);
assert.equal(
(getControllerConfigHandler!({}) as { scrollPixelsPerSecond: number }).scrollPixelsPerSecond,
960,
);
});
test('registerIpcHandlers exposes subtitle sidebar snapshot request', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const snapshot = createSubtitleSidebarSnapshotFixture();
snapshot.cues = [{ startTime: 1, endTime: 2, text: 'line-1' }];
snapshot.config.enabled = true;
registerIpcHandlers(
createRegisterIpcDeps({
getSubtitleSidebarSnapshot: async () => snapshot,
}),
registrar,
);
const handler = handlers.handle.get(IPC_CHANNELS.request.getSubtitleSidebarSnapshot);
assert.ok(handler);
assert.deepEqual(await handler!({}), snapshot);
});
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
immersionTracker: createFakeImmersionTracker({
recordYomitanLookup: () => {
calls.push('lookup');
},
}),
}),
registrar,
);
const handler = handlers.on.get(IPC_CHANNELS.command.recordYomitanLookup);
assert.equal(typeof handler, 'function');
handler?.({}, null);
assert.deepEqual(calls, ['lookup']);
});
test('registerIpcHandlers returns empty stats overview shape without a tracker', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
registerIpcHandlers(createRegisterIpcDeps(), registrar);
const overviewHandler = handlers.handle.get(IPC_CHANNELS.request.statsGetOverview);
assert.ok(overviewHandler);
assert.deepEqual(await overviewHandler!({}), {
sessions: [],
rollups: [],
hints: {
totalSessions: 0,
activeSessions: 0,
episodesToday: 0,
activeAnimeCount: 0,
totalCards: 0,
totalActiveMin: 0,
activeDays: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalTokensSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
},
});
});
test('registerIpcHandlers validates and clamps stats request limits', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: Array<[string, number, number?]> = [];
registerIpcHandlers(
createRegisterIpcDeps({
immersionTracker: {
recordYomitanLookup: () => {},
getSessionSummaries: async (limit = 0) => {
calls.push(['sessions', limit]);
return [];
},
getDailyRollups: async (limit = 0) => {
calls.push(['daily', limit]);
return [];
},
getMonthlyRollups: async (limit = 0) => {
calls.push(['monthly', limit]);
return [];
},
getQueryHints: async () => ({
totalSessions: 0,
activeSessions: 0,
episodesToday: 0,
activeAnimeCount: 0,
totalCards: 0,
totalActiveMin: 0,
activeDays: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalTokensSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
}),
getSessionTimeline: async (sessionId: number, limit = 0) => {
calls.push(['timeline', limit, sessionId]);
return [];
},
getSessionEvents: async (sessionId: number, limit = 0) => {
calls.push(['events', limit, sessionId]);
return [];
},
getVocabularyStats: async (limit = 0) => {
calls.push(['vocabulary', limit]);
return [];
},
getKanjiStats: async (limit = 0) => {
calls.push(['kanji', limit]);
return [];
},
getMediaLibrary: async () => [],
getMediaDetail: async () => null,
getMediaSessions: async () => [],
getMediaDailyRollups: async () => [],
getCoverArt: async () => null,
markActiveVideoWatched: async () => false,
},
}),
registrar,
);
await handlers.handle.get(IPC_CHANNELS.request.statsGetDailyRollups)!({}, -1);
await handlers.handle.get(IPC_CHANNELS.request.statsGetMonthlyRollups)!(
{},
Number.POSITIVE_INFINITY,
);
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessions)!({}, 9999);
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionTimeline)!({}, 7, 12.5);
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionEvents)!({}, 7, 0);
await handlers.handle.get(IPC_CHANNELS.request.statsGetVocabulary)!({}, 1000);
await handlers.handle.get(IPC_CHANNELS.request.statsGetKanji)!({}, NaN);
assert.deepEqual(calls, [
['daily', 60],
['monthly', 24],
['sessions', 500],
['timeline', 200, 7],
['events', 500, 7],
['vocabulary', 500],
['kanji', 100],
]);
});
test('registerIpcHandlers requests the full timeline when no limit is provided', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: Array<[string, number | undefined, number]> = [];
registerIpcHandlers(
createRegisterIpcDeps({
immersionTracker: {
recordYomitanLookup: () => {},
getSessionSummaries: async () => [],
getDailyRollups: async () => [],
getMonthlyRollups: async () => [],
getQueryHints: async () => ({
totalSessions: 0,
activeSessions: 0,
episodesToday: 0,
activeAnimeCount: 0,
totalCards: 0,
totalActiveMin: 0,
activeDays: 0,
totalEpisodesWatched: 0,
totalAnimeCompleted: 0,
totalTokensSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
}),
getSessionTimeline: async (sessionId: number, limit?: number) => {
calls.push(['timeline', limit, sessionId]);
return [];
},
getSessionEvents: async () => [],
getVocabularyStats: async () => [],
getKanjiStats: async () => [],
getMediaLibrary: async () => [],
getMediaDetail: async () => null,
getMediaSessions: async () => [],
getMediaDailyRollups: async () => [],
getCoverArt: async () => null,
markActiveVideoWatched: async () => false,
},
}),
registrar,
);
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionTimeline)!({}, 7, undefined);
assert.deepEqual(calls, [['timeline', undefined, 7]]);
});
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const saves: unknown[] = [];
const controllerSaves: unknown[] = [];
const closedModals: unknown[] = [];
const openedModals: unknown[] = [];
registerIpcHandlers(
{
onOverlayModalClosed: (modal) => {
closedModals.push(modal);
},
onOverlayModalOpened: (modal) => {
openedModals.push(modal);
},
openYomitanSettings: () => {},
quitApp: () => {},
toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => false,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: (position) => {
saves.push(position);
},
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
saveControllerPreference: (update) => {
controllerSaves.push(update);
},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
},
registrar,
);
handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 'bad' });
handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 42 });
assert.deepEqual(saves, [{ yPercent: 42 }]);
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku');
assert.deepEqual(closedModals, ['subsync', 'kiku']);
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad');
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync');
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
assert.deepEqual(openedModals, ['subsync', 'runtime-options']);
});
test('registerIpcHandlers awaits saveControllerPreference through request-response IPC', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const controllerSaves: unknown[] = [];
registerIpcHandlers(
{
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => false,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: () => {},
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
saveControllerPreference: async (update) => {
await Promise.resolve();
controllerSaves.push(update);
},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
},
registrar,
);
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
assert.ok(saveHandler);
await assert.rejects(async () => {
await saveHandler!({}, { preferredGamepadId: 12 });
}, /Invalid controller preference payload/);
await saveHandler!(
{},
{
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'Pad 1',
},
);
assert.deepEqual(controllerSaves, [
{
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'Pad 1',
},
]);
});
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
registerIpcHandlers(
{
onOverlayModalClosed: () => {},
openYomitanSettings: () => {},
quitApp: () => {},
toggleDevTools: () => {},
getVisibleOverlayVisibility: () => false,
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
getPlaybackPaused: () => false,
getSubtitlePosition: () => null,
getSubtitleStyle: () => null,
saveSubtitlePosition: () => {},
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
setMecabEnabled: () => {},
handleMpvCommand: () => {},
getKeybindings: () => [],
getConfiguredShortcuts: () => ({}),
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
saveControllerPreference: async () => {},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
setRuntimeOption: () => ({ ok: true }),
cycleRuntimeOption: () => ({ ok: true }),
reportOverlayContentBounds: () => {},
getAnilistStatus: () => ({}),
clearAnilistToken: () => {},
openAnilistSetup: () => {},
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
},
registrar,
);
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
await assert.rejects(async () => {
await saveHandler!({}, { preferredGamepadId: 12 });
}, /Invalid controller preference payload/);
});