blob: 258684418b34c6253a254e4f84e58d838e71fd6b [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import <Foundation/Foundation.h>
#import <OCMock/OCMock.h>
#import <UIKit/UIKit.h>
#import <XCTest/XCTest.h>
#include <_types/_uint32_t.h>
#include "flutter/fml/platform/darwin/message_loop_darwin.h"
#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h"
#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFakeKeyEvents.h"
#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterKeyboardManager.h"
#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterUIPressProxy.h"
#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h"
FLUTTER_ASSERT_ARC;
namespace flutter {
class PointerDataPacket {};
}
/// Sometimes we have to use a custom mock to avoid retain cycles in ocmock.
@interface FlutterEnginePartialMock : FlutterEngine
@property(nonatomic, strong) FlutterBasicMessageChannel* lifecycleChannel;
@property(nonatomic, weak) FlutterViewController* viewController;
@property(nonatomic, assign) BOOL didCallNotifyLowMemory;
@end
@interface FlutterEngine ()
- (BOOL)createShell:(NSString*)entrypoint
libraryURI:(NSString*)libraryURI
initialRoute:(NSString*)initialRoute;
- (void)dispatchPointerDataPacket:(std::unique_ptr<flutter::PointerDataPacket>)packet;
@end
@interface FlutterEngine (TestLowMemory)
- (void)notifyLowMemory;
@end
extern NSNotificationName const FlutterViewControllerWillDealloc;
/// A simple mock class for FlutterEngine.
///
/// OCMockClass can't be used for FlutterEngine sometimes because OCMock retains arguments to
/// invocations and since the init for FlutterViewController calls a method on the
/// FlutterEngine it creates a retain cycle that stops us from testing behaviors related to
/// deleting FlutterViewControllers.
@interface MockEngine : NSObject
@end
@interface FlutterKeyboardManagerUnittestsObjC : NSObject
- (bool)nextResponderShouldThrowOnPressesEnded;
- (bool)singlePrimaryResponder;
- (bool)doublePrimaryResponder;
- (bool)singleSecondaryResponder;
- (bool)emptyNextResponder;
@end
namespace {
typedef void (^KeyCallbackSetter)(FlutterUIPressProxy* press, FlutterAsyncKeyCallback callback)
API_AVAILABLE(ios(13.4));
typedef BOOL (^BoolGetter)();
} // namespace
@interface FlutterKeyboardManagerTest : XCTestCase
@property(nonatomic, strong) id mockEngine;
- (FlutterViewController*)mockOwnerWithPressesBeginOnlyNext API_AVAILABLE(ios(13.4));
@end
@implementation FlutterKeyboardManagerTest
- (void)setUp {
[super setUp];
self.mockEngine = OCMClassMock([FlutterEngine class]);
}
- (void)tearDown {
// We stop mocking here to avoid retain cycles that stop
// FlutterViewControllers from deallocing.
[self.mockEngine stopMocking];
self.mockEngine = nil;
[super tearDown];
}
- (id)checkKeyDownEvent:(UIKeyboardHIDUsage)keyCode API_AVAILABLE(ios(13.4)) {
return [OCMArg checkWithBlock:^BOOL(id value) {
if (![value isKindOfClass:[FlutterUIPressProxy class]]) {
return NO;
}
FlutterUIPressProxy* press = value;
return press.key.keyCode == keyCode;
}];
}
- (id<FlutterKeyPrimaryResponder>)mockPrimaryResponder:(KeyCallbackSetter)callbackSetter
API_AVAILABLE(ios(13.4)) {
id<FlutterKeyPrimaryResponder> mock =
OCMStrictProtocolMock(@protocol(FlutterKeyPrimaryResponder));
OCMStub([mock handlePress:[OCMArg any] callback:[OCMArg any]])
.andDo((^(NSInvocation* invocation) {
FlutterUIPressProxy* press;
FlutterAsyncKeyCallback callback;
[invocation getArgument:&press atIndex:2];
[invocation getArgument:&callback atIndex:3];
CFRunLoopPerformBlock(CFRunLoopGetCurrent(),
fml::MessageLoopDarwin::kMessageLoopCFRunLoopMode, ^() {
callbackSetter(press, callback);
});
}));
return mock;
}
- (id<FlutterKeySecondaryResponder>)mockSecondaryResponder:(BoolGetter)resultGetter
API_AVAILABLE(ios(13.4)) {
id<FlutterKeySecondaryResponder> mock =
OCMStrictProtocolMock(@protocol(FlutterKeySecondaryResponder));
OCMStub([mock handlePress:[OCMArg any]]).andDo((^(NSInvocation* invocation) {
BOOL result = resultGetter();
[invocation setReturnValue:&result];
}));
return mock;
}
- (FlutterViewController*)mockOwnerWithPressesBeginOnlyNext API_AVAILABLE(ios(13.4)) {
// The nextResponder is a strict mock and hasn't stubbed pressesEnded.
// An error will be thrown on pressesEnded.
UIResponder* nextResponder = OCMStrictClassMock([UIResponder class]);
OCMStub([nextResponder pressesBegan:[OCMArg any] withEvent:[OCMArg any]]).andDo(nil);
FlutterViewController* viewController =
[[FlutterViewController alloc] initWithEngine:self.mockEngine nibName:nil bundle:nil];
FlutterViewController* owner = OCMPartialMock(viewController);
OCMStub([owner nextResponder]).andReturn(nextResponder);
return owner;
}
// Verify that the nextResponder returned from mockOwnerWithPressesBeginOnlyNext()
// throws exception when pressesEnded is called.
- (bool)testNextResponderShouldThrowOnPressesEnded API_AVAILABLE(ios(13.4)) {
FlutterViewController* owner = [self mockOwnerWithPressesBeginOnlyNext];
@try {
[owner.nextResponder pressesEnded:[NSSet init] withEvent:[[UIPressesEvent alloc] init]];
return false;
} @catch (...) {
return true;
}
}
- (void)testSinglePrimaryResponder API_AVAILABLE(ios(13.4)) {
FlutterKeyboardManager* manager = [[FlutterKeyboardManager alloc] init];
__block BOOL primaryResponse = FALSE;
__block int callbackCount = 0;
[manager addPrimaryResponder:[self mockPrimaryResponder:^(FlutterUIPressProxy* press,
FlutterAsyncKeyCallback callback) {
callbackCount++;
callback(primaryResponse);
}]];
constexpr UIKeyboardHIDUsage keyId = (UIKeyboardHIDUsage)0x50;
// Case: The responder reports TRUE
__block bool completeHandled = true;
primaryResponse = TRUE;
[manager handlePress:keyDownEvent(keyId)
nextAction:^() {
completeHandled = false;
}];
XCTAssertEqual(callbackCount, 1);
XCTAssertTrue(completeHandled);
completeHandled = true;
callbackCount = 0;
// Case: The responder reports FALSE
primaryResponse = FALSE;
[manager handlePress:keyUpEvent(keyId)
nextAction:^() {
completeHandled = false;
}];
XCTAssertEqual(callbackCount, 1);
XCTAssertFalse(completeHandled);
}
- (void)testDoublePrimaryResponder API_AVAILABLE(ios(13.4)) {
FlutterKeyboardManager* manager = [[FlutterKeyboardManager alloc] init];
__block BOOL callback1Response = FALSE;
__block int callback1Count = 0;
[manager addPrimaryResponder:[self mockPrimaryResponder:^(FlutterUIPressProxy* press,
FlutterAsyncKeyCallback callback) {
callback1Count++;
callback(callback1Response);
}]];
__block BOOL callback2Response = FALSE;
__block int callback2Count = 0;
[manager addPrimaryResponder:[self mockPrimaryResponder:^(FlutterUIPressProxy* press,
FlutterAsyncKeyCallback callback) {
callback2Count++;
callback(callback2Response);
}]];
// Case: Both responders report TRUE.
__block bool somethingWasHandled = true;
constexpr UIKeyboardHIDUsage keyId = (UIKeyboardHIDUsage)0x50;
callback1Response = TRUE;
callback2Response = TRUE;
[manager handlePress:keyUpEvent(keyId)
nextAction:^() {
somethingWasHandled = false;
}];
XCTAssertEqual(callback1Count, 1);
XCTAssertEqual(callback2Count, 1);
XCTAssertTrue(somethingWasHandled);
somethingWasHandled = true;
callback1Count = 0;
callback2Count = 0;
// Case: One responder reports TRUE.
callback1Response = TRUE;
callback2Response = FALSE;
[manager handlePress:keyUpEvent(keyId)
nextAction:^() {
somethingWasHandled = false;
}];
XCTAssertEqual(callback1Count, 1);
XCTAssertEqual(callback2Count, 1);
XCTAssertTrue(somethingWasHandled);
somethingWasHandled = true;
callback1Count = 0;
callback2Count = 0;
// Case: Both responders report FALSE.
callback1Response = FALSE;
callback2Response = FALSE;
[manager handlePress:keyDownEvent(keyId)
nextAction:^() {
somethingWasHandled = false;
}];
XCTAssertEqual(callback1Count, 1);
XCTAssertEqual(callback2Count, 1);
XCTAssertFalse(somethingWasHandled);
}
- (void)testSingleSecondaryResponder API_AVAILABLE(ios(13.4)) {
FlutterKeyboardManager* manager = [[FlutterKeyboardManager alloc] init];
__block BOOL primaryResponse = FALSE;
__block int callbackCount = 0;
[manager addPrimaryResponder:[self mockPrimaryResponder:^(FlutterUIPressProxy* press,
FlutterAsyncKeyCallback callback) {
callbackCount++;
callback(primaryResponse);
}]];
__block BOOL secondaryResponse;
[manager addSecondaryResponder:[self mockSecondaryResponder:^() {
return secondaryResponse;
}]];
// Case: Primary responder responds TRUE. The event shouldn't be handled by
// the secondary responder.
constexpr UIKeyboardHIDUsage keyId = (UIKeyboardHIDUsage)0x50;
secondaryResponse = FALSE;
primaryResponse = TRUE;
__block bool completeHandled = true;
[manager handlePress:keyUpEvent(keyId)
nextAction:^() {
completeHandled = false;
}];
XCTAssertEqual(callbackCount, 1);
XCTAssertTrue(completeHandled);
completeHandled = true;
callbackCount = 0;
// Case: Primary responder responds FALSE. The secondary responder returns
// TRUE.
secondaryResponse = TRUE;
primaryResponse = FALSE;
[manager handlePress:keyUpEvent(keyId)
nextAction:^() {
completeHandled = false;
}];
XCTAssertEqual(callbackCount, 1);
XCTAssertTrue(completeHandled);
completeHandled = true;
callbackCount = 0;
// Case: Primary responder responds FALSE. The secondary responder returns FALSE.
secondaryResponse = FALSE;
primaryResponse = FALSE;
[manager handlePress:keyDownEvent(keyId)
nextAction:^() {
completeHandled = false;
}];
XCTAssertEqual(callbackCount, 1);
XCTAssertFalse(completeHandled);
}
- (void)testEventsProcessedSequentially API_AVAILABLE(ios(13.4)) {
constexpr UIKeyboardHIDUsage keyId1 = (UIKeyboardHIDUsage)0x50;
constexpr UIKeyboardHIDUsage keyId2 = (UIKeyboardHIDUsage)0x51;
FlutterUIPressProxy* event1 = keyDownEvent(keyId1);
FlutterUIPressProxy* event2 = keyDownEvent(keyId2);
__block FlutterAsyncKeyCallback key1Callback;
__block FlutterAsyncKeyCallback key2Callback;
__block bool key1Handled = true;
__block bool key2Handled = true;
FlutterKeyboardManager* manager = [[FlutterKeyboardManager alloc] init];
[manager addPrimaryResponder:[self mockPrimaryResponder:^(FlutterUIPressProxy* press,
FlutterAsyncKeyCallback callback) {
if (press == event1) {
key1Callback = callback;
} else if (press == event2) {
key2Callback = callback;
}
}]];
// Add both presses into the main CFRunLoop queue
CFRunLoopTimerRef timer0 = CFRunLoopTimerCreateWithHandler(
kCFAllocatorDefault, CFAbsoluteTimeGetCurrent(), 0, 0, 0, ^(CFRunLoopTimerRef timerRef) {
[manager handlePress:event1
nextAction:^() {
key1Handled = false;
}];
});
CFRunLoopAddTimer(CFRunLoopGetCurrent(), timer0, kCFRunLoopCommonModes);
CFRunLoopTimerRef timer1 = CFRunLoopTimerCreateWithHandler(
kCFAllocatorDefault, CFAbsoluteTimeGetCurrent() + 1, 0, 0, 0, ^(CFRunLoopTimerRef timerRef) {
// key1 should be completely finished by now
XCTAssertFalse(key1Handled);
[manager handlePress:event2
nextAction:^() {
key2Handled = false;
}];
// End the nested CFRunLoop
CFRunLoopStop(CFRunLoopGetCurrent());
});
CFRunLoopAddTimer(CFRunLoopGetCurrent(), timer1, kCFRunLoopCommonModes);
// Add the callbacks to the CFRunLoop with mode kMessageLoopCFRunLoopMode
// This allows them to interrupt the loop started within handlePress
CFRunLoopTimerRef timer2 = CFRunLoopTimerCreateWithHandler(
kCFAllocatorDefault, CFAbsoluteTimeGetCurrent() + 2, 0, 0, 0, ^(CFRunLoopTimerRef timerRef) {
// No processing should be done on key2 yet
XCTAssertTrue(key1Callback != nil);
XCTAssertTrue(key2Callback == nil);
key1Callback(false);
});
CFRunLoopAddTimer(CFRunLoopGetCurrent(), timer2,
fml::MessageLoopDarwin::kMessageLoopCFRunLoopMode);
CFRunLoopTimerRef timer3 = CFRunLoopTimerCreateWithHandler(
kCFAllocatorDefault, CFAbsoluteTimeGetCurrent() + 3, 0, 0, 0, ^(CFRunLoopTimerRef timerRef) {
// Both keys should be processed by now
XCTAssertTrue(key1Callback != nil);
XCTAssertTrue(key2Callback != nil);
key2Callback(false);
});
CFRunLoopAddTimer(CFRunLoopGetCurrent(), timer3,
fml::MessageLoopDarwin::kMessageLoopCFRunLoopMode);
// Start a nested CFRunLoop so we can wait for both presses to complete before exiting the test
CFRunLoopRun();
XCTAssertFalse(key2Handled);
XCTAssertFalse(key1Handled);
}
@end