// Copyright 2008 The Closure Library Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS-IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. goog.provide('goog.editor.plugins.UndoRedoTest'); goog.setTestOnly('goog.editor.plugins.UndoRedoTest'); goog.require('goog.array'); goog.require('goog.dom'); goog.require('goog.dom.browserrange'); goog.require('goog.editor.Field'); goog.require('goog.editor.plugins.LoremIpsum'); goog.require('goog.editor.plugins.UndoRedo'); goog.require('goog.events'); goog.require('goog.functions'); goog.require('goog.testing.MockClock'); goog.require('goog.testing.PropertyReplacer'); goog.require('goog.testing.StrictMock'); goog.require('goog.testing.jsunit'); var mockEditableField; var editableField; var fieldHashCode; var undoPlugin; var state; var mockState; var commands; var clock; var stubs = new goog.testing.PropertyReplacer(); function setUp() { mockEditableField = new goog.testing.StrictMock(goog.editor.Field); // Update the arg list verifier for dispatchCommandValueChange to // correctly compare arguments that are arrays (or other complex objects). mockEditableField.$registerArgumentListVerifier( 'dispatchEvent', function(expected, args) { return goog.array.equals(expected, args, function(a, b) { assertObjectEquals(a, b); return true; }); }); mockEditableField.getHashCode = function() { return 'fieldId'; }; undoPlugin = new goog.editor.plugins.UndoRedo(); undoPlugin.registerFieldObject(mockEditableField); mockState = new goog.testing.StrictMock(goog.editor.plugins.UndoRedo.UndoState_); mockState.fieldHashCode = 'fieldId'; mockState.isAsynchronous = function() { return 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. mockState.addEventListener = goog.nullFunction; commands = [ goog.editor.plugins.UndoRedo.COMMAND.REDO, goog.editor.plugins.UndoRedo.COMMAND.UNDO ]; state = new goog.editor.plugins.UndoRedo.UndoState_( '1', '', null, goog.nullFunction); clock = new goog.testing.MockClock(true); editableField = new goog.editor.Field('testField'); fieldHashCode = editableField.getHashCode(); } function 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(); goog.dom.removeChildren(goog.dom.getElement('testField')); stubs.reset(); } // undo-redo plugin tests function testQueryCommandValue() { assertFalse( 'Must return false for empty undo stack.', undoPlugin.queryCommandValue(goog.editor.plugins.UndoRedo.COMMAND.UNDO)); assertFalse( 'Must return false for empty redo stack.', undoPlugin.queryCommandValue(goog.editor.plugins.UndoRedo.COMMAND.REDO)); undoPlugin.undoManager_.addState(mockState); assertTrue( 'Must return true for a non-empty undo stack.', undoPlugin.queryCommandValue(goog.editor.plugins.UndoRedo.COMMAND.UNDO)); } function testExecCommand() { undoPlugin.undoManager_.addState(mockState); mockState.undo(); mockState.$replay(); undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.UNDO); // Second undo should do nothing since only one item on stack. undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.UNDO); mockState.$verify(); mockState.$reset(); mockState.redo(); mockState.$replay(); undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.REDO); // Second redo should do nothing since only one item on stack. undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.REDO); mockState.$verify(); } function testHandleKeyboardShortcut_TrogStates() { undoPlugin.undoManager_.addState(mockState); undoPlugin.undoManager_.addState(state); undoPlugin.undoManager_.undo(); mockEditableField.$reset(); var stubUndoEvent = {ctrlKey: true, altKey: false, shiftKey: false}; var stubRedoEvent = {ctrlKey: true, altKey: false, shiftKey: true}; var stubRedoEvent2 = {ctrlKey: true, altKey: false, shiftKey: false}; var 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(goog.editor.plugins.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(goog.editor.plugins.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(goog.editor.plugins.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(); } function testHandleKeyboardShortcut_NotTrogStates() { var 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. 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: goog.editor.Field.EventType.COMMAND_VALUE_CHANGE, commands: commands }); mockEditableField.$replay(); var result = undoPlugin.handleKeyboardShortcut(stubUndoEvent, 'z', true); assertTrue('Plugin must return true when it handles shortcut.', result); mockEditableField.$verify(); } function testEnable() { assertFalse( 'Plugin must start disabled.', undoPlugin.isEnabled(editableField)); editableField.makeEditable(); editableField.setHtml(false, '
a
'); undoPlugin.enable(editableField); assertTrue(undoPlugin.isEnabled(editableField)); assertNotNull( 'Must have an event handler for enabled field.', undoPlugin.eventHandlers_[fieldHashCode]); var 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_); } function testDisable() { editableField.makeEditable(); undoPlugin.enable(editableField); assertTrue( 'Plugin must be enabled so we can test disabling.', undoPlugin.isEnabled(editableField)); var delayedChangeFired = false; goog.events.listenOnce( editableField, goog.editor.Field.EventType.DELAYEDCHANGE, function(e) { delayedChangeFired = true; }); editableField.setHtml(false, '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]); } function testUpdateCurrentState_() { editableField.registerPlugin(new goog.editor.plugins.LoremIpsum('LOREM')); editableField.makeEditable(); editableField.getPluginByClassId('LoremIpsum').usingLorem_ = true; undoPlugin.updateCurrentState_(editableField); var 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 = function(contents, styles) { return contents == '' ? 'foo' : contents; }; editableField.setHtml(false, 'foo'); undoPlugin.updateCurrentState_(editableField); assertEquals(currentState, undoPlugin.currentStates_[fieldHashCode]); // NOTE(user): Because there is already a current state, this setHtml will add // a state to the undo stack. editableField.setHtml(false, '
a
'); // Select some text so we have a valid selection that gets saved in the // UndoState. goog.dom.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); var content = editableField.getElement().innerHTML; var 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.setHtml(false, '
b
'); undoPlugin.updateCurrentState_(editableField); currentState = undoPlugin.currentStates_[fieldHashCode]; assertNotNull( 'Must create state for field not using lorem ipsum', currentState); assertEquals(fieldHashCode, currentState.fieldHashCode); var newContent = editableField.getElement().innerHTML; var newCursorPosition = getCurrentCursorPosition(); assertEquals(newContent, currentState.undoContent_); assertTrue( cursorPositionsEqual( newCursorPosition, currentState.undoCursorPosition_)); assertUndefined(currentState.redoContent_); assertUndefined(currentState.redoCursorPosition_); var undoState = goog.array.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). */ function testUndoRestartsChangeEvents() { undoPlugin.registerFieldObject(editableField); editableField.makeEditable(); editableField.setHtml(false, '
a
'); clock.tick(1000); undoPlugin.enable(editableField); // Change content so we can undo it. editableField.setHtml(false, '
b
'); clock.tick(1000); var currentState = undoPlugin.currentStates_[fieldHashCode]; stubs.set( editableField, 'setCursorPosition', goog.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_[goog.editor.Field.EventType.CHANGE]); assertEquals( 'Delayed change events should be on', 0, editableField.stoppedEvents_[goog.editor.Field.EventType.DELAYEDCHANGE]); } function testRefreshCurrentState() { editableField.makeEditable(); editableField.setHtml(false, '
a
'); clock.tick(1000); undoPlugin.enable(editableField); // Create current state and verify it. var currentState = undoPlugin.currentStates_[fieldHashCode]; assertEquals(fieldHashCode, currentState.fieldHashCode); var content = editableField.getElement().innerHTML; var 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.setHtml(false, '
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]); } /** * Returns the CursorPosition for the selection currently in the Field. * @return {goog.editor.plugins.UndoRedo.CursorPosition_} */ function getCurrentCursorPosition() { return undoPlugin.getCursorPosition_(editableField); } /** * Compares two cursor positions and returns whether they are equal. * @param {goog.editor.plugins.UndoRedo.CursorPosition_} a * A cursor position. * @param {goog.editor.plugins.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 function testSetUndoState() { state.setUndoState('content', 'position'); assertEquals('Undo content incorrectly set', 'content', state.undoContent_); assertEquals( 'Undo cursor position incorrectly set', 'position', state.undoCursorPosition_); } function testSetRedoState() { state.setRedoState('content', 'position'); assertEquals('Redo content incorrectly set', 'content', state.redoContent_); assertEquals( 'Redo cursor position incorrectly set', 'position', state.redoCursorPosition_); } function testEquals() { assertTrue('A state must equal itself', state.equals(state)); var state2 = new goog.editor.plugins.UndoRedo.UndoState_('1', '', null); assertTrue( 'A state must equal a state with the same hash code and content.', state.equals(state2)); state2 = new goog.editor.plugins.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)); state2 = new goog.editor.plugins.UndoRedo.UndoState_('3', '', null); assertFalse( 'States with different field hash codes must not be equal', state.equals(state2)); state2 = new goog.editor.plugins.UndoRedo.UndoState_('1', 'baz', null); assertFalse( 'States with different undoContent must not be equal', state.equals(state2)); } /** @bug 1359214 */ function testClearUndoHistory() { var undoRedoPlugin = new goog.editor.plugins.UndoRedo(); editableField.registerPlugin(undoRedoPlugin); editableField.makeEditable(); editableField.dispatchChange(); clock.tick(10000); goog.dom.setTextContent(editableField.getElement(), 'y'); editableField.dispatchChange(); assertFalse(undoRedoPlugin.undoManager_.hasUndoState()); clock.tick(10000); assertTrue(undoRedoPlugin.undoManager_.hasUndoState()); goog.dom.setTextContent(editableField.getElement(), 'z'); editableField.dispatchChange(); var numCalls = 0; goog.events.listen( editableField, goog.editor.Field.EventType.DELAYEDCHANGE, function() { 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()); }