braille_input_handler_test.unitjs revision 1320f92c476a1ad9d19dba2a48c72b75566198e9
1// Copyright 2014 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5// Include test fixture.
6GEN_INCLUDE(['../../testing/chromevox_unittest_base.js']);
7
8GEN_INCLUDE(['../../testing/fake_objects.js']);
9
10// Fake out the Chrome API namespace we depend on.
11var chrome = {};
12/** Fake chrome.runtime object. */
13chrome.runtime = {};
14/** Fake chrome.virtualKeyboardPrivate object. */
15chrome.virtualKeyboardPrivate = {};
16
17
18/**
19 * A fake input field that behaves like the Braille IME and also updates
20 * the input manager's knowledge about the display content when text changes
21 * in the edit field.
22 * @param {FakePort} port A fake port.
23 * @param {cvox.BrailleInputHandler} inputHandler to work with.
24 * @constructor
25 */
26function FakeEditor(port, inputHandler) {
27  /** @private {FakePort} */
28  this.port_ = port;
29  /** @private {cvox.BrailleInputHandler} */
30  this.inputHandler_ = inputHandler;
31  /** @private {string} */
32  this.text_ = '';
33  /** @private {number} */
34  this.selectionStart_ = 0;
35  /** @private {number} */
36  this.selectionEnd_ = 0;
37  /** @private {number} */
38  this.contextID_ = 0;
39  /** @private {boolean} */
40  this.allowDeletes_ = false;
41  port.postMessage = goog.bind(this.handleMessage_, this);
42}
43
44
45/**
46 * Sets the content and selection (or cursor) of the edit field.
47 * This fakes what happens when the field is edited by other means than
48 * via the braille keyboard.
49 * @param {string} text Text to replace the current content of the field.
50 * @param {number} selectionStart Start of the selection or cursor position.
51 * @param {number=} opt_selectionEnd End of selection, or ommited if the
52 *     selection is a cursor.
53 */
54FakeEditor.prototype.setContent = function(
55    text, selectionStart, opt_selectionEnd) {
56  this.text_ = text;
57  this.selectionStart_ = selectionStart;
58  this.selectionEnd_ = goog.isDef(opt_selectionEnd) ?
59      opt_selectionEnd : selectionStart;
60  this.callOnDisplayContentChanged_();
61};
62
63
64/**
65 * Sets the selection in the editor.
66 * @param {number} selectionStart Start of the selection or cursor position.
67 * @param {number=} opt_selectionEnd End of selection, or ommited if the
68 *     selection is a cursor.
69 */
70FakeEditor.prototype.select = function(selectionStart, opt_selectionEnd) {
71  this.setContent(this.text_, selectionStart, opt_selectionEnd);
72};
73
74
75/**
76 * Inserts text into the edit field, optionally selecting the inserted
77 * text.
78 * @param {string} newText Text to insert.
79 * @param {boolean=} opt_select If {@code true}, selects the inserted text,
80 *     otherwise leaves the cursor at the end of the new text.
81 */
82FakeEditor.prototype.insert = function(newText, opt_select) {
83  this.text_ =
84      this.text_.substring(0, this.selectionStart_) +
85      newText +
86      this.text_.substring(this.selectionEnd_);
87  if (opt_select) {
88    this.selectionEnd_ = this.selectionStart_ + newText.length;
89  } else {
90    this.selectionStart_ += newText.length;
91    this.selectionEnd_ = this.selectionStart_;
92  }
93  this.callOnDisplayContentChanged_();
94};
95
96
97/**
98 * Sets whether the editor should cause a test failure if the input handler
99 * tries to delete text before the cursor.  By default, thi value is
100 * {@code false}.
101 * @param {boolean} allowDeletes The new value.
102 */
103FakeEditor.prototype.setAllowDeletes = function(allowDeletes) {
104  this.allowDeletes_ = allowDeletes;
105};
106
107
108/**
109 * Signals to the input handler that the Braille IME is active or not active,
110 * depending on the argument.
111 * @param {boolean} value Whether the IME is active or not.
112 */
113FakeEditor.prototype.setActive = function(value) {
114  this.message_({type: 'activeState', active: value});
115};
116
117
118/**
119 * Fails if the current editor content and selection range don't match
120 * the arguments to this function.
121 * @param {string} text Text that should be in the field.
122 * @param {number} selectionStart Start of selection.
123 * @param {number+} opt_selectionEnd End of selection, default to selection
124 *     start to indicate a cursor.
125 */
126FakeEditor.prototype.assertContentIs = function(
127    text, selectionStart, opt_selectionEnd) {
128  var selectionEnd = goog.isDef(opt_selectionEnd) ? opt_selectionEnd :
129      selectionStart;
130  assertEquals(text, this.text_);
131  assertEquals(selectionStart, this.selectionStart_);
132  assertEquals(selectionEnd, this.selectionEnd_);
133};
134
135
136/**
137 * Sends a message from the IME to the input handler.
138 * @param {Object} msg The message to send.
139 * @private
140 */
141FakeEditor.prototype.message_ = function(msg) {
142  var listener = this.port_.onMessage.getListener();
143  assertNotEquals(null, listener);
144  listener(msg);
145};
146
147
148/**
149 * Calls the {@code onDisplayContentChanged} method of the input handler
150 * with the current editor content and selection.
151 * @private
152 */
153FakeEditor.prototype.callOnDisplayContentChanged_ = function() {
154  this.inputHandler_.onDisplayContentChanged(
155      cvox.BrailleUtil.createValue(
156          this.text_, this.selectionStart_, this.selectionEnd_));
157};
158
159
160/**
161 * Informs the input handler that a new text field is focused.  The content
162 * of the field is not cleared and should be updated separately.
163 * @param {string} fieldType The type of the field (see the documentation
164 *     for the {@code chrome.input.ime} API).
165 */
166FakeEditor.prototype.focus = function(fieldType) {
167  this.contextID_++;
168  this.message_({type: 'inputContext',
169                 context: {type: fieldType,
170                           contextID: this.contextID_}});
171};
172
173
174/**
175 * Inform the input handler that focus left the input field.
176 */
177FakeEditor.prototype.blur = function() {
178  this.message_({type: 'inputContext', context: null});
179  this.contextID_ = 0;
180};
181
182
183/**
184 * Handles a message from the input handler to the IME.
185 * @param {Object} msg The message.
186 * @private
187 */
188FakeEditor.prototype.handleMessage_ = function(msg) {
189  assertEquals('replaceText', msg.type);
190  assertEquals(this.contextID_, msg.contextID);
191  var deleteBefore = msg.deleteBefore;
192  var newText = msg.newText;
193  assertTrue(goog.isNumber(deleteBefore));
194  assertTrue(goog.isString(newText));
195  assertTrue(deleteBefore <= this.selectionStart_);
196  if (deleteBefore > 0) {
197    assertTrue(this.allowDeletes_);
198    this.text_ =
199        this.text_.substring(0, this.selectionStart_ - deleteBefore) +
200        this.text_.substring(this.selectionEnd_);
201    this.selectionStart_ -= deleteBefore;
202    this.selectionEnd_ = this.selectionStart_;
203    this.callOnDisplayContentChanged_();
204  }
205  this.insert(newText);
206};
207
208/*
209 * Fakes a {@code Port} used for message passing in the Chrome extension APIs.
210 * @constructor
211 */
212function FakePort() {
213  /** @type {FakeChromeEvent} */
214  this.onDisconnect = new FakeChromeEvent();
215  /** @type {FakeChromeEvent} */
216  this.onMessage = new FakeChromeEvent();
217  /** @type {string} */
218  this.name = cvox.BrailleInputHandler.IME_PORT_NAME_;
219  /** @type {{id: string}} */
220  this.sender = {id: cvox.BrailleInputHandler.IME_EXTENSION_ID_};
221}
222
223/**
224 * Mapping from braille cells to Unicode characters.
225 * @const Array.<Array.<string> >
226 */
227var UNCONTRACTED_TABLE = [
228  ['0', ' '],
229  ['1', 'a'], ['12', 'b'], ['14', 'c'], ['145', 'd'], ['15', 'e'],
230  ['124', 'f'], ['1245', 'g'], ['125', 'h'], ['24', 'i'], ['245', 'j'],
231  ['13', 'k'], ['123', 'l'], ['134', 'm'], ['1345', 'n'], ['135', 'o'],
232  ['1234', 'p'], ['12345', 'q'], ['1235', 'r'], ['234', 's'], ['2345', 't']
233];
234
235
236/**
237 * Mapping of braille cells to the corresponding word in Grade 2 US English
238 * braille.  This table also includes the uncontracted table above.
239 * If a match 'pattern' starts with '^', it must be at the beginning of
240 * the string or be preceded by a blank cell.  Similarly, '$' at the end
241 * of a 'pattern' means that the match must be at the end of the string
242 * or be followed by a blank cell.  Note that order is significant in the
243 * table.  First match wins.
244 * @const
245 */
246var CONTRACTED_TABLE = [
247  ['12 1235 123', 'braille'],
248  ['^12$', 'but'],
249  ['1456', 'this']].concat(UNCONTRACTED_TABLE);
250
251/**
252 * A fake braille translator that can do back translation according
253 * to one of the tables above.
254 * @param {Array.<Array.<number>>} table Backtranslation mapping.
255 * @param {boolean=} opt_capitalize Whether the result should be capitalized.
256 * @constructor
257 */
258function FakeTranslator(table, opt_capitalize) {
259  /** @private */
260  this.table_ = table.map(function(entry) {
261    var cells = entry[0];
262    var result = [];
263    if (cells[0] === '^') {
264      result.start = true;
265      cells = cells.substring(1);
266    }
267    if (cells[cells.length - 1] === '$') {
268      result.end = true;
269      cells = cells.substring(0, cells.length - 1);
270    }
271    result[0] = cellsToArray(cells);
272    result[1] = entry[1];
273    return result;
274  });
275  /** @private {boolean} */
276  this.capitalize_ = opt_capitalize || false;
277}
278
279
280/**
281 * Implements the {@code cvox.LibLouis.BrailleTranslator.backTranslate} method.
282 * @param {!ArrayBuffer} cells Cells to be translated.
283 * @param {function(?string)} callback Callback for result.
284 */
285FakeTranslator.prototype.backTranslate = function(cells, callback) {
286  var cellsArray = new Uint8Array(cells);
287  var result = '';
288  var pos = 0;
289  while (pos < cellsArray.length) {
290    var match = null;
291    outer: for (var i = 0, entry; entry = this.table_[i]; ++i) {
292      if (pos + entry[0].length > cellsArray.length) {
293        continue;
294      }
295      if (entry.start && pos > 0 && cellsArray[pos - 1] !== 0) {
296        continue;
297      }
298      for (var j = 0; j < entry[0].length; ++j) {
299        if (entry[0][j] !== cellsArray[pos + j]) {
300          continue outer;
301        }
302      }
303      if (entry.end && pos + j < cellsArray.length &&
304          cellsArray[pos + j] !== 0) {
305        continue;
306      }
307      match = entry;
308      break;
309    }
310    assertNotEquals(
311        null, match,
312        'Backtranslating ' + cellsArray[pos] + ' at ' + pos);
313    result += match[1];
314    pos += match[0].length;
315  }
316  if (this.capitalize_) {
317    result = result.toUpperCase();
318  }
319  callback(result);
320};
321
322/**
323 * Converts a list of cells, represented as a string, to an array.
324 * @param {string} cells A string with space separated groups of digits.
325 *     Each group corresponds to one braille cell and each digit in a group
326 *     corresponds to a particular dot in the cell (1 to 8).  As a special
327 *     case, the digit 0 by itself represents a blank cell.
328 * @return {Array.<number>} An array with each cell encoded as a bit
329 *     pattern (dot 1 uses bit 0, etc).
330 */
331function cellsToArray(cells) {
332  return cells.split(/\s+/).map(function(cellString) {
333    var cell = 0;
334    assertTrue(cellString.length > 0);
335    if (cellString != '0') {
336      for (var i = 0; i < cellString.length; ++i) {
337        var dot = cellString.charCodeAt(i) - '0'.charCodeAt(0);
338        assertTrue(dot >= 1);
339        assertTrue(dot <= 8);
340        cell |= 1 << (dot - 1);
341      }
342    }
343    return cell;
344  });
345}
346
347/**
348 * Test fixture.
349 * @constructor
350 * @extends {ChromeVoxUnitTestBase}
351 */
352function CvoxBrailleInputHandlerUnitTest() {}
353
354CvoxBrailleInputHandlerUnitTest.prototype = {
355  __proto__: ChromeVoxUnitTestBase.prototype,
356
357  /** @override */
358  closureModuleDeps: [
359    'cvox.BrailleInputHandler',
360    'cvox.BrailleUtil',
361    'cvox.Spannable',
362  ],
363
364  /**
365   * Creates an editor and establishes a connection from the IME.
366   * @return {FakeEditor}
367   */
368  createEditor: function() {
369    chrome.runtime.onConnectExternal.getListener()(this.port);
370    return new FakeEditor(this.port, this.inputHandler);
371  },
372
373  /**
374   * Sends a series of braille cells to the input handler.
375   * @param {string} cells Braille cells, encoded as described in
376   *     {@code cellsToArray}.
377   * @return {boolean} {@code true} iff all cells were sent successfully.
378   */
379  sendCells: function(cells) {
380    return cellsToArray(cells).reduce(function(prevResult, cell) {
381      var event = {command: cvox.BrailleKeyCommand.DOTS, brailleDots: cell};
382      return prevResult && this.inputHandler.onBrailleKeyEvent(event);
383    }.bind(this), true);
384  },
385
386  /**
387   * Sends a standard key event (such as backspace) to the braille input
388   * handler.
389   * @param {string} keyCode The key code name.
390   * @return {boolean} Whether the event was handled.
391   */
392  sendKeyEvent: function(keyCode) {
393    var event = {command: cvox.BrailleKeyCommand.STANDARD_KEY,
394                 standardKeyCode: keyCode};
395    return this.inputHandler.onBrailleKeyEvent(event);
396  },
397
398  /**
399   * Shortcut for asserting that the value expansion mode is {@code NONE}.
400   */
401  assertExpandingNone: function() {
402    assertEquals(cvox.ExpandingBrailleTranslator.ExpansionType.NONE,
403                 this.inputHandler.getExpansionType());
404  },
405
406  /**
407   * Shortcut for asserting that the value expansion mode is {@code SELECTION}.
408   */
409  assertExpandingSelection: function() {
410    assertEquals(cvox.ExpandingBrailleTranslator.ExpansionType.SELECTION,
411                 this.inputHandler.getExpansionType());
412  },
413
414  /**
415   * Shortcut for asserting that the value expansion mode is {@code ALL}.
416   */
417  assertExpandingAll: function() {
418    assertEquals(cvox.ExpandingBrailleTranslator.ExpansionType.ALL,
419                 this.inputHandler.getExpansionType());
420  },
421
422  storeKeyEvent: function(event, opt_callback) {
423    var storedCopy = {keyCode: event.keyCode, keyName: event.keyName,
424                      charValue: event.charValue};
425    if (event.type == 'keydown') {
426      this.keyEvents.push(storedCopy);
427    } else {
428      assertEquals('keyup', event.type);
429      assertTrue(this.keyEvents.length > 0);
430      assertEqualsJSON(storedCopy, this.keyEvents[this.keyEvents.length - 1]);
431    }
432    if (goog.isDef(opt_callback)) {
433      callback();
434    }
435  },
436
437  /** @override */
438  setUp: function() {
439    chrome.runtime.onConnectExternal = new FakeChromeEvent();
440    this.port = new FakePort();
441    this.inputHandler = new cvox.BrailleInputHandler();
442    this.inputHandler.init();
443    this.uncontractedTranslator = new FakeTranslator(UNCONTRACTED_TABLE);
444    this.contractedTranslator = new FakeTranslator(CONTRACTED_TABLE, true);
445    chrome.virtualKeyboardPrivate.sendKeyEvent =
446      this.storeKeyEvent.bind(this);
447    this.keyEvents = [];
448  }
449};
450
451TEST_F('CvoxBrailleInputHandlerUnitTest', 'ConnectFromUnknownExtension',
452  function() {
453  this.port.sender.id = 'your unknown friend';
454  chrome.runtime.onConnectExternal.getListener()(this.port);
455  this.port.onMessage.assertNoListener();
456});
457
458
459TEST_F('CvoxBrailleInputHandlerUnitTest', 'NoTranslator', function() {
460  var editor = this.createEditor();
461  editor.setContent('blah', 0);
462  editor.setActive(true);
463  editor.focus('email');
464  assertFalse(this.sendCells('145 135 125'));
465  editor.setActive(false);
466  editor.blur();
467  editor.assertContentIs('blah', 0);
468});
469
470
471TEST_F('CvoxBrailleInputHandlerUnitTest', 'InputUncontracted', function() {
472  this.inputHandler.setTranslator(this.uncontractedTranslator);
473  var editor = this.createEditor();
474  editor.setActive(true);
475
476  // Focus and type in a text field.
477  editor.focus('text');
478  assertTrue(this.sendCells('125 15 123 123 135'));  // hello
479  editor.assertContentIs('hello', 'hello'.length);
480  this.assertExpandingNone();
481
482  // Move the cursor and type in the middle.
483  editor.select(2);
484  assertTrue(this.sendCells('0 2345 125 15 1235 15 0'));  // ' there '
485  editor.assertContentIs('he there llo', 'he there '.length);
486
487  // Field changes by some other means.
488  editor.insert('you!');
489  // Then type on the braille keyboard again.
490  assertTrue(this.sendCells('0 125 15'));  // ' he'
491  editor.assertContentIs('he there you! hello', 'he there you! he'.length);
492
493  editor.blur();
494  editor.setActive(false);
495});
496
497
498TEST_F('CvoxBrailleInputHandlerUnitTest', 'InputContracted', function() {
499  var editor = this.createEditor();
500  this.inputHandler.setTranslator(this.contractedTranslator,
501                                  this.uncontractedTranslator);
502  editor.setActive(true);
503  editor.focus('text');
504  this.assertExpandingSelection();
505
506  // First, type a 'b'.
507  assertTrue(this.sendCells('12'));
508  // Remember that the contracted translator produces uppercase.
509  editor.assertContentIs('BUT', 'BUT'.length);
510  this.assertExpandingNone();
511
512  // From here on, the input handler needs to replace already entered text.
513  editor.setAllowDeletes(true);
514  // Typing 'rl' changes to a different contraction.
515  assertTrue(this.sendCells('1235 123'));
516  editor.assertContentIs('BRAILLE', 'BRAILLE'.length);
517  // Now, finish the word.
518  assertTrue(this.sendCells('0'));
519  editor.assertContentIs('BRAILLE ', 'BRAILLE '.length);
520  this.assertExpandingNone();
521
522  // Move the cursor to the beginning.
523  editor.select(0);
524  this.assertExpandingSelection();
525
526  // Typing now uses the uncontracted table.
527  assertTrue(this.sendCells('12'));  // 'b'
528  editor.assertContentIs('bBRAILLE ', 1);
529  this.assertExpandingSelection();
530  editor.select('bBRAILLE'.length);
531  this.assertExpandingSelection();
532  assertTrue(this.sendCells('12')); // 'b'
533  editor.assertContentIs('bBRAILLEb ', 'bBRAILLEb'.length);
534  // Move to the end, where contracted typing should work.
535  editor.select('bBRAILLEb '.length);
536  assertTrue(this.sendCells('1456 0'));  // Symbol for 'this', then space.
537  this.assertExpandingNone();
538  editor.assertContentIs('bBRAILLEb THIS ', 'bBRAILLEb this '.length);
539
540  // Move between the two words.
541  editor.select('bBRAILLEb'.length);
542  this.assertExpandingSelection();
543  assertTrue(this.sendCells('0'));
544  assertTrue(this.sendCells('12'));  // 'b' for 'but'
545  editor.assertContentIs('bBRAILLEb BUT THIS ', 'bBRAILLEb BUT'.length);
546  this.assertExpandingNone();
547});
548
549
550TEST_F('CvoxBrailleInputHandlerUnitTest', 'TypingUrlWithContracted',
551       function() {
552  var editor = this.createEditor();
553  this.inputHandler.setTranslator(this.contractedTranslator,
554                                  this.uncontractedTranslator);
555  editor.setActive(true);
556  editor.focus('url');
557  this.assertExpandingAll();
558  assertTrue(this.sendCells('1245'));  // 'g'
559  editor.insert('oogle.com', true /*select*/);
560  editor.assertContentIs('google.com', 1, 'google.com'.length);
561  this.assertExpandingAll();
562  this.sendCells('135');  // 'o'
563  editor.insert('ogle.com', true /*select*/);
564  editor.assertContentIs('google.com', 2, 'google.com'.length);
565  this.assertExpandingAll();
566  this.sendCells('0');
567  editor.assertContentIs('go ', 'go '.length);
568  // In a URL, even when the cursor is in whitespace, all of the value
569  // is expanded to uncontracted braille.
570  this.assertExpandingAll();
571});
572
573
574TEST_F('CvoxBrailleInputHandlerUnitTest', 'Backspace', function() {
575  var editor = this.createEditor();
576  this.inputHandler.setTranslator(this.contractedTranslator,
577                                  this.uncontractedTranslator);
578  editor.setActive(true);
579  editor.focus('text');
580
581  // Add some text that we can delete later.
582  editor.setContent('Text ', 'Text '.length);
583
584  // The IME needs to delete text, even when typing.
585  editor.setAllowDeletes(true);
586  // Type 'brl' to make sure replacement works when deleting text.
587  assertTrue(this.sendCells('12 1235 123'));
588  editor.assertContentIs('Text BRAILLE', 'Text BRAILLE'.length);
589
590  // Delete what we just typed, one cell at a time.
591  this.sendKeyEvent('Backspace');
592  editor.assertContentIs('Text BR', 'Text BR'.length);
593  this.sendKeyEvent('Backspace');
594  editor.assertContentIs('Text BUT', 'Text BUT'.length);
595  this.sendKeyEvent('Backspace');
596  editor.assertContentIs('Text ', 'Text '.length);
597
598  // Now, backspace should be handled as usual, synthetizing key events.
599  assertEquals(0, this.keyEvents.length);
600  this.sendKeyEvent('Backspace');
601  assertEqualsJSON([{keyCode: 8, keyName: 'Backspace', charValue: 8}],
602                   this.keyEvents);
603});
604
605
606TEST_F('CvoxBrailleInputHandlerUnitTest', 'KeysImeNotActive', function() {
607  var editor = this.createEditor();
608  this.sendKeyEvent('Enter');
609  this.sendKeyEvent('ArrowUp');
610  assertEqualsJSON([{keyCode: 13, keyName: 'Enter', charValue: 0x0A},
611                    {keyCode: 38, keyName: 'ArrowUp', charValue: 0x41}],
612                   this.keyEvents);
613});
614