fune/accessible/tests/browser/mac/browser_text_input.js
James Teh a431ef8f27 Bug 1827557 part 1: When constructing a CachedTextMarker from a HyperText, allow an offset equal to the character count. r=morgan
CachedTextMarker uses TextLeafPoint.
Because caret and selection events currently use HyperText offsets, we construct text markers using a HyperTextAccessible.
The constructor detects that it was provided with a HyperText and converts to a TextLeafPoint appropriately.
However, this was previously conditional on the provided offset being less than the character count.
When the caret is at the insertion point at the end of a text box, the caret offset will be the character count itself.
This meant that we didn't convert to TextLeafPoint in this case, resulting in an incorrect text marker.
This was causing VoiceOver to report the first character, word, etc. when cursoring through text boxes instead of the last.

Differential Revision: https://phabricator.services.mozilla.com/D176522
2023-04-27 08:27:24 +00:00

525 lines
14 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
/* import-globals-from ../../mochitest/role.js */
/* import-globals-from ../../mochitest/states.js */
loadScripts(
{ name: "role.js", dir: MOCHITESTS_DIR },
{ name: "states.js", dir: MOCHITESTS_DIR }
);
function testValueChangedEventData(
macIface,
data,
expectedId,
expectedChangeValue,
expectedEditType,
expectedWordAtLeft
) {
is(
data.AXTextChangeElement.getAttributeValue("AXDOMIdentifier"),
expectedId,
"Correct AXTextChangeElement"
);
is(
data.AXTextStateChangeType,
AXTextStateChangeTypeEdit,
"Correct AXTextStateChangeType"
);
let changeValues = data.AXTextChangeValues;
is(changeValues.length, 1, "One element in AXTextChangeValues");
is(
changeValues[0].AXTextChangeValue,
expectedChangeValue,
"Correct AXTextChangeValue"
);
is(
changeValues[0].AXTextEditType,
expectedEditType,
"Correct AXTextEditType"
);
let textMarker = changeValues[0].AXTextChangeValueStartMarker;
ok(textMarker, "There is a AXTextChangeValueStartMarker");
let range = macIface.getParameterizedAttributeValue(
"AXLeftWordTextMarkerRangeForTextMarker",
textMarker
);
let str = macIface.getParameterizedAttributeValue(
"AXStringForTextMarkerRange",
range,
"correct word before caret"
);
is(str, expectedWordAtLeft);
}
// Return true if the first given object a subset of the second
function isSubset(subset, superset) {
if (typeof subset != "object" || typeof superset != "object") {
return superset == subset;
}
for (let [prop, val] of Object.entries(subset)) {
if (!isSubset(val, superset[prop])) {
return false;
}
}
return true;
}
function matchWebArea(expectedId, expectedInfo) {
return (iface, data) => {
if (!data) {
return false;
}
let textChangeElemID = data.AXTextChangeElement.getAttributeValue(
"AXDOMIdentifier"
);
return (
iface.getAttributeValue("AXRole") == "AXWebArea" &&
textChangeElemID == expectedId &&
isSubset(expectedInfo, data)
);
};
}
function matchInput(expectedId, expectedInfo) {
return (iface, data) => {
if (!data) {
return false;
}
return (
iface.getAttributeValue("AXDOMIdentifier") == expectedId &&
isSubset(expectedInfo, data)
);
};
}
async function synthKeyAndTestSelectionChanged(
synthKey,
synthEvent,
expectedId,
expectedSelectionString,
expectedSelectionInfo
) {
let selectionChangedEvents = Promise.all([
waitForMacEventWithInfo(
"AXSelectedTextChanged",
matchWebArea(expectedId, expectedSelectionInfo)
),
waitForMacEventWithInfo(
"AXSelectedTextChanged",
matchInput(expectedId, expectedSelectionInfo)
),
]);
EventUtils.synthesizeKey(synthKey, synthEvent);
let [webareaEvent, inputEvent] = await selectionChangedEvents;
is(
inputEvent.data.AXTextChangeElement.getAttributeValue("AXDOMIdentifier"),
expectedId,
"Correct AXTextChangeElement"
);
let rangeString = inputEvent.macIface.getParameterizedAttributeValue(
"AXStringForTextMarkerRange",
inputEvent.data.AXSelectedTextMarkerRange
);
is(
rangeString,
expectedSelectionString,
`selection has correct value (${expectedSelectionString})`
);
is(
webareaEvent.macIface.getAttributeValue("AXDOMIdentifier"),
"body",
"Input event target is top-level WebArea"
);
rangeString = webareaEvent.macIface.getParameterizedAttributeValue(
"AXStringForTextMarkerRange",
inputEvent.data.AXSelectedTextMarkerRange
);
is(
rangeString,
expectedSelectionString,
`selection has correct value (${expectedSelectionString}) via top document`
);
return inputEvent;
}
function testSelectionEventLeftChar(event, expectedChar) {
const selStart = event.macIface.getParameterizedAttributeValue(
"AXStartTextMarkerForTextMarkerRange",
event.data.AXSelectedTextMarkerRange
);
const selLeft = event.macIface.getParameterizedAttributeValue(
"AXPreviousTextMarkerForTextMarker",
selStart
);
const leftCharRange = event.macIface.getParameterizedAttributeValue(
"AXTextMarkerRangeForUnorderedTextMarkers",
[selLeft, selStart]
);
const leftCharString = event.macIface.getParameterizedAttributeValue(
"AXStringForTextMarkerRange",
leftCharRange
);
is(leftCharString, expectedChar, "Left character is correct");
}
async function synthKeyAndTestValueChanged(
synthKey,
synthEvent,
expectedId,
expectedTextSelectionId,
expectedChangeValue,
expectedEditType,
expectedWordAtLeft
) {
let valueChangedEvents = Promise.all([
waitForMacEvent(
"AXSelectedTextChanged",
matchWebArea(expectedTextSelectionId, {
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
})
),
waitForMacEvent(
"AXSelectedTextChanged",
matchInput(expectedTextSelectionId, {
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
})
),
waitForMacEventWithInfo(
"AXValueChanged",
matchWebArea(expectedId, {
AXTextStateChangeType: AXTextStateChangeTypeEdit,
AXTextChangeValues: [
{
AXTextChangeValue: expectedChangeValue,
AXTextEditType: expectedEditType,
},
],
})
),
waitForMacEventWithInfo(
"AXValueChanged",
matchInput(expectedId, {
AXTextStateChangeType: AXTextStateChangeTypeEdit,
AXTextChangeValues: [
{
AXTextChangeValue: expectedChangeValue,
AXTextEditType: expectedEditType,
},
],
})
),
]);
EventUtils.synthesizeKey(synthKey, synthEvent);
let [, , webareaEvent, inputEvent] = await valueChangedEvents;
testValueChangedEventData(
webareaEvent.macIface,
webareaEvent.data,
expectedId,
expectedChangeValue,
expectedEditType,
expectedWordAtLeft
);
testValueChangedEventData(
inputEvent.macIface,
inputEvent.data,
expectedId,
expectedChangeValue,
expectedEditType,
expectedWordAtLeft
);
}
async function focusIntoInput(accDoc, inputId, innerContainerId) {
let selectionId = innerContainerId ? innerContainerId : inputId;
let input = getNativeInterface(accDoc, inputId);
ok(!input.getAttributeValue("AXFocused"), "input is not focused");
ok(input.isAttributeSettable("AXFocused"), "input is focusable");
let events = Promise.all([
waitForMacEvent(
"AXFocusedUIElementChanged",
iface => iface.getAttributeValue("AXDOMIdentifier") == inputId
),
waitForMacEventWithInfo(
"AXSelectedTextChanged",
matchWebArea(selectionId, {
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
})
),
waitForMacEventWithInfo(
"AXSelectedTextChanged",
matchInput(selectionId, {
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
})
),
]);
input.setAttributeValue("AXFocused", true);
await events;
}
async function focusIntoInputAndType(accDoc, inputId, innerContainerId) {
let selectionId = innerContainerId ? innerContainerId : inputId;
await focusIntoInput(accDoc, inputId, innerContainerId);
async function testTextInput(
synthKey,
expectedChangeValue,
expectedWordAtLeft
) {
await synthKeyAndTestValueChanged(
synthKey,
null,
inputId,
selectionId,
expectedChangeValue,
AXTextEditTypeTyping,
expectedWordAtLeft
);
}
await testTextInput("h", "h", "h");
await testTextInput("e", "e", "he");
await testTextInput("l", "l", "hel");
await testTextInput("l", "l", "hell");
await testTextInput("o", "o", "hello");
await testTextInput(" ", " ", "hello");
// You would expect this to be useless but this is what VO
// consumes. I guess it concats the inserted text data to the
// word to the left of the marker.
await testTextInput("w", "w", " ");
await testTextInput("o", "o", "wo");
await testTextInput("r", "r", "wor");
await testTextInput("l", "l", "worl");
await testTextInput("d", "d", "world");
async function testTextDelete(expectedChangeValue, expectedWordAtLeft) {
await synthKeyAndTestValueChanged(
"KEY_Backspace",
null,
inputId,
selectionId,
expectedChangeValue,
AXTextEditTypeDelete,
expectedWordAtLeft
);
}
await testTextDelete("d", "worl");
await testTextDelete("l", "wor");
await synthKeyAndTestSelectionChanged(
"KEY_ArrowLeft",
null,
selectionId,
"",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
AXTextSelectionDirection: AXTextSelectionDirectionPrevious,
AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
}
);
await synthKeyAndTestSelectionChanged(
"KEY_ArrowLeft",
{ shiftKey: true },
selectionId,
"o",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend,
AXTextSelectionDirection: AXTextSelectionDirectionPrevious,
AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
}
);
await synthKeyAndTestSelectionChanged(
"KEY_ArrowLeft",
{ shiftKey: true },
selectionId,
"wo",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend,
AXTextSelectionDirection: AXTextSelectionDirectionPrevious,
AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
}
);
await synthKeyAndTestSelectionChanged(
"KEY_ArrowLeft",
null,
selectionId,
"",
{ AXTextStateChangeType: AXTextStateChangeTypeSelectionMove }
);
await synthKeyAndTestSelectionChanged(
"KEY_ArrowLeft",
{ shiftKey: true, metaKey: true },
selectionId,
"hello ",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend,
AXTextSelectionDirection: AXTextSelectionDirectionBeginning,
AXTextSelectionGranularity: AXTextSelectionGranularityLine,
}
);
await synthKeyAndTestSelectionChanged(
"KEY_ArrowLeft",
null,
selectionId,
"",
{ AXTextStateChangeType: AXTextStateChangeTypeSelectionMove }
);
await synthKeyAndTestSelectionChanged(
"KEY_ArrowRight",
{ shiftKey: true, altKey: true },
selectionId,
"hello",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend,
AXTextSelectionDirection: AXTextSelectionDirectionNext,
AXTextSelectionGranularity: AXTextSelectionGranularityWord,
}
);
}
// Test text input
addAccessibleTask(
`<a href="#">link</a> <input id="input">`,
async (browser, accDoc) => {
await focusIntoInputAndType(accDoc, "input");
},
{ topLevel: true, iframe: true, remoteIframe: true }
);
// Test content editable
addAccessibleTask(
`<div id="input" contentEditable="true" tabindex="0" role="textbox" aria-multiline="true"><div id="inner"><br /></div></div>`,
async (browser, accDoc) => {
const inner = getNativeInterface(accDoc, "inner");
const editableAncestor = inner.getAttributeValue("AXEditableAncestor");
is(
editableAncestor.getAttributeValue("AXDOMIdentifier"),
"input",
"Editable ancestor is input"
);
await focusIntoInputAndType(accDoc, "input");
}
);
// Test input that gets role::EDITCOMBOBOX
addAccessibleTask(`<input type="text" id="box">`, async (browser, accDoc) => {
const box = getNativeInterface(accDoc, "box");
const editableAncestor = box.getAttributeValue("AXEditableAncestor");
is(
editableAncestor.getAttributeValue("AXDOMIdentifier"),
"box",
"Editable ancestor is box itself"
);
await focusIntoInputAndType(accDoc, "box");
});
// Test multiline caret control in a text area
addAccessibleTask(
`<textarea id="input" cols="15">one two three four five six seven eight</textarea>`,
async (browser, accDoc) => {
await focusIntoInput(accDoc, "input");
await synthKeyAndTestSelectionChanged("KEY_ArrowRight", null, "input", "", {
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
AXTextSelectionDirection: AXTextSelectionDirectionNext,
AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
});
await synthKeyAndTestSelectionChanged("KEY_ArrowDown", null, "input", "", {
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
AXTextSelectionDirection: AXTextSelectionDirectionNext,
AXTextSelectionGranularity: AXTextSelectionGranularityLine,
});
await synthKeyAndTestSelectionChanged(
"KEY_ArrowLeft",
{ metaKey: true },
"input",
"",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
AXTextSelectionDirection: AXTextSelectionDirectionBeginning,
AXTextSelectionGranularity: AXTextSelectionGranularityLine,
}
);
await synthKeyAndTestSelectionChanged(
"KEY_ArrowRight",
{ metaKey: true },
"input",
"",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
AXTextSelectionDirection: AXTextSelectionDirectionEnd,
AXTextSelectionGranularity: AXTextSelectionGranularityLine,
}
);
},
{ topLevel: true, iframe: true, remoteIframe: true }
);
/**
* Test that the caret returns the correct marker when it is positioned after
* the last character (to facilitate appending text).
*/
addAccessibleTask(
`<input id="input" value="abc">`,
async function(browser, docAcc) {
await focusIntoInput(docAcc, "input");
let event = await synthKeyAndTestSelectionChanged(
"KEY_ArrowRight",
null,
"input",
"",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
AXTextSelectionDirection: AXTextSelectionDirectionNext,
AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
}
);
testSelectionEventLeftChar(event, "a");
event = await synthKeyAndTestSelectionChanged(
"KEY_ArrowRight",
null,
"input",
"",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
AXTextSelectionDirection: AXTextSelectionDirectionNext,
AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
}
);
testSelectionEventLeftChar(event, "b");
event = await synthKeyAndTestSelectionChanged(
"KEY_ArrowRight",
null,
"input",
"",
{
AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
AXTextSelectionDirection: AXTextSelectionDirectionNext,
AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
}
);
testSelectionEventLeftChar(event, "c");
},
{ chrome: true, topLevel: true }
);