import test from 'node:test'; 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; const visibilityTransitions: boolean[] = []; const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({ getMainWindow: () => null, getVisibleOverlayVisible: () => visible, setVisibleOverlayVisible: (nextVisible) => { visible = nextVisible; visibilityTransitions.push(nextVisible); }, getResolver: () => resolver as ((choice: KikuFieldGroupingChoice) => void) | null, setResolver: (nextResolver: ((choice: KikuFieldGroupingChoice) => void) | null) => { resolver = nextResolver; }, getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(), sendToVisibleOverlay: () => true, }); const callback = runtime.createFieldGroupingCallback(); const pendingChoice = 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(visible, true); assert.ok(resolver); if (typeof resolver !== 'function') { throw new Error('expected field grouping resolver to be assigned'); } (resolver as (choice: KikuFieldGroupingChoice) => void)({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false, }); await pendingChoice; assert.equal(visible, false); assert.deepEqual(visibilityTransitions, [true, false]); }); async function settleWithinMicrotasks( promise: Promise, attempts = 10, ): Promise { let settled = false; let settledValue: T | undefined; void promise.then((value) => { settled = true; settledValue = value; }); for (let i = 0; i < attempts; i += 1) { await Promise.resolve(); if (settled) { return settledValue as T; } } return 'timeout'; } test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku modal never acknowledges open', async () => { let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null; const sends: Array<{ channel: string; payload: unknown; restoreOnModalClose?: string; preferModalWindow?: boolean; }> = []; const waitCalls: Array<{ modal: string; timeoutMs: number }> = []; const warnings: string[] = []; const closed: string[] = []; const originalSetTimeout = globalThis.setTimeout; globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout; try { const runtime = createFieldGroupingOverlayRuntime<'kiku'>({ getMainWindow: () => null, getVisibleOverlayVisible: () => true, setVisibleOverlayVisible: () => {}, getResolver: () => resolver, setResolver: (nextResolver) => { resolver = nextResolver; }, getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(), sendToVisibleOverlay: (channel, payload, runtimeOptions) => { sends.push({ channel, payload, restoreOnModalClose: runtimeOptions?.restoreOnModalClose, preferModalWindow: runtimeOptions?.preferModalWindow, }); return true; }, waitForModalOpen: async (modal, timeoutMs) => { waitCalls.push({ modal, timeoutMs }); return false; }, handleOverlayModalClosed: (modal) => { closed.push(modal); }, logWarn: (message) => { warnings.push(message); }, }); const request = { 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, }, }; const pendingChoice = runtime.createFieldGroupingCallback()(request); const result = await settleWithinMicrotasks(pendingChoice); assert.notEqual(result, 'timeout'); assert.deepEqual(result, { keepNoteId: 0, deleteNoteId: 0, deleteDuplicate: true, cancelled: true, }); assert.equal(resolver, null); assert.deepEqual( sends.map(({ channel, restoreOnModalClose, preferModalWindow }) => ({ channel, restoreOnModalClose, preferModalWindow, })), [ { channel: 'kiku:field-grouping-request', restoreOnModalClose: 'kiku', preferModalWindow: true, }, { channel: 'kiku:field-grouping-request', restoreOnModalClose: 'kiku', preferModalWindow: true, }, // Abandonment also asks the renderer hosting the modal to close its dialog. { channel: 'kiku:field-grouping-cancel', restoreOnModalClose: undefined, preferModalWindow: true, }, ], ); assert.deepEqual(waitCalls, [ { modal: 'kiku', timeoutMs: 1500 }, { modal: 'kiku', timeoutMs: 1500 }, ]); assert.deepEqual(warnings, [ 'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', ]); // Once from the send-failure path inside sendKikuFieldGroupingRequest, once from the // callback's abandonment cleanup. The real runtime guards this via the restore set, so // the duplicate is a harmless no-op. assert.deepEqual(closed, ['kiku', 'kiku']); } finally { globalThis.setTimeout = originalSetTimeout; } }); test('createFieldGroupingOverlayRuntime prepares overlay windows before opening the modal', async () => { // The field grouping modal must run the same prerequisites as every other modal // (openOverlayHostedModal) so it opens with the overlay runtime ready and the visible overlay // window present — otherwise on Hyprland it fails to sit above / focus over fullscreen mpv. const order: string[] = []; const originalSetTimeout = globalThis.setTimeout; // The modal acknowledges open below, so the callback stays pending and arms its response // timeout; stub the timer so no real 90s handle leaks into the test runner. globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout; try { const runtime = createFieldGroupingOverlayRuntime<'kiku'>({ getMainWindow: () => null, getVisibleOverlayVisible: () => false, setVisibleOverlayVisible: () => {}, getResolver: () => null, setResolver: () => {}, getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(), ensureOverlayStartupPrereqs: () => order.push('prereqs'), ensureOverlayWindowsReadyForVisibilityActions: () => order.push('windows-ready'), sendToVisibleOverlay: (channel) => { order.push(`send:${channel}`); return true; }, waitForModalOpen: async () => { order.push('wait'); return true; }, }); // Do not await: an acknowledged modal leaves the choice pending until the user responds. void runtime.createFieldGroupingCallback()({ 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, }, }); // Let the async send + modal-open ack chain run. for (let i = 0; i < 10; i += 1) { await Promise.resolve(); } assert.deepEqual(order, [ 'prereqs', 'windows-ready', 'send:kiku:field-grouping-request', 'wait', ]); } finally { globalThis.setTimeout = originalSetTimeout; } });