diff --git a/test/bacon-ui/Rakefile b/test/bacon-ui/Rakefile new file mode 100644 index 00000000..ca845ba9 --- /dev/null +++ b/test/bacon-ui/Rakefile @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +$:.unshift("/Library/RubyMotion/lib") +require 'motion/project' + +Motion::Project::App.setup do |app| + # Use `rake config' to see complete project settings. + app.name = 'MacBacon UI' + app.frameworks << 'CoreGraphics' +end diff --git a/test/bacon-ui/app/app_delegate.rb b/test/bacon-ui/app/app_delegate.rb new file mode 100644 index 00000000..43b2aa9e --- /dev/null +++ b/test/bacon-ui/app/app_delegate.rb @@ -0,0 +1,5 @@ +class AppDelegate + def application(application, didFinishLaunchingWithOptions:launchOptions) + true + end +end diff --git a/test/bacon-ui/app/macbacon_ui.rb b/test/bacon-ui/app/macbacon_ui.rb new file mode 100644 index 00000000..79c5a907 --- /dev/null +++ b/test/bacon-ui/app/macbacon_ui.rb @@ -0,0 +1,530 @@ +# TODO make it pwetty +class TouchHighlightView < UIView + DIAMETER = 7 + + def self.forTouch(touch) + touch.window.addSubview(alloc.initWithTouch(touch)) if touch.window + end + + def self.updateLocationForTouch(touch) + if highlight = highlightForTouch(touch) + highlight.updateLocation + end + end + + def self.removeForTouch(touch) + performSelector('reallyRemoveForTouch:', withObject:touch, afterDelay:0.1) + end + + def self.reallyRemoveForTouch(touch) + if highlight = highlightForTouch(touch) + highlight.removeFromSuperview + end + end + + def self.highlightForTouch(touch) + # If the touch has no window anymore, then the window has already been destroyed. + touch.window.subviews.find { |v| v.is_a?(TouchHighlightView) && v.touch == touch } if touch.window + end + + attr_reader :touch + + def initWithTouch(touch) + @touch = touch + if initWithFrame(highlightFrame) + self.backgroundColor = UIColor.redColor + end + self + end + + def highlightFrame + point = @touch.locationInView(nil) + offset = (DIAMETER-1)/2 + CGRectMake(point.x-offset, point.y-offset, DIAMETER, DIAMETER) + end + + def updateLocation + self.frame = highlightFrame + end +end + +module UIApplicationExt + attr_accessor :logEvents + attr_accessor :highlightTouches + + def sendEvent(event) + if @logEvents + NSLog(event.description) + end + + # TODO this code breaks most gestures + if @highlightTouches + event.allTouches.each do |touch| + case touch.phase + when UITouchPhaseBegan + TouchHighlightView.forTouch(touch) + when UITouchPhaseMoved + TouchHighlightView.updateLocationForTouch(touch) + when UITouchPhaseStationary + # nothing + when UITouchPhaseEnded, UITouchPhaseCancelled + TouchHighlightView.removeForTouch(touch) + end + end + end + + super + end +end + +module RunLoopHelpers + extend self + + MIN_INTERVAL = 0.01 + + # TODO this is already in the master branch, merge it! + def proper_wait(sec) + CFRunLoopRunInMode(KCFRunLoopDefaultMode, sec, false) + end + + # Keeps trying the block until it returns a truthy value. Has a default + # `timeout` of 3 seconds. + # + # Returns the return value of the block. + def wait_till(timeout = Bacon::Functional.default_timeout) + result = nil + interval = MIN_INTERVAL + while interval < timeout + # First sleep for a bit, otherwise it can happen that even if a view + # (e.g. a button) is found, it won't properly respond to touches yet. + proper_wait(interval) + break if result = yield + interval *= 2 + end + result + end +end + +module UIViewExt + def viewByName(accessibilityLabel, timeout = Bacon::Functional.default_timeout) + RunLoopHelpers.wait_till(timeout) { _viewByName(accessibilityLabel) } + end + + def viewsByClass(viewClass, timeout = Bacon::Functional.default_timeout) + views = RunLoopHelpers.wait_till(timeout) { v = _viewsByClass(viewClass); v if !v.empty? } || [] + # sort by Y first, then X + views.sort_by { |v| v.convertPoint(v.bounds.origin, toView:nil).to_a.reverse } + end + + def up(viewClass, timeout = Bacon::Functional.default_timeout) + RunLoopHelpers.wait_till(timeout) do + view = self + view = view.superview while view && !view.is_a?(viewClass) + view + end + end + + private + + def _viewByName(accessibilityLabel) + subviews.each do |subview| + if subview.accessibilityLabel == accessibilityLabel + return subview + elsif found = subview.send(:_viewByName, accessibilityLabel) + return found + end + end + nil + end + + def _viewsByClass(viewClass) + result = [] + subviews.each do |view| + result << view if view.is_a?(viewClass) + result.concat(view.send(:_viewsByClass, viewClass)) + end + result + end +end +UIView.send(:include, UIViewExt) + +module Bacon + class Context + # TODO + # * :navigation => true + # * :tab => true + def tests(controller_class, options = {}) + @controller_class = controller_class + extend Bacon::Functional::API + extend Bacon::Functional::ContextExt + end + end + + module Functional + module ContextExt + def self.extended(context) + context.before do + # Ensure a window exists and is on screen. + window + end + + context.after do + # Disable logging events after each spec + app = UIApplication.sharedApplication + if app.respond_to?(:logEvents=) + app.logEvents = app.highlightTouches = false + end + + # Remove window and ensure a new one will be created on the next run. + window.removeFromSuperview + proper_wait(0.3) # give objects a chance to do their cleanup, otherwise sometimes a segfault will occur + @window = nil + @controller = nil + end + end + + def window + unless @window + @window = UIWindow.alloc.initWithFrame(UIScreen.mainScreen.bounds) + # On some iOS/simulator versions the background isn't cleared before + # a new window is shown. Make our windows black, just to make it a + # bit prettier. + @window.backgroundColor = UIColor.blackColor + @window.rootViewController = controller + end + @window.makeKeyAndVisible + @window + end + + attr_accessor :controller + + def controller + @controller ||= @controller_class.alloc.init + end + end + + class << self + # The default timeout value, for the view finder methods, in seconds. + attr_accessor :default_timeout + + # The default duration, for gestures, in seconds. + attr_accessor :default_duration + end + self.default_timeout = 3 + self.default_duration = 0.25 + + module API + include RunLoopHelpers + + attr_accessor :window + + # Gets overriden by ContextExt#window when the spec context is configured + # to run against a specific controller. + def window + UIApplication.sharedApplication.keyWindow + end + + def log_events! + app = UIApplication.sharedApplication + app.extend(UIApplicationExt) unless app.respond_to?(:logEvents) + app.logEvents = true + end + + # Calling this will draw a red dot at each location a touch occurs. + # + # Note, however, that at this moment this *will* break most gestures. If + # you have a good idea of how to make this work then please file a ticket. + def highlight_touches! + app = UIApplication.sharedApplication + app.extend(UIApplicationExt) unless app.respond_to?(:logEvents) + app.highlightTouches = true + end + + # Returns a list of points interpolated between `from` and `to`. + # + # The `from` and `to` points should be in window coordinates. + def linear_interpolate(from, to, number_of_points = nil) + number_of_points ||= 20 + interval = 1.0 / number_of_points + points = Array.new(number_of_points-2) { |i| _linear_interpolate_point(from, to, (i+1)*interval) } + points.unshift(from) + points.push(to) + points + end + + def view(label) + return label if label.is_a?(UIView) + window.viewByName(label) || + raise(Bacon::Error.new(:error, "Unable to find a view with label `#{label}'")) + end + + def views(view_class) + views = window.viewsByClass(view_class) + if views.empty? + raise(Bacon::Error.new(:error, "Unable to find any view of class `#{view_class.name}'")) + end + views + end + + def rotate_device(options) + orientation = case options.values_at(:to, :button).compact + when [:portrait, :bottom], [:portrait] + UIInterfaceOrientationPortrait + when [:portrait, :top] + UIInterfaceOrientationPortraitUpsideDown + when [:landscape, :left], [:landscape] + UIInterfaceOrientationLandscapeLeft + when [:landscape, :right] + UIInterfaceOrientationLandscapeRight + end + + if UIDevice.currentDevice.orientation != orientation + _event_generator.setOrientation(orientation) + proper_wait(0.6) + end + end + + def accelerate(options) + duration = options[:duration] || Functional.default_duration + _event_generator.sendAccelerometerX(options[:x], Y:options[:y], Z:options[:z], duration:duration) + proper_wait(duration) + end + + def shake + _event_generator.shake + proper_wait(MIN_INTERVAL) + end + + def tap(label_or_view, options = {}) + view = view(label_or_view) + taps = options[:times] || 1 + touches = options[:touches] || 1 + location = _coerce_location_to_point(view, options[:at], false) || view.superview.convertPoint(view.center, toView:nil) + + location = view.superview.convertPoint(view.center, toView:window) + + _event_generator.sendTaps(taps, + location:location, + withNumberOfTouches:touches, + inRect:window.frame) + proper_wait(taps * 0.4) + + view + end + + def flick(label_or_view, options) + view = view(label_or_view) + from, to = _extract_start_and_end_points(view, options) + duration = options[:duration] || Functional.default_duration + + _event_generator.sendFlickWithStartPoint(from, endPoint:to, duration:duration) + proper_wait(duration) + + view + end + + def pinch_open(label_or_view, options = {}) + view = view(label_or_view) + duration = options[:duration] || Functional.default_duration + + options[:from] ||= :left unless options[:to] + from, to = _extract_start_and_end_points(view, options) + + EventDispatcher.dispatch(duration) do + _event_generator.sendPinchOpenWithStartPoint(from, endPoint:to, duration:duration) + end + + view + end + + def pinch_close(label_or_view, options = {}) + view = view(label_or_view) + duration = options[:duration] || Functional.default_duration + + options[:from] ||= :right unless options[:to] + from, to = _extract_start_and_end_points(view, options) + + EventDispatcher.dispatch(duration) do + _event_generator.sendPinchCloseWithStartPoint(from, endPoint:to, duration:duration) + end + + view + end + + # TODO add scroll helper? E.g. `scroll_down 'Scroll view'` would do `drag 'Scroll view', :from => :bottom`? + def drag(label_or_view, options) + view = view(label_or_view) + duration = options[:duration] || Functional.default_duration + touches = options[:touches] || 1 + + unless points = options[:points] + from, to = _extract_start_and_end_points(view, options) + points = linear_interpolate(from, to, options[:number_of_points]) + end + + pointer = Pointer.new(CGPoint.type, points.size) + points.each.with_index do |point, i| + pointer[i] = point + end + + EventDispatcher.dispatch(duration) do + _event_generator.sendMultifingerDragWithPointArray(pointer, numPoints:points.size, duration:duration, numFingers:touches) + end + + view + end + + # TODO offset from the center in the same way that UIAutomation does (values between 0 and 1) + def rotate(label_or_view, options) + view = view(label_or_view) + center = view.superview.convertPoint(view.center, toView:nil) + angle = options[:radians] || (options[:degrees] && options[:degrees] * (Math::PI/180)) || Math::PI + touches = options[:touches] || 2 + radius = (view.frame.size.width / 2.0) + duration = options[:duration] || Functional.default_duration + + EventDispatcher.dispatch(duration) do + _event_generator.sendRotate(center, withRadius:radius, rotation:angle, duration:duration, touchCount:touches) + end + + view + end + + private + + def _event_generator + UIASyntheticEvents.sharedEventGenerator + end + + def _location_opposite(location) + case location + when :top_left + :bottom_right + when :top + :bottom + when :top_right + :bottom_left + when :right + :left + when :bottom_right + :top_left + when :bottom + :top + when :bottom_left + :top_right + when :left + :right + else + raise ArgumentError, "Invalid location value `#{location}'." + end + end + + LOCATION_TO_POINT_INSET = 5 + + def _location_to_point(view, location, raise_if_invalid = true) + frame = view.frame + case location + when :top_left + CGPointMake(CGRectGetMinX(frame) + LOCATION_TO_POINT_INSET, CGRectGetMinY(frame) + LOCATION_TO_POINT_INSET) + when :top + CGPointMake(CGRectGetMidX(frame), CGRectGetMinY(frame) + LOCATION_TO_POINT_INSET) + when :top_right + CGPointMake(CGRectGetMaxX(frame) - LOCATION_TO_POINT_INSET, CGRectGetMinY(frame) + LOCATION_TO_POINT_INSET) + when :right + CGPointMake(CGRectGetMaxX(frame) - LOCATION_TO_POINT_INSET, CGRectGetMidY(frame)) + when :bottom_right + CGPointMake(CGRectGetMaxX(frame) - LOCATION_TO_POINT_INSET, CGRectGetMaxY(frame) - LOCATION_TO_POINT_INSET) + when :bottom + CGPointMake(CGRectGetMidX(frame), CGRectGetMaxY(frame) - LOCATION_TO_POINT_INSET) + when :bottom_left + CGPointMake(CGRectGetMinX(frame) + LOCATION_TO_POINT_INSET, CGRectGetMaxY(frame) - LOCATION_TO_POINT_INSET) + when :left + CGPointMake(CGRectGetMinX(frame) + LOCATION_TO_POINT_INSET, CGRectGetMidY(frame)) + else + raise ArgumentError, "Invalid location value `#{location}'." if raise_if_invalid + end + end + + def _location_to_converted_point(view, location, raise_if_invalid = true) + if sv = view.superview + sv.convertPoint(_location_to_point(view, location, raise_if_invalid), toView:nil) + else + raise ArgumentError, "It is not possible to use the location constants on a view that has no superview." + end + end + + def _linear_interpolate_point(a, b, alpha) + x = a.x + ((b.x-a.x) * alpha) + y = a.y + ((b.y-a.y) * alpha) + CGPointMake(x.round, y.round) + end + + def _coerce_location_to_point(view, value, raise_if_invalid = true) + case value + when CGPoint then value + when Symbol then _location_to_converted_point(view, value, raise_if_invalid) + end + end + + def _extract_point(view, options, from_or_to) + unless point = _coerce_location_to_point(view, options[from_or_to]) + other = from_or_to == :from ? :to : :from + if options[other] && options[other].is_a?(Symbol) + point = _location_to_converted_point(view, _location_opposite(options[other])) + else + raise ArgumentError, "No :#{from_or_to} location given and unable to inflect from :#{other} location `#{options[opposite].inspect}'." + end + end + point + end + + def _extract_start_and_end_points(view, options) + from = _extract_point(view, options, :from) + to = _extract_point(view, options, :to) + [from, to] + end + + def _view(accessibilityLabel, error_message = nil) + return accessibilityLabel if accessibilityLabel.is_a?(UIView) + window.viewByName(accessibilityLabel) || + raise(Bacon::Error.new(:error, error_message || "Unable to find a view with label `#{accessibilityLabel}'")) + end + + # This class wraps a block that will be executed on a GCD queue, but will + # halt the main thread's call stack (while still handling the main thread's + # runloop) until the block has completely finished its work. + # + # This is only meant for those events that are continuous and need more + # time to be generated. E.g. a pinch gesture. + class EventDispatcher + include RunLoopHelpers + + def self.dispatch(duration, &block) + new(duration, &block).call + end + + attr_reader :done, :duration + + def initialize(duration, &block) + @duration = duration + @done = false + @block = block + end + + def call + group = Dispatch::Group.new + queue = Dispatch::Queue.concurrent + queue.async(group, &@block) + group.notify(queue) { @done = true } + # First wait the standard duration, then block additionally until the + # job has been actually finished. + # + # TODO have one mixin with wait helpers? + proper_wait(@duration) + proper_wait(MIN_INTERVAL) while !@done + end + end + end + + end +end diff --git a/test/bacon-ui/resources/We Need You.jpg b/test/bacon-ui/resources/We Need You.jpg new file mode 100644 index 00000000..d2d205a7 Binary files /dev/null and b/test/bacon-ui/resources/We Need You.jpg differ diff --git a/test/bacon-ui/spec/continuous_gestures_spec.rb b/test/bacon-ui/spec/continuous_gestures_spec.rb new file mode 100644 index 00000000..2496fabe --- /dev/null +++ b/test/bacon-ui/spec/continuous_gestures_spec.rb @@ -0,0 +1,93 @@ +class ScrollViewController < UIViewController + attr_reader :scrollView, :scrollViewRotation, :imageView + + def loadView + @scrollViewRotation = 0 + + frame = UIScreen.mainScreen.applicationFrame + frame.origin = CGPointZero + self.view = UIView.alloc.initWithFrame(frame) + + @scrollView = UIScrollView.alloc.initWithFrame(view.bounds) + @scrollView.backgroundColor = UIColor.redColor + @scrollView.delegate = self + @scrollView.maximumZoomScale = 3 + view.addSubview(@scrollView) + + @imageView = UIImageView.alloc.initWithImage(UIImage.imageNamed('We Need You.jpg')) + @imageView.frame = [[0, 0], @imageView.image.size] + @scrollView.accessibilityLabel = 'Scroll view' + + @scrollView.contentSize = @imageView.image.size + @scrollView.addSubview(@imageView) + + recognizer = UIRotationGestureRecognizer.alloc.initWithTarget(self, action:'handleRotation:') + @scrollView.addGestureRecognizer(recognizer) + end + + def viewForZoomingInScrollView(sv) + @imageView + end + + def handleRotation(recognizer) + @scrollViewRotation = recognizer.rotation + @scrollView.transform = CGAffineTransformMakeRotation(recognizer.rotation) + end +end + +describe "Bacon::Functional::API, concerning continuous gestures" do + tests ScrollViewController + + it "creates 'pinch open' and 'pinch close' gesture events" do + before = controller.scrollView.zoomScale + pinch_open 'Scroll view' + controller.scrollView.zoomScale.should > before + + before = controller.scrollView.zoomScale + pinch_close 'Scroll view' + controller.scrollView.zoomScale.should < before + end + + # TODO currently it doesn't rotate exactly 90 degreese. I think this has to + # do with the center point being off and the radius not being a perfect half + # of the total diameter. + it "creates rotate gesture events" do + before = controller.scrollViewRotation + rotate 'Scroll view', :degrees => 90 + controller.scrollViewRotation.should > before + + #before = controller.scrollView.transform + #rotate 'Scroll view', :degrees => 90, :duration => 5 + #after = controller.scrollView.transform + #expected = CGAffineTransformRotate(before, Math::PI/2) + #CGAffineTransformEqualToTransform(after, expected).should == true + end + + before do + pinch_open 'Scroll view' + end + + it "drags from point A to point B" do + before = controller.scrollView.contentOffset + drag 'Scroll view', :from => CGPointMake(310, 100), :to => CGPointMake(5, 150) + controller.scrollView.contentOffset.x.should > before.x + controller.scrollView.contentOffset.y.should < before.y + end + + it "drags along the specified list of points" do + view = controller.scrollView + before = view.contentOffset + drag 'Scroll view', :points => linear_interpolate(_location_to_point(view, :bottom_right), _location_to_point(view, :left)) + view.contentOffset.x.should > before.x + view.contentOffset.y.should > before.y + end + + it "drags with multiple fingers" do + controller.scrollView.panGestureRecognizer.minimumNumberOfTouches = 3 + controller.scrollView.panGestureRecognizer.maximumNumberOfTouches = 3 + before = controller.scrollView.contentOffset + drag 'Scroll view', :from => :right, :touches => 3 + controller.scrollView.contentOffset.x.should > before.x + controller.scrollView.contentOffset.y.should == before.y + end +end diff --git a/test/bacon-ui/spec/device_events_spec.rb b/test/bacon-ui/spec/device_events_spec.rb new file mode 100644 index 00000000..62329d48 --- /dev/null +++ b/test/bacon-ui/spec/device_events_spec.rb @@ -0,0 +1,99 @@ +class DeviceSpecController < UIViewController + def loadView + frame = UIScreen.mainScreen.applicationFrame + frame.origin = CGPointZero + self.view = UIImageView.alloc.initWithFrame(frame) + view.image = UIImage.imageNamed('We Need You.jpg') + end + + def shouldAutorotateToInterfaceOrientation(orientation) + true + end + + # This is all for `shake` support + + attr_accessor :shaked + def shaked? + @shaked + end + + def viewWillAppear(animated) + super + becomeFirstResponder + end + + def viewDidDisappear(animated) + super + resignFirstResponder + end + + def canBecomeFirstResponder + true + end + + def motionEnded(motion, withEvent:event) + @shaked = motion == UIEventSubtypeMotionShake + end + + # Accelerometer support + + def enableAccelerometer=(flag) + UIAccelerometer.sharedAccelerometer.delegate = (flag == true ? self : nil) + end + + attr_reader :accelerationData + def accelerometer(accelerometer, didAccelerate:acceleration) + @accelerationData = acceleration + end +end + +describe "Bacon::Functional::API, concerning device events" do + tests DeviceSpecController + + after do + rotate_device :to => :portrait + end + + it "changes device orientation" do + rotate_device :to => :landscape, :button => :right + controller.interfaceOrientation.should == UIInterfaceOrientationLandscapeRight + + rotate_device :to => :landscape, :button => :left + controller.interfaceOrientation.should == UIInterfaceOrientationLandscapeLeft + + rotate_device :to => :portrait, :button => :bottom + controller.interfaceOrientation.should == UIInterfaceOrientationPortrait + + rotate_device :to => :portrait, :button => :top + controller.interfaceOrientation.should == UIInterfaceOrientationPortraitUpsideDown + end + + it "has default orientations for portrait and landscape for when the :button option is omitted" do + rotate_device :to => :landscape + controller.interfaceOrientation.should == UIInterfaceOrientationLandscapeLeft + + rotate_device :to => :portrait + controller.interfaceOrientation.should == UIInterfaceOrientationPortrait + end + + it "creates a shake motion gesture (for undo support, for instance)" do + shake + controller.should.be.shaked + end + + it "sends accelerometer events" do + with_accelerometer do + accelerate :x => 0.5, :y => 0.5, :z => 0.5 + end + controller.accelerationData.x.should == 0.5 + controller.accelerationData.y.should == 0.5 + controller.accelerationData.z.should == 0.5 + end + + def with_accelerometer + controller.enableAccelerometer = true + yield + ensure + controller.enableAccelerometer = false + end +end diff --git a/test/bacon-ui/spec/simple_gestures_spec.rb b/test/bacon-ui/spec/simple_gestures_spec.rb new file mode 100644 index 00000000..b2e62b0a --- /dev/null +++ b/test/bacon-ui/spec/simple_gestures_spec.rb @@ -0,0 +1,141 @@ +class SmallControlsViewController < UIViewController + attr_reader :tappableView, :switch, :lateKumbayaButton + + def loadView + frame = UIScreen.mainScreen.applicationFrame + frame.origin = CGPointZero + self.view = UIView.alloc.initWithFrame(frame) + #view.userInteractionEnabled = true + view.accessibilityLabel = 'Container view' + + buttonMargin, buttonWidth, buttonHeight = 20, 150, 34 + buttonY = buttonMargin + button = UIButton.buttonWithType(UIButtonTypeRoundedRect) + button.setTitle("Kumbaya!", forState:UIControlStateNormal) + button.frame = CGRectMake((frame.size.width - buttonWidth) / 2, buttonY, buttonWidth, buttonHeight) + button.addTarget(self, action:'buttonTapped=:', forControlEvents:UIControlEventTouchUpInside) + view.addSubview(button) + + buttonY += buttonHeight + buttonMargin + @lateKumbayaButton = UIButton.buttonWithType(UIButtonTypeRoundedRect) + @lateKumbayaButton.setTitle("Late Kumbaya!", forState:UIControlStateNormal) + @lateKumbayaButton.frame = CGRectMake((frame.size.width - buttonWidth) / 2, buttonY, buttonWidth, buttonHeight) + @lateKumbayaButton.addTarget(self, action:'buttonTapped=:', forControlEvents:UIControlEventTouchUpInside) + # Delay adding the view so we can test that the `view` method retries until it's found + view.performSelector('addSubview:', withObject:@lateKumbayaButton, afterDelay:1) + + switchY = buttonY + buttonHeight + buttonMargin + @switch = UISwitch.alloc.initWithFrame(CGRectMake((frame.size.width - buttonWidth) / 2, switchY, buttonWidth, buttonHeight)) + @switch.accessibilityLabel = 'Switch control' + @switch.on = false + view.addSubview(@switch) + + # Nested tap gesture recognizers + tappableViewContainerY = switchY + buttonHeight + buttonMargin + container = UIView.alloc.initWithFrame(CGRectMake(10, tappableViewContainerY, buttonWidth + 50, buttonHeight + 50)) + container.backgroundColor = UIColor.greenColor + view.addSubview(container) + tappableViewY = buttonMargin + @tappableView = UILabel.alloc.initWithFrame(CGRectMake(10, tappableViewY, buttonWidth, buttonHeight)) + @tappableView.userInteractionEnabled = true + @tappableView.backgroundColor = UIColor.blueColor + @tappableView.accessibilityLabel = 'Tappable view' + @tappableView.textAlignment = UITextAlignmentCenter + @tappableView.text = 'Taps: 0' + container.addSubview(@tappableView) + + previous_recognizer = nil + 3.downto(1) do |taps| + 2.downto(1) do |touches| + recognizer = UITapGestureRecognizer.alloc.initWithTarget(self, action:'handleTap:') + recognizer.numberOfTapsRequired = taps + recognizer.numberOfTouchesRequired = touches + @tappableView.addGestureRecognizer(recognizer) + + recognizer.requireGestureRecognizerToFail(previous_recognizer) if previous_recognizer + previous_recognizer = recognizer + end + end + end + + #def viewWillAppear(animated) + #super + ##self.navigationItem.rightBarButtonItem = UIBarButtonItem.alloc.initWithBarButtonSystemItem(UIBarButtonSystemItemDone, target:self, action:'bla:') + #end + + attr_accessor :buttonTapped + # RM TODO: Assertion failed: (i != sel_to_attr.end()), function rb_attr_generic_getter, file vm.cpp, line 2753. + #alias_method :buttonTapped?, :buttonTapped + def buttonTapped?; @buttonTapped; end + + attr_reader :tapRecognizer + attr_reader :tappedLocationInWindow + def handleTap(recognizer) + @tappableView.text = "Taps: #{recognizer.numberOfTapsRequired}" + @tapRecognizer = recognizer + @tappedLocationInWindow = recognizer.locationInView(nil) + end +end + +describe "Bacon::Functional::API, concerning one-shot gestures" do + tests SmallControlsViewController + + it "flicks a switch" do + flick "Switch control", :from => :left + controller.switch.isOn.should == true + flick "Switch control", :to => :left + controller.switch.isOn.should == false + end + + it "by default taps at the center of a view" do + highlight_touches! + view = tap("Tappable view") + controller.tappedLocationInWindow.should == view.superview.convertPoint(view.center, toView:nil) + end + + #it "taps at a specific point in window coordinates" do + #view = controller.tappableView + #point = CGPointMake(view.frame.origin.x + 10, view.frame.origin.y + 10) + #point = view.superview.convertPoint(point, toView:nil) + #tap "Tappable view", :at => point + #controller.tappedLocationInWindow.should == point + #end + + it "taps buttons" do + tap "Kumbaya!" + controller.buttonTapped.currentTitle.should == "Kumbaya!" + # this one actually shows up a second later + tap "Late Kumbaya!" + controller.buttonTapped.currentTitle.should == "Late Kumbaya!" + end + + it "recognizes a single tap" do + tap "Tappable view" + controller.tapRecognizer.numberOfTapsRequired.should == 1 + controller.tapRecognizer.numberOfTouchesRequired.should == 1 + + tap "Tappable view", :touches => 2 + controller.tapRecognizer.numberOfTapsRequired.should == 1 + controller.tapRecognizer.numberOfTouchesRequired.should == 2 + end + + it "recognizes a double tap" do + tap "Tappable view", :times => 2 + controller.tapRecognizer.numberOfTapsRequired.should == 2 + controller.tapRecognizer.numberOfTouchesRequired.should == 1 + + tap "Tappable view", :times => 2, :touches => 2 + controller.tapRecognizer.numberOfTapsRequired.should == 2 + controller.tapRecognizer.numberOfTouchesRequired.should == 2 + end + + it "recognizes a triple tap" do + tap "Tappable view", :times => 3 + controller.tapRecognizer.numberOfTapsRequired.should == 3 + controller.tapRecognizer.numberOfTouchesRequired.should == 1 + + tap "Tappable view", :times => 3, :touches => 2 + controller.tapRecognizer.numberOfTapsRequired.should == 3 + controller.tapRecognizer.numberOfTouchesRequired.should == 2 + end +end diff --git a/test/bacon-ui/spec/view_spec.rb b/test/bacon-ui/spec/view_spec.rb new file mode 100644 index 00000000..962cf8e7 --- /dev/null +++ b/test/bacon-ui/spec/view_spec.rb @@ -0,0 +1,147 @@ +describe "Bacon::Functional::API, concerning location helpers" do + extend Bacon::Functional::API + + def inset + Bacon::Functional::API::LOCATION_TO_POINT_INSET + end + + it "returns the points corresponding to the view" do + view = UIView.alloc.initWithFrame(CGRectMake(100, 100, 100, 100)) + _location_to_point(view, :top_left).should == CGPointMake(100+inset, 100+inset) + _location_to_point(view, :top).should == CGPointMake(150, 100+inset) + _location_to_point(view, :top_right).should == CGPointMake(200-inset, 100+inset) + _location_to_point(view, :right).should == CGPointMake(200-inset, 150) + _location_to_point(view, :bottom_right).should == CGPointMake(200-inset, 200-inset) + _location_to_point(view, :bottom).should == CGPointMake(150, 200-inset) + _location_to_point(view, :bottom_left).should == CGPointMake(100+inset, 200-inset) + _location_to_point(view, :left).should == CGPointMake(100+inset, 150) + end + + it "returns the opposite of a location" do + _location_opposite(:top_left).should == :bottom_right + _location_opposite(:top).should == :bottom + _location_opposite(:top_right).should == :bottom_left + _location_opposite(:right).should == :left + _location_opposite(:bottom_right).should == :top_left + _location_opposite(:bottom).should == :top + _location_opposite(:bottom_left).should == :top_right + _location_opposite(:left).should == :right + end +end + +class ContainerView < UIView +end + +class SimpleViewController < UIViewController + attr_reader :purpleView, :blueView, :redView + + def loadView + frame = UIScreen.mainScreen.applicationFrame + frame.origin = CGPointZero + self.view = ContainerView.alloc.initWithFrame(frame) + view.accessibilityLabel = 'Container view' + + @purpleView = UIView.alloc.initWithFrame(CGRectMake(100, 100, 100, 100)) + @purpleView.backgroundColor = UIColor.purpleColor + @purpleView.accessibilityLabel = 'Purple view' + view.addSubview(@purpleView) + + @blueView = UIView.alloc.initWithFrame(CGRectMake(25, 25, 50, 50)) + @blueView.backgroundColor = UIColor.blueColor + @blueView.accessibilityLabel = 'Blue view' + @purpleView.performSelector('addSubview:', withObject:@blueView, afterDelay:0.5) + + @redView = UIView.alloc.initWithFrame(CGRectMake(110, 25, 75, 75)) + @redView.backgroundColor = UIColor.redColor + @redView.accessibilityLabel = 'Red view' + view.addSubview(@redView) + end +end + +describe "UIView extensions" do + tests SimpleViewController + + it "returns the first subview with the specified accessibility label" do + window.viewByName('Container view', 1).should == controller.view + controller.view.viewByName('Purple view', 1).should == controller.purpleView + controller.blueView.viewByName('Purple view', 1).should == nil + end + + it "keeps trying to find a view by accessibility label during the timeout" do + # This button shows up 0.5 second after the other views + window.viewByName('Blue view', 0.1).should == nil + window.viewByName('Blue view', 0.6).should == controller.blueView + end + + it "looks through all superviews, until it finds a matching class" do + controller.purpleView.up(UIButton, 0.1).should == nil + controller.purpleView.up(ContainerView, 0.6).should == controller.view + end + + it "keeps trying to look through all superviews, until it finds a matching class, during the timeout" do + controller.blueView.up(ContainerView, 0.1).should == nil + controller.blueView.up(ContainerView, 0.6).should == controller.view + end + + it "returns all views of a specific class and sorts them top-left to bottom-right" do + window.viewsByClass(UIButton, 0.1).should == [] + window.viewsByClass(ContainerView, 0.1).should == [controller.view] + window.viewsByClass(UIView, 0.1).should == [ + controller.view, + controller.redView, + controller.purpleView + ] + end + + it "keeps trying to find a view by class during the timeout" do + controller.purpleView.viewsByClass(UIView, 0.1).should == [] + controller.purpleView.viewsByClass(UIView, 0.6).should == [controller.blueView] + end +end + +describe "Bacon::Functional::API, concerning device events" do + tests SimpleViewController + + it "finds a view by its accessibility label" do + view('Purple view').should == controller.purpleView + view('Blue view').should == controller.blueView + end + + it "finds views by class" do + views(ContainerView).should == [controller.view] + end + + it "returns a view immediately if given instead of an accessibility label" do + view = controller.purpleView + # This will raise if the #view helper would not actually return the view immediately + def window.viewByName(accessibilityLabel) + raise 'Oh noes!' + end + view(view).should == view + end + + it "raises if no view by label could be found after the `timeout` passes" do + start = Time.now.to_i + e = catch_bacon_error { view('Does not exist') } + Time.now.to_i.should >= (start + 3) + e.message.should == "Unable to find a view with label `Does not exist'" + end + + it "raises if no views by class could be found after the `timeout` passes" do + start = Time.now.to_i + e = catch_bacon_error { views(UITableView) } + Time.now.to_i.should >= (start + 3) + e.message.should == "Unable to find any view of class `UITableView'" + end + + def catch_bacon_error + e = nil + begin + yield + rescue Bacon::Error => e + end + e.should.not == nil + e.count_as.should == :error + e + end +end