packages/pluginutils/src/text-replacers/markdown/markdown-replacer.spec.ts
/* 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 ] ) ) );
} );
} );
} );
} );