Browse Source

Merge pull request #6731 from ckeditor/i/6685

Internal (table): Added helpers for debugging tables and preparing automatic tests. Closes #6685.
Maciej 5 years ago
parent
commit
f88556aca2

+ 2 - 1
packages/ckeditor5-table/package.json

@@ -29,7 +29,8 @@
     "@ckeditor/ckeditor5-paragraph": "^19.0.0",
     "@ckeditor/ckeditor5-typing": "^19.0.0",
     "@ckeditor/ckeditor5-undo": "^19.0.0",
-    "@ckeditor/ckeditor5-utils": "^19.0.0"
+    "@ckeditor/ckeditor5-utils": "^19.0.0",
+    "json-diff": "^0.5.4"
   },
   "engines": {
     "node": ">=8.0.0",

+ 348 - 0
packages/ckeditor5-table/tests/_utils-tests/table-ascii-art.js

@@ -0,0 +1,348 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import { createTableAsciiArt, modelTable, prepareModelTableInput, prettyFormatModelTableInput } from '../_utils/utils';
+import TableEditing from '../../src/tableediting';
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'table ascii-art and model helpers', () => {
+	let editor, model, modelRoot;
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ TableEditing, Paragraph ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+
+				model = editor.model;
+				modelRoot = model.document.getRoot();
+			} );
+	} );
+
+	afterEach( () => {
+		editor.destroy();
+	} );
+
+	describe( 'for the table with only one cell', () => {
+		let table, tableData;
+
+		beforeEach( () => {
+			tableData = [
+				[ '00' ]
+			];
+
+			setModelData( model, modelTable( tableData ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should create proper ascii-art', () => {
+			const asciiArt = createTableAsciiArt( table );
+
+			expect( asciiArt ).to.equal( [
+				'+----+',
+				'| 00 |',
+				'+----+'
+			].join( '\n' ) );
+		} );
+
+		it( 'should create proper tableData', () => {
+			const modelData = prepareModelTableInput( table );
+			const modelDataString = prettyFormatModelTableInput( modelData );
+
+			expect( modelData ).to.deep.equal( tableData );
+
+			assertSameCodeString( modelDataString,
+				`[
+					[ '00' ]
+				]`
+			);
+		} );
+	} );
+
+	describe( 'for the table containing only one row', () => {
+		let table, tableData;
+
+		beforeEach( () => {
+			tableData = [
+				[ '00', '01' ]
+			];
+
+			setModelData( model, modelTable( tableData ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should create proper ascii-art', () => {
+			const asciiArt = createTableAsciiArt( table );
+
+			expect( asciiArt ).to.equal( [
+				'+----+----+',
+				'| 00 | 01 |',
+				'+----+----+'
+			].join( '\n' ) );
+		} );
+
+		it( 'should create proper tableData', () => {
+			const modelData = prepareModelTableInput( table );
+			const modelDataString = prettyFormatModelTableInput( modelData );
+
+			expect( modelData ).to.deep.equal( tableData );
+
+			assertSameCodeString( modelDataString,
+				`[
+					[ '00', '01' ]
+				]`
+			);
+		} );
+	} );
+
+	describe( 'for the table containing only one column', () => {
+		let table, tableData;
+
+		beforeEach( () => {
+			tableData = [
+				[ '00' ],
+				[ '10' ]
+			];
+
+			setModelData( model, modelTable( tableData ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should create proper ascii-art', () => {
+			const asciiArt = createTableAsciiArt( table );
+
+			expect( asciiArt ).to.equal( [
+				'+----+',
+				'| 00 |',
+				'+----+',
+				'| 10 |',
+				'+----+'
+			].join( '\n' ) );
+		} );
+
+		it( 'should create proper tableData', () => {
+			const modelData = prepareModelTableInput( table );
+			const modelDataString = prettyFormatModelTableInput( modelData );
+
+			expect( modelData ).to.deep.equal( tableData );
+
+			assertSameCodeString( modelDataString,
+				`[
+					[ '00' ], 
+					[ '10' ]
+				]`
+			);
+		} );
+	} );
+
+	describe( 'for the table containing two rows and two columns', () => {
+		let table, tableData;
+
+		beforeEach( () => {
+			tableData = [
+				[ '00', '01' ],
+				[ '10', '11' ]
+			];
+
+			setModelData( model, modelTable( tableData ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should create proper ascii-art', () => {
+			const asciiArt = createTableAsciiArt( table );
+
+			expect( asciiArt ).to.equal( [
+				'+----+----+',
+				'| 00 | 01 |',
+				'+----+----+',
+				'| 10 | 11 |',
+				'+----+----+'
+			].join( '\n' ) );
+		} );
+
+		it( 'should create proper tableData', () => {
+			const modelData = prepareModelTableInput( table );
+			const modelDataString = prettyFormatModelTableInput( modelData );
+
+			expect( modelData ).to.deep.equal( tableData );
+
+			assertSameCodeString( modelDataString,
+				`[
+					[ '00', '01' ],
+					[ '10', '11' ]
+				]`
+			);
+		} );
+	} );
+
+	describe( 'for the table containing column and row-spanned cells', () => {
+		let table, tableData;
+
+		beforeEach( () => {
+			tableData = [
+				[ { contents: '00', colspan: 2, rowspan: 2 }, { contents: '02', rowspan: 2 }, '03' ],
+				[ '13' ],
+				[ { contents: '20', colspan: 2 }, { contents: '22', colspan: 2, rowspan: 2 } ],
+				[ '30', '31' ]
+			];
+
+			setModelData( model, modelTable( structuredClone( tableData ) ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should create proper ascii-art', () => {
+			const asciiArt = createTableAsciiArt( table );
+
+			expect( asciiArt ).to.equal( [
+				'+----+----+----+----+',
+				'| 00      | 02 | 03 |',
+				'+         +    +----+',
+				'|         |    | 13 |',
+				'+----+----+----+----+',
+				'| 20      | 22      |',
+				'+----+----+         +',
+				'| 30 | 31 |         |',
+				'+----+----+----+----+'
+			].join( '\n' ) );
+		} );
+
+		it( 'should create proper tableData', () => {
+			const modelData = prepareModelTableInput( table );
+			const modelDataString = prettyFormatModelTableInput( modelData );
+
+			expect( modelData ).to.deep.equal( tableData );
+
+			assertSameCodeString( modelDataString,
+				`[
+					[ { contents: '00', colspan: 2, rowspan: 2 }, { contents: '02', rowspan: 2 }, '03' ],
+					[ '13' ],
+					[ { contents: '20', colspan: 2 }, { contents: '22', colspan: 2, rowspan: 2 } ],
+					[ '30', '31' ]
+				]`
+			);
+		} );
+	} );
+
+	describe( 'for the table containing larger column and row-spanned cells', () => {
+		let table, tableData;
+
+		beforeEach( () => {
+			tableData = [
+				[ '00', { contents: '01', rowspan: 2 }, { contents: '02', rowspan: 3 }, { contents: '03', rowspan: 4 } ],
+				[ '10' ],
+				[ { contents: '20', colspan: 2 } ],
+				[ { contents: '30', colspan: 3 } ],
+				[ { contents: '40', colspan: 4 } ]
+			];
+
+			setModelData( model, modelTable( structuredClone( tableData ) ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should create proper ascii-art', () => {
+			const asciiArt = createTableAsciiArt( table );
+
+			expect( asciiArt ).to.equal( [
+				'+----+----+----+----+',
+				'| 00 | 01 | 02 | 03 |',
+				'+----+    +    +    +',
+				'| 10 |    |    |    |',
+				'+----+----+    +    +',
+				'| 20      |    |    |',
+				'+----+----+----+    +',
+				'| 30           |    |',
+				'+----+----+----+----+',
+				'| 40                |',
+				'+----+----+----+----+'
+			].join( '\n' ) );
+		} );
+
+		it( 'should create proper tableData', () => {
+			const modelData = prepareModelTableInput( table );
+			const modelDataString = prettyFormatModelTableInput( modelData );
+
+			expect( modelData ).to.deep.equal( tableData );
+
+			assertSameCodeString( modelDataString,
+				`[
+					[ '00', { contents: '01', rowspan: 2 }, { contents: '02', rowspan: 3 }, { contents: '03', rowspan: 4 } ],
+					[ '10' ],
+					[ { contents: '20', colspan: 2 } ],
+					[ { contents: '30', colspan: 3 } ],
+					[ { contents: '40', colspan: 4 } ]
+				]`
+			);
+		} );
+	} );
+
+	describe( 'with cells\' content not matching cell\'s coordinates', () => {
+		let table, tableData;
+
+		beforeEach( () => {
+			tableData = [
+				[ 'x', 'x' ],
+				[ 'x', 'x' ]
+			];
+
+			setModelData( model, modelTable( tableData ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should create proper ascii-art', () => {
+			const asciiArt = createTableAsciiArt( table );
+
+			expect( asciiArt ).to.equal( [
+				'+----+----+',
+				'| 00 | 01 |',
+				'+----+----+',
+				'| 10 | 11 |',
+				'+----+----+'
+			].join( '\n' ) );
+		} );
+
+		it( 'should create proper tableData', () => {
+			const modelData = prepareModelTableInput( table );
+			const modelDataString = prettyFormatModelTableInput( modelData );
+
+			tableData = [
+				[ '00', '01' ],
+				[ '10', '11' ]
+			];
+
+			expect( modelData ).to.deep.equal( tableData );
+
+			assertSameCodeString( modelDataString,
+				`[
+					[ '00', '01' ],
+					[ '10', '11' ]
+				]`
+			);
+		} );
+	} );
+
+	function structuredClone( data ) {
+		return JSON.parse( JSON.stringify( data ) );
+	}
+
+	function assertSameCodeString( actual, expected ) {
+		expect( trimLines( actual ) ).to.equal( trimLines( expected ) );
+	}
+
+	function trimLines( string ) {
+		return string.replace( /^\s+|\s+$/gm, '' );
+	}
+} );

+ 119 - 0
packages/ckeditor5-table/tests/_utils/utils.js

@@ -14,6 +14,7 @@ import {
 import upcastTable, { upcastTableCell } from '../../src/converters/upcasttable';
 import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import TableWalker from '../../src/tablewalker';
 
 const WIDGET_TABLE_CELL_CLASS = 'ck-editor__editable ck-editor__nested-editable';
 const BORDER_REG_EXP = /[\s\S]+/;
@@ -488,3 +489,121 @@ function getClassToSet( attributes ) {
 		.sort()
 		.join( ' ' );
 }
+
+/**
+ * Returns ascii-art visualization of the table.
+ *
+ * @param {module:engine/model/element~Element} table The table model element.
+ * @returns {String}
+ */
+export function createTableAsciiArt( table ) {
+	const tableMap = [ ...new TableWalker( table, { includeSpanned: true } ) ];
+
+	const { row: lastRow, column: lastColumn } = tableMap[ tableMap.length - 1 ];
+	const columns = lastColumn + 1;
+
+	let result = '';
+
+	for ( let row = 0; row <= lastRow; row++ ) {
+		let gridLine = '';
+		let contentLine = '';
+
+		for ( let column = 0; column <= lastColumn; column++ ) {
+			const cellInfo = tableMap[ row * columns + column ];
+
+			if ( cellInfo.rowspan > 1 || cellInfo.colspan > 1 ) {
+				for ( let subRow = row; subRow < row + cellInfo.rowspan; subRow++ ) {
+					for ( let subColumn = column; subColumn < column + cellInfo.colspan; subColumn++ ) {
+						const subCellInfo = tableMap[ subRow * columns + subColumn ];
+
+						subCellInfo.isColSpan = subColumn > column;
+						subCellInfo.isRowSpan = subRow > row;
+					}
+				}
+			}
+
+			gridLine += !cellInfo.isColSpan || !cellInfo.isRowSpan ? '+' : ' ';
+			gridLine += !cellInfo.isRowSpan ? '----' : '    ';
+
+			contentLine += !cellInfo.isColSpan ? '|' : ' ';
+			contentLine += !cellInfo.isColSpan && !cellInfo.isRowSpan ? ` ${ cellInfo.row }${ cellInfo.column } ` : '    ';
+
+			if ( column == lastColumn ) {
+				gridLine += '+';
+				contentLine += '|';
+			}
+		}
+		result += gridLine + '\n';
+		result += contentLine + '\n';
+
+		if ( row == lastRow ) {
+			result += `+${ '----+'.repeat( columns ) }`;
+		}
+	}
+
+	return result;
+}
+
+/**
+ * Generates input data for `modelTable` helper method.
+ *
+ * @param {module:engine/model/element~Element} table The table model element.
+ * @returns {Array.<Array.<String|Object>>}
+ */
+export function prepareModelTableInput( table ) {
+	const result = [];
+	let row = [];
+
+	for ( const cellInfo of new TableWalker( table, { includeSpanned: true } ) ) {
+		if ( cellInfo.column == 0 && cellInfo.row > 0 ) {
+			result.push( row );
+			row = [];
+		}
+
+		if ( cellInfo.isSpanned ) {
+			continue;
+		}
+
+		const contents = `${ cellInfo.row }${ cellInfo.column }`;
+
+		if ( cellInfo.colspan > 1 || cellInfo.rowspan > 1 ) {
+			row.push( {
+				contents,
+				...( cellInfo.colspan > 1 ? { colspan: cellInfo.colspan } : null ),
+				...( cellInfo.rowspan > 1 ? { rowspan: cellInfo.rowspan } : null )
+			} );
+		} else {
+			row.push( contents );
+		}
+	}
+
+	result.push( row );
+
+	return result;
+}
+
+/**
+ * Pretty formats `modelTable` input data.
+ *
+ * @param {Array.<Array.<String|Object>>} data
+ * @returns {String}
+ */
+export function prettyFormatModelTableInput( data ) {
+	const rowsStringified = data.map( row => {
+		const cellsStringified = row.map( cell => {
+			if ( typeof cell == 'string' ) {
+				return `'${ cell }'`;
+			}
+
+			const fieldsStringified = Object.entries( cell ).map( ( [ key, value ] ) => {
+				return `${ key }: ${ typeof value == 'string' ? `'${ value }'` : value }`;
+			} );
+
+			return `{ ${ fieldsStringified.join( ', ' ) } }`;
+		} );
+
+		return '\t[ ' + cellsStringified.join( ', ' ) + ' ]';
+	} );
+
+	return `[\n${ rowsStringified.join( ',\n' ) }\n]`;
+}

+ 52 - 0
packages/ckeditor5-table/tests/manual/tablemocking.html

@@ -0,0 +1,52 @@
+<style>
+	body {
+		font-family: Helvetica, Arial, sans-serif;
+		font-size: 14px;
+	}
+	textarea#model-data {
+		white-space: pre;
+		font-family: monospace;
+		display: block;
+		width: 100%;
+		height: 100px;
+		box-sizing: border-box;
+		margin: 10px 0;
+	}
+	pre,code {
+		font-size: 11px;
+		font-family: Menlo, Consolas, Lucida Console, Courier New, dejavu sans mono, monospace;
+	}
+	.diff-add {
+		color: hsl( 120, 70%, 35% );
+	}
+	.diff-del {
+		color: hsl( 0, 80%, 45% );
+	}
+	#input-status {
+		color: hsl( 0, 90%, 50% );
+	}
+</style>
+
+<div style="margin-bottom: 10px;">
+	<label for="model-data">Table data as expected by <a href="https://github.com/ckeditor/ckeditor5-table/blob/1004f9106110be9de125825afd491a1618b71271/tests/_utils/utils.js#L48">modelTable</a> helper function:</label>
+	<textarea id="model-data">
+[
+	[ '00', '01', '02', '03', '04' ],
+	[ '10', '11', '12', '13', '14' ],
+	[ '20', '21', '22', '23', '24' ],
+	[ '30', '31', '32', '33', '34' ],
+	[ '40', '41', '42', '43', '44' ]
+]
+	</textarea>
+
+	<button type="button" id="clear-content">Clear editor</button>
+	<button type="button" id="set-model-data">↓ Set model data ↓</button>
+	<button type="button" id="get-model-data">↑ Get model data ↑</button>
+
+	<span id="input-status"></span>
+</div>
+
+<div id="editor">
+</div>
+
+<pre id="ascii-art"></pre>

+ 114 - 0
packages/ckeditor5-table/tests/manual/tablemocking.js

@@ -0,0 +1,114 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, window, document */
+
+import { createTableAsciiArt, modelTable, prepareModelTableInput, prettyFormatModelTableInput } from '../_utils/utils';
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { diffString } from 'json-diff';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet ],
+		toolbar: [
+			'insertTable', 'undo', 'redo'
+		],
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		const asciiOut = document.getElementById( 'ascii-art' );
+		const modelData = document.getElementById( 'model-data' );
+
+		document.getElementById( 'clear-content' ).addEventListener( 'click', () => {
+			editor.setData( '' );
+		} );
+
+		document.getElementById( 'set-model-data' ).addEventListener( 'click', () => {
+			updateInputStatus();
+
+			const inputModelData = parseModelData( modelData.value );
+			setModelData( editor.model, inputModelData ? modelTable( inputModelData ) : '' );
+		} );
+
+		document.getElementById( 'get-model-data' ).addEventListener( 'click', () => {
+			updateInputStatus();
+
+			const table = findTable( editor );
+			modelData.value = table ? prettyFormatModelTableInput( prepareModelTableInput( table ) ) : '';
+
+			updateAsciiAndDiff();
+		} );
+
+		editor.model.document.on( 'change:data', updateAsciiAndDiff );
+		updateAsciiAndDiff();
+
+		function updateAsciiAndDiff() {
+			const table = findTable( editor );
+
+			if ( !table ) {
+				asciiOut.innerText = '-- table not found --';
+				return;
+			}
+
+			const inputModelData = parseModelData( modelData.value );
+			const currentModelData = prepareModelTableInput( table );
+
+			const diffOutput = inputModelData ? diffString( inputModelData, currentModelData, {
+				theme: {
+					' ': string => string,
+					'+': string => `<span class="diff-add">${ string }</span>`,
+					'-': string => `<span class="diff-del">${ string }</span>`
+				}
+			} ) : '-- no input --';
+
+			asciiOut.innerHTML = createTableAsciiArt( table ) + '\n\n' +
+				'Diff: input vs post-fixed model:\n' + ( diffOutput ? diffOutput : '-- no differences --' );
+		}
+
+		function findTable( editor ) {
+			const range = editor.model.createRangeIn( editor.model.document.getRoot() );
+
+			for ( const element of range.getItems() ) {
+				if ( element.is( 'table' ) ) {
+					return element;
+				}
+			}
+
+			return null;
+		}
+
+		function parseModelData( string ) {
+			if ( !string.trim() ) {
+				return null;
+			}
+
+			const jsonString = string
+				.replace( /'/g, '"' )
+				.replace( /([a-z0-9$_]+)\s*:/gi, '"$1":' );
+
+			try {
+				return JSON.parse( jsonString );
+			} catch ( error ) {
+				updateInputStatus( error.message );
+			}
+
+			return null;
+		}
+
+		function updateInputStatus( message = '' ) {
+			document.getElementById( 'input-status' ).innerText = message;
+		}
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 26 - 0
packages/ckeditor5-table/tests/manual/tablemocking.md

@@ -0,0 +1,26 @@
+### Table mocking tools
+
+Helper tools for preparing test cases like this:
+ 
+```javascript
+// +----+----+----+----+----+
+// | 00 | 01 | 02 | 03 | 04 |
+// +----+----+----+----+----+
+// | 10 | 11      | 13 | 14 |
+// +----+         +    +----+
+// | 20 |         |    | 24 |
+// +----+----+----+----+----+
+// | 30 | 31      | 33 | 34 |
+// +----+----+----+----+----+
+// | 40 | 41 | 42 | 43 | 44 |
+// +----+----+----+----+----+
+setModelData( model, modelTable( [
+    [ '00', '01', '02', '03', '04' ],
+    [ '10', { contents: '11', colspan: 2, rowspan: 2 }, { contents: '13', rowspan: 2 }, '14' ],
+    [ '20', '24' ],
+    [ '30', { contents: '31', colspan: 2 }, '33', '34' ],
+    [ '40', '41', '42', '43', '44' ]
+] ) );
+```
+
+**Note:** Cell content is ignored while generating ASCII-art and `modelTableData`.