Flutter iOS Embedder
FlutterTextInputPluginTest.mm
Go to the documentation of this file.
1 // Copyright 2013 The Flutter Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
8 
9 #import <OCMock/OCMock.h>
10 #import <XCTest/XCTest.h>
11 
16 
18 
19 @interface FlutterEngine ()
21 @end
22 
23 @interface FlutterTextInputView ()
24 @property(nonatomic, copy) NSString* autofillId;
25 - (void)setEditableTransform:(NSArray*)matrix;
26 - (void)setTextInputClient:(int)client;
27 - (void)setTextInputState:(NSDictionary*)state;
28 - (void)setMarkedRect:(CGRect)markedRect;
29 - (void)updateEditingState;
30 - (BOOL)isVisibleToAutofill;
31 - (id<FlutterTextInputDelegate>)textInputDelegate;
32 - (void)configureWithDictionary:(NSDictionary*)configuration;
33 - (void)handleSearchWebAction;
34 - (void)handleLookUpAction;
35 - (void)handleShareAction;
36 @end
37 
39 @property(nonatomic, assign) UIAccessibilityNotifications receivedNotification;
40 @property(nonatomic, assign) id receivedNotificationTarget;
41 @property(nonatomic, assign) BOOL isAccessibilityFocused;
42 
43 - (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target;
44 
45 @end
46 
47 @implementation FlutterTextInputViewSpy {
48 }
49 
50 - (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target {
51  self.receivedNotification = notification;
52  self.receivedNotificationTarget = target;
53 }
54 
55 - (BOOL)accessibilityElementIsFocused {
56  return _isAccessibilityFocused;
57 }
58 
59 @end
60 
62 @property(nonatomic, strong) UITextField* textField;
63 @end
64 
65 @interface FlutterTextInputPlugin ()
66 @property(nonatomic, assign) FlutterTextInputView* activeView;
67 @property(nonatomic, readonly) UIView* inputHider;
68 @property(nonatomic, readonly) UIView* keyboardViewContainer;
69 @property(nonatomic, readonly) UIView* keyboardView;
70 @property(nonatomic, assign) UIView* cachedFirstResponder;
71 @property(nonatomic, readonly) CGRect keyboardRect;
72 @property(nonatomic, readonly) BOOL pendingAutofillRemoval;
73 @property(nonatomic, readonly) BOOL pendingInputViewRemoval;
74 @property(nonatomic, readonly)
75  NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
76 
77 - (void)cleanUpViewHierarchy:(BOOL)includeActiveView
78  clearText:(BOOL)clearText
79  delayRemoval:(BOOL)delayRemoval;
80 - (NSArray<UIView*>*)textInputViews;
81 - (UIView*)hostView;
82 - (void)addToInputParentViewIfNeeded:(FlutterTextInputView*)inputView;
83 - (void)startLiveTextInput;
84 - (void)showKeyboardAndRemoveScreenshot;
85 
86 @end
87 
88 namespace flutter {
89 namespace {
90 class MockPlatformViewDelegate : public PlatformView::Delegate {
91  public:
92  void OnPlatformViewCreated(std::unique_ptr<Surface> surface) override {}
93  void OnPlatformViewDestroyed() override {}
94  void OnPlatformViewScheduleFrame() override {}
95  void OnPlatformViewAddView(int64_t view_id,
96  const ViewportMetrics& viewport_metrics,
97  AddViewCallback callback) override {}
98  void OnPlatformViewRemoveView(int64_t view_id, RemoveViewCallback callback) override {}
99  void OnPlatformViewSendViewFocusEvent(const ViewFocusEvent& event) override {};
100  void OnPlatformViewSetNextFrameCallback(const fml::closure& closure) override {}
101  void OnPlatformViewSetViewportMetrics(int64_t view_id, const ViewportMetrics& metrics) override {}
102  const flutter::Settings& OnPlatformViewGetSettings() const override { return settings_; }
103  void OnPlatformViewDispatchPlatformMessage(std::unique_ptr<PlatformMessage> message) override {}
104  void OnPlatformViewDispatchPointerDataPacket(std::unique_ptr<PointerDataPacket> packet) override {
105  }
106  void OnPlatformViewDispatchSemanticsAction(int64_t view_id,
107  int32_t node_id,
108  SemanticsAction action,
109  fml::MallocMapping args) override {}
110  void OnPlatformViewSetSemanticsEnabled(bool enabled) override {}
111  void OnPlatformViewSetAccessibilityFeatures(int32_t flags) override {}
112  void OnPlatformViewRegisterTexture(std::shared_ptr<Texture> texture) override {}
113  void OnPlatformViewUnregisterTexture(int64_t texture_id) override {}
114  void OnPlatformViewMarkTextureFrameAvailable(int64_t texture_id) override {}
115 
116  void LoadDartDeferredLibrary(intptr_t loading_unit_id,
117  std::unique_ptr<const fml::Mapping> snapshot_data,
118  std::unique_ptr<const fml::Mapping> snapshot_instructions) override {
119  }
120  void LoadDartDeferredLibraryError(intptr_t loading_unit_id,
121  const std::string error_message,
122  bool transient) override {}
123  void UpdateAssetResolverByType(std::unique_ptr<flutter::AssetResolver> updated_asset_resolver,
124  flutter::AssetResolver::AssetResolverType type) override {}
125 
126  flutter::Settings settings_;
127 };
128 
129 } // namespace
130 } // namespace flutter
131 
132 @interface FlutterTextInputPluginTest : XCTestCase
133 @end
134 
135 @implementation FlutterTextInputPluginTest {
136  NSDictionary* _template;
137  NSDictionary* _passwordTemplate;
138  id engine;
140 
142 }
143 
144 - (void)setUp {
145  [super setUp];
146  engine = OCMClassMock([FlutterEngine class]);
147 
148  textInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
149 
150  viewController = [[FlutterViewController alloc] init];
152 
153  // Clear pasteboard between tests.
154  UIPasteboard.generalPasteboard.items = @[];
155 }
156 
157 - (void)tearDown {
158  textInputPlugin = nil;
159  engine = nil;
160  [textInputPlugin.autofillContext removeAllObjects];
161  [textInputPlugin cleanUpViewHierarchy:YES clearText:YES delayRemoval:NO];
162  [[[[textInputPlugin textInputView] superview] subviews]
163  makeObjectsPerformSelector:@selector(removeFromSuperview)];
164  viewController = nil;
165  [super tearDown];
166 }
167 
168 - (void)setClientId:(int)clientId configuration:(NSDictionary*)config {
169  FlutterMethodCall* setClientCall =
170  [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
171  arguments:@[ [NSNumber numberWithInt:clientId], config ]];
172  [textInputPlugin handleMethodCall:setClientCall
173  result:^(id _Nullable result){
174  }];
175 }
176 
177 - (void)setClientClear {
178  FlutterMethodCall* clearClientCall =
179  [FlutterMethodCall methodCallWithMethodName:@"TextInput.clearClient" arguments:@[]];
180  [textInputPlugin handleMethodCall:clearClientCall
181  result:^(id _Nullable result){
182  }];
183 }
184 
185 - (void)setTextInputShow {
186  FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.show"
187  arguments:@[]];
188  [textInputPlugin handleMethodCall:setClientCall
189  result:^(id _Nullable result){
190  }];
191 }
192 
193 - (void)setTextInputHide {
194  FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.hide"
195  arguments:@[]];
196  [textInputPlugin handleMethodCall:setClientCall
197  result:^(id _Nullable result){
198  }];
199 }
200 
201 - (void)flushScheduledAsyncBlocks {
202  __block bool done = false;
203  XCTestExpectation* expectation =
204  [[XCTestExpectation alloc] initWithDescription:@"Testing on main queue"];
205  dispatch_async(dispatch_get_main_queue(), ^{
206  done = true;
207  });
208  dispatch_async(dispatch_get_main_queue(), ^{
209  XCTAssertTrue(done);
210  [expectation fulfill];
211  });
212  [self waitForExpectations:@[ expectation ] timeout:10];
213 }
214 
215 - (NSMutableDictionary*)mutableTemplateCopy {
216  if (!_template) {
217  _template = @{
218  @"inputType" : @{@"name" : @"TextInuptType.text"},
219  @"keyboardAppearance" : @"Brightness.light",
220  @"obscureText" : @NO,
221  @"inputAction" : @"TextInputAction.unspecified",
222  @"smartDashesType" : @"0",
223  @"smartQuotesType" : @"0",
224  @"autocorrect" : @YES,
225  @"enableInteractiveSelection" : @YES,
226  };
227  }
228 
229  return [_template mutableCopy];
230 }
231 
232 - (NSArray<FlutterTextInputView*>*)installedInputViews {
233  return (NSArray<FlutterTextInputView*>*)[textInputPlugin.textInputViews
234  filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@",
235  [FlutterTextInputView class]]];
236 }
237 
238 - (FlutterTextRange*)getLineRangeFromTokenizer:(id<UITextInputTokenizer>)tokenizer
239  atIndex:(NSInteger)index {
240  UITextRange* range =
241  [tokenizer rangeEnclosingPosition:[FlutterTextPosition positionWithIndex:index]
242  withGranularity:UITextGranularityLine
243  inDirection:UITextLayoutDirectionRight];
244  XCTAssertTrue([range isKindOfClass:[FlutterTextRange class]]);
245  return (FlutterTextRange*)range;
246 }
247 
248 - (void)updateConfig:(NSDictionary*)config {
249  FlutterMethodCall* updateConfigCall =
250  [FlutterMethodCall methodCallWithMethodName:@"TextInput.updateConfig" arguments:config];
251  [textInputPlugin handleMethodCall:updateConfigCall
252  result:^(id _Nullable result){
253  }];
254 }
255 
256 #pragma mark - Tests
257 
258 - (void)testWillNotCrashWhenViewControllerIsNil {
259  FlutterEngine* flutterEngine = [[FlutterEngine alloc] init];
260  FlutterTextInputPlugin* inputPlugin =
261  [[FlutterTextInputPlugin alloc] initWithDelegate:(id<FlutterTextInputDelegate>)flutterEngine];
262  XCTAssertNil(inputPlugin.viewController);
263  FlutterMethodCall* methodCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.show"
264  arguments:nil];
265  XCTestExpectation* expectation = [[XCTestExpectation alloc] initWithDescription:@"result called"];
266 
267  [inputPlugin handleMethodCall:methodCall
268  result:^(id _Nullable result) {
269  XCTAssertNil(result);
270  [expectation fulfill];
271  }];
272  XCTAssertNil(inputPlugin.activeView);
273  [self waitForExpectations:@[ expectation ] timeout:1.0];
274 }
275 
276 - (void)testInvokeStartLiveTextInput {
277  FlutterMethodCall* methodCall =
278  [FlutterMethodCall methodCallWithMethodName:@"TextInput.startLiveTextInput" arguments:nil];
279  FlutterTextInputPlugin* mockPlugin = OCMPartialMock(textInputPlugin);
280  [mockPlugin handleMethodCall:methodCall
281  result:^(id _Nullable result){
282  }];
283  OCMVerify([mockPlugin startLiveTextInput]);
284 }
285 
286 - (void)testNoDanglingEnginePointer {
287  __weak FlutterTextInputPlugin* weakFlutterTextInputPlugin;
288  FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
289  __weak FlutterEngine* weakFlutterEngine;
290 
291  FlutterTextInputView* currentView;
292 
293  // The engine instance will be deallocated after the autorelease pool is drained.
294  @autoreleasepool {
295  FlutterEngine* flutterEngine = OCMClassMock([FlutterEngine class]);
296  weakFlutterEngine = flutterEngine;
297  XCTAssertNotNil(weakFlutterEngine, @"flutter engine must not be nil");
298  FlutterTextInputPlugin* flutterTextInputPlugin = [[FlutterTextInputPlugin alloc]
299  initWithDelegate:(id<FlutterTextInputDelegate>)flutterEngine];
300  weakFlutterTextInputPlugin = flutterTextInputPlugin;
301  flutterTextInputPlugin.viewController = flutterViewController;
302 
303  // Set client so the text input plugin has an active view.
304  NSDictionary* config = self.mutableTemplateCopy;
305  FlutterMethodCall* setClientCall =
306  [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
307  arguments:@[ [NSNumber numberWithInt:123], config ]];
308  [flutterTextInputPlugin handleMethodCall:setClientCall
309  result:^(id _Nullable result){
310  }];
311  currentView = flutterTextInputPlugin.activeView;
312  }
313 
314  XCTAssertNil(weakFlutterEngine, @"flutter engine must be nil");
315  XCTAssertNotNil(currentView, @"current view must not be nil");
316 
317  XCTAssertNil(weakFlutterTextInputPlugin);
318  // Verify that the view can no longer access the deallocated engine/text input plugin
319  // instance.
320  XCTAssertNil(currentView.textInputDelegate);
321 }
322 
323 - (void)testSecureInput {
324  NSDictionary* config = self.mutableTemplateCopy;
325  [config setValue:@"YES" forKey:@"obscureText"];
326  [self setClientId:123 configuration:config];
327 
328  // Find all the FlutterTextInputViews we created.
329  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
330 
331  // There are no autofill and the mock framework requested a secure entry. The first and only
332  // inserted FlutterTextInputView should be a secure text entry one.
333  FlutterTextInputView* inputView = inputFields[0];
334 
335  // Verify secureTextEntry is set to the correct value.
336  XCTAssertTrue(inputView.secureTextEntry);
337 
338  // Verify keyboardType is set to the default value.
339  XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeDefault);
340 
341  // We should have only ever created one FlutterTextInputView.
342  XCTAssertEqual(inputFields.count, 1ul);
343 
344  // The one FlutterTextInputView we inserted into the view hierarchy should be the text input
345  // plugin's active text input view.
346  XCTAssertEqual(inputView, textInputPlugin.textInputView);
347 
348  // Despite not given an id in configuration, inputView has
349  // an autofill id.
350  XCTAssert(inputView.autofillId.length > 0);
351 }
352 
353 - (void)testKeyboardType {
354  NSDictionary* config = self.mutableTemplateCopy;
355  [config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"];
356  [self setClientId:123 configuration:config];
357 
358  // Find all the FlutterTextInputViews we created.
359  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
360 
361  FlutterTextInputView* inputView = inputFields[0];
362 
363  // Verify keyboardType is set to the value specified in config.
364  XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeURL);
365 }
366 
367 - (void)testKeyboardTypeWebSearch {
368  NSDictionary* config = self.mutableTemplateCopy;
369  [config setValue:@{@"name" : @"TextInputType.webSearch"} forKey:@"inputType"];
370  [self setClientId:123 configuration:config];
371 
372  // Find all the FlutterTextInputViews we created.
373  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
374 
375  FlutterTextInputView* inputView = inputFields[0];
376 
377  // Verify keyboardType is set to the value specified in config.
378  XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeWebSearch);
379 }
380 
381 - (void)testKeyboardTypeTwitter {
382  NSDictionary* config = self.mutableTemplateCopy;
383  [config setValue:@{@"name" : @"TextInputType.twitter"} forKey:@"inputType"];
384  [self setClientId:123 configuration:config];
385 
386  // Find all the FlutterTextInputViews we created.
387  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
388 
389  FlutterTextInputView* inputView = inputFields[0];
390 
391  // Verify keyboardType is set to the value specified in config.
392  XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeTwitter);
393 }
394 
395 - (void)testVisiblePasswordUseAlphanumeric {
396  NSDictionary* config = self.mutableTemplateCopy;
397  [config setValue:@{@"name" : @"TextInputType.visiblePassword"} forKey:@"inputType"];
398  [self setClientId:123 configuration:config];
399 
400  // Find all the FlutterTextInputViews we created.
401  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
402 
403  FlutterTextInputView* inputView = inputFields[0];
404 
405  // Verify keyboardType is set to the value specified in config.
406  XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeASCIICapable);
407 }
408 
409 - (void)testSettingKeyboardTypeNoneDisablesSystemKeyboard {
410  NSDictionary* config = self.mutableTemplateCopy;
411  [config setValue:@{@"name" : @"TextInputType.none"} forKey:@"inputType"];
412  [self setClientId:123 configuration:config];
413 
414  // Verify the view's inputViewController is not nil;
415  XCTAssertNotNil(textInputPlugin.activeView.inputViewController);
416 
417  [config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"];
418  [self setClientId:124 configuration:config];
419  XCTAssertNotNil(textInputPlugin.activeView);
420  XCTAssertNil(textInputPlugin.activeView.inputViewController);
421 }
422 
423 - (void)testAutocorrectionPromptRectAppearsBeforeIOS17AndDoesNotAppearAfterIOS17 {
424  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
425  [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
426 
427  if (@available(iOS 17.0, *)) {
428  // Auto-correction prompt is disabled in iOS 17+.
429  OCMVerify(never(), [engine flutterTextInputView:inputView
430  showAutocorrectionPromptRectForStart:0
431  end:1
432  withClient:0]);
433  } else {
434  OCMVerify([engine flutterTextInputView:inputView
435  showAutocorrectionPromptRectForStart:0
436  end:1
437  withClient:0]);
438  }
439 }
440 
441 - (void)testIgnoresSelectionChangeIfSelectionIsDisabled {
442  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
443  __block int updateCount = 0;
444  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]])
445  .andDo(^(NSInvocation* invocation) {
446  updateCount++;
447  });
448 
449  [inputView.text setString:@"Some initial text"];
450  XCTAssertEqual(updateCount, 0);
451 
452  FlutterTextRange* textRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
453  [inputView setSelectedTextRange:textRange];
454  XCTAssertEqual(updateCount, 1);
455 
456  // Disable the interactive selection.
457  NSDictionary* config = self.mutableTemplateCopy;
458  [config setValue:@(NO) forKey:@"enableInteractiveSelection"];
459  [config setValue:@(NO) forKey:@"obscureText"];
460  [config setValue:@(NO) forKey:@"enableDeltaModel"];
461  [inputView configureWithDictionary:config];
462 
463  textRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(2, 3)];
464  [inputView setSelectedTextRange:textRange];
465  // The update count does not change.
466  XCTAssertEqual(updateCount, 1);
467 }
468 
469 - (void)testAutocorrectionPromptRectDoesNotAppearDuringScribble {
470  // Auto-correction prompt is disabled in iOS 17+.
471  if (@available(iOS 17.0, *)) {
472  return;
473  }
474 
475  if (@available(iOS 14.0, *)) {
476  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
477 
478  __block int callCount = 0;
479  OCMStub([engine flutterTextInputView:inputView
480  showAutocorrectionPromptRectForStart:0
481  end:1
482  withClient:0])
483  .andDo(^(NSInvocation* invocation) {
484  callCount++;
485  });
486 
487  [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
488  // showAutocorrectionPromptRectForStart fires in response to firstRectForRange
489  XCTAssertEqual(callCount, 1);
490 
491  UIScribbleInteraction* scribbleInteraction =
492  [[UIScribbleInteraction alloc] initWithDelegate:inputView];
493 
494  [inputView scribbleInteractionWillBeginWriting:scribbleInteraction];
495  [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
496  // showAutocorrectionPromptRectForStart does not fire in response to setMarkedText during a
497  // scribble interaction.firstRectForRange
498  XCTAssertEqual(callCount, 1);
499 
500  [inputView scribbleInteractionDidFinishWriting:scribbleInteraction];
501  [inputView resetScribbleInteractionStatusIfEnding];
502  [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
503  // showAutocorrectionPromptRectForStart fires in response to firstRectForRange.
504  XCTAssertEqual(callCount, 2);
505 
506  inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing;
507  [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
508  // showAutocorrectionPromptRectForStart does not fire in response to firstRectForRange during a
509  // scribble-initiated focus.
510  XCTAssertEqual(callCount, 2);
511 
512  inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocused;
513  [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
514  // showAutocorrectionPromptRectForStart does not fire in response to firstRectForRange after a
515  // scribble-initiated focus.
516  XCTAssertEqual(callCount, 2);
517 
518  inputView.scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused;
519  [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]];
520  // showAutocorrectionPromptRectForStart fires in response to firstRectForRange.
521  XCTAssertEqual(callCount, 3);
522  }
523 }
524 
525 - (void)testInputHiderOverlapWithTextWhenScribbleIsDisabledAfterIOS17AndDoesNotOverlapBeforeIOS17 {
526  FlutterTextInputPlugin* myInputPlugin =
527  [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
528 
529  FlutterMethodCall* setClientCall =
530  [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
531  arguments:@[ @(123), self.mutableTemplateCopy ]];
532  [myInputPlugin handleMethodCall:setClientCall
533  result:^(id _Nullable result){
534  }];
535 
536  FlutterTextInputView* mockInputView = OCMPartialMock(myInputPlugin.activeView);
537  OCMStub([mockInputView isScribbleAvailable]).andReturn(NO);
538 
539  // yOffset = 200.
540  NSArray* yOffsetMatrix = @[ @1, @0, @0, @0, @0, @1, @0, @0, @0, @0, @1, @0, @0, @200, @0, @1 ];
541 
542  FlutterMethodCall* setPlatformViewClientCall =
543  [FlutterMethodCall methodCallWithMethodName:@"TextInput.setEditableSizeAndTransform"
544  arguments:@{@"transform" : yOffsetMatrix}];
545  [myInputPlugin handleMethodCall:setPlatformViewClientCall
546  result:^(id _Nullable result){
547  }];
548 
549  if (@available(iOS 17, *)) {
550  XCTAssert(CGRectEqualToRect(myInputPlugin.inputHider.frame, CGRectMake(0, 200, 0, 0)),
551  @"The input hider should overlap with the text on and after iOS 17");
552 
553  } else {
554  XCTAssert(CGRectEqualToRect(myInputPlugin.inputHider.frame, CGRectZero),
555  @"The input hider should be on the origin of screen on and before iOS 16.");
556  }
557 }
558 
559 - (void)testTextRangeFromPositionMatchesUITextViewBehavior {
560  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
563 
564  FlutterTextRange* flutterRange = (FlutterTextRange*)[inputView textRangeFromPosition:fromPosition
565  toPosition:toPosition];
566  NSRange range = flutterRange.range;
567 
568  XCTAssertEqual(range.location, 0ul);
569  XCTAssertEqual(range.length, 2ul);
570 }
571 
572 - (void)testTextInRange {
573  NSDictionary* config = self.mutableTemplateCopy;
574  [config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"];
575  [self setClientId:123 configuration:config];
576  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
577  FlutterTextInputView* inputView = inputFields[0];
578 
579  [inputView insertText:@"test"];
580 
581  UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 20)];
582  NSString* substring = [inputView textInRange:range];
583  XCTAssertEqual(substring.length, 4ul);
584 
585  range = [FlutterTextRange rangeWithNSRange:NSMakeRange(10, 20)];
586  substring = [inputView textInRange:range];
587  XCTAssertEqual(substring.length, 0ul);
588 }
589 
590 - (void)testTextInRangeAcceptsNSNotFoundLocationGracefully {
591  NSDictionary* config = self.mutableTemplateCopy;
592  [self setClientId:123 configuration:config];
593  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
594  FlutterTextInputView* inputView = inputFields[0];
595 
596  [inputView insertText:@"text"];
597  UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(NSNotFound, 0)];
598 
599  NSString* substring = [inputView textInRange:range];
600  XCTAssertNil(substring);
601 }
602 
603 - (void)testStandardEditActions {
604  NSDictionary* config = self.mutableTemplateCopy;
605  [self setClientId:123 configuration:config];
606  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
607  FlutterTextInputView* inputView = inputFields[0];
608 
609  [inputView insertText:@"aaaa"];
610  [inputView selectAll:nil];
611  [inputView cut:nil];
612  [inputView insertText:@"bbbb"];
613  XCTAssertTrue([inputView canPerformAction:@selector(paste:) withSender:nil]);
614  [inputView paste:nil];
615  [inputView selectAll:nil];
616  [inputView copy:nil];
617  [inputView paste:nil];
618  [inputView selectAll:nil];
619  [inputView delete:nil];
620  [inputView paste:nil];
621  [inputView paste:nil];
622 
623  UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 30)];
624  NSString* substring = [inputView textInRange:range];
625  XCTAssertEqualObjects(substring, @"bbbbaaaabbbbaaaa");
626 }
627 
628 - (void)testCanPerformActionForSelectActions {
629  NSDictionary* config = self.mutableTemplateCopy;
630  [self setClientId:123 configuration:config];
631  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
632  FlutterTextInputView* inputView = inputFields[0];
633 
634  XCTAssertFalse([inputView canPerformAction:@selector(selectAll:) withSender:nil]);
635 
636  [inputView insertText:@"aaaa"];
637 
638  XCTAssertTrue([inputView canPerformAction:@selector(selectAll:) withSender:nil]);
639 }
640 
641 - (void)testCanPerformActionCaptureTextFromCamera {
642  if (@available(iOS 15.0, *)) {
643  NSDictionary* config = self.mutableTemplateCopy;
644  [self setClientId:123 configuration:config];
645  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
646  FlutterTextInputView* inputView = inputFields[0];
647 
648  [inputView becomeFirstResponder];
649  XCTAssertTrue([inputView canPerformAction:@selector(captureTextFromCamera:) withSender:nil]);
650 
651  [inputView insertText:@"test"];
652  [inputView selectAll:nil];
653  XCTAssertTrue([inputView canPerformAction:@selector(captureTextFromCamera:) withSender:nil]);
654  }
655 }
656 
657 - (void)testDeletingBackward {
658  NSDictionary* config = self.mutableTemplateCopy;
659  [self setClientId:123 configuration:config];
660  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
661  FlutterTextInputView* inputView = inputFields[0];
662 
663  [inputView insertText:@"������� text ������������������������������������������� "];
664  [inputView deleteBackward];
665  [inputView deleteBackward];
666 
667  // Thai vowel is removed.
668  XCTAssertEqualObjects(inputView.text, @"ឹ😀 text 🥰👨‍👩‍👧‍👦🇺🇳ด");
669  [inputView deleteBackward];
670  XCTAssertEqualObjects(inputView.text, @"ឹ😀 text 🥰👨‍👩‍👧‍👦🇺🇳");
671  [inputView deleteBackward];
672  XCTAssertEqualObjects(inputView.text, @"ឹ😀 text 🥰👨‍👩‍👧‍👦");
673  [inputView deleteBackward];
674  XCTAssertEqualObjects(inputView.text, @"ឹ😀 text 🥰");
675  [inputView deleteBackward];
676 
677  XCTAssertEqualObjects(inputView.text, @"ឹ😀 text ");
678  [inputView deleteBackward];
679  [inputView deleteBackward];
680  [inputView deleteBackward];
681  [inputView deleteBackward];
682  [inputView deleteBackward];
683  [inputView deleteBackward];
684 
685  XCTAssertEqualObjects(inputView.text, @"ឹ😀");
686  [inputView deleteBackward];
687  XCTAssertEqualObjects(inputView.text, @"ឹ");
688  [inputView deleteBackward];
689  XCTAssertEqualObjects(inputView.text, @"");
690 }
691 
692 // This tests the workaround to fix an iOS 16 bug
693 // See: https://github.com/flutter/flutter/issues/111494
694 - (void)testSystemOnlyAddingPartialComposedCharacter {
695  NSDictionary* config = self.mutableTemplateCopy;
696  [self setClientId:123 configuration:config];
697  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
698  FlutterTextInputView* inputView = inputFields[0];
699 
700  [inputView insertText:@"�������������������������"];
701  [inputView deleteBackward];
702 
703  // Insert the first unichar in the emoji.
704  [inputView insertText:[@"�������������������������" substringWithRange:NSMakeRange(0, 1)]];
705  [inputView insertText:@"���"];
706 
707  XCTAssertEqualObjects(inputView.text, @"👨‍👩‍👧‍👦아");
708 
709  // Deleting 아.
710  [inputView deleteBackward];
711  // 👨‍👩‍👧‍👦 should be the current string.
712 
713  [inputView insertText:@"����"];
714  [inputView deleteBackward];
715  // Insert the first unichar in the emoji.
716  [inputView insertText:[@"����" substringWithRange:NSMakeRange(0, 1)]];
717  [inputView insertText:@"���"];
718  XCTAssertEqualObjects(inputView.text, @"👨‍👩‍👧‍👦😀아");
719 
720  // Deleting 아.
721  [inputView deleteBackward];
722  // 👨‍👩‍👧‍👦😀 should be the current string.
723 
724  [inputView deleteBackward];
725  // Insert the first unichar in the emoji.
726  [inputView insertText:[@"����" substringWithRange:NSMakeRange(0, 1)]];
727  [inputView insertText:@"���"];
728 
729  XCTAssertEqualObjects(inputView.text, @"👨‍👩‍👧‍👦😀아");
730 }
731 
732 - (void)testCachedComposedCharacterClearedAtKeyboardInteraction {
733  NSDictionary* config = self.mutableTemplateCopy;
734  [self setClientId:123 configuration:config];
735  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
736  FlutterTextInputView* inputView = inputFields[0];
737 
738  [inputView insertText:@"�������������������������"];
739  [inputView deleteBackward];
740  [inputView shouldChangeTextInRange:OCMClassMock([UITextRange class]) replacementText:@""];
741 
742  // Insert the first unichar in the emoji.
743  NSString* brokenEmoji = [@"�������������������������" substringWithRange:NSMakeRange(0, 1)];
744  [inputView insertText:brokenEmoji];
745  [inputView insertText:@"���"];
746 
747  NSString* finalText = [NSString stringWithFormat:@"%@���", brokenEmoji];
748  XCTAssertEqualObjects(inputView.text, finalText);
749 }
750 
751 - (void)testPastingNonTextDisallowed {
752  NSDictionary* config = self.mutableTemplateCopy;
753  [self setClientId:123 configuration:config];
754  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
755  FlutterTextInputView* inputView = inputFields[0];
756 
757  UIPasteboard.generalPasteboard.color = UIColor.redColor;
758  XCTAssertNil(UIPasteboard.generalPasteboard.string);
759  XCTAssertFalse([inputView canPerformAction:@selector(paste:) withSender:nil]);
760  [inputView paste:nil];
761 
762  XCTAssertEqualObjects(inputView.text, @"");
763 }
764 
765 - (void)testNoZombies {
766  // Regression test for https://github.com/flutter/flutter/issues/62501.
767  FlutterSecureTextInputView* passwordView =
768  [[FlutterSecureTextInputView alloc] initWithOwner:textInputPlugin];
769 
770  @autoreleasepool {
771  // Initialize the lazy textField.
772  [passwordView.textField description];
773  }
774  XCTAssert([[passwordView.textField description] containsString:@"TextField"]);
775 }
776 
777 - (void)testInputViewCrash {
778  FlutterTextInputView* activeView = nil;
779  @autoreleasepool {
780  FlutterEngine* flutterEngine = [[FlutterEngine alloc] init];
781  FlutterTextInputPlugin* inputPlugin = [[FlutterTextInputPlugin alloc]
782  initWithDelegate:(id<FlutterTextInputDelegate>)flutterEngine];
783  activeView = inputPlugin.activeView;
784  }
785  [activeView updateEditingState];
786 }
787 
788 - (void)testDoNotReuseInputViews {
789  NSDictionary* config = self.mutableTemplateCopy;
790  [self setClientId:123 configuration:config];
791  FlutterTextInputView* currentView = textInputPlugin.activeView;
792  [self setClientId:456 configuration:config];
793 
794  XCTAssertNotNil(currentView);
795  XCTAssertNotNil(textInputPlugin.activeView);
796  XCTAssertNotEqual(currentView, textInputPlugin.activeView);
797 }
798 
799 - (void)ensureOnlyActiveViewCanBecomeFirstResponder {
800  for (FlutterTextInputView* inputView in self.installedInputViews) {
801  XCTAssertEqual(inputView.canBecomeFirstResponder, inputView == textInputPlugin.activeView);
802  }
803 }
804 
805 - (void)testPropagatePressEventsToViewController {
806  FlutterViewController* mockViewController = OCMPartialMock(viewController);
807  OCMStub([mockViewController pressesBegan:[OCMArg isNotNil] withEvent:[OCMArg isNotNil]]);
808  OCMStub([mockViewController pressesEnded:[OCMArg isNotNil] withEvent:[OCMArg isNotNil]]);
809 
810  textInputPlugin.viewController = mockViewController;
811 
812  NSDictionary* config = self.mutableTemplateCopy;
813  [self setClientId:123 configuration:config];
814  FlutterTextInputView* currentView = textInputPlugin.activeView;
815  [self setTextInputShow];
816 
817  [currentView pressesBegan:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil]
818  withEvent:OCMClassMock([UIPressesEvent class])];
819 
820  OCMVerify(times(1), [mockViewController pressesBegan:[OCMArg isNotNil]
821  withEvent:[OCMArg isNotNil]]);
822  OCMVerify(times(0), [mockViewController pressesEnded:[OCMArg isNotNil]
823  withEvent:[OCMArg isNotNil]]);
824 
825  [currentView pressesEnded:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil]
826  withEvent:OCMClassMock([UIPressesEvent class])];
827 
828  OCMVerify(times(1), [mockViewController pressesBegan:[OCMArg isNotNil]
829  withEvent:[OCMArg isNotNil]]);
830  OCMVerify(times(1), [mockViewController pressesEnded:[OCMArg isNotNil]
831  withEvent:[OCMArg isNotNil]]);
832 }
833 
834 - (void)testPropagatePressEventsToViewController2 {
835  FlutterViewController* mockViewController = OCMPartialMock(viewController);
836  OCMStub([mockViewController pressesBegan:[OCMArg isNotNil] withEvent:[OCMArg isNotNil]]);
837  OCMStub([mockViewController pressesEnded:[OCMArg isNotNil] withEvent:[OCMArg isNotNil]]);
838 
839  textInputPlugin.viewController = mockViewController;
840 
841  NSDictionary* config = self.mutableTemplateCopy;
842  [self setClientId:123 configuration:config];
843  [self setTextInputShow];
844  FlutterTextInputView* currentView = textInputPlugin.activeView;
845 
846  [currentView pressesBegan:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil]
847  withEvent:OCMClassMock([UIPressesEvent class])];
848 
849  OCMVerify(times(1), [mockViewController pressesBegan:[OCMArg isNotNil]
850  withEvent:[OCMArg isNotNil]]);
851  OCMVerify(times(0), [mockViewController pressesEnded:[OCMArg isNotNil]
852  withEvent:[OCMArg isNotNil]]);
853 
854  // Switch focus to a different view.
855  [self setClientId:321 configuration:config];
856  [self setTextInputShow];
857  NSAssert(textInputPlugin.activeView, @"active view must not be nil");
858  NSAssert(textInputPlugin.activeView != currentView, @"active view must change");
859  currentView = textInputPlugin.activeView;
860  [currentView pressesEnded:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil]
861  withEvent:OCMClassMock([UIPressesEvent class])];
862 
863  OCMVerify(times(1), [mockViewController pressesBegan:[OCMArg isNotNil]
864  withEvent:[OCMArg isNotNil]]);
865  OCMVerify(times(1), [mockViewController pressesEnded:[OCMArg isNotNil]
866  withEvent:[OCMArg isNotNil]]);
867 }
868 
869 - (void)testHotRestart {
870  flutter::MockPlatformViewDelegate mock_platform_view_delegate;
871  auto thread = std::make_unique<fml::Thread>("TextInputHotRestart");
872  auto thread_task_runner = thread->GetTaskRunner();
873  flutter::TaskRunners runners(/*label=*/self.name.UTF8String,
874  /*platform=*/thread_task_runner,
875  /*raster=*/thread_task_runner,
876  /*ui=*/thread_task_runner,
877  /*io=*/thread_task_runner);
878  id mockFlutterView = OCMClassMock([FlutterView class]);
879  id mockFlutterTextInputPlugin = OCMClassMock([FlutterTextInputPlugin class]);
880  id mockFlutterViewController = OCMClassMock([FlutterViewController class]);
881  OCMStub([mockFlutterViewController viewIfLoaded]).andReturn(mockFlutterView);
882  OCMStub([mockFlutterViewController textInputPlugin]).andReturn(mockFlutterTextInputPlugin);
883 
884  fml::AutoResetWaitableEvent latch;
885  thread_task_runner->PostTask([&] {
886  auto platform_view = std::make_unique<flutter::PlatformViewIOS>(
887  /*delegate=*/mock_platform_view_delegate,
888  /*rendering_api=*/mock_platform_view_delegate.settings_.enable_impeller
891  /*platform_views_controller=*/nil,
892  /*task_runners=*/runners,
893  /*worker_task_runner=*/nil,
894  /*is_gpu_disabled_sync_switch=*/std::make_shared<fml::SyncSwitch>());
895 
896  platform_view->SetOwnerViewController(mockFlutterViewController);
897 
898  OCMExpect([mockFlutterTextInputPlugin reset]);
899  platform_view->OnPreEngineRestart();
900  OCMVerifyAll(mockFlutterView);
901 
902  latch.Signal();
903  });
904  latch.Wait();
905 }
906 
907 - (void)testUpdateSecureTextEntry {
908  NSDictionary* config = self.mutableTemplateCopy;
909  [config setValue:@"YES" forKey:@"obscureText"];
910  [self setClientId:123 configuration:config];
911 
912  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
913  FlutterTextInputView* inputView = OCMPartialMock(inputFields[0]);
914 
915  __block int callCount = 0;
916  OCMStub([inputView reloadInputViews]).andDo(^(NSInvocation* invocation) {
917  callCount++;
918  });
919 
920  XCTAssertTrue(inputView.isSecureTextEntry);
921 
922  config = self.mutableTemplateCopy;
923  [config setValue:@"NO" forKey:@"obscureText"];
924  [self updateConfig:config];
925 
926  XCTAssertEqual(callCount, 1);
927  XCTAssertFalse(inputView.isSecureTextEntry);
928 }
929 
930 - (void)testInputActionContinueAction {
931  id mockBinaryMessenger = OCMClassMock([FlutterBinaryMessengerRelay class]);
932  FlutterEngine* testEngine = [[FlutterEngine alloc] init];
933  [testEngine setBinaryMessenger:mockBinaryMessenger];
934  [testEngine runWithEntrypoint:FlutterDefaultDartEntrypoint initialRoute:@"test"];
935 
936  FlutterTextInputPlugin* inputPlugin =
937  [[FlutterTextInputPlugin alloc] initWithDelegate:(id<FlutterTextInputDelegate>)testEngine];
938  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:inputPlugin];
939 
940  [testEngine flutterTextInputView:inputView
941  performAction:FlutterTextInputActionContinue
942  withClient:123];
943 
944  FlutterMethodCall* methodCall =
945  [FlutterMethodCall methodCallWithMethodName:@"TextInputClient.performAction"
946  arguments:@[ @(123), @"TextInputAction.continueAction" ]];
947  NSData* encodedMethodCall = [[FlutterJSONMethodCodec sharedInstance] encodeMethodCall:methodCall];
948  OCMVerify([mockBinaryMessenger sendOnChannel:@"flutter/textinput" message:encodedMethodCall]);
949 }
950 
951 - (void)testDisablingAutocorrectDisablesSpellChecking {
952  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
953 
954  // Disable the interactive selection.
955  NSDictionary* config = self.mutableTemplateCopy;
956  [inputView configureWithDictionary:config];
957 
958  XCTAssertEqual(inputView.autocorrectionType, UITextAutocorrectionTypeDefault);
959  XCTAssertEqual(inputView.spellCheckingType, UITextSpellCheckingTypeDefault);
960 
961  [config setValue:@(NO) forKey:@"autocorrect"];
962  [inputView configureWithDictionary:config];
963 
964  XCTAssertEqual(inputView.autocorrectionType, UITextAutocorrectionTypeNo);
965  XCTAssertEqual(inputView.spellCheckingType, UITextSpellCheckingTypeNo);
966 }
967 
968 - (void)testEnableInlinePredictionFromConfiguration API_AVAILABLE(ios(17.0)) {
969  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
970  NSMutableDictionary* config = self.mutableTemplateCopy;
971 
972  // Template does not include enableInlinePrediction -> disabled.
973  [inputView configureWithDictionary:config];
974  XCTAssertEqual(inputView.inlinePredictionType, UITextInlinePredictionTypeNo);
975 
976  [config setValue:@NO forKey:@"enableInlinePrediction"];
977  [inputView configureWithDictionary:config];
978  XCTAssertEqual(inputView.inlinePredictionType, UITextInlinePredictionTypeNo);
979 
980  [config setValue:@YES forKey:@"enableInlinePrediction"];
981  [inputView configureWithDictionary:config];
982  XCTAssertEqual(inputView.inlinePredictionType, UITextInlinePredictionTypeYes);
983 
984  // Explicit nil / missing key -> disabled.
985  [config removeObjectForKey:@"enableInlinePrediction"];
986  [inputView configureWithDictionary:config];
987  XCTAssertEqual(inputView.inlinePredictionType, UITextInlinePredictionTypeNo);
988 
989  // Key present with NSNull (e.g. framework sent null) -> disabled.
990  [config setValue:[NSNull null] forKey:@"enableInlinePrediction"];
991  [inputView configureWithDictionary:config];
992  XCTAssertEqual(inputView.inlinePredictionType, UITextInlinePredictionTypeNo);
993 }
994 
995 - (void)testReplaceTestLocalAdjustSelectionAndMarkedTextRange {
996  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
997  [inputView setMarkedText:@"test text" selectedRange:NSMakeRange(0, 5)];
998  NSRange selectedTextRange = ((FlutterTextRange*)inputView.selectedTextRange).range;
999  const NSRange markedTextRange = ((FlutterTextRange*)inputView.markedTextRange).range;
1000  XCTAssertEqual(selectedTextRange.location, 0ul);
1001  XCTAssertEqual(selectedTextRange.length, 5ul);
1002  XCTAssertEqual(markedTextRange.location, 0ul);
1003  XCTAssertEqual(markedTextRange.length, 9ul);
1004 
1005  // Replaces space with space.
1006  [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(4, 1)] withText:@" "];
1007  selectedTextRange = ((FlutterTextRange*)inputView.selectedTextRange).range;
1008 
1009  XCTAssertEqual(selectedTextRange.location, 5ul);
1010  XCTAssertEqual(selectedTextRange.length, 0ul);
1011  XCTAssertEqual(inputView.markedTextRange, nil);
1012 }
1013 
1014 - (void)testFlutterTextInputViewIsNotClearWhenKeyboardShowAndHide {
1015  // Regression test for https://github.com/flutter/flutter/issues/172250.
1016  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1017  [inputView setMarkedText:@"test text" selectedRange:NSMakeRange(0, 5)];
1018  XCTAssertEqualObjects(inputView.text, @"test text");
1019 
1020  // Showing keyboard does not trigger clearing of marked text.
1021  [self setTextInputShow];
1022  XCTAssertEqualObjects(inputView.text, @"test text");
1023 
1024  // Hiding keyboard does not trigger clearing of marked text.
1025  [self setTextInputHide];
1026  XCTAssertEqualObjects(inputView.text, @"test text");
1027 }
1028 
1029 - (void)testFlutterTextInputViewOnlyRespondsToInsertionPointColorBelowIOS17 {
1030  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1031  // [UITextInputTraits insertionPointColor] is non-public API, so @selector(insertionPointColor)
1032  // would generate a compile-time warning.
1033  SEL insertionPointColor = NSSelectorFromString(@"insertionPointColor");
1034  BOOL respondsToInsertionPointColor = [inputView respondsToSelector:insertionPointColor];
1035  if (@available(iOS 17, *)) {
1036  XCTAssertFalse(respondsToInsertionPointColor);
1037  } else {
1038  XCTAssertTrue(respondsToInsertionPointColor);
1039  }
1040 }
1041 
1042 - (void)testSetAttributedMarkedTextSelectedRange API_AVAILABLE(ios(17.0)) {
1043  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1044  NSAttributedString* attributedText =
1045  [[NSAttributedString alloc] initWithString:@"inline prediction"
1046  attributes:@{
1047  NSForegroundColorAttributeName : [UIColor grayColor],
1048  }];
1049  [inputView setAttributedMarkedText:attributedText selectedRange:NSMakeRange(0, 7)];
1050 
1051  XCTAssertEqualObjects(inputView.text, @"inline prediction");
1052  NSRange selectedRange = ((FlutterTextRange*)inputView.selectedTextRange).range;
1053  XCTAssertEqual(selectedRange.location, 0ul);
1054  XCTAssertEqual(selectedRange.length, 7ul);
1055  FlutterTextRange* markedRange = (FlutterTextRange*)inputView.markedTextRange;
1056  XCTAssertNotNil(markedRange);
1057  XCTAssertEqual(markedRange.range.location, 0ul);
1058  // Marked range length must match the attributed string length (17 for "inline prediction").
1059  XCTAssertEqual(markedRange.range.length, 17ul);
1060 
1061  // Nil attributed string should behave like empty string.
1062  [inputView setAttributedMarkedText:nil selectedRange:NSMakeRange(0, 0)];
1063  XCTAssertEqualObjects(inputView.text, @"");
1064  XCTAssertNil(inputView.markedTextRange);
1065 }
1066 
1067 #pragma mark - TextEditingDelta tests
1068 - (void)testTextEditingDeltasAreGeneratedOnTextInput {
1069  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1070  inputView.enableDeltaModel = YES;
1071 
1072  __block int updateCount = 0;
1073 
1074  [inputView insertText:@"text to insert"];
1075  OCMExpect(
1076  [engine
1077  flutterTextInputView:inputView
1078  updateEditingClient:0
1079  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1080  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1081  isEqualToString:@""]) &&
1082  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1083  isEqualToString:@"text to insert"]) &&
1084  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 0) &&
1085  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 0);
1086  }]])
1087  .andDo(^(NSInvocation* invocation) {
1088  updateCount++;
1089  });
1090  XCTAssertEqual(updateCount, 0);
1091 
1092  [self flushScheduledAsyncBlocks];
1093 
1094  // Update the framework exactly once.
1095  XCTAssertEqual(updateCount, 1);
1096 
1097  [inputView deleteBackward];
1098  OCMExpect([engine flutterTextInputView:inputView
1099  updateEditingClient:0
1100  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1101  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1102  isEqualToString:@"text to insert"]) &&
1103  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1104  isEqualToString:@""]) &&
1105  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"]
1106  intValue] == 13) &&
1107  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"]
1108  intValue] == 14);
1109  }]])
1110  .andDo(^(NSInvocation* invocation) {
1111  updateCount++;
1112  });
1113  [self flushScheduledAsyncBlocks];
1114  XCTAssertEqual(updateCount, 2);
1115 
1116  inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
1117  OCMExpect([engine flutterTextInputView:inputView
1118  updateEditingClient:0
1119  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1120  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1121  isEqualToString:@"text to inser"]) &&
1122  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1123  isEqualToString:@""]) &&
1124  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"]
1125  intValue] == -1) &&
1126  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"]
1127  intValue] == -1);
1128  }]])
1129  .andDo(^(NSInvocation* invocation) {
1130  updateCount++;
1131  });
1132  [self flushScheduledAsyncBlocks];
1133  XCTAssertEqual(updateCount, 3);
1134 
1135  [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]
1136  withText:@"replace text"];
1137  OCMExpect(
1138  [engine
1139  flutterTextInputView:inputView
1140  updateEditingClient:0
1141  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1142  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1143  isEqualToString:@"text to inser"]) &&
1144  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1145  isEqualToString:@"replace text"]) &&
1146  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 0) &&
1147  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 1);
1148  }]])
1149  .andDo(^(NSInvocation* invocation) {
1150  updateCount++;
1151  });
1152  [self flushScheduledAsyncBlocks];
1153  XCTAssertEqual(updateCount, 4);
1154 
1155  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1156  OCMExpect([engine flutterTextInputView:inputView
1157  updateEditingClient:0
1158  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1159  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1160  isEqualToString:@"replace textext to inser"]) &&
1161  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1162  isEqualToString:@"marked text"]) &&
1163  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"]
1164  intValue] == 12) &&
1165  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"]
1166  intValue] == 12);
1167  }]])
1168  .andDo(^(NSInvocation* invocation) {
1169  updateCount++;
1170  });
1171  [self flushScheduledAsyncBlocks];
1172  XCTAssertEqual(updateCount, 5);
1173 
1174  [inputView unmarkText];
1175  OCMExpect([engine
1176  flutterTextInputView:inputView
1177  updateEditingClient:0
1178  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1179  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1180  isEqualToString:@"replace textmarked textext to inser"]) &&
1181  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1182  isEqualToString:@""]) &&
1183  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] ==
1184  -1) &&
1185  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] ==
1186  -1);
1187  }]])
1188  .andDo(^(NSInvocation* invocation) {
1189  updateCount++;
1190  });
1191  [self flushScheduledAsyncBlocks];
1192 
1193  XCTAssertEqual(updateCount, 6);
1194  OCMVerifyAll(engine);
1195 }
1196 
1197 - (void)testTextEditingDeltasAreBatchedAndForwardedToFramework {
1198  // Setup
1199  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1200  inputView.enableDeltaModel = YES;
1201 
1202  // Expected call.
1203  OCMExpect([engine flutterTextInputView:inputView
1204  updateEditingClient:0
1205  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1206  NSArray* deltas = state[@"deltas"];
1207  NSDictionary* firstDelta = deltas[0];
1208  NSDictionary* secondDelta = deltas[1];
1209  NSDictionary* thirdDelta = deltas[2];
1210  return [firstDelta[@"oldText"] isEqualToString:@""] &&
1211  [firstDelta[@"deltaText"] isEqualToString:@"-"] &&
1212  [firstDelta[@"deltaStart"] intValue] == 0 &&
1213  [firstDelta[@"deltaEnd"] intValue] == 0 &&
1214  [secondDelta[@"oldText"] isEqualToString:@"-"] &&
1215  [secondDelta[@"deltaText"] isEqualToString:@""] &&
1216  [secondDelta[@"deltaStart"] intValue] == 0 &&
1217  [secondDelta[@"deltaEnd"] intValue] == 1 &&
1218  [thirdDelta[@"oldText"] isEqualToString:@""] &&
1219  [thirdDelta[@"deltaText"] isEqualToString:@"���"] &&
1220  [thirdDelta[@"deltaStart"] intValue] == 0 &&
1221  [thirdDelta[@"deltaEnd"] intValue] == 0;
1222  }]]);
1223 
1224  // Simulate user input.
1225  [inputView insertText:@"-"];
1226  [inputView deleteBackward];
1227  [inputView insertText:@"���"];
1228 
1229  [self flushScheduledAsyncBlocks];
1230  OCMVerifyAll(engine);
1231 }
1232 
1233 - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextReplacement {
1234  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1235  inputView.enableDeltaModel = YES;
1236 
1237  __block int updateCount = 0;
1238  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
1239  .andDo(^(NSInvocation* invocation) {
1240  updateCount++;
1241  });
1242 
1243  [inputView.text setString:@"Some initial text"];
1244  XCTAssertEqual(updateCount, 0);
1245 
1246  UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)];
1247  inputView.markedTextRange = range;
1248  inputView.selectedTextRange = nil;
1249  [self flushScheduledAsyncBlocks];
1250  XCTAssertEqual(updateCount, 1);
1251 
1252  [inputView setMarkedText:@"new marked text." selectedRange:NSMakeRange(0, 1)];
1253  OCMVerify([engine
1254  flutterTextInputView:inputView
1255  updateEditingClient:0
1256  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1257  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1258  isEqualToString:@"Some initial text"]) &&
1259  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1260  isEqualToString:@"new marked text."]) &&
1261  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) &&
1262  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17);
1263  }]]);
1264  [self flushScheduledAsyncBlocks];
1265  XCTAssertEqual(updateCount, 2);
1266 }
1267 
1268 - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextInsertion {
1269  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1270  inputView.enableDeltaModel = YES;
1271 
1272  __block int updateCount = 0;
1273  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
1274  .andDo(^(NSInvocation* invocation) {
1275  updateCount++;
1276  });
1277 
1278  [inputView.text setString:@"Some initial text"];
1279  [self flushScheduledAsyncBlocks];
1280  XCTAssertEqual(updateCount, 0);
1281 
1282  UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)];
1283  inputView.markedTextRange = range;
1284  inputView.selectedTextRange = nil;
1285  [self flushScheduledAsyncBlocks];
1286  XCTAssertEqual(updateCount, 1);
1287 
1288  [inputView setMarkedText:@"text." selectedRange:NSMakeRange(0, 1)];
1289  OCMVerify([engine
1290  flutterTextInputView:inputView
1291  updateEditingClient:0
1292  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1293  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1294  isEqualToString:@"Some initial text"]) &&
1295  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1296  isEqualToString:@"text."]) &&
1297  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) &&
1298  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17);
1299  }]]);
1300  [self flushScheduledAsyncBlocks];
1301  XCTAssertEqual(updateCount, 2);
1302 }
1303 
1304 - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextDeletion {
1305  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1306  inputView.enableDeltaModel = YES;
1307 
1308  __block int updateCount = 0;
1309  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
1310  .andDo(^(NSInvocation* invocation) {
1311  updateCount++;
1312  });
1313 
1314  [inputView.text setString:@"Some initial text"];
1315  [self flushScheduledAsyncBlocks];
1316  XCTAssertEqual(updateCount, 0);
1317 
1318  UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)];
1319  inputView.markedTextRange = range;
1320  inputView.selectedTextRange = nil;
1321  [self flushScheduledAsyncBlocks];
1322  XCTAssertEqual(updateCount, 1);
1323 
1324  [inputView setMarkedText:@"tex" selectedRange:NSMakeRange(0, 1)];
1325  OCMVerify([engine
1326  flutterTextInputView:inputView
1327  updateEditingClient:0
1328  withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1329  return ([[state[@"deltas"] objectAtIndex:0][@"oldText"]
1330  isEqualToString:@"Some initial text"]) &&
1331  ([[state[@"deltas"] objectAtIndex:0][@"deltaText"]
1332  isEqualToString:@"tex"]) &&
1333  ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) &&
1334  ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17);
1335  }]]);
1336  [self flushScheduledAsyncBlocks];
1337  XCTAssertEqual(updateCount, 2);
1338 }
1339 
1340 #pragma mark - EditingState tests
1341 
1342 - (void)testUITextInputCallsUpdateEditingStateOnce {
1343  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1344 
1345  __block int updateCount = 0;
1346  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]])
1347  .andDo(^(NSInvocation* invocation) {
1348  updateCount++;
1349  });
1350 
1351  [inputView insertText:@"text to insert"];
1352  // Update the framework exactly once.
1353  XCTAssertEqual(updateCount, 1);
1354 
1355  [inputView deleteBackward];
1356  XCTAssertEqual(updateCount, 2);
1357 
1358  inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
1359  XCTAssertEqual(updateCount, 3);
1360 
1361  [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]
1362  withText:@"replace text"];
1363  XCTAssertEqual(updateCount, 4);
1364 
1365  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1366  XCTAssertEqual(updateCount, 5);
1367 
1368  [inputView unmarkText];
1369  XCTAssertEqual(updateCount, 6);
1370 }
1371 
1372 - (void)testUITextInputCallsUpdateEditingStateWithDeltaOnce {
1373  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1374  inputView.enableDeltaModel = YES;
1375 
1376  __block int updateCount = 0;
1377  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
1378  .andDo(^(NSInvocation* invocation) {
1379  updateCount++;
1380  });
1381 
1382  [inputView insertText:@"text to insert"];
1383  [self flushScheduledAsyncBlocks];
1384  // Update the framework exactly once.
1385  XCTAssertEqual(updateCount, 1);
1386 
1387  [inputView deleteBackward];
1388  [self flushScheduledAsyncBlocks];
1389  XCTAssertEqual(updateCount, 2);
1390 
1391  inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
1392  [self flushScheduledAsyncBlocks];
1393  XCTAssertEqual(updateCount, 3);
1394 
1395  [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]
1396  withText:@"replace text"];
1397  [self flushScheduledAsyncBlocks];
1398  XCTAssertEqual(updateCount, 4);
1399 
1400  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1401  [self flushScheduledAsyncBlocks];
1402  XCTAssertEqual(updateCount, 5);
1403 
1404  [inputView unmarkText];
1405  [self flushScheduledAsyncBlocks];
1406  XCTAssertEqual(updateCount, 6);
1407 }
1408 
1409 - (void)testTextChangesDoNotTriggerUpdateEditingClient {
1410  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1411 
1412  __block int updateCount = 0;
1413  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]])
1414  .andDo(^(NSInvocation* invocation) {
1415  updateCount++;
1416  });
1417 
1418  [inputView.text setString:@"BEFORE"];
1419  XCTAssertEqual(updateCount, 0);
1420 
1421  inputView.markedTextRange = nil;
1422  inputView.selectedTextRange = nil;
1423  XCTAssertEqual(updateCount, 1);
1424 
1425  // Text changes don't trigger an update.
1426  XCTAssertEqual(updateCount, 1);
1427  [inputView setTextInputState:@{@"text" : @"AFTER"}];
1428  XCTAssertEqual(updateCount, 1);
1429  [inputView setTextInputState:@{@"text" : @"AFTER"}];
1430  XCTAssertEqual(updateCount, 1);
1431 
1432  // Selection changes don't trigger an update.
1433  [inputView
1434  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @3}];
1435  XCTAssertEqual(updateCount, 1);
1436  [inputView
1437  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @3}];
1438  XCTAssertEqual(updateCount, 1);
1439 
1440  // Composing region changes don't trigger an update.
1441  [inputView
1442  setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @2}];
1443  XCTAssertEqual(updateCount, 1);
1444  [inputView
1445  setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
1446  XCTAssertEqual(updateCount, 1);
1447 }
1448 
1449 - (void)testTextChangesDoNotTriggerUpdateEditingClientWithDelta {
1450  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1451  inputView.enableDeltaModel = YES;
1452 
1453  __block int updateCount = 0;
1454  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]])
1455  .andDo(^(NSInvocation* invocation) {
1456  updateCount++;
1457  });
1458 
1459  [inputView.text setString:@"BEFORE"];
1460  [self flushScheduledAsyncBlocks];
1461  XCTAssertEqual(updateCount, 0);
1462 
1463  inputView.markedTextRange = nil;
1464  inputView.selectedTextRange = nil;
1465  [self flushScheduledAsyncBlocks];
1466  XCTAssertEqual(updateCount, 1);
1467 
1468  // Text changes don't trigger an update.
1469  XCTAssertEqual(updateCount, 1);
1470  [inputView setTextInputState:@{@"text" : @"AFTER"}];
1471  [self flushScheduledAsyncBlocks];
1472  XCTAssertEqual(updateCount, 1);
1473 
1474  [inputView setTextInputState:@{@"text" : @"AFTER"}];
1475  [self flushScheduledAsyncBlocks];
1476  XCTAssertEqual(updateCount, 1);
1477 
1478  // Selection changes don't trigger an update.
1479  [inputView
1480  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @3}];
1481  [self flushScheduledAsyncBlocks];
1482  XCTAssertEqual(updateCount, 1);
1483 
1484  [inputView
1485  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @3}];
1486  [self flushScheduledAsyncBlocks];
1487  XCTAssertEqual(updateCount, 1);
1488 
1489  // Composing region changes don't trigger an update.
1490  [inputView
1491  setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @2}];
1492  [self flushScheduledAsyncBlocks];
1493  XCTAssertEqual(updateCount, 1);
1494 
1495  [inputView
1496  setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
1497  [self flushScheduledAsyncBlocks];
1498  XCTAssertEqual(updateCount, 1);
1499 }
1500 
1501 - (void)testUITextInputAvoidUnnecessaryUndateEditingClientCalls {
1502  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1503 
1504  __block int updateCount = 0;
1505  OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]])
1506  .andDo(^(NSInvocation* invocation) {
1507  updateCount++;
1508  });
1509 
1510  [inputView unmarkText];
1511  // updateEditingClient shouldn't fire as the text is already unmarked.
1512  XCTAssertEqual(updateCount, 0);
1513 
1514  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1515  // updateEditingClient fires in response to setMarkedText.
1516  XCTAssertEqual(updateCount, 1);
1517 
1518  [inputView unmarkText];
1519  // updateEditingClient fires in response to unmarkText.
1520  XCTAssertEqual(updateCount, 2);
1521 }
1522 
1523 - (void)testCanCopyPasteWithScribbleEnabled {
1524  if (@available(iOS 14.0, *)) {
1525  NSDictionary* config = self.mutableTemplateCopy;
1526  [self setClientId:123 configuration:config];
1527  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
1528  FlutterTextInputView* inputView = inputFields[0];
1529 
1530  FlutterTextInputView* mockInputView = OCMPartialMock(inputView);
1531  OCMStub([mockInputView isScribbleAvailable]).andReturn(YES);
1532 
1533  [mockInputView insertText:@"aaaa"];
1534  [mockInputView selectAll:nil];
1535 
1536  XCTAssertTrue([mockInputView canPerformAction:@selector(copy:) withSender:NULL]);
1537  XCTAssertTrue([mockInputView canPerformAction:@selector(copy:) withSender:@"sender"]);
1538  XCTAssertFalse([mockInputView canPerformAction:@selector(paste:) withSender:NULL]);
1539  XCTAssertFalse([mockInputView canPerformAction:@selector(paste:) withSender:@"sender"]);
1540 
1541  [mockInputView copy:NULL];
1542  XCTAssertTrue([mockInputView canPerformAction:@selector(copy:) withSender:NULL]);
1543  XCTAssertTrue([mockInputView canPerformAction:@selector(copy:) withSender:@"sender"]);
1544  XCTAssertTrue([mockInputView canPerformAction:@selector(paste:) withSender:NULL]);
1545  XCTAssertTrue([mockInputView canPerformAction:@selector(paste:) withSender:@"sender"]);
1546  }
1547 }
1548 
1549 - (void)testSetMarkedTextDuringScribbleDoesNotTriggerUpdateEditingClient {
1550  if (@available(iOS 14.0, *)) {
1551  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1552 
1553  __block int updateCount = 0;
1554  OCMStub([engine flutterTextInputView:inputView
1555  updateEditingClient:0
1556  withState:[OCMArg isNotNil]])
1557  .andDo(^(NSInvocation* invocation) {
1558  updateCount++;
1559  });
1560 
1561  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1562  // updateEditingClient fires in response to setMarkedText.
1563  XCTAssertEqual(updateCount, 1);
1564 
1565  UIScribbleInteraction* scribbleInteraction =
1566  [[UIScribbleInteraction alloc] initWithDelegate:inputView];
1567 
1568  [inputView scribbleInteractionWillBeginWriting:scribbleInteraction];
1569  [inputView setMarkedText:@"during writing" selectedRange:NSMakeRange(1, 2)];
1570  // updateEditingClient does not fire in response to setMarkedText during a scribble interaction.
1571  XCTAssertEqual(updateCount, 1);
1572 
1573  [inputView scribbleInteractionDidFinishWriting:scribbleInteraction];
1574  [inputView resetScribbleInteractionStatusIfEnding];
1575  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1576  // updateEditingClient fires in response to setMarkedText.
1577  XCTAssertEqual(updateCount, 2);
1578 
1579  inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing;
1580  [inputView setMarkedText:@"during focus" selectedRange:NSMakeRange(1, 2)];
1581  // updateEditingClient does not fire in response to setMarkedText during a scribble-initiated
1582  // focus.
1583  XCTAssertEqual(updateCount, 2);
1584 
1585  inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocused;
1586  [inputView setMarkedText:@"after focus" selectedRange:NSMakeRange(2, 3)];
1587  // updateEditingClient does not fire in response to setMarkedText after a scribble-initiated
1588  // focus.
1589  XCTAssertEqual(updateCount, 2);
1590 
1591  inputView.scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused;
1592  [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
1593  // updateEditingClient fires in response to setMarkedText.
1594  XCTAssertEqual(updateCount, 3);
1595  }
1596 }
1597 
1598 - (void)testUpdateEditingClientNegativeSelection {
1599  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1600 
1601  [inputView.text setString:@"SELECTION"];
1602  inputView.markedTextRange = nil;
1603  inputView.selectedTextRange = nil;
1604 
1605  [inputView setTextInputState:@{
1606  @"text" : @"SELECTION",
1607  @"selectionBase" : @-1,
1608  @"selectionExtent" : @-1
1609  }];
1610  [inputView updateEditingState];
1611  OCMVerify([engine flutterTextInputView:inputView
1612  updateEditingClient:0
1613  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1614  return ([state[@"selectionBase"] intValue]) == 0 &&
1615  ([state[@"selectionExtent"] intValue] == 0);
1616  }]]);
1617 
1618  // Returns (0, 0) when either end goes below 0.
1619  [inputView
1620  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @-1, @"selectionExtent" : @1}];
1621  [inputView updateEditingState];
1622  OCMVerify([engine flutterTextInputView:inputView
1623  updateEditingClient:0
1624  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1625  return ([state[@"selectionBase"] intValue]) == 0 &&
1626  ([state[@"selectionExtent"] intValue] == 0);
1627  }]]);
1628 
1629  [inputView
1630  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @-1}];
1631  [inputView updateEditingState];
1632  OCMVerify([engine flutterTextInputView:inputView
1633  updateEditingClient:0
1634  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1635  return ([state[@"selectionBase"] intValue]) == 0 &&
1636  ([state[@"selectionExtent"] intValue] == 0);
1637  }]]);
1638 }
1639 
1640 - (void)testUpdateEditingClientSelectionClamping {
1641  // Regression test for https://github.com/flutter/flutter/issues/62992.
1642  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1643 
1644  [inputView.text setString:@"SELECTION"];
1645  inputView.markedTextRange = nil;
1646  inputView.selectedTextRange = nil;
1647 
1648  [inputView
1649  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @0}];
1650  [inputView updateEditingState];
1651  OCMVerify([engine flutterTextInputView:inputView
1652  updateEditingClient:0
1653  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1654  return ([state[@"selectionBase"] intValue]) == 0 &&
1655  ([state[@"selectionExtent"] intValue] == 0);
1656  }]]);
1657 
1658  // Needs clamping.
1659  [inputView setTextInputState:@{
1660  @"text" : @"SELECTION",
1661  @"selectionBase" : @0,
1662  @"selectionExtent" : @9999
1663  }];
1664  [inputView updateEditingState];
1665 
1666  OCMVerify([engine flutterTextInputView:inputView
1667  updateEditingClient:0
1668  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1669  return ([state[@"selectionBase"] intValue]) == 0 &&
1670  ([state[@"selectionExtent"] intValue] == 9);
1671  }]]);
1672 
1673  // No clamping needed, but in reverse direction.
1674  [inputView
1675  setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @0}];
1676  [inputView updateEditingState];
1677  OCMVerify([engine flutterTextInputView:inputView
1678  updateEditingClient:0
1679  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1680  return ([state[@"selectionBase"] intValue]) == 0 &&
1681  ([state[@"selectionExtent"] intValue] == 1);
1682  }]]);
1683 
1684  // Both ends need clamping.
1685  [inputView setTextInputState:@{
1686  @"text" : @"SELECTION",
1687  @"selectionBase" : @9999,
1688  @"selectionExtent" : @9999
1689  }];
1690  [inputView updateEditingState];
1691  OCMVerify([engine flutterTextInputView:inputView
1692  updateEditingClient:0
1693  withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
1694  return ([state[@"selectionBase"] intValue]) == 9 &&
1695  ([state[@"selectionExtent"] intValue] == 9);
1696  }]]);
1697 }
1698 
1699 - (void)testInputViewsHasNonNilInputDelegate {
1700  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1701  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
1702 
1703  [inputView setTextInputClient:123];
1704  [inputView reloadInputViews];
1705  [inputView becomeFirstResponder];
1706  NSAssert(inputView.isFirstResponder, @"inputView is not first responder");
1707  inputView.inputDelegate = nil;
1708 
1709  FlutterTextInputView* mockInputView = OCMPartialMock(inputView);
1710  [mockInputView
1711  setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
1712  OCMVerify([mockInputView setInputDelegate:[OCMArg isNotNil]]);
1713  [inputView removeFromSuperview];
1714 }
1715 
1716 - (void)testInputViewsDoNotHaveUITextInteractions {
1717  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1718  BOOL hasTextInteraction = NO;
1719  for (id interaction in inputView.interactions) {
1720  hasTextInteraction = [interaction isKindOfClass:[UITextInteraction class]];
1721  if (hasTextInteraction) {
1722  break;
1723  }
1724  }
1725  XCTAssertFalse(hasTextInteraction);
1726 }
1727 
1728 #pragma mark - UITextInput methods - Tests
1729 
1730 - (void)testUpdateFirstRectForRange {
1731  [self setClientId:123 configuration:self.mutableTemplateCopy];
1732 
1733  FlutterTextInputView* inputView = textInputPlugin.activeView;
1734  textInputPlugin.viewController.view.frame = CGRectMake(0, 0, 0, 0);
1735 
1736  [inputView
1737  setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}];
1738 
1739  CGRect kInvalidFirstRect = CGRectMake(-1, -1, 9999, 9999);
1740  FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
1741  // yOffset = 200.
1742  NSArray* yOffsetMatrix = @[ @1, @0, @0, @0, @0, @1, @0, @0, @0, @0, @1, @0, @0, @200, @0, @1 ];
1743  NSArray* zeroMatrix = @[ @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0 ];
1744  // This matrix can be generated by running this dart code snippet:
1745  // Matrix4.identity()..scale(3.0)..rotateZ(math.pi/2)..translate(1.0, 2.0,
1746  // 3.0);
1747  NSArray* affineMatrix = @[
1748  @(0.0), @(3.0), @(0.0), @(0.0), @(-3.0), @(0.0), @(0.0), @(0.0), @(0.0), @(0.0), @(3.0), @(0.0),
1749  @(-6.0), @(3.0), @(9.0), @(1.0)
1750  ];
1751 
1752  // Invalid since we don't have the transform or the rect.
1753  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1754 
1755  [inputView setEditableTransform:yOffsetMatrix];
1756  // Invalid since we don't have the rect.
1757  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1758 
1759  // Valid rect and transform.
1760  CGRect testRect = CGRectMake(0, 0, 100, 100);
1761  [inputView setMarkedRect:testRect];
1762 
1763  CGRect finalRect = CGRectOffset(testRect, 0, 200);
1764  XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
1765  // Idempotent.
1766  XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
1767 
1768  // Use an invalid matrix:
1769  [inputView setEditableTransform:zeroMatrix];
1770  // Invalid matrix is invalid.
1771  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1772  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1773 
1774  // Revert the invalid matrix change.
1775  [inputView setEditableTransform:yOffsetMatrix];
1776  [inputView setMarkedRect:testRect];
1777  XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
1778 
1779  // Use an invalid rect:
1780  [inputView setMarkedRect:kInvalidFirstRect];
1781  // Invalid marked rect is invalid.
1782  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1783  XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
1784 
1785  // Use a 3d affine transform that does 3d-scaling, z-index rotating and 3d translation.
1786  [inputView setEditableTransform:affineMatrix];
1787  [inputView setMarkedRect:testRect];
1788  XCTAssertTrue(
1789  CGRectEqualToRect(CGRectMake(-306, 3, 300, 300), [inputView firstRectForRange:range]));
1790 
1791  NSAssert(inputView.superview, @"inputView is not in the view hierarchy!");
1792  const CGPoint offset = CGPointMake(113, 119);
1793  CGRect currentFrame = inputView.frame;
1794  currentFrame.origin = offset;
1795  inputView.frame = currentFrame;
1796  // Moving the input view within the FlutterView shouldn't affect the coordinates,
1797  // since the framework sends us global coordinates.
1798  XCTAssertTrue(CGRectEqualToRect(CGRectMake(-306 - 113, 3 - 119, 300, 300),
1799  [inputView firstRectForRange:range]));
1800 }
1801 
1802 - (void)testFirstRectForRangeReturnsNoneZeroRectWhenScribbleIsEnabled {
1803  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1804  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1805 
1806  FlutterTextInputView* mockInputView = OCMPartialMock(inputView);
1807  OCMStub([mockInputView isScribbleAvailable]).andReturn(YES);
1808 
1809  [inputView setSelectionRects:@[
1810  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1811  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
1812  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
1813  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
1814  ]];
1815 
1816  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1817 
1818  if (@available(iOS 17, *)) {
1819  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100),
1820  [inputView firstRectForRange:multiRectRange]));
1821  } else {
1822  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100),
1823  [inputView firstRectForRange:multiRectRange]));
1824  }
1825 }
1826 
1827 - (void)testFirstRectForRangeReturnsCorrectRectOnASingleLineLeftToRight {
1828  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1829  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1830 
1831  [inputView setSelectionRects:@[
1832  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1833  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
1834  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
1835  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
1836  ]];
1837  FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)];
1838  if (@available(iOS 17, *)) {
1839  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100),
1840  [inputView firstRectForRange:singleRectRange]));
1841  } else {
1842  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:singleRectRange]));
1843  }
1844 
1845  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1846 
1847  if (@available(iOS 17, *)) {
1848  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100),
1849  [inputView firstRectForRange:multiRectRange]));
1850  } else {
1851  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1852  }
1853 
1854  [inputView setTextInputState:@{@"text" : @"COM"}];
1855  FlutterTextRange* rangeOutsideBounds = [FlutterTextRange rangeWithNSRange:NSMakeRange(3, 1)];
1856  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:rangeOutsideBounds]));
1857 }
1858 
1859 - (void)testFirstRectForRangeReturnsCorrectRectOnASingleLineRightToLeft {
1860  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1861  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1862 
1863  [inputView setSelectionRects:@[
1864  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U],
1865  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:1U],
1866  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:2U],
1867  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U],
1868  ]];
1869  FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)];
1870  if (@available(iOS 17, *)) {
1871  XCTAssertTrue(CGRectEqualToRect(CGRectMake(200, 0, 100, 100),
1872  [inputView firstRectForRange:singleRectRange]));
1873  } else {
1874  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:singleRectRange]));
1875  }
1876 
1877  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1878  if (@available(iOS 17, *)) {
1879  XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 300, 100),
1880  [inputView firstRectForRange:multiRectRange]));
1881  } else {
1882  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1883  }
1884 
1885  [inputView setTextInputState:@{@"text" : @"COM"}];
1886  FlutterTextRange* rangeOutsideBounds = [FlutterTextRange rangeWithNSRange:NSMakeRange(3, 1)];
1887  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:rangeOutsideBounds]));
1888 }
1889 
1890 - (void)testFirstRectForRangeReturnsCorrectRectOnMultipleLinesLeftToRight {
1891  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1892  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1893 
1894  [inputView setSelectionRects:@[
1895  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1896  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
1897  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
1898  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
1899  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:4U],
1900  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:5U],
1901  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:6U],
1902  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 100, 100, 100) position:7U],
1903  ]];
1904  FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)];
1905  if (@available(iOS 17, *)) {
1906  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100),
1907  [inputView firstRectForRange:singleRectRange]));
1908  } else {
1909  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:singleRectRange]));
1910  }
1911 
1912  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
1913 
1914  if (@available(iOS 17, *)) {
1915  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100),
1916  [inputView firstRectForRange:multiRectRange]));
1917  } else {
1918  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1919  }
1920 }
1921 
1922 - (void)testFirstRectForRangeReturnsCorrectRectOnMultipleLinesRightToLeft {
1923  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1924  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1925 
1926  [inputView setSelectionRects:@[
1927  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U],
1928  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:1U],
1929  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:2U],
1930  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U],
1931  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 100, 100, 100) position:4U],
1932  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:5U],
1933  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:6U],
1934  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:7U],
1935  ]];
1936  FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)];
1937  if (@available(iOS 17, *)) {
1938  XCTAssertTrue(CGRectEqualToRect(CGRectMake(200, 0, 100, 100),
1939  [inputView firstRectForRange:singleRectRange]));
1940  } else {
1941  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:singleRectRange]));
1942  }
1943 
1944  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
1945  if (@available(iOS 17, *)) {
1946  XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 300, 100),
1947  [inputView firstRectForRange:multiRectRange]));
1948  } else {
1949  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1950  }
1951 }
1952 
1953 - (void)testFirstRectForRangeReturnsCorrectRectOnSingleLineWithVaryingMinYAndMaxYLeftToRight {
1954  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1955  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1956 
1957  [inputView setSelectionRects:@[
1958  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
1959  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 10, 100, 80)
1960  position:1U], // shorter
1961  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, -10, 100, 120)
1962  position:2U], // taller
1963  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
1964  ]];
1965 
1966  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1967 
1968  if (@available(iOS 17, *)) {
1969  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, -10, 300, 120),
1970  [inputView firstRectForRange:multiRectRange]));
1971  } else {
1972  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1973  }
1974 }
1975 
1976 - (void)testFirstRectForRangeReturnsCorrectRectOnSingleLineWithVaryingMinYAndMaxYRightToLeft {
1977  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
1978  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
1979 
1980  [inputView setSelectionRects:@[
1981  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U],
1982  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, -10, 100, 120)
1983  position:1U], // taller
1984  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 10, 100, 80)
1985  position:2U], // shorter
1986  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U],
1987  ]];
1988 
1989  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)];
1990 
1991  if (@available(iOS 17, *)) {
1992  XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, -10, 300, 120),
1993  [inputView firstRectForRange:multiRectRange]));
1994  } else {
1995  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
1996  }
1997 }
1998 
1999 - (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsExceedingThresholdLeftToRight {
2000  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2001  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2002 
2003  [inputView setSelectionRects:@[
2004  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2005  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
2006  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
2007  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
2008  // y=60 exceeds threshold, so treat it as a new line.
2009  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 60, 100, 100) position:4U],
2010  ]];
2011 
2012  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
2013 
2014  if (@available(iOS 17, *)) {
2015  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100),
2016  [inputView firstRectForRange:multiRectRange]));
2017  } else {
2018  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
2019  }
2020 }
2021 
2022 - (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsExceedingThresholdRightToLeft {
2023  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2024  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2025 
2026  [inputView setSelectionRects:@[
2027  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U],
2028  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:1U],
2029  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:2U],
2030  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U],
2031  // y=60 exceeds threshold, so treat it as a new line.
2032  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 60, 100, 100) position:4U],
2033  ]];
2034 
2035  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
2036 
2037  if (@available(iOS 17, *)) {
2038  XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 300, 100),
2039  [inputView firstRectForRange:multiRectRange]));
2040  } else {
2041  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
2042  }
2043 }
2044 
2045 - (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsWithinThresholdLeftToRight {
2046  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2047  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2048 
2049  [inputView setSelectionRects:@[
2050  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2051  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U],
2052  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
2053  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U],
2054  // y=40 is within line threshold, so treat it as the same line
2055  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(400, 40, 100, 100) position:4U],
2056  ]];
2057 
2058  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
2059 
2060  if (@available(iOS 17, *)) {
2061  XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 400, 140),
2062  [inputView firstRectForRange:multiRectRange]));
2063  } else {
2064  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
2065  }
2066 }
2067 
2068 - (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsWithinThresholdRightToLeft {
2069  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2070  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2071 
2072  [inputView setSelectionRects:@[
2073  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(400, 0, 100, 100) position:0U],
2074  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:1U],
2075  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
2076  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:3U],
2077  // y=40 is within line threshold, so treat it as the same line
2078  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 40, 100, 100) position:4U],
2079  ]];
2080 
2081  FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)];
2082 
2083  if (@available(iOS 17, *)) {
2084  XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 400, 140),
2085  [inputView firstRectForRange:multiRectRange]));
2086  } else {
2087  XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:multiRectRange]));
2088  }
2089 }
2090 
2091 - (void)testClosestPositionToPoint {
2092  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2093  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2094 
2095  // Minimize the vertical distance from the center of the rects first
2096  [inputView setSelectionRects:@[
2097  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2098  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
2099  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:2U],
2100  ]];
2101  CGPoint point = CGPointMake(150, 150);
2102  XCTAssertEqual(2U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
2103  XCTAssertEqual(UITextStorageDirectionBackward,
2104  ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
2105 
2106  // Then, if the point is above the bottom of the closest rects vertically, get the closest x
2107  // origin
2108  [inputView setSelectionRects:@[
2109  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2110  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
2111  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
2112  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
2113  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U],
2114  ]];
2115  point = CGPointMake(125, 150);
2116  XCTAssertEqual(2U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
2117  XCTAssertEqual(UITextStorageDirectionForward,
2118  ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
2119 
2120  // However, if the point is below the bottom of the closest rects vertically, get the position
2121  // farthest to the right
2122  [inputView setSelectionRects:@[
2123  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2124  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
2125  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
2126  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
2127  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 300, 100, 100) position:4U],
2128  ]];
2129  point = CGPointMake(125, 201);
2130  XCTAssertEqual(4U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
2131  XCTAssertEqual(UITextStorageDirectionBackward,
2132  ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
2133 
2134  // Also check a point at the right edge of the last selection rect
2135  [inputView setSelectionRects:@[
2136  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2137  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
2138  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
2139  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
2140  ]];
2141  point = CGPointMake(125, 250);
2142  XCTAssertEqual(4U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
2143  XCTAssertEqual(UITextStorageDirectionBackward,
2144  ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
2145 
2146  // Minimize vertical distance if the difference is more than 1 point.
2147  [inputView setSelectionRects:@[
2148  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 2, 100, 100) position:0U],
2149  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 2, 100, 100) position:1U],
2150  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U],
2151  ]];
2152  point = CGPointMake(110, 50);
2153  XCTAssertEqual(2U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
2154  XCTAssertEqual(UITextStorageDirectionForward,
2155  ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
2156 
2157  // In floating cursor mode, the vertical difference is allowed to be 10 points.
2158  // The closest horizontal position will now win.
2159  [inputView beginFloatingCursorAtPoint:CGPointZero];
2160  XCTAssertEqual(1U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index);
2161  XCTAssertEqual(UITextStorageDirectionForward,
2162  ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity);
2163  [inputView endFloatingCursor];
2164 }
2165 
2166 - (void)testClosestPositionToPointRTL {
2167  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2168  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2169 
2170  [inputView setSelectionRects:@[
2171  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100)
2172  position:0U
2173  writingDirection:NSWritingDirectionRightToLeft],
2174  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100)
2175  position:1U
2176  writingDirection:NSWritingDirectionRightToLeft],
2177  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100)
2178  position:2U
2179  writingDirection:NSWritingDirectionRightToLeft],
2180  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100)
2181  position:3U
2182  writingDirection:NSWritingDirectionRightToLeft],
2183  ]];
2184  FlutterTextPosition* position =
2185  (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(275, 50)];
2186  XCTAssertEqual(0U, position.index);
2187  XCTAssertEqual(UITextStorageDirectionForward, position.affinity);
2188  position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(225, 50)];
2189  XCTAssertEqual(1U, position.index);
2190  XCTAssertEqual(UITextStorageDirectionBackward, position.affinity);
2191  position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(175, 50)];
2192  XCTAssertEqual(1U, position.index);
2193  XCTAssertEqual(UITextStorageDirectionForward, position.affinity);
2194  position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(125, 50)];
2195  XCTAssertEqual(2U, position.index);
2196  XCTAssertEqual(UITextStorageDirectionBackward, position.affinity);
2197  position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(75, 50)];
2198  XCTAssertEqual(2U, position.index);
2199  XCTAssertEqual(UITextStorageDirectionForward, position.affinity);
2200  position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(25, 50)];
2201  XCTAssertEqual(3U, position.index);
2202  XCTAssertEqual(UITextStorageDirectionBackward, position.affinity);
2203  position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(-25, 50)];
2204  XCTAssertEqual(3U, position.index);
2205  XCTAssertEqual(UITextStorageDirectionBackward, position.affinity);
2206 }
2207 
2208 - (void)testSelectionRectsForRange {
2209  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2210  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2211 
2212  CGRect testRect0 = CGRectMake(100, 100, 100, 100);
2213  CGRect testRect1 = CGRectMake(200, 200, 100, 100);
2214  [inputView setSelectionRects:@[
2215  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2218  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U],
2219  ]];
2220 
2221  // Returns the matching rects within a range
2222  FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 2)];
2223  XCTAssertTrue(CGRectEqualToRect(testRect0, [inputView selectionRectsForRange:range][0].rect));
2224  XCTAssertTrue(CGRectEqualToRect(testRect1, [inputView selectionRectsForRange:range][1].rect));
2225  XCTAssertEqual(2U, [[inputView selectionRectsForRange:range] count]);
2226 
2227  // Returns a 0 width rect for a 0-length range
2228  range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 0)];
2229  XCTAssertEqual(1U, [[inputView selectionRectsForRange:range] count]);
2230  XCTAssertTrue(CGRectEqualToRect(
2231  CGRectMake(testRect0.origin.x, testRect0.origin.y, 0, testRect0.size.height),
2232  [inputView selectionRectsForRange:range][0].rect));
2233 }
2234 
2235 - (void)testClosestPositionToPointWithinRange {
2236  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2237  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2238 
2239  // Do not return a position before the start of the range
2240  [inputView setSelectionRects:@[
2241  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2242  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
2243  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
2244  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
2245  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U],
2246  ]];
2247  CGPoint point = CGPointMake(125, 150);
2248  FlutterTextRange* range = [[FlutterTextRange rangeWithNSRange:NSMakeRange(3, 2)] copy];
2249  XCTAssertEqual(
2250  3U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).index);
2251  XCTAssertEqual(
2252  UITextStorageDirectionForward,
2253  ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).affinity);
2254 
2255  // Do not return a position after the end of the range
2256  [inputView setSelectionRects:@[
2257  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U],
2258  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U],
2259  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U],
2260  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U],
2261  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U],
2262  ]];
2263  point = CGPointMake(125, 150);
2264  range = [[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)] copy];
2265  XCTAssertEqual(
2266  1U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).index);
2267  XCTAssertEqual(
2268  UITextStorageDirectionForward,
2269  ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).affinity);
2270 }
2271 
2272 - (void)testClosestPositionToPointWithPartialSelectionRects {
2273  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2274  [inputView setTextInputState:@{@"text" : @"COMPOSING"}];
2275 
2276  [inputView setSelectionRects:@[ [FlutterTextSelectionRect
2277  selectionRectWithRect:CGRectMake(0, 0, 100, 100)
2278  position:0U] ]];
2279  // Asking with a position at the end of selection rects should give you the trailing edge of
2280  // the last rect.
2281  XCTAssertTrue(CGRectEqualToRect(
2283  positionWithIndex:1
2284  affinity:UITextStorageDirectionForward]],
2285  CGRectMake(100, 0, 0, 100)));
2286  // Asking with a position beyond the end of selection rects should return CGRectZero without
2287  // crashing.
2288  XCTAssertTrue(CGRectEqualToRect(
2290  positionWithIndex:2
2291  affinity:UITextStorageDirectionForward]],
2292  CGRectZero));
2293 }
2294 
2295 #pragma mark - Floating Cursor - Tests
2296 
2297 - (void)testFloatingCursorDoesNotThrow {
2298  // The keyboard implementation may send unbalanced calls to the input view.
2299  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2300  [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
2301  [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
2302  [inputView endFloatingCursor];
2303  [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
2304  [inputView endFloatingCursor];
2305 }
2306 
2307 - (void)testFloatingCursor {
2308  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2309  [inputView setTextInputState:@{
2310  @"text" : @"test",
2311  @"selectionBase" : @1,
2312  @"selectionExtent" : @1,
2313  }];
2314 
2315  FlutterTextSelectionRect* first =
2316  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U];
2317  FlutterTextSelectionRect* second =
2318  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:1U];
2319  FlutterTextSelectionRect* third =
2320  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 200, 100, 100) position:2U];
2321  FlutterTextSelectionRect* fourth =
2322  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U];
2323  [inputView setSelectionRects:@[ first, second, third, fourth ]];
2324 
2325  // Verify zeroth caret rect is based on left edge of first character.
2326  XCTAssertTrue(CGRectEqualToRect(
2328  positionWithIndex:0
2329  affinity:UITextStorageDirectionForward]],
2330  CGRectMake(0, 0, 0, 100)));
2331  // Since the textAffinity is downstream, the caret rect will be based on the
2332  // left edge of the succeeding character.
2333  XCTAssertTrue(CGRectEqualToRect(
2335  positionWithIndex:1
2336  affinity:UITextStorageDirectionForward]],
2337  CGRectMake(100, 100, 0, 100)));
2338  XCTAssertTrue(CGRectEqualToRect(
2340  positionWithIndex:2
2341  affinity:UITextStorageDirectionForward]],
2342  CGRectMake(200, 200, 0, 100)));
2343  XCTAssertTrue(CGRectEqualToRect(
2345  positionWithIndex:3
2346  affinity:UITextStorageDirectionForward]],
2347  CGRectMake(300, 300, 0, 100)));
2348  // There is no subsequent character for the last position, so the caret rect
2349  // will be based on the right edge of the preceding character.
2350  XCTAssertTrue(CGRectEqualToRect(
2352  positionWithIndex:4
2353  affinity:UITextStorageDirectionForward]],
2354  CGRectMake(400, 300, 0, 100)));
2355  // Verify no caret rect for out-of-range character.
2356  XCTAssertTrue(CGRectEqualToRect(
2358  positionWithIndex:5
2359  affinity:UITextStorageDirectionForward]],
2360  CGRectZero));
2361 
2362  // Check caret rects again again when text affinity is upstream.
2363  [inputView setTextInputState:@{
2364  @"text" : @"test",
2365  @"selectionBase" : @2,
2366  @"selectionExtent" : @2,
2367  }];
2368  // Verify zeroth caret rect is based on left edge of first character.
2369  XCTAssertTrue(CGRectEqualToRect(
2371  positionWithIndex:0
2372  affinity:UITextStorageDirectionBackward]],
2373  CGRectMake(0, 0, 0, 100)));
2374  // Since the textAffinity is upstream, all below caret rects will be based on
2375  // the right edge of the preceding character.
2376  XCTAssertTrue(CGRectEqualToRect(
2378  positionWithIndex:1
2379  affinity:UITextStorageDirectionBackward]],
2380  CGRectMake(100, 0, 0, 100)));
2381  XCTAssertTrue(CGRectEqualToRect(
2383  positionWithIndex:2
2384  affinity:UITextStorageDirectionBackward]],
2385  CGRectMake(200, 100, 0, 100)));
2386  XCTAssertTrue(CGRectEqualToRect(
2388  positionWithIndex:3
2389  affinity:UITextStorageDirectionBackward]],
2390  CGRectMake(300, 200, 0, 100)));
2391  XCTAssertTrue(CGRectEqualToRect(
2393  positionWithIndex:4
2394  affinity:UITextStorageDirectionBackward]],
2395  CGRectMake(400, 300, 0, 100)));
2396  // Verify no caret rect for out-of-range character.
2397  XCTAssertTrue(CGRectEqualToRect(
2399  positionWithIndex:5
2400  affinity:UITextStorageDirectionBackward]],
2401  CGRectZero));
2402 
2403  // Verify floating cursor updates are relative to original position, and that there is no bounds
2404  // change.
2405  CGRect initialBounds = inputView.bounds;
2406  [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)];
2407  XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds));
2408  OCMVerify([engine flutterTextInputView:inputView
2409  updateFloatingCursor:FlutterFloatingCursorDragStateStart
2410  withClient:0
2411  withPosition:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
2412  return ([state[@"X"] isEqualToNumber:@(0)]) &&
2413  ([state[@"Y"] isEqualToNumber:@(0)]);
2414  }]]);
2415 
2416  [inputView updateFloatingCursorAtPoint:CGPointMake(456, 654)];
2417  XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds));
2418  OCMVerify([engine flutterTextInputView:inputView
2419  updateFloatingCursor:FlutterFloatingCursorDragStateUpdate
2420  withClient:0
2421  withPosition:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
2422  return ([state[@"X"] isEqualToNumber:@(333)]) &&
2423  ([state[@"Y"] isEqualToNumber:@(333)]);
2424  }]]);
2425 
2426  [inputView endFloatingCursor];
2427  XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds));
2428  OCMVerify([engine flutterTextInputView:inputView
2429  updateFloatingCursor:FlutterFloatingCursorDragStateEnd
2430  withClient:0
2431  withPosition:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
2432  return ([state[@"X"] isEqualToNumber:@(0)]) &&
2433  ([state[@"Y"] isEqualToNumber:@(0)]);
2434  }]]);
2435 }
2436 
2437 #pragma mark - UIKeyInput Overrides - Tests
2438 
2439 - (void)testInsertTextAddsPlaceholderSelectionRects {
2440  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
2441  [inputView
2442  setTextInputState:@{@"text" : @"test", @"selectionBase" : @1, @"selectionExtent" : @1}];
2443 
2444  FlutterTextSelectionRect* first =
2445  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U];
2446  FlutterTextSelectionRect* second =
2447  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:1U];
2448  FlutterTextSelectionRect* third =
2449  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 200, 100, 100) position:2U];
2450  FlutterTextSelectionRect* fourth =
2451  [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U];
2452  [inputView setSelectionRects:@[ first, second, third, fourth ]];
2453 
2454  // Inserts additional selection rects at the selection start
2455  [inputView insertText:@"in"];
2456  NSArray* selectionRects =
2457  [inputView selectionRectsForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 6)]];
2458  XCTAssertEqual(6U, [selectionRects count]);
2459 
2460  XCTAssertEqual(first.position, ((FlutterTextSelectionRect*)selectionRects[0]).position);
2461  XCTAssertTrue(CGRectEqualToRect(first.rect, ((FlutterTextSelectionRect*)selectionRects[0]).rect));
2462 
2463  XCTAssertEqual(second.position, ((FlutterTextSelectionRect*)selectionRects[1]).position);
2464  XCTAssertTrue(
2465  CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[1]).rect));
2466 
2467  XCTAssertEqual(second.position + 1, ((FlutterTextSelectionRect*)selectionRects[2]).position);
2468  XCTAssertTrue(
2469  CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[2]).rect));
2470 
2471  XCTAssertEqual(second.position + 2, ((FlutterTextSelectionRect*)selectionRects[3]).position);
2472  XCTAssertTrue(
2473  CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[3]).rect));
2474 
2475  XCTAssertEqual(third.position + 2, ((FlutterTextSelectionRect*)selectionRects[4]).position);
2476  XCTAssertTrue(CGRectEqualToRect(third.rect, ((FlutterTextSelectionRect*)selectionRects[4]).rect));
2477 
2478  XCTAssertEqual(fourth.position + 2, ((FlutterTextSelectionRect*)selectionRects[5]).position);
2479  XCTAssertTrue(
2480  CGRectEqualToRect(fourth.rect, ((FlutterTextSelectionRect*)selectionRects[5]).rect));
2481 }
2482 
2483 #pragma mark - Autofill - Utilities
2484 
2485 - (NSMutableDictionary*)mutablePasswordTemplateCopy {
2486  if (!_passwordTemplate) {
2487  _passwordTemplate = @{
2488  @"inputType" : @{@"name" : @"TextInuptType.text"},
2489  @"keyboardAppearance" : @"Brightness.light",
2490  @"obscureText" : @YES,
2491  @"inputAction" : @"TextInputAction.unspecified",
2492  @"smartDashesType" : @"0",
2493  @"smartQuotesType" : @"0",
2494  @"autocorrect" : @YES
2495  };
2496  }
2497 
2498  return [_passwordTemplate mutableCopy];
2499 }
2500 
2501 - (NSArray<FlutterTextInputView*>*)viewsVisibleToAutofill {
2502  return [self.installedInputViews
2503  filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isVisibleToAutofill == YES"]];
2504 }
2505 
2506 - (void)commitAutofillContextAndVerify {
2507  FlutterMethodCall* methodCall =
2508  [FlutterMethodCall methodCallWithMethodName:@"TextInput.finishAutofillContext"
2509  arguments:@YES];
2510  [textInputPlugin handleMethodCall:methodCall
2511  result:^(id _Nullable result){
2512  }];
2513 
2514  XCTAssertEqual(self.viewsVisibleToAutofill.count,
2515  [textInputPlugin.activeView isVisibleToAutofill] ? 1ul : 0ul);
2516  XCTAssertNotEqual(textInputPlugin.textInputView, nil);
2517  // The active view should still be installed so it doesn't get
2518  // deallocated.
2519  XCTAssertEqual(self.installedInputViews.count, 1ul);
2520  XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul);
2521 }
2522 
2523 #pragma mark - Autofill - Tests
2524 
2525 - (void)testDisablingAutofillOnInputClient {
2526  NSDictionary* config = self.mutableTemplateCopy;
2527  [config setValue:@"YES" forKey:@"obscureText"];
2528 
2529  [self setClientId:123 configuration:config];
2530 
2531  FlutterTextInputView* inputView = self.installedInputViews[0];
2532  XCTAssertEqualObjects(inputView.textContentType, @"");
2533 }
2534 
2535 - (void)testAutofillEnabledByDefault {
2536  NSDictionary* config = self.mutableTemplateCopy;
2537  [config setValue:@"NO" forKey:@"obscureText"];
2538  [config setValue:@{@"uniqueIdentifier" : @"field1", @"editingValue" : @{@"text" : @""}}
2539  forKey:@"autofill"];
2540 
2541  [self setClientId:123 configuration:config];
2542 
2543  FlutterTextInputView* inputView = self.installedInputViews[0];
2544  XCTAssertNil(inputView.textContentType);
2545 }
2546 
2547 - (void)testAutofillContext {
2548  NSMutableDictionary* field1 = self.mutableTemplateCopy;
2549 
2550  [field1 setValue:@{
2551  @"uniqueIdentifier" : @"field1",
2552  @"hints" : @[ @"hint1" ],
2553  @"editingValue" : @{@"text" : @""}
2554  }
2555  forKey:@"autofill"];
2556 
2557  NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
2558  [field2 setValue:@{
2559  @"uniqueIdentifier" : @"field2",
2560  @"hints" : @[ @"hint2" ],
2561  @"editingValue" : @{@"text" : @""}
2562  }
2563  forKey:@"autofill"];
2564 
2565  NSMutableDictionary* config = [field1 mutableCopy];
2566  [config setValue:@[ field1, field2 ] forKey:@"fields"];
2567 
2568  [self setClientId:123 configuration:config];
2569  XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
2570 
2571  XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
2572 
2573  [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2574  XCTAssertEqual(self.installedInputViews.count, 2ul);
2575  XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
2576  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2577 
2578  // The configuration changes.
2579  NSMutableDictionary* field3 = self.mutablePasswordTemplateCopy;
2580  [field3 setValue:@{
2581  @"uniqueIdentifier" : @"field3",
2582  @"hints" : @[ @"hint3" ],
2583  @"editingValue" : @{@"text" : @""}
2584  }
2585  forKey:@"autofill"];
2586 
2587  NSMutableDictionary* oldContext = textInputPlugin.autofillContext;
2588  // Replace field2 with field3.
2589  [config setValue:@[ field1, field3 ] forKey:@"fields"];
2590 
2591  [self setClientId:123 configuration:config];
2592 
2593  XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
2594  XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul);
2595 
2596  [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2597  XCTAssertEqual(self.installedInputViews.count, 3ul);
2598  XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
2599  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2600 
2601  // Old autofill input fields are still installed and reused.
2602  for (NSString* key in oldContext.allKeys) {
2603  XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
2604  }
2605 
2606  // Switch to a password field that has no contentType and is not in an AutofillGroup.
2607  config = self.mutablePasswordTemplateCopy;
2608 
2609  oldContext = textInputPlugin.autofillContext;
2610  [self setClientId:124 configuration:config];
2611  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2612 
2613  XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
2614  XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul);
2615 
2616  [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2617  XCTAssertEqual(self.installedInputViews.count, 4ul);
2618 
2619  // Old autofill input fields are still installed and reused.
2620  for (NSString* key in oldContext.allKeys) {
2621  XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
2622  }
2623  // The active view should change.
2624  XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
2625  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2626 
2627  // Switch to a similar password field, the previous field should be reused.
2628  oldContext = textInputPlugin.autofillContext;
2629  [self setClientId:200 configuration:config];
2630 
2631  // Reuse the input view instance from the last time.
2632  XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
2633  XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul);
2634 
2635  [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2636  XCTAssertEqual(self.installedInputViews.count, 4ul);
2637 
2638  // Old autofill input fields are still installed and reused.
2639  for (NSString* key in oldContext.allKeys) {
2640  XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
2641  }
2642  XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
2643  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2644 }
2645 
2646 - (void)testCommitAutofillContext {
2647  NSMutableDictionary* field1 = self.mutableTemplateCopy;
2648  [field1 setValue:@{
2649  @"uniqueIdentifier" : @"field1",
2650  @"hints" : @[ @"hint1" ],
2651  @"editingValue" : @{@"text" : @""}
2652  }
2653  forKey:@"autofill"];
2654 
2655  NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
2656  [field2 setValue:@{
2657  @"uniqueIdentifier" : @"field2",
2658  @"hints" : @[ @"hint2" ],
2659  @"editingValue" : @{@"text" : @""}
2660  }
2661  forKey:@"autofill"];
2662 
2663  NSMutableDictionary* field3 = self.mutableTemplateCopy;
2664  [field3 setValue:@{
2665  @"uniqueIdentifier" : @"field3",
2666  @"hints" : @[ @"hint3" ],
2667  @"editingValue" : @{@"text" : @""}
2668  }
2669  forKey:@"autofill"];
2670 
2671  NSMutableDictionary* config = [field1 mutableCopy];
2672  [config setValue:@[ field1, field2 ] forKey:@"fields"];
2673 
2674  [self setClientId:123 configuration:config];
2675  XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
2676  XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
2677  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2678 
2679  [self commitAutofillContextAndVerify];
2680  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2681 
2682  // Install the password field again.
2683  [self setClientId:123 configuration:config];
2684  // Switch to a regular autofill group.
2685  [self setClientId:124 configuration:field3];
2686  XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
2687 
2688  [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2689  XCTAssertEqual(self.installedInputViews.count, 3ul);
2690  XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
2691  XCTAssertNotEqual(textInputPlugin.textInputView, nil);
2692  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2693 
2694  [self commitAutofillContextAndVerify];
2695  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2696 
2697  // Now switch to an input field that does not autofill.
2698  [self setClientId:125 configuration:self.mutableTemplateCopy];
2699 
2700  XCTAssertEqual(self.viewsVisibleToAutofill.count, 0ul);
2701  // The active view should still be installed so it doesn't get
2702  // deallocated.
2703 
2704  [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2705  XCTAssertEqual(self.installedInputViews.count, 1ul);
2706  XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul);
2707  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2708 
2709  [self commitAutofillContextAndVerify];
2710  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2711 }
2712 
2713 - (void)testAutofillInputViews {
2714  NSMutableDictionary* field1 = self.mutableTemplateCopy;
2715  [field1 setValue:@{
2716  @"uniqueIdentifier" : @"field1",
2717  @"hints" : @[ @"hint1" ],
2718  @"editingValue" : @{@"text" : @""}
2719  }
2720  forKey:@"autofill"];
2721 
2722  NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
2723  [field2 setValue:@{
2724  @"uniqueIdentifier" : @"field2",
2725  @"hints" : @[ @"hint2" ],
2726  @"editingValue" : @{@"text" : @""}
2727  }
2728  forKey:@"autofill"];
2729 
2730  NSMutableDictionary* config = [field1 mutableCopy];
2731  [config setValue:@[ field1, field2 ] forKey:@"fields"];
2732 
2733  [self setClientId:123 configuration:config];
2734  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2735 
2736  // Find all the FlutterTextInputViews we created.
2737  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
2738 
2739  // Both fields are installed and visible because it's a password group.
2740  XCTAssertEqual(inputFields.count, 2ul);
2741  XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul);
2742 
2743  // Find the inactive autofillable input field.
2744  FlutterTextInputView* inactiveView = inputFields[1];
2745  [inactiveView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 0)]
2746  withText:@"Autofilled!"];
2747  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2748 
2749  // Verify behavior.
2750  OCMVerify([engine flutterTextInputView:inactiveView
2751  updateEditingClient:0
2752  withState:[OCMArg isNotNil]
2753  withTag:@"field2"]);
2754 }
2755 
2756 - (void)testAutofillContextPersistsAfterClearClient {
2757  NSMutableDictionary* field1 = self.mutableTemplateCopy;
2758  [field1 setValue:@{
2759  @"uniqueIdentifier" : @"field1",
2760  @"hints" : @[ @"username" ],
2761  @"editingValue" : @{@"text" : @""}
2762  }
2763  forKey:@"autofill"];
2764 
2765  NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy;
2766  [field2 setValue:@{
2767  @"uniqueIdentifier" : @"field2",
2768  @"hints" : @[ @"password" ],
2769  @"editingValue" : @{@"text" : @""}
2770  }
2771  forKey:@"autofill"];
2772 
2773  NSMutableDictionary* config = [field1 mutableCopy];
2774  [config setValue:@[ field1, field2 ] forKey:@"fields"];
2775 
2776  // Verify initial state.
2777  [self setClientId:123 configuration:config];
2778  XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
2779  XCTAssertFalse(textInputPlugin.pendingAutofillRemoval);
2780 
2781  // Retain autofill context.
2782  [self setClientClear];
2783  XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
2784  XCTAssertTrue(textInputPlugin.pendingAutofillRemoval);
2785 
2786  // Consume autofill context.
2787  [self commitAutofillContextAndVerify];
2788  XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul);
2789  XCTAssertFalse(textInputPlugin.pendingAutofillRemoval);
2790 }
2791 
2792 - (void)testSetClientResetsPendingAutofillRemoval {
2793  // When autofill context exists and clearClient sets pendingAutofillRemoval,
2794  // a subsequent setClient should reset the flag because a new client is
2795  // connecting and the deferred removal is no longer needed.
2796  NSMutableDictionary* field = self.mutablePasswordTemplateCopy;
2797  [field setValue:@{
2798  @"uniqueIdentifier" : @"field1",
2799  @"hints" : @[ @"password" ],
2800  @"editingValue" : @{@"text" : @""}
2801  }
2802  forKey:@"autofill"];
2803  [field setValue:@[ field ] forKey:@"fields"];
2804 
2805  // Set up autofill context.
2806  [self setClientId:123 configuration:field];
2807  XCTAssertGreaterThan(textInputPlugin.autofillContext.count, 0ul);
2808 
2809  // clearClient with autofill context sets pendingAutofillRemoval.
2810  [self setClientClear];
2811  XCTAssertTrue(textInputPlugin.pendingAutofillRemoval);
2812 
2813  // A new setClient resets the pending autofill removal flag.
2814  [self setClientId:456 configuration:self.mutableTemplateCopy];
2815  XCTAssertFalse(textInputPlugin.pendingAutofillRemoval);
2816  XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
2817 }
2818 
2819 - (void)testPendingInputViewRemovalAfterClearClient {
2820  // When autofillContext is empty and the view is first responder,
2821  // clearClient should set pendingInputViewRemoval,
2822  // and hideTextInput should consume it.
2823  NSDictionary* config = self.mutableTemplateCopy;
2824 
2825  // Verify initial state.
2826  [self setClientId:123 configuration:config];
2827  XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul);
2828  XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
2829 
2830  // Stub isFirstResponder to simulate the view being first responder.
2831  // In the test environment, becomeFirstResponder does not work.
2832  id mockActiveView = OCMPartialMock(textInputPlugin.activeView);
2833  OCMStub([mockActiveView isFirstResponder]).andReturn(YES);
2834 
2835  // clearClient with no autofill context sets pendingInputViewRemoval.
2836  [self setClientClear];
2837  XCTAssertTrue(textInputPlugin.pendingInputViewRemoval);
2838  XCTAssertFalse(textInputPlugin.pendingAutofillRemoval);
2839 
2840  // hideTextInput consumes the flag and removes the view.
2841  [self setTextInputHide];
2842  XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
2843  XCTAssertNil(textInputPlugin.activeView.superview);
2844 }
2845 
2846 - (void)testHideBeforeClearClientRemovesViewImmediately {
2847  // When hideTextInput is called before clearTextInputClient,
2848  // the view is no longer first responder. clearClient should
2849  // remove the view immediately since no keyboard dismiss animation is needed.
2850  NSDictionary* config = self.mutableTemplateCopy;
2851 
2852  [self setClientId:123 configuration:config];
2853  [self setTextInputShow];
2854  XCTAssertNotNil(textInputPlugin.activeView.superview);
2855 
2856  // Hide first: resignFirstResponder, but no removeFromSuperview.
2857  [self setTextInputHide];
2858  XCTAssertNotNil(textInputPlugin.activeView.superview);
2859 
2860  // clearClient after hide: view is not first responder,
2861  // so removeFromSuperview should happen immediately.
2862  [self setClientClear];
2863  XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
2864  XCTAssertNil(textInputPlugin.activeView.superview);
2865 }
2866 
2867 - (void)testSetClientResetsPendingInputViewRemoval {
2868  // When clearClient sets pendingInputViewRemoval (no autofill, first responder),
2869  // a subsequent setClient should reset the flag because a new client is
2870  // connecting and the deferred removal is no longer needed.
2871  NSDictionary* config = self.mutableTemplateCopy;
2872 
2873  // Field 1: setClient → show → clearClient (sets pendingInputViewRemoval).
2874  [self setClientId:123 configuration:config];
2875  [self setTextInputShow];
2876 
2877  // Stub isFirstResponder to simulate the view being first responder.
2878  // In the test environment, becomeFirstResponder does not work.
2879  id mockActiveView = OCMPartialMock(textInputPlugin.activeView);
2880  OCMStub([mockActiveView isFirstResponder]).andReturn(YES);
2881 
2882  [self setClientClear];
2883  XCTAssertTrue(textInputPlugin.pendingInputViewRemoval);
2884 
2885  // Field 2: setClient resets the stale flag.
2886  [self setClientId:456 configuration:config];
2887  XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
2888 }
2889 
2890 - (void)testPasswordAutofillHack {
2891  NSDictionary* config = self.mutableTemplateCopy;
2892  [config setValue:@"YES" forKey:@"obscureText"];
2893  [self setClientId:123 configuration:config];
2894 
2895  // Find all the FlutterTextInputViews we created.
2896  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
2897 
2898  FlutterTextInputView* inputView = inputFields[0];
2899 
2900  XCTAssert([inputView isKindOfClass:[UITextField class]]);
2901  // FlutterSecureTextInputView does not respond to font,
2902  // but it should return the default UITextField.font.
2903  XCTAssertNotEqual([inputView performSelector:@selector(font)], nil);
2904 }
2905 
2906 - (void)testClearAutofillContextClearsSelection {
2907  NSMutableDictionary* regularField = self.mutableTemplateCopy;
2908  NSDictionary* editingValue = @{
2909  @"text" : @"REGULAR_TEXT_FIELD",
2910  @"composingBase" : @0,
2911  @"composingExtent" : @3,
2912  @"selectionBase" : @1,
2913  @"selectionExtent" : @4
2914  };
2915  [regularField setValue:@{
2916  @"uniqueIdentifier" : @"field2",
2917  @"hints" : @[ @"hint2" ],
2918  @"editingValue" : editingValue,
2919  }
2920  forKey:@"autofill"];
2921  [regularField addEntriesFromDictionary:editingValue];
2922  [self setClientId:123 configuration:regularField];
2923  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2924  XCTAssertEqual(self.installedInputViews.count, 1ul);
2925 
2926  FlutterTextInputView* oldInputView = self.installedInputViews[0];
2927  XCTAssert([oldInputView.text isEqualToString:@"REGULAR_TEXT_FIELD"]);
2928  FlutterTextRange* selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange;
2929  XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(1, 3)));
2930 
2931  // Replace the original password field with new one. This should remove
2932  // the old password field, but not immediately.
2933  [self setClientId:124 configuration:self.mutablePasswordTemplateCopy];
2934  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2935 
2936  XCTAssertEqual(self.installedInputViews.count, 2ul);
2937 
2938  [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO];
2939  XCTAssertEqual(self.installedInputViews.count, 1ul);
2940 
2941  // Verify the old input view is properly cleaned up.
2942  XCTAssert([oldInputView.text isEqualToString:@""]);
2943  selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange;
2944  XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(0, 0)));
2945 }
2946 
2947 - (void)testGarbageInputViewsAreNotRemovedImmediately {
2948  // Add a password field that should autofill.
2949  [self setClientId:123 configuration:self.mutablePasswordTemplateCopy];
2950  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2951 
2952  XCTAssertEqual(self.installedInputViews.count, 1ul);
2953  // Add an input field that doesn't autofill. This should remove the password
2954  // field, but not immediately.
2955  [self setClientId:124 configuration:self.mutableTemplateCopy];
2956  [self ensureOnlyActiveViewCanBecomeFirstResponder];
2957 
2958  XCTAssertEqual(self.installedInputViews.count, 2ul);
2959 
2960  [self commitAutofillContextAndVerify];
2961 }
2962 
2963 - (void)testScribbleSetSelectionRects {
2964  NSMutableDictionary* regularField = self.mutableTemplateCopy;
2965  NSDictionary* editingValue = @{
2966  @"text" : @"REGULAR_TEXT_FIELD",
2967  @"composingBase" : @0,
2968  @"composingExtent" : @3,
2969  @"selectionBase" : @1,
2970  @"selectionExtent" : @4
2971  };
2972  [regularField setValue:@{
2973  @"uniqueIdentifier" : @"field1",
2974  @"hints" : @[ @"hint2" ],
2975  @"editingValue" : editingValue,
2976  }
2977  forKey:@"autofill"];
2978  [regularField addEntriesFromDictionary:editingValue];
2979  [self setClientId:123 configuration:regularField];
2980  XCTAssertEqual(self.installedInputViews.count, 1ul);
2981  XCTAssertEqual([textInputPlugin.activeView.selectionRects count], 0u);
2982 
2983  NSArray<NSNumber*>* selectionRect = [NSArray arrayWithObjects:@0, @0, @100, @100, @0, @1, nil];
2984  NSArray* selectionRects = [NSArray arrayWithObjects:selectionRect, nil];
2985  FlutterMethodCall* methodCall =
2986  [FlutterMethodCall methodCallWithMethodName:@"Scribble.setSelectionRects"
2987  arguments:selectionRects];
2988  [textInputPlugin handleMethodCall:methodCall
2989  result:^(id _Nullable result){
2990  }];
2991 
2992  XCTAssertEqual([textInputPlugin.activeView.selectionRects count], 1u);
2993 }
2994 
2995 - (void)testDecommissionedViewAreNotReusedByAutofill {
2996  // Regression test for https://github.com/flutter/flutter/issues/84407.
2997  NSMutableDictionary* configuration = self.mutableTemplateCopy;
2998  [configuration setValue:@{
2999  @"uniqueIdentifier" : @"field1",
3000  @"hints" : @[ UITextContentTypePassword ],
3001  @"editingValue" : @{@"text" : @""}
3002  }
3003  forKey:@"autofill"];
3004  [configuration setValue:@[ [configuration copy] ] forKey:@"fields"];
3005 
3006  [self setClientId:123 configuration:configuration];
3007 
3008  [self setTextInputHide];
3009  UIView* previousActiveView = textInputPlugin.activeView;
3010 
3011  [self setClientId:124 configuration:configuration];
3012 
3013  // Make sure the autofillable view is reused.
3014  XCTAssertEqual(previousActiveView, textInputPlugin.activeView);
3015  XCTAssertNotNil(previousActiveView);
3016  // Does not crash.
3017 }
3018 
3019 - (void)testInitialActiveViewCantAccessTextInputDelegate {
3020  // Before the framework sends the first text input configuration,
3021  // the dummy "activeView" we use should never have access to
3022  // its textInputDelegate.
3023  XCTAssertNil(textInputPlugin.activeView.textInputDelegate);
3024 }
3025 
3026 - (void)testAutoFillDoesNotTriggerOnShowAndHideKeyboard {
3027  // Regression test for https://github.com/flutter/flutter/issues/145681.
3028  NSMutableDictionary* configuration = self.mutableTemplateCopy;
3029  [configuration setValue:@{
3030  @"uniqueIdentifier" : @"field1",
3031  @"hints" : @[ UITextContentTypePassword ],
3032  @"editingValue" : @{@"text" : @""}
3033  }
3034  forKey:@"autofill"];
3035  [configuration setValue:@[ [configuration copy] ] forKey:@"fields"];
3036  [self setClientId:123 configuration:configuration];
3037 
3038  [self setTextInputShow];
3039  XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
3040 
3041  // Hiding keyboard does not trigger showing autofill prompt.
3042  [self setTextInputHide];
3043  XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul);
3044 
3045  [self commitAutofillContextAndVerify];
3046 }
3047 
3048 #pragma mark - Accessibility - Tests
3049 
3050 - (void)testUITextInputAccessibilityNotHiddenWhenKeyboardIsShownAndHidden {
3051  [self setClientId:123 configuration:self.mutableTemplateCopy];
3052 
3053  // Find all the FlutterTextInputViews we created.
3054  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
3055 
3056  // The input view should not be hidden.
3057  XCTAssertEqual([inputFields count], 1u);
3058 
3059  // Send show text input method call.
3060  [self setTextInputShow];
3061 
3062  inputFields = self.installedInputViews;
3063 
3064  XCTAssertEqual([inputFields count], 1u);
3065 
3066  // Send hide text input method call.
3067  [self setTextInputHide];
3068 
3069  inputFields = self.installedInputViews;
3070 
3071  XCTAssertEqual([inputFields count], 1u);
3072 
3073  // Send clear text client method call.
3074  [self setClientClear];
3075 
3076  inputFields = self.installedInputViews;
3077 
3078  // The input view should be hidden.
3079  XCTAssertEqual([inputFields count], 0u);
3080 }
3081 
3082 - (void)testFlutterTextInputViewDirectFocusToBackingTextInput {
3083  FlutterTextInputViewSpy* inputView =
3084  [[FlutterTextInputViewSpy alloc] initWithOwner:textInputPlugin];
3085  UIView* container = [[UIView alloc] init];
3086  UIAccessibilityElement* backing =
3087  [[UIAccessibilityElement alloc] initWithAccessibilityContainer:container];
3088  inputView.backingTextInputAccessibilityObject = backing;
3089  // Simulate accessibility focus.
3090  inputView.isAccessibilityFocused = YES;
3091  [inputView accessibilityElementDidBecomeFocused];
3092 
3093  XCTAssertEqual(inputView.receivedNotification, UIAccessibilityScreenChangedNotification);
3094  XCTAssertEqual(inputView.receivedNotificationTarget, backing);
3095 }
3096 
3097 - (void)testFlutterTokenizerCanParseLines {
3098  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3099  id<UITextInputTokenizer> tokenizer = [inputView tokenizer];
3100 
3101  // The tokenizer returns zero range When text is empty.
3102  FlutterTextRange* range = [self getLineRangeFromTokenizer:tokenizer atIndex:0];
3103  XCTAssertEqual(range.range.location, 0u);
3104  XCTAssertEqual(range.range.length, 0u);
3105 
3106  [inputView insertText:@"how are you\nI am fine, Thank you"];
3107 
3108  range = [self getLineRangeFromTokenizer:tokenizer atIndex:0];
3109  XCTAssertEqual(range.range.location, 0u);
3110  XCTAssertEqual(range.range.length, 11u);
3111 
3112  range = [self getLineRangeFromTokenizer:tokenizer atIndex:2];
3113  XCTAssertEqual(range.range.location, 0u);
3114  XCTAssertEqual(range.range.length, 11u);
3115 
3116  range = [self getLineRangeFromTokenizer:tokenizer atIndex:11];
3117  XCTAssertEqual(range.range.location, 0u);
3118  XCTAssertEqual(range.range.length, 11u);
3119 
3120  range = [self getLineRangeFromTokenizer:tokenizer atIndex:12];
3121  XCTAssertEqual(range.range.location, 12u);
3122  XCTAssertEqual(range.range.length, 20u);
3123 
3124  range = [self getLineRangeFromTokenizer:tokenizer atIndex:15];
3125  XCTAssertEqual(range.range.location, 12u);
3126  XCTAssertEqual(range.range.length, 20u);
3127 
3128  range = [self getLineRangeFromTokenizer:tokenizer atIndex:32];
3129  XCTAssertEqual(range.range.location, 12u);
3130  XCTAssertEqual(range.range.length, 20u);
3131 }
3132 
3133 - (void)testFlutterTokenizerLineEnclosingEndOfDocumentInBackwardDirectionShouldNotReturnNil {
3134  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3135  [inputView insertText:@"0123456789\n012345"];
3136  id<UITextInputTokenizer> tokenizer = [inputView tokenizer];
3137 
3138  FlutterTextRange* range =
3139  (FlutterTextRange*)[tokenizer rangeEnclosingPosition:[inputView endOfDocument]
3140  withGranularity:UITextGranularityLine
3141  inDirection:UITextStorageDirectionBackward];
3142  XCTAssertEqual(range.range.location, 11u);
3143  XCTAssertEqual(range.range.length, 6u);
3144 }
3145 
3146 - (void)testFlutterTokenizerLineEnclosingEndOfDocumentInForwardDirectionShouldReturnNilOnIOS17 {
3147  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3148  [inputView insertText:@"0123456789\n012345"];
3149  id<UITextInputTokenizer> tokenizer = [inputView tokenizer];
3150 
3151  FlutterTextRange* range =
3152  (FlutterTextRange*)[tokenizer rangeEnclosingPosition:[inputView endOfDocument]
3153  withGranularity:UITextGranularityLine
3154  inDirection:UITextStorageDirectionForward];
3155  if (@available(iOS 17.0, *)) {
3156  XCTAssertNil(range);
3157  } else {
3158  XCTAssertEqual(range.range.location, 11u);
3159  XCTAssertEqual(range.range.length, 6u);
3160  }
3161 }
3162 
3163 - (void)testFlutterTokenizerLineEnclosingOutOfRangePositionShouldReturnNilOnIOS17 {
3164  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3165  [inputView insertText:@"0123456789\n012345"];
3166  id<UITextInputTokenizer> tokenizer = [inputView tokenizer];
3167 
3169  FlutterTextRange* range =
3170  (FlutterTextRange*)[tokenizer rangeEnclosingPosition:position
3171  withGranularity:UITextGranularityLine
3172  inDirection:UITextStorageDirectionForward];
3173  if (@available(iOS 17.0, *)) {
3174  XCTAssertNil(range);
3175  } else {
3176  XCTAssertEqual(range.range.location, 0u);
3177  XCTAssertEqual(range.range.length, 0u);
3178  }
3179 }
3180 
3181 - (void)testFlutterTextInputPluginRetainsFlutterTextInputView {
3182  FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
3183  FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
3184  myInputPlugin.viewController = flutterViewController;
3185 
3186  __weak UIView* activeView;
3187  @autoreleasepool {
3188  FlutterMethodCall* setClientCall = [FlutterMethodCall
3189  methodCallWithMethodName:@"TextInput.setClient"
3190  arguments:@[
3191  [NSNumber numberWithInt:123], self.mutablePasswordTemplateCopy
3192  ]];
3193  [myInputPlugin handleMethodCall:setClientCall
3194  result:^(id _Nullable result){
3195  }];
3196  activeView = myInputPlugin.textInputView;
3197  FlutterMethodCall* hideCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.hide"
3198  arguments:@[]];
3199  [myInputPlugin handleMethodCall:hideCall
3200  result:^(id _Nullable result){
3201  }];
3202  XCTAssertNotNil(activeView);
3203  }
3204  // This assert proves the myInputPlugin.textInputView is not deallocated.
3205  XCTAssertNotNil(activeView);
3206 }
3207 
3208 - (void)testFlutterTextInputPluginHostViewNilCrash {
3209  FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
3210  myInputPlugin.viewController = nil;
3211  XCTAssertThrows([myInputPlugin hostView], @"Throws exception if host view is nil");
3212 }
3213 
3214 - (void)testFlutterTextInputPluginHostViewNotNil {
3215  FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
3216  FlutterEngine* flutterEngine = [[FlutterEngine alloc] init];
3217  [flutterEngine runWithEntrypoint:nil];
3218  flutterEngine.viewController = flutterViewController;
3219  XCTAssertNotNil(flutterEngine.textInputPlugin.viewController);
3220  XCTAssertNotNil([flutterEngine.textInputPlugin hostView]);
3221 }
3222 
3223 - (void)testSetPlatformViewClient {
3224  FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
3225  FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
3226  myInputPlugin.viewController = flutterViewController;
3227 
3228  FlutterMethodCall* setClientCall = [FlutterMethodCall
3229  methodCallWithMethodName:@"TextInput.setClient"
3230  arguments:@[ [NSNumber numberWithInt:123], self.mutablePasswordTemplateCopy ]];
3231  [myInputPlugin handleMethodCall:setClientCall
3232  result:^(id _Nullable result){
3233  }];
3234  UIView* activeView = myInputPlugin.textInputView;
3235  XCTAssertNotNil(activeView.superview, @"activeView must be added to the view hierarchy.");
3236  FlutterMethodCall* setPlatformViewClientCall = [FlutterMethodCall
3237  methodCallWithMethodName:@"TextInput.setPlatformViewClient"
3238  arguments:@{@"platformViewId" : [NSNumber numberWithLong:456]}];
3239  [myInputPlugin handleMethodCall:setPlatformViewClientCall
3240  result:^(id _Nullable result){
3241  }];
3242  XCTAssertNil(activeView.superview, @"activeView must be removed from view hierarchy.");
3243 }
3244 
3245 - (void)testEditMenu_shouldSetupEditMenuDelegateCorrectly {
3246  if (@available(iOS 16.0, *)) {
3247  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3248  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3249  XCTAssertEqual(inputView.editMenuInteraction.delegate, inputView,
3250  @"editMenuInteraction setup delegate correctly");
3251  }
3252 }
3253 
3254 - (void)testEditMenu_shouldNotPresentEditMenuIfNotFirstResponder {
3255  if (@available(iOS 16.0, *)) {
3256  FlutterTextInputPlugin* myInputPlugin =
3257  [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
3258  BOOL shownEditMenu = [myInputPlugin showEditMenu:@{}];
3259  XCTAssertFalse(shownEditMenu, @"Should not show edit menu if not first responder.");
3260  }
3261 }
3262 
3263 - (void)testEditMenu_shouldPresentEditMenuWithCorrectConfiguration {
3264  if (@available(iOS 16.0, *)) {
3265  FlutterTextInputPlugin* myInputPlugin =
3266  [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
3267  FlutterViewController* myViewController = [[FlutterViewController alloc] init];
3268  myInputPlugin.viewController = myViewController;
3269  [myViewController loadView];
3270  FlutterMethodCall* setClientCall =
3271  [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
3272  arguments:@[ @(123), self.mutableTemplateCopy ]];
3273  [myInputPlugin handleMethodCall:setClientCall
3274  result:^(id _Nullable result){
3275  }];
3276 
3277  FlutterTextInputView* myInputView = myInputPlugin.activeView;
3278  FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
3279 
3280  OCMStub([mockInputView isFirstResponder]).andReturn(YES);
3281 
3282  XCTestExpectation* expectation = [[XCTestExpectation alloc]
3283  initWithDescription:@"presentEditMenuWithConfiguration must be called."];
3284 
3285  id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
3286  OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
3287  OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
3288  .andDo(^(NSInvocation* invocation) {
3289  // arguments are released once invocation is released.
3290  [invocation retainArguments];
3291  UIEditMenuConfiguration* config;
3292  [invocation getArgument:&config atIndex:2];
3293  XCTAssertEqual(config.preferredArrowDirection, UIEditMenuArrowDirectionAutomatic,
3294  @"UIEditMenuConfiguration must use automatic arrow direction.");
3295  XCTAssert(CGPointEqualToPoint(config.sourcePoint, CGPointZero),
3296  @"UIEditMenuConfiguration must have the correct point.");
3297  [expectation fulfill];
3298  });
3299 
3300  NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
3301  @{@"x" : @(0), @"y" : @(0), @"width" : @(0), @"height" : @(0)};
3302 
3303  BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}];
3304  XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
3305  [self waitForExpectations:@[ expectation ] timeout:1.0];
3306  }
3307 }
3308 
3309 - (void)testEditMenu_shouldPresentEditMenuWithCorectTargetRect {
3310  if (@available(iOS 16.0, *)) {
3311  FlutterTextInputPlugin* myInputPlugin =
3312  [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
3313  FlutterViewController* myViewController = [[FlutterViewController alloc] init];
3314  myInputPlugin.viewController = myViewController;
3315  [myViewController loadView];
3316 
3317  FlutterMethodCall* setClientCall =
3318  [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
3319  arguments:@[ @(123), self.mutableTemplateCopy ]];
3320  [myInputPlugin handleMethodCall:setClientCall
3321  result:^(id _Nullable result){
3322  }];
3323 
3324  FlutterTextInputView* myInputView = myInputPlugin.activeView;
3325 
3326  FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
3327  OCMStub([mockInputView isFirstResponder]).andReturn(YES);
3328 
3329  XCTestExpectation* expectation = [[XCTestExpectation alloc]
3330  initWithDescription:@"presentEditMenuWithConfiguration must be called."];
3331 
3332  id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
3333  OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
3334  OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
3335  .andDo(^(NSInvocation* invocation) {
3336  [expectation fulfill];
3337  });
3338 
3339  myInputView.frame = CGRectMake(10, 20, 30, 40);
3340  NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
3341  @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};
3342 
3343  BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}];
3344  XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
3345  [self waitForExpectations:@[ expectation ] timeout:1.0];
3346 
3347  CGRect targetRect =
3348  [myInputView editMenuInteraction:mockInteraction
3349  targetRectForConfiguration:OCMClassMock([UIEditMenuConfiguration class])];
3350  // the encoded target rect is in global coordinate space.
3351  XCTAssert(CGRectEqualToRect(targetRect, CGRectMake(90, 180, 300, 400)),
3352  @"targetRectForConfiguration must return the correct target rect.");
3353  }
3354 }
3355 
3356 - (void)testEditMenu_shouldPresentEditMenuWithSuggestedItemsByDefaultIfNoFrameworkData {
3357  if (@available(iOS 16.0, *)) {
3358  FlutterTextInputPlugin* myInputPlugin =
3359  [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
3360  FlutterViewController* myViewController = [[FlutterViewController alloc] init];
3361  myInputPlugin.viewController = myViewController;
3362  [myViewController loadView];
3363 
3364  FlutterMethodCall* setClientCall =
3365  [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
3366  arguments:@[ @(123), self.mutableTemplateCopy ]];
3367  [myInputPlugin handleMethodCall:setClientCall
3368  result:^(id _Nullable result){
3369  }];
3370 
3371  FlutterTextInputView* myInputView = myInputPlugin.activeView;
3372 
3373  FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
3374  OCMStub([mockInputView isFirstResponder]).andReturn(YES);
3375 
3376  XCTestExpectation* expectation = [[XCTestExpectation alloc]
3377  initWithDescription:@"presentEditMenuWithConfiguration must be called."];
3378 
3379  id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
3380  OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
3381  OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
3382  .andDo(^(NSInvocation* invocation) {
3383  [expectation fulfill];
3384  });
3385 
3386  myInputView.frame = CGRectMake(10, 20, 30, 40);
3387  NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
3388  @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};
3389  // No items provided from framework. Show the suggested items by default.
3390  BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}];
3391  XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
3392  [self waitForExpectations:@[ expectation ] timeout:1.0];
3393 
3394  UICommand* copyItem = [UICommand commandWithTitle:@"Copy"
3395  image:nil
3396  action:@selector(copy:)
3397  propertyList:nil];
3398  UICommand* pasteItem = [UICommand commandWithTitle:@"Paste"
3399  image:nil
3400  action:@selector(paste:)
3401  propertyList:nil];
3402  NSArray<UICommand*>* suggestedActions = @[ copyItem, pasteItem ];
3403 
3404  UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
3405  menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
3406  suggestedActions:suggestedActions];
3407  XCTAssertEqualObjects(menu.children, suggestedActions,
3408  @"Must show suggested items by default.");
3409  }
3410 }
3411 
3412 - (void)testEditMenu_shouldPresentEditMenuWithCorectItemsAndCorrectOrderingForBasicEditingActions {
3413  if (@available(iOS 16.0, *)) {
3414  FlutterTextInputPlugin* myInputPlugin =
3415  [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
3416  FlutterViewController* myViewController = [[FlutterViewController alloc] init];
3417  myInputPlugin.viewController = myViewController;
3418  [myViewController loadView];
3419 
3420  FlutterMethodCall* setClientCall =
3421  [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
3422  arguments:@[ @(123), self.mutableTemplateCopy ]];
3423  [myInputPlugin handleMethodCall:setClientCall
3424  result:^(id _Nullable result){
3425  }];
3426 
3427  FlutterTextInputView* myInputView = myInputPlugin.activeView;
3428 
3429  FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
3430  OCMStub([mockInputView isFirstResponder]).andReturn(YES);
3431 
3432  XCTestExpectation* expectation = [[XCTestExpectation alloc]
3433  initWithDescription:@"presentEditMenuWithConfiguration must be called."];
3434 
3435  id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
3436  OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
3437  OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
3438  .andDo(^(NSInvocation* invocation) {
3439  [expectation fulfill];
3440  });
3441 
3442  myInputView.frame = CGRectMake(10, 20, 30, 40);
3443  NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
3444  @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};
3445 
3446  NSArray<NSDictionary<NSString*, id>*>* encodedItems =
3447  @[ @{@"type" : @"paste"}, @{@"type" : @"copy"} ];
3448 
3449  BOOL shownEditMenu =
3450  [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}];
3451  XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
3452  [self waitForExpectations:@[ expectation ] timeout:1.0];
3453 
3454  UICommand* copyItem = [UICommand commandWithTitle:@"Copy"
3455  image:nil
3456  action:@selector(copy:)
3457  propertyList:nil];
3458  UICommand* pasteItem = [UICommand commandWithTitle:@"Paste"
3459  image:nil
3460  action:@selector(paste:)
3461  propertyList:nil];
3462  NSArray<UICommand*>* suggestedActions = @[ copyItem, pasteItem ];
3463 
3464  UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
3465  menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
3466  suggestedActions:suggestedActions];
3467  // The item ordering should follow the encoded data sent from the framework.
3468  NSArray<UICommand*>* expectedChildren = @[ pasteItem, copyItem ];
3469  XCTAssertEqualObjects(menu.children, expectedChildren);
3470  }
3471 }
3472 
3473 - (void)testEditMenu_shouldPresentEditMenuWithCorectItemsUnderNestedSubtreeForBasicEditingActions {
3474  if (@available(iOS 16.0, *)) {
3475  FlutterTextInputPlugin* myInputPlugin =
3476  [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
3477  FlutterViewController* myViewController = [[FlutterViewController alloc] init];
3478  myInputPlugin.viewController = myViewController;
3479  [myViewController loadView];
3480 
3481  FlutterMethodCall* setClientCall =
3482  [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
3483  arguments:@[ @(123), self.mutableTemplateCopy ]];
3484  [myInputPlugin handleMethodCall:setClientCall
3485  result:^(id _Nullable result){
3486  }];
3487 
3488  FlutterTextInputView* myInputView = myInputPlugin.activeView;
3489 
3490  FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
3491  OCMStub([mockInputView isFirstResponder]).andReturn(YES);
3492 
3493  XCTestExpectation* expectation = [[XCTestExpectation alloc]
3494  initWithDescription:@"presentEditMenuWithConfiguration must be called."];
3495 
3496  id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
3497  OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
3498  OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
3499  .andDo(^(NSInvocation* invocation) {
3500  [expectation fulfill];
3501  });
3502 
3503  myInputView.frame = CGRectMake(10, 20, 30, 40);
3504  NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
3505  @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};
3506 
3507  NSArray<NSDictionary<NSString*, id>*>* encodedItems =
3508  @[ @{@"type" : @"cut"}, @{@"type" : @"paste"}, @{@"type" : @"copy"} ];
3509 
3510  BOOL shownEditMenu =
3511  [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}];
3512  XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
3513  [self waitForExpectations:@[ expectation ] timeout:1.0];
3514 
3515  UICommand* copyItem = [UICommand commandWithTitle:@"Copy"
3516  image:nil
3517  action:@selector(copy:)
3518  propertyList:nil];
3519  UICommand* cutItem = [UICommand commandWithTitle:@"Cut"
3520  image:nil
3521  action:@selector(cut:)
3522  propertyList:nil];
3523  UICommand* pasteItem = [UICommand commandWithTitle:@"Paste"
3524  image:nil
3525  action:@selector(paste:)
3526  propertyList:nil];
3527  /*
3528  A more complex menu hierarchy for DFS:
3529 
3530  menu
3531  / | \
3532  copy menu menu
3533  | \
3534  paste menu
3535  |
3536  cut
3537  */
3538  NSArray<UIMenuElement*>* suggestedActions = @[
3539  copyItem, [UIMenu menuWithChildren:@[ pasteItem ]],
3540  [UIMenu menuWithChildren:@[ [UIMenu menuWithChildren:@[ cutItem ]] ]]
3541  ];
3542 
3543  UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
3544  menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
3545  suggestedActions:suggestedActions];
3546  // The item ordering should follow the encoded data sent from the framework.
3547  NSArray<UICommand*>* expectedActions = @[ cutItem, pasteItem, copyItem ];
3548  XCTAssertEqualObjects(menu.children, expectedActions);
3549  }
3550 }
3551 
3552 - (void)testEditMenu_shouldPresentEditMenuWithCorectItemsForMoreAdditionalItems {
3553  if (@available(iOS 16.0, *)) {
3554  FlutterTextInputPlugin* myInputPlugin =
3555  [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
3556  FlutterViewController* myViewController = [[FlutterViewController alloc] init];
3557  myInputPlugin.viewController = myViewController;
3558  [myViewController loadView];
3559 
3560  FlutterMethodCall* setClientCall =
3561  [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
3562  arguments:@[ @(123), self.mutableTemplateCopy ]];
3563  [myInputPlugin handleMethodCall:setClientCall
3564  result:^(id _Nullable result){
3565  }];
3566 
3567  FlutterTextInputView* myInputView = myInputPlugin.activeView;
3568 
3569  FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
3570  OCMStub([mockInputView isFirstResponder]).andReturn(YES);
3571 
3572  XCTestExpectation* expectation = [[XCTestExpectation alloc]
3573  initWithDescription:@"presentEditMenuWithConfiguration must be called."];
3574 
3575  id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
3576  OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
3577  OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]])
3578  .andDo(^(NSInvocation* invocation) {
3579  [expectation fulfill];
3580  });
3581 
3582  myInputView.frame = CGRectMake(10, 20, 30, 40);
3583  NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
3584  @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};
3585 
3586  NSArray<NSDictionary<NSString*, id>*>* encodedItems = @[
3587  @{@"type" : @"searchWeb", @"title" : @"Search Web"},
3588  @{@"type" : @"lookUp", @"title" : @"Look Up"}, @{@"type" : @"share", @"title" : @"Share"}
3589  ];
3590 
3591  BOOL shownEditMenu =
3592  [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}];
3593  XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
3594  [self waitForExpectations:@[ expectation ] timeout:1.0];
3595 
3596  NSArray<UICommand*>* suggestedActions = @[
3597  [UICommand commandWithTitle:@"copy" image:nil action:@selector(copy:) propertyList:nil],
3598  ];
3599 
3600  UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
3601  menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
3602  suggestedActions:suggestedActions];
3603  XCTAssert(menu.children.count == 3, @"There must be 3 menu items");
3604 
3605  XCTAssert(((UICommand*)menu.children[0]).action == @selector(handleSearchWebAction),
3606  @"Must create search web item in the tree.");
3607  XCTAssert(((UICommand*)menu.children[1]).action == @selector(handleLookUpAction),
3608  @"Must create look up item in the tree.");
3609  XCTAssert(((UICommand*)menu.children[2]).action == @selector(handleShareAction),
3610  @"Must create share item in the tree.");
3611  }
3612 }
3613 
3614 - (void)testInteractiveKeyboardAfterUserScrollWillResignFirstResponder {
3615  if (@available(iOS 17.0, *)) {
3616  XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
3617  "https://github.com/flutter/flutter/issues/183473");
3618  }
3619  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3620  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3621 
3622  [inputView setTextInputClient:123];
3623  [inputView reloadInputViews];
3624  [inputView becomeFirstResponder];
3625  XCTAssert(inputView.isFirstResponder);
3626 
3627  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3628  [NSNotificationCenter.defaultCenter
3629  postNotificationName:UIKeyboardWillShowNotification
3630  object:nil
3631  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3632  FlutterMethodCall* onPointerMoveCall =
3633  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3634  arguments:@{@"pointerY" : @(500)}];
3635  [textInputPlugin handleMethodCall:onPointerMoveCall
3636  result:^(id _Nullable result){
3637  }];
3638  XCTAssertFalse(inputView.isFirstResponder);
3639  textInputPlugin.cachedFirstResponder = nil;
3640 }
3641 
3642 - (void)testInteractiveKeyboardAfterUserScrollToTopOfKeyboardWillTakeScreenshot {
3643  if (@available(iOS 17.0, *)) {
3644  XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
3645  "https://github.com/flutter/flutter/issues/183473");
3646  }
3647  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3648  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3649  UIScene* scene = scenes.anyObject;
3650  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3651  UIWindowScene* windowScene = (UIWindowScene*)scene;
3652  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3653  UIWindow* window = windowScene.windows[0];
3654  [window addSubview:viewController.view];
3655 
3656  [viewController loadView];
3657 
3658  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3659  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3660 
3661  [inputView setTextInputClient:123];
3662  [inputView reloadInputViews];
3663  [inputView becomeFirstResponder];
3664 
3665  if (textInputPlugin.keyboardView.superview != nil) {
3666  for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
3667  [subView removeFromSuperview];
3668  }
3669  }
3670  XCTAssert(textInputPlugin.keyboardView.superview == nil);
3671  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3672  [NSNotificationCenter.defaultCenter
3673  postNotificationName:UIKeyboardWillShowNotification
3674  object:nil
3675  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3676  FlutterMethodCall* onPointerMoveCall =
3677  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3678  arguments:@{@"pointerY" : @(510)}];
3679  [textInputPlugin handleMethodCall:onPointerMoveCall
3680  result:^(id _Nullable result){
3681  }];
3682  XCTAssertFalse(textInputPlugin.keyboardView.superview == nil);
3683  for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
3684  [subView removeFromSuperview];
3685  }
3686  textInputPlugin.cachedFirstResponder = nil;
3687 }
3688 
3689 - (void)testInteractiveKeyboardScreenshotWillBeMovedDownAfterUserScroll {
3690  if (@available(iOS 17.0, *)) {
3691  XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
3692  "https://github.com/flutter/flutter/issues/183473");
3693  }
3694  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3695  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3696  UIScene* scene = scenes.anyObject;
3697  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3698  UIWindowScene* windowScene = (UIWindowScene*)scene;
3699  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3700  UIWindow* window = windowScene.windows[0];
3701  [window addSubview:viewController.view];
3702 
3703  [viewController loadView];
3704 
3705  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3706  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3707 
3708  [inputView setTextInputClient:123];
3709  [inputView reloadInputViews];
3710  [inputView becomeFirstResponder];
3711 
3712  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3713  [NSNotificationCenter.defaultCenter
3714  postNotificationName:UIKeyboardWillShowNotification
3715  object:nil
3716  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3717  FlutterMethodCall* onPointerMoveCall =
3718  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3719  arguments:@{@"pointerY" : @(510)}];
3720  [textInputPlugin handleMethodCall:onPointerMoveCall
3721  result:^(id _Nullable result){
3722  }];
3723  XCTAssert(textInputPlugin.keyboardView.superview != nil);
3724 
3725  XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);
3726 
3727  FlutterMethodCall* onPointerMoveCallMove =
3728  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3729  arguments:@{@"pointerY" : @(600)}];
3730  [textInputPlugin handleMethodCall:onPointerMoveCallMove
3731  result:^(id _Nullable result){
3732  }];
3733  XCTAssert(textInputPlugin.keyboardView.superview != nil);
3734 
3735  XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, 600.0);
3736 
3737  for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
3738  [subView removeFromSuperview];
3739  }
3740  textInputPlugin.cachedFirstResponder = nil;
3741 }
3742 
3743 - (void)testInteractiveKeyboardScreenshotWillBeMovedToOrginalPositionAfterUserScroll {
3744  if (@available(iOS 17.0, *)) {
3745  XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
3746  "https://github.com/flutter/flutter/issues/183473");
3747  }
3748  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3749  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3750  UIScene* scene = scenes.anyObject;
3751  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3752  UIWindowScene* windowScene = (UIWindowScene*)scene;
3753  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3754  UIWindow* window = windowScene.windows[0];
3755  [window addSubview:viewController.view];
3756 
3757  [viewController loadView];
3758 
3759  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3760  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3761 
3762  [inputView setTextInputClient:123];
3763  [inputView reloadInputViews];
3764  [inputView becomeFirstResponder];
3765 
3766  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3767  [NSNotificationCenter.defaultCenter
3768  postNotificationName:UIKeyboardWillShowNotification
3769  object:nil
3770  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3771  FlutterMethodCall* onPointerMoveCall =
3772  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3773  arguments:@{@"pointerY" : @(500)}];
3774  [textInputPlugin handleMethodCall:onPointerMoveCall
3775  result:^(id _Nullable result){
3776  }];
3777  XCTAssert(textInputPlugin.keyboardView.superview != nil);
3778  XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);
3779 
3780  FlutterMethodCall* onPointerMoveCallMove =
3781  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3782  arguments:@{@"pointerY" : @(600)}];
3783  [textInputPlugin handleMethodCall:onPointerMoveCallMove
3784  result:^(id _Nullable result){
3785  }];
3786  XCTAssert(textInputPlugin.keyboardView.superview != nil);
3787  XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, 600.0);
3788 
3789  FlutterMethodCall* onPointerMoveCallBackUp =
3790  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3791  arguments:@{@"pointerY" : @(10)}];
3792  [textInputPlugin handleMethodCall:onPointerMoveCallBackUp
3793  result:^(id _Nullable result){
3794  }];
3795  XCTAssert(textInputPlugin.keyboardView.superview != nil);
3796  XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y);
3797  for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
3798  [subView removeFromSuperview];
3799  }
3800  textInputPlugin.cachedFirstResponder = nil;
3801 }
3802 
3803 - (void)testInteractiveKeyboardFindFirstResponderRecursive {
3804  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3805  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3806  [inputView setTextInputClient:123];
3807  [inputView reloadInputViews];
3808  [inputView becomeFirstResponder];
3809 
3810  UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
3811  XCTAssertEqualObjects(inputView, firstResponder);
3812  textInputPlugin.cachedFirstResponder = nil;
3813 }
3814 
3815 - (void)testInteractiveKeyboardFindFirstResponderRecursiveInMultipleSubviews {
3816  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3817  FlutterTextInputView* subInputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3818  FlutterTextInputView* otherSubInputView =
3819  [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3820  FlutterTextInputView* subFirstResponderInputView =
3821  [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3822  [subInputView addSubview:subFirstResponderInputView];
3823  [inputView addSubview:subInputView];
3824  [inputView addSubview:otherSubInputView];
3825  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3826  [inputView setTextInputClient:123];
3827  [inputView reloadInputViews];
3828  [subInputView setTextInputClient:123];
3829  [subInputView reloadInputViews];
3830  [otherSubInputView setTextInputClient:123];
3831  [otherSubInputView reloadInputViews];
3832  [subFirstResponderInputView setTextInputClient:123];
3833  [subFirstResponderInputView reloadInputViews];
3834  [subFirstResponderInputView becomeFirstResponder];
3835 
3836  UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
3837  XCTAssertEqualObjects(subFirstResponderInputView, firstResponder);
3838  textInputPlugin.cachedFirstResponder = nil;
3839 }
3840 
3841 - (void)testInteractiveKeyboardFindFirstResponderIsNilRecursive {
3842  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3843  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3844  [inputView setTextInputClient:123];
3845  [inputView reloadInputViews];
3846 
3847  UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
3848  XCTAssertNil(firstResponder);
3849  textInputPlugin.cachedFirstResponder = nil;
3850 }
3851 
3852 - (void)testInteractiveKeyboardDidResignFirstResponderDelegateisCalledAfterDismissedKeyboard {
3853  if (@available(iOS 17.0, *)) {
3854  XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
3855  "https://github.com/flutter/flutter/issues/183473");
3856  }
3857  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3858  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3859  UIScene* scene = scenes.anyObject;
3860  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3861  UIWindowScene* windowScene = (UIWindowScene*)scene;
3862  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3863  UIWindow* window = windowScene.windows[0];
3864  [window addSubview:viewController.view];
3865 
3866  [viewController loadView];
3867 
3868  XCTestExpectation* expectation = [[XCTestExpectation alloc]
3869  initWithDescription:
3870  @"didResignFirstResponder is called after screenshot keyboard dismissed."];
3871  OCMStub([engine flutterTextInputView:[OCMArg any] didResignFirstResponderWithTextInputClient:0])
3872  .andDo(^(NSInvocation* invocation) {
3873  [expectation fulfill];
3874  });
3875  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3876  [NSNotificationCenter.defaultCenter
3877  postNotificationName:UIKeyboardWillShowNotification
3878  object:nil
3879  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3880  FlutterMethodCall* initialMoveCall =
3881  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3882  arguments:@{@"pointerY" : @(500)}];
3883  [textInputPlugin handleMethodCall:initialMoveCall
3884  result:^(id _Nullable result){
3885  }];
3886  FlutterMethodCall* subsequentMoveCall =
3887  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3888  arguments:@{@"pointerY" : @(1000)}];
3889  [textInputPlugin handleMethodCall:subsequentMoveCall
3890  result:^(id _Nullable result){
3891  }];
3892 
3893  FlutterMethodCall* pointerUpCall =
3894  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
3895  arguments:@{@"pointerY" : @(1000)}];
3896  [textInputPlugin handleMethodCall:pointerUpCall
3897  result:^(id _Nullable result){
3898  }];
3899 
3900  [self waitForExpectations:@[ expectation ] timeout:2.0];
3901  textInputPlugin.cachedFirstResponder = nil;
3902 }
3903 
3904 - (void)testInteractiveKeyboardScreenshotDismissedAfterPointerLiftedAboveMiddleYOfKeyboard {
3905  if (@available(iOS 17.0, *)) {
3906  XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
3907  "https://github.com/flutter/flutter/issues/183473");
3908  }
3909  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3910  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3911  UIScene* scene = scenes.anyObject;
3912  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3913  UIWindowScene* windowScene = (UIWindowScene*)scene;
3914  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3915  UIWindow* window = windowScene.windows[0];
3916  [window addSubview:viewController.view];
3917 
3918  [viewController loadView];
3919 
3920  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3921  [NSNotificationCenter.defaultCenter
3922  postNotificationName:UIKeyboardWillShowNotification
3923  object:nil
3924  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3925  FlutterMethodCall* initialMoveCall =
3926  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3927  arguments:@{@"pointerY" : @(500)}];
3928  [textInputPlugin handleMethodCall:initialMoveCall
3929  result:^(id _Nullable result){
3930  }];
3931  FlutterMethodCall* subsequentMoveCall =
3932  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3933  arguments:@{@"pointerY" : @(1000)}];
3934  [textInputPlugin handleMethodCall:subsequentMoveCall
3935  result:^(id _Nullable result){
3936  }];
3937 
3938  FlutterMethodCall* subsequentMoveBackUpCall =
3939  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3940  arguments:@{@"pointerY" : @(0)}];
3941  [textInputPlugin handleMethodCall:subsequentMoveBackUpCall
3942  result:^(id _Nullable result){
3943  }];
3944 
3945  FlutterMethodCall* pointerUpCall =
3946  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
3947  arguments:@{@"pointerY" : @(0)}];
3948  [textInputPlugin handleMethodCall:pointerUpCall
3949  result:^(id _Nullable result){
3950  }];
3951  NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary* bindings) {
3952  return textInputPlugin.keyboardViewContainer.subviews.count == 0;
3953  }];
3954  XCTNSPredicateExpectation* expectation =
3955  [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:nil];
3956  [self waitForExpectations:@[ expectation ] timeout:10.0];
3957  textInputPlugin.cachedFirstResponder = nil;
3958 }
3959 
3960 - (void)testInteractiveKeyboardKeyboardReappearsAfterPointerLiftedAboveMiddleYOfKeyboard {
3961  if (@available(iOS 17.0, *)) {
3962  XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
3963  "https://github.com/flutter/flutter/issues/183473");
3964  }
3965  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
3966  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
3967  UIScene* scene = scenes.anyObject;
3968  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
3969  UIWindowScene* windowScene = (UIWindowScene*)scene;
3970  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
3971  UIWindow* window = windowScene.windows[0];
3972  [window addSubview:viewController.view];
3973 
3974  [viewController loadView];
3975 
3976  FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
3977  [UIApplication.sharedApplication.keyWindow addSubview:inputView];
3978 
3979  [inputView setTextInputClient:123];
3980  [inputView reloadInputViews];
3981  [inputView becomeFirstResponder];
3982 
3983  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
3984  [NSNotificationCenter.defaultCenter
3985  postNotificationName:UIKeyboardWillShowNotification
3986  object:nil
3987  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
3988  FlutterMethodCall* initialMoveCall =
3989  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3990  arguments:@{@"pointerY" : @(500)}];
3991  [textInputPlugin handleMethodCall:initialMoveCall
3992  result:^(id _Nullable result){
3993  }];
3994  FlutterMethodCall* subsequentMoveCall =
3995  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
3996  arguments:@{@"pointerY" : @(1000)}];
3997  [textInputPlugin handleMethodCall:subsequentMoveCall
3998  result:^(id _Nullable result){
3999  }];
4000 
4001  FlutterMethodCall* subsequentMoveBackUpCall =
4002  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
4003  arguments:@{@"pointerY" : @(0)}];
4004  [textInputPlugin handleMethodCall:subsequentMoveBackUpCall
4005  result:^(id _Nullable result){
4006  }];
4007 
4008  FlutterMethodCall* pointerUpCall =
4009  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
4010  arguments:@{@"pointerY" : @(0)}];
4011  [textInputPlugin handleMethodCall:pointerUpCall
4012  result:^(id _Nullable result){
4013  }];
4014  NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary* bindings) {
4015  return textInputPlugin.cachedFirstResponder.isFirstResponder;
4016  }];
4017  XCTNSPredicateExpectation* expectation =
4018  [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:nil];
4019  [self waitForExpectations:@[ expectation ] timeout:10.0];
4020  textInputPlugin.cachedFirstResponder = nil;
4021 }
4022 
4023 - (void)testInteractiveKeyboardKeyboardAnimatesToOriginalPositionalOnPointerUp {
4024  if (@available(iOS 17.0, *)) {
4025  XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
4026  "https://github.com/flutter/flutter/issues/183473");
4027  }
4028  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
4029  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
4030  UIScene* scene = scenes.anyObject;
4031  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
4032  UIWindowScene* windowScene = (UIWindowScene*)scene;
4033  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
4034  UIWindow* window = windowScene.windows[0];
4035  [window addSubview:viewController.view];
4036 
4037  [viewController loadView];
4038 
4039  XCTestExpectation* expectation =
4040  [[XCTestExpectation alloc] initWithDescription:@"Keyboard animates to proper position."];
4041  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
4042  [NSNotificationCenter.defaultCenter
4043  postNotificationName:UIKeyboardWillShowNotification
4044  object:nil
4045  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
4046  FlutterMethodCall* initialMoveCall =
4047  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
4048  arguments:@{@"pointerY" : @(500)}];
4049  [textInputPlugin handleMethodCall:initialMoveCall
4050  result:^(id _Nullable result){
4051  }];
4052  FlutterMethodCall* subsequentMoveCall =
4053  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
4054  arguments:@{@"pointerY" : @(1000)}];
4055  [textInputPlugin handleMethodCall:subsequentMoveCall
4056  result:^(id _Nullable result){
4057  }];
4058  FlutterMethodCall* upwardVelocityMoveCall =
4059  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
4060  arguments:@{@"pointerY" : @(500)}];
4061  [textInputPlugin handleMethodCall:upwardVelocityMoveCall
4062  result:^(id _Nullable result){
4063  }];
4064 
4065  FlutterMethodCall* pointerUpCall =
4066  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
4067  arguments:@{@"pointerY" : @(0)}];
4068  [textInputPlugin
4069  handleMethodCall:pointerUpCall
4070  result:^(id _Nullable result) {
4071  XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y,
4072  viewController.flutterScreenIfViewLoaded.bounds.size.height -
4073  keyboardFrame.origin.y);
4074  [expectation fulfill];
4075  }];
4076  textInputPlugin.cachedFirstResponder = nil;
4077 }
4078 
4079 - (void)testInteractiveKeyboardKeyboardAnimatesToDismissalPositionalOnPointerUp {
4080  if (@available(iOS 17.0, *)) {
4081  XCTSkip(@"Interactive keyboard tests broken on iOS 17+ due to SDK bugs. See: "
4082  "https://github.com/flutter/flutter/issues/183473");
4083  }
4084  NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
4085  XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
4086  UIScene* scene = scenes.anyObject;
4087  XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
4088  UIWindowScene* windowScene = (UIWindowScene*)scene;
4089  XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
4090  UIWindow* window = windowScene.windows[0];
4091  [window addSubview:viewController.view];
4092 
4093  [viewController loadView];
4094 
4095  XCTestExpectation* expectation =
4096  [[XCTestExpectation alloc] initWithDescription:@"Keyboard animates to proper position."];
4097  CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
4098  [NSNotificationCenter.defaultCenter
4099  postNotificationName:UIKeyboardWillShowNotification
4100  object:nil
4101  userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
4102  FlutterMethodCall* initialMoveCall =
4103  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
4104  arguments:@{@"pointerY" : @(500)}];
4105  [textInputPlugin handleMethodCall:initialMoveCall
4106  result:^(id _Nullable result){
4107  }];
4108  FlutterMethodCall* subsequentMoveCall =
4109  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
4110  arguments:@{@"pointerY" : @(1000)}];
4111  [textInputPlugin handleMethodCall:subsequentMoveCall
4112  result:^(id _Nullable result){
4113  }];
4114 
4115  FlutterMethodCall* pointerUpCall =
4116  [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
4117  arguments:@{@"pointerY" : @(1000)}];
4118  [textInputPlugin
4119  handleMethodCall:pointerUpCall
4120  result:^(id _Nullable result) {
4121  XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y,
4122  viewController.flutterScreenIfViewLoaded.bounds.size.height);
4123  [expectation fulfill];
4124  }];
4125  textInputPlugin.cachedFirstResponder = nil;
4126 }
4127 - (void)testInteractiveKeyboardShowKeyboardAndRemoveScreenshotAnimationIsNotImmediatelyEnable {
4128  [UIView setAnimationsEnabled:YES];
4129  [textInputPlugin showKeyboardAndRemoveScreenshot];
4130  XCTAssertFalse(
4131  UIView.areAnimationsEnabled,
4132  @"The animation should still be disabled following showKeyboardAndRemoveScreenshot");
4133 }
4134 
4135 - (void)testInteractiveKeyboardShowKeyboardAndRemoveScreenshotAnimationIsReenabledAfterDelay {
4136  [UIView setAnimationsEnabled:YES];
4137  [textInputPlugin showKeyboardAndRemoveScreenshot];
4138 
4139  NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary* bindings) {
4140  // This will be enabled after a delay
4141  return UIView.areAnimationsEnabled;
4142  }];
4143  XCTNSPredicateExpectation* expectation =
4144  [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:nil];
4145  [self waitForExpectations:@[ expectation ] timeout:10.0];
4146 }
4147 
4148 - (void)testEditMenu_shouldCreateCustomMenuItemWithCorrectProperties {
4149  if (@available(iOS 16.0, *)) {
4150  FlutterTextInputPlugin* myInputPlugin =
4151  [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
4152  FlutterViewController* myViewController = [[FlutterViewController alloc] init];
4153  myInputPlugin.viewController = myViewController;
4154  [myViewController loadView];
4155 
4156  FlutterMethodCall* setClientCall =
4157  [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
4158  arguments:@[ @(123), self.mutableTemplateCopy ]];
4159  [myInputPlugin handleMethodCall:setClientCall
4160  result:^(id _Nullable result){
4161  }];
4162 
4163  FlutterTextInputView* myInputView = myInputPlugin.activeView;
4164  FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
4165  OCMStub([mockInputView isFirstResponder]).andReturn(YES);
4166 
4167  id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
4168  OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
4169 
4170  NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
4171  @{@"x" : @(0), @"y" : @(0), @"width" : @(100), @"height" : @(50)};
4172 
4173  NSArray<NSDictionary*>* encodedItems = @[
4174  @{@"type" : @"custom", @"id" : @"custom-action-1", @"title" : @"Custom Action 1"},
4175  @{@"type" : @"custom", @"id" : @"custom-action-2", @"title" : @"Custom Action 2"},
4176  ];
4177 
4178  BOOL shownEditMenu =
4179  [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}];
4180  XCTAssertTrue(shownEditMenu, @"Should show edit menu");
4181 
4182  UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
4183  menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
4184  suggestedActions:@[]];
4185 
4186  XCTAssertEqual(menu.children.count, 2UL, @"Should create 2 custom menu items");
4187  UIAction* firstAction = (UIAction*)menu.children[0];
4188  UIAction* secondAction = (UIAction*)menu.children[1];
4189  XCTAssertEqualObjects(firstAction.title, @"Custom Action 1",
4190  @"First action title should match");
4191  XCTAssertEqualObjects(secondAction.title, @"Custom Action 2",
4192  @"Second action title should match");
4193  }
4194 }
4195 
4196 - (void)testEditMenu_customActionShouldTriggerDelegateCallback {
4197  if (@available(iOS 16.0, *)) {
4198  id mockEngine = OCMClassMock([FlutterEngine class]);
4199  id mockPlatformChannel = OCMClassMock([FlutterMethodChannel class]);
4200  OCMStub([mockEngine platformChannel]).andReturn(mockPlatformChannel);
4201 
4202  OCMStub([mockEngine flutterTextInputView:[OCMArg any]
4203  performContextMenuCustomActionWithActionID:@"test-callback-id"
4204  textInputClient:123])
4205  .andDo((^(NSInvocation* invocation) {
4206  [mockPlatformChannel invokeMethod:@"ContextMenu.onPerformCustomAction"
4207  arguments:@[ @(123), @"test-callback-id" ]];
4208  }));
4209 
4210  FlutterTextInputPlugin* myInputPlugin =
4211  [[FlutterTextInputPlugin alloc] initWithDelegate:mockEngine];
4212  FlutterViewController* myViewController = [[FlutterViewController alloc] init];
4213  myInputPlugin.viewController = myViewController;
4214  [myViewController loadView];
4215 
4216  FlutterMethodCall* setClientCall =
4217  [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
4218  arguments:@[ @(123), self.mutableTemplateCopy ]];
4219  [myInputPlugin handleMethodCall:setClientCall
4220  result:^(id _Nullable result){
4221  }];
4222 
4223  FlutterTextInputView* myInputView = myInputPlugin.activeView;
4224  FlutterTextInputView* mockInputView = OCMPartialMock(myInputView);
4225  OCMStub([mockInputView isFirstResponder]).andReturn(YES);
4226  XCTestExpectation* expectation = [[XCTestExpectation alloc]
4227  initWithDescription:@"Custom action delegate callback should be called"];
4228  OCMStub(([mockPlatformChannel invokeMethod:@"ContextMenu.onPerformCustomAction"
4229  arguments:@[ @(123), @"test-callback-id" ]]))
4230  .andDo(^(NSInvocation* invocation) {
4231  [expectation fulfill];
4232  });
4233  id mockInteraction = OCMClassMock([UIEditMenuInteraction class]);
4234  OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction);
4235 
4236  NSDictionary<NSString*, NSNumber*>* encodedTargetRect =
4237  @{@"x" : @(0), @"y" : @(0), @"width" : @(100), @"height" : @(50)};
4238 
4239  NSArray<NSDictionary*>* encodedItems = @[
4240  @{@"type" : @"custom", @"id" : @"test-callback-id", @"title" : @"Test Action"},
4241  ];
4242 
4243  BOOL shownEditMenu =
4244  [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect, @"items" : encodedItems}];
4245  XCTAssertTrue(shownEditMenu, @"Should show edit menu");
4246 
4247  UIMenu* menu = [myInputView editMenuInteraction:mockInteraction
4248  menuForConfiguration:OCMClassMock([UIEditMenuConfiguration class])
4249  suggestedActions:@[]];
4250 
4251  XCTAssertEqual(menu.children.count, 1UL, @"Should have 1 custom menu item");
4252  UIAction* customAction = (UIAction*)menu.children[0];
4253  XCTAssertEqualObjects(customAction.title, @"Test Action", @"Action title should match");
4254 
4255  [myInputView.textInputDelegate flutterTextInputView:myInputView
4256  performContextMenuCustomActionWithActionID:@"test-callback-id"
4257  textInputClient:123];
4258 
4259  [self waitForExpectations:@[ expectation ] timeout:1.0];
4260  OCMVerifyAll(mockPlatformChannel);
4261  }
4262 }
4263 
4264 @end
flutter::Settings settings_
std::unique_ptr< flutter::PlatformViewIOS > platform_view
NSArray< FlutterTextSelectionRect * > * selectionRects
BOOL isScribbleAvailable
UITextRange * markedTextRange
API_AVAILABLE(ios(13.0)) @interface FlutterTextPlaceholder UITextRange * selectedTextRange
UITextSmartQuotesType smartQuotesType API_AVAILABLE(ios(11.0))
CGRect caretRectForPosition
const CGRect kInvalidFirstRect
NSDictionary * _passwordTemplate
FlutterViewController * viewController
FlutterTextInputPlugin * textInputPlugin
BOOL runWithEntrypoint:(nullable NSString *entrypoint)
void setBinaryMessenger:(FlutterBinaryMessengerRelay *binaryMessenger)
FlutterViewController * viewController
void flutterTextInputView:performAction:withClient:(FlutterTextInputView *textInputView,[performAction] FlutterTextInputAction action,[withClient] int client)
BOOL runWithEntrypoint:initialRoute:(nullable NSString *entrypoint,[initialRoute] nullable NSString *initialRoute)
instancetype methodCallWithMethodName:arguments:(NSString *method,[arguments] id _Nullable arguments)
UIView< UITextInput > * textInputView()
UIIndirectScribbleInteractionDelegate UIViewController * viewController
BOOL showEditMenu:(ios(16.0) API_AVAILABLE)
void handleMethodCall:result:(FlutterMethodCall *call,[result] FlutterResult result)
UIAccessibilityNotifications receivedNotification
instancetype positionWithIndex:(NSUInteger index)
UITextStorageDirection affinity
instancetype rangeWithNSRange:(NSRange range)
instancetype selectionRectWithRect:position:(CGRect rect,[position] NSUInteger position)
instancetype selectionRectWithRect:position:writingDirection:(CGRect rect,[position] NSUInteger position,[writingDirection] NSWritingDirection writingDirection)
int64_t texture_id