diff --git a/test/index.html b/test/index.html index 212d0354..d64f6a64 100644 --- a/test/index.html +++ b/test/index.html @@ -108,7 +108,12 @@ - + + diff --git a/test/loadTestBlocks.js b/test/loadTestBlocks.js index 3f5c56be..da64cb05 100644 --- a/test/loadTestBlocks.js +++ b/test/loadTestBlocks.js @@ -570,7 +570,15 @@ const navigationTestBlocks = { }, }; -const moveTestBlocks = { +// The draw block contains a stack of statement blocks, each of which +// has a value input to which is connected a value expression block +// which itself has one or two inputs which have (non-shadow) simple +// value blocks connected. Each statement block will be selected in +// turn and then a move initiated (and then aborted). This is then +// repeated with the first level value blocks (those that are attached +// to the statement blocks). The second level value blocks are +// present to verify correct (lack of) heal behaviour. +const moveStartTestBlocks = { 'blocks': { 'languageVersion': 0, 'blocks': [ @@ -862,6 +870,112 @@ const moveTestBlocks = { }, }; +// A bunch of statement blocks. The blocks with IDs simple_mover and +// complex_mover will be (constrained-)moved up, down, left and right +// to verify that they visit all the expected candidate connections. +const moveStatementTestBlocks = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'p5_setup', + 'id': 'p5_setup', + 'x': 75, + 'y': 75, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'p5_canvas', + 'id': 'p5_canvas', + 'deletable': false, + 'movable': false, + 'fields': { + 'WIDTH': 400, + 'HEIGHT': 400, + }, + 'next': { + 'block': { + 'type': 'draw_emoji', + 'id': 'simple_mover', + 'fields': { + 'emoji': '✨' + }, + 'next': { + 'block': { + 'type': 'controls_if', + 'id': 'complex_mover', + 'extraState': { + 'hasElse': true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + 'type': 'text_print', + 'id': 'text_print', + "disabledReasons": [ + "MANUALLY_DISABLED" + ], + 'x': 75, + 'y': 400, + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_text', + 'fields': { + 'TEXT': 'abc', + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_if', + 'id': 'controls_if', + 'extraState': { + 'elseIfCount': 1, + 'hasElse': true, + }, + 'inputs': { + 'DO0': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'controls_repeat_ext', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_math_number', + 'fields': { + 'NUM': 10, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + 'type': 'p5_draw', + 'id': 'p5_draw', + 'x': 75, + 'y': 950, + 'deletable': false, + }, + ], + }, +}; + const comments = { 'workspaceComments': [ { @@ -985,17 +1099,20 @@ const comments = { export const load = function (workspace, scenarioString) { const scenarioMap = { 'blank': blankCanvas, - 'comments': comments, - 'moreBlocks': moreBlocks, - 'moveTestBlocks': moveTestBlocks, - 'navigationTestBlocks': navigationTestBlocks, - 'simpleCircle': simpleCircle, + comments, + moreBlocks, + moveStartTestBlocks, + moveStatementTestBlocks, + navigationTestBlocks, + simpleCircle, 'sun': sunnyDay, }; - - const data = JSON.stringify(scenarioMap[scenarioString]); // Don't emit events during loading. Blockly.Events.disable(); - Blockly.serialization.workspaces.load(JSON.parse(data), workspace, false); + Blockly.serialization.workspaces.load( + scenarioMap[scenarioString], + workspace, + false, + ); Blockly.Events.enable(); }; diff --git a/test/webdriverio/test/move_test.ts b/test/webdriverio/test/move_test.ts index a8a34b39..781abdd3 100644 --- a/test/webdriverio/test/move_test.ts +++ b/test/webdriverio/test/move_test.ts @@ -17,14 +17,14 @@ import { contextMenuItems, } from './test_setup.js'; -suite('Move tests', function () { +suite('Move start tests', function () { // Increase timeout to 10s for this longer test (but disable // timeouts if when non-zero PAUSE_TIME is used to watch tests) run. this.timeout(PAUSE_TIME ? 0 : 10000); // Clear the workspace and load start blocks. setup(async function () { - this.browser = await testSetup(testFileLocations.MOVE_TEST_BLOCKS); + this.browser = await testSetup(testFileLocations.MOVE_START_TEST_BLOCKS); await this.browser.pause(PAUSE_TIME); }); @@ -158,6 +158,149 @@ suite('Move tests', function () { await sendKeyAndWait(this.browser, Key.Escape); } }); +}); + +suite('Statement move tests', function () { + // Increase timeout to 10s for this longer test (but disable + // timeouts if when non-zero PAUSE_TIME is used to watch tests) run. + this.timeout(PAUSE_TIME ? 0 : 10000); + + // Clear the workspace and load start blocks. + setup(async function () { + this.browser = await testSetup( + testFileLocations.MOVE_STATEMENT_TEST_BLOCKS, + ); + await this.browser.pause(PAUSE_TIME); + }); + + /** ID of a statement block with no inputs. */ + const BLOCK_SIMPLE = 'simple_mover'; + + /** + * Expected connection candidates when moving BLOCK_SIMPLE, after + * pressing right or down arrow n times. + */ + const EXPECTED_SIMPLE = [ + {id: 'p5_canvas', index: 1, ownIndex: 0}, // Next; starting location. + {id: 'complex_mover', index: 3, ownIndex: 0}, // "If" statement input. + {id: 'complex_mover', index: 4, ownIndex: 0}, // "Else" statement input. + {id: 'complex_mover', index: 1, ownIndex: 0}, // Next. + {id: 'text_print', index: 0, ownIndex: 1}, // Previous. + {id: 'text_print', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 3, ownIndex: 0}, // "If" statement input. + {id: 'controls_repeat_ext', index: 3, ownIndex: 0}, // Statement input. + {id: 'controls_repeat_ext', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 5, ownIndex: 0}, // "Else if" statement input. + {id: 'controls_if', index: 6, ownIndex: 0}, // "Else" statement input. + {id: 'controls_if', index: 1, ownIndex: 0}, // Next. + {id: 'p5_draw', index: 0, ownIndex: 0}, // Statement input. + {id: 'p5_canvas', index: 1, ownIndex: 0}, // Next; starting location again. + ]; + const EXPECTED_SIMPLE_REVERSED = EXPECTED_SIMPLE.slice().reverse(); + + test( + 'Constrained move of simple stack block right', + moveTest(BLOCK_SIMPLE, Key.ArrowRight, EXPECTED_SIMPLE, { + parentId: 'complex_mover', + parentIndex: 3, + nextId: null, + valueId: null, + }), + ); + test( + 'Constrained move of simple stack block left', + moveTest(BLOCK_SIMPLE, Key.ArrowLeft, EXPECTED_SIMPLE_REVERSED, { + parentId: 'p5_draw', + parentIndex: 0, + nextId: null, + valueId: null, + }), + ); + test( + 'Constrained move of simple stack block down', + moveTest(BLOCK_SIMPLE, Key.ArrowDown, EXPECTED_SIMPLE, { + parentId: 'complex_mover', + parentIndex: 3, + nextId: null, + valueId: null, + }), + ); + test( + 'Constrained move of simple stack block up', + moveTest(BLOCK_SIMPLE, Key.ArrowUp, EXPECTED_SIMPLE_REVERSED, { + parentId: 'p5_draw', + parentIndex: 0, + nextId: null, + valueId: null, + }), + ); + + /** ID of a statement block with multiple statement inputs. */ + const BLOCK_COMPLEX = 'complex_mover'; + + /** + * Expected connection candidates when moving BLOCK_COMPLEX, after + * pressing right or down arrow n times. + */ + const EXPECTED_COMPLEX = [ + // TODO(#702): Due to a bug in KeyboardDragStrategy, certain + // connection candidates that can be found using the mouse are not + // visited when doing a keyboard drag. They appear in the list + // below, but commented out for now. + // is fixed. + {id: 'simple_mover', index: 1, ownIndex: 0}, // Next; starting location. + // {id: 'text_print', index: 0, ownIndex: 1}, // Previous to own next. + {id: 'text_print', index: 0, ownIndex: 4}, // Previous to own else input. + // {id: 'text_print', index: 0, ownIndex: 3}, // Previous to own if input. + {id: 'text_print', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 3, ownIndex: 0}, // "If" statement input. + {id: 'controls_repeat_ext', index: 3, ownIndex: 0}, // Statement input. + {id: 'controls_repeat_ext', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 5, ownIndex: 0}, // "Else if" statement input. + {id: 'controls_if', index: 6, ownIndex: 0}, // "Else" statement input. + {id: 'controls_if', index: 1, ownIndex: 0}, // Next. + {id: 'p5_draw', index: 0, ownIndex: 0}, // Statement input. + {id: 'p5_canvas', index: 1, ownIndex: 0}, // Next; starting location again. + {id: 'simple_mover', index: 1, ownIndex: 0}, // Next; starting location. + ]; + const EXPECTED_COMPLEX_REVERSED = EXPECTED_COMPLEX.slice().reverse(); + + test( + 'Constrained move of complex stack block right', + moveTest(BLOCK_COMPLEX, Key.ArrowRight, EXPECTED_COMPLEX,{ + parentId: null, + parentIndex: null, + nextId: null, // TODO(#702): Should be 'text_print', + valueId: null, + }), + ); + test( + 'Constrained move of complex stack block left', + moveTest(BLOCK_COMPLEX, Key.ArrowLeft, EXPECTED_COMPLEX_REVERSED, { + parentId: 'p5_canvas', + parentIndex: 1, + nextId: 'simple_mover', + valueId: null, + }), + ); + test( + 'Constrained move of complex stack block down', + moveTest(BLOCK_COMPLEX, Key.ArrowDown, EXPECTED_COMPLEX, { + parentId: null, + parentIndex: null, + nextId: null, // TODO(#702): Should be 'text_print', + valueId: null, + }), + ); + test( + 'Constrained move of complex stack block up', + moveTest(BLOCK_COMPLEX, Key.ArrowUp, EXPECTED_COMPLEX_REVERSED, { + parentId: 'p5_canvas', + parentIndex: 1, + nextId: 'simple_mover', + valueId: null, + }), + ); // When a top-level block with no previous, next or output // connections is subject to a constrained move, it should not move. @@ -168,7 +311,7 @@ suite('Move tests', function () { // block unexpectedly moving (unless workspace scale was === 1). test('Constrained move of unattachable top-level block', async function () { // Block ID of an unconnectable block. - const BLOCK = 'p5_setup_1'; + const BLOCK = 'p5_setup'; // Scale workspace. await this.browser.execute(() => { @@ -216,15 +359,58 @@ suite('Move tests', function () { }); /** - * Get information about the currently-selected block's parent and + * Create a mocha test function moving a specified block in a + * particular direction, checking that it has the the expected + * connection candidate after each step, and that once the move + * finishes it is connected as expected. + * + * @param mover Block ID of the block to be moved. + * @param key Key to send to move one step. + * @param candidates Array of expected connection candidates. + * @param finalInfo Expected final connections when move finished, + * as returne d by getFocusedNeighbourInfo. + * @returns function to pass as second argument to mocha's test function. + */ +function moveTest( + mover: string, + key: string | string[], + candidates: Array<{id: string; index: number}>, + finalInfo: Awaited>, +) { + return async function (this: Mocha.Context) { + // Navigate to block to be moved and intiate move. + await focusOnBlock(this.browser, mover); + await sendKeyAndWait(this.browser, 'm'); + // Move to right multiple times, checking connection candidates. + for (let i = 0; i < candidates.length; i++) { + const candidate = await getConnectionCandidate(this.browser); + chai.assert.deepEqual(candidate, candidates[i]); + await sendKeyAndWait(this.browser, key); + } + + // Finish move and check final location of moved block. + await sendKeyAndWait(this.browser, Key.Enter); + const info = await getFocusedNeighbourInfo(this.browser); + chai.assert.deepEqual(info, finalInfo); + }; +} + +/** + * Get information about the currently-focused block's parent and * child blocks. * * @param browser The webdriverio browser session. - * @returns A promise setting to {parentId, parentIndex, nextId, - * valueId}, being respectively the parent block ID, index of parent - * connection, next block ID, and ID of the block connected to the - * zeroth value value input, or null if the given item does not - * exist. + * @returns A promise setting to + * + * {parentId, parentIndex, nextId, valueId} + * + * where parentId, parentIndex are the ID of the parent block and + * the index of the connection on that block to which the + * currently-focused block is connected, nextId is the ID of block + * connected to the focused block's next connection, and valueID + * is the ID of a block connected to the zeroth input of the + * focused block (or, in each case, null if there is no such + * block). */ function getFocusedNeighbourInfo(browser: Browser) { return browser.execute(() => { @@ -305,3 +491,43 @@ function getCoordinate( return block.getRelativeToSurfaceXY(); }, id); } + +/** + * Get information about the connection candidate for the + * currently-moving block (if any). + * + * @param browser The webdriverio browser session. + * @returns A promise setting to either null if there is no connection + * candidate, or otherwise if there is one to + * + * {id, index, ownIndex} + * + * where id is the block ID of the neighbour, index is the index + * of the candidate connection on the neighbour, and ownIndex is + * the index of the candidate connection on the moving block. + */ +function getConnectionCandidate( + browser: Browser, +): Promise<{id: string; index: number} | null> { + return browser.execute(() => { + const focused = Blockly.getFocusManager().getFocusedNode(); + if (!focused) throw new Error('nothing focused'); + if (!(focused instanceof Blockly.BlockSvg)) { + throw new TypeError('focused node is not a BlockSvg'); + } + const block = focused; // Inferred as BlockSvg. + const dragStrategy = + block.getDragStrategy() as Blockly.dragging.BlockDragStrategy; + if (!dragStrategy) throw new Error('no drag strategy'); + // @ts-expect-error connectionCandidate is private. + const candidate = dragStrategy.connectionCandidate; + if (!candidate) return null; + const neighbourBlock = candidate.neighbour.getSourceBlock(); + if (!neighbourBlock) throw new TypeError('connection has no source block'); + const neighbourConnections = neighbourBlock.getConnections_(true); + const index = neighbourConnections.indexOf(candidate.neighbour); + const ownConnections = block.getConnections_(true); + const ownIndex = ownConnections.indexOf(candidate.local); + return {id: neighbourBlock.id, index, ownIndex}; + }); +} diff --git a/test/webdriverio/test/test_setup.ts b/test/webdriverio/test/test_setup.ts index 93bec99a..783270ad 100644 --- a/test/webdriverio/test/test_setup.ts +++ b/test/webdriverio/test/test_setup.ts @@ -155,8 +155,12 @@ export const testFileLocations = { // eslint-disable-next-line @typescript-eslint/naming-convention MORE_BLOCKS: createTestUrl(new URLSearchParams({scenario: 'moreBlocks'})), // eslint-disable-next-line @typescript-eslint/naming-convention - MOVE_TEST_BLOCKS: createTestUrl( - new URLSearchParams({scenario: 'moveTestBlocks'}), + MOVE_START_TEST_BLOCKS: createTestUrl( + new URLSearchParams({scenario: 'moveStartTestBlocks'}), + ), + // eslint-disable-next-line @typescript-eslint/naming-convention + MOVE_STATEMENT_TEST_BLOCKS: createTestUrl( + new URLSearchParams({scenario: 'moveStatementTestBlocks'}), ), COMMENTS: createTestUrl(new URLSearchParams({scenario: 'comments'})), // eslint-disable-next-line @typescript-eslint/naming-convention