diff --git a/AGENTS.md b/AGENTS.md index ea2191a8..516d7a8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ Start here, then leave this file. - Architecture map: [`docs/architecture/README.md`](./docs/architecture/README.md) - Workflow map: [`docs/workflow/README.md`](./docs/workflow/README.md) - Verification lanes: [`docs/workflow/verification.md`](./docs/workflow/verification.md) +- Testing guidelines: [`docs/workflow/testing.md`](./docs/workflow/testing.md) - Knowledge-base rules: [`docs/knowledge-base/README.md`](./docs/knowledge-base/README.md) - Release guide: [`docs/RELEASING.md`](./docs/RELEASING.md) @@ -47,7 +48,7 @@ Start here, then leave this file. - Build/release scripts (`scripts/**`): `bun run test:scripts` - Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb` - User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build` -- macOS mpv window helper: `bun test scripts/get-mpv-window-macos.test.ts` +- macOS mpv window helper (`scripts/get-mpv-window-macos.swift`): compile it on macOS with `bun run build` - Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json` ## Docs Upkeep diff --git a/docs/knowledge-base/catalog.md b/docs/knowledge-base/catalog.md index d0120cad..bf605db7 100644 --- a/docs/knowledge-base/catalog.md +++ b/docs/knowledge-base/catalog.md @@ -21,6 +21,7 @@ Read when: finding internal docs or checking verification status | Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans | | Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-23 | repo-local workflow skill ownership | | Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes | +| Testing guidelines | `docs/workflow/testing.md` | active | 2026-10-04 | test style and anti-patterns | | Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist | ## Update Rules diff --git a/docs/workflow/README.md b/docs/workflow/README.md index b89a6d9b..7dbf4143 100644 --- a/docs/workflow/README.md +++ b/docs/workflow/README.md @@ -13,6 +13,7 @@ This section is the internal workflow map for contributors and agents. - [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan - [Verification](./verification.md) - maintained test/build lanes and handoff gate +- [Testing](./testing.md) - what a good test looks like and what not to write - [Agent Skills](./agent-skills.md) - repo-local workflow skill ownership - [Release Guide](../RELEASING.md) - tagged release workflow diff --git a/docs/workflow/testing.md b/docs/workflow/testing.md new file mode 100644 index 00000000..ac36d2ed --- /dev/null +++ b/docs/workflow/testing.md @@ -0,0 +1,49 @@ + + +# Testing Guidelines + +Status: active +Last verified: 2026-10-04 +Owner: Kyle Yasuda +Read when: adding or changing tests + +Which lanes to run lives in [Verification](./verification.md). This page covers what a +good test looks like here. + +## Test Behavior, Not Wiring + +- Exercise a module through its public interface and assert outcomes: return values, + resulting state, files written, rows in sqlite, messages sent to a fake process. +- Prefer real collaborators that are cheap: tmp dirs, in-memory or tmp sqlite, fake + executables, pure parsers. Fake only the edges (Electron, mpv socket, network). +- Assert call order only when order is the behavior (startup sequencing, ABA races), + and then assert relative order, not the whole call log. + +## Do Not Write + +- Pass-through tests: a builder or handler that only forwards deps (`*-main-deps.ts`, + `*-runtime-handlers.ts`) needs no test. TypeScript already checks the shape. +- Source-text tests: reading `.ts`, `.css`, `.html`, `.lua`, `.swift`, or workflow YAML + and asserting with regexes. If the invariant matters, extract the logic and run it + (see `src/preload-ipc-listeners.ts`). Policy lints over workflow YAML are the + exception and belong in one parsed-YAML check. +- "Stays deleted" tests for removed modules, flags, or selectors. `tsc` and review + catch reintroduction. +- Tests that restate constants, defaults, labels, or window options. +- The same rule at several layers. Test a rule once where it lives; keep one + end-to-end case (for the tokenizer, the golden corpus) for integration. + +## Keep Tests Small + +- Shared setup goes in a `makeDeps(overrides)` helper or a `*-test-harness.ts` file, not + pasted into every test. +- Near-identical cases become a table: `for (const c of cases) test(c.name, ...)`. +- Inject timers, clocks, and loggers instead of patching globals or private members. +- A bug fix gets a regression test for the behavior that broke, named after the + behavior, not the ticket. + +## Known Debt + +- `src/main/main-wiring.test.ts` and `src/renderer/renderer-init-order.test.ts` still + regex `main.ts` / `renderer.ts` to guard startup ordering. Replace each test with a + behavioral one as the logic moves out of `main.ts` closures. diff --git a/docs/workflow/verification.md b/docs/workflow/verification.md index ed780f20..cf0a2cb3 100644 --- a/docs/workflow/verification.md +++ b/docs/workflow/verification.md @@ -14,8 +14,9 @@ Read when: selecting the right verification lane for a change files in `package.json`. - `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process (per-file isolation with a wall timeout) so a hanging test or leaked global in - one file cannot cascade into the rest of the lane. `--jobs N` parallelizes; - `--single-process` restores the shared-process mode for debugging. + one file cannot cascade into the rest of the lane. Files run in parallel, one + worker per CPU by default; `--jobs 1` runs serially and `--single-process` + restores the shared-process mode for debugging. - `bun run test:fast` is the full source gate: discovered `src/**`, launcher unit, and `scripts/**`. - `.github/workflows/quality-gate.yml` is the reusable `workflow_call` gate for @@ -70,7 +71,7 @@ bun run docs:build - `bun run test:coverage:src` runs the same discovered `bun-src-full` membership as `test:src` through a sharded coverage runner: one Bun coverage process per test - file, then merged LCOV output. + file (parallel, one per CPU unless `--jobs N`), then merged LCOV output. - A failing coverage shard stops the runner with a nonzero status. Coverage is a source test gate, not a report-only step. - Machine-readable output lands at `coverage/test-src/lcov.info`. diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index bc56cf8b..2b8b96fc 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -150,74 +150,7 @@ test('doctor command exits non-zero for missing hard dependencies', () => { ); }); -test('doctor command forwards refresh-known-words to app binary', () => { - const context = createContext(); - context.args.doctor = true; - context.args.doctorRefreshKnownWords = true; - const forwarded: string[][] = []; - - const handled = runDoctorCommand(context, { - commandExists: () => false, - configExists: () => true, - resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc', - runAppCommandWithInherit: (_appPath, appArgs) => { - forwarded.push(appArgs); - }, - }); - - assert.equal(handled, true); - assert.deepEqual(forwarded, [['--refresh-known-words']]); -}); - -test('logs command exports logs and writes archive path', () => { - const writes: string[] = []; - const context = createContext(); - context.args.logsExport = true; - context.processAdapter = { - ...context.processAdapter, - writeStdout: (text) => writes.push(text), - }; - - const handled = runLogsCommand(context, { - exportLogsArchive: () => ({ - zipPath: '/tmp/subminer-logs.zip', - exportedFiles: ['/tmp/app.log'], - mode: 'current-day', - }), - }); - - assert.equal(handled, true); - assert.deepEqual(writes, ['/tmp/subminer-logs.zip\n']); -}); - -test('logs command ignores unrelated launcher commands', () => { - const context = createContext(); - - assert.equal(runLogsCommand(context), false); -}); - -test('app command starts default macOS background app detached from launcher', () => { - const context = createContext(); - context.args.appPassthrough = true; - context.args.appArgs = []; - const calls: string[] = []; - - const handled = runAppPassthroughCommand(context, { - runAppCommandWithInherit: () => { - calls.push('attached'); - }, - launchSyncUiDetached: () => calls.push('sync-ui'), - launchYoutubeBrowserDetached: () => calls.push('youtube'), - launchAppBackgroundDetached: (appPath, logLevel) => { - calls.push(`detached:${appPath}:${logLevel}`); - }, - }); - - assert.equal(handled, true); - assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']); -}); - -test('app command starts default Linux background app detached from launcher', () => { +test('app command starts default background app detached from launcher app detached from launcher', () => { const context = createContext(); context.args.appPassthrough = true; context.args.appArgs = []; @@ -389,49 +322,6 @@ test('dictionary command forwards candidate and selection modes to app binary', ]); }); -test('dictionary command returns after app handoff starts', () => { - const context = createContext(); - context.args.dictionary = true; - - const handled = runDictionaryCommand(context, { - runAppCommandWithInherit: () => undefined, - }); - - assert.equal(handled, true); -}); - -test('update command runs direct Linux release update without launching Electron', async () => { - const context = createContext(); - context.args.update = true; - const calls: string[] = []; - - const handled = await runUpdateCommand(context, { - runAppCommandCaptureOutput: () => { - throw new Error('unexpected Electron launch'); - }, - runDirectReleaseUpdate: async (request) => { - calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`); - return { - appImage: { status: 'not-found' }, - launcher: { status: 'updated' }, - supportAssets: [{ status: 'skipped' }], - }; - }, - readMainConfig: () => null, - log: (level, _configured, message) => { - calls.push(`${level}:${message}`); - }, - }); - - assert.equal(handled, true); - assert.deepEqual(calls, [ - 'direct:/tmp/subminer.app:/tmp/subminer:stable', - 'info:AppImage update: not-found', - 'info:Launcher update: updated', - 'info:Support assets update: skipped', - ]); -}); - test('stats command launches attached app command with response path', async () => { const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' }); const handled = await runStatsCommand(harness.context, harness.commandDeps); @@ -449,16 +339,41 @@ test('stats command launches attached app command with response path', async () assert.equal(harness.removedPaths.length, 1); }); -test('stats background command launches attached daemon control command with response path', async () => { - const harness = createStatsTestHarness({ stats: true, statsBackground: true }); - const handled = await runStatsCommand(harness.context, harness.commandDeps); +const STATS_RESPONSE_ARGS = ['--stats-response-path', '/tmp/subminer-stats-test/response.json']; - assert.equal(handled, true); - assert.deepEqual(harness.forwarded, [ - ['--stats-daemon-start', '--stats-response-path', '/tmp/subminer-stats-test/response.json'], - ]); - assert.equal(harness.removedPaths.length, 1); -}); +const statsForwardingCases = [ + { + name: 'stats background command launches the daemon start command', + overrides: { statsBackground: true }, + expected: ['--stats-daemon-start', ...STATS_RESPONSE_ARGS], + }, + { + name: 'stats stop command forwards the daemon stop flag', + overrides: { statsStop: true }, + expected: ['--stats-daemon-stop', ...STATS_RESPONSE_ARGS], + }, + { + name: 'stats cleanup command forwards cleanup vocab flags', + overrides: { statsCleanup: true, statsCleanupVocab: true }, + expected: ['--stats', ...STATS_RESPONSE_ARGS, '--stats-cleanup', '--stats-cleanup-vocab'], + }, + { + name: 'stats cleanup command forwards the lifetime rebuild flag', + overrides: { statsCleanup: true, statsCleanupLifetime: true }, + expected: ['--stats', ...STATS_RESPONSE_ARGS, '--stats-cleanup', '--stats-cleanup-lifetime'], + }, +]; + +for (const c of statsForwardingCases) { + test(`${c.name} to the app`, async () => { + const harness = createStatsTestHarness({ stats: true, ...c.overrides }); + const handled = await runStatsCommand(harness.context, harness.commandDeps); + + assert.equal(handled, true); + assert.deepEqual(harness.forwarded, [c.expected]); + assert.equal(harness.removedPaths.length, 1); + }); +} test('stats command waits for attached app exit after startup response', async () => { const harness = createStatsTestHarness({ stats: true }); @@ -503,45 +418,6 @@ test('stats command throws when attached app exits non-zero after startup respon assert.equal(harness.removedPaths.length, 1); }); -test('stats cleanup command forwards cleanup vocab flags to the app', async () => { - const harness = createStatsTestHarness({ - stats: true, - statsCleanup: true, - statsCleanupVocab: true, - }); - const handled = await runStatsCommand(harness.context, { - ...harness.commandDeps, - waitForStatsResponse: async () => ({ ok: true }), - }); - - assert.equal(handled, true); - assert.deepEqual(harness.forwarded, [ - [ - '--stats', - '--stats-response-path', - '/tmp/subminer-stats-test/response.json', - '--stats-cleanup', - '--stats-cleanup-vocab', - ], - ]); - assert.equal(harness.removedPaths.length, 1); -}); - -test('stats stop command forwards stop flag to the app', async () => { - const harness = createStatsTestHarness({ stats: true, statsStop: true }); - - const handled = await runStatsCommand(harness.context, { - ...harness.commandDeps, - waitForStatsResponse: async () => ({ ok: true }), - }); - - assert.equal(handled, true); - assert.deepEqual(harness.forwarded, [ - ['--stats-daemon-stop', '--stats-response-path', '/tmp/subminer-stats-test/response.json'], - ]); - assert.equal(harness.removedPaths.length, 1); -}); - test('stats stop command exits on process exit without waiting for startup response', async () => { const harness = createStatsTestHarness({ stats: true, statsStop: true }); let waitedForResponse = false; @@ -563,30 +439,6 @@ test('stats stop command exits on process exit without waiting for startup respo assert.equal(harness.removedPaths.length, 1); }); -test('stats cleanup command forwards lifetime rebuild flag to the app', async () => { - const harness = createStatsTestHarness({ - stats: true, - statsCleanup: true, - statsCleanupLifetime: true, - }); - const handled = await runStatsCommand(harness.context, { - ...harness.commandDeps, - waitForStatsResponse: async () => ({ ok: true }), - }); - - assert.equal(handled, true); - assert.deepEqual(harness.forwarded, [ - [ - '--stats', - '--stats-response-path', - '/tmp/subminer-stats-test/response.json', - '--stats-cleanup', - '--stats-cleanup-lifetime', - ], - ]); - assert.equal(harness.removedPaths.length, 1); -}); - test('stats command throws when stats response reports an error', async () => { const harness = createStatsTestHarness({ stats: true }); @@ -692,36 +544,3 @@ test('stats command aborts pending response wait when attached app fails to spaw assert.equal(aborted, true); assert.equal(harness.removedPaths.length, 1); }); - -test('stats cleanup command aborts pending response wait when app exits before startup response', async () => { - const harness = createStatsTestHarness({ - stats: true, - statsCleanup: true, - statsCleanupVocab: true, - }); - let aborted = false; - - await assert.rejects(async () => { - await runStatsCommand(harness.context, { - ...harness.commandDeps, - runAppCommandAttached: async (...args) => { - await harness.runAppCommandAttachedStub(...args); - return 2; - }, - waitForStatsResponse: async (_responsePath, signal) => - await new Promise((resolve) => { - signal?.addEventListener( - 'abort', - () => { - aborted = true; - resolve({ ok: false, error: 'aborted' }); - }, - { once: true }, - ); - }), - }); - }, /Stats app exited before startup response \(status 2\)\./); - - assert.equal(aborted, true); - assert.equal(harness.removedPaths.length, 1); -}); diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index f50ee97e..125bd54b 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -4,81 +4,53 @@ import { EventEmitter } from 'node:events'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import type { Args } from '../types.js'; import type { LauncherCommandContext } from './context.js'; import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js'; -import { state } from '../mpv.js'; +import { state, type startMpv } from '../mpv.js'; +import { makeLauncherArgs } from '../test-support/args.js'; +import { withEnv } from '../test-support/env.js'; -function createContext(): LauncherCommandContext { +type PlaybackDeps = Parameters[1]; +type StartMpvOptions = NonNullable[6]>; + +const YOUTUBE_URL = 'https://www.youtube.com/watch?v=65Ovd7t8sNw'; + +function makePluginConfig( + overrides: Partial = {}, +): LauncherCommandContext['pluginRuntimeConfig'] { return { - args: { - backend: 'auto', - directory: '.', - recursive: false, - profile: '', - startOverlay: false, - youtubePrimarySubLangs: [], - youtubeSecondarySubLangs: [], - youtubeAudioLangs: [], - useTexthooker: false, - autoStartOverlay: false, - texthookerOnly: false, - texthookerOpenBrowser: false, - useRofi: false, - history: false, - sync: false, - syncCliTokens: [], - syncUi: false, - logLevel: 'info', - logRotation: 7, - passwordStore: '', - target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw', + socketPath: '/tmp/subminer.sock', + binaryPath: '', + backend: 'auto', + autoStart: true, + autoStartVisibleOverlay: true, + autoStartPauseUntilReady: true, + osdMessages: false, + texthookerEnabled: false, + ...overrides, + }; +} + +/** A context for a YouTube URL target with logging at info and CLI texthooker off. */ +function createContext( + options: { + args?: Partial; + plugin?: Partial; + } = {}, +): LauncherCommandContext { + return { + args: makeLauncherArgs({ + target: YOUTUBE_URL, targetKind: 'url', - jimakuApiKey: '', - jimakuApiKeyCommand: '', - jimakuApiBaseUrl: '', - jimakuLanguagePreference: 'ja', - jimakuMaxEntryResults: 20, - jellyfin: false, - jellyfinLogin: false, - jellyfinLogout: false, - jellyfinPlay: false, - jellyfinDiscovery: false, - dictionary: false, - dictionaryCandidates: false, - dictionarySelect: false, - stats: false, - doctor: false, - doctorRefreshKnownWords: false, - logsExport: false, - version: false, - settings: false, - youtubeBrowser: false, - configPath: false, - configShow: false, - mpvIdle: false, - mpvSocket: false, - mpvStatus: false, - mpvArgs: '', - appPassthrough: false, - appArgs: [], - jellyfinServer: '', - jellyfinUsername: '', - jellyfinPassword: '', - launchMode: 'normal', - }, + logLevel: 'info', + useTexthooker: false, + ...options.args, + }), scriptPath: '/tmp/subminer', scriptName: 'subminer', mpvSocketPath: '/tmp/subminer.sock', - pluginRuntimeConfig: { - socketPath: '/tmp/subminer.sock', - binaryPath: '', - backend: 'auto', - autoStart: true, - autoStartVisibleOverlay: true, - autoStartPauseUntilReady: true, - osdMessages: false, - texthookerEnabled: false, - }, + pluginRuntimeConfig: makePluginConfig(options.plugin), appPath: '/tmp/SubMiner.AppImage', launcherJellyfinConfig: {}, processAdapter: { @@ -93,8 +65,70 @@ function createContext(): LauncherCommandContext { }; } +const FILE_TARGET = { target: '/tmp/movie.mkv', targetKind: 'file' } as const; + +/** + * Playback harness. Deps are inert by default and record what the tests assert on: the + * options mpv was started with, the overlay launches, and an `events` log for the few cases + * where relative order is the behavior. + */ +function makeHarness( + options: { + context?: LauncherCommandContext; + deps?: Partial; + events?: string[]; + } = {}, +) { + const context = options.context ?? createContext(); + const events = options.events ?? []; + const startMpvOptions: StartMpvOptions[] = []; + const overlayCalls: Array<{ extraAppArgs: string[]; configDir?: string }> = []; + const deps: PlaybackDeps = { + ensurePlaybackSetupReady: async () => {}, + ensureRuntimePluginReady: async () => { + events.push('plugin'); + }, + chooseTarget: async () => ({ + target: context.args.target, + kind: context.args.targetKind === 'file' ? 'file' : 'url', + }), + checkDependencies: () => {}, + registerCleanup: () => {}, + startMpv: async (_target, _kind, _args, _socket, _appPath, _subtitles, startOptions) => { + events.push('startMpv'); + if (startOptions) startMpvOptions.push(startOptions); + }, + waitForUnixSocketReady: async () => true, + startOverlay: async (_appPath, _args, _socket, extraAppArgs = [], configDir) => { + events.push('startOverlay'); + overlayCalls.push({ extraAppArgs, configDir }); + }, + launchAppCommandDetached: () => {}, + log: () => {}, + cleanupPlaybackSession: async () => {}, + getMpvProc: () => null, + ...options.deps, + }; + return { + context, + events, + startMpvOptions, + overlayCalls, + run: () => runPlaybackCommandWithDeps(context, deps), + }; +} + +function assertRanInOrder(events: string[], ...expected: string[]): void { + const positions = expected.map((event) => events.indexOf(event)); + assert.ok( + positions.every( + (position, index) => position >= 0 && (index === 0 || position > positions[index - 1]!), + ), + `expected ${expected.join(' -> ')} in order, got ${events.join(', ')}`, + ); +} + test('playback cleanup signal handlers are registered once across repeated sessions', () => { - assert.equal(typeof registerCleanup, 'function', 'cleanup registration is not exported'); const context = createContext(); const registeredSignals: NodeJS.Signals[] = []; context.processAdapter.onSignal = (signal) => { @@ -108,121 +142,41 @@ test('playback cleanup signal handlers are registered once across repeated sessi }); test('youtube playback launches overlay with app-owned youtube flow args', async () => { - const calls: string[] = []; - const context = createContext(); - context.pluginRuntimeConfig = { - ...context.pluginRuntimeConfig, - autoStart: false, - autoStartVisibleOverlay: false, - autoStartPauseUntilReady: false, - }; - const receivedStartMpvOptions: Record[] = []; - - await runPlaybackCommandWithDeps(context, { - ensurePlaybackSetupReady: async () => {}, - ensureRuntimePluginReady: async () => {}, - chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }), - checkDependencies: () => {}, - registerCleanup: () => {}, - startMpv: async ( - _target, - _targetKind, - _args, - _socketPath, - _appPath, - _preloadedSubtitles, - options, - ) => { - if (options) { - receivedStartMpvOptions.push(options as Record); - } - calls.push('startMpv'); - }, - waitForUnixSocketReady: async () => true, - startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => { - calls.push(`startOverlay:${extraAppArgs.join(' ')}`); - }, - launchAppCommandDetached: (_appPath: string, appArgs: string[]) => { - calls.push(`launch:${appArgs.join(' ')}`); - }, - log: () => {}, - cleanupPlaybackSession: async () => {}, - getMpvProc: () => null, + const harness = makeHarness({ + context: createContext({ + plugin: { + autoStart: false, + autoStartVisibleOverlay: false, + autoStartPauseUntilReady: false, + }, + }), }); - assert.deepEqual(calls, [ - 'startMpv', - 'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw', - ]); - assert.equal(receivedStartMpvOptions[0]?.startPaused, true); - assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true); + await harness.run(); + + assertRanInOrder(harness.events, 'startMpv', 'startOverlay'); + assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--youtube-play', YOUTUBE_URL]); + assert.equal(harness.startMpvOptions[0]?.startPaused, true); + assert.equal(harness.startMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true); }); test('youtube app-owned playback disables mpv plugin auto-start', async () => { - const context = createContext(); - context.pluginRuntimeConfig = { - ...context.pluginRuntimeConfig, - autoStart: true, - autoStartVisibleOverlay: true, - autoStartPauseUntilReady: true, - }; - const receivedStartMpvOptions: Record[] = []; + const harness = makeHarness(); - await runPlaybackCommandWithDeps(context, { - ensurePlaybackSetupReady: async () => {}, - ensureRuntimePluginReady: async () => {}, - chooseTarget: async () => ({ target: context.args.target, kind: 'url' }), - checkDependencies: () => {}, - registerCleanup: () => {}, - startMpv: async ( - _target, - _targetKind, - _args, - _socketPath, - _appPath, - _preloadedSubtitles, - options, - ) => { - if (options) { - receivedStartMpvOptions.push(options as Record); - } - }, - waitForUnixSocketReady: async () => true, - startOverlay: async () => {}, - launchAppCommandDetached: () => {}, - log: () => {}, - cleanupPlaybackSession: async () => {}, - getMpvProc: () => null, - }); + await harness.run(); - const runtimeConfig = receivedStartMpvOptions[0]?.runtimePluginConfig as - | { autoStart?: boolean; autoStartVisibleOverlay?: boolean; autoStartPauseUntilReady?: boolean } - | undefined; + const runtimeConfig = harness.startMpvOptions[0]?.runtimePluginConfig; assert.equal(runtimeConfig?.autoStart, false); assert.equal(runtimeConfig?.autoStartVisibleOverlay, false); assert.equal(runtimeConfig?.autoStartPauseUntilReady, false); }); test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => { - const context = createContext(); - context.args = { - ...context.args, - target: '/tmp/movie.mkv', - targetKind: 'file', - useTexthooker: true, - }; - context.pluginRuntimeConfig = { - socketPath: '/tmp/subminer.sock', - binaryPath: '', - backend: 'auto', - autoStart: true, - autoStartVisibleOverlay: false, - autoStartPauseUntilReady: false, - osdMessages: false, - texthookerEnabled: false, - }; - const appPath = context.appPath ?? ''; - state.appPath = appPath; + const context = createContext({ + args: { ...FILE_TARGET, useTexthooker: true }, + plugin: { autoStartVisibleOverlay: false, autoStartPauseUntilReady: false }, + }); + state.appPath = context.appPath ?? ''; state.overlayManagedByLauncher = false; const mpvProc = new EventEmitter() as EventEmitter & { exitCode: number | null; @@ -233,31 +187,27 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner', mpvProc.killed = false; mpvProc.kill = () => true; let cleanupSawManagedOverlay = true; - - try { - await runPlaybackCommandWithDeps(context, { - ensurePlaybackSetupReady: async () => {}, - ensureRuntimePluginReady: async () => {}, - chooseTarget: async () => ({ target: context.args.target, kind: 'file' }), - checkDependencies: () => {}, - registerCleanup: () => {}, + const harness = makeHarness({ + context, + deps: { startMpv: async () => { setTimeout(() => { mpvProc.exitCode = 0; mpvProc.emit('exit', 0); }, 5); }, - waitForUnixSocketReady: async () => true, startOverlay: async () => { throw new Error('startOverlay should not run when plugin auto-start is used'); }, - launchAppCommandDetached: () => {}, - log: () => {}, cleanupPlaybackSession: async () => { cleanupSawManagedOverlay = state.overlayManagedByLauncher; }, getMpvProc: () => mpvProc as NonNullable, - }); + }, + }); + + try { + await harness.run(); assert.equal(cleanupSawManagedOverlay, false); } finally { @@ -267,258 +217,89 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner', }); test('plugin auto-start playback attaches a warm background app through the launcher', async () => { - const context = createContext(); - context.args = { - ...context.args, - target: '/tmp/movie.mkv', - targetKind: 'file', - useTexthooker: true, - }; - context.pluginRuntimeConfig = { - socketPath: '/tmp/subminer.sock', - binaryPath: '', - backend: 'auto', - autoStart: true, - autoStartVisibleOverlay: true, - autoStartPauseUntilReady: true, - osdMessages: false, - texthookerEnabled: true, - }; - const calls: string[] = []; - const receivedStartMpvOptions: Record[] = []; - - await runPlaybackCommandWithDeps(context, { - ensurePlaybackSetupReady: async () => {}, - ensureRuntimePluginReady: async () => {}, - chooseTarget: async () => ({ target: context.args.target, kind: 'file' }), - checkDependencies: () => {}, - registerCleanup: () => {}, - startMpv: async ( - _target, - _targetKind, - _args, - _socketPath, - _appPath, - _preloadedSubtitles, - options, - ) => { - calls.push('startMpv'); - if (options) { - receivedStartMpvOptions.push(options as Record); - } - }, - waitForUnixSocketReady: async () => true, - startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => { - calls.push(`startOverlay:${extraAppArgs.join(' ')}`); - }, - launchAppCommandDetached: () => {}, - log: () => {}, - cleanupPlaybackSession: async () => {}, - getMpvProc: () => null, - isAppControlServerAvailable: async () => true, - } as Parameters[1] & { - isAppControlServerAvailable: () => Promise; + const harness = makeHarness({ + context: createContext({ + args: { ...FILE_TARGET, useTexthooker: true }, + plugin: { texthookerEnabled: true }, + }), + deps: { isAppControlServerAvailable: async () => true }, }); - assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']); - assert.equal(receivedStartMpvOptions[0]?.startPaused, true); - assert.equal( - (receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined) - ?.autoStart, - false, - ); + await harness.run(); + + assertRanInOrder(harness.events, 'startMpv', 'startOverlay'); + assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, [ + '--show-visible-overlay', + '--texthooker', + ]); + assert.equal(harness.startMpvOptions[0]?.startPaused, true); + assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.autoStart, false); }); test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => { - const context = createContext(); - const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; - const originalAppData = process.env.APPDATA; const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-')); const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner'); fs.mkdirSync(expectedConfigDir, { recursive: true }); fs.writeFileSync(path.join(expectedConfigDir, 'config.jsonc'), '{}'); - context.args = { - ...context.args, - target: '/tmp/movie.mkv', - targetKind: 'file', - useTexthooker: true, - }; - context.pluginRuntimeConfig = { - socketPath: '/tmp/subminer.sock', - binaryPath: '', - backend: 'auto', - autoStart: true, - autoStartVisibleOverlay: true, - autoStartPauseUntilReady: true, - osdMessages: false, - texthookerEnabled: true, - }; let availabilityConfigDir: string | undefined; - let overlayConfigDir: string | undefined; - let overlayLoadingOsd: boolean | undefined; - - try { - process.env.XDG_CONFIG_HOME = xdgConfigHome; - process.env.APPDATA = xdgConfigHome; - - await runPlaybackCommandWithDeps(context, { - ensurePlaybackSetupReady: async () => {}, - ensureRuntimePluginReady: async () => {}, - chooseTarget: async () => ({ target: context.args.target, kind: 'file' }), - checkDependencies: () => {}, - registerCleanup: () => {}, - startMpv: async ( - _target, - _targetKind, - _args, - _socketPath, - _appPath, - _preloadedSubtitles, - options, - ) => { - overlayLoadingOsd = ( - options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined - )?.overlayLoadingOsd; - }, - waitForUnixSocketReady: async () => true, - startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => { - overlayConfigDir = configDir; - }, - launchAppCommandDetached: () => {}, - log: () => {}, - cleanupPlaybackSession: async () => {}, - getMpvProc: () => null, + const harness = makeHarness({ + context: createContext({ + args: { ...FILE_TARGET, useTexthooker: true }, + plugin: { texthookerEnabled: true }, + }), + deps: { isAppControlServerAvailable: async (_logLevel, configDir) => { availabilityConfigDir = configDir; return true; }, - }); + }, + }); + + try { + await withEnv({ XDG_CONFIG_HOME: xdgConfigHome, APPDATA: xdgConfigHome }, () => harness.run()); assert.equal(availabilityConfigDir, expectedConfigDir); - assert.equal(overlayConfigDir, expectedConfigDir); - assert.equal(overlayLoadingOsd, true); + assert.equal(harness.overlayCalls[0]?.configDir, expectedConfigDir); + assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.overlayLoadingOsd, true); } finally { - if (originalXdgConfigHome === undefined) { - delete process.env.XDG_CONFIG_HOME; - } else { - process.env.XDG_CONFIG_HOME = originalXdgConfigHome; - } - if (originalAppData === undefined) { - delete process.env.APPDATA; - } else { - process.env.APPDATA = originalAppData; - } fs.rmSync(xdgConfigHome, { recursive: true, force: true }); } }); test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => { - const context = createContext(); - context.args = { - ...context.args, - target: '/tmp/movie.mkv', - targetKind: 'file', - }; - context.pluginRuntimeConfig = { - socketPath: '/tmp/subminer.sock', - binaryPath: '', - backend: 'auto', - autoStart: true, - autoStartVisibleOverlay: true, - autoStartPauseUntilReady: true, - osdMessages: false, - texthookerEnabled: true, - }; - const calls: string[] = []; - - await runPlaybackCommandWithDeps(context, { - ensurePlaybackSetupReady: async () => {}, - ensureRuntimePluginReady: async () => {}, - chooseTarget: async () => ({ target: context.args.target, kind: 'file' }), - checkDependencies: () => {}, - registerCleanup: () => {}, - startMpv: async () => { - calls.push('startMpv'); - }, - waitForUnixSocketReady: async () => true, - startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => { - calls.push(`startOverlay:${extraAppArgs.join(' ')}`); - }, - launchAppCommandDetached: () => {}, - log: () => {}, - cleanupPlaybackSession: async () => {}, - getMpvProc: () => null, - isAppControlServerAvailable: async () => true, - } as Parameters[1] & { - isAppControlServerAvailable: () => Promise; + const harness = makeHarness({ + context: createContext({ args: FILE_TARGET, plugin: { texthookerEnabled: true } }), + deps: { isAppControlServerAvailable: async () => true }, }); - assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']); + await harness.run(); + + assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--show-visible-overlay']); }); test('playback command ensures Linux runtime plugin before mpv launch', async () => { - const context = createContext(); - context.args = { - ...context.args, - target: '/tmp/movie.mkv', - targetKind: 'file', - }; - const calls: string[] = []; + const harness = makeHarness({ context: createContext({ args: FILE_TARGET }) }); - await runPlaybackCommandWithDeps(context, { - ensurePlaybackSetupReady: async () => {}, - ensureRuntimePluginReady: async () => { - calls.push('plugin'); - }, - chooseTarget: async () => ({ target: context.args.target, kind: 'file' }), - checkDependencies: () => {}, - registerCleanup: () => {}, - startMpv: async () => { - calls.push('startMpv'); - }, - waitForUnixSocketReady: async () => true, - startOverlay: async () => {}, - launchAppCommandDetached: () => {}, - log: () => {}, - cleanupPlaybackSession: async () => {}, - getMpvProc: () => null, - }); + await harness.run(); - assert.deepEqual(calls, ['plugin', 'startMpv']); + assertRanInOrder(harness.events, 'plugin', 'startMpv'); }); test('rofi playback repairs support assets before opening the picker', async () => { - const context = createContext(); - context.args = { - ...context.args, - target: '', - targetKind: '', - useRofi: true, - }; - const calls: string[] = []; - - await runPlaybackCommandWithDeps(context, { - ensurePlaybackSetupReady: async () => {}, - ensureRuntimePluginReady: async () => { - calls.push('assets'); + const events: string[] = []; + const harness = makeHarness({ + events, + context: createContext({ args: { target: '', targetKind: '', useRofi: true } }), + deps: { + chooseTarget: async () => { + events.push('picker'); + return { target: '/tmp/movie.mkv', kind: 'file' }; + }, + checkPickerDependencies: () => {}, }, - chooseTarget: async () => { - calls.push('picker'); - return { target: '/tmp/movie.mkv', kind: 'file' }; - }, - checkPickerDependencies: () => {}, - checkDependencies: () => {}, - registerCleanup: () => {}, - startMpv: async () => { - calls.push('startMpv'); - }, - waitForUnixSocketReady: async () => true, - startOverlay: async () => {}, - launchAppCommandDetached: () => {}, - log: () => {}, - cleanupPlaybackSession: async () => {}, - getMpvProc: () => null, }); - assert.deepEqual(calls, ['assets', 'picker', 'startMpv']); + await harness.run(); + + assertRanInOrder(events, 'plugin', 'picker', 'startMpv'); }); diff --git a/launcher/commands/sync-command.test.ts b/launcher/commands/sync-command.test.ts index 66cfc8f6..2728d44f 100644 --- a/launcher/commands/sync-command.test.ts +++ b/launcher/commands/sync-command.test.ts @@ -48,49 +48,6 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () assert.equal(spawned.length, 1); }); -test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => { - const spawned: string[][] = []; - const deps: Partial = { - runAppCommand: (_appPath, appArgs) => { - spawned.push(appArgs); - }, - }; - - await runSyncCommand( - makeContext({ - syncCliTokens: [ - 'media-box', - '--pull', - '--remote-cmd', - '/opt/SubMiner.AppImage', - '--db', - '/tmp/db.sqlite', - '--force', - '--json', - ], - logLevel: 'debug', - }), - deps, - ); - - assert.deepEqual(spawned, [ - [ - '--sync-cli', - 'sync', - 'media-box', - '--pull', - '--remote-cmd', - '/opt/SubMiner.AppImage', - '--db', - '/tmp/db.sqlite', - '--force', - '--json', - '--log-level', - 'debug', - ], - ]); -}); - test('runSyncCommand fails with a clear message when the app binary is missing', async () => { const deps: Partial = { runAppCommand: () => { diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index 4f2ff1f0..1d85686e 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -8,7 +8,6 @@ import { buildPluginRuntimeScriptOptParts, parsePluginRuntimeConfigFromMainConfig, } from './config/plugin-runtime-config.js'; -import { getDefaultSocketPath } from './types.js'; test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => { const parsed = parseLauncherYoutubeSubgenConfig({ @@ -207,10 +206,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri ); }); -test('getDefaultSocketPath returns Windows named pipe default', () => { - assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket'); -}); - test('parseLauncherMpvConfig reads configured mpv profile', () => { assert.deepEqual( parseLauncherMpvConfig({ diff --git a/launcher/config.test.ts b/launcher/config.test.ts deleted file mode 100644 index 72f5ee2d..00000000 --- a/launcher/config.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import path from 'node:path'; - -test('launcher root help lists subcommands', () => { - const output = execFileSync('bun', ['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'], { - encoding: 'utf8', - }); - - assert.match(output, /Commands:/); - assert.match(output, /jellyfin\|jf/); - assert.match(output, /doctor/); - assert.match(output, /config/); - assert.match(output, /mpv/); - assert.match(output, /logs/); - assert.match(output, /dictionary\|dict/); - assert.match(output, /texthooker/); - assert.match(output, /app\|bin/); -}); diff --git a/launcher/config/args-normalizer.test.ts b/launcher/config/args-normalizer.test.ts index 9bbb4c96..ea178c29 100644 --- a/launcher/config/args-normalizer.test.ts +++ b/launcher/config/args-normalizer.test.ts @@ -9,6 +9,7 @@ import { applyRootOptionsToArgs, createDefaultArgs, } from './args-normalizer.js'; +import type { CliInvocations } from './cli-parser-builder.js'; function withTempDir(fn: (dir: string) => T): T { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-')); @@ -19,6 +20,48 @@ function withTempDir(fn: (dir: string) => T): T { } } +/** An invocations bag with nothing triggered; tests pass only the fields under test. */ +function emptyInvocations(overrides: Partial = {}): CliInvocations { + return { + jellyfinInvocation: null, + configInvocation: null, + settingsInvocation: null, + youtubeBrowserInvocation: null, + mpvInvocation: null, + appInvocation: null, + dictionaryTriggered: false, + dictionaryTarget: null, + dictionaryLogLevel: null, + dictionaryCandidates: false, + dictionarySelect: false, + dictionaryAnilistId: null, + statsTriggered: false, + statsBackground: false, + statsStop: false, + statsCleanup: false, + statsCleanupVocab: false, + statsCleanupLifetime: false, + statsCleanupDuplicateLines: false, + statsCleanupDryRun: false, + statsCleanupLookbackDays: null, + statsLogLevel: null, + syncTriggered: false, + syncCliTokens: [], + syncLogLevel: null, + syncUiTriggered: false, + syncUiLogLevel: null, + doctorTriggered: false, + doctorLogLevel: null, + doctorRefreshKnownWords: false, + logsTriggered: false, + logsExport: false, + texthookerTriggered: false, + texthookerLogLevel: null, + texthookerOpenBrowser: false, + ...overrides, + }; +} + test('createDefaultArgs normalizes configured language codes', () => { const parsed = createDefaultArgs({ primarySubLanguages: [' JA ', 'jpn', 'ja'], @@ -92,53 +135,23 @@ test('applyRootOptionsToArgs rejects unsupported targets', () => { test('applyInvocationsToArgs maps config and jellyfin invocation state', () => { const parsed = createDefaultArgs({}); - applyInvocationsToArgs(parsed, { - jellyfinInvocation: { - action: 'play', - play: true, - server: 'https://jf.example', - username: 'alice', - password: 'secret', - logLevel: 'debug', - }, - configInvocation: { - action: 'show', - logLevel: 'warn', - }, - settingsInvocation: null, - youtubeBrowserInvocation: null, - mpvInvocation: null, - appInvocation: null, - dictionaryTriggered: false, - dictionaryTarget: null, - dictionaryLogLevel: null, - dictionaryCandidates: false, - dictionarySelect: false, - dictionaryAnilistId: null, - statsTriggered: false, - statsBackground: false, - statsStop: false, - statsCleanup: false, - statsCleanupVocab: false, - statsCleanupLifetime: false, - statsCleanupDuplicateLines: false, - statsCleanupDryRun: false, - statsCleanupLookbackDays: null, - statsLogLevel: null, - syncTriggered: false, - syncCliTokens: [], - syncLogLevel: null, - syncUiTriggered: false, - syncUiLogLevel: null, - doctorTriggered: false, - doctorLogLevel: null, - doctorRefreshKnownWords: false, - logsTriggered: false, - logsExport: false, - texthookerTriggered: false, - texthookerLogLevel: null, - texthookerOpenBrowser: false, - }); + applyInvocationsToArgs( + parsed, + emptyInvocations({ + jellyfinInvocation: { + action: 'play', + play: true, + server: 'https://jf.example', + username: 'alice', + password: 'secret', + logLevel: 'debug', + }, + configInvocation: { + action: 'show', + logLevel: 'warn', + }, + }), + ); assert.equal(parsed.jellyfin, false); assert.equal(parsed.jellyfinPlay, true); @@ -155,45 +168,14 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => { test('applyInvocationsToArgs maps settings invocation to settings window', () => { const parsed = createDefaultArgs({}); - applyInvocationsToArgs(parsed, { - jellyfinInvocation: null, - configInvocation: null, - settingsInvocation: { - logLevel: undefined, - }, - youtubeBrowserInvocation: null, - mpvInvocation: null, - appInvocation: null, - dictionaryTriggered: false, - dictionaryTarget: null, - dictionaryLogLevel: null, - dictionaryCandidates: false, - dictionarySelect: false, - dictionaryAnilistId: null, - statsTriggered: false, - statsBackground: false, - statsStop: false, - statsCleanup: false, - statsCleanupVocab: false, - statsCleanupLifetime: false, - statsCleanupDuplicateLines: false, - statsCleanupDryRun: false, - statsCleanupLookbackDays: null, - statsLogLevel: null, - syncTriggered: false, - syncCliTokens: [], - syncLogLevel: null, - syncUiTriggered: false, - syncUiLogLevel: null, - doctorTriggered: false, - doctorLogLevel: null, - doctorRefreshKnownWords: false, - logsTriggered: false, - logsExport: false, - texthookerTriggered: false, - texthookerLogLevel: null, - texthookerOpenBrowser: false, - }); + applyInvocationsToArgs( + parsed, + emptyInvocations({ + settingsInvocation: { + logLevel: undefined, + }, + }), + ); assert.equal(parsed.settings, true); assert.equal(parsed.configPath, false); @@ -203,45 +185,14 @@ test('applyInvocationsToArgs fails when config invocation has no action', () => const parsed = createDefaultArgs({}); const error = withProcessExitIntercept(() => { - applyInvocationsToArgs(parsed, { - jellyfinInvocation: null, - configInvocation: { - action: undefined, - }, - settingsInvocation: null, - youtubeBrowserInvocation: null, - mpvInvocation: null, - appInvocation: null, - dictionaryTriggered: false, - dictionaryTarget: null, - dictionaryLogLevel: null, - dictionaryCandidates: false, - dictionarySelect: false, - dictionaryAnilistId: null, - statsTriggered: false, - statsBackground: false, - statsStop: false, - statsCleanup: false, - statsCleanupVocab: false, - statsCleanupLifetime: false, - statsCleanupDuplicateLines: false, - statsCleanupDryRun: false, - statsCleanupLookbackDays: null, - statsLogLevel: null, - syncTriggered: false, - syncCliTokens: [], - syncLogLevel: null, - syncUiTriggered: false, - syncUiLogLevel: null, - doctorTriggered: false, - doctorLogLevel: null, - doctorRefreshKnownWords: false, - logsTriggered: false, - logsExport: false, - texthookerTriggered: false, - texthookerLogLevel: null, - texthookerOpenBrowser: false, - }); + applyInvocationsToArgs( + parsed, + emptyInvocations({ + configInvocation: { + action: undefined, + }, + }), + ); }); assert.equal(error.code, 1); @@ -251,43 +202,13 @@ test('applyInvocationsToArgs fails when config invocation has no action', () => test('applyInvocationsToArgs maps texthooker browser-open request', () => { const parsed = createDefaultArgs({}); - applyInvocationsToArgs(parsed, { - jellyfinInvocation: null, - configInvocation: null, - settingsInvocation: null, - youtubeBrowserInvocation: null, - mpvInvocation: null, - appInvocation: null, - dictionaryTriggered: false, - dictionaryTarget: null, - dictionaryLogLevel: null, - dictionaryCandidates: false, - dictionarySelect: false, - dictionaryAnilistId: null, - statsTriggered: false, - statsBackground: false, - statsStop: false, - statsCleanup: false, - statsCleanupVocab: false, - statsCleanupLifetime: false, - statsCleanupDuplicateLines: false, - statsCleanupDryRun: false, - statsCleanupLookbackDays: null, - statsLogLevel: null, - syncTriggered: false, - syncCliTokens: [], - syncLogLevel: null, - syncUiTriggered: false, - syncUiLogLevel: null, - doctorTriggered: false, - doctorLogLevel: null, - doctorRefreshKnownWords: false, - logsTriggered: false, - logsExport: false, - texthookerTriggered: true, - texthookerLogLevel: null, - texthookerOpenBrowser: true, - }); + applyInvocationsToArgs( + parsed, + emptyInvocations({ + texthookerTriggered: true, + texthookerOpenBrowser: true, + }), + ); assert.equal(parsed.texthookerOnly, true); assert.equal(parsed.texthookerOpenBrowser, true); diff --git a/launcher/jellyfin.test.ts b/launcher/jellyfin.test.ts index 329570e4..12582d46 100644 --- a/launcher/jellyfin.test.ts +++ b/launcher/jellyfin.test.ts @@ -1,128 +1,305 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import type { Args } from './types.js'; -import { runJellyfinPlayMenuWithDeps } from './jellyfin.js'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + buildForwardedJellyfinAppArgs, + buildRootSearchGroups, + classifyJellyfinChildSelection, + deriveJellyfinTokenStorePath, + hasStoredJellyfinSession, + parseEpisodePathFromDisplay, + parseJellyfinErrorFromAppOutput, + parseJellyfinItemsFromAppOutput, + parseJellyfinLibrariesFromAppOutput, + parseJellyfinPreviewAuthResponse, + readUtf8FileAppendedSince, + runJellyfinPlayMenuWithDeps, + shouldRetryWithStartForNoRunningInstance, +} from './jellyfin.js'; +import { makeLauncherArgs } from './test-support/args.js'; +import { withEnv } from './test-support/env.js'; -function createArgs(): Args { - return { - backend: 'auto', - directory: '.', - recursive: false, - profile: '', - startOverlay: false, - youtubePrimarySubLangs: [], - youtubeSecondarySubLangs: [], - youtubeAudioLangs: [], - useTexthooker: false, - autoStartOverlay: false, - texthookerOnly: false, - texthookerOpenBrowser: false, - useRofi: false, - history: false, - sync: false, - syncCliTokens: [], - syncUi: false, - logLevel: 'info', - logRotation: 7, - passwordStore: '', - target: '', - targetKind: '', - jimakuApiKey: '', - jimakuApiKeyCommand: '', - jimakuApiBaseUrl: '', - jimakuLanguagePreference: 'ja', - jimakuMaxEntryResults: 20, - jellyfin: false, - jellyfinLogin: false, - jellyfinLogout: false, - jellyfinPlay: false, - jellyfinDiscovery: false, - dictionary: false, - dictionaryCandidates: false, - dictionarySelect: false, - stats: false, - doctor: false, - doctorRefreshKnownWords: false, - logsExport: false, - version: false, - settings: false, - youtubeBrowser: false, - configPath: false, - configShow: false, - mpvIdle: false, - mpvSocket: false, - mpvStatus: false, - mpvArgs: '', - appPassthrough: false, - appArgs: [], - jellyfinServer: 'https://jellyfin.example.test', - jellyfinUsername: '', - jellyfinPassword: '', - launchMode: 'normal', - }; +function withTempDir(fn: (dir: string) => T): T { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-test-')); + try { + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } } -test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => { - const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN; - const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID; - process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token'; - process.env.SUBMINER_JELLYFIN_USER_ID = 'user'; - const calls: string[] = []; +class HandoffComplete extends Error {} - try { - await assert.rejects( - () => - runJellyfinPlayMenuWithDeps( - '/tmp/SubMiner.AppImage', - createArgs(), - '/tmp/subminer', - '/tmp/subminer.sock', - { - loadLauncherJellyfinConfig: () => ({}), - findRofiTheme: () => null, - resolveJellyfinSelection: async () => 'item-123', - resolveJellyfinSelectionViaApp: async () => { - throw new Error('unexpected app-based selection'); - }, - hasStoredJellyfinSession: () => true, - requestJellyfinPreviewAuthFromApp: async () => null, - resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc', - pathExists: () => false, - ensureRuntimePluginReady: async () => { - calls.push('plugin'); - }, - waitForUnixSocketReady: async () => { - calls.push('wait'); - return true; - }, - launchMpvIdleDetached: async () => { - calls.push('launch'); - }, - resolveLauncherRuntimePluginPath: () => { - calls.push('resolve'); - return '/tmp/plugin/main.lua'; - }, - runAppCommandWithInheritLogged: () => { - calls.push('handoff'); - throw new Error('stop after handoff'); - }, - log: () => {}, - }, - ), - /stop after handoff/, - ); +type PlayMenuDeps = NonNullable[4]>; - assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']); - } finally { - if (originalAccessToken === undefined) { - delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN; - } else { - process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken; - } - if (originalUserId === undefined) { - delete process.env.SUBMINER_JELLYFIN_USER_ID; - } else { - process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId; - } - } +/** + * Runs the play menu with fake deps and a direct env session. The app handoff never returns in + * production, so the fake throws `HandoffComplete` after recording its args into `handoff`. + */ +async function runPlayMenu(options: { + socketReady?: boolean; + overrides?: Partial; + args?: Parameters[0]; +}) { + const launches: string[] = []; + const handoff: { appArgs: string[] | null } = { appArgs: null }; + const deps: PlayMenuDeps = { + loadLauncherJellyfinConfig: () => ({}), + findRofiTheme: () => null, + resolveJellyfinSelection: async () => 'item-123', + resolveJellyfinSelectionViaApp: async () => { + throw new Error('unexpected app-based selection'); + }, + hasStoredJellyfinSession: () => true, + requestJellyfinPreviewAuthFromApp: async () => null, + resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc', + pathExists: () => options.socketReady ?? false, + ensureRuntimePluginReady: async () => { + launches.push('plugin'); + }, + waitForUnixSocketReady: async () => true, + launchMpvIdleDetached: async () => { + launches.push('launch'); + }, + resolveLauncherRuntimePluginPath: () => '/tmp/plugin/main.lua', + runAppCommandWithInheritLogged: (_appPath, appArgs) => { + handoff.appArgs = appArgs; + throw new HandoffComplete(); + }, + log: () => {}, + ...options.overrides, + }; + + await withEnv( + { SUBMINER_JELLYFIN_ACCESS_TOKEN: 'token', SUBMINER_JELLYFIN_USER_ID: 'user' }, + () => + assert.rejects( + () => + runJellyfinPlayMenuWithDeps( + '/tmp/SubMiner.AppImage', + makeLauncherArgs({ jellyfinServer: 'https://jellyfin.example.test', ...options.args }), + '/tmp/subminer', + '/tmp/subminer.sock', + deps, + ), + HandoffComplete, + ), + ); + + return { launches, appArgs: handoff.appArgs }; +} + +test('Jellyfin playback launches the idle mpv with the runtime plugin, then hands the item to the app', async () => { + const { launches, appArgs } = await runPlayMenu({}); + + assert.deepEqual(launches, ['plugin', 'launch']); + assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']); +}); + +test('Jellyfin playback reuses a ready mpv socket instead of launching idle mpv', async () => { + const { launches, appArgs } = await runPlayMenu({ socketReady: true }); + + assert.deepEqual(launches, []); + assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']); +}); + +test('parseJellyfinLibrariesFromAppOutput parses prefixed library lines', () => { + const parsed = parseJellyfinLibrariesFromAppOutput(` +[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin library: Anime [lib1] (tvshows) +[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin library: Movies [lib2] (movies) +`); + + assert.deepEqual(parsed, [ + { id: 'lib1', name: 'Anime', kind: 'tvshows' }, + { id: 'lib2', name: 'Movies', kind: 'movies' }, + ]); +}); + +test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => { + const parsed = parseJellyfinItemsFromAppOutput(` +[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin item: Solo Leveling S01E10 [item-10] (Episode) +[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin item: Movie [Alt] [movie-1] (Movie) +`); + + assert.deepEqual(parsed, [ + { + id: 'item-10', + name: 'Solo Leveling S01E10', + type: 'Episode', + display: 'Solo Leveling S01E10', + }, + { + id: 'movie-1', + name: 'Movie [Alt]', + type: 'Movie', + display: 'Movie [Alt]', + }, + ]); +}); + +test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => { + const forwarded = buildForwardedJellyfinAppArgs( + { + jellyfinServer: 'https://jf.example.test/', + passwordStore: 'gnome-libsecret', + logLevel: 'info', + } as never, + ['--jellyfin-libraries'], + ); + + assert.deepEqual(forwarded, [ + '--jellyfin-libraries', + '--jellyfin-server', + 'https://jf.example.test', + '--password-store', + 'gnome-libsecret', + '--log-level', + 'info', + ]); +}); + +test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => { + const parsed = parseJellyfinErrorFromAppOutput(` +[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning +[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry. +`); + + assert.equal( + parsed, + 'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.', + ); +}); + +test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => { + const parsed = parseJellyfinErrorFromAppOutput(` +[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."} +`); + + assert.equal( + parsed, + '[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}', + ); +}); + +test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => { + const parsed = parseJellyfinPreviewAuthResponse( + JSON.stringify({ + serverUrl: 'http://pve-main:8096/', + accessToken: 'token-123', + userId: 'user-1', + }), + ); + + assert.deepEqual(parsed, { + serverUrl: 'http://pve-main:8096', + accessToken: 'token-123', + userId: 'user-1', + }); +}); + +test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => { + assert.equal(parseJellyfinPreviewAuthResponse(''), null); + assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null); + assert.equal( + parseJellyfinPreviewAuthResponse( + JSON.stringify({ + serverUrl: 'http://pve-main:8096', + accessToken: '', + userId: 'user-1', + }), + ), + null, + ); +}); + +test('hasStoredJellyfinSession checks token-store existence', () => { + const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc'); + const tokenPath = deriveJellyfinTokenStorePath(configPath); + const exists = (candidate: string): boolean => candidate === tokenPath; + assert.equal(hasStoredJellyfinSession(configPath, exists), true); + assert.equal( + hasStoredJellyfinSession(path.join('/home/test', '.config', 'Other', 'alt.jsonc'), exists), + false, + ); +}); + +test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => { + assert.equal( + shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'), + true, + ); + assert.equal( + shouldRetryWithStartForNoRunningInstance( + 'Missing Jellyfin session. Run --jellyfin-login first.', + ), + false, + ); +}); + +test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte logs', () => { + withTempDir((root) => { + const logPath = path.join(root, 'SubMiner.log'); + const prefix = '[subminer] こんにちは\n'; + const suffix = '[subminer] Jellyfin library: Movies [lib2] (movies)\n'; + fs.writeFileSync(logPath, `${prefix}${suffix}`, 'utf8'); + + const byteOffset = Buffer.byteLength(prefix, 'utf8'); + const fromByteOffset = readUtf8FileAppendedSince(logPath, byteOffset); + assert.match(fromByteOffset, /Jellyfin library: Movies \[lib2\] \(movies\)/); + + const fromBeyondEnd = readUtf8FileAppendedSince(logPath, byteOffset + 9999); + assert.match(fromBeyondEnd, /Jellyfin library: Movies \[lib2\] \(movies\)/); + }); +}); + +test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => { + assert.deepEqual( + parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'), + { + seriesName: 'KONOSUBA', + seasonNumber: 1, + }, + ); + assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), { + seriesName: 'Frieren', + seasonNumber: 2, + }); +}); + +test('parseEpisodePathFromDisplay returns null for non-episode displays', () => { + assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null); + assert.equal(parseEpisodePathFromDisplay('Just A Name'), null); +}); + +test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => { + const groups = buildRootSearchGroups([ + { id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' }, + { id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' }, + { id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' }, + ]); + + assert.deepEqual(groups, [ + { + id: 'series-1', + name: 'The Eminence in Shadow', + type: 'Series', + display: 'The Eminence in Shadow (Series)', + }, + { + id: 'movie-1', + name: 'Spirited Away', + type: 'Movie', + display: 'Spirited Away (Movie)', + }, + ]); +}); + +test('classifyJellyfinChildSelection keeps container drilldown state instead of flattening', () => { + const next = classifyJellyfinChildSelection({ id: 'season-2', type: 'Season' }); + assert.deepEqual(next, { + kind: 'container', + id: 'season-2', + }); }); diff --git a/launcher/main.test.ts b/launcher/main.test.ts index 7d5d93d9..542329e4 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -4,21 +4,6 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { resolveConfigFilePath } from '../src/config/path-resolution.js'; -import { - parseJellyfinLibrariesFromAppOutput, - parseJellyfinItemsFromAppOutput, - parseJellyfinErrorFromAppOutput, - parseJellyfinPreviewAuthResponse, - deriveJellyfinTokenStorePath, - hasStoredJellyfinSession, - shouldRetryWithStartForNoRunningInstance, - readUtf8FileAppendedSince, - parseEpisodePathFromDisplay, - buildRootSearchGroups, - classifyJellyfinChildSelection, - buildForwardedJellyfinAppArgs, -} from './jellyfin.js'; type RunResult = { status: number | null; @@ -96,74 +81,205 @@ const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin- fi `; +interface Sandbox { + root: string; + homeDir: string; + xdgConfigHome: string; + /** `/SubMiner`, created on demand by `writeConfigFile`. */ + configDir: string; + /** Base launcher env (isolated HOME/XDG); add `SUBMINER_APPIMAGE_PATH` etc. per test. */ + env: NodeJS.ProcessEnv; + writeConfigFile(name: string, content: string | object): void; +} + +/** Runs `fn` inside a temp root with an isolated home and XDG config dir. */ +function withSandbox(fn: (sandbox: Sandbox) => T): T { + return withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const xdgConfigHome = path.join(root, 'xdg'); + const configDir = path.join(xdgConfigHome, 'SubMiner'); + return fn({ + root, + homeDir, + xdgConfigHome, + configDir, + env: makeTestEnv(homeDir, xdgConfigHome), + writeConfigFile: (name, content) => { + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, name), + typeof content === 'string' ? content : JSON.stringify(content), + ); + }, + }); + }); +} + +function writeExecutable(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); + fs.chmodSync(filePath, 0o755); +} + +/** + * Writes a fake SubMiner app script. `body` runs after the runtime-plugin preflight prelude + * (omit the prelude with `preflight: false` for apps that never reach playback). The default + * body is a no-op; the launcher itself captures forwarded app args when + * `SUBMINER_TEST_CAPTURE` is set. + */ +function makeFakeApp( + root: string, + options: { name?: string; body?: string; preflight?: boolean } = {}, +): string { + const appPath = path.join(root, options.name ?? 'fake-subminer.sh'); + const prelude = options.preflight === false ? '' : RUNTIME_PLUGIN_PREFLIGHT_SH; + writeExecutable(appPath, `#!/bin/sh\n${prelude}${options.body ?? 'exit 0\n'}`); + return appPath; +} + +/** + * Fake mpv/yt-dlp/ffmpeg in `/bin`. The mpv stub records its argv into + * `SUBMINER_TEST_MPV_ARGS` and briefly serves the requested IPC socket so startup succeeds. + */ +function makeFakeMpv(root: string): { + binDir: string; + mpvArgsPath: string; + env: NodeJS.ProcessEnv; +} { + const binDir = path.join(root, 'bin'); + const mpvArgsPath = path.join(root, 'mpv-args.txt'); + const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/')); + writeExecutable( + path.join(binDir, 'mpv'), + `#!/bin/sh +set -eu +printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS" +socket_path="" +for arg in "$@"; do + case "$arg" in + --input-ipc-server=*) + socket_path="\${arg#--input-ipc-server=}" + ;; + esac +done +${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path" +`, + ); + writeExecutable(path.join(binDir, 'yt-dlp'), '#!/bin/sh\nexit 0\n'); + writeExecutable(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n'); + const pathValue = `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`; + return { + binDir, + mpvArgsPath, + env: { PATH: pathValue, Path: pathValue, SUBMINER_TEST_MPV_ARGS: mpvArgsPath }, + }; +} + +/** + * Sets up a completed-setup sandbox that reaches real playback: fake app + fake mpv, a config + * pointing mpv at a temp socket. Note: no `SUBMINER_TEST_CAPTURE` here. When set, the launcher + * intercepts every app command, including the Linux runtime-plugin preflight install, and + * returns without running the fake app, so the preflight would poll 30s for a response. + */ +function setupPlayback(sandbox: Sandbox, options: { autoStart: boolean; extraConfig?: object }) { + const socketPath = path.join(sandbox.root, 'mpv.sock'); + sandbox.writeConfigFile('setup-state.json', { + version: 1, + status: 'completed', + completedAt: '2026-03-08T00:00:00.000Z', + completionSource: 'user', + lastSeenYomitanDictionaryCount: 0, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + }); + sandbox.writeConfigFile('config.jsonc', { + auto_start_overlay: options.autoStart, + mpv: { + socketPath, + autoStartSubMiner: options.autoStart, + pauseUntilOverlayReady: options.autoStart, + }, + ...options.extraConfig, + }); + const appPath = makeFakeApp(sandbox.root); + const mpv = makeFakeMpv(sandbox.root); + const videoPath = path.join(sandbox.root, 'movie.mkv'); + fs.writeFileSync(videoPath, 'fake video content'); + + return { + videoPath, + env: { ...sandbox.env, ...mpv.env, SUBMINER_APPIMAGE_PATH: appPath }, + readMpvArgs: () => fs.readFileSync(mpv.mpvArgsPath, 'utf8'), + }; +} + +function assertSucceeded(result: RunResult): void { + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); +} + +/** Script for a fake app that answers `--stats-response-path` after `sleepSeconds`. */ +function statsResponderScript( + options: { sleepSeconds?: number; captureEnv?: string } = {}, +): string { + return `set -eu +response_path="" +prev="" +for arg in "$@"; do + if [ "$prev" = "--stats-response-path" ]; then + response_path="$arg" + prev="" + continue + fi + case "$arg" in + --stats-response-path=*) + response_path="\${arg#--stats-response-path=}" + ;; + --stats-response-path) + prev="--stats-response-path" + ;; + esac +done +${options.captureEnv ? `if [ -n "$${options.captureEnv}" ]; then\n printf '%s\\n' "$@" > "$${options.captureEnv}"\nfi\n` : ''}${options.sleepSeconds ? `sleep ${options.sleepSeconds}\n` : ''}mkdir -p "$(dirname "$response_path")" +printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path" +exit 0 +`; +} + test('config path uses XDG_CONFIG_HOME override', () => { - withTempDir((root) => { - const xdgConfigHome = path.join(root, 'xdg'); - const homeDir = path.join(root, 'home'); - fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); - fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.json'), '{"source":"xdg"}'); + withSandbox((sandbox) => { + sandbox.writeConfigFile('config.json', '{"source":"xdg"}'); - const result = runLauncher(['config', 'path'], makeTestEnv(homeDir, xdgConfigHome)); + const result = runLauncher(['config', 'path'], sandbox.env); assert.equal(result.status, 0); - assert.equal(result.stdout.trim(), path.join(xdgConfigHome, 'SubMiner', 'config.json')); + assert.equal(result.stdout.trim(), path.join(sandbox.configDir, 'config.json')); }); }); -test('config discovery ignores lowercase subminer candidate', () => { - const homeDir = '/home/tester'; - const xdgConfigHome = '/tmp/xdg-config'; - const expected = path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc'); - const foundPaths = new Set([path.posix.join(xdgConfigHome, 'subminer', 'config.json')]); +for (const flag of ['--version', '-v']) { + test(`${flag} prints installed app version without requiring app binary`, () => { + withSandbox((sandbox) => { + const result = runLauncher([flag], sandbox.env); - const resolved = resolveConfigFilePath({ - xdgConfigHome, - homeDir, - platform: 'linux', - existsSync: (candidate) => foundPaths.has(path.posix.normalize(candidate)), + assert.equal(result.status, 0); + assert.match(result.stdout.trim(), /^SubMiner \d+\.\d+\.\d+/); + assert.equal(result.stderr, ''); + }); }); - - assert.equal(resolved, expected); -}); - -test('version flag prints installed app version without requiring app binary', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const result = runLauncher(['--version'], makeTestEnv(homeDir, xdgConfigHome)); - - assert.equal(result.status, 0); - assert.match(result.stdout.trim(), /^SubMiner \d+\.\d+\.\d+/); - assert.equal(result.stderr, ''); - }); -}); - -test('short version flag prints installed app version without requiring app binary', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const result = runLauncher(['-v'], makeTestEnv(homeDir, xdgConfigHome)); - - assert.equal(result.status, 0); - assert.match(result.stdout.trim(), /^SubMiner \d+\.\d+\.\d+/); - assert.equal(result.stderr, ''); - }); -}); +} test('logs export writes sanitized archive without requiring app binary', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); + withSandbox((sandbox) => { const logsDir = process.platform === 'win32' - ? path.join(xdgConfigHome, 'SubMiner', 'logs') - : path.join(homeDir, '.config', 'SubMiner', 'logs'); + ? path.join(sandbox.xdgConfigHome, 'SubMiner', 'logs') + : path.join(sandbox.homeDir, '.config', 'SubMiner', 'logs'); fs.mkdirSync(logsDir, { recursive: true }); fs.writeFileSync(path.join(logsDir, 'app-2026-W21.log'), `/home/kyle/video.mkv\n`, 'utf8'); - const result = runLauncher(['logs', '-e'], makeTestEnv(homeDir, xdgConfigHome)); + const result = runLauncher(['logs', '-e'], sandbox.env); - assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assertSucceeded(result); const zipPath = result.stdout.trim(); assert.match(zipPath, /subminer-logs-.+\.zip$/); assert.equal(fs.existsSync(zipPath), true); @@ -174,28 +290,22 @@ test('logs export writes sanitized archive without requiring app binary', () => }); test('config path prefers jsonc over json for same directory', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); - fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.json'), '{"format":"json"}'); - fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), '{"format":"jsonc"}'); + withSandbox((sandbox) => { + sandbox.writeConfigFile('config.json', '{"format":"json"}'); + sandbox.writeConfigFile('config.jsonc', '{"format":"jsonc"}'); - const result = runLauncher(['config', 'path'], makeTestEnv(homeDir, xdgConfigHome)); + const result = runLauncher(['config', 'path'], sandbox.env); assert.equal(result.status, 0); - assert.equal(result.stdout.trim(), path.join(xdgConfigHome, 'SubMiner', 'config.jsonc')); + assert.equal(result.stdout.trim(), path.join(sandbox.configDir, 'config.jsonc')); }); }); test('config show prints config body and appends trailing newline', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); - fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), '{"logLevel":"debug"}'); + withSandbox((sandbox) => { + sandbox.writeConfigFile('config.jsonc', '{"logLevel":"debug"}'); - const result = runLauncher(['config', 'show'], makeTestEnv(homeDir, xdgConfigHome)); + const result = runLauncher(['config', 'show'], sandbox.env); assert.equal(result.status, 0); assert.equal(result.stdout, '{"logLevel":"debug"}\n'); @@ -203,17 +313,11 @@ test('config show prints config body and appends trailing newline', () => { }); test('mpv socket command returns socket path from plugin runtime config', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const expectedSocket = path.join(root, 'custom', 'subminer.sock'); - fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); - fs.writeFileSync( - path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), - JSON.stringify({ mpv: { socketPath: expectedSocket } }), - ); + withSandbox((sandbox) => { + const expectedSocket = path.join(sandbox.root, 'custom', 'subminer.sock'); + sandbox.writeConfigFile('config.jsonc', { mpv: { socketPath: expectedSocket } }); - const result = runLauncher(['mpv', 'socket'], makeTestEnv(homeDir, xdgConfigHome)); + const result = runLauncher(['mpv', 'socket'], sandbox.env); assert.equal(result.status, 0); assert.equal(result.stdout.trim(), expectedSocket); @@ -221,16 +325,12 @@ test('mpv socket command returns socket path from plugin runtime config', () => }); test('mpv status exits non-zero when socket is not ready', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const socketPath = path.join(root, 'missing.sock'); - fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); - fs.writeFileSync( - path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), - JSON.stringify({ mpv: { socketPath } }), - ); - const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome)); + withSandbox((sandbox) => { + sandbox.writeConfigFile('config.jsonc', { + mpv: { socketPath: path.join(sandbox.root, 'missing.sock') }, + }); + + const result = runLauncher(['mpv', 'status'], sandbox.env); assert.equal(result.status, 1); assert.match(result.stdout, /socket not ready/i); @@ -238,15 +338,8 @@ test('mpv status exits non-zero when socket is not ready', () => { }); test('doctor reports checks and exits non-zero without hard dependencies', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - PATH: '', - Path: '', - }; - const result = runLauncher(['doctor'], env); + withSandbox((sandbox) => { + const result = runLauncher(['doctor'], { ...sandbox.env, PATH: '', Path: '' }); assert.equal(result.status, 1); assert.match(result.stdout, /\[doctor\] app binary:/); @@ -256,24 +349,16 @@ test('doctor reports checks and exits non-zero without hard dependencies', () => }); test('doctor refresh-known-words forwards app refresh command without requiring mpv', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - const capturePath = path.join(root, 'captured-args.txt'); - fs.writeFileSync( - appPath, - `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`, - ); - fs.chmodSync(appPath, 0o755); - + withSandbox((sandbox) => { + const capturePath = path.join(sandbox.root, 'captured-args.txt'); const env = { - ...makeTestEnv(homeDir, xdgConfigHome), + ...sandbox.env, PATH: '', Path: '', - SUBMINER_APPIMAGE_PATH: appPath, + SUBMINER_APPIMAGE_PATH: makeFakeApp(sandbox.root), SUBMINER_TEST_CAPTURE: capturePath, }; + const result = runLauncher(['doctor', '--refresh-known-words'], env); assert.equal(result.status, 0); @@ -282,97 +367,92 @@ test('doctor refresh-known-words forwards app refresh command without requiring }); }); -test('launcher settings option forwards app settings window command', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - const capturePath = path.join(root, 'captured-args.txt'); - fs.writeFileSync( - appPath, - `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`, - ); - fs.chmodSync(appPath, 0o755); +const forwardedAppArgsCases: Array<{ + name: string; + /** Receives the sandbox root so cases can create targets (dictionary folders). */ + argv: (root: string) => string[]; + expected: (root: string) => string; +}> = [ + { + name: 'launcher settings command forwards app settings window command', + argv: () => ['settings'], + expected: () => '--settings\n', + }, + { + name: 'launcher youtube command forwards the YouTube browser flag and log level', + argv: () => ['yt', '--log-level', 'debug'], + expected: () => '--youtube-browser\n--log-level\ndebug\n', + }, + { + name: 'dictionary command forwards --dictionary and --dictionary-target to app command path', + argv: (root) => ['dictionary', path.join(root, 'anime-folder')], + expected: (root) => + `--start\n--dictionary\n--dictionary-target\n${path.join(root, 'anime-folder')}\n`, + }, + { + name: 'jellyfin discovery routes to app --background and remote announce with log-level forwarding', + argv: () => ['jellyfin', 'discovery', '--log-level', 'debug'], + expected: () => '--background\n--jellyfin-remote-announce\n--log-level\ndebug\n', + }, + { + name: 'jellyfin discovery via jf alias forwards remote announce for cast visibility', + argv: () => ['-R', 'jf', '--discovery', '--log-level', 'debug'], + expected: () => '--background\n--jellyfin-remote-announce\n--log-level\ndebug\n', + }, + { + name: 'jellyfin login routes credentials to app command', + argv: () => [ + 'jellyfin', + 'login', + '--server', + 'https://jf.example.test', + '--username', + 'alice', + '--password', + 'secret', + ], + expected: () => + '--jellyfin-login\n--jellyfin-server\nhttps://jf.example.test\n--jellyfin-username\nalice\n--jellyfin-password\nsecret\n', + }, + { + name: 'jellyfin setup forwards password-store to app command', + argv: () => ['jf', 'setup', '--password-store', 'gnome-libsecret'], + expected: () => '--jellyfin\n--password-store\ngnome-libsecret\n', + }, +]; - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_CAPTURE: capturePath, - }; - const result = runLauncher(['--settings'], env); +for (const c of forwardedAppArgsCases) { + test(c.name, () => { + withSandbox((sandbox) => { + fs.mkdirSync(path.join(sandbox.root, 'anime-folder'), { recursive: true }); + const capturePath = path.join(sandbox.root, 'captured-args.txt'); + const env = { + ...sandbox.env, + SUBMINER_APPIMAGE_PATH: makeFakeApp(sandbox.root), + SUBMINER_TEST_CAPTURE: capturePath, + }; - assert.equal(result.status, 0); - assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n'); + const result = runLauncher(c.argv(sandbox.root), env); + + assert.equal(result.status, 0); + assert.equal(fs.readFileSync(capturePath, 'utf8'), c.expected(sandbox.root)); + }); }); -}); - -test('launcher settings command forwards app settings window command', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - const capturePath = path.join(root, 'captured-args.txt'); - fs.writeFileSync( - appPath, - `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`, - ); - fs.chmodSync(appPath, 0o755); - - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_CAPTURE: capturePath, - }; - const result = runLauncher(['settings'], env); - - assert.equal(result.status, 0); - assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n'); - }); -}); - -test('launcher youtube command forwards the YouTube browser flag and log level', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - const capturePath = path.join(root, 'captured-args.txt'); - fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`); - fs.chmodSync(appPath, 0o755); - - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_CAPTURE: capturePath, - }; - const result = runLauncher(['yt', '--log-level', 'debug'], env); - - assert.equal(result.status, 0); - assert.equal(fs.readFileSync(capturePath, 'utf8'), '--youtube-browser\n--log-level\ndebug\n'); - }); -}); +} test('launcher settings command suppresses known Electron macOS menu diagnostics', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - fs.writeFileSync( - appPath, - [ - '#!/bin/sh', + withSandbox((sandbox) => { + const appPath = makeFakeApp(sandbox.root, { + preflight: false, + body: [ 'printf "%s\\n" "2026-05-17 02:59:52.141 SubMiner[29060:305323] representedObject is not a WeakPtrToElectronMenuModelAsNSObject" >&2', 'printf "%s\\n" "real stderr line" >&2', 'exit 0', '', ].join('\n'), - ); - fs.chmodSync(appPath, 0o755); + }); - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - SUBMINER_APPIMAGE_PATH: appPath, - }; - const result = runLauncher(['settings'], env); + const result = runLauncher(['settings'], { ...sandbox.env, SUBMINER_APPIMAGE_PATH: appPath }); assert.equal(result.status, 0); assert.equal(result.stderr, 'real stderr line\n'); @@ -380,326 +460,60 @@ test('launcher settings command suppresses known Electron macOS menu diagnostics }); test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const binDir = path.join(root, 'bin'); - const appPath = path.join(root, 'fake-subminer.sh'); - const videoPath = path.join(root, 'movie.mkv'); - const mpvArgsPath = path.join(root, 'mpv-args.txt'); - const socketPath = path.join(root, 'mpv.sock'); - const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/')); + withSandbox((sandbox) => { + const playback = setupPlayback(sandbox, { autoStart: false }); - fs.mkdirSync(binDir, { recursive: true }); - fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); - fs.writeFileSync(videoPath, 'fake video content'); - fs.writeFileSync( - path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'), - JSON.stringify({ - version: 1, - status: 'completed', - completedAt: '2026-03-08T00:00:00.000Z', - completionSource: 'user', - lastSeenYomitanDictionaryCount: 0, - pluginInstallStatus: 'installed', - pluginInstallPathSummary: null, - }), + const result = runLauncher( + ['--args', '--pause=yes --title="movie night"', playback.videoPath], + playback.env, ); - fs.writeFileSync( - path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), - JSON.stringify({ - auto_start_overlay: false, - mpv: { - socketPath, - autoStartSubMiner: false, - pauseUntilOverlayReady: false, - }, - }), - ); - fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`); - fs.chmodSync(appPath, 0o755); - fs.writeFileSync( - path.join(binDir, 'mpv'), - `#!/bin/sh -set -eu -printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS" -socket_path="" -for arg in "$@"; do - case "$arg" in - --input-ipc-server=*) - socket_path="\${arg#--input-ipc-server=}" - ;; - esac -done -${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path" -`, - 'utf8', - ); - fs.chmodSync(path.join(binDir, 'mpv'), 0o755); - fs.writeFileSync(path.join(binDir, 'yt-dlp'), '#!/bin/sh\nexit 0\n', 'utf8'); - fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8'); - fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755); - fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755); - - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, - Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_MPV_ARGS: mpvArgsPath, - }; - const result = runLauncher(['--args', '--pause=yes --title="movie night"', videoPath], env); - - assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); - const argsFile = fs.readFileSync(mpvArgsPath, 'utf8'); - const forwardedArgs = argsFile + assertSucceeded(result); + const forwardedArgs = playback + .readMpvArgs() .trim() .split('\n') .map((item) => item.trim()) .filter(Boolean); - assert.equal(forwardedArgs.includes('--pause=yes'), true); assert.equal(forwardedArgs.includes('--title=movie night'), true); - assert.equal(forwardedArgs.includes(videoPath), true); + assert.equal(forwardedArgs.includes(playback.videoPath), true); }); }); test('launcher forwards non-info log level into mpv logging args', { timeout: 15000 }, () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const binDir = path.join(root, 'bin'); - const appPath = path.join(root, 'fake-subminer.sh'); - const videoPath = path.join(root, 'movie.mkv'); - const mpvArgsPath = path.join(root, 'mpv-args.txt'); - const socketPath = path.join(root, 'mpv.sock'); - const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/')); + withSandbox((sandbox) => { + const playback = setupPlayback(sandbox, { + autoStart: true, + extraConfig: { logging: { files: { mpv: true } } }, + }); - fs.mkdirSync(binDir, { recursive: true }); - fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); - fs.writeFileSync(videoPath, 'fake video content'); - fs.writeFileSync( - path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'), - JSON.stringify({ - version: 1, - status: 'completed', - completedAt: '2026-03-08T00:00:00.000Z', - completionSource: 'user', - lastSeenYomitanDictionaryCount: 0, - pluginInstallStatus: 'installed', - pluginInstallPathSummary: null, - }), - ); - fs.writeFileSync( - path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), - JSON.stringify({ - auto_start_overlay: true, - mpv: { - socketPath, - autoStartSubMiner: true, - pauseUntilOverlayReady: true, - }, - logging: { - files: { - mpv: true, - }, - }, - }), - ); - fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`); - fs.chmodSync(appPath, 0o755); + const result = runLauncher(['--log-level', 'debug', playback.videoPath], playback.env); - fs.writeFileSync( - path.join(binDir, 'mpv'), - `#!/bin/sh -set -eu -printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS" -socket_path="" -for arg in "$@"; do - case "$arg" in - --input-ipc-server=*) - socket_path="\${arg#--input-ipc-server=}" - ;; - esac -done -${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path" -`, - 'utf8', - ); - fs.chmodSync(path.join(binDir, 'mpv'), 0o755); - fs.writeFileSync(path.join(binDir, 'yt-dlp'), '#!/bin/sh\nexit 0\n', 'utf8'); - fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8'); - fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755); - fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755); - - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, - Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_MPV_ARGS: mpvArgsPath, - }; - const result = runLauncher(['--log-level', 'debug', videoPath], env); - - assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); - const mpvArgs = fs.readFileSync(mpvArgsPath, 'utf8'); + assertSucceeded(result); + const mpvArgs = playback.readMpvArgs(); assert.match(mpvArgs, /--msg-level=all=warn,subminer=debug/); assert.doesNotMatch(mpvArgs, /--script-opts=.*subminer-log_level=debug/); }); }); test('launcher routes youtube urls through regular playback startup', { timeout: 15000 }, () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const binDir = path.join(root, 'bin'); - const appPath = path.join(root, 'fake-subminer.sh'); - const mpvArgsPath = path.join(root, 'mpv-args.txt'); - const socketPath = path.join(root, 'mpv.sock'); - const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/')); + withSandbox((sandbox) => { + const playback = setupPlayback(sandbox, { autoStart: true }); + const url = 'https://www.youtube.com/watch?v=abc123'; - fs.mkdirSync(binDir, { recursive: true }); - fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); - fs.writeFileSync( - path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'), - JSON.stringify({ - version: 1, - status: 'completed', - completedAt: '2026-03-08T00:00:00.000Z', - completionSource: 'user', - lastSeenYomitanDictionaryCount: 0, - pluginInstallStatus: 'installed', - pluginInstallPathSummary: null, - }), - ); - fs.writeFileSync( - path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), - JSON.stringify({ - auto_start_overlay: true, - mpv: { - socketPath, - autoStartSubMiner: true, - pauseUntilOverlayReady: true, - }, - }), - ); - fs.writeFileSync( - appPath, - `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`, - ); - fs.chmodSync(appPath, 0o755); - - fs.writeFileSync( - path.join(binDir, 'mpv'), - `#!/bin/sh -set -eu -printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS" -socket_path="" -for arg in "$@"; do - case "$arg" in - --input-ipc-server=*) - socket_path="\${arg#--input-ipc-server=}" - ;; - esac -done -${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path" -`, - 'utf8', - ); - fs.chmodSync(path.join(binDir, 'mpv'), 0o755); - fs.writeFileSync(path.join(binDir, 'yt-dlp'), '#!/bin/sh\nexit 0\n', 'utf8'); - fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8'); - fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755); - fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755); - - // Note: no SUBMINER_TEST_CAPTURE here. When set, the launcher intercepts - // *every* app command — including the Linux runtime-plugin preflight's - // `--ensure-linux-runtime-plugin-assets` install — and returns without - // running the fake app, so the preflight would poll 30s for a response that - // never arrives and time out. This test asserts on the mpv args, not on - // captured app args, so capture isn't needed. - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, - Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_MPV_ARGS: mpvArgsPath, - }; // Pass an explicit backend so overlay startup doesn't probe for a display // (headless CI has none), matching launcher/smoke.e2e.test.ts. - const result = runLauncher(['--backend', 'x11', 'https://www.youtube.com/watch?v=abc123'], env); + const result = runLauncher(['--backend', 'x11', url], playback.env); - assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); - const forwardedArgs = fs - .readFileSync(mpvArgsPath, 'utf8') + assertSucceeded(result); + const forwardedArgs = playback + .readMpvArgs() .trim() .split('\n') .map((item) => item.trim()) .filter(Boolean); - assert.equal(forwardedArgs.includes('https://www.youtube.com/watch?v=abc123'), true); - }); -}); - -test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - const capturePath = path.join(root, 'captured-args.txt'); - fs.writeFileSync( - appPath, - `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`, - ); - fs.chmodSync(appPath, 0o755); - - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_CAPTURE: capturePath, - }; - const targetPath = path.join(root, 'anime-folder'); - fs.mkdirSync(targetPath, { recursive: true }); - const result = runLauncher(['dictionary', targetPath], env); - - assert.equal(result.status, 0); - assert.equal( - fs.readFileSync(capturePath, 'utf8'), - `--start\n--dictionary\n--dictionary-target\n${targetPath}\n`, - ); - }); -}); - -test('dictionary command forwards manual AniList selection modes to app command path', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - const capturePath = path.join(root, 'captured-args.txt'); - fs.writeFileSync( - appPath, - `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`, - ); - fs.chmodSync(appPath, 0o755); - - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_CAPTURE: capturePath, - }; - const targetPath = path.join(root, 'anime.mkv'); - fs.writeFileSync(targetPath, ''); - - assert.equal(runLauncher(['dictionary', '--candidates', targetPath], env).status, 0); - assert.equal( - fs.readFileSync(capturePath, 'utf8'), - `--start\n--dictionary-candidates\n--dictionary-target\n${targetPath}\n`, - ); - assert.equal(runLauncher(['dictionary', '--select', '21355', targetPath], env).status, 0); - assert.equal( - fs.readFileSync(capturePath, 'utf8'), - `--start\n--dictionary-select\n--dictionary-anilist-id\n21355\n--dictionary-target\n${targetPath}\n`, - ); + assert.equal(forwardedArgs.includes(url), true); }); }); @@ -707,50 +521,21 @@ test( 'stats command launches attached app flow and waits for response file', { timeout: 15000 }, () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - const capturePath = path.join(root, 'captured-args.txt'); - fs.writeFileSync( - appPath, - `#!/bin/sh -set -eu -response_path="" -prev="" -for arg in "$@"; do - if [ "$prev" = "--stats-response-path" ]; then - response_path="$arg" - prev="" - continue - fi - case "$arg" in - --stats-response-path=*) - response_path="\${arg#--stats-response-path=}" - ;; - --stats-response-path) - prev="--stats-response-path" - ;; - esac -done -if [ -n "$SUBMINER_TEST_STATS_CAPTURE" ]; then - printf '%s\\n' "$@" > "$SUBMINER_TEST_STATS_CAPTURE" -fi -mkdir -p "$(dirname "$response_path")" -printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path" -exit 0 -`, - ); - fs.chmodSync(appPath, 0o755); - + withSandbox((sandbox) => { + const capturePath = path.join(sandbox.root, 'captured-args.txt'); + const appPath = makeFakeApp(sandbox.root, { + preflight: false, + body: statsResponderScript({ captureEnv: 'SUBMINER_TEST_STATS_CAPTURE' }), + }); const env = { - ...makeTestEnv(homeDir, xdgConfigHome), + ...sandbox.env, SUBMINER_APPIMAGE_PATH: appPath, SUBMINER_TEST_STATS_CAPTURE: capturePath, }; + const result = runLauncher(['stats', '--log-level', 'debug'], env); - assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assertSucceeded(result); assert.match( fs.readFileSync(capturePath, 'utf8'), /^--stats\n--stats-response-path\n.+\n--log-level\ndebug\n$/, @@ -759,375 +544,21 @@ exit 0 }, ); +// The startup response timeout (12s) is not overridable, so this really waits 9s. test( 'stats command tolerates slower dashboard startup before timing out', { timeout: 20000 }, () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer-slow.sh'); - fs.writeFileSync( - appPath, - `#!/bin/sh -set -eu -response_path="" -prev="" -for arg in "$@"; do - if [ "$prev" = "--stats-response-path" ]; then - response_path="$arg" - prev="" - continue - fi - case "$arg" in - --stats-response-path=*) - response_path="\${arg#--stats-response-path=}" - ;; - --stats-response-path) - prev="--stats-response-path" - ;; - esac -done -sleep 9 -mkdir -p "$(dirname "$response_path")" -printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path" -exit 0 -`, - ); - fs.chmodSync(appPath, 0o755); + withSandbox((sandbox) => { + const appPath = makeFakeApp(sandbox.root, { + name: 'fake-subminer-slow.sh', + preflight: false, + body: statsResponderScript({ sleepSeconds: 9 }), + }); - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - SUBMINER_APPIMAGE_PATH: appPath, - }; - const result = runLauncher(['stats'], env); + const result = runLauncher(['stats'], { ...sandbox.env, SUBMINER_APPIMAGE_PATH: appPath }); - assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assertSucceeded(result); }); }, ); - -test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - const capturePath = path.join(root, 'captured-args.txt'); - fs.writeFileSync( - appPath, - `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`, - ); - fs.chmodSync(appPath, 0o755); - - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_CAPTURE: capturePath, - }; - const result = runLauncher(['jellyfin', 'discovery', '--log-level', 'debug'], env); - - assert.equal(result.status, 0); - assert.equal( - fs.readFileSync(capturePath, 'utf8'), - '--background\n--jellyfin-remote-announce\n--log-level\ndebug\n', - ); - }); -}); - -test('jellyfin discovery via jf alias forwards remote announce for cast visibility', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - const capturePath = path.join(root, 'captured-args.txt'); - fs.writeFileSync( - appPath, - `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`, - ); - fs.chmodSync(appPath, 0o755); - - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_CAPTURE: capturePath, - }; - const result = runLauncher(['-R', 'jf', '--discovery', '--log-level', 'debug'], env); - - assert.equal(result.status, 0); - assert.equal( - fs.readFileSync(capturePath, 'utf8'), - '--background\n--jellyfin-remote-announce\n--log-level\ndebug\n', - ); - }); -}); - -test('jellyfin login routes credentials to app command', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - const capturePath = path.join(root, 'captured-args.txt'); - fs.writeFileSync( - appPath, - `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`, - ); - fs.chmodSync(appPath, 0o755); - - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_CAPTURE: capturePath, - }; - const result = runLauncher( - [ - 'jellyfin', - 'login', - '--server', - 'https://jf.example.test', - '--username', - 'alice', - '--password', - 'secret', - ], - env, - ); - - assert.equal(result.status, 0); - assert.equal( - fs.readFileSync(capturePath, 'utf8'), - '--jellyfin-login\n--jellyfin-server\nhttps://jf.example.test\n--jellyfin-username\nalice\n--jellyfin-password\nsecret\n', - ); - }); -}); - -test('jellyfin setup forwards password-store to app command', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - const capturePath = path.join(root, 'captured-args.txt'); - fs.writeFileSync( - appPath, - `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`, - ); - fs.chmodSync(appPath, 0o755); - - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_CAPTURE: capturePath, - }; - const result = runLauncher(['jf', 'setup', '--password-store', 'gnome-libsecret'], env); - - assert.equal(result.status, 0); - assert.equal( - fs.readFileSync(capturePath, 'utf8'), - '--jellyfin\n--password-store\ngnome-libsecret\n', - ); - }); -}); - -test('parseJellyfinLibrariesFromAppOutput parses prefixed library lines', () => { - const parsed = parseJellyfinLibrariesFromAppOutput(` -[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin library: Anime [lib1] (tvshows) -[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin library: Movies [lib2] (movies) -`); - - assert.deepEqual(parsed, [ - { id: 'lib1', name: 'Anime', kind: 'tvshows' }, - { id: 'lib2', name: 'Movies', kind: 'movies' }, - ]); -}); - -test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => { - const parsed = parseJellyfinItemsFromAppOutput(` -[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin item: Solo Leveling S01E10 [item-10] (Episode) -[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin item: Movie [Alt] [movie-1] (Movie) -`); - - assert.deepEqual(parsed, [ - { - id: 'item-10', - name: 'Solo Leveling S01E10', - type: 'Episode', - display: 'Solo Leveling S01E10', - }, - { - id: 'movie-1', - name: 'Movie [Alt]', - type: 'Movie', - display: 'Movie [Alt]', - }, - ]); -}); - -test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => { - const forwarded = buildForwardedJellyfinAppArgs( - { - jellyfinServer: 'https://jf.example.test/', - passwordStore: 'gnome-libsecret', - logLevel: 'info', - } as never, - ['--jellyfin-libraries'], - ); - - assert.deepEqual(forwarded, [ - '--jellyfin-libraries', - '--jellyfin-server', - 'https://jf.example.test', - '--password-store', - 'gnome-libsecret', - '--log-level', - 'info', - ]); -}); - -test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => { - const parsed = parseJellyfinErrorFromAppOutput(` -[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning -[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry. -`); - - assert.equal( - parsed, - 'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.', - ); -}); - -test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => { - const parsed = parseJellyfinErrorFromAppOutput(` -[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."} -`); - - assert.equal( - parsed, - '[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}', - ); -}); - -test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => { - const parsed = parseJellyfinPreviewAuthResponse( - JSON.stringify({ - serverUrl: 'http://pve-main:8096/', - accessToken: 'token-123', - userId: 'user-1', - }), - ); - - assert.deepEqual(parsed, { - serverUrl: 'http://pve-main:8096', - accessToken: 'token-123', - userId: 'user-1', - }); -}); - -test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => { - assert.equal(parseJellyfinPreviewAuthResponse(''), null); - assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null); - assert.equal( - parseJellyfinPreviewAuthResponse( - JSON.stringify({ - serverUrl: 'http://pve-main:8096', - accessToken: '', - userId: 'user-1', - }), - ), - null, - ); -}); - -test('deriveJellyfinTokenStorePath resolves alongside config path', () => { - const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc'); - const tokenPath = deriveJellyfinTokenStorePath(configPath); - assert.equal(tokenPath, path.join(path.dirname(configPath), 'jellyfin-token-store.json')); -}); - -test('hasStoredJellyfinSession checks token-store existence', () => { - const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc'); - const tokenPath = deriveJellyfinTokenStorePath(configPath); - const exists = (candidate: string): boolean => candidate === tokenPath; - assert.equal(hasStoredJellyfinSession(configPath, exists), true); - assert.equal( - hasStoredJellyfinSession(path.join('/home/test', '.config', 'Other', 'alt.jsonc'), exists), - false, - ); -}); - -test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => { - assert.equal( - shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'), - true, - ); - assert.equal( - shouldRetryWithStartForNoRunningInstance( - 'Missing Jellyfin session. Run --jellyfin-login first.', - ), - false, - ); -}); - -test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte logs', () => { - withTempDir((root) => { - const logPath = path.join(root, 'SubMiner.log'); - const prefix = '[subminer] こんにちは\n'; - const suffix = '[subminer] Jellyfin library: Movies [lib2] (movies)\n'; - fs.writeFileSync(logPath, `${prefix}${suffix}`, 'utf8'); - - const byteOffset = Buffer.byteLength(prefix, 'utf8'); - const fromByteOffset = readUtf8FileAppendedSince(logPath, byteOffset); - assert.match(fromByteOffset, /Jellyfin library: Movies \[lib2\] \(movies\)/); - - const fromBeyondEnd = readUtf8FileAppendedSince(logPath, byteOffset + 9999); - assert.match(fromBeyondEnd, /Jellyfin library: Movies \[lib2\] \(movies\)/); - }); -}); - -test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => { - assert.deepEqual( - parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'), - { - seriesName: 'KONOSUBA', - seasonNumber: 1, - }, - ); - assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), { - seriesName: 'Frieren', - seasonNumber: 2, - }); -}); - -test('parseEpisodePathFromDisplay returns null for non-episode displays', () => { - assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null); - assert.equal(parseEpisodePathFromDisplay('Just A Name'), null); -}); - -test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => { - const groups = buildRootSearchGroups([ - { id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' }, - { id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' }, - { id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' }, - ]); - - assert.deepEqual(groups, [ - { - id: 'series-1', - name: 'The Eminence in Shadow', - type: 'Series', - display: 'The Eminence in Shadow (Series)', - }, - { - id: 'movie-1', - name: 'Spirited Away', - type: 'Movie', - display: 'Spirited Away (Movie)', - }, - ]); -}); - -test('classifyJellyfinChildSelection keeps container drilldown state instead of flattening', () => { - const next = classifyJellyfinChildSelection({ id: 'season-2', type: 'Season' }); - assert.deepEqual(next, { - kind: 'container', - id: 'season-2', - }); -}); diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 637dc3ea..ff0fab42 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -7,6 +7,8 @@ import net from 'node:net'; import { EventEmitter } from 'node:events'; import type { Args } from './types'; import { getAppControlSocketPath } from '../src/shared/app-control'; +import { makeLauncherArgs } from './test-support/args.js'; +import { withEnv } from './test-support/env.js'; import { withProcessExitIntercept } from './test-support/exit-intercept.js'; import { buildConfiguredMpvDefaultArgs, @@ -28,13 +30,28 @@ import { state, waitForUnixSocketReady, } from './mpv'; -import * as mpvModule from './mpv'; -function createTempSocketPath(): { dir: string; socketPath: string } { +function makeArgs(overrides: Partial = {}): Args { + return makeLauncherArgs({ backend: 'x11', logLevel: 'error', ...overrides }); +} + +// ── shared helpers ─────────────────────────────────────────────────────────── + +interface TempCase { + dir: string; + socketPath: string; +} + +/** Runs `run` in a fresh temp dir (with an mpv socket path inside it) and removes it afterwards. */ +async function withTempCase(run: (tempCase: TempCase) => T | Promise): Promise { const baseDir = path.join(process.cwd(), '.tmp', 'launcher-mpv-tests'); fs.mkdirSync(baseDir, { recursive: true }); const dir = fs.mkdtempSync(path.join(baseDir, 'case-')); - return { dir, socketPath: path.join(dir, 'mpv.sock') }; + try { + return await run({ dir, socketPath: path.join(dir, 'mpv.sock') }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } } function withPlatform(platform: NodeJS.Platform, callback: () => T): T { @@ -53,9 +70,83 @@ function withPlatform(platform: NodeJS.Platform, callback: () => T): T { } } -test('mpv module exposes only canonical socket readiness helper', () => { - assert.equal('waitForSocket' in mpvModule, false); -}); +/** Writes an executable shell script `name` into `dir` and returns its path. */ +function writeFakeApp(dir: string, lines: string[], name = 'fake-subminer.sh'): string { + const appPath = path.join(dir, name); + fs.writeFileSync(appPath, ['#!/bin/sh', ...lines, ''].join('\n')); + fs.chmodSync(appPath, 0o755); + return appPath; +} + +/** Fake app that logs every invocation's argv and answers `--app-ping` with `pingExitCode`. */ +function writeInvocationRecorder(dir: string, pingExitCode: number) { + const invocationsPath = path.join(dir, 'app-invocations.log'); + const appPath = writeFakeApp(dir, [ + `printf '%s\\n' "$@" >> ${JSON.stringify(invocationsPath)}`, + `if [ "$1" = "--app-ping" ]; then exit ${pingExitCode}; fi`, + 'exit 0', + ]); + return { + appPath, + readInvocations: () => + fs.existsSync(invocationsPath) ? fs.readFileSync(invocationsPath, 'utf8') : '', + }; +} + +/** Makes every `net.createConnection` look like an mpv socket that connects after `delayMs`. */ +async function withConnectableSockets(delayMs: number, run: () => Promise): Promise { + const originalCreateConnection = net.createConnection; + net.createConnection = (() => { + const socket = new EventEmitter() as net.Socket; + socket.destroy = (() => socket) as net.Socket['destroy']; + socket.setTimeout = (() => socket) as net.Socket['setTimeout']; + setTimeout(() => socket.emit('connect'), delayMs); + return socket; + }) as typeof net.createConnection; + try { + return await run(); + } finally { + net.createConnection = originalCreateConnection; + } +} + +function resetLauncherState(): void { + state.overlayProc = null; + state.overlayManagedByLauncher = false; + state.appPath = ''; +} + +const listen = (server: net.Server, socketPath: string) => + new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + +const closeServer = (server: net.Server) => + new Promise((resolve) => server.close(() => resolve())); + +/** Fake app control server: records each request's argv and answers with `reply`. */ +function createControlServer(reply: { ok: boolean; error?: string } = { ok: true }) { + const receivedArgv: string[][] = []; + const server = net.createServer((socket) => { + let buffer = ''; + socket.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex < 0) return; + const payload = JSON.parse(buffer.slice(0, newlineIndex)) as { argv?: unknown }; + if (Array.isArray(payload.argv)) { + receivedArgv.push( + payload.argv.filter((value): value is string => typeof value === 'string'), + ); + } + socket.end(JSON.stringify(reply) + '\n'); + }); + }); + return { server, receivedArgv }; +} + +// ── app command execution ──────────────────────────────────────────────────── test('runAppCommandCaptureOutput captures status and stdio', () => { const result = runAppCommandCaptureOutput(process.execPath, [ @@ -69,10 +160,8 @@ test('runAppCommandCaptureOutput captures status and stdio', () => { assert.equal(result.error, undefined); }); -test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env', () => { - const original = process.env.ELECTRON_RUN_AS_NODE; - try { - process.env.ELECTRON_RUN_AS_NODE = '1'; +test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env', async () => { + await withEnv({ ELECTRON_RUN_AS_NODE: '1' }, () => { const result = runAppCommandCaptureOutput(process.execPath, [ '-e', 'process.stdout.write(String(process.env.ELECTRON_RUN_AS_NODE ?? ""));', @@ -80,62 +169,49 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env' assert.equal(result.status, 0); assert.equal(result.stdout, ''); - } finally { - if (original === undefined) { - delete process.env.ELECTRON_RUN_AS_NODE; - } else { - process.env.ELECTRON_RUN_AS_NODE = original; - } - } + }); }); -test('runAppCommandCaptureOutput transports Linux AppImage args through environment', () => { - if (process.platform !== 'linux') return; - const { dir } = createTempSocketPath(); - const appPath = path.join(dir, 'SubMiner.AppImage'); - fs.writeFileSync( - appPath, - [ - '#!/bin/sh', - 'printf "args:%s\\n" "$*"', - 'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"', - 'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"', - 'printf "arg1:%s\\n" "$SUBMINER_APP_ARG_1"', - '', - ].join('\n'), - ); - fs.chmodSync(appPath, 0o755); +test( + 'runAppCommandCaptureOutput transports Linux AppImage args through environment', + { skip: process.platform !== 'linux' }, + async () => { + await withTempCase(({ dir }) => { + const appPath = writeFakeApp( + dir, + [ + 'printf "args:%s\\n" "$*"', + 'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"', + 'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"', + 'printf "arg1:%s\\n" "$SUBMINER_APP_ARG_1"', + ], + 'SubMiner.AppImage', + ); - try { - const result = runAppCommandCaptureOutput(appPath, ['--app-ping', '--socket']); + const result = runAppCommandCaptureOutput(appPath, ['--app-ping', '--socket']); - assert.equal(result.status, 0); - assert.match(result.stdout, /^args:\n/m); - assert.match(result.stdout, /^argc:2\n/m); - assert.match(result.stdout, /^arg0:--app-ping\n/m); - assert.match(result.stdout, /^arg1:--socket\n/m); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); + assert.equal(result.status, 0); + assert.match(result.stdout, /^args:\n/m); + assert.match(result.stdout, /^argc:2\n/m); + assert.match(result.stdout, /^arg0:--app-ping\n/m); + assert.match(result.stdout, /^arg1:--socket\n/m); + }); + }, +); -test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', () => { - const { dir } = createTempSocketPath(); - const appPath = path.join(dir, 'SubMiner.AppImage'); - fs.writeFileSync( - appPath, - [ - '#!/bin/sh', - 'printf "args:%s\\n" "$*"', - 'printf "electron-node:%s\\n" "$ELECTRON_RUN_AS_NODE"', - 'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"', - 'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"', - '', - ].join('\n'), - ); - fs.chmodSync(appPath, 0o755); +test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', async () => { + await withTempCase(({ dir }) => { + const appPath = writeFakeApp( + dir, + [ + 'printf "args:%s\\n" "$*"', + 'printf "electron-node:%s\\n" "$ELECTRON_RUN_AS_NODE"', + 'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"', + 'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"', + ], + 'SubMiner.AppImage', + ); - try { const result = withPlatform('linux', () => runAppCommandCaptureOutput(appPath, ['--sync-cli', 'sync', '--snapshot', '/tmp/out']), ); @@ -145,11 +221,11 @@ test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', () assert.match(result.stdout, /^electron-node:1$/m); assert.match(result.stdout, /^argc:4$/m); assert.match(result.stdout, /^arg0:--sync-cli$/m); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + }); }); +// ── args, env, and plugin resolution ───────────────────────────────────────── + test('parseMpvArgString preserves empty quoted tokens', () => { assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [ '--title', @@ -276,27 +352,12 @@ test('buildMpvBackendArgs keeps supported Hyprland and Sway auto backends unchan test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured defaults', () => { withPlatform('linux', () => { - assert.deepEqual( - buildConfiguredMpvDefaultArgs(makeArgs({ launchMode: 'maximized' }), { - DISPLAY: ':1', - WAYLAND_DISPLAY: 'wayland-0', - XDG_SESSION_TYPE: 'wayland', - XDG_CURRENT_DESKTOP: 'KDE', - XDG_SESSION_DESKTOP: 'plasma', - }), - [ - '--sub-auto=fuzzy', - '--sub-file-paths=.;subs;subtitles', - '--sid=auto', - '--secondary-sid=auto', - '--sub-visibility=no', - '--secondary-sub-visibility=no', - '--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', - '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', - '--gpu-context=x11vk,x11egl,x11', - '--window-maximized=yes', - ], - ); + const args = buildConfiguredMpvDefaultArgs(makeArgs({ launchMode: 'maximized' }), { + DISPLAY: ':1', + XDG_SESSION_TYPE: 'x11', + }); + + assert.equal(args.at(-1), '--window-maximized=yes'); }); }); @@ -432,27 +493,25 @@ test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () assert.match(error.stderr, /Failed to launch texthooker mode/); }); -test('launchTexthookerOnly forwards browser-open request to app command', () => { - const { dir } = createTempSocketPath(); - const appPath = path.join(dir, 'fake-subminer.sh'); - const argsPath = path.join(dir, 'args.txt'); - const openedUrls: string[] = []; - fs.writeFileSync(appPath, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsPath}"\nexit 0\n`); - fs.chmodSync(appPath, 0o755); +test('launchTexthookerOnly forwards browser-open request to app command', async () => { + await withTempCase(({ dir }) => { + const argsPath = path.join(dir, 'args.txt'); + const openedUrls: string[] = []; + const appPath = writeFakeApp(dir, [`printf '%s\\n' "$@" > "${argsPath}"`, 'exit 0']); - const error = withProcessExitIntercept(() => { - launchTexthookerOnly(appPath, makeArgs({ logLevel: 'info', texthookerOpenBrowser: true }), { - openBrowser: (url) => openedUrls.push(url), + const error = withProcessExitIntercept(() => { + launchTexthookerOnly(appPath, makeArgs({ logLevel: 'info', texthookerOpenBrowser: true }), { + openBrowser: (url) => openedUrls.push(url), + }); }); - }); - assert.equal(error.code, 0); - assert.deepEqual(fs.readFileSync(argsPath, 'utf8').trim().split('\n'), [ - '--texthooker', - '--open-browser', - ]); - assert.deepEqual(openedUrls, ['http://127.0.0.1:5174']); - fs.rmSync(dir, { recursive: true, force: true }); + assert.equal(error.code, 0); + assert.deepEqual(fs.readFileSync(argsPath, 'utf8').trim().split('\n'), [ + '--texthooker', + '--open-browser', + ]); + assert.deepEqual(openedUrls, ['http://127.0.0.1:5174']); + }); }); test('launchAppCommandDetached handles child process spawn errors', async () => { @@ -476,31 +535,24 @@ test('launchAppCommandDetached handles child process spawn errors', async () => }); test('launchAppBackgroundDetached starts background child directly', async () => { - const { dir } = createTempSocketPath(); - const appPath = path.join(dir, 'fake-subminer.sh'); - const argsPath = path.join(dir, 'args.txt'); - const envPath = path.join(dir, 'env.txt'); - fs.writeFileSync( - appPath, - [ - '#!/bin/sh', + await withTempCase(async ({ dir }) => { + const argsPath = path.join(dir, 'args.txt'); + const envPath = path.join(dir, 'env.txt'); + const appPath = writeFakeApp(dir, [ `printf '%s\\n' "$@" > ${JSON.stringify(argsPath)}`, `printf '%s\\n' "$SUBMINER_BACKGROUND_CHILD" > ${JSON.stringify(envPath)}`, - '', - ].join('\n'), - ); - fs.chmodSync(appPath, 0o755); + ]); - launchAppBackgroundDetached(appPath, 'info'); + launchAppBackgroundDetached(appPath, 'info'); - const deadline = Date.now() + 1000; - while ((!fs.existsSync(argsPath) || !fs.existsSync(envPath)) && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 20)); - } + const deadline = Date.now() + 1000; + while ((!fs.existsSync(argsPath) || !fs.existsSync(envPath)) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } - assert.equal(fs.readFileSync(argsPath, 'utf8').trim(), '--start\n--background'); - assert.equal(fs.readFileSync(envPath, 'utf8').trim(), '1'); - fs.rmSync(dir, { recursive: true, force: true }); + assert.equal(fs.readFileSync(argsPath, 'utf8').trim(), '--start\n--background'); + assert.equal(fs.readFileSync(envPath, 'utf8').trim(), '1'); + }); }); test('stopOverlay logs a warning when stop command cannot be spawned', () => { @@ -527,578 +579,302 @@ test('stopOverlay logs a warning when stop command cannot be spawned', () => { } finally { process.stdout.write = originalWrite; state.stopRequested = false; - state.overlayManagedByLauncher = false; - state.appPath = ''; - state.overlayProc = null; + resetLauncherState(); } }); +// ── socket readiness ───────────────────────────────────────────────────────── + test('waitForUnixSocketReady returns false when socket never appears', async () => { - const { dir, socketPath } = createTempSocketPath(); - try { - const ready = await waitForUnixSocketReady(socketPath, 120); - assert.equal(ready, false); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + await withTempCase(async ({ socketPath }) => { + assert.equal(await waitForUnixSocketReady(socketPath, 120), false); + }); }); test('waitForUnixSocketReady returns false when path exists but is not socket', async () => { - const { dir, socketPath } = createTempSocketPath(); - try { + await withTempCase(async ({ socketPath }) => { fs.writeFileSync(socketPath, 'not-a-socket'); - const ready = await waitForUnixSocketReady(socketPath, 200); - assert.equal(ready, false); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + assert.equal(await waitForUnixSocketReady(socketPath, 200), false); + }); }); test('waitForUnixSocketReady returns true when socket becomes connectable before timeout', async () => { - const { dir, socketPath } = createTempSocketPath(); - fs.writeFileSync(socketPath, ''); - const originalCreateConnection = net.createConnection; - try { - net.createConnection = (() => { - const socket = new EventEmitter() as net.Socket; - socket.destroy = (() => socket) as net.Socket['destroy']; - socket.setTimeout = (() => socket) as net.Socket['setTimeout']; - setTimeout(() => socket.emit('connect'), 25); - return socket; - }) as typeof net.createConnection; - - const ready = await waitForUnixSocketReady(socketPath, 400); + await withTempCase(async ({ socketPath }) => { + fs.writeFileSync(socketPath, ''); + const ready = await withConnectableSockets(25, () => waitForUnixSocketReady(socketPath, 400)); assert.equal(ready, true); - } finally { - net.createConnection = originalCreateConnection; - fs.rmSync(dir, { recursive: true, force: true }); - } + }); }); -function makeArgs(overrides: Partial = {}): Args { - return { - backend: 'x11', - directory: '.', - recursive: false, - profile: '', - startOverlay: false, - youtubePrimarySubLangs: [], - youtubeSecondarySubLangs: [], - youtubeAudioLangs: [], - useTexthooker: false, - autoStartOverlay: false, - texthookerOnly: false, - texthookerOpenBrowser: false, - useRofi: false, - history: false, - sync: false, - syncCliTokens: [], - syncUi: false, - logLevel: 'error', - logRotation: 7, - passwordStore: '', - target: '', - targetKind: '', - jimakuApiKey: '', - jimakuApiKeyCommand: '', - jimakuApiBaseUrl: '', - jimakuLanguagePreference: 'none', - jimakuMaxEntryResults: 10, - jellyfin: false, - jellyfinLogin: false, - jellyfinLogout: false, - jellyfinPlay: false, - jellyfinDiscovery: false, - dictionary: false, - dictionaryCandidates: false, - dictionarySelect: false, - stats: false, - doctor: false, - doctorRefreshKnownWords: false, - logsExport: false, - version: false, - settings: false, - youtubeBrowser: false, - configPath: false, - configShow: false, - mpvIdle: false, - mpvSocket: false, - mpvStatus: false, - mpvArgs: '', - appPassthrough: false, - appArgs: [], - jellyfinServer: '', - jellyfinUsername: '', - jellyfinPassword: '', - launchMode: 'normal', - ...overrides, - }; +// ── startOverlay ownership and attach ──────────────────────────────────────── + +/** + * Runs `startOverlay` against a recording fake app and a connectable mpv socket, and returns + * what the app saw plus the launcher ownership state, captured before state is reset. + */ +async function runStartOverlay(options: { pingExitCode: number; alreadyManaged?: boolean }) { + return withTempCase(async ({ dir, socketPath }) => { + const app = writeInvocationRecorder(dir, options.pingExitCode); + fs.writeFileSync(socketPath, ''); + if (options.alreadyManaged) { + state.appPath = app.appPath; + state.overlayManagedByLauncher = true; + } + try { + await withConnectableSockets(10, () => startOverlay(app.appPath, makeArgs(), socketPath)); + return { + invocations: app.readInvocations(), + managedByLauncher: state.overlayManagedByLauncher, + ownedAppPath: state.appPath, + appPath: app.appPath, + }; + } finally { + resetLauncherState(); + } + }); } -test('startOverlay resolves without fixed 2s sleep when readiness signals arrive quickly', async () => { - const { dir, socketPath } = createTempSocketPath(); - const appPath = path.join(dir, 'fake-subminer.sh'); - fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n'); - fs.chmodSync(appPath, 0o755); - fs.writeFileSync(socketPath, ''); - const originalCreateConnection = net.createConnection; - try { - net.createConnection = (() => { - const socket = new EventEmitter() as net.Socket; - socket.destroy = (() => socket) as net.Socket['destroy']; - socket.setTimeout = (() => socket) as net.Socket['setTimeout']; - setTimeout(() => socket.emit('connect'), 10); - return socket; - }) as typeof net.createConnection; - - const startedAt = Date.now(); - await startOverlay(appPath, makeArgs(), socketPath); - const elapsedMs = Date.now() - startedAt; - - assert.ok(elapsedMs < 1200, `expected startOverlay <1200ms, got ${elapsedMs}ms`); - } finally { - net.createConnection = originalCreateConnection; - state.overlayProc = null; - state.overlayManagedByLauncher = false; - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - test('startOverlay captures app stdout and stderr into app log', async () => { - const { dir, socketPath } = createTempSocketPath(); - const appPath = path.join(dir, 'fake-subminer.sh'); - const appLogPath = path.join(dir, 'app.log'); - const originalAppLog = process.env.SUBMINER_APP_LOG; - fs.writeFileSync( - appPath, - '#!/bin/sh\nprintf "hello from stdout\\n"\nprintf "hello from stderr\\n" >&2\nexit 0\n', - ); - fs.chmodSync(appPath, 0o755); - fs.writeFileSync(socketPath, ''); - const originalCreateConnection = net.createConnection; - try { - process.env.SUBMINER_APP_LOG = appLogPath; - net.createConnection = (() => { - const socket = new EventEmitter() as net.Socket; - socket.destroy = (() => socket) as net.Socket['destroy']; - socket.setTimeout = (() => socket) as net.Socket['setTimeout']; - setTimeout(() => socket.emit('connect'), 10); - return socket; - }) as typeof net.createConnection; + await withTempCase(async ({ dir, socketPath }) => { + const appLogPath = path.join(dir, 'app.log'); + const appPath = writeFakeApp(dir, [ + 'printf "hello from stdout\\n"', + 'printf "hello from stderr\\n" >&2', + 'exit 0', + ]); + fs.writeFileSync(socketPath, ''); - await startOverlay(appPath, makeArgs(), socketPath); + try { + await withEnv({ SUBMINER_APP_LOG: appLogPath }, () => + withConnectableSockets(10, () => startOverlay(appPath, makeArgs(), socketPath)), + ); - const logText = fs.readFileSync(appLogPath, 'utf8'); - assert.match(logText, /\[STDOUT\] hello from stdout/); - assert.match(logText, /\[STDERR\] hello from stderr/); - } finally { - net.createConnection = originalCreateConnection; - state.overlayProc = null; - state.overlayManagedByLauncher = false; - if (originalAppLog === undefined) { - delete process.env.SUBMINER_APP_LOG; - } else { - process.env.SUBMINER_APP_LOG = originalAppLog; + const logText = fs.readFileSync(appLogPath, 'utf8'); + assert.match(logText, /\[STDOUT\] hello from stdout/); + assert.match(logText, /\[STDERR\] hello from stderr/); + } finally { + resetLauncherState(); } - fs.rmSync(dir, { recursive: true, force: true }); - } + }); }); test('startOverlay starts launcher-owned playback in background managed mode', async () => { - const { dir, socketPath } = createTempSocketPath(); - const appPath = path.join(dir, 'fake-subminer.sh'); - const appInvocationsPath = path.join(dir, 'app-invocations.log'); - fs.writeFileSync( - appPath, - [ - '#!/bin/sh', - `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`, - 'if [ "$1" = "--app-ping" ]; then exit 1; fi', - 'exit 0', - '', - ].join('\n'), - ); - fs.chmodSync(appPath, 0o755); - fs.writeFileSync(socketPath, ''); - const originalCreateConnection = net.createConnection; - try { - net.createConnection = (() => { - const socket = new EventEmitter() as net.Socket; - socket.destroy = (() => socket) as net.Socket['destroy']; - socket.setTimeout = (() => socket) as net.Socket['setTimeout']; - setTimeout(() => socket.emit('connect'), 10); - return socket; - }) as typeof net.createConnection; + const result = await runStartOverlay({ pingExitCode: 1 }); - await startOverlay(appPath, makeArgs(), socketPath); - - const invocationText = fs.readFileSync(appInvocationsPath, 'utf8'); - assert.match(invocationText, /--background/); - assert.match(invocationText, /--managed-playback/); - assert.equal(state.overlayManagedByLauncher, true); - assert.equal(state.appPath, appPath); - } finally { - net.createConnection = originalCreateConnection; - state.overlayProc = null; - state.overlayManagedByLauncher = false; - state.appPath = ''; - fs.rmSync(dir, { recursive: true, force: true }); - } + assert.match(result.invocations, /--background/); + assert.match(result.invocations, /--managed-playback/); + assert.equal(result.managedByLauncher, true); + assert.equal(result.ownedAppPath, result.appPath); }); test('startOverlay borrows an already-running background app instead of owning its lifecycle', async () => { - const { dir, socketPath } = createTempSocketPath(); - const appPath = path.join(dir, 'fake-subminer.sh'); - const appInvocationsPath = path.join(dir, 'app-invocations.log'); - fs.writeFileSync( - appPath, - [ - '#!/bin/sh', - `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`, - 'if [ "$1" = "--app-ping" ]; then exit 0; fi', - 'exit 0', - '', - ].join('\n'), - ); - fs.chmodSync(appPath, 0o755); - fs.writeFileSync(socketPath, ''); - const originalCreateConnection = net.createConnection; - try { - net.createConnection = (() => { - const socket = new EventEmitter() as net.Socket; - socket.destroy = (() => socket) as net.Socket['destroy']; - socket.setTimeout = (() => socket) as net.Socket['setTimeout']; - setTimeout(() => socket.emit('connect'), 10); - return socket; - }) as typeof net.createConnection; + const result = await runStartOverlay({ pingExitCode: 0 }); - await startOverlay(appPath, makeArgs(), socketPath); - - const invocationText = fs.readFileSync(appInvocationsPath, 'utf8'); - assert.match(invocationText, /--app-ping/); - assert.match(invocationText, /--start/); - assert.doesNotMatch(invocationText, /--background/); - assert.equal(state.overlayManagedByLauncher, false); - assert.equal(state.appPath, ''); - } finally { - net.createConnection = originalCreateConnection; - state.overlayProc = null; - state.overlayManagedByLauncher = false; - state.appPath = ''; - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('startOverlay attaches through the running app control socket without spawning another app command', async () => { - if (process.platform === 'win32') return; - - const { dir, socketPath } = createTempSocketPath(); - const controlSocketPath = path.join(dir, 'control.sock'); - const appPath = path.join(dir, 'fake-subminer.sh'); - const appInvocationsPath = path.join(dir, 'app-invocations.log'); - const receivedControlArgv: string[][] = []; - const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET; - - fs.writeFileSync( - appPath, - [ - '#!/bin/sh', - `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`, - 'if [ "$1" = "--app-ping" ]; then exit 0; fi', - 'exit 0', - '', - ].join('\n'), - ); - fs.chmodSync(appPath, 0o755); - - const mpvServer = net.createServer((socket) => socket.end()); - const controlServer = net.createServer((socket) => { - let buffer = ''; - socket.on('data', (chunk) => { - buffer += chunk.toString('utf8'); - const newlineMatch = buffer.match(/\r?\n/); - if (!newlineMatch || newlineMatch.index === undefined) return; - const line = buffer.slice(0, newlineMatch.index).trim(); - buffer = buffer.slice(newlineMatch.index + newlineMatch[0].length); - if (!line) return; - const payload = JSON.parse(line) as { argv?: unknown }; - if (Array.isArray(payload.argv)) { - receivedControlArgv.push( - payload.argv.filter((value): value is string => typeof value === 'string'), - ); - } - socket.end(JSON.stringify({ ok: true }) + '\n'); - }); - }); - - try { - process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath; - await new Promise((resolve, reject) => { - mpvServer.once('error', reject); - mpvServer.listen(socketPath, resolve); - }); - await new Promise((resolve, reject) => { - controlServer.once('error', reject); - controlServer.listen(controlSocketPath, resolve); - }); - - await startOverlay(appPath, makeArgs(), socketPath); - - const invocationText = fs.existsSync(appInvocationsPath) - ? fs.readFileSync(appInvocationsPath, 'utf8') - : ''; - assert.equal(invocationText, ''); - assert.equal(receivedControlArgv.length, 1); - assert.deepEqual(receivedControlArgv[0]?.slice(0, 7), [ - '--start', - '--managed-playback', - '--backend', - 'x11', - '--socket', - socketPath, - '--log-level', - ]); - assert.equal(state.overlayManagedByLauncher, false); - assert.equal(state.appPath, ''); - } finally { - if (originalControlSocket === undefined) { - delete process.env.SUBMINER_APP_CONTROL_SOCKET; - } else { - process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket; - } - await new Promise((resolve) => mpvServer.close(() => resolve())); - await new Promise((resolve) => controlServer.close(() => resolve())); - state.overlayProc = null; - state.overlayManagedByLauncher = false; - state.appPath = ''; - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('startOverlay uses caller config dir for app control socket discovery', async () => { - if (process.platform === 'win32') return; - - const { dir, socketPath } = createTempSocketPath(); - const configDir = path.join(dir, 'launcher-config'); - const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' }); - fs.mkdirSync(configDir, { recursive: true }); - const appPath = path.join(dir, 'fake-subminer.sh'); - const appInvocationsPath = path.join(dir, 'app-invocations.log'); - const receivedControlArgv: string[][] = []; - const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET; - - fs.writeFileSync( - appPath, - [ - '#!/bin/sh', - `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`, - 'if [ "$1" = "--app-ping" ]; then exit 0; fi', - 'exit 0', - '', - ].join('\n'), - ); - fs.chmodSync(appPath, 0o755); - - const mpvServer = net.createServer((socket) => socket.end()); - const controlServer = net.createServer((socket) => { - let buffer = ''; - socket.on('data', (chunk) => { - buffer += chunk.toString('utf8'); - const newlineIndex = buffer.indexOf('\n'); - if (newlineIndex < 0) return; - const payload = JSON.parse(buffer.slice(0, newlineIndex)) as { argv?: unknown }; - if (Array.isArray(payload.argv)) { - receivedControlArgv.push( - payload.argv.filter((value): value is string => typeof value === 'string'), - ); - } - socket.end(JSON.stringify({ ok: true }) + '\n'); - }); - }); - - try { - delete process.env.SUBMINER_APP_CONTROL_SOCKET; - await new Promise((resolve, reject) => { - mpvServer.once('error', reject); - mpvServer.listen(socketPath, resolve); - }); - await new Promise((resolve, reject) => { - controlServer.once('error', reject); - controlServer.listen(controlSocketPath, resolve); - }); - - await startOverlay(appPath, makeArgs(), socketPath, [], configDir); - - const invocationText = fs.existsSync(appInvocationsPath) - ? fs.readFileSync(appInvocationsPath, 'utf8') - : ''; - assert.equal(invocationText, ''); - assert.equal(receivedControlArgv.length, 1); - assert.deepEqual(receivedControlArgv[0]?.slice(0, 6), [ - '--start', - '--managed-playback', - '--backend', - 'x11', - '--socket', - socketPath, - ]); - } finally { - if (originalControlSocket === undefined) { - delete process.env.SUBMINER_APP_CONTROL_SOCKET; - } else { - process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket; - } - await new Promise((resolve) => mpvServer.close(() => resolve())); - await new Promise((resolve) => controlServer.close(() => resolve())); - state.overlayProc = null; - state.overlayManagedByLauncher = false; - state.appPath = ''; - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('startOverlay falls back to legacy app startup when control command fails', async () => { - if (process.platform === 'win32') return; - - const { dir, socketPath } = createTempSocketPath(); - const controlSocketPath = path.join(dir, 'control.sock'); - const appPath = path.join(dir, 'fake-subminer.sh'); - const appInvocationsPath = path.join(dir, 'app-invocations.log'); - const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET; - - fs.writeFileSync( - appPath, - [ - '#!/bin/sh', - `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`, - 'if [ "$1" = "--app-ping" ]; then exit 0; fi', - 'exit 0', - '', - ].join('\n'), - ); - fs.chmodSync(appPath, 0o755); - - const controlServer = net.createServer((socket) => { - socket.on('data', () => { - socket.end(JSON.stringify({ ok: false, error: 'boom' }) + '\n'); - }); - }); - - try { - process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath; - await new Promise((resolve, reject) => { - controlServer.once('error', reject); - controlServer.listen(controlSocketPath, resolve); - }); - - await startOverlay(appPath, makeArgs(), socketPath); - - const invocationText = fs.readFileSync(appInvocationsPath, 'utf8'); - assert.match(invocationText, /--app-ping/); - assert.match(invocationText, /--start/); - } finally { - if (originalControlSocket === undefined) { - delete process.env.SUBMINER_APP_CONTROL_SOCKET; - } else { - process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket; - } - await new Promise((resolve) => controlServer.close(() => resolve())); - state.overlayProc = null; - state.overlayManagedByLauncher = false; - state.appPath = ''; - fs.rmSync(dir, { recursive: true, force: true }); - } + assert.match(result.invocations, /--app-ping/); + assert.match(result.invocations, /--start/); + assert.doesNotMatch(result.invocations, /--background/); + assert.equal(result.managedByLauncher, false); + assert.equal(result.ownedAppPath, ''); }); test('startOverlay keeps lifecycle ownership for its already-managed app', async () => { - const { dir, socketPath } = createTempSocketPath(); - const appPath = path.join(dir, 'fake-subminer.sh'); - const appInvocationsPath = path.join(dir, 'app-invocations.log'); - fs.writeFileSync( - appPath, - [ - '#!/bin/sh', - `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`, - 'if [ "$1" = "--app-ping" ]; then exit 0; fi', - 'exit 0', - '', - ].join('\n'), - ); - fs.chmodSync(appPath, 0o755); - fs.writeFileSync(socketPath, ''); - const originalCreateConnection = net.createConnection; - try { - state.appPath = appPath; - state.overlayManagedByLauncher = true; - net.createConnection = (() => { - const socket = new EventEmitter() as net.Socket; - socket.destroy = (() => socket) as net.Socket['destroy']; - socket.setTimeout = (() => socket) as net.Socket['setTimeout']; - setTimeout(() => socket.emit('connect'), 10); - return socket; - }) as typeof net.createConnection; + const result = await runStartOverlay({ pingExitCode: 0, alreadyManaged: true }); - await startOverlay(appPath, makeArgs(), socketPath); - - assert.equal(state.overlayManagedByLauncher, true); - assert.equal(state.appPath, appPath); - } finally { - net.createConnection = originalCreateConnection; - state.overlayProc = null; - state.overlayManagedByLauncher = false; - state.appPath = ''; - fs.rmSync(dir, { recursive: true, force: true }); - } + assert.equal(result.managedByLauncher, true); + assert.equal(result.ownedAppPath, result.appPath); }); +const controlSocketCases = [ + { + name: 'startOverlay attaches through the running app control socket without spawning another app command', + // The control socket is found through SUBMINER_APP_CONTROL_SOCKET. + useConfigDir: false, + }, + { + name: 'startOverlay uses caller config dir for app control socket discovery', + useConfigDir: true, + }, +]; + +for (const c of controlSocketCases) { + test(c.name, { skip: process.platform === 'win32' }, async () => { + await withTempCase(async ({ dir, socketPath }) => { + const configDir = path.join(dir, 'launcher-config'); + fs.mkdirSync(configDir, { recursive: true }); + const controlSocketPath = c.useConfigDir + ? getAppControlSocketPath({ configDir, platform: 'linux' }) + : path.join(dir, 'control.sock'); + const app = writeInvocationRecorder(dir, 0); + const mpvServer = net.createServer((socket) => socket.end()); + const control = createControlServer(); + + try { + await listen(mpvServer, socketPath); + await listen(control.server, controlSocketPath); + + await withEnv( + { SUBMINER_APP_CONTROL_SOCKET: c.useConfigDir ? undefined : controlSocketPath }, + () => + c.useConfigDir + ? startOverlay(app.appPath, makeArgs(), socketPath, [], configDir) + : startOverlay(app.appPath, makeArgs(), socketPath), + ); + + assert.equal(app.readInvocations(), ''); + assert.equal(control.receivedArgv.length, 1); + assert.deepEqual(control.receivedArgv[0]?.slice(0, 6), [ + '--start', + '--managed-playback', + '--backend', + 'x11', + '--socket', + socketPath, + ]); + assert.equal(state.overlayManagedByLauncher, false); + assert.equal(state.appPath, ''); + } finally { + await closeServer(mpvServer); + await closeServer(control.server); + resetLauncherState(); + } + }); + }); +} + +test( + 'startOverlay falls back to legacy app startup when control command fails', + { skip: process.platform === 'win32' }, + async () => { + await withTempCase(async ({ dir, socketPath }) => { + const controlSocketPath = path.join(dir, 'control.sock'); + const app = writeInvocationRecorder(dir, 0); + const control = createControlServer({ ok: false, error: 'boom' }); + + try { + await listen(control.server, controlSocketPath); + + await withEnv({ SUBMINER_APP_CONTROL_SOCKET: controlSocketPath }, () => + startOverlay(app.appPath, makeArgs(), socketPath), + ); + + const invocations = app.readInvocations(); + assert.match(invocations, /--app-ping/); + assert.match(invocations, /--start/); + } finally { + await closeServer(control.server); + resetLauncherState(); + } + }); + }, +); + test('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned children', async () => { - const { dir } = createTempSocketPath(); - const appPath = path.join(dir, 'fake-subminer.sh'); - const appInvocationsPath = path.join(dir, 'app-invocations.log'); - fs.writeFileSync( - appPath, - `#!/bin/sh\necho \"$@\" >> ${JSON.stringify(appInvocationsPath)}\nexit 0\n`, - ); - fs.chmodSync(appPath, 0o755); + await withTempCase(async ({ dir }) => { + const app = writeInvocationRecorder(dir, 0); + const calls: string[] = []; + const overlayProc = { + killed: false, + kill: () => { + calls.push('overlay-kill'); + return true; + }, + } as unknown as NonNullable; + const mpvProc = { + killed: false, + kill: () => { + calls.push('mpv-kill'); + return true; + }, + } as unknown as NonNullable; - const calls: string[] = []; - const overlayProc = { - killed: false, - kill: () => { - calls.push('overlay-kill'); - return true; - }, - } as unknown as NonNullable; - const mpvProc = { - killed: false, - kill: () => { - calls.push('mpv-kill'); - return true; - }, - } as unknown as NonNullable; - - state.stopRequested = false; - state.appPath = appPath; - state.overlayManagedByLauncher = true; - state.overlayProc = overlayProc; - state.mpvProc = mpvProc; - - try { - await cleanupPlaybackSession(makeArgs()); - - assert.deepEqual(calls, ['overlay-kill', 'mpv-kill']); - assert.match(fs.readFileSync(appInvocationsPath, 'utf8'), /--stop/); - } finally { - state.overlayProc = null; - state.mpvProc = null; - state.overlayManagedByLauncher = false; - state.appPath = ''; state.stopRequested = false; - fs.rmSync(dir, { recursive: true, force: true }); - } + state.appPath = app.appPath; + state.overlayManagedByLauncher = true; + state.overlayProc = overlayProc; + state.mpvProc = mpvProc; + + try { + await cleanupPlaybackSession(makeArgs()); + + assert.deepEqual(calls.sort(), ['mpv-kill', 'overlay-kill']); + assert.match(app.readInvocations(), /--stop/); + } finally { + state.mpvProc = null; + state.stopRequested = false; + resetLauncherState(); + } + }); }); -// ── findAppBinary: Linux packaged path discovery ────────────────────────────── +// ── findAppBinary ──────────────────────────────────────────────────────────── + +interface FindAppBinaryScenario { + platform: NodeJS.Platform; + home: string; + /** Extra env; SUBMINER_APPIMAGE_PATH / SUBMINER_BINARY_PATH are always cleared first. */ + env?: Record; + /** When set, `fs.accessSync` accepts only these paths. */ + executables?: string[]; + /** When set, `fs.existsSync` / `fs.statSync` see only these paths. */ + existing?: string[]; + directories?: string[]; + /** When set, replaces `fs.realpathSync`. */ + realpath?: (filePath: string) => string; +} + +/** + * Runs `run` with platform, home dir, env, and (optionally) fs probes faked for + * `findAppBinary`, which reads them from process globals. Everything is restored afterwards. + */ +async function withFindAppBinaryScenario( + scenario: FindAppBinaryScenario, + run: (pathModule: typeof path) => void, +): Promise { + const restores: Array<() => void> = []; + const stub = (target: object, key: string, value: unknown): void => { + const original = Object.getOwnPropertyDescriptor(target, key); + Object.defineProperty(target, key, { value, configurable: true, writable: true }); + restores.push(() => { + if (original) Object.defineProperty(target, key, original); + else Reflect.deleteProperty(target, key); + }); + }; + const missing = (code: string, filePath: string) => + Object.assign(new Error(`${code}: ${filePath}`), { code }); + + try { + stub(process, 'platform', scenario.platform); + stub(os, 'homedir', () => scenario.home); + if (scenario.executables) { + const executables = new Set(scenario.executables); + stub(fs, 'accessSync', (filePath: string): void => { + if (!executables.has(filePath)) throw missing('EACCES', filePath); + }); + } + if (scenario.existing || scenario.directories) { + const directories = new Set(scenario.directories ?? []); + const files = new Set(scenario.existing ?? []); + stub( + fs, + 'existsSync', + (filePath: string) => files.has(filePath) || directories.has(filePath), + ); + stub(fs, 'statSync', (filePath: string) => { + if (directories.has(filePath)) return { isDirectory: () => true }; + if (files.has(filePath)) return { isDirectory: () => false }; + throw missing('ENOENT', filePath); + }); + } + if (scenario.realpath) stub(fs, 'realpathSync', scenario.realpath); + + await withEnv( + { SUBMINER_APPIMAGE_PATH: undefined, SUBMINER_BINARY_PATH: undefined, ...scenario.env }, + () => run(scenario.platform === 'win32' ? (path.win32 as typeof path) : path), + ); + } finally { + for (const restore of restores.reverse()) restore(); + } +} function makeExecutable(filePath: string): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -1106,194 +882,19 @@ function makeExecutable(filePath: string): void { fs.chmodSync(filePath, 0o755); } -function withFindAppBinaryEnvSandbox(run: () => void): void { - const originalAppImagePath = process.env.SUBMINER_APPIMAGE_PATH; - const originalBinaryPath = process.env.SUBMINER_BINARY_PATH; - try { - delete process.env.SUBMINER_APPIMAGE_PATH; - delete process.env.SUBMINER_BINARY_PATH; - run(); - } finally { - if (originalAppImagePath === undefined) { - delete process.env.SUBMINER_APPIMAGE_PATH; - } else { - process.env.SUBMINER_APPIMAGE_PATH = originalAppImagePath; - } - if (originalBinaryPath === undefined) { - delete process.env.SUBMINER_BINARY_PATH; - } else { - process.env.SUBMINER_BINARY_PATH = originalBinaryPath; - } - } -} - -function withFindAppBinaryPlatformSandbox( - platform: NodeJS.Platform, - run: (pathModule: typeof path) => void, -): void { - const originalPlatform = process.platform; - try { - Object.defineProperty(process, 'platform', { value: platform, configurable: true }); - withFindAppBinaryEnvSandbox(() => - run(platform === 'win32' ? (path.win32 as typeof path) : path), - ); - } finally { - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); - } -} - -function withAccessSyncStub( - isExecutablePath: (filePath: string) => boolean, - run: () => void, -): void { - const originalAccessSync = fs.accessSync; - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (fs as any).accessSync = (filePath: string): void => { - if (isExecutablePath(filePath)) { - return; - } - throw Object.assign(new Error(`EACCES: ${filePath}`), { code: 'EACCES' }); - }; - run(); - } finally { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (fs as any).accessSync = originalAccessSync; - } -} - -function withExistsAndStatSyncStubs( - options: { - existingPaths?: string[]; - directoryPaths?: string[]; - }, - run: () => void, -): void { - const existingPaths = new Set(options.existingPaths ?? []); - const directoryPaths = new Set(options.directoryPaths ?? []); - const originalExistsSync = fs.existsSync; - const originalStatSync = fs.statSync; - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (fs as any).existsSync = (filePath: string): boolean => - existingPaths.has(filePath) || directoryPaths.has(filePath); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (fs as any).statSync = (filePath: string) => { - if (directoryPaths.has(filePath)) { - return { isDirectory: () => true }; - } - if (existingPaths.has(filePath)) { - return { isDirectory: () => false }; - } - throw Object.assign(new Error(`ENOENT: ${filePath}`), { code: 'ENOENT' }); - }; - run(); - } finally { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (fs as any).existsSync = originalExistsSync; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (fs as any).statSync = originalStatSync; - } -} - -function withRealpathSyncStub(resolvePath: (filePath: string) => string, run: () => void): void { - const originalRealpathSync = fs.realpathSync; - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (fs as any).realpathSync = (filePath: string): string => resolvePath(filePath); - run(); - } finally { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (fs as any).realpathSync = originalRealpathSync; - } -} - -function listRepoRootWindowsTempArtifacts(): string[] { - return fs - .readdirSync(process.cwd()) - .filter((entry) => /^\\tmp\\subminer-test-win-/.test(entry)) - .sort(); -} - -function runFindAppBinaryWindowsPathCase(): void { - const baseDir = 'C:\\Users\\tester\\subminer-test-win-path'; - const originalHomedir = os.homedir; - const originalPath = process.env.PATH; - try { - os.homedir = () => baseDir; - const binDir = path.win32.join(baseDir, 'bin'); - const wrapperPath = path.win32.join(binDir, 'SubMiner.exe'); - process.env.PATH = `${binDir}${path.win32.delimiter}${originalPath ?? ''}`; - - withFindAppBinaryPlatformSandbox('win32', (pathModule) => { - withAccessSyncStub( - (filePath) => filePath === wrapperPath, - () => { - const result = findAppBinary( - pathModule.join(baseDir, 'launcher', 'SubMiner.exe'), - pathModule, - ); - assert.equal(result, wrapperPath); - }, - ); - }); - } finally { - os.homedir = originalHomedir; - process.env.PATH = originalPath; - } -} - -function runFindAppBinaryWindowsInstallDirCase(): void { - const baseDir = 'C:\\Users\\tester\\subminer-test-win-dir'; - const originalHomedir = os.homedir; - const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH; - try { - os.homedir = () => baseDir; - const installDir = path.win32.join(baseDir, 'Programs', 'SubMiner'); - const appExe = path.win32.join(installDir, 'SubMiner.exe'); - process.env.SUBMINER_BINARY_PATH = installDir; - - withPlatform('win32', () => { - withExistsAndStatSyncStubs({ existingPaths: [appExe], directoryPaths: [installDir] }, () => { - withAccessSyncStub( - (filePath) => filePath === appExe, - () => { - const result = findAppBinary( - path.win32.join(baseDir, 'launcher', 'SubMiner.exe'), - path.win32, - ); - assert.equal(result, appExe); - }, - ); - }); - }); - } finally { - os.homedir = originalHomedir; - if (originalSubminerBinaryPath === undefined) { - delete process.env.SUBMINER_BINARY_PATH; - } else { - process.env.SUBMINER_BINARY_PATH = originalSubminerBinaryPath; - } - } -} - test( 'findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', { concurrency: false }, - () => { + async () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-')); - const originalHomedir = os.homedir; try { - os.homedir = () => baseDir; const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage'); makeExecutable(appImage); - withFindAppBinaryPlatformSandbox('linux', (pathModule) => { - const result = findAppBinary('/some/other/path/subminer', pathModule); - assert.equal(result, appImage); + await withFindAppBinaryScenario({ platform: 'linux', home: baseDir }, (pathModule) => { + assert.equal(findAppBinary('/some/other/path/subminer', pathModule), appImage); }); } finally { - os.homedir = originalHomedir; fs.rmSync(baseDir, { recursive: true, force: true }); } }, @@ -1302,166 +903,124 @@ test( test( 'findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist', { concurrency: false }, - () => { - const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-')); - const originalHomedir = os.homedir; - try { - os.homedir = () => baseDir; - withFindAppBinaryPlatformSandbox('linux', (pathModule) => { - withAccessSyncStub( - (filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage', - () => { - const result = findAppBinary('/some/other/path/subminer', pathModule); - assert.equal(result, '/opt/SubMiner/SubMiner.AppImage'); - }, + async () => { + await withFindAppBinaryScenario( + { + platform: 'linux', + home: '/home/tester', + executables: ['/opt/SubMiner/SubMiner.AppImage'], + }, + (pathModule) => { + assert.equal( + findAppBinary('/some/other/path/subminer', pathModule), + '/opt/SubMiner/SubMiner.AppImage', ); - }); - } finally { - os.homedir = originalHomedir; - fs.rmSync(baseDir, { recursive: true, force: true }); - } + }, + ); }, ); test( 'findAppBinary finds subminer on PATH when AppImage candidates do not exist', { concurrency: false }, - () => { - const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-')); - const originalHomedir = os.homedir; - const originalPath = process.env.PATH; - try { - os.homedir = () => baseDir; - // No AppImage candidates in empty home dir; place subminer wrapper on PATH - const binDir = path.join(baseDir, 'bin'); - const wrapperPath = path.join(binDir, 'subminer'); - makeExecutable(wrapperPath); - process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`; + async () => { + const binDir = '/home/tester/bin'; + const wrapperPath = path.join(binDir, 'subminer'); - withFindAppBinaryPlatformSandbox('linux', (pathModule) => { - withAccessSyncStub( - (filePath) => filePath === wrapperPath, - () => { - // selfPath must differ from wrapperPath so the self-check does not exclude it - const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'), pathModule); - assert.equal(result, wrapperPath); - }, - ); - }); - } finally { - os.homedir = originalHomedir; - process.env.PATH = originalPath; - fs.rmSync(baseDir, { recursive: true, force: true }); - } + await withFindAppBinaryScenario( + { + platform: 'linux', + home: '/home/tester', + env: { PATH: binDir }, + executables: [wrapperPath], + }, + (pathModule) => { + // selfPath must differ from wrapperPath so the self-check does not exclude it + assert.equal(findAppBinary('/home/tester/launcher/subminer', pathModule), wrapperPath); + }, + ); }, ); test( 'findAppBinary excludes PATH matches that canonicalize to the launcher path', { concurrency: false }, - () => { - const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-realpath-')); - const originalHomedir = os.homedir; - const originalPath = process.env.PATH; - try { - os.homedir = () => baseDir; - const binDir = path.join(baseDir, 'bin'); - const wrapperPath = path.join(binDir, 'subminer'); - const canonicalPath = path.join(baseDir, 'launch', 'subminer'); - makeExecutable(wrapperPath); - process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`; + async () => { + const binDir = '/home/tester/bin'; + const wrapperPath = path.join(binDir, 'subminer'); + const canonicalPath = '/home/tester/launch/subminer'; - withFindAppBinaryPlatformSandbox('linux', (pathModule) => { - withAccessSyncStub( - (filePath) => filePath === wrapperPath, - () => { - withRealpathSyncStub( - (filePath) => { - if (filePath === canonicalPath || filePath === wrapperPath) { - return canonicalPath; - } - return filePath; - }, - () => { - const result = findAppBinary(canonicalPath, pathModule); - assert.equal(result, null); - }, - ); - }, - ); - }); - } finally { - os.homedir = originalHomedir; - process.env.PATH = originalPath; - fs.rmSync(baseDir, { recursive: true, force: true }); - } + await withFindAppBinaryScenario( + { + platform: 'linux', + home: '/home/tester', + env: { PATH: binDir }, + executables: [wrapperPath], + realpath: (filePath) => + filePath === canonicalPath || filePath === wrapperPath ? canonicalPath : filePath, + }, + (pathModule) => { + assert.equal(findAppBinary(canonicalPath, pathModule), null); + }, + ); }, ); -test('findAppBinary resolves Windows install paths when present', { concurrency: false }, () => { - const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-')); - const originalHomedir = os.homedir; - const originalLocalAppData = process.env.LOCALAPPDATA; - try { - os.homedir = () => baseDir; - process.env.LOCALAPPDATA = path.win32.join(baseDir, 'AppData', 'Local'); - const appExe = path.win32.join( - baseDir, - 'AppData', - 'Local', - 'Programs', - 'SubMiner', - 'SubMiner.exe', - ); - - withFindAppBinaryPlatformSandbox('win32', (pathModule) => { - withAccessSyncStub( - (filePath) => filePath === appExe, - () => { - const result = findAppBinary( - pathModule.join(baseDir, 'launcher', 'SubMiner.exe'), - pathModule, - ); - assert.equal(result, appExe); - }, - ); - }); - } finally { - os.homedir = originalHomedir; - if (originalLocalAppData === undefined) { - delete process.env.LOCALAPPDATA; - } else { - process.env.LOCALAPPDATA = originalLocalAppData; - } - fs.rmSync(baseDir, { recursive: true, force: true }); - } -}); +const windowsHome = 'C:\\Users\\tester'; +const windowsLauncherPath = path.win32.join(windowsHome, 'launcher', 'SubMiner.exe'); test( - 'findAppBinary Windows cases do not leak backslash temp artifacts on POSIX', + 'findAppBinary resolves Windows install paths when present', { concurrency: false }, - () => { - if (path.sep === '\\') { - return; - } + async () => { + const localAppData = path.win32.join(windowsHome, 'AppData', 'Local'); + const appExe = path.win32.join(localAppData, 'Programs', 'SubMiner', 'SubMiner.exe'); - const before = listRepoRootWindowsTempArtifacts(); - runFindAppBinaryWindowsPathCase(); - runFindAppBinaryWindowsInstallDirCase(); - const after = listRepoRootWindowsTempArtifacts(); - - assert.deepEqual(after, before); + await withFindAppBinaryScenario( + { + platform: 'win32', + home: windowsHome, + env: { LOCALAPPDATA: localAppData }, + executables: [appExe], + }, + (pathModule) => { + assert.equal(findAppBinary(windowsLauncherPath, pathModule), appExe); + }, + ); }, ); -test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: false }, () => { - runFindAppBinaryWindowsPathCase(); +test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: false }, async () => { + const binDir = path.win32.join(windowsHome, 'bin'); + const wrapperPath = path.win32.join(binDir, 'SubMiner.exe'); + + await withFindAppBinaryScenario( + { platform: 'win32', home: windowsHome, env: { PATH: binDir }, executables: [wrapperPath] }, + (pathModule) => { + assert.equal(findAppBinary(windowsLauncherPath, pathModule), wrapperPath); + }, + ); }); test( 'findAppBinary resolves a Windows install directory to SubMiner.exe', { concurrency: false }, - () => { - runFindAppBinaryWindowsInstallDirCase(); + async () => { + const installDir = path.win32.join(windowsHome, 'Programs', 'SubMiner'); + const appExe = path.win32.join(installDir, 'SubMiner.exe'); + + await withFindAppBinaryScenario( + { + platform: 'win32', + home: windowsHome, + env: { SUBMINER_BINARY_PATH: installDir }, + existing: [appExe], + directories: [installDir], + executables: [appExe], + }, + (pathModule) => { + assert.equal(findAppBinary(windowsLauncherPath, pathModule), appExe); + }, + ); }, ); diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts index d4bda44d..bb82fca7 100644 --- a/launcher/parse-args.test.ts +++ b/launcher/parse-args.test.ts @@ -30,12 +30,6 @@ test('parseArgs captures mpv args string', () => { assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"'); }); -test('parseArgs appends CLI mpv profile to configured mpv profile', () => { - const parsed = parseArgs(['--profile', 'hdr'], 'subminer', {}, { profile: 'anime' }); - - assert.equal(parsed.profile, 'anime,hdr'); -}); - test('parseArgs maps root settings window option', () => { const parsed = parseArgs(['--settings'], 'subminer', {}); @@ -224,14 +218,6 @@ test('parseArgs maps stats cleanup to vocab mode by default', () => { assert.equal(parsed.statsCleanupVocab, true); }); -test('parseArgs maps explicit stats cleanup vocab flag', () => { - const parsed = parseArgs(['stats', 'cleanup', '-v'], 'subminer', {}); - - assert.equal(parsed.stats, true); - assert.equal(parsed.statsCleanup, true); - assert.equal(parsed.statsCleanupVocab, true); -}); - test('parseArgs maps lifetime stats cleanup flag', () => { const parsed = parseArgs(['stats', 'cleanup', '--lifetime'], 'subminer', {}); diff --git a/launcher/runtime-plugin-preflight.test.ts b/launcher/runtime-plugin-preflight.test.ts index 7d9be846..cd13beb0 100644 --- a/launcher/runtime-plugin-preflight.test.ts +++ b/launcher/runtime-plugin-preflight.test.ts @@ -8,282 +8,168 @@ import { installManagedPluginAssetsViaApp, } from './runtime-plugin-preflight'; -test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => { - const calls: string[] = []; +type EnsureOptions = Parameters[0]; - await ensureLinuxRuntimePluginAvailable({ - platform: 'darwin', - detectInstalledPlugin: () => { - calls.push('detect'); - return false; - }, - resolveRuntimePluginPath: () => { - calls.push('resolve'); - return null; - }, - installManagedPluginAssets: async () => { - calls.push('install'); - return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; - }, - log: () => { - calls.push('log'); - }, - }); - - assert.deepEqual(calls, []); -}); - -test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and thumbnailer exist', async () => { - const calls: string[] = []; - - await ensureLinuxRuntimePluginAvailable({ - platform: 'linux', - detectInstalledPlugin: () => { - calls.push('detect'); - return true; - }, - resolveRuntimePluginPath: () => { - calls.push('resolve'); - return null; - }, - installManagedPluginAssets: async () => { - calls.push('install'); - return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; - }, - isManagedThemeAvailable: () => { - calls.push('theme'); - return true; - }, - isManagedThumbnailerAvailable: () => { - calls.push('thumbnailer'); - return true; - }, - log: () => {}, - }); - - assert.deepEqual(calls, ['detect', 'theme', 'thumbnailer']); -}); - -test('ensureLinuxRuntimePluginAvailable skips install when all managed assets resolve', async () => { - const calls: string[] = []; - - await ensureLinuxRuntimePluginAvailable({ - platform: 'linux', - xdgDataHome: '/tmp/xdg-data', - detectInstalledPlugin: () => { - calls.push('detect'); - return false; - }, - resolveRuntimePluginPath: () => { - calls.push('resolve'); - return '/tmp/plugin/main.lua'; - }, - installManagedPluginAssets: async () => { - calls.push('install'); - return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; - }, - isManagedThemeAvailable: () => { - calls.push('theme'); - return true; - }, - isManagedThumbnailerAvailable: () => { - calls.push('thumbnailer'); - return true; - }, - log: () => {}, - }); - - assert.deepEqual(calls, ['detect', 'resolve', 'theme', 'thumbnailer']); -}); - -test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => { - const calls: string[] = []; - let themeAvailable = false; - - await ensureLinuxRuntimePluginAvailable({ - platform: 'linux', - xdgDataHome: '/tmp/xdg-data', - detectInstalledPlugin: () => { - calls.push('detect'); - return false; - }, - resolveRuntimePluginPath: () => { - calls.push('resolve'); - return '/tmp/plugin/main.lua'; - }, - isManagedThemeAvailable: () => { - calls.push('theme'); - return themeAvailable; - }, - isManagedThumbnailerAvailable: () => { - calls.push('thumbnailer'); - return true; - }, - installManagedPluginAssets: async () => { - calls.push('install'); - themeAvailable = true; - return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; - }, - log: (level, _configured, message) => { - calls.push(`${level}:${message}`); - }, - }); - - assert.deepEqual(calls, [ - 'detect', - 'resolve', - 'theme', - 'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.', - 'install', - 'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer', - 'resolve', - 'theme', - 'thumbnailer', - ]); -}); - -test('ensureLinuxRuntimePluginAvailable installs managed assets when thumbnailer is missing', async () => { - const calls: string[] = []; - let thumbnailerAvailable = false; - - await ensureLinuxRuntimePluginAvailable({ - platform: 'linux', - xdgDataHome: '/tmp/xdg-data', - detectInstalledPlugin: () => true, - resolveRuntimePluginPath: () => '/tmp/plugin/main.lua', - isManagedThemeAvailable: () => true, - isManagedThumbnailerAvailable: () => thumbnailerAvailable, - installManagedPluginAssets: async () => { - calls.push('install'); - thumbnailerAvailable = true; - return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; - }, - log: (_level, _configured, message) => { - calls.push(message); - }, - }); - - assert.deepEqual(calls, [ - 'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.', - 'install', - 'Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer', - ]); -}); - -test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => { - const calls: string[] = []; - let thumbnailerAvailable = false; - - await ensureLinuxRuntimePluginAvailable({ - platform: 'linux', - xdgDataHome: '/tmp/xdg-data', - detectInstalledPlugin: () => true, - resolveRuntimePluginPath: () => { - calls.push('resolve'); - return null; - }, - isManagedThemeAvailable: () => true, - isManagedThumbnailerAvailable: () => thumbnailerAvailable, - installManagedPluginAssets: async () => { - calls.push('install'); - thumbnailerAvailable = true; - return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; - }, - log: () => {}, - }); - - assert.deepEqual(calls, ['install']); -}); - -test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => { - const calls: string[] = []; - let resolveCount = 0; - - await ensureLinuxRuntimePluginAvailable({ +/** + * Linux preflight harness: by default every managed asset resolves, so tests override only + * the probes that should report something missing. Counts installs and collects log messages. + */ +function makeEnsureHarness(overrides: Partial = {}) { + const state = { installs: 0, logs: [] as string[] }; + const options: EnsureOptions = { platform: 'linux', xdgDataHome: '/tmp/xdg-data', detectInstalledPlugin: () => false, - resolveRuntimePluginPath: () => { - resolveCount += 1; - calls.push(`resolve:${resolveCount}`); - return resolveCount === 1 ? null : '/tmp/plugin/main.lua'; - }, + resolveRuntimePluginPath: () => '/tmp/plugin/main.lua', isManagedThemeAvailable: () => true, isManagedThumbnailerAvailable: () => true, installManagedPluginAssets: async () => { - calls.push('install'); + state.installs += 1; return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; }, - log: (level, _configured, message) => { - calls.push(`${level}:${message}`); + log: (_level, _configured, message) => { + state.logs.push(message); + }, + ...overrides, + }; + return { state, run: () => ensureLinuxRuntimePluginAvailable(options) }; +} + +test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => { + const unexpected = () => { + throw new Error('probed on a non-Linux platform'); + }; + const harness = makeEnsureHarness({ + platform: 'darwin', + detectInstalledPlugin: unexpected, + resolveRuntimePluginPath: unexpected, + isManagedThemeAvailable: unexpected, + isManagedThumbnailerAvailable: unexpected, + }); + + await harness.run(); + + assert.equal(harness.state.installs, 0); + assert.deepEqual(harness.state.logs, []); +}); + +const skipInstallCases: Array<{ name: string; overrides: Partial }> = [ + { + name: 'plugin, theme, and thumbnailer exist', + overrides: { detectInstalledPlugin: () => true, resolveRuntimePluginPath: () => null }, + }, + { name: 'all managed assets resolve', overrides: {} }, +]; + +for (const c of skipInstallCases) { + test(`ensureLinuxRuntimePluginAvailable skips install when ${c.name}`, async () => { + const harness = makeEnsureHarness(c.overrides); + + await harness.run(); + + assert.equal(harness.state.installs, 0); + assert.deepEqual(harness.state.logs, []); + }); +} + +const installWhenMissingCases: Array<{ name: string; missing: 'theme' | 'thumbnailer' }> = [ + { name: 'rofi theme is missing', missing: 'theme' }, + { name: 'thumbnailer is missing', missing: 'thumbnailer' }, +]; + +for (const c of installWhenMissingCases) { + test(`ensureLinuxRuntimePluginAvailable installs managed assets when ${c.name}`, async () => { + let available = false; + const probe = { + [c.missing === 'theme' ? 'isManagedThemeAvailable' : 'isManagedThumbnailerAvailable']: () => + available, + }; + const harness = makeEnsureHarness({ + ...probe, + installManagedPluginAssets: async () => { + harness.state.installs += 1; + available = true; + return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; + }, + }); + + await harness.run(); + + assert.equal(harness.state.installs, 1); + assert.equal(harness.state.logs.length, 2); + assert.match(harness.state.logs[0]!, /support assets missing; installing/); + assert.match( + harness.state.logs[1]!, + /installed: plugin=\/tmp\/plugin\/main\.lua theme=.*subminer\.rasi thumbnailer=.*\.thumbnailer/, + ); + }); +} + +test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => { + let thumbnailerAvailable = false; + const harness = makeEnsureHarness({ + detectInstalledPlugin: () => true, + // An installed plugin means the bundled path is never needed, even after the install. + resolveRuntimePluginPath: () => null, + isManagedThumbnailerAvailable: () => thumbnailerAvailable, + installManagedPluginAssets: async () => { + harness.state.installs += 1; + thumbnailerAvailable = true; + return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; }, }); - assert.deepEqual(calls, [ - 'resolve:1', - 'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.', - 'install', - 'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer', - 'resolve:2', - ]); + await harness.run(); + + assert.equal(harness.state.installs, 1); }); -test('ensureLinuxRuntimePluginAvailable fails when install result is not ok', async () => { - await assert.rejects( - () => - ensureLinuxRuntimePluginAvailable({ - platform: 'linux', - detectInstalledPlugin: () => false, - resolveRuntimePluginPath: () => null, - installManagedPluginAssets: async () => ({ - ok: false, - status: 'failed', - error: 'copy failed', - }), - log: () => {}, - }), - /copy failed/, - ); +test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => { + let installed = false; + const harness = makeEnsureHarness({ + resolveRuntimePluginPath: () => (installed ? '/tmp/plugin/main.lua' : null), + installManagedPluginAssets: async () => { + harness.state.installs += 1; + installed = true; + return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; + }, + }); + + await harness.run(); + + assert.equal(harness.state.installs, 1); }); -test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolved after install', async () => { - await assert.rejects( - () => - ensureLinuxRuntimePluginAvailable({ - platform: 'linux', - detectInstalledPlugin: () => false, - resolveRuntimePluginPath: () => null, - installManagedPluginAssets: async () => ({ - ok: true, - status: 'installed', - path: '/tmp/plugin/main.lua', - }), - log: () => {}, +const failureCases: Array<{ name: string; overrides: Partial; error: RegExp }> = [ + { + name: 'install result is not ok', + overrides: { + resolveRuntimePluginPath: () => null, + installManagedPluginAssets: async () => ({ + ok: false, + status: 'failed', + error: 'copy failed', }), - /managed runtime plugin assets could not be installed/i, - ); -}); + }, + error: /copy failed/, + }, + { + name: 'runtime path remains unresolved after install', + overrides: { resolveRuntimePluginPath: () => null }, + error: /managed runtime plugin assets could not be installed/i, + }, + { + name: 'thumbnailer remains missing after install', + overrides: { detectInstalledPlugin: () => true, isManagedThumbnailerAvailable: () => false }, + error: /thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i, + }, +]; -test('ensureLinuxRuntimePluginAvailable fails when thumbnailer remains missing after install', async () => { - await assert.rejects( - () => - ensureLinuxRuntimePluginAvailable({ - platform: 'linux', - xdgDataHome: '/tmp/xdg-data', - detectInstalledPlugin: () => true, - resolveRuntimePluginPath: () => '/tmp/plugin/main.lua', - isManagedThemeAvailable: () => true, - isManagedThumbnailerAvailable: () => false, - installManagedPluginAssets: async () => ({ - ok: true, - status: 'installed', - path: '/tmp/plugin/main.lua', - }), - log: () => {}, - }), - /thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i, - ); -}); +for (const c of failureCases) { + test(`ensureLinuxRuntimePluginAvailable fails when ${c.name}`, async () => { + await assert.rejects(() => makeEnsureHarness(c.overrides).run(), c.error); + }); +} test('ensureLinuxRuntimePluginAvailable rejects a thumbnailer directory before and after install', async () => { const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-thumbnailer-directory-')); diff --git a/launcher/setup-gate.test.ts b/launcher/setup-gate.test.ts index d61e8e67..489b70ec 100644 --- a/launcher/setup-gate.test.ts +++ b/launcher/setup-gate.test.ts @@ -3,104 +3,90 @@ import assert from 'node:assert/strict'; import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate'; import type { SetupState } from '../src/shared/setup-state'; -const commandLineSetupDefaults = { - bunInstallStatus: 'unknown', - launcherInstallStatus: 'unknown', - launcherInstallPath: null, -} satisfies Pick; +/** An in-progress setup state; tests override only the fields that matter. */ +function makeState(overrides: Partial = {}): SetupState { + return { + version: 4, + status: 'in_progress', + completedAt: null, + completionSource: null, + yomitanSetupMode: null, + lastSeenYomitanDictionaryCount: 0, + pluginInstallStatus: 'unknown', + pluginInstallPathSummary: null, + windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, + windowsMpvShortcutLastStatus: 'unknown', + bunInstallStatus: 'unknown', + launcherInstallStatus: 'unknown', + launcherInstallPath: null, + ...overrides, + }; +} -test('waitForSetupCompletion resolves completed and cancelled states', async () => { - const sequence: Array = [ - null, - { - version: 4, - status: 'in_progress', - completedAt: null, - completionSource: null, - yomitanSetupMode: null, - lastSeenYomitanDictionaryCount: 0, - pluginInstallStatus: 'unknown', - pluginInstallPathSummary: null, - windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, - windowsMpvShortcutLastStatus: 'unknown', - ...commandLineSetupDefaults, - }, - { - version: 4, - status: 'completed', - completedAt: '2026-03-07T00:00:00.000Z', - completionSource: 'user', - yomitanSetupMode: 'internal', - lastSeenYomitanDictionaryCount: 1, - pluginInstallStatus: 'skipped', - pluginInstallPathSummary: null, - windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, - windowsMpvShortcutLastStatus: 'skipped', - ...commandLineSetupDefaults, - }, - ]; +function completedState(overrides: Partial = {}): SetupState { + return makeState({ + status: 'completed', + completedAt: '2026-03-07T00:00:00.000Z', + completionSource: 'user', + yomitanSetupMode: 'internal', + lastSeenYomitanDictionaryCount: 1, + ...overrides, + }); +} - const result = await waitForSetupCompletion({ - readSetupState: () => sequence.shift() ?? null, +const cancelledState = () => makeState({ status: 'cancelled' }); + +/** Deterministic clock: every `now()` advances 100ms and `sleep` returns immediately. */ +function fakeClock() { + let value = 0; + return { sleep: async () => undefined, - now: (() => { - let value = 0; - return () => (value += 100); - })(), + now: () => (value += 100), timeoutMs: 5_000, pollIntervalMs: 100, - }); + }; +} - assert.equal(result, 'completed'); -}); +/** Reader that serves `states` in order, then repeats the last one; counts its reads. */ +function sequenceReader(states: Array) { + const reader = { + reads: 0, + read: (): SetupState | null => states[Math.min(reader.reads++, states.length - 1)] ?? null, + }; + return reader; +} + +for (const c of [ + { name: 'completed', last: completedState(), expected: 'completed' }, + { name: 'cancelled', last: cancelledState(), expected: 'cancelled' }, +] as const) { + test(`waitForSetupCompletion resolves ${c.name} state after polling through earlier states`, async () => { + const reader = sequenceReader([null, makeState(), c.last]); + + const result = await waitForSetupCompletion({ + readSetupState: reader.read, + ...fakeClock(), + }); + + assert.equal(result, c.expected); + assert.equal(reader.reads, 3); + }); +} test('ensureLauncherSetupReady launches setup app and resumes only after completion', async () => { const calls: string[] = []; - let reads = 0; + const reader = sequenceReader([ + null, + makeState(), + completedState({ pluginInstallStatus: 'installed', pluginInstallPathSummary: '/tmp/mpv' }), + ]); const ready = await ensureLauncherSetupReady({ - readSetupState: () => { - reads += 1; - if (reads === 1) return null; - if (reads === 2) { - return { - version: 4, - status: 'in_progress', - completedAt: null, - completionSource: null, - yomitanSetupMode: null, - lastSeenYomitanDictionaryCount: 0, - pluginInstallStatus: 'unknown', - pluginInstallPathSummary: null, - windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, - windowsMpvShortcutLastStatus: 'unknown', - ...commandLineSetupDefaults, - }; - } - return { - version: 4, - status: 'completed', - completedAt: '2026-03-07T00:00:00.000Z', - completionSource: 'user', - yomitanSetupMode: 'internal', - lastSeenYomitanDictionaryCount: 1, - pluginInstallStatus: 'installed', - pluginInstallPathSummary: '/tmp/mpv', - windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, - windowsMpvShortcutLastStatus: 'installed', - ...commandLineSetupDefaults, - }; - }, + readSetupState: reader.read, launchSetupApp: () => { calls.push('launch'); }, - sleep: async () => undefined, - now: (() => { - let value = 0; - return () => (value += 100); - })(), - timeoutMs: 5_000, - pollIntervalMs: 100, + ...fakeClock(), }); assert.equal(ready, true); @@ -116,10 +102,7 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf launchSetupApp: () => { calls.push('launch'); }, - sleep: async () => undefined, - now: () => 0, - timeoutMs: 5_000, - pollIntervalMs: 100, + ...fakeClock(), }); assert.equal(ready, true); @@ -129,76 +112,47 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal', async () => { const calls: string[] = []; let legacyPluginInstalled = true; - let reads = 0; + // The completion timestamp moves forward once the setup app has finished. + const reader = sequenceReader([ + completedState({ yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0 }), + completedState({ yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0 }), + completedState({ + completedAt: '2026-05-12T14:40:00.000Z', + yomitanSetupMode: null, + lastSeenYomitanDictionaryCount: 0, + }), + ]); const ready = await ensureLauncherSetupReady({ - readSetupState: () => { - reads += 1; - return { - version: 4, - status: 'completed', - completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:40:00.000Z', - completionSource: 'user', - yomitanSetupMode: null, - lastSeenYomitanDictionaryCount: 0, - pluginInstallStatus: 'unknown', - pluginInstallPathSummary: null, - windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, - windowsMpvShortcutLastStatus: 'unknown', - ...commandLineSetupDefaults, - }; - }, + readSetupState: reader.read, hasLegacyMpvPlugin: () => legacyPluginInstalled, launchSetupApp: () => { calls.push('launch'); legacyPluginInstalled = false; }, - sleep: async () => undefined, - now: (() => { - let value = 0; - return () => (value += 100); - })(), - timeoutMs: 5_000, - pollIntervalMs: 100, + ...fakeClock(), }); assert.equal(ready, true); assert.deepEqual(calls, ['launch']); - assert.equal(reads >= 3, true); + assert.equal(reader.reads >= 3, true); }); test('ensureLauncherSetupReady lets users continue without removing a legacy mpv plugin', async () => { const calls: string[] = []; - let reads = 0; + const reader = sequenceReader([ + completedState({ lastSeenYomitanDictionaryCount: 2 }), + completedState({ lastSeenYomitanDictionaryCount: 2 }), + completedState({ completedAt: '2026-05-12T14:30:00.000Z', lastSeenYomitanDictionaryCount: 2 }), + ]); const ready = await ensureLauncherSetupReady({ - readSetupState: () => { - reads += 1; - return { - version: 4, - status: 'completed', - completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:30:00.000Z', - completionSource: 'user', - yomitanSetupMode: 'internal', - lastSeenYomitanDictionaryCount: 2, - pluginInstallStatus: 'unknown', - pluginInstallPathSummary: null, - windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, - windowsMpvShortcutLastStatus: 'unknown', - ...commandLineSetupDefaults, - }; - }, + readSetupState: reader.read, hasLegacyMpvPlugin: () => true, launchSetupApp: () => { calls.push('launch'); }, - sleep: async () => undefined, - now: (() => { - let value = 0; - return () => (value += 100); - })(), - timeoutMs: 5_000, - pollIntervalMs: 100, + ...fakeClock(), }); assert.equal(ready, true); @@ -207,90 +161,30 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => { const result = await ensureLauncherSetupReady({ - readSetupState: () => ({ - version: 4, - status: 'cancelled', - completedAt: null, - completionSource: null, - yomitanSetupMode: null, - lastSeenYomitanDictionaryCount: 0, - pluginInstallStatus: 'unknown', - pluginInstallPathSummary: null, - windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, - windowsMpvShortcutLastStatus: 'unknown', - ...commandLineSetupDefaults, - }), + readSetupState: cancelledState, launchSetupApp: () => undefined, - sleep: async () => undefined, - now: (() => { - let value = 0; - return () => (value += 100); - })(), - timeoutMs: 5_000, - pollIntervalMs: 100, + ...fakeClock(), }); assert.equal(result, false); }); test('ensureLauncherSetupReady ignores stale cancelled state after launching setup app', async () => { - let reads = 0; + const reader = sequenceReader([ + cancelledState(), + cancelledState(), + makeState(), + completedState({ + completionSource: 'legacy_auto_detected', + pluginInstallStatus: 'installed', + pluginInstallPathSummary: '/tmp/mpv', + }), + ]); const result = await ensureLauncherSetupReady({ - readSetupState: () => { - reads += 1; - if (reads <= 2) { - return { - version: 4, - status: 'cancelled', - completedAt: null, - completionSource: null, - yomitanSetupMode: null, - lastSeenYomitanDictionaryCount: 0, - pluginInstallStatus: 'unknown', - pluginInstallPathSummary: null, - windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, - windowsMpvShortcutLastStatus: 'unknown', - ...commandLineSetupDefaults, - }; - } - if (reads === 3) { - return { - version: 4, - status: 'in_progress', - completedAt: null, - completionSource: null, - yomitanSetupMode: null, - lastSeenYomitanDictionaryCount: 0, - pluginInstallStatus: 'unknown', - pluginInstallPathSummary: null, - windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, - windowsMpvShortcutLastStatus: 'unknown', - ...commandLineSetupDefaults, - }; - } - return { - version: 4, - status: 'completed', - completedAt: '2026-03-07T00:00:00.000Z', - completionSource: 'legacy_auto_detected', - yomitanSetupMode: 'internal', - lastSeenYomitanDictionaryCount: 1, - pluginInstallStatus: 'installed', - pluginInstallPathSummary: '/tmp/mpv', - windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true }, - windowsMpvShortcutLastStatus: 'unknown', - ...commandLineSetupDefaults, - }; - }, + readSetupState: reader.read, launchSetupApp: () => undefined, - sleep: async () => undefined, - now: (() => { - let value = 0; - return () => (value += 100); - })(), - timeoutMs: 5_000, - pollIntervalMs: 100, + ...fakeClock(), }); assert.equal(result, true); diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts index 718622f8..b695e176 100644 --- a/launcher/smoke.e2e.test.ts +++ b/launcher/smoke.e2e.test.ts @@ -9,7 +9,6 @@ import { createDefaultSetupState, getDefaultConfigDir, getSetupStatePath, - readSetupState, writeSetupState, } from '../src/shared/setup-state.js'; @@ -407,65 +406,65 @@ setInterval(() => {}, 1000); }; } -test('launcher smoke fixture seeds completed setup state', () => { - const smokeCase = createSmokeCase('setup-state'); +/** + * Some sandboxes forbid binding unix sockets (EPERM). The fake mpv needs one, so these tests + * cannot pass there; probe once and skip them instead of weakening every assertion. + */ +function unixSocketsDenied(): boolean { + if (process.platform === 'win32') return false; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sock-probe-')); try { - const configDir = getDefaultConfigDir({ - xdgConfigHome: smokeCase.xdgConfigHome, - homeDir: smokeCase.homeDir, - }); - const statePath = getSetupStatePath(configDir); - - assert.equal(readSetupState(statePath)?.status, 'completed'); + const probe = spawnSync( + process.execPath, + [ + '-e', + `require('node:net').createServer().on('error', (e) => { process.stderr.write(String(e)); process.exit(1); }).listen(${JSON.stringify(path.join(dir, 's.sock'))}, function () { this.close(() => process.exit(0)); });`, + ], + { encoding: 'utf8', timeout: 5000 }, + ); + return probe.status !== 0 && /eperm|operation not permitted/i.test(probe.stderr); } finally { - fs.rmSync(smokeCase.root, { recursive: true, force: true }); - fs.rmSync(smokeCase.socketDir, { recursive: true, force: true }); + fs.rmSync(dir, { recursive: true, force: true }); } -}); +} -test('launcher mpv status returns ready when socket is connectable', async () => { - await withSmokeCase('mpv-status', async (smokeCase) => { - const env = makeTestEnv(smokeCase); - const fakeMpv = spawn(smokeCase.fakeMpvPath, [`--input-ipc-server=${smokeCase.socketPath}`], { - env, - stdio: 'ignore', - }); +const skipWithoutUnixSockets = unixSocketsDenied() ? 'sandbox denies unix socket binding' : false; - try { - await waitForSocketReady(smokeCase.socketPath); - const result = runLauncher( - smokeCase, - ['mpv', 'status', '--log-level', 'debug'], +test( + 'launcher mpv status returns ready when socket is connectable', + { skip: skipWithoutUnixSockets }, + async () => { + await withSmokeCase('mpv-status', async (smokeCase) => { + const env = makeTestEnv(smokeCase); + const fakeMpv = spawn(smokeCase.fakeMpvPath, [`--input-ipc-server=${smokeCase.socketPath}`], { env, - 'mpv-status', - ); - const fakeMpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log')); - const fakeMpvError = fakeMpvEntries.find( - (entry): entry is { error: string } => typeof entry.error === 'string', - )?.error; - const unixSocketDenied = - typeof fakeMpvError === 'string' && /eperm|operation not permitted/i.test(fakeMpvError); + stdio: 'ignore', + }); - if (unixSocketDenied) { - assert.equal(result.status, 1); - assert.match(result.stdout, /socket not ready/i); - } else { + try { + await waitForSocketReady(smokeCase.socketPath); + const result = runLauncher( + smokeCase, + ['mpv', 'status', '--log-level', 'debug'], + env, + 'mpv-status', + ); assert.equal(result.status, 0); assert.match(result.stdout, /socket ready/i); + } finally { + if (fakeMpv.exitCode === null) { + await new Promise((resolve) => { + fakeMpv.once('close', () => resolve()); + }); + } } - } finally { - if (fakeMpv.exitCode === null) { - await new Promise((resolve) => { - fakeMpv.once('close', () => resolve()); - }); - } - } - }); -}); + }); + }, +); test( 'launcher start-overlay run forwards socket/backend and stops owned background app after mpv exits', - { timeout: LONG_SMOKE_TEST_TIMEOUT_MS }, + { timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets }, async () => { await withSmokeCase('overlay-start-stop', async (smokeCase) => { const env = makeTestEnv(smokeCase); @@ -484,13 +483,8 @@ test( const appStartEntries = readJsonLines(appStartPath); const appStopEntries = readJsonLines(appStopPath); const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log')); - const mpvError = mpvEntries.find( - (entry): entry is { error: string } => typeof entry.error === 'string', - )?.error; - const unixSocketDenied = - typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError); - assert.equal(result.status, unixSocketDenied ? 3 : 0); + assert.equal(result.status, 0); assert.match(result.stdout, /Starting SubMiner overlay/i); assert.equal(appStartEntries.length, 1); @@ -523,7 +517,7 @@ test( test( 'launcher start-overlay attaches to a running background app without spawning another app start command', - { timeout: LONG_SMOKE_TEST_TIMEOUT_MS }, + { timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets }, async () => { await withSmokeCase('overlay-borrow-background', async (smokeCase) => { const controlServer = await startFakeControlServer(smokeCase); @@ -550,13 +544,8 @@ test( const appStopEntries = readJsonLines(appStopPath); const controlEntries = readJsonLines(controlServer.logPath); const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log')); - const mpvError = mpvEntries.find( - (entry): entry is { error: string } => typeof entry.error === 'string', - )?.error; - const unixSocketDenied = - typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError); - assert.equal(result.status, unixSocketDenied ? 3 : 0); + assert.equal(result.status, 0); if (process.platform === 'linux') { assert.equal(appEntries.length > 0, true); assert.equal( @@ -583,7 +572,7 @@ test( test( 'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled', - { timeout: LONG_SMOKE_TEST_TIMEOUT_MS }, + { timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets }, async () => { await withSmokeCase('autoplay-ready-gate', async (smokeCase) => { fs.writeFileSync( @@ -607,14 +596,9 @@ test( ); const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log')); - const mpvError = mpvEntries.find( - (entry): entry is { error: string } => typeof entry.error === 'string', - )?.error; - const unixSocketDenied = - typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError); const mpvFirstArgs = mpvEntries[0]?.argv; - assert.equal(result.status, unixSocketDenied ? 3 : 0); + assert.equal(result.status, 0); assert.equal(Array.isArray(mpvFirstArgs), true); assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true); assert.match( diff --git a/launcher/test-support/args.ts b/launcher/test-support/args.ts new file mode 100644 index 00000000..f1d5a8a2 --- /dev/null +++ b/launcher/test-support/args.ts @@ -0,0 +1,7 @@ +import { parseArgs } from '../config.js'; +import type { Args } from '../types.js'; + +/** Default launcher `Args` (as if run with no CLI input), with `overrides` applied. */ +export function makeLauncherArgs(overrides: Partial = {}): Args { + return { ...parseArgs([], 'subminer', {}), ...overrides }; +} diff --git a/launcher/test-support/env.ts b/launcher/test-support/env.ts new file mode 100644 index 00000000..c86fdd77 --- /dev/null +++ b/launcher/test-support/env.ts @@ -0,0 +1,25 @@ +/** + * Runs `run` with the given process env values (`undefined` deletes a key) and restores the + * originals afterwards, even if `run` throws or rejects. + */ +export async function withEnv( + overrides: Record, + run: () => T | Promise, +): Promise { + const originals = Object.fromEntries( + Object.keys(overrides).map((key) => [key, process.env[key]]), + ); + const apply = (values: Record): void => { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; + + apply(overrides); + try { + return await run(); + } finally { + apply(originals); + } +} diff --git a/launcher/test-support/immersion-db-schema.test.ts b/launcher/test-support/immersion-db-schema.test.ts index 22d71d2e..eb59a25a 100644 --- a/launcher/test-support/immersion-db-schema.test.ts +++ b/launcher/test-support/immersion-db-schema.test.ts @@ -5,7 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { Database as BunDatabase } from 'bun:sqlite'; import { ensureSchema } from '../../src/core/services/immersion-tracker/storage.js'; -import { createImmersionDbFixture, insertFixtureSession } from './immersion-db-fixture.js'; +import { createImmersionDbFixture } from './immersion-db-fixture.js'; type SchemaRow = { type: string; name: string; tbl_name: string; sql: string | null }; @@ -108,65 +108,3 @@ test('fixture schema stays aligned with production sync-touched tables and index fs.rmSync(dir, { recursive: true, force: true }); } }); - -test('fixture leaves lexical rollups pending when their table is absent', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-rollup-state-')); - const fixturePath = path.join(dir, 'fixture.sqlite'); - try { - createImmersionDbFixture(fixturePath); - const db = new BunDatabase(fixturePath, { readonly: true }); - try { - const state = db - .query<{ state_value: string }>( - `SELECT state_value FROM imm_rollup_state - WHERE state_key = 'lexical_daily_rollups_version'`, - ) - .get(); - const rollupTable = db - .query<{ name: string }>( - `SELECT name FROM sqlite_schema - WHERE type = 'table' AND name = 'imm_lexical_daily_rollups'`, - ) - .get(); - - assert.equal(state?.state_value, '0'); - assert.equal(rollupTable, null); - } finally { - db.close(); - } - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('fixture session inserts enforce foreign keys', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-foreign-keys-')); - const fixturePath = path.join(dir, 'fixture.sqlite'); - try { - createImmersionDbFixture(fixturePath); - const db = new BunDatabase(fixturePath, { readwrite: true }); - try { - db.run(` - CREATE TRIGGER remove_fixture_video - BEFORE INSERT ON imm_sessions - BEGIN - DELETE FROM imm_videos WHERE video_id = NEW.video_id; - END - `); - } finally { - db.close(); - } - - assert.throws( - () => - insertFixtureSession(fixturePath, { - uuid: 'foreign-key-check', - videoKey: 'foreign-key-check', - startedAtMs: Date.UTC(2026, 6, 9), - }), - /FOREIGN KEY constraint failed/, - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/scripts/build-changelog.test.ts b/scripts/build-changelog.test.ts index ed4db755..77661893 100644 --- a/scripts/build-changelog.test.ts +++ b/scripts/build-changelog.test.ts @@ -14,6 +14,63 @@ function createWorkspace(name: string): string { return fs.mkdtempSync(path.join(baseDir, `${name}-`)); } +interface Project { + /** Workspace parent dir; also handy as an empty directory. */ + workspace: string; + projectRoot: string; + read(relativePath: string): string; + exists(relativePath: string): boolean; + write(relativePath: string, content: string): void; + remove(relativePath: string): void; + /** Absolute path of `relativePath` inside the project. */ + at(relativePath: string): string; +} + +const EMPTY_CHANGELOG = '# Changelog\n'; + +function packageJson(version: string): string { + return JSON.stringify({ name: 'subminer', version }, null, 2); +} + +/** Builds a fragment file body; `extraMeta` lines (e.g. `breaking: true`) follow `area`. */ +function fragment(type: string, area: string, body: string, extraMeta: string[] = []): string { + return [`type: ${type}`, `area: ${area}`, ...extraMeta, '', body].join('\n'); +} + +/** + * Creates a temp project containing `files` (relative path -> content), always with an + * empty `changes/` dir, runs `run`, then removes the workspace. + */ +async function withProject( + name: string, + files: Record, + run: (project: Project) => T | Promise, +): Promise { + const workspace = createWorkspace(name); + const projectRoot = path.join(workspace, 'SubMiner'); + const at = (relativePath: string) => path.join(projectRoot, relativePath); + const write = (relativePath: string, content: string) => { + fs.mkdirSync(path.dirname(at(relativePath)), { recursive: true }); + fs.writeFileSync(at(relativePath), content, 'utf8'); + }; + fs.mkdirSync(at('changes'), { recursive: true }); + for (const [relativePath, content] of Object.entries(files)) write(relativePath, content); + + try { + return await run({ + workspace, + projectRoot, + at, + write, + read: (relativePath) => fs.readFileSync(at(relativePath), 'utf8'), + exists: (relativePath) => fs.existsSync(at(relativePath)), + remove: (relativePath) => fs.rmSync(at(relativePath)), + }); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +} + type RunClaudeArgs = { input: string; args: string[] }; function recordingRunClaude(responder: (input: string) => string): { @@ -44,14 +101,6 @@ function fragmentTypesInPrompt(input: string): string[] { .map((line) => line.slice('type: '.length).trim()); } -function assertPromptRequestsConciseItems(input: string): void { - assert.match(input, /Default to inline/); - assert.match(input, /Use nested bullets only when an item has several distinct changes/); - assert.match(input, /Never write paragraph-length bullets/); - assert.match(input, /Merge aggressively: one item per feature or area/); - assert.match(input, /Release notes summarize; the docs hold the details/); -} - function defaultPolishedBody(input: string): string { const mode = modeFromPrompt(input); const types = fragmentTypesInPrompt(input); @@ -91,27 +140,23 @@ function defaultStubClaude() { return recordingRunClaude(defaultPolishedBody); } -test('resolveChangelogOutputPaths stays repo-local and never writes docs paths', async () => { - const { resolveChangelogOutputPaths } = await loadModule(); - const workspace = createWorkspace('with-docs-repo'); - const projectRoot = path.join(workspace, 'SubMiner'); +const PRERELEASE_BANNER = + '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.'; - fs.mkdirSync(projectRoot, { recursive: true }); +const INSTALLATION_SECTION = [ + '## Installation', + '', + 'See the README and docs/installation guide for full setup steps.', + '', +]; - try { - const outputPaths = resolveChangelogOutputPaths({ cwd: projectRoot }); - - assert.deepEqual(outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]); - assert.equal(outputPaths.includes(path.join(projectRoot, 'docs', 'changelog.md')), false); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } -}); +/** Fixture of a previous prerelease-notes.md: banner, optional marker, then `bodyLines`. */ +function existingPrereleaseNotes(markerLine: string | null, bodyLines: string[]): string { + return [PRERELEASE_BANNER, '', ...(markerLine ? [markerLine, ''] : []), ...bodyLines].join('\n'); +} test('writeChangelogArtifacts ignores README, groups fragments by type, writes release notes, and deletes only fragment files', async () => { const { writeChangelogArtifacts } = await loadModule(); - const workspace = createWorkspace('write-artifacts'); - const projectRoot = path.join(workspace, 'SubMiner'); const existingChangelog = [ '# Changelog', '', @@ -120,76 +165,55 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r '', ].join('\n'); - fs.mkdirSync(projectRoot, { recursive: true }); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', 'README.md'), - '# Changelog Fragments\n\nIgnored helper text.\n', - 'utf8', + await withProject( + 'write-artifacts', + { + 'CHANGELOG.md': existingChangelog, + 'changes/README.md': '# Changelog Fragments\n\nIgnored helper text.\n', + 'changes/001.md': fragment('added', 'overlay', '- Added release fragments.'), + 'changes/002.md': fragment('fixed', 'release', 'Fixed release notes generation.'), + }, + (project) => { + const stub = defaultStubClaude(); + const result = writeChangelogArtifacts({ + cwd: project.projectRoot, + version: '0.4.1', + date: '2026-03-07', + deps: { runClaude: stub.runClaude }, + }); + + assert.deepEqual(result.outputPaths, [project.at('CHANGELOG.md')]); + assert.deepEqual(result.deletedFragmentPaths, [ + project.at('changes/001.md'), + project.at('changes/002.md'), + ]); + assert.equal(project.exists('changes/001.md'), false); + assert.equal(project.exists('changes/002.md'), false); + assert.equal(project.exists('changes/README.md'), true); + + assert.deepEqual( + stub.calls.map((call) => modeFromPrompt(call.input)), + ['changelog', 'release-notes'], + 'expected one Claude call per output (changelog + release notes)', + ); + + const changelog = project.read('CHANGELOG.md'); + assert.match(changelog, /^# Changelog\n\n## v0\.4\.1 \(2026-03-07\)\n\n/); + assert.match(changelog, /### Added\n- Polished: added entry\./); + assert.match(changelog, /### Fixed\n- Polished: fixed entry\./); + assert.match(changelog, /## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/); + + const releaseNotes = project.read('release/release-notes.md'); + assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./); + assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./); + assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/); + assert.match(releaseNotes, /- Windows: `SubMiner-\*\.exe` and `SubMiner-\*-win\.zip`/); + }, ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: added', 'area: overlay', '', '- Added release fragments.'].join('\n'), - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '002.md'), - ['type: fixed', 'area: release', '', 'Fixed release notes generation.'].join('\n'), - 'utf8', - ); - - try { - const stub = defaultStubClaude(); - const result = writeChangelogArtifacts({ - cwd: projectRoot, - version: '0.4.1', - date: '2026-03-07', - deps: { runClaude: stub.runClaude }, - }); - - assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]); - assert.deepEqual(result.deletedFragmentPaths, [ - path.join(projectRoot, 'changes', '001.md'), - path.join(projectRoot, 'changes', '002.md'), - ]); - assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), false); - assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), false); - assert.equal(fs.existsSync(path.join(projectRoot, 'changes', 'README.md')), true); - - assert.equal( - stub.calls.length, - 2, - 'expected one Claude call per output (changelog + release notes)', - ); - assert.deepEqual( - stub.calls.map((call) => modeFromPrompt(call.input)), - ['changelog', 'release-notes'], - ); - - const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8'); - assert.match(changelog, /^# Changelog\n\n## v0\.4\.1 \(2026-03-07\)\n\n/); - assert.match(changelog, /### Added\n- Polished: added entry\./); - assert.match(changelog, /### Fixed\n- Polished: fixed entry\./); - assert.match(changelog, /## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/); - - const releaseNotes = fs.readFileSync( - path.join(projectRoot, 'release', 'release-notes.md'), - 'utf8', - ); - assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./); - assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./); - assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/); - assert.match(releaseNotes, /- Windows: `SubMiner-\*\.exe` and `SubMiner-\*-win\.zip`/); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('writeChangelogArtifacts skips changelog prepend when release section already exists', async () => { const { writeChangelogArtifacts } = await loadModule(); - const workspace = createWorkspace('write-artifacts-existing-version'); - const projectRoot = path.join(workspace, 'SubMiner'); const existingChangelog = [ '# Changelog', '', @@ -199,285 +223,160 @@ test('writeChangelogArtifacts skips changelog prepend when release section alrea '', ].join('\n'); - fs.mkdirSync(projectRoot, { recursive: true }); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: added', 'area: overlay', '', '- Stale release fragment.'].join('\n'), - 'utf8', + await withProject( + 'write-artifacts-existing-version', + { + 'CHANGELOG.md': existingChangelog, + 'changes/001.md': fragment('added', 'overlay', '- Stale release fragment.'), + }, + (project) => { + const result = writeChangelogArtifacts({ + cwd: project.projectRoot, + version: '0.4.1', + date: '2026-03-08', + }); + + assert.deepEqual(result.deletedFragmentPaths, [project.at('changes/001.md')]); + assert.equal(project.exists('changes/001.md'), false); + assert.equal(project.read('CHANGELOG.md'), existingChangelog); + assert.match( + project.read('release/release-notes.md'), + /## Highlights\n### Added\n- Existing release bullet\./, + ); + }, ); - - try { - const result = writeChangelogArtifacts({ - cwd: projectRoot, - version: '0.4.1', - date: '2026-03-08', - }); - - assert.deepEqual(result.deletedFragmentPaths, [path.join(projectRoot, 'changes', '001.md')]); - assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), false); - - const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8'); - assert.equal(changelog, existingChangelog); - const releaseNotes = fs.readFileSync( - path.join(projectRoot, 'release', 'release-notes.md'), - 'utf8', - ); - assert.match(releaseNotes, /## Highlights\n### Added\n- Existing release bullet\./); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('writeStableReleaseArtifacts reuses the requested version and date for changelog, release notes, and docs-site output', async () => { const { writeStableReleaseArtifacts } = await loadModule(); - const workspace = createWorkspace('write-stable-release-artifacts'); - const projectRoot = path.join(workspace, 'SubMiner'); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.mkdirSync(path.join(projectRoot, 'docs-site'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.4.1' }, null, 2), - 'utf8', + await withProject( + 'write-stable-release-artifacts', + { + 'docs-site/.keep': '', + 'package.json': packageJson('0.4.1'), + 'CHANGELOG.md': EMPTY_CHANGELOG, + 'changes/001.md': fragment('fixed', 'release', '- Reused explicit stable release date.'), + }, + (project) => { + const stub = defaultStubClaude(); + const result = writeStableReleaseArtifacts({ + cwd: project.projectRoot, + version: '0.4.1', + date: '2026-03-07', + deps: { runClaude: stub.runClaude }, + }); + + assert.deepEqual(result.outputPaths, [project.at('CHANGELOG.md')]); + assert.equal(result.releaseNotesPath, project.at('release/release-notes.md')); + assert.equal(result.docsChangelogPath, project.at('docs-site/changelog.md')); + + assert.match(project.read('CHANGELOG.md'), /## v0\.4\.1 \(2026-03-07\)/); + assert.match(project.read('docs-site/changelog.md'), /## v0\.4\.1 \(2026-03-07\)/); + }, ); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: fixed', 'area: release', '', '- Reused explicit stable release date.'].join('\n'), - 'utf8', - ); - - try { - const stub = defaultStubClaude(); - const result = writeStableReleaseArtifacts({ - cwd: projectRoot, - version: '0.4.1', - date: '2026-03-07', - deps: { runClaude: stub.runClaude }, - }); - - assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]); - assert.equal(result.releaseNotesPath, path.join(projectRoot, 'release', 'release-notes.md')); - assert.equal(result.docsChangelogPath, path.join(projectRoot, 'docs-site', 'changelog.md')); - - const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8'); - const docsChangelog = fs.readFileSync( - path.join(projectRoot, 'docs-site', 'changelog.md'), - 'utf8', - ); - - assert.match(changelog, /## v0\.4\.1 \(2026-03-07\)/); - assert.match(docsChangelog, /## v0\.4\.1 \(2026-03-07\)/); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('verifyChangelogReadyForRelease ignores README but rejects pending fragments and missing version sections', async () => { const { verifyChangelogReadyForRelease } = await loadModule(); - const workspace = createWorkspace('verify-release'); - const projectRoot = path.join(workspace, 'SubMiner'); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', 'README.md'), - '# Changelog Fragments\n', - 'utf8', + await withProject( + 'verify-release', + { + 'CHANGELOG.md': EMPTY_CHANGELOG, + 'changes/README.md': '# Changelog Fragments\n', + 'changes/001.md': '- Pending fragment.\n', + }, + (project) => { + assert.throws( + () => verifyChangelogReadyForRelease({ cwd: project.projectRoot, version: '0.4.1' }), + /Pending changelog fragments/, + ); + + project.remove('changes/001.md'); + + assert.throws( + () => verifyChangelogReadyForRelease({ cwd: project.projectRoot, version: '0.4.1' }), + /Missing CHANGELOG section for v0\.4\.1/, + ); + }, ); - fs.writeFileSync(path.join(projectRoot, 'changes', '001.md'), '- Pending fragment.\n', 'utf8'); - - try { - assert.throws( - () => verifyChangelogReadyForRelease({ cwd: projectRoot, version: '0.4.1' }), - /Pending changelog fragments/, - ); - - fs.rmSync(path.join(projectRoot, 'changes', '001.md')); - - assert.throws( - () => verifyChangelogReadyForRelease({ cwd: projectRoot, version: '0.4.1' }), - /Missing CHANGELOG section for v0\.4\.1/, - ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('verifyChangelogReadyForRelease rejects explicit release versions that do not match package.json', async () => { const { verifyChangelogReadyForRelease } = await loadModule(); - const workspace = createWorkspace('verify-release-version-match'); - const projectRoot = path.join(workspace, 'SubMiner'); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.4.0' }, null, 2), - 'utf8', + await withProject( + 'verify-release-version-match', + { + 'package.json': packageJson('0.4.0'), + 'CHANGELOG.md': '# Changelog\n\n## v0.4.1 (2026-03-09)\n- Ready.\n', + }, + (project) => { + assert.throws( + () => verifyChangelogReadyForRelease({ cwd: project.projectRoot, version: '0.4.1' }), + /package\.json version \(0\.4\.0\) does not match requested release version \(0\.4\.1\)/, + ); + }, ); - fs.writeFileSync( - path.join(projectRoot, 'CHANGELOG.md'), - '# Changelog\n\n## v0.4.1 (2026-03-09)\n- Ready.\n', - 'utf8', - ); - - try { - assert.throws( - () => verifyChangelogReadyForRelease({ cwd: projectRoot, version: '0.4.1' }), - /package\.json version \(0\.4\.0\) does not match requested release version \(0\.4\.1\)/, - ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('writeChangelogArtifacts renders breaking changes section above type sections', async () => { const { writeChangelogArtifacts } = await loadModule(); - const workspace = createWorkspace('breaking-changes'); - const projectRoot = path.join(workspace, 'SubMiner'); - fs.mkdirSync(projectRoot, { recursive: true }); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: changed', 'area: config', 'breaking: true', '', '- Renamed `foo` to `bar`.'].join('\n'), - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '002.md'), - ['type: fixed', 'area: overlay', '', '- Fixed subtitle rendering.'].join('\n'), - 'utf8', - ); + await withProject( + 'breaking-changes', + { + 'CHANGELOG.md': EMPTY_CHANGELOG, + 'changes/001.md': fragment('changed', 'config', '- Renamed `foo` to `bar`.', [ + 'breaking: true', + ]), + 'changes/002.md': fragment('fixed', 'overlay', '- Fixed subtitle rendering.'), + }, + (project) => { + const stub = defaultStubClaude(); + writeChangelogArtifacts({ + cwd: project.projectRoot, + version: '0.5.0', + date: '2026-04-06', + deps: { runClaude: stub.runClaude }, + }); - try { - const stub = defaultStubClaude(); - writeChangelogArtifacts({ - cwd: projectRoot, - version: '0.5.0', - date: '2026-04-06', - deps: { runClaude: stub.runClaude }, - }); + const changelog = project.read('CHANGELOG.md'); + const breakingIndex = changelog.indexOf('### Breaking Changes'); + const changedIndex = changelog.indexOf('### Changed'); + const fixedIndex = changelog.indexOf('### Fixed'); - const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8'); - const breakingIndex = changelog.indexOf('### Breaking Changes'); - const changedIndex = changelog.indexOf('### Changed'); - const fixedIndex = changelog.indexOf('### Fixed'); + assert.notEqual(breakingIndex, -1, 'Breaking Changes section should exist'); + assert.notEqual(changedIndex, -1, 'Changed section should exist'); + assert.notEqual(fixedIndex, -1, 'Fixed section should exist'); + assert.ok(breakingIndex < changedIndex, 'Breaking Changes should appear before Changed'); + assert.ok(changedIndex < fixedIndex, 'Changed should appear before Fixed'); - assert.notEqual(breakingIndex, -1, 'Breaking Changes section should exist'); - assert.notEqual(changedIndex, -1, 'Changed section should exist'); - assert.notEqual(fixedIndex, -1, 'Fixed section should exist'); - assert.ok(breakingIndex < changedIndex, 'Breaking Changes should appear before Changed'); - assert.ok(changedIndex < fixedIndex, 'Changed should appear before Fixed'); - - const changelogCall = stub.calls.find((call) => modeFromPrompt(call.input) === 'changelog'); - assert.ok(changelogCall, 'expected at least one changelog-mode Claude invocation'); - assert.match( - changelogCall.input, - /breaking: true/, - 'breaking metadata should reach the prompt verbatim', - ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } -}); - -test('writeChangelogArtifacts prompts Claude to summarize the final stable outcome instead of prerelease churn', async () => { - const { writeChangelogArtifacts } = await loadModule(); - const workspace = createWorkspace('stable-outcome-prompt'); - const projectRoot = path.join(workspace, 'SubMiner'); - - fs.mkdirSync(projectRoot, { recursive: true }); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - [ - 'type: added', - 'area: config', - '', - '- Added a dedicated Config window with launcher entry points.', - ].join('\n'), - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '002.md'), - [ - 'type: changed', - 'area: config', - 'breaking: true', - '', - '- Renamed the Config window to Settings window and changed the launcher entry point to `subminer settings`.', - ].join('\n'), - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '003.md'), - [ - 'type: fixed', - 'area: config', - '', - '- Fixed Settings window search and live subtitle CSS saves.', - ].join('\n'), - 'utf8', - ); - - try { - const stub = defaultStubClaude(); - writeChangelogArtifacts({ - cwd: projectRoot, - version: '0.12.0', - date: '2026-05-24', - deps: { runClaude: stub.runClaude }, - }); - - const prompts = stub.calls.map((call) => call.input); - assert.equal(prompts.length, 2, 'expected changelog and release-notes prompts'); - for (const prompt of prompts) { - assert.match(prompt, /Treat the fragment list as one cumulative release outcome/); + const changelogCall = stub.calls.find((call) => modeFromPrompt(call.input) === 'changelog'); + assert.ok(changelogCall, 'expected at least one changelog-mode Claude invocation'); assert.match( - prompt, - /only if the final release requires action from users upgrading from the previous stable release/, + changelogCall.input, + /breaking: true/, + 'breaking metadata should reach the prompt verbatim', ); - assert.match(prompt, /Config window.*Settings window/s); - assert.match( - prompt, - /Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/, - ); - assertPromptRequestsConciseItems(prompt); - } - - const releaseNotesPrompt = stub.calls.find( - (call) => modeFromPrompt(call.input) === 'release-notes', - ); - assert.ok(releaseNotesPrompt, 'expected a release-notes Claude invocation'); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } + }, + ); }); test('verifyChangelogFragments rejects invalid metadata', async () => { const { verifyChangelogFragments } = await loadModule(); - const workspace = createWorkspace('lint-invalid'); - const projectRoot = path.join(workspace, 'SubMiner'); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: nope', 'area: overlay', '', '- Invalid type.'].join('\n'), - 'utf8', + await withProject( + 'lint-invalid', + { 'changes/001.md': fragment('nope', 'overlay', '- Invalid type.') }, + (project) => { + assert.throws( + () => verifyChangelogFragments({ cwd: project.projectRoot }), + /must declare type as one of/, + ); + }, ); - - try { - assert.throws( - () => verifyChangelogFragments({ cwd: projectRoot }), - /must declare type as one of/, - ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('verifyPullRequestChangelog requires fragments for user-facing changes and skips docs-only changes', async () => { @@ -552,405 +451,253 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => { const { writePrereleaseNotesForVersion } = await loadModule(); - const workspace = createWorkspace('prerelease-beta-notes'); - const projectRoot = path.join(workspace, 'SubMiner'); - const changelogPath = path.join(projectRoot, 'CHANGELOG.md'); - const docsChangelogPath = path.join(projectRoot, 'docs-site', 'changelog.md'); const existingChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable release.\n'; const existingDocsChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable docs release.\n'; - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.mkdirSync(path.join(projectRoot, 'docs-site'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2), - 'utf8', + await withProject( + 'prerelease-beta-notes', + { + 'package.json': packageJson('0.11.3-beta.1'), + 'CHANGELOG.md': existingChangelog, + 'docs-site/changelog.md': existingDocsChangelog, + 'changes/001.md': fragment('added', 'overlay', '- Added prerelease coverage.'), + 'changes/002.md': fragment('fixed', 'launcher', '- Fixed prerelease packaging checks.'), + }, + (project) => { + const stub = defaultStubClaude(); + const outputPath = writePrereleaseNotesForVersion({ + cwd: project.projectRoot, + version: '0.11.3-beta.1', + deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] }, + }); + + assert.equal(outputPath, project.at('release/prerelease-notes.md')); + assert.equal( + project.read('CHANGELOG.md'), + existingChangelog, + 'stable CHANGELOG.md should remain unchanged', + ); + assert.equal( + project.read('docs-site/changelog.md'), + existingDocsChangelog, + 'docs-site changelog should remain unchanged', + ); + assert.equal(project.exists('changes/001.md'), true); + assert.equal(project.exists('changes/002.md'), true); + + assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call'); + assert.equal(modeFromPrompt(stub.calls[0]!.input), 'release-notes'); + + const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); + assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m); + assert.match(prereleaseNotes, //); + assert.doesNotMatch(prereleaseNotes, /## Changes since /); + assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./); + assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./); + assert.match( + prereleaseNotes, + /## Installation\n\nSee the README and docs\/installation guide/, + ); + assert.match(prereleaseNotes, /Windows `subminer\.cmd` launcher/); + assert.match( + prereleaseNotes, + /Both launcher downloads use Bun included with the SubMiner app/, + ); + assert.match(prereleaseNotes, /Bun corresponding source: `bun-v1\.3\.5-source\.tar\.gz`/); + assert.match(prereleaseNotes, /statically links JavaScriptCore \(LGPL 2\.0\)/); + }, ); - fs.writeFileSync(changelogPath, existingChangelog, 'utf8'); - fs.writeFileSync(docsChangelogPath, existingDocsChangelog, 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: added', 'area: overlay', '', '- Added prerelease coverage.'].join('\n'), - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '002.md'), - ['type: fixed', 'area: launcher', '', '- Fixed prerelease packaging checks.'].join('\n'), - 'utf8', - ); - - try { - const stub = defaultStubClaude(); - const outputPath = writePrereleaseNotesForVersion({ - cwd: projectRoot, - version: '0.11.3-beta.1', - deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] }, - }); - - assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md')); - assert.equal( - fs.readFileSync(changelogPath, 'utf8'), - existingChangelog, - 'stable CHANGELOG.md should remain unchanged', - ); - assert.equal( - fs.readFileSync(docsChangelogPath, 'utf8'), - existingDocsChangelog, - 'docs-site changelog should remain unchanged', - ); - assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), true); - assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), true); - - assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call'); - assert.equal(modeFromPrompt(stub.calls[0]!.input), 'release-notes'); - - const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); - assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m); - assert.match(prereleaseNotes, //); - assert.doesNotMatch(prereleaseNotes, /## Changes since /); - assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./); - assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./); - assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/); - assert.match(prereleaseNotes, /Windows `subminer\.cmd` launcher/); - assert.match(prereleaseNotes, /Both launcher downloads use Bun included with the SubMiner app/); - assert.match(prereleaseNotes, /Bun corresponding source: `bun-v1\.3\.5-source\.tar\.gz`/); - assert.match(prereleaseNotes, /statically links JavaScriptCore \(LGPL 2\.0\)/); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('writePrereleaseNotesForVersion reuses existing prerelease notes when adding new fragments', async () => { const { writePrereleaseNotesForVersion } = await loadModule(); - const workspace = createWorkspace('prerelease-reuse-existing-notes'); - const projectRoot = path.join(workspace, 'SubMiner'); - const existingNotes = [ - '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', - '', - '', - '', + const existingNotes = existingPrereleaseNotes('', [ '## Highlights', '### Added', '- Overlay: Previous beta entry.', '', - '## Installation', - '', - 'See the README and docs/installation guide for full setup steps.', - '', + ...INSTALLATION_SECTION, '## Assets', '', '- Linux: `SubMiner.AppImage`', '', - ].join('\n'); + ]); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.11.3-beta.2' }, null, 2), - 'utf8', + await withProject( + 'prerelease-reuse-existing-notes', + { + 'package.json': packageJson('0.11.3-beta.2'), + 'release/prerelease-notes.md': existingNotes, + 'changes/001.md': fragment('fixed', 'launcher', '- Fixed launcher prerelease packaging.'), + }, + (project) => { + const stub = recordingRunClaude((input) => { + if (!input.includes('Overlay: Previous beta entry.')) { + return '### Fixed\n- Launcher: Added only the latest fix.'; + } + return [ + '### Added', + '- Overlay: Previous beta entry.', + '', + '### Fixed', + '- Launcher: Added only the latest fix.', + ].join('\n'); + }); + + const outputPath = writePrereleaseNotesForVersion({ + cwd: project.projectRoot, + version: '0.11.3-beta.2', + deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] }, + }); + + assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call'); + assert.match(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/); + + const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); + assert.match(prereleaseNotes, /- Overlay: Previous beta entry\./); + assert.match(prereleaseNotes, /- Launcher: Added only the latest fix\./); + }, ); - fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: fixed', 'area: launcher', '', '- Fixed launcher prerelease packaging.'].join('\n'), - 'utf8', - ); - - try { - const stub = recordingRunClaude((input) => { - if (!input.includes('Overlay: Previous beta entry.')) { - return '### Fixed\n- Launcher: Added only the latest fix.'; - } - return [ - '### Added', - '- Overlay: Previous beta entry.', - '', - '### Fixed', - '- Launcher: Added only the latest fix.', - ].join('\n'); - }); - - const outputPath = writePrereleaseNotesForVersion({ - cwd: projectRoot, - version: '0.11.3-beta.2', - deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] }, - }); - - assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call'); - assert.match(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/); - - const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); - assert.match(prereleaseNotes, /- Overlay: Previous beta entry\./); - assert.match(prereleaseNotes, /- Launcher: Added only the latest fix\./); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('writePrereleaseNotesForVersion ignores unmarked prerelease notes from an older release line', async () => { const { writePrereleaseNotesForVersion } = await loadModule(); - const workspace = createWorkspace('prerelease-ignore-unmarked-old-notes'); - const projectRoot = path.join(workspace, 'SubMiner'); - const existingNotes = [ - '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', - '', + const existingNotes = existingPrereleaseNotes(null, [ '## Highlights', '### Added', '- Settings Window: Previous release line entry.', '', - '## Installation', - '', - 'See the README and docs/installation guide for full setup steps.', - '', - ].join('\n'); + ...INSTALLATION_SECTION, + ]); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.17.0-beta.1' }, null, 2), - 'utf8', + await withProject( + 'prerelease-ignore-unmarked-old-notes', + { + 'package.json': packageJson('0.17.0-beta.1'), + 'release/prerelease-notes.md': existingNotes, + 'changes/001.md': fragment( + 'changed', + 'overlay', + '- Replaced subtitle delay actions with native mpv keybindings.', + ), + }, + (project) => { + const stub = defaultStubClaude(); + const outputPath = writePrereleaseNotesForVersion({ + cwd: project.projectRoot, + version: '0.17.0-beta.1', + deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] }, + }); + + assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call'); + assert.doesNotMatch(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/); + assert.doesNotMatch(stub.calls[0]!.input, /Settings Window: Previous release line entry/); + + const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); + assert.match(prereleaseNotes, /### Changed\n- Polished: changed entry\./); + }, ); - fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - [ - 'type: changed', - 'area: overlay', - '', - '- Replaced subtitle delay actions with native mpv keybindings.', - ].join('\n'), - 'utf8', - ); - - try { - const stub = defaultStubClaude(); - const outputPath = writePrereleaseNotesForVersion({ - cwd: projectRoot, - version: '0.17.0-beta.1', - deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] }, - }); - - assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call'); - assert.doesNotMatch(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/); - assert.doesNotMatch(stub.calls[0]!.input, /Settings Window: Previous release line entry/); - - const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); - assert.match(prereleaseNotes, /### Changed\n- Polished: changed entry\./); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } -}); - -test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease bullets instead of appending fix churn', async () => { - const { writePrereleaseNotesForVersion } = await loadModule(); - const workspace = createWorkspace('prerelease-net-outcome-prompt'); - const projectRoot = path.join(workspace, 'SubMiner'); - const existingNotes = [ - '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', - '', - '', - '', - '## Highlights', - '### Added', - '- Config Window: Previous beta entry.', - '', - '## Installation', - '', - 'See the README and docs/installation guide for full setup steps.', - '', - '## Assets', - '', - '- Linux: `SubMiner.AppImage`', - '', - ].join('\n'); - - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2), - 'utf8', - ); - fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - [ - 'type: changed', - 'area: config', - 'breaking: true', - '', - '- Renamed the Config window to Settings window.', - ].join('\n'), - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '002.md'), - ['type: fixed', 'area: config', '', '- Fixed Settings window search.'].join('\n'), - 'utf8', - ); - - try { - const stub = recordingRunClaude(() => '### Added\n- Settings Window: Current beta state.'); - writePrereleaseNotesForVersion({ - cwd: projectRoot, - version: '0.12.0-beta.2', - deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] }, - }); - - assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call'); - const prompt = stub.calls[0]!.input; - assert.match(prompt, /EXISTING PRERELEASE NOTES/); - assert.match(prompt, /Existing prerelease notes are a baseline, not an immutable changelog/); - assert.match(prompt, /replace stale beta or RC wording/); - assert.match( - prompt, - /Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/, - ); - assert.match( - prompt, - /shorten long baseline bullets and collapse nested lists that do not need to be nested/, - ); - assertPromptRequestsConciseItems(prompt); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('writePrereleaseNotesForVersion supports rc prereleases', async () => { const { writePrereleaseNotesForVersion } = await loadModule(); - const workspace = createWorkspace('prerelease-rc-notes'); - const projectRoot = path.join(workspace, 'SubMiner'); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.11.3-rc.1' }, null, 2), - 'utf8', + await withProject( + 'prerelease-rc-notes', + { + 'package.json': packageJson('0.11.3-rc.1'), + 'changes/001.md': fragment('changed', 'release', '- Prepared release candidate notes.'), + }, + (project) => { + const stub = defaultStubClaude(); + const outputPath = writePrereleaseNotesForVersion({ + cwd: project.projectRoot, + version: '0.11.3-rc.1', + deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] }, + }); + + assert.match( + fs.readFileSync(outputPath, 'utf8'), + /## Highlights\n### Changed\n- Polished: changed entry\./, + ); + }, ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: changed', 'area: release', '', '- Prepared release candidate notes.'].join('\n'), - 'utf8', - ); - - try { - const stub = defaultStubClaude(); - const outputPath = writePrereleaseNotesForVersion({ - cwd: projectRoot, - version: '0.11.3-rc.1', - deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] }, - }); - - const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); - assert.match(prereleaseNotes, /## Highlights\n### Changed\n- Polished: changed entry\./); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); -test('writePrereleaseNotesForVersion rejects unsupported prerelease identifiers', async () => { - const { writePrereleaseNotesForVersion } = await loadModule(); - const workspace = createWorkspace('prerelease-alpha-reject'); - const projectRoot = path.join(workspace, 'SubMiner'); - - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.11.3-alpha.1' }, null, 2), - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: added', 'area: overlay', '', '- Unsupported alpha prerelease.'].join('\n'), - 'utf8', - ); - - try { - assert.throws( - () => - writePrereleaseNotesForVersion({ - cwd: projectRoot, - version: '0.11.3-alpha.1', - }), - /Unsupported prerelease version \(0\.11\.3-alpha\.1\)/, - ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } -}); - -test('writePrereleaseNotesForVersion rejects mismatched package versions', async () => { - const { writePrereleaseNotesForVersion } = await loadModule(); - const workspace = createWorkspace('prerelease-version-mismatch'); - const projectRoot = path.join(workspace, 'SubMiner'); - - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2), - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: added', 'area: overlay', '', '- Mismatched prerelease.'].join('\n'), - 'utf8', - ); - - try { - assert.throws( - () => - writePrereleaseNotesForVersion({ - cwd: projectRoot, - version: '0.11.3-beta.2', - }), +const prereleaseRejectionCases = [ + { + name: 'rejects unsupported prerelease identifiers', + packageVersion: '0.11.3-alpha.1', + requestedVersion: '0.11.3-alpha.1', + fragments: { + 'changes/001.md': fragment('added', 'overlay', '- Unsupported alpha prerelease.'), + }, + error: /Unsupported prerelease version \(0\.11\.3-alpha\.1\)/, + }, + { + name: 'rejects mismatched package versions', + packageVersion: '0.11.3-beta.1', + requestedVersion: '0.11.3-beta.2', + fragments: { 'changes/001.md': fragment('added', 'overlay', '- Mismatched prerelease.') }, + error: /package\.json version \(0\.11\.3-beta\.1\) does not match requested release version \(0\.11\.3-beta\.2\)/, + }, + { + name: 'rejects empty prerelease note generation when no fragments exist', + packageVersion: '0.11.3-beta.1', + requestedVersion: '0.11.3-beta.1', + fragments: {}, + error: /No changelog fragments found in changes\//, + }, +]; + +for (const c of prereleaseRejectionCases) { + test(`writePrereleaseNotesForVersion ${c.name}`, async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + + await withProject( + 'prerelease-reject', + { 'package.json': packageJson(c.packageVersion), ...c.fragments }, + (project) => { + assert.throws( + () => + writePrereleaseNotesForVersion({ + cwd: project.projectRoot, + version: c.requestedVersion, + }), + c.error, + ); + }, ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } -}); + }); +} -test('writePrereleaseNotesForVersion rejects empty prerelease note generation when no fragments exist', async () => { - const { writePrereleaseNotesForVersion } = await loadModule(); - const workspace = createWorkspace('prerelease-no-fragments'); - const projectRoot = path.join(workspace, 'SubMiner'); +/** Project with one valid fragment and a fixed stable-release call, for Claude failure modes. */ +async function expectChangelogFailure( + name: string, + files: Record, + runClaude: (input: string, args: string[]) => string, + error: RegExp, +): Promise { + const { writeChangelogArtifacts } = await loadModule(); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2), - 'utf8', - ); - - try { + await withProject(name, { 'CHANGELOG.md': EMPTY_CHANGELOG, ...files }, (project) => { assert.throws( () => - writePrereleaseNotesForVersion({ - cwd: projectRoot, - version: '0.11.3-beta.1', + writeChangelogArtifacts({ + cwd: project.projectRoot, + version: '0.5.0', + date: '2026-04-06', + deps: { runClaude }, }), - /No changelog fragments found in changes\//, + error, ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } -}); + }); +} + +const aChange = { 'changes/001.md': fragment('added', 'overlay', '- A change.') }; test('writeChangelogArtifacts surfaces a clear error when claude is missing from PATH', async () => { - const { writeChangelogArtifacts } = await loadModule(); - const workspace = createWorkspace('claude-missing'); - const projectRoot = path.join(workspace, 'SubMiner'); - - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: added', 'area: overlay', '', '- A change.'].join('\n'), - 'utf8', - ); - // The production defaultRunClaude wrapper translates ENOENT into this friendly // message; we simulate that contract here so the test exercises the propagation // path through polishFragmentsWithClaude rather than re-implementing the @@ -961,98 +708,28 @@ test('writeChangelogArtifacts surfaces a clear error when claude is missing from ); }; - try { - assert.throws( - () => - writeChangelogArtifacts({ - cwd: projectRoot, - version: '0.5.0', - date: '2026-04-06', - deps: { runClaude: enoent }, - }), - /claude CLI not found on PATH/, - ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } + await expectChangelogFailure('claude-missing', aChange, enoent, /claude CLI not found on PATH/); }); test('writeChangelogArtifacts rejects empty claude output', async () => { - const { writeChangelogArtifacts } = await loadModule(); - const workspace = createWorkspace('claude-empty'); - const projectRoot = path.join(workspace, 'SubMiner'); - - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: added', 'area: overlay', '', '- A change.'].join('\n'), - 'utf8', + await expectChangelogFailure( + 'claude-empty', + aChange, + () => ' \n ', + /claude returned empty output/, ); - - try { - assert.throws( - () => - writeChangelogArtifacts({ - cwd: projectRoot, - version: '0.5.0', - date: '2026-04-06', - deps: { runClaude: () => ' \n ' }, - }), - /claude returned empty output/, - ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('writeChangelogArtifacts rejects claude output missing required section headers', async () => { - const { writeChangelogArtifacts } = await loadModule(); - const workspace = createWorkspace('claude-no-headers'); - const projectRoot = path.join(workspace, 'SubMiner'); - - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: added', 'area: overlay', '', '- A change.'].join('\n'), - 'utf8', + await expectChangelogFailure( + 'claude-no-headers', + aChange, + () => 'Sure, here is your changelog: it is great.', + /missing the expected section heading/, ); - - try { - assert.throws( - () => - writeChangelogArtifacts({ - cwd: projectRoot, - version: '0.5.0', - date: '2026-04-06', - deps: { runClaude: () => 'Sure, here is your changelog: it is great.' }, - }), - /missing the expected section heading/, - ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('writeChangelogArtifacts rejects changelog-mode output that omits the Internal
wrapper when internal fragments are present', async () => { - const { writeChangelogArtifacts } = await loadModule(); - const workspace = createWorkspace('claude-no-details'); - const projectRoot = path.join(workspace, 'SubMiner'); - - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: added', 'area: overlay', '', '- A user-facing change.'].join('\n'), - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '002.md'), - ['type: internal', 'area: release', '', '- An internal note.'].join('\n'), - 'utf8', - ); - const noDetailsResponder = (input: string): string => { if (modeFromPrompt(input) === 'changelog') { return '### Added\n- Polished: added.\n\n### Internal\n- Polished: internal (no details wrapper).'; @@ -1060,234 +737,184 @@ test('writeChangelogArtifacts rejects changelog-mode output that omits the Inter return defaultPolishedBody(input); }; - try { - assert.throws( - () => - writeChangelogArtifacts({ - cwd: projectRoot, - version: '0.5.0', - date: '2026-04-06', - deps: { runClaude: noDetailsResponder }, - }), - /
Internal changes<\/summary> wrapper/, - ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } + await expectChangelogFailure( + 'claude-no-details', + { + 'changes/001.md': fragment('added', 'overlay', '- A user-facing change.'), + 'changes/002.md': fragment('internal', 'release', '- An internal note.'), + }, + noDetailsResponder, + /
Internal changes<\/summary> wrapper/, + ); }); test('writeChangelogArtifacts filters internal fragments from the release-notes Claude prompt', async () => { const { writeChangelogArtifacts } = await loadModule(); - const workspace = createWorkspace('release-notes-internal-filter'); - const projectRoot = path.join(workspace, 'SubMiner'); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: added', 'area: overlay', '', '- A user-facing change.'].join('\n'), - 'utf8', + await withProject( + 'release-notes-internal-filter', + { + 'CHANGELOG.md': EMPTY_CHANGELOG, + 'changes/001.md': fragment('added', 'overlay', '- A user-facing change.'), + 'changes/002.md': fragment('internal', 'release', '- An internal CI tweak.'), + }, + (project) => { + const stub = defaultStubClaude(); + writeChangelogArtifacts({ + cwd: project.projectRoot, + version: '0.5.0', + date: '2026-04-06', + deps: { runClaude: stub.runClaude }, + }); + + const changelogCall = stub.calls.find((call) => modeFromPrompt(call.input) === 'changelog'); + const releaseNotesCall = stub.calls.find( + (call) => modeFromPrompt(call.input) === 'release-notes', + ); + assert.ok(changelogCall, 'expected a changelog-mode invocation'); + assert.ok(releaseNotesCall, 'expected a release-notes-mode invocation'); + + assert.deepEqual( + fragmentTypesInPrompt(changelogCall.input).sort(), + ['added', 'internal'], + 'changelog mode keeps internal fragments', + ); + assert.deepEqual( + fragmentTypesInPrompt(releaseNotesCall.input), + ['added'], + 'release-notes mode drops internal fragments', + ); + + const releaseNotes = project.read('release/release-notes.md'); + assert.doesNotMatch(releaseNotes, /
/); + assert.doesNotMatch(releaseNotes, /### Internal/); + assert.match( + project.read('CHANGELOG.md'), + /
[\s\S]*Internal changes<\/summary>/, + ); + }, ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '002.md'), - ['type: internal', 'area: release', '', '- An internal CI tweak.'].join('\n'), - 'utf8', - ); - - try { - const stub = defaultStubClaude(); - writeChangelogArtifacts({ - cwd: projectRoot, - version: '0.5.0', - date: '2026-04-06', - deps: { runClaude: stub.runClaude }, - }); - - const changelogCall = stub.calls.find((call) => modeFromPrompt(call.input) === 'changelog'); - const releaseNotesCall = stub.calls.find( - (call) => modeFromPrompt(call.input) === 'release-notes', - ); - assert.ok(changelogCall, 'expected a changelog-mode invocation'); - assert.ok(releaseNotesCall, 'expected a release-notes-mode invocation'); - - assert.deepEqual( - fragmentTypesInPrompt(changelogCall.input).sort(), - ['added', 'internal'], - 'changelog mode keeps internal fragments', - ); - assert.deepEqual( - fragmentTypesInPrompt(releaseNotesCall.input), - ['added'], - 'release-notes mode drops internal fragments', - ); - - const releaseNotes = fs.readFileSync( - path.join(projectRoot, 'release', 'release-notes.md'), - 'utf8', - ); - assert.doesNotMatch(releaseNotes, /
/); - assert.doesNotMatch(releaseNotes, /### Internal/); - - const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8'); - assert.match(changelog, /
[\s\S]*Internal changes<\/summary>/); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('writeChangelogArtifacts appends contributor attribution and a new-contributors section to release notes', async () => { const { writeChangelogArtifacts } = await loadModule(); - const workspace = createWorkspace('release-notes-contributors'); - const projectRoot = path.join(workspace, 'SubMiner'); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: added', 'area: overlay', '', '- Added a feature.'].join('\n'), - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '002.md'), - ['type: fixed', 'area: jellyfin', '', '- Fixed a bug.'].join('\n'), - 'utf8', - ); - - try { - const stub = defaultStubClaude(); - const resolveContributionsCalls: string[][] = []; - writeChangelogArtifacts({ - cwd: projectRoot, - version: '0.6.0', - date: '2026-05-06', - deps: { - runClaude: stub.runClaude, - resolveContributions: (fragmentPaths) => { - resolveContributionsCalls.push(fragmentPaths); - return [ - { - prNumber: 110, - login: 'ksyasuda', - title: 'feat(overlay): add a feature', - isFirstContribution: false, - }, - { - prNumber: 112, - login: 'bee-san', - title: 'fix(jellyfin): restart remote session', - isFirstContribution: true, - }, - ]; + await withProject( + 'release-notes-contributors', + { + 'CHANGELOG.md': EMPTY_CHANGELOG, + 'changes/001.md': fragment('added', 'overlay', '- Added a feature.'), + 'changes/002.md': fragment('fixed', 'jellyfin', '- Fixed a bug.'), + }, + (project) => { + const stub = defaultStubClaude(); + const resolveContributionsCalls: string[][] = []; + writeChangelogArtifacts({ + cwd: project.projectRoot, + version: '0.6.0', + date: '2026-05-06', + deps: { + runClaude: stub.runClaude, + resolveContributions: (fragmentPaths) => { + resolveContributionsCalls.push(fragmentPaths); + return [ + { + prNumber: 110, + login: 'ksyasuda', + title: 'feat(overlay): add a feature', + isFirstContribution: false, + }, + { + prNumber: 112, + login: 'bee-san', + title: 'fix(jellyfin): restart remote session', + isFirstContribution: true, + }, + ]; + }, }, - }, - }); + }); - assert.equal(resolveContributionsCalls.length, 1, 'resolves contributions once per release'); - assert.deepEqual(resolveContributionsCalls[0], [ - path.join(projectRoot, 'changes', '001.md'), - path.join(projectRoot, 'changes', '002.md'), - ]); + assert.equal(resolveContributionsCalls.length, 1, 'resolves contributions once per release'); + assert.deepEqual(resolveContributionsCalls[0], [ + project.at('changes/001.md'), + project.at('changes/002.md'), + ]); - const releaseNotes = fs.readFileSync( - path.join(projectRoot, 'release', 'release-notes.md'), - 'utf8', - ); - assert.match(releaseNotes, /## What's Changed\n\n/); - assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/); - assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/); - assert.match( - releaseNotes, - /## New Contributors\n\n- @bee-san made their first contribution in #112/, - ); - assert.ok( - releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'), - "What's Changed should follow Highlights", - ); - assert.ok( - releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'), - 'contributor attribution should appear before Installation', - ); - assert.doesNotMatch(releaseNotes, /## What’s Changed/); - assert.doesNotMatch( - releaseNotes, - /ksyasuda made their first contribution/, - 'returning contributors are not listed under New Contributors', - ); + const releaseNotes = project.read('release/release-notes.md'); + assert.match(releaseNotes, /## What's Changed\n\n/); + assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/); + assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/); + assert.match( + releaseNotes, + /## New Contributors\n\n- @bee-san made their first contribution in #112/, + ); + assert.ok( + releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'), + "What's Changed should follow Highlights", + ); + assert.ok( + releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'), + 'contributor attribution should appear before Installation', + ); + assert.doesNotMatch(releaseNotes, /## What’s Changed/); + assert.doesNotMatch( + releaseNotes, + /ksyasuda made their first contribution/, + 'returning contributors are not listed under New Contributors', + ); - // Attribution is a release-notes concern only; the CHANGELOG stays clean. - const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8'); - assert.doesNotMatch(changelog, /What's Changed|What’s Changed/); - assert.doesNotMatch(changelog, /New Contributors/); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } + // Attribution is a release-notes concern only; the CHANGELOG stays clean. + const changelog = project.read('CHANGELOG.md'); + assert.doesNotMatch(changelog, /What's Changed|What’s Changed/); + assert.doesNotMatch(changelog, /New Contributors/); + }, + ); }); test('writeChangelogArtifacts skips contributor attribution in GitHub Actions without a token', async () => { const { writeChangelogArtifacts } = await loadModule(); - const workspace = createWorkspace('release-notes-actions-no-token'); - const projectRoot = path.join(workspace, 'SubMiner'); - const originalActions = process.env.GITHUB_ACTIONS; - const originalGhToken = process.env.GH_TOKEN; - const originalGithubToken = process.env.GITHUB_TOKEN; - const originalPath = process.env.PATH; + const envKeys = ['GITHUB_ACTIONS', 'GH_TOKEN', 'GITHUB_TOKEN', 'PATH'] as const; + const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); const originalWarn = console.warn; const warnings: string[] = []; - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: added', 'area: release', '', '- Added a feature.'].join('\n'), - 'utf8', + await withProject( + 'release-notes-actions-no-token', + { + 'CHANGELOG.md': EMPTY_CHANGELOG, + 'changes/001.md': fragment('added', 'release', '- Added a feature.'), + }, + (project) => { + try { + process.env.GITHUB_ACTIONS = 'true'; + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + // An empty PATH dir means the default lookup could not find `gh` even if it tried. + process.env.PATH = project.workspace; + console.warn = (message?: unknown) => { + warnings.push(String(message)); + }; + + writeChangelogArtifacts({ + cwd: project.projectRoot, + version: '0.6.0', + date: '2026-05-06', + deps: { runClaude: defaultStubClaude().runClaude }, + }); + + assert.deepEqual(warnings, []); + assert.doesNotMatch(project.read('release/release-notes.md'), /## What's Changed/); + } finally { + console.warn = originalWarn; + for (const key of envKeys) { + const original = originalEnv[key]; + if (original === undefined) delete process.env[key]; + else process.env[key] = original; + } + } + }, ); - - try { - process.env.GITHUB_ACTIONS = 'true'; - delete process.env.GH_TOKEN; - delete process.env.GITHUB_TOKEN; - process.env.PATH = workspace; - console.warn = (message?: unknown) => { - warnings.push(String(message)); - }; - - writeChangelogArtifacts({ - cwd: projectRoot, - version: '0.6.0', - date: '2026-05-06', - deps: { runClaude: defaultStubClaude().runClaude }, - }); - - assert.deepEqual(warnings, []); - const releaseNotes = fs.readFileSync( - path.join(projectRoot, 'release', 'release-notes.md'), - 'utf8', - ); - assert.doesNotMatch(releaseNotes, /## What's Changed/); - } finally { - console.warn = originalWarn; - if (originalActions === undefined) { - delete process.env.GITHUB_ACTIONS; - } else { - process.env.GITHUB_ACTIONS = originalActions; - } - if (originalGhToken === undefined) { - delete process.env.GH_TOKEN; - } else { - process.env.GH_TOKEN = originalGhToken; - } - if (originalGithubToken === undefined) { - delete process.env.GITHUB_TOKEN; - } else { - process.env.GITHUB_TOKEN = originalGithubToken; - } - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('shouldSkipDefaultContributionLookup skips GitHub Actions without a gh token', async () => { @@ -1319,10 +946,19 @@ test('shouldSkipDefaultContributionLookup skips GitHub Actions without a gh toke ); }); +const INTERNAL_DETAILS_BLOCK = [ + '
', + 'Internal changes', + '', + '### Internal', + '- Polished: internal release note.', + '', + '
', + '', +]; + test('writeReleaseNotesForVersion preserves committed contributor attribution before installation', async () => { const { writeReleaseNotesForVersion } = await loadModule(); - const workspace = createWorkspace('release-notes-preserve-attribution'); - const projectRoot = path.join(workspace, 'SubMiner'); const existingChangelog = [ '# Changelog', '', @@ -1330,24 +966,14 @@ test('writeReleaseNotesForVersion preserves committed contributor attribution be '### Added', '- Polished: released feature.', '', - '
', - 'Internal changes', - '', - '### Internal', - '- Polished: internal release note.', - '', - '
', - '', + ...INTERNAL_DETAILS_BLOCK, ].join('\n'); const committedReleaseNotes = [ '## Highlights', '### Added', '- Old generated body.', '', - '## Installation', - '', - 'See the README and docs/installation guide for full setup steps.', - '', + ...INSTALLATION_SECTION, '## Assets', '', '- Linux: `SubMiner.AppImage`', @@ -1362,50 +988,42 @@ test('writeReleaseNotesForVersion preserves committed contributor attribution be '', ].join('\n'); - fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'release', 'release-notes.md'), - committedReleaseNotes, - 'utf8', + await withProject( + 'release-notes-preserve-attribution', + { 'CHANGELOG.md': existingChangelog, 'release/release-notes.md': committedReleaseNotes }, + (project) => { + const outputPath = writeReleaseNotesForVersion({ + cwd: project.projectRoot, + version: '0.8.0', + }); + const releaseNotes = fs.readFileSync(outputPath, 'utf8'); + + assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: released feature\./); + assert.doesNotMatch(releaseNotes, /
/); + assert.doesNotMatch(releaseNotes, /### Internal/); + assert.match( + releaseNotes, + /## What's Changed\n\n- feat\(release\): add contributor attribution by @ksyasuda in #114/, + ); + assert.match( + releaseNotes, + /## New Contributors\n\n- @bee-san made their first contribution in #112/, + ); + assert.ok( + releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'), + "What's Changed should follow Highlights", + ); + assert.ok( + releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'), + 'New Contributors should appear before Installation', + ); + assert.doesNotMatch(releaseNotes, /## What’s Changed/); + }, ); - - try { - const outputPath = writeReleaseNotesForVersion({ - cwd: projectRoot, - version: '0.8.0', - }); - const releaseNotes = fs.readFileSync(outputPath, 'utf8'); - - assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: released feature\./); - assert.doesNotMatch(releaseNotes, /
/); - assert.doesNotMatch(releaseNotes, /### Internal/); - assert.match( - releaseNotes, - /## What's Changed\n\n- feat\(release\): add contributor attribution by @ksyasuda in #114/, - ); - assert.match( - releaseNotes, - /## New Contributors\n\n- @bee-san made their first contribution in #112/, - ); - assert.ok( - releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'), - "What's Changed should follow Highlights", - ); - assert.ok( - releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'), - 'New Contributors should appear before Installation', - ); - assert.doesNotMatch(releaseNotes, /## What’s Changed/); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('writeChangelogArtifacts strips
blocks from release notes when reusing an existing CHANGELOG section', async () => { const { writeChangelogArtifacts } = await loadModule(); - const workspace = createWorkspace('reuse-existing-section'); - const projectRoot = path.join(workspace, 'SubMiner'); const existingChangelog = [ '# Changelog', '', @@ -1413,49 +1031,36 @@ test('writeChangelogArtifacts strips
blocks from release notes when re '### Added', '- Polished: previously committed.', '', - '
', - 'Internal changes', - '', - '### Internal', - '- Polished: internal note.', - '', - '
', - '', + ...INTERNAL_DETAILS_BLOCK, ].join('\n'); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: added', 'area: overlay', '', '- Stale fragment.'].join('\n'), - 'utf8', + await withProject( + 'reuse-existing-section', + { + 'CHANGELOG.md': existingChangelog, + 'changes/001.md': fragment('added', 'overlay', '- Stale fragment.'), + }, + (project) => { + const stub = defaultStubClaude(); + writeChangelogArtifacts({ + cwd: project.projectRoot, + version: '0.4.1', + date: '2026-03-08', + deps: { runClaude: stub.runClaude }, + }); + + assert.equal( + stub.calls.length, + 0, + 'no Claude calls should fire when the section already exists', + ); + + const releaseNotes = project.read('release/release-notes.md'); + assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: previously committed\./); + assert.doesNotMatch(releaseNotes, /
/); + assert.doesNotMatch(releaseNotes, /### Internal/); + }, ); - - try { - const stub = defaultStubClaude(); - writeChangelogArtifacts({ - cwd: projectRoot, - version: '0.4.1', - date: '2026-03-08', - deps: { runClaude: stub.runClaude }, - }); - - assert.equal( - stub.calls.length, - 0, - 'no Claude calls should fire when the section already exists', - ); - - const releaseNotes = fs.readFileSync( - path.join(projectRoot, 'release', 'release-notes.md'), - 'utf8', - ); - assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: previously committed\./); - assert.doesNotMatch(releaseNotes, /
/); - assert.doesNotMatch(releaseNotes, /### Internal/); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('selectPreviousPrereleaseTag orders betas before rcs and filters other base versions', async () => { @@ -1482,348 +1087,259 @@ test('selectPreviousPrereleaseTag orders betas before rcs and filters other base test('writePrereleaseNotesForVersion adds a delta section generated from fragment diffs', async () => { const { writePrereleaseNotesForVersion } = await loadModule(); - const workspace = createWorkspace('prerelease-delta-section'); - const projectRoot = path.join(workspace, 'SubMiner'); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2), - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: fixed', 'area: overlay', '', '- Fixed overlay focus and macOS helper.'].join('\n'), - 'utf8', - ); - - try { - const stub = recordingRunClaude((input) => - input.includes('MODIFIED FRAGMENT') - ? '- Fixed the macOS helper deployment target for older systems.' - : '### Fixed\n- Overlay: cumulative fixed entry.', - ); - const outputPath = writePrereleaseNotesForVersion({ - cwd: projectRoot, - version: '0.12.0-beta.2', - deps: { - runClaude: stub.runClaude, - listPrereleaseTags: () => ['v0.12.0-beta.1'], - resolveFragmentDelta: (_cwd, previousTag) => { - assert.equal(previousTag, 'v0.12.0-beta.1'); - return [ - { - path: 'changes/002.md', - status: 'added', - after: 'type: fixed\narea: macos\n\n- Fixed helper deployment target.', - }, - { - path: 'changes/001.md', - status: 'modified', - before: '- Fixed overlay focus.', - after: '- Fixed overlay focus and macOS helper.', - }, - { - path: 'changes/003.md', - status: 'deleted', - before: 'type: added\narea: stats\n\n- Reverted experimental stats view.', - }, - ]; + await withProject( + 'prerelease-delta-section', + { + 'package.json': packageJson('0.12.0-beta.2'), + 'changes/001.md': fragment('fixed', 'overlay', '- Fixed overlay focus and macOS helper.'), + }, + (project) => { + const stub = recordingRunClaude((input) => + input.includes('MODIFIED FRAGMENT') + ? '- Fixed the macOS helper deployment target for older systems.' + : '### Fixed\n- Overlay: cumulative fixed entry.', + ); + const outputPath = writePrereleaseNotesForVersion({ + cwd: project.projectRoot, + version: '0.12.0-beta.2', + deps: { + runClaude: stub.runClaude, + listPrereleaseTags: () => ['v0.12.0-beta.1'], + resolveFragmentDelta: (_cwd, previousTag) => { + assert.equal(previousTag, 'v0.12.0-beta.1'); + return [ + { + path: 'changes/002.md', + status: 'added', + after: 'type: fixed\narea: macos\n\n- Fixed helper deployment target.', + }, + { + path: 'changes/001.md', + status: 'modified', + before: '- Fixed overlay focus.', + after: '- Fixed overlay focus and macOS helper.', + }, + { + path: 'changes/003.md', + status: 'deleted', + before: 'type: added\narea: stats\n\n- Reverted experimental stats view.', + }, + ]; + }, }, - }, - }); + }); - assert.equal(stub.calls.length, 2, 'delta and cumulative polish are separate Claude calls'); - const deltaPrompt = stub.calls[0]!.input; - assert.match(deltaPrompt, /ADDED FRAGMENT changes\/002\.md/); - assert.match(deltaPrompt, /MODIFIED FRAGMENT changes\/001\.md/); - assert.match(deltaPrompt, /BEFORE:\n- Fixed overlay focus\./); - assert.match(deltaPrompt, /AFTER:\n- Fixed overlay focus and macOS helper\./); - assert.match(deltaPrompt, /DELETED FRAGMENT changes\/003\.md/); - assert.match(deltaPrompt, /If the edit is editorial/); - assert.match(deltaPrompt, /removed or reverted/); - assert.match(deltaPrompt, /No user-facing changes since v0\.12\.0-beta\.1\./); - assert.equal(modeFromPrompt(stub.calls[1]!.input), 'release-notes'); + assert.equal(stub.calls.length, 2, 'delta and cumulative polish are separate Claude calls'); + const deltaPrompt = stub.calls[0]!.input; + assert.match(deltaPrompt, /ADDED FRAGMENT changes\/002\.md/); + assert.match(deltaPrompt, /MODIFIED FRAGMENT changes\/001\.md/); + assert.match(deltaPrompt, /BEFORE:\n- Fixed overlay focus\./); + assert.match(deltaPrompt, /AFTER:\n- Fixed overlay focus and macOS helper\./); + assert.match(deltaPrompt, /DELETED FRAGMENT changes\/003\.md/); + assert.equal(modeFromPrompt(stub.calls[1]!.input), 'release-notes'); - const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); - assert.match( - prereleaseNotes, - //, - ); - const deltaIndex = prereleaseNotes.indexOf('## Changes since v0.12.0-beta.1'); - const highlightsIndex = prereleaseNotes.indexOf('## Highlights'); - assert.ok(deltaIndex !== -1, 'delta section heading should be present'); - assert.ok(deltaIndex < highlightsIndex, 'delta section should precede Highlights'); - assert.match(prereleaseNotes, /- Fixed the macOS helper deployment target for older systems\./); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } + const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); + assert.match( + prereleaseNotes, + //, + ); + const deltaIndex = prereleaseNotes.indexOf('## Changes since v0.12.0-beta.1'); + const highlightsIndex = prereleaseNotes.indexOf('## Highlights'); + assert.ok(deltaIndex !== -1, 'delta section heading should be present'); + assert.ok(deltaIndex < highlightsIndex, 'delta section should precede Highlights'); + assert.match( + prereleaseNotes, + /- Fixed the macOS helper deployment target for older systems\./, + ); + }, + ); }); test('writePrereleaseNotesForVersion renders a fallback delta line when no fragments changed', async () => { const { writePrereleaseNotesForVersion } = await loadModule(); - const workspace = createWorkspace('prerelease-empty-delta'); - const projectRoot = path.join(workspace, 'SubMiner'); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.12.0-beta.3' }, null, 2), - 'utf8', + await withProject( + 'prerelease-empty-delta', + { + 'package.json': packageJson('0.12.0-beta.3'), + 'changes/001.md': fragment('fixed', 'overlay', '- Fixed overlay focus.'), + }, + (project) => { + const stub = defaultStubClaude(); + const outputPath = writePrereleaseNotesForVersion({ + cwd: project.projectRoot, + version: '0.12.0-beta.3', + deps: { + runClaude: stub.runClaude, + listPrereleaseTags: () => ['v0.12.0-beta.1', 'v0.12.0-beta.2'], + resolveFragmentDelta: () => [], + }, + }); + + assert.equal(stub.calls.length, 1, 'empty delta must not spend a Claude call'); + assert.match( + fs.readFileSync(outputPath, 'utf8'), + /## Changes since v0\.12\.0-beta\.2\n\n- No changelog fragment changes since v0\.12\.0-beta\.2; this build contains packaging or internal-only updates\./, + ); + }, ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: fixed', 'area: overlay', '', '- Fixed overlay focus.'].join('\n'), - 'utf8', - ); - - try { - const stub = defaultStubClaude(); - const outputPath = writePrereleaseNotesForVersion({ - cwd: projectRoot, - version: '0.12.0-beta.3', - deps: { - runClaude: stub.runClaude, - listPrereleaseTags: () => ['v0.12.0-beta.1', 'v0.12.0-beta.2'], - resolveFragmentDelta: () => [], - }, - }); - - assert.equal(stub.calls.length, 1, 'empty delta must not spend a Claude call'); - const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); - assert.match( - prereleaseNotes, - /## Changes since v0\.12\.0-beta\.2\n\n- No changelog fragment changes since v0\.12\.0-beta\.2; this build contains packaging or internal-only updates\./, - ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('writePrereleaseNotesForVersion rejects non-bullet delta output from Claude', async () => { const { writePrereleaseNotesForVersion } = await loadModule(); - const workspace = createWorkspace('prerelease-delta-invalid-output'); - const projectRoot = path.join(workspace, 'SubMiner'); - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2), - 'utf8', + await withProject( + 'prerelease-delta-invalid-output', + { + 'package.json': packageJson('0.12.0-beta.2'), + 'changes/001.md': fragment('fixed', 'overlay', '- Fixed overlay focus.'), + }, + (project) => { + const stub = recordingRunClaude(() => 'Here are the changes:\n- One change.'); + assert.throws( + () => + writePrereleaseNotesForVersion({ + cwd: project.projectRoot, + version: '0.12.0-beta.2', + deps: { + runClaude: stub.runClaude, + listPrereleaseTags: () => ['v0.12.0-beta.1'], + resolveFragmentDelta: () => [ + { path: 'changes/001.md', status: 'added', after: '- Fixed overlay focus.' }, + ], + }, + }), + /delta output must contain only Markdown bullets/, + ); + }, ); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: fixed', 'area: overlay', '', '- Fixed overlay focus.'].join('\n'), - 'utf8', - ); - - try { - const stub = recordingRunClaude(() => 'Here are the changes:\n- One change.'); - assert.throws( - () => - writePrereleaseNotesForVersion({ - cwd: projectRoot, - version: '0.12.0-beta.2', - deps: { - runClaude: stub.runClaude, - listPrereleaseTags: () => ['v0.12.0-beta.1'], - resolveFragmentDelta: () => [ - { path: 'changes/001.md', status: 'added', after: '- Fixed overlay focus.' }, - ], - }, - }), - /delta output must contain only Markdown bullets/, - ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('writePrereleaseNotesForVersion strips the stale delta section from the reused baseline', async () => { const { writePrereleaseNotesForVersion } = await loadModule(); - const workspace = createWorkspace('prerelease-reuse-strips-delta'); - const projectRoot = path.join(workspace, 'SubMiner'); - const existingNotes = [ - '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', - '', + const existingNotes = existingPrereleaseNotes( '', - '', - '## Changes since v0.12.0-beta.1', - '', - '- Stale beta-to-beta delta bullet.', - '', - '## Highlights', - '### Added', - '- Overlay: Previous beta entry.', - '', - '## Installation', - '', - 'See the README and docs/installation guide for full setup steps.', - '', - ].join('\n'); - - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.12.0-beta.3' }, null, 2), - 'utf8', - ); - fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8'); - fs.writeFileSync( - path.join(projectRoot, 'changes', '001.md'), - ['type: added', 'area: overlay', '', '- Added overlay coverage.'].join('\n'), - 'utf8', + [ + '## Changes since v0.12.0-beta.1', + '', + '- Stale beta-to-beta delta bullet.', + '', + '## Highlights', + '### Added', + '- Overlay: Previous beta entry.', + '', + ...INSTALLATION_SECTION, + ], ); - try { - const stub = defaultStubClaude(); - writePrereleaseNotesForVersion({ - cwd: projectRoot, - version: '0.12.0-beta.3', - deps: { - runClaude: stub.runClaude, - listPrereleaseTags: () => [], - resolveFragmentDelta: () => [], - }, - }); + await withProject( + 'prerelease-reuse-strips-delta', + { + 'package.json': packageJson('0.12.0-beta.3'), + 'release/prerelease-notes.md': existingNotes, + 'changes/001.md': fragment('added', 'overlay', '- Added overlay coverage.'), + }, + (project) => { + const stub = defaultStubClaude(); + writePrereleaseNotesForVersion({ + cwd: project.projectRoot, + version: '0.12.0-beta.3', + deps: { + runClaude: stub.runClaude, + listPrereleaseTags: () => [], + resolveFragmentDelta: () => [], + }, + }); - assert.equal(stub.calls.length, 1); - const prompt = stub.calls[0]!.input; - assert.match(prompt, /EXISTING PRERELEASE NOTES/); - assert.match(prompt, /Overlay: Previous beta entry\./); - assert.doesNotMatch(prompt, /Stale beta-to-beta delta bullet\./); - assert.doesNotMatch(prompt, /## Changes since /); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } + assert.equal(stub.calls.length, 1); + const prompt = stub.calls[0]!.input; + assert.match(prompt, /EXISTING PRERELEASE NOTES/); + assert.match(prompt, /Overlay: Previous beta entry\./); + assert.doesNotMatch(prompt, /Stale beta-to-beta delta bullet\./); + assert.doesNotMatch(prompt, /## Changes since /); + }, + ); }); test('verifyPrereleaseNotesMatchVersion accepts matching notes and rejects stale or legacy markers', async () => { const { verifyPrereleaseNotesMatchVersion } = await loadModule(); - const workspace = createWorkspace('verify-prerelease-notes'); - const projectRoot = path.join(workspace, 'SubMiner'); - const notesPath = path.join(projectRoot, 'release', 'prerelease-notes.md'); - fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2), - 'utf8', + await withProject( + 'verify-prerelease-notes', + { 'package.json': packageJson('0.12.0-beta.2') }, + (project) => { + const verify = (version = '0.12.0-beta.2') => + verifyPrereleaseNotesMatchVersion({ cwd: project.projectRoot, version }); + const notesPath = 'release/prerelease-notes.md'; + + assert.throws(() => verify(), /Missing .*prerelease-notes\.md/); + + project.write( + notesPath, + '\n\n## Highlights\n', + ); + verify(); + verify('v0.12.0-beta.2'); + + project.write(notesPath, '\n\n## Highlights\n'); + assert.throws( + () => verify(), + /generated for 0\.12\.0-beta\.1 but this release is 0\.12\.0-beta\.2/, + ); + + project.write(notesPath, '\n\n## Highlights\n'); + assert.throws(() => verify(), /missing or legacy prerelease-version marker/); + }, ); - - try { - assert.throws( - () => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }), - /Missing .*prerelease-notes\.md/, - ); - - fs.writeFileSync( - notesPath, - '\n\n## Highlights\n', - 'utf8', - ); - verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }); - verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: 'v0.12.0-beta.2' }); - - fs.writeFileSync( - notesPath, - '\n\n## Highlights\n', - 'utf8', - ); - assert.throws( - () => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }), - /generated for 0\.12\.0-beta\.1 but this release is 0\.12\.0-beta\.2/, - ); - - fs.writeFileSync( - notesPath, - '\n\n## Highlights\n', - 'utf8', - ); - assert.throws( - () => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }), - /missing or legacy prerelease-version marker/, - ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); test('default git tag listing and fragment delta resolution work against a real repository', async () => { const { writePrereleaseNotesForVersion } = await loadModule(); - const workspace = createWorkspace('prerelease-git-defaults'); - const projectRoot = path.join(workspace, 'SubMiner'); - const git = (...args: string[]): void => { - execFileSync('git', args, { cwd: projectRoot, stdio: 'ignore' }); - }; - fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2), - 'utf8', + await withProject( + 'prerelease-git-defaults', + { + 'package.json': packageJson('0.11.3-beta.1'), + 'changes/kept.md': fragment('added', 'overlay', '- Kept change.'), + 'changes/edited.md': fragment('fixed', 'launcher', '- Original launcher fix.'), + 'changes/removed.md': fragment('added', 'stats', '- Reverted stats change.'), + }, + (project) => { + const git = (...args: string[]): void => { + execFileSync('git', args, { cwd: project.projectRoot, stdio: 'ignore' }); + }; + + git('init', '--quiet'); + git('-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'add', '.'); + git('-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'beta.1'); + git('tag', 'v0.11.3-beta.1'); + + project.write('changes/edited.md', fragment('fixed', 'launcher', '- Broader launcher fix.')); + project.remove('changes/removed.md'); + project.write('changes/new.md', fragment('added', 'anki', '- New anki change.')); + project.write('package.json', packageJson('0.11.3-beta.2')); + + const stub = recordingRunClaude((input) => + input.includes('PREVIOUS_TAG:') ? '- Delta bullet.' : defaultPolishedBody(input), + ); + writePrereleaseNotesForVersion({ + cwd: project.projectRoot, + version: '0.11.3-beta.2', + deps: { runClaude: stub.runClaude }, + }); + + assert.equal(stub.calls.length, 2); + const deltaPrompt = stub.calls[0]!.input; + assert.match(deltaPrompt, /PREVIOUS_TAG: v0\.11\.3-beta\.1/); + assert.match(deltaPrompt, /ADDED FRAGMENT changes\/new\.md/); + assert.match(deltaPrompt, /- New anki change\./); + assert.match(deltaPrompt, /MODIFIED FRAGMENT changes\/edited\.md/); + assert.match(deltaPrompt, /- Original launcher fix\./); + assert.match(deltaPrompt, /- Broader launcher fix\./); + assert.match(deltaPrompt, /DELETED FRAGMENT changes\/removed\.md/); + assert.match(deltaPrompt, /- Reverted stats change\./); + assert.doesNotMatch(deltaPrompt, /kept\.md/); + }, ); - fs.writeFileSync( - path.join(projectRoot, 'changes', 'kept.md'), - ['type: added', 'area: overlay', '', '- Kept change.'].join('\n'), - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, 'changes', 'edited.md'), - ['type: fixed', 'area: launcher', '', '- Original launcher fix.'].join('\n'), - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, 'changes', 'removed.md'), - ['type: added', 'area: stats', '', '- Reverted stats change.'].join('\n'), - 'utf8', - ); - - try { - git('init', '--quiet'); - git('-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'add', '.'); - git('-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'beta.1'); - git('tag', 'v0.11.3-beta.1'); - - fs.writeFileSync( - path.join(projectRoot, 'changes', 'edited.md'), - ['type: fixed', 'area: launcher', '', '- Broader launcher fix.'].join('\n'), - 'utf8', - ); - fs.rmSync(path.join(projectRoot, 'changes', 'removed.md')); - fs.writeFileSync( - path.join(projectRoot, 'changes', 'new.md'), - ['type: added', 'area: anki', '', '- New anki change.'].join('\n'), - 'utf8', - ); - fs.writeFileSync( - path.join(projectRoot, 'package.json'), - JSON.stringify({ name: 'subminer', version: '0.11.3-beta.2' }, null, 2), - 'utf8', - ); - - const stub = recordingRunClaude((input) => - input.includes('PREVIOUS_TAG:') ? '- Delta bullet.' : defaultPolishedBody(input), - ); - writePrereleaseNotesForVersion({ - cwd: projectRoot, - version: '0.11.3-beta.2', - deps: { runClaude: stub.runClaude }, - }); - - assert.equal(stub.calls.length, 2); - const deltaPrompt = stub.calls[0]!.input; - assert.match(deltaPrompt, /PREVIOUS_TAG: v0\.11\.3-beta\.1/); - assert.match(deltaPrompt, /ADDED FRAGMENT changes\/new\.md/); - assert.match(deltaPrompt, /- New anki change\./); - assert.match(deltaPrompt, /MODIFIED FRAGMENT changes\/edited\.md/); - assert.match(deltaPrompt, /- Original launcher fix\./); - assert.match(deltaPrompt, /- Broader launcher fix\./); - assert.match(deltaPrompt, /DELETED FRAGMENT changes\/removed\.md/); - assert.match(deltaPrompt, /- Reverted stats change\./); - assert.doesNotMatch(deltaPrompt, /kept\.md/); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } }); diff --git a/scripts/compare-yomitan-api.test.ts b/scripts/compare-yomitan-api.test.ts deleted file mode 100644 index c6ffe913..00000000 --- a/scripts/compare-yomitan-api.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -const source = readFileSync('scripts/compare-yomitan-api.ts', 'utf8'); - -test('creates the configured Yomitan user-data directory before Electron uses it', () => { - const setPathIndex = source.indexOf( - "electronModule.app.setPath('userData', options.yomitanUserDataPath)", - ); - assert.notEqual(setPathIndex, -1); - - const mkdirIndex = source.lastIndexOf( - 'fs.mkdirSync(options.yomitanUserDataPath, { recursive: true })', - setPathIndex, - ); - assert.ok(mkdirIndex > -1, 'must create the user-data directory'); - assert.ok(mkdirIndex < setPathIndex, 'must create the directory before app.setPath'); -}); - -test('lets output drain by using exitCode in the top-level completion handlers', () => { - const completionHandlers = source.slice(source.lastIndexOf('\nmain()')); - assert.match(completionHandlers, /process\.exitCode = process\.exitCode \?\? 0/); - assert.match(completionHandlers, /process\.exitCode = 1/); - assert.doesNotMatch(completionHandlers, /process\.exit\(/); -}); diff --git a/scripts/docs-knowledge-base.test.ts b/scripts/docs-knowledge-base.test.ts index d08052df..93abafa5 100644 --- a/scripts/docs-knowledge-base.test.ts +++ b/scripts/docs-knowledge-base.test.ts @@ -21,6 +21,7 @@ const requiredDocs = [ 'docs/workflow/README.md', 'docs/workflow/agent-skills.md', 'docs/workflow/planning.md', + 'docs/workflow/testing.md', 'docs/workflow/verification.md', ] as const; diff --git a/scripts/get-mpv-window-macos.test.ts b/scripts/get-mpv-window-macos.test.ts deleted file mode 100644 index 59baab6f..00000000 --- a/scripts/get-mpv-window-macos.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -const source = readFileSync('scripts/get-mpv-window-macos.swift', 'utf8'); - -test('minimized Accessibility windows are validated by PID and socket before reporting minimized', () => { - const minimizedAssignmentIndex = source.indexOf('foundMinimizedTargetWindow = true'); - assert.notEqual(minimizedAssignmentIndex, -1); - - const loopStartIndex = source.lastIndexOf('for window in windows', minimizedAssignmentIndex); - assert.notEqual(loopStartIndex, -1); - - const pidExtractionIndex = source.indexOf( - 'AXUIElementGetPid(window, &windowPid)', - loopStartIndex, - ); - const appPidMatchIndex = source.indexOf('windowPid != app.processIdentifier', loopStartIndex); - const socketCheckIndex = source.indexOf('if !windowHasTargetSocket(windowPid)', loopStartIndex); - - assert.ok( - pidExtractionIndex > loopStartIndex && pidExtractionIndex < minimizedAssignmentIndex, - 'window PID must be extracted before accepting a minimized window', - ); - assert.ok( - appPidMatchIndex > pidExtractionIndex && appPidMatchIndex < minimizedAssignmentIndex, - 'window PID must match the owning app before accepting a minimized window', - ); - assert.ok( - socketCheckIndex > appPidMatchIndex && socketCheckIndex < minimizedAssignmentIndex, - 'target socket must be validated before accepting a minimized window', - ); -}); - -test('focused mpv window follows the frontmost mpv app signal', () => { - const focusHelperIndex = source.indexOf('private func isFocusedMpvWindow'); - assert.notEqual(focusHelperIndex, -1); - - const nextFunctionIndex = source.indexOf('\nprivate func ', focusHelperIndex + 1); - const focusHelperBody = source.slice(focusHelperIndex, nextFunctionIndex); - - assert.ok( - focusHelperBody.includes('frontmost.pid == ownerPid'), - 'matching frontmost PID should mark the mpv window focused', - ); - assert.ok( - focusHelperBody.includes('frontmost.isMpv && windowHasTargetSocket(ownerPid)'), - 'frontmost mpv app should mark the target mpv window focused even when PIDs differ', - ); - assert.ok( - source.includes('focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)'), - 'Accessibility path should use the shared focused mpv helper', - ); - assert.ok( - source.includes('focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)'), - 'CoreGraphics path should use the shared focused mpv helper', - ); -}); - -test('frontmost mpv app emits active state when geometry lookup misses', () => { - assert.ok( - /case\s+\.active:/.test(source), - 'helper should expose an active state without window geometry', - ); - assert.ok( - source.includes('if windowHasTargetSocket(frontmost.pid)'), - 'active state should still accept a matching target socket when available', - ); - assert.ok( - source.includes('return targetMpvSocketPath != nil'), - 'active state should preserve frontmost mpv even if command-line socket detection fails', - ); - assert.ok( - source.includes('return .active'), - 'lookup should preserve active mpv state after geometry lookup misses', - ); - assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker'); -}); - -test('frontmost non-mpv app emits inactive state when geometry lookup misses', () => { - assert.ok( - /case\s+\.inactive:/.test(source), - 'helper should expose an inactive state without window geometry', - ); - assert.ok( - source.includes('if frontmost != nil'), - 'helper should distinguish a known non-mpv frontmost app from an unknown miss', - ); - assert.ok(source.includes('return .inactive'), 'known non-mpv focus should return inactive'); - assert.ok( - source.includes('print("inactive")'), - 'inactive state should be printed for the tracker', - ); -}); diff --git a/scripts/get_frequency.test.ts b/scripts/get_frequency.test.ts deleted file mode 100644 index 5911864d..00000000 --- a/scripts/get_frequency.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import test from 'node:test'; -import { spawnSync } from 'node:child_process'; - -function createWorkspace(name: string): string { - const baseDir = path.join(process.cwd(), '.tmp', 'get-frequency-typecheck-test'); - fs.mkdirSync(baseDir, { recursive: true }); - return fs.mkdtempSync(path.join(baseDir, `${name}-`)); -} - -test('scripts/get_frequency.ts typechecks in isolation', () => { - const workspace = createWorkspace('isolated-script'); - const tsconfigPath = path.join(workspace, 'tsconfig.json'); - - fs.writeFileSync( - tsconfigPath, - JSON.stringify( - { - extends: '../../../tsconfig.typecheck.json', - include: ['../../../scripts/get_frequency.ts'], - exclude: [], - }, - null, - 2, - ), - 'utf8', - ); - - try { - const result = spawnSync('bunx', ['tsc', '--noEmit', '-p', tsconfigPath], { - cwd: process.cwd(), - encoding: 'utf8', - }); - - assert.equal( - result.status, - 0, - `expected scripts/get_frequency.ts to typecheck\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, - ); - } finally { - fs.rmSync(workspace, { recursive: true, force: true }); - } -}); diff --git a/scripts/package-bun-source.test.ts b/scripts/package-bun-source.test.ts index d361523a..0bd341c2 100644 --- a/scripts/package-bun-source.test.ts +++ b/scripts/package-bun-source.test.ts @@ -39,24 +39,6 @@ describe('Bun corresponding-source manifest', () => { } }); - test('pins the source to the revision reported by the distributed binary', async () => { - const manifest = parseSourceManifest( - JSON.parse( - await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'), - ), - ); - - expect(manifest.version).toBe('1.3.5'); - expect(manifest.bunRevision).toBe('1e86cebd74a5723e818b5c0555276b646bcf0e4c'); - expect(manifest.releaseTagCommit).toBe('fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab'); - expect(manifest.sources.find((source) => source.name === 'WebKit')?.revision).toBe( - '6d0f3aac0b817cc01a846b3754b21271adedac12', - ); - expect(manifest.sources.find((source) => source.name === 'tinycc')?.revision).toBe( - '29985a3b59898861442fa3b43f663fc1af2591d7', - ); - }); - test('parses commit and tag registrations from Bun CMake', () => { const registrations = parseRegisteredRepositories(` register_repository( diff --git a/scripts/preload-bundles.test.ts b/scripts/preload-bundles.test.ts deleted file mode 100644 index 95fd30a5..00000000 --- a/scripts/preload-bundles.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; - -test('build:syncui bundles the sandboxed preload and keeps Electron external', () => { - const packageJson = JSON.parse( - fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'), - ) as { scripts: Record }; - const command = packageJson.scripts['build:syncui'] ?? ''; - - assert.match(command, /src\/preload-syncui\.ts/); - assert.match(command, /--bundle/); - assert.match(command, /--external:electron/); - assert.match(command, /--outfile=dist\/preload-syncui\.js/); -}); diff --git a/scripts/prepare-build-assets.test.ts b/scripts/prepare-build-assets.test.ts deleted file mode 100644 index d710f8b9..00000000 --- a/scripts/prepare-build-assets.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -const source = readFileSync('scripts/prepare-build-assets.mjs', 'utf8'); - -test('macOS helper build creates dist scripts directory before swiftc output', () => { - const buildFunctionIndex = source.indexOf('function buildMacosHelper()'); - assert.notEqual(buildFunctionIndex, -1); - - const swiftcIndex = source.indexOf("'swiftc'", buildFunctionIndex); - assert.notEqual(swiftcIndex, -1); - - const ensureDirIndex = source.lastIndexOf('ensureDir(scriptsOutputDir)', swiftcIndex); - - assert.ok( - ensureDirIndex > buildFunctionIndex, - 'buildMacosHelper must create dist/scripts before swiftc writes the helper binary', - ); -}); - -// Regression guard for #213: an untargeted swiftc stamps the build machine's OS -// version as the helper's minimum, so released builds refuse to load on older macOS. -test('macOS helper is compiled with an explicit deployment target', () => { - assert.match(source, /-target/); - assert.match(source, /apple-macos\$\{MACOS_HELPER_DEPLOYMENT_TARGET\}/); -}); diff --git a/scripts/run-coverage-lane.test.ts b/scripts/run-coverage-lane.test.ts index 2a5fa2e7..a49ed4f6 100644 --- a/scripts/run-coverage-lane.test.ts +++ b/scripts/run-coverage-lane.test.ts @@ -75,7 +75,7 @@ test('resolveCoverageDir keeps coverage output inside the repository', () => { assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape'])); }); -test('runCoverageLane returns a failure when a discovered test fails', () => { +test('runCoverageLane returns a failure when a discovered test fails', async () => { const repoRoot = mkdtempSync(join(tmpdir(), 'subminer-coverage-failure-')); try { mkdirSync(join(repoRoot, 'src')); @@ -93,10 +93,10 @@ test('runCoverageLane returns a failure when a discovered test fails', () => { ); assert.notEqual( - runCoverageLane({ + await runCoverageLane({ repoRootDir: repoRoot, argv: ['bun-src-full', '--coverage-dir', 'coverage/test-src'], - stdio: 'pipe', + quiet: true, }), 0, ); diff --git a/scripts/run-coverage-lane.ts b/scripts/run-coverage-lane.ts index 5d567b40..8687e437 100644 --- a/scripts/run-coverage-lane.ts +++ b/scripts/run-coverage-lane.ts @@ -1,5 +1,6 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { availableParallelism } from 'node:os'; import { isAbsolute, join, relative, resolve } from 'node:path'; import { collectLaneFiles } from './test-lanes'; @@ -201,9 +202,40 @@ export function mergeLcovReports(reports: string[]): string { return chunks.length > 0 ? `${chunks.join('\n')}\n` : ''; } -export function runCoverageLane( - options: { repoRootDir?: string; argv?: string[]; stdio?: 'inherit' | 'pipe' } = {}, -): number { +type ShardResult = { status: number; output: string; lcov: string | null }; + +// Runs one test file under coverage into its own shard directory, buffering +// output so parallel shards do not interleave on the terminal. +function runShard(repoRootDir: string, file: string, shardDir: string): Promise { + return new Promise((resolveShard) => { + const child = spawn( + 'bun', + ['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`], + { cwd: repoRootDir }, + ); + let output = ''; + child.stdout.on('data', (chunk) => (output += chunk)); + child.stderr.on('data', (chunk) => (output += chunk)); + child.on('error', (error) => resolveShard({ status: 1, output: String(error), lcov: null })); + child.on('close', (code) => { + const lcovPath = join(shardDir, 'lcov.info'); + const lcov = existsSync(lcovPath) ? readFileSync(lcovPath, 'utf8') : null; + resolveShard({ status: code ?? 1, output, lcov }); + }); + }); +} + +function parseJobsArg(argv: string[]): number { + const index = argv.indexOf('--jobs'); + if (index === -1) return availableParallelism(); + return Math.max(1, Number(argv[index + 1]) || 1); +} + +// Shards run in parallel (one per CPU unless --jobs N). The first failing shard +// stops scheduling new shards and its exit status becomes the lane result. +export async function runCoverageLane( + options: { repoRootDir?: string; argv?: string[]; quiet?: boolean } = {}, +): Promise { const repoRootDir = options.repoRootDir ?? repoRoot; const argv = options.argv ?? process.argv.slice(2); const laneName = argv[0]; @@ -225,40 +257,44 @@ export function runCoverageLane( process.stderr.write(`${error instanceof Error ? error.message : error}\n`); return 1; } - const reports: string[] = []; + + const reports: Array = new Array(files.length).fill(null); + const failures: Array<{ file: string; result: ShardResult }> = []; + let nextIndex = 0; + + const worker = async (): Promise => { + while (failures.length === 0 && nextIndex < files.length) { + const index = nextIndex++; + const file = files[index]!; + const shardDir = join(shardRoot, String(index + 1).padStart(3, '0')); + const result = await runShard(repoRootDir, file, shardDir); + if (result.status !== 0) { + failures.push({ file, result }); + return; + } + if (result.lcov === null && !options.quiet) { + process.stdout.write(`Skipping empty coverage shard for ${file}\n`); + } + reports[index] = result.lcov; + } + }; try { - for (const [index, file] of files.entries()) { - const shardDir = join(shardRoot, `${String(index + 1).padStart(3, '0')}`); - const result = spawnSync( - 'bun', - ['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`], - { - cwd: repoRootDir, - stdio: options.stdio ?? 'inherit', - }, - ); + const jobs = Math.min(parseJobsArg(argv), files.length); + await Promise.all(Array.from({ length: jobs }, worker)); - if (result.error) { - throw result.error; + const [failure] = failures; + if (failure !== undefined) { + const { file, result } = failure; + if (!options.quiet) { + process.stderr.write(`${result.output}\nCoverage shard failed: ${file}\n`); } - if ((result.status ?? 1) !== 0) { - return result.status ?? 1; - } - - const lcovPath = join(shardDir, 'lcov.info'); - if (!existsSync(lcovPath)) { - process.stdout.write(`Skipping empty coverage shard for ${file}\n`); - continue; - } - - reports.push(readFileSync(lcovPath, 'utf8')); + return result.status; } - writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8'); - process.stdout.write( - `Merged LCOV written to ${relative(repoRootDir, join(coverageDir, 'lcov.info'))}\n`, - ); + const lcovPath = join(coverageDir, 'lcov.info'); + writeFileSync(lcovPath, mergeLcovReports(reports.filter((r) => r !== null)), 'utf8'); + process.stdout.write(`Merged LCOV written to ${relative(repoRootDir, lcovPath)}\n`); return 0; } finally { rmSync(shardRoot, { recursive: true, force: true }); @@ -267,5 +303,5 @@ export function runCoverageLane( // @ts-ignore Bun entrypoint detection; TS config for scripts still targets CommonJS. if (import.meta.main) { - process.exit(runCoverageLane()); + runCoverageLane().then((status) => process.exit(status)); } diff --git a/scripts/run-test-lane.mjs b/scripts/run-test-lane.mjs index 82106baa..4b617b96 100644 --- a/scripts/run-test-lane.mjs +++ b/scripts/run-test-lane.mjs @@ -1,5 +1,6 @@ import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; +import { availableParallelism } from 'node:os'; import { spawn, spawnSync } from 'node:child_process'; import { collectLaneFiles } from './test-lanes.ts'; @@ -7,6 +8,8 @@ import { collectLaneFiles } from './test-lanes.ts'; // test file so a hanging test or leaked global in one file cannot poison the // rest of the lane. Use --single-process for the old all-in-one-process mode. // +// Files run in parallel, one worker per CPU by default; --jobs 1 runs serially. +// // Usage: bun scripts/run-test-lane.mjs [--jobs N] [--timeout-secs N] [--single-process] const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); @@ -34,7 +37,12 @@ for (const signal of ['SIGINT', 'SIGTERM']) { } function parseArgs(argv) { - const options = { lane: undefined, jobs: 1, timeoutSecs: 300, singleProcess: false }; + const options = { + lane: undefined, + jobs: availableParallelism(), + timeoutSecs: 300, + singleProcess: false, + }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--jobs') { diff --git a/scripts/stage-bun-runtime.test.ts b/scripts/stage-bun-runtime.test.ts index 56f1c255..1d166d2d 100644 --- a/scripts/stage-bun-runtime.test.ts +++ b/scripts/stage-bun-runtime.test.ts @@ -61,15 +61,6 @@ test('normalizeTarget maps each supported electron-builder target without using assert.throws(() => normalizeTarget('linux', 0), /Unsupported Bun runtime target architecture/); }); -test('resolveArtifact chooses baseline x64 builds and standard arm64 builds', () => { - const config = runtimeConfig(); - assert.equal(resolveArtifact(config, 'linux', 'x64').file, 'bun-linux-x64-baseline.zip'); - assert.equal(resolveArtifact(config, 'darwin', 'x64').file, 'bun-darwin-x64-baseline.zip'); - assert.equal(resolveArtifact(config, 'win32', 'x64').file, 'bun-windows-x64-baseline.zip'); - assert.equal(resolveArtifact(config, 'linux', 'arm64').file, 'bun-linux-aarch64.zip'); - assert.equal(resolveArtifact(config, 'darwin', 'arm64').file, 'bun-darwin-aarch64.zip'); -}); - test('tracked runtime manifest covers every supported target at the packageManager version', async () => { const config = await loadRuntimeConfig(); assert.equal(config.version, '1.3.5'); diff --git a/src/anki-connect.test.ts b/src/anki-connect.test.ts index c876745d..1b959017 100644 --- a/src/anki-connect.test.ts +++ b/src/anki-connect.test.ts @@ -3,20 +3,6 @@ import assert from 'node:assert/strict'; import { AnkiConnectClient } from './anki-connect'; import { setLogLevel } from './logger'; -test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => { - const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as { - client: { - defaults: { - httpAgent?: { options?: { keepAlive?: boolean } }; - httpsAgent?: { options?: { keepAlive?: boolean } }; - }; - }; - }; - - assert.equal(client.client.defaults.httpAgent?.options?.keepAlive, false); - assert.equal(client.client.defaults.httpsAgent?.options?.keepAlive, false); -}); - test('AnkiConnectClient includes action name in retry logs', async () => { const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as { client: { post: (url: string, body: unknown, options: unknown) => Promise }; diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 4370446d..11ac03c6 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -6,7 +6,6 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { AnkiIntegration } from './anki-integration'; -import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; import type { MediaInput } from './media-input'; import { AnkiConnectConfig } from './types'; @@ -20,13 +19,108 @@ type TestOverlayNotificationPayload = { actions?: Array<{ id: string; label: string; noteId?: number }>; }; -interface IntegrationTestContext { - integration: AnkiIntegration; - calls: { - findNotes: number; - notesInfo: number; +type GenerateAudioFn = ( + path: MediaInput, + startTime: number, + endTime: number, + audioPadding?: number, + audioStreamIndex?: number, + normalizeAudio?: boolean, + volumeScale?: number, +) => Promise; + +type PendingYoutubeJob = { + sourceUrl: string; + noteId: number; + startTime: number; + endTime: number; + label: string | number; + audioStreamIndex?: number; + audioFieldName?: string; + imageFieldName?: string; + generateAudio: boolean; + generateImage: boolean; + volumeScale?: number; +}; + +/** + * The private members these tests swap or call. AnkiIntegration has no constructor seam for its + * AnkiConnect client or media generator, so tests replace them after construction. + */ +type IntegrationInternals = { + client: { + findNotes?: () => Promise; + notesInfo?: (noteIds: number[]) => Promise; + updateNoteFields?: (noteId: number, fields: Record) => Promise; + storeMediaFile?: (filename: string, data: Buffer) => Promise; }; - stateDir: string; + mediaGenerator: { + generateAudio?: GenerateAudioFn; + generateScreenshot?: (path: MediaInput) => Promise; + generateNotificationIcon?: (videoPath: string, timestamp: number) => Promise; + writeNotificationIconToFile?: (iconBuffer: Buffer, noteId: number) => string; + scheduleNotificationIconCleanup?: (filePath: string) => void; + }; + config: AnkiConnectConfig; + cardCreationService: { createSentenceCard: () => Promise }; + fieldGroupingService: { triggerFieldGroupingForLastAddedCard: () => Promise }; + showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise; + beginUpdateProgress: (message: string) => void; + endUpdateProgress: () => void; + processSentence: (sentence: string, fields: Record) => string; + processSentenceFurigana: (sentence: string, fields: Record) => string; + queuePendingYoutubeMediaUpdate: (job: PendingYoutubeJob) => void; + queuePendingYoutubeMediaUpdateForNote: (job: { + noteId: number; + noteInfo: { noteId: number; fields: Record }; + label: string | number; + }) => Promise; + generateAudio: () => Promise; + formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; + appendKnownWordsFromNoteInfo: (noteInfo: { + noteId: number; + fields: Record; + }) => void; + rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void; +}; + +/** Named stand-ins for AnkiIntegration's long positional constructor arguments. */ +type IntegrationHooks = { + mpvClient?: Record; + osd?: (text: string) => void; + desktop?: (title: string, options: { body?: string; icon?: string }) => void; + overlay?: (payload: TestOverlayNotificationPayload) => void; + dismissOverlay?: (id: string) => void; + knownWordCacheStatePath?: string; + getCachedMediaPath?: () => Promise; + shouldRequireRemoteMediaCache?: () => boolean; + getYoutubeMediaSourceUrl?: () => string | null; +}; + +function createIntegration( + config: Partial, + hooks: IntegrationHooks = {}, +): AnkiIntegration { + return new AnkiIntegration( + config as AnkiConnectConfig, + {} as never, + (hooks.mpvClient ?? {}) as never, + hooks.osd, + hooks.desktop as never, + undefined, + hooks.knownWordCacheStatePath, + {}, + undefined, + hooks.overlay as never, + hooks.getCachedMediaPath, + hooks.shouldRequireRemoteMediaCache, + hooks.getYoutubeMediaSourceUrl, + hooks.dismissOverlay, + ); +} + +function internalsOf(integration: AnkiIntegration): IntegrationInternals { + return integration as unknown as IntegrationInternals; } function describeMediaInputForTest(input: MediaInput): string { @@ -36,412 +130,199 @@ function describeMediaInputForTest(input: MediaInput): string { return `${input.path}:${input.source ?? 'raw'}`; } -function createIntegrationTestContext( - options: { - highlightEnabled?: boolean; - nPlusOneEnabled?: boolean; - onFindNotes?: () => Promise; - onNotesInfo?: () => Promise; - stateDirPrefix?: string; - } = {}, -): IntegrationTestContext { - const calls = { - findNotes: 0, - notesInfo: 0, - }; +// --- Known-word cache ------------------------------------------------------- - const stateDir = fs.mkdtempSync( - path.join(os.tmpdir(), options.stateDirPrefix ?? 'subminer-anki-integration-'), - ); - const knownWordCacheStatePath = path.join(stateDir, 'known-words-cache.json'); +type KnownWordsOptions = { + highlightEnabled?: boolean; + nPlusOneEnabled?: boolean; + onFindNotes?: () => Promise; +}; - const client = { - findNotes: async () => { - calls.findNotes += 1; - if (options.onFindNotes) { - return options.onFindNotes(); - } - return [] as number[]; - }, - notesInfo: async () => { - calls.notesInfo += 1; - if (options.onNotesInfo) { - return options.onNotesInfo(); - } - return [] as unknown[]; - }, - } as { - findNotes: () => Promise; - notesInfo: () => Promise; - }; +type KnownWordsContext = { + integration: AnkiIntegration; + calls: { findNotes: number; notesInfo: number }; +}; - const integration = new AnkiIntegration( - { - knownWords: { - highlightEnabled: options.highlightEnabled ?? true, +/** Runs `run` against an integration whose AnkiConnect client is a counting fake; cleans up its cache dir. */ +async function withKnownWordsIntegration( + options: KnownWordsOptions, + run: (ctx: KnownWordsContext) => Promise | void, +): Promise { + const calls = { findNotes: 0, notesInfo: 0 }; + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anki-integration-')); + + try { + const integration = createIntegration( + { + knownWords: { highlightEnabled: options.highlightEnabled ?? true }, + nPlusOne: + options.nPlusOneEnabled === undefined ? undefined : { enabled: options.nPlusOneEnabled }, }, - nPlusOne: - options.nPlusOneEnabled === undefined - ? undefined - : { - enabled: options.nPlusOneEnabled, - }, - }, - {} as never, - {} as never, - undefined, - undefined, - undefined, - knownWordCacheStatePath, - ); - - const integrationWithClient = integration as unknown as { - client: { - findNotes: () => Promise; - notesInfo: () => Promise; - }; - }; - integrationWithClient.client = client; - - const privateState = integration as unknown as { - knownWordsScope: string; - knownWordsLastRefreshedAtMs: number; - }; - privateState.knownWordsScope = 'all'; - privateState.knownWordsLastRefreshedAtMs = Date.now(); - - return { - integration, - calls, - stateDir, - }; -} - -function cleanupIntegrationTestContext(ctx: IntegrationTestContext): void { - fs.rmSync(ctx.stateDir, { recursive: true, force: true }); -} - -function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null { - const exact = availableFieldNames.find((name) => name === preferredName); - if (exact) return exact; - - const lower = preferredName.toLowerCase(); - return availableFieldNames.find((name) => name.toLowerCase() === lower) ?? null; -} - -function createFieldGroupingMergeCollaborator(options?: { - config?: Partial; - currentSubtitleText?: string; - generatedMedia?: { - audioField?: string; - audioValue?: string; - imageField?: string; - imageValue?: string; - miscInfoValue?: string; - }; -}): FieldGroupingMergeCollaborator { - const config = { - fields: { - sentence: 'Sentence', - audio: 'ExpressionAudio', - image: 'Picture', - ...(options?.config?.fields ?? {}), - }, - ...(options?.config ?? {}), - } as AnkiConnectConfig; - - return new FieldGroupingMergeCollaborator({ - getConfig: () => config, - getEffectiveSentenceCardConfig: () => ({ - sentenceField: 'Sentence', - audioField: 'SentenceAudio', - fieldGroupingProvider: 'kiku' as const, - }), - getCurrentSubtitleText: () => options?.currentSubtitleText, - resolveFieldName, - resolveNoteFieldName: (noteInfo, preferredName) => { - if (!preferredName) return null; - return resolveFieldName(Object.keys(noteInfo.fields), preferredName); - }, - extractFields: (fields) => { - const result: Record = {}; - for (const [key, value] of Object.entries(fields)) { - result[key.toLowerCase()] = value.value || ''; - } - return result; - }, - processSentence: (mpvSentence) => `${mpvSentence}::processed`, - generateMediaForMerge: async () => options?.generatedMedia ?? {}, - warnFieldParseOnce: () => undefined, - }); -} - -test('AnkiIntegration.refreshKnownWordCache bypasses stale checks', async () => { - const ctx = createIntegrationTestContext(); - - try { - await ctx.integration.refreshKnownWordCache(); - - assert.equal(ctx.calls.findNotes, 1); - assert.equal(ctx.calls.notesInfo, 0); - } finally { - cleanupIntegrationTestContext(ctx); - } -}); - -test('AnkiIntegration.refreshKnownWordCache notifies annotation cache listeners', async () => { - const ctx = createIntegrationTestContext({ - stateDirPrefix: 'subminer-anki-integration-refresh-notify-', - }); - let notifications = 0; - - try { - ctx.integration.setKnownWordCacheUpdatedCallback(() => { - notifications += 1; - }); - - await ctx.integration.refreshKnownWordCache(); - - assert.equal(notifications, 1); - } finally { - cleanupIntegrationTestContext(ctx); - } -}); - -test('AnkiIntegration.refreshKnownWordCache notifies when n+1 is enabled without highlights', async () => { - const ctx = createIntegrationTestContext({ - highlightEnabled: false, - nPlusOneEnabled: true, - stateDirPrefix: 'subminer-anki-integration-nplusone-notify-', - }); - let notifications = 0; - - try { - ctx.integration.setKnownWordCacheUpdatedCallback(() => { - notifications += 1; - }); - - await ctx.integration.refreshKnownWordCache(); - - assert.equal(ctx.calls.findNotes, 1); - assert.equal(notifications, 1); - } finally { - cleanupIntegrationTestContext(ctx); - } -}); - -test('AnkiIntegration.refreshKnownWordCache skips work when highlight mode is disabled', async () => { - const ctx = createIntegrationTestContext({ - highlightEnabled: false, - stateDirPrefix: 'subminer-anki-integration-disabled-', - }); - - try { - await ctx.integration.refreshKnownWordCache(); - - assert.equal(ctx.calls.findNotes, 0); - assert.equal(ctx.calls.notesInfo, 0); - } finally { - cleanupIntegrationTestContext(ctx); - } -}); - -test('AnkiIntegration notifies when mined note info updates known words', () => { - const ctx = createIntegrationTestContext({ - stateDirPrefix: 'subminer-anki-integration-known-update-', - }); - let notifications = 0; - - try { - const integrationState = ctx.integration as unknown as { - config: AnkiConnectConfig; - appendKnownWordsFromNoteInfo: (noteInfo: { - noteId: number; - fields: Record; - }) => void; - }; - integrationState.config.deck = 'Mining'; - integrationState.config.knownWords = { - ...integrationState.config.knownWords, - decks: { - Mining: ['Word'], + { knownWordCacheStatePath: path.join(stateDir, 'known-words-cache.json') }, + ); + internalsOf(integration).client = { + findNotes: async () => { + calls.findNotes += 1; + return options.onFindNotes ? options.onFindNotes() : []; + }, + notesInfo: async () => { + calls.notesInfo += 1; + return []; }, }; - ctx.integration.setKnownWordCacheUpdatedCallback(() => { - notifications += 1; - }); - integrationState.appendKnownWordsFromNoteInfo({ - noteId: 42, - fields: { - Word: { value: '食べる' }, - }, - }); - - assert.equal(ctx.integration.isKnownWord('食べる'), true); - assert.equal(notifications, 1); + await run({ integration, calls }); } finally { - cleanupIntegrationTestContext(ctx); + fs.rmSync(stateDir, { recursive: true, force: true }); } -}); +} + +const KNOWN_WORD_REFRESH_CASES: Array<{ + name: string; + options: KnownWordsOptions; + expected: { findNotes: number; notifications: number }; +}> = [ + { + name: 'refreshes once, skipping stale checks, and notifies annotation cache listeners', + options: {}, + expected: { findNotes: 1, notifications: 1 }, + }, + { + name: 'notifies when n+1 is enabled without highlights', + options: { highlightEnabled: false, nPlusOneEnabled: true }, + expected: { findNotes: 1, notifications: 1 }, + }, + { + name: 'skips work when highlight mode is disabled', + options: { highlightEnabled: false }, + expected: { findNotes: 0, notifications: 0 }, + }, +]; + +for (const c of KNOWN_WORD_REFRESH_CASES) { + test(`AnkiIntegration.refreshKnownWordCache ${c.name}`, async () => { + await withKnownWordsIntegration(c.options, async ({ integration, calls }) => { + let notifications = 0; + integration.setKnownWordCacheUpdatedCallback(() => { + notifications += 1; + }); + + await integration.refreshKnownWordCache(); + + assert.equal(calls.findNotes, c.expected.findNotes); + assert.equal(calls.notesInfo, 0); + assert.equal(notifications, c.expected.notifications); + }); + }); +} test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes', async () => { - let releaseFindNotes: (() => void) | undefined; - const findNotesPromise = new Promise((resolve) => { + let releaseFindNotes: () => void = () => {}; + const findNotesGate = new Promise((resolve) => { releaseFindNotes = resolve; }); - const ctx = createIntegrationTestContext({ - onFindNotes: async () => { - await findNotesPromise; - return [] as number[]; + await withKnownWordsIntegration( + { + onFindNotes: async () => { + await findNotesGate; + return []; + }, }, - stateDirPrefix: 'subminer-anki-integration-concurrent-', + async ({ integration, calls }) => { + const first = integration.refreshKnownWordCache(); + await Promise.resolve(); + const second = integration.refreshKnownWordCache(); + releaseFindNotes(); + await Promise.all([first, second]); + + assert.equal(calls.findNotes, 1); + assert.equal(calls.notesInfo, 0); + }, + ); +}); + +test('AnkiIntegration notifies when mined note info updates known words', async () => { + await withKnownWordsIntegration({}, ({ integration }) => { + let notifications = 0; + const internals = internalsOf(integration); + internals.config.deck = 'Mining'; + internals.config.knownWords = { + ...internals.config.knownWords, + decks: { Mining: ['Word'] }, + }; + integration.setKnownWordCacheUpdatedCallback(() => { + notifications += 1; + }); + + internals.appendKnownWordsFromNoteInfo({ noteId: 42, fields: { Word: { value: '食べる' } } }); + + assert.equal(integration.isKnownWord('食べる'), true); + assert.equal(notifications, 1); }); - - const first = ctx.integration.refreshKnownWordCache(); - await Promise.resolve(); - const second = ctx.integration.refreshKnownWordCache(); - - if (releaseFindNotes !== undefined) { - releaseFindNotes(); - } - - await Promise.all([first, second]); - - try { - assert.equal(ctx.calls.findNotes, 1); - assert.equal(ctx.calls.notesInfo, 0); - } finally { - cleanupIntegrationTestContext(ctx); - } }); test('AnkiIntegration resolves merged-away note ids to the kept note id', () => { - const ctx = createIntegrationTestContext({ - stateDirPrefix: 'subminer-anki-integration-note-redirect-', + const integration = createIntegration({}); + const internals = internalsOf(integration); + internals.rememberMergedNoteIds(111, 222); + internals.rememberMergedNoteIds(222, 333); + + assert.equal(integration.resolveCurrentNoteId(111), 333); + assert.equal(integration.resolveCurrentNoteId(222), 333); + assert.equal(integration.resolveCurrentNoteId(333), 333); + assert.equal(integration.resolveCurrentNoteId(444), 444); +}); + +// --- Sentence highlighting -------------------------------------------------- + +const MPV_SENTENCE = '先日 貴様らが潜入した キールダンジョンから―'; + +const HIGHLIGHT_CASES = [ + { + name: 'highlights mined word from expression field when sentence has no bold marker', + highlightWord: true, + noteSentence: MPV_SENTENCE, + expected: '先日 貴様らが潜入した キールダンジョンから―', + }, + { + name: 'keeps existing Yomitan bold target when present', + highlightWord: true, + noteSentence: '潜入した', + expected: '先日 貴様らが潜入した キールダンジョンから―', + }, + { + name: 'leaves sentence plain when word highlighting is disabled', + highlightWord: false, + noteSentence: '潜入', + expected: MPV_SENTENCE, + }, +]; + +for (const c of HIGHLIGHT_CASES) { + test(`AnkiIntegration ${c.name}`, () => { + const integration = createIntegration({ + fields: { word: 'Expression', sentence: 'Sentence' }, + behavior: { highlightWord: c.highlightWord }, + }); + + const processed = internalsOf(integration).processSentence(MPV_SENTENCE, { + expression: '潜入', + sentence: c.noteSentence, + }); + + assert.equal(processed, c.expected); }); - - try { - const integrationWithInternals = ctx.integration as unknown as { - rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void; - }; - integrationWithInternals.rememberMergedNoteIds(111, 222); - integrationWithInternals.rememberMergedNoteIds(222, 333); - - assert.equal(ctx.integration.resolveCurrentNoteId(111), 333); - assert.equal(ctx.integration.resolveCurrentNoteId(222), 333); - assert.equal(ctx.integration.resolveCurrentNoteId(333), 333); - assert.equal(ctx.integration.resolveCurrentNoteId(444), 444); - } finally { - cleanupIntegrationTestContext(ctx); - } -}); - -function processSentenceWithConfig( - config: Partial, - mpvSentence: string, - noteFields: Record, -): string { - const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never); - return ( - integration as unknown as { - processSentence: (sentence: string, fields: Record) => string; - } - ).processSentence(mpvSentence, noteFields); } -function processSentenceFuriganaWithConfig( - config: Partial, - sentenceFurigana: string, - noteFields: Record, -): string { - const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never); - return ( - integration as unknown as { - processSentenceFurigana: (sentence: string, fields: Record) => string; - } - ).processSentenceFurigana(sentenceFurigana, noteFields); -} - -test('AnkiIntegration highlights mined word from expression field when sentence has no bold marker', () => { - const processed = processSentenceWithConfig( - { - fields: { - word: 'Expression', - sentence: 'Sentence', - }, - behavior: { - highlightWord: true, - }, - }, - '先日 貴様らが潜入した キールダンジョンから―', - { - expression: '潜入', - sentence: '先日 貴様らが潜入した キールダンジョンから―', - }, - ); - - assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―'); -}); - -test('AnkiIntegration keeps existing Yomitan bold target when present', () => { - const processed = processSentenceWithConfig( - { - fields: { - word: 'Expression', - sentence: 'Sentence', - }, - behavior: { - highlightWord: true, - }, - }, - '先日 貴様らが潜入した キールダンジョンから―', - { - expression: '潜入', - sentence: '潜入した', - }, - ); - - assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―'); -}); - -test('AnkiIntegration leaves sentence plain when word highlighting is disabled', () => { - const processed = processSentenceWithConfig( - { - fields: { - word: 'Expression', - sentence: 'Sentence', - }, - behavior: { - highlightWord: false, - }, - }, - '先日 貴様らが潜入した キールダンジョンから―', - { - expression: '潜入', - sentence: '潜入', - }, - ); - - assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―'); -}); - test('AnkiIntegration highlights mined word in sentence furigana field', () => { - const processed = processSentenceFuriganaWithConfig( - { - fields: { - word: 'Expression', - sentence: 'Sentence', - }, - behavior: { - highlightWord: true, - }, - }, + const integration = createIntegration({ + fields: { word: 'Expression', sentence: 'Sentence' }, + behavior: { highlightWord: true }, + }); + + const processed = internalsOf(integration).processSentenceFurigana( '不思議ふしぎな特技とくぎを', - { - expression: '特技', - sentence: '不思議な特技を', - }, + { expression: '特技', sentence: '不思議な特技を' }, ); assert.equal( @@ -450,25 +331,7 @@ test('AnkiIntegration highlights mined word in sentence furigana field', () => { ); }); -test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => { - const integration = new AnkiIntegration( - { - enabled: true, - proxy: { - enabled: false, - }, - } as never, - {} as never, - {} as never, - ); - - const privateState = integration as unknown as { - runtime: { - proxyServer: unknown | null; - }; - }; - assert.equal(privateState.runtime.proxyServer, null); -}); +// --- Proxy, field grouping, notifications ----------------------------------- test('AnkiIntegration reports an occupied proxy address through its notification seam', async () => { const occupiedServer = http.createServer(); @@ -478,7 +341,7 @@ test('AnkiIntegration reports an occupied proxy address through its notification assert.ok(occupiedAddress && typeof occupiedAddress === 'object'); const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anki-proxy-collision-')); const overlayNotifications: TestOverlayNotificationPayload[] = []; - const integration = new AnkiIntegration( + const integration = createIntegration( { enabled: true, url: 'http://127.0.0.1:8765', @@ -488,26 +351,15 @@ test('AnkiIntegration reports an occupied proxy address through its notification port: occupiedAddress.port, upstreamUrl: 'http://127.0.0.1:8765', }, - behavior: { - notificationType: 'overlay', + behavior: { notificationType: 'overlay' }, + knownWords: { highlightEnabled: false }, + nPlusOne: { enabled: false }, + }, + { + knownWordCacheStatePath: path.join(stateDir, 'known-words-cache.json'), + overlay: (payload) => { + overlayNotifications.push(payload); }, - knownWords: { - highlightEnabled: false, - }, - nPlusOne: { - enabled: false, - }, - } as never, - {} as never, - {} as never, - undefined, - undefined, - undefined, - path.join(stateDir, 'known-words-cache.json'), - {}, - undefined, - (payload) => { - overlayNotifications.push(payload as TestOverlayNotificationPayload); }, ); @@ -531,31 +383,11 @@ test('AnkiIntegration reports an occupied proxy address through its notification }); test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => { - const integration = new AnkiIntegration( - { - isKiku: { - enabled: true, - fieldGrouping: 'manual', - }, - } as never, - {} as never, - {} as never, - ); - + const integration = createIntegration({ + isKiku: { enabled: true, fieldGrouping: 'manual' }, + }); let groupingTriggered = 0; - const internals = integration as unknown as { - cardCreationService: { - createSentenceCard: ( - sentence: string, - startTime: number, - endTime: number, - secondarySubText?: string, - ) => Promise; - }; - fieldGroupingService: { - triggerFieldGroupingForLastAddedCard: () => Promise; - }; - }; + const internals = internalsOf(integration); internals.cardCreationService = { createSentenceCard: async () => { integration.trackDuplicateNoteIdsForNote(42, [7]); @@ -574,98 +406,58 @@ test('AnkiIntegration triggers field grouping after a local duplicate sentence c test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => { const osdMessages: string[] = []; - const integration = new AnkiIntegration( - { - behavior: { - notificationType: 'osd', - }, - }, - {} as never, - {} as never, - (text) => { - osdMessages.push(text); - }, + const integration = createIntegration( + { behavior: { notificationType: 'osd' } }, + { osd: (text) => osdMessages.push(text) }, ); - await ( - integration as unknown as { - showNotification: ( - noteId: number, - label: string | number, - errorSuffix?: string, - ) => Promise; - } - ).showNotification(42, 'taberu', 'image failed'); + await internalsOf(integration).showNotification(42, 'taberu', 'image failed'); assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']); }); -test('AnkiIntegration applies ready YouTube cache media to every queued note id', async () => { +test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => { const osdMessages: string[] = []; - const updatedNotes: Array<{ noteId: number; fields: Record }> = []; - const storedMedia: string[] = []; - const mediaInputs: string[] = []; - - const integration = new AnkiIntegration( + const desktopMessages: string[] = []; + const overlayMessages: string[] = []; + const integration = createIntegration( + { behavior: { notificationType: 'both' } }, { - fields: { - audio: 'ExpressionAudio', - image: 'Picture', - }, - media: { - imageFormat: 'jpg', - }, - behavior: { - notificationType: 'osd', - }, - }, - {} as never, - {} as never, - (text) => { - osdMessages.push(text); + osd: (text) => osdMessages.push(text), + desktop: (title, options) => desktopMessages.push(`${title}:${options.body ?? ''}`), + overlay: (payload) => + overlayMessages.push(`${payload.title}:${payload.body ?? ''}:${payload.variant ?? ''}`), }, ); - const internals = integration as unknown as { - client: { - notesInfo: (noteIds: number[]) => Promise; - updateNoteFields: (noteId: number, fields: Record) => Promise; - storeMediaFile: (filename: string, data: Buffer) => Promise; - }; - mediaGenerator: { - generateAudio: ( - path: MediaInput, - startTime: number, - endTime: number, - audioPadding?: number, - audioStreamIndex?: number, - normalizeAudio?: boolean, - volumeScale?: number, - ) => Promise; - generateScreenshot: (path: MediaInput) => Promise; - }; - queuePendingYoutubeMediaUpdate: (job: { - sourceUrl: string; - noteId: number; - startTime: number; - endTime: number; - label: string | number; - audioStreamIndex?: number; - audioFieldName?: string; - imageFieldName?: string; - generateAudio: boolean; - generateImage: boolean; - volumeScale?: number; - }) => void; - }; + assert.equal(await integration.createSentenceCard('食べる', 0, 1), false); + + assert.deepEqual(osdMessages, []); + assert.deepEqual(overlayMessages, ['SubMiner:No video loaded:info']); + assert.deepEqual(desktopMessages, ['SubMiner:No video loaded']); +}); + +// --- YouTube media cache ---------------------------------------------------- + +/** + * Installs a fake AnkiConnect client whose notes carry empty values for `noteFieldNames`, plus a + * media generator that succeeds unless overridden. Records every note update and stored file. + */ +function installFakeAnki( + integration: AnkiIntegration, + options: { + noteFieldNames: string[]; + mediaGenerator?: IntegrationInternals['mediaGenerator']; + }, +) { + const updatedNotes: Array<{ noteId: number; fields: Record }> = []; + const storedMedia: string[] = []; + const internals = internalsOf(integration); internals.client = { notesInfo: async (noteIds) => noteIds.map((noteId) => ({ noteId, - fields: { - ExpressionAudio: { value: '' }, - Picture: { value: '' }, - }, + fields: Object.fromEntries(options.noteFieldNames.map((name) => [name, { value: '' }])), })), updateNoteFields: async (noteId, fields) => { updatedNotes.push({ noteId, fields }); @@ -675,51 +467,68 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id' }, }; internals.mediaGenerator = { - generateAudio: async ( - mediaPath, - _startTime, - _endTime, - _audioPadding, - audioStreamIndex, - _normalizeAudio, - volumeScale, - ) => { - mediaInputs.push( - `audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}:${volumeScale}`, - ); - return Buffer.from('audio'); - }, - generateScreenshot: async (mediaPath) => { - mediaInputs.push(`image:${describeMediaInputForTest(mediaPath)}`); - return Buffer.from('image'); - }, + generateAudio: async () => Buffer.from('audio'), + generateScreenshot: async () => Buffer.from('image'), + ...options.mediaGenerator, }; - internals.queuePendingYoutubeMediaUpdate({ + return { updatedNotes, storedMedia }; +} + +function youtubeJob(overrides: Partial & { noteId: number }): PendingYoutubeJob { + return { sourceUrl: 'https://www.youtube.com/watch?v=abc123', - noteId: 101, startTime: 10, endTime: 12, - label: 'first', - audioStreamIndex: 22, + label: String(overrides.noteId), audioFieldName: 'SentenceAudio', imageFieldName: 'Picture', generateAudio: true, generateImage: true, - volumeScale: 0.25, - }); - internals.queuePendingYoutubeMediaUpdate({ - sourceUrl: 'https://youtu.be/abc123', - noteId: 202, - startTime: 20, - endTime: 22, - label: 'second', - audioStreamIndex: 23, - audioFieldName: 'SentenceAudio', - imageFieldName: 'Picture', - generateAudio: true, - generateImage: true, - volumeScale: 0.8, + ...overrides, + }; +} + +test('AnkiIntegration applies ready YouTube cache media to every queued note id', async () => { + const osdMessages: string[] = []; + const mediaInputs: string[] = []; + const integration = createIntegration( + { + fields: { audio: 'ExpressionAudio', image: 'Picture' }, + media: { imageFormat: 'jpg' }, + behavior: { notificationType: 'osd' }, + }, + { osd: (text) => osdMessages.push(text) }, + ); + const { updatedNotes, storedMedia } = installFakeAnki(integration, { + noteFieldNames: ['ExpressionAudio', 'Picture'], + mediaGenerator: { + generateAudio: async (mediaPath, _start, _end, _padding, audioStreamIndex, _norm, scale) => { + mediaInputs.push( + `audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}:${scale}`, + ); + return Buffer.from('audio'); + }, + generateScreenshot: async (mediaPath) => { + mediaInputs.push(`image:${describeMediaInputForTest(mediaPath)}`); + return Buffer.from('image'); + }, + }, }); + const internals = internalsOf(integration); + internals.queuePendingYoutubeMediaUpdate( + youtubeJob({ noteId: 101, label: 'first', audioStreamIndex: 22, volumeScale: 0.25 }), + ); + internals.queuePendingYoutubeMediaUpdate( + youtubeJob({ + noteId: 202, + sourceUrl: 'https://youtu.be/abc123', + startTime: 20, + endTime: 22, + label: 'second', + audioStreamIndex: 23, + volumeScale: 0.8, + }), + ); await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv'); @@ -733,105 +542,48 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id' updatedNotes.map((update) => update.noteId), [101, 202], ); - const firstUpdate = updatedNotes[0]; - const secondUpdate = updatedNotes[1]; - assert.ok(firstUpdate); - assert.ok(secondUpdate); - assert.match(firstUpdate.fields.SentenceAudio ?? '', /^\[sound:audio_/); - assert.match(firstUpdate.fields.Picture ?? '', /^ message.includes('Queued YouTube media finished with 0 updated, 1 partial, and 0 failed.'), ), - true, ); }); test('AnkiIntegration queues YouTube media updates against recovered source URLs', async () => { - const updatedNotes: Array<{ noteId: number; fields: Record }> = []; - const storedMedia: string[] = []; const audioVolumeScales: Array = []; let mpvVolume = 30; - - const integration = new AnkiIntegration( + const integration = createIntegration( + { fields: { image: 'Picture' }, media: { imageFormat: 'jpg' } }, { - fields: { - image: 'Picture', - }, - media: { - imageFormat: 'jpg', - }, - }, - {} as never, - { - currentVideoPath: 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777', - currentSubStart: 10, - currentSubEnd: 12, - currentTimePos: 11, - requestProperty: async (name: string) => { - assert.equal(name, 'volume'); - return mpvVolume; - }, - } as never, - () => undefined, - undefined, - undefined, - undefined, - {}, - undefined, - undefined, - async () => null, - () => true, - () => 'https://www.youtube.com/watch?v=abc123', - ); - - const internals = integration as unknown as { - client: { - notesInfo: (noteIds: number[]) => Promise; - updateNoteFields: (noteId: number, fields: Record) => Promise; - storeMediaFile: (filename: string) => Promise; - }; - mediaGenerator: { - generateAudio: ( - path: MediaInput, - startTime: number, - endTime: number, - audioPadding?: number, - audioStreamIndex?: number, - normalizeAudio?: boolean, - volumeScale?: number, - ) => Promise; - generateScreenshot: () => Promise; - }; - queuePendingYoutubeMediaUpdateForNote: (job: { - noteId: number; - noteInfo: { noteId: number; fields: Record }; - label: string | number; - }) => Promise; - showNotification: () => Promise; - }; - internals.client = { - notesInfo: async (noteIds) => - noteIds.map((noteId) => ({ - noteId, - fields: { - SentenceAudio: { value: '' }, - Picture: { value: '' }, + mpvClient: { + currentVideoPath: + 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777', + currentSubStart: 10, + currentSubEnd: 12, + currentTimePos: 11, + requestProperty: async (name: string) => { + assert.equal(name, 'volume'); + return mpvVolume; }, - })), - updateNoteFields: async (noteId, fields) => { - updatedNotes.push({ noteId, fields }); + }, + osd: () => undefined, + getCachedMediaPath: async () => null, + shouldRequireRemoteMediaCache: () => true, + getYoutubeMediaSourceUrl: () => 'https://www.youtube.com/watch?v=abc123', }, - storeMediaFile: async (filename) => { - storedMedia.push(filename); + ); + const { updatedNotes, storedMedia } = installFakeAnki(integration, { + noteFieldNames: ['SentenceAudio', 'Picture'], + mediaGenerator: { + generateAudio: async (_path, _start, _end, _padding, _stream, _norm, volumeScale) => { + audioVolumeScales.push(volumeScale); + return Buffer.from('audio'); + }, }, - }; - internals.mediaGenerator = { - generateAudio: async ( - _path, - _startTime, - _endTime, - _audioPadding, - _audioStreamIndex, - _normalizeAudio, - volumeScale, - ) => { - audioVolumeScales.push(volumeScale); - return Buffer.from('audio'); - }, - generateScreenshot: async () => Buffer.from('image'), - }; + }); + const internals = internalsOf(integration); internals.showNotification = async () => undefined; const queued = await internals.queuePendingYoutubeMediaUpdateForNote({ noteId: 404, noteInfo: { noteId: 404, - fields: { - ExpressionAudio: { value: '' }, - Picture: { value: '' }, - }, + fields: { ExpressionAudio: { value: '' }, Picture: { value: '' } }, }, label: 'resolved source', }); + // The volume is captured when the job is queued, not when the cache becomes ready. mpvVolume = 90; await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv'); @@ -975,67 +663,46 @@ test('AnkiIntegration passes audio normalization config for ready cached YouTube volumeScale?: number; }> = []; const requestedProperties: string[] = []; - - const integration = new AnkiIntegration( + const integration = createIntegration( + { media: { audioPadding: 0, normalizeAudio: false } }, { - media: { - audioPadding: 0, - normalizeAudio: false, + mpvClient: { + currentVideoPath: 'https://www.youtube.com/watch?v=abc123', + currentAudioStreamIndex: 0, + currentSubStart: 10, + currentSubEnd: 12, + currentTimePos: 11, + requestProperty: async (name: string) => { + requestedProperties.push(name); + return 55; + }, }, + osd: () => undefined, + getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/media.mkv', + shouldRequireRemoteMediaCache: () => true, }, - {} as never, - { - currentVideoPath: 'https://www.youtube.com/watch?v=abc123', - currentAudioStreamIndex: 0, - currentSubStart: 10, - currentSubEnd: 12, - currentTimePos: 11, - requestProperty: async (name: string) => { - requestedProperties.push(name); - return 55; - }, - } as never, - () => undefined, - undefined, - undefined, - undefined, - {}, - undefined, - undefined, - async () => '/tmp/subminer-youtube-media-cache/media.mkv', - () => true, ); - - const internals = integration as unknown as { - mediaGenerator: { - generateAudio: ( - path: { path: string }, - startTime: number, - endTime: number, - audioPadding?: number, - audioStreamIndex?: number, - normalizeAudio?: boolean, - volumeScale?: number, - ) => Promise; - }; - generateAudio: () => Promise; - }; - internals.mediaGenerator = { + internalsOf(integration).mediaGenerator = { generateAudio: async ( - path, - _startTime, - _endTime, - _audioPadding, + mediaPath, + _start, + _end, + _padding, audioStreamIndex, normalizeAudio, volumeScale, ) => { - audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio, volumeScale }); + audioCalls.push({ + path: typeof mediaPath === 'string' ? mediaPath : mediaPath.path, + audioStreamIndex, + normalizeAudio, + volumeScale, + }); return Buffer.from('audio'); }, }; - await internals.generateAudio(); + await internalsOf(integration).generateAudio(); assert.deepEqual(requestedProperties, ['volume']); assert.deepEqual(audioCalls, [ @@ -1048,172 +715,129 @@ test('AnkiIntegration passes audio normalization config for ready cached YouTube ]); }); -test('AnkiIntegration announces ready YouTube cache when no queued notes exist', async () => { - const overlayNotifications: TestOverlayNotificationPayload[] = []; +const NO_QUEUED_CACHE_READY_CASES: Array<{ + name: string; + options?: { notifyNoQueued: boolean }; + expectedBodies: string[]; +}> = [ + { + name: 'announces ready YouTube cache when no queued notes exist', + expectedBodies: ['YouTube media cache ready.'], + }, + { + name: 'can let caller own no-queued YouTube cache ready notification', + options: { notifyNoQueued: false }, + expectedBodies: [], + }, +]; - const integration = new AnkiIntegration( - { - behavior: { - notificationType: 'overlay', - }, - }, - {} as never, - {} as never, - undefined, - undefined, - undefined, - undefined, - {}, - undefined, - (payload) => { - overlayNotifications.push(payload as TestOverlayNotificationPayload); - }, - ); +for (const c of NO_QUEUED_CACHE_READY_CASES) { + test(`AnkiIntegration ${c.name}`, async () => { + const overlayNotifications: TestOverlayNotificationPayload[] = []; + const integration = createIntegration( + { behavior: { notificationType: 'overlay' } }, + { overlay: (payload) => overlayNotifications.push(payload) }, + ); - await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv'); + await integration.handleYoutubeMediaCacheReady( + 'https://youtu.be/abc123', + '/tmp/media.mkv', + c.options, + ); - assert.equal(overlayNotifications.length, 1); - assert.equal(overlayNotifications[0]?.title, 'SubMiner'); - assert.equal(overlayNotifications[0]?.body, 'YouTube media cache ready.'); -}); - -test('AnkiIntegration can let caller own no-queued YouTube cache ready notification', async () => { - const overlayNotifications: TestOverlayNotificationPayload[] = []; - - const integration = new AnkiIntegration( - { - behavior: { - notificationType: 'overlay', - }, - }, - {} as never, - {} as never, - undefined, - undefined, - undefined, - undefined, - {}, - undefined, - (payload) => { - overlayNotifications.push(payload as TestOverlayNotificationPayload); - }, - ); - - await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv', { - notifyNoQueued: false, + assert.deepEqual( + overlayNotifications.map((notification) => [notification.title, notification.body]), + c.expectedBodies.map((body) => ['SubMiner', body]), + ); }); +} - assert.deepEqual(overlayNotifications, []); -}); +// --- Overlay / desktop notification surfaces -------------------------------- -test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => { +/** + * Integration in 'both' notification mode with a fake media generator whose temp-icon write is + * `writeNotificationIconToFile`. Returns the surfaces' captured output. + */ +function createNotificationImageFixture( + writeNotificationIconToFile: (iconBuffer: Buffer, noteId: number) => string, +) { const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = []; const overlayNotifications: TestOverlayNotificationPayload[] = []; const generatedFrom: Array<{ videoPath: string; timestamp: number }> = []; const cleanupPaths: string[] = []; - const notificationIconPath = path.join(os.tmpdir(), 'subminer-notification-icon.png'); - - const integration = new AnkiIntegration( + const integration = createIntegration( + { behavior: { notificationType: 'both' } }, { - behavior: { - notificationType: 'both', + mpvClient: { currentVideoPath: '/tmp/show.mkv', currentTimePos: 123.45 }, + desktop: (title, options) => { + desktopNotifications.push({ title, body: options.body, icon: options.icon }); }, - }, - {} as never, - { - currentVideoPath: '/tmp/show.mkv', - currentTimePos: 123.45, - } as never, - undefined, - (title, options) => { - desktopNotifications.push({ title, body: options.body, icon: options.icon }); - }, - undefined, - undefined, - {}, - undefined, - (payload) => { - overlayNotifications.push(payload as TestOverlayNotificationPayload); + overlay: (payload) => overlayNotifications.push(payload), }, ); - - ( - integration as unknown as { - mediaGenerator: { - generateNotificationIcon: (videoPath: string, timestamp: number) => Promise; - writeNotificationIconToFile: (iconBuffer: Buffer, noteId: number) => string; - scheduleNotificationIconCleanup: (filePath: string) => void; - }; - } - ).mediaGenerator = { + internalsOf(integration).mediaGenerator = { generateNotificationIcon: async (videoPath, timestamp) => { generatedFrom.push({ videoPath, timestamp }); return Buffer.from('png'); }, - writeNotificationIconToFile: (iconBuffer, noteId) => { - assert.equal(iconBuffer.toString(), 'png'); - assert.equal(noteId, 42); - return notificationIconPath; - }, + writeNotificationIconToFile, scheduleNotificationIconCleanup: (filePath) => { cleanupPaths.push(filePath); }, }; + return { integration, desktopNotifications, overlayNotifications, generatedFrom, cleanupPaths }; +} - await ( - integration as unknown as { - showNotification: (noteId: number, label: string | number) => Promise; - } - ).showNotification(42, '食べる'); +const PNG_DATA_URL = `data:image/png;base64,${Buffer.from('png').toString('base64')}`; - assert.deepEqual(generatedFrom, [{ videoPath: '/tmp/show.mkv', timestamp: 123.45 }]); - assert.equal(overlayNotifications.length, 1); - assert.equal(overlayNotifications[0]?.title, 'Anki Card Updated'); - assert.equal(overlayNotifications[0]?.body, 'Updated card: 食べる'); - assert.equal( - overlayNotifications[0]?.image, - `data:image/png;base64,${Buffer.from('png').toString('base64')}`, - ); - assert.deepEqual(overlayNotifications[0]?.actions, [ +test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => { + const notificationIconPath = path.join(os.tmpdir(), 'subminer-notification-icon.png'); + const fixture = createNotificationImageFixture((iconBuffer, noteId) => { + assert.equal(iconBuffer.toString(), 'png'); + assert.equal(noteId, 42); + return notificationIconPath; + }); + + await internalsOf(fixture.integration).showNotification(42, '食べる'); + + assert.deepEqual(fixture.generatedFrom, [{ videoPath: '/tmp/show.mkv', timestamp: 123.45 }]); + assert.equal(fixture.overlayNotifications.length, 1); + assert.equal(fixture.overlayNotifications[0]?.title, 'Anki Card Updated'); + assert.equal(fixture.overlayNotifications[0]?.body, 'Updated card: 食べる'); + assert.equal(fixture.overlayNotifications[0]?.image, PNG_DATA_URL); + assert.deepEqual(fixture.overlayNotifications[0]?.actions, [ { id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }, ]); - assert.deepEqual(desktopNotifications, [ - { - title: 'Anki Card Updated', - body: 'Updated card: 食べる', - icon: notificationIconPath, - }, + assert.deepEqual(fixture.desktopNotifications, [ + { title: 'Anki Card Updated', body: 'Updated card: 食べる', icon: notificationIconPath }, ]); - assert.deepEqual(cleanupPaths, [notificationIconPath]); + assert.deepEqual(fixture.cleanupPaths, [notificationIconPath]); +}); + +test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => { + const fixture = createNotificationImageFixture(() => { + throw new Error('disk full'); + }); + + await internalsOf(fixture.integration).showNotification(42, '食べる'); + + assert.equal(fixture.overlayNotifications[0]?.image, PNG_DATA_URL); + assert.deepEqual(fixture.desktopNotifications, [ + { title: 'Anki Card Updated', body: 'Updated card: 食べる', icon: undefined }, + ]); + assert.deepEqual(fixture.cleanupPaths, []); }); test('AnkiIntegration keeps overlay card-update progress visible until the terminal notification', async () => { const overlayNotifications: TestOverlayNotificationPayload[] = []; - const integration = new AnkiIntegration( - { - behavior: { - notificationType: 'overlay', - }, - }, - {} as never, - {} as never, - undefined, - undefined, - undefined, - undefined, - {}, - undefined, - (payload) => { - overlayNotifications.push(payload); - }, + const integration = createIntegration( + { behavior: { notificationType: 'overlay' } }, + { overlay: (payload) => overlayNotifications.push(payload) }, ); - const updateNotifications = integration as unknown as { - beginUpdateProgress: (message: string) => void; - showNotification: (noteId: number, label: string | number) => Promise; - }; + const internals = internalsOf(integration); - updateNotifications.beginUpdateProgress('Updating card'); - await updateNotifications.showNotification(42, '食べる'); + internals.beginUpdateProgress('Updating card'); + await internals.showNotification(42, '食べる'); assert.deepEqual( overlayNotifications.map(({ id, variant, persistent }) => ({ id, variant, persistent })), @@ -1227,343 +851,96 @@ test('AnkiIntegration keeps overlay card-update progress visible until the termi test('AnkiIntegration dismisses persistent overlay update progress when no terminal notification replaces it', () => { const overlayNotifications: TestOverlayNotificationPayload[] = []; const dismissedIds: string[] = []; - const integration = new AnkiIntegration( + const integration = createIntegration( + { behavior: { notificationType: 'overlay' } }, { - behavior: { - notificationType: 'overlay', - }, - }, - {} as never, - {} as never, - undefined, - undefined, - undefined, - undefined, - {}, - undefined, - (payload) => { - overlayNotifications.push(payload); - }, - undefined, - undefined, - undefined, - (id) => { - dismissedIds.push(id); + overlay: (payload) => overlayNotifications.push(payload), + dismissOverlay: (id) => dismissedIds.push(id), }, ); - const updateNotifications = integration as unknown as { - beginUpdateProgress: (message: string) => void; - endUpdateProgress: () => void; - }; + const internals = internalsOf(integration); - updateNotifications.beginUpdateProgress('Updating card'); - updateNotifications.endUpdateProgress(); + internals.beginUpdateProgress('Updating card'); + internals.endUpdateProgress(); assert.equal(overlayNotifications[0]?.persistent, true); assert.deepEqual(dismissedIds, ['anki-update-progress']); }); test('AnkiIntegration dismisses overlay update progress after notifications switch to OSD', () => { - const behavior: NonNullable = { - notificationType: 'overlay', - }; + const behavior: NonNullable = { notificationType: 'overlay' }; const dismissedIds: string[] = []; - const integration = new AnkiIntegration( + const integration = createIntegration( { behavior }, - {} as never, - {} as never, - undefined, - undefined, - undefined, - undefined, - {}, - undefined, - () => {}, - undefined, - undefined, - undefined, - (id) => { - dismissedIds.push(id); - }, + { overlay: () => {}, dismissOverlay: (id) => dismissedIds.push(id) }, ); - const updateNotifications = integration as unknown as { - beginUpdateProgress: (message: string) => void; - endUpdateProgress: () => void; - }; + const internals = internalsOf(integration); - updateNotifications.beginUpdateProgress('Updating card'); + internals.beginUpdateProgress('Updating card'); behavior.notificationType = 'osd'; - updateNotifications.endUpdateProgress(); + internals.endUpdateProgress(); assert.deepEqual(dismissedIds, ['anki-update-progress']); }); -test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => { - const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = []; - const overlayNotifications: TestOverlayNotificationPayload[] = []; - const cleanupPaths: string[] = []; +// --- Misc-info metadata ----------------------------------------------------- - const integration = new AnkiIntegration( - { - behavior: { - notificationType: 'both', - }, - }, - {} as never, - { - currentVideoPath: '/tmp/show.mkv', - currentTimePos: 123.45, - } as never, - undefined, - (title, options) => { - desktopNotifications.push({ title, body: options.body, icon: options.icon }); - }, - undefined, - undefined, - {}, - undefined, - (payload) => { - overlayNotifications.push(payload as TestOverlayNotificationPayload); - }, - ); - - ( - integration as unknown as { - mediaGenerator: { - generateNotificationIcon: () => Promise; - writeNotificationIconToFile: () => string; - scheduleNotificationIconCleanup: (filePath: string) => void; - }; - } - ).mediaGenerator = { - generateNotificationIcon: async () => Buffer.from('png'), - writeNotificationIconToFile: () => { - throw new Error('disk full'); - }, - scheduleNotificationIconCleanup: (filePath) => { - cleanupPaths.push(filePath); - }, - }; - - await ( - integration as unknown as { - showNotification: (noteId: number, label: string | number) => Promise; - } - ).showNotification(42, '食べる'); - - assert.equal( - overlayNotifications[0]?.image, - `data:image/png;base64,${Buffer.from('png').toString('base64')}`, - ); - assert.deepEqual(desktopNotifications, [ - { - title: 'Anki Card Updated', - body: 'Updated card: 食べる', - icon: undefined, - }, - ]); - assert.deepEqual(cleanupPaths, []); -}); - -test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => { - const osdMessages: string[] = []; - const desktopMessages: string[] = []; - const overlayMessages: string[] = []; - const integration = new AnkiIntegration( - { - behavior: { - notificationType: 'both', - }, - }, - {} as never, - {} as never, - (text) => { - osdMessages.push(text); - }, - (title, options) => { - desktopMessages.push(`${title}:${options.body ?? ''}`); - }, - undefined, - undefined, - {}, - undefined, - (payload) => { - overlayMessages.push(`${payload.title}:${payload.body ?? ''}:${payload.variant ?? ''}`); - }, - ); - - assert.equal(await integration.createSentenceCard('食べる', 0, 1), false); - - assert.deepEqual(osdMessages, []); - assert.deepEqual(overlayMessages, ['SubMiner:No video loaded:info']); - assert.deepEqual(desktopMessages, ['SubMiner:No video loaded']); -}); - -test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => { - const collaborator = createFieldGroupingMergeCollaborator(); - - const merged = await collaborator.computeFieldGroupingMergedFields( - 101, - 202, - { - noteId: 101, - fields: { - SentenceAudio: { value: '[sound:keep.mp3]' }, - ExpressionAudio: { value: '[sound:stale.mp3]' }, - }, - }, - { - noteId: 202, - fields: { - SentenceAudio: { value: '[sound:new.mp3]' }, - }, - }, - false, - ); - - assert.equal( - merged.SentenceAudio, - '[sound:new.mp3][sound:keep.mp3]', - ); - assert.equal('ExpressionAudio' in merged, false); -}); - -test('FieldGroupingMergeCollaborator uses generated media fallback when source lacks audio', async () => { - const collaborator = createFieldGroupingMergeCollaborator({ - generatedMedia: { - audioField: 'SentenceAudio', - audioValue: '[sound:generated.mp3]', - }, - }); - - const merged = await collaborator.computeFieldGroupingMergedFields( - 11, - 22, - { - noteId: 11, - fields: { - SentenceAudio: { value: '' }, - }, - }, - { - noteId: 22, - fields: { - SentenceAudio: { value: '' }, - }, - }, - true, - ); - - assert.equal(merged.SentenceAudio, '[sound:generated.mp3]'); -}); - -test('FieldGroupingMergeCollaborator keeps independent groups for identical sentence, audio, and image values', async () => { - const collaborator = createFieldGroupingMergeCollaborator(); - - const merged = await collaborator.computeFieldGroupingMergedFields( - 202, - 101, - { - noteId: 202, - fields: { - Sentence: { value: 'same sentence' }, - SentenceAudio: { value: '[sound:same.mp3]' }, - Picture: { value: '' }, - ExpressionAudio: { value: '[sound:same.mp3]' }, - }, - }, - { - noteId: 101, - fields: { - Sentence: { value: 'same sentence' }, - SentenceAudio: { value: '[sound:same.mp3]' }, - Picture: { value: '' }, - }, - }, - false, - ); - - assert.equal( - merged.Sentence, - 'same sentencesame sentence', - ); - assert.equal( - merged.SentenceAudio, - '[sound:same.mp3][sound:same.mp3]', - ); - assert.equal( - merged.Picture, - '', - ); - assert.equal('ExpressionAudio' in merged, false); -}); - -test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key query params', () => { - const integration = new AnkiIntegration( - { - metadata: { - pattern: '[SubMiner] %f (%t)', - }, - } as never, - {} as never, - { - currentSubText: '', +const MISC_INFO_CASES = [ + { + name: 'avoids leaking Jellyfin api_key query params', + pattern: '[SubMiner] %f (%t)', + mpv: { currentVideoPath: 'stream?static=true&api_key=secret-token&MediaSourceId=a762ab23d26d4347e3cacdb83aaae405&AudioStreamIndex=3', - currentTimePos: 426, - currentSubStart: 426, - currentSubEnd: 428, - currentAudioStreamIndex: 3, currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02', - send: () => true, - } as unknown as never, - ); - - const privateApi = integration as unknown as { - formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; - }; - const result = privateApi.formatMiscInfoPattern('audio_123.mp3', 426); - - assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)'); - assert.equal(result.includes('api_key='), false); -}); - -test('Anki metadata rejects a credential-bearing media title before metadata arrives', () => { - const integration = new AnkiIntegration( - { metadata: { pattern: '[SubMiner] %f | %F (%t)' } } as never, - {} as never, - { + }, + fallbackFilename: 'audio_123.mp3', + expected: '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)', + leaked: 'api_key=', + }, + { + name: 'treats ApiKey stream paths like legacy api_key ones', + pattern: '[SubMiner] %f (%t)', + mpv: { + currentVideoPath: 'stream?static=true&ApiKey=secret-token&MediaSourceId=ms-1', + currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02', + }, + fallbackFilename: 'audio_123.mp3', + expected: '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)', + leaked: 'ApiKey=', + }, + { + name: 'rejects a credential-bearing media title before metadata arrives', + pattern: '[SubMiner] %f | %F (%t)', + mpv: { currentVideoPath: 'https://jellyfin.example/Videos/item/stream?api_key=test-secret', currentMediaTitle: 'stream?static=true&api_key=test-secret', - currentTimePos: 426, - send: () => true, - } as never, - ); - const privateApi = integration as unknown as { - formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; - }; - const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426); - assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)'); -}); + }, + fallbackFilename: 'stream?api_key=test-secret', + expected: '[SubMiner] Unknown media | Unknown media (00:07:06)', + leaked: 'test-secret', + }, +]; -test('AnkiIntegration.formatMiscInfoPattern treats ApiKey stream paths like legacy api_key ones', () => { - const integration = new AnkiIntegration( - { metadata: { pattern: '[SubMiner] %f (%t)' } } as never, - {} as never, - { - currentSubText: '', - currentVideoPath: 'stream?static=true&ApiKey=secret-token&MediaSourceId=ms-1', - currentTimePos: 426, - currentSubStart: 426, - currentSubEnd: 428, - currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02', - send: () => true, - } as unknown as never, - ); - const privateApi = integration as unknown as { - formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; - }; - const result = privateApi.formatMiscInfoPattern('audio_123.mp3', 426); - assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)'); - assert.equal(result.includes('ApiKey='), false); -}); +for (const c of MISC_INFO_CASES) { + test(`AnkiIntegration.formatMiscInfoPattern ${c.name}`, () => { + const integration = createIntegration( + { metadata: { pattern: c.pattern } }, + { + mpvClient: { + currentSubText: '', + currentTimePos: 426, + currentSubStart: 426, + currentSubEnd: 428, + send: () => true, + ...c.mpv, + }, + }, + ); + + const result = internalsOf(integration).formatMiscInfoPattern(c.fallbackFilename, 426); + + assert.equal(result, c.expected); + assert.equal(result.includes(c.leaked), false); + }); +} diff --git a/src/anki-integration/card-creation-sentence-media.test.ts b/src/anki-integration/card-creation-sentence-media.test.ts index baadfd92..503449df 100644 --- a/src/anki-integration/card-creation-sentence-media.test.ts +++ b/src/anki-integration/card-creation-sentence-media.test.ts @@ -2,9 +2,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { CardCreationService } from './card-creation'; -import type { AnkiConnectConfig } from '../types/anki'; - -type CardCreationDeps = ConstructorParameters[0]; +import { + type CardCreationDeps, + createAnkiConfig, + createCardCreationDeps, + createFakeClient, + createFakeMediaGenerator, +} from './card-creation-test-utils'; test('sentence card writes generated audio only to sentence audio field', async () => { const addedFields: Record[] = []; @@ -13,128 +17,88 @@ test('sentence card writes generated audio only to sentence audio field', async const requestedProperties: string[] = []; const audioVolumeScales: Array = []; const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = []; - - const deps: CardCreationDeps = { - getConfig: () => - ({ - deck: 'Mining', - fields: { - word: 'Expression', - sentence: 'Sentence', - audio: 'ExpressionAudio', - translation: 'SelectionText', - }, - media: { - generateAudio: true, - generateImage: false, - mirrorMpvVolume: true, - maxMediaDuration: 30, - }, - behavior: {}, - ai: false, - }) as AnkiConnectConfig, - getAiConfig: () => ({}), - getTimingTracker: () => ({}) as never, - getMpvClient: () => - ({ - currentVideoPath: '/video.mp4', - currentSubText: '字幕', - currentSubStart: 12, - currentSubEnd: 14, - currentTimePos: 13, - currentAudioStreamIndex: 0, - requestProperty: async (name: string) => { - requestedProperties.push(name); - return 40; - }, - }) as never, - client: { - addNote: async (_deck, _modelName, fields) => { - addedFields.push(fields); - return 42; - }, - addTags: async () => undefined, - notesInfo: async () => [ - { - noteId: 42, - fields: { - Expression: { value: '字幕' }, - Sentence: { value: '字幕' }, - SelectionText: { value: 'Subtitle' }, - ExpressionAudio: { value: '' }, - SentenceAudio: { value: '' }, - }, - }, - ], - updateNoteFields: async (_noteId, fields) => { - updatedFields.push(fields); - }, - storeMediaFile: async (filename) => { - storedMedia.push(filename); - }, - findNotes: async () => [], - retrieveMediaFile: async () => '', - deleteNotes: async () => undefined, - }, - mediaGenerator: { - generateAudio: async ( - _path, - startTime, - endTime, - audioPadding, - _audioStreamIndex, - _normalizeAudio, - volumeScale, - ) => { - audioRanges.push({ start: startTime, end: endTime, padding: audioPadding }); - audioVolumeScales.push(volumeScale); - return Buffer.from('audio'); - }, - generateScreenshot: async () => null, - generateAnimatedImage: async () => null, - }, - showOsdNotification: () => undefined, - showUpdateResult: () => undefined, - showStatusNotification: () => undefined, - showNotification: async () => undefined, - beginUpdateProgress: () => undefined, - endUpdateProgress: () => undefined, - withUpdateProgress: async (_message, action) => action(), - resolveConfiguredFieldName: (noteInfo, ...preferredNames) => { - for (const preferredName of preferredNames) { - if (preferredName && preferredName in noteInfo.fields) return preferredName; - } - return null; - }, - resolveNoteFieldName: (noteInfo, preferredName) => - preferredName && preferredName in noteInfo.fields ? preferredName : null, - getAnimatedImageLeadInSeconds: async () => 0, - extractFields: () => ({}), - processSentence: (sentence) => sentence, - setCardTypeFields: () => undefined, - mergeFieldValue: (_existing, newValue) => newValue, - formatMiscInfoPattern: () => '', - getEffectiveSentenceCardConfig: () => ({ - model: 'Sentence', - sentenceField: 'Sentence', - audioField: 'SentenceAudio', - lapisEnabled: true, - kikuEnabled: false, - fieldGroupingMode: 'disabled', - }), - getFallbackDurationSeconds: () => 10, - appendKnownWordsFromNoteInfo: () => undefined, - removeKnownWordNote: () => undefined, - isUpdateInProgress: () => false, - setUpdateInProgress: () => undefined, - trackLastAddedNoteId: () => undefined, - reviewMediaTiming: async () => ({ action: 'confirm', startTime: 11.4, endTime: 14.2 }), + let review: Awaited>> = { + action: 'confirm', + startTime: 11.4, + endTime: 14.2, }; - const service = new CardCreationService(deps); - const created = await service.createSentenceCard('字幕', 12, 14, 'Subtitle'); + const service = new CardCreationService( + createCardCreationDeps({ + getConfig: () => + createAnkiConfig({ + fields: { + word: 'Expression', + audio: 'ExpressionAudio', + translation: 'SelectionText', + }, + media: { generateAudio: true, mirrorMpvVolume: true, maxMediaDuration: 30 }, + }), + getMpvClient: () => + ({ + currentVideoPath: '/video.mp4', + currentSubText: '字幕', + currentSubStart: 12, + currentSubEnd: 14, + currentTimePos: 13, + currentAudioStreamIndex: 0, + requestProperty: async (name: string) => { + requestedProperties.push(name); + return 40; + }, + }) as never, + client: createFakeClient({ + addNote: async (_deck, _modelName, fields) => { + addedFields.push(fields); + return 42; + }, + notesInfo: async () => [ + { + noteId: 42, + fields: { + Expression: { value: '字幕' }, + Sentence: { value: '字幕' }, + SelectionText: { value: 'Subtitle' }, + ExpressionAudio: { value: '' }, + SentenceAudio: { value: '' }, + }, + }, + ], + updateNoteFields: async (_noteId, fields) => { + updatedFields.push(fields); + }, + storeMediaFile: async (filename) => { + storedMedia.push(filename); + }, + }), + mediaGenerator: createFakeMediaGenerator({ + generateAudio: async ( + _path, + startTime, + endTime, + audioPadding, + _audioStreamIndex, + _normalizeAudio, + volumeScale, + ) => { + audioRanges.push({ start: startTime, end: endTime, padding: audioPadding }); + audioVolumeScales.push(volumeScale); + return Buffer.from('audio'); + }, + }), + getEffectiveSentenceCardConfig: () => ({ + model: 'Sentence', + sentenceField: 'Sentence', + audioField: 'SentenceAudio', + lapisEnabled: true, + kikuEnabled: false, + fieldGroupingMode: 'disabled', + }), + reviewMediaTiming: async () => review, + }), + ); - assert.equal(created, true); + assert.equal(await service.createSentenceCard('字幕', 12, 14, 'Subtitle'), true); assert.deepEqual(addedFields[0], { Sentence: '字幕', SelectionText: 'Subtitle', @@ -149,11 +113,11 @@ test('sentence card writes generated audio only to sentence audio field', async assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`); assert.equal('ExpressionAudio' in mediaUpdate!, false); - deps.reviewMediaTiming = async () => ({ action: 'discard' }); + review = { action: 'discard' }; assert.equal(await service.createSentenceCard('作らない', 20, 22), false); assert.equal(addedFields.length, 1); - deps.reviewMediaTiming = async () => ({ action: 'skip-media' }); + review = { action: 'skip-media' }; assert.equal(await service.createSentenceCard('メディアなし', 30, 32), true); assert.equal(addedFields.length, 2); assert.equal(storedMedia.length, 1); diff --git a/src/anki-integration/card-creation-test-utils.ts b/src/anki-integration/card-creation-test-utils.ts new file mode 100644 index 00000000..292b65a3 --- /dev/null +++ b/src/anki-integration/card-creation-test-utils.ts @@ -0,0 +1,107 @@ +import { CardCreationService } from './card-creation'; +import type { AnkiConnectConfig } from '../types/anki'; + +export type CardCreationDeps = ConstructorParameters[0]; + +/** Builds the minimal config the service reads; pass only the sections a test cares about. */ +export function createAnkiConfig( + overrides: { + fields?: Record; + media?: Record; + } = {}, +): AnkiConnectConfig { + return { + deck: 'Mining', + fields: { sentence: 'Sentence', audio: 'SentenceAudio', ...overrides.fields }, + media: { generateAudio: false, generateImage: false, ...overrides.media }, + behavior: {}, + ai: false, + } as AnkiConnectConfig; +} + +/** + * Fake CardCreationService dependencies: a bare mpv session, a client that adds note 42, no media, + * and field resolvers that match exact field names. Override only what the test exercises. + */ +export function createCardCreationDeps( + overrides: Partial = {}, +): CardCreationDeps { + return { + getConfig: () => createAnkiConfig(), + getAiConfig: () => ({}), + getTimingTracker: () => ({}) as never, + getMpvClient: () => + ({ + currentVideoPath: '/video.mp4', + currentSubText: '字幕', + currentSubStart: 1, + currentSubEnd: 2, + currentTimePos: 1.5, + currentAudioStreamIndex: 0, + }) as never, + client: { + addNote: async () => 42, + addTags: async () => undefined, + notesInfo: async () => [], + updateNoteFields: async () => undefined, + storeMediaFile: async () => undefined, + findNotes: async () => [], + retrieveMediaFile: async () => '', + deleteNotes: async () => undefined, + }, + mediaGenerator: { + generateAudio: async () => null, + generateScreenshot: async () => null, + generateAnimatedImage: async () => null, + }, + showOsdNotification: () => undefined, + showUpdateResult: () => undefined, + showStatusNotification: () => undefined, + showNotification: async () => undefined, + beginUpdateProgress: () => undefined, + endUpdateProgress: () => undefined, + withUpdateProgress: async (_message, action) => action(), + resolveConfiguredFieldName: (noteInfo, ...preferredNames) => { + for (const preferredName of preferredNames) { + if (preferredName && preferredName in noteInfo.fields) return preferredName; + } + return null; + }, + resolveNoteFieldName: (noteInfo, preferredName) => + preferredName && preferredName in noteInfo.fields ? preferredName : null, + getAnimatedImageLeadInSeconds: async () => 0, + extractFields: () => ({}), + processSentence: (sentence) => sentence, + setCardTypeFields: () => undefined, + mergeFieldValue: (_existing, newValue) => newValue, + formatMiscInfoPattern: () => '', + getEffectiveSentenceCardConfig: () => ({ + model: 'Sentence', + sentenceField: 'Sentence', + audioField: 'SentenceAudio', + lapisEnabled: false, + kikuEnabled: false, + fieldGroupingMode: 'disabled', + }), + getFallbackDurationSeconds: () => 10, + appendKnownWordsFromNoteInfo: () => undefined, + removeKnownWordNote: () => undefined, + isUpdateInProgress: () => false, + setUpdateInProgress: () => undefined, + trackLastAddedNoteId: () => undefined, + ...overrides, + }; +} + +/** Anki client overrides, kept separate so a test can swap one method without redeclaring the rest. */ +export function createFakeClient( + overrides: Partial = {}, +): CardCreationDeps['client'] { + return { ...createCardCreationDeps().client, ...overrides }; +} + +export function createFakeMediaGenerator( + overrides: Partial = {}, +): CardCreationDeps['mediaGenerator'] { + return { ...createCardCreationDeps().mediaGenerator, ...overrides }; +} diff --git a/src/anki-integration/card-creation.test.ts b/src/anki-integration/card-creation.test.ts index 881ebe5f..cf2d4002 100644 --- a/src/anki-integration/card-creation.test.ts +++ b/src/anki-integration/card-creation.test.ts @@ -2,292 +2,83 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { CardCreationService } from './card-creation'; +import { + type CardCreationDeps, + createAnkiConfig, + createCardCreationDeps, + createFakeClient, + createFakeMediaGenerator, +} from './card-creation-test-utils'; import { toMpvEdlValue } from './mpv-edl-test-utils'; import type { MediaInput } from '../media-generator'; -import type { AnkiConnectConfig } from '../types/anki'; + +const YOUTUBE_URL = 'https://www.youtube.com/watch?v=abc123'; + +const mediaInputPath = (mediaInput: MediaInput): string => + typeof mediaInput === 'string' ? mediaInput : mediaInput.path; test('CardCreationService counts locally created sentence cards', async () => { const minedCards: Array<{ count: number; noteIds?: number[] }> = []; - const service = new CardCreationService({ - getConfig: () => - ({ - deck: 'Mining', - fields: { - sentence: 'Sentence', - audio: 'SentenceAudio', - }, - media: { - generateAudio: false, - generateImage: false, - }, - behavior: {}, - ai: false, - }) as AnkiConnectConfig, - getAiConfig: () => ({}), - getTimingTracker: () => ({}) as never, - getMpvClient: () => - ({ - currentVideoPath: '/video.mp4', - currentSubText: '字幕', - currentSubStart: 1, - currentSubEnd: 2, - currentTimePos: 1.5, - currentAudioStreamIndex: 0, - }) as never, - client: { - addNote: async () => 42, - addTags: async () => undefined, - notesInfo: async () => [], - updateNoteFields: async () => undefined, - storeMediaFile: async () => undefined, - findNotes: async () => [], - retrieveMediaFile: async () => '', - deleteNotes: async () => undefined, - }, - mediaGenerator: { - generateAudio: async () => null, - generateScreenshot: async () => null, - generateAnimatedImage: async () => null, - }, - showOsdNotification: () => undefined, - showUpdateResult: () => undefined, - showStatusNotification: () => undefined, - showNotification: async () => undefined, - beginUpdateProgress: () => undefined, - endUpdateProgress: () => undefined, - withUpdateProgress: async (_message, action) => action(), - resolveConfiguredFieldName: () => null, - resolveNoteFieldName: () => null, - getAnimatedImageLeadInSeconds: async () => 0, - extractFields: () => ({}), - processSentence: (sentence) => sentence, - setCardTypeFields: () => undefined, - mergeFieldValue: (_existing, newValue) => newValue, - formatMiscInfoPattern: () => '', - getEffectiveSentenceCardConfig: () => ({ - model: 'Sentence', - sentenceField: 'Sentence', - audioField: 'SentenceAudio', - lapisEnabled: false, - kikuEnabled: false, - fieldGroupingMode: 'disabled', + const service = new CardCreationService( + createCardCreationDeps({ + recordCardsMinedCallback: (count, noteIds) => { + minedCards.push({ count, noteIds }); + }, }), - getFallbackDurationSeconds: () => 10, - appendKnownWordsFromNoteInfo: () => undefined, - removeKnownWordNote: () => undefined, - isUpdateInProgress: () => false, - setUpdateInProgress: () => undefined, - trackLastAddedNoteId: () => undefined, - recordCardsMinedCallback: (count, noteIds) => { - minedCards.push({ count, noteIds }); - }, - }); + ); - const created = await service.createSentenceCard('テスト', 0, 1); - - assert.equal(created, true); + assert.equal(await service.createSentenceCard('テスト', 0, 1), true); assert.deepEqual(minedCards, [{ count: 1, noteIds: [42] }]); }); -test('CardCreationService keeps updating after trackLastAddedNoteId throws', async () => { - const calls = { - notesInfo: 0, - updateNoteFields: 0, - }; - const service = new CardCreationService({ - getConfig: () => - ({ - deck: 'Mining', - fields: { - sentence: 'Sentence', - audio: 'SentenceAudio', - }, - media: { - generateAudio: false, - generateImage: false, - }, - behavior: {}, - ai: false, - }) as AnkiConnectConfig, - getAiConfig: () => ({}), - getTimingTracker: () => ({}) as never, - getMpvClient: () => - ({ - currentVideoPath: '/video.mp4', - currentSubText: '字幕', - currentSubStart: 1, - currentSubEnd: 2, - currentTimePos: 1.5, - currentAudioStreamIndex: 0, - }) as never, - client: { - addNote: async () => 42, - addTags: async () => undefined, - notesInfo: async () => { - calls.notesInfo += 1; - return [ - { - noteId: 42, - fields: { - Sentence: { value: 'existing' }, - }, +const THROWING_HOOKS: Array<{ + name: string; + hook: Partial; +}> = [ + { + name: 'trackLastAddedNoteId', + hook: { + trackLastAddedNoteId: () => { + throw new Error('track failed'); + }, + }, + }, + { + name: 'recordCardsMinedCallback', + hook: { + recordCardsMinedCallback: () => { + throw new Error('record failed'); + }, + }, + }, +]; + +for (const c of THROWING_HOOKS) { + test(`CardCreationService keeps updating after ${c.name} throws`, async () => { + const calls = { notesInfo: 0, updateNoteFields: 0 }; + const service = new CardCreationService( + createCardCreationDeps({ + client: createFakeClient({ + notesInfo: async () => { + calls.notesInfo += 1; + return [{ noteId: 42, fields: { Sentence: { value: 'existing' } } }]; }, - ]; - }, - updateNoteFields: async () => { - calls.updateNoteFields += 1; - }, - storeMediaFile: async () => undefined, - findNotes: async () => [], - retrieveMediaFile: async () => '', - deleteNotes: async () => undefined, - }, - mediaGenerator: { - generateAudio: async () => null, - generateScreenshot: async () => null, - generateAnimatedImage: async () => null, - }, - showOsdNotification: () => undefined, - showUpdateResult: () => undefined, - showStatusNotification: () => undefined, - showNotification: async () => undefined, - beginUpdateProgress: () => undefined, - endUpdateProgress: () => undefined, - withUpdateProgress: async (_message, action) => action(), - resolveConfiguredFieldName: () => null, - resolveNoteFieldName: () => null, - getAnimatedImageLeadInSeconds: async () => 0, - extractFields: () => ({}), - processSentence: (sentence) => sentence, - setCardTypeFields: (updatedFields) => { - updatedFields.CardType = 'sentence'; - }, - mergeFieldValue: (_existing, newValue) => newValue, - formatMiscInfoPattern: () => '', - getEffectiveSentenceCardConfig: () => ({ - model: 'Sentence', - sentenceField: 'Sentence', - audioField: 'SentenceAudio', - lapisEnabled: false, - kikuEnabled: false, - fieldGroupingMode: 'disabled', - }), - getFallbackDurationSeconds: () => 10, - appendKnownWordsFromNoteInfo: () => undefined, - removeKnownWordNote: () => undefined, - isUpdateInProgress: () => false, - setUpdateInProgress: () => undefined, - trackLastAddedNoteId: () => { - throw new Error('track failed'); - }, - }); - - const created = await service.createSentenceCard('テスト', 0, 1); - - assert.equal(created, true); - assert.equal(calls.notesInfo, 1); - assert.equal(calls.updateNoteFields, 1); -}); - -test('CardCreationService keeps updating after recordCardsMinedCallback throws', async () => { - const calls = { - notesInfo: 0, - updateNoteFields: 0, - }; - const service = new CardCreationService({ - getConfig: () => - ({ - deck: 'Mining', - fields: { - sentence: 'Sentence', - audio: 'SentenceAudio', - }, - media: { - generateAudio: false, - generateImage: false, - }, - behavior: {}, - ai: false, - }) as AnkiConnectConfig, - getAiConfig: () => ({}), - getTimingTracker: () => ({}) as never, - getMpvClient: () => - ({ - currentVideoPath: '/video.mp4', - currentSubText: '字幕', - currentSubStart: 1, - currentSubEnd: 2, - currentTimePos: 1.5, - currentAudioStreamIndex: 0, - }) as never, - client: { - addNote: async () => 42, - addTags: async () => undefined, - notesInfo: async () => { - calls.notesInfo += 1; - return [ - { - noteId: 42, - fields: { - Sentence: { value: 'existing' }, - }, + updateNoteFields: async () => { + calls.updateNoteFields += 1; }, - ]; - }, - updateNoteFields: async () => { - calls.updateNoteFields += 1; - }, - storeMediaFile: async () => undefined, - findNotes: async () => [], - retrieveMediaFile: async () => '', - deleteNotes: async () => undefined, - }, - mediaGenerator: { - generateAudio: async () => null, - generateScreenshot: async () => null, - generateAnimatedImage: async () => null, - }, - showOsdNotification: () => undefined, - showUpdateResult: () => undefined, - showStatusNotification: () => undefined, - showNotification: async () => undefined, - beginUpdateProgress: () => undefined, - endUpdateProgress: () => undefined, - withUpdateProgress: async (_message, action) => action(), - resolveConfiguredFieldName: () => null, - resolveNoteFieldName: () => null, - getAnimatedImageLeadInSeconds: async () => 0, - extractFields: () => ({}), - processSentence: (sentence) => sentence, - setCardTypeFields: (updatedFields) => { - updatedFields.CardType = 'sentence'; - }, - mergeFieldValue: (_existing, newValue) => newValue, - formatMiscInfoPattern: () => '', - getEffectiveSentenceCardConfig: () => ({ - model: 'Sentence', - sentenceField: 'Sentence', - audioField: 'SentenceAudio', - lapisEnabled: false, - kikuEnabled: false, - fieldGroupingMode: 'disabled', - }), - getFallbackDurationSeconds: () => 10, - appendKnownWordsFromNoteInfo: () => undefined, - removeKnownWordNote: () => undefined, - isUpdateInProgress: () => false, - setUpdateInProgress: () => undefined, - recordCardsMinedCallback: () => { - throw new Error('record failed'); - }, + }), + setCardTypeFields: (updatedFields) => { + updatedFields.CardType = 'sentence'; + }, + ...c.hook, + }), + ); + + assert.equal(await service.createSentenceCard('テスト', 0, 1), true); + assert.equal(calls.notesInfo, 1); + assert.equal(calls.updateNoteFields, 1); }); - - const created = await service.createSentenceCard('テスト', 0, 1); - - assert.equal(created, true); - assert.equal(calls.notesInfo, 1); - assert.equal(calls.updateNoteFields, 1); -}); +} test('CardCreationService uses stream-open-filename for remote media generation', async () => { let reviewing = false; @@ -295,8 +86,6 @@ test('CardCreationService uses stream-open-filename for remote media generation' const imageTimes: number[] = []; const audioPaths: string[] = []; const imagePaths: string[] = []; - const recordMediaPath = (mediaInput: MediaInput): string => - typeof mediaInput === 'string' ? mediaInput : mediaInput.path; const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm'; const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4'; const edlSource = [ @@ -305,120 +94,67 @@ test('CardCreationService uses stream-open-filename for remote media generation' '!global_tags,title=test', ].join(';'); - const service = new CardCreationService({ - getConfig: () => - ({ - deck: 'Mining', - fields: { - sentence: 'Sentence', - audio: 'SentenceAudio', - image: 'Picture', - }, - media: { - generateAudio: true, - generateImage: true, - imageFormat: 'jpg', - }, - behavior: {}, - ai: false, - }) as AnkiConnectConfig, - reviewMediaTiming: async () => - reviewing - ? { action: 'confirm', startTime: 0.2, endTime: 0.8, screenshotTime: 3.125 } - : { action: 'use-original' }, - getAiConfig: () => ({}), - getTimingTracker: () => ({}) as never, - getMpvClient: () => - ({ - currentVideoPath: 'https://www.youtube.com/watch?v=abc123', - currentSubText: '字幕', - currentSubStart: 1, - currentSubEnd: 2, - currentTimePos: 1.5, - currentAudioStreamIndex: 0, - requestProperty: async (name: string) => { - assert.equal(name, 'stream-open-filename'); - return edlSource; - }, - }) as never, - client: { - addNote: async () => 42, - addTags: async () => undefined, - notesInfo: async () => [ - { - noteId: 42, - fields: { - Sentence: { value: '' }, - SentenceAudio: { value: '' }, - Picture: { value: '' }, + const service = new CardCreationService( + createCardCreationDeps({ + getConfig: () => + createAnkiConfig({ + fields: { image: 'Picture' }, + media: { generateAudio: true, generateImage: true, imageFormat: 'jpg' }, + }), + reviewMediaTiming: async () => + reviewing + ? { action: 'confirm', startTime: 0.2, endTime: 0.8, screenshotTime: 3.125 } + : { action: 'use-original' }, + getMpvClient: () => + ({ + currentVideoPath: YOUTUBE_URL, + currentSubText: '字幕', + currentSubStart: 1, + currentSubEnd: 2, + currentTimePos: 1.5, + currentAudioStreamIndex: 0, + requestProperty: async (name: string) => { + assert.equal(name, 'stream-open-filename'); + return edlSource; }, + }) as never, + client: createFakeClient({ + notesInfo: async () => [ + { + noteId: 42, + fields: { + Sentence: { value: '' }, + SentenceAudio: { value: '' }, + Picture: { value: '' }, + }, + }, + ], + findNotes: async () => [42], + }), + mediaGenerator: createFakeMediaGenerator({ + generateAudio: async (path, start, end, padding) => { + audioRanges.push([start, end, padding ?? -1]); + audioPaths.push(mediaInputPath(path)); + return Buffer.from('audio'); }, - ], - updateNoteFields: async () => undefined, - storeMediaFile: async () => undefined, - findNotes: async () => [42], - retrieveMediaFile: async () => '', - deleteNotes: async () => undefined, - }, - mediaGenerator: { - generateAudio: async (path, start, end, padding) => { - audioRanges.push([start, end, padding ?? -1]); - audioPaths.push(recordMediaPath(path)); - return Buffer.from('audio'); - }, - generateScreenshot: async (path, timestamp) => { - imageTimes.push(timestamp); - imagePaths.push(recordMediaPath(path)); - return Buffer.from('image'); - }, - generateAnimatedImage: async () => null, - }, - showOsdNotification: () => undefined, - showUpdateResult: () => undefined, - showStatusNotification: () => undefined, - showNotification: async () => undefined, - beginUpdateProgress: () => undefined, - endUpdateProgress: () => undefined, - withUpdateProgress: async (_message, action) => action(), - resolveConfiguredFieldName: (noteInfo, preferredName) => { - if (!preferredName) return null; - return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null; - }, - resolveNoteFieldName: (noteInfo, preferredName) => { - if (!preferredName) return null; - return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null; - }, - getAnimatedImageLeadInSeconds: async () => 0, - extractFields: () => ({}), - processSentence: (sentence) => sentence, - setCardTypeFields: () => undefined, - mergeFieldValue: (_existing, newValue) => newValue, - formatMiscInfoPattern: () => '', - getEffectiveSentenceCardConfig: () => ({ - model: 'Sentence', - sentenceField: 'Sentence', - audioField: 'SentenceAudio', - lapisEnabled: false, - kikuEnabled: false, - fieldGroupingMode: 'disabled', + generateScreenshot: async (path, timestamp) => { + imageTimes.push(timestamp); + imagePaths.push(mediaInputPath(path)); + return Buffer.from('image'); + }, + }), }), - getFallbackDurationSeconds: () => 10, - appendKnownWordsFromNoteInfo: () => undefined, - removeKnownWordNote: () => undefined, - isUpdateInProgress: () => false, - setUpdateInProgress: () => undefined, - trackLastAddedNoteId: () => undefined, - }); + ); - const created = await service.createSentenceCard('テスト', 0, 1); - - assert.equal(created, true); + assert.equal(await service.createSentenceCard('テスト', 0, 1), true); assert.deepEqual(audioPaths, [audioUrl]); assert.deepEqual(imagePaths, [videoUrl]); + reviewing = true; assert.equal(await service.createSentenceCard('テスト', 0, 1), true); assert.deepEqual(audioRanges.at(-1), [0.2, 0.8, 0]); assert.equal(imageTimes.at(-1), 3.125); + await service.markLastCardAsAudioCard(); assert.equal(imageTimes.length, 3); assert.deepEqual(audioRanges.at(-1), [0.2, 0.8, 0]); @@ -428,108 +164,48 @@ test('CardCreationService uses stream-open-filename for remote media generation' test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => { const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = []; - const service = new CardCreationService({ - getConfig: () => - ({ - deck: 'Mining', - fields: { - sentence: 'Sentence', - audio: 'SentenceAudio', - image: 'Picture', - }, - media: { - generateAudio: true, - generateImage: false, - imageFormat: 'jpg', - }, - behavior: {}, - ai: false, - }) as AnkiConnectConfig, - getAiConfig: () => ({}), - getTimingTracker: () => ({}) as never, - getMpvClient: () => - ({ - currentVideoPath: 'https://www.youtube.com/watch?v=abc123', - currentSubText: '字幕', - currentSubStart: 10, - currentSubEnd: 12, - currentTimePos: 11, - currentAudioStreamIndex: 0, - }) as never, - getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/media.mkv', - shouldRequireRemoteMediaCache: () => true, - client: { - addNote: async () => 42, - addTags: async () => undefined, - notesInfo: async () => [ - { - noteId: 42, - fields: { - Sentence: { value: '' }, - SentenceAudio: { value: '' }, - Picture: { value: '' }, + const service = new CardCreationService( + createCardCreationDeps({ + getConfig: () => + createAnkiConfig({ + fields: { image: 'Picture' }, + media: { generateAudio: true, imageFormat: 'jpg' }, + }), + getMpvClient: () => + ({ + currentVideoPath: YOUTUBE_URL, + currentSubText: '字幕', + currentSubStart: 10, + currentSubEnd: 12, + currentTimePos: 11, + currentAudioStreamIndex: 0, + }) as never, + getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/media.mkv', + shouldRequireRemoteMediaCache: () => true, + client: createFakeClient({ + notesInfo: async () => [ + { + noteId: 42, + fields: { + Sentence: { value: '' }, + SentenceAudio: { value: '' }, + Picture: { value: '' }, + }, }, + ], + }), + mediaGenerator: createFakeMediaGenerator({ + generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => { + audioCalls.push({ path: mediaInputPath(path), audioStreamIndex }); + return Buffer.from('audio'); }, - ], - updateNoteFields: async () => undefined, - storeMediaFile: async () => undefined, - findNotes: async () => [], - retrieveMediaFile: async () => '', - deleteNotes: async () => undefined, - }, - mediaGenerator: { - generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => { - audioCalls.push({ path: typeof path === 'string' ? path : path.path, audioStreamIndex }); - return Buffer.from('audio'); - }, - generateScreenshot: async () => null, - generateAnimatedImage: async () => null, - }, - showOsdNotification: () => undefined, - showUpdateResult: () => undefined, - showStatusNotification: () => undefined, - showNotification: async () => undefined, - beginUpdateProgress: () => undefined, - endUpdateProgress: () => undefined, - withUpdateProgress: async (_message, action) => action(), - resolveConfiguredFieldName: (noteInfo, preferredName) => { - if (!preferredName) return null; - return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null; - }, - resolveNoteFieldName: (noteInfo, preferredName) => { - if (!preferredName) return null; - return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null; - }, - getAnimatedImageLeadInSeconds: async () => 0, - extractFields: () => ({}), - processSentence: (sentence) => sentence, - setCardTypeFields: () => undefined, - mergeFieldValue: (_existing, newValue) => newValue, - formatMiscInfoPattern: () => '', - getEffectiveSentenceCardConfig: () => ({ - model: 'Sentence', - sentenceField: 'Sentence', - audioField: 'SentenceAudio', - lapisEnabled: false, - kikuEnabled: false, - fieldGroupingMode: 'disabled', + }), }), - getFallbackDurationSeconds: () => 10, - appendKnownWordsFromNoteInfo: () => undefined, - removeKnownWordNote: () => undefined, - isUpdateInProgress: () => false, - setUpdateInProgress: () => undefined, - trackLastAddedNoteId: () => undefined, - }); + ); - const created = await service.createSentenceCard('テスト', 10, 12); - - assert.equal(created, true); + assert.equal(await service.createSentenceCard('テスト', 10, 12), true); assert.deepEqual(audioCalls, [ - { - path: '/tmp/subminer-youtube-media-cache/media.mkv', - audioStreamIndex: undefined, - }, + { path: '/tmp/subminer-youtube-media-cache/media.mkv', audioStreamIndex: undefined }, ]); }); @@ -551,125 +227,67 @@ test('CardCreationService queues YouTube media when required cache is not ready' }> = []; let streamRequests = 0; - const service = new CardCreationService({ - getConfig: () => - ({ - deck: 'Mining', - fields: { - sentence: 'Sentence', - audio: 'SentenceAudio', - image: 'Picture', - miscInfo: 'MiscInfo', - }, - media: { - generateAudio: true, - generateImage: true, - imageFormat: 'jpg', - }, - behavior: {}, - ai: false, - }) as AnkiConnectConfig, - getAiConfig: () => ({}), - getTimingTracker: () => ({}) as never, - getMpvClient: () => - ({ - currentVideoPath: 'https://www.youtube.com/watch?v=abc123', - currentSubText: '字幕', - currentSubStart: 10, - currentSubEnd: 12, - currentTimePos: 11, - currentAudioStreamIndex: 2, - requestProperty: async (name: string) => { - if (name === 'volume') { - return 35; - } - streamRequests += 1; - return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123'; - }, - }) as never, - getCachedMediaPath: async () => null, - shouldRequireRemoteMediaCache: () => true, - queuePendingYoutubeMediaUpdate: (job) => { - queuedUpdates.push(job); - }, - client: { - addNote: async () => 42, - addTags: async () => undefined, - notesInfo: async () => [ - { - noteId: 42, - fields: { - Sentence: { value: '' }, - SentenceAudio: { value: '' }, - Picture: { value: '' }, - MiscInfo: { value: '' }, + const service = new CardCreationService( + createCardCreationDeps({ + getConfig: () => + createAnkiConfig({ + fields: { image: 'Picture', miscInfo: 'MiscInfo' }, + media: { generateAudio: true, generateImage: true, imageFormat: 'jpg' }, + }), + getMpvClient: () => + ({ + currentVideoPath: YOUTUBE_URL, + currentSubText: '字幕', + currentSubStart: 10, + currentSubEnd: 12, + currentTimePos: 11, + currentAudioStreamIndex: 2, + requestProperty: async (name: string) => { + if (name === 'volume') return 35; + streamRequests += 1; + return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123'; }, + }) as never, + getCachedMediaPath: async () => null, + shouldRequireRemoteMediaCache: () => true, + queuePendingYoutubeMediaUpdate: (job) => { + queuedUpdates.push(job); + }, + client: createFakeClient({ + notesInfo: async () => [ + { + noteId: 42, + fields: { + Sentence: { value: '' }, + SentenceAudio: { value: '' }, + Picture: { value: '' }, + MiscInfo: { value: '' }, + }, + }, + ], + updateNoteFields: async (noteId, fields) => { + updates.push({ noteId, fields }); }, - ], - updateNoteFields: async (noteId, fields) => { - updates.push({ noteId, fields }); - }, - storeMediaFile: async () => undefined, - findNotes: async () => [], - retrieveMediaFile: async () => '', - deleteNotes: async () => undefined, - }, - mediaGenerator: { - generateAudio: async () => { - mediaCalls.push('audio'); - return Buffer.from('audio'); - }, - generateScreenshot: async () => { - mediaCalls.push('image'); - return Buffer.from('image'); - }, - generateAnimatedImage: async () => null, - }, - showOsdNotification: () => undefined, - showUpdateResult: () => undefined, - showStatusNotification: () => undefined, - showNotification: async () => undefined, - beginUpdateProgress: () => undefined, - endUpdateProgress: () => undefined, - withUpdateProgress: async (_message, action) => action(), - resolveConfiguredFieldName: (noteInfo, preferredName) => { - if (!preferredName) return null; - return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null; - }, - resolveNoteFieldName: (noteInfo, preferredName) => { - if (!preferredName) return null; - return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null; - }, - getAnimatedImageLeadInSeconds: async () => 0, - extractFields: () => ({}), - processSentence: (sentence) => sentence, - setCardTypeFields: () => undefined, - mergeFieldValue: (_existing, newValue) => newValue, - formatMiscInfoPattern: () => '', - getEffectiveSentenceCardConfig: () => ({ - model: 'Sentence', - sentenceField: 'Sentence', - audioField: 'SentenceAudio', - lapisEnabled: false, - kikuEnabled: false, - fieldGroupingMode: 'disabled', + }), + mediaGenerator: createFakeMediaGenerator({ + generateAudio: async () => { + mediaCalls.push('audio'); + return Buffer.from('audio'); + }, + generateScreenshot: async () => { + mediaCalls.push('image'); + return Buffer.from('image'); + }, + }), }), - getFallbackDurationSeconds: () => 10, - appendKnownWordsFromNoteInfo: () => undefined, - removeKnownWordNote: () => undefined, - isUpdateInProgress: () => false, - setUpdateInProgress: () => undefined, - trackLastAddedNoteId: () => undefined, - }); + ); - const created = await service.createSentenceCard('テスト', 10, 12); - - assert.equal(created, true); + assert.equal(await service.createSentenceCard('テスト', 10, 12), true); assert.equal(streamRequests, 0); assert.deepEqual(mediaCalls, []); assert.deepEqual(queuedUpdates, [ { - sourceUrl: 'https://www.youtube.com/watch?v=abc123', + sourceUrl: YOUTUBE_URL, noteId: 42, startTime: 10, endTime: 12, @@ -685,179 +303,48 @@ test('CardCreationService queues YouTube media when required cache is not ready' assert.deepEqual(updates, []); }); -test('CardCreationService tracks pre-add duplicate note ids for kiku sentence cards', async () => { - const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = []; - const duplicateLookupExpressions: string[] = []; +const DUPLICATE_TRACKING_CASES = [ + { + name: 'tracks sorted, deduplicated pre-add duplicate note ids for kiku sentence cards', + sentence: '重複文', + lookupResult: [18, 7, 30, 7], + expected: [{ noteId: 42, duplicateNoteIds: [7, 18, 30] }], + }, + { + name: 'does not track duplicate ids when pre-add lookup returns none', + sentence: '重複なし', + lookupResult: [], + expected: [], + }, +]; - const service = new CardCreationService({ - getConfig: () => - ({ - deck: 'Mining', - fields: { - word: 'Expression', - sentence: 'Sentence', - audio: 'SentenceAudio', +for (const c of DUPLICATE_TRACKING_CASES) { + test(`CardCreationService ${c.name}`, async () => { + const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = []; + const lookedUp: string[] = []; + const service = new CardCreationService( + createCardCreationDeps({ + getConfig: () => createAnkiConfig({ fields: { word: 'Expression' } }), + getEffectiveSentenceCardConfig: () => ({ + model: 'Sentence', + sentenceField: 'Sentence', + audioField: 'SentenceAudio', + lapisEnabled: false, + kikuEnabled: true, + fieldGroupingMode: 'manual', + }), + findDuplicateNoteIds: async (expression) => { + lookedUp.push(expression); + return c.lookupResult; }, - media: { - generateAudio: false, - generateImage: false, + trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => { + trackedDuplicates.push({ noteId, duplicateNoteIds }); }, - behavior: {}, - ai: false, - }) as AnkiConnectConfig, - getAiConfig: () => ({}), - getTimingTracker: () => ({}) as never, - getMpvClient: () => - ({ - currentVideoPath: '/video.mp4', - currentSubText: '字幕', - currentSubStart: 1, - currentSubEnd: 2, - currentTimePos: 1.5, - currentAudioStreamIndex: 0, - }) as never, - client: { - addNote: async () => 42, - addTags: async () => undefined, - notesInfo: async () => [], - updateNoteFields: async () => undefined, - storeMediaFile: async () => undefined, - findNotes: async () => [], - retrieveMediaFile: async () => '', - deleteNotes: async () => undefined, - }, - mediaGenerator: { - generateAudio: async () => null, - generateScreenshot: async () => null, - generateAnimatedImage: async () => null, - }, - showOsdNotification: () => undefined, - showUpdateResult: () => undefined, - showStatusNotification: () => undefined, - showNotification: async () => undefined, - beginUpdateProgress: () => undefined, - endUpdateProgress: () => undefined, - withUpdateProgress: async (_message, action) => action(), - resolveConfiguredFieldName: () => null, - resolveNoteFieldName: () => null, - getAnimatedImageLeadInSeconds: async () => 0, - extractFields: () => ({}), - processSentence: (sentence) => sentence, - setCardTypeFields: () => undefined, - mergeFieldValue: (_existing, newValue) => newValue, - formatMiscInfoPattern: () => '', - getEffectiveSentenceCardConfig: () => ({ - model: 'Sentence', - sentenceField: 'Sentence', - audioField: 'SentenceAudio', - lapisEnabled: false, - kikuEnabled: true, - fieldGroupingMode: 'manual', - }), - getFallbackDurationSeconds: () => 10, - appendKnownWordsFromNoteInfo: () => undefined, - removeKnownWordNote: () => undefined, - isUpdateInProgress: () => false, - setUpdateInProgress: () => undefined, - trackLastAddedNoteId: () => undefined, - findDuplicateNoteIds: async (expression) => { - duplicateLookupExpressions.push(expression); - return [18, 7, 30, 7]; - }, - trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => { - trackedDuplicates.push({ noteId, duplicateNoteIds }); - }, + }), + ); + + assert.equal(await service.createSentenceCard(c.sentence, 0, 1), true); + assert.deepEqual(lookedUp, [c.sentence]); + assert.deepEqual(trackedDuplicates, c.expected); }); - - const created = await service.createSentenceCard('重複文', 0, 1); - - assert.equal(created, true); - assert.deepEqual(duplicateLookupExpressions, ['重複文']); - assert.deepEqual(trackedDuplicates, [{ noteId: 42, duplicateNoteIds: [7, 18, 30] }]); -}); - -test('CardCreationService does not track duplicate ids when pre-add lookup returns none', async () => { - const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = []; - - const service = new CardCreationService({ - getConfig: () => - ({ - deck: 'Mining', - fields: { - word: 'Expression', - sentence: 'Sentence', - audio: 'SentenceAudio', - }, - media: { - generateAudio: false, - generateImage: false, - }, - behavior: {}, - ai: false, - }) as AnkiConnectConfig, - getAiConfig: () => ({}), - getTimingTracker: () => ({}) as never, - getMpvClient: () => - ({ - currentVideoPath: '/video.mp4', - currentSubText: '字幕', - currentSubStart: 1, - currentSubEnd: 2, - currentTimePos: 1.5, - currentAudioStreamIndex: 0, - }) as never, - client: { - addNote: async () => 42, - addTags: async () => undefined, - notesInfo: async () => [], - updateNoteFields: async () => undefined, - storeMediaFile: async () => undefined, - findNotes: async () => [], - retrieveMediaFile: async () => '', - deleteNotes: async () => undefined, - }, - mediaGenerator: { - generateAudio: async () => null, - generateScreenshot: async () => null, - generateAnimatedImage: async () => null, - }, - showOsdNotification: () => undefined, - showUpdateResult: () => undefined, - showStatusNotification: () => undefined, - showNotification: async () => undefined, - beginUpdateProgress: () => undefined, - endUpdateProgress: () => undefined, - withUpdateProgress: async (_message, action) => action(), - resolveConfiguredFieldName: () => null, - resolveNoteFieldName: () => null, - getAnimatedImageLeadInSeconds: async () => 0, - extractFields: () => ({}), - processSentence: (sentence) => sentence, - setCardTypeFields: () => undefined, - mergeFieldValue: (_existing, newValue) => newValue, - formatMiscInfoPattern: () => '', - getEffectiveSentenceCardConfig: () => ({ - model: 'Sentence', - sentenceField: 'Sentence', - audioField: 'SentenceAudio', - lapisEnabled: false, - kikuEnabled: true, - fieldGroupingMode: 'manual', - }), - getFallbackDurationSeconds: () => 10, - appendKnownWordsFromNoteInfo: () => undefined, - removeKnownWordNote: () => undefined, - isUpdateInProgress: () => false, - setUpdateInProgress: () => undefined, - trackLastAddedNoteId: () => undefined, - findDuplicateNoteIds: async () => [], - trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => { - trackedDuplicates.push({ noteId, duplicateNoteIds }); - }, - }); - - const created = await service.createSentenceCard('重複なし', 0, 1); - - assert.equal(created, true); - assert.deepEqual(trackedDuplicates, []); -}); +} diff --git a/src/anki-integration/field-grouping-merge.test.ts b/src/anki-integration/field-grouping-merge.test.ts index 92c04146..2e4768db 100644 --- a/src/anki-integration/field-grouping-merge.test.ts +++ b/src/anki-integration/field-grouping-merge.test.ts @@ -500,3 +500,38 @@ test('Senren merge keeps duplicate SentenceFurigana when the kept field is empty assert.equal(merged.SentenceFurigana, 'duplicate furigana'); }); + +test('computeFieldGroupingMergedFields keeps independent groups for identical sentence, audio, and image values', async () => { + const { collaborator } = createCollaborator(); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 202, + 101, + makeNote(202, { + Sentence: 'same sentence', + SentenceAudio: '[sound:same.mp3]', + Picture: '', + ExpressionAudio: '[sound:same.mp3]', + }), + makeNote(101, { + Sentence: 'same sentence', + SentenceAudio: '[sound:same.mp3]', + Picture: '', + }), + false, + ); + + assert.equal( + merged.Sentence, + 'same sentencesame sentence', + ); + assert.equal( + merged.SentenceAudio, + '[sound:same.mp3][sound:same.mp3]', + ); + assert.equal( + merged.Picture, + '', + ); + assert.equal('ExpressionAudio' in merged, false); +}); diff --git a/src/anki-integration/known-word-cache-maturity.test.ts b/src/anki-integration/known-word-cache-maturity.test.ts index 37d881b7..99411e79 100644 --- a/src/anki-integration/known-word-cache-maturity.test.ts +++ b/src/anki-integration/known-word-cache-maturity.test.ts @@ -127,18 +127,6 @@ test('lifecycle config key is unchanged when maturity is disabled', () => { ); }); -test('a cache built under the old tier rules is invalidated', () => { - const config: AnkiConnectConfig = { - knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 }, - }; - // v1 rules put lapsed cards in young because the interval queries did not - // exclude is:learn; those persisted tiers must not be served under v2. - assert.notEqual( - getKnownWordCacheLifecycleConfig(config), - '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}', - ); -}); - test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => { const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig()); diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts index d1dcb188..abd6b32e 100644 --- a/src/anki-integration/note-update-workflow.test.ts +++ b/src/anki-integration/note-update-workflow.test.ts @@ -223,127 +223,6 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car }); }); -test('NoteUpdateWorkflow marks the configured word card kind instead of word-and-sentence', async () => { - const harness = createWorkflowHarness(); - harness.deps.getEffectiveSentenceCardConfig = () => ({ - sentenceField: 'Sentence', - lapisEnabled: false, - kikuEnabled: true, - fieldGroupingMode: 'manual', - wordCardKind: 'click', - }); - harness.deps.client.notesInfo = async () => - [ - { - noteId: 42, - fields: { - Expression: { value: 'taberu' }, - Sentence: { value: '' }, - IsWordAndSentenceCard: { value: 'x' }, - IsClickCard: { value: '' }, - IsSentenceCard: { value: '' }, - IsAudioCard: { value: '' }, - }, - }, - ] satisfies NoteUpdateWorkflowNoteInfo[]; - - await harness.workflow.execute(42); - - assert.equal(harness.updates.length, 1); - assert.deepEqual(harness.updates[0]?.fields, { - Sentence: 'subtitle-text', - IsClickCard: 'x', - IsWordAndSentenceCard: '', - IsSentenceCard: '', - IsAudioCard: '', - }); -}); - -test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is none', async () => { - const harness = createWorkflowHarness(); - harness.deps.getEffectiveSentenceCardConfig = () => ({ - sentenceField: 'Sentence', - lapisEnabled: false, - kikuEnabled: true, - fieldGroupingMode: 'manual', - wordCardKind: 'none', - }); - harness.deps.client.notesInfo = async () => - [ - { - noteId: 42, - fields: { - Expression: { value: 'taberu' }, - Sentence: { value: '' }, - IsWordAndSentenceCard: { value: '' }, - IsSentenceCard: { value: '' }, - IsAudioCard: { value: '' }, - }, - }, - ] satisfies NoteUpdateWorkflowNoteInfo[]; - - await harness.workflow.execute(42); - - assert.equal(harness.updates.length, 1); - assert.deepEqual(harness.updates[0]?.fields, { - Sentence: 'subtitle-text', - }); -}); - -test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => { - const harness = createWorkflowHarness(); - harness.deps.client.notesInfo = async () => - [ - { - noteId: 42, - fields: { - Expression: { value: 'taberu' }, - Sentence: { value: '' }, - IsWordAndSentenceCard: { value: '' }, - IsSentenceCard: { value: '' }, - IsAudioCard: { value: '' }, - }, - }, - ] satisfies NoteUpdateWorkflowNoteInfo[]; - - await harness.workflow.execute(42); - - assert.equal(harness.updates.length, 1); - assert.deepEqual(harness.updates[0]?.fields, { - Sentence: 'subtitle-text', - }); -}); - -test('NoteUpdateWorkflow preserves explicit sentence card type during sentence enrichment', async () => { - const harness = createWorkflowHarness(); - harness.deps.getEffectiveSentenceCardConfig = () => ({ - sentenceField: 'Sentence', - lapisEnabled: true, - kikuEnabled: false, - fieldGroupingMode: 'disabled', - }); - harness.deps.client.notesInfo = async () => - [ - { - noteId: 42, - fields: { - Expression: { value: 'sentence expression' }, - Sentence: { value: '' }, - IsWordAndSentenceCard: { value: '' }, - IsSentenceCard: { value: 'x' }, - IsAudioCard: { value: '' }, - }, - }, - ] satisfies NoteUpdateWorkflowNoteInfo[]; - - await harness.workflow.execute(42); - - assert.equal(harness.updates.length, 1); - assert.deepEqual(harness.updates[0]?.fields, { - Sentence: 'subtitle-text', - }); -}); - test('NoteUpdateWorkflow no-ops when note info is missing', async () => { const harness = createWorkflowHarness(); harness.deps.client.notesInfo = async () => []; diff --git a/src/ci-workflow.test.ts b/src/ci-workflow.test.ts deleted file mode 100644 index db4895c1..00000000 --- a/src/ci-workflow.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml'); -const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8'); -const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml'); -const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8'); -const packageJsonPath = resolve(__dirname, '../package.json'); -const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { - scripts: Record; -}; - -test('package scripts expose a sharded maintained source coverage lane with lcov output', () => { - assert.equal( - packageJson.scripts['test:coverage:src'], - 'bun run build:yomitan && bun run scripts/run-coverage-lane.ts bun-src-full --coverage-dir coverage/test-src', - ); -}); - -test('source and coverage scripts discover the same maintained source lane', () => { - const sourceLane = packageJson.scripts['test:src']?.match(/run-test-lane\.mjs\s+([^\s]+)/)?.[1]; - const coverageLane = packageJson.scripts['test:coverage:src']?.match( - /run-coverage-lane\.ts\s+([^\s]+)/, - )?.[1]; - - assert.equal(sourceLane, 'bun-src-full'); - assert.equal(coverageLane, sourceLane); -}); - -test('environment suite owns launcher smoke execution', () => { - assert.match( - packageJson.scripts['test:env'] ?? '', - /^bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src$/, - ); -}); - -test('ci delegates its gate instead of duplicating quality steps', () => { - assert.match( - ciWorkflow, - /build-test-audit:\s*\n\s*uses: \.\/\.github\/workflows\/quality-gate\.yml/, - ); - assert.doesNotMatch(ciWorkflow, /oven-sh\/setup-bun/); - assert.doesNotMatch(ciWorkflow, /bun run test:coverage:src/); - assert.doesNotMatch(ciWorkflow, /bun run test:env/); -}); - -test('main docs deploy exists, serializes deploys, and uses Cloudflare credentials', () => { - assert.match(docsPagesWorkflow, /name: Docs Pages/); - assert.match(docsPagesWorkflow, /branches:\s*\n\s*-\s*main/); - assert.match(docsPagesWorkflow, /group:\s*docs-pages-production/); - assert.match(docsPagesWorkflow, /CLOUDFLARE_API_TOKEN/); - assert.match(docsPagesWorkflow, /CLOUDFLARE_ACCOUNT_ID/); - assert.match(docsPagesWorkflow, /CLOUDFLARE_PAGES_PROJECT_NAME/); - assert.match(docsPagesWorkflow, /pages deploy \.\.\/\.tmp\/docs-versioned-site/); - assert.match(docsPagesWorkflow, /--branch main/); -}); - -test('docs deploy syncs frozen archives to R2 and ships the archive Pages Function', () => { - assert.doesNotMatch(docsPagesWorkflow, /actions\/cache@/); - assert.match(docsPagesWorkflow, /DOCS_ARCHIVE_R2_ACCESS_KEY_ID/); - assert.match(docsPagesWorkflow, /DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY/); - assert.match(docsPagesWorkflow, /--require-archives/); - assert.match(docsPagesWorkflow, /--rebuild-archives=\$\{REBUILD_ARCHIVES\}/); - assert.match(docsPagesWorkflow, /workingDirectory: docs-site/); -}); - -test('docs deploy skips invalid release tags without failing the workflow', () => { - assert.match(docsPagesWorkflow, /id:\s*tag_guard/); - assert.match(docsPagesWorkflow, /stable_tag=false/); - assert.doesNotMatch(docsPagesWorkflow, /exit 78/); - assert.match(docsPagesWorkflow, /if:\s*steps\.tag_guard\.outputs\.stable_tag != 'false'/); -}); diff --git a/src/cli/help.test.ts b/src/cli/help.test.ts deleted file mode 100644 index 7e46f330..00000000 --- a/src/cli/help.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { printHelp } from './help'; - -test('printHelp includes configured texthooker port', () => { - const original = console.log; - let output = ''; - console.log = (value?: unknown) => { - output += String(value); - }; - - try { - printHelp(7777); - } finally { - console.log = original; - } - - assert.match(output, /--help\s+Show this help/); - assert.match(output, /default: 7777/); - assert.match(output, /--launch-mpv.*Launch mpv with SubMiner defaults and exit/); - assert.match(output, /--stats\s+Open the stats dashboard in your browser/); - assert.match(output, /--open-browser\s+Open texthooker in your default browser/); - assert.doesNotMatch(output, /--refresh-known-words/); - assert.match(output, /--setup\s+Open first-run setup window/); - assert.match(output, /--settings\s+Open SubMiner settings window/); - assert.match(output, /--yomitan\s+Open Yomitan settings window/); - assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/); - assert.match(output, /--anilist-status/); - assert.match(output, /--anilist-retry-queue/); - assert.match(output, /--dictionary/); - assert.match(output, /--dictionary-target/); - assert.match(output, /--jellyfin\s+Open Jellyfin setup window/); - assert.match(output, /--jellyfin-login/); - assert.match(output, /--jellyfin-subtitles/); - assert.match(output, /--jellyfin-play/); -}); diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 115f32fc..1b1c383c 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -4,12 +4,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { ConfigService, ConfigStartupParseError } from './service'; -import { - DEFAULT_CONFIG, - DEFAULT_KEYBINDINGS, - RUNTIME_OPTION_REGISTRY, - deepMergeRawConfig, -} from './definitions'; +import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, deepMergeRawConfig } from './definitions'; import { parseConfigContent } from './parse'; import { generateConfigTemplate } from './template'; import { @@ -19,11 +14,6 @@ import { type SubtitleCssScope, } from '../settings/subtitle-style-css'; -const DEFAULT_SUBTITLE_FONT_FAMILY = - 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP'; -const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY; -const DEFAULT_SUBTITLE_TEXT_SHADOW = - '-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)'; const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar']; function makeTempDir(): string { @@ -51,122 +41,10 @@ function buildDefaultSubtitleCssDeclarations(scope: SubtitleCssScope): Record { - const dir = makeTempDir(); - const service = new ConfigService(dir); - const config = service.getConfig(); - assert.equal(config.websocket.enabled, false); - assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port); - assert.equal(config.annotationWebsocket.enabled, false); - assert.equal(config.annotationWebsocket.port, DEFAULT_CONFIG.annotationWebsocket.port); - assert.equal(config.texthooker.launchAtStartup, false); - assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true); - assert.deepEqual(config.ankiConnect.tags, ['SubMiner']); - assert.equal(config.ankiConnect.media.audioPadding, 0); - assert.equal(config.anilist.enabled, false); - assert.equal(config.subtitleStyle.nameMatchImagesEnabled, false); - assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168); - assert.equal(config.anilist.characterDictionary.maxLoaded, 3); - assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete'); - assert.equal(config.anilist.characterDictionary.profileScope, 'all'); - assert.equal(config.anilist.characterDictionary.collapsibleSections.description, false); - assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false); - assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false); - assert.equal(config.yomitan.externalProfilePath, ''); - assert.equal(config.jellyfin.remoteControlEnabled, true); - assert.equal(config.jellyfin.remoteControlAutoConnect, true); - assert.equal(config.jellyfin.autoAnnounce, false); - assert.equal('clientName' in config.jellyfin, false); - assert.equal('remoteControlDeviceName' in config.jellyfin, false); - assert.equal('deviceId' in config.jellyfin, false); - assert.equal('clientVersion' in config.jellyfin, false); - assert.equal(config.youtube.mediaCache.mode, 'direct'); - assert.equal(config.youtube.mediaCache.maxHeight, 720); - assert.equal(config.ai.enabled, false); - assert.equal(config.ai.apiKeyCommand, ''); - assert.equal(config.texthooker.openBrowser, false); - assert.equal(config.controller.enabled, false); - assert.equal(config.ankiConnect.enabled, true); - assert.deepEqual(config.ankiConnect.ai, { - enabled: false, - model: '', - systemPrompt: '', - }); - assert.equal(config.ankiConnect.media.normalizeAudio, true); - assert.equal(config.ankiConnect.media.mirrorMpvVolume, true); - assert.equal(config.startupWarmups.lowPowerMode, false); - assert.equal(config.startupWarmups.mecab, true); - assert.equal(config.startupWarmups.yomitanExtension, true); - assert.equal(config.startupWarmups.subtitleDictionaries, true); - assert.equal(config.startupWarmups.jellyfinRemoteSession, false); - assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A'); - assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D'); - assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash'); - assert.equal(config.shortcuts.toggleNotificationHistory, 'CommandOrControl+N'); - assert.equal(config.discordPresence.enabled, true); - assert.equal(config.discordPresence.updateIntervalMs, 3_000); - assert.equal(config.subtitleStyle.backgroundColor, 'transparent'); - assert.equal(config.subtitleStyle.primaryDefaultMode, 'visible'); - assert.equal(config.subtitleStyle.preserveLineBreaks, false); - assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true); - assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true); - assert.equal(config.subtitleStyle.primaryVisibleOnYomitanPopup, true); - assert.equal(config.subtitleSidebar.enabled, true); - assert.equal(config.subtitleSidebar.pauseVideoOnHover, true); - assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6'); - assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent'); - assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY); - assert.equal(config.subtitleStyle.fontWeight, '600'); - assert.equal(config.subtitleStyle.lineHeight, 1.35); - assert.equal(config.subtitleStyle.letterSpacing, '-0.01em'); - assert.equal(config.subtitleStyle.wordSpacing, 0); - assert.equal(config.subtitleStyle.fontKerning, 'normal'); - assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision'); - assert.equal(config.subtitleStyle.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW); - assert.equal(config.subtitleStyle.paintOrder, ''); - assert.equal(config.subtitleStyle.WebkitTextStroke, ''); - assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)'); - assert.equal(config.subtitleStyle.jlptColors.N4, '#8bd5ca'); - assert.equal(config.subtitleStyle.secondary.fontFamily, DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY); - assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5'); - assert.equal(config.subtitleStyle.secondary.fontWeight, '600'); - assert.equal(config.subtitleStyle.secondary.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW); - assert.equal(config.subtitleStyle.secondary.paintOrder, ''); - assert.equal(config.subtitleStyle.secondary.WebkitTextStroke, ''); - assert.equal(config.subtitleStyle.secondary.backgroundColor, 'transparent'); - assert.deepEqual(config.subtitleSidebar.css, {}); - assert.equal(config.subtitleSidebar.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY); - assert.equal(config.immersionTracking.enabled, true); - assert.equal(config.immersionTracking.dbPath, ''); - assert.equal(config.immersionTracking.batchSize, 25); - assert.equal(config.immersionTracking.flushIntervalMs, 500); - assert.equal(config.immersionTracking.queueCap, 1000); - assert.equal(config.immersionTracking.payloadCapBytes, 256); - assert.equal(config.immersionTracking.maintenanceIntervalMs, 86_400_000); - assert.equal(config.immersionTracking.retention.eventsDays, 0); - assert.equal(config.immersionTracking.retention.telemetryDays, 0); - assert.equal(config.immersionTracking.retention.sessionsDays, 0); - assert.equal(config.immersionTracking.retention.dailyRollupsDays, 0); - assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 0); - assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 0); - assert.equal(config.immersionTracking.retentionMode, 'preset'); - assert.equal(config.immersionTracking.retentionPreset, 'balanced'); - assert.equal(config.immersionTracking.lifetimeSummaries?.global, true); - assert.equal(config.immersionTracking.lifetimeSummaries?.anime, true); - assert.equal(config.immersionTracking.lifetimeSummaries?.media, true); - assert.equal(config.stats.autoOpenBrowser, false); - assert.equal(config.updates.enabled, true); - assert.equal(config.updates.checkIntervalHours, 24); - assert.equal(config.updates.notificationType, 'overlay'); - assert.equal(config.updates.channel, 'stable'); - assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath); - assert.equal(config.mpv.backend, 'auto'); - assert.equal(config.mpv.profile, ''); - assert.equal(config.mpv.autoStartSubMiner, true); - assert.equal(config.mpv.pauseUntilOverlayReady, true); - assert.equal(config.mpv.subminerBinaryPath, ''); - assert.equal(config.mpv.aniskipEnabled, true); - assert.equal(config.mpv.aniskipButtonKey, 'TAB'); +test('loads defaults without warnings when config is missing', () => { + const service = new ConfigService(makeTempDir()); + assert.deepEqual(service.getConfig(), DEFAULT_CONFIG); + assert.deepEqual(service.getWarnings(), []); }); test('rejects invalid mpv volume mirroring values', () => { @@ -375,44 +253,6 @@ test('resolves legacy subtitle appearance options without rewriting config on lo }); }); -test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => { - const validDir = makeTempDir(); - fs.writeFileSync( - path.join(validDir, 'config.jsonc'), - `{ - "subtitleStyle": { - "preserveLineBreaks": true - } - }`, - 'utf-8', - ); - - const validService = new ConfigService(validDir); - assert.equal(validService.getConfig().subtitleStyle.preserveLineBreaks, true); - - const invalidDir = makeTempDir(); - fs.writeFileSync( - path.join(invalidDir, 'config.jsonc'), - `{ - "subtitleStyle": { - "preserveLineBreaks": "yes" - } - }`, - 'utf-8', - ); - - const invalidService = new ConfigService(invalidDir); - assert.equal( - invalidService.getConfig().subtitleStyle.preserveLineBreaks, - DEFAULT_CONFIG.subtitleStyle.preserveLineBreaks, - ); - assert.ok( - invalidService - .getWarnings() - .some((warning) => warning.path === 'subtitleStyle.preserveLineBreaks'), - ); -}); - test('parses texthooker.launchAtStartup and warns on invalid values', () => { const validDir = makeTempDir(); fs.writeFileSync( @@ -564,120 +404,6 @@ test('parses annotationWebsocket settings and warns on invalid values', () => { ); }); -test('parses subtitleStyle.autoPauseVideoOnHover and warns on invalid values', () => { - const validDir = makeTempDir(); - fs.writeFileSync( - path.join(validDir, 'config.jsonc'), - `{ - "subtitleStyle": { - "autoPauseVideoOnHover": true - } - }`, - 'utf-8', - ); - - const validService = new ConfigService(validDir); - assert.equal(validService.getConfig().subtitleStyle.autoPauseVideoOnHover, true); - - const invalidDir = makeTempDir(); - fs.writeFileSync( - path.join(invalidDir, 'config.jsonc'), - `{ - "subtitleStyle": { - "autoPauseVideoOnHover": "yes" - } - }`, - 'utf-8', - ); - - const invalidService = new ConfigService(invalidDir); - assert.equal( - invalidService.getConfig().subtitleStyle.autoPauseVideoOnHover, - DEFAULT_CONFIG.subtitleStyle.autoPauseVideoOnHover, - ); - assert.ok( - invalidService - .getWarnings() - .some((warning) => warning.path === 'subtitleStyle.autoPauseVideoOnHover'), - ); -}); - -test('parses subtitleStyle.autoPauseVideoOnYomitanPopup and warns on invalid values', () => { - const validDir = makeTempDir(); - fs.writeFileSync( - path.join(validDir, 'config.jsonc'), - `{ - "subtitleStyle": { - "autoPauseVideoOnYomitanPopup": true - } - }`, - 'utf-8', - ); - - const validService = new ConfigService(validDir); - assert.equal(validService.getConfig().subtitleStyle.autoPauseVideoOnYomitanPopup, true); - - const invalidDir = makeTempDir(); - fs.writeFileSync( - path.join(invalidDir, 'config.jsonc'), - `{ - "subtitleStyle": { - "autoPauseVideoOnYomitanPopup": "yes" - } - }`, - 'utf-8', - ); - - const invalidService = new ConfigService(invalidDir); - assert.equal( - invalidService.getConfig().subtitleStyle.autoPauseVideoOnYomitanPopup, - DEFAULT_CONFIG.subtitleStyle.autoPauseVideoOnYomitanPopup, - ); - assert.ok( - invalidService - .getWarnings() - .some((warning) => warning.path === 'subtitleStyle.autoPauseVideoOnYomitanPopup'), - ); -}); - -test('parses subtitleStyle.primaryVisibleOnYomitanPopup and warns on invalid values', () => { - const validDir = makeTempDir(); - fs.writeFileSync( - path.join(validDir, 'config.jsonc'), - `{ - "subtitleStyle": { - "primaryVisibleOnYomitanPopup": false - } - }`, - 'utf-8', - ); - - const validService = new ConfigService(validDir); - assert.equal(validService.getConfig().subtitleStyle.primaryVisibleOnYomitanPopup, false); - - const invalidDir = makeTempDir(); - fs.writeFileSync( - path.join(invalidDir, 'config.jsonc'), - `{ - "subtitleStyle": { - "primaryVisibleOnYomitanPopup": "yes" - } - }`, - 'utf-8', - ); - - const invalidService = new ConfigService(invalidDir); - assert.equal( - invalidService.getConfig().subtitleStyle.primaryVisibleOnYomitanPopup, - DEFAULT_CONFIG.subtitleStyle.primaryVisibleOnYomitanPopup, - ); - assert.ok( - invalidService - .getWarnings() - .some((warning) => warning.path === 'subtitleStyle.primaryVisibleOnYomitanPopup'), - ); -}); - test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => { const validDir = makeTempDir(); fs.writeFileSync( @@ -716,47 +442,6 @@ test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => { ); }); -test('parses subtitleStyle.nameMatchColor and warns on invalid values', () => { - const validDir = makeTempDir(); - fs.writeFileSync( - path.join(validDir, 'config.jsonc'), - `{ - "subtitleStyle": { - "nameMatchColor": "#eed49f" - } - }`, - 'utf-8', - ); - - const validService = new ConfigService(validDir); - assert.equal( - ((validService.getConfig().subtitleStyle as unknown as Record) - .nameMatchColor ?? null) as string | null, - '#eed49f', - ); - - const invalidDir = makeTempDir(); - fs.writeFileSync( - path.join(invalidDir, 'config.jsonc'), - `{ - "subtitleStyle": { - "nameMatchColor": "pink" - } - }`, - 'utf-8', - ); - - const invalidService = new ConfigService(invalidDir); - assert.equal( - ((invalidService.getConfig().subtitleStyle as unknown as Record) - .nameMatchColor ?? null) as string | null, - '#f5bde6', - ); - assert.ok( - invalidService.getWarnings().some((warning) => warning.path === 'subtitleStyle.nameMatchColor'), - ); -}); - test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values', () => { const validDir = makeTempDir(); fs.writeFileSync( @@ -835,82 +520,6 @@ test('parses subtitleStyle.hoverTokenBackgroundColor null as invalid instead of ); }); -test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () => { - const validDir = makeTempDir(); - fs.writeFileSync( - path.join(validDir, 'config.jsonc'), - `{ - "subtitleStyle": { - "nameMatchEnabled": false - } - }`, - 'utf-8', - ); - - const validService = new ConfigService(validDir); - assert.equal(validService.getConfig().subtitleStyle.nameMatchEnabled, false); - - const invalidDir = makeTempDir(); - fs.writeFileSync( - path.join(invalidDir, 'config.jsonc'), - `{ - "subtitleStyle": { - "nameMatchEnabled": "no" - } - }`, - 'utf-8', - ); - - const invalidService = new ConfigService(invalidDir); - assert.equal( - invalidService.getConfig().subtitleStyle.nameMatchEnabled, - DEFAULT_CONFIG.subtitleStyle.nameMatchEnabled, - ); - assert.ok( - invalidService - .getWarnings() - .some((warning) => warning.path === 'subtitleStyle.nameMatchEnabled'), - ); -}); - -test('parses subtitleStyle.nameMatchImagesEnabled and warns on invalid values', () => { - const validDir = makeTempDir(); - fs.writeFileSync( - path.join(validDir, 'config.jsonc'), - `{ - "subtitleStyle": { - "nameMatchImagesEnabled": true - } - }`, - 'utf-8', - ); - - const validService = new ConfigService(validDir); - assert.equal(validService.getConfig().subtitleStyle.nameMatchImagesEnabled, true); - - const invalidDir = makeTempDir(); - fs.writeFileSync( - path.join(invalidDir, 'config.jsonc'), - `{ - "subtitleStyle": { - "nameMatchImagesEnabled": "yes" - } - }`, - 'utf-8', - ); - - const invalidService = new ConfigService(invalidDir); - assert.equal( - invalidService.getConfig().subtitleStyle.nameMatchImagesEnabled, - DEFAULT_CONFIG.subtitleStyle.nameMatchImagesEnabled, - ); - assert.ok( - invalidService - .getWarnings() - .some((warning) => warning.path === 'subtitleStyle.nameMatchImagesEnabled'), - ); -}); - test('parses anilist.enabled and warns for invalid value', () => { const dir = makeTempDir(); fs.writeFileSync( @@ -934,77 +543,6 @@ test('parses anilist.enabled and warns for invalid value', () => { assert.equal(service.getConfig().anilist.enabled, true); }); -test('parses anilist.characterDictionary config with clamping and enum validation', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "anilist": { - "characterDictionary": { - "enabled": true, - "refreshTtlHours": 0, - "maxLoaded": 1000, - "evictionPolicy": "remove", - "profileScope": "everywhere" - } - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - const warnings = service.getWarnings(); - - assert.equal(config.anilist.characterDictionary.refreshTtlHours, 1); - assert.equal(config.anilist.characterDictionary.maxLoaded, 20); - assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete'); - assert.equal(config.anilist.characterDictionary.profileScope, 'all'); - assert.ok( - warnings.some((warning) => warning.path === 'anilist.characterDictionary.refreshTtlHours'), - ); - assert.ok(warnings.some((warning) => warning.path === 'anilist.characterDictionary.maxLoaded')); - assert.ok( - warnings.some((warning) => warning.path === 'anilist.characterDictionary.evictionPolicy'), - ); - assert.ok( - warnings.some((warning) => warning.path === 'anilist.characterDictionary.profileScope'), - ); -}); - -test('parses anilist.characterDictionary.collapsibleSections booleans and warns on invalid values', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "anilist": { - "characterDictionary": { - "collapsibleSections": { - "description": true, - "characterInformation": "yes", - "voicedBy": true - } - } - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - const warnings = service.getWarnings(); - - assert.equal(config.anilist.characterDictionary.collapsibleSections.description, true); - assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false); - assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, true); - assert.ok( - warnings.some( - (warning) => - warning.path === 'anilist.characterDictionary.collapsibleSections.characterInformation', - ), - ); -}); - test('parses jellyfin remote control fields and ignores legacy identity fields', () => { const dir = makeTempDir(); fs.writeFileSync( @@ -1155,31 +693,6 @@ test('invalid startup warmup values warn and keep defaults', () => { assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.jellyfinRemoteSession')); }); -test('parses discordPresence fields and warns for invalid types', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "discordPresence": { - "enabled": true, - "updateIntervalMs": 3000, - "debounceMs": 250 - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - assert.equal(config.discordPresence.enabled, true); - assert.equal(config.discordPresence.updateIntervalMs, 3000); - assert.equal(config.discordPresence.debounceMs, 250); - - service.patchRawConfig({ discordPresence: { enabled: 'yes' as never } }); - assert.equal(service.getConfig().discordPresence.enabled, DEFAULT_CONFIG.discordPresence.enabled); - assert.ok(service.getWarnings().some((warning) => warning.path === 'discordPresence.enabled')); -}); - test('accepts immersion tracking config values', () => { const dir = makeTempDir(); fs.writeFileSync( @@ -1477,17 +990,6 @@ test('reloadConfigStrict parse failure does not mutate raw config or warnings', assert.deepEqual(service.getWarnings(), beforeWarnings); }); -test('SM-012 config paths do not use JSON serialize-clone helpers', () => { - const definitionsSource = fs.readFileSync( - path.join(process.cwd(), 'src/config/definitions.ts'), - 'utf-8', - ); - const serviceSource = fs.readFileSync(path.join(process.cwd(), 'src/config/service.ts'), 'utf-8'); - - assert.equal(definitionsSource.includes('JSON.parse(JSON.stringify('), false); - assert.equal(serviceSource.includes('JSON.parse(JSON.stringify('), false); -}); - test('getRawConfig returns a detached clone', () => { const dir = makeTempDir(); fs.writeFileSync( @@ -1574,149 +1076,65 @@ test('warning emission order is deterministic across reloads', () => { ); }); -test('accepts valid logging.level', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "logging": { - "level": "warn" - } - }`, - 'utf-8', - ); +const loggingCases: Array<{ + name: string; + logging: Record; + expected: Partial; + warningPath?: string; +}> = [ + { name: 'accepts valid logging.level', logging: { level: 'warn' }, expected: { level: 'warn' } }, + { name: 'accepts valid logging.rotation', logging: { rotation: 14 }, expected: { rotation: 14 } }, + { + name: 'accepts valid logging file toggles', + logging: { files: { app: false, launcher: true, mpv: true } }, + expected: { files: { app: false, launcher: true, mpv: true } }, + }, + { + name: 'falls back for invalid logging.level and reports warning', + logging: { level: 'trace' }, + expected: { level: DEFAULT_CONFIG.logging.level }, + warningPath: 'logging.level', + }, + { + name: 'falls back for invalid logging.rotation and reports warning', + logging: { rotation: 0 }, + expected: { rotation: DEFAULT_CONFIG.logging.rotation }, + warningPath: 'logging.rotation', + }, + { + name: 'falls back for invalid logging file toggles and reports warning', + logging: { files: { mpv: 'yes' } }, + expected: { files: DEFAULT_CONFIG.logging.files }, + warningPath: 'logging.files.mpv', + }, + { + name: 'falls back for invalid logging files object and reports warning', + logging: { files: false }, + expected: { files: DEFAULT_CONFIG.logging.files }, + warningPath: 'logging.files', + }, +]; - const service = new ConfigService(dir); - const config = service.getConfig(); +for (const c of loggingCases) { + test(c.name, () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + JSON.stringify({ logging: c.logging }), + 'utf-8', + ); + const service = new ConfigService(dir); + const logging = service.getConfig().logging; - assert.equal(config.logging.level, 'warn'); -}); - -test('accepts valid logging.rotation', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "logging": { - "rotation": 14 - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - - assert.equal(config.logging.rotation, 14); -}); - -test('accepts valid logging file toggles', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "logging": { - "files": { - "app": false, - "launcher": true, - "mpv": true - } - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - - assert.deepEqual(config.logging.files, { - app: false, - launcher: true, - mpv: true, + for (const [key, value] of Object.entries(c.expected)) { + assert.deepEqual(logging[key as keyof typeof logging], value, key); + } + assert.deepEqual( + service.getWarnings().map((warning) => warning.path), + c.warningPath ? [c.warningPath] : [], + ); }); -}); - -test('falls back for invalid logging.level and reports warning', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "logging": { - "level": "trace" - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - const warnings = service.getWarnings(); - - assert.equal(config.logging.level, DEFAULT_CONFIG.logging.level); - assert.ok(warnings.some((warning) => warning.path === 'logging.level')); -}); - -test('falls back for invalid logging.rotation and reports warning', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "logging": { - "rotation": 0 - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - const warnings = service.getWarnings(); - - assert.equal(config.logging.rotation, DEFAULT_CONFIG.logging.rotation); - assert.ok(warnings.some((warning) => warning.path === 'logging.rotation')); -}); - -test('falls back for invalid logging file toggles and reports warning', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "logging": { - "files": { - "mpv": "yes" - } - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - const warnings = service.getWarnings(); - - assert.equal(config.logging.files.mpv, DEFAULT_CONFIG.logging.files.mpv); - assert.ok(warnings.some((warning) => warning.path === 'logging.files.mpv')); -}); - -test('falls back for invalid logging files object and reports warning', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "logging": { - "files": false - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - const warnings = service.getWarnings(); - - assert.deepEqual(config.logging.files, DEFAULT_CONFIG.logging.files); - assert.ok(warnings.some((warning) => warning.path === 'logging.files')); -}); +} test('warns and ignores unknown top-level config keys', () => { const dir = makeTempDir(); @@ -2193,91 +1611,20 @@ test('controller button index config rejects fractional values', () => { ); }); -test('runtime options registry is centralized', () => { - const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id); - assert.deepEqual(ids, [ - 'anki.autoUpdateNewCards', - 'anki.mediaReviewTiming', - 'subtitle.annotation.knownWords.highlightEnabled', - 'subtitle.annotation.knownWords.maturityEnabled', - 'subtitle.annotation.nPlusOne', - 'subtitle.annotation.jlpt', - 'subtitle.annotation.frequency', - 'anki.nPlusOneMatchMode', - 'anki.kikuFieldGrouping', - 'anki.senrenFieldGrouping', - ]); -}); - -test('validates ankiConnect knownWords behavior values', () => { +test('invalid ankiConnect knownWords, n+1, and tags values warn and fall back', () => { const dir = makeTempDir(); fs.writeFileSync( path.join(dir, 'config.jsonc'), `{ "ankiConnect": { + "tags": ["SubMiner", 123], "knownWords": { "highlightEnabled": "yes", "refreshMinutes": -5, - "addMinedWordsImmediately": "no" - } - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - const warnings = service.getWarnings(); - - assert.equal( - config.ankiConnect.knownWords.highlightEnabled, - DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled, - ); - assert.equal( - config.ankiConnect.knownWords.refreshMinutes, - DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes, - ); - assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.highlightEnabled')); - assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.refreshMinutes')); - assert.equal( - config.ankiConnect.knownWords.addMinedWordsImmediately, - DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately, - ); - assert.ok( - warnings.some((warning) => warning.path === 'ankiConnect.knownWords.addMinedWordsImmediately'), - ); -}); - -test('accepts valid ankiConnect knownWords behavior values', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "ankiConnect": { - "knownWords": { - "highlightEnabled": true, - "refreshMinutes": 120, - "addMinedWordsImmediately": false - } - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - - assert.equal(config.ankiConnect.knownWords.highlightEnabled, true); - assert.equal(config.ankiConnect.knownWords.refreshMinutes, 120); - assert.equal(config.ankiConnect.knownWords.addMinedWordsImmediately, false); -}); - -test('validates ankiConnect n+1 minimum sentence word count', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "ankiConnect": { + "addMinedWordsImmediately": "no", + "matchMode": "bad-mode", + "decks": "not-an-array" + }, "nPlusOne": { "minSentenceWords": 0 } @@ -2287,22 +1634,43 @@ test('validates ankiConnect n+1 minimum sentence word count', () => { ); const service = new ConfigService(dir); - const config = service.getConfig(); - const warnings = service.getWarnings(); + const { knownWords, nPlusOne, tags } = service.getConfig().ankiConnect; + const defaults = DEFAULT_CONFIG.ankiConnect; - assert.equal( - config.ankiConnect.nPlusOne.minSentenceWords, - DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords, - ); - assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.minSentenceWords')); + assert.equal(knownWords.highlightEnabled, defaults.knownWords.highlightEnabled); + assert.equal(knownWords.refreshMinutes, defaults.knownWords.refreshMinutes); + assert.equal(knownWords.addMinedWordsImmediately, defaults.knownWords.addMinedWordsImmediately); + assert.equal(knownWords.matchMode, defaults.knownWords.matchMode); + assert.deepEqual(knownWords.decks, {}); + assert.equal(nPlusOne.minSentenceWords, defaults.nPlusOne.minSentenceWords); + assert.deepEqual(tags, ['SubMiner']); + + const warnedPaths = service.getWarnings().map((warning) => warning.path); + for (const expected of [ + 'ankiConnect.tags', + 'ankiConnect.knownWords.highlightEnabled', + 'ankiConnect.knownWords.refreshMinutes', + 'ankiConnect.knownWords.addMinedWordsImmediately', + 'ankiConnect.knownWords.matchMode', + 'ankiConnect.knownWords.decks', + 'ankiConnect.nPlusOne.minSentenceWords', + ]) { + assert.ok(warnedPaths.includes(expected), expected); + } }); -test('accepts valid ankiConnect n+1 minimum sentence word count', () => { +test('accepts valid ankiConnect knownWords and n+1 values', () => { const dir = makeTempDir(); fs.writeFileSync( path.join(dir, 'config.jsonc'), `{ "ankiConnect": { + "knownWords": { + "highlightEnabled": true, + "refreshMinutes": 120, + "addMinedWordsImmediately": false, + "matchMode": "surface" + }, "nPlusOne": { "minSentenceWords": 4 } @@ -2312,54 +1680,14 @@ test('accepts valid ankiConnect n+1 minimum sentence word count', () => { ); const service = new ConfigService(dir); - const config = service.getConfig(); + const { knownWords, nPlusOne } = service.getConfig().ankiConnect; - assert.equal(config.ankiConnect.nPlusOne.minSentenceWords, 4); -}); - -test('validates ankiConnect knownWords match mode values', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "ankiConnect": { - "knownWords": { - "matchMode": "bad-mode" - } - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - const warnings = service.getWarnings(); - - assert.equal( - config.ankiConnect.knownWords.matchMode, - DEFAULT_CONFIG.ankiConnect.knownWords.matchMode, - ); - assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.matchMode')); -}); - -test('accepts valid ankiConnect knownWords match mode values', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "ankiConnect": { - "knownWords": { - "matchMode": "surface" - } - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - - assert.equal(config.ankiConnect.knownWords.matchMode, 'surface'); + assert.equal(knownWords.highlightEnabled, true); + assert.equal(knownWords.refreshMinutes, 120); + assert.equal(knownWords.addMinedWordsImmediately, false); + assert.equal(knownWords.matchMode, 'surface'); + assert.equal(nPlusOne.minSentenceWords, 4); + assert.deepEqual(service.getWarnings(), []); }); test('ignores invalid legacy ankiConnect n+1 color value after migration attempt', () => { @@ -2826,235 +2154,6 @@ test('forces Senren off when Kiku is also enabled and validates Senren fieldGrou ); }); -test('warns and falls back when isSenren.enabled is not boolean', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "ankiConnect": { - "isSenren": { "enabled": "true" } - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - - assert.equal( - service.getConfig().ankiConnect.isSenren.enabled, - DEFAULT_CONFIG.ankiConnect.isSenren.enabled, - ); - assert.ok( - service.getWarnings().some((warning) => warning.path === 'ankiConnect.isSenren.enabled'), - ); -}); - -test('accepts valid ankiConnect knownWords deck object', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "ankiConnect": { - "knownWords": { - "decks": { "Deck One": ["Word", "Reading"], "Deck Two": ["Expression"] } - } - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - - assert.deepEqual(config.ankiConnect.knownWords.decks, { - 'Deck One': ['Word', 'Reading'], - 'Deck Two': ['Expression'], - }); -}); - -test('accepts valid ankiConnect tags list', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "ankiConnect": { - "tags": ["SubMiner", "Mining"] - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - - assert.deepEqual(config.ankiConnect.tags, ['SubMiner', 'Mining']); -}); - -test('falls back to default when ankiConnect tags list is invalid', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "ankiConnect": { - "tags": ["SubMiner", 123] - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - const warnings = service.getWarnings(); - - assert.deepEqual(config.ankiConnect.tags, ['SubMiner']); - assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.tags')); -}); - -test('falls back to default when ankiConnect knownWords deck list is invalid', () => { - const dir = makeTempDir(); - fs.writeFileSync( - path.join(dir, 'config.jsonc'), - `{ - "ankiConnect": { - "knownWords": { - "decks": "not-an-array" - } - } - }`, - 'utf-8', - ); - - const service = new ConfigService(dir); - const config = service.getConfig(); - const warnings = service.getWarnings(); - - assert.deepEqual(config.ankiConnect.knownWords.decks, {}); - assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks')); -}); - -test('template generator includes known keys', () => { - const output = generateConfigTemplate(DEFAULT_CONFIG); - assert.match(output, /"ai":/); - assert.match(output, /"ankiConnect":/); - assert.match(output, /"controller":/); - assert.match(output, /"logging":/); - assert.match(output, /"websocket":/); - assert.match(output, /"discordPresence":/); - assert.match(output, /"startupWarmups":/); - assert.match(output, /"updates":/); - assert.match(output, /"youtube":/); - assert.doesNotMatch(output, /"deviceId":/); - assert.doesNotMatch(output, /"clientVersion":/); - assert.doesNotMatch(output, /"youtubeSubgen":/); - assert.match(output, /"characterDictionary":\s*\{/); - assert.doesNotMatch(output, /"characterDictionary":\s*\{\s*"enabled":/); - assert.match(output, /"preserveLineBreaks": false/); - assert.match(output, /"knownWords"\s*:\s*\{/); - assert.match(output, /"knownWordColor": "#a6da95"/); - assert.match(output, /"nPlusOneColor": "#c6a0f6"/); - assert.match(output, /"nPlusOne"\s*:\s*\{/); - assert.match(output, /"minSentenceWords": 3/); - assert.match(output, /auto-generated from src\/config\/definitions.ts/); - assert.match( - output, - /"level": "warn",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/, - ); - assert.match( - output, - /"enabled": false,? \/\/ Built-in subtitle websocket server mode\. Values: auto \| true \| false/, - ); - assert.match( - output, - /"enabled": false,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/, - ); - assert.match( - output, - /"scrollPixelsPerSecond": 900,? \/\/ Base popup scroll speed for controller stick input\./, - ); - assert.match( - output, - /"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/, - ); - assert.match( - output, - /"preferredGamepadId": "",? \/\/ Preferred controller id saved from the controller config modal\./, - ); - assert.match( - output, - /"toggleLookup": \{\s*"kind": "button"[\s\S]*\},? \/\/ Controller binding descriptor for toggling lookup\. Use Alt\+C learn mode or set a raw button\/axis descriptor manually\./, - ); - assert.match( - output, - /"kind": "button",? \/\/ Discrete binding input source kind\. When kind is "axis", set both axisIndex and direction\. Values: none \| button \| axis/, - ); - assert.match(output, /"toggleLookup": \{\s*"kind": "button"/); - assert.match(output, /"leftStickHorizontal": \{\s*"kind": "axis"/); - assert.match( - output, - /"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/, - ); - assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./); - assert.match( - output, - /"openBrowser": false,? \/\/ Open the texthooker page in the default browser when the server starts\. Values: true \| false/, - ); - assert.match( - output, - /"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/, - ); - assert.match( - output, - /"autoPauseVideoOnYomitanPopup": true,? \/\/ Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes\. Values: true \| false/, - ); - assert.match( - output, - /"enabled": true,? \/\/ Enable the subtitle sidebar feature for parsed subtitle sources\. Values: true \| false/, - ); - assert.match( - output, - /"enabled": true,? \/\/ Enable AnkiConnect integration\. Values: true \| false/, - ); - assert.match( - output, - /"enabled": false,? \/\/ Enable AI provider usage for Anki translation\/enrichment flows\. Values: true \| false/, - ); - assert.match( - output, - /"model": "",? \/\/ Optional model override for Anki AI translation\/enrichment flows\./, - ); - assert.match( - output, - /"enabled": false,? \/\/ Enable shared OpenAI-compatible AI provider features\. Values: true \| false/, - ); - assert.match( - output, - /"enabled": true,? \/\/ Enable optional Discord Rich Presence updates\. Values: true \| false/, - ); - assert.match( - output, - /"autoOpenBrowser": false,? \/\/ Automatically open the stats dashboard in a browser when the server starts\. Values: true \| false/, - ); - assert.match( - output, - /"notificationType": "overlay",? \/\/ How SubMiner announces available updates\..*Values: overlay \| system \| both \| none \| osd \| osd-system/, - ); - assert.match( - output, - /"channel": "stable",? \/\/ Release channel used for update checks\. Values: stable \| prerelease/, - ); - assert.match( - output, - /"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for managed subtitle auto-selection\./, - ); - assert.doesNotMatch(output, /"mode": "automatic"/); - assert.doesNotMatch(output, /"fixWithAi": false/); - assert.doesNotMatch(output, /"whisperThreads": 4/); - assert.match( - output, - /"launchAtStartup": false,? \/\/ Launch texthooker server automatically when SubMiner starts\. Values: true \| false/, - ); -}); - test('template generator uses settings CSS declaration paths for appearance fields', () => { const output = generateConfigTemplate(DEFAULT_CONFIG); const parsed = parseConfigContent('config.example.jsonc', output); diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index 791017bb..f96bff6b 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -2,17 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { ResolvedConfig } from '../../types/config'; -import { - CONFIG_OPTION_REGISTRY, - CONFIG_TEMPLATE_SECTIONS, - DEFAULT_CONFIG, - DEFAULT_KEYBINDINGS, - RUNTIME_OPTION_REGISTRY, -} from '../definitions'; -import { buildCoreConfigOptionRegistry } from './options-core'; -import { buildImmersionConfigOptionRegistry } from './options-immersion'; -import { buildIntegrationConfigOptionRegistry } from './options-integrations'; -import { buildSubtitleConfigOptionRegistry } from './options-subtitle'; +import { CONFIG_OPTION_REGISTRY, CONFIG_TEMPLATE_SECTIONS, DEFAULT_CONFIG } from '../definitions'; function collectConfigLeafPaths(config: ResolvedConfig): string[] { const leaves: string[] = []; @@ -87,58 +77,6 @@ const UNDOCUMENTED_LEAVES: ReadonlySet = new Set([ 'subtitleStyle.wordSpacing', ]); -test('config option registry includes critical paths and has unique entries', () => { - const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path); - - for (const requiredPath of [ - 'logging.level', - 'logging.files.mpv', - 'annotationWebsocket.enabled', - 'controller.enabled', - 'controller.scrollPixelsPerSecond', - 'startupWarmups.lowPowerMode', - 'updates.channel', - 'youtube.primarySubLanguages', - 'subtitleStyle.enableJlpt', - 'subtitleStyle.autoPauseVideoOnYomitanPopup', - 'ankiConnect.enabled', - 'subtitleStyle.nameMatchEnabled', - 'ankiConnect.media.normalizeAudio', - 'ankiConnect.media.mirrorMpvVolume', - 'anilist.characterDictionary.collapsibleSections.description', - 'mpv.executablePath', - 'mpv.launchMode', - 'mpv.profile', - 'mpv.socketPath', - 'mpv.backend', - 'mpv.autoStartSubMiner', - 'mpv.pauseUntilOverlayReady', - 'mpv.subminerBinaryPath', - 'mpv.aniskipEnabled', - 'mpv.aniskipButtonKey', - 'yomitan.externalProfilePath', - 'immersionTracking.enabled', - ]) { - assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`); - } - - assert.equal(new Set(paths).size, paths.length); -}); - -test('known-word annotation color has one public config path', () => { - const leaves = collectConfigLeafPaths(DEFAULT_CONFIG); - - assert.ok(leaves.includes('subtitleStyle.knownWordColor')); - assert.ok(!leaves.includes('ankiConnect.knownWords.color')); -}); - -test('n+1 annotation color has one public config path', () => { - const leaves = collectConfigLeafPaths(DEFAULT_CONFIG); - - assert.ok(leaves.includes('subtitleStyle.nPlusOneColor')); - assert.ok(!leaves.includes('ankiConnect.nPlusOne.color')); -}); - test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => { const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path)); const leaves = collectConfigLeafPaths(DEFAULT_CONFIG); @@ -168,77 +106,10 @@ test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEA ); }); -test('config template sections include expected domains and unique keys', () => { +test('config option paths and template section keys are unique', () => { + const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path); + assert.equal(new Set(paths).size, paths.length); + const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key); - const requiredKeys: (typeof keys)[number][] = [ - 'websocket', - 'annotationWebsocket', - 'controller', - 'startupWarmups', - 'youtube', - 'subtitleStyle', - 'ankiConnect', - 'yomitan', - 'mpv', - 'immersionTracking', - ]; - - for (const requiredKey of requiredKeys) { - assert.ok(keys.includes(requiredKey), `missing template section key: ${requiredKey}`); - } - assert.equal(new Set(keys).size, keys.length); }); - -test('domain registry builders each contribute entries to composed registry', () => { - const domainEntries = [ - buildCoreConfigOptionRegistry(DEFAULT_CONFIG), - buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG), - buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY), - buildImmersionConfigOptionRegistry(DEFAULT_CONFIG), - ]; - const composedPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path)); - - for (const entries of domainEntries) { - assert.ok(entries.length > 0); - assert.ok(entries.some((entry) => composedPaths.has(entry.path))); - } -}); - -test('default keybindings include primary and secondary subtitle track cycling on J keys', () => { - const keybindingMap = new Map( - DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]), - ); - assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']); - assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']); - assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyC'), ['__youtube-picker-open']); - assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyP'), ['__playlist-browser-open']); -}); - -test('default keybindings include fullscreen on F', () => { - const keybindingMap = new Map( - DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]), - ); - assert.deepEqual(keybindingMap.get('KeyF'), ['cycle', 'fullscreen']); -}); - -test('default keybindings include replay and next subtitle controls', () => { - const keybindingMap = new Map( - DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]), - ); - assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyH'), ['__replay-subtitle']); - assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-subtitle']); -}); - -test('default keybindings mirror mpv subtitle delay and sub-step keys', () => { - const keybindingMap = new Map( - DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]), - ); - assert.deepEqual(keybindingMap.get('KeyZ'), ['add', 'sub-delay', -0.1]); - assert.deepEqual(keybindingMap.get('Shift+KeyZ'), ['add', 'sub-delay', 0.1]); - assert.deepEqual(keybindingMap.get('KeyX'), ['add', 'sub-delay', 0.1]); - assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowLeft'), ['sub-step', -1]); - assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowRight'), ['sub-step', 1]); - assert.equal(keybindingMap.has('Shift+BracketLeft'), false); - assert.equal(keybindingMap.has('Shift+BracketRight'), false); -}); diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index 4bed800e..dd98d0c4 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -11,209 +11,6 @@ function field(path: string) { return match; } -test('settings registry splits viewing into appearance and behavior categories', () => { - assert.equal(field('subtitleStyle.fontSize').category, 'appearance'); - assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior'); - assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior'); - assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').category, 'behavior'); - assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').section, 'Subtitle Behavior'); - assert.equal(field('secondarySub.defaultMode').category, 'behavior'); - assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position'); - assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode'); - assert.equal(field('auto_start_overlay').category, 'behavior'); - assert.equal(field('auto_start_overlay').section, 'Playback Behavior'); - assert.equal(field('youtube.primarySubLanguages').category, 'behavior'); - assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings'); - assert.equal(field('mpv.launchMode').category, 'behavior'); - assert.equal(field('mpv.launchMode').section, 'mpv Playback'); - assert.equal(field('mpv.profile').category, 'behavior'); - assert.equal(field('mpv.profile').section, 'mpv Playback'); - assert.ok( - fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') < - fields.findIndex( - (candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup', - ), - ); - assert.ok( - fields.findIndex( - (candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup', - ) < fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'), - ); -}); - -test('settings registry groups playback startup controls under playback behavior', () => { - for (const path of [ - 'subtitleStyle.autoPauseVideoOnHover', - 'subtitleStyle.autoPauseVideoOnYomitanPopup', - 'subtitleSidebar.pauseVideoOnHover', - 'mpv.autoStartSubMiner', - 'auto_start_overlay', - 'mpv.pauseUntilOverlayReady', - ]) { - assert.equal(field(path).category, 'behavior', path); - assert.equal(field(path).section, 'Playback Behavior', path); - } -}); - -test('settings registry moves AniSkip button key into input shortcuts and hot reload', () => { - assert.equal(field('mpv.aniskipButtonKey').category, 'input'); - assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts'); - assert.equal(field('mpv.aniskipButtonKey').subsection, 'Playback'); - assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key'); - assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload'); -}); - -test('settings registry exposes character dictionary panel shortcuts dynamically', () => { - assert.equal( - field('shortcuts.openCharacterDictionaryManager').label, - 'Open Character Dictionary Manager', - ); - assert.equal(field('shortcuts.openCharacterDictionaryManager').subsection, 'Open Panels'); -}); - -test('settings registry hides removed modal-only fields', () => { - for (const path of [ - 'shortcuts.multiCopyTimeoutMs', - 'anilist.characterDictionary.profileScope', - 'jellyfin.directPlayContainers', - ]) { - assert.equal( - fields.some((candidate) => candidate.configPath === path), - false, - path, - ); - } -}); - -test('settings registry orders websocket server immediately after annotation websocket', () => { - const integrationSections = [ - ...new Set( - fields - .filter((candidate) => candidate.category === 'integrations') - .map((candidate) => candidate.section), - ), - ]; - const annotationIndex = integrationSections.indexOf('Annotation WebSocket'); - assert.equal(integrationSections[annotationIndex + 1], 'WebSocket server'); -}); - -test('settings registry explains websocket auto mode and keeps it disabled by default', () => { - assert.equal(field('websocket.enabled').defaultValue, false); - assert.equal( - field('websocket.enabled').description, - 'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.', - ); -}); - -test('settings registry places immersion tracking after other tracking and app sections', () => { - const trackingSections = [ - ...new Set( - fields - .filter((candidate) => candidate.category === 'tracking-app') - .map((candidate) => candidate.section), - ), - ]; - assert.equal(trackingSections.at(-1), 'Immersion tracking'); -}); - -test('settings registry groups annotation display fields by config group', () => { - assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display'); - assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words'); - assert.equal(field('subtitleStyle.knownWordColor').subsection, 'Known Words'); - assert.equal(field('subtitleStyle.nPlusOneColor').subsection, 'N+1'); - assert.equal(field('subtitleStyle.enableJlpt').subsection, 'JLPT'); - assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color'); -}); - -test('settings registry groups maturity settings under Known Words with color controls', () => { - assert.equal(field('ankiConnect.knownWords.maturityEnabled').subsection, 'Known Words'); - assert.equal(field('ankiConnect.knownWords.matureThresholdDays').subsection, 'Known Words'); - for (const tier of ['new', 'learning', 'young', 'mature']) { - const tierField = field(`subtitleStyle.knownWordMaturityColors.${tier}`); - assert.equal(tierField.subsection, 'Known Words'); - assert.equal(tierField.control, 'color'); - } -}); - -test('settings registry orders maturity colors from least mature to mature', () => { - const knownWordsPaths = fields - .filter((candidate) => candidate.subsection === 'Known Words') - .map((candidate) => candidate.configPath); - assert.deepEqual(knownWordsPaths, [ - 'ankiConnect.knownWords.highlightEnabled', - 'ankiConnect.knownWords.maturityEnabled', - 'subtitleStyle.knownWordColor', - 'ankiConnect.knownWords.matureThresholdDays', - 'subtitleStyle.knownWordMaturityColors.new', - 'subtitleStyle.knownWordMaturityColors.learning', - 'subtitleStyle.knownWordMaturityColors.young', - 'subtitleStyle.knownWordMaturityColors.mature', - ]); -}); - -test('settings registry routes known words sync, n+1, and frequency config to behavior', () => { - assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior'); - assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words'); - assert.equal(field('ankiConnect.knownWords.decks').category, 'behavior'); - assert.equal(field('ankiConnect.knownWords.decks').section, 'Known Words'); - assert.equal(field('ankiConnect.knownWords.matchMode').category, 'behavior'); - assert.equal(field('ankiConnect.knownWords.matchMode').section, 'Known Words'); - assert.equal(field('ankiConnect.knownWords.refreshMinutes').category, 'behavior'); - assert.equal(field('ankiConnect.knownWords.refreshMinutes').section, 'Known Words'); - assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').category, 'behavior'); - assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').section, 'N+1'); - assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').category, 'behavior'); - assert.equal( - field('subtitleStyle.frequencyDictionary.sourcePath').section, - 'Frequency Highlighting', - ); - assert.equal(field('subtitleStyle.frequencyDictionary.mode').category, 'behavior'); - assert.equal(field('subtitleStyle.frequencyDictionary.matchMode').category, 'behavior'); - assert.equal(field('subtitleStyle.frequencyDictionary.topX').category, 'behavior'); -}); - -test('settings registry exposes mpv aniskip button as an mpv key learn control', () => { - assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key'); -}); - -test('settings registry exposes specialized controls for config-assisted inputs', () => { - assert.equal(field('ankiConnect.deck').control, 'anki-deck'); - assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks'); - assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type'); - assert.equal(field('ankiConnect.fields.word').control, 'anki-field'); - assert.equal(field('keybindings').control, 'mpv-keybindings'); - assert.equal(field('subtitleStyle.css').control, 'css-declarations'); - assert.equal(field('subtitleStyle.secondary.css').control, 'css-declarations'); - assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut'); - assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key'); - assert.equal(field('subtitleSidebar.css').control, 'css-declarations'); - assert.equal(field('stats.toggleKey').control, 'key-code'); - assert.equal(field('discordPresence.presenceStyle').control, 'select'); -}); - -test('settings registry exposes mpv volume mirroring as a mining toggle', () => { - const volumeField = field('ankiConnect.media.mirrorMpvVolume'); - - assert.equal(volumeField.category, 'mining-anki'); - assert.equal(volumeField.section, 'Media Capture'); - assert.equal(volumeField.control, 'boolean'); - assert.equal(volumeField.defaultValue, true); -}); - -test('settings registry exposes YouTube media cache mode as a labeled select', () => { - const mediaCacheMode = field('youtube.mediaCache.mode'); - const mediaCacheMaxHeight = field('youtube.mediaCache.maxHeight'); - - assert.equal(mediaCacheMode.control, 'select'); - assert.deepEqual(mediaCacheMode.enumValues, ['direct', 'background']); - assert.deepEqual(mediaCacheMode.enumLabels, { - direct: 'Direct stream extraction', - background: 'Background media cache', - }); - assert.equal(mediaCacheMaxHeight.control, 'number'); - assert.equal(mediaCacheMaxHeight.defaultValue, 720); -}); - test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => { const primaryVisible = fields .filter( @@ -268,18 +65,6 @@ test('settings registry exposes css declaration editor for subtitle sidebar appe assert.equal(field('subtitleSidebar.layout').settingsHidden, false); }); -test('settings registry routes playback-related integrations into integrations', () => { - assert.equal(field('jimaku.apiBaseUrl').category, 'integrations'); - assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku'); - assert.equal(field('subsync.replace').category, 'integrations'); - assert.equal(field('subsync.replace').section, 'Subtitle Sync'); - assert.equal(field('tsukihime.apiBaseUrl').category, 'integrations'); - assert.equal(field('tsukihime.apiBaseUrl').section, 'TsukiHime'); - assert.equal(field('tmdb.apiKey').category, 'integrations'); - assert.equal(field('tmdb.apiKey').section, 'TMDB'); - assert.equal(field('tmdb.apiKey').secret, true); -}); - test('settings registry puts feature toggles first, then other toggles alphabetically', () => { const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect'); assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled'); @@ -309,89 +94,3 @@ test('settings registry puts feature toggles first, then other toggles alphabeti ['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled', 'ankiConnect.isSenren.enabled'], ); }); - -test('settings registry hides app-managed and inactive config surfaces', () => { - const paths = new Set(fields.map((candidate) => candidate.configPath)); - for (const hiddenPath of [ - 'ai.enabled', - 'ai.apiKey', - 'ai.apiKeyCommand', - 'ai.model', - 'ai.baseUrl', - 'ai.systemPrompt', - 'ai.requestTimeoutMs', - 'ankiConnect.ai.enabled', - 'ankiConnect.ai.model', - 'ankiConnect.ai.systemPrompt', - 'ankiConnect.fields.translation', - 'controller.bindings', - 'controller.preferredGamepadId', - 'controller.preferredGamepadLabel', - 'controller.profiles', - 'jellyfin.defaultLibraryId', - 'subtitleSidebar.toggleKey', - 'jellyfin.recentServers', - ]) { - assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`); - } - assert.equal(paths.has('anilist.characterDictionary.enabled'), false); -}); - -test('settings registry marks safe live config paths as hot-reloadable', () => { - for (const path of [ - 'mpv.aniskipButtonKey', - 'stats.toggleKey', - 'stats.markWatchedKey', - 'logging.level', - 'logging.rotation', - 'logging.files.app', - 'logging.files.launcher', - 'logging.files.mpv', - 'youtube.primarySubLanguages', - 'jimaku.apiBaseUrl', - 'jimaku.languagePreference', - 'jimaku.maxEntryResults', - 'subsync.replace', - 'ankiConnect.behavior.autoUpdateNewCards', - 'ankiConnect.deck', - 'ankiConnect.media.normalizeAudio', - 'ankiConnect.media.mirrorMpvVolume', - 'ankiConnect.media.reviewTiming', - 'ankiConnect.knownWords.highlightEnabled', - 'ankiConnect.knownWords.refreshMinutes', - 'ankiConnect.knownWords.addMinedWordsImmediately', - 'ankiConnect.knownWords.matchMode', - 'ankiConnect.knownWords.decks', - 'ankiConnect.nPlusOne.enabled', - 'ankiConnect.nPlusOne.minSentenceWords', - 'ankiConnect.fields.word', - 'ankiConnect.fields.audio', - 'ankiConnect.fields.wordAudio', - 'ankiConnect.fields.image', - 'ankiConnect.fields.sentence', - 'ankiConnect.fields.miscInfo', - 'ankiConnect.isLapis.sentenceCardModel', - 'ankiConnect.isKiku.fieldGrouping', - 'ankiConnect.isSenren.fieldGrouping', - ]) { - assert.equal(field(path).restartBehavior, 'hot-reload', path); - } -}); - -test('settings registry does not expose removed subsync mode option', () => { - const paths = new Set(fields.map((candidate) => candidate.configPath)); - assert.equal(paths.has('subsync.defaultMode'), false); -}); - -test('settings registry keeps unsafe config siblings restart-required', () => { - for (const path of [ - 'stats.serverPort', - 'ankiConnect.url', - 'ankiConnect.proxy.enabled', - 'mpv.socketPath', - 'mpv.profile', - 'websocket.port', - ]) { - assert.equal(field(path).restartBehavior, 'restart', path); - } -}); diff --git a/src/core/services/__tests__/immersion-tracker-write-queue.test.ts b/src/core/services/__tests__/immersion-tracker-write-queue.test.ts index f9449b74..7daa228b 100644 --- a/src/core/services/__tests__/immersion-tracker-write-queue.test.ts +++ b/src/core/services/__tests__/immersion-tracker-write-queue.test.ts @@ -5,30 +5,6 @@ import os from 'node:os'; import path from 'node:path'; import type { DatabaseSync } from '../immersion-tracker/sqlite'; -type ImmersionTrackerService = import('../immersion-tracker-service').ImmersionTrackerService; -type ImmersionTrackerServiceCtor = - typeof import('../immersion-tracker-service').ImmersionTrackerService; - -let trackerCtor: ImmersionTrackerServiceCtor | null = null; - -async function loadTrackerCtor(): Promise { - if (trackerCtor) return trackerCtor; - const mod = await import('../immersion-tracker-service'); - trackerCtor = mod.ImmersionTrackerService; - return trackerCtor; -} - -function makeDbPath(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-')); - return path.join(dir, 'immersion.sqlite'); -} - -function cleanupDbPath(dbPath: string): void { - const dir = path.dirname(dbPath); - if (!fs.existsSync(dir)) return; - fs.rmSync(dir, { recursive: true, force: true }); -} - interface TrackerInternals { db: DatabaseSync; queue: unknown[]; @@ -42,155 +18,38 @@ interface TrackerInternals { writeLock: { locked: boolean }; } -test('delete maintenance fails closed when queued writes cannot drain', async () => { - const dbPath = makeDbPath(); - let tracker: ImmersionTrackerService | null = null; +interface TrackerHarness { + tracker: TrackerInternals; + deleteRunnerCalls: () => number; +} + +/** Runs `fn` against a fresh tracker (batchSize 2) seeded with two library entries. */ +async function withTracker(fn: (harness: TrackerHarness) => Promise): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-')); + const { ImmersionTrackerService } = await import('../immersion-tracker-service'); let deleteRunnerCalls = 0; - - try { - const Ctor = await loadTrackerCtor(); - tracker = new Ctor( - { dbPath, policy: { batchSize: 2 } }, - { - runDeleteMaintenanceTask: async () => { - deleteRunnerCalls += 1; - }, + const service = new ImmersionTrackerService( + { dbPath: path.join(dir, 'immersion.sqlite'), policy: { batchSize: 2 } }, + { + runDeleteMaintenanceTask: async () => { + deleteRunnerCalls += 1; }, - ); - const internals = tracker as unknown as TrackerInternals; - seedTwoEntries(internals.db); - queueSubtitleLines(internals, 1); - let flushCalls = 0; - internals.flushNow = () => { - flushCalls += 1; - if (flushCalls > 1) throw new Error('bounded no-progress sentinel'); - }; - - await assert.rejects(internals.deleteSession(1), /queue did not drain/i); - - assert.equal(flushCalls, 1); - assert.equal(deleteRunnerCalls, 0); - assert.equal(internals.writeLock.locked, false); - } finally { - tracker?.destroy(); - cleanupDbPath(dbPath); - } -}); - -test('reassignAnimeAnilist fails closed before resolving a conflict when writes cannot drain', async () => { - const dbPath = makeDbPath(); - let tracker: ImmersionTrackerService | null = null; - + }, + ); try { - const Ctor = await loadTrackerCtor(); - tracker = new Ctor({ dbPath, policy: { batchSize: 2 } }); - const internals = tracker as unknown as TrackerInternals; - seedTwoEntries(internals.db); - internals.db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = 2').run(); - queueSubtitleLines(internals, 1); - internals.flushNow = () => {}; - - await assert.rejects( - internals.reassignAnimeAnilist(1, { anilistId: 123 }), - /queue did not drain/i, - ); - - assert.deepEqual( - internals.db - .prepare( - 'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id', - ) - .all(), - [ - { animeId: 1, anilistId: null }, - { animeId: 2, anilistId: 123 }, - ], - ); + const tracker = service as unknown as TrackerInternals; + seedTwoEntries(tracker.db); + await fn({ tracker, deleteRunnerCalls: () => deleteRunnerCalls }); } finally { - tracker?.destroy(); - cleanupDbPath(dbPath); + service.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); } -}); - -test('mergeAnime fails closed when queued writes cannot drain', async () => { - const dbPath = makeDbPath(); - let tracker: ImmersionTrackerService | null = null; - - try { - const Ctor = await loadTrackerCtor(); - tracker = new Ctor({ dbPath, policy: { batchSize: 2 } }); - const internals = tracker as unknown as TrackerInternals; - seedTwoEntries(internals.db); - queueSubtitleLines(internals, 1); - internals.flushNow = () => {}; - - await assert.rejects(internals.mergeAnime(1, [2]), /queue did not drain/i); - - assert.deepEqual( - internals.db - .prepare('SELECT anime_id AS animeId FROM imm_anime ORDER BY anime_id') - .all() - .map((row) => (row as { animeId: number }).animeId), - [1, 2], - ); - } finally { - tracker?.destroy(); - cleanupDbPath(dbPath); - } -}); - -test('moveVideoToAnime fails closed when queued writes cannot drain', async () => { - const dbPath = makeDbPath(); - let tracker: ImmersionTrackerService | null = null; - - try { - const Ctor = await loadTrackerCtor(); - tracker = new Ctor({ dbPath, policy: { batchSize: 2 } }); - const internals = tracker as unknown as TrackerInternals; - seedTwoEntries(internals.db); - queueSubtitleLines(internals, 1); - internals.flushNow = () => {}; - - await assert.rejects(internals.moveVideoToAnime(2, 1), /queue did not drain/i); - assert.equal( - ( - internals.db - .prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 2') - .get() as { - animeId: number; - } - ).animeId, - 2, - ); - } finally { - tracker?.destroy(); - cleanupDbPath(dbPath); - } -}); - -test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', async () => { - const dbPath = makeDbPath(); - let tracker: ImmersionTrackerService | null = null; - - try { - const Ctor = await loadTrackerCtor(); - tracker = new Ctor({ dbPath, policy: { batchSize: 2 } }); - const internals = tracker as unknown as TrackerInternals; - seedTwoEntries(internals.db); - queueSubtitleLines(internals, 1); - internals.flushNow = () => {}; - - await assert.rejects(internals.rebuildLifetimeSummaries(), /queue did not drain/i); - } finally { - tracker?.destroy(); - cleanupDbPath(dbPath); - } -}); +} function seedTwoEntries(db: DatabaseSync): void { db.exec(` - INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE) - VALUES (1, 'show', 'Show', 1000, 1000), (2, 'show season 1', 'Show Season 1', 1000, 1000); + INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, anilist_id, CREATED_DATE, LAST_UPDATE_DATE) + VALUES (1, 'show', 'Show', NULL, 1000, 1000), (2, 'show season 1', 'Show Season 1', 123, 1000, 1000); INSERT INTO imm_videos (video_id, video_key, canonical_title, anime_id, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE) VALUES (1, 'local:/tmp/a.mkv', 'A', 1, 1, 0, 1440000, 1000, 1000), (2, 'local:/tmp/b.mkv', 'B', 2, 1, 0, 1440000, 1000, 1000); @@ -244,25 +103,53 @@ function queueTelemetry(tracker: TrackerInternals, linesSeen: number): void { }); } -/** The queued telemetry sample only exists in the database once the queue drained fully. */ -function latestTelemetryLinesSeen(db: DatabaseSync, sessionId: number): number | null { - const row = db - .prepare( - `SELECT lines_seen AS linesSeen - FROM imm_session_telemetry - WHERE session_id = ? - ORDER BY sample_ms DESC, telemetry_id DESC - LIMIT 1`, - ) - .get(sessionId) as { linesSeen: number } | undefined; - return row ? Number(row.linesSeen) : null; +const SNAPSHOT_TABLES = [ + 'imm_anime', + 'imm_videos', + 'imm_sessions', + 'imm_subtitle_lines', + 'imm_session_telemetry', + 'imm_lifetime_global', + 'imm_lifetime_anime', + 'imm_lifetime_media', + 'imm_lifetime_applied_sessions', +]; + +function snapshotDb(db: DatabaseSync): Record { + return Object.fromEntries( + SNAPSHOT_TABLES.map((table) => [table, db.prepare(`SELECT * FROM ${table} ORDER BY 1`).all()]), + ); } -function countLinesForAnime(db: DatabaseSync, animeId: number): number { - const row = db - .prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?') - .get(animeId) as { total: number }; - return Number(row.total); +const guardedOperations: Array<{ + name: string; + run: (tracker: TrackerInternals) => Promise; +}> = [ + { name: 'deleteSession', run: (tracker) => tracker.deleteSession(1) }, + { name: 'mergeAnime', run: (tracker) => tracker.mergeAnime(1, [2]) }, + { name: 'moveVideoToAnime', run: (tracker) => tracker.moveVideoToAnime(2, 1) }, + { name: 'rebuildLifetimeSummaries', run: (tracker) => tracker.rebuildLifetimeSummaries() }, + { + // Anime 2 already owns AniList id 123, so this would otherwise resolve a conflict. + name: 'reassignAnimeAnilist', + run: (tracker) => tracker.reassignAnimeAnilist(1, { anilistId: 123 }), + }, +]; + +for (const operation of guardedOperations) { + test(`${operation.name} fails closed without touching the database when queued writes cannot drain`, async () => { + await withTracker(async ({ tracker, deleteRunnerCalls }) => { + queueSubtitleLines(tracker, 1); + tracker.flushNow = () => {}; + const before = snapshotDb(tracker.db); + + await assert.rejects(operation.run(tracker), /queue did not drain/i); + + assert.deepEqual(snapshotDb(tracker.db), before); + assert.equal(deleteRunnerCalls(), 0); + assert.equal(tracker.writeLock.locked, false); + }); + }); } /** @@ -271,52 +158,36 @@ function countLinesForAnime(db: DatabaseSync, animeId: number): number { * queue, so anything past `batchSize` would still be unwritten when the merge * repoints rows. */ -test('mergeAnime drains a queue larger than one batch before repointing rows', async () => { - const dbPath = makeDbPath(); - let tracker: ImmersionTrackerService | null = null; +const repointingOperations: Array<{ + name: string; + run: (tracker: TrackerInternals) => Promise; +}> = [ + { name: 'mergeAnime', run: (tracker) => tracker.mergeAnime(1, [2]) }, + { name: 'moveVideoToAnime', run: (tracker) => tracker.moveVideoToAnime(2, 1) }, +]; - try { - const Ctor = await loadTrackerCtor(); - tracker = new Ctor({ dbPath, policy: { batchSize: 2 } }); - const internals = tracker as unknown as TrackerInternals; +for (const operation of repointingOperations) { + test(`${operation.name} drains a queue larger than one batch before repointing rows`, async () => { + await withTracker(async ({ tracker }) => { + queueSubtitleLines(tracker, 8); + queueTelemetry(tracker, 8); + assert.ok(tracker.queue.length > 2, 'expected more queued writes than one batch'); - seedTwoEntries(internals.db); - queueSubtitleLines(internals, 8); - queueTelemetry(internals, 8); - assert.ok(internals.queue.length > 2, 'expected more queued writes than one batch'); + await operation.run(tracker); - await internals.mergeAnime(1, [2]); - - assert.equal(internals.queue.length, 0); - // Every queued line landed, attributed to the surviving entry. - assert.equal(countLinesForAnime(internals.db, 1), 8); - assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8); - } finally { - tracker?.destroy(); - cleanupDbPath(dbPath); - } -}); - -test('moveVideoToAnime drains a queue larger than one batch before repointing rows', async () => { - const dbPath = makeDbPath(); - let tracker: ImmersionTrackerService | null = null; - - try { - const Ctor = await loadTrackerCtor(); - tracker = new Ctor({ dbPath, policy: { batchSize: 2 } }); - const internals = tracker as unknown as TrackerInternals; - - seedTwoEntries(internals.db); - queueSubtitleLines(internals, 8); - queueTelemetry(internals, 8); - - await internals.moveVideoToAnime(2, 1); - - assert.equal(internals.queue.length, 0); - assert.equal(countLinesForAnime(internals.db, 1), 8); - assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8); - } finally { - tracker?.destroy(); - cleanupDbPath(dbPath); - } -}); + assert.equal(tracker.queue.length, 0); + // Every queued line and the trailing telemetry sample landed on the surviving entry. + const lines = tracker.db + .prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = 1') + .get() as { total: number }; + assert.equal(Number(lines.total), 8); + const telemetry = tracker.db + .prepare( + `SELECT lines_seen AS linesSeen FROM imm_session_telemetry + WHERE session_id = 1 ORDER BY sample_ms DESC, telemetry_id DESC LIMIT 1`, + ) + .get() as { linesSeen: number } | undefined; + assert.equal(Number(telemetry?.linesSeen), 8); + }); + }); +} diff --git a/src/core/services/__tests__/stats-server-route-groups.test.ts b/src/core/services/__tests__/stats-server-route-groups.test.ts deleted file mode 100644 index 9980ce7c..00000000 --- a/src/core/services/__tests__/stats-server-route-groups.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import { - registerStatsAnalyticsRoutes, - registerStatsIntegrationRoutes, - registerStatsLibraryRoutes, - registerStatsMiningRoutes, - registerStatsStaticRoutes, -} from '../stats-server/routes.js'; - -describe('stats server route groups', () => { - test('exposes focused registrars for createStatsApp composition', () => { - for (const registrar of [ - registerStatsAnalyticsRoutes, - registerStatsLibraryRoutes, - registerStatsIntegrationRoutes, - registerStatsMiningRoutes, - registerStatsStaticRoutes, - ]) { - assert.equal(typeof registrar, 'function'); - } - }); -}); diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 7e3bf1ed..56a9a5a7 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -12,10 +12,7 @@ import { } from '../stats-server.js'; import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; import { INCOMPATIBLE_PROVIDER_MERGE_MESSAGE } from '../immersion-tracker/anime-merge.js'; -import { - clearRetimedSecondarySubtitleCache, - resolveRetimedSecondarySubtitleTextFromSidecar, -} from '../secondary-subtitle-sidecar.js'; +import type { StatsServerMediaGenerator } from '../stats-server/mining-support.js'; const SESSION_SUMMARIES = [ { @@ -262,6 +259,22 @@ const EPISODE_CARD_EVENTS = [ { eventId: 1, sessionId: 1, tsMs: Date.now(), cardsDelta: 1, noteIds: [12345] }, ]; +const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xd9]); +const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function animeCoverArt(coverBlob: Buffer) { + return { + videoId: 1, + anilistId: 21858, + coverUrl: 'https://example.com/cover.jpg', + coverBlob, + titleRomaji: 'Little Witch Academia', + titleEnglish: 'Little Witch Academia', + episodesTotal: 25, + fetchedAtMs: Date.now(), + }; +} + function createMockTracker( overrides: Partial = {}, ): ImmersionTrackerService { @@ -326,19 +339,7 @@ function createMockTracker( { epochDay: Math.floor(Date.now() / 86_400_000), totalActiveMin: 45 }, ], ensureAnimeCoverArt: async () => false, - getAnimeCoverArt: async (animeId: number) => - animeId === 1 - ? { - videoId: 1, - anilistId: 21858, - coverUrl: 'https://example.com/cover.jpg', - coverBlob: Buffer.from([0xff, 0xd8, 0xff, 0xd9]), - titleRomaji: 'Little Witch Academia', - titleEnglish: 'Little Witch Academia', - episodesTotal: 25, - fetchedAtMs: Date.now(), - } - : null, + getAnimeCoverArt: async (animeId: number) => (animeId === 1 ? animeCoverArt(JPEG_BYTES) : null), getWordDetail: async (wordId: number) => (wordId === 1 ? WORD_DETAIL : null), getWordAnimeAppearances: async () => WORD_ANIME_APPEARANCES, getSimilarWords: async () => SIMILAR_WORDS, @@ -383,7 +384,10 @@ type CapturedAnkiRequest = { async function withFakeAnkiConnect( fn: (requests: CapturedAnkiRequest[], url: string) => Promise, - options?: { notesInfoFields?: Record | null }, + options?: { + notesInfoFields?: Record | null; + onRequest?: (request: CapturedAnkiRequest) => void; + }, ): Promise { const requests: CapturedAnkiRequest[] = []; const server = http.createServer((req, res) => { @@ -392,6 +396,7 @@ async function withFakeAnkiConnect( req.on('end', () => { const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) as CapturedAnkiRequest; requests.push(payload); + options?.onRequest?.(payload); let body: unknown = { result: null, error: null }; if (payload.action === 'addNote') { @@ -445,6 +450,281 @@ async function withFakeAnkiConnect( } } +type StatsApp = ReturnType; +type StatsAppOptions = NonNullable[1]>; +type AnkiConfig = NonNullable; +type MiningMode = 'word' | 'sentence' | 'audio'; + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +/** Swaps globalThis.fetch for the duration of `fn`. */ +async function withFetch(stub: typeof fetch, fn: () => Promise): Promise { + const originalFetch = globalThis.fetch; + globalThis.fetch = stub; + try { + return await fn(); + } finally { + globalThis.fetch = originalFetch; + } +} + +/** Mock tracker that records the name of every tracker method a route calls. */ +function createRecordingTracker(calls: string[]): ImmersionTrackerService { + return new Proxy(createMockTracker(), { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (typeof value !== 'function') return value; + return (...args: unknown[]) => { + calls.push(String(prop)); + return value.apply(target, args); + }; + }, + }); +} + +type MiningEnv = { + dir: string; + sourcePath: string; + requests: CapturedAnkiRequest[]; + ankiUrl: string; +}; + +/** + * Runs `fn` with a fake episode file (plus optional sidecar `files`) in a temp dir and a fake + * AnkiConnect server recording every request. + */ +async function withMiningEnv( + cfg: { + files?: Record; + notesInfoFields?: Record | null; + onAnkiRequest?: (request: CapturedAnkiRequest) => void; + }, + fn: (env: MiningEnv) => Promise, +): Promise { + await withTempDir(async (dir) => { + const sourcePath = path.join(dir, 'episode.mkv'); + fs.writeFileSync(sourcePath, 'fake media'); + for (const [name, content] of Object.entries(cfg.files ?? {})) { + fs.writeFileSync(path.join(dir, name), content); + } + await withFakeAnkiConnect((requests, ankiUrl) => fn({ dir, sourcePath, requests, ankiUrl }), { + notesInfoFields: cfg.notesInfoFields, + onRequest: cfg.onAnkiRequest, + }); + }); +} + +const MINE_CARD_DEFAULTS = { + startMs: 1_000, + endMs: 2_000, + sentence: '猫を見た', + word: '猫', + videoTitle: 'Episode 1', +}; + +type MineCardRequest = typeof MINE_CARD_DEFAULTS & { sourcePath: string; secondaryText?: string }; +type MineCardResponse = { noteId?: number; errors?: string[]; error?: string }; + +async function mineCard( + app: StatsApp, + mode: MiningMode, + overrides: Partial & { sourcePath: string }, +): Promise<{ status: number; body: MineCardResponse }> { + const res = await app.request(`/api/stats/mine-card?mode=${mode}`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ ...MINE_CARD_DEFAULTS, ...overrides }), + }); + return { status: res.status, body: (await res.json()) as MineCardResponse }; +} + +const NO_MEDIA = { generateAudio: false, generateImage: false }; +const LAPIS = { isLapis: { enabled: true, sentenceCardModel: 'Lapis Morph' } }; +const SENTENCE_FIELDS = { word: 'Expression', sentence: 'Sentence', translation: 'SelectionText' }; +const AUDIO = Buffer.from('audio'); +const IMAGE = Buffer.from('image'); + +function ankiConfig(env: MiningEnv, overrides: Partial = {}): AnkiConfig { + return { url: env.ankiUrl, deck: 'Mining', media: NO_MEDIA, ...overrides }; +} + +/** The note sent with the first AnkiConnect request of `action`, with fields defaulted. */ +function sentNote(requests: CapturedAnkiRequest[], action: 'addNote' | 'updateNoteFields') { + const note = requests.find((request) => request.action === action)?.params?.note; + return { ...note, fields: note?.fields ?? {} }; +} + +/** Media generator stub returning fixed outputs and recording audio ranges. */ +function fakeMedia( + outputs: { audio?: Buffer | null; image?: Buffer | null; animated?: Buffer | null } = {}, +) { + const audioCalls: Parameters[] = []; + let imageCalls = 0; + const create = (): StatsServerMediaGenerator => ({ + generateAudio: async (...args) => { + audioCalls.push(args); + return outputs.audio ?? null; + }, + generateScreenshot: async () => { + imageCalls += 1; + return outputs.image ?? null; + }, + generateAnimatedImage: async () => { + imageCalls += 1; + return outputs.animated ?? null; + }, + }); + return { create, audioCalls, callCount: () => audioCalls.length + imageCalls }; +} + +const KNOWN_WORD_CACHES = [ + { + name: 'filtered persisted totals', + cache: { version: 1, refreshedAtMs: 1, scope: 'deck:test', words: ['する'] }, + knownWordsSeen: 2, + }, + { + name: 'a v3 reading-aware cache', + cache: { + version: 3, + refreshedAtMs: 1, + scope: 'deck:test', + notes: { + '101': [{ word: 'する', reading: 'する' }], + '102': [{ word: '猫', reading: null }], + }, + }, + knownWordsSeen: 2, + }, + { + name: 'a v4 maturity cache', + cache: { + version: 4, + refreshedAtMs: 1, + scope: 'deck:test', + notes: { + '101': [{ word: 'する', reading: 'する' }], + '102': [{ word: '猫', reading: null }], + }, + tiers: { '101': 'mature', '102': 'young' }, + }, + knownWordsSeen: 2, + }, + { + name: 'an unrecognized cache format (no known words)', + cache: { version: 99, refreshedAtMs: 1, scope: 'deck:test', notes: {} }, + knownWordsSeen: 0, + }, +]; + +/** Query limits each route caps (or floors) before asking the tracker. */ +const CLAMPED_QUERIES: Array<{ + path: string; + method: keyof ImmersionTrackerService; + expectedArgs: unknown[]; +}> = [ + { + path: '/api/stats/streak-calendar?days=999999', + method: 'getStreakCalendar', + expectedArgs: [365], + }, + { + path: '/api/stats/trends/episodes-per-day?limit=999999', + method: 'getEpisodesPerDay', + expectedArgs: [365], + }, + { + path: '/api/stats/trends/new-anime-per-day?limit=999999', + method: 'getNewAnimePerDay', + expectedArgs: [365], + }, + { + path: '/api/stats/trends/watch-time-per-anime?limit=999999', + method: 'getWatchTimePerAnime', + expectedArgs: [365], + }, + { + path: '/api/stats/anime/1/words?limit=999999', + method: 'getAnimeWords', + expectedArgs: [1, 200], + }, + { + path: '/api/stats/anime/1/rollups?limit=999999', + method: 'getAnimeDailyRollups', + expectedArgs: [1, 365], + }, + { + path: '/api/stats/vocabulary?limit=999999', + method: 'getVocabularyStats', + expectedArgs: [500, undefined], + }, + { + path: '/api/stats/vocabulary?limit=12.9', + method: 'getVocabularyStats', + expectedArgs: [12, undefined], + }, +]; + +const TRENDS_DASHBOARD_QUERIES = [ + { query: 'range=90d&groupBy=month', expectedArgs: ['90d', 'month', true] }, + { query: 'range=weird&groupBy=year', expectedArgs: ['30d', 'day', true] }, + { query: 'range=30d&groupBy=day&fillEmpty=false', expectedArgs: ['30d', 'day', false] }, +]; + +const MALFORMED_IDS = [ + '0', + '-1', + '1.9', + '1.0', + '1e2', + '9007199254740992', + '01', + '+1', + '0x1', + '1%0A', + '%201', +]; + +/** Every route that takes a positive resource id; `:id` is replaced per request. */ +const ID_ROUTES: Array<{ + method: 'GET' | 'DELETE' | 'PATCH' | 'POST'; + path: string; + body?: unknown; +}> = [ + { method: 'GET', path: '/api/stats/sessions/:id/timeline' }, + { method: 'GET', path: '/api/stats/sessions/:id/events' }, + { method: 'GET', path: '/api/stats/sessions/:id/known-words-timeline' }, + { method: 'GET', path: '/api/stats/vocabulary/:id/detail' }, + { method: 'GET', path: '/api/stats/kanji/:id/detail' }, + { method: 'GET', path: '/api/stats/episode/:id/detail' }, + { method: 'GET', path: '/api/stats/media/:id' }, + { method: 'GET', path: '/api/stats/media/:id/cover' }, + { method: 'GET', path: '/api/stats/media/:id/known-words-summary' }, + { method: 'GET', path: '/api/stats/anime/:id' }, + { method: 'GET', path: '/api/stats/anime/:id/words' }, + { method: 'GET', path: '/api/stats/anime/:id/rollups' }, + { method: 'GET', path: '/api/stats/anime/:id/cover' }, + { method: 'GET', path: '/api/stats/anime/:id/known-words-summary' }, + { method: 'DELETE', path: '/api/stats/sessions/:id' }, + { method: 'DELETE', path: '/api/stats/media/:id' }, + { method: 'DELETE', path: '/api/stats/anime/:id' }, + { method: 'DELETE', path: '/api/stats/anime/merge-recommendations/:id' }, + { method: 'PATCH', path: '/api/stats/media/:id/watched', body: { watched: true } }, + { method: 'PATCH', path: '/api/stats/media/:id/anime', body: { animeId: 7 } }, + { method: 'PATCH', path: '/api/stats/anime/:id/anilist', body: { anilistId: 21858 } }, + { method: 'PATCH', path: '/api/stats/anime/:id/tmdb', body: { tmdbId: 12, tmdbType: 'tv' } }, + { method: 'POST', path: '/api/stats/anime/:id/merge', body: { sourceAnimeIds: [8] } }, + { method: 'POST', path: '/api/stats/anki/browse?noteId=:id' }, +]; + describe('stats server API routes', () => { it('rejects untrusted mutation requests before merging anime', async () => { let merges = 0; @@ -534,14 +814,6 @@ describe('stats server API routes', () => { assert.equal(body.hints.totalYomitanLookupCount, 5); }); - it('GET /api/stats/sessions returns session list', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/sessions?limit=5'); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body)); - }); - it('GET /api/stats/sentences/search resolves headword candidates by default', async () => { const seen: Array<{ query: string; @@ -579,141 +851,32 @@ describe('stats server API routes', () => { ]); }); - it('GET /api/stats/sessions enriches known-word metrics using filtered persisted totals', async () => { - await withTempDir(async (dir) => { - const cachePath = path.join(dir, 'known-words.json'); - fs.writeFileSync( - cachePath, - JSON.stringify({ - version: 1, - refreshedAtMs: 1, - scope: 'deck:test', - words: ['する'], - }), - ); + for (const { name, cache, knownWordsSeen } of KNOWN_WORD_CACHES) { + it(`GET /api/stats/sessions enriches known-word metrics from ${name}`, async () => { + await withTempDir(async (dir) => { + const cachePath = path.join(dir, 'known-words.json'); + fs.writeFileSync(cachePath, JSON.stringify(cache)); + const app = createStatsApp( + createMockTracker({ + getSessionWordsByLine: async (sessionId: number) => + sessionId === 1 + ? [ + { lineIndex: 1, headword: 'する', occurrenceCount: 2 }, + { lineIndex: 2, headword: '未知', occurrenceCount: 1 }, + ] + : [], + }), + { knownWordCachePath: cachePath }, + ); - const app = createStatsApp( - createMockTracker({ - getSessionWordsByLine: async (sessionId: number) => - sessionId === 1 - ? [ - { lineIndex: 1, headword: 'する', occurrenceCount: 2 }, - { lineIndex: 2, headword: '未知', occurrenceCount: 1 }, - ] - : [], - }), - { knownWordCachePath: cachePath }, - ); - - const res = await app.request('/api/stats/sessions?limit=5'); - assert.equal(res.status, 200); - const body = await res.json(); - const first = body[0]; - assert.equal(first.knownWordsSeen, 2); - assert.equal(first.knownWordRate, 66.7); + const res = await app.request('/api/stats/sessions?limit=5'); + assert.equal(res.status, 200); + const [first] = await res.json(); + assert.equal(first.knownWordsSeen, knownWordsSeen); + if (knownWordsSeen > 0) assert.equal(first.knownWordRate, 66.7); + }); }); - }); - - it('GET /api/stats/sessions enriches known-word metrics from a v3 reading-aware cache', async () => { - await withTempDir(async (dir) => { - const cachePath = path.join(dir, 'known-words.json'); - fs.writeFileSync( - cachePath, - JSON.stringify({ - version: 3, - refreshedAtMs: 1, - scope: 'deck:test', - notes: { - '101': [{ word: 'する', reading: 'する' }], - '102': [{ word: '猫', reading: null }], - }, - }), - ); - - const app = createStatsApp( - createMockTracker({ - getSessionWordsByLine: async (sessionId: number) => - sessionId === 1 - ? [ - { lineIndex: 1, headword: 'する', occurrenceCount: 2 }, - { lineIndex: 2, headword: '未知', occurrenceCount: 1 }, - ] - : [], - }), - { knownWordCachePath: cachePath }, - ); - - const res = await app.request('/api/stats/sessions?limit=5'); - assert.equal(res.status, 200); - const body = await res.json(); - const first = body[0]; - assert.equal(first.knownWordsSeen, 2); - assert.equal(first.knownWordRate, 66.7); - }); - }); - - it('GET /api/stats/sessions enriches known-word metrics from a v4 maturity cache', async () => { - await withTempDir(async (dir) => { - const cachePath = path.join(dir, 'known-words.json'); - fs.writeFileSync( - cachePath, - JSON.stringify({ - version: 4, - refreshedAtMs: 1, - scope: 'deck:test', - notes: { - '101': [{ word: 'する', reading: 'する' }], - '102': [{ word: '猫', reading: null }], - }, - tiers: { '101': 'mature', '102': 'young' }, - }), - ); - - const app = createStatsApp( - createMockTracker({ - getSessionWordsByLine: async (sessionId: number) => - sessionId === 1 - ? [ - { lineIndex: 1, headword: 'する', occurrenceCount: 2 }, - { lineIndex: 2, headword: '未知', occurrenceCount: 1 }, - ] - : [], - }), - { knownWordCachePath: cachePath }, - ); - - const res = await app.request('/api/stats/sessions?limit=5'); - assert.equal(res.status, 200); - const body = await res.json(); - const first = body[0]; - assert.equal(first.knownWordsSeen, 2); - assert.equal(first.knownWordRate, 66.7); - }); - }); - - it('GET /api/stats/sessions reports no known words when the cache format is unrecognized', async () => { - await withTempDir(async (dir) => { - const cachePath = path.join(dir, 'known-words.json'); - fs.writeFileSync( - cachePath, - JSON.stringify({ version: 99, refreshedAtMs: 1, scope: 'deck:test', notes: {} }), - ); - - const app = createStatsApp( - createMockTracker({ - getSessionWordsByLine: async () => [ - { lineIndex: 1, headword: 'する', occurrenceCount: 2 }, - ], - }), - { knownWordCachePath: cachePath }, - ); - - const res = await app.request('/api/stats/sessions?limit=5'); - assert.equal(res.status, 200); - const body = await res.json(); - assert.equal(body[0].knownWordsSeen, 0); - }); - }); + } it('GET /api/stats/sessions/:id/events forwards event type filters to the tracker', async () => { let seenSessionId = 0; @@ -791,228 +954,60 @@ describe('stats server API routes', () => { }); }); - it('GET /api/stats/vocabulary returns word frequency data', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/vocabulary'); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body)); - assert.equal(body[0].headword, 'する'); - }); + for (const { path: requestPath, method, expectedArgs } of CLAMPED_QUERIES) { + it(`GET ${requestPath} passes a bounded integer limit to ${method}`, async () => { + let seenArgs: unknown[] = []; + const app = createStatsApp( + createMockTracker({ + [method]: async (...args: unknown[]) => { + seenArgs = args; + return []; + }, + }), + ); - it('GET /api/stats/vocabulary/summary returns database-wide card totals', async () => { - const app = createStatsApp(createMockTracker()); - - const res = await app.request('/api/stats/vocabulary/summary'); - - assert.equal(res.status, 200); - assert.deepEqual(await res.json(), { - uniqueWords: 501, - uniqueWordsWithoutNames: 500, - uniqueKanji: 201, - newThisWeek: 7, - newThisWeekWithoutNames: 6, - knownWordCount: 250, - knownWordCountWithoutNames: 249, + const res = await app.request(requestPath); + assert.equal(res.status, 200); + assert.deepEqual(seenArgs, expectedArgs); }); - }); + } - it('GET /api/stats/vocabulary/charts returns complete chart datasets', async () => { - const app = createStatsApp(createMockTracker()); - - const res = await app.request('/api/stats/vocabulary/charts'); - - assert.equal(res.status, 200); - assert.deepEqual(await res.json(), { - ready: true, - topWords: [{ wordId: 1, headword: 'する', frequency: 50 }], - topWordsWithoutNames: [{ wordId: 1, headword: 'する', frequency: 50 }], - newWordsTimeline: [{ epochDay: 20_000, wordCount: 3 }], - newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 3 }], - }); - }); - - it('GET /api/stats/kanji returns kanji frequency data', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/kanji'); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body)); - assert.equal(body[0].kanji, '日'); - }); - - it('GET /api/stats/streak-calendar returns streak calendar rows', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/streak-calendar'); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body)); - assert.equal(body.length, 2); - assert.equal(body[0].totalActiveMin, 30); - assert.equal(body[1].totalActiveMin, 45); - }); - - it('GET /api/stats/streak-calendar clamps oversized days', async () => { - let seenDays = 0; - const app = createStatsApp( - createMockTracker({ - getStreakCalendar: async (days?: number) => { - seenDays = days ?? 0; - return []; - }, - }), - ); - - const res = await app.request('/api/stats/streak-calendar?days=999999'); - assert.equal(res.status, 200); - assert.equal(seenDays, 365); - }); - - it('GET /api/stats/trends/episodes-per-day returns episode count rows', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/trends/episodes-per-day'); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body)); - assert.equal(body.length, 2); - assert.equal(body[0].episodeCount, 3); - }); - - it('GET /api/stats/trends/episodes-per-day clamps oversized limits', async () => { - let seenLimit = 0; - const app = createStatsApp( - createMockTracker({ - getEpisodesPerDay: async (limit?: number) => { - seenLimit = limit ?? 0; - return EPISODES_PER_DAY; - }, - }), - ); - const res = await app.request('/api/stats/trends/episodes-per-day?limit=999999'); - assert.equal(res.status, 200); - assert.equal(seenLimit, 365); - }); - - it('GET /api/stats/trends/new-anime-per-day returns new anime rows', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/trends/new-anime-per-day'); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body)); - assert.equal(body.length, 1); - assert.equal(body[0].newAnimeCount, 2); - }); - - it('GET /api/stats/trends/new-anime-per-day clamps oversized limits', async () => { - let seenLimit = 0; - const app = createStatsApp( - createMockTracker({ - getNewAnimePerDay: async (limit?: number) => { - seenLimit = limit ?? 0; - return NEW_ANIME_PER_DAY; - }, - }), - ); - const res = await app.request('/api/stats/trends/new-anime-per-day?limit=999999'); - assert.equal(res.status, 200); - assert.equal(seenLimit, 365); - }); - - it('GET /api/stats/trends/watch-time-per-anime returns watch time rows', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/trends/watch-time-per-anime'); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body)); - assert.equal(body.length, 1); - assert.equal(body[0].animeTitle, 'Little Witch Academia'); - assert.equal(body[0].totalActiveMin, 25); - }); - - it('GET /api/stats/trends/watch-time-per-anime clamps oversized limits', async () => { - let seenLimit = 0; - const app = createStatsApp( - createMockTracker({ - getWatchTimePerAnime: async (limit?: number) => { - seenLimit = limit ?? 0; - return WATCH_TIME_PER_ANIME; - }, - }), - ); - const res = await app.request('/api/stats/trends/watch-time-per-anime?limit=999999'); - assert.equal(res.status, 200); - assert.equal(seenLimit, 365); - }); - - it('GET /api/stats/trends/dashboard returns chart-ready trends data', async () => { + it('GET /api/stats/vocabulary passes excludePos to tracker', async () => { let seenArgs: unknown[] = []; const app = createStatsApp( createMockTracker({ - getTrendsDashboard: async (...args: unknown[]) => { + getVocabularyStats: async (...args: unknown[]) => { seenArgs = args; - return TRENDS_DASHBOARD; - }, - }), - ); - - const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month'); - assert.equal(res.status, 200); - const body = await res.json(); - assert.deepEqual(seenArgs, ['90d', 'month', true]); - assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime); - assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary); - }); - - it('GET /api/stats/trends/dashboard accepts 365d range', async () => { - let seenArgs: unknown[] = []; - const app = createStatsApp( - createMockTracker({ - getTrendsDashboard: async (...args: unknown[]) => { - seenArgs = args; - return TRENDS_DASHBOARD; - }, - }), - ); - - const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month'); - assert.equal(res.status, 200); - assert.deepEqual(seenArgs, ['365d', 'month', true]); - }); - - it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => { - let seenArgs: unknown[] = []; - const app = createStatsApp( - createMockTracker({ - getTrendsDashboard: async (...args: unknown[]) => { - seenArgs = args; - return TRENDS_DASHBOARD; - }, - }), - ); - - const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year'); - assert.equal(res.status, 200); - assert.deepEqual(seenArgs, ['30d', 'day', true]); - }); - - it('GET /api/stats/trends/dashboard forwards fillEmpty=false to disable zero-fill', async () => { - let seenArgs: unknown[] = []; - const app = createStatsApp( - createMockTracker({ - getTrendsDashboard: async (...args: unknown[]) => { - seenArgs = args; - return TRENDS_DASHBOARD; + return VOCABULARY_STATS; }, }), ); const res = await app.request( - '/api/stats/trends/dashboard?range=30d&groupBy=day&fillEmpty=false', + '/api/stats/vocabulary?excludePos=particle,%20auxiliary,%20,%20noun%20', ); assert.equal(res.status, 200); - assert.deepEqual(seenArgs, ['30d', 'day', false]); + assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary', 'noun']]); }); + for (const { query, expectedArgs } of TRENDS_DASHBOARD_QUERIES) { + it(`GET /api/stats/trends/dashboard?${query} requests ${expectedArgs.join('/')}`, async () => { + let seenArgs: unknown[] = []; + const app = createStatsApp( + createMockTracker({ + getTrendsDashboard: async (...args: unknown[]) => { + seenArgs = args; + return TRENDS_DASHBOARD; + }, + }), + ); + + const res = await app.request(`/api/stats/trends/dashboard?${query}`); + assert.equal(res.status, 200); + assert.deepEqual(seenArgs, expectedArgs); + }); + } + it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => { let seenArgs: unknown[] = []; const app = createStatsApp( @@ -1061,86 +1056,6 @@ describe('stats server API routes', () => { assert.equal(res.status, 400); }); - it('GET /api/stats/vocabulary clamps oversized limits', async () => { - let seenLimit = 0; - const app = createStatsApp( - createMockTracker({ - getVocabularyStats: async (limit?: number, _excludePos?: string[]) => { - seenLimit = limit ?? 0; - return VOCABULARY_STATS; - }, - }), - ); - - const res = await app.request('/api/stats/vocabulary?limit=999999'); - assert.equal(res.status, 200); - assert.equal(seenLimit, 500); - }); - - it('GET /api/stats/vocabulary floors fractional pagination limits', async () => { - let seenLimit = 0; - const app = createStatsApp( - createMockTracker({ - getVocabularyStats: async (limit?: number) => { - seenLimit = limit ?? 0; - return VOCABULARY_STATS; - }, - }), - ); - - const res = await app.request('/api/stats/vocabulary?limit=12.9'); - - assert.equal(res.status, 200); - assert.equal(seenLimit, 12); - }); - - it('GET /api/stats/vocabulary passes excludePos to tracker', async () => { - let seenArgs: unknown[] = []; - const app = createStatsApp( - createMockTracker({ - getVocabularyStats: async (...args: unknown[]) => { - seenArgs = args; - return VOCABULARY_STATS; - }, - }), - ); - - const res = await app.request( - '/api/stats/vocabulary?excludePos=particle,%20auxiliary,%20,%20noun%20', - ); - assert.equal(res.status, 200); - assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary', 'noun']]); - }); - - it('GET /api/stats/vocabulary returns POS fields', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/vocabulary'); - assert.equal(res.status, 200); - const body = await res.json(); - assert.equal(body[0].partOfSpeech, 'verb'); - assert.equal(body[0].pos1, '動詞'); - assert.equal(body[0].pos2, '自立'); - assert.equal(body[0].pos3, null); - }); - - it('GET /api/stats/excluded-words returns tracker exclusion rows', async () => { - const app = createStatsApp( - createMockTracker({ - getStatsExcludedWords: async () => [ - { headword: '猫', word: '猫', reading: 'ねこ' }, - { headword: 'する', word: 'する', reading: 'する' }, - ], - }), - ); - - const res = await app.request('/api/stats/excluded-words'); - assert.equal(res.status, 200); - assert.deepEqual(await res.json(), [ - { headword: '猫', word: '猫', reading: 'ねこ' }, - { headword: 'する', word: 'する', reading: 'する' }, - ]); - }); - it('PUT /api/stats/excluded-words replaces tracker exclusion rows', async () => { let seenWords: unknown = null; const app = createStatsApp( @@ -1153,7 +1068,7 @@ describe('stats server API routes', () => { const res = await app.request('/api/stats/excluded-words', { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: JSON.stringify({ words: [ { headword: '猫', word: '猫', reading: 'ねこ' }, @@ -1170,6 +1085,18 @@ describe('stats server API routes', () => { ]); }); + it('PUT /api/stats/excluded-words rejects malformed rows', async () => { + const app = createStatsApp(createMockTracker()); + + const res = await app.request('/api/stats/excluded-words', { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ words: [{ headword: '猫', word: 7, reading: 'ねこ' }] }), + }); + + assert.equal(res.status, 400); + }); + it('POST /api/stats/maintenance/duplicate-lines forwards the window and dry-run flag', async () => { let seenOptions: unknown = null; const summary = { @@ -1193,7 +1120,7 @@ describe('stats server API routes', () => { const res = await app.request('/api/stats/maintenance/duplicate-lines', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: JSON.stringify({ dryRun: true, lookbackDays: 30 }), }); @@ -1226,160 +1153,61 @@ describe('stats server API routes', () => { assert.equal(cleanupCalls, 0); }); - it('POST /api/stats/maintenance/duplicate-lines rejects a window shorter than a day', async () => { - let cleanupCalls = 0; - const app = createStatsApp( - createMockTracker({ - cleanupDuplicateSubtitleLines: async () => { - cleanupCalls += 1; - return { - dryRun: true, - lookbackDays: null, - scannedLines: 0, - burstGroups: 0, - removedLines: 0, - removedWordOccurrences: 0, - removedKanjiOccurrences: 0, - samples: [], - }; - }, - }), - ); - - const res = await app.request('/api/stats/maintenance/duplicate-lines', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dryRun: true, lookbackDays: 0.5 }), - }); - - assert.equal(res.status, 400); - assert.equal(cleanupCalls, 0); - }); - - it('POST /api/stats/maintenance/duplicate-lines floors a fractional multi-day window', async () => { - let seenOptions: unknown = null; - const app = createStatsApp( - createMockTracker({ - cleanupDuplicateSubtitleLines: async (options: unknown) => { - seenOptions = options; - return { - dryRun: true, - lookbackDays: 1, - scannedLines: 0, - burstGroups: 0, - removedLines: 0, - removedWordOccurrences: 0, - removedKanjiOccurrences: 0, - samples: [], - }; - }, - }), - ); - - const res = await app.request('/api/stats/maintenance/duplicate-lines', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dryRun: true, lookbackDays: 1.5 }), - }); - - assert.equal(res.status, 200); - assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: 1 }); - }); - - it('POST /api/stats/maintenance/duplicate-lines accepts an explicit empty object for all history', async () => { - let seenOptions: unknown = null; - const app = createStatsApp( - createMockTracker({ - cleanupDuplicateSubtitleLines: async (options: unknown) => { - seenOptions = options; - return { - dryRun: false, - lookbackDays: null, - scannedLines: 0, - burstGroups: 0, - removedLines: 0, - removedWordOccurrences: 0, - removedKanjiOccurrences: 0, - samples: [], - }; - }, - }), - ); - - const res = await app.request('/api/stats/maintenance/duplicate-lines', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + // `expected: null` means the body is rejected with 400 before cleanup runs. + for (const { name, body, expected } of [ + { + name: 'floors a fractional multi-day window', + body: '{"dryRun":true,"lookbackDays":1.5}', + expected: { dryRun: true, lookbackDays: 1 }, + }, + { + name: 'accepts an explicit empty object for all history', body: '{}', - }); - - assert.equal(res.status, 200); - assert.deepEqual(seenOptions, { dryRun: false, lookbackDays: null }); - }); - - for (const malformed of [ - { name: 'a missing body', body: undefined }, - { name: 'malformed JSON', body: '{' }, - { name: 'JSON null', body: 'null' }, - { name: 'a JSON array', body: '[]' }, + expected: { dryRun: false, lookbackDays: null }, + }, + { + name: 'rejects a window shorter than a day', + body: '{"dryRun":true,"lookbackDays":0.5}', + expected: null, + }, + { name: 'rejects a missing body', body: undefined, expected: null }, + { name: 'rejects malformed JSON', body: '{', expected: null }, + { name: 'rejects JSON null', body: 'null', expected: null }, + { name: 'rejects a JSON array', body: '[]', expected: null }, ]) { - it(`POST /api/stats/maintenance/duplicate-lines rejects ${malformed.name}`, async () => { - let cleanupCalls = 0; + it(`POST /api/stats/maintenance/duplicate-lines ${name}`, async () => { + const seenOptions: unknown[] = []; const app = createStatsApp( createMockTracker({ - cleanupDuplicateSubtitleLines: async () => { - cleanupCalls += 1; - throw new Error('cleanup must not run'); + cleanupDuplicateSubtitleLines: async (options: { + dryRun: boolean; + lookbackDays: number | null; + }) => { + seenOptions.push(options); + return { + ...options, + scannedLines: 0, + burstGroups: 0, + removedLines: 0, + removedWordOccurrences: 0, + removedKanjiOccurrences: 0, + samples: [], + }; }, }), ); const res = await app.request('/api/stats/maintenance/duplicate-lines', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: malformed.body, + headers: JSON_HEADERS, + body, }); - assert.equal(res.status, 400); - assert.equal(cleanupCalls, 0); + assert.equal(res.status, expected ? 200 : 400); + assert.deepEqual(seenOptions, expected ? [expected] : []); }); } - it('PUT /api/stats/excluded-words rejects malformed rows', async () => { - const app = createStatsApp(createMockTracker()); - - const res = await app.request('/api/stats/excluded-words', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ words: [{ headword: '猫', word: 7, reading: 'ねこ' }] }), - }); - - assert.equal(res.status, 400); - }); - - it('GET /api/stats/anime returns anime library', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/anime'); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body)); - assert.equal(body[0].canonicalTitle, 'Little Witch Academia'); - }); - - it('GET /api/stats/anime/merge-recommendations returns pending duplicate pairs', async () => { - const app = createStatsApp( - createMockTracker({ - getAnimeMergeRecommendations: async () => [{ recommendationId: 4, animeIds: [1, 2] }], - } as Partial), - ); - - const res = await app.request('/api/stats/anime/merge-recommendations'); - - assert.equal(res.status, 200); - assert.deepEqual(await res.json(), { - recommendations: [{ recommendationId: 4, animeIds: [1, 2] }], - }); - }); - it('DELETE /api/stats/anime/merge-recommendations/:id dismisses a pending pair', async () => { let dismissedId: number | null = null; const app = createStatsApp( @@ -1388,7 +1216,7 @@ describe('stats server API routes', () => { dismissedId = recommendationId; return true; }, - } as Partial), + }), ); const res = await app.request('/api/stats/anime/merge-recommendations/4', { @@ -1404,7 +1232,7 @@ describe('stats server API routes', () => { const app = createStatsApp( createMockTracker({ dismissAnimeMergeRecommendation: async () => false, - } as Partial), + }), ); const res = await app.request('/api/stats/anime/merge-recommendations/99', { @@ -1444,7 +1272,7 @@ describe('stats server API routes', () => { for (const anilistId of [-1, 0, 1.5, 9_007_199_254_740_992, '12', true, undefined]) { const res = await app.request('/api/stats/anime/1/anilist', { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: JSON.stringify({ anilistId }), }); assert.equal(res.status, 400, `accepted invalid AniList id: ${String(anilistId)}`); @@ -1454,7 +1282,7 @@ describe('stats server API routes', () => { const body = { anilistId: 21_802, titleRomaji: 'Little Witch Academia' }; const res = await app.request('/api/stats/anime/1/anilist', { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: JSON.stringify(body), }); assert.equal(res.status, 200); @@ -1485,26 +1313,15 @@ describe('stats server API routes', () => { it('GET /api/stats/anime/:animeId/cover resends art when the ETag no longer matches', async () => { // A relinked AniList entry swaps the bytes behind the same cover URL. - let coverBlob = Buffer.from([0xff, 0xd8, 0xff, 0xd9]); + let coverBlob = JPEG_BYTES; const app = createStatsApp( - createMockTracker({ - getAnimeCoverArt: async () => ({ - videoId: 1, - anilistId: 21858, - coverUrl: 'https://example.com/cover.jpg', - coverBlob, - titleRomaji: 'Little Witch Academia', - titleEnglish: 'Little Witch Academia', - episodesTotal: 25, - fetchedAtMs: Date.now(), - }), - }), + createMockTracker({ getAnimeCoverArt: async () => animeCoverArt(coverBlob) }), ); const first = await app.request('/api/stats/anime/1/cover'); const staleEtag = first.headers.get('etag'); assert.ok(staleEtag); - coverBlob = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + coverBlob = PNG_BYTES; const res = await app.request('/api/stats/anime/1/cover', { headers: { 'If-None-Match': staleEtag }, }); @@ -1515,18 +1332,7 @@ describe('stats server API routes', () => { it('GET /api/stats/anime/:animeId/cover serves detected cover MIME type', async () => { const app = createStatsApp( - createMockTracker({ - getAnimeCoverArt: async () => ({ - videoId: 1, - anilistId: 21858, - coverUrl: 'https://example.com/cover.png', - coverBlob: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - titleRomaji: 'Little Witch Academia', - titleEnglish: 'Little Witch Academia', - episodesTotal: 25, - fetchedAtMs: Date.now(), - }), - }), + createMockTracker({ getAnimeCoverArt: async () => animeCoverArt(PNG_BYTES) }), ); const res = await app.request('/api/stats/anime/1/cover'); assert.equal(res.status, 200); @@ -1539,72 +1345,21 @@ describe('stats server API routes', () => { assert.equal(res.status, 404); }); - it('resource routes reject fractional ids before calling dependencies', async () => { - const dependencyCalls: string[] = []; - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => { - dependencyCalls.push('fetch'); - return new Response('{}', { status: 200 }); - }; + it('GET /api/stats/anime/:animeId/cover fetches missing art before serving', async () => { + let fetched = false; + const app = createStatsApp( + createMockTracker({ + getAnimeCoverArt: async () => (fetched ? animeCoverArt(JPEG_BYTES) : null), + ensureAnimeCoverArt: async () => { + fetched = true; + return true; + }, + }), + ); - try { - const app = createStatsApp( - createMockTracker({ - getWordDetail: async () => { - dependencyCalls.push('getWordDetail'); - return null; - }, - getSessionEvents: async () => { - dependencyCalls.push('getSessionEvents'); - return []; - }, - getEpisodeSessions: async () => { - dependencyCalls.push('getEpisodeSessions'); - return []; - }, - getAnimeCoverArt: async () => { - dependencyCalls.push('getAnimeCoverArt'); - return null; - }, - ensureAnimeCoverArt: async () => { - dependencyCalls.push('ensureAnimeCoverArt'); - return false; - }, - setVideoWatched: async () => { - dependencyCalls.push('setVideoWatched'); - }, - reassignAnimeAnilist: async () => { - dependencyCalls.push('reassignAnimeAnilist'); - }, - }), - ); - - const responses = await Promise.all([ - app.request('/api/stats/vocabulary/1.9/detail'), - app.request('/api/stats/sessions/1.9/events'), - app.request('/api/stats/episode/1.9/detail'), - app.request('/api/stats/anime/1.9/cover'), - app.request('/api/stats/media/1.9/watched', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: '{"watched":true}', - }), - app.request('/api/stats/anime/1.9/anilist', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: '{"anilistId":21858}', - }), - app.request('/api/stats/anki/browse?noteId=1.9', { method: 'POST' }), - ]); - - assert.deepEqual( - responses.map((response) => response.status), - [400, 400, 400, 400, 400, 400, 400], - ); - assert.deepEqual(dependencyCalls, []); - } finally { - globalThis.fetch = originalFetch; - } + const res = await app.request('/api/stats/anime/1/cover'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'image/jpeg'); }); it('POST /api/stats/covers batches stored cover art and backfills missing anime art in the background', async () => { @@ -1618,7 +1373,7 @@ describe('stats server API routes', () => { videoId, anilistId: null, coverUrl: null, - coverBlob: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + coverBlob: PNG_BYTES, titleRomaji: null, titleEnglish: null, episodesTotal: null, @@ -1638,7 +1393,7 @@ describe('stats server API routes', () => { const res = await app.request('/api/stats/covers', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: JSON.stringify({ animeIds: [1, 99999], videoIds: [7, 99999] }), }); @@ -1663,58 +1418,6 @@ describe('stats server API routes', () => { assert.deepEqual(ensureAnimeCoverArtCalls, [99999]); }); - it('JSON id lists reject malformed members before side effects', async () => { - const dependencyCalls: string[] = []; - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => { - dependencyCalls.push('fetch'); - return new Response('{}', { status: 200 }); - }; - - try { - const app = createStatsApp( - createMockTracker({ - deleteSessions: async () => { - dependencyCalls.push('deleteSessions'); - }, - mergeAnime: async () => { - dependencyCalls.push('mergeAnime'); - return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 }; - }, - getAnimeCoverArt: async () => { - dependencyCalls.push('getAnimeCoverArt'); - return null; - }, - ensureAnimeCoverArt: async () => { - dependencyCalls.push('ensureAnimeCoverArt'); - return false; - }, - }), - ); - const request = async (path: string, body: string, method = 'POST'): Promise => - await app.request(path, { - method, - headers: { 'Content-Type': 'application/json' }, - body, - }); - - const responses = await Promise.all([ - request('/api/stats/sessions', '{"sessionIds":[4,1.9,7]}', 'DELETE'), - request('/api/stats/anime/7/merge', '{"sourceAnimeIds":[8,"9"]}'), - request('/api/stats/covers', '{"animeIds":[1,1.9]}'), - request('/api/stats/anki/notesInfo', '{"noteIds":[1,1.9]}'), - ]); - - assert.deepEqual( - responses.map((response) => response.status), - [400, 400, 400, 400], - ); - assert.deepEqual(dependencyCalls, []); - } finally { - globalThis.fetch = originalFetch; - } - }); - it('POST /api/stats/covers limits concurrent missing anime cover backfills', async () => { let activeBackfills = 0; let maxActiveBackfills = 0; @@ -1736,7 +1439,7 @@ describe('stats server API routes', () => { const res = await app.request('/api/stats/covers', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: JSON.stringify({ animeIds: [101, 102, 103, 104, 105] }), }); @@ -1747,33 +1450,56 @@ describe('stats server API routes', () => { } }); - it('GET /api/stats/anime/:animeId/cover fetches missing art before serving', async () => { - let fetched = false; - const app = createStatsApp( - createMockTracker({ - getAnimeCoverArt: async () => - fetched - ? { - videoId: 1, - anilistId: 21858, - coverUrl: 'https://example.com/cover.jpg', - coverBlob: Buffer.from([0xff, 0xd8, 0xff, 0xd9]), - titleRomaji: 'Little Witch Academia', - titleEnglish: 'Little Witch Academia', - episodesTotal: 25, - fetchedAtMs: Date.now(), - } - : null, - ensureAnimeCoverArt: async () => { - fetched = true; - return true; + for (const route of ID_ROUTES) { + it(`${route.method} ${route.path} rejects malformed ids before calling dependencies`, async () => { + const dependencyCalls: string[] = []; + const app = createStatsApp(createRecordingTracker(dependencyCalls)); + await withFetch( + async () => { + dependencyCalls.push('fetch'); + return new Response('{}', { status: 200 }); }, - }), + async () => { + for (const id of MALFORMED_IDS) { + const res = await app.request(route.path.replace(':id', id), { + method: route.method, + ...(route.body === undefined + ? {} + : { headers: JSON_HEADERS, body: JSON.stringify(route.body) }), + }); + assert.equal(res.status, 400, `accepted malformed id: ${id}`); + } + }, + ); + assert.deepEqual(dependencyCalls, []); + }); + } + + it('JSON id lists reject malformed members before side effects', async () => { + const dependencyCalls: string[] = []; + const app = createStatsApp(createRecordingTracker(dependencyCalls)); + const request = async (path: string, body: string, method = 'POST'): Promise => + await app.request(path, { method, headers: JSON_HEADERS, body }); + + const statuses = await withFetch( + async () => { + dependencyCalls.push('fetch'); + return new Response('{}', { status: 200 }); + }, + async () => + ( + await Promise.all([ + request('/api/stats/sessions', '{"sessionIds":[4,1.9,7]}', 'DELETE'), + request('/api/stats/sessions', '{"sessionIds":[1.5,1e309,9007199254740992]}', 'DELETE'), + request('/api/stats/anime/7/merge', '{"sourceAnimeIds":[8,"9"]}'), + request('/api/stats/covers', '{"animeIds":[1,1.9]}'), + request('/api/stats/anki/notesInfo', '{"noteIds":[1,1.9]}'), + ]) + ).map((response) => response.status), ); - const res = await app.request('/api/stats/anime/1/cover'); - assert.equal(res.status, 200); - assert.equal(res.headers.get('content-type'), 'image/jpeg'); + assert.deepEqual(statuses, [400, 400, 400, 400, 400]); + assert.deepEqual(dependencyCalls, []); }); it('GET /api/stats/anime/:animeId/words returns top words for an anime', async () => { @@ -1795,28 +1521,6 @@ describe('stats server API routes', () => { assert.deepEqual(seenArgs, [1, 25]); }); - it('GET /api/stats/anime/:animeId/words rejects invalid animeId', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/anime/0/words'); - assert.equal(res.status, 400); - }); - - it('GET /api/stats/anime/:animeId/words clamps oversized limits', async () => { - let seenArgs: unknown[] = []; - const app = createStatsApp( - createMockTracker({ - getAnimeWords: async (...args: unknown[]) => { - seenArgs = args; - return ANIME_WORDS; - }, - }), - ); - - const res = await app.request('/api/stats/anime/1/words?limit=999999'); - assert.equal(res.status, 200); - assert.deepEqual(seenArgs, [1, 200]); - }); - it('GET /api/stats/anime/:animeId/rollups returns daily rollups for an anime', async () => { let seenArgs: unknown[] = []; const app = createStatsApp( @@ -1836,28 +1540,6 @@ describe('stats server API routes', () => { assert.deepEqual(seenArgs, [1, 30]); }); - it('GET /api/stats/anime/:animeId/rollups rejects invalid animeId', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/anime/-1/rollups'); - assert.equal(res.status, 400); - }); - - it('GET /api/stats/anime/:animeId/rollups clamps oversized limits', async () => { - let seenArgs: unknown[] = []; - const app = createStatsApp( - createMockTracker({ - getAnimeDailyRollups: async (...args: unknown[]) => { - seenArgs = args; - return DAILY_ROLLUPS; - }, - }), - ); - - const res = await app.request('/api/stats/anime/1/rollups?limit=999999'); - assert.equal(res.status, 200); - assert.deepEqual(seenArgs, [1, 365]); - }); - it('GET /api/stats/vocabulary/:wordId/detail returns word detail', async () => { const app = createStatsApp(createMockTracker()); const res = await app.request('/api/stats/vocabulary/1/detail'); @@ -1878,12 +1560,6 @@ describe('stats server API routes', () => { assert.equal(res.status, 404); }); - it('GET /api/stats/vocabulary/:wordId/detail returns 400 for invalid wordId', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/vocabulary/0/detail'); - assert.equal(res.status, 400); - }); - it('GET /api/stats/kanji/:kanjiId/detail returns kanji detail', async () => { const app = createStatsApp(createMockTracker()); const res = await app.request('/api/stats/kanji/1/detail'); @@ -1904,1589 +1580,612 @@ describe('stats server API routes', () => { assert.equal(res.status, 404); }); - it('GET /api/stats/kanji/:kanjiId/detail returns 400 for invalid kanjiId', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/kanji/0/detail'); - assert.equal(res.status, 400); - }); - - it('GET /api/stats/vocabulary/occurrences still works with detail routes present', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request( - '/api/stats/vocabulary/occurrences?headword=%E7%8C%AB&word=%E7%8C%AB&reading=%E3%81%AD%E3%81%93', - ); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body)); - }); - - it('GET /api/stats/kanji/occurrences still works with detail routes present', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/kanji/occurrences?kanji=%E6%97%A5'); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body)); - }); - it('POST /api/stats/mine-card rejects non-positive source timing before media generation', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - let generatedAudio = false; - + await withMiningEnv({}, async ({ sourcePath }) => { + const media = fakeMedia({ audio: AUDIO, image: IMAGE }); const app = createStatsApp(createMockTracker(), { - createMediaGenerator: () => ({ - generateAudio: async () => { - generatedAudio = true; - return Buffer.from('audio'); - }, - generateScreenshot: async () => Buffer.from('image'), - generateAnimatedImage: async () => null, - }), - ankiConnectConfig: { - deck: 'Mining', - media: { - generateAudio: true, - generateImage: true, - }, - }, + createMediaGenerator: media.create, + ankiConnectConfig: { deck: 'Mining', media: { generateAudio: true, generateImage: true } }, }); - const res = await app.request('/api/stats/mine-card?mode=sentence', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 953_991, - endMs: 953_891, - sentence: '猫を見た', - word: '猫', - videoTitle: 'Episode 1', - }), + const { status, body } = await mineCard(app, 'sentence', { + sourcePath, + startMs: 953_991, + endMs: 953_891, }); - const body = await res.json(); - assert.equal(res.status, 400, JSON.stringify(body)); + assert.equal(status, 400, JSON.stringify(body)); assert.deepEqual(body, { error: 'endMs must be greater than startMs' }); - assert.equal(generatedAudio, false); + assert.equal(media.callCount(), 0); }); }); it('POST /api/stats/mine-card treats a zero media duration cap as unlimited', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = []; - const scenarios = [ + await withMiningEnv({}, async ({ sourcePath }) => { + for (const { maxMediaDuration, expectedEnd } of [ { maxMediaDuration: 0, expectedEnd: 12 }, { maxMediaDuration: 1, expectedEnd: 11 }, - ]; - - for (const scenario of scenarios) { + ]) { + const media = fakeMedia({ audio: AUDIO }); const app = createStatsApp(createMockTracker(), { addYomitanNote: async () => null, - createMediaGenerator: () => ({ - generateAudio: async (_path, start, end, padding) => { - audioRanges.push({ start, end, padding }); - return Buffer.from('audio'); - }, - generateScreenshot: async () => null, - generateAnimatedImage: async () => null, - }), + createMediaGenerator: media.create, ankiConnectConfig: { deck: 'Mining', media: { generateAudio: true, generateImage: false, audioPadding: 0.25, - maxMediaDuration: scenario.maxMediaDuration, + maxMediaDuration, }, }, }); - const res = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 10_000, - endMs: 12_000, - sentence: '猫を見た', - word: '猫', - }), + const { status } = await mineCard(app, 'word', { + sourcePath, + startMs: 10_000, + endMs: 12_000, }); - assert.equal(res.status, 502); - assert.deepEqual(audioRanges.at(-1), { - start: 10, - end: scenario.expectedEnd, - padding: 0.25, - }); + assert.equal(status, 502); + const [, start, end, padding] = media.audioCalls.at(-1) ?? []; + assert.deepEqual({ start, end, padding }, { start: 10, end: expectedEnd, padding: 0.25 }); } }); }); it('POST /api/stats/mine-card requires a non-empty word in word mode', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); + await withMiningEnv({}, async ({ sourcePath }) => { let yomitanCalls = 0; - let mediaCalls = 0; + const media = fakeMedia({ audio: AUDIO, image: IMAGE }); const app = createStatsApp(createMockTracker(), { addYomitanNote: async () => { yomitanCalls += 1; return 777; }, - createMediaGenerator: () => ({ - generateAudio: async () => { - mediaCalls += 1; - return Buffer.from('audio'); - }, - generateScreenshot: async () => { - mediaCalls += 1; - return Buffer.from('image'); - }, - generateAnimatedImage: async () => null, - }), + createMediaGenerator: media.create, ankiConnectConfig: { media: { generateAudio: true, generateImage: true } }, }); - const res = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: ' ', - }), - }); + const { status } = await mineCard(app, 'word', { sourcePath, word: ' ' }); - assert.equal(res.status, 400); + assert.equal(status, 400); assert.equal(yomitanCalls, 0); - assert.equal(mediaCalls, 0); + assert.equal(media.callCount(), 0); }); }); it('POST /api/stats/mine-card does not start word media without a Yomitan bridge', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - let mediaCalls = 0; + await withMiningEnv({}, async ({ sourcePath }) => { + const media = fakeMedia({ audio: AUDIO, image: IMAGE }); const app = createStatsApp(createMockTracker(), { - createMediaGenerator: () => ({ - generateAudio: async () => { - mediaCalls += 1; - return Buffer.from('audio'); - }, - generateScreenshot: async () => { - mediaCalls += 1; - return Buffer.from('image'); - }, - generateAnimatedImage: async () => null, - }), + createMediaGenerator: media.create, ankiConnectConfig: { media: { generateAudio: true, generateImage: true } }, }); - const res = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - }), - }); + const { status } = await mineCard(app, 'word', { sourcePath }); - assert.equal(res.status, 500); - assert.equal(mediaCalls, 0); + assert.equal(status, 500); + assert.equal(media.callCount(), 0); }); }); - it('POST /api/stats/mine-card falls back to Default deck for empty deck config', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); + // Direct sentence cards resolve their deck: configured deck, then Yomitan's deck, then Default. + for (const { name, options, expectedDeck } of [ + { + name: 'falls back to Default deck for empty deck config', + options: (env: MiningEnv): StatsAppOptions => ({ + ankiConnectConfig: ankiConfig(env, { deck: '', fields: SENTENCE_FIELDS, ...LAPIS }), + }), + expectedDeck: 'Default', + }, + { + name: 'uses Yomitan deck for direct sentence cards when config deck is empty', + options: (env: MiningEnv): StatsAppOptions => ({ + getYomitanAnkiDeckName: async () => 'Minecraft', + ankiConnectConfig: ankiConfig(env, { deck: '', fields: SENTENCE_FIELDS, ...LAPIS }), + }), + expectedDeck: 'Minecraft', + }, + { + name: 'resolves Anki config at request time', + options: (env: MiningEnv): StatsAppOptions => ({ + getAnkiConnectConfig: () => ankiConfig(env, { fields: SENTENCE_FIELDS, ...LAPIS }), + }), + expectedDeck: 'Mining', + }, + ]) { + it(`POST /api/stats/mine-card ${name}`, async () => { + await withMiningEnv({}, async (env) => { + const app = createStatsApp(createMockTracker(), options(env)); - await withFakeAnkiConnect(async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - ankiConnectConfig: { - url, - deck: '', - tags: ['SubMiner'], - fields: { - word: 'Expression', - audio: 'ExpressionAudio', - image: 'Picture', - sentence: 'Sentence', - miscInfo: 'MiscInfo', - translation: 'SelectionText', - }, - media: { - generateAudio: false, - generateImage: false, - }, - isLapis: { - enabled: true, - sentenceCardModel: 'Lapis Morph', - }, - isKiku: { - enabled: true, - fieldGrouping: 'manual', - deleteDuplicateInAuto: true, - }, - }, + const { status, body } = await mineCard(app, 'sentence', { + sourcePath: env.sourcePath, + secondaryText: 'I saw a cat', }); - const res = await app.request('/api/stats/mine-card?mode=sentence', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - secondaryText: 'I saw a cat', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); + assert.equal(status, 200, JSON.stringify(body)); assert.equal(body.noteId, 12345); - - const addNoteRequest = requests.find((request) => request.action === 'addNote'); - assert.equal(addNoteRequest?.params?.note?.deckName, 'Default'); - assert.equal(addNoteRequest?.params?.note?.modelName, 'Lapis Morph'); - assert.equal(addNoteRequest?.params?.note?.fields?.Sentence, '猫を見た'); - assert.equal(addNoteRequest?.params?.note?.fields?.IsSentenceCard, 'x'); + const note = sentNote(env.requests, 'addNote'); + assert.equal(note.deckName, expectedDeck); + assert.equal(note.modelName, 'Lapis Morph'); + assert.equal(note.fields.SelectionText, 'I saw a cat'); }); }); - }); + } - it('POST /api/stats/mine-card uses Yomitan deck for direct sentence cards when config deck is empty', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect(async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - getYomitanAnkiDeckName: async () => 'Minecraft', - ankiConnectConfig: { - url, - deck: '', - tags: ['SubMiner'], - fields: { - word: 'Expression', - sentence: 'Sentence', - translation: 'SelectionText', - }, - media: { - generateAudio: false, - generateImage: false, - }, - isLapis: { - enabled: true, - sentenceCardModel: 'Lapis Morph', - }, - }, - }); - - const res = await app.request('/api/stats/mine-card?mode=sentence', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - secondaryText: 'I saw a cat', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - const addNoteRequest = requests.find((request) => request.action === 'addNote'); - assert.equal(addNoteRequest?.params?.note?.deckName, 'Minecraft'); + it('POST /api/stats/mine-card uses the full sentence as sentence-card expression', async () => { + await withMiningEnv({}, async (env) => { + const app = createStatsApp(createMockTracker(), { + ankiConnectConfig: ankiConfig(env, { fields: SENTENCE_FIELDS, ...LAPIS }), }); - }); - }); - it('POST /api/stats/mine-card resolves Anki config at request time', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); + const { status, body } = await mineCard(app, 'sentence', { + sourcePath: env.sourcePath, + secondaryText: 'I saw a cat', + }); - await withFakeAnkiConnect(async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - getAnkiConnectConfig: () => ({ - url, - deck: 'Mining', - tags: ['SubMiner'], - fields: { - word: 'Expression', - sentence: 'Sentence', - translation: 'SelectionText', - }, - media: { - generateAudio: false, - generateImage: false, - }, - isLapis: { - enabled: true, - sentenceCardModel: 'Lapis Morph', - }, - }), - } as Parameters[1]); - - const res = await app.request('/api/stats/mine-card?mode=sentence', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - secondaryText: 'I saw a cat', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - const addNoteRequest = requests.find((request) => request.action === 'addNote'); - assert.equal(addNoteRequest?.params?.note?.deckName, 'Mining'); - assert.equal(addNoteRequest?.params?.note?.modelName, 'Lapis Morph'); - assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, 'I saw a cat'); + assert.equal(status, 200, JSON.stringify(body)); + assert.deepEqual(sentNote(env.requests, 'addNote').fields, { + Expression: '猫を見た', + Sentence: '猫を見た', + SelectionText: 'I saw a cat', + IsSentenceCard: 'x', }); }); }); it('POST /api/stats/mine-card prefers request secondary text over retimed fallback', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect(async (requests, url) => { - let retimedCalls = 0; - const options = { - resolveRetimedSecondarySubtitleText: async () => { - retimedCalls += 1; - return 'Aligned English subtitle'; - }, - ankiConnectConfig: { - url, - deck: 'Mining', - fields: { - sentence: 'Sentence', - translation: 'SelectionText', - }, - media: { - generateAudio: false, - generateImage: false, - }, - isLapis: { - enabled: true, - sentenceCardModel: 'Lapis Morph', - }, - }, - } as Parameters[1] & { - resolveRetimedSecondarySubtitleText: () => Promise; - }; - const app = createStatsApp(createMockTracker(), options); - - const res = await app.request('/api/stats/mine-card?mode=sentence', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - secondaryText: 'Stale stored English subtitle', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - - const addNoteRequest = requests.find((request) => request.action === 'addNote'); - assert.equal( - addNoteRequest?.params?.note?.fields?.SelectionText, - 'Stale stored English subtitle', - ); - assert.equal(retimedCalls, 0); - }); - }); - }); - - it('retimes secondary sidecar subtitles against the Japanese sidecar and caches the output', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - const japanesePath = path.join(dir, 'episode.ja.srt'); - const englishPath = path.join(dir, 'episode.en.srt'); - const alassPath = path.join(dir, 'alass-cli'); - const originalEnglish = `1 -00:00:09,000 --> 00:00:10,000 -Stale English subtitle -`; - fs.writeFileSync(sourcePath, 'fake media'); - fs.writeFileSync(alassPath, 'fake alass'); - fs.writeFileSync( - japanesePath, - `1 -00:00:01,000 --> 00:00:02,000 -猫を見た -`, - ); - fs.writeFileSync(englishPath, originalEnglish); - - let alassRuns = 0; - try { - const first = await resolveRetimedSecondarySubtitleTextFromSidecar({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - alassPath, - runAlass: async (_alassPath, referencePath, inputPath, outputPath) => { - alassRuns += 1; - assert.equal(referencePath, japanesePath); - assert.equal(inputPath, englishPath); - fs.writeFileSync( - outputPath, - `1 -00:00:01,000 --> 00:00:02,000 -Aligned English subtitle -`, - ); - return { ok: true, code: 0, stdout: '', stderr: '' }; - }, - }); - - const second = await resolveRetimedSecondarySubtitleTextFromSidecar({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - alassPath, - runAlass: async () => { - alassRuns += 1; - return { ok: false, code: 1, stdout: '', stderr: 'should use cache' }; - }, - }); - - assert.equal(first, 'Aligned English subtitle'); - assert.equal(second, 'Aligned English subtitle'); - assert.equal(alassRuns, 1); - assert.equal(fs.readFileSync(englishPath, 'utf8'), originalEnglish); - } finally { - clearRetimedSecondarySubtitleCache(); - } - }); - }); - - it('shares in-flight retimed secondary subtitle work for concurrent requests', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - const japanesePath = path.join(dir, 'episode.ja.srt'); - const englishPath = path.join(dir, 'episode.en.srt'); - const alassPath = path.join(dir, 'alass-cli'); - fs.writeFileSync(sourcePath, 'fake media'); - fs.writeFileSync(alassPath, 'fake alass'); - fs.writeFileSync( - japanesePath, - `1 -00:00:01,000 --> 00:00:02,000 -猫を見た -`, - ); - fs.writeFileSync( - englishPath, - `1 -00:00:09,000 --> 00:00:10,000 -Stale English subtitle -`, - ); - - let alassRuns = 0; - let releaseAlass!: () => void; - const alassGate = new Promise((resolve) => { - releaseAlass = resolve; - }); - const input = { - sourcePath, - startMs: 1_000, - endMs: 2_000, - alassPath, - runAlass: async ( - _alassPath: string, - _referencePath: string, - _inputPath: string, - outputPath: string, - ) => { - alassRuns += 1; - await alassGate; - fs.writeFileSync( - outputPath, - `1 -00:00:01,000 --> 00:00:02,000 -Aligned English subtitle -`, - ); - return { ok: true, code: 0, stdout: '', stderr: '' }; + await withMiningEnv({}, async (env) => { + let retimedCalls = 0; + const app = createStatsApp(createMockTracker(), { + resolveRetimedSecondarySubtitleText: async () => { + retimedCalls += 1; + return 'Aligned English subtitle'; }, - }; + ankiConnectConfig: ankiConfig(env, { fields: SENTENCE_FIELDS, ...LAPIS }), + }); - try { - const first = resolveRetimedSecondarySubtitleTextFromSidecar(input); - const second = resolveRetimedSecondarySubtitleTextFromSidecar(input); - releaseAlass(); + const { status, body } = await mineCard(app, 'sentence', { + sourcePath: env.sourcePath, + secondaryText: 'Stale stored English subtitle', + }); - assert.deepEqual(await Promise.all([first, second]), [ - 'Aligned English subtitle', - 'Aligned English subtitle', - ]); - assert.equal(alassRuns, 1); - } finally { - clearRetimedSecondarySubtitleCache(); - } + assert.equal(status, 200, JSON.stringify(body)); + assert.equal( + sentNote(env.requests, 'addNote').fields.SelectionText, + 'Stale stored English subtitle', + ); + assert.equal(retimedCalls, 0); }); }); + for (const { name, cues, sentence, expected } of [ + { + name: 'fills selection text from a matching secondary sidecar subtitle', + cues: [ + ['00:00:00,800 --> 00:00:02,500', 'I saw a cat.'], + ['00:00:03,000 --> 00:00:04,000', 'Not this line.'], + ], + sentence: '猫を見た', + expected: 'I saw a cat.', + }, + { + name: 'does not append the next sidecar cue near a timing boundary', + cues: [ + ['00:00:00,800 --> 00:00:01,500', "I don't give a damn what family she's from."], + ['00:00:01,700 --> 00:00:03,000', 'That snobby attitude just pisses me off!'], + ], + sentence: '名門か何だか知らねえが', + expected: "I don't give a damn what family she's from.", + }, + ]) { + it(`POST /api/stats/mine-card ${name}`, async () => { + const srt = cues + .map(([timing, text], index) => `${index + 1}\n${timing}\n${text}\n`) + .join('\n'); + await withMiningEnv({ files: { 'episode.en.srt': srt } }, async (env) => { + const app = createStatsApp(createMockTracker(), { + ankiConnectConfig: ankiConfig(env, { fields: SENTENCE_FIELDS, ...LAPIS }), + secondarySubtitleLanguages: ['en'], + }); + + const { status, body } = await mineCard(app, 'sentence', { + sourcePath: env.sourcePath, + sentence, + }); + + assert.equal(status, 200, JSON.stringify(body)); + assert.equal(sentNote(env.requests, 'addNote').fields.SelectionText, expected); + }); + }); + } + it('POST /api/stats/mine-card adds direct sentence cards before slow media finishes', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect(async (requests, url) => { - const mediaRelease: { - audio?: () => void; - image?: () => void; - } = {}; + const addNoteSent = deferred(); + await withMiningEnv( + { onAnkiRequest: (request) => request.action === 'addNote' && addNoteSent.resolve() }, + async (env) => { + // Media only finishes once the note exists, so the request deadlocks (and the + // test times out) if mining ever waits for media before adding the note. const app = createStatsApp(createMockTracker(), { createMediaGenerator: () => ({ - generateAudio: async () => - await new Promise((resolve) => { - mediaRelease.audio = () => resolve(Buffer.from('audio')); - }), - generateScreenshot: async () => - await new Promise((resolve) => { - mediaRelease.image = () => resolve(Buffer.from('image')); - }), + generateAudio: async () => addNoteSent.promise.then(() => AUDIO), + generateScreenshot: async () => addNoteSent.promise.then(() => IMAGE), generateAnimatedImage: async () => null, }), - ankiConnectConfig: { - url, - deck: 'Mining', - tags: ['SubMiner'], + ankiConnectConfig: ankiConfig(env, { fields: { - word: 'Expression', + ...SENTENCE_FIELDS, audio: 'ExpressionAudio', image: 'Picture', - sentence: 'Sentence', - translation: 'SelectionText', }, - media: { - generateAudio: true, - generateImage: true, - imageType: 'static', - }, - isLapis: { - enabled: true, - sentenceCardModel: 'Lapis Morph', - }, - }, - }); - - const pendingResponse = app.request('/api/stats/mine-card?mode=sentence', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - secondaryText: 'I saw a cat', - videoTitle: 'Episode 1', + media: { generateAudio: true, generateImage: true, imageType: 'static' }, + ...LAPIS, }), }); - for (let attempt = 0; attempt < 20; attempt += 1) { - if (requests.some((request) => request.action === 'addNote')) break; - await new Promise((resolve) => setTimeout(resolve, 1)); - } - const addedBeforeMediaFinished = requests.some((request) => request.action === 'addNote'); - mediaRelease.audio?.(); - mediaRelease.image?.(); - - const res = await pendingResponse; - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - assert.equal(addedBeforeMediaFinished, true); - }); - }); - }); - - it('POST /api/stats/mine-card leaves word card selection text to Yomitan glossary', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect(async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - addYomitanNote: async () => 777, - ankiConnectConfig: { - url, - deck: 'Mining', - fields: { - audio: 'ExpressionAudio', - image: 'Picture', - sentence: 'Sentence', - miscInfo: 'MiscInfo', - translation: 'SelectionText', - }, - media: { - generateAudio: false, - generateImage: false, - }, - }, + const { status, body } = await mineCard(app, 'sentence', { + sourcePath: env.sourcePath, + secondaryText: 'I saw a cat', }); - const res = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - secondaryText: 'I saw a cat', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - assert.equal(body.noteId, 777); - - const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); - assert.equal(updateRequest?.params?.note?.id, 777); - assert.equal(updateRequest?.params?.note?.fields?.Sentence, '猫を見た'); - assert.equal(updateRequest?.params?.note?.fields?.SelectionText, undefined); - }); - }); - }); - - it('POST /api/stats/mine-card moves Yomitan-created word notes to the configured deck', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect(async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - addYomitanNote: async () => 777, - ankiConnectConfig: { - url, - deck: 'Mining', - fields: { - sentence: 'Sentence', - translation: 'SelectionText', - }, - media: { - generateAudio: false, - generateImage: false, - }, - }, - }); - - const res = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - - const findCardsRequest = requests.find((request) => request.action === 'findCards'); - assert.equal(findCardsRequest?.params?.query, 'nid:777'); - const changeDeckRequest = requests.find((request) => request.action === 'changeDeck'); - assert.deepEqual(changeDeckRequest?.params?.cards, [9001]); - assert.equal(changeDeckRequest?.params?.deck, 'Mining'); - }); - }); - }); - - it('POST /api/stats/mine-card moves Yomitan-created word notes to Yomitan deck when config deck is empty', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect(async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - getYomitanAnkiDeckName: async () => 'Minecraft', - addYomitanNote: async () => 777, - ankiConnectConfig: { - url, - deck: '', - fields: { - sentence: 'Sentence', - translation: 'SelectionText', - }, - media: { - generateAudio: false, - generateImage: false, - }, - }, - }); - - const res = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - - const findCardsRequest = requests.find((request) => request.action === 'findCards'); - assert.equal(findCardsRequest?.params?.query, 'nid:777'); - const changeDeckRequest = requests.find((request) => request.action === 'changeDeck'); - assert.deepEqual(changeDeckRequest?.params?.cards, [9001]); - assert.equal(changeDeckRequest?.params?.deck, 'Minecraft'); - }); - }); - }); - - it('POST /api/stats/mine-card uses the full sentence as sentence-card expression', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect(async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - ankiConnectConfig: { - url, - deck: 'Minecraft', - tags: ['SubMiner'], - fields: { - word: 'Expression', - sentence: 'Sentence', - translation: 'SelectionText', - }, - media: { - generateAudio: false, - generateImage: false, - }, - isLapis: { - enabled: true, - sentenceCardModel: 'Lapis Morph', - }, - }, - }); - - const res = await app.request('/api/stats/mine-card?mode=sentence', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - secondaryText: 'I saw a cat', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - - const addNoteRequest = requests.find((request) => request.action === 'addNote'); - assert.equal(addNoteRequest?.params?.note?.deckName, 'Minecraft'); - assert.equal(addNoteRequest?.params?.note?.fields?.Expression, '猫を見た'); - assert.equal(addNoteRequest?.params?.note?.fields?.Sentence, '猫を見た'); - assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, 'I saw a cat'); - assert.equal(addNoteRequest?.params?.note?.fields?.IsSentenceCard, 'x'); - }); - }); - }); - - it('POST /api/stats/mine-card fills selection text from a matching secondary sidecar subtitle', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - fs.writeFileSync( - path.join(dir, 'episode.en.srt'), - [ - '1', - '00:00:00,800 --> 00:00:02,500', - 'I saw a cat.', - '', - '2', - '00:00:03,000 --> 00:00:04,000', - 'Not this line.', - '', - ].join('\n'), - ); - - await withFakeAnkiConnect(async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - ankiConnectConfig: { - url, - deck: 'Minecraft', - tags: ['SubMiner'], - fields: { - word: 'Expression', - sentence: 'Sentence', - translation: 'SelectionText', - }, - media: { - generateAudio: false, - generateImage: false, - }, - isLapis: { - enabled: true, - sentenceCardModel: 'Lapis Morph', - }, - }, - secondarySubtitleLanguages: ['en'], - } as Parameters[1]); - - const res = await app.request('/api/stats/mine-card?mode=sentence', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - - const addNoteRequest = requests.find((request) => request.action === 'addNote'); - assert.equal(addNoteRequest?.params?.note?.deckName, 'Minecraft'); - assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, 'I saw a cat.'); - }); - }); - }); - - it('POST /api/stats/mine-card does not append the next sidecar cue near a timing boundary', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - fs.writeFileSync( - path.join(dir, 'episode.en.srt'), - [ - '1', - '00:00:00,800 --> 00:00:01,500', - "I don't give a damn what family she's from.", - '', - '2', - '00:00:01,700 --> 00:00:03,000', - 'That snobby attitude just pisses me off!', - '', - ].join('\n'), - ); - - await withFakeAnkiConnect(async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - ankiConnectConfig: { - url, - deck: 'Minecraft', - tags: ['SubMiner'], - fields: { - word: 'Expression', - sentence: 'Sentence', - translation: 'SelectionText', - }, - media: { - generateAudio: false, - generateImage: false, - }, - isLapis: { - enabled: true, - sentenceCardModel: 'Lapis Morph', - }, - }, - secondarySubtitleLanguages: ['en'], - } as Parameters[1]); - - const res = await app.request('/api/stats/mine-card?mode=sentence', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '名門か何だか知らねえが', - word: '名門', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - - const addNoteRequest = requests.find((request) => request.action === 'addNote'); - assert.equal( - addNoteRequest?.params?.note?.fields?.SelectionText, - "I don't give a damn what family she's from.", + assert.equal(status, 200, JSON.stringify(body)); + assert.match( + sentNote(env.requests, 'updateNoteFields').fields.Picture ?? '', + /^$/, ); - }); - }); - }); - - it('POST /api/stats/mine-card writes word mining audio to SentenceAudio when present', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect(async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - addYomitanNote: async () => 777, - createMediaGenerator: () => ({ - generateAudio: async () => Buffer.from('audio'), - generateScreenshot: async () => null, - generateAnimatedImage: async () => null, - }), - ankiConnectConfig: { - url, - deck: 'Mining', - fields: { - audio: 'ExpressionAudio', - image: 'Picture', - sentence: 'Sentence', - miscInfo: 'MiscInfo', - }, - media: { - generateAudio: true, - generateImage: false, - }, - }, - }); - - const res = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - - const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); - const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio; - assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/); - assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined); - }); - }); - }); - - it('POST /api/stats/mine-card marks Kiku word mining notes as word-and-sentence cards when enabled', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect( - async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - addYomitanNote: async () => 777, - createMediaGenerator: () => ({ - generateAudio: async () => null, - generateScreenshot: async () => null, - generateAnimatedImage: async () => null, - }), - ankiConnectConfig: { - url, - deck: 'Mining', - fields: { - image: 'Picture', - sentence: 'Sentence', - }, - media: { - generateAudio: false, - generateImage: false, - }, - isKiku: { - enabled: true, - fieldGrouping: 'disabled', - deleteDuplicateInAuto: true, - }, - }, - }); - - const res = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - - const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); - const fields = updateRequest?.params?.note?.fields ?? {}; - assert.equal(fields.Sentence, '猫を見た'); - assert.equal(fields.IsWordAndSentenceCard, 'x'); - assert.equal(fields.IsSentenceCard, ''); - assert.equal(fields.IsAudioCard, ''); - }, - { - notesInfoFields: { - Expression: { value: '猫' }, - Sentence: { value: '' }, - Picture: { value: '' }, - IsWordAndSentenceCard: { value: '' }, - IsSentenceCard: { value: '' }, - IsAudioCard: { value: '' }, - }, - }, - ); - }); - }); - - it('POST /api/stats/mine-card marks the configured Kiku word card kind', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect( - async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - addYomitanNote: async () => 777, - createMediaGenerator: () => ({ - generateAudio: async () => null, - generateScreenshot: async () => null, - generateAnimatedImage: async () => null, - }), - ankiConnectConfig: { - url, - deck: 'Mining', - fields: { - image: 'Picture', - sentence: 'Sentence', - }, - media: { - generateAudio: false, - generateImage: false, - }, - isKiku: { - enabled: true, - fieldGrouping: 'disabled', - deleteDuplicateInAuto: true, - }, - lapisKiku: { - wordCardKind: 'click', - }, - }, - }); - - const res = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - - const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); - const fields = updateRequest?.params?.note?.fields ?? {}; - assert.equal(fields.IsClickCard, 'x'); - assert.equal(fields.IsWordAndSentenceCard, ''); - assert.equal(fields.IsSentenceCard, ''); - assert.equal(fields.IsAudioCard, ''); - }, - { - notesInfoFields: { - Expression: { value: '猫' }, - Sentence: { value: '' }, - Picture: { value: '' }, - IsWordAndSentenceCard: { value: '' }, - IsClickCard: { value: '' }, - IsSentenceCard: { value: '' }, - IsAudioCard: { value: '' }, - }, - }, - ); - }); - }); - - it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect(async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - addYomitanNote: async () => 777, - createMediaGenerator: () => ({ - generateAudio: async () => Buffer.from('audio'), - generateScreenshot: async () => Buffer.from('image'), - generateAnimatedImage: async () => null, - }), - ankiConnectConfig: { - url, - deck: 'Mining', - fields: { - audio: 'ExpressionAudio', - image: 'Picture', - sentence: 'Sentence', - }, - media: { - generateAudio: true, - generateImage: true, - imageType: 'static', - }, - }, - }); - - const res = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - assert.equal(body.errors, undefined); - - const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); - const fields = updateRequest?.params?.note?.fields ?? {}; - assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/); - assert.match(fields.Picture ?? '', /^$/); - assert.equal(fields.ExpressionAudio, undefined); - assert.equal(fields.SelectionText, undefined); - }); - }); - }); - - it('POST /api/stats/mine-card writes word mining sentence audio and animated image together', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect(async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - addYomitanNote: async () => 777, - createMediaGenerator: () => ({ - generateAudio: async () => Buffer.from('audio'), - generateScreenshot: async () => null, - generateAnimatedImage: async () => Buffer.from('animated'), - }), - ankiConnectConfig: { - url, - deck: 'Mining', - fields: { - audio: 'ExpressionAudio', - image: 'Picture', - sentence: 'Sentence', - }, - media: { - generateAudio: true, - generateImage: true, - imageType: 'avif', - }, - }, - }); - - const res = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - assert.equal(body.errors, undefined); - - const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); - const fields = updateRequest?.params?.note?.fields ?? {}; - assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/); - assert.match(fields.Picture ?? '', /^$/); - assert.equal(fields.ExpressionAudio, undefined); - assert.equal(fields.SelectionText, undefined); - }); - }); - }); - - it('POST /api/stats/mine-card reports an error when requested word image generation returns no image', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect(async (_requests, url) => { - const app = createStatsApp(createMockTracker(), { - addYomitanNote: async () => 777, - createMediaGenerator: () => ({ - generateAudio: async () => Buffer.from('audio'), - generateScreenshot: async () => null, - generateAnimatedImage: async () => null, - }), - ankiConnectConfig: { - url, - deck: 'Mining', - fields: { - audio: 'ExpressionAudio', - image: 'Picture', - sentence: 'Sentence', - }, - media: { - generateAudio: true, - generateImage: true, - imageType: 'static', - }, - }, - }); - - const res = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - assert.deepEqual(body.errors, ['image: no image generated']); - }); - }); - }); - - it('POST /api/stats/mine-card reports an error when requested word audio generation returns no audio', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect(async (_requests, url) => { - const app = createStatsApp(createMockTracker(), { - addYomitanNote: async () => 777, - createMediaGenerator: () => ({ - generateAudio: async () => null, - generateScreenshot: async () => Buffer.from('image'), - generateAnimatedImage: async () => null, - }), - ankiConnectConfig: { - url, - deck: 'Mining', - fields: { - audio: 'ExpressionAudio', - image: 'Picture', - sentence: 'Sentence', - }, - media: { - generateAudio: true, - generateImage: true, - imageType: 'static', - }, - }, - }); - - const res = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - assert.deepEqual(body.errors, ['audio: no audio generated']); - }); - }); - }); - - it('POST /api/stats/mine-card writes audio cards to configured audio field when note info is missing', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect( - async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - createMediaGenerator: () => ({ - generateAudio: async () => Buffer.from('audio'), - generateScreenshot: async () => null, - generateAnimatedImage: async () => null, - }), - ankiConnectConfig: { - url, - deck: 'Mining', - fields: { - audio: 'Voice', - image: 'Picture', - sentence: 'Sentence', - }, - media: { - generateAudio: true, - generateImage: false, - }, - isLapis: { - enabled: true, - sentenceCardModel: 'Lapis Morph', - }, - }, - }); - - const res = await app.request('/api/stats/mine-card?mode=audio', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - - const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); - assert.match( - updateRequest?.params?.note?.fields?.Voice ?? '', - /^\[sound:subminer_audio_\d+_12345\.mp3\]$/, - ); - }, - { notesInfoFields: null }, - ); - }); + }, + ); }); it('POST /api/stats/mine-card records timing for slow sentence mining phases', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect(async (requests, url) => { - let now = 0; - const timings: Array<{ mode: string; phase: string; elapsedMs: number; noteId?: number }> = - []; - const app = createStatsApp(createMockTracker(), { - nowMs: () => { - now += 10; - return now; + await withMiningEnv({}, async (env) => { + let now = 0; + const timings: Array<{ mode: string; phase: string; elapsedMs: number; noteId?: number }> = + []; + const app = createStatsApp(createMockTracker(), { + nowMs: () => { + now += 10; + return now; + }, + onMiningTiming: (event) => { + timings.push(event); + }, + createMediaGenerator: fakeMedia({ audio: AUDIO, image: IMAGE, animated: IMAGE }).create, + ankiConnectConfig: ankiConfig(env, { + tags: ['SubMiner'], + fields: { + ...SENTENCE_FIELDS, + audio: 'ExpressionAudio', + image: 'Picture', + miscInfo: 'MiscInfo', }, - onMiningTiming: (event) => { - timings.push(event); - }, - createMediaGenerator: () => ({ - generateAudio: async () => Buffer.from('audio'), - generateScreenshot: async () => Buffer.from('image'), - generateAnimatedImage: async () => Buffer.from('animated'), - }), - ankiConnectConfig: { - url, - deck: 'Mining', - tags: ['SubMiner'], - fields: { - word: 'Expression', - audio: 'ExpressionAudio', - image: 'Picture', - sentence: 'Sentence', - miscInfo: 'MiscInfo', - translation: 'SelectionText', - }, - media: { - generateAudio: true, - generateImage: true, - imageType: 'static', - }, - isLapis: { - enabled: true, - sentenceCardModel: 'Lapis Morph', - }, - }, - }); - - const res = await app.request('/api/stats/mine-card?mode=sentence', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - secondaryText: 'I saw a cat', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - assert.deepEqual( - timings.map((entry) => entry.phase), - [ - 'generateAudio', - 'generateScreenshot', - 'addNote', - 'findCards', - 'changeDeck', - 'uploadAudio', - 'uploadImage', - 'updateNoteFields', - ], - ); - assert.ok(timings.every((entry) => entry.mode === 'sentence' && entry.elapsedMs >= 0)); - assert.equal(timings.find((entry) => entry.phase === 'addNote')?.noteId, 12345); - - const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); - const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio; - assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_12345\.mp3\]$/); - assert.match( - updateRequest?.params?.note?.fields?.Picture ?? '', - /^$/, - ); - assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined); + media: { generateAudio: true, generateImage: true, imageType: 'static' }, + ...LAPIS, + }), }); + + const { status, body } = await mineCard(app, 'sentence', { + sourcePath: env.sourcePath, + secondaryText: 'I saw a cat', + }); + + assert.equal(status, 200, JSON.stringify(body)); + assert.deepEqual( + timings.map((entry) => entry.phase), + [ + 'generateAudio', + 'generateScreenshot', + 'addNote', + 'findCards', + 'changeDeck', + 'uploadAudio', + 'uploadImage', + 'updateNoteFields', + ], + ); + assert.ok(timings.every((entry) => entry.mode === 'sentence' && entry.elapsedMs >= 0)); + assert.equal(timings.find((entry) => entry.phase === 'addNote')?.noteId, 12345); + + const { fields } = sentNote(env.requests, 'updateNoteFields'); + assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_12345\.mp3\]$/); + assert.match(fields.Picture ?? '', /^$/); + assert.equal(fields.ExpressionAudio, undefined); }); }); it('POST /api/stats/mine-card only writes selection text for sentence cards', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - - await withFakeAnkiConnect(async (requests, url) => { - const app = createStatsApp(createMockTracker(), { - ankiConnectConfig: { - url, - deck: 'Mining', - fields: { - word: 'Expression', - sentence: 'Sentence', - translation: 'SelectionText', - }, - media: { - generateAudio: false, - generateImage: false, - }, - isLapis: { - enabled: true, - sentenceCardModel: 'Lapis Morph', - }, - }, - }); - - const res = await app.request('/api/stats/mine-card?mode=audio', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1_000, - endMs: 2_000, - sentence: '猫を見た', - word: '猫', - secondaryText: 'I saw a cat', - videoTitle: 'Episode 1', - }), - }); - - const body = await res.json(); - assert.equal(res.status, 200, JSON.stringify(body)); - - const addNoteRequest = requests.find((request) => request.action === 'addNote'); - assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, undefined); - assert.equal(addNoteRequest?.params?.note?.fields?.IsAudioCard, 'x'); + await withMiningEnv({}, async (env) => { + const app = createStatsApp(createMockTracker(), { + ankiConnectConfig: ankiConfig(env, { fields: SENTENCE_FIELDS, ...LAPIS }), }); + + const { status, body } = await mineCard(app, 'audio', { + sourcePath: env.sourcePath, + secondaryText: 'I saw a cat', + }); + + assert.equal(status, 200, JSON.stringify(body)); + const { fields } = sentNote(env.requests, 'addNote'); + assert.equal(fields.SelectionText, undefined); + assert.equal(fields.IsAudioCard, 'x'); }); }); - it('GET /api/stats/episode/:videoId/detail returns episode detail', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/episode/1/detail'); - assert.equal(res.status, 200); - const body = await res.json(); - assert.ok(Array.isArray(body.sessions)); - assert.ok(Array.isArray(body.words)); - assert.ok(Array.isArray(body.cardEvents)); - assert.equal(body.cardEvents[0].noteIds[0], 12345); + it('POST /api/stats/mine-card writes audio cards to configured audio field when note info is missing', async () => { + await withMiningEnv({ notesInfoFields: null }, async (env) => { + const app = createStatsApp(createMockTracker(), { + createMediaGenerator: fakeMedia({ audio: AUDIO }).create, + ankiConnectConfig: ankiConfig(env, { + fields: { audio: 'Voice', image: 'Picture', sentence: 'Sentence' }, + media: { generateAudio: true, generateImage: false }, + ...LAPIS, + }), + }); + + const { status, body } = await mineCard(app, 'audio', { sourcePath: env.sourcePath }); + + assert.equal(status, 200, JSON.stringify(body)); + assert.match( + sentNote(env.requests, 'updateNoteFields').fields.Voice ?? '', + /^\[sound:subminer_audio_\d+_12345\.mp3\]$/, + ); + }); }); - it('GET /api/stats/episode/:videoId/detail returns 400 for invalid videoId', async () => { - const app = createStatsApp(createMockTracker()); - const res = await app.request('/api/stats/episode/0/detail'); - assert.equal(res.status, 400); + it('POST /api/stats/mine-card leaves word card selection text to Yomitan glossary', async () => { + await withMiningEnv({}, async (env) => { + const app = createStatsApp(createMockTracker(), { + addYomitanNote: async () => 777, + ankiConnectConfig: ankiConfig(env, { + fields: { + audio: 'ExpressionAudio', + image: 'Picture', + sentence: 'Sentence', + miscInfo: 'MiscInfo', + translation: 'SelectionText', + }, + }), + }); + + const { status, body } = await mineCard(app, 'word', { + sourcePath: env.sourcePath, + secondaryText: 'I saw a cat', + }); + + assert.equal(status, 200, JSON.stringify(body)); + assert.equal(body.noteId, 777); + const note = sentNote(env.requests, 'updateNoteFields'); + assert.equal(note.id, 777); + assert.equal(note.fields.Sentence, '猫を見た'); + assert.equal(note.fields.SelectionText, undefined); + }); + }); + + for (const { name, deck, yomitanDeck, expectedDeck } of [ + { name: 'the configured deck', deck: 'Mining', yomitanDeck: undefined, expectedDeck: 'Mining' }, + { + name: 'Yomitan deck when config deck is empty', + deck: '', + yomitanDeck: 'Minecraft', + expectedDeck: 'Minecraft', + }, + ]) { + it(`POST /api/stats/mine-card moves Yomitan-created word notes to ${name}`, async () => { + await withMiningEnv({}, async (env) => { + const app = createStatsApp(createMockTracker(), { + getYomitanAnkiDeckName: yomitanDeck ? async () => yomitanDeck : undefined, + addYomitanNote: async () => 777, + ankiConnectConfig: ankiConfig(env, { + deck, + fields: { sentence: 'Sentence', translation: 'SelectionText' }, + }), + }); + + const { status, body } = await mineCard(app, 'word', { sourcePath: env.sourcePath }); + + assert.equal(status, 200, JSON.stringify(body)); + const findCards = env.requests.find((request) => request.action === 'findCards'); + assert.equal(findCards?.params?.query, 'nid:777'); + const changeDeck = env.requests.find((request) => request.action === 'changeDeck'); + assert.deepEqual(changeDeck?.params?.cards, [9001]); + assert.equal(changeDeck?.params?.deck, expectedDeck); + }); + }); + } + + it('POST /api/stats/mine-card writes word mining audio to SentenceAudio when present', async () => { + await withMiningEnv({}, async (env) => { + const app = createStatsApp(createMockTracker(), { + addYomitanNote: async () => 777, + createMediaGenerator: fakeMedia({ audio: AUDIO }).create, + ankiConnectConfig: ankiConfig(env, { + fields: { + audio: 'ExpressionAudio', + image: 'Picture', + sentence: 'Sentence', + miscInfo: 'MiscInfo', + }, + media: { generateAudio: true, generateImage: false }, + }), + }); + + const { status, body } = await mineCard(app, 'word', { sourcePath: env.sourcePath }); + + assert.equal(status, 200, JSON.stringify(body)); + const { fields } = sentNote(env.requests, 'updateNoteFields'); + assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/); + assert.equal(fields.ExpressionAudio, undefined); + }); + }); + + for (const { name, lapisKiku, expectedFlags } of [ + { + name: 'marks Kiku word mining notes as word-and-sentence cards when enabled', + lapisKiku: undefined, + expectedFlags: { IsWordAndSentenceCard: 'x', IsSentenceCard: '', IsAudioCard: '' }, + }, + { + name: 'marks the configured Kiku word card kind', + lapisKiku: { wordCardKind: 'click' as const }, + expectedFlags: { + IsClickCard: 'x', + IsWordAndSentenceCard: '', + IsSentenceCard: '', + IsAudioCard: '', + }, + }, + ]) { + it(`POST /api/stats/mine-card ${name}`, async () => { + const notesInfoFields = { + Expression: { value: '猫' }, + Sentence: { value: '' }, + Picture: { value: '' }, + IsWordAndSentenceCard: { value: '' }, + IsClickCard: { value: '' }, + IsSentenceCard: { value: '' }, + IsAudioCard: { value: '' }, + }; + await withMiningEnv({ notesInfoFields }, async (env) => { + const app = createStatsApp(createMockTracker(), { + addYomitanNote: async () => 777, + createMediaGenerator: fakeMedia().create, + ankiConnectConfig: ankiConfig(env, { + fields: { image: 'Picture', sentence: 'Sentence' }, + isKiku: { enabled: true, fieldGrouping: 'disabled', deleteDuplicateInAuto: true }, + ...(lapisKiku ? { lapisKiku } : {}), + }), + }); + + const { status, body } = await mineCard(app, 'word', { sourcePath: env.sourcePath }); + + assert.equal(status, 200, JSON.stringify(body)); + const { fields } = sentNote(env.requests, 'updateNoteFields'); + assert.equal(fields.Sentence, '猫を見た'); + for (const [field, value] of Object.entries(expectedFlags)) { + assert.equal(fields[field], value, field); + } + }); + }); + } + + for (const { imageType, media, extension } of [ + { imageType: 'static' as const, media: { audio: AUDIO, image: IMAGE }, extension: 'jpg' }, + { imageType: 'avif' as const, media: { audio: AUDIO, animated: IMAGE }, extension: 'avif' }, + ]) { + it(`POST /api/stats/mine-card writes word mining sentence audio and ${imageType} image together`, async () => { + await withMiningEnv({}, async (env) => { + const app = createStatsApp(createMockTracker(), { + addYomitanNote: async () => 777, + createMediaGenerator: fakeMedia(media).create, + ankiConnectConfig: ankiConfig(env, { + fields: { audio: 'ExpressionAudio', image: 'Picture', sentence: 'Sentence' }, + media: { generateAudio: true, generateImage: true, imageType }, + }), + }); + + const { status, body } = await mineCard(app, 'word', { sourcePath: env.sourcePath }); + + assert.equal(status, 200, JSON.stringify(body)); + assert.equal(body.errors, undefined); + const { fields } = sentNote(env.requests, 'updateNoteFields'); + assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/); + assert.match( + fields.Picture ?? '', + new RegExp(`^$`), + ); + assert.equal(fields.ExpressionAudio, undefined); + assert.equal(fields.SelectionText, undefined); + }); + }); + } + + for (const { missing, media, expectedError } of [ + { missing: 'image', media: { audio: AUDIO }, expectedError: 'image: no image generated' }, + { missing: 'audio', media: { image: IMAGE }, expectedError: 'audio: no audio generated' }, + ]) { + it(`POST /api/stats/mine-card reports an error when requested word ${missing} generation returns nothing`, async () => { + await withMiningEnv({}, async (env) => { + const app = createStatsApp(createMockTracker(), { + addYomitanNote: async () => 777, + createMediaGenerator: fakeMedia(media).create, + ankiConnectConfig: ankiConfig(env, { + fields: { audio: 'ExpressionAudio', image: 'Picture', sentence: 'Sentence' }, + media: { generateAudio: true, generateImage: true, imageType: 'static' }, + }), + }); + + const { status, body } = await mineCard(app, 'word', { sourcePath: env.sourcePath }); + + assert.equal(status, 200, JSON.stringify(body)); + assert.deepEqual(body.errors, [expectedError]); + }); + }); + } + + for (const outcome of ['success', 'unavailable', 'throws', 'no-field'] as const) { + it(`POST /api/stats/mine-card updates full sentence furigana for word cards without media (${outcome})`, async () => { + const notesInfoFields = { + Sentence: { value: '猫' }, + ...(outcome === 'no-field' ? {} : { sentencefurigana: { value: ' 猫[ねこ]' } }), + }; + await withMiningEnv({ notesInfoFields }, async (env) => { + let calls = 0; + const app = createStatsApp(createMockTracker(), { + ankiConnectConfig: ankiConfig(env), + addYomitanNote: async () => 12345, + generateSentenceFurigana: async (text, word) => { + calls++; + assert.equal(text, '猫を見た。'); + assert.equal(word, '猫'); + if (outcome === 'throws') throw new Error('parser unavailable'); + return outcome === 'success' ? ' 猫[ねこ]を 見[み]た。' : null; + }, + }); + + const { status } = await mineCard(app, 'word', { + sourcePath: env.sourcePath, + sentence: '猫を見た。', + }); + + assert.equal(status, 200); + const { fields } = sentNote(env.requests, 'updateNoteFields'); + assert.equal(fields.Sentence, '猫を見た。'); + assert.equal( + fields.sentencefurigana, + outcome === 'no-field' + ? undefined + : outcome === 'success' + ? ' 猫[ねこ]を 見[み]た。' + : '', + ); + assert.equal(calls, outcome === 'no-field' ? 0 : 1); + }); + }); + } + + it('POST /api/stats/mine-card skips furigana highlighting when highlightWord is disabled', async () => { + const notesInfoFields = { Sentence: { value: '猫' }, SentenceFurigana: { value: ' 猫[ねこ]' } }; + await withMiningEnv({ notesInfoFields }, async (env) => { + const highlights: Array = []; + const app = createStatsApp(createMockTracker(), { + ankiConnectConfig: ankiConfig(env, { behavior: { highlightWord: false } }), + addYomitanNote: async () => 12345, + generateSentenceFurigana: async (_text, highlightedText) => { + highlights.push(highlightedText); + return ' 猫[ねこ]を 見[み]た。'; + }, + }); + + const { status } = await mineCard(app, 'word', { + sourcePath: env.sourcePath, + sentence: '猫を見た。', + }); + + assert.equal(status, 200); + assert.deepEqual(highlights, [undefined]); + }); }); it('DELETE /api/stats/sessions/:sessionId deletes a session', async () => { @@ -3506,46 +2205,6 @@ Aligned English subtitle assert.deepEqual(await res.json(), { ok: true }); }); - it('DELETE /api/stats/sessions rejects non-safe or fractional session ids', async () => { - let deleteCalls = 0; - const app = createStatsApp( - createMockTracker({ - deleteSessions: async () => { - deleteCalls += 1; - }, - }), - ); - - const res = await app.request('/api/stats/sessions', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: '{"sessionIds":[1.5,1e309,9007199254740992]}', - }); - - assert.equal(res.status, 400); - assert.equal(deleteCalls, 0); - }); - - it('DELETE /api/stats/sessions rejects a partly invalid id list without deleting', async () => { - let deleteCalls = 0; - const app = createStatsApp( - createMockTracker({ - deleteSessions: async () => { - deleteCalls += 1; - }, - }), - ); - - const res = await app.request('/api/stats/sessions', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: '{"sessionIds":[4,1.9,7]}', - }); - - assert.equal(res.status, 400); - assert.equal(deleteCalls, 0); - }); - it('DELETE /api/stats/sessions deduplicates valid ids', async () => { let deletedSessionIds: number[] = []; const app = createStatsApp( @@ -3558,7 +2217,7 @@ Aligned English subtitle const res = await app.request('/api/stats/sessions', { method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: '{"sessionIds":[4,4,7]}', }); @@ -3573,7 +2232,7 @@ Aligned English subtitle deleteAnime: async (animeId: number) => { deletedAnimeId = animeId; }, - } as Partial), + }), ); const res = await app.request('/api/stats/anime/7', { method: 'DELETE' }); @@ -3583,49 +2242,6 @@ Aligned English subtitle assert.deepEqual(await res.json(), { ok: true }); }); - it('DELETE /api/stats/anime/:animeId rejects non-positive anime ids', async () => { - let deleteCalls = 0; - const app = createStatsApp( - createMockTracker({ - deleteAnime: async () => { - deleteCalls += 1; - }, - } as Partial), - ); - - const res = await app.request('/api/stats/anime/0', { method: 'DELETE' }); - - assert.equal(res.status, 400); - assert.equal(deleteCalls, 0); - }); - - it('DELETE /api/stats/anime/:animeId rejects malformed anime ids before deleting', async () => { - let deletedAnimeId: number | null = null; - const app = createStatsApp( - createMockTracker({ - deleteAnime: async (animeId: number) => { - deletedAnimeId = animeId; - }, - }), - ); - - for (const animeId of [ - '1.9', - '1.0', - '1e2', - '9007199254740992', - '1%0A', - '%201', - '01', - '+1', - '0x1', - ]) { - const res = await app.request(`/api/stats/anime/${animeId}`, { method: 'DELETE' }); - assert.equal(res.status, 400, `accepted malformed anime id: ${animeId}`); - } - assert.equal(deletedAnimeId, null); - }); - it('POST /api/stats/anime/:animeId/merge folds the given entries into the target', async () => { let merged: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null; const app = createStatsApp( @@ -3638,12 +2254,12 @@ Aligned English subtitle movedVideos: 3, }; }, - } as Partial), + }), ); const res = await app.request('/api/stats/anime/7/merge', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, // The target repeated in the sources must not delete the entry we keep. body: '{"sourceAnimeIds":[8,9,8,7]}', }); @@ -3666,7 +2282,7 @@ Aligned English subtitle mergeCalls += 1; return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 }; }, - } as Partial), + }), ); for (const body of [ @@ -3676,7 +2292,7 @@ Aligned English subtitle ]) { const res = await app.request('/api/stats/anime/7/merge', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body, }); assert.equal(res.status, 400); @@ -3692,12 +2308,12 @@ Aligned English subtitle moved = { videoId, animeId }; return { targetAnimeId: animeId, previousAnimeId: 4, removedPreviousAnime: true }; }, - } as Partial), + }), ); const res = await app.request('/api/stats/media/12/anime', { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: '{"animeId":7}', }); @@ -3719,12 +2335,12 @@ Aligned English subtitle mergedAnimeIds: [], movedVideos: 0, }), - } as Partial), + }), ); const res = await app.request('/api/stats/anime/7/merge', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: '{"sourceAnimeIds":[8]}', }); @@ -3737,12 +2353,12 @@ Aligned English subtitle mergeAnime: async () => { throw new Error(INCOMPATIBLE_PROVIDER_MERGE_MESSAGE); }, - } as Partial), + }), ); const res = await app.request('/api/stats/anime/7/merge', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: '{"sourceAnimeIds":[8]}', }); @@ -3756,12 +2372,12 @@ Aligned English subtitle moveVideoToAnime: async () => { throw new Error('Unknown episode or target library entry'); }, - } as Partial), + }), ); const res = await app.request('/api/stats/media/12/anime', { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: '{"animeId":99}', }); @@ -3774,12 +2390,12 @@ Aligned English subtitle moveVideoToAnime: async () => { throw new Error('database is locked'); }, - } as Partial), + }), ); const res = await app.request('/api/stats/media/12/anime', { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: '{"animeId":7}', }); @@ -3872,7 +2488,7 @@ Aligned English subtitle }), { status: 200, - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, }, ); }) as typeof fetch; @@ -3884,7 +2500,7 @@ Aligned English subtitle }); const res = await app.request('/api/stats/anki/notesInfo', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: JSON.stringify({ noteIds: [111] }), }); @@ -3935,7 +2551,7 @@ Aligned English subtitle }), { status: 200, - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, }, )) as typeof fetch; @@ -3951,7 +2567,7 @@ Aligned English subtitle }); const res = await app.request('/api/stats/anki/notesInfo', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: JSON.stringify({ noteIds: [333] }), }); @@ -3993,7 +2609,7 @@ Aligned English subtitle }), { status: 200, - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, }, )) as typeof fetch; @@ -4010,7 +2626,7 @@ Aligned English subtitle }); const res = await app.request('/api/stats/anki/notesInfo', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: JSON.stringify({ noteIds: [444] }), }); @@ -4309,7 +2925,7 @@ it('TMDB reassignment returns 404 for a missing library entry before fetching de const request = (animeId: number) => app.request(`/api/stats/anime/${animeId}/tmdb`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: JSON_HEADERS, body: JSON.stringify({ tmdbId: 12, tmdbType: 'tv' }), }); assert.equal((await request(99999)).status, 404); @@ -4321,101 +2937,3 @@ it('TMDB reassignment returns 404 for a missing library entry before fetching de assert.equal(fetches, 1); assert.deepEqual(assignments, [1]); }); - -for (const outcome of ['success', 'unavailable', 'throws', 'no-field'] as const) { - it(`stats word mining updates full sentence furigana without media (${outcome})`, async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - await withFakeAnkiConnect( - async (requests, url) => { - let calls = 0; - const app = createStatsApp(createMockTracker(), { - ankiConnectConfig: { - url, - deck: 'Mining', - media: { generateAudio: false, generateImage: false }, - }, - addYomitanNote: async () => 12345, - generateSentenceFurigana: async (text, word) => { - calls++; - assert.equal(text, '猫を見た。'); - assert.equal(word, '猫'); - if (outcome === 'throws') throw new Error('parser unavailable'); - return outcome === 'success' ? ' 猫[ねこ]を 見[み]た。' : null; - }, - }); - const response = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1000, - endMs: 2000, - sentence: '猫を見た。', - word: '猫', - }), - }); - assert.equal(response.status, 200); - const fields = requests.find((request) => request.action === 'updateNoteFields')?.params - ?.note?.fields; - assert.equal(fields?.Sentence, '猫を見た。'); - assert.equal( - fields?.sentencefurigana, - outcome === 'no-field' - ? undefined - : outcome === 'success' - ? ' 猫[ねこ]を 見[み]た。' - : '', - ); - assert.equal(calls, outcome === 'no-field' ? 0 : 1); - }, - { - notesInfoFields: { - Sentence: { value: '猫' }, - ...(outcome === 'no-field' ? {} : { sentencefurigana: { value: ' 猫[ねこ]' } }), - }, - }, - ); - }); - }); -} - -it('stats word mining skips furigana highlighting when highlightWord is disabled', async () => { - await withTempDir(async (dir) => { - const sourcePath = path.join(dir, 'episode.mkv'); - fs.writeFileSync(sourcePath, 'fake media'); - await withFakeAnkiConnect( - async (_requests, url) => { - const highlights: Array = []; - const app = createStatsApp(createMockTracker(), { - ankiConnectConfig: { - url, - deck: 'Mining', - media: { generateAudio: false, generateImage: false }, - behavior: { highlightWord: false }, - }, - addYomitanNote: async () => 12345, - generateSentenceFurigana: async (_text, highlightedText) => { - highlights.push(highlightedText); - return ' 猫[ねこ]を 見[み]た。'; - }, - }); - const response = await app.request('/api/stats/mine-card?mode=word', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath, - startMs: 1000, - endMs: 2000, - sentence: '猫を見た。', - word: '猫', - }), - }); - assert.equal(response.status, 200); - assert.deepEqual(highlights, [undefined]); - }, - { notesInfoFields: { Sentence: { value: '猫' }, SentenceFurigana: { value: ' 猫[ねこ]' } } }, - ); - }); -}); diff --git a/src/core/services/anki-jimaku.test.ts b/src/core/services/anki-jimaku.test.ts index fe820288..c1bf42e0 100644 --- a/src/core/services/anki-jimaku.test.ts +++ b/src/core/services/anki-jimaku.test.ts @@ -158,33 +158,6 @@ function createHarness(): RuntimeHarness { return { options, registered, state }; } -test('registerAnkiJimakuIpcRuntime provides full handler surface', () => { - const { registered } = createHarness(); - const expected = [ - 'setAnkiConnectEnabled', - 'clearAnkiHistory', - 'refreshKnownWords', - 'respondFieldGrouping', - 'buildKikuMergePreview', - 'getJimakuMediaInfo', - 'searchJimakuEntries', - 'listJimakuFiles', - 'resolveJimakuApiKey', - 'getCurrentMediaPath', - 'isRemoteMediaPath', - 'downloadToFile', - 'onDownloadedSubtitle', - 'searchTsukihimeEntries', - 'listTsukihimeFiles', - 'downloadTsukihimeSubtitle', - 'onDownloadedSecondarySubtitle', - ]; - - for (const key of expected) { - assert.equal(typeof registered[key], 'function', `missing handler: ${key}`); - } -}); - test('refreshKnownWords throws when integration is unavailable', async () => { const { registered } = createHarness(); @@ -196,20 +169,6 @@ test('refreshKnownWords throws when integration is unavailable', async () => { ); }); -test('refreshKnownWords delegates to integration', async () => { - const { registered, state } = createHarness(); - let refreshed = 0; - state.ankiIntegration = { - refreshKnownWordCache: async () => { - refreshed += 1; - }, - }; - - await registered.refreshKnownWords!(); - - assert.equal(refreshed, 1); -}); - test('setAnkiConnectEnabled disables active integration and broadcasts changes', () => { const { registered, state } = createHarness(); let destroyed = 0; @@ -274,30 +233,6 @@ test('buildKikuMergePreview returns guard error when integration is missing', as }); }); -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(); diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index 7e91afb1..a51fb49d 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -103,17 +103,6 @@ test('runAppReadyRuntime starts texthooker on startup when enabled in config', a ); }); -test('runAppReadyRuntime creates immersion tracker during heavy startup', async () => { - const { deps, calls } = makeDeps({ - shouldAutoInitializeOverlayRuntimeFromConfig: () => false, - }); - - await runAppReadyRuntime(deps); - - assert.equal(calls.includes('createImmersionTracker'), false); - assert.ok(calls.includes('log:Runtime ready: immersion tracker startup requested.')); -}); - test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => { const { deps, calls } = makeDeps({ getResolvedConfig: () => ({ @@ -226,22 +215,6 @@ test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wi ); }); -test('runAppReadyRuntime logs when createImmersionTracker dependency is missing', async () => { - const { deps, calls } = makeDeps({ - createImmersionTracker: undefined, - }); - await runAppReadyRuntime(deps); - assert.ok(calls.includes('log:Runtime ready: immersion tracker dependency is missing.')); -}); - -test('runAppReadyRuntime logs defer message when overlay not auto-started', async () => { - const { deps, calls } = makeDeps({ - shouldAutoInitializeOverlayRuntimeFromConfig: () => false, - }); - await runAppReadyRuntime(deps); - assert.ok(calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.')); -}); - test('runAppReadyRuntime applies config logging level during app-ready', async () => { const { deps, calls } = makeDeps({ getResolvedConfig: () => ({ diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index dd71c4c4..92713c72 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -1,11 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { CliArgs } from '../../cli/args'; -import { - CliCommandServiceDeps, - createCliCommandDepsRuntime, - handleCliCommand, -} from './cli-command'; +import { CliCommandServiceDeps, handleCliCommand } from './cli-command'; function makeArgs(overrides: Partial = {}): CliArgs { return { @@ -557,135 +553,6 @@ test('handleCliCommand applies socket path and connects on start', () => { assert.ok(calls.includes('connectMpvClient')); }); -test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exists', () => { - const calls: string[] = []; - const client = { - setSocketPath: (socketPath: string) => { - calls.push(`setSocketPath:${socketPath}`); - }, - connect: () => { - calls.push('connect'); - }, - reconnect: () => { - calls.push('reconnect'); - }, - }; - const deps = createCliCommandDepsRuntime({ - mpv: { - getSocketPath: () => '/tmp/runtime.sock', - setSocketPath: () => {}, - getClient: () => client, - showOsd: () => {}, - }, - texthooker: { - service: { isRunning: () => false, start: () => {} }, - getPort: () => 5174, - setPort: () => {}, - getWebsocketUrl: () => undefined, - shouldOpenBrowser: () => false, - openInBrowser: () => {}, - }, - overlay: { - isInitialized: () => true, - initialize: () => {}, - toggleVisible: () => {}, - togglePrimarySubtitleBar: () => {}, - setVisible: () => {}, - }, - mining: { - copyCurrentSubtitle: () => {}, - startPendingMultiCopy: () => {}, - mineSentenceCard: async () => {}, - startPendingMineSentenceMultiple: () => {}, - updateLastCardFromClipboard: async () => {}, - refreshKnownWords: async () => {}, - triggerFieldGrouping: async () => {}, - triggerSubsyncFromConfig: async () => {}, - markLastCardAsAudioCard: async () => {}, - }, - anilist: { - getStatus: () => ({ - tokenStatus: 'not_checked', - tokenSource: 'none', - tokenMessage: null, - tokenResolvedAt: null, - tokenErrorAt: null, - queuePending: 0, - queueReady: 0, - queueDeadLetter: 0, - queueLastAttemptAt: null, - queueLastError: null, - }), - clearToken: () => {}, - openSetup: () => {}, - getQueueStatus: () => ({ - pending: 0, - ready: 0, - deadLetter: 0, - lastAttemptAt: null, - lastError: null, - }), - retryQueueNow: async () => ({ ok: true, message: 'ok' }), - }, - dictionary: { - generate: async () => ({ - zipPath: '/tmp/test.zip', - fromCache: false, - mediaId: 1, - mediaTitle: 'Test', - entryCount: 0, - }), - getSelection: async () => ({ - seriesKey: 'test', - guessTitle: null, - current: null, - override: null, - candidates: [], - }), - setSelection: async () => ({ - ok: true, - seriesKey: 'test', - selected: { id: 1, title: 'Test', episodes: null }, - staleMediaIds: [], - }), - }, - jellyfin: { - openSetup: () => {}, - runStatsCommand: async () => {}, - runCommand: async () => {}, - }, - ui: { - openFirstRunSetup: () => {}, - openYomitanSettings: () => {}, - openConfigSettingsWindow: () => {}, - openSyncUiWindow: () => {}, - openYoutubeBrowserWindow: () => {}, - cycleSecondarySubMode: () => {}, - openRuntimeOptionsPalette: () => {}, - printHelp: () => {}, - }, - app: { - stop: () => {}, - hasMainWindow: () => true, - runUpdateCommand: async () => {}, - runEnsureLinuxRuntimePluginAssetsCommand: async () => {}, - runYoutubePlaybackFlow: async () => {}, - }, - dispatchSessionAction: async () => {}, - getMultiCopyTimeoutMs: () => 2500, - schedule: () => undefined, - log: () => {}, - logDebug: () => {}, - warn: () => {}, - error: () => {}, - }); - - deps.setMpvClientSocketPath('/tmp/runtime.sock'); - deps.connectMpvClient(); - - assert.deepEqual(calls, ['setSocketPath:/tmp/runtime.sock', 'reconnect']); -}); - test('handleCliCommand warns when texthooker port override used while running', () => { const { deps, calls } = createDeps({ isTexthookerRunning: () => true, @@ -882,13 +749,6 @@ test('handleCliCommand dispatches mark-watched session action', async () => { }); }); -test('handleCliCommand logs AniList status details', () => { - const { deps, calls } = createDeps(); - handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps); - assert.ok(calls.some((value) => value.startsWith('log:AniList token status:'))); - assert.ok(calls.some((value) => value.startsWith('log:AniList queue:'))); -}); - test('handleCliCommand runs AniList retry command', async () => { const { deps, calls } = createDeps(); handleCliCommand(makeArgs({ anilistRetryQueue: true }), 'initial', deps); diff --git a/src/core/services/discord-presence.test.ts b/src/core/services/discord-presence.test.ts index 57149b46..36092309 100644 --- a/src/core/services/discord-presence.test.ts +++ b/src/core/services/discord-presence.test.ts @@ -49,28 +49,6 @@ test('buildDiscordPresenceActivity falls back to idle with default style', () => assert.equal(payload.details, 'Sentence Mining'); }); -test('buildDiscordPresenceActivity uses meme style fallback', () => { - const memeConfig = { ...baseConfig, presenceStyle: 'meme' as const }; - const payload = buildDiscordPresenceActivity(memeConfig, { - ...baseSnapshot, - connected: false, - mediaPath: null, - }); - assert.equal(payload.details, 'Mining and crafting (Anki cards)'); - assert.equal(payload.smallImageText, 'Sentence Mining'); -}); - -test('buildDiscordPresenceActivity uses japanese style', () => { - const jpConfig = { ...baseConfig, presenceStyle: 'japanese' as const }; - const payload = buildDiscordPresenceActivity(jpConfig, { - ...baseSnapshot, - connected: false, - mediaPath: null, - }); - assert.equal(payload.details, '文の採掘中'); - assert.equal(payload.smallImageText, 'イマージョン学習'); -}); - test('buildDiscordPresenceActivity uses minimal style', () => { const minConfig = { ...baseConfig, presenceStyle: 'minimal' as const }; const payload = buildDiscordPresenceActivity(minConfig, { diff --git a/src/core/services/field-grouping-overlay.test.ts b/src/core/services/field-grouping-overlay.test.ts index c485ce05..6a14fad4 100644 --- a/src/core/services/field-grouping-overlay.test.ts +++ b/src/core/services/field-grouping-overlay.test.ts @@ -3,78 +3,6 @@ import assert from 'node:assert/strict'; import { KikuFieldGroupingChoice } from '../../types'; import { createFieldGroupingOverlayRuntime } from './field-grouping-overlay'; -test('createFieldGroupingOverlayRuntime sends overlay messages and sets restore flag', () => { - const sent: unknown[][] = []; - let visible = false; - const restore = new Set<'runtime-options' | 'subsync'>(); - - const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({ - getMainWindow: () => ({ - isDestroyed: () => false, - webContents: { - isLoading: () => false, - send: (...args: unknown[]) => { - sent.push(args); - }, - }, - }), - getVisibleOverlayVisible: () => visible, - setVisibleOverlayVisible: (next) => { - visible = next; - }, - getResolver: () => null, - setResolver: () => {}, - getRestoreVisibleOverlayOnModalClose: () => restore, - }); - - const ok = runtime.sendToVisibleOverlay('runtime-options:open', undefined, { - restoreOnModalClose: 'runtime-options', - }); - - assert.equal(ok, true); - assert.equal(visible, true); - assert.equal(restore.has('runtime-options'), true); - assert.deepEqual(sent, [['runtime-options:open']]); -}); - -test('createFieldGroupingOverlayRuntime callback cancels when send fails', async () => { - let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null; - const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({ - getMainWindow: () => null, - getVisibleOverlayVisible: () => false, - setVisibleOverlayVisible: () => {}, - getResolver: () => resolver, - setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => { - resolver = next; - }, - getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(), - }); - - const callback = runtime.createFieldGroupingCallback(); - const result = await callback({ - original: { - noteId: 1, - expression: 'a', - sentencePreview: 'a', - hasAudio: false, - hasImage: false, - isOriginal: true, - }, - duplicate: { - noteId: 2, - expression: 'b', - sentencePreview: 'b', - hasAudio: false, - hasImage: false, - isOriginal: false, - }, - }); - - assert.equal(result.cancelled, true); - assert.equal(result.keepNoteId, 0); - assert.equal(result.deleteNoteId, 0); -}); - test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay after resolver settles', async () => { let resolver: unknown = null; let visible = false; diff --git a/src/core/services/field-grouping.test.ts b/src/core/services/field-grouping.test.ts index 4feece86..d8210465 100644 --- a/src/core/services/field-grouping.test.ts +++ b/src/core/services/field-grouping.test.ts @@ -98,124 +98,59 @@ test('field grouping callback does not reject the next request after a response' assert.equal(secondChoice.keepNoteId, 2); }); -test('field grouping callback dismisses the modal UI when the send fails', async () => { - const store = createWrappedResolverStore(); - let dismissed = 0; - const callback = createFieldGroupingCallback({ - getVisibleOverlayVisible: () => false, - setVisibleOverlayVisible: () => {}, - getResolver: store.getResolver, - setResolver: store.setResolver, - sendRequestToVisibleOverlay: () => false, - dismissModalUi: () => { - dismissed += 1; - }, - }); +const dismissCases = [ + { name: 'the send fails', sends: false, responseTimeoutMs: undefined }, + { name: 'the response times out', sends: true, responseTimeoutMs: 5 }, +]; - const result = await callback(makeRequestData()); - assert.equal(result.cancelled, true); - assert.equal(dismissed, 1); - assert.equal(store.getResolver(), null); -}); - -test('field grouping callback handles modal dismiss failures on send failure', async () => { - const store = createWrappedResolverStore(); - const originalConsoleError = console.error; - console.error = () => {}; - try { +for (const c of dismissCases) { + test(`field grouping callback dismisses the modal UI when ${c.name}`, async () => { + const store = createWrappedResolverStore(); + let dismissed = 0; const callback = createFieldGroupingCallback({ getVisibleOverlayVisible: () => false, setVisibleOverlayVisible: () => {}, getResolver: store.getResolver, setResolver: store.setResolver, - sendRequestToVisibleOverlay: () => false, + sendRequestToVisibleOverlay: () => c.sends, dismissModalUi: () => { - throw new Error('dismiss failed'); + dismissed += 1; }, + responseTimeoutMs: c.responseTimeoutMs, }); const result = await callback(makeRequestData()); - assert.equal(result.cancelled, true); + assert.equal(dismissed, 1); assert.equal(store.getResolver(), null); - } finally { - console.error = originalConsoleError; - } -}); - -test('field grouping callback handles modal dismiss failures on timeout', async () => { - const store = createWrappedResolverStore(); - const originalConsoleError = console.error; - console.error = () => {}; - try { - const callback = createFieldGroupingCallback({ - getVisibleOverlayVisible: () => false, - setVisibleOverlayVisible: () => {}, - getResolver: store.getResolver, - setResolver: store.setResolver, - sendRequestToVisibleOverlay: () => true, - dismissModalUi: () => { - throw new Error('dismiss failed'); - }, - responseTimeoutMs: 5, - }); - - const result = await callback(makeRequestData()); - - assert.equal(result.cancelled, true); - assert.equal(store.getResolver(), null); - } finally { - console.error = originalConsoleError; - } -}); - -test('field grouping callback reports modal dismiss failures', async () => { - const store = createWrappedResolverStore(); - const errors: unknown[] = []; - const originalConsoleError = console.error; - console.error = (...args: unknown[]) => { - errors.push(args); - }; - try { - const callback = createFieldGroupingCallback({ - getVisibleOverlayVisible: () => false, - setVisibleOverlayVisible: () => {}, - getResolver: store.getResolver, - setResolver: store.setResolver, - sendRequestToVisibleOverlay: () => false, - dismissModalUi: () => { - throw new Error('dismiss failed'); - }, - }); - - await callback(makeRequestData()); - - assert.equal(errors.length, 1); - } finally { - console.error = originalConsoleError; - } -}); - -test('field grouping callback dismisses the modal UI when the response times out', async () => { - const store = createWrappedResolverStore(); - let dismissed = 0; - const callback = createFieldGroupingCallback({ - getVisibleOverlayVisible: () => false, - setVisibleOverlayVisible: () => {}, - getResolver: store.getResolver, - setResolver: store.setResolver, - sendRequestToVisibleOverlay: () => true, - dismissModalUi: () => { - dismissed += 1; - }, - responseTimeoutMs: 5, }); - const result = await callback(makeRequestData()); - assert.equal(result.cancelled, true); - assert.equal(dismissed, 1); - assert.equal(store.getResolver(), null); -}); + test(`field grouping callback still cancels and clears the resolver when ${c.name} and dismiss throws`, async () => { + const store = createWrappedResolverStore(); + const originalConsoleError = console.error; + console.error = () => {}; + try { + const callback = createFieldGroupingCallback({ + getVisibleOverlayVisible: () => false, + setVisibleOverlayVisible: () => {}, + getResolver: store.getResolver, + setResolver: store.setResolver, + sendRequestToVisibleOverlay: () => c.sends, + dismissModalUi: () => { + throw new Error('dismiss failed'); + }, + responseTimeoutMs: c.responseTimeoutMs, + }); + + const result = await callback(makeRequestData()); + + assert.equal(result.cancelled, true); + assert.equal(store.getResolver(), null); + } finally { + console.error = originalConsoleError; + } + }); +} test('field grouping callback does not dismiss the modal UI on a normal response', async () => { const store = createWrappedResolverStore(); diff --git a/src/core/services/hyprland-window-placement.test.ts b/src/core/services/hyprland-window-placement.test.ts index ea6fc36e..4adb7bbd 100644 --- a/src/core/services/hyprland-window-placement.test.ts +++ b/src/core/services/hyprland-window-placement.test.ts @@ -499,36 +499,13 @@ test('ensureHyprlandWindowFloatingByTitle dispatches Lua syntax for Lua-config H }); assert.equal(placed, true); - assert.deepEqual( - calls.map(([, args]) => args), - [ - ['-j', 'clients'], - ['-j', 'status'], - ['dispatch', 'hl.dsp.window.resize({ x = 1920, y = 1080, window = "address:0xmatch" })'], - ['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xmatch" })'], - [ - 'dispatch', - 'hl.dsp.window.set_prop({ prop = "rounding", value = "0", window = "address:0xmatch" })', - ], - [ - 'dispatch', - 'hl.dsp.window.set_prop({ prop = "border_size", value = "0", window = "address:0xmatch" })', - ], - [ - 'dispatch', - 'hl.dsp.window.set_prop({ prop = "no_shadow", value = "1", window = "address:0xmatch" })', - ], - [ - 'dispatch', - 'hl.dsp.window.set_prop({ prop = "no_blur", value = "1", window = "address:0xmatch" })', - ], - [ - 'dispatch', - 'hl.dsp.window.set_prop({ prop = "decorate", value = "0", window = "address:0xmatch" })', - ], - ['dispatch', 'hl.dsp.window.alter_zorder({ mode = "top", window = "address:0xmatch" })'], - ], - ); + const dispatches = calls + .map(([, args]) => args as string[]) + .filter(([verb]) => verb === 'dispatch'); + assert.ok(dispatches.length > 0); + for (const [, dispatcher] of dispatches) { + assert.ok(dispatcher!.startsWith('hl.dsp.window.'), dispatcher); + } }); test('hasHyprlandWindowPlacementBoundsMismatch compares compositor client bounds', () => { diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index 0e30740f..bbdbed68 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -3,7 +3,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { toMonthKey } from './immersion-tracker/maintenance'; import { enqueueWrite } from './immersion-tracker/queue'; import { toDbTimestamp } from './immersion-tracker/query-shared'; import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair'; @@ -137,11 +136,6 @@ test('seam: enqueueWrite drops oldest entries once capacity is exceeded', () => assert.equal((queue[1] as Extract).eventType, 3); }); -test('seam: toMonthKey uses UTC calendar month', () => { - assert.equal(toMonthKey(-86_400_000), 196912); - assert.equal(toMonthKey(0), 197001); -}); - test('startSession generates UUID-like session identifiers', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; @@ -528,37 +522,6 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal } }); -test('fresh tracker DB creates lifetime summary tables', async () => { - const dbPath = makeDbPath(); - let tracker: ImmersionTrackerService | null = null; - - try { - const Ctor = await loadTrackerCtor(); - tracker = new Ctor({ dbPath }); - - const db = new Database(dbPath); - const tableRows = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") - .all() as Array<{ name: string }>; - db.close(); - - const tableNames = new Set(tableRows.map((row) => row.name)); - const expectedTables = [ - 'imm_lifetime_global', - 'imm_lifetime_anime', - 'imm_lifetime_media', - 'imm_lifetime_applied_sessions', - ]; - - for (const tableName of expectedTables) { - assert.ok(tableNames.has(tableName), `Expected ${tableName} to exist`); - } - } finally { - tracker?.destroy(); - cleanupDbPath(dbPath); - } -}); - test('fresh tracker DB skips lexical rollup backfill work', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; @@ -3773,138 +3736,6 @@ test('monthly rollups are grouped by calendar month', async () => { } }); -test('flushSingle reuses cached prepared statements', async () => { - const dbPath = makeDbPath(); - let tracker: ImmersionTrackerService | null = null; - let originalPrepare: DatabaseSync['prepare'] | null = null; - - try { - const Ctor = await loadTrackerCtor(); - tracker = new Ctor({ dbPath }); - const privateApi = tracker as unknown as { - db: DatabaseSync; - flushSingle: (write: { - kind: 'telemetry' | 'event'; - sessionId: number; - sampleMs: number; - eventType?: number; - lineIndex?: number | null; - segmentStartMs?: number | null; - segmentEndMs?: number | null; - tokensDelta?: number; - cardsDelta?: number; - payloadJson?: string | null; - totalWatchedMs?: number; - activeWatchedMs?: number; - linesSeen?: number; - tokensSeen?: number; - cardsMined?: number; - lookupCount?: number; - lookupHits?: number; - yomitanLookupCount?: number; - pauseCount?: number; - pauseMs?: number; - seekForwardCount?: number; - seekBackwardCount?: number; - mediaBufferEvents?: number; - }) => void; - }; - - originalPrepare = privateApi.db.prepare; - let prepareCalls = 0; - privateApi.db.prepare = (...args: Parameters) => { - prepareCalls += 1; - return originalPrepare!.apply(privateApi.db, args); - }; - const preparedRestore = originalPrepare; - - privateApi.db.exec(` - INSERT INTO imm_videos ( - video_id, - video_key, - canonical_title, - source_type, - duration_ms, - CREATED_DATE, - LAST_UPDATE_DATE - ) VALUES ( - 1, - 'local:/tmp/prepared.mkv', - 'Prepared', - 1, - 0, - 1000, - 1000 - ) - `); - - privateApi.db.exec(` - INSERT INTO imm_sessions ( - session_id, - session_uuid, - video_id, - started_at_ms, - status, - CREATED_DATE, - LAST_UPDATE_DATE, - ended_at_ms - ) VALUES ( - 1, - '33333333-3333-3333-3333-333333333333', - 1, - 1000, - 2, - 1000, - 1000, - 2000 - ) - `); - - privateApi.flushSingle({ - kind: 'telemetry', - sessionId: 1, - sampleMs: 1500, - totalWatchedMs: 1000, - activeWatchedMs: 1000, - linesSeen: 1, - tokensSeen: 2, - cardsMined: 0, - lookupCount: 0, - lookupHits: 0, - yomitanLookupCount: 0, - pauseCount: 0, - pauseMs: 0, - seekForwardCount: 0, - seekBackwardCount: 0, - mediaBufferEvents: 0, - }); - - privateApi.flushSingle({ - kind: 'event', - sessionId: 1, - sampleMs: 1600, - eventType: 1, - lineIndex: 1, - segmentStartMs: 0, - segmentEndMs: 1000, - tokensDelta: 2, - cardsDelta: 0, - payloadJson: '{"event":"subtitle-line"}', - }); - - privateApi.db.prepare = preparedRestore; - - assert.equal(prepareCalls, 0); - } finally { - if (tracker && originalPrepare) { - const privateApi = tracker as unknown as { db: DatabaseSync }; - privateApi.db.prepare = originalPrepare; - } - tracker?.destroy(); - cleanupDbPath(dbPath); - } -}); - test('reassignAnimeAnilist deduplicates cover blobs and getCoverArt remains compatible', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; diff --git a/src/core/services/immersion-tracker/__tests__/maintenance-source.test.ts b/src/core/services/immersion-tracker/__tests__/maintenance-source.test.ts deleted file mode 100644 index 6890ba7a..00000000 --- a/src/core/services/immersion-tracker/__tests__/maintenance-source.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import test from 'node:test'; - -test('getRollupGroupsForSessions uses only localtime rollup keys', () => { - const source = fs.readFileSync( - path.join(process.cwd(), 'src/core/services/immersion-tracker/maintenance.ts'), - 'utf8', - ); - const start = source.indexOf('export function getRollupGroupsForSessions'); - const end = source.indexOf('export function refreshRollupsForGroupsInTransaction'); - const functionSource = source.slice(start, end); - - assert.match(functionSource, /'unixepoch', 'localtime'/); - assert.doesNotMatch(functionSource, /UNION/); - assert.doesNotMatch(functionSource, /86400000/); -}); diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index 36b9c3fe..eecaac2d 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -602,39 +602,6 @@ test('getSessionTimeline returns the full session when no limit is provided', () } }); -test('getDailyRollups limits by distinct days (not rows)', () => { - const dbPath = makeDbPath(); - const db = openTestDb(dbPath); - - try { - ensureSchema(db); - - const insert = db.prepare( - ` - INSERT INTO imm_daily_rollups ( - rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, - total_tokens_seen, total_cards - ) VALUES (?, ?, ?, ?, ?, ?, ?) - `, - ); - - insert.run(10, 1, 1, 1, 0, 0, 2); - insert.run(10, 2, 1, 1, 0, 0, 3); - insert.run(9, 1, 1, 1, 0, 0, 1); - insert.run(8, 1, 1, 1, 0, 0, 1); - - const rows = getDailyRollups(db, 2); - assert.equal(rows.length, 3); - assert.ok(rows.every((r) => r.rollupDayOrMonth === 10 || r.rollupDayOrMonth === 9)); - assert.ok(rows.some((r) => r.rollupDayOrMonth === 10 && r.videoId === 1)); - assert.ok(rows.some((r) => r.rollupDayOrMonth === 10 && r.videoId === 2)); - assert.ok(rows.some((r) => r.rollupDayOrMonth === 9 && r.videoId === 1)); - } finally { - db.close(); - cleanupDbPath(dbPath); - } -}); - test('getTrendsDashboard returns chart-ready aggregated series', () => { const dbPath = makeDbPath(); const db = openTestDb(dbPath); @@ -2075,20 +2042,6 @@ test('getVocabularyStats pages past hidden rows until enough visible rows are co } }); -test('getVocabularyStats returns empty array when no words exist', () => { - const dbPath = makeDbPath(); - const db = openTestDb(dbPath); - - try { - ensureSchema(db); - const rows = getVocabularyStats(db, 10); - assert.deepEqual(rows, []); - } finally { - db.close(); - cleanupDbPath(dbPath); - } -}); - test('cleanupVocabularyStats repairs stored POS metadata and removes excluded imm_words rows', async () => { const dbPath = makeDbPath(); const db = openTestDb(dbPath); @@ -2524,20 +2477,6 @@ test('getKanjiStats returns rows ordered by frequency descending', () => { } }); -test('getKanjiStats returns empty array when no kanji exist', () => { - const dbPath = makeDbPath(); - const db = openTestDb(dbPath); - - try { - ensureSchema(db); - const rows = getKanjiStats(db, 10); - assert.deepEqual(rows, []); - } finally { - db.close(); - cleanupDbPath(dbPath); - } -}); - test('getSessionEvents returns events ordered by ts_ms ascending', () => { const dbPath = makeDbPath(); const db = openTestDb(dbPath); @@ -2643,29 +2582,6 @@ test('getSessionEvents round-trips wall-clock timestamps written through event i } }); -test('getSessionEvents returns empty array for session with no events', () => { - const dbPath = makeDbPath(); - const db = openTestDb(dbPath); - - try { - ensureSchema(db); - - const videoId = getOrCreateVideoRecord(db, 'local:/tmp/events-empty.mkv', { - canonicalTitle: 'Events Empty', - sourcePath: '/tmp/events-empty.mkv', - sourceUrl: null, - sourceType: SOURCE_TYPE_LOCAL, - }); - const { sessionId } = startSessionRecord(db, videoId, 6_000_000); - - const events = getSessionEvents(db, sessionId, 50); - assert.deepEqual(events, []); - } finally { - db.close(); - cleanupDbPath(dbPath); - } -}); - test('getSessionEvents filters events to the requested session id', () => { const dbPath = makeDbPath(); const db = openTestDb(dbPath); diff --git a/src/core/services/immersion-tracker/maintenance.test.ts b/src/core/services/immersion-tracker/maintenance.test.ts index 5ab59f34..12431ae0 100644 --- a/src/core/services/immersion-tracker/maintenance.test.ts +++ b/src/core/services/immersion-tracker/maintenance.test.ts @@ -4,12 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { Database } from './sqlite'; -import { - pruneRawRetention, - pruneRollupRetention, - runOptimizeMaintenance, - toMonthKey, -} from './maintenance'; +import { pruneRawRetention, pruneRollupRetention, toMonthKey } from './maintenance'; import { ensureSchema } from './storage'; import { toDbTimestamp } from './query-shared'; @@ -225,42 +220,3 @@ test('raw retention keeps rollups and rollup retention prunes them separately', cleanupDbPath(dbPath); } }); - -test('ensureSchema adds sample_ms index for telemetry rollup scans', () => { - const dbPath = makeDbPath(); - const db = new Database(dbPath); - - try { - ensureSchema(db); - const indexes = db.prepare("PRAGMA index_list('imm_session_telemetry')").all() as Array<{ - name: string; - }>; - const hasSampleMsIndex = indexes.some((row) => row.name === 'idx_telemetry_sample_ms'); - assert.equal(hasSampleMsIndex, true); - - const indexColumns = db.prepare("PRAGMA index_info('idx_telemetry_sample_ms')").all() as Array<{ - name: string; - }>; - assert.deepEqual( - indexColumns.map((column) => column.name), - ['sample_ms'], - ); - } finally { - db.close(); - cleanupDbPath(dbPath); - } -}); - -test('runOptimizeMaintenance executes PRAGMA optimize', () => { - const executedSql: string[] = []; - const db = { - exec(source: string) { - executedSql.push(source); - return this; - }, - } as unknown as Parameters[0]; - - runOptimizeMaintenance(db); - - assert.deepEqual(executedSql, ['PRAGMA optimize']); -}); diff --git a/src/core/services/immersion-tracker/storage-session.test.ts b/src/core/services/immersion-tracker/storage-session.test.ts index ff36deb8..749bfcad 100644 --- a/src/core/services/immersion-tracker/storage-session.test.ts +++ b/src/core/services/immersion-tracker/storage-session.test.ts @@ -59,34 +59,6 @@ function cleanupDbPath(dbPath: string): void { // libsql keeps Windows file handles alive after close when prepared statements were used. } -test('applyPragmas sets the SQLite tuning defaults used by immersion tracking', () => { - const dbPath = makeDbPath(); - const db = new Database(dbPath); - - try { - applyPragmas(db); - - const journalModeRow = db.prepare('PRAGMA journal_mode').get() as { - journal_mode: string; - }; - const synchronousRow = db.prepare('PRAGMA synchronous').get() as { synchronous: number }; - const foreignKeysRow = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }; - const busyTimeoutRow = db.prepare('PRAGMA busy_timeout').get() as { timeout: number }; - const journalSizeLimitRow = db.prepare('PRAGMA journal_size_limit').get() as { - journal_size_limit: number; - }; - - assert.equal(journalModeRow.journal_mode, 'wal'); - assert.equal(synchronousRow.synchronous, 1); - assert.equal(foreignKeysRow.foreign_keys, 1); - assert.equal(busyTimeoutRow.timeout, 2500); - assert.equal(journalSizeLimitRow.journal_size_limit, 67_108_864); - } finally { - db.close(); - cleanupDbPath(dbPath); - } -}); - test('applyPragmas installs the busy timeout before WAL negotiation', () => { const statements: string[] = []; const db: DatabaseSync = { @@ -110,76 +82,6 @@ test('applyPragmas installs the busy timeout before WAL negotiation', () => { ]); }); -test('ensureSchema creates immersion core tables', () => { - const dbPath = makeDbPath(); - const db = new Database(dbPath); - - try { - ensureSchema(db); - const rows = db - .prepare( - `SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%' ORDER BY name`, - ) - .all() as Array<{ name: string }>; - const tableNames = new Set(rows.map((row) => row.name)); - - assert.ok(tableNames.has('imm_videos')); - assert.ok(tableNames.has('imm_anime')); - assert.ok(tableNames.has('imm_sessions')); - assert.ok(tableNames.has('imm_session_telemetry')); - assert.ok(tableNames.has('imm_session_events')); - assert.ok(tableNames.has('imm_daily_rollups')); - assert.ok(tableNames.has('imm_monthly_rollups')); - assert.ok(tableNames.has('imm_words')); - assert.ok(tableNames.has('imm_kanji')); - assert.ok(tableNames.has('imm_subtitle_lines')); - assert.ok(tableNames.has('imm_word_line_occurrences')); - assert.ok(tableNames.has('imm_kanji_line_occurrences')); - assert.ok(tableNames.has('imm_rollup_state')); - assert.ok(tableNames.has('imm_cover_art_blobs')); - assert.ok(tableNames.has('imm_youtube_videos')); - assert.ok(tableNames.has('imm_stats_excluded_words')); - - const videoColumns = new Set( - ( - db.prepare('PRAGMA table_info(imm_videos)').all() as Array<{ - name: string; - }> - ).map((row) => row.name), - ); - - assert.ok(videoColumns.has('anime_id')); - assert.ok(videoColumns.has('parsed_basename')); - assert.ok(videoColumns.has('parsed_title')); - assert.ok(videoColumns.has('parsed_season')); - assert.ok(videoColumns.has('parsed_episode')); - assert.ok(videoColumns.has('parser_source')); - assert.ok(videoColumns.has('parser_confidence')); - assert.ok(videoColumns.has('parse_metadata_json')); - assert.ok(videoColumns.has('anime_assignment_locked')); - - const mediaArtColumns = new Set( - ( - db.prepare('PRAGMA table_info(imm_media_art)').all() as Array<{ - name: string; - }> - ).map((row) => row.name), - ); - assert.ok(mediaArtColumns.has('cover_blob_hash')); - - const rollupStateRow = db - .prepare('SELECT state_value FROM imm_rollup_state WHERE state_key = ?') - .get('last_rollup_sample_ms') as { - state_value: string; - } | null; - assert.ok(rollupStateRow); - assert.equal(Number(rollupStateRow?.state_value ?? 0), 0); - } finally { - db.close(); - cleanupDbPath(dbPath); - } -}); - test('ensureSchema adds manual assignment locks when upgrading the previous schema', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); @@ -752,37 +654,6 @@ test('ensureSchema migrates session event timestamps to text and repairs libsql- } }); -test('ensureSchema creates large-history performance indexes', () => { - const dbPath = makeDbPath(); - const db = new Database(dbPath); - - try { - ensureSchema(db); - const indexNames = new Set( - ( - db - .prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%'`) - .all() as Array<{ - name: string; - }> - ).map((row) => row.name), - ); - - assert.ok(indexNames.has('idx_telemetry_sample_ms')); - assert.ok(indexNames.has('idx_sessions_started_at')); - assert.ok(indexNames.has('idx_sessions_ended_at')); - assert.ok(indexNames.has('idx_words_frequency')); - assert.ok(indexNames.has('idx_kanji_frequency')); - assert.ok(indexNames.has('idx_media_art_anilist_id')); - assert.ok(indexNames.has('idx_media_art_cover_url')); - assert.ok(indexNames.has('idx_youtube_videos_channel_id')); - assert.ok(indexNames.has('idx_youtube_videos_youtube_video_id')); - } finally { - db.close(); - cleanupDbPath(dbPath); - } -}); - test('ensureSchema adds the subtitle event index to current-version databases', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); diff --git a/src/core/services/ipc-command.test.ts b/src/core/services/ipc-command.test.ts index c2484ede..18c4a96e 100644 --- a/src/core/services/ipc-command.test.ts +++ b/src/core/services/ipc-command.test.ts @@ -129,47 +129,32 @@ test('handleMpvCommandFromIpc emits mpv OSD for subtitle step keybinding proxies assert.deepEqual(playbackFeedback, []); }); -test('handleMpvCommandFromIpc does not dispatch retired subtitle-delay shift tokens', () => { - const { options, calls, sentCommands, osd } = createOptions(); - handleMpvCommandFromIpc(['__sub-delay-next-line'], options); - assert.deepEqual(calls, []); - assert.deepEqual(sentCommands, [['__sub-delay-next-line']]); - assert.deepEqual(osd, []); -}); +const specialCommandCases = [ + { name: 'youtube picker open', command: '__youtube-picker-open', expected: 'youtube-picker' }, + { name: 'jimaku open', command: '__jimaku-open', expected: 'jimaku' }, + // Legacy Animetosho command is kept as a TsukiHime alias. + { + name: 'legacy Animetosho open as a TsukiHime alias', + command: '__animetosho-open', + expected: 'tsukihime', + }, + { + name: 'playlist browser open', + command: '__playlist-browser-open', + expected: 'playlist-browser', + }, +]; -test('handleMpvCommandFromIpc dispatches special youtube picker open command', () => { - const { options, calls, sentCommands, osd } = createOptions(); - handleMpvCommandFromIpc(['__youtube-picker-open'], options); - assert.deepEqual(calls, ['youtube-picker']); - assert.deepEqual(sentCommands, []); - assert.deepEqual(osd, []); -}); - -test('handleMpvCommandFromIpc dispatches special jimaku open command', () => { - const { options, calls, sentCommands, osd } = createOptions(); - handleMpvCommandFromIpc(['__jimaku-open'], options); - assert.deepEqual(calls, ['jimaku']); - assert.deepEqual(sentCommands, []); - assert.deepEqual(osd, []); -}); - -test('handleMpvCommandFromIpc keeps the legacy Animetosho command as a TsukiHime alias', () => { - const { options, calls, sentCommands } = createOptions(); - - handleMpvCommandFromIpc(['__animetosho-open'], options); - - assert.deepEqual(calls, ['tsukihime']); - assert.deepEqual(sentCommands, []); -}); - -test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => { - const { options, calls, sentCommands, osd } = createOptions(); - handleMpvCommandFromIpc(['__playlist-browser-open'], options); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(calls, ['playlist-browser']); - assert.deepEqual(sentCommands, []); - assert.deepEqual(osd, []); -}); +for (const c of specialCommandCases) { + test(`handleMpvCommandFromIpc dispatches special ${c.name} command`, async () => { + const { options, calls, sentCommands, osd } = createOptions(); + handleMpvCommandFromIpc([c.command], options); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, [c.expected]); + assert.deepEqual(sentCommands, []); + assert.deepEqual(osd, []); + }); +} test('handleMpvCommandFromIpc surfaces playlist browser open rejections via mpv osd', async () => { const { options, osd } = createOptions({ diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 91f16769..db262953 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -3,11 +3,7 @@ import assert from 'node:assert/strict'; import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; -import type { - PlaylistBrowserSnapshot, - SessionActionDispatchRequest, - SubtitleSidebarSnapshot, -} from '../../types'; +import type { SessionActionDispatchRequest, SubtitleSidebarSnapshot } from '../../types'; interface FakeIpcRegistrar { on: Map void>; @@ -187,107 +183,6 @@ function createFakeImmersionTracker( }; } -test('createIpcDepsRuntime wires AniList handlers', async () => { - const calls: string[] = []; - const deps = createIpcDepsRuntime({ - getMainWindow: () => null, - getVisibleOverlayVisibility: () => false, - onOverlayModalClosed: () => {}, - onOverlayMouseInteractionChanged: (active) => { - calls.push(`overlay-interaction:${active}`); - }, - openYomitanSettings: () => {}, - quitApp: () => {}, - toggleVisibleOverlay: () => {}, - tokenizeCurrentSubtitle: async () => null, - getCurrentSubtitleRaw: () => '', - getCurrentSubtitleAss: () => '', - getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(), - getPlaybackPaused: () => true, - getSubtitlePosition: () => null, - getSubtitleStyle: () => null, - saveSubtitlePosition: () => {}, - getMecabTokenizer: () => null, - handleMpvCommand: () => {}, - getKeybindings: () => [], - getSessionBindings: () => [], - getConfiguredShortcuts: () => ({}), - dispatchSessionAction: async () => {}, - getStatsToggleKey: () => 'Backquote', - getMarkWatchedKey: () => 'KeyW', - getOverlayNotificationPosition: () => 'top-right', - getControllerConfig: () => createControllerConfigFixture(), - saveControllerConfig: () => {}, - saveControllerPreference: () => {}, - getSecondarySubMode: () => 'hover', - getMpvClient: () => null, - focusMainWindow: () => {}, - activatePlaybackWindowForOverlayInteraction: () => false, - runSubsyncManual: async () => ({ ok: true, message: 'ok' }), - getAnkiConnectStatus: () => false, - getRuntimeOptions: () => ({}), - setRuntimeOption: () => ({ ok: true }), - cycleRuntimeOption: () => ({ ok: true }), - reportOverlayContentBounds: () => {}, - getAnilistStatus: () => ({ tokenStatus: 'resolved' }), - clearAnilistToken: () => { - calls.push('clearAnilistToken'); - }, - openAnilistSetup: () => { - calls.push('openAnilistSetup'); - }, - getAnilistQueueStatus: () => ({ pending: 1, ready: 0, deadLetter: 0 }), - retryAnilistQueueNow: async () => { - calls.push('retryAnilistQueueNow'); - return { ok: true, message: 'done' }; - }, - appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }), - getPlaylistBrowserSnapshot: async () => ({ - directoryPath: '/tmp', - directoryAvailable: true, - directoryStatus: '/tmp', - directoryItems: [], - playlistItems: [], - playingIndex: 0, - currentFilePath: '/tmp/current.mkv', - }), - appendPlaylistBrowserFile: async () => ({ ok: true, message: 'append' }), - playPlaylistBrowserIndex: async () => ({ ok: true, message: 'play' }), - removePlaylistBrowserIndex: async () => ({ ok: true, message: 'remove' }), - movePlaylistBrowserIndex: async () => ({ ok: true, message: 'move' }), - onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), - }); - - assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' }); - deps.clearAnilistToken(); - deps.openAnilistSetup(); - deps.onOverlayMouseInteractionChanged?.(true, null); - assert.deepEqual(deps.getAnilistQueueStatus(), { - pending: 1, - ready: 0, - deadLetter: 0, - }); - assert.deepEqual(await deps.retryAnilistQueueNow(), { - ok: true, - message: 'done', - }); - assert.equal((await deps.getPlaylistBrowserSnapshot()).directoryAvailable, true); - assert.deepEqual(await deps.appendPlaylistBrowserFile('/tmp/new.mkv'), { - ok: true, - message: 'append', - }); - assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' }); - assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' }); - assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' }); - assert.deepEqual(calls, [ - 'clearAnilistToken', - 'openAnilistSetup', - 'overlay-interaction:true', - 'retryAnilistQueueNow', - ]); - assert.equal(deps.getPlaybackPaused(), true); -}); - test('createIpcDepsRuntime ignores overlay content reports from stale visible renderers', () => { const mainWindow = { id: 'main', isDestroyed: () => false } as never; const staleWindow = { id: 'stale', isDestroyed: () => false } as never; @@ -332,27 +227,6 @@ test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction activ assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']); }); -test('registerIpcHandlers passes sender window to overlay content bounds reports', () => { - const { registrar, handlers } = createFakeIpcRegistrar(); - const senderWindows: unknown[] = []; - - registerIpcHandlers( - createRegisterIpcDeps({ - reportOverlayContentBounds: ((_payload: unknown, senderWindow: unknown) => { - senderWindows.push(senderWindow); - }) as IpcServiceDeps['reportOverlayContentBounds'], - }), - registrar, - ); - - const handler = handlers.on.get(IPC_CHANNELS.command.reportOverlayContentBounds); - assert.equal(typeof handler, 'function'); - - handler?.({}, { layer: 'visible' }); - - assert.deepEqual(senderWindows, [null]); -}); - test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); const calls: string[] = []; @@ -433,71 +307,6 @@ test('registerIpcHandlers skips AniList update when manual mark watched has no a assert.deepEqual(calls, ['mark']); }); -test('registerIpcHandlers exposes playlist browser snapshot and mutations', async () => { - const { registrar, handlers } = createFakeIpcRegistrar(); - const calls: Array<[string, unknown[]]> = []; - registerIpcHandlers( - createRegisterIpcDeps({ - getPlaylistBrowserSnapshot: async () => ({ - directoryPath: '/tmp/videos', - directoryAvailable: true, - directoryStatus: '/tmp/videos', - directoryItems: [], - playlistItems: [], - playingIndex: 1, - currentFilePath: '/tmp/videos/ep2.mkv', - }), - appendPlaylistBrowserFile: async (filePath) => { - calls.push(['append', [filePath]]); - return { ok: true, message: 'append-ok' }; - }, - playPlaylistBrowserIndex: async (index) => { - calls.push(['play', [index]]); - return { ok: true, message: 'play-ok' }; - }, - removePlaylistBrowserIndex: async (index) => { - calls.push(['remove', [index]]); - return { ok: true, message: 'remove-ok' }; - }, - movePlaylistBrowserIndex: async (index, direction) => { - calls.push(['move', [index, direction]]); - return { ok: true, message: 'move-ok' }; - }, - }), - registrar, - ); - - const snapshot = (await handlers.handle.get(IPC_CHANNELS.request.getPlaylistBrowserSnapshot)?.( - {}, - )) as PlaylistBrowserSnapshot | undefined; - const append = await handlers.handle.get(IPC_CHANNELS.request.appendPlaylistBrowserFile)?.( - {}, - '/tmp/videos/ep3.mkv', - ); - const play = await handlers.handle.get(IPC_CHANNELS.request.playPlaylistBrowserIndex)?.({}, 2); - const remove = await handlers.handle.get(IPC_CHANNELS.request.removePlaylistBrowserIndex)?.( - {}, - 2, - ); - const move = await handlers.handle.get(IPC_CHANNELS.request.movePlaylistBrowserIndex)?.( - {}, - 2, - -1, - ); - - assert.equal(snapshot?.playingIndex, 1); - assert.deepEqual(append, { ok: true, message: 'append-ok' }); - assert.deepEqual(play, { ok: true, message: 'play-ok' }); - assert.deepEqual(remove, { ok: true, message: 'remove-ok' }); - assert.deepEqual(move, { ok: true, message: 'move-ok' }); - assert.deepEqual(calls, [ - ['append', ['/tmp/videos/ep3.mkv']], - ['play', [2]], - ['remove', [2]], - ['move', [2, -1]], - ]); -}); - test('registerIpcHandlers rejects malformed runtime-option payloads', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); const calls: Array<{ id: string; value: unknown }> = []; @@ -610,45 +419,6 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = ); }); -test('registerIpcHandlers exposes subtitle sidebar snapshot request', async () => { - const { registrar, handlers } = createFakeIpcRegistrar(); - const snapshot = createSubtitleSidebarSnapshotFixture(); - snapshot.cues = [{ startTime: 1, endTime: 2, text: 'line-1' }]; - snapshot.config.enabled = true; - - registerIpcHandlers( - createRegisterIpcDeps({ - getSubtitleSidebarSnapshot: async () => snapshot, - }), - registrar, - ); - - const handler = handlers.handle.get(IPC_CHANNELS.request.getSubtitleSidebarSnapshot); - assert.ok(handler); - assert.deepEqual(await handler!({}), snapshot); -}); - -test('registerIpcHandlers exposes playback window activation request', async () => { - const { registrar, handlers } = createFakeIpcRegistrar(); - const calls: string[] = []; - registerIpcHandlers( - createRegisterIpcDeps({ - activatePlaybackWindowForOverlayInteraction: async () => { - calls.push('activate'); - return true; - }, - }), - registrar, - ); - - const handler = handlers.handle.get( - IPC_CHANNELS.request.activatePlaybackWindowForOverlayInteraction, - ); - assert.ok(handler); - assert.equal(await handler!({}), true); - assert.deepEqual(calls, ['activate']); -}); - test('registerIpcHandlers accepts the keep-without-media timing decision', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); const requests: unknown[] = []; @@ -757,28 +527,6 @@ test('registerIpcHandlers validates and forwards timing review text and screensh assert.equal(requests.length, 1); }); -test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => { - const { registrar, handlers } = createFakeIpcRegistrar(); - const calls: string[] = []; - registerIpcHandlers( - createRegisterIpcDeps({ - immersionTracker: createFakeImmersionTracker({ - recordYomitanLookup: () => { - calls.push('lookup'); - }, - }), - }), - registrar, - ); - - const handler = handlers.on.get(IPC_CHANNELS.command.recordYomitanLookup); - assert.equal(typeof handler, 'function'); - - handler?.({}, null); - - assert.deepEqual(calls, ['lookup']); -}); - test('registerIpcHandlers forwards valid subtitle sidebar mining context', () => { const { registrar, handlers } = createFakeIpcRegistrar(); const contexts: unknown[] = []; @@ -1369,3 +1117,39 @@ test('mpv discovery has its own request and does not change session bindings', a assert.deepEqual(await discovery({}), snapshot); assert.deepEqual(await session({}), []); }); + +test('registerIpcHandlers validates playlist browser mutation payloads before forwarding', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const forwarded: unknown[][] = []; + const ok = { ok: true, message: 'ok' }; + registerIpcHandlers( + createRegisterIpcDeps({ + appendPlaylistBrowserFile: async (...args) => (forwarded.push(args), ok), + playPlaylistBrowserIndex: async (...args) => (forwarded.push(args), ok), + removePlaylistBrowserIndex: async (...args) => (forwarded.push(args), ok), + movePlaylistBrowserIndex: async (...args) => (forwarded.push(args), ok), + }), + registrar, + ); + const { request } = IPC_CHANNELS; + const cases: Array<{ channel: string; args: unknown[]; valid: boolean }> = [ + { channel: request.appendPlaylistBrowserFile, args: ['/media/ep01.mkv'], valid: true }, + { channel: request.appendPlaylistBrowserFile, args: [' '], valid: false }, + { channel: request.appendPlaylistBrowserFile, args: [42], valid: false }, + { channel: request.playPlaylistBrowserIndex, args: [0], valid: true }, + { channel: request.playPlaylistBrowserIndex, args: [-1], valid: false }, + { channel: request.removePlaylistBrowserIndex, args: [1.5], valid: false }, + { channel: request.removePlaylistBrowserIndex, args: ['2'], valid: false }, + { channel: request.movePlaylistBrowserIndex, args: [2, -1], valid: true }, + { channel: request.movePlaylistBrowserIndex, args: [2, 2], valid: false }, + { channel: request.movePlaylistBrowserIndex, args: [Number.NaN, 1], valid: false }, + ]; + + for (const { channel, args, valid } of cases) { + forwarded.length = 0; + const result = (await handlers.handle.get(channel)?.({}, ...args)) as { ok: boolean }; + const label = `${channel}(${args.map(String).join(', ')})`; + assert.equal(result.ok, valid, label); + assert.deepEqual(forwarded, valid ? [args] : [], label); + } +}); diff --git a/src/core/services/jlpt-token-filter.test.ts b/src/core/services/jlpt-token-filter.test.ts deleted file mode 100644 index 1dca794d..00000000 --- a/src/core/services/jlpt-token-filter.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - getIgnoredPos1Entries, - JLPT_EXCLUDED_TERMS, - JLPT_IGNORED_MECAB_POS1, - JLPT_IGNORED_MECAB_POS1_ENTRIES, - JLPT_IGNORED_MECAB_POS1_LIST, - shouldIgnoreJlptByTerm, - shouldIgnoreJlptForMecabPos1, -} from './jlpt-token-filter'; - -test('shouldIgnoreJlptByTerm matches the excluded JLPT lexical terms', () => { - assert.equal(shouldIgnoreJlptByTerm('この'), true); - assert.equal(shouldIgnoreJlptByTerm('そこ'), true); - assert.equal(shouldIgnoreJlptByTerm('猫'), false); - assert.deepEqual(Array.from(JLPT_EXCLUDED_TERMS), [ - 'この', - 'その', - 'あの', - 'どの', - 'これ', - 'それ', - 'あれ', - 'どれ', - 'ここ', - 'そこ', - 'あそこ', - 'どこ', - 'こと', - 'ああ', - 'ええ', - 'うう', - 'おお', - 'はは', - 'へえ', - 'ふう', - 'ほう', - ]); -}); - -test('shouldIgnoreJlptForMecabPos1 matches the exported ignored POS1 list', () => { - assert.equal(shouldIgnoreJlptForMecabPos1('助詞'), true); - assert.equal(shouldIgnoreJlptForMecabPos1('名詞'), false); - assert.deepEqual(JLPT_IGNORED_MECAB_POS1, JLPT_IGNORED_MECAB_POS1_LIST); - assert.deepEqual( - JLPT_IGNORED_MECAB_POS1_ENTRIES.map((entry) => entry.pos1), - JLPT_IGNORED_MECAB_POS1_LIST, - ); - assert.deepEqual(getIgnoredPos1Entries(), JLPT_IGNORED_MECAB_POS1_ENTRIES); -}); diff --git a/src/core/services/mpv-control.test.ts b/src/core/services/mpv-control.test.ts index d13fd003..3a9ea429 100644 --- a/src/core/services/mpv-control.test.ts +++ b/src/core/services/mpv-control.test.ts @@ -1,12 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { - playNextSubtitleRuntime, - replayCurrentSubtitleRuntime, - sendMpvCommandRuntime, - setMpvSubVisibilityRuntime, - showMpvOsdRuntime, -} from './mpv'; +import { showMpvOsdRuntime } from './mpv'; test('showMpvOsdRuntime sends show-text when connected', () => { const commands: (string | number)[][] = []; @@ -55,29 +49,3 @@ test('showMpvOsdRuntime logs fallback when disconnected', () => { assert.equal(shown, false); assert.deepEqual(logs, ['OSD (MPV not connected): hello']); }); - -test('mpv runtime command wrappers call expected client methods', () => { - const calls: string[] = []; - const client = { - connected: true, - send: ({ command }: { command: (string | number)[] }) => { - calls.push(`send:${command.join(',')}`); - }, - replayCurrentSubtitle: () => { - calls.push('replay'); - }, - playNextSubtitle: () => { - calls.push('next'); - }, - setSubVisibility: (visible: boolean) => { - calls.push(`subVisible:${visible}`); - }, - }; - - replayCurrentSubtitleRuntime(client); - playNextSubtitleRuntime(client); - sendMpvCommandRuntime(client, ['script-message', 'x']); - setMpvSubVisibilityRuntime(client, false); - - assert.deepEqual(calls, ['replay', 'next', 'send:script-message,x', 'subVisible:false']); -}); diff --git a/src/core/services/mpv-transport.test.ts b/src/core/services/mpv-transport.test.ts index 1b75c1fb..99241e1e 100644 --- a/src/core/services/mpv-transport.test.ts +++ b/src/core/services/mpv-transport.test.ts @@ -48,19 +48,6 @@ class HangingSocket extends FakeSocket { const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); -test('getMpvReconnectDelay follows existing reconnect ramp', () => { - assert.equal(getMpvReconnectDelay(0, true), 1000); - assert.equal(getMpvReconnectDelay(1, true), 1000); - assert.equal(getMpvReconnectDelay(2, true), 2000); - assert.equal(getMpvReconnectDelay(4, true), 5000); - assert.equal(getMpvReconnectDelay(7, true), 10000); - - assert.equal(getMpvReconnectDelay(0, false), 200); - assert.equal(getMpvReconnectDelay(2, false), 500); - assert.equal(getMpvReconnectDelay(4, false), 1000); - assert.equal(getMpvReconnectDelay(6, false), 2000); -}); - test('scheduleMpvReconnect clears existing timer and increments attempt', () => { const existing = {} as ReturnType; const cleared: Array | null> = []; diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index 5b14f061..0072dbaa 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -262,50 +262,6 @@ test('MpvIpcClient requestProperty throws on mpv error response', async () => { ); }); -test('MpvIpcClient connect does not log connect-request at info level', () => { - const originalLevel = process.env.SUBMINER_LOG_LEVEL; - const originalInfo = console.info; - const infoLines: string[] = []; - process.env.SUBMINER_LOG_LEVEL = 'info'; - console.info = (message?: unknown) => { - infoLines.push(String(message ?? '')); - }; - - try { - const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); - (client as any).transport.connect = () => {}; - client.connect(); - } finally { - process.env.SUBMINER_LOG_LEVEL = originalLevel; - console.info = originalInfo; - } - - const requestLogs = infoLines.filter((line) => line.includes('MPV IPC connect requested.')); - assert.equal(requestLogs.length, 0); -}); - -test('MpvIpcClient connect logs connect-request at debug level', () => { - const originalLevel = process.env.SUBMINER_LOG_LEVEL; - const originalDebug = console.debug; - const debugLines: string[] = []; - process.env.SUBMINER_LOG_LEVEL = 'debug'; - console.debug = (message?: unknown) => { - debugLines.push(String(message ?? '')); - }; - - try { - const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); - (client as any).transport.connect = () => {}; - client.connect(); - } finally { - process.env.SUBMINER_LOG_LEVEL = originalLevel; - console.debug = originalDebug; - } - - const requestLogs = debugLines.filter((line) => line.includes('MPV IPC connect requested.')); - assert.equal(requestLogs.length, 1); -}); - test('MpvIpcClient reconnect clears stale connected state and starts a fresh transport connect', () => { const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); const calls: string[] = []; @@ -356,80 +312,6 @@ test('MpvIpcClient failPendingRequests resolves outstanding requests as disconne 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); -}); - -test('MpvIpcClient scheduleReconnect clears existing reconnect timer', () => { - const timers: Array | null> = []; - const cleared: Array | null> = []; - const existingTimer = {} as ReturnType; - const client = new MpvIpcClient( - '/tmp/mpv.sock', - makeDeps({ - getReconnectTimer: () => existingTimer, - setReconnectTimer: (timer) => { - timers.push(timer); - }, - }), - ); - - let connectCalled = false; - (client as any).connect = () => { - connectCalled = true; - }; - - const originalSetTimeout = globalThis.setTimeout; - const originalClearTimeout = globalThis.clearTimeout; - (globalThis as any).setTimeout = (handler: () => void, _delay: number) => { - handler(); - return 1 as unknown as ReturnType; - }; - (globalThis as any).clearTimeout = (timer: ReturnType | null) => { - cleared.push(timer); - }; - - try { - (client as any).scheduleReconnect(); - } finally { - (globalThis as any).setTimeout = originalSetTimeout; - (globalThis as any).clearTimeout = originalClearTimeout; - } - - assert.equal(cleared.length, 1); - assert.equal(cleared[0], existingTimer); - assert.equal(timers.length, 1); - assert.equal(connectCalled, true); -}); - test('MpvIpcClient onClose resolves outstanding requests and schedules reconnect', () => { const timers: Array | null> = []; const client = new MpvIpcClient( diff --git a/src/core/services/numeric-shortcut-session.test.ts b/src/core/services/numeric-shortcut-session.test.ts index 4f984928..ccd61ba0 100644 --- a/src/core/services/numeric-shortcut-session.test.ts +++ b/src/core/services/numeric-shortcut-session.test.ts @@ -1,51 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createNumericShortcutRuntime, createNumericShortcutSession } from './numeric-shortcut'; - -test('createNumericShortcutRuntime creates sessions wired to globalShortcut', () => { - const registered: string[] = []; - const unregistered: string[] = []; - const osd: string[] = []; - const handlers = new Map void>(); - - const runtime = createNumericShortcutRuntime({ - globalShortcut: { - register: (accelerator, callback) => { - registered.push(accelerator); - handlers.set(accelerator, callback); - return true; - }, - unregister: (accelerator) => { - unregistered.push(accelerator); - handlers.delete(accelerator); - }, - }, - showMpvOsd: (text) => { - osd.push(text); - }, - setTimer: () => setTimeout(() => {}, 1000), - clearTimer: (timer) => clearTimeout(timer), - }); - - const session = runtime.createSession(); - session.start({ - timeoutMs: 5000, - onDigit: () => {}, - messages: { - prompt: 'Select count', - timeout: 'Timed out', - }, - }); - - assert.equal(session.isActive(), true); - assert.ok(registered.includes('1')); - assert.ok(registered.includes('Escape')); - assert.equal(osd[0], 'Select count'); - - handlers.get('Escape')?.(); - assert.equal(session.isActive(), false); - assert.ok(unregistered.includes('Escape')); -}); +import { createNumericShortcutSession } from './numeric-shortcut'; test('numeric shortcut session handles digit selection and unregisters shortcuts', () => { const handlers = new Map void>(); diff --git a/src/core/services/overlay-bridge.test.ts b/src/core/services/overlay-bridge.test.ts index 5b1cb6bc..b20af77a 100644 --- a/src/core/services/overlay-bridge.test.ts +++ b/src/core/services/overlay-bridge.test.ts @@ -1,7 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { KikuFieldGroupingChoice } from '../../types'; -import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge'; +import { sendToVisibleOverlayRuntime } from './overlay-bridge'; test('sendToVisibleOverlayRuntime restores visibility flag when opening hidden overlay modal', () => { const sent: unknown[][] = []; @@ -72,39 +71,3 @@ test('sendToVisibleOverlayRuntime waits for overlay page before sending open com assert.deepEqual(sent, [['runtime-options:open']]); }); - -test('createFieldGroupingCallbackRuntime cancels when overlay request cannot be sent', async () => { - let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null; - const callback = createFieldGroupingCallbackRuntime<'runtime-options' | 'subsync'>({ - getVisibleOverlayVisible: () => false, - setVisibleOverlayVisible: () => {}, - getResolver: () => resolver, - setResolver: (next) => { - resolver = next; - }, - sendToVisibleOverlay: () => false, - }); - - const result = await callback({ - original: { - noteId: 1, - expression: 'a', - sentencePreview: 'a', - hasAudio: false, - hasImage: false, - isOriginal: true, - }, - duplicate: { - noteId: 2, - expression: 'b', - sentencePreview: 'b', - hasAudio: false, - hasImage: false, - isOriginal: false, - }, - }); - - assert.equal(result.cancelled, true); - assert.equal(result.keepNoteId, 0); - assert.equal(result.deleteNoteId, 0); -}); diff --git a/src/core/services/overlay-manager.test.ts b/src/core/services/overlay-manager.test.ts index 23085386..00483bc0 100644 --- a/src/core/services/overlay-manager.test.ts +++ b/src/core/services/overlay-manager.test.ts @@ -1,37 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - broadcastRuntimeOptionsChangedRuntime, createOverlayManager, setOverlayDebugVisualizationEnabledRuntime, } from './overlay-manager'; -test('overlay manager initializes with empty windows and hidden overlays', () => { - const manager = createOverlayManager(); - assert.equal(manager.getMainWindow(), null); - assert.equal(manager.getModalWindow(), null); - assert.equal(manager.getVisibleOverlayVisible(), false); - assert.deepEqual(manager.getOverlayWindows(), []); -}); - -test('overlay manager stores window references and returns stable window order', () => { - const manager = createOverlayManager(); - const visibleWindow = { - isDestroyed: () => false, - } as unknown as Electron.BrowserWindow; - const modalWindow = { - isDestroyed: () => false, - } as unknown as Electron.BrowserWindow; - - manager.setMainWindow(visibleWindow); - manager.setModalWindow(modalWindow); - - assert.equal(manager.getMainWindow(), visibleWindow); - assert.equal(manager.getModalWindow(), modalWindow); - assert.equal(manager.getOverlayWindow(), visibleWindow); - assert.deepEqual(manager.getOverlayWindows(), [visibleWindow]); -}); - test('overlay manager excludes destroyed windows', () => { const manager = createOverlayManager(); manager.setMainWindow({ @@ -44,13 +17,6 @@ test('overlay manager excludes destroyed windows', () => { assert.equal(manager.getOverlayWindows().length, 0); }); -test('overlay manager stores visibility state', () => { - const manager = createOverlayManager(); - - manager.setVisibleOverlayVisible(true); - assert.equal(manager.getVisibleOverlayVisible(), true); -}); - test('overlay manager broadcasts to non-destroyed windows', () => { const manager = createOverlayManager(); const calls: unknown[][] = []; @@ -136,19 +102,13 @@ test('overlay manager can suppress z-order promotion during bounds updates', () assert.deepEqual(calls, ['promote:false']); }); -test('runtime-option broadcast still uses expected channel', () => { - const broadcasts: unknown[][] = []; - broadcastRuntimeOptionsChangedRuntime( - () => [], - (channel, ...args) => { - broadcasts.push([channel, ...args]); - }, - ); +test('setOverlayDebugVisualizationEnabledRuntime only updates state when the value changes', () => { let state = false; - const changed = setOverlayDebugVisualizationEnabledRuntime(state, true, (enabled) => { + const setState = (enabled: boolean) => { state = enabled; - }); - assert.equal(changed, true); + }; + + assert.equal(setOverlayDebugVisualizationEnabledRuntime(state, false, setState), false); + assert.equal(setOverlayDebugVisualizationEnabledRuntime(state, true, setState), true); assert.equal(state, true); - assert.deepEqual(broadcasts, [['runtime-options:changed', []]]); }); diff --git a/src/core/services/overlay-runtime-init.test.ts b/src/core/services/overlay-runtime-init.test.ts index 56d5d6c0..276e50bf 100644 --- a/src/core/services/overlay-runtime-init.test.ts +++ b/src/core/services/overlay-runtime-init.test.ts @@ -1,103 +1,53 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import type { WindowGeometry } from '../../types'; import { initializeOverlayAnkiIntegration, initializeOverlayRuntime, startOverlayWindowTracker, } from './overlay-runtime-init'; -test('startOverlayWindowTracker starts tracker for the current mpv socket', () => { - const calls: string[] = []; - const tracker = { - onGeometryChange: null as ((...args: unknown[]) => void) | null, - onWindowFound: null as ((...args: unknown[]) => void) | null, +type AnkiOptions = Parameters[0]; +type RuntimeOptions = Parameters[0]; +type CreateAnkiIntegrationArgs = Parameters>[0]; + +function createFakeTracker(targetMinimized = false) { + return { + onGeometryChange: null as ((geometry: WindowGeometry) => void) | null, + onWindowFound: null as ((geometry: WindowGeometry) => void) | null, onWindowLost: null as (() => void) | null, onWindowFocusChange: null as ((focused: boolean) => void) | null, - isTargetWindowMinimized: () => false, - start: () => { - calls.push('start'); + isTargetWindowMinimized: () => targetMinimized, + started: false, + start() { + this.started = true; }, }; +} - const result = startOverlayWindowTracker({ - backendOverride: 'windows', - getMpvSocketPath: () => '\\\\.\\pipe\\subminer-socket', - createWindowTracker: (override, socketPath) => { - calls.push(`create:${override}:${socketPath}`); - return tracker as never; - }, - setWindowTracker: (nextTracker) => { - calls.push(nextTracker === tracker ? 'set-tracker' : 'clear-tracker'); - }, - updateVisibleOverlayBounds: () => { - calls.push('bounds'); - }, - isVisibleOverlayVisible: () => true, - updateVisibleOverlayVisibility: () => { - calls.push('visibility'); - }, - refreshCurrentSubtitle: () => { - calls.push('refresh-subtitle'); - }, - getOverlayWindows: () => [], - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - }); - - assert.equal(result, tracker); - tracker.onWindowFound?.({ x: 10, y: 20, width: 300, height: 200 }); - tracker.onWindowFocusChange?.(true); - - assert.deepEqual(calls, [ - 'create:windows:\\\\.\\pipe\\subminer-socket', - 'set-tracker', - 'start', - 'bounds', - 'visibility', - 'refresh-subtitle', - 'visibility', - 'sync-shortcuts', - ]); -}); - -test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => { - let createdIntegrations = 0; - let startedIntegrations = 0; - let setIntegrationCalls = 0; - - initializeOverlayRuntime({ - backendOverride: null, - createMainWindow: () => {}, - registerGlobalShortcuts: () => {}, - updateVisibleOverlayBounds: () => {}, - isVisibleOverlayVisible: () => false, - updateVisibleOverlayVisibility: () => {}, - getOverlayWindows: () => [], - syncOverlayShortcuts: () => {}, - setWindowTracker: () => {}, - getMpvSocketPath: () => '/tmp/mpv.sock', - createWindowTracker: () => null, - getResolvedConfig: () => ({ - ankiConnect: { enabled: false } as never, - }), +function createAnkiHarness(overrides: Partial = {}) { + const state = { + created: [] as CreateAnkiIntegrationArgs[], + started: 0, + stored: [] as unknown[], + }; + const options: AnkiOptions = { + getResolvedConfig: () => ({ ankiConnect: { enabled: true } as never }), getSubtitleTimingTracker: () => ({}), - getMpvClient: () => ({ - send: () => {}, - }), + getMpvClient: () => ({ send: () => {} }), getRuntimeOptionsManager: () => ({ getEffectiveAnkiConnectConfig: (config) => config as never, }), - createAnkiIntegration: () => { - createdIntegrations += 1; + createAnkiIntegration: (args) => { + state.created.push(args); return { start: () => { - startedIntegrations += 1; + state.started += 1; }, }; }, - setAnkiIntegration: () => { - setIntegrationCalls += 1; + setAnkiIntegration: (integration) => { + state.stored.push(integration); }, showDesktopNotification: () => {}, createFieldGroupingCallback: () => async () => ({ @@ -107,264 +57,149 @@ test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled i cancelled: false, }), getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + ...overrides, + }; + return { options, state }; +} + +/** + * Runs initializeOverlayRuntime with Anki disabled and returns the fake tracker plus a + * snapshot of what the tracker callbacks did since initialization finished. + */ +function createTrackerHarness(config: { overlayVisible: boolean; targetMinimized?: boolean }) { + const tracker = createFakeTracker(config.targetMinimized); + const fresh = () => ({ + bounds: [] as WindowGeometry[], + visibilityRefreshes: 0, + subtitleRefreshes: 0, + shortcutSyncs: 0, + hiddenWindows: [] as string[], }); + let state = fresh(); + const overlayWindows = ['visible', 'modal'].map((name) => ({ + hide: () => { + state.hiddenWindows.push(name); + }, + })); - assert.equal(createdIntegrations, 0); - assert.equal(startedIntegrations, 0); - assert.equal(setIntegrationCalls, 0); -}); - -test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled is true', () => { - let createdIntegrations = 0; - let startedIntegrations = 0; - let setIntegrationCalls = 0; - - initializeOverlayRuntime({ + const options: RuntimeOptions = { + ...createAnkiHarness({ + getResolvedConfig: () => ({ ankiConnect: { enabled: false } as never }), + }).options, backendOverride: null, createMainWindow: () => {}, registerGlobalShortcuts: () => {}, - updateVisibleOverlayBounds: () => {}, - isVisibleOverlayVisible: () => false, - updateVisibleOverlayVisibility: () => {}, - getOverlayWindows: () => [], - syncOverlayShortcuts: () => {}, + updateVisibleOverlayBounds: (geometry) => { + state.bounds.push(geometry); + }, + isVisibleOverlayVisible: () => config.overlayVisible, + updateVisibleOverlayVisibility: () => { + state.visibilityRefreshes += 1; + }, + refreshCurrentSubtitle: () => { + state.subtitleRefreshes += 1; + }, + getOverlayWindows: () => overlayWindows as never, + syncOverlayShortcuts: () => { + state.shortcutSyncs += 1; + }, setWindowTracker: () => {}, getMpvSocketPath: () => '/tmp/mpv.sock', - createWindowTracker: () => null, - getResolvedConfig: () => ({ - ankiConnect: { enabled: true } as never, - }), - getSubtitleTimingTracker: () => ({}), - getMpvClient: () => ({ - send: () => {}, - }), - getRuntimeOptionsManager: () => ({ - getEffectiveAnkiConnectConfig: (config) => config as never, - }), - createAnkiIntegration: (args) => { - createdIntegrations += 1; - assert.equal(args.config.enabled, true); - return { - start: () => { - startedIntegrations += 1; - }, - }; + createWindowTracker: () => tracker as never, + }; + + initializeOverlayRuntime(options); + state = fresh(); + + return { tracker, snapshot: () => state }; +} + +test('startOverlayWindowTracker starts tracker for the current mpv socket', () => { + const tracker = createFakeTracker(); + const created: Array<[string | null | undefined, string | null | undefined]> = []; + const stored: unknown[] = []; + const bounds: WindowGeometry[] = []; + let visibilityRefreshes = 0; + let handlersInstalledAtStart = false; + tracker.start = () => { + handlersInstalledAtStart = tracker.onWindowFound !== null && tracker.onWindowLost !== null; + tracker.started = true; + }; + + const result = startOverlayWindowTracker({ + backendOverride: 'windows', + getMpvSocketPath: () => '\\\\.\\pipe\\subminer-socket', + createWindowTracker: (override, socketPath) => { + created.push([override, socketPath]); + return tracker as never; }, - setAnkiIntegration: () => { - setIntegrationCalls += 1; + setWindowTracker: (nextTracker) => { + stored.push(nextTracker); }, - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 3, - deleteNoteId: 4, - deleteDuplicate: false, - cancelled: false, - }), - getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', - }); - - assert.equal(createdIntegrations, 1); - assert.equal(startedIntegrations, 1); - assert.equal(setIntegrationCalls, 1); -}); - -test('initializeOverlayAnkiIntegration can initialize Anki transport after overlay runtime already exists', () => { - let createdIntegrations = 0; - let startedIntegrations = 0; - let setIntegrationCalls = 0; - - initializeOverlayAnkiIntegration({ - getResolvedConfig: () => ({ - ankiConnect: { enabled: true } as never, - }), - getSubtitleTimingTracker: () => ({}), - getMpvClient: () => ({ - send: () => {}, - }), - getRuntimeOptionsManager: () => ({ - getEffectiveAnkiConnectConfig: (config) => config as never, - }), - createAnkiIntegration: (args) => { - createdIntegrations += 1; - assert.equal(args.config.enabled, true); - return { - start: () => { - startedIntegrations += 1; - }, - }; + updateVisibleOverlayBounds: (geometry) => { + bounds.push(geometry); }, - setAnkiIntegration: () => { - setIntegrationCalls += 1; + isVisibleOverlayVisible: () => true, + updateVisibleOverlayVisibility: () => { + visibilityRefreshes += 1; }, - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 11, - deleteNoteId: 12, - deleteDuplicate: false, - cancelled: false, - }), - getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', - }); - - assert.equal(createdIntegrations, 1); - assert.equal(startedIntegrations, 1); - assert.equal(setIntegrationCalls, 1); -}); - -test('initializeOverlayAnkiIntegration returns false when integration already exists', () => { - let createdIntegrations = 0; - let startedIntegrations = 0; - let setIntegrationCalls = 0; - - const result = initializeOverlayAnkiIntegration({ - getResolvedConfig: () => ({ - ankiConnect: { enabled: true } as never, - }), - getSubtitleTimingTracker: () => ({}), - getMpvClient: () => ({ - send: () => {}, - }), - getRuntimeOptionsManager: () => ({ - getEffectiveAnkiConnectConfig: (config) => config as never, - }), - getAnkiIntegration: () => ({}), - createAnkiIntegration: () => { - createdIntegrations += 1; - return { - start: () => { - startedIntegrations += 1; - }, - }; - }, - setAnkiIntegration: () => { - setIntegrationCalls += 1; - }, - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 11, - deleteNoteId: 12, - deleteDuplicate: false, - cancelled: false, - }), - getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', - }); - - assert.equal(result, false); - assert.equal(createdIntegrations, 0); - assert.equal(startedIntegrations, 0); - assert.equal(setIntegrationCalls, 0); -}); - -test('initializeOverlayAnkiIntegration returns false when ankiConnect is disabled', () => { - let createdIntegrations = 0; - let startedIntegrations = 0; - let setIntegrationCalls = 0; - - const result = initializeOverlayAnkiIntegration({ - getResolvedConfig: () => ({ - ankiConnect: { enabled: false } as never, - }), - getSubtitleTimingTracker: () => ({}), - getMpvClient: () => ({ - send: () => {}, - }), - getRuntimeOptionsManager: () => ({ - getEffectiveAnkiConnectConfig: (config) => config as never, - }), - createAnkiIntegration: () => { - createdIntegrations += 1; - return { - start: () => { - startedIntegrations += 1; - }, - }; - }, - setAnkiIntegration: () => { - setIntegrationCalls += 1; - }, - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 11, - deleteNoteId: 12, - deleteDuplicate: false, - cancelled: false, - }), - getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', - }); - - assert.equal(result, false); - assert.equal(createdIntegrations, 0); - assert.equal(startedIntegrations, 0); - assert.equal(setIntegrationCalls, 0); -}); - -test('initializeOverlayRuntime can skip starting Anki integration transport', () => { - let createdIntegrations = 0; - let startedIntegrations = 0; - let setIntegrationCalls = 0; - - initializeOverlayRuntime({ - backendOverride: null, - createMainWindow: () => {}, - registerGlobalShortcuts: () => {}, - updateVisibleOverlayBounds: () => {}, - isVisibleOverlayVisible: () => false, - updateVisibleOverlayVisibility: () => {}, getOverlayWindows: () => [], syncOverlayShortcuts: () => {}, - setWindowTracker: () => {}, - getMpvSocketPath: () => '/tmp/mpv.sock', - createWindowTracker: () => null, - getResolvedConfig: () => ({ - ankiConnect: { enabled: true } as never, - }), - getSubtitleTimingTracker: () => ({}), - getMpvClient: () => ({ - send: () => {}, - }), - getRuntimeOptionsManager: () => ({ - getEffectiveAnkiConnectConfig: (config) => config as never, - }), - createAnkiIntegration: () => { - createdIntegrations += 1; - return { - start: () => { - startedIntegrations += 1; - }, - }; - }, - setAnkiIntegration: () => { - setIntegrationCalls += 1; - }, - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 7, - deleteNoteId: 8, - deleteDuplicate: false, - cancelled: false, - }), - getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', - shouldStartAnkiIntegration: () => false, }); - assert.equal(createdIntegrations, 1); - assert.equal(startedIntegrations, 0); - assert.equal(setIntegrationCalls, 1); + const geometry = { x: 10, y: 20, width: 300, height: 200 }; + tracker.onWindowFound?.(geometry); + + assert.equal(result, tracker); + assert.deepEqual(created, [['windows', '\\\\.\\pipe\\subminer-socket']]); + assert.deepEqual(stored, [tracker]); + assert.equal(tracker.started, true); + assert.equal(handlersInstalledAtStart, true); + assert.deepEqual(bounds, [geometry]); + assert.equal(visibilityRefreshes, 1); }); -test('initializeOverlayRuntime merges shared ai config with Anki overrides', () => { - initializeOverlayRuntime({ - backendOverride: null, - createMainWindow: () => {}, - registerGlobalShortcuts: () => {}, - updateVisibleOverlayBounds: () => {}, - isVisibleOverlayVisible: () => false, - updateVisibleOverlayVisibility: () => {}, - getOverlayWindows: () => [], - syncOverlayShortcuts: () => {}, - setWindowTracker: () => {}, - getMpvSocketPath: () => '/tmp/mpv.sock', - createWindowTracker: () => null, +const ankiNotCreatedCases: Array<{ name: string; overrides: Partial }> = [ + { + name: 'ankiConnect is disabled', + overrides: { getResolvedConfig: () => ({ ankiConnect: { enabled: false } as never }) }, + }, + { name: 'an integration already exists', overrides: { getAnkiIntegration: () => ({}) } }, +]; + +for (const c of ankiNotCreatedCases) { + test(`initializeOverlayAnkiIntegration returns false when ${c.name}`, () => { + const { options, state } = createAnkiHarness(c.overrides); + + assert.equal(initializeOverlayAnkiIntegration(options), false); + assert.equal(state.created.length, 0); + assert.equal(state.started, 0); + assert.equal(state.stored.length, 0); + }); +} + +test('initializeOverlayAnkiIntegration creates, starts, and stores the integration when enabled', () => { + const { options, state } = createAnkiHarness(); + + assert.equal(initializeOverlayAnkiIntegration(options), true); + assert.equal(state.created.length, 1); + assert.equal(state.created[0]!.config.enabled, true); + assert.equal(state.started, 1); + assert.equal(state.stored.length, 1); +}); + +test('initializeOverlayAnkiIntegration can skip starting the Anki integration transport', () => { + const { options, state } = createAnkiHarness({ shouldStartAnkiIntegration: () => false }); + + initializeOverlayAnkiIntegration(options); + + assert.equal(state.created.length, 1); + assert.equal(state.started, 0); + assert.equal(state.stored.length, 1); +}); + +test('initializeOverlayAnkiIntegration merges shared ai config with Anki overrides', () => { + const { options, state } = createAnkiHarness({ getResolvedConfig: () => ({ ankiConnect: { enabled: true, @@ -383,45 +218,23 @@ test('initializeOverlayRuntime merges shared ai config with Anki overrides', () requestTimeoutMs: 15000, }, }), - getSubtitleTimingTracker: () => ({}), - getMpvClient: () => ({ - send: () => {}, - }), - getRuntimeOptionsManager: () => ({ - getEffectiveAnkiConnectConfig: (config) => config as never, - }), - createAnkiIntegration: (args) => { - assert.equal(args.aiConfig.apiKey, 'shared-key'); - assert.equal(args.aiConfig.baseUrl, 'https://openrouter.ai/api'); - assert.equal(args.aiConfig.model, 'openrouter/anki-model'); - assert.equal(args.aiConfig.systemPrompt, 'Translate mined sentence text.'); - return { - start: () => {}, - }; - }, - setAnkiIntegration: () => {}, - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 5, - deleteNoteId: 6, - deleteDuplicate: false, - cancelled: false, - }), - getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', }); + + initializeOverlayAnkiIntegration(options); + + const aiConfig = state.created[0]!.aiConfig; + assert.equal(aiConfig.apiKey, 'shared-key'); + assert.equal(aiConfig.baseUrl, 'https://openrouter.ai/api'); + assert.equal(aiConfig.model, 'openrouter/anki-model'); + assert.equal(aiConfig.systemPrompt, 'Translate mined sentence text.'); }); -test('initializeOverlayRuntime re-syncs overlay shortcuts when tracker focus changes', () => { - let syncCalls = 0; - const tracker = { - onGeometryChange: null as ((...args: unknown[]) => void) | null, - onWindowFound: null as ((...args: unknown[]) => void) | null, - onWindowLost: null as (() => void) | null, - onWindowFocusChange: null as ((focused: boolean) => void) | null, - start: () => {}, - }; +test('initializeOverlayRuntime initializes the Anki integration after the window tracker starts', () => { + const { options: ankiOptions, state } = createAnkiHarness(); + const tracker = createFakeTracker(); initializeOverlayRuntime({ + ...ankiOptions, backendOverride: null, createMainWindow: () => {}, registerGlobalShortcuts: () => {}, @@ -429,287 +242,76 @@ test('initializeOverlayRuntime re-syncs overlay shortcuts when tracker focus cha isVisibleOverlayVisible: () => false, updateVisibleOverlayVisibility: () => {}, getOverlayWindows: () => [], - syncOverlayShortcuts: () => { - syncCalls += 1; - }, - setWindowTracker: () => {}, - getMpvSocketPath: () => '/tmp/mpv.sock', - createWindowTracker: () => tracker as never, - getResolvedConfig: () => ({ - ankiConnect: { enabled: false } as never, - }), - getSubtitleTimingTracker: () => null, - getMpvClient: () => null, - getRuntimeOptionsManager: () => null, - setAnkiIntegration: () => {}, - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 1, - deleteNoteId: 2, - deleteDuplicate: false, - cancelled: false, - }), - getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', - }); - - assert.equal(typeof tracker.onWindowFocusChange, 'function'); - tracker.onWindowFocusChange?.(true); - assert.equal(syncCalls, 1); -}); - -test('initializeOverlayRuntime refreshes visible overlay when tracker focus changes while overlay is shown', () => { - let visibilityRefreshCalls = 0; - const tracker = { - onGeometryChange: null as ((...args: unknown[]) => void) | null, - onWindowFound: null as ((...args: unknown[]) => void) | null, - onWindowLost: null as (() => void) | null, - onWindowFocusChange: null as ((focused: boolean) => void) | null, - start: () => {}, - }; - - initializeOverlayRuntime({ - backendOverride: null, - createMainWindow: () => {}, - registerGlobalShortcuts: () => {}, - updateVisibleOverlayBounds: () => {}, - isVisibleOverlayVisible: () => true, - updateVisibleOverlayVisibility: () => { - visibilityRefreshCalls += 1; - }, - getOverlayWindows: () => [], syncOverlayShortcuts: () => {}, setWindowTracker: () => {}, getMpvSocketPath: () => '/tmp/mpv.sock', createWindowTracker: () => tracker as never, - getResolvedConfig: () => ({ - ankiConnect: { enabled: false } as never, - }), - getSubtitleTimingTracker: () => null, - getMpvClient: () => null, - getRuntimeOptionsManager: () => null, - setAnkiIntegration: () => {}, - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 1, - deleteNoteId: 2, - deleteDuplicate: false, - cancelled: false, - }), - getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', }); - tracker.onWindowFocusChange?.(true); - - assert.equal(visibilityRefreshCalls, 2); + assert.equal(tracker.started, true); + assert.equal(state.created.length, 1); + assert.equal(state.started, 1); + assert.equal(state.stored.length, 1); }); -test('initializeOverlayRuntime refreshes the current subtitle when tracker finds the target window again', () => { - let subtitleRefreshCalls = 0; - const tracker = { - onGeometryChange: null as ((...args: unknown[]) => void) | null, - onWindowFound: null as ((...args: unknown[]) => void) | null, - onWindowLost: null as (() => void) | null, - onWindowFocusChange: null as ((focused: boolean) => void) | null, - start: () => {}, - }; - - initializeOverlayRuntime({ - backendOverride: null, - createMainWindow: () => {}, - registerGlobalShortcuts: () => {}, - updateVisibleOverlayBounds: () => {}, - isVisibleOverlayVisible: () => true, - updateVisibleOverlayVisibility: () => {}, - refreshCurrentSubtitle: () => { - subtitleRefreshCalls += 1; +const trackerEventCases: Array<{ + name: string; + overlayVisible: boolean; + targetMinimized?: boolean; + trigger: (tracker: ReturnType) => void; + expected: Partial['snapshot']>>; +}> = [ + { + name: 'refreshes the visible overlay and re-syncs shortcuts when focus changes while shown', + overlayVisible: true, + trigger: (tracker) => tracker.onWindowFocusChange?.(true), + expected: { visibilityRefreshes: 1, shortcutSyncs: 1 }, + }, + { + name: 'only re-syncs shortcuts when focus changes while the overlay is hidden', + overlayVisible: false, + trigger: (tracker) => tracker.onWindowFocusChange?.(true), + expected: { shortcutSyncs: 1 }, + }, + { + name: 'restores bounds, visibility, and subtitle when the tracker finds the target window again', + overlayVisible: true, + trigger: (tracker) => tracker.onWindowFound?.({ x: 100, y: 200, width: 1280, height: 720 }), + expected: { + bounds: [{ x: 100, y: 200, width: 1280, height: 720 }], + visibilityRefreshes: 1, + subtitleRefreshes: 1, }, - getOverlayWindows: () => [], - syncOverlayShortcuts: () => {}, - setWindowTracker: () => {}, - getMpvSocketPath: () => '/tmp/mpv.sock', - createWindowTracker: () => tracker as never, - getResolvedConfig: () => ({ - ankiConnect: { enabled: false } as never, - }), - getSubtitleTimingTracker: () => null, - getMpvClient: () => null, - getRuntimeOptionsManager: () => null, - setAnkiIntegration: () => {}, - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 1, - deleteNoteId: 2, - deleteDuplicate: false, - cancelled: false, - }), - getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }, + { + name: 'hides overlay windows when the tracker loses a minimized target window', + overlayVisible: true, + targetMinimized: true, + trigger: (tracker) => tracker.onWindowLost?.(), + expected: { hiddenWindows: ['visible', 'modal'], shortcutSyncs: 1 }, + }, + { + name: 'refreshes visibility instead of hiding when the tracker loses a non-minimized target', + overlayVisible: true, + targetMinimized: false, + trigger: (tracker) => tracker.onWindowLost?.(), + expected: { visibilityRefreshes: 1 }, + }, +]; + +for (const c of trackerEventCases) { + test(`initializeOverlayRuntime ${c.name}`, () => { + const { tracker, snapshot } = createTrackerHarness(c); + + c.trigger(tracker); + + assert.deepEqual(snapshot(), { + bounds: [], + visibilityRefreshes: 0, + subtitleRefreshes: 0, + shortcutSyncs: 0, + hiddenWindows: [], + ...c.expected, + }); }); - - tracker.onWindowFound?.({ x: 100, y: 200, width: 1280, height: 720 }); - - assert.equal(subtitleRefreshCalls, 1); -}); - -test('initializeOverlayRuntime hides overlay windows when tracker loses the target window', () => { - const calls: string[] = []; - const tracker = { - onGeometryChange: null as ((...args: unknown[]) => void) | null, - onWindowFound: null as ((...args: unknown[]) => void) | null, - onWindowLost: null as (() => void) | null, - onWindowFocusChange: null as ((focused: boolean) => void) | null, - isTargetWindowMinimized: () => true, - start: () => {}, - }; - const overlayWindows = [ - { - hide: () => calls.push('hide-visible'), - }, - { - hide: () => calls.push('hide-modal'), - }, - ]; - - initializeOverlayRuntime({ - backendOverride: null, - createMainWindow: () => {}, - registerGlobalShortcuts: () => {}, - updateVisibleOverlayBounds: () => {}, - isVisibleOverlayVisible: () => true, - updateVisibleOverlayVisibility: () => {}, - refreshCurrentSubtitle: () => {}, - getOverlayWindows: () => overlayWindows as never, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - setWindowTracker: () => {}, - getMpvSocketPath: () => '/tmp/mpv.sock', - createWindowTracker: () => tracker as never, - getResolvedConfig: () => ({ - ankiConnect: { enabled: false } as never, - }), - getSubtitleTimingTracker: () => null, - getMpvClient: () => null, - getRuntimeOptionsManager: () => null, - setAnkiIntegration: () => {}, - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 1, - deleteNoteId: 2, - deleteDuplicate: false, - cancelled: false, - }), - getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', - }); - - tracker.onWindowLost?.(); - - assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']); -}); - -test('initializeOverlayRuntime refreshes visible overlay on tracker loss when target is not minimized', () => { - const calls: string[] = []; - const tracker = { - onGeometryChange: null as ((...args: unknown[]) => void) | null, - onWindowFound: null as ((...args: unknown[]) => void) | null, - onWindowLost: null as (() => void) | null, - onWindowFocusChange: null as ((focused: boolean) => void) | null, - isTargetWindowMinimized: () => false, - start: () => {}, - }; - const overlayWindows = [ - { - hide: () => calls.push('hide-visible'), - }, - ]; - - initializeOverlayRuntime({ - backendOverride: null, - createMainWindow: () => {}, - registerGlobalShortcuts: () => {}, - updateVisibleOverlayBounds: () => {}, - isVisibleOverlayVisible: () => true, - updateVisibleOverlayVisibility: () => { - calls.push('update-visible'); - }, - refreshCurrentSubtitle: () => {}, - getOverlayWindows: () => overlayWindows as never, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - setWindowTracker: () => {}, - getMpvSocketPath: () => '/tmp/mpv.sock', - createWindowTracker: () => tracker as never, - getResolvedConfig: () => ({ - ankiConnect: { enabled: false } as never, - }), - getSubtitleTimingTracker: () => null, - getMpvClient: () => null, - getRuntimeOptionsManager: () => null, - setAnkiIntegration: () => {}, - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 1, - deleteNoteId: 2, - deleteDuplicate: false, - cancelled: false, - }), - getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', - }); - - calls.length = 0; - tracker.onWindowLost?.(); - - assert.deepEqual(calls, ['update-visible']); -}); - -test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => { - const bounds: Array<{ x: number; y: number; width: number; height: number }> = []; - let visibilityRefreshCalls = 0; - const tracker = { - onGeometryChange: null as ((...args: unknown[]) => void) | null, - onWindowFound: null as ((...args: unknown[]) => void) | null, - onWindowLost: null as (() => void) | null, - onWindowFocusChange: null as ((focused: boolean) => void) | null, - start: () => {}, - }; - - initializeOverlayRuntime({ - backendOverride: null, - createMainWindow: () => {}, - registerGlobalShortcuts: () => {}, - updateVisibleOverlayBounds: (geometry) => { - bounds.push(geometry); - }, - isVisibleOverlayVisible: () => true, - updateVisibleOverlayVisibility: () => { - visibilityRefreshCalls += 1; - }, - refreshCurrentSubtitle: () => {}, - getOverlayWindows: () => [], - syncOverlayShortcuts: () => {}, - setWindowTracker: () => {}, - getMpvSocketPath: () => '/tmp/mpv.sock', - createWindowTracker: () => tracker as never, - getResolvedConfig: () => ({ - ankiConnect: { enabled: false } as never, - }), - getSubtitleTimingTracker: () => null, - getMpvClient: () => null, - getRuntimeOptionsManager: () => null, - setAnkiIntegration: () => {}, - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 1, - deleteNoteId: 2, - deleteDuplicate: false, - cancelled: false, - }), - getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', - }); - - const restoredGeometry = { x: 100, y: 200, width: 1280, height: 720 }; - tracker.onWindowFound?.(restoredGeometry); - - assert.deepEqual(bounds, [restoredGeometry]); - assert.equal(visibilityRefreshCalls, 2); -}); +} diff --git a/src/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts index 724b21a4..7c254fc7 100644 --- a/src/core/services/overlay-shortcut-handler.test.ts +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -93,34 +93,6 @@ function createDeps(overrides: Partial = {}) { 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.openCharacterDictionaryManager(); - overlayHandlers.openJimaku(); - overlayHandlers.mineSentenceMultiple(2222); - overlayHandlers.updateLastCardFromClipboard(); - fallbackHandlers.mineSentence(); - await new Promise((resolve) => setImmediate(resolve)); - - assert.deepEqual(calls, [ - 'copySubtitle', - 'copySubtitleMultiple:1111', - 'toggleSecondarySub', - 'openRuntimeOptions', - 'openCharacterDictionaryManager', - 'openJimaku', - 'mineSentenceMultiple:2222', - 'updateLastCardFromClipboard', - 'mineSentence', - ]); -}); - test('createOverlayShortcutRuntimeHandlers reports async failures via OSD', async () => { const logs: unknown[][] = []; const originalError = console.error; @@ -243,79 +215,58 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re assert.deepEqual(handled, []); }); -test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle', () => { - const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = []; - const shortcuts = makeShortcuts({ - toggleSecondarySub: 'Ctrl+2', +const allowWhenRegisteredCases: Array<{ + name: string; + shortcut: Partial; + accelerator: string; +}> = [ + { + name: 'secondary-sub toggle', + shortcut: { toggleSecondarySub: 'Ctrl+2' }, + accelerator: 'Ctrl+2', + }, + { + name: 'registered-global jimaku shortcut', + shortcut: { openJimaku: 'Ctrl+J' }, + accelerator: 'Ctrl+J', + }, +]; + +for (const c of allowWhenRegisteredCases) { + test(`runOverlayShortcutLocalFallback passes allowWhenRegistered for ${c.name}`, () => { + const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = []; + + const result = runOverlayShortcutLocalFallback( + {} as Electron.Input, + makeShortcuts(c.shortcut), + (_input, accelerator, allowWhenRegistered) => { + matched.push({ + accelerator, + allowWhenRegistered: allowWhenRegistered === true, + }); + return accelerator === c.accelerator; + }, + { + openRuntimeOptions: () => {}, + openCharacterDictionaryManager: () => {}, + openJimaku: () => {}, + openTsukihime: () => {}, + markAudioCard: () => {}, + copySubtitleMultiple: () => {}, + copySubtitle: () => {}, + toggleSecondarySub: () => {}, + updateLastCardFromClipboard: () => {}, + triggerFieldGrouping: () => {}, + triggerSubsync: () => {}, + mineSentence: () => {}, + mineSentenceMultiple: () => {}, + }, + ); + + assert.equal(result, true); + assert.deepEqual(matched, [{ accelerator: c.accelerator, allowWhenRegistered: true }]); }); - - const result = runOverlayShortcutLocalFallback( - {} as Electron.Input, - shortcuts, - (_input, accelerator, allowWhenRegistered) => { - matched.push({ - accelerator, - allowWhenRegistered: allowWhenRegistered === true, - }); - return accelerator === 'Ctrl+2'; - }, - { - openRuntimeOptions: () => {}, - openCharacterDictionaryManager: () => {}, - openJimaku: () => {}, - openTsukihime: () => {}, - markAudioCard: () => {}, - copySubtitleMultiple: () => {}, - copySubtitle: () => {}, - toggleSecondarySub: () => {}, - updateLastCardFromClipboard: () => {}, - triggerFieldGrouping: () => {}, - triggerSubsync: () => {}, - mineSentence: () => {}, - mineSentenceMultiple: () => {}, - }, - ); - - assert.equal(result, true); - assert.deepEqual(matched, [{ accelerator: 'Ctrl+2', allowWhenRegistered: true }]); -}); - -test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut', () => { - const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = []; - const shortcuts = makeShortcuts({ - openJimaku: 'Ctrl+J', - }); - - const result = runOverlayShortcutLocalFallback( - {} as Electron.Input, - shortcuts, - (_input, accelerator, allowWhenRegistered) => { - matched.push({ - accelerator, - allowWhenRegistered: allowWhenRegistered === true, - }); - return accelerator === 'Ctrl+J'; - }, - { - openRuntimeOptions: () => {}, - openCharacterDictionaryManager: () => {}, - openJimaku: () => {}, - openTsukihime: () => {}, - markAudioCard: () => {}, - copySubtitleMultiple: () => {}, - copySubtitle: () => {}, - toggleSecondarySub: () => {}, - updateLastCardFromClipboard: () => {}, - triggerFieldGrouping: () => {}, - triggerSubsync: () => {}, - mineSentence: () => {}, - mineSentenceMultiple: () => {}, - }, - ); - - assert.equal(result, true); - assert.deepEqual(matched, [{ accelerator: 'Ctrl+J', allowWhenRegistered: true }]); -}); +} test('runOverlayShortcutLocalFallback returns false when no action matches', () => { const shortcuts = makeShortcuts({ diff --git a/src/core/services/overlay-shortcut.test.ts b/src/core/services/overlay-shortcut.test.ts index c616fe85..a6a5fdc7 100644 --- a/src/core/services/overlay-shortcut.test.ts +++ b/src/core/services/overlay-shortcut.test.ts @@ -1,11 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { ConfiguredShortcuts } from '../utils/shortcut-config'; -import { - registerOverlayShortcuts, - syncOverlayShortcutsRuntime, - unregisterOverlayShortcutsRuntime, -} from './overlay-shortcut'; +import { registerOverlayShortcuts } from './overlay-shortcut'; function createShortcuts(overrides: Partial = {}): ConfiguredShortcuts { return { @@ -36,27 +32,6 @@ function createShortcuts(overrides: Partial = {}): Configur }; } -test('registerOverlayShortcuts reports active overlay shortcuts when configured', () => { - assert.equal( - registerOverlayShortcuts(createShortcuts({ openJimaku: 'Ctrl+J' }), { - copySubtitle: () => {}, - copySubtitleMultiple: () => {}, - updateLastCardFromClipboard: () => {}, - triggerFieldGrouping: () => {}, - triggerSubsync: () => {}, - mineSentence: () => {}, - mineSentenceMultiple: () => {}, - toggleSecondarySub: () => {}, - markAudioCard: () => {}, - openCharacterDictionaryManager: () => {}, - openRuntimeOptions: () => {}, - openJimaku: () => {}, - openTsukihime: () => {}, - }), - true, - ); -}); - test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent', () => { assert.equal( registerOverlayShortcuts(createShortcuts(), { @@ -77,34 +52,3 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent' false, ); }); - -test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active', () => { - const calls: string[] = []; - const result = syncOverlayShortcutsRuntime(false, true, { - getConfiguredShortcuts: () => createShortcuts(), - getOverlayHandlers: () => ({ - copySubtitle: () => {}, - copySubtitleMultiple: () => {}, - updateLastCardFromClipboard: () => {}, - triggerFieldGrouping: () => {}, - triggerSubsync: () => {}, - mineSentence: () => {}, - mineSentenceMultiple: () => {}, - toggleSecondarySub: () => {}, - markAudioCard: () => {}, - openCharacterDictionaryManager: () => {}, - openRuntimeOptions: () => {}, - openJimaku: () => {}, - openTsukihime: () => {}, - }), - cancelPendingMultiCopy: () => { - calls.push('cancel-multi-copy'); - }, - cancelPendingMineSentenceMultiple: () => { - calls.push('cancel-mine-sentence-multiple'); - }, - }); - - assert.equal(result, false); - assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']); -}); diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index 6c37c9f2..5e1b8009 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -1,2698 +1,807 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import type { WindowGeometry } from '../../types'; import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; -import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility'; +import { updateVisibleOverlayVisibility } from './overlay-visibility'; -type WindowTrackerStub = { - isTracking: () => boolean; - getGeometry: () => { x: number; y: number; width: number; height: number } | null; - isTargetWindowFocused?: () => boolean; - isTargetWindowMinimized?: () => boolean; +type VisibilityArgs = Parameters[0]; +type Platform = 'linux' | 'macos' | 'windows'; +type MouseMode = 'interactive' | 'passthrough' | 'passthrough-forward'; + +// Optional fields are omitted from the stub tracker entirely, matching trackers that +// cannot report target focus or minimized state. +type TrackerState = { + tracking: boolean; + geometry: WindowGeometry | null; + targetFocused?: boolean; + targetMinimized?: boolean; }; -function createMainWindowRecorder(options: { emitShowImmediately?: boolean } = {}) { +// What a single updateVisibleOverlayVisibility call did. Reset at the start of every run. +type RunEffects = { + shownWith: 'show' | 'showInactive' | null; + hid: boolean; + focusRequested: boolean; + // Last window-level action: ensureOverlayWindowLevel ('raised') or the internal release. + level: 'raised' | 'released' | null; + orderEnforced: boolean; + layerSynced: boolean; + shortcutsSynced: boolean; + zOrderSyncs: number; + boundsUpdated: boolean; + // Distinct mouse modes applied, in first-applied order. + mouseModes: MouseMode[]; +}; + +type Outcome = RunEffects & { + visible: boolean; + mouse: MouseMode | null; + opacity: number; + fullScreen: boolean | null; + allWorkspaces: boolean | null; + trackerWarning: boolean; + osd: string[]; + dismissals: number; +}; + +const GEOMETRY: WindowGeometry = { x: 0, y: 0, width: 1280, height: 720 }; +const LOADING_OSD = 'Overlay loading...'; + +const tracked = (extra: Partial = {}): TrackerState => ({ + tracking: true, + geometry: GEOMETRY, + ...extra, +}); +const lost = (extra: Partial = {}): TrackerState => ({ + tracking: false, + geometry: null, + ...extra, +}); + +const emptyEffects = (): RunEffects => ({ + shownWith: null, + hid: false, + focusRequested: false, + level: null, + orderEnforced: false, + layerSynced: false, + shortcutsSynced: false, + zOrderSyncs: 0, + boundsUpdated: false, + mouseModes: [], +}); + +/** + * Drives updateVisibleOverlayVisibility against a fake overlay window and default deps. + * `run(overrides)` returns an Outcome: the run's effects plus resulting window and OSD state. + */ +function createHarness( + options: { + platform?: Platform; + tracker?: TrackerState | null; + visible?: boolean; + focused?: boolean; + contentReady?: boolean; + trackerWarning?: boolean; + emitShowImmediately?: boolean; + } = {}, +) { + const platform = options.platform ?? 'linux'; const emitShowImmediately = options.emitShowImmediately ?? true; - const calls: string[] = []; const listeners = new Map void>>(); - let visible = false; - let focused = false; - let opacity = 1; - let contentReady = true; - const emit = (event: string): void => { - const handlers = listeners.get(event) ?? []; - listeners.delete(event); - for (const handler of handlers) { - handler(); - } + const win = { + visible: options.visible ?? false, + focused: options.focused ?? false, + opacity: 1, + mouse: null as MouseMode | null, + fullScreen: null as boolean | null, + allWorkspaces: null as boolean | null, }; + let effects = emptyEffects(); + const h = { + tracker: options.tracker === undefined ? null : options.tracker, + trackerWarning: options.trackerWarning ?? false, + osd: [] as string[], + dismissals: 0, + // Every bounds update, with whether the window was already visible when it landed. + bounds: [] as Array<{ width: number; visible: boolean }>, + }; + const emitShow = (): void => { - visible = true; - emit('show'); + win.visible = true; + const handlers = listeners.get('show') ?? []; + listeners.delete('show'); + for (const handler of handlers) handler(); }; - const window = { + const show = (mode: 'show' | 'showInactive'): void => { + effects.shownWith = mode; + if (emitShowImmediately) emitShow(); + }; + const fakeWindow: Record = { webContents: {}, + [OVERLAY_WINDOW_CONTENT_READY_FLAG]: options.contentReady ?? true, isDestroyed: () => false, - isVisible: () => visible, - isFocused: () => focused, + isVisible: () => win.visible, + isFocused: () => win.focused, once: (event: string, handler: () => void) => { listeners.set(event, [...(listeners.get(event) ?? []), handler]); }, hide: () => { - visible = false; - focused = false; - calls.push('hide'); - }, - show: () => { - calls.push('show'); - if (emitShowImmediately) { - emitShow(); - } - }, - showInactive: () => { - calls.push('show-inactive'); - if (emitShowImmediately) { - emitShow(); - } + win.visible = false; + win.focused = false; + effects.hid = true; }, + show: () => show('show'), + showInactive: () => show('showInactive'), focus: () => { - focused = true; - calls.push('focus'); + win.focused = true; + effects.focusRequested = true; }, setAlwaysOnTop: (flag: boolean) => { - calls.push(`always-on-top:${flag}`); + if (!flag) effects.level = 'released'; }, - setFullScreen: (fullscreen: boolean) => { - calls.push(`fullscreen:${fullscreen}`); + setFullScreen: (flag: boolean) => { + win.fullScreen = flag; }, - setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => { - calls.push( - `all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`, - ); + setVisibleOnAllWorkspaces: (flag: boolean) => { + win.allWorkspaces = flag; }, - setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { - calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`); + setIgnoreMouseEvents: (ignore: boolean, opts?: { forward?: boolean }) => { + const mode: MouseMode = !ignore + ? 'interactive' + : opts?.forward + ? 'passthrough-forward' + : 'passthrough'; + win.mouse = mode; + if (!effects.mouseModes.includes(mode)) effects.mouseModes.push(mode); }, - setOpacity: (nextOpacity: number) => { - opacity = nextOpacity; - calls.push(`opacity:${nextOpacity}`); - }, - moveTop: () => { - calls.push('move-top'); + setOpacity: (opacity: number) => { + win.opacity = opacity; }, }; - ( - window as { - [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean; - } - )[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady; + + const buildTracker = (state: TrackerState | null) => + state === null + ? null + : ({ + isTracking: () => state.tracking, + getGeometry: () => state.geometry, + ...(state.targetFocused === undefined + ? {} + : { isTargetWindowFocused: () => state.targetFocused }), + ...(state.targetMinimized === undefined + ? {} + : { isTargetWindowMinimized: () => state.targetMinimized }), + } as unknown as VisibilityArgs['windowTracker']); + + const outcome = (): Outcome => ({ + ...effects, + mouseModes: [...effects.mouseModes], + visible: win.visible, + mouse: win.mouse, + opacity: win.opacity, + fullScreen: win.fullScreen, + allWorkspaces: win.allWorkspaces, + trackerWarning: h.trackerWarning, + osd: [...h.osd], + dismissals: h.dismissals, + }); + + const run = (overrides: Partial = {}): Outcome => { + effects = emptyEffects(); + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: fakeWindow as unknown as VisibilityArgs['mainWindow'], + windowTracker: buildTracker(h.tracker), + trackerNotReadyWarningShown: h.trackerWarning, + setTrackerNotReadyWarningShown: (shown) => { + h.trackerWarning = shown; + }, + updateVisibleOverlayBounds: (geometry) => { + effects.boundsUpdated = true; + h.bounds.push({ width: geometry.width, visible: win.visible }); + }, + ensureOverlayWindowLevel: () => { + effects.level = 'raised'; + }, + syncWindowsOverlayToMpvZOrder: () => { + effects.zOrderSyncs += 1; + }, + syncPrimaryOverlayWindowLayer: () => { + effects.layerSynced = true; + }, + enforceOverlayLayerOrder: () => { + effects.orderEnforced = true; + }, + syncOverlayShortcuts: () => { + effects.shortcutsSynced = true; + }, + showOverlayLoadingOsd: (message) => { + h.osd.push(message); + }, + dismissOverlayLoadingOsd: () => { + h.dismissals += 1; + }, + isMacOSPlatform: platform === 'macos', + isWindowsPlatform: platform === 'windows', + ...overrides, + }); + return outcome(); + }; return { - window, - calls, - getOpacity: () => opacity, + state: h, + run, + outcome, emitShow, - setContentReady: (nextContentReady: boolean) => { - contentReady = nextContentReady; - ( - window as { - [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean; - } - )[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady; + setFocused: (focused: boolean) => { + win.focused = focused; }, - setFocused: (nextFocused: boolean) => { - focused = nextFocused; + setContentReady: (ready: boolean) => { + fakeWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] = ready; }, }; } -test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => { - const { window, calls } = createMainWindowRecorder(); - let trackerWarning = false; - const osdMessages: string[] = []; - const tracker: WindowTrackerStub = { - isTracking: () => false, - getGeometry: () => null, +// Compares only the keys a scenario cares about. +function assertOutcome(actual: Outcome, expected: Partial): void { + const picked: Partial = {}; + for (const key of Object.keys(expected) as Array) { + Object.assign(picked, { [key]: actual[key] }); + } + assert.deepEqual(picked, expected); +} + +// Shared "suppress repeat loading OSD for 5s" policy used by the OSD cooldown tests. +function loadingOsdCooldown(now: () => number) { + let lastShownAtMs: number | null = null; + return { + shouldShowOverlayLoadingOsd: () => lastShownAtMs === null || now() - lastShownAtMs >= 5_000, + markOverlayLoadingOsdShown: () => { + lastShownAtMs = now(); + }, + resetOverlayLoadingOsdSuppression: () => { + lastShownAtMs = null; + }, }; +} - const run = () => - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: trackerWarning, - setTrackerNotReadyWarningShown: (shown: boolean) => { - trackerWarning = shown; - }, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - showOverlayLoadingOsd: (message: string) => { - osdMessages.push(message); - }, - } as never); +type Scenario = { + name: string; + platform: Platform; + tracker: TrackerState | null; + visible?: boolean; + focused?: boolean; + trackerWarning?: boolean; + overrides?: Partial; + expect: Partial; +}; - run(); - run(); +function runScenarios(scenarios: Scenario[]): void { + for (const c of scenarios) { + test(c.name, () => { + const h = createHarness({ + platform: c.platform, + tracker: c.tracker, + visible: c.visible, + focused: c.focused, + trackerWarning: c.trackerWarning, + }); + assertOutcome(h.run(c.overrides), c.expect); + }); + } +} - assert.equal(trackerWarning, true); - assert.deepEqual(osdMessages, ['Overlay loading...']); - assert.ok(calls.includes('hide')); - assert.ok(!calls.includes('show')); +const hiddenNotShown = { + visible: false, + shownWith: null, + boundsUpdated: false, + focusRequested: false, +} satisfies Partial; + +// Tracker not ready: the overlay stays hidden. Native platforms warn once with a loading OSD; +// untracked Linux has nothing to wait for, so it stays quiet. +for (const c of [ + { name: 'macOS tracker not ready', platform: 'macos', tracker: lost(), warns: true }, + { name: 'macOS tracker not initialized', platform: 'macos', tracker: null, warns: true }, + { name: 'Windows tracker not ready', platform: 'windows', tracker: lost(), warns: true }, + { name: 'Linux tracker not ready', platform: 'linux', tracker: lost(), warns: true }, + { name: 'Linux without a tracker', platform: 'linux', tracker: null, warns: false }, +] satisfies Array<{ + name: string; + platform: Platform; + tracker: TrackerState | null; + warns: boolean; +}>) { + test(`${c.name}: overlay stays hidden${c.warns ? ' and emits one loading OSD' : ' without a loading OSD'}`, () => { + const h = createHarness({ platform: c.platform, tracker: c.tracker }); + h.run(); + assertOutcome(h.run(), { + ...hiddenNotShown, + trackerWarning: c.warns, + osd: c.warns ? [LOADING_OSD] : [], + }); + }); +} + +test('untracked Linux overlay is released to click-through while hidden', () => { + const h = createHarness({ platform: 'linux', tracker: null, visible: true }); + assertOutcome(h.run(), { + hid: true, + mouse: 'passthrough-forward', + level: 'released', + shortcutsSynced: true, + }); }); test('macOS dismisses overlay loading OSD when tracker recovers', () => { - const { window, calls } = createMainWindowRecorder(); - let trackerWarning = false; - const osdMessages: string[] = []; - const dismissedOsds: string[] = []; - let tracking = false; - let geometry: WindowTrackerStub['getGeometry'] extends () => infer T ? T : never = null; - const tracker: WindowTrackerStub = { - isTracking: () => tracking, - getGeometry: () => geometry, - isTargetWindowFocused: () => tracking, - }; + const h = createHarness({ platform: 'macos', tracker: lost({ targetFocused: false }) }); + h.run(); + h.state.tracker = tracked({ targetFocused: true }); - const run = () => - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: trackerWarning, - setTrackerNotReadyWarningShown: (shown: boolean) => { - trackerWarning = shown; - }, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - showOverlayLoadingOsd: (message: string) => { - osdMessages.push(message); - }, - dismissOverlayLoadingOsd: () => { - dismissedOsds.push('dismiss'); - }, - } as never); - - run(); - tracking = true; - geometry = { x: 0, y: 0, width: 1280, height: 720 }; - run(); - - assert.deepEqual(osdMessages, ['Overlay loading...']); - assert.deepEqual(dismissedOsds, ['dismiss']); - assert.equal(trackerWarning, false); - assert.ok(calls.includes('show-inactive')); + assertOutcome(h.run(), { + osd: [LOADING_OSD], + dismissals: 1, + trackerWarning: false, + shownWith: 'showInactive', + }); }); -test('tracked non-native overlay shows loading OSD until renderer content is visible', () => { - const { window, calls, setContentReady } = createMainWindowRecorder(); - let loadingShown = false; - const osdMessages: string[] = []; - const dismissedOsds: string[] = []; - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => true, - }; +for (const platform of ['linux', 'windows'] satisfies Platform[]) { + test(`${platform} tracked overlay shows loading OSD and waits for renderer content before first reveal`, () => { + const h = createHarness({ + platform, + tracker: tracked({ targetFocused: true }), + contentReady: false, + }); + h.run(); + assertOutcome(h.run(), { + visible: false, + shownWith: null, + trackerWarning: true, + osd: [LOADING_OSD], + dismissals: 0, + }); - const run = () => - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: loadingShown, - setTrackerNotReadyWarningShown: (shown: boolean) => { - loadingShown = shown; - }, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: false, - showOverlayLoadingOsd: (message: string) => { - osdMessages.push(message); - }, - dismissOverlayLoadingOsd: () => { - dismissedOsds.push('dismiss'); - }, - } as never); + h.setContentReady(true); + assertOutcome(h.run(), { + visible: true, + shownWith: 'showInactive', + trackerWarning: false, + dismissals: 1, + }); + }); +} - setContentReady(false); - run(); - run(); +runScenarios([ + { + name: 'visible overlay stays hidden while a modal window is active', + platform: 'macos', + tracker: tracked(), + overrides: { modalActive: true }, + expect: { hid: true, shownWith: null, boundsUpdated: false }, + }, + { + name: 'suspended visible overlay hides without refreshing bounds or z-order', + platform: 'linux', + tracker: tracked(), + visible: true, + overrides: { suspendVisibleOverlay: true }, + expect: { + visible: false, + hid: true, + mouse: 'passthrough-forward', + level: 'released', + shortcutsSynced: true, + boundsUpdated: false, + orderEnforced: false, + shownWith: null, + focusRequested: false, + }, + }, +]); - assert.equal(loadingShown, true); - assert.deepEqual(osdMessages, ['Overlay loading...']); - assert.deepEqual(dismissedOsds, []); - assert.ok(!calls.includes('show')); - assert.ok(!calls.includes('show-inactive')); - - setContentReady(true); - run(); - - assert.equal(loadingShown, false); - assert.deepEqual(dismissedOsds, ['dismiss']); - assert.ok(calls.includes('show-inactive')); +test('passive Linux overlay shows without focus and stays click-through on later updates', () => { + const h = createHarness({ platform: 'linux', tracker: tracked() }); + assertOutcome(h.run(), { + shownWith: 'showInactive', + focusRequested: false, + mouse: 'passthrough-forward', + }); + assertOutcome(h.run(), { mouseModes: ['passthrough-forward'], shownWith: null }); }); -test('tracked non-macOS overlay stays hidden and emits loading OSD while tracker is not ready', () => { - const { window, calls } = createMainWindowRecorder(); - let trackerWarning = false; - const tracker: WindowTrackerStub = { - isTracking: () => false, - getGeometry: () => null, - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: trackerWarning, - setTrackerNotReadyWarningShown: (shown: boolean) => { - trackerWarning = shown; +runScenarios([ + { + name: 'non-native shaped input region stays mouse-enabled without focusing the overlay', + platform: 'linux', + tracker: tracked({ targetFocused: true }), + overrides: { nonNativeInputRegionActive: true }, + expect: { mouseModes: ['interactive'], shownWith: 'showInactive', focusRequested: false }, + }, + { + name: 'passive Linux tracked overlay releases global topmost when mpv loses focus', + platform: 'linux', + tracker: tracked({ targetFocused: false }), + visible: true, + expect: { + visible: true, + hid: false, + mouse: 'passthrough-forward', + level: 'released', + fullScreen: false, + allWorkspaces: false, + orderEnforced: false, }, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - showOverlayLoadingOsd: () => { - calls.push('osd'); - }, - resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }), - } as never); - - assert.equal(trackerWarning, true); - assert.ok(calls.includes('hide')); - assert.ok(!calls.includes('update-bounds')); - assert.ok(!calls.includes('show')); - assert.ok(!calls.includes('focus')); - assert.ok(calls.includes('osd')); -}); - -test('non-native passive overlay stays click-through after subsequent visibility updates', () => { - const { window, calls } = createMainWindowRecorder(); - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: false, - overlayInteractionActive: false, - showOverlayLoadingOsd: () => {}, - resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }), - } as never); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: false, - overlayInteractionActive: false, - showOverlayLoadingOsd: () => {}, - resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }), - } as never); - - assert.equal(calls.includes('mouse-ignore:false:plain'), false); - assert.ok(calls.includes('mouse-ignore:true:forward')); -}); - -test('non-native shaped input region stays mouse-enabled without focusing the overlay', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => true, - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: false, - overlayInteractionActive: false, - nonNativeInputRegionActive: true, - showOverlayLoadingOsd: () => {}, - resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }), - } as never); - - assert.ok(calls.includes('mouse-ignore:false:plain')); - assert.ok(calls.includes('show-inactive')); - assert.ok(!calls.includes('show')); - assert.ok(!calls.includes('focus')); - assert.equal(calls.includes('mouse-ignore:true:forward'), false); -}); - -test('suspended visible overlay hides without refreshing bounds or z-order', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - }; - - window.show(); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - suspendVisibleOverlay: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: false, - } as never); - - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('always-on-top:false')); - assert.ok(calls.includes('hide')); - assert.ok(calls.includes('sync-shortcuts')); - assert.ok(!calls.includes('update-bounds')); - assert.ok(!calls.includes('ensure-level')); - assert.ok(!calls.includes('enforce-order')); - assert.ok(!calls.includes('show')); - assert.ok(!calls.includes('focus')); -}); - -test('untracked Linux overlay stays hidden when no tracker exists', () => { - const { window, calls } = createMainWindowRecorder(); - let trackerWarning = false; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: null, - trackerNotReadyWarningShown: trackerWarning, - setTrackerNotReadyWarningShown: (shown: boolean) => { - trackerWarning = shown; - }, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - showOverlayLoadingOsd: () => { - calls.push('osd'); - }, - resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }), - } as never); - - assert.equal(trackerWarning, false); - assert.ok(calls.includes('hide')); - assert.ok(!calls.includes('show-inactive')); - assert.ok(!calls.includes('show')); - assert.ok(!calls.includes('focus')); - assert.ok(!calls.includes('osd')); -}); - -test('passive Linux visible overlay does not take keyboard focus', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: false, - } as never); - - assert.ok(calls.includes('show-inactive')); - assert.ok(!calls.includes('show')); - assert.ok(!calls.includes('focus')); -}); - -test('passive Linux tracked overlay releases global topmost when mpv loses focus', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => false, - }; - - window.show(); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: false, - } as never); - - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('always-on-top:false')); - assert.ok(calls.includes('fullscreen:false')); - assert.ok(calls.includes('all-workspaces:false:plain')); - assert.ok(!calls.includes('hide')); - assert.ok(!calls.includes('ensure-level')); - assert.ok(!calls.includes('enforce-order')); -}); - -test('passive Linux fullscreen override overlay hides when mpv loses focus', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => false, - }; - - window.show(); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - hideNonNativeOverlayWhenTargetUnfocused: true, - isMacOSPlatform: false, - isWindowsPlatform: false, - } as never); - - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('always-on-top:false')); - assert.ok(calls.includes('hide')); - assert.ok(!calls.includes('ensure-level')); - assert.ok(!calls.includes('enforce-order')); -}); - -test('Linux active overlay interaction does not focus the overlay over fullscreen mpv', () => { - const { window, calls, setFocused } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => true, - }; - - window.show(); - setFocused(false); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: false, - overlayInteractionActive: true, - } as never); - - assert.ok(calls.includes('mouse-ignore:false:plain')); - assert.ok(calls.includes('ensure-level')); - assert.ok(calls.includes('enforce-order')); - assert.ok(!calls.includes('focus')); -}); - -test('Linux active hover keeps global topmost when mpv loses focus and overlay is not focused', () => { - const { window, calls, setFocused } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => false, - }; - - window.show(); - setFocused(false); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: false, - overlayInteractionActive: true, - } as never); - - assert.ok(calls.includes('mouse-ignore:false:plain')); - assert.ok(!calls.includes('always-on-top:false')); - assert.ok(!calls.includes('all-workspaces:false:plain')); - assert.ok(calls.includes('ensure-level')); - assert.ok(calls.includes('enforce-order')); -}); + }, + { + name: 'passive Linux fullscreen override overlay hides when mpv loses focus', + platform: 'linux', + tracker: tracked({ targetFocused: false }), + visible: true, + overrides: { hideNonNativeOverlayWhenTargetUnfocused: true }, + expect: { hid: true, mouse: 'passthrough-forward', level: 'released', orderEnforced: false }, + }, + { + name: 'Linux active overlay interaction does not focus the overlay over fullscreen mpv', + platform: 'linux', + tracker: tracked({ targetFocused: true }), + visible: true, + overrides: { overlayInteractionActive: true }, + expect: { mouse: 'interactive', level: 'raised', orderEnforced: true, focusRequested: false }, + }, + { + name: 'Linux active hover keeps global topmost when mpv loses focus and overlay is not focused', + platform: 'linux', + tracker: tracked({ targetFocused: false }), + visible: true, + overrides: { overlayInteractionActive: true }, + expect: { mouse: 'interactive', level: 'raised', orderEnforced: true }, + }, +]); test('tracked non-macOS overlay reapplies bounds after first show', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: false, - } as never); - - assert.deepEqual( - calls.filter((call) => call === 'update-bounds' || call === 'show-inactive'), - ['update-bounds', 'show-inactive', 'update-bounds'], - ); + const h = createHarness({ platform: 'linux', tracker: tracked() }); + h.run(); + assert.deepEqual(h.state.bounds, [ + { width: 1280, visible: false }, + { width: 1280, visible: true }, + ]); }); -test('tracked non-macOS overlay queues only one first-show bounds refresh', () => { - const { window, calls, emitShow } = createMainWindowRecorder({ emitShowImmediately: false }); - let width = 1280; - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width, height: 720 }), - }; - const run = () => - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: (geometry: { width: number }) => { - calls.push(`update-bounds:${geometry.width}`); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: false, - } as never); +test('tracked non-macOS overlay queues only one first-show bounds refresh with the latest geometry', () => { + const h = createHarness({ platform: 'linux', tracker: tracked(), emitShowImmediately: false }); + h.run(); + h.state.tracker = tracked({ geometry: { ...GEOMETRY, width: 1440 } }); + h.run(); + h.emitShow(); - run(); - width = 1440; - run(); - emitShow(); - - assert.deepEqual( - calls.filter((call) => call.startsWith('update-bounds:')), - ['update-bounds:1280', 'update-bounds:1440', 'update-bounds:1440'], - ); + assert.deepEqual(h.state.bounds, [ + { width: 1280, visible: false }, + { width: 1440, visible: false }, + { width: 1440, visible: true }, + ]); }); -test('Windows visible overlay stays click-through and binds to mpv while tracked', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - }; +// Windows binds the overlay to mpv via the owner window, so it never raises or enforces +// layer order itself; it stays plain click-through (no forwarding) unless the overlay is focused. +runScenarios([ + { + name: 'Windows first show starts transparent and click-through, bound to mpv', + platform: 'windows', + tracker: tracked(), + expect: { + opacity: 0, + shownWith: 'showInactive', + mouse: 'passthrough', + zOrderSyncs: 1, + level: null, + orderEnforced: false, + focusRequested: false, + }, + }, + { + name: 'tracked Windows overlay refresh rebinds while already visible', + platform: 'windows', + tracker: tracked(), + visible: true, + expect: { + shownWith: null, + mouse: 'passthrough', + zOrderSyncs: 1, + level: null, + shortcutsSynced: true, + }, + }, + { + name: 'forced passthrough still reapplies while visible on Windows', + platform: 'windows', + tracker: tracked(), + visible: true, + overrides: { forceMousePassthrough: true }, + expect: { mouse: 'passthrough', zOrderSyncs: 1, level: null, orderEnforced: false }, + }, + { + name: 'forced passthrough still shows tracked overlay while bound to mpv on Windows', + platform: 'windows', + tracker: tracked(), + overrides: { forceMousePassthrough: true }, + expect: { shownWith: 'showInactive', zOrderSyncs: 1, level: null }, + }, + { + name: 'tracked Windows overlay rebinds without hiding when tracker focus changes', + platform: 'windows', + tracker: tracked({ targetFocused: false }), + visible: true, + expect: { + visible: true, + hid: false, + shownWith: null, + mouse: 'passthrough', + zOrderSyncs: 1, + level: null, + orderEnforced: false, + }, + }, + { + name: 'tracked Windows overlay stays interactive while the overlay window itself is focused', + platform: 'windows', + tracker: tracked({ targetFocused: false }), + visible: true, + focused: true, + expect: { mouse: 'interactive', zOrderSyncs: 1, level: null, orderEnforced: false }, + }, + { + name: 'tracked Windows overlay reshows click-through even if focus state is stale after a modal closes', + platform: 'windows', + tracker: tracked({ targetFocused: false }), + visible: false, + focused: true, + expect: { mouse: 'passthrough', shownWith: 'showInactive' }, + }, + { + name: 'tracked Windows overlay binds above mpv even when tracker focus lags', + platform: 'windows', + tracker: tracked({ targetFocused: false }), + expect: { mouse: 'passthrough', zOrderSyncs: 1, level: null }, + }, + { + name: 'Windows preserves visible overlay and rebinds to mpv while tracker transiently loses a non-minimized window', + platform: 'windows', + tracker: tracked({ tracking: false, targetFocused: false, targetMinimized: false }), + visible: true, + expect: { + hid: false, + shownWith: null, + mouse: 'passthrough', + zOrderSyncs: 1, + level: null, + shortcutsSynced: true, + }, + }, + { + name: 'Windows hides the visible overlay when the tracked window is minimized', + platform: 'windows', + tracker: lost({ targetMinimized: true }), + visible: true, + expect: { visible: false, hid: true, zOrderSyncs: 0 }, + }, +]); - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); +test('Windows visible overlay restores opacity and rebinds after the deferred reveal delay', async () => { + const h = createHarness({ platform: 'windows', tracker: tracked() }); + assertOutcome(h.run(), { opacity: 0, zOrderSyncs: 1 }); - assert.ok(calls.includes('opacity:0')); - assert.ok(calls.includes('mouse-ignore:true:plain')); - assert.ok(calls.includes('show-inactive')); - assert.ok(calls.includes('sync-windows-z-order')); - assert.ok(!calls.includes('move-top')); - assert.ok(!calls.includes('ensure-level')); - assert.ok(!calls.includes('enforce-order')); - assert.ok(!calls.includes('focus')); -}); - -test('Windows visible overlay restores opacity after the deferred reveal delay', async () => { - const { window, calls, getOpacity } = createMainWindowRecorder(); - let syncWindowsZOrderCalls = 0; - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - syncWindowsZOrderCalls += 1; - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - assert.equal(getOpacity(), 0); - assert.equal(syncWindowsZOrderCalls, 1); await new Promise((resolve) => setTimeout(resolve, 60)); - assert.equal(getOpacity(), 1); - assert.equal(syncWindowsZOrderCalls, 2); - assert.ok(calls.includes('opacity:1')); + + assertOutcome(h.outcome(), { opacity: 1, zOrderSyncs: 2 }); }); -test('Windows visible overlay waits for content-ready before first reveal', () => { - const { window, calls, setContentReady } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - }; - setContentReady(false); - - const run = () => - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - run(); - - assert.ok(!calls.includes('show-inactive')); - assert.ok(!calls.includes('show')); - - setContentReady(true); - run(); - - assert.ok(calls.includes('show-inactive')); -}); - -test('Linux visible overlay waits for content-ready before first reveal', () => { - const { window, calls, setContentReady } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - }; - setContentReady(false); - - const run = () => - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: false, - } as never); - - run(); - - assert.ok(!calls.includes('show-inactive')); - assert.ok(!calls.includes('show')); - - setContentReady(true); - run(); - - assert.ok(calls.includes('show-inactive')); -}); - -test('tracked Windows overlay refresh rebinds while already visible', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - assert.ok(calls.includes('mouse-ignore:true:plain')); - assert.ok(calls.includes('sync-windows-z-order')); - assert.ok(!calls.includes('move-top')); - assert.ok(!calls.includes('show')); - assert.ok(!calls.includes('ensure-level')); - assert.ok(calls.includes('sync-shortcuts')); -}); - -test('forced passthrough still reapplies while visible on Windows', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - forceMousePassthrough: true, - } as never); - - assert.ok(calls.includes('mouse-ignore:true:plain')); - assert.ok(!calls.includes('always-on-top:false')); - assert.ok(!calls.includes('move-top')); - assert.ok(calls.includes('sync-windows-z-order')); - assert.ok(!calls.includes('ensure-level')); - assert.ok(!calls.includes('enforce-order')); -}); - -test('forced passthrough still shows tracked overlay while bound to mpv on Windows', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - forceMousePassthrough: true, - } as never); - - assert.ok(calls.includes('show-inactive')); - assert.ok(!calls.includes('always-on-top:false')); - assert.ok(!calls.includes('move-top')); - assert.ok(calls.includes('sync-windows-z-order')); -}); - -test('forced mouse passthrough keeps macOS tracked overlay above active mpv', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => true, - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - isWindowsPlatform: false, - forceMousePassthrough: true, - } as never); - - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('ensure-level')); - assert.ok(calls.includes('enforce-order')); - assert.ok(!calls.includes('always-on-top:false')); -}); - -test('forced mouse passthrough still hides macOS tracked overlay when mpv loses foreground', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => false, - }; - - window.show(); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - isWindowsPlatform: false, - forceMousePassthrough: true, - } as never); - - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('always-on-top:false')); - assert.ok(calls.includes('all-workspaces:false:plain')); - assert.ok(calls.includes('hide')); - assert.ok(!calls.includes('ensure-level')); - assert.ok(!calls.includes('enforce-order')); -}); - -test('tracked Windows overlay rebinds without hiding when tracker focus changes', () => { - const { window, calls } = createMainWindowRecorder(); - let focused = true; - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => focused, - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - calls.length = 0; - focused = false; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - assert.ok(!calls.includes('always-on-top:false')); - assert.ok(!calls.includes('move-top')); - assert.ok(calls.includes('mouse-ignore:true:plain')); - assert.ok(calls.includes('sync-windows-z-order')); - assert.ok(!calls.includes('ensure-level')); - assert.ok(!calls.includes('enforce-order')); - assert.ok(!calls.includes('show')); -}); - -test('tracked Windows overlay stays interactive while the overlay window itself is focused', () => { - const { window, calls, setFocused } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => false, - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - calls.length = 0; - setFocused(true); - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - assert.ok(calls.includes('mouse-ignore:false:plain')); - assert.ok(calls.includes('sync-windows-z-order')); - assert.ok(!calls.includes('move-top')); - assert.ok(!calls.includes('ensure-level')); - assert.ok(!calls.includes('enforce-order')); -}); - -test('tracked Windows overlay reshows click-through even if focus state is stale after a modal closes', () => { - const { window, calls, setFocused } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => false, - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - calls.length = 0; - window.hide(); - calls.length = 0; - setFocused(true); - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - assert.ok(calls.includes('mouse-ignore:true:plain')); - assert.ok(calls.includes('show-inactive')); - assert.ok(!calls.includes('show')); -}); - -test('tracked Windows overlay binds above mpv even when tracker focus lags', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => false, - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - assert.ok(!calls.includes('always-on-top:false')); - assert.ok(!calls.includes('move-top')); - assert.ok(calls.includes('mouse-ignore:true:plain')); - assert.ok(calls.includes('sync-windows-z-order')); - assert.ok(!calls.includes('ensure-level')); -}); - -test('visible overlay stays hidden while a modal window is active', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - modalActive: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - isWindowsPlatform: false, - } as never); - - assert.ok(calls.includes('hide')); - assert.ok(!calls.includes('show')); - assert.ok(!calls.includes('update-bounds')); -}); - -test('macOS tracked visible overlay starts click-through without passively stealing focus', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - isWindowsPlatform: false, - } as never); - - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('show-inactive')); - assert.ok(!calls.includes('show')); - assert.ok(!calls.includes('focus')); -}); - -test('macOS tracked visible overlay remains click-through even if the overlay had focus', () => { - const { window, calls, setFocused } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => true, - }; - - setFocused(true); - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - isWindowsPlatform: false, - } as never); - - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('ensure-level')); - assert.ok(!calls.includes('focus')); -}); - -test('macOS keeps active mpv overlay visible and click-through during tracker refresh', () => { - const { window, calls } = createMainWindowRecorder(); - const osdMessages: string[] = []; - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => true, - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => { - calls.push('tracker-warning'); - }, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - isWindowsPlatform: false, - showOverlayLoadingOsd: (message: string) => { - osdMessages.push(message); - }, - } as never); - - assert.ok(calls.includes('update-bounds')); - assert.ok(calls.includes('sync-layer')); - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('ensure-level')); - assert.ok(calls.includes('enforce-order')); - assert.ok(calls.includes('sync-shortcuts')); - assert.ok(!calls.includes('hide')); - assert.deepEqual(osdMessages, []); -}); - -test('macOS tracked overlay hides when mpv loses foreground', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => false, - }; - - window.show(); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - isWindowsPlatform: false, - } as never); - - assert.ok(calls.includes('update-bounds')); - assert.ok(calls.includes('sync-layer')); - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('always-on-top:false')); - assert.ok(calls.includes('all-workspaces:false:plain')); - assert.ok(calls.includes('hide')); - assert.ok(calls.includes('sync-shortcuts')); - assert.ok(!calls.includes('ensure-level')); - assert.ok(!calls.includes('enforce-order')); - assert.ok(!calls.includes('focus')); - assert.ok(!calls.includes('show')); -}); - -test('macOS keeps visible overlay stable while probing frontmost app after overlay blur', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => false, - isTargetWindowMinimized: () => false, - }; - - window.show(); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - isWindowsPlatform: false, - macOSForegroundProbeActive: true, - } as never); - - assert.ok(calls.includes('update-bounds')); - assert.ok(calls.includes('sync-layer')); - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('ensure-level')); - assert.ok(calls.includes('enforce-order')); - assert.ok(calls.includes('sync-shortcuts')); - assert.ok(!calls.includes('always-on-top:false')); - assert.ok(!calls.includes('hide')); -}); - -test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => { - const { window, calls, setFocused } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => false, - }; - - window.show(); - setFocused(false); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - overlayInteractionActive: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - isWindowsPlatform: false, - } as never); - - assert.ok(calls.includes('update-bounds')); - assert.ok(calls.includes('sync-layer')); - assert.ok(calls.includes('mouse-ignore:false:plain')); - assert.ok(calls.includes('ensure-level')); - assert.ok(calls.includes('enforce-order')); - assert.ok(calls.includes('sync-shortcuts')); - assert.ok(!calls.includes('always-on-top:false')); - assert.ok(!calls.includes('hide')); -}); - -test('macOS lets an active overlay receive mouse input instead of forcing passthrough', () => { - const { window, calls, setFocused } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => false, - }; - - window.show(); - setFocused(false); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - overlayInteractionActive: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - isWindowsPlatform: false, - } as never); - - assert.ok(calls.includes('mouse-ignore:false:plain')); - assert.ok(!calls.includes('mouse-ignore:true:forward')); - assert.ok(!calls.includes('hide')); -}); - -test('macOS focuses an active overlay so lookup trigger keys reach it', () => { - const { window, calls, setFocused } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => false, - }; - - window.show(); - setFocused(false); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - overlayInteractionActive: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - isWindowsPlatform: false, - } as never); - - assert.ok(calls.includes('mouse-ignore:false:plain')); - assert.ok(calls.includes('focus')); - assert.ok(!calls.includes('hide')); -}); +// macOS overlays stay click-through with mouse-move forwarding unless the renderer reports +// interaction, and release their window level (and hide) once mpv loses the foreground. +const macPassiveShown = { + boundsUpdated: true, + layerSynced: true, + mouse: 'passthrough-forward', + level: 'raised', + orderEnforced: true, + shortcutsSynced: true, + hid: false, +} satisfies Partial; + +const macReleasedHidden = { + layerSynced: true, + mouse: 'passthrough-forward', + level: 'released', + allWorkspaces: false, + hid: true, + shortcutsSynced: true, + orderEnforced: false, + focusRequested: false, + shownWith: null, +} satisfies Partial; + +runScenarios([ + { + name: 'macOS tracked visible overlay starts click-through without passively stealing focus', + platform: 'macos', + tracker: tracked(), + expect: { mouse: 'passthrough-forward', shownWith: 'showInactive', focusRequested: false }, + }, + { + name: 'forced mouse passthrough keeps macOS tracked overlay passive while visible', + platform: 'macos', + tracker: tracked(), + overrides: { forceMousePassthrough: true }, + expect: { mouse: 'passthrough-forward', shownWith: 'showInactive', focusRequested: false }, + }, + { + name: 'macOS tracked visible overlay remains click-through even if the overlay had focus', + platform: 'macos', + tracker: tracked({ targetFocused: true }), + focused: true, + expect: { mouse: 'passthrough-forward', level: 'raised', focusRequested: false }, + }, + { + name: 'macOS keeps active mpv overlay visible and click-through during tracker refresh', + platform: 'macos', + tracker: tracked({ targetFocused: true }), + expect: { ...macPassiveShown, osd: [] }, + }, + { + name: 'forced mouse passthrough keeps macOS tracked overlay above active mpv', + platform: 'macos', + tracker: tracked({ targetFocused: true }), + overrides: { forceMousePassthrough: true }, + expect: { mouse: 'passthrough-forward', level: 'raised', orderEnforced: true }, + }, + { + name: 'macOS tracked overlay hides when mpv loses foreground', + platform: 'macos', + tracker: tracked({ targetFocused: false }), + visible: true, + expect: { ...macReleasedHidden, boundsUpdated: true }, + }, + { + name: 'forced mouse passthrough still hides macOS tracked overlay when mpv loses foreground', + platform: 'macos', + tracker: tracked({ targetFocused: false }), + visible: true, + overrides: { forceMousePassthrough: true }, + expect: macReleasedHidden, + }, + { + name: 'macOS keeps visible overlay stable while probing frontmost app after overlay blur', + platform: 'macos', + tracker: tracked({ targetFocused: false, targetMinimized: false }), + visible: true, + overrides: { macOSForegroundProbeActive: true }, + expect: macPassiveShown, + }, + { + // Interaction keeps the overlay up, lets it take mouse input, and focuses it so lookup + // trigger keys reach the renderer. + name: 'macOS active overlay stays visible, receives mouse input, and takes focus after mpv loses foreground', + platform: 'macos', + tracker: tracked({ targetFocused: false }), + visible: true, + overrides: { overlayInteractionActive: true }, + expect: { + ...macPassiveShown, + mouse: 'interactive', + mouseModes: ['interactive'], + focusRequested: true, + }, + }, +]); test('macOS tracked overlay passively reappears when mpv regains foreground', () => { - const { window, calls } = createMainWindowRecorder(); - let targetFocused = false; - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => targetFocused, - }; + const h = createHarness({ + platform: 'macos', + tracker: tracked({ targetFocused: false }), + visible: true, + }); + assertOutcome(h.run(), { hid: true, visible: false }); - window.show(); - calls.length = 0; - - const run = () => - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - isWindowsPlatform: false, - } as never); - - run(); - assert.ok(calls.includes('hide')); - - calls.length = 0; - targetFocused = true; - run(); - - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('ensure-level')); - assert.ok(calls.includes('show-inactive')); - assert.ok(calls.includes('enforce-order')); - assert.ok(!calls.includes('show')); - assert.ok(!calls.includes('focus')); + h.state.tracker = tracked({ targetFocused: true }); + assertOutcome(h.run(), { + mouse: 'passthrough-forward', + level: 'raised', + shownWith: 'showInactive', + orderEnforced: true, + focusRequested: false, + }); }); -test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => { - const { window, calls } = createMainWindowRecorder(); - const osdMessages: string[] = []; - let trackerWarning = false; - const tracker: WindowTrackerStub = { - isTracking: () => false, - getGeometry: () => null, - isTargetWindowFocused: () => true, - }; +// macOS tracker loss (isTracking false) on an already visible overlay: keep it up when there is +// retained geometry or an active focus signal, hide it once mpv is known to be in the background. +const macTrackerLossKept = { + trackerWarning: false, + osd: [], + layerSynced: true, + mouse: 'passthrough-forward', + level: 'raised', + orderEnforced: true, + shortcutsSynced: true, + hid: false, +} satisfies Partial; - window.show(); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: trackerWarning, - setTrackerNotReadyWarningShown: (shown: boolean) => { - trackerWarning = shown; - calls.push(`tracker-warning:${shown}`); - }, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - isWindowsPlatform: false, - showOverlayLoadingOsd: (message: string) => { - osdMessages.push(message); - }, - } as never); - - assert.equal(trackerWarning, false); - assert.ok(calls.includes('sync-layer')); - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('ensure-level')); - assert.ok(calls.includes('sync-shortcuts')); - assert.ok(!calls.includes('hide')); - assert.deepEqual(osdMessages, []); -}); - -test('forced mouse passthrough keeps macOS tracked overlay passive while visible', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - isWindowsPlatform: false, - forceMousePassthrough: true, - } as never); - - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('show-inactive')); - assert.ok(!calls.includes('show')); - assert.ok(!calls.includes('focus')); -}); - -test('Windows keeps visible overlay hidden while tracker is not ready', () => { - const { window, calls } = createMainWindowRecorder(); - let trackerWarning = false; - const tracker: WindowTrackerStub = { - isTracking: () => false, - getGeometry: () => null, - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: trackerWarning, - setTrackerNotReadyWarningShown: (shown: boolean) => { - trackerWarning = shown; - }, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }), - } as never); - - assert.equal(trackerWarning, true); - assert.ok(calls.includes('hide')); - assert.ok(!calls.includes('show')); - assert.ok(!calls.includes('update-bounds')); -}); - -test('Windows preserves visible overlay and rebinds to mpv while tracker transiently loses a non-minimized window', () => { - const { window, calls } = createMainWindowRecorder(); - let tracking = true; - const tracker: WindowTrackerStub = { - isTracking: () => tracking, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => false, - isTargetWindowMinimized: () => false, - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - calls.length = 0; - tracking = false; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - assert.ok(!calls.includes('hide')); - assert.ok(!calls.includes('show')); - assert.ok(!calls.includes('always-on-top:false')); - assert.ok(!calls.includes('move-top')); - assert.ok(calls.includes('mouse-ignore:true:plain')); - assert.ok(calls.includes('sync-windows-z-order')); - assert.ok(!calls.includes('ensure-level')); - assert.ok(calls.includes('sync-shortcuts')); -}); - -test('Windows hides the visible overlay when the tracked window is minimized', () => { - const { window, calls } = createMainWindowRecorder(); - let tracking = true; - const tracker: WindowTrackerStub = { - isTracking: () => tracking, - getGeometry: () => (tracking ? { x: 0, y: 0, width: 1280, height: 720 } : null), - isTargetWindowMinimized: () => !tracking, - }; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - calls.length = 0; - tracking = false; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncWindowsOverlayToMpvZOrder: () => { - calls.push('sync-windows-z-order'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: false, - isWindowsPlatform: true, - } as never); - - assert.ok(calls.includes('hide')); - assert.ok(!calls.includes('sync-windows-z-order')); -}); - -test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => { - const { window, calls } = createMainWindowRecorder(); - let trackerWarning = false; - const osdMessages: string[] = []; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: null, - trackerNotReadyWarningShown: trackerWarning, - setTrackerNotReadyWarningShown: (shown: boolean) => { - trackerWarning = shown; - }, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - showOverlayLoadingOsd: (message: string) => { - osdMessages.push(message); - }, - } as never); - - assert.equal(trackerWarning, true); - assert.deepEqual(osdMessages, ['Overlay loading...']); - assert.ok(calls.includes('hide')); - assert.ok(!calls.includes('show')); - assert.ok(!calls.includes('update-bounds')); -}); - -test('macOS preserves visible overlay during transient tracker loss with retained geometry', () => { - const { window, calls } = createMainWindowRecorder(); - const osdMessages: string[] = []; - let trackerWarning = false; - let tracking = true; - const tracker: WindowTrackerStub = { - isTracking: () => tracking, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), - isTargetWindowFocused: () => true, - }; - - const run = () => - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: trackerWarning, - setTrackerNotReadyWarningShown: (shown: boolean) => { - trackerWarning = shown; - }, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - showOverlayLoadingOsd: (message: string) => { - osdMessages.push(message); - }, - } as never); - - run(); - calls.length = 0; - tracking = false; - - run(); - - assert.equal(trackerWarning, false); - assert.deepEqual(osdMessages, []); - assert.ok(calls.includes('update-bounds')); - assert.ok(calls.includes('sync-layer')); - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('ensure-level')); - assert.ok(calls.includes('enforce-order')); - assert.ok(calls.includes('sync-shortcuts')); - assert.ok(!calls.includes('hide')); - assert.ok(!calls.includes('show')); -}); - -test('macOS hides visible overlay during tracker loss after mpv loses foreground', () => { - const { window, calls } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => false, - getGeometry: () => null, - isTargetWindowFocused: () => false, - isTargetWindowMinimized: () => false, - }; - - window.show(); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - showOverlayLoadingOsd: () => { - calls.push('loading-osd'); - }, - } as never); - - assert.ok(calls.includes('sync-layer')); - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('always-on-top:false')); - assert.ok(calls.includes('all-workspaces:false:plain')); - assert.ok(calls.includes('hide')); - assert.ok(calls.includes('sync-shortcuts')); - assert.ok(!calls.includes('ensure-level')); - assert.ok(!calls.includes('enforce-order')); - assert.ok(!calls.includes('loading-osd')); -}); - -test('macOS keeps a focused overlay visible during tracker loss', () => { - const { window, calls, setFocused } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => false, - getGeometry: () => null, - isTargetWindowFocused: () => false, - isTargetWindowMinimized: () => false, - }; - - window.show(); - setFocused(true); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - showOverlayLoadingOsd: () => { - calls.push('loading-osd'); - }, - } as never); - - assert.ok(calls.includes('sync-layer')); - assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('ensure-level')); - assert.ok(calls.includes('enforce-order')); - assert.ok(calls.includes('sync-shortcuts')); - assert.ok(!calls.includes('hide')); - assert.ok(!calls.includes('loading-osd')); -}); - -test('macOS keeps an interactive overlay visible during tracker loss even when Electron focus drops', () => { - const { window, calls, setFocused } = createMainWindowRecorder(); - const tracker: WindowTrackerStub = { - isTracking: () => false, - getGeometry: () => null, - isTargetWindowFocused: () => false, - isTargetWindowMinimized: () => false, - }; - - window.show(); - setFocused(false); - calls.length = 0; - - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - overlayInteractionActive: true, - mainWindow: window as never, - windowTracker: tracker as never, - trackerNotReadyWarningShown: false, - setTrackerNotReadyWarningShown: () => {}, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - showOverlayLoadingOsd: () => { - calls.push('loading-osd'); - }, - } as never); - - assert.ok(calls.includes('sync-layer')); - assert.ok(calls.includes('mouse-ignore:false:plain')); - assert.ok(calls.includes('ensure-level')); - assert.ok(calls.includes('enforce-order')); - assert.ok(calls.includes('sync-shortcuts')); - assert.ok(!calls.includes('always-on-top:false')); - assert.ok(!calls.includes('hide')); - assert.ok(!calls.includes('loading-osd')); -}); +runScenarios([ + { + name: 'macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', + platform: 'macos', + tracker: lost({ targetFocused: true }), + visible: true, + trackerWarning: true, + expect: macTrackerLossKept, + }, + { + name: 'macOS preserves visible overlay during transient tracker loss with retained geometry', + platform: 'macos', + tracker: tracked({ tracking: false, targetFocused: true }), + visible: true, + trackerWarning: true, + expect: { ...macTrackerLossKept, boundsUpdated: true, shownWith: null }, + }, + { + name: 'macOS hides visible overlay during tracker loss after mpv loses foreground', + platform: 'macos', + tracker: lost({ targetFocused: false, targetMinimized: false }), + visible: true, + expect: { ...macReleasedHidden, osd: [] }, + }, + { + name: 'macOS keeps a focused overlay visible during tracker loss', + platform: 'macos', + tracker: lost({ targetFocused: false, targetMinimized: false }), + visible: true, + focused: true, + expect: macTrackerLossKept, + }, + { + name: 'macOS keeps an interactive overlay visible during tracker loss even when Electron focus drops', + platform: 'macos', + tracker: lost({ targetFocused: false, targetMinimized: false }), + visible: true, + overrides: { overlayInteractionActive: true }, + expect: { ...macTrackerLossKept, mouse: 'interactive' }, + }, +]); test('macOS suppresses immediate repeat loading OSD after tracker recovery until cooldown expires', () => { - const { window } = createMainWindowRecorder(); - const osdMessages: string[] = []; - let trackerWarning = false; - let lastLoadingOsdAtMs: number | null = null; let nowMs = 1_000; - const hiddenTracker: WindowTrackerStub = { - isTracking: () => false, - getGeometry: () => null, - }; - const trackedTracker: WindowTrackerStub = { - isTracking: () => true, - getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + const cooldown = loadingOsdCooldown(() => nowMs); + const h = createHarness({ platform: 'macos' }); + const runWith = (tracker: TrackerState) => { + h.state.tracker = tracker; + h.run(cooldown); }; - const run = (windowTracker: WindowTrackerStub) => - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: windowTracker as never, - trackerNotReadyWarningShown: trackerWarning, - setTrackerNotReadyWarningShown: (shown: boolean) => { - trackerWarning = shown; - }, - updateVisibleOverlayBounds: () => {}, - ensureOverlayWindowLevel: () => {}, - syncPrimaryOverlayWindowLayer: () => {}, - enforceOverlayLayerOrder: () => {}, - syncOverlayShortcuts: () => {}, - isMacOSPlatform: true, - showOverlayLoadingOsd: (message: string) => { - osdMessages.push(message); - }, - shouldShowOverlayLoadingOsd: () => - lastLoadingOsdAtMs === null || nowMs - lastLoadingOsdAtMs >= 5_000, - markOverlayLoadingOsdShown: () => { - lastLoadingOsdAtMs = nowMs; - }, - } as never); - - run(hiddenTracker); - run(trackedTracker); - + runWith(lost()); + runWith(tracked()); nowMs = 2_000; - run(hiddenTracker); - run(trackedTracker); - + runWith(lost()); + runWith(tracked()); nowMs = 6_500; - run(hiddenTracker); + runWith(lost()); - assert.deepEqual(osdMessages, ['Overlay loading...', 'Overlay loading...']); -}); - -test('setVisibleOverlayVisible does not mutate mpv subtitle visibility directly', () => { - const calls: string[] = []; - setVisibleOverlayVisible({ - visible: true, - setVisibleOverlayVisibleState: (visible) => { - calls.push(`state:${visible}`); - }, - updateVisibleOverlayVisibility: () => { - calls.push('update'); - }, - }); - - assert.deepEqual(calls, ['state:true', 'update']); + assert.deepEqual(h.state.osd, [LOADING_OSD, LOADING_OSD]); }); test('macOS explicit hide resets loading OSD suppression before retry', () => { - const { window, calls } = createMainWindowRecorder(); - const osdMessages: string[] = []; - let trackerWarning = false; - let lastLoadingOsdAtMs: number | null = null; let nowMs = 1_000; + const cooldown = loadingOsdCooldown(() => nowMs); + const h = createHarness({ platform: 'macos', tracker: null }); - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: null, - trackerNotReadyWarningShown: trackerWarning, - setTrackerNotReadyWarningShown: (shown: boolean) => { - trackerWarning = shown; - calls.push(`warn:${shown ? 'yes' : 'no'}`); - }, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - showOverlayLoadingOsd: (message: string) => { - osdMessages.push(message); - }, - shouldShowOverlayLoadingOsd: () => - lastLoadingOsdAtMs === null || nowMs - lastLoadingOsdAtMs >= 5_000, - markOverlayLoadingOsdShown: () => { - lastLoadingOsdAtMs = nowMs; - }, - resetOverlayLoadingOsdSuppression: () => { - lastLoadingOsdAtMs = null; - }, - } as never); - + h.run(cooldown); nowMs = 1_500; - updateVisibleOverlayVisibility({ - visibleOverlayVisible: false, - mainWindow: window as never, - windowTracker: null, - trackerNotReadyWarningShown: trackerWarning, - setTrackerNotReadyWarningShown: (shown: boolean) => { - trackerWarning = shown; - calls.push(`warn:${shown ? 'yes' : 'no'}`); - }, - updateVisibleOverlayBounds: () => {}, - ensureOverlayWindowLevel: () => {}, - syncPrimaryOverlayWindowLayer: () => {}, - enforceOverlayLayerOrder: () => {}, - syncOverlayShortcuts: () => {}, - isMacOSPlatform: true, - showOverlayLoadingOsd: () => {}, - resetOverlayLoadingOsdSuppression: () => { - lastLoadingOsdAtMs = null; - }, - } as never); + assertOutcome(h.run({ ...cooldown, visibleOverlayVisible: false }), { + trackerWarning: false, + dismissals: 1, + hid: true, + }); + h.run(cooldown); - updateVisibleOverlayVisibility({ - visibleOverlayVisible: true, - mainWindow: window as never, - windowTracker: null, - trackerNotReadyWarningShown: trackerWarning, - setTrackerNotReadyWarningShown: (shown: boolean) => { - trackerWarning = shown; - calls.push(`warn:${shown ? 'yes' : 'no'}`); - }, - updateVisibleOverlayBounds: () => { - calls.push('update-bounds'); - }, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - syncPrimaryOverlayWindowLayer: () => { - calls.push('sync-layer'); - }, - enforceOverlayLayerOrder: () => { - calls.push('enforce-order'); - }, - syncOverlayShortcuts: () => { - calls.push('sync-shortcuts'); - }, - isMacOSPlatform: true, - showOverlayLoadingOsd: (message: string) => { - osdMessages.push(message); - }, - shouldShowOverlayLoadingOsd: () => - lastLoadingOsdAtMs === null || nowMs - lastLoadingOsdAtMs >= 5_000, - markOverlayLoadingOsdShown: () => { - lastLoadingOsdAtMs = nowMs; - }, - resetOverlayLoadingOsdSuppression: () => { - lastLoadingOsdAtMs = null; - }, - } as never); - - assert.deepEqual(osdMessages, ['Overlay loading...', 'Overlay loading...']); + assert.deepEqual(h.state.osd, [LOADING_OSD, LOADING_OSD]); }); diff --git a/src/core/services/overlay-window-config.test.ts b/src/core/services/overlay-window-config.test.ts index e0ad3cc8..968bb862 100644 --- a/src/core/services/overlay-window-config.test.ts +++ b/src/core/services/overlay-window-config.test.ts @@ -2,19 +2,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { buildOverlayWindowOptions } from './overlay-window-options'; -test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => { - const options = buildOverlayWindowOptions('visible', { - isDev: false, - yomitanSession: null, - }); - - assert.equal(options.title, 'SubMiner Overlay'); - assert.equal(options.backgroundColor, '#00000000'); - assert.equal(options.paintWhenInitiallyHidden, true); - assert.equal(options.webPreferences?.sandbox, false); - assert.equal(options.webPreferences?.backgroundThrottling, false); -}); - test('macOS modal overlay uses a fullscreen auxiliary panel without changing the passive overlay', () => { const visibleOptions = buildOverlayWindowOptions('visible', { isDev: false, @@ -31,117 +18,50 @@ test('macOS modal overlay uses a fullscreen auxiliary panel without changing the assert.equal(modalOptions.type, 'panel'); }); -test('non-macOS modal overlay remains a regular window', () => { - const options = buildOverlayWindowOptions('modal', { +test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => { + const visibleOptions = buildOverlayWindowOptions('visible', { + isDev: false, + platform: 'linux', + yomitanSession: null, + }); + const modalOptions = buildOverlayWindowOptions('modal', { isDev: false, platform: 'linux', yomitanSession: null, }); - assert.equal(options.type, undefined); - assert.equal(options.roundedCorners, false); -}); - -test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => { - const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'linux', - }); - - try { - const visibleOptions = buildOverlayWindowOptions('visible', { - isDev: false, - yomitanSession: null, - }); - const modalOptions = buildOverlayWindowOptions('modal', { - isDev: false, - yomitanSession: null, - }); - - assert.equal(visibleOptions.resizable, true); - assert.equal(modalOptions.resizable, false); - } finally { - if (originalPlatformDescriptor) { - Object.defineProperty(process, 'platform', originalPlatformDescriptor); - } - } + assert.equal(visibleOptions.resizable, true); + assert.equal(modalOptions.resizable, false); }); test('Linux visible overlay window stays managed so native apps can cover it', () => { - const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'linux', + const visibleOptions = buildOverlayWindowOptions('visible', { + isDev: false, + platform: 'linux', + yomitanSession: null, + }); + const modalOptions = buildOverlayWindowOptions('modal', { + isDev: false, + platform: 'linux', + yomitanSession: null, }); - try { - const visibleOptions = buildOverlayWindowOptions('visible', { - isDev: false, - yomitanSession: null, - }); - const modalOptions = buildOverlayWindowOptions('modal', { - isDev: false, - yomitanSession: null, - }); - - assert.equal(visibleOptions.alwaysOnTop, false); - assert.equal(visibleOptions.focusable, true); - assert.equal(modalOptions.focusable, true); - } finally { - if (originalPlatformDescriptor) { - Object.defineProperty(process, 'platform', originalPlatformDescriptor); - } - } + assert.equal(visibleOptions.alwaysOnTop, false); + assert.equal(visibleOptions.focusable, true); + assert.equal(modalOptions.focusable, true); }); test('Linux fullscreen visible overlay window uses X11 override-redirect-friendly options', () => { - const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'linux', + const visibleOptions = buildOverlayWindowOptions('visible', { + isDev: false, + platform: 'linux', + linuxX11FullscreenOverlay: true, + yomitanSession: null, }); - try { - const visibleOptions = buildOverlayWindowOptions('visible', { - isDev: false, - linuxX11FullscreenOverlay: true, - yomitanSession: null, - }); - - assert.equal(visibleOptions.alwaysOnTop, true); - assert.equal(visibleOptions.focusable, false); - assert.equal(visibleOptions.resizable, false); - } finally { - if (originalPlatformDescriptor) { - Object.defineProperty(process, 'platform', originalPlatformDescriptor); - } - } -}); - -test('Windows visible overlay window config does not start as always-on-top', () => { - const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - - Object.defineProperty(process, 'platform', { - configurable: true, - value: 'win32', - }); - - try { - const options = buildOverlayWindowOptions('visible', { - isDev: false, - yomitanSession: null, - }); - - assert.equal(options.alwaysOnTop, false); - } finally { - if (originalPlatformDescriptor) { - Object.defineProperty(process, 'platform', originalPlatformDescriptor); - } - } + assert.equal(visibleOptions.alwaysOnTop, true); + assert.equal(visibleOptions.focusable, false); + assert.equal(visibleOptions.resizable, false); }); test('overlay window config uses the provided Yomitan session when available', () => { diff --git a/src/core/services/overlay-window.test.ts b/src/core/services/overlay-window.test.ts index 2d38c042..396843e4 100644 --- a/src/core/services/overlay-window.test.ts +++ b/src/core/services/overlay-window.test.ts @@ -133,26 +133,6 @@ test('handleOverlayWindowBlurred skips visible overlay restacking after manual h assert.deepEqual(calls, []); }); -test('handleOverlayWindowBlurred skips Windows visible overlay restacking after focus loss', () => { - const calls: string[] = []; - - const handled = handleOverlayWindowBlurred({ - kind: 'visible', - windowVisible: true, - isOverlayVisible: () => true, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - moveWindowTop: () => { - calls.push('move-top'); - }, - platform: 'win32', - }); - - assert.equal(handled, false); - assert.deepEqual(calls, []); -}); - test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback without restacking', () => { const calls: string[] = []; @@ -176,92 +156,6 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback assert.deepEqual(calls, ['visible-blur']); }); -test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => { - const calls: string[] = []; - - const handled = handleOverlayWindowBlurred({ - kind: 'visible', - windowVisible: true, - isOverlayVisible: () => true, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - moveWindowTop: () => { - calls.push('move-top'); - }, - platform: 'darwin', - }); - - assert.equal(handled, false); - assert.deepEqual(calls, []); -}); - -test('handleOverlayWindowBlurred skips Linux visible overlay restacking after focus loss', () => { - const calls: string[] = []; - - const handled = handleOverlayWindowBlurred({ - kind: 'visible', - windowVisible: true, - isOverlayVisible: () => true, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - moveWindowTop: () => { - calls.push('move-top'); - }, - platform: 'linux', - }); - - assert.equal(handled, false); - assert.deepEqual(calls, []); -}); - -test('handleOverlayWindowBlurred notifies Linux visible overlay blur callback without restacking', () => { - const calls: string[] = []; - - const handled = handleOverlayWindowBlurred({ - kind: 'visible', - windowVisible: true, - isOverlayVisible: () => true, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - moveWindowTop: () => { - calls.push('move-top'); - }, - onVisibleOverlayBlur: () => { - calls.push('visible-blur'); - }, - platform: 'linux', - }); - - assert.equal(handled, false); - assert.deepEqual(calls, ['visible-blur']); -}); - -test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => { - const calls: string[] = []; - - const handled = handleOverlayWindowBlurred({ - kind: 'visible', - windowVisible: true, - isOverlayVisible: () => true, - ensureOverlayWindowLevel: () => { - calls.push('ensure-level'); - }, - moveWindowTop: () => { - calls.push('move-top'); - }, - onVisibleOverlayBlur: () => { - calls.push('visible-blur'); - }, - platform: 'darwin', - }); - - assert.equal(handled, false); - assert.deepEqual(calls, ['visible-blur']); -}); - test('handleOverlayWindowBlurred preserves modal window stacking', () => { const calls: string[] = []; diff --git a/src/core/services/secondary-subtitle-sidecar.test.ts b/src/core/services/secondary-subtitle-sidecar.test.ts new file mode 100644 index 00000000..966618e1 --- /dev/null +++ b/src/core/services/secondary-subtitle-sidecar.test.ts @@ -0,0 +1,108 @@ +import { afterEach, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + clearRetimedSecondarySubtitleCache, + resolveRetimedSecondarySubtitleTextFromSidecar, + type RetimedSubtitleCommandRunner, +} from './secondary-subtitle-sidecar.js'; + +const ORIGINAL_ENGLISH = `1 +00:00:09,000 --> 00:00:10,000 +Stale English subtitle +`; + +const ALIGNED_ENGLISH = `1 +00:00:01,000 --> 00:00:02,000 +Aligned English subtitle +`; + +type SidecarFixture = { + sourcePath: string; + japanesePath: string; + englishPath: string; + alassPath: string; +}; + +/** Writes an episode with a correctly timed Japanese sidecar and a mistimed English one. */ +async function withSidecarFixture(fn: (fixture: SidecarFixture) => Promise): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-secondary-sidecar-test-')); + try { + const fixture = { + sourcePath: path.join(dir, 'episode.mkv'), + japanesePath: path.join(dir, 'episode.ja.srt'), + englishPath: path.join(dir, 'episode.en.srt'), + alassPath: path.join(dir, 'alass-cli'), + }; + fs.writeFileSync(fixture.sourcePath, 'fake media'); + fs.writeFileSync(fixture.alassPath, 'fake alass'); + fs.writeFileSync(fixture.japanesePath, '1\n00:00:01,000 --> 00:00:02,000\n猫を見た\n'); + fs.writeFileSync(fixture.englishPath, ORIGINAL_ENGLISH); + await fn(fixture); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +afterEach(() => { + clearRetimedSecondarySubtitleCache(); +}); + +it('retimes secondary sidecar subtitles against the Japanese sidecar and caches the output', async () => { + await withSidecarFixture(async ({ sourcePath, japanesePath, englishPath, alassPath }) => { + let alassRuns = 0; + const input = { sourcePath, startMs: 1_000, endMs: 2_000, alassPath }; + + const first = await resolveRetimedSecondarySubtitleTextFromSidecar({ + ...input, + runAlass: async (_alassPath, referencePath, inputPath, outputPath) => { + alassRuns += 1; + assert.equal(referencePath, japanesePath); + assert.equal(inputPath, englishPath); + fs.writeFileSync(outputPath, ALIGNED_ENGLISH); + return { ok: true, code: 0, stdout: '', stderr: '' }; + }, + }); + const second = await resolveRetimedSecondarySubtitleTextFromSidecar({ + ...input, + runAlass: async () => { + alassRuns += 1; + return { ok: false, code: 1, stdout: '', stderr: 'should use cache' }; + }, + }); + + assert.equal(first, 'Aligned English subtitle'); + assert.equal(second, 'Aligned English subtitle'); + assert.equal(alassRuns, 1); + assert.equal(fs.readFileSync(englishPath, 'utf8'), ORIGINAL_ENGLISH); + }); +}); + +it('shares in-flight retimed secondary subtitle work for concurrent requests', async () => { + await withSidecarFixture(async ({ sourcePath, alassPath }) => { + let alassRuns = 0; + let releaseAlass!: () => void; + const alassGate = new Promise((resolve) => { + releaseAlass = resolve; + }); + const runAlass: RetimedSubtitleCommandRunner = async (_alass, _reference, _input, output) => { + alassRuns += 1; + await alassGate; + fs.writeFileSync(output, ALIGNED_ENGLISH); + return { ok: true, code: 0, stdout: '', stderr: '' }; + }; + const input = { sourcePath, startMs: 1_000, endMs: 2_000, alassPath, runAlass }; + + const first = resolveRetimedSecondarySubtitleTextFromSidecar(input); + const second = resolveRetimedSecondarySubtitleTextFromSidecar(input); + releaseAlass(); + + assert.deepEqual(await Promise.all([first, second]), [ + 'Aligned English subtitle', + 'Aligned English subtitle', + ]); + assert.equal(alassRuns, 1); + }); +}); diff --git a/src/core/services/session-actions.test.ts b/src/core/services/session-actions.test.ts index b6216d01..c1d0c75c 100644 --- a/src/core/services/session-actions.test.ts +++ b/src/core/services/session-actions.test.ts @@ -81,17 +81,3 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops', assert.deepEqual(calls, ['mark-watched']); }); - -test('dispatchSessionAction opens the character dictionary manager', async () => { - const { calls, deps } = createDeps(); - - await dispatchSessionAction({ actionId: 'openCharacterDictionaryManager' }, deps); - - assert.deepEqual(calls, ['character-dictionary-manager']); -}); - -test('dispatchSessionAction opens subtitle generation without opening the sidebar', async () => { - const { calls, deps } = createDeps(); - await dispatchSessionAction({ actionId: 'openSubtitleGeneration' }, deps); - assert.deepEqual(calls, ['subtitle-generation']); -}); diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts index 4141a9c7..9d1cb7f1 100644 --- a/src/core/services/session-bindings.test.ts +++ b/src/core/services/session-bindings.test.ts @@ -275,67 +275,6 @@ test('compileSessionBindings drops conflicting bindings that canonicalize to the ]); }); -test('compileSessionBindings keeps default replay and next subtitle session actions on Linux', () => { - const result = compileSessionBindings({ - shortcuts: resolveConfiguredShortcuts(DEFAULT_CONFIG, DEFAULT_CONFIG), - keybindings: DEFAULT_KEYBINDINGS, - statsToggleKey: DEFAULT_CONFIG.stats.toggleKey, - platform: 'linux', - rawConfig: DEFAULT_CONFIG, - }); - - assert.deepEqual( - result.warnings.filter((warning) => warning.kind === 'conflict'), - [], - ); - const bySignature = new Map( - result.bindings.map((binding) => [ - `${binding.key.modifiers.join('+')}+${binding.key.code}`, - binding, - ]), - ); - - const replay = bySignature.get('ctrl+shift+KeyH'); - assert.equal(replay?.actionType, 'session-action'); - assert.equal(replay?.actionId, 'replayCurrentSubtitle'); - - const next = bySignature.get('ctrl+shift+KeyL'); - assert.equal(next?.actionType, 'session-action'); - assert.equal(next?.actionId, 'playNextSubtitle'); -}); - -test('compileSessionBindings keeps only the character dictionary manager bound by default', () => { - const result = compileSessionBindings({ - shortcuts: resolveConfiguredShortcuts(DEFAULT_CONFIG, DEFAULT_CONFIG), - keybindings: DEFAULT_KEYBINDINGS, - statsToggleKey: DEFAULT_CONFIG.stats.toggleKey, - platform: 'linux', - rawConfig: DEFAULT_CONFIG, - }); - - const characterDictionaryBindings = result.bindings.flatMap((binding) => { - if (binding.actionType !== 'session-action') return []; - if (binding.actionId !== 'openCharacterDictionaryManager') { - return []; - } - return [ - { - sourcePath: binding.sourcePath, - originalKey: binding.originalKey, - actionId: binding.actionId, - }, - ]; - }); - - assert.deepEqual(characterDictionaryBindings, [ - { - sourcePath: 'shortcuts.openCharacterDictionaryManager', - originalKey: 'CommandOrControl+D', - actionId: 'openCharacterDictionaryManager', - }, - ]); -}); - test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => { const expectedSpecialActions: Record = { [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker', diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 66ebe76e..ff8d8bcc 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -78,19 +78,14 @@ function makeArgs(overrides: Partial = {}): CliArgs { }; } -test('runStartupBootstrapRuntime configures startup state and starts lifecycle', () => { +function runBootstrap( + argsOverrides: Partial = {}, + depOverrides: Partial[0]> = {}, +) { const calls: string[] = []; - const args = makeArgs({ - logLevel: 'debug', - socketPath: '/tmp/custom.sock', - texthookerPort: 9001, - backend: 'x11', - autoStartOverlay: true, - texthooker: true, - }); - + const args = makeArgs(argsOverrides); const result = runStartupBootstrapRuntime({ - argv: ['node', 'main.ts', '--log-level', 'debug'], + argv: ['node', 'main.ts'], parseArgs: () => args, setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), forceX11Backend: () => calls.push('forceX11'), @@ -99,6 +94,19 @@ test('runStartupBootstrapRuntime configures startup state and starts lifecycle', defaultTexthookerPort: 5174, runGenerateConfigFlow: () => false, startAppLifecycle: () => calls.push('startLifecycle'), + ...depOverrides, + }); + return { args, result, calls }; +} + +test('runStartupBootstrapRuntime maps CLI args to startup state', () => { + const { args, result } = runBootstrap({ + socketPath: '/tmp/custom.sock', + texthookerPort: 9001, + backend: 'x11', + autoStartOverlay: true, + texthooker: true, + background: true, }); assert.equal(result.initialArgs, args); @@ -107,141 +115,59 @@ test('runStartupBootstrapRuntime configures startup state and starts lifecycle', assert.equal(result.backendOverride, 'x11'); assert.equal(result.autoStartOverlay, true); assert.equal(result.texthookerOnlyMode, true); - assert.equal(result.backgroundMode, false); - assert.deepEqual(calls, ['setLog:debug:cli', 'forceX11', 'enforceWayland', 'startLifecycle']); + assert.equal(result.backgroundMode, true); }); -test('runStartupBootstrapRuntime keeps log-level precedence for repeated calls', () => { - const calls: string[] = []; - const args = makeArgs({ - logLevel: 'warn', - }); - - runStartupBootstrapRuntime({ - argv: ['node', 'main.ts', '--log-level', 'warn'], - parseArgs: () => args, - setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), - forceX11Backend: () => calls.push('forceX11'), - enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'), - getDefaultSocketPath: () => '/tmp/default.sock', - defaultTexthookerPort: 5174, - runGenerateConfigFlow: () => false, - startAppLifecycle: () => calls.push('startLifecycle'), - }); - - assert.deepEqual(calls.slice(0, 3), ['setLog:warn:cli', 'forceX11', 'enforceWayland']); -}); - -test('runStartupBootstrapRuntime remains lifecycle-stable with Jellyfin CLI flags', () => { - const calls: string[] = []; - const args = makeArgs({ - jellyfin: true, - jellyfinLibraries: true, - socketPath: '/tmp/stable.sock', - texthookerPort: 8888, - }); - - const result = runStartupBootstrapRuntime({ - argv: ['node', 'main.ts', '--jellyfin', '--jellyfin-libraries'], - parseArgs: () => args, - setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), - forceX11Backend: () => calls.push('forceX11'), - enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'), - getDefaultSocketPath: () => '/tmp/default.sock', - defaultTexthookerPort: 5174, - runGenerateConfigFlow: () => false, - startAppLifecycle: () => calls.push('startLifecycle'), - }); - - assert.equal(result.mpvSocketPath, '/tmp/stable.sock'); - assert.equal(result.texthookerPort, 8888); - assert.equal(result.backendOverride, null); - assert.equal(result.autoStartOverlay, false); - assert.equal(result.texthookerOnlyMode, false); - assert.equal(result.backgroundMode, false); - assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']); -}); - -test('runStartupBootstrapRuntime keeps --debug separate from log verbosity', () => { - const calls: string[] = []; - const args = makeArgs({ - debug: true, - }); - - runStartupBootstrapRuntime({ - argv: ['node', 'main.ts', '--debug'], - parseArgs: () => args, - setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), - forceX11Backend: () => calls.push('forceX11'), - enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'), - getDefaultSocketPath: () => '/tmp/default.sock', - defaultTexthookerPort: 5174, - runGenerateConfigFlow: () => false, - startAppLifecycle: () => calls.push('startLifecycle'), - }); - - assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']); -}); - -test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handled', () => { - const calls: string[] = []; - const args = makeArgs({ generateConfig: true, logLevel: 'warn' }); - - const result = runStartupBootstrapRuntime({ - argv: ['node', 'main.ts', '--generate-config'], - parseArgs: () => args, - setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), - forceX11Backend: () => calls.push('forceX11'), - enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'), - getDefaultSocketPath: () => '/tmp/default.sock', - defaultTexthookerPort: 5174, - runGenerateConfigFlow: () => true, - startAppLifecycle: () => calls.push('startLifecycle'), - }); +test('runStartupBootstrapRuntime falls back to defaults when args are unset', () => { + const { result } = runBootstrap(); assert.equal(result.mpvSocketPath, '/tmp/default.sock'); assert.equal(result.texthookerPort, 5174); assert.equal(result.backendOverride, null); + assert.equal(result.autoStartOverlay, false); + assert.equal(result.texthookerOnlyMode, false); assert.equal(result.backgroundMode, false); - assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']); }); -test('runStartupBootstrapRuntime lets config govern background log level by default', () => { - const calls: string[] = []; - const args = makeArgs({ background: true }); +const logLevelCases: Array<{ name: string; args: Partial; expected: string[] }> = [ + { + name: '--log-level applies the requested level', + args: { logLevel: 'debug' }, + expected: ['setLog:debug:cli'], + }, + { name: '--update defaults to warn', args: { update: true }, expected: ['setLog:warn:cli'] }, + { + name: '--log-level wins over --update', + args: { update: true, logLevel: 'info' }, + expected: ['setLog:info:cli'], + }, + { name: '--debug leaves log verbosity alone', args: { debug: true }, expected: [] }, + { name: '--background leaves log level to config', args: { background: true }, expected: [] }, +]; - const result = runStartupBootstrapRuntime({ - argv: ['node', 'main.ts', '--background'], - parseArgs: () => args, - setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), - forceX11Backend: () => calls.push('forceX11'), - enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'), - getDefaultSocketPath: () => '/tmp/default.sock', - defaultTexthookerPort: 5174, - runGenerateConfigFlow: () => false, - startAppLifecycle: () => calls.push('startLifecycle'), +for (const c of logLevelCases) { + test(`runStartupBootstrapRuntime log level: ${c.name}`, () => { + const { calls } = runBootstrap(c.args); + + assert.deepEqual( + calls.filter((call) => call.startsWith('setLog:')), + c.expected, + ); }); +} - assert.equal(result.backgroundMode, true); - assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']); +test('runStartupBootstrapRuntime applies log level and platform setup before starting lifecycle', () => { + const { calls } = runBootstrap({ logLevel: 'debug' }); + + const index = (call: string) => calls.indexOf(call); + assert.ok(index('setLog:debug:cli') >= 0); + assert.ok(index('setLog:debug:cli') < index('forceX11')); + assert.ok(index('forceX11') < index('startLifecycle')); + assert.ok(index('enforceWayland') < index('startLifecycle')); }); -test('runStartupBootstrapRuntime enables quiet update mode by default', () => { - const calls: string[] = []; - const args = makeArgs({ update: true }); +test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handled', () => { + const { calls } = runBootstrap({ generateConfig: true }, { runGenerateConfigFlow: () => true }); - const result = runStartupBootstrapRuntime({ - argv: ['node', 'main.ts', '--update'], - parseArgs: () => args, - setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`), - forceX11Backend: () => calls.push('forceX11'), - enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'), - getDefaultSocketPath: () => '/tmp/default.sock', - defaultTexthookerPort: 5174, - runGenerateConfigFlow: () => false, - startAppLifecycle: () => calls.push('startLifecycle'), - }); - - assert.equal(result.backgroundMode, false); - assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']); + assert.equal(calls.includes('startLifecycle'), false); }); diff --git a/src/core/services/startup.test.ts b/src/core/services/startup.test.ts index cfba8def..e08948a5 100644 --- a/src/core/services/startup.test.ts +++ b/src/core/services/startup.test.ts @@ -1,447 +1,126 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { runAppReadyRuntime } from './startup'; +import { runAppReadyRuntime, type AppReadyRuntimeDeps } from './startup'; -test('runAppReadyRuntime minimal startup skips Yomitan and first-run setup while still handling CLI args', async () => { +// Every dep records its name in `calls`; overrides replace individual deps. +function createAppReadyDeps(overrides: Partial = {}) { const calls: string[] = []; - - await runAppReadyRuntime({ - ensureDefaultConfigBootstrap: () => { - calls.push('bootstrap'); - }, - loadSubtitlePosition: () => { - calls.push('load-subtitle-position'); - }, - resolveKeybindings: () => { - calls.push('resolve-keybindings'); - }, - createMpvClient: () => { - calls.push('create-mpv'); - }, - reloadConfig: () => { - calls.push('reload-config'); - }, - getResolvedConfig: () => ({}), - getConfigWarnings: () => [], - logConfigWarning: () => { - calls.push('config-warning'); - }, - setLogLevel: () => { - calls.push('set-log-level'); - }, - initRuntimeOptionsManager: () => { - calls.push('init-runtime-options'); - }, - setSecondarySubMode: () => { - calls.push('set-secondary-sub-mode'); - }, - defaultSecondarySubMode: 'hover', - defaultWebsocketPort: 0, - defaultAnnotationWebsocketPort: 0, - defaultTexthookerPort: 0, - hasMpvWebsocketPlugin: () => false, - startSubtitleWebsocket: () => { - calls.push('subtitle-ws'); - }, - startAnnotationWebsocket: () => { - calls.push('annotation-ws'); - }, - startTexthooker: () => { - calls.push('texthooker'); - }, - log: () => { - calls.push('log'); - }, - createMecabTokenizerAndCheck: async () => { - calls.push('mecab'); - }, - createSubtitleTimingTracker: () => { - calls.push('subtitle-timing'); - }, - createImmersionTracker: () => { - calls.push('immersion'); - }, - startJellyfinRemoteSession: async () => { - calls.push('jellyfin'); - }, - loadYomitanExtension: async () => { - calls.push('load-yomitan'); - }, - handleFirstRunSetup: async () => { - calls.push('first-run'); - }, - prewarmSubtitleDictionaries: async () => { - calls.push('prewarm'); - }, - startBackgroundWarmups: () => { - calls.push('warmups'); - }, - texthookerOnlyMode: false, - shouldAutoInitializeOverlayRuntimeFromConfig: () => false, - setVisibleOverlayVisible: () => { - calls.push('visible-overlay'); - }, - initializeOverlayRuntime: () => { - calls.push('init-overlay'); - }, - handleInitialArgs: () => { - calls.push('handle-initial-args'); - }, - shouldUseMinimalStartup: () => true, - shouldSkipHeavyStartup: () => false, - }); - - assert.deepEqual(calls, ['bootstrap', 'reload-config', 'handle-initial-args']); -}); - -test('runAppReadyRuntime headless refresh bootstraps Anki runtime without UI startup', async () => { - const calls: string[] = []; - - await runAppReadyRuntime({ - ensureDefaultConfigBootstrap: () => { - calls.push('bootstrap'); - }, - loadSubtitlePosition: () => { - calls.push('load-subtitle-position'); - }, - resolveKeybindings: () => { - calls.push('resolve-keybindings'); - }, - createMpvClient: () => { - calls.push('create-mpv'); - }, - reloadConfig: () => { - calls.push('reload-config'); - }, - getResolvedConfig: () => ({}), - getConfigWarnings: () => [], - logConfigWarning: () => { - calls.push('config-warning'); - }, - setLogLevel: () => { - calls.push('set-log-level'); - }, - initRuntimeOptionsManager: () => { - calls.push('init-runtime-options'); - }, - setSecondarySubMode: () => { - calls.push('set-secondary-sub-mode'); - }, - defaultSecondarySubMode: 'hover', - defaultWebsocketPort: 0, - defaultAnnotationWebsocketPort: 0, - defaultTexthookerPort: 0, - hasMpvWebsocketPlugin: () => false, - startSubtitleWebsocket: () => { - calls.push('subtitle-ws'); - }, - startAnnotationWebsocket: () => { - calls.push('annotation-ws'); - }, - startTexthooker: () => { - calls.push('texthooker'); - }, - log: () => { - calls.push('log'); - }, - createMecabTokenizerAndCheck: async () => { - calls.push('mecab'); - }, - createSubtitleTimingTracker: () => { - calls.push('subtitle-timing'); - }, - createImmersionTracker: () => { - calls.push('immersion'); - }, - startJellyfinRemoteSession: async () => { - calls.push('jellyfin'); - }, - loadYomitanExtension: async () => { - calls.push('load-yomitan'); - }, - handleFirstRunSetup: async () => { - calls.push('first-run'); - }, - prewarmSubtitleDictionaries: async () => { - calls.push('prewarm'); - }, - startBackgroundWarmups: () => { - calls.push('warmups'); - }, - texthookerOnlyMode: false, - shouldAutoInitializeOverlayRuntimeFromConfig: () => false, - setVisibleOverlayVisible: () => { - calls.push('visible-overlay'); - }, - initializeOverlayRuntime: () => { - calls.push('init-overlay'); - }, - runHeadlessInitialCommand: async () => { - calls.push('run-headless-command'); - }, - handleInitialArgs: () => { - calls.push('handle-initial-args'); - }, - shouldRunHeadlessInitialCommand: () => true, - shouldUseMinimalStartup: () => false, - shouldSkipHeavyStartup: () => false, - }); - - assert.deepEqual(calls, [ - 'bootstrap', - 'reload-config', - 'init-runtime-options', - 'run-headless-command', - ]); -}); - -test('runAppReadyRuntime loads Yomitan before headless overlay fallback initialization', async () => { - const calls: string[] = []; - - await runAppReadyRuntime({ - ensureDefaultConfigBootstrap: () => { - calls.push('bootstrap'); - }, - loadSubtitlePosition: () => { - calls.push('load-subtitle-position'); - }, - resolveKeybindings: () => { - calls.push('resolve-keybindings'); - }, - createMpvClient: () => { - calls.push('create-mpv'); - }, - reloadConfig: () => { - calls.push('reload-config'); - }, - getResolvedConfig: () => ({}), - getConfigWarnings: () => [], - logConfigWarning: () => {}, - setLogLevel: () => {}, - initRuntimeOptionsManager: () => { - calls.push('init-runtime-options'); - }, - setSecondarySubMode: () => {}, - defaultSecondarySubMode: 'hover', - defaultWebsocketPort: 0, - defaultAnnotationWebsocketPort: 0, - defaultTexthookerPort: 0, - hasMpvWebsocketPlugin: () => false, - startSubtitleWebsocket: () => {}, - startAnnotationWebsocket: () => {}, - startTexthooker: () => {}, - log: () => {}, - createMecabTokenizerAndCheck: async () => {}, - createSubtitleTimingTracker: () => { - calls.push('subtitle-timing'); - }, - createImmersionTracker: () => {}, - startJellyfinRemoteSession: async () => {}, - loadYomitanExtension: async () => { - calls.push('load-yomitan'); - }, - handleFirstRunSetup: async () => {}, - prewarmSubtitleDictionaries: async () => {}, - startBackgroundWarmups: () => {}, - texthookerOnlyMode: false, - shouldAutoInitializeOverlayRuntimeFromConfig: () => false, - setVisibleOverlayVisible: () => {}, - initializeOverlayRuntime: () => { - calls.push('init-overlay'); - }, - handleInitialArgs: () => { - calls.push('handle-initial-args'); - }, - shouldRunHeadlessInitialCommand: () => true, - shouldUseMinimalStartup: () => false, - shouldSkipHeavyStartup: () => false, - }); - - assert.deepEqual(calls, [ - 'bootstrap', - 'reload-config', - 'init-runtime-options', - 'create-mpv', - 'subtitle-timing', - 'load-yomitan', - 'init-overlay', - 'handle-initial-args', - ]); -}); - -test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yomitan', async () => { - const calls: string[] = []; - - await runAppReadyRuntime({ - ensureDefaultConfigBootstrap: () => { - calls.push('bootstrap'); - }, - loadSubtitlePosition: () => { - calls.push('load-subtitle-position'); - }, - resolveKeybindings: () => { - calls.push('resolve-keybindings'); - }, - createMpvClient: () => { - calls.push('create-mpv'); - }, - reloadConfig: () => { - calls.push('reload-config'); - }, + const record = (name: string) => () => { + calls.push(name); + }; + const recordAsync = (name: string) => async () => { + calls.push(name); + }; + const deps: AppReadyRuntimeDeps = { + ensureDefaultConfigBootstrap: record('bootstrap'), + loadSubtitlePosition: record('load-subtitle-position'), + resolveKeybindings: record('resolve-keybindings'), + createMpvClient: record('create-mpv'), + reloadConfig: record('reload-config'), getResolvedConfig: () => ({ websocket: { enabled: false }, annotationWebsocket: { enabled: false }, texthooker: { launchAtStartup: false }, }), getConfigWarnings: () => [], - logConfigWarning: () => {}, - setLogLevel: () => { - calls.push('set-log-level'); - }, - initRuntimeOptionsManager: () => { - calls.push('init-runtime-options'); - }, - setSecondarySubMode: () => { - calls.push('set-secondary-sub-mode'); - }, + logConfigWarning: record('config-warning'), + setLogLevel: record('set-log-level'), + initRuntimeOptionsManager: record('init-runtime-options'), + setSecondarySubMode: record('set-secondary-sub-mode'), defaultSecondarySubMode: 'hover', defaultWebsocketPort: 0, defaultAnnotationWebsocketPort: 0, defaultTexthookerPort: 0, hasMpvWebsocketPlugin: () => false, - startSubtitleWebsocket: () => { - calls.push('subtitle-ws'); - }, - startAnnotationWebsocket: () => { - calls.push('annotation-ws'); - }, - startTexthooker: () => { - calls.push('texthooker'); - }, - log: () => { - calls.push('log'); - }, - createMecabTokenizerAndCheck: async () => {}, - createSubtitleTimingTracker: () => { - calls.push('subtitle-timing'); - }, - createImmersionTracker: () => { - calls.push('immersion'); - }, - startJellyfinRemoteSession: async () => {}, - loadYomitanExtension: async () => { - calls.push('load-yomitan'); - }, - handleFirstRunSetup: async () => { - calls.push('first-run'); - }, - prewarmSubtitleDictionaries: async () => {}, - startBackgroundWarmups: () => { - calls.push('warmups'); - }, + startSubtitleWebsocket: record('subtitle-ws'), + startAnnotationWebsocket: record('annotation-ws'), + startTexthooker: record('texthooker'), + log: record('log'), + createMecabTokenizerAndCheck: recordAsync('mecab'), + createSubtitleTimingTracker: record('subtitle-timing'), + createImmersionTracker: record('immersion'), + startJellyfinRemoteSession: recordAsync('jellyfin'), + loadYomitanExtension: recordAsync('load-yomitan'), + handleFirstRunSetup: recordAsync('first-run'), + prewarmSubtitleDictionaries: recordAsync('prewarm'), + startBackgroundWarmups: record('warmups'), texthookerOnlyMode: false, - shouldAutoInitializeOverlayRuntimeFromConfig: () => true, - setVisibleOverlayVisible: () => { - calls.push('visible-overlay'); - }, - initializeOverlayRuntime: () => { - calls.push('init-overlay'); - }, - handleInitialArgs: () => { - calls.push('handle-initial-args'); - }, + shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + setVisibleOverlayVisible: record('visible-overlay'), + initializeOverlayRuntime: record('init-overlay'), + handleInitialArgs: record('handle-initial-args'), shouldUseMinimalStartup: () => false, shouldSkipHeavyStartup: () => false, + ...overrides, + }; + return { calls, deps }; +} + +function assertBefore(calls: string[], earlier: string, later: string) { + const earlierIndex = calls.indexOf(earlier); + const laterIndex = calls.indexOf(later); + assert.notEqual(earlierIndex, -1, `${earlier} was not called`); + assert.notEqual(laterIndex, -1, `${later} was not called`); + assert.ok(earlierIndex < laterIndex, `${earlier} should run before ${later}`); +} + +test('runAppReadyRuntime minimal startup skips Yomitan and first-run setup while still handling CLI args', async () => { + const { calls, deps } = createAppReadyDeps({ shouldUseMinimalStartup: () => true }); + + await runAppReadyRuntime(deps); + + assert.ok(calls.includes('handle-initial-args')); + for (const skipped of ['load-yomitan', 'first-run', 'create-mpv', 'init-overlay', 'warmups']) { + assert.equal(calls.includes(skipped), false, `${skipped} should be skipped`); + } +}); + +test('runAppReadyRuntime headless refresh bootstraps Anki runtime without UI startup', async () => { + const { calls, deps } = createAppReadyDeps({ + shouldRunHeadlessInitialCommand: () => true, + runHeadlessInitialCommand: async () => { + calls.push('run-headless-command'); + }, }); - assert.ok(calls.indexOf('init-overlay') !== -1); - assert.ok(calls.indexOf('warmups') !== -1); - assert.ok(calls.indexOf('init-overlay') < calls.indexOf('warmups')); + await runAppReadyRuntime(deps); + + assertBefore(calls, 'init-runtime-options', 'run-headless-command'); + for (const skipped of ['create-mpv', 'load-yomitan', 'init-overlay', 'handle-initial-args']) { + assert.equal(calls.includes(skipped), false, `${skipped} should be skipped`); + } +}); + +test('runAppReadyRuntime loads Yomitan before headless overlay fallback initialization', async () => { + const { calls, deps } = createAppReadyDeps({ shouldRunHeadlessInitialCommand: () => true }); + + await runAppReadyRuntime(deps); + + assertBefore(calls, 'create-mpv', 'subtitle-timing'); + assertBefore(calls, 'load-yomitan', 'init-overlay'); + assertBefore(calls, 'init-overlay', 'handle-initial-args'); +}); + +test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yomitan', async () => { + const { calls, deps } = createAppReadyDeps({ + shouldAutoInitializeOverlayRuntimeFromConfig: () => true, + }); + + await runAppReadyRuntime(deps); + + assertBefore(calls, 'init-overlay', 'warmups'); assert.equal(calls.includes('load-yomitan'), false); }); test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => { - const calls: string[] = []; - - await runAppReadyRuntime({ - ensureDefaultConfigBootstrap: () => { - calls.push('bootstrap'); - }, - loadSubtitlePosition: () => { - calls.push('load-subtitle-position'); - }, - resolveKeybindings: () => { - calls.push('resolve-keybindings'); - }, - createMpvClient: () => { - calls.push('create-mpv'); - }, - reloadConfig: () => { - calls.push('reload-config'); - }, - getResolvedConfig: () => ({ - websocket: { enabled: false }, - annotationWebsocket: { enabled: false }, - texthooker: { launchAtStartup: false }, - }), - getConfigWarnings: () => [], - logConfigWarning: () => {}, - setLogLevel: () => { - calls.push('set-log-level'); - }, - initRuntimeOptionsManager: () => { - calls.push('init-runtime-options'); - }, - setSecondarySubMode: () => { - calls.push('set-secondary-sub-mode'); - }, - defaultSecondarySubMode: 'hover', - defaultWebsocketPort: 0, - defaultAnnotationWebsocketPort: 0, - defaultTexthookerPort: 0, - hasMpvWebsocketPlugin: () => false, - startSubtitleWebsocket: () => {}, - startAnnotationWebsocket: () => {}, - startTexthooker: () => {}, - log: () => { - calls.push('log'); - }, - createMecabTokenizerAndCheck: async () => {}, - createSubtitleTimingTracker: () => { - calls.push('subtitle-timing'); - }, - createImmersionTracker: () => { - calls.push('immersion'); - }, - startJellyfinRemoteSession: async () => {}, - loadYomitanExtension: async () => { - calls.push('load-yomitan-direct'); - }, + const { calls, deps } = createAppReadyDeps({ ensureYomitanExtensionLoaded: async () => { calls.push('load-yomitan-guarded'); }, - handleFirstRunSetup: async () => { - calls.push('first-run'); - }, - prewarmSubtitleDictionaries: async () => {}, - startBackgroundWarmups: () => { - calls.push('warmups'); - }, - texthookerOnlyMode: false, - shouldAutoInitializeOverlayRuntimeFromConfig: () => false, - setVisibleOverlayVisible: () => { - calls.push('visible-overlay'); - }, - initializeOverlayRuntime: () => { - calls.push('init-overlay'); - }, - handleInitialArgs: () => { - calls.push('handle-initial-args'); - }, - shouldUseMinimalStartup: () => false, - shouldSkipHeavyStartup: () => false, }); - assert.equal(calls.includes('load-yomitan-direct'), false); - assert.equal(calls.includes('load-yomitan-guarded'), true); + await runAppReadyRuntime(deps); + + assert.equal(calls.includes('load-yomitan'), false); + assertBefore(calls, 'warmups', 'load-yomitan-guarded'); }); diff --git a/src/core/services/stats-window.test.ts b/src/core/services/stats-window.test.ts index ed123482..4b10bfce 100644 --- a/src/core/services/stats-window.test.ts +++ b/src/core/services/stats-window.test.ts @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildStatsWindowUrl, - buildStatsWindowOptions, buildStatsNativeConfirmDialogOptions, demoteVisibleStatsWindowBelowDialogs, presentStatsWindow, @@ -12,60 +11,8 @@ import { scheduleStatsWindowPostShowReconciles, showStatsNativeConfirmDialog, shouldHideStatsWindowForInput, - shouldPresentStatsWindowAfterLoad, } from './stats-window-runtime'; -test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly web preferences', () => { - const options = buildStatsWindowOptions({ - preloadPath: '/tmp/preload-stats.js', - bounds: { - x: 120, - y: 80, - width: 1440, - height: 900, - }, - }); - - assert.equal(options.title, 'SubMiner Stats'); - assert.equal(options.x, 120); - assert.equal(options.y, 80); - assert.equal(options.width, 1440); - assert.equal(options.height, 900); - assert.equal(options.frame, false); - assert.equal(options.transparent, false); - assert.equal(options.backgroundColor, '#24273a'); - assert.equal(options.resizable, false); - assert.equal(options.webPreferences?.preload, '/tmp/preload-stats.js'); - assert.equal(options.webPreferences?.contextIsolation, true); - assert.equal(options.webPreferences?.nodeIntegration, false); - assert.equal(options.webPreferences?.sandbox, true); -}); - -test('buildStatsWindowOptions uses a fullscreen auxiliary panel on macOS', () => { - const options = buildStatsWindowOptions({ - preloadPath: '/tmp/preload-stats.js', - platform: 'darwin', - }); - - assert.equal(options.type, 'panel'); -}); - -test('buildStatsWindowOptions remains a regular window off macOS', () => { - const options = buildStatsWindowOptions({ - preloadPath: '/tmp/preload-stats.js', - platform: 'linux', - }); - - assert.equal(options.type, undefined); - assert.equal(options.roundedCorners, false); -}); - -test('stats panels present after document load on macOS', () => { - assert.equal(shouldPresentStatsWindowAfterLoad('darwin'), true); - assert.equal(shouldPresentStatsWindowAfterLoad('linux'), false); - assert.equal(shouldPresentStatsWindowAfterLoad('win32'), false); -}); - test('shouldHideStatsWindowForInput matches Escape and configured bare toggle key', () => { assert.equal( shouldHideStatsWindowForInput( @@ -173,10 +120,6 @@ test('buildStatsWindowUrl enables overlay rendering on the local HTTP origin', ( assert.equal(buildStatsWindowUrl('http://127.0.0.1:6969'), 'http://127.0.0.1:6969/?overlay=1'); }); -test('buildStatsWindowUrl uses the active server port as the document origin', () => { - assert.equal(buildStatsWindowUrl('http://127.0.0.1:6123'), 'http://127.0.0.1:6123/?overlay=1'); -}); - test('resolveStatsWindowOuterBoundsForContent compensates for Wayland content insets', () => { assert.deepEqual( resolveStatsWindowOuterBoundsForContent( diff --git a/src/core/services/subsync.test.ts b/src/core/services/subsync.test.ts index cd1cf114..1fc86be0 100644 --- a/src/core/services/subsync.test.ts +++ b/src/core/services/subsync.test.ts @@ -133,31 +133,6 @@ test('triggerSubsyncFromConfig marks ffsubsync unavailable for remote media path assert.equal(ffsubsyncAvailable, false); }); -test('triggerSubsyncFromConfig does not run automatic sync', async () => { - const osd: string[] = []; - let payloadTrackCount = 0; - let spinnerRan = false; - - await triggerSubsyncFromConfig( - makeDeps({ - openManualPicker: (payload) => { - payloadTrackCount = payload.subtitleTracks.length; - }, - showMpvOsd: (text) => { - osd.push(text); - }, - runWithSubsyncSpinner: async (task: () => Promise) => { - spinnerRan = true; - return task(); - }, - }), - ); - - assert.equal(payloadTrackCount, 2); - assert.equal(spinnerRan, false); - assert.deepEqual(osd, ['Subsync: choose engine and subtitles']); -}); - test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => { let payloadTrackCount = 0; diff --git a/src/core/services/subtitle-cue-parser.test.ts b/src/core/services/subtitle-cue-parser.test.ts index fe4ed0be..99f3b3c9 100644 --- a/src/core/services/subtitle-cue-parser.test.ts +++ b/src/core/services/subtitle-cue-parser.test.ts @@ -267,34 +267,6 @@ test('parseAssCues respects dynamic field ordering from the Format row', () => { assert.equal(cues[0]!.text, '順番が違う'); }); -test('parseSubtitleCues auto-detects SRT format', () => { - const content = ['1', '00:00:01,000 --> 00:00:04,000', 'SRTテスト', ''].join('\n'); - - const cues = parseSubtitleCues(content, 'test.srt'); - assert.equal(cues.length, 1); - assert.equal(cues[0]!.text, 'SRTテスト'); -}); - -test('parseSubtitleCues auto-detects ASS format', () => { - const content = [ - '[Events]', - 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', - 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,ASSテスト', - ].join('\n'); - - const cues = parseSubtitleCues(content, 'test.ass'); - assert.equal(cues.length, 1); - assert.equal(cues[0]!.text, 'ASSテスト'); -}); - -test('parseSubtitleCues auto-detects VTT format', () => { - const content = ['1', '00:00:01.000 --> 00:00:04.000', 'VTTテスト', ''].join('\n'); - - const cues = parseSubtitleCues(content, 'test.vtt'); - assert.equal(cues.length, 1); - assert.equal(cues[0]!.text, 'VTTテスト'); -}); - test('parseSubtitleCues returns empty for unknown format', () => { assert.deepEqual(parseSubtitleCues('random content', 'test.xyz'), []); }); @@ -449,23 +421,6 @@ test('parseSubtitleCues keeps short styled repeats separate even with richer sty ]); }); -test('parseSubtitleCues keeps back-to-back plain dialogue repeats separate', () => { - // Several characters greeting in turn: distinct utterances that happen to abut. - const content = [ - '[Events]', - 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', - 'Dialogue: 0,0:04:05.67,0:04:06.82,Dial_JP,,0,0,0,,おはよう', - 'Dialogue: 0,0:04:06.82,0:04:07.56,Dial_JP,,0,0,0,,おはよう', - 'Dialogue: 0,0:04:07.56,0:04:08.78,Dial_JP,,0,0,0,,おはよう', - ].join('\n'); - - const cues = parseSubtitleCues(content, 'test.ass'); - - assert.equal(cues.length, 3); - assert.equal(cues[0]!.endTime, 246.82); - assert.equal(cues[2]!.startTime, 247.56); -}); - test('parseSubtitleCues collapses exact duplicate cues even without effect tags', () => { const content = [ '[Events]', @@ -613,28 +568,6 @@ test('parseSubtitleCues keeps short animated English dialogue as separate cues', ]); }); -test('parseSubtitleCues does not reconstruct an already canonical English cue', () => { - const content = [ - '[Events]', - 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', - 'Comment: 0,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\move(100,100,120,100)}POOF', - 'Dialogue: 0,0:00:01.00,0:00:01.04,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 1 1)}POOF', - 'Dialogue: 0,0:00:01.04,0:00:01.08,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 2 2)}POOF', - 'Dialogue: 0,0:00:01.08,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 3 3)}POOF', - ].join('\n'); - - assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ - { - startTime: 1, - endTime: 3, - text: 'POOF', - source: 'canonical-ass', - animationStartTime: 1, - animationEndTime: 3, - }, - ]); -}); - test('parseSubtitleCues reconstructs a short positioned fragment without a lyric style name', () => { const content = [ '[Events]', @@ -794,38 +727,6 @@ test('parseSubtitleCues keeps two positioned signs that repeat the same text', ( assert.equal(cues[1]!.startTime, 63.0); }); -test('parseSubtitleCues keeps a run of ordinary positioned lines separate', () => { - // Three events is a sequence, but none of them runs at animation-frame speed. - const content = [ - '[Events]', - 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', - 'Dialogue: 0,0:01:00.00,0:01:02.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ', - 'Dialogue: 0,0:01:02.00,0:01:04.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ', - 'Dialogue: 0,0:01:04.00,0:01:06.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ', - ].join('\n'); - - const cues = parseSubtitleCues(content, 'test.ass'); - - assert.equal(cues.length, 3); -}); - -test('parseSubtitleCues keeps a short repeated SRT pair without burst evidence', () => { - const content = [ - '1', - '00:00:01,000 --> 00:00:01,200', - 'えっ', - '', - '2', - '00:00:01,200 --> 00:00:01,400', - 'えっ', - '', - ].join('\n'); - - const cues = parseSubtitleCues(content, 'test.srt'); - - assert.equal(cues.length, 2); -}); - test('parseSubtitleCues collapses a burst marked only by the Effect column', () => { const content = [ '[Events]', @@ -881,22 +782,6 @@ test('parseSubtitleCues does not merge a burst into unrelated dialogue between f assert.equal(cues[0]!.endTime, 3.55); }); -test('parseSubtitleCues keeps rapid ASS lines from different actors separate', () => { - // Three 200ms `えっ` reactions traded between characters. Fast, adjacent and identical, - // but authored as three lines: different styles and different actors. - const content = [ - '[Events]', - 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', - 'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_A,アリス,0,0,0,,えっ', - 'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_B,ボブ,0,0,0,,えっ', - 'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_C,キャロル,0,0,0,,えっ', - ].join('\n'); - - const cues = parseSubtitleCues(content, 'test.ass'); - - assert.equal(cues.length, 3); -}); - test('parseSubtitleCues reads the speaker column when it is spelled Actor', () => { const content = [ '[Events]', diff --git a/src/core/services/subtitle-processing-controller.test.ts b/src/core/services/subtitle-processing-controller.test.ts index 50547c20..90b30961 100644 --- a/src/core/services/subtitle-processing-controller.test.ts +++ b/src/core/services/subtitle-processing-controller.test.ts @@ -541,20 +541,6 @@ test('cache evicts least recently used entries once the limit is reached', () => ); }); -test('default cache limit covers a full-length title without evicting', () => { - const controller = createSubtitleProcessingController({ - tokenizeSubtitle: async (text) => ({ text, tokens: [] }), - emitSubtitle: () => {}, - }); - - for (let i = 0; i < 2000; i += 1) { - controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] }); - } - - assert.equal(controller.hasCachedSubtitle('line-0'), true); - assert.equal(controller.hasCachedSubtitle('line-1999'), true); -}); - test('onSubtitleChange reports whether processing was scheduled', async () => { const emitted: SubtitleData[] = []; const controller = createSubtitleProcessingController({ diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index b8a7f0d2..6e24806a 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -1,8 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { PartOfSpeech } from '../../types'; -import { setLogLevel } from '../../logger'; -import { createTokenizerDepsRuntime, TokenizerServiceDeps, tokenizeSubtitle } from './tokenizer'; +import { MergedToken, PartOfSpeech, Token } from '../../types'; +import { + createTokenizerDepsRuntime, + TokenizerDepsRuntimeOptions, + TokenizerServiceDeps, + tokenizeSubtitle, +} from './tokenizer'; +import { createBackendDeps, runInjectedYomitanScript } from './tokenizer/yomitan-scan-test-harness'; function makeDeps(overrides: Partial = {}): TokenizerServiceDeps { return { @@ -21,54 +26,236 @@ function makeDeps(overrides: Partial = {}): TokenizerServi }; } +type TermReadingPair = { term: string; reading: string | null }; + +interface YomitanBackend { + // Answers the scanner call and the parseText fallback. + respond: (script: string) => unknown; + // Answers getTermFrequencies for the requested term/reading pairs. + frequencies?: (pairs: TermReadingPair[]) => unknown[] | Promise; + // Enabled dictionaries of the active profile, in priority order. + dictionaries?: string[]; + dictionaryInfo?: unknown[]; +} + +// A ready Yomitan parser window. Profile-metadata and frequency scripts run +// for real against a fake backend; every other script goes to `respond`. +function createYomitanParserWindow(backend: YomitanBackend): Electron.BrowserWindow { + const dictionaries = backend.dictionaries ?? ['freq-dict']; + const handleBackendAction = (action: string, params: unknown): unknown => { + switch (action) { + case 'optionsGetFull': + return { + profileCurrent: 0, + profiles: [ + { + options: { + scanning: { length: 40 }, + dictionaries: dictionaries.map((name, id) => ({ name, enabled: true, id })), + }, + }, + ], + }; + case 'getDictionaryInfo': + return backend.dictionaryInfo ?? []; + case 'getTermFrequencies': + return ( + backend.frequencies?.( + (params as { termReadingList: TermReadingPair[] }).termReadingList, + ) ?? [] + ); + default: + throw new Error(`unexpected action: ${action}`); + } + }; + return { + isDestroyed: () => false, + webContents: { + executeJavaScript: async (script: string) => + script.includes('getDictionaryInfo') || script.includes('getTermFrequencies') + ? await runInjectedYomitanScript(script, handleBackendAction) + : backend.respond(script), + }, + } as unknown as Electron.BrowserWindow; +} + +function makeYomitanDeps( + backend: YomitanBackend, + overrides: Partial = {}, +): TokenizerServiceDeps { + const parserWindow = createYomitanParserWindow(backend); + return makeDeps({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as Electron.Extension, + getYomitanParserWindow: () => parserWindow, + ...overrides, + }); +} + interface YomitanTokenInput { surface: string; reading?: string; headword?: string; + // Defaults to the end of the previous token. + startPos?: number; frequencyRank?: number; isNameMatch?: boolean; wordClasses?: string[]; isUnparsedRun?: boolean; } +// Scanner-call responder returning `tokens` as the in-window scanner would. +function scanTokens(tokens: YomitanTokenInput[]) { + return () => { + let cursor = 0; + return tokens.map((token) => { + const startPos = token.startPos ?? cursor; + const endPos = startPos + token.surface.length; + cursor = endPos; + return { + surface: token.surface, + reading: token.reading ?? token.surface, + headword: token.headword ?? token.surface, + startPos, + endPos, + isNameMatch: token.isNameMatch ?? false, + frequencyRank: token.frequencyRank, + wordClasses: token.wordClasses, + isUnparsedRun: token.isUnparsedRun, + }; + }); + }; +} + function makeDepsFromYomitanTokens( tokens: YomitanTokenInput[], overrides: Partial = {}, + backend: Omit = {}, ): TokenizerServiceDeps { - let cursor = 0; - return makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return []; - } + return makeYomitanDeps({ ...backend, respond: scanTokens(tokens) }, overrides); +} - cursor = 0; - return tokens.map((token) => { - const startPos = cursor; - const endPos = startPos + token.surface.length; - cursor = endPos; - return { - surface: token.surface, - reading: token.reading ?? token.surface, - headword: token.headword ?? token.surface, - startPos, - endPos, - isNameMatch: token.isNameMatch ?? false, - frequencyRank: token.frequencyRank, - wordClasses: token.wordClasses, - isUnparsedRun: token.isUnparsedRun, - }; - }); - }, - }, - }) as unknown as Electron.BrowserWindow, +interface ParseSegment { + text: string; + reading: string; + headwords?: string[]; +} + +// One parseText segment; each headword is one alternative dictionary term. +function seg(text: string, reading: string, ...headwords: string[]): ParseSegment { + return headwords.length > 0 ? { text, reading, headwords } : { text, reading }; +} + +// Deps whose scanner yields no usable payload, so tokenization falls back to +// parseText and gets `lines` (token groups of segments) as the single +// scanning-parser candidate. +function makeDepsFromScanningParser( + lines: ParseSegment[][], + overrides: Partial = {}, + backend: Omit = {}, +): TokenizerServiceDeps { + const parseResults = [ + { + source: 'scanning-parser', + index: 0, + content: lines.map((line) => + line.map(({ text, reading, headwords }) => ({ + text, + reading, + ...(headwords ? { headwords: headwords.map((term) => [{ term }]) } : {}), + })), + ), + }, + ]; + return makeYomitanDeps({ ...backend, respond: () => parseResults }, overrides); +} + +function yomitanFrequency( + term: string, + reading: string | null, + frequency: number, + extra: Record = {}, +) { + return { + term, + reading, + dictionary: 'freq-dict', + frequency, + displayValue: String(frequency), + displayValueParsed: true, + ...extra, + }; +} + +function requestsPair(pairs: TermReadingPair[], term: string, reading: string | null): boolean { + return pairs.some((pair) => pair.term === term && pair.reading === reading); +} + +// A MeCab token for POS enrichment, which only reads its span and POS tags. +function mecabToken( + surface: string, + startPos: number, + pos1: string, + pos2?: string, + pos3?: string, +): MergedToken { + return { + surface, + headword: surface, + reading: '', + startPos, + endPos: startPos + surface.length, + partOfSpeech: PartOfSpeech.other, + pos1, + pos2, + pos3, + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }; +} + +// A raw MeCab tokenizer word, before createTokenizerDepsRuntime merges it. +function mecabWord( + word: string, + partOfSpeech: PartOfSpeech, + pos1: string, + pos2: string, + katakanaReading: string, + extra: Partial = {}, +): Token { + return { + word, + partOfSpeech, + pos1, + pos2, + pos3: '', + pos4: '', + inflectionType: '', + inflectionForm: '', + headword: word, + katakanaReading, + pronunciation: katakanaReading, + ...extra, + }; +} + +function makeRuntimeOptions( + overrides: Partial = {}, +): TokenizerDepsRuntimeOptions { + return { + getYomitanExt: () => null, + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + getYomitanParserReadyPromise: () => null, + setYomitanParserReadyPromise: () => {}, + getYomitanParserInitPromise: () => null, + setYomitanParserInitPromise: () => {}, + isKnownWord: () => false, + getKnownWordMatchMode: () => 'headword', + getJlptLevel: () => null, + getMecabTokenizer: () => null, ...overrides, - }); + }; } function createDeferred() { @@ -84,55 +271,10 @@ function createDeferred() { }; } -test('tokenizeSubtitle keeps the blank line separating simultaneous cues', async () => { - // The tokenized payload's text drives display; folding the cue boundary would merge - // two speakers back onto one line the moment tokenization upgrades the plain emit. - const result = await tokenizeSubtitle( - '\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee', - makeDeps({ getYomitanExt: () => null }), - ); - - assert.equal(result.text, '\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee'); -}); - test('tokenizeSubtitle splits same-line grammar endings before applying annotations', async () => { const result = await tokenizeSubtitle( '猫です', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return []; - } - - return [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '猫', - reading: 'ねこ', - headwords: [[{ term: '猫' }]], - }, - { - text: 'です', - reading: 'です', - headwords: [[{ term: 'です' }]], - }, - ], - ], - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - tokenizeWithMecab: async () => null, + makeDepsFromScanningParser([[seg('猫', 'ねこ', '猫'), seg('です', 'です', 'です')]], { getFrequencyDictionaryEnabled: () => true, getFrequencyRank: (text) => (text === '猫' ? 40 : text === 'です' ? 50 : null), getJlptLevel: (text) => (text === '猫' || text === 'です' ? 'N5' : null), @@ -161,8 +303,8 @@ test('tokenizeSubtitle preserves Yomitan name-match metadata on tokens', async ( ); assert.equal(result.tokens?.length, 2); - assert.equal((result.tokens?.[0] as { isNameMatch?: boolean } | undefined)?.isNameMatch, true); - assert.equal((result.tokens?.[1] as { isNameMatch?: boolean } | undefined)?.isNameMatch, false); + assert.equal(result.tokens?.[0]?.isNameMatch, true); + assert.equal(result.tokens?.[1]?.isNameMatch, false); }); test('tokenizeSubtitle attaches character image metadata to name matches when enabled', async () => { @@ -253,18 +395,6 @@ test('tokenizeSubtitle caches JLPT lookups across repeated tokens', async () => assert.equal(result.tokens?.[1]?.jlptLevel, 'N5'); }); -test('tokenizeSubtitle leaves JLPT unset for non-matching tokens', async () => { - const result = await tokenizeSubtitle( - '猫', - makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫' }], { - getJlptLevel: () => null, - }), - ); - - assert.equal(result.tokens?.length, 1); - assert.equal(result.tokens?.[0]?.jlptLevel, undefined); -}); - test('tokenizeSubtitle skips JLPT lookups when disabled', async () => { let lookupCalls = 0; const result = await tokenizeSubtitle( @@ -283,85 +413,26 @@ test('tokenizeSubtitle skips JLPT lookups when disabled', async () => { assert.equal(lookupCalls, 0); }); -test('tokenizeSubtitle applies frequency dictionary ranks', async () => { - const result = await tokenizeSubtitle( - '猫です', - makeDepsFromYomitanTokens( - [ - { surface: '猫', reading: 'ねこ', headword: '猫' }, - { surface: 'です', reading: 'です', headword: 'です' }, - ], - { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === '猫' ? 23 : 1200), - }, - ), - ); - - assert.equal(result.tokens?.length, 2); - assert.equal(result.tokens?.[0]?.frequencyRank, 23); - assert.equal(result.tokens?.[1]?.frequencyRank, undefined); -}); - test('tokenizeSubtitle uses left-to-right yomitan scanning to keep full katakana name tokens', async () => { const result = await tokenizeSubtitle( 'カズマ 魔王軍', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return []; - } - - return [ - { - surface: 'カズマ', - reading: 'かずま', - headword: 'カズマ', - startPos: 0, - endPos: 3, - }, - { - surface: '魔王軍', - reading: 'まおうぐん', - headword: '魔王軍', - startPos: 4, - endPos: 7, - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - }), + makeDepsFromYomitanTokens([ + { surface: 'カズマ', reading: 'かずま' }, + { surface: '魔王軍', reading: 'まおうぐん', startPos: 4 }, + ]), ); assert.deepEqual( - result.tokens?.map((token) => ({ - surface: token.surface, - reading: token.reading, - headword: token.headword, - startPos: token.startPos, - endPos: token.endPos, + result.tokens?.map(({ surface, reading, headword, startPos, endPos }) => ({ + surface, + reading, + headword, + startPos, + endPos, })), [ - { - surface: 'カズマ', - reading: 'かずま', - headword: 'カズマ', - startPos: 0, - endPos: 3, - }, - { - surface: '魔王軍', - reading: 'まおうぐん', - headword: '魔王軍', - startPos: 4, - endPos: 7, - }, + { surface: 'カズマ', reading: 'かずま', headword: 'カズマ', startPos: 0, endPos: 3 }, + { surface: '魔王軍', reading: 'まおうぐん', headword: '魔王軍', startPos: 4, endPos: 7 }, ], ); }); @@ -369,46 +440,11 @@ test('tokenizeSubtitle uses left-to-right yomitan scanning to keep full katakana test('tokenizeSubtitle loads frequency ranks from Yomitan installed dictionaries', async () => { const result = await tokenizeSubtitle( '猫', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return [ - { - term: '猫', - reading: 'ねこ', - dictionary: 'freq-dict', - frequency: 77, - displayValue: '77', - displayValueParsed: true, - }, - ]; - } - - return [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '猫', - reading: 'ねこ', - headwords: [[{ term: '猫' }]], - }, - ], - ], - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - }), + makeDepsFromScanningParser( + [[seg('猫', 'ねこ', '猫')]], + { getFrequencyDictionaryEnabled: () => true }, + { frequencies: () => [yomitanFrequency('猫', 'ねこ', 77)] }, + ), ); assert.equal(result.tokens?.length, 1); @@ -423,58 +459,29 @@ test('tokenizeSubtitle starts Yomitan frequency lookup and MeCab enrichment in p const pendingResult = tokenizeSubtitle( '猫', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - frequencyRequested = true; - return await frequencyDeferred.promise; - } - - return [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '猫', - reading: 'ねこ', - headwords: [[{ term: '猫' }]], - }, - ], - ], - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - tokenizeWithMecab: async () => { - mecabRequested = true; - return await mecabDeferred.promise; + makeDepsFromScanningParser( + [[seg('猫', 'ねこ', '猫')]], + { + getFrequencyDictionaryEnabled: () => true, + tokenizeWithMecab: async () => { + mecabRequested = true; + return await mecabDeferred.promise; + }, }, - }), + { + frequencies: async () => { + frequencyRequested = true; + return await frequencyDeferred.promise; + }, + }, + ), ); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(frequencyRequested, true); assert.equal(mecabRequested, true); - frequencyDeferred.resolve([ - { - term: '猫', - reading: 'ねこ', - dictionary: 'freq-dict', - frequency: 77, - displayValue: '77', - displayValueParsed: true, - }, - ]); + frequencyDeferred.resolve([yomitanFrequency('猫', 'ねこ', 77)]); mecabDeferred.resolve(null); const result = await pendingResult; @@ -488,43 +495,17 @@ test('tokenizeSubtitle can signal tokenization-ready before enrichment completes const pendingResult = tokenizeSubtitle( '猫', - makeDeps({ - onTokenizationReady: (text) => { - tokenizationReadyText = text; + makeDepsFromScanningParser( + [[seg('猫', 'ねこ', '猫')]], + { + onTokenizationReady: (text) => { + tokenizationReadyText = text; + }, + getFrequencyDictionaryEnabled: () => true, + tokenizeWithMecab: async () => await mecabDeferred.promise, }, - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return await frequencyDeferred.promise; - } - - return [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '猫', - reading: 'ねこ', - headwords: [[{ term: '猫' }]], - }, - ], - ], - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - tokenizeWithMecab: async () => { - return await mecabDeferred.promise; - }, - }), + { frequencies: async () => await frequencyDeferred.promise }, + ), ); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -538,31 +519,10 @@ test('tokenizeSubtitle can signal tokenization-ready before enrichment completes test('tokenizeSubtitle appends trailing kana to merged Yomitan readings when headword equals surface', async () => { const result = await tokenizeSubtitle( '断じて見ていない', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { text: '断', reading: 'だん', headwords: [[{ term: '断じて' }]] }, - { text: 'じて', reading: '', headwords: [[{ term: 'じて' }]] }, - ], - [ - { text: '見', reading: 'み', headwords: [[{ term: '見る' }]] }, - { text: 'ていない', reading: '', headwords: [[{ term: 'ていない' }]] }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, - }), + makeDepsFromScanningParser([ + [seg('断', 'だん', '断じて'), seg('じて', '', 'じて')], + [seg('見', 'み', '見る'), seg('ていない', '', 'ていない')], + ]), ); assert.equal(result.tokens?.length, 2); @@ -573,110 +533,45 @@ test('tokenizeSubtitle appends trailing kana to merged Yomitan readings when hea }); test('tokenizeSubtitle queries headword frequencies with token reading for disambiguation', async () => { + const requested: TermReadingPair[] = []; const result = await tokenizeSubtitle( '鍛えた', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - assert.equal( - script.includes('"term":"鍛える","reading":null'), - false, - 'should not eagerly include term-only fallback pair when reading lookup is present', - ); - if (!script.includes('"term":"鍛える","reading":"きた"')) { - return []; - } - return [ - { - term: '鍛える', - reading: 'きたえる', - dictionary: 'freq-dict', - frequency: 46961, - displayValue: '2847,46961', - displayValueParsed: true, - }, - ]; - } - - return [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '鍛えた', - reading: 'きた', - headwords: [[{ term: '鍛える' }]], - }, - ], - ], - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - }), + makeDepsFromScanningParser( + [[seg('鍛えた', 'きた', '鍛える')]], + { getFrequencyDictionaryEnabled: () => true }, + { + frequencies: (pairs) => { + requested.push(...pairs); + return requestsPair(pairs, '鍛える', 'きた') + ? [yomitanFrequency('鍛える', 'きたえる', 46961, { displayValue: '2847,46961' })] + : []; + }, + }, + ), ); assert.equal(result.tokens?.length, 1); assert.equal(result.tokens?.[0]?.headword, '鍛える'); assert.equal(result.tokens?.[0]?.reading, 'きた'); assert.equal(result.tokens?.[0]?.frequencyRank, 2847); + // The reading lookup hits, so no term-only fallback pair is requested. + assert.deepEqual( + requested.filter((pair) => pair.term === '鍛える'), + [{ term: '鍛える', reading: 'きた' }], + ); }); test('tokenizeSubtitle falls back to term-only Yomitan frequency lookup when reading is noisy', async () => { const result = await tokenizeSubtitle( '断じて', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - if (!script.includes('"term":"断じて","reading":null')) { - return []; - } - return [ - { - term: '断じて', - reading: null, - dictionary: 'freq-dict', - frequency: 7082, - displayValue: '7082', - displayValueParsed: true, - }, - ]; - } - - return [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '断じて', - reading: 'だん', - headwords: [[{ term: '断じて' }]], - }, - ], - ], - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - }), + makeDepsFromScanningParser( + [[seg('断じて', 'だん', '断じて')]], + { getFrequencyDictionaryEnabled: () => true }, + { + frequencies: (pairs) => + requestsPair(pairs, '断じて', null) ? [yomitanFrequency('断じて', null, 7082)] : [], + }, + ), ); assert.equal(result.tokens?.length, 1); @@ -684,61 +579,22 @@ test('tokenizeSubtitle falls back to term-only Yomitan frequency lookup when rea }); test('tokenizeSubtitle avoids headword term-only fallback rank when reading-specific frequency exists', async () => { + const cc100 = { dictionary: 'CC100', dictionaryPriority: 0, displayValue: null }; const result = await tokenizeSubtitle( '無人', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - if (!script.includes('"term":"無人","reading":"むじん"')) { - return []; - } - return [ - { - term: '無人', - reading: null, - dictionary: 'CC100', - dictionaryPriority: 0, - frequency: 157632, - displayValue: null, - displayValueParsed: false, - }, - { - term: '無人', - reading: 'むじん', - dictionary: 'CC100', - dictionaryPriority: 0, - frequency: 7141, - displayValue: null, - displayValueParsed: false, - }, - ]; - } - - return [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '無人', - reading: 'むじん', - headwords: [[{ term: '無人' }]], - }, - ], - ], - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - }), + makeDepsFromScanningParser( + [[seg('無人', 'むじん', '無人')]], + { getFrequencyDictionaryEnabled: () => true }, + { + frequencies: (pairs) => + requestsPair(pairs, '無人', 'むじん') + ? [ + yomitanFrequency('無人', null, 157632, cc100), + yomitanFrequency('無人', 'むじん', 7141, cc100), + ] + : [], + }, + ), ); assert.equal(result.tokens?.length, 1); @@ -748,118 +604,47 @@ test('tokenizeSubtitle avoids headword term-only fallback rank when reading-spec test('tokenizeSubtitle prefers Yomitan frequency from highest-priority dictionary', async () => { const result = await tokenizeSubtitle( '猫', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return [ - { - term: '猫', - reading: 'ねこ', - dictionary: 'low-priority', - dictionaryPriority: 2, - frequency: 5, - displayValue: '5', - displayValueParsed: true, - }, - { - term: '猫', - reading: 'ねこ', - dictionary: 'high-priority', - dictionaryPriority: 0, - frequency: 100, - displayValue: '100', - displayValueParsed: true, - }, - ]; - } - - return [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '猫', - reading: 'ねこ', - headwords: [[{ term: '猫' }]], - }, - ], - ], - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - }), + makeDepsFromScanningParser( + [[seg('猫', 'ねこ', '猫')]], + { getFrequencyDictionaryEnabled: () => true }, + { + frequencies: () => [ + yomitanFrequency('猫', 'ねこ', 5, { dictionary: 'low-priority', dictionaryPriority: 2 }), + yomitanFrequency('猫', 'ねこ', 100, { + dictionary: 'high-priority', + dictionaryPriority: 0, + }), + ], + }, + ), ); assert.equal(result.tokens?.length, 1); assert.equal(result.tokens?.[0]?.frequencyRank, 100); }); +// 潜み scanned with a truncated reading and no scan-derived rank, so the rank +// comes from the term-only CC100 lookup. +const HISOMI_TOKEN: YomitanTokenInput = { surface: '潜み', reading: 'ひそ', headword: '潜む' }; +const CC100_HISOMI = { + dictionary: 'CC100', + hasReading: false, + displayValue: null, + displayValueParsed: false, +}; + test('tokenizeSubtitle ignores occurrence-based Yomitan frequencies for inflected terms', async () => { const result = await tokenizeSubtitle( '潜み', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return [ - { - term: '潜む', - reading: 'ひそ', - dictionary: 'CC100', - frequency: 118121, - displayValue: null, - displayValueParsed: false, - }, - ]; - } - - if (script.includes('optionsGetFull')) { - return { - profileCurrent: 0, - profileIndex: 0, - scanLength: 40, - dictionaries: ['CC100'], - dictionaryPriorityByName: { CC100: 0 }, - dictionaryFrequencyModeByName: { CC100: 'occurrence-based' }, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [{ name: 'CC100', enabled: true, id: 0 }], - }, - }, - ], - }; - } - - return [ - { - surface: '潜み', - reading: 'ひそ', - headword: '潜む', - startPos: 0, - endPos: 2, - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - }), + makeDepsFromYomitanTokens( + [HISOMI_TOKEN], + { getFrequencyDictionaryEnabled: () => true }, + { + dictionaries: ['CC100'], + dictionaryInfo: [{ title: 'CC100', frequencyMode: 'occurrence-based' }], + frequencies: () => [yomitanFrequency('潜む', 'ひそ', 118121, CC100_HISOMI)], + }, + ), ); assert.equal(result.tokens?.length, 1); @@ -869,60 +654,15 @@ test('tokenizeSubtitle ignores occurrence-based Yomitan frequencies for inflecte test('tokenizeSubtitle falls back to raw term-only Yomitan rank when no scan-derived rank exists', async () => { const result = await tokenizeSubtitle( '潜み', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return [ - { - term: '潜む', - reading: 'ひそ', - hasReading: false, - dictionary: 'CC100', - frequency: 118121, - displayValue: null, - displayValueParsed: false, - }, - ]; - } - - if (script.includes('optionsGetFull')) { - return { - profileCurrent: 0, - profileIndex: 0, - scanLength: 40, - dictionaries: ['CC100'], - dictionaryPriorityByName: { CC100: 0 }, - dictionaryFrequencyModeByName: { CC100: 'rank-based' }, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [{ name: 'CC100', enabled: true, id: 0 }], - }, - }, - ], - }; - } - - return [ - { - surface: '潜み', - reading: 'ひそ', - headword: '潜む', - startPos: 0, - endPos: 2, - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - }), + makeDepsFromYomitanTokens( + [HISOMI_TOKEN], + { getFrequencyDictionaryEnabled: () => true }, + { + dictionaries: ['CC100'], + dictionaryInfo: [{ title: 'CC100', frequencyMode: 'rank-based' }], + frequencies: () => [yomitanFrequency('潜む', 'ひそ', 118121, CC100_HISOMI)], + }, + ), ); assert.equal(result.tokens?.length, 1); @@ -932,60 +672,17 @@ test('tokenizeSubtitle falls back to raw term-only Yomitan rank when no scan-der test('tokenizeSubtitle keeps parsed display rank for term-only inflected headword fallback', async () => { const result = await tokenizeSubtitle( '潜み', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return [ - { - term: '潜む', - reading: 'ひそ', - hasReading: false, - dictionary: 'CC100', - frequency: 118121, - displayValue: '118,121', - displayValueParsed: false, - }, - ]; - } - - if (script.includes('optionsGetFull')) { - return { - profileCurrent: 0, - profileIndex: 0, - scanLength: 40, - dictionaries: ['CC100'], - dictionaryPriorityByName: { CC100: 0 }, - dictionaryFrequencyModeByName: { CC100: 'rank-based' }, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [{ name: 'CC100', enabled: true, id: 0 }], - }, - }, - ], - }; - } - - return [ - { - surface: '潜み', - reading: 'ひそ', - headword: '潜む', - startPos: 0, - endPos: 2, - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - }), + makeDepsFromYomitanTokens( + [HISOMI_TOKEN], + { getFrequencyDictionaryEnabled: () => true }, + { + dictionaries: ['CC100'], + dictionaryInfo: [{ title: 'CC100', frequencyMode: 'rank-based' }], + frequencies: () => [ + yomitanFrequency('潜む', 'ひそ', 118121, { ...CC100_HISOMI, displayValue: '118,121' }), + ], + }, + ), ); assert.equal(result.tokens?.length, 1); @@ -995,43 +692,15 @@ test('tokenizeSubtitle keeps parsed display rank for term-only inflected headwor test('tokenizeSubtitle preserves scan-derived rank over lower-priority Yomitan fallback', async () => { const result = await tokenizeSubtitle( '潜み', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return [ - { - term: '潜む', - reading: 'ひそ', - hasReading: false, - dictionary: 'CC100', - dictionaryPriority: 2, - frequency: 118121, - displayValue: null, - displayValueParsed: false, - }, - ]; - } - - return [ - { - surface: '潜み', - reading: 'ひそむ', - headword: '潜む', - startPos: 0, - endPos: 2, - frequencyRank: 4073, - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - }), + makeDepsFromYomitanTokens( + [{ ...HISOMI_TOKEN, reading: 'ひそむ', frequencyRank: 4073 }], + { getFrequencyDictionaryEnabled: () => true }, + { + frequencies: () => [ + yomitanFrequency('潜む', 'ひそ', 118121, { ...CC100_HISOMI, dictionaryPriority: 2 }), + ], + }, + ), ); assert.equal(result.tokens?.length, 1); @@ -1041,30 +710,8 @@ test('tokenizeSubtitle preserves scan-derived rank over lower-priority Yomitan f test('tokenizeSubtitle uses only selected Yomitan headword for frequency lookup', async () => { const result = await tokenizeSubtitle( '猫です', - makeDeps({ + makeDepsFromScanningParser([[seg('猫です', 'ねこです', '猫です', '猫')]], { getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '猫です', - reading: 'ねこです', - headwords: [[{ term: '猫です' }], [{ term: '猫' }]], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, getFrequencyRank: (text) => (text === '猫' ? 40 : text === '猫です' ? 1200 : null), }), ); @@ -1076,50 +723,17 @@ test('tokenizeSubtitle uses only selected Yomitan headword for frequency lookup' test('tokenizeSubtitle keeps furigana-split Yomitan segments as one token', async () => { const result = await tokenizeSubtitle( '友達と話した', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '友', - reading: 'とも', - headwords: [[{ term: '友達' }]], - }, - { - text: '達', - reading: 'だち', - }, - ], - [ - { - text: 'と', - reading: 'と', - headwords: [[{ term: 'と' }]], - }, - ], - [ - { - text: '話した', - reading: 'はなした', - headwords: [[{ term: '話す' }]], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, - getFrequencyRank: (text) => (text === '友達' ? 22 : text === '話す' ? 90 : null), - }), + makeDepsFromScanningParser( + [ + [seg('友', 'とも', '友達'), seg('達', 'だち')], + [seg('と', 'と', 'と')], + [seg('話した', 'はなした', '話す')], + ], + { + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: (text) => (text === '友達' ? 22 : text === '話す' ? 90 : null), + }, + ), ); assert.equal(result.tokens?.length, 3); @@ -1136,30 +750,8 @@ test('tokenizeSubtitle keeps furigana-split Yomitan segments as one token', asyn test('tokenizeSubtitle prefers exact headword frequency over surface/reading when available', async () => { const result = await tokenizeSubtitle( '猫です', - makeDeps({ + makeDepsFromScanningParser([[seg('猫', 'ねこ', 'ネコ')]], { getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '猫', - reading: 'ねこ', - headwords: [[{ term: 'ネコ' }]], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, getFrequencyRank: (text) => (text === '猫' ? 1200 : text === 'ネコ' ? 8 : null), }), ); @@ -1169,95 +761,36 @@ test('tokenizeSubtitle prefers exact headword frequency over surface/reading whe }); test('tokenizeSubtitle falls back to exact surface frequency when merged headword lookup misses', async () => { - const frequencyScripts: string[] = []; + const requested: TermReadingPair[] = []; const result = await tokenizeSubtitle( '陰に', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - frequencyScripts.push(script); - return script.includes('"term":"陰に","reading":"いんに"') - ? [ - { - term: '陰に', - reading: 'いんに', - dictionary: 'freq-dict', - frequency: 5702, - displayValue: '5702', - displayValueParsed: true, - }, - ] - : []; - } - - return [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '陰に', - reading: 'いんに', - headwords: [[{ term: '陰' }]], - }, - ], - ], - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - }), + makeDepsFromScanningParser( + [[seg('陰に', 'いんに', '陰')]], + { getFrequencyDictionaryEnabled: () => true }, + { + frequencies: (pairs) => { + requested.push(...pairs); + return requestsPair(pairs, '陰に', 'いんに') + ? [yomitanFrequency('陰に', 'いんに', 5702)] + : []; + }, + }, + ), ); assert.equal(result.tokens?.length, 1); assert.equal(result.tokens?.[0]?.surface, '陰に'); assert.equal(result.tokens?.[0]?.headword, '陰'); assert.equal(result.tokens?.[0]?.frequencyRank, 5702); - assert.equal( - frequencyScripts.some((script) => script.includes('"term":"陰","reading":"いんに"')), - true, - ); - assert.equal( - frequencyScripts.some((script) => script.includes('"term":"陰に","reading":"いんに"')), - true, - ); + assert.equal(requestsPair(requested, '陰', 'いんに'), true); + assert.equal(requestsPair(requested, '陰に', 'いんに'), true); }); test('tokenizeSubtitle keeps no frequency when only reading matches and headword misses', async () => { const result = await tokenizeSubtitle( '猫です', - makeDeps({ + makeDepsFromScanningParser([[seg('猫', 'ねこ', '猫です')]], { getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '猫', - reading: 'ねこ', - headwords: [[{ term: '猫です' }]], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, getFrequencyRank: (text) => (text === 'ねこ' ? 77 : null), }), ); @@ -1269,30 +802,8 @@ test('tokenizeSubtitle keeps no frequency when only reading matches and headword test('tokenizeSubtitle ignores invalid frequency rank on selected headword', async () => { const result = await tokenizeSubtitle( '猫です', - makeDeps({ + makeDepsFromScanningParser([[seg('猫です', 'ねこです', '猫', '猫です')]], { getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '猫です', - reading: 'ねこです', - headwords: [[{ term: '猫' }], [{ term: '猫です' }]], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, getFrequencyRank: (text) => (text === '猫' ? Number.NaN : text === '猫です' ? 500 : null), }), ); @@ -1301,72 +812,11 @@ test('tokenizeSubtitle ignores invalid frequency rank on selected headword', asy assert.equal(result.tokens?.[0]?.frequencyRank, undefined); }); -test('tokenizeSubtitle handles real-word frequency candidates and prefers most frequent term', async () => { - const result = await tokenizeSubtitle( - '昨日', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '昨日', - reading: 'きのう', - headwords: [[{ term: '昨日' }], [{ term: 'きのう' }]], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, - getFrequencyRank: (text) => (text === 'きのう' ? 120 : text === '昨日' ? 40 : null), - }), - ); - - assert.equal(result.tokens?.length, 1); - assert.equal(result.tokens?.[0]?.frequencyRank, 40); -}); - test('tokenizeSubtitle ignores candidates with no dictionary rank when higher-frequency candidate exists', async () => { const result = await tokenizeSubtitle( '猫です', - makeDeps({ + makeDepsFromScanningParser([[seg('猫', 'ねこ', '猫', '猫です', 'unknown-term')]], { getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '猫', - reading: 'ねこ', - headwords: [ - [{ term: '猫' }], - [{ term: '猫です' }], - [{ term: 'unknown-term' }], - ], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, getFrequencyRank: (text) => text === 'unknown-term' ? -1 : text === '猫' ? 88 : text === '猫です' ? 9000 : null, }), @@ -1379,71 +829,24 @@ test('tokenizeSubtitle ignores candidates with no dictionary rank when higher-fr test('tokenizeSubtitle ignores frequency lookup failures', async () => { const result = await tokenizeSubtitle( '猫', - makeDeps({ + makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ' }], { getFrequencyDictionaryEnabled: () => true, - tokenizeWithMecab: async () => [ - { - headword: '猫', - surface: '猫', - reading: 'ネコ', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.noun, - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], getFrequencyRank: () => { throw new Error('frequency lookup unavailable'); }, }), ); + assert.equal(result.tokens?.[0]?.surface, '猫'); assert.equal(result.tokens?.[0]?.frequencyRank, undefined); }); test('tokenizeSubtitle keeps standalone particle token hoverable while clearing annotation metadata', async () => { const result = await tokenizeSubtitle( 'は', - makeDeps({ + makeDepsFromScanningParser([[seg('は', 'は', 'は')]], { getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: 'は', - reading: 'は', - headwords: [[{ term: 'は' }]], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, - tokenizeWithMecab: async () => [ - { - headword: 'は', - surface: 'は', - reading: 'ハ', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], + tokenizeWithMecab: async () => [mecabToken('は', 0, '助詞')], getFrequencyRank: (text) => (text === 'は' ? 10 : null), }), ); @@ -1477,58 +880,6 @@ test('tokenizeSubtitle keeps standalone particle token hoverable while clearing ); }); -test('tokenizeSubtitle keeps frequency rank when mecab tags classify token as content-bearing', async () => { - const result = await tokenizeSubtitle( - 'ふふ', - makeDepsFromYomitanTokens([{ surface: 'ふふ', reading: '', headword: 'ふふ' }], { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === 'ふふ' ? 3014 : null), - tokenizeWithMecab: async () => [ - { - headword: 'ふふ', - surface: 'ふふ', - reading: 'フフ', - startPos: 0, - endPos: 2, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - }), - ); - - assert.equal(result.tokens?.length, 1); - assert.equal(result.tokens?.[0]?.frequencyRank, 3014); -}); - -test('tokenizeSubtitle ignores invalid frequency ranks', async () => { - const result = await tokenizeSubtitle( - '猫', - makeDepsFromYomitanTokens( - [ - { surface: '猫', reading: 'ねこ', headword: '猫' }, - { surface: 'です', reading: 'です', headword: 'です' }, - ], - { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => { - if (text === '猫') return Number.NaN; - if (text === 'です') return -1; - return 100; - }, - }, - ), - ); - - assert.equal(result.tokens?.length, 2); - assert.equal(result.tokens?.[0]?.frequencyRank, undefined); - assert.equal(result.tokens?.[1]?.frequencyRank, undefined); -}); - test('tokenizeSubtitle skips frequency lookups when disabled', async () => { let frequencyCalls = 0; const result = await tokenizeSubtitle( @@ -1547,68 +898,10 @@ test('tokenizeSubtitle skips frequency lookups when disabled', async () => { assert.equal(frequencyCalls, 0); }); -test('tokenizeSubtitle skips JLPT level for excluded demonstratives', async () => { - const result = await tokenizeSubtitle( - 'この', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: 'この', - reading: 'この', - headwords: [[{ term: 'この' }]], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, - tokenizeWithMecab: async () => null, - getJlptLevel: (text) => (text === 'この' ? 'N5' : null), - }), - ); - - assert.equal(result.tokens?.length, 1); - assert.equal(result.tokens?.[0]?.jlptLevel, undefined); -}); - test('tokenizeSubtitle keeps repeated kana interjections tokenized while clearing annotation metadata', async () => { const result = await tokenizeSubtitle( 'ああ', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: 'ああ', - reading: 'ああ', - headwords: [[{ term: 'ああ' }]], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, - tokenizeWithMecab: async () => null, + makeDepsFromScanningParser([[seg('ああ', 'ああ', 'ああ')]], { getJlptLevel: (text) => (text === 'ああ' ? 'N5' : null), }), ); @@ -1638,30 +931,6 @@ test('tokenizeSubtitle keeps repeated kana interjections tokenized while clearin ); }); -test('tokenizeSubtitle assigns JLPT level to Yomitan tokens', async () => { - const result = await tokenizeSubtitle( - '猫です', - makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫' }], { - getJlptLevel: (text) => (text === '猫' ? 'N4' : null), - }), - ); - - assert.equal(result.tokens?.length, 1); - assert.equal(result.tokens?.[0]?.jlptLevel, 'N4'); -}); - -test('tokenizeSubtitle clears JLPT level from standalone Yomitan particle token', async () => { - const result = await tokenizeSubtitle( - 'は', - makeDepsFromYomitanTokens([{ surface: 'は', reading: 'は', headword: 'は' }], { - getJlptLevel: (text) => (text === 'は' ? 'N5' : null), - }), - ); - - assert.equal(result.tokens?.length, 1); - assert.equal(result.tokens?.[0]?.jlptLevel, undefined); -}); - test('tokenizeSubtitle returns the normalized text when it comes out empty', async () => { // Handing back the original would push whatever normalization dropped into app state // as if it were subtitle text. @@ -1669,29 +938,35 @@ test('tokenizeSubtitle returns the normalized text when it comes out empty', asy assert.deepEqual(result, { text: '', tokens: null }); }); -test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async () => { - let parseInput = ''; - const result = await tokenizeSubtitle( - '猫\\Nです\nね', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - parseInput = script; - return null; - }, - }, - }) as unknown as Electron.BrowserWindow, - }), - ); +const LINE_NORMALIZATION_CASES = [ + { + name: 'normalizes newlines before Yomitan parse request', + input: '猫\\Nです\nね', + scanned: '猫 です ね', + text: '猫\nです\nね', + }, + { + name: 'collapses zero-width separators before Yomitan parse request', + input: 'キリキリと​かかってこい\nこのヘナチョコ冒険者どもめが!', + scanned: 'キリキリと かかってこい このヘナチョコ冒険者どもめが!', + text: 'キリキリと​かかってこい\nこのヘナチョコ冒険者どもめが!', + }, +]; - assert.match(parseInput, /猫 です ね/); - assert.equal(result.text, '猫\nです\nね'); - assert.equal(result.tokens, null); -}); +for (const c of LINE_NORMALIZATION_CASES) { + test(`tokenizeSubtitle ${c.name}`, async () => { + const lookups: string[] = []; + const result = await tokenizeSubtitle( + c.input, + makeDeps(createBackendDeps({ dictionaries: ['JMdict'], lookups, termsFind: () => null })), + ); + + // The scanner's first lookup window is the whole normalized line. + assert.equal(lookups[0], c.scanned); + assert.equal(result.text, c.text); + assert.equal(result.tokens, null); + }); +} test('tokenizeSubtitle preserves CRLF boundaries between simultaneous cues', async () => { const result = await tokenizeSubtitle('a\r\n\r\nb', makeDeps()); @@ -1699,184 +974,16 @@ test('tokenizeSubtitle preserves CRLF boundaries between simultaneous cues', asy assert.deepEqual(result, { text: 'a\n\nb', tokens: null }); }); -test('tokenizeSubtitle collapses zero-width separators before Yomitan parse request', async () => { - let parseInput = ''; - const result = await tokenizeSubtitle( - 'キリキリと\u200bかかってこい\nこのヘナチョコ冒険者どもめが!', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - parseInput = script; - return null; - }, - }, - }) as unknown as Electron.BrowserWindow, - }), - ); - - assert.match(parseInput, /キリキリと かかってこい このヘナチョコ冒険者どもめが!/); - assert.equal(result.text, 'キリキリと\u200bかかってこい\nこのヘナチョコ冒険者どもめが!'); - assert.equal(result.tokens, null); -}); - test('tokenizeSubtitle returns null tokens when Yomitan parsing is unavailable', async () => { const result = await tokenizeSubtitle('猫です', makeDeps()); assert.deepEqual(result, { text: '猫です', tokens: null }); }); -test('tokenizeSubtitle skips token payload and annotations when Yomitan parse has no dictionary matches', async () => { - let frequencyRequested = false; - let jlptLookupCalls = 0; - let mecabCalls = 0; - - const result = await tokenizeSubtitle( - 'これはテスト', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - frequencyRequested = true; - return []; - } - - return [ - { - source: 'scanning-parser', - index: 0, - content: [ - [{ text: 'これは', reading: 'これは' }], - [{ text: 'テスト', reading: 'てすと' }], - ], - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - tokenizeWithMecab: async () => { - mecabCalls += 1; - return null; - }, - getJlptLevel: () => { - jlptLookupCalls += 1; - return 'N5'; - }, - }), - ); - - assert.deepEqual(result, { text: 'これはテスト', tokens: null }); - assert.equal(frequencyRequested, false); - assert.equal(jlptLookupCalls, 0); - assert.equal(mecabCalls, 0); -}); - -test('tokenizeSubtitle excludes Yomitan token groups without dictionary headwords from annotation paths', async () => { - let jlptLookupCalls = 0; - let frequencyLookupCalls = 0; - - const result = await tokenizeSubtitle( - '(ダクネスの荒い息) 猫', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return []; - } - - return [ - { - source: 'scanning-parser', - index: 0, - content: [ - [{ text: '(ダクネスの荒い息)', reading: 'だくねすのあらいいき' }], - [{ text: '猫', reading: 'ねこ', headwords: [[{ term: '猫' }]] }], - ], - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - getJlptLevel: (text) => { - jlptLookupCalls += 1; - return text === '猫' ? 'N5' : null; - }, - getFrequencyRank: () => { - frequencyLookupCalls += 1; - return 12; - }, - tokenizeWithMecab: async () => null, - }), - ); - - assert.equal(result.tokens?.length, 1); - assert.equal(result.tokens?.[0]?.surface, '猫'); - assert.equal(result.tokens?.[0]?.headword, '猫'); - assert.equal(jlptLookupCalls, 1); - assert.equal(frequencyLookupCalls, 1); -}); - -test('tokenizeSubtitle returns null tokens when mecab throws', async () => { - const result = await tokenizeSubtitle( - '猫です', - makeDeps({ - tokenizeWithMecab: async () => { - throw new Error('mecab failed'); - }, - }), - ); - - assert.deepEqual(result, { text: '猫です', tokens: null }); -}); - test('tokenizeSubtitle uses Yomitan parser result and keeps no-headword groups as surface tokens', 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 tokenizeSubtitle( '猫です', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => parserWindow, - tokenizeWithMecab: async () => null, - }), + makeDepsFromScanningParser([[seg('猫', 'ねこ', '猫')], [seg('です', 'です')]]), ); assert.equal(result.text, '猫です'); @@ -1888,147 +995,10 @@ test('tokenizeSubtitle uses Yomitan parser result and keeps no-headword groups a assert.equal(result.tokens?.[1]?.headword, 'です'); }); -test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled', async () => { - const infoLogs: string[] = []; - const originalInfo = console.info; - setLogLevel('info'); - console.info = (...args: unknown[]) => { - infoLogs.push(args.map((value) => String(value)).join(' ')); - }; - - try { - await tokenizeSubtitle( - '友達と話した', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '友', - reading: 'とも', - headwords: [[{ term: '友達' }]], - }, - { - text: '達', - reading: 'だち', - }, - ], - [ - { - text: 'と', - reading: 'と', - headwords: [[{ term: 'と' }]], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, - tokenizeWithMecab: async () => null, - getYomitanGroupDebugEnabled: () => true, - }), - ); - } finally { - console.info = originalInfo; - setLogLevel(undefined); - } - - assert.ok(infoLogs.some((line) => line.includes('Selected Yomitan token groups'))); -}); - -test('tokenizeSubtitle does not log Yomitan groups when debug toggle is disabled', async () => { - const infoLogs: string[] = []; - const originalInfo = console.info; - console.info = (...args: unknown[]) => { - infoLogs.push(args.map((value) => String(value)).join(' ')); - }; - - try { - await tokenizeSubtitle( - '友達と話した', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '友', - reading: 'とも', - headwords: [[{ term: '友達' }]], - }, - { - text: '達', - reading: 'だち', - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, - tokenizeWithMecab: async () => null, - getYomitanGroupDebugEnabled: () => false, - }), - ); - } finally { - console.info = originalInfo; - } - - assert.equal( - infoLogs.some((line) => line.includes('Selected Yomitan token groups')), - false, - ); -}); - test('tokenizeSubtitle preserves segmented Yomitan line as one token', 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 tokenizeSubtitle( '猫です', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => parserWindow, - tokenizeWithMecab: async () => null, - }), + makeDepsFromScanningParser([[seg('猫', 'ねこ', '猫です'), seg('です', 'です')]]), ); assert.equal(result.text, '猫です'); @@ -2039,338 +1009,6 @@ test('tokenizeSubtitle preserves segmented Yomitan line as one token', async () assert.equal(result.tokens?.[0]?.isKnown, false); }); -test('tokenizeSubtitle keeps scanning parser token when scanning parser returns one token', async () => { - const result = await tokenizeSubtitle( - '俺は小園にいきたい', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '俺は小園にいきたい', - reading: 'おれは小園にいきたい', - headwords: [[{ term: '俺は小園にいきたい' }]], - }, - ], - ], - }, - { - source: 'mecab', - index: 0, - content: [ - [ - { - text: '俺', - reading: 'おれ', - headwords: [[{ term: '俺' }]], - }, - ], - [ - { - text: 'は', - reading: 'は', - headwords: [[{ term: 'は' }]], - }, - ], - [ - { - text: '小園', - reading: 'おうえん', - headwords: [[{ term: '小園' }]], - }, - ], - [ - { - text: 'に', - reading: 'に', - headwords: [[{ term: 'に' }]], - }, - ], - [ - { - text: 'いきたい', - reading: 'いきたい', - headwords: [[{ term: 'いきたい' }]], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, - getFrequencyDictionaryEnabled: () => true, - tokenizeWithMecab: async () => null, - getFrequencyRank: (text) => (text === '小園' ? 25 : text === 'いきたい' ? 1500 : null), - }), - ); - - assert.equal(result.tokens?.length, 1); - assert.equal(result.tokens?.map((token) => token.surface).join(','), '俺は小園にいきたい'); - assert.equal(result.tokens?.[0]?.frequencyRank, undefined); -}); - -test('tokenizeSubtitle keeps scanning parser tokens when they are already split', async () => { - const result = await tokenizeSubtitle( - '小園に行きたい', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '小園', - reading: 'おうえん', - headwords: [[{ term: '小園' }]], - }, - ], - [ - { - text: 'に', - reading: 'に', - headwords: [[{ term: 'に' }]], - }, - ], - [ - { - text: '行きたい', - reading: 'いきたい', - headwords: [[{ term: '行きたい' }]], - }, - ], - ], - }, - { - source: 'mecab', - index: 0, - content: [ - [ - { - text: '小', - reading: 'お', - headwords: [[{ term: '小' }]], - }, - ], - [ - { - text: '園', - reading: 'えん', - headwords: [[{ term: '園' }]], - }, - ], - [ - { - text: 'に', - reading: 'に', - headwords: [[{ term: 'に' }]], - }, - ], - [ - { - text: '行き', - reading: 'いき', - headwords: [[{ term: '行き' }]], - }, - ], - [ - { - text: 'たい', - reading: 'たい', - headwords: [[{ term: 'たい' }]], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === '小園' ? 20 : null), - tokenizeWithMecab: async () => null, - }), - ); - - assert.equal(result.tokens?.length, 3); - assert.equal(result.tokens?.map((token) => token.surface).join(','), '小園,に,行きたい'); - assert.equal(result.tokens?.[0]?.frequencyRank, 20); - assert.equal(result.tokens?.[1]?.frequencyRank, undefined); - assert.equal(result.tokens?.[2]?.frequencyRank, undefined); -}); - -test('tokenizeSubtitle keeps parsing explicit by scanning-parser source only', async () => { - const result = await tokenizeSubtitle( - '俺は公園にいきたい', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '俺', - reading: 'おれ', - headwords: [[{ term: '俺' }]], - }, - ], - [{ text: 'は', reading: '', headwords: [[{ term: 'は' }]] }], - [ - { - text: '公園', - reading: 'こうえん', - headwords: [[{ term: '公園' }]], - }, - ], - [ - { - text: 'にい', - reading: '', - headwords: [[{ term: '兄' }], [{ term: '二位' }]], - }, - ], - [ - { - text: 'きたい', - reading: '', - headwords: [[{ term: '期待' }], [{ term: '来る' }]], - }, - ], - ], - }, - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '俺', - reading: 'おれ', - headwords: [[{ term: '俺' }]], - }, - ], - [ - { - text: 'は', - reading: 'は', - headwords: [[{ term: 'は' }]], - }, - ], - [ - { - text: '公園', - reading: 'こうえん', - headwords: [[{ term: '公園' }]], - }, - ], - [ - { - text: 'に', - reading: 'に', - headwords: [[{ term: 'に' }]], - }, - ], - [ - { - text: '行きたい', - reading: 'いきたい', - headwords: [[{ term: '行きたい' }]], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => - text === '俺' ? 51 : text === '公園' ? 2304 : text === '行きたい' ? 1500 : null, - tokenizeWithMecab: async () => null, - }), - ); - - assert.equal(result.tokens?.map((token) => token.surface).join(','), '俺,は,公園,に,行きたい'); - assert.equal(result.tokens?.[1]?.frequencyRank, undefined); - assert.equal(result.tokens?.[3]?.frequencyRank, undefined); - assert.equal(result.tokens?.[4]?.frequencyRank, 1500); -}); - -test('tokenizeSubtitle still assigns frequency to non-known multi-character Yomitan tokens', async () => { - const result = await tokenizeSubtitle( - '小園友達', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '小園', - reading: 'おうえん', - headwords: [[{ term: '小園' }]], - }, - ], - [ - { - text: '友達', - reading: 'ともだち', - headwords: [[{ term: '友達' }]], - }, - ], - ], - }, - ], - }, - }) as unknown as Electron.BrowserWindow, - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === '小園' ? 75 : text === '友達' ? 3000 : null), - isKnownWord: (text) => text === '小園', - }), - ); - - assert.equal(result.tokens?.length, 2); - assert.equal(result.tokens?.[0]?.isKnown, true); - assert.equal(result.tokens?.[0]?.frequencyRank, 75); - assert.equal(result.tokens?.[1]?.isKnown, false); - assert.equal(result.tokens?.[1]?.frequencyRank, 3000); -}); - -test('tokenizeSubtitle marks tokens as known using callback', async () => { - const result = await tokenizeSubtitle( - '猫です', - makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫' }], { - isKnownWord: (text) => text === '猫', - }), - ); - - assert.equal(result.text, '猫です'); - assert.equal(result.tokens?.[0]?.isKnown, true); -}); - test('tokenizeSubtitle still assigns frequency rank to non-known tokens', async () => { const result = await tokenizeSubtitle( '既知未知', @@ -2414,479 +1052,101 @@ test('tokenizeSubtitle selects one N+1 target token', async () => { assert.equal(targets[0]?.surface, '犬'); }); -test('tokenizeSubtitle does not select kana-only N+1 target tokens', async () => { +test('tokenizeSubtitle keeps correct MeCab pos1 enrichment when Yomitan offsets skip spaces', async () => { const result = await tokenizeSubtitle( - '私のばあい', - makeDepsFromYomitanTokens( + '私も あの仮面が欲しいです', + makeDepsFromScanningParser( [ - { surface: '私', reading: 'わたし', headword: '私' }, - { surface: 'の', reading: 'の', headword: 'の' }, - { surface: 'ばあい', reading: 'ばあい', headword: '場合' }, + [seg('私', 'わたし', '私')], + [seg('も', 'も', 'も')], + [seg('あの', 'あの', 'あの')], + [seg('仮面', 'かめん', '仮面')], + [seg('が', 'が', 'が')], + [seg('欲しい', 'ほしい', '欲しい')], + [seg('です', 'です', 'です')], ], { - getMinSentenceWordsForNPlusOne: () => 2, - isKnownWord: (text) => text === '私', + tokenizeWithMecab: async () => [ + mecabToken('私', 0, '名詞'), + mecabToken('も', 1, '助詞'), + mecabToken(' ', 2, '記号'), + mecabToken('あの', 3, '連体詞'), + mecabToken('仮面', 5, '名詞'), + mecabToken('が', 7, '助詞'), + mecabToken('欲しい', 8, '形容詞'), + mecabToken('です', 11, '助動詞'), + ], + isKnownWord: (text) => text === '私' || text === 'あの' || text === '欲しい', }, ), ); - assert.equal(result.tokens?.length, 3); - assert.equal( - result.tokens?.some((token) => token.isNPlusOneTarget), - false, - ); -}); - -test('tokenizeSubtitle does not mark target when sentence has multiple candidates', async () => { - const result = await tokenizeSubtitle( - '猫犬', - makeDepsFromYomitanTokens( - [ - { surface: '猫', reading: 'ねこ', headword: '猫' }, - { surface: '犬', reading: 'いぬ', headword: '犬' }, - ], - {}, - ), - ); - - assert.equal( - result.tokens?.some((token) => token.isNPlusOneTarget), - false, - ); -}); - -test('tokenizeSubtitle applies N+1 target marking to Yomitan results', async () => { - const parserWindow = { - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [ - { - text: '猫', - reading: 'ねこ', - headwords: [[{ term: '猫' }]], - }, - ], - [ - { - text: 'です', - reading: 'です', - headwords: [[{ term: 'です' }]], - }, - ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow; - - const result = await tokenizeSubtitle( - '猫です', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => parserWindow, - tokenizeWithMecab: async () => null, - isKnownWord: (text) => text === 'です', - getMinSentenceWordsForNPlusOne: () => 1, - }), - ); - - assert.equal(result.text, '猫です'); - assert.equal(result.tokens?.length, 2); - assert.equal(result.tokens?.[0]?.surface, '猫'); - assert.equal(result.tokens?.[0]?.isNPlusOneTarget, true); - assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false); -}); - -test('tokenizeSubtitle ignores Yomitan functional tokens when evaluating N+1 candidates', async () => { - const parserWindow = { - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [{ text: '私', reading: 'わたし', headwords: [[{ term: '私' }]] }], - [{ text: 'も', reading: 'も', headwords: [[{ term: 'も' }]] }], - [{ text: 'あの', reading: 'あの', headwords: [[{ term: 'あの' }]] }], - [{ text: '仮面', reading: 'かめん', headwords: [[{ term: '仮面' }]] }], - [{ text: 'が', reading: 'が', headwords: [[{ term: 'が' }]] }], - [{ text: '欲しい', reading: 'ほしい', headwords: [[{ term: '欲しい' }]] }], - [{ text: 'です', reading: 'です', headwords: [[{ term: 'です' }]] }], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow; - - const result = await tokenizeSubtitle( - '私も あの仮面が欲しいです', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => parserWindow, - tokenizeWithMecab: async () => [ - { - surface: '私', - reading: 'ワタシ', - headword: '私', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - isMerged: false, - isKnown: true, - isNPlusOneTarget: false, - }, - { - surface: 'も', - reading: 'モ', - headword: 'も', - startPos: 1, - endPos: 2, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - surface: 'あの', - reading: 'アノ', - headword: 'あの', - startPos: 2, - endPos: 4, - partOfSpeech: PartOfSpeech.other, - pos1: '連体詞', - isMerged: false, - isKnown: true, - isNPlusOneTarget: false, - }, - { - surface: '仮面', - reading: 'カメン', - headword: '仮面', - startPos: 4, - endPos: 6, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - surface: 'が', - reading: 'ガ', - headword: 'が', - startPos: 6, - endPos: 7, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - surface: '欲しい', - reading: 'ホシイ', - headword: '欲しい', - startPos: 7, - endPos: 10, - partOfSpeech: PartOfSpeech.i_adjective, - pos1: '形容詞', - isMerged: false, - isKnown: true, - isNPlusOneTarget: false, - }, - { - surface: 'です', - reading: 'デス', - headword: 'です', - startPos: 10, - endPos: 12, - partOfSpeech: PartOfSpeech.bound_auxiliary, - pos1: '助動詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - isKnownWord: (text) => text === '私' || text === 'あの' || text === '欲しい', - }), - ); - const targets = result.tokens?.filter((token) => token.isNPlusOneTarget) ?? []; - assert.equal(targets.length, 1); - assert.equal(targets[0]?.surface, '仮面'); -}); - -test('tokenizeSubtitle keeps correct MeCab pos1 enrichment when Yomitan offsets skip spaces', async () => { - const parserWindow = { - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: 'scanning-parser', - index: 0, - content: [ - [{ text: '私', reading: 'わたし', headwords: [[{ term: '私' }]] }], - [{ text: 'も', reading: 'も', headwords: [[{ term: 'も' }]] }], - [{ text: 'あの', reading: 'あの', headwords: [[{ term: 'あの' }]] }], - [{ text: '仮面', reading: 'かめん', headwords: [[{ term: '仮面' }]] }], - [{ text: 'が', reading: 'が', headwords: [[{ term: 'が' }]] }], - [{ text: '欲しい', reading: 'ほしい', headwords: [[{ term: '欲しい' }]] }], - [{ text: 'です', reading: 'です', headwords: [[{ term: 'です' }]] }], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow; - - const result = await tokenizeSubtitle( - '私も あの仮面が欲しいです', - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => parserWindow, - tokenizeWithMecab: async () => [ - { - surface: '私', - reading: 'ワタシ', - headword: '私', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - isMerged: false, - isKnown: true, - isNPlusOneTarget: false, - }, - { - surface: 'も', - reading: 'モ', - headword: 'も', - startPos: 1, - endPos: 2, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - surface: ' ', - reading: '', - headword: ' ', - startPos: 2, - endPos: 3, - partOfSpeech: PartOfSpeech.symbol, - pos1: '記号', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - surface: 'あの', - reading: 'アノ', - headword: 'あの', - startPos: 3, - endPos: 5, - partOfSpeech: PartOfSpeech.other, - pos1: '連体詞', - isMerged: false, - isKnown: true, - isNPlusOneTarget: false, - }, - { - surface: '仮面', - reading: 'カメン', - headword: '仮面', - startPos: 5, - endPos: 7, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - surface: 'が', - reading: 'ガ', - headword: 'が', - startPos: 7, - endPos: 8, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - surface: '欲しい', - reading: 'ホシイ', - headword: '欲しい', - startPos: 8, - endPos: 11, - partOfSpeech: PartOfSpeech.i_adjective, - pos1: '形容詞', - isMerged: false, - isKnown: true, - isNPlusOneTarget: false, - }, - { - surface: 'です', - reading: 'デス', - headword: 'です', - startPos: 11, - endPos: 13, - partOfSpeech: PartOfSpeech.bound_auxiliary, - pos1: '助動詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - isKnownWord: (text) => text === '私' || text === 'あの' || text === '欲しい', - }), - ); - - const targets = result.tokens?.filter((token) => token.isNPlusOneTarget) ?? []; - const gaToken = result.tokens?.find((token) => token.surface === 'が'); - const desuToken = result.tokens?.find((token) => token.surface === 'です'); - assert.equal(gaToken?.pos1, '助詞'); - assert.equal(gaToken?.isKnown, false); - assert.equal(gaToken?.isNPlusOneTarget, false); - assert.equal(gaToken?.jlptLevel, undefined); - assert.equal(gaToken?.frequencyRank, undefined); - assert.equal(desuToken?.pos1, '助動詞'); - assert.equal(desuToken?.isKnown, false); - assert.equal(desuToken?.isNPlusOneTarget, false); - assert.equal(desuToken?.jlptLevel, undefined); - assert.equal(desuToken?.frequencyRank, undefined); + for (const [surface, pos1] of [ + ['が', '助詞'], + ['です', '助動詞'], + ]) { + const token = result.tokens?.find((candidate) => candidate.surface === surface); + assert.deepEqual( + { + pos1: token?.pos1, + isKnown: token?.isKnown, + isNPlusOneTarget: token?.isNPlusOneTarget, + jlptLevel: token?.jlptLevel, + frequencyRank: token?.frequencyRank, + }, + { + pos1, + isKnown: false, + isNPlusOneTarget: false, + jlptLevel: undefined, + frequencyRank: undefined, + }, + surface, + ); + } assert.equal(targets.length, 1); assert.equal(targets[0]?.surface, '仮面'); }); test('tokenizeSubtitle preserves merged token frequency when MeCab positions cross a newline gap', async () => { - const parserWindow = { - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return script.includes('"term":"陰に","reading":"いんに"') - ? [ - { - term: '陰に', - reading: 'いんに', - dictionary: 'JPDBv2㋕', - frequency: 5702, - displayValue: '5702', - displayValueParsed: false, - }, - ] - : []; - } - - return [ - { - surface: 'X', - reading: 'えっくす', - headword: 'X', - startPos: 0, - endPos: 1, - }, - { - surface: '陰に', - reading: 'いんに', - headword: '陰に', - startPos: 2, - endPos: 4, - }, - { - surface: '潜み', - reading: 'ひそ', - headword: '潜む', - startPos: 4, - endPos: 6, - }, - ]; - }, - }, - } as unknown as Electron.BrowserWindow; - - const deps = createTokenizerDepsRuntime({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => parserWindow, - setYomitanParserWindow: () => {}, - getYomitanParserReadyPromise: () => null, - setYomitanParserReadyPromise: () => {}, - getYomitanParserInitPromise: () => null, - setYomitanParserInitPromise: () => {}, - isKnownWord: () => false, - getKnownWordMatchMode: () => 'headword', - getJlptLevel: () => null, - getFrequencyDictionaryEnabled: () => true, - getMecabTokenizer: () => ({ - tokenize: async () => [ - { - word: 'X', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - pos3: '', - pos4: '', - inflectionType: '', - inflectionForm: '', - headword: 'X', - katakanaReading: 'エックス', - pronunciation: 'エックス', - }, - { - word: '陰', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - pos3: '', - pos4: '', - inflectionType: '', - inflectionForm: '', - headword: '陰', - katakanaReading: 'カゲ', - pronunciation: 'カゲ', - }, - { - word: 'に', - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '格助詞', - pos3: '一般', - pos4: '', - inflectionType: '', - inflectionForm: '', - headword: 'に', - katakanaReading: 'ニ', - pronunciation: 'ニ', - }, - { - word: '潜み', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - pos3: '', - pos4: '', - inflectionType: '五段・マ行', - inflectionForm: '連用形', - headword: '潜む', - katakanaReading: 'ヒソミ', - pronunciation: 'ヒソミ', - }, - ], - }), + const parserWindow = createYomitanParserWindow({ + respond: scanTokens([ + { surface: 'X', reading: 'えっくす' }, + { surface: '陰に', reading: 'いんに', startPos: 2 }, + { surface: '潜み', reading: 'ひそ', headword: '潜む' }, + ]), + frequencies: (pairs) => + requestsPair(pairs, '陰に', 'いんに') + ? [ + yomitanFrequency('陰に', 'いんに', 5702, { + dictionary: 'JPDBv2㋕', + displayValueParsed: false, + }), + ] + : [], }); + const deps = createTokenizerDepsRuntime( + makeRuntimeOptions({ + getYomitanExt: () => ({ id: 'dummy-ext' }) as Electron.Extension, + getYomitanParserWindow: () => parserWindow, + getFrequencyDictionaryEnabled: () => true, + getMecabTokenizer: () => ({ + tokenize: async () => [ + mecabWord('X', PartOfSpeech.noun, '名詞', '一般', 'エックス'), + mecabWord('陰', PartOfSpeech.noun, '名詞', '一般', 'カゲ'), + mecabWord('に', PartOfSpeech.particle, '助詞', '格助詞', 'ニ', { pos3: '一般' }), + mecabWord('潜み', PartOfSpeech.verb, '動詞', '自立', 'ヒソミ', { + headword: '潜む', + inflectionType: '五段・マ行', + inflectionForm: '連用形', + }), + ], + }), + }), + ); + const result = await tokenizeSubtitle('X\n陰に潜み', deps); assert.equal(result.tokens?.[1]?.surface, '陰に'); @@ -2895,36 +1155,6 @@ test('tokenizeSubtitle preserves merged token frequency when MeCab positions cro assert.equal(result.tokens?.[1]?.frequencyRank, 5702); }); -test('tokenizeSubtitle does not color 1-2 word sentences by default', async () => { - const result = await tokenizeSubtitle( - '猫です', - makeDepsFromYomitanTokens( - [ - { surface: '私', reading: 'わたし', headword: '私' }, - { surface: '犬', reading: 'いぬ', headword: '犬' }, - ], - {}, - ), - ); - - assert.equal( - result.tokens?.some((token) => token.isNPlusOneTarget), - false, - ); -}); - -test('tokenizeSubtitle checks known words by headword, not surface', async () => { - const result = await tokenizeSubtitle( - '猫です', - makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫です' }], { - isKnownWord: (text) => text === '猫です', - }), - ); - - assert.equal(result.text, '猫です'); - assert.equal(result.tokens?.[0]?.isKnown, true); -}); - test('tokenizeSubtitle checks known words by surface when configured', async () => { const result = await tokenizeSubtitle( '猫です', @@ -2942,96 +1172,21 @@ test('tokenizeSubtitle preserves Yomitan compound token when MeCab components ar const text = '取り組んでもらいます'; const result = await tokenizeSubtitle( text, - makeDeps({ - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return []; - } - - return [ - { - surface: '取り組んで', - reading: 'とりくんで', - headword: '取り組む', - startPos: 0, - endPos: 5, - }, - { - surface: 'もらいます', - reading: 'もらいます', - headword: 'もらう', - startPos: 5, - endPos: 10, - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - isKnownWord: (word) => word === '取る' || word === '組む' || word === 'もらう', - tokenizeWithMecab: async () => [ - { - headword: '取り組む', - surface: '取り組ん', - reading: 'トリクン', - startPos: 0, - endPos: 4, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - pos3: '*', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'で', - surface: 'で', - reading: 'デ', - startPos: 4, - endPos: 5, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '接続助詞', - pos3: '*', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'もらう', - surface: 'もらい', - reading: 'モライ', - startPos: 5, - endPos: 8, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '非自立', - pos3: '*', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'ます', - surface: 'ます', - reading: 'マス', - startPos: 8, - endPos: 10, - partOfSpeech: PartOfSpeech.bound_auxiliary, - pos1: '助動詞', - pos2: '*', - pos3: '*', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, + makeDepsFromYomitanTokens( + [ + { surface: '取り組んで', reading: 'とりくんで', headword: '取り組む' }, + { surface: 'もらいます', reading: 'もらいます', headword: 'もらう' }, ], - }), + { + isKnownWord: (word) => word === '取る' || word === '組む' || word === 'もらう', + tokenizeWithMecab: async () => [ + mecabToken('取り組ん', 0, '動詞', '自立', '*'), + mecabToken('で', 4, '助詞', '接続助詞', '*'), + mecabToken('もらい', 5, '動詞', '非自立', '*'), + mecabToken('ます', 8, '助動詞', '*', '*'), + ], + }, + ), ); assert.equal(result.text, text); @@ -3055,50 +1210,25 @@ test('tokenizeSubtitle uses frequency surface match mode when configured', async assert.equal(result.tokens?.[0]?.frequencyRank, 2847); }); +const KAMEN_WORD = mecabWord('仮面', PartOfSpeech.noun, '名詞', '一般', 'カメン'); + test('createTokenizerDepsRuntime checks MeCab availability before first tokenizeWithMecab call', async () => { let available = false; let checkCalls = 0; - const deps = createTokenizerDepsRuntime({ - getYomitanExt: () => null, - getYomitanParserWindow: () => null, - setYomitanParserWindow: () => {}, - getYomitanParserReadyPromise: () => null, - setYomitanParserReadyPromise: () => {}, - getYomitanParserInitPromise: () => null, - setYomitanParserInitPromise: () => {}, - isKnownWord: () => false, - getKnownWordMatchMode: () => 'headword', - getJlptLevel: () => null, - getMecabTokenizer: () => ({ - getStatus: () => ({ available }), - checkAvailability: async () => { - checkCalls += 1; - available = true; - return true; - }, - tokenize: async () => { - if (!available) { - return null; - } - return [ - { - word: '仮面', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - pos3: '', - pos4: '', - inflectionType: '', - inflectionForm: '', - headword: '仮面', - katakanaReading: 'カメン', - pronunciation: 'カメン', - }, - ]; - }, + const deps = createTokenizerDepsRuntime( + makeRuntimeOptions({ + getMecabTokenizer: () => ({ + getStatus: () => ({ available }), + checkAvailability: async () => { + checkCalls += 1; + available = true; + return true; + }, + tokenize: async () => (available ? [KAMEN_WORD] : null), + }), }), - }); + ); const first = await deps.tokenizeWithMecab('仮面'); const second = await deps.tokenizeWithMecab('仮面'); @@ -3111,38 +1241,15 @@ test('createTokenizerDepsRuntime checks MeCab availability before first tokenize test('createTokenizerDepsRuntime skips known-word lookup for MeCab POS enrichment tokens', async () => { let knownWordCalls = 0; - const deps = createTokenizerDepsRuntime({ - getYomitanExt: () => null, - getYomitanParserWindow: () => null, - setYomitanParserWindow: () => {}, - getYomitanParserReadyPromise: () => null, - setYomitanParserReadyPromise: () => {}, - getYomitanParserInitPromise: () => null, - setYomitanParserInitPromise: () => {}, - isKnownWord: () => { - knownWordCalls += 1; - return true; - }, - getKnownWordMatchMode: () => 'headword', - getJlptLevel: () => null, - getMecabTokenizer: () => ({ - tokenize: async () => [ - { - word: '仮面', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - pos3: '', - pos4: '', - inflectionType: '', - inflectionForm: '', - headword: '仮面', - katakanaReading: 'カメン', - pronunciation: 'カメン', - }, - ], + const deps = createTokenizerDepsRuntime( + makeRuntimeOptions({ + isKnownWord: () => { + knownWordCalls += 1; + return true; + }, + getMecabTokenizer: () => ({ tokenize: async () => [KAMEN_WORD] }), }), - }); + ); const tokens = await deps.tokenizeWithMecab('仮面'); @@ -3150,84 +1257,6 @@ test('createTokenizerDepsRuntime skips known-word lookup for MeCab POS enrichmen assert.equal(tokens?.[0]?.isKnown, false); }); -test('tokenizeSubtitle uses async MeCab enrichment override when provided', async () => { - const result = await tokenizeSubtitle( - '猫', - makeDepsFromYomitanTokens([{ surface: '猫', reading: 'ねこ', headword: '猫' }], { - tokenizeWithMecab: async () => [ - { - headword: '猫', - surface: '猫', - reading: 'ネコ', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - isMerged: true, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - enrichTokensWithMecab: async (tokens) => - tokens.map((token) => ({ - ...token, - pos1: 'override-pos', - })), - }), - ); - - assert.equal(result.tokens?.length, 1); - assert.equal(result.tokens?.[0]?.pos1, 'override-pos'); -}); - -test('createTokenizerDepsRuntime exposes async MeCab enrichment helper', async () => { - const deps = createTokenizerDepsRuntime({ - getYomitanExt: () => null, - getYomitanParserWindow: () => null, - setYomitanParserWindow: () => {}, - getYomitanParserReadyPromise: () => null, - setYomitanParserReadyPromise: () => {}, - getYomitanParserInitPromise: () => null, - setYomitanParserInitPromise: () => {}, - isKnownWord: () => false, - getKnownWordMatchMode: () => 'headword', - getJlptLevel: () => null, - getMecabTokenizer: () => null, - }); - - const enriched = await deps.enrichTokensWithMecab?.( - [ - { - headword: 'は', - surface: 'は', - reading: 'は', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.other, - isMerged: true, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - [ - { - headword: 'は', - surface: 'は', - reading: 'ハ', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - ); - - assert.equal(enriched?.[0]?.pos1, '助詞'); -}); - test('tokenizeSubtitle skips all enrichment stages when disabled', async () => { let knownCalls = 0; let mecabCalls = 0; @@ -3306,7 +1335,6 @@ test('tokenizeSubtitle uses Yomitan word classes to classify auxiliary subclasse getFrequencyDictionaryEnabled: () => true, getFrequencyRank: () => 10, getJlptLevel: () => 'N5', - tokenizeWithMecab: async () => null, }, ), ); @@ -3323,24 +1351,7 @@ test('tokenizeSubtitle fills detailed MeCab POS when Yomitan word class supplies 'は', makeDepsFromYomitanTokens( [{ surface: 'は', reading: 'は', headword: 'は', wordClasses: ['prt'] }], - { - tokenizeWithMecab: async () => [ - { - headword: 'は', - surface: 'は', - reading: 'ハ', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '係助詞', - pos3: '*', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - }, + { tokenizeWithMecab: async () => [mecabToken('は', 0, '助詞', '係助詞', '*')] }, ), ); @@ -3370,20 +1381,7 @@ test('tokenizeSubtitle keeps frequency enrichment while n+1 is disabled', async }, tokenizeWithMecab: async () => { mecabCalls += 1; - return [ - { - headword: '猫', - surface: '猫', - reading: 'ネコ', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ]; + return [mecabToken('猫', 0, '名詞')]; }, }), ); @@ -3395,207 +1393,27 @@ test('tokenizeSubtitle keeps frequency enrichment while n+1 is disabled', async assert.equal(frequencyCalls, 1); }); -test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and frequency annotations', async () => { - const result = await tokenizeSubtitle( - 'になれば', - makeDepsFromYomitanTokens([{ surface: 'になれば', reading: 'になれば', headword: 'なる' }], { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === 'なる' ? 11 : null), - tokenizeWithMecab: async () => [ - { - headword: 'なる', - surface: 'になれば', - reading: 'ニナレバ', - startPos: 0, - endPos: 4, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '非自立', - isMerged: true, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - getMinSentenceWordsForNPlusOne: () => 1, - }), - ); - - assert.equal(result.tokens?.length, 1); - assert.equal(result.tokens?.[0]?.frequencyRank, undefined); - assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false); -}); - -test('tokenizeSubtitle keeps known-word highlight for exact non-independent kanji noun tokens', async () => { - const result = await tokenizeSubtitle( - 'その点', - makeDepsFromYomitanTokens( - [ - { surface: 'その', reading: 'その', headword: 'その' }, - { surface: '点', reading: 'てん', headword: '点' }, - ], - { - isKnownWord: (text) => text === '点' || text === 'てん', - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === '点' ? 1384 : null), - getJlptLevel: (text) => (text === '点' ? 'N3' : null), - tokenizeWithMecab: async () => [ - { - headword: 'その', - surface: 'その', - reading: 'ソノ', - startPos: 0, - endPos: 2, - partOfSpeech: PartOfSpeech.other, - pos1: '連体詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '点', - surface: '点', - reading: 'テン', - startPos: 2, - endPos: 3, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '非自立', - pos3: '一般', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - }, - ), - ); - - assert.equal(result.tokens?.length, 2); - assert.equal(result.tokens?.[0]?.isKnown, false); - assert.equal(result.tokens?.[1]?.surface, '点'); - assert.equal(result.tokens?.[1]?.isKnown, true); - assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false); - assert.equal(result.tokens?.[1]?.frequencyRank, 1384); - assert.equal(result.tokens?.[1]?.jlptLevel, 'N3'); -}); - -test('tokenizeSubtitle keeps mecab-tagged interjections tokenized while clearing annotation metadata', async () => { - const result = await tokenizeSubtitle( - 'ぐはっ', - makeDepsFromYomitanTokens([{ surface: 'ぐはっ', reading: 'ぐはっ', headword: 'ぐはっ' }], { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: () => 17, - getJlptLevel: () => 'N5', - tokenizeWithMecab: async () => [ - { - headword: 'ぐはっ', - surface: 'ぐはっ', - reading: 'グハッ', - startPos: 0, - endPos: 3, - partOfSpeech: PartOfSpeech.other, - pos1: '感動詞', - isMerged: true, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - }), - ); - - assert.equal(result.text, 'ぐはっ'); - assert.deepEqual( - result.tokens?.map((token) => ({ - surface: token.surface, - headword: token.headword, - reading: token.reading, - pos1: token.pos1, - jlptLevel: token.jlptLevel, - frequencyRank: token.frequencyRank, - isKnown: token.isKnown, - isNPlusOneTarget: token.isNPlusOneTarget, - })), - [ - { - surface: 'ぐはっ', - headword: 'ぐはっ', - reading: 'ぐはっ', - pos1: '感動詞', - jlptLevel: undefined, - frequencyRank: undefined, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - ); -}); - test('tokenizeSubtitle keeps excluded interjections hoverable while clearing annotation metadata', async () => { const result = await tokenizeSubtitle( 'ぐはっ 猫', - makeDeps({ + makeDepsFromScanningParser([[seg('ぐはっ', 'ぐはっ', 'ぐはっ')], [seg('猫', 'ねこ', '猫')]], { getFrequencyDictionaryEnabled: () => true, getFrequencyRank: (text) => (text === '猫' ? 11 : 17), getJlptLevel: (text) => (text === '猫' ? 'N5' : null), - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return []; - } - - return [ - { - source: 'scanning-parser', - index: 0, - content: [ - [{ text: 'ぐはっ', reading: 'ぐはっ', headwords: [[{ term: 'ぐはっ' }]] }], - [{ text: '猫', reading: 'ねこ', headwords: [[{ term: '猫' }]] }], - ], - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, tokenizeWithMecab: async () => [ - { - headword: 'ぐはっ', - surface: 'ぐはっ', - reading: 'グハッ', - startPos: 0, - endPos: 3, - partOfSpeech: PartOfSpeech.other, - pos1: '感動詞', - isMerged: true, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '猫', - surface: '猫', - reading: 'ネコ', - startPos: 4, - endPos: 5, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - isMerged: true, - isKnown: false, - isNPlusOneTarget: false, - }, + mecabToken('ぐはっ', 0, '感動詞'), + mecabToken('猫', 4, '名詞'), ], }), ); assert.equal(result.text, 'ぐはっ 猫'); assert.deepEqual( - result.tokens?.map((token) => ({ - surface: token.surface, - headword: token.headword, - frequencyRank: token.frequencyRank, - jlptLevel: token.jlptLevel, + result.tokens?.map(({ surface, headword, frequencyRank, jlptLevel }) => ({ + surface, + headword, + frequencyRank, + jlptLevel, })), [ { surface: 'ぐはっ', headword: 'ぐはっ', frequencyRank: undefined, jlptLevel: undefined }, @@ -3604,185 +1422,39 @@ test('tokenizeSubtitle keeps excluded interjections hoverable while clearing ann ); }); -test('tokenizeSubtitle keeps explanatory ending variants hoverable while clearing annotation metadata', async () => { +test('tokenizeSubtitle keeps standalone grammar-only tokens hoverable while clearing annotation metadata', async () => { const result = await tokenizeSubtitle( - '猫んです', - makeDepsFromYomitanTokens( + '私はこの猫です', + makeDepsFromScanningParser( [ - { surface: '猫', reading: 'ねこ', headword: '猫' }, - { surface: 'んです', reading: 'んです', headword: 'ん' }, + [seg('私', 'わたし', '私')], + [seg('は', 'は', 'は')], + [seg('この', 'この', 'この')], + [seg('猫', 'ねこ', '猫')], + [seg('です', 'です', 'です')], ], { getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === '猫' ? 11 : 500), - getJlptLevel: (text) => (text === '猫' ? 'N5' : null), + getFrequencyRank: (text) => (text === '私' ? 50 : text === '猫' ? 11 : 500), + getJlptLevel: (text) => (text === '私' ? 'N5' : text === '猫' ? 'N5' : null), tokenizeWithMecab: async () => [ - { - headword: '猫', - surface: '猫', - reading: 'ネコ', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'ん', - surface: 'ん', - reading: 'ン', - startPos: 1, - endPos: 2, - partOfSpeech: PartOfSpeech.other, - pos1: '名詞', - pos2: '非自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'です', - surface: 'です', - reading: 'デス', - startPos: 2, - endPos: 4, - partOfSpeech: PartOfSpeech.bound_auxiliary, - pos1: '助動詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, + mecabToken('私', 0, '名詞', '代名詞'), + mecabToken('は', 1, '助詞', '係助詞'), + mecabToken('この', 2, '連体詞'), + mecabToken('猫', 4, '名詞', '一般'), + mecabToken('です', 5, '助動詞'), ], }, ), ); - assert.equal(result.text, '猫んです'); - assert.deepEqual( - result.tokens?.map((token) => ({ - surface: token.surface, - headword: token.headword, - jlptLevel: token.jlptLevel, - frequencyRank: token.frequencyRank, - })), - [ - { surface: '猫', headword: '猫', jlptLevel: 'N5', frequencyRank: 11 }, - { surface: 'んです', headword: 'ん', jlptLevel: undefined, frequencyRank: undefined }, - ], - ); -}); - -test('tokenizeSubtitle keeps standalone grammar-only tokens hoverable while clearing annotation metadata', async () => { - const result = await tokenizeSubtitle( - '私はこの猫です', - makeDeps({ - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === '私' ? 50 : text === '猫' ? 11 : 500), - getJlptLevel: (text) => (text === '私' ? 'N5' : text === '猫' ? 'N5' : null), - getYomitanExt: () => ({ id: 'dummy-ext' }) as any, - getYomitanParserWindow: () => - ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async (script: string) => { - if (script.includes('getTermFrequencies')) { - return []; - } - - return [ - { - source: 'scanning-parser', - index: 0, - content: [ - [{ text: '私', reading: 'わたし', headwords: [[{ term: '私' }]] }], - [{ text: 'は', reading: 'は', headwords: [[{ term: 'は' }]] }], - [{ text: 'この', reading: 'この', headwords: [[{ term: 'この' }]] }], - [{ text: '猫', reading: 'ねこ', headwords: [[{ term: '猫' }]] }], - [{ text: 'です', reading: 'です', headwords: [[{ term: 'です' }]] }], - ], - }, - ]; - }, - }, - }) as unknown as Electron.BrowserWindow, - tokenizeWithMecab: async () => [ - { - headword: '私', - surface: '私', - reading: 'ワタシ', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '代名詞', - isMerged: true, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'は', - surface: 'は', - reading: 'ハ', - startPos: 1, - endPos: 2, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '係助詞', - isMerged: true, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'この', - surface: 'この', - reading: 'コノ', - startPos: 2, - endPos: 4, - partOfSpeech: PartOfSpeech.other, - pos1: '連体詞', - isMerged: true, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '猫', - surface: '猫', - reading: 'ネコ', - startPos: 4, - endPos: 5, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - isMerged: true, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'です', - surface: 'です', - reading: 'デス', - startPos: 5, - endPos: 7, - partOfSpeech: PartOfSpeech.bound_auxiliary, - pos1: '助動詞', - isMerged: true, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - }), - ); - assert.equal(result.text, '私はこの猫です'); assert.deepEqual( - result.tokens?.map((token) => ({ - surface: token.surface, - headword: token.headword, - frequencyRank: token.frequencyRank, - jlptLevel: token.jlptLevel, + result.tokens?.map(({ surface, headword, frequencyRank, jlptLevel }) => ({ + surface, + headword, + frequencyRank, + jlptLevel, })), [ { surface: '私', headword: '私', frequencyRank: 50, jlptLevel: 'N5' }, @@ -3794,456 +1466,6 @@ test('tokenizeSubtitle keeps standalone grammar-only tokens hoverable while clea ); }); -test('tokenizeSubtitle keeps trailing quote-particle merged tokens hoverable while clearing annotation metadata', async () => { - const result = await tokenizeSubtitle( - 'どうしてもって', - makeDepsFromYomitanTokens( - [{ surface: 'どうしてもって', reading: 'どうしてもって', headword: 'どうしても' }], - { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === 'どうしても' ? 123 : null), - getJlptLevel: (text) => (text === 'どうしても' ? 'N3' : null), - tokenizeWithMecab: async () => [ - { - headword: 'どうしても', - surface: 'どうしても', - reading: 'ドウシテモ', - startPos: 0, - endPos: 5, - partOfSpeech: PartOfSpeech.other, - pos1: '副詞', - pos2: '一般', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'って', - surface: 'って', - reading: 'ッテ', - startPos: 5, - endPos: 7, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '格助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - getMinSentenceWordsForNPlusOne: () => 1, - }, - ), - ); - - assert.equal(result.text, 'どうしてもって'); - assert.deepEqual( - result.tokens?.map((token) => ({ - surface: token.surface, - headword: token.headword, - jlptLevel: token.jlptLevel, - frequencyRank: token.frequencyRank, - })), - [ - { - surface: 'どうしてもって', - headword: 'どうしても', - jlptLevel: undefined, - frequencyRank: undefined, - }, - ], - ); -}); - -test('tokenizeSubtitle keeps auxiliary-stem そうだ grammar tails hoverable while clearing annotation metadata', async () => { - const result = await tokenizeSubtitle( - '与えるそうだ', - makeDepsFromYomitanTokens( - [ - { surface: '与える', reading: 'あたえる', headword: '与える' }, - { surface: 'そうだ', reading: 'そうだ', headword: 'そうだ' }, - ], - { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === '与える' ? 100 : text === 'そうだ' ? 12 : null), - getJlptLevel: (text) => (text === '与える' ? 'N3' : text === 'そうだ' ? 'N5' : null), - tokenizeWithMecab: async () => [ - { - headword: '与える', - surface: '与える', - reading: 'アタエル', - startPos: 0, - endPos: 3, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'そう', - surface: 'そう', - reading: 'ソウ', - startPos: 3, - endPos: 5, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '特殊', - pos3: '助動詞語幹', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'だ', - surface: 'だ', - reading: 'ダ', - startPos: 5, - endPos: 6, - partOfSpeech: PartOfSpeech.bound_auxiliary, - pos1: '助動詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - getMinSentenceWordsForNPlusOne: () => 1, - }, - ), - ); - - assert.equal(result.text, '与えるそうだ'); - assert.deepEqual( - result.tokens?.map((token) => ({ - surface: token.surface, - headword: token.headword, - frequencyRank: token.frequencyRank, - jlptLevel: token.jlptLevel, - })), - [ - { surface: '与える', headword: '与える', frequencyRank: 100, jlptLevel: 'N3' }, - { surface: 'そうだ', headword: 'そうだ', frequencyRank: undefined, jlptLevel: undefined }, - ], - ); -}); - -test('tokenizeSubtitle excludes single-kana merged tokens from frequency highlighting', async () => { - const result = await tokenizeSubtitle( - 'た', - makeDepsFromYomitanTokens([{ surface: 'た', reading: 'た', headword: 'た' }], { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === 'た' ? 17 : null), - getMinSentenceWordsForNPlusOne: () => 1, - tokenizeWithMecab: async () => null, - }), - ); - - assert.equal(result.tokens?.length, 1); - assert.equal(result.tokens?.[0]?.frequencyRank, undefined); -}); - -test('tokenizeSubtitle excludes merged kana-only function/content token from frequency and N+1', async () => { - const result = await tokenizeSubtitle( - 'になれば', - makeDepsFromYomitanTokens([{ surface: 'になれば', reading: 'になれば', headword: 'なる' }], { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === 'なる' ? 13 : null), - tokenizeWithMecab: async () => [ - { - headword: 'に', - surface: 'に', - reading: 'ニ', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '格助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'なる', - surface: 'なれ', - reading: 'ナレ', - startPos: 1, - endPos: 3, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'ば', - surface: 'ば', - reading: 'バ', - startPos: 3, - endPos: 4, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '接続助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - getMinSentenceWordsForNPlusOne: () => 1, - }), - ); - - assert.equal(result.tokens?.length, 1); - assert.equal(result.tokens?.[0]?.pos1, '助詞|動詞'); - assert.equal(result.tokens?.[0]?.frequencyRank, undefined); - assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false); -}); - -test('tokenizeSubtitle clears all annotations for kana-only demonstrative helper merges', async () => { - const result = await tokenizeSubtitle( - 'これで実力どおりか', - makeDepsFromYomitanTokens( - [ - { surface: 'これで', reading: 'これで', headword: 'これ' }, - { surface: '実力どおり', reading: 'じつりょくどおり', headword: '実力どおり' }, - { surface: 'か', reading: 'か', headword: 'か' }, - ], - { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => - text === 'これ' ? 9 : text === '実力どおり' ? 2500 : text === 'か' ? 800 : null, - getJlptLevel: (text) => - text === 'これ' ? 'N5' : text === '実力どおり' ? 'N1' : text === 'か' ? 'N5' : null, - isKnownWord: (text) => text === 'これ', - getMinSentenceWordsForNPlusOne: () => 1, - tokenizeWithMecab: async () => [ - { - headword: 'これ', - surface: 'これ', - reading: 'コレ', - startPos: 0, - endPos: 2, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '代名詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'で', - surface: 'で', - reading: 'デ', - startPos: 2, - endPos: 3, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '格助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '実力どおり', - surface: '実力どおり', - reading: 'ジツリョクドオリ', - startPos: 3, - endPos: 8, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'か', - surface: 'か', - reading: 'カ', - startPos: 8, - endPos: 9, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '終助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - }, - ), - ); - - assert.deepEqual( - result.tokens?.map((token) => ({ - surface: token.surface, - headword: token.headword, - isKnown: token.isKnown, - isNPlusOneTarget: token.isNPlusOneTarget, - frequencyRank: token.frequencyRank, - jlptLevel: token.jlptLevel, - })), - [ - { - surface: 'これで', - headword: 'これ', - isKnown: true, - isNPlusOneTarget: false, - frequencyRank: undefined, - jlptLevel: undefined, - }, - { - surface: '実力どおり', - headword: '実力どおり', - isKnown: false, - isNPlusOneTarget: true, - frequencyRank: 2500, - jlptLevel: 'N1', - }, - { - surface: 'か', - headword: 'か', - isKnown: false, - isNPlusOneTarget: false, - frequencyRank: undefined, - jlptLevel: undefined, - }, - ], - ); -}); - -test('tokenizeSubtitle clears all annotations for explanatory pondering endings', async () => { - const result = await tokenizeSubtitle( - '俺どうかしちゃったのかな', - makeDepsFromYomitanTokens( - [ - { surface: '俺', reading: 'おれ', headword: '俺' }, - { surface: 'どうかしちゃった', reading: 'どうかしちゃった', headword: 'どうかしちゃう' }, - { surface: 'のかな', reading: 'のかな', headword: 'の' }, - ], - { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === '俺' ? 19 : text === 'どうかしちゃう' ? 3200 : 77), - getJlptLevel: (text) => - text === '俺' ? 'N5' : text === 'どうかしちゃう' ? 'N3' : text === 'の' ? 'N5' : null, - isKnownWord: (text) => text === '俺' || text === 'の', - getMinSentenceWordsForNPlusOne: () => 1, - tokenizeWithMecab: async () => [ - { - headword: '俺', - surface: '俺', - reading: 'オレ', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '代名詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'どうかしちゃう', - surface: 'どうかしちゃった', - reading: 'ドウカシチャッタ', - startPos: 1, - endPos: 8, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'の', - surface: 'のかな', - reading: 'ノカナ', - startPos: 8, - endPos: 11, - partOfSpeech: PartOfSpeech.other, - pos1: '名詞|助動詞', - pos2: '非自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - }, - ), - ); - - assert.deepEqual( - result.tokens?.map((token) => ({ - surface: token.surface, - headword: token.headword, - isKnown: token.isKnown, - isNPlusOneTarget: token.isNPlusOneTarget, - frequencyRank: token.frequencyRank, - jlptLevel: token.jlptLevel, - })), - [ - { - surface: '俺', - headword: '俺', - isKnown: true, - isNPlusOneTarget: false, - frequencyRank: 19, - jlptLevel: 'N5', - }, - { - surface: 'どうかしちゃった', - headword: 'どうかしちゃう', - isKnown: false, - isNPlusOneTarget: false, - frequencyRank: 3200, - jlptLevel: 'N3', - }, - { - surface: 'のかな', - headword: 'の', - isKnown: true, - isNPlusOneTarget: false, - frequencyRank: undefined, - jlptLevel: undefined, - }, - ], - ); -}); - -test('tokenizeSubtitle ignores unparsed-run tokens for annotations and N+1', async () => { - // もう いるぅ~!: the ぅ~ elongation has no Yomitan dictionary entry; it must - // not become the sole N+1 candidate or receive frequency/JLPT annotations. - const result = await tokenizeSubtitle( - 'もう いるぅ~!', - makeDepsFromYomitanTokens( - [ - { surface: 'もう', reading: 'もう', headword: 'もう' }, - { surface: 'いる', reading: 'いる', headword: 'いる' }, - { surface: 'ぅ~', reading: '', headword: 'ぅ~', isUnparsedRun: true, frequencyRank: 999 }, - ], - { - getFrequencyDictionaryEnabled: () => true, - getJlptLevel: (text) => (text === 'ぅ~' ? 'N5' : null), - isKnownWord: (text) => text === 'もう' || text === 'いる', - getMinSentenceWordsForNPlusOne: () => 2, - tokenizeWithMecab: async () => null, - }, - ), - ); - - const filler = result.tokens?.find((token) => token.surface === 'ぅ~'); - assert.ok(filler); - assert.equal(filler?.isNPlusOneTarget, false); - assert.equal(filler?.frequencyRank, undefined); - assert.equal(filler?.jlptLevel, undefined); - assert.equal( - result.tokens?.some((token) => token.isNPlusOneTarget), - false, - ); -}); - test('tokenizeSubtitle keeps frequency for content-led merged token with trailing colloquial suffixes', async () => { const result = await tokenizeSubtitle( '張り切ってんじゃ', @@ -4253,45 +1475,9 @@ test('tokenizeSubtitle keeps frequency for content-led merged token with trailin getFrequencyDictionaryEnabled: () => true, getFrequencyRank: (text) => (text === '張り切る' ? 5468 : null), tokenizeWithMecab: async () => [ - { - headword: '張り切る', - surface: '張り切っ', - reading: 'ハリキッ', - startPos: 0, - endPos: 4, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'て', - surface: 'て', - reading: 'テ', - startPos: 4, - endPos: 5, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '接続助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'んじゃ', - surface: 'んじゃ', - reading: 'ンジャ', - startPos: 5, - endPos: 8, - partOfSpeech: PartOfSpeech.other, - pos1: '接続詞', - pos2: '*', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, + mecabToken('張り切っ', 0, '動詞', '自立'), + mecabToken('て', 4, '助詞', '接続助詞'), + mecabToken('んじゃ', 5, '接続詞', '*'), ], getMinSentenceWordsForNPlusOne: () => 1, }, @@ -4312,45 +1498,9 @@ test('tokenizeSubtitle keeps Yomitan frequency for noun-particle-noun compounds' { getFrequencyDictionaryEnabled: () => true, tokenizeWithMecab: async () => [ - { - headword: '目', - surface: '目', - reading: 'メ', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'の', - surface: 'の', - reading: 'ノ', - startPos: 1, - endPos: 2, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '連体化', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '前', - surface: '前', - reading: 'マエ', - startPos: 2, - endPos: 3, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '副詞可能', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, + mecabToken('目', 0, '名詞', '一般'), + mecabToken('の', 1, '助詞', '連体化'), + mecabToken('前', 2, '名詞', '副詞可能'), ], }, ), @@ -4374,45 +1524,9 @@ test('tokenizeSubtitle keeps frequency for ordinal prefix-noun tokens', async () getFrequencyDictionaryEnabled: () => true, getFrequencyRank: (text) => (text === '第二' ? 1820 : text === '走者' ? 41555 : null), tokenizeWithMecab: async () => [ - { - headword: '第', - surface: '第', - reading: 'ダイ', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.other, - pos1: '接頭詞', - pos2: '数接続', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '二', - surface: '二', - reading: 'ニ', - startPos: 1, - endPos: 2, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '数', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '走者', - surface: '走者', - reading: 'ソウシャ', - startPos: 2, - endPos: 4, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, + mecabToken('第', 0, '接頭詞', '数接続'), + mecabToken('二', 1, '名詞', '数'), + mecabToken('走者', 2, '名詞', '一般'), ], getMinSentenceWordsForNPlusOne: () => 1, }, @@ -4438,71 +1552,11 @@ test('tokenizeSubtitle keeps frequency for honorific prefix-noun tokens', async getFrequencyDictionaryEnabled: () => true, getFrequencyRank: (text) => (text === 'ご機嫌' ? 5484 : null), tokenizeWithMecab: async () => [ - { - headword: 'ご', - surface: 'ご', - reading: 'ゴ', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.other, - pos1: '接頭詞', - pos2: '名詞接続', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '機嫌', - surface: '機嫌', - reading: 'キゲン', - startPos: 1, - endPos: 3, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'が', - surface: 'が', - reading: 'ガ', - startPos: 3, - endPos: 4, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '格助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '良い', - surface: '良く', - reading: 'ヨク', - startPos: 4, - endPos: 6, - partOfSpeech: PartOfSpeech.i_adjective, - pos1: '形容詞', - pos2: '自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'ない', - surface: 'ない', - reading: 'ナイ', - startPos: 6, - endPos: 8, - partOfSpeech: PartOfSpeech.bound_auxiliary, - pos1: '助動詞', - pos2: '*', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, + mecabToken('ご', 0, '接頭詞', '名詞接続'), + mecabToken('機嫌', 1, '名詞', '一般'), + mecabToken('が', 3, '助詞', '格助詞'), + mecabToken('良く', 4, '形容詞', '自立'), + mecabToken('ない', 6, '助動詞', '*'), ], getMinSentenceWordsForNPlusOne: () => 1, }, @@ -4514,872 +1568,3 @@ test('tokenizeSubtitle keeps frequency for honorific prefix-noun tokens', async assert.equal(result.tokens?.[0]?.pos2, '名詞接続|一般'); assert.equal(result.tokens?.[0]?.frequencyRank, 5484); }); - -test('tokenizeSubtitle clears all annotations for explanatory contrast endings', async () => { - const result = await tokenizeSubtitle( - '最近辛いものが続いとるんですけど', - makeDepsFromYomitanTokens( - [ - { surface: '最近', reading: 'さいきん', headword: '最近' }, - { surface: '辛い', reading: 'つらい', headword: '辛い' }, - { surface: 'もの', reading: 'もの', headword: 'もの' }, - { surface: 'が', reading: 'が', headword: 'が' }, - { surface: '続いとる', reading: 'つづいとる', headword: '続く' }, - { surface: 'んですけど', reading: 'んですけど', headword: 'ん' }, - ], - { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => - text === '最近' ? 120 : text === '辛い' ? 800 : text === '続く' ? 240 : 77, - getJlptLevel: (text) => - text === '最近' ? 'N4' : text === '辛い' ? 'N2' : text === '続く' ? 'N4' : null, - isKnownWord: (text) => text === '最近', - getMinSentenceWordsForNPlusOne: () => 1, - tokenizeWithMecab: async () => [ - { - headword: '最近', - surface: '最近', - reading: 'サイキン', - startPos: 0, - endPos: 2, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '副詞可能', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '辛い', - surface: '辛い', - reading: 'ツライ', - startPos: 2, - endPos: 4, - partOfSpeech: PartOfSpeech.i_adjective, - pos1: '形容詞', - pos2: '自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'もの', - surface: 'もの', - reading: 'モノ', - startPos: 4, - endPos: 6, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'が', - surface: 'が', - reading: 'ガ', - startPos: 6, - endPos: 7, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '格助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '続く', - surface: '続いとる', - reading: 'ツヅイトル', - startPos: 7, - endPos: 11, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'ん', - surface: 'んですけど', - reading: 'ンデスケド', - startPos: 11, - endPos: 16, - partOfSpeech: PartOfSpeech.other, - pos1: '名詞|助動詞|助詞', - pos2: '非自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - }, - ), - ); - - assert.deepEqual( - result.tokens?.map((token) => ({ - surface: token.surface, - headword: token.headword, - isKnown: token.isKnown, - isNPlusOneTarget: token.isNPlusOneTarget, - frequencyRank: token.frequencyRank, - jlptLevel: token.jlptLevel, - })), - [ - { - surface: '最近', - headword: '最近', - isKnown: true, - isNPlusOneTarget: false, - frequencyRank: 120, - jlptLevel: 'N4', - }, - { - surface: '辛い', - headword: '辛い', - isKnown: false, - isNPlusOneTarget: false, - frequencyRank: 800, - jlptLevel: 'N2', - }, - { - surface: 'もの', - headword: 'もの', - isKnown: false, - isNPlusOneTarget: false, - frequencyRank: 77, - jlptLevel: undefined, - }, - { - surface: 'が', - headword: 'が', - isKnown: false, - isNPlusOneTarget: false, - frequencyRank: undefined, - jlptLevel: undefined, - }, - { - surface: '続いとる', - headword: '続く', - isKnown: false, - isNPlusOneTarget: false, - frequencyRank: 240, - jlptLevel: 'N4', - }, - { - surface: 'んですけど', - headword: 'ん', - isKnown: false, - isNPlusOneTarget: false, - frequencyRank: undefined, - jlptLevel: undefined, - }, - ], - ); -}); - -test('tokenizeSubtitle clears annotations for ja-nai explanatory endings and aru verbs', async () => { - const result = await tokenizeSubtitle( - 'みたいなのあるじゃないですか', - makeDepsFromYomitanTokens( - [ - { surface: 'みたいな', reading: 'みたいな', headword: 'みたい' }, - { surface: 'の', reading: 'の', headword: 'の' }, - { surface: 'ある', reading: 'ある', headword: 'ある' }, - { surface: 'じゃないですか', reading: 'じゃないですか', headword: 'じゃない' }, - ], - { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => - text === 'みたい' ? 320 : text === 'ある' ? 240 : text === 'じゃない' ? 80 : null, - getJlptLevel: (text) => - text === 'みたい' ? 'N4' : text === 'ある' ? 'N5' : text === 'じゃない' ? 'N5' : null, - isKnownWord: (text) => text === 'みたい' || text === 'の' || text === 'ある', - getMinSentenceWordsForNPlusOne: () => 1, - tokenizeWithMecab: async () => [ - { - headword: 'みたい', - surface: 'みたい', - reading: 'ミタイ', - startPos: 0, - endPos: 3, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '非自立', - pos3: '形容動詞語幹', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'だ', - surface: 'な', - reading: 'ナ', - startPos: 3, - endPos: 4, - partOfSpeech: PartOfSpeech.bound_auxiliary, - pos1: '助動詞', - pos2: '*', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'の', - surface: 'の', - reading: 'ノ', - startPos: 4, - endPos: 5, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '非自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'ある', - surface: 'ある', - reading: 'アル', - startPos: 5, - endPos: 7, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'じゃない', - surface: 'じゃない', - reading: 'ジャナイ', - startPos: 7, - endPos: 11, - partOfSpeech: PartOfSpeech.i_adjective, - pos1: '接続詞|形容詞', - pos2: '*|自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'です', - surface: 'です', - reading: 'デス', - startPos: 11, - endPos: 13, - partOfSpeech: PartOfSpeech.bound_auxiliary, - pos1: '助動詞', - pos2: '*', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'か', - surface: 'か', - reading: 'カ', - startPos: 13, - endPos: 14, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '副助詞/並立助詞/終助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - }, - ), - ); - - const tokenSummary = result.tokens?.map((token) => ({ - surface: token.surface, - headword: token.headword, - isKnown: token.isKnown, - isNPlusOneTarget: token.isNPlusOneTarget, - frequencyRank: token.frequencyRank, - jlptLevel: token.jlptLevel, - })); - - assert.deepEqual( - tokenSummary?.find((token) => token.surface === 'じゃないですか'), - { - surface: 'じゃないですか', - headword: 'じゃない', - isKnown: false, - isNPlusOneTarget: false, - frequencyRank: undefined, - jlptLevel: undefined, - }, - ); - assert.deepEqual( - tokenSummary?.find((token) => token.surface === 'ある'), - { - surface: 'ある', - headword: 'ある', - isKnown: true, - isNPlusOneTarget: false, - frequencyRank: undefined, - jlptLevel: undefined, - }, - ); -}); - -test('tokenizeSubtitle clears annotations for standalone polite copula endings without POS metadata', async () => { - const result = await tokenizeSubtitle( - '現実は感じですよ', - makeDepsFromYomitanTokens( - [ - { surface: '現実', reading: 'げんじつ', headword: '現実' }, - { surface: 'は', reading: 'は', headword: 'は' }, - { surface: '感じ', reading: 'かんじ', headword: '感じ' }, - { surface: 'ですよ', reading: 'ですよ', headword: 'です' }, - ], - { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => - text === '現実' ? 600 : text === '感じ' ? 240 : text === 'です' ? 50 : null, - getJlptLevel: (text) => - text === '現実' ? 'N3' : text === '感じ' ? 'N4' : text === 'です' ? 'N5' : null, - isKnownWord: (text) => text === '現実' || text === 'は' || text === 'です', - getMinSentenceWordsForNPlusOne: () => 1, - tokenizeWithMecab: async () => null, - }, - ), - ); - - const tokenSummary = result.tokens?.map((token) => ({ - surface: token.surface, - headword: token.headword, - isKnown: token.isKnown, - isNPlusOneTarget: token.isNPlusOneTarget, - frequencyRank: token.frequencyRank, - jlptLevel: token.jlptLevel, - })); - - assert.deepEqual( - tokenSummary?.find((token) => token.surface === 'ですよ'), - { - surface: 'ですよ', - headword: 'です', - isKnown: true, - isNPlusOneTarget: false, - frequencyRank: undefined, - jlptLevel: undefined, - }, - ); - assert.deepEqual( - tokenSummary?.find((token) => token.surface === '感じ'), - { - surface: '感じ', - headword: '感じ', - isKnown: false, - isNPlusOneTarget: true, - frequencyRank: 240, - jlptLevel: 'N4', - }, - ); -}); - -test('tokenizeSubtitle clears annotations for ことに while preserving lexical N+1 target', async () => { - const result = await tokenizeSubtitle( - 'さっきの俺と違うことに気付かないのかい?', - makeDepsFromYomitanTokens( - [ - { surface: 'さっき', reading: 'さっき', headword: 'さっき' }, - { surface: 'の', reading: 'の', headword: 'の' }, - { surface: '俺', reading: 'おれ', headword: '俺' }, - { surface: 'と', reading: 'と', headword: 'と' }, - { surface: '違う', reading: 'ちがう', headword: '違う' }, - { surface: 'ことに', reading: 'ことに', headword: '事' }, - { surface: '気付かない', reading: 'きづかない', headword: '気付く' }, - { surface: 'の', reading: 'の', headword: 'の' }, - { surface: 'かい', reading: 'かい', headword: 'かい' }, - { surface: '?', reading: '', headword: '?' }, - ], - { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => - text === '違う' ? 900 : text === '事' ? 81 : text === '気付く' ? 1500 : null, - getJlptLevel: (text) => - text === '違う' ? 'N4' : text === '事' ? 'N4' : text === '気付く' ? 'N3' : null, - isKnownWord: (text) => ['さっき', 'の', '俺', 'と', '気付く', 'かい', '?'].includes(text), - getMinSentenceWordsForNPlusOne: () => 1, - tokenizeWithMecab: async () => [ - { - headword: 'さっき', - surface: 'さっき', - reading: 'サッキ', - startPos: 0, - endPos: 3, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '副詞可能', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'の', - surface: 'の', - reading: 'ノ', - startPos: 3, - endPos: 4, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '連体化', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '俺', - surface: '俺', - reading: 'オレ', - startPos: 4, - endPos: 5, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '代名詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'と', - surface: 'と', - reading: 'ト', - startPos: 5, - endPos: 6, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '格助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '違う', - surface: '違う', - reading: 'チガウ', - startPos: 6, - endPos: 8, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '事', - surface: 'こと', - reading: 'コト', - startPos: 8, - endPos: 10, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '非自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'に', - surface: 'に', - reading: 'ニ', - startPos: 10, - endPos: 11, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '格助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '気付く', - surface: '気付か', - reading: 'キヅカ', - startPos: 11, - endPos: 14, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'ない', - surface: 'ない', - reading: 'ナイ', - startPos: 14, - endPos: 16, - partOfSpeech: PartOfSpeech.bound_auxiliary, - pos1: '助動詞', - pos2: '*', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'の', - surface: 'の', - reading: 'ノ', - startPos: 16, - endPos: 17, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '終助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'かい', - surface: 'かい', - reading: 'カイ', - startPos: 17, - endPos: 19, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '終助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '?', - surface: '?', - reading: '', - startPos: 19, - endPos: 20, - partOfSpeech: PartOfSpeech.symbol, - pos1: '記号', - pos2: '一般', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - }, - ), - ); - - const tokenSummary = result.tokens?.map((token) => ({ - surface: token.surface, - headword: token.headword, - isKnown: token.isKnown, - isNPlusOneTarget: token.isNPlusOneTarget, - frequencyRank: token.frequencyRank, - jlptLevel: token.jlptLevel, - })); - - assert.deepEqual( - tokenSummary?.find((token) => token.surface === 'ことに'), - { - surface: 'ことに', - headword: '事', - isKnown: false, - isNPlusOneTarget: false, - frequencyRank: undefined, - jlptLevel: undefined, - }, - ); - assert.deepEqual( - tokenSummary?.find((token) => token.surface === '違う'), - { - surface: '違う', - headword: '違う', - isKnown: false, - isNPlusOneTarget: true, - frequencyRank: 900, - jlptLevel: 'N4', - }, - ); -}); - -test('tokenizeSubtitle clears annotations for auxiliary inflection fragments while preserving lexical N+1 target', async () => { - const result = await tokenizeSubtitle( - '私れた猫', - makeDepsFromYomitanTokens( - [ - { surface: '私', reading: 'わたし', headword: '私' }, - { surface: 'れた', reading: 'れた', headword: 'れる' }, - { surface: '猫', reading: 'ねこ', headword: '猫' }, - ], - { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => - text === '私' ? 50 : text === 'れる' ? 18 : text === '猫' ? 900 : null, - getJlptLevel: (text) => - text === '私' ? 'N5' : text === 'れる' ? 'N4' : text === '猫' ? 'N5' : null, - isKnownWord: (text) => text === '私' || text === 'れる', - getMinSentenceWordsForNPlusOne: () => 1, - tokenizeWithMecab: async () => [ - { - headword: '私', - surface: '私', - reading: 'ワタシ', - startPos: 0, - endPos: 1, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '代名詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'れる', - surface: 'れ', - reading: 'レ', - startPos: 1, - endPos: 2, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '接尾', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'た', - surface: 'た', - reading: 'タ', - startPos: 2, - endPos: 3, - partOfSpeech: PartOfSpeech.bound_auxiliary, - pos1: '助動詞', - pos2: '*', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: '猫', - surface: '猫', - reading: 'ネコ', - startPos: 3, - endPos: 4, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - }, - ), - ); - - const tokenSummary = result.tokens?.map((token) => ({ - surface: token.surface, - headword: token.headword, - isKnown: token.isKnown, - isNPlusOneTarget: token.isNPlusOneTarget, - frequencyRank: token.frequencyRank, - jlptLevel: token.jlptLevel, - })); - - assert.deepEqual( - tokenSummary?.find((token) => token.surface === 'れた'), - { - surface: 'れた', - headword: 'れる', - isKnown: true, - isNPlusOneTarget: false, - frequencyRank: undefined, - jlptLevel: undefined, - }, - ); - assert.deepEqual( - tokenSummary?.find((token) => token.surface === '猫'), - { - surface: '猫', - headword: '猫', - isKnown: false, - isNPlusOneTarget: true, - frequencyRank: 900, - jlptLevel: 'N5', - }, - ); -}); - -test('tokenizeSubtitle clears annotations for te-kureru auxiliary helper spans', async () => { - const result = await tokenizeSubtitle( - 'ベアトリスがいてくれたから', - makeDepsFromYomitanTokens( - [ - { surface: 'ベアトリス', reading: 'べあとりす', headword: 'ベアトリス' }, - { surface: 'が', reading: 'が', headword: 'が' }, - { surface: 'い', reading: 'い', headword: 'いる' }, - { surface: 'てく', reading: 'てく', headword: 'てく' }, - { surface: 'れた', reading: 'れた', headword: 'れる' }, - { surface: 'から', reading: 'から', headword: 'から' }, - ], - { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => - text === 'ベアトリス' ? 1000 : text === 'てく' ? 140 : text === 'れる' ? 19 : null, - getJlptLevel: (text) => - text === 'てく' || text === 'れる' || text === 'いる' ? 'N4' : null, - isKnownWord: (text) => text === 'てく' || text === 'れる', - getMinSentenceWordsForNPlusOne: () => 1, - tokenizeWithMecab: async () => [ - { - headword: 'ベアトリス', - surface: 'ベアトリス', - reading: 'ベアトリス', - startPos: 0, - endPos: 5, - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '固有名詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'が', - surface: 'が', - reading: 'ガ', - startPos: 5, - endPos: 6, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '格助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'いる', - surface: 'い', - reading: 'イ', - startPos: 6, - endPos: 7, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'てく', - surface: 'てく', - reading: 'テク', - startPos: 7, - endPos: 9, - partOfSpeech: PartOfSpeech.verb, - pos1: '助詞|動詞', - pos2: '接続助詞|非自立', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'れる', - surface: 'れた', - reading: 'レタ', - startPos: 9, - endPos: 11, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞|助動詞', - pos2: '接尾|*', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'から', - surface: 'から', - reading: 'カラ', - startPos: 11, - endPos: 13, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '接続助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - }, - ), - ); - - const tokenSummary = result.tokens?.map((token) => ({ - surface: token.surface, - headword: token.headword, - isKnown: token.isKnown, - isNPlusOneTarget: token.isNPlusOneTarget, - frequencyRank: token.frequencyRank, - jlptLevel: token.jlptLevel, - })); - - assert.deepEqual( - tokenSummary?.find((token) => token.surface === 'てく'), - { - surface: 'てく', - headword: 'てく', - isKnown: true, - isNPlusOneTarget: false, - frequencyRank: undefined, - jlptLevel: undefined, - }, - ); - assert.deepEqual( - tokenSummary?.find((token) => token.surface === 'れた'), - { - surface: 'れた', - headword: 'れる', - isKnown: true, - isNPlusOneTarget: false, - frequencyRank: undefined, - jlptLevel: undefined, - }, - ); -}); - -test('tokenizeSubtitle excludes default non-independent pos2 from N+1 when JLPT/frequency are disabled', async () => { - let mecabCalls = 0; - const result = await tokenizeSubtitle( - 'になれば', - makeDepsFromYomitanTokens([{ surface: 'になれば', reading: 'になれば', headword: 'なる' }], { - getJlptEnabled: () => false, - getFrequencyDictionaryEnabled: () => false, - getMinSentenceWordsForNPlusOne: () => 1, - tokenizeWithMecab: async () => { - mecabCalls += 1; - return [ - { - headword: 'なる', - surface: 'になれば', - reading: 'ニナレバ', - startPos: 0, - endPos: 4, - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '非自立', - isMerged: true, - isKnown: false, - isNPlusOneTarget: false, - }, - ]; - }, - }), - ); - - assert.equal(mecabCalls, 1); - assert.equal(result.tokens?.length, 1); - assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false); -}); diff --git a/src/core/services/tokenizer/annotation-stage.test.ts b/src/core/services/tokenizer/annotation-stage.test.ts index 3313ca81..056f2542 100644 --- a/src/core/services/tokenizer/annotation-stage.test.ts +++ b/src/core/services/tokenizer/annotation-stage.test.ts @@ -208,17 +208,6 @@ test('annotateTokens hides known-word marks while still using known words for N+ assert.equal(result[2]?.isNPlusOneTarget, true); }); -test('shouldExcludeTokenFromSubtitleAnnotations excludes unparsed-run tokens', () => { - // 戻 from 「…とこ戻ろ…」: Yomitan had no dictionary entry, headword falls back - // to the surface. Without the flag the token passes every other filter. - const unflagged = makeToken({ surface: '戻', headword: '戻', reading: '' }); - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(unflagged), false); - - const flagged = makeToken({ surface: '戻', headword: '戻', reading: '', isUnparsedRun: true }); - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(flagged), true); - assert.equal(shouldExcludeTokenFromVocabularyPersistence(flagged), true); -}); - test('annotateTokens ignores unparsed-run tokens for annotations and N+1', () => { // もう いるぅ~!-style line: the elongation run is the only unknown token and // used to become the sole N+1 candidate despite having no dictionary entry. @@ -467,319 +456,354 @@ test('annotateTokens handles JLPT disabled and eligibility exclusion paths', () assert.equal(excludedLookupCalls, 0); }); -test('shouldExcludeTokenFromSubtitleAnnotations excludes explanatory ending variants', () => { - const tokens = [ - makeToken({ - surface: 'んです', - headword: 'ん', - reading: 'ンデス', - pos1: '名詞|助動詞', - pos2: '非自立', - }), - makeToken({ - surface: 'のだ', - headword: 'の', - reading: 'ノダ', - pos1: '名詞|助動詞', - pos2: '非自立', - }), - makeToken({ - surface: 'んだ', - headword: 'ん', - reading: 'ンダ', - pos1: '名詞|助動詞', - pos2: '非自立', - }), - makeToken({ - surface: 'のです', - headword: 'の', - reading: 'ノデス', - pos1: '名詞|助動詞', - pos2: '非自立', - }), - makeToken({ +// One-token predicate cases. `vocabularyExcluded` also checks +// shouldExcludeTokenFromVocabularyPersistence for the same token. +const SUBTITLE_ANNOTATION_EXCLUSION_CASES: Array<{ + label: string; + token: Partial; + excluded: boolean; + vocabularyExcluded?: boolean; +}> = [ + // 戻 from 「…とこ戻ろ…」: Yomitan had no dictionary entry, headword falls back + // to the surface. Without the flag the token passes every other filter. + { + label: 'unparsed-run tokens', + token: { surface: '戻', headword: '戻', reading: '', isUnparsedRun: true }, + excluded: true, + vocabularyExcluded: true, + }, + { + label: 'surface-headword tokens without the unparsed-run flag', + token: { surface: '戻', headword: '戻', reading: '' }, + excluded: false, + }, + ...[ + { surface: 'んです', headword: 'ん', reading: 'ンデス', pos1: '名詞|助動詞', pos2: '非自立' }, + { surface: 'のだ', headword: 'の', reading: 'ノダ', pos1: '名詞|助動詞', pos2: '非自立' }, + { surface: 'んだ', headword: 'ん', reading: 'ンダ', pos1: '名詞|助動詞', pos2: '非自立' }, + { surface: 'のです', headword: 'の', reading: 'ノデス', pos1: '名詞|助動詞', pos2: '非自立' }, + { surface: 'なんです', headword: 'だ', reading: 'ナンデス', pos1: '助動詞|名詞|助動詞', pos2: '|非自立', - }), - makeToken({ - surface: 'んでした', + }, + { surface: 'んでした', headword: 'ん', reading: 'ンデシタ', pos1: '助動詞|助動詞|助動詞' }, + { surface: 'のでは', headword: 'の', reading: 'ノデハ', pos1: '助詞|接続詞' }, + { surface: 'のかな', headword: 'の', reading: 'ノカナ', pos1: '名詞|助動詞', pos2: '非自立' }, + { + surface: 'んですけど', headword: 'ん', - reading: 'ンデシタ', - pos1: '助動詞|助動詞|助動詞', - }), - makeToken({ - surface: 'のでは', - headword: 'の', - reading: 'ノデハ', - pos1: '助詞|接続詞', - }), - ]; - - for (const token of tokens) { - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, token.surface); - } -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes explanatory pondering endings', () => { - const token = makeToken({ - surface: 'のかな', - headword: 'の', - reading: 'ノカナ', - pos1: '名詞|助動詞', - pos2: '非自立', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes explanatory contrast endings', () => { - const token = makeToken({ - surface: 'んですけど', - headword: 'ん', - reading: 'ンデスケド', - pos1: '名詞|助動詞|助詞', - pos2: '非自立', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes ja-nai explanatory endings', () => { - const tokens = [ - makeToken({ + reading: 'ンデスケド', + pos1: '名詞|助動詞|助詞', + pos2: '非自立', + }, + ].map((token) => ({ label: 'explanatory endings', token, excluded: true })), + ...[ + { surface: 'じゃない', headword: 'じゃない', reading: 'ジャナイ', partOfSpeech: PartOfSpeech.i_adjective, pos1: '接続詞|形容詞', pos2: '*|自立', - }), - makeToken({ + }, + { surface: 'じゃないですか', headword: 'じゃない', reading: 'ジャナイデスカ', partOfSpeech: PartOfSpeech.i_adjective, pos1: '接続詞|形容詞|助動詞|助詞', pos2: '*|自立|*|副助詞/並立助詞/終助詞', - }), - ]; - - for (const token of tokens) { - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, token.surface); - } -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone polite copula suffix endings without POS tags', () => { - const tokens = [ - makeToken({ - surface: 'ですよ', - headword: 'です', - reading: 'デスヨ', - partOfSpeech: PartOfSpeech.other, - pos1: '', - pos2: '', - }), - ]; - - for (const token of tokens) { - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, token.surface); - } -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes grammar-ending patterns without enumerating variants', () => { - const tokens = [ - makeToken({ - surface: 'ですわ', - headword: 'です', - reading: 'デスワ', - partOfSpeech: PartOfSpeech.other, - pos1: '', - pos2: '', - }), - makeToken({ - surface: 'ではないですか', - headword: 'ない', - reading: 'デハナイデスカ', - partOfSpeech: PartOfSpeech.other, - pos1: '', - pos2: '', - }), - ]; - - for (const token of tokens) { - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, token.surface); - } -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes auxiliary-stem そうだ grammar tails', () => { - const token = makeToken({ - surface: 'そうだ', - headword: 'そうだ', - reading: 'ソウダ', - pos1: '名詞|助動詞', - pos2: '特殊', - pos3: '助動詞語幹', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations keeps lexical tokens outside explanatory ending family', () => { - const token = makeToken({ - surface: '問題', - headword: '問題', - reading: 'モンダイ', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations keeps lexical non-independent kanji nouns', () => { + }, + ].map((token) => ({ label: 'ja-nai explanatory endings', token, excluded: true })), + // Grammar-ending patterns match without enumerating every variant. + ...[ + { surface: 'ですよ', headword: 'です', reading: 'デスヨ' }, + { surface: 'ですわ', headword: 'です', reading: 'デスワ' }, + { surface: 'ではないですか', headword: 'ない', reading: 'デハナイデスカ' }, + ].map((token) => ({ + label: 'polite copula grammar endings without POS tags', + token: { ...token, partOfSpeech: PartOfSpeech.other, pos1: '', pos2: '' }, + excluded: true, + })), + { + label: 'auxiliary-stem そうだ grammar tails', + token: { + surface: 'そうだ', + headword: 'そうだ', + reading: 'ソウダ', + pos1: '名詞|助動詞', + pos2: '特殊', + pos3: '助動詞語幹', + }, + excluded: true, + }, + { + label: 'auxiliary-stem そうだ grammar tails', + token: { + surface: 'そうだ', + headword: 'そう', + reading: 'ソウダ', + pos1: '名詞|助動詞', + pos2: '一般|', + pos3: '助動詞語幹|', + }, + excluded: true, + vocabularyExcluded: true, + }, + { + label: 'lexical tokens outside the explanatory ending family', + token: { surface: '問題', headword: '問題', reading: 'モンダイ', pos1: '名詞', pos2: '一般' }, + excluded: false, + }, // Yomitan segments 以外/日/方 as standalone vocabulary tokens; MeCab's // 非自立 tag must only suppress kana grammar nouns (こと, もの, とき). - const token = makeToken({ - surface: '以外', - headword: '以外', - reading: 'イガイ', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '非自立', - pos3: '副詞可能', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false); - assert.equal(shouldExcludeTokenFromVocabularyPersistence(token), false); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations still excludes kana non-independent nouns', () => { - const token = makeToken({ - surface: 'こと', - headword: 'こと', - reading: 'コト', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '非自立', - pos3: '一般', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); - assert.equal(shouldExcludeTokenFromVocabularyPersistence(token), true); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone particles auxiliaries and adnominals', () => { - const tokens = [ - makeToken({ + { + label: 'lexical non-independent kanji nouns', + token: { + surface: '以外', + headword: '以外', + reading: 'イガイ', + pos1: '名詞', + pos2: '非自立', + pos3: '副詞可能', + }, + excluded: false, + vocabularyExcluded: false, + }, + { + label: 'kana non-independent nouns', + token: { + surface: 'こと', + headword: 'こと', + reading: 'コト', + pos1: '名詞', + pos2: '非自立', + pos3: '一般', + }, + excluded: true, + vocabularyExcluded: true, + }, + { + label: 'standalone particles', + token: { surface: 'は', headword: 'は', reading: 'ハ', partOfSpeech: PartOfSpeech.particle, pos1: '助詞', - }), - makeToken({ + }, + excluded: true, + }, + { + label: 'standalone auxiliaries', + token: { surface: 'です', headword: 'です', reading: 'デス', partOfSpeech: PartOfSpeech.bound_auxiliary, pos1: '助動詞', - }), - makeToken({ + }, + excluded: true, + }, + { + label: 'standalone adnominals', + token: { surface: 'この', headword: 'この', reading: 'コノ', partOfSpeech: PartOfSpeech.other, pos1: '連体詞', - }), - ]; - - for (const token of tokens) { - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, token.surface); - } -}); - -test('shouldExcludeTokenFromSubtitleAnnotations keeps mixed content tokens with trailing helpers', () => { - const token = makeToken({ - surface: '行きます', - headword: '行く', - reading: 'イキマス', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞|助動詞', - pos2: '自立', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes merged lexical tokens with trailing quote particles', () => { - const token = makeToken({ - surface: 'どうしてもって', - headword: 'どうしても', - reading: 'ドウシテモッテ', - partOfSpeech: PartOfSpeech.other, - pos1: '副詞|助詞', - pos2: '一般|格助詞', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes kana-only demonstrative helper merges', () => { - const token = makeToken({ - surface: 'これで', - headword: 'これ', - reading: 'コレデ', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞|助詞', - pos2: '代名詞|格助詞', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes kana-only non-independent noun helper merges', () => { - const token = makeToken({ - surface: 'ことに', - headword: '事', - reading: 'コトニ', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞|助詞', - pos2: '非自立|格助詞', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); -}); - -test('shouldExcludeTokenFromVocabularyPersistence mirrors subtitle annotation grammar filters', () => { - const tokens = [ - makeToken({ + }, + excluded: true, + }, + { + label: 'mixed content tokens with trailing helpers', + token: { + surface: '行きます', + headword: '行く', + reading: 'イキマス', + partOfSpeech: PartOfSpeech.verb, + pos1: '動詞|助動詞', + pos2: '自立', + }, + excluded: false, + }, + { + label: 'merged lexical tokens with trailing quote particles', + token: { surface: 'どうしてもって', headword: 'どうしても', reading: 'ドウシテモッテ', partOfSpeech: PartOfSpeech.other, pos1: '副詞|助詞', pos2: '一般|格助詞', - }), - makeToken({ - surface: 'そうだ', - headword: 'そう', - reading: 'ソウダ', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞|助動詞', - pos2: '一般|', - pos3: '助動詞語幹|', - }), - ]; + }, + excluded: true, + vocabularyExcluded: true, + }, + { + label: 'kana-only demonstrative helper merges', + token: { + surface: 'これで', + headword: 'これ', + reading: 'コレデ', + pos1: '名詞|助詞', + pos2: '代名詞|格助詞', + }, + excluded: true, + }, + { + label: 'kana-only non-independent noun helper merges', + token: { + surface: 'ことに', + headword: '事', + reading: 'コトニ', + pos1: '名詞|助詞', + pos2: '非自立|格助詞', + }, + excluded: true, + }, + ...[ + { + surface: '確かに', + headword: '確かに', + reading: 'たしかに', + partOfSpeech: PartOfSpeech.other, + pos1: '名詞|助詞', + pos2: '形容動詞語幹|副詞化', + pos3: '*', + }, + { + surface: 'やはり', + headword: 'やはり', + reading: 'ヤハリ', + partOfSpeech: PartOfSpeech.other, + pos1: '副詞', + pos2: '一般', + pos3: '*', + }, + ].map((token) => ({ + label: 'content adverbs', + token, + excluded: false, + vocabularyExcluded: false, + })), + ...[ + { surface: 'して', reading: 'シテ', pos1: '動詞|助詞', pos2: '自立|接続助詞' }, + { surface: 'してる', reading: 'シテル', pos1: '動詞|助動詞', pos2: '自立|非自立' }, + ].map((token) => ({ + label: 'standalone して grammar helper fragments', + token: { ...token, headword: 'する', partOfSpeech: PartOfSpeech.verb }, + excluded: true, + })), + ...[ + { surface: 'れる', reading: 'レル', pos1: '動詞', pos2: '接尾' }, + { surface: 'れた', reading: 'レタ', pos1: '動詞|助動詞', pos2: '接尾|*' }, + ].map((token) => ({ + label: 'standalone auxiliary inflection fragments', + token: { ...token, headword: 'れる', partOfSpeech: PartOfSpeech.verb }, + excluded: true, + })), + { + label: 'auxiliary-only te-kureru helper spans', + token: { + surface: 'てく', + headword: 'てく', + reading: 'テク', + partOfSpeech: PartOfSpeech.verb, + pos1: '助詞|動詞', + pos2: '接続助詞|非自立', + }, + excluded: true, + }, + // Standalone particle, quote, auxiliary, and interjection terms that arrive + // without POS tags. + ...[ + { surface: 'と', headword: 'と', reading: 'ト' }, + { surface: 'たって', headword: 'たって', reading: 'タッテ' }, + { surface: 'って', headword: 'って', reading: 'ッテ' }, + { surface: 'べき', headword: 'べき', reading: 'ベキ' }, + { surface: 'あ', headword: 'あ', reading: 'あ' }, + { surface: 'は', headword: 'は', reading: 'は' }, + { surface: 'この', headword: 'この', reading: 'この' }, + ].map((token) => ({ + label: 'standalone grammar terms without POS tags', + token: { ...token, partOfSpeech: PartOfSpeech.other, pos1: '', pos2: '' }, + excluded: true, + })), + { + label: 'lexical verbs whose reading matches connective particles', + token: { + surface: '立って', + headword: '立つ', + reading: 'タッテ', + partOfSpeech: PartOfSpeech.verb, + pos1: '動詞', + pos2: '自立', + }, + excluded: false, + }, + ...[ + { surface: 'もんか', headword: 'もんか', reading: 'モンカ' }, + { surface: 'ものか', headword: 'ものか', reading: 'モノカ' }, + ].map((token) => ({ + label: 'rhetorical もんか grammar particle phrases', + token: { ...token, pos1: '名詞|助詞', pos2: '非自立|副助詞/並立助詞/終助詞' }, + excluded: true, + })), + { + label: 'bare くれ auxiliary fragments', + token: { surface: 'くれ', headword: '暮れ', reading: 'クレ', pos1: '名詞', pos2: '一般' }, + excluded: true, + }, + ...['ある', '有る'].map((surface) => ({ + label: 'aru existence verbs', + token: { + surface, + headword: surface, + reading: 'アル', + partOfSpeech: PartOfSpeech.verb, + pos1: '動詞', + pos2: '自立', + }, + excluded: true, + })), + ...[ + { surface: 'ふ', headword: '不', partOfSpeech: PartOfSpeech.other, pos1: '接頭詞', pos2: '' }, + { surface: 'フ', headword: '負', pos1: '名詞', pos2: '一般' }, + { surface: 'た', headword: 'た', partOfSpeech: PartOfSpeech.other, pos1: '', pos2: '' }, + ].map((token) => ({ + label: 'single-kana surface fragments', + token: { reading: token.surface, ...token }, + excluded: true, + })), + { + label: 'single-kanji tokens without POS tags', + token: { + surface: '山', + headword: '山', + reading: 'やま', + partOfSpeech: PartOfSpeech.other, + pos1: '', + pos2: '', + }, + excluded: false, + }, +]; - for (const token of tokens) { - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, token.surface); - assert.equal(shouldExcludeTokenFromVocabularyPersistence(token), true, token.surface); - } -}); +for (const c of SUBTITLE_ANNOTATION_EXCLUSION_CASES) { + const verb = c.excluded ? 'excludes' : 'keeps'; + test(`shouldExcludeTokenFromSubtitleAnnotations ${verb} ${c.label}: ${c.token.surface}`, () => { + const token = makeToken(c.token); + assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), c.excluded); + if (c.vocabularyExcluded !== undefined) { + assert.equal(shouldExcludeTokenFromVocabularyPersistence(token), c.vocabularyExcluded); + } + }); +} test('shouldExcludeTokenFromVocabularyPersistence excludes common frequency stop terms', () => { const tokens = [ @@ -826,196 +850,6 @@ test('shouldExcludeTokenFromVocabularyPersistence excludes common frequency stop } }); -test('content adverbs are not excluded from annotations or vocabulary persistence', () => { - const tokens = [ - makeToken({ - surface: '確かに', - headword: '確かに', - reading: 'たしかに', - partOfSpeech: PartOfSpeech.other, - pos1: '名詞|助詞', - pos2: '形容動詞語幹|副詞化', - pos3: '*', - }), - makeToken({ - surface: 'やはり', - headword: 'やはり', - reading: 'ヤハリ', - partOfSpeech: PartOfSpeech.other, - pos1: '副詞', - pos2: '一般', - pos3: '*', - }), - ]; - - for (const token of tokens) { - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false, token.surface); - assert.equal(shouldExcludeTokenFromVocabularyPersistence(token), false, token.surface); - } -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone して grammar helper fragments', () => { - const token = makeToken({ - surface: 'して', - headword: 'する', - reading: 'シテ', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞|助詞', - pos2: '自立|接続助詞', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes inflected standalone して grammar helper fragments', () => { - const token = makeToken({ - surface: 'してる', - headword: 'する', - reading: 'シテル', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞|助動詞', - pos2: '自立|非自立', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone particle fragments without POS tags', () => { - const token = makeToken({ - surface: 'と', - headword: 'と', - reading: 'ト', - partOfSpeech: PartOfSpeech.other, - pos1: '', - pos2: '', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone connective particle fragments without POS tags', () => { - const token = makeToken({ - surface: 'たって', - headword: 'たって', - reading: 'タッテ', - partOfSpeech: PartOfSpeech.other, - pos1: '', - pos2: '', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations keeps lexical verbs whose reading matches connective particles', () => { - const token = makeToken({ - surface: '立って', - headword: '立つ', - reading: 'タッテ', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes rhetorical もんか grammar particle phrases', () => { - for (const surface of ['もんか', 'ものか']) { - const token = makeToken({ - surface, - headword: surface, - reading: surface === 'もんか' ? 'モンカ' : 'モノカ', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞|助詞', - pos2: '非自立|副助詞/並立助詞/終助詞', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, surface); - } -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes bare くれ auxiliary fragments', () => { - const token = makeToken({ - surface: 'くれ', - headword: '暮れ', - reading: 'クレ', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - }); - - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes aru existence verbs', () => { - for (const token of [ - makeToken({ - surface: 'ある', - headword: 'ある', - reading: 'アル', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - }), - makeToken({ - surface: '有る', - headword: '有る', - reading: 'アル', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - }), - ]) { - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, token.surface); - } -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone quote particle and auxiliary grammar terms', () => { - for (const token of [ - makeToken({ - surface: 'って', - headword: 'って', - reading: 'ッテ', - partOfSpeech: PartOfSpeech.other, - pos1: '', - pos2: '', - }), - makeToken({ - surface: 'べき', - headword: 'べき', - reading: 'ベキ', - partOfSpeech: PartOfSpeech.other, - pos1: '', - pos2: '', - }), - ]) { - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, token.surface); - } -}); - -test('shouldExcludeTokenFromSubtitleAnnotations excludes single-kana surface fragments', () => { - for (const token of [ - makeToken({ - surface: 'ふ', - headword: '不', - reading: 'フ', - partOfSpeech: PartOfSpeech.other, - pos1: '接頭詞', - pos2: '', - }), - makeToken({ - surface: 'フ', - headword: '負', - reading: 'フ', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - }), - ]) { - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true, token.surface); - } -}); - test('stripSubtitleAnnotationMetadata keeps known hover data while clearing non-known annotation fields', () => { const token = makeToken({ surface: 'は', @@ -1174,6 +1008,40 @@ test('annotateTokens N+1 handoff marks expected target when threshold is satisfi assert.equal(result[2]?.isNPlusOneTarget, false); }); +test('annotateTokens marks no N+1 target without exactly one candidate in a long enough sentence', () => { + const cases = [ + { + name: 'two-word sentence below the default three-word minimum', + surfaces: ['私', '犬'], + known: ['私'], + options: {}, + }, + { + name: 'two unknown candidates', + surfaces: ['私', '猫', '犬'], + known: ['私'], + options: { minSentenceWordsForNPlusOne: 3 }, + }, + ]; + for (const c of cases) { + const tokens = c.surfaces.map((surface, index) => + makeToken({ surface, headword: surface, startPos: index, endPos: index + 1 }), + ); + + const result = annotateTokens( + tokens, + makeDeps({ isKnownWord: (text) => c.known.includes(text) }), + c.options, + ); + + assert.equal( + result.some((token) => token.isNPlusOneTarget), + false, + c.name, + ); + } +}); + test('annotateTokens does not mark kana-only unknown target as N+1', () => { const tokens = [ makeToken({ @@ -1293,105 +1161,58 @@ test('annotateTokens N+1 minimum sentence words excludes unknown tokens filtered }); test('annotateTokens N+1 sentence word count respects source punctuation gaps omitted by Yomitan', () => { - const tokens = [ - makeToken({ - surface: '私', - headword: '私', - pos1: '名詞', - startPos: 0, - endPos: 1, - }), - makeToken({ - surface: '猫', - headword: '猫', - pos1: '名詞', - startPos: 1, - endPos: 2, - }), - makeToken({ - surface: '犬', - headword: '犬', - pos1: '名詞', - startPos: 2, - endPos: 3, - }), - makeToken({ - surface: 'ふざけん', - headword: 'ふざける', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - startPos: 4, - endPos: 8, - }), - ]; + // ! splits the line into 私猫犬 and ふざけんなよ, so neither sentence reaches + // three words. CRLF line breaks are normalized before the gap check. + for (const { sourceText, offsets } of [ + { sourceText: '私猫犬!ふざけんなよ!', offsets: [0, 1, 2, 4] }, + { sourceText: '私\r\n猫犬!ふざけんなよ!', offsets: [0, 2, 3, 5] }, + ]) { + const [first, second, third, verb] = offsets as [number, number, number, number]; + const tokens = [ + makeToken({ + surface: '私', + headword: '私', + pos1: '名詞', + startPos: first, + endPos: first + 1, + }), + makeToken({ + surface: '猫', + headword: '猫', + pos1: '名詞', + startPos: second, + endPos: second + 1, + }), + makeToken({ + surface: '犬', + headword: '犬', + pos1: '名詞', + startPos: third, + endPos: third + 1, + }), + makeToken({ + surface: 'ふざけん', + headword: 'ふざける', + partOfSpeech: PartOfSpeech.verb, + pos1: '動詞', + pos2: '自立', + startPos: verb, + endPos: verb + 4, + }), + ]; - const result = annotateTokens( - tokens, - makeDeps({ - isKnownWord: (text) => text === '私' || text === '猫' || text === '犬', - }), - { - minSentenceWordsForNPlusOne: 3, - sourceText: '私猫犬!ふざけんなよ!', - }, - ); + const result = annotateTokens( + tokens, + makeDeps({ isKnownWord: (text) => text === '私' || text === '猫' || text === '犬' }), + { minSentenceWordsForNPlusOne: 3, sourceText }, + ); - assert.equal(result[0]?.isNPlusOneTarget, false); - assert.equal(result[1]?.isNPlusOneTarget, false); - assert.equal(result[2]?.isNPlusOneTarget, false); - assert.equal(result[3]?.isNPlusOneTarget, false); -}); - -test('annotateTokens N+1 sentence word count normalizes line breaks before gap detection', () => { - const tokens = [ - makeToken({ - surface: '私', - headword: '私', - pos1: '名詞', - startPos: 0, - endPos: 1, - }), - makeToken({ - surface: '猫', - headword: '猫', - pos1: '名詞', - startPos: 2, - endPos: 3, - }), - makeToken({ - surface: '犬', - headword: '犬', - pos1: '名詞', - startPos: 3, - endPos: 4, - }), - makeToken({ - surface: 'ふざけん', - headword: 'ふざける', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - startPos: 5, - endPos: 9, - }), - ]; - - const result = annotateTokens( - tokens, - makeDeps({ - isKnownWord: (text) => text === '私' || text === '猫' || text === '犬', - }), - { - minSentenceWordsForNPlusOne: 3, - sourceText: '私\r\n猫犬!ふざけんなよ!', - }, - ); - - assert.equal(result[0]?.isNPlusOneTarget, false); - assert.equal(result[1]?.isNPlusOneTarget, false); - assert.equal(result[2]?.isNPlusOneTarget, false); - assert.equal(result[3]?.isNPlusOneTarget, false); + assert.deepEqual( + result.map((token) => token.isNPlusOneTarget), + [false, false, false, false], + JSON.stringify(sourceText), + ); + } }); test('annotateTokens applies configured pos1 exclusions to both frequency and N+1', () => { @@ -1507,35 +1328,6 @@ test('annotateTokens keeps known-word status for non-independent kanji noun toke assert.equal(result[0]?.jlptLevel, 'N3'); }); -test('annotateTokens keeps known-word status for lexical non-independent kanji nouns', () => { - const tokens = [ - makeToken({ - surface: '以外', - reading: 'イガイ', - headword: '以外', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '非自立', - pos3: '副詞可能', - startPos: 2, - endPos: 4, - frequencyRank: 437, - }), - ]; - - const result = annotateTokens( - tokens, - makeDeps({ - isKnownWord: (text) => text === '以外', - }), - { minSentenceWordsForNPlusOne: 1 }, - ); - - assert.equal(result[0]?.isKnown, true); - assert.equal(result[0]?.frequencyRank, 437); - assert.equal(result[0]?.isNPlusOneTarget, false); -}); - test('annotateTokens keeps frequency for unknown non-independent kanji noun tokens', () => { // 日 in いい日だったな: MeCab tags it 名詞/非自立 but Yomitan segments it as // a standalone vocabulary token, so frequency highlighting must survive. @@ -1568,32 +1360,6 @@ test('annotateTokens keeps frequency for unknown non-independent kanji noun toke assert.equal(result[0]?.jlptLevel, 'N4'); }); -test('annotateTokens still clears annotations for kana non-independent noun tokens', () => { - const tokens = [ - makeToken({ - surface: 'こと', - reading: 'こと', - headword: 'こと', - partOfSpeech: PartOfSpeech.other, - pos1: '名詞', - pos2: '非自立', - pos3: '一般', - startPos: 0, - endPos: 2, - frequencyRank: 96, - }), - ]; - - const result = annotateTokens(tokens, makeDeps(), { - minSentenceWordsForNPlusOne: 1, - }); - - assert.equal(result[0]?.isKnown, false); - assert.equal(result[0]?.isNPlusOneTarget, false); - assert.equal(result[0]?.frequencyRank, undefined); - assert.equal(result[0]?.jlptLevel, undefined); -}); - test('annotateTokens excludes likely kana SFX tokens from frequency when POS tags are missing', () => { const tokens = [ makeToken({ @@ -1615,58 +1381,6 @@ test('annotateTokens excludes likely kana SFX tokens from frequency when POS tag assert.equal(result[0]?.frequencyRank, undefined); }); -test('annotateTokens clears all annotations from single hiragana and katakana surface fragments', () => { - const tokens = [ - makeToken({ - surface: 'た', - reading: 'た', - headword: 'た', - pos1: '', - pos2: '', - partOfSpeech: PartOfSpeech.other, - frequencyRank: 21, - startPos: 0, - endPos: 1, - }), - makeToken({ - surface: 'フ', - reading: 'フ', - headword: '負', - pos1: '名詞', - pos2: '', - partOfSpeech: PartOfSpeech.noun, - frequencyRank: 22, - startPos: 1, - endPos: 2, - }), - makeToken({ - surface: '山', - reading: 'やま', - headword: '山', - pos1: '', - pos2: '', - partOfSpeech: PartOfSpeech.other, - frequencyRank: 23, - startPos: 2, - endPos: 3, - }), - ]; - - const result = annotateTokens(tokens, makeDeps(), { - minSentenceWordsForNPlusOne: 1, - }); - - assert.equal(result[0]?.isKnown, false); - assert.equal(result[0]?.isNPlusOneTarget, false); - assert.equal(result[0]?.frequencyRank, undefined); - assert.equal(result[0]?.jlptLevel, undefined); - assert.equal(result[1]?.isKnown, false); - assert.equal(result[1]?.isNPlusOneTarget, false); - assert.equal(result[1]?.frequencyRank, undefined); - assert.equal(result[1]?.jlptLevel, undefined); - assert.equal(result[2]?.frequencyRank, 23); -}); - test('annotateTokens keeps frequency when mecab tags classify token as content-bearing', () => { const tokens = [ makeToken({ @@ -1806,79 +1520,6 @@ test('annotateTokens lets known words bypass the shared exclusion gate for known assert.equal(result[0]?.jlptLevel, undefined); }); -test('annotateTokens keeps known status while clearing other annotations for kana-only non-independent noun helper merges', () => { - const tokens = [ - makeToken({ - surface: 'ことに', - headword: '事', - reading: 'コトニ', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞|助詞', - pos2: '非自立|格助詞', - startPos: 0, - endPos: 3, - frequencyRank: 81, - }), - ]; - - const result = annotateTokens( - tokens, - makeDeps({ - isKnownWord: (text) => text === '事', - getJlptLevel: (text) => (text === '事' ? 'N4' : null), - }), - { minSentenceWordsForNPlusOne: 1 }, - ); - - assert.equal(result[0]?.isKnown, true); - assert.equal(result[0]?.isNPlusOneTarget, false); - assert.equal(result[0]?.frequencyRank, undefined); - assert.equal(result[0]?.jlptLevel, undefined); -}); - -test('annotateTokens keeps known status while clearing other annotations for standalone auxiliary inflection fragments', () => { - const tokens = [ - makeToken({ - surface: 'れる', - headword: 'れる', - reading: 'レル', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '接尾', - startPos: 0, - endPos: 2, - frequencyRank: 18, - }), - makeToken({ - surface: 'れた', - headword: 'れる', - reading: 'レタ', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞|助動詞', - pos2: '接尾|*', - startPos: 2, - endPos: 4, - frequencyRank: 19, - }), - ]; - - const result = annotateTokens( - tokens, - makeDeps({ - isKnownWord: (text) => text === 'れる', - getJlptLevel: (text) => (text === 'れる' ? 'N4' : null), - }), - { minSentenceWordsForNPlusOne: 1 }, - ); - - for (const token of result) { - assert.equal(token.isKnown, true, token.surface); - assert.equal(token.isNPlusOneTarget, false, token.surface); - assert.equal(token.frequencyRank, undefined, token.surface); - assert.equal(token.jlptLevel, undefined, token.surface); - } -}); - test('annotateTokens excludes standalone noun-suffix tokens from annotations while keeping cache-backed known status', () => { const tokens = [ makeToken({ @@ -1909,49 +1550,6 @@ test('annotateTokens excludes standalone noun-suffix tokens from annotations whi assert.equal(result[0]?.jlptLevel, undefined); }); -test('annotateTokens keeps known status while clearing other annotations for auxiliary-only te-kureru helper spans', () => { - const tokens = [ - makeToken({ - surface: 'てく', - headword: 'てく', - reading: 'テク', - partOfSpeech: PartOfSpeech.verb, - pos1: '助詞|動詞', - pos2: '接続助詞|非自立', - startPos: 0, - endPos: 2, - frequencyRank: 140, - }), - makeToken({ - surface: 'れた', - headword: 'れる', - reading: 'レタ', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞|助動詞', - pos2: '接尾|*', - startPos: 2, - endPos: 4, - frequencyRank: 19, - }), - ]; - - const result = annotateTokens( - tokens, - makeDeps({ - isKnownWord: (text) => text === 'てく' || text === 'れる', - getJlptLevel: (text) => (text === 'てく' || text === 'れる' ? 'N4' : null), - }), - { minSentenceWordsForNPlusOne: 1 }, - ); - - for (const token of result) { - assert.equal(token.isKnown, true, token.surface); - assert.equal(token.isNPlusOneTarget, false, token.surface); - assert.equal(token.frequencyRank, undefined, token.surface); - assert.equal(token.jlptLevel, undefined, token.surface); - } -}); - test('annotateTokens keeps lexical くれる forms eligible for annotation', () => { const tokens = [ makeToken({ @@ -1981,66 +1579,6 @@ test('annotateTokens keeps lexical くれる forms eligible for annotation', () assert.equal(result[0]?.jlptLevel, 'N4'); }); -test('annotateTokens keeps known status while clearing other annotations for standalone して helper fragments', () => { - const tokens = [ - makeToken({ - surface: 'してる', - headword: 'する', - reading: 'シテル', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞|助動詞', - pos2: '自立|非自立', - startPos: 0, - endPos: 3, - frequencyRank: 22, - }), - ]; - - const result = annotateTokens( - tokens, - makeDeps({ - isKnownWord: (text) => text === 'する', - getJlptLevel: (text) => (text === 'する' ? 'N5' : null), - }), - { minSentenceWordsForNPlusOne: 1 }, - ); - - assert.equal(result[0]?.isKnown, true); - assert.equal(result[0]?.isNPlusOneTarget, false); - assert.equal(result[0]?.frequencyRank, undefined); - assert.equal(result[0]?.jlptLevel, undefined); -}); - -test('annotateTokens keeps known status while clearing other annotations for standalone particle fragments without POS tags', () => { - const tokens = [ - makeToken({ - surface: 'と', - headword: 'と', - reading: 'ト', - partOfSpeech: PartOfSpeech.other, - pos1: '', - pos2: '', - startPos: 0, - endPos: 1, - frequencyRank: 4, - }), - ]; - - const result = annotateTokens( - tokens, - makeDeps({ - isKnownWord: (text) => text === 'と', - getJlptLevel: (text) => (text === 'と' ? 'N5' : null), - }), - { minSentenceWordsForNPlusOne: 1 }, - ); - - assert.equal(result[0]?.isKnown, true); - assert.equal(result[0]?.isNPlusOneTarget, false); - assert.equal(result[0]?.frequencyRank, undefined); - assert.equal(result[0]?.jlptLevel, undefined); -}); - test('annotateTokens keeps known status on standalone particles when the known-word cache contains them', () => { const tokens = [ makeToken({ @@ -2133,183 +1671,6 @@ test('annotateTokens does not mark standalone connective particles as N+1', () = assert.equal(result[1]?.jlptLevel, undefined); }); -test('annotateTokens keeps known status while clearing other annotations for rhetorical もんか grammar particle phrases', () => { - const tokens = [ - makeToken({ - surface: 'もんか', - headword: 'もんか', - reading: 'モンカ', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞|助詞', - pos2: '非自立|副助詞/並立助詞/終助詞', - startPos: 0, - endPos: 3, - frequencyRank: 69629, - }), - ]; - - const result = annotateTokens( - tokens, - makeDeps({ - isKnownWord: (text) => text === 'もんか', - getJlptLevel: (text) => (text === 'もんか' ? 'N2' : null), - }), - { minSentenceWordsForNPlusOne: 1 }, - ); - - assert.equal(result[0]?.isKnown, true); - assert.equal(result[0]?.isNPlusOneTarget, false); - assert.equal(result[0]?.frequencyRank, undefined); - assert.equal(result[0]?.jlptLevel, undefined); -}); - -test('annotateTokens keeps known status while clearing other annotations for bare くれ auxiliary fragments', () => { - const tokens = [ - makeToken({ - surface: 'くれ', - headword: '暮れ', - reading: 'クレ', - partOfSpeech: PartOfSpeech.noun, - pos1: '名詞', - pos2: '一般', - startPos: 0, - endPos: 2, - frequencyRank: 12877, - }), - ]; - - const result = annotateTokens( - tokens, - makeDeps({ - isKnownWord: (text) => text === '暮れ', - getJlptLevel: (text) => (text === '暮れ' ? 'N3' : null), - }), - { minSentenceWordsForNPlusOne: 1 }, - ); - - assert.equal(result[0]?.isKnown, true); - assert.equal(result[0]?.isNPlusOneTarget, false); - assert.equal(result[0]?.frequencyRank, undefined); - assert.equal(result[0]?.jlptLevel, undefined); -}); - -test('annotateTokens keeps known status while clearing other annotations for aru existence verbs', () => { - const tokens = [ - makeToken({ - surface: '有る', - headword: '有る', - reading: 'アル', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - startPos: 0, - endPos: 2, - frequencyRank: 8447, - isKnown: true, - isNPlusOneTarget: true, - isNameMatch: true, - jlptLevel: 'N5', - }), - ]; - - const result = annotateTokens( - tokens, - makeDeps({ - isKnownWord: (text) => text === '有る' || text === 'ある', - getJlptLevel: (text) => (text === '有る' || text === 'ある' ? 'N5' : null), - }), - { minSentenceWordsForNPlusOne: 1 }, - ); - - assert.equal(result[0]?.surface, '有る'); - assert.equal(result[0]?.headword, '有る'); - assert.equal(result[0]?.isKnown, true); - assert.equal(result[0]?.isNPlusOneTarget, false); - // Name matches take precedence over the annotation noise filter. - assert.equal(result[0]?.isNameMatch, true); - assert.equal(result[0]?.frequencyRank, undefined); - assert.equal(result[0]?.jlptLevel, undefined); -}); - -test('annotateTokens keeps known status while clearing other annotations for standalone quote particle and auxiliary grammar terms', () => { - const tokens = [ - makeToken({ - surface: 'って', - headword: 'って', - reading: 'ッテ', - partOfSpeech: PartOfSpeech.other, - pos1: '', - pos2: '', - startPos: 0, - endPos: 2, - frequencyRank: 28, - }), - makeToken({ - surface: 'べき', - headword: 'べき', - reading: 'ベキ', - partOfSpeech: PartOfSpeech.other, - pos1: '', - pos2: '', - startPos: 2, - endPos: 4, - frequencyRank: 268, - }), - ]; - - const result = annotateTokens( - tokens, - makeDeps({ - isKnownWord: (text) => text === 'って' || text === 'べき', - getJlptLevel: (text) => (text === 'って' || text === 'べき' ? 'N3' : null), - }), - { minSentenceWordsForNPlusOne: 1 }, - ); - - for (const token of result) { - assert.equal(token.isKnown, true, token.surface); - assert.equal(token.isNPlusOneTarget, false, token.surface); - assert.equal(token.frequencyRank, undefined, token.surface); - assert.equal(token.jlptLevel, undefined, token.surface); - } -}); - -test('annotateTokens keeps known status while clearing other annotations from standalone あ interjections without POS tags', () => { - const tokens = [ - makeToken({ - surface: 'あ', - headword: 'あ', - reading: 'あ', - partOfSpeech: PartOfSpeech.other, - pos1: '', - pos2: '', - startPos: 0, - endPos: 1, - isKnown: true, - isNPlusOneTarget: true, - frequencyRank: 522, - jlptLevel: 'N5', - }), - ]; - - const result = annotateTokens( - tokens, - makeDeps({ - isKnownWord: (text) => text === 'あ', - getJlptLevel: (text) => (text === 'あ' ? 'N5' : null), - }), - { minSentenceWordsForNPlusOne: 1 }, - ); - - assert.equal(result[0]?.surface, 'あ'); - assert.equal(result[0]?.headword, 'あ'); - assert.equal(result[0]?.reading, 'あ'); - assert.equal(result[0]?.isKnown, true); - assert.equal(result[0]?.isNPlusOneTarget, false); - assert.equal(result[0]?.frequencyRank, undefined); - assert.equal(result[0]?.jlptLevel, undefined); -}); - test('annotateTokens keeps known status while clearing other annotations from expressive subtitle interjections without POS tags', () => { const tokens = [ makeToken({ diff --git a/src/core/services/tokenizer/parser-enrichment-stage.test.ts b/src/core/services/tokenizer/parser-enrichment-stage.test.ts index b4df9cd7..0d10e6af 100644 --- a/src/core/services/tokenizer/parser-enrichment-stage.test.ts +++ b/src/core/services/tokenizer/parser-enrichment-stage.test.ts @@ -99,77 +99,3 @@ test('enrichTokensWithMecabPos1 passes through unchanged when mecab tokens are n const emptyResult = enrichTokensWithMecabPos1(tokens, []); assert.strictEqual(emptyResult, tokens); }); - -test('enrichTokensWithMecabPos1 avoids repeated full scans over distant mecab surfaces', () => { - const tokens = Array.from({ length: 12 }, (_, index) => - makeToken({ surface: `w${index}`, startPos: index, endPos: index + 1, pos1: '' }), - ); - const mecabTokens = tokens.map((token) => - makeToken({ - surface: token.surface, - startPos: token.startPos, - endPos: token.endPos, - pos1: '名詞', - }), - ); - - let distantSurfaceReads = 0; - const distantToken = makeToken({ surface: '遠', startPos: 500, endPos: 501, pos1: '記号' }); - Object.defineProperty(distantToken, 'surface', { - configurable: true, - get() { - distantSurfaceReads += 1; - if (distantSurfaceReads > 3) { - throw new Error('repeated full scan detected'); - } - return '遠'; - }, - }); - mecabTokens.push(distantToken); - - const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens); - assert.equal(enriched.length, tokens.length); - for (const token of enriched) { - assert.equal(token.pos1, '名詞'); - } -}); - -test('enrichTokensWithMecabPos1 avoids repeated active-candidate filter scans', () => { - const tokens = Array.from({ length: 8 }, (_, index) => - makeToken({ surface: `u${index}`, startPos: index, endPos: index + 1, pos1: '' }), - ); - const mecabTokens = [ - makeToken({ surface: 'SENTINEL', startPos: 0, endPos: 100, pos1: '記号' }), - ...tokens.map((token, index) => - makeToken({ - surface: `m${index}`, - startPos: token.startPos, - endPos: token.endPos, - pos1: '名詞', - }), - ), - ]; - - let sentinelFilterCalls = 0; - const originalFilter = Array.prototype.filter; - Array.prototype.filter = function filterWithSentinelCheck( - this: unknown[], - ...args: any[] - ): any[] { - const target = this as Array<{ surface?: string }>; - if (target.some((candidate) => candidate?.surface === 'SENTINEL')) { - sentinelFilterCalls += 1; - if (sentinelFilterCalls > 2) { - throw new Error('repeated active candidate filter scan detected'); - } - } - return (originalFilter as (...params: any[]) => any[]).apply(this, args); - } as typeof Array.prototype.filter; - - try { - const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens); - assert.equal(enriched.length, tokens.length); - } finally { - Array.prototype.filter = originalFilter; - } -}); diff --git a/src/core/services/tokenizer/parser-selection-stage.test.ts b/src/core/services/tokenizer/parser-selection-stage.test.ts index 8d532015..b3468bf0 100644 --- a/src/core/services/tokenizer/parser-selection-stage.test.ts +++ b/src/core/services/tokenizer/parser-selection-stage.test.ts @@ -175,18 +175,6 @@ test('still drops punctuation-only and whitespace-only unparsed runs', () => { assert.equal(tokens?.map((token) => token.surface).join(','), '猫,犬'); }); -test('candidate with only unparsed tokens still yields no dictionary match', () => { - const parseResults = [ - makeParseItem('scanning-parser', [ - [{ text: '戻', reading: '' }], - [{ text: '轟', reading: '' }], - ]), - ]; - - const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword'); - assert.equal(tokens, null); -}); - test('prefers the longest dictionary headword across merged segments', () => { const parseResults = [ makeParseItem('scanning-parser', [ @@ -379,50 +367,30 @@ test('merges trailing katakana continuation without headword into previous token // is merged with trailing function particles (e.g. かかってこいよ → headword かかってくる). // When a competing multi-token candidate splits content and function separately, the // multi-token candidate should win so the content token remains frequency-highlightable. -test('multi-token candidate beats single merged content+function token candidate (frequency regression)', () => { - // Candidate A: single merged token — content verb fused with trailing sentence-final particle - // This is the "bad" candidate: downstream annotation would exclude frequency for the whole - // token because the merged pos1 would contain a function-word component. +test('multi-token candidate beats single merged content+function token candidate in either input order (frequency regression)', () => { + // Merged candidate: content verb fused with the trailing sentence-final particle. Downstream + // annotation would exclude frequency for the whole token because its merged pos1 contains a + // function-word component. const mergedCandidate = makeParseItem('scanning-parser', [ [{ text: 'かかってこいよ', reading: 'かかってこいよ', headword: 'かかってくる' }], ]); - - // Candidate B: two tokens — content verb surface + particle separately. - // The content token is frequency-eligible on its own. + // Split candidate: the content token stays frequency-eligible on its own. const splitCandidate = makeParseItem('scanning-parser', [ [{ text: 'かかってこい', reading: 'かかってこい', headword: 'かかってくる' }], [{ text: 'よ', reading: 'よ', headword: 'よ' }], ]); - // When merged candidate comes first in the array, multi-token split still wins. - const tokens = selectYomitanParseTokens( + for (const candidates of [ [mergedCandidate, splitCandidate], - () => false, - 'headword', - ); - assert.equal(tokens?.length, 2); - assert.equal(tokens?.[0]?.surface, 'かかってこい'); - assert.equal(tokens?.[0]?.headword, 'かかってくる'); - assert.equal(tokens?.[1]?.surface, 'よ'); -}); - -test('multi-token candidate beats single merged content+function token regardless of input order', () => { - const mergedCandidate = makeParseItem('scanning-parser', [ - [{ text: 'かかってこいよ', reading: 'かかってこいよ', headword: 'かかってくる' }], - ]); - - const splitCandidate = makeParseItem('scanning-parser', [ - [{ text: 'かかってこい', reading: 'かかってこい', headword: 'かかってくる' }], - [{ text: 'よ', reading: 'よ', headword: 'よ' }], - ]); - - // Split candidate comes first — should still win over merged. - const tokens = selectYomitanParseTokens( [splitCandidate, mergedCandidate], - () => false, - 'headword', - ); - assert.equal(tokens?.length, 2); - assert.equal(tokens?.[0]?.surface, 'かかってこい'); - assert.equal(tokens?.[1]?.surface, 'よ'); + ]) { + const tokens = selectYomitanParseTokens(candidates, () => false, 'headword'); + assert.deepEqual( + tokens?.map((token) => ({ surface: token.surface, headword: token.headword })), + [ + { surface: 'かかってこい', headword: 'かかってくる' }, + { surface: 'よ', headword: 'よ' }, + ], + ); + } }); diff --git a/src/core/services/tokenizer/subtitle-annotation-filter.test.ts b/src/core/services/tokenizer/subtitle-annotation-filter.test.ts index 898c36ae..f13a8d06 100644 --- a/src/core/services/tokenizer/subtitle-annotation-filter.test.ts +++ b/src/core/services/tokenizer/subtitle-annotation-filter.test.ts @@ -4,10 +4,8 @@ import { MergedToken, PartOfSpeech } from '../../../types'; import { createSubtitleAnnotationRuleContext, shouldExcludeTokenFromSubtitleAnnotations, - SUBTITLE_ANNOTATION_EXCLUDED_TERMS, SUBTITLE_ANNOTATION_RULES, } from './subtitle-annotation-filter'; -import { isKanaChar } from './token-classification'; function makeToken(overrides: Partial = {}): MergedToken { return { @@ -24,96 +22,6 @@ function makeToken(overrides: Partial = {}): MergedToken { }; } -test('subtitle annotation rules expose stable ordered provenance', () => { - assert.deepEqual( - SUBTITLE_ANNOTATION_RULES.map(({ id, issueRef }) => ({ id, issueRef })), - [ - { id: 'unparsed-run', issueRef: '#153' }, - { id: 'configured-pos1-exclusion', issueRef: '#19' }, - { id: 'configured-pos2-exclusion', issueRef: '#150' }, - { id: 'coarse-grammar-pos-fallback', issueRef: '#19' }, - { id: 'auxiliary-stem-grammar-tail', issueRef: '#19' }, - { id: 'kana-non-independent-noun-helper', issueRef: '#56' }, - { id: 'standalone-auxiliary-inflection', issueRef: '#57' }, - { id: 'auxiliary-only-helper-span', issueRef: '#57' }, - { id: 'standalone-suru-te-helper', issueRef: '#57' }, - { id: 'standalone-grammar-particle', issueRef: '#57' }, - { id: 'single-kana-fragment', issueRef: '#57' }, - { id: 'merged-trailing-quote-particle', issueRef: '#19' }, - { id: 'lexical-kureru-keep', issueRef: '#57' }, - { id: 'excluded-term-or-pattern', issueRef: '#19, #33, #57' }, - ], - ); - assert.equal(new Set(SUBTITLE_ANNOTATION_RULES.map(({ id }) => id)).size, 14); - assert.ok(SUBTITLE_ANNOTATION_RULES.every(({ description }) => description.length > 0)); - assert.ok( - SUBTITLE_ANNOTATION_RULES.every( - ({ data }) => Object.isFrozen(data) && Object.values(data).every(Object.isFrozen), - ), - ); -}); - -test('lexical kureru keep rule precedes and overrides the excluded-term rule', () => { - const token = makeToken({ - surface: 'くれ', - headword: 'くれる', - reading: 'クレ', - partOfSpeech: PartOfSpeech.verb, - pos1: '動詞', - pos2: '自立', - }); - const context = createSubtitleAnnotationRuleContext(token); - const keepIndex = SUBTITLE_ANNOTATION_RULES.findIndex(({ id }) => id === 'lexical-kureru-keep'); - const termIndex = SUBTITLE_ANNOTATION_RULES.findIndex( - ({ id }) => id === 'excluded-term-or-pattern', - ); - - assert.ok(keepIndex >= 0 && keepIndex < termIndex); - assert.ok(SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has('くれ')); - assert.equal(SUBTITLE_ANNOTATION_RULES[keepIndex]?.test(context), 'keep'); - assert.equal(SUBTITLE_ANNOTATION_RULES[termIndex]?.test(context), 'exclude'); - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false); -}); - -test('adding an exported excluded term changes annotation matching', () => { - const term = '超語彙'; - const token = makeToken({ - surface: term, - headword: term, - reading: term, - pos1: '名詞', - pos2: '一般', - }); - - SUBTITLE_ANNOTATION_EXCLUDED_TERMS.delete(term); - try { - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false); - SUBTITLE_ANNOTATION_EXCLUDED_TERMS.add(term); - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true); - } finally { - SUBTITLE_ANNOTATION_EXCLUDED_TERMS.delete(term); - } -}); - -test('deleting an exported excluded term changes annotation matching', () => { - const term = 'あなた'; - const token = makeToken({ - surface: term, - headword: term, - reading: term, - pos1: '名詞', - pos2: '一般', - }); - - assert.ok(SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(term)); - try { - SUBTITLE_ANNOTATION_EXCLUDED_TERMS.delete(term); - assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false); - } finally { - SUBTITLE_ANNOTATION_EXCLUDED_TERMS.add(term); - } -}); - test('configured POS rules consume the context exclusion sets in table order', () => { const token = makeToken({ pos1: '名詞', pos2: '一般' }); const context = createSubtitleAnnotationRuleContext(token, { @@ -175,9 +83,3 @@ test('configured POS2 rule preserves supplementary-plane kanji nouns', () => { false, ); }); - -test('kana detection excludes katakana punctuation boundaries', () => { - assert.equal(isKanaChar('゠'), false); - assert.equal(isKanaChar('・'), false); - assert.equal(isKanaChar('ァ'), true); -}); diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.test.ts b/src/core/services/tokenizer/yomitan-parser-runtime.test.ts index ee7d8fa2..46876d0c 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.test.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.test.ts @@ -5,9 +5,11 @@ import * as path from 'path'; import test from 'node:test'; import { countTermsFindLookups, + createBackendDeps, createDeps, - createScanDeps, - runInjectedYomitanScript, + createSettingsAutomationDeps, + termEntry, + termsFound, } from './yomitan-scan-test-harness'; import { addYomitanNoteViaSearch, @@ -23,129 +25,125 @@ import { upsertYomitanDictionarySettings, } from './yomitan-parser-runtime'; -test('syncYomitanDefaultAnkiServer updates default profile server when script reports update', async () => { - let scriptValue = ''; - const deps = createDeps(async (script) => { - scriptValue = script; - return { updated: true }; - }); +const TARGET_ANKI_SERVER = 'http://127.0.0.1:8766'; +const CUSTOM_ANKI_SERVER = 'http://192.168.1.5:8765'; +const CHARACTER_DICTIONARY = 'SubMiner Character Dictionary (AniList 130298)'; - const infoLogs: string[] = []; - const updated = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, { - error: () => undefined, - info: (message) => infoLogs.push(message), - }); +interface AnkiSyncProfile { + profileCurrent: number; + profiles: Array<{ options: { anki: Record } }>; +} - assert.equal(updated, true); - assert.match(scriptValue, /optionsGetFull/); - assert.match(scriptValue, /setAllSettings/); - assert.match(scriptValue, /profileCurrent/); - assert.match(scriptValue, /forceOverride = false/); - assert.equal(infoLogs.length, 1); -}); - -test('syncYomitanDefaultAnkiServer returns true when script reports no change', async () => { - const deps = createDeps(async () => ({ updated: false })); - let infoLogCount = 0; - - const synced = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, { - error: () => undefined, - info: () => { - infoLogCount += 1; +// Runs the real sync script against a backend whose settings start as +// `optionsFull`; every setAllSettings payload lands in `saved`. +function createAnkiSyncDeps(optionsFull: AnkiSyncProfile) { + const saved: AnkiSyncProfile[] = []; + const deps = createBackendDeps({ + actions: { + optionsGetFull: () => structuredClone(optionsFull), + setAllSettings: (params) => { + saved.push((params as { value: AnkiSyncProfile }).value); + return true; + }, }, }); + return { deps, saved }; +} - assert.equal(synced, true); - assert.equal(infoLogCount, 0); -}); +function ankiProfile(anki: Record): AnkiSyncProfile { + return { profileCurrent: 0, profiles: [{ options: { anki } }] }; +} -test('syncYomitanDefaultAnkiServer returns false when existing non-default server blocks update', async () => { - const deps = createDeps(async () => ({ - updated: false, - matched: false, - reason: 'blocked-existing-server', - })); - const infoLogs: string[] = []; +const ANKI_SERVER_SYNC_CASES = [ + { + name: 'replaces the stock default server', + currentServer: 'http://127.0.0.1:8765', + forceOverride: false, + expected: true, + savedServers: [TARGET_ANKI_SERVER], + infoLog: /Updated Yomitan default profile Anki server/, + }, + { + name: 'reports success without saving when already on the target server', + currentServer: TARGET_ANKI_SERVER, + forceOverride: false, + expected: true, + savedServers: [], + infoLog: null, + }, + { + name: 'refuses to replace a custom server', + currentServer: CUSTOM_ANKI_SERVER, + forceOverride: false, + expected: false, + savedServers: [], + infoLog: /blocked-existing-server/, + }, + { + name: 'replaces a custom server when forced', + currentServer: CUSTOM_ANKI_SERVER, + forceOverride: true, + expected: true, + savedServers: [TARGET_ANKI_SERVER], + infoLog: /Updated Yomitan default profile Anki server/, + }, +]; - const synced = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, { - error: () => undefined, - info: (message) => infoLogs.push(message), +for (const c of ANKI_SERVER_SYNC_CASES) { + test(`syncYomitanDefaultAnkiServer ${c.name}`, async () => { + const { deps, saved } = createAnkiSyncDeps(ankiProfile({ server: c.currentServer })); + const infoLogs: string[] = []; + + const synced = await syncYomitanDefaultAnkiServer( + TARGET_ANKI_SERVER, + deps, + { error: () => undefined, info: (message) => infoLogs.push(message) }, + { forceOverride: c.forceOverride }, + ); + + assert.equal(synced, c.expected); + assert.deepEqual( + saved.map((value) => value.profiles[0]?.options.anki.server), + c.savedServers, + ); + if (c.infoLog) { + assert.equal(infoLogs.length, 1); + assert.match(infoLogs[0] ?? '', c.infoLog); + } else { + assert.deepEqual(infoLogs, []); + } }); - - assert.equal(synced, false); - assert.equal(infoLogs.length, 1); - assert.match(infoLogs[0] ?? '', /blocked-existing-server/); -}); - -test('syncYomitanDefaultAnkiServer injects force override when enabled', async () => { - let scriptValue = ''; - const deps = createDeps(async (script) => { - scriptValue = script; - return { updated: false, matched: true }; - }); - - const synced = await syncYomitanDefaultAnkiServer( - 'http://127.0.0.1:8766', - deps, - { - error: () => undefined, - info: () => undefined, - }, - { forceOverride: true }, - ); - - assert.equal(synced, true); - assert.match(scriptValue, /forceOverride = true/); -}); +} test('syncYomitanDefaultAnkiServer updates the active profile Anki deck', async () => { - const optionsFull = { - profileCurrent: 0, - profiles: [ - { - options: { - anki: { - server: 'http://127.0.0.1:8766', - cardFormats: [ - { type: 'term', deck: 'Default', model: 'Mining Note', fields: {} }, - { type: 'kanji', deck: 'Kanji', model: 'Kanji Note', fields: {} }, - ], - terms: { deck: 'Default', model: 'Legacy Note', fields: {} }, - }, - }, - }, - ], - }; - let savedOptions: typeof optionsFull | null = null; - const deps = createDeps((script) => - runInjectedYomitanScript(script, (action, params) => { - if (action === 'optionsGetFull') { - return JSON.parse(JSON.stringify(optionsFull)); - } - if (action === 'setAllSettings') { - savedOptions = (params as { value: typeof optionsFull }).value; - return true; - } - throw new Error(`Unexpected action: ${action}`); + const { deps, saved } = createAnkiSyncDeps( + ankiProfile({ + server: TARGET_ANKI_SERVER, + cardFormats: [ + { type: 'term', deck: 'Default', model: 'Mining Note', fields: {} }, + { type: 'kanji', deck: 'Kanji', model: 'Kanji Note', fields: {} }, + ], + terms: { deck: 'Default', model: 'Legacy Note', fields: {} }, }), ); const synced = await syncYomitanDefaultAnkiServer( - 'http://127.0.0.1:8766', + TARGET_ANKI_SERVER, deps, - { - error: () => undefined, - info: () => undefined, - }, + { error: () => undefined, info: () => undefined }, { deck: 'Minecraft', forceOverride: true }, ); assert.equal(synced, true); - assert.ok(savedOptions); - const saved = savedOptions as typeof optionsFull; - assert.equal(saved.profiles[0]?.options.anki.cardFormats[0]?.deck, 'Minecraft'); - assert.equal(saved.profiles[0]?.options.anki.cardFormats[1]?.deck, 'Kanji'); - assert.equal(saved.profiles[0]?.options.anki.terms.deck, 'Minecraft'); + const anki = saved[0]?.profiles[0]?.options.anki as { + cardFormats: Array<{ deck: string }>; + terms: { deck: string }; + }; + assert.deepEqual( + anki.cardFormats.map((format) => format.deck), + ['Minecraft', 'Kanji'], + ); + assert.equal(anki.terms.deck, 'Minecraft'); }); test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => { @@ -154,7 +152,7 @@ test('syncYomitanDefaultAnkiServer logs and returns false on script failure', as }); const errorLogs: string[] = []; - const updated = await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, { + const updated = await syncYomitanDefaultAnkiServer(TARGET_ANKI_SERVER, deps, { error: (message) => errorLogs.push(message), info: () => undefined, }); @@ -245,402 +243,185 @@ test('extractYomitanCurrentAnkiDeckName falls back to legacy term deck', () => { ); }); +type TermReadingPair = { term: string; reading: string | null }; + +// Frequency backend over one enabled dictionary. `lookup` answers each +// getTermFrequencies request; the requested pair lists land in `requests`. +function createFrequencyDeps( + lookup: (termReadingList: TermReadingPair[]) => unknown[], + options: { dictionary?: string; dictionaryInfo?: unknown[] } = {}, +) { + const requests: TermReadingPair[][] = []; + const actionLog: string[] = []; + const deps = createBackendDeps({ + dictionaries: [options.dictionary ?? 'freq-dict'], + dictionaryInfo: options.dictionaryInfo, + actionLog, + actions: { + getTermFrequencies: (params) => { + const { termReadingList } = params as { termReadingList: TermReadingPair[] }; + requests.push(termReadingList); + return lookup(termReadingList); + }, + }, + }); + return { deps, requests, actionLog }; +} + +function frequencyEntry( + term: string, + reading: string | null, + frequency: number, + displayValue: unknown = String(frequency), + extra: Record = {}, +) { + return { term, reading, dictionary: 'freq-dict', frequency, displayValue, ...extra }; +} + +const quietLogger = { error: () => undefined }; + test('requestYomitanTermFrequencies returns normalized frequency entries', async () => { - let scriptValue = ''; - const deps = createDeps(async (script) => { - scriptValue = script; - return [ - { - term: '猫', - reading: 'ねこ', - hasReading: true, - dictionary: 'freq-dict', - dictionaryPriority: 0, - frequency: 77, - displayValue: '77', - displayValueParsed: true, - }, - { - term: '鍛える', - reading: 'きたえる', - hasReading: false, - dictionary: 'freq-dict', - dictionaryPriority: 1, - frequency: 46961, - displayValue: '2847,46961', - displayValueParsed: true, - }, - { - term: 'invalid', - dictionary: 'freq-dict', - frequency: 0, - }, - ]; - }); - - const result = await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, { - error: () => undefined, - }); - - assert.equal(result.length, 2); - assert.equal(result[0]?.term, '猫'); - assert.equal(result[0]?.hasReading, true); - assert.equal(result[0]?.frequency, 77); - assert.equal(result[0]?.dictionaryPriority, 0); - assert.equal(result[1]?.term, '鍛える'); - assert.equal(result[1]?.hasReading, false); - assert.equal(result[1]?.frequency, 2847); - assert.match(scriptValue, /getTermFrequencies/); - assert.match(scriptValue, /optionsGetFull/); -}); - -test('requestYomitanTermFrequencies prefers primary rank from displayValue array pair', async () => { - const deps = createDeps(async () => [ - { - term: '無人', - reading: 'むじん', - dictionary: 'freq-dict', - dictionaryPriority: 0, - frequency: 157632, - displayValue: [7141, 157632], - displayValueParsed: true, - }, + const { deps } = createFrequencyDeps(() => [ + frequencyEntry('猫', 'ねこ', 77, '77', { hasReading: true }), + frequencyEntry('鍛える', 'きたえる', 46961, '2847,46961', { hasReading: false }), + { term: 'invalid', dictionary: 'freq-dict', frequency: 0 }, ]); - const result = await requestYomitanTermFrequencies([{ term: '無人', reading: 'むじん' }], deps, { - error: () => undefined, - }); + const result = await requestYomitanTermFrequencies( + [{ term: '猫', reading: 'ねこ' }], + deps, + quietLogger, + ); - assert.equal(result.length, 1); - assert.equal(result[0]?.term, '無人'); - assert.equal(result[0]?.frequency, 7141); + assert.deepEqual( + result.map(({ term, hasReading, frequency, dictionaryPriority }) => ({ + term, + hasReading, + frequency, + dictionaryPriority, + })), + [ + { term: '猫', hasReading: true, frequency: 77, dictionaryPriority: 0 }, + { term: '鍛える', hasReading: false, frequency: 2847, dictionaryPriority: 0 }, + ], + ); }); -test('requestYomitanTermFrequencies prefers primary rank from displayValue string pair when raw frequency matches trailing count', async () => { - const deps = createDeps(async () => [ - { - term: '潜む', - reading: 'ひそむ', - dictionary: 'freq-dict', - dictionaryPriority: 0, - frequency: 121, - displayValue: '118,121', - displayValueParsed: false, - }, - ]); +const DISPLAY_VALUE_RANK_CASES = [ + { name: 'array pair', frequency: 157632, displayValue: [7141, 157632], expected: 7141 }, + { name: 'string with leading digits', frequency: 1234, displayValue: '1,234', expected: 1 }, +]; - const result = await requestYomitanTermFrequencies([{ term: '潜む', reading: 'ひそむ' }], deps, { - error: () => undefined, +for (const c of DISPLAY_VALUE_RANK_CASES) { + test(`requestYomitanTermFrequencies takes the primary rank from a displayValue ${c.name}`, async () => { + const { deps } = createFrequencyDeps(() => [ + frequencyEntry('例', 'れい', c.frequency, c.displayValue), + ]); + + const result = await requestYomitanTermFrequencies( + [{ term: '例', reading: 'れい' }], + deps, + quietLogger, + ); + + assert.deepEqual( + result.map(({ term, frequency }) => ({ term, frequency })), + [{ term: '例', frequency: c.expected }], + ); }); - - assert.equal(result.length, 1); - assert.equal(result[0]?.term, '潜む'); - assert.equal(result[0]?.frequency, 118); -}); - -test('requestYomitanTermFrequencies uses leading display digits for displayValue strings', async () => { - const deps = createDeps(async () => [ - { - term: '例', - reading: 'れい', - dictionary: 'freq-dict', - dictionaryPriority: 0, - frequency: 1234, - displayValue: '1,234', - displayValueParsed: false, - }, - ]); - - const result = await requestYomitanTermFrequencies([{ term: '例', reading: 'れい' }], deps, { - error: () => undefined, - }); - - assert.equal(result.length, 1); - assert.equal(result[0]?.term, '例'); - assert.equal(result[0]?.frequency, 1); -}); +} test('requestYomitanTermFrequencies ignores occurrence-based dictionaries for rank tagging', async () => { - let metadataScript = ''; - const deps = createDeps(async (script) => { - if (script.includes('getTermFrequencies')) { - return [ - { - term: '潜む', - reading: 'ひそむ', - dictionary: 'CC100', - frequency: 118121, - displayValue: null, - displayValueParsed: false, - }, - ]; - } + const { deps } = createFrequencyDeps( + () => [{ ...frequencyEntry('潜む', 'ひそむ', 118121, null), dictionary: 'CC100' }], + { + dictionary: 'CC100', + dictionaryInfo: [{ title: 'CC100', frequencyMode: 'occurrence-based' }], + }, + ); - if (script.includes('optionsGetFull')) { - metadataScript = script; - return { - profileCurrent: 0, - profileIndex: 0, - scanLength: 40, - dictionaries: ['CC100'], - dictionaryPriorityByName: { CC100: 0 }, - dictionaryFrequencyModeByName: { CC100: 'occurrence-based' }, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [{ name: 'CC100', enabled: true, id: 0 }], - }, - }, - ], - }; - } - return []; - }); - - const result = await requestYomitanTermFrequencies([{ term: '潜む', reading: 'ひそむ' }], deps, { - error: () => undefined, - }); + const result = await requestYomitanTermFrequencies( + [{ term: '潜む', reading: 'ひそむ' }], + deps, + quietLogger, + ); assert.deepEqual(result, []); - assert.match(metadataScript, /getDictionaryInfo/); }); test('requestYomitanTermFrequencies requests term-only fallback only after reading miss', async () => { - const frequencyScripts: string[] = []; - const deps = createDeps(async (script) => { - if (script.includes('optionsGetFull')) { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [{ name: 'freq-dict', enabled: true, id: 0 }], - }, - }, - ], - }; - } + const { deps, requests } = createFrequencyDeps(([pair]) => + pair?.reading === null ? [frequencyEntry('断じて', null, 7082)] : [], + ); - if (!script.includes('getTermFrequencies')) { - return []; - } + const result = await requestYomitanTermFrequencies( + [{ term: '断じて', reading: 'だん' }], + deps, + quietLogger, + ); - frequencyScripts.push(script); - if (script.includes('"term":"断じて","reading":"だん"')) { - return []; - } - if (script.includes('"term":"断じて","reading":null')) { - return [ - { - term: '断じて', - reading: null, - dictionary: 'freq-dict', - frequency: 7082, - displayValue: '7082', - displayValueParsed: true, - }, - ]; - } - return []; - }); - - const result = await requestYomitanTermFrequencies([{ term: '断じて', reading: 'だん' }], deps, { - error: () => undefined, - }); - - assert.equal(result.length, 1); - assert.equal(result[0]?.frequency, 7082); - assert.equal(frequencyScripts.length, 2); - assert.match(frequencyScripts[0] ?? '', /"term":"断じて","reading":"だん"/); - assert.doesNotMatch(frequencyScripts[0] ?? '', /"term":"断じて","reading":null/); - assert.match(frequencyScripts[1] ?? '', /"term":"断じて","reading":null/); + assert.deepEqual( + result.map((entry) => entry.frequency), + [7082], + ); + assert.deepEqual(requests, [ + [{ term: '断じて', reading: 'だん' }], + [{ term: '断じて', reading: null }], + ]); }); test('requestYomitanTermFrequencies avoids term-only fallback request when reading lookup succeeds', async () => { - const frequencyScripts: string[] = []; - const deps = createDeps(async (script) => { - if (script.includes('optionsGetFull')) { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [{ name: 'freq-dict', enabled: true, id: 0 }], - }, - }, - ], - }; - } + const { deps, requests } = createFrequencyDeps(() => [ + frequencyEntry('鍛える', 'きたえる', 2847), + ]); - if (!script.includes('getTermFrequencies')) { - return []; - } - - frequencyScripts.push(script); - return [ - { - term: '鍛える', - reading: 'きたえる', - dictionary: 'freq-dict', - frequency: 2847, - displayValue: '2847', - displayValueParsed: true, - }, - ]; - }); - - const result = await requestYomitanTermFrequencies([{ term: '鍛える', reading: 'きた' }], deps, { - error: () => undefined, - }); + const result = await requestYomitanTermFrequencies( + [{ term: '鍛える', reading: 'きた' }], + deps, + quietLogger, + ); assert.equal(result.length, 1); - assert.equal(frequencyScripts.length, 1); - assert.match(frequencyScripts[0] ?? '', /"term":"鍛える","reading":"きた"/); - assert.doesNotMatch(frequencyScripts[0] ?? '', /"term":"鍛える","reading":null/); + assert.deepEqual(requests, [[{ term: '鍛える', reading: 'きた' }]]); }); test('requestYomitanTermFrequencies caches profile metadata between calls', async () => { - const scripts: string[] = []; - const deps = createDeps(async (script) => { - scripts.push(script); - if (script.includes('optionsGetFull')) { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [{ name: 'freq-dict', enabled: true, id: 0 }], - }, - }, - ], - }; - } + const { deps, actionLog } = createFrequencyDeps(([pair]) => [ + frequencyEntry(pair?.term ?? '', pair?.reading ?? null, 12), + ]); - if (script.includes('"term":"犬"')) { - return [ - { - term: '犬', - reading: 'いぬ', - dictionary: 'freq-dict', - frequency: 12, - displayValue: '12', - displayValueParsed: true, - }, - ]; - } + await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, quietLogger); + await requestYomitanTermFrequencies([{ term: '犬', reading: 'いぬ' }], deps, quietLogger); - return [ - { - term: '猫', - reading: 'ねこ', - dictionary: 'freq-dict', - frequency: 77, - displayValue: '77', - displayValueParsed: true, - }, - ]; - }); - - await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, { - error: () => undefined, - }); - await requestYomitanTermFrequencies([{ term: '犬', reading: 'いぬ' }], deps, { - error: () => undefined, - }); - - const optionsCalls = scripts.filter((script) => script.includes('optionsGetFull')).length; - assert.equal(optionsCalls, 1); + assert.equal(actionLog.filter((action) => action === 'optionsGetFull').length, 1); }); test('requestYomitanTermFrequencies caches repeated term+reading lookups', async () => { - const scripts: string[] = []; - const deps = createDeps(async (script) => { - scripts.push(script); - if (script.includes('optionsGetFull')) { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [{ name: 'freq-dict', enabled: true, id: 0 }], - }, - }, - ], - }; - } + const { deps, requests } = createFrequencyDeps(() => [frequencyEntry('猫', 'ねこ', 77)]); - return [ - { - term: '猫', - reading: 'ねこ', - dictionary: 'freq-dict', - frequency: 77, - displayValue: '77', - displayValueParsed: true, - }, - ]; - }); + await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, quietLogger); + await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, quietLogger); - await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, { - error: () => undefined, - }); - await requestYomitanTermFrequencies([{ term: '猫', reading: 'ねこ' }], deps, { - error: () => undefined, - }); - - const frequencyCalls = scripts.filter((script) => script.includes('getTermFrequencies')).length; - assert.equal(frequencyCalls, 1); + assert.equal(requests.length, 1); }); test('requestYomitanScanTokens tokenizes with the in-window scanner and no parseText request', async () => { - const scripts: string[] = []; - const actions: string[] = []; - const deps = createScanDeps( - (action, params) => { - actions.push(action); - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [{ options: { scanning: { length: 40 } } }], - }; - } - if (action === 'getDictionaryInfo') { + const parsedTexts: string[] = []; + const deps = createBackendDeps({ + termsFind: (text) => + text.startsWith('取り組んで') + ? termsFound(5, termEntry('取り組む', 'とりくむ', { originalText: '取り組んで' })) + : null, + actions: { + parseText: (params) => { + parsedTexts.push((params as { text: string }).text); return []; - } - if (action === 'termsFind') { - const text = (params as { text?: string } | undefined)?.text ?? ''; - if (!text.startsWith('取り組んで')) { - return { originalTextLength: 0, dictionaryEntries: [] }; - } - return { - originalTextLength: 5, - dictionaryEntries: [ - { - headwords: [ - { - term: '取り組む', - reading: 'とりくむ', - sources: [{ originalText: '取り組んで', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; - } - throw new Error(`unexpected action: ${action}`); + }, }, - { onScript: (script) => scripts.push(script) }, - ); - - const result = await requestYomitanScanTokens('取り組んで', deps, { - error: () => undefined, }); + const result = await requestYomitanScanTokens('取り組んで', deps, quietLogger); + assert.deepEqual(result, [ { surface: '取り組んで', @@ -653,36 +434,13 @@ test('requestYomitanScanTokens tokenizes with the in-window scanner and no parse frequencyRank: undefined, }, ]); - // The duplicate full parse per line is gone: the scanner walk is the only - // tokenization request. - assert.ok(!actions.includes('parseText')); - const installScript = scripts.find((script) => script.includes('termsFind')); - assert.ok(installScript, 'expected the scan runtime install script'); - assert.match(installScript ?? '', /matchType:\s*"exact"/); - assert.match(installScript ?? '', /deinflect:\s*true/); + // The scanner walk is the only tokenization request: no duplicate full parse. + assert.deepEqual(parsedTexts, []); }); test('requestYomitanScanTokens warns when active Yomitan profile has no dictionaries', async () => { const warnings: Array<{ message: string; details: unknown }> = []; - const deps = createScanDeps((action) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [], - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - return { originalTextLength: 0, dictionaryEntries: [] }; - }); + const deps = createBackendDeps({ dictionaries: [], termsFind: () => null }); await requestYomitanScanTokens('字幕', deps, { error: () => undefined, @@ -701,41 +459,16 @@ test('requestYomitanScanTokens warns when active Yomitan profile has no dictiona }); test('requestYomitanScanTokens keeps reading aligned when a kana run extends the previous token', async () => { - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [{ options: { scanning: { length: 40 } } }], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - const text = (params as { text?: string } | undefined)?.text ?? ''; - // 待ち合わせ matches, the trailing る does not, so the kana run extends the - // previous token instead of becoming its own filler token. - if (text.startsWith('待ち合わせ')) { - return { - originalTextLength: 5, - dictionaryEntries: [ - { - headwords: [ - { - term: '待ち合わせる', - reading: 'まちあわせる', - sources: [{ originalText: '待ち合わせ', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; - } - return { originalTextLength: 0, dictionaryEntries: [] }; + // 待ち合わせ matches, the trailing る does not, so the kana run extends the + // previous token instead of becoming its own filler token. + const deps = createBackendDeps({ + termsFind: (text) => + text.startsWith('待ち合わせ') + ? termsFound(5, termEntry('待ち合わせる', 'まちあわせる', { originalText: '待ち合わせ' })) + : null, }); - const result = await requestYomitanScanTokens('待ち合わせる', deps, { - error: () => undefined, - }); + const result = await requestYomitanScanTokens('待ち合わせる', deps, quietLogger); assert.equal(result?.length, 1); assert.equal(result?.[0]?.surface, '待ち合わせる'); @@ -747,59 +480,22 @@ test('requestYomitanScanTokens keeps reading aligned when a kana run extends the }); test('requestYomitanScanTokens emits unparsed filler runs for text the scanner skips', async () => { - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [{ options: { scanning: { length: 40 } } }], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - const text = (params as { text?: string } | undefined)?.text ?? ''; - const singleCharEntry = (term: string, reading: string) => ({ - originalTextLength: 1, - dictionaryEntries: [ - { - headwords: [ - { - term, - reading, - sources: [{ originalText: text[0], isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }); - if (text.startsWith('や')) { - return singleCharEntry('や', 'や'); - } - if (text.startsWith('ほ')) { - return singleCharEntry('帆', 'ほ'); - } - if (text.startsWith('ミナト')) { - return { - originalTextLength: 3, - dictionaryEntries: [ - { - headwords: [ - { - term: 'ミナト', - reading: 'みなと', - sources: [{ originalText: 'ミナト', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; - } - return { originalTextLength: 0, dictionaryEntries: [] }; + const deps = createBackendDeps({ + termsFind: (text) => { + if (text.startsWith('や')) { + return termsFound(1, termEntry('や', 'や')); + } + if (text.startsWith('ほ')) { + return termsFound(1, termEntry('帆', 'ほ', { originalText: 'ほ' })); + } + if (text.startsWith('ミナト')) { + return termsFound(3, termEntry('ミナト', 'みなと')); + } + return null; + }, }); - const result = await requestYomitanScanTokens('やほっ ミナト', deps, { - error: () => undefined, - }); + const result = await requestYomitanScanTokens('やほっ ミナト', deps, quietLogger); assert.deepEqual( result?.map(({ surface, headword, startPos, endPos, isUnparsedRun }) => ({ @@ -821,76 +517,35 @@ test('requestYomitanScanTokens emits unparsed filler runs for text the scanner s assert.equal(result?.[2]?.reading, ''); }); +const RANKED_DICTIONARIES = ['JPDBv2㋕', 'Jiten', 'CC100']; + test('requestYomitanScanTokens extracts best frequency rank from selected termsFind entry', async () => { - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [ - { name: 'JPDBv2㋕', enabled: true, id: 0 }, - { name: 'Jiten', enabled: true, id: 1 }, - { name: 'CC100', enabled: true, id: 2 }, - ], - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - - const text = (params as { text?: string } | undefined)?.text ?? ''; - if (!text.startsWith('潜み')) { - return { originalTextLength: 0, dictionaryEntries: [] }; - } - - return { - originalTextLength: 2, - dictionaryEntries: [ - { - headwords: [ - { - term: '潜む', - reading: 'ひそむ', - sources: [{ originalText: '潜み', isPrimary: true, matchType: 'exact' }], - }, - ], - frequencies: [ - { - headwordIndex: 0, - dictionary: 'JPDBv2㋕', - frequency: 20181, - displayValue: '4073,20181句', - }, - { - headwordIndex: 0, - dictionary: 'Jiten', - frequency: 28594, - displayValue: '4592,28594句', - }, - { - headwordIndex: 0, - dictionary: 'CC100', - frequency: 118121, - displayValue: null, - }, - ], - }, - ], - }; + const deps = createBackendDeps({ + dictionaries: RANKED_DICTIONARIES, + termsFind: (text) => + text.startsWith('潜み') + ? termsFound(2, { + ...termEntry('潜む', 'ひそむ', { originalText: '潜み' }), + frequencies: [ + { + headwordIndex: 0, + dictionary: 'JPDBv2㋕', + frequency: 20181, + displayValue: '4073,20181句', + }, + { + headwordIndex: 0, + dictionary: 'Jiten', + frequency: 28594, + displayValue: '4592,28594句', + }, + { headwordIndex: 0, dictionary: 'CC100', frequency: 118121, displayValue: null }, + ], + }) + : null, }); - const result = await requestYomitanScanTokens('潜み', deps, { - error: () => undefined, - }); + const result = await requestYomitanScanTokens('潜み', deps, quietLogger); assert.deepEqual(result, [ { @@ -906,140 +561,122 @@ test('requestYomitanScanTokens extracts best frequency rank from selected termsF ]); }); -test('requestYomitanScanTokens retries shorter windows when a greedy match has no exact-source headword', async () => { - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [{ name: 'JMdict', enabled: true, id: 0 }], - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - - const text = (params as { text?: string } | undefined)?.text ?? ''; - if (!text.startsWith('平')) { - return { originalTextLength: 0, dictionaryEntries: [] }; - } - if (text.length >= 4) { - // Simulates Yomitan normalization consuming punctuation/whitespace: - // the greedy match spans 平 (平 but no headword source equals it. - return { - originalTextLength: 4, - dictionaryEntries: [ - { - headwords: [ - { - term: '平々', - reading: 'へいへい', - sources: [{ originalText: '平平', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; - } - return { - originalTextLength: 1, - dictionaryEntries: [ +const LATER_EXACT_FREQUENCY_CASES = [ + { + name: 'uses frequency from later exact-match entry when first exact entry has none', + line: '者', + later: { + ...termEntry('者', 'もの'), + frequencies: [ + { headwordIndex: 0, dictionary: 'JPDBv2㋕', frequency: 79601, displayValue: '475,79601句' }, + { headwordIndex: 0, dictionary: 'Jiten', frequency: 338, displayValue: '338' }, + ], + }, + first: termEntry('者', 'もの'), + expected: { surface: '者', reading: 'もの', headword: '者', headwordReading: 'もの' }, + expectedRank: 475, + }, + { + name: 'can use frequency from later exact secondary-match entry', + line: '者', + later: { + ...termEntry('者', 'もの', { isPrimary: false }), + frequencies: [ + { headwordIndex: 0, dictionary: 'JPDBv2㋕', frequency: 79601, displayValue: '475,79601句' }, + ], + }, + first: termEntry('者', 'もの'), + expected: { surface: '者', reading: 'もの', headword: '者', headwordReading: 'もの' }, + expectedRank: 475, + }, + { + name: 'uses exact frequency entry when selected reading differs', + line: '第二走者', + later: { + ...termEntry('第二', '', { isPrimary: false }), + frequencies: [ { - headwords: [ - { - term: '平', - reading: 'たいら', - sources: [{ originalText: '平', isPrimary: true, matchType: 'exact' }], - }, - ], + headwordIndex: 0, + dictionary: 'JPDBv2㋕', + frequency: 189513, + displayValue: '1820,189513句', }, ], - }; - }); + }, + first: termEntry('第二', 'だいに'), + expected: { surface: '第二', reading: 'だいに', headword: '第二', headwordReading: 'だいに' }, + expectedRank: 1820, + }, +]; - const result = await requestYomitanScanTokens('平 (平)', deps, { - error: () => undefined, - }); +for (const c of LATER_EXACT_FREQUENCY_CASES) { + test(`requestYomitanScanTokens ${c.name}`, async () => { + const surface = c.expected.surface; + const deps = createBackendDeps({ + dictionaries: RANKED_DICTIONARIES, + termsFind: (text) => + text.startsWith(surface) + ? termsFound(surface.length, { ...c.first, frequencies: [] }, c.later) + : null, + }); - assert.deepEqual(result, [ - { - surface: '平', - reading: 'たいら', - headword: '平', - headwordReading: 'たいら', + const result = await requestYomitanScanTokens(c.line, deps, quietLogger); + + assert.deepEqual(result?.[0], { + ...c.expected, startPos: 0, - endPos: 1, + endPos: surface.length, isNameMatch: false, - frequencyRank: undefined, - }, - { - surface: '平', - reading: 'たいら', - headword: '平', - headwordReading: 'たいら', - startPos: 3, - endPos: 4, - isNameMatch: false, - frequencyRank: undefined, + frequencyRank: c.expectedRank, + }); + }); +} + +test('requestYomitanScanTokens retries shorter windows when a greedy match has no exact-source headword', async () => { + const deps = createBackendDeps({ + dictionaries: ['JMdict'], + termsFind: (text) => { + if (!text.startsWith('平')) { + return null; + } + if (text.length >= 4) { + // Simulates Yomitan normalization consuming punctuation/whitespace: + // the greedy match spans 平 (平 but no headword source equals it. + return termsFound(4, termEntry('平々', 'へいへい', { originalText: '平平' })); + } + return termsFound(1, termEntry('平', 'たいら')); }, + }); + + const result = await requestYomitanScanTokens('平 (平)', deps, quietLogger); + + const token = { + surface: '平', + reading: 'たいら', + headword: '平', + headwordReading: 'たいら', + isNameMatch: false, + frequencyRank: undefined, + }; + assert.deepEqual(result, [ + { ...token, startPos: 0, endPos: 1 }, + { ...token, startPos: 3, endPos: 4 }, ]); }); test('requestYomitanScanTokens emits complete readings for kanji-kana compounds', async () => { - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [{ name: 'JPDBv2㋕', enabled: true, id: 0 }], - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - - const text = (params as { text?: string } | undefined)?.text ?? ''; - if (!text.startsWith('待ち合わせてる')) { - return { originalTextLength: 0, dictionaryEntries: [] }; - } - - return { - originalTextLength: 7, - dictionaryEntries: [ - { - headwords: [ - { - term: '待ち合わせる', - reading: 'まちあわせる', - sources: [{ originalText: '待ち合わせてる', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; + const deps = createBackendDeps({ + dictionaries: ['JPDBv2㋕'], + termsFind: (text) => + text.startsWith('待ち合わせてる') + ? termsFound( + 7, + termEntry('待ち合わせる', 'まちあわせる', { originalText: '待ち合わせてる' }), + ) + : null, }); - const result = await requestYomitanScanTokens('待ち合わせてる', deps, { - error: () => undefined, - }); + const result = await requestYomitanScanTokens('待ち合わせてる', deps, quietLogger); assert.deepEqual(result, [ { @@ -1055,476 +692,74 @@ test('requestYomitanScanTokens emits complete readings for kanji-kana compounds' ]); }); -test('requestYomitanScanTokens uses frequency from later exact-match entry when first exact entry has none', async () => { - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [ - { name: 'JPDBv2㋕', enabled: true, id: 0 }, - { name: 'Jiten', enabled: true, id: 1 }, - { name: 'CC100', enabled: true, id: 2 }, - ], - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - - const text = (params as { text?: string } | undefined)?.text ?? ''; - if (!text.startsWith('者')) { - return { originalTextLength: 0, dictionaryEntries: [] }; - } - - return { - originalTextLength: 1, - dictionaryEntries: [ - { - headwords: [ - { - term: '者', - reading: 'もの', - sources: [{ originalText: '者', isPrimary: true, matchType: 'exact' }], - }, - ], - frequencies: [], - }, - { - headwords: [ - { - term: '者', - reading: 'もの', - sources: [{ originalText: '者', isPrimary: true, matchType: 'exact' }], - }, - ], - frequencies: [ - { - headwordIndex: 0, - dictionary: 'JPDBv2㋕', - frequency: 79601, - displayValue: '475,79601句', - }, - { - headwordIndex: 0, - dictionary: 'Jiten', - frequency: 338, - displayValue: '338', - }, - ], - }, - ], - }; - }); - - const result = await requestYomitanScanTokens('者', deps, { - error: () => undefined, - }); - - assert.deepEqual(result, [ - { - surface: '者', - reading: 'もの', - headword: '者', - headwordReading: 'もの', - startPos: 0, - endPos: 1, - isNameMatch: false, - frequencyRank: 475, - }, - ]); -}); - -test('requestYomitanScanTokens can use frequency from later exact secondary-match entry', async () => { - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [ - { name: 'JPDBv2㋕', enabled: true, id: 0 }, - { name: 'Jiten', enabled: true, id: 1 }, - { name: 'CC100', enabled: true, id: 2 }, - ], - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - - const text = (params as { text?: string } | undefined)?.text ?? ''; - if (!text.startsWith('者')) { - return { originalTextLength: 0, dictionaryEntries: [] }; - } - - return { - originalTextLength: 1, - dictionaryEntries: [ - { - headwords: [ - { - term: '者', - reading: 'もの', - sources: [{ originalText: '者', isPrimary: true, matchType: 'exact' }], - }, - ], - frequencies: [], - }, - { - headwords: [ - { - term: '者', - reading: 'もの', - sources: [{ originalText: '者', isPrimary: false, matchType: 'exact' }], - }, - ], - frequencies: [ - { - headwordIndex: 0, - dictionary: 'JPDBv2㋕', - frequency: 79601, - displayValue: '475,79601句', - }, - ], - }, - ], - }; - }); - - const result = await requestYomitanScanTokens('者', deps, { - error: () => undefined, - }); - - assert.deepEqual(result, [ - { - surface: '者', - reading: 'もの', - headword: '者', - headwordReading: 'もの', - startPos: 0, - endPos: 1, - isNameMatch: false, - frequencyRank: 475, - }, - ]); -}); - -test('requestYomitanScanTokens uses exact frequency entry when selected reading differs', async () => { - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [ - { name: 'JPDBv2㋕', enabled: true, id: 0 }, - { name: 'Jiten', enabled: true, id: 1 }, - { name: 'CC100', enabled: true, id: 2 }, - ], - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - - const text = (params as { text?: string } | undefined)?.text ?? ''; - if (!text.startsWith('第二')) { - return { originalTextLength: 0, dictionaryEntries: [] }; - } - - return { - originalTextLength: 2, - dictionaryEntries: [ - { - headwords: [ - { - term: '第二', - reading: 'だいに', - sources: [{ originalText: '第二', isPrimary: true, matchType: 'exact' }], - }, - ], - frequencies: [], - }, - { - headwords: [ - { - term: '第二', - reading: '', - sources: [{ originalText: '第二', isPrimary: false, matchType: 'exact' }], - }, - ], - frequencies: [ - { - headwordIndex: 0, - dictionary: 'JPDBv2㋕', - frequency: 189513, - displayValue: '1820,189513句', - }, - ], - }, - ], - }; - }); - - const result = await requestYomitanScanTokens('第二走者', deps, { - error: () => undefined, - }); - - assert.deepEqual(result?.[0], { - surface: '第二', - reading: 'だいに', - headword: '第二', - headwordReading: 'だいに', - startPos: 0, - endPos: 2, - isNameMatch: false, - frequencyRank: 1820, - }); -}); - -test('requestYomitanScanTokens marks tokens backed by SubMiner character dictionary entries', async () => { - const deps = createDeps(async (script) => { - if (script.includes('optionsGetFull')) { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - }, - }, - ], - }; - } - - return [ - { - surface: 'アクア', - reading: 'あくあ', - headword: 'アクア', - startPos: 0, - endPos: 3, - isNameMatch: true, - }, - { - surface: 'です', - reading: 'です', - headword: 'です', - startPos: 3, - endPos: 5, - isNameMatch: false, - }, - ]; - }); - - const result = await requestYomitanScanTokens('アクアです', deps, { - error: () => undefined, - }); - - assert.equal(result?.length, 2); - assert.equal((result?.[0] as { isNameMatch?: boolean } | undefined)?.isNameMatch, true); - assert.equal((result?.[1] as { isNameMatch?: boolean } | undefined)?.isNameMatch, false); -}); - -test('requestYomitanScanTokens skips name-match work when disabled', async () => { - let scanCallScript = ''; - const deps = createDeps(async (script) => { - if (script.includes('__subminerYomitanScan(')) { - scanCallScript = script; - } - if (script.includes('optionsGetFull')) { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - }, - }, - ], - }; - } - - return [ - { - surface: 'アクア', - reading: 'あくあ', - headword: 'アクア', - startPos: 0, - endPos: 3, - }, - ]; - }); - - const result = await requestYomitanScanTokens( - 'アクア', - deps, - { error: () => undefined }, - { includeNameMatchMetadata: false }, - ); - - assert.equal(result?.length, 1); - assert.equal((result?.[0] as { isNameMatch?: boolean } | undefined)?.isNameMatch, undefined); - assert.match(scanCallScript, /"includeNameMatchMetadata":false/); -}); +function nameTokenSummary(result: Awaited>) { + return result?.map(({ surface, headword, startPos, endPos, isNameMatch }) => ({ + surface, + headword, + startPos, + endPos, + isNameMatch, + })); +} test('requestYomitanScanTokens marks grouped entries when SubMiner dictionary alias only exists on definitions', async () => { - const scripts: string[] = []; - const deps = createScanDeps( - (action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action === 'termsFind') { - const text = (params as { text?: string } | undefined)?.text; - if (text === 'カズマ') { - return { - originalTextLength: 3, - dictionaryEntries: [ - { - dictionaryAlias: '', - headwords: [ - { - term: 'カズマ', - reading: 'かずま', - sources: [{ originalText: 'カズマ', isPrimary: true, matchType: 'exact' }], - }, - ], - definitions: [ - { dictionary: 'JMdict', dictionaryAlias: 'JMdict' }, - { - dictionary: 'SubMiner Character Dictionary (AniList 130298)', - dictionaryAlias: 'SubMiner Character Dictionary (AniList 130298)', - }, - ], - }, + const deps = createBackendDeps({ + termsFind: (text) => + text === 'カズマ' + ? termsFound(3, { + ...termEntry('カズマ', 'かずま'), + dictionaryAlias: '', + definitions: [ + { dictionary: 'JMdict', dictionaryAlias: 'JMdict' }, + { dictionary: CHARACTER_DICTIONARY, dictionaryAlias: CHARACTER_DICTIONARY }, ], - }; - } - return { originalTextLength: 0, dictionaryEntries: [] }; - } - throw new Error(`unexpected action: ${action}`); - }, - { onScript: (script) => scripts.push(script) }, - ); - - const result = await requestYomitanScanTokens( - 'カズマ', - deps, - { error: () => undefined }, - { includeNameMatchMetadata: true }, - ); - - assert.ok(scripts.some((script) => script.includes('getPreferredHeadword'))); - assert.equal(Array.isArray(result), true); - assert.equal((result as { length?: number } | null)?.length, 1); - assert.equal((result as Array<{ surface?: string }>)[0]?.surface, 'カズマ'); - assert.equal((result as Array<{ headword?: string }>)[0]?.headword, 'カズマ'); - assert.equal((result as Array<{ startPos?: number }>)[0]?.startPos, 0); - assert.equal((result as Array<{ endPos?: number }>)[0]?.endPos, 3); - assert.equal((result as Array<{ isNameMatch?: boolean }>)[0]?.isNameMatch, true); -}); - -test('requestYomitanScanTokens ignores SubMiner character entries from other media', async () => { - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - const text = (params as { text?: string } | undefined)?.text; - if (text !== 'カズ') { - return { originalTextLength: 0, dictionaryEntries: [] }; - } - return { - originalTextLength: 2, - dictionaryEntries: [ - { - headwords: [ - { - term: 'カズ', - reading: 'かず', - sources: [{ originalText: 'カズ', isPrimary: true, matchType: 'exact' }], - }, - ], - definitions: [ - { - dictionary: 'SubMiner Character Dictionary', - dictionaryAlias: 'SubMiner Character Dictionary', - entries: [ - { - type: 'structured-content', - content: { - tag: 'img', - path: 'img/m115230-c9.png', - alt: 'Kaz', - }, - }, - ], - }, - ], - }, - ], - }; + }) + : null, }); - const result = await requestYomitanScanTokens( - 'カズ', - deps, - { error: () => undefined }, - { includeNameMatchMetadata: true, currentCharacterDictionaryMediaId: 21202 }, - ); + const result = await requestYomitanScanTokens('カズマ', deps, quietLogger, { + includeNameMatchMetadata: true, + }); + + assert.deepEqual(nameTokenSummary(result), [ + { surface: 'カズマ', headword: 'カズマ', startPos: 0, endPos: 3, isNameMatch: true }, + ]); +}); + +// A SubMiner character entry whose structured content carries `media` (an +// image path or a data attribute) naming the media it was generated for. +function characterEntryForMedia(term: string, reading: string, media: Record) { + return { + ...termEntry(term, reading), + definitions: [ + { + dictionary: 'SubMiner Character Dictionary', + dictionaryAlias: 'SubMiner Character Dictionary', + entries: [{ type: 'structured-content', content: media }], + }, + ], + }; +} + +test('requestYomitanScanTokens ignores SubMiner character entries from other media', async () => { + const deps = createBackendDeps({ + termsFind: (text) => + text === 'カズ' + ? termsFound( + 2, + characterEntryForMedia('カズ', 'かず', { + tag: 'img', + path: 'img/m115230-c9.png', + alt: 'Kaz', + }), + ) + : null, + }); + + const result = await requestYomitanScanTokens('カズ', deps, quietLogger, { + includeNameMatchMetadata: true, + currentCharacterDictionaryMediaId: 21202, + }); // No dictionary-backed token survives (the only match belongs to another // media's character dictionary), so the line reports no tokenization. @@ -1532,684 +767,241 @@ test('requestYomitanScanTokens ignores SubMiner character entries from other med }); test('requestYomitanScanTokens accepts SubMiner character entries with structured-content media data', async () => { - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - const text = (params as { text?: string } | undefined)?.text; - if (text !== 'アクア') { - return { originalTextLength: 0, dictionaryEntries: [] }; - } - return { - originalTextLength: 3, - dictionaryEntries: [ - { - headwords: [ - { - term: 'アクア', - reading: 'あくあ', - sources: [{ originalText: 'アクア', isPrimary: true, matchType: 'exact' }], - }, - ], - definitions: [ - { - dictionary: 'SubMiner Character Dictionary', - dictionaryAlias: 'SubMiner Character Dictionary', - entries: [ - { - type: 'structured-content', - content: { - tag: 'div', - data: { subminerMediaId: '21699' }, - content: [ - { - tag: 'img', - path: 'img/m115230-c1.png', - alt: 'アクア', - }, - ], - }, - }, - ], - }, - ], - }, - ], - }; + const deps = createBackendDeps({ + termsFind: (text) => + text === 'アクア' + ? termsFound( + 3, + characterEntryForMedia('アクア', 'あくあ', { + tag: 'div', + data: { subminerMediaId: '21699' }, + content: [{ tag: 'img', path: 'img/m115230-c1.png', alt: 'アクア' }], + }), + ) + : null, }); - const result = await requestYomitanScanTokens( - 'アクア', - deps, - { error: () => undefined }, - { includeNameMatchMetadata: true, currentCharacterDictionaryMediaId: 21699 }, - ); + const result = await requestYomitanScanTokens('アクア', deps, quietLogger, { + includeNameMatchMetadata: true, + currentCharacterDictionaryMediaId: 21699, + }); - assert.equal(Array.isArray(result), true); - assert.equal((result as Array<{ surface?: string }>)[0]?.surface, 'アクア'); - assert.equal((result as Array<{ isNameMatch?: boolean }>)[0]?.isNameMatch, true); + assert.equal(result?.[0]?.surface, 'アクア'); + assert.equal(result?.[0]?.isNameMatch, true); }); -test('requestYomitanScanTokens greedily tokenizes character names before longer generic matches', async () => { - let scanCallScript = ''; - const nameEntry = (term: string, reading: string) => ({ - headwords: [ - { - term, - reading, - sources: [{ originalText: term, isPrimary: true, matchType: 'exact' }], - }, - ], - definitions: [ - { - dictionary: 'SubMiner Character Dictionary (AniList 130298)', - dictionaryAlias: 'SubMiner Character Dictionary (AniList 130298)', - }, - ], - }); - const jmdictEntry = (term: string, reading: string, originalText: string) => ({ - headwords: [ - { - term, - reading, - sources: [{ originalText, isPrimary: true, matchType: 'exact' }], - }, - ], - definitions: [{ dictionary: 'JMdict', dictionaryAlias: 'JMdict' }], - }); +const NAME_DICTIONARIES = ['JMdict', CHARACTER_DICTIONARY]; - const deps = createScanDeps( - (action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [ - { name: 'JMdict', enabled: true }, - { name: 'SubMiner Character Dictionary (AniList 130298)', enabled: true }, - ], - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - const text = (params as { text?: string } | undefined)?.text ?? ''; +function nameEntry(term: string, reading: string) { + return termEntry(term, reading, { dictionary: CHARACTER_DICTIONARY }); +} + +function jmdictEntry(term: string, reading: string, originalText = term) { + return termEntry(term, reading, { originalText, dictionary: 'JMdict' }); +} + +test('requestYomitanScanTokens greedily tokenizes character names before longer generic matches', async () => { + const deps = createBackendDeps({ + dictionaries: NAME_DICTIONARIES, + termsFind: (text) => { if (text.startsWith('美姫')) { - return { originalTextLength: 2, dictionaryEntries: [nameEntry('美姫', 'みき')] }; + return termsFound(2, nameEntry('美姫', 'みき')); } if (text.startsWith('とヨータ')) { // Greedy generic match: とヨー normalizes to とよう (渡洋). Without the // name pre-pass this consumes the ヨ of ヨータ. - return { - originalTextLength: 3, - dictionaryEntries: [ - jmdictEntry('渡洋', 'とよう', 'とヨー'), - jmdictEntry('と', 'と', 'と'), - ], - }; + return termsFound(3, jmdictEntry('渡洋', 'とよう', 'とヨー'), jmdictEntry('と', 'と')); } if (text.startsWith('ヨータ')) { - return { originalTextLength: 3, dictionaryEntries: [nameEntry('ヨータ', 'よーた')] }; + return termsFound(3, nameEntry('ヨータ', 'よーた')); } if (text === 'と') { - return { originalTextLength: 1, dictionaryEntries: [jmdictEntry('と', 'と', 'と')] }; + return termsFound(1, jmdictEntry('と', 'と')); } - return { originalTextLength: 0, dictionaryEntries: [] }; + return null; }, - { - onScript: (script) => { - if (script.includes('__subminerYomitanScan(')) { - scanCallScript = script; - } - }, - }, - ); + }); - const result = await requestYomitanScanTokens( - '美姫とヨータ', - deps, - { error: () => undefined }, - { includeNameMatchMetadata: true }, - ); + const result = await requestYomitanScanTokens('美姫とヨータ', deps, quietLogger, { + includeNameMatchMetadata: true, + }); - assert.match(scanCallScript, /"greedyNameScanEnabled":true/); - assert.equal(Array.isArray(result), true); - assert.deepEqual( - result?.map(({ surface, headword, startPos, endPos, isNameMatch }) => ({ - surface, - headword, - startPos, - endPos, - isNameMatch, - })), - [ - { surface: '美姫', headword: '美姫', startPos: 0, endPos: 2, isNameMatch: true }, - { surface: 'と', headword: 'と', startPos: 2, endPos: 3, isNameMatch: false }, - { surface: 'ヨータ', headword: 'ヨータ', startPos: 3, endPos: 6, isNameMatch: true }, - ], - ); + assert.deepEqual(nameTokenSummary(result), [ + { surface: '美姫', headword: '美姫', startPos: 0, endPos: 2, isNameMatch: true }, + { surface: 'と', headword: 'と', startPos: 2, endPos: 3, isNameMatch: false }, + { surface: 'ヨータ', headword: 'ヨータ', startPos: 3, endPos: 6, isNameMatch: true }, + ]); }); test('requestYomitanScanTokens lets a longer generic word beat a shorter name at the same position', async () => { - const nameEntry = (term: string, reading: string) => ({ - headwords: [ - { - term, - reading, - sources: [{ originalText: term, isPrimary: true, matchType: 'exact' }], - }, - ], - definitions: [ - { - dictionary: 'SubMiner Character Dictionary (AniList 130298)', - dictionaryAlias: 'SubMiner Character Dictionary (AniList 130298)', - }, - ], - }); - const jmdictEntry = (term: string, reading: string, originalText: string) => ({ - headwords: [ - { - term, - reading, - sources: [{ originalText, isPrimary: true, matchType: 'exact' }], - }, - ], - definitions: [{ dictionary: 'JMdict', dictionaryAlias: 'JMdict' }], + const deps = createBackendDeps({ + dictionaries: NAME_DICTIONARIES, + termsFind: (text) => { + if (text.startsWith('空気')) { + // A character named 空 matches here, but the generic 空気 is longer and + // must win the position. + return termsFound(2, nameEntry('空', 'くう'), jmdictEntry('空気', 'くうき')); + } + if (text.startsWith('変わって')) { + return termsFound(4, jmdictEntry('変わる', 'かわる', '変わって')); + } + return null; + }, }); - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [ - { name: 'JMdict', enabled: true }, - { name: 'SubMiner Character Dictionary (AniList 130298)', enabled: true }, - ], - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - const text = (params as { text?: string } | undefined)?.text ?? ''; - if (text.startsWith('空気')) { - // A character named 空 matches here, but the generic 空気 is longer and - // must win the position. - return { - originalTextLength: 2, - dictionaryEntries: [nameEntry('空', 'くう'), jmdictEntry('空気', 'くうき', '空気')], - }; - } - if (text.startsWith('変わって')) { - return { - originalTextLength: 4, - dictionaryEntries: [jmdictEntry('変わる', 'かわる', '変わって')], - }; - } - return { originalTextLength: 0, dictionaryEntries: [] }; + const result = await requestYomitanScanTokens('空気変わって', deps, quietLogger, { + includeNameMatchMetadata: true, }); - const result = await requestYomitanScanTokens( - '空気変わって', - deps, - { error: () => undefined }, - { includeNameMatchMetadata: true }, - ); - - assert.equal(Array.isArray(result), true); - assert.deepEqual( - result?.map(({ surface, headword, startPos, endPos, isNameMatch }) => ({ - surface, - headword, - startPos, - endPos, - isNameMatch, - })), - [ - { surface: '空気', headword: '空気', startPos: 0, endPos: 2, isNameMatch: false }, - { surface: '変わって', headword: '変わる', startPos: 2, endPos: 6, isNameMatch: false }, - ], - ); + assert.deepEqual(nameTokenSummary(result), [ + { surface: '空気', headword: '空気', startPos: 0, endPos: 2, isNameMatch: false }, + { surface: '変わって', headword: '変わる', startPos: 2, endPos: 6, isNameMatch: false }, + ]); }); test('requestYomitanScanTokens lets a generic word beat a name it fully contains', async () => { - const nameEntry = (term: string, reading: string) => ({ - headwords: [ - { - term, - reading, - sources: [{ originalText: term, isPrimary: true, matchType: 'exact' }], - }, - ], - definitions: [ - { - dictionary: 'SubMiner Character Dictionary (AniList 130298)', - dictionaryAlias: 'SubMiner Character Dictionary (AniList 130298)', - }, - ], - }); - const jmdictEntry = (term: string, reading: string, originalText: string) => ({ - headwords: [ - { - term, - reading, - sources: [{ originalText, isPrimary: true, matchType: 'exact' }], - }, - ], - definitions: [{ dictionary: 'JMdict', dictionaryAlias: 'JMdict' }], + const deps = createBackendDeps({ + dictionaries: NAME_DICTIONARIES, + termsFind: (text) => { + if (text.startsWith('写真')) { + return termsFound(2, jmdictEntry('写真', 'しゃしん')); + } + if (text.startsWith('写')) { + return termsFound(1, jmdictEntry('写', 'しゃ')); + } + if (text.startsWith('真')) { + // The given name of 安田真 also matches the second half of 写真. + return termsFound(1, nameEntry('真', 'しん'), jmdictEntry('真', 'しん')); + } + if (text.startsWith('は')) { + return termsFound(1, jmdictEntry('は', 'は')); + } + return null; + }, }); - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [ - { name: 'JMdict', enabled: true }, - { name: 'SubMiner Character Dictionary (AniList 130298)', enabled: true }, - ], - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - const text = (params as { text?: string } | undefined)?.text ?? ''; - if (text.startsWith('写真')) { - return { - originalTextLength: 2, - dictionaryEntries: [jmdictEntry('写真', 'しゃしん', '写真')], - }; - } - if (text.startsWith('写')) { - return { originalTextLength: 1, dictionaryEntries: [jmdictEntry('写', 'しゃ', '写')] }; - } - if (text.startsWith('真')) { - // The given name of 安田真 also matches the second half of 写真. - return { - originalTextLength: 1, - dictionaryEntries: [nameEntry('真', 'しん'), jmdictEntry('真', 'しん', '真')], - }; - } - if (text.startsWith('は')) { - return { originalTextLength: 1, dictionaryEntries: [jmdictEntry('は', 'は', 'は')] }; - } - return { originalTextLength: 0, dictionaryEntries: [] }; + const result = await requestYomitanScanTokens('写真は', deps, quietLogger, { + includeNameMatchMetadata: true, + }); + + assert.deepEqual(nameTokenSummary(result), [ + { surface: '写真', headword: '写真', startPos: 0, endPos: 2, isNameMatch: false }, + { surface: 'は', headword: 'は', startPos: 2, endPos: 3, isNameMatch: false }, + ]); +}); + +test('requestYomitanScanTokens still finds an emphatically elongated name a longer generic match would swallow', async () => { + // Yomitan collapses emphatic sequences, so ミナァァト resolves to the ミナト + // entry. The generic word とミナ starts earlier and would swallow the name + // unless the pre-pass reserves it, so this only passes when the candidate + // prefilter still treats the elongated spelling as a possible name start. + const characterDictionary = 'SubMiner Character Dictionary (AniList 1)'; + const deps = createBackendDeps({ + dictionaries: ['JMdict', characterDictionary], + termsFind: (text) => { + if (text.startsWith('とミナ')) { + return termsFound(3, jmdictEntry('トミナ', 'とみな', 'とミナ')); + } + if (text.startsWith('ミナァァト')) { + return termsFound( + 5, + termEntry('ミナト', 'みなと', { + originalText: 'ミナァァト', + dictionary: characterDictionary, + }), + ); + } + if (text.startsWith('と')) { + return termsFound(1, jmdictEntry('と', 'と')); + } + return null; + }, + }); + + const result = await requestYomitanScanTokens('とミナァァト', deps, quietLogger, { + includeNameMatchMetadata: true, + currentCharacterDictionaryMediaId: 1, + nameCandidates: { key: 'media-1', forms: ['ミナト', 'みなと'] }, + }); + + const nameToken = result?.find((token) => token.isNameMatch === true); + assert.ok(nameToken, 'expected the elongated name to be reserved by the pre-pass'); + assert.equal(nameToken?.headword, 'ミナト'); + assert.equal(nameToken?.startPos, 1); +}); + +test('requestYomitanScanTokens preserves matched headword word classes', async () => { + const deps = createBackendDeps({ + termsFind: (text) => { + if (text !== 'は') { + return null; + } + const entry = termEntry('は', 'は'); + return termsFound(1, { + ...entry, + headwords: [{ ...entry.headwords[0], wordClasses: ['prt'] }], + }); + }, + }); + + const result = await requestYomitanScanTokens('は', deps, quietLogger); + + assert.deepEqual(result?.[0]?.wordClasses, ['prt']); +}); + +test('requestYomitanScanTokens skips fallback fragments without exact primary source matches', async () => { + const matches: Array<[string, ReturnType]> = [ + ['だが ', termsFound(2, termEntry('だが', 'だが'))], + ['それでも', termsFound(4, termEntry('それでも', 'それでも'))], + ['届かぬ', termsFound(3, termEntry('届く', 'とどく', { originalText: '届かぬ' }))], + ['高み', termsFound(2, termEntry('高み', 'たかみ'))], + ['があった', termsFound(2, termEntry('があ', '', { originalText: 'が' }))], + ['あった', termsFound(3, termEntry('ある', 'ある', { originalText: 'あった' }))], + ]; + const deps = createBackendDeps({ + termsFind: (text) => matches.find(([prefix]) => text.startsWith(prefix))?.[1], }); const result = await requestYomitanScanTokens( - '写真は', + 'だが それでも届かぬ高みがあった', deps, - { error: () => undefined }, - { includeNameMatchMetadata: true }, + quietLogger, ); - assert.equal(Array.isArray(result), true); assert.deepEqual( - result?.map(({ surface, headword, startPos, endPos, isNameMatch }) => ({ + result?.map(({ surface, headword, startPos, endPos }) => ({ surface, headword, startPos, endPos, - isNameMatch, })), [ - { surface: '写真', headword: '写真', startPos: 0, endPos: 2, isNameMatch: false }, - { surface: 'は', headword: 'は', startPos: 2, endPos: 3, isNameMatch: false }, - ], - ); -}); - -test('requestYomitanScanTokens skips greedy name scan without an enabled character dictionary', async () => { - let scanCallScript = ''; - const deps = createScanDeps( - (action) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [{ name: 'JMdict', enabled: true }], - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - return { originalTextLength: 0, dictionaryEntries: [] }; - }, - { - onScript: (script) => { - if (script.includes('__subminerYomitanScan(')) { - scanCallScript = script; - } - }, - }, - ); - - await requestYomitanScanTokens( - 'アクア', - deps, - { error: () => undefined }, - { includeNameMatchMetadata: true }, - ); - - assert.match(scanCallScript, /"greedyNameScanEnabled":false/); -}); - -test('requestYomitanScanTokens preserves matched headword word classes', async () => { - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - - const text = (params as { text?: string } | undefined)?.text; - if (text !== 'は') { - return { originalTextLength: 0, dictionaryEntries: [] }; - } - - return { - originalTextLength: 1, - dictionaryEntries: [ - { - headwords: [ - { - term: 'は', - reading: 'は', - wordClasses: ['prt'], - sources: [{ originalText: 'は', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; - }); - - const result = await requestYomitanScanTokens('は', deps, { error: () => undefined }); - - assert.deepEqual((result as Array<{ wordClasses?: string[] }>)[0]?.wordClasses, ['prt']); -}); - -test('requestYomitanScanTokens skips fallback fragments without exact primary source matches', async () => { - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - - { - const text = (params as { text?: string } | undefined)?.text ?? ''; - if (text.startsWith('だが ')) { - return { - originalTextLength: 2, - dictionaryEntries: [ - { - headwords: [ - { - term: 'だが', - reading: 'だが', - sources: [{ originalText: 'だが', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; - } - if (text.startsWith('それでも')) { - return { - originalTextLength: 4, - dictionaryEntries: [ - { - headwords: [ - { - term: 'それでも', - reading: 'それでも', - sources: [{ originalText: 'それでも', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; - } - if (text.startsWith('届かぬ')) { - return { - originalTextLength: 3, - dictionaryEntries: [ - { - headwords: [ - { - term: '届く', - reading: 'とどく', - sources: [{ originalText: '届かぬ', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; - } - if (text.startsWith('高み')) { - return { - originalTextLength: 2, - dictionaryEntries: [ - { - headwords: [ - { - term: '高み', - reading: 'たかみ', - sources: [{ originalText: '高み', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; - } - if (text.startsWith('があった')) { - return { - originalTextLength: 2, - dictionaryEntries: [ - { - headwords: [ - { - term: 'があ', - reading: '', - sources: [{ originalText: 'が', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; - } - if (text.startsWith('あった')) { - return { - originalTextLength: 3, - dictionaryEntries: [ - { - headwords: [ - { - term: 'ある', - reading: 'ある', - sources: [{ originalText: 'あった', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; - } - return { originalTextLength: 0, dictionaryEntries: [] }; - } - }); - - const result = await requestYomitanScanTokens('だが それでも届かぬ高みがあった', deps, { - error: () => undefined, - }); - - assert.deepEqual( - result?.map((token) => ({ - surface: token.surface, - headword: token.headword, - startPos: token.startPos, - endPos: token.endPos, - })), - [ - { - surface: 'だが', - headword: 'だが', - startPos: 0, - endPos: 2, - }, - { - surface: 'それでも', - headword: 'それでも', - startPos: 3, - endPos: 7, - }, - { - surface: '届かぬ', - headword: '届く', - startPos: 7, - endPos: 10, - }, - { - surface: '高み', - headword: '高み', - startPos: 10, - endPos: 12, - }, + { surface: 'だが', headword: 'だが', startPos: 0, endPos: 2 }, + { surface: 'それでも', headword: 'それでも', startPos: 3, endPos: 7 }, + { surface: '届かぬ', headword: '届く', startPos: 7, endPos: 10 }, + { surface: '高み', headword: '高み', startPos: 10, endPos: 12 }, // が has no exact primary source match, so it survives only as an // unparsed filler run (the parseText segmentation used to supply this). - { - surface: 'が', - headword: 'が', - startPos: 12, - endPos: 13, - }, - { - surface: 'あった', - headword: 'ある', - startPos: 13, - endPos: 16, - }, + { surface: 'が', headword: 'が', startPos: 12, endPos: 13 }, + { surface: 'あった', headword: 'ある', startPos: 13, endPos: 16 }, ], ); assert.equal(result?.[4]?.isUnparsedRun, true); }); -function createSingleTermScanHandler(lookups: string[]) { - return (action: string, params: unknown): unknown => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [{ options: { scanning: { length: 40 } } }], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - const text = (params as { text?: string } | undefined)?.text ?? ''; - lookups.push(text); - if (text.startsWith('猫')) { - return { - originalTextLength: 1, - dictionaryEntries: [ - { - headwords: [ - { - term: '猫', - reading: 'ねこ', - sources: [{ originalText: '猫', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; - } - return { originalTextLength: 0, dictionaryEntries: [] }; - }; +function createCatScanDeps(lookups: string[]) { + return createBackendDeps({ + lookups, + termsFind: (text) => (text.startsWith('猫') ? termsFound(1, termEntry('猫', 'ねこ')) : null), + }); } test('requestYomitanScanTokens reuses the cross-line termsFind cache for repeated lookups', async () => { const lookups: string[] = []; - const deps = createScanDeps(createSingleTermScanHandler(lookups)); + const deps = createCatScanDeps(lookups); - const first = await requestYomitanScanTokens('猫', deps, { error: () => undefined }); - const second = await requestYomitanScanTokens('猫', deps, { error: () => undefined }); + const first = await requestYomitanScanTokens('猫', deps, quietLogger); + const second = await requestYomitanScanTokens('猫', deps, quietLogger); assert.equal(first?.length, 1); assert.equal(second?.length, 1); @@ -2219,11 +1011,11 @@ test('requestYomitanScanTokens reuses the cross-line termsFind cache for repeate test('clearYomitanParserCachesForWindow invalidates the cross-line termsFind cache', async () => { const lookups: string[] = []; - const deps = createScanDeps(createSingleTermScanHandler(lookups)); + const deps = createCatScanDeps(lookups); - await requestYomitanScanTokens('猫', deps, { error: () => undefined }); + await requestYomitanScanTokens('猫', deps, quietLogger); clearYomitanParserCachesForWindow(deps.getYomitanParserWindow() as never); - await requestYomitanScanTokens('猫', deps, { error: () => undefined }); + await requestYomitanScanTokens('猫', deps, quietLogger); assert.equal(countTermsFindLookups(lookups, '猫'), 2); }); @@ -2232,92 +1024,39 @@ test('an oversized termsFind result is dropped from the cache instead of being r const lookups: string[] = []; // One entry over the runtime's 20,000 retained-entry budget: the weight is // only known once the lookup resolves, so the cache has to re-check then. - const oversizedEntries = Array.from({ length: 20_001 }, () => ({ - headwords: [ - { - term: '猫', - reading: 'ねこ', - sources: [{ originalText: '猫', isPrimary: true, matchType: 'exact' }], - }, - ], - })); - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [{ options: { scanning: { length: 40 } } }], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - const text = (params as { text?: string } | undefined)?.text ?? ''; - lookups.push(text); - if (text.startsWith('猫')) { - return { originalTextLength: 1, dictionaryEntries: oversizedEntries }; - } - return { originalTextLength: 0, dictionaryEntries: [] }; + const oversizedEntries = Array.from({ length: 20_001 }, () => termEntry('猫', 'ねこ')); + const deps = createBackendDeps({ + lookups, + termsFind: (text) => (text.startsWith('猫') ? termsFound(1, ...oversizedEntries) : null), }); - await requestYomitanScanTokens('猫', deps, { error: () => undefined }); - await requestYomitanScanTokens('猫', deps, { error: () => undefined }); + await requestYomitanScanTokens('猫', deps, quietLogger); + await requestYomitanScanTokens('猫', deps, quietLogger); assert.equal(countTermsFindLookups(lookups, '猫'), 2); }); +// A window that "matches" its whole length but never yields an exact-source +// headword: the worst case for the retry ladder. +function mismatchAcross(length: number) { + return termsFound(length, termEntry('ミスマッチ', 'みすまっち', { originalText: 'ZZZ' })); +} + test('scanner tokens survive a retry-budget escalation whose parseText finds nothing', async () => { const parsedTexts: string[] = []; - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [{ options: { scanning: { length: 40 } } }], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - const text = (params as { text?: string } | undefined)?.text ?? ''; - if (action === 'parseText') { - parsedTexts.push(text); - return []; - } - if (text.startsWith('猫')) { - return { - originalTextLength: 1, - dictionaryEntries: [ - { - headwords: [ - { - term: '猫', - reading: 'ねこ', - sources: [{ originalText: '猫', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; - } + const deps = createBackendDeps({ // The rest of the line burns the blind-retry budget at every position. - return { - originalTextLength: text.length, - dictionaryEntries: [ - { - headwords: [ - { - term: 'ミスマッチ', - reading: 'みすまっち', - sources: [{ originalText: 'ZZZ', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; + termsFind: (text) => + text.startsWith('猫') ? termsFound(1, termEntry('猫', 'ねこ')) : mismatchAcross(text.length), + actions: { + parseText: (params) => { + parsedTexts.push((params as { text: string }).text); + return []; + }, + }, }); - const result = await requestYomitanScanTokens('猫あいうえおかきくけこ', deps, { - error: () => undefined, - }); + const result = await requestYomitanScanTokens('猫あいうえおかきくけこ', deps, quietLogger); // The escalation ran exactly once and found nothing, so the tokens the // scanner did resolve are kept instead of dropping the line to raw text. @@ -2327,9 +1066,9 @@ test('scanner tokens survive a retry-budget escalation whose parseText finds not test('requestYomitanScanTokens skips termsFind lookups at punctuation and whitespace positions', async () => { const lookups: string[] = []; - const deps = createScanDeps(createSingleTermScanHandler(lookups)); + const deps = createCatScanDeps(lookups); - const result = await requestYomitanScanTokens('「猫」…♪', deps, { error: () => undefined }); + const result = await requestYomitanScanTokens('「猫」…♪', deps, quietLogger); assert.equal(result?.length, 1); assert.equal(result?.[0]?.surface, '猫'); @@ -2342,52 +1081,28 @@ test('requestYomitanScanTokens skips termsFind lookups at punctuation and whites test('requestYomitanScanTokens caps blind retries and escalates the line to parseText', async () => { const lookups: string[] = []; const parsedTexts: string[] = []; - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [{ options: { scanning: { length: 40 } } }], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - const text = (params as { text?: string } | undefined)?.text ?? ''; - if (action === 'parseText') { - parsedTexts.push(text); - return [ - { - source: 'scanning-parser', - index: 0, - content: [ - [{ text: 'あいうえお', reading: 'あいうえお', headwords: [[{ term: 'あい' }]] }], - ], - }, - ]; - } - lookups.push(text); - // Every window "matches" its whole length but never yields an - // exact-source headword, the worst case for the retry ladder: each step - // down is a blind guess with nothing shorter reported to aim at. - return { - originalTextLength: text.length, - dictionaryEntries: [ - { - headwords: [ - { - term: 'ミスマッチ', - reading: 'みすまっち', - sources: [{ originalText: 'ZZZ', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; + const deps = createBackendDeps({ + lookups, + // Each step down the ladder is a blind guess with nothing shorter + // reported to aim at. + termsFind: (text) => mismatchAcross(text.length), + actions: { + parseText: (params) => { + parsedTexts.push((params as { text: string }).text); + return [ + { + source: 'scanning-parser', + index: 0, + content: [ + [{ text: 'あいうえお', reading: 'あいうえお', headwords: [[{ term: 'あい' }]] }], + ], + }, + ]; + }, + }, }); - const result = await requestYomitanScanTokens('あいうえおかきくけこ', deps, { - error: () => undefined, - }); + const result = await requestYomitanScanTokens('あいうえおかきくけこ', deps, quietLogger); // Position 0: one initial window lookup plus at most four blind retries, so // the ladder cannot degrade into a lookup per window length. @@ -2400,57 +1115,19 @@ test('requestYomitanScanTokens caps blind retries and escalates the line to pars test('requestYomitanScanTokens keeps shrinking while the backend guides the retry ladder', async () => { const lookups: string[] = []; - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [{ options: { scanning: { length: 40 } } }], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - const text = (params as { text?: string } | undefined)?.text ?? ''; - lookups.push(text); + const deps = createBackendDeps({ + lookups, // Normalization keeps eating one character past the term, so every window // reports a shorter consumed length: informative steps that must not be // spent from the blind-retry budget. The term only surfaces at length 2, // six lookups down the ladder. - if (text.length === 2) { - return { - originalTextLength: 2, - dictionaryEntries: [ - { - headwords: [ - { - term: 'あい', - reading: 'あい', - sources: [{ originalText: 'あい', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; - } - return { - originalTextLength: Math.max(text.length - 1, 0), - dictionaryEntries: [ - { - headwords: [ - { - term: 'ミスマッチ', - reading: 'みすまっち', - sources: [{ originalText: 'ZZZ', isPrimary: true, matchType: 'exact' }], - }, - ], - }, - ], - }; + termsFind: (text) => + text.length === 2 + ? termsFound(2, termEntry('あい', 'あい')) + : mismatchAcross(Math.max(text.length - 1, 0)), }); - const result = await requestYomitanScanTokens('あいうえおかきくけこさしすせ', deps, { - error: () => undefined, - }); + const result = await requestYomitanScanTokens('あいうえおかきくけこさしすせ', deps, quietLogger); assert.equal(result?.[0]?.surface, 'あい'); // Windows of 14, 12, 10, 8, 6, 4 characters, then the match at 2: a ladder @@ -2506,257 +1183,144 @@ test('requestYomitanScanTokens falls back to parseText when the scanner eval fai assert.equal(errors.length, 1); }); -test('getYomitanDictionaryInfo requests dictionary info via backend action', async () => { - let scriptValue = ''; - const deps = createDeps(async (script) => { - scriptValue = script; - return [{ title: 'SubMiner Character Dictionary (AniList 130298)', revision: '1' }]; +test('getYomitanDictionaryInfo normalizes the backend dictionary list', async () => { + const deps = createBackendDeps({ + dictionaryInfo: [ + { title: ` ${CHARACTER_DICTIONARY} `, revision: '1', frequencyMode: 'rank-based' }, + { title: 'JPDB', revision: 3, frequencyMode: 'unknown' }, + { title: ' ' }, + 'not-an-entry', + ], }); - const dictionaries = await getYomitanDictionaryInfo(deps, { error: () => undefined }); - assert.equal(dictionaries.length, 1); - assert.equal(dictionaries[0]?.title, 'SubMiner Character Dictionary (AniList 130298)'); - assert.match(scriptValue, /getDictionaryInfo/); + const dictionaries = await getYomitanDictionaryInfo(deps, quietLogger); + + assert.deepEqual(dictionaries, [ + { title: CHARACTER_DICTIONARY, revision: '1', frequencyMode: 'rank-based' }, + { title: 'JPDB', revision: 3, frequencyMode: undefined }, + ]); }); test('dictionary settings helpers upsert and remove dictionary entries without reordering', async () => { - const scripts: string[] = []; + const title = 'SubMiner Character Dictionary (AniList 1)'; const optionsFull = { profileCurrent: 0, profiles: [ { options: { dictionaries: [ - { - name: 'Jitendex', - alias: 'Jitendex', - enabled: true, - }, - { - name: 'SubMiner Character Dictionary (AniList 1)', - alias: 'SubMiner Character Dictionary (AniList 1)', - enabled: false, - }, + { name: 'Jitendex', alias: 'Jitendex', enabled: true }, + { name: title, alias: title, enabled: false }, ], }, }, ], }; - - const deps = createDeps(async (script) => { - scripts.push(script); - if (script.includes('optionsGetFull')) { - return structuredClone(optionsFull); - } - if (script.includes('setAllSettings')) { - return true; - } - return null; + const saved: Array = []; + const deps = createBackendDeps({ + actions: { + optionsGetFull: () => structuredClone(optionsFull), + setAllSettings: (params) => { + saved.push((params as { value: typeof optionsFull }).value); + return true; + }, + }, }); - const title = 'SubMiner Character Dictionary (AniList 1)'; - const upserted = await upsertYomitanDictionarySettings(title, 'all', deps, { - error: () => undefined, - }); - const removed = await removeYomitanDictionarySettings(title, 'all', 'delete', deps, { - error: () => undefined, - }); + const upserted = await upsertYomitanDictionarySettings(title, 'all', deps, quietLogger); + const removed = await removeYomitanDictionarySettings(title, 'all', 'delete', deps, quietLogger); assert.equal(upserted, true); assert.equal(removed, true); - const setCalls = scripts.filter((script) => script.includes('setAllSettings')).length; - assert.equal(setCalls, 2); - - const upsertScript = scripts.find( - (script) => - script.includes('setAllSettings') && - script.includes('"SubMiner Character Dictionary (AniList 1)"'), + assert.deepEqual( + saved.map((value) => + value.profiles[0]?.options.dictionaries.map(({ name, enabled }) => ({ name, enabled })), + ), + [ + [ + { name: 'Jitendex', enabled: true }, + { name: title, enabled: true }, + ], + [{ name: 'Jitendex', enabled: true }], + ], ); - assert.ok(upsertScript); - const jitendexOffset = upsertScript?.indexOf('"Jitendex"') ?? -1; - const subMinerOffset = upsertScript?.indexOf('"SubMiner Character Dictionary (AniList 1)"') ?? -1; - assert.equal(jitendexOffset >= 0, true); - assert.equal(subMinerOffset >= 0, true); - assert.equal(jitendexOffset < subMinerOffset, true); - assert.match(upsertScript ?? '', /"enabled":true/); }); -test('importYomitanDictionaryFromZip imports via localhost URL instead of embedding archive bytes in script', async () => { +function writeTempDictionaryZip(): string { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-')); const zipPath = path.join(tempDir, 'dict.zip'); fs.writeFileSync(zipPath, Buffer.from('zip-bytes')); + return zipPath; +} - const scripts: string[] = []; +test('importYomitanDictionaryFromZip imports via localhost URL instead of embedding archive bytes in script', async () => { + const zipPath = writeTempDictionaryZip(); const servedArchives: string[] = []; - const settingsWindow = { - isDestroyed: () => false, - destroy: () => undefined, - webContents: { - executeJavaScript: async (script: string) => { - scripts.push(script); - const urlMatch = script.match(/importDictionaryArchiveUrl\(\s*"([^"]+)"/); - if (urlMatch) { - const response = await fetch(JSON.parse(`"${urlMatch[1]}"`) as string); - servedArchives.push(await response.text()); - } - return true; - }, + const base64Imports: string[] = []; + const deps = createSettingsAutomationDeps({ + importDictionaryArchiveUrl: async (url: string) => { + servedArchives.push(await (await fetch(url)).text()); }, - }; - - const deps = createDeps(async () => true, { - createYomitanExtensionWindow: async (pageName: string) => { - assert.equal(pageName, 'settings.html'); - return settingsWindow; + importDictionaryArchiveBase64: async (archive: string) => { + base64Imports.push(archive); }, }); - const imported = await importYomitanDictionaryFromZip(zipPath, deps, { - error: () => undefined, - }); + const imported = await importYomitanDictionaryFromZip(zipPath, deps, quietLogger); assert.equal(imported, true); - assert.equal( - scripts.some((script) => script.includes('__subminerYomitanSettingsAutomation')), - true, - ); - assert.equal( - scripts.some((script) => script.includes('importDictionaryArchiveUrl')), - true, - ); assert.deepEqual(servedArchives, ['zip-bytes']); - assert.equal( - scripts.some((script) => script.includes('emlwLWJ5dGVz')), - false, - ); - assert.equal( - scripts.some((script) => script.includes('subminerImportDictionary')), - false, - ); + assert.deepEqual(base64Imports, []); }); test('importYomitanDictionaryFromZip falls back to base64 import for older Yomitan bridge', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-')); - const zipPath = path.join(tempDir, 'dict.zip'); - fs.writeFileSync(zipPath, Buffer.from('zip-bytes')); - - const scripts: string[] = []; - const settingsWindow = { - isDestroyed: () => false, - destroy: () => undefined, - webContents: { - executeJavaScript: async (script: string) => { - scripts.push(script); - if ( - script.includes( - 'typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl', - ) - ) { - return false; - } - return true; - }, - }, - }; - - const deps = createDeps(async () => true, { - createYomitanExtensionWindow: async (pageName: string) => { - assert.equal(pageName, 'settings.html'); - return settingsWindow; + const zipPath = writeTempDictionaryZip(); + const base64Imports: Array<{ archive: string; fileName: string }> = []; + const deps = createSettingsAutomationDeps({ + importDictionaryArchiveBase64: async (archive: string, fileName: string) => { + base64Imports.push({ archive: Buffer.from(archive, 'base64').toString(), fileName }); }, }); - const imported = await importYomitanDictionaryFromZip(zipPath, deps, { - error: () => undefined, - }); + const imported = await importYomitanDictionaryFromZip(zipPath, deps, quietLogger); assert.equal(imported, true); - assert.equal( - scripts.some((script) => script.includes('importDictionaryArchiveBase64')), - true, - ); - assert.equal( - scripts.some((script) => script.includes('importDictionaryArchiveUrl(')), - false, - ); - assert.equal( - scripts.some((script) => script.includes('emlwLWJ5dGVz')), - true, - ); + assert.deepEqual(base64Imports, [{ archive: 'zip-bytes', fileName: 'dict.zip' }]); }); test('importYomitanDictionaryFromZip returns false when served archive cannot be read', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-')); - const zipPath = path.join(tempDir, 'dict.zip'); - fs.writeFileSync(zipPath, Buffer.from('zip-bytes')); - - const settingsWindow = { - isDestroyed: () => false, - destroy: () => undefined, - webContents: { - executeJavaScript: async (script: string) => { - const urlMatch = script.match(/importDictionaryArchiveUrl\(\s*"([^"]+)"/); - if (!urlMatch) return true; - fs.unlinkSync(zipPath); - const response = await fetch(JSON.parse(`"${urlMatch[1]}"`) as string); - return response.ok; - }, - }, - }; - - const deps = createDeps(async () => true, { - createYomitanExtensionWindow: async (pageName: string) => { - assert.equal(pageName, 'settings.html'); - return settingsWindow; + const zipPath = writeTempDictionaryZip(); + const deps = createSettingsAutomationDeps({ + importDictionaryArchiveUrl: async (url: string) => { + fs.unlinkSync(zipPath); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`archive fetch failed: ${response.status}`); + } }, }); - const imported = await importYomitanDictionaryFromZip(zipPath, deps, { - error: () => undefined, - }); + const imported = await importYomitanDictionaryFromZip(zipPath, deps, quietLogger); assert.equal(imported, false); }); -test('deleteYomitanDictionaryByTitle uses settings automation bridge instead of custom backend action', async () => { - const scripts: string[] = []; - const settingsWindow = { - isDestroyed: () => false, - destroy: () => undefined, - webContents: { - executeJavaScript: async (script: string) => { - scripts.push(script); - return true; - }, - }, - }; - - const deps = createDeps(async () => true, { - createYomitanExtensionWindow: async (pageName: string) => { - assert.equal(pageName, 'settings.html'); - return settingsWindow; +test('deleteYomitanDictionaryByTitle deletes through the settings automation bridge', async () => { + const deletedTitles: string[] = []; + const deps = createSettingsAutomationDeps({ + deleteDictionary: async (title: string) => { + deletedTitles.push(title); }, }); const deleted = await deleteYomitanDictionaryByTitle( - 'SubMiner Character Dictionary (AniList 130298)', + ` ${CHARACTER_DICTIONARY} `, deps, - { error: () => undefined }, + quietLogger, ); assert.equal(deleted, true); - assert.equal( - scripts.some((script) => script.includes('__subminerYomitanSettingsAutomation')), - true, - ); - assert.equal( - scripts.some((script) => script.includes('deleteDictionary')), - true, - ); - assert.equal( - scripts.some((script) => script.includes('subminerDeleteDictionary')), - false, - ); + assert.deepEqual(deletedTitles, [CHARACTER_DICTIONARY]); }); test('addYomitanNoteViaSearch returns note and duplicate ids from the bridge payload', async () => { @@ -2765,9 +1329,7 @@ test('addYomitanNoteViaSearch returns note and duplicate ids from the bridge pay duplicateNoteIds: [18, 7, 18], })); - const result = await addYomitanNoteViaSearch('食べる', deps, { - error: () => undefined, - }); + const result = await addYomitanNoteViaSearch('食べる', deps, quietLogger); assert.deepEqual(result, { noteId: 42, @@ -2778,9 +1340,7 @@ test('addYomitanNoteViaSearch returns note and duplicate ids from the bridge pay test('addYomitanNoteViaSearch rejects invalid numeric note ids from the bridge shortcut', async () => { const deps = createDeps(async () => NaN); - const result = await addYomitanNoteViaSearch('食べる', deps, { - error: () => undefined, - }); + const result = await addYomitanNoteViaSearch('食べる', deps, quietLogger); assert.deepEqual(result, { noteId: null, @@ -2794,109 +1354,10 @@ test('addYomitanNoteViaSearch sanitizes invalid payload note ids while keeping v duplicateNoteIds: [18, 0, 7.5, 7], })); - const result = await addYomitanNoteViaSearch('食べる', deps, { - error: () => undefined, - }); + const result = await addYomitanNoteViaSearch('食べる', deps, quietLogger); assert.deepEqual(result, { noteId: null, duplicateNoteIds: [18, 7], }); }); - -test('requestYomitanScanTokens still finds an emphatically elongated name a longer generic match would swallow', async () => { - // Yomitan collapses emphatic sequences, so ミナァァト resolves to the ミナト - // entry. The generic word とミナ starts earlier and would swallow the name - // unless the pre-pass reserves it, so this only passes when the candidate - // prefilter still treats the elongated spelling as a possible name start. - const deps = createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [ - { name: 'JMdict', enabled: true, id: 0 }, - { name: 'SubMiner Character Dictionary (AniList 1)', enabled: true, id: 1 }, - ], - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - const text = (params as { text?: string } | undefined)?.text ?? ''; - if (text.startsWith('とミナ')) { - return { - originalTextLength: 3, - dictionaryEntries: [ - { - headwords: [ - { - term: 'トミナ', - reading: 'とみな', - sources: [{ originalText: 'とミナ', isPrimary: true, matchType: 'exact' }], - }, - ], - definitions: [{ dictionary: 'JMdict' }], - }, - ], - }; - } - if (text.startsWith('ミナァァト')) { - return { - originalTextLength: 5, - dictionaryEntries: [ - { - headwords: [ - { - term: 'ミナト', - reading: 'みなと', - sources: [{ originalText: 'ミナァァト', isPrimary: true, matchType: 'exact' }], - }, - ], - definitions: [{ dictionary: 'SubMiner Character Dictionary (AniList 1)' }], - }, - ], - }; - } - if (text.startsWith('と')) { - return { - originalTextLength: 1, - dictionaryEntries: [ - { - headwords: [ - { - term: 'と', - reading: 'と', - sources: [{ originalText: 'と', isPrimary: true, matchType: 'exact' }], - }, - ], - definitions: [{ dictionary: 'JMdict' }], - }, - ], - }; - } - return { originalTextLength: 0, dictionaryEntries: [] }; - }); - - const result = await requestYomitanScanTokens( - 'とミナァァト', - deps, - { error: () => undefined }, - { - includeNameMatchMetadata: true, - currentCharacterDictionaryMediaId: 1, - nameCandidates: { key: 'media-1', forms: ['ミナト', 'みなと'] }, - }, - ); - - const nameToken = result?.find((token) => token.isNameMatch === true); - assert.ok(nameToken, 'expected the elongated name to be reserved by the pre-pass'); - assert.equal(nameToken?.headword, 'ミナト'); - assert.equal(nameToken?.startPos, 1); -}); diff --git a/src/core/services/tokenizer/yomitan-scan-runtime.test.ts b/src/core/services/tokenizer/yomitan-scan-runtime.test.ts index 26713464..c6351964 100644 --- a/src/core/services/tokenizer/yomitan-scan-runtime.test.ts +++ b/src/core/services/tokenizer/yomitan-scan-runtime.test.ts @@ -258,20 +258,6 @@ test('halfwidth voiced kana compose into the reading instead of leaving a stray ]); }); -test('requestYomitanScanTokens falls back to the exhaustive name scan without candidates', async () => { - const withoutLookups: string[] = []; - const withoutCandidates = await requestYomitanScanTokens( - NAME_SCAN_LINE, - createNameScanDeps(withoutLookups), - { error: () => undefined }, - { includeNameMatchMetadata: true, currentCharacterDictionaryMediaId: 1, nameCandidates: null }, - ); - - assert.equal(withoutCandidates?.[0]?.isNameMatch, true); - // No candidate list means every Japanese position is probed, as before. - assert.ok(countTermsFindLookups(withoutLookups, '校に') > 0); -}); - test('requestYomitanScanTokens reinstalls name candidates when the media changes', async () => { const lookups: string[] = []; const deps = createNameScanDeps(lookups); @@ -302,3 +288,35 @@ test('requestYomitanScanTokens reinstalls name candidates when the media changes assert.equal(correctMedia?.[0]?.surface, 'ミナト'); assert.equal(correctMedia?.[0]?.isNameMatch, true); }); + +test('requestYomitanScanTokens skips the greedy name pre-pass when name matching is off', async () => { + const cases = [ + { + name: 'name-match metadata disabled', + dictionaries: undefined, + includeNameMatchMetadata: false, + expectedNameMatch: false, + }, + { + name: 'no enabled character dictionary', + dictionaries: ['JMdict'], + includeNameMatchMetadata: true, + // The main walk still classifies the match; only the pre-pass is gated. + expectedNameMatch: true, + }, + ]; + for (const c of cases) { + const lookups: string[] = []; + const result = await requestYomitanScanTokens( + NAME_SCAN_LINE, + createNameScanDeps(lookups, NAME_SCAN_WORDS, c.dictionaries), + { error: () => undefined }, + { includeNameMatchMetadata: c.includeNameMatchMetadata }, + ); + + assert.equal(result?.[0]?.surface, 'ミナト', c.name); + assert.equal(result?.[0]?.isNameMatch, c.expectedNameMatch, c.name); + // Mid-token positions are only probed by the pre-pass. + assert.equal(countTermsFindLookups(lookups, '校に'), 0, c.name); + } +}); diff --git a/src/core/services/tokenizer/yomitan-scan-test-harness.ts b/src/core/services/tokenizer/yomitan-scan-test-harness.ts index 8c9efeaa..9c001828 100644 --- a/src/core/services/tokenizer/yomitan-scan-test-harness.ts +++ b/src/core/services/tokenizer/yomitan-scan-test-harness.ts @@ -39,7 +39,11 @@ function createYomitanScriptSandbox(handler: (action: string, params: unknown) = callback: (response: { result?: unknown; error?: { message?: string } }) => void, ) => { try { - callback({ result: handler(payload.action ?? '', payload.params) }); + // Messages cross a process boundary in Electron; clone params into + // the host realm so handlers can compare them structurally. + const params = + payload.params === undefined ? undefined : structuredClone(payload.params); + callback({ result: handler(payload.action ?? '', params) }); } catch (error) { callback({ error: { message: (error as Error).message } }); } @@ -98,8 +102,128 @@ export function countTermsFindLookups(lookups: string[], prefix: string): number return lookups.filter((lookupText) => lookupText.startsWith(prefix)).length; } +export interface TermsFindResult { + originalTextLength: number; + dictionaryEntries: unknown[]; +} + +// One termsFind dictionary entry whose headword exactly matches `originalText` +// (the scanned surface, defaulting to the term). `dictionary` attaches a +// definition from that dictionary, e.g. a SubMiner character dictionary. +export function termEntry( + term: string, + reading: string, + options: { originalText?: string; isPrimary?: boolean; dictionary?: string } = {}, +) { + return { + headwords: [ + { + term, + reading, + sources: [ + { + originalText: options.originalText ?? term, + isPrimary: options.isPrimary ?? true, + matchType: 'exact', + }, + ], + }, + ], + ...(options.dictionary ? { definitions: [{ dictionary: options.dictionary }] } : {}), + }; +} + +export function termsFound( + originalTextLength: number, + ...dictionaryEntries: unknown[] +): TermsFindResult { + return { originalTextLength, dictionaryEntries }; +} + +// Scan deps backed by a fake Yomitan backend. The active profile enables +// `dictionaries` (in priority order; omitted means no dictionary list), +// getDictionaryInfo returns `dictionaryInfo`, and termsFind answers through +// `termsFind` (null/undefined means no match) while recording each text in +// `lookups`. `actions` handles any other backend action and overrides the +// defaults above; anything else throws. Every action name lands in `actionLog`. +export function createBackendDeps( + options: { + dictionaries?: string[]; + dictionaryInfo?: unknown[]; + termsFind?: (text: string) => TermsFindResult | null | undefined; + lookups?: string[]; + actions?: Record unknown>; + actionLog?: string[]; + onScript?: (script: string) => void; + } = {}, +) { + return createScanDeps( + (action, params) => { + options.actionLog?.push(action); + const override = options.actions?.[action]; + if (override) { + return override(params); + } + if (action === 'optionsGetFull') { + return { + profileCurrent: 0, + profiles: [ + { + options: { + scanning: { length: 40 }, + ...(options.dictionaries + ? { + dictionaries: options.dictionaries.map((name, id) => ({ + name, + enabled: true, + id, + })), + } + : {}), + }, + }, + ], + }; + } + if (action === 'getDictionaryInfo') { + return options.dictionaryInfo ?? []; + } + if (action === 'termsFind' && options.termsFind) { + const text = (params as { text?: string } | undefined)?.text ?? ''; + options.lookups?.push(text); + return options.termsFind(text) ?? termsFound(0); + } + throw new Error(`unexpected action: ${action}`); + }, + { onScript: options.onScript }, + ); +} + +// Deps whose hidden settings.html window runs injected scripts against a fake +// `__subminerYomitanSettingsAutomation` bridge, like the real settings page. +export function createSettingsAutomationDeps(automation: Record) { + const context = vm.createContext({ + __subminerYomitanSettingsAutomation: { ready: true, ...automation }, + setTimeout, + }); + const settingsWindow = { + isDestroyed: () => false, + destroy: () => undefined, + webContents: { + executeJavaScript: async (script: string) => await vm.runInContext(script, context), + }, + }; + return createDeps(async () => true, { + createYomitanExtensionWindow: async (pageName) => + pageName === 'settings.html' ? settingsWindow : null, + }); +} + +export const SUBMINER_TEST_CHARACTER_DICTIONARY = 'SubMiner Character Dictionary (AniList 1)'; + // Backend stub for the greedy name pre-pass: one character name (ミナト) in a -// line of ordinary words, with the SubMiner character dictionary enabled. +// line of ordinary words, with the SubMiner character dictionary enabled +// unless `dictionaries` says otherwise. export const NAME_SCAN_WORDS: Array<[string, string, string, boolean]> = [ ['ミナト', 'ミナト', 'みなと', true], ['は', 'は', 'は', false], @@ -112,57 +236,24 @@ export const NAME_SCAN_WORDS: Array<[string, string, string, boolean]> = [ export function createNameScanDeps( lookups: string[], words: Array<[string, string, string, boolean]> = NAME_SCAN_WORDS, + dictionaries: string[] = ['JMdict', SUBMINER_TEST_CHARACTER_DICTIONARY], ) { - return createScanDeps((action, params) => { - if (action === 'optionsGetFull') { - return { - profileCurrent: 0, - profiles: [ - { - options: { - scanning: { length: 40 }, - dictionaries: [ - { name: 'JMdict', enabled: true, id: 0 }, - { - name: 'SubMiner Character Dictionary (AniList 1)', - enabled: true, - id: 1, - }, - ], - }, - }, - ], - }; - } - if (action === 'getDictionaryInfo') { - return []; - } - if (action !== 'termsFind') { - throw new Error(`unexpected action: ${action}`); - } - const text = (params as { text?: string } | undefined)?.text ?? ''; - lookups.push(text); - for (const [surface, term, reading, isName] of words) { - if (text.startsWith(surface)) { - return { - originalTextLength: surface.length, - dictionaryEntries: [ - { - headwords: [ - { - term, - reading, - sources: [{ originalText: surface, isPrimary: true, matchType: 'exact' }], - }, - ], - definitions: [ - { dictionary: isName ? 'SubMiner Character Dictionary (AniList 1)' : 'JMdict' }, - ], - }, - ], - }; + return createBackendDeps({ + dictionaries, + lookups, + termsFind: (text) => { + const match = words.find(([surface]) => text.startsWith(surface)); + if (!match) { + return null; } - } - return { originalTextLength: 0, dictionaryEntries: [] }; + const [surface, term, reading, isName] = match; + return termsFound( + surface.length, + termEntry(term, reading, { + originalText: surface, + dictionary: isName ? SUBMINER_TEST_CHARACTER_DICTIONARY : 'JMdict', + }), + ); + }, }); } diff --git a/src/core/services/yomitan-extension-runtime-state.test.ts b/src/core/services/yomitan-extension-runtime-state.test.ts index 886c49e8..e6660104 100644 --- a/src/core/services/yomitan-extension-runtime-state.test.ts +++ b/src/core/services/yomitan-extension-runtime-state.test.ts @@ -2,44 +2,49 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { clearYomitanParserRuntimeState } from './yomitan-extension-runtime-state'; -test('clearYomitanParserRuntimeState destroys parser window and clears parser promises', () => { - const calls: string[] = []; - const parserWindow = { - isDestroyed: () => false, - destroy: () => { - calls.push('destroy'); - }, - }; +const cases = [ + { name: 'a live parser window', window: { destroyed: false }, expectDestroyed: true }, + { + name: 'an already destroyed parser window', + window: { destroyed: true }, + expectDestroyed: false, + }, + { name: 'no parser window', window: null, expectDestroyed: false }, +]; - clearYomitanParserRuntimeState({ - getYomitanParserWindow: () => parserWindow as never, - setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`), - setYomitanParserReadyPromise: (promise) => - calls.push(`ready:${promise === null ? 'null' : 'set'}`), - setYomitanParserInitPromise: (promise) => - calls.push(`init:${promise === null ? 'null' : 'set'}`), +for (const c of cases) { + test(`clearYomitanParserRuntimeState clears parser state with ${c.name}`, () => { + let destroyCalls = 0; + const parserWindow = c.window && { + isDestroyed: () => c.window.destroyed, + destroy: () => { + destroyCalls += 1; + }, + }; + const state: { + window: unknown; + ready: Promise | null; + init: Promise | null; + } = { + window: parserWindow, + ready: Promise.resolve(), + init: Promise.resolve(true), + }; + + clearYomitanParserRuntimeState({ + getYomitanParserWindow: () => parserWindow, + setYomitanParserWindow: (window) => { + state.window = window; + }, + setYomitanParserReadyPromise: (promise) => { + state.ready = promise; + }, + setYomitanParserInitPromise: (promise) => { + state.init = promise; + }, + }); + + assert.equal(destroyCalls, c.expectDestroyed ? 1 : 0); + assert.deepEqual(state, { window: null, ready: null, init: null }); }); - - assert.deepEqual(calls, ['destroy', 'window:null', 'ready:null', 'init:null']); -}); - -test('clearYomitanParserRuntimeState skips destroy when parser window is already gone', () => { - const calls: string[] = []; - const parserWindow = { - isDestroyed: () => true, - destroy: () => { - calls.push('destroy'); - }, - }; - - clearYomitanParserRuntimeState({ - getYomitanParserWindow: () => parserWindow as never, - setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`), - setYomitanParserReadyPromise: (promise) => - calls.push(`ready:${promise === null ? 'null' : 'set'}`), - setYomitanParserInitPromise: (promise) => - calls.push(`init:${promise === null ? 'null' : 'set'}`), - }); - - assert.deepEqual(calls, ['window:null', 'ready:null', 'init:null']); -}); +} diff --git a/src/core/services/yomitan-settings.test.ts b/src/core/services/yomitan-settings.test.ts index 2f047ae5..b560ca93 100644 --- a/src/core/services/yomitan-settings.test.ts +++ b/src/core/services/yomitan-settings.test.ts @@ -2,9 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - buildYomitanSettingsCloseButtonScript, buildYomitanSettingsWindowMenuTemplate, - buildYomitanSettingsUrl, configureYomitanSettingsWindowChrome, destroyYomitanSettingsWindow, installYomitanSettingsCloseButton, @@ -49,15 +47,6 @@ test('yomitan settings close menu skips destroyed windows', () => { assert.deepEqual(calls, []); }); -test('yomitan settings close button script installs an idempotent in-page close control', () => { - const script = buildYomitanSettingsCloseButtonScript(); - - assert.match(script, /subminer-yomitan-settings-close/); - assert.match(script, /aria-label', 'Close Yomitan settings'/); - assert.match(script, /window\.close\(\)/); - assert.match(script, /getElementById\(buttonId\)/); -}); - test('yomitan settings close button only installs for Hyprland sessions', () => { assert.equal( shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }), @@ -96,13 +85,6 @@ test('yomitan settings close button injection skips non-Hyprland windows', () => assert.deepEqual(calls, []); }); -test('yomitan settings URL disables the embedded popup preview', () => { - assert.equal( - buildYomitanSettingsUrl('abc123'), - 'chrome-extension://abc123/settings.html?popup-preview=false', - ); -}); - test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existing window', () => { const calls: string[] = []; @@ -134,18 +116,6 @@ test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existi ]); }); -test('destroyYomitanSettingsWindow destroys a live settings window before app quit', () => { - const calls: string[] = []; - - const destroyed = destroyYomitanSettingsWindow({ - isDestroyed: () => false, - destroy: () => calls.push('destroy'), - } as never); - - assert.equal(destroyed, true); - assert.deepEqual(calls, ['destroy']); -}); - test('destroyYomitanSettingsWindow skips missing or already destroyed settings windows', () => { assert.equal(destroyYomitanSettingsWindow(null), false); assert.equal( diff --git a/src/core/services/youtube/ytdlp-single-video-args.test.ts b/src/core/services/youtube/ytdlp-single-video-args.test.ts index b6bb10a9..616648e4 100644 --- a/src/core/services/youtube/ytdlp-single-video-args.test.ts +++ b/src/core/services/youtube/ytdlp-single-video-args.test.ts @@ -4,7 +4,6 @@ import { buildYoutubeMetadataProbeArgs } from './metadata-probe'; import { buildYoutubePlaybackResolveArgs } from './playback-resolve'; import { buildDownloadArgs } from './track-download'; import { buildYoutubeTrackProbeArgs } from './track-probe'; -import { YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command'; // Regression guard for issue #179: a `list=`/`index=` URL made yt-dlp enumerate the whole // playlist (e.g. Watch Later) and blow past our 15s timeouts on every single-video call. @@ -26,10 +25,6 @@ const cases: Array<{ name: string; args: string[] }> = [ }, ]; -test('YTDLP_SINGLE_VIDEO_ARG is the yt-dlp flag that disables playlist expansion', () => { - assert.equal(YTDLP_SINGLE_VIDEO_ARG, '--no-playlist'); -}); - for (const { name, args } of cases) { test(`${name} passes --no-playlist for playlist-scoped URLs`, () => { assert.ok(args.includes('--no-playlist'), `${name} args: ${args.join(' ')}`); diff --git a/src/dead-architecture-cleanup.test.ts b/src/dead-architecture-cleanup.test.ts deleted file mode 100644 index 01bca85b..00000000 --- a/src/dead-architecture-cleanup.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; - -const DEAD_MODULE_PATHS = [ - 'src/translators/index.ts', - 'src/subsync/engines.ts', - 'src/subtitle/pipeline.ts', - 'src/subtitle/stages/merge.ts', - 'src/subtitle/stages/normalize.ts', - 'src/subtitle/stages/normalize.test.ts', - 'src/subtitle/stages/tokenize.ts', - 'src/tokenizers/index.ts', - 'src/token-mergers/index.ts', -] as const; - -const FORBIDDEN_IMPORT_PATTERNS = [ - /from ['"]\.\.?\/tokenizers['"]/, - /from ['"]\.\.?\/token-mergers['"]/, - /from ['"]\.\.?\/subtitle\/pipeline['"]/, - /from ['"]\.\.?\/subsync\/engines['"]/, - /from ['"]\.\.?\/translators['"]/, -] as const; - -function readWorkspaceFile(relativePath: string): string { - return fs.readFileSync(path.join(process.cwd(), relativePath), 'utf8'); -} - -function collectSourceFiles(rootDir: string): string[] { - const absoluteRoot = path.join(process.cwd(), rootDir); - const out: string[] = []; - - const visit = (currentDir: string) => { - for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { - const fullPath = path.join(currentDir, entry.name); - if (entry.isDirectory()) { - visit(fullPath); - continue; - } - if (!fullPath.endsWith('.ts') && !fullPath.endsWith('.tsx')) { - continue; - } - out.push(path.relative(process.cwd(), fullPath).replaceAll('\\', '/')); - } - }; - - visit(absoluteRoot); - out.sort(); - return out; -} - -test('dead registry and pipeline modules stay removed from the repository', () => { - for (const relativePath of DEAD_MODULE_PATHS) { - assert.equal( - fs.existsSync(path.join(process.cwd(), relativePath)), - false, - `${relativePath} should stay deleted`, - ); - } -}); - -test('live source tree no longer imports dead registry and pipeline modules', () => { - for (const relativePath of collectSourceFiles('src')) { - const source = readWorkspaceFile(relativePath); - for (const pattern of FORBIDDEN_IMPORT_PATTERNS) { - assert.doesNotMatch(source, pattern, `${relativePath} should not import ${pattern.source}`); - } - } -}); diff --git a/src/generate-config-example.test.ts b/src/generate-config-example.test.ts index c7cfc8b9..a44eef19 100644 --- a/src/generate-config-example.test.ts +++ b/src/generate-config-example.test.ts @@ -3,7 +3,6 @@ import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import { - generateConfigExampleTemplate, resolveConfigExampleOutputPaths, writeConfigExampleArtifacts, } from './generate-config-example'; @@ -81,9 +80,3 @@ test('writeConfigExampleArtifacts creates parent directories for resolved output fs.rmSync(workspace, { recursive: true, force: true }); } }); - -test('generateConfigExampleTemplate uses the canonical example socket path', () => { - const template = generateConfigExampleTemplate(); - - assert.match(template, /"socketPath": "\/tmp\/subminer-socket"/); -}); diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index bfde0ba1..0134432c 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -7,13 +8,10 @@ import { DEFAULT_CONFIG } from './config/definitions'; import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config'; import { configureEarlyAppPaths, - exitBackgroundBootstrap, normalizeLaunchMpvExtraArgs, normalizeStartupArgv, normalizeLaunchMpvTargets, resolveStatsDaemonCommandAction, - sanitizeHelpEnv, - sanitizeLaunchMpvEnv, sanitizeStartupEnv, sanitizeBackgroundEnv, shouldDetachBackgroundLaunch, @@ -24,36 +22,26 @@ import { shouldForwardStartupArgvViaAppControl, applyBackgroundBootstrapCommandLineSwitches, applyEarlyLinuxCommandLineSwitches, - resolveAppControlHandoffTimeoutMs, resolveLinuxPasswordStoreValue, spawnDetachedApp, } from './main-entry-runtime'; -test('app-control handoffs allow for macOS application activation latency', () => { - assert.equal(resolveAppControlHandoffTimeoutMs('darwin'), 3000); - assert.equal(resolveAppControlHandoffTimeoutMs('linux'), 500); - assert.equal(resolveAppControlHandoffTimeoutMs('win32'), 500); -}); - -test('detached app launch policy stays in the startup runtime utilities', () => { - const entrySource = fs.readFileSync(path.join(process.cwd(), 'src/main-entry.ts'), 'utf8'); - const runtimeSource = fs.readFileSync( - path.join(process.cwd(), 'src/main-entry-runtime.ts'), - 'utf8', - ); - - assert.equal(typeof spawnDetachedApp, 'function'); - assert.doesNotMatch(entrySource, /function spawnDetachedApp/); - assert.match( - runtimeSource, - /child\.once\('error', \(error\) => \{\s*console\.error\([^;]*error\);\s*\}\);\s*child\.unref\(\)/, - ); -}); - -test('background bootstrap exits through Electron so Chromium children shut down', () => { - const exitCodes: number[] = []; - exitBackgroundBootstrap({ exit: (code) => exitCodes.push(code) }); - assert.deepEqual(exitCodes, [0]); +test('spawnDetachedApp survives a failed spawn of the detached app', () => { + const child = Object.assign(new EventEmitter(), { unref: () => {} }); + const fakeSpawn = (() => child) as unknown as Parameters[2]; + const originalConsoleError = console.error; + const logged: unknown[][] = []; + console.error = (...args: unknown[]) => { + logged.push(args); + }; + try { + spawnDetachedApp(['--start'], {}, fakeSpawn); + // An unhandled 'error' event would throw here and crash the entry process. + child.emit('error', new Error('spawn ENOENT')); + } finally { + console.error = originalConsoleError; + } + assert.equal(logged.length, 1); }); test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => { @@ -545,22 +533,6 @@ test('sanitizeStartupEnv suppresses warnings and lsfg layer', () => { assert.equal('VK_INSTANCE_LAYERS' in env, false); }); -test('sanitizeHelpEnv suppresses warnings and lsfg layer', () => { - const env = sanitizeHelpEnv({ - VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar', - }); - assert.equal(env.NODE_NO_WARNINGS, '1'); - assert.equal('VK_INSTANCE_LAYERS' in env, false); -}); - -test('sanitizeLaunchMpvEnv suppresses warnings and lsfg layer', () => { - const env = sanitizeLaunchMpvEnv({ - VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar', - }); - assert.equal(env.NODE_NO_WARNINGS, '1'); - assert.equal('VK_INSTANCE_LAYERS' in env, false); -}); - test('sanitizeBackgroundEnv marks background child and keeps warning suppression', () => { const env = sanitizeBackgroundEnv({ VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar', diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index 29c9c166..e2cc5fcc 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -296,15 +296,20 @@ export function exitBackgroundBootstrap(app: BackgroundBootstrapAppLike): void { app.exit(0); } -export function spawnDetachedApp(childArgs: string[], env: NodeJS.ProcessEnv): void { +// spawnProcess is a test seam; production callers use node's spawn. +export function spawnDetachedApp( + childArgs: string[], + env: NodeJS.ProcessEnv, + spawnProcess: typeof spawn = spawn, +): void { const keepalive = resolveAppImageMountKeepaliveInvocation(env); const child = keepalive - ? spawn(keepalive.command, [...keepalive.args, ...childArgs], { + ? spawnProcess(keepalive.command, [...keepalive.args, ...childArgs], { detached: true, stdio: 'ignore', env, }) - : spawn(process.execPath, childArgs, { + : spawnProcess(process.execPath, childArgs, { detached: true, stdio: 'ignore', env, diff --git a/src/main/appimage-mount-keepalive.test.ts b/src/main/appimage-mount-keepalive.test.ts index 44d1c192..7971fcf8 100644 --- a/src/main/appimage-mount-keepalive.test.ts +++ b/src/main/appimage-mount-keepalive.test.ts @@ -5,8 +5,6 @@ import os from 'node:os'; import path from 'node:path'; import { execFile } from 'node:child_process'; import { - APPIMAGE_MOUNT_KEEPALIVE_LABEL, - APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, resolveAppImageMountKeepaliveInvocation, resolveMountFsType, } from './appimage-mount-keepalive'; @@ -79,32 +77,17 @@ test('resolveMountFsType picks the longest containing mount point', () => { assert.equal(resolveMountFsType('/usr/bin/sh', ''), null); }); -test('resolveAppImageMountKeepaliveInvocation builds sh invocation with AppImage path', () => { - const invocation = resolveOnFuse({ APPIMAGE: '/opt/SubMiner.AppImage' }, 'linux'); - assert.ok(invocation); - assert.equal(invocation.command, '/bin/sh'); - assert.deepEqual(invocation.args, [ - '-c', - APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, - APPIMAGE_MOUNT_KEEPALIVE_LABEL, - '/opt/SubMiner.AppImage', - ]); -}); - function runKeepaliveScript( appImagePath: string, extraArgs: string[] = [], ): Promise<{ status: number }> { + // Run the exact invocation the app would spawn, so the argv is exercised too. + const invocation = resolveOnFuse({ APPIMAGE: appImagePath }, 'linux'); + assert.ok(invocation); return new Promise((resolve, reject) => { execFile( - '/bin/sh', - [ - '-c', - APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, - APPIMAGE_MOUNT_KEEPALIVE_LABEL, - appImagePath, - ...extraArgs, - ], + invocation.command, + [...invocation.args, ...extraArgs], { timeout: 30_000 }, (error) => { if (error && typeof error.code !== 'number') { diff --git a/src/main/boot/services.test.ts b/src/main/boot/services.test.ts index 4f6ba5ad..0525b515 100644 --- a/src/main/boot/services.test.ts +++ b/src/main/boot/services.test.ts @@ -1,109 +1,66 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { ConfigStartupParseError } from '../../config'; import { createMainBootServices } from './services'; -test('createMainBootServices builds boot-phase service bundle', () => { - type MockAppLifecycleApp = { - requestSingleInstanceLock: () => boolean; - quit: () => void; - exit: (code?: number) => void; - on: (event: string, listener: (...args: unknown[]) => void) => MockAppLifecycleApp; - whenReady: () => Promise; - }; - - const calls: string[] = []; - let setPathValue: string | null = null; - const appOnCalls: string[] = []; - let secondInstanceHandlerRegistered = false; - - const services = createMainBootServices< - { configDir: string }, - { targetPath: string }, - { targetPath: string }, - { targetPath: string }, - { kind: string; payloadMode: 'plain' | 'annotated' }, - { scope: string; warn: () => void; info: () => void; error: () => void }, - { registry: boolean }, - { getMainWindow: () => null; getModalWindow: () => null }, - { - inputState: boolean; - getModalInputExclusive: () => boolean; - handleModalInputStateChange: (isActive: boolean) => void; - }, - { measurementStore: boolean }, - { modalRuntime: boolean }, - { mpvSocketPath: string; texthookerPort: number }, - MockAppLifecycleApp - >({ - platform: 'linux', +// Minimal params whose factories echo their inputs, so tests can assert what each service received. +function baseParams() { + return { + platform: 'linux' as NodeJS.Platform, argv: ['node', 'main.ts'], appDataDir: undefined, xdgConfigHome: undefined, homeDir: '/home/tester', defaultMpvLogFile: '/tmp/default.log', - envMpvLog: ' /tmp/custom.log ', + envMpvLog: undefined as string | undefined, defaultTexthookerPort: 5174, getDefaultSocketPath: () => '/tmp/subminer.sock', resolveConfigDir: () => '/tmp/subminer-config', - existsSync: () => false, - mkdirSync: (targetPath) => { - calls.push(`mkdir:${targetPath}`); - }, - joinPath: (...parts) => parts.join('/'), + existsSync: (_targetPath: string) => false, + mkdirSync: (_targetPath: string) => {}, + joinPath: (...parts: string[]) => parts.join('/'), app: { - setPath: (_name, value) => { - setPathValue = value; - }, + setPath: (_name: string, _value: string) => {}, quit: () => {}, - exit: (code?: number) => { - calls.push(`exit:${code ?? 0}`); - }, - on: (event: string) => { - appOnCalls.push(event); - return {}; - }, + exit: (_code?: number) => {}, + on: (_event: string, _listener: unknown) => ({}), whenReady: async () => {}, }, shouldBypassSingleInstanceLock: () => false, requestSingleInstanceLockEarly: () => true, - registerSecondInstanceHandlerEarly: () => { - secondInstanceHandlerRegistered = true; - }, - onConfigStartupParseError: () => { - throw new Error('unexpected parse failure'); - }, - createConfigService: (configDir) => ({ configDir }), - createAnilistTokenStore: (targetPath) => ({ targetPath }), - createJellyfinTokenStore: (targetPath) => ({ targetPath }), - createAnilistUpdateQueue: (targetPath) => ({ targetPath }), - createSubtitleWebSocket: (payloadMode) => ({ kind: 'ws', payloadMode }), - createLogger: (scope) => - ({ - scope, - warn: () => {}, - info: () => {}, - error: () => {}, - }) as const, - createMainRuntimeRegistry: () => ({ registry: true }), - createOverlayManager: () => ({ - getMainWindow: () => null, - getModalWindow: () => null, - }), + registerSecondInstanceHandlerEarly: (_listener: unknown) => {}, + onConfigStartupParseError: (_error: ConfigStartupParseError) => {}, + createConfigService: (configDir: string) => ({ configDir }), + createAnilistTokenStore: (targetPath: string) => ({ targetPath }), + createJellyfinTokenStore: (targetPath: string) => ({ targetPath }), + createAnilistUpdateQueue: (targetPath: string) => ({ targetPath }), + createSubtitleWebSocket: (payloadMode: 'plain' | 'annotated') => ({ payloadMode }), + createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }), + createMainRuntimeRegistry: () => ({}), + createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }), createOverlayModalInputState: () => ({ - inputState: true, getModalInputExclusive: () => false, - handleModalInputStateChange: () => {}, + handleModalInputStateChange: (_isActive: boolean) => {}, }), - createOverlayContentMeasurementStore: () => ({ measurementStore: true }), - getSyncOverlayShortcutsForModal: () => () => {}, + createOverlayContentMeasurementStore: () => ({}), + getSyncOverlayShortcutsForModal: () => (_isActive: boolean) => {}, getSyncOverlayVisibilityForModal: () => () => {}, - createOverlayModalRuntime: () => ({ modalRuntime: true }), - createAppState: (input) => ({ ...input }), - }); + createOverlayModalRuntime: () => ({}), + createAppState: (input: { mpvSocketPath: string; texthookerPort: number }) => input, + }; +} + +function createServices( + overrides: Partial> & { configDir?: string }, +) { + return createMainBootServices({ ...baseParams(), ...overrides }); +} + +test('createMainBootServices derives data paths from the resolved config dir', () => { + const services = createServices({}); assert.equal(services.configDir, '/tmp/subminer-config'); assert.equal(services.userDataPath, '/tmp/subminer-config'); - assert.equal(services.defaultMpvLogPath, '/tmp/custom.log'); assert.equal(services.defaultImmersionDbPath, '/tmp/subminer-config/immersion.sqlite'); assert.deepEqual(services.configService, { configDir: '/tmp/subminer-config' }); assert.deepEqual(services.anilistTokenStore, { @@ -115,79 +72,115 @@ test('createMainBootServices builds boot-phase service bundle', () => { assert.deepEqual(services.anilistUpdateQueue, { targetPath: '/tmp/subminer-config/anilist-retry-queue.json', }); - assert.deepEqual(services.subtitleWsService, { kind: 'ws', payloadMode: 'plain' }); - assert.deepEqual(services.annotationSubtitleWsService, { - kind: 'ws', - payloadMode: 'annotated', - }); + assert.deepEqual(services.subtitleWsService, { payloadMode: 'plain' }); + assert.deepEqual(services.annotationSubtitleWsService, { payloadMode: 'annotated' }); assert.deepEqual(services.appState, { mpvSocketPath: '/tmp/subminer.sock', texthookerPort: 5174, }); - assert.equal( - services.appLifecycleApp.on('ready', () => {}), - services.appLifecycleApp, - ); - assert.equal( - services.appLifecycleApp.on('second-instance', () => {}), - services.appLifecycleApp, - ); - services.appLifecycleApp.exit(7); - assert.deepEqual(appOnCalls, ['ready']); - assert.equal(secondInstanceHandlerRegistered, true); - assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']); - assert.equal(setPathValue, '/tmp/subminer-config'); +}); + +test('createMainBootServices creates the user data dir only when missing and registers it', () => { + for (const exists of [false, true]) { + const created: string[] = []; + const setPaths: Array<[string, string]> = []; + createServices({ + existsSync: () => exists, + mkdirSync: (targetPath) => { + created.push(targetPath); + }, + app: { + ...baseParams().app, + setPath: (name, value) => { + setPaths.push([name, value]); + }, + }, + }); + + assert.deepEqual(created, exists ? [] : ['/tmp/subminer-config']); + assert.deepEqual(setPaths, [['userData', '/tmp/subminer-config']]); + } +}); + +test('createMainBootServices uses a trimmed MPV log env path and falls back when blank', () => { + const cases = [ + { envMpvLog: ' /tmp/custom.log ', expected: '/tmp/custom.log' }, + { envMpvLog: ' ', expected: '/tmp/default.log' }, + { envMpvLog: undefined, expected: '/tmp/default.log' }, + ]; + for (const { envMpvLog, expected } of cases) { + assert.equal(createServices({ envMpvLog }).defaultMpvLogPath, expected); + } }); test('createMainBootServices honors the profile selected by the early entrypoint', () => { - const services = createMainBootServices({ - platform: 'linux', - argv: ['electron', '.', '--dev'], + const services = createServices({ configDir: '/tmp/SubMiner-dev', - appDataDir: undefined, - xdgConfigHome: undefined, - homeDir: '/home/tester', - defaultMpvLogFile: '/tmp/default.log', - envMpvLog: undefined, - defaultTexthookerPort: 5174, - getDefaultSocketPath: () => '/tmp/subminer.sock', resolveConfigDir: () => { throw new Error('early profile should be authoritative'); }, - existsSync: () => false, - mkdirSync: () => {}, - joinPath: (...parts) => parts.join('/'), - app: { - setPath: () => {}, - quit: () => {}, - exit: () => {}, - on: () => ({}), - whenReady: async () => {}, - }, - shouldBypassSingleInstanceLock: () => false, - requestSingleInstanceLockEarly: () => true, - registerSecondInstanceHandlerEarly: () => {}, - onConfigStartupParseError: () => {}, - createConfigService: (configDir) => ({ configDir }), - createAnilistTokenStore: (targetPath) => ({ targetPath }), - createJellyfinTokenStore: (targetPath) => ({ targetPath }), - createAnilistUpdateQueue: (targetPath) => ({ targetPath }), - createSubtitleWebSocket: (payloadMode) => ({ payloadMode }), - createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }), - createMainRuntimeRegistry: () => ({}), - createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }), - createOverlayModalInputState: () => ({ - getModalInputExclusive: () => false, - handleModalInputStateChange: () => {}, - }), - createOverlayContentMeasurementStore: () => ({}), - getSyncOverlayShortcutsForModal: () => () => {}, - getSyncOverlayVisibilityForModal: () => () => {}, - createOverlayModalRuntime: () => ({}), - createAppState: (input) => input, }); assert.equal(services.configDir, '/tmp/SubMiner-dev'); assert.equal(services.userDataPath, '/tmp/SubMiner-dev'); assert.deepEqual(services.configService, { configDir: '/tmp/SubMiner-dev' }); }); + +test('createMainBootServices routes second-instance listeners to the early handler', () => { + const appEvents: string[] = []; + const earlyListeners: unknown[] = []; + const services = createServices({ + app: { + ...baseParams().app, + on: (event) => { + appEvents.push(event); + return {}; + }, + }, + registerSecondInstanceHandlerEarly: (listener) => { + earlyListeners.push(listener); + }, + }); + + const secondInstanceListener = () => {}; + services.appLifecycleApp.on('ready', () => {}); + services.appLifecycleApp.on('second-instance', secondInstanceListener); + + assert.deepEqual(appEvents, ['ready']); + assert.deepEqual(earlyListeners, [secondInstanceListener]); +}); + +test('createMainBootServices skips the single-instance lock when bypassed', () => { + for (const bypass of [false, true]) { + let lockRequests = 0; + const services = createServices({ + shouldBypassSingleInstanceLock: () => bypass, + requestSingleInstanceLockEarly: () => { + lockRequests += 1; + return false; + }, + }); + + assert.equal(services.appLifecycleApp.requestSingleInstanceLock(), bypass); + assert.equal(lockRequests, bypass ? 0 : 1); + } +}); + +test('createMainBootServices reports config parse errors and rethrows', () => { + const parseError = new ConfigStartupParseError('/tmp/config.jsonc', 'unexpected token'); + const reported: ConfigStartupParseError[] = []; + + assert.throws( + () => + createServices({ + createConfigService: () => { + throw parseError; + }, + onConfigStartupParseError: (error) => { + reported.push(error); + }, + }), + (error) => error === parseError, + ); + assert.deepEqual(reported, [parseError]); +}); diff --git a/src/main/character-dictionary-runtime.test.ts b/src/main/character-dictionary-runtime.test.ts index 13f01446..8b4fee66 100644 --- a/src/main/character-dictionary-runtime.test.ts +++ b/src/main/character-dictionary-runtime.test.ts @@ -1,2764 +1,555 @@ import assert from 'node:assert/strict'; import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; import test from 'node:test'; -import { createCharacterDictionaryRuntimeService } from './character-dictionary-runtime'; -const GRAPHQL_URL = 'https://graphql.anilist.co'; -const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50; -const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50; -const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50; -const PNG_1X1 = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=', - 'base64', -); +import { buildSnapshotFromCharacters } from './character-dictionary-runtime/build'; +import { getSnapshotPath } from './character-dictionary-runtime/cache'; +import { + EMINENCE_MEDIA_ID, + EMINENCE_TITLE, + createRuntime, + findSection, + getGlossaryChildren, + installFakeAniList, + readStoredZipEntry, + readTermBank, + type FakeAniListCharacterEdge, +} from './character-dictionary-runtime/runtime-test-harness'; +import type { + CharacterDictionarySnapshot, + CharacterRecord, +} from './character-dictionary-runtime/types'; -function makeTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-')); +const ALEXIA_IMAGE_URL = 'https://cdn.example.com/character-123.png'; +const VA_IMAGE_URL = 'https://cdn.example.com/va-456.jpg'; + +function eminenceMedia(characters: FakeAniListCharacterEdge[]) { + return { id: EMINENCE_MEDIA_ID, episodes: 20, title: EMINENCE_TITLE, characters }; } -function readStoredZipEntry(zipPath: string, entryName: string): Buffer { - const archive = fs.readFileSync(zipPath); - let offset = 0; - - while (offset + 4 <= archive.length) { - const signature = archive.readUInt32LE(offset); - if ( - signature === CENTRAL_DIRECTORY_SIGNATURE || - signature === END_OF_CENTRAL_DIRECTORY_SIGNATURE - ) { - break; - } - if (signature !== LOCAL_FILE_HEADER_SIGNATURE) { - throw new Error(`Unexpected ZIP signature 0x${signature.toString(16)} at offset ${offset}`); - } - - const compressionMethod = archive.readUInt16LE(offset + 8); - assert.equal(compressionMethod, 0, 'expected stored ZIP entry'); - const compressedSize = archive.readUInt32LE(offset + 18); - const fileNameLength = archive.readUInt16LE(offset + 26); - const extraFieldLength = archive.readUInt16LE(offset + 28); - const fileNameStart = offset + 30; - const fileNameEnd = fileNameStart + fileNameLength; - const fileName = archive.subarray(fileNameStart, fileNameEnd).toString('utf8'); - const dataStart = fileNameEnd + extraFieldLength; - const dataEnd = dataStart + compressedSize; - - if (fileName === entryName) { - return archive.subarray(dataStart, dataEnd); - } - - offset = dataEnd; - } - - throw new Error(`ZIP entry not found: ${entryName}`); -} - -test('generateForCurrentMedia emits structured-content glossary so image stays with text', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - variables?: Record; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 130298, - episodes: 20, - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - }, - ], - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'SUPPORTING', - node: { - id: 123, - description: - '__Race:__ Human\nAlexia Midgar is the second princess of the Kingdom of Midgar.', - image: { - large: 'https://example.com/alexia.png', - medium: null, - }, - name: { - full: 'Alexia Midgar', - native: 'アレクシア・ミドガル', - }, - }, - }, - ], - }, - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - } - - if (url === 'https://example.com/alexia.png') { - return new Response(PNG_1X1, { - status: 200, - headers: { 'content-type': 'image/png' }, - }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - now: () => 1_700_000_000_000, - }); - - const result = await runtime.generateForCurrentMedia(); - const termBank = JSON.parse( - readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), - ) as Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - const alexia = termBank.find(([term]) => term === 'アレクシア'); - - assert.ok(alexia, 'expected compact native-name variant for character'); - const glossary = alexia[5]; - assert.equal(glossary.length, 1); - - const entry = glossary[0] as { - type: string; - content: { tag: string; content: Array> }; - }; - assert.equal(entry.type, 'structured-content'); - - const wrapper = entry.content; - assert.equal(wrapper.tag, 'div'); - const children = wrapper.content; - - const nameDiv = children[0] as { tag: string; content: string }; - assert.equal(nameDiv.tag, 'div'); - assert.equal(nameDiv.content, 'アレクシア・ミドガル'); - - assert.equal( - children.some((child) => (child as { content?: unknown }).content === 'Alexia Midgar'), - false, - ); - - const imageWrap = children.find((child) => { - const content = (child as { content?: unknown }).content; - return ( - content && - typeof content === 'object' && - !Array.isArray(content) && - (content as { path?: unknown }).path === 'img/m130298-c123.png' - ); - }) as { tag: string; content: Record } | undefined; - assert.ok(imageWrap); - assert.equal(imageWrap.tag, 'div'); - const image = imageWrap.content as Record; - assert.equal(image.tag, 'img'); - assert.equal(image.path, 'img/m130298-c123.png'); - assert.equal(image.sizeUnits, 'em'); - - const sourceDiv = children.find((child) => { - const content = (child as { content?: unknown }).content; - return typeof content === 'string' && content.includes('The Eminence in Shadow'); - }) as { tag: string; content: string } | undefined; - assert.ok(sourceDiv); - assert.equal(sourceDiv.tag, 'div'); - assert.ok(sourceDiv.content.includes('The Eminence in Shadow')); - - const roleBadgeDiv = children.find((child) => { - const content = (child as { content?: unknown }).content; - return ( - content && - typeof content === 'object' && - !Array.isArray(content) && - (content as { content?: unknown }).content === 'Main Character' - ); - }) as { tag: string; content: Record } | undefined; - assert.ok(roleBadgeDiv); - assert.equal(roleBadgeDiv.tag, 'div'); - const badge = roleBadgeDiv.content as { tag: string; content: string }; - assert.equal(badge.tag, 'span'); - assert.equal(badge.content, 'Main Character'); - - const descSection = children.find( - (c) => - (c as { tag?: string }).tag === 'details' && - Array.isArray((c as { content?: unknown[] }).content) && - (c as { content: Array<{ content?: string }> }).content[0]?.content === 'Description', - ) as { tag: string; open?: boolean; content: Array> } | undefined; - assert.ok(descSection, 'expected Description collapsible section'); - assert.equal(descSection.open, false); - const descBody = descSection.content[1] as { content: string }; - assert.ok( - descBody.content.includes('Alexia Midgar is the second princess of the Kingdom of Midgar.'), - ); - - const infoSection = children.find( - (c) => - (c as { tag?: string }).tag === 'details' && - Array.isArray((c as { content?: unknown[] }).content) && - (c as { content: Array<{ content?: string }> }).content[0]?.content === - 'Character Information', - ) as { tag: string; open?: boolean; content: Array> } | undefined; - assert.ok( - infoSection, - 'expected Character Information collapsible section with parsed __Race:__ field', - ); - assert.equal(infoSection.open, false); - - const topLevelImageGlossaryEntry = glossary.find( - (item) => - typeof item === 'object' && item !== null && (item as { type?: string }).type === 'image', - ); - assert.equal(topLevelImageGlossaryEntry, undefined); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('generateForCurrentMedia applies configured open states to character dictionary sections', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 130298, - episodes: 20, - title: { - romaji: 'The Eminence in Shadow', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - }, - ], - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - romaji: 'The Eminence in Shadow', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'SUPPORTING', - voiceActors: [ - { - id: 456, - name: { - full: 'Rina Hidaka', - native: '日高里菜', - }, - image: { - medium: 'https://cdn.example.com/va-456.jpg', - }, - }, - ], - node: { - id: 123, - description: - 'Alexia Midgar is the second princess of the Kingdom of Midgar.\n\n__Race:__ Human', - image: { - large: 'https://cdn.example.com/character-123.png', - medium: 'https://cdn.example.com/character-123-small.png', - }, - name: { - full: 'Alexia Midgar', - native: 'アレクシア・ミドガル', - }, - }, - }, - ], - }, - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - } - - if (url === 'https://cdn.example.com/character-123.png') { - return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), { - status: 200, - headers: { 'content-type': 'image/png' }, - }); - } - - if (url === 'https://cdn.example.com/va-456.jpg') { - return new Response(Buffer.from([0xff, 0xd8, 0xff, 0xd9]), { - status: 200, - headers: { 'content-type': 'image/jpeg' }, - }); - } - - throw new Error(`Unexpected fetch: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - getCollapsibleSectionOpenState: (section) => - section === 'description' || section === 'voicedBy', - now: () => 1_700_000_000_000, - }); - - const result = await runtime.generateForCurrentMedia(); - const termBank = JSON.parse( - readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), - ) as Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - const alexia = termBank.find(([term]) => term === 'アレクシア'); - assert.ok(alexia); - - const glossary = alexia[5]; - const entry = glossary[0] as { - type: string; - content: { tag: string; content: Array> }; - }; - const children = entry.content.content; - - const getSection = (title: string) => - children.find( - (c) => - (c as { tag?: string }).tag === 'details' && - Array.isArray((c as { content?: unknown[] }).content) && - (c as { content: Array<{ content?: string }> }).content[0]?.content === title, - ) as { open?: boolean } | undefined; - - assert.equal(getSection('Description')?.open, true); - assert.equal(getSection('Character Information')?.open, false); - assert.equal(getSection('Voiced by')?.open, true); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('generateForCurrentMedia reapplies collapsible open states when using cached snapshot data', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 130298, - episodes: 20, - title: { - romaji: 'The Eminence in Shadow', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - }, - ], - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - english: 'The Eminence in Shadow', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'SUPPORTING', - voiceActors: [ - { - id: 456, - name: { - full: 'Rina Hidaka', - native: '日高里菜', - }, - image: { - medium: 'https://cdn.example.com/va-456.jpg', - }, - }, - ], - node: { - id: 123, - description: - 'Alexia Midgar is the second princess of the Kingdom of Midgar.\n\n__Race:__ Human', - image: { - large: 'https://cdn.example.com/character-123.png', - medium: null, - }, - name: { - full: 'Alexia Midgar', - native: 'アレクシア・ミドガル', - }, - }, - }, - ], - }, - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - } - - if (url === 'https://cdn.example.com/character-123.png') { - return new Response(PNG_1X1, { - status: 200, - headers: { 'content-type': 'image/png' }, - }); - } - - if (url === 'https://cdn.example.com/va-456.jpg') { - return new Response(PNG_1X1, { - status: 200, - headers: { 'content-type': 'image/png' }, - }); - } - - throw new Error(`Unexpected fetch: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtimeOpen = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - getCollapsibleSectionOpenState: () => true, - now: () => 1_700_000_000_000, - }); - await runtimeOpen.generateForCurrentMedia(); - - const runtimeClosed = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - getCollapsibleSectionOpenState: () => false, - now: () => 1_700_000_000_500, - }); - const result = await runtimeClosed.generateForCurrentMedia(); - - const termBank = JSON.parse( - readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), - ) as Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - const alexia = termBank.find(([term]) => term === 'アレクシア'); - assert.ok(alexia); - - const children = ( - alexia[5][0] as { - content: { content: Array> }; - } - ).content.content; - const sections = children.filter( - (item) => (item as { tag?: string }).tag === 'details', - ) as Array<{ - open?: boolean; - }>; - assert.ok(sections.length >= 2); - assert.ok(sections.every((section) => section.open === false)); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('generateForCurrentMedia adds kana aliases for romanized names when native name is kanji', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 20594, - episodes: 10, - title: { - romaji: 'Kono Subarashii Sekai ni Shukufuku wo!', - english: 'KONOSUBA -God’s blessing on this wonderful world!', - native: 'この素晴らしい世界に祝福を!', - }, - }, - ], - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - romaji: 'Kono Subarashii Sekai ni Shukufuku wo!', - english: 'KONOSUBA -God’s blessing on this wonderful world!', - native: 'この素晴らしい世界に祝福を!', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { - id: 1, - description: 'The protagonist.', - image: null, - name: { - full: 'Satou Kazuma', - native: '佐藤和真', - }, - }, - }, - ], - }, - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/konosuba-s02e05.mkv', - getCurrentMediaTitle: () => 'Konosuba S02E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'Konosuba', - season: null, - episode: 5, - source: 'fallback', - }), - now: () => 1_700_000_000_000, - }); - - const result = await runtime.generateForCurrentMedia(); - const termBank = JSON.parse( - readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), - ) as Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - - const kazuma = termBank.find(([term]) => term === 'カズマ'); - assert.ok(kazuma, 'expected katakana alias for romanized name'); - assert.equal(kazuma[1], 'かずま'); - - const fullName = termBank.find(([term]) => term === 'サトウカズマ'); - assert.ok(fullName, 'expected compact full-name katakana alias for romanized name'); - assert.equal(fullName[1], 'さとうかずま'); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('generateForCurrentMedia indexes kanji family and given names using AniList first and last hints', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 37450, - episodes: 13, - title: { - romaji: 'Seishun Buta Yarou wa Bunny Girl Senpai no Yume wo Minai', - english: 'Rascal Does Not Dream of Bunny Girl Senpai', - native: '青春ブタ野郎はバニーガール先輩の夢を見ない', - }, - }, - ], - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - romaji: 'Seishun Buta Yarou wa Bunny Girl Senpai no Yume wo Minai', - english: 'Rascal Does Not Dream of Bunny Girl Senpai', - native: '青春ブタ野郎はバニーガール先輩の夢を見ない', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'SUPPORTING', - node: { - id: 77, - description: 'Classmate.', - image: null, - name: { - first: 'Yuuma', - full: 'Yuuma Kunimi', - last: 'Kunimi', - native: '国見佑真', - }, - }, - }, - ], - }, - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/bunny-girl-senpai-s01e01.mkv', - getCurrentMediaTitle: () => 'Rascal Does Not Dream of Bunny Girl Senpai - S01E01', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'Rascal Does Not Dream of Bunny Girl Senpai', - season: null, - episode: 1, - source: 'fallback', - }), - now: () => 1_700_000_000_000, - }); - - const result = await runtime.generateForCurrentMedia(); - const termBank = JSON.parse( - readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), - ) as Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - - const familyName = termBank.find(([term]) => term === '国見'); - assert.ok(familyName, 'expected kanji family-name term from AniList hints'); - assert.equal(familyName[1], 'くにみ'); - - const givenName = termBank.find(([term]) => term === '佑真'); - assert.ok(givenName, 'expected kanji given-name term from AniList hints'); - assert.equal(givenName[1], 'ゆうま'); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('generateForCurrentMedia indexes AniList alternative character names for alias lookups', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 130298, - episodes: 20, - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - }, - ], - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { - id: 321, - description: 'Leader of Shadow Garden.', - image: null, - name: { - full: 'Cid Kagenou', - native: 'シド・カゲノー', - alternative: ['Shadow', 'Minoru Kagenou'], - }, - }, - }, - ], - }, - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - now: () => 1_700_000_000_000, - }); - - const result = await runtime.generateForCurrentMedia(); - const termBank = JSON.parse( - readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), - ) as Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - - const shadowKana = termBank.find(([term]) => term === 'シャドウ'); - assert.ok(shadowKana, 'expected katakana alias from AniList alternative name'); - assert.equal(shadowKana[1], 'しゃどう'); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('generateForCurrentMedia skips AniList characters without a native name when other valid characters exist', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 130298, - episodes: 20, - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - }, - ], - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - english: 'The Eminence in Shadow', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { - id: 111, - description: 'Valid native name.', - image: null, - name: { - full: 'Alpha', - native: 'アルファ', - first: 'Alpha', - last: null, - }, - }, - }, - { - role: 'SUPPORTING', - node: { - id: 222, - description: 'Missing native name.', - image: null, - name: { - full: 'John Smith', - native: '', - first: 'John', - last: 'Smith', - }, - }, - }, - ], - }, - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - now: () => 1_700_000_000_000, - }); - - const result = await runtime.generateForCurrentMedia(); - const termBank = JSON.parse( - readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), - ) as Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - - assert.ok(termBank.find(([term]) => term === 'アルファ')); - assert.equal( - termBank.some(([term]) => term === 'John Smith'), - false, - ); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('generateForCurrentMedia uses AniList first and last name hints to build kanji readings', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 20594, - episodes: 10, - title: { - romaji: 'Kono Subarashii Sekai ni Shukufuku wo!', - english: 'KONOSUBA -God’s blessing on this wonderful world!', - native: 'この素晴らしい世界に祝福を!', - }, - }, - ], - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - romaji: 'Kono Subarashii Sekai ni Shukufuku wo!', - english: 'KONOSUBA -God’s blessing on this wonderful world!', - native: 'この素晴らしい世界に祝福を!', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { - id: 1, - description: 'The protagonist.', - image: null, - name: { - full: 'Satou Kazuma', - native: '佐藤和真', - first: '和真', - last: '佐藤', - }, - }, - }, - ], - }, - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/konosuba-s02e05.mkv', - getCurrentMediaTitle: () => 'Konosuba S02E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'Konosuba', - season: null, - episode: 5, - source: 'fallback', - }), - now: () => 1_700_000_000_000, - }); - - const result = await runtime.generateForCurrentMedia(); - const termBank = JSON.parse( - readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), - ) as Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - - assert.equal(termBank.find(([term]) => term === '佐藤和真')?.[1], 'さとうかずま'); - assert.equal(termBank.find(([term]) => term === '佐藤')?.[1], 'さとう'); - assert.equal(termBank.find(([term]) => term === '和真')?.[1], 'かずま'); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('generateForCurrentMedia includes AniList gender age birthday and blood type in character information', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 130298, - episodes: 20, - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - }, - ], - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - english: 'The Eminence in Shadow', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'SUPPORTING', - node: { - id: 123, - description: 'Second princess of Midgar.', - image: null, - gender: 'Female', - age: '15', - dateOfBirth: { - month: 9, - day: 1, - }, - bloodType: 'A', - name: { - full: 'Alexia Midgar', - native: 'アレクシア・ミドガル', - first: 'Alexia', - last: 'Midgar', - }, - }, - }, - ], - }, - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - now: () => 1_700_000_000_000, - }); - - const result = await runtime.generateForCurrentMedia(); - const termBank = JSON.parse( - readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), - ) as Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - const alexia = termBank.find(([term]) => term === 'アレクシア'); - assert.ok(alexia); - - const children = ( - alexia[5][0] as { - content: { content: Array> }; - } - ).content.content; - const infoSection = children.find( - (c) => - (c as { tag?: string }).tag === 'details' && - Array.isArray((c as { content?: unknown[] }).content) && - (c as { content: Array<{ content?: string }> }).content[0]?.content === - 'Character Information', - ) as { content: Array> } | undefined; - assert.ok(infoSection); - const body = infoSection.content[1] as { content: Array<{ content?: string }> }; - const flattened = JSON.stringify(body.content); - - assert.match(flattened, /Female|♂ Male|♀ Female/); - assert.match(flattened, /15 years/); - assert.match(flattened, /Blood Type A/); - assert.match(flattened, /Birthday: September 1/); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('generateForCurrentMedia preserves duplicate surface forms across different characters', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 130298, - episodes: 20, - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - }, - ], - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - english: 'The Eminence in Shadow', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { - id: 111, - description: 'First Alpha.', - image: null, - name: { - full: 'Alpha One', - native: 'アルファ', - first: 'Alpha', - last: 'One', - }, - }, - }, - { - role: 'MAIN', - node: { - id: 222, - description: 'Second Alpha.', - image: null, - name: { - full: 'Alpha Two', - native: 'アルファ', - first: 'Alpha', - last: 'Two', - }, - }, - }, - ], - }, - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - now: () => 1_700_000_000_000, - }); - - const result = await runtime.generateForCurrentMedia(); - const termBank = JSON.parse( - readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), - ) as Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - - const alphaEntries = termBank.filter(([term]) => term === 'アルファ'); - assert.equal(alphaEntries.length, 2); - const glossaries = alphaEntries.map((entry) => - JSON.stringify( - ( - entry[5][0] as { - content: { content: Array> }; - } - ).content.content, - ), - ); - assert.ok(glossaries.some((value) => value.includes('First Alpha.'))); - assert.ok(glossaries.some((value) => value.includes('Second Alpha.'))); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('getOrCreateCurrentSnapshot reuses cached media resolution without AniList requests', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - let searchQueryCount = 0; - let characterQueryCount = 0; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - searchQueryCount += 1; - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 130298, - episodes: 20, - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - }, - ], - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - characterQueryCount += 1; - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { - id: 321, - description: 'Alpha is the second-in-command of Shadow Garden.', - image: { - large: 'https://example.com/alpha.png', - medium: null, - }, - name: { - full: 'Alpha', - native: 'アルファ', - }, - }, - }, - ], - }, - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - } - - if (url === 'https://example.com/alpha.png') { - return new Response(PNG_1X1, { - status: 200, - headers: { 'content-type': 'image/png' }, - }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - now: () => 1_700_000_000_100, - }); - - const first = await runtime.getOrCreateCurrentSnapshot(); - assert.equal(searchQueryCount, 1); - assert.equal(characterQueryCount, 1); - - fs.rmSync(path.join(userDataPath, 'character-dictionaries', 'anilist-resolution-cache.json'), { - force: true, - }); - - const second = await runtime.getOrCreateCurrentSnapshot(); - - assert.equal(first.fromCache, false); - assert.equal(second.fromCache, true); - assert.equal(searchQueryCount, 1); - assert.equal(characterQueryCount, 1); - assert.equal( - fs.existsSync(path.join(userDataPath, 'character-dictionaries', 'cache.json')), - false, - ); - - const snapshotPath = path.join( - userDataPath, - 'character-dictionaries', - 'snapshots', - 'anilist-130298.json', - ); - const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8')) as { - mediaId: number; - entryCount: number; - termEntries: Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - }; - assert.equal(snapshot.mediaId, 130298); - assert.equal(snapshot.entryCount > 0, true); - const alpha = snapshot.termEntries.find(([term]) => term === 'アルファ'); - assert.ok(alpha); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('getOrCreateCurrentSnapshot rebuilds snapshots written with an older format version', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - let searchQueryCount = 0; - let characterQueryCount = 0; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - searchQueryCount += 1; - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 130298, - episodes: 20, - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - }, - ], - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - characterQueryCount += 1; - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { - id: 321, - description: 'Alpha is the second-in-command of Shadow Garden.', - image: null, - name: { - full: 'Alpha', - native: 'アルファ', - }, - }, - }, - ], - }, - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const snapshotsDir = path.join(userDataPath, 'character-dictionaries', 'snapshots'); - fs.mkdirSync(snapshotsDir, { recursive: true }); - fs.writeFileSync( - path.join(snapshotsDir, 'anilist-130298.json'), - JSON.stringify({ - formatVersion: 9, - mediaId: 130298, - mediaTitle: 'The Eminence in Shadow', - entryCount: 1, - updatedAt: 1_700_000_000_000, - termEntries: [['stale', '', 'name side', '', 1, ['stale'], 0, '']], - images: [], - }), - 'utf8', - ); - - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - now: () => 1_700_000_000_100, - }); - - const result = await runtime.getOrCreateCurrentSnapshot(); - - assert.equal(result.fromCache, false); - assert.equal(searchQueryCount, 1); - assert.equal(characterQueryCount, 1); - - const snapshotPath = path.join(snapshotsDir, 'anilist-130298.json'); - const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8')) as { - formatVersion: number; - termEntries: Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - }; - assert.equal(snapshot.formatVersion > 9, true); - assert.equal( - snapshot.termEntries.some(([term]) => term === 'アルファ'), - true, - ); - assert.equal( - snapshot.termEntries.some(([term]) => term === 'stale'), - false, - ); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('generateForCurrentMedia logs progress while resolving and rebuilding snapshot data', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - const logs: string[] = []; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 130298, - episodes: 20, - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - }, - ], - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { - id: 321, - description: 'Alpha is the second-in-command of Shadow Garden.', - image: { - large: 'https://example.com/alpha.png', - medium: null, - }, - name: { - full: 'Alpha', - native: 'アルファ', - }, - }, - }, - ], - }, - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - } - - if (url === 'https://example.com/alpha.png') { - return new Response(PNG_1X1, { - status: 200, - headers: { 'content-type': 'image/png' }, - }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - now: () => 1_700_000_000_100, - sleep: async () => undefined, - logInfo: (message) => { - logs.push(message); - }, - }); - - await runtime.generateForCurrentMedia(); - - assert.deepEqual(logs, [ - '[dictionary] resolving current anime for character dictionary generation', - '[dictionary] current anime guess: The Eminence in Shadow (episode 5)', - '[dictionary] AniList match: The Eminence in Shadow -> AniList 130298', - '[dictionary] snapshot miss for AniList 130298, fetching characters', - '[dictionary] downloaded AniList character page 1 for AniList 130298 (1 characters)', - '[dictionary] downloading 1 images for AniList 130298', - '[dictionary] stored snapshot for AniList 130298: 16 terms', - '[dictionary] building ZIP for AniList 130298', - '[dictionary] generated AniList 130298: 16 terms -> ' + - path.join(userDataPath, 'character-dictionaries', 'anilist-130298.zip'), - ]); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('generateForCurrentMedia downloads shared voice actor images once per AniList person id', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - const fetchedImageUrls: string[] = []; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 130298, - episodes: 20, - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - }, - ], - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - voiceActors: [ - { - id: 9001, - name: { - full: 'Kana Hanazawa', - native: '花澤香菜', - }, - image: { - large: null, - medium: 'https://example.com/kana.png', - }, - }, - ], - node: { - id: 321, - description: 'Alpha is the second-in-command of Shadow Garden.', - image: { - large: 'https://example.com/alpha.png', - medium: null, - }, - name: { - full: 'Alpha', - native: 'アルファ', - }, - }, - }, - { - role: 'SUPPORTING', - voiceActors: [ - { - id: 9001, - name: { - full: 'Kana Hanazawa', - native: '花澤香菜', - }, - image: { - large: null, - medium: 'https://example.com/kana.png', - }, - }, - ], - node: { - id: 654, - description: 'Beta documents Shadow Garden operations.', - image: { - large: 'https://example.com/beta.png', - medium: null, - }, - name: { - full: 'Beta', - native: 'ベータ', - }, - }, - }, - ], - }, - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - } - - if ( - url === 'https://example.com/alpha.png' || - url === 'https://example.com/beta.png' || - url === 'https://example.com/kana.png' - ) { - fetchedImageUrls.push(url); - return new Response(PNG_1X1, { - status: 200, - headers: { 'content-type': 'image/png' }, - }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - now: () => 1_700_000_000_100, - sleep: async () => undefined, - }); - - await runtime.generateForCurrentMedia(); - - assert.deepEqual(fetchedImageUrls, [ - 'https://example.com/alpha.png', - 'https://example.com/kana.png', - 'https://example.com/beta.png', - ]); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('buildMergedDictionary combines stored snapshots into one stable dictionary', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - const current = { title: 'The Eminence in Shadow', episode: 5 }; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - variables?: Record; - }; - - if (body.query?.includes('Page(perPage: 10)')) { - if (body.variables?.search === 'The Eminence in Shadow') { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 130298, - episodes: 20, - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - }, - ], - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 21, - episodes: 28, - title: { - romaji: 'Sousou no Frieren', - english: 'Frieren: Beyond Journey’s End', - native: '葬送のフリーレン', - }, - }, - ], - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - const mediaId = Number(body.variables?.id); - if (mediaId === 130298) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - english: 'The Eminence in Shadow', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { - id: 111, - description: 'Leader of Shadow Garden.', - image: { - large: 'https://example.com/alpha.png', - medium: null, - }, - name: { - full: 'Alpha', - native: 'アルファ', - }, - }, - }, - ], - }, - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - english: 'Frieren: Beyond Journey’s End', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { - id: 222, - description: 'Elven mage.', - image: { - large: 'https://example.com/frieren.png', - medium: null, - }, - name: { - full: 'Frieren', - native: 'フリーレン', - }, - }, - }, - ], - }, - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - } - - if (url === 'https://example.com/alpha.png' || url === 'https://example.com/frieren.png') { - return new Response(PNG_1X1, { - status: 200, - headers: { 'content-type': 'image/png' }, - }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/current.mkv', - getCurrentMediaTitle: () => current.title, - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: current.title, - season: null, - episode: current.episode, - source: 'fallback', - }), - now: () => 1_700_000_000_100, - }); - - await runtime.getOrCreateCurrentSnapshot(); - current.title = 'Frieren: Beyond Journey’s End'; - current.episode = 1; - await runtime.getOrCreateCurrentSnapshot(); - - const merged = await runtime.buildMergedDictionary([21, 130298]); - const mergedReordered = await runtime.buildMergedDictionary([130298, 21]); - const index = JSON.parse(readStoredZipEntry(merged.zipPath, 'index.json').toString('utf8')) as { - title: string; - }; - const termBank = JSON.parse( - readStoredZipEntry(merged.zipPath, 'term_bank_1.json').toString('utf8'), - ) as Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - const frieren = termBank.find(([term]) => term === 'フリーレン'); - const alpha = termBank.find(([term]) => term === 'アルファ'); - - assert.equal(index.title, 'SubMiner Character Dictionary'); - assert.equal(merged.entryCount >= 2, true); - assert.equal(merged.revision, mergedReordered.revision); - assert.ok(frieren); - assert.ok(alpha); - assert.equal((frieren[5][0] as { type?: string }).type, 'structured-content'); - assert.equal((alpha[5][0] as { type?: string }).type, 'structured-content'); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('buildMergedDictionary rebuilds snapshots written with an older format version', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - let characterQueryCount = 0; - - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - variables?: Record; - }; - - if (body.query?.includes('characters(page: $page')) { - characterQueryCount += 1; - assert.equal(body.variables?.id, 130298); - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - english: 'The Eminence in Shadow', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { - id: 111, - description: 'Leader of Shadow Garden.', - image: null, - name: { - full: 'Cid Kagenou', - native: 'シド・カゲノー', - alternative: ['Shadow'], - }, - }, - }, - ], - }, - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const snapshotsDir = path.join(userDataPath, 'character-dictionaries', 'snapshots'); - fs.mkdirSync(snapshotsDir, { recursive: true }); - fs.writeFileSync( - path.join(snapshotsDir, 'anilist-130298.json'), - JSON.stringify({ - formatVersion: 12, - mediaId: 130298, - mediaTitle: 'The Eminence in Shadow', - entryCount: 1, - updatedAt: 1_700_000_000_000, - termEntries: [['stale', '', 'name main', '', 100, ['stale'], 0, '']], - images: [], - }), - 'utf8', - ); - - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => null, - getCurrentMediaTitle: () => null, - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => null, - now: () => 1_700_000_000_100, - }); - - const merged = await runtime.buildMergedDictionary([130298]); - const termBank = JSON.parse( - readStoredZipEntry(merged.zipPath, 'term_bank_1.json').toString('utf8'), - ) as Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - - assert.equal(characterQueryCount, 1); - assert.ok(termBank.find(([term]) => term === 'シャドウ')); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('getManualSelectionSnapshot falls back to mpv current video path when app media path is not ready', async () => { - const userDataPath = makeTempDir(); - const mpvPath = - 'C:\\Videos\\KonoSuba - God’s blessing on this wonderful world!! (2016) - S02E05.mkv'; - const calls: Array<{ mediaPath: string | null; mediaTitle: string | null }> = []; - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => null, - getCurrentVideoPath: () => mpvPath, - getCurrentMediaTitle: () => null, - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async (mediaPath, mediaTitle) => { - calls.push({ mediaPath, mediaTitle }); - return { - title: 'KonoSuba - God’s blessing on this wonderful world!!', - season: 2, - episode: 5, - source: 'fallback', - }; +const alexiaWithVoiceActor: FakeAniListCharacterEdge = { + role: 'SUPPORTING', + voiceActors: [ + { + id: 456, + name: { full: 'Rina Hidaka', native: '日高里菜' }, + image: { medium: VA_IMAGE_URL }, }, - now: () => 1_700_000_000_000, + ], + node: { + id: 123, + description: + 'Alexia Midgar is the second princess of the Kingdom of Midgar.\n\n__Race:__ Human', + image: { large: ALEXIA_IMAGE_URL, medium: null }, + name: { full: 'Alexia Midgar', native: 'アレクシア・ミドガル' }, + }, +}; + +test('generateForCurrentMedia writes structured-content glossary entries from AniList characters', async (t) => { + const anilist = installFakeAniList(t, { + media: [ + eminenceMedia([ + { + role: 'SUPPORTING', + node: { + id: 123, + description: + '__Race:__ Human\nAlexia Midgar is the second princess of the Kingdom of Midgar.', + image: { large: ALEXIA_IMAGE_URL, medium: null }, + gender: 'Female', + age: '15', + dateOfBirth: { month: 9, day: 1 }, + bloodType: 'A', + name: { + full: 'Alexia Midgar', + native: 'アレクシア・ミドガル', + first: 'Alexia', + last: 'Midgar', + }, + }, + }, + { + role: 'SUPPORTING', + node: { + id: 222, + description: 'Missing native name.', + image: { large: 'https://example.com/john.png', medium: null }, + name: { full: 'John Smith', native: '', first: 'John', last: 'Smith' }, + }, + }, + ]), + ], + images: [ALEXIA_IMAGE_URL], + }); + const { runtime } = createRuntime(); + + const result = await runtime.generateForCurrentMedia(); + const termBank = readTermBank(result.zipPath); + + // Characters without a native name are skipped (no portrait download, no terms) while the rest + // still generate. + assert.equal(anilist.requests.includes('https://example.com/john.png'), false); + assert.equal( + termBank.some((entry) => JSON.stringify(entry).includes('Missing native name.')), + false, + ); + + const alexia = termBank.find(([term]) => term === 'アレクシア'); + assert.ok(alexia, 'expected compact native-name variant for character'); + const glossary = alexia[5]; + assert.equal(glossary.length, 1); + assert.equal((glossary[0] as { type?: string }).type, 'structured-content'); + assert.equal( + glossary.some((item) => typeof item === 'object' && item.type === 'image'), + false, + 'image must live inside the structured content, not as a separate glossary item', + ); + + const children = getGlossaryChildren(alexia); + assert.equal(children[0]?.tag, 'div'); + assert.equal(children[0]?.content, 'アレクシア・ミドガル'); + assert.equal( + children.some((child) => child.content === 'Alexia Midgar'), + false, + ); + + const imageWrap = children.find( + (child) => (child.content as { path?: unknown } | undefined)?.path === 'img/m130298-c123.png', + ); + assert.ok(imageWrap); + assert.equal(imageWrap.tag, 'div'); + const image = imageWrap.content as Record; + assert.equal(image.tag, 'img'); + assert.equal(image.sizeUnits, 'em'); + + const sourceDiv = children.find( + (child) => + typeof child.content === 'string' && child.content.includes('The Eminence in Shadow'), + ); + assert.equal(sourceDiv?.tag, 'div'); + + const roleBadgeDiv = children.find( + (child) => (child.content as { content?: unknown } | undefined)?.content === 'Main Character', + ); + assert.ok(roleBadgeDiv); + assert.equal(roleBadgeDiv.tag, 'div'); + assert.equal((roleBadgeDiv.content as { tag?: string }).tag, 'span'); + + const descSection = findSection(children, 'Description'); + assert.ok(descSection, 'expected Description collapsible section'); + assert.equal(descSection.open, false); + assert.ok( + String(descSection.content[1]?.content).includes( + 'Alexia Midgar is the second princess of the Kingdom of Midgar.', + ), + ); + + const infoSection = findSection(children, 'Character Information'); + assert.ok(infoSection, 'expected Character Information section with parsed __Race:__ field'); + assert.equal(infoSection.open, false); + const info = JSON.stringify(infoSection.content[1]?.content); + assert.match(info, /Female/); + assert.match(info, /15 years/); + assert.match(info, /Blood Type A/); + assert.match(info, /Birthday: September 1/); +}); + +test('generateForCurrentMedia applies configured open states to character dictionary sections', async (t) => { + installFakeAniList(t, { + media: [eminenceMedia([alexiaWithVoiceActor])], + images: [ALEXIA_IMAGE_URL, VA_IMAGE_URL], + }); + const { runtime } = createRuntime({ + getCollapsibleSectionOpenState: (section) => + section === 'description' || section === 'voicedBy', }); - const snapshot = await runtime.getManualSelectionSnapshot(undefined, ''); + const result = await runtime.generateForCurrentMedia(); + const alexia = readTermBank(result.zipPath).find(([term]) => term === 'アレクシア'); + assert.ok(alexia); + const children = getGlossaryChildren(alexia); - assert.deepEqual(calls, [{ mediaPath: mpvPath, mediaTitle: null }]); - assert.equal(snapshot.guessTitle, 'KonoSuba - God’s blessing on this wonderful world!!'); - assert.equal(snapshot.candidates.length, 0); + assert.equal(findSection(children, 'Description')?.open, true); + assert.equal(findSection(children, 'Character Information')?.open, false); + assert.equal(findSection(children, 'Voiced by')?.open, true); }); -test('buildMergedDictionary reapplies collapsible open states from current config', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - const current = { title: 'The Eminence in Shadow', episode: 5 }; +test('generateForCurrentMedia reapplies collapsible open states when using cached snapshot data', async (t) => { + installFakeAniList(t, { + media: [eminenceMedia([alexiaWithVoiceActor])], + images: [ALEXIA_IMAGE_URL, VA_IMAGE_URL], + }); + const { userDataPath, runtime: runtimeOpen } = createRuntime({ + getCollapsibleSectionOpenState: () => true, + }); + await runtimeOpen.generateForCurrentMedia(); - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - variables?: Record; - }; + const { runtime: runtimeClosed } = createRuntime({ + userDataPath, + getCollapsibleSectionOpenState: () => false, + now: () => 1_700_000_000_500, + }); + const result = await runtimeClosed.generateForCurrentMedia(); - if (body.query?.includes('Page(perPage: 10)')) { - if (body.variables?.search === 'The Eminence in Shadow') { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 130298, - episodes: 20, - title: { - english: 'The Eminence in Shadow', - }, - }, - ], - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 21, - episodes: 28, - title: { - english: 'Frieren: Beyond Journey’s End', - }, - }, - ], - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - const mediaId = Number(body.variables?.id); - if (mediaId === 130298) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { english: 'The Eminence in Shadow' }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { - id: 111, - description: 'Leader of Shadow Garden.', - image: { - large: 'https://example.com/alpha.png', - medium: null, - }, - name: { - full: 'Alpha', - native: 'アルファ', - }, - }, - }, - ], - }, - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - - return new Response( - JSON.stringify({ - data: { - Media: { - title: { english: 'Frieren: Beyond Journey’s End' }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { - id: 222, - description: 'Elven mage.', - image: { - large: 'https://example.com/frieren.png', - medium: null, - }, - name: { - full: 'Frieren', - native: 'フリーレン', - }, - }, - }, - ], - }, - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - } - - if (url === 'https://example.com/alpha.png' || url === 'https://example.com/frieren.png') { - return new Response(PNG_1X1, { - status: 200, - headers: { 'content-type': 'image/png' }, - }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtimeOpen = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/current.mkv', - getCurrentMediaTitle: () => current.title, - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: current.title, - season: null, - episode: current.episode, - source: 'fallback', - }), - getCollapsibleSectionOpenState: () => true, - now: () => 1_700_000_000_100, - }); - - await runtimeOpen.getOrCreateCurrentSnapshot(); - current.title = 'Frieren: Beyond Journey’s End'; - current.episode = 1; - await runtimeOpen.getOrCreateCurrentSnapshot(); - - const runtimeClosed = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/current.mkv', - getCurrentMediaTitle: () => current.title, - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: current.title, - season: null, - episode: current.episode, - source: 'fallback', - }), - getCollapsibleSectionOpenState: () => false, - now: () => 1_700_000_000_200, - }); - - const merged = await runtimeClosed.buildMergedDictionary([21, 130298]); - const termBank = JSON.parse( - readStoredZipEntry(merged.zipPath, 'term_bank_1.json').toString('utf8'), - ) as Array< - [ - string, - string, - string, - string, - number, - Array>, - number, - string, - ] - >; - const alpha = termBank.find(([term]) => term === 'アルファ'); - assert.ok(alpha); - const children = ( - alpha[5][0] as { - content: { content: Array> }; - } - ).content.content; - const sections = children.filter( - (item) => (item as { tag?: string }).tag === 'details', - ) as Array<{ - open?: boolean; - }>; - assert.ok(sections.length >= 1); - assert.ok(sections.every((section) => section.open === false)); - } finally { - globalThis.fetch = originalFetch; - } + const alexia = readTermBank(result.zipPath).find(([term]) => term === 'アレクシア'); + assert.ok(alexia); + const sections = getGlossaryChildren(alexia).filter((child) => child.tag === 'details'); + assert.ok(sections.length >= 2); + assert.ok(sections.every((section) => section.open === false)); }); -test('generateForCurrentMedia paces AniList requests and character image downloads', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; - const sleepCalls: number[] = []; - const imageRequests: string[] = []; +function makeCharacter(overrides: Partial & { id: number }): CharacterRecord { + return { + role: 'main', + firstNameHint: '', + fullName: '', + lastNameHint: '', + nativeName: '', + alternativeNames: [], + bloodType: '', + birthday: null, + description: '', + imageUrl: null, + age: '', + sex: '', + voiceActors: [], + ...overrides, + }; +} - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; +function buildSnapshot(characters: CharacterRecord[]): CharacterDictionarySnapshot { + return buildSnapshotFromCharacters( + 1, + 'Test Anime', + characters, + new Map(), + new Map(), + 1_700_000_000_000, + () => false, + ); +} - if (body.query?.includes('Page(perPage: 10)')) { - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 130298, - episodes: 20, - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - }, - ], - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, +const nameTermCases: Array<{ + name: string; + character: CharacterRecord; + readings: Array<[term: string, reading: string]>; +}> = [ + { + name: 'adds kana aliases for romanized names when native name is kanji', + character: makeCharacter({ id: 1, fullName: 'Satou Kazuma', nativeName: '佐藤和真' }), + readings: [ + ['カズマ', 'かずま'], + ['サトウカズマ', 'さとうかずま'], + ], + }, + { + name: 'indexes kanji family and given names using romanized first and last hints', + character: makeCharacter({ + id: 77, + firstNameHint: 'Yuuma', + fullName: 'Yuuma Kunimi', + lastNameHint: 'Kunimi', + nativeName: '国見佑真', + }), + readings: [ + ['国見', 'くにみ'], + ['佑真', 'ゆうま'], + ], + }, + { + name: 'indexes alternative character names for alias lookups', + character: makeCharacter({ + id: 321, + fullName: 'Cid Kagenou', + nativeName: 'シド・カゲノー', + alternativeNames: ['Shadow', 'Minoru Kagenou'], + }), + readings: [['シャドウ', 'しゃどう']], + }, + { + name: 'uses kanji first and last name hints to build kanji readings', + character: makeCharacter({ + id: 1, + firstNameHint: '和真', + fullName: 'Satou Kazuma', + lastNameHint: '佐藤', + nativeName: '佐藤和真', + }), + readings: [ + ['佐藤和真', 'さとうかずま'], + ['佐藤', 'さとう'], + ['和真', 'かずま'], + ], + }, +]; + +for (const { name, character, readings } of nameTermCases) { + test(`buildSnapshotFromCharacters ${name}`, () => { + const { termEntries } = buildSnapshot([character]); + for (const [term, reading] of readings) { + assert.equal( + termEntries.find(([entryTerm]) => entryTerm === term)?.[1], + reading, + `expected ${term} with reading ${reading}`, + ); + } + }); +} + +test('buildSnapshotFromCharacters preserves duplicate surface forms across different characters', () => { + const { termEntries } = buildSnapshot([ + makeCharacter({ + id: 111, + description: 'First Alpha.', + firstNameHint: 'Alpha', + fullName: 'Alpha One', + lastNameHint: 'One', + nativeName: 'アルファ', + }), + makeCharacter({ + id: 222, + description: 'Second Alpha.', + firstNameHint: 'Alpha', + fullName: 'Alpha Two', + lastNameHint: 'Two', + nativeName: 'アルファ', + }), + ]); + + const glossaries = termEntries + .filter(([term]) => term === 'アルファ') + .map((entry) => JSON.stringify(getGlossaryChildren(entry))); + assert.equal(glossaries.length, 2); + assert.ok(glossaries.some((value) => value.includes('First Alpha.'))); + assert.ok(glossaries.some((value) => value.includes('Second Alpha.'))); +}); + +const alphaEdge: FakeAniListCharacterEdge = { + role: 'MAIN', + node: { + id: 111, + description: 'Leader of Shadow Garden.', + image: { large: 'https://example.com/alpha.png', medium: null }, + name: { full: 'Alpha', native: 'アルファ' }, + }, +}; + +test('getOrCreateCurrentSnapshot reuses cached media resolution without AniList requests', async (t) => { + const anilist = installFakeAniList(t, { + media: [eminenceMedia([alphaEdge])], + images: ['https://example.com/alpha.png'], + }); + const { runtime, outputDir } = createRuntime(); + + const first = await runtime.getOrCreateCurrentSnapshot(); + fs.rmSync(path.join(outputDir, 'anilist-resolution-cache.json'), { force: true }); + const second = await runtime.getOrCreateCurrentSnapshot(); + + assert.equal(first.fromCache, false); + assert.equal(second.fromCache, true); + assert.equal(anilist.searchCount, 1); + assert.equal(anilist.characterPageCount, 1); + assert.equal(fs.existsSync(path.join(outputDir, 'cache.json')), false); + + const snapshot = JSON.parse( + fs.readFileSync(getSnapshotPath(outputDir, EMINENCE_MEDIA_ID), 'utf8'), + ) as CharacterDictionarySnapshot; + assert.equal(snapshot.mediaId, EMINENCE_MEDIA_ID); + assert.ok(snapshot.termEntries.some(([term]) => term === 'アルファ')); +}); + +test('generateForCurrentMedia downloads shared voice actor images once per AniList person id', async (t) => { + const kana = { + id: 9001, + name: { full: 'Kana Hanazawa', native: '花澤香菜' }, + image: { large: null, medium: 'https://example.com/kana.png' }, + }; + const anilist = installFakeAniList(t, { + media: [ + eminenceMedia([ + { ...alphaEdge, voiceActors: [kana] }, + { + role: 'SUPPORTING', + voiceActors: [kana], + node: { + id: 654, + description: 'Beta documents Shadow Garden operations.', + image: { large: 'https://example.com/beta.png', medium: null }, + name: { full: 'Beta', native: 'ベータ' }, }, - ); - } - - if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - romaji: 'Kage no Jitsuryokusha ni Naritakute!', - english: 'The Eminence in Shadow', - native: '陰の実力者になりたくて!', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { - id: 111, - description: 'First character.', - image: { - large: 'https://example.com/alpha.png', - medium: null, - }, - name: { - full: 'Alpha', - native: 'アルファ', - }, - }, - }, - { - role: 'SUPPORTING', - node: { - id: 222, - description: 'Second character.', - image: { - large: 'https://example.com/beta.png', - medium: null, - }, - name: { - full: 'Beta', - native: 'ベータ', - }, - }, - }, - ], - }, - }, - }, - }), - { - status: 200, - headers: { 'content-type': 'application/json' }, - }, - ); - } - } - - if (url === 'https://example.com/alpha.png') { - imageRequests.push(url); - return new Response('missing', { - status: 404, - headers: { 'content-type': 'text/plain' }, - }); - } - - if (url === 'https://example.com/beta.png') { - imageRequests.push(url); - return new Response(PNG_1X1, { - status: 200, - headers: { 'content-type': 'image/png' }, - }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - now: () => 1_700_000_000_000, - sleep: async (ms) => { - sleepCalls.push(ms); - }, - }); - - await runtime.generateForCurrentMedia(); - - assert.deepEqual(sleepCalls, [2000, 250]); - assert.deepEqual(imageRequests, [ + }, + ]), + ], + images: [ 'https://example.com/alpha.png', 'https://example.com/beta.png', - ]); - } finally { - globalThis.fetch = originalFetch; - } + 'https://example.com/kana.png', + ], + }); + const { runtime } = createRuntime(); + + await runtime.generateForCurrentMedia(); + + assert.deepEqual([...anilist.imageRequests].sort(), [ + 'https://example.com/alpha.png', + 'https://example.com/beta.png', + 'https://example.com/kana.png', + ]); +}); + +test('buildMergedDictionary combines stored snapshots into one stable dictionary', async (t) => { + const current = { title: 'The Eminence in Shadow', episode: 5 }; + installFakeAniList(t, { + media: [ + { ...eminenceMedia([alphaEdge]), search: 'The Eminence in Shadow' }, + { + id: 21, + episodes: 28, + title: { english: 'Frieren: Beyond Journey’s End', native: '葬送のフリーレン' }, + search: 'Frieren: Beyond Journey’s End', + characters: [ + { + role: 'MAIN', + node: { + id: 222, + description: 'Elven mage.', + image: { large: 'https://example.com/frieren.png', medium: null }, + name: { full: 'Frieren', native: 'フリーレン' }, + }, + }, + ], + }, + ], + images: ['https://example.com/alpha.png', 'https://example.com/frieren.png'], + }); + const { runtime } = createRuntime({ + getCurrentMediaPath: () => '/tmp/current.mkv', + getCurrentMediaTitle: () => current.title, + guessAnilistMediaInfo: async () => ({ + title: current.title, + season: null, + episode: current.episode, + source: 'fallback', + }), + }); + + await runtime.getOrCreateCurrentSnapshot(); + current.title = 'Frieren: Beyond Journey’s End'; + current.episode = 1; + await runtime.getOrCreateCurrentSnapshot(); + + const merged = await runtime.buildMergedDictionary([21, EMINENCE_MEDIA_ID]); + const mergedReordered = await runtime.buildMergedDictionary([EMINENCE_MEDIA_ID, 21]); + const index = JSON.parse(readStoredZipEntry(merged.zipPath, 'index.json').toString('utf8')) as { + title: string; + }; + const termBank = readTermBank(merged.zipPath); + const frieren = termBank.find(([term]) => term === 'フリーレン'); + const alpha = termBank.find(([term]) => term === 'アルファ'); + + assert.equal(index.title, 'SubMiner Character Dictionary'); + assert.equal(merged.entryCount >= 2, true); + assert.equal(merged.revision, mergedReordered.revision); + assert.equal((frieren?.[5][0] as { type?: string }).type, 'structured-content'); + assert.equal((alpha?.[5][0] as { type?: string }).type, 'structured-content'); +}); + +test('buildMergedDictionary rebuilds snapshots written with an older format version', async (t) => { + const anilist = installFakeAniList(t, { + media: [ + eminenceMedia([ + { + role: 'MAIN', + node: { + id: 111, + description: 'Leader of Shadow Garden.', + image: null, + name: { full: 'Cid Kagenou', native: 'シド・カゲノー', alternative: ['Shadow'] }, + }, + }, + ]), + ], + }); + const { runtime, outputDir } = createRuntime({ + getCurrentMediaPath: () => null, + getCurrentMediaTitle: () => null, + guessAnilistMediaInfo: async () => null, + }); + const snapshotPath = getSnapshotPath(outputDir, EMINENCE_MEDIA_ID); + fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); + fs.writeFileSync( + snapshotPath, + JSON.stringify({ + formatVersion: 12, + mediaId: EMINENCE_MEDIA_ID, + mediaTitle: 'The Eminence in Shadow', + entryCount: 1, + updatedAt: 1_700_000_000_000, + termEntries: [['stale', '', 'name main', '', 100, ['stale'], 0, '']], + images: [], + }), + 'utf8', + ); + + const merged = await runtime.buildMergedDictionary([EMINENCE_MEDIA_ID]); + const termBank = readTermBank(merged.zipPath); + + assert.equal(anilist.characterPageCount, 1); + assert.ok(termBank.some(([term]) => term === 'シャドウ')); + assert.equal( + termBank.some(([term]) => term === 'stale'), + false, + ); +}); + +test('generateForCurrentMedia paces AniList requests and character image downloads', async (t) => { + const anilist = installFakeAniList(t, { + media: [ + eminenceMedia([ + { + role: 'MAIN', + node: { + id: 111, + description: 'First character.', + image: { large: 'https://example.com/alpha.png', medium: null }, + name: { full: 'Alpha', native: 'アルファ' }, + }, + }, + { + role: 'SUPPORTING', + node: { + id: 222, + description: 'Second character.', + image: { large: 'https://example.com/beta.png', medium: null }, + name: { full: 'Beta', native: 'ベータ' }, + }, + }, + ]), + ], + images: ['https://example.com/beta.png'], + missingImages: ['https://example.com/alpha.png'], + }); + // Number of requests already sent each time the runtime pauses. + const requestsBeforeSleep: number[] = []; + const { runtime, outputDir } = createRuntime({ + sleep: async () => { + requestsBeforeSleep.push(anilist.requests.length); + }, + }); + + await runtime.generateForCurrentMedia(); + + // search, [pause], character page, alpha image, [pause], beta image + assert.deepEqual(requestsBeforeSleep, [1, 3]); + assert.equal(anilist.requests.length, 4); + + // The 404 portrait is skipped without aborting generation. + const snapshot = JSON.parse( + fs.readFileSync(getSnapshotPath(outputDir, EMINENCE_MEDIA_ID), 'utf8'), + ) as CharacterDictionarySnapshot; + assert.deepEqual( + snapshot.images.map((image) => image.path), + ['img/m130298-c222.png'], + ); }); diff --git a/src/main/character-dictionary-runtime/cache.test.ts b/src/main/character-dictionary-runtime/cache.test.ts index 8ceda692..b24a869d 100644 --- a/src/main/character-dictionary-runtime/cache.test.ts +++ b/src/main/character-dictionary-runtime/cache.test.ts @@ -121,18 +121,3 @@ test('readSnapshot ignores snapshots written with an older format version', asyn assert.equal(await readSnapshot(snapshotPath), null); }); - -test('readSnapshot ignores v15 snapshots with stale romanized character-name entries', async () => { - const outputDir = makeTempDir(); - const snapshotPath = getSnapshotPath(outputDir, 130298); - const staleSnapshot = { - ...createSnapshot(), - formatVersion: 15, - termEntries: [['Vanir', 'ばにる', 'name primary', '', 75, ['Vanir'], 0, '']], - }; - - fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); - fs.writeFileSync(snapshotPath, JSON.stringify(staleSnapshot), 'utf8'); - - assert.equal(await readSnapshot(snapshotPath), null); -}); diff --git a/src/main/character-dictionary-runtime/manual-selection-snapshot.test.ts b/src/main/character-dictionary-runtime/manual-selection-snapshot.test.ts index 20bd715d..c31263dd 100644 --- a/src/main/character-dictionary-runtime/manual-selection-snapshot.test.ts +++ b/src/main/character-dictionary-runtime/manual-selection-snapshot.test.ts @@ -161,6 +161,36 @@ test('getManualSelectionSnapshot hydrates override episode count from searched c } }); +test('getManualSelectionSnapshot falls back to mpv current video path when app media path is not ready', async () => { + const userDataPath = makeTempDir(); + const mpvPath = + 'C:\\Videos\\KonoSuba - God’s blessing on this wonderful world!! (2016) - S02E05.mkv'; + const calls: Array<{ mediaPath: string | null; mediaTitle: string | null }> = []; + const runtime = createCharacterDictionaryRuntimeService({ + userDataPath, + getCurrentMediaPath: () => null, + getCurrentVideoPath: () => mpvPath, + getCurrentMediaTitle: () => null, + resolveMediaPathForJimaku: (mediaPath) => mediaPath, + guessAnilistMediaInfo: async (mediaPath, mediaTitle) => { + calls.push({ mediaPath, mediaTitle }); + return { + title: 'KonoSuba - God’s blessing on this wonderful world!!', + season: 2, + episode: 5, + source: 'fallback', + }; + }, + now: () => 1_700_000_000_000, + }); + + const snapshot = await runtime.getManualSelectionSnapshot(undefined, ''); + + assert.deepEqual(calls, [{ mediaPath: mpvPath, mediaTitle: null }]); + assert.equal(snapshot.guessTitle, 'KonoSuba - God’s blessing on this wonderful world!!'); + assert.equal(snapshot.candidates.length, 0); +}); + test('resolvePinnedMediaId returns the manual override for the current season', async () => { const userDataPath = makeTempDir(); const seasonThreePath = '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv'; diff --git a/src/main/character-dictionary-runtime/name-candidates.test.ts b/src/main/character-dictionary-runtime/name-candidates.test.ts index 416b31ee..a4a7412d 100644 --- a/src/main/character-dictionary-runtime/name-candidates.test.ts +++ b/src/main/character-dictionary-runtime/name-candidates.test.ts @@ -106,12 +106,14 @@ test('returns null for a media with no cached snapshot', async () => { }); }); -test('key changes when the snapshot content changes', async () => { +test('invalidate picks up a snapshot change within the recheck interval and changes the key', async () => { await withTempDir(async (dir) => { writeSnapshot(dir, 1, [['ミナト', 'みなと']]); + // Frozen clock: only invalidate() can trigger the refresh below. const lookup = createCharacterNameCandidateLookup({ outputDir: dir, getCurrentMediaId: () => 1, + now: () => 1_000_000, }); const first = await waitForRefresh(() => lookup.get()); @@ -162,31 +164,3 @@ test('does not re-read the snapshot directory on every lookup', async () => { assert.equal(refreshed.forms.length, 4, 'expected a refresh past the interval'); }); }); - -test('invalidate picks up a snapshot change on the next refresh', async () => { - await withTempDir(async (dir) => { - writeSnapshot(dir, 1, [['ミナト', 'みなと']]); - let nowMs = 1_000_000; - const lookup = createCharacterNameCandidateLookup({ - outputDir: dir, - getCurrentMediaId: () => 1, - now: () => nowMs, - }); - - await waitForRefresh(() => lookup.get()); - assert.equal(lookup.get()?.forms.length, 2); - - writeSnapshot(dir, 1, [ - ['ミナト', 'みなと'], - ['アクア', 'あくあ'], - ]); - nowMs += 1; - lookup.invalidate(); - - const refreshed = await waitForRefresh(() => { - const candidates = lookup.get(); - return candidates && candidates.forms.length === 4 ? candidates : null; - }); - assert.equal(refreshed.forms.length, 4); - }); -}); diff --git a/src/main/character-dictionary-runtime/runtime-test-harness.ts b/src/main/character-dictionary-runtime/runtime-test-harness.ts new file mode 100644 index 00000000..47f1a00e --- /dev/null +++ b/src/main/character-dictionary-runtime/runtime-test-harness.ts @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { TestContext } from 'node:test'; + +import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime'; +import { ANILIST_GRAPHQL_URL } from './constants'; +import type { CharacterDictionaryRuntimeDeps, CharacterDictionaryTermEntry } from './types'; + +// Shared fixtures for the character dictionary runtime tests: a fake AniList GraphQL + image +// server, a runtime factory with Eminence in Shadow defaults, and readers for the built ZIP. + +export const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=', + 'base64', +); + +export function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-')); +} + +type FetchHandler = (url: string, init?: RequestInit) => Promise | Response; + +/** Replaces `globalThis.fetch` for the duration of the test. */ +export function installFetch(t: TestContext, handler: FetchHandler): void { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + return handler(url, init); + }) as typeof globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); +} + +export function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +/** AniList `characters.edges[]` item, as returned by the GraphQL API. */ +export type FakeAniListCharacterEdge = { + role: string; + voiceActors?: Array<{ + id: number; + name: { full?: string; native?: string }; + image?: { large?: string | null; medium?: string | null }; + }>; + node: { + id: number; + description?: string; + image?: { large?: string | null; medium?: string | null } | null; + name: { + full?: string; + native?: string; + first?: string | null; + last?: string | null; + alternative?: string[]; + }; + gender?: string; + age?: string; + dateOfBirth?: { month: number; day: number }; + bloodType?: string; + }; +}; + +export type FakeAniListMedia = { + id: number; + episodes?: number; + title: { romaji?: string; english?: string; native?: string }; + /** Search text that resolves to this media. Unmatched searches resolve to the first media. */ + search?: string; + characters?: FakeAniListCharacterEdge[]; +}; + +export type FakeAniList = { + /** Every fetched URL, in request order. */ + requests: string[]; + searchCount: number; + characterPageCount: number; + imageRequests: string[]; +}; + +/** + * Serves AniList media search and character page queries from `media`, and image downloads for + * `images` (PNG) and `missingImages` (404). Any other request throws. + */ +export function installFakeAniList( + t: TestContext, + options: { media?: FakeAniListMedia[]; images?: string[]; missingImages?: string[] } = {}, +): FakeAniList { + const media = options.media ?? []; + const images = new Set(options.images ?? []); + const missingImages = new Set(options.missingImages ?? []); + const fake: FakeAniList = { + requests: [], + searchCount: 0, + characterPageCount: 0, + imageRequests: [], + }; + + installFetch(t, (url, init) => { + fake.requests.push(url); + if (url === ANILIST_GRAPHQL_URL) { + const body = JSON.parse(String(init?.body ?? '{}')) as { + query?: string; + variables?: { search?: string; id?: number }; + }; + + if (body.query?.includes('Page(perPage: 10)') && media.length > 0) { + fake.searchCount += 1; + const match = media.find((entry) => entry.search === body.variables?.search) ?? media[0]!; + return jsonResponse({ + data: { + Page: { media: [{ id: match.id, episodes: match.episodes ?? 12, title: match.title }] }, + }, + }); + } + + const pageMedia = media.find((entry) => entry.id === body.variables?.id); + if (body.query?.includes('characters(page: $page') && pageMedia) { + fake.characterPageCount += 1; + return jsonResponse({ + data: { + Media: { + title: pageMedia.title, + characters: { + pageInfo: { hasNextPage: false }, + edges: pageMedia.characters ?? [], + }, + }, + }, + }); + } + } + + if (images.has(url) || missingImages.has(url)) { + fake.imageRequests.push(url); + return images.has(url) + ? new Response(PNG_1X1, { status: 200, headers: { 'content-type': 'image/png' } }) + : new Response('missing', { status: 404, headers: { 'content-type': 'text/plain' } }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + return fake; +} + +export const EMINENCE_MEDIA_ID = 130298; +export const EMINENCE_TITLE = { + romaji: 'Kage no Jitsuryokusha ni Naritakute!', + english: 'The Eminence in Shadow', + native: '陰の実力者になりたくて!', +}; + +export type CharacterDictionaryRuntime = ReturnType; + +/** Creates a runtime watching an Eminence in Shadow episode. AniList pacing sleeps are skipped. */ +export function createRuntime(overrides: Partial = {}): { + runtime: CharacterDictionaryRuntime; + userDataPath: string; + outputDir: string; +} { + const userDataPath = overrides.userDataPath ?? makeTempDir(); + const runtime = createCharacterDictionaryRuntimeService({ + getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', + getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', + resolveMediaPathForJimaku: (mediaPath) => mediaPath, + guessAnilistMediaInfo: async () => ({ + title: 'The Eminence in Shadow', + season: null, + episode: 5, + source: 'fallback', + }), + now: () => 1_700_000_000_000, + sleep: async () => undefined, + ...overrides, + userDataPath, + }); + return { runtime, userDataPath, outputDir: path.join(userDataPath, 'character-dictionaries') }; +} + +const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50; +const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50; +const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50; + +export function readStoredZipEntry(zipPath: string, entryName: string): Buffer { + const archive = fs.readFileSync(zipPath); + let offset = 0; + + while (offset + 4 <= archive.length) { + const signature = archive.readUInt32LE(offset); + if ( + signature === CENTRAL_DIRECTORY_SIGNATURE || + signature === END_OF_CENTRAL_DIRECTORY_SIGNATURE + ) { + break; + } + if (signature !== LOCAL_FILE_HEADER_SIGNATURE) { + throw new Error(`Unexpected ZIP signature 0x${signature.toString(16)} at offset ${offset}`); + } + + const compressionMethod = archive.readUInt16LE(offset + 8); + assert.equal(compressionMethod, 0, 'expected stored ZIP entry'); + const compressedSize = archive.readUInt32LE(offset + 18); + const fileNameLength = archive.readUInt16LE(offset + 26); + const extraFieldLength = archive.readUInt16LE(offset + 28); + const fileNameStart = offset + 30; + const fileNameEnd = fileNameStart + fileNameLength; + const fileName = archive.subarray(fileNameStart, fileNameEnd).toString('utf8'); + const dataStart = fileNameEnd + extraFieldLength; + const dataEnd = dataStart + compressedSize; + + if (fileName === entryName) { + return archive.subarray(dataStart, dataEnd); + } + + offset = dataEnd; + } + + throw new Error(`ZIP entry not found: ${entryName}`); +} + +export function readTermBank(zipPath: string): CharacterDictionaryTermEntry[] { + return JSON.parse( + readStoredZipEntry(zipPath, 'term_bank_1.json').toString('utf8'), + ) as CharacterDictionaryTermEntry[]; +} + +type StructuredNode = { tag?: string; open?: boolean; content?: unknown }; + +/** Top-level structured-content children of a term entry's glossary. */ +export function getGlossaryChildren(entry: CharacterDictionaryTermEntry): StructuredNode[] { + const glossary = entry[5][0] as { content: { content: StructuredNode[] } }; + return glossary.content.content; +} + +/** Finds a collapsible `details` section by its summary title. */ +export function findSection( + children: StructuredNode[], + title: string, +): (StructuredNode & { content: StructuredNode[] }) | undefined { + return children.find( + (child): child is StructuredNode & { content: StructuredNode[] } => + child.tag === 'details' && + Array.isArray(child.content) && + (child.content[0] as StructuredNode | undefined)?.content === title, + ); +} diff --git a/src/main/character-dictionary-runtime/snapshot-refresh.test.ts b/src/main/character-dictionary-runtime/snapshot-refresh.test.ts index 25d0ecbd..07cfa61b 100644 --- a/src/main/character-dictionary-runtime/snapshot-refresh.test.ts +++ b/src/main/character-dictionary-runtime/snapshot-refresh.test.ts @@ -1,219 +1,135 @@ import assert from 'node:assert/strict'; import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import test from 'node:test'; +import test, { type TestContext } from 'node:test'; -import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime'; import { getSnapshotPath, writeSnapshot } from './cache'; -import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants'; -import type { CharacterDictionarySnapshot, NameSplitTokenizer } from './types'; +import { ANILIST_GRAPHQL_URL, CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants'; +import { + EMINENCE_MEDIA_ID, + EMINENCE_TITLE, + PNG_1X1, + createRuntime, + installFakeAniList, + installFetch, + jsonResponse, + type FakeAniListCharacterEdge, +} from './runtime-test-harness'; +import type { + CharacterDictionaryRuntimeDeps, + CharacterDictionarySnapshot, + NameSplitTokenizer, +} from './types'; -const GRAPHQL_URL = 'https://graphql.anilist.co'; -const PNG_1X1 = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=', - 'base64', -); - -function makeTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-')); -} - -function createSnapshotWithoutImages(): CharacterDictionarySnapshot { +function createSnapshotWithoutImages( + overrides: Partial = {}, +): CharacterDictionarySnapshot { return { formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION, - mediaId: 130298, + mediaId: EMINENCE_MEDIA_ID, mediaTitle: 'The Eminence in Shadow', entryCount: 1, updatedAt: 1_700_000_000_000, termEntries: [['アレクシア', 'あれくしあ', 'name primary', '', 75, ['Alexia'], 0, '']], images: [], + ...overrides, }; } -test('generateForCurrentMedia refreshes same-version snapshots missing images when inline images are enabled', async () => { - const userDataPath = makeTempDir(); - const outputDir = path.join(userDataPath, 'character-dictionaries'); - await writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages()); - const originalFetch = globalThis.fetch; - const fetchUrls: string[] = []; +/** Seeds a cached snapshot, then returns a runtime that will find it for the current episode. */ +async function createRuntimeWithSnapshot( + snapshot: CharacterDictionarySnapshot, + overrides: Partial = {}, +) { + const harness = createRuntime({ now: () => 1_700_000_000_500, ...overrides }); + await writeSnapshot(getSnapshotPath(harness.outputDir, EMINENCE_MEDIA_ID), snapshot); + return harness; +} - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - fetchUrls.push(url); +function readStoredSnapshot(outputDir: string): CharacterDictionarySnapshot { + return JSON.parse( + fs.readFileSync(getSnapshotPath(outputDir, EMINENCE_MEDIA_ID), 'utf8'), + ) as CharacterDictionarySnapshot; +} - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { - query?: string; - }; - if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { - english: 'The Eminence in Shadow', - }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'SUPPORTING', - node: { - id: 123, - description: 'Alexia Midgar.', - image: { - large: 'https://cdn.example.com/character-123.png', - medium: null, - }, - name: { - full: 'Alexia Midgar', - native: 'アレクシア・ミドガル', - }, - }, - }, - ], - }, - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - } +function installCharacterPage(t: TestContext, edge: FakeAniListCharacterEdge, images?: string[]) { + return installFakeAniList(t, { + media: [ + { id: EMINENCE_MEDIA_ID, title: { english: EMINENCE_TITLE.english }, characters: [edge] }, + ], + images, + }); +} - if (url === 'https://cdn.example.com/character-123.png') { - return new Response(PNG_1X1, { - status: 200, - headers: { 'content-type': 'image/png' }, - }); - } +test('generateForCurrentMedia refreshes same-version snapshots missing images when inline images are enabled', async (t) => { + const imageUrl = 'https://cdn.example.com/character-123.png'; + const anilist = installCharacterPage( + t, + { + role: 'SUPPORTING', + node: { + id: 123, + description: 'Alexia Midgar.', + image: { large: imageUrl, medium: null }, + name: { full: 'Alexia Midgar', native: 'アレクシア・ミドガル' }, + }, + }, + [imageUrl], + ); + const { runtime, outputDir } = await createRuntimeWithSnapshot(createSnapshotWithoutImages(), { + getNameMatchImagesEnabled: () => true, + }); - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; + const result = await runtime.generateForCurrentMedia(); - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - getNameMatchImagesEnabled: () => true, - now: () => 1_700_000_000_500, - }); - - const result = await runtime.generateForCurrentMedia(); - const refreshedSnapshot = JSON.parse( - fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'), - ) as CharacterDictionarySnapshot; - - assert.equal(result.fromCache, false); - assert.ok(fetchUrls.includes(GRAPHQL_URL)); - assert.ok(refreshedSnapshot.images.some((image) => image.path === 'img/m130298-c123.png')); - } finally { - globalThis.fetch = originalFetch; - } + assert.equal(result.fromCache, false); + assert.equal(anilist.characterPageCount, 1); + assert.ok( + readStoredSnapshot(outputDir).images.some((image) => image.path === 'img/m130298-c123.png'), + ); }); -async function runNameSplitRefreshScenario(tokenizeJapaneseName: NameSplitTokenizer): Promise<{ +async function runNameSplitRefreshScenario( + t: TestContext, + tokenizeJapaneseName: NameSplitTokenizer, +): Promise<{ characterPageRequests: number; firstResultFromCache: boolean; refreshedNameSplitSource: CharacterDictionarySnapshot['nameSplitSource']; secondResultFromCache: boolean; }> { - const userDataPath = makeTempDir(); - const outputDir = path.join(userDataPath, 'character-dictionaries'); - await writeSnapshot(getSnapshotPath(outputDir, 130298), { - ...createSnapshotWithoutImages(), - nameSplitSource: 'heuristic', + const anilist = installCharacterPage(t, { + role: 'SUPPORTING', + node: { + id: 123, + description: 'Alexia Midgar.', + image: { large: null, medium: null }, + name: { first: 'Taro', last: 'Yamada', full: 'Taro Yamada', native: '山田太郎' }, + }, }); - const originalFetch = globalThis.fetch; - - let characterPageRequests = 0; - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GRAPHQL_URL) { - const body = JSON.parse(String(init?.body ?? '{}')) as { query?: string }; - if (body.query?.includes('characters(page: $page')) { - characterPageRequests += 1; - return new Response( - JSON.stringify({ - data: { - Media: { - title: { english: 'The Eminence in Shadow' }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'SUPPORTING', - node: { - id: 123, - description: 'Alexia Midgar.', - image: { large: null, medium: null }, - name: { - first: 'Taro', - last: 'Yamada', - full: 'Taro Yamada', - native: '山田太郎', - }, - }, - }, - ], - }, - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - } - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), + const { runtime, outputDir } = await createRuntimeWithSnapshot( + createSnapshotWithoutImages({ nameSplitSource: 'heuristic' }), + { getNameMatchImagesEnabled: () => false, tokenizeJapaneseName, getJapaneseNameTokenizerAvailable: () => true, - now: () => 1_700_000_000_500, - }); + }, + ); - const firstResult = await runtime.generateForCurrentMedia(); - const refreshedSnapshot = JSON.parse( - fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'), - ) as CharacterDictionarySnapshot; - const secondResult = await runtime.generateForCurrentMedia(); + const firstResult = await runtime.generateForCurrentMedia(); + const refreshedSnapshot = readStoredSnapshot(outputDir); + const secondResult = await runtime.generateForCurrentMedia(); - return { - characterPageRequests, - firstResultFromCache: firstResult.fromCache, - refreshedNameSplitSource: refreshedSnapshot.nameSplitSource, - secondResultFromCache: secondResult.fromCache, - }; - } finally { - globalThis.fetch = originalFetch; - } + return { + characterPageRequests: anilist.characterPageCount, + firstResultFromCache: firstResult.fromCache, + refreshedNameSplitSource: refreshedSnapshot.nameSplitSource, + secondResultFromCache: secondResult.fromCache, + }; } -test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => { +test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async (t) => { let tokenizerCalls = 0; - const result = await runNameSplitRefreshScenario(async () => { + const result = await runNameSplitRefreshScenario(t, async () => { tokenizerCalls += 1; return null; }); @@ -225,9 +141,9 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable' assert.equal(tokenizerCalls, 2); }); -test('generateForCurrentMedia caches completed MeCab refreshes with no resolved splits', async () => { +test('generateForCurrentMedia caches completed MeCab refreshes with no resolved splits', async (t) => { let tokenizerCalls = 0; - const result = await runNameSplitRefreshScenario(async () => { + const result = await runNameSplitRefreshScenario(t, async () => { tokenizerCalls += 1; return []; }); @@ -239,129 +155,53 @@ test('generateForCurrentMedia caches completed MeCab refreshes with no resolved assert.equal(tokenizerCalls, 1); }); -test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => { - const userDataPath = makeTempDir(); - const outputDir = path.join(userDataPath, 'character-dictionaries'); - await writeSnapshot(getSnapshotPath(outputDir, 130298), { - ...createSnapshotWithoutImages(), - nameSplitSource: 'mecab', - }); - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (input: string | URL | Request) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), +const keptSnapshotCases: Array<{ + name: string; + snapshot: CharacterDictionarySnapshot; + deps: Partial; +}> = [ + { + name: 'keeps mecab-split snapshots when MeCab is available', + snapshot: createSnapshotWithoutImages({ nameSplitSource: 'mecab' }), + deps: { getNameMatchImagesEnabled: () => false, tokenizeJapaneseName: async () => null, getJapaneseNameTokenizerAvailable: () => true, - now: () => 1_700_000_000_500, - }); - - const result = await runtime.generateForCurrentMedia(); - - assert.equal(result.fromCache, true); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('generateForCurrentMedia keeps heuristic-split snapshots while MeCab is unavailable', async () => { - const userDataPath = makeTempDir(); - const outputDir = path.join(userDataPath, 'character-dictionaries'); - await writeSnapshot(getSnapshotPath(outputDir, 130298), { - ...createSnapshotWithoutImages(), - nameSplitSource: 'heuristic', - }); - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (input: string | URL | Request) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), + }, + }, + { + name: 'keeps heuristic-split snapshots while MeCab is unavailable', + snapshot: createSnapshotWithoutImages({ nameSplitSource: 'heuristic' }), + deps: { getNameMatchImagesEnabled: () => false, tokenizeJapaneseName: async () => null, getJapaneseNameTokenizerAvailable: () => false, - now: () => 1_700_000_000_500, - }); + }, + }, + { + name: 'keeps same-version snapshots without images when inline images are disabled', + snapshot: createSnapshotWithoutImages(), + deps: { getNameMatchImagesEnabled: () => false }, + }, +]; + +for (const { name, snapshot, deps } of keptSnapshotCases) { + test(`generateForCurrentMedia ${name}`, async (t) => { + // No media or images: any AniList request throws. + installFakeAniList(t); + const { runtime } = await createRuntimeWithSnapshot(snapshot, deps); const result = await runtime.generateForCurrentMedia(); assert.equal(result.fromCache, true); - } finally { - globalThis.fetch = originalFetch; - } -}); + }); +} -test('generateForCurrentMedia keeps same-version snapshots without images when inline images are disabled', async () => { - const userDataPath = makeTempDir(); - const outputDir = path.join(userDataPath, 'character-dictionaries'); - await writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages()); - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async (input: string | URL | Request) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - throw new Error(`Unexpected fetch URL: ${url}`); - }) as typeof globalThis.fetch; - - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', - getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'The Eminence in Shadow', - season: null, - episode: 5, - source: 'fallback', - }), - getNameMatchImagesEnabled: () => false, - now: () => 1_700_000_000_500, - }); - - const result = await runtime.generateForCurrentMedia(); - - assert.equal(result.fromCache, true); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('an unresolvable season is not cached as a normal AniList match', async () => { - const userDataPath = makeTempDir(); - const originalFetch = globalThis.fetch; +test('an unresolvable season is not cached as a normal AniList match', async (t) => { let searchCalls = 0; - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url !== GRAPHQL_URL) { + installFetch(t, (url, init) => { + if (url !== ANILIST_GRAPHQL_URL) { return new Response(PNG_1X1, { status: 200, headers: { 'content-type': 'image/png' } }); } @@ -371,84 +211,66 @@ test('an unresolvable season is not cached as a normal AniList match', async () }; if (body.query?.includes('characters(page: $page')) { - return new Response( - JSON.stringify({ - data: { - Media: { - title: { english: 'My Teen Romantic Comedy SNAFU' }, - characters: { - pageInfo: { hasNextPage: false }, - edges: [ - { - role: 'MAIN', - node: { id: 1, name: { full: 'Hachiman Hikigaya', native: '比企谷八幡' } }, - }, - ], - }, + return jsonResponse({ + data: { + Media: { + title: { english: 'My Teen Romantic Comedy SNAFU' }, + characters: { + pageInfo: { hasNextPage: false }, + edges: [ + { + role: 'MAIN', + node: { id: 1, name: { full: 'Hachiman Hikigaya', native: '比企谷八幡' } }, + }, + ], }, }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); + }, + }); } if (typeof body.variables?.id === 'number') { // No sequel edges: season 3 is unreachable from the season 1 anchor. - return new Response(JSON.stringify({ data: { Media: { relations: { edges: [] } } } }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); + return jsonResponse({ data: { Media: { relations: { edges: [] } } } }); } searchCalls += 1; - return new Response( - JSON.stringify({ - data: { - Page: { - media: [ - { - id: 14813, - episodes: 13, - format: 'TV', - seasonYear: 2013, - title: { romaji: null, english: 'My Teen Romantic Comedy SNAFU', native: null }, - }, - ], - }, + return jsonResponse({ + data: { + Page: { + media: [ + { + id: 14813, + episodes: 13, + format: 'TV', + seasonYear: 2013, + title: { romaji: null, english: 'My Teen Romantic Comedy SNAFU', native: null }, + }, + ], }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - }) as typeof globalThis.fetch; + }, + }); + }); const warnings: string[] = []; - try { - const runtime = createCharacterDictionaryRuntimeService({ - userDataPath, - getCurrentMediaPath: () => - '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv', - getCurrentMediaTitle: () => null, - resolveMediaPathForJimaku: (mediaPath) => mediaPath, - guessAnilistMediaInfo: async () => ({ - title: 'My Teen Romantic Comedy SNAFU', - year: 2013, - season: 3, - episode: 1, - source: 'guessit', - }), - now: () => 1_700_000_000_000, - sleep: async () => {}, - logWarn: (message) => warnings.push(message), - }); + const { runtime } = createRuntime({ + getCurrentMediaPath: () => '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv', + getCurrentMediaTitle: () => null, + guessAnilistMediaInfo: async () => ({ + title: 'My Teen Romantic Comedy SNAFU', + year: 2013, + season: 3, + episode: 1, + source: 'guessit', + }), + logWarn: (message) => warnings.push(message), + }); - await runtime.getOrCreateCurrentSnapshot(); - const searchesAfterFirst = searchCalls; - await runtime.getOrCreateCurrentSnapshot(); + await runtime.getOrCreateCurrentSnapshot(); + const searchesAfterFirst = searchCalls; + await runtime.getOrCreateCurrentSnapshot(); - // Re-resolved rather than served from the resolution cache, and warned both times. - assert.ok(searchCalls > searchesAfterFirst); - assert.equal(warnings.filter((m) => /could not find season 3/i.test(m)).length, 2); - } finally { - globalThis.fetch = originalFetch; - } + // Re-resolved rather than served from the resolution cache, and warned both times. + assert.ok(searchCalls > searchesAfterFirst); + assert.equal(warnings.filter((m) => /could not find season 3/i.test(m)).length, 2); }); diff --git a/src/main/config-validation.test.ts b/src/main/config-validation.test.ts index 98344c8a..4fefe7d2 100644 --- a/src/main/config-validation.test.ts +++ b/src/main/config-validation.test.ts @@ -1,8 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - buildConfigParseErrorDetails, - buildConfigWarningDialogDetails, buildConfigWarningNotificationBody, buildConfigWarningSummary, failStartupFromConfig, @@ -54,29 +52,27 @@ test('buildConfigWarningNotificationBody includes concise warning details', () = ); }); -test('buildConfigWarningDialogDetails includes full warning details', () => { - const details = buildConfigWarningDialogDetails('/tmp/config.jsonc', [ - { - path: 'ankiConnect.pollingRate', - message: 'must be >= 50', - value: 10, - fallback: 250, - }, +test('buildConfigWarningNotificationBody caps listed warnings and clips long config paths', () => { + const longPath = `/home/user/${'nested/'.repeat(10)}config.jsonc`; + const warnings = [1, 2, 3, 4, 5].map((n) => ({ + path: `key${n}`, + message: 'invalid', + value: n, + fallback: 0, + })); + + const lines = buildConfigWarningNotificationBody(longPath, warnings).split('\n'); + + assert.equal(lines[0], '5 config validation issue(s) detected.'); + const fileLine = lines.find((line) => line.startsWith('File: ')); + assert.equal(fileLine, `File: ...${longPath.slice(-45)}`); + assert.equal(fileLine?.length, 'File: '.length + 48); + assert.deepEqual(lines.slice(-4), [ + '1. key1: invalid', + '2. key2: invalid', + '3. key3: invalid', + '+2 more issue(s)', ]); - - assert.match(details, /SubMiner detected config validation issues\./); - assert.match(details, /File: \/tmp\/config\.jsonc/); - assert.match(details, /1\. ankiConnect\.pollingRate: must be >= 50/); - assert.match(details, /actual=10 fallback=250/); -}); - -test('buildConfigParseErrorDetails includes path error and restart guidance', () => { - const details = buildConfigParseErrorDetails('/tmp/config.jsonc', 'unexpected token at line 1'); - - assert.match(details, /Failed to parse config file at:/); - assert.match(details, /\/tmp\/config\.jsonc/); - assert.match(details, /Error: unexpected token at line 1/); - assert.match(details, /Fix the config file and restart SubMiner\./); }); test('failStartupFromConfig invokes handlers and throws', () => { diff --git a/src/main/controller-config-update.test.ts b/src/main/controller-config-update.test.ts index 0e30e401..b13b02d5 100644 --- a/src/main/controller-config-update.test.ts +++ b/src/main/controller-config-update.test.ts @@ -1,18 +1,8 @@ import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; import test from 'node:test'; import { applyControllerConfigUpdate } from './controller-config-update.js'; -test('SM-012 controller config update path does not use JSON serialize-clone helpers', () => { - const source = fs.readFileSync( - path.join(process.cwd(), 'src/main/controller-config-update.ts'), - 'utf-8', - ); - assert.equal(source.includes('JSON.parse(JSON.stringify('), false); -}); - test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => { const next = applyControllerConfigUpdate( { diff --git a/src/main/electron-runtime-guard.test.ts b/src/main/electron-runtime-guard.test.ts index b069dc4f..fda50a66 100644 --- a/src/main/electron-runtime-guard.test.ts +++ b/src/main/electron-runtime-guard.test.ts @@ -15,12 +15,13 @@ function withTempDir(run: (directory: string) => void): void { } test('runtime guard major matches the pinned Electron dependency', () => { + // A stale constant would make the guard reject the bundled runtime after an Electron bump. const packageJson = JSON.parse( fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'), ) as { devDependencies: { electron: string } }; + const pinnedMajor = Number.parseInt(packageJson.devDependencies.electron.replace(/^\D*/, ''), 10); - assert.equal(Number.parseInt(packageJson.devDependencies.electron.split('.', 1)[0]!, 10), 43); - assert.equal(SUPPORTED_ELECTRON_MAJOR, 43); + assert.equal(SUPPORTED_ELECTRON_MAJOR, pinnedMajor); }); test('runtime guard records the supported Electron major', () => { @@ -155,28 +156,6 @@ test('runtime guard allows a patch downgrade while preserving minor downgrade pr }); }); -test('runtime guard blocks an Electron 42 build from reopening an Electron 43 profile', () => { - withTempDir((userDataPath) => { - const statePath = path.join(userDataPath, 'electron-runtime.json'); - const previousState = JSON.stringify({ - highestElectronMajor: 43, - lastElectronVersion: '43.7.2', - }); - fs.writeFileSync(statePath, previousState, 'utf8'); - - const result = enforceElectronRuntimeGuard({ - electronVersion: '42.11.8', - userDataPath, - supportedElectronMajor: 42, - }); - - assert.equal(result.ok, false); - if (result.ok) return; - assert.equal(result.title, 'Electron downgrade blocked'); - assert.equal(fs.readFileSync(statePath, 'utf8'), previousState); - }); -}); - test('runtime guard fails closed when its safety record is malformed', () => { withTempDir((userDataPath) => { const statePath = path.join(userDataPath, 'electron-runtime.json'); diff --git a/src/main/main-runtime-direct-wiring.test.ts b/src/main/main-runtime-direct-wiring.test.ts deleted file mode 100644 index d1cdd468..00000000 --- a/src/main/main-runtime-direct-wiring.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import ts from 'typescript'; - -test('main process call sites bypass zero-logic pass-through wrappers', () => { - const sourcePath = path.join(process.cwd(), 'src/main.ts'); - const source = fs.readFileSync(sourcePath, 'utf8'); - const sourceFile = ts.createSourceFile( - sourcePath, - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); - const offenders: string[] = []; - - for (const statement of sourceFile.statements) { - if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) { - if (isRuntimePassthrough(statement, statement.body)) { - offenders.push(statement.name.text); - } - continue; - } - - if (!ts.isVariableStatement(statement)) continue; - for (const declaration of statement.declarationList.declarations) { - if ( - ts.isIdentifier(declaration.name) && - declaration.initializer && - (ts.isArrowFunction(declaration.initializer) || - ts.isFunctionExpression(declaration.initializer)) && - isRuntimePassthrough(declaration.initializer, declaration.initializer.body) - ) { - offenders.push(declaration.name.text); - } - } - } - - assert.deepEqual(offenders, []); -}); - -function isRuntimePassthrough( - callable: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression, - body: ts.ConciseBody, -): boolean { - let expression: ts.Expression | undefined; - if (!ts.isBlock(body)) { - expression = body; - } else { - if (body.statements.length !== 1) return false; - const statement = body.statements[0]!; - if (ts.isExpressionStatement(statement)) expression = statement.expression; - if (ts.isReturnStatement(statement)) expression = statement.expression; - } - if (!expression) return false; - if (ts.isAwaitExpression(expression) || ts.isVoidExpression(expression)) { - expression = expression.expression; - } - if (!ts.isCallExpression(expression)) return false; - - const root = getCalleeRoot(expression.expression); - if (!root) return false; - if (callable.parameters.length !== expression.arguments.length) return false; - - return callable.parameters.every((parameter, index) => { - if (!ts.isIdentifier(parameter.name)) return false; - const argument = expression.arguments[index]; - if (!argument) return false; - if (parameter.dotDotDotToken) { - if (!ts.isSpreadElement(argument)) return false; - const spreadExpression = unwrapTransparentExpression(argument.expression); - return ts.isIdentifier(spreadExpression) && spreadExpression.text === parameter.name.text; - } - const argumentExpression = unwrapTransparentExpression(argument); - return ts.isIdentifier(argumentExpression) && argumentExpression.text === parameter.name.text; - }); -} - -function unwrapTransparentExpression(expression: ts.Expression): ts.Expression { - let current = expression; - while ( - ts.isParenthesizedExpression(current) || - ts.isAsExpression(current) || - ts.isNonNullExpression(current) - ) { - current = current.expression; - } - return current; -} - -function getCalleeRoot(expression: ts.LeftHandSideExpression): string { - let current: ts.Expression = expression; - while (ts.isPropertyAccessExpression(current) || ts.isCallExpression(current)) { - current = current.expression; - } - return ts.isIdentifier(current) ? current.text : ''; -} diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 63f67012..031db5ea 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -1,3 +1,7 @@ +// Interim source-text guards: each test regexes src/main.ts (or a runtime file) to +// pin a startup/overlay invariant that main.ts closures make hard to exercise. +// Replace each with a behavioral test as the logic moves out of main.ts closures +// (see docs/workflow/testing.md, Known Debt). Do not add new tests here. import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -50,16 +54,6 @@ test('media path changes clear rendered subtitle state without clearing same-you ); }); -test('media path changes start the YouTube media cache coordinator', () => { - const source = readMainSource(); - const actionBlock = source.match( - /updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?[\s\S]*?)\n restoreMpvSubVisibility:/, - )?.groups?.body; - - assert.ok(actionBlock); - assert.match(actionBlock, /youtubeMediaCachePlaybackRuntime\.handleMediaPathChange\(path\);/); -}); - test('same media path updates do not reset autoplay ready fallback state', () => { const source = readMainSource(); const actionBlock = source.match( @@ -104,19 +98,6 @@ test('mpv startup signals start overlay loading OSD before readiness work', () = ); }); -test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => { - const source = readSource('src/main/runtime/overlay-notifications-runtime.ts'); - const dismissBlock = source.match( - /function dismissOverlayLoadingStatusNotification\(\): void \{(?[\s\S]*?)\n \}/, - )?.groups?.body; - - assert.ok(dismissBlock); - assert.match( - dismissBlock, - /sendMpvCommandRuntime\(deps\.getMpvClient\(\), \[\s*'script-message',\s*'subminer-overlay-loading-ready',\s*\]\);/, - ); -}); - test('manual visible overlay toggles only release current-media autoplay when hiding', () => { const source = readMainSource(); const actionBlock = source.match( @@ -157,6 +138,19 @@ test('all visible overlay hide paths clear stale overlay input state', () => { setOverlayBlock, /if \(!visible\) \{[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?resetVisibleOverlayInputState\(\);[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, ); + // Hiding must also dismiss the loading OSD, or it lingers over mpv. + assert.match( + setVisibleBlock, + /if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/, + ); + assert.match( + toggleBlock, + /if \(!nextVisible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/, + ); + assert.match( + setOverlayBlock, + /if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/, + ); }); test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => { @@ -176,56 +170,6 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () = ); }); -test('remote media keeps parsed cues when the active subtitle source cannot be resolved', () => { - const source = readMainSource(); - const actionBlock = source.match( - /createRefreshSubtitlePrefetchFromActiveTrackHandler\(\{(?[\s\S]*?)\n \}\);/, - )?.groups?.body; - - assert.ok(actionBlock); - assert.match( - actionBlock, - /isYoutubeMediaPath\(videoPath\) \|\| \(await detectRemoteMediaPath\(videoPath\)\)/, - ); -}); - -test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => { - const source = readMainSource(); - const actionBlock = source.match( - /preloadJellyfinExternalSubtitlesMainDeps:\s*\{(?[\s\S]*?)\n \},/, - )?.groups?.body; - - assert.ok(actionBlock); - assert.match( - actionBlock, - /initSubtitlePrefetch: \(sourcePath\) =>\s*subtitlePrefetchRuntime\.refreshSubtitleSidebarFromSource\(sourcePath\),/, - ); -}); - -test('update overlay notification action triggers install flow', () => { - const source = readMainSource(); - const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts'); - - assert.match( - source, - /handleOverlayNotificationAction:\s*\(notificationId,\s*actionId,\s*noteId\)\s*=>/, - ); - assert.match(source, /notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID/); - assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/); - assert.match(source, /installWhenAvailable:\s*true/); - assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/); - assert.match(runtimeSource, /deps\.getAnkiIntegration\(\)\?\.openNoteInAnki\(noteId\)/); - assert.match( - runtimeSource, - /deps\.getRuntimeOptionsManager\(\)\?\.getEffectiveAnkiConnectConfig/, - ); - assert.match( - runtimeSource, - /new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/, - ); - assert.match(runtimeSource, /fallbackClient\.openNoteInBrowser\(noteId\)/); -}); - test('subtitle change pauses prefetch without restarting its run before tokenizing current line', () => { const source = readMainSource(); const actionBlock = source.match( @@ -263,8 +207,6 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o ); assert.match(actionBlock, /if \(cachedPayload\) \{/); assert.match(actionBlock, /emitSubtitlePayload\(cachedPayload\);/); - assert.doesNotMatch(actionBlock, /withCurrentSubtitleTiming\(\{ text, tokens: null \}\)/); - assert.doesNotMatch(actionBlock, /broadcastToOverlayWindows\('subtitle:set', rawPayload\)/); assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/); assert.ok( actionBlock.indexOf('consumeCachedSubtitle(text)') < @@ -272,31 +214,6 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o ); }); -test('autoplay subtitle prime reuses active parsed cues before synthetic warm release', () => { - const source = readMainSource(); - const runtimeDepsBlock = source.match( - /const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime\(\{(?[\s\S]*?)\n\}\);/, - )?.groups?.body; - const primeSource = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts'); - const emptyTextBlock = primeSource.match( - /if \(!text\.trim\(\) && isCurrentAutoplayMediaPath\(mediaPath\)\) \{(?[\s\S]*?)\n \}/, - )?.groups?.body; - - assert.ok(runtimeDepsBlock); - assert.match( - runtimeDepsBlock, - /getActiveParsedSubtitleCues:\s*\(\) => appState\.activeParsedSubtitleCues/, - ); - - assert.ok(emptyTextBlock); - assert.ok( - emptyTextBlock.indexOf('await deps.refreshSubtitlePrefetchFromActiveTrack()') < - emptyTextBlock.indexOf( - 'await primeAutoplaySubtitleFromParsedCues(mediaPath, deps.getActiveParsedSubtitleCues())', - ), - ); -}); - test('startup autoplay release is tied to visible overlay measurement readiness', () => { const source = readMainSource(); const gateBlock = source.match( @@ -509,20 +426,6 @@ test('subtitle annotation updates invalidate prefetched tokenizations before ref ); }); -test('character portrait index readiness refreshes cached subtitle annotations', () => { - const source = readMainSource(); - const lookupDeps = source.match( - /const characterDictionaryImageLookup = createCharacterDictionaryImageLookup\(\{(?[\s\S]*?)\n\}\);/, - )?.groups?.body; - - assert.ok(lookupDeps); - assert.match(lookupDeps, /onIndexReady: \(\) => refreshCurrentSubtitleAnnotations\(\),/); - assert.match( - lookupDeps, - /onIndexReadyError: \(error\) =>[\s\S]*?logger\.warn\([\s\S]*?character portrait index became ready\.[\s\S]*?error,/, - ); -}); - test('subtitle processing controller resumes prefetch on settle, not on its emits', () => { const source = readMainSource(); const depsBlock = source.match( @@ -542,120 +445,6 @@ test('subtitle processing controller resumes prefetch on settle, not on its emit ); }); -test('manual visible overlay changes notify mpv plugin visibility state', () => { - const source = readMainSource(); - const setBlock = source.match( - /function setVisibleOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - const toggleBlock = source.match( - /function toggleVisibleOverlay\(\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - - assert.ok(setBlock); - assert.ok(toggleBlock); - assert.match(setBlock, /notifyMpvPluginVisibleOverlayVisibility\(visible\);/); - assert.match(toggleBlock, /const nextVisible = !overlayManager\.getVisibleOverlayVisible\(\);/); - assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/); -}); - -test('manual visible overlay hide dismisses loading OSD', () => { - const source = readMainSource(); - const setBlock = source.match( - /function setVisibleOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - const toggleBlock = source.match( - /function toggleVisibleOverlay\(\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - const setOverlayBlock = source.match( - /function setOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - - assert.ok(setBlock); - assert.ok(toggleBlock); - assert.ok(setOverlayBlock); - assert.match(setBlock, /if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/); - assert.match( - toggleBlock, - /if \(!nextVisible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/, - ); - assert.match( - setOverlayBlock, - /if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/, - ); -}); - -test('configured overlay notifications require visible ready overlay window', () => { - const source = readSource('src/main/runtime/overlay-notifications-runtime.ts'); - const readinessBlock = source.match( - /function isVisibleOverlayContentReady\(\): boolean \{(?[\s\S]*?)\n \}/, - )?.groups?.body; - const statusBlock = source.match( - /function showConfiguredStatusNotification\([\s\S]*?\): void \{(?[\s\S]*?)\n \}/, - )?.groups?.body; - - assert.ok(readinessBlock); - assert.ok(statusBlock); - assert.match(readinessBlock, /deps\.getVisibleOverlayVisible\(\)/); - assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/); - assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/); - assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/); -}); - -test('YouTube media cache lifecycle routes through configured status notifications', () => { - const source = readMainSource(); - const cacheBlock = source.match( - /const youtubeMediaCache = createYoutubeMediaCacheService\(\{(?[\s\S]*?)\n\}\);\nconst waitForYoutubeMpvConnected/, - )?.groups?.body; - const startCacheBlock = source.match( - /startYoutubeMediaCache:\s*\(url\)\s*=>\s*\{(?[\s\S]*?)\n \},\n runYoutubePlaybackFlow/, - )?.groups?.body; - - assert.ok(cacheBlock); - assert.ok(startCacheBlock); - assert.match( - cacheBlock, - /onDownloadStarted:\s*\(event\)\s*=>\s*\{[\s\S]*showConfiguredStatusNotification\(\s*'YouTube media cache is downloading\.'/, - ); - assert.match(cacheBlock, /id:\s*'youtube-media-cache-status'/); - assert.match(cacheBlock, /variant:\s*'progress'/); - assert.match(cacheBlock, /persistent:\s*true/); - assert.match( - cacheBlock, - /onReady:\s*\(event\)\s*=>\s*\{[\s\S]*showConfiguredStatusNotification\(\s*'YouTube media cache ready\.'/, - ); - assert.match(cacheBlock, /variant:\s*'success'/); - assert.match(cacheBlock, /notifyNoQueued:\s*false/); - assert.match( - startCacheBlock, - /const mediaCacheConfig = configService\.getConfig\(\)\.youtube\.mediaCache;/, - ); - assert.match(startCacheBlock, /mode:\s*mediaCacheConfig\.mode/); - assert.match(startCacheBlock, /maxHeight:\s*mediaCacheConfig\.maxHeight/); -}); - -test('subtitle broadcasts share one frequency options snapshot per emitted payload', () => { - const source = readMainSource(); - const emitBlock = source.match( - /function emitSubtitlePayload\([\s\S]*?\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - const frequencyOptionsSnapshot = emitBlock?.match( - /const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;(?[\s\S]*?)\n \};/, - )?.[0]; - - assert.ok(emitBlock); - assert.ok(frequencyOptionsSnapshot); - assert.match(frequencyOptionsSnapshot, /const frequencyOptions = \{/); - assert.match(frequencyOptionsSnapshot, /enabled:\s*frequencyDictionary\.enabled/); - assert.match(frequencyOptionsSnapshot, /topX:\s*frequencyDictionary\.topX/); - assert.match(frequencyOptionsSnapshot, /mode:\s*frequencyDictionary\.mode/); - assert.equal((frequencyOptionsSnapshot.match(/configService\.getConfig\(\)/g) ?? []).length, 1); - assert.match(emitBlock, /subtitleWsService\.broadcast\(timedPayload, frequencyOptions\);/); - assert.match( - emitBlock, - /annotationSubtitleWsService\.broadcast\(timedPayload, frequencyOptions\);/, - ); -}); - test('annotation upgrades skip the duplicate basic websocket event', () => { const source = readMainSource(); const emitBlock = source.match( @@ -685,31 +474,6 @@ test('annotation upgrades skip the duplicate basic websocket event', () => { ); }); -test('websocket frequency options callbacks each read one configuration snapshot', () => { - const source = readMainSource(); - const subtitleBlock = source.match( - /startSubtitleWebsocket:\s*\(port: number\)\s*=>\s*\{(?[\s\S]*?)\n \},\n startAnnotationWebsocket:/, - )?.groups?.body; - const annotationBlock = source.match( - /startAnnotationWebsocket:\s*\(port: number\)\s*=>\s*\{(?[\s\S]*?)\n \},\n startTexthooker:/, - )?.groups?.body; - const subtitleFrequencyOptionsCallback = subtitleBlock?.match( - /(?\(\) => \{\s+const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;[\s\S]*?\s+return \{[\s\S]*?\s+\};\s+\})/, - )?.groups?.body; - const annotationFrequencyOptionsCallback = annotationBlock?.match( - /(?\(\) => \{\s+const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;[\s\S]*?\s+return \{[\s\S]*?\s+\};\s+\})/, - )?.groups?.body; - - assert.ok(subtitleFrequencyOptionsCallback); - assert.ok(annotationFrequencyOptionsCallback); - for (const callback of [subtitleFrequencyOptionsCallback, annotationFrequencyOptionsCallback]) { - assert.match(callback, /return \{[\s\S]*enabled:\s*frequencyDictionary\.enabled/); - assert.match(callback, /topX:\s*frequencyDictionary\.topX/); - assert.match(callback, /mode:\s*frequencyDictionary\.mode/); - assert.equal((callback.match(/configService\.getConfig\(\)/g) ?? []).length, 1); - } -}); - test('mpv connection flushes queued configured OSD notifications', () => { const source = readMainSource(); const connectedBlock = source.match( @@ -721,48 +485,6 @@ test('mpv connection flushes queued configured OSD notifications', () => { assert.match(connectedBlock, /flushQueuedMpvOsdNotifications\(\);/); }); -test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => { - const source = readMainSource(); - const setBlock = source.match( - /function setVisibleOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - const toggleBlock = source.match( - /function toggleVisibleOverlay\(\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - - assert.ok(setBlock); - assert.ok(toggleBlock); - assert.match( - setBlock, - /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void autoplaySubtitlePrimingRuntime\.primeCurrentSubtitleForVisibleOverlay\(\);/, - ); - assert.match( - toggleBlock, - /else \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void autoplaySubtitlePrimingRuntime\.primeCurrentSubtitleForVisibleOverlay\(\);/, - ); -}); - -test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => { - const source = readMainSource(); - const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts'); - const resetBlock = runtimeSource.match( - /function resetVisibleOverlayInputState\(\): void \{(?[\s\S]*?)\n \}/, - )?.groups?.body; - const setBlock = source.match( - /function setVisibleOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, - )?.groups?.body; - - assert.ok(resetBlock); - assert.ok(setBlock); - assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/); - assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/); - assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/); - assert.match( - setBlock, - /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/, - ); -}); - test('Linux visible overlay startup reapplies passive passthrough after input reset', () => { const pointerSource = readSource('src/main/runtime/linux-overlay-pointer-interaction.ts'); const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts'); @@ -841,16 +563,6 @@ test('Linux visible overlay bounds refresh restores X11 shape after applying mpv ); }); -test('main process uses one shared mpv plugin runtime config helper', () => { - const source = readMainSource(); - assert.match(source, /function getMpvPluginRuntimeConfig\(\)/); - assert.equal((source.match(/socketPath: appState\.mpvSocketPath/g) ?? []).length, 1); - assert.equal( - (source.match(/binaryPath: getResolvedConfig\(\)\.mpv\.subminerBinaryPath/g) ?? []).length, - 0, - ); -}); - test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active-source parsing', () => { const source = readMainSource(); const snapshotBlock = source.match( @@ -866,19 +578,3 @@ test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'), ); }); - -test('main process extracts internal subtitle tracks without a network-mount guard', () => { - const source = readMainSource(); - const resolverWiring = source.match( - /const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler\(\{(?[\s\S]*?)\n\}\);/, - )?.groups?.body; - - assert.ok(resolverWiring); - // Network-mounted files are extracted like local ones; only remote URLs skip - // extraction, handled inside the resolver itself. - assert.doesNotMatch(resolverWiring, /isRemoteMediaPath/); - assert.match( - resolverWiring, - /extractInternalSubtitleTrack:[\s\S]*cachedInternalSubtitleTrackExtractor\.extract/, - ); -}); diff --git a/src/main/overlay-runtime-test-harness.ts b/src/main/overlay-runtime-test-harness.ts new file mode 100644 index 00000000..62ef889f --- /dev/null +++ b/src/main/overlay-runtime-test-harness.ts @@ -0,0 +1,190 @@ +import type { WindowGeometry } from '../types'; +import { + OVERLAY_WINDOW_CONTENT_READY_FLAG, + OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG, +} from '../core/services/overlay-window-flags'; +import { + createOverlayModalRuntimeService, + type OverlayModalRuntimeOptions, + type OverlayWindowResolver, +} from './overlay-runtime'; + +export const MODAL_URL = 'file:///overlay/index.html?layer=modal'; +export const MODAL_GEOMETRY: WindowGeometry = { x: 0, y: 0, width: 400, height: 300 }; + +export interface FakeOverlayWindowInit { + // Prefix for show/hide entries pushed to `log`, e.g. `modal:show`. + name?: string; + log?: string[]; + visible?: boolean; + focused?: boolean; + webContentsFocused?: boolean; + ignoreMouseEvents?: boolean; + loading?: boolean; + url?: string; + contentReady?: boolean; + documentLoaded?: boolean; +} + +// A still-loading window whose document has not been committed yet. +export const LOADING_BLANK_WINDOW: FakeOverlayWindowInit = { + loading: true, + url: '', + contentReady: false, +}; + +/** + * BrowserWindow stand-in covering the surface overlay-runtime touches. Defaults to a hidden, + * fully loaded, content-ready modal window. + */ +export class FakeOverlayWindow { + destroyed = false; + visible = false; + focused = false; + webContentsFocused = false; + ignoreMouseEvents = false; + forwardedIgnoreMouseEvents = false; + loading = false; + url = MODAL_URL; + alwaysOnTopCalls: string[] = []; + showCount = 0; + hideCount = 0; + sent: unknown[][] = []; + [OVERLAY_WINDOW_CONTENT_READY_FLAG] = true; + [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] = true; + + private readonly name: string; + private readonly log: string[] | undefined; + private loadListeners: Array<() => void> = []; + private readyToShowListeners: Array<() => void> = []; + + readonly webContents = { + isLoading: (): boolean => this.loading, + getURL: (): string => this.url, + send: (channel: string, ...payload: unknown[]): void => { + this.sent.push([channel, ...payload]); + }, + isFocused: (): boolean => this.webContentsFocused, + focus: (): void => { + this.webContentsFocused = true; + }, + once: (_event: 'did-finish-load', listener: () => void): void => { + this.loadListeners.push(listener); + }, + }; + + constructor(init: FakeOverlayWindowInit = {}) { + const { name = 'window', log, contentReady = true, documentLoaded = true, ...fields } = init; + this.name = name; + this.log = log; + Object.assign(this, fields); + this[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady; + this[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] = documentLoaded; + } + + isDestroyed(): boolean { + return this.destroyed; + } + + isVisible(): boolean { + return this.visible; + } + + isFocused(): boolean { + return this.focused; + } + + setIgnoreMouseEvents(ignore: boolean, options?: { forward?: boolean }): void { + this.ignoreMouseEvents = ignore; + this.forwardedIgnoreMouseEvents = options?.forward === true; + } + + setAlwaysOnTop(flag: boolean, level?: string, relativeLevel?: number): void { + this.alwaysOnTopCalls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`); + } + + moveTop(): void {} + + show(): void { + this.log?.push(`${this.name}:show`); + this.visible = true; + this.showCount += 1; + } + + showInactive(): void { + this.log?.push(`${this.name}:show-inactive`); + this.visible = true; + this.showCount += 1; + } + + hide(): void { + this.log?.push(`${this.name}:hide`); + this.visible = false; + this.hideCount += 1; + } + + destroy(): void { + this.destroyed = true; + this.visible = false; + } + + focus(): void { + this.focused = true; + } + + once(_event: 'ready-to-show', listener: () => void): void { + this.readyToShowListeners.push(listener); + } + + // Commits the modal document and fires the queued did-finish-load listeners. + finishLoad(): void { + this.loading = false; + this.url = MODAL_URL; + this[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] = true; + for (const listener of this.loadListeners.splice(0)) listener(); + } + + // Marks renderer content ready and fires the queued ready-to-show listeners. + emitReadyToShow(): void { + this[OVERLAY_WINDOW_CONTENT_READY_FLAG] = true; + for (const listener of this.readyToShowListeners.splice(0)) listener(); + } +} + +export interface OverlayRuntimeHarnessInit { + main?: FakeOverlayWindow | null; + modal?: FakeOverlayWindow | null; + // Called by the runtime's createModalWindow; its result becomes the current modal window. + createModal?: () => FakeOverlayWindow | null; + // Defaults to linux so behavior does not depend on the host running the tests. + platform?: NodeJS.Platform; + options?: Omit; + setModalWindowBounds?: OverlayWindowResolver['setModalWindowBounds']; +} + +/** Builds the modal runtime over fake windows and records resolver traffic. */ +export function createHarness(init: OverlayRuntimeHarnessInit = {}) { + const harness = { + modal: init.modal ?? null, + createCalls: 0, + boundsCalls: [] as WindowGeometry[], + }; + const runtime = createOverlayModalRuntimeService( + { + getMainWindow: () => (init.main ?? null) as never, + getModalWindow: () => harness.modal as never, + createModalWindow: () => { + harness.createCalls += 1; + harness.modal = init.createModal?.() ?? null; + return harness.modal as never; + }, + getModalGeometry: () => MODAL_GEOMETRY, + setModalWindowBounds: (geometry) => { + harness.boundsCalls.push(geometry); + return init.setModalWindowBounds?.(geometry); + }, + }, + { ...init.options, platform: init.platform ?? 'linux' }, + ); + return Object.assign(harness, { runtime }); +} diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index 2190ad28..60152803 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -1,638 +1,362 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createOverlayModalRuntimeService } from './overlay-runtime'; +import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement'; +import { + FakeOverlayWindow, + LOADING_BLANK_WINDOW, + MODAL_GEOMETRY, + createHarness, + type FakeOverlayWindowInit, +} from './overlay-runtime-test-harness'; +import type { OverlayHostedModal } from '../shared/ipc/contracts'; -type MockWindow = { - destroyed: boolean; - visible: boolean; - focused: boolean; - ignoreMouseEvents: boolean; - forwardedIgnoreMouseEvents: boolean; - webContentsFocused: boolean; - alwaysOnTopCalls: string[]; - showCount: number; - hideCount: number; - sent: unknown[][]; - loading: boolean; - url: string; - contentReady: boolean; - documentLoaded: boolean; - loadCallbacks: Array<() => void>; - readyToShowCallbacks: Array<() => void>; +const SUBSYNC_PAYLOAD = { + ffsubsyncAvailable: true, + videoReferenceAvailable: true, + subtitleTracks: [], + defaultReferenceTrackId: null, + defaultTargetTrackId: null, }; -function createMockWindow(): MockWindow & { - isDestroyed: () => boolean; - isVisible: () => boolean; - isFocused: () => boolean; - getURL: () => string; - setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; - setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => void; - moveTop: () => void; - getShowCount: () => number; - getHideCount: () => number; - show: () => void; - showInactive: () => void; - hide: () => void; - destroy: () => void; - focus: () => void; - emitDidFinishLoad: () => void; - emitReadyToShow: () => void; - once: (event: 'ready-to-show', cb: () => void) => void; - webContents: { - focused: boolean; - isLoading: () => boolean; - getURL: () => string; - send: (channel: string, payload?: unknown) => void; - isFocused: () => boolean; - once: (event: 'did-finish-load', cb: () => void) => void; - focus: () => void; +const PENDING_PLACEMENT: HyprlandPlacementStatus = { + applicable: true, + clientFound: false, + dispatched: false, +}; + +type FakeTimer = { callback: () => void; active: boolean; unref: () => void }; + +/** + * Swaps the global setTimeout/clearTimeout for a manual queue while `run` executes. The + * placement reconcile ladder calls the globals directly, so there is no option to inject. + */ +function withFakeTimers( + run: (timers: { runNext: () => boolean; runAll: () => void; activeCount: () => number }) => void, +): void { + const timers: FakeTimer[] = []; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + globalThis.setTimeout = ((callback: () => void) => { + const timer: FakeTimer = { callback, active: true, unref() {} }; + timers.push(timer); + return timer; + }) as unknown as typeof globalThis.setTimeout; + globalThis.clearTimeout = ((timer?: FakeTimer) => { + if (timer) timer.active = false; + }) as unknown as typeof globalThis.clearTimeout; + + const runNext = (): boolean => { + const timer = timers.find((candidate) => candidate.active); + if (!timer) return false; + timer.active = false; + timer.callback(); + return true; }; -} { - const state: MockWindow = { - destroyed: false, - visible: false, - focused: false, - ignoreMouseEvents: false, - forwardedIgnoreMouseEvents: false, - webContentsFocused: false, - alwaysOnTopCalls: [], - showCount: 0, - hideCount: 0, - sent: [], - loading: false, - url: 'file:///overlay/index.html?layer=modal', - contentReady: true, - documentLoaded: true, - loadCallbacks: [], - readyToShowCallbacks: [], - }; - const window = { - ...state, - isDestroyed: () => state.destroyed, - isVisible: () => state.visible, - isFocused: () => state.focused, - getURL: () => state.url, - setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { - state.ignoreMouseEvents = ignore; - state.forwardedIgnoreMouseEvents = options?.forward === true; - }, - setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => { - state.alwaysOnTopCalls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`); - }, - moveTop: () => {}, - getShowCount: () => state.showCount, - getHideCount: () => state.hideCount, - show: () => { - state.visible = true; - state.showCount += 1; - }, - showInactive: () => { - state.visible = true; - state.showCount += 1; - }, - hide: () => { - state.visible = false; - state.hideCount += 1; - }, - destroy: () => { - state.destroyed = true; - state.visible = false; - }, - focus: () => { - state.focused = true; - }, - emitDidFinishLoad: () => { - state.documentLoaded = true; - ( - window as typeof window & { __subminerOverlayDocumentLoaded?: boolean } - ).__subminerOverlayDocumentLoaded = true; - const callbacks = state.loadCallbacks.splice(0); - for (const callback of callbacks) { - callback(); - } - }, - emitReadyToShow: () => { - const callbacks = state.readyToShowCallbacks.splice(0); - for (const callback of callbacks) { - callback(); - } - }, - once: (_event: 'ready-to-show', cb: () => void) => { - state.readyToShowCallbacks.push(cb); - }, - webContents: { - isLoading: () => state.loading, - getURL: () => state.url, - send: (channel: string, payload?: unknown) => { - if (payload === undefined) { - state.sent.push([channel]); - return; + try { + run({ + runNext, + runAll: () => { + for (let ran = 0; runNext(); ran += 1) { + assert.ok(ran < 50, 'timer queue did not drain'); } - state.sent.push([channel, payload]); }, - focused: false, - isFocused: () => state.webContentsFocused, - once: (_event: 'did-finish-load', cb: () => void) => { - state.loadCallbacks.push(cb); - }, - focus: () => { - state.webContentsFocused = true; - }, - }, - }; - - Object.defineProperty(window, 'loading', { - get: () => state.loading, - set: (value: boolean) => { - state.loading = value; - }, - }); - - Object.defineProperty(window, 'visible', { - get: () => state.visible, - set: (value: boolean) => { - state.visible = value; - }, - }); - - Object.defineProperty(window, 'focused', { - get: () => state.focused, - set: (value: boolean) => { - state.focused = value; - }, - }); - - Object.defineProperty(window, 'webContentsFocused', { - get: () => state.webContentsFocused, - set: (value: boolean) => { - state.webContentsFocused = value; - }, - }); - - Object.defineProperty(window, 'alwaysOnTopCalls', { - get: () => state.alwaysOnTopCalls, - set: (value: string[]) => { - state.alwaysOnTopCalls = value; - }, - }); - - Object.defineProperty(window, 'url', { - get: () => state.url, - set: (value: string) => { - state.url = value; - }, - }); - - Object.defineProperty(window, 'ignoreMouseEvents', { - get: () => state.ignoreMouseEvents, - set: (value: boolean) => { - state.ignoreMouseEvents = value; - }, - }); - - Object.defineProperty(window, 'forwardedIgnoreMouseEvents', { - get: () => state.forwardedIgnoreMouseEvents, - set: (value: boolean) => { - state.forwardedIgnoreMouseEvents = value; - }, - }); - - Object.defineProperty(window, 'contentReady', { - get: () => state.contentReady, - set: (value: boolean) => { - state.contentReady = value; - ( - window as typeof window & { __subminerOverlayContentReady?: boolean } - ).__subminerOverlayContentReady = value; - }, - }); - - Object.defineProperty(window, 'documentLoaded', { - get: () => state.documentLoaded, - set: (value: boolean) => { - state.documentLoaded = value; - ( - window as typeof window & { __subminerOverlayDocumentLoaded?: boolean } - ).__subminerOverlayDocumentLoaded = value; - }, - }); - - ( - window as typeof window & { __subminerOverlayContentReady?: boolean } - ).__subminerOverlayContentReady = state.contentReady; - ( - window as typeof window & { __subminerOverlayDocumentLoaded?: boolean } - ).__subminerOverlayDocumentLoaded = state.documentLoaded; - - return window; + activeCount: () => timers.filter((timer) => timer.active).length, + }); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } } test('sendToActiveOverlayWindow targets modal window with full geometry and tracks close restore', () => { - const window = createMockWindow(); - const calls: string[] = []; - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => { - calls.push('create-modal-window'); - return window as never; - }, - getModalGeometry: () => ({ x: 10, y: 20, width: 300, height: 200 }), - setModalWindowBounds: (geometry) => { - calls.push(`bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`); - }, - }); + const modal = new FakeOverlayWindow(); + const h = createHarness({ modal }); - const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + const sent = h.runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', }); assert.equal(sent, true); - assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().has('runtime-options'), true); - assert.deepEqual(calls, ['bounds:10,20,300,200']); - assert.equal(window.getShowCount(), 0); - runtime.notifyOverlayModalOpened('runtime-options'); - assert.equal(window.getShowCount(), 1); - assert.equal(window.isFocused(), true); - assert.deepEqual(calls, ['bounds:10,20,300,200', 'bounds:10,20,300,200']); - assert.deepEqual(window.alwaysOnTopCalls, ['top:true:screen-saver:3']); - assert.deepEqual(window.sent, [['runtime-options:open']]); + assert.equal(h.runtime.getRestoreVisibleOverlayOnModalClose().has('runtime-options'), true); + assert.deepEqual(h.boundsCalls, [MODAL_GEOMETRY]); + assert.equal(modal.showCount, 0); + + h.runtime.notifyOverlayModalOpened('runtime-options'); + assert.equal(h.createCalls, 0); + assert.equal(modal.showCount, 1); + assert.equal(modal.isFocused(), true); + assert.deepEqual(h.boundsCalls, [MODAL_GEOMETRY, MODAL_GEOMETRY]); + assert.deepEqual(modal.alwaysOnTopCalls, ['top:true:screen-saver:3']); + assert.deepEqual(modal.sent, [['runtime-options:open']]); }); test('sendToActiveOverlayWindow creates modal window lazily when absent', () => { - const window = createMockWindow(); - let modalWindow: ReturnType | null = null; - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => modalWindow as never, - createModalWindow: () => { - modalWindow = window; - return modalWindow as never; - }, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); + const modal = new FakeOverlayWindow(); + const h = createHarness({ createModal: () => modal }); assert.equal( - runtime.sendToActiveOverlayWindow('jimaku:open', undefined, { restoreOnModalClose: 'jimaku' }), + h.runtime.sendToActiveOverlayWindow('jimaku:open', undefined, { + restoreOnModalClose: 'jimaku', + }), true, ); - assert.equal(window.getShowCount(), 0); - runtime.notifyOverlayModalOpened('jimaku'); - assert.equal(window.getShowCount(), 1); - assert.deepEqual(window.sent, [['jimaku:open']]); + assert.equal(h.createCalls, 1); + assert.equal(modal.showCount, 0); + h.runtime.notifyOverlayModalOpened('jimaku'); + assert.equal(modal.showCount, 1); + assert.deepEqual(modal.sent, [['jimaku:open']]); }); for (const platform of ['darwin', 'win32'] as const) { test(`primeModalWindow creates and warms a hidden modal on ${platform}`, () => { - const modalWindow = createMockWindow(); - modalWindow.loading = true; - modalWindow.url = ''; - modalWindow.contentReady = false; - modalWindow.documentLoaded = false; - let currentModal: ReturnType | null = null; - let createCalls = 0; - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => null, - getModalWindow: () => currentModal as never, - createModalWindow: () => { - createCalls += 1; - currentModal = modalWindow; - return modalWindow as never; - }, - getModalGeometry: () => ({ x: 1, y: 2, width: 300, height: 200 }), - setModalWindowBounds: () => {}, - }, - { platform }, - ); + const modal = new FakeOverlayWindow({ ...LOADING_BLANK_WINDOW, documentLoaded: false }); + const h = createHarness({ createModal: () => modal, platform }); - assert.equal(runtime.primeModalWindow(), true); - assert.equal(createCalls, 1); - assert.equal(modalWindow.isVisible(), false); + assert.equal(h.runtime.primeModalWindow(), true); + assert.equal(h.createCalls, 1); + assert.equal(modal.isVisible(), false); - modalWindow.loading = false; - modalWindow.url = 'file:///overlay/index.html?layer=modal'; - modalWindow.emitDidFinishLoad(); - modalWindow.emitReadyToShow(); - modalWindow.contentReady = true; + modal.finishLoad(); + modal.emitReadyToShow(); assert.equal( - runtime.sendToActiveOverlayWindow('session-help:open', undefined, { + h.runtime.sendToActiveOverlayWindow('session-help:open', undefined, { restoreOnModalClose: 'session-help', preferModalWindow: true, }), true, ); - assert.equal(createCalls, 1); - assert.equal(modalWindow.isVisible(), true); - assert.deepEqual(modalWindow.sent, [['session-help:open']]); + assert.equal(h.createCalls, 1); + assert.equal(modal.isVisible(), true); + assert.deepEqual(modal.sent, [['session-help:open']]); }); } test('primeModalWindow leaves Linux modal creation lazy', () => { - let createCalls = 0; - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => null, - getModalWindow: () => null, - createModalWindow: () => { - createCalls += 1; - return createMockWindow() as never; - }, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }, - { platform: 'linux' }, - ); + const h = createHarness({ createModal: () => new FakeOverlayWindow(), platform: 'linux' }); - assert.equal(runtime.primeModalWindow(), false); - assert.equal(createCalls, 0); + assert.equal(h.runtime.primeModalWindow(), false); + assert.equal(h.createCalls, 0); }); test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => { - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => null, - createModalWindow: () => null, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); + const h = createHarness({ createModal: () => null }); assert.equal( - runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + h.runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', }), false, ); - assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().has('runtime-options'), false); + assert.equal(h.runtime.getRestoreVisibleOverlayOnModalClose().has('runtime-options'), false); }); -test('sendToActiveOverlayWindow waits for blank modal URL before sending open command', () => { - const window = createMockWindow(); - window.url = ''; - window.loading = true; - window.contentReady = false; - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => { - throw new Error('modal window should not be created when already present'); - }, - getModalGeometry: () => ({ x: 10, y: 20, width: 300, height: 200 }), - setModalWindowBounds: () => {}, +const readinessCases: Array<{ + name: string; + window: FakeOverlayWindowInit; + opens: OverlayHostedModal[]; + sentBeforeLoad: unknown[][]; +}> = [ + { + name: 'delivers on first modal load without waiting for ready-to-show', + window: LOADING_BLANK_WINDOW, + opens: ['runtime-options'], + sentBeforeLoad: [], + }, + { + name: 'delivers when the modal loaded before listeners were registered', + window: { contentReady: false }, + opens: ['runtime-options'], + sentBeforeLoad: [['runtime-options:open']], + }, + { + name: 'does not infer document readiness from a pending file URL', + window: { contentReady: false, documentLoaded: false }, + opens: ['runtime-options'], + sentBeforeLoad: [], + }, + { + name: 'rejects stale content readiness during document reload', + window: { contentReady: true, documentLoaded: false }, + opens: ['session-help'], + sentBeforeLoad: [], + }, + { + name: 'flushes every queued open in order once the modal loads', + window: LOADING_BLANK_WINDOW, + opens: ['runtime-options', 'session-help'], + sentBeforeLoad: [], + }, +]; + +for (const c of readinessCases) { + test(`sendToActiveOverlayWindow ${c.name}`, () => { + const modal = new FakeOverlayWindow(c.window); + const h = createHarness({ modal }); + + for (const modalId of c.opens) { + assert.equal( + h.runtime.sendToActiveOverlayWindow(`${modalId}:open`, undefined, { + restoreOnModalClose: modalId, + }), + true, + ); + } + assert.deepEqual(modal.sent, c.sentBeforeLoad); + + const expected = c.opens.map((modalId) => [`${modalId}:open`]); + modal.finishLoad(); + assert.deepEqual(modal.sent, expected); + + // Later ready-to-show and the renderer ack must not resend, and the ack reveals once. + modal.emitReadyToShow(); + h.runtime.notifyOverlayModalOpened(c.opens[0]!); + assert.deepEqual(modal.sent, expected); + assert.equal(modal.showCount, 1); + assert.equal(h.createCalls, 0); }); - - const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { - restoreOnModalClose: 'runtime-options', - }); - - assert.equal(sent, true); - assert.deepEqual(window.sent, []); - window.loading = false; - window.url = 'file:///overlay/index.html?layer=modal'; - window.emitDidFinishLoad(); - assert.deepEqual(window.sent, [['runtime-options:open']]); - - window.contentReady = true; - window.emitReadyToShow(); - - runtime.notifyOverlayModalOpened('runtime-options'); - assert.deepEqual(window.sent, [['runtime-options:open']]); - assert.equal(window.getShowCount(), 1); -}); +} test('handleOverlayModalClosed keeps the modal window warm after all pending modals close', () => { - const window = createMockWindow(); - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => window as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }, - { platform: 'darwin' }, - ); + const modal = new FakeOverlayWindow(); + const h = createHarness({ modal, platform: 'darwin' }); - runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + h.runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', }); - runtime.sendToActiveOverlayWindow( - 'subsync:open-manual', - { - ffsubsyncAvailable: true, - videoReferenceAvailable: true, - subtitleTracks: [], - defaultReferenceTrackId: null, - defaultTargetTrackId: null, - }, - { - restoreOnModalClose: 'subsync', - }, - ); + h.runtime.sendToActiveOverlayWindow('subsync:open-manual', SUBSYNC_PAYLOAD, { + restoreOnModalClose: 'subsync', + }); - runtime.handleOverlayModalClosed('runtime-options'); - assert.equal(window.isDestroyed(), false); + h.runtime.handleOverlayModalClosed('runtime-options'); + assert.equal(modal.isDestroyed(), false); - runtime.handleOverlayModalClosed('subsync'); - assert.equal(window.isDestroyed(), false); - assert.equal(window.isVisible(), false); - assert.equal(window.ignoreMouseEvents, true); + h.runtime.handleOverlayModalClosed('subsync'); + assert.equal(modal.isDestroyed(), false); + assert.equal(modal.isVisible(), false); + assert.equal(modal.ignoreMouseEvents, true); + assert.equal(h.runtime.getRestoreVisibleOverlayOnModalClose().size, 0); }); test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => { - const mainWindow = createMockWindow(); - mainWindow.visible = true; - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => mainWindow as never, - getModalWindow: () => null, - createModalWindow: () => { - throw new Error('modal window should not be created when main overlay is visible'); - }, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); + const main = new FakeOverlayWindow({ visible: true }); + const h = createHarness({ main, createModal: () => new FakeOverlayWindow() }); - const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + const sent = h.runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', }); assert.equal(sent, true); - assert.deepEqual(mainWindow.sent, [['runtime-options:open']]); + assert.equal(h.createCalls, 0); + assert.deepEqual(main.sent, [['runtime-options:open']]); }); test('sendToActiveOverlayWindow can prefer modal window even when main overlay is visible', () => { - const mainWindow = createMockWindow(); - mainWindow.visible = true; - const modalWindow = createMockWindow(); - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => mainWindow as never, - getModalWindow: () => modalWindow as never, - createModalWindow: () => modalWindow as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); + const main = new FakeOverlayWindow({ visible: true }); + const modal = new FakeOverlayWindow(); + const h = createHarness({ main, modal }); - const sent = runtime.sendToActiveOverlayWindow( + const sent = h.runtime.sendToActiveOverlayWindow( 'youtube:picker-open', { sessionId: 'yt-1' }, - { - restoreOnModalClose: 'youtube-track-picker', - preferModalWindow: true, - }, + { restoreOnModalClose: 'youtube-track-picker', preferModalWindow: true }, ); assert.equal(sent, true); - assert.deepEqual(mainWindow.sent, []); - assert.deepEqual(modalWindow.sent, [['youtube:picker-open', { sessionId: 'yt-1' }]]); + assert.deepEqual(main.sent, []); + assert.deepEqual(modal.sent, [['youtube:picker-open', { sessionId: 'yt-1' }]]); }); test('modal window path makes visible main overlay click-through until modal closes', () => { - const mainWindow = createMockWindow(); - mainWindow.visible = true; - const modalWindow = createMockWindow(); - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => mainWindow as never, - getModalWindow: () => modalWindow as never, - createModalWindow: () => modalWindow as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); + const main = new FakeOverlayWindow({ visible: true }); + const modal = new FakeOverlayWindow(); + const h = createHarness({ main, modal }); - const sent = runtime.sendToActiveOverlayWindow( + const sent = h.runtime.sendToActiveOverlayWindow( 'youtube:picker-open', { sessionId: 'yt-1' }, - { - restoreOnModalClose: 'youtube-track-picker', - preferModalWindow: true, - }, + { restoreOnModalClose: 'youtube-track-picker', preferModalWindow: true }, ); - runtime.notifyOverlayModalOpened('youtube-track-picker'); + h.runtime.notifyOverlayModalOpened('youtube-track-picker'); assert.equal(sent, true); - assert.equal(mainWindow.ignoreMouseEvents, true); - assert.equal(mainWindow.forwardedIgnoreMouseEvents, true); - assert.equal(modalWindow.ignoreMouseEvents, false); + assert.equal(main.ignoreMouseEvents, true); + assert.equal(main.forwardedIgnoreMouseEvents, true); + assert.equal(modal.ignoreMouseEvents, false); - runtime.handleOverlayModalClosed('youtube-track-picker'); + h.runtime.handleOverlayModalClosed('youtube-track-picker'); - assert.equal(mainWindow.ignoreMouseEvents, true); + assert.equal(main.ignoreMouseEvents, true); }); test('modal window path restores visible main overlay before modal input deactivates', () => { - const mainWindow = createMockWindow(); - mainWindow.visible = true; - const modalWindow = createMockWindow(); + const main = new FakeOverlayWindow({ visible: true }); const events: string[] = []; - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => mainWindow as never, - getModalWindow: () => modalWindow as never, - createModalWindow: () => modalWindow as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, + const h = createHarness({ + main, + modal: new FakeOverlayWindow(), + options: { + onModalStateChange: (active) => events.push(`state:${active}:visible:${main.isVisible()}`), }, - { - onModalStateChange: (active: boolean): void => { - events.push(`state:${active}:visible:${mainWindow.isVisible()}`); - }, - }, - ); + }); - runtime.sendToActiveOverlayWindow( + h.runtime.sendToActiveOverlayWindow( 'youtube:picker-open', { sessionId: 'yt-1' }, - { - restoreOnModalClose: 'youtube-track-picker', - preferModalWindow: true, - }, + { restoreOnModalClose: 'youtube-track-picker', preferModalWindow: true }, ); - runtime.notifyOverlayModalOpened('youtube-track-picker'); + h.runtime.notifyOverlayModalOpened('youtube-track-picker'); - assert.equal(mainWindow.getHideCount(), 1); - assert.equal(mainWindow.isVisible(), false); + assert.equal(main.hideCount, 1); + assert.equal(main.isVisible(), false); - runtime.handleOverlayModalClosed('youtube-track-picker'); + h.runtime.handleOverlayModalClosed('youtube-track-picker'); - assert.equal(mainWindow.getShowCount(), 1); - assert.equal(mainWindow.isVisible(), true); + assert.equal(main.showCount, 1); + assert.equal(main.isVisible(), true); assert.deepEqual(events, ['state:true:visible:true', 'state:false:visible:true']); }); test('macOS maps a new modal panel before focusing SubMiner and hiding the subtitle overlay', () => { - const mainWindow = createMockWindow(); - mainWindow.visible = true; - const modalWindow = createMockWindow(); - const events: string[] = []; - const showInactive = modalWindow.showInactive; - modalWindow.showInactive = () => { - events.push('show-inactive'); - showInactive(); - }; - const hideMainWindow = mainWindow.hide; - mainWindow.hide = () => { - events.push('hide-main'); - hideMainWindow(); - }; - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => mainWindow as never, - getModalWindow: () => modalWindow as never, - createModalWindow: () => modalWindow as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }, - { - platform: 'darwin', - focusApplication: () => events.push('focus-application'), - }, - ); + const log: string[] = []; + const main = new FakeOverlayWindow({ name: 'main', log, visible: true }); + const modal = new FakeOverlayWindow({ name: 'modal', log }); + const h = createHarness({ + main, + modal, + platform: 'darwin', + options: { focusApplication: () => log.push('focus-application') }, + }); - runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + h.runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', preferModalWindow: true, }); - runtime.notifyOverlayModalOpened('runtime-options'); + h.runtime.notifyOverlayModalOpened('runtime-options'); - assert.deepEqual(events, ['show-inactive', 'focus-application', 'hide-main']); - assert.equal(modalWindow.isVisible(), true); - assert.equal(mainWindow.isVisible(), false); + assert.deepEqual(log, ['modal:show-inactive', 'focus-application', 'main:hide']); + assert.equal(modal.isVisible(), true); + assert.equal(main.isVisible(), false); }); test('modal window path runs final close handoff before modal input deactivates', () => { - const mainWindow = createMockWindow(); - mainWindow.visible = true; - const modalWindow = createMockWindow(); + const main = new FakeOverlayWindow({ visible: true }); const events: string[] = []; - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => mainWindow as never, - getModalWindow: () => modalWindow as never, - createModalWindow: () => modalWindow as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, + const h = createHarness({ + main, + modal: new FakeOverlayWindow(), + options: { + onFinalModalClosed: () => events.push(`handoff:visible:${main.isVisible()}`), + onModalStateChange: (active) => events.push(`state:${active}:visible:${main.isVisible()}`), }, - { - onFinalModalClosed: (): void => { - events.push(`handoff:visible:${mainWindow.isVisible()}`); - }, - onModalStateChange: (active: boolean): void => { - events.push(`state:${active}:visible:${mainWindow.isVisible()}`); - }, - }, - ); + }); - runtime.sendToActiveOverlayWindow( + h.runtime.sendToActiveOverlayWindow( 'youtube:picker-open', { sessionId: 'yt-1' }, - { - restoreOnModalClose: 'youtube-track-picker', - preferModalWindow: true, - }, + { restoreOnModalClose: 'youtube-track-picker', preferModalWindow: true }, ); - runtime.notifyOverlayModalOpened('youtube-track-picker'); - runtime.handleOverlayModalClosed('youtube-track-picker'); + h.runtime.notifyOverlayModalOpened('youtube-track-picker'); + h.runtime.handleOverlayModalClosed('youtube-track-picker'); assert.deepEqual(events, [ 'state:true:visible:true', @@ -642,193 +366,99 @@ test('modal window path runs final close handoff before modal input deactivates' }); test('modal runtime deactivates modal state when final close handoff throws', () => { - const mainWindow = createMockWindow(); - mainWindow.visible = true; - const modalWindow = createMockWindow(); const events: string[] = []; - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => mainWindow as never, - getModalWindow: () => modalWindow as never, - createModalWindow: () => modalWindow as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }, - { - onFinalModalClosed: (): void => { + const h = createHarness({ + main: new FakeOverlayWindow({ visible: true }), + modal: new FakeOverlayWindow(), + options: { + onFinalModalClosed: () => { events.push('handoff'); throw new Error('handoff failed'); }, - onModalStateChange: (active: boolean): void => { - events.push(`state:${active}`); - }, + onModalStateChange: (active) => events.push(`state:${active}`), }, - ); + }); - runtime.sendToActiveOverlayWindow( + h.runtime.sendToActiveOverlayWindow( 'youtube:picker-open', { sessionId: 'yt-1' }, - { - restoreOnModalClose: 'youtube-track-picker', - preferModalWindow: true, - }, + { restoreOnModalClose: 'youtube-track-picker', preferModalWindow: true }, ); - runtime.notifyOverlayModalOpened('youtube-track-picker'); + h.runtime.notifyOverlayModalOpened('youtube-track-picker'); - assert.doesNotThrow(() => runtime.handleOverlayModalClosed('youtube-track-picker')); + assert.doesNotThrow(() => h.runtime.handleOverlayModalClosed('youtube-track-picker')); assert.deepEqual(events, ['state:true', 'handoff', 'state:false']); }); test('modal runtime notifies callers when modal input state becomes active/inactive', () => { - const window = createMockWindow(); const state: boolean[] = []; - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => window as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }, - { - onModalStateChange: (active: boolean): void => { - state.push(active); - }, - }, - ); + const h = createHarness({ + modal: new FakeOverlayWindow(), + options: { onModalStateChange: (active) => state.push(active) }, + }); - runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + h.runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', }); - runtime.sendToActiveOverlayWindow( - 'subsync:open-manual', - { - ffsubsyncAvailable: true, - videoReferenceAvailable: true, - subtitleTracks: [], - defaultReferenceTrackId: null, - defaultTargetTrackId: null, - }, - { - restoreOnModalClose: 'subsync', - }, - ); + h.runtime.sendToActiveOverlayWindow('subsync:open-manual', SUBSYNC_PAYLOAD, { + restoreOnModalClose: 'subsync', + }); assert.deepEqual(state, []); - runtime.notifyOverlayModalOpened('runtime-options'); + h.runtime.notifyOverlayModalOpened('runtime-options'); assert.deepEqual(state, [true]); - runtime.handleOverlayModalClosed('runtime-options'); + h.runtime.handleOverlayModalClosed('runtime-options'); assert.deepEqual(state, [true]); - runtime.handleOverlayModalClosed('subsync'); + h.runtime.handleOverlayModalClosed('subsync'); assert.deepEqual(state, [true, false]); }); test('notifyOverlayModalOpened enables input on visible main overlay window when no modal window exists', () => { - const mainWindow = createMockWindow(); - mainWindow.visible = true; - mainWindow.ignoreMouseEvents = true; + const main = new FakeOverlayWindow({ visible: true, ignoreMouseEvents: true }); const state: boolean[] = []; + const h = createHarness({ + main, + options: { onModalStateChange: (active) => state.push(active) }, + }); - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => mainWindow as never, - getModalWindow: () => null, - createModalWindow: () => { - throw new Error('modal window should not be created when main overlay is visible'); - }, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }, - { - onModalStateChange: (active: boolean): void => { - state.push(active); - }, - }, - ); - - const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + const sent = h.runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', }); - runtime.notifyOverlayModalOpened('runtime-options'); + h.runtime.notifyOverlayModalOpened('runtime-options'); assert.equal(sent, true); + assert.equal(h.createCalls, 0); assert.deepEqual(state, [true]); - assert.equal(mainWindow.ignoreMouseEvents, false); - assert.equal(mainWindow.isFocused(), true); - assert.equal(mainWindow.webContentsFocused, true); + assert.equal(main.ignoreMouseEvents, false); + assert.equal(main.isFocused(), true); + assert.equal(main.webContentsFocused, true); }); test('handleOverlayModalClosed is a no-op when no modal window can be targeted', () => { const state: boolean[] = []; - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => null, - getModalWindow: () => null, - createModalWindow: () => null, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }, - { - onModalStateChange: (active: boolean): void => { - state.push(active); - }, - }, - ); + const h = createHarness({ + createModal: () => null, + options: { onModalStateChange: (active) => state.push(active) }, + }); - const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + const sent = h.runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', }); assert.equal(sent, false); - runtime.notifyOverlayModalOpened('runtime-options'); - runtime.handleOverlayModalClosed('runtime-options'); + h.runtime.notifyOverlayModalOpened('runtime-options'); + h.runtime.handleOverlayModalClosed('runtime-options'); assert.deepEqual(state, []); }); -test('handleOverlayModalClosed hides and retains modal window for single kiku modal', () => { - const window = createMockWindow(); - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => window as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }, - { platform: 'darwin' }, - ); - - runtime.sendToActiveOverlayWindow( - 'kiku:field-grouping-open', - { test: true }, - { - restoreOnModalClose: 'kiku', - }, - ); - runtime.handleOverlayModalClosed('kiku'); - - assert.equal(window.isDestroyed(), false); - assert.equal(window.isVisible(), false); - assert.equal(window.ignoreMouseEvents, true); - assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0); -}); - -test('modal fallback reveal skips showing window when content is not ready', async () => { - const window = createMockWindow(); +test('modal fallback reveal skips showing window when content is not ready', () => { + const modal = new FakeOverlayWindow(LOADING_BLANK_WINDOW); let scheduledReveal: (() => void) | null = null; - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => { - throw new Error('modal window should not be created when already present'); - }, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }, - { - platform: 'darwin', + const h = createHarness({ + modal, + platform: 'darwin', + options: { scheduleRevealFallback: (callback) => { scheduledReveal = callback; return { scheduled: true } as never; @@ -837,589 +467,239 @@ test('modal fallback reveal skips showing window when content is not ready', asy scheduledReveal = null; }, }, - ); + }); - window.loading = true; - window.url = ''; - window.contentReady = false; - - const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, { + const sent = h.runtime.sendToActiveOverlayWindow('jimaku:open', undefined, { restoreOnModalClose: 'jimaku', }); assert.equal(sent, true); - if (scheduledReveal === null) { - throw new Error('expected reveal callback'); - } - const runScheduledReveal: () => void = scheduledReveal; - runScheduledReveal(); + assert.ok(scheduledReveal, 'expected reveal callback'); + (scheduledReveal as () => void)(); + assert.equal(modal.showCount, 0); - assert.equal(window.getShowCount(), 0); - - runtime.notifyOverlayModalOpened('jimaku'); - assert.equal(window.getShowCount(), 1); - assert.equal(window.ignoreMouseEvents, false); -}); - -test('sendToActiveOverlayWindow delivers on first modal load without waiting for ready-to-show', () => { - const window = createMockWindow(); - window.loading = true; - window.url = ''; - window.contentReady = false; - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => { - throw new Error('modal window should not be created when already present'); - }, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); - - const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { - restoreOnModalClose: 'runtime-options', - }); - - assert.equal(sent, true); - assert.deepEqual(window.sent, []); - window.loading = false; - window.url = 'file:///overlay/index.html?layer=modal'; - window.emitDidFinishLoad(); - assert.deepEqual(window.sent, [['runtime-options:open']]); - - window.contentReady = true; - window.emitReadyToShow(); - assert.deepEqual(window.sent, [['runtime-options:open']]); -}); - -test('sendToActiveOverlayWindow delivers when the modal loaded before listeners were registered', () => { - const window = createMockWindow(); - window.contentReady = false; - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => { - throw new Error('modal window should not be created when already present'); - }, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); - - assert.equal( - runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { - restoreOnModalClose: 'runtime-options', - }), - true, - ); - assert.deepEqual(window.sent, [['runtime-options:open']]); - - window.contentReady = true; - window.emitReadyToShow(); - assert.deepEqual(window.sent, [['runtime-options:open']]); -}); - -test('sendToActiveOverlayWindow does not infer document readiness from a pending file URL', () => { - const window = createMockWindow(); - window.contentReady = false; - window.documentLoaded = false; - window.loading = false; - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => { - throw new Error('modal window should not be created when already present'); - }, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); - - assert.equal( - runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { - restoreOnModalClose: 'runtime-options', - }), - true, - ); - assert.deepEqual(window.sent, []); - - window.emitDidFinishLoad(); - assert.deepEqual(window.sent, [['runtime-options:open']]); -}); - -test('sendToActiveOverlayWindow rejects stale content readiness during document reload', () => { - const window = createMockWindow(); - window.contentReady = true; - window.documentLoaded = false; - window.loading = false; - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => { - throw new Error('modal window should not be created when already present'); - }, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); - - assert.equal( - runtime.sendToActiveOverlayWindow('session-help:open', undefined, { - restoreOnModalClose: 'session-help', - }), - true, - ); - assert.deepEqual(window.sent, []); - - window.emitDidFinishLoad(); - assert.deepEqual(window.sent, [['session-help:open']]); -}); - -test('sendToActiveOverlayWindow flushes every queued load and ready listener before sending', () => { - const window = createMockWindow(); - window.loading = true; - window.url = ''; - window.contentReady = false; - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => { - throw new Error('modal window should not be created when already present'); - }, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); - - assert.equal( - runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { - restoreOnModalClose: 'runtime-options', - }), - true, - ); - assert.equal( - runtime.sendToActiveOverlayWindow('session-help:open', undefined, { - restoreOnModalClose: 'session-help', - }), - true, - ); - assert.deepEqual(window.sent, []); - - window.loading = false; - window.url = 'file:///overlay/index.html?layer=modal'; - window.emitDidFinishLoad(); - assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]); - - window.contentReady = true; - window.emitReadyToShow(); - assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]); + h.runtime.notifyOverlayModalOpened('jimaku'); + assert.equal(modal.showCount, 1); + assert.equal(modal.ignoreMouseEvents, false); }); test('modal reopen reuses the warm window and shows it immediately on macOS', () => { - const modalWindow = createMockWindow(); - let createCalls = 0; + const modal = new FakeOverlayWindow(); + const state: boolean[] = []; + const h = createHarness({ + modal, + createModal: () => modal, + platform: 'darwin', + options: { onModalStateChange: (active) => state.push(active) }, + }); - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => null, - getModalWindow: () => modalWindow as never, - createModalWindow: () => { - createCalls += 1; - return modalWindow as never; - }, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }, - { platform: 'darwin' }, - ); - - runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + h.runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', }); - runtime.notifyOverlayModalOpened('runtime-options'); - runtime.handleOverlayModalClosed('runtime-options'); + h.runtime.notifyOverlayModalOpened('runtime-options'); + h.runtime.handleOverlayModalClosed('runtime-options'); - assert.equal(modalWindow.isDestroyed(), false); - assert.equal(modalWindow.isVisible(), false); + assert.equal(modal.isDestroyed(), false); + assert.equal(modal.isVisible(), false); + assert.deepEqual(state, [true, false]); - const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + const sent = h.runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', }); assert.equal(sent, true); - assert.equal(createCalls, 0); - assert.equal(modalWindow.isVisible(), true); - assert.equal(modalWindow.getShowCount(), 2); + assert.equal(h.createCalls, 0); + assert.equal(modal.isVisible(), true); + assert.equal(modal.showCount, 2); + + h.runtime.notifyOverlayModalOpened('runtime-options'); + assert.deepEqual(state, [true, false, true]); }); test('modal reopen on Windows uses a fresh prewarmed interactive window', () => { - const firstWindow = createMockWindow(); - const replacementWindow = createMockWindow(); - let currentModal = firstWindow; - let createCalls = 0; + const firstWindow = new FakeOverlayWindow(); + const replacementWindow = new FakeOverlayWindow(); + const h = createHarness({ + modal: firstWindow, + createModal: () => replacementWindow, + platform: 'win32', + }); - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => null, - getModalWindow: () => currentModal as never, - createModalWindow: () => { - createCalls += 1; - currentModal = replacementWindow; - return replacementWindow as never; - }, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }, - { platform: 'win32' }, - ); - - runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + h.runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', }); - runtime.notifyOverlayModalOpened('runtime-options'); - runtime.handleOverlayModalClosed('runtime-options'); + h.runtime.notifyOverlayModalOpened('runtime-options'); + h.runtime.handleOverlayModalClosed('runtime-options'); assert.equal(firstWindow.isDestroyed(), true); - assert.equal(currentModal, replacementWindow); + assert.equal(h.modal, replacementWindow); assert.equal(replacementWindow.isVisible(), false); - assert.equal(createCalls, 1); + assert.equal(h.createCalls, 1); - const sent = runtime.sendToActiveOverlayWindow('session-help:open', undefined, { + const sent = h.runtime.sendToActiveOverlayWindow('session-help:open', undefined, { restoreOnModalClose: 'session-help', }); assert.equal(sent, true); - assert.equal(createCalls, 1); + assert.equal(h.createCalls, 1); assert.equal(replacementWindow.isVisible(), true); assert.equal(replacementWindow.ignoreMouseEvents, false); assert.deepEqual(replacementWindow.sent, [['session-help:open']]); }); -test('modal reopen on the warm window notifies state change for each lifecycle', () => { - const modalWindow = createMockWindow(); - const state: boolean[] = []; - - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => null, - getModalWindow: () => modalWindow as never, - createModalWindow: () => modalWindow as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }, - { - onModalStateChange: (active: boolean): void => { - state.push(active); - }, - platform: 'darwin', - }, - ); - - runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { - restoreOnModalClose: 'runtime-options', - }); - runtime.notifyOverlayModalOpened('runtime-options'); - runtime.handleOverlayModalClosed('runtime-options'); - - assert.deepEqual(state, [true, false]); - assert.equal(modalWindow.isDestroyed(), false); - - runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { - restoreOnModalClose: 'runtime-options', - }); - runtime.notifyOverlayModalOpened('runtime-options'); - - assert.deepEqual(state, [true, false, true]); - assert.equal(modalWindow.isVisible(), true); -}); - test('visible stale modal window is made interactive again before reopening', () => { - const window = createMockWindow(); - window.visible = true; - window.focused = true; - window.webContentsFocused = false; - window.ignoreMouseEvents = true; - - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => window as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, + const modal = new FakeOverlayWindow({ + visible: true, + focused: true, + webContentsFocused: false, + ignoreMouseEvents: true, }); + const h = createHarness({ modal }); - const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + const sent = h.runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', }); assert.equal(sent, true); - assert.equal(window.ignoreMouseEvents, false); - assert.equal(window.isFocused(), true); - assert.equal(window.webContentsFocused, true); - assert.deepEqual(window.sent, [['runtime-options:open']]); + assert.equal(modal.ignoreMouseEvents, false); + assert.equal(modal.isFocused(), true); + assert.equal(modal.webContentsFocused, true); + assert.deepEqual(modal.sent, [['runtime-options:open']]); }); test('waitForModalOpen resolves true after modal acknowledgement', async () => { - const modalWindow = createMockWindow(); - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => modalWindow as never, - createModalWindow: () => modalWindow as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); + const h = createHarness({ modal: new FakeOverlayWindow() }); - runtime.sendToActiveOverlayWindow( + h.runtime.sendToActiveOverlayWindow( 'youtube:picker-open', { sessionId: 'yt-1' }, - { - restoreOnModalClose: 'youtube-track-picker', - }, + { restoreOnModalClose: 'youtube-track-picker' }, ); - const pending = runtime.waitForModalOpen('youtube-track-picker', 1000); - runtime.notifyOverlayModalOpened('youtube-track-picker'); + const pending = h.runtime.waitForModalOpen('youtube-track-picker', 1000); + h.runtime.notifyOverlayModalOpened('youtube-track-picker'); assert.equal(await pending, true); }); test('waitForModalOpen resolves true when modal acknowledgement arrives before waiter registration', async () => { - const modalWindow = createMockWindow(); - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => modalWindow as never, - createModalWindow: () => modalWindow as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); + const h = createHarness({ modal: new FakeOverlayWindow() }); - runtime.sendToActiveOverlayWindow( + h.runtime.sendToActiveOverlayWindow( 'kiku:field-grouping-request', {}, - { - restoreOnModalClose: 'kiku', - }, + { restoreOnModalClose: 'kiku' }, ); - runtime.notifyOverlayModalOpened('kiku'); + h.runtime.notifyOverlayModalOpened('kiku'); - assert.equal(await runtime.waitForModalOpen('kiku', 5), true); + assert.equal(await h.runtime.waitForModalOpen('kiku', 5), true); }); test('waitForModalOpen resolves false on timeout', async () => { - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => null, - createModalWindow: () => null, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }); + const h = createHarness(); - assert.equal(await runtime.waitForModalOpen('youtube-track-picker', 5), false); + assert.equal(await h.runtime.waitForModalOpen('youtube-track-picker', 5), false); }); +function openKikuInModalWindow(h: ReturnType): void { + h.runtime.sendToActiveOverlayWindow( + 'kiku:field-grouping-open', + { test: true }, + { restoreOnModalClose: 'kiku', preferModalWindow: true }, + ); +} + test('modal placement reconcile retries until the Hyprland client is mapped', () => { - const window = createMockWindow(); - const timers: Array<() => void> = []; - const originalSetTimeout = globalThis.setTimeout; - globalThis.setTimeout = ((cb: () => void) => { - timers.push(cb); - return { unref() {} }; - }) as unknown as typeof globalThis.setTimeout; - - const statuses: Array<{ applicable: boolean; clientFound: boolean }> = []; - try { - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => window as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => { - // The compositor never maps the window, so every reconcile reports pending. - const status = { applicable: true, clientFound: false, dispatched: false }; - statuses.push(status); - return status; - }, + withFakeTimers((timers) => { + // The compositor never maps the window, so every reconcile reports pending. + const h = createHarness({ + modal: new FakeOverlayWindow(), + setModalWindowBounds: () => PENDING_PLACEMENT, }); + openKikuInModalWindow(h); + h.runtime.notifyOverlayModalOpened('kiku'); + const reconcilesBeforeLadder = h.boundsCalls.length; - runtime.sendToActiveOverlayWindow( - 'kiku:field-grouping-open', - { test: true }, - { restoreOnModalClose: 'kiku', preferModalWindow: true }, - ); - runtime.notifyOverlayModalOpened('kiku'); + timers.runAll(); - let iterations = 0; - while (timers.length > 0 && iterations < 50) { - const next = timers.shift(); - next?.(); - iterations += 1; - } - - // The reconcile ladder re-asserts placement across all six delays while the client - // stays unmapped, instead of the old single post-show attempt. - assert.ok( - statuses.length >= 6, - `expected at least 6 pending reconcile attempts, saw ${statuses.length}`, - ); - } finally { - globalThis.setTimeout = originalSetTimeout; - } + // One re-assert per post-show ladder delay. + assert.equal(h.boundsCalls.length - reconcilesBeforeLadder, 6); + }); }); test('modal placement reconcile stops retrying once the client is mapped', () => { - const window = createMockWindow(); - const timers: Array<() => void> = []; - const originalSetTimeout = globalThis.setTimeout; - globalThis.setTimeout = ((cb: () => void) => { - timers.push(cb); - return { unref() {} }; - }) as unknown as typeof globalThis.setTimeout; - - let reconcileCount = 0; - try { - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => window as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => { - reconcileCount += 1; - // Client is already mapped, so placement is settled on the first attempt. - return { applicable: true, clientFound: true, dispatched: true }; - }, + withFakeTimers((timers) => { + const h = createHarness({ + modal: new FakeOverlayWindow(), + setModalWindowBounds: () => ({ applicable: true, clientFound: true, dispatched: true }), }); + openKikuInModalWindow(h); + h.runtime.notifyOverlayModalOpened('kiku'); + const reconcilesBeforeLadder = h.boundsCalls.length; - runtime.sendToActiveOverlayWindow( - 'kiku:field-grouping-open', - { test: true }, - { restoreOnModalClose: 'kiku', preferModalWindow: true }, - ); - runtime.notifyOverlayModalOpened('kiku'); + timers.runAll(); - let iterations = 0; - while (timers.length > 0 && iterations < 50) { - const next = timers.shift(); - next?.(); - iterations += 1; - } - - // No 6-deep ladder: a settled placement should not keep rescheduling. - assert.ok( - reconcileCount < 6, - `expected the ladder to stop early, saw ${reconcileCount} reconcile attempts`, - ); - } finally { - globalThis.setTimeout = originalSetTimeout; - } + assert.equal(h.boundsCalls.length - reconcilesBeforeLadder, 1); + }); }); test('modal placement reconcile cancels stale retry ladder after a newer visible modal interaction', () => { - const window = createMockWindow(); - type TimerEntry = { active: boolean; callback: () => void }; - const timers: TimerEntry[] = []; - const activeTimerCount = () => timers.filter((timer) => timer.active).length; - const runNextActiveTimer = () => { - const timer = timers.find((candidate) => candidate.active); - if (!timer) return; - timer.active = false; - timer.callback(); - }; - const originalSetTimeout = globalThis.setTimeout; - const originalClearTimeout = globalThis.clearTimeout; - globalThis.setTimeout = ((cb: () => void) => { - const timer = { active: true, callback: cb, unref() {} }; - timers.push(timer); - return timer; - }) as unknown as typeof globalThis.setTimeout; - globalThis.clearTimeout = ((timeout: TimerEntry | undefined) => { - if (timeout) { - timeout.active = false; - } - }) as unknown as typeof globalThis.clearTimeout; - - try { - const runtime = createOverlayModalRuntimeService({ - getMainWindow: () => null, - getModalWindow: () => window as never, - createModalWindow: () => window as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => ({ applicable: true, clientFound: false, dispatched: false }), + withFakeTimers((timers) => { + const h = createHarness({ + modal: new FakeOverlayWindow(), + setModalWindowBounds: () => PENDING_PLACEMENT, }); + openKikuInModalWindow(h); + h.runtime.notifyOverlayModalOpened('kiku'); + assert.equal(timers.activeCount(), 1); - runtime.sendToActiveOverlayWindow( - 'kiku:field-grouping-open', - { test: true }, - { restoreOnModalClose: 'kiku', preferModalWindow: true }, - ); - runtime.notifyOverlayModalOpened('kiku'); - assert.equal(activeTimerCount(), 1); + openKikuInModalWindow(h); + assert.equal(timers.activeCount(), 2); - runtime.sendToActiveOverlayWindow( - 'kiku:field-grouping-open', - { test: true }, - { restoreOnModalClose: 'kiku', preferModalWindow: true }, - ); - assert.equal(activeTimerCount(), 2); + timers.runNext(); - runNextActiveTimer(); - - assert.equal(activeTimerCount(), 1, 'stale retry should not schedule a continuation'); - } finally { - globalThis.setTimeout = originalSetTimeout; - globalThis.clearTimeout = originalClearTimeout; - } + assert.equal(timers.activeCount(), 1, 'stale retry should not schedule a continuation'); + }); }); test('Linux keeps the dedicated modal window unmapped until the renderer opens the modal, then hides the overlay before revealing it', () => { - const mainWindow = createMockWindow(); - mainWindow.visible = true; - const modalWindow = createMockWindow(); - const order: string[] = []; - const hideMain = mainWindow.hide; - mainWindow.hide = () => { - order.push('main:hide'); - hideMain(); - }; - const showModal = modalWindow.show; - modalWindow.show = () => { - order.push('modal:show'); - showModal(); - }; + const log: string[] = []; + const main = new FakeOverlayWindow({ name: 'main', log, visible: true }); + const modal = new FakeOverlayWindow({ name: 'modal', log }); let revealScheduled = false; - const runtime = createOverlayModalRuntimeService( - { - getMainWindow: () => mainWindow as never, - getModalWindow: () => modalWindow as never, - createModalWindow: () => modalWindow as never, - getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), - setModalWindowBounds: () => {}, - }, - { - platform: 'linux', + const h = createHarness({ + main, + modal, + platform: 'linux', + options: { scheduleRevealFallback: () => { revealScheduled = true; return { scheduled: true } as never; }, clearRevealFallback: () => {}, }, - ); + }); const open = () => - runtime.sendToActiveOverlayWindow( + h.runtime.sendToActiveOverlayWindow( 'media-timing-review:open', { reviewId: 'review' }, { restoreOnModalClose: 'media-timing-review', preferModalWindow: true }, ); assert.equal(open(), true); - assert.deepEqual(modalWindow.sent, [['media-timing-review:open', { reviewId: 'review' }]]); + assert.deepEqual(modal.sent, [['media-timing-review:open', { reviewId: 'review' }]]); assert.equal(revealScheduled, false); - assert.equal(modalWindow.getShowCount(), 0); - assert.equal(mainWindow.getHideCount(), 0); + assert.equal(modal.showCount, 0); + assert.equal(main.hideCount, 0); // The open retry must not map the window before the renderer answers either. assert.equal(open(), true); - assert.equal(modalWindow.getShowCount(), 0); + assert.equal(modal.showCount, 0); - runtime.notifyOverlayModalOpened('media-timing-review'); + h.runtime.notifyOverlayModalOpened('media-timing-review'); - assert.deepEqual(order, ['main:hide', 'modal:show']); - assert.equal(mainWindow.isVisible(), false); - assert.equal(modalWindow.isVisible(), true); - assert.equal(modalWindow.ignoreMouseEvents, false); + assert.deepEqual(log, ['main:hide', 'modal:show']); + assert.equal(main.isVisible(), false); + assert.equal(modal.isVisible(), true); + assert.equal(modal.ignoreMouseEvents, false); }); diff --git a/src/main/runtime/anilist-media-guess-main-deps.test.ts b/src/main/runtime/anilist-media-guess-main-deps.test.ts deleted file mode 100644 index e3e2bde8..00000000 --- a/src/main/runtime/anilist-media-guess-main-deps.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - createBuildEnsureAnilistMediaGuessMainDepsHandler, - createBuildMaybeProbeAnilistDurationMainDepsHandler, -} from './anilist-media-guess-main-deps'; - -test('maybe probe anilist duration main deps builder maps callbacks', async () => { - const calls: string[] = []; - const deps = createBuildMaybeProbeAnilistDurationMainDepsHandler({ - getState: () => ({ - mediaKey: 'm', - mediaDurationSec: null, - mediaGuess: null, - mediaGuessPromise: null, - lastDurationProbeAtMs: 0, - }), - setState: () => calls.push('set-state'), - durationRetryIntervalMs: 1000, - now: () => 42, - requestMpvDuration: async () => 3600, - logWarn: (message) => calls.push(`warn:${message}`), - })(); - - assert.equal(deps.durationRetryIntervalMs, 1000); - assert.equal(deps.now(), 42); - assert.equal(await deps.requestMpvDuration(), 3600); - deps.setState({ - mediaKey: 'm', - mediaDurationSec: 100, - mediaGuess: null, - mediaGuessPromise: null, - lastDurationProbeAtMs: 0, - }); - deps.logWarn('oops', null); - assert.deepEqual(calls, ['set-state', 'warn:oops']); -}); - -test('ensure anilist media guess main deps builder maps callbacks', async () => { - const calls: string[] = []; - const deps = createBuildEnsureAnilistMediaGuessMainDepsHandler({ - getState: () => ({ - mediaKey: 'm', - mediaDurationSec: null, - mediaGuess: null, - mediaGuessPromise: null, - lastDurationProbeAtMs: 0, - }), - setState: () => calls.push('set-state'), - resolveMediaPathForJimaku: (path) => { - calls.push('resolve'); - return path; - }, - getCurrentMediaPath: () => '/tmp/video.mkv', - getCurrentMediaTitle: () => 'title', - guessAnilistMediaInfo: async () => { - calls.push('guess'); - return { title: 'title', season: null, episode: 1, source: 'fallback' }; - }, - })(); - - assert.equal(deps.getCurrentMediaPath(), '/tmp/video.mkv'); - assert.equal(deps.getCurrentMediaTitle(), 'title'); - assert.equal(deps.resolveMediaPathForJimaku('/tmp/video.mkv'), '/tmp/video.mkv'); - assert.deepEqual(await deps.guessAnilistMediaInfo('/tmp/video.mkv', 'title'), { - title: 'title', - season: null, - episode: 1, - source: 'fallback', - }); - deps.setState({ - mediaKey: 'm', - mediaDurationSec: null, - mediaGuess: null, - mediaGuessPromise: null, - lastDurationProbeAtMs: 0, - }); - assert.deepEqual(calls, ['resolve', 'guess', 'set-state']); -}); diff --git a/src/main/runtime/anilist-media-state-main-deps.test.ts b/src/main/runtime/anilist-media-state-main-deps.test.ts deleted file mode 100644 index eef7d466..00000000 --- a/src/main/runtime/anilist-media-state-main-deps.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler, - createBuildGetCurrentAnilistMediaKeyMainDepsHandler, - createBuildRecordAnilistMediaDurationMainDepsHandler, - createBuildResetAnilistMediaGuessStateMainDepsHandler, - createBuildResetAnilistMediaTrackingMainDepsHandler, - createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler, -} from './anilist-media-state-main-deps'; - -test('get current anilist media key main deps builder maps callbacks', () => { - const deps = createBuildGetCurrentAnilistMediaKeyMainDepsHandler({ - getCurrentMediaPath: () => '/tmp/video.mkv', - })(); - assert.equal(deps.getCurrentMediaPath(), '/tmp/video.mkv'); -}); - -test('reset anilist media tracking main deps builder maps callbacks', () => { - const calls: string[] = []; - const deps = createBuildResetAnilistMediaTrackingMainDepsHandler({ - setMediaKey: () => calls.push('key'), - setMediaDurationSec: () => calls.push('duration'), - setMediaGuess: () => calls.push('guess'), - setMediaGuessPromise: () => calls.push('promise'), - setLastDurationProbeAtMs: () => calls.push('probe'), - })(); - deps.setMediaKey(null); - deps.setMediaDurationSec(null); - deps.setMediaGuess(null); - deps.setMediaGuessPromise(null); - deps.setLastDurationProbeAtMs(0); - assert.deepEqual(calls, ['key', 'duration', 'guess', 'promise', 'probe']); -}); - -test('get/set anilist media guess runtime state main deps builders map callbacks', () => { - const getter = createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler({ - getMediaKey: () => '/tmp/video.mkv', - getMediaDurationSec: () => 24, - getMediaGuess: () => ({ title: 'X' }) as never, - getMediaGuessPromise: () => Promise.resolve(null) as never, - getLastDurationProbeAtMs: () => 123, - })(); - assert.equal(getter.getMediaKey(), '/tmp/video.mkv'); - assert.equal(getter.getMediaDurationSec(), 24); - assert.equal(getter.getLastDurationProbeAtMs(), 123); - - const calls: string[] = []; - const setter = createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler({ - setMediaKey: () => calls.push('key'), - setMediaDurationSec: () => calls.push('duration'), - setMediaGuess: () => calls.push('guess'), - setMediaGuessPromise: () => calls.push('promise'), - setLastDurationProbeAtMs: () => calls.push('probe'), - })(); - setter.setMediaKey(null); - setter.setMediaDurationSec(null); - setter.setMediaGuess(null); - setter.setMediaGuessPromise(null); - setter.setLastDurationProbeAtMs(0); - assert.deepEqual(calls, ['key', 'duration', 'guess', 'promise', 'probe']); -}); - -test('reset anilist media guess state main deps builder maps callbacks', () => { - const calls: string[] = []; - const deps = createBuildResetAnilistMediaGuessStateMainDepsHandler({ - setMediaGuess: () => calls.push('guess'), - setMediaGuessPromise: () => calls.push('promise'), - })(); - deps.setMediaGuess(null); - deps.setMediaGuessPromise(null); - assert.deepEqual(calls, ['guess', 'promise']); -}); - -test('record anilist media duration main deps builder maps callbacks', () => { - const calls: string[] = []; - const state = { - mediaKey: '/tmp/video.mkv', - mediaDurationSec: null, - mediaGuess: null, - mediaGuessPromise: null, - lastDurationProbeAtMs: 0, - }; - const deps = createBuildRecordAnilistMediaDurationMainDepsHandler({ - getCurrentMediaKey: () => { - calls.push('key'); - return '/tmp/video.mkv'; - }, - getState: () => { - calls.push('get'); - return state; - }, - setState: () => { - calls.push('set'); - }, - })(); - - assert.equal(deps.getCurrentMediaKey(), '/tmp/video.mkv'); - deps.getState(); - deps.setState(state); - assert.deepEqual(calls, ['key', 'get', 'set']); -}); diff --git a/src/main/runtime/anilist-media-state.test.ts b/src/main/runtime/anilist-media-state.test.ts index ebfbcb64..fc2bb459 100644 --- a/src/main/runtime/anilist-media-state.test.ts +++ b/src/main/runtime/anilist-media-state.test.ts @@ -1,12 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - createGetAnilistMediaGuessRuntimeStateHandler, createGetCurrentAnilistMediaKeyHandler, createRecordAnilistMediaDurationHandler, - createResetAnilistMediaGuessStateHandler, createResetAnilistMediaTrackingHandler, - createSetAnilistMediaGuessRuntimeStateHandler, } from './anilist-media-state'; test('get current anilist media key trims and normalizes empty path', () => { @@ -66,118 +63,6 @@ test('reset anilist media tracking clears duration/guess/probe state', () => { assert.equal(lastDurationProbeAtMs, 0); }); -test('reset anilist media tracking is idempotent', () => { - const state = { - mediaKey: 'old' as string | null, - mediaDurationSec: 123 as number | null, - mediaGuess: { title: 'guess' } as { title: string } | null, - mediaGuessPromise: Promise.resolve(null) as Promise | null, - lastDurationProbeAtMs: 999, - }; - - const reset = createResetAnilistMediaTrackingHandler({ - setMediaKey: (value) => { - state.mediaKey = value; - }, - setMediaDurationSec: (value) => { - state.mediaDurationSec = value; - }, - setMediaGuess: (value) => { - state.mediaGuess = value as { title: string } | null; - }, - setMediaGuessPromise: (value) => { - state.mediaGuessPromise = value; - }, - setLastDurationProbeAtMs: (value) => { - state.lastDurationProbeAtMs = value; - }, - }); - - reset('/new/media'); - const afterFirstReset = { ...state }; - reset('/new/media'); - - assert.deepEqual(state, afterFirstReset); -}); - -test('get/set anilist media guess runtime state round-trips fields', () => { - let state = { - mediaKey: null as string | null, - mediaDurationSec: null as number | null, - mediaGuess: null as { title: string } | null, - mediaGuessPromise: null as Promise | null, - lastDurationProbeAtMs: 0, - }; - - const setState = createSetAnilistMediaGuessRuntimeStateHandler({ - setMediaKey: (value) => { - state.mediaKey = value; - }, - setMediaDurationSec: (value) => { - state.mediaDurationSec = value; - }, - setMediaGuess: (value) => { - state.mediaGuess = value as { title: string } | null; - }, - setMediaGuessPromise: (value) => { - state.mediaGuessPromise = value; - }, - setLastDurationProbeAtMs: (value) => { - state.lastDurationProbeAtMs = value; - }, - }); - - const getState = createGetAnilistMediaGuessRuntimeStateHandler({ - getMediaKey: () => state.mediaKey, - getMediaDurationSec: () => state.mediaDurationSec, - getMediaGuess: () => state.mediaGuess as never, - getMediaGuessPromise: () => state.mediaGuessPromise as never, - getLastDurationProbeAtMs: () => state.lastDurationProbeAtMs, - }); - - const nextPromise = Promise.resolve(null); - setState({ - mediaKey: '/tmp/video.mkv', - mediaDurationSec: 24, - mediaGuess: { title: 'Title' } as never, - mediaGuessPromise: nextPromise as never, - lastDurationProbeAtMs: 321, - }); - - const roundTrip = getState(); - assert.equal(roundTrip.mediaKey, '/tmp/video.mkv'); - assert.equal(roundTrip.mediaDurationSec, 24); - assert.deepEqual(roundTrip.mediaGuess, { title: 'Title' }); - assert.equal(roundTrip.mediaGuessPromise, nextPromise); - assert.equal(roundTrip.lastDurationProbeAtMs, 321); -}); - -test('reset anilist media guess state clears guess and in-flight promise', () => { - const state = { - mediaKey: '/tmp/video.mkv' as string | null, - mediaDurationSec: 240 as number | null, - mediaGuess: { title: 'guess' } as { title: string } | null, - mediaGuessPromise: Promise.resolve(null) as Promise | null, - lastDurationProbeAtMs: 321, - }; - - const resetGuessState = createResetAnilistMediaGuessStateHandler({ - setMediaGuess: (value) => { - state.mediaGuess = value as { title: string } | null; - }, - setMediaGuessPromise: (value) => { - state.mediaGuessPromise = value; - }, - }); - - resetGuessState(); - assert.equal(state.mediaGuess, null); - assert.equal(state.mediaGuessPromise, null); - assert.equal(state.mediaKey, '/tmp/video.mkv'); - assert.equal(state.mediaDurationSec, 240); - assert.equal(state.lastDurationProbeAtMs, 321); -}); - test('record anilist media duration stores observed mpv duration for current media', () => { const existingPromise = Promise.resolve(null); let state = { diff --git a/src/main/runtime/anilist-post-watch-main-deps.test.ts b/src/main/runtime/anilist-post-watch-main-deps.test.ts deleted file mode 100644 index 86b0604f..00000000 --- a/src/main/runtime/anilist-post-watch-main-deps.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler, - createBuildProcessNextAnilistRetryUpdateMainDepsHandler, -} from './anilist-post-watch-main-deps'; - -test('process next anilist retry update main deps builder maps callbacks', async () => { - const calls: string[] = []; - const deps = createBuildProcessNextAnilistRetryUpdateMainDepsHandler({ - nextReady: () => ({ key: 'k', title: 't', season: null, episode: 1 }), - refreshRetryQueueState: () => calls.push('refresh'), - setLastAttemptAt: () => calls.push('attempt'), - setLastError: () => calls.push('error'), - refreshAnilistClientSecretState: async () => 'token', - updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({ - status: 'updated', - message: `ok:${season}`, - }), - markSuccess: () => calls.push('success'), - rememberAttemptedUpdateKey: () => calls.push('remember'), - markFailure: () => calls.push('failure'), - logInfo: (message) => calls.push(`info:${message}`), - now: () => 7, - })(); - - assert.deepEqual(deps.nextReady(), { key: 'k', title: 't', season: null, episode: 1 }); - deps.refreshRetryQueueState(); - deps.setLastAttemptAt(1); - deps.setLastError('x'); - assert.equal(await deps.refreshAnilistClientSecretState(), 'token'); - assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), { - status: 'updated', - message: 'ok:2', - }); - deps.markSuccess('k'); - deps.rememberAttemptedUpdateKey('k'); - deps.markFailure('k', 'bad'); - deps.logInfo('hello'); - assert.equal(deps.now(), 7); - assert.deepEqual(calls, [ - 'refresh', - 'attempt', - 'error', - 'success', - 'remember', - 'failure', - 'info:hello', - ]); -}); - -test('maybe run anilist post watch update main deps builder maps callbacks', async () => { - const calls: string[] = []; - const deps = createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler({ - getInFlight: () => false, - setInFlight: () => calls.push('in-flight'), - getResolvedConfig: () => ({}), - isAnilistTrackingEnabled: () => true, - getCurrentMediaKey: () => 'media', - hasMpvClient: () => true, - getTrackedMediaKey: () => 'media', - resetTrackedMedia: () => calls.push('reset'), - getWatchedSeconds: () => 100, - maybeProbeAnilistDuration: async (_mediaKey, options) => { - calls.push(`probe:${options?.force === true}`); - return 120; - }, - ensureAnilistMediaGuess: async () => ({ title: 'x', season: null, episode: 1 }), - hasAttemptedUpdateKey: () => false, - processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }), - refreshAnilistClientSecretState: async () => 'token', - enqueueRetry: (_key, _title, _episode, season) => calls.push(`enqueue:${season}`), - markRetryFailure: () => calls.push('retry-fail'), - markRetrySuccess: () => calls.push('retry-ok'), - refreshRetryQueueState: () => calls.push('refresh'), - updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({ - status: 'updated', - message: `done:${season}`, - }), - rememberAttemptedUpdateKey: () => calls.push('remember'), - showMpvOsd: () => calls.push('osd'), - logInfo: (message) => calls.push(`info:${message}`), - logWarn: (message) => calls.push(`warn:${message}`), - minWatchSeconds: 5, - minWatchRatio: 0.5, - })(); - - assert.equal(deps.getInFlight(), false); - deps.setInFlight(true); - assert.equal(deps.isAnilistTrackingEnabled(deps.getResolvedConfig()), true); - assert.equal(deps.getCurrentMediaKey(), 'media'); - assert.equal(deps.hasMpvClient(), true); - assert.equal(deps.getTrackedMediaKey(), 'media'); - deps.resetTrackedMedia('media'); - assert.equal(deps.getWatchedSeconds(), 100); - assert.equal(await deps.maybeProbeAnilistDuration('media', { force: true }), 120); - assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), { - title: 'x', - season: null, - episode: 1, - }); - assert.equal(deps.hasAttemptedUpdateKey('k'), false); - assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' }); - assert.equal(await deps.refreshAnilistClientSecretState(), 'token'); - deps.enqueueRetry('k', 't', 1, 2); - deps.markRetryFailure('k', 'bad'); - deps.markRetrySuccess('k'); - deps.refreshRetryQueueState(); - assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), { - status: 'updated', - message: 'done:2', - }); - deps.rememberAttemptedUpdateKey('k'); - deps.showMpvOsd('ok'); - deps.logInfo('x'); - deps.logWarn('y'); - assert.equal(deps.minWatchSeconds, 5); - assert.equal(deps.minWatchRatio, 0.5); - assert.deepEqual(calls, [ - 'in-flight', - 'reset', - 'probe:true', - 'enqueue:2', - 'retry-fail', - 'retry-ok', - 'refresh', - 'remember', - 'osd', - 'info:x', - 'warn:y', - ]); -}); diff --git a/src/main/runtime/anilist-post-watch.test.ts b/src/main/runtime/anilist-post-watch.test.ts index 45c97d01..1beb9cd8 100644 --- a/src/main/runtime/anilist-post-watch.test.ts +++ b/src/main/runtime/anilist-post-watch.test.ts @@ -7,6 +7,99 @@ import { rememberAnilistAttemptedUpdateKey, } from './anilist-post-watch'; +type PostWatchDeps = Parameters[0]; +type RetryDeps = Parameters[0]; +type ProbeOptions = Parameters[1]; +type ProgressCall = { + title: string; + episode: number; + season?: number | null; + mediaId?: number | null; +}; + +const MEDIA_KEY = '/tmp/video.mkv'; + +function emptyOutcome() { + return { + inFlight: [] as boolean[], + resets: [] as Array, + probes: [] as ProbeOptions[], + guesses: 0, + retryRuns: 0, + tokenRefreshes: 0, + updates: [] as ProgressCall[], + enqueued: [] as Array, + remembered: [] as string[], + retrySuccesses: [] as string[], + retryFailures: [] as Array<{ key: string; message: string }>, + queueRefreshes: 0, + osd: [] as string[], + info: [] as string[], + warn: [] as string[], + }; +} + +// Defaults model a fully watched local episode 1 with a valid token. Default deps record +// into `outcome`; an overridden dep records nothing unless the test does it itself. +function makeDeps(overrides: Partial = {}) { + const outcome = emptyOutcome(); + const attemptedKeys = new Set(); + let inFlight = false; + const deps: PostWatchDeps = { + getInFlight: () => inFlight, + setInFlight: (value) => { + inFlight = value; + outcome.inFlight.push(value); + }, + getResolvedConfig: () => ({}), + isAnilistTrackingEnabled: () => true, + getCurrentMediaKey: () => MEDIA_KEY, + hasMpvClient: () => true, + getTrackedMediaKey: () => MEDIA_KEY, + resetTrackedMedia: (mediaKey) => outcome.resets.push(mediaKey), + getWatchedSeconds: () => 1000, + maybeProbeAnilistDuration: async (_mediaKey, options) => { + outcome.probes.push(options); + return 1000; + }, + ensureAnilistMediaGuess: async () => { + outcome.guesses += 1; + return { title: 'Show', season: null, episode: 1 }; + }, + hasAttemptedUpdateKey: (key) => attemptedKeys.has(key), + processNextAnilistRetryUpdate: async () => { + outcome.retryRuns += 1; + return { ok: true, message: 'noop' }; + }, + refreshAnilistClientSecretState: async () => { + outcome.tokenRefreshes += 1; + return 'token'; + }, + enqueueRetry: (key, title, episode, season, mediaId) => + outcome.enqueued.push({ key, title, episode, season, mediaId }), + markRetryFailure: (key, message) => outcome.retryFailures.push({ key, message }), + markRetrySuccess: (key) => outcome.retrySuccesses.push(key), + refreshRetryQueueState: () => { + outcome.queueRefreshes += 1; + }, + updateAnilistPostWatchProgress: async (_accessToken, title, episode, season, mediaId) => { + outcome.updates.push({ title, episode, season, mediaId }); + return { status: 'updated', message: 'updated ok' }; + }, + rememberAttemptedUpdateKey: (key) => { + attemptedKeys.add(key); + outcome.remembered.push(key); + }, + showMpvOsd: (message) => outcome.osd.push(message), + logInfo: (message) => outcome.info.push(message), + logWarn: (message) => outcome.warn.push(message), + minWatchSeconds: 600, + minWatchRatio: 0.85, + ...overrides, + }; + return { outcome, attemptedKeys, run: createMaybeRunAnilistPostWatchUpdateHandler(deps) }; +} + test('buildAnilistAttemptKey formats media and episode', () => { assert.equal(buildAnilistAttemptKey('/tmp/video.mkv', 3), '/tmp/video.mkv::3'); assert.equal( @@ -29,554 +122,221 @@ test('post-watch rejects empty media identities before attempted keys or update 'https://[invalid', ]) { assert.equal(buildAnilistAttemptKey(mediaKey, 3), null); - const calls: string[] = []; - const unexpected = () => assert.fail('invalid identity reached update side effects'); - const handler = createMaybeRunAnilistPostWatchUpdateHandler({ - getInFlight: () => false, - setInFlight: (value) => calls.push(`inflight:${value}`), - getResolvedConfig: () => ({}), - isAnilistTrackingEnabled: () => true, + const { outcome, run } = makeDeps({ getCurrentMediaKey: () => mediaKey, - hasMpvClient: () => true, getTrackedMediaKey: () => mediaKey, - resetTrackedMedia: unexpected, - getWatchedSeconds: () => 1000, - maybeProbeAnilistDuration: async () => 1000, - ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }), - hasAttemptedUpdateKey: unexpected, - processNextAnilistRetryUpdate: unexpected, - refreshAnilistClientSecretState: unexpected, - enqueueRetry: unexpected, - markRetryFailure: unexpected, - markRetrySuccess: unexpected, - refreshRetryQueueState: unexpected, - updateAnilistPostWatchProgress: unexpected, - rememberAttemptedUpdateKey: unexpected, - showMpvOsd: unexpected, - logInfo: unexpected, - logWarn: unexpected, - minWatchSeconds: 600, - minWatchRatio: 0.85, + hasAttemptedUpdateKey: () => assert.fail('invalid identity reached attempted-key lookup'), + }); + await run(); + await run({ force: true }); + // Only gating ran: no retry, token, update, queue, or notification side effects. + assert.deepEqual(outcome, { + ...emptyOutcome(), + inFlight: [true, false, true, false], + probes: [{ force: false }], + guesses: 2, }); - await handler(); - await handler({ force: true }); - assert.deepEqual(calls, ['inflight:true', 'inflight:false', 'inflight:true', 'inflight:false']); } }); -test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => { - const calls: string[] = []; - const handler = createProcessNextAnilistRetryUpdateHandler({ - nextReady: () => ({ key: 'k1', title: 'Show', season: 2, episode: 1 }), - refreshRetryQueueState: () => calls.push('refresh'), - setLastAttemptAt: () => calls.push('attempt'), - setLastError: (value) => calls.push(`error:${value ?? 'null'}`), +test('createProcessNextAnilistRetryUpdateHandler forwards the queued item and marks success', async () => { + const updates: ProgressCall[] = []; + const successes: string[] = []; + const remembered: string[] = []; + const errors: Array = []; + const deps: RetryDeps = { + nextReady: () => ({ key: 'k1', title: 'Show', season: 2, mediaId: 108489, episode: 1 }), + refreshRetryQueueState: () => {}, + setLastAttemptAt: () => {}, + setLastError: (value) => errors.push(value), refreshAnilistClientSecretState: async () => 'token', - updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({ - status: 'updated', - message: `updated ok:${season}`, - }), - markSuccess: () => calls.push('success'), - rememberAttemptedUpdateKey: () => calls.push('remember'), - markFailure: () => calls.push('failure'), - logInfo: () => calls.push('info'), - now: () => 1, - }); - - const result = await handler(); - assert.deepEqual(result, { ok: true, message: 'updated ok:2' }); - assert.ok(calls.includes('success')); - assert.ok(calls.includes('remember')); -}); - -test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', async () => { - const calls: string[] = []; - const handler = createMaybeRunAnilistPostWatchUpdateHandler({ - getInFlight: () => false, - setInFlight: (value) => calls.push(`inflight:${value}`), - getResolvedConfig: () => ({}), - isAnilistTrackingEnabled: () => true, - getCurrentMediaKey: () => '/tmp/video.mkv', - hasMpvClient: () => true, - getTrackedMediaKey: () => '/tmp/video.mkv', - resetTrackedMedia: () => {}, - getWatchedSeconds: () => 1000, - maybeProbeAnilistDuration: async () => 1000, - ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }), - hasAttemptedUpdateKey: () => false, - processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }), - refreshAnilistClientSecretState: async () => null, - enqueueRetry: () => calls.push('enqueue'), - markRetryFailure: () => calls.push('mark-failure'), - markRetrySuccess: () => calls.push('mark-success'), - refreshRetryQueueState: () => calls.push('refresh'), - updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }), - rememberAttemptedUpdateKey: () => calls.push('remember'), - showMpvOsd: (message) => calls.push(`osd:${message}`), - logInfo: (message) => calls.push(`info:${message}`), - logWarn: (message) => calls.push(`warn:${message}`), - minWatchSeconds: 600, - minWatchRatio: 0.85, - }); - - await handler(); - assert.ok(calls.includes('enqueue')); - assert.ok(calls.includes('mark-failure')); - assert.ok(calls.includes('osd:AniList: access token not configured')); - assert.ok(calls.includes('inflight:true')); - assert.ok(calls.includes('inflight:false')); -}); - -test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched updates below threshold', async () => { - const calls: string[] = []; - const handler = createMaybeRunAnilistPostWatchUpdateHandler({ - getInFlight: () => false, - setInFlight: (value) => calls.push(`inflight:${value}`), - getResolvedConfig: () => ({}), - isAnilistTrackingEnabled: () => true, - getCurrentMediaKey: () => '/tmp/video.mkv', - hasMpvClient: () => false, - getTrackedMediaKey: () => '/tmp/video.mkv', - resetTrackedMedia: () => {}, - getWatchedSeconds: () => 0, - maybeProbeAnilistDuration: async () => { - calls.push('probe'); - return 1000; - }, - ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }), - hasAttemptedUpdateKey: () => false, - processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }), - refreshAnilistClientSecretState: async () => 'token', - enqueueRetry: () => calls.push('enqueue'), - markRetryFailure: () => calls.push('mark-failure'), - markRetrySuccess: () => calls.push('mark-success'), - refreshRetryQueueState: () => calls.push('refresh'), - updateAnilistPostWatchProgress: async () => { - calls.push('update'); + updateAnilistPostWatchProgress: async (_accessToken, title, episode, season, mediaId) => { + updates.push({ title, episode, season, mediaId }); return { status: 'updated', message: 'updated ok' }; }, - rememberAttemptedUpdateKey: () => calls.push('remember'), - showMpvOsd: (message) => calls.push(`osd:${message}`), - logInfo: (message) => calls.push(`info:${message}`), - logWarn: (message) => calls.push(`warn:${message}`), - minWatchSeconds: 600, - minWatchRatio: 0.85, + markSuccess: (key) => successes.push(key), + rememberAttemptedUpdateKey: (key) => remembered.push(key), + markFailure: () => assert.fail('successful retry marked as failure'), + logInfo: () => {}, + now: () => 1, + }; + + const result = await createProcessNextAnilistRetryUpdateHandler(deps)(); + assert.deepEqual(result, { ok: true, message: 'updated ok' }); + assert.deepEqual(updates, [{ title: 'Show', episode: 1, season: 2, mediaId: 108489 }]); + assert.deepEqual(successes, ['k1']); + assert.deepEqual(remembered, ['k1']); + assert.deepEqual(errors, [null]); +}); + +for (const c of [ + { name: 'queues when token missing', pinnedMediaId: null }, + { name: 'queues the pinned media id for retry', pinnedMediaId: 108489 }, +]) { + test(`createMaybeRunAnilistPostWatchUpdateHandler ${c.name}`, async () => { + const key = `${MEDIA_KEY}::1`; + const { outcome, run } = makeDeps({ + refreshAnilistClientSecretState: async () => null, + resolvePinnedAnilistMediaId: async () => c.pinnedMediaId, + }); + + await run(); + + assert.deepEqual(outcome.updates, []); + assert.deepEqual(outcome.enqueued, [ + { key, title: 'Show', episode: 1, season: null, mediaId: c.pinnedMediaId }, + ]); + assert.deepEqual(outcome.retryFailures, [ + { key, message: 'cannot authenticate without anilist.accessToken' }, + ]); + assert.deepEqual(outcome.osd, ['AniList: access token not configured']); + assert.deepEqual(outcome.inFlight, [true, false]); + }); +} + +test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched updates below threshold', async () => { + const { outcome, run } = makeDeps({ + hasMpvClient: () => false, + getWatchedSeconds: () => 0, + ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }), }); - await handler({ force: true }); + await run({ force: true }); - assert.equal(calls.includes('probe'), false); - assert.ok(calls.includes('update')); - assert.ok(calls.includes('remember')); - assert.ok(calls.includes('osd:updated ok')); + assert.deepEqual(outcome.probes, []); + assert.equal(outcome.updates.length, 1); + assert.deepEqual(outcome.remembered, [`${MEDIA_KEY}::3`]); + assert.deepEqual(outcome.osd, ['updated ok']); }); test('createMaybeRunAnilistPostWatchUpdateHandler shows permanent AniList update errors without queueing retry', async () => { - const calls: string[] = []; - const attemptedKeys = new Set(); + const message = + 'AniList update not possible: Show is not in your AniList Planning or Watching list.'; let updateCalls = 0; - const handler = createMaybeRunAnilistPostWatchUpdateHandler({ - getInFlight: () => false, - setInFlight: (value) => calls.push(`inflight:${value}`), - getResolvedConfig: () => ({}), - isAnilistTrackingEnabled: () => true, - getCurrentMediaKey: () => '/tmp/video.mkv', - hasMpvClient: () => true, - getTrackedMediaKey: () => '/tmp/video.mkv', - resetTrackedMedia: () => {}, - getWatchedSeconds: () => 1000, - maybeProbeAnilistDuration: async () => 1000, + const { outcome, run } = makeDeps({ ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 2 }), - hasAttemptedUpdateKey: (key) => attemptedKeys.has(key), - processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }), - refreshAnilistClientSecretState: async () => 'token', - enqueueRetry: () => calls.push('enqueue'), - markRetryFailure: () => calls.push('mark-failure'), - markRetrySuccess: () => calls.push('mark-success'), - refreshRetryQueueState: () => calls.push('refresh'), updateAnilistPostWatchProgress: async () => { updateCalls += 1; - return { - status: 'error', - retryable: false, - message: - 'AniList update not possible: Show is not in your AniList Planning or Watching list.', - }; + return { status: 'error', retryable: false, message }; }, - rememberAttemptedUpdateKey: (key) => { - attemptedKeys.add(key); - calls.push(`remember:${key}`); - }, - showMpvOsd: (message) => calls.push(`osd:${message}`), - logInfo: (message) => calls.push(`info:${message}`), - logWarn: (message) => calls.push(`warn:${message}`), - minWatchSeconds: 600, - minWatchRatio: 0.85, }); - await handler(); - await handler(); + await run(); + await run(); assert.equal(updateCalls, 1); - assert.equal(calls.includes('enqueue'), false); - assert.equal(calls.includes('mark-failure'), false); - assert.ok(calls.some((call) => call.startsWith('remember:'))); - assert.ok(calls.includes('refresh')); - assert.ok(calls.some((call) => call.startsWith('osd:AniList update not possible'))); - assert.ok(calls.some((call) => call.startsWith('warn:AniList update not possible'))); + assert.deepEqual(outcome.enqueued, []); + assert.deepEqual(outcome.retryFailures, []); + assert.deepEqual(outcome.remembered, [`${MEDIA_KEY}::2`]); + assert.equal(outcome.queueRefreshes, 1); + assert.deepEqual(outcome.osd, [message]); + assert.deepEqual(outcome.warn, [message]); }); test('createMaybeRunAnilistPostWatchUpdateHandler uses provided watched seconds from time-position events', async () => { - const calls: string[] = []; - let durationProbeOptions: unknown = null; - const handler = createMaybeRunAnilistPostWatchUpdateHandler({ - getInFlight: () => false, - setInFlight: (value) => calls.push(`inflight:${value}`), - getResolvedConfig: () => ({}), - isAnilistTrackingEnabled: () => true, - getCurrentMediaKey: () => '/tmp/video.mkv', - hasMpvClient: () => true, - getTrackedMediaKey: () => '/tmp/video.mkv', - resetTrackedMedia: () => {}, + const { outcome, run } = makeDeps({ getWatchedSeconds: () => 0, - maybeProbeAnilistDuration: async (_mediaKey, options) => { - durationProbeOptions = options; - return 1000; - }, ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 2, episode: 8 }), - hasAttemptedUpdateKey: () => false, - processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }), - refreshAnilistClientSecretState: async () => 'token', - enqueueRetry: () => calls.push('enqueue'), - markRetryFailure: () => calls.push('mark-failure'), - markRetrySuccess: () => calls.push('mark-success'), - refreshRetryQueueState: () => calls.push('refresh'), - updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => { - calls.push(`update:${season}`); - return { status: 'updated', message: 'updated ok' }; - }, - rememberAttemptedUpdateKey: () => calls.push('remember'), - showMpvOsd: (message) => calls.push(`osd:${message}`), - logInfo: (message) => calls.push(`info:${message}`), - logWarn: (message) => calls.push(`warn:${message}`), - minWatchSeconds: 600, - minWatchRatio: 0.85, }); - await handler({ watchedSeconds: 850 }); + await run({ watchedSeconds: 850 }); - assert.deepEqual(durationProbeOptions, { force: true }); - assert.ok(calls.includes('update:2')); - assert.ok(calls.includes('remember')); - assert.ok(calls.includes('osd:updated ok')); + assert.deepEqual(outcome.probes, [{ force: true }]); + assert.deepEqual(outcome.updates, [{ title: 'Show', episode: 8, season: 2, mediaId: null }]); + assert.deepEqual(outcome.remembered, [`${MEDIA_KEY}::8`]); + assert.deepEqual(outcome.osd, ['updated ok']); }); test('createMaybeRunAnilistPostWatchUpdateHandler blocks concurrent runs before async gating', async () => { - const calls: string[] = []; - let inFlight = false; + let probes = 0; let resolveDuration!: (duration: number) => void; const durationPromise = new Promise((resolve) => { resolveDuration = resolve; }); - const handler = createMaybeRunAnilistPostWatchUpdateHandler({ - getInFlight: () => inFlight, - setInFlight: (value) => { - inFlight = value; - calls.push(`inflight:${value}`); - }, - getResolvedConfig: () => ({}), - isAnilistTrackingEnabled: () => true, - getCurrentMediaKey: () => '/tmp/video.mkv', - hasMpvClient: () => true, - getTrackedMediaKey: () => '/tmp/video.mkv', - resetTrackedMedia: () => {}, - getWatchedSeconds: () => 1000, + const { outcome, run } = makeDeps({ maybeProbeAnilistDuration: async () => { - calls.push('probe'); + probes += 1; return await durationPromise; }, - ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }), - hasAttemptedUpdateKey: () => false, - processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }), - refreshAnilistClientSecretState: async () => 'token', - enqueueRetry: () => calls.push('enqueue'), - markRetryFailure: () => calls.push('mark-failure'), - markRetrySuccess: () => calls.push('mark-success'), - refreshRetryQueueState: () => calls.push('refresh'), - updateAnilistPostWatchProgress: async () => { - calls.push('update'); - return { status: 'updated', message: 'updated ok' }; - }, - rememberAttemptedUpdateKey: () => calls.push('remember'), - showMpvOsd: (message) => calls.push(`osd:${message}`), - logInfo: (message) => calls.push(`info:${message}`), - logWarn: (message) => calls.push(`warn:${message}`), - minWatchSeconds: 600, - minWatchRatio: 0.85, }); - const firstRun = handler(); - assert.deepEqual(calls, ['inflight:true', 'probe']); + // The in-flight flag must be set synchronously, before the first await. + const firstRun = run(); + assert.deepEqual(outcome.inFlight, [true]); + assert.equal(probes, 1); - await handler(); - assert.deepEqual(calls, ['inflight:true', 'probe']); + await run(); + assert.deepEqual(outcome.inFlight, [true]); + assert.equal(probes, 1); resolveDuration(1000); await firstRun; - assert.equal(calls.filter((call) => call === 'update').length, 1); - assert.equal(calls.at(-1), 'inflight:false'); + assert.equal(outcome.updates.length, 1); + assert.deepEqual(outcome.inFlight, [true, false]); }); test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirely', async () => { - const calls: string[] = []; - const handler = createMaybeRunAnilistPostWatchUpdateHandler({ - getInFlight: () => false, - setInFlight: (value) => calls.push(`inflight:${value}`), - getResolvedConfig: () => ({}), - isAnilistTrackingEnabled: () => true, + const { outcome, run } = makeDeps({ getCurrentMediaKey: () => 'https://www.youtube.com/watch?v=abc123', - hasMpvClient: () => true, - getTrackedMediaKey: () => 'https://www.youtube.com/watch?v=abc123', - resetTrackedMedia: () => calls.push('reset'), - getWatchedSeconds: () => 1000, - maybeProbeAnilistDuration: async () => { - calls.push('probe'); - return 1000; - }, - ensureAnilistMediaGuess: async () => { - calls.push('guess'); - return { title: 'Show', season: null, episode: 1 }; - }, - hasAttemptedUpdateKey: () => false, - processNextAnilistRetryUpdate: async () => { - calls.push('process-retry'); - return { ok: true, message: 'noop' }; - }, - refreshAnilistClientSecretState: async () => { - calls.push('refresh-token'); - return 'token'; - }, - enqueueRetry: () => calls.push('enqueue'), - markRetryFailure: () => calls.push('mark-failure'), - markRetrySuccess: () => calls.push('mark-success'), - refreshRetryQueueState: () => calls.push('refresh'), - updateAnilistPostWatchProgress: async () => { - calls.push('update'); - return { status: 'updated', message: 'ok' }; - }, - rememberAttemptedUpdateKey: () => calls.push('remember'), - showMpvOsd: (message) => calls.push(`osd:${message}`), - logInfo: (message) => calls.push(`info:${message}`), - logWarn: (message) => calls.push(`warn:${message}`), - minWatchSeconds: 600, - minWatchRatio: 0.85, }); - await handler(); - assert.deepEqual(calls, []); + await run(); + + assert.deepEqual(outcome, emptyOutcome()); }); test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already handled current attempt key', async () => { - const calls: string[] = []; - const attemptedKeys = new Set(); - const mediaKey = '/tmp/video.mkv'; - const attemptKey = buildAnilistAttemptKey(mediaKey, 1); - assert.ok(attemptKey); - const handler = createMaybeRunAnilistPostWatchUpdateHandler({ - getInFlight: () => false, - setInFlight: (value) => calls.push(`inflight:${value}`), - getResolvedConfig: () => ({}), - isAnilistTrackingEnabled: () => true, - getCurrentMediaKey: () => mediaKey, - hasMpvClient: () => true, - getTrackedMediaKey: () => mediaKey, - resetTrackedMedia: () => {}, - getWatchedSeconds: () => 1000, - maybeProbeAnilistDuration: async () => 1000, - ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }), - hasAttemptedUpdateKey: (key) => attemptedKeys.has(key), + const { outcome, attemptedKeys, run } = makeDeps({ processNextAnilistRetryUpdate: async () => { - attemptedKeys.add(attemptKey); - calls.push('process-retry'); + attemptedKeys.add(`${MEDIA_KEY}::1`); return { ok: true, message: 'retry ok' }; }, - refreshAnilistClientSecretState: async () => 'token', - enqueueRetry: () => calls.push('enqueue'), - markRetryFailure: () => calls.push('mark-failure'), - markRetrySuccess: () => calls.push('mark-success'), - refreshRetryQueueState: () => calls.push('refresh'), - updateAnilistPostWatchProgress: async () => { - calls.push('update'); - return { status: 'updated', message: 'updated ok' }; - }, - rememberAttemptedUpdateKey: (key) => { - attemptedKeys.add(key); - calls.push(`remember:${key}`); - }, - showMpvOsd: (message) => calls.push(`osd:${message}`), - logInfo: (message) => calls.push(`info:${message}`), - logWarn: (message) => calls.push(`warn:${message}`), - minWatchSeconds: 600, - minWatchRatio: 0.85, }); - await handler(); + await run(); - assert.equal(calls.includes('update'), false); - assert.equal(calls.includes('enqueue'), false); - assert.equal(calls.includes('mark-failure'), false); - assert.deepEqual(calls, ['inflight:true', 'process-retry', 'osd:retry ok', 'inflight:false']); + assert.deepEqual(outcome.osd, ['retry ok']); + assert.equal(outcome.tokenRefreshes, 0); + assert.deepEqual(outcome.updates, []); + assert.deepEqual(outcome.enqueued, []); + assert.deepEqual(outcome.retryFailures, []); + assert.deepEqual(outcome.inFlight, [true, false]); }); test('createMaybeRunAnilistPostWatchUpdateHandler passes the pinned override media id', async () => { - const updateArgs: Array = []; - const pinInputs: Array<{ mediaPath: string | null; mediaTitle: string | null; guess: unknown }> = + const guess = { title: 'Show', season: 3, episode: 1 }; + const pinInputs: Array>[0]> = []; - const handler = createMaybeRunAnilistPostWatchUpdateHandler({ - getInFlight: () => false, - setInFlight: () => {}, - getResolvedConfig: () => ({}), - isAnilistTrackingEnabled: () => true, - getCurrentMediaKey: () => '/tmp/video.mkv', - hasMpvClient: () => true, - getTrackedMediaKey: () => '/tmp/video.mkv', - resetTrackedMedia: () => {}, - getWatchedSeconds: () => 1000, - maybeProbeAnilistDuration: async () => 1000, - ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }), - hasAttemptedUpdateKey: () => false, - processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }), - refreshAnilistClientSecretState: async () => 'token', - enqueueRetry: () => {}, + const { outcome, run } = makeDeps({ + ensureAnilistMediaGuess: async () => guess, getCurrentMediaTitle: () => 'Show S03E01.mkv', resolvePinnedAnilistMediaId: async (input) => { pinInputs.push(input); return 108489; }, - markRetryFailure: () => {}, - markRetrySuccess: () => {}, - refreshRetryQueueState: () => {}, - updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => { - updateArgs.push(mediaId); - return { status: 'updated', message: 'ok' }; - }, - rememberAttemptedUpdateKey: () => {}, - showMpvOsd: () => {}, - logInfo: () => {}, - logWarn: () => {}, - minWatchSeconds: 600, - minWatchRatio: 0.85, }); - await handler(); - assert.deepEqual(updateArgs, [108489]); + await run(); + + assert.deepEqual(outcome.updates, [{ title: 'Show', episode: 1, season: 3, mediaId: 108489 }]); // Resolved against the media this run captured, not whatever is playing now. - assert.equal(pinInputs.length, 1); - assert.equal(pinInputs[0]!.mediaPath, '/tmp/video.mkv'); - assert.equal(pinInputs[0]!.mediaTitle, 'Show S03E01.mkv'); - assert.deepEqual(pinInputs[0]!.guess, { title: 'Show', season: 3, episode: 1 }); -}); - -test('createMaybeRunAnilistPostWatchUpdateHandler queues the pinned media id for retry', async () => { - const enqueued: Array = []; - const handler = createMaybeRunAnilistPostWatchUpdateHandler({ - getInFlight: () => false, - setInFlight: () => {}, - getResolvedConfig: () => ({}), - isAnilistTrackingEnabled: () => true, - getCurrentMediaKey: () => '/tmp/video.mkv', - hasMpvClient: () => true, - getTrackedMediaKey: () => '/tmp/video.mkv', - resetTrackedMedia: () => {}, - getWatchedSeconds: () => 1000, - maybeProbeAnilistDuration: async () => 1000, - ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }), - hasAttemptedUpdateKey: () => false, - processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }), - refreshAnilistClientSecretState: async () => null, - enqueueRetry: (_key, _title, _episode, _season, mediaId) => { - enqueued.push(mediaId); - }, - resolvePinnedAnilistMediaId: async () => 108489, - markRetryFailure: () => {}, - markRetrySuccess: () => {}, - refreshRetryQueueState: () => {}, - updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }), - rememberAttemptedUpdateKey: () => {}, - showMpvOsd: () => {}, - logInfo: () => {}, - logWarn: () => {}, - minWatchSeconds: 600, - minWatchRatio: 0.85, - }); - - await handler(); - assert.deepEqual(enqueued, [108489]); + assert.deepEqual(pinInputs, [{ mediaPath: MEDIA_KEY, mediaTitle: 'Show S03E01.mkv', guess }]); }); test('createMaybeRunAnilistPostWatchUpdateHandler still updates when the override lookup throws', async () => { - const updateArgs: Array = []; - const warnings: string[] = []; - const handler = createMaybeRunAnilistPostWatchUpdateHandler({ - getInFlight: () => false, - setInFlight: () => {}, - getResolvedConfig: () => ({}), - isAnilistTrackingEnabled: () => true, - getCurrentMediaKey: () => '/tmp/video.mkv', - hasMpvClient: () => true, - getTrackedMediaKey: () => '/tmp/video.mkv', - resetTrackedMedia: () => {}, - getWatchedSeconds: () => 1000, - maybeProbeAnilistDuration: async () => 1000, - ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }), - hasAttemptedUpdateKey: () => false, - processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }), - refreshAnilistClientSecretState: async () => 'token', - enqueueRetry: () => {}, + const { outcome, run } = makeDeps({ resolvePinnedAnilistMediaId: async () => { throw new Error('store unreadable'); }, - markRetryFailure: () => {}, - markRetrySuccess: () => {}, - refreshRetryQueueState: () => {}, - updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => { - updateArgs.push(mediaId); - return { status: 'updated', message: 'ok' }; - }, - rememberAttemptedUpdateKey: () => {}, - showMpvOsd: () => {}, - logInfo: () => {}, - logWarn: (message) => warnings.push(message), - minWatchSeconds: 600, - minWatchRatio: 0.85, }); - await handler(); - assert.deepEqual(updateArgs, [null]); - assert.equal(warnings.length, 1); - assert.match(warnings[0]!, /override lookup failed/i); -}); + await run(); -test('createProcessNextAnilistRetryUpdateHandler forwards the queued media id', async () => { - const received: Array = []; - const handler = createProcessNextAnilistRetryUpdateHandler({ - nextReady: () => ({ key: 'k1', title: 'Show', season: 3, mediaId: 108489, episode: 1 }), - refreshRetryQueueState: () => {}, - setLastAttemptAt: () => {}, - setLastError: () => {}, - refreshAnilistClientSecretState: async () => 'token', - updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => { - received.push(mediaId); - return { status: 'updated', message: 'ok' }; - }, - markSuccess: () => {}, - rememberAttemptedUpdateKey: () => {}, - markFailure: () => {}, - logInfo: () => {}, - now: () => 1, - }); - - await handler(); - assert.deepEqual(received, [108489]); + assert.deepEqual( + outcome.updates.map((update) => update.mediaId), + [null], + ); + assert.equal(outcome.warn.length, 1); + assert.match(outcome.warn[0]!, /override lookup failed/i); }); diff --git a/src/main/runtime/anilist-setup-protocol-main-deps.test.ts b/src/main/runtime/anilist-setup-protocol-main-deps.test.ts deleted file mode 100644 index d02fd972..00000000 --- a/src/main/runtime/anilist-setup-protocol-main-deps.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler, - createBuildHandleAnilistSetupProtocolUrlMainDepsHandler, - createBuildNotifyAnilistSetupMainDepsHandler, - createBuildRegisterSubminerProtocolClientMainDepsHandler, -} from './anilist-setup-protocol-main-deps'; - -test('notify anilist setup main deps builder maps callbacks', () => { - const calls: string[] = []; - const deps = createBuildNotifyAnilistSetupMainDepsHandler({ - hasMpvClient: () => true, - showMpvOsd: (message) => calls.push(`osd:${message}`), - showDesktopNotification: (title) => calls.push(`notify:${title}`), - logInfo: (message) => calls.push(`log:${message}`), - })(); - - assert.equal(deps.hasMpvClient(), true); - deps.showMpvOsd('ok'); - deps.showDesktopNotification('SubMiner', { body: 'x' }); - deps.logInfo('done'); - assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']); -}); - -test('notify anilist setup main deps builder preserves optional notification callbacks', () => { - const deps = createBuildNotifyAnilistSetupMainDepsHandler({ - hasMpvClient: () => true, - showMpvOsd: () => {}, - showDesktopNotification: () => {}, - logInfo: () => {}, - })(); - - assert.equal(deps.getNotificationType, undefined); - assert.equal(deps.showOverlayNotification, undefined); -}); - -test('consume anilist setup token main deps builder maps callbacks', () => { - const calls: string[] = []; - const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({ - consumeAnilistSetupCallbackUrl: () => true, - saveToken: () => { - calls.push('save'); - return true; - }, - setCachedToken: () => calls.push('cache'), - setResolvedState: () => calls.push('resolved'), - setSetupPageOpened: () => calls.push('opened'), - onSuccess: () => calls.push('success'), - closeWindow: () => calls.push('close'), - })(); - - assert.equal( - deps.consumeAnilistSetupCallbackUrl({ - rawUrl: 'subminer://anilist-setup', - saveToken: () => true, - setCachedToken: () => {}, - setResolvedState: () => {}, - setSetupPageOpened: () => {}, - onSuccess: () => {}, - closeWindow: () => {}, - }), - true, - ); - deps.saveToken('token'); - deps.setCachedToken('token'); - deps.setResolvedState(Date.now()); - deps.setSetupPageOpened(true); - deps.onSuccess(); - deps.closeWindow(); - assert.deepEqual(calls, ['save', 'cache', 'resolved', 'opened', 'success', 'close']); -}); - -test('handle anilist setup protocol url main deps builder maps callbacks', () => { - const calls: string[] = []; - const deps = createBuildHandleAnilistSetupProtocolUrlMainDepsHandler({ - consumeAnilistSetupTokenFromUrl: () => true, - logWarn: (message) => calls.push(`warn:${message}`), - })(); - - assert.equal(deps.consumeAnilistSetupTokenFromUrl('subminer://anilist-setup'), true); - deps.logWarn('missing', null); - assert.deepEqual(calls, ['warn:missing']); -}); - -test('register subminer protocol client main deps builder maps callbacks', () => { - const calls: string[] = []; - const deps = createBuildRegisterSubminerProtocolClientMainDepsHandler({ - isDefaultApp: () => true, - getArgv: () => ['electron', 'entry.js'], - execPath: '/tmp/electron', - resolvePath: (value) => `/abs/${value}`, - setAsDefaultProtocolClient: () => true, - logDebug: (message) => calls.push(`debug:${message}`), - })(); - - assert.equal(deps.isDefaultApp(), true); - assert.deepEqual(deps.getArgv(), ['electron', 'entry.js']); - assert.equal(deps.execPath, '/tmp/electron'); - assert.equal(deps.resolvePath('entry.js'), '/abs/entry.js'); - assert.equal(deps.setAsDefaultProtocolClient('subminer'), true); -}); diff --git a/src/main/runtime/anilist-setup-protocol.test.ts b/src/main/runtime/anilist-setup-protocol.test.ts index cc32f9e9..3455541c 100644 --- a/src/main/runtime/anilist-setup-protocol.test.ts +++ b/src/main/runtime/anilist-setup-protocol.test.ts @@ -1,7 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - createConsumeAnilistSetupTokenFromUrlHandler, createHandleAnilistSetupProtocolUrlHandler, createNotifyAnilistSetupHandler, createRegisterSubminerProtocolClientHandler, @@ -37,20 +36,6 @@ test('createNotifyAnilistSetupHandler routes through configured notification sur ]); }); -test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => { - const consume = createConsumeAnilistSetupTokenFromUrlHandler({ - consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'), - saveToken: () => true, - setCachedToken: () => {}, - setResolvedState: () => {}, - setSetupPageOpened: () => {}, - onSuccess: () => {}, - closeWindow: () => {}, - }); - assert.equal(consume('subminer://anilist-setup?access_token=ok'), true); - assert.equal(consume('subminer://anilist-setup'), false); -}); - test('createHandleAnilistSetupProtocolUrlHandler validates scheme and logs missing token', () => { const warnings: string[] = []; const handleProtocolUrl = createHandleAnilistSetupProtocolUrlHandler({ diff --git a/src/main/runtime/anilist-setup-window-main-deps.test.ts b/src/main/runtime/anilist-setup-window-main-deps.test.ts deleted file mode 100644 index eceb34db..00000000 --- a/src/main/runtime/anilist-setup-window-main-deps.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { createBuildOpenAnilistSetupWindowMainDepsHandler } from './anilist-setup-window-main-deps'; - -test('open anilist setup window main deps builder maps callbacks', () => { - const calls: string[] = []; - const deps = createBuildOpenAnilistSetupWindowMainDepsHandler({ - maybeFocusExistingSetupWindow: () => false, - createSetupWindow: () => ({}) as never, - buildAuthorizeUrl: () => 'https://anilist.co/auth', - consumeCallbackUrl: () => true, - openSetupInBrowser: (url) => calls.push(`browser:${url}`), - loadManualTokenEntry: () => calls.push('manual'), - redirectUri: 'subminer://anilist-auth', - developerSettingsUrl: 'https://anilist.co/settings/developer', - isAllowedExternalUrl: () => true, - isAllowedNavigationUrl: () => true, - logWarn: (message) => calls.push(`warn:${message}`), - logError: (message) => calls.push(`error:${message}`), - clearSetupWindow: () => calls.push('clear'), - setSetupPageOpened: (opened) => calls.push(`opened:${String(opened)}`), - setSetupWindow: () => calls.push('window'), - openExternal: (url) => calls.push(`external:${url}`), - })(); - - assert.equal(deps.maybeFocusExistingSetupWindow(), false); - assert.equal(deps.buildAuthorizeUrl(), 'https://anilist.co/auth'); - assert.equal(deps.consumeCallbackUrl('subminer://anilist-setup?access_token=x'), true); - assert.equal(deps.redirectUri, 'subminer://anilist-auth'); - assert.equal(deps.developerSettingsUrl, 'https://anilist.co/settings/developer'); - assert.equal(deps.isAllowedExternalUrl('https://anilist.co'), true); - assert.equal(deps.isAllowedNavigationUrl('https://anilist.co/oauth'), true); - deps.openSetupInBrowser('https://anilist.co/auth'); - deps.loadManualTokenEntry({} as never, 'https://anilist.co/auth'); - deps.logWarn('warn'); - deps.logError('error', null); - deps.clearSetupWindow(); - deps.setSetupPageOpened(true); - deps.setSetupWindow({} as never); - deps.openExternal('https://anilist.co'); - - assert.deepEqual(calls, [ - 'browser:https://anilist.co/auth', - 'manual', - 'warn:warn', - 'error:error', - 'clear', - 'opened:true', - 'window', - 'external:https://anilist.co', - ]); -}); diff --git a/src/main/runtime/anilist-setup-window.test.ts b/src/main/runtime/anilist-setup-window.test.ts index 20e91d42..bc3f0efd 100644 --- a/src/main/runtime/anilist-setup-window.test.ts +++ b/src/main/runtime/anilist-setup-window.test.ts @@ -1,12 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - createHandleAnilistSetupWindowClosedHandler, - createMaybeFocusExistingAnilistSetupWindowHandler, - createHandleAnilistSetupWindowOpenedHandler, - createAnilistSetupDidFailLoadHandler, createAnilistSetupDidFinishLoadHandler, - createAnilistSetupDidNavigateHandler, createAnilistSetupFallbackHandler, createAnilistSetupWillNavigateHandler, createAnilistSetupWillRedirectHandler, @@ -32,20 +27,6 @@ test('manual anilist setup submission forwards access token to callback consumer assert.ok(consumed[0]!.includes('https://anilist.subminer.moe/#access_token=abc123')); }); -test('maybe focus anilist setup window focuses existing window', () => { - let focused = false; - const handler = createMaybeFocusExistingAnilistSetupWindowHandler({ - getSetupWindow: () => ({ - focus: () => { - focused = true; - }, - }), - }); - const handled = handler(); - assert.equal(handled, true); - assert.equal(focused, true); -}); - test('manual anilist setup submission warns on missing token', () => { const warnings: string[] = []; const handleSubmission = createHandleManualAnilistSetupSubmissionHandler({ @@ -153,35 +134,6 @@ test('anilist setup will-redirect handler prevents callback redirects', () => { assert.equal(prevented, true); }); -test('anilist setup did-navigate handler consumes callback url', () => { - const seen: string[] = []; - const handler = createAnilistSetupDidNavigateHandler({ - consumeCallbackUrl: (url) => { - seen.push(url); - return true; - }, - }); - - handler('https://anilist.subminer.moe/#access_token=abc'); - assert.deepEqual(seen, ['https://anilist.subminer.moe/#access_token=abc']); -}); - -test('anilist setup did-fail-load handler forwards details', () => { - const seen: Array<{ errorCode: number; errorDescription: string; validatedURL: string }> = []; - const handler = createAnilistSetupDidFailLoadHandler({ - onLoadFailure: (details) => seen.push(details), - }); - - handler({ - errorCode: -3, - errorDescription: 'timeout', - validatedURL: 'https://anilist.co/api/v2/oauth/authorize', - }); - - assert.equal(seen.length, 1); - assert.equal(seen[0]!.errorCode, -3); -}); - test('anilist setup did-finish-load handler triggers fallback on blank page', () => { const calls: string[] = []; const handler = createAnilistSetupDidFinishLoadHandler({ @@ -204,28 +156,6 @@ test('anilist setup did-finish-load handler no-ops on non-blank page', () => { assert.equal(calls.length, 0); }); -test('anilist setup window closed handler clears references', () => { - const calls: string[] = []; - const handler = createHandleAnilistSetupWindowClosedHandler({ - clearSetupWindow: () => calls.push('clear-window'), - setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`), - }); - - handler(); - assert.deepEqual(calls, ['clear-window', 'opened:no']); -}); - -test('anilist setup window opened handler sets references', () => { - const calls: string[] = []; - const handler = createHandleAnilistSetupWindowOpenedHandler({ - setSetupWindow: () => calls.push('set-window'), - setSetupPageOpened: (opened) => calls.push(`opened:${opened ? 'yes' : 'no'}`), - }); - - handler(); - assert.deepEqual(calls, ['set-window', 'opened:yes']); -}); - test('open anilist setup handler no-ops when existing setup window focused', () => { const calls: string[] = []; const handler = createOpenAnilistSetupWindowHandler({ diff --git a/src/main/runtime/anilist-setup.test.ts b/src/main/runtime/anilist-setup.test.ts index 01005bda..4e7e9bf5 100644 --- a/src/main/runtime/anilist-setup.test.ts +++ b/src/main/runtime/anilist-setup.test.ts @@ -116,39 +116,6 @@ test('consumeAnilistSetupCallbackUrl persists token and closes window for callba } }); -test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL', () => { - const originalDateNow = Date.now; - const events: string[] = []; - try { - Date.now = () => 120_000; - const handled = consumeAnilistSetupCallbackUrl({ - rawUrl: 'subminer://anilist-setup?access_token=saved-token', - saveToken: (value: string) => { - events.push(`save:${value}`); - return true; - }, - setCachedToken: (value: string) => events.push(`cache:${value}`), - setResolvedState: (timestampMs: number) => - events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`), - setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`), - onSuccess: () => events.push('success'), - closeWindow: () => events.push('close'), - }); - - assert.equal(handled, true); - assert.deepEqual(events, [ - 'save:saved-token', - 'cache:saved-token', - 'state:ok', - 'opened:false', - 'success', - 'close', - ]); - } finally { - Date.now = originalDateNow; - } -}); - test('consumeAnilistSetupCallbackUrl keeps setup open when token persistence fails', () => { const events: string[] = []; const handled = consumeAnilistSetupCallbackUrl({ diff --git a/src/main/runtime/anilist-token-refresh-main-deps.test.ts b/src/main/runtime/anilist-token-refresh-main-deps.test.ts deleted file mode 100644 index b6b4a723..00000000 --- a/src/main/runtime/anilist-token-refresh-main-deps.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { createBuildRefreshAnilistClientSecretStateMainDepsHandler } from './anilist-token-refresh-main-deps'; - -test('refresh anilist client secret state main deps builder maps callbacks', () => { - const calls: string[] = []; - const config = { anilist: { accessToken: 'token' } }; - const deps = createBuildRefreshAnilistClientSecretStateMainDepsHandler({ - getResolvedConfig: () => config as never, - isAnilistTrackingEnabled: () => true, - getCachedAccessToken: () => 'cached', - setCachedAccessToken: () => calls.push('set-cache'), - saveStoredToken: () => calls.push('save'), - loadStoredToken: () => 'stored', - setClientSecretState: () => calls.push('set-state'), - getAnilistSetupPageOpened: () => false, - setAnilistSetupPageOpened: () => calls.push('set-opened'), - openAnilistSetupWindow: () => calls.push('open-window'), - now: () => 123, - })(); - - assert.equal(deps.getResolvedConfig(), config); - assert.equal(deps.isAnilistTrackingEnabled(config as never), true); - assert.equal(deps.getCachedAccessToken(), 'cached'); - deps.setCachedAccessToken(null); - deps.saveStoredToken('x'); - assert.equal(deps.loadStoredToken(), 'stored'); - deps.setClientSecretState({} as never); - assert.equal(deps.getAnilistSetupPageOpened(), false); - deps.setAnilistSetupPageOpened(true); - deps.openAnilistSetupWindow(); - assert.equal(deps.now(), 123); - assert.deepEqual(calls, ['set-cache', 'save', 'set-state', 'set-opened', 'open-window']); -}); diff --git a/src/main/runtime/anki-actions-main-deps.test.ts b/src/main/runtime/anki-actions-main-deps.test.ts deleted file mode 100644 index a75489ad..00000000 --- a/src/main/runtime/anki-actions-main-deps.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - createBuildMarkLastCardAsAudioCardMainDepsHandler, - createBuildMineSentenceCardMainDepsHandler, - createBuildRefreshKnownWordCacheMainDepsHandler, - createBuildTriggerFieldGroupingMainDepsHandler, - createBuildUpdateLastCardFromClipboardMainDepsHandler, -} from './anki-actions-main-deps'; - -test('anki action main deps builders map callbacks', async () => { - const calls: string[] = []; - - const update = createBuildUpdateLastCardFromClipboardMainDepsHandler({ - getAnkiIntegration: () => ({ enabled: true }), - readClipboardText: () => 'clip', - showMpvOsd: (text) => calls.push(`osd:${text}`), - updateLastCardFromClipboardCore: async () => { - calls.push('update'); - }, - })(); - assert.deepEqual(update.getAnkiIntegration(), { enabled: true }); - assert.equal(update.readClipboardText(), 'clip'); - update.showMpvOsd('x'); - await update.updateLastCardFromClipboardCore({ - ankiIntegration: { enabled: true }, - readClipboardText: () => '', - showMpvOsd: () => {}, - }); - - const refresh = createBuildRefreshKnownWordCacheMainDepsHandler({ - getAnkiIntegration: () => null, - missingIntegrationMessage: 'missing', - })(); - assert.equal(refresh.getAnkiIntegration(), null); - assert.equal(refresh.missingIntegrationMessage, 'missing'); - - const fieldGrouping = createBuildTriggerFieldGroupingMainDepsHandler({ - getAnkiIntegration: () => ({ enabled: true }), - showMpvOsd: (text) => calls.push(`fg:${text}`), - triggerFieldGroupingCore: async () => { - calls.push('trigger'); - }, - })(); - fieldGrouping.showMpvOsd('fg'); - await fieldGrouping.triggerFieldGroupingCore({ - ankiIntegration: { enabled: true }, - showMpvOsd: () => {}, - }); - - const markAudio = createBuildMarkLastCardAsAudioCardMainDepsHandler({ - getAnkiIntegration: () => ({ enabled: true }), - showMpvOsd: (text) => calls.push(`audio:${text}`), - markLastCardAsAudioCardCore: async () => { - calls.push('mark'); - }, - })(); - markAudio.showMpvOsd('a'); - await markAudio.markLastCardAsAudioCardCore({ - ankiIntegration: { enabled: true }, - showMpvOsd: () => {}, - }); - - const mine = createBuildMineSentenceCardMainDepsHandler({ - getAnkiIntegration: () => ({ enabled: true }), - getMpvClient: () => ({ connected: true }), - getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }), - showMpvOsd: (text) => calls.push(`mine:${text}`), - mineSentenceCardCore: async () => true, - })(); - assert.deepEqual(mine.getMpvClient(), { connected: true }); - assert.deepEqual(mine.getPrimarySubtitle?.(), { - text: '正式な字幕', - startTime: 1, - endTime: 3, - }); - mine.showMpvOsd('m'); - await mine.mineSentenceCardCore({ - ankiIntegration: { enabled: true }, - mpvClient: { connected: true }, - showMpvOsd: () => {}, - }); - - assert.deepEqual(calls, ['osd:x', 'update', 'fg:fg', 'trigger', 'audio:a', 'mark', 'mine:m']); -}); diff --git a/src/main/runtime/anki-actions.test.ts b/src/main/runtime/anki-actions.test.ts index 2be35a88..b475f226 100644 --- a/src/main/runtime/anki-actions.test.ts +++ b/src/main/runtime/anki-actions.test.ts @@ -1,31 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { - createMarkLastCardAsAudioCardHandler, - createMineSentenceCardHandler, - createRefreshKnownWordCacheHandler, - createTriggerFieldGroupingHandler, - createUpdateLastCardFromClipboardHandler, -} from './anki-actions'; - -test('update last card handler forwards integration/clipboard/osd deps', async () => { - const calls: string[] = []; - const integration = {}; - const updateLastCard = createUpdateLastCardFromClipboardHandler({ - getAnkiIntegration: () => integration, - readClipboardText: () => 'clipboard-value', - showMpvOsd: (text) => calls.push(`osd:${text}`), - updateLastCardFromClipboardCore: async (options) => { - assert.equal(options.ankiIntegration, integration); - assert.equal(options.readClipboardText(), 'clipboard-value'); - options.showMpvOsd('ok'); - calls.push('core'); - }, - }); - - await updateLastCard(); - assert.deepEqual(calls, ['osd:ok', 'core']); -}); +import { createMineSentenceCardHandler, createRefreshKnownWordCacheHandler } from './anki-actions'; test('refresh known word cache handler throws when Anki integration missing', async () => { const refresh = createRefreshKnownWordCacheHandler({ @@ -36,57 +11,6 @@ test('refresh known word cache handler throws when Anki integration missing', as await assert.rejects(() => refresh(), /AnkiConnect integration not enabled/); }); -test('trigger and mark handlers delegate to core services', async () => { - const calls: string[] = []; - const integration = {}; - const triggerFieldGrouping = createTriggerFieldGroupingHandler({ - getAnkiIntegration: () => integration, - showMpvOsd: (text) => calls.push(`osd:${text}`), - triggerFieldGroupingCore: async (options) => { - assert.equal(options.ankiIntegration, integration); - options.showMpvOsd('group'); - calls.push('group-core'); - }, - }); - const markAudio = createMarkLastCardAsAudioCardHandler({ - getAnkiIntegration: () => integration, - showMpvOsd: (text) => calls.push(`osd:${text}`), - markLastCardAsAudioCardCore: async (options) => { - assert.equal(options.ankiIntegration, integration); - options.showMpvOsd('mark'); - calls.push('mark-core'); - }, - }); - - await triggerFieldGrouping(); - await markAudio(); - assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']); -}); - -test('mine sentence handler leaves card counting to card creation', async () => { - const calls: string[] = []; - const integration = {}; - const mpvClient = {}; - let created = false; - const mineSentenceCard = createMineSentenceCardHandler({ - getAnkiIntegration: () => integration, - getMpvClient: () => mpvClient, - showMpvOsd: (text) => calls.push(`osd:${text}`), - mineSentenceCardCore: async (options) => { - assert.equal(options.ankiIntegration, integration); - assert.equal(options.mpvClient, mpvClient); - options.showMpvOsd('mine'); - return created; - }, - }); - - created = false; - await mineSentenceCard(); - created = true; - await mineSentenceCard(); - assert.deepEqual(calls, ['osd:mine', 'osd:mine']); -}); - test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => { const primarySubtitle = { text: '正式な字幕', startTime: 1, endTime: 3 }; const mineSentenceCard = createMineSentenceCardHandler({ diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index 1fff472d..325fd60e 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -3,10 +3,62 @@ import assert from 'node:assert/strict'; import { createForceQuitHandler, createOnWillQuitCleanupHandler, - createRestoreWindowsOnActivateHandler, createShouldRestoreWindowsOnActivateHandler, } from './app-lifecycle-actions'; +type CleanupDeps = Parameters[0]; + +// Every cleanup step records its own dep name into `calls` unless overridden. +function makeCleanupDeps(calls: string[], overrides: Partial = {}): CleanupDeps { + const record = (name: string) => () => { + calls.push(name); + }; + return { + destroyTray: record('destroyTray'), + stopConfigHotReload: record('stopConfigHotReload'), + restorePreviousSecondarySubVisibility: record('restorePreviousSecondarySubVisibility'), + restoreMpvSubVisibility: record('restoreMpvSubVisibility'), + unregisterAllGlobalShortcuts: record('unregisterAllGlobalShortcuts'), + stopSubtitleWebsocket: record('stopSubtitleWebsocket'), + stopTexthookerService: record('stopTexthookerService'), + stopSyncAutoScheduler: record('stopSyncAutoScheduler'), + clearWindowsVisibleOverlayForegroundPollLoop: record( + 'clearWindowsVisibleOverlayForegroundPollLoop', + ), + clearLinuxMpvFullscreenOverlayRefreshTimeouts: record( + 'clearLinuxMpvFullscreenOverlayRefreshTimeouts', + ), + destroyMainOverlayWindow: record('destroyMainOverlayWindow'), + destroyModalOverlayWindow: record('destroyModalOverlayWindow'), + destroyYomitanParserWindow: record('destroyYomitanParserWindow'), + clearYomitanParserState: record('clearYomitanParserState'), + stopWindowTracker: record('stopWindowTracker'), + flushMpvLog: record('flushMpvLog'), + destroyMpvSocket: record('destroyMpvSocket'), + clearReconnectTimer: record('clearReconnectTimer'), + destroySubtitleTimingTracker: record('destroySubtitleTimingTracker'), + stopStatsServer: record('stopStatsServer'), + destroyImmersionTracker: record('destroyImmersionTracker'), + destroyAnkiIntegration: record('destroyAnkiIntegration'), + destroyAnilistSetupWindow: record('destroyAnilistSetupWindow'), + clearAnilistSetupWindow: record('clearAnilistSetupWindow'), + destroyJellyfinSetupWindow: record('destroyJellyfinSetupWindow'), + clearJellyfinSetupWindow: record('clearJellyfinSetupWindow'), + destroyFirstRunSetupWindow: record('destroyFirstRunSetupWindow'), + clearFirstRunSetupWindow: record('clearFirstRunSetupWindow'), + destroyYomitanSettingsWindow: record('destroyYomitanSettingsWindow'), + clearYomitanSettingsWindow: record('clearYomitanSettingsWindow'), + stopJellyfinRemoteSession: record('stopJellyfinRemoteSession'), + cleanupInternalSubtitleTrackCache: record('cleanupInternalSubtitleTrackCache'), + cleanupYoutubeSubtitleTempDirs: record('cleanupYoutubeSubtitleTempDirs'), + cleanupYoutubeMediaCache: record('cleanupYoutubeMediaCache'), + cleanupRemoteMediaWindows: record('cleanupRemoteMediaWindows'), + cleanupJellyfinSubtitleCache: record('cleanupJellyfinSubtitleCache'), + stopDiscordPresenceService: record('stopDiscordPresenceService'), + ...overrides, + }; +} + test('forced quit finalizes stats before exiting, even when finalization throws', async () => { for (const fails of [false, true]) { const calls: string[] = []; @@ -26,86 +78,44 @@ test('forced quit finalizes stats before exiting, even when finalization throws' } }); -test('on will quit cleanup handler runs all cleanup steps', async () => { +test('on will quit cleanup handler flushes before teardown and awaits stats before dependents', async () => { const calls: string[] = []; - const cleanup = createOnWillQuitCleanupHandler({ - destroyTray: () => calls.push('destroy-tray'), - stopConfigHotReload: () => calls.push('stop-config'), - restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'), - restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), - unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), - stopSubtitleWebsocket: () => calls.push('stop-ws'), - stopTexthookerService: () => calls.push('stop-texthooker'), - stopSyncAutoScheduler: () => { - calls.push('stop-sync-auto-scheduler'); - }, - clearWindowsVisibleOverlayForegroundPollLoop: () => - calls.push('clear-windows-visible-overlay-poll'), - clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => - calls.push('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'), - destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'), - destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'), - destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'), - clearYomitanParserState: () => calls.push('clear-yomitan-state'), - stopWindowTracker: () => calls.push('stop-tracker'), - flushMpvLog: () => calls.push('flush-mpv-log'), - destroyMpvSocket: () => calls.push('destroy-socket'), - clearReconnectTimer: () => calls.push('clear-reconnect'), - destroySubtitleTimingTracker: () => calls.push('destroy-subtitle-tracker'), - stopStatsServer: async () => { - calls.push('stop-stats-server-start'); - await Promise.resolve(); - calls.push('stop-stats-server-complete'); - }, - destroyImmersionTracker: async () => { - await Promise.resolve(); - calls.push('destroy-immersion'); - }, - destroyAnkiIntegration: () => calls.push('destroy-anki'), - destroyAnilistSetupWindow: () => calls.push('destroy-anilist-window'), - clearAnilistSetupWindow: () => calls.push('clear-anilist-window'), - destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'), - clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'), - destroyFirstRunSetupWindow: () => calls.push('destroy-first-run-window'), - clearFirstRunSetupWindow: () => calls.push('clear-first-run-window'), - destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'), - clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), - stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), - cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'), - cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), - cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'), - cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'), - cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), - stopDiscordPresenceService: () => calls.push('stop-discord-presence'), - }); + const cleanup = createOnWillQuitCleanupHandler( + makeCleanupDeps(calls, { + stopStatsServer: async () => { + await Promise.resolve(); + calls.push('stopStatsServer:complete'); + }, + destroyImmersionTracker: async () => { + await Promise.resolve(); + calls.push('destroyImmersionTracker'); + }, + }), + ); await cleanup(); - assert.equal(calls.length, 38); - assert.equal(calls[0], 'destroy-tray'); - assert.equal(calls[calls.length - 1], 'stop-discord-presence'); - assert.ok(calls.includes('cleanup-jellyfin-subtitles')); - assert.ok(calls.includes('cleanup-internal-subtitles')); - assert.ok(calls.includes('clear-windows-visible-overlay-poll')); - assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); - assert.ok(calls.includes('cleanup-youtube-subtitles')); - assert.ok(calls.includes('cleanup-youtube-media')); - assert.ok(calls.includes('cleanup-remote-media-windows')); - assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); - assert.ok(calls.indexOf('stop-stats-server-complete') < calls.indexOf('destroy-immersion')); - assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('destroy-anki')); + assert.ok(calls.indexOf('flushMpvLog') < calls.indexOf('destroyMpvSocket')); + assert.ok(calls.indexOf('stopStatsServer:complete') < calls.indexOf('destroyImmersionTracker')); + assert.ok(calls.indexOf('destroyImmersionTracker') < calls.indexOf('destroyAnkiIntegration')); }); -test('forced quit waits for asynchronous stats finalization', async () => { +test('on will quit cleanup handler cleans subtitle caches when stopping remote session fails', async () => { const calls: string[] = []; - await createForceQuitHandler({ - destroyImmersionTracker: async () => { - await Promise.resolve(); - calls.push('finalized'); - }, - logError: () => calls.push('error'), - exit: () => calls.push('exit'), - })(); - assert.deepEqual(calls, ['finalized', 'exit']); + const cleanup = createOnWillQuitCleanupHandler( + makeCleanupDeps(calls, { + stopJellyfinRemoteSession: () => { + calls.push('stopJellyfinRemoteSession'); + throw new Error('stop failed'); + }, + }), + ); + + await assert.rejects(cleanup(), /stop failed/); + assert.deepEqual(calls.slice(calls.indexOf('stopJellyfinRemoteSession')), [ + 'stopJellyfinRemoteSession', + 'cleanupJellyfinSubtitleCache', + 'cleanupInternalSubtitleTrackCache', + ]); }); test('forced quit exits when asynchronous stats finalization never settles', async () => { @@ -121,59 +131,6 @@ test('forced quit exits when asynchronous stats finalization never settles', asy assert.deepEqual(calls, ['timeout', 'exit']); }); -test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', async () => { - const calls: string[] = []; - const cleanup = createOnWillQuitCleanupHandler({ - destroyTray: () => {}, - stopConfigHotReload: () => {}, - restorePreviousSecondarySubVisibility: () => {}, - restoreMpvSubVisibility: () => {}, - unregisterAllGlobalShortcuts: () => {}, - stopSubtitleWebsocket: () => {}, - stopTexthookerService: () => {}, - stopSyncAutoScheduler: () => {}, - clearWindowsVisibleOverlayForegroundPollLoop: () => {}, - clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {}, - destroyMainOverlayWindow: () => {}, - destroyModalOverlayWindow: () => {}, - destroyYomitanParserWindow: () => {}, - clearYomitanParserState: () => {}, - stopWindowTracker: () => {}, - flushMpvLog: () => {}, - destroyMpvSocket: () => {}, - clearReconnectTimer: () => {}, - destroySubtitleTimingTracker: () => {}, - stopStatsServer: () => {}, - destroyImmersionTracker: () => {}, - destroyAnkiIntegration: () => {}, - destroyAnilistSetupWindow: () => {}, - clearAnilistSetupWindow: () => {}, - destroyJellyfinSetupWindow: () => {}, - clearJellyfinSetupWindow: () => {}, - destroyFirstRunSetupWindow: () => {}, - clearFirstRunSetupWindow: () => {}, - destroyYomitanSettingsWindow: () => {}, - clearYomitanSettingsWindow: () => {}, - stopJellyfinRemoteSession: () => { - calls.push('stop-jellyfin-remote'); - throw new Error('stop failed'); - }, - cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'), - cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), - cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'), - cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'), - cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), - stopDiscordPresenceService: () => calls.push('stop-discord-presence'), - }); - - await assert.rejects(cleanup(), /stop failed/); - assert.deepEqual(calls, [ - 'stop-jellyfin-remote', - 'cleanup-jellyfin-subtitles', - 'cleanup-internal-subtitles', - ]); -}); - test('should restore windows on activate requires initialized runtime and no windows', () => { let initialized = false; let windowCount = 1; @@ -188,15 +145,3 @@ test('should restore windows on activate requires initialized runtime and no win windowCount = 0; assert.equal(shouldRestore(), true); }); - -test('restore windows on activate recreates windows then syncs visibility', () => { - const calls: string[] = []; - const restore = createRestoreWindowsOnActivateHandler({ - createMainWindow: () => calls.push('main'), - updateVisibleOverlayVisibility: () => calls.push('visible-sync'), - syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'), - }); - - restore(); - assert.deepEqual(calls, ['main', 'visible-sync', 'mpv-sync']); -}); diff --git a/src/main/runtime/app-lifecycle-main-activate.test.ts b/src/main/runtime/app-lifecycle-main-activate.test.ts deleted file mode 100644 index 786078c5..00000000 --- a/src/main/runtime/app-lifecycle-main-activate.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - createBuildRestoreWindowsOnActivateMainDepsHandler, - createBuildShouldRestoreWindowsOnActivateMainDepsHandler, -} from './app-lifecycle-main-activate'; - -test('should restore windows on activate deps builder maps visibility state checks', () => { - const deps = createBuildShouldRestoreWindowsOnActivateMainDepsHandler({ - isOverlayRuntimeInitialized: () => true, - getAllWindowCount: () => 0, - })(); - - assert.equal(deps.isOverlayRuntimeInitialized(), true); - assert.equal(deps.getAllWindowCount(), 0); -}); - -test('restore windows on activate deps builder maps all restoration callbacks', () => { - const calls: string[] = []; - const deps = createBuildRestoreWindowsOnActivateMainDepsHandler({ - createMainWindow: () => calls.push('main'), - updateVisibleOverlayVisibility: () => calls.push('visible'), - syncOverlayMpvSubtitleSuppression: () => calls.push('mpv-sync'), - })(); - - deps.createMainWindow(); - deps.updateVisibleOverlayVisibility(); - deps.syncOverlayMpvSubtitleSuppression(); - assert.deepEqual(calls, ['main', 'visible', 'mpv-sync']); -}); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index 54a3bf15..9bc04239 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -3,231 +3,134 @@ import test from 'node:test'; import { createBuildOnWillQuitCleanupDepsHandler } from './app-lifecycle-main-cleanup'; import { createOnWillQuitCleanupHandler } from './app-lifecycle-actions'; -test('cleanup deps builder returns handlers that guard optional runtime objects', async () => { - const calls: string[] = []; - let reconnectTimer: ReturnType | null = setTimeout(() => {}, 60_000); - let immersionTracker: { destroy: () => Promise } | null = { - destroy: async () => { - await Promise.resolve(); - calls.push('destroy-immersion'); - }, +type BuilderDeps = Parameters[0]; + +// Defaults model an app that is ready but has no live runtime objects. +function buildCleanupDeps(overrides: Partial = {}) { + const noop = () => {}; + const none = () => null; + return createBuildOnWillQuitCleanupDepsHandler({ + destroyTray: noop, + stopConfigHotReload: noop, + restorePreviousSecondarySubVisibility: noop, + restoreMpvSubVisibility: noop, + isAppReady: () => true, + unregisterAllGlobalShortcuts: noop, + stopSubtitleWebsocket: noop, + stopTexthookerService: noop, + stopSyncAutoScheduler: noop, + clearWindowsVisibleOverlayForegroundPollLoop: noop, + clearLinuxMpvFullscreenOverlayRefreshTimeouts: noop, + getMainOverlayWindow: none, + clearMainOverlayWindow: noop, + getModalOverlayWindow: none, + clearModalOverlayWindow: noop, + getYomitanParserWindow: none, + clearYomitanParserState: noop, + getWindowTracker: none, + flushMpvLog: noop, + getMpvSocket: none, + getReconnectTimer: none, + clearReconnectTimerRef: noop, + getSubtitleTimingTracker: none, + getImmersionTracker: none, + stopStatsServer: noop, + clearImmersionTracker: noop, + getAnkiIntegration: none, + getAnilistSetupWindow: none, + clearAnilistSetupWindow: noop, + getJellyfinSetupWindow: none, + clearJellyfinSetupWindow: noop, + getFirstRunSetupWindow: none, + clearFirstRunSetupWindow: noop, + getYomitanSettingsWindow: none, + clearYomitanSettingsWindow: noop, + stopJellyfinRemoteSession: noop, + cleanupInternalSubtitleTrackCache: noop, + cleanupYoutubeSubtitleTempDirs: noop, + cleanupYoutubeMediaCache: noop, + cleanupRemoteMediaWindows: noop, + cleanupJellyfinSubtitleCache: noop, + stopDiscordPresenceService: noop, + ...overrides, + })(); +} + +function fakeWindow(calls: string[], destroyed: boolean) { + return { + isDestroyed: () => destroyed, + destroy: () => calls.push('destroy'), }; +} - const depsFactory = createBuildOnWillQuitCleanupDepsHandler({ - destroyTray: () => calls.push('destroy-tray'), - stopConfigHotReload: () => calls.push('stop-config'), - restorePreviousSecondarySubVisibility: () => calls.push('restore-sub'), - restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), - isAppReady: () => true, - unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), - stopSubtitleWebsocket: () => calls.push('stop-ws'), - stopTexthookerService: () => calls.push('stop-texthooker'), - stopSyncAutoScheduler: () => { - calls.push('stop-sync-auto-scheduler'); - }, - clearWindowsVisibleOverlayForegroundPollLoop: () => - calls.push('clear-windows-visible-overlay-foreground-poll-loop'), - clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => - calls.push('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'), - getMainOverlayWindow: () => ({ - isDestroyed: () => false, - destroy: () => calls.push('destroy-main-overlay-window'), - }), - clearMainOverlayWindow: () => calls.push('clear-main-overlay-window'), - getModalOverlayWindow: () => ({ - isDestroyed: () => false, - destroy: () => calls.push('destroy-modal-overlay-window'), - }), - clearModalOverlayWindow: () => calls.push('clear-modal-overlay-window'), - - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - destroy: () => calls.push('destroy-yomitan-window'), - }), - clearYomitanParserState: () => calls.push('clear-yomitan-state'), - - getWindowTracker: () => ({ stop: () => calls.push('stop-tracker') }), - flushMpvLog: () => calls.push('flush-mpv-log'), - getMpvSocket: () => ({ destroy: () => calls.push('destroy-socket') }), - getReconnectTimer: () => reconnectTimer, - clearReconnectTimerRef: () => { - reconnectTimer = null; - calls.push('clear-reconnect-ref'); - }, - - getSubtitleTimingTracker: () => ({ destroy: () => calls.push('destroy-subtitle-tracker') }), - getImmersionTracker: () => immersionTracker, - stopStatsServer: () => { - calls.push('stop-stats-server'); - }, - clearImmersionTracker: () => { - immersionTracker = null; - calls.push('clear-immersion-ref'); - }, - getAnkiIntegration: () => ({ destroy: () => calls.push('destroy-anki') }), - - getAnilistSetupWindow: () => ({ destroy: () => calls.push('destroy-anilist-window') }), - clearAnilistSetupWindow: () => calls.push('clear-anilist-window'), - getJellyfinSetupWindow: () => ({ destroy: () => calls.push('destroy-jellyfin-window') }), - clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'), - getFirstRunSetupWindow: () => ({ destroy: () => calls.push('destroy-first-run-window') }), - clearFirstRunSetupWindow: () => calls.push('clear-first-run-window'), - getYomitanSettingsWindow: () => ({ - destroy: () => calls.push('destroy-yomitan-settings-window'), - }), - clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), - - stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), - cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'), - cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), - cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'), - cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'), - cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), - stopDiscordPresenceService: () => calls.push('stop-discord-presence'), - }); - - const cleanup = createOnWillQuitCleanupHandler(depsFactory()); - await cleanup(); - - assert.ok(calls.includes('destroy-tray')); - assert.ok(calls.includes('destroy-main-overlay-window')); - assert.ok(calls.includes('clear-main-overlay-window')); - assert.ok(calls.includes('destroy-modal-overlay-window')); - assert.ok(calls.includes('clear-modal-overlay-window')); - assert.ok(calls.includes('destroy-yomitan-window')); - assert.ok(calls.includes('flush-mpv-log')); - assert.ok(calls.includes('destroy-socket')); - assert.ok(calls.includes('clear-reconnect-ref')); - assert.ok(calls.includes('destroy-immersion')); - assert.ok(calls.includes('clear-immersion-ref')); - assert.ok(calls.indexOf('destroy-immersion') < calls.indexOf('clear-immersion-ref')); - assert.ok(calls.includes('destroy-first-run-window')); - assert.ok(calls.includes('destroy-yomitan-settings-window')); - assert.ok(calls.includes('stop-jellyfin-remote')); - assert.ok(calls.includes('cleanup-internal-subtitles')); - assert.ok(calls.includes('cleanup-youtube-subtitles')); - assert.ok(calls.includes('cleanup-youtube-media')); - assert.ok(calls.includes('cleanup-jellyfin-subtitles')); - assert.ok(calls.includes('stop-discord-presence')); - assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop')); - assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); - assert.equal(reconnectTimer, null); - assert.equal(immersionTracker, null); +test('cleanup deps builder runs the full quit cleanup with no runtime objects', async () => { + await createOnWillQuitCleanupHandler(buildCleanupDeps())(); }); -test('cleanup deps builder skips destroyed yomitan window', () => { - const calls: string[] = []; - const depsFactory = createBuildOnWillQuitCleanupDepsHandler({ - destroyTray: () => {}, - stopConfigHotReload: () => {}, - restorePreviousSecondarySubVisibility: () => {}, - restoreMpvSubVisibility: () => {}, - isAppReady: () => true, - unregisterAllGlobalShortcuts: () => {}, - stopSubtitleWebsocket: () => {}, - stopTexthookerService: () => {}, - stopSyncAutoScheduler: () => {}, - clearWindowsVisibleOverlayForegroundPollLoop: () => {}, - clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {}, - getMainOverlayWindow: () => ({ - isDestroyed: () => true, - destroy: () => calls.push('destroy-main-overlay-window'), - }), - clearMainOverlayWindow: () => calls.push('clear-main-overlay-window'), - getModalOverlayWindow: () => ({ - isDestroyed: () => true, - destroy: () => calls.push('destroy-modal-overlay-window'), - }), - clearModalOverlayWindow: () => calls.push('clear-modal-overlay-window'), - getYomitanParserWindow: () => ({ - isDestroyed: () => true, - destroy: () => calls.push('destroy-yomitan-window'), - }), - clearYomitanParserState: () => {}, - getWindowTracker: () => null, - flushMpvLog: () => {}, - getMpvSocket: () => null, - getReconnectTimer: () => null, - clearReconnectTimerRef: () => {}, - getSubtitleTimingTracker: () => null, - getImmersionTracker: () => null, - stopStatsServer: () => {}, - clearImmersionTracker: () => {}, - getAnkiIntegration: () => null, - getAnilistSetupWindow: () => null, - clearAnilistSetupWindow: () => {}, - getJellyfinSetupWindow: () => null, - clearJellyfinSetupWindow: () => {}, - getFirstRunSetupWindow: () => null, - clearFirstRunSetupWindow: () => {}, - getYomitanSettingsWindow: () => null, - clearYomitanSettingsWindow: () => {}, - stopJellyfinRemoteSession: () => {}, - cleanupInternalSubtitleTrackCache: () => {}, - cleanupYoutubeSubtitleTempDirs: () => {}, - cleanupYoutubeMediaCache: () => {}, - cleanupRemoteMediaWindows: () => {}, - cleanupJellyfinSubtitleCache: () => {}, - stopDiscordPresenceService: () => {}, +for (const destroyed of [false, true]) { + test(`cleanup deps builder ${destroyed ? 'skips' : 'destroys and clears'} ${destroyed ? 'already destroyed' : 'live'} overlay windows`, () => { + const calls: string[] = []; + const deps = buildCleanupDeps({ + getMainOverlayWindow: () => fakeWindow(calls, destroyed), + clearMainOverlayWindow: () => calls.push('clear'), + getModalOverlayWindow: () => fakeWindow(calls, destroyed), + clearModalOverlayWindow: () => calls.push('clear'), + getYomitanParserWindow: () => fakeWindow(calls, destroyed), + }); + + deps.destroyMainOverlayWindow(); + deps.destroyModalOverlayWindow(); + deps.destroyYomitanParserWindow(); + + assert.deepEqual(calls, destroyed ? [] : ['destroy', 'clear', 'destroy', 'clear', 'destroy']); }); - - const cleanup = createOnWillQuitCleanupHandler(depsFactory()); - cleanup(); - - assert.deepEqual(calls, []); -}); +} test('cleanup deps builder skips global shortcut cleanup before app ready', () => { - const calls: string[] = []; - const depsFactory = createBuildOnWillQuitCleanupDepsHandler({ - destroyTray: () => calls.push('destroy-tray'), - stopConfigHotReload: () => {}, - restorePreviousSecondarySubVisibility: () => {}, - restoreMpvSubVisibility: () => {}, + const deps = buildCleanupDeps({ isAppReady: () => false, unregisterAllGlobalShortcuts: () => { throw new Error('globalShortcut cannot be used before the app is ready'); }, - stopSubtitleWebsocket: () => {}, - stopTexthookerService: () => {}, - stopSyncAutoScheduler: () => {}, - clearWindowsVisibleOverlayForegroundPollLoop: () => {}, - clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {}, - getMainOverlayWindow: () => null, - clearMainOverlayWindow: () => {}, - getModalOverlayWindow: () => null, - clearModalOverlayWindow: () => {}, - getYomitanParserWindow: () => null, - clearYomitanParserState: () => {}, - getWindowTracker: () => null, - flushMpvLog: () => {}, - getMpvSocket: () => null, - getReconnectTimer: () => null, - clearReconnectTimerRef: () => {}, - getSubtitleTimingTracker: () => null, - getImmersionTracker: () => null, - stopStatsServer: () => {}, - clearImmersionTracker: () => {}, - getAnkiIntegration: () => null, - getAnilistSetupWindow: () => null, - clearAnilistSetupWindow: () => {}, - getJellyfinSetupWindow: () => null, - clearJellyfinSetupWindow: () => {}, - getFirstRunSetupWindow: () => null, - clearFirstRunSetupWindow: () => {}, - getYomitanSettingsWindow: () => null, - clearYomitanSettingsWindow: () => {}, - stopJellyfinRemoteSession: () => {}, - cleanupInternalSubtitleTrackCache: () => {}, - cleanupYoutubeSubtitleTempDirs: () => {}, - cleanupYoutubeMediaCache: () => {}, - cleanupRemoteMediaWindows: () => {}, - cleanupJellyfinSubtitleCache: () => {}, - stopDiscordPresenceService: () => {}, }); - const cleanup = createOnWillQuitCleanupHandler(depsFactory()); - cleanup(); + deps.unregisterAllGlobalShortcuts(); +}); - assert.deepEqual(calls, ['destroy-tray']); +test('cleanup deps builder clears the reconnect timer and its reference', () => { + let reconnectTimer: ReturnType | null = setTimeout(() => { + assert.fail('reconnect timer should have been cleared'); + }, 0); + const deps = buildCleanupDeps({ + getReconnectTimer: () => reconnectTimer, + clearReconnectTimerRef: () => { + reconnectTimer = null; + }, + }); + + deps.clearReconnectTimer(); + + assert.equal(reconnectTimer, null); +}); + +test('cleanup deps builder clears the immersion tracker only after destroy settles', async () => { + const calls: string[] = []; + let immersionTracker: { destroy: () => Promise } | null = { + destroy: async () => { + await Promise.resolve(); + calls.push('destroy'); + }, + }; + const deps = buildCleanupDeps({ + getImmersionTracker: () => immersionTracker, + clearImmersionTracker: () => { + immersionTracker = null; + calls.push('clear'); + }, + }); + + await deps.destroyImmersionTracker(); + + assert.deepEqual(calls, ['destroy', 'clear']); + assert.equal(immersionTracker, null); }); diff --git a/src/main/runtime/app-ready-main-deps.test.ts b/src/main/runtime/app-ready-main-deps.test.ts deleted file mode 100644 index 832d58fe..00000000 --- a/src/main/runtime/app-ready-main-deps.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { createBuildAppReadyRuntimeMainDepsHandler } from './app-ready-main-deps'; - -test('app-ready main deps builder returns mapped app-ready runtime deps', async () => { - const calls: string[] = []; - const onReady = createBuildAppReadyRuntimeMainDepsHandler({ - ensureDefaultConfigBootstrap: () => calls.push('bootstrap-config'), - loadSubtitlePosition: () => calls.push('load-subtitle-position'), - resolveKeybindings: () => calls.push('resolve-keybindings'), - createMpvClient: () => calls.push('create-mpv-client'), - reloadConfig: () => calls.push('reload-config'), - getResolvedConfig: () => ({ websocket: {} }), - getConfigWarnings: () => [], - logConfigWarning: () => calls.push('log-config-warning'), - initRuntimeOptionsManager: () => calls.push('init-runtime-options'), - setSecondarySubMode: () => calls.push('set-secondary-sub-mode'), - defaultSecondarySubMode: 'hover', - defaultWebsocketPort: 5174, - defaultAnnotationWebsocketPort: 6678, - defaultTexthookerPort: 5174, - hasMpvWebsocketPlugin: () => false, - startSubtitleWebsocket: () => calls.push('start-ws'), - startAnnotationWebsocket: () => calls.push('start-annotation-ws'), - startTexthooker: () => calls.push('start-texthooker'), - log: () => calls.push('log'), - setLogLevel: () => calls.push('set-log-level'), - createMecabTokenizerAndCheck: async () => { - calls.push('create-mecab'); - }, - createSubtitleTimingTracker: () => calls.push('create-subtitle-tracker'), - createImmersionTracker: () => calls.push('create-immersion'), - startJellyfinRemoteSession: async () => { - calls.push('start-jellyfin'); - }, - loadYomitanExtension: async () => { - calls.push('load-yomitan'); - }, - ensureYomitanExtensionLoaded: async () => { - calls.push('ensure-yomitan'); - }, - handleFirstRunSetup: async () => { - calls.push('handle-first-run-setup'); - }, - prewarmSubtitleDictionaries: async () => { - calls.push('prewarm-dicts'); - }, - startBackgroundWarmups: () => calls.push('start-warmups'), - texthookerOnlyMode: false, - shouldAutoInitializeOverlayRuntimeFromConfig: () => true, - shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true, - setVisibleOverlayVisible: () => calls.push('set-visible-overlay'), - initializeOverlayRuntime: () => calls.push('init-overlay'), - handleInitialArgs: () => calls.push('handle-initial-args'), - onCriticalConfigErrors: () => { - throw new Error('should not call'); - }, - logDebug: () => calls.push('debug'), - now: () => 123, - })(); - - assert.equal(onReady.defaultSecondarySubMode, 'hover'); - assert.equal(onReady.defaultWebsocketPort, 5174); - assert.equal(onReady.defaultAnnotationWebsocketPort, 6678); - assert.equal(onReady.defaultTexthookerPort, 5174); - assert.equal(onReady.texthookerOnlyMode, false); - assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true); - assert.equal(onReady.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.(), true); - assert.equal(onReady.now?.(), 123); - onReady.loadSubtitlePosition(); - onReady.resolveKeybindings(); - onReady.createMpvClient(); - await onReady.createMecabTokenizerAndCheck(); - await onReady.loadYomitanExtension(); - await onReady.ensureYomitanExtensionLoaded?.(); - await onReady.handleFirstRunSetup(); - await onReady.prewarmSubtitleDictionaries?.(); - onReady.startBackgroundWarmups(); - onReady.startTexthooker(5174); - onReady.setVisibleOverlayVisible(true); - - assert.deepEqual(calls, [ - 'load-subtitle-position', - 'resolve-keybindings', - 'create-mpv-client', - 'create-mecab', - 'load-yomitan', - 'ensure-yomitan', - 'handle-first-run-setup', - 'prewarm-dicts', - 'start-warmups', - 'start-texthooker', - 'set-visible-overlay', - ]); -}); diff --git a/src/main/runtime/app-runtime-main-deps.test.ts b/src/main/runtime/app-runtime-main-deps.test.ts index 5f6833a0..f513459c 100644 --- a/src/main/runtime/app-runtime-main-deps.test.ts +++ b/src/main/runtime/app-runtime-main-deps.test.ts @@ -1,11 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { - createBuildDestroyTrayMainDepsHandler, - createBuildEnsureTrayMainDepsHandler, - createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler, - createBuildOpenYomitanSettingsMainDepsHandler, -} from './app-runtime-main-deps'; +import { createBuildEnsureTrayMainDepsHandler } from './app-runtime-main-deps'; test('ensure tray main deps trigger overlay bootstrap on tray click when runtime not initialized', () => { const calls: string[] = []; @@ -28,79 +23,3 @@ test('ensure tray main deps trigger overlay bootstrap on tray click when runtime deps.ensureOverlayVisibleFromTrayClick(); assert.deepEqual(calls, ['init-overlay', 'set-visible:true']); }); - -test('destroy tray main deps map passthrough getters/setters', () => { - let tray: unknown = { id: 'tray' }; - const deps = createBuildDestroyTrayMainDepsHandler({ - getTray: () => tray, - setTray: (next) => { - tray = next; - }, - })(); - - assert.deepEqual(deps.getTray(), { id: 'tray' }); - deps.setTray(null); - assert.equal(tray, null); -}); - -test('initialize overlay runtime main deps map build options and callbacks', () => { - const calls: string[] = []; - const options = { id: 'opts' }; - const deps = createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler({ - isOverlayRuntimeInitialized: () => false, - initializeOverlayRuntimeCore: (value) => { - calls.push(`core:${JSON.stringify(value)}`); - }, - buildOptions: () => options, - setOverlayRuntimeInitialized: (initialized) => calls.push(`set-initialized:${initialized}`), - startBackgroundWarmups: () => calls.push('warmups'), - })(); - - assert.equal(deps.isOverlayRuntimeInitialized(), false); - assert.equal(deps.buildOptions(), options); - assert.equal(deps.initializeOverlayRuntimeCore(options), undefined); - deps.setOverlayRuntimeInitialized(true); - deps.startBackgroundWarmups(); - assert.deepEqual(calls, ['core:{"id":"opts"}', 'set-initialized:true', 'warmups']); -}); - -test('open yomitan settings main deps map async open callbacks', async () => { - const calls: string[] = []; - let currentWindow: unknown = null; - const extension = { id: 'ext' }; - const startupLoad = Promise.resolve(extension); - const yomitanSession = { id: 'session' }; - const deps = createBuildOpenYomitanSettingsMainDepsHandler({ - ensureYomitanExtensionLoaded: async () => extension, - getYomitanExtension: () => extension, - getYomitanExtensionLoadInFlight: () => startupLoad, - openYomitanSettingsWindow: ({ yomitanExt, yomitanSession: forwardedSession }) => - calls.push( - `open:${(yomitanExt as { id: string }).id}:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`, - ), - getExistingWindow: () => currentWindow, - setWindow: (window) => { - currentWindow = window; - calls.push('set-window'); - }, - getYomitanSession: () => yomitanSession, - logWarn: (message) => calls.push(`warn:${message}`), - logError: (message) => calls.push(`error:${message}`), - })(); - - assert.equal(await deps.ensureYomitanExtensionLoaded(), extension); - assert.equal(deps.getYomitanExtension?.(), extension); - assert.equal(deps.getYomitanExtensionLoadInFlight?.(), startupLoad); - assert.equal(deps.getExistingWindow(), null); - deps.setWindow({ id: 'win' }); - deps.openYomitanSettingsWindow({ - yomitanExt: extension, - getExistingWindow: () => deps.getExistingWindow(), - setWindow: (window) => deps.setWindow(window), - yomitanSession: deps.getYomitanSession(), - }); - deps.logWarn('warn'); - deps.logError('error', new Error('boom')); - assert.deepEqual(calls, ['set-window', 'open:ext:session', 'warn:warn', 'error:error']); - assert.deepEqual(currentWindow, { id: 'win' }); -}); diff --git a/src/main/runtime/autoplay-ready-gate.test.ts b/src/main/runtime/autoplay-ready-gate.test.ts index c2945e7f..acf997a6 100644 --- a/src/main/runtime/autoplay-ready-gate.test.ts +++ b/src/main/runtime/autoplay-ready-gate.test.ts @@ -1,620 +1,262 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createAutoplayReadyGate } from './autoplay-ready-gate'; +import type { MergedToken } from '../../types'; +import { + createAutoplayReadyGate, + type AutoplayReadyGateDeps, + type AutoplayReadySignal, +} from './autoplay-ready-gate'; -test('autoplay ready gate suppresses duplicate media signals for the same media', async () => { - const commands: Array> = []; +type MpvCommand = Array; + +const PLUGIN_READY: MpvCommand = ['script-message', 'subminer-autoplay-ready']; +const FAKE_TIMER = 1 as unknown as ReturnType; + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const isUnpause = (command: MpvCommand): boolean => + command[0] === 'set_property' && command[1] === 'pause' && command[2] === false; + +// Builds a gate against a connected fake mpv that starts paused. An unpause command +// flips `mpv.paused`; scheduled retries queue in `scheduled` until run explicitly. +function createGate(overrides: Partial = {}) { + const commands: MpvCommand[] = []; const scheduled: Array<() => void> = []; + const mpv = { paused: true }; const gate = createAutoplayReadyGate({ isAppOwnedFlowInFlight: () => false, getCurrentMediaPath: () => '/media/video.mkv', getCurrentVideoPath: () => null, - getPlaybackPaused: () => true, - getMpvClient: () => - ({ - connected: true, - requestProperty: async () => true, - send: ({ command }: { command: Array }) => { - commands.push(command); - }, - }) as never, + getPlaybackPaused: () => mpv.paused, + getMpvClient: () => ({ + connected: true, + requestProperty: async () => mpv.paused, + send: ({ command }) => { + commands.push(command); + if (isUnpause(command)) mpv.paused = false; + }, + }), signalPluginAutoplayReady: () => { - commands.push(['script-message', 'subminer-autoplay-ready']); + commands.push(PLUGIN_READY); }, schedule: (callback) => { scheduled.push(callback); - return 1 as never; + return FAKE_TIMER; }, logDebug: () => {}, + ...overrides, }); - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }); - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }); + return { + gate, + commands, + scheduled, + mpv, + pluginSignals: () => commands.filter((command) => command[0] === 'script-message'), + unpauseCount: () => commands.filter(isUnpause).length, + // Delivers a forced-while-paused subtitle signal and lets async release attempts settle. + signal: async (text: string, tokens: MergedToken[] | null = null) => { + gate.maybeSignalPluginAutoplayReady({ text, tokens }, { forceWhilePaused: true }); + await settle(); + }, + // Runs queued retries one at a time, including retries they schedule, until none remain. + runScheduled: async () => { + for (let callback = scheduled.shift(); callback; callback = scheduled.shift()) { + callback(); + await settle(); + } + }, + }; +} - await new Promise((resolve) => setTimeout(resolve, 0)); - const firstScheduled = scheduled.shift(); - firstScheduled?.(); - await new Promise((resolve) => setTimeout(resolve, 0)); +test('autoplay ready gate signals the plugin once per media across duplicate signals and release retries', async () => { + const h = createGate(); - assert.deepEqual( - commands.filter((command) => command[0] === 'script-message'), - [['script-message', 'subminer-autoplay-ready']], - ); - assert.ok( - commands.some( - (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, - ), - ); + await h.signal('字幕'); + await h.signal('字幕'); + await h.runScheduled(); + + assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]); + assert.equal(h.unpauseCount(), 1); }); -test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => { - const commands: Array> = []; - const scheduled: Array<() => void> = []; - - const gate = createAutoplayReadyGate({ - isAppOwnedFlowInFlight: () => false, - getCurrentMediaPath: () => '/media/video.mkv', - getCurrentVideoPath: () => null, - getPlaybackPaused: () => true, - getMpvClient: () => - ({ - connected: true, - requestProperty: async () => true, - send: ({ command }: { command: Array }) => { - commands.push(command); - }, - }) as never, - signalPluginAutoplayReady: () => { - commands.push(['script-message', 'subminer-autoplay-ready']); - }, - schedule: (callback) => { - scheduled.push(callback); - return 1 as never; - }, - logDebug: () => {}, - }); - - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - for (const callback of scheduled.splice(0, 3)) { - callback(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - - assert.deepEqual( - commands.filter((command) => command[0] === 'script-message'), - [['script-message', 'subminer-autoplay-ready']], - ); - assert.equal( - commands.filter( - (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, - ).length > 0, - true, - ); -}); - -test('autoplay ready gate requests overlay pointer recovery when media readiness is signaled', async () => { - const commands: Array> = []; +test('autoplay ready gate requests overlay pointer recovery once per released media', async () => { let pointerRecoveryRequests = 0; - - const gate = createAutoplayReadyGate({ - isAppOwnedFlowInFlight: () => false, - getCurrentMediaPath: () => '/media/video.mkv', - getCurrentVideoPath: () => null, - getPlaybackPaused: () => true, - getMpvClient: () => - ({ - connected: true, - requestProperty: async () => true, - send: ({ command }: { command: Array }) => { - commands.push(command); - }, - }) as never, - signalPluginAutoplayReady: () => { - commands.push(['script-message', 'subminer-autoplay-ready']); - }, + const h = createGate({ requestOverlayPointerRecovery: () => { pointerRecoveryRequests += 1; }, - schedule: (callback) => { - queueMicrotask(callback); - return 1 as never; - }, - logDebug: () => {}, }); - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - gate.maybeSignalPluginAutoplayReady( - { text: '字幕その2', tokens: null }, - { forceWhilePaused: true }, - ); - await new Promise((resolve) => setTimeout(resolve, 0)); + await h.signal('字幕'); + await h.signal('字幕その2'); assert.equal(pointerRecoveryRequests, 1); }); test('autoplay ready gate reports the released autoplay signal once', async () => { const releasedSignals: string[] = []; - - const gate = createAutoplayReadyGate({ - isAppOwnedFlowInFlight: () => false, - getCurrentMediaPath: () => '/media/video.mkv', - getCurrentVideoPath: () => null, - getPlaybackPaused: () => true, - getMpvClient: () => - ({ - connected: true, - requestProperty: async () => true, - send: () => {}, - }) as never, - signalPluginAutoplayReady: () => {}, + const h = createGate({ onAutoplayReadyReleased: (signal) => { releasedSignals.push(signal.payload.text); }, - schedule: (callback) => { - queueMicrotask(callback); - return 1 as never; - }, - logDebug: () => {}, }); - gate.maybeSignalPluginAutoplayReady( - { text: '__warm__', tokens: null }, - { forceWhilePaused: true }, - ); - await new Promise((resolve) => setTimeout(resolve, 0)); - - gate.maybeSignalPluginAutoplayReady( - { text: '次の字幕', tokens: null }, - { forceWhilePaused: true }, - ); - await new Promise((resolve) => setTimeout(resolve, 0)); + await h.signal('__warm__'); + await h.signal('次の字幕'); assert.deepEqual(releasedSignals, ['__warm__']); }); -test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => { - const commands: Array> = []; - let playbackPaused = true; +const manualPauseCases: Array<{ + name: string; + afterManualPause: (h: ReturnType) => Promise; +}> = [ + { + name: 'autoplay ready gate does not unpause again after a later manual pause on the same media', + afterManualPause: (h) => h.signal('字幕その2'), + }, + { + name: 'autoplay ready gate cancels release retries after playback is paused again', + afterManualPause: (h) => h.runScheduled(), + }, +]; - const gate = createAutoplayReadyGate({ - isAppOwnedFlowInFlight: () => false, - getCurrentMediaPath: () => '/media/video.mkv', - getCurrentVideoPath: () => null, - getPlaybackPaused: () => playbackPaused, - getMpvClient: () => - ({ - connected: true, - requestProperty: async () => playbackPaused, - send: ({ command }: { command: Array }) => { - commands.push(command); - if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) { - playbackPaused = false; - } - }, - }) as never, - signalPluginAutoplayReady: () => { - commands.push(['script-message', 'subminer-autoplay-ready']); - }, - schedule: (callback) => { - queueMicrotask(callback); - return 1 as never; - }, - logDebug: () => {}, +for (const c of manualPauseCases) { + test(c.name, async () => { + const h = createGate(); + + await h.signal('字幕'); + assert.equal(h.unpauseCount(), 1); + + h.mpv.paused = true; + await c.afterManualPause(h); + + assert.equal(h.unpauseCount(), 1); + assert.equal(h.mpv.paused, true); }); - - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - playbackPaused = true; - gate.maybeSignalPluginAutoplayReady( - { text: '字幕その2', tokens: null }, - { forceWhilePaused: true }, - ); - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.equal( - commands.filter( - (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, - ).length, - 1, - ); -}); - -test('autoplay ready gate cancels release retries after playback is paused again', async () => { - const commands: Array> = []; - const scheduled: Array<() => void> = []; - let playbackPaused = true; - - const gate = createAutoplayReadyGate({ - isAppOwnedFlowInFlight: () => false, - getCurrentMediaPath: () => '/media/video.mkv', - getCurrentVideoPath: () => null, - getPlaybackPaused: () => playbackPaused, - getMpvClient: () => - ({ - connected: true, - requestProperty: async () => playbackPaused, - send: ({ command }: { command: Array }) => { - commands.push(command); - if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) { - playbackPaused = false; - } - }, - }) as never, - signalPluginAutoplayReady: () => { - commands.push(['script-message', 'subminer-autoplay-ready']); - }, - schedule: (callback) => { - scheduled.push(callback); - return 1 as never; - }, - logDebug: () => {}, - }); - - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - playbackPaused = true; - const retry = scheduled.shift(); - retry?.(); - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.equal( - commands.filter( - (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, - ).length, - 1, - ); -}); +} test('autoplay ready gate suppresses release after manual current-media dismissal', async () => { - const commands: Array> = []; + const h = createGate(); - const gate = createAutoplayReadyGate({ - isAppOwnedFlowInFlight: () => false, - getCurrentMediaPath: () => '/media/video.mkv', - getCurrentVideoPath: () => null, - getPlaybackPaused: () => true, - getMpvClient: () => - ({ - connected: true, - requestProperty: async () => true, - send: ({ command }: { command: Array }) => { - commands.push(command); - }, - }) as never, - signalPluginAutoplayReady: () => { - commands.push(['script-message', 'subminer-autoplay-ready']); - }, - schedule: (callback) => { - queueMicrotask(callback); - return 1 as never; - }, - logDebug: () => {}, - }); + h.gate.markCurrentMediaAutoplayReady(); + await h.signal('字幕'); - gate.markCurrentMediaAutoplayReady(); - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.deepEqual(commands, []); + assert.deepEqual(h.commands, []); }); -test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => { - const commands: Array> = []; - let targetReady = false; +const deferredReleaseCases: Array<{ + name: string; + release: (h: ReturnType) => Promise; +}> = [ + { + name: 'autoplay ready gate defers plugin readiness until the signal target is ready', + release: async (h) => { + h.gate.flushPendingAutoplayReadySignal(); + await settle(); + }, + }, + { + name: 'autoplay ready gate retries deferred readiness without an external flush event', + release: (h) => h.runScheduled(), + }, +]; - const gate = createAutoplayReadyGate({ - isAppOwnedFlowInFlight: () => false, - getCurrentMediaPath: () => '/media/video.mkv', - getCurrentVideoPath: () => null, - getPlaybackPaused: () => true, - getMpvClient: () => - ({ - connected: true, - requestProperty: async () => true, - send: ({ command }: { command: Array }) => { - commands.push(command); - }, - }) as never, - signalPluginAutoplayReady: () => { - commands.push(['script-message', 'subminer-autoplay-ready']); - }, - isSignalTargetReady: () => targetReady, - schedule: (callback) => { - queueMicrotask(callback); - return 1 as never; - }, - logDebug: () => {}, +for (const c of deferredReleaseCases) { + test(c.name, async () => { + let targetReady = false; + const h = createGate({ isSignalTargetReady: () => targetReady }); + + await h.signal('字幕'); + assert.deepEqual(h.commands, []); + assert.equal(h.scheduled.length, 1); + + targetReady = true; + await c.release(h); + + assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]); + assert.equal(h.unpauseCount(), 1); }); - - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.deepEqual(commands, []); - - targetReady = true; - gate.flushPendingAutoplayReadySignal(); - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.deepEqual( - commands.filter((command) => command[0] === 'script-message'), - [['script-message', 'subminer-autoplay-ready']], - ); - assert.equal( - commands.some( - (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, - ), - true, - ); -}); - -test('autoplay ready gate retries deferred readiness without an external flush event', async () => { - const commands: Array> = []; - const scheduled: Array<() => void> = []; - let targetReady = false; - - const gate = createAutoplayReadyGate({ - isAppOwnedFlowInFlight: () => false, - getCurrentMediaPath: () => '/media/video.mkv', - getCurrentVideoPath: () => null, - getPlaybackPaused: () => true, - getMpvClient: () => - ({ - connected: true, - requestProperty: async () => true, - send: ({ command }: { command: Array }) => { - commands.push(command); - }, - }) as never, - signalPluginAutoplayReady: () => { - commands.push(['script-message', 'subminer-autoplay-ready']); - }, - isSignalTargetReady: () => targetReady, - schedule: (callback) => { - scheduled.push(callback); - return 1 as never; - }, - logDebug: () => {}, - }); - - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.deepEqual(commands, []); - assert.equal(scheduled.length, 1); - - targetReady = true; - scheduled.shift()?.(); - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.deepEqual( - commands.filter((command) => command[0] === 'script-message'), - [['script-message', 'subminer-autoplay-ready']], - ); - assert.equal( - commands.some( - (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, - ), - true, - ); -}); +} test('autoplay ready gate keeps deferred startup readiness retries active for cold starts', async () => { - const commands: Array> = []; - const scheduled: Array<() => void> = []; + const h = createGate({ isSignalTargetReady: () => false }); - const gate = createAutoplayReadyGate({ - isAppOwnedFlowInFlight: () => false, - getCurrentMediaPath: () => '/media/video.mkv', - getCurrentVideoPath: () => null, - getPlaybackPaused: () => true, - getMpvClient: () => - ({ - connected: true, - requestProperty: async () => true, - send: ({ command }: { command: Array }) => { - commands.push(command); - }, - }) as never, - signalPluginAutoplayReady: () => { - commands.push(['script-message', 'subminer-autoplay-ready']); - }, - isSignalTargetReady: () => false, - schedule: (callback) => { - scheduled.push(callback); - return 1 as never; - }, - logDebug: () => {}, - }); - - gate.maybeSignalPluginAutoplayReady( - { text: '__warm__', tokens: null }, - { forceWhilePaused: true }, - ); - await new Promise((resolve) => setTimeout(resolve, 0)); + await h.signal('__warm__'); for (let attempt = 1; attempt <= 100; attempt += 1) { - assert.equal(scheduled.length, 1, `missing deferred readiness retry ${attempt}`); - scheduled.shift()?.(); - await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(h.scheduled.length, 1, `missing deferred readiness retry ${attempt}`); + h.scheduled.shift()?.(); + await settle(); } - assert.deepEqual(commands, []); + assert.deepEqual(h.commands, []); }); test('autoplay ready gate drops deferred readiness after media changes before flush', async () => { - const commands: Array> = []; let targetReady = false; let currentMediaPath = '/media/video-1.mkv'; - - const gate = createAutoplayReadyGate({ - isAppOwnedFlowInFlight: () => false, + const h = createGate({ getCurrentMediaPath: () => currentMediaPath, - getCurrentVideoPath: () => null, - getPlaybackPaused: () => true, - getMpvClient: () => - ({ - connected: true, - requestProperty: async () => true, - send: ({ command }: { command: Array }) => { - commands.push(command); - }, - }) as never, - signalPluginAutoplayReady: () => { - commands.push(['script-message', 'subminer-autoplay-ready']); - }, isSignalTargetReady: () => targetReady, - schedule: (callback) => { - queueMicrotask(callback); - return 1 as never; - }, - logDebug: () => {}, }); - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await h.signal('字幕'); currentMediaPath = '/media/video-2.mkv'; targetReady = true; - gate.flushPendingAutoplayReadySignal(); - await new Promise((resolve) => setTimeout(resolve, 0)); + h.gate.flushPendingAutoplayReadySignal(); + await settle(); - assert.deepEqual(commands, []); + assert.deepEqual(h.commands, []); }); test('autoplay ready gate passes the pending subtitle signal to the readiness predicate', async () => { - const commands: Array> = []; + const observed: Array & { text: string }> = []; let targetReadyText: string | null = null; - let observedText: string | null = null; - let observedRequestedAtMs: number | null = null; let now = 1_000; - - const gate = createAutoplayReadyGate({ - isAppOwnedFlowInFlight: () => false, - getCurrentMediaPath: () => '/media/video.mkv', - getCurrentVideoPath: () => null, - getPlaybackPaused: () => true, - getMpvClient: () => - ({ - connected: true, - requestProperty: async () => true, - send: ({ command }: { command: Array }) => { - commands.push(command); - }, - }) as never, - signalPluginAutoplayReady: () => { - commands.push(['script-message', 'subminer-autoplay-ready']); - }, - isSignalTargetReady: ((signal: { payload: { text: string }; requestedAtMs: number }) => { - observedText = signal.payload.text; - observedRequestedAtMs = signal.requestedAtMs; + const h = createGate({ + isSignalTargetReady: (signal) => { + observed.push({ text: signal.payload.text, requestedAtMs: signal.requestedAtMs }); return targetReadyText === signal.payload.text; - }) as never, - now: () => now, - schedule: (callback) => { - queueMicrotask(callback); - return 1 as never; }, - logDebug: () => {}, + now: () => now, }); - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await h.signal('字幕'); - assert.equal(observedText, '字幕'); - assert.equal(observedRequestedAtMs, 1_000); - assert.deepEqual(commands, []); + assert.deepEqual(observed, [{ text: '字幕', requestedAtMs: 1_000 }]); + assert.deepEqual(h.commands, []); now = 2_000; targetReadyText = '字幕'; - gate.flushPendingAutoplayReadySignal(); - await new Promise((resolve) => setTimeout(resolve, 0)); + h.gate.flushPendingAutoplayReadySignal(); + await settle(); - assert.equal(observedRequestedAtMs, 1_000); - assert.deepEqual( - commands.filter((command) => command[0] === 'script-message'), - [['script-message', 'subminer-autoplay-ready']], - ); + // The flushed signal keeps its original request time instead of the flush time. + assert.deepEqual(observed.at(-1), { text: '字幕', requestedAtMs: 1_000 }); + assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]); }); -function createTokenizationGateHarness(deps: { isTokenizationReady: () => boolean }) { - const commands: Array> = []; - const gate = createAutoplayReadyGate({ - isAppOwnedFlowInFlight: () => false, - getCurrentMediaPath: () => '/media/video.mkv', - getCurrentVideoPath: () => null, - getPlaybackPaused: () => true, - getMpvClient: () => - ({ - connected: true, - requestProperty: async () => true, - send: ({ command }: { command: Array }) => { - commands.push(command); - }, - }) as never, - signalPluginAutoplayReady: () => { - commands.push(['script-message', 'subminer-autoplay-ready']); - }, - isTokenizationReady: deps.isTokenizationReady, - schedule: (callback) => { - queueMicrotask(callback); - return 1 as never; - }, - logDebug: () => {}, - }); - return { gate, commands }; -} +test('autoplay ready gate ignores untokenized signals until tokenization warmup is ready', async () => { + let tokenizationReady = false; + const h = createGate({ isTokenizationReady: () => tokenizationReady }); -test('autoplay ready gate ignores untokenized signals while tokenization warmup is pending', async () => { - const { gate, commands } = createTokenizationGateHarness({ - isTokenizationReady: () => false, - }); + await h.signal('字幕'); + assert.deepEqual(h.commands, []); - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); - await new Promise((resolve) => setTimeout(resolve, 0)); + tokenizationReady = true; + await h.signal('字幕'); - assert.deepEqual(commands, []); + assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]); }); test('autoplay ready gate releases tokenized signals while tokenization warmup is pending', async () => { - const { gate, commands } = createTokenizationGateHarness({ - isTokenizationReady: () => false, - }); + const h = createGate({ isTokenizationReady: () => false }); - gate.maybeSignalPluginAutoplayReady( - { text: '字幕', tokens: [{ surface: '字幕' }] as never }, - { forceWhilePaused: true }, - ); - await new Promise((resolve) => setTimeout(resolve, 0)); + await h.signal('字幕', [{ surface: '字幕' }] as unknown as MergedToken[]); - assert.deepEqual( - commands.filter((command) => command[0] === 'script-message'), - [['script-message', 'subminer-autoplay-ready']], - ); -}); - -test('autoplay ready gate releases untokenized signals once tokenization warmup is ready', async () => { - let tokenizationReady = false; - const { gate, commands } = createTokenizationGateHarness({ - isTokenizationReady: () => tokenizationReady, - }); - - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.deepEqual(commands, []); - - tokenizationReady = true; - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.deepEqual( - commands.filter((command) => command[0] === 'script-message'), - [['script-message', 'subminer-autoplay-ready']], - ); + assert.deepEqual(h.pluginSignals(), [PLUGIN_READY]); }); diff --git a/src/main/runtime/autoplay-subtitle-priming-runtime.test.ts b/src/main/runtime/autoplay-subtitle-priming-runtime.test.ts index 0a2c1f4d..e5dcc41d 100644 --- a/src/main/runtime/autoplay-subtitle-priming-runtime.test.ts +++ b/src/main/runtime/autoplay-subtitle-priming-runtime.test.ts @@ -3,35 +3,7 @@ import test from 'node:test'; import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser'; import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller'; import type { SubtitleData } from '../../types'; -import { - createAutoplaySubtitlePrimingRuntime, - setMpvCurrentSecondarySubText, -} from './autoplay-subtitle-priming-runtime'; - -test('setMpvCurrentSecondarySubText uses client setter when available', () => { - const calls: string[] = []; - const client = { - currentSecondarySubText: '', - setCurrentSecondarySubText: (text: string) => { - calls.push(text); - }, - }; - - setMpvCurrentSecondarySubText(client, 'secondary'); - - assert.deepEqual(calls, ['secondary']); - assert.equal(client.currentSecondarySubText, ''); -}); - -test('setMpvCurrentSecondarySubText updates client property when setter is unavailable', () => { - const client = { - currentSecondarySubText: '', - }; - - setMpvCurrentSecondarySubText(client, 'secondary'); - - assert.equal(client.currentSecondarySubText, 'secondary'); -}); +import { createAutoplaySubtitlePrimingRuntime } from './autoplay-subtitle-priming-runtime'; test('scheduleSubtitlePrefetchRefresh logs refresh failures from timer callback', async () => { const logs: string[] = []; diff --git a/src/main/runtime/changelog/changelog-runtime.test.ts b/src/main/runtime/changelog/changelog-runtime.test.ts index 3a81ca0f..f62d50c9 100644 --- a/src/main/runtime/changelog/changelog-runtime.test.ts +++ b/src/main/runtime/changelog/changelog-runtime.test.ts @@ -1,11 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { - CHANGELOG_REQUEST_TIMEOUT_MS, - createChangelogRuntime, - withRequestTimeout, -} from './changelog-runtime'; +import { createChangelogRuntime, withRequestTimeout } from './changelog-runtime'; import type { FetchLike, FetchResponseLike } from '../update/release-assets'; import { createCurlFetch } from '../update/fetch-adapter'; @@ -115,8 +111,3 @@ test('changelog timeout reaches the curl transport, not just global fetch', asyn assert.equal(killed, 'SIGKILL', 'the stalled curl process is killed at the changelog deadline'); }); - -test('changelog request timeout is finite', () => { - assert.ok(Number.isFinite(CHANGELOG_REQUEST_TIMEOUT_MS)); - assert.ok(CHANGELOG_REQUEST_TIMEOUT_MS > 0); -}); diff --git a/src/main/runtime/character-dictionary-auto-sync.test.ts b/src/main/runtime/character-dictionary-auto-sync.test.ts index 35b34ace..ab10ccf2 100644 --- a/src/main/runtime/character-dictionary-auto-sync.test.ts +++ b/src/main/runtime/character-dictionary-auto-sync.test.ts @@ -3,18 +3,68 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import test from 'node:test'; +import type { AnilistCharacterDictionaryProfileScope } from '../../types'; +import type { + CharacterDictionarySnapshotResult, + MergedCharacterDictionaryBuildResult, +} from '../character-dictionary-runtime'; import { buildDictionaryZip } from '../character-dictionary-runtime/zip'; import { createCharacterDictionaryAutoSyncRuntimeService, getCharacterDictionaryManagerSnapshot, moveCharacterDictionaryManagedEntry, removeCharacterDictionaryManagedEntry, + type CharacterDictionaryAutoSyncRuntimeDeps, + type CharacterDictionaryAutoSyncStatusEvent, } from './character-dictionary-auto-sync'; +const DICTIONARY_TITLE = 'SubMiner Character Dictionary'; +const FRIEREN = { mediaId: 7, mediaTitle: 'Frieren' }; +const ONE_PIECE = { mediaId: 21, mediaTitle: 'ONE PIECE' }; +const DEFAULT_SNAPSHOT: CharacterDictionarySnapshotResult = { + ...FRIEREN, + entryCount: 100, + fromCache: true, + updatedAt: 1000, +}; + +type PersistedState = { + activeMediaIds: string[]; + mergedRevision: string | null; + mergedDictionaryTitle: string | null; +}; + function makeTempDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-')); } +function dictionariesDir(userDataPath: string): string { + return path.join(userDataPath, 'character-dictionaries'); +} + +function statePath(userDataPath: string): string { + return path.join(dictionariesDir(userDataPath), 'auto-sync-state.json'); +} + +function writeState( + userDataPath: string, + state: Omit & { activeMediaIds: Array }, +): void { + fs.mkdirSync(dictionariesDir(userDataPath), { recursive: true }); + fs.writeFileSync(statePath(userDataPath), JSON.stringify(state, null, 2), 'utf8'); +} + +function readState(userDataPath: string): PersistedState { + return JSON.parse(fs.readFileSync(statePath(userDataPath), 'utf8')) as PersistedState; +} + +/** Writes a real `merged.zip` whose index.json carries `revision`. */ +async function writeMergedZip(userDataPath: string, revision: string): Promise { + const zipPath = path.join(dictionariesDir(userDataPath), 'merged.zip'); + await buildDictionaryZip(zipPath, DICTIONARY_TITLE, 'Character names', revision, [], []); + return zipPath; +} + async function waitUntil(predicate: () => boolean, label: string): Promise { for (let attempt = 0; attempt < 500; attempt += 1) { if (predicate()) return; @@ -31,49 +81,134 @@ function createDeferred(): { promise: Promise; resolve: (value: T) => void return { promise, resolve }; } +/** Captures heartbeat ticks so a test can fire them by hand instead of waiting on real timers. */ +function manualScheduler(): { + scheduled: Array<() => void>; + deps: Pick; +} { + const scheduled: Array<() => void> = []; + return { + scheduled, + deps: { + schedule: (fn) => { + scheduled.push(fn); + return 0 as unknown as ReturnType; + }, + clearSchedule: () => undefined, + }, + }; +} + +type RuntimeOptions = Partial & { + snapshot?: Partial; + /** What the settings upsert reports; `false` means Yomitan settings were already in place. */ + settingsChanged?: boolean; +}; + +/** + * Builds the runtime against an in-memory Yomitan that remembers the last imported revision. + * Default merged builds are named `/tmp/rev-.zip` with revision `rev-`, and an import + * records the ZIP basename as the installed revision. + */ +function makeRuntime({ snapshot, settingsChanged = true, ...overrides }: RuntimeOptions = {}) { + const userDataPath = overrides.userDataPath ?? makeTempDir(); + const recorded = { + userDataPath, + builds: [] as number[][], + imports: [] as string[], + deletes: [] as string[], + upserts: [] as Array<{ title: string; scope: AnilistCharacterDictionaryProfileScope }>, + events: [] as CharacterDictionaryAutoSyncStatusEvent[], + completions: [] as Array<{ mediaId: number; mediaTitle: string; changed: boolean }>, + logs: [] as string[], + yomitan: { revision: null as string | null, infoQueries: 0 }, + }; + const runtime = createCharacterDictionaryAutoSyncRuntimeService({ + userDataPath, + getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }), + getOrCreateCurrentSnapshot: async () => ({ ...DEFAULT_SNAPSHOT, ...snapshot }), + buildMergedDictionary: async (mediaIds): Promise => { + recorded.builds.push([...mediaIds]); + const revision = `rev-${mediaIds.join('-')}`; + return { + zipPath: `/tmp/${revision}.zip`, + revision, + dictionaryTitle: DICTIONARY_TITLE, + entryCount: mediaIds.length * 10, + }; + }, + getYomitanDictionaryInfo: async () => { + recorded.yomitan.infoQueries += 1; + const { revision } = recorded.yomitan; + return revision ? [{ title: DICTIONARY_TITLE, revision }] : []; + }, + importYomitanDictionary: async (zipPath) => { + recorded.imports.push(zipPath); + recorded.yomitan.revision = path.basename(zipPath, '.zip'); + return true; + }, + deleteYomitanDictionary: async (title) => { + recorded.deletes.push(title); + recorded.yomitan.revision = null; + return true; + }, + upsertYomitanDictionarySettings: async (title, scope) => { + recorded.upserts.push({ title, scope }); + return settingsChanged; + }, + now: () => 1000, + logInfo: (message) => { + recorded.logs.push(message); + }, + onSyncStatus: (event) => { + recorded.events.push(event); + }, + onSyncComplete: (completion) => { + recorded.completions.push(completion); + }, + ...overrides, + }); + return { + ...recorded, + runtime, + phases: () => recorded.events.map((event) => event.phase), + }; +} + +const konosuba = { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba' }; +const towerOfGod = { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God' }; +const eminence = { mediaId: 130298, label: '130298 - Eminence', title: 'Eminence' }; + test('character dictionary manager snapshots, reorders, and removes MRU entries', () => { const userDataPath = makeTempDir(); - const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json'); - fs.mkdirSync(path.dirname(statePath), { recursive: true }); - fs.writeFileSync( - statePath, - JSON.stringify( - { - activeMediaIds: ['21202 - KonoSuba', '115230 - Tower of God', '130298 - Eminence'], - mergedRevision: 'rev-1', - mergedDictionaryTitle: 'SubMiner Character Dictionary', - }, - null, - 2, - ), - 'utf8', - ); + writeState(userDataPath, { + activeMediaIds: [konosuba.label, towerOfGod.label, eminence.label], + mergedRevision: 'rev-1', + mergedDictionaryTitle: DICTIONARY_TITLE, + }); assert.deepEqual(getCharacterDictionaryManagerSnapshot(userDataPath).entries, [ - { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true }, - { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: false }, - { mediaId: 130298, label: '130298 - Eminence', title: 'Eminence', current: false }, + { ...konosuba, current: true }, + { ...towerOfGod, current: false }, + { ...eminence, current: false }, ]); - assert.deepEqual(moveCharacterDictionaryManagedEntry(userDataPath, 130298, -1), { + assert.deepEqual(moveCharacterDictionaryManagedEntry(userDataPath, eminence.mediaId, -1), { ok: true, entries: [ - { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true }, - { mediaId: 130298, label: '130298 - Eminence', title: 'Eminence', current: false }, - { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: false }, + { ...konosuba, current: true }, + { ...eminence, current: false }, + { ...towerOfGod, current: false }, ], rebuildRequired: true, }); - const reorderedState = JSON.parse(fs.readFileSync(statePath, 'utf8')) as { - mergedRevision: string | null; - }; - assert.equal(reorderedState.mergedRevision, null); + assert.equal(readState(userDataPath).mergedRevision, null); - assert.deepEqual(removeCharacterDictionaryManagedEntry(userDataPath, 115230), { + assert.deepEqual(removeCharacterDictionaryManagedEntry(userDataPath, towerOfGod.mediaId), { ok: true, entries: [ - { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true }, - { mediaId: 130298, label: '130298 - Eminence', title: 'Eminence', current: false }, + { ...konosuba, current: true }, + { ...eminence, current: false }, ], rebuildRequired: true, }); @@ -81,1147 +216,386 @@ test('character dictionary manager snapshots, reorders, and removes MRU entries' test('character dictionary manager protects the actual current media after LRU reorder', () => { const userDataPath = makeTempDir(); - const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json'); - fs.mkdirSync(path.dirname(statePath), { recursive: true }); - fs.writeFileSync( - statePath, - JSON.stringify( - { - activeMediaIds: ['21202 - KonoSuba', '115230 - Tower of God'], - mergedRevision: 'rev-1', - mergedDictionaryTitle: 'SubMiner Character Dictionary', - }, - null, - 2, - ), - 'utf8', - ); + writeState(userDataPath, { + activeMediaIds: [konosuba.label, towerOfGod.label], + mergedRevision: 'rev-1', + mergedDictionaryTitle: DICTIONARY_TITLE, + }); + const current = towerOfGod.mediaId; + const entries = [ + { ...konosuba, current: false }, + { ...towerOfGod, current: true }, + ]; - assert.deepEqual(getCharacterDictionaryManagerSnapshot(userDataPath, 115230).entries, [ - { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: false }, - { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: true }, - ]); - assert.deepEqual(moveCharacterDictionaryManagedEntry(userDataPath, 115230, -1, 115230), { + assert.deepEqual(getCharacterDictionaryManagerSnapshot(userDataPath, current).entries, entries); + assert.deepEqual(moveCharacterDictionaryManagedEntry(userDataPath, current, -1, current), { ok: false, message: 'The current anime stays anchored while you are watching it.', - entries: [ - { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: false }, - { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: true }, - ], + entries, }); - assert.deepEqual(removeCharacterDictionaryManagedEntry(userDataPath, 115230, 115230), { + assert.deepEqual(removeCharacterDictionaryManagedEntry(userDataPath, current, current), { ok: false, message: 'The current anime stays loaded while you are watching it.', - entries: [ - { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: false }, - { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: true }, - ], + entries, }); }); -test('auto sync imports merged dictionary and persists MRU state', async () => { - const userDataPath = makeTempDir(); - const imported: string[] = []; - const deleted: string[] = []; - const upserts: Array<{ title: string; scope: 'all' | 'active' }> = []; - const mergedBuilds: number[][] = []; - const logs: string[] = []; - - let importedRevision: string | null = null; - - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ - enabled: true, - maxLoaded: 3, - profileScope: 'all', - }), - getOrCreateCurrentSnapshot: async () => ({ +test('auto sync imports merged dictionary, persists MRU state, and reports completion', async () => { + const r = makeRuntime({ + snapshot: { mediaId: 130298, mediaTitle: 'The Eminence in Shadow', entryCount: 2544, fromCache: false, - updatedAt: 1000, - }), - buildMergedDictionary: async (mediaIds) => { - mergedBuilds.push([...mediaIds]); - return { - zipPath: '/tmp/subminer-character-dictionary.zip', - revision: 'rev-1', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 2544, - }; - }, - getYomitanDictionaryInfo: async () => - importedRevision - ? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }] - : [], - importYomitanDictionary: async (zipPath) => { - imported.push(zipPath); - importedRevision = 'rev-1'; - return true; - }, - deleteYomitanDictionary: async (dictionaryTitle) => { - deleted.push(dictionaryTitle); - importedRevision = null; - return true; - }, - upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => { - upserts.push({ title: dictionaryTitle, scope: profileScope }); - return true; - }, - now: () => 1000, - logInfo: (message) => { - logs.push(message); }, }); - await runtime.runSyncNow(); + await r.runtime.runSyncNow(); - assert.deepEqual(mergedBuilds, [[130298]]); - assert.deepEqual(imported, ['/tmp/subminer-character-dictionary.zip']); - assert.deepEqual(deleted, []); - assert.deepEqual(upserts, [{ title: 'SubMiner Character Dictionary', scope: 'all' }]); - - const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json'); - const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as { - activeMediaIds: string[]; - mergedRevision: string | null; - mergedDictionaryTitle: string | null; - }; - assert.deepEqual(state.activeMediaIds, ['130298 - The Eminence in Shadow']); - assert.equal(state.mergedRevision, 'rev-1'); - assert.equal(state.mergedDictionaryTitle, 'SubMiner Character Dictionary'); - assert.deepEqual(logs, [ + assert.deepEqual(r.builds, [[130298]]); + assert.deepEqual(r.imports, ['/tmp/rev-130298.zip']); + assert.deepEqual(r.deletes, []); + assert.deepEqual(r.upserts, [{ title: DICTIONARY_TITLE, scope: 'all' }]); + assert.deepEqual(readState(r.userDataPath), { + activeMediaIds: ['130298 - The Eminence in Shadow'], + mergedRevision: 'rev-130298', + mergedDictionaryTitle: DICTIONARY_TITLE, + }); + assert.deepEqual(r.logs, [ '[dictionary:auto-sync] syncing current anime snapshot', '[dictionary:auto-sync] active AniList media set: 130298 - The Eminence in Shadow', '[dictionary:auto-sync] rebuilding merged dictionary for active anime set', - '[dictionary:auto-sync] importing merged dictionary: /tmp/subminer-character-dictionary.zip (timeout 120000ms)', - '[dictionary:auto-sync] applying Yomitan settings for SubMiner Character Dictionary', - '[dictionary:auto-sync] synced AniList 130298: SubMiner Character Dictionary (2544 entries)', + '[dictionary:auto-sync] importing merged dictionary: /tmp/rev-130298.zip (timeout 120000ms)', + `[dictionary:auto-sync] applying Yomitan settings for ${DICTIONARY_TITLE}`, + `[dictionary:auto-sync] synced AniList 130298: ${DICTIONARY_TITLE} (2544 entries)`, + ]); + assert.deepEqual(r.completions, [ + { mediaId: 130298, mediaTitle: 'The Eminence in Shadow', changed: true }, ]); }); -test('auto sync skips rebuild/import on unchanged revisit when merged dictionary is current', async () => { - const userDataPath = makeTempDir(); - const mergedBuilds: number[][] = []; - const imports: string[] = []; - let importedRevision: string | null = null; +test('auto sync skips rebuild, import, and progress phases on an unchanged revisit', async () => { + const r = makeRuntime({ settingsChanged: false }); - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ - enabled: true, - maxLoaded: 3, - profileScope: 'all', - }), - getOrCreateCurrentSnapshot: async () => ({ - mediaId: 7, - mediaTitle: 'Frieren', - entryCount: 100, - fromCache: true, - updatedAt: 1000, - }), - buildMergedDictionary: async (mediaIds) => { - mergedBuilds.push([...mediaIds]); - return { - zipPath: '/tmp/merged.zip', - revision: 'rev-7', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 100, - }; - }, - getYomitanDictionaryInfo: async () => - importedRevision - ? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }] - : [], - importYomitanDictionary: async (zipPath) => { - imports.push(zipPath); - importedRevision = 'rev-7'; - return true; - }, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => true, - now: () => 1000, - }); + await r.runtime.runSyncNow(); + assert.deepEqual(r.phases(), ['building', 'importing', 'ready']); - await runtime.runSyncNow(); - await runtime.runSyncNow(); - - assert.deepEqual(mergedBuilds, [[7]]); - assert.deepEqual(imports, ['/tmp/merged.zip']); + r.events.length = 0; + await r.runtime.runSyncNow(); + assert.deepEqual(r.phases(), ['ready']); + assert.deepEqual(r.builds, [[7]]); + assert.deepEqual(r.imports, ['/tmp/rev-7.zip']); }); -test('auto sync does not emit updating progress for unchanged revisit when merged dictionary is current', async () => { - const userDataPath = makeTempDir(); - let importedRevision: string | null = null; - let currentRun: string[] = []; - const phaseHistory: string[][] = []; +const mruCases: Array<{ + name: string; + sequence: number[]; + builds: number[][]; + activeMediaIds: string[]; +}> = [ + { + name: 'auto sync updates MRU order without rebuilding merged dictionary when membership is unchanged', + sequence: [1, 2, 1], + builds: [[1], [2, 1]], + activeMediaIds: ['1 - Title 1', '2 - Title 2'], + }, + { + name: 'auto sync evicts least recently used media from merged set', + sequence: [1, 2, 3, 4], + builds: [[1], [2, 1], [3, 2, 1], [4, 3, 2]], + activeMediaIds: ['4 - Title 4', '3 - Title 3', '2 - Title 2'], + }, + { + name: 'auto sync keeps revisited media retained when a new title is added afterward', + sequence: [1, 2, 3, 1, 4, 1], + builds: [[1], [2, 1], [3, 2, 1], [4, 1, 3]], + activeMediaIds: ['1 - Title 1', '4 - Title 4', '3 - Title 3'], + }, +]; - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ - enabled: true, - maxLoaded: 3, - profileScope: 'all', - }), - getOrCreateCurrentSnapshot: async () => ({ - mediaId: 7, - mediaTitle: 'Frieren', - entryCount: 100, - fromCache: true, - updatedAt: 1000, - }), - buildMergedDictionary: async () => ({ - zipPath: '/tmp/merged.zip', - revision: 'rev-7', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 100, - }), - getYomitanDictionaryInfo: async () => - importedRevision - ? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }] - : [], - importYomitanDictionary: async () => { - importedRevision = 'rev-7'; - return true; - }, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => false, - now: () => 1000, - onSyncStatus: (event) => { - currentRun.push(event.phase); - }, +for (const c of mruCases) { + test(c.name, async () => { + let runIndex = 0; + const r = makeRuntime({ + getOrCreateCurrentSnapshot: async () => { + const mediaId = c.sequence[runIndex++]!; + return { + mediaId, + mediaTitle: `Title ${mediaId}`, + entryCount: 10, + fromCache: true, + updatedAt: mediaId, + }; + }, + }); + + for (let run = 0; run < c.sequence.length; run += 1) { + await r.runtime.runSyncNow(); + } + + assert.deepEqual(r.builds, c.builds); + // A revisit that only reorders the set neither rebuilds nor reimports. + assert.deepEqual( + r.imports, + c.builds.map((mediaIds) => `/tmp/rev-${mediaIds.join('-')}.zip`), + ); + assert.deepEqual(readState(r.userDataPath).activeMediaIds, c.activeMediaIds); }); - - currentRun = []; - await runtime.runSyncNow(); - phaseHistory.push([...currentRun]); - currentRun = []; - await runtime.runSyncNow(); - phaseHistory.push([...currentRun]); - - assert.deepEqual(phaseHistory[0], ['building', 'importing', 'ready']); - assert.deepEqual(phaseHistory[1], ['ready']); -}); - -test('auto sync updates MRU order without rebuilding merged dictionary when membership is unchanged', async () => { - const userDataPath = makeTempDir(); - const sequence = [1, 2, 1]; - const mergedBuilds: number[][] = []; - const deleted: string[] = []; - let importedRevision: string | null = null; - let runIndex = 0; - - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ - enabled: true, - maxLoaded: 3, - profileScope: 'all', - }), - getOrCreateCurrentSnapshot: async () => { - const mediaId = sequence[Math.min(runIndex, sequence.length - 1)]!; - runIndex += 1; - return { - mediaId, - mediaTitle: `Title ${mediaId}`, - entryCount: 10, - fromCache: true, - updatedAt: mediaId, - }; - }, - buildMergedDictionary: async (mediaIds) => { - mergedBuilds.push([...mediaIds]); - const revision = `rev-${mediaIds.join('-')}`; - return { - zipPath: `/tmp/${revision}.zip`, - revision, - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: mediaIds.length * 10, - }; - }, - getYomitanDictionaryInfo: async () => - importedRevision - ? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }] - : [], - importYomitanDictionary: async (zipPath) => { - importedRevision = path.basename(zipPath, '.zip'); - return true; - }, - deleteYomitanDictionary: async (dictionaryTitle) => { - deleted.push(dictionaryTitle); - importedRevision = null; - return true; - }, - upsertYomitanDictionarySettings: async () => true, - now: () => 1000, - }); - - await runtime.runSyncNow(); - await runtime.runSyncNow(); - await runtime.runSyncNow(); - - assert.deepEqual(mergedBuilds, [[1], [2, 1]]); - assert.equal(deleted.length, 1); - - const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json'); - const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as { - activeMediaIds: string[]; - }; - assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '2 - Title 2']); -}); +} test('auto sync reimports existing merged zip without rebuilding on unchanged revisit', async () => { - const userDataPath = makeTempDir(); - const dictionariesDir = path.join(userDataPath, 'character-dictionaries'); - fs.mkdirSync(dictionariesDir, { recursive: true }); - buildDictionaryZip( - path.join(dictionariesDir, 'merged.zip'), - 'SubMiner Character Dictionary', - 'Character names', - 'rev-7', - [{ term: 'フリーレン', reading: 'フリーレン', role: 'main', glossary: [] } as never], - [], - ); - const mergedBuilds: number[][] = []; - const imports: string[] = []; - let importedRevision: string | null = null; + const r = makeRuntime(); + const cachedZipPath = await writeMergedZip(r.userDataPath, 'rev-7'); - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ - enabled: true, - maxLoaded: 3, - profileScope: 'all', - }), - getOrCreateCurrentSnapshot: async () => ({ - mediaId: 7, - mediaTitle: 'Frieren', - entryCount: 100, - fromCache: true, - updatedAt: 1000, - }), - buildMergedDictionary: async (mediaIds) => { - mergedBuilds.push([...mediaIds]); - return { - zipPath: '/tmp/merged.zip', - revision: 'rev-7', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 100, - }; - }, - getYomitanDictionaryInfo: async () => - importedRevision - ? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }] - : [], - importYomitanDictionary: async (zipPath) => { - imports.push(zipPath); - importedRevision = 'rev-7'; - return true; - }, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => true, - now: () => 1000, - }); + await r.runtime.runSyncNow(); + // Yomitan lost the dictionary between syncs. + r.yomitan.revision = null; + await r.runtime.runSyncNow(); - await runtime.runSyncNow(); - importedRevision = null; - await runtime.runSyncNow(); - - assert.deepEqual(mergedBuilds, [[7]]); - assert.deepEqual(imports, [ - '/tmp/merged.zip', - path.join(userDataPath, 'character-dictionaries', 'merged.zip'), - ]); + assert.deepEqual(r.builds, [[7]]); + assert.deepEqual(r.imports, ['/tmp/rev-7.zip', cachedZipPath]); }); -test('auto sync evicts least recently used media from merged set', async () => { - const userDataPath = makeTempDir(); - const sequence = [1, 2, 3, 4]; - const mergedBuilds: number[][] = []; - let runIndex = 0; - let importedRevision: string | null = null; - - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ - enabled: true, - maxLoaded: 3, - profileScope: 'all', - }), - getOrCreateCurrentSnapshot: async () => { - const mediaId = sequence[Math.min(runIndex, sequence.length - 1)]!; - runIndex += 1; - return { - mediaId, - mediaTitle: `Title ${mediaId}`, - entryCount: 10, - fromCache: true, - updatedAt: mediaId, - }; - }, - buildMergedDictionary: async (mediaIds) => { - mergedBuilds.push([...mediaIds]); - const revision = `rev-${mediaIds.join('-')}`; - return { - zipPath: `/tmp/${revision}.zip`, - revision, - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: mediaIds.length * 10, - }; - }, - getYomitanDictionaryInfo: async () => - importedRevision - ? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }] - : [], - importYomitanDictionary: async (zipPath) => { - importedRevision = path.basename(zipPath, '.zip'); - return true; - }, - deleteYomitanDictionary: async () => { - importedRevision = null; - return true; - }, - upsertYomitanDictionarySettings: async () => true, - now: () => Date.now(), +test('auto sync rebuilds instead of importing a cached merged ZIP with a mismatched revision', async () => { + const r = makeRuntime(); + writeState(r.userDataPath, { + activeMediaIds: ['7 - Frieren'], + mergedRevision: 'rev-7', + mergedDictionaryTitle: DICTIONARY_TITLE, }); + // Left over from an interrupted run: the archive on disk is not the revision state recorded. + await writeMergedZip(r.userDataPath, 'rev-stale'); - await runtime.runSyncNow(); - await runtime.runSyncNow(); - await runtime.runSyncNow(); - await runtime.runSyncNow(); + // Yomitan does not have the dictionary, so the sync has to import despite the cached state. + await r.runtime.runSyncNow(); - assert.deepEqual(mergedBuilds, [[1], [2, 1], [3, 2, 1], [4, 3, 2]]); - - const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json'); - const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as { - activeMediaIds: string[]; - }; - assert.deepEqual(state.activeMediaIds, ['4 - Title 4', '3 - Title 3', '2 - Title 2']); -}); - -test('auto sync keeps revisited media retained when a new title is added afterward', async () => { - const userDataPath = makeTempDir(); - const sequence = [1, 2, 3, 1, 4, 1]; - const mergedBuilds: number[][] = []; - let runIndex = 0; - let importedRevision: string | null = null; - - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ - enabled: true, - maxLoaded: 3, - profileScope: 'all', - }), - getOrCreateCurrentSnapshot: async () => { - const mediaId = sequence[Math.min(runIndex, sequence.length - 1)]!; - runIndex += 1; - return { - mediaId, - mediaTitle: `Title ${mediaId}`, - entryCount: 10, - fromCache: true, - updatedAt: mediaId, - }; - }, - buildMergedDictionary: async (mediaIds) => { - mergedBuilds.push([...mediaIds]); - const revision = `rev-${mediaIds.join('-')}`; - return { - zipPath: `/tmp/${revision}.zip`, - revision, - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: mediaIds.length * 10, - }; - }, - getYomitanDictionaryInfo: async () => - importedRevision - ? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }] - : [], - importYomitanDictionary: async (zipPath) => { - importedRevision = path.basename(zipPath, '.zip'); - return true; - }, - deleteYomitanDictionary: async () => { - importedRevision = null; - return true; - }, - upsertYomitanDictionarySettings: async () => true, - now: () => Date.now(), - }); - - await runtime.runSyncNow(); - await runtime.runSyncNow(); - await runtime.runSyncNow(); - await runtime.runSyncNow(); - await runtime.runSyncNow(); - await runtime.runSyncNow(); - - assert.deepEqual(mergedBuilds, [[1], [2, 1], [3, 2, 1], [4, 1, 3]]); - - const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json'); - const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as { - activeMediaIds: string[]; - }; - assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '4 - Title 4', '3 - Title 3']); + assert.deepEqual(r.builds, [[7]]); + assert.deepEqual(r.imports, ['/tmp/rev-7.zip']); }); test('auto sync removes stale manual-selection media ids when applying corrected snapshot', async () => { - const userDataPath = makeTempDir(); - const dictionariesDir = path.join(userDataPath, 'character-dictionaries'); - fs.mkdirSync(dictionariesDir, { recursive: true }); - fs.writeFileSync( - path.join(dictionariesDir, 'auto-sync-state.json'), - JSON.stringify( - { - activeMediaIds: ['10607 - Rerere no Tensai Bakabon', '130298 - The Eminence in Shadow'], - mergedRevision: 'old', - mergedDictionaryTitle: 'SubMiner Character Dictionary', - }, - null, - 2, - ), - ); - const builtMediaIds: number[][] = []; - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ - enabled: true, - maxLoaded: 5, - profileScope: 'all', - }), - getOrCreateCurrentSnapshot: async () => ({ + const r = makeRuntime({ + snapshot: { mediaId: 21355, mediaTitle: 'Re:ZERO -Starting Life in Another World-', - entryCount: 120, fromCache: false, - updatedAt: 99, staleMediaIds: [10607], - }), - buildMergedDictionary: async (mediaIds) => { - builtMediaIds.push([...mediaIds]); - return { - zipPath: path.join(dictionariesDir, 'merged.zip'), - revision: `rev-${mediaIds.join('-')}`, - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 200, - }; }, - getYomitanDictionaryInfo: async () => [], - importYomitanDictionary: async () => true, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => false, - now: () => 1, + }); + writeState(r.userDataPath, { + activeMediaIds: ['10607 - Rerere no Tensai Bakabon', '130298 - The Eminence in Shadow'], + mergedRevision: 'old', + mergedDictionaryTitle: DICTIONARY_TITLE, }); - await runtime.runSyncNow(); + await r.runtime.runSyncNow(); - assert.deepEqual(builtMediaIds, [[21355, 130298]]); - const state = JSON.parse( - fs.readFileSync(path.join(dictionariesDir, 'auto-sync-state.json'), 'utf8'), - ) as { activeMediaIds: string[] }; - assert.deepEqual(state.activeMediaIds, [ + assert.deepEqual(r.builds, [[21355, 130298]]); + assert.deepEqual(readState(r.userDataPath).activeMediaIds, [ '21355 - Re:ZERO -Starting Life in Another World-', '130298 - The Eminence in Shadow', ]); }); test('auto sync persists rebuilt MRU state even if Yomitan import fails afterward', async () => { - const userDataPath = makeTempDir(); - const dictionariesDir = path.join(userDataPath, 'character-dictionaries'); - fs.mkdirSync(dictionariesDir, { recursive: true }); - fs.writeFileSync( - path.join(dictionariesDir, 'auto-sync-state.json'), - JSON.stringify( - { - activeMediaIds: [2, 3, 4], - mergedRevision: 'rev-2-3-4', - mergedDictionaryTitle: 'SubMiner Character Dictionary', - }, - null, - 2, - ), - ); - - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ - enabled: true, - maxLoaded: 3, - profileScope: 'all', - }), - getOrCreateCurrentSnapshot: async () => ({ - mediaId: 1, - mediaTitle: 'Title 1', - entryCount: 10, - fromCache: true, - updatedAt: 1, - }), - buildMergedDictionary: async (mediaIds) => { - assert.deepEqual(mediaIds, [1, 2, 3]); - return { - zipPath: '/tmp/rev-1-2-3.zip', - revision: 'rev-1-2-3', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 30, - }; - }, - waitForYomitanMutationReady: async () => undefined, - getYomitanDictionaryInfo: async () => [], + const r = makeRuntime({ + snapshot: { mediaId: 1, mediaTitle: 'Title 1' }, importYomitanDictionary: async () => { throw new Error('import failed'); }, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => true, - now: () => 1000, + }); + writeState(r.userDataPath, { + activeMediaIds: [2, 3, 4], + mergedRevision: 'rev-2-3-4', + mergedDictionaryTitle: DICTIONARY_TITLE, }); - await assert.rejects(runtime.runSyncNow(), /import failed/); + await assert.rejects(r.runtime.runSyncNow(), /import failed/); - const state = JSON.parse( - fs.readFileSync(path.join(dictionariesDir, 'auto-sync-state.json'), 'utf8'), - ) as { - activeMediaIds: string[]; - mergedRevision: string | null; - mergedDictionaryTitle: string | null; - }; - assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '2', '3']); - assert.equal(state.mergedRevision, 'rev-1-2-3'); - assert.equal(state.mergedDictionaryTitle, 'SubMiner Character Dictionary'); -}); - -test('auto sync invokes completion callback after successful sync', async () => { - const userDataPath = makeTempDir(); - const completions: Array<{ mediaId: number; mediaTitle: string; changed: boolean }> = []; - let importedRevision: string | null = null; - - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ - enabled: true, - maxLoaded: 3, - profileScope: 'all', - }), - getOrCreateCurrentSnapshot: async () => ({ - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - entryCount: 2560, - fromCache: false, - updatedAt: 1000, - }), - buildMergedDictionary: async () => ({ - zipPath: '/tmp/merged.zip', - revision: 'rev-101291', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 2560, - }), - getYomitanDictionaryInfo: async () => - importedRevision - ? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }] - : [], - importYomitanDictionary: async () => { - importedRevision = 'rev-101291'; - return true; - }, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => true, - now: () => 1000, - onSyncComplete: (completion) => { - completions.push(completion); - }, + assert.deepEqual(r.builds, [[1, 2, 3]]); + assert.deepEqual(readState(r.userDataPath), { + activeMediaIds: ['1 - Title 1', '2', '3'], + mergedRevision: 'rev-1-2-3', + mergedDictionaryTitle: DICTIONARY_TITLE, }); - - await runtime.runSyncNow(); - - assert.deepEqual(completions, [ - { - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - changed: true, - }, - ]); }); test('auto sync emits progress events for start import and completion', async () => { - const userDataPath = makeTempDir(); - const events: Array<{ - phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed'; - mediaId?: number; - mediaTitle?: string; - message: string; - changed?: boolean; - }> = []; - let importedRevision: string | null = null; - - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ - enabled: true, - maxLoaded: 3, - profileScope: 'all', - }), + const r = makeRuntime({ getOrCreateCurrentSnapshot: async (_targetPath, progress) => { - progress?.onChecking?.({ - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - }); - progress?.onGenerating?.({ - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - }); - return { - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - entryCount: 2560, - fromCache: false, - updatedAt: 1000, - }; - }, - buildMergedDictionary: async () => ({ - zipPath: '/tmp/merged.zip', - revision: 'rev-101291', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 2560, - }), - getYomitanDictionaryInfo: async () => - importedRevision - ? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }] - : [], - importYomitanDictionary: async () => { - importedRevision = 'rev-101291'; - return true; - }, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => true, - now: () => 1000, - onSyncStatus: (event) => { - events.push(event); + progress?.onChecking?.(FRIEREN); + progress?.onGenerating?.(FRIEREN); + return { ...DEFAULT_SNAPSHOT, fromCache: false }; }, }); - await runtime.runSyncNow(); + await r.runtime.runSyncNow(); - assert.deepEqual(events, [ - { - phase: 'checking', - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - message: 'Checking character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...', - }, - { - phase: 'generating', - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - message: 'Generating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...', - }, - { - phase: 'building', - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - message: 'Building character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...', - }, - { - phase: 'importing', - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - message: 'Importing character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...', - }, + assert.deepEqual(r.events, [ + { phase: 'checking', ...FRIEREN, message: 'Checking character dictionary for Frieren...' }, + { phase: 'generating', ...FRIEREN, message: 'Generating character dictionary for Frieren...' }, + { phase: 'building', ...FRIEREN, message: 'Building character dictionary for Frieren...' }, + { phase: 'importing', ...FRIEREN, message: 'Importing character dictionary for Frieren...' }, { phase: 'ready', - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - message: 'Character dictionary ready for Rascal Does Not Dream of Bunny Girl Senpai', + ...FRIEREN, + message: 'Character dictionary ready for Frieren', changed: true, }, ]); }); test('auto sync emits checking before snapshot resolves and skips generating on cache hit', async () => { - const userDataPath = makeTempDir(); - const events: Array<{ - phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed'; - mediaId?: number; - mediaTitle?: string; - message: string; - changed?: boolean; - }> = []; - const snapshotDeferred = createDeferred<{ - mediaId: number; - mediaTitle: string; - entryCount: number; - fromCache: boolean; - updatedAt: number; - }>(); - let importedRevision: string | null = null; - - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ - enabled: true, - maxLoaded: 3, - profileScope: 'all', - }), + const snapshot = createDeferred(); + const r = makeRuntime({ getOrCreateCurrentSnapshot: async (_targetPath, progress) => { - progress?.onChecking?.({ - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - }); - return await snapshotDeferred.promise; - }, - buildMergedDictionary: async () => ({ - zipPath: '/tmp/merged.zip', - revision: 'rev-101291', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 2560, - }), - getYomitanDictionaryInfo: async () => - importedRevision - ? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }] - : [], - importYomitanDictionary: async () => { - importedRevision = 'rev-101291'; - return true; - }, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => true, - now: () => 1000, - onSyncStatus: (event) => { - events.push(event); + progress?.onChecking?.(FRIEREN); + return await snapshot.promise; }, }); - const syncPromise = runtime.runSyncNow(); + const syncPromise = r.runtime.runSyncNow(); await Promise.resolve(); - assert.deepEqual(events, [ - { - phase: 'checking', - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - message: 'Checking character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...', - }, + assert.deepEqual(r.events, [ + { phase: 'checking', ...FRIEREN, message: 'Checking character dictionary for Frieren...' }, ]); - snapshotDeferred.resolve({ - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - entryCount: 2560, - fromCache: true, - updatedAt: 1000, - }); + snapshot.resolve(DEFAULT_SNAPSHOT); await syncPromise; - assert.equal( - events.some((event) => event.phase === 'generating'), - false, - ); + assert.equal(r.phases().includes('generating'), false); }); test('auto sync emits building while merged dictionary generation is in flight', async () => { - const userDataPath = makeTempDir(); - const events: Array<{ - phase: 'checking' | 'generating' | 'building' | 'syncing' | 'importing' | 'ready' | 'failed'; - mediaId?: number; - mediaTitle?: string; - message: string; - changed?: boolean; - }> = []; - const buildDeferred = createDeferred<{ - zipPath: string; - revision: string; - dictionaryTitle: string; - entryCount: number; - }>(); - let importedRevision: string | null = null; + const build = createDeferred(); + const r = makeRuntime({ buildMergedDictionary: () => build.promise }); - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ - enabled: true, - maxLoaded: 3, - profileScope: 'all', - }), - getOrCreateCurrentSnapshot: async (_targetPath, progress) => { - progress?.onChecking?.({ - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - }); - return { - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - entryCount: 2560, - fromCache: true, - updatedAt: 1000, - }; - }, - buildMergedDictionary: async () => await buildDeferred.promise, - getYomitanDictionaryInfo: async () => - importedRevision - ? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }] - : [], - importYomitanDictionary: async () => { - importedRevision = 'rev-101291'; - return true; - }, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => true, - now: () => 1000, - onSyncStatus: (event) => { - events.push(event); - }, - }); + const syncPromise = r.runtime.runSyncNow(); + await waitUntil(() => r.phases().includes('building'), 'the building status event'); - const syncPromise = runtime.runSyncNow(); - await waitUntil( - () => events.some((event) => event.phase === 'building'), - 'the building status event', - ); - - buildDeferred.resolve({ - zipPath: '/tmp/merged.zip', - revision: 'rev-101291', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 2560, + build.resolve({ + zipPath: '/tmp/rev-7.zip', + revision: 'rev-7', + dictionaryTitle: DICTIONARY_TITLE, + entryCount: 100, }); await syncPromise; }); test('auto sync waits for tokenization-ready gate before Yomitan mutations', async () => { - const userDataPath = makeTempDir(); - const gate = (() => { - let resolve!: () => void; - const promise = new Promise((nextResolve) => { - resolve = nextResolve; - }); - return { promise, resolve }; - })(); - const calls: string[] = []; - - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ - enabled: true, - maxLoaded: 3, - profileScope: 'all', - }), - getOrCreateCurrentSnapshot: async () => ({ - mediaId: 101291, - mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', - entryCount: 2560, - fromCache: false, - updatedAt: 1000, - }), - buildMergedDictionary: async () => { - calls.push('build'); - return { - zipPath: '/tmp/merged.zip', - revision: 'rev-101291', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 2560, - }; - }, + const gate = createDeferred(); + let gateReached = false; + const r = makeRuntime({ waitForYomitanMutationReady: async () => { - calls.push('wait'); + gateReached = true; await gate.promise; }, - getYomitanDictionaryInfo: async () => { - calls.push('info'); - return []; - }, - importYomitanDictionary: async () => { - calls.push('import'); - return true; - }, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => { - calls.push('settings'); - return true; - }, - now: () => 1000, }); - const syncPromise = runtime.runSyncNow(); - await waitUntil(() => calls.includes('wait'), 'the tokenization-ready gate'); + const syncPromise = r.runtime.runSyncNow(); + await waitUntil(() => gateReached, 'the tokenization-ready gate'); - assert.deepEqual(calls, ['build', 'wait']); + assert.deepEqual(r.builds, [[7]]); + assert.equal(r.yomitan.infoQueries, 0); + assert.deepEqual(r.imports, []); + assert.deepEqual(r.upserts, []); gate.resolve(); await syncPromise; - assert.deepEqual(calls, ['build', 'wait', 'info', 'import', 'settings']); + assert.equal(r.yomitan.infoQueries, 1); + assert.deepEqual(r.imports, ['/tmp/rev-7.zip']); + assert.equal(r.upserts.length, 1); }); test('auto sync scales the import timeout with the merged dictionary size', async () => { const userDataPath = makeTempDir(); - const dictionariesDir = path.join(userDataPath, 'character-dictionaries'); - fs.mkdirSync(dictionariesDir, { recursive: true }); - const zipPath = path.join(dictionariesDir, 'merged.zip'); + const zipPath = path.join(dictionariesDir(userDataPath), 'merged.zip'); + fs.mkdirSync(path.dirname(zipPath), { recursive: true }); // 2 MB of merged dictionary buys ~12s of import budget on top of the base. fs.writeFileSync(zipPath, Buffer.alloc(2 * 1024 * 1024)); - const events: Array<{ phase: string; message: string }> = []; - let importedRevision: string | null = null; - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ + const r = makeRuntime({ userDataPath, - getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }), - getOrCreateCurrentSnapshot: async () => ({ - mediaId: 21, - mediaTitle: 'ONE PIECE', - entryCount: 4000, - fromCache: false, - updatedAt: 1000, - }), buildMergedDictionary: async () => ({ zipPath, - revision: 'rev-21', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 4000, + revision: 'rev-7', + dictionaryTitle: DICTIONARY_TITLE, + entryCount: 100, }), - getYomitanDictionaryInfo: async () => - importedRevision - ? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }] - : [], importYomitanDictionary: async () => { // Far longer than the quick-operation budget, well inside the size-scaled one. await new Promise((resolve) => setTimeout(resolve, 400)); - importedRevision = 'rev-21'; return true; }, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => true, - now: () => 1000, // Comfortable for the stubs that resolve immediately, still far under the import's 400ms. operationTimeoutMs: 100, dictionaryImportTimeoutBaseMs: 20, - onSyncStatus: (event) => { - events.push({ phase: event.phase, message: event.message }); - }, }); - await runtime.runSyncNow(); + await r.runtime.runSyncNow(); - assert.equal( - events.some((event) => event.phase === 'failed'), - false, - ); - assert.deepEqual(events.at(-1), { - phase: 'ready', - message: 'Character dictionary ready for ONE PIECE', - }); + assert.equal(r.phases().includes('failed'), false); + assert.equal(r.events.at(-1)?.message, 'Character dictionary ready for Frieren'); }); test('auto sync reports the scaled budget when an import really does hang', async () => { const userDataPath = makeTempDir(); - const events: Array<{ phase: string; message: string }> = []; - - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ + const r = makeRuntime({ userDataPath, - getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }), - getOrCreateCurrentSnapshot: async () => ({ - mediaId: 21, - mediaTitle: 'ONE PIECE', - entryCount: 4000, - fromCache: false, - updatedAt: 1000, - }), buildMergedDictionary: async () => ({ - zipPath: path.join(userDataPath, 'character-dictionaries', 'missing.zip'), - revision: 'rev-21', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 4000, + zipPath: path.join(dictionariesDir(userDataPath), 'missing.zip'), + revision: 'rev-7', + dictionaryTitle: DICTIONARY_TITLE, + entryCount: 100, }), - getYomitanDictionaryInfo: async () => [], importYomitanDictionary: () => new Promise(() => {}), - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => true, - now: () => 1000, dictionaryImportTimeoutBaseMs: 20, - onSyncStatus: (event) => { - events.push({ phase: event.phase, message: event.message }); - }, }); await assert.rejects( - runtime.runSyncNow(), + r.runtime.runSyncNow(), /importYomitanDictionary\(missing\.zip\) timed out after 20ms/, ); - assert.equal(events.at(-1)?.phase, 'failed'); + assert.equal(r.events.at(-1)?.phase, 'failed'); }); test('auto sync ticks the importing notification while the import runs', async () => { - const userDataPath = makeTempDir(); - const events: Array<{ phase: string; message: string }> = []; - const scheduled: Array<() => void> = []; - const importDeferred = createDeferred(); + const scheduler = manualScheduler(); + const importResult = createDeferred(); let clock = 1000; - - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }), - getOrCreateCurrentSnapshot: async () => ({ - mediaId: 21, - mediaTitle: 'ONE PIECE', - entryCount: 4000, - fromCache: false, - updatedAt: 1000, - }), - buildMergedDictionary: async () => ({ - zipPath: '/tmp/merged.zip', - revision: 'rev-21', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 4000, - }), - getYomitanDictionaryInfo: async () => [], - importYomitanDictionary: () => importDeferred.promise, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => true, + const r = makeRuntime({ + ...scheduler.deps, + importYomitanDictionary: () => importResult.promise, now: () => clock, - schedule: (fn) => { - scheduled.push(fn); - return 0 as unknown as ReturnType; - }, - clearSchedule: () => undefined, - onSyncStatus: (event) => { - events.push({ phase: event.phase, message: event.message }); - }, }); - const syncPromise = runtime.runSyncNow(); - await waitUntil( - () => events.some((event) => event.phase === 'importing'), - 'the importing status event', - ); + const syncPromise = r.runtime.runSyncNow(); + await waitUntil(() => r.phases().includes('importing'), 'the importing status event'); - clock = 1000 + 65_000; + clock += 65_000; // The importing heartbeat is the most recently scheduled tick. - scheduled.at(-1)!(); + scheduler.scheduled.at(-1)!(); - assert.deepEqual(events.at(-1), { + assert.deepEqual(r.events.at(-1), { phase: 'importing', - message: 'Importing character dictionary for ONE PIECE (1m 05s)...', + ...FRIEREN, + message: 'Importing character dictionary for Frieren (1m 05s)...', }); - importDeferred.resolve(true); + importResult.resolve(true); await syncPromise; - assert.equal(events.at(-1)?.phase, 'ready'); + assert.equal(r.events.at(-1)?.phase, 'ready'); }); test('auto sync reports character and image counts while generating a snapshot', async () => { - const userDataPath = makeTempDir(); - const events: Array<{ phase: string; message: string }> = []; let clock = 1000; - let importedRevision: string | null = null; - - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }), + const r = makeRuntime({ getOrCreateCurrentSnapshot: async (_targetPath, progress) => { - progress?.onGenerating?.({ mediaId: 21, mediaTitle: 'ONE PIECE' }); + progress?.onGenerating?.(ONE_PIECE); progress?.onGenerateProgress?.({ - mediaId: 21, - mediaTitle: 'ONE PIECE', + ...ONE_PIECE, stage: 'characters', completed: 50, total: null, @@ -1229,78 +603,33 @@ test('auto sync reports character and image counts while generating a snapshot', }); // Same stage, same clock tick: throttled away so a 33-page fetch cannot spam the overlay. progress?.onGenerateProgress?.({ - mediaId: 21, - mediaTitle: 'ONE PIECE', + ...ONE_PIECE, stage: 'characters', completed: 100, total: null, page: 13, }); // A stage change always reports, throttle window or not. - progress?.onGenerateProgress?.({ - mediaId: 21, - mediaTitle: 'ONE PIECE', - stage: 'images', - completed: 1, - total: 1220, - }); + progress?.onGenerateProgress?.({ ...ONE_PIECE, stage: 'images', completed: 1, total: 1220 }); clock += 2000; progress?.onGenerateProgress?.({ - mediaId: 21, - mediaTitle: 'ONE PIECE', + ...ONE_PIECE, stage: 'images', completed: 240, total: 1220, }); clock += 6000; - progress?.onGenerateProgress?.({ - mediaId: 21, - mediaTitle: 'ONE PIECE', - stage: 'names', - completed: 800, - total: 1220, - }); - progress?.onGenerateProgress?.({ - mediaId: 21, - mediaTitle: 'ONE PIECE', - stage: 'saving', - completed: 0, - total: null, - }); - return { - mediaId: 21, - mediaTitle: 'ONE PIECE', - entryCount: 4000, - fromCache: false, - updatedAt: 1000, - }; + progress?.onGenerateProgress?.({ ...ONE_PIECE, stage: 'names', completed: 800, total: 1220 }); + progress?.onGenerateProgress?.({ ...ONE_PIECE, stage: 'saving', completed: 0, total: null }); + return { ...ONE_PIECE, entryCount: 4000, fromCache: false, updatedAt: 1000 }; }, - buildMergedDictionary: async () => ({ - zipPath: '/tmp/merged.zip', - revision: 'rev-21', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 4000, - }), - getYomitanDictionaryInfo: async () => - importedRevision - ? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }] - : [], - importYomitanDictionary: async () => { - importedRevision = 'rev-21'; - return true; - }, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => true, now: () => clock, - onSyncStatus: (event) => { - events.push({ phase: event.phase, message: event.message }); - }, }); - await runtime.runSyncNow(); + await r.runtime.runSyncNow(); assert.deepEqual( - events.filter((event) => event.phase === 'generating').map((event) => event.message), + r.events.filter((event) => event.phase === 'generating').map((event) => event.message), [ 'Generating character dictionary for ONE PIECE...', 'Generating character dictionary for ONE PIECE (page 12, 50 characters)...', @@ -1313,134 +642,38 @@ test('auto sync reports character and image counts while generating a snapshot', }); test('auto sync keeps the generating clock ticking when a stage stalls', async () => { - const userDataPath = makeTempDir(); - const events: Array<{ phase: string; message: string }> = []; - const scheduled: Array<() => void> = []; - const snapshotDeferred = createDeferred<{ - mediaId: number; - mediaTitle: string; - entryCount: number; - fromCache: boolean; - updatedAt: number; - }>(); + const scheduler = manualScheduler(); + const snapshot = createDeferred(); let clock = 1000; - - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }), + const r = makeRuntime({ + ...scheduler.deps, getOrCreateCurrentSnapshot: async (_targetPath, progress) => { - progress?.onGenerating?.({ mediaId: 21, mediaTitle: 'ONE PIECE' }); + progress?.onGenerating?.(ONE_PIECE); progress?.onGenerateProgress?.({ - mediaId: 21, - mediaTitle: 'ONE PIECE', + ...ONE_PIECE, stage: 'images', completed: 240, total: 1220, }); - return await snapshotDeferred.promise; + return await snapshot.promise; }, - buildMergedDictionary: async () => ({ - zipPath: '/tmp/merged.zip', - revision: 'rev-21', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 4000, - }), - getYomitanDictionaryInfo: async () => [], - importYomitanDictionary: async () => true, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => true, now: () => clock, - schedule: (fn) => { - scheduled.push(fn); - return 0 as unknown as ReturnType; - }, - clearSchedule: () => undefined, - onSyncStatus: (event) => { - events.push({ phase: event.phase, message: event.message }); - }, }); - const syncPromise = runtime.runSyncNow(); - await waitUntil(() => scheduled.length > 0, 'the generating heartbeat'); + const syncPromise = r.runtime.runSyncNow(); + await waitUntil(() => scheduler.scheduled.length > 0, 'the generating heartbeat'); // No further progress arrives: only the clock moves. clock += 95_000; - scheduled.at(-1)!(); + scheduler.scheduled.at(-1)!(); - assert.deepEqual(events.at(-1), { + assert.deepEqual(r.events.at(-1), { phase: 'generating', + ...ONE_PIECE, message: 'Generating character dictionary for ONE PIECE (image 240/1220 · 1m 35s)...', }); - snapshotDeferred.resolve({ - mediaId: 21, - mediaTitle: 'ONE PIECE', - entryCount: 4000, - fromCache: false, - updatedAt: 1000, - }); + snapshot.resolve({ ...ONE_PIECE, entryCount: 4000, fromCache: false, updatedAt: 1000 }); await syncPromise; - assert.equal(events.at(-1)?.phase, 'ready'); -}); - -test('auto sync rebuilds instead of importing a cached merged ZIP with a mismatched revision', async () => { - const userDataPath = makeTempDir(); - const dictionariesDir = path.join(userDataPath, 'character-dictionaries'); - fs.mkdirSync(dictionariesDir, { recursive: true }); - const statePath = path.join(dictionariesDir, 'auto-sync-state.json'); - fs.writeFileSync( - statePath, - JSON.stringify({ - activeMediaIds: ['7 - Frieren'], - mergedRevision: 'rev-7', - mergedDictionaryTitle: 'SubMiner Character Dictionary', - }), - 'utf8', - ); - // Left over from an interrupted run: the archive on disk is not the revision state recorded. - buildDictionaryZip( - path.join(dictionariesDir, 'merged.zip'), - 'SubMiner Character Dictionary', - 'Character names', - 'rev-stale', - [{ term: 'フリーレン', reading: 'フリーレン', role: 'main', glossary: [] } as never], - [], - ); - const mergedBuilds: number[][] = []; - const imports: string[] = []; - - const runtime = createCharacterDictionaryAutoSyncRuntimeService({ - userDataPath, - getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }), - getOrCreateCurrentSnapshot: async () => ({ - mediaId: 7, - mediaTitle: 'Frieren', - entryCount: 100, - fromCache: true, - updatedAt: 1000, - }), - buildMergedDictionary: async (mediaIds) => { - mergedBuilds.push([...mediaIds]); - return { - zipPath: '/tmp/rebuilt-merged.zip', - revision: 'rev-7', - dictionaryTitle: 'SubMiner Character Dictionary', - entryCount: 100, - }; - }, - // Yomitan does not have the dictionary, so the sync has to import despite the cached state. - getYomitanDictionaryInfo: async () => [], - importYomitanDictionary: async (zipPath) => { - imports.push(zipPath); - return true; - }, - deleteYomitanDictionary: async () => true, - upsertYomitanDictionarySettings: async () => true, - now: () => 1000, - }); - - await runtime.runSyncNow(); - - assert.deepEqual(mergedBuilds, [[7]]); - assert.deepEqual(imports, ['/tmp/rebuilt-merged.zip']); + assert.equal(r.events.at(-1)?.phase, 'ready'); }); diff --git a/src/main/runtime/character-dictionary-availability.test.ts b/src/main/runtime/character-dictionary-availability.test.ts deleted file mode 100644 index 77a3aa43..00000000 --- a/src/main/runtime/character-dictionary-availability.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - getCharacterDictionaryDisabledReason, - isCharacterDictionaryRuntimeEnabled, -} from './character-dictionary-availability'; - -test('character dictionary runtime is enabled when external Yomitan profile is not configured', () => { - assert.equal(isCharacterDictionaryRuntimeEnabled(''), true); - assert.equal(isCharacterDictionaryRuntimeEnabled(' '), true); - assert.equal(getCharacterDictionaryDisabledReason(''), null); -}); - -test('character dictionary runtime is disabled when external Yomitan profile is configured', () => { - assert.equal(isCharacterDictionaryRuntimeEnabled('/tmp/gsm-profile'), false); - assert.equal( - getCharacterDictionaryDisabledReason('/tmp/gsm-profile'), - 'Character dictionary is disabled while yomitan.externalProfilePath is configured.', - ); -}); diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts deleted file mode 100644 index 7b3f07ef..00000000 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createBuildCliCommandContextDepsHandler } from './cli-command-context-deps'; - -test('build cli command context deps maps handlers and values', () => { - const calls: string[] = []; - const buildDeps = createBuildCliCommandContextDepsHandler({ - getSocketPath: () => '/tmp/mpv.sock', - setSocketPath: (socketPath) => calls.push(`socket:${socketPath}`), - getMpvClient: () => null, - showOsd: (text) => calls.push(`osd:${text}`), - texthookerService: { start: () => null, status: () => ({ running: false }) } as never, - getTexthookerPort: () => 5174, - setTexthookerPort: (port) => calls.push(`port:${port}`), - getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678', - shouldOpenBrowser: () => true, - openExternal: async (url) => calls.push(`open:${url}`), - logBrowserOpenError: (url) => calls.push(`open-error:${url}`), - isOverlayInitialized: () => true, - initializeOverlay: () => calls.push('init'), - toggleVisibleOverlay: () => calls.push('toggle-visible'), - togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'), - openFirstRunSetup: () => calls.push('setup'), - setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`), - copyCurrentSubtitle: () => calls.push('copy'), - startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`), - mineSentenceCard: async () => { - calls.push('mine'); - }, - startPendingMineSentenceMultiple: (ms) => calls.push(`mine-multi:${ms}`), - updateLastCardFromClipboard: async () => { - calls.push('update'); - }, - refreshKnownWordCache: async () => { - calls.push('refresh'); - }, - triggerFieldGrouping: async () => { - calls.push('group'); - }, - triggerSubsyncFromConfig: async () => { - calls.push('subsync'); - }, - markLastCardAsAudioCard: async () => { - calls.push('mark'); - }, - dispatchSessionAction: async () => {}, - getAnilistStatus: () => ({}) as never, - clearAnilistToken: () => calls.push('clear-token'), - openAnilistSetup: () => calls.push('anilist'), - openJellyfinSetup: () => calls.push('jellyfin'), - getAnilistQueueStatus: () => ({}) as never, - retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), - generateCharacterDictionary: async () => ({ - zipPath: '/tmp/anilist-1.zip', - fromCache: false, - mediaId: 1, - mediaTitle: 'Test', - entryCount: 10, - }), - runStatsCommand: async () => { - calls.push('run-stats'); - }, - runJellyfinCommand: async () => { - calls.push('run-jellyfin'); - }, - runUpdateCommand: async () => { - calls.push('run-update'); - }, - runEnsureLinuxRuntimePluginAssetsCommand: async () => { - calls.push('run-ensure-linux-runtime-plugin-assets'); - }, - runYoutubePlaybackFlow: async () => { - calls.push('run-youtube-playback'); - }, - openYomitanSettings: () => calls.push('yomitan'), - openConfigSettingsWindow: () => calls.push('config-settings'), - openSyncUiWindow: () => {}, - openYoutubeBrowserWindow: () => {}, - cycleSecondarySubMode: () => calls.push('cycle-secondary'), - openRuntimeOptionsPalette: () => calls.push('runtime-options'), - printHelp: () => calls.push('help'), - stopApp: () => calls.push('stop'), - hasMainWindow: () => true, - getMultiCopyTimeoutMs: () => 5000, - schedule: (fn) => { - fn(); - return setTimeout(() => {}, 0); - }, - logInfo: (message) => calls.push(`info:${message}`), - logDebug: (message) => calls.push(`debug:${message}`), - logWarn: (message) => calls.push(`warn:${message}`), - logError: (message) => calls.push(`error:${message}`), - }); - - const deps = buildDeps(); - assert.equal(deps.getSocketPath(), '/tmp/mpv.sock'); - assert.equal(deps.getTexthookerPort(), 5174); - assert.equal(deps.getTexthookerWebsocketUrl(), 'ws://127.0.0.1:6678'); - assert.equal(deps.shouldOpenBrowser(), true); - assert.equal(deps.isOverlayInitialized(), true); - assert.equal(deps.hasMainWindow(), true); - assert.equal(deps.getMultiCopyTimeoutMs(), 5000); - - deps.setSocketPath('/tmp/next.sock'); - deps.showOsd('hello'); - deps.setTexthookerPort(5175); - deps.printHelp(); - assert.deepEqual(calls, ['socket:/tmp/next.sock', 'osd:hello', 'port:5175', 'help']); -}); diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts deleted file mode 100644 index c489c7ee..00000000 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { createCliCommandContextFactory } from './cli-command-context-factory'; - -test('cli command context factory composes main deps and context handlers', () => { - const calls: string[] = []; - const appState = { - mpvSocketPath: '/tmp/mpv.sock', - mpvClient: null, - texthookerPort: 5174, - overlayRuntimeInitialized: false, - }; - - const createContext = createCliCommandContextFactory({ - appState, - texthookerService: { isRunning: () => false, start: () => null }, - getResolvedConfig: () => ({ - texthooker: { openBrowser: true }, - annotationWebsocket: { enabled: true, port: 6678 }, - }), - defaultWebsocketPort: 6677, - defaultAnnotationWebsocketPort: 6678, - hasMpvWebsocketPlugin: () => false, - openExternal: async () => {}, - logBrowserOpenError: () => {}, - showMpvOsd: (text) => calls.push(`osd:${text}`), - initializeOverlayRuntime: () => calls.push('init-overlay'), - toggleVisibleOverlay: () => calls.push('toggle-visible'), - togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'), - openFirstRunSetupWindow: () => calls.push('setup'), - setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), - copyCurrentSubtitle: () => calls.push('copy-sub'), - startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`), - mineSentenceCard: async () => {}, - startPendingMineSentenceMultiple: () => {}, - updateLastCardFromClipboard: async () => {}, - refreshKnownWordCache: async () => {}, - triggerFieldGrouping: async () => {}, - triggerSubsyncFromConfig: async () => {}, - markLastCardAsAudioCard: async () => {}, - dispatchSessionAction: async () => {}, - getAnilistStatus: () => ({ - tokenStatus: 'resolved', - tokenSource: 'literal', - tokenMessage: null, - tokenResolvedAt: null, - tokenErrorAt: null, - queuePending: 0, - queueReady: 0, - queueDeadLetter: 0, - queueLastAttemptAt: null, - queueLastError: null, - }), - clearAnilistToken: () => {}, - openAnilistSetupWindow: () => {}, - openJellyfinSetupWindow: () => {}, - getAnilistQueueStatus: () => ({ - pending: 0, - ready: 0, - deadLetter: 0, - lastAttemptAt: null, - lastError: null, - }), - processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }), - generateCharacterDictionary: async () => ({ - zipPath: '/tmp/anilist-1.zip', - fromCache: false, - mediaId: 1, - mediaTitle: 'Test', - entryCount: 10, - }), - runStatsCommand: async () => {}, - runJellyfinCommand: async () => {}, - runUpdateCommand: async () => {}, - runEnsureLinuxRuntimePluginAssetsCommand: async () => {}, - runYoutubePlaybackFlow: async () => {}, - openYomitanSettings: () => {}, - openConfigSettingsWindow: () => {}, - openSyncUiWindow: () => {}, - openYoutubeBrowserWindow: () => {}, - cycleSecondarySubMode: () => {}, - openRuntimeOptionsPalette: () => {}, - printHelp: () => {}, - stopApp: () => {}, - hasMainWindow: () => true, - getMultiCopyTimeoutMs: () => 5000, - schedule: (fn) => setTimeout(fn, 0), - logInfo: () => {}, - logDebug: () => {}, - logWarn: () => {}, - logError: () => {}, - }); - - const context = createContext(); - context.setSocketPath('/tmp/new.sock'); - context.showOsd('hello'); - context.setVisibleOverlay(true); - context.toggleVisibleOverlay(); - - assert.equal(appState.mpvSocketPath, '/tmp/new.sock'); - assert.deepEqual(calls, ['osd:hello', 'set-visible:true', 'toggle-visible']); -}); diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index 3394eab2..d0d93d6a 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -2,62 +2,41 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createBuildCliCommandContextMainDepsHandler } from './cli-command-context-main-deps'; -test('cli command context main deps builder maps state and callbacks', async () => { - const calls: string[] = []; - const appState = { - mpvSocketPath: '/tmp/mpv.sock', - mpvClient: null, - texthookerPort: 5174, - overlayRuntimeInitialized: false, - }; +type MainDeps = Parameters[0]; - const build = createBuildCliCommandContextMainDepsHandler({ - appState, - onMpvSocketPathChanged: (next, previous) => calls.push(`socket:${previous}->${next}`), +function buildContextDeps(overrides: Partial = {}) { + const noop = () => {}; + const noopAsync = async () => {}; + return createBuildCliCommandContextMainDepsHandler({ + appState: { + mpvSocketPath: '/tmp/mpv.sock', + mpvClient: null, + texthookerPort: 5174, + overlayRuntimeInitialized: false, + }, texthookerService: { isRunning: () => false, start: () => null }, - getResolvedConfig: () => ({ - texthooker: { openBrowser: true }, - annotationWebsocket: { enabled: true, port: 6678 }, - }), + getResolvedConfig: () => ({}), defaultWebsocketPort: 6677, defaultAnnotationWebsocketPort: 6678, hasMpvWebsocketPlugin: () => false, - openExternal: async (url) => { - calls.push(`open:${url}`); - }, - logBrowserOpenError: (url) => calls.push(`open-error:${url}`), - showMpvOsd: (text) => calls.push(`osd:${text}`), - - initializeOverlayRuntime: () => calls.push('init-overlay'), - toggleVisibleOverlay: () => calls.push('toggle-visible'), - togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'), - openFirstRunSetupWindow: (force?: boolean) => - calls.push(`open-setup:${force === true ? 'force' : 'default'}`), - setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), - - copyCurrentSubtitle: () => calls.push('copy-sub'), - startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`), - mineSentenceCard: async () => { - calls.push('mine'); - }, - startPendingMineSentenceMultiple: (timeoutMs) => calls.push(`mine-multi:${timeoutMs}`), - updateLastCardFromClipboard: async () => { - calls.push('update-last-card'); - }, - refreshKnownWordCache: async () => { - calls.push('refresh-known'); - }, - triggerFieldGrouping: async () => { - calls.push('field-grouping'); - }, - triggerSubsyncFromConfig: async () => { - calls.push('subsync'); - }, - markLastCardAsAudioCard: async () => { - calls.push('mark-audio'); - }, - dispatchSessionAction: async () => {}, - + openExternal: noopAsync, + logBrowserOpenError: noop, + showMpvOsd: noop, + initializeOverlayRuntime: noop, + toggleVisibleOverlay: noop, + togglePrimarySubtitleBar: noop, + openFirstRunSetupWindow: noop, + setVisibleOverlayVisible: noop, + copyCurrentSubtitle: noop, + startPendingMultiCopy: noop, + mineSentenceCard: noopAsync, + startPendingMineSentenceMultiple: noop, + updateLastCardFromClipboard: noopAsync, + refreshKnownWordCache: noopAsync, + triggerFieldGrouping: noopAsync, + triggerSubsyncFromConfig: noopAsync, + markLastCardAsAudioCard: noopAsync, + dispatchSessionAction: noopAsync, getAnilistStatus: () => ({ tokenStatus: 'resolved', tokenSource: 'literal', @@ -70,11 +49,11 @@ test('cli command context main deps builder maps state and callbacks', async () queueLastAttemptAt: null, queueLastError: null, }), - clearAnilistToken: () => calls.push('clear-token'), - openAnilistSetupWindow: () => calls.push('open-anilist-setup'), - openJellyfinSetupWindow: () => calls.push('open-jellyfin-setup'), + clearAnilistToken: noop, + openAnilistSetupWindow: noop, + openJellyfinSetupWindow: noop, getAnilistQueueStatus: () => ({ - pending: 1, + pending: 0, ready: 0, deadLetter: 0, lastAttemptAt: null, @@ -88,72 +67,89 @@ test('cli command context main deps builder maps state and callbacks', async () mediaTitle: 'Test', entryCount: 10, }), - runStatsCommand: async () => { - calls.push('run-stats'); - }, - runJellyfinCommand: async () => { - calls.push('run-jellyfin'); - }, - runUpdateCommand: async () => { - calls.push('run-update'); - }, - runEnsureLinuxRuntimePluginAssetsCommand: async () => { - calls.push('run-ensure-linux-runtime-plugin-assets'); - }, - runYoutubePlaybackFlow: async () => { - calls.push('run-youtube-playback'); - }, - openYomitanSettings: () => calls.push('open-yomitan'), - openConfigSettingsWindow: () => calls.push('open-config-settings'), - openSyncUiWindow: () => {}, - openYoutubeBrowserWindow: () => {}, - cycleSecondarySubMode: () => calls.push('cycle-secondary'), - openRuntimeOptionsPalette: () => calls.push('open-runtime-options'), - printHelp: () => calls.push('help'), - stopApp: () => calls.push('stop-app'), + runStatsCommand: noopAsync, + runJellyfinCommand: noopAsync, + runUpdateCommand: noopAsync, + runEnsureLinuxRuntimePluginAssetsCommand: noopAsync, + runYoutubePlaybackFlow: noopAsync, + openYomitanSettings: noop, + openConfigSettingsWindow: noop, + openSyncUiWindow: noop, + openYoutubeBrowserWindow: noop, + cycleSecondarySubMode: noop, + openRuntimeOptionsPalette: noop, + printHelp: noop, + stopApp: noop, hasMainWindow: () => true, getMultiCopyTimeoutMs: () => 5000, - schedule: (fn) => { - fn(); - return setTimeout(() => {}, 0); - }, - logInfo: (message) => calls.push(`info:${message}`), - logDebug: (message) => calls.push(`debug:${message}`), - logWarn: (message) => calls.push(`warn:${message}`), - logError: (message) => calls.push(`error:${message}`), + schedule: (fn) => setTimeout(fn, 0), + logInfo: noop, + logDebug: noop, + logWarn: noop, + logError: noop, + ...overrides, + })(); +} + +test('cli command context setSocketPath notifies only when the socket path changes', () => { + const changes: string[] = []; + const appState = { + mpvSocketPath: '/tmp/mpv.sock', + mpvClient: null, + texthookerPort: 5174, + overlayRuntimeInitialized: false, + }; + const deps = buildContextDeps({ + appState, + onMpvSocketPathChanged: (next, previous) => changes.push(`${previous}->${next}`), }); - const deps = build(); - assert.equal(deps.getSocketPath(), '/tmp/mpv.sock'); deps.setSocketPath('/tmp/next.sock'); + deps.setSocketPath('/tmp/next.sock'); + assert.equal(appState.mpvSocketPath, '/tmp/next.sock'); - assert.deepEqual(calls, ['socket:/tmp/mpv.sock->/tmp/next.sock']); - deps.setSocketPath('/tmp/next.sock'); - assert.deepEqual(calls, ['socket:/tmp/mpv.sock->/tmp/next.sock']); - calls.length = 0; - assert.equal(deps.getTexthookerPort(), 5174); - deps.setTexthookerPort(5175); - assert.equal(appState.texthookerPort, 5175); - assert.equal(deps.getTexthookerWebsocketUrl(), 'ws://127.0.0.1:6678'); - assert.equal(deps.shouldOpenBrowser(), true); - deps.showOsd('hello'); - deps.initializeOverlay(); - deps.openFirstRunSetup(true); - deps.setVisibleOverlay(true); - deps.openConfigSettingsWindow(); - deps.printHelp(); - await deps.runUpdateCommand({ update: true } as never, 'initial'); - - assert.deepEqual(calls, [ - 'osd:hello', - 'init-overlay', - 'open-setup:force', - 'set-visible:true', - 'open-config-settings', - 'help', - 'run-update', - ]); - - const retry = await deps.retryAnilistQueueNow(); - assert.deepEqual(retry, { ok: true, message: 'ok' }); + assert.equal(deps.getSocketPath(), '/tmp/next.sock'); + assert.deepEqual(changes, ['/tmp/mpv.sock->/tmp/next.sock']); }); + +for (const c of [ + { openBrowser: undefined, expected: true }, + { openBrowser: true, expected: true }, + { openBrowser: false, expected: false }, +]) { + test(`cli command context shouldOpenBrowser is ${c.expected} when texthooker.openBrowser is ${c.openBrowser}`, () => { + const deps = buildContextDeps({ + getResolvedConfig: () => ({ texthooker: { openBrowser: c.openBrowser } }), + }); + assert.equal(deps.shouldOpenBrowser(), c.expected); + }); +} + +for (const c of [ + { + name: 'uses the annotation websocket port when annotations are enabled', + config: { annotationWebsocket: { enabled: true } }, + hasPlugin: true, + expected: 'ws://127.0.0.1:6678', + }, + { + name: 'falls back to the default websocket port without the mpv plugin', + config: { annotationWebsocket: { enabled: false } }, + hasPlugin: false, + expected: 'ws://127.0.0.1:6677', + }, + { + name: 'returns no websocket url when the mpv plugin owns the websocket', + config: { annotationWebsocket: { enabled: false } }, + hasPlugin: true, + expected: undefined, + }, +]) { + test(`cli command context texthooker websocket url ${c.name}`, () => { + const deps = buildContextDeps({ + getResolvedConfig: () => c.config, + hasMpvWebsocketPlugin: () => c.hasPlugin, + }); + assert.equal(deps.getTexthookerWebsocketUrl(), c.expected); + }); +} diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index 69fc47d7..c23be099 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -3,16 +3,12 @@ import assert from 'node:assert/strict'; import { createCliCommandContext } from './cli-command-context'; function createDeps() { - let socketPath = '/tmp/mpv.sock'; - const logs: string[] = []; const browserErrors: string[] = []; return { deps: { - getSocketPath: () => socketPath, - setSocketPath: (value: string) => { - socketPath = value; - }, + getSocketPath: () => '/tmp/mpv.sock', + setSocketPath: () => {}, getMpvClient: () => null, showOsd: () => {}, texthookerService: {} as never, @@ -67,32 +63,15 @@ function createDeps() { hasMainWindow: () => true, getMultiCopyTimeoutMs: () => 1000, schedule: (fn: () => void) => setTimeout(fn, 0), - logInfo: (message: string) => { - logs.push(`i:${message}`); - }, - logDebug: (message: string) => { - logs.push(`d:${message}`); - }, - logWarn: (message: string) => { - logs.push(`w:${message}`); - }, - logError: (message: string) => { - logs.push(`e:${message}`); - }, + logInfo: () => {}, + logDebug: () => {}, + logWarn: () => {}, + logError: () => {}, }, - getLogs: () => logs, getBrowserErrors: () => browserErrors, }; } -test('cli command context proxies socket path getters/setters', () => { - const { deps } = createDeps(); - const context = createCliCommandContext(deps); - assert.equal(context.getSocketPath(), '/tmp/mpv.sock'); - context.setSocketPath('/tmp/next.sock'); - assert.equal(context.getSocketPath(), '/tmp/next.sock'); -}); - test('cli command context openInBrowser reports failures', async () => { const { deps, getBrowserErrors } = createDeps(); deps.openExternal = async () => { @@ -104,13 +83,3 @@ test('cli command context openInBrowser reports failures', async () => { await Promise.resolve(); assert.deepEqual(getBrowserErrors(), ['https://example.com']); }); - -test('cli command context log methods map to deps loggers', () => { - const { deps, getLogs } = createDeps(); - const context = createCliCommandContext(deps); - context.log('info'); - context.logDebug('debug'); - context.warn('warn'); - context.error('error', new Error('x')); - assert.deepEqual(getLogs(), ['i:info', 'd:debug', 'w:warn', 'e:error']); -}); diff --git a/src/main/runtime/cli-command-prechecks-main-deps.test.ts b/src/main/runtime/cli-command-prechecks-main-deps.test.ts deleted file mode 100644 index cf0a1f32..00000000 --- a/src/main/runtime/cli-command-prechecks-main-deps.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler } from './cli-command-prechecks-main-deps'; - -test('cli prechecks main deps builder maps transition handlers', () => { - const calls: string[] = []; - const deps = createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler({ - isTexthookerOnlyMode: () => true, - setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`), - commandNeedsOverlayStartupPrereqs: () => true, - ensureOverlayStartupPrereqs: () => calls.push('prereqs'), - startBackgroundWarmups: () => calls.push('warmups'), - logInfo: (message) => calls.push(`info:${message}`), - })(); - - assert.equal(deps.isTexthookerOnlyMode(), true); - assert.equal(deps.commandNeedsOverlayStartupPrereqs({} as never), true); - deps.setTexthookerOnlyMode(false); - deps.ensureOverlayStartupPrereqs(); - deps.startBackgroundWarmups(); - deps.logInfo('x'); - assert.deepEqual(calls, ['set:false', 'prereqs', 'warmups', 'info:x']); -}); diff --git a/src/main/runtime/composers/anilist-setup-composer.test.ts b/src/main/runtime/composers/anilist-setup-composer.test.ts deleted file mode 100644 index 10a5f971..00000000 --- a/src/main/runtime/composers/anilist-setup-composer.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { composeAnilistSetupHandlers } from './anilist-setup-composer'; - -test('composeAnilistSetupHandlers returns callable setup handlers', () => { - const calls: string[] = []; - const composed = composeAnilistSetupHandlers({ - notifyDeps: { - hasMpvClient: () => false, - showMpvOsd: () => {}, - showDesktopNotification: (title, opts) => { - calls.push(`notify:${opts.body}`); - }, - logInfo: () => {}, - }, - consumeTokenDeps: { - consumeAnilistSetupCallbackUrl: () => false, - saveToken: () => true, - setCachedToken: () => {}, - setResolvedState: () => {}, - setSetupPageOpened: () => {}, - onSuccess: () => {}, - closeWindow: () => {}, - }, - handleProtocolDeps: { - consumeAnilistSetupTokenFromUrl: () => false, - logWarn: () => {}, - }, - registerProtocolClientDeps: { - isDefaultApp: () => false, - getArgv: () => [], - execPath: process.execPath, - resolvePath: (value) => value, - setAsDefaultProtocolClient: () => true, - logDebug: () => {}, - }, - }); - - assert.equal(typeof composed.notifyAnilistSetup, 'function'); - assert.equal(typeof composed.consumeAnilistSetupTokenFromUrl, 'function'); - assert.equal(typeof composed.handleAnilistSetupProtocolUrl, 'function'); - assert.equal(typeof composed.registerSubminerProtocolClient, 'function'); - - // notifyAnilistSetup forwards to showDesktopNotification when no MPV client - composed.notifyAnilistSetup('Setup complete'); - assert.deepEqual(calls, ['notify:Setup complete']); - - // handleAnilistSetupProtocolUrl returns false for non-subminer URLs - const handled = composed.handleAnilistSetupProtocolUrl('https://other.example.com/'); - assert.equal(handled, false); - - // handleAnilistSetupProtocolUrl returns true for subminer:// URLs - const handledProtocol = composed.handleAnilistSetupProtocolUrl( - 'subminer://anilist-setup?code=abc', - ); - assert.equal(handledProtocol, true); -}); diff --git a/src/main/runtime/composers/anilist-tracking-composer.test.ts b/src/main/runtime/composers/anilist-tracking-composer.test.ts deleted file mode 100644 index d78d62c1..00000000 --- a/src/main/runtime/composers/anilist-tracking-composer.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import type { AnilistMediaGuess } from '../../../core/services/anilist/anilist-updater'; -import { composeAnilistTrackingHandlers } from './anilist-tracking-composer'; - -test('composeAnilistTrackingHandlers returns callable handlers and forwards calls to deps', async () => { - const refreshSavedTokens: string[] = []; - let refreshCachedToken: string | null = null; - - let mediaKeyState: string | null = 'media-key'; - let mediaDurationSecState: number | null = null; - let mediaGuessState: AnilistMediaGuess | null = null; - let mediaGuessPromiseState: Promise | null = null; - let lastDurationProbeAtMsState = 0; - let requestMpvDurationCalls = 0; - let guessAnilistMediaInfoCalls = 0; - - let retryUpdateCalls = 0; - let maybeRunUpdateCalls = 0; - - const composed = composeAnilistTrackingHandlers({ - refreshClientSecretMainDeps: { - getResolvedConfig: () => ({ anilist: { accessToken: 'refresh-token' } }), - isAnilistTrackingEnabled: () => true, - getCachedAccessToken: () => refreshCachedToken, - setCachedAccessToken: (token) => { - refreshCachedToken = token; - }, - saveStoredToken: (token) => { - refreshSavedTokens.push(token); - }, - loadStoredToken: () => null, - setClientSecretState: () => {}, - getAnilistSetupPageOpened: () => false, - setAnilistSetupPageOpened: () => {}, - openAnilistSetupWindow: () => {}, - now: () => 100, - }, - getCurrentMediaKeyMainDeps: { - getCurrentMediaPath: () => ' media-key ', - }, - resetMediaTrackingMainDeps: { - setMediaKey: (value) => { - mediaKeyState = value; - }, - setMediaDurationSec: (value) => { - mediaDurationSecState = value; - }, - setMediaGuess: (value) => { - mediaGuessState = value; - }, - setMediaGuessPromise: (value) => { - mediaGuessPromiseState = value; - }, - setLastDurationProbeAtMs: (value) => { - lastDurationProbeAtMsState = value; - }, - }, - getMediaGuessRuntimeStateMainDeps: { - getMediaKey: () => mediaKeyState, - getMediaDurationSec: () => mediaDurationSecState, - getMediaGuess: () => mediaGuessState, - getMediaGuessPromise: () => mediaGuessPromiseState, - getLastDurationProbeAtMs: () => lastDurationProbeAtMsState, - }, - setMediaGuessRuntimeStateMainDeps: { - setMediaKey: (value) => { - mediaKeyState = value; - }, - setMediaDurationSec: (value) => { - mediaDurationSecState = value; - }, - setMediaGuess: (value) => { - mediaGuessState = value; - }, - setMediaGuessPromise: (value) => { - mediaGuessPromiseState = value; - }, - setLastDurationProbeAtMs: (value) => { - lastDurationProbeAtMsState = value; - }, - }, - recordMediaDurationMainDeps: { - getCurrentMediaKey: () => 'media-key', - getState: () => ({ - mediaKey: mediaKeyState, - mediaDurationSec: mediaDurationSecState, - mediaGuess: mediaGuessState, - mediaGuessPromise: mediaGuessPromiseState, - lastDurationProbeAtMs: lastDurationProbeAtMsState, - }), - setState: (state) => { - mediaKeyState = state.mediaKey; - mediaDurationSecState = state.mediaDurationSec; - mediaGuessState = state.mediaGuess; - mediaGuessPromiseState = state.mediaGuessPromise; - lastDurationProbeAtMsState = state.lastDurationProbeAtMs; - }, - }, - resetMediaGuessStateMainDeps: { - setMediaGuess: (value) => { - mediaGuessState = value; - }, - setMediaGuessPromise: (value) => { - mediaGuessPromiseState = value; - }, - }, - maybeProbeDurationMainDeps: { - getState: () => ({ - mediaKey: mediaKeyState, - mediaDurationSec: mediaDurationSecState, - mediaGuess: mediaGuessState, - mediaGuessPromise: mediaGuessPromiseState, - lastDurationProbeAtMs: lastDurationProbeAtMsState, - }), - setState: (state) => { - mediaKeyState = state.mediaKey; - mediaDurationSecState = state.mediaDurationSec; - mediaGuessState = state.mediaGuess; - mediaGuessPromiseState = state.mediaGuessPromise; - lastDurationProbeAtMsState = state.lastDurationProbeAtMs; - }, - durationRetryIntervalMs: 0, - now: () => 1000, - requestMpvDuration: async () => { - requestMpvDurationCalls += 1; - return 120; - }, - logWarn: () => {}, - }, - ensureMediaGuessMainDeps: { - getState: () => ({ - mediaKey: mediaKeyState, - mediaDurationSec: mediaDurationSecState, - mediaGuess: mediaGuessState, - mediaGuessPromise: mediaGuessPromiseState, - lastDurationProbeAtMs: lastDurationProbeAtMsState, - }), - setState: (state) => { - mediaKeyState = state.mediaKey; - mediaDurationSecState = state.mediaDurationSec; - mediaGuessState = state.mediaGuess; - mediaGuessPromiseState = state.mediaGuessPromise; - lastDurationProbeAtMsState = state.lastDurationProbeAtMs; - }, - resolveMediaPathForJimaku: (value) => value, - getCurrentMediaPath: () => '/tmp/media.mkv', - getCurrentMediaTitle: () => 'Episode title', - guessAnilistMediaInfo: async () => { - guessAnilistMediaInfoCalls += 1; - return { title: 'Episode title', season: null, episode: 7, source: 'guessit' }; - }, - }, - processNextRetryUpdateMainDeps: { - nextReady: () => ({ key: 'retry-key', title: 'Retry title', season: null, episode: 1 }), - refreshRetryQueueState: () => {}, - setLastAttemptAt: () => {}, - setLastError: () => {}, - refreshAnilistClientSecretState: async () => 'retry-token', - updateAnilistPostWatchProgress: async () => { - retryUpdateCalls += 1; - return { status: 'updated', message: 'ok' }; - }, - markSuccess: () => {}, - rememberAttemptedUpdateKey: () => {}, - markFailure: () => {}, - logInfo: () => {}, - now: () => 1, - }, - maybeRunPostWatchUpdateMainDeps: { - getInFlight: () => false, - setInFlight: () => {}, - getResolvedConfig: () => ({ tracking: true }), - isAnilistTrackingEnabled: () => true, - getCurrentMediaKey: () => 'media-key', - hasMpvClient: () => true, - getTrackedMediaKey: () => 'media-key', - resetTrackedMedia: () => {}, - getWatchedSeconds: () => 500, - maybeProbeAnilistDuration: async () => 600, - ensureAnilistMediaGuess: async () => ({ - title: 'Episode title', - season: null, - episode: 2, - source: 'guessit', - }), - hasAttemptedUpdateKey: () => false, - processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }), - refreshAnilistClientSecretState: async () => 'run-token', - enqueueRetry: () => {}, - markRetryFailure: () => {}, - markRetrySuccess: () => {}, - refreshRetryQueueState: () => {}, - updateAnilistPostWatchProgress: async () => { - maybeRunUpdateCalls += 1; - return { status: 'updated', message: 'updated from maybeRun' }; - }, - rememberAttemptedUpdateKey: () => {}, - showMpvOsd: () => {}, - logInfo: () => {}, - logWarn: () => {}, - minWatchSeconds: 10, - minWatchRatio: 0.5, - }, - }); - - assert.equal(typeof composed.refreshAnilistClientSecretState, 'function'); - assert.equal(typeof composed.getCurrentAnilistMediaKey, 'function'); - assert.equal(typeof composed.resetAnilistMediaTracking, 'function'); - assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function'); - assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function'); - assert.equal(typeof composed.recordAnilistMediaDuration, 'function'); - assert.equal(typeof composed.resetAnilistMediaGuessState, 'function'); - assert.equal(typeof composed.maybeProbeAnilistDuration, 'function'); - assert.equal(typeof composed.ensureAnilistMediaGuess, 'function'); - assert.equal(typeof composed.processNextAnilistRetryUpdate, 'function'); - assert.equal(typeof composed.maybeRunAnilistPostWatchUpdate, 'function'); - - const refreshed = await composed.refreshAnilistClientSecretState({ force: true }); - assert.equal(refreshed, 'refresh-token'); - assert.deepEqual(refreshSavedTokens, ['refresh-token']); - - assert.equal(composed.getCurrentAnilistMediaKey(), 'media-key'); - composed.resetAnilistMediaTracking('next-key'); - assert.equal(mediaKeyState, 'next-key'); - assert.equal(mediaDurationSecState, null); - - composed.setAnilistMediaGuessRuntimeState({ - mediaKey: 'media-key', - mediaDurationSec: 90, - mediaGuess: { title: 'Known', season: null, episode: 3, source: 'fallback' }, - mediaGuessPromise: null, - lastDurationProbeAtMs: 11, - }); - assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90); - - composed.recordAnilistMediaDuration(180); - assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 180); - - composed.resetAnilistMediaGuessState(); - assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null); - - mediaKeyState = 'media-key'; - mediaDurationSecState = null; - const probedDuration = await composed.maybeProbeAnilistDuration('media-key'); - assert.equal(probedDuration, 120); - assert.equal(requestMpvDurationCalls, 1); - - mediaGuessState = null; - await composed.ensureAnilistMediaGuess('media-key'); - assert.equal(guessAnilistMediaInfoCalls, 1); - - const retryResult = await composed.processNextAnilistRetryUpdate(); - assert.deepEqual(retryResult, { ok: true, message: 'ok' }); - assert.equal(retryUpdateCalls, 1); - - await composed.maybeRunAnilistPostWatchUpdate(); - assert.equal(maybeRunUpdateCalls, 1); -}); diff --git a/src/main/runtime/composers/app-ready-composer.test.ts b/src/main/runtime/composers/app-ready-composer.test.ts deleted file mode 100644 index e53294b3..00000000 --- a/src/main/runtime/composers/app-ready-composer.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { composeAppReadyRuntime } from './app-ready-composer'; - -test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => { - const calls: string[] = []; - const composed = composeAppReadyRuntime({ - reloadConfigMainDeps: { - reloadConfigStrict: () => { - calls.push('reloadConfigStrict'); - return { ok: true, path: '/tmp/config.jsonc', warnings: [] }; - }, - logInfo: () => {}, - logDebug: () => {}, - logWarning: () => {}, - showDesktopNotification: () => {}, - startConfigHotReload: () => {}, - refreshAnilistClientSecretState: async () => {}, - failHandlers: { - logError: () => {}, - showErrorBox: () => {}, - quit: () => {}, - }, - }, - criticalConfigErrorMainDeps: { - getConfigPath: () => '/tmp/config.jsonc', - failHandlers: { - logError: () => {}, - showErrorBox: () => {}, - quit: () => {}, - }, - }, - appReadyRuntimeMainDeps: { - ensureDefaultConfigBootstrap: () => {}, - loadSubtitlePosition: () => {}, - resolveKeybindings: () => {}, - createMpvClient: () => {}, - getResolvedConfig: () => ({}) as never, - getConfigWarnings: () => [], - logConfigWarning: () => {}, - setLogLevel: () => {}, - initRuntimeOptionsManager: () => {}, - setSecondarySubMode: () => {}, - defaultSecondarySubMode: 'hover', - defaultWebsocketPort: 5174, - defaultAnnotationWebsocketPort: 6678, - defaultTexthookerPort: 5174, - hasMpvWebsocketPlugin: () => false, - startSubtitleWebsocket: () => {}, - startAnnotationWebsocket: () => {}, - startTexthooker: () => {}, - log: () => {}, - createMecabTokenizerAndCheck: async () => {}, - createSubtitleTimingTracker: () => {}, - loadYomitanExtension: async () => {}, - handleFirstRunSetup: async () => {}, - startJellyfinRemoteSession: async () => {}, - prewarmSubtitleDictionaries: async () => {}, - startBackgroundWarmups: () => {}, - texthookerOnlyMode: false, - shouldAutoInitializeOverlayRuntimeFromConfig: () => false, - setVisibleOverlayVisible: () => {}, - initializeOverlayRuntime: () => {}, - handleInitialArgs: () => {}, - logDebug: () => {}, - now: () => Date.now(), - }, - immersionTrackerStartupMainDeps: { - getResolvedConfig: () => ({}) as never, - getConfiguredDbPath: () => '/tmp/immersion.sqlite', - createTrackerService: () => - ({ - startSession: () => {}, - }) as never, - setTracker: () => {}, - getMpvClient: () => null, - seedTrackerFromCurrentMedia: () => {}, - logInfo: () => {}, - logDebug: () => {}, - logWarn: () => {}, - }, - }); - - assert.equal(typeof composed.reloadConfig, 'function'); - assert.equal(typeof composed.criticalConfigError, 'function'); - assert.equal(typeof composed.appReadyRuntimeRunner, 'function'); - - // reloadConfig invokes the injected reloadConfigStrict dep - composed.reloadConfig(); - assert.deepEqual(calls, ['reloadConfigStrict']); -}); diff --git a/src/main/runtime/composers/cli-startup-composer.test.ts b/src/main/runtime/composers/cli-startup-composer.test.ts deleted file mode 100644 index 880b7801..00000000 --- a/src/main/runtime/composers/cli-startup-composer.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import type { CliArgs } from '../../../cli/args'; -import { composeCliStartupHandlers } from './cli-startup-composer'; - -test('composeCliStartupHandlers returns callable CLI startup handlers', () => { - const calls: string[] = []; - const handlers = composeCliStartupHandlers({ - cliCommandContextMainDeps: { - appState: {} as never, - setLogLevel: () => {}, - texthookerService: {} as never, - getResolvedConfig: () => ({}) as never, - defaultWebsocketPort: 6677, - defaultAnnotationWebsocketPort: 6678, - hasMpvWebsocketPlugin: () => false, - openExternal: async () => {}, - logBrowserOpenError: () => {}, - showMpvOsd: () => {}, - initializeOverlayRuntime: () => {}, - toggleVisibleOverlay: () => {}, - togglePrimarySubtitleBar: () => {}, - openFirstRunSetupWindow: () => {}, - setVisibleOverlayVisible: () => {}, - copyCurrentSubtitle: () => {}, - startPendingMultiCopy: () => {}, - mineSentenceCard: async () => {}, - startPendingMineSentenceMultiple: () => {}, - updateLastCardFromClipboard: async () => {}, - refreshKnownWordCache: async () => {}, - triggerFieldGrouping: async () => {}, - triggerSubsyncFromConfig: async () => {}, - markLastCardAsAudioCard: async () => {}, - dispatchSessionAction: async () => {}, - getAnilistStatus: () => ({}) as never, - clearAnilistToken: () => {}, - openAnilistSetupWindow: () => {}, - openJellyfinSetupWindow: () => {}, - getAnilistQueueStatus: () => ({}) as never, - processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }), - generateCharacterDictionary: async () => ({ - zipPath: '/tmp/test.zip', - fromCache: false, - mediaId: 1, - mediaTitle: 'Test', - entryCount: 1, - }), - runJellyfinCommand: async () => {}, - runStatsCommand: async () => {}, - runUpdateCommand: async () => {}, - runEnsureLinuxRuntimePluginAssetsCommand: async () => {}, - runYoutubePlaybackFlow: async () => {}, - openYomitanSettings: () => {}, - openConfigSettingsWindow: () => {}, - openSyncUiWindow: () => {}, - openYoutubeBrowserWindow: () => {}, - cycleSecondarySubMode: () => {}, - openRuntimeOptionsPalette: () => {}, - printHelp: () => {}, - stopApp: () => {}, - hasMainWindow: () => false, - getMultiCopyTimeoutMs: () => 0, - schedule: () => 0 as never, - logInfo: () => {}, - logDebug: () => {}, - logWarn: () => {}, - logError: () => {}, - }, - cliCommandRuntimeHandlerMainDeps: { - handleTexthookerOnlyModeTransitionMainDeps: { - isTexthookerOnlyMode: () => false, - ensureOverlayStartupPrereqs: () => {}, - setTexthookerOnlyMode: () => {}, - commandNeedsOverlayStartupPrereqs: () => false, - startBackgroundWarmups: () => {}, - logInfo: () => {}, - }, - handleCliCommandRuntimeServiceWithContext: (args, _source, _ctx) => { - calls.push(`handleCommand:${(args as { command?: string }).command ?? 'unknown'}`); - }, - }, - initialArgsRuntimeHandlerMainDeps: { - getInitialArgs: () => null, - isBackgroundMode: () => false, - shouldEnsureTrayOnStartup: () => false, - shouldRunHeadlessInitialCommand: () => false, - ensureTray: () => {}, - isTexthookerOnlyMode: () => false, - hasImmersionTracker: () => false, - getMpvClient: () => null, - commandNeedsOverlayStartupPrereqs: () => false, - commandNeedsOverlayRuntime: () => false, - ensureOverlayStartupPrereqs: () => {}, - isOverlayRuntimeInitialized: () => false, - initializeOverlayRuntime: () => {}, - logInfo: () => {}, - }, - }); - - assert.equal(typeof handlers.createCliCommandContext, 'function'); - assert.equal(typeof handlers.handleCliCommand, 'function'); - assert.equal(typeof handlers.handleInitialArgs, 'function'); - - // handleCliCommand routes to the injected handleCliCommandRuntimeServiceWithContext dep - handlers.handleCliCommand({ command: 'start' } as unknown as CliArgs); - assert.deepEqual(calls, ['handleCommand:start']); -}); diff --git a/src/main/runtime/composers/composer-contracts.type-test.ts b/src/main/runtime/composers/composer-contracts.type-test.ts deleted file mode 100644 index feb2980e..00000000 --- a/src/main/runtime/composers/composer-contracts.type-test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { ComposerInputs } from './contracts'; -import type { IpcRuntimeComposerOptions } from './ipc-runtime-composer'; -import type { JellyfinRemoteComposerOptions } from './jellyfin-remote-composer'; -import type { MpvRuntimeComposerOptions } from './mpv-runtime-composer'; -import type { AnilistSetupComposerOptions } from './anilist-setup-composer'; - -type Assert = T; -type IsAssignable = [From] extends [To] ? true : false; - -type FakeMpvClient = { - on: (...args: unknown[]) => unknown; - connect: () => void; -}; - -type FakeTokenizerDeps = { isKnownWord: (text: string) => boolean }; -type FakeTokenizedSubtitle = { text: string }; - -type RequiredAnilistSetupInputKeys = keyof ComposerInputs; -type RequiredJellyfinInputKeys = keyof ComposerInputs; -type RequiredIpcInputKeys = keyof ComposerInputs; -type RequiredMpvInputKeys = keyof ComposerInputs< - MpvRuntimeComposerOptions ->; - -const contractAssertions = [ - true as Assert>, - true as Assert>, - true as Assert>, - true as Assert>, -]; -void contractAssertions; - -// @ts-expect-error missing required notifyDeps should fail compile-time contract -const anilistMissingRequired: AnilistSetupComposerOptions = { - consumeTokenDeps: {} as AnilistSetupComposerOptions['consumeTokenDeps'], - handleProtocolDeps: {} as AnilistSetupComposerOptions['handleProtocolDeps'], - registerProtocolClientDeps: {} as AnilistSetupComposerOptions['registerProtocolClientDeps'], -}; - -// @ts-expect-error missing required getMpvClient should fail compile-time contract -const jellyfinMissingRequired: JellyfinRemoteComposerOptions = { - getConfiguredSession: {} as JellyfinRemoteComposerOptions['getConfiguredSession'], - getClientInfo: {} as JellyfinRemoteComposerOptions['getClientInfo'], - getJellyfinConfig: {} as JellyfinRemoteComposerOptions['getJellyfinConfig'], - playJellyfinItem: {} as JellyfinRemoteComposerOptions['playJellyfinItem'], - logWarn: {} as JellyfinRemoteComposerOptions['logWarn'], - sendMpvCommand: {} as JellyfinRemoteComposerOptions['sendMpvCommand'], - jellyfinTicksToSeconds: {} as JellyfinRemoteComposerOptions['jellyfinTicksToSeconds'], - getActivePlayback: {} as JellyfinRemoteComposerOptions['getActivePlayback'], - clearActivePlayback: {} as JellyfinRemoteComposerOptions['clearActivePlayback'], - getSession: {} as JellyfinRemoteComposerOptions['getSession'], - getNow: {} as JellyfinRemoteComposerOptions['getNow'], - getLastProgressAtMs: {} as JellyfinRemoteComposerOptions['getLastProgressAtMs'], - setLastProgressAtMs: {} as JellyfinRemoteComposerOptions['setLastProgressAtMs'], - progressIntervalMs: 3000, - ticksPerSecond: 10_000_000, - logDebug: {} as JellyfinRemoteComposerOptions['logDebug'], -}; - -// @ts-expect-error missing required registration should fail compile-time contract -const ipcMissingRequired: IpcRuntimeComposerOptions = { - mpvCommandMainDeps: {} as IpcRuntimeComposerOptions['mpvCommandMainDeps'], - handleMpvCommandFromIpcRuntime: {} as IpcRuntimeComposerOptions['handleMpvCommandFromIpcRuntime'], - runSubsyncManualFromIpc: {} as IpcRuntimeComposerOptions['runSubsyncManualFromIpc'], -}; - -// @ts-expect-error missing required tokenizer should fail compile-time contract -const mpvMissingRequired: MpvRuntimeComposerOptions< - FakeMpvClient, - FakeTokenizerDeps, - FakeTokenizedSubtitle -> = { - bindMpvMainEventHandlersMainDeps: {} as MpvRuntimeComposerOptions< - FakeMpvClient, - FakeTokenizerDeps, - FakeTokenizedSubtitle - >['bindMpvMainEventHandlersMainDeps'], - mpvClientRuntimeServiceFactoryMainDeps: {} as MpvRuntimeComposerOptions< - FakeMpvClient, - FakeTokenizerDeps, - FakeTokenizedSubtitle - >['mpvClientRuntimeServiceFactoryMainDeps'], - updateMpvSubtitleRenderMetricsMainDeps: {} as MpvRuntimeComposerOptions< - FakeMpvClient, - FakeTokenizerDeps, - FakeTokenizedSubtitle - >['updateMpvSubtitleRenderMetricsMainDeps'], - warmups: {} as MpvRuntimeComposerOptions< - FakeMpvClient, - FakeTokenizerDeps, - FakeTokenizedSubtitle - >['warmups'], -}; - -void anilistMissingRequired; -void jellyfinMissingRequired; -void ipcMissingRequired; -void mpvMissingRequired; diff --git a/src/main/runtime/composers/headless-startup-composer.test.ts b/src/main/runtime/composers/headless-startup-composer.test.ts deleted file mode 100644 index 2a2c9eb9..00000000 --- a/src/main/runtime/composers/headless-startup-composer.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import type { CliArgs } from '../../../cli/args'; -import { composeHeadlessStartupHandlers } from './headless-startup-composer'; - -test('composeHeadlessStartupHandlers returns startup bootstrap handlers', () => { - const calls: string[] = []; - - const handlers = composeHeadlessStartupHandlers< - CliArgs, - { mode: string }, - { startAppLifecycle: (args: CliArgs) => void } - >({ - startupRuntimeHandlersDeps: { - appLifecycleRuntimeRunnerMainDeps: { - app: { on: () => {} } as never, - platform: 'darwin', - shouldStartApp: () => true, - parseArgs: () => ({}) as never, - handleCliCommand: () => {}, - printHelp: () => {}, - logNoRunningInstance: () => {}, - onReady: async () => {}, - onWillQuitCleanup: () => {}, - shouldRestoreWindowsOnActivate: () => false, - restoreWindowsOnActivate: () => {}, - shouldQuitOnWindowAllClosed: () => false, - }, - createAppLifecycleRuntimeRunner: () => (args) => { - calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`); - }, - buildStartupBootstrapMainDeps: (startAppLifecycle) => ({ - argv: ['node', 'main.js'], - parseArgs: () => ({ command: 'start' }) as never, - setLogLevel: () => {}, - forceX11Backend: () => {}, - enforceUnsupportedWaylandMode: () => {}, - shouldStartApp: () => true, - getDefaultSocketPath: () => '/tmp/mpv.sock', - defaultTexthookerPort: 5174, - configDir: '/tmp/config', - defaultConfig: {} as never, - generateConfigTemplate: () => 'template', - generateDefaultConfigFile: async () => 0, - setExitCode: () => {}, - quitApp: () => {}, - logGenerateConfigError: () => {}, - startAppLifecycle: (args) => startAppLifecycle(args as never), - }), - createStartupBootstrapRuntimeDeps: (deps) => ({ - startAppLifecycle: deps.startAppLifecycle, - }), - runStartupBootstrapRuntime: (deps) => { - deps.startAppLifecycle({ command: 'start' } as unknown as CliArgs); - return { mode: 'started' }; - }, - applyStartupState: (state) => { - calls.push(`apply:${state.mode}`); - }, - }, - }); - - assert.equal(typeof handlers.runAndApplyStartupState, 'function'); - assert.deepEqual(handlers.runAndApplyStartupState(), { mode: 'started' }); - assert.deepEqual(calls, ['lifecycle:start', 'apply:started']); -}); diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts deleted file mode 100644 index 0d7afc9d..00000000 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { composeIpcRuntimeHandlers } from './ipc-runtime-composer'; - -test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => { - let registered = false; - let receivedReferenceTrackId: number | null | undefined; - - const composed = composeIpcRuntimeHandlers({ - mpvCommandMainDeps: { - triggerSubsyncFromConfig: async () => {}, - openRuntimeOptionsPalette: () => {}, - openJimaku: () => {}, - openTsukihime: () => {}, - openYoutubeTrackPicker: () => {}, - openPlaylistBrowser: () => {}, - cycleRuntimeOption: () => ({ ok: true }), - showMpvOsd: () => {}, - replayCurrentSubtitle: () => {}, - playNextSubtitle: () => {}, - sendMpvCommand: () => {}, - getMpvClient: () => null, - isMpvConnected: () => false, - hasRuntimeOptionsManager: () => true, - }, - handleMpvCommandFromIpcRuntime: () => {}, - runSubsyncManualFromIpc: async (request) => { - receivedReferenceTrackId = request.referenceTrackId; - return { - ok: true, - message: 'ok', - }; - }, - registration: { - runtimeOptions: { - getRuntimeOptionsManager: () => null, - showMpvOsd: () => {}, - }, - mainDeps: { - getMainWindow: () => null, - getVisibleOverlayVisibility: () => false, - focusMainWindow: () => {}, - onOverlayModalClosed: () => {}, - openYomitanSettings: () => {}, - quitApp: () => {}, - toggleVisibleOverlay: () => {}, - tokenizeCurrentSubtitle: async () => null, - getCurrentSubtitleRaw: () => '', - getCurrentSubtitleAss: () => '', - getPlaybackPaused: () => null, - getSubtitlePosition: () => ({}) as never, - getSubtitleStyle: () => ({}) as never, - saveSubtitlePosition: () => {}, - getMecabTokenizer: () => null, - getKeybindings: () => [], - getSessionBindings: () => [], - getConfiguredShortcuts: () => ({}) as never, - dispatchSessionAction: async () => {}, - getStatsToggleKey: () => 'Backquote', - getMarkWatchedKey: () => 'KeyW', - getOverlayNotificationPosition: () => 'top-right', - getControllerConfig: () => ({}) as never, - saveControllerConfig: () => {}, - saveControllerPreference: () => {}, - getSecondarySubMode: () => 'hover' as never, - getMpvClient: () => null, - getAnkiConnectStatus: () => false, - getRuntimeOptions: () => [], - reportOverlayContentBounds: () => {}, - getAnilistStatus: () => ({}) as never, - clearAnilistToken: () => {}, - openAnilistSetup: () => {}, - getAnilistQueueStatus: () => ({}) as never, - retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), - appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), - getPlaylistBrowserSnapshot: async () => - ({ - directoryPath: null, - directoryAvailable: false, - directoryStatus: '', - directoryItems: [], - playlistItems: [], - playingIndex: null, - currentFilePath: null, - }) as never, - appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), - playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), - removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), - movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), - onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), - }, - ankiJimakuDeps: { - patchAnkiConnectEnabled: () => {}, - getResolvedConfig: () => ({}) as never, - getRuntimeOptionsManager: () => null, - getSubtitleTimingTracker: () => null, - getMpvClient: () => null, - getAnkiIntegration: () => null, - setAnkiIntegration: () => {}, - getKnownWordCacheStatePath: () => '', - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => (() => {}) as never, - broadcastRuntimeOptionsChanged: () => {}, - getFieldGroupingResolver: () => null, - setFieldGroupingResolver: () => {}, - parseMediaInfo: () => ({}) as never, - getCurrentMediaPath: () => null, - jimakuFetchJson: async () => ({ data: null }) as never, - getJimakuMaxEntryResults: () => 0, - getJimakuLanguagePreference: () => 'ja' as never, - resolveJimakuApiKey: async () => null, - isRemoteMediaPath: () => false, - downloadToFile: async () => ({ ok: true, path: '/tmp/file' }), - }, - registerIpcRuntimeServices: () => { - registered = true; - }, - }, - }); - - assert.equal(typeof composed.handleMpvCommandFromIpc, 'function'); - assert.equal(typeof composed.runSubsyncManualFromIpc, 'function'); - assert.equal(typeof composed.registerIpcRuntimeHandlers, 'function'); - - const result = await composed.runSubsyncManualFromIpc({ - engine: 'alass', - referenceTrackId: 7, - }); - assert.deepEqual(result, { ok: true, message: 'ok' }); - assert.equal(receivedReferenceTrackId, 7); - - composed.registerIpcRuntimeHandlers(); - assert.equal(registered, true); -}); diff --git a/src/main/runtime/composers/jellyfin-remote-composer.test.ts b/src/main/runtime/composers/jellyfin-remote-composer.test.ts deleted file mode 100644 index 0ecce276..00000000 --- a/src/main/runtime/composers/jellyfin-remote-composer.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer'; - -test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', async () => { - let lastProgressAt = 0; - let activePlayback: unknown = { - itemId: 'item-1', - mediaSourceId: 'src-1', - playMethod: 'DirectPlay', - audioStreamIndex: null, - subtitleStreamIndex: null, - }; - const calls: string[] = []; - - const composed = composeJellyfinRemoteHandlers({ - getConfiguredSession: () => null, - getClientInfo: () => - ({ clientName: 'SubMiner', clientVersion: 'test', deviceId: 'dev' }) as never, - getJellyfinConfig: () => ({ enabled: false }) as never, - playJellyfinItem: async () => {}, - logWarn: () => {}, - getMpvClient: () => null, - sendMpvCommand: () => {}, - jellyfinTicksToSeconds: () => 0, - getActivePlayback: () => activePlayback as never, - clearActivePlayback: () => { - activePlayback = null; - calls.push('clearActivePlayback'); - }, - getSession: () => null, - getNow: () => 0, - getLastProgressAtMs: () => lastProgressAt, - setLastProgressAtMs: (next) => { - lastProgressAt = next; - }, - progressIntervalMs: 3000, - ticksPerSecond: 10_000_000, - logDebug: () => {}, - }); - - assert.equal(typeof composed.reportJellyfinRemoteProgress, 'function'); - assert.equal(typeof composed.reportJellyfinRemoteStopped, 'function'); - assert.equal(typeof composed.handleJellyfinRemotePlay, 'function'); - assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function'); - assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function'); - - // reportJellyfinRemoteStopped clears active playback when there is no connected session - await composed.reportJellyfinRemoteStopped(); - assert.equal(activePlayback, null); - assert.deepEqual(calls, ['clearActivePlayback']); -}); diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts index b587eabc..f69e4913 100644 --- a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts +++ b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts @@ -1,9 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { - composeJellyfinRuntimeHandlers, - createRestartJellyfinRemoteSessionAfterSetupLoginHandler, -} from './jellyfin-runtime-composer'; +import { createRestartJellyfinRemoteSessionAfterSetupLoginHandler } from './jellyfin-runtime-composer'; test('setup login restart uses auto-connect path without an active remote session', async () => { const startOptions: Array<{ explicit?: boolean } | undefined> = []; @@ -32,209 +29,3 @@ test('setup login restart explicitly refreshes an active remote session', async assert.deepEqual(startOptions, [{ explicit: true }]); }); - -test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers', () => { - let activePlayback: unknown = null; - let lastProgressAtMs = 0; - const composed = composeJellyfinRuntimeHandlers({ - getResolvedJellyfinConfigMainDeps: { - getResolvedConfig: () => ({ jellyfin: { enabled: false, serverUrl: '' } }) as never, - loadStoredSession: () => null, - getEnv: () => undefined, - }, - getJellyfinClientInfoMainDeps: { - getResolvedJellyfinConfig: () => ({}) as never, - getHostName: () => 'workstation', - defaultClientName: 'SubMiner', - defaultClientVersion: 'test', - }, - waitForMpvConnectedMainDeps: { - getMpvClient: () => null, - now: () => Date.now(), - sleep: async () => {}, - }, - launchMpvIdleForJellyfinPlaybackMainDeps: { - getMpvExecutablePath: () => 'mpv', - getSocketPath: () => '/tmp/test-mpv.sock', - getLaunchMode: () => 'normal', - platform: 'linux', - execPath: process.execPath, - getDefaultMpvLogPath: () => '/tmp/test-mpv.log', - defaultMpvArgs: [], - removeSocketPath: () => {}, - spawnMpv: () => ({ unref: () => {} }) as never, - logWarn: () => {}, - logInfo: () => {}, - }, - ensureMpvConnectedForJellyfinPlaybackMainDeps: { - getMpvClient: () => null, - setMpvClient: () => {}, - createMpvClient: () => ({}) as never, - getAutoLaunchInFlight: () => null, - setAutoLaunchInFlight: () => {}, - connectTimeoutMs: 10, - autoLaunchTimeoutMs: 10, - }, - preloadJellyfinExternalSubtitlesMainDeps: { - listJellyfinSubtitleTracks: async () => [], - getMpvClient: () => null, - sendMpvCommand: () => {}, - wait: async () => {}, - cacheSubtitleTrack: async () => ({ path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' }), - cleanupCachedSubtitles: () => {}, - logDebug: () => {}, - }, - playJellyfinItemInMpvMainDeps: { - getMpvClient: () => null, - resolvePlaybackPlan: async () => ({ - mode: 'direct', - url: 'https://example.test/video.m3u8', - title: 'Episode 1', - itemTitle: 'Episode 1', - seriesTitle: null, - seasonNumber: null, - episodeNumber: null, - startTimeTicks: 0, - audioStreamIndex: null, - subtitleStreamIndex: null, - }), - applyJellyfinMpvDefaults: () => {}, - showVisibleOverlay: () => {}, - sendMpvCommand: () => {}, - armQuitOnDisconnect: () => {}, - schedule: () => undefined, - convertTicksToSeconds: () => 0, - setActivePlayback: (value) => { - activePlayback = value; - }, - setLastProgressAtMs: (value) => { - lastProgressAtMs = value; - }, - reportPlaying: () => {}, - showMpvOsd: () => {}, - }, - remoteComposerOptions: { - getConfiguredSession: () => null, - logWarn: () => {}, - getMpvClient: () => null, - sendMpvCommand: () => {}, - jellyfinTicksToSeconds: () => 0, - getActivePlayback: () => activePlayback as never, - clearActivePlayback: () => { - activePlayback = null; - }, - getSession: () => null, - getNow: () => Date.now(), - getLastProgressAtMs: () => lastProgressAtMs, - setLastProgressAtMs: (value) => { - lastProgressAtMs = value; - }, - progressIntervalMs: 3000, - ticksPerSecond: 10_000_000, - logDebug: () => {}, - }, - handleJellyfinAuthCommandsMainDeps: { - patchRawConfig: () => {}, - authenticateWithPassword: async () => ({ - serverUrl: 'https://example.test', - username: 'user', - accessToken: 'token', - userId: 'id', - }), - saveStoredSession: () => {}, - clearStoredSession: () => {}, - logInfo: () => {}, - }, - handleJellyfinListCommandsMainDeps: { - listJellyfinLibraries: async () => [], - listJellyfinItems: async () => [], - listJellyfinSubtitleTracks: async () => [], - writeJellyfinPreviewAuth: () => {}, - logInfo: () => {}, - }, - handleJellyfinPlayCommandMainDeps: { - logWarn: () => {}, - }, - handleJellyfinRemoteAnnounceCommandMainDeps: { - getRemoteSession: () => null, - logInfo: () => {}, - logWarn: () => {}, - }, - startJellyfinRemoteSessionMainDeps: { - getCurrentSession: () => null, - setCurrentSession: () => {}, - createRemoteSessionService: () => - ({ - start: async () => {}, - }) as never, - defaultDeviceId: 'dev', - defaultClientName: 'SubMiner', - defaultClientVersion: 'test', - getHostName: () => 'workstation', - logInfo: () => {}, - logWarn: () => {}, - }, - stopJellyfinRemoteSessionMainDeps: { - getCurrentSession: () => null, - setCurrentSession: () => {}, - clearActivePlayback: () => { - activePlayback = null; - }, - }, - runJellyfinCommandMainDeps: { - defaultServerUrl: 'https://example.test', - }, - maybeFocusExistingJellyfinSetupWindowMainDeps: { - getSetupWindow: () => null, - }, - openJellyfinSetupWindowMainDeps: { - createSetupWindow: () => - ({ - focus: () => {}, - webContents: { on: () => {} }, - loadURL: () => {}, - on: () => {}, - isDestroyed: () => false, - close: () => {}, - }) as never, - buildSetupFormHtml: (state) => `${state.selectedServerUrl}${state.username}`, - parseSubmissionUrl: () => null, - authenticateWithPassword: async () => ({ - serverUrl: 'https://example.test', - username: 'user', - accessToken: 'token', - userId: 'id', - }), - saveStoredSession: () => {}, - clearStoredSession: () => {}, - patchJellyfinConfig: () => {}, - logInfo: () => {}, - logError: () => {}, - showMpvOsd: () => {}, - clearSetupWindow: () => {}, - setSetupWindow: () => {}, - encodeURIComponent, - defaultServerUrl: 'https://example.test', - hasStoredSession: () => false, - }, - }); - - assert.equal(typeof composed.getResolvedJellyfinConfig, 'function'); - assert.equal(typeof composed.getJellyfinClientInfo, 'function'); - assert.equal(typeof composed.reportJellyfinRemoteProgress, 'function'); - assert.equal(typeof composed.reportJellyfinRemoteStopped, 'function'); - assert.equal(typeof composed.handleJellyfinRemotePlay, 'function'); - assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function'); - assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function'); - assert.equal(typeof composed.playJellyfinItemInMpv, 'function'); - assert.equal(typeof composed.cleanupJellyfinSubtitleCache, 'function'); - assert.equal(typeof composed.startJellyfinRemoteSession, 'function'); - assert.equal(typeof composed.stopJellyfinRemoteSession, 'function'); - assert.equal(typeof composed.runJellyfinCommand, 'function'); - assert.equal(typeof composed.openJellyfinSetupWindow, 'function'); - - // getResolvedJellyfinConfig forwards to the injected getResolvedConfig dep - const jellyfinConfig = composed.getResolvedJellyfinConfig(); - assert.equal(jellyfinConfig.enabled, false); - assert.equal(jellyfinConfig.serverUrl, ''); -}); diff --git a/src/main/runtime/composers/mpv-runtime-composer.test.ts b/src/main/runtime/composers/mpv-runtime-composer.test.ts index ec1b8038..e83683cc 100644 --- a/src/main/runtime/composers/mpv-runtime-composer.test.ts +++ b/src/main/runtime/composers/mpv-runtime-composer.test.ts @@ -151,172 +151,6 @@ function createDefaultMpvFixture() { }; } -test('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => { - const calls: string[] = []; - let started = false; - let metrics = BASE_METRICS; - let mecabTokenizer: { id: string } | null = null; - - class FakeMpvClient { - connected = false; - - constructor( - public socketPath: string, - public options: unknown, - ) { - const autoStartOverlay = (options as { autoStartOverlay: boolean }).autoStartOverlay; - calls.push(`create-client:${socketPath}`); - calls.push(`auto-start:${String(autoStartOverlay)}`); - } - - on(): void {} - - connect(): void { - this.connected = true; - calls.push('client-connect'); - } - } - - const fixture = createDefaultMpvFixture(); - const composed = composeMpvRuntimeHandlers< - FakeMpvClient, - { isKnownWord: (text: string) => boolean }, - { text: string } - >({ - ...fixture, - mpvClientRuntimeServiceFactoryMainDeps: { - ...fixture.mpvClientRuntimeServiceFactoryMainDeps, - createClient: FakeMpvClient, - isAutoStartOverlayEnabled: () => true, - }, - updateMpvSubtitleRenderMetricsMainDeps: { - getCurrentMetrics: () => metrics, - setCurrentMetrics: (next) => { - metrics = next; - calls.push('set-metrics'); - }, - applyPatch: (current, patch) => { - calls.push('apply-metrics-patch'); - return { next: { ...current, ...patch }, changed: true }; - }, - broadcastMetrics: () => { - calls.push('broadcast-metrics'); - }, - }, - tokenizer: { - ...fixture.tokenizer, - buildTokenizerDepsMainDeps: { - ...fixture.tokenizer.buildTokenizerDepsMainDeps, - isKnownWord: (text) => text === 'known', - getJlptEnabled: () => true, - getFrequencyDictionaryEnabled: () => true, - }, - createTokenizerRuntimeDeps: (deps) => { - calls.push('create-tokenizer-runtime-deps'); - return { isKnownWord: (text: string) => deps.isKnownWord(text) }; - }, - tokenizeSubtitle: async (text, deps) => { - calls.push(`tokenize:${text}`); - deps.isKnownWord('known'); - return { text }; - }, - createMecabTokenizerAndCheckMainDeps: { - getMecabTokenizer: () => mecabTokenizer, - setMecabTokenizer: (next) => { - mecabTokenizer = next as { id: string }; - calls.push('set-mecab'); - }, - createMecabTokenizer: () => { - calls.push('create-mecab'); - return { id: 'mecab' }; - }, - checkAvailability: async () => { - calls.push('check-mecab'); - }, - }, - prewarmSubtitleDictionariesMainDeps: { - ensureJlptDictionaryLookup: async () => { - calls.push('prewarm-jlpt'); - }, - ensureFrequencyDictionaryLookup: async () => { - calls.push('prewarm-frequency'); - }, - }, - }, - warmups: { - launchBackgroundWarmupTaskMainDeps: { - now: () => 100, - logDebug: () => { - calls.push('warmup-debug'); - }, - logWarn: () => { - calls.push('warmup-warn'); - }, - }, - startBackgroundWarmupsMainDeps: { - ...fixture.warmups.startBackgroundWarmupsMainDeps, - getStarted: () => started, - setStarted: (next) => { - started = next; - calls.push(`set-started:${String(next)}`); - }, - ensureYomitanExtensionLoaded: async () => { - calls.push('warmup-yomitan'); - }, - shouldWarmupMecab: () => true, - shouldWarmupYomitanExtension: () => true, - shouldWarmupSubtitleDictionaries: () => true, - shouldWarmupJellyfinRemoteSession: () => true, - startJellyfinRemoteSession: async () => { - calls.push('warmup-jellyfin'); - }, - }, - }, - }); - - assert.equal(typeof composed.bindMpvClientEventHandlers, 'function'); - assert.equal(typeof composed.createMpvClientRuntimeService, 'function'); - assert.equal(typeof composed.updateMpvSubtitleRenderMetrics, 'function'); - assert.equal(typeof composed.tokenizeSubtitle, 'function'); - assert.equal(typeof composed.createMecabTokenizerAndCheck, 'function'); - assert.equal(typeof composed.prewarmSubtitleDictionaries, 'function'); - assert.equal(typeof composed.startTokenizationWarmups, 'function'); - assert.equal(typeof composed.isTokenizationWarmupReady, 'function'); - assert.equal(typeof composed.launchBackgroundWarmupTask, 'function'); - assert.equal(typeof composed.startBackgroundWarmups, 'function'); - - const client = composed.createMpvClientRuntimeService(); - assert.equal(client.connected, true); - - composed.updateMpvSubtitleRenderMetrics({ subPos: 90 }); - assert.equal(composed.isTokenizationWarmupReady(), false); - await composed.startTokenizationWarmups(); - assert.equal(composed.isTokenizationWarmupReady(), true); - const tokenized = await composed.tokenizeSubtitle('subtitle text'); - await composed.createMecabTokenizerAndCheck(); - await composed.prewarmSubtitleDictionaries(); - composed.startBackgroundWarmups(); - - assert.deepEqual(tokenized, { text: 'subtitle text' }); - assert.equal(metrics.subPos, 90); - assert.ok(calls.includes('create-client:/tmp/mpv.sock')); - assert.ok(calls.includes('auto-start:true')); - assert.ok(calls.includes('client-connect')); - assert.ok(calls.includes('apply-metrics-patch')); - assert.ok(calls.includes('set-metrics')); - assert.ok(calls.includes('broadcast-metrics')); - assert.ok(calls.includes('create-tokenizer-runtime-deps')); - assert.ok(calls.includes('tokenize:subtitle text')); - assert.ok(calls.includes('create-mecab')); - assert.ok(calls.includes('set-mecab')); - assert.ok(calls.includes('check-mecab')); - assert.ok(calls.includes('prewarm-jlpt')); - assert.ok(calls.includes('prewarm-frequency')); - assert.ok(calls.includes('set-started:true')); - assert.ok(calls.includes('warmup-yomitan')); - assert.ok(calls.indexOf('create-mecab') < calls.indexOf('set-started:true')); -}); - test('composeMpvRuntimeHandlers skips MeCab warmup when all POS-dependent annotations are disabled', async () => { const calls: string[] = []; let mecabTokenizer: { id: string } | null = null; diff --git a/src/main/runtime/composers/overlay-visibility-runtime-composer.test.ts b/src/main/runtime/composers/overlay-visibility-runtime-composer.test.ts deleted file mode 100644 index 59f7e676..00000000 --- a/src/main/runtime/composers/overlay-visibility-runtime-composer.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { composeOverlayVisibilityRuntime } from './overlay-visibility-runtime-composer'; - -test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () => { - const calls: string[] = []; - const composed = composeOverlayVisibilityRuntime({ - overlayVisibilityRuntime: { - updateVisibleOverlayVisibility: () => { - calls.push('updateVisibleOverlayVisibility'); - }, - }, - restorePreviousSecondarySubVisibilityMainDeps: { - getMpvClient: () => null, - }, - broadcastRuntimeOptionsChangedMainDeps: { - broadcastRuntimeOptionsChangedRuntime: () => { - calls.push('broadcastRuntimeOptionsChangedRuntime'); - }, - getRuntimeOptionsState: () => [], - broadcastToOverlayWindows: () => {}, - }, - sendToActiveOverlayWindowMainDeps: { - sendToActiveOverlayWindowRuntime: () => true, - }, - setOverlayDebugVisualizationEnabledMainDeps: { - setOverlayDebugVisualizationEnabledRuntime: () => {}, - getCurrentEnabled: () => false, - setCurrentEnabled: () => {}, - }, - openRuntimeOptionsPaletteMainDeps: { - openRuntimeOptionsPaletteRuntime: () => { - calls.push('openRuntimeOptionsPaletteRuntime'); - }, - }, - }); - - assert.equal(typeof composed.updateVisibleOverlayVisibility, 'function'); - assert.equal(typeof composed.restorePreviousSecondarySubVisibility, 'function'); - assert.equal(typeof composed.broadcastRuntimeOptionsChanged, 'function'); - assert.equal(typeof composed.sendToActiveOverlayWindow, 'function'); - assert.equal(typeof composed.setOverlayDebugVisualizationEnabled, 'function'); - assert.equal(typeof composed.openRuntimeOptionsPalette, 'function'); - - // updateVisibleOverlayVisibility passes through to the injected runtime dep - composed.updateVisibleOverlayVisibility(); - assert.deepEqual(calls, ['updateVisibleOverlayVisibility']); - - // openRuntimeOptionsPalette forwards to the injected runtime dep - composed.openRuntimeOptionsPalette(); - assert.deepEqual(calls, ['updateVisibleOverlayVisibility', 'openRuntimeOptionsPaletteRuntime']); - - // broadcastRuntimeOptionsChanged forwards to the injected runtime dep - composed.broadcastRuntimeOptionsChanged(); - assert.ok(calls.includes('broadcastRuntimeOptionsChangedRuntime')); -}); diff --git a/src/main/runtime/composers/shortcuts-runtime-composer.test.ts b/src/main/runtime/composers/shortcuts-runtime-composer.test.ts deleted file mode 100644 index ad87e36f..00000000 --- a/src/main/runtime/composers/shortcuts-runtime-composer.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { composeShortcutRuntimes } from './shortcuts-runtime-composer'; - -test('composeShortcutRuntimes returns callable shortcut runtime handlers', () => { - const calls: string[] = []; - const composed = composeShortcutRuntimes({ - globalShortcuts: { - getConfiguredShortcutsMainDeps: { - getResolvedConfig: () => ({}) as never, - defaultConfig: {} as never, - resolveConfiguredShortcuts: () => ({}) as never, - }, - buildRegisterGlobalShortcutsMainDeps: () => ({ - getConfiguredShortcuts: () => ({}) as never, - registerGlobalShortcutsCore: () => {}, - toggleVisibleOverlay: () => {}, - openYomitanSettings: () => {}, - isDev: false, - getMainWindow: () => null, - }), - buildRefreshGlobalAndOverlayShortcutsMainDeps: () => ({ - unregisterAllGlobalShortcuts: () => {}, - registerGlobalShortcuts: () => {}, - syncOverlayShortcuts: () => {}, - }), - }, - numericShortcutRuntimeMainDeps: { - globalShortcut: { - register: () => true, - unregister: () => {}, - }, - showMpvOsd: () => {}, - setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs), - clearTimer: (timer) => clearTimeout(timer), - }, - numericSessions: { - onMultiCopyDigit: () => {}, - onMineSentenceDigit: () => {}, - }, - overlayShortcutsRuntimeMainDeps: { - overlayShortcutsRuntime: { - registerOverlayShortcuts: () => { - calls.push('registerOverlayShortcuts'); - }, - unregisterOverlayShortcuts: () => {}, - syncOverlayShortcuts: () => { - calls.push('syncOverlayShortcuts'); - }, - refreshOverlayShortcuts: () => {}, - }, - }, - }); - - assert.equal(typeof composed.getConfiguredShortcuts, 'function'); - assert.equal(typeof composed.registerGlobalShortcuts, 'function'); - assert.equal(typeof composed.refreshGlobalAndOverlayShortcuts, 'function'); - assert.equal(typeof composed.cancelPendingMultiCopy, 'function'); - assert.equal(typeof composed.startPendingMultiCopy, 'function'); - assert.equal(typeof composed.cancelPendingMineSentenceMultiple, 'function'); - assert.equal(typeof composed.startPendingMineSentenceMultiple, 'function'); - assert.equal(typeof composed.registerOverlayShortcuts, 'function'); - assert.equal(typeof composed.unregisterOverlayShortcuts, 'function'); - assert.equal(typeof composed.syncOverlayShortcuts, 'function'); - assert.equal(typeof composed.refreshOverlayShortcuts, 'function'); - - // registerOverlayShortcuts forwards to the injected overlayShortcutsRuntime dep - composed.registerOverlayShortcuts(); - assert.deepEqual(calls, ['registerOverlayShortcuts']); - - // syncOverlayShortcuts forwards to the injected overlayShortcutsRuntime dep - composed.syncOverlayShortcuts(); - assert.deepEqual(calls, ['registerOverlayShortcuts', 'syncOverlayShortcuts']); -}); diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts deleted file mode 100644 index a1c484a4..00000000 --- a/src/main/runtime/composers/startup-lifecycle-composer.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { composeStartupLifecycleHandlers } from './startup-lifecycle-composer'; - -test('composeStartupLifecycleHandlers returns callable startup lifecycle handlers', () => { - const calls: string[] = []; - const composed = composeStartupLifecycleHandlers({ - registerProtocolUrlHandlersMainDeps: { - registerOpenUrl: () => {}, - registerSecondInstance: () => {}, - handleAnilistSetupProtocolUrl: () => false, - findAnilistSetupDeepLinkArgvUrl: () => null, - logUnhandledOpenUrl: () => {}, - logUnhandledSecondInstanceUrl: () => {}, - }, - onWillQuitCleanupMainDeps: { - destroyTray: () => {}, - stopConfigHotReload: () => {}, - restorePreviousSecondarySubVisibility: () => {}, - restoreMpvSubVisibility: () => {}, - isAppReady: () => true, - unregisterAllGlobalShortcuts: () => {}, - stopSubtitleWebsocket: () => {}, - stopTexthookerService: () => {}, - stopSyncAutoScheduler: () => {}, - clearWindowsVisibleOverlayForegroundPollLoop: () => {}, - clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {}, - getMainOverlayWindow: () => null, - clearMainOverlayWindow: () => {}, - getModalOverlayWindow: () => null, - clearModalOverlayWindow: () => {}, - getYomitanParserWindow: () => null, - clearYomitanParserState: () => {}, - getWindowTracker: () => null, - flushMpvLog: () => {}, - getMpvSocket: () => null, - getReconnectTimer: () => null, - clearReconnectTimerRef: () => {}, - getSubtitleTimingTracker: () => null, - getImmersionTracker: () => null, - stopStatsServer: () => {}, - clearImmersionTracker: () => {}, - getAnkiIntegration: () => null, - getAnilistSetupWindow: () => null, - clearAnilistSetupWindow: () => {}, - getJellyfinSetupWindow: () => null, - clearJellyfinSetupWindow: () => {}, - getFirstRunSetupWindow: () => null, - clearFirstRunSetupWindow: () => {}, - getYomitanSettingsWindow: () => null, - clearYomitanSettingsWindow: () => {}, - stopJellyfinRemoteSession: async () => {}, - cleanupInternalSubtitleTrackCache: () => {}, - cleanupYoutubeSubtitleTempDirs: () => {}, - cleanupYoutubeMediaCache: () => {}, - cleanupRemoteMediaWindows: () => {}, - cleanupJellyfinSubtitleCache: () => {}, - stopDiscordPresenceService: () => {}, - }, - shouldRestoreWindowsOnActivateMainDeps: { - isOverlayRuntimeInitialized: () => false, - getAllWindowCount: () => 0, - }, - restoreWindowsOnActivateMainDeps: { - createMainWindow: () => { - calls.push('createMainWindow'); - }, - updateVisibleOverlayVisibility: () => {}, - syncOverlayMpvSubtitleSuppression: () => {}, - }, - }); - - assert.equal(typeof composed.registerProtocolUrlHandlers, 'function'); - assert.equal(typeof composed.onWillQuitCleanup, 'function'); - assert.equal(typeof composed.shouldRestoreWindowsOnActivate, 'function'); - assert.equal(typeof composed.restoreWindowsOnActivate, 'function'); - - // shouldRestoreWindowsOnActivate returns false when overlay runtime is not initialized - assert.equal(composed.shouldRestoreWindowsOnActivate(), false); - - // restoreWindowsOnActivate invokes the injected createMainWindow dep - composed.restoreWindowsOnActivate(); - assert.deepEqual(calls, ['createMainWindow']); -}); diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts index d7d49f7a..9d7bcd0a 100644 --- a/src/main/runtime/config-hot-reload-handlers.test.ts +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -3,7 +3,6 @@ import assert from 'node:assert/strict'; import { DEFAULT_CONFIG, deepCloneConfig } from '../../config'; import { buildConfigHotReloadPayload, - buildRestartRequiredConfigMessage, createConfigHotReloadAppliedHandler, createConfigHotReloadMessageHandler, } from './config-hot-reload-handlers'; @@ -329,10 +328,3 @@ test('createConfigHotReloadMessageHandler routes message through configured noti 'notify:SubMiner:Config reload failed', ]); }); - -test('buildRestartRequiredConfigMessage formats changed fields', () => { - assert.equal( - buildRestartRequiredConfigMessage(['websocket', 'subtitleStyle']), - 'Config updated; restart required for: websocket, subtitleStyle', - ); -}); diff --git a/src/main/runtime/config-hot-reload-main-deps.test.ts b/src/main/runtime/config-hot-reload-main-deps.test.ts index c5a83271..39512a79 100644 --- a/src/main/runtime/config-hot-reload-main-deps.test.ts +++ b/src/main/runtime/config-hot-reload-main-deps.test.ts @@ -1,14 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { ReloadConfigStrictResult } from '../../config'; -import type { ResolvedConfig } from '../../types'; -import { - createBuildConfigHotReloadMessageMainDepsHandler, - createBuildConfigHotReloadAppliedMainDepsHandler, - createBuildConfigHotReloadRuntimeMainDepsHandler, - createBuildWatchConfigPathMainDepsHandler, - createWatchConfigPathHandler, -} from './config-hot-reload-main-deps'; +import { createWatchConfigPathHandler } from './config-hot-reload-main-deps'; test('watch config path handler watches file directly when config exists', () => { const calls: string[] = []; @@ -44,119 +36,3 @@ test('watch config path handler filters directory events to config files only', watchConfigPath('/tmp/config.jsonc', () => calls.push('change')); assert.deepEqual(calls, ['change', 'change', 'change']); }); - -test('watch config path main deps builder maps filesystem callbacks', () => { - const calls: string[] = []; - const deps = createBuildWatchConfigPathMainDepsHandler({ - fileExists: () => true, - dirname: (targetPath) => { - calls.push(`dirname:${targetPath}`); - return '/tmp'; - }, - watchPath: (targetPath, listener) => { - calls.push(`watch:${targetPath}`); - listener('change', 'config.jsonc'); - return { close: () => calls.push('close') }; - }, - })(); - - assert.equal(deps.fileExists('/tmp/config.jsonc'), true); - assert.equal(deps.dirname('/tmp/config.jsonc'), '/tmp'); - const watcher = deps.watchPath('/tmp/config.jsonc', () => calls.push('listener')); - watcher.close(); - assert.deepEqual(calls, [ - 'dirname:/tmp/config.jsonc', - 'watch:/tmp/config.jsonc', - 'listener', - 'close', - ]); -}); - -test('config hot reload message main deps builder maps notifications', () => { - const calls: string[] = []; - const deps = createBuildConfigHotReloadMessageMainDepsHandler({ - showMpvOsd: (message) => calls.push(`osd:${message}`), - showDesktopNotification: (title) => calls.push(`notify:${title}`), - })(); - - deps.showMpvOsd('updated'); - deps.showDesktopNotification('SubMiner', { body: 'updated' }); - assert.deepEqual(calls, ['osd:updated', 'notify:SubMiner']); -}); - -test('config hot reload applied main deps builder maps callbacks', () => { - const calls: string[] = []; - const warningCounts: number[] = []; - const buildDeps = createBuildConfigHotReloadAppliedMainDepsHandler({ - setKeybindings: () => calls.push('keybindings'), - setSessionBindings: (_sessionBindings, warnings) => { - calls.push('session-bindings'); - warningCounts.push(warnings.length); - }, - refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'), - setSecondarySubMode: () => calls.push('set-secondary'), - broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), - applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'), - }); - - const deps = buildDeps(); - deps.setKeybindings([]); - deps.setSessionBindings([], []); - deps.refreshGlobalAndOverlayShortcuts(); - deps.setSecondarySubMode('hover'); - deps.broadcastToOverlayWindows('config:hot-reload', {}); - deps.applyAnkiRuntimeConfigPatch({ ai: true }); - assert.deepEqual(calls, [ - 'keybindings', - 'session-bindings', - 'refresh-shortcuts', - 'set-secondary', - 'broadcast:config:hot-reload', - 'apply-anki', - ]); - assert.deepEqual(warningCounts, [0]); -}); - -test('config hot reload runtime main deps builder maps runtime callbacks', () => { - const calls: string[] = []; - const deps = createBuildConfigHotReloadRuntimeMainDepsHandler({ - getCurrentConfig: () => ({ id: 1 }) as never as ResolvedConfig, - reloadConfigStrict: () => - ({ - ok: true, - config: { id: 1 } as never as ResolvedConfig, - warnings: [], - path: '/tmp/config.jsonc', - }) as ReloadConfigStrictResult, - watchConfigPath: (_configPath, _onChange) => ({ close: () => calls.push('close') }), - setTimeout: (callback) => { - callback(); - return 1 as never; - }, - clearTimeout: () => calls.push('clear-timeout'), - debounceMs: 250, - onHotReloadApplied: () => calls.push('hot-reload'), - onRestartRequired: () => calls.push('restart-required'), - onInvalidConfig: () => calls.push('invalid-config'), - onValidationWarnings: () => calls.push('validation-warnings'), - })(); - - assert.deepEqual(deps.getCurrentConfig(), { id: 1 }); - assert.deepEqual(deps.reloadConfigStrict(), { - ok: true, - config: { id: 1 }, - warnings: [], - path: '/tmp/config.jsonc', - }); - assert.equal(deps.debounceMs, 250); - deps.onHotReloadApplied({} as never, {} as never); - deps.onRestartRequired([]); - deps.onInvalidConfig('bad'); - deps.onValidationWarnings('/tmp/config.jsonc', []); - assert.deepEqual(calls, [ - 'hot-reload', - 'restart-required', - 'invalid-config', - 'validation-warnings', - ]); -}); diff --git a/src/main/runtime/configured-status-notification.test.ts b/src/main/runtime/configured-status-notification.test.ts index b9902f1a..96ac989c 100644 --- a/src/main/runtime/configured-status-notification.test.ts +++ b/src/main/runtime/configured-status-notification.test.ts @@ -6,7 +6,6 @@ import { getYoutubeFlowStatusNotificationOptions, notifyConfiguredStatus, } from './configured-status-notification'; -import { createOverlayNotificationDelivery } from './overlay-notification-delivery'; test('notifyConfiguredStatus routes both to overlay and system without osd', () => { const calls: string[] = []; @@ -30,30 +29,11 @@ test('notifyConfiguredStatus routes both to overlay and system without osd', () ]); }); -test('notifyConfiguredStatus queues overlay for pre-overlay both status and preserves desktop', () => { - const calls: string[] = []; - - notifyConfiguredStatus('Overlay loading...', { - getNotificationType: () => 'both', - isOverlayReady: () => false, - showOsd: (message) => { - calls.push(`osd:${message}`); - }, - showOverlayNotification: (payload) => - calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`), - showDesktopNotification: (title, options) => - calls.push(`desktop:${title}:${options.body ?? ''}`), - }); - - assert.deepEqual(calls, ['overlay::Overlay loading...', 'desktop:SubMiner:Overlay loading...']); -}); - -test('notifyConfiguredStatus queues overlay for pre-overlay overlay-only status', () => { +test('notifyConfiguredStatus routes overlay-only status to the overlay', () => { const calls: string[] = []; notifyConfiguredStatus('Overlay loading...', { getNotificationType: () => 'overlay', - isOverlayReady: () => false, showOsd: (message) => { calls.push(`osd:${message}`); }, @@ -66,12 +46,11 @@ test('notifyConfiguredStatus queues overlay for pre-overlay overlay-only status' assert.deepEqual(calls, ['overlay::Overlay loading...']); }); -test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => { +test('notifyConfiguredStatus routes system status to desktop only', () => { const calls: string[] = []; notifyConfiguredStatus('Overlay loading...', { getNotificationType: () => 'system', - isOverlayReady: () => false, showOsd: (message) => { calls.push(`osd:${message}`); }, @@ -330,231 +309,3 @@ test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', ( assert.deepEqual(calls, ['desktop:SubMiner:Overlay unavailable.']); }); - -test('overlay notification delivery queues until an overlay window is ready', () => { - const sent: string[] = []; - let ready = false; - const delivery = createOverlayNotificationDelivery({ - hasReadyOverlayWindow: () => ready, - send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`), - }); - - delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' }); - delivery.send({ id: 'character-dictionary-auto-sync', title: 'Dictionary', body: 'Building' }); - - assert.equal(delivery.getQueuedCount(), 2); - assert.deepEqual(sent, []); - - ready = true; - delivery.flush(); - - assert.equal(delivery.getQueuedCount(), 0); - assert.deepEqual(sent, [ - 'startup-tokenization:Loading', - 'character-dictionary-auto-sync:Building', - ]); -}); - -test('overlay notification delivery upserts queued progress by notification id', () => { - const sent: string[] = []; - let ready = false; - const delivery = createOverlayNotificationDelivery({ - hasReadyOverlayWindow: () => ready, - send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`), - }); - - delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '|' }); - delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '/' }); - delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Ready' }); - - ready = true; - delivery.flush(); - - assert.deepEqual(sent, ['startup-subtitle-annotations:/', 'startup-tokenization:Ready']); -}); - -test('overlay notification delivery preserves queued events with distinct history ids', () => { - const sent: string[] = []; - let ready = false; - const delivery = createOverlayNotificationDelivery({ - hasReadyOverlayWindow: () => ready, - send: (payload) => - sent.push( - `${payload.id ?? ''}:${'historyId' in payload ? payload.historyId : ''}:${'body' in payload ? payload.body : ''}`, - ), - }); - - delivery.send({ - id: 'character-dictionary-auto-sync', - historyId: 'character-dictionary-auto-sync-checking', - title: 'Character dictionary', - body: 'Checking character dictionary...', - persistent: true, - }); - delivery.send({ - id: 'character-dictionary-auto-sync', - historyId: 'character-dictionary-auto-sync-building', - title: 'Character dictionary', - body: 'Building character dictionary...', - persistent: true, - }); - - ready = true; - delivery.flush(); - - assert.deepEqual(sent, [ - 'character-dictionary-auto-sync:character-dictionary-auto-sync-checking:Checking character dictionary...', - 'character-dictionary-auto-sync:character-dictionary-auto-sync-building:Building character dictionary...', - ]); -}); - -test('overlay notification delivery preserves queued startup progress before terminal update', () => { - const sent: string[] = []; - const scheduled: Array<() => void> = []; - let ready = false; - const delivery = createOverlayNotificationDelivery({ - hasReadyOverlayWindow: () => ready, - send: (payload) => - sent.push( - `${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`, - ), - scheduleFlushRetry: (callback) => { - scheduled.push(callback); - }, - }); - - delivery.send({ - id: 'startup-tokenization', - title: 'Subtitle tokenization', - body: 'Loading subtitle tokenization...', - variant: 'progress', - persistent: true, - }); - delivery.send({ - id: 'startup-tokenization', - title: 'Subtitle tokenization', - body: 'Subtitle tokenization ready', - variant: 'success', - persistent: false, - }); - - ready = true; - delivery.flush(); - scheduled.shift()?.(); - - assert.deepEqual(sent, [ - 'startup-tokenization:Loading subtitle tokenization...:pin', - 'startup-tokenization:Subtitle tokenization ready:auto', - ]); -}); - -test('overlay notification delivery defers terminal update after first queued progress paint', () => { - const sent: string[] = []; - const scheduled: Array<() => void> = []; - const delays: number[] = []; - let ready = false; - const delivery = createOverlayNotificationDelivery({ - hasReadyOverlayWindow: () => ready, - send: (payload) => - sent.push( - `${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`, - ), - scheduleFlushRetry: (callback, delayMs) => { - scheduled.push(callback); - delays.push(delayMs); - }, - terminalUpdateDelayMs: 750, - }); - - delivery.send({ - id: 'startup-subtitle-annotations', - title: 'Subtitle annotations', - body: 'Loading subtitle annotations |', - variant: 'progress', - persistent: true, - }); - delivery.send({ - id: 'startup-subtitle-annotations', - title: 'Subtitle annotations', - body: 'Subtitle annotations loaded', - variant: 'success', - persistent: false, - }); - - ready = true; - delivery.flush(); - - assert.deepEqual(sent, ['startup-subtitle-annotations:Loading subtitle annotations |:pin']); - assert.equal(delivery.getQueuedCount(), 1); - assert.deepEqual(delays, [750]); - - scheduled.shift()?.(); - - assert.equal(delivery.getQueuedCount(), 0); - assert.deepEqual(sent, [ - 'startup-subtitle-annotations:Loading subtitle annotations |:pin', - 'startup-subtitle-annotations:Subtitle annotations loaded:auto', - ]); -}); - -test('overlay notification delivery retries flush when lifecycle fires before window readiness settles', () => { - const sent: string[] = []; - const scheduled: Array<() => void> = []; - let ready = false; - const delivery = createOverlayNotificationDelivery({ - hasReadyOverlayWindow: () => ready, - send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`), - scheduleFlushRetry: (callback) => { - scheduled.push(callback); - }, - }); - - delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' }); - delivery.flush(); - - assert.equal(delivery.getQueuedCount(), 1); - assert.equal(scheduled.length, 1); - assert.deepEqual(sent, []); - - ready = true; - scheduled.shift()?.(); - - assert.equal(delivery.getQueuedCount(), 0); - assert.deepEqual(sent, ['startup-tokenization:Loading']); -}); - -test('overlay notification delivery drops queued notification when dismissed before flush', () => { - const sent: string[] = []; - let ready = false; - const delivery = createOverlayNotificationDelivery({ - hasReadyOverlayWindow: () => ready, - send: (payload) => - sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`), - }); - - delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' }); - delivery.send({ id: 'overlay-loading-status', dismiss: true }); - - ready = true; - delivery.flush(); - - assert.deepEqual(sent, []); -}); - -test('overlay notification delivery removes queued notification when dismissed at readiness', () => { - const sent: string[] = []; - let ready = false; - const delivery = createOverlayNotificationDelivery({ - hasReadyOverlayWindow: () => ready, - send: (payload) => - sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`), - }); - - delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' }); - - ready = true; - delivery.send({ id: 'overlay-loading-status', dismiss: true }); - delivery.flush(); - - assert.deepEqual(sent, ['dismiss:overlay-loading-status']); -}); diff --git a/src/main/runtime/dictionary-runtime-main-deps.test.ts b/src/main/runtime/dictionary-runtime-main-deps.test.ts index 73e43db6..e41f01b5 100644 --- a/src/main/runtime/dictionary-runtime-main-deps.test.ts +++ b/src/main/runtime/dictionary-runtime-main-deps.test.ts @@ -3,8 +3,6 @@ import test from 'node:test'; import { createBuildDictionaryRootsMainHandler, createBuildFrequencyDictionaryRootsMainHandler, - createBuildFrequencyDictionaryRuntimeMainDepsHandler, - createBuildJlptDictionaryRuntimeMainDepsHandler, } from './dictionary-runtime-main-deps'; test('dictionary roots main handler returns expected root list', () => { @@ -25,24 +23,6 @@ test('dictionary roots main handler returns expected root list', () => { assert.equal(roots[10], '/repo'); }); -test('jlpt dictionary runtime main deps builder maps search paths and log prefix', () => { - const calls: string[] = []; - const deps = createBuildJlptDictionaryRuntimeMainDepsHandler({ - isJlptEnabled: () => true, - getDictionaryRoots: () => ['/root/a'], - getJlptDictionarySearchPaths: ({ getDictionaryRoots }) => - getDictionaryRoots().map((path) => `${path}/jlpt`), - setJlptLevelLookup: () => calls.push('set-lookup'), - logInfo: (message) => calls.push(`log:${message}`), - })(); - - assert.equal(deps.isJlptEnabled(), true); - assert.deepEqual(deps.getSearchPaths(), ['/root/a/jlpt']); - deps.setJlptLevelLookup(() => null); - deps.log('loaded'); - assert.deepEqual(calls, ['set-lookup', 'log:[JLPT] loaded']); -}); - test('frequency dictionary roots main handler returns expected root list', () => { const roots = createBuildFrequencyDictionaryRootsMainHandler({ platform: 'darwin', @@ -96,24 +76,3 @@ test('frequency dictionary roots main handler uses APPDATA-style roots on window assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false); assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true); }); - -test('frequency dictionary runtime main deps builder maps search paths/source and log prefix', () => { - const calls: string[] = []; - const deps = createBuildFrequencyDictionaryRuntimeMainDepsHandler({ - isFrequencyDictionaryEnabled: () => true, - getDictionaryRoots: () => ['/root/a', ''], - getFrequencyDictionarySearchPaths: ({ getDictionaryRoots, getSourcePath }) => [ - ...getDictionaryRoots().map((path) => `${path}/freq`), - getSourcePath() || '', - ], - getSourcePath: () => '/custom/freq.json', - setFrequencyRankLookup: () => calls.push('set-rank'), - logInfo: (message) => calls.push(`log:${message}`), - })(); - - assert.equal(deps.isFrequencyDictionaryEnabled(), true); - assert.deepEqual(deps.getSearchPaths(), ['/root/a/freq', '/custom/freq.json']); - deps.setFrequencyRankLookup(() => null); - deps.log('loaded'); - assert.deepEqual(calls, ['set-rank', 'log:[Frequency] loaded']); -}); diff --git a/src/main/runtime/discord-rpc-client.test.ts b/src/main/runtime/discord-rpc-client.test.ts deleted file mode 100644 index 2c185bb1..00000000 --- a/src/main/runtime/discord-rpc-client.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { createDiscordRpcClient } from './discord-rpc-client'; - -test('createDiscordRpcClient forwards rich presence calls through client.user', async () => { - const calls: Array = []; - const rpcClient = createDiscordRpcClient('123456789012345678', { - createClient: () => - ({ - login: async () => { - calls.push('login'); - }, - user: { - setActivity: async () => { - calls.push('setActivity'); - }, - clearActivity: async () => { - calls.push('clearActivity'); - }, - }, - destroy: async () => { - calls.push('destroy'); - }, - }) as never, - }); - - await rpcClient.login(); - await rpcClient.setActivity({ - details: 'Title', - state: 'Playing 00:01 / 00:02', - startTimestamp: 1_700_000_000, - }); - await rpcClient.clearActivity(); - await rpcClient.destroy(); - - assert.deepEqual(calls, ['login', 'setActivity', 'clearActivity', 'destroy']); -}); diff --git a/src/main/runtime/field-grouping-overlay-main-deps.test.ts b/src/main/runtime/field-grouping-overlay-main-deps.test.ts deleted file mode 100644 index c3ff06b6..00000000 --- a/src/main/runtime/field-grouping-overlay-main-deps.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { createBuildFieldGroupingOverlayMainDepsHandler } from './field-grouping-overlay-main-deps'; - -test('field grouping overlay main deps builder maps window visibility and resolver wiring', () => { - const calls: string[] = []; - const modalSet = new Set<'runtime-options'>(); - const resolver = (choice: unknown) => calls.push(`resolver:${choice}`); - - const deps = createBuildFieldGroupingOverlayMainDepsHandler({ - getMainWindow: () => ({ - isDestroyed: () => false, - webContents: { - send: () => {}, - }, - }), - getVisibleOverlayVisible: () => true, - setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`), - getResolver: () => resolver, - setResolver: (nextResolver) => { - calls.push(`set-resolver:${nextResolver ? 'set' : 'null'}`); - }, - getRestoreVisibleOverlayOnModalClose: () => modalSet, - sendToActiveOverlayWindow: (channel, payload) => { - calls.push(`send:${channel}:${String(payload)}`); - return true; - }, - })(); - - assert.equal(deps.getMainWindow()?.isDestroyed(), false); - assert.equal(deps.getVisibleOverlayVisible(), true); - assert.equal(deps.getResolver(), resolver); - assert.equal(deps.getRestoreVisibleOverlayOnModalClose(), modalSet); - deps.setVisibleOverlayVisible(true); - deps.setResolver(null); - assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true); - assert.deepEqual(calls, ['visible:true', 'set-resolver:null', 'send:kiku:open:1']); -}); - -test('field grouping overlay main deps builder forwards modal open/teardown/prereq wiring', () => { - // Regression: these are optional on the runtime options, so a missing forward compiled - // silently and left the field grouping modal with no ack/retry, no teardown, and no prereqs — - // the reason the earlier recovery fixes never took effect. - const calls: string[] = []; - const waitForModalOpen = async (modal: 'kiku', timeoutMs: number): Promise => { - calls.push(`wait:${modal}:${timeoutMs}`); - return true; - }; - const handleOverlayModalClosed = (modal: 'kiku'): void => { - calls.push(`closed:${modal}`); - }; - const logWarn = (message: string): void => { - calls.push(`warn:${message}`); - }; - const ensureOverlayStartupPrereqs = (): void => { - calls.push('prereqs'); - }; - const ensureOverlayWindowsReadyForVisibilityActions = (): void => { - calls.push('windows-ready'); - }; - - const deps = createBuildFieldGroupingOverlayMainDepsHandler<'kiku'>({ - getMainWindow: () => null, - getVisibleOverlayVisible: () => false, - setVisibleOverlayVisible: () => {}, - getResolver: () => null, - setResolver: () => {}, - getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(), - waitForModalOpen, - handleOverlayModalClosed, - logWarn, - ensureOverlayStartupPrereqs, - ensureOverlayWindowsReadyForVisibilityActions, - sendToActiveOverlayWindow: () => true, - })(); - - assert.equal(deps.waitForModalOpen, waitForModalOpen); - assert.equal(deps.handleOverlayModalClosed, handleOverlayModalClosed); - assert.equal(deps.logWarn, logWarn); - assert.equal(deps.ensureOverlayStartupPrereqs, ensureOverlayStartupPrereqs); - assert.equal( - deps.ensureOverlayWindowsReadyForVisibilityActions, - ensureOverlayWindowsReadyForVisibilityActions, - ); -}); diff --git a/src/main/runtime/field-grouping-resolver-main-deps.test.ts b/src/main/runtime/field-grouping-resolver-main-deps.test.ts deleted file mode 100644 index cdfd3348..00000000 --- a/src/main/runtime/field-grouping-resolver-main-deps.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - createBuildGetFieldGroupingResolverMainDepsHandler, - createBuildSetFieldGroupingResolverMainDepsHandler, -} from './field-grouping-resolver-main-deps'; - -test('get field grouping resolver main deps builder maps callbacks', () => { - const resolver = () => undefined; - const deps = createBuildGetFieldGroupingResolverMainDepsHandler({ - getResolver: () => resolver, - })(); - assert.equal(deps.getResolver(), resolver); -}); - -test('set field grouping resolver main deps builder maps callbacks', () => { - const calls: string[] = []; - const wrapped = (choice: unknown) => calls.push(String(choice)); - const deps = createBuildSetFieldGroupingResolverMainDepsHandler({ - setResolver: (resolver) => { - if (resolver) { - resolver('x' as never); - } - }, - nextSequence: () => 2, - getSequence: () => 2, - })(); - - assert.equal(deps.nextSequence(), 2); - assert.equal(deps.getSequence(), 2); - deps.setResolver(wrapped as never); - assert.deepEqual(calls, ['x']); -}); diff --git a/src/main/runtime/field-grouping-resolver.test.ts b/src/main/runtime/field-grouping-resolver.test.ts index 9bdca07e..06cd87c6 100644 --- a/src/main/runtime/field-grouping-resolver.test.ts +++ b/src/main/runtime/field-grouping-resolver.test.ts @@ -1,18 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { - createGetFieldGroupingResolverHandler, - createSetFieldGroupingResolverHandler, -} from './field-grouping-resolver'; - -test('get field grouping resolver returns current resolver', () => { - const resolver = () => undefined; - const getResolver = createGetFieldGroupingResolverHandler({ - getResolver: () => resolver, - }); - - assert.equal(getResolver(), resolver); -}); +import { createSetFieldGroupingResolverHandler } from './field-grouping-resolver'; test('set field grouping resolver clears resolver when null is provided', () => { let current: ((choice: unknown) => void) | null = () => undefined; diff --git a/src/main/runtime/first-run-setup-window.test.ts b/src/main/runtime/first-run-setup-window.test.ts index eb2de879..28950411 100644 --- a/src/main/runtime/first-run-setup-window.test.ts +++ b/src/main/runtime/first-run-setup-window.test.ts @@ -3,7 +3,6 @@ import assert from 'node:assert/strict'; import { buildFirstRunSetupHtml, createHandleFirstRunSetupNavigationHandler, - createMaybeFocusExistingFirstRunSetupWindowHandler, createOpenFirstRunSetupWindowHandler, parseFirstRunSetupSubmissionUrl, } from './first-run-setup-window'; @@ -34,78 +33,6 @@ function createCommandLineLauncherSnapshot( }; } -test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish state', () => { - const html = buildFirstRunSetupHtml({ - configReady: true, - dictionaryCount: 0, - canFinish: false, - externalYomitanConfigured: false, - pluginStatus: 'required', - pluginInstallPathSummary: null, - mpvExecutablePath: '', - mpvExecutablePathStatus: 'blank', - windowsMpvShortcuts: { - supported: false, - startMenuEnabled: true, - desktopEnabled: true, - startMenuInstalled: false, - desktopInstalled: false, - status: 'optional', - }, - commandLineLauncher: createCommandLineLauncherSnapshot(), - message: 'Waiting for dictionaries', - }); - - assert.match(html, /SubMiner setup/); - assert.doesNotMatch(html, /Install legacy mpv plugin/); - assert.doesNotMatch(html, /action=install-plugin/); - assert.doesNotMatch(html, /mpv runtime plugin/); - assert.doesNotMatch(html, /Bundled ready/); - assert.doesNotMatch(html, /Managed mpv launches use the bundled runtime plugin\./); - assert.match(html, /Open Yomitan Settings/); - assert.match(html, /Open SubMiner Settings/); - assert.match( - html, - /action=open-yomitan-settings'">Open Yomitan Settings<\/button>\s*