Files
blockly/tests/mocha/cursor_test.js
Ben Henning 7288860bd4 fix: Clean up accessibility node hierarchy (experimental) (#9449)
## The basics

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

## The details
### Resolves

Fixes #9304

### Proposed Changes

Fixes non-visual parent roles and rendered connection navigation policy.

### Reason for Changes

Other PRs have made progress on removing extraneous accessibility nodes with #9446 being essentially the last of these. Ensuring that parent/child relationships are correct is the last step in ensuring that the entirety of the accessibility node graph is correctly representing the DOM and navigational structure of Blockly.

This can have implications and (ideally) improvements for certain screen reader modes that provide higher-level summarization and sometimes navigation (bypassing Blockly's keyboard navigation) since it avoids an incorrect flat node structure and instead ensures correct hierarchy and ordering.

It was discovered during the development of the PR that setting `aria-owns` properties to ensure that all focusable accessibility nodes have the correct parent/child relationships (particularly for blocks) isn't actually viable per the analysis summarized in this comment: https://github.com/RaspberryPiFoundation/blockly/pull/9449#issuecomment-3663234767. At a high level introducing these relationships seems to actually cause problems in both ChromeVox and Voiceover.

Part of the analysis discovered that nodes set with the `presentation` role aren't going to behave correctly due to the spec ignoring that role if any children of such elements are focusable, so this PR does change those over to `generic` which is more correct. They are still missing in Chrome's accessibility node viewer, and `generic` _seems_ to introduce slightly better `group` behaviors on VoiceOver (that is, it seems to reduce some of the `group` announcements which VoiceOver is known for over-specifying).

Note that some tests needed to be updated to ensure that they were properly rendering blocks (in order for `RenderedConnection.canBeFocused()` to behave correctly) in the original implementation of the PR. Only one test actually changed in behavior because it seemed like it was incorrect before--the particular connection being tested wasn't actually navigable and the change to `canBeFocused` actually enforces that. These changes were kept even though the behaviors weren't needed anymore since it's still a bit more correct than before.

Overall, #9304 is closed here because the tree seems to be about as good as it can get with current knowledge (assuming no other invalid roles need to be fixed, but that can be addressed in separate issues as needed).

### Test Coverage

No automated tests are needed for this since it's experimental but it has been manually tested with both ChromeVox and Voiceover.

### Documentation

No documentation changes are needed for these experimental changes.

### Additional Information

Note that there are some limitations with this approach: text editors and listboxes (e.g. for comboboxes) are generally outside of the hierarchy represented by the Blockly workspace. This is an existing issue that remains unaffected by these changes, and fixing it to be both ARIA compliant and consistent with the DOM may not be possible (though it doesn't seem like there's a strong requirement to maintain DOM and accessibility node tree hierarchical relationships).

The analysis linked above also considered introducing a top-level `application` role which might change some of the automated behaviors of certain roles but this only seemed to worsen local testing with ChromeVox so it was excluded.
2025-12-18 09:53:14 -08:00

931 lines
27 KiB
JavaScript

/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '../../node_modules/chai/index.js';
import {createRenderedBlock} from './test_helpers/block_definitions.js';
import {
sharedTestSetup,
sharedTestTeardown,
} from './test_helpers/setup_teardown.js';
suite('Cursor', function () {
suite('Movement', function () {
setup(function () {
sharedTestSetup.call(this);
Blockly.defineBlocksWithJsonArray([
{
'type': 'input_statement',
'message0': '%1 %2 %3 %4',
'args0': [
{
'type': 'field_input',
'name': 'NAME1',
'text': 'default',
},
{
'type': 'field_input',
'name': 'NAME2',
'text': 'default',
},
{
'type': 'input_value',
'name': 'NAME3',
},
{
'type': 'input_statement',
'name': 'NAME4',
},
],
'previousStatement': null,
'nextStatement': null,
'colour': 230,
'tooltip': '',
'helpUrl': '',
},
{
'type': 'field_input',
'message0': '%1',
'args0': [
{
'type': 'field_input',
'name': 'NAME',
'text': 'default',
},
],
'output': null,
'colour': 230,
'tooltip': '',
'helpUrl': '',
},
{
'type': 'multi_statement_input',
'message0': '%1 %2',
'args0': [
{
'type': 'input_statement',
'name': 'FIRST',
},
{
'type': 'input_statement',
'name': 'SECOND',
},
],
},
{
'type': 'simple_statement',
'message0': '%1',
'args0': [
{
'type': 'field_input',
'name': 'NAME',
'text': 'default',
},
],
'previousStatement': null,
'nextStatement': null,
},
]);
this.workspace = Blockly.inject('blocklyDiv', {});
this.cursor = this.workspace.getCursor();
const blockA = createRenderedBlock(this.workspace, 'input_statement');
const blockB = createRenderedBlock(this.workspace, 'input_statement');
const blockC = createRenderedBlock(this.workspace, 'input_statement');
const blockD = createRenderedBlock(this.workspace, 'input_statement');
const blockE = createRenderedBlock(this.workspace, 'field_input');
blockA.nextConnection.connect(blockB.previousConnection);
blockA.inputList[0].connection.connect(blockE.outputConnection);
blockB.inputList[1].connection.connect(blockC.previousConnection);
this.cursor.drawer = null;
this.blocks = {
A: blockA,
B: blockB,
C: blockC,
D: blockD,
E: blockE,
};
});
teardown(function () {
sharedTestTeardown.call(this);
});
test('Next - From a Previous connection go to the next block', function () {
const prevNode = this.blocks.A.previousConnection;
this.cursor.setCurNode(prevNode);
this.cursor.next();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, this.blocks.A);
});
test('Next - From a block go to its statement input', function () {
const prevNode = this.blocks.B;
this.cursor.setCurNode(prevNode);
this.cursor.next();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, this.blocks.C);
});
test('In - From field to attached input connection', function () {
const fieldBlock = this.blocks.E;
const fieldNode = this.blocks.A.getField('NAME2');
this.cursor.setCurNode(fieldNode);
this.cursor.in();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, fieldBlock);
});
test('Prev - From previous connection does not skip over next connection', function () {
const prevConnection = this.blocks.B.previousConnection;
const prevConnectionNode = prevConnection;
this.cursor.setCurNode(prevConnectionNode);
this.cursor.prev();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, this.blocks.A.nextConnection);
});
test('Prev - From first block loop to last statement input', function () {
const prevConnection = this.blocks.A;
const prevConnectionNode = prevConnection;
this.cursor.setCurNode(prevConnectionNode);
this.cursor.prev();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, this.blocks.D.getInput('NAME4').connection);
});
test('Out - From field does not skip over block node', function () {
const field = this.blocks.E.inputList[0].fieldRow[0];
const fieldNode = field;
this.cursor.setCurNode(fieldNode);
this.cursor.out();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, this.blocks.E);
});
test('Out - From first connection loop to last next connection', function () {
const prevConnection = this.blocks.A.previousConnection;
const prevConnectionNode = prevConnection;
this.cursor.setCurNode(prevConnectionNode);
this.cursor.out();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, this.blocks.D.nextConnection);
});
});
suite('Multiple statement inputs', function () {
setup(function () {
sharedTestSetup.call(this);
Blockly.defineBlocksWithJsonArray([
{
'type': 'multi_statement_input',
'message0': '%1 %2',
'args0': [
{
'type': 'input_statement',
'name': 'FIRST',
},
{
'type': 'input_statement',
'name': 'SECOND',
},
],
},
{
'type': 'simple_statement',
'message0': '%1',
'args0': [
{
'type': 'field_input',
'name': 'NAME',
'text': 'default',
},
],
'previousStatement': null,
'nextStatement': null,
},
]);
this.workspace = Blockly.inject('blocklyDiv', {});
this.cursor = this.workspace.getCursor();
this.multiStatement1 = createRenderedBlock(
this.workspace,
'multi_statement_input',
);
this.multiStatement2 = createRenderedBlock(
this.workspace,
'multi_statement_input',
);
this.firstStatement = createRenderedBlock(
this.workspace,
'simple_statement',
);
this.secondStatement = createRenderedBlock(
this.workspace,
'simple_statement',
);
this.thirdStatement = createRenderedBlock(
this.workspace,
'simple_statement',
);
this.fourthStatement = createRenderedBlock(
this.workspace,
'simple_statement',
);
this.multiStatement1
.getInput('FIRST')
.connection.connect(this.firstStatement.previousConnection);
this.firstStatement.nextConnection.connect(
this.secondStatement.previousConnection,
);
this.multiStatement1
.getInput('SECOND')
.connection.connect(this.thirdStatement.previousConnection);
this.multiStatement2
.getInput('FIRST')
.connection.connect(this.fourthStatement.previousConnection);
});
teardown(function () {
sharedTestTeardown.call(this);
});
test('In - from field in nested statement block to next nested statement block', function () {
this.cursor.setCurNode(this.secondStatement.getField('NAME'));
this.cursor.in();
// Skip over the next connection
this.cursor.in();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, this.thirdStatement);
});
test('In - from field in nested statement block to next stack', function () {
this.cursor.setCurNode(this.thirdStatement.getField('NAME'));
this.cursor.in();
// Skip over the next connection
this.cursor.in();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, this.multiStatement2);
});
test('Out - from nested statement block to last field of previous nested statement block', function () {
this.cursor.setCurNode(this.thirdStatement);
this.cursor.out();
// Skip over the previous next connection
this.cursor.out();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, this.secondStatement.getField('NAME'));
});
test('Out - from root block to last field of last nested statement block in previous stack', function () {
this.cursor.setCurNode(this.multiStatement2);
this.cursor.out();
// Skip over the previous next connection
this.cursor.out();
const curNode = this.cursor.getCurNode();
assert.equal(curNode, this.thirdStatement.getField('NAME'));
});
});
suite('Searching', function () {
setup(function () {
sharedTestSetup.call(this);
Blockly.defineBlocksWithJsonArray([
{
'type': 'empty_block',
'message0': '',
},
{
'type': 'stack_block',
'message0': '',
'previousStatement': null,
'nextStatement': null,
},
{
'type': 'row_block',
'message0': '%1',
'args0': [
{
'type': 'input_value',
'name': 'INPUT',
},
],
'output': null,
},
{
'type': 'statement_block',
'message0': '%1',
'args0': [
{
'type': 'input_statement',
'name': 'STATEMENT',
},
],
'previousStatement': null,
'nextStatement': null,
},
{
'type': 'c_hat_block',
'message0': '%1',
'args0': [
{
'type': 'input_statement',
'name': 'STATEMENT',
},
],
},
]);
this.workspace = Blockly.inject('blocklyDiv', {});
this.cursor = this.workspace.getCursor();
});
teardown(function () {
sharedTestTeardown.call(this);
});
suite('one empty block', function () {
setup(function () {
this.blockA = createRenderedBlock(this.workspace, 'empty_block');
});
teardown(function () {
this.workspace.clear();
});
test('getFirstNode', function () {
const node = this.cursor.getFirstNode();
assert.equal(node, this.blockA);
});
test('getLastNode', function () {
const node = this.cursor.getLastNode();
assert.equal(node, this.blockA);
});
});
suite('one stack block', function () {
setup(function () {
this.blockA = createRenderedBlock(this.workspace, 'stack_block');
});
teardown(function () {
this.workspace.clear();
});
test('getFirstNode', function () {
const node = this.cursor.getFirstNode();
assert.equal(node, this.blockA);
});
test('getLastNode', function () {
const node = this.cursor.getLastNode();
assert.equal(node, this.blockA);
});
});
suite('one row block', function () {
setup(function () {
this.blockA = createRenderedBlock(this.workspace, 'row_block');
});
teardown(function () {
this.workspace.clear();
});
test('getFirstNode', function () {
const node = this.cursor.getFirstNode();
assert.equal(node, this.blockA);
});
test('getLastNode', function () {
const node = this.cursor.getLastNode();
assert.equal(node, this.blockA.inputList[0].connection);
});
});
suite('one c-hat block', function () {
setup(function () {
this.blockA = createRenderedBlock(this.workspace, 'c_hat_block');
});
teardown(function () {
this.workspace.clear();
});
test('getFirstNode', function () {
const node = this.cursor.getFirstNode();
assert.equal(node, this.blockA);
});
test('getLastNode', function () {
const node = this.cursor.getLastNode();
assert.equal(node, this.blockA.inputList[0].connection);
});
});
suite('multiblock stack', function () {
setup(function () {
const state = {
'blocks': {
'languageVersion': 0,
'blocks': [
{
'type': 'stack_block',
'id': 'A',
'x': 0,
'y': 0,
'next': {
'block': {
'type': 'stack_block',
'id': 'B',
},
},
},
],
},
};
Blockly.serialization.workspaces.load(state, this.workspace);
});
teardown(function () {
this.workspace.clear();
});
test('getFirstNode', function () {
const node = this.cursor.getFirstNode();
const blockA = this.workspace.getBlockById('A');
assert.equal(node, blockA);
});
test('getLastNode', function () {
const node = this.cursor.getLastNode();
const blockB = this.workspace.getBlockById('B');
assert.equal(node, blockB);
});
});
suite('multiblock row', function () {
setup(function () {
const state = {
'blocks': {
'languageVersion': 0,
'blocks': [
{
'type': 'row_block',
'id': 'A',
'x': 0,
'y': 0,
'inputs': {
'INPUT': {
'block': {
'type': 'row_block',
'id': 'B',
},
},
},
},
],
},
};
Blockly.serialization.workspaces.load(state, this.workspace);
});
teardown(function () {
this.workspace.clear();
});
test('getFirstNode', function () {
const node = this.cursor.getFirstNode();
const blockA = this.workspace.getBlockById('A');
assert.equal(node, blockA);
});
test('getLastNode', function () {
const node = this.cursor.getLastNode();
const blockB = this.workspace.getBlockById('B');
assert.equal(node, blockB.inputList[0].connection);
});
});
suite('two stacks', function () {
setup(function () {
const state = {
'blocks': {
'languageVersion': 0,
'blocks': [
{
'type': 'stack_block',
'id': 'A',
'x': 0,
'y': 0,
'next': {
'block': {
'type': 'stack_block',
'id': 'B',
},
},
},
{
'type': 'stack_block',
'id': 'C',
'x': 100,
'y': 100,
'next': {
'block': {
'type': 'stack_block',
'id': 'D',
},
},
},
],
},
};
Blockly.serialization.workspaces.load(state, this.workspace);
});
teardown(function () {
this.workspace.clear();
});
test('getFirstNode', function () {
const node = this.cursor.getFirstNode();
const location = node;
const blockA = this.workspace.getBlockById('A');
assert.equal(location, blockA);
});
test('getLastNode', function () {
const node = this.cursor.getLastNode();
const location = node;
const blockD = this.workspace.getBlockById('D');
assert.equal(location, blockD);
});
});
});
suite('Get next node', function () {
setup(function () {
sharedTestSetup.call(this);
Blockly.defineBlocksWithJsonArray([
{
'type': 'empty_block',
'message0': '',
},
{
'type': 'stack_block',
'message0': '%1',
'args0': [
{
'type': 'field_input',
'name': 'FIELD',
'text': 'default',
},
],
'previousStatement': null,
'nextStatement': null,
},
{
'type': 'row_block',
'message0': '%1 %2',
'args0': [
{
'type': 'field_input',
'name': 'FIELD',
'text': 'default',
},
{
'type': 'input_value',
'name': 'INPUT',
},
],
'output': null,
},
]);
this.workspace = Blockly.inject('blocklyDiv', {});
this.cursor = this.workspace.getCursor();
this.neverValid = () => false;
this.alwaysValid = () => true;
this.isBlock = (node) => {
return node && node instanceof Blockly.BlockSvg;
};
});
teardown(function () {
sharedTestTeardown.call(this);
});
suite('stack', function () {
setup(function () {
const state = {
'blocks': {
'languageVersion': 0,
'blocks': [
{
'type': 'stack_block',
'id': 'A',
'x': 0,
'y': 0,
'next': {
'block': {
'type': 'stack_block',
'id': 'B',
'next': {
'block': {
'type': 'stack_block',
'id': 'C',
},
},
},
},
},
],
},
};
Blockly.serialization.workspaces.load(state, this.workspace);
this.blockA = this.workspace.getBlockById('A');
this.blockB = this.workspace.getBlockById('B');
this.blockC = this.workspace.getBlockById('C');
});
teardown(function () {
this.workspace.clear();
});
test('Never valid - start at top', function () {
const startNode = this.blockA.previousConnection;
const nextNode = this.cursor.getNextNode(
startNode,
this.neverValid,
false,
);
assert.isNull(nextNode);
});
test('Never valid - start in middle', function () {
const startNode = this.blockB;
const nextNode = this.cursor.getNextNode(
startNode,
this.neverValid,
false,
);
assert.isNull(nextNode);
});
test('Never valid - start at end', function () {
const startNode = this.blockC.nextConnection;
const nextNode = this.cursor.getNextNode(
startNode,
this.neverValid,
false,
);
assert.isNull(nextNode);
});
test('Always valid - start at top', function () {
const startNode = this.blockA.previousConnection;
const nextNode = this.cursor.getNextNode(
startNode,
this.alwaysValid,
false,
);
assert.equal(nextNode, this.blockA);
});
test('Always valid - start in middle', function () {
const startNode = this.blockB;
const nextNode = this.cursor.getNextNode(
startNode,
this.alwaysValid,
false,
);
assert.equal(nextNode, this.blockB.getField('FIELD'));
});
test('Always valid - start at end', function () {
const startNode = this.blockC.getField('FIELD');
const nextNode = this.cursor.getNextNode(
startNode,
this.alwaysValid,
false,
);
assert.isNull(nextNode);
});
test('Valid if block - start at top', function () {
const startNode = this.blockA;
const nextNode = this.cursor.getNextNode(
startNode,
this.isBlock,
false,
);
assert.equal(nextNode, this.blockB);
});
test('Valid if block - start in middle', function () {
const startNode = this.blockB;
const nextNode = this.cursor.getNextNode(
startNode,
this.isBlock,
false,
);
assert.equal(nextNode, this.blockC);
});
test('Valid if block - start at end', function () {
const startNode = this.blockC.getField('FIELD');
const nextNode = this.cursor.getNextNode(
startNode,
this.isBlock,
false,
);
assert.isNull(nextNode);
});
test('Never valid - start at end - with loopback', function () {
const startNode = this.blockC.nextConnection;
const nextNode = this.cursor.getNextNode(
startNode,
this.neverValid,
true,
);
assert.isNull(nextNode);
});
test('Always valid - start at end - with loopback', function () {
const startNode = this.blockC.nextConnection;
const nextNode = this.cursor.getNextNode(
startNode,
this.alwaysValid,
true,
);
assert.equal(nextNode, this.blockA.previousConnection);
});
test('Valid if block - start at end - with loopback', function () {
const startNode = this.blockC;
const nextNode = this.cursor.getNextNode(startNode, this.isBlock, true);
assert.equal(nextNode, this.blockA);
});
});
});
suite('Get previous node', function () {
setup(function () {
sharedTestSetup.call(this);
Blockly.defineBlocksWithJsonArray([
{
'type': 'empty_block',
'message0': '',
},
{
'type': 'stack_block',
'message0': '%1',
'args0': [
{
'type': 'field_input',
'name': 'FIELD',
'text': 'default',
},
],
'previousStatement': null,
'nextStatement': null,
},
{
'type': 'row_block',
'message0': '%1 %2',
'args0': [
{
'type': 'field_input',
'name': 'FIELD',
'text': 'default',
},
{
'type': 'input_value',
'name': 'INPUT',
},
],
'output': null,
},
]);
this.workspace = Blockly.inject('blocklyDiv', {});
this.cursor = this.workspace.getCursor();
this.neverValid = () => false;
this.alwaysValid = () => true;
this.isBlock = (node) => {
return node && node instanceof Blockly.BlockSvg;
};
});
teardown(function () {
sharedTestTeardown.call(this);
});
suite('stack', function () {
setup(function () {
const state = {
'blocks': {
'languageVersion': 0,
'blocks': [
{
'type': 'stack_block',
'id': 'A',
'x': 0,
'y': 0,
'next': {
'block': {
'type': 'stack_block',
'id': 'B',
'next': {
'block': {
'type': 'stack_block',
'id': 'C',
},
},
},
},
},
],
},
};
Blockly.serialization.workspaces.load(state, this.workspace);
this.blockA = this.workspace.getBlockById('A');
this.blockB = this.workspace.getBlockById('B');
this.blockC = this.workspace.getBlockById('C');
});
teardown(function () {
this.workspace.clear();
});
test('Never valid - start at top', function () {
const startNode = this.blockA.previousConnection;
const previousNode = this.cursor.getPreviousNode(
startNode,
this.neverValid,
false,
);
assert.isNull(previousNode);
});
test('Never valid - start in middle', function () {
const startNode = this.blockB;
const previousNode = this.cursor.getPreviousNode(
startNode,
this.neverValid,
false,
);
assert.isNull(previousNode);
});
test('Never valid - start at end', function () {
const startNode = this.blockC.nextConnection;
const previousNode = this.cursor.getPreviousNode(
startNode,
this.neverValid,
false,
);
assert.isNull(previousNode);
});
test('Always valid - start at top', function () {
const startNode = this.blockA;
const previousNode = this.cursor.getPreviousNode(
startNode,
this.alwaysValid,
false,
);
assert.isNull(previousNode);
});
test('Always valid - start in middle', function () {
const startNode = this.blockB;
const previousNode = this.cursor.getPreviousNode(
startNode,
this.alwaysValid,
false,
);
assert.equal(previousNode, this.blockA.getField('FIELD'));
});
test('Always valid - start at end', function () {
const startNode = this.blockC.nextConnection;
const previousNode = this.cursor.getPreviousNode(
startNode,
this.alwaysValid,
false,
);
assert.equal(previousNode, this.blockC.getField('FIELD'));
});
test('Valid if block - start at top', function () {
const startNode = this.blockA;
const previousNode = this.cursor.getPreviousNode(
startNode,
this.isBlock,
false,
);
assert.isNull(previousNode);
});
test('Valid if block - start in middle', function () {
const startNode = this.blockB;
const previousNode = this.cursor.getPreviousNode(
startNode,
this.isBlock,
false,
);
assert.equal(previousNode, this.blockA);
});
test('Valid if block - start at end', function () {
const startNode = this.blockC;
const previousNode = this.cursor.getPreviousNode(
startNode,
this.isBlock,
false,
);
assert.equal(previousNode, this.blockB);
});
test('Never valid - start at top - with loopback', function () {
const startNode = this.blockA.previousConnection;
const previousNode = this.cursor.getPreviousNode(
startNode,
this.neverValid,
true,
);
assert.isNull(previousNode);
});
test('Always valid - start at top - with loopback', function () {
const startNode = this.blockA.previousConnection;
const previousNode = this.cursor.getPreviousNode(
startNode,
this.alwaysValid,
true,
);
assert.equal(previousNode, this.blockC.nextConnection);
});
test('Valid if block - start at top - with loopback', function () {
const startNode = this.blockA;
const previousNode = this.cursor.getPreviousNode(
startNode,
this.isBlock,
true,
);
assert.equal(previousNode, this.blockC);
});
});
});
});