Bug 1337133 - Dispatch pointerMove action for mouse; r=ato+446296,jgraham
authorMaja Frydrychowicz <mjzffr@gmail.com>
Wed, 22 Feb 2017 16:24:44 -0500
changeset 374059 356602cac222cf748f47d3f23a171d2227c9c666
parent 374058 aba48a32380e0c1fffd26dee09b2eb7970ac0b29
child 374060 47ac31461e9dc8387eabfee6874add93fa39c247
push id10863
push userjlorenzo@mozilla.com
push dateMon, 06 Mar 2017 23:02:23 +0000
treeherdermozilla-aurora@0931190cd725 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersato
bugs1337133, 446296
milestone54.0a1
Bug 1337133 - Dispatch pointerMove action for mouse; r=ato+446296,jgraham MozReview-Commit-ID: 9CIGusZVz7w
testing/marionette/action.js
testing/marionette/test_action.js
--- a/testing/marionette/action.js
+++ b/testing/marionette/action.js
@@ -988,16 +988,50 @@ action.computeTickDuration = function(ti
     if (affectsWallClockTime && a.duration) {
       max = Math.max(a.duration, max);
     }
   }
   return max;
 };
 
 /**
+ * Compute viewport coordinates of pointer target based on given origin.
+ *
+ * @param {action.Action} a
+ *     Action that specifies pointer origin and x and y coordinates of target.
+ * @param {action.InputState} inputState
+ *     Input state that specifies current x and y coordinates of pointer.
+ * @param {Map.<string, number>=} center
+ *     Object representing x and y coordinates of an element center-point.
+ *     This is only used if |a.origin| is a web element reference.
+ *
+ * @return {Map.<string, number>}
+ *     x and y coordinates of pointer destination.
+ */
+action.computePointerDestination = function(a, inputState, center = undefined) {
+  let {x, y} = a;
+  switch (a.origin) {
+    case action.PointerOrigin.Viewport:
+      break;
+    case action.PointerOrigin.Pointer:
+      x += inputState.x;
+      y += inputState.y;
+      break;
+    default:
+      // origin represents web element
+      assert.defined(center);
+      assert.in("x", center);
+      assert.in("y", center);
+      x += center.x;
+      y += center.y;
+  }
+  return {"x": x, "y": y};
+};
+
+/**
  * Create a closure to use as a map from action definitions to Promise events.
  *
  * @param {number} tickDuration
  *     Duration in milliseconds of this tick.
  * @param {element.Store} seenEls
  *     Element store.
  * @param {?} container
  *     Object with |frame| attribute of type |nsIDOMWindow|.
@@ -1021,16 +1055,18 @@ function toEvents(tickDuration, seenEls,
 
       case action.PointerDown:
         return dispatchPointerDown(a, inputState, container.frame);
 
       case action.PointerUp:
         return dispatchPointerUp(a, inputState, container.frame);
 
       case action.PointerMove:
+        return dispatchPointerMove(a, inputState, tickDuration, seenEls, container);
+
       case action.PointerCancel:
         throw new UnsupportedOperationError();
 
       case action.Pause:
         return dispatchPause(a, tickDuration);
     }
   };
 }
@@ -1122,17 +1158,17 @@ function dispatchPointerDown(a, inputSta
     switch (inputState.subtype) {
       case action.PointerType.Mouse:
         let mouseEvent = new action.Mouse("mousedown", a.button);
         mouseEvent.update(inputState);
         event.synthesizeMouseAtPoint(inputState.x, inputState.y, mouseEvent, win);
         break;
       case action.PointerType.Pen:
       case action.PointerType.Touch:
-        throw new UnsupportedOperationError("Only 'mouse' pointer type is supported.");
+        throw new UnsupportedOperationError("Only 'mouse' pointer type is supported");
         break;
       default:
         throw new TypeError(`Unknown pointer type: ${inputState.subtype}`);
     }
     resolve();
   });
 }
 
@@ -1161,25 +1197,121 @@ function dispatchPointerUp(a, inputState
       case action.PointerType.Mouse:
         let mouseEvent = new action.Mouse("mouseup", a.button);
         mouseEvent.update(inputState);
         event.synthesizeMouseAtPoint(inputState.x, inputState.y,
             mouseEvent, win);
         break;
       case action.PointerType.Pen:
       case action.PointerType.Touch:
-        throw new UnsupportedOperationError("Only 'mouse' pointer type is supported.");
+        throw new UnsupportedOperationError("Only 'mouse' pointer type is supported");
       default:
         throw new TypeError(`Unknown pointer type: ${inputState.subtype}`);
     }
     resolve();
   });
 }
 
 /**
+ * Dispatch a pointerMove action equivalent to moving pointer device in a line.
+ *
+ * If the action duration is 0, the pointer jumps immediately to the target coordinates.
+ * Otherwise, events are synthesized to mimic a pointer travelling in a discontinuous,
+ * approximately straight line, with the pointer coordinates being updated around 60
+ * times per second.
+ *
+ * @param {action.Action} a
+ *     Action to dispatch.
+ * @param {action.InputState} inputState
+ *     Input state for this action's input source.
+ * @param {element.Store} seenEls
+ *     Element store.
+ * @param {?} container
+ *     Object with |frame| attribute of type |nsIDOMWindow|.
+ *
+ * @return {Promise}
+ *     Promise to dispatch at least one pointermove event, as well as mousemove events
+ *     as appropriate.
+ */
+function dispatchPointerMove(a, inputState, tickDuration, seenEls, container) {
+  const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+  // interval between pointermove increments in ms, based on common vsync
+  const fps60 = 17;
+  return new Promise(resolve => {
+    const start = Date.now();
+    const [startX, startY] = [inputState.x, inputState.y];
+    let target = action.computePointerDestination(a, inputState,
+        getElementCenter(a.origin, seenEls, container));
+    const [targetX, targetY] = [target.x, target.y];
+    if (!inViewPort(targetX, targetY, container.frame)) {
+      throw new MoveTargetOutOfBoundsError(
+          `(${targetX}, ${targetY}) is out of bounds of viewport ` +
+          `width (${container.frame.innerWidth}) and height (${container.frame.innerHeight})`);
+    }
+
+    const duration = typeof a.duration == "undefined" ? tickDuration : a.duration;
+    if (duration === 0) {
+      // move pointer to destination in one step
+      performOnePointerMove(inputState, targetX, targetY, container.frame);
+      resolve();
+      return;
+    }
+
+    const distanceX = targetX - startX;
+    const distanceY = targetY - startY;
+    const ONE_SHOT = Ci.nsITimer.TYPE_ONE_SHOT;
+    let intermediatePointerEvents = Task.spawn(function* () {
+      // wait |fps60| ms before performing first incremental pointer move
+      yield new Promise(resolveTimer =>
+          timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)
+      );
+      let durationRatio = Math.floor(Date.now() - start) / duration;
+      const epsilon = fps60 / duration / 10;
+      while ((1 - durationRatio) > epsilon) {
+        let x = Math.floor(durationRatio * distanceX + startX);
+        let y = Math.floor(durationRatio * distanceY + startY);
+        performOnePointerMove(inputState, x, y, container.frame);
+        // wait |fps60| ms before performing next pointer move
+        yield new Promise(resolveTimer =>
+            timer.initWithCallback(resolveTimer, fps60, ONE_SHOT));
+        durationRatio = Math.floor(Date.now() - start) / duration;
+      }
+    });
+    // perform last pointer move after all incremental moves are resolved and
+    // durationRatio is close enough to 1
+    intermediatePointerEvents.then(() => {
+      performOnePointerMove(inputState, targetX, targetY, container.frame);
+      resolve();
+    });
+
+  });
+}
+
+function performOnePointerMove(inputState, targetX, targetY, win) {
+  if (targetX == inputState.x && targetY == inputState.y) {
+    return;
+  }
+  switch (inputState.subtype) {
+    case action.PointerType.Mouse:
+      let mouseEvent = new action.Mouse("mousemove");
+      mouseEvent.update(inputState);
+      //TODO both pointermove (if available) and mousemove
+      event.synthesizeMouseAtPoint(targetX, targetY, mouseEvent, win);
+      break;
+    case action.PointerType.Pen:
+    case action.PointerType.Touch:
+      throw new UnsupportedOperationError("Only 'mouse' pointer type is supported");
+    default:
+        throw new TypeError(`Unknown pointer type: ${inputState.subtype}`);
+  }
+  inputState.x = targetX;
+  inputState.y = targetY;
+}
+
+/**
  * Dispatch a pause action equivalent waiting for |a.duration| milliseconds, or a
  * default time interval of |tickDuration|.
  *
  * @param {action.Action} a
  *     Action to dispatch.
  * @param {number} tickDuration
  *     Duration in milliseconds of this tick.
  *
@@ -1209,8 +1341,23 @@ function flushEvents(container) {
 }
 
 function capitalize(str) {
   if (typeof str != "string") {
     throw new InvalidArgumentError(`Expected string, got: ${str}`);
   }
   return str.charAt(0).toUpperCase() + str.slice(1);
 }
+
+function inViewPort(x, y, win) {
+  assert.number(x);
+  assert.number(y);
+  // Viewport includes scrollbars if rendered.
+  return !(x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight);
+}
+
+function getElementCenter(elementReference, seenEls, container) {
+  if (element.isWebElementReference(elementReference)) {
+    let uuid = elementReference[element.Key] || elementReference[element.LegacyKey];
+    let el = seenEls.get(uuid, container);
+    return element.coordinates(el);
+  }
+}
--- a/testing/marionette/test_action.js
+++ b/testing/marionette/test_action.js
@@ -178,16 +178,65 @@ add_test(function test_processPointerMov
       origin = action.PointerOrigin.Viewport;
     }
     deepEqual(actual.origin, origin);
 
   }
   run_next_test();
 });
 
+add_test(function test_computePointerDestinationViewport() {
+  let act = { type: "pointerMove", x: 100, y: 200, origin: "viewport"};
+  let inputState = new action.InputState.Pointer(action.PointerType.Mouse);
+  // these values should not affect the outcome
+  inputState.x = "99";
+  inputState.y = "10";
+  let target = action.computePointerDestination(act, inputState);
+  equal(act.x, target.x);
+  equal(act.y, target.y);
+
+  run_next_test();
+});
+
+add_test(function test_computePointerDestinationPointer() {
+  let act = { type: "pointerMove", x: 100, y: 200, origin: "pointer"};
+  let inputState = new action.InputState.Pointer(action.PointerType.Mouse);
+  inputState.x = 10;
+  inputState.y = 99;
+  let target = action.computePointerDestination(act, inputState);
+  equal(act.x + inputState.x, target.x);
+  equal(act.y + inputState.y, target.y);
+
+
+  run_next_test();
+});
+
+add_test(function test_computePointerDestinationElement() {
+  // origin represents a web element
+  // using an object literal instead to test default case in computePointerDestination
+  let act = {type: "pointerMove", x: 100, y: 200, origin: {}};
+  let inputState = new action.InputState.Pointer(action.PointerType.Mouse);
+  let elementCenter = {x: 10, y: 99};
+  let target = action.computePointerDestination(act, inputState, elementCenter);
+  equal(act.x + elementCenter.x, target.x);
+  equal(act.y + elementCenter.y, target.y);
+
+  Assert.throws(
+      () => action.computePointerDestination(act, inputState, {a: 1}),
+      InvalidArgumentError,
+      "Invalid element center coordinates.");
+
+  Assert.throws(
+      () => action.computePointerDestination(act, inputState, undefined),
+      InvalidArgumentError,
+      "Undefined element center coordinates.");
+
+  run_next_test();
+});
+
 add_test(function test_processPointerAction() {
   let actionSequence = {
     type: "pointer",
     id: "some_id",
     parameters: {
       pointerType: "mouse" //TODO "touch"
     },
   };