blob: 6289c07d1e996c743718cbc0948e326c2cd70ff0 [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 "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h"
#import <OCMock/OCMock.h>
#import <XCTest/XCTest.h>
#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h"
#import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h"
#import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h"
FLUTTER_ASSERT_ARC
@interface FlutterEngine ()
- (nonnull FlutterTextInputPlugin*)textInputPlugin;
@end
@interface FlutterTextInputView ()
@property(nonatomic, copy) NSString* autofillId;
- (void)setEditableTransform:(NSArray*)matrix;
- (void)setTextInputState:(NSDictionary*)state;
- (void)setMarkedRect:(CGRect)markedRect;
- (void)updateEditingState;
- (BOOL)isVisibleToAutofill;
@end
@interface FlutterTextInputViewSpy : FlutterTextInputView
@property(nonatomic, assign) UIAccessibilityNotifications receivedNotification;
@property(nonatomic, assign) id receivedNotificationTarget;
@property(nonatomic, assign) BOOL isAccessibilityFocused;
- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target;
@end
@implementation FlutterTextInputViewSpy {
}
- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target {
self.receivedNotification = notification;
self.receivedNotificationTarget = target;
}
- (BOOL)accessibilityElementIsFocused {
return _isAccessibilityFocused;
}
@end
@interface FlutterSecureTextInputView : FlutterTextInputView
@property(nonatomic, strong) UITextField* textField;
@end
@interface FlutterTextInputPlugin ()
@property(nonatomic, assign) FlutterTextInputView* activeView;
@property(nonatomic, readonly)
NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
- (void)cleanUpViewHierarchy:(BOOL)includeActiveView
clearText:(BOOL)clearText
delayRemoval:(BOOL)delayRemoval;
- (NSArray<UIView*>*)textInputViews;
- (UIView*)hostView;
- (void)addToInputParentViewIfNeeded:(FlutterTextInputView*)inputView;
@end
@interface FlutterTextInputPluginTest : XCTestCase
@end
@implementation FlutterTextInputPluginTest {
NSDictionary* _template;
NSDictionary* _passwordTemplate;
id engine;
FlutterTextInputPlugin* textInputPlugin;
FlutterViewController* viewController;
}
- (void)setUp {
[super setUp];
engine = OCMClassMock([FlutterEngine class]);
textInputPlugin = [[FlutterTextInputPlugin alloc] init];
textInputPlugin.textInputDelegate = engine;
viewController = [FlutterViewController new];
textInputPlugin.viewController = viewController;
// Clear pasteboard between tests.
UIPasteboard.generalPasteboard.items = @[];
}
- (void)tearDown {
for (FlutterTextInputView* autofillView in textInputPlugin.autofillContext.allValues) {
autofillView.textInputDelegate = nil;
}
engine = nil;
[textInputPlugin.autofillContext removeAllObjects];
[textInputPlugin cleanUpViewHierarchy:YES clearText:YES delayRemoval:NO];
[[[[textInputPlugin textInputView] superview] subviews]
makeObjectsPerformSelector:@selector(removeFromSuperview)];
viewController = nil;
[super tearDown];
}
- (void)setClientId:(int)clientId configuration:(NSDictionary*)config {
FlutterMethodCall* setClientCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
arguments:@[ [NSNumber numberWithInt:clientId], config ]];
[textInputPlugin handleMethodCall:setClientCall
result:^(id _Nullable result){
}];
}
- (void)setTextInputShow {
FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.show"
arguments:@[]];
[textInputPlugin handleMethodCall:setClientCall
result:^(id _Nullable result){
}];
}
- (void)setTextInputHide {
FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.hide"
arguments:@[]];
[textInputPlugin handleMethodCall:setClientCall
result:^(id _Nullable result){
}];
}
- (NSMutableDictionary*)mutableTemplateCopy {
if (!_template) {
_template = @{
@"inputType" : @{@"name" : @"TextInuptType.text"},
@"keyboardAppearance" : @"Brightness.light",
@"obscureText" : @NO,
@"inputAction" : @"TextInputAction.unspecified",
@"smartDashesType" : @"0",
@"smartQuotesType" : @"0",
@"autocorrect" : @YES
};
}
return [_template mutableCopy];
}
- (NSArray<FlutterTextInputView*>*)installedInputViews {
return (NSArray<FlutterTextInputView*>*)[textInputPlugin.textInputViews
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@",
[FlutterTextInputView class]]];
}
- (FlutterTextRange*)getLineRangeFromTokenizer:(id<UITextInputTokenizer>)tokenizer
atIndex:(NSInteger)index {
UITextRange* range =
[tokenizer rangeEnclosingPosition:[FlutterTextPosition positionWithIndex:index]
withGranularity:UITextGranularityLine
inDirection:UITextLayoutDirectionRight];
XCTAssertTrue([range isKindOfClass:[FlutterTextRange class]]);
return (FlutterTextRange*)range;
}
#pragma mark - Tests
- (void)testSecureInput {
NSDictionary* config = self.mutableTemplateCopy;
[config setValue:@"YES" forKey:@"obscureText"];
[self setClientId:123 configuration:config];
// Find all the FlutterTextInputViews we created.
NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
// There are no autofill and the mock framework requested a secure entry. The first and only
// inserted FlutterTextInputView should be a secure text entry one.
FlutterTextInputView* inputView = inputFields[0];
// Verify secureTextEntry is set to the correct value.
XCTAssertTrue(inputView.secureTextEntry);
// Verify keyboardType is set to the default value.
XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeDefault);
// We should have only ever created one FlutterTextInputView.
XCTAssertEqual(inputFields.count, 1ul);
// The one FlutterTextInputView we inserted into the view hierarchy should be the text input
// plugin's active text input view.
XCTAssertEqual(inputView, textInputPlugin.textInputView);
// Despite not given an id in configuration, inputView has
// an autofill id.
XCTAssert(inputView.autofillId.length > 0);
}
- (void)testKeyboardType {
NSDictionary* config = self.mutableTemplateCopy;
[config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"];
[self setClientId:123 configuration:config];
// Find all the FlutterTextInputViews we created.
NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
FlutterTextInputView* inputView = inputFields[0];
// Verify keyboardType is set to the value specified in config.
XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeURL);
}
- (void)testSettingKeyboardTypeNoneDisablesSystemKeyboard {
NSDictionary* config = self.mutableTemplateCopy;
[config setValue:@{@"name" : @"TextInputType.none"} forKey:@"inputType"];
[self setClientId:123 configuration:config];
// Verify the view's inputViewController is not nil;
XCTAssertNotNil(textInputPlugin.activeView.inputViewController);
[config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"];
[self setClientId:124 configuration:config];
XCTAssertNotNil(textInputPlugin.activeView);
XCTAssertNil(textInputPlugin.activeView.inputViewController);
}
- (void)testAutocorrectionPromptRectAppears {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithFrame:CGRectZero];
inputView.textInputDelegate = engine;
[inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
// Verify behavior.
OCMVerify([engine flutterTextInputView:inputView
showAutocorrectionPromptRectForStart:0
end:1
withClient:0]);
}
- (void)testAutocorrectionPromptRectDoesNotAppearDuringScribble {
if (@available(iOS 14.0, *)) {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithFrame:CGRectZero];
inputView.textInputDelegate = engine;
__block int callCount = 0;
OCMStub([engine flutterTextInputView:inputView
showAutocorrectionPromptRectForStart:0
end:1
withClient:0])
.andDo(^(NSInvocation* invocation) {
callCount++;
});
[inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
// showAutocorrectionPromptRectForStart fires in response to firstRectForRange
XCTAssertEqual(callCount, 1);
UIScribbleInteraction* scribbleInteraction =
[[UIScribbleInteraction alloc] initWithDelegate:inputView];
[inputView scribbleInteractionWillBeginWriting:scribbleInteraction];
[inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
// showAutocorrectionPromptRectForStart does not fire in response to setMarkedText during a
// scribble interaction.firstRectForRange
XCTAssertEqual(callCount, 1);
[inputView scribbleInteractionDidFinishWriting:scribbleInteraction];
[inputView resetScribbleInteractionStatusIfEnding];
[inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
// showAutocorrectionPromptRectForStart fires in response to firstRectForRange.
XCTAssertEqual(callCount, 2);
inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing;
[inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
// showAutocorrectionPromptRectForStart does not fire in response to firstRectForRange during a
// scribble-initiated focus.
XCTAssertEqual(callCount, 2);
inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocused;
[inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
// showAutocorrectionPromptRectForStart does not fire in response to firstRectForRange after a
// scribble-initiated focus.
XCTAssertEqual(callCount, 2);
inputView.scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused;
[inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
// showAutocorrectionPromptRectForStart fires in response to firstRectForRange.
XCTAssertEqual(callCount, 3);
}
}
- (void)testTextRangeFromPositionMatchesUITextViewBehavior {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithFrame:CGRectZero];
FlutterTextPosition* fromPosition = [[FlutterTextPosition alloc] initWithIndex:2];
FlutterTextPosition* toPosition = [[FlutterTextPosition alloc] initWithIndex:0];
FlutterTextRange* flutterRange = (FlutterTextRange*)[inputView textRangeFromPosition:fromPosition
toPosition:toPosition];
NSRange range = flutterRange.range;
XCTAssertEqual(range.location, 0ul);
XCTAssertEqual(range.length, 2ul);
}
- (void)testTextInRange {
NSDictionary* config = self.mutableTemplateCopy;
[config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"];
[self setClientId:123 configuration:config];
NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
FlutterTextInputView* inputView = inputFields[0];
[inputView insertText:@"test"];
UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 20)];
NSString* substring = [inputView textInRange:range];
XCTAssertEqual(substring.length, 4ul);
range = [FlutterTextRange rangeWithNSRange:NSMakeRange(10, 20)];
substring = [inputView textInRange:range];
XCTAssertEqual(substring.length, 0ul);
}
- (void)testStandardEditActions {
NSDictionary* config = self.mutableTemplateCopy;
[self setClientId:123 configuration:config];
NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
FlutterTextInputView* inputView = inputFields[0];
[inputView insertText:@"aaaa"];
[inputView selectAll:nil];
[inputView cut:nil];
[inputView insertText:@"bbbb"];
XCTAssertTrue([inputView canPerformAction:@selector(paste:) withSender:nil]);
[inputView paste:nil];
[inputView selectAll:nil];
[inputView copy:nil];
[inputView paste:nil];
[inputView selectAll:nil];
[inputView delete:nil];
[inputView paste:nil];
[inputView paste:nil];
UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 30)];
NSString* substring = [inputView textInRange:range];
XCTAssertEqualObjects(substring, @"bbbbaaaabbbbaaaa");
}
- (void)testPastingNonTextDisallowed {
NSDictionary* config = self.mutableTemplateCopy;
[self setClientId:123 configuration:config];
NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
FlutterTextInputView* inputView = inputFields[0];
UIPasteboard.generalPasteboard.color = UIColor.redColor;
XCTAssertNil(UIPasteboard.generalPasteboard.string);
XCTAssertFalse([inputView canPerformAction:@selector(paste:) withSender:nil]);
[inputView paste:nil];
XCTAssertEqualObjects(inputView.text, @"");
}
- (void)testNoZombies {
// Regression test for https://github.com/flutter/flutter/issues/62501.
FlutterSecureTextInputView* passwordView = [[FlutterSecureTextInputView alloc] init];
@autoreleasepool {
// Initialize the lazy textField.
[passwordView.textField description];
}
XCTAssert([[passwordView.textField description] containsString:@"TextField"]);
}
- (void)testInputViewCrash {
FlutterTextInputView* activeView = nil;
@autoreleasepool {
FlutterTextInputPlugin* inputPlugin = [[FlutterTextInputPlugin alloc] init];
activeView = inputPlugin.activeView;
FlutterEngine* flutterEngine = [[FlutterEngine alloc] init];
activeView.textInputDelegate = (id<FlutterTextInputDelegate>)flutterEngine;
}
XCTAssert(!activeView.textInputDelegate);
[activeView updateEditingState];
}
- (void)testDoNotReuseInputViews {
NSDictionary* config = self.mutableTemplateCopy;
[self setClientId:123 configuration:config];
FlutterTextInputView* currentView = textInputPlugin.activeView;
[self setClientId:456 configuration:config];
XCTAssertNotNil(currentView);
XCTAssertNotNil(textInputPlugin.activeView);
XCTAssertNotEqual(currentView, textInputPlugin.activeView);
}
- (void)testNoDanglingEnginePointer {
NSDictionary* config = self.mutableTemplateCopy;
[self setClientId:123 configuration:config];
// We'll hold onto the current view and try to access the engine
// after changing the active view.
FlutterTextInputView* currentView = textInputPlugin.activeView;
[self setClientId:456 configuration:config];
XCTAssertNotNil(currentView);
XCTAssertNotNil(textInputPlugin.activeView);
XCTAssertNotEqual(currentView, textInputPlugin.activeView);
// Verify that the view can no longer access the engine
// instance.
XCTAssertNil(currentView.textInputDelegate);
}
- (void)ensureOnlyActiveViewCanBecomeFirstResponder {
for (FlutterTextInputView* inputView in self.installedInputViews) {
XCTAssertEqual(inputView.canBecomeFirstResponder, inputView == textInputPlugin.activeView);
}
}
- (void)testPropagatePressEventsToNextResponder {
NSDictionary* config = self.mutableTemplateCopy;
[self setClientId:123 configuration:config];
FlutterTextInputView* currentView = textInputPlugin.activeView;
UIView* mockCurrentView = OCMPartialMock(currentView);
[mockCurrentView pressesBegan:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil]
withEvent:OCMClassMock([UIPressesEvent class])];
// The event should be propagated to the next responder instead
// of the engine.
OCMVerify([mockCurrentView nextResponder]);
}
#pragma mark - TextEditingDelta tests
- (void)testTextEditingDeltasAreGeneratedOnTextInput {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;
inputView.enableDeltaModel = YES;
__block int updateCount = 0;
OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
.andDo(^(NSInvocation* invocation) {
updateCount++;
});
[inputView insertText:@"text to insert"];
// Update the framework exactly once.
XCTAssertEqual(updateCount, 1);
// Verify correct delta is generated.
OCMVerify([engine
flutterTextInputView:inputView
updateEditingClient:0
withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] isEqualToString:@""]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
isEqualToString:@"text to insert"]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 0) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 0);
}]]);
[inputView deleteBackward];
XCTAssertEqual(updateCount, 2);
OCMVerify([engine
flutterTextInputView:inputView
updateEditingClient:0
withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
isEqualToString:@"text to insert"]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
isEqualToString:@""]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 14);
}]]);
inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
XCTAssertEqual(updateCount, 3);
OCMVerify([engine
flutterTextInputView:inputView
updateEditingClient:0
withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
isEqualToString:@"text to inser"]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
isEqualToString:@""]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == -1) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == -1);
}]]);
[inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]
withText:@"replace text"];
XCTAssertEqual(updateCount, 4);
OCMVerify([engine
flutterTextInputView:inputView
updateEditingClient:0
withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
isEqualToString:@"text to inser"]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
isEqualToString:@"replace text"]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 0) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 1);
}]]);
[inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
XCTAssertEqual(updateCount, 5);
OCMVerify([engine
flutterTextInputView:inputView
updateEditingClient:0
withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
isEqualToString:@"replace textext to inser"]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
isEqualToString:@"marked text"]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 12) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 12);
}]]);
[inputView unmarkText];
XCTAssertEqual(updateCount, 6);
OCMVerify([engine
flutterTextInputView:inputView
updateEditingClient:0
withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
isEqualToString:@"replace textmarked textext to inser"]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
isEqualToString:@""]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == -1) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == -1);
}]]);
}
- (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextReplacement {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;
inputView.enableDeltaModel = YES;
__block int updateCount = 0;
OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
.andDo(^(NSInvocation* invocation) {
updateCount++;
});
[inputView.text setString:@"Some initial text"];
XCTAssertEqual(updateCount, 0);
UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)];
inputView.markedTextRange = range;
inputView.selectedTextRange = nil;
XCTAssertEqual(updateCount, 1);
[inputView setMarkedText:@"new marked text." selectedRange:NSMakeRange(0, 1)];
XCTAssertEqual(updateCount, 2);
OCMVerify([engine
flutterTextInputView:inputView
updateEditingClient:0
withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
isEqualToString:@"Some initial text"]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
isEqualToString:@"new marked text."]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17);
}]]);
}
- (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextInsertion {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;
inputView.enableDeltaModel = YES;
__block int updateCount = 0;
OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
.andDo(^(NSInvocation* invocation) {
updateCount++;
});
[inputView.text setString:@"Some initial text"];
XCTAssertEqual(updateCount, 0);
UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)];
inputView.markedTextRange = range;
inputView.selectedTextRange = nil;
XCTAssertEqual(updateCount, 1);
[inputView setMarkedText:@"text." selectedRange:NSMakeRange(0, 1)];
XCTAssertEqual(updateCount, 2);
OCMVerify([engine
flutterTextInputView:inputView
updateEditingClient:0
withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
isEqualToString:@"Some initial text"]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
isEqualToString:@"text."]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17);
}]]);
}
- (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextDeletion {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;
inputView.enableDeltaModel = YES;
__block int updateCount = 0;
OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
.andDo(^(NSInvocation* invocation) {
updateCount++;
});
[inputView.text setString:@"Some initial text"];
XCTAssertEqual(updateCount, 0);
UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)];
inputView.markedTextRange = range;
inputView.selectedTextRange = nil;
XCTAssertEqual(updateCount, 1);
[inputView setMarkedText:@"tex" selectedRange:NSMakeRange(0, 1)];
XCTAssertEqual(updateCount, 2);
OCMVerify([engine
flutterTextInputView:inputView
updateEditingClient:0
withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
isEqualToString:@"Some initial text"]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
isEqualToString:@"tex"]) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) &&
([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17);
}]]);
}
#pragma mark - EditingState tests
- (void)testUITextInputCallsUpdateEditingStateOnce {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;
__block int updateCount = 0;
OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]])
.andDo(^(NSInvocation* invocation) {
updateCount++;
});
[inputView insertText:@"text to insert"];
// Update the framework exactly once.
XCTAssertEqual(updateCount, 1);
[inputView deleteBackward];
XCTAssertEqual(updateCount, 2);
inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
XCTAssertEqual(updateCount, 3);
[inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]
withText:@"replace text"];
XCTAssertEqual(updateCount, 4);
[inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
XCTAssertEqual(updateCount, 5);
[inputView unmarkText];
XCTAssertEqual(updateCount, 6);
}
- (void)testUITextInputCallsUpdateEditingStateWithDeltaOnce {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;
inputView.enableDeltaModel = YES;
__block int updateCount = 0;
OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
.andDo(^(NSInvocation* invocation) {
updateCount++;
});
[inputView insertText:@"text to insert"];
// Update the framework exactly once.
XCTAssertEqual(updateCount, 1);
[inputView deleteBackward];
XCTAssertEqual(updateCount, 2);
inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
XCTAssertEqual(updateCount, 3);
[inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]
withText:@"replace text"];
XCTAssertEqual(updateCount, 4);
[inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
XCTAssertEqual(updateCount, 5);
[inputView unmarkText];
XCTAssertEqual(updateCount, 6);
}
- (void)testTextChangesDoNotTriggerUpdateEditingClient {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;
__block int updateCount = 0;
OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]])
.andDo(^(NSInvocation* invocation) {
updateCount++;
});
[inputView.text setString:@"BEFORE"];
XCTAssertEqual(updateCount, 0);
inputView.markedTextRange = nil;
inputView.selectedTextRange = nil;
XCTAssertEqual(updateCount, 1);
// Text changes don't trigger an update.
XCTAssertEqual(updateCount, 1);
[inputView setTextInputState:@{@"text" : @"AFTER"}];
XCTAssertEqual(updateCount, 1);
[inputView setTextInputState:@{@"text" : @"AFTER"}];
XCTAssertEqual(updateCount, 1);
// Selection changes don't trigger an update.
[inputView
setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @3}];
XCTAssertEqual(updateCount, 1);
[inputView
setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @3}];
XCTAssertEqual(updateCount, 1);
// Composing region changes don't trigger an update.
[inputView
setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @2}];
XCTAssertEqual(updateCount, 1);
[inputView
setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
XCTAssertEqual(updateCount, 1);
}
- (void)testTextChangesDoNotTriggerUpdateEditingClientWithDelta {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;
inputView.enableDeltaModel = YES;
__block int updateCount = 0;
OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
.andDo(^(NSInvocation* invocation) {
updateCount++;
});
[inputView.text setString:@"BEFORE"];
XCTAssertEqual(updateCount, 0);
inputView.markedTextRange = nil;
inputView.selectedTextRange = nil;
XCTAssertEqual(updateCount, 1);
// Text changes don't trigger an update.
XCTAssertEqual(updateCount, 1);
[inputView setTextInputState:@{@"text" : @"AFTER"}];
XCTAssertEqual(updateCount, 1);
[inputView setTextInputState:@{@"text" : @"AFTER"}];
XCTAssertEqual(updateCount, 1);
// Selection changes don't trigger an update.
[inputView
setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @3}];
XCTAssertEqual(updateCount, 1);
[inputView
setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @3}];
XCTAssertEqual(updateCount, 1);
// Composing region changes don't trigger an update.
[inputView
setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @2}];
XCTAssertEqual(updateCount, 1);
[inputView
setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
XCTAssertEqual(updateCount, 1);
}
- (void)testUITextInputAvoidUnnecessaryUndateEditingClientCalls {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;
__block int updateCount = 0;
OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]])
.andDo(^(NSInvocation* invocation) {
updateCount++;
});
[inputView unmarkText];
// updateEditingClient shouldn't fire as the text is already unmarked.
XCTAssertEqual(updateCount, 0);
[inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
// updateEditingClient fires in response to setMarkedText.
XCTAssertEqual(updateCount, 1);
[inputView unmarkText];
// updateEditingClient fires in response to unmarkText.
XCTAssertEqual(updateCount, 2);
}
- (void)testSetMarkedTextDuringScribbleDoesNotTriggerUpdateEditingClient {
if (@available(iOS 14.0, *)) {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;
__block int updateCount = 0;
OCMStub([engine flutterTextInputView:inputView
updateEditingClient:0
withState:[OCMArg isNotNil]])
.andDo(^(NSInvocation* invocation) {
updateCount++;
});
[inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
// updateEditingClient fires in response to setMarkedText.
XCTAssertEqual(updateCount, 1);
UIScribbleInteraction* scribbleInteraction =
[[UIScribbleInteraction alloc] initWithDelegate:inputView];
[inputView scribbleInteractionWillBeginWriting:scribbleInteraction];
[inputView setMarkedText:@"during writing" selectedRange:NSMakeRange(1, 2)];
// updateEditingClient does not fire in response to setMarkedText during a scribble interaction.
XCTAssertEqual(updateCount, 1);
[inputView scribbleInteractionDidFinishWriting:scribbleInteraction];
[inputView resetScribbleInteractionStatusIfEnding];
[inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
// updateEditingClient fires in response to setMarkedText.
XCTAssertEqual(updateCount, 2);
inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing;
[inputView setMarkedText:@"during focus" selectedRange:NSMakeRange(1, 2)];
// updateEditingClient does not fire in response to setMarkedText during a scribble-initiated
// focus.
XCTAssertEqual(updateCount, 2);
inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocused;
[inputView setMarkedText:@"after focus" selectedRange:NSMakeRange(2, 3)];
// updateEditingClient does not fire in response to setMarkedText after a scribble-initiated
// focus.
XCTAssertEqual(updateCount, 2);
inputView.scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused;
[inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
// updateEditingClient fires in response to setMarkedText.
XCTAssertEqual(updateCount, 3);
}
}
- (void)testUpdateEditingClientNegativeSelection {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;
[inputView.text setString:@"SELECTION"];
inputView.markedTextRange = nil;
inputView.selectedTextRange = nil;
[inputView setTextInputState:@{
@"text" : @"SELECTION",
@"selectionBase" : @-1,
@"selectionExtent" : @-1
}];
[inputView updateEditingState];
OCMVerify([engine flutterTextInputView:inputView
updateEditingClient:0
withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([state[@"selectionBase"] intValue]) == 0 &&
([state[@"selectionExtent"] intValue] == 0);
}]]);
// Returns (0, 0) when either end goes below 0.
[inputView
setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @-1, @"selectionExtent" : @1}];
[inputView updateEditingState];
OCMVerify([engine flutterTextInputView:inputView
updateEditingClient:0
withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([state[@"selectionBase"] intValue]) == 0 &&
([state[@"selectionExtent"] intValue] == 0);
}]]);
[inputView
setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @-1}];
[inputView updateEditingState];
OCMVerify([engine flutterTextInputView:inputView
updateEditingClient:0
withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([state[@"selectionBase"] intValue]) == 0 &&
([state[@"selectionExtent"] intValue] == 0);
}]]);
}
- (void)testUpdateEditingClientSelectionClamping {
// Regression test for https://github.com/flutter/flutter/issues/62992.
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;
[inputView.text setString:@"SELECTION"];
inputView.markedTextRange = nil;
inputView.selectedTextRange = nil;
[inputView
setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @0}];
[inputView updateEditingState];
OCMVerify([engine flutterTextInputView:inputView
updateEditingClient:0
withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([state[@"selectionBase"] intValue]) == 0 &&
([state[@"selectionExtent"] intValue] == 0);
}]]);
// Needs clamping.
[inputView setTextInputState:@{
@"text" : @"SELECTION",
@"selectionBase" : @0,
@"selectionExtent" : @9999
}];
[inputView updateEditingState];
OCMVerify([engine flutterTextInputView:inputView
updateEditingClient:0
withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([state[@"selectionBase"] intValue]) == 0 &&
([state[@"selectionExtent"] intValue] == 9);
}]]);
// No clamping needed, but in reverse direction.
[inputView
setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @0}];
[inputView updateEditingState];
OCMVerify([engine flutterTextInputView:inputView
updateEditingClient:0
withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([state[@"selectionBase"] intValue]) == 0 &&
([state[@"selectionExtent"] intValue] == 1);
}]]);
// Both ends need clamping.
[inputView setTextInputState:@{
@"text" : @"SELECTION",
@"selectionBase" : @9999,
@"selectionExtent" : @9999
}];
[inputView updateEditingState];
OCMVerify([engine flutterTextInputView:inputView
updateEditingClient:0
withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([state[@"selectionBase"] intValue]) == 9 &&
([state[@"selectionExtent"] intValue] == 9);
}]]);
}
#pragma mark - UITextInput methods - Tests
- (void)testUpdateFirstRectForRange {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
[inputView
setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
CGRect kInvalidFirstRect = CGRectMake(-1, -1, 9999, 9999);
FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
// yOffset = 200.
NSArray* yOffsetMatrix = @[ @1, @0, @0, @0, @0, @1, @0, @0, @0, @0, @1, @0, @0, @200, @0, @1 ];
NSArray* zeroMatrix = @[ @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0 ];
// Invalid since we don't have the transform or the rect.
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
[inputView setEditableTransform:yOffsetMatrix];
// Invalid since we don't have the rect.
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
// Valid rect and transform.
CGRect testRect = CGRectMake(0, 0, 100, 100);
[inputView setMarkedRect:testRect];
CGRect finalRect = CGRectOffset(testRect, 0, 200);
XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
// Idempotent.
XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
// Use an invalid matrix:
[inputView setEditableTransform:zeroMatrix];
// Invalid matrix is invalid.
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
// Revert the invalid matrix change.
[inputView setEditableTransform:yOffsetMatrix];
[inputView setMarkedRect:testRect];
XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
// Use an invalid rect:
[inputView setMarkedRect:kInvalidFirstRect];
// Invalid marked rect is invalid.
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
}
- (void)testFirstRectForRangeReturnsCorrectSelectionRect {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
[inputView setTextInputState:@{@"text" : @"COMPOSING"}];
FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)];
CGRect testRect = CGRectMake(100, 100, 100, 100);
[inputView setSelectionRects:@[
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
[FlutterTextSelectionRect selectionRectWithRect:testRect position:1U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 200, 100, 100) position:2U],
]];
XCTAssertTrue(CGRectEqualToRect(testRect, [inputView firstRectForRange:range]));
[inputView setTextInputState:@{@"text" : @"COM"}];
FlutterTextRange* rangeOutsideBounds = [FlutterTextRange rangeWithNSRange:NSMakeRange(3, 1)];
XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:rangeOutsideBounds]));
}
- (void)testClosestPositionToPoint {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
[inputView setTextInputState:@{@"text" : @"COMPOSING"}];
// Minimize the vertical distance from the center of the rects first
[inputView setSelectionRects:@[
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:2U],
]];
CGPoint point = CGPointMake(150, 150);
XCTAssertEqual(1U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
// Then, if the point is above the bottom of the closest rects vertically, get the closest x
// origin
[inputView setSelectionRects:@[
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U],
]];
point = CGPointMake(125, 150);
XCTAssertEqual(2U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
// However, if the point is below the bottom of the closest rects vertically, get the position
// farthest to the right
[inputView setSelectionRects:@[
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 300, 100, 100) position:4U],
]];
point = CGPointMake(125, 201);
XCTAssertEqual(3U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
// Also check a point at the right edge of the last selection rect
[inputView setSelectionRects:@[
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
]];
point = CGPointMake(125, 250);
XCTAssertEqual(4U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
}
- (void)testSelectionRectsForRange {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
[inputView setTextInputState:@{@"text" : @"COMPOSING"}];
CGRect testRect0 = CGRectMake(100, 100, 100, 100);
CGRect testRect1 = CGRectMake(200, 200, 100, 100);
[inputView setSelectionRects:@[
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
[FlutterTextSelectionRect selectionRectWithRect:testRect0 position:1U],
[FlutterTextSelectionRect selectionRectWithRect:testRect1 position:2U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U],
]];
// Returns the matching rects within a range
FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)];
XCTAssertTrue(CGRectEqualToRect(testRect0, [inputView selectionRectsForRange:range][0].rect));
XCTAssertTrue(CGRectEqualToRect(testRect1, [inputView selectionRectsForRange:range][1].rect));
XCTAssertEqual(2U, [[inputView selectionRectsForRange:range] count]);
// Returns a 0 width rect for a 0-length range
range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 0)];
XCTAssertEqual(1U, [[inputView selectionRectsForRange:range] count]);
XCTAssertTrue(CGRectEqualToRect(
CGRectMake(testRect0.origin.x, testRect0.origin.y, 0, testRect0.size.height),
[inputView selectionRectsForRange:range][0].rect));
}
- (void)testClosestPositionToPointWithinRange {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
[inputView setTextInputState:@{@"text" : @"COMPOSING"}];
// Do not return a position before the start of the range
[inputView setSelectionRects:@[
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U],
]];
CGPoint point = CGPointMake(125, 150);
FlutterTextRange* range = [[FlutterTextRange rangeWithNSRange:NSMakeRange(3, 2)] copy];
XCTAssertEqual(
3U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).index);
// Do not return a position after the end of the range
[inputView setSelectionRects:@[
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U],
]];
point = CGPointMake(125, 150);
range = [[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)] copy];
XCTAssertEqual(
1U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).index);
}
#pragma mark - Floating Cursor - Tests
- (void)testInputViewsHaveUIInteractions {
if (@available(iOS 13.0, *)) {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
XCTAssertGreaterThan(inputView.interactions.count, 0ul);
}
}
- (void)testFloatingCursorDoesNotThrow {
// The keyboard implementation may send unbalanced calls to the input view.
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
[inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
[inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
[inputView endFloatingCursor];
[inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
[inputView endFloatingCursor];
}
- (void)testBoundsForFloatingCursor {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
CGRect initialBounds = inputView.bounds;
// Make sure the initial bounds.size is not as large.
XCTAssertLessThan(inputView.bounds.size.width, 100);
XCTAssertLessThan(inputView.bounds.size.height, 100);
[inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
CGRect bounds = inputView.bounds;
XCTAssertGreaterThan(bounds.size.width, 1000);
XCTAssertGreaterThan(bounds.size.height, 1000);
// Verify the caret is centered.
XCTAssertEqual(
CGRectGetMidX(bounds),
CGRectGetMidX([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:1235]]));
XCTAssertEqual(
CGRectGetMidY(bounds),
CGRectGetMidY([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:4567]]));
[inputView updateFloatingCursorAtPoint:CGPointMake(456, 654)];
bounds = inputView.bounds;
XCTAssertGreaterThan(bounds.size.width, 1000);
XCTAssertGreaterThan(bounds.size.height, 1000);
// Verify the caret is centered.
XCTAssertEqual(
CGRectGetMidX(bounds),
CGRectGetMidX([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:21]]));
XCTAssertEqual(
CGRectGetMidY(bounds),
CGRectGetMidY([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:42]]));
[inputView endFloatingCursor];
XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds));
}
#pragma mark - UIKeyInput Overrides - Tests
- (void)testInsertTextAddsPlaceholderSelectionRects {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
[inputView
setTextInputState:@{@"text" : @"test", @"selectionBase" : @1, @"selectionExtent" : @1}];
FlutterTextSelectionRect* first =
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U];
FlutterTextSelectionRect* second =
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:1U];
FlutterTextSelectionRect* third =
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 200, 100, 100) position:2U];
FlutterTextSelectionRect* fourth =
[FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U];
[inputView setSelectionRects:@[ first, second, third, fourth ]];
// Inserts additional selection rects at the selection start
[inputView insertText:@"in"];
NSArray* selectionRects =
[inputView selectionRectsForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 6)]];
XCTAssertEqual(6U, [selectionRects count]);
XCTAssertEqual(first.position, ((FlutterTextSelectionRect*)selectionRects[0]).position);
XCTAssertTrue(CGRectEqualToRect(first.rect, ((FlutterTextSelectionRect*)selectionRects[0]).rect));
XCTAssertEqual(second.position, ((FlutterTextSelectionRect*)selectionRects[1]).position);
XCTAssertTrue(
CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[1]).rect));
XCTAssertEqual(second.position + 1, ((FlutterTextSelectionRect*)selectionRects[2]).position);
XCTAssertTrue(
CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[2]).rect));
XCTAssertEqual(second.position + 2, ((FlutterTextSelectionRect*)selectionRects[3]).position);
XCTAssertTrue(
CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[3]).rect));
XCTAssertEqual(third.position + 2, ((FlutterTextSelectionRect*)selectionRects[4]).position);
XCTAssertTrue(CGRectEqualToRect(third.rect, ((FlutterTextSelectionRect*)selectionRects[4]).rect));
XCTAssertEqual(fourth.position + 2, ((FlutterTextSelectionRect*)selectionRects[5]).position);
XCTAssertTrue(
CGRectEqualToRect(fourth.rect, ((FlutterTextSelectionRect*)selectionRects[5]).rect));
}
#pragma mark - Autofill - Utilities
- (NSMutableDictionary*)mutablePasswordTemplateCopy {
if (!_passwordTemplate) {
_passwordTemplate = @{
@"inputType" : @{@"name" : @"TextInuptType.text"},
@"keyboardAppearance" : @"Brightness.light",
@"obscureText" : @YES,
@"inputAction" : @"TextInputAction.unspecified",
@"smartDashesType" : @"0",
@"smartQuotesType" : @"0",
@"autocorrect" : @YES
};
}
return [_passwordTemplate mutableCopy];
}
- (NSArray<FlutterTextInputView*>*)viewsVisibleToAutofill {
return [self.installedInputViews
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isVisibleToAutofill == YES"]];
}
- (void)commitAutofillContextAndVerify {
FlutterMethodCall* methodCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.finishAutofillContext"
arguments:@YES];
[textInputPlugin handleMethodCall:methodCall
result:^(id _Nullable result){
}];
XCTAssertEqual(self.viewsVisibleToAutofill.count,
[textInputPlugin.activeView isVisibleToAutofill] ? 1ul : 0ul);
XCTAssertNotEqual(textInputPlugin.textInputView, nil);
// The active view should still be installed so it doesn't get
// deallocated.
XCTAssertEqual(self.installedInputViews.count, 1ul);
XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul);
}
#pragma mark - Autofill - Tests
- (void)testDisablingAutofillOnInputClient {
NSDictionary* config = self.mutableTemplateCopy;
[config setValue:@"YES" forKey:@"obscureText"];
[self setClientId:123 configuration:config];
FlutterTextInputView* inputView = self.installedInputViews[0];
XCTAssertEqualObjects(inputView.textContentType, @"");
}
- (void)testAutofillEnabledByDefault {
NSDictionary* config = self.mutableTemplateCopy;
[config setValue:@"NO" forKey:@"obscureText"];
[config setValue:@{@"uniqueIdentifier" : @"field1", @"editingValue" : @{@"text" : @""}}
forKey:@"autofill"];
[self setClientId:123 configuration:config];
FlutterTextInputView* inputView = self.installedInputViews[0];
XCTAssertNil(inputView.textContentType);
}
- (void)testAutofillContext {
NSMutableDictionary* field1 = self.mutableTemplateCopy;
[field1 setValue:@{
@"uniqueIdentifier" : @"field1",
@"hints" : @[ @"hint1" ],
@"editingValue" : @{@"text" : @""}
}
forKey:@"autofill"];
NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
[field2 setValue:@{
@"uniqueIdentifier" : @"field2",
@"hints" : @[ @"hint2" ],
@"editingValue" : @{@"text" : @""}
}
forKey:@"autofill"];
NSMutableDictionary* config = [field1 mutableCopy];
[config setValue:@[ field1, field2 ] forKey:@"fields"];
[self setClientId:123 configuration:config];
XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
[textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
XCTAssertEqual(self.installedInputViews.count, 2ul);
XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
[self ensureOnlyActiveViewCanBecomeFirstResponder];
// The configuration changes.
NSMutableDictionary* field3 = self.mutablePasswordTemplateCopy;
[field3 setValue:@{
@"uniqueIdentifier" : @"field3",
@"hints" : @[ @"hint3" ],
@"editingValue" : @{@"text" : @""}
}
forKey:@"autofill"];
NSMutableDictionary* oldContext = textInputPlugin.autofillContext;
// Replace field2 with field3.
[config setValue:@[ field1, field3 ] forKey:@"fields"];
[self setClientId:123 configuration:config];
XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul);
[textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
XCTAssertEqual(self.installedInputViews.count, 3ul);
XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
[self ensureOnlyActiveViewCanBecomeFirstResponder];
// Old autofill input fields are still installed and reused.
for (NSString* key in oldContext.allKeys) {
XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
}
// Switch to a password field that has no contentType and is not in an AutofillGroup.
config = self.mutablePasswordTemplateCopy;
oldContext = textInputPlugin.autofillContext;
[self setClientId:124 configuration:config];
[self ensureOnlyActiveViewCanBecomeFirstResponder];
XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul);
[textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
XCTAssertEqual(self.installedInputViews.count, 4ul);
// Old autofill input fields are still installed and reused.
for (NSString* key in oldContext.allKeys) {
XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
}
// The active view should change.
XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
[self ensureOnlyActiveViewCanBecomeFirstResponder];
// Switch to a similar password field, the previous field should be reused.
oldContext = textInputPlugin.autofillContext;
[self setClientId:200 configuration:config];
// Reuse the input view instance from the last time.
XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul);
[textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
XCTAssertEqual(self.installedInputViews.count, 4ul);
// Old autofill input fields are still installed and reused.
for (NSString* key in oldContext.allKeys) {
XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
}
XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
[self ensureOnlyActiveViewCanBecomeFirstResponder];
}
- (void)testCommitAutofillContext {
NSMutableDictionary* field1 = self.mutableTemplateCopy;
[field1 setValue:@{
@"uniqueIdentifier" : @"field1",
@"hints" : @[ @"hint1" ],
@"editingValue" : @{@"text" : @""}
}
forKey:@"autofill"];
NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
[field2 setValue:@{
@"uniqueIdentifier" : @"field2",
@"hints" : @[ @"hint2" ],
@"editingValue" : @{@"text" : @""}
}
forKey:@"autofill"];
NSMutableDictionary* field3 = self.mutableTemplateCopy;
[field3 setValue:@{
@"uniqueIdentifier" : @"field3",
@"hints" : @[ @"hint3" ],
@"editingValue" : @{@"text" : @""}
}
forKey:@"autofill"];
NSMutableDictionary* config = [field1 mutableCopy];
[config setValue:@[ field1, field2 ] forKey:@"fields"];
[self setClientId:123 configuration:config];
XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
[self ensureOnlyActiveViewCanBecomeFirstResponder];
[self commitAutofillContextAndVerify];
[self ensureOnlyActiveViewCanBecomeFirstResponder];
// Install the password field again.
[self setClientId:123 configuration:config];
// Switch to a regular autofill group.
[self setClientId:124 configuration:field3];
XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
[textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
XCTAssertEqual(self.installedInputViews.count, 3ul);
XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
XCTAssertNotEqual(textInputPlugin.textInputView, nil);
[self ensureOnlyActiveViewCanBecomeFirstResponder];
[self commitAutofillContextAndVerify];
[self ensureOnlyActiveViewCanBecomeFirstResponder];
// Now switch to an input field that does not autofill.
[self setClientId:125 configuration:self.mutableTemplateCopy];
XCTAssertEqual(self.viewsVisibleToAutofill.count, 0ul);
// The active view should still be installed so it doesn't get
// deallocated.
[textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
XCTAssertEqual(self.installedInputViews.count, 1ul);
XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul);
[self ensureOnlyActiveViewCanBecomeFirstResponder];
[self commitAutofillContextAndVerify];
[self ensureOnlyActiveViewCanBecomeFirstResponder];
}
- (void)testAutofillInputViews {
NSMutableDictionary* field1 = self.mutableTemplateCopy;
[field1 setValue:@{
@"uniqueIdentifier" : @"field1",
@"hints" : @[ @"hint1" ],
@"editingValue" : @{@"text" : @""}
}
forKey:@"autofill"];
NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
[field2 setValue:@{
@"uniqueIdentifier" : @"field2",
@"hints" : @[ @"hint2" ],
@"editingValue" : @{@"text" : @""}
}
forKey:@"autofill"];
NSMutableDictionary* config = [field1 mutableCopy];
[config setValue:@[ field1, field2 ] forKey:@"fields"];
[self setClientId:123 configuration:config];
[self ensureOnlyActiveViewCanBecomeFirstResponder];
// Find all the FlutterTextInputViews we created.
NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
// Both fields are installed and visible because it's a password group.
XCTAssertEqual(inputFields.count, 2ul);
XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
// Find the inactive autofillable input field.
FlutterTextInputView* inactiveView = inputFields[1];
[inactiveView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 0)]
withText:@"Autofilled!"];
[self ensureOnlyActiveViewCanBecomeFirstResponder];
// Verify behavior.
OCMVerify([engine flutterTextInputView:inactiveView
updateEditingClient:0
withState:[OCMArg isNotNil]
withTag:@"field2"]);
}
- (void)testPasswordAutofillHack {
NSDictionary* config = self.mutableTemplateCopy;
[config setValue:@"YES" forKey:@"obscureText"];
[self setClientId:123 configuration:config];
// Find all the FlutterTextInputViews we created.
NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
FlutterTextInputView* inputView = inputFields[0];
XCTAssert([inputView isKindOfClass:[UITextField class]]);
// FlutterSecureTextInputView does not respond to font,
// but it should return the default UITextField.font.
XCTAssertNotEqual([inputView performSelector:@selector(font)], nil);
}
- (void)testClearAutofillContextClearsSelection {
NSMutableDictionary* regularField = self.mutableTemplateCopy;
NSDictionary* editingValue = @{
@"text" : @"REGULAR_TEXT_FIELD",
@"composingBase" : @0,
@"composingExtent" : @3,
@"selectionBase" : @1,
@"selectionExtent" : @4
};
[regularField setValue:@{
@"uniqueIdentifier" : @"field2",
@"hints" : @[ @"hint2" ],
@"editingValue" : editingValue,
}
forKey:@"autofill"];
[regularField addEntriesFromDictionary:editingValue];
[self setClientId:123 configuration:regularField];
[self ensureOnlyActiveViewCanBecomeFirstResponder];
XCTAssertEqual(self.installedInputViews.count, 1ul);
FlutterTextInputView* oldInputView = self.installedInputViews[0];
XCTAssert([oldInputView.text isEqualToString:@"REGULAR_TEXT_FIELD"]);
FlutterTextRange* selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange;
XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(1, 3)));
// Replace the original password field with new one. This should remove
// the old password field, but not immediately.
[self setClientId:124 configuration:self.mutablePasswordTemplateCopy];
[self ensureOnlyActiveViewCanBecomeFirstResponder];
XCTAssertEqual(self.installedInputViews.count, 2ul);
[textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
XCTAssertEqual(self.installedInputViews.count, 1ul);
// Verify the old input view is properly cleaned up.
XCTAssert([oldInputView.text isEqualToString:@""]);
selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange;
XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(0, 0)));
}
- (void)testGarbageInputViewsAreNotRemovedImmediately {
// Add a password field that should autofill.
[self setClientId:123 configuration:self.mutablePasswordTemplateCopy];
[self ensureOnlyActiveViewCanBecomeFirstResponder];
XCTAssertEqual(self.installedInputViews.count, 1ul);
// Add an input field that doesn't autofill. This should remove the password
// field, but not immediately.
[self setClientId:124 configuration:self.mutableTemplateCopy];
[self ensureOnlyActiveViewCanBecomeFirstResponder];
XCTAssertEqual(self.installedInputViews.count, 2ul);
[self commitAutofillContextAndVerify];
}
- (void)testDecommissionedViewAreNotReusedByAutofill {
// Regression test for https://github.com/flutter/flutter/issues/84407.
NSMutableDictionary* configuration = self.mutableTemplateCopy;
[configuration setValue:@{
@"uniqueIdentifier" : @"field1",
@"hints" : @[ UITextContentTypePassword ],
@"editingValue" : @{@"text" : @""}
}
forKey:@"autofill"];
[configuration setValue:@[ [configuration copy] ] forKey:@"fields"];
[self setClientId:123 configuration:configuration];
[self setTextInputHide];
UIView* previousActiveView = textInputPlugin.activeView;
[self setClientId:124 configuration:configuration];
// Make sure the autofillable view is reused.
XCTAssertEqual(previousActiveView, textInputPlugin.activeView);
XCTAssertNotNil(previousActiveView);
// Does not crash.
}
- (void)testInitialActiveViewCantAccessTextInputDelegate {
textInputPlugin.activeView.textInputDelegate = engine;
// Before the framework sends the first text input configuration,
// the dummy "activeView" we use should never have access to
// its textInputDelegate.
XCTAssertNil(textInputPlugin.activeView.textInputDelegate);
}
#pragma mark - Accessibility - Tests
- (void)testUITextInputAccessibilityNotHiddenWhenShowed {
// Send show text input method call.
[self setTextInputShow];
// Find all the FlutterTextInputViews we created.
NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
// The input view should not be hidden.
XCTAssertEqual([inputFields count], 1u);
// Send hide text input method call.
[self setTextInputHide];
inputFields = self.installedInputViews;
// The input view should be hidden.
XCTAssertEqual([inputFields count], 0u);
}
- (void)testFlutterTextInputViewDirectFocusToBackingTextInput {
FlutterTextInputViewSpy* inputView = [[FlutterTextInputViewSpy alloc] init];
inputView.textInputDelegate = engine;
UIView* container = [[UIView alloc] init];
UIAccessibilityElement* backing =
[[UIAccessibilityElement alloc] initWithAccessibilityContainer:container];
inputView.backingTextInputAccessibilityObject = backing;
// Simulate accessibility focus.
inputView.isAccessibilityFocused = YES;
[inputView accessibilityElementDidBecomeFocused];
XCTAssertEqual(inputView.receivedNotification, UIAccessibilityScreenChangedNotification);
XCTAssertEqual(inputView.receivedNotificationTarget, backing);
}
- (void)testFlutterTokenizerCanParseLines {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;
id<UITextInputTokenizer> tokenizer = [inputView tokenizer];
// The tokenizer returns zero range When text is empty.
FlutterTextRange* range = [self getLineRangeFromTokenizer:tokenizer atIndex:0];
XCTAssertEqual(range.range.location, 0u);
XCTAssertEqual(range.range.length, 0u);
[inputView insertText:@"how are you\nI am fine, Thank you"];
range = [self getLineRangeFromTokenizer:tokenizer atIndex:0];
XCTAssertEqual(range.range.location, 0u);
XCTAssertEqual(range.range.length, 11u);
range = [self getLineRangeFromTokenizer:tokenizer atIndex:2];
XCTAssertEqual(range.range.location, 0u);
XCTAssertEqual(range.range.length, 11u);
range = [self getLineRangeFromTokenizer:tokenizer atIndex:11];
XCTAssertEqual(range.range.location, 0u);
XCTAssertEqual(range.range.length, 11u);
range = [self getLineRangeFromTokenizer:tokenizer atIndex:12];
XCTAssertEqual(range.range.location, 12u);
XCTAssertEqual(range.range.length, 20u);
range = [self getLineRangeFromTokenizer:tokenizer atIndex:15];
XCTAssertEqual(range.range.location, 12u);
XCTAssertEqual(range.range.length, 20u);
range = [self getLineRangeFromTokenizer:tokenizer atIndex:32];
XCTAssertEqual(range.range.location, 12u);
XCTAssertEqual(range.range.length, 20u);
}
- (void)testFlutterTextInputPluginRetainsFlutterTextInputView {
FlutterViewController* flutterViewController = [FlutterViewController new];
FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] init];
myInputPlugin.textInputDelegate = engine;
myInputPlugin.viewController = flutterViewController;
__weak UIView* activeView;
@autoreleasepool {
FlutterMethodCall* setClientCall = [FlutterMethodCall
methodCallWithMethodName:@"TextInput.setClient"
arguments:@[
[NSNumber numberWithInt:123], self.mutablePasswordTemplateCopy
]];
[myInputPlugin handleMethodCall:setClientCall
result:^(id _Nullable result){
}];
activeView = myInputPlugin.textInputView;
FlutterMethodCall* hideCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.hide"
arguments:@[]];
[myInputPlugin handleMethodCall:hideCall
result:^(id _Nullable result){
}];
XCTAssertNotNil(activeView);
}
// This assert proves the myInputPlugin.textInputView is not deallocated.
XCTAssertNotNil(activeView);
}
- (void)testFlutterTextInputPluginHostViewNilCrash {
FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] init];
myInputPlugin.viewController = nil;
XCTAssertThrows([myInputPlugin hostView], @"Throws exception if host view is nil");
}
- (void)testFlutterTextInputPluginHostViewNotNil {
FlutterViewController* flutterViewController = [FlutterViewController new];
FlutterEngine* flutterEngine = [[FlutterEngine alloc] init];
[flutterEngine runWithEntrypoint:nil];
flutterEngine.viewController = flutterViewController;
XCTAssertNotNil(flutterEngine.textInputPlugin.viewController);
XCTAssertNotNil([flutterEngine.textInputPlugin hostView]);
}
@end