chromium/third_party/google-closure-library/closure/goog/editor/plugins/undoredo_test.js

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

goog.module('goog.editor.plugins.UndoRedoTest');
goog.setTestOnly();

const Field = goog.require('goog.editor.Field');
const LoremIpsum = goog.require('goog.editor.plugins.LoremIpsum');
const MockClock = goog.require('goog.testing.MockClock');
const PropertyReplacer = goog.require('goog.testing.PropertyReplacer');
const SafeHtml = goog.require('goog.html.SafeHtml');
const StrictMock = goog.require('goog.testing.StrictMock');
const UndoRedo = goog.require('goog.editor.plugins.UndoRedo');
const browserrange = goog.require('goog.dom.browserrange');
const dom = goog.require('goog.dom');
const events = goog.require('goog.events');
const functions = goog.require('goog.functions');
const googArray = goog.require('goog.array');
const testSuite = goog.require('goog.testing.testSuite');

let mockEditableField;
let editableField;
let fieldHashCode;
let undoPlugin;
let state;
let mockState;
let commands;
let clock;
const stubs = new PropertyReplacer();

// undo-redo plugin tests

/**
 * Returns the CursorPosition for the selection currently in the Field.
 * @return {UndoRedo.CursorPosition_}
 * @suppress {visibility} suppression added to enable type checking
 */
function getCurrentCursorPosition() {
  return undoPlugin.getCursorPosition_(editableField);
}

/**
 * Compares two cursor positions and returns whether they are equal.
 * @param {UndoRedo.CursorPosition_} a A cursor position.
 * @param {UndoRedo.CursorPosition_} b A cursor position.
 * @return {boolean} Whether the positions are equal.
 */
function cursorPositionsEqual(a, b) {
  if (!a && !b) {
    return true;
  } else if (a && b) {
    return a.toString() == b.toString();
  }
  // Only one cursor position is an object, can't be equal.
  return false;
}
// Undo state tests

testSuite({
  /** @suppress {checkTypes} suppression added to enable type checking */
  setUp() {
    mockEditableField = new StrictMock(Field);

    // Update the arg list verifier for dispatchCommandValueChange to
    // correctly compare arguments that are arrays (or other complex objects).
    mockEditableField.$registerArgumentListVerifier(
        'dispatchEvent',
        (expected, args) => googArray.equals(expected, args, (a, b) => {
          assertObjectEquals(a, b);
          return true;
        }));
    /**
     * @suppress {strictMissingProperties} suppression added to enable type
     * checking
     */
    mockEditableField.getHashCode = () => 'fieldId';

    undoPlugin = new UndoRedo();
    undoPlugin.registerFieldObject(mockEditableField);
    mockState = new StrictMock(UndoRedo.UndoState_);
    /**
     * @suppress {strictMissingProperties} suppression added to enable type
     * checking
     */
    mockState.fieldHashCode = 'fieldId';
    /**
     * @suppress {strictMissingProperties} suppression added to enable type
     * checking
     */
    mockState.isAsynchronous = () => false;
    // Don't bother mocking the inherited event target pieces of the state.
    // If we don't do this, then mocked asynchronous undos are a lot harder and
    // that behavior is tested as part of the UndoRedoManager tests.
    /**
     * @suppress {strictMissingProperties} suppression added to enable type
     * checking
     */
    mockState.addEventListener = goog.nullFunction;

    commands = [
      UndoRedo.COMMAND.REDO,
      UndoRedo.COMMAND.UNDO,
    ];
    /** @suppress {visibility} suppression added to enable type checking */
    state = new UndoRedo.UndoState_('1', '', null, goog.nullFunction);

    clock = new MockClock(true);

    editableField = new Field('testField');
    fieldHashCode = editableField.getHashCode();
  },

  /** @suppress {uselessCode} suppression added to enable type checking */
  tearDown() {
    // Reset field so any attempted access during disposes don't cause errors.
    mockEditableField.$reset();
    clock.dispose();
    undoPlugin.dispose();

    // 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) {
    }

    if (!editableField.isUneditable()) {
      editableField.makeUneditable();
    }
    editableField.dispose();
    dom.removeChildren(dom.getElement('testField'));
    stubs.reset();
  },

  /** @suppress {visibility} suppression added to enable type checking */
  testQueryCommandValue() {
    assertFalse(
        'Must return false for empty undo stack.',
        undoPlugin.queryCommandValue(UndoRedo.COMMAND.UNDO));

    assertFalse(
        'Must return false for empty redo stack.',
        undoPlugin.queryCommandValue(UndoRedo.COMMAND.REDO));

    undoPlugin.undoManager_.addState(mockState);

    assertTrue(
        'Must return true for a non-empty undo stack.',
        undoPlugin.queryCommandValue(UndoRedo.COMMAND.UNDO));
  },

  /**
     @suppress {visibility,missingProperties} suppression added to enable type
     checking
   */
  testExecCommand() {
    undoPlugin.undoManager_.addState(mockState);

    mockState.undo();
    mockState.$replay();

    undoPlugin.execCommand(UndoRedo.COMMAND.UNDO);
    // Second undo should do nothing since only one item on stack.
    undoPlugin.execCommand(UndoRedo.COMMAND.UNDO);
    mockState.$verify();

    mockState.$reset();
    mockState.redo();
    mockState.$replay();
    undoPlugin.execCommand(UndoRedo.COMMAND.REDO);
    // Second redo should do nothing since only one item on stack.
    undoPlugin.execCommand(UndoRedo.COMMAND.REDO);
    mockState.$verify();
  },

  /**
     @suppress {visibility,missingProperties} suppression added to enable type
     checking
   */
  testHandleKeyboardShortcut_TrogStates() {
    undoPlugin.undoManager_.addState(mockState);
    undoPlugin.undoManager_.addState(state);
    undoPlugin.undoManager_.undo();
    mockEditableField.$reset();

    const stubUndoEvent = {ctrlKey: true, altKey: false, shiftKey: false};
    const stubRedoEvent = {ctrlKey: true, altKey: false, shiftKey: true};
    const stubRedoEvent2 = {ctrlKey: true, altKey: false, shiftKey: false};
    let result;

    // Test handling Trogedit undos. Should always call EditableField's
    // execCommand. Since EditableField is mocked, this will not result in a
    // call to the mockState's undo and redo methods.
    mockEditableField.execCommand(UndoRedo.COMMAND.UNDO);
    mockEditableField.$replay();
    result = undoPlugin.handleKeyboardShortcut(stubUndoEvent, 'z', true);
    assertTrue('Plugin must return true when it handles shortcut.', result);
    mockEditableField.$verify();
    mockEditableField.$reset();

    mockEditableField.execCommand(UndoRedo.COMMAND.REDO);
    mockEditableField.$replay();
    result = undoPlugin.handleKeyboardShortcut(stubRedoEvent, 'z', true);
    assertTrue('Plugin must return true when it handles shortcut.', result);
    mockEditableField.$verify();
    mockEditableField.$reset();

    mockEditableField.execCommand(UndoRedo.COMMAND.REDO);
    mockEditableField.$replay();
    result = undoPlugin.handleKeyboardShortcut(stubRedoEvent2, 'y', true);
    assertTrue('Plugin must return true when it handles shortcut.', result);
    mockEditableField.$verify();
    mockEditableField.$reset();

    mockEditableField.$replay();
    result = undoPlugin.handleKeyboardShortcut(stubRedoEvent2, 'y', false);
    assertFalse(
        'Plugin must return false when modifier is not pressed.', result);
    mockEditableField.$verify();
    mockEditableField.$reset();

    mockEditableField.$replay();
    result = undoPlugin.handleKeyboardShortcut(stubUndoEvent, 'f', true);
    assertFalse(
        'Plugin must return false when it doesn\'t handle shortcut.', result);
    mockEditableField.$verify();
  },

  /**
     @suppress {visibility,missingProperties} suppression added to enable type
     checking
   */
  testHandleKeyboardShortcut_NotTrogStates() {
    const stubUndoEvent = {ctrlKey: true, altKey: false, shiftKey: false};

    // Trogedit undo states all have a fieldHashCode, nulling that out makes
    // this state be treated as a non-Trogedit undo-redo state.
    /** @suppress {checkTypes} suppression added to enable type checking */
    state.fieldHashCode = null;
    undoPlugin.undoManager_.addState(state);
    mockEditableField.$reset();

    // Non-trog state shouldn't go through EditableField.execCommand, however,
    // we still exect command value change dispatch since undo-redo plugin
    // redispatches those anytime manager's state changes.
    mockEditableField.dispatchEvent({
      type: Field.EventType.COMMAND_VALUE_CHANGE,
      commands: commands,
    });
    mockEditableField.$replay();
    const result = undoPlugin.handleKeyboardShortcut(stubUndoEvent, 'z', true);
    assertTrue('Plugin must return true when it handles shortcut.', result);
    mockEditableField.$verify();
  },

  testEnable() {
    assertFalse(
        'Plugin must start disabled.', undoPlugin.isEnabled(editableField));

    editableField.makeEditable();
    editableField.setSafeHtml(false, SafeHtml.create('div', {}, 'a'));
    undoPlugin.enable(editableField);

    assertTrue(undoPlugin.isEnabled(editableField));
    assertNotNull(
        'Must have an event handler for enabled field.',
        undoPlugin.eventHandlers_[fieldHashCode]);

    const currentState = undoPlugin.currentStates_[fieldHashCode];
    assertNotNull('Enabled plugin must have a current state.', currentState);
    assertEquals(
        'After enable, undo content must match the field content.',
        editableField.getElement().innerHTML, currentState.undoContent_);

    assertTrue(
        'After enable, undo cursorPosition must match the field cursor' +
            'position.',
        cursorPositionsEqual(
            getCurrentCursorPosition(), currentState.undoCursorPosition_));

    assertUndefined(
        'Current state must never have redo content.',
        currentState.redoContent_);
    assertUndefined(
        'Current state must never have redo cursor position.',
        currentState.redoCursorPosition_);
  },

  /** @suppress {visibility} suppression added to enable type checking */
  testDisable() {
    editableField.makeEditable();
    undoPlugin.enable(editableField);
    assertTrue(
        'Plugin must be enabled so we can test disabling.',
        undoPlugin.isEnabled(editableField));

    let delayedChangeFired = false;
    events.listenOnce(editableField, Field.EventType.DELAYEDCHANGE, (e) => {
      delayedChangeFired = true;
    });
    editableField.setSafeHtml(false, SafeHtml.htmlEscape('foo'));

    undoPlugin.disable(editableField);
    assertTrue(
        'disable must fire pending delayed changes.', delayedChangeFired);
    assertEquals(
        'disable must add undo state from pending change.', 1,
        undoPlugin.undoManager_.undoStack_.length);

    assertFalse(undoPlugin.isEnabled(editableField));
    assertUndefined(
        'Disabled plugin must not have current state.',
        undoPlugin.eventHandlers_[fieldHashCode]);
    assertUndefined(
        'Disabled plugin must not have event handlers.',
        undoPlugin.eventHandlers_[fieldHashCode]);
  },

  /** @suppress {visibility} suppression added to enable type checking */
  testUpdateCurrentState_() {
    editableField.registerPlugin(new LoremIpsum('LOREM'));
    editableField.makeEditable();
    editableField.getPluginByClassId('LoremIpsum').usingLorem_ = true;
    undoPlugin.updateCurrentState_(editableField);
    let currentState = undoPlugin.currentStates_[fieldHashCode];
    assertNotUndefined(
        'Must create empty states for field using lorem ipsum.',
        undoPlugin.currentStates_[fieldHashCode]);
    assertEquals('', currentState.undoContent_);
    assertNull(currentState.undoCursorPosition_);

    editableField.getPluginByClassId('LoremIpsum').usingLorem_ = false;

    // Pretend foo is the default contents to test '' == default contents
    // behavior.
    editableField.getInjectableContents = (contents, styles) =>
        contents == '' ? 'foo' : contents;
    editableField.setSafeHtml(false, SafeHtml.htmlEscape('foo'));
    undoPlugin.updateCurrentState_(editableField);
    assertEquals(currentState, undoPlugin.currentStates_[fieldHashCode]);

    // NOTE(user): Because there is already a current state, this setSafeHtml
    // will add a state to the undo stack.
    editableField.setSafeHtml(false, SafeHtml.create('div', {}, 'a'));
    // Select some text so we have a valid selection that gets saved in the
    // UndoState.
    browserrange.createRangeFromNodeContents(editableField.getElement())
        .select();

    undoPlugin.updateCurrentState_(editableField);
    currentState = undoPlugin.currentStates_[fieldHashCode];
    assertNotNull(
        'Must create state for field not using lorem ipsum', currentState);
    assertEquals(fieldHashCode, currentState.fieldHashCode);
    const content = editableField.getElement().innerHTML;
    const cursorPosition = getCurrentCursorPosition();
    assertEquals(content, currentState.undoContent_);
    assertTrue(
        cursorPositionsEqual(cursorPosition, currentState.undoCursorPosition_));
    assertUndefined(currentState.redoContent_);
    assertUndefined(currentState.redoCursorPosition_);

    undoPlugin.updateCurrentState_(editableField);
    assertEquals(
        'Updating state when state has not changed must not add undo ' +
            'state to stack.',
        1, undoPlugin.undoManager_.undoStack_.length);
    assertEquals(
        'Updating state when state has not changed must not create ' +
            'a new state.',
        currentState, undoPlugin.currentStates_[fieldHashCode]);
    assertUndefined(
        'Updating state when state has not changed must not add ' +
            'redo content.',
        currentState.redoContent_);
    assertUndefined(
        'Updating state when state has not changed must not add ' +
            'redo cursor position.',
        currentState.redoCursorPosition_);

    editableField.setSafeHtml(false, SafeHtml.create('div', {}, 'b'));
    undoPlugin.updateCurrentState_(editableField);
    currentState = undoPlugin.currentStates_[fieldHashCode];
    assertNotNull(
        'Must create state for field not using lorem ipsum', currentState);
    assertEquals(fieldHashCode, currentState.fieldHashCode);
    const newContent = editableField.getElement().innerHTML;
    const newCursorPosition = getCurrentCursorPosition();
    assertEquals(newContent, currentState.undoContent_);
    assertTrue(cursorPositionsEqual(
        newCursorPosition, currentState.undoCursorPosition_));
    assertUndefined(currentState.redoContent_);
    assertUndefined(currentState.redoCursorPosition_);

    /** @suppress {visibility} suppression added to enable type checking */
    const undoState = googArray.peek(undoPlugin.undoManager_.undoStack_);
    assertNotNull(
        'Must create state for field not using lorem ipsum', currentState);
    assertEquals(fieldHashCode, currentState.fieldHashCode);
    assertEquals(content, undoState.undoContent_);
    assertTrue(
        cursorPositionsEqual(cursorPosition, undoState.undoCursorPosition_));
    assertEquals(newContent, undoState.redoContent_);
    assertTrue(
        cursorPositionsEqual(newCursorPosition, undoState.redoCursorPosition_));
  },

  /**
   * Tests that change events get restarted properly after an undo call despite
   * an exception being thrown in the process (see bug/1991234).
   * @suppress {visibility} suppression added to enable type checking
   */
  testUndoRestartsChangeEvents() {
    undoPlugin.registerFieldObject(editableField);
    editableField.makeEditable();
    editableField.setSafeHtml(false, SafeHtml.create('div', {}, 'a'));
    clock.tick(1000);
    undoPlugin.enable(editableField);

    // Change content so we can undo it.
    editableField.setSafeHtml(false, SafeHtml.create('div', {}, 'b'));
    clock.tick(1000);

    const currentState = undoPlugin.currentStates_[fieldHashCode];
    stubs.set(
        editableField, 'setCursorPosition',
        functions.error('Faking exception during setCursorPosition()'));
    try {
      currentState.undo();
    } catch (e) {
      fail('Exception should not have been thrown during undo()');
    }
    assertEquals(
        'Change events should be on', 0,
        editableField.stoppedEvents_[Field.EventType.CHANGE]);
    assertEquals(
        'Delayed change events should be on', 0,
        editableField.stoppedEvents_[Field.EventType.DELAYEDCHANGE]);
  },

  /** @suppress {visibility} suppression added to enable type checking */
  testRefreshCurrentState() {
    editableField.makeEditable();
    editableField.setSafeHtml(false, SafeHtml.create('div', {}, 'a'));
    clock.tick(1000);
    undoPlugin.enable(editableField);

    // Create current state and verify it.
    let currentState = undoPlugin.currentStates_[fieldHashCode];
    assertEquals(fieldHashCode, currentState.fieldHashCode);
    let content = editableField.getElement().innerHTML;
    let cursorPosition = getCurrentCursorPosition();
    assertEquals(content, currentState.undoContent_);
    assertTrue(
        cursorPositionsEqual(cursorPosition, currentState.undoCursorPosition_));

    // Update the field w/o dispatching delayed change, and verify that the
    // current state hasn't changed to reflect new values.
    editableField.setSafeHtml(false, SafeHtml.create('div', {}, 'b'), true);
    clock.tick(1000);
    currentState = undoPlugin.currentStates_[fieldHashCode];
    assertEquals(
        'Content must match old state.', content, currentState.undoContent_);
    assertTrue(
        'Cursor position must match old state.',
        cursorPositionsEqual(cursorPosition, currentState.undoCursorPosition_));

    undoPlugin.refreshCurrentState(editableField);
    assertFalse(
        'Refresh must not cause states to go on the undo-redo stack.',
        undoPlugin.undoManager_.hasUndoState());
    currentState = undoPlugin.currentStates_[fieldHashCode];
    content = editableField.getElement().innerHTML;
    cursorPosition = getCurrentCursorPosition();
    assertEquals(
        'Content must match current field state.', content,
        currentState.undoContent_);
    assertTrue(
        'Cursor position must match current field state.',
        cursorPositionsEqual(cursorPosition, currentState.undoCursorPosition_));

    undoPlugin.disable(editableField);
    assertUndefined(undoPlugin.currentStates_[fieldHashCode]);
    undoPlugin.refreshCurrentState(editableField);
    assertUndefined(
        'Must not refresh current state of fields that do not have ' +
            'undo-redo enabled.',
        undoPlugin.currentStates_[fieldHashCode]);
  },

  /** @suppress {visibility} suppression added to enable type checking */
  testSetUndoState() {
    state.setUndoState('content', 'position');
    assertEquals('Undo content incorrectly set', 'content', state.undoContent_);
    assertEquals(
        'Undo cursor position incorrectly set', 'position',
        state.undoCursorPosition_);
  },

  /** @suppress {visibility} suppression added to enable type checking */
  testSetRedoState() {
    state.setRedoState('content', 'position');
    assertEquals('Redo content incorrectly set', 'content', state.redoContent_);
    assertEquals(
        'Redo cursor position incorrectly set', 'position',
        state.redoCursorPosition_);
  },

  testEquals() {
    assertTrue('A state must equal itself', state.equals(state));

    /**
     * @suppress {checkTypes,visibility} suppression added to enable type
     * checking
     */
    let state2 = new UndoRedo.UndoState_('1', '', null);
    assertTrue(
        'A state must equal a state with the same hash code and content.',
        state.equals(state2));

    /**
     * @suppress {checkTypes,visibility} suppression added to enable type
     * checking
     */
    state2 = new UndoRedo.UndoState_('1', '', 'foo');
    assertTrue(
        'States with different cursor positions must be equal',
        state.equals(state2));

    state2.setRedoState('bar', null);
    assertFalse(
        'States with different redo content must not be equal',
        state.equals(state2));

    /**
     * @suppress {checkTypes,visibility} suppression added to enable type
     * checking
     */
    state2 = new UndoRedo.UndoState_('3', '', null);
    assertFalse(
        'States with different field hash codes must not be equal',
        state.equals(state2));

    /**
     * @suppress {checkTypes,visibility} suppression added to enable type
     * checking
     */
    state2 = new UndoRedo.UndoState_('1', 'baz', null);
    assertFalse(
        'States with different undoContent must not be equal',
        state.equals(state2));
  },

  /**
   * @bug 1359214
   * @suppress {visibility} suppression added to enable type
   *      checking
   */
  testClearUndoHistory() {
    const undoRedoPlugin = new UndoRedo();
    editableField.registerPlugin(undoRedoPlugin);
    editableField.makeEditable();

    editableField.dispatchChange();
    clock.tick(10000);

    dom.setTextContent(editableField.getElement(), 'y');
    editableField.dispatchChange();
    assertFalse(undoRedoPlugin.undoManager_.hasUndoState());

    clock.tick(10000);
    assertTrue(undoRedoPlugin.undoManager_.hasUndoState());

    dom.setTextContent(editableField.getElement(), 'z');
    editableField.dispatchChange();

    let numCalls = 0;
    events.listen(editableField, Field.EventType.DELAYEDCHANGE, () => {
      numCalls++;
    });
    undoRedoPlugin.clearHistory();
    // 1 call from stopChangeEvents(). 0 calls from startChangeEvents().
    assertEquals(
        'clearHistory must not cause delayed change when none pending', 1,
        numCalls);

    clock.tick(10000);
    assertFalse(undoRedoPlugin.undoManager_.hasUndoState());
  },
});