/** * Copyright 2009 Google Inc. * * 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. * */ package org.waveprotocol.wave.client.editor.event; import com.google.gwt.dom.client.Document; import com.google.gwt.dom.client.Element; import com.google.gwt.dom.client.Node; import com.google.gwt.dom.client.Text; import com.google.gwt.event.dom.client.KeyCodes; import com.google.gwt.junit.client.GWTTestCase; import org.waveprotocol.wave.client.common.util.EventWrapper; import org.waveprotocol.wave.client.common.util.FakeSignalEvent; import org.waveprotocol.wave.client.common.util.SignalEvent; import org.waveprotocol.wave.client.common.util.SignalEvent.KeyModifier; import org.waveprotocol.wave.client.common.util.SignalEvent.KeySignalType; import org.waveprotocol.wave.client.common.util.SignalKeyLogic; import org.waveprotocol.wave.client.common.util.UserAgent; import org.waveprotocol.wave.client.editor.content.ContentElement; import org.waveprotocol.wave.client.editor.content.ContentNode; import org.waveprotocol.wave.client.editor.content.ContentPoint; import org.waveprotocol.wave.client.editor.content.ContentRange; import org.waveprotocol.wave.client.editor.content.ContentTextNode; import org.waveprotocol.wave.client.editor.content.FocusedContentRange; import org.waveprotocol.wave.client.editor.content.NodeEventRouter; import org.waveprotocol.wave.client.editor.testing.FakeEditorEvent; import org.waveprotocol.wave.client.scheduler.testing.FakeTimerService; import org.waveprotocol.wave.model.document.AnnotationBehaviour.CursorDirection; import org.waveprotocol.wave.model.document.util.FocusedPointRange; import org.waveprotocol.wave.model.document.util.Point; import org.waveprotocol.wave.model.document.util.PointRange; import java.util.ArrayList; import java.util.Arrays; import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Tests for EditorEventHandler. * * TODO(user): Use jmock when this no longer needs to be a gwt test case. * */ public class EditorEventHandlerGwtTest // extends JavaGWTTestCase { extends GWTTestCase { @Override public String getModuleName() { return "org.waveprotocol.wave.client.editor.event.Tests"; } // @Override protected void setUp() throws Exception { // super.setUp(); // super.getBrowserEmulator().setGWTcreateClass( // UserAgentStaticProperties.class, // UserAgentStaticProperties.FirefoxImpl.class); // super.getBrowserEmulator().setIsClient(false); // } private EditorEventHandler createEditorEventHandler(FakeRouter router, FakeEditorInteractor interactor, EditorEventsSubHandler subHandler) { return new EditorEventHandler(interactor, subHandler, router, true, true); } private EditorEventHandler createEditorEventHandler( FakeEditorInteractor interactor, EditorEventsSubHandler subHandler) { return new EditorEventHandler(interactor, subHandler, new FakeRouter(), true, true); } /** * Set up a FakeEditorInteractor with some default expectations. * * - allow any number of calls to getSelectionPoints, forceFlush, isEditing, * setCaret, normalizePoint and notifyingListeners * * - set default return values for some of these * * - normalizePoint returns the start of selection. This might be unexpected, * but is convenient for most cases. * * @param selection */ private FakeEditorInteractor setupFakeEditorInteractor(FocusedContentRange selection) { FakeEditorInteractor interactor = new FakeEditorInteractor(); interactor.call(FakeEditorInteractor.GET_SELECTION_POINTS).anyOf().returns(selection); interactor.call(FakeEditorInteractor.FORCE_FLUSH).anyOf(); interactor.call(FakeEditorInteractor.IS_EDITING).anyOf().returns(true); interactor.call(FakeEditorInteractor.SET_CARET).anyOf(); interactor.call(FakeEditorInteractor.NOTIFYING_LISTENERS).anyOf().returns(false); interactor.call(FakeEditorInteractor.CHECKPOINT).anyOf(); interactor.call(FakeEditorInteractor.HAS_CONTENT_SELECTION).anyOf().returns(true); interactor.call(FakeEditorInteractor.REBIAS_SELECTION).anyOf(); FocusedPointRange<Node> htmlSelection = null; if (selection != null) { interactor.call(FakeEditorInteractor.NORMALIZE_POINT).anyOf().returns(selection.getFocus()); htmlSelection = new FocusedPointRange<Node>( toNodePoint(selection.getAnchor()), toNodePoint(selection.getFocus())); } interactor.call(FakeEditorInteractor.GET_HTML_SELECTION).anyOf().returns(htmlSelection); return interactor; } private Point<Node> toNodePoint(Point<ContentNode> content) { if (content == null) { return null; } else { if (content.isInTextNode()) { return Point.inText(content.getContainer().getImplNodelet(), content.getTextOffset()); } else { Node post = content.getNodeAfter() == null ? null : content.getNodeAfter().getImplNodelet(); return Point.inElement(content.getContainer().getImplNodelet(), post); } } } private ContentElement newParaElement() { return new ContentElement("p", Document.get().createPElement(), null); } /** * Ensure that the event handler normalises the selection when necessary * Note that this is currently just for firefox. */ public void testNormalisesSelection() { FakeEditorEvent fakeEvent = FakeEditorEvent.create(KeySignalType.INPUT, 'a'); final Point<ContentNode> caret = Point.<ContentNode> end(newParaElement()); EditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); FakeEditorInteractor interactor = setupFakeEditorInteractor(new FocusedContentRange(caret)); final Point<ContentNode> newCaret = Point.<ContentNode>inText( new ContentTextNode(Document.get().createTextNode("hi"), null), 2); interactor.call(FakeEditorInteractor.NORMALIZE_POINT).returns(newCaret); interactor.call(FakeEditorInteractor.SET_CARET).nOf(1).withArgs(newCaret); interactor.call(FakeEditorInteractor.NOTIFYING_TYPING_EXTRACTOR).nOf(1).withArgs( newCaret, false); EditorEventHandler handler = createEditorEventHandler(interactor, subHandler); handler.handleEvent(fakeEvent); interactor.checkExpectations(); } /** * Firefox specific- * * Test that typing extractor is notifed of the correct caret location. */ public void testNormalTypingNotifiesExtractor() { FakeEditorEvent fakeEvent = FakeEditorEvent.create(KeySignalType.INPUT, 'a'); final Point<ContentNode> caret = Point.<ContentNode> end(newParaElement()); EditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); FakeEditorInteractor interactor = setupFakeEditorInteractor(new FocusedContentRange(caret)); EditorEventHandler handler = createEditorEventHandler(interactor, subHandler); interactor.call(FakeEditorInteractor.NOTIFYING_TYPING_EXTRACTOR).nOf(1).withArgs(caret, false); boolean cancel = handler.handleEvent(fakeEvent); assertFalse("Should allow typing event", cancel); interactor.checkExpectations(); } /** * Tests that events handled by listeners exits the EditorEventHandler and * does not continue triggering other handler methods. */ public void testEventsHandledByListenerExitsHandler() { FakeEditorEvent fakeEvent = FakeEditorEvent.create(KeySignalType.INPUT, 'a'); final Point<ContentNode> caret = Point.<ContentNode> end(newParaElement()); FakeEditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); FakeEditorInteractor interactor = setupFakeEditorInteractor(new FocusedContentRange(caret)); EditorEventHandler handler = createEditorEventHandler(interactor, subHandler); interactor.call(FakeEditorInteractor.NOTIFYING_LISTENERS).nOf(1).withArgs(fakeEvent).returns( true); boolean cancel = handler.handleEvent(fakeEvent); assertFalse("Should not cancel events even if handled by listeners", cancel); interactor.checkExpectations(); subHandler.checkExpectations(); } ContentElement newElement() { return new ContentElement("p", Document.get().createPElement(), null); } /** * Tests mouse event triggers on node. */ public void testMouseEventsTriggeredOnNode() { EditorEvent mouseSignal = FakeSignalEvent.createClick(FakeEditorEvent.ED_FACTORY, null); ContentElement fakeContentElement = newElement(); final Point<ContentNode> caret = Point.<ContentNode> end(fakeContentElement); FakeRouter router = new FakeRouter(); FakeEditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); FakeEditorInteractor interactor = setupFakeEditorInteractor(new FocusedContentRange(caret)); EditorEventHandler handler = createEditorEventHandler(router, interactor, subHandler); interactor.call(FakeEditorInteractor.FIND_ELEMENT_WRAPPER).nOf(1).withArgs( mouseSignal.getTarget()).returns(fakeContentElement); router.ctx.call(FakeRouter.HANDLE_CLICK).nOf(1).withArgs(mouseSignal) .returns(true); interactor.call(FakeEditorInteractor.CLEAR_ANNOTATIONS).nOf(1); boolean cancel = handler.handleEvent(mouseSignal); router.ctx.checkExpectations(); interactor.checkExpectations(); subHandler.checkExpectations(); assertEquals("Allow iff event allows browser default", mouseSignal.shouldAllowBrowserDefault(), !cancel); } /** * Tests that an unhandled accelerator should trigger subhandlers and cancel * browser default if unhandled and non-whitelisted. */ public void testUnhandledAcceleratorKeys() { // Always cancel INS key EditorEvent insKeyEvent = FakeEditorEvent.create( KeySignalType.NOEFFECT, EventWrapper.KEY_INSERT); testUnhandledAcceleratorHelper(insKeyEvent, true, true); // test tab, a non-metesque accelerator EditorEvent tabPressEvent = createTabSignal(); testUnhandledAcceleratorHelper(tabPressEvent, true, true); // test an allowable combo EditorEvent ctrlInsert = createCtrlComboKeyPress(KeySignalType.NOEFFECT, EventWrapper.KEY_INSERT); if (UserAgent.isLinux() || UserAgent.isWin()) { testUnhandledAcceleratorHelper(ctrlInsert, true, false); } else { testUnhandledAcceleratorHelper(ctrlInsert, true, true); } // test something that is not whitelisted EditorEvent ctrlY = createCtrlComboKeyPress(KeySignalType.INPUT, 'Y'); testUnhandledAcceleratorHelper(ctrlY, true, true); testUnhandledAcceleratorHelper(ctrlY, false, false); } private void testUnhandledAcceleratorHelper(EditorEvent editorEvent, boolean cancelNonWhitelistedCombos, boolean expectCancel) { final Point<ContentNode> start = Point.<ContentNode> end(newParaElement()); final Point<ContentNode> end = Point.<ContentNode> end(newParaElement()); FocusedContentRange selection = new FocusedContentRange(start, end); ContentRange range = selection.asOrderedRange(true); FakeEditorInteractor interactor = setupFakeEditorInteractor(selection); FakeEditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); FakeRouter router = new FakeRouter(); EditorEventHandler handler = new EditorEventHandler(new FakeTimerService(), interactor, subHandler, router, cancelNonWhitelistedCombos, true); subHandler.call(FakeEditorEventsSubHandler.HANDLE_COMMAND).nOf(1).withArgs(editorEvent) .returns(false); subHandler.call(FakeEditorEventsSubHandler.HANDLE_BLOCK_LEVEL_COMMANDS).nOf(1).withArgs( editorEvent, range).returns(false); subHandler.call(FakeEditorEventsSubHandler.HANDLE_RANGE_KEY_COMBO).nOf(1).withArgs(editorEvent, range).returns(false); boolean cancel = handler.handleEvent(editorEvent); interactor.checkExpectations(); subHandler.checkExpectations(); assertEquals("Cancel does not match expected", expectCancel, cancel); } /** * Test that handleLeft/Right are triggered on the correct node. */ public void testHandleLeftRightTriggeredOnNode() { FakeEditorEvent fakeEvent = FakeEditorEvent.create(KeySignalType.NAVIGATION, KeyCodes.KEY_LEFT); FakeRouter router = new FakeRouter(); ContentElement fakeContentElement = newElement(); final Point<ContentNode> caret = Point.<ContentNode> end(fakeContentElement); EditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); FakeEditorInteractor interactor = setupFakeEditorInteractor(new FocusedContentRange(caret)); EditorEventHandler handler = createEditorEventHandler(router, interactor, subHandler); router.ctx.call(FakeRouter.HANDLE_LEFT).nOf(1).withArgs(fakeEvent).returns( true); interactor.call(FakeEditorInteractor.CLEAR_ANNOTATIONS).nOf(1); boolean cancel1 = handler.handleEvent(fakeEvent); router.ctx.checkExpectations(); assertEquals(!fakeEvent.shouldAllowBrowserDefault(), cancel1); router.ctx.reset(); FakeEditorEvent fakeEvent2 = FakeEditorEvent.create(KeySignalType.NAVIGATION, KeyCodes.KEY_RIGHT); router.ctx.call(FakeRouter.HANDLE_RIGHT).nOf(1).withArgs(fakeEvent2) .returns(true); boolean cancel2 = handler.handleEvent(fakeEvent2); assertEquals(!fakeEvent.shouldAllowBrowserDefault(), cancel2); router.ctx.checkExpectations(); } public void testDeleteWithRangeSelectedDeletesRange() { FakeEditorEvent fakeEvent = FakeEditorEvent.create(KeySignalType.DELETE, KeyCodes.KEY_LEFT); //Event event = Document.get().createKeyPressEvent( // false, false, false, false, KeyCodes.KEY_BACKSPACE, 0).cast(); Text input = Document.get().createTextNode("ABCDE"); ContentNode node = new ContentTextNode(input, null); final Point<ContentNode> start = Point.inText(node, 1); final Point<ContentNode> end = Point.inText(node, 4); FakeEditorInteractor interactor = setupFakeEditorInteractor( new FocusedContentRange(start, end)); EditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); EditorEventHandler handler = createEditorEventHandler(interactor, subHandler); interactor.call(FakeEditorInteractor.DELETE_RANGE).nOf(1).withArgs( start, end, false).returns(start); handler.handleEvent(fakeEvent); interactor.checkExpectations(); } /** * Firefox specific- Test that typing with selection deletes the selected * range and reports correct cursor to typing extractor. */ public void testTypingWithRangeSelectedDeletesRangeAndNotifiesExtrator() { FakeEditorEvent fakeEvent = FakeEditorEvent.create(KeySignalType.INPUT, 'a'); final Point<ContentNode> start = Point.<ContentNode> end(newParaElement()); final Point<ContentNode> end = Point.<ContentNode> end(newParaElement()); PointRange<ContentNode> deleteRangeReturnValue = new PointRange<ContentNode>(end); FakeEditorInteractor interactor = setupFakeEditorInteractor( new FocusedContentRange(start, end)); EditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); EditorEventHandler handler = createEditorEventHandler(interactor, subHandler); interactor.call(FakeEditorInteractor.DELETE_RANGE).nOf(1).withArgs( start, end, true).returns(start); interactor.call(FakeEditorInteractor.NOTIFYING_TYPING_EXTRACTOR).nOf(1).withArgs( deleteRangeReturnValue.getSecond(), true); boolean cancel = handler.handleEvent(fakeEvent); interactor.checkExpectations(); assertFalse(cancel); } /** * Tests backspace, shift backspace and delete. */ public void testBackspaceDeleteVariants() { // normal backspace handled/unhandled testBackspaceDeleteHelper(true, true, true); testBackspaceDeleteHelper(true, false, true); // shift backspace handled/unhandled testBackspaceDeleteHelper(true, true, false); testBackspaceDeleteHelper(true, false, false); // normal delete handled/unhandled testBackspaceDeleteHelper(false, true, false); testBackspaceDeleteHelper(false, false, false); // TODO(user): test shift delete elsewhere. Shift-delete is a special case, // as it means cut in windows/linux and normal delete on mac. } private void testBackspaceDeleteHelper(boolean isBackspace, boolean handled, boolean isShiftDown) { EditorEvent signal = FakeSignalEvent.createKeyPress(FakeEditorEvent.ED_FACTORY, KeySignalType.DELETE, isBackspace ? KeyCodes.KEY_BACKSPACE : KeyCodes.KEY_DELETE, isShiftDown ? EnumSet.of(KeyModifier.SHIFT) : null); FakeRouter router = new FakeRouter(); ContentElement fakeContentElement = newElement(); final Point<ContentNode> caret = Point.<ContentNode> end(fakeContentElement); FakeEditorInteractor interactor = setupFakeEditorInteractor(new FocusedContentRange(caret)); EditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); EditorEventHandler handler = createEditorEventHandler(router, interactor, subHandler); // Because we cannot override handleBackspace in ContentElement, we test at // handleBackspaceAtBeginning instead. We have to be sure that the caret is // at beginning or it wouldn't work, so this is a bit fragile. assertTrue(ContentPoint.fromPoint(caret).isAtBeginning()); if (isBackspace) { router.ctx.call(FakeRouter.HANDLE_BACKSPACE_AT_BEGINNING).nOf(1) .withArgs(signal).returns(handled); } else { router.ctx.call(FakeRouter.HANDLE_DELETE).nOf(1).withArgs(signal) .returns(handled); } boolean cancel = handler.handleEvent(signal); router.ctx.checkExpectations(); assertEquals("Backspace should be cancelled if handled", handled, cancel); } /** * Test that collapsed keycombos gets routed to block level handling unless it * is caught by handleCommand */ public void testRouteToCollapsedKeyCombo() { testRouteToCollapsedKeyComboHelper(createTabSignal()); testRouteToCollapsedKeyComboHelper(createCtrlComboKeyPress(KeySignalType.INPUT, 'b')); } private EditorEvent createTabSignal() { return FakeSignalEvent.createKeyPress(FakeEditorEvent.ED_FACTORY, KeySignalType.INPUT, KeyCodes.KEY_TAB, null); } private EditorEvent createCtrlComboKeyPress(KeySignalType type, int c) { return FakeSignalEvent.createKeyPress(FakeEditorEvent.ED_FACTORY, type, c, EnumSet.of(KeyModifier.CTRL)); } private void testRouteToCollapsedKeyComboHelper(EditorEvent signal) { testRouteToCollapsedKeyComboHelperInner(signal, false, true); testRouteToCollapsedKeyComboHelperInner(signal, true, false); testRouteToCollapsedKeyComboHelperInner(signal, false, false); } private void testRouteToCollapsedKeyComboHelperInner(EditorEvent tabSignal, boolean isHandledCommand, boolean isHandledBlockLevelCommand) { final Point<ContentNode> caret = Point.<ContentNode> end(newParaElement()); FocusedContentRange selection = new FocusedContentRange(caret); ContentRange range = selection.asOrderedRange(true); FakeEditorInteractor interactor = setupFakeEditorInteractor(selection); FakeEditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); EditorEventHandler handler = createEditorEventHandler(interactor, subHandler); subHandler.call(FakeEditorEventsSubHandler.HANDLE_COMMAND).nOf(1).withArgs(tabSignal).returns( isHandledCommand); if (!isHandledCommand) { subHandler.call(FakeEditorEventsSubHandler.HANDLE_BLOCK_LEVEL_COMMANDS).nOf(1).withArgs( tabSignal, range).returns(isHandledBlockLevelCommand); if (!isHandledBlockLevelCommand) { // Stop it here by returning true, test lower down commands in other // methods. subHandler.call(FakeEditorEventsSubHandler.HANDLE_COLLAPSED_KEY_COMBO).nOf(1).withArgs( tabSignal, selection.getFocus()).returns(true); } } boolean cancel = handler.handleEvent(tabSignal); interactor.checkExpectations(); subHandler.checkExpectations(); assertTrue("Handled commands should be cancelled", cancel); } /** * Test that ranged combos are routed correctly. */ public void testRouteToRangeKeyCombo() { EditorEvent editorEvent = createCtrlComboKeyPress(KeySignalType.INPUT, 'b'); final Point<ContentNode> start = Point.<ContentNode> end(newParaElement()); final Point<ContentNode> end = Point.<ContentNode> end(newParaElement()); FocusedContentRange selection = new FocusedContentRange(start, end); ContentRange range = selection.asOrderedRange(true); FakeEditorInteractor interactor = setupFakeEditorInteractor(selection); FakeEditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); EditorEventHandler handler = createEditorEventHandler(interactor, subHandler); subHandler.call(FakeEditorEventsSubHandler.HANDLE_COMMAND).nOf(1).withArgs(editorEvent) .returns(false); subHandler.call(FakeEditorEventsSubHandler.HANDLE_BLOCK_LEVEL_COMMANDS).nOf(1).withArgs( editorEvent, range).returns(false); subHandler.call(FakeEditorEventsSubHandler.HANDLE_RANGE_KEY_COMBO).nOf(1).withArgs(editorEvent, range).returns(true); boolean cancel = handler.handleEvent(editorEvent); interactor.checkExpectations(); subHandler.checkExpectations(); assertTrue(cancel); } /** * TODO(user): Test this for mac, mac has alt-backspace and alt-delete * instead. * * NOTE(user): This isn't testing the final intended behaviour. We'd * like ctrl-backspace and ctrl-delete to actually delete words. However, * for now we are ensuring that we cancel to avoid editor becoming inconsistent. */ public void testNonCharacterMoveUnitIsCancelled() { // test ctrl-backspace EditorEvent ctrlBackspaceEvent = createCtrlComboKeyPress(KeySignalType.DELETE, KeyCodes.KEY_BACKSPACE); testNonCharacterMoveUnitIsCancelledHelper(ctrlBackspaceEvent, true); // test ctrl-delete EditorEvent ctrlDeleteEvent = createCtrlComboKeyPress(KeySignalType.DELETE, KeyCodes.KEY_DELETE); testNonCharacterMoveUnitIsCancelledHelper(ctrlDeleteEvent, false); } private void testNonCharacterMoveUnitIsCancelledHelper(EditorEvent event, boolean backspace) { final Point<ContentNode> caret = Point.<ContentNode> end(newParaElement()); FocusedContentRange selection = new FocusedContentRange(caret); FakeEditorInteractor interactor = setupFakeEditorInteractor(selection); FakeEditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); EditorEventHandler handler = createEditorEventHandler(interactor, subHandler); if (backspace) { interactor.call(FakeEditorInteractor.DELETE_WORD_ENDING_AT).withArgs(caret).anyOf(); } else { interactor.call(FakeEditorInteractor.DELETE_WORD_STARTING_AT).withArgs(caret).anyOf(); } boolean cancel = handler.handleEvent(event); interactor.checkExpectations(); subHandler.checkExpectations(); assertEquals("Cancel does not match expected", true, cancel); } /** * Test that paste events are routed correctly. */ public void testRoutePasteEvent() { final Point<ContentNode> caret = Point.<ContentNode> end(newParaElement()); FocusedContentRange selection = new FocusedContentRange(caret); FakeEditorEvent pasteEvent = FakeEditorEvent.createPasteEvent(); FakeEditorInteractor interactor = setupFakeEditorInteractor(selection); FakeEditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); EditorEventHandler handler = createEditorEventHandler(interactor, subHandler); subHandler.call(FakeEditorEventsSubHandler.HANDLE_PASTE).nOf(1).withArgs(pasteEvent).returns( false); boolean cancel = handler.handleEvent(pasteEvent); interactor.checkExpectations(); subHandler.checkExpectations(); assertFalse(cancel); } public void testCompositionEventsChangeState() { FakeEditorEvent[] events = FakeEditorEvent.compositionSequence(2); FakeEditorEvent keyEvent1 = FakeEditorEvent.create(KeySignalType.INPUT, 'a'); FakeEditorEvent keyEvent2 = FakeEditorEvent.create(KeySignalType.INPUT, SignalKeyLogic.IME_CODE); final Point<ContentNode> caret = Point.<ContentNode> end(newParaElement()); EditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); FakeEditorInteractor interactor = setupFakeEditorInteractor(new FocusedContentRange(caret)); EditorEventHandler handler = createEditorEventHandler(interactor, subHandler); interactor.call(FakeEditorInteractor.COMPOSITION_START).nOf(1); interactor.call(FakeEditorInteractor.COMPOSITION_UPDATE).nOf(2); interactor.call(FakeEditorInteractor.COMPOSITION_END).nOf(1); interactor.call(FakeEditorInteractor.NOTIFYING_TYPING_EXTRACTOR).nOf(1).anyArgs(); assertEquals(EditorEventHandler.State.NORMAL, handler.getState()); boolean cancel; cancel = handler.handleEvent(events[0]); assertFalse("Should allow composition start event", cancel); assertEquals(EditorEventHandler.State.COMPOSITION, handler.getState()); cancel = handler.handleEvent(keyEvent1); assertFalse("Should allow regular keycode key event", cancel); cancel = handler.handleEvent(events[1]); assertFalse("Should allow composition update event", cancel); cancel = handler.handleEvent(keyEvent2); assertFalse("Should allow ime keycode key event", cancel); cancel = handler.handleEvent(events[2]); assertFalse("Should allow 2nd composition update event", cancel); assertEquals(EditorEventHandler.State.COMPOSITION, handler.getState()); cancel = handler.handleEvent(events[3]); assertFalse("Should allow composition end event", cancel); assertEquals(EditorEventHandler.State.NORMAL, handler.getState()); cancel = handler.handleEvent(keyEvent1); assertFalse("Should allow regular keycode key event", cancel); // Note: explicitly should only call the key event handling code // (resulting in notifying the typing extractor when in a normal // state, not during composition). interactor.checkExpectations(); } public void testIsAccelerator() { // Test alt+input and alt+shift+input keys - These are normal input on mac, // and accelerators on // other platforms for (int c = 'a'; c <= 'z'; c++) { SignalEvent signal = FakeSignalEvent.createKeyPress(KeySignalType.INPUT, c, EnumSet.of(KeyModifier.ALT)); // mac assertFalse(EditorEventHandler.isAcceleratorInner(signal, true, false)); assertFalse(EditorEventHandler.isAcceleratorInner(signal, true, true)); // other platforms assertTrue(EditorEventHandler.isAcceleratorInner(signal, false, false)); assertTrue(EditorEventHandler.isAcceleratorInner(signal, false, true)); } // Test a few others such as `, - and ; // mac String otherInputKeys = "`-;"; for (char c : otherInputKeys.toCharArray()) { SignalEvent signal = FakeSignalEvent.createKeyPress(KeySignalType.INPUT, c, EnumSet.of(KeyModifier.ALT)); assertFalse(EditorEventHandler.isAcceleratorInner(signal, true, false)); assertFalse(EditorEventHandler.isAcceleratorInner(signal, true, true)); // with alt+shift SignalEvent altShift = FakeSignalEvent.createKeyPress(KeySignalType.INPUT, c, EnumSet.of(KeyModifier.ALT, KeyModifier.SHIFT)); assertFalse(EditorEventHandler.isAcceleratorInner(altShift, true, false)); assertFalse(EditorEventHandler.isAcceleratorInner(altShift, true, true)); } // Test ctrl/meta keys- these are accelerators unless they are navigation // related assertIsAcceleratorInner(FakeSignalEvent.createKeyPress(KeySignalType.INPUT, 'c', EnumSet.of(KeyModifier.CTRL)), true); assertIsAcceleratorInner(FakeSignalEvent.createKeyPress(KeySignalType.INPUT, 'c', EnumSet.of(KeyModifier.META)), true); assertIsAcceleratorInner(FakeSignalEvent.createKeyPress(KeySignalType.NAVIGATION, KeyCodes.KEY_LEFT, EnumSet.of(KeyModifier.CTRL)), false); // Test shift-delete is an accelerator on windows/linux assertTrue(EditorEventHandler.isAcceleratorInner(FakeSignalEvent.createKeyPress( KeySignalType.DELETE, KeyCodes.KEY_DELETE, EnumSet.of(KeyModifier.SHIFT)), false, true)); } private void assertIsAcceleratorInner(SignalEvent evt, boolean expected) { assertEquals(expected, EditorEventHandler.isAcceleratorInner(evt, false, false)); assertEquals(expected, EditorEventHandler.isAcceleratorInner(evt, false, true)); assertEquals(expected, EditorEventHandler.isAcceleratorInner(evt, true, false)); assertEquals(expected, EditorEventHandler.isAcceleratorInner(evt, true, true)); } /** * Checks that events are cancelled/permitted properly when selection is lost, * both when there was initial content selection, and when there wasn't. */ public void testSelectionLostCancelling() { /// part one - start with no content selection FakeEditorInteractor interactor = new FakeEditorInteractor(); EditorEventsSubHandler subHandler = new FakeEditorEventsSubHandler(); EditorEventHandler handler = createEditorEventHandler(interactor, subHandler); // should check for selection, the flush, realise selection is lost, and get a null selection. interactor.call(FakeEditorInteractor.NOTIFYING_LISTENERS).nOf(1).returns(false); interactor.call(FakeEditorInteractor.HAS_CONTENT_SELECTION).nOf(1).returns(false); interactor.call(FakeEditorInteractor.FORCE_FLUSH).nOf(1); interactor.call(FakeEditorInteractor.GET_SELECTION_POINTS).nOf(1).returns(null); // didn't have content selection, so don't cancel. FakeEditorEvent keyEvent = FakeEditorEvent.createPasteEvent(); assertFalse(handler.handleEvent(keyEvent)); /// part two - now have content selection // should check for selection, the flush, realise selection is lost, and get a null selection. interactor.call(FakeEditorInteractor.NOTIFYING_LISTENERS).nOf(1).returns(false); interactor.call(FakeEditorInteractor.HAS_CONTENT_SELECTION).nOf(1).returns(true); interactor.call(FakeEditorInteractor.FORCE_FLUSH).nOf(1); interactor.call(FakeEditorInteractor.GET_SELECTION_POINTS).nOf(1).returns(null); // and again, this time cancel! assertTrue(handler.handleEvent(keyEvent)); } private static class FakeEditorInteractor extends MockContext implements EditorInteractor { public static final MethodID DELETE_RANGE = new MethodID("deleteRange"); public static final MethodID INSERT_TEXT = new MethodID("insertText"); public static final MethodID FIND_ELEMENT_WRAPPER = new MethodID("findElementWrapper"); public static final MethodID FORCE_FLUSH = new MethodID("forceFlush"); public static final MethodID GET_SELECTION_POINTS = new MethodID("getSelectionPoints"); public static final MethodID HAS_CONTENT_SELECTION = new MethodID("hasContentSelection"); public static final MethodID IS_EDITING = new MethodID("isEditing"); public static final MethodID NORMALIZE_POINT = new MethodID("normalizePoint"); public static final MethodID NOTIFYING_LISTENERS = new MethodID("notifyingListeners"); public static final MethodID NOTIFYING_TYPING_EXTRACTOR = new MethodID("notifyingTypingExtractor"); public static final MethodID SET_CARET = new MethodID("setCaret"); public static final MethodID CLEAR_ANNOTATIONS = new MethodID("clearCaretAnnotations"); public static final MethodID NOTE_WEOLHO = new MethodID("noteWebkitEndOfLineHackOccurred"); public static final MethodID GET_HTML_SELECTION = new MethodID("getHtmlSelection"); private static final MethodID DELETE_WORD_ENDING_AT = new MethodID("deleteWordEndingAt"); private static final MethodID DELETE_WORD_STARTING_AT = new MethodID("deleteWordStartingAt"); private static final MethodID COMPOSITION_START = new MethodID("compositionStart"); private static final MethodID COMPOSITION_END = new MethodID("compositionEnd"); private static final MethodID COMPOSITION_UPDATE = new MethodID("compositionUpdate"); private static final MethodID CHECKPOINT = new MethodID("checkpoint"); private static final MethodID REBIAS_SELECTION = new MethodID("rebiasSelection"); @Override public Point<ContentNode> deleteRange(Point<ContentNode> fst, Point<ContentNode> second, boolean isReplace) { return methodCalledHelper(DELETE_RANGE, fst, second, isReplace); } @Override public Point<ContentNode> insertText(Point<ContentNode> at, String text, boolean isReplace) { return methodCalledHelper(INSERT_TEXT, at, text, isReplace); } @Override public ContentElement findElementWrapper(Element target) { return methodCalledHelper(FIND_ELEMENT_WRAPPER, target); } @Override public void forceFlush() { methodCalledHelper(FORCE_FLUSH); } @Override public FocusedContentRange getSelectionPoints() { return methodCalledHelper(GET_SELECTION_POINTS); } @Override public boolean selectionIsOrdered() { // TODO(danilatos) return true; } @Override public boolean isExpectingMutationEvents() { // TODO(user) return false; } @Override public boolean shouldIgnoreMutations() { // TODO(user) return false; } @Override public boolean isTyping() { // TODO(user) return false; } @Override public FocusedPointRange<Node> getHtmlSelection() { return methodCalledHelper(GET_HTML_SELECTION); } @Override public boolean hasContentSelection() { return methodCalledHelperBoolean(HAS_CONTENT_SELECTION); } @Override public boolean isEditing() { return methodCalledHelperBoolean(IS_EDITING); } @Override public Point<ContentNode> normalizePoint(Point<ContentNode> caret) { return methodCalledHelper(NORMALIZE_POINT, caret); } @Override public boolean notifyListeners(SignalEvent event) { return methodCalledHelperBoolean(NOTIFYING_LISTENERS, event); } @Override public boolean notifyTypingExtractor(Point<ContentNode> caret, boolean useHtmlCaret, boolean isReplace) { Boolean ret = methodCalledHelperBoolean(NOTIFYING_TYPING_EXTRACTOR, caret, isReplace); return ret != null ? ret : false; } @Override public void setCaret(Point<ContentNode> caret) { methodCalledHelper(SET_CARET, caret); } @Override public void noteWebkitEndOfLinkHackOccurred(Text textNode) { methodCalledHelper(NOTE_WEOLHO, textNode); } @Override public void clearCaretAnnotations() { methodCalledHelper(CLEAR_ANNOTATIONS); } @Override public void deleteWordEndingAt(Point<ContentNode> caret) { methodCalledHelper(DELETE_WORD_ENDING_AT, caret); } @Override public void deleteWordStartingAt(Point<ContentNode> caret) { methodCalledHelper(DELETE_WORD_STARTING_AT, caret); } @Override public void compositionUpdate() { methodCalledHelper(COMPOSITION_UPDATE); } @Override public FocusedContentRange compositionEnd() { return methodCalledHelper(COMPOSITION_END); } @Override public void compositionStart(Point<ContentNode> caret) { methodCalledHelper(COMPOSITION_START); } @Override public void checkpoint(FocusedContentRange selection) { methodCalledHelper(CHECKPOINT); } @Override public void rebiasSelection(CursorDirection defaultDirection) { methodCalledHelper(REBIAS_SELECTION); } } private static class FakeEditorEventsSubHandler extends MockContext implements EditorEventsSubHandler { public static final MethodID HANDLE_BLOCK_LEVEL_COMMANDS = new MethodID("handleBlockLevelCommands"); public static final MethodID HANDLE_COLLAPSED_KEY_COMBO = new MethodID("handleCollapsedKeyCombo"); public static final MethodID HANDLE_COMMAND = new MethodID("handleCommand"); public static final MethodID HANDLE_CUT = new MethodID("handleCut"); public static final MethodID HANDLE_COPY = new MethodID("handleCopy"); public static final MethodID HANDLE_DOM_MUTATION = new MethodID("handleDOMMutation"); public static final MethodID HANDLE_PASTE = new MethodID("handlePaste"); public static final MethodID HANDLE_RANGE_KEY_COMBO = new MethodID("handleRangeKeyCombo"); public static final MethodID HANDLE_SUBMIT = new MethodID("handleSubmit"); @Override public boolean handleBlockLevelCommands(EditorEvent event, ContentRange selection) { return methodCalledHelperBoolean(HANDLE_BLOCK_LEVEL_COMMANDS, event, selection); } @Override public boolean handleCollapsedKeyCombo(EditorEvent event, Point<ContentNode> caret) { return methodCalledHelperBoolean(HANDLE_COLLAPSED_KEY_COMBO, event, caret); } @Override public boolean handleCommand(EditorEvent event) { return methodCalledHelperBoolean(HANDLE_COMMAND, event); } @Override public boolean handleCut(EditorEvent event) { return methodCalledHelperBoolean(HANDLE_CUT, event); } @Override public void handleDomMutation(SignalEvent event) { methodCalledHelperBoolean(HANDLE_DOM_MUTATION, event); } @Override public boolean handlePaste(EditorEvent event) { return methodCalledHelperBoolean(HANDLE_PASTE, event); } @Override public boolean handleRangeKeyCombo(EditorEvent event, ContentRange selection) { return methodCalledHelperBoolean(HANDLE_RANGE_KEY_COMBO, event, selection); } @Override public boolean handleCopy(EditorEvent event) { return methodCalledHelperBoolean(HANDLE_COPY, event); } } private static class FakeRouter extends NodeEventRouter { public static final MethodID HANDLE_CLICK = new MethodID("handleClick"); public static final MethodID HANDLE_BACKSPACE_AT_BEGINNING = new MethodID("handleBackspaceAtBeginning"); public static final MethodID HANDLE_DELETE = new MethodID("handleDelete"); public static final MethodID HANDLE_LEFT = new MethodID("handleLeft"); public static final MethodID HANDLE_RIGHT = new MethodID("handleRight"); public MockContext ctx = new MockContext(); @Override public boolean handleClick(ContentNode node, EditorEvent event) { return ctx.methodCalledHelperBoolean(HANDLE_CLICK, event); } @Override public boolean handleBackspaceAtBeginning(ContentNode node, EditorEvent event) { return ctx.methodCalledHelperBoolean(HANDLE_BACKSPACE_AT_BEGINNING, event); } @Override public boolean handleDelete(ContentNode node, EditorEvent event) { return ctx.methodCalledHelperBoolean(HANDLE_DELETE, event); } @Override public boolean handleLeft(ContentNode node, EditorEvent event) { return ctx.methodCalledHelperBoolean(HANDLE_LEFT, event); } @Override public boolean handleRight(ContentNode node, EditorEvent event) { return ctx.methodCalledHelperBoolean(HANDLE_RIGHT, event); } } /** * Poor man's jmock (Can't use jmock in gwt as that relies on reflection): * * These are probably generally useful for cases where jUnit tests are not * possible without huge refactoring. * * It seems like this might be generally useful, but I'd rather keep it here * until I can doc and polish it. */ private static class MethodContext { private Object returnValue; private boolean ignoreCallCount = true; private int expectedCalls; private int actualCalls; // Ignore args for this method. private boolean ignoreArgs = true; private final List<Object> actualArgs = new ArrayList<Object>(); private List<Object> expectedArgs; public MethodContext() { } public MethodContext nOf(int n) { this.ignoreCallCount = false; this.expectedCalls = n; return this; } public MethodContext anyOf() { this.ignoreCallCount = true; return this; } public MethodContext withArgs(Object... args) { this.ignoreArgs = false; expectedArgs = Arrays.asList(args); return this; } public MethodContext anyArgs() { this.ignoreArgs = true; return this; } public MethodContext returns(Object returnValue) { this.returnValue = returnValue; return this; } } private static class MethodID { public final String name; private MethodID(String name) { this.name = name; } } private static class MockContext { Map<MethodID, MethodContext> methodContexts = new HashMap<MethodID, MethodContext>(); /** * NOTE(user): We need this because javac cannot infer type correctly. * * @param methodId * @param args * @return the predefined return value. */ Boolean methodCalledHelperBoolean(MethodID methodId, Object... args) { return methodCalledHelper(methodId, args); } @SuppressWarnings("unchecked") <T> T methodCalledHelper(MethodID methodId, Object... args) { MethodContext ctx = methodContexts.get(methodId); assertNotNull("no calls to " + methodId.name + " expected", ctx); ctx.actualCalls++; for (Object arg : args) { ctx.actualArgs.add(arg); } return (T) ctx.returnValue; } /** * Expect a call to method corresponding to methodId * * @param methodId */ MethodContext call(MethodID methodId) { MethodContext retVal = new MethodContext(); methodContexts.put(methodId, retVal); return retVal; } /** * Check that the defined expectations are satisfied. */ public void checkExpectations() { for (MethodID method : methodContexts.keySet()) { MethodContext m = methodContexts.get(method); if (!m.ignoreCallCount) { assertEquals(method.name + ": Expected number of calls does not match actual number of calls for method: ", m.expectedCalls, m.actualCalls); } if (!m.ignoreArgs) { assertEquals(method.name + ": args do not match", m.expectedArgs, m.actualArgs); } } } /** * Resets expectations and count/args of methods called. */ public void reset() { methodContexts.clear(); } } }