mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
Complete runtime service follow-ups and invisible subtitle edit mode
This commit is contained in:
228
src/core/services/anki-jimaku-service.test.ts
Normal file
228
src/core/services/anki-jimaku-service.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
AnkiJimakuIpcRuntimeOptions,
|
||||
registerAnkiJimakuIpcRuntimeService,
|
||||
} from "./anki-jimaku-service";
|
||||
|
||||
interface RuntimeHarness {
|
||||
options: AnkiJimakuIpcRuntimeOptions;
|
||||
registered: Record<string, (...args: unknown[]) => unknown>;
|
||||
state: {
|
||||
ankiIntegration: unknown;
|
||||
fieldGroupingResolver: ((choice: unknown) => void) | null;
|
||||
patches: boolean[];
|
||||
broadcasts: number;
|
||||
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
sentCommands: Array<{ command: string[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(): RuntimeHarness {
|
||||
const state = {
|
||||
ankiIntegration: null as unknown,
|
||||
fieldGroupingResolver: null as ((choice: unknown) => void) | null,
|
||||
patches: [] as boolean[],
|
||||
broadcasts: 0,
|
||||
fetchCalls: [] as Array<{ endpoint: string; query?: Record<string, unknown> }>,
|
||||
sentCommands: [] as Array<{ command: string[] }>,
|
||||
};
|
||||
|
||||
const options: AnkiJimakuIpcRuntimeOptions = {
|
||||
patchAnkiConnectEnabled: (enabled) => {
|
||||
state.patches.push(enabled);
|
||||
},
|
||||
getResolvedConfig: () => ({}),
|
||||
getRuntimeOptionsManager: () => null,
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
send: (payload) => {
|
||||
state.sentCommands.push(payload);
|
||||
},
|
||||
}),
|
||||
getAnkiIntegration: () => state.ankiIntegration as never,
|
||||
setAnkiIntegration: (integration) => {
|
||||
state.ankiIntegration = integration;
|
||||
},
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
broadcastRuntimeOptionsChanged: () => {
|
||||
state.broadcasts += 1;
|
||||
},
|
||||
getFieldGroupingResolver: () => state.fieldGroupingResolver as never,
|
||||
setFieldGroupingResolver: (resolver) => {
|
||||
state.fieldGroupingResolver = resolver as never;
|
||||
},
|
||||
parseMediaInfo: () => ({
|
||||
title: "video",
|
||||
confidence: "high",
|
||||
rawTitle: "video",
|
||||
filename: "video.mkv",
|
||||
season: null,
|
||||
episode: null,
|
||||
}),
|
||||
getCurrentMediaPath: () => "/tmp/video.mkv",
|
||||
jimakuFetchJson: async (endpoint, query) => {
|
||||
state.fetchCalls.push({ endpoint, query: query as Record<string, unknown> });
|
||||
return {
|
||||
ok: true,
|
||||
data: [
|
||||
{ id: 1, name: "a" },
|
||||
{ id: 2, name: "b" },
|
||||
{ id: 3, name: "c" },
|
||||
] as never,
|
||||
};
|
||||
},
|
||||
getJimakuMaxEntryResults: () => 2,
|
||||
getJimakuLanguagePreference: () => "ja",
|
||||
resolveJimakuApiKey: async () => "token",
|
||||
isRemoteMediaPath: () => false,
|
||||
downloadToFile: async (url, destPath) => ({
|
||||
ok: true,
|
||||
path: `${destPath}:${url}`,
|
||||
}),
|
||||
};
|
||||
|
||||
let registered: Record<string, (...args: unknown[]) => unknown> = {};
|
||||
registerAnkiJimakuIpcRuntimeService(
|
||||
options,
|
||||
(deps) => {
|
||||
registered = deps as unknown as Record<string, (...args: unknown[]) => unknown>;
|
||||
},
|
||||
);
|
||||
|
||||
return { options, registered, state };
|
||||
}
|
||||
|
||||
test("registerAnkiJimakuIpcRuntimeService provides full handler surface", () => {
|
||||
const { registered } = createHarness();
|
||||
const expected = [
|
||||
"setAnkiConnectEnabled",
|
||||
"clearAnkiHistory",
|
||||
"respondFieldGrouping",
|
||||
"buildKikuMergePreview",
|
||||
"getJimakuMediaInfo",
|
||||
"searchJimakuEntries",
|
||||
"listJimakuFiles",
|
||||
"resolveJimakuApiKey",
|
||||
"getCurrentMediaPath",
|
||||
"isRemoteMediaPath",
|
||||
"downloadToFile",
|
||||
"onDownloadedSubtitle",
|
||||
];
|
||||
|
||||
for (const key of expected) {
|
||||
assert.equal(typeof registered[key], "function", `missing handler: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("setAnkiConnectEnabled disables active integration and broadcasts changes", () => {
|
||||
const { registered, state } = createHarness();
|
||||
let destroyed = 0;
|
||||
state.ankiIntegration = {
|
||||
destroy: () => {
|
||||
destroyed += 1;
|
||||
},
|
||||
};
|
||||
|
||||
registered.setAnkiConnectEnabled(false);
|
||||
|
||||
assert.deepEqual(state.patches, [false]);
|
||||
assert.equal(destroyed, 1);
|
||||
assert.equal(state.ankiIntegration, null);
|
||||
assert.equal(state.broadcasts, 1);
|
||||
});
|
||||
|
||||
test("clearAnkiHistory and respondFieldGrouping execute runtime callbacks", () => {
|
||||
const { registered, state, options } = createHarness();
|
||||
let cleaned = 0;
|
||||
let resolvedChoice: unknown = null;
|
||||
state.fieldGroupingResolver = (choice) => {
|
||||
resolvedChoice = choice;
|
||||
};
|
||||
|
||||
const originalGetTracker = options.getSubtitleTimingTracker;
|
||||
options.getSubtitleTimingTracker = () =>
|
||||
({ cleanup: () => {
|
||||
cleaned += 1;
|
||||
} }) as never;
|
||||
|
||||
const choice = {
|
||||
keepNoteId: 10,
|
||||
deleteNoteId: 11,
|
||||
deleteDuplicate: true,
|
||||
cancelled: false,
|
||||
};
|
||||
registered.clearAnkiHistory();
|
||||
registered.respondFieldGrouping(choice);
|
||||
|
||||
options.getSubtitleTimingTracker = originalGetTracker;
|
||||
|
||||
assert.equal(cleaned, 1);
|
||||
assert.deepEqual(resolvedChoice, choice);
|
||||
assert.equal(state.fieldGroupingResolver, null);
|
||||
});
|
||||
|
||||
test("buildKikuMergePreview returns guard error when integration is missing", async () => {
|
||||
const { registered } = createHarness();
|
||||
|
||||
const result = await registered.buildKikuMergePreview({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
error: "AnkiConnect integration not enabled",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildKikuMergePreview delegates to integration when available", async () => {
|
||||
const { registered, state } = createHarness();
|
||||
const calls: unknown[] = [];
|
||||
state.ankiIntegration = {
|
||||
buildFieldGroupingPreview: async (
|
||||
keepNoteId: number,
|
||||
deleteNoteId: number,
|
||||
deleteDuplicate: boolean,
|
||||
) => {
|
||||
calls.push([keepNoteId, deleteNoteId, deleteDuplicate]);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await registered.buildKikuMergePreview({
|
||||
keepNoteId: 3,
|
||||
deleteNoteId: 4,
|
||||
deleteDuplicate: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [[3, 4, true]]);
|
||||
assert.deepEqual(result, { ok: true });
|
||||
});
|
||||
|
||||
test("searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv", async () => {
|
||||
const { registered, state } = createHarness();
|
||||
|
||||
const searchResult = await registered.searchJimakuEntries({ query: "test" });
|
||||
assert.deepEqual(state.fetchCalls, [
|
||||
{
|
||||
endpoint: "/api/entries/search",
|
||||
query: { anime: true, query: "test" },
|
||||
},
|
||||
]);
|
||||
assert.equal((searchResult as { ok: boolean }).ok, true);
|
||||
assert.equal((searchResult as { data: unknown[] }).data.length, 2);
|
||||
|
||||
registered.onDownloadedSubtitle("/tmp/subtitle.ass");
|
||||
assert.deepEqual(state.sentCommands, [
|
||||
{ command: ["sub-add", "/tmp/subtitle.ass", "select"] },
|
||||
]);
|
||||
});
|
||||
@@ -59,8 +59,9 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
|
||||
export function registerAnkiJimakuIpcRuntimeService(
|
||||
options: AnkiJimakuIpcRuntimeOptions,
|
||||
registerHandlers: typeof registerAnkiJimakuIpcHandlers = registerAnkiJimakuIpcHandlers,
|
||||
): void {
|
||||
registerAnkiJimakuIpcHandlers({
|
||||
registerHandlers({
|
||||
setAnkiConnectEnabled: (enabled) => {
|
||||
options.patchAnkiConnectEnabled(enabled);
|
||||
const config = options.getResolvedConfig();
|
||||
|
||||
@@ -2,7 +2,7 @@ export { TexthookerService } from "./texthooker-service";
|
||||
export { hasMpvWebsocketPlugin, SubtitleWebSocketService } from "./subtitle-ws-service";
|
||||
export { registerGlobalShortcutsService } from "./shortcut-service";
|
||||
export { createIpcDepsRuntimeService, registerIpcHandlersService } from "./ipc-service";
|
||||
export { isGlobalShortcutRegisteredSafe, shortcutMatchesInputForLocalFallback } from "./shortcut-fallback-service";
|
||||
export { shortcutMatchesInputForLocalFallback } from "./shortcut-fallback-service";
|
||||
export {
|
||||
refreshOverlayShortcutsRuntimeService,
|
||||
registerOverlayShortcutsService,
|
||||
|
||||
168
src/core/services/mining-service.test.ts
Normal file
168
src/core/services/mining-service.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
copyCurrentSubtitleService,
|
||||
handleMineSentenceDigitService,
|
||||
handleMultiCopyDigitService,
|
||||
mineSentenceCardService,
|
||||
} from "./mining-service";
|
||||
|
||||
test("copyCurrentSubtitleService reports tracker and subtitle guards", () => {
|
||||
const osd: string[] = [];
|
||||
const copied: string[] = [];
|
||||
|
||||
copyCurrentSubtitleService({
|
||||
subtitleTimingTracker: null,
|
||||
writeClipboardText: (text) => copied.push(text),
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
assert.equal(osd.at(-1), "Subtitle tracker not available");
|
||||
|
||||
copyCurrentSubtitleService({
|
||||
subtitleTimingTracker: {
|
||||
getRecentBlocks: () => [],
|
||||
getCurrentSubtitle: () => null,
|
||||
findTiming: () => null,
|
||||
},
|
||||
writeClipboardText: (text) => copied.push(text),
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
assert.equal(osd.at(-1), "No current subtitle");
|
||||
assert.deepEqual(copied, []);
|
||||
});
|
||||
|
||||
test("copyCurrentSubtitleService copies current subtitle text", () => {
|
||||
const osd: string[] = [];
|
||||
const copied: string[] = [];
|
||||
|
||||
copyCurrentSubtitleService({
|
||||
subtitleTimingTracker: {
|
||||
getRecentBlocks: () => [],
|
||||
getCurrentSubtitle: () => "hello world",
|
||||
findTiming: () => null,
|
||||
},
|
||||
writeClipboardText: (text) => copied.push(text),
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
|
||||
assert.deepEqual(copied, ["hello world"]);
|
||||
assert.equal(osd.at(-1), "Copied subtitle");
|
||||
});
|
||||
|
||||
test("mineSentenceCardService handles missing integration and disconnected mpv", async () => {
|
||||
const osd: string[] = [];
|
||||
|
||||
await mineSentenceCardService({
|
||||
ankiIntegration: null,
|
||||
mpvClient: null,
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
assert.equal(osd.at(-1), "AnkiConnect integration not enabled");
|
||||
|
||||
await mineSentenceCardService({
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async () => {},
|
||||
},
|
||||
mpvClient: {
|
||||
connected: false,
|
||||
currentSubText: "line",
|
||||
currentSubStart: 1,
|
||||
currentSubEnd: 2,
|
||||
},
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
|
||||
assert.equal(osd.at(-1), "MPV not connected");
|
||||
});
|
||||
|
||||
test("mineSentenceCardService creates sentence card from mpv subtitle state", async () => {
|
||||
const created: Array<{
|
||||
sentence: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
secondarySub?: string;
|
||||
}> = [];
|
||||
|
||||
await mineSentenceCardService({
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async (sentence, startTime, endTime, secondarySub) => {
|
||||
created.push({ sentence, startTime, endTime, secondarySub });
|
||||
},
|
||||
},
|
||||
mpvClient: {
|
||||
connected: true,
|
||||
currentSubText: "subtitle line",
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSecondarySubText: "secondary line",
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(created, [
|
||||
{
|
||||
sentence: "subtitle line",
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
secondarySub: "secondary line",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("handleMultiCopyDigitService copies available history and reports truncation", () => {
|
||||
const osd: string[] = [];
|
||||
const copied: string[] = [];
|
||||
|
||||
handleMultiCopyDigitService(5, {
|
||||
subtitleTimingTracker: {
|
||||
getRecentBlocks: (count) => ["a", "b"].slice(0, count),
|
||||
getCurrentSubtitle: () => null,
|
||||
findTiming: () => null,
|
||||
},
|
||||
writeClipboardText: (text) => copied.push(text),
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
});
|
||||
|
||||
assert.deepEqual(copied, ["a\n\nb"]);
|
||||
assert.equal(osd.at(-1), "Only 2 lines available, copied 2");
|
||||
});
|
||||
|
||||
test("handleMineSentenceDigitService reports async create failures", async () => {
|
||||
const osd: string[] = [];
|
||||
const logs: Array<{ message: string; err: unknown }> = [];
|
||||
|
||||
handleMineSentenceDigitService(2, {
|
||||
subtitleTimingTracker: {
|
||||
getRecentBlocks: () => ["one", "two"],
|
||||
getCurrentSubtitle: () => null,
|
||||
findTiming: (text) =>
|
||||
text === "one"
|
||||
? { startTime: 1, endTime: 3 }
|
||||
: { startTime: 4, endTime: 7 },
|
||||
},
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async () => {
|
||||
throw new Error("mine boom");
|
||||
},
|
||||
},
|
||||
getCurrentSecondarySubText: () => "sub2",
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
logError: (message, err) => logs.push({ message, err }),
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(logs.length, 1);
|
||||
assert.equal(logs[0]?.message, "mineSentenceMultiple failed:");
|
||||
assert.equal((logs[0]?.err as Error).message, "mine boom");
|
||||
assert.ok(osd.some((entry) => entry.includes("Mine sentence failed: mine boom")));
|
||||
});
|
||||
256
src/core/services/overlay-shortcut-handler.test.ts
Normal file
256
src/core/services/overlay-shortcut-handler.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ConfiguredShortcuts } from "../utils/shortcut-config";
|
||||
import {
|
||||
createOverlayShortcutRuntimeHandlers,
|
||||
OverlayShortcutRuntimeDeps,
|
||||
runOverlayShortcutLocalFallback,
|
||||
} from "./overlay-shortcut-handler";
|
||||
|
||||
function makeShortcuts(
|
||||
overrides: Partial<ConfiguredShortcuts> = {},
|
||||
): ConfiguredShortcuts {
|
||||
return {
|
||||
toggleVisibleOverlayGlobal: null,
|
||||
toggleInvisibleOverlayGlobal: null,
|
||||
copySubtitle: null,
|
||||
copySubtitleMultiple: null,
|
||||
updateLastCardFromClipboard: null,
|
||||
triggerFieldGrouping: null,
|
||||
triggerSubsync: null,
|
||||
mineSentence: null,
|
||||
mineSentenceMultiple: null,
|
||||
multiCopyTimeoutMs: 2500,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
|
||||
const calls: string[] = [];
|
||||
const osd: string[] = [];
|
||||
const deps: OverlayShortcutRuntimeDeps = {
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
openRuntimeOptions: () => {
|
||||
calls.push("openRuntimeOptions");
|
||||
},
|
||||
openJimaku: () => {
|
||||
calls.push("openJimaku");
|
||||
},
|
||||
markAudioCard: async () => {
|
||||
calls.push("markAudioCard");
|
||||
},
|
||||
copySubtitleMultiple: (timeoutMs) => {
|
||||
calls.push(`copySubtitleMultiple:${timeoutMs}`);
|
||||
},
|
||||
copySubtitle: () => {
|
||||
calls.push("copySubtitle");
|
||||
},
|
||||
toggleSecondarySub: () => {
|
||||
calls.push("toggleSecondarySub");
|
||||
},
|
||||
updateLastCardFromClipboard: async () => {
|
||||
calls.push("updateLastCardFromClipboard");
|
||||
},
|
||||
triggerFieldGrouping: async () => {
|
||||
calls.push("triggerFieldGrouping");
|
||||
},
|
||||
triggerSubsync: async () => {
|
||||
calls.push("triggerSubsync");
|
||||
},
|
||||
mineSentence: async () => {
|
||||
calls.push("mineSentence");
|
||||
},
|
||||
mineSentenceMultiple: (timeoutMs) => {
|
||||
calls.push(`mineSentenceMultiple:${timeoutMs}`);
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return { deps, calls, osd };
|
||||
}
|
||||
|
||||
test("createOverlayShortcutRuntimeHandlers dispatches sync and async handlers", async () => {
|
||||
const { deps, calls } = createDeps();
|
||||
const { overlayHandlers, fallbackHandlers } =
|
||||
createOverlayShortcutRuntimeHandlers(deps);
|
||||
|
||||
overlayHandlers.copySubtitle();
|
||||
overlayHandlers.copySubtitleMultiple(1111);
|
||||
overlayHandlers.toggleSecondarySub();
|
||||
overlayHandlers.openRuntimeOptions();
|
||||
overlayHandlers.openJimaku();
|
||||
overlayHandlers.mineSentenceMultiple(2222);
|
||||
overlayHandlers.updateLastCardFromClipboard();
|
||||
fallbackHandlers.mineSentence();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"copySubtitle",
|
||||
"copySubtitleMultiple:1111",
|
||||
"toggleSecondarySub",
|
||||
"openRuntimeOptions",
|
||||
"openJimaku",
|
||||
"mineSentenceMultiple:2222",
|
||||
"updateLastCardFromClipboard",
|
||||
"mineSentence",
|
||||
]);
|
||||
});
|
||||
|
||||
test("createOverlayShortcutRuntimeHandlers reports async failures via OSD", async () => {
|
||||
const logs: unknown[][] = [];
|
||||
const originalError = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
logs.push(args);
|
||||
};
|
||||
|
||||
try {
|
||||
const { deps, osd } = createDeps({
|
||||
markAudioCard: async () => {
|
||||
throw new Error("audio boom");
|
||||
},
|
||||
});
|
||||
const { overlayHandlers } = createOverlayShortcutRuntimeHandlers(deps);
|
||||
|
||||
overlayHandlers.markAudioCard();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(logs.length, 1);
|
||||
assert.equal(logs[0]?.[0], "markLastCardAsAudioCard failed:");
|
||||
assert.ok(osd.some((entry) => entry.includes("Audio card failed: audio boom")));
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
});
|
||||
|
||||
test("runOverlayShortcutLocalFallback dispatches matching actions with timeout", () => {
|
||||
const handled: string[] = [];
|
||||
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
|
||||
const shortcuts = makeShortcuts({
|
||||
copySubtitleMultiple: "Ctrl+M",
|
||||
multiCopyTimeoutMs: 4321,
|
||||
});
|
||||
|
||||
const result = runOverlayShortcutLocalFallback(
|
||||
{} as Electron.Input,
|
||||
shortcuts,
|
||||
(_input, accelerator, allowWhenRegistered) => {
|
||||
matched.push({
|
||||
accelerator,
|
||||
allowWhenRegistered: allowWhenRegistered === true,
|
||||
});
|
||||
return accelerator === "Ctrl+M";
|
||||
},
|
||||
{
|
||||
openRuntimeOptions: () => handled.push("openRuntimeOptions"),
|
||||
openJimaku: () => handled.push("openJimaku"),
|
||||
markAudioCard: () => handled.push("markAudioCard"),
|
||||
copySubtitleMultiple: (timeoutMs) =>
|
||||
handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
copySubtitle: () => handled.push("copySubtitle"),
|
||||
toggleSecondarySub: () => handled.push("toggleSecondarySub"),
|
||||
updateLastCardFromClipboard: () =>
|
||||
handled.push("updateLastCardFromClipboard"),
|
||||
triggerFieldGrouping: () => handled.push("triggerFieldGrouping"),
|
||||
triggerSubsync: () => handled.push("triggerSubsync"),
|
||||
mineSentence: () => handled.push("mineSentence"),
|
||||
mineSentenceMultiple: (timeoutMs) =>
|
||||
handled.push(`mineSentenceMultiple:${timeoutMs}`),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(handled, ["copySubtitleMultiple:4321"]);
|
||||
assert.deepEqual(matched, [{ accelerator: "Ctrl+M", allowWhenRegistered: false }]);
|
||||
});
|
||||
|
||||
test("runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle", () => {
|
||||
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
|
||||
const shortcuts = makeShortcuts({
|
||||
toggleSecondarySub: "Ctrl+2",
|
||||
});
|
||||
|
||||
const result = runOverlayShortcutLocalFallback(
|
||||
{} as Electron.Input,
|
||||
shortcuts,
|
||||
(_input, accelerator, allowWhenRegistered) => {
|
||||
matched.push({
|
||||
accelerator,
|
||||
allowWhenRegistered: allowWhenRegistered === true,
|
||||
});
|
||||
return accelerator === "Ctrl+2";
|
||||
},
|
||||
{
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
copySubtitle: () => {},
|
||||
toggleSecondarySub: () => {},
|
||||
updateLastCardFromClipboard: () => {},
|
||||
triggerFieldGrouping: () => {},
|
||||
triggerSubsync: () => {},
|
||||
mineSentence: () => {},
|
||||
mineSentenceMultiple: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(matched, [{ accelerator: "Ctrl+2", allowWhenRegistered: true }]);
|
||||
});
|
||||
|
||||
test("runOverlayShortcutLocalFallback returns false when no action matches", () => {
|
||||
const shortcuts = makeShortcuts({
|
||||
copySubtitle: "Ctrl+C",
|
||||
});
|
||||
let called = false;
|
||||
|
||||
const result = runOverlayShortcutLocalFallback(
|
||||
{} as Electron.Input,
|
||||
shortcuts,
|
||||
() => false,
|
||||
{
|
||||
openRuntimeOptions: () => {
|
||||
called = true;
|
||||
},
|
||||
openJimaku: () => {
|
||||
called = true;
|
||||
},
|
||||
markAudioCard: () => {
|
||||
called = true;
|
||||
},
|
||||
copySubtitleMultiple: () => {
|
||||
called = true;
|
||||
},
|
||||
copySubtitle: () => {
|
||||
called = true;
|
||||
},
|
||||
toggleSecondarySub: () => {
|
||||
called = true;
|
||||
},
|
||||
updateLastCardFromClipboard: () => {
|
||||
called = true;
|
||||
},
|
||||
triggerFieldGrouping: () => {
|
||||
called = true;
|
||||
},
|
||||
triggerSubsync: () => {
|
||||
called = true;
|
||||
},
|
||||
mineSentence: () => {
|
||||
called = true;
|
||||
},
|
||||
mineSentenceMultiple: () => {
|
||||
called = true;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
@@ -80,7 +80,20 @@ export function loadSubtitlePositionService(options: {
|
||||
typeof parsed.yPercent === "number" &&
|
||||
Number.isFinite(parsed.yPercent)
|
||||
) {
|
||||
return { yPercent: parsed.yPercent };
|
||||
const position: SubtitlePosition = { yPercent: parsed.yPercent };
|
||||
if (
|
||||
typeof parsed.invisibleOffsetXPx === "number" &&
|
||||
Number.isFinite(parsed.invisibleOffsetXPx)
|
||||
) {
|
||||
position.invisibleOffsetXPx = parsed.invisibleOffsetXPx;
|
||||
}
|
||||
if (
|
||||
typeof parsed.invisibleOffsetYPx === "number" &&
|
||||
Number.isFinite(parsed.invisibleOffsetYPx)
|
||||
) {
|
||||
position.invisibleOffsetYPx = parsed.invisibleOffsetYPx;
|
||||
}
|
||||
return position;
|
||||
}
|
||||
return options.fallbackPosition;
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user