/* eslint-disable @typescript-eslint/unbound-method */ import '~/test/setup' // setupFilesAfterEnv doesn't work here import arrify from 'arrify' import { Document, HTMLElement } from 'nodom' import buildElementCreator from '@/elementCreator' import NbRenderer, { NbRendererOpts } from '@/renderer' import { DisplayData, MimeBundle, MultilineString, Notebook } from '@/nbformat' // eslint-disable-next-line @typescript-eslint/no-unused-vars import { Anything } from '~/test/support/matchers/toMatchElement' import { mockLastResult, mockResults } from '~/test/support/helpers' import * as fixtures from './support/fixtures/notebook' const document = new Document() describe('built renderer', () => { const elementCreator = buildElementCreator(document.createElement.bind(document), '') const markdownRenderer = jest.fn(x => `${x}`) const ansiCodesRenderer = jest.fn(x => `${x}`) const codeHighlighter = jest.fn(x => `${x}`) const notebook = fixtures.Notebook const dataRenderers = { 'text/custom': rendererMock('DisplayData'), } const rendererOpts: NbRendererOpts = { ansiCodesRenderer, codeHighlighter, markdownRenderer, dataRenderers, } let renderer: NbRenderer beforeEach(() => { renderer = new NbRenderer(elementCreator, rendererOpts) }) describe('.render', () => { beforeEach(() => { renderer.renderCell = rendererMock('Cell') }) it('returns div.notebook', () => { expect( renderer.render({ ...notebook, cells: [] }) ).toHtmlEqual(
) }) it('returns element with $cells converted using .renderCell() as the children', () => { const result = renderer.render(notebook) notebook.cells.forEach((cell, idx) => { expect( renderer.renderCell ).toHaveBeenNthCalledWith(idx + 1, cell, notebook) }) expect( result.children ).toHtmlEqual(mockResults(renderer.renderCell)) }) }) describe('.renderCell', () => { describe.each([ 'renderCodeCell', 'renderMarkdownCell', 'renderRawCell', ] as const)('with %s', (funcName) => { const type = funcName.replace('render', '') const cell = (fixtures as any)[type] it(`returns result of calling .${funcName}() with the given cell`, () => { const expected = stubElement(type) const rendererFunc = (renderer as any)[funcName] = jest.fn(() => expected) expect( renderer.renderCell(cell, notebook) ).toBe(expected) expect( rendererFunc ).toBeCalledWith(cell, notebook) }) }) describe('with unsupported cell type', () => { const cell = { cell_type: 'whatever', metadata: {}, } as any it('returns div with comment "Unsupported cell type"', () => { expect( renderer.renderCell(cell, notebook) ).toHtmlEqual(
{{__html: '' }}
) }) }) }) describe('.renderMarkdownCell', () => { eachMultilineVariant(fixtures.MarkdownCell, 'source', (cell) => { const source = join(cell.source) it('returns section.cell.markdown-cell with the $source converted using markdownRenderer() as content', () => { expect( renderer.renderMarkdownCell(cell, notebook) ).toHtmlEqual(
{{__html: mockLastResult(markdownRenderer) }}
) expect( markdownRenderer ).toBeCalledWith(source) }) }) }) describe('.renderRawCell', () => { eachMultilineVariant(fixtures.RawCell, 'source', (cell) => { it('returns section.cell.raw-cell with the $source as content', () => { expect( renderer.renderRawCell(cell, notebook) ).toHtmlEqual(
{{__html: join(cell.source) }}
) }) }) }) describe('.renderCodeCell', () => { const cell = fixtures.CodeCell let result: HTMLElement beforeEach(() => { renderer.renderSource = rendererMock('Source') renderer.renderOutput = rendererMock('Output') result = renderer.renderCodeCell(cell, notebook) }) it('returns section.cell.code-cell', () => { expect( result ).toMatchElement(
) }) describe('with non-empty $source', () => { it('returns element with $source rendered using .renderSource() as children[0]', () => { expect( renderer.renderSource ).toBeCalledWith(cell, notebook) expect( result.children[0] ).toHtmlEqual(mockLastResult(renderer.renderSource)!) }) }) describe('with empty $source', () => { beforeEach(() => { result = renderer.renderCodeCell({ ...cell, source: [] }, notebook) }) it('returns element with empty div as children[0]', () => { expect( result.children[0] ).toHtmlEqual(
) }) }) it('returns element with $outputs rendered using .renderOutput() as children[1+]', () => { cell.outputs.forEach((output, idx) => { expect( renderer.renderOutput ).toHaveBeenNthCalledWith(idx + 1, output, cell) }) expect( result.children.slice(1) ).toHtmlEqual(mockResults(renderer.renderOutput)) }) }) describe('.renderSource', () => { const cell = fixtures.CodeCell const notebookLang = notebook.metadata.language_info!.name let result: HTMLElement beforeEach(() => { result = renderer.renderSource(cell, notebook) }) it('returns div > pre > code', () => { expect( result ).toMatchElement(
            
          
, { ignoreAttrs: true } ) }) it("calls the codeHighlighter() with the notebook's language", () => { expect( codeHighlighter ).toBeCalledWith(expect.anything(), notebookLang) }) describe('outer div', () => { it('has class "source input"', () => { expect( result.className ).toBe('source input') }) describe('when the cell has non-null execution_count', () => { const myCell = { ...cell, execution_count: 2 } it('has data-execution-count and data-prompt-number attributes', () => { const result = renderer.renderSource(myCell, notebook) expect( result.attributes ).toMatchObject({ 'data-execution-count': String(myCell.execution_count), 'data-prompt-number': String(myCell.execution_count), }) }) }) describe('when the cell has null execution_count', () => { const myCell = { ...cell, execution_count: null } it('has data-execution-count and data-prompt-number attributes', () => { const result = renderer.renderSource(myCell, notebook) expect( result.attributes ) .not.toHaveProperty('data-execution-count') .not.toHaveProperty('data-prompt-number') }) }) }) describe('inner code', () => { let codeEl: HTMLElement beforeEach(() => { codeEl = renderer.renderSource(cell, notebook).firstChild!.firstChild as HTMLElement }) it("has class lang- where lang is the notebook's language", () => { expect( codeEl.className ).toBe(`lang-${notebookLang}`) }) it("has attribute data-language with the notebook's language", () => { expect( codeEl.getAttribute('data-language') ).toBe(notebookLang) }) it('has $source converted using codeHighlighter() as the innerHTML', () => { expect( codeHighlighter ).toBeCalledWith(join(cell.source), expect.anything()) expect( codeEl.innerHTML ).toEqual(mockLastResult(codeHighlighter)) }) }) describe('when the notebook does not have metadata.language_info.name', () => { const myNotebook: Notebook = { ...notebook, metadata: {} } const notebookLang = 'python' it('uses the default language: python', () => { const result = renderer.renderSource(cell, myNotebook) const codeEl = result.firstChild!.firstChild as HTMLElement expect( codeEl.getAttribute('data-language') ).toBe(notebookLang) expect( codeEl.classList ).toContain(`lang-${notebookLang}`) expect( codeHighlighter ).toBeCalledWith(expect.anything(), notebookLang) }) }) }) describe('.renderOutput', () => { const cell = { ...fixtures.CodeCell, execution_count: null } describe.each([ 'renderDisplayData', 'renderExecuteResult', 'renderStream', 'renderError', ] as const)('with %s output', (funcName) => { const type = funcName.replace('render', '') const output = (fixtures as any)[type] let result: HTMLElement beforeEach(() => { renderer[funcName] = rendererMock(type) result = renderer.renderOutput(output, cell) }) it('returns div.output', () => { expect( result ).toMatchElement(
) }) it(`returns element with the output rendered using .${funcName}() as the only child`, () => { expect( renderer[funcName] ).toBeCalledWith(output) expect( result.children ).toHtmlEqual([mockLastResult(renderer[funcName])!]) }) describe('when the cell has non-null execution_count', () => { const cell = { ...fixtures.CodeCell, execution_count: 2 } it('returns element with attributes data-execution-count and data-prompt-number', () => { const result = renderer.renderOutput(output, cell) expect( result.attributes ).toMatchObject({ 'data-execution-count': String(cell.execution_count), 'data-prompt-number': String(cell.execution_count), }) }) }) }) describe('with unsupported output type', () => { const output = { output_type: 'whatever', } as any const cell = { ...fixtures.CodeCell, execution_count: null, output: [output], } it('returns div with comment "Unsupported output type"', () => { expect( renderer.renderOutput(output, cell) ).toHtmlEqual(
{{__html: '' }}
) }) }) }) describe('.renderDisplayData', () => { function displayDataWith (data: MimeBundle): DisplayData { return { ...fixtures.DisplayData, data } } function withMimeData ( mimeType: string, value: MultilineString, fn: (output: DisplayData, value: MultilineString) => void, ): void { describe(mimeType, () => { describe('as a string', () => { const data = join(value) fn(displayDataWith({ [mimeType]: data }), data) }) describe('as an array', () => { const data = arrify(value) fn(displayDataWith({ [mimeType]: data }), data) }) }) } describe('with single data of unsupported MIME type', () => { const displayData = displayDataWith({ 'text/non-sense': 'whaat' }) it('returns div.empty-output', () => { expect( renderer.renderDisplayData(displayData) ).toHtmlEqual(
) }) }) describe('with single data of built-in MIME type', () => { ;['image/png', 'image/jpeg'].forEach(mimeType => { withMimeData(mimeType, ['aW1hZ2Ug\n', 'ZGF0YQ=='], (output) => { it('returns img.image-output with the data in the src attribute', () => { expect( renderer.renderDisplayData(output) ).toHtmlEqual( ) }) }) }) // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion ;([ /* mimeType | classes */ ['image/svg+xml', ['svg-output'] ], ['text/svg+xml' , ['svg-output'] ], ['text/html' , ['html-output'] ], ['text/latex' , ['latex-output']], ] as Array<[string, string[]]>).forEach(([mimeType, classes]) => { withMimeData(mimeType, 'data', (output, data) => { it(`returns div${classes.map(x => `.${x}`)} with the data as content`, () => { expect( renderer.renderDisplayData(output) ).toHtmlEqual(
{{__html: join(data) }}
) }) }) }) withMimeData('text/markdown', ['Lorem\n', 'ipsum'], (output, data) => { it('returns div.html-output with the data converted using markdownRenderer() as content', () => { expect( renderer.renderDisplayData(output) ).toHtmlEqual(
{{__html: mockLastResult(markdownRenderer) }}
) expect( markdownRenderer ).toBeCalledWith(join(data)) }) }) withMimeData('text/plain', '>_<', (output) => { it('returns pre.text-output with html-escaped data', () => { expect( renderer.renderDisplayData(output) ).toHtmlEqual(
{ '>_<' }
) }) }) withMimeData('application/javascript', 'alert("Hello &!")', (output, data) => { it('returns script with the data', () => { expect( renderer.renderDisplayData(output) ).toHtmlEqual( ) }) }) }) describe('with single data of non-built-in MIME type', () => { withMimeData('text/custom', 'Lorem ipsum', (output, data) => { it('renders the data using the associated external renderer', () => { expect( renderer.renderDisplayData(output) ).toHtmlEqual( mockLastResult(dataRenderers['text/custom'])! ) expect( dataRenderers['text/custom'] ).toBeCalledWith(join(data)) }) }) }) describe('with multiple data', () => { const mimeBundle = { 'text/plain': 'Lorem ipsum', 'text/html': '

Lorem ipsum

', 'text/unknown': '???', } const output = displayDataWith(mimeBundle) it('renders the data of the MIME type with a higher priority', () => { expect( renderer.renderDisplayData(output) ).toHtmlEqual(
{{__html: mimeBundle['text/html'] }}
) }) test('the provided dataRenderers have higher priority than the built-ins', () => { const mimeBundle = { 'text/custom': '>>Lorem ipsum<<', 'text/html': '

Lorem ipsum

', } const output = displayDataWith(mimeBundle) expect( renderer.renderDisplayData(output) ).toHtmlEqual( mockLastResult(dataRenderers['text/custom'])! ) }) }) describe('when built with external renderer for the built-in type', () => { const dataRenderer = rendererMock('DisplayData') beforeEach(() => { renderer = new NbRenderer(elementCreator, { ...rendererOpts, dataRenderers: { 'text/plain': dataRenderer }, }) }) it('renders the data using the external renderer instead of the built-in', () => { const data = 'allons-y!' const output = displayDataWith({ 'text/plain': [data] }) expect( renderer.renderDisplayData(output) ).toBe(mockLastResult(dataRenderer)) expect( dataRenderer ).toBeCalledWith(data) }) }) }) describe('.renderError', () => { const error = fixtures.Error const traceback = error.traceback.join('\n') it('returns pre.error.pyerr with inner $traceback converted using ansiCodesRenderer', () => { expect( renderer.renderError(error) ).toHtmlEqual(
          {{__html: mockLastResult(ansiCodesRenderer) }}
        
) expect( ansiCodesRenderer ).toBeCalledWith(traceback) }) }) describe('.renderStream', () => { eachMultilineVariant(fixtures.Stream, 'text', (stream) => { const text = join(stream.text) it('returns pre.$name with inner $text converted using ansiCodesRenderer', () => { expect( renderer.renderStream(stream) ).toHtmlEqual(
            {{__html: mockLastResult(ansiCodesRenderer) }}
          
) expect( ansiCodesRenderer ).toBeCalledWith(text) }) }) }) }) function eachMultilineVariant ( obj: T, propName: K, fn: (obj: T) => void, ): void { const propValue = obj[propName] describe(`when ${propName} is an array`, () => fn({ ...obj, [propName]: arrify(propValue) })) describe(`when ${propName} is a string`, () => fn({ ...obj, [propName]: join(propValue) })) } const genStubElement = jest.fn((type: string) => { const id = genStubElement.mock.calls.filter(args => args[0] === type).length const el = document.createElement('stub') el.setAttribute('type', type) el.setAttribute('id', id.toString()) return el }) function stubElement (type: string): HTMLElement { return genStubElement(type) } function rendererMock (type: string) { return jest.fn(() => stubElement(type)) } function join (input: MultilineString): string { return Array.isArray(input) ? input.join('') : input }