KnodesCommunity/typedoc-plugins

View on GitHub
packages/pluginutils/src/text-replacers/markdown/markdown-replacer.spec.ts

Summary

Maintainability
A
0 mins
Test Coverage
/* eslint-disable @typescript-eslint/no-var-requires */
import assert from 'assert';

import { identity } from 'lodash';
import { Application, DeclarationReflection, MarkdownEvent, ReflectionKind, SourceReference } from 'typedoc';

import { relative, resolve } from '@knodes/typedoc-pluginutils/path';

jest.mock( '../../base-plugin' );
const { ABasePlugin, getPlugin, getApplication } = require( '../../base-plugin' ) as jest.Mocked<typeof import( '../../base-plugin' )>;
getPlugin.mockImplementation( identity );
getApplication.mockImplementation( jest.requireActual( '../../base-plugin' ).getApplication );

import { MarkdownReplacer } from './markdown-replacer';
import { CurrentPageMemo } from '../../current-page-memo';

class TestPlugin extends ABasePlugin {
    public override application: jest.MockedObjectDeep<Application>;
    public constructor(){
        super( {} as any, __filename );
        this.application = Object.assign( Object.create( Application.prototype ), {
            renderer: {
                on: jest.fn(),
            },
            options: { freeze: jest.fn() },
        } ) as any;
        const pseudoLogger = {
            makeChildLogger: jest.fn().mockImplementation( () => pseudoLogger ),
            error: jest.fn().mockImplementation( assert.fail ),
            warn: jest.fn().mockImplementation( assert.fail ),
        } as any;
        ( this as any ).logger = pseudoLogger;
    }
    // eslint-disable-next-line @typescript-eslint/no-empty-function
    public initialize(): void {}
    public relativeToRoot( path: string ){
        return relative( process.cwd(), path );
    }
}
const mockCurrentPage = ( name: string, source: string, line: number, character: number ) => {
    const ref = new DeclarationReflection( name, ReflectionKind.Class );
    ref.sources = [
        new SourceReference( source, line, character ),
    ];
    Object.defineProperty( CurrentPageMemo.prototype, 'currentReflection', { writable: true, value: ref } );
    Object.defineProperty( CurrentPageMemo.prototype, 'hasCurrent', { writable: true, value: true } );
};
afterEach( () => {
    Object.defineProperty( CurrentPageMemo.prototype, 'currentReflection', { writable: true, value: undefined } );
    Object.defineProperty( CurrentPageMemo.prototype, 'hasCurrent', { writable: true, value: false } );
} );
const getMarkdownEventParseListeners = ( plugin: TestPlugin ) => plugin.application.renderer.on.mock.calls
    .filter( c => c[0] === MarkdownEvent.PARSE )
    .map( c => c[1] );
describe( MarkdownReplacer.name, () => {
    let plugin: TestPlugin;
    let replacer: MarkdownReplacer;
    beforeEach( () => {
        plugin = new TestPlugin();
        replacer = new MarkdownReplacer( plugin );
    } );
    it( 'should replace correctly inline tag in markdown event', () =>{
        // Arrange
        mockCurrentPage( 'Test', resolve( 'hello.ts' ), 1, 1 );
        const fn = jest.fn().mockReturnValueOnce( 'REPLACE 1' ).mockReturnValueOnce( 'REPLACE 2' );
        replacer.registerMarkdownTag( '@test', /(foo)?/g, fn );
        const source = 'Hello {@test foo} {@test} @test';
        const event = new MarkdownEvent( MarkdownEvent.PARSE, source, source );
        const listeners = getMarkdownEventParseListeners( plugin );
        expect( listeners ).toHaveLength( 1 );

        // Act
        listeners[0]( event );

        // Assert
        expect( event.parsedText ).toEqual( 'Hello REPLACE 1 REPLACE 2 @test' );
        expect( fn ).toHaveBeenCalledTimes( 2 );
        expect( fn ).toHaveBeenNthCalledWith( 1, { captures: [ 'foo' ], fullMatch: 'test foo', event }, expect.toBeFunction() );
        expect( fn ).toHaveBeenNthCalledWith( 2, { captures: [ undefined ], fullMatch: 'test', event }, expect.toBeFunction() );
    } );
    it( 'should ignore excluded matches', () =>{
        // Arrange
        mockCurrentPage( 'Test', resolve( 'hello.ts' ), 1, 1 );
        const fn = jest.fn().mockReturnValueOnce( '1' ).mockReturnValueOnce( '2' ).mockReturnValueOnce( '3' );
        replacer.registerMarkdownTag( '@test', /(foo\d?)?/g, fn, { excludedMatches: [ '{@test foo1}', '{@test foo4}' ] } );
        const source = 'Hello {@test} {@test foo1} {@test foo2} {@test foo3} {@test foo4} @test';
        const event = new MarkdownEvent( MarkdownEvent.PARSE, source, source );
        const listeners = getMarkdownEventParseListeners( plugin );
        expect( listeners ).toHaveLength( 1 );

        // Act
        listeners[0]( event );

        // Assert
        expect( event.parsedText ).toEqual( 'Hello 1 {@test foo1} 2 3 {@test foo4} @test' );
        expect( fn ).toHaveBeenCalledTimes( 3 );
        expect( fn ).toHaveBeenNthCalledWith( 1, { captures: [], fullMatch: 'test', event }, expect.toBeFunction() );
        expect( fn ).toHaveBeenNthCalledWith( 2, { captures: [ 'foo2' ], fullMatch: 'test foo2', event }, expect.toBeFunction() );
        expect( fn ).toHaveBeenNthCalledWith( 3, { captures: [ 'foo3' ], fullMatch: 'test foo3', event }, expect.toBeFunction() );
    } );
    describe( 'Source map', () => {
        describe( 'Once', () => {
            it.each( [
                [ 'hello {@test ##} world', [ 'hello.ts:1:7' ]],
                [ 'hello \n{@test ## } world', [ 'hello.ts:2:1' ]],
                [ '\nhello {@test ## } world', [ 'hello.ts:2:7' ]],
                [ 'hello {@test ## } world{@test ##}\nHow are you doing ?\n{@test ##}', [ 'hello.ts:1:7', 'hello.ts:1:24', 'hello.ts:3:1' ]],
            ] )( 'should match %j with sourcemaps %j', ( source, expectedMaps ) => {
                mockCurrentPage( 'Test', 'hello.ts', 1, 1 );
                const fn = jest.fn().mockReturnValue( '#' );
                replacer.registerMarkdownTag( '@test', /##/g, fn );
                const evt = new MarkdownEvent( MarkdownEvent.PARSE, source, source );
                const listeners = getMarkdownEventParseListeners( plugin );
                expect( listeners ).toHaveLength( 1 );
                listeners[0]( evt );
                expect( fn ).toHaveBeenCalledTimes( expectedMaps.length );
                expectedMaps.forEach( ( m, i ) => {
                    expect( fn.mock.calls[i][1]() ).toStartWith( `"${m}" ` );
                    expect( fn.mock.calls[i][1]() ).toEndWith( 'in expansion of @test)' );
                } );
            } );
        } );
    } );
    describe( 'Multi', () => {
        describe( 'Source map', () => {
            const short = '=';
            const long = '='.repeat( 20 );
            it.each( [
                [ 'hello {@tag1} world', 'Simple', [
                    { tag: '@tag1',       maps: [ { map: 'hello.ts:1:7', ctxs: [ '@tag1' ] } ], replacer: jest.fn().mockReturnValueOnce( short ) },
                ]],
                [ 'hello {@tag1} world', 'Overlapping same size', [
                    { tag: '@tag1',       maps: [ { map: 'hello.ts:1:7', ctxs: [ '@tag1' ] } ], replacer: jest.fn().mockReturnValueOnce( '{@tag2}' ) },
                    { tag: '@tag2',       maps: [ { map: 'hello.ts:1:7', ctxs: [ '@tag1', '@tag2' ] } ], replacer: jest.fn().mockReturnValueOnce( short ) },
                ]],
                [ 'hello {@tag1} world', 'Overlapping diff size', [
                    { tag: '@tag1',       maps: [ { map: 'hello.ts:1:7', ctxs: [ '@tag1' ] } ], replacer: jest.fn().mockReturnValueOnce( '{@tag2long}' ) },
                    { tag: '@tag2long',   maps: [ { map: 'hello.ts:1:7', ctxs: [ '@tag1', '@tag2long' ] } ], replacer: jest.fn().mockReturnValueOnce( short ) },
                ]],
                [ 'hello {@tag1} world {@tag1} foo {@tag1} bar', 'Multiple longer', [
                    { tag: '@tag1',       maps: [
                        { map: 'hello.ts:1:7', ctxs: [ '@tag1' ] },
                        { map: 'hello.ts:1:21', ctxs: [ '@tag1' ] },
                        { map: 'hello.ts:1:33', ctxs: [ '@tag1' ] },
                    ], replacer: jest.fn().mockReturnValue( long ) },
                ]],
                [ 'hello {@tag1} world {@tag1} foo {@tag1} bar', 'Multiple shorter', [
                    { tag: '@tag1',       maps: [
                        { map: 'hello.ts:1:7', ctxs: [ '@tag1' ] },
                        { map: 'hello.ts:1:21', ctxs: [ '@tag1' ] },
                        { map: 'hello.ts:1:33', ctxs: [ '@tag1' ] },
                    ], replacer: jest.fn().mockReturnValue( short ) },
                ]],
                [ 'hello {@tag1} world {@tag2} foo', 'Subsequent', [
                    { tag: '@tag1',       maps: [ { map: 'hello.ts:1:7', ctxs: [ '@tag1' ] } ], replacer: jest.fn().mockReturnValueOnce( long ) },
                    { tag: '@tag2',       maps: [ { map: 'hello.ts:1:21', ctxs: [ '@tag2' ] } ], replacer: jest.fn().mockReturnValueOnce( short ) },
                ]],
                [ 'hello {@tag1} world {@tag2} foo', '1=>2 + 2', [
                    { tag: '@tag1',       maps: [ { map: 'hello.ts:1:7', ctxs: [ '@tag1' ] } ], replacer: jest.fn().mockReturnValueOnce( '{@tag2}' ) },
                    { tag: '@tag2',       maps: [
                        { map: 'hello.ts:1:7', ctxs: [ '@tag1', '@tag2' ] },
                        { map: 'hello.ts:1:21', ctxs: [ '@tag2' ] },
                    ], replacer: jest.fn().mockReturnValue( short ) },
                ]],
                [ 'hello \n{@tag2}\n{@tag1} world ', '2 + 1=>2', [
                    { tag: '@tag1',       maps: [ { map: 'hello.ts:3:1', ctxs: [ '@tag1' ] } ], replacer: jest.fn().mockReturnValueOnce( '{@tag2}' ) },
                    { tag: '@tag2',       maps: [
                        { map: 'hello.ts:2:1', ctxs: [ '@tag2' ] },
                        { map: 'hello.ts:3:1', ctxs: [ '@tag1', '@tag2' ] },
                    ], replacer: jest.fn().mockReturnValue( short ) },
                ]],
                [ 'hello\n{@tag1} world\n{@tag2}\n{@tag1} foo', '1=>2 + 2 + 1=>2', [
                    { tag: '@tag1',       maps: [
                        { map: 'hello.ts:2:1', ctxs: [ '@tag1' ] },
                        { map: 'hello.ts:4:1', ctxs: [ '@tag1' ] },
                    ], replacer: jest.fn().mockReturnValue( 'Hello {@tag2}' ) },
                    { tag: '@tag2',       maps: [
                        { map: 'hello.ts:2:1', ctxs: [ '@tag1', '@tag2' ] },
                        { map: 'hello.ts:3:1', ctxs: [ '@tag2' ] },
                        { map: 'hello.ts:4:1', ctxs: [ '@tag1', '@tag2' ] },
                    ], replacer: jest.fn().mockReturnValue( short ) },
                ]],
            ] )( 'should match %j with sourcemaps (%s) %#', ( source, _label, binds ) => {
                mockCurrentPage( 'Test', 'hello.ts', 1, 1 );
                binds.forEach( b => replacer.registerMarkdownTag( b.tag as any, null, b.replacer ) );
                const evt = new MarkdownEvent( MarkdownEvent.PARSE, source, source );
                const listeners = getMarkdownEventParseListeners( plugin );
                expect( listeners ).toHaveLength( binds.length );
                binds.forEach( ( _b, i ) => listeners[i]( evt ) );
                const mapped = binds.map( b => {
                    expect( b.replacer, `Replacer ${b.tag}` ).toHaveBeenCalledTimes( b.maps.length );
                    return b.maps
                        .map( ( m, j ) => [
                            `Replacer ${b.tag}, call ${j}`,
                            b.replacer.mock.calls[j][1](),
                            `"${m.map}" (in expansion of ${m.ctxs.join( ' ⇒ ' )})`,
                        ] as const );
                } ).flat( 1 );
                expect( Object.fromEntries( mapped.map( ( [ l, v ] ) => [ l, v ] ) ) )
                    .toEqual( Object.fromEntries( mapped.map( ( [ l, , a ] ) => [ l, a ] ) ) );
            } );
        } );
    } );
} );