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, '&amp;')
             .replace(/</g, '&lt;')
@@ -1087,12 +2065,30 @@
             .replace(/'/g, '&#39;');
     };
 
+    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]


Reply via email to