diff --git a/src/actions/action_menu.ts b/src/actions/action_menu.ts index ed456fbc..dea986d2 100644 --- a/src/actions/action_menu.ts +++ b/src/actions/action_menu.ts @@ -45,12 +45,23 @@ export class ActionMenu { private registerShortcut() { const menuShortcut: ShortcutRegistry.KeyboardShortcut = { name: Constants.SHORTCUT_NAMES.MENU, - preconditionFn: (workspace) => - this.navigation.canCurrentlyNavigate(workspace), + preconditionFn: (workspace) => { + return ( + this.navigation.canCurrentlyNavigate(workspace) && + !workspace.isDragging() + ); + }, callback: (workspace) => { switch (this.navigation.getState(workspace)) { case Constants.STATE.WORKSPACE: return this.openActionMenu(workspace); + case Constants.STATE.FLYOUT: { + const flyoutWorkspace = workspace.getFlyout()?.getWorkspace(); + if (flyoutWorkspace) { + return this.openActionMenu(flyoutWorkspace); + } + return false; + } default: return false; } diff --git a/src/navigation.ts b/src/navigation.ts index d1ec3342..6cbf0866 100644 --- a/src/navigation.ts +++ b/src/navigation.ts @@ -99,9 +99,9 @@ export class Navigation { getState(workspace: Blockly.WorkspaceSvg): Constants.STATE { const focusedTree = Blockly.getFocusManager().getFocusedTree(); if (focusedTree instanceof Blockly.WorkspaceSvg) { - if (focusedTree.isFlyout && workspace === focusedTree.targetWorkspace) { + if (focusedTree.isFlyout) { return Constants.STATE.FLYOUT; - } else if (workspace === focusedTree) { + } else { return Constants.STATE.WORKSPACE; } } else if (focusedTree instanceof Blockly.Toolbox) { @@ -109,9 +109,7 @@ export class Navigation { return Constants.STATE.TOOLBOX; } } else if (focusedTree instanceof Blockly.Flyout) { - if (workspace === focusedTree.targetWorkspace) { - return Constants.STATE.FLYOUT; - } + return Constants.STATE.FLYOUT; } // Either a non-Blockly element currently has DOM focus, or a different // workspace holds it. @@ -822,8 +820,11 @@ export class Navigation { * @returns whether keyboard navigation is currently allowed. */ canCurrentlyNavigate(workspace: Blockly.WorkspaceSvg) { + const accessibilityMode = workspace.isFlyout + ? workspace.targetWorkspace?.keyboardAccessibilityMode + : workspace.keyboardAccessibilityMode; return ( - workspace.keyboardAccessibilityMode && + !!accessibilityMode && this.getState(workspace) !== Constants.STATE.NOWHERE ); } diff --git a/test/webdriverio/test/actions_test.ts b/test/webdriverio/test/actions_test.ts new file mode 100644 index 00000000..239ed7ed --- /dev/null +++ b/test/webdriverio/test/actions_test.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as chai from 'chai'; +import {Key} from 'webdriverio'; +import { + contextMenuExists, + moveToToolboxCategory, + PAUSE_TIME, + setCurrentCursorNodeById, + tabNavigateToWorkspace, + testFileLocations, + testSetup, +} from './test_setup.js'; + +suite('Menus test', function () { + // Setting timeout to unlimited as these tests take longer time to run + this.timeout(0); + + // Clear the workspace and load start blocks + setup(async function () { + this.browser = await testSetup(testFileLocations.BASE); + await this.browser.pause(PAUSE_TIME); + }); + + test('Menu action opens menu', async function () { + // Navigate to draw_circle_1. + await tabNavigateToWorkspace(this.browser); + await setCurrentCursorNodeById(this.browser, 'draw_circle_1'); + await this.browser.pause(PAUSE_TIME); + await this.browser.keys([Key.Ctrl, Key.Return]); + await this.browser.pause(PAUSE_TIME); + chai.assert.isTrue( + await contextMenuExists(this.browser, 'Duplicate'), + 'The menu should be openable on a block', + ); + }); + + test('Menu action returns true in the toolbox', async function () { + // Navigate to draw_circle_1. + await tabNavigateToWorkspace(this.browser); + await setCurrentCursorNodeById(this.browser, 'draw_circle_1'); + // Navigate to a toolbox category + await moveToToolboxCategory(this.browser, 'Functions'); + // Move to flyout. + await this.browser.keys(Key.ArrowRight); + await this.browser.keys([Key.Ctrl, Key.Return]); + await this.browser.pause(PAUSE_TIME); + + chai.assert.isTrue( + await contextMenuExists(this.browser, 'Help'), + 'The menu should be openable on a block in the toolbox', + ); + }); + + test('Menu action returns false during drag', async function () { + // Navigate to draw_circle_1. + await tabNavigateToWorkspace(this.browser); + await setCurrentCursorNodeById(this.browser, 'draw_circle_1'); + // Start moving the block + await this.browser.keys('m'); + await this.browser.keys([Key.Ctrl, Key.Return]); + await this.browser.pause(PAUSE_TIME); + chai.assert.isTrue( + await contextMenuExists(this.browser, 'Duplicate', true), + 'The menu should not be openable during a move', + ); + }); +}); diff --git a/test/webdriverio/test/test_setup.ts b/test/webdriverio/test/test_setup.ts index 8c9fd94a..85f0bf0f 100644 --- a/test/webdriverio/test/test_setup.ts +++ b/test/webdriverio/test/test_setup.ts @@ -431,3 +431,56 @@ export async function isDragging( return workspaceSvg.isDragging(); }); } + +/** + * Returns the result of the specificied action precondition. + * + * @param browser The active WebdriverIO Browser object. + * @param action The action to check the precondition for. + */ +export async function checkActionPrecondition( + browser: WebdriverIO.Browser, + action: string, +): Promise { + return await browser.execute((action) => { + const node = Blockly.getFocusManager().getFocusedNode(); + let workspace; + if (node instanceof Blockly.BlockSvg) { + workspace = node.workspace as Blockly.WorkspaceSvg; + } else if (node instanceof Blockly.Workspace) { + workspace = node as Blockly.WorkspaceSvg; + } else if (node instanceof Blockly.Field) { + workspace = node.getSourceBlock()?.workspace as Blockly.WorkspaceSvg; + } + + if (!workspace) { + throw new Error('Unable to derive workspace from focused node'); + } + const actionItem = Blockly.ShortcutRegistry.registry.getRegistry()[action]; + if (!actionItem || !actionItem.preconditionFn) { + throw new Error( + `No registered action or missing precondition: ${action}`, + ); + } + return actionItem.preconditionFn(workspace, { + focusedNode: node ?? undefined, + }); + }, action); +} + +/** + * Wait for the specified context menu item to exist. + * + * @param browser The active WebdriverIO Browser object. + * @param itemText The display text of the context menu item to click. + * @param reverse Whether to check for non-existence instead. + * @return A Promise that resolves when the actions are completed. + */ +export async function contextMenuExists( + browser: WebdriverIO.Browser, + itemText: string, + reverse = false, +): Promise { + const item = await browser.$(`div=${itemText}`); + return await item.waitForExist({timeout: 200, reverse: reverse}); +}