// // Copyright 2026 DXOS.org // import { EditorState, StateEffect } from '@codemirror/state'; import { EditorView } from '@codemirror/view'; import { describe, test } from 'vitest'; import { isBusy } from '../../state/busy.ts'; import { appendPendingText, cancelPending, cancelPendingText, commitPending, pendingText, pendingTextState, setPendingAnchor, setPendingInterim, } from './pending-text.ts'; const createView = (doc: string): EditorView => new EditorView({ state: EditorState.create({ doc, extensions: [pendingText()] }) }); // Busy is toggled via a queued microtask, so flush before asserting. const flush = () => new Promise((resolve) => queueMicrotask(resolve)); describe('pendingText extension', () => { test('append and interim accumulate without changing the document', ({ expect }) => { const view = createView('hello '); view.dispatch({ effects: setPendingAnchor.of({ anchor: 6 }) }); view.dispatch({ effects: appendPendingText.of('world') }); view.dispatch({ effects: setPendingInterim.of(' and') }); expect(view.state.field(pendingTextState)).toEqual({ anchor: 6, final: 'world', interim: ' and', placeholder: undefined, }); expect(view.state.doc.toString()).toBe('hello '); view.destroy(); }); test('appending finalized text clears the interim tail', ({ expect }) => { const view = createView('hello '); view.dispatch({ effects: setPendingAnchor.of({ anchor: 6 }) }); view.dispatch({ effects: setPendingInterim.of('wor') }); view.dispatch({ effects: appendPendingText.of('world') }); expect(view.state.field(pendingTextState)).toMatchObject({ anchor: 6, final: 'world', interim: '' }); view.destroy(); }); test('commit at the end of the document inserts the text and leaves a trailing blank line', ({ expect }) => { const view = createView('hello '); view.dispatch({ effects: setPendingAnchor.of({ anchor: 6 }) }); view.dispatch({ effects: appendPendingText.of('world') }); view.dispatch({ effects: setPendingInterim.of(' dropped') }); expect(commitPending(view)).toBe(true); expect(view.state.doc.toString()).toBe('hello world\n\n'); expect(view.state.field(pendingTextState)).toBeNull(); view.destroy(); }); test('commit before the end of the document does not add a trailing blank line', ({ expect }) => { const view = createView('one\ntwo'); view.dispatch({ effects: setPendingAnchor.of({ anchor: 3 }) }); view.dispatch({ effects: appendPendingText.of(' and a half') }); expect(commitPending(view)).toBe(true); // Single newline terminates the inserted line; the document still ends at 'two' (no trailing blank line). expect(view.state.doc.toString()).toBe('one and a half\n\ntwo'); view.destroy(); }); test('cancel discards pending text and leaves the document unchanged', ({ expect }) => { const view = createView('hello '); view.dispatch({ effects: setPendingAnchor.of({ anchor: 6 }) }); view.dispatch({ effects: appendPendingText.of('world') }); expect(cancelPending(view)).toBe(true); expect(view.state.doc.toString()).toBe('hello '); expect(view.state.field(pendingTextState)).toBeNull(); view.destroy(); }); test('commit and cancel are no-ops when there is no pending text', ({ expect }) => { const view = createView('hello '); expect(commitPending(view)).toBe(false); expect(cancelPending(view)).toBe(false); view.destroy(); }); test('a placeholder-only session is held without committable text', ({ expect }) => { const view = createView('hello '); view.dispatch({ effects: setPendingAnchor.of({ anchor: 6, placeholder: 'Recording…' }) }); expect(view.state.field(pendingTextState)).toEqual({ anchor: 6, final: '', interim: '', placeholder: 'Recording…', }); // Nothing finalized yet, so commit is a no-op. expect(commitPending(view)).toBe(false); view.destroy(); }); test('anchor survives an edit before the anchor', ({ expect }) => { const view = createView('hello '); view.dispatch({ effects: setPendingAnchor.of({ anchor: 6 }) }); view.dispatch({ effects: appendPendingText.of('world') }); view.dispatch({ changes: { from: 0, insert: 'X' } }); expect(view.state.field(pendingTextState)?.anchor).toBe(7); expect(commitPending(view)).toBe(true); expect(view.state.doc.toString()).toBe('Xhello world\n\n'); view.destroy(); }); test('the first text effect without an explicit anchor uses the selection head', ({ expect }) => { const view = createView('hello '); view.dispatch({ selection: { anchor: 6 } }); view.dispatch({ effects: appendPendingText.of('world') }); expect(view.state.field(pendingTextState)?.anchor).toBe(6); view.destroy(); }); test('flags the editor busy while a session is active and clears it on commit', async ({ expect }) => { const view = createView('hello '); view.dispatch({ effects: setPendingAnchor.of({ anchor: 6, placeholder: 'Recording…' }) }); await flush(); expect(isBusy(view.state)).toBe(true); view.dispatch({ effects: appendPendingText.of('world') }); commitPending(view); await flush(); expect(isBusy(view.state)).toBe(false); view.destroy(); }); test('tearing the extension down cancels the queued busy update', async ({ expect }) => { const view = createView('hello '); const errors: unknown[] = []; const onError = (error: unknown) => errors.push(error); process.on('uncaughtException', onError); try { // Opening a session queues the busy microtask; dropping the extension before it runs leaves // the callback holding a configuration that no longer has the field. view.dispatch({ effects: setPendingAnchor.of({ anchor: 6 }) }); view.dispatch({ effects: StateEffect.reconfigure.of([]) }); await flush(); expect(errors).to.deep.equal([]); expect(view.state.field(pendingTextState, false)).to.be.undefined; } finally { process.off('uncaughtException', onError); view.destroy(); } }); // The editor's own placeholder shows whenever the document is empty, and pending text is a // decoration rather than document content — so without this the two are drawn over each other, // the hint sitting behind the words being dictated. test('the preview marks the content so the empty-document placeholder can stand aside', ({ expect }) => { const view = createView(''); expect(view.contentDOM.hasAttribute('data-pending-text')).to.be.false; // Opening a session paints nothing, so the hint stays until there is something to replace it. view.dispatch({ effects: setPendingAnchor.of({ anchor: 0 }) }); expect(view.contentDOM.hasAttribute('data-pending-text')).to.be.false; view.dispatch({ effects: appendPendingText.of('the world is everything') }); expect(view.contentDOM.hasAttribute('data-pending-text')).to.be.true; view.dispatch({ effects: cancelPendingText.of() }); expect(view.contentDOM.hasAttribute('data-pending-text')).to.be.false; }); });