From ce18107f293064f1d15f85b29bd5e9704b8ac84a Mon Sep 17 00:00:00 2001 From: "Rich Cameron (rcameron)" Date: Mon, 24 Oct 2016 10:54:35 -0700 Subject: [PATCH] gesture unit tests Reviewers: O4 Material Motion Apple platform reviewers, O2 Material Motion, featherless Reviewed By: O4 Material Motion Apple platform reviewers, O2 Material Motion, featherless Subscribers: featherless Tags: #material_motion Differential Revision: http://codereview.cc/D1757 --- .../Catalog/Catalog.xcodeproj/project.pbxproj | 18 +- src/DirectManipulationMotionFamily.swift | 14 +- tests/unit/AnchorPointTests.swift | 76 ++++++++ tests/unit/GestureActionTests.swift | 79 ++++++++ tests/unit/SimpleGestureTests.swift | 47 ++++- tests/unit/TestableGestureRecognizer.swift | 179 +++++++++++++++++- 6 files changed, 401 insertions(+), 12 deletions(-) create mode 100644 tests/unit/AnchorPointTests.swift create mode 100644 tests/unit/GestureActionTests.swift diff --git a/examples/apps/Catalog/Catalog.xcodeproj/project.pbxproj b/examples/apps/Catalog/Catalog.xcodeproj/project.pbxproj index ac9f4d0..db1fdce 100644 --- a/examples/apps/Catalog/Catalog.xcodeproj/project.pbxproj +++ b/examples/apps/Catalog/Catalog.xcodeproj/project.pbxproj @@ -14,9 +14,11 @@ 73D1CC614139358D78678E9F /* Pods_MaterialMotionDirectManipulationFamily_Catalog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 47AD29C808E6149118D934CA /* Pods_MaterialMotionDirectManipulationFamily_Catalog.framework */; }; DE0294BD1D5CE6E300A5BBA5 /* SimpleGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0294BB1D5CE6B400A5BBA5 /* SimpleGestureTests.swift */; }; DE1EFE1A1DB7EA33009939F5 /* ObjectiveCAPITests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE1EFE191DB7EA33009939F5 /* ObjectiveCAPITests.m */; }; + DE1EFE1D1DB81FB6009939F5 /* GestureActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1EFE1B1DB81F4C009939F5 /* GestureActionTests.swift */; }; DE73309F1D88A188003BBF53 /* ComposedGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE73309D1D88A14B003BBF53 /* ComposedGestureTests.swift */; }; DE7330A21D88BD9C003BBF53 /* TestableGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7330A01D88B9A4003BBF53 /* TestableGestureRecognizer.swift */; }; DE7977851D4FF53900691A95 /* DirectManipulationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7977841D4FF53900691A95 /* DirectManipulationViewController.swift */; }; + DEFA68101DB96A2000C83810 /* AnchorPointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFA680E1DB969A700C83810 /* AnchorPointTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -46,9 +48,11 @@ DC36123760B2FCBFD565C0E7 /* Pods-MaterialMotionDirectManipulationFamily-UnitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MaterialMotionDirectManipulationFamily-UnitTests.debug.xcconfig"; path = "../../../Pods/Target Support Files/Pods-MaterialMotionDirectManipulationFamily-UnitTests/Pods-MaterialMotionDirectManipulationFamily-UnitTests.debug.xcconfig"; sourceTree = ""; }; DE0294BB1D5CE6B400A5BBA5 /* SimpleGestureTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SimpleGestureTests.swift; sourceTree = ""; }; DE1EFE191DB7EA33009939F5 /* ObjectiveCAPITests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ObjectiveCAPITests.m; sourceTree = ""; }; + DE1EFE1B1DB81F4C009939F5 /* GestureActionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GestureActionTests.swift; sourceTree = ""; }; DE73309D1D88A14B003BBF53 /* ComposedGestureTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComposedGestureTests.swift; sourceTree = ""; }; DE7330A01D88B9A4003BBF53 /* TestableGestureRecognizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestableGestureRecognizer.swift; sourceTree = ""; }; DE7977841D4FF53900691A95 /* DirectManipulationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DirectManipulationViewController.swift; path = apps/Catalog/DirectManipulationViewController.swift; sourceTree = ""; }; + DEFA680E1DB969A700C83810 /* AnchorPointTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnchorPointTests.swift; sourceTree = ""; }; F3DF17AC229B9E4FE3049EC8 /* Pods-MaterialMotionDirectManipulationFamily-Catalog.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MaterialMotionDirectManipulationFamily-Catalog.release.xcconfig"; path = "../../../Pods/Target Support Files/Pods-MaterialMotionDirectManipulationFamily-Catalog/Pods-MaterialMotionDirectManipulationFamily-Catalog.release.xcconfig"; sourceTree = ""; }; F4B078EA77710D032C55A942 /* Pods-MaterialMotionDirectManipulationFamily-Catalog.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MaterialMotionDirectManipulationFamily-Catalog.debug.xcconfig"; path = "../../../Pods/Target Support Files/Pods-MaterialMotionDirectManipulationFamily-Catalog/Pods-MaterialMotionDirectManipulationFamily-Catalog.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -108,9 +112,11 @@ 666FAA971D384A6B000363DA /* tests */ = { isa = PBXGroup; children = ( + DE1EFE1E1DB820B3009939F5 /* helpers */, DE0294BB1D5CE6B400A5BBA5 /* SimpleGestureTests.swift */, DE73309D1D88A14B003BBF53 /* ComposedGestureTests.swift */, - DE7330A01D88B9A4003BBF53 /* TestableGestureRecognizer.swift */, + DE1EFE1B1DB81F4C009939F5 /* GestureActionTests.swift */, + DEFA680E1DB969A700C83810 /* AnchorPointTests.swift */, DE1EFE191DB7EA33009939F5 /* ObjectiveCAPITests.m */, ); name = tests; @@ -175,6 +181,14 @@ name = Pods; sourceTree = ""; }; + DE1EFE1E1DB820B3009939F5 /* helpers */ = { + isa = PBXGroup; + children = ( + DE7330A01D88B9A4003BBF53 /* TestableGestureRecognizer.swift */, + ); + name = helpers; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -386,6 +400,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DE1EFE1D1DB81FB6009939F5 /* GestureActionTests.swift in Sources */, + DEFA68101DB96A2000C83810 /* AnchorPointTests.swift in Sources */, DE0294BD1D5CE6E300A5BBA5 /* SimpleGestureTests.swift in Sources */, DE7330A21D88BD9C003BBF53 /* TestableGestureRecognizer.swift in Sources */, DE1EFE1A1DB7EA33009939F5 /* ObjectiveCAPITests.m in Sources */, diff --git a/src/DirectManipulationMotionFamily.swift b/src/DirectManipulationMotionFamily.swift index eb8dd12..4f5faa7 100644 --- a/src/DirectManipulationMotionFamily.swift +++ b/src/DirectManipulationMotionFamily.swift @@ -37,7 +37,9 @@ public final class Draggable: NSObject, Plan { } public func copy(with zone: NSZone? = nil) -> Any { - return Draggable(withGestureRecognizer: panGestureRecognizer) + let draggable = Draggable(withGestureRecognizer: panGestureRecognizer) + draggable.shouldAdjustAnchorPointOnGestureStart = shouldAdjustAnchorPointOnGestureStart + return draggable } } @@ -120,7 +122,9 @@ public final class Pinchable: NSObject, Plan { } public func copy(with zone: NSZone? = nil) -> Any { - return Pinchable(withGestureRecognizer: pinchGestureRecognizer) + let pinchable = Pinchable(withGestureRecognizer: pinchGestureRecognizer) + pinchable.shouldAdjustAnchorPointOnGestureStart = shouldAdjustAnchorPointOnGestureStart + return pinchable } } @@ -137,7 +141,7 @@ final class PinchablePerformer: NSObject, PlanPerforming, ComposablePerforming { func addPlan(_ plan: Plan) { guard let plan = plan as? Pinchable else { - fatalError("DraggablePerformer can only add Draggable plans.") + fatalError("PinchablePerformer can only add Pinchable plans.") } let recognizer = plan.pinchGestureRecognizer @@ -197,7 +201,9 @@ public final class Rotatable: NSObject, Plan { } public func copy(with zone: NSZone? = nil) -> Any { - return Rotatable(withGestureRecognizer: rotationGestureRecognizer) + let rotatable = Rotatable(withGestureRecognizer: rotationGestureRecognizer) + rotatable.shouldAdjustAnchorPointOnGestureStart = shouldAdjustAnchorPointOnGestureStart + return rotatable } } diff --git a/tests/unit/AnchorPointTests.swift b/tests/unit/AnchorPointTests.swift new file mode 100644 index 0000000..071b7eb --- /dev/null +++ b/tests/unit/AnchorPointTests.swift @@ -0,0 +1,76 @@ +/* + Copyright 2016-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import XCTest +import MaterialMotionRuntime +import MaterialMotionDirectManipulationFamily + +class AnchorPointTests: XCTestCase { + func testThatAnchorPointIsModifiedByDraggablePerformer() { + let pan = TestablePanGestureRecognizer() + let draggable = Draggable(withGestureRecognizer: pan) + draggable.shouldAdjustAnchorPointOnGestureStart = true + let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + let scheduler = Scheduler() + scheduler.addPlan(draggable, to: view) + + pan.performTouch(location: CGPoint(x: 10, y: 20), state: .began) + + XCTAssertEqual(view.layer.anchorPoint, CGPoint(x: 0.1, y: 0.2)) + } + + func testThatAnchorPointIsModifiedByPinchablePerformer() { + let pinch = TestablePinchGestureRecognizer() + let pinchable = Pinchable(withGestureRecognizer: pinch) + let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + let scheduler = Scheduler() + scheduler.addPlan(pinchable, to: view) + + pinch.performTouch(location: CGPoint(x: 10, y: 20), state: .began) + + XCTAssertEqual(view.layer.anchorPoint, CGPoint(x: 0.1, y: 0.2)) + } + + func testThatAnchorPointIsModifiedByRotatablePerformer() { + let rotate = TestableRotationGestureRecognizer() + let rotatable = Rotatable(withGestureRecognizer: rotate) + let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + let scheduler = Scheduler() + scheduler.addPlan(rotatable, to: view) + + rotate.performTouch(location: CGPoint(x: 10, y: 20), state: .began) + + XCTAssertEqual(view.layer.anchorPoint, CGPoint(x: 0.1, y: 0.2)) + } + + func testThatPinchableAnchorPointFlagPreventsModification() { + let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + let pinch = TestablePinchGestureRecognizer() + let pinchable = Pinchable(withGestureRecognizer: pinch) + pinchable.shouldAdjustAnchorPointOnGestureStart = false + + let scheduler = Scheduler() + scheduler.addPlan(pinchable, to: view) + + pinch.performTouch(location: CGPoint(x: 10, y: 20), state: .began) + + XCTAssertEqual(view.layer.anchorPoint, CGPoint(x: 0.5, y: 0.5)) + } +} diff --git a/tests/unit/GestureActionTests.swift b/tests/unit/GestureActionTests.swift new file mode 100644 index 0000000..014c3bd --- /dev/null +++ b/tests/unit/GestureActionTests.swift @@ -0,0 +1,79 @@ +/* + Copyright 2016-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import XCTest +import MaterialMotionRuntime +import MaterialMotionDirectManipulationFamily + +class GestureActionTests: XCTestCase { + func testThatDraggableDrags() { + let pan = TestablePanGestureRecognizer() + let draggable = Draggable(withGestureRecognizer: pan) + let view = UIView(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + + let scheduler = Scheduler() + scheduler.addPlan(draggable, to: view) + + pan.performTouch(state: .began) + pan.performTouch(translation: CGPoint(x: 25, y: -50), state: .changed) + + let newCenter = CGPoint(x: 25+25, y: 25-50) + XCTAssertEqual(newCenter, view.center, "View's center (\(view.center.x), \(view.center.y)) should be (\(newCenter.x), \(newCenter.y))") + } + + func testThatPinchableScales() { + let pinch = TestablePinchGestureRecognizer() + let pinchable = Pinchable(withGestureRecognizer: pinch) + let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 80)) + + let scheduler = Scheduler() + scheduler.addPlan(pinchable, to: view) + + pinch.performTouch(scale: 1, state: .began) + pinch.performTouch(scale: 0.50, state: .changed) + + XCTAssertEqual(view.frame.width, 50, "View's width (\(view.bounds.width)) should equal 50") + XCTAssertEqual(view.frame.height, 40, "View's height (\(view.bounds.height)) should equal 40") + } + + func testThatRotatableRotates() { + let rotateGesture = TestableRotationGestureRecognizer() + let rotatable = Rotatable(withGestureRecognizer: rotateGesture) + let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 80)) + + let scheduler = Scheduler() + scheduler.addPlan(rotatable, to: view) + + rotateGesture.performTouch(state: .began) + rotateGesture.performTouch(rotation: CGFloat.pi / 2, state: .changed) + + let rotation = atan2(view.transform.b, view.transform.a) + + XCTAssertEqual(rotation, CGFloat.pi / 2, "View's rotation (\(rotation) should equal Pi / 2") + } + + func testThatAnchorPointIsModified() { + let view = UIView(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + let newAnchorPoint = CGPoint(x: 0.33, y: 0.33) + let changeAnchor = ChangeAnchorPoint(withAnchorPoint: newAnchorPoint) + + let scheduler = Scheduler() + scheduler.addPlan(changeAnchor, to: view) + + XCTAssertEqual(view.layer.anchorPoint, newAnchorPoint, + "View's anchor point (\(view.layer.anchorPoint.x), \(view.layer.anchorPoint.y)) should be (\(newAnchorPoint.x), \(newAnchorPoint.y))") + } +} diff --git a/tests/unit/SimpleGestureTests.swift b/tests/unit/SimpleGestureTests.swift index b9eb79c..04355e4 100644 --- a/tests/unit/SimpleGestureTests.swift +++ b/tests/unit/SimpleGestureTests.swift @@ -58,18 +58,59 @@ class SimpleGestureTests: XCTestCase { let plan = Draggable(withGestureRecognizer: recognizer) let scheduler = Scheduler() - scheduler.addPlan(plan, to: targetView) - guard recognizer.target is DraggablePerformer else { + guard recognizer.targets.first is DraggablePerformer else { XCTFail("Pan gesture recognizer should have a DraggablePerformer target") return } let selector = #selector(DraggablePerformer.handle(gesture:)) - guard let action = recognizer.action, action == selector else { + guard recognizer.actions.contains(selector) else { XCTFail("Pan gesture recognizer should have an action matching 'handle(gesture:)'") return } } + + func testThatTargetActionIsAddedToRecognizerUnderPinchablePlan() { + let targetView = UIView() + + let recognizer = TestablePinchGestureRecognizer() + let plan = Pinchable(withGestureRecognizer: recognizer) + + let scheduler = Scheduler() + scheduler.addPlan(plan, to: targetView) + + guard recognizer.targets.first is PinchablePerformer else { + XCTFail("Pinch gesture recognizer should have a PinchablePerformer target") + return + } + + let selector = #selector(PinchablePerformer.handle(gesture:)) + guard recognizer.actions.contains(selector) else { + XCTFail("Pinch gesture recognizer should have an action matching 'handle(gesture:)'") + return + } + } + + func testThatTargetActionIsAddedToRecognizerUnderRotatablePlan() { + let targetView = UIView() + + let recognizer = TestableRotationGestureRecognizer() + let plan = Rotatable(withGestureRecognizer: recognizer) + + let scheduler = Scheduler() + scheduler.addPlan(plan, to: targetView) + + guard recognizer.targets.first is RotatablePerformer else { + XCTFail("Rotation gesture recognizer should have a RotatablePerformer target") + return + } + + let selector = #selector(RotatablePerformer.handle(gesture:)) + guard recognizer.actions.contains(selector) else { + XCTFail("Rotation gesture recognizer should have an action matching 'handle(gesture:)'") + return + } + } } diff --git a/tests/unit/TestableGestureRecognizer.swift b/tests/unit/TestableGestureRecognizer.swift index 7d4d001..358409c 100644 --- a/tests/unit/TestableGestureRecognizer.swift +++ b/tests/unit/TestableGestureRecognizer.swift @@ -17,14 +17,185 @@ import UIKit import MaterialMotionRuntime +/// Testing technique borrowed from: http://vojtastavik.com/2016/03/30/testing-gesture-recognizers/ + +/// A proxy object that allows simulating touch events +class TestableGestureRecognizerProxy { + + fileprivate class TargetAction { + let target: AnyObject + let action: Selector + + init(target: AnyObject, action: Selector) { + self.target = target + self.action = action + } + } + + fileprivate var targetActions: [TargetAction] = [] + + var state: UIGestureRecognizerState? + var location: CGPoint? + + fileprivate func addTarget(_ target: Any, action: Selector) { + let targetAction = TargetAction(target: target as AnyObject, action: action) + targetActions.append(targetAction) + } + + fileprivate func performTouch(location: CGPoint? = nil, state: UIGestureRecognizerState, with gestureRecognizer: UIGestureRecognizer) { + self.location = location + self.state = state + + for targetAction in targetActions { + targetAction.target.performSelector(onMainThread: targetAction.action, with: gestureRecognizer, waitUntilDone: true) + } + } +} + +/// A pan gesture recognizer that facilitates testing through a proxy object class TestablePanGestureRecognizer: UIPanGestureRecognizer { - var target: Any? - var action: Selector? + fileprivate var testProxy = TestableGestureRecognizerProxy() + + var targets: [AnyObject] { + return testProxy.targetActions.map { $0.target } + } + + var actions: [Selector] { + return testProxy.targetActions.map { $0.action } + } + + private var testTranslation: CGPoint? + + override func translation(in view: UIView?) -> CGPoint { + if let translation = testTranslation { + return translation + } + + return CGPoint.zero + } + + private var _state: UIGestureRecognizerState = .possible + override var state: UIGestureRecognizerState { + get { + return testProxy.state ?? _state + } + set { + _state = newValue + } + } + + override func location(in view: UIView?) -> CGPoint { + return testProxy.location ?? super.location(in: view) + } override func addTarget(_ target: Any, action: Selector) { - self.target = target - self.action = action + testProxy.addTarget(target, action: action) super.addTarget(target, action: action) } + + func performTouch(location: CGPoint? = nil, translation: CGPoint? = nil, state: UIGestureRecognizerState) { + testTranslation = translation + testProxy.performTouch(location: location, state: state, with: self) + } +} + +/// A pinch gesture recognizer that facilitates testing through a proxy object +class TestablePinchGestureRecognizer: UIPinchGestureRecognizer { + fileprivate var testProxy = TestableGestureRecognizerProxy() + + var targets: [AnyObject] { + return testProxy.targetActions.map { $0.target } + } + + var actions: [Selector] { + return testProxy.targetActions.map { $0.action } + } + + private var testScale: CGFloat? + + private var _scale: CGFloat = 1 + override var scale: CGFloat { + get { + return testScale ?? _scale + } + set { + _scale = newValue + } + } + + private var _state: UIGestureRecognizerState = .possible + override var state: UIGestureRecognizerState { + get { + return testProxy.state ?? _state + } + set { + _state = newValue + } + } + + override func location(in view: UIView?) -> CGPoint { + return testProxy.location ?? super.location(in: view) + } + + override func addTarget(_ target: Any, action: Selector) { + testProxy.addTarget(target, action: action) + + super.addTarget(target, action: action) + } + + func performTouch(location: CGPoint? = nil, scale: CGFloat? = nil, state: UIGestureRecognizerState) { + testScale = scale + testProxy.performTouch(location: location, state: state, with: self) + } +} + +/// A rotation gesture recognizer that facilitates testing through a proxy object +class TestableRotationGestureRecognizer: UIRotationGestureRecognizer { + fileprivate var testProxy = TestableGestureRecognizerProxy() + + var targets: [AnyObject] { + return testProxy.targetActions.map { $0.target } + } + + var actions: [Selector] { + return testProxy.targetActions.map { $0.action } + } + + private var testRotation: CGFloat? + + private var _rotation: CGFloat = 0 + override var rotation: CGFloat { + get { + return testRotation ?? _rotation + } + set { + _rotation = newValue + } + } + + private var _state: UIGestureRecognizerState = .possible + override var state: UIGestureRecognizerState { + get { + return testProxy.state ?? _state + } + set { + _state = newValue + } + } + + override func location(in view: UIView?) -> CGPoint { + return testProxy.location ?? super.location(in: view) + } + + override func addTarget(_ target: Any, action: Selector) { + testProxy.addTarget(target, action: action) + + super.addTarget(target, action: action) + } + + func performTouch(location: CGPoint? = nil, rotation: CGFloat? = nil, state: UIGestureRecognizerState) { + testRotation = rotation + testProxy.performTouch(location: location, state: state, with: self) + } }