Files
MapLibreParticleLab/platform/ios/Integration_Tests/Annotation Tests/MLNAnnotationViewIntegrationTests.mm
mwilsnd 20d9e1028c Platform flattening overhaul (#1393)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Bart Louwers <bart@emeel.net>
2023-07-26 17:50:34 +02:00

827 lines
37 KiB
Plaintext

#import "MLNMapViewIntegrationTest.h"
#import "MLNTestUtility.h"
#import "MLNMapAccessibilityElement.h"
#import "MLNTestLocationManager.h"
#import "MLNCompactCalloutView.h"
#import "MLNGeometry_Private.h"
#import "MLNMapView_Private.h"
#include <mbgl/util/geo.hpp>
#include <mbgl/map/camera.hpp>
#include <mbgl/map/map.hpp>
@interface MLNTestCalloutView : MLNCompactCalloutView
@property (nonatomic) BOOL implementsMarginHints;
@end
@implementation MLNTestCalloutView
- (BOOL)respondsToSelector:(SEL)aSelector {
if (!self.implementsMarginHints &&
(aSelector == @selector(marginInsetsHintForPresentationFromRect:))) {
return NO;
}
return [super respondsToSelector:aSelector];
}
@end
@interface MLNMapView (Tests)
- (MLNAnnotationTag)annotationTagAtPoint:(CGPoint)point persistingResults:(BOOL)persist;
- (id <MLNAnnotation>)annotationWithTag:(MLNAnnotationTag)tag;
- (MLNMapCamera *)cameraByRotatingToDirection:(CLLocationDirection)degrees aroundAnchorPoint:(CGPoint)anchorPoint;
- (MLNMapCamera *)cameraByZoomingToZoomLevel:(double)zoom aroundAnchorPoint:(CGPoint)anchorPoint;
- (MLNMapCamera *)cameraForCameraOptions:(const mbgl::CameraOptions &)cameraOptions;
@property (nonatomic) UIView<MLNCalloutView> *calloutViewForSelectedAnnotation;
@end
@interface MLNAnnotationViewIntegrationTests : MLNMapViewIntegrationTest
@end
@implementation MLNAnnotationViewIntegrationTests
// 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 MLNTestAnnotationReuseIdentifer = @"MLNTestAnnotationReuseIdentifer";
CGSize size = self.mapView.bounds.size;
CGSize annotationSize = CGSizeMake(floor(size.width*kAnnotationRelativeScale.x), floor(size.height*kAnnotationRelativeScale.y));
self.viewForAnnotation = ^MLNAnnotationView*(MLNMapView *view, id<MLNAnnotation> annotation) {
if (![annotation isKindOfClass:[MLNPointAnnotation class]]) {
return nil;
}
// No dequeue
MLNAnnotationView *annotationView = [[MLNAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:MLNTestAnnotationReuseIdentifer];
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];
MLNPointAnnotation *point = [[MLNPointAnnotation alloc] init];
point.title = title;
point.coordinate = coordinate;
self.mapViewAnnotationCanShowCalloutForAnnotation = ^BOOL(MLNMapView *mapView, id<MLNAnnotation> annotation) {
return showsCallout;
};
self.mapViewCalloutViewForAnnotation = ^id<MLNCalloutView>(MLNMapView *mapView, id<MLNAnnotation> annotation) {
if (!showsCallout)
return nil;
MLNTestCalloutView *calloutView = [[MLNTestCalloutView 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 MLNAnimationDuration (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)
#if TARGET_OS_SIMULATOR
// Something is amiss with dispatch_async & XCTest on simulators in a recent
// iOS SDK release.
// TODO: Add issue
[self waitForExpectations:@[selectionCompleted] timeout:animateSelection ? 0.4: 0.1];
#else
[self waitForExpectations:@[selectionCompleted] timeout:animateSelection ? 0.4: 0.05];
#endif
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 MLNTestWarning
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 MLNTestWarning
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 },
{ {(CGFloat)(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];
}
// 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 MLNTestAnnotationReuseIdentifer = @"MLNTestAnnotationReuseIdentifer";
self.mapView.contentInset = edgeInsets;
CGSize size = self.mapView.bounds.size;
CGSize annotationSize = CGSizeMake(40.0, 40.0);
self.viewForAnnotation = ^MLNAnnotationView*(MLNMapView *view, id<MLNAnnotation> annotation) {
if (![annotation isKindOfClass:[MLNPointAnnotation class]]) {
return nil;
}
// No dequeue
MLNAnnotationView *annotationView = [[MLNAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:MLNTestAnnotationReuseIdentifer];
annotationView.bounds = (CGRect){ .origin = CGPointZero, .size = annotationSize };
annotationView.backgroundColor = UIColor.redColor;
annotationView.enabled = YES;
annotationView.centerOffset = offset;
return annotationView;
};
MLNPointAnnotation *point = [[MLNPointAnnotation 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);
MLNAnnotationTag 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)];
MLNTestLocationManager *locationManager = [[MLNTestLocationManager alloc] init];
self.mapView.locationManager = locationManager;
[self.mapView setUserTrackingMode:MLNUserTrackingModeFollow 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:MLNUserTrackingModeNone animated:NO completionHandler:nil];
CGPoint offset = CGPointMake(20, 20);
self.mapViewUserLocationAnchorPoint = ^CGPoint (MLNMapView *mapView) {
return offset;;
};
[self.mapView setUserTrackingMode:MLNUserTrackingModeFollow 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);
}
// MARK: - Rotating/zooming
- (void)testSelectingAnnotationWhenMapIsRotated {
CLLocationCoordinate2D coordinates[] = {
{ 40.0, 40.0 },
{ NAN, NAN }
};
NSArray *annotations = [self internalAddAnnotationsAtCoordinates:coordinates];
MLNPointAnnotation *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) {
MLNMapCamera *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];
MLNPointAnnotation *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) {
MLNMapCamera *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];
MLNPointAnnotation *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;
MLNMapCamera *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 (MLNPointAnnotation *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 MLNTestAnnotationReuseIdentifer = @"MLNTestAnnotationReuseIdentifer";
CGSize annotationSize = CGSizeMake(40.0, 40.0);
self.viewForAnnotation = ^MLNAnnotationView*(MLNMapView *view, id<MLNAnnotation> annotation2) {
if (![annotation2 isKindOfClass:[MLNPointAnnotation class]]) {
return nil;
}
// No dequeue
MLNAnnotationView *annotationView = [[MLNAnnotationView alloc] initWithAnnotation:annotation2 reuseIdentifier:MLNTestAnnotationReuseIdentifer];
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++;
MLNPointAnnotation *annotation = [[MLNPointAnnotation 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:(MLNPointAnnotation*)point withCamera:(MLNMapCamera*)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:(MLNPointAnnotation*)point {
[XCTContext runActivityNamed:[NSString stringWithFormat:@"Select annotation: %@", point]
block:^(id<XCTActivity> _Nonnull activity)
{
CGPoint annotationPoint = [self.mapView convertCoordinate:point.coordinate toPointToView:self.mapView];
MLNAnnotationTag tagAtPoint = [self.mapView annotationTagAtPoint:annotationPoint persistingResults:YES];
if (tagAtPoint != UINT32_MAX)
{
id <MLNAnnotation> 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));
}
}];
}
// 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.mapView setNeedsRerender];
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