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