undoredo_test.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  1. // Copyright 2008 The Closure Library Authors. All Rights Reserved.
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS-IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. goog.provide('goog.editor.plugins.UndoRedoTest');
  15. goog.setTestOnly('goog.editor.plugins.UndoRedoTest');
  16. goog.require('goog.array');
  17. goog.require('goog.dom');
  18. goog.require('goog.dom.browserrange');
  19. goog.require('goog.editor.Field');
  20. goog.require('goog.editor.plugins.LoremIpsum');
  21. goog.require('goog.editor.plugins.UndoRedo');
  22. goog.require('goog.events');
  23. goog.require('goog.functions');
  24. goog.require('goog.testing.MockClock');
  25. goog.require('goog.testing.PropertyReplacer');
  26. goog.require('goog.testing.StrictMock');
  27. goog.require('goog.testing.jsunit');
  28. var mockEditableField;
  29. var editableField;
  30. var fieldHashCode;
  31. var undoPlugin;
  32. var state;
  33. var mockState;
  34. var commands;
  35. var clock;
  36. var stubs = new goog.testing.PropertyReplacer();
  37. function setUp() {
  38. mockEditableField = new goog.testing.StrictMock(goog.editor.Field);
  39. // Update the arg list verifier for dispatchCommandValueChange to
  40. // correctly compare arguments that are arrays (or other complex objects).
  41. mockEditableField.$registerArgumentListVerifier(
  42. 'dispatchEvent', function(expected, args) {
  43. return goog.array.equals(expected, args, function(a, b) {
  44. assertObjectEquals(a, b);
  45. return true;
  46. });
  47. });
  48. mockEditableField.getHashCode = function() { return 'fieldId'; };
  49. undoPlugin = new goog.editor.plugins.UndoRedo();
  50. undoPlugin.registerFieldObject(mockEditableField);
  51. mockState =
  52. new goog.testing.StrictMock(goog.editor.plugins.UndoRedo.UndoState_);
  53. mockState.fieldHashCode = 'fieldId';
  54. mockState.isAsynchronous = function() { return false; };
  55. // Don't bother mocking the inherited event target pieces of the state.
  56. // If we don't do this, then mocked asynchronous undos are a lot harder and
  57. // that behavior is tested as part of the UndoRedoManager tests.
  58. mockState.addEventListener = goog.nullFunction;
  59. commands = [
  60. goog.editor.plugins.UndoRedo.COMMAND.REDO,
  61. goog.editor.plugins.UndoRedo.COMMAND.UNDO
  62. ];
  63. state = new goog.editor.plugins.UndoRedo.UndoState_(
  64. '1', '', null, goog.nullFunction);
  65. clock = new goog.testing.MockClock(true);
  66. editableField = new goog.editor.Field('testField');
  67. fieldHashCode = editableField.getHashCode();
  68. }
  69. function tearDown() {
  70. // Reset field so any attempted access during disposes don't cause errors.
  71. mockEditableField.$reset();
  72. clock.dispose();
  73. undoPlugin.dispose();
  74. // NOTE(nicksantos): I think IE is blowing up on this call because
  75. // it is lame. It manifests its lameness by throwing an exception.
  76. // Kudos to XT for helping me to figure this out.
  77. try {
  78. } catch (e) {
  79. }
  80. if (!editableField.isUneditable()) {
  81. editableField.makeUneditable();
  82. }
  83. editableField.dispose();
  84. goog.dom.removeChildren(goog.dom.getElement('testField'));
  85. stubs.reset();
  86. }
  87. // undo-redo plugin tests
  88. function testQueryCommandValue() {
  89. assertFalse(
  90. 'Must return false for empty undo stack.',
  91. undoPlugin.queryCommandValue(goog.editor.plugins.UndoRedo.COMMAND.UNDO));
  92. assertFalse(
  93. 'Must return false for empty redo stack.',
  94. undoPlugin.queryCommandValue(goog.editor.plugins.UndoRedo.COMMAND.REDO));
  95. undoPlugin.undoManager_.addState(mockState);
  96. assertTrue(
  97. 'Must return true for a non-empty undo stack.',
  98. undoPlugin.queryCommandValue(goog.editor.plugins.UndoRedo.COMMAND.UNDO));
  99. }
  100. function testExecCommand() {
  101. undoPlugin.undoManager_.addState(mockState);
  102. mockState.undo();
  103. mockState.$replay();
  104. undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.UNDO);
  105. // Second undo should do nothing since only one item on stack.
  106. undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.UNDO);
  107. mockState.$verify();
  108. mockState.$reset();
  109. mockState.redo();
  110. mockState.$replay();
  111. undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.REDO);
  112. // Second redo should do nothing since only one item on stack.
  113. undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.REDO);
  114. mockState.$verify();
  115. }
  116. function testHandleKeyboardShortcut_TrogStates() {
  117. undoPlugin.undoManager_.addState(mockState);
  118. undoPlugin.undoManager_.addState(state);
  119. undoPlugin.undoManager_.undo();
  120. mockEditableField.$reset();
  121. var stubUndoEvent = {ctrlKey: true, altKey: false, shiftKey: false};
  122. var stubRedoEvent = {ctrlKey: true, altKey: false, shiftKey: true};
  123. var stubRedoEvent2 = {ctrlKey: true, altKey: false, shiftKey: false};
  124. var result;
  125. // Test handling Trogedit undos. Should always call EditableField's
  126. // execCommand. Since EditableField is mocked, this will not result in a call
  127. // to the mockState's undo and redo methods.
  128. mockEditableField.execCommand(goog.editor.plugins.UndoRedo.COMMAND.UNDO);
  129. mockEditableField.$replay();
  130. result = undoPlugin.handleKeyboardShortcut(stubUndoEvent, 'z', true);
  131. assertTrue('Plugin must return true when it handles shortcut.', result);
  132. mockEditableField.$verify();
  133. mockEditableField.$reset();
  134. mockEditableField.execCommand(goog.editor.plugins.UndoRedo.COMMAND.REDO);
  135. mockEditableField.$replay();
  136. result = undoPlugin.handleKeyboardShortcut(stubRedoEvent, 'z', true);
  137. assertTrue('Plugin must return true when it handles shortcut.', result);
  138. mockEditableField.$verify();
  139. mockEditableField.$reset();
  140. mockEditableField.execCommand(goog.editor.plugins.UndoRedo.COMMAND.REDO);
  141. mockEditableField.$replay();
  142. result = undoPlugin.handleKeyboardShortcut(stubRedoEvent2, 'y', true);
  143. assertTrue('Plugin must return true when it handles shortcut.', result);
  144. mockEditableField.$verify();
  145. mockEditableField.$reset();
  146. mockEditableField.$replay();
  147. result = undoPlugin.handleKeyboardShortcut(stubRedoEvent2, 'y', false);
  148. assertFalse('Plugin must return false when modifier is not pressed.', result);
  149. mockEditableField.$verify();
  150. mockEditableField.$reset();
  151. mockEditableField.$replay();
  152. result = undoPlugin.handleKeyboardShortcut(stubUndoEvent, 'f', true);
  153. assertFalse(
  154. 'Plugin must return false when it doesn\'t handle shortcut.', result);
  155. mockEditableField.$verify();
  156. }
  157. function testHandleKeyboardShortcut_NotTrogStates() {
  158. var stubUndoEvent = {ctrlKey: true, altKey: false, shiftKey: false};
  159. // Trogedit undo states all have a fieldHashCode, nulling that out makes this
  160. // state be treated as a non-Trogedit undo-redo state.
  161. state.fieldHashCode = null;
  162. undoPlugin.undoManager_.addState(state);
  163. mockEditableField.$reset();
  164. // Non-trog state shouldn't go through EditableField.execCommand, however,
  165. // we still exect command value change dispatch since undo-redo plugin
  166. // redispatches those anytime manager's state changes.
  167. mockEditableField.dispatchEvent({
  168. type: goog.editor.Field.EventType.COMMAND_VALUE_CHANGE,
  169. commands: commands
  170. });
  171. mockEditableField.$replay();
  172. var result = undoPlugin.handleKeyboardShortcut(stubUndoEvent, 'z', true);
  173. assertTrue('Plugin must return true when it handles shortcut.', result);
  174. mockEditableField.$verify();
  175. }
  176. function testEnable() {
  177. assertFalse(
  178. 'Plugin must start disabled.', undoPlugin.isEnabled(editableField));
  179. editableField.makeEditable();
  180. editableField.setHtml(false, '<div>a</div>');
  181. undoPlugin.enable(editableField);
  182. assertTrue(undoPlugin.isEnabled(editableField));
  183. assertNotNull(
  184. 'Must have an event handler for enabled field.',
  185. undoPlugin.eventHandlers_[fieldHashCode]);
  186. var currentState = undoPlugin.currentStates_[fieldHashCode];
  187. assertNotNull('Enabled plugin must have a current state.', currentState);
  188. assertEquals(
  189. 'After enable, undo content must match the field content.',
  190. editableField.getElement().innerHTML, currentState.undoContent_);
  191. assertTrue(
  192. 'After enable, undo cursorPosition must match the field cursor' +
  193. 'position.',
  194. cursorPositionsEqual(
  195. getCurrentCursorPosition(), currentState.undoCursorPosition_));
  196. assertUndefined(
  197. 'Current state must never have redo content.', currentState.redoContent_);
  198. assertUndefined(
  199. 'Current state must never have redo cursor position.',
  200. currentState.redoCursorPosition_);
  201. }
  202. function testDisable() {
  203. editableField.makeEditable();
  204. undoPlugin.enable(editableField);
  205. assertTrue(
  206. 'Plugin must be enabled so we can test disabling.',
  207. undoPlugin.isEnabled(editableField));
  208. var delayedChangeFired = false;
  209. goog.events.listenOnce(
  210. editableField, goog.editor.Field.EventType.DELAYEDCHANGE,
  211. function(e) { delayedChangeFired = true; });
  212. editableField.setHtml(false, 'foo');
  213. undoPlugin.disable(editableField);
  214. assertTrue('disable must fire pending delayed changes.', delayedChangeFired);
  215. assertEquals(
  216. 'disable must add undo state from pending change.', 1,
  217. undoPlugin.undoManager_.undoStack_.length);
  218. assertFalse(undoPlugin.isEnabled(editableField));
  219. assertUndefined(
  220. 'Disabled plugin must not have current state.',
  221. undoPlugin.eventHandlers_[fieldHashCode]);
  222. assertUndefined(
  223. 'Disabled plugin must not have event handlers.',
  224. undoPlugin.eventHandlers_[fieldHashCode]);
  225. }
  226. function testUpdateCurrentState_() {
  227. editableField.registerPlugin(new goog.editor.plugins.LoremIpsum('LOREM'));
  228. editableField.makeEditable();
  229. editableField.getPluginByClassId('LoremIpsum').usingLorem_ = true;
  230. undoPlugin.updateCurrentState_(editableField);
  231. var currentState = undoPlugin.currentStates_[fieldHashCode];
  232. assertNotUndefined(
  233. 'Must create empty states for field using lorem ipsum.',
  234. undoPlugin.currentStates_[fieldHashCode]);
  235. assertEquals('', currentState.undoContent_);
  236. assertNull(currentState.undoCursorPosition_);
  237. editableField.getPluginByClassId('LoremIpsum').usingLorem_ = false;
  238. // Pretend foo is the default contents to test '' == default contents
  239. // behavior.
  240. editableField.getInjectableContents = function(contents, styles) {
  241. return contents == '' ? 'foo' : contents;
  242. };
  243. editableField.setHtml(false, 'foo');
  244. undoPlugin.updateCurrentState_(editableField);
  245. assertEquals(currentState, undoPlugin.currentStates_[fieldHashCode]);
  246. // NOTE(user): Because there is already a current state, this setHtml will add
  247. // a state to the undo stack.
  248. editableField.setHtml(false, '<div>a</div>');
  249. // Select some text so we have a valid selection that gets saved in the
  250. // UndoState.
  251. goog.dom.browserrange.createRangeFromNodeContents(editableField.getElement())
  252. .select();
  253. undoPlugin.updateCurrentState_(editableField);
  254. currentState = undoPlugin.currentStates_[fieldHashCode];
  255. assertNotNull(
  256. 'Must create state for field not using lorem ipsum', currentState);
  257. assertEquals(fieldHashCode, currentState.fieldHashCode);
  258. var content = editableField.getElement().innerHTML;
  259. var cursorPosition = getCurrentCursorPosition();
  260. assertEquals(content, currentState.undoContent_);
  261. assertTrue(
  262. cursorPositionsEqual(cursorPosition, currentState.undoCursorPosition_));
  263. assertUndefined(currentState.redoContent_);
  264. assertUndefined(currentState.redoCursorPosition_);
  265. undoPlugin.updateCurrentState_(editableField);
  266. assertEquals(
  267. 'Updating state when state has not changed must not add undo ' +
  268. 'state to stack.',
  269. 1, undoPlugin.undoManager_.undoStack_.length);
  270. assertEquals(
  271. 'Updating state when state has not changed must not create ' +
  272. 'a new state.',
  273. currentState, undoPlugin.currentStates_[fieldHashCode]);
  274. assertUndefined(
  275. 'Updating state when state has not changed must not add ' +
  276. 'redo content.',
  277. currentState.redoContent_);
  278. assertUndefined(
  279. 'Updating state when state has not changed must not add ' +
  280. 'redo cursor position.',
  281. currentState.redoCursorPosition_);
  282. editableField.setHtml(false, '<div>b</div>');
  283. undoPlugin.updateCurrentState_(editableField);
  284. currentState = undoPlugin.currentStates_[fieldHashCode];
  285. assertNotNull(
  286. 'Must create state for field not using lorem ipsum', currentState);
  287. assertEquals(fieldHashCode, currentState.fieldHashCode);
  288. var newContent = editableField.getElement().innerHTML;
  289. var newCursorPosition = getCurrentCursorPosition();
  290. assertEquals(newContent, currentState.undoContent_);
  291. assertTrue(
  292. cursorPositionsEqual(
  293. newCursorPosition, currentState.undoCursorPosition_));
  294. assertUndefined(currentState.redoContent_);
  295. assertUndefined(currentState.redoCursorPosition_);
  296. var undoState = goog.array.peek(undoPlugin.undoManager_.undoStack_);
  297. assertNotNull(
  298. 'Must create state for field not using lorem ipsum', currentState);
  299. assertEquals(fieldHashCode, currentState.fieldHashCode);
  300. assertEquals(content, undoState.undoContent_);
  301. assertTrue(
  302. cursorPositionsEqual(cursorPosition, undoState.undoCursorPosition_));
  303. assertEquals(newContent, undoState.redoContent_);
  304. assertTrue(
  305. cursorPositionsEqual(newCursorPosition, undoState.redoCursorPosition_));
  306. }
  307. /**
  308. * Tests that change events get restarted properly after an undo call despite
  309. * an exception being thrown in the process (see bug/1991234).
  310. */
  311. function testUndoRestartsChangeEvents() {
  312. undoPlugin.registerFieldObject(editableField);
  313. editableField.makeEditable();
  314. editableField.setHtml(false, '<div>a</div>');
  315. clock.tick(1000);
  316. undoPlugin.enable(editableField);
  317. // Change content so we can undo it.
  318. editableField.setHtml(false, '<div>b</div>');
  319. clock.tick(1000);
  320. var currentState = undoPlugin.currentStates_[fieldHashCode];
  321. stubs.set(
  322. editableField, 'setCursorPosition',
  323. goog.functions.error('Faking exception during setCursorPosition()'));
  324. try {
  325. currentState.undo();
  326. } catch (e) {
  327. fail('Exception should not have been thrown during undo()');
  328. }
  329. assertEquals(
  330. 'Change events should be on', 0,
  331. editableField.stoppedEvents_[goog.editor.Field.EventType.CHANGE]);
  332. assertEquals(
  333. 'Delayed change events should be on', 0,
  334. editableField.stoppedEvents_[goog.editor.Field.EventType.DELAYEDCHANGE]);
  335. }
  336. function testRefreshCurrentState() {
  337. editableField.makeEditable();
  338. editableField.setHtml(false, '<div>a</div>');
  339. clock.tick(1000);
  340. undoPlugin.enable(editableField);
  341. // Create current state and verify it.
  342. var currentState = undoPlugin.currentStates_[fieldHashCode];
  343. assertEquals(fieldHashCode, currentState.fieldHashCode);
  344. var content = editableField.getElement().innerHTML;
  345. var cursorPosition = getCurrentCursorPosition();
  346. assertEquals(content, currentState.undoContent_);
  347. assertTrue(
  348. cursorPositionsEqual(cursorPosition, currentState.undoCursorPosition_));
  349. // Update the field w/o dispatching delayed change, and verify that the
  350. // current state hasn't changed to reflect new values.
  351. editableField.setHtml(false, '<div>b</div>', true);
  352. clock.tick(1000);
  353. currentState = undoPlugin.currentStates_[fieldHashCode];
  354. assertEquals(
  355. 'Content must match old state.', content, currentState.undoContent_);
  356. assertTrue(
  357. 'Cursor position must match old state.',
  358. cursorPositionsEqual(cursorPosition, currentState.undoCursorPosition_));
  359. undoPlugin.refreshCurrentState(editableField);
  360. assertFalse(
  361. 'Refresh must not cause states to go on the undo-redo stack.',
  362. undoPlugin.undoManager_.hasUndoState());
  363. currentState = undoPlugin.currentStates_[fieldHashCode];
  364. content = editableField.getElement().innerHTML;
  365. cursorPosition = getCurrentCursorPosition();
  366. assertEquals(
  367. 'Content must match current field state.', content,
  368. currentState.undoContent_);
  369. assertTrue(
  370. 'Cursor position must match current field state.',
  371. cursorPositionsEqual(cursorPosition, currentState.undoCursorPosition_));
  372. undoPlugin.disable(editableField);
  373. assertUndefined(undoPlugin.currentStates_[fieldHashCode]);
  374. undoPlugin.refreshCurrentState(editableField);
  375. assertUndefined(
  376. 'Must not refresh current state of fields that do not have ' +
  377. 'undo-redo enabled.',
  378. undoPlugin.currentStates_[fieldHashCode]);
  379. }
  380. /**
  381. * Returns the CursorPosition for the selection currently in the Field.
  382. * @return {goog.editor.plugins.UndoRedo.CursorPosition_}
  383. */
  384. function getCurrentCursorPosition() {
  385. return undoPlugin.getCursorPosition_(editableField);
  386. }
  387. /**
  388. * Compares two cursor positions and returns whether they are equal.
  389. * @param {goog.editor.plugins.UndoRedo.CursorPosition_} a
  390. * A cursor position.
  391. * @param {goog.editor.plugins.UndoRedo.CursorPosition_} b
  392. * A cursor position.
  393. * @return {boolean} Whether the positions are equal.
  394. */
  395. function cursorPositionsEqual(a, b) {
  396. if (!a && !b) {
  397. return true;
  398. } else if (a && b) {
  399. return a.toString() == b.toString();
  400. }
  401. // Only one cursor position is an object, can't be equal.
  402. return false;
  403. }
  404. // Undo state tests
  405. function testSetUndoState() {
  406. state.setUndoState('content', 'position');
  407. assertEquals('Undo content incorrectly set', 'content', state.undoContent_);
  408. assertEquals(
  409. 'Undo cursor position incorrectly set', 'position',
  410. state.undoCursorPosition_);
  411. }
  412. function testSetRedoState() {
  413. state.setRedoState('content', 'position');
  414. assertEquals('Redo content incorrectly set', 'content', state.redoContent_);
  415. assertEquals(
  416. 'Redo cursor position incorrectly set', 'position',
  417. state.redoCursorPosition_);
  418. }
  419. function testEquals() {
  420. assertTrue('A state must equal itself', state.equals(state));
  421. var state2 = new goog.editor.plugins.UndoRedo.UndoState_('1', '', null);
  422. assertTrue(
  423. 'A state must equal a state with the same hash code and content.',
  424. state.equals(state2));
  425. state2 = new goog.editor.plugins.UndoRedo.UndoState_('1', '', 'foo');
  426. assertTrue(
  427. 'States with different cursor positions must be equal',
  428. state.equals(state2));
  429. state2.setRedoState('bar', null);
  430. assertFalse(
  431. 'States with different redo content must not be equal',
  432. state.equals(state2));
  433. state2 = new goog.editor.plugins.UndoRedo.UndoState_('3', '', null);
  434. assertFalse(
  435. 'States with different field hash codes must not be equal',
  436. state.equals(state2));
  437. state2 = new goog.editor.plugins.UndoRedo.UndoState_('1', 'baz', null);
  438. assertFalse(
  439. 'States with different undoContent must not be equal',
  440. state.equals(state2));
  441. }
  442. /** @bug 1359214 */
  443. function testClearUndoHistory() {
  444. var undoRedoPlugin = new goog.editor.plugins.UndoRedo();
  445. editableField.registerPlugin(undoRedoPlugin);
  446. editableField.makeEditable();
  447. editableField.dispatchChange();
  448. clock.tick(10000);
  449. goog.dom.setTextContent(editableField.getElement(), 'y');
  450. editableField.dispatchChange();
  451. assertFalse(undoRedoPlugin.undoManager_.hasUndoState());
  452. clock.tick(10000);
  453. assertTrue(undoRedoPlugin.undoManager_.hasUndoState());
  454. goog.dom.setTextContent(editableField.getElement(), 'z');
  455. editableField.dispatchChange();
  456. var numCalls = 0;
  457. goog.events.listen(
  458. editableField, goog.editor.Field.EventType.DELAYEDCHANGE,
  459. function() { numCalls++; });
  460. undoRedoPlugin.clearHistory();
  461. // 1 call from stopChangeEvents(). 0 calls from startChangeEvents().
  462. assertEquals(
  463. 'clearHistory must not cause delayed change when none pending', 1,
  464. numCalls);
  465. clock.tick(10000);
  466. assertFalse(undoRedoPlugin.undoManager_.hasUndoState());
  467. }