diff --git a/core/utils/rect.ts b/core/utils/rect.ts index 817462b699..c7da2a6860 100644 --- a/core/utils/rect.ts +++ b/core/utils/rect.ts @@ -13,6 +13,8 @@ */ // Former goog.module ID: Blockly.utils.Rect +import {Coordinate} from './coordinate.js'; + /** * Class for representing rectangular regions. */ @@ -30,10 +32,21 @@ export class Rect { public right: number, ) {} + /** + * Creates a new copy of this rectangle. + * + * @returns A copy of this Rect. + */ + clone(): Rect { + return new Rect(this.top, this.bottom, this.left, this.right); + } + + /** Returns the height of this rectangle. */ getHeight(): number { return this.bottom - this.top; } + /** Returns the width of this rectangle. */ getWidth(): number { return this.right - this.left; } @@ -59,11 +72,56 @@ export class Rect { * @returns Whether this rectangle intersects the provided rectangle. */ intersects(other: Rect): boolean { - return !( - this.left > other.right || - this.right < other.left || - this.top > other.bottom || - this.bottom < other.top + // The following logic can be derived and then simplified from a longer form symmetrical check + // of verifying each rectangle's borders with the other rectangle by checking if either end of + // the border's line segment is contained within the other rectangle. The simplified version + // used here can be logically interpreted as ensuring that each line segment of 'this' rectangle + // is not outside the bounds of the 'other' rectangle (proving there's an intersection). + return ( + this.left <= other.right && + this.right >= other.left && + this.bottom >= other.top && + this.top <= other.bottom ); } + + /** + * Compares bounding rectangles for equality. + * + * @param a A Rect. + * @param b A Rect. + * @returns True iff the bounding rectangles are equal, or if both are null. + */ + static equals(a?: Rect | null, b?: Rect | null): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + return ( + a.top === b.top && + a.bottom === b.bottom && + a.left === b.left && + a.right === b.right + ); + } + + /** + * Creates a new Rect using a position and supplied dimensions. + * + * @param position The upper left coordinate of the new rectangle. + * @param width The width of the rectangle, in pixels. + * @param height The height of the rectangle, in pixels. + * @returns A newly created Rect using the provided Coordinate and dimensions. + */ + static createFromPoint( + position: Coordinate, + width: number, + height: number, + ): Rect { + const left = position.x; + const top = position.y; + return new Rect(top, top + height, left, left + width); + } } diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index a7d56d1005..b8ef96292b 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -1645,23 +1645,56 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { return boundary; } - /** Clean up the workspace by ordering all the blocks in a column. */ + /** Clean up the workspace by ordering all the blocks in a column such that none overlap. */ cleanUp() { this.setResizesEnabled(false); eventUtils.setGroup(true); + const topBlocks = this.getTopBlocks(true); - let cursorY = 0; - for (let i = 0, block; (block = topBlocks[i]); i++) { - if (!block.isMovable()) { - continue; + const movableBlocks = topBlocks.filter((block) => block.isMovable()); + const immovableBlocks = topBlocks.filter((block) => !block.isMovable()); + + const immovableBlockBounds = immovableBlocks.map((block) => + block.getBoundingRectangle(), + ); + + const getNextIntersectingImmovableBlock = function ( + rect: Rect, + ): Rect | null { + for (const immovableRect of immovableBlockBounds) { + if (rect.intersects(immovableRect)) { + return immovableRect; + } } - const xy = block.getRelativeToSurfaceXY(); - block.moveBy(-xy.x, cursorY - xy.y, ['cleanup']); + return null; + }; + + let cursorY = 0; + const minBlockHeight = this.renderer.getConstants().MIN_BLOCK_HEIGHT; + for (const block of movableBlocks) { + // Make the initial movement of shifting the block to its best possible position. + let boundingRect = block.getBoundingRectangle(); + block.moveBy(-boundingRect.left, cursorY - boundingRect.top, ['cleanup']); block.snapToGrid(); + + boundingRect = block.getBoundingRectangle(); + let conflictingRect = getNextIntersectingImmovableBlock(boundingRect); + while (conflictingRect != null) { + // If the block intersects with an immovable block, move it down past that immovable block. + cursorY = + conflictingRect.top + conflictingRect.getHeight() + minBlockHeight; + block.moveBy(0, cursorY - boundingRect.top, ['cleanup']); + block.snapToGrid(); + boundingRect = block.getBoundingRectangle(); + conflictingRect = getNextIntersectingImmovableBlock(boundingRect); + } + + // Ensure all next blocks start past the most recent (which will also put them past all + // previously intersecting immovable blocks). cursorY = block.getRelativeToSurfaceXY().y + block.getHeightWidth().height + - this.renderer.getConstants().MIN_BLOCK_HEIGHT; + minBlockHeight; } eventUtils.setGroup(false); this.setResizesEnabled(true); diff --git a/tests/mocha/contextmenu_items_test.js b/tests/mocha/contextmenu_items_test.js index b5d480c37b..a9e2bb3de6 100644 --- a/tests/mocha/contextmenu_items_test.js +++ b/tests/mocha/contextmenu_items_test.js @@ -123,7 +123,7 @@ suite('Context Menu Items', function () { suite('Cleanup', function () { setup(function () { this.cleanupOption = this.registry.getItem('cleanWorkspace'); - this.cleanupStub = sinon.stub(this.workspace, 'cleanUp'); + this.cleanUpStub = sinon.stub(this.workspace, 'cleanUp'); }); test('Enabled when multiple blocks', function () { @@ -153,9 +153,9 @@ suite('Context Menu Items', function () { ); }); - test('Calls workspace cleanup', function () { + test('Calls workspace cleanUp', function () { this.cleanupOption.callback(this.scope); - sinon.assert.calledOnce(this.cleanupStub); + sinon.assert.calledOnce(this.cleanUpStub); }); test('Has correct label', function () { diff --git a/tests/mocha/index.html b/tests/mocha/index.html index ff3467907d..adc63da4a1 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -110,6 +110,7 @@ import './old_workspace_comment_test.js'; import './procedure_map_test.js'; import './blocks/procedures_test.js'; + import './rect_test.js'; import './registry_test.js'; import './render_management_test.js'; import './serializer_test.js'; diff --git a/tests/mocha/rect_test.js b/tests/mocha/rect_test.js new file mode 100644 index 0000000000..37712dff3a --- /dev/null +++ b/tests/mocha/rect_test.js @@ -0,0 +1,1668 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../../node_modules/chai/chai.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Rect', function () { + setup(function () { + sharedTestSetup.call(this); + this.createCoord = function (x, y) { + return new Blockly.utils.Coordinate(x, y); + }; + }); + teardown(function () { + sharedTestTeardown.call(this); + }); + + suite('Rect()', function () { + test('initializes properties correctly', function () { + const rect = new Blockly.utils.Rect(1, 2, 3, 4); + + assert.equal(rect.top, 1, 'top should be initialized'); + assert.equal(rect.bottom, 2, 'bottom should be initialized'); + assert.equal(rect.left, 3, 'left should be initialized'); + assert.equal(rect.right, 4, 'right should be initialized'); + }); + }); + + suite('createFromPoint()', function () { + test('initializes properties correctly', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + assert.equal(rect.top, 2, 'top should be initialized'); + assert.equal(rect.bottom, 47, 'bottom should be initialized'); + assert.equal(rect.left, 1, 'left should be initialized'); + assert.equal(rect.right, 24, 'right should be initialized'); + }); + }); + + suite('clone()', function () { + test('copies properties correctly', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const clonedRect = rect.clone(); + + assert.equal(clonedRect.top, rect.top, 'top should be cloned'); + assert.equal(clonedRect.bottom, rect.bottom, 'bottom should be cloned'); + assert.equal(clonedRect.left, rect.left, 'left should be cloned'); + assert.equal(clonedRect.right, rect.right, 'right should be cloned'); + }); + }); + + suite('equals()', function () { + test('same object instance should equal itself', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect, rect); + + assert.isTrue(areEqual, 'an instance should equal itself'); + }); + + test('null instances should be equal', function () { + const areEqual = Blockly.utils.Rect.equals(null, null); + + assert.isTrue(areEqual, 'null should equal null'); + }); + + test('an object and null should not be equal', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect, null); + + assert.isFalse(areEqual, 'non-null should not equal null'); + }); + + test('null and an object should not be equal', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(null, rect); + + assert.isFalse(areEqual, 'null should not equal non-null'); + }); + + test('object should equal its clone', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect, rect.clone()); + + assert.isTrue(areEqual, 'an instance and its clone should be equal'); + }); + + test('object should equal an exact explicit copy', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect1, rect2); + + assert.isTrue( + areEqual, + 'two objects constructed in the same way should be equal', + ); + }); + + test('object should not equal object with different x position', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(3, 2), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect1, rect2); + + assert.isFalse( + areEqual, + 'two objects with different x positions should not be equal', + ); + }); + + test('object should not equal object with different y position', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 4), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect1, rect2); + + assert.isFalse( + areEqual, + 'two objects with different y positions should not be equal', + ); + }); + + test('object should not equal object with different x and y positions', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(3, 4), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect1, rect2); + + assert.isFalse( + areEqual, + 'two objects with different x/y positions should not be equal', + ); + }); + + test('object should not equal object with different width', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 46, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect1, rect2); + + assert.isFalse( + areEqual, + 'two objects with different widths should not be equal', + ); + }); + + test('object should not equal object with different height', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 89, + ); + + const areEqual = Blockly.utils.Rect.equals(rect1, rect2); + + assert.isFalse( + areEqual, + 'two objects with different heights should not be equal', + ); + }); + + test('object should not equal object with all different properties', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(3, 4), + 46, + 89, + ); + + const areEqual = Blockly.utils.Rect.equals(rect1, rect2); + + assert.isFalse( + areEqual, + 'two objects with all different properties should not be equal', + ); + }); + }); + + suite('getHeight()', function () { + test('computes zero height for empty rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 0, + 0, + ); + + assert.equal(rect.getHeight(), 0, 'height should be 0'); + }); + + test('computes height of 1 for unit square rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 1, + 1, + ); + + assert.equal(rect.getHeight(), 1, 'height should be 1'); + }); + + test('computes height of 1 for unit square rectangle not at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 1, + 1, + ); + + assert.equal(rect.getHeight(), 1, 'height should be 1'); + }); + + test('computes height of 1 for unit square rectangle with negative position', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(-1, -2), + 1, + 1, + ); + + assert.equal(rect.getHeight(), 1, 'height should be 1'); + }); + + test('computes decimal height for non-square rectangle not at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1.1, 2.2), + 3.3, + 4.4, + ); + + assert.approximately(rect.getHeight(), 4.4, 1e-5, 'height should be 4.4'); + }); + }); + + suite('getWidth()', function () { + test('computes zero width for empty rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 0, + 0, + ); + + assert.equal(rect.getWidth(), 0, 'width should be 0'); + }); + + test('computes width of 1 for unit square rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 1, + 1, + ); + + assert.equal(rect.getWidth(), 1, 'width should be 1'); + }); + + test('computes width of 1 for unit square rectangle not at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 1, + 1, + ); + + assert.equal(rect.getWidth(), 1, 'width should be 1'); + }); + + test('computes width of 1 for unit square rectangle with negative position', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(-1, -2), + 1, + 1, + ); + + assert.equal(rect.getWidth(), 1, 'width should be 1'); + }); + + test('computes decimal width for non-square rectangle not at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1.1, 2.2), + 3.3, + 4.4, + ); + + assert.approximately(rect.getWidth(), 3.3, 1e-5, 'width should be 3.3'); + }); + }); + + suite('contains()', function () { + suite('point contained within rect', function () { + test('origin for zero-sized square', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 0, + 0, + ); + + const isContained = rect.contains(0, 0); + + assert.isTrue(isContained, 'Rect contains (0, 0)'); + }); + + test('whole number centroid for square at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 2, + 2, + ); + + const isContained = rect.contains(1, 1); + + assert.isTrue(isContained, 'Rect contains (1, 1)'); + }); + + test('decimal number centroid for square at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 1, + 1, + ); + + const isContained = rect.contains(0.5, 0.5); + + assert.isTrue(isContained, 'Rect contains (0.5, 0.5)'); + }); + + test('centroid for non-square not at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(2.5, 4); + + assert.isTrue(isContained, 'Rect contains (2.5, 4)'); + }); + + test('negative centroid for non-square not at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(-10, -20), + 3, + 5, + ); + + const isContained = rect.contains(-8.5, -17.5); + + assert.isTrue(isContained, 'Rect contains (-8.5, -17.5)'); + }); + + test('NW corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(1, 2); + + assert.isTrue(isContained, 'Rect contains (1, 2)'); + }); + + test('NE corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(4, 2); + + assert.isTrue(isContained, 'Rect contains (4, 2)'); + }); + + test('SW corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(1, 6); + + assert.isTrue(isContained, 'Rect contains (1, 6)'); + }); + + test('SE corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(4, 6); + + assert.isTrue(isContained, 'Rect contains (4, 6)'); + }); + + test('left edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(1, 4); + + assert.isTrue(isContained, 'Rect contains (1, 4)'); + }); + + test('right edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(4, 4); + + assert.isTrue(isContained, 'Rect contains (4, 4)'); + }); + + test('top edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(2.5, 2); + + assert.isTrue(isContained, 'Rect contains (2.5, 2)'); + }); + + test('bottom edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(2.5, 6); + + assert.isTrue(isContained, 'Rect contains (2.5, 6)'); + }); + }); + suite('point not contained within rect', function () { + test('non-origin for zero-sized square', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 0, + 0, + ); + + const isContained = rect.contains(0.1, 0.1); + + assert.isFalse(isContained, 'Rect does not contain (0.1, 0.1)'); + }); + + test('point at midpoint x but above unit square', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + + const isContained = rect.contains(2, 0); + + assert.isFalse(isContained, 'Rect does not contain (2, 0)'); + }); + + test('point at midpoint x but below unit square', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + + const isContained = rect.contains(2, 4); + + assert.isFalse(isContained, 'Rect does not contain (2, 4)'); + }); + + test('point at midpoint y but left of unit square', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + + const isContained = rect.contains(0, 2); + + assert.isFalse(isContained, 'Rect does not contain (0, 2)'); + }); + + test('point at midpoint y but right of unit square', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + + const isContained = rect.contains(4, 2); + + assert.isFalse(isContained, 'Rect does not contain (4, 2)'); + }); + + test('positive point far outside positive rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(45, 89); + + assert.isFalse(isContained, 'Rect does not contain (45, 89)'); + }); + + test('negative point far outside positive rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(-45, -89); + + assert.isFalse(isContained, 'Rect does not contain (-45, -89)'); + }); + + test('positive point far outside negative rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(-10, -20), + 3, + 5, + ); + + const isContained = rect.contains(45, 89); + + assert.isFalse(isContained, 'Rect does not contain (45, 89)'); + }); + + test('negative point far outside negative rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(-10, -20), + 3, + 5, + ); + + const isContained = rect.contains(-45, -89); + + assert.isFalse(isContained, 'Rect does not contain (-45, -89)'); + }); + + test('Point just outside NW corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(0.9, 1.9); + + assert.isFalse(isContained, 'Rect does not contain (0.9, 1.9)'); + }); + + test('Point just outside NE corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(4.1, 1.9); + + assert.isFalse(isContained, 'Rect does not contain (4.1, 1.9)'); + }); + + test('Point just outside SW corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(0.9, 6.1); + + assert.isFalse(isContained, 'Rect does not contain (0.9, 6.1)'); + }); + + test('Point just outside SE corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(4.1, 6.1); + + assert.isFalse(isContained, 'Rect does not contain (4.1, 6.1)'); + }); + + test('Point just outside left edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(0.9, 4); + + assert.isFalse(isContained, 'Rect does not contain (0.9, 4)'); + }); + + test('Point just outside right edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(4.1, 4); + + assert.isFalse(isContained, 'Rect does not contain (4.1, 4)'); + }); + + test('Point just outside top edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(2.5, 1.9); + + assert.isFalse(isContained, 'Rect does not contain (2.5, 1.9)'); + }); + + test('Point just outside bottom edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(2.5, 6.1); + + assert.isFalse(isContained, 'Rect does not contain (2.5, 6.1)'); + }); + }); + }); + + // NOTE TO DEVELOPER: For intersection tests, rects are either large (dimension size '2') or small + // (dimension size '1'). For compactness, the comments denoting the scenario being tested try to + // label larger rects with '2' where they can fit, but smaller rects are generally too small to + // fit any text. + suite('intersects()', function () { + suite('does intersect', function () { + test('rect and itself', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const doIntersect = rect.intersects(rect); + + assert.isTrue(doIntersect, 'a rect always intersects with itself'); + }); + + test('rect and its clone', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const doIntersect = rect.intersects(rect.clone()); + + assert.isTrue(doIntersect, 'a rect always intersects with its clone'); + }); + + test('two rects of the same positions and dimensions', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const doIntersect = rect1.intersects(rect2); + + assert.isTrue( + doIntersect, + 'two rects with the same positions and dimensions intersect', + ); + }); + + test('upper left/lower right', function () { + // ┌───┐ + // │2┌───┐ + // └─│─┘2│ + // └───┘ + const nwRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const seRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2, 2), + 2, + 2, + ); + + const doIntersectOneWay = nwRect.intersects(seRect); + const doIntersectOtherWay = seRect.intersects(nwRect); + + assert.isTrue( + doIntersectOneWay, + 'SE corner of NW rect intersects with SE rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'NW corner of SE rect intersects with NW rect', + ); + }); + + test('upper right/lower left', function () { + // ┌───┐ + // ┌───┐2│ + // │2└─│─┘ + // └───┘ + const neRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2, 1), + 2, + 2, + ); + const swRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 2, + 2, + ); + + const doIntersectOneWay = neRect.intersects(swRect); + const doIntersectOtherWay = swRect.intersects(neRect); + + assert.isTrue( + doIntersectOneWay, + 'SW corner of NE rect intersects with SW rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'NE corner of SW rect intersects with NE rect', + ); + }); + + test('small rect overlapping left side of big rect', function () { + // ┌────┐ + // ┌───┐2 │ + // └───┘ │ + // └────┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0.5, 1.5), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + 'big rect intersects top/bottom sides of small rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'small rect intersects left side of big rect', + ); + }); + + test('small rect overlapping right side of big rect', function () { + // ┌────┐ + // │ 2┌───┐ + // │ └───┘ + // └────┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2.5, 1.5), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + 'big rect intersects top/bottom sides of small rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'small rect intersects right side of big rect', + ); + }); + + test('small rect overlapping top side of big rect', function () { + // ┌─┐ + // ┌│─│┐ + // │└─┘│ + // └───┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1.5, 0.5), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + 'big rect intersects left/right sides of small rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'small rect intersects top side of big rect', + ); + }); + + test('small rect overlapping bottom side of big rect', function () { + // ┌───┐ + // │┌─┐│ + // └│─│┘ + // └─┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1.5, 2.5), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + 'big rect intersects left/right sides of small rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'small rect intersects bottom side of big rect', + ); + }); + + test('edge only intersection with all corners outside each rect', function () { + // ┌─┐ + // │ │ + // ┌─────┐ + // └─────┘ + // │ │ + // └─┘ + const tallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2, 1), + 1, + 2, + ); + const wideRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 2, + 1, + ); + + const doIntersectOneWay = tallRect.intersects(wideRect); + const doIntersectOtherWay = wideRect.intersects(tallRect); + + assert.isTrue( + doIntersectOneWay, + 'tall rect intersects top/bottom of wide rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'wide rect intersects left/right of tall rect', + ); + }); + + test('small rect within larger rect', function () { + // ┌─────┐ + // │ ┌─┐ │ + // │ └─┘ │ + // └─────┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1.5, 1.5), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + 'big rect intersects small rect since it is contained', + ); + assert.isTrue( + doIntersectOtherWay, + 'small rect intersects big rect since it is contained', + ); + }); + + test('rects overlapping on left/right sides', function () { + // ┌──┌────┐ + // │ 2│ │2 │ + // └──└────┘ + const leftRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const rightRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2, 1), + 2, + 2, + ); + + const doIntersectOneWay = leftRect.intersects(rightRect); + const doIntersectOtherWay = rightRect.intersects(leftRect); + + assert.isTrue( + doIntersectOneWay, + "Left rect's right overlaps with right rect's left", + ); + assert.isTrue( + doIntersectOtherWay, + "Right rect's left overlaps with left rect's right", + ); + }); + + test('rects overlapping on top/bottom sides', function () { + // ┌───┐ + // ┌───┐ + // │───│ + // └───┘ + const topRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const bottomRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 2, + 2, + ); + + const doIntersectOneWay = topRect.intersects(bottomRect); + const doIntersectOtherWay = bottomRect.intersects(topRect); + + assert.isTrue( + doIntersectOneWay, + "Top rect's bottom overlaps with bottom rect's top", + ); + assert.isTrue( + doIntersectOtherWay, + "Bottom rect's top overlaps with top rect's bottom", + ); + }); + + test('rects adjacent on left/right sides', function () { + // ┌───┬───┐ + // │ 2 │ 2 │ + // └───┴───┘ + const leftRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const rightRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3, 1), + 2, + 2, + ); + + const doIntersectOneWay = leftRect.intersects(rightRect); + const doIntersectOtherWay = rightRect.intersects(leftRect); + + assert.isTrue( + doIntersectOneWay, + "Left rect's right intersects with right rect's left", + ); + assert.isTrue( + doIntersectOtherWay, + "Right rect's left intersects with left rect's right", + ); + }); + + test('rects adjacent on top/bottom sides', function () { + // ┌───┐ + // │ 2 │ + // ├───┤ + // │ 2 │ + // └───┘ + const topRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const bottomRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 3), + 2, + 2, + ); + + const doIntersectOneWay = topRect.intersects(bottomRect); + const doIntersectOtherWay = bottomRect.intersects(topRect); + + assert.isTrue( + doIntersectOneWay, + "Top rect's bottom intersects with bottom rect's top", + ); + assert.isTrue( + doIntersectOtherWay, + "Bottom rect's top intersects with top rect's bottom", + ); + }); + + test('small left rect adjacent to big right rect', function () { + // ┌───┐ + // ┌─┐ 2 │ + // └─┘ │ + // └───┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1.5), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + "big rect's left intersects small rect's right", + ); + assert.isTrue( + doIntersectOtherWay, + "small rect's right intersects big rect's left", + ); + }); + + test('small right rect adjacent to big left rect', function () { + // ┌───┐ + // │ 2 ┌─┐ + // │ └─┘ + // └───┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3, 1.5), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + "big rect's right intersects small rect's left", + ); + assert.isTrue( + doIntersectOtherWay, + "small rect's left intersects big rect's right", + ); + }); + + test('small top rect adjacent to big bottom rect', function () { + // ┌─┐ + // ┌└─┘┐ + // │ 2 │ + // └───┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1.5, 0), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + "big rect's top intersects small rect's bottom", + ); + assert.isTrue( + doIntersectOtherWay, + "small rect's bottom intersects big rect's top", + ); + }); + + test('small bottom rect adjacent to big top rect', function () { + // ┌───┐ + // │ 2 │ + // └┌─┐┘ + // └─┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1.5, 3), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + "big rect's bottom intersects small rect's top", + ); + assert.isTrue( + doIntersectOtherWay, + "small rect's top intersects big rect's bottom", + ); + }); + + test('SW rect corner-adjacent to NE rect', function () { + // ┌───┐ + // │ 2 │ + // ┌───┐───┘ + // │ 2 │ + // └───┘ + const swRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 3), + 2, + 2, + ); + const neRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3, 1), + 2, + 2, + ); + + const doIntersectOneWay = swRect.intersects(neRect); + const doIntersectOtherWay = neRect.intersects(swRect); + + assert.isTrue( + doIntersectOneWay, + 'SW rect intersects with SW corner of NE rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'NE rect intersects with NE corner of SW rect', + ); + }); + + test('NW rect corner-adjacent to SE rect', function () { + // ┌───┐ + // │ 2 │ + // └───┘───┐ + // │ 2 │ + // └───┘ + const nwRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const seRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3, 3), + 2, + 2, + ); + + const doIntersectOneWay = seRect.intersects(nwRect); + const doIntersectOtherWay = nwRect.intersects(seRect); + + assert.isTrue( + doIntersectOneWay, + 'SE rect intersects with SE corner of NW rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'NW rect intersects with NW corner of SE rect', + ); + }); + }); + suite('does not intersect', function () { + test('Same-size rects nearly side-adjacent', function () { + // ┌───┐ ┌───┐ + // │ 2 │ │ 2 │ + // └───┘ └───┘ + const westRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const eastRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3.5, 1), + 2, + 2, + ); + + const doIntersectOneWay = westRect.intersects(eastRect); + const doIntersectOtherWay = eastRect.intersects(westRect); + + assert.isFalse( + doIntersectOneWay, + 'Western rect does not intersect with eastern rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'Eastern rect does not intersect with western rect', + ); + }); + + test('Same-size rects nearly side-adjacent', function () { + // ┌───┐ + // │ 2 │ + // └───┘ + // ┌───┐ + // │ 2 │ + // └───┘ + const northRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const southRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 3.5), + 2, + 2, + ); + + const doIntersectOneWay = northRect.intersects(southRect); + const doIntersectOtherWay = southRect.intersects(northRect); + + assert.isFalse( + doIntersectOneWay, + 'Northern rect does not intersect with southern rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'Southern rect does not intersect with northern rect', + ); + }); + + test('Small rect left of big rect', function () { + // ┌───┐ + // ┌─┐│ 2 │ + // └─┘│ │ + // └───┘ + const westRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 1, + 1, + ); + const eastRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2.5, 1), + 2, + 2, + ); + + const doIntersectOneWay = westRect.intersects(eastRect); + const doIntersectOtherWay = eastRect.intersects(westRect); + + assert.isFalse( + doIntersectOneWay, + 'Western rect does not intersect with eastern rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'Eastern rect does not intersect with western rect', + ); + }); + + test('Small rect right of big rect', function () { + // ┌───┐ + // │ 2 │┌─┐ + // │ │└─┘ + // └───┘ + const westRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const eastRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3.5, 1), + 1, + 1, + ); + + const doIntersectOneWay = westRect.intersects(eastRect); + const doIntersectOtherWay = eastRect.intersects(westRect); + + assert.isFalse( + doIntersectOneWay, + 'Western rect does not intersect with eastern rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'Eastern rect does not intersect with western rect', + ); + }); + + test('Small rect above big rect', function () { + // ┌─┐ + // └─┘ + // ┌───┐ + // │ 2 │ + // └───┘ + const northRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 1, + 1, + ); + const southRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2.5), + 2, + 2, + ); + + const doIntersectOneWay = northRect.intersects(southRect); + const doIntersectOtherWay = southRect.intersects(northRect); + + assert.isFalse( + doIntersectOneWay, + 'Northern rect does not intersect with southern rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'Southern rect does not intersect with northern rect', + ); + }); + + test('Small rect below big rect', function () { + // ┌───┐ + // │ 2 │ + // └───┘ + // ┌─┐ + // └─┘ + const northRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const southRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 3.5), + 1, + 1, + ); + + const doIntersectOneWay = northRect.intersects(southRect); + const doIntersectOtherWay = southRect.intersects(northRect); + + assert.isFalse( + doIntersectOneWay, + 'Northern rect does not intersect with southern rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'Southern rect does not intersect with northern rect', + ); + }); + + test('Same-size rects diagonal (NE and SW) to each other', function () { + // ┌───┐ + // │ 2 │ + // └───┘ + // ┌───┐ + // │ 2 │ + // └───┘ + const neRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3.5, 1), + 2, + 2, + ); + const swRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 3.5), + 2, + 2, + ); + + const doIntersectOneWay = neRect.intersects(swRect); + const doIntersectOtherWay = swRect.intersects(neRect); + + assert.isFalse( + doIntersectOneWay, + 'NE rect does not intersect with SW rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'SW rect does not intersect with NE rect', + ); + }); + + test('Same-size rects diagonal (NW and SE) to each other', function () { + // ┌───┐ + // │ 2 │ + // └───┘ + // ┌───┐ + // │ 2 │ + // └───┘ + const nwRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const seRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3.5, 3.5), + 2, + 2, + ); + + const doIntersectOneWay = nwRect.intersects(seRect); + const doIntersectOtherWay = seRect.intersects(nwRect); + + assert.isFalse( + doIntersectOneWay, + 'NW rect does not intersect with SE rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'SE rect does not intersect with NW rect', + ); + }); + + test('Small rect NE of big rect', function () { + // ┌─┐ + // └─┘ + // ┌───┐ + // │ 2 │ + // └───┘ + const neRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3.5, 1), + 1, + 1, + ); + const swRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2.5), + 2, + 2, + ); + + const doIntersectOneWay = neRect.intersects(swRect); + const doIntersectOtherWay = swRect.intersects(neRect); + + assert.isFalse( + doIntersectOneWay, + 'NE rect does not intersect with SW rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'SW rect does not intersect with NE rect', + ); + }); + + test('Small rect NW of big rect', function () { + // ┌─┐ + // └─┘ + // ┌───┐ + // │ 2 │ + // └───┘ + const nwRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 1, + 1, + ); + const seRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2.5, 2.5), + 2, + 2, + ); + + const doIntersectOneWay = nwRect.intersects(seRect); + const doIntersectOtherWay = seRect.intersects(nwRect); + + assert.isFalse( + doIntersectOneWay, + 'NW rect does not intersect with SE rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'SE rect does not intersect with NW rect', + ); + }); + + test('Small rect SW of big rect', function () { + // ┌───┐ + // │ 2 │ + // └───┘ + // ┌─┐ + // └─┘ + const neRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2.5, 1), + 2, + 2, + ); + const swRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 3.5), + 1, + 1, + ); + + const doIntersectOneWay = neRect.intersects(swRect); + const doIntersectOtherWay = swRect.intersects(neRect); + + assert.isFalse( + doIntersectOneWay, + 'NE rect does not intersect with SW rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'SW rect does not intersect with NE rect', + ); + }); + + test('Small rect SE of big rect', function () { + // ┌───┐ + // │ 2 │ + // └───┘ + // ┌─┐ + // └─┘ + const nwRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const seRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3.5, 3.5), + 1, + 1, + ); + + const doIntersectOneWay = nwRect.intersects(seRect); + const doIntersectOtherWay = seRect.intersects(nwRect); + + assert.isFalse( + doIntersectOneWay, + 'NW rect does not intersect with SE rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'SE rect does not intersect with NW rect', + ); + }); + }); + }); +}); diff --git a/tests/mocha/workspace_svg_test.js b/tests/mocha/workspace_svg_test.js index d5c80e2f32..75c0625fb1 100644 --- a/tests/mocha/workspace_svg_test.js +++ b/tests/mocha/workspace_svg_test.js @@ -406,6 +406,560 @@ suite('WorkspaceSvg', function () { }); }); }); + + suite('cleanUp', function () { + test('empty workspace does not change', function () { + this.workspace.cleanUp(); + + const blocks = this.workspace.getTopBlocks(true); + assert.equal(blocks.length, 0, 'workspace is empty'); + }); + + test('single block at (0, 0) does not change', function () { + const blockJson = { + 'type': 'math_number', + 'x': 0, + 'y': 0, + 'fields': { + 'NUM': 123, + }, + }; + Blockly.serialization.blocks.append(blockJson, this.workspace); + + this.workspace.cleanUp(); + + const blocks = this.workspace.getTopBlocks(true); + const origin = new Blockly.utils.Coordinate(0, 0); + assert.equal(blocks.length, 1, 'workspace has one top-level block'); + assert.deepEqual( + blocks[0].getRelativeToSurfaceXY(), + origin, + 'block is at origin', + ); + }); + + test('single block at (10, 15) is moved to (0, 0)', function () { + const blockJson = { + 'type': 'math_number', + 'x': 10, + 'y': 15, + 'fields': { + 'NUM': 123, + }, + }; + Blockly.serialization.blocks.append(blockJson, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const allBlocks = this.workspace.getAllBlocks(false); + const origin = new Blockly.utils.Coordinate(0, 0); + assert.equal(topBlocks.length, 1, 'workspace has one top-level block'); + assert.equal(allBlocks.length, 1, 'workspace has one block overall'); + assert.deepEqual( + topBlocks[0].getRelativeToSurfaceXY(), + origin, + 'block is at origin', + ); + }); + + test('single block at (10, 15) with child is moved as unit to (0, 0)', function () { + const blockJson = { + 'type': 'logic_negate', + 'id': 'parent', + 'x': 10, + 'y': 15, + 'inputs': { + 'BOOL': { + 'block': { + 'type': 'logic_boolean', + 'id': 'child', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + }, + }; + Blockly.serialization.blocks.append(blockJson, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const allBlocks = this.workspace.getAllBlocks(false); + const origin = new Blockly.utils.Coordinate(0, 0); + assert.equal(topBlocks.length, 1, 'workspace has one top-level block'); + assert.equal(allBlocks.length, 2, 'workspace has two blocks overall'); + assert.deepEqual( + topBlocks[0].getRelativeToSurfaceXY(), + origin, + 'block is at origin', + ); + assert.notDeepEqual( + allBlocks[1].getRelativeToSurfaceXY(), + origin, + 'child is not at origin', + ); + }); + + test('two blocks first at (10, 15) second at (0, 0) do not switch places', function () { + const blockJson1 = { + 'type': 'math_number', + 'id': 'block1', + 'x': 10, + 'y': 15, + 'fields': { + 'NUM': 123, + }, + }; + const blockJson2 = {...blockJson1, 'id': 'block2', 'x': 0, 'y': 0}; + Blockly.serialization.blocks.append(blockJson1, this.workspace); + Blockly.serialization.blocks.append(blockJson2, this.workspace); + + this.workspace.cleanUp(); + + // block1 and block2 do not switch places since blocks are pre-sorted by their position before + // being tidied up, so the order they were added to the workspace doesn't matter. + const topBlocks = this.workspace.getTopBlocks(true); + const block1 = this.workspace.getBlockById('block1'); + const block2 = this.workspace.getBlockById('block2'); + const origin = new Blockly.utils.Coordinate(0, 0); + const belowBlock2 = new Blockly.utils.Coordinate(0, 50); + assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks'); + assert.deepEqual( + block2.getRelativeToSurfaceXY(), + origin, + 'block2 is at origin', + ); + assert.deepEqual( + block1.getRelativeToSurfaceXY(), + belowBlock2, + 'block1 is below block2', + ); + }); + + test('two overlapping blocks are moved to origin and below', function () { + const blockJson1 = { + 'type': 'math_number', + 'id': 'block1', + 'x': 25, + 'y': 15, + 'fields': { + 'NUM': 123, + }, + }; + const blockJson2 = { + ...blockJson1, + 'id': 'block2', + 'x': 15.25, + 'y': 20.25, + }; + Blockly.serialization.blocks.append(blockJson1, this.workspace); + Blockly.serialization.blocks.append(blockJson2, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const block1 = this.workspace.getBlockById('block1'); + const block2 = this.workspace.getBlockById('block2'); + const origin = new Blockly.utils.Coordinate(0, 0); + const belowBlock1 = new Blockly.utils.Coordinate(0, 50); + assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks'); + assert.deepEqual( + block1.getRelativeToSurfaceXY(), + origin, + 'block1 is at origin', + ); + assert.deepEqual( + block2.getRelativeToSurfaceXY(), + belowBlock1, + 'block2 is below block1', + ); + }); + + test('two overlapping blocks with snapping are moved to grid-aligned positions', function () { + const blockJson1 = { + 'type': 'math_number', + 'id': 'block1', + 'x': 25, + 'y': 15, + 'fields': { + 'NUM': 123, + }, + }; + const blockJson2 = { + ...blockJson1, + 'id': 'block2', + 'x': 15.25, + 'y': 20.25, + }; + Blockly.serialization.blocks.append(blockJson1, this.workspace); + Blockly.serialization.blocks.append(blockJson2, this.workspace); + this.workspace.getGrid().setSpacing(20); + this.workspace.getGrid().setSnapToGrid(true); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const block1 = this.workspace.getBlockById('block1'); + const block2 = this.workspace.getBlockById('block2'); + const snappedOffOrigin = new Blockly.utils.Coordinate(10, 10); + const belowBlock1 = new Blockly.utils.Coordinate(10, 70); + assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks'); + assert.deepEqual( + block1.getRelativeToSurfaceXY(), + snappedOffOrigin, + 'block1 is near origin', + ); + assert.deepEqual( + block2.getRelativeToSurfaceXY(), + belowBlock1, + 'block2 is below block1', + ); + }); + + test('two overlapping blocks are moved to origin and below including children', function () { + const blockJson1 = { + 'type': 'logic_negate', + 'id': 'block1', + 'x': 10, + 'y': 15, + 'inputs': { + 'BOOL': { + 'block': { + 'type': 'logic_boolean', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + }, + }; + const blockJson2 = { + ...blockJson1, + 'id': 'block2', + 'x': 15.25, + 'y': 20.25, + }; + Blockly.serialization.blocks.append(blockJson1, this.workspace); + Blockly.serialization.blocks.append(blockJson2, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const allBlocks = this.workspace.getAllBlocks(false); + const block1 = this.workspace.getBlockById('block1'); + const block2 = this.workspace.getBlockById('block2'); + const origin = new Blockly.utils.Coordinate(0, 0); + const belowBlock1 = new Blockly.utils.Coordinate(0, 50); + const block1Pos = block1.getRelativeToSurfaceXY(); + const block2Pos = block2.getRelativeToSurfaceXY(); + const block1ChildPos = block1.getChildren()[0].getRelativeToSurfaceXY(); + const block2ChildPos = block2.getChildren()[0].getRelativeToSurfaceXY(); + assert.equal(topBlocks.length, 2, 'workspace has two top-level block2'); + assert.equal(allBlocks.length, 4, 'workspace has four blocks overall'); + assert.deepEqual(block1Pos, origin, 'block1 is at origin'); + assert.deepEqual(block2Pos, belowBlock1, 'block2 is below block1'); + assert.isAbove( + block1ChildPos.x, + block1Pos.x, + "block1's child is right of it", + ); + assert.isBelow( + block1ChildPos.y, + block2Pos.y, + "block1's child is above block 2", + ); + assert.isAbove( + block2ChildPos.x, + block2Pos.x, + "block2's child is right of it", + ); + assert.isAbove( + block2ChildPos.y, + block1Pos.y, + "block2's child is below block 1", + ); + }); + + test('two large overlapping blocks are moved to origin and below', function () { + const blockJson1 = { + 'type': 'controls_repeat_ext', + 'id': 'block1', + 'x': 10, + 'y': 20, + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'fields': { + 'NUM': 10, + }, + }, + }, + 'DO': { + 'block': { + 'type': 'controls_if', + 'inputs': { + 'IF0': { + 'block': { + 'type': 'logic_boolean', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + 'DO0': { + 'block': { + 'type': 'text_print', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'fields': { + 'TEXT': 'abc', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const blockJson2 = {...blockJson1, 'id': 'block2', 'x': 20, 'y': 30}; + Blockly.serialization.blocks.append(blockJson1, this.workspace); + Blockly.serialization.blocks.append(blockJson2, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const block1 = this.workspace.getBlockById('block1'); + const block2 = this.workspace.getBlockById('block2'); + const origin = new Blockly.utils.Coordinate(0, 0); + const belowBlock1 = new Blockly.utils.Coordinate(0, 144); + assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks'); + assert.deepEqual( + block1.getRelativeToSurfaceXY(), + origin, + 'block1 is at origin', + ); + assert.deepEqual( + block2.getRelativeToSurfaceXY(), + belowBlock1, + 'block2 is below block1', + ); + }); + + test('five overlapping blocks are moved in-order as one column', function () { + const blockJson1 = { + 'type': 'math_number', + 'id': 'block1', + 'x': 1, + 'y': 2, + 'fields': { + 'NUM': 123, + }, + }; + const blockJson2 = {...blockJson1, 'id': 'block2', 'x': 3, 'y': 4}; + const blockJson3 = {...blockJson1, 'id': 'block3', 'x': 5, 'y': 6}; + const blockJson4 = {...blockJson1, 'id': 'block4', 'x': 7, 'y': 8}; + const blockJson5 = {...blockJson1, 'id': 'block5', 'x': 9, 'y': 10}; + Blockly.serialization.blocks.append(blockJson1, this.workspace); + Blockly.serialization.blocks.append(blockJson2, this.workspace); + Blockly.serialization.blocks.append(blockJson3, this.workspace); + Blockly.serialization.blocks.append(blockJson4, this.workspace); + Blockly.serialization.blocks.append(blockJson5, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const block1Pos = this.workspace + .getBlockById('block1') + .getRelativeToSurfaceXY(); + const block2Pos = this.workspace + .getBlockById('block2') + .getRelativeToSurfaceXY(); + const block3Pos = this.workspace + .getBlockById('block3') + .getRelativeToSurfaceXY(); + const block4Pos = this.workspace + .getBlockById('block4') + .getRelativeToSurfaceXY(); + const block5Pos = this.workspace + .getBlockById('block5') + .getRelativeToSurfaceXY(); + const origin = new Blockly.utils.Coordinate(0, 0); + assert.equal(topBlocks.length, 5, 'workspace has five top-level blocks'); + assert.deepEqual(block1Pos, origin, 'block1 is at origin'); + assert.equal(block2Pos.x, 0, 'block2.x is at 0'); + assert.equal(block3Pos.x, 0, 'block3.x is at 0'); + assert.equal(block4Pos.x, 0, 'block4.x is at 0'); + assert.equal(block5Pos.x, 0, 'block5.x is at 0'); + assert.isAbove(block2Pos.y, block1Pos.y, 'block2 is below block1'); + assert.isAbove(block3Pos.y, block2Pos.y, 'block3 is below block2'); + assert.isAbove(block4Pos.y, block3Pos.y, 'block4 is below block3'); + assert.isAbove(block5Pos.y, block4Pos.y, 'block5 is below block4'); + }); + + test('single immovable block at (10, 15) is not moved', function () { + const blockJson = { + 'type': 'math_number', + 'x': 10, + 'y': 15, + 'movable': false, + 'fields': { + 'NUM': 123, + }, + }; + Blockly.serialization.blocks.append(blockJson, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const allBlocks = this.workspace.getAllBlocks(false); + const origPos = new Blockly.utils.Coordinate(10, 15); + assert.equal(topBlocks.length, 1, 'workspace has one top-level block'); + assert.equal(allBlocks.length, 1, 'workspace has one block overall'); + assert.deepEqual( + topBlocks[0].getRelativeToSurfaceXY(), + origPos, + 'block is at (10, 15)', + ); + }); + + test('multiple block types immovable blocks are not moved', function () { + const smallBlockJson = { + 'type': 'math_number', + 'fields': { + 'NUM': 123, + }, + }; + const largeBlockJson = { + 'type': 'controls_repeat_ext', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'fields': { + 'NUM': 10, + }, + }, + }, + 'DO': { + 'block': { + 'type': 'controls_if', + 'inputs': { + 'IF0': { + 'block': { + 'type': 'logic_boolean', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + 'DO0': { + 'block': { + 'type': 'text_print', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'fields': { + 'TEXT': 'abc', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + // Block 1 overlaps block 2 (immovable) from above. + const blockJson1 = {...smallBlockJson, 'id': 'block1', 'x': 1, 'y': 2}; + const blockJson2 = { + ...smallBlockJson, + 'id': 'block2', + 'x': 10, + 'y': 20, + 'movable': false, + }; + // Block 3 overlaps block 2 (immovable) from below. + const blockJson3 = {...smallBlockJson, 'id': 'block3', 'x': 2, 'y': 30}; + const blockJson4 = {...largeBlockJson, 'id': 'block4', 'x': 3, 'y': 40}; + // Block 5 (immovable) will end up overlapping with block 4 since it's large and will be + // moved. + const blockJson5 = { + ...smallBlockJson, + 'id': 'block5', + 'x': 20, + 'y': 200, + 'movable': false, + }; + Blockly.serialization.blocks.append(blockJson1, this.workspace); + Blockly.serialization.blocks.append(blockJson2, this.workspace); + Blockly.serialization.blocks.append(blockJson3, this.workspace); + Blockly.serialization.blocks.append(blockJson4, this.workspace); + Blockly.serialization.blocks.append(blockJson5, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const block1Rect = this.workspace + .getBlockById('block1') + .getBoundingRectangle(); + const block2Rect = this.workspace + .getBlockById('block2') + .getBoundingRectangle(); + const block3Rect = this.workspace + .getBlockById('block3') + .getBoundingRectangle(); + const block4Rect = this.workspace + .getBlockById('block4') + .getBoundingRectangle(); + const block5Rect = this.workspace + .getBlockById('block5') + .getBoundingRectangle(); + assert.equal(topBlocks.length, 5, 'workspace has five top-level blocks'); + // Check that immovable blocks haven't moved. + assert.equal(block2Rect.left, 10, 'block2.x is at 10'); + assert.equal(block2Rect.top, 20, 'block2.y is at 20'); + assert.equal(block5Rect.left, 20, 'block5.x is at 20'); + assert.equal(block5Rect.top, 200, 'block5.y is at 200'); + // Check that movable positions have correctly been left-aligned. + assert.equal(block1Rect.left, 0, 'block1.x is at 0'); + assert.equal(block3Rect.left, 0, 'block3.x is at 0'); + assert.equal(block4Rect.left, 0, 'block4.x is at 0'); + // Block order should be: 2, 1, 3, 5, 4 since 2 and 5 are immovable. + assert.isAbove(block1Rect.top, block2Rect.top, 'block1 is below block2'); + assert.isAbove(block3Rect.top, block1Rect.top, 'block3 is below block1'); + assert.isAbove(block5Rect.top, block3Rect.top, 'block5 is below block3'); + assert.isAbove(block4Rect.top, block5Rect.top, 'block4 is below block5'); + // Ensure no blocks intersect (can check in order due to the position verification above). + assert.isFalse( + block2Rect.intersects(block1Rect), + 'block2/block1 do not intersect', + ); + assert.isFalse( + block1Rect.intersects(block3Rect), + 'block1/block3 do not intersect', + ); + assert.isFalse( + block3Rect.intersects(block5Rect), + 'block3/block5 do not intersect', + ); + assert.isFalse( + block5Rect.intersects(block4Rect), + 'block5/block4 do not intersect', + ); + }); + }); + suite('Workspace Base class', function () { testAWorkspace(); });