[embedder] Touch and mouse input unit tests

Change-Id: Iecfe410fbf1c0825621f3c27ae2d9f373ceadf50
Reviewed-on: https://fuchsia-review.googlesource.com/c/flutter-embedder/+/775325
Reviewed-by: Alexander Biggs <akbiggs@google.com>
Reviewed-by: Naud Ghebre <naudzghebre@google.com>
diff --git a/README.md b/README.md
index 53e851f..de895db 100644
--- a/README.md
+++ b/README.md
@@ -106,6 +106,16 @@
    - To attach a debugger to the example component, see
    [_`debugging.md`_](https://fuchsia.googlesource.com/flutter-embedder/+/refs/heads/main/docs/debugging.md)
 
+## Running unit tests
+
+To run unit tests for this repository locally, first execute steps 1 & 2 from the previos section, then run the following command:
+
+```sh
+$FUCHSIA_EMBEDDER_DIR/scripts/tests/run_unittests.sh
+```
+
+There is no CQ for this repository yet.
+
 ## Uploading changes for review.
 
 See [_`git_workflow.md`_](https://fuchsia.googlesource.com/flutter-embedder/+/refs/heads/main/docs/git_workflow.md).
diff --git a/scripts/tests/run_unittests.sh b/scripts/tests/run_unittests.sh
new file mode 100755
index 0000000..caeb4f1
--- /dev/null
+++ b/scripts/tests/run_unittests.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+#
+# Copyright 2022 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+#
+# Runs local tests for all workflow scripts. Not runnable on CQ.
+#
+# Usage:
+#   $FUCHSIA_EMBEDDER_DIR/scripts/tests/run_all.sh
+
+set -e # Fail on any error.
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"/../lib/helpers.sh || exit $?
+
+tests=( "${FUCHSIA_EMBEDDER_DIR}"/src/embedder/*_unittests.cc )
+
+for test in "${tests[@]}"
+do
+  # Convert filename to test name by removing prefix path (73 characters)
+  # and file extension (3 chacarcters)
+  test_name=${test:73:-3}
+  echo-info "Running $test_name ..."
+  bazel test //src/embedder:${test_name}_pkg --config=fuchsia_x64
+done
\ No newline at end of file
diff --git a/src/embedder/BUILD.bazel b/src/embedder/BUILD.bazel
index 5217c52..75811da 100644
--- a/src/embedder/BUILD.bazel
+++ b/src/embedder/BUILD.bazel
@@ -7,35 +7,98 @@
 load(
     "@rules_fuchsia//fuchsia:defs.bzl",
     "fuchsia_cc_binary",
+    "fuchsia_cc_test",
+    "fuchsia_test_group",
+    "fuchsia_test_package",
+    "fuchsia_tests",
     "fuchsia_component_manifest",
 )
+load("@rules_cc//cc:defs.bzl", "cc_library")
+
+
+cc_library(
+  name = "logging",
+  srcs = [ 
+    "logging.h",
+    "fuchsia_logger.h",
+    "fuchsia_logger.cc"
+  ],
+  deps = [
+    "@fuchsia_sdk//pkg/syslog"
+  ]
+)
+
+cc_library(
+  name = "embedder_state",
+  srcs = [
+    "flatland_connection.h",
+    "software_surface.h",
+    "embedder_state.h"
+  ],
+  deps = [
+    "//src/embedder/engine:embedder_header"
+  ]
+)
+
+cc_library(
+  name = "pointer_utility",
+  srcs = [
+    ":logging",
+    "pointer_utility.h"
+  ],
+  deps = [
+    "@fuchsia_sdk//pkg/trace",
+    "@fuchsia_sdk//pkg/trace-engine",
+        "@fuchsia_sdk//pkg/trace-provider-so"
+  ]
+)
+
+cc_library(
+  name = "mouse_delegate",
+  srcs = [
+    "mouse_delegate.h",
+    "mouse_delegate.cc"
+  ],
+  deps = [
+    ":logging",
+    ":pointer_utility",
+    "//src/embedder/engine:embedder_header",
+    "@fuchsia_sdk//fidl/fuchsia.ui.composition:fuchsia.ui.composition_cc",
+  ]
+)
+
+cc_library(
+  name = "touch_delegate",
+  srcs = [
+    "touch_delegate.h",
+    "touch_delegate.cc"
+  ],
+  deps = [
+    ":logging",
+    ":pointer_utility",
+    "//src/embedder/engine:embedder_header",
+    "@fuchsia_sdk//fidl/fuchsia.ui.composition:fuchsia.ui.composition_cc",
+  ]
+)
+
 
 # ELF binary that takes the path to Flutter app assets as an argument
 # and runs the Flutter app defined by those assets.
 fuchsia_cc_binary(
     name = "embedder",
     srcs = [
-        "embedder_state.h",
         "flatland_connection.cc",
-        "flatland_connection.h",
         "flatland_ids.h",
         "flatland_view_provider.h",
-        "fuchsia_logger.cc",
-        "fuchsia_logger.h",
         "keyboard.cc",
         "keyboard.h",
-        "logging.h",
         "main.cc",
-        "mouse_delegate.cc",
-        "mouse_delegate.h",
         "platform_message_channels.h",
         "pointer_utility.h",
         "software_surface.cc",
         "software_surface.h",
         "text_delegate.cc",
         "text_delegate.h",
-        "touch_delegate.cc",
-        "touch_delegate.h",
         "accessibility_bridge.cc",
         "accessibility_bridge.h",
         "root_inspect_node.cc",
@@ -54,6 +117,40 @@
     ],
     visibility = ["//visibility:public"],
     deps = [
+        ":logging",
+        ":embedder_state",
+        ":mouse_delegate",
+        ":touch_delegate",
+        "//src/embedder/engine:embedder_header",
+        "//src/embedder/engine:libflutter_engine_for_platform",
+        "@rapidjson",
+        "@fuchsia_sdk//fidl/fuchsia.fonts:fuchsia.fonts_cc",
+        "@fuchsia_sdk//fidl/fuchsia.sysmem:fuchsia.sysmem_cc",
+        "@fuchsia_sdk//fidl/fuchsia.ui.app:fuchsia.ui.app_cc",
+        "@fuchsia_sdk//fidl/fuchsia.ui.composition:fuchsia.ui.composition_cc",
+        "@fuchsia_sdk//pkg/async-loop-default",
+        "@fuchsia_sdk//pkg/async-loop-cpp",
+        "@fuchsia_sdk//pkg/fit",
+        "@fuchsia_sdk//pkg/scenic_cpp",
+        "@fuchsia_sdk//pkg/sys_cpp",
+        "@fuchsia_sdk//pkg/syslog",
+        "@fuchsia_sdk//pkg/trace",
+        "@fuchsia_sdk//pkg/trace-engine",
+        "@fuchsia_sdk//pkg/trace-provider-so"
+    ],
+)
+
+fuchsia_cc_test(
+    name = "mouse_delegate_unittests",
+    size = "small",
+    visibility = ["//visibility:public"],
+    srcs = [ "mouse_delegate_unittests.cc"],
+    deps = [
+        "@com_google_googletest//:gtest_main",
+        ":logging",
+        ":embedder_state",
+        ":mouse_delegate",
+        "//src/embedder/test_util:mouse_event_builder",
         "//src/embedder/engine:embedder_header",
         "//src/embedder/engine:libflutter_engine_for_platform",
         "@rapidjson",
@@ -72,9 +169,65 @@
         "@fuchsia_sdk//pkg/trace",
         "@fuchsia_sdk//pkg/trace-engine",
         "@fuchsia_sdk//pkg/trace-provider-so",
+        "@fuchsia_sdk//pkg/fdio"
     ],
 )
 
+fuchsia_cc_test(
+    name = "touch_delegate_unittests",
+    size = "small",
+    visibility = ["//visibility:public"],
+    srcs = ["touch_delegate_unittests.cc"],
+    deps = [
+        "@com_google_googletest//:gtest_main",
+        ":logging",
+        ":embedder_state",
+        ":touch_delegate",
+        "//src/embedder/test_util:touch_event_builder",
+        "//src/embedder/engine:embedder_header",
+        "//src/embedder/engine:libflutter_engine_for_platform",
+        "@rapidjson",
+        "@fuchsia_sdk//fidl/fuchsia.fonts:fuchsia.fonts_cc",
+        "@fuchsia_sdk//fidl/fuchsia.sysmem:fuchsia.sysmem_cc",
+        "@fuchsia_sdk//fidl/fuchsia.ui.app:fuchsia.ui.app_cc",
+        "@fuchsia_sdk//fidl/fuchsia.ui.composition:fuchsia.ui.composition_cc",
+        "@fuchsia_sdk//pkg/async-loop-default",
+        "@fuchsia_sdk//pkg/async-loop-cpp",
+        "@fuchsia_sdk//pkg/fit",
+        "@fuchsia_sdk//pkg/scenic_cpp",
+        "@fuchsia_sdk//pkg/sys_cpp",
+        "@fuchsia_sdk//pkg/syslog",
+        "@fuchsia_sdk//pkg/trace",
+        "@fuchsia_sdk//pkg/trace-engine",
+        "@fuchsia_sdk//pkg/trace-provider-so",
+        "@fuchsia_sdk//pkg/fdio"
+    ],
+)
+
+fuchsia_test_package(
+    name = "mouse_delegate_unittests_pkg",
+    package_name = "mouse_delegate_unittests_pkg",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//src/embedder/engine:libflutter_engine_pkg_resource"
+    ],
+    components = [
+         ":mouse_delegate_unittests"
+    ]
+)
+
+fuchsia_test_package(
+    name = "touch_delegate_unittests_pkg",
+    package_name = "touch_delegate_unittests_pkg",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//src/embedder/engine:libflutter_engine_pkg_resource"
+    ],
+    components = [
+         ":touch_delegate_unittests"
+    ]
+)
+
 fuchsia_component_manifest(
     name = "embedder_manifest",
     src = "meta/embedder.cml",
diff --git a/src/embedder/mouse_delegate_unittests.cc b/src/embedder/mouse_delegate_unittests.cc
new file mode 100644
index 0000000..bcf2ae4
--- /dev/null
+++ b/src/embedder/mouse_delegate_unittests.cc
@@ -0,0 +1,209 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include <fuchsia/ui/pointer/cpp/fidl.h>
+#include <lib/async-loop/cpp/loop.h>
+#include <lib/async-loop/default.h>
+#include <lib/fdio/fd.h>
+#include <lib/fidl/cpp/binding_set.h>
+
+#include <array>
+#include <optional>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+#include "src/embedder/mouse_delegate.h"
+#include "src/embedder/test_util/fakes/mouse_source.h"
+#include "src/embedder/test_util/mouse_event_builder.h"
+
+namespace embedder_testing {
+
+using fup_EventPhase = fuchsia::ui::pointer::EventPhase;
+using fup_ViewParameters = fuchsia::ui::pointer::ViewParameters;
+using fup_MouseEvent = fuchsia::ui::pointer::MouseEvent;
+
+constexpr std::array<std::array<float, 2>, 2> kRect = {{{0, 0}, {20, 20}}};
+constexpr std::array<float, 9> kIdentity = {1, 0, 0, 0, 1, 0, 0, 0, 1};
+
+constexpr uint32_t kMouseDeviceId = 123;
+constexpr std::array<int64_t, 2> kNoScrollInPhysicalPixelDelta = {0, 0};
+const bool kNotPrecisionScroll = false;
+const bool kPrecisionScroll = true;
+
+// Fixture to exercise Flutter runner's implementation for
+// fuchsia.ui.pointer.TouchSource.
+class MouseDelegateTest : public ::testing::Test {
+ protected:
+  MouseDelegateTest() : loop_(&kAsyncLoopConfigAttachToCurrentThread) {
+    mouse_source_ = std::make_unique<FakeMouseSource>();
+    mouse_delegate_ = std::make_unique<embedder::MouseDelegate>(
+        mouse_source_bindings_.AddBinding(mouse_source_.get()));
+  }
+
+  void RunLoopUntilIdle() { loop_.RunUntilIdle(); }
+
+  std::unique_ptr<FakeMouseSource> mouse_source_;
+  std::unique_ptr<embedder::MouseDelegate> mouse_delegate_;
+
+  async::Loop loop_;
+  fidl::BindingSet<fuchsia::ui::pointer::MouseSource> mouse_source_bindings_;
+};
+
+TEST_F(MouseDelegateTest, MouseWheel_TickBased) {
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  mouse_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+
+  std::vector<fup_MouseEvent> events =
+      MouseEventBuilder()
+          .AddTime(1111789u)
+          .AddViewParameters(kRect, kRect, kIdentity)
+          .AddSample(kMouseDeviceId, {10.f, 10.f}, {}, {0, 1}, kNoScrollInPhysicalPixelDelta,
+                     kNotPrecisionScroll)
+          .AddMouseDeviceInfo(kMouseDeviceId, {0, 1, 2})
+          .BuildAsVector();
+  mouse_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 1u);
+  EXPECT_EQ(pointers.value()[0].phase, FlutterPointerPhase::kHover);
+  EXPECT_EQ(pointers.value()[0].signal_kind,
+            FlutterPointerSignalKind::kFlutterPointerSignalKindScroll);
+  EXPECT_EQ(pointers.value()[0].device_kind,
+            FlutterPointerDeviceKind::kFlutterPointerDeviceKindMouse);
+  EXPECT_EQ(pointers.value()[0].buttons, 0);
+  EXPECT_EQ(pointers.value()[0].scroll_delta_x, 0);
+  EXPECT_EQ(pointers.value()[0].scroll_delta_y, -20);
+  pointers = {};
+
+  // receive a horizontal scroll
+  events = MouseEventBuilder()
+               .AddTime(1111789u)
+               .AddViewParameters(kRect, kRect, kIdentity)
+               .AddSample(kMouseDeviceId, {10.f, 10.f}, {}, {1, 0}, kNoScrollInPhysicalPixelDelta,
+                          kNotPrecisionScroll)
+               .AddMouseDeviceInfo(kMouseDeviceId, {0, 1, 2})
+               .BuildAsVector();
+  mouse_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 1u);
+  EXPECT_EQ(pointers.value()[0].phase, FlutterPointerPhase::kHover);
+  EXPECT_EQ(pointers.value()[0].signal_kind,
+            FlutterPointerSignalKind::kFlutterPointerSignalKindScroll);
+  EXPECT_EQ(pointers.value()[0].device_kind,
+            FlutterPointerDeviceKind::kFlutterPointerDeviceKindMouse);
+  EXPECT_EQ(pointers.value()[0].buttons, 0);
+  EXPECT_EQ(pointers.value()[0].scroll_delta_x, 20);
+  EXPECT_EQ(pointers.value()[0].scroll_delta_y, 0);
+  pointers = {};
+}
+
+TEST_F(MouseDelegateTest, MouseWheel_PixelBased) {
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  mouse_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+
+  std::vector<fup_MouseEvent> events =
+      MouseEventBuilder()
+          .AddTime(1111789u)
+          .AddViewParameters(kRect, kRect, kIdentity)
+          .AddSample(kMouseDeviceId, {10.f, 10.f}, {}, {0, 1}, {0, 120}, kNotPrecisionScroll)
+          .AddMouseDeviceInfo(kMouseDeviceId, {0, 1, 2})
+          .BuildAsVector();
+  mouse_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 1u);
+  EXPECT_EQ(pointers.value()[0].phase, FlutterPointerPhase::kHover);
+  EXPECT_EQ(pointers.value()[0].signal_kind,
+            FlutterPointerSignalKind::kFlutterPointerSignalKindScroll);
+  EXPECT_EQ(pointers.value()[0].device_kind,
+            FlutterPointerDeviceKind::kFlutterPointerDeviceKindMouse);
+  EXPECT_EQ(pointers.value()[0].buttons, 0);
+  EXPECT_EQ(pointers.value()[0].scroll_delta_x, 0);
+  EXPECT_EQ(pointers.value()[0].scroll_delta_y, -120);
+  pointers = {};
+
+  // receive a horizontal scroll
+  events = MouseEventBuilder()
+               .AddTime(1111789u)
+               .AddViewParameters(kRect, kRect, kIdentity)
+               .AddSample(kMouseDeviceId, {10.f, 10.f}, {}, {1, 0}, {120, 0}, kNotPrecisionScroll)
+               .AddMouseDeviceInfo(kMouseDeviceId, {0, 1, 2})
+               .BuildAsVector();
+  mouse_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 1u);
+  EXPECT_EQ(pointers.value()[0].phase, FlutterPointerPhase::kHover);
+  EXPECT_EQ(pointers.value()[0].signal_kind,
+            FlutterPointerSignalKind::kFlutterPointerSignalKindScroll);
+  EXPECT_EQ(pointers.value()[0].device_kind,
+            FlutterPointerDeviceKind::kFlutterPointerDeviceKindMouse);
+  EXPECT_EQ(pointers.value()[0].buttons, 0);
+  EXPECT_EQ(pointers.value()[0].scroll_delta_x, 120);
+  EXPECT_EQ(pointers.value()[0].scroll_delta_y, 0);
+  pointers = {};
+}
+
+TEST_F(MouseDelegateTest, MouseWheel_TouchpadPixelBased) {
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  mouse_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+
+  std::vector<fup_MouseEvent> events =
+      MouseEventBuilder()
+          .AddTime(1111789u)
+          .AddViewParameters(kRect, kRect, kIdentity)
+          .AddSample(kMouseDeviceId, {10.f, 10.f}, {}, {0, 1}, {0, 120}, kPrecisionScroll)
+          .AddMouseDeviceInfo(kMouseDeviceId, {0, 1, 2})
+          .BuildAsVector();
+  mouse_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 1u);
+  EXPECT_EQ(pointers.value()[0].phase, FlutterPointerPhase::kHover);
+  EXPECT_EQ(pointers.value()[0].signal_kind,
+            FlutterPointerSignalKind::kFlutterPointerSignalKindScroll);
+  EXPECT_EQ(pointers.value()[0].device_kind,
+            FlutterPointerDeviceKind::kFlutterPointerDeviceKindMouse);
+  EXPECT_EQ(pointers.value()[0].buttons, 0);
+  EXPECT_EQ(pointers.value()[0].scroll_delta_x, 0);
+  EXPECT_EQ(pointers.value()[0].scroll_delta_y, -120);
+  pointers = {};
+
+  // receive a horizontal scroll
+  events = MouseEventBuilder()
+               .AddTime(1111789u)
+               .AddViewParameters(kRect, kRect, kIdentity)
+               .AddSample(kMouseDeviceId, {10.f, 10.f}, {}, {1, 0}, {120, 0}, kPrecisionScroll)
+               .AddMouseDeviceInfo(kMouseDeviceId, {0, 1, 2})
+               .BuildAsVector();
+  mouse_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 1u);
+  EXPECT_EQ(pointers.value()[0].phase, FlutterPointerPhase::kHover);
+  EXPECT_EQ(pointers.value()[0].signal_kind,
+            FlutterPointerSignalKind::kFlutterPointerSignalKindScroll);
+  EXPECT_EQ(pointers.value()[0].device_kind,
+            FlutterPointerDeviceKind::kFlutterPointerDeviceKindMouse);
+  EXPECT_EQ(pointers.value()[0].buttons, 0);
+  EXPECT_EQ(pointers.value()[0].scroll_delta_x, 120);
+  EXPECT_EQ(pointers.value()[0].scroll_delta_y, 0);
+  pointers = {};
+}
+
+}  // namespace embedder_testing
diff --git a/src/embedder/test_util/BUILD.bazel b/src/embedder/test_util/BUILD.bazel
new file mode 100644
index 0000000..0b96f2e
--- /dev/null
+++ b/src/embedder/test_util/BUILD.bazel
@@ -0,0 +1,33 @@
+# Copyright 2022 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+#
+# Package for the Flutter embedder for Fuchsia.
+
+load("@rules_cc//cc:defs.bzl", "cc_library")
+
+cc_library(
+  name = "mouse_event_builder",
+  visibility = ["//visibility:public"],
+  srcs = [
+    "mouse_event_builder.h",
+    "mouse_event_builder.cc",
+    "fakes/mouse_source.h"
+  ],
+  deps = [
+    "@fuchsia_sdk//fidl/fuchsia.ui.composition:fuchsia.ui.composition_cc"
+  ]
+)
+
+cc_library(
+  name = "touch_event_builder",
+  visibility = ["//visibility:public"],
+  srcs = [
+    "touch_event_builder.h",
+    "touch_event_builder.cc",
+    "fakes/touch_source.h"
+  ],
+  deps = [
+    "@fuchsia_sdk//fidl/fuchsia.ui.composition:fuchsia.ui.composition_cc"
+  ]
+)
\ No newline at end of file
diff --git a/src/embedder/test_util/fakes/mouse_source.h b/src/embedder/test_util/fakes/mouse_source.h
new file mode 100644
index 0000000..ed15afd
--- /dev/null
+++ b/src/embedder/test_util/fakes/mouse_source.h
@@ -0,0 +1,37 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef FLUTTER_SHELL_PLATFORM_FUCHSIA_TEST_UTIL_FAKES_MOUSE_SOURCE_H_
+#define FLUTTER_SHELL_PLATFORM_FUCHSIA_TEST_UTIL_FAKES_MOUSE_SOURCE_H_
+
+#include <fuchsia/ui/pointer/cpp/fidl.h>
+
+#include <iostream>
+
+#include "src/embedder/fuchsia_logger.h"
+
+namespace embedder_testing {
+
+// A test stub to act as the protocol server. A test can control what is sent
+// back by this server implementation, via the ScheduleCallback call.
+class FakeMouseSource : public fuchsia::ui::pointer::MouseSource {
+ public:
+  // |fuchsia.ui.pointer.MouseSource|
+  void Watch(MouseSource::WatchCallback callback) override { callback_ = std::move(callback); }
+
+  // Have the server issue events to the client's hanging-get Watch call.
+  void ScheduleCallback(std::vector<fuchsia::ui::pointer::MouseEvent> events) {
+    FX_CHECK(callback_);
+    callback_(std::move(events));
+  }
+
+ private:
+  // Client-side logic to invoke on Watch() call's return. A test triggers it
+  // with ScheduleCallback().
+  MouseSource::WatchCallback callback_;
+};
+
+}  // namespace embedder_testing
+
+#endif  // FLUTTER_SHELL_PLATFORM_FUCHSIA_TEST_UTIL_FAKES_MOUSE_SOURCE_H_
diff --git a/src/embedder/test_util/fakes/touch_source.h b/src/embedder/test_util/fakes/touch_source.h
new file mode 100644
index 0000000..58d80b2
--- /dev/null
+++ b/src/embedder/test_util/fakes/touch_source.h
@@ -0,0 +1,59 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef FLUTTER_SHELL_PLATFORM_FUCHSIA_TEST_UTIL_FAKES_TOUCH_SOURCE_H_
+#define FLUTTER_SHELL_PLATFORM_FUCHSIA_TEST_UTIL_FAKES_TOUCH_SOURCE_H_
+
+#include <fuchsia/ui/pointer/cpp/fidl.h>
+
+#include <optional>
+#include <vector>
+
+#include "src/embedder/fuchsia_logger.h"
+
+namespace embedder_testing {
+
+// A test stub to act as the protocol server. A test can control what is sent
+// back by this server implementation, via the ScheduleCallback call.
+class FakeTouchSource : public fuchsia::ui::pointer::TouchSource {
+ public:
+  // |fuchsia.ui.pointer.TouchSource|
+  void Watch(std::vector<fuchsia::ui::pointer::TouchResponse> responses,
+             TouchSource::WatchCallback callback) override {
+    responses_ = std::move(responses);
+    callback_ = std::move(callback);
+  }
+
+  // Have the server issue events to the client's hanging-get Watch call.
+  void ScheduleCallback(std::vector<fuchsia::ui::pointer::TouchEvent> events) {
+    FX_CHECK(callback_);
+    callback_(std::move(events));
+  }
+
+  // Allow the test to observe what the client uploaded on the next Watch call.
+  std::optional<std::vector<fuchsia::ui::pointer::TouchResponse>> UploadedResponses() {
+    auto responses = std::move(responses_);
+    responses_.reset();
+    return responses;
+  }
+
+ private:
+  // |fuchsia.ui.pointer.TouchSource|
+  void UpdateResponse(fuchsia::ui::pointer::TouchInteractionId ixn,
+                      fuchsia::ui::pointer::TouchResponse response,
+                      TouchSource::UpdateResponseCallback callback) override {
+    FX_CHECK(false);
+  }
+
+  // Client uploads responses to server.
+  std::optional<std::vector<fuchsia::ui::pointer::TouchResponse>> responses_;
+
+  // Client-side logic to invoke on Watch() call's return. A test triggers it
+  // with ScheduleCallback().
+  TouchSource::WatchCallback callback_;
+};
+
+}  // namespace embedder_testing
+
+#endif  // FLUTTER_SHELL_PLATFORM_FUCHSIA_TEST_UTIL_FAKES_TOUCH_SOURCE_H_
diff --git a/src/embedder/test_util/mouse_event_builder.cc b/src/embedder/test_util/mouse_event_builder.cc
new file mode 100644
index 0000000..aaea937
--- /dev/null
+++ b/src/embedder/test_util/mouse_event_builder.cc
@@ -0,0 +1,108 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "src/embedder/test_util/mouse_event_builder.h"
+
+namespace embedder_testing {
+
+using fup_EventPhase = fuchsia::ui::pointer::EventPhase;
+using fup_ViewParameters = fuchsia::ui::pointer::ViewParameters;
+using fup_MouseEvent = fuchsia::ui::pointer::MouseEvent;
+using fup_MousePointerSample = fuchsia::ui::pointer::MousePointerSample;
+using fup_MouseDeviceInfo = fuchsia::ui::pointer::MouseDeviceInfo;
+
+namespace {
+
+fup_ViewParameters CreateMouseViewParameters(std::array<std::array<float, 2>, 2> view,
+                                        std::array<std::array<float, 2>, 2> viewport,
+                                        std::array<float, 9> transform) {
+  fup_ViewParameters params;
+  fuchsia::ui::pointer::Rectangle view_rect;
+  view_rect.min = view[0];
+  view_rect.max = view[1];
+  params.view = view_rect;
+  fuchsia::ui::pointer::Rectangle viewport_rect;
+  viewport_rect.min = viewport[0];
+  viewport_rect.max = viewport[1];
+  params.viewport = viewport_rect;
+  params.viewport_to_view_transform = transform;
+  return params;
+}
+
+}  // namespace
+
+MouseEventBuilder MouseEventBuilder::New() { return MouseEventBuilder(); }
+
+MouseEventBuilder& MouseEventBuilder::AddTime(zx_time_t time) {
+  time_ = time;
+  return *this;
+}
+
+MouseEventBuilder& MouseEventBuilder::AddSample(uint32_t id, std::array<float, 2> position,
+                                                std::vector<uint8_t> pressed_buttons,
+                                                std::array<int64_t, 2> scroll,
+                                                std::array<int64_t, 2> scroll_in_physical_pixel,
+                                                bool is_precision_scroll) {
+  sample_ = std::make_optional<fup_MousePointerSample>();
+  sample_->set_device_id(id);
+  if (!pressed_buttons.empty()) {
+    sample_->set_pressed_buttons(pressed_buttons);
+  }
+  sample_->set_position_in_viewport(position);
+  if (scroll[0] != 0) {
+    sample_->set_scroll_h(scroll[0]);
+  }
+  if (scroll[1] != 0) {
+    sample_->set_scroll_v(scroll[1]);
+  }
+  if (scroll_in_physical_pixel[0] != 0) {
+    sample_->set_scroll_h_physical_pixel(scroll_in_physical_pixel[0]);
+  }
+  if (scroll_in_physical_pixel[1] != 0) {
+    sample_->set_scroll_v_physical_pixel(scroll_in_physical_pixel[1]);
+  }
+  sample_->set_is_precision_scroll(is_precision_scroll);
+  return *this;
+}
+
+MouseEventBuilder& MouseEventBuilder::AddViewParameters(
+    std::array<std::array<float, 2>, 2> view, std::array<std::array<float, 2>, 2> viewport,
+    std::array<float, 9> transform) {
+  params_ = CreateMouseViewParameters(std::move(view), std::move(viewport), std::move(transform));
+  return *this;
+}
+
+MouseEventBuilder& MouseEventBuilder::AddMouseDeviceInfo(uint32_t id,
+                                                         std::vector<uint8_t> buttons) {
+  device_info_ = std::make_optional<fup_MouseDeviceInfo>();
+  device_info_->set_id(id);
+  device_info_->set_buttons(buttons);
+  return *this;
+}
+
+fup_MouseEvent MouseEventBuilder::Build() {
+  fup_MouseEvent event;
+  if (time_) {
+    event.set_timestamp(time_.value());
+  }
+  if (params_) {
+    event.set_view_parameters(std::move(params_.value()));
+  }
+  if (sample_) {
+    event.set_pointer_sample(std::move(sample_.value()));
+  }
+  if (device_info_) {
+    event.set_device_info(std::move(device_info_.value()));
+  }
+  event.set_trace_flow_id(123);
+  return event;
+}
+
+std::vector<fup_MouseEvent> MouseEventBuilder::BuildAsVector() {
+  std::vector<fup_MouseEvent> events;
+  events.emplace_back(Build());
+  return events;
+}
+
+}  // namespace embedder_testing
diff --git a/src/embedder/test_util/mouse_event_builder.h b/src/embedder/test_util/mouse_event_builder.h
new file mode 100644
index 0000000..484de6b
--- /dev/null
+++ b/src/embedder/test_util/mouse_event_builder.h
@@ -0,0 +1,43 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef FLUTTER_SHELL_PLATFORM_FUCHSIA_TEST_UTIL_MOUSE_EVENT_BUILDER_H_
+#define FLUTTER_SHELL_PLATFORM_FUCHSIA_TEST_UTIL_MOUSE_EVENT_BUILDER_H_
+
+#include <fuchsia/ui/pointer/cpp/fidl.h>
+#include <zircon/types.h>
+
+#include <array>
+#include <optional>
+#include <vector>
+
+namespace embedder_testing {
+
+// A helper class for crafting a fuchsia.ui.pointer.MouseEventBuilder table.
+class MouseEventBuilder {
+ public:
+  static MouseEventBuilder New();
+
+  MouseEventBuilder& AddTime(zx_time_t time);
+  MouseEventBuilder& AddSample(uint32_t id, std::array<float, 2> position,
+                               std::vector<uint8_t> pressed_buttons, std::array<int64_t, 2> scroll,
+                               std::array<int64_t, 2> scroll_in_physical_pixel,
+                               bool is_precision_scroll);
+  MouseEventBuilder& AddViewParameters(std::array<std::array<float, 2>, 2> view,
+                                       std::array<std::array<float, 2>, 2> viewport,
+                                       std::array<float, 9> transform);
+  MouseEventBuilder& AddMouseDeviceInfo(uint32_t id, std::vector<uint8_t> buttons);
+  fuchsia::ui::pointer::MouseEvent Build();
+  std::vector<fuchsia::ui::pointer::MouseEvent> BuildAsVector();
+
+ private:
+  std::optional<zx_time_t> time_;
+  std::optional<fuchsia::ui::pointer::MousePointerSample> sample_;
+  std::optional<fuchsia::ui::pointer::ViewParameters> params_;
+  std::optional<fuchsia::ui::pointer::MouseDeviceInfo> device_info_;
+};
+
+}  // namespace embedder_testing
+
+#endif  // FLUTTER_SHELL_PLATFORM_FUCHSIA_TEST_UTIL_MOUSE_EVENT_BUILDER_H_
diff --git a/src/embedder/test_util/touch_event_builder.cc b/src/embedder/test_util/touch_event_builder.cc
new file mode 100644
index 0000000..c099840
--- /dev/null
+++ b/src/embedder/test_util/touch_event_builder.cc
@@ -0,0 +1,88 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "src/embedder/test_util/touch_event_builder.h"
+
+namespace embedder_testing {
+  
+using fup_EventPhase = fuchsia::ui::pointer::EventPhase;
+using fup_TouchEvent = fuchsia::ui::pointer::TouchEvent;
+using fup_TouchIxnId = fuchsia::ui::pointer::TouchInteractionId;
+using fup_TouchIxnResult = fuchsia::ui::pointer::TouchInteractionResult;
+using fup_TouchPointerSample = fuchsia::ui::pointer::TouchPointerSample;
+using fup_ViewParameters = fuchsia::ui::pointer::ViewParameters;
+
+namespace {
+
+fup_ViewParameters CreateTouchViewParameters(std::array<std::array<float, 2>, 2> view,
+                                             std::array<std::array<float, 2>, 2> viewport,
+                                             std::array<float, 9> transform) {
+  fup_ViewParameters params;
+  fuchsia::ui::pointer::Rectangle view_rect;
+  view_rect.min = view[0];
+  view_rect.max = view[1];
+  params.view = view_rect;
+  fuchsia::ui::pointer::Rectangle viewport_rect;
+  viewport_rect.min = viewport[0];
+  viewport_rect.max = viewport[1];
+  params.viewport = viewport_rect;
+  params.viewport_to_view_transform = transform;
+  return params;
+}
+
+}  // namespace
+
+TouchEventBuilder TouchEventBuilder::New() { return TouchEventBuilder(); }
+
+TouchEventBuilder& TouchEventBuilder::AddTime(zx_time_t time) {
+  time_ = time;
+  return *this;
+}
+
+TouchEventBuilder& TouchEventBuilder::AddSample(fup_TouchIxnId id, fup_EventPhase phase,
+                                                std::array<float, 2> position) {
+  sample_ = std::make_optional<fup_TouchPointerSample>();
+  sample_->set_interaction(id);
+  sample_->set_phase(phase);
+  sample_->set_position_in_viewport(position);
+  return *this;
+}
+
+TouchEventBuilder& TouchEventBuilder::AddViewParameters(
+    std::array<std::array<float, 2>, 2> view, std::array<std::array<float, 2>, 2> viewport,
+    std::array<float, 9> transform) {
+  params_ = CreateTouchViewParameters(std::move(view), std::move(viewport), std::move(transform));
+  return *this;
+}
+
+TouchEventBuilder& TouchEventBuilder::AddResult(fup_TouchIxnResult result) {
+  result_ = result;
+  return *this;
+}
+
+fup_TouchEvent TouchEventBuilder::Build() {
+  fup_TouchEvent event;
+  if (time_) {
+    event.set_timestamp(time_.value());
+  }
+  if (params_) {
+    event.set_view_parameters(std::move(params_.value()));
+  }
+  if (sample_) {
+    event.set_pointer_sample(std::move(sample_.value()));
+  }
+  if (result_) {
+    event.set_interaction_result(std::move(result_.value()));
+  }
+  event.set_trace_flow_id(0);
+  return event;
+}
+
+std::vector<fup_TouchEvent> TouchEventBuilder::BuildAsVector() {
+  std::vector<fup_TouchEvent> events;
+  events.emplace_back(Build());
+  return events;
+}
+
+}  // namespace embedder_testing
diff --git a/src/embedder/test_util/touch_event_builder.h b/src/embedder/test_util/touch_event_builder.h
new file mode 100644
index 0000000..f7ee886
--- /dev/null
+++ b/src/embedder/test_util/touch_event_builder.h
@@ -0,0 +1,43 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef FLUTTER_SHELL_PLATFORM_FUCHSIA_TEST_UTIL_TOUCH_EVENT_BUILDER_H_
+#define FLUTTER_SHELL_PLATFORM_FUCHSIA_TEST_UTIL_TOUCH_EVENT_BUILDER_H_
+
+#include <fuchsia/ui/pointer/cpp/fidl.h>
+#include <zircon/types.h>
+
+#include <array>
+#include <optional>
+#include <vector>
+
+namespace embedder_testing {
+
+// A helper class for crafting a fuchsia.ui.pointer.TouchEvent table.
+class TouchEventBuilder {
+ public:
+  static TouchEventBuilder New();
+
+  TouchEventBuilder& AddTime(zx_time_t time);
+  TouchEventBuilder& AddSample(fuchsia::ui::pointer::TouchInteractionId id,
+                               fuchsia::ui::pointer::EventPhase phase,
+                               std::array<float, 2> position);
+  TouchEventBuilder& AddViewParameters(std::array<std::array<float, 2>, 2> view,
+                                       std::array<std::array<float, 2>, 2> viewport,
+                                       std::array<float, 9> transform);
+  TouchEventBuilder& AddResult(fuchsia::ui::pointer::TouchInteractionResult result);
+
+  fuchsia::ui::pointer::TouchEvent Build();
+  std::vector<fuchsia::ui::pointer::TouchEvent> BuildAsVector();
+
+ private:
+  std::optional<zx_time_t> time_;
+  std::optional<fuchsia::ui::pointer::ViewParameters> params_;
+  std::optional<fuchsia::ui::pointer::TouchPointerSample> sample_;
+  std::optional<fuchsia::ui::pointer::TouchInteractionResult> result_;
+};
+
+}  // namespace embedder_testing
+
+#endif  // FLUTTER_SHELL_PLATFORM_FUCHSIA_TEST_UTIL_TOUCH_EVENT_BUILDER_H_
diff --git a/src/embedder/touch_delegate_unittests.cc b/src/embedder/touch_delegate_unittests.cc
new file mode 100644
index 0000000..175a0e7
--- /dev/null
+++ b/src/embedder/touch_delegate_unittests.cc
@@ -0,0 +1,621 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include <fuchsia/ui/pointer/cpp/fidl.h>
+#include <lib/async-loop/cpp/loop.h>
+#include <lib/async-loop/default.h>
+#include <lib/fdio/fd.h>
+#include <lib/fidl/cpp/binding_set.h>
+
+#include <array>
+#include <optional>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+#include "src/embedder/test_util/fakes/touch_source.h"
+#include "src/embedder/test_util/touch_event_builder.h"
+#include "src/embedder/touch_delegate.h"
+
+namespace embedder_testing {
+
+using fup_EventPhase = fuchsia::ui::pointer::EventPhase;
+using fup_TouchEvent = fuchsia::ui::pointer::TouchEvent;
+using fup_TouchIxnId = fuchsia::ui::pointer::TouchInteractionId;
+using fup_TouchIxnResult = fuchsia::ui::pointer::TouchInteractionResult;
+using fup_TouchIxnStatus = fuchsia::ui::pointer::TouchInteractionStatus;
+using fup_TouchPointerSample = fuchsia::ui::pointer::TouchPointerSample;
+using fup_TouchResponse = fuchsia::ui::pointer::TouchResponse;
+using fup_TouchResponseType = fuchsia::ui::pointer::TouchResponseType;
+
+constexpr std::array<std::array<float, 2>, 2> kRect = {{{0, 0}, {20, 20}}};
+constexpr std::array<float, 9> kIdentity = {1, 0, 0, 0, 1, 0, 0, 0, 1};
+constexpr fup_TouchIxnId kIxnOne = {.device_id = 1u, .pointer_id = 1u, .interaction_id = 2u};
+
+// Fixture to exercise Flutter runner's implementation for
+// fuchsia.ui.pointer.TouchSource.
+class TouchDelegateTest : public ::testing::Test {
+ protected:
+  TouchDelegateTest() : loop_(&kAsyncLoopConfigAttachToCurrentThread) {
+    touch_source_ = std::make_unique<FakeTouchSource>();
+    touch_delegate_ = std::make_unique<embedder::TouchDelegate>(
+        touch_source_bindings_.AddBinding(touch_source_.get()));
+  }
+
+  void RunLoopUntilIdle() { loop_.RunUntilIdle(); }
+
+  std::unique_ptr<FakeTouchSource> touch_source_;
+  std::unique_ptr<embedder::TouchDelegate> touch_delegate_;
+
+  async::Loop loop_;
+  fidl::BindingSet<fuchsia::ui::pointer::TouchSource> touch_source_bindings_;
+};
+
+TEST_F(TouchDelegateTest, Data_FuchsiaTimeVersusFlutterTime) {
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  touch_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+  // Fuchsia ADD -> Flutter ADD+DOWN
+  std::vector<fup_TouchEvent> events =
+      TouchEventBuilder::New()
+          .AddTime(/* in nanoseconds */ 1111789u)
+          .AddViewParameters(kRect, kRect, kIdentity)
+          .AddSample(kIxnOne, fup_EventPhase::ADD, {10.f, 10.f})
+          .AddResult({.interaction = kIxnOne, .status = fup_TouchIxnStatus::GRANTED})
+          .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers->size(), 2u);
+  EXPECT_EQ((*pointers)[0].timestamp, /* in microseconds */ 1111u);
+  EXPECT_EQ((*pointers)[1].timestamp, /* in microseconds */ 1111u);
+}
+
+TEST_F(TouchDelegateTest, Phase_FlutterPhasesAreSynthesized) {
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  touch_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+
+  // Fuchsia ADD -> Flutter ADD+DOWN
+  std::vector<fup_TouchEvent> events =
+      TouchEventBuilder::New()
+          .AddTime(1111000u)
+          .AddViewParameters(kRect, kRect, kIdentity)
+          .AddSample(kIxnOne, fup_EventPhase::ADD, {10.f, 10.f})
+          .AddResult({.interaction = kIxnOne, .status = fup_TouchIxnStatus::GRANTED})
+          .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers->size(), 2u);
+  EXPECT_EQ((*pointers)[0].phase, FlutterPointerPhase::kAdd);
+  EXPECT_EQ((*pointers)[1].phase, FlutterPointerPhase::kDown);
+
+  // Fuchsia CHANGE -> Flutter MOVE
+  events = TouchEventBuilder::New()
+               .AddTime(2222000u)
+               .AddSample(kIxnOne, fup_EventPhase::CHANGE, {10.f, 10.f})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers->size(), 1u);
+  EXPECT_EQ((*pointers)[0].phase, FlutterPointerPhase::kMove);
+
+  // Fuchsia REMOVE -> Flutter UP+REMOVE
+  events = TouchEventBuilder::New()
+               .AddTime(3333000u)
+               .AddSample(kIxnOne, fup_EventPhase::REMOVE, {10.f, 10.f})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers->size(), 2u);
+  EXPECT_EQ((*pointers)[0].phase, FlutterPointerPhase::kUp);
+  EXPECT_EQ((*pointers)[1].phase, FlutterPointerPhase::kRemove);
+}
+
+TEST_F(TouchDelegateTest, Phase_FuchsiaCancelBecomesFlutterCancel) {
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  touch_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+
+  // Fuchsia ADD -> Flutter ADD+DOWN
+  std::vector<fup_TouchEvent> events =
+      TouchEventBuilder::New()
+          .AddTime(1111000u)
+          .AddViewParameters(kRect, kRect, kIdentity)
+          .AddSample(kIxnOne, fup_EventPhase::ADD, {10.f, 10.f})
+          .AddResult({.interaction = kIxnOne, .status = fup_TouchIxnStatus::GRANTED})
+          .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers->size(), 2u);
+  EXPECT_EQ((*pointers)[0].phase, FlutterPointerPhase::kAdd);
+  EXPECT_EQ((*pointers)[1].phase, FlutterPointerPhase::kDown);
+
+  // Fuchsia CANCEL -> Flutter CANCEL
+  events = TouchEventBuilder::New()
+               .AddTime(2222000u)
+               .AddSample(kIxnOne, fup_EventPhase::CANCEL, {10.f, 10.f})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers->size(), 1u);
+  EXPECT_EQ((*pointers)[0].phase, FlutterPointerPhase::kCancel);
+}
+
+TEST_F(TouchDelegateTest, Coordinates_CorrectMapping) {
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  touch_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+
+  // Fuchsia ADD event, with a view parameter that maps the viewport identically
+  // to the view. Then the center point of the viewport should map to the center
+  // of the view, (10.f, 10.f).
+  std::vector<fup_TouchEvent> events =
+      TouchEventBuilder::New()
+          .AddTime(2222000u)
+          .AddViewParameters(/*view*/ {{{0, 0}, {20, 20}}},
+                             /*viewport*/ {{{0, 0}, {20, 20}}},
+                             /*matrix*/ {1, 0, 0, 0, 1, 0, 0, 0, 1})
+          .AddSample(kIxnOne, fup_EventPhase::ADD, {10.f, 10.f})
+          .AddResult({.interaction = kIxnOne, .status = fup_TouchIxnStatus::GRANTED})
+          .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers->size(), 2u);  // ADD - DOWN
+  EXPECT_FLOAT_EQ((*pointers)[0].x, 10.f);
+  EXPECT_FLOAT_EQ((*pointers)[0].y, 10.f);
+  pointers = {};
+
+  // Fuchsia CHANGE event, with a view parameter that translates the viewport by
+  // (10, 10) within the view. Then the minimal point in the viewport (its
+  // origin) should map to the center of the view, (10.f, 10.f).
+  events = TouchEventBuilder::New()
+               .AddTime(2222000u)
+               .AddViewParameters(/*view*/ {{{0, 0}, {20, 20}}},
+                                  /*viewport*/ {{{0, 0}, {20, 20}}},
+                                  /*matrix*/ {1, 0, 0, 0, 1, 0, 10, 10, 1})
+               .AddSample(kIxnOne, fup_EventPhase::CHANGE, {0.f, 0.f})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers->size(), 1u);  // MOVE
+  EXPECT_FLOAT_EQ((*pointers)[0].x, 10.f);
+  EXPECT_FLOAT_EQ((*pointers)[0].y, 10.f);
+
+  // Fuchsia CHANGE event, with a view parameter that scales the viewport by
+  // (0.5, 0.5) within the view. Then the maximal point in the viewport should
+  // map to the center of the view, (10.f, 10.f).
+  events = TouchEventBuilder::New()
+               .AddTime(2222000u)
+               .AddViewParameters(/*view*/ {{{0, 0}, {20, 20}}},
+                                  /*viewport*/ {{{0, 0}, {20, 20}}},
+                                  /*matrix*/ {0.5f, 0, 0, 0, 0.5f, 0, 0, 0, 1})
+               .AddSample(kIxnOne, fup_EventPhase::CHANGE, {20.f, 20.f})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers->size(), 1u);  // MOVE
+  EXPECT_FLOAT_EQ((*pointers)[0].x, 10.f);
+  EXPECT_FLOAT_EQ((*pointers)[0].y, 10.f);
+}
+
+TEST_F(TouchDelegateTest, Coordinates_DownEventClampedToView) {
+  const float kSmallDiscrepancy = -0.00003f;
+
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  touch_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+
+  std::vector<fup_TouchEvent> events =
+      TouchEventBuilder::New()
+          .AddTime(1111000u)
+          .AddViewParameters(kRect, kRect, kIdentity)
+          .AddSample(kIxnOne, fup_EventPhase::ADD, {10.f, kSmallDiscrepancy})
+          .AddResult({.interaction = kIxnOne, .status = fup_TouchIxnStatus::GRANTED})
+          .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers->size(), 2u);
+
+  const auto& add_event = (*pointers)[0];
+  EXPECT_FLOAT_EQ(add_event.x, 10.f);
+  EXPECT_FLOAT_EQ(add_event.y, kSmallDiscrepancy);
+
+  const auto& down_event = (*pointers)[1];
+  EXPECT_FLOAT_EQ(down_event.x, 10.f);
+  EXPECT_EQ(down_event.y, 0.f);
+}
+
+TEST_F(TouchDelegateTest, Protocol_FirstResponseIsEmpty) {
+  bool called = false;
+  touch_delegate_->WatchLoop([&called](std::vector<FlutterPointerEvent> events) { called = true; });
+  RunLoopUntilIdle();  // Server gets Watch call.
+
+  EXPECT_FALSE(called);  // No events yet received to forward to client.
+  // Server sees an initial "response" from client, which is empty, by contract.
+  const auto responses = touch_source_->UploadedResponses();
+  ASSERT_TRUE(responses.has_value());
+  ASSERT_EQ(responses->size(), 0u);
+}
+
+TEST_F(TouchDelegateTest, Protocol_ResponseMatchesEarlierEvents) {
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  touch_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+
+  // Fuchsia view parameter only. Empty response.
+  std::vector<fup_TouchEvent> events = TouchEventBuilder::New()
+                                           .AddTime(1111000u)
+                                           .AddViewParameters(kRect, kRect, kIdentity)
+                                           .BuildAsVector();
+
+  // Fuchsia ptr 1 ADD sample. Yes response.
+  fup_TouchEvent e1 = TouchEventBuilder::New()
+                          .AddTime(1111000u)
+                          .AddViewParameters(kRect, kRect, kIdentity)
+                          .AddSample({.device_id = 0u, .pointer_id = 1u, .interaction_id = 3u},
+                                     fup_EventPhase::ADD, {10.f, 10.f})
+                          .Build();
+  events.emplace_back(std::move(e1));
+
+  // Fuchsia ptr 2 ADD sample. Yes response.
+  fup_TouchEvent e2 = TouchEventBuilder::New()
+                          .AddTime(1111000u)
+                          .AddSample({.device_id = 0u, .pointer_id = 2u, .interaction_id = 3u},
+                                     fup_EventPhase::ADD, {5.f, 5.f})
+                          .Build();
+  events.emplace_back(std::move(e2));
+
+  // Fuchsia ptr 3 ADD sample. Yes response.
+  fup_TouchEvent e3 = TouchEventBuilder::New()
+                          .AddTime(1111000u)
+                          .AddSample({.device_id = 0u, .pointer_id = 3u, .interaction_id = 3u},
+                                     fup_EventPhase::ADD, {1.f, 1.f})
+                          .Build();
+  events.emplace_back(std::move(e3));
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  const auto responses = touch_source_->UploadedResponses();
+  ASSERT_TRUE(responses.has_value());
+  ASSERT_EQ(responses.value().size(), 4u);
+  // Event 0 did not carry a sample, so no response.
+  EXPECT_FALSE(responses.value()[0].has_response_type());
+  // Events 1-3 had a sample, must have a response.
+  EXPECT_TRUE(responses.value()[1].has_response_type());
+  EXPECT_EQ(responses.value()[1].response_type(), fup_TouchResponseType::YES);
+  EXPECT_TRUE(responses.value()[2].has_response_type());
+  EXPECT_EQ(responses.value()[2].response_type(), fup_TouchResponseType::YES);
+  EXPECT_TRUE(responses.value()[3].has_response_type());
+  EXPECT_EQ(responses.value()[3].response_type(), fup_TouchResponseType::YES);
+}
+
+TEST_F(TouchDelegateTest, Protocol_LateGrant) {
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  touch_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+
+  // Fuchsia ADD, no grant result - buffer it.
+  std::vector<fup_TouchEvent> events = TouchEventBuilder::New()
+                                           .AddTime(1111000u)
+                                           .AddViewParameters(kRect, kRect, kIdentity)
+                                           .AddSample(kIxnOne, fup_EventPhase::ADD, {10.f, 10.f})
+                                           .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  EXPECT_EQ(pointers.value().size(), 0u);
+  pointers = {};
+
+  // Fuchsia CHANGE, no grant result - buffer it.
+  events = TouchEventBuilder::New()
+               .AddTime(2222000u)
+               .AddSample(kIxnOne, fup_EventPhase::CHANGE, {10.f, 10.f})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  EXPECT_EQ(pointers.value().size(), 0u);
+  pointers = {};
+
+  // Fuchsia result: ownership granted. Buffered pointers released.
+  events = TouchEventBuilder::New()
+               .AddTime(3333000u)
+               .AddResult({kIxnOne, fup_TouchIxnStatus::GRANTED})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 3u);  // ADD - DOWN - MOVE
+  EXPECT_EQ(pointers.value()[0].phase, FlutterPointerPhase::kAdd);
+  EXPECT_EQ(pointers.value()[1].phase, FlutterPointerPhase::kDown);
+  EXPECT_EQ(pointers.value()[2].phase, FlutterPointerPhase::kMove);
+  pointers = {};
+
+  // Fuchsia CHANGE, grant result - release immediately.
+  events = TouchEventBuilder::New()
+               .AddTime(/* in nanoseconds */ 4444000u)
+               .AddSample(kIxnOne, fup_EventPhase::CHANGE, {10.f, 10.f})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 1u);
+  EXPECT_EQ(pointers.value()[0].phase, FlutterPointerPhase::kMove);
+  EXPECT_EQ(pointers.value()[0].timestamp, /* in microseconds */ 4444u);
+  pointers = {};
+}
+
+TEST_F(TouchDelegateTest, Protocol_LateGrantCombo) {
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  touch_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+
+  // Fuchsia ADD, no grant result - buffer it.
+  std::vector<fup_TouchEvent> events = TouchEventBuilder::New()
+                                           .AddTime(1111000u)
+                                           .AddViewParameters(kRect, kRect, kIdentity)
+                                           .AddSample(kIxnOne, fup_EventPhase::ADD, {10.f, 10.f})
+                                           .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  EXPECT_EQ(pointers.value().size(), 0u);
+  pointers = {};
+
+  // Fuchsia CHANGE, no grant result - buffer it.
+  events = TouchEventBuilder::New()
+               .AddTime(2222000u)
+               .AddSample(kIxnOne, fup_EventPhase::CHANGE, {10.f, 10.f})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  EXPECT_EQ(pointers.value().size(), 0u);
+  pointers = {};
+
+  // Fuchsia CHANGE, with grant result - release buffered events.
+  events = TouchEventBuilder::New()
+               .AddTime(3333000u)
+               .AddSample(kIxnOne, fup_EventPhase::CHANGE, {10.f, 10.f})
+               .AddResult({kIxnOne, fup_TouchIxnStatus::GRANTED})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 4u);  // ADD - DOWN - MOVE - MOVE
+  EXPECT_EQ(pointers.value()[0].phase, FlutterPointerPhase::kAdd);
+  EXPECT_EQ(pointers.value()[0].timestamp, 1111u);
+  EXPECT_EQ(pointers.value()[1].phase, FlutterPointerPhase::kDown);
+  EXPECT_EQ(pointers.value()[1].timestamp, 1111u);
+  EXPECT_EQ(pointers.value()[2].phase, FlutterPointerPhase::kMove);
+  EXPECT_EQ(pointers.value()[2].timestamp, 2222u);
+  EXPECT_EQ(pointers.value()[3].phase, FlutterPointerPhase::kMove);
+  EXPECT_EQ(pointers.value()[3].timestamp, 3333u);
+  pointers = {};
+}
+
+TEST_F(TouchDelegateTest, Protocol_EarlyGrant) {
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  touch_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+
+  // Fuchsia ADD, with grant result - release immediately.
+  std::vector<fup_TouchEvent> events = TouchEventBuilder::New()
+                                           .AddTime(1111000u)
+                                           .AddViewParameters(kRect, kRect, kIdentity)
+                                           .AddSample(kIxnOne, fup_EventPhase::ADD, {10.f, 10.f})
+                                           .AddResult({kIxnOne, fup_TouchIxnStatus::GRANTED})
+                                           .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 2u);
+  EXPECT_EQ(pointers.value()[0].phase, FlutterPointerPhase::kAdd);
+  EXPECT_EQ(pointers.value()[1].phase, FlutterPointerPhase::kDown);
+  pointers = {};
+
+  // Fuchsia CHANGE, after grant result - release immediately.
+  events = TouchEventBuilder::New()
+               .AddTime(2222000u)
+               .AddSample(kIxnOne, fup_EventPhase::CHANGE, {10.f, 10.f})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 1u);
+  EXPECT_EQ(pointers.value()[0].phase, FlutterPointerPhase::kMove);
+  pointers = {};
+}
+
+TEST_F(TouchDelegateTest, Protocol_LateDeny) {
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  touch_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+
+  // Fuchsia ADD, no grant result - buffer it.
+  std::vector<fup_TouchEvent> events = TouchEventBuilder::New()
+                                           .AddTime(1111000u)
+                                           .AddViewParameters(kRect, kRect, kIdentity)
+                                           .AddSample(kIxnOne, fup_EventPhase::ADD, {10.f, 10.f})
+                                           .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  EXPECT_EQ(pointers.value().size(), 0u);
+  pointers = {};
+
+  // Fuchsia CHANGE, no grant result - buffer it.
+  events = TouchEventBuilder::New()
+               .AddTime(2222000u)
+               .AddSample(kIxnOne, fup_EventPhase::CHANGE, {10.f, 10.f})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  EXPECT_EQ(pointers.value().size(), 0u);
+  pointers = {};
+
+  // Fuchsia result: ownership denied. Buffered pointers deleted.
+  events = TouchEventBuilder::New()
+               .AddTime(3333000u)
+               .AddResult({kIxnOne, fup_TouchIxnStatus::DENIED})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 0u);  // Do not release to client!
+  pointers = {};
+}
+
+TEST_F(TouchDelegateTest, Protocol_LateDenyCombo) {
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  touch_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+
+  // Fuchsia ADD, no grant result - buffer it.
+  std::vector<fup_TouchEvent> events = TouchEventBuilder::New()
+                                           .AddTime(1111000u)
+                                           .AddViewParameters(kRect, kRect, kIdentity)
+                                           .AddSample(kIxnOne, fup_EventPhase::ADD, {10.f, 10.f})
+                                           .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  EXPECT_EQ(pointers.value().size(), 0u);
+  pointers = {};
+
+  // Fuchsia CHANGE, no grant result - buffer it.
+  events = TouchEventBuilder::New()
+               .AddTime(2222000u)
+               .AddSample(kIxnOne, fup_EventPhase::CHANGE, {10.f, 10.f})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  EXPECT_EQ(pointers.value().size(), 0u);
+  pointers = {};
+
+  // Fuchsia result: ownership denied. Buffered pointers deleted.
+  events = TouchEventBuilder::New()
+               .AddTime(3333000u)
+               .AddSample(kIxnOne, fup_EventPhase::CANCEL, {10.f, 10.f})
+               .AddResult({kIxnOne, fup_TouchIxnStatus::DENIED})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 0u);  // Do not release to client!
+  pointers = {};
+}
+
+TEST_F(TouchDelegateTest, Protocol_PointersAreIndependent) {
+  std::optional<std::vector<FlutterPointerEvent>> pointers;
+  touch_delegate_->WatchLoop(
+      [&pointers](std::vector<FlutterPointerEvent> events) { pointers = std::move(events); });
+  RunLoopUntilIdle();  // Server gets watch call.
+
+  constexpr fup_TouchIxnId kIxnTwo = {.device_id = 1u, .pointer_id = 2u, .interaction_id = 2u};
+
+  // Fuchsia ptr1 ADD and ptr2 ADD, no grant result for either - buffer them.
+  std::vector<fup_TouchEvent> events = TouchEventBuilder::New()
+                                           .AddTime(1111000u)
+                                           .AddViewParameters(kRect, kRect, kIdentity)
+                                           .AddSample(kIxnOne, fup_EventPhase::ADD, {10.f, 10.f})
+                                           .BuildAsVector();
+  fup_TouchEvent ptr2 = TouchEventBuilder::New()
+                            .AddTime(1111000u)
+                            .AddSample(kIxnTwo, fup_EventPhase::ADD, {15.f, 15.f})
+                            .Build();
+  events.emplace_back(std::move(ptr2));
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  EXPECT_EQ(pointers.value().size(), 0u);
+  pointers = {};
+
+  // Server grants win to pointer 2.
+  events = TouchEventBuilder::New()
+               .AddTime(2222000u)
+               .AddResult({kIxnTwo, fup_TouchIxnStatus::GRANTED})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  // Note: Fuchsia's device and pointer IDs (both 32 bit) are coerced together
+  // to fit in Flutter's 64-bit device ID. However, Flutter's pointer_identifier
+  // is not set by platform runner code - PointerDataCaptureConverter (PDPC)
+  // sets it.
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 2u);
+  EXPECT_EQ(pointers.value()[0].device, (int64_t)((1ul << 16) | 2u));
+  EXPECT_EQ(pointers.value()[0].phase, FlutterPointerPhase::kAdd);
+  EXPECT_EQ(pointers.value()[1].device, (int64_t)((1ul << 16) | 2u));
+  EXPECT_EQ(pointers.value()[1].phase, FlutterPointerPhase::kDown);
+  pointers = {};
+
+  // Server grants win to pointer 1.
+  events = TouchEventBuilder::New()
+               .AddTime(3333000u)
+               .AddResult({kIxnOne, fup_TouchIxnStatus::GRANTED})
+               .BuildAsVector();
+  touch_source_->ScheduleCallback(std::move(events));
+  RunLoopUntilIdle();
+
+  ASSERT_TRUE(pointers.has_value());
+  ASSERT_EQ(pointers.value().size(), 2u);
+  EXPECT_EQ(pointers.value()[0].device, (int64_t)((1ul << 16) | 1u));
+  EXPECT_EQ(pointers.value()[0].phase, FlutterPointerPhase::kAdd);
+  EXPECT_EQ(pointers.value()[1].device, (int64_t)((1ul << 16) | 1u));
+  EXPECT_EQ(pointers.value()[1].phase, FlutterPointerPhase::kDown);
+  pointers = {};
+}
+
+}  // namespace embedder_testing
diff --git a/third_party/googletest b/third_party/googletest
index 42ca3da..0b56cbe 160000
--- a/third_party/googletest
+++ b/third_party/googletest
@@ -1 +1 @@
-Subproject commit 42ca3da5798750c2998dd09b751838227e1f58d3
+Subproject commit 0b56cbec076adbb75d22e3d9f75e99ad063d1ac3
diff --git a/third_party/sdk-integration b/third_party/sdk-integration
index 43888d9..5956f5f 160000
--- a/third_party/sdk-integration
+++ b/third_party/sdk-integration
@@ -1 +1 @@
-Subproject commit 43888d9923fee6cd774cf6932c94bbea41307f8e
+Subproject commit 5956f5f770fa73f62949bc1429b1575403db9064