import test from "node:test"; import assert from "node:assert/strict"; import { AnkiJimakuIpcRuntimeOptions, registerAnkiJimakuIpcRuntimeService, } from "./anki-jimaku-service"; interface RuntimeHarness { options: AnkiJimakuIpcRuntimeOptions; registered: Record unknown>; state: { ankiIntegration: unknown; fieldGroupingResolver: ((choice: unknown) => void) | null; patches: boolean[]; broadcasts: number; fetchCalls: Array<{ endpoint: string; query?: Record }>; sentCommands: Array<{ command: string[] }>; }; } function createHarness(): RuntimeHarness { const state = { ankiIntegration: null as unknown, fieldGroupingResolver: null as ((choice: unknown) => void) | null, patches: [] as boolean[], broadcasts: 0, fetchCalls: [] as Array<{ endpoint: string; query?: Record }>, sentCommands: [] as Array<{ command: string[] }>, }; const options: AnkiJimakuIpcRuntimeOptions = { patchAnkiConnectEnabled: (enabled) => { state.patches.push(enabled); }, getResolvedConfig: () => ({}), getRuntimeOptionsManager: () => null, getSubtitleTimingTracker: () => null, getMpvClient: () => ({ connected: true, send: (payload) => { state.sentCommands.push(payload); }, }), getAnkiIntegration: () => state.ankiIntegration as never, setAnkiIntegration: (integration) => { state.ankiIntegration = integration; }, getKnownWordCacheStatePath: () => "/tmp/subminer-known-words-cache.json", showDesktopNotification: () => {}, createFieldGroupingCallback: () => async () => ({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: false, cancelled: false, }), broadcastRuntimeOptionsChanged: () => { state.broadcasts += 1; }, getFieldGroupingResolver: () => state.fieldGroupingResolver as never, setFieldGroupingResolver: (resolver) => { state.fieldGroupingResolver = resolver as never; }, parseMediaInfo: () => ({ title: "video", confidence: "high", rawTitle: "video", filename: "video.mkv", season: null, episode: null, }), getCurrentMediaPath: () => "/tmp/video.mkv", jimakuFetchJson: async (endpoint, query) => { state.fetchCalls.push({ endpoint, query: query as Record }); return { ok: true, data: [ { id: 1, name: "a" }, { id: 2, name: "b" }, { id: 3, name: "c" }, ] as never, }; }, getJimakuMaxEntryResults: () => 2, getJimakuLanguagePreference: () => "ja", resolveJimakuApiKey: async () => "token", isRemoteMediaPath: () => false, downloadToFile: async (url, destPath) => ({ ok: true, path: `${destPath}:${url}`, }), }; let registered: Record unknown> = {}; registerAnkiJimakuIpcRuntimeService( options, (deps) => { registered = deps as unknown as Record unknown>; }, ); return { options, registered, state }; } test("registerAnkiJimakuIpcRuntimeService provides full handler surface", () => { const { registered } = createHarness(); const expected = [ "setAnkiConnectEnabled", "clearAnkiHistory", "refreshKnownWords", "respondFieldGrouping", "buildKikuMergePreview", "getJimakuMediaInfo", "searchJimakuEntries", "listJimakuFiles", "resolveJimakuApiKey", "getCurrentMediaPath", "isRemoteMediaPath", "downloadToFile", "onDownloadedSubtitle", ]; for (const key of expected) { assert.equal(typeof registered[key], "function", `missing handler: ${key}`); } }); test("refreshKnownWords throws when integration is unavailable", async () => { const { registered } = createHarness(); await assert.rejects( async () => { await registered.refreshKnownWords(); }, { message: "AnkiConnect integration not enabled" }, ); }); 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; state.ankiIntegration = { destroy: () => { destroyed += 1; }, }; registered.setAnkiConnectEnabled(false); assert.deepEqual(state.patches, [false]); assert.equal(destroyed, 1); assert.equal(state.ankiIntegration, null); assert.equal(state.broadcasts, 1); }); test("clearAnkiHistory and respondFieldGrouping execute runtime callbacks", () => { const { registered, state, options } = createHarness(); let cleaned = 0; let resolvedChoice: unknown = null; state.fieldGroupingResolver = (choice) => { resolvedChoice = choice; }; const originalGetTracker = options.getSubtitleTimingTracker; options.getSubtitleTimingTracker = () => ({ cleanup: () => { cleaned += 1; } }) as never; const choice = { keepNoteId: 10, deleteNoteId: 11, deleteDuplicate: true, cancelled: false, }; registered.clearAnkiHistory(); registered.respondFieldGrouping(choice); options.getSubtitleTimingTracker = originalGetTracker; assert.equal(cleaned, 1); assert.deepEqual(resolvedChoice, choice); assert.equal(state.fieldGroupingResolver, null); }); test("buildKikuMergePreview returns guard error when integration is missing", async () => { const { registered } = createHarness(); const result = await registered.buildKikuMergePreview({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: false, }); assert.deepEqual(result, { ok: false, error: "AnkiConnect integration not enabled", }); }); test("buildKikuMergePreview delegates to integration when available", async () => { const { registered, state } = createHarness(); const calls: unknown[] = []; state.ankiIntegration = { buildFieldGroupingPreview: async ( keepNoteId: number, deleteNoteId: number, deleteDuplicate: boolean, ) => { calls.push([keepNoteId, deleteNoteId, deleteDuplicate]); return { ok: true }; }, }; const result = await registered.buildKikuMergePreview({ keepNoteId: 3, deleteNoteId: 4, deleteDuplicate: true, }); assert.deepEqual(calls, [[3, 4, true]]); assert.deepEqual(result, { ok: true }); }); test("searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv", async () => { const { registered, state } = createHarness(); const searchResult = await registered.searchJimakuEntries({ query: "test" }); assert.deepEqual(state.fetchCalls, [ { endpoint: "/api/entries/search", query: { anime: true, query: "test" }, }, ]); assert.equal((searchResult as { ok: boolean }).ok, true); assert.equal((searchResult as { data: unknown[] }).data.length, 2); registered.onDownloadedSubtitle("/tmp/subtitle.ass"); assert.deepEqual(state.sentCommands, [ { command: ["sub-add", "/tmp/subtitle.ass", "select"] }, ]); });