mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
test(core): expand mpv/subsync/tokenizer and cli coverage
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs",
|
||||
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort",
|
||||
"test:config": "pnpm run build && node --test dist/config/config.test.js",
|
||||
"test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/cli-command-deps-runtime-service.test.js dist/core/services/ipc-deps-runtime-service.test.js dist/core/services/field-grouping-overlay-runtime-service.test.js dist/core/services/numeric-shortcut-runtime-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/tokenizer-deps-runtime-service.test.js dist/core/services/shortcut-ui-deps-runtime-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-runtime-service.test.js dist/core/services/runtime-options-runtime-service.test.js dist/core/services/overlay-modal-restore-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/overlay-bridge-runtime-service.test.js dist/core/services/overlay-broadcast-runtime-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/app-ready-runtime-service.test.js dist/core/services/app-shutdown-runtime-service.test.js dist/core/services/app-lifecycle-deps-runtime-service.test.js dist/core/services/runtime-options-manager-runtime-service.test.js dist/core/services/config-warning-runtime-service.test.js dist/core/services/app-logging-runtime-service.test.js dist/core/services/startup-resource-runtime-service.test.js dist/core/services/config-generation-runtime-service.test.js",
|
||||
"test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/field-grouping-overlay-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-control-service.test.js dist/core/services/mpv-service.test.js dist/core/services/runtime-options-ipc-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/tokenizer-service.test.js dist/core/services/subsync-service.test.js dist/core/services/overlay-bridge-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/app-ready-service.test.js dist/core/services/startup-bootstrap-service.test.js dist/subsync/utils.test.js",
|
||||
"test:subtitle": "pnpm run build && node --test dist/subtitle/stages.test.js dist/subtitle/pipeline.test.js",
|
||||
"generate:config-example": "pnpm run build && node dist/generate-config-example.js",
|
||||
"start": "pnpm run build && electron . --start",
|
||||
|
||||
@@ -179,3 +179,112 @@ test("handleCliCommandService reports async mine errors to OSD", async () => {
|
||||
assert.ok(calls.some((value) => value.startsWith("error:mineSentenceCard failed:")));
|
||||
assert.ok(osd.some((value) => value.includes("Mine sentence failed: boom")));
|
||||
});
|
||||
|
||||
test("handleCliCommandService applies socket path and connects on start", () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
handleCliCommandService(
|
||||
makeArgs({ start: true, socketPath: "/tmp/custom.sock" }),
|
||||
"initial",
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.ok(calls.includes("setMpvSocketPath:/tmp/custom.sock"));
|
||||
assert.ok(calls.includes("setMpvClientSocketPath:/tmp/custom.sock"));
|
||||
assert.ok(calls.includes("connectMpvClient"));
|
||||
});
|
||||
|
||||
test("handleCliCommandService warns when texthooker port override used while running", () => {
|
||||
const { deps, calls } = createDeps({
|
||||
isTexthookerRunning: () => true,
|
||||
});
|
||||
|
||||
handleCliCommandService(
|
||||
makeArgs({ texthookerPort: 9999, texthooker: true }),
|
||||
"initial",
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
calls.includes(
|
||||
"warn:Ignoring --port override because the texthooker server is already running.",
|
||||
),
|
||||
);
|
||||
assert.equal(calls.some((value) => value === "setTexthookerPort:9999"), false);
|
||||
});
|
||||
|
||||
test("handleCliCommandService prints help and stops app when no window exists", () => {
|
||||
const { deps, calls } = createDeps({
|
||||
hasMainWindow: () => false,
|
||||
});
|
||||
|
||||
handleCliCommandService(makeArgs({ help: true }), "initial", deps);
|
||||
|
||||
assert.ok(calls.includes("printHelp"));
|
||||
assert.ok(calls.includes("stopApp"));
|
||||
});
|
||||
|
||||
test("handleCliCommandService reports async trigger-subsync errors to OSD", async () => {
|
||||
const { deps, calls, osd } = createDeps({
|
||||
triggerSubsyncFromConfig: async () => {
|
||||
throw new Error("subsync boom");
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommandService(makeArgs({ triggerSubsync: true }), "initial", deps);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.ok(
|
||||
calls.some((value) => value.startsWith("error:triggerSubsyncFromConfig failed:")),
|
||||
);
|
||||
assert.ok(osd.some((value) => value.includes("Subsync failed: subsync boom")));
|
||||
});
|
||||
|
||||
test("handleCliCommandService stops app for --stop command", () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommandService(makeArgs({ stop: true }), "initial", deps);
|
||||
assert.ok(calls.includes("log:Stopping SubMiner..."));
|
||||
assert.ok(calls.includes("stopApp"));
|
||||
});
|
||||
|
||||
test("handleCliCommandService still runs non-start actions on second-instance", () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommandService(
|
||||
makeArgs({ start: true, toggleVisibleOverlay: true }),
|
||||
"second-instance",
|
||||
deps,
|
||||
);
|
||||
assert.ok(calls.includes("toggleVisibleOverlay"));
|
||||
assert.equal(calls.some((value) => value === "connectMpvClient"), false);
|
||||
});
|
||||
|
||||
test("handleCliCommandService handles visibility and utility command dispatches", () => {
|
||||
const cases: Array<{
|
||||
args: Partial<CliArgs>;
|
||||
expected: string;
|
||||
}> = [
|
||||
{ args: { toggleInvisibleOverlay: true }, expected: "toggleInvisibleOverlay" },
|
||||
{ args: { settings: true }, expected: "openYomitanSettingsDelayed:1000" },
|
||||
{ args: { showVisibleOverlay: true }, expected: "setVisibleOverlayVisible:true" },
|
||||
{ args: { hideVisibleOverlay: true }, expected: "setVisibleOverlayVisible:false" },
|
||||
{ args: { showInvisibleOverlay: true }, expected: "setInvisibleOverlayVisible:true" },
|
||||
{ args: { hideInvisibleOverlay: true }, expected: "setInvisibleOverlayVisible:false" },
|
||||
{ args: { copySubtitle: true }, expected: "copyCurrentSubtitle" },
|
||||
{ args: { copySubtitleMultiple: true }, expected: "startPendingMultiCopy:2500" },
|
||||
{
|
||||
args: { mineSentenceMultiple: true },
|
||||
expected: "startPendingMineSentenceMultiple:2500",
|
||||
},
|
||||
{ args: { toggleSecondarySub: true }, expected: "cycleSecondarySubMode" },
|
||||
{ args: { openRuntimeOptions: true }, expected: "openRuntimeOptionsPalette" },
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommandService(makeArgs(entry.args), "initial", deps);
|
||||
assert.ok(
|
||||
calls.includes(entry.expected),
|
||||
`expected call missing for args ${JSON.stringify(entry.args)}: ${entry.expected}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
176
src/core/services/mpv-service.test.ts
Normal file
176
src/core/services/mpv-service.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { MpvIpcClient, MpvIpcClientDeps } from "./mpv-service";
|
||||
|
||||
function makeDeps(
|
||||
overrides: Partial<MpvIpcClientDeps> = {},
|
||||
): MpvIpcClientDeps {
|
||||
return {
|
||||
getResolvedConfig: () => ({} as any),
|
||||
autoStartOverlay: false,
|
||||
setOverlayVisible: () => {},
|
||||
shouldBindVisibleOverlayToMpvSubVisibility: () => false,
|
||||
isVisibleOverlayVisible: () => false,
|
||||
getReconnectTimer: () => null,
|
||||
setReconnectTimer: () => {},
|
||||
getCurrentSubText: () => "",
|
||||
setCurrentSubText: () => {},
|
||||
setCurrentSubAssText: () => {},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
subtitleWsBroadcast: () => {},
|
||||
getOverlayWindowsCount: () => 0,
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: null }),
|
||||
broadcastToOverlayWindows: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
updateMpvSubtitleRenderMetrics: () => {},
|
||||
getMpvSubtitleRenderMetrics: () => ({
|
||||
subPos: 100,
|
||||
subFontSize: 36,
|
||||
subScale: 1,
|
||||
subMarginY: 0,
|
||||
subMarginX: 0,
|
||||
subFont: "sans-serif",
|
||||
subSpacing: 0,
|
||||
subBold: false,
|
||||
subItalic: false,
|
||||
subBorderSize: 0,
|
||||
subShadowOffset: 0,
|
||||
subAssOverride: "yes",
|
||||
subScaleByWindow: true,
|
||||
subUseMargins: true,
|
||||
osdHeight: 720,
|
||||
osdDimensions: null,
|
||||
}),
|
||||
setPreviousSecondarySubVisibility: () => {},
|
||||
showMpvOsd: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("MpvIpcClient resolves pending request by request_id", async () => {
|
||||
const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps());
|
||||
let resolved: unknown = null;
|
||||
(client as any).pendingRequests.set(1234, (msg: unknown) => {
|
||||
resolved = msg;
|
||||
});
|
||||
|
||||
await (client as any).handleMessage({ request_id: 1234, data: "ok" });
|
||||
|
||||
assert.deepEqual(resolved, { request_id: 1234, data: "ok" });
|
||||
assert.equal((client as any).pendingRequests.size, 0);
|
||||
});
|
||||
|
||||
test("MpvIpcClient handles sub-text property change and broadcasts tokenized subtitle", async () => {
|
||||
const calls: string[] = [];
|
||||
const client = new MpvIpcClient(
|
||||
"/tmp/mpv.sock",
|
||||
makeDeps({
|
||||
setCurrentSubText: (text) => {
|
||||
calls.push(`setCurrentSubText:${text}`);
|
||||
},
|
||||
subtitleWsBroadcast: (text) => {
|
||||
calls.push(`subtitleWsBroadcast:${text}`);
|
||||
},
|
||||
getOverlayWindowsCount: () => 1,
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: null }),
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
calls.push(`broadcast:${channel}:${String((payload as any).text ?? "")}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await (client as any).handleMessage({
|
||||
event: "property-change",
|
||||
name: "sub-text",
|
||||
data: "字幕",
|
||||
});
|
||||
|
||||
assert.ok(calls.includes("setCurrentSubText:字幕"));
|
||||
assert.ok(calls.includes("subtitleWsBroadcast:字幕"));
|
||||
assert.ok(calls.includes("broadcast:subtitle:set:字幕"));
|
||||
});
|
||||
|
||||
test("MpvIpcClient parses JSON line protocol in processBuffer", () => {
|
||||
const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps());
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
(client as any).handleMessage = (msg: Record<string, unknown>) => {
|
||||
seen.push(msg);
|
||||
};
|
||||
(client as any).buffer =
|
||||
"{\"event\":\"property-change\",\"name\":\"path\",\"data\":\"a\"}\n{\"request_id\":1,\"data\":\"ok\"}\n{\"partial\":";
|
||||
|
||||
(client as any).processBuffer();
|
||||
|
||||
assert.equal(seen.length, 2);
|
||||
assert.equal(seen[0].name, "path");
|
||||
assert.equal(seen[1].request_id, 1);
|
||||
assert.equal((client as any).buffer, "{\"partial\":");
|
||||
});
|
||||
|
||||
test("MpvIpcClient request rejects when disconnected", async () => {
|
||||
const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps());
|
||||
await assert.rejects(
|
||||
async () => client.request(["get_property", "path"]),
|
||||
/MPV not connected/,
|
||||
);
|
||||
});
|
||||
|
||||
test("MpvIpcClient requestProperty throws on mpv error response", async () => {
|
||||
const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps());
|
||||
(client as any).request = async () => ({ error: "property unavailable" });
|
||||
await assert.rejects(
|
||||
async () => client.requestProperty("path"),
|
||||
/Failed to read MPV property 'path': property unavailable/,
|
||||
);
|
||||
});
|
||||
|
||||
test("MpvIpcClient failPendingRequests resolves outstanding requests as disconnected", () => {
|
||||
const client = new MpvIpcClient("/tmp/mpv.sock", makeDeps());
|
||||
const resolved: unknown[] = [];
|
||||
(client as any).pendingRequests.set(10, (msg: unknown) => {
|
||||
resolved.push(msg);
|
||||
});
|
||||
(client as any).pendingRequests.set(11, (msg: unknown) => {
|
||||
resolved.push(msg);
|
||||
});
|
||||
|
||||
(client as any).failPendingRequests();
|
||||
|
||||
assert.deepEqual(resolved, [
|
||||
{ request_id: 10, error: "disconnected" },
|
||||
{ request_id: 11, error: "disconnected" },
|
||||
]);
|
||||
assert.equal((client as any).pendingRequests.size, 0);
|
||||
});
|
||||
|
||||
test("MpvIpcClient scheduleReconnect schedules timer and invokes connect", () => {
|
||||
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
|
||||
const client = new MpvIpcClient(
|
||||
"/tmp/mpv.sock",
|
||||
makeDeps({
|
||||
getReconnectTimer: () => null,
|
||||
setReconnectTimer: (timer) => {
|
||||
timers.push(timer);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let connectCalled = false;
|
||||
(client as any).connect = () => {
|
||||
connectCalled = true;
|
||||
};
|
||||
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
(globalThis as any).setTimeout = (handler: () => void, _delay: number) => {
|
||||
handler();
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
};
|
||||
try {
|
||||
(client as any).scheduleReconnect();
|
||||
} finally {
|
||||
(globalThis as any).setTimeout = originalSetTimeout;
|
||||
}
|
||||
|
||||
assert.equal(timers.length, 1);
|
||||
assert.equal(connectCalled, true);
|
||||
});
|
||||
300
src/core/services/subsync-service.test.ts
Normal file
300
src/core/services/subsync-service.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import {
|
||||
TriggerSubsyncFromConfigDeps,
|
||||
runSubsyncManualService,
|
||||
triggerSubsyncFromConfigService,
|
||||
} from "./subsync-service";
|
||||
|
||||
function makeDeps(
|
||||
overrides: Partial<TriggerSubsyncFromConfigDeps> = {},
|
||||
): TriggerSubsyncFromConfigDeps {
|
||||
const mpvClient = {
|
||||
connected: true,
|
||||
currentAudioStreamIndex: null,
|
||||
send: () => {},
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === "path") return "/tmp/video.mkv";
|
||||
if (name === "sid") return 1;
|
||||
if (name === "secondary-sid") return null;
|
||||
if (name === "track-list") {
|
||||
return [
|
||||
{ id: 1, type: "sub", selected: true, lang: "jpn" },
|
||||
{
|
||||
id: 2,
|
||||
type: "sub",
|
||||
selected: false,
|
||||
external: true,
|
||||
lang: "eng",
|
||||
"external-filename": "/tmp/ref.srt",
|
||||
},
|
||||
{ id: 3, type: "audio", selected: true, "ff-index": 1 },
|
||||
];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
getMpvClient: () => mpvClient,
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: "manual",
|
||||
alassPath: "/usr/bin/alass",
|
||||
ffsubsyncPath: "/usr/bin/ffsubsync",
|
||||
ffmpegPath: "/usr/bin/ffmpeg",
|
||||
}),
|
||||
isSubsyncInProgress: () => false,
|
||||
setSubsyncInProgress: () => {},
|
||||
showMpvOsd: () => {},
|
||||
runWithSubsyncSpinner: async <T>(task: () => Promise<T>) => task(),
|
||||
openManualPicker: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("triggerSubsyncFromConfigService returns early when already in progress", async () => {
|
||||
const osd: string[] = [];
|
||||
await triggerSubsyncFromConfigService(
|
||||
makeDeps({
|
||||
isSubsyncInProgress: () => true,
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(osd, ["Subsync already running"]);
|
||||
});
|
||||
|
||||
test("triggerSubsyncFromConfigService opens manual picker in manual mode", async () => {
|
||||
const osd: string[] = [];
|
||||
let payloadTrackCount = 0;
|
||||
let inProgressState: boolean | null = null;
|
||||
|
||||
await triggerSubsyncFromConfigService(
|
||||
makeDeps({
|
||||
openManualPicker: (payload) => {
|
||||
payloadTrackCount = payload.sourceTracks.length;
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
setSubsyncInProgress: (value) => {
|
||||
inProgressState = value;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(payloadTrackCount, 1);
|
||||
assert.ok(osd.includes("Subsync: choose engine and source"));
|
||||
assert.equal(inProgressState, false);
|
||||
});
|
||||
|
||||
test("triggerSubsyncFromConfigService reports failures to OSD", async () => {
|
||||
const osd: string[] = [];
|
||||
await triggerSubsyncFromConfigService(
|
||||
makeDeps({
|
||||
getMpvClient: () => null,
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.ok(osd.some((line) => line.startsWith("Subsync failed: MPV not connected")));
|
||||
});
|
||||
|
||||
test("runSubsyncManualService requires a source track for alass", async () => {
|
||||
const result = await runSubsyncManualService(
|
||||
{ engine: "alass", sourceTrackId: null },
|
||||
makeDeps(),
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
message: "Select a subtitle source track for alass",
|
||||
});
|
||||
});
|
||||
|
||||
test("triggerSubsyncFromConfigService reports path validation failures", async () => {
|
||||
const osd: string[] = [];
|
||||
const inProgress: boolean[] = [];
|
||||
|
||||
await triggerSubsyncFromConfigService(
|
||||
makeDeps({
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: "auto",
|
||||
alassPath: "/missing/alass",
|
||||
ffsubsyncPath: "/missing/ffsubsync",
|
||||
ffmpegPath: "/missing/ffmpeg",
|
||||
}),
|
||||
setSubsyncInProgress: (value) => {
|
||||
inProgress.push(value);
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(inProgress, [true, false]);
|
||||
assert.ok(
|
||||
osd.some((line) =>
|
||||
line.startsWith("Subsync failed: Configured ffmpeg executable not found"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
function writeExecutableScript(filePath: string, content: string): void {
|
||||
fs.writeFileSync(filePath, content, { encoding: "utf8", mode: 0o755 });
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
test("runSubsyncManualService constructs ffsubsync command and returns success", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subsync-ffsubsync-"));
|
||||
const ffsubsyncLogPath = path.join(tmpDir, "ffsubsync-args.log");
|
||||
const ffsubsyncPath = path.join(tmpDir, "ffsubsync.sh");
|
||||
const ffmpegPath = path.join(tmpDir, "ffmpeg.sh");
|
||||
const alassPath = path.join(tmpDir, "alass.sh");
|
||||
const videoPath = path.join(tmpDir, "video.mkv");
|
||||
const primaryPath = path.join(tmpDir, "primary.srt");
|
||||
|
||||
fs.writeFileSync(videoPath, "video");
|
||||
fs.writeFileSync(primaryPath, "sub");
|
||||
writeExecutableScript(
|
||||
ffmpegPath,
|
||||
"#!/bin/sh\nexit 0\n",
|
||||
);
|
||||
writeExecutableScript(
|
||||
alassPath,
|
||||
"#!/bin/sh\nexit 0\n",
|
||||
);
|
||||
writeExecutableScript(
|
||||
ffsubsyncPath,
|
||||
`#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`,
|
||||
);
|
||||
|
||||
const sentCommands: Array<Array<string | number>> = [];
|
||||
const deps = makeDeps({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentAudioStreamIndex: 2,
|
||||
send: (payload) => {
|
||||
sentCommands.push(payload.command);
|
||||
},
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === "path") return videoPath;
|
||||
if (name === "sid") return 1;
|
||||
if (name === "secondary-sid") return null;
|
||||
if (name === "track-list") {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
type: "sub",
|
||||
selected: true,
|
||||
external: true,
|
||||
"external-filename": primaryPath,
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: "manual",
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSubsyncManualService(
|
||||
{ engine: "ffsubsync", sourceTrackId: null },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.message, "Subtitle synchronized with ffsubsync");
|
||||
const ffArgs = fs.readFileSync(ffsubsyncLogPath, "utf8").trim().split("\n");
|
||||
assert.equal(ffArgs[0], videoPath);
|
||||
assert.ok(ffArgs.includes("-i"));
|
||||
assert.ok(ffArgs.includes(primaryPath));
|
||||
assert.ok(ffArgs.includes("--reference-stream"));
|
||||
assert.ok(ffArgs.includes("0:2"));
|
||||
assert.equal(sentCommands[0]?.[0], "sub_add");
|
||||
assert.deepEqual(sentCommands[1], ["set_property", "sub-delay", 0]);
|
||||
});
|
||||
|
||||
test("runSubsyncManualService constructs alass command and returns failure on non-zero exit", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subsync-alass-"));
|
||||
const alassLogPath = path.join(tmpDir, "alass-args.log");
|
||||
const alassPath = path.join(tmpDir, "alass.sh");
|
||||
const ffmpegPath = path.join(tmpDir, "ffmpeg.sh");
|
||||
const ffsubsyncPath = path.join(tmpDir, "ffsubsync.sh");
|
||||
const videoPath = path.join(tmpDir, "video.mkv");
|
||||
const primaryPath = path.join(tmpDir, "primary.srt");
|
||||
const sourcePath = path.join(tmpDir, "source.srt");
|
||||
|
||||
fs.writeFileSync(videoPath, "video");
|
||||
fs.writeFileSync(primaryPath, "sub");
|
||||
fs.writeFileSync(sourcePath, "sub2");
|
||||
writeExecutableScript(ffmpegPath, "#!/bin/sh\nexit 0\n");
|
||||
writeExecutableScript(ffsubsyncPath, "#!/bin/sh\nexit 0\n");
|
||||
writeExecutableScript(
|
||||
alassPath,
|
||||
`#!/bin/sh\n: > "${alassLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${alassLogPath}"; done\nexit 1\n`,
|
||||
);
|
||||
|
||||
const deps = makeDeps({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentAudioStreamIndex: null,
|
||||
send: () => {},
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === "path") return videoPath;
|
||||
if (name === "sid") return 1;
|
||||
if (name === "secondary-sid") return null;
|
||||
if (name === "track-list") {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
type: "sub",
|
||||
selected: true,
|
||||
external: true,
|
||||
"external-filename": primaryPath,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "sub",
|
||||
selected: false,
|
||||
external: true,
|
||||
"external-filename": sourcePath,
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: "manual",
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSubsyncManualService(
|
||||
{ engine: "alass", sourceTrackId: 2 },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
message: "alass synchronization failed",
|
||||
});
|
||||
const alassArgs = fs.readFileSync(alassLogPath, "utf8").trim().split("\n");
|
||||
assert.equal(alassArgs[0], sourcePath);
|
||||
assert.equal(alassArgs[1], primaryPath);
|
||||
});
|
||||
129
src/core/services/tokenizer-service.test.ts
Normal file
129
src/core/services/tokenizer-service.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { PartOfSpeech } from "../../types";
|
||||
import { tokenizeSubtitleService, TokenizerServiceDeps } from "./tokenizer-service";
|
||||
|
||||
function makeDeps(
|
||||
overrides: Partial<TokenizerServiceDeps> = {},
|
||||
): TokenizerServiceDeps {
|
||||
return {
|
||||
getYomitanExt: () => null,
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => {},
|
||||
getYomitanParserReadyPromise: () => null,
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
getYomitanParserInitPromise: () => null,
|
||||
setYomitanParserInitPromise: () => {},
|
||||
tokenizeWithMecab: async () => null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("tokenizeSubtitleService returns null tokens for empty normalized text", async () => {
|
||||
const result = await tokenizeSubtitleService(" \\n ", makeDeps());
|
||||
assert.deepEqual(result, { text: " \\n ", tokens: null });
|
||||
});
|
||||
|
||||
test("tokenizeSubtitleService normalizes newlines before mecab fallback", async () => {
|
||||
let tokenizeInput = "";
|
||||
const result = await tokenizeSubtitleService(
|
||||
"猫\\Nです\nね",
|
||||
makeDeps({
|
||||
tokenizeWithMecab: async (text) => {
|
||||
tokenizeInput = text;
|
||||
return [
|
||||
{
|
||||
surface: "猫ですね",
|
||||
reading: "ネコデスネ",
|
||||
headword: "猫ですね",
|
||||
startPos: 0,
|
||||
endPos: 4,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
isMerged: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(tokenizeInput, "猫 です ね");
|
||||
assert.equal(result.text, "猫\nです\nね");
|
||||
assert.equal(result.tokens?.[0]?.surface, "猫ですね");
|
||||
});
|
||||
|
||||
test("tokenizeSubtitleService falls back to mecab tokens when available", async () => {
|
||||
const result = await tokenizeSubtitleService(
|
||||
"猫です",
|
||||
makeDeps({
|
||||
tokenizeWithMecab: async () => [
|
||||
{
|
||||
surface: "猫",
|
||||
reading: "ネコ",
|
||||
headword: "猫",
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
isMerged: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.text, "猫です");
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.surface, "猫");
|
||||
});
|
||||
|
||||
test("tokenizeSubtitleService returns null tokens when mecab throws", async () => {
|
||||
const result = await tokenizeSubtitleService(
|
||||
"猫です",
|
||||
makeDeps({
|
||||
tokenizeWithMecab: async () => {
|
||||
throw new Error("mecab failed");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { text: "猫です", tokens: null });
|
||||
});
|
||||
|
||||
test("tokenizeSubtitleService uses Yomitan parser result when available", async () => {
|
||||
const parserWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: async () => [
|
||||
{
|
||||
source: "scanning-parser",
|
||||
index: 0,
|
||||
content: [
|
||||
[
|
||||
{
|
||||
text: "猫",
|
||||
reading: "ねこ",
|
||||
headwords: [[{ term: "猫" }]],
|
||||
},
|
||||
{
|
||||
text: "です",
|
||||
reading: "です",
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
|
||||
const result = await tokenizeSubtitleService(
|
||||
"猫です",
|
||||
makeDeps({
|
||||
getYomitanExt: () => ({ id: "dummy-ext" } as any),
|
||||
getYomitanParserWindow: () => parserWindow,
|
||||
tokenizeWithMecab: async () => null,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.text, "猫です");
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.surface, "猫です");
|
||||
assert.equal(result.tokens?.[0]?.reading, "ねこです");
|
||||
});
|
||||
34
src/subsync/utils.test.ts
Normal file
34
src/subsync/utils.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { getSubsyncConfig, runCommand } from "./utils";
|
||||
|
||||
test("getSubsyncConfig applies fallback executable paths for blank values", () => {
|
||||
const config = getSubsyncConfig({
|
||||
defaultMode: "manual",
|
||||
alass_path: " ",
|
||||
ffsubsync_path: "",
|
||||
ffmpeg_path: undefined,
|
||||
});
|
||||
|
||||
assert.equal(config.defaultMode, "manual");
|
||||
assert.equal(config.alassPath, "/usr/bin/alass");
|
||||
assert.equal(config.ffsubsyncPath, "/usr/bin/ffsubsync");
|
||||
assert.equal(config.ffmpegPath, "/usr/bin/ffmpeg");
|
||||
});
|
||||
|
||||
test("runCommand returns failure on timeout", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subsync-utils-"));
|
||||
const sleeperPath = path.join(tmpDir, "sleeper.sh");
|
||||
fs.writeFileSync(sleeperPath, "#!/bin/sh\nsleep 2\n", {
|
||||
encoding: "utf8",
|
||||
mode: 0o755,
|
||||
});
|
||||
fs.chmodSync(sleeperPath, 0o755);
|
||||
|
||||
const result = await runCommand(sleeperPath, [], 50);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
});
|
||||
Reference in New Issue
Block a user