mirror of
https://github.com/zadam/trilium.git
synced 2026-09-14 02:56:04 +05:00
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:
parent
f17a542041
commit
4a32394001
@ -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 => {
|
||||
|
||||
@ -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 );
|
||||
}
|
||||
} );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 );
|
||||
}
|
||||
} );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 )
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
@ -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 );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
@ -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 );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
127
packages/ckeditor5-mermaid/tests/mermaidconverterguards.ts
Normal file
127
packages/ckeditor5-mermaid/tests/mermaidconverterguards.ts
Normal 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();
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@ -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">' +
|
||||
|
||||
216
packages/ckeditor5-mermaid/tests/mermaidrendering.ts
Normal file
216
packages/ckeditor5-mermaid/tests/mermaidrendering.ts
Normal 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 );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@ -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 );
|
||||
} );
|
||||
} );
|
||||
|
||||
99
packages/ckeditor5-mermaid/tests/utils.ts
Normal file
99
packages/ckeditor5-mermaid/tests/utils.ts
Normal 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 );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
Loading…
Reference in New Issue
Block a user