/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Shared tests for Field and ContentEditableField.
 * Since ContentEditableField switches many of the internal code paths in Field
 * (such as via usesIframe()) it's important to re-run a lot of the same tests.
 */

/** @suppress {extraProvide} */
goog.module('goog.editor.field_test');
goog.setTestOnly();

const BrowserEvent = goog.require('goog.events.BrowserEvent');
const BrowserFeature = goog.require('goog.editor.BrowserFeature');
const EventType = goog.require('goog.events.EventType');
const Field = goog.require('goog.editor.Field');
const GoogTestingEvent = goog.require('goog.testing.events.Event');
const KeyCodes = goog.require('goog.events.KeyCodes');
const LooseMock = goog.require('goog.testing.LooseMock');
const MockClock = goog.require('goog.testing.MockClock');
const Plugin = goog.require('goog.editor.Plugin');
const Range = goog.require('goog.dom.Range');
const SafeHtml = goog.require('goog.html.SafeHtml');
const TagName = goog.require('goog.dom.TagName');
const TestEvent = goog.require('goog.testing.events.Event');
const classlist = goog.require('goog.dom.classlist');
const editorRange = goog.require('goog.editor.range');
const events = goog.require('goog.events');
const functions = goog.require('goog.functions');
const googDom = goog.require('goog.dom');
const platform = goog.require('goog.labs.userAgent.platform');
const recordFunction = goog.require('goog.testing.recordFunction');
const testSuite = goog.require('goog.testing.testSuite');
const testingDom = goog.require('goog.testing.dom');
const testingEvents = goog.require('goog.testing.events');
const userAgent = goog.require('goog.userAgent');

/** Hard-coded HTML for the tests. */
const HTML = '<div id="testField">I am text.</div>';

// Tests for the plugin interface.
/**
 * Dummy plugin for test usage.
 * @final
 */
class TestPlugin extends Plugin {
  constructor() {
    super();

    this.getTrogClassId = () => 'TestPlugin';

    /** @suppress {checkTypes} suppression added to enable type checking */
    this.handleKeyDown = () => {};
    /** @suppress {checkTypes} suppression added to enable type checking */
    this.handleKeyPress = () => {};
    /** @suppress {checkTypes} suppression added to enable type checking */
    this.handleKeyUp = () => {};
    /** @suppress {checkTypes} suppression added to enable type checking */
    this.handleKeyboardShortcut = () => {};
    /** @suppress {checkTypes} suppression added to enable type checking */
    this.isSupportedCommand = () => {};
    this.execCommandInternal = () => {};
    this.queryCommandValue = () => {};
    /** @suppress {checkTypes} suppression added to enable type checking */
    this.activeOnUneditableFields = () => {};
    /** @suppress {checkTypes} suppression added to enable type checking */
    this.handleSelectionChange = () => {};
  }
}

const STRING_KEY = String.fromCharCode(KeyCodes.A).toLowerCase();

/**
 * @return {!events.Event} Returns an event for a keyboard shortcut for the
 *     letter 'a'.
 */
function getBrowserEvent() {
  const e = new BrowserEvent();
  e.ctrlKey = true;
  e.metaKey = true;
  e.charCode = KeyCodes.A;
  return e;
}

/**
 * @param {boolean} followLinkInNewWindow Whether activating a hyperlink in the
 *     editable field will open a new window or not.
 * @return {!Field} Returns an editable field after its load phase.
 */
function createEditableFieldWithListeners(followLinkInNewWindow) {
  const editableField = new FieldConstructor('testField');
  editableField.setFollowLinkInNewWindow(followLinkInNewWindow);

  const originalElement = editableField.getOriginalElement();
  editableField.setupFieldObject(originalElement);
  editableField.handleFieldLoad();

  return editableField;
}

function getListenerTarget(editableField) {
  const elt = editableField.getElement();
  const listenerTarget =
      BrowserFeature.USE_DOCUMENT_FOR_KEY_EVENTS && editableField.usesIframe() ?
      elt.ownerDocument :
      elt;
  return listenerTarget;
}

function assertClickDefaultActionIsCanceled(editableField) {
  /** @suppress {visibility} suppression added to enable type checking */
  const cancelClickDefaultActionListener = events.getListener(
      getListenerTarget(editableField), EventType.CLICK, Field.cancelLinkClick_,
      undefined, editableField);

  assertNotNull(cancelClickDefaultActionListener);
}

function assertClickDefaultActionIsNotCanceled(editableField) {
  /** @suppress {visibility} suppression added to enable type checking */
  const cancelClickDefaultActionListener = events.getListener(
      getListenerTarget(editableField), EventType.CLICK, Field.cancelLinkClick_,
      undefined, editableField);

  assertNull(cancelClickDefaultActionListener);
}

function focusFieldSync(field) {
  field.focus();

  // IE fires focus events async, so create a fake focus event
  // synchronously.
  testingEvents.fireFocusEvent(field.getElement());
}

/**
 * Helper to test that the cursor is placed at the beginning of the editable
 * field's contents.
 * @param {string=} html Html to replace the test file default field contents
 *     with.
 * @param {string=} parentId Id of the parent of the node where the cursor is
 *     expected to be placed. If omitted, will expect cursor to be placed in the
 *     first child of the field element (or, if the field has no content, in the
 *     field element itself).
 */
function doTestPlaceCursorAtStart(html = undefined, parentId = undefined) {
  const editableField = new FieldConstructor('testField', document);
  editableField.makeEditable();
  // Initially place selection not at the start of the editable field.
  let textNode = editableField.getElement().firstChild;
  Range.createFromNodes(textNode, 1, textNode, 2).select();
  if (html != null) {
    editableField.getElement().innerHTML = html;
  }

  editableField.placeCursorAtStart();
  const range = editableField.getRange();
  assertNotNull(
      'Placing the cursor should result in a range object being available',
      range);
  assertTrue('The range should be collapsed', range.isCollapsed());
  textNode = editableField.getElement().firstChild;

  // We check whether getAttribute exist because textNode may be an actual
  // TextNode, which does not have getAttribute.
  const hasBogusNode = textNode &&
      ((textNode.getAttribute &&
        textNode.getAttribute('_moz_editor_bogus_node')) ||
       (userAgent.GECKO && textNode.tagName === TagName.BR &&
        textNode.parentNode.children.length === 1));
  if (hasBogusNode) {
    // At least in FF >= 6, assigning '' to innerHTML of a contentEditable
    // element will results in textNode being modified into:
    // <br _moz_editor_bogus_node="TRUE" _moz_dirty=""> instead of nulling
    // it. So we should null it ourself.
    // This was changed in FF >= 70 to simply be a single <br>.
    textNode = null;
  }

  let startNode = parentId ?
      editableField.getEditableDomHelper().getElement(parentId).firstChild :
      textNode ? textNode :
                 editableField.getElement();
  assertEquals(
      'The range should start at the specified expected node', startNode,
      range.getStartNode());
  assertEquals(
      'The range should start at the beginning of the node', 0,
      range.getStartOffset());
}

/**
 * Helper to test that the cursor is placed at the beginning of the editable
 * field's contents.
 * @param {string=} html Html to replace the test file default field contents
 *     with.
 * @param {string=} parentId Id of the parent of the node where the cursor is
 *     expected to be placed. If omitted, will expect cursor to be placed in the
 *     first child of the field element (or, if the field has no content, in the
 *     field element itself).
 * @param {number=} opt_offset The offset to expect for the end position.
 */
function doTestPlaceCursorAtEnd(
    html = undefined, parentId = undefined, opt_offset) {
  const editableField = new FieldConstructor('testField', document);
  editableField.makeEditable();

  // Initially place selection not at the end of the editable field.
  let textNode = editableField.getElement().firstChild;
  Range.createFromNodes(textNode, 0, textNode, 1).select();
  if (html != null) {
    editableField.getElement().innerHTML = html;
  }

  editableField.placeCursorAtEnd();
  const range = editableField.getRange();
  assertNotNull(
      'Placing the cursor should result in a range object being available',
      range);
  assertTrue('The range should be collapsed', range.isCollapsed());
  textNode = editableField.getElement().firstChild;

  // We check whether getAttribute exist because textNode may be an actual
  // TextNode, which does not have getAttribute.
  const hasBogusNode = textNode &&
      ((textNode.getAttribute &&
        textNode.getAttribute('_moz_editor_bogus_node')) ||
       (userAgent.GECKO && textNode.tagName === TagName.BR &&
        textNode.parentNode.children.length === 1));
  if (hasBogusNode) {
    // At least in FF >= 6, assigning '' to innerHTML of a contentEditable
    // element will results in textNode being modified into:
    // <br _moz_editor_bogus_node="TRUE" _moz_dirty=""> instead of nulling
    // it. So we should null it ourself.
    // This was changed in FF >= 70 to simply be a single <br>.
    textNode = null;
  }

  const endNode = parentId ?
      editableField.getEditableDomHelper().getElement(parentId).lastChild :
      textNode ? textNode :
                 editableField.getElement();
  assertEquals(
      'The range should end at the specified expected node', endNode,
      range.getEndNode());
  const offset = (opt_offset != null) ? opt_offset :
      textNode                        ? endNode.nodeValue.length :
                                        endNode.childNodes.length - 1;
  if (hasBogusNode) {
    assertEquals(
        'The range should end at the ending of the bogus node ' +
            'added by FF',
        offset + 1, range.getEndOffset());
  } else {
    assertEquals(
        'The range should end at the ending of the node', offset,
        range.getEndOffset());
  }
}

testSuite({
  setUp() {
    googDom.getElement('parent').innerHTML = HTML;
    assertTrue(
        'FieldConstructor should be set by the test HTML file',
        typeof FieldConstructor === 'function');
  },

  /** @suppress {uselessCode} suppression added to enable type checking */
  tearDown() {
    // NOTE(nicksantos): I think IE is blowing up on this call because
    // it is lame. It manifests its lameness by throwing an exception.
    // Kudos to XT for helping me to figure this out.
    try {
    } catch (e) {
    }
  },

  /**
   * Tests that calling registerPlugin will add the plugin to the
   * plugin map.
   */
  testRegisterPlugin() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();

    editableField.registerPlugin(plugin);

    assertEquals(
        'Registered plugin must be in protected plugin map.', plugin,
        editableField.plugins_[plugin.getTrogClassId()]);
    assertEquals(
        'Plugin has a keydown handler, should be in keydown map', plugin,
        editableField.indexedPlugins_[Plugin.Op.KEYDOWN][0]);
    assertEquals(
        'Plugin has a keypress handler, should be in keypress map', plugin,
        editableField.indexedPlugins_[Plugin.Op.KEYPRESS][0]);
    assertEquals(
        'Plugin has a keyup handler, should be in keuup map', plugin,
        editableField.indexedPlugins_[Plugin.Op.KEYUP][0]);
    assertEquals(
        'Plugin has a selectionchange handler, should be in selectionchange map',
        plugin, editableField.indexedPlugins_[Plugin.Op.SELECTION][0]);
    assertEquals(
        'Plugin has a shortcut handler, should be in shortcut map', plugin,
        editableField.indexedPlugins_[Plugin.Op.SHORTCUT][0]);
    assertEquals(
        'Plugin has a execCommand, should be in execCommand map', plugin,
        editableField.indexedPlugins_[Plugin.Op.EXEC_COMMAND][0]);
    assertEquals(
        'Plugin has a queryCommand, should be in queryCommand map', plugin,
        editableField.indexedPlugins_[Plugin.Op.QUERY_COMMAND][0]);
    assertEquals(
        'Plugin does not have a prepareContentsHtml,' +
            'should not be in prepareContentsHtml map',
        undefined,
        editableField.indexedPlugins_[Plugin.Op.PREPARE_CONTENTS_HTML][0]);
    assertEquals(
        'Plugin does not have a cleanContentsDom,' +
            'should not be in cleanContentsDom map',
        undefined,
        editableField.indexedPlugins_[Plugin.Op.CLEAN_CONTENTS_DOM][0]);
    assertEquals(
        'Plugin does not have a cleanContentsHtml,' +
            'should not be in cleanContentsHtml map',
        undefined,
        editableField.indexedPlugins_[Plugin.Op.CLEAN_CONTENTS_HTML][0]);

    editableField.dispose();
  },

  /**
   * Tests that calling unregisterPlugin will remove the plugin from
   * the map.
   */
  testUnregisterPlugin() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();

    editableField.registerPlugin(plugin);
    editableField.unregisterPlugin(plugin);

    assertUndefined(
        'Unregistered plugin must not be in protected plugin map.',
        editableField.plugins_[plugin.getTrogClassId()]);

    editableField.dispose();
  },

  /** Tests that registered plugins can be fetched by their id. */
  testGetPluginByClassId() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();

    assertNull(
        'Must not be able to get unregistered plugins by class id.',
        editableField.getPluginByClassId(plugin.getTrogClassId()));

    editableField.registerPlugin(plugin);
    assertEquals(
        'Must be able to get registered plugins by class id.', plugin,
        editableField.getPluginByClassId(plugin.getTrogClassId()));
    editableField.dispose();
  },

  /**
   * Tests that plugins get auto disposed by default when the field is
   * disposed. Tests that plugins with setAutoDispose(false) do not get
   * disposed when the field is disposed.
   */
  testDisposed_PluginAutoDispose() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();

    const noDisposePlugin = new Plugin();
    noDisposePlugin.getTrogClassId = () => 'noDisposeId';
    noDisposePlugin.setAutoDispose(false);

    editableField.registerPlugin(plugin);
    editableField.registerPlugin(noDisposePlugin);
    editableField.dispose();
    assert(editableField.isDisposed());
    assertTrue(plugin.isDisposed());
    assertFalse(noDisposePlugin.isDisposed());
  },

  /** Tests that plugins are disabled when the field is made uneditable. */
  testMakeUneditableDisablesPlugins() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();

    let calls = 0;
    plugin.disable = (field) => {
      assertEquals(editableField, field);
      assertTrue(field.isUneditable());
      calls++;
    };

    editableField.registerPlugin(plugin);
    editableField.makeEditable();

    assertEquals(0, calls);
    editableField.makeUneditable();

    assertEquals(1, calls);

    editableField.dispose();
  },

  /**
   * Test that if a browser open a new page when clicking a link in a
   * content editable element, a click listener is set to cancel this
   * default action.
   */
  testClickDefaultActionIsCanceledWhenBrowserFollowsClick() {
    // Simulate a browser that will open a new page when activating a link
    // in a content editable element.
    const editableField =
        createEditableFieldWithListeners(true /* followLinkInNewWindow */);
    assertClickDefaultActionIsCanceled(editableField);

    editableField.dispose();
  },

  /**
   * Test that if a browser does not open a new page when clicking a link in
   * a content editable element, the click default action is not canceled.
   */
  testClickDefaultActionIsNotCanceledWhenBrowserDontFollowsClick() {
    // Simulate a browser that will NOT open a new page when activating a
    // link in a content editable element.
    const editableField =
        createEditableFieldWithListeners(false /* followLinkInNewWindow */);
    assertClickDefaultActionIsNotCanceled(editableField);

    editableField.dispose();
  },

  /**
     Test that if a plugin registers keyup, it gets called.
     @suppress {missingProperties} suppression added to enable type checking
   */
  testPluginKeyUp() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();
    const e = getBrowserEvent();

    const mockPlugin = new LooseMock(plugin);
    mockPlugin.getTrogClassId().$returns('mockPlugin');
    mockPlugin.registerFieldObject(editableField);
    mockPlugin.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin.handleKeyUp(e);
    mockPlugin.$replay();

    editableField.registerPlugin(mockPlugin);

    editableField.handleKeyUp_(e);

    mockPlugin.$verify();
  },

  /**
     Test that if a plugin registers keydown, it gets called.
     @suppress {missingProperties} suppression added to enable type checking
   */
  testPluginKeyDown() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();
    const e = getBrowserEvent();

    const mockPlugin = new LooseMock(plugin);
    mockPlugin.getTrogClassId().$returns('mockPlugin');
    mockPlugin.registerFieldObject(editableField);
    mockPlugin.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin.handleKeyDown(e).$returns(true);
    mockPlugin.$replay();

    editableField.registerPlugin(mockPlugin);

    editableField.handleKeyDown_(e);

    mockPlugin.$verify();
  },

  /**
     Test that if a plugin registers keypress, it gets called.
     @suppress {missingProperties} suppression added to enable type checking
   */
  testPluginKeyPress() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();
    const e = getBrowserEvent();

    const mockPlugin = new LooseMock(plugin);
    mockPlugin.getTrogClassId().$returns('mockPlugin');
    mockPlugin.registerFieldObject(editableField);
    mockPlugin.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin.handleKeyPress(e).$returns(true);
    mockPlugin.$replay();

    editableField.registerPlugin(mockPlugin);

    editableField.handleKeyPress_(e);

    mockPlugin.$verify();
  },

  /**
   * If one plugin handles a key event, the rest of the plugins do not get
   * their key handlers invoked.
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testHandledKeyEvent() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();
    const e = getBrowserEvent();

    const mockPlugin1 = new LooseMock(plugin);
    mockPlugin1.getTrogClassId().$returns('mockPlugin1');
    mockPlugin1.registerFieldObject(editableField);
    mockPlugin1.isEnabled(editableField).$anyTimes().$returns(true);
    if (BrowserFeature.USES_KEYDOWN) {
      mockPlugin1.handleKeyDown(e).$returns(true);
    } else {
      mockPlugin1.handleKeyPress(e).$returns(true);
    }
    mockPlugin1.handleKeyUp(e).$returns(true);
    mockPlugin1.$replay();

    const mockPlugin2 = new LooseMock(plugin);
    mockPlugin2.getTrogClassId().$returns('mockPlugin2');
    mockPlugin2.registerFieldObject(editableField);
    mockPlugin2.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin2.$replay();

    editableField.registerPlugin(mockPlugin1);
    editableField.registerPlugin(mockPlugin2);

    editableField.handleKeyUp_(e);
    if (BrowserFeature.USES_KEYDOWN) {
      editableField.handleKeyDown_(e);
    } else {
      editableField.handleKeyPress_(e);
    }

    mockPlugin1.$verify();
    mockPlugin2.$verify();
  },

  /**
     Tests to make sure the cut and paste events are not dispatched
     immediately.
   */
  testHandleCutAndPasteEvents() {
    const editableField = new FieldConstructor('testField');
    const clock = new MockClock(true);
    const delayedChanges = recordFunction();
    events.listen(editableField, Field.EventType.DELAYEDCHANGE, delayedChanges);

    editableField.makeEditable();

    testingEvents.fireBrowserEvent(
        new GoogTestingEvent('cut', editableField.getElement()));
    assertEquals(
        'Cut event should be on a timer', 0, delayedChanges.getCallCount());
    clock.tick(1000);
    assertEquals(
        'delayed change event should fire within 1s after cut', 1,
        delayedChanges.getCallCount());

    testingEvents.fireBrowserEvent(
        new GoogTestingEvent('paste', editableField.getElement()));
    assertEquals(
        'Paste event should be on a timer', 1, delayedChanges.getCallCount());
    clock.tick(1000);
    assertEquals(
        'delayed change event should fire within 1s after paste', 2,
        delayedChanges.getCallCount());

    clock.dispose();
    editableField.dispose();
  },

  /**
   * If the first plugin does not handle the key event, the next plugin gets
   * a chance to handle it.
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testNotHandledKeyEvent() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();
    const e = getBrowserEvent();

    const mockPlugin1 = new LooseMock(plugin);
    mockPlugin1.getTrogClassId().$returns('mockPlugin1');
    mockPlugin1.registerFieldObject(editableField);
    mockPlugin1.isEnabled(editableField).$anyTimes().$returns(true);
    if (BrowserFeature.USES_KEYDOWN) {
      mockPlugin1.handleKeyDown(e).$returns(false);
    } else {
      mockPlugin1.handleKeyPress(e).$returns(false);
    }
    mockPlugin1.handleKeyUp(e).$returns(false);
    mockPlugin1.$replay();

    const mockPlugin2 = new LooseMock(plugin);
    mockPlugin2.getTrogClassId().$returns('mockPlugin2');
    mockPlugin2.registerFieldObject(editableField);
    mockPlugin2.isEnabled(editableField).$anyTimes().$returns(true);
    if (BrowserFeature.USES_KEYDOWN) {
      mockPlugin2.handleKeyDown(e).$returns(true);
    } else {
      mockPlugin2.handleKeyPress(e).$returns(true);
    }
    mockPlugin2.handleKeyUp(e).$returns(true);
    mockPlugin2.$replay();

    editableField.registerPlugin(mockPlugin1);
    editableField.registerPlugin(mockPlugin2);

    editableField.handleKeyUp_(e);
    if (BrowserFeature.USES_KEYDOWN) {
      editableField.handleKeyDown_(e);
    } else {
      editableField.handleKeyPress_(e);
    }

    mockPlugin1.$verify();
    mockPlugin2.$verify();
  },

  /**
   * Make sure that handleKeyboardShortcut is called if other key handlers
   * return false.
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testKeyboardShortcutCalled() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();
    const e = getBrowserEvent();

    const mockPlugin = new LooseMock(plugin);
    mockPlugin.getTrogClassId().$returns('mockPlugin');
    mockPlugin.registerFieldObject(editableField);
    mockPlugin.isEnabled(editableField).$anyTimes().$returns(true);
    if (BrowserFeature.USES_KEYDOWN) {
      mockPlugin.handleKeyDown(e).$returns(false);
    } else {
      mockPlugin.handleKeyPress(e).$returns(false);
    }
    mockPlugin.handleKeyboardShortcut(e, STRING_KEY, true).$returns(false);
    mockPlugin.$replay();

    editableField.registerPlugin(mockPlugin);

    if (BrowserFeature.USES_KEYDOWN) {
      editableField.handleKeyDown_(e);
    } else {
      editableField.handleKeyPress_(e);
    }

    mockPlugin.$verify();
  },

  /**
   * Make sure that handleKeyboardShortcut is not called if other key
   * handlers return true.
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testKeyboardShortcutNotCalled() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();
    const e = getBrowserEvent();

    const mockPlugin = new LooseMock(plugin);
    mockPlugin.getTrogClassId().$returns('mockPlugin');
    mockPlugin.registerFieldObject(editableField);
    mockPlugin.isEnabled(editableField).$anyTimes().$returns(true);
    if (BrowserFeature.USES_KEYDOWN) {
      mockPlugin.handleKeyDown(e).$returns(true);
    } else {
      mockPlugin.handleKeyPress(e).$returns(true);
    }
    mockPlugin.$replay();

    editableField.registerPlugin(mockPlugin);

    if (BrowserFeature.USES_KEYDOWN) {
      editableField.handleKeyDown_(e);
    } else {
      editableField.handleKeyPress_(e);
    }

    mockPlugin.$verify();
  },

  /**
   * Make sure that handleKeyboardShortcut is not called if alt is pressed.
   * @bug 1363959
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testKeyHandlingAlt() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();
    const e = getBrowserEvent();
    /**
     * @suppress {strictMissingProperties} suppression added to enable type
     * checking
     */
    e.altKey = true;

    const mockPlugin = new LooseMock(plugin);
    mockPlugin.getTrogClassId().$returns('mockPlugin');
    mockPlugin.registerFieldObject(editableField);
    mockPlugin.isEnabled(editableField).$anyTimes().$returns(true);
    if (BrowserFeature.USES_KEYDOWN) {
      mockPlugin.handleKeyDown(e).$returns(false);
    } else {
      mockPlugin.handleKeyPress(e).$returns(false);
    }
    mockPlugin.$replay();

    editableField.registerPlugin(mockPlugin);

    if (BrowserFeature.USES_KEYDOWN) {
      editableField.handleKeyDown_(e);
    } else {
      editableField.handleKeyPress_(e);
    }

    mockPlugin.$verify();
  },

  /**
   * Make sure that handleKeyboardShortcut is called if alt+shift is
   * pressed.
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testKeyHandlingAltShift() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();
    const e = getBrowserEvent();
    /**
     * @suppress {strictMissingProperties} suppression added to enable type
     * checking
     */
    e.altKey = true;
    /**
     * @suppress {strictMissingProperties} suppression added to enable type
     * checking
     */
    e.shiftKey = true;

    const mockPlugin = new LooseMock(plugin);
    mockPlugin.getTrogClassId().$returns('mockPlugin');
    mockPlugin.registerFieldObject(editableField);
    mockPlugin.isEnabled(editableField).$anyTimes().$returns(true);
    if (BrowserFeature.USES_KEYDOWN) {
      mockPlugin.handleKeyDown(e).$returns(false);
    } else {
      mockPlugin.handleKeyPress(e).$returns(false);
    }
    mockPlugin.handleKeyboardShortcut(e, STRING_KEY, true).$returns(false);
    mockPlugin.$replay();

    editableField.registerPlugin(mockPlugin);

    if (BrowserFeature.USES_KEYDOWN) {
      editableField.handleKeyDown_(e);
    } else {
      editableField.handleKeyPress_(e);
    }

    mockPlugin.$verify();
  },

  /**
   * Test that if a plugin has an execCommand function, it gets called
   * but only for supported commands.
   */
  testPluginExecCommand() {
    const plugin = new TestPlugin();
    let passedCommand;
    let passedArg;
    plugin.execCommand = (command, arg) => {
      passedCommand = command;
      passedArg = arg;
    };

    const editableField = new FieldConstructor('testField');
    editableField.registerPlugin(plugin);
    plugin.enable(editableField);
    plugin.isSupportedCommand = functions.constant(true);

    editableField.execCommand('+indent', true);
    // Verify that the plugin's execCommand was called with the correct
    // args.
    assertEquals('+indent', passedCommand);
    assertTrue(passedArg);

    passedCommand = null;
    passedArg = null;
    plugin.isSupportedCommand = functions.constant(false);

    editableField.execCommand('+outdent', false);
    // Verify that a plugin's execCommand is not called if it isn't a
    // supported command.
    assertNull(passedCommand);
    assertNull(passedArg);

    editableField.dispose();
    plugin.dispose();
  },

  /**
   * Test that if one plugin supports execCommand, no other plugins
   * get a chance to handle the execComand.
   * @suppress {missingProperties,strictMissingProperties} suppression added
   * to enable type checking
   */
  testSupportedExecCommand() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();

    const mockPlugin1 = new LooseMock(plugin);
    mockPlugin1.getTrogClassId().$returns('mockPlugin1');
    mockPlugin1.registerFieldObject(editableField);
    mockPlugin1.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin1.isSupportedCommand('+indent').$returns(true);
    mockPlugin1.execCommandInternal('+indent').$returns(true);
    mockPlugin1.execCommand('+indent')
        .$does(/**
                  @suppress {strictMissingProperties} suppression added to
                  enable type checking
                */
               () => {
                 mockPlugin1.execCommandInternal('+indent');
               });
    mockPlugin1.$replay();

    const mockPlugin2 = new LooseMock(plugin);
    mockPlugin2.getTrogClassId().$returns('mockPlugin2');
    mockPlugin2.registerFieldObject(editableField);
    mockPlugin2.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin2.$replay();

    editableField.registerPlugin(mockPlugin1);
    editableField.registerPlugin(mockPlugin2);

    editableField.execCommand('+indent');

    mockPlugin1.$verify();
    mockPlugin2.$verify();
  },

  /**
   * Test that if the first plugin does not support execCommand, the other
   * plugins get a chance to handle the execCommand.
   * @suppress {missingProperties,strictMissingProperties} suppression added
   * to enable type checking
   */
  testNotSupportedExecCommand() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();

    const mockPlugin1 = new LooseMock(plugin);
    mockPlugin1.getTrogClassId().$returns('mockPlugin1');
    mockPlugin1.registerFieldObject(editableField);
    mockPlugin1.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin1.isSupportedCommand('+indent').$returns(false);
    mockPlugin1.$replay();

    const mockPlugin2 = new LooseMock(plugin);
    mockPlugin2.getTrogClassId().$returns('mockPlugin2');
    mockPlugin2.registerFieldObject(editableField);
    mockPlugin2.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin2.isSupportedCommand('+indent').$returns(true);
    mockPlugin2.execCommandInternal('+indent').$returns(true);
    mockPlugin2.execCommand('+indent')
        .$does(/**
                  @suppress {strictMissingProperties} suppression added to
                  enable type checking
                */
               () => {
                 mockPlugin2.execCommandInternal('+indent');
               });
    mockPlugin2.$replay();

    editableField.registerPlugin(mockPlugin1);
    editableField.registerPlugin(mockPlugin2);

    editableField.execCommand('+indent');

    mockPlugin1.$verify();
    mockPlugin2.$verify();
  },

  /**
   * Tests that if a plugin supports a command that its queryCommandValue
   * gets called and no further plugins can handle the queryCommandValue.
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testSupportedQueryCommand() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();

    const mockPlugin1 = new LooseMock(plugin);
    mockPlugin1.getTrogClassId().$returns('mockPlugin1');
    mockPlugin1.registerFieldObject(editableField);
    mockPlugin1.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin1.isSupportedCommand('+indent').$returns(true);
    mockPlugin1.queryCommandValue('+indent').$returns(true);
    mockPlugin1.activeOnUneditableFields().$returns(true);
    mockPlugin1.$replay();

    const mockPlugin2 = new LooseMock(plugin);
    mockPlugin2.getTrogClassId().$returns('mockPlugin2');
    mockPlugin2.registerFieldObject(editableField);
    mockPlugin2.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin2.$replay();

    editableField.registerPlugin(mockPlugin1);
    editableField.registerPlugin(mockPlugin2);

    editableField.queryCommandValue('+indent');

    mockPlugin1.$verify();
    mockPlugin2.$verify();
  },

  /**
   * Tests that if the first plugin does not support a command that its
   * queryCommandValue do not get called and the next plugin can handle the
   * queryCommandValue.
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testNotSupportedQueryCommand() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();

    const mockPlugin1 = new LooseMock(plugin);
    mockPlugin1.getTrogClassId().$returns('mockPlugin1');
    mockPlugin1.registerFieldObject(editableField);
    mockPlugin1.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin1.isSupportedCommand('+indent').$returns(false);
    mockPlugin1.$replay();

    const mockPlugin2 = new LooseMock(plugin);
    mockPlugin2.getTrogClassId().$returns('mockPlugin2');
    mockPlugin2.registerFieldObject(editableField);
    mockPlugin2.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin2.isSupportedCommand('+indent').$returns(true);
    mockPlugin2.queryCommandValue('+indent').$returns(true);
    mockPlugin2.activeOnUneditableFields().$returns(true);
    mockPlugin2.$replay();

    editableField.registerPlugin(mockPlugin1);
    editableField.registerPlugin(mockPlugin2);

    editableField.queryCommandValue('+indent');

    mockPlugin1.$verify();
    mockPlugin2.$verify();
  },

  /**
   * Tests that if a plugin handles selectionChange that it gets called and
   * no further plugins can handle the selectionChange.
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testHandledSelectionChange() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();
    const e = getBrowserEvent();

    const mockPlugin1 = new LooseMock(plugin);
    mockPlugin1.getTrogClassId().$returns('mockPlugin1');
    mockPlugin1.registerFieldObject(editableField);
    mockPlugin1.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin1.handleSelectionChange(e, undefined).$returns(true);
    mockPlugin1.$replay();

    const mockPlugin2 = new LooseMock(plugin);
    mockPlugin2.getTrogClassId().$returns('mockPlugin2');
    mockPlugin2.registerFieldObject(editableField);
    mockPlugin2.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin2.$replay();

    editableField.registerPlugin(mockPlugin1);
    editableField.registerPlugin(mockPlugin2);

    editableField.dispatchSelectionChangeEvent(e);

    mockPlugin1.$verify();
    mockPlugin2.$verify();
  },

  /**
   * Tests that if the first plugin does not handle selectionChange that
   * the next plugin gets a chance to handle it.
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testNotHandledSelectionChange() {
    const editableField = new FieldConstructor('testField');
    const plugin = new TestPlugin();
    const e = getBrowserEvent();

    const mockPlugin1 = new LooseMock(plugin);
    mockPlugin1.getTrogClassId().$returns('mockPlugin1');
    mockPlugin1.registerFieldObject(editableField);
    mockPlugin1.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin1.handleSelectionChange(e, undefined).$returns(false);
    mockPlugin1.$replay();

    const mockPlugin2 = new LooseMock(plugin);
    mockPlugin2.getTrogClassId().$returns('mockPlugin2');
    mockPlugin2.registerFieldObject(editableField);
    mockPlugin2.isEnabled(editableField).$anyTimes().$returns(true);
    mockPlugin2.handleSelectionChange(e, undefined).$returns(true);
    mockPlugin2.$replay();

    editableField.registerPlugin(mockPlugin1);
    editableField.registerPlugin(mockPlugin2);

    editableField.dispatchSelectionChangeEvent(e);

    mockPlugin1.$verify();
    mockPlugin2.$verify();
  },

  // Tests for goog.editor.Field internals.
  testSelectionChange() {
    const editableField = new FieldConstructor('testField', document);
    const clock = new MockClock(true);
    const beforeSelectionChanges = recordFunction();
    events.listen(
        editableField, Field.EventType.BEFORESELECTIONCHANGE,
        beforeSelectionChanges);
    const selectionChanges = recordFunction();
    events.listen(
        editableField, Field.EventType.SELECTIONCHANGE, selectionChanges);

    editableField.makeEditable();

    // Emulate pressing left arrow key, this should result in a
    // BEFORESELECTIONCHANGE event immediately, and a SELECTIONCHANGE event
    // after a short timeout.
    editableField.handleKeyUp_({keyCode: KeyCodes.LEFT});
    assertEquals(
        'Before selection change should fire immediately', 1,
        beforeSelectionChanges.getCallCount());
    assertEquals(
        'Selection change should be on a timer', 0,
        selectionChanges.getCallCount());
    clock.tick(1000);
    assertEquals(
        'Selection change should fire within 1s', 1,
        selectionChanges.getCallCount());

    // Programically place cursor at start. SELECTIONCHANGE event should be
    // fired.
    editableField.placeCursorAtStart();
    assertEquals(
        'Selection change should fire', 2, selectionChanges.getCallCount());

    clock.dispose();
    editableField.dispose();
  },

  /**
     @suppress {missingProperties} suppression added to enable type
     checking
   */
  testSelectionChangeOnMouseUp() {
    /** @suppress {checkTypes} suppression added to enable type checking */
    const fakeEvent = new BrowserEvent({type: 'mouseup', target: 'fakeTarget'});
    const editableField = new FieldConstructor('testField', document);
    const clock = new MockClock(true);
    const beforeSelectionChanges = recordFunction();
    events.listen(
        editableField, Field.EventType.BEFORESELECTIONCHANGE,
        beforeSelectionChanges);
    const selectionChanges = recordFunction();
    events.listen(
        editableField, Field.EventType.SELECTIONCHANGE, selectionChanges);

    const plugin = new TestPlugin();
    plugin.handleSelectionChange = recordFunction();
    editableField.registerPlugin(plugin);

    editableField.makeEditable();

    // Emulate a mouseup event, this should result in immediate
    // BEFORESELECTIONCHANGE and SELECTIONCHANGE, plus a second
    // SELECTIONCHANGE in IE after a short timeout.
    editableField.handleMouseUp_(fakeEvent);
    assertEquals(
        'Before selection change should fire immediately', 1,
        beforeSelectionChanges.getCallCount());
    assertEquals(
        'Selection change should fire immediately', 1,
        selectionChanges.getCallCount());
    assertEquals(
        'Plugin should have handled selection change immediately', 1,
        plugin.handleSelectionChange.getCallCount());
    assertEquals(
        'Plugin should have received original browser event to handle',
        fakeEvent,
        plugin.handleSelectionChange.getLastCall().getArguments()[0]);

    // Pretend another plugin fired a SELECTIONCHANGE in the meantime.
    editableField.dispatchSelectionChangeEvent();
    assertEquals(
        'Second selection change should fire immediately', 2,
        selectionChanges.getCallCount());
    assertEquals(
        'Plugin should have handled second selection change immediately', 2,
        plugin.handleSelectionChange.getCallCount());
    /**
     * @suppress {missingProperties} suppression added to enable type
     * checking
     */
    const args = plugin.handleSelectionChange.getLastCall().getArguments();
    assertTrue(
        'Plugin should not have received data from extra firing',
        args.length == 0 || !args[0] && (args.length == 1 || !args[1]));

    // Now check for the extra call in IE.
    clock.tick(1000);
    if (userAgent.IE) {
      assertEquals(
          'Additional selection change should fire within 1s', 3,
          selectionChanges.getCallCount());
      assertEquals(
          'Plugin should have handled selection change within 1s', 3,
          plugin.handleSelectionChange.getCallCount());
      assertEquals(
          'Plugin should have received target of original browser event',
          fakeEvent.target,
          plugin.handleSelectionChange.getLastCall().getArguments().pop());
    } else {
      assertEquals(
          'No additional selection change should fire', 2,
          selectionChanges.getCallCount());
      assertEquals(
          'Plugin should not have handled selection change again', 2,
          plugin.handleSelectionChange.getCallCount());
    }

    clock.dispose();
    editableField.dispose();
  },

  testSelectionChangeBeforeUneditable() {
    const editableField = new FieldConstructor('testField', document);
    const clock = new MockClock(true);
    const selectionChanges = recordFunction();
    events.listen(
        editableField, Field.EventType.SELECTIONCHANGE, selectionChanges);

    editableField.makeEditable();
    editableField.handleKeyUp_({keyCode: KeyCodes.LEFT});
    assertEquals(
        'Selection change should be on a timer', 0,
        selectionChanges.getCallCount());
    editableField.makeUneditable();
    assertEquals(
        'Selection change should fire during make uneditable', 1,
        selectionChanges.getCallCount());
    clock.tick(1000);
    assertEquals(
        'No additional selection change should fire', 1,
        selectionChanges.getCallCount());

    clock.dispose();
    editableField.dispose();
  },

  testGetEditableDomHelper() {
    const editableField = new FieldConstructor('testField', document);
    assertNull(
        'Before being made editable, we do not know the dom helper',
        editableField.getEditableDomHelper());
    editableField.makeEditable();
    assertNotNull(
        'After being made editable, we know the dom helper',
        editableField.getEditableDomHelper());
    assertEquals(
        'Document from domHelper should be the editable elements doc',
        googDom.getOwnerDocument(editableField.getElement()),
        editableField.getEditableDomHelper().getDocument());
    editableField.dispose();
  },

  testQueryCommandValue() {
    const editableField = new FieldConstructor('testField', document);
    assertFalse(editableField.queryCommandValue('boo'));
    assertObjectEquals(
        {'boo': false, 'aieee': false},
        editableField.queryCommandValue(['boo', 'aieee']));

    editableField.makeEditable();
    assertFalse(editableField.queryCommandValue('boo'));

    focusFieldSync(editableField);
    assertNull(editableField.queryCommandValue('boo'));
    assertObjectEquals(
        {'boo': null, 'aieee': null},
        editableField.queryCommandValue(['boo', 'aieee']));
    editableField.dispose();
  },

  testSetSafeHtml() {
    const editableField = new FieldConstructor('testField', document);
    const clock = new MockClock(true);

    try {
      let delayedChangeCalled = false;
      events.listen(editableField, Field.EventType.DELAYEDCHANGE, () => {
        delayedChangeCalled = true;
      });

      editableField.makeEditable();
      clock.tick(1000);
      assertFalse(
          'Make editable must not fire delayed change.', delayedChangeCalled);

      editableField.setSafeHtml(
          false, SafeHtml.htmlEscape('bar'),
          true /* Don't fire delayed change */);
      testingDom.assertHtmlContentsMatch('bar', editableField.getElement());
      clock.tick(1000);
      assertFalse(
          'setSafeHtml must not fire delayed change if so configured.',
          delayedChangeCalled);

      editableField.setSafeHtml(
          false, SafeHtml.htmlEscape('foo'), false /* Fire delayed change */);
      testingDom.assertHtmlContentsMatch('foo', editableField.getElement());
      clock.tick(1000);
      assertTrue(
          'setSafeHtml must fire delayed change by default',
          delayedChangeCalled);
    } finally {
      clock.dispose();
      editableField.dispose();
    }
  },

  testSetSafeHtml_withPendingDelayedChangeEvent() {
    const editableField = new FieldConstructor('testField', document);
    const clock = new MockClock(true);

    try {
      let delayedChangeCalled = false;
      events.listen(editableField, Field.EventType.DELAYEDCHANGE, () => {
        delayedChangeCalled = true;
      });

      editableField.makeEditable();
      clock.tick(1000);
      assertFalse(
          'Make editable must not fire delayed change.', delayedChangeCalled);

      let shouldWrapInParagraphTag = false;
      editableField.setSafeHtml(
          shouldWrapInParagraphTag, SafeHtml.htmlEscape('foo'),
          false /* Fire delayed change */);
      testingDom.assertHtmlContentsMatch('foo', editableField.getElement());
      clock.tick(100);
      assertFalse(
          'delayedChange should not fire after only 100ms.',
          delayedChangeCalled);

      editableField.setSafeHtml(
          shouldWrapInParagraphTag, SafeHtml.htmlEscape('bar'),
          true /* Don't fire delayed change */);
      testingDom.assertHtmlContentsMatch('bar', editableField.getElement());
      clock.tick(1000);
      assertFalse(
          'setSafeHtml must not fire pending delayed change if so configured.',
          delayedChangeCalled);
    } finally {
      clock.dispose();
      editableField.dispose();
    }
  },

  /**
     Verify that restoreSavedRange() restores the range and sets the focus.
   */
  testRestoreSavedRange() {
    const editableField = new FieldConstructor('testField', document);
    editableField.makeEditable();

    // Create another node to take the focus later.
    const doc = googDom.getOwnerDocument(editableField.getElement());
    const dom = googDom.getDomHelper(editableField.getElement());
    const otherElem = dom.createElement(TagName.DIV);
    otherElem.tabIndex = 1;  // Make it focusable.
    editableField.getElement().parentNode.appendChild(otherElem);

    // Initially place selection not at the start of the editable field.
    const textNode = editableField.getElement().firstChild;
    const range = Range.createFromNodes(textNode, 1, textNode, 2);
    range.select();
    const savedRange = editorRange.saveUsingNormalizedCarets(range);

    // Change range to be a simple cursor at start, and move focus away.
    editableField.placeCursorAtStart();
    otherElem.focus();

    editableField.restoreSavedRange(savedRange);

    // Verify that we have focus and the range is restored.
    assertEquals(
        'Field should be focused', editableField.getElement(),
        googDom.getActiveElement(doc));
    const newRange = editableField.getRange();
    assertEquals('Range startNode', textNode, newRange.getStartNode());
    assertEquals('Range startOffset', 1, newRange.getStartOffset());
    assertEquals('Range endNode', textNode, newRange.getEndNode());
    assertEquals('Range endOffset', 2, newRange.getEndOffset());
  },

  testPlaceCursorAtStart() {
    doTestPlaceCursorAtStart();
  },

  testPlaceCursorAtStartEmptyField() {
    doTestPlaceCursorAtStart('');
  },

  testPlaceCursorAtStartNonImportantTextNode() {
    doTestPlaceCursorAtStart(
        '\n<span id="important">important text</span>', 'important');
  },

  testPlaceCursorAtEnd() {
    doTestPlaceCursorAtEnd();
  },

  /** @suppress {checkTypes} suppression added to enable type checking */
  testPlaceCursorAtEndEmptyField() {
    doTestPlaceCursorAtEnd('', null, 0);
  },

  testPlaceCursorAtEndNonImportantTextNode() {
    doTestPlaceCursorAtStart(
        '\n<span id="important">important text</span>', 'important');
  },

  // Tests related to change/delayed change events.
  testClearDelayedChange() {
    const clock = new MockClock(true);
    const editableField = new FieldConstructor('testField', document);
    editableField.makeEditable();

    let delayedChangeCalled = false;
    events.listen(editableField, Field.EventType.DELAYEDCHANGE, () => {
      delayedChangeCalled = true;
    });

    // Clears delayed change timer.
    editableField.delayedChangeTimer_.start();
    editableField.clearDelayedChange();
    assertTrue(delayedChangeCalled);
    assertFalse(editableField.delayedChangeTimer_.isActive());

    clock.dispose();
  },

  testDispatchDelayedChange() {
    const editableField = new FieldConstructor('testField', document);
    editableField.makeEditable();

    editableField.delayedChangeTimer_.start();
    editableField.dispatchDelayedChange_();
    assertFalse(editableField.delayedChangeTimer_.isActive());
  },

  testHandleWindowLevelMouseUp() {
    const editableField = new FieldConstructor('testField', document);
    if (editableField.usesIframe()) {
      // Only run this test if the editor does not use an iframe.
      return;
    }
    editableField.setUseWindowMouseUp(true);
    editableField.makeEditable();
    let selectionHasFired = false;
    events.listenOnce(editableField, Field.EventType.SELECTIONCHANGE, (e) => {
      selectionHasFired = true;
    });
    const editableElement = editableField.getElement();
    const otherElement = googDom.createDom(TagName.DIV);
    googDom.insertSiblingAfter(otherElement, document.body.lastChild);

    testingEvents.fireMouseDownEvent(editableElement);
    assertFalse(selectionHasFired);
    testingEvents.fireMouseUpEvent(otherElement);
    assertTrue(selectionHasFired);
  },

  testNoHandleWindowLevelMouseUp() {
    const editableField = new FieldConstructor('testField', document);
    editableField.setUseWindowMouseUp(false);
    editableField.makeEditable();
    let selectionHasFired = false;
    events.listenOnce(editableField, Field.EventType.SELECTIONCHANGE, (e) => {
      selectionHasFired = true;
    });
    const editableElement = editableField.getElement();
    const otherElement = googDom.createDom(TagName.DIV);
    googDom.insertSiblingAfter(otherElement, document.body.lastChild);

    testingEvents.fireMouseDownEvent(editableElement);
    assertFalse(selectionHasFired);
    testingEvents.fireMouseUpEvent(otherElement);
    assertFalse(selectionHasFired);
  },

  /** @suppress {visibility} suppression added to enable type checking */
  testIsGeneratingKey() {
    const regularKeyEvent = new BrowserEvent();
    regularKeyEvent.charCode = KeyCodes.A;

    const ctrlKeyEvent = new BrowserEvent();
    ctrlKeyEvent.ctrlKey = true;
    ctrlKeyEvent.metaKey = true;
    ctrlKeyEvent.charCode = KeyCodes.A;

    const imeKeyEvent = new BrowserEvent();
    imeKeyEvent.keyCode =
        229;  // indicates from an IME - see KEYS_CAUSING_CHANGES

    assertTrue(Field.isGeneratingKey_(regularKeyEvent, true));
    assertFalse(Field.isGeneratingKey_(ctrlKeyEvent, true));
    if ((userAgent.WINDOWS || platform.isAndroid()) && !userAgent.GECKO) {
      assertTrue(Field.isGeneratingKey_(imeKeyEvent, false));
    } else {
      assertFalse(Field.isGeneratingKey_(imeKeyEvent, false));
    }
  },

  testRegularKeyDispatchesDelayedChange() {
    if (userAgent.GECKO) {
      // Gecko based browsers handle changes via mutation events
      return;
    }
    if (userAgent.WINDOWS || platform.isAndroid()) {
      // Windows and Android platforms do not emit events with 'regular' key
      // codes.
      return;
    }
    const editableField = new FieldConstructor('testField');
    const clock = new MockClock(true);
    const delayedChanges = recordFunction();

    editableField.makeEditable();
    events.listen(editableField, Field.EventType.DELAYEDCHANGE, delayedChanges);

    testingEvents.fireKeySequence(editableField.getElement(), KeyCodes.A);
    clock.tick(1000);

    if (!(userAgent.WINDOWS || platform.isAndroid()) || userAgent.GECKO) {
      assertEquals(
          'Delayed change event should\'ve been dispatched', 1,
          delayedChanges.getCallCount());
    }

    clock.dispose();
    editableField.dispose();
  },

  testImeKeyDispatchesDelayedChange() {
    if (!(userAgent.WINDOWS || platform.isAndroid())) {
      // Only Windows and Android platforms emit these IME-specific events.
      return;
    }
    const editableField = new FieldConstructor('testField');
    const clock = new MockClock(true);
    const delayedChanges = recordFunction();

    editableField.makeEditable();
    events.listen(editableField, Field.EventType.DELAYEDCHANGE, delayedChanges);

    testingEvents.fireKeySequence(editableField.getElement(), KeyCodes.WIN_IME);
    clock.tick(1000);

    assertEquals(
        'Delayed change event should\'ve been dispatched', 1,
        delayedChanges.getCallCount());


    clock.dispose();
    editableField.dispose();
  },

  testInputEventDispatchesDelayedChange() {
    const editableField = new FieldConstructor('testField');
    const clock = new MockClock(true);
    const delayedChanges = recordFunction();

    editableField.makeEditable();
    events.listen(editableField, Field.EventType.DELAYEDCHANGE, delayedChanges);

    // Non-typing changes on some devices rely on emitting an INPUT event, such
    // as:
    // - swipe-typing a word (on iOS)
    // - accepting a word prediction (on iOS)
    // - using speech to text (on iOS)
    // - accepting a spellcheck suggestion (on iOS, Android or Desktop)
    testingEvents.fireBrowserEvent(
        new TestEvent(EventType.INPUT, editableField.getElement()));
    clock.tick(1000);

    assertEquals(
        'Delayed change event should\'ve been dispatched', 1,
        delayedChanges.getCallCount());

    clock.dispose();
    editableField.dispose();
  },

  testSetEditableClassName() {
    const element = googDom.getElement('testField');
    const editableField = new FieldConstructor('testField');

    assertFalse(classlist.contains(element, 'editable'));
    editableField.makeEditable();
    assertTrue(classlist.contains(element, 'editable'));
    assertEquals(
        1,
        Array.prototype.filter
            .call(classlist.get(element), functions.equalTo('editable'))
            .length);

    // Skip restore won't reset the original element's CSS classes.
    editableField.makeUneditable(true /* opt_skipRestore */);

    editableField.makeEditable();
    assertTrue(classlist.contains(element, 'editable'));
    assertEquals(
        1,
        Array.prototype.filter
            .call(classlist.get(element), functions.equalTo('editable'))
            .length);
  },
});
