From 35cad1983945441022ad6bf5bf8ca1ebc7b56dde Mon Sep 17 00:00:00 2001 From: kyasuda Date: Tue, 10 Feb 2026 13:13:47 -0800 Subject: [PATCH] test(core): expand mpv/subsync/tokenizer and cli coverage --- package.json | 2 +- src/core/services/cli-command-service.test.ts | 109 +++++++ src/core/services/mpv-service.test.ts | 176 ++++++++++ src/core/services/subsync-service.test.ts | 300 ++++++++++++++++++ src/core/services/tokenizer-service.test.ts | 129 ++++++++ src/subsync/utils.test.ts | 34 ++ 6 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 src/core/services/mpv-service.test.ts create mode 100644 src/core/services/subsync-service.test.ts create mode 100644 src/core/services/tokenizer-service.test.ts create mode 100644 src/subsync/utils.test.ts diff --git a/package.json b/package.json index c8696af..1e84382 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/services/cli-command-service.test.ts b/src/core/services/cli-command-service.test.ts index 3a0f969..ff4a434 100644 --- a/src/core/services/cli-command-service.test.ts +++ b/src/core/services/cli-command-service.test.ts @@ -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; + 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}`, + ); + } +}); diff --git a/src/core/services/mpv-service.test.ts b/src/core/services/mpv-service.test.ts new file mode 100644 index 0000000..342719a --- /dev/null +++ b/src/core/services/mpv-service.test.ts @@ -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 { + 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> = []; + (client as any).handleMessage = (msg: Record) => { + 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 | 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; + }; + try { + (client as any).scheduleReconnect(); + } finally { + (globalThis as any).setTimeout = originalSetTimeout; + } + + assert.equal(timers.length, 1); + assert.equal(connectCalled, true); +}); diff --git a/src/core/services/subsync-service.test.ts b/src/core/services/subsync-service.test.ts new file mode 100644 index 0000000..c64f2b9 --- /dev/null +++ b/src/core/services/subsync-service.test.ts @@ -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 { + 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 (task: () => Promise) => 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> = []; + 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); +}); diff --git a/src/core/services/tokenizer-service.test.ts b/src/core/services/tokenizer-service.test.ts new file mode 100644 index 0000000..aa0fd81 --- /dev/null +++ b/src/core/services/tokenizer-service.test.ts @@ -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 { + 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, "ねこです"); +}); diff --git a/src/subsync/utils.test.ts b/src/subsync/utils.test.ts new file mode 100644 index 0000000..e97879e --- /dev/null +++ b/src/subsync/utils.test.ts @@ -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); +});