test(ckeditor5-mermaid): take the package to 100% coverage

The package declared 100% thresholds but sat at 87.8% statements / 74.1%
branches / 86.2% functions — CI never runs it with --coverage, so the
gate has been decorative. Close it ahead of folding the package into the
aggregate, where the gate is enforced.

The renderer was the largest hole: nothing exercised _renderMermaid at
all. It is now covered through a stand-in mermaid instance — lazy-loaded
once, config passed through (and defaulted), SVG injected, error message
shown and the orphan probe node cleaned up on failure, and a superseded
render ignored via the generation guard. Also covered: the debounced
textarea write-back and its focus workaround, the consumable guards in
both downcasts and the upcast (each via a competing higher-priority
converter), the upcast rejection cases, the two "Missing command."
guards, the info button and the toolbar buttons, and debounce/checkIsOn.

Four fragments could not be covered because they are unreachable, so
they are simplified rather than papered over:

- mermaidtoolbar registered its toolbar behind `if ( mermaidToolbarItems )`,
  testing a non-empty array literal declared two lines above.
- The three view commands resolved their target as `getSelectedElement()
  || getLastPosition()?.parent`. `mermaid` is registered `isObject: true`,
  so the selection is always *on* the element and the fallback could only
  ever yield a non-mermaid block, which these commands must not touch.
- With that fallback gone, the source and split commands were left
  dereferencing a possibly-null item, so both gain the `if ( mermaidItem )`
  guard the preview command already had.
- mermaidui nested a DOM-element check inside a view-element check; an
  attached view element always has a DOM counterpart — stubbing it away
  makes CKEditor's own renderer throw, so that state cannot exist.

113 tests, 100% statements/branches/functions/lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Elian Doran 2026-08-01 16:35:56 +03:00
parent f17a542041
commit 4a32394001
No known key found for this signature in database
13 changed files with 689 additions and 25 deletions

View File

@ -27,7 +27,8 @@ export default class MermaidPreviewCommand extends Command {
const editor = this.editor;
const model = editor.model;
const documentSelection = this.editor.model.document.selection;
const mermaidItem = (documentSelection.getSelectedElement() || documentSelection.getLastPosition()?.parent) as ModelElement;
// `mermaid` is an object element, so the selection is always *on* it — never inside.
const mermaidItem = documentSelection.getSelectedElement() as ModelElement | null;
if (mermaidItem) {
model.change( writer => {

View File

@ -29,12 +29,15 @@ export default class MermaidSourceViewCommand extends Command {
const editor = this.editor;
const model = editor.model;
const documentSelection = this.editor.model.document.selection;
const mermaidItem = (documentSelection.getSelectedElement() || documentSelection.getLastPosition()?.parent) as ModelElement;
// `mermaid` is an object element, so the selection is always *on* it — never inside.
const mermaidItem = documentSelection.getSelectedElement() as ModelElement | null;
model.change( writer => {
if ( mermaidItem.getAttribute( 'displayMode' ) !== 'source' ) {
writer.setAttribute( 'displayMode', 'source', mermaidItem );
}
} );
if ( mermaidItem ) {
model.change( writer => {
if ( mermaidItem.getAttribute( 'displayMode' ) !== 'source' ) {
writer.setAttribute( 'displayMode', 'source', mermaidItem );
}
} );
}
}
}

View File

@ -31,12 +31,15 @@ export default class MermaidSplitViewCommand extends Command {
const editor = this.editor;
const model = editor.model;
const documentSelection = this.editor.model.document.selection;
const mermaidItem = (documentSelection.getSelectedElement() || documentSelection.getLastPosition()?.parent) as ModelElement;
// `mermaid` is an object element, so the selection is always *on* it — never inside.
const mermaidItem = documentSelection.getSelectedElement() as ModelElement | null;
model.change( writer => {
if ( mermaidItem.getAttribute( 'displayMode' ) !== 'split' ) {
writer.setAttribute( 'displayMode', 'split', mermaidItem );
}
} );
if ( mermaidItem ) {
model.change( writer => {
if ( mermaidItem.getAttribute( 'displayMode' ) !== 'split' ) {
writer.setAttribute( 'displayMode', 'split', mermaidItem );
}
} );
}
}
}

View File

@ -22,13 +22,11 @@ export default class MermaidToolbar extends Plugin {
const widgetToolbarRepository = editor.plugins.get( WidgetToolbarRepository );
const mermaidToolbarItems = [ 'mermaidSourceView', 'mermaidSplitView', 'mermaidPreview', '|', 'mermaidInfo' ];
if ( mermaidToolbarItems ) {
widgetToolbarRepository.register( 'mermaidToolbar', {
ariaLabel: t( 'Mermaid toolbar' ),
items: mermaidToolbarItems,
getRelatedElement: selection => getSelectedElement( selection )
} );
}
widgetToolbarRepository.register( 'mermaidToolbar', {
ariaLabel: t( 'Mermaid toolbar' ),
items: mermaidToolbarItems,
getRelatedElement: selection => getSelectedElement( selection )
} );
}
}

View File

@ -117,11 +117,11 @@ export default class MermaidUI extends Plugin {
view.focus();
if ( mermaidItemViewElement ) {
const mermaidItemDomElement = view.domConverter.viewToDom( mermaidItemViewElement );
// A view element that is attached always has a DOM counterpart — the editor could not
// have rendered the widget otherwise — so this lookup is not guarded separately.
const mermaidItemDomElement = view.domConverter.viewToDom( mermaidItemViewElement ) as HTMLElement;
if ( mermaidItemDomElement ) {
(mermaidItemDomElement.querySelector( '.ck-mermaid__editing-view' ) as HTMLElement)?.focus();
}
mermaidItemDomElement.querySelector<HTMLElement>( '.ck-mermaid__editing-view' )?.focus();
}
}

View File

@ -108,4 +108,36 @@ describe( 'MermaidPreviewCommand', () => {
);
} );
} );
describe( "#execute() edge cases", () => {
it( "resolves the mermaid from the caret when nothing is selected", () => {
setModelData( model, '<mermaid displayMode="split" source="foo">[]</mermaid>' );
command.execute();
expect( getModelData( model, { withoutSelection: true } ) )
.to.equal( `<mermaid displayMode="preview" source="foo"></mermaid>` );
} );
it( "leaves a mermaid already in preview mode untouched", () => {
setModelData( model, `[<mermaid displayMode="preview" source="foo"></mermaid>]` );
const before = getModelData( model, { withoutSelection: true } );
command.execute();
expect( getModelData( model, { withoutSelection: true } ) ).to.equal( before );
} );
it( "does nothing when no mermaid is selected", () => {
setModelData( model, "<paragraph>foo[]</paragraph>" );
const before = getModelData( model, { withoutSelection: true } );
// CKEditor blocks execute() on a disabled command, and this command disables itself
// when nothing is selected — so force it through to exercise the guard itself.
command.isEnabled = true;
command.execute();
expect( getModelData( model, { withoutSelection: true } ) ).to.equal( before );
} );
} );
} );

View File

@ -108,4 +108,36 @@ describe( 'MermaidSourceViewCommand', () => {
);
} );
} );
describe( "#execute() edge cases", () => {
it( "resolves the mermaid from the caret when nothing is selected", () => {
setModelData( model, '<mermaid displayMode="split" source="foo">[]</mermaid>' );
command.execute();
expect( getModelData( model, { withoutSelection: true } ) )
.to.equal( `<mermaid displayMode="source" source="foo"></mermaid>` );
} );
it( "leaves a mermaid already in source mode untouched", () => {
setModelData( model, `[<mermaid displayMode="source" source="foo"></mermaid>]` );
const before = getModelData( model, { withoutSelection: true } );
command.execute();
expect( getModelData( model, { withoutSelection: true } ) ).to.equal( before );
} );
it( "does nothing when no mermaid is selected", () => {
setModelData( model, "<paragraph>foo[]</paragraph>" );
const before = getModelData( model, { withoutSelection: true } );
// CKEditor blocks execute() on a disabled command, and this command disables itself
// when nothing is selected — so force it through to exercise the guard itself.
command.isEnabled = true;
command.execute();
expect( getModelData( model, { withoutSelection: true } ) ).to.equal( before );
} );
} );
} );

View File

@ -108,4 +108,36 @@ describe( 'MermaidSplitViewCommand', () => {
);
} );
} );
describe( "#execute() edge cases", () => {
it( "resolves the mermaid from the caret when nothing is selected", () => {
setModelData( model, '<mermaid displayMode="preview" source="foo">[]</mermaid>' );
command.execute();
expect( getModelData( model, { withoutSelection: true } ) )
.to.equal( `<mermaid displayMode="split" source="foo"></mermaid>` );
} );
it( "leaves a mermaid already in split mode untouched", () => {
setModelData( model, `[<mermaid displayMode="split" source="foo"></mermaid>]` );
const before = getModelData( model, { withoutSelection: true } );
command.execute();
expect( getModelData( model, { withoutSelection: true } ) ).to.equal( before );
} );
it( "does nothing when no mermaid is selected", () => {
setModelData( model, "<paragraph>foo[]</paragraph>" );
const before = getModelData( model, { withoutSelection: true } );
// CKEditor blocks execute() on a disabled command, and this command disables itself
// when nothing is selected — so force it through to exercise the guard itself.
command.isEnabled = true;
command.execute();
expect( getModelData( model, { withoutSelection: true } ) ).to.equal( before );
} );
} );
} );

View File

@ -0,0 +1,127 @@
import { ClassicEditor, CodeBlockEditing, Essentials, Paragraph, _getModelData as getModelData, _setModelData as setModelData } from 'ckeditor5';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MermaidEditing from '../src/mermaidediting.js';
/* global document */
/**
* The converters all begin by asking whether someone else has already claimed the item. Those
* guards only fire when another converter of higher priority consumed it first, so each test
* here registers exactly such a competitor.
*/
describe( 'MermaidEditing converter guards', () => {
let domElement: HTMLDivElement, editor: ClassicEditor;
beforeEach( () => {
domElement = document.createElement( 'div' );
document.body.appendChild( domElement );
} );
afterEach( async () => {
domElement.remove();
await editor?.destroy();
} );
async function createEditor( extraPlugins: unknown[] = [] ) {
return ClassicEditor.create( domElement, {
licenseKey: 'GPL',
plugins: [ Paragraph, Essentials, CodeBlockEditing, MermaidEditing, ...extraPlugins ] as never
} ) as Promise<ClassicEditor>;
}
it( 'skips the data downcast when another converter already consumed the item', async () => {
editor = await createEditor();
editor.data.downcastDispatcher.on( 'insert:mermaid', ( evt, data, conversionApi ) => {
conversionApi.consumable.consume( data.item, 'insert' );
}, { priority: 'highest' } );
setModelData( editor.model, '[<mermaid displayMode="split" source="graph TD;"></mermaid>]' );
// Ours bailed out, so nothing of its <pre><code> structure reached the data.
expect( editor.getData() ).to.not.contain( 'language-mermaid' );
} );
it( 'skips the editing downcast when another converter already consumed the item', async () => {
editor = await createEditor();
editor.editing.downcastDispatcher.on( 'insert:mermaid', ( evt, data, conversionApi ) => {
// Claim the item *and* stand in for it, otherwise the attribute converters that run
// afterwards have no view element to attach to.
conversionApi.consumable.consume( data.item, 'insert' );
conversionApi.consumable.consume( data.item, 'attribute:displayMode' );
conversionApi.consumable.consume( data.item, 'attribute:source' );
const stub = conversionApi.writer.createContainerElement( 'div', { class: 'stub' } );
conversionApi.mapper.bindElements( data.item as never, stub );
conversionApi.writer.insert(
conversionApi.mapper.toViewPosition( editor.model.createPositionBefore( data.item as never ) ),
stub
);
}, { priority: 'highest' } );
setModelData( editor.model, '[<mermaid displayMode="split" source="graph TD;"></mermaid>]' );
expect( editor.editing.view.getDomRoot()?.querySelector( '.ck-mermaid__wrapper' ) ).to.equal( null );
} );
it( 'skips the upcast when another converter already claimed the code element', async () => {
editor = await createEditor();
editor.data.upcastDispatcher.on( 'element:code', ( evt, data, conversionApi ) => {
conversionApi.consumable.consume( data.viewItem, { name: true } );
}, { priority: 'highest' } );
editor.setData(
'<pre spellcheck="false"><code class="language-mermaid">flowchart TB</code></pre>'
);
expect( getModelData( editor.model, { withoutSelection: true } ) ).to.not.contain( '<mermaid' );
} );
describe( 'the source attribute downcast', () => {
/** Invoke the converter directly — these guards depend on mapper/DOM lookups failing. */
function callSourceDowncast( mapperResult: unknown, viewToDomResult: unknown ) {
const plugin = editor.plugins.get( MermaidEditing ) as unknown as {
_sourceAttributeDowncast( evt: unknown, data: unknown, conversionApi: unknown ): void;
};
const item = editor.model.document.getRoot()?.getChild( 0 );
// Only the preview lookup is stubbed: the textarea branch runs first and dereferences
// its DOM element unguarded.
const realViewToDom = editor.editing.view.domConverter.viewToDom.bind( editor.editing.view.domConverter );
vi.spyOn( editor.editing.view.domConverter, 'viewToDom' ).mockImplementation( ( view: never ) => {
const element = view as unknown as { hasClass( name: string ): boolean };
if ( element.hasClass?.( 'ck-mermaid__preview' ) ) {
return viewToDomResult as ReturnType<typeof realViewToDom>;
}
return realViewToDom( view );
} );
plugin._sourceAttributeDowncast(
{},
{ item, attributeNewValue: 'graph TD;' },
{ mapper: { toViewElement: () => mapperResult } }
);
}
beforeEach( async () => {
editor = await createEditor();
setModelData( editor.model, '[<mermaid displayMode="split" source="a"></mermaid>]' );
} );
it( 'does nothing when the item has no view element', () => {
expect( () => callSourceDowncast( undefined, null ) ).to.not.throw();
} );
it( 'does nothing when the preview wrapper has no DOM element', () => {
const viewElement = editor.editing.mapper.toViewElement(
editor.model.document.getRoot()?.getChild( 0 ) as never
);
// The preview child is found in the view, but has no DOM counterpart.
expect( () => callSourceDowncast( viewElement, null ) ).to.not.throw();
} );
} );
} );

View File

@ -50,6 +50,29 @@ describe( 'MermaidEditing', () => {
);
} );
it( 'ignores a language-mermaid code element that is not inside a pre', () => {
editor.setData( '<code class="language-mermaid">flowchart TB</code>' );
// Left to the code-block/paragraph converters — no mermaid widget.
expect( getModelData( model, { withoutSelection: true } ) ).to.not.contain( '<mermaid' );
} );
it( 'ignores a code element without the language-mermaid class', () => {
editor.setData( '<pre spellcheck="false"><code class="language-plaintext">plain</code></pre>' );
expect( getModelData( model, { withoutSelection: true } ) ).to.not.contain( '<mermaid' );
} );
it( 'ignores a mermaid block nested inside another code element', () => {
editor.setData(
'<pre spellcheck="false"><code class="language-plaintext">' +
'<pre spellcheck="false"><code class="language-mermaid">flowchart TB</code></pre>' +
'</code></pre>'
);
expect( getModelData( model, { withoutSelection: true } ) ).to.not.contain( '<mermaid' );
} );
it( 'works correctly when empty', () => {
editor.setData(
'<pre spellcheck="false">' +

View File

@ -0,0 +1,216 @@
import { ClassicEditor, CodeBlockEditing, Essentials, Paragraph, _getModelData as getModelData, _setModelData as setModelData } from 'ckeditor5';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MermaidEditing from '../src/mermaidediting.js';
/* global document */
/**
* The diagram renderer: how `_renderMermaid` lazy-loads the mermaid instance, what it does with
* the SVG it gets back, and how it behaves when a render fails or is superseded by a newer one.
*/
describe( 'MermaidEditing rendering', () => {
let domElement: HTMLDivElement, editor: ClassicEditor;
/** A stand-in for the mermaid library, with a render we can resolve or reject at will. */
function createFakeMermaid( render: MermaidInstance['render'] ) {
return { initialize: vi.fn(), render: vi.fn( render ) };
}
async function createEditor( mermaidConfig?: Record<string, unknown> ) {
domElement = document.createElement( 'div' );
document.body.appendChild( domElement );
return ClassicEditor.create( domElement, {
licenseKey: 'GPL',
plugins: [ Paragraph, Essentials, CodeBlockEditing, MermaidEditing ],
...( mermaidConfig ? { mermaid: mermaidConfig } : {} )
} ) as Promise<ClassicEditor>;
}
/** The rendered preview pane of the first mermaid widget in the editing view. */
function previewDom(): HTMLElement {
const preview = editor.editing.view.getDomRoot()?.querySelector( '.ck-mermaid__preview' );
if ( !( preview instanceof HTMLElement ) ) {
throw new Error( 'Expected a rendered mermaid preview.' );
}
return preview;
}
/** Let the render promise chain settle. */
const flush = () => new Promise( resolve => setTimeout( resolve, 0 ) );
/** Poll until `predicate` holds — the render chain takes several microtask turns. */
async function waitFor( predicate: () => boolean ) {
for ( let i = 0; i < 50 && !predicate(); i++ ) {
await flush();
}
if ( !predicate() ) {
throw new Error( 'Timed out waiting for the render to start.' );
}
}
/** The `source` attribute of the first mermaid element in the model. */
function sourceAttribute(): unknown {
const item = editor.model.document.getRoot()?.getChild( 0 );
return item?.is( 'element' ) ? item.getAttribute( 'source' ) : undefined;
}
afterEach( async () => {
domElement?.remove();
await editor?.destroy();
} );
it( 'lazy-loads mermaid once, initialises it, and injects the SVG', async () => {
const instance = createFakeMermaid( async () => ( { svg: '<svg id="rendered"></svg>' } ) );
const lazyLoad = vi.fn( async () => instance );
editor = await createEditor( { lazyLoad, config: { theme: 'dark' } } );
setModelData( editor.model, '[<mermaid displayMode="split" source="graph TD;"></mermaid>]' );
editor.model.change( writer => {
const item = editor.model.document.getRoot()?.getChild( 0 );
if ( item?.is( 'element' ) ) {
writer.setAttribute( 'source', 'graph TD; A-->B;', item );
}
} );
await flush();
expect( lazyLoad ).toHaveBeenCalledTimes( 1 );
expect( instance.initialize ).toHaveBeenCalledWith( { theme: 'dark' } );
expect( previewDom().innerHTML ).to.equal( '<svg id="rendered"></svg>' );
} );
it( 'falls back to an empty config object when none is supplied', async () => {
const instance = createFakeMermaid( async () => ( { svg: '<svg></svg>' } ) );
editor = await createEditor( { lazyLoad: async () => instance } );
setModelData( editor.model, '[<mermaid displayMode="split" source="a"></mermaid>]' );
editor.model.change( writer => {
const item = editor.model.document.getRoot()?.getChild( 0 );
if ( item?.is( 'element' ) ) {
writer.setAttribute( 'source', 'b', item );
}
} );
await flush();
expect( instance.initialize ).toHaveBeenCalledWith( {} );
} );
it( 'shows the error message when a render throws, and cleans up the orphan node', async () => {
const instance = createFakeMermaid( async ( id: string ) => {
// mermaid leaves a probe element behind in the document when it fails.
const orphan = document.createElement( 'div' );
orphan.id = id;
document.body.appendChild( orphan );
throw new Error( 'Parse error on line 1' );
} );
editor = await createEditor( { lazyLoad: async () => instance } );
setModelData( editor.model, '[<mermaid displayMode="split" source="a"></mermaid>]' );
editor.model.change( writer => {
const item = editor.model.document.getRoot()?.getChild( 0 );
if ( item?.is( 'element' ) ) {
writer.setAttribute( 'source', 'not a diagram', item );
}
} );
await flush();
expect( previewDom().innerText ).to.equal( 'Parse error on line 1' );
expect( document.querySelectorAll( '[id^="ck-mermaid-"]' ) ).to.have.length( 0 );
} );
it( 'ignores a render that a newer one has superseded', async () => {
const pending: Array<( value: { svg: string } ) => void> = [];
const instance = createFakeMermaid( () => new Promise( resolve => {
pending.push( resolve );
} ) );
editor = await createEditor( { lazyLoad: async () => instance } );
// Drive the renderer directly: going through attribute changes would re-render the
// widget and hand each render a different preview node, which is not what is under
// test here — the generation guard is.
const plugin = editor.plugins.get( MermaidEditing ) as unknown as {
_renderMermaid( domElement: HTMLElement, source: string ): Promise<void>;
};
const target = document.createElement( 'div' );
const stale = plugin._renderMermaid( target, 'first' );
await waitFor( () => pending.length >= 1 );
const latest = plugin._renderMermaid( target, 'second' );
await waitFor( () => pending.length >= 2 );
// Resolve the newer render first, then let the stale one finish.
pending[ 1 ]?.( { svg: '<svg id="second"></svg>' } );
await latest;
pending[ 0 ]?.( { svg: '<svg id="first"></svg>' } );
await stale;
expect( target.innerHTML ).to.equal( '<svg id="second"></svg>' );
} );
it( 'does nothing when the host configured no lazyLoad', async () => {
editor = await createEditor( {} );
setModelData( editor.model, '[<mermaid displayMode="split" source="a"></mermaid>]' );
editor.model.change( writer => {
const item = editor.model.document.getRoot()?.getChild( 0 );
if ( item?.is( 'element' ) ) {
writer.setAttribute( 'source', 'b', item );
}
} );
await flush();
expect( previewDom().innerHTML ).to.equal( '' );
} );
describe( 'the source textarea', () => {
beforeEach( async () => {
editor = await createEditor( { lazyLoad: async () => createFakeMermaid( async () => ( { svg: '' } ) ) } );
} );
function textarea(): HTMLTextAreaElement {
const el = editor.editing.view.getDomRoot()?.querySelector( '.ck-mermaid__editing-view' );
if ( !( el instanceof HTMLTextAreaElement ) ) {
throw new Error( 'Expected a rendered mermaid textarea.' );
}
return el;
}
it( 'writes typed text back to the model, debounced', async () => {
vi.useFakeTimers();
try {
setModelData( editor.model, '[<mermaid displayMode="split" source="a"></mermaid>]' );
const el = textarea();
el.value = 'graph TD; A-->B;';
el.dispatchEvent( new Event( 'input' ) );
// Nothing yet — the listener is debounced.
expect( sourceAttribute() ).to.equal( 'a' );
vi.advanceTimersByTime( 300 );
expect( sourceAttribute() ).to.equal( 'graph TD; A-->B;' );
} finally {
vi.useRealTimers();
}
} );
it( 'selects the widget when the textarea takes focus', () => {
setModelData( editor.model, '<paragraph>[]foo</paragraph><mermaid displayMode="split" source="a"></mermaid>' );
textarea().dispatchEvent( new FocusEvent( 'focus' ) );
expect( editor.model.document.selection.getSelectedElement()?.name ).to.equal( 'mermaid' );
} );
it( 'leaves the selection alone when the widget is already selected', () => {
setModelData( editor.model, '[<mermaid displayMode="split" source="a"></mermaid>]' );
const before = getModelData( editor.model );
textarea().dispatchEvent( new FocusEvent( 'focus' ) );
expect( getModelData( editor.model ) ).to.equal( before );
} );
} );
} );

View File

@ -3,7 +3,7 @@ import { ClassicEditor, Paragraph, _getModelData as getModelData, _setModelData
import '../src/augmentation.js';
import Mermaid from '../src/mermaid.js';
import MermaidUI from '../src/mermaidui.js';
import { afterEach, beforeEach, describe, it } from 'vitest';
import { afterEach, beforeEach, describe, it, vi } from 'vitest';
import { expect } from 'vitest';
/* global document */
@ -158,3 +158,101 @@ describe( 'MermaidUI', () => {
} );
} );
describe( 'MermaidUI without its commands', () => {
let domElement: HTMLDivElement;
beforeEach( () => {
domElement = document.createElement( 'div' );
document.body.appendChild( domElement );
} );
afterEach( () => {
domElement.remove();
} );
it( 'refuses to initialise without the insert command', async () => {
// MermaidUI alone: MermaidEditing never registered insertMermaid, and the
// insert button is built during init(), so the whole editor fails to start.
await expect( ClassicEditor.create( domElement, {
licenseKey: 'GPL',
plugins: [ Paragraph, MermaidUI ]
} ) ).rejects.toThrow( 'Missing command.' );
} );
it( 'refuses to build a toolbar button whose command is missing', async () => {
const editor = await ClassicEditor.create( domElement, {
licenseKey: 'GPL',
plugins: [ Paragraph, Mermaid ]
} );
const ui = editor.plugins.get( MermaidUI ) as unknown as {
_createToolbarButton( editor: ClassicEditor, name: string, label: string, icon: string ): void;
};
// Toolbar buttons resolve their command lazily, inside the factory callback.
ui._createToolbarButton( editor, 'mermaidNoSuch', 'No such', '<svg></svg>' );
expect( () => editor.ui.componentFactory.create( 'mermaidNoSuch' ) ).to.throw( 'Missing command.' );
await editor.destroy();
} );
} );
describe( 'MermaidUI buttons', () => {
let domElement: HTMLDivElement, editor: ClassicEditor;
beforeEach( async () => {
domElement = document.createElement( 'div' );
document.body.appendChild( domElement );
editor = await ClassicEditor.create( domElement, {
licenseKey: 'GPL',
plugins: [ Paragraph, Mermaid ]
} );
} );
afterEach( () => {
domElement.remove();
return editor.destroy();
} );
it( 'opens the syntax documentation in a new tab', () => {
const open = vi.spyOn( window, 'open' ).mockReturnValue( null );
const button = editor.ui.componentFactory.create( 'mermaidInfo' );
button.fire( 'execute' );
expect( open ).toHaveBeenCalledWith(
'https://ckeditor.com/blog/basic-overview-of-creating-flowcharts-using-mermaid/',
'_blank',
'noopener'
);
open.mockRestore();
} );
it( 'runs the matching command and returns focus when a toolbar button executes', () => {
setModelData( editor.model, '[<mermaid displayMode="split" source="foo"></mermaid>]' );
const execute = vi.spyOn( editor, 'execute' );
const focus = vi.spyOn( editor.editing.view, 'focus' );
const scroll = vi.spyOn( editor.editing.view, 'scrollToTheSelection' );
editor.ui.componentFactory.create( 'mermaidPreview' ).fire( 'execute' );
expect( execute ).toHaveBeenCalledWith( 'mermaidPreviewCommand' );
expect( scroll ).toHaveBeenCalled();
expect( focus ).toHaveBeenCalled();
} );
it( 'tracks its command through isOn and isEnabled', () => {
setModelData( editor.model, '[<mermaid displayMode="preview" source="foo"></mermaid>]' );
const button = editor.ui.componentFactory.create( 'mermaidPreview' ) as unknown as {
isOn: boolean; isEnabled: boolean;
};
expect( button.isOn ).to.equal( true );
expect( button.isEnabled ).to.equal( true );
} );
} );

View File

@ -0,0 +1,99 @@
import { ClassicEditor, Paragraph, _setModelData as setModelData } from 'ckeditor5';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MermaidEditing from '../src/mermaidediting.js';
import { checkIsOn, debounce } from '../src/utils.js';
/* global document */
describe( 'utils', () => {
describe( 'debounce()', () => {
beforeEach( () => {
vi.useFakeTimers();
} );
afterEach( () => {
vi.useRealTimers();
} );
it( 'defers the call until the wait elapses', () => {
const spy = vi.fn();
const debounced = debounce( spy, 50 );
debounced( 'a' );
expect( spy ).not.toHaveBeenCalled();
vi.advanceTimersByTime( 50 );
expect( spy ).toHaveBeenCalledTimes( 1 );
expect( spy ).toHaveBeenCalledWith( 'a' );
} );
it( 'collapses a burst of calls into the last one', () => {
const spy = vi.fn();
const debounced = debounce( spy, 50 );
debounced( 'first' );
vi.advanceTimersByTime( 20 );
// Lands inside the window, so the pending timeout is cleared and restarted.
debounced( 'second' );
vi.advanceTimersByTime( 20 );
debounced( 'third' );
vi.advanceTimersByTime( 50 );
expect( spy ).toHaveBeenCalledTimes( 1 );
expect( spy ).toHaveBeenCalledWith( 'third' );
} );
it( 'preserves the caller as `this`', () => {
const calls: unknown[] = [];
const debounced = debounce( function( this: unknown ) {
calls.push( this );
}, 10 );
const host = { run: debounced };
host.run();
vi.advanceTimersByTime( 10 );
expect( calls ).to.deep.equal( [ host ] );
} );
} );
describe( 'checkIsOn()', () => {
let domElement: HTMLDivElement, editor: ClassicEditor;
beforeEach( async () => {
domElement = document.createElement( 'div' );
document.body.appendChild( domElement );
editor = await ClassicEditor.create( domElement, {
licenseKey: 'GPL',
plugins: [ Paragraph, MermaidEditing ]
} );
} );
afterEach( () => {
domElement.remove();
return editor.destroy();
} );
it( 'is true for the selected mermaid whose displayMode matches', () => {
setModelData( editor.model, '[<mermaid displayMode="preview" source="foo"></mermaid>]' );
expect( checkIsOn( editor, 'preview' ) ).to.equal( true );
} );
it( 'is false for the selected mermaid whose displayMode differs', () => {
setModelData( editor.model, '[<mermaid displayMode="split" source="foo"></mermaid>]' );
expect( checkIsOn( editor, 'preview' ) ).to.equal( false );
} );
it( 'is false when the selection is not on a mermaid at all', () => {
setModelData( editor.model, '<paragraph>foo[]</paragraph>' );
expect( checkIsOn( editor, 'preview' ) ).to.equal( false );
} );
} );
} );