From 10d9cc2db47b022a83d2e90d818c374cbedaf0b9 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 9 Feb 2026 22:56:22 -0800 Subject: [PATCH] test: add core service coverage for cli, shortcuts, and secondary subtitle --- package.json | 2 +- src/core/services/cli-command-service.test.ts | 181 ++++++++++++++++++ .../numeric-shortcut-session-service.test.ts | 108 +++++++++++ .../secondary-subtitle-service.test.ts | 64 +++++++ 4 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 src/core/services/cli-command-service.test.ts create mode 100644 src/core/services/numeric-shortcut-session-service.test.ts create mode 100644 src/core/services/secondary-subtitle-service.test.ts diff --git a/package.json b/package.json index 9856bbf..2f7de9c 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "docs:build": "vitepress build docs", "docs:preview": "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", + "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/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.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 new file mode 100644 index 0000000..3a0f969 --- /dev/null +++ b/src/core/services/cli-command-service.test.ts @@ -0,0 +1,181 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { CliArgs } from "../../cli/args"; +import { CliCommandServiceDeps, handleCliCommandService } from "./cli-command-service"; + +function makeArgs(overrides: Partial = {}): CliArgs { + return { + start: false, + stop: false, + toggle: false, + toggleVisibleOverlay: false, + toggleInvisibleOverlay: false, + settings: false, + show: false, + hide: false, + showVisibleOverlay: false, + hideVisibleOverlay: false, + showInvisibleOverlay: false, + hideInvisibleOverlay: false, + copySubtitle: false, + copySubtitleMultiple: false, + mineSentence: false, + mineSentenceMultiple: false, + updateLastCardFromClipboard: false, + toggleSecondarySub: false, + triggerFieldGrouping: false, + triggerSubsync: false, + markAudioCard: false, + openRuntimeOptions: false, + texthooker: false, + help: false, + autoStartOverlay: false, + generateConfig: false, + backupOverwrite: false, + verbose: false, + ...overrides, + }; +} + +function createDeps(overrides: Partial = {}) { + const calls: string[] = []; + let mpvSocketPath = "/tmp/subminer.sock"; + let texthookerPort = 5174; + const osd: string[] = []; + + const deps: CliCommandServiceDeps = { + getMpvSocketPath: () => mpvSocketPath, + setMpvSocketPath: (socketPath) => { + mpvSocketPath = socketPath; + calls.push(`setMpvSocketPath:${socketPath}`); + }, + setMpvClientSocketPath: (socketPath) => { + calls.push(`setMpvClientSocketPath:${socketPath}`); + }, + hasMpvClient: () => true, + connectMpvClient: () => { + calls.push("connectMpvClient"); + }, + isTexthookerRunning: () => false, + setTexthookerPort: (port) => { + texthookerPort = port; + calls.push(`setTexthookerPort:${port}`); + }, + getTexthookerPort: () => texthookerPort, + shouldOpenTexthookerBrowser: () => true, + ensureTexthookerRunning: (port) => { + calls.push(`ensureTexthookerRunning:${port}`); + }, + openTexthookerInBrowser: (url) => { + calls.push(`openTexthookerInBrowser:${url}`); + }, + stopApp: () => { + calls.push("stopApp"); + }, + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntime: () => { + calls.push("initializeOverlayRuntime"); + }, + toggleVisibleOverlay: () => { + calls.push("toggleVisibleOverlay"); + }, + toggleInvisibleOverlay: () => { + calls.push("toggleInvisibleOverlay"); + }, + openYomitanSettingsDelayed: (delayMs) => { + calls.push(`openYomitanSettingsDelayed:${delayMs}`); + }, + setVisibleOverlayVisible: (visible) => { + calls.push(`setVisibleOverlayVisible:${visible}`); + }, + setInvisibleOverlayVisible: (visible) => { + calls.push(`setInvisibleOverlayVisible:${visible}`); + }, + copyCurrentSubtitle: () => { + calls.push("copyCurrentSubtitle"); + }, + startPendingMultiCopy: (timeoutMs) => { + calls.push(`startPendingMultiCopy:${timeoutMs}`); + }, + mineSentenceCard: async () => { + calls.push("mineSentenceCard"); + }, + startPendingMineSentenceMultiple: (timeoutMs) => { + calls.push(`startPendingMineSentenceMultiple:${timeoutMs}`); + }, + updateLastCardFromClipboard: async () => { + calls.push("updateLastCardFromClipboard"); + }, + cycleSecondarySubMode: () => { + calls.push("cycleSecondarySubMode"); + }, + triggerFieldGrouping: async () => { + calls.push("triggerFieldGrouping"); + }, + triggerSubsyncFromConfig: async () => { + calls.push("triggerSubsyncFromConfig"); + }, + markLastCardAsAudioCard: async () => { + calls.push("markLastCardAsAudioCard"); + }, + openRuntimeOptionsPalette: () => { + calls.push("openRuntimeOptionsPalette"); + }, + printHelp: () => { + calls.push("printHelp"); + }, + hasMainWindow: () => true, + getMultiCopyTimeoutMs: () => 2500, + showMpvOsd: (text) => { + osd.push(text); + }, + log: (message) => { + calls.push(`log:${message}`); + }, + warn: (message) => { + calls.push(`warn:${message}`); + }, + error: (message) => { + calls.push(`error:${message}`); + }, + ...overrides, + }; + + return { deps, calls, osd }; +} + +test("handleCliCommandService ignores --start for second-instance without actions", () => { + const { deps, calls } = createDeps(); + const args = makeArgs({ start: true }); + + handleCliCommandService(args, "second-instance", deps); + + assert.ok(calls.includes("log:Ignoring --start because SubMiner is already running.")); + assert.equal(calls.some((value) => value.includes("connectMpvClient")), false); +}); + +test("handleCliCommandService runs texthooker flow with browser open", () => { + const { deps, calls } = createDeps(); + const args = makeArgs({ texthooker: true }); + + handleCliCommandService(args, "initial", deps); + + assert.ok(calls.includes("ensureTexthookerRunning:5174")); + assert.ok( + calls.includes("openTexthookerInBrowser:http://127.0.0.1:5174"), + ); +}); + +test("handleCliCommandService reports async mine errors to OSD", async () => { + const { deps, calls, osd } = createDeps({ + mineSentenceCard: async () => { + throw new Error("boom"); + }, + }); + + handleCliCommandService(makeArgs({ mineSentence: true }), "initial", deps); + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok(calls.some((value) => value.startsWith("error:mineSentenceCard failed:"))); + assert.ok(osd.some((value) => value.includes("Mine sentence failed: boom"))); +}); diff --git a/src/core/services/numeric-shortcut-session-service.test.ts b/src/core/services/numeric-shortcut-session-service.test.ts new file mode 100644 index 0000000..4002a37 --- /dev/null +++ b/src/core/services/numeric-shortcut-session-service.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createNumericShortcutSessionService } from "./numeric-shortcut-session-service"; + +test("numeric shortcut session handles digit selection and unregisters shortcuts", () => { + const handlers = new Map void>(); + const unregistered: string[] = []; + const osd: string[] = []; + const session = createNumericShortcutSessionService({ + registerShortcut: (accelerator, handler) => { + handlers.set(accelerator, handler); + return true; + }, + unregisterShortcut: (accelerator) => { + unregistered.push(accelerator); + handlers.delete(accelerator); + }, + setTimer: () => setTimeout(() => {}, 0), + clearTimer: (timer) => clearTimeout(timer), + showMpvOsd: (text) => { + osd.push(text); + }, + }); + + const digits: number[] = []; + session.start({ + timeoutMs: 5000, + onDigit: (digit) => { + digits.push(digit); + }, + messages: { + prompt: "Pick a digit", + timeout: "Timed out", + }, + }); + + assert.equal(session.isActive(), true); + assert.equal(osd[0], "Pick a digit"); + assert.ok(handlers.has("3")); + handlers.get("3")?.(); + + assert.deepEqual(digits, [3]); + assert.equal(session.isActive(), false); + assert.ok(unregistered.includes("Escape")); + assert.ok(unregistered.includes("1")); + assert.ok(unregistered.includes("9")); +}); + +test("numeric shortcut session emits timeout message", () => { + const osd: string[] = []; + const session = createNumericShortcutSessionService({ + registerShortcut: () => true, + unregisterShortcut: () => {}, + setTimer: (handler) => { + handler(); + return setTimeout(() => {}, 0); + }, + clearTimer: (timer) => clearTimeout(timer), + showMpvOsd: (text) => { + osd.push(text); + }, + }); + + session.start({ + timeoutMs: 5000, + onDigit: () => {}, + messages: { + prompt: "Pick a digit", + timeout: "Timed out", + cancelled: "Aborted", + }, + }); + + assert.equal(session.isActive(), false); + assert.ok(osd.includes("Timed out")); +}); + +test("numeric shortcut session handles escape cancellation", () => { + const handlers = new Map void>(); + const osd: string[] = []; + const session = createNumericShortcutSessionService({ + registerShortcut: (accelerator, handler) => { + handlers.set(accelerator, handler); + return true; + }, + unregisterShortcut: (accelerator) => { + handlers.delete(accelerator); + }, + setTimer: () => setTimeout(() => {}, 10000), + clearTimer: (timer) => clearTimeout(timer), + showMpvOsd: (text) => { + osd.push(text); + }, + }); + + session.start({ + timeoutMs: 5000, + onDigit: () => {}, + messages: { + prompt: "Pick a digit", + timeout: "Timed out", + cancelled: "Aborted", + }, + }); + handlers.get("Escape")?.(); + assert.equal(session.isActive(), false); + assert.ok(osd.includes("Aborted")); +}); diff --git a/src/core/services/secondary-subtitle-service.test.ts b/src/core/services/secondary-subtitle-service.test.ts new file mode 100644 index 0000000..ddbc342 --- /dev/null +++ b/src/core/services/secondary-subtitle-service.test.ts @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { SecondarySubMode } from "../../types"; +import { cycleSecondarySubModeService } from "./secondary-subtitle-service"; + +test("cycleSecondarySubModeService cycles and emits broadcast + OSD", () => { + let mode: SecondarySubMode = "hover"; + let lastToggleAt = 0; + const broadcasts: SecondarySubMode[] = []; + const osd: string[] = []; + + cycleSecondarySubModeService({ + getSecondarySubMode: () => mode, + setSecondarySubMode: (next) => { + mode = next; + }, + getLastSecondarySubToggleAtMs: () => lastToggleAt, + setLastSecondarySubToggleAtMs: (value) => { + lastToggleAt = value; + }, + broadcastSecondarySubMode: (next) => { + broadcasts.push(next); + }, + showMpvOsd: (text) => { + osd.push(text); + }, + now: () => 1000, + }); + + assert.equal(mode, "hidden"); + assert.deepEqual(broadcasts, ["hidden"]); + assert.deepEqual(osd, ["Secondary subtitle: hidden"]); + assert.equal(lastToggleAt, 1000); +}); + +test("cycleSecondarySubModeService obeys debounce window", () => { + let mode: SecondarySubMode = "visible"; + let lastToggleAt = 950; + let broadcasted = false; + let osdShown = false; + + cycleSecondarySubModeService({ + getSecondarySubMode: () => mode, + setSecondarySubMode: (next) => { + mode = next; + }, + getLastSecondarySubToggleAtMs: () => lastToggleAt, + setLastSecondarySubToggleAtMs: (value) => { + lastToggleAt = value; + }, + broadcastSecondarySubMode: () => { + broadcasted = true; + }, + showMpvOsd: () => { + osdShown = true; + }, + now: () => 1000, + }); + + assert.equal(mode, "visible"); + assert.equal(lastToggleAt, 950); + assert.equal(broadcasted, false); + assert.equal(osdShown, false); +});