Files
blockly/tests/mocha/connection_checker_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

695 lines
20 KiB
JavaScript

/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {ConnectionType} from '../../build/src/core/connection_type.js';
import {assert} from '../../node_modules/chai/chai.js';
import {
sharedTestSetup,
sharedTestTeardown,
} from './test_helpers/setup_teardown.js';
suite('Connection checker', function () {
setup(function () {
sharedTestSetup.call(this);
});
teardown(function () {
sharedTestTeardown.call(this);
});
suiteSetup(function () {
this.checker = new Blockly.ConnectionChecker();
});
suite('Safety checks', function () {
function assertReasonHelper(checker, one, two, reason) {
assert.equal(checker.canConnectWithReason(one, two), reason);
// Order should not matter.
assert.equal(checker.canConnectWithReason(two, one), reason);
}
test('Target Null', function () {
const connection = new Blockly.Connection(
{id: 'test'},
ConnectionType.INPUT_VALUE,
);
assertReasonHelper(
this.checker,
connection,
null,
Blockly.Connection.REASON_TARGET_NULL,
);
});
test('Target Self', function () {
const block = {id: 'test', workspace: 1};
const connection1 = new Blockly.Connection(
block,
ConnectionType.INPUT_VALUE,
);
const connection2 = new Blockly.Connection(
block,
ConnectionType.OUTPUT_VALUE,
);
assertReasonHelper(
this.checker,
connection1,
connection2,
Blockly.Connection.REASON_SELF_CONNECTION,
);
});
test('Different Workspaces', function () {
const connection1 = new Blockly.Connection(
{id: 'test1', workspace: 1},
ConnectionType.INPUT_VALUE,
);
const connection2 = new Blockly.Connection(
{id: 'test2', workspace: 2},
ConnectionType.OUTPUT_VALUE,
);
assertReasonHelper(
this.checker,
connection1,
connection2,
Blockly.Connection.REASON_DIFFERENT_WORKSPACES,
);
});
suite('Types', function () {
setup(function () {
// We have to declare each separately so that the connections belong
// on different blocks.
const prevBlock = {id: 'test1', isShadow: function () {}};
const nextBlock = {id: 'test2', isShadow: function () {}};
const outBlock = {id: 'test3', isShadow: function () {}};
const inBlock = {id: 'test4', isShadow: function () {}};
this.previous = new Blockly.Connection(
prevBlock,
ConnectionType.PREVIOUS_STATEMENT,
);
this.next = new Blockly.Connection(
nextBlock,
ConnectionType.NEXT_STATEMENT,
);
this.output = new Blockly.Connection(
outBlock,
ConnectionType.OUTPUT_VALUE,
);
this.input = new Blockly.Connection(
inBlock,
ConnectionType.INPUT_VALUE,
);
});
test('Previous, Next', function () {
assertReasonHelper(
this.checker,
this.previous,
this.next,
Blockly.Connection.CAN_CONNECT,
);
});
test('Previous, Output', function () {
assertReasonHelper(
this.checker,
this.previous,
this.output,
Blockly.Connection.REASON_WRONG_TYPE,
);
});
test('Previous, Input', function () {
assertReasonHelper(
this.checker,
this.previous,
this.input,
Blockly.Connection.REASON_WRONG_TYPE,
);
});
test('Next, Previous', function () {
assertReasonHelper(
this.checker,
this.next,
this.previous,
Blockly.Connection.CAN_CONNECT,
);
});
test('Next, Output', function () {
assertReasonHelper(
this.checker,
this.next,
this.output,
Blockly.Connection.REASON_WRONG_TYPE,
);
});
test('Next, Input', function () {
assertReasonHelper(
this.checker,
this.next,
this.input,
Blockly.Connection.REASON_WRONG_TYPE,
);
});
test('Output, Previous', function () {
assertReasonHelper(
this.checker,
this.previous,
this.output,
Blockly.Connection.REASON_WRONG_TYPE,
);
});
test('Output, Next', function () {
assertReasonHelper(
this.checker,
this.output,
this.next,
Blockly.Connection.REASON_WRONG_TYPE,
);
});
test('Output, Input', function () {
assertReasonHelper(
this.checker,
this.output,
this.input,
Blockly.Connection.CAN_CONNECT,
);
});
test('Input, Previous', function () {
assertReasonHelper(
this.checker,
this.previous,
this.input,
Blockly.Connection.REASON_WRONG_TYPE,
);
});
test('Input, Next', function () {
assertReasonHelper(
this.checker,
this.input,
this.next,
Blockly.Connection.REASON_WRONG_TYPE,
);
});
test('Input, Output', function () {
assertReasonHelper(
this.checker,
this.input,
this.output,
Blockly.Connection.CAN_CONNECT,
);
});
});
suite('Shadows', function () {
test('Previous Shadow', function () {
const prevBlock = {
id: 'test1',
isShadow: function () {
return true;
},
};
const nextBlock = {
id: 'test2',
isShadow: function () {
return false;
},
};
const prev = new Blockly.Connection(
prevBlock,
ConnectionType.PREVIOUS_STATEMENT,
);
const next = new Blockly.Connection(
nextBlock,
ConnectionType.NEXT_STATEMENT,
);
assertReasonHelper(
this.checker,
prev,
next,
Blockly.Connection.CAN_CONNECT,
);
});
test('Next Shadow', function () {
const prevBlock = {
id: 'test1',
isShadow: function () {
return false;
},
};
const nextBlock = {
id: 'test2',
isShadow: function () {
return true;
},
};
const prev = new Blockly.Connection(
prevBlock,
ConnectionType.PREVIOUS_STATEMENT,
);
const next = new Blockly.Connection(
nextBlock,
ConnectionType.NEXT_STATEMENT,
);
assertReasonHelper(
this.checker,
prev,
next,
Blockly.Connection.REASON_SHADOW_PARENT,
);
});
test('Prev and Next Shadow', function () {
const prevBlock = {
id: 'test1',
isShadow: function () {
return true;
},
};
const nextBlock = {
id: 'test2',
isShadow: function () {
return true;
},
};
const prev = new Blockly.Connection(
prevBlock,
ConnectionType.PREVIOUS_STATEMENT,
);
const next = new Blockly.Connection(
nextBlock,
ConnectionType.NEXT_STATEMENT,
);
assertReasonHelper(
this.checker,
prev,
next,
Blockly.Connection.CAN_CONNECT,
);
});
test('Output Shadow', function () {
const outBlock = {
id: 'test1',
isShadow: function () {
return true;
},
};
const inBlock = {
id: 'test2',
isShadow: function () {
return false;
},
};
const outCon = new Blockly.Connection(
outBlock,
ConnectionType.OUTPUT_VALUE,
);
const inCon = new Blockly.Connection(
inBlock,
ConnectionType.INPUT_VALUE,
);
assertReasonHelper(
this.checker,
outCon,
inCon,
Blockly.Connection.CAN_CONNECT,
);
});
test('Input Shadow', function () {
const outBlock = {
id: 'test1',
isShadow: function () {
return false;
},
};
const inBlock = {
id: 'test2',
isShadow: function () {
return true;
},
};
const outCon = new Blockly.Connection(
outBlock,
ConnectionType.OUTPUT_VALUE,
);
const inCon = new Blockly.Connection(
inBlock,
ConnectionType.INPUT_VALUE,
);
assertReasonHelper(
this.checker,
outCon,
inCon,
Blockly.Connection.REASON_SHADOW_PARENT,
);
});
test('Output and Input Shadow', function () {
const outBlock = {
id: 'test1',
isShadow: function () {
return true;
},
};
const inBlock = {
id: 'test2',
isShadow: function () {
return true;
},
};
const outCon = new Blockly.Connection(
outBlock,
ConnectionType.OUTPUT_VALUE,
);
const inCon = new Blockly.Connection(
inBlock,
ConnectionType.INPUT_VALUE,
);
assertReasonHelper(
this.checker,
outCon,
inCon,
Blockly.Connection.CAN_CONNECT,
);
});
});
suite('Output and Previous', function () {
/**
* Update two connections to target each other.
* @param {Connection} first The first connection to update.
* @param {Connection} second The second connection to update.
*/
const connectReciprocally = function (first, second) {
if (!first || !second) {
throw Error('Cannot connect null connections.');
}
first.targetConnection = second;
second.targetConnection = first;
};
test('Output connected, adding previous', function () {
const outBlock = {
id: 'test1',
isShadow: function () {},
};
const inBlock = {
id: 'test2',
isShadow: function () {},
};
const outCon = new Blockly.Connection(
outBlock,
ConnectionType.OUTPUT_VALUE,
);
const inCon = new Blockly.Connection(
inBlock,
ConnectionType.INPUT_VALUE,
);
outBlock.outputConnection = outCon;
inBlock.inputConnection = inCon;
connectReciprocally(inCon, outCon);
const prevCon = new Blockly.Connection(
outBlock,
ConnectionType.PREVIOUS_STATEMENT,
);
const nextBlock = {
id: 'test3',
isShadow: function () {},
};
const nextCon = new Blockly.Connection(
nextBlock,
ConnectionType.NEXT_STATEMENT,
);
assertReasonHelper(
this.checker,
prevCon,
nextCon,
Blockly.Connection.REASON_PREVIOUS_AND_OUTPUT,
);
});
test('Previous connected, adding output', function () {
const prevBlock = {
id: 'test1',
isShadow: function () {},
};
const nextBlock = {
id: 'test2',
isShadow: function () {},
};
const prevCon = new Blockly.Connection(
prevBlock,
ConnectionType.PREVIOUS_STATEMENT,
);
const nextCon = new Blockly.Connection(
nextBlock,
ConnectionType.NEXT_STATEMENT,
);
prevBlock.previousConnection = prevCon;
nextBlock.nextConnection = nextCon;
connectReciprocally(prevCon, nextCon);
const outCon = new Blockly.Connection(
prevBlock,
ConnectionType.OUTPUT_VALUE,
);
const inBlock = {
id: 'test3',
isShadow: function () {},
};
const inCon = new Blockly.Connection(
inBlock,
ConnectionType.INPUT_VALUE,
);
assertReasonHelper(
this.checker,
outCon,
inCon,
Blockly.Connection.REASON_PREVIOUS_AND_OUTPUT,
);
});
});
});
suite('Check Types', function () {
setup(function () {
this.con1 = new Blockly.Connection(
{id: 'test1'},
ConnectionType.PREVIOUS_STATEMENT,
);
this.con2 = new Blockly.Connection(
{id: 'test2'},
ConnectionType.NEXT_STATEMENT,
);
});
function assertCheckTypes(checker, one, two) {
assert.isTrue(checker.doTypeChecks(one, two));
// Order should not matter.
assert.isTrue(checker.doTypeChecks(one, two));
}
test('No Types', function () {
assertCheckTypes(this.checker, this.con1, this.con2);
});
test('Same Type', function () {
this.con1.setCheck('type1');
this.con2.setCheck('type1');
assertCheckTypes(this.checker, this.con1, this.con2);
});
test('Same Types', function () {
this.con1.setCheck(['type1', 'type2']);
this.con2.setCheck(['type1', 'type2']);
assertCheckTypes(this.checker, this.con1, this.con2);
});
test('Single Same Type', function () {
this.con1.setCheck(['type1', 'type2']);
this.con2.setCheck(['type1', 'type3']);
assertCheckTypes(this.checker, this.con1, this.con2);
});
test('One Typed, One Promiscuous', function () {
this.con1.setCheck('type1');
assertCheckTypes(this.checker, this.con1, this.con2);
});
test('No Compatible Types', function () {
this.con1.setCheck('type1');
this.con2.setCheck('type2');
assert.isFalse(this.checker.doTypeChecks(this.con1, this.con2));
});
});
suite('Dragging Checks', function () {
suite('Stacks', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv');
// Load in three blocks: A and B are connected (next/prev); B is unmovable.
Blockly.Xml.domToWorkspace(
Blockly.utils.xml
.textToDom(`<xml xmlns="https://developers.google.com/blockly/xml">
<block type="text_print" id="A" x="-76" y="-112">
<next>
<block type="text_print" id="B" movable="false">
</block>
</next>
</block>
<block type="text_print" id="C" x="47" y="-118"/>
</xml>`),
this.workspace,
);
this.blockA = this.workspace.getBlockById('A');
this.blockB = this.workspace.getBlockById('B');
this.blockC = this.workspace.getBlockById('C');
this.checker = this.workspace.connectionChecker;
});
test('Connect a stack', function () {
// block C is not connected to block A; both are movable.
assert.isTrue(
this.checker.doDragChecks(
this.blockC.nextConnection,
this.blockA.previousConnection,
9000,
),
'Should connect two compatible stack blocks',
);
});
test('Connect to unmovable shadow block', function () {
// Remove original test blocks.
this.workspace.clear();
// Add the same test blocks, but this time block B is a shadow block.
Blockly.Xml.domToWorkspace(
Blockly.utils.xml
.textToDom(`<xml xmlns="https://developers.google.com/blockly/xml">
<block type="text_print" id="A" x="-76" y="-112">
<next>
<shadow type="text_print" id="B" movable="false">
</shadow>
</next>
</block>
<block type="text_print" id="C" x="47" y="-118"/>
</xml>`),
this.workspace,
);
[this.blockA, this.blockB, this.blockC] =
this.workspace.getAllBlocks(true);
// Try to connect blockC into the input connection of blockA, replacing blockB.
// This is allowed because shadow blocks can always be replaced, even though
// they are unmovable.
assert.isTrue(
this.checker.doDragChecks(
this.blockC.previousConnection,
this.blockA.nextConnection,
9000,
),
'Should connect in place of a shadow block',
);
});
test('Do not splice into unmovable stack', function () {
// Try to connect blockC above blockB. It shouldn't work because B is not movable
// and is already connected to A's nextConnection.
assert.isFalse(
this.checker.doDragChecks(
this.blockC.previousConnection,
this.blockA.nextConnection,
9000,
),
'Should not splice in a block above an unmovable block',
);
});
test('Connect to bottom of unmovable stack', function () {
// Try to connect blockC below blockB.
// This is allowed even though B is not movable because it is on B's nextConnection.
assert.isTrue(
this.checker.doDragChecks(
this.blockC.previousConnection,
this.blockB.nextConnection,
9000,
),
'Should connect below an unmovable stack block',
);
});
test('Connect to unconnected unmovable block', function () {
this.blockB.previousConnection.disconnect();
this.blockA.dispose();
// Try to connect blockC above blockB.
// This is allowed because we're not splicing into a stack.
assert.isTrue(
this.checker.doDragChecks(
this.blockC.nextConnection,
this.blockB.previousConnection,
9000,
),
'Should connect above an unconnected unmovable block',
);
});
});
suite('Rows', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv');
// Load 3 blocks: A and B are connected (input/output); B is unmovable.
Blockly.Xml.domToWorkspace(
Blockly.utils.xml
.textToDom(`<xml xmlns="https://developers.google.com/blockly/xml">
<block type="test_basic_row" id="A" x="38" y="37">
<value name="INPUT">
<block type="test_basic_row" id="B" movable="false"></block>
</value>
</block>
<block type="test_basic_row" id="C" x="38" y="87"></block>
</xml>`),
this.workspace,
);
[this.blockA, this.blockB, this.blockC] =
this.workspace.getAllBlocks(true);
this.checker = this.workspace.connectionChecker;
});
test('Do not splice into unmovable block row', function () {
// Try to connect C's output to A's input. Should fail because
// A is already connected to B, which is unmovable.
const inputConnection = this.blockA.inputList[0].connection;
assert.isFalse(
this.checker.doDragChecks(
this.blockC.outputConnection,
inputConnection,
9000,
),
'Should not splice in a block before an unmovable block',
);
});
test('Connect to end of unmovable block', function () {
// Make blockC unmovable
this.blockC.setMovable(false);
// Try to connect A's output to C's input. This is allowed.
const inputConnection = this.blockC.inputList[0].connection;
assert.isTrue(
this.checker.doDragChecks(
this.blockA.outputConnection,
inputConnection,
9000,
),
'Should connect to end of unmovable block',
);
});
test('Connect to unconnected unmovable block', function () {
this.blockB.outputConnection.disconnect();
this.blockA.dispose();
// Try to connect C's input to B's output. Allowed because B is now unconnected.
const inputConnection = this.blockC.inputList[0].connection;
assert.isTrue(
this.checker.doDragChecks(
inputConnection,
this.blockB.outputConnection,
9000,
),
'Should connect to unconnected unmovable block',
);
});
});
});
});