/** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.declareModuleId('Blockly.test.toolbox'); import {defineStackBlock} from './test_helpers/block_definitions.js'; import {sharedTestSetup, sharedTestTeardown} from './test_helpers/setup_teardown.js'; import {getBasicToolbox, getCategoryJSON, getChildItem, getCollapsibleItem, getDeeplyNestedJSON, getInjectedToolbox, getNonCollapsibleItem, getSeparator, getSimpleJson, getXmlArray} from './test_helpers/toolbox_definitions.js'; suite('Toolbox', function() { setup(function() { sharedTestSetup.call(this); defineStackBlock(); }); teardown(function() { sharedTestTeardown.call(this); }); suite('init', function() { setup(function() { this.toolbox = getInjectedToolbox(); }); teardown(function() { this.toolbox.dispose(); }); test('Init called -> HtmlDiv is created', function() { chai.assert.isDefined(this.toolbox.HtmlDiv); }); test('Init called -> HtmlDiv is inserted before parent node', function() { const toolboxDiv = Blockly.common.getMainWorkspace().getInjectionDiv().childNodes[0]; chai.assert.equal(toolboxDiv.className, 'blocklyToolboxDiv blocklyNonSelectable'); }); test('Init called -> Toolbox is subscribed to background and foreground colour', function() { const themeManager = this.toolbox.workspace_.getThemeManager(); const themeManagerSpy = sinon.spy(themeManager, 'subscribe'); const componentManager = this.toolbox.workspace_.getComponentManager(); sinon.stub(componentManager, 'addComponent'); this.toolbox.init(); sinon.assert.calledWith(themeManagerSpy, this.toolbox.HtmlDiv, 'toolboxBackgroundColour', 'background-color'); sinon.assert.calledWith(themeManagerSpy, this.toolbox.HtmlDiv, 'toolboxForegroundColour', 'color'); }); test('Init called -> Render is called', function() { const renderSpy = sinon.spy(this.toolbox, 'render'); const componentManager = this.toolbox.workspace_.getComponentManager(); sinon.stub(componentManager, 'addComponent'); this.toolbox.init(); sinon.assert.calledOnce(renderSpy); }); test('Init called -> Flyout is initialized', function() { const componentManager = this.toolbox.workspace_.getComponentManager(); sinon.stub(componentManager, 'addComponent'); this.toolbox.init(); chai.assert.isDefined(this.toolbox.flyout_); }); }); suite('render', function() { setup(function() { this.toolbox = getInjectedToolbox(); }); teardown(function() { this.toolbox.dispose(); }); test('Render called with valid toolboxDef -> Contents are created', function() { const positionStub = sinon.stub(this.toolbox, 'position'); this.toolbox.render({'contents': [ {'kind': 'category', 'contents': []}, {'kind': 'category', 'contents': []}, ]}); chai.assert.lengthOf(this.toolbox.contents_, 2); sinon.assert.called(positionStub); }); // TODO: Uncomment once implemented. test.skip('Toolbox definition with both blocks and categories -> Should throw an error', function() { const toolbox = this.toolbox; const badToolboxDef = [ { "kind": "block", }, { "kind": "category", }, ]; chai.assert.throws(function() { toolbox.render({'contents': badToolboxDef}); }, 'Toolbox cannot have both blocks and categories in the root level.'); }); // TODO: Uncomment once implemented. test.skip('Expanded set to true for a non collapsible toolbox item -> Should open flyout', function() { this.toolbox.render(this.toolboxXml); const selectedNode = this.toolbox.tree_.children_[0]; chai.assert.isTrue(selectedNode.selected_); }); test('JSON toolbox definition -> Should create toolbox with contents', function() { const jsonDef = {'contents': [ { "kind": "category", "contents": [ { "kind": "block", "blockxml": 'FirstCategory-FirstBlock', }, { "kind": "label", "text": "Input/Output:", "web-class": "ioLabel", }, { "kind": "button", "text": "insert", "callbackkey": "insertConnectionStacks", "web-class": "ioLabel", }, { "kind": "sep", "gap": "7", }, ], }, ]}; this.toolbox.render(jsonDef); chai.assert.lengthOf(this.toolbox.contents_, 1); }); test('multiple icon classes can be applied', function() { const jsonDef = {'contents': [ { "kind": "category", "cssConfig": { "icon": "customIcon customIconEvents", }, "contents": [ { "kind": "block", "blockxml": 'FirstCategory-FirstBlock', }, ], }, ]}; chai.assert.doesNotThrow(() => { this.toolbox.render(jsonDef); }); chai.assert.lengthOf(this.toolbox.contents_, 1); }); }); suite('onClick_', function() { setup(function() { this.toolbox = getInjectedToolbox(); }); teardown(function() { this.toolbox.dispose(); }); test('Toolbox clicked -> Should close flyout', function() { const hideChaffStub = sinon.stub( Blockly.WorkspaceSvg.prototype, "hideChaff"); const evt = new PointerEvent('pointerdown', {}); this.toolbox.HtmlDiv.dispatchEvent(evt); sinon.assert.calledOnce(hideChaffStub); }); test('Category clicked -> Should select category', function() { const categoryXml = document.getElementsByClassName('blocklyTreeRow')[0]; const evt = { 'target': categoryXml, }; const item = this.toolbox.contentMap_[categoryXml.getAttribute('id')]; const setSelectedSpy = sinon.spy(this.toolbox, 'setSelectedItem'); const onClickSpy = sinon.spy(item, 'onClick'); this.toolbox.onClick_(evt); sinon.assert.calledOnce(setSelectedSpy); sinon.assert.calledOnce(onClickSpy); }); }); suite('onKeyDown_', function() { setup(function() { this.toolbox = getInjectedToolbox(); }); teardown(function() { this.toolbox.dispose(); }); function createKeyDownMock(key) { return { 'key': key, 'preventDefault': function() {}, }; } function testCorrectFunctionCalled(toolbox, key, funcName) { const event = createKeyDownMock(key); const preventDefaultEvent = sinon.stub(event, 'preventDefault'); const selectMethodStub = sinon.stub(toolbox, funcName); selectMethodStub.returns(true); toolbox.onKeyDown_(event); sinon.assert.called(selectMethodStub); sinon.assert.called(preventDefaultEvent); } test('Down button is pushed -> Should call selectNext_', function() { testCorrectFunctionCalled(this.toolbox, 'ArrowDown', 'selectNext_', true); }); test('Up button is pushed -> Should call selectPrevious_', function() { testCorrectFunctionCalled(this.toolbox, 'ArrowUp', 'selectPrevious_', true); }); test('Left button is pushed -> Should call selectParent_', function() { testCorrectFunctionCalled(this.toolbox, 'ArrowLeft', 'selectParent_', true); }); test('Right button is pushed -> Should call selectChild_', function() { testCorrectFunctionCalled(this.toolbox, 'ArrowRight', 'selectChild_', true); }); test('Enter button is pushed -> Should toggle expanded', function() { this.toolbox.selectedItem_ = getCollapsibleItem(this.toolbox); const toggleExpandedStub = sinon.stub(this.toolbox.selectedItem_, 'toggleExpanded'); const event = createKeyDownMock('Enter'); const preventDefaultEvent = sinon.stub(event, 'preventDefault'); this.toolbox.onKeyDown_(event); sinon.assert.called(toggleExpandedStub); sinon.assert.called(preventDefaultEvent); }); test('Enter button is pushed when no item is selected -> Should not call prevent default', function() { this.toolbox.selectedItem_ = null; const event = createKeyDownMock('Enter'); const preventDefaultEvent = sinon.stub(event, 'preventDefault'); this.toolbox.onKeyDown_(event); sinon.assert.notCalled(preventDefaultEvent); }); }); suite('Select Methods', function() { setup(function() { this.toolbox = getInjectedToolbox(); }); teardown(function() { this.toolbox.dispose(); }); suite('selectChild_', function() { test('No item is selected -> Should not handle event', function() { this.toolbox.selectedItem_ = null; const handled = this.toolbox.selectChild_(); chai.assert.isFalse(handled); }); test('Selected item is not collapsible -> Should not handle event', function() { this.toolbox.selectedItem_ = getNonCollapsibleItem(this.toolbox); const handled = this.toolbox.selectChild_(); chai.assert.isFalse(handled); }); test('Selected item is collapsible -> Should expand', function() { const collapsibleItem = getCollapsibleItem(this.toolbox); this.toolbox.selectedItem_ = collapsibleItem; const handled = this.toolbox.selectChild_(); chai.assert.isTrue(handled); chai.assert.isTrue(collapsibleItem.isExpanded()); chai.assert.equal(this.toolbox.selectedItem_, collapsibleItem); }); test('Selected item is expanded -> Should select child', function() { const collapsibleItem = getCollapsibleItem(this.toolbox); collapsibleItem.expanded_ = true; const selectNextStub = sinon.stub(this.toolbox, 'selectNext_'); this.toolbox.selectedItem_ = collapsibleItem; const handled = this.toolbox.selectChild_(); chai.assert.isTrue(handled); sinon.assert.called(selectNextStub); }); }); suite('selectParent_', function() { test('No item selected -> Should not handle event', function() { this.toolbox.selectedItem_ = null; const handled = this.toolbox.selectParent_(); chai.assert.isFalse(handled); }); test('Selected item is expanded -> Should collapse', function() { const collapsibleItem = getCollapsibleItem(this.toolbox); collapsibleItem.expanded_ = true; this.toolbox.selectedItem_ = collapsibleItem; const handled = this.toolbox.selectParent_(); chai.assert.isTrue(handled); chai.assert.isFalse(collapsibleItem.isExpanded()); chai.assert.equal(this.toolbox.selectedItem_, collapsibleItem); }); test('Selected item is not expanded -> Should get parent', function() { const childItem = getChildItem(this.toolbox); this.toolbox.selectedItem_ = childItem; const handled = this.toolbox.selectParent_(); chai.assert.isTrue(handled); chai.assert.equal(this.toolbox.selectedItem_, childItem.getParent()); }); }); suite('selectNext_', function() { test('No item is selected -> Should not handle event', function() { this.toolbox.selectedItem_ = null; const handled = this.toolbox.selectNext_(); chai.assert.isFalse(handled); }); test('Next item is selectable -> Should select next item', function() { const item = this.toolbox.contents_[0]; this.toolbox.selectedItem_ = item; const handled = this.toolbox.selectNext_(); chai.assert.isTrue(handled); chai.assert.equal(this.toolbox.selectedItem_, this.toolbox.contents_[1]); }); test('Selected item is last item -> Should not handle event', function() { const item = this.toolbox.contents_[this.toolbox.contents_.length - 1]; this.toolbox.selectedItem_ = item; const handled = this.toolbox.selectNext_(); chai.assert.isFalse(handled); chai.assert.equal(this.toolbox.selectedItem_, item); }); test('Selected item is collapsed -> Should skip over its children', function() { const item = getCollapsibleItem(this.toolbox); const childItem = item.flyoutItems_[0]; item.expanded_ = false; this.toolbox.selectedItem_ = item; const handled = this.toolbox.selectNext_(); chai.assert.isTrue(handled); chai.assert.notEqual(this.toolbox.selectedItem_, childItem); }); }); suite('selectPrevious', function() { test('No item is selected -> Should not handle event', function() { this.toolbox.selectedItem_ = null; const handled = this.toolbox.selectPrevious_(); chai.assert.isFalse(handled); }); test('Selected item is first item -> Should not handle event', function() { const item = this.toolbox.contents_[0]; this.toolbox.selectedItem_ = item; const handled = this.toolbox.selectPrevious_(); chai.assert.isFalse(handled); chai.assert.equal(this.toolbox.selectedItem_, item); }); test('Previous item is selectable -> Should select previous item', function() { const item = this.toolbox.contents_[1]; const prevItem = this.toolbox.contents_[0]; this.toolbox.selectedItem_ = item; const handled = this.toolbox.selectPrevious_(); chai.assert.isTrue(handled); chai.assert.equal(this.toolbox.selectedItem_, prevItem); }); test('Previous item is collapsed -> Should skip over children of the previous item', function() { const childItem = getChildItem(this.toolbox); const parentItem = childItem.getParent(); const parentIdx = this.toolbox.contents_.indexOf(parentItem); // Gets the item after the parent. const item = this.toolbox.contents_[parentIdx + 1]; this.toolbox.selectedItem_ = item; const handled = this.toolbox.selectPrevious_(); chai.assert.isTrue(handled); chai.assert.notEqual(this.toolbox.selectedItem_, childItem); }); }); }); suite('setSelectedItem', function() { setup(function() { this.toolbox = getInjectedToolbox(); }); teardown(function() { this.toolbox.dispose(); }); function setupSetSelected(toolbox, oldItem, newItem) { toolbox.selectedItem_ = oldItem; const newItemStub = sinon.stub(newItem, 'setSelected'); toolbox.setSelectedItem(newItem); return newItemStub; } test('Selected item and new item are null -> Should not update the flyout', function() { this.selectedItem_ = null; this.toolbox.setSelectedItem(null); const updateFlyoutStub = sinon.stub(this.toolbox, 'updateFlyout_'); sinon.assert.notCalled(updateFlyoutStub); }); test('New item is not selectable -> Should not update the flyout', function() { const separator = getSeparator(this.toolbox); this.toolbox.setSelectedItem(separator); const updateFlyoutStub = sinon.stub(this.toolbox, 'updateFlyout_'); sinon.assert.notCalled(updateFlyoutStub); }); test('Select an item with no children -> Should select item', function() { const oldItem = getCollapsibleItem(this.toolbox); const oldItemStub = sinon.stub(oldItem, 'setSelected'); const newItem = getNonCollapsibleItem(this.toolbox); const newItemStub = setupSetSelected(this.toolbox, oldItem, newItem); sinon.assert.calledWith(oldItemStub, false); sinon.assert.calledWith(newItemStub, true); }); test('Select previously selected item with no children -> Should deselect', function() { const newItem = getNonCollapsibleItem(this.toolbox); const newItemStub = setupSetSelected(this.toolbox, newItem, newItem); sinon.assert.calledWith(newItemStub, false); }); test('Select collapsible item -> Should select item', function() { const newItem = getCollapsibleItem(this.toolbox); const newItemStub = setupSetSelected(this.toolbox, null, newItem); sinon.assert.calledWith(newItemStub, true); }); test('Select previously selected collapsible item -> Should not deselect', function() { const newItem = getCollapsibleItem(this.toolbox); const newItemStub = setupSetSelected(this.toolbox, newItem, newItem); sinon.assert.notCalled(newItemStub); }); }); suite('updateFlyout_', function() { setup(function() { this.toolbox = getInjectedToolbox(); }); teardown(function() { this.toolbox.dispose(); }); function testHideFlyout(toolbox, oldItem, newItem) { const updateFlyoutStub = sinon.stub(toolbox.flyout_, 'hide'); toolbox.updateFlyout_(oldItem, newItem); sinon.assert.called(updateFlyoutStub); } test('Select previously selected item -> Should close flyout', function() { const newItem = getNonCollapsibleItem(this.toolbox); testHideFlyout(this.toolbox, newItem, newItem); }); test('No new item -> Should close flyout', function() { testHideFlyout(this.toolbox, null, null); }); test('Old item but no new item -> Should close flyout', function() { const oldItem = getNonCollapsibleItem(this.toolbox); testHideFlyout(this.toolbox, oldItem, null); }); test('Select collapsible item -> Should close flyout', function() { const newItem = getCollapsibleItem(this.toolbox); testHideFlyout(this.toolbox, null, newItem); }); test('Select selectable item -> Should open flyout', function() { const showFlyoutstub = sinon.stub(this.toolbox.flyout_, 'show'); const scrollToStartFlyout = sinon.stub(this.toolbox.flyout_, 'scrollToStart'); const newItem = getNonCollapsibleItem(this.toolbox); this.toolbox.updateFlyout_(null, newItem); sinon.assert.called(showFlyoutstub); sinon.assert.called(scrollToStartFlyout); }); }); suite('position', function() { setup(function() { this.toolbox = getBasicToolbox(); const metricsStub = sinon.stub(this.toolbox.workspace_, 'getMetrics'); metricsStub.returns({}); }); function checkHorizontalToolbox(toolbox) { chai.assert.equal(toolbox.HtmlDiv.style.left, '0px', 'Check left position'); chai.assert.equal(toolbox.HtmlDiv.style.height, 'auto', 'Check height'); chai.assert.equal(toolbox.HtmlDiv.style.width, '100%', 'Check width'); chai.assert.equal(toolbox.height_, toolbox.HtmlDiv.offsetHeight, 'Check height'); } function checkVerticalToolbox(toolbox) { chai.assert.equal(toolbox.HtmlDiv.style.height, '100%', 'Check height'); chai.assert.equal(toolbox.width_, toolbox.HtmlDiv.offsetWidth, 'Check width'); } test('HtmlDiv is not created -> Should not resize', function() { const toolbox = this.toolbox; toolbox.HtmlDiv = null; toolbox.horizontalLayout_ = true; toolbox.position(); chai.assert.equal(toolbox.height_, 0); }); test('Horizontal toolbox at top -> Should anchor horizontal toolbox to top', function() { const toolbox = this.toolbox; toolbox.toolboxPosition = Blockly.utils.toolbox.Position.TOP; toolbox.horizontalLayout_ = true; toolbox.position(); checkHorizontalToolbox(toolbox); chai.assert.equal(toolbox.HtmlDiv.style.top, '0px', 'Check top'); }); test('Horizontal toolbox at bottom -> Should anchor horizontal toolbox to bottom', function() { const toolbox = this.toolbox; toolbox.toolboxPosition = Blockly.utils.toolbox.Position.BOTTOM; toolbox.horizontalLayout_ = true; toolbox.position(); checkHorizontalToolbox(toolbox); chai.assert.equal(toolbox.HtmlDiv.style.bottom, '0px', 'Check bottom'); }); test('Vertical toolbox at right -> Should anchor to right', function() { const toolbox = this.toolbox; toolbox.toolboxPosition = Blockly.utils.toolbox.Position.RIGHT; toolbox.horizontalLayout_ = false; toolbox.position(); chai.assert.equal(toolbox.HtmlDiv.style.right, '0px', 'Check right'); checkVerticalToolbox(toolbox); }); test('Vertical toolbox at left -> Should anchor to left', function() { const toolbox = this.toolbox; toolbox.toolboxPosition = Blockly.utils.toolbox.Position.LEFT; toolbox.horizontalLayout_ = false; toolbox.position(); chai.assert.equal(toolbox.HtmlDiv.style.left, '0px', 'Check left'); checkVerticalToolbox(toolbox); }); }); suite('parseMethods', function() { setup(function() { this.categoryToolboxJSON = getCategoryJSON(); this.simpleToolboxJSON = getSimpleJson(); }); function checkValue(actual, expected, value) { const actualVal = actual[value]; const expectedVal = expected[value]; chai.assert.equal(actualVal.toUpperCase(), expectedVal.toUpperCase(), 'Checking value for: ' + value); } function checkContents(actualContents, expectedContents) { chai.assert.equal(actualContents.length, expectedContents.length); for (let i = 0; i < actualContents.length; i++) { // TODO: Check the values as well as all the keys. chai.assert.containsAllKeys(actualContents[i], Object.keys(expectedContents[i])); } } function checkCategory(actual, expected) { checkValue(actual, expected, 'kind'); checkValue(actual, expected, 'name'); chai.assert.deepEqual(actual['cssconfig'], expected['cssconfig']); checkContents(actual.contents, expected.contents); } function checkCategoryToolbox(actual, expected) { const actualContents = actual['contents']; const expectedContents = expected['contents']; chai.assert.equal(actualContents.length, expectedContents.length); for (let i = 0; i < expected.length; i++) { checkCategory(actualContents[i], expected[i]); } } function checkSimpleToolbox(actual, expected) { checkContents(actual['contents'], expected['contents']); } suite('parseToolbox', function() { test('Category Toolbox: JSON', function() { const toolboxDef = Blockly.utils.toolbox.convertToolboxDefToJson(this.categoryToolboxJSON); chai.assert.isNotNull(toolboxDef); checkCategoryToolbox(toolboxDef, this.categoryToolboxJSON); }); test('Simple Toolbox: JSON', function() { const toolboxDef = Blockly.utils.toolbox.convertToolboxDefToJson(this.simpleToolboxJSON); chai.assert.isNotNull(toolboxDef); checkSimpleToolbox(toolboxDef, this.simpleToolboxJSON); }); test('Category Toolbox: xml', function() { const toolboxXml = document.getElementById('toolbox-categories'); const toolboxDef = Blockly.utils.toolbox.convertToolboxDefToJson(toolboxXml); chai.assert.isNotNull(toolboxDef); checkCategoryToolbox(toolboxDef, this.categoryToolboxJSON); }); test('Simple Toolbox: xml', function() { const toolboxXml = document.getElementById('toolbox-simple'); const toolboxDef = Blockly.utils.toolbox.convertToolboxDefToJson(toolboxXml); chai.assert.isNotNull(toolboxDef); checkSimpleToolbox(toolboxDef, this.simpleToolboxJSON); }); test('Simple Toolbox: string', function() { let toolbox = ''; toolbox += ' '; toolbox += ' '; toolbox += ''; const toolboxJson = { 'contents': [ { 'kind': 'block', 'type': 'controls_if', }, { 'kind': 'block', 'type': 'controls_if', }, ], }; const toolboxDef = Blockly.utils.toolbox.convertToolboxDefToJson(toolbox); chai.assert.isNotNull(toolboxDef); checkSimpleToolbox(toolboxDef, toolboxJson); }); test('Category Toolbox: string', function() { let toolbox = ''; toolbox += ' '; toolbox += ' '; toolbox += ''; const toolboxJson = { 'contents': [ { 'kind': 'category', 'name': 'a', }, { 'kind': 'category', 'name': 'b', }, ], }; const toolboxDef = Blockly.utils.toolbox.convertToolboxDefToJson(toolbox); chai.assert.isNotNull(toolboxDef); checkSimpleToolbox(toolboxDef, toolboxJson); }); }); suite('parseFlyout', function() { test('Array of Nodes', function() { const xmlList = getXmlArray(); const flyoutDef = Blockly.utils.toolbox.convertFlyoutDefToJsonArray(xmlList); checkContents(flyoutDef, this.simpleToolboxJSON['contents']); }); test('NodeList', function() { const nodeList = document.getElementById('toolbox-simple').childNodes; const flyoutDef = Blockly.utils.toolbox.convertFlyoutDefToJsonArray(nodeList); checkContents(flyoutDef, this.simpleToolboxJSON['contents']); }); test('List of json', function() { const jsonList = this.simpleToolboxJSON['contents']; const flyoutDef = Blockly.utils.toolbox.convertFlyoutDefToJsonArray(jsonList); checkContents(flyoutDef, this.simpleToolboxJSON['contents']); }); test('Json', function() { const flyoutDef = Blockly.utils.toolbox.convertFlyoutDefToJsonArray(this.simpleToolboxJSON); checkContents(flyoutDef, this.simpleToolboxJSON['contents']); }); }); }); suite('Nested Categories', function() { setup(function() { this.toolbox = getInjectedToolbox(); }); teardown(function() { this.toolbox.dispose(); }); test('Child categories visible if all ancestors expanded', function() { this.toolbox.render(getDeeplyNestedJSON()); const outerCategory = this.toolbox.contents_[0]; const middleCategory = this.toolbox.contents_[1]; const innerCategory = this.toolbox.contents_[2]; outerCategory.toggleExpanded(); middleCategory.toggleExpanded(); innerCategory.show(); chai.assert.isTrue(innerCategory.isVisible(), 'All ancestors are expanded, so category should be visible'); }); test('Child categories not visible if any ancestor not expanded', function() { this.toolbox.render(getDeeplyNestedJSON()); const middleCategory = this.toolbox.contents_[1]; const innerCategory = this.toolbox.contents_[2]; // Don't expand the outermost category // Even though the direct parent of inner is expanded, it shouldn't be visible // because all ancestor categories need to be visible, not just parent middleCategory.toggleExpanded(); innerCategory.show(); chai.assert.isFalse(innerCategory.isVisible(), 'Not all ancestors are expanded, so category should not be visible'); }); }); });