This is an automated email from the ASF dual-hosted git repository. sushuang pushed a commit to branch fix/contain-label-name in repository https://gitbox.apache.org/repos/asf/echarts.git
commit 565577110e5694ec5a741817fc1aa137393c50a0 Author: 100pah <[email protected]> AuthorDate: Mon Apr 28 22:03:06 2025 +0800 test: Enhance test utilities. (1) Support display boundingRect. (2) Support some complex input to build complex test cases. (3) Enhance mktest. --- test/build/mktest-tpl.html | 133 ++- test/lib/reset.css | 163 +++- test/lib/testHelper.js | 2127 +++++++++++++++++++++++++++++++++++--------- test/tmp-base.html | 106 ++- 4 files changed, 2079 insertions(+), 450 deletions(-) diff --git a/test/build/mktest-tpl.html b/test/build/mktest-tpl.html index 84218ef3f..bc6a49e43 100644 --- a/test/build/mktest-tpl.html +++ b/test/build/mktest-tpl.html @@ -28,14 +28,19 @@ under the License. <script src="lib/jquery.min.js"></script> <script src="lib/facePrint.js"></script> <script src="lib/testHelper.js"></script> - <!-- <script src="ut/lib/canteen.js"></script> --> + <!-- <script src="lib/canteen.js"></script> --> + <!-- <script src="lib/draggable.js"></script> --> <link rel="stylesheet" href="lib/reset.css" /> </head> <body> <style> + html { + /* Fix the line-height to integer to avoid it varying across clients and + causing visual test failures. Some clients may not support fractional px. */ + line-height: 18px; + } </style> - <!-- TPL_DOM_PLACE --> <!-- TPL_JS_PLACE --> @@ -48,33 +53,101 @@ under the License. <!-- TPL_SEGMENT_DELIMITER --> - <script> - require([ - 'echarts', - // 'map/js/china', - // './data/nutrients.json' - ], function (echarts) { - var option; - - option = { - xAxis: {}, - yAxis: {}, - series: { - type: 'line', - data: [[11, 22], [33, 44]] - } - }; - - var chart = testHelper.create(echarts, '{{TPL_DOM_ID}}', { - title: [ - 'Test Case Description of {{TPL_DOM_ID}}', - '(Muliple lines and **emphasis** are supported in description)' - ], - option: option - // height: 300, - // buttons: [{text: 'btn-txt', onclick: function () {}}], - // recordCanvas: true, - }); - }); + + require(['echarts'], function (echarts) { + + // // Data can be fetched by: + // $.getJSON('./data/nutrients.json', function (data) { + // }); + + var option = { + xAxis: {}, + yAxis: {}, + series: { + type: 'scatter', + symbolSize: 50, + label: {show: true, position: 'top'}, + data: [[1, 2], [100, 200], [500, 50]] + } + }; + + var chart = testHelper.create(echarts, '{{TPL_DOM_ID}}', { + title: [ + 'Test Case Description of {{TPL_DOM_ID}}', + '(Muliple lines and **emphasis** are supported in description)' + ], + option: option, + // + // -------------------------- Optional settings: -------------------------- + // height: 400, // Optional. Specify a different chart height. + // draggable: true, // Optional. Add a draggable button to mutify the chart size. + // // This feature require "test/lib/draggable.js" + // recordCanvas: true, // Optional. Record canvas instructions. (for debug) + // // This feature requires "test/lib/canteen.js" + // boundingRect: true, // Optional. Show boundingRects of zrender elements (for debug). + // + // ------------------- Inputs (button/range/select/br/hr): ---------------- + // inputsHeight: 30, // Optional. Fix the height of inputs area (scrollable if overflow) + inputsStyle: 'compact', // Optional. + inputs: [ // Optional. The following are sample inputs: + { + type: 'select', + text: '(sample) boundingRect:', + values: [false, true, undefined, {color: 'rgba(255,0,0,0.8)', silent: false}], + onchange: function () { + chart.__testHelper.boundingRect(this.value); + } + }, + { + type: 'range', + text: '(sample) symbolSize:', + // min: -100, // Optional. + // max: 100, // Optional. + // value: 50, // Optional. + onchange: function () { + console.log('range changed:', this.value); + chart.setOption({series: {symbolSize: this.value}}); + } + }, + { + type: 'select', + text: '(sample range embedded select) grid.left:', + options: [ + {value: undefined}, + {value: 30}, + {input: {type: 'range', min: -300, max: 300, value: 50}} + ], + onchange: function () { + var newVal = this.value; + console.log('select 2 changed:', newVal); + chart.setOption({grid: {left: newVal}}); + } + }, + { + type: 'br', // line break + }, + { + text: '(sample) print failures to screen', + onclick: function () { + testHelper.printAssert('{{TPL_DOM_ID}}', function (assert) { + assert(true); + }); + } + }, + { + text: '(sample) copy option to clipboard', + onclick: function () { + // console.log(testHelper.printObject(option)); + testHelper.clipboard(option); + } + } + + ] // End of `inputs` + + }); // End of `testHelper.create` + + }); // End of `require` + + </script> diff --git a/test/lib/reset.css b/test/lib/reset.css index f1666779c..645774184 100644 --- a/test/lib/reset.css +++ b/test/lib/reset.css @@ -58,13 +58,33 @@ body > .main { * otherwise, some recorded visual test cases may fail. */ +.test-inputs { + background: #eee; +} .test-inputs button { margin: 10px 5px; } .test-inputs-fix-height { position: relative; overflow: scroll; - box-shadow: inset -3px -3px 3px rgba(0, 0, 0, 0.3); + box-sizing: border-box; + background: #eee; + padding: 0; +} +.test-inputs-fix-height::before { + content: 'Scroll ⬇'; + position: sticky; + float: right; + top: 0; + right: 0; + padding: 0 2px 0 5px; + height: 14px; + line-height: 14px; + font-size: 11px; + color: #333; + z-index: 9999; + background: #ccc; + text-align: right; } .test-inputs-style-compact button { margin-top: 2px; @@ -82,14 +102,23 @@ body > .main { margin-bottom: 2px; } .test-inputs-slider-input { - width: 129px; + width: 109px; height: 16px; } +.test-inputs-slider-no-delta-buttons .test-inputs-slider-input { + width: 129px; +} .test-inputs-style-compact .test-inputs-slider-input { - width: 90px; + width: 75px; } .test-inputs-slider-sub { margin-left: -10px; + margin-top: 0; + margin-bottom: 0; +} +.test-inputs-style-compact .test-inputs-slider-sub { + margin-top: 0; + margin-bottom: 0; } .test-inputs-slider span { vertical-align: middle; @@ -102,6 +131,48 @@ body > .main { vertical-align: middle; margin: 0 3px; } +.test-inputs-slider-btn-incdec { + display: inline-block; + vertical-align: middle; + width: 0; + height: 0; + border-style: solid; + background: none; + border-color: transparent; + padding: 0; + user-select: none; + cursor: pointer; +} +.test-inputs-slider-btn-decrease { + border-width: 5px 7px 5px 0; + border-right-color: rgb(48,119,226); + margin: 0 1px 0 2px; +} +.test-inputs-slider-btn-decrease:hover { + border-right-color: #245dc1; +} +.test-inputs-slider-btn-increase { + border-width: 5px 0 5px 7px; + border-left-color: rgb(48,119,226); + margin: 0 2px 0 1px; +} +.test-inputs-slider-btn-increase:hover { + border-left-color: #245dc1; +} +.test-inputs-slider-disabled .test-inputs-slider-btn-decrease { + border-right-color: #bbb; + cursor: default; +} +.test-inputs-slider-disabled .test-inputs-slider-btn-increase { + border-left-color: #bbb; + cursor: default; +} +.test-inputs-slider-disabled .test-inputs-slider-btn-decrease:hover { + border-left-color: #bbb; +} +.test-inputs-slider-disabled .test-inputs-slider-btn-increase:hover { + border-left-color: #bbb; +} .test-inputs-select { white-space: nowrap; display: inline-block; @@ -117,6 +188,9 @@ body > .main { vertical-align: middle; margin: 0 3px; } +.test-inputs-select-disabled span { + color: #aaa; +} .test-inputs-select select { vertical-align: middle; margin: 0; @@ -124,6 +198,74 @@ body > .main { font-size: 13.3333px; height: 19px; } +.test-inputs-groupset { + position: relative; + overflow: auto; + border: 1px solid #bbb; + padding: 0; + margin: 0 3px; /* margin-bottom will collapse. */ + background: #fff; + display: block; + width: auto; +} +.test-inputs-groupset.test-inputs-fix-height::before { + right: 0; + left: 0; + height: 12px; + line-height: 12px; + font-size: 9px; +} +.test-inputs-groupset-text { + position: sticky; + display: block; + top: 0; + left: 0; + padding: 0; + margin: 0; + padding-left: 10px; + color: #333; + z-index: 9998; + height: 12px; + line-height: 12px; + font-size: 9px; + background: #ccc; +} +.test-inputs-groupset-group { + position: relative; + margin: 5px 2px; + background: #fff; +} +.test-inputs-groupset-margin-bottom { + /* A workaround for margin collapse, without breaking parent CSS (may fail previous visual tests) */ + height: 3px; + position: relative; + display: block; + width: auto; +} +.test-inputs-hr { + position: relative; + background: #ccc; + padding: 0; + height: 1px; + border-width: 0; + margin-block: unset; + margin-inline: unset; + margin: 8px 2px; +} +.test-inputs-hr-text { + position: absolute; + color: #333; + background: #ddd; + right: 0; + top: -6px; + text-align: right; + vertical-align: middle; + white-space: nowrap; + font-size: 10px; + line-height: 10px; + padding: 2px 5px; + margin: 0; +} .test-chart-block { position: relative; @@ -144,6 +286,21 @@ body > .main { .test-chart-block-has-right .test-chart-block-left { margin-right: 320px; } +.test-chart-wrapper { + position: relative; + padding: 0; + margin: 0; + border-width: 0; +} +.test-bounding-rects { + position: absolute; + left: 0; + top: 0; + padding: 0; + margin: 0; + border-width: 0; + z-index: 999999; +} .test-info { padding-left: 10px; overflow: auto; diff --git a/test/lib/testHelper.js b/test/lib/testHelper.js index 224c2a133..a1f4b6bb7 100644 --- a/test/lib/testHelper.js +++ b/test/lib/testHelper.js @@ -57,103 +57,217 @@ /** * @param {Object} opt - * @param {string|Array.<string>} [opt.title] If array, each item is on a single line. + * @param {string|string[]} [opt.title] If array, each item is on a single line. * Can use '**abc**', means <strong>abc</strong>. - * @param {Option} opt.option - * @param {Object} [opt.info] info object to display. - * info can be updated by `chart.__testHelper.updateInfo(someInfoObj, 'some_info_key');` - * @param {string} [opt.infoKey='option'] - * @param {Object|Array} [opt.dataTable] - * @param {Array.<Object|Array>} [opt.dataTables] Multiple dataTables. - * @param {number} [opt.dataTableLimit=DEFAULT_DATA_TABLE_LIMIT] - * @param {number} [opt.width] - * @param {number} [opt.height] - * @param {boolean} [opt.draggable] - * @param {boolean} [opt.lazyUpdate] - * @param {boolean} [opt.notMerge] - * @param {boolean} [opt.autoResize=true] + * @param {Option} opt.option The chart option. + * + * @param {number} [opt.width] Optional. Specify a different chart width. + * @param {number} [opt.height] Optional. Specify a different chart height. + * @param {boolean} [opt.notMerge] Optional. `chart.setOption(option, {norMerge});` + * @param {boolean} [opt.lazyUpdate] Optional. `chart.setOption(option, {lazyUpdate});` + * @param {boolean} [opt.autoResize=true] Optional. Enable chart auto response to window resize. + * @param {string} [opt.renderer] Optional. 'canvas' or 'svg'. DO NOT set it in formmal test cases; + * leave it controlled by __ECHARTS__DEFAULT__RENDERER__ for visual testing. + * + * @param {boolean} [opt.draggable] Optional. Add a draggable button to mutify the chart size. + * This feature require "test/lib/draggable.js" + * * @param {string} [opt.inputsStyle='normal'] Optional, can be 'normal', 'compact'. * Can be either `inputsStyle` or `buttonsStyle`. - * @param {number} [opt.inputsHeight] Default not fix height. If specified, a scroll + * @param {number} [opt.inputsHeight] Optional. By default not fix height. If specified, a scroll * bar will be displayed if overflow the height. In visual test, once a height changed * by adding something, the subsequent position will be changed, leading to test failures. * Fixing the height helps avoid this. * Can be either `inputsHeight` or `buttonsHeight`. - * @param {Array.<Object>|Object|Function} [opt.inputs] - * They are the same: `opt.buttons` `opt.button`, `opt.inputs`, `opt.input` - * It can be a function that return buttons configuration, like: + * @param {boolean} [opt.saveInputsInitialState] Optional. + * Required by `chart.__testHelper.restoreInputsToInitialState` + * @param {InputDefine[]|InputDefine|()=>InputDefine[]} [opt.inputs] Optional. + * definitions of button/range/select/br/hr. + * They are the same: `opt.buttons` `opt.button`, `opt.inputs`, `opt.input`. + * It can be a function that return inputs definitions, like: * inputs: chart => { return [{text: 'xxx', onclick: fn}, ...]; } - * Item can be these types: - * [{ - * // A button (default). - * text: 'xxx', - * // They are the same: `onclick`, `click` (capital insensitive) - * onclick: fn - * }, { - * // A range slider (HTML <input type="range">). - * type: 'range', // Or 'slider' - * id: 'some_id', // Optional. Can be used in `getState` and `setState`. - * stateGroup: 'some_state_group', // Optional. Can be used in `getState` and `setState`. - * text: 'xxx', // Optional - * min: 0, // Optional - * max: 100, // Optional - * value: 30, // Optional. Must be a number. - * step: 1, // Optional - * // They are the same: `oninput` `input` - * // `onchange` `change` - * // `onselect` `select` (capital insensitive) - * onchange: function () { console.log(this.value); } - * }, { - * // A select (HTML <select>...</select>). - * type: 'select', // Or `selection` - * id: 'some_id', // Optional. Can be used in `getState` and `setState`. - * stateGroup: 'some_state_group', // Optional. Can be used in `getState` and `setState`. - * // Either `values` or `options` can be used. - * // Items in `values` or `options[i].value` can be any type, like `true`, `123`, etc. - * values: ['a', 'b', 'c'], - * options: [ - * {text: 'a', value: 123}, - * {value: {some: {some: 456}}}, // `text` can be omitted and auto generated by `value`. - * {text: 'c', input: ...}, // `input` can be used as shown below. - * ... - * ], - * // `options[i]` can nest other input type, currently only support `type: range`: - * options: [ - * {value: undefined}, - * {text: 'c', input: { - * type: 'range', - * // ... Other properties of `range` input except `onchange` and `text`. - * // When this option is not selected, the range input will be disabled. - * }} - * ], - * valueIndex: 0, // Optional. The initial value index. By default, the first option. - * value: 'cval', // Optional. The initial value. By default, the first option. - * // Can be any type, like `true`, `123`, etc. - * // But can only be JS primitive type, as `===` is used internally. - * text: 'xxx', // Optional. - * // They are the same: `oninput` `input` - * // `onchange` `change` - * // `onselect` `select` (capital insensitive) - * onchange: function () { console.log(this.value); } - * }, { - * // A line break. - * // They are the same: `br` `lineBreak` `break` `wrap` `newLine` `endOfLine` `carriageReturn` - * // `lineFeed` `lineSeparator` `nextLine` (capital insensitive) - * type: 'br', - * }, - * // ... + * Inputs can be these types: + * [ + * { + * // A button (default). + * text: 'xxx', + * // They are the same: `onclick`, `click` (capital insensitive) + * onclick: fn, + * disabled: false, // Optional. + * prevent: { // Optional. + * recordInputs: false, // Optional. + * inputsState: false, // Optional. + * }, + * }, + * { + * // A range slider (HTML <input type="range">). + * type: 'range', // They are the same: 'range' 'slider' + * id: 'some_id', // Optional. Can be used in `switchGroup`. + * text: 'xxx', // Optional + * min: 0, // Optional + * max: 100, // Optional + * value: 30, // Optional. Must be a number. + * step: 1, // Optional + * disabled: false, // Optional. + * prevent: { // Optional. + * recordInputs: false, // Optional. + * inputsState: false, // Optional. + * }, + * // They are the same: `oninput` `input` + * // `onchange` `change` `onchanged` `changed` + * // `onselect` `select` (capital insensitive) + * onchange: function () { console.log(this.value); } + * }, + * { + * // A select (HTML <select>...</select>). + * type: 'select', // They are the same: 'select' 'selection' + * id: 'some_id', // Optional. Can be used in `getState` and `setState`. + * // Either `values` or `options` can be used. + * // Items in `values` or `options[i].value` can be any type, like `true`, `123`, etc. + * values: ['a', 'b', 'c'], + * options: [ + * {text: 'a', value: 123}, + * {value: {some: {some: 456}}}, // `text` can be omitted and auto generated by `value`. + * {text: 'c', input: ...}, // `input` can be used as shown below. + * ... + * ], + * // `options[i]` can nest other input type, currently only support `type: range`: + * options: [ + * {value: undefined}, + * {text: 'c', input: { + * type: 'range', + * // ... Other properties of `range` input except `onchange` and `text`. + * // When this option is not selected, the range input will be disabled. + * }} + * ], + * optionIndex: 0, // Optional. Or `valueIndex`. The initial value index. + * // By default, the first option. + * value: 'cval', // Optional. The initial value. By default, the first option. + * // Can be any type, like `true`, `123`, etc. + * // But can only be JS primitive type, as `===` is used internally. + * text: 'xxx', // Optional. + * disabled: false, // Optional. + * prevent: { // Optional. + * recordInputs: false, // Optional. + * inputsState: false, // Optional. + * }, + * // They are the same: `oninput` `input` + * // `onchange` `change` `onchanged` `changed` + * // `onselect` `select` (capital insensitive) + * onchange: function () { console.log(this.value); } + * }, + * { + * // Group inputs. Only one group can be displayed at a time with in a group set. + * type: 'groups', // They are the same: 'groups' 'group' 'groupset' + * // `inputsHeight` is mandatory in group set to avoid height change to affects visual testing + * // when switching groups. It will be applied to all groups. + * inputsHeight, + * // `inputsHeight` will be applied to all groups. + * inputsStyle, + * disabled: false, // Optional. Controlls all groups inside, + * // unless `group.disabled` or `input.disbles` specified. + * prevent: { // Optional. + * recordInputs: false, // Optional. + * inputsState: false, // Optional. + * }, + * groups: [{ + * id: 'group_A', + * text: 'xxx', // Optional. Or `title`. Displayed in the header line of the group content. + * disabled: false, // Optional. Controlls all inputs inside, unless `input.disabled` specified. + * inputs: [{...}, {...}, ...], + * }, { + * id: 'group_B', + * inputs: [{...}, {...}, ...], + * }, ...] + * // Group switching API: @see chart.__testHelper.switchGroup(groupId); + * }, + * { + * // A line break. + * // They are the same: `br` `lineBreak` `break` `wrap` `newLine` `endOfLine` `carriageReturn` + * // `lineFeed` `lineSeparator` `nextLine` (capital insensitive) + * type: 'br', + * }, + * { + * // A separate line. + * type: 'hr', + * text: 'xxx', // Optional. Display text on the split line. + * }, + * // ... * ] - * The value of the inputs can be update by: - * chart.__testHelper.setState({'some_id_1': 'value1', 'some_id_2': 'value2'}); - * // Only set state from input that has 'some_state_group_1'. - * chart.__testHelper.setState({...}, 'some_state_group_1'); - * // Get: {some_id_1: 'value1', some_id_2: 'value2'} - * chart.__testHelper.getState(); - * // Only get state from input that has 'some_state_group_1'. - * chart.__testHelper.getState('some_state_group_1'); - * @param {boolean} [opt.recordCanvas] 'test/lib/canteen.js' is required. - * @param {boolean} [opt.recordVideo] - * @param {string} [opt.renderer] 'canvas' or 'svg' + * ----------------------------- Inputs related API ----------------------------------- + * @function chart.__testHelper.switchGroup Switch group. + * chart.__testHelper.switchGroup( + * groupId: string, + * opt?: { + * recordInputs: boolean, // Optional. @see `chart.__testHelper.recordInputs`. + * } + * ); + * + * @function chart.__testHelper.disableInputs Disable the specified inputs. + * chart.__testHelper.disableInputs(opt: { + * disabled: boolean, // disables/enables + * inputId: string, // Optional. id or id array. disables/enables the id-specified inputs. + * groupId: string, // Optional. id or id array. disables/enables the inputs within the group. + * recordInputs: boolean, // Optional. @see `chart.__testHelper.recordInputs`. + * }) + * + * @function chart.__testHelper.recordInputs + * @see `prevent` in `inputs` to prevent record. + * chart.__testHelper.recordInputs(opt: { // start record inputs operations for replay. + * action: 'start' + * }) + * chart.__testHelper.recordInputs(opt: { // stop record inputs operations and output. + * action: 'stop', + * outputType?: 'clipboard' | 'console', // Optional. 'clipboard' by default. + * printObjectOpt?: {} // Optional. the opt of `testHelper.printObject`. + * }) + * Note: if some API `chart.__testHelper.xxx` has parameter `recordInputs`, it indicates that wether + * record this call. It is `false` by default, and: + * - When this API is called in a callback function of an input where no `prevent.recordInputs` is + * declared, this option should be kept `false`. (This is the most cases.) + * - Otherwise it should be `true`. + * @function chart.__testHelper.replayInputs + * chart.__testHelper.replayInputs(inputsRecord) + * + * (TL;DR) NOTE: Currently, echarts can not be restored to the initial state by + * `setOption({..., xxx: undefined})` or `setOption({..., xxx: 'auto'})` in most options. + * That is, the initial state can only be obtained by: + * - either "not specified in echarts option" from the beginning; + * - or "sepecify the exact default value to match the internal default value in echarts option". + * + * @function chart.__testHelper.getInputsState Get the current state of `inputs`. + * chart.__testHelper.getInputsState() + * e.g., result: {some_id_1: 'value1', some_id_2: 'value2'} + * @see `prevent` in `inputs` to prevent. + * @function chart.__testHelper.setInputsState Set the current state of `inputs`. + * chart.__testHelper.setInputsState(state) + * @function chart.__testHelper.restoreInputsToInitialState + * chart.__testHelper.restoreInputsToInitialState() + * @see opt.saveInputsInitialState which must be specified as true for this API. + * ------------------------------------------------------------------------------------ + * + * @param {BoundingRectOpt} [opt.boundingRect] Optional. + * @typedef {boolean | {color?: string, slient: boolean}} BoundingRectOpt + * Enable display bounding rect for zrender elements. + * - `true`: Simply display the bounding rects. + * - `opt.boundingRect.color`: a string to indicate the color, like 'red', 'rgba(0,0,0,0.2)', '#fff'. + * - `opt.boundingRect.silent`: by default `false`; + * if `false`, click on the bounding rect, window.$0 will be assigned the original zrender element. + * - Can be switched dynamically by: + * // Update BoundingRectOpt, typically used to show/hide bounding rects. + * @function chart.__testHelper.boundingRect + * chart.__testHelper.boundingRect(opt: BoundingRectOpt); + * chart.__testHelper.boundingRect(); // Use the last BoundingRectOpt. + * + * @param {boolean} [opt.recordCanvas] Optional. 'test/lib/canteen.js' is required. + * @param {boolean} [opt.recordVideo] Optional. + * + * @param {Object} [opt.info] Optional. info object to display. + * @api info can be updated by `chart.__testHelper.updateInfo(someInfoObj, 'some_info_key');` + * @param {string} [opt.infoKey='option'] Optional. + * @param {Object|Array} [opt.dataTable] Optional. + * @param {Array.<Object|Array>} [opt.dataTables] Optional. Multiple dataTables. + * @param {number} [opt.dataTableLimit=DEFAULT_DATA_TABLE_LIMIT] Optional. */ testHelper.create = function (echarts, domOrId, opt) { var dom = getDom(domOrId); @@ -164,43 +278,31 @@ var errMsgPrefix = '[testHelper dom: ' + domOrId + ']'; - var title = document.createElement('div'); + var titleContainer = document.createElement('div'); var left = document.createElement('div'); + var chartContainerWrapper = document.createElement('div'); var chartContainer = document.createElement('div'); var inputsContainer = document.createElement('div'); var dataTableContainer = document.createElement('div'); var infoContainer = document.createElement('div'); var recordCanvasContainer = document.createElement('div'); var recordVideoContainer = document.createElement('div'); + var boundingRectsContainer = document.createElement('div'); - title.setAttribute('title', dom.getAttribute('id')); + titleContainer.setAttribute('title', dom.getAttribute('id')); - var inputsHeight = testHelper.retrieveValue(opt.inputsHeight, opt.buttonsHeight, null); - if (inputsHeight != null) { - inputsHeight = parseFloat(inputsHeight); - } - - title.className = 'test-title'; + titleContainer.className = 'test-title'; dom.className = 'test-chart-block'; left.className = 'test-chart-block-left'; + chartContainerWrapper.className = 'test-chart-wrapper'; chartContainer.className = 'test-chart'; dataTableContainer.className = 'test-data-table'; infoContainer.className = 'test-info'; + boundingRectsContainer.className = 'test-bounding-rects'; + boundingRectsContainer.style.display = 'none'; recordCanvasContainer.className = 'record-canvas'; recordVideoContainer.className = 'record-video'; - inputsContainer.className = [ - 'test-inputs', - 'test-buttons', // deprecated but backward compat. - 'test-inputs-style-' + (opt.inputsStyle || opt.buttonsStyle || 'normal'), - (inputsHeight != null ? 'test-inputs-fix-height' : '') - ].join(' '); - if (inputsHeight != null) { - inputsContainer.style.cssText = [ - 'height:' + inputsHeight + 'px', - ].join(';') + ';'; - } - if (opt.info) { dom.className += ' test-chart-block-has-right'; infoContainer.className += ' test-chart-block-right'; @@ -210,43 +312,49 @@ left.appendChild(recordVideoContainer); left.appendChild(inputsContainer); left.appendChild(dataTableContainer); - left.appendChild(chartContainer); + left.appendChild(chartContainerWrapper); + chartContainerWrapper.appendChild(chartContainer); + chartContainerWrapper.appendChild(boundingRectsContainer); dom.appendChild(infoContainer); dom.appendChild(left); - dom.parentNode.insertBefore(title, dom); + dom.parentNode.insertBefore(titleContainer, dom); - var chart; + initTestTitle(opt, titleContainer); + + var chart = testHelper.createChart(echarts, chartContainer, opt.option, opt, opt.setOptionOpts, errMsgPrefix); + chart.__testHelper = {}; + + initDataTables(opt, dataTableContainer); + + if (chart) { + initInputs(chart, opt, inputsContainer, errMsgPrefix); + initUpdateInfo(opt, chart, infoContainer); + initRecordCanvas(opt, chart, recordCanvasContainer); + if (opt.recordVideo) { + testHelper.createRecordVideo(chart, recordVideoContainer); + } + initShowBoundingRects(chart, echarts, opt, boundingRectsContainer); + } + return chart; + }; + + function initTestTitle(opt, titleContainer) { var optTitle = opt.title; if (optTitle) { if (optTitle instanceof Array) { optTitle = optTitle.join('\n'); } - title.innerHTML = '<div class="test-title-inner">' - + testHelper.encodeHTML(optTitle) + titleContainer.innerHTML = '<div class="test-title-inner">' + + encodeHTML(optTitle) .replace(/\*\*([^*]+?)\*\*/g, '<strong>$1</strong>') .replace(/\n/g, '<br>') + '</div>'; } + } - chart = testHelper.createChart(echarts, chartContainer, opt.option, opt, opt.setOptionOpts, errMsgPrefix); - - var dataTables = opt.dataTables; - if (!dataTables && opt.dataTable) { - dataTables = [opt.dataTable]; - } - if (dataTables) { - var tableHTML = []; - for (var i = 0; i < dataTables.length; i++) { - tableHTML.push(createDataTableHTML(dataTables[i], opt)); - } - dataTableContainer.innerHTML = tableHTML.join(''); - } - - var inputsResult; - if (chart) { - inputsResult = initInputs(chart, opt, inputsContainer, errMsgPrefix); - } + function initUpdateInfo(opt, chart, infoContainer) { + assert(chart.__testHelper); if (opt.info) { updateInfo(opt.info, opt.infoKey); @@ -256,27 +364,17 @@ infoContainer.innerHTML = createObjectHTML(info, infoKey || 'option'); } - initRecordCanvas(opt, chart, recordCanvasContainer); - - if (opt.recordVideo) { - testHelper.createRecordVideo(chart, recordVideoContainer); - } - - chart.__testHelper = { - updateInfo: updateInfo, - setState: inputsResult && inputsResult.setState, - getState: inputsResult && inputsResult.getState - }; - - return chart; - }; + chart.__testHelper.updateInfo = updateInfo; + } function initInputs(chart, opt, inputsContainer, errMsgPrefix) { + assert(chart.__testHelper); + var NAMES_ON_INPUT_CHANGE = makeFlexibleNames([ - 'input', 'on-input', 'on-change', 'select', 'on-select' + 'input', 'on-input', 'change', 'on-change', 'changed', 'on-changed', 'select', 'on-select' ]); var NAMES_ON_CLICK = makeFlexibleNames([ - 'on-click', 'click' + 'click', 'on-click' ]); var NAMES_TYPE_BUTTON = makeFlexibleNames(['button', 'btn']); var NAMES_TYPE_RANGE = makeFlexibleNames(['range', 'slider']); @@ -285,70 +383,450 @@ 'br', 'line-break', 'break', 'wrap', 'new-line', 'end-of-line', 'carriage-return', 'line-feed', 'line-separator', 'next-line' ]); - // key: id, value: {setState, getState} + var NAMES_TYPE_HR = makeFlexibleNames([ + 'hr', 'horizontal-line', 'divider', 'separate-line' + ]); + var NAMES_TYPE_GROUP_SET = makeFlexibleNames(['group', 'groups', 'group-set']); + /** + * key: inputId, + * value: { + * id: inputId, + * disable?, + * switchGroup?, + * setState?, + * getState?, + * } + */ var _inputsDict = {}; + var NAMES_RECORD_INPUTS_ACTION_START = makeFlexibleNames(['start', 'begin']); + var NAMES_RECORD_INPUTS_ACTION_STOP = makeFlexibleNames(['stop', 'end', 'finish']); + var _inputsRecord = null; + /** + * key: inputId + * value: @see makeInputRecorder + */ + var _inputRecorderWrapperMap = {}; + var _INPUTS_RECORD_VERSION = '1.0.0'; + var NANES_PREVENT_INPUTS_STATE = makeFlexibleNames([ + 'inputs-state', 'input-state', 'inputs-states', 'input-states' + ]); + var NANES_PREVENT_RECORD_INPUTS = makeFlexibleNames([ + 'record-inputs', 'record-input', + 'input-record', 'inputs-record', + ]); + var _initStateBackup = null; + + initInputsContainer(inputsContainer, opt); + var inputsDefineList = retrieveInputDefineList(opt); + dealInitEachInput(inputsDefineList, inputsContainer); + + // --- Input operation related API --- + chart.__testHelper.switchGroup + = makeSwitchGroup(); + chart.__testHelper.disableInputs + = chart.__testHelper.disableInput + = makeDisableInputs(); + + // --- Input meta related API --- + chart.__testHelper.recordInputs + = recordInputs; + chart.__testHelper.replayInputs + = chart.__testHelper.replayInput + = replayInputs; + chart.__testHelper.getInputsState + = chart.__testHelper.getInputState + = getInputsState; + chart.__testHelper.setInputsState + = chart.__testHelper.setInputState + = setInputsState; + chart.__testHelper.restoreInputsToInitialState + = restoreInputsToInitialState; + + if (opt.saveInputsInitialState) { + _initStateBackup = chart.__testHelper.getInputsState(); + } + + return; + + function makeDisableInputs() { + var inputRecorderWrapper = makeInputRecorder(); + inputRecorderWrapper.setupInputId('__\0testHelper_disableInputs'); + var disableInputsWithRecordInputs = inputRecorderWrapper.inputRecorder.wrapUserInputListener({ + listener: disableInputs, + op: 'disableInputs' + }); - init(); - - return {setState: setState, getState: getState}; - + /** + * @param {string|Array.<string>?} opt.groupId + * @param {string|Array.<string>?} opt.inputId + * @param {boolean} opt.recordInputs + */ + return function (opt) { + opt.recordInputs + ? disableInputsWithRecordInputs(opt) + : disableInputs(opt); + } + + function disableInputs(opt) { + assert(opt, '[disableInputs] requires parameters.'); + var groupId = opt.groupId; + var inputId = opt.inputId; + assert( + groupId != null || inputId != null, + '[disableInputs] requires `groupId` or/and `inputId`.' + ); + var inputIdList = []; + if (inputId != null) { + if (getType(inputId) !== 'array') { + inputId = [inputId]; + } + for (var idx = 0; idx < inputId.length; idx++) { + var id = inputId[idx]; + findInputCreatedAndCheck(id, {throw: true}); + inputIdList.push(id); + } + } + if (groupId != null) { + if (getType(groupId) !== 'array') { + groupId = [groupId]; + } + for (var idx = 0; idx < groupId.length; idx++) { + inputIdList = inputIdList.concat(retrieveAndVerifyGroup(groupId[idx]).idList); + } + } + var disabled = opt.disabled; + for (var idx = 0; idx < inputIdList.length; idx++) { + var id = inputIdList[idx]; + if (_inputsDict[id].disable) { + _inputsDict[id].disable({disabled: disabled}); + } + } + } + } - function init() { - var inputsDefineList = retrieveInputDefineList(); + /** + * @param {string} opt.action 'start' or 'stop'. + * @param {string} opt.outputType Optional. 'clipboard' or 'console'. + * @param {Object} opt.printObjectOpt Optional. The opt of `testHelper.printObject`. + */ + function recordInputs(opt) { + var action = opt.action; + assert( + NAMES_RECORD_INPUTS_ACTION_START.indexOf(action) >= 0 + || NAMES_RECORD_INPUTS_ACTION_STOP.indexOf(action) >= 0, + 'Invalide recordInputs action: ' + action + '. Should be ' + + NAMES_RECORD_INPUTS_ACTION_START + ' ' + NAMES_RECORD_INPUTS_ACTION_STOP + ); + if (NAMES_RECORD_INPUTS_ACTION_START.indexOf(action) >= 0) { + _inputsRecord = { + version: _INPUTS_RECORD_VERSION, + startTime: +(new Date()), + operations: [], + }; + } + else if (NAMES_RECORD_INPUTS_ACTION_STOP.indexOf(action) >= 0) { + if (_inputsRecord == null) { + console.error( + 'Inputs record is not started. Please call' + + ' `chart.__testHelper.recordInputs({action: "start"})` first.' + ); + return; + } + _inputsRecord.endTime = +(new Date()); + var inputsRecord = _inputsRecord; + _inputsRecord = null; + outputInputsRecord(inputsRecord); + return inputsRecord; + } - for (var i = 0; i < inputsDefineList.length; i++) { - var singleCreated = createInputByDefine(inputsDefineList[i]); - if (!singleCreated) { - continue; + function outputInputsRecord(record) { + if (opt.outputType === 'console') { + console.log(testHelper.printObject(record, opt.printObjectOpt)); } - for (var j = 0; j < singleCreated.elList.length; j++) { - inputsContainer.appendChild(singleCreated.elList[j]); + else { + testHelper.clipboard(record, opt.printObjectOpt); } - var id = retrieveId(inputsDefineList[i], 'id'); - var stateGroup = retrieveId(inputsDefineList[i], 'stateGroup'); - if (stateGroup != null) { - if (id == null) { - id = generateId('test_inputs_'); - } - singleCreated.stateGroup = stateGroup; + } + } + + function replayInputs(inputsRecord) { + assert( + inputsRecord.version === _INPUTS_RECORD_VERSION, + 'Not supported inputs record version. expect' + _INPUTS_RECORD_VERSION + ' Need to re-record.' + ); + for (var idx = 0; idx < inputsRecord.operations.length; idx++) { + var opItem = inputsRecord.operations[idx]; + findInputCreatedAndCheck(opItem.id, {throw: true}); + assert( + !shouldPrevent(opItem.id, NANES_PREVENT_RECORD_INPUTS), + 'Input (id:' + opItem.id + ') has prevented recording. This may caused by test case change.' + ); + var inputRecorderWrapper = _inputRecorderWrapperMap[opItem.id]; + assert(inputRecorderWrapper); + assert(getType(opItem.op) === 'string', 'Invalid op: ' + opItem.op); + var listenerDefine = inputRecorderWrapper.listenerDefineMap[opItem.op]; + assert( + listenerDefine, + 'Can not find listener by op: ' + opItem.op + ' This may caused by test case change.' + ); + var prepared = {this: [], arguments: {}}; + if (listenerDefine.prepareReplay) { + prepared = listenerDefine.prepareReplay(opItem.args); + assert( + isObject(prepared) + && prepared.hasOwnProperty('this') + && getType(prepared.arguments) === 'array', + '`prepareReplay` must return an object: {this: any, arguments: []}.' + ); } - if (id != null) { - if (_inputsDict[singleCreated.id]) { - throw new Error(errMsgPrefix + 'Duplicate input id: ' + singleCreated.id); + listenerDefine.listener.apply(prepared.this, prepared.arguments); + } + } + + function makeInputRecorder() { + var _inputId = null; + var inputRecorderWrapper = { + setupInputId: function (inputId) { + _inputId = inputId; + _inputRecorderWrapperMap[inputId] = inputRecorderWrapper; + }, + inputRecorder: { + wrapUserInputListener: wrapUserInputListener + }, + /** + * key: op, + */ + listenerDefineMap: {}, + }; + + return inputRecorderWrapper; + + function wrapUserInputListener(listenerDefine) { + assert( + getType(listenerDefine.listener) === 'function', + 'Must provide a function `listener`.' + ); + assert( + getType(listenerDefine.op) === 'string', + 'Must provide an `op` string to identify this listener.' + ); + + assert( + !inputRecorderWrapper.listenerDefineMap[listenerDefine.op], + '`op` ' + listenerDefine.op + ' overlapped.' + ); + inputRecorderWrapper.listenerDefineMap[listenerDefine.op] = listenerDefine; + + return function wrappedListener() { + assert(_inputId != null); + if (_inputsRecord && !shouldPrevent(_inputId, NANES_PREVENT_RECORD_INPUTS)) { + var recordWrapper = {id: _inputId, op: listenerDefine.op}; + if (listenerDefine.createRecordArgs) { + recordWrapper.args = listenerDefine.createRecordArgs.apply(this, arguments); + } + _inputsRecord.operations.push(recordWrapper); } - singleCreated.id = id; - _inputsDict[singleCreated.id] = singleCreated; - } + return listenerDefine.listener.apply(this, arguments); + }; } } - function setState(state, stateGroup) { + function setInputsState(state) { + var changedCreatedList = []; for (var id in state) { if (state.hasOwnProperty(id)) { - if (_inputsDict[id] == null) { - throw new Error(errMsgPrefix + 'No input with id: ' + id); + var inputCreated = findInputCreatedAndCheck(id, {log: true}); + if (!inputCreated) { + continue; } - if (!stateGroup || _inputsDict[id].stateGroup === stateGroup) { - _inputsDict[id].setState(state[id]); + if (shouldPrevent(id, NANES_PREVENT_INPUTS_STATE) || !inputCreated.setState) { + continue; } + inputCreated.setState(state[id]); + changedCreatedList.push(inputCreated); } } } - function getState(stateGroup) { + function getInputsState() { var result = {}; for (var id in _inputsDict) { - if (_inputsDict.hasOwnProperty(id) - && (!stateGroup || _inputsDict[id].stateGroup === stateGroup) - ) { - result[id] = _inputsDict[id].getState(); + if (_inputsDict.hasOwnProperty(id)) { + var inputCreated = _inputsDict[id]; + if (shouldPrevent(id, NANES_PREVENT_INPUTS_STATE) || !inputCreated.getState) { + continue; + } + if (inputCreated.idCanNotPersist) { + throw new Error( + errMsgPrefix + '[getInputsState]. Please specify an id explicitly or unique text' + + ' for input:' + printObject(inputCreated.__inputDefine) + ); + } + result[id] = inputCreated.getState(); } } return result; } - function retrieveInputDefineList() { - var defineList = testHelper.retrieveValue(opt.buttons, opt.button, opt.input, opt.inputs); + function restoreInputsToInitialState() { + assert( + _initStateBackup != null, + 'opt.saveInputsInitialState must be true to use `restoreInputsToInitialState`.' + ); + setInputsState(_initStateBackup); + } + + function initInputsContainer(container, define, features) { + assert(container.tagName.toLowerCase() === 'div'); + container.innerHTML = ''; + + var ignoreFixHeight = features && features.ignoreFixHeight; + var ignoreInputsStyle = features && features.ignoreInputsStyle; + + var inputsHeight = retrieveValue(define.inputsHeight, define.buttonsHeight, null); + if (inputsHeight != null) { + inputsHeight = parseFloat(inputsHeight); + } + + var classNameArr = []; + if (features && features.className) { + classNameArr.push(features.className); + } + if (!ignoreInputsStyle) { + classNameArr.push( + 'test-inputs', + 'test-buttons', // deprecated but backward compat. + 'test-inputs-style-' + (define.inputsStyle || define.buttonsStyle || 'normal') + ); + } + if (!ignoreFixHeight && inputsHeight != null) { + classNameArr.push('test-inputs-fix-height'); + container.style.cssText += [ + 'height:' + inputsHeight + 'px' + ].join(';') + ';'; + } + + container.className = classNameArr.join(' '); + } + + function dealInitEachInput(inputsDefineList, inputsContainer) { + var idList = []; + for (var i = 0; i < inputsDefineList.length; i++) { + var inputDefine = inputsDefineList[i]; + var inputRecorderWrapper = makeInputRecorder(); + var inputCreated = createInputByDefine( + inputDefine, + inputRecorderWrapper.inputRecorder + ); + if (!inputCreated) { + continue; + } + for (var j = 0; j < inputCreated.elList.length; j++) { + inputsContainer.appendChild(inputCreated.elList[j]); + } + var id = storeToInputDict(inputDefine, inputCreated, inputRecorderWrapper.setupInputId); + idList.push(id); + } + return idList; + } + + function storeToInputDict(inputDefine, inputCreated, inputRecorderSetupInputId) { + var id = retrieveId(inputDefine, 'id'); + if (id != null) { + id = '' + id; + if (_inputsDict[id]) { + throw new Error(errMsgPrefix + ' Duplicate input id: ' + id); + } + } + if (id == null) { + var text = retrieveValue(inputDefine.text, '') + ''; + if (text) { + var textBasedId = '__inputs|' + text + '|'; + if (!_inputsDict[textBasedId]) { + id = textBasedId; + } + } + } + if (id == null) { + id = generateNonPersistentId('__inputs_non_persist'); + assert(!_inputsDict[id]); + inputCreated.idCanNotPersist = true; + } + inputCreated.id = id; + inputCreated.__inputDefine = inputDefine; + _inputsDict[id] = inputCreated; + if (inputRecorderSetupInputId) { + inputRecorderSetupInputId(id); + } + return id; + } + + function retrieveAndVerifyGroup(groupId) { + var groupCreated = _inputsDict[groupId]; + assert(groupCreated, 'Can not find group by id: ' + groupId); + assert(groupCreated.groupParent, 'This is not a group. id: ' + groupId); + return groupCreated; + } + + function makeSwitchGroup() { + var inputRecorderWrapper = makeInputRecorder(); + inputRecorderWrapper.setupInputId('__\0testHelper_switchGroup'); + var switchGroupWithRecordInputs = inputRecorderWrapper.inputRecorder.wrapUserInputListener({ + listener: dealSwitchGroup, + op: 'switchGroup' + }); + + return function (groupId, opt) { + (opt && opt.recordInputs) + ? switchGroupWithRecordInputs(groupId, opt) + : dealSwitchGroup(groupId); + }; + + function dealSwitchGroup(groupId) { + var groupCreatedToShow = retrieveAndVerifyGroup(groupId); + var groupSetCreated = groupCreatedToShow.groupParent; + groupSetCreated.switchGroup(groupId); + } + } + + function showHideGroupInGroupSet(groupCreated, showOrHide) { + groupCreated.inputsContainerEl.style.display = showOrHide + ? 'block' : 'none'; + var groupDefine = groupCreated.groupDefine; + groupCreated.groupSetTextEl.innerHTML = showOrHide + ? encodeHTML(retrieveValue(groupDefine.text, groupDefine.title, '')) + : ''; + } + + function shouldPrevent(inputId, names) { + var prevent = _inputsDict[inputId].__inputDefine.prevent || {}; + for (var idx = 0; idx < names.length; idx++) { + if (prevent[names[idx]]) { + return true; + } + } + return false; + } + + function findInputCreatedAndCheck(inputId, errorHandling) { + var inputCreated = _inputsDict[inputId]; + if (!inputCreated) { + var errMsg = errMsgPrefix + ' No input found by id: ' + id + '. May caused by test case change.'; + if (errorHandling.log) { + console.error(errMsg); + } + else if (errorHandling.throw) { + throw new Error(errMsg); + } + else { + throw new Error('internal failure.') + } + } + return inputCreated; + } + + function retrieveInputDefineList(define) { + var defineList = retrieveValue(define.buttons, define.button, define.input, define.inputs); if (typeof defineList === 'function') { defineList = defineList(chart); } @@ -358,12 +836,10 @@ return defineList; } - function getBtnTextHTML(inputDefine, defaultText) { - return testHelper.encodeHTML(testHelper.retrieveValue(inputDefine.name, inputDefine.text, defaultText)); - } - function getBtnDefineAttr(inputDefine, attr, defaultValue) { - return inputDefine[attr] != null ? inputDefine[attr] : defaultValue; + function getInputsTextHTML(inputDefine, defaultText) { + return encodeHTML(retrieveValue(inputDefine.name, inputDefine.text, defaultText)); } + function getBtnEventListener(inputDefine, names) { for (var idx = 0; idx < names.length; idx++) { if (inputDefine[names[idx]]) { @@ -376,181 +852,299 @@ if (inputDefine && inputDefine[idPropName] != null) { var type = getType(inputDefine[idPropName]); if (type !== 'string' && type != 'number') { - throw new Error(errMsgPrefix + 'id must be string or number.'); + throw new Error(errMsgPrefix + ' id must be string or number.'); } return inputDefine[idPropName] + ''; } } - function createInputByDefine(inputDefine) { + function createInputByDefine(inputDefine, inputRecorder) { if (!inputDefine) { return; } var inputType = inputDefine.hasOwnProperty('type') ? inputDefine.type : 'button'; if (arrayIndexOf(NAMES_TYPE_RANGE, inputType) >= 0) { - var rangeInputCreated = createRangeInput(inputDefine); - return { - elList: [rangeInputCreated.el], - getState: rangeInputCreated.getState, - setState: rangeInputCreated.setState - }; + return createRangeInput(inputDefine, null, inputRecorder); } else if (arrayIndexOf(NAMES_TYPE_SELECT, inputType) >= 0) { - return createSelectInput(inputDefine); + return createSelectInput(inputDefine, inputRecorder); } else if (arrayIndexOf(NAMES_TYPE_BR, inputType) >= 0) { - return { - elList: [createBr(inputDefine)] - }; + return createBr(inputDefine, inputRecorder); + } + else if (arrayIndexOf(NAMES_TYPE_HR, inputType) >= 0) { + return createHr(inputDefine, inputRecorder); } else if (arrayIndexOf(NAMES_TYPE_BUTTON, inputType) >= 0) { - return { - elList: [createButtonInput(inputDefine)] - }; + return createButtonInput(inputDefine, inputRecorder); } - else { - throw new Error(errMsgPrefix + 'Unsupported button type: ' + inputType); - } - } - - function createRangeInput(inputDefine, internallyForceDef) { - var sliderWrapperEl = document.createElement('span'); - resetWrapperCSS(false); - - var sliderTextEl = document.createElement('span'); - sliderTextEl.className = 'test-inputs-slider-text'; - sliderTextEl.innerHTML = internallyForceDef - ? getBtnTextHTML(internallyForceDef, '') - : getBtnTextHTML(inputDefine, ''); - sliderWrapperEl.appendChild(sliderTextEl); - - var sliderInputEl = document.createElement('input'); - sliderInputEl.className = 'test-inputs-slider-input'; - sliderInputEl.setAttribute('type', 'range'); - var sliderListener = internallyForceDef - ? getBtnEventListener(internallyForceDef, NAMES_ON_INPUT_CHANGE) - : getBtnEventListener(inputDefine, NAMES_ON_INPUT_CHANGE); - if (!sliderListener) { - throw new Error(errMsgPrefix + 'No listener (either ' + NAMES_ON_INPUT_CHANGE.join(', ') + ') specified for slider.'); - } - sliderInputEl.addEventListener('input', function () { - updateSliderValueEl(); - var target = {value: this.value}; - sliderListener.call(target, {target: target}); - }); - sliderInputEl.setAttribute('min', getBtnDefineAttr(inputDefine, 'min', 0)); - sliderInputEl.setAttribute('max', getBtnDefineAttr(inputDefine, 'max', 100)); - sliderInputEl.setAttribute('value', getBtnDefineAttr(inputDefine, 'value', 30)); - sliderInputEl.setAttribute('step', getBtnDefineAttr(inputDefine, 'step', 1)); - sliderWrapperEl.appendChild(sliderInputEl); - - var sliderValueEl = document.createElement('span'); - sliderValueEl.className = 'test-inputs-slider-value'; - function updateSliderValueEl() { - var val = sliderInputEl.value; - updateText(val); - } - function updateText(val) { - sliderValueEl.innerHTML = testHelper.encodeHTML(val); - } - updateSliderValueEl(); - sliderWrapperEl.appendChild(sliderValueEl); - - function resetWrapperCSS(disabled) { - sliderWrapperEl.className = 'test-inputs-slider' - + (internallyForceDef ? ' test-inputs-slider-sub' : '') - + (disabled ? ' test-inputs-slider-disabled' : ''); + else if (arrayIndexOf(NAMES_TYPE_GROUP_SET, inputType) >= 0) { + return createGroupSetInput(inputDefine, inputRecorder); } + else { + throw new Error(errMsgPrefix + ' Unsupported button type: ' + inputType); + } + } + + function createRangeInput(inputDefine, internallyForceDef, inputRecorder) { + var _currVal = +retrieveValue(inputDefine.value, 0); + var _disabled = false; + var _step = +retrieveValue(inputDefine.step, 1); + var _minVal = +retrieveValue(inputDefine.min, 0); + var _maxVal = +retrieveValue(inputDefine.max, 100); + var _precision = Math.max( + getPrecision(_minVal), + getPrecision(_maxVal), + getPrecision(_currVal), + getPrecision(_step) + ); + var _noDeltaButtons = !!inputDefine.noDeltaButtons; // Only for backward compat. + var _rangeInputWrapperEl; + var _rangeInputListener; + var _rangeInputEl; + var _rangeInputValueEl; + + dealInitRangeInput(); return { - el: sliderWrapperEl, - setState: function (state) { - if (state == null || !isFinite(+state.value)) { - throw new Error(errMsgPrefix + 'Invalid state: ' + printObject(state) + ' for range'); + elList: [_rangeInputWrapperEl], + disable: resetRangeInputDisabled, + getState: getRangeInputState, + setState: setRangeInputState, + }; + + function dealInitRangeInput() { + _rangeInputWrapperEl = document.createElement('span'); + resetRangeInputWrapperCSS(_rangeInputWrapperEl, false); + + _rangeInputListener = internallyForceDef + ? getBtnEventListener(internallyForceDef, NAMES_ON_INPUT_CHANGE) + : getBtnEventListener(inputDefine, NAMES_ON_INPUT_CHANGE); + if (!_rangeInputListener) { + throw new Error( + errMsgPrefix + ' No listener (either ' + + NAMES_ON_INPUT_CHANGE.join(', ') + ') specified for slider.' + ); + } + + var sliderTextEl = document.createElement('span'); + sliderTextEl.className = 'test-inputs-slider-text'; + sliderTextEl.innerHTML = internallyForceDef + ? getInputsTextHTML(internallyForceDef, '') + : getInputsTextHTML(inputDefine, ''); + _rangeInputWrapperEl.appendChild(sliderTextEl); + + function createRangeInputDeltaBtn(btnName, delta) { + if (_noDeltaButtons) { return; } + var sliderLRBtnEl = document.createElement('div'); + sliderLRBtnEl.className = 'test-inputs-slider-btn-incdec test-inputs-slider-btn-' + btnName; + _rangeInputWrapperEl.appendChild(sliderLRBtnEl); + sliderLRBtnEl.addEventListener('click', inputRecorder.wrapUserInputListener({ + listener: function () { + if (_disabled) { return; } + // 0.1 + 0.2 = 0.30000000000000004 + _currVal = round(_currVal + delta, _precision); + updateRangeInputViewValue(_currVal); + dispatchRangeInputChangedEvent(); + }, + op: btnName + })); + } + createRangeInputDeltaBtn('decrease', -_step); + createRangeInputDeltaBtn('increase', _step); + + _rangeInputEl = document.createElement('input'); + _rangeInputEl.className = 'test-inputs-slider-input'; + _rangeInputEl.setAttribute('type', 'range'); + _rangeInputEl.addEventListener('input', inputRecorder.wrapUserInputListener({ + listener: function () { + if (_disabled) { return; } + _currVal = +this.value; + updateRangeInputViewValue(_currVal); + dispatchRangeInputChangedEvent(); + }, + op: 'slide', + createRecordArgs: function () { + return [+this.value]; + }, + prepareReplay: function (recordArgs) { + _rangeInputEl.value = recordArgs[0]; + return { + this: _rangeInputEl, + arguments: [] + }; } - sliderInputEl.value = state.value; - updateText(state.value); - }, - getState: function () { - return {value: +sliderInputEl.value}; - }, - disable: function (disabled) { - sliderInputEl.disabled = disabled; - resetWrapperCSS(disabled); + })); + _rangeInputEl.setAttribute('min', _minVal); + _rangeInputEl.setAttribute('max', _maxVal); + _rangeInputEl.setAttribute('value', _currVal); + _rangeInputEl.setAttribute('step', _step); + _rangeInputWrapperEl.appendChild(_rangeInputEl); + + _rangeInputValueEl = document.createElement('span'); + _rangeInputValueEl.className = 'test-inputs-slider-value'; + _rangeInputWrapperEl.appendChild(_rangeInputValueEl); + + updateRangeInputViewValue(_currVal); + resetRangeInputDisabled(inputDefine); + } + + function updateRangeInputViewValue(newVal) { + _rangeInputEl.value = +newVal; + _rangeInputValueEl.innerHTML = encodeHTML(newVal + ''); + } + function resetRangeInputWrapperCSS(wrapperEl, disabled) { + wrapperEl.className = 'test-inputs-slider' + + (internallyForceDef ? ' test-inputs-slider-sub' : '') + + (disabled ? ' test-inputs-slider-disabled' : ''); + + (_noDeltaButtons ? ' test-inputs-slider-no-delta-buttons' : ''); + } + function setRangeInputState(state) { + if (!isObject(state)) { + console.error( + errMsgPrefix + ' Range input state must be object rather than ' + printObject(state) + + ' May caused by test case change.' + ); + return; } - }; - } + var newVal = +state.value; + if (!isFinite(newVal)) { + console.error( + errMsgPrefix + ' Range input state.value must be number rather than ' + printObject(state) + + ' May caused by test case change.' + ); + return; + } + _currVal = newVal; + resetRangeInputDisabled({disabled: state.disabled}); + updateRangeInputViewValue(_currVal); + } + function getRangeInputState() { + return { + value: _currVal, + disabled: _disabled, + }; + } + function resetRangeInputDisabled(opt) { + _disabled = !!opt.disabled; + _rangeInputEl.disabled = _disabled; + resetRangeInputWrapperCSS(_rangeInputWrapperEl, _disabled); + } + function dispatchRangeInputChangedEvent() { + if (_disabled) { return; } + var target = {value: _currVal}; + _rangeInputListener.call(target, {target: target}); + } + } // End of createRangeInput - function createSelectInput(inputDefine) { + function createSelectInput(inputDefine, inputRecorder) { var selectCtx = { _optionList: [], + _selectWrapperEl: null, _selectEl: null, _optionIdxToSubInput: [], - _elList: [] + _el: null, + _disabled: false, }; - createElementsForSelect(); + var _SAMPLE_SELECT_DEFINITION = [ + '{', + ' type: "select",', + ' text?: "my select:",', + ' options: [', + ' {text?: string, value: any},', + ' {text?: string, input: {type: "range", ...}},', + ' ...,', + ' ],', + ' onchange() { ... },', + '}' + ].join('\n'); + + createSelectInputElements(); var _selectListener = getBtnEventListener(inputDefine, NAMES_ON_INPUT_CHANGE); - if (!_selectListener) { - throw new Error(errMsgPrefix + 'No listener (either ' + NAMES_ON_INPUT_CHANGE.join(', ') + ') specified for select.'); - } - - initOptionsForSelect(inputDefine); - - selectCtx._selectEl.addEventListener('change', function () { - var optionIdx = getOptionIndex(selectCtx._selectEl); - disableSubInputs(optionIdx); - handleSelectChange(getValueByOptionIndex(optionIdx)); - }); + assert( + _selectListener, + errMsgPrefix + ' No listener specified for select. Should have either one of ' + + NAMES_ON_INPUT_CHANGE.join(', ') + '.' + ); + + initSelectInputOptions(inputDefine); + + selectCtx._selectEl.addEventListener('change', inputRecorder.wrapUserInputListener({ + listener: function dispatchSelectInputChangedEvent() { + if (selectCtx._disabled) { return; } + resetSelectInputSubInputsDisabled(); + triggerUserSelectChangedEvent(); + }, + op: 'select', + createRecordArgs: function () { + return [getSelectInputOptionIndex()]; + }, + prepareReplay: function (recordArgs) { + var optionIndex = recordArgs[0]; + validateOptionIndex(optionIndex); + selectCtx._selectEl.value = optionIndex; + return { + this: selectCtx._selectEl, + arguments: [] + }; + } + })); - setInitValue(inputDefine); + setSelectInputInitValue(inputDefine); + resetSelectInputDisabled(inputDefine); return { - elList: selectCtx._elList, - getState: getStateForSelect, - setState: setStateForSelect + elList: [selectCtx._el], + disable: resetSelectInputDisabled, + getState: getSelectInputState, + setState: setSelectInputState, }; - function createElementsForSelect() { + function createSelectInputElements() { var selectWrapperEl = document.createElement('span'); - selectWrapperEl.className = 'test-inputs-select'; + selectCtx._selectWrapperEl = selectWrapperEl; + resetSelectInputWrapperCSS(selectWrapperEl, false); var textEl = document.createElement('span'); textEl.className = 'test-inputs-select-text'; - textEl.innerHTML = getBtnTextHTML(inputDefine, ''); + textEl.innerHTML = getInputsTextHTML(inputDefine, ''); selectWrapperEl.appendChild(textEl); var selectEl = document.createElement('select'); selectEl.className = 'test-inputs-select-select'; selectWrapperEl.appendChild(selectEl); - selectCtx._elList.push(selectWrapperEl); + selectCtx._el = selectWrapperEl; selectCtx._selectEl = selectEl; } - function initOptionsForSelect(inputDefine) { + function resetSelectInputWrapperCSS(selectWrapperEl, disabled) { + selectWrapperEl.className = 'test-inputs-select' + + (disabled ? ' test-inputs-select-disabled' : ''); + } + + function initSelectInputOptions(inputDefine) { // optionDef can be {text, value} or just value // (value can be null/undefined/array/object/... everything). // Convinient but might cause ambiguity when a value happens to be {text, value}, but rarely happen. if (inputDefine.options) { for (var optionIdx = 0; optionIdx < inputDefine.options.length; optionIdx++) { var optionDef = inputDefine.options[optionIdx]; - if ( - !isObject(optionDef) - || ( - !optionDef.hasOwnProperty('value') - && !isObject(optionDef.input) - ) - ) { - throw new Error( - 'Can only be {type: "select", options: {value: any, text?: string, input?: SubInput}[]}' - ); - } + assert(isObject(optionDef), [ + errMsgPrefix + ' Select option definition should be an object, such as,', + _SAMPLE_SELECT_DEFINITION + ].join('\n')); + assert(optionDef.hasOwnProperty('value') || isObject(optionDef.input), [ + errMsgPrefix + ' Select option definition should contain prop' + + ' either `value` or `option`, such as,', + _SAMPLE_SELECT_DEFINITION + ].join('\n')); var text = getType(optionDef.text) === 'string' ? optionDef.text - : makeTextByValue(optionDef); + : makeSelectInputTextByValue(optionDef); selectCtx._optionList.push({ value: optionDef.value, input: optionDef.input, @@ -563,19 +1157,19 @@ var value = inputDefine.values[optionIdx]; selectCtx._optionList.push({ value: value, - text: makeTextByValue({value: value}) + text: makeSelectInputTextByValue({value: value}) }); } } if (!selectCtx._optionList.length) { - throw new Error(errMsgPrefix + 'No options specified for select.'); + throw new Error(errMsgPrefix + ' No options specified for select.'); } for (var optionIdx = 0; optionIdx < selectCtx._optionList.length; optionIdx++) { var optionDef = selectCtx._optionList[optionIdx]; selectCtx._optionList[optionIdx] = optionDef; var optionEl = document.createElement('option'); - optionEl.innerHTML = testHelper.encodeHTML(optionDef.text); + optionEl.innerHTML = encodeHTML(optionDef.text); // HTML select.value is always string. But it would be more convenient to // convert it to user's raw input value type. // (The input raw value can be null/undefined/array/object/... everything). @@ -584,60 +1178,105 @@ if (optionDef.input) { if (arrayIndexOf(NAMES_TYPE_RANGE, optionDef.input.type) < 0) { - throw new Error(errMsgPrefix + 'Sub input only supported for range input.'); + throw new Error(errMsgPrefix + ' Sub input only supported for range input.'); } - var createdRangeInput = createRangeInput(optionDef.input, { + var rangeInputCreated = createRangeInput(optionDef.input, { text: '', onchange: function () { - handleSelectChange(this.value) + if (selectCtx._disabled) { return; } + triggerUserSelectChangedEvent(); } - }); - selectCtx._elList.push(createdRangeInput.el); - selectCtx._optionIdxToSubInput[optionIdx] = createdRangeInput; + }, inputRecorder); + for (var idx = 0; idx < rangeInputCreated.elList.length; idx++) { + selectCtx._el.appendChild(rangeInputCreated.elList[idx]); + } + selectCtx._optionIdxToSubInput[optionIdx] = rangeInputCreated; } } } - function getStateForSelect() { - var subInputState = {}; - for (var optionIdx = 0; optionIdx < selectCtx._optionIdxToSubInput.length; optionIdx++) { - if (selectCtx._optionIdxToSubInput[optionIdx]) { - subInputState[optionIdx] = selectCtx._optionIdxToSubInput[optionIdx].getState(); + function resetSelectInputDisabled(opt) { + selectCtx._disabled = !!opt.disabled; + selectCtx._selectEl.disabled = selectCtx._disabled; + resetSelectInputWrapperCSS(selectCtx._selectWrapperEl, selectCtx._disabled); + resetSelectInputSubInputsDisabled(); + } + + function getSelectInputState() { + var optionIndex = getSelectInputOptionIndex(); + var state = {}; + state.optionIndex = optionIndex; + state.disabled = selectCtx._disabled; + if (selectCtx._optionIdxToSubInput.length) { // Make literal state short to save space. + state.optionStateMap = {}; + for (var optionIdx = 0; optionIdx < selectCtx._optionIdxToSubInput.length; optionIdx++) { + if (selectCtx._optionIdxToSubInput[optionIdx]) { + state.optionStateMap[optionIdx] = selectCtx._optionIdxToSubInput[optionIdx].getState(); + } } } - return { - valueIndex: getOptionIndex(selectCtx._selectEl), - subInputState: subInputState - }; + return state; } - function setStateForSelect(state) { - if (state == null - || getType(state.valueIndex) !== 'number' - || !isObject(state.subInputState) - ) { - throw new Error(errMsgPrefix + 'Invalid state: ' + printObject(state) + ' for select'); + function setSelectInputState(state) { + if (!isObject(state)) { + console.error( + errMsgPrefix + ' Invalid select input state: ' + printObject(state) + + ' May caused by test case change.' + ); + return; } - resetOptionIndex(state.valueIndex); - for (var optionIdx in state.subInputState) { - if (state.subInputState.hasOwnProperty(optionIdx)) { + if (!validateOptionIndex(state.optionIndex)) { + return; + } + + var optionStateMap = state.optionStateMap || {}; + for (var optionIdx in optionStateMap) { + if (state.optionStateMap.hasOwnProperty(optionIdx)) { var subInput = selectCtx._optionIdxToSubInput[optionIdx]; - if (subInput) { - subInput.setState(state.subInputState[optionIdx]); + if (!subInput) { + console.error( + errMsgPrefix + ' Invalid select input state: ' + printObject(state) + + ' Can not find a sub-input by optionIndex: ' + optionIdx + '.' + + ' May caused by test case change.' + ); + return; } } } + for (var optionIdx in optionStateMap) { + if (state.optionStateMap.hasOwnProperty(optionIdx)) { + var subInput = selectCtx._optionIdxToSubInput[optionIdx]; + subInput.setState(state.optionStateMap[optionIdx]); + } + } + resetSelectInputDisabled({disabled: state.disabled}); + resetSelectInputOptionIndex(state.optionIndex); } - function setInitValue(inputDefine) { + function validateOptionIndex(optionIndex) { + if (getType(optionIndex) !== 'number' + || optionIndex < 0 + || optionIndex >= selectCtx._optionList.length + ) { + console.error( + errMsgPrefix + ' Invalid select, optionIndex: ' + optionIndex + ' is out if range.' + + ' May caused by test case change.' + ); + return false; + } + return true; + } + + function setSelectInputInitValue(inputDefine) { var initOptionIdx = 0; - if (inputDefine.hasOwnProperty('valueIndex')) { - var valueIndex = inputDefine.valueIndex; - if (valueIndex < 0 || valueIndex >= selectCtx._optionList.length) { - throw new Error(errMsgPrefix + 'Invalid valueIndex: ' + valueIndex); + var initOptionIdxOpt = retrieveValue(inputDefine.optionIndex, inputDefine.valueIndex, undefined); + if (initOptionIdxOpt != null) { + if (initOptionIdxOpt < 0 || initOptionIdxOpt >= selectCtx._optionList.length) { + throw new Error(errMsgPrefix + ' Invalid optionIndex: ' + initOptionIdxOpt); } - selectCtx._selectEl.value = selectCtx._optionList[valueIndex].value; - initOptionIdx = valueIndex; + selectCtx._selectEl.value = selectCtx._optionList[initOptionIdxOpt].value; + initOptionIdx = initOptionIdxOpt; } else if (inputDefine.hasOwnProperty('value')) { var found = false; @@ -648,42 +1287,48 @@ } } if (!found) { - throw new Error(errMsgPrefix + 'Value not found in select options: ' + inputDefine.value); + throw new Error(errMsgPrefix + ' Value not found in select options: ' + inputDefine.value); } } - resetOptionIndex(initOptionIdx); + resetSelectInputOptionIndex(initOptionIdx); } - function resetOptionIndex(optionIdx) { - disableSubInputs(optionIdx); + function resetSelectInputOptionIndex(optionIdx) { selectCtx._selectEl.value = optionIdx; + resetSelectInputSubInputsDisabled(); } - function getOptionIndex(optionIndexHost) { - return +optionIndexHost.value; + function getSelectInputOptionIndex() { + return +selectCtx._selectEl.value; } - function getValueByOptionIndex(optionIdx) { + function getSelectInputValueByOptionIndex(optionIdx) { return selectCtx._optionList[optionIdx].input ? selectCtx._optionIdxToSubInput[optionIdx].getState().value : selectCtx._optionList[optionIdx].value; } - function handleSelectChange(value) { + function triggerUserSelectChangedEvent() { + var optionIdx = getSelectInputOptionIndex(); + var value = getSelectInputValueByOptionIndex(optionIdx); var target = {value: value}; _selectListener.call(target, {target: target}); } - function disableSubInputs(currOptionIdx) { + function resetSelectInputSubInputsDisabled() { + var optionIdx = getSelectInputOptionIndex(); for (var i = 0; i < selectCtx._optionIdxToSubInput.length; i++) { var subInput = selectCtx._optionIdxToSubInput[i]; if (subInput) { - subInput.disable(i !== currOptionIdx); + var disabled = selectCtx._disabled + ? true // Disable all options. + : i !== optionIdx // Disable all except current selected option. + subInput.disable({disabled: disabled}); } } } - function makeTextByValue(optionDef) { + function makeSelectInputTextByValue(optionDef) { if (optionDef.hasOwnProperty('value')) { return printObject(optionDef.value, { arrayLineBreak: false, objectLineBreak: false, indent: 0, lineBreak: '' @@ -693,19 +1338,198 @@ return 'range input'; } } - } + } // End of createSelectInput + + function createGroupSetInput(groupSetDefine) { + assert( + getType(groupSetDefine.inputsHeight) === 'number', + '`inputsHeight` is mandatory on groupSet to avoid height change' + + ' to affects visual testing when switching groups.' + ) + assert( + getType(groupSetDefine.groups) === 'array', + '.groups must be an array.' + ); + assert( + groupSetDefine.groups.length > 0, + 'groupset.group must have at least one group' + ); + + var groupSetEl = document.createElement('div'); + initInputsContainer(groupSetEl, groupSetDefine, { + ignoreInputsStyle: true, + className: 'test-inputs-groupset', + }); + var groupSetMarginBottomEl = document.createElement('div'); + groupSetMarginBottomEl.className = 'test-inputs-groupset-margin-bottom'; + + var groupSetTextEl = document.createElement('div'); + groupSetTextEl.className = 'test-inputs-groupset-text'; + groupSetEl.appendChild(groupSetTextEl); + + var groupSetCreated = { + currentGroupIndex: 0, + elList: [groupSetEl, groupSetMarginBottomEl], + children: [], + getState: getGroupSetInputState, + setState: setGroupSetInputState, + switchGroup: switchGroup + }; + + for (var groupIdx = 0; groupIdx < groupSetDefine.groups.length; groupIdx++) { + var groupDefine = groupSetDefine.groups[groupIdx]; + assert(groupDefine, 'groupset.group must not be undefined/null.'); + + var groupChildInputsContainer = document.createElement('div'); + initInputsContainer(groupChildInputsContainer, groupSetDefine, { + ignoreFixHeight: true, + className: 'test-inputs-groupset-group', + }); + groupSetEl.appendChild(groupChildInputsContainer); + + var groupChildId = retrieveId(groupDefine, 'id'); + if (groupChildId == null) { + throw new Error('In group child input, id must be specified.'); + } + + var groupCreated = { + groupParent: groupSetCreated, + inputsContainerEl: groupChildInputsContainer, + groupSetTextEl: groupSetTextEl, + groupDefine: groupDefine, + idList: null, + groupIndex: groupSetCreated.children.length + }; + groupSetCreated.children.push(groupCreated); + + storeToInputDict(groupDefine, groupCreated); + + var inputsDefineList = retrieveInputDefineList(groupDefine).slice(); + + // Cascade `disabled`. + for (var inputIdx = 0; inputIdx < inputsDefineList.length; inputIdx++) { + var inputDefine = inputsDefineList[inputIdx]; + if (!inputDefine) { + continue; + } + assert(isObject(inputDefine)); + inputsDefineList[inputIdx] = inputDefine = Object.assign({}, inputDefine); + inputDefine.disabled = retrieveValue( + inputDefine.disabled, groupDefine.disabled, groupSetDefine.disabled + ); + } + + groupCreated.idList = dealInitEachInput(inputsDefineList, groupChildInputsContainer); + + showHideGroupInGroupSet(groupCreated, false); + } + + showHideGroupInGroupSet(groupSetCreated.children[groupSetCreated.currentGroupIndex], true); + + return groupSetCreated; + + function switchGroup(groupId) { + var groupCreatedToShow = retrieveAndVerifyGroup(groupId); + if (groupCreatedToShow.groupIndex === groupCreatedToShow.groupParent.currentGroupIndex) { + return; + } + var groupCreatedToHide = groupCreatedToShow.groupParent.children[ + groupCreatedToShow.groupParent.currentGroupIndex + ]; + showHideGroupInGroupSet(groupCreatedToHide, false); + showHideGroupInGroupSet(groupCreatedToShow, true); + groupCreatedToShow.groupParent.currentGroupIndex = groupCreatedToShow.groupIndex; + } + + function getGroupSetInputState() { + var state = {currentGroupIndex: groupSetCreated.currentGroupIndex}; + return state; + } + + function setGroupSetInputState(state) { + if (!isObject(state)) { + console.error( + errMsgPrefix + ' Invalid group set state: ' + printObject(state) + + ' May caused by test case change.' + ); + return; + } + var currentGroupIndex = state.currentGroupIndex; + if (getType(currentGroupIndex) !== 'number' + || currentGroupIndex < 0 + || currentGroupIndex >= groupSetCreated.children.length + ) { + console.error( + errMsgPrefix + ' Invalid group set currentGroupIndex: ' + currentGroupIndex + + ' May caused by test case change.' + ); + return; + } + switchGroup(currentGroupIndex); + } + + } // End of createGroupSetInput + + function createButtonInput(inputDefine, inputRecorder) { + var _btnDisabled = false; + var btn = document.createElement('button'); + btn.innerHTML = getInputsTextHTML(inputDefine, 'button'); + var _btnListener = getBtnEventListener(inputDefine, NAMES_ON_CLICK); + assert(_btnListener, 'No button onclick provided.'); + btn.addEventListener('click', inputRecorder.wrapUserInputListener({ + listener: function () { + if (_btnDisabled) { return; } + return _btnListener.apply(this, arguments); + }, + op: 'click' + })); + resetButtonInputDisabled(inputDefine); + + return { + elList: [btn], + disable: resetButtonInputDisabled, + setState: setButtonInputState, + getState: getButtonInputState + }; + + function resetButtonInputDisabled(opt) { + _btnDisabled = !!opt.disabled; + btn.disabled = _btnDisabled; + } + function getButtonInputState() { + return {disabled: _btnDisabled}; + } + function setButtonInputState(state) { + if (!isObject(state)) { + console.error( + errMsgPrefix + ' Button input state must be object rather than ' + printObject(state) + + ' May caused by test case change.' + ); + return; + } + resetButtonInputDisabled(state); + } + } // End of createButtonInput function createBr(inputDefine) { - return document.createElement('br'); + return {elList: [document.createElement('br')]}; } - function createButtonInput(inputDefine) { - var btn = document.createElement('button'); - btn.innerHTML = getBtnTextHTML(inputDefine, 'button'); - btn.addEventListener('click', getBtnEventListener(inputDefine, NAMES_ON_CLICK)); - return btn; + function createHr(inputDefine) { + var _hrWrapperEl = document.createElement('div'); + _hrWrapperEl.className = 'test-inputs-hr' + var textEl = document.createElement('span'); + textEl.className = 'test-inputs-hr-text'; + _hrWrapperEl.appendChild(textEl); + var text = textEl.innerHTML = getInputsTextHTML(inputDefine, ''); + textEl.style.display = text ? 'block' : 'none'; + + return { + elList: [_hrWrapperEl] + }; } - } + + } // End of initInputs function initRecordCanvas(opt, chart, recordCanvasContainer) { if (!opt.recordCanvas) { @@ -761,6 +1585,156 @@ } } + /** + * @param {EChartsInstance} chart + * @param {Parameter<testHelper.create, 2>['boundingRect']} opt.boundingRect + */ + function initShowBoundingRects(chart, echarts, opt, boundingRectsContainer) { + assert(chart.__testHelper); + + var _bRectZr; + var _bRectGroup; + // @type Parameter<testHelper.create, 2>['boundingRect'] + var _currBoundingRectOpt = false; + + chart.__testHelper.updateBoundingRects + = chart.__testHelper.updateBoundingRect + = chart.__testHelper.boundingRect + = chart.__testHelper.boundingRects + = updateBoundingRects; + + updateBoundingRects(opt.boundingRect); + + return; + + function updateBoundingRects(opt) { + if (arguments.length > 0) { + _currBoundingRectOpt = opt; + } // If no opt, keep the last one. + + _currBoundingRectOpt + ? buildBoundingRects(_currBoundingRectOpt) + : disableBoundingRects(); + } + + function ensureBoundingRectsFacilities() { + // zr requires size non-zero. + boundingRectsContainer.style.width = chart.getWidth() + 'px'; + boundingRectsContainer.style.height = chart.getHeight() + 'px'; + + if (_bRectZr) { + _bRectZr.resize(); + return; + } + + _bRectGroup = new echarts.graphic.Group(); + _bRectGroup.__testHelperBoundingRectsRoot = true; + _bRectGroup.on('click', function (event) { + var target = event.target; + if (!target || !target.__testHelperBoundingRectTarget) { + return; + } + var wrapper = { + boundingRect: target, + rawElement: target.__testHelperBoundingRectTarget + }; + console.log('boundingRect:', wrapper.boundingRect); + console.log('rawElement:', wrapper.rawElement); + window.$0 = wrapper; + }); + _bRectZr = echarts.zrender.init(boundingRectsContainer); + _bRectZr.add(_bRectGroup); + } + + function disableBoundingRects() { + chart.off('finished', updateBoundingRects); + boundingRectsContainer.style.display = 'none'; + if (_bRectGroup) { + _bRectGroup.removeAll(); + } + } + + function buildBoundingRects(boundingRectOpt) { + ensureBoundingRectsFacilities(); + boundingRectOpt = isObject(boundingRectOpt) ? boundingRectOpt : {}; + + boundingRectsContainer.style.display = 'block'; + _bRectGroup.removeAll(); + + var strokeColor = boundingRectOpt.color || 'rgba(0,0,255,0.5)'; + var silent = boundingRectOpt.silent != null ? boundingRectOpt.silent : false; + + boundingRectsContainer.style.pointerEvent = silent ? 'none' : 'auto'; + + var roots = chart.getZr().storage.getRoots(); + for (var rootIdx = 0; rootIdx < roots.length; rootIdx++) { + travelGroupAndBuildRects(roots[rootIdx], _bRectGroup); + } + + // Follow chart update and resize. + chart.on('finished', updateBoundingRects); + + return; + + function travelGroupAndBuildRects(group, visualRectGroupParent) { + var visualRectGroup = createVisualRectGroup(group, visualRectGroupParent) + group.eachChild(function (child) { + if (child.isGroup) { + travelGroupAndBuildRects(child, visualRectGroup); + return; + } + + createRectForDisplayable(child, visualRectGroup); + + var textContent = child.getTextContent(); + var textGuildLine = child.getTextGuideLine(); + if (textContent || textGuildLine) { + textContent && createRectForDisplayable(textContent, _bRectGroup, true); + textGuildLine && createRectForDisplayable(textGuildLine, _bRectGroup, true); + } + }); + + function createVisualRectGroup(fromEl, visualRectGroupParent) { + var visualRectGroup = new echarts.graphic.Group(); + copyTransformAttrs(visualRectGroup, fromEl); + visualRectGroupParent.add(visualRectGroup); + return visualRectGroup; + } + + function createRectForDisplayable(el, visualRectGroup, useInnerTransformable) { + var elRawRect = el.getBoundingRect(); + var visualRect = new echarts.graphic.Rect({ + shape: {x: elRawRect.x, y: elRawRect.y, width: elRawRect.width, height: elRawRect.height}, + style: {fill: null, stroke: strokeColor, lineWidth: 1, strokeNoScale: true}, + silent: silent, + z: Number.MAX_SAFE_INTEGER + }); + visualRect.__testHelperBoundingRectTarget = el; + var transAttrSource = el; + if (useInnerTransformable && el.innerTransformable) { + transAttrSource = el.innerTransformable; + } + copyTransformAttrs(visualRect, transAttrSource); + visualRectGroup.add(visualRect); + } + } + + function copyTransformAttrs(target, source) { + target.x = source.x; + target.y = source.y; + target.rotation = source.rotation; + target.scaleX = source.scaleX; + target.scaleY = source.scaleY; + target.originX = source.originX; + target.originY = source.originY; + target.skewX = source.skewX; + target.skewY = source.skewY; + target.anchorX = source.anchorX; + target.anchorY = source.anchorY; + } + } + } + testHelper.createRecordVideo = function (chart, recordVideoContainer) { var button = document.createElement('button'); button.innerHTML = 'Start Recording'; @@ -820,7 +1794,7 @@ if (opt.draggable) { if (!window.draggable) { throw new Error( - errMsgPrefix + 'Pleasse add the script in HTML: \n' + errMsgPrefix + ' Pleasse add the script in HTML: \n' + '<script src="lib/draggable.js"></script>' ); } @@ -893,9 +1867,10 @@ resultDom.style.cssText = [ 'position: absolute;', 'left: 20px;', + 'pointer-events: none;', 'font-size: ' + fontSize + 'px;', 'z-index: ' + (failErr ? 99999 : 88888) + ';', - 'color: ' + (failErr ? 'red' : 'green') + ';', + 'color: ' + (failErr ? 'rgba(150,0,0,0.8)' : 'rgba(0,150,0,0.8)') + ';', ].join(''); printAssertRecord.push(resultDom); hostDOMEl.appendChild(resultDom); @@ -1016,13 +1991,16 @@ var newHeight = dom.clientHeight; if (width !== newWidth || height !== newHeight) { chart.resize(); + if (chart.__testHelper && chart.__testHelper.updateBoundingRects) { + chart.__testHelper.updateBoundingRects(); + } width = newWidth; height = newHeight; } } if (window.attachEvent) { // Use builtin resize in IE - window.attachEvent('onresize', chart.resize); + window.attachEvent('onresize', resize); } else if (window.addEventListener) { window.addEventListener('resize', resize, false); @@ -1078,7 +2056,7 @@ return '/' + resolvedPath; }; - testHelper.encodeHTML = function (source) { + var encodeHTML = testHelper.encodeHTML = function (source) { return String(source) .replace(/&/g, '&') .replace(/</g, '<') @@ -1087,12 +2065,30 @@ .replace(/'/g, '''); }; + var encodeJSObjectKey = function (source, quotationMark) { + source = '' + source; + if (!/^[a-zA-Z$_][a-zA-Z0-9$_]*$/.test(source)) { + source = convertStringToJSLiteral(source, quotationMark); + } + return source; + }; + + var convertStringToJSLiteral = function (str, quotationMark) { + // assert(getType(str) === 'string'); + // assert(quotationMark === '"' || quotationMark === "'"); + str = JSON.stringify(str); // escapse \n\r or others. + if (quotationMark === "'") { + str = "'" + str.slice(1, str.length - 1).replace(/'/g, "\\'") + "'"; + } + return str; + } + /** * @usage * var result = retrieveValue(val, defaultVal); * var result = retrieveValue(val1, val2, defaultVal); */ - testHelper.retrieveValue = function() { + var retrieveValue = testHelper.retrieveValue = function () { for (var i = 0, len = arguments.length; i < len; i++) { var val = arguments[i]; if (val != null) { @@ -1121,7 +2117,7 @@ return !!TYPED_ARRAY[objToString.call(value)] ? 'typedArray' - : typeof type === 'function' + : typeof value === 'function' ? 'function' : typeStr === '[object Array]' ? 'array' @@ -1156,11 +2152,14 @@ * @param {*} object * @param {opt|string} [opt] If string, means key. * @param {string} [opt.key=''] Top level key, if given, print like: 'someKey: [asdf]' - * @param {string} [opt.objectLineBreak=true] - * @param {string} [opt.arrayLineBreak=false] + * @param {number} [opt.lineBreakMaxColumn=80] If the content in a single line is greater than + * `maxColumn` (indent is not included), line break. + * @param {boolean} [opt.objectLineBreak=undefined] Whether to line break. undefined/null means auto. + * @param {boolean} [opt.arrayLineBreak=undefined] Whether to line break. undefined/null means auto. * @param {string} [opt.indent=4] + * @param {string} [opt.marginLeft=0] Spaces number for margin left of the entire text. * @param {string} [opt.lineBreak='\n'] - * @param {string} [opt.quotationMark='\''] + * @param {string} [opt.quotationMark="'"] "'" or '"'. */ var printObject = testHelper.printObject = function (obj, opt) { opt = typeof opt === 'string' @@ -1169,16 +2168,28 @@ var indent = opt.indent != null ? opt.indent : 4; var lineBreak = opt.lineBreak != null ? opt.lineBreak : '\n'; - var quotationMark = opt.quotationMark != null ? opt.quotationMark : '\''; + var quotationMark = ({'"': '"', "'": "'"})[opt.quotationMark] || "'"; + var marginLeft = opt.marginLeft || 0; + var lineBreakMaxColumn = opt.lineBreakMaxColumn || 80; + var forceObjectLineBreak = opt.objectLineBreak === true || opt.objectLineBreak === false; + var forceArrayLineBreak = opt.arrayLineBreak === true || opt.arrayLineBreak === false; - return doPrint(obj, opt.key, 0).str; + return (new Array(marginLeft + 1)).join(' ') + doPrint(obj, opt.key, 0).str; function doPrint(obj, key, depth) { - var codeIndent = (new Array(depth * indent + 1)).join(' '); - var subCodeIndent = (new Array((depth + 1) * indent + 1)).join(' '); + var codeIndent = (new Array(depth * indent + marginLeft + 1)).join(' '); + var subCodeIndent = (new Array((depth + 1) * indent + marginLeft + 1)).join(' '); var hasLineBreak = false; + // [ + // 11, 22, 33, 44, 55, 66, // This is a partial break. + // 77, 88, 99 + // ] + var preventParentArrayPartiallyBreak = false; - var preStr = key != null ? (key + ': ' ) : ''; + var preStr = ''; + if (key != null) { + preStr += encodeJSObjectKey(key, quotationMark) + ': '; + } var str; var objType = getType(obj); @@ -1186,7 +2197,10 @@ switch (objType) { case 'function': hasLineBreak = true; - str = preStr + quotationMark + obj + quotationMark; + preventParentArrayPartiallyBreak = true; + var fnStr = obj.toString(); + var isMethodShorthand = key != null && isMethodShorthandNotAccurate(fnStr, obj.name, key); + str = (isMethodShorthand ? '' : preStr) + fnStr; break; case 'regexp': case 'date': @@ -1194,63 +2208,194 @@ break; case 'array': case 'typedArray': - hasLineBreak = opt.arrayLineBreak != null ? opt.arrayLineBreak : false; + if (forceArrayLineBreak) { + hasLineBreak = !!opt.arrayLineBreak; + } // If no break line in array, print in single line, like [12, 23, 34]. // else, each item takes a line. var childBuilder = []; + var maxColumnWithoutLineBreak = preStr.length; + var canPartiallyBreak = true; for (var i = 0, len = obj.length; i < len; i++) { var subResult = doPrint(obj[i], null, depth + 1); childBuilder.push(subResult.str); + if (subResult.hasLineBreak) { hasLineBreak = true; } + else { + maxColumnWithoutLineBreak += subResult.str.length + 2; // `2` is ', '.length + } + + if (subResult.preventParentArrayPartiallyBreak) { + preventParentArrayPartiallyBreak = true; + canPartiallyBreak = false + } + } + if (obj.length > 3) { + // `3` is an arbitrary value, considering a path array: + // [ + // [1,2], [3,4], [5,6], + // [7,8], [9,10] + // ] + preventParentArrayPartiallyBreak = true; + } + if (!forceObjectLineBreak && maxColumnWithoutLineBreak > lineBreakMaxColumn) { + hasLineBreak = true; } var tail = hasLineBreak ? lineBreak : ''; - var delimiter = ',' + (hasLineBreak ? (lineBreak + subCodeIndent) : ' '); var subPre = hasLineBreak ? subCodeIndent : ''; var endPre = hasLineBreak ? codeIndent : ''; - str = '' - + preStr + '[' + tail - + subPre + childBuilder.join(delimiter) + tail - + endPre + ']'; + var delimiterInline = ', '; + var delimiterBreak = ',' + lineBreak + subCodeIndent; + if (!childBuilder.length) { + str = preStr + '[]'; + } + else { + var subContentStr = ''; + var subContentMaxColumn = 0; + if (canPartiallyBreak && hasLineBreak) { + for (var idx = 0; idx < childBuilder.length; idx++) { + var childStr = childBuilder[idx]; + subContentMaxColumn += childStr.length + delimiterInline.length; + if (idx === childBuilder.length - 1) { + subContentStr += childStr; + } + else if (subContentMaxColumn > lineBreakMaxColumn) { + subContentStr += childStr + delimiterBreak; + subContentMaxColumn = 0; + } + else { + subContentStr += childStr + delimiterInline; + } + } + } + else { + subContentStr = childBuilder.join(hasLineBreak ? delimiterBreak : delimiterInline); + } + str = '' + + preStr + '[' + tail + + subPre + subContentStr + tail + + endPre + ']'; + } break; case 'object': - hasLineBreak = opt.objectLineBreak != null ? opt.objectLineBreak : true; + if (forceObjectLineBreak) { + hasLineBreak = !!opt.objectLineBreak; + } var childBuilder = []; + var maxColumnWithoutLineBreak = preStr.length; + var keyCount = 0; for (var i in obj) { if (obj.hasOwnProperty(i)) { + keyCount++; var subResult = doPrint(obj[i], i, depth + 1); childBuilder.push(subResult.str); + if (subResult.hasLineBreak) { hasLineBreak = true; } + else { + maxColumnWithoutLineBreak += subResult.str.length + 2; // `2` is ', '.length + } + + if (subResult.preventParentArrayPartiallyBreak) { + preventParentArrayPartiallyBreak = true; + } } } - str = '' - + preStr + '{' + (hasLineBreak ? lineBreak : '') - + (childBuilder.length - ? (hasLineBreak ? subCodeIndent : '') + childBuilder.join(',' + (hasLineBreak ? lineBreak + subCodeIndent: ' ')) + (hasLineBreak ? lineBreak: '') - : '' - ) - + (hasLineBreak ? codeIndent : '') + '}'; + if (keyCount > 1) { + // `3` is an arbitrary value, considering case like: + // [ + // {name: 'xx'}, {name: 'yy'}, {name: 'zz'}, + // {name: 'aa'}, {name: 'bb'} + // ] + preventParentArrayPartiallyBreak = true; + } + if (!forceObjectLineBreak && maxColumnWithoutLineBreak > lineBreakMaxColumn) { + hasLineBreak = true; + } + if (!childBuilder.length) { + str = preStr + '{}'; + } + else { + str = '' + + preStr + '{' + (hasLineBreak ? lineBreak : '') + + (hasLineBreak ? subCodeIndent : '') + + childBuilder.join(',' + (hasLineBreak ? lineBreak + subCodeIndent: ' ')) + + (hasLineBreak ? lineBreak: '') + + (hasLineBreak ? codeIndent : '') + '}'; + } break; case 'boolean': case 'number': str = preStr + obj + ''; break; case 'string': - str = JSON.stringify(obj); // escapse \n\r or others. - str = preStr + quotationMark + str.slice(1, str.length - 1) + quotationMark; + str = preStr + convertStringToJSLiteral(obj, quotationMark); break; default: str = preStr + obj + ''; + preventParentArrayPartiallyBreak = true; } return { str: str, - hasLineBreak: hasLineBreak + hasLineBreak: hasLineBreak, + isMethodShorthand: isMethodShorthand, + preventParentArrayPartiallyBreak: preventParentArrayPartiallyBreak }; } + + /** + * Simple implementation for detecting method shorthand, such as, + * ({abc() { return 1; }}).abc is a method shorthand and needs to + * be serialized as `{abc() { return 1; }}` rather than `{abc: abc() { return 1; }}`. + * Those cases can be detected: + * ({abc() { console.log('=>'); return 1; }}).abc expected: IS_SHORTHAND + * ({abc(x, y = 5) { return 1; }}).abc expected: IS_SHORTHAND + * ({$ab_c() { return 1; }}).$ab_c expected: IS_SHORTHAND + * ({*abc() { return 1; }}).abc expected: IS_SHORTHAND + * ({* abc() { return 1; }}).abc expected: IS_SHORTHAND + * ({async abc() { return 1; }}).abc expected: IS_SHORTHAND + * ({*abc() { yield 1; }}).abc expected: IS_SHORTHAND + * ({abc(x, y) { return x + y; }}).abc expected: IS_SHORTHAND + * ({abc: function abc() { return 1; }}).abc expected: NOT_SHORTHAND + * ({abc: function def() { return 1; }}).abc expected: NOT_SHORTHAND + * ({abc: function() { return 1; }}).abc expected: NOT_SHORTHAND + * ({abc: function* () { return 1; }}).abc expected: NOT_SHORTHAND + * ({abc: function (aa, bb) { return 1; }}).abc expected: NOT_SHORTHAND + * ({abc: function (aa, bb = 5) { return 1; }}).abc expected: NOT_SHORTHAND + * ({abc: async () => { return 1; }}).abc expected: NOT_SHORTHAND + * ({abc: () => { return 1; }}).abc expected: NOT_SHORTHAND + * ({abc: (aa, bb = 5) => { return 1; }}).abc expected: NOT_SHORTHAND + * FIXME: fail at some rare cases, such as: + * Literal string involved, like: + * ({"ab-() ' =>c"() { return 1; }})["ab-() ' =>c"] expected: IS_SHORTHAND + * ({async "ab-c"() { return 1; }})["ab-c"] expected: IS_SHORTHAND + * Computed property name involved, like: + * ({[some]() { return 1; }})[some] expected: IS_SHORTHAND + */ + function isMethodShorthandNotAccurate(fnStr, fnName, objKey) { + // Assert fnStr, fnName, objKey is a string. + if (fnName !== objKey) { + return false; + } + var matched = fnStr.match(/^\s*(async\s+)?(function\s*)?(\*\s*)?([a-zA-Z$_][a-zA-Z0-9$_]*)?\s*\(/); + if (!matched) { + return false; + } + if (matched[2]) { // match 'function' + return false; + } + // May enhanced by /(['"])(?:(?=(\\?))\2.)*?\1/; to match literal string, + // such as "ab-c", "a\nc". But this simple impl does not cover it. + if (!matched[4] || matched[4] !== objKey) { // match "maybe function name" + return false; + } + return true; + } + }; /** @@ -1270,7 +2415,7 @@ * @param {function} [opt.filter] print a subtree only if any satisfied node exists. * param: el, return: boolean */ - testHelper.stringifyElements = function (chart, opt) { + var stringifyElements = testHelper.stringifyElements = function (chart, opt) { if (!chart) { return; } @@ -1360,7 +2505,7 @@ * * @see `stringifyElements`. */ - testHelper.printElements = function (chart, opt) { + var printElements = testHelper.printElements = function (chart, opt) { var elsStr = testHelper.stringifyElements(chart, opt); console.log(elsStr); }; @@ -1380,7 +2525,7 @@ * param: el, return: boolean * @return {Array.<Element>} */ - testHelper.retrieveElements = function (chart, opt) { + var retrieveElements = testHelper.retrieveElements = function (chart, opt) { if (!chart) { return; } @@ -1449,6 +2594,20 @@ document.body.appendChild(canvas); }; + function initDataTables(opt, dataTableContainer) { + var dataTables = opt.dataTables; + if (!dataTables && opt.dataTable) { + dataTables = [opt.dataTable]; + } + if (dataTables) { + var tableHTML = []; + for (var i = 0; i < dataTables.length; i++) { + tableHTML.push(createDataTableHTML(dataTables[i], opt)); + } + dataTableContainer.innerHTML = tableHTML.join(''); + } + } + function createDataTableHTML(data, opt) { var sourceFormat = detectSourceFormat(data); var dataTableLimit = opt.dataTableLimit || DEFAULT_DATA_TABLE_LIMIT; @@ -1465,7 +2624,7 @@ var htmlLine = ['<tr>']; for (var j = 0; j < line.length; j++) { var val = i === dataTableLimit ? '...' : line[j]; - htmlLine.push('<td>' + testHelper.encodeHTML(val) + '</td>'); + htmlLine.push('<td>' + encodeHTML(val) + '</td>'); } htmlLine.push('</tr>'); html.push(htmlLine.join('')); @@ -1478,9 +2637,9 @@ for (var key in line) { if (line.hasOwnProperty(key)) { var keyText = i === dataTableLimit ? '...' : key; - htmlLine.push('<td class="test-data-table-key">' + testHelper.encodeHTML(keyText) + '</td>'); + htmlLine.push('<td class="test-data-table-key">' + encodeHTML(keyText) + '</td>'); var val = i === dataTableLimit ? '...' : line[key]; - htmlLine.push('<td>' + testHelper.encodeHTML(val) + '</td>'); + htmlLine.push('<td>' + encodeHTML(val) + '</td>'); } } htmlLine.push('</tr>'); @@ -1490,12 +2649,12 @@ else if (sourceFormat === 'keyedColumns') { for (var key in data) { var htmlLine = ['<tr>']; - htmlLine.push('<td class="test-data-table-key">' + testHelper.encodeHTML(key) + '</td>'); + htmlLine.push('<td class="test-data-table-key">' + encodeHTML(key) + '</td>'); if (data.hasOwnProperty(key)) { var col = data[key] || []; for (var i = 0; i < col.length && i <= dataTableLimit; i++) { var val = i === dataTableLimit ? '...' : col[i]; - htmlLine.push('<td>' + testHelper.encodeHTML(val) + '</td>'); + htmlLine.push('<td>' + encodeHTML(val) + '</td>'); } } htmlLine.push('</tr>'); @@ -1531,7 +2690,7 @@ function createObjectHTML(obj, key) { var html = isObject(obj) - ? testHelper.encodeHTML(printObject(obj, key)) + ? encodeHTML(printObject(obj, key)) : obj ? obj.toString() : ''; @@ -1600,6 +2759,12 @@ return -1; } + var assert = testHelper.assert = function (cond, msg) { + if (!cond) { + throw new Error(msg || 'Assertion failed.'); + } + } + function makeFlexibleNames(dashedNames) { var nameMap = {}; for (var i = 0; i < dashedNames.length; i++) { @@ -1626,6 +2791,71 @@ return names; } + /** + * Copied from src/util/number.ts + */ + function getPrecision(val) { + val = +val; + if (isNaN(val)) { + return 0; + } + + // It is much faster than methods converting number to string as follows + // let tmp = val.toString(); + // return tmp.length - 1 - tmp.indexOf('.'); + // especially when precision is low + // Notice: + // (1) If the loop count is over about 20, it is slower than `getPrecisionSafe`. + // (see https://jsbench.me/2vkpcekkvw/1) + // (2) If the val is less than for example 1e-15, the result may be incorrect. + // (see test/ut/spec/util/number.test.ts `getPrecision_equal_random`) + if (val > 1e-14) { + var e = 1; + for (var i = 0; i < 15; i++, e *= 10) { + if (Math.round(val * e) / e === val) { + return i; + } + } + } + + return getPrecisionSafe(val); + } + + /** + * Copied from src/util/number.ts + * Get precision with slow but safe method + */ + function getPrecisionSafe(val) { + // toLowerCase for: '3.4E-12' + var str = val.toString().toLowerCase(); + + // Consider scientific notation: '3.4e-12' '3.4e+12' + var eIndex = str.indexOf('e'); + var exp = eIndex > 0 ? +str.slice(eIndex + 1) : 0; + var significandPartLen = eIndex > 0 ? eIndex : str.length; + var dotIndex = str.indexOf('.'); + var decimalPartLen = dotIndex < 0 ? 0 : significandPartLen - 1 - dotIndex; + return Math.max(0, decimalPartLen - exp); + } + + /** + * Copied from src/util/number.ts + */ + function round(x, precision, returnStr) { + if (precision == null) { + precision = 10; + } + // Avoid range error + precision = Math.min(Math.max(0, precision), ROUND_SUPPORTED_PRECISION_MAX); + // PENDING: 1.005.toFixed(2) is '1.00' rather than '1.01' + x = (+x).toFixed(precision); + return (returnStr ? x : +x); + } + // Although chrome already enlarge this number to 100 for `toFixed`, but + // we sill follow the spec for compatibility. + var ROUND_SUPPORTED_PRECISION_MAX = 20; + + function objectNoOtherNotNullUndefinedPropExcept(obj, exceptProps) { if (!obj) { return false; @@ -1638,9 +2868,94 @@ return true; } + var copyToClipboard = function (text) { + if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) { + console.error('[clipboard] Can not copy to clipboard.'); + return; + } + return navigator.clipboard.writeText(text).then(function () { + console.log('[clipboard] Text copied to clipboard.'); + }).catch(function (err) { + console.error('[clipboard] Failed to copy text: ', err); // Just print for easy to use. + return err; + }); + }; + + /** + * A shortcut for both stringify and copy to clipboard. + * + * @param {any} val Any val to stringify and copy to clipboard. + * @param {Object?} printObjectOpt Optional. + */ + testHelper.clipboard = function (val, printObjectOpt) { + var literal = testHelper.printObject(val, printObjectOpt); + if (document.hasFocus()) { + copyToClipboard(literal); + } + else { + // Handle the error: + // NotAllowedError: Failed to execute 'writeText' on 'Clipboard': Document is not focused. + ensureClipboardButton(); + updateClipboardButton(literal) + console.log( + '⚠️ [clipboard] Please click the new button that appears on the top-left corner of the screen' + + ' to copy to clipboard.' + ); + } + + function updateClipboardButton(text) { + var button = __tmpClipboardButttonWrapper.button; + button.innerHTML = 'Click me to copy to clipboard'; + button.style.display = 'block'; + __tmpClipboardButttonWrapper.text = text; + } + + function ensureClipboardButton() { + var button = __tmpClipboardButttonWrapper.button; + if (button != null) { + return; + } + __tmpClipboardButttonWrapper.button = button = document.createElement('div'); + button.style.cssText = [ + 'height: 80px;', + 'line-height: 80px;', + 'padding: 10px 20px;', + 'margin: 5px;', + 'text-align: center;', + 'position: fixed;', + 'top: 10px;', + 'left: 10px;', + 'z-index: 9999;', + 'cursor: pointer;', + 'color: #fff;', + 'background-color: #333;', + 'border: 2px solid #eee;', + 'border-radius: 5px;', + 'font-size: 18px;', + 'font-weight: bold;', + 'font-family: sans-serif;', + 'box-shadow: 0 4px 10px rgba(0, 0, 0, 0.8);' + ].join(''); + document.body.appendChild(button); + button.addEventListener('click', function () { + copyToClipboard(__tmpClipboardButttonWrapper.text).then(function (err) { + if (!err) { + button.style.display = 'none'; + } + else { + button.innerHTML = 'error, see console log.'; + } + }); + }); + } + // Do not return the text, because it may be too long for a console.log. + }; + var __tmpClipboardButttonWrapper = {}; + + // It may be changed by test case changing. Do not use it as a persistent id. var _idBase = 1; - function generateId(prefix) { - return prefix + '' + (_idBase++); + function generateNonPersistentId(prefix) { + return (prefix || '') + '' + (_idBase++); } function VideoRecorder(chart) { @@ -1707,4 +3022,4 @@ context.testHelper = testHelper; -})(window); \ No newline at end of file +})(window); diff --git a/test/tmp-base.html b/test/tmp-base.html index d8883e373..aaed692e6 100644 --- a/test/tmp-base.html +++ b/test/tmp-base.html @@ -28,7 +28,8 @@ under the License. <script src="lib/jquery.min.js"></script> <script src="lib/facePrint.js"></script> <script src="lib/testHelper.js"></script> - <!-- <script src="ut/lib/canteen.js"></script> --> + <!-- <script src="lib/canteen.js"></script> --> + <!-- <script src="lib/draggable.js"></script> --> <link rel="stylesheet" href="lib/reset.css" /> </head> <body> @@ -41,26 +42,109 @@ under the License. </style> - <div id="main0"></div> + <div id="{{TPL_DOM_ID}}"></div> + <!-- <div id="main_some_other_case_1"></div> --> + <!-- <div id="main_some_other_case_2"></div> --> <script> - var option; - - require([ - 'echarts'/*, 'map/js/china' */ - ], function (echarts) { + require(['echarts'], function (echarts) { + // // Data can be fetched by: // $.getJSON('./data/nutrients.json', function (data) { // }); - var chart = testHelper.create(echarts, 'main0', { + var option = { + xAxis: {}, + yAxis: {}, + series: { + type: 'scatter', + symbolSize: 50, + label: {show: true, position: 'top'}, + data: [[1, 2], [100, 200], [500, 50]] + } + }; + + var chart = testHelper.create(echarts, '{{TPL_DOM_ID}}', { + title: [ + 'A sample **test case** title', + 'multiple lines', + ], option: option, - // recordCanvas: true - }); - }); + // + // -------------------------- Optional settings: -------------------------- + // height: 400, // Optional. Specify a different chart height. + // draggable: true, // Optional. Add a draggable button to mutify the chart size. + // // This feature require "test/lib/draggable.js" + // recordCanvas: true, // Optional. Record canvas instructions. (for debug) + // // This feature requires "test/lib/canteen.js" + // boundingRect: true, // Optional. Show boundingRects of zrender elements (for debug). + // + // ------------------- Inputs (button/range/select/br/hr): ---------------- + // inputsHeight: 30, // Optional. Fix the height of inputs area (scrollable if overflow) + inputsStyle: 'compact', // Optional. + inputs: [ // Optional. The following are sample inputs: + { + type: 'select', + text: '(sample) boundingRect:', + values: [false, true, undefined, {color: 'rgba(255,0,0,0.8)', silent: false}], + onchange: function () { + chart.__testHelper.boundingRect(this.value); + } + }, + { + type: 'range', + text: '(sample) symbolSize:', + // min: -100, // Optional. + // max: 100, // Optional. + // value: 50, // Optional. + onchange: function () { + console.log('range changed:', this.value); + chart.setOption({series: {symbolSize: this.value}}); + } + }, + { + type: 'select', + text: '(sample range embedded select) grid.left:', + options: [ + {value: undefined}, + {value: 30}, + {input: {type: 'range', min: -300, max: 300, value: 50}} + ], + onchange: function () { + var newVal = this.value; + console.log('select 2 changed:', newVal); + chart.setOption({grid: {left: newVal}}); + } + }, + { + type: 'br', // line break + }, + { + text: '(sample) print failures to screen', + onclick: function () { + testHelper.printAssert('{{TPL_DOM_ID}}', function (assert) { + assert(true); + }); + } + }, + { + text: '(sample) copy option to clipboard', + onclick: function () { + // console.log(testHelper.printObject(option)); + testHelper.clipboard(option); + } + } + + ] // End of `inputs` + + }); // End of `testHelper.create` + + }); // End of `require` + </script> + </body> </html> \ No newline at end of file --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
