diff --git a/core/contextmenu_items.js b/core/contextmenu_items.js
index 26228ca56..560c6405e 100644
--- a/core/contextmenu_items.js
+++ b/core/contextmenu_items.js
@@ -167,7 +167,7 @@ Blockly.ContextMenuItems.registerExpand = function() {
Blockly.ContextMenuItems.toggleOption_(false, scope.workspace.getTopBlocks(true));
},
scopeType: Blockly.ContextMenuRegistry.ScopeType.WORKSPACE,
- id: 'toggleWorkspace',
+ id: 'expandWorkspace',
weight: 0,
};
Blockly.ContextMenuRegistry.registry.register(expandOption);
diff --git a/tests/mocha/contextmenu_items_test.js b/tests/mocha/contextmenu_items_test.js
new file mode 100644
index 000000000..387e7083b
--- /dev/null
+++ b/tests/mocha/contextmenu_items_test.js
@@ -0,0 +1,407 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+suite('Context Menu Items', function() {
+ setup(function() {
+ sharedTestSetup.call(this);
+
+ // Creates a WorkspaceSVG
+ var toolbox = document.getElementById('toolbox-categories');
+ this.workspace = Blockly.inject('blocklyDiv', {toolbox: toolbox});
+
+ // Declare a new registry to ensure default options are called.
+ new Blockly.ContextMenuRegistry();
+ this.registry = Blockly.ContextMenuRegistry.registry;
+ });
+
+ teardown(function() {
+ sharedTestTeardown.call(this);
+ });
+
+ suite('Workspace Items', function() {
+ setup(function() {
+ this.scope = {workspace: this.workspace};
+ });
+
+ suite('undo', function() {
+ setup(function() {
+ this.undoOption = this.registry.getItem('undoWorkspace');
+ });
+
+ test('Disabled when nothing to undo', function() {
+ var precondition = this.undoOption.preconditionFn(this.scope);
+ chai.assert.equal(precondition, 'disabled',
+ 'Should be disabled when there is nothing to undo');
+ });
+
+ test('Enabled when something to undo', function() {
+ // Create a new block, which should be undoable.
+ this.workspace.newBlock('text');
+ var precondition = this.undoOption.preconditionFn(this.scope);
+ chai.assert.equal(precondition, 'enabled',
+ 'Should be enabled when there are actions to undo');
+ });
+
+ test('Undoes adding a new block', function() {
+ this.workspace.newBlock('text');
+ chai.assert.equal(this.workspace.getTopBlocks(false).length, 1);
+ this.undoOption.callback(this.scope);
+ chai.assert.equal(this.workspace.getTopBlocks(false).length, 0,
+ 'Should be no blocks after undo');
+ });
+
+ test('Has correct label', function() {
+ chai.assert.equal(this.undoOption.displayText(), 'Undo');
+ });
+ });
+
+ suite('Redo', function() {
+ setup(function() {
+ this.redoOption = this.registry.getItem('redoWorkspace');
+ });
+
+ test('Disabled when nothing to redo', function() {
+ // Create a new block. There should be something to undo, but not redo.
+ this.workspace.newBlock('text');
+ var precondition = this.redoOption.preconditionFn(this.scope);
+ chai.assert.equal(precondition, 'disabled',
+ 'Should be disabled when there is nothing to redo');
+ });
+
+ test('Enabled when something to redo', function() {
+ // Create a new block, then undo it, which means there is something to redo.
+ this.workspace.newBlock('text');
+ this.workspace.undo(false);
+ var precondition = this.redoOption.preconditionFn(this.scope);
+ chai.assert.equal(precondition, 'enabled',
+ 'Should be enabled when there are actions to redo');
+ });
+
+ test('Redoes adding new block', function() {
+ // Add a new block, then undo it, then redo it.
+ this.workspace.newBlock('text');
+ this.workspace.undo(false);
+ chai.assert.equal(this.workspace.getTopBlocks(false).length, 0);
+ this.redoOption.callback(this.scope);
+ chai.assert.equal(this.workspace.getTopBlocks(false).length, 1,
+ 'Should be 1 block after redo');
+ });
+
+ test('Has correct label', function() {
+ chai.assert.equal(this.redoOption.displayText(), 'Redo');
+ });
+ });
+
+ suite('Cleanup', function() {
+ setup(function() {
+ this.cleanupOption = this.registry.getItem('cleanWorkspace');
+ this.cleanupStub = sinon.stub(this.workspace, 'cleanUp');
+ });
+
+ test('Enabled when multiple blocks', function() {
+ this.workspace.newBlock('text');
+ this.workspace.newBlock('text');
+ chai.assert.equal(this.cleanupOption.preconditionFn(this.scope), 'enabled',
+ 'Should be enabled if there are multiple blocks');
+ });
+
+ test('Disabled when no blocks', function() {
+ chai.assert.equal(this.cleanupOption.preconditionFn(this.scope), 'disabled',
+ 'Should be disabled if there are no blocks');
+ });
+
+ test('Hidden when not movable', function() {
+ sinon.stub(this.workspace, 'isMovable').returns(false);
+ chai.assert.equal(this.cleanupOption.preconditionFn(this.scope), 'hidden',
+ 'Should be hidden if the workspace is not movable');
+ });
+
+ test('Calls workspace cleanup', function() {
+ this.cleanupOption.callback(this.scope);
+ sinon.assert.calledOnce(this.cleanupStub);
+ });
+
+ test('Has correct label', function() {
+ chai.assert.equal(this.cleanupOption.displayText(), 'Clean up Blocks');
+ });
+ });
+
+ suite('Collapse', function() {
+ setup(function() {
+ this.collapseOption = this.registry.getItem('collapseWorkspace');
+ });
+
+ test('Enabled when uncollapsed blocks', function() {
+ this.workspace.newBlock('text');
+ var block2 = this.workspace.newBlock('text');
+ block2.setCollapsed(true);
+ chai.assert.equal(this.collapseOption.preconditionFn(this.scope), 'enabled',
+ 'Should be enabled when any blocks are expanded');
+ });
+
+ test('Disabled when all blocks collapsed', function() {
+ this.workspace.newBlock('text').setCollapsed(true);
+ chai.assert.equal(this.collapseOption.preconditionFn(this.scope), 'disabled',
+ 'Should be disabled when no blocks are expanded');
+ });
+
+ test('Hidden when no collapse option', function() {
+ var workspaceWithOptions = new Blockly.Workspace(new Blockly.Options({collapse: false}));
+ this.scope.workspace = workspaceWithOptions;
+
+ try {
+ chai.assert.equal(this.collapseOption.preconditionFn(this.scope), 'hidden',
+ 'Should be hidden if collapse is disabled in options');
+ } finally {
+ workspaceTeardown.call(this, workspaceWithOptions);
+ }
+ });
+
+ test('Collapses all blocks', function() {
+ // All blocks should be collapsed, even if some already were.
+ var block1 = this.workspace.newBlock('text');
+ var block2 = this.workspace.newBlock('text');
+ // Need to render block to properly collapse it.
+ block1.initSvg();
+ block1.render();
+ block1.setCollapsed(true);
+
+ this.collapseOption.callback(this.scope);
+ this.clock.runAll();
+
+ chai.assert.isTrue(block1.isCollapsed(),
+ 'Previously collapsed block should still be collapsed');
+ chai.assert.isTrue(block2.isCollapsed(),
+ 'Previously expanded block should now be collapsed');
+ });
+
+ test('Has correct label', function() {
+ chai.assert.equal(this.collapseOption.displayText(), 'Collapse Blocks');
+ });
+ });
+
+ suite('Expand', function() {
+ setup(function() {
+ this.expandOption = this.registry.getItem('expandWorkspace');
+ });
+
+ test('Enabled when collapsed blocks', function() {
+ this.workspace.newBlock('text');
+ var block2 = this.workspace.newBlock('text');
+ block2.setCollapsed(true);
+
+ chai.assert.equal(this.expandOption.preconditionFn(this.scope), 'enabled',
+ 'Should be enabled when any blocks are collapsed');
+ });
+
+ test('Disabled when no collapsed blocks', function() {
+ this.workspace.newBlock('text');
+ chai.assert.equal(this.expandOption.preconditionFn(this.scope), 'disabled',
+ 'Should be disabled when no blocks are collapsed');
+ });
+
+ test('Hidden when no collapse option', function() {
+ var workspaceWithOptions = new Blockly.Workspace(new Blockly.Options({collapse: false}));
+ this.scope.workspace = workspaceWithOptions;
+
+ try {
+ chai.assert.equal(this.expandOption.preconditionFn(this.scope), 'hidden',
+ 'Should be hidden if collapse is disabled in options');
+ } finally {
+ workspaceTeardown.call(this, workspaceWithOptions);
+ }
+ });
+
+ test('Expands all blocks', function() {
+ // All blocks should be expanded, even if some already were.
+ var block1 = this.workspace.newBlock('text');
+ var block2 = this.workspace.newBlock('text');
+ // Need to render block to properly collapse it.
+ block2.initSvg();
+ block2.render();
+ block2.setCollapsed(true);
+
+ this.expandOption.callback(this.scope);
+ this.clock.runAll();
+
+ chai.assert.isFalse(block1.isCollapsed(),
+ 'Previously expanded block should still be expanded');
+ chai.assert.isFalse(block2.isCollapsed(),
+ 'Previously collapsed block should now be expanded');
+ });
+
+ test('Has correct label', function() {
+ chai.assert.equal(this.expandOption.displayText(), 'Expand Blocks');
+ });
+ });
+
+ suite('Delete', function() {
+ setup(function() {
+ this.deleteOption = this.registry.getItem('workspaceDelete');
+ });
+
+ test('Enabled when blocks to delete', function() {
+ this.workspace.newBlock('text');
+ chai.assert.equal(this.deleteOption.preconditionFn(this.scope), 'enabled');
+ });
+
+ test('Disabled when no blocks to delete', function() {
+ chai.assert.equal(this.deleteOption.preconditionFn(this.scope), 'disabled');
+ });
+
+ test('Deletes all blocks after confirming', function() {
+ // Mocks the confirmation dialog and calls the callback with 'true' simulating ok.
+ var confirmStub = sinon.stub(Blockly, 'confirm').callsArgWith(1, true);
+
+ this.workspace.newBlock('text');
+ this.workspace.newBlock('text');
+ this.deleteOption.callback(this.scope);
+ this.clock.runAll();
+ sinon.assert.calledOnce(confirmStub);
+ chai.assert.equal(this.workspace.getTopBlocks(false).length, 0);
+ });
+
+ test('Does not delete blocks if not confirmed', function() {
+ // Mocks the confirmation dialog and calls the callback with 'false' simulating cancel.
+ var confirmStub = sinon.stub(Blockly, 'confirm').callsArgWith(1, false);
+
+ this.workspace.newBlock('text');
+ this.workspace.newBlock('text');
+ this.deleteOption.callback(this.scope);
+ this.clock.runAll();
+ sinon.assert.calledOnce(confirmStub);
+ chai.assert.equal(this.workspace.getTopBlocks(false).length, 2);
+ });
+
+ test('No dialog for single block', function() {
+ var confirmStub = sinon.stub(Blockly, 'confirm');
+ this.workspace.newBlock('text');
+ this.deleteOption.callback(this.scope);
+ this.clock.runAll();
+
+ sinon.assert.notCalled(confirmStub);
+ chai.assert.equal(this.workspace.getTopBlocks(false).length, 0);
+ });
+
+ test('Has correct label for multiple blocks', function() {
+ this.workspace.newBlock('text');
+ this.workspace.newBlock('text');
+
+ chai.assert.equal(this.deleteOption.displayText(this.scope), 'Delete 2 Blocks');
+ });
+
+ test('Has correct label for single block', function() {
+ this.workspace.newBlock('text');
+ chai.assert.equal(this.deleteOption.displayText(this.scope), 'Delete Block');
+ });
+ });
+ });
+
+ suite('Block Items', function() {
+ setup(function() {
+ this.block = this.workspace.newBlock('text');
+ this.scope = {block: this.block};
+ });
+
+ suite('Duplicate', function() {
+ setup(function() {
+ this.duplicateOption = this.registry.getItem('blockDuplicate');
+ });
+
+ test('Enabled when block is duplicatable', function() {
+ // Block is duplicatable by default
+ chai.assert.equal(this.duplicateOption.preconditionFn(this.scope), 'enabled');
+ });
+
+ test('Disabled when block is not dupicatable', function() {
+ sinon.stub(this.block, 'isDuplicatable').returns(false);
+ chai.assert.equal(this.duplicateOption.preconditionFn(this.scope), 'disabled');
+ });
+
+ test('Hidden when in flyout', function() {
+ this.block.isInFlyout = true;
+ chai.assert.equal(this.duplicateOption.preconditionFn(this.scope), 'hidden');
+ });
+
+ test('Calls duplicate', function() {
+ var stub = sinon.stub(Blockly, 'duplicate');
+
+ this.duplicateOption.callback(this.scope);
+
+ sinon.assert.calledOnce(stub);
+ sinon.assert.calledWith(stub, this.block);
+ });
+
+ test('Has correct label', function() {
+ chai.assert.equal(this.duplicateOption.displayText(), 'Duplicate');
+ });
+ });
+
+ suite('Comment', function() {
+ setup(function() {
+ this.commentOption = this.registry.getItem('blockComment');
+ });
+
+ test('Enabled for normal block', function() {
+ chai.assert.equal(this.commentOption.preconditionFn(this.scope), 'enabled');
+ });
+
+ test('Hidden for IE', function() {
+ var oldState = Blockly.utils.userAgent.IE;
+ try {
+ Blockly.utils.userAgent.IE = true;
+ chai.assert.equal(this.commentOption.preconditionFn(this.scope), 'hidden');
+ } finally {
+ Blockly.utils.userAgent.IE = oldState;
+ }
+ });
+
+ test('Hidden for collapsed block', function() {
+ // Must render block to collapse it properly.
+ this.block.initSvg();
+ this.block.render();
+ this.block.setCollapsed(true);
+
+ chai.assert.equal(this.commentOption.preconditionFn(this.scope), 'hidden');
+ });
+
+ test('Creates comment if one did not exist', function() {
+ chai.assert.isNull(this.block.getCommentIcon(), 'New block should not have a comment');
+ this.commentOption.callback(this.scope);
+ chai.assert.exists(this.block.getCommentIcon());
+ chai.assert.isEmpty(this.block.getCommentText(), 'Block should have empty comment text');
+ });
+
+ test('Removes comment if block had one', function() {
+ this.block.setCommentText('Test comment');
+ this.commentOption.callback(this.scope);
+ chai.assert.isNull(this.block.getCommentText(),
+ 'Block should not have comment after removal');
+ });
+
+ test('Has correct label for add comment', function() {
+ chai.assert.equal(this.commentOption.displayText(this.scope), 'Add Comment');
+ });
+
+ test('Has correct label for remove comment', function() {
+ this.block.setCommentText('Test comment');
+ chai.assert.equal(this.commentOption.displayText(this.scope), 'Remove Comment');
+ });
+ });
+
+ suite('Inline Variables', function() {
+ setup(function() {
+ this.inlineOption = this.registry.getItem('blockInline');
+ });
+
+ test('Enabled when inputs to inline', function() {
+ this.block.appendValueInput('test1');
+ this.block.appendValueInput('test2');
+ chai.assert.equal(this.inlineOption.preconditionFn(this.scope), 'enabled');
+ });
+ });
+ });
+});
diff --git a/tests/mocha/index.html b/tests/mocha/index.html
index e4391e0c7..5dd168d80 100644
--- a/tests/mocha/index.html
+++ b/tests/mocha/index.html
@@ -42,6 +42,7 @@
+