diff options
Diffstat (limited to 'platform/ios/Integration Tests/Annotation Tests/MGLAnnotationViewIntegrationTests.mm')
-rw-r--r-- | platform/ios/Integration Tests/Annotation Tests/MGLAnnotationViewIntegrationTests.mm | 817 |
1 files changed, 817 insertions, 0 deletions
diff --git a/platform/ios/Integration Tests/Annotation Tests/MGLAnnotationViewIntegrationTests.mm b/platform/ios/Integration Tests/Annotation Tests/MGLAnnotationViewIntegrationTests.mm new file mode 100644 index 0000000000..eb3c85e30d --- /dev/null +++ b/platform/ios/Integration Tests/Annotation Tests/MGLAnnotationViewIntegrationTests.mm @@ -0,0 +1,817 @@ +#import "MGLMapViewIntegrationTest.h" +#import "MGLTestUtility.h" +#import "MGLMapAccessibilityElement.h" +#import "MGLTestLocationManager.h" +#import "MGLCompactCalloutView.h" + +#import "MGLGeometry_Private.h" +#import "MGLMapView_Private.h" + +#include <mbgl/util/geo.hpp> +#include <mbgl/map/camera.hpp> +#include <mbgl/map/map.hpp> + +@interface MGLTestCalloutView : MGLCompactCalloutView +@property (nonatomic) BOOL implementsMarginHints; +@end + +@implementation MGLTestCalloutView +- (BOOL)respondsToSelector:(SEL)aSelector { + if (!self.implementsMarginHints && + (aSelector == @selector(marginInsetsHintForPresentationFromRect:))) { + return NO; + } + return [super respondsToSelector:aSelector]; +} +@end + + +@interface MGLMapView (Tests) +- (MGLAnnotationTag)annotationTagAtPoint:(CGPoint)point persistingResults:(BOOL)persist; +- (id <MGLAnnotation>)annotationWithTag:(MGLAnnotationTag)tag; +- (MGLMapCamera *)cameraByRotatingToDirection:(CLLocationDirection)degrees aroundAnchorPoint:(CGPoint)anchorPoint; +- (MGLMapCamera *)cameraByZoomingToZoomLevel:(double)zoom aroundAnchorPoint:(CGPoint)anchorPoint; +- (MGLMapCamera *)cameraForCameraOptions:(const mbgl::CameraOptions &)cameraOptions; +@property (nonatomic) UIView<MGLCalloutView> *calloutViewForSelectedAnnotation; +@end + +@interface MGLAnnotationViewIntegrationTests : MGLMapViewIntegrationTest +@end + +@implementation MGLAnnotationViewIntegrationTests + +#pragma mark - Offscreen/panning selection tests + +typedef struct PanTestData { + CGPoint relativeCoord; + BOOL showsCallout; + BOOL implementsMargins; + BOOL moveIntoView; + BOOL expectMapToHavePanned; + BOOL calloutOnScreen; +} PanTestData; + +#define PAN_TEST_TERMINATOR {{FLT_MAX, FLT_MAX}, NO, NO, NO, NO, NO} +static const CGPoint kAnnotationRelativeScale = { 0.05f, 0.125f }; + +- (void)internalTestOffscreenSelectionTitle:(NSString*)title withTestData:(PanTestData)test animateSelection:(BOOL)animateSelection { + + CGPoint relativeCoordinate = test.relativeCoord; + BOOL showsCallout = test.showsCallout; + BOOL calloutImplementsMarginHints = test.implementsMargins; + BOOL moveIntoView = test.moveIntoView; + BOOL expectMapToHavePanned = test.expectMapToHavePanned; + BOOL expectCalloutToBeFullyOnscreen = test.calloutOnScreen; + + // Reset the map to a consistent state - want the map to be zoomed in, so that + // it's free to be panned without hitting boundaries. + [self.mapView setCenterCoordinate:CLLocationCoordinate2DMake(0, 0) zoomLevel:14 animated:NO]; + [self waitForMapViewToBeRenderedWithTimeout:1.0]; + + XCTAssert(self.mapView.annotations.count == 0); + + NSString * const MGLTestAnnotationReuseIdentifer = @"MGLTestAnnotationReuseIdentifer"; + CGSize size = self.mapView.bounds.size; + CGSize annotationSize = CGSizeMake(floor(size.width*kAnnotationRelativeScale.x), floor(size.height*kAnnotationRelativeScale.y)); + + self.viewForAnnotation = ^MGLAnnotationView*(MGLMapView *view, id<MGLAnnotation> annotation) { + + if (![annotation isKindOfClass:[MGLPointAnnotation class]]) { + return nil; + } + + // No dequeue + MGLAnnotationView *annotationView = [[MGLAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:MGLTestAnnotationReuseIdentifer]; + annotationView.bounds = (CGRect){ .origin = CGPointZero, .size = annotationSize }; + annotationView.backgroundColor = UIColor.redColor; + annotationView.enabled = YES; + + return annotationView; + }; + + // Coordinate for annotation screen coordinate + CGPoint annotationPoint = CGPointMake(floor(relativeCoordinate.x * size.width), floor(relativeCoordinate.y * size.height) ); + CLLocationCoordinate2D coordinate = [self.mapView convertPoint:annotationPoint toCoordinateFromView:self.mapView]; + + MGLPointAnnotation *point = [[MGLPointAnnotation alloc] init]; + point.title = title; + point.coordinate = coordinate; + + self.mapViewAnnotationCanShowCalloutForAnnotation = ^BOOL(MGLMapView *mapView, id<MGLAnnotation> annotation) { + return showsCallout; + }; + + self.mapViewCalloutViewForAnnotation = ^id<MGLCalloutView>(MGLMapView *mapView, id<MGLAnnotation> annotation) { + if (!showsCallout) + return nil; + + MGLTestCalloutView *calloutView = [[MGLTestCalloutView alloc] init]; + calloutView.representedObject = annotation; + calloutView.implementsMarginHints = calloutImplementsMarginHints; + return calloutView; + }; + + [self.mapView addAnnotation:point]; + + // Check assumptions before selection + UIView *annotationViewBeforeSelection = [self.mapView viewForAnnotation:point]; + XCTAssertNotNil(annotationViewBeforeSelection); + + CLLocationCoordinate2D mapCenterBeforeSelection = self.mapView.centerCoordinate; + XCTAssert(CLLocationCoordinate2DIsValid(mapCenterBeforeSelection)); + + // Also note, that at this point, the internal mechanism that determines if + // an annotation view is offscreen and should be put back in the reuse queue + // will have run, and `viewForAnnotation` may return nil + + XCTestExpectation *selectionCompleted = [self expectationWithDescription:@"Selection completed"]; + [self.mapView selectAnnotation:point moveIntoView:moveIntoView animateSelection:animateSelection completionHandler:^{ + [selectionCompleted fulfill]; + }]; + + // Animated selection takes MGLAnimationDuration (0.3 seconds), so wait a little + // longer. We don't need to wait as long if we're not animated (but we do + // want the runloop to tick over) + [self waitForExpectations:@[selectionCompleted] timeout:animateSelection ? 0.4: 0.05]; + + UIView *annotationViewAfterSelection = [self.mapView viewForAnnotation:point]; + CLLocationCoordinate2D mapCenterAfterSelection = self.mapView.centerCoordinate; + XCTAssert(CLLocationCoordinate2DIsValid(mapCenterAfterSelection)); + + // If the annotation is still "offscreen" at this point, then the above annotation view + // may be nil, which is expected. + BOOL (^CGRectContainsRectWithAccuracy)(CGRect, CGRect, CGFloat) = ^(CGRect rect1, CGRect rect2, CGFloat accuracy) { + CGRect expandedRect1 = CGRectInset(rect1, -accuracy, -accuracy); + return (BOOL)CGRectContainsRect(expandedRect1, rect2); + }; + + CGFloat epsilon = 0.00001; + if (expectMapToHavePanned) { + CLLocationDegrees latitudeDelta = fabs(mapCenterAfterSelection.latitude - mapCenterBeforeSelection.latitude); + CLLocationDegrees longitudeDelta = fabs(mapCenterAfterSelection.longitude - mapCenterBeforeSelection.longitude); + + XCTAssert( (latitudeDelta > epsilon) || (longitudeDelta > epsilon), @"Deltas: lat=%f, long=%f", latitudeDelta, longitudeDelta); // One of them should have moved + + // If the map panned - the intention is that the annotation is on-screen, + // and so should have an annotation view and that it is fully on screen + CGRect annotationFrameAfterSelection = annotationViewAfterSelection.frame; + + XCTAssertNotNil(annotationViewAfterSelection); + + XCTAssert(CGRectContainsRectWithAccuracy(self.mapView.bounds, annotationFrameAfterSelection, 0.25), @"Mapview:%@ frame:%@", NSStringFromCGRect(self.mapView.bounds), NSStringFromCGRect(annotationFrameAfterSelection)); + + // Check the callout + if (showsCallout) { + UIView *calloutView = self.mapView.calloutViewForSelectedAnnotation; + XCTAssertNotNil(calloutView); + + // This can fail if the callout view's width is < the annotations. This is really a warning, so + // if you need this NOT to fail the tests, consider replacing with MGLTestWarning + XCTAssert(expectCalloutToBeFullyOnscreen == CGRectContainsRectWithAccuracy(self.mapView.bounds, calloutView.frame, 0.25), + @"Expect contains:%d, Mapview:%@ annotation:%@ callout:%@", + expectCalloutToBeFullyOnscreen, + NSStringFromCGRect(self.mapView.bounds), + NSStringFromCGRect(annotationFrameAfterSelection), + NSStringFromCGRect(calloutView.frame)); + } + } + else { + // The map shouldn't have moved, so use equality (rather than an error check) + XCTAssertEqual(mapCenterBeforeSelection.latitude, mapCenterAfterSelection.latitude); + XCTAssertEqual(mapCenterBeforeSelection.longitude, mapCenterAfterSelection.longitude); + + // Annotation shouldn't have moved + CGPoint annotationPoint2 = [self.mapView convertCoordinate:point.coordinate toPointToView:self.mapView]; + CGFloat xDelta = fabs(annotationPoint2.x - annotationPoint.x); + CGFloat yDelta = fabs(annotationPoint2.y - annotationPoint.y); + + XCTAssert((xDelta < epsilon) && (yDelta < epsilon)); + + if (showsCallout) { + UIView *calloutView = self.mapView.calloutViewForSelectedAnnotation; + + if (annotationViewAfterSelection) { + XCTAssertNotNil(calloutView); + + // If kAnnotationScale == 0.25, then the following assert can fail. + // This is really a warning (see https://github.com/mapbox/mapbox-gl-native/issues/13744 ) + // If you need this NOT to fail the tests, consider replacing with MGLTestWarning + XCTAssert((expectCalloutToBeFullyOnscreen == CGRectContainsRectWithAccuracy(self.mapView.bounds, calloutView.frame, 0.25)), + @"Mapview:%@ annotation:%@ callout:%@", + NSStringFromCGRect(self.mapView.bounds), + NSStringFromCGRect(annotationViewAfterSelection.frame), + NSStringFromCGRect(calloutView.frame)); + } + else { + // If there's no annotation view, should we expect a callout? + XCTAssertNil(calloutView); + XCTAssertFalse(expectCalloutToBeFullyOnscreen); + } + } + } + + // Remove the annotation + [self.mapView removeAnnotation:point]; + + XCTAssert(self.mapView.annotations.count == 0); +} + +// See https://github.com/mapbox/mapbox-gl-native/pull/13727#issuecomment-454028698 +// What follows are tests based on this table. +// This is not a full-set of possible combinations, just the most important/likely +// ones +- (void)internalRunTests:(PanTestData*)testData +{ + // Test both animated and not-animated. + for (int i = 0; i<2; i++) { + int row = 0; + PanTestData *test = testData; + while (test->relativeCoord.x != FLT_MAX) { + NSString *activityTitle = [NSString stringWithFormat:@"Row %d/%d", row, i]; + [XCTContext runActivityNamed:activityTitle + block:^(id<XCTActivity> _Nonnull activity) { + [self internalTestOffscreenSelectionTitle:activityTitle + withTestData:*test + animateSelection:!i]; + }]; + ++test; + ++row; + } + } +} + +- (void)testBasicSelection { + // Tests moveIntoView:NO + // WITHOUT a callout + + PanTestData tests[] = { + // Coord showsCallout impl margins? moveIntoView expectMapToPan calloutOnScreen + // Offscreen + { {-1.0f, 0.5f}, NO, NO, NO, NO, NO }, + { { 2.0f, 0.5f}, NO, NO, NO, NO, NO }, + { { 0.5f,-1.0f}, NO, NO, NO, NO, NO }, + { { 0.5f, 2.0f}, NO, NO, NO, NO, NO }, + + // Partial + { { 0.0f, 0.5f}, NO, NO, NO, NO, NO }, + { { 1.0f, 0.5f}, NO, NO, NO, NO, NO }, + { { 0.5f, 0.0f}, NO, NO, NO, NO, NO }, + { { 0.5f, 1.0f}, NO, NO, NO, NO, NO }, + + // Onscreen + { { 0.5f, 0.5f}, NO, NO, NO, NO, NO }, + + PAN_TEST_TERMINATOR + }; + + [self internalRunTests:tests]; +} + +- (void)testBasicSelectionWithCallout { + // Tests moveIntoView:NO + // WITH the default callout (implements marginshint) + + PanTestData tests[] = { + // Coord showsCallout impl margins? moveIntoView expectMapToPan calloutOnScreen + { {-1.0f, 0.5f}, YES, YES, NO, NO, NO }, + { { 0.0f, 0.5f}, YES, YES, NO, NO, NO }, + { { 0.5f, 1.0f}, YES, YES, NO, NO, YES }, // Because annotation was off the bottom of screen, and callout is above annotation + { { 0.5f, 0.5f}, YES, YES, NO, NO, YES }, + + PAN_TEST_TERMINATOR + }; + + [self internalRunTests:tests]; +} + +- (void)testSelectionMoveIntoView { + // Tests moveIntoView:YES + // without a callout + + // From https://github.com/mapbox/mapbox-gl-native/pull/13727#issuecomment-454028698 + // + // | Annotation position | Has callout? | Callout implements `marginInsets...`? | Map pans when selected with moveIntoView=YES? | + // |---------------------|--------------|---------------------------------------|-----------------------------------------------| + // | Offscreen | No | n/a | Yes (no margins) | + // | Partially | No | n/a | No | + // | Onscreen | No | n/a | No | + // + + PanTestData tests[] = { + // Coord showsCallout impl margins? moveIntoView expectMapToPan calloutOnScreen + // Offscreen + { {-1.0f, 0.5f}, NO, NO, YES, YES, NO }, + { { 2.0f, 0.5f}, NO, NO, YES, YES, NO }, + { { 0.5f,-1.0f}, NO, NO, YES, YES, NO }, + { { 0.5f, 2.0f}, NO, NO, YES, YES, NO }, + + // Partial + { { 0.0f, 0.5f}, NO, NO, YES, NO, NO }, + { { 1.0f, 0.5f}, NO, NO, YES, NO, NO }, + { { 0.5f, 0.0f}, NO, NO, YES, NO, NO }, + { { 0.5f, 1.0f}, NO, NO, YES, NO, NO }, + + // Onscreen + { { 0.5f, 0.5f}, NO, NO, YES, NO, NO }, + + PAN_TEST_TERMINATOR + }; + + [self internalRunTests:tests]; +} + +- (void)testSelectionMoveIntoViewWithCallout { + // Tests moveIntoView:YES + // WITH the default callout (implements marginshint) + + // From https://github.com/mapbox/mapbox-gl-native/pull/13727#issuecomment-454028698 + // + // | Annotation position | Has callout? | Callout implements `marginInsets...`? | Map pans when selected with moveIntoView=YES? | + // |---------------------|--------------|---------------------------------------|-----------------------------------------------| + // | Offscreen | Yes | Yes | Yes to ensure callout is fully visible | + // | Partially | Yes | Yes | Yes to ensure callout is fully visible | + // | Onscreen | Yes | Yes | Yes, but *only* to ensure callout is fully visible | + // + + CGFloat offset = kAnnotationRelativeScale.x * 0.5f; + + PanTestData tests[] = { + // Coord showsCallout impl margins? moveIntoView expectMapToPan calloutOnScreen + // Offscreen + { {-1.0f, 0.5f}, YES, YES, YES, YES, YES }, + { { 2.0f, 0.5f}, YES, YES, YES, YES, YES }, + { { 0.5f,-1.0f}, YES, YES, YES, YES, YES }, + { { 0.5f, 2.0f}, YES, YES, YES, YES, YES }, + + // Partial + { { 0.0f, 0.5f}, YES, YES, YES, YES, YES }, + { { 1.0f, 0.5f}, YES, YES, YES, YES, YES }, + { { 0.5f, 0.0f}, YES, YES, YES, YES, YES }, + { { 0.5f, 1.0f}, YES, YES, YES, YES, YES }, + + // Onscreen + { { 0.5f, 0.5f}, YES, YES, YES, NO, YES }, + + // Just at the edge of the screen. + // Expects to move, because although onscreen, callout would not be. + // However, if the scale is 0.25, then expectToPan should be NO, because + // of the width of the annotation + + // Coord showsCallout impl margins? moveIntoView expectMapToPan calloutOnScreen + { {offset, 0.5f}, YES, YES, YES, YES, YES }, + { {1.0 - offset, 0.5f}, YES, YES, YES, YES, YES }, + + PAN_TEST_TERMINATOR + }; + + [self internalRunTests:tests]; +} + +- (void)testSelectionMoveIntoViewWithBasicCallout { + // Tests moveIntoView:YES + // WITH a callout that DOES NOT implement marginshint + + // From https://github.com/mapbox/mapbox-gl-native/pull/13727#issuecomment-454028698 + // + // | Annotation position | Has callout? | Callout implements `marginInsets...`? | Map pans when selected with moveIntoView=YES? | + // |---------------------|--------------|---------------------------------------|-----------------------------------------------| + // | Offscreen | Yes | No | Yes, but only to show annotation (not callout) with no margins | + // | Partially | Yes | No | No | + // | Onscreen | Yes | No | No | + // + + PanTestData tests[] = { + // Coord showsCallout impl margins? moveIntoView expectMapToPan calloutOnScreen + // Offscreen + { {-1.0f, 0.5f}, YES, NO, YES, YES, NO }, + { { 2.0f, 0.5f}, YES, NO, YES, YES, NO }, + { { 0.5f,-1.0f}, YES, NO, YES, YES, NO }, + { { 0.5f, 2.0f}, YES, NO, YES, YES, YES }, // Because annotation was off the bottom of screen, and callout is above annotation + { { 2.0f, 2.0f}, YES, NO, YES, YES, NO }, + + // Partial + { { 0.0f, 0.5f}, YES, NO, YES, NO, NO }, + { { 1.0f, 0.5f}, YES, NO, YES, NO, NO }, + { { 0.5f, 0.0f}, YES, NO, YES, NO, NO }, + { { 0.5f, 1.0f}, YES, NO, YES, NO, YES }, // Because annotation was off the bottom of screen, and callout is above annotation + { { 1.0f, 1.0f}, YES, NO, YES, NO, NO }, + + // Onscreen + { { 0.5f, 0.5f}, YES, NO, YES, NO, YES }, + + PAN_TEST_TERMINATOR + }; + + [self internalRunTests:tests]; +} + +#pragma mark - Selection with an offset + +- (void)testSelectingAnnotationWithCenterOffset { + + for (CGFloat dx = -100.0; dx <= 100.0; dx += 100.0 ) { + for (CGFloat dy = -100.0; dy <= 100.0; dy += 100.0 ) { + CGVector offset = CGVectorMake(dx, dy); + UIEdgeInsets edgeInsets = UIEdgeInsetsMake(fmax(-dy, 0.0), fmax(-dy, 0.0), fmax(dy, 0.0), fmax(dx, 0.0)); + [self internalTestSelectingAnnotationWithCenterOffsetWithOffset:offset edgeInsets:edgeInsets]; + } + } +} + +- (void)internalTestSelectingAnnotationWithCenterOffsetWithOffset:(CGVector)offset edgeInsets:(UIEdgeInsets)edgeInsets { + + NSString * const MGLTestAnnotationReuseIdentifer = @"MGLTestAnnotationReuseIdentifer"; + + self.mapView.contentInset = edgeInsets; + CGSize size = self.mapView.bounds.size; + + CGSize annotationSize = CGSizeMake(40.0, 40.0); + + self.viewForAnnotation = ^MGLAnnotationView*(MGLMapView *view, id<MGLAnnotation> annotation) { + + if (![annotation isKindOfClass:[MGLPointAnnotation class]]) { + return nil; + } + + // No dequeue + MGLAnnotationView *annotationView = [[MGLAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:MGLTestAnnotationReuseIdentifer]; + annotationView.bounds = (CGRect){ .origin = CGPointZero, .size = annotationSize }; + annotationView.backgroundColor = UIColor.redColor; + annotationView.enabled = YES; + annotationView.centerOffset = offset; + + return annotationView; + }; + + MGLPointAnnotation *point = [[MGLPointAnnotation alloc] init]; + point.title = NSStringFromSelector(_cmd); + point.coordinate = CLLocationCoordinate2DMake(0.0, 0.0); + [self.mapView addAnnotation:point]; + + // From https://github.com/mapbox/mapbox-gl-native/issues/12259#issuecomment-401414168 + // + // queryRenderedFeatures depends on collision detection having been run + // before it shows results [...]. Collision detection runs asynchronously + // (at least every 300ms, sometimes more often), and therefore the results + // of queryRenderedFeatures are similarly asynchronous. + // + // So, we need to wait before `annotationTagAtPoint:persistingResults:` will + // return out newly added annotation + + [self waitForCollisionDetectionToRun]; + + // Check that the annotation is in the center of the view + CGPoint annotationPoint = [self.mapView convertCoordinate:point.coordinate toPointToView:self.mapView]; + XCTAssertEqualWithAccuracy(annotationPoint.x, (size.width - edgeInsets.right + edgeInsets.left)/2.0, 0.25); + XCTAssertEqualWithAccuracy(annotationPoint.y, (size.height - edgeInsets.bottom + edgeInsets.top)/2.0, 0.25); + + // Now test taps around the annotation + CGPoint tapPoint = CGPointMake(annotationPoint.x + offset.dx, annotationPoint.y + offset.dy); + + MGLAnnotationTag tagAtPoint = [self.mapView annotationTagAtPoint:tapPoint persistingResults:YES]; + XCTAssert(tagAtPoint != UINT32_MAX, @"Should have tapped on annotation"); + + CGPoint testPoints[] = { + { tapPoint.x - annotationSize.width, tapPoint.y }, + { tapPoint.x + annotationSize.width, tapPoint.y }, + { tapPoint.x, tapPoint.y - annotationSize.height }, + { tapPoint.x, tapPoint.y + annotationSize.height }, + CGPointZero + }; + + CGPoint *testPoint = testPoints; + + while (!CGPointEqualToPoint(*testPoint, CGPointZero)) { + tagAtPoint = [self.mapView annotationTagAtPoint:*testPoints persistingResults:YES]; + XCTAssert(tagAtPoint == UINT32_MAX, @"Tap should to the side of the annotation"); + testPoint++; + } +} + +- (void)testUserLocationWithOffsetAnchorPoint { + [self.mapView setCenterCoordinate:CLLocationCoordinate2DMake(37.787357, -122.39899)]; + MGLTestLocationManager *locationManager = [[MGLTestLocationManager alloc] init]; + self.mapView.locationManager = locationManager; + + [self.mapView setUserTrackingMode:MGLUserTrackingModeFollow animated:NO completionHandler:nil]; + CGRect originalFrame = [self.mapView viewForAnnotation:self.mapView.userLocation].frame; + + // Temporarily disable location tracking so we can save the value of + // the originalFrame in memory + [self.mapView setUserTrackingMode:MGLUserTrackingModeNone animated:NO completionHandler:nil]; + + CGPoint offset = CGPointMake(20, 20); + + self.mapViewUserLocationAnchorPoint = ^CGPoint (MGLMapView *mapView) { + return offset;; + }; + + [self.mapView setUserTrackingMode:MGLUserTrackingModeFollow animated:NO completionHandler:nil]; + CGRect offsetFrame = [self.mapView viewForAnnotation:self.mapView.userLocation].frame; + + XCTAssertEqual(originalFrame.origin.x + offset.x, offsetFrame.origin.x); + XCTAssertEqual(originalFrame.origin.y + offset.y, offsetFrame.origin.y); +} + +#pragma mark - Rotating/zooming + +- (void)testSelectingAnnotationWhenMapIsRotated { + + CLLocationCoordinate2D coordinates[] = { + { 40.0, 40.0 }, + { NAN, NAN } + }; + + NSArray *annotations = [self internalAddAnnotationsAtCoordinates:coordinates]; + MGLPointAnnotation *annotation = annotations.firstObject; + + // Rotate + CLLocationDirection lastAngle = 0.0; + + srand48(0); + for (NSInteger iter = 0; iter < 10; iter++ ) { + + CLLocationDirection angle = (CLLocationDirection)((drand48()*1080.0) - 540.0); + + CGPoint anchor = CGPointMake(drand48()*CGRectGetWidth(self.mapView.bounds), drand48()*CGRectGetHeight(self.mapView.bounds)); + + NSString *activityTitle = [NSString stringWithFormat:@"Rotate to: %0.1f from: %0.1f", angle, lastAngle]; + [XCTContext runActivityNamed:activityTitle + block:^(id<XCTActivity> _Nonnull activity) { + + MGLMapCamera *toCamera = [self.mapView cameraByRotatingToDirection:angle aroundAnchorPoint:anchor]; + [self internalTestSelecting:annotation withCamera:toCamera]; + }]; + + lastAngle = angle; + } +} + +- (void)testSelectingAnnotationWhenMapIsScaled { + + CLLocationCoordinate2D coordinates[] = { + { 0.005, 0.005 }, + { NAN, NAN } + }; + + NSArray *annotations = [self internalAddAnnotationsAtCoordinates:coordinates]; + MGLPointAnnotation *annotation = annotations.firstObject; + + CGPoint anchor = CGPointMake(CGRectGetMidX(self.mapView.bounds), CGRectGetMidY(self.mapView.bounds)); + + srand48(0); + for (NSInteger iter = 0; iter < 10; iter++ ) { + + double zoom = (double)(drand48()*14.0); + + NSString *activityTitle = [NSString stringWithFormat:@"Zoom to %0.1f", zoom]; + [XCTContext runActivityNamed:activityTitle + block:^(id<XCTActivity> _Nonnull activity) { + MGLMapCamera *toCamera = [self.mapView cameraByZoomingToZoomLevel:zoom aroundAnchorPoint:anchor]; + [self internalTestSelecting:annotation withCamera:toCamera]; + }]; + } +} + +- (void)testSelectingAnnotationWhenMapIsScaledAndRotated { + + CLLocationCoordinate2D coordinates[] = { + { 0.005, 0.005 }, + { NAN, NAN } + }; + + NSArray *annotations = [self internalAddAnnotationsAtCoordinates:coordinates]; + MGLPointAnnotation *annotation = annotations.firstObject; + + srand48(0); + for (NSInteger iter = 0; iter < 10; iter++ ) { + + double zoom = (double)(7.0 + drand48()*7.0); + CLLocationDirection angle = (CLLocationDirection)((drand48()*1080.0) - 540.0); + + CGPoint anchor = CGPointMake(drand48()*CGRectGetWidth(self.mapView.bounds), drand48()*CGRectGetHeight(self.mapView.bounds)); + + NSString *activityTitle = [NSString stringWithFormat:@"Zoom to %0.1f", zoom]; + [XCTContext runActivityNamed:activityTitle + block:^(id<XCTActivity> _Nonnull activity) + { + mbgl::CameraOptions currentCameraOptions; + + currentCameraOptions.bearing = angle; + currentCameraOptions.anchor = mbgl::ScreenCoordinate { anchor.x, anchor.y }; + currentCameraOptions.zoom = zoom; + MGLMapCamera *toCamera = [self.mapView cameraForCameraOptions:currentCameraOptions]; + + [self internalTestSelecting:annotation withCamera:toCamera]; + }]; + } +} + + +- (void)testShowingAnnotationsThenSelectingAnimated { + [self internalTestShowingAnnotationsThenSelectingAnimated:YES]; +} + +- (void)testShowingAnnotationsThenSelecting { + [self internalTestShowingAnnotationsThenSelectingAnimated:NO]; +} + +- (void)internalTestShowingAnnotationsThenSelectingAnimated:(BOOL)animated { + srand48(0); + + CGFloat maxXPadding = std::max(CGRectGetWidth(self.mapView.bounds)/5.0, 100.0); + CGFloat maxYPadding = std::max(CGRectGetHeight(self.mapView.bounds)/5.0, 100.0); + + for (int i = 0; i < 10; i++) { + UIEdgeInsets edgePadding; + edgePadding.top = floor(drand48()*maxYPadding); + edgePadding.bottom = floor(drand48()*maxYPadding); + edgePadding.left = floor(drand48()*maxXPadding); + edgePadding.right = floor(drand48()*maxXPadding); + + UIEdgeInsets contentInsets; + contentInsets.top = floor(drand48()*maxYPadding); + contentInsets.bottom = floor(drand48()*maxYPadding); + contentInsets.left = floor(drand48()*maxXPadding); + contentInsets.right = floor(drand48()*maxXPadding); + + [self internalTestShowingAnnotationsThenSelectingAnimated:animated edgePadding:edgePadding contentInsets:contentInsets]; + } +} + +- (void)internalTestShowingAnnotationsThenSelectingAnimated:(BOOL)animated edgePadding:(UIEdgeInsets)edgeInsets contentInsets:(UIEdgeInsets)contentInsets { + CLLocationCoordinate2D coordinates[21]; + + for (int i = 0; i < (int)(sizeof(coordinates)/sizeof(coordinates[0])); i++) + { + coordinates[i].latitude = drand48(); + coordinates[i].longitude = drand48(); + } + coordinates[20] = CLLocationCoordinate2DMake(NAN, NAN); + + NSArray *annotations = [self internalAddAnnotationsAtCoordinates:coordinates]; + + XCTestExpectation *showCompleted = [self expectationWithDescription:@"showCompleted"]; + + self.mapView.contentInset = contentInsets; + [self.mapView showAnnotations:annotations + edgePadding:edgeInsets + animated:animated + completionHandler:^{ + [showCompleted fulfill]; + }]; + + [self waitForExpectations:@[showCompleted] timeout:3.5]; + + // These tests will fail if this isn't here. But this isn't quite what we're + // seeing in https://github.com/mapbox/mapbox-gl-native/issues/15106 + [self waitForCollisionDetectionToRun]; + + for (MGLPointAnnotation *point in annotations) { + [self internalSelectDeselectAnnotation:point]; + } + + [self.mapView removeAnnotations:annotations]; + self.mapView.contentInset = UIEdgeInsetsZero; + [self waitForCollisionDetectionToRun]; +} + +- (NSArray*)internalAddAnnotationsAtCoordinates:(CLLocationCoordinate2D*)coordinates +{ + __block NSMutableArray *annotations = [NSMutableArray array]; + + [XCTContext runActivityNamed:@"Map setup" + block:^(id<XCTActivity> _Nonnull activity) + { + + NSString * const MGLTestAnnotationReuseIdentifer = @"MGLTestAnnotationReuseIdentifer"; + + CGSize annotationSize = CGSizeMake(40.0, 40.0); + + self.viewForAnnotation = ^MGLAnnotationView*(MGLMapView *view, id<MGLAnnotation> annotation2) { + + if (![annotation2 isKindOfClass:[MGLPointAnnotation class]]) { + return nil; + } + + // No dequeue + MGLAnnotationView *annotationView = [[MGLAnnotationView alloc] initWithAnnotation:annotation2 reuseIdentifier:MGLTestAnnotationReuseIdentifer]; + annotationView.bounds = (CGRect){ .origin = CGPointZero, .size = annotationSize }; + annotationView.backgroundColor = UIColor.redColor; + annotationView.enabled = YES; + + return annotationView; + }; + + CLLocationCoordinate2D *coordinatePtr = coordinates; + while (!isnan(coordinatePtr->latitude)) { + CLLocationCoordinate2D coordinate = *coordinatePtr++; + + MGLPointAnnotation *annotation = [[MGLPointAnnotation alloc] init]; + annotation.title = NSStringFromSelector(_cmd); + annotation.coordinate = coordinate; + [annotations addObject:annotation]; + } + + [self.mapView addAnnotations:annotations]; + + }]; + + NSArray *copiedAnnotations = [annotations copy]; + annotations = nil; + + return copiedAnnotations; +} + +- (void)internalTestSelecting:(MGLPointAnnotation*)point withCamera:(MGLMapCamera*)camera { + + // Rotate + XCTestExpectation *rotationCompleted = [self expectationWithDescription:@"rotationCompleted"]; + [self.mapView setCamera:camera withDuration:0.1 animationTimingFunction:nil completionHandler:^{ + [rotationCompleted fulfill]; + }]; + + [self waitForExpectations:@[rotationCompleted] timeout:1.5]; + + // Collision detection may not have completed, if not we may not get our annotation. + [self waitForCollisionDetectionToRun]; + + // Look up annotation at point + [self internalSelectDeselectAnnotation:point]; +} + +- (void)internalSelectDeselectAnnotation:(MGLPointAnnotation*)point { + [XCTContext runActivityNamed:[NSString stringWithFormat:@"Select annotation: %@", point] + block:^(id<XCTActivity> _Nonnull activity) + { + CGPoint annotationPoint = [self.mapView convertCoordinate:point.coordinate toPointToView:self.mapView]; + + MGLAnnotationTag tagAtPoint = [self.mapView annotationTagAtPoint:annotationPoint persistingResults:YES]; + if (tagAtPoint != UINT32_MAX) + { + id <MGLAnnotation> annotation = [self.mapView annotationWithTag:tagAtPoint]; + XCTAssertNotNil(annotation); + + // Select + XCTestExpectation *selectionCompleted = [self expectationWithDescription:@"Selection completed"]; + [self.mapView selectAnnotation:annotation moveIntoView:NO animateSelection:NO completionHandler:^{ + [selectionCompleted fulfill]; + }]; + + [self waitForExpectations:@[selectionCompleted] timeout:0.05]; + + XCTAssert(self.mapView.selectedAnnotations.count == 1, @"There should only be 1 selected annotation"); + XCTAssertEqualObjects(self.mapView.selectedAnnotations.firstObject, annotation, @"The annotation should be selected"); + + // Deselect + [self.mapView deselectAnnotation:annotation animated:NO]; + } + else + { + XCTFail(@"Should be an annotation at this point: %@", NSStringFromCGPoint(annotationPoint)); + } + }]; + +} + +#pragma mark - Utilities + +- (void)runRunLoop { + [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]; +} + +- (void)waitFor:(NSTimeInterval)seconds { + XCTestExpectation *timerExpired = [self expectationWithDescription:@"Timer expires"]; + + NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:0.1 + target:self + selector:@selector(runRunLoop) + userInfo:nil + repeats:YES]; + + double duration = seconds * (double)NSEC_PER_SEC; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)duration), dispatch_get_main_queue(), ^{ + [timerExpired fulfill]; + }); + + [self waitForExpectations:@[timerExpired] timeout:seconds + 1.0]; + [timer invalidate]; +} + +- (void)waitForCollisionDetectionToRun { + XCTAssertNil(self.renderFinishedExpectation, @"Incorrect test setup"); + + self.renderFinishedExpectation = [self expectationWithDescription:@"Map view should be rendered"]; + XCTestExpectation *timerExpired = [self expectationWithDescription:@"Timer expires"]; + + // Wait 1/2 second + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(NSEC_PER_SEC >> 1)), dispatch_get_main_queue(), ^{ + [timerExpired fulfill]; + }); + + [self waitForExpectations:@[timerExpired, self.renderFinishedExpectation] timeout:5]; + + self.renderFinishedExpectation = nil; +} + +@end |