Files
blockly/tests/mocha/connection_db_test.js
Ben Henning 1e37d21f0a fix: Ensure focus changes when tabbing fields (#9173)
## The basics

- [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change)

## The details
### Resolves

Fixes https://github.com/google/blockly-keyboard-experimentation/issues/578
Fixes part of #8915

### Proposed Changes

Ensures fields update focus to the next field when tabbing between field editors. The old behavior can be observed in [#578](https://github.com/google/blockly-keyboard-experimentation/issues/578) and the new behavior can be observed here:

[Screen recording 2025-06-25 1.39.28 PM.webm](https://github.com/user-attachments/assets/e00fcb55-5c20-4d5c-81a8-be9049cc0d7e)

### Reason for Changes

Having focus reset back to the original field editor that was opened is an unexpected experience for users. This approach is better.

Note that there are some separate changes added here, as well:
- Connections and fields now check if their block IDs contain their indicator prefixes since this will all-but-guarantee focus breaks for those nodes. This is an excellent example of why #9171 is needed.
- Some minor naming updates for `FieldInput`: it was incorrectly implying that key events are sent for changes to the `input` element used by the field editor, but they are actually `InputEvent`s per https://developer.mozilla.org/en-US/docs/Web/API/Element/input_event.

### Test Coverage

New tests were added for field editing in general (since this seems to be missing), including tabbing support to ensure the fixes originally introduced in #9049.

One new test has been added specifically for verifying that focus updates with tabbing. This has been verified to fail with the fix removed (as have all tabbing tests with the tabbing code from #9049 removed).

Some specific notes for the test changes:
- There's a slight test dependency inversion happening here. `FieldInput` is being tested with a specific `FieldNumber` class via real block loading. This isn't ideal, but it seems fine given the circumstances (otherwise a lot of extra setup would be necessary for the tests).
- The workspace actually needs to be made visible during tests in order for focus to work correctly (though it's reset at the end of each test, but this may cause some flickering while the tests are running).
- It's the case that a bunch of tests were actually setting up blocks incorrectly (i.e. not defining a must-have `id` property which caused some issues with the new field and connection ID validation checks). These tests have been corrected, but it's worth noting that the blocks are likely still technically wrong since they are not conforming to their TypeScript contracts.
- Similar to the previous point, one test was incorrectly setting the first ID to be returned by the ID generator as `undefined` since (presumably due to a copy-and-paste error when the test was introduced) it was referencing a `TEST_BLOCK_ID` property that hadn't been defined for that test suite. This has been corrected as, without it, there are failures due to the new validation checks.
- For the connection database checks, a new ID is generated instead of fixing the block ID to ensure that it's always unique even if called multiple times (otherwise a block ID would need to be piped through from the calling tests, or an invalid situation would need to be introduced where multiple blocks shared an ID; the former seemed unnecessary and the latter seemed nonideal).
- There are distinct Geras/Zelos tests to validate the case where a full-block field should have its parent block, rather than the field itself, focused on tabbing. See this conversation for more context: https://github.com/google/blockly/pull/9173#discussion_r2172921455.

### Documentation

No documentation changes should be needed here.

### Additional Information

Nothing to add.
2025-07-02 16:07:05 -07:00

364 lines
11 KiB
JavaScript

/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {ConnectionType} from '../../build/src/core/connection_type.js';
import * as idGenerator from '../../build/src/core/utils/idgenerator.js';
import {assert} from '../../node_modules/chai/chai.js';
import {
sharedTestSetup,
sharedTestTeardown,
} from './test_helpers/setup_teardown.js';
suite('Connection Database', function () {
setup(function () {
sharedTestSetup.call(this);
this.database = new Blockly.ConnectionDB(new Blockly.ConnectionChecker());
this.assertOrder = function () {
const length = this.database.connections.length;
for (let i = 1; i < length; i++) {
assert.isAtMost(
this.database.connections[i - 1].y,
this.database.connections[i].y,
);
}
};
this.createConnection = function (x, y, type, opt_database) {
const workspace = {
connectionDBList: [],
};
workspace.connectionDBList[type] = opt_database || this.database;
const connection = new Blockly.RenderedConnection(
{id: idGenerator.getNextUniqueId(), workspace: workspace},
type,
);
connection.x = x;
connection.y = y;
return connection;
};
this.createSimpleTestConnections = function () {
for (let i = 0; i < 10; i++) {
const connection = this.createConnection(
0,
i,
ConnectionType.PREVIOUS_STATEMENT,
);
this.database.addConnection(connection, i);
}
};
});
teardown(function () {
sharedTestTeardown.call(this);
});
test('Add Connection', function () {
const y2 = {y: 2};
const y4 = {y: 4};
const y1 = {y: 1};
const y3a = {y: 3};
const y3b = {y: 3};
this.database.addConnection(y2, 2);
assert.sameOrderedMembers(this.database.connections, [y2]);
this.database.addConnection(y4, 4);
assert.sameOrderedMembers(this.database.connections, [y2, y4]);
this.database.addConnection(y1, 1);
assert.sameOrderedMembers(this.database.connections, [y1, y2, y4]);
this.database.addConnection(y3a, 3);
assert.sameOrderedMembers(this.database.connections, [y1, y2, y3a, y4]);
this.database.addConnection(y3b, 3);
assert.sameOrderedMembers(this.database.connections, [
y1,
y2,
y3b,
y3a,
y4,
]);
});
test('Remove Connection', function () {
const y2 = {y: 2};
const y4 = {y: 4};
const y1 = {y: 1};
const y3a = {y: 3};
const y3b = {y: 3};
const y3c = {y: 3};
this.database.addConnection(y2, 2);
this.database.addConnection(y4, 4);
this.database.addConnection(y1, 1);
this.database.addConnection(y3c, 3);
this.database.addConnection(y3b, 3);
this.database.addConnection(y3a, 3);
assert.sameOrderedMembers(this.database.connections, [
y1,
y2,
y3a,
y3b,
y3c,
y4,
]);
this.database.removeConnection(y2, 2);
assert.sameOrderedMembers(this.database.connections, [
y1,
y3a,
y3b,
y3c,
y4,
]);
this.database.removeConnection(y4, 4);
assert.sameOrderedMembers(this.database.connections, [y1, y3a, y3b, y3c]);
this.database.removeConnection(y1, 1);
assert.sameOrderedMembers(this.database.connections, [y3a, y3b, y3c]);
this.database.removeConnection(y3a, 3);
assert.sameOrderedMembers(this.database.connections, [y3b, y3c]);
this.database.removeConnection(y3c, 3);
assert.sameOrderedMembers(this.database.connections, [y3b]);
this.database.removeConnection(y3b, 3);
assert.isEmpty(this.database.connections);
});
suite('Get Neighbors', function () {
test('Empty Database', function () {
const connection = this.createConnection(
0,
0,
ConnectionType.NEXT_STATEMENT,
new Blockly.ConnectionDB(),
);
assert.isEmpty(this.database.getNeighbours(connection), 100);
});
test('Block At Top', function () {
this.createSimpleTestConnections();
const checkConnection = this.createConnection(
0,
0,
ConnectionType.NEXT_STATEMENT,
new Blockly.ConnectionDB(),
);
const neighbors = this.database.getNeighbours(checkConnection, 4);
assert.sameMembers(neighbors, this.database.connections.slice(0, 5));
});
test('Block In Middle', function () {
this.createSimpleTestConnections();
const checkConnection = this.createConnection(
0,
4,
ConnectionType.NEXT_STATEMENT,
new Blockly.ConnectionDB(),
);
const neighbors = this.database.getNeighbours(checkConnection, 2);
assert.sameMembers(neighbors, this.database.connections.slice(2, 7));
});
test('Block At End', function () {
this.createSimpleTestConnections();
const checkConnection = this.createConnection(
0,
9,
ConnectionType.NEXT_STATEMENT,
new Blockly.ConnectionDB(),
);
const neighbors = this.database.getNeighbours(checkConnection, 4);
assert.sameMembers(neighbors, this.database.connections.slice(5, 10));
});
test('Out of Range X', function () {
this.createSimpleTestConnections();
const checkConnection = this.createConnection(
10,
9,
ConnectionType.NEXT_STATEMENT,
new Blockly.ConnectionDB(),
);
const neighbors = this.database.getNeighbours(checkConnection, 4);
assert.isEmpty(neighbors);
});
test('Out of Range Y', function () {
this.createSimpleTestConnections();
const checkConnection = this.createConnection(
0,
19,
ConnectionType.NEXT_STATEMENT,
new Blockly.ConnectionDB(),
);
const neighbors = this.database.getNeighbours(checkConnection, 4);
assert.isEmpty(neighbors);
});
test('Out of Range Diagonal', function () {
this.createSimpleTestConnections();
const checkConnection = this.createConnection(
-2,
-2,
ConnectionType.NEXT_STATEMENT,
new Blockly.ConnectionDB(),
);
const neighbors = this.database.getNeighbours(checkConnection, 2);
assert.isEmpty(neighbors);
});
});
suite('Ordering', function () {
test('Simple', function () {
for (let i = 0; i < 10; i++) {
const connection = this.createConnection(
0,
i,
ConnectionType.NEXT_STATEMENT,
);
this.database.addConnection(connection, i);
}
this.assertOrder();
});
test('Quasi-Random', function () {
const xCoords = [
-29, -47, -77, 2, 43, 34, -59, -52, -90, -36, -91, 38, 87, -20, 60, 4,
-57, 65, -37, -81, 57, 58, -96, 1, 67, -79, 34, 93, -90, -99, -62, 4,
11, -36, -51, -72, 3, -50, -24, -45, -92, -38, 37, 24, -47, -73, 79,
-20, 99, 43, -10, -87, 19, 35, -62, -36, 49, 86, -24, -47, -89, 33, -44,
25, -73, -91, 85, 6, 0, 89, -94, 36, -35, 84, -9, 96, -21, 52, 10, -95,
7, -67, -70, 62, 9, -40, -95, -9, -94, 55, 57, -96, 55, 8, -48, -57,
-87, 81, 23, 65,
];
const yCoords = [
-81, 82, 5, 47, 30, 57, -12, 28, 38, 92, -25, -20, 23, -51, 73, -90, 8,
28, -51, -15, 81, -60, -6, -16, 77, -62, -42, -24, 35, 95, -46, -7, 61,
-16, 14, 91, 57, -38, 27, -39, 92, 47, -98, 11, -33, -72, 64, 38, -64,
-88, -35, -59, -76, -94, 45, -25, -100, -95, 63, -97, 45, 98, 99, 34,
27, 52, -18, -45, 66, -32, -38, 70, -73, -23, 5, -2, -13, -9, 48, 74,
-97, -11, 35, -79, -16, -77, 83, -57, -53, 35, -44, 100, -27, -15, 5,
39, 33, -19, -20, -95,
];
const length = xCoords.length;
for (let i = 0; i < length; i++) {
const connection = this.createConnection(
xCoords[i],
yCoords[i],
ConnectionType.NEXT_STATEMENT,
);
this.database.addConnection(connection, yCoords[i]);
}
this.assertOrder();
});
});
suite('Search For Closest', function () {
setup(function () {
// Ignore type checks.
sinon.stub(this.database.connectionChecker, 'doTypeChecks').returns(true);
// Ignore safety checks.
sinon
.stub(this.database.connectionChecker, 'doSafetyChecks')
.returns(Blockly.Connection.CAN_CONNECT);
// Skip everything but the distance checks.
sinon
.stub(this.database.connectionChecker, 'doDragChecks')
.callsFake(function (a, b, distance) {
return a.distanceFrom(b) <= distance;
});
this.createCheckConnection = function (x, y) {
const checkConnection = this.createConnection(
x,
y,
ConnectionType.NEXT_STATEMENT,
new Blockly.ConnectionDB(),
);
return checkConnection;
};
});
test('Empty Database', function () {
const checkConnection = this.createConnection(
0,
0,
ConnectionType.NEXT_STATEMENT,
new Blockly.ConnectionDB(),
);
assert.isNull(
this.database.searchForClosest(checkConnection, 100, {x: 0, y: 0})
.connection,
);
});
test('Too Far', function () {
const connection = this.createConnection(
0,
100,
ConnectionType.PREVIOUS_STATEMENT,
);
this.database.addConnection(connection, 100);
const checkConnection = this.createConnection(
0,
0,
ConnectionType.NEXT_STATEMENT,
new Blockly.ConnectionDB(),
);
assert.isNull(
this.database.searchForClosest(checkConnection, 50, {x: 0, y: 0})
.connection,
);
});
test('Single in Range', function () {
this.createSimpleTestConnections();
const checkConnection = this.createCheckConnection(0, 14);
const last = this.database.connections[9];
const closest = this.database.searchForClosest(checkConnection, 5, {
x: 0,
y: 0,
}).connection;
assert.equal(last, closest);
});
test('Many in Range', function () {
this.createSimpleTestConnections();
const checkConnection = this.createCheckConnection(0, 10);
const last = this.database.connections[9];
const closest = this.database.searchForClosest(checkConnection, 5, {
x: 0,
y: 0,
}).connection;
assert.equal(last, closest);
});
test('No Y-Coord Priority', function () {
const connection1 = this.createConnection(
6,
6,
ConnectionType.PREVIOUS_STATEMENT,
);
this.database.addConnection(connection1, 6);
const connection2 = this.createConnection(
5,
5,
ConnectionType.PREVIOUS_STATEMENT,
);
this.database.addConnection(connection2, 5);
const checkConnection = this.createCheckConnection(4, 6);
const closest = this.database.searchForClosest(checkConnection, 3, {
x: 0,
y: 0,
}).connection;
assert.equal(connection2, closest);
});
});
});