diff --git a/engine/src/flutter/ci/licenses_golden/licenses_flutter b/engine/src/flutter/ci/licenses_golden/licenses_flutter index 5ce695d846c..f97bd7e7a4e 100644 --- a/engine/src/flutter/ci/licenses_golden/licenses_flutter +++ b/engine/src/flutter/ci/licenses_golden/licenses_flutter @@ -1698,6 +1698,9 @@ FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterRestora FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterRestorationPluginTest.mm FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.h FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.mm +FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.h +FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.mm +FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPluginTest.mm FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm diff --git a/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn b/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn index 118d170480c..40a86698dd3 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn +++ b/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn @@ -79,6 +79,8 @@ source_set("flutter_framework_source") { "framework/Source/FlutterRestorationPlugin.mm", "framework/Source/FlutterSemanticsScrollView.h", "framework/Source/FlutterSemanticsScrollView.mm", + "framework/Source/FlutterSpellCheckPlugin.h", + "framework/Source/FlutterSpellCheckPlugin.mm", "framework/Source/FlutterTextInputDelegate.h", "framework/Source/FlutterTextInputPlugin.h", "framework/Source/FlutterTextInputPlugin.mm", @@ -264,6 +266,7 @@ shared_library("ios_test_flutter") { "framework/Source/FlutterKeyboardManagerTest.mm", "framework/Source/FlutterPluginAppLifeCycleDelegateTest.mm", "framework/Source/FlutterRestorationPluginTest.mm", + "framework/Source/FlutterSpellCheckPluginTest.mm", "framework/Source/FlutterTextInputPluginTest.mm", "framework/Source/FlutterUndoManagerPluginTest.mm", "framework/Source/FlutterViewControllerTest.mm", diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index 9fddc3f4bfe..b69ce5123a9 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -24,6 +24,7 @@ #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterIndirectScribbleDelegate.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterObservatoryPublisher.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerDelegate.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterUndoManagerPlugin.h" @@ -116,6 +117,7 @@ static constexpr int kNumProfilerSamplesPerSec = 5; fml::scoped_nsobject _platformPlugin; fml::scoped_nsobject _textInputPlugin; fml::scoped_nsobject _undoManagerPlugin; + fml::scoped_nsobject _spellCheckPlugin; fml::scoped_nsobject _restorationPlugin; fml::scoped_nsobject _localizationChannel; fml::scoped_nsobject _navigationChannel; @@ -124,6 +126,7 @@ static constexpr int kNumProfilerSamplesPerSec = 5; fml::scoped_nsobject _platformViewsChannel; fml::scoped_nsobject _textInputChannel; fml::scoped_nsobject _undoManagerChannel; + fml::scoped_nsobject _spellCheckChannel; fml::scoped_nsobject _lifecycleChannel; fml::scoped_nsobject _systemChannel; fml::scoped_nsobject _settingsChannel; @@ -469,6 +472,9 @@ static constexpr int kNumProfilerSamplesPerSec = 5; - (FlutterMethodChannel*)undoManagerChannel { return _undoManagerChannel.get(); } +- (FlutterMethodChannel*)spellCheckChannel { + return _spellCheckChannel.get(); +} - (FlutterBasicMessageChannel*)lifecycleChannel { return _lifecycleChannel.get(); } @@ -498,6 +504,7 @@ static constexpr int kNumProfilerSamplesPerSec = 5; _systemChannel.reset(); _settingsChannel.reset(); _keyEventChannel.reset(); + _spellCheckChannel.reset(); } - (void)startProfiler { @@ -566,6 +573,11 @@ static constexpr int kNumProfilerSamplesPerSec = 5; binaryMessenger:self.binaryMessenger codec:[FlutterJSONMethodCodec sharedInstance]]); + _spellCheckChannel.reset([[FlutterMethodChannel alloc] + initWithName:@"flutter/spellcheck" + binaryMessenger:self.binaryMessenger + codec:[FlutterStandardMethodCodec sharedInstance]]); + _lifecycleChannel.reset([[FlutterBasicMessageChannel alloc] initWithName:@"flutter/lifecycle" binaryMessenger:self.binaryMessenger @@ -600,6 +612,7 @@ static constexpr int kNumProfilerSamplesPerSec = 5; _restorationPlugin.reset([[FlutterRestorationPlugin alloc] initWithChannel:_restorationChannel.get() restorationEnabled:_restorationEnabled]); + _spellCheckPlugin.reset([[FlutterSpellCheckPlugin alloc] init]); } - (void)maybeSetupPlatformViewChannels { @@ -627,6 +640,12 @@ static constexpr int kNumProfilerSamplesPerSec = 5; setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) { [undoManagerPlugin handleMethodCall:call result:result]; }]; + + FlutterSpellCheckPlugin* spellCheckPlugin = _spellCheckPlugin.get(); + [_spellCheckChannel.get() + setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) { + [spellCheckPlugin handleMethodCall:call result:result]; + }]; } } diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.h new file mode 100644 index 00000000000..790e192362d --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.h @@ -0,0 +1,32 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERSPELLCHECKPLUGIN_H_ +#define SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERSPELLCHECKPLUGIN_H_ + +#include "flutter/fml/memory/weak_ptr.h" + +#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterChannels.h" +#import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h" + +@interface FlutterSpellCheckPlugin : NSObject + +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result; + +@end + +@interface FlutterSpellCheckResult : NSObject + +@property(nonatomic, copy, readonly) NSArray* suggestions; +@property(nonatomic, assign, readonly) NSRange misspelledRange; + +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)new NS_UNAVAILABLE; +- (instancetype)initWithMisspelledRange:(NSRange)range + suggestions:(NSArray*)suggestions NS_DESIGNATED_INITIALIZER; +- (NSDictionary*)toDictionary; + +@end + +#endif // SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERSPELLCHECKPLUGIN_H_ diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.mm new file mode 100644 index 00000000000..2eac491c3ce --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.mm @@ -0,0 +1,158 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.h" + +#import +#import + +#import "flutter/fml/logging.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h" + +// Method Channel name to start spell check. +static NSString* const kInitiateSpellCheck = @"SpellCheck.initiateSpellCheck"; + +@interface FlutterSpellCheckPlugin () + +@property(nonatomic, retain) UITextChecker* textChecker; + +@end + +@implementation FlutterSpellCheckPlugin + +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + if (!_textChecker) { + // UITextChecker is an expensive object to initiate, see: + // https://github.com/flutter/flutter/issues/104454. Lazily initialate the UITextChecker object + // until at first method channel call. We avoid using lazy getter for testing. + _textChecker = [[UITextChecker alloc] init]; + } + NSString* method = call.method; + NSArray* args = call.arguments; + if ([method isEqualToString:kInitiateSpellCheck]) { + FML_DCHECK(args.count == 2); + id language = args[0]; + id text = args[1]; + if (language == [NSNull null] || text == [NSNull null]) { + // Bail if null arguments are passed from dart. + result(nil); + return; + } + + NSArray*>* spellCheckResult = + [self findAllSpellCheckSuggestionsForText:text inLanguage:language]; + result(spellCheckResult); + } +} + +// Get all the misspelled words and suggestions in the entire String. +// +// The result will be formatted as an NSArray. +// Each item of the array is a dictionary representing a misspelled word and suggestions. +// The format looks like: +// { +// startIndex: 0, +// endIndex: 5, +// suggestions: [hello, ...] +// } +// +// Returns nil if the language is invalid. +// Returns an empty array if no spell check suggestions. +- (NSArray*>*)findAllSpellCheckSuggestionsForText:(NSString*)text + inLanguage:(NSString*)language { + if (![UITextChecker.availableLanguages containsObject:language]) { + return nil; + } + + NSMutableArray* allSpellSuggestions = [[NSMutableArray alloc] init]; + + FlutterSpellCheckResult* nextSpellSuggestion; + NSUInteger nextOffset = 0; + do { + nextSpellSuggestion = [self findSpellCheckSuggestionsForText:text + inLanguage:language + startingOffset:nextOffset]; + if (nextSpellSuggestion != nil) { + [allSpellSuggestions addObject:nextSpellSuggestion]; + nextOffset = + nextSpellSuggestion.misspelledRange.location + nextSpellSuggestion.misspelledRange.length; + } + } while (nextSpellSuggestion != nil && nextOffset < text.length); + + NSMutableArray* methodChannelResult = [[[NSMutableArray alloc] init] autorelease]; + + for (FlutterSpellCheckResult* result in allSpellSuggestions) { + [methodChannelResult addObject:[result toDictionary]]; + } + + [allSpellSuggestions release]; + return methodChannelResult; +} + +// Get the misspelled word and suggestions. +// +// Returns nil if no spell check suggestions. +- (FlutterSpellCheckResult*)findSpellCheckSuggestionsForText:(NSString*)text + inLanguage:(NSString*)language + startingOffset:(NSInteger)startingOffset { + FML_DCHECK([UITextChecker.availableLanguages containsObject:language]); + NSRange misspelledRange = + [self.textChecker rangeOfMisspelledWordInString:text + range:NSMakeRange(0, text.length) + startingAt:startingOffset + wrap:NO + language:language]; + if (misspelledRange.location == NSNotFound) { + // No misspelled word found + return nil; + } + + // If no possible guesses, the API returns an empty array: + // https://developer.apple.com/documentation/uikit/uitextchecker/1621037-guessesforwordrange?language=objc + NSArray* suggestions = [self.textChecker guessesForWordRange:misspelledRange + inString:text + language:language]; + FlutterSpellCheckResult* result = + [[[FlutterSpellCheckResult alloc] initWithMisspelledRange:misspelledRange + suggestions:suggestions] autorelease]; + return result; +} + +- (UITextChecker*)textChecker { + return _textChecker; +} + +- (void)dealloc { + [_textChecker release]; + [super dealloc]; +} + +@end + +@implementation FlutterSpellCheckResult + +- (instancetype)initWithMisspelledRange:(NSRange)range + suggestions:(NSArray*)suggestions { + self = [super init]; + if (self) { + _suggestions = [suggestions copy]; + _misspelledRange = range; + } + return self; +} + +- (NSDictionary*)toDictionary { + NSMutableDictionary* result = [[[NSMutableDictionary alloc] initWithCapacity:3] autorelease]; + result[@"startIndex"] = @(_misspelledRange.location); + result[@"endIndex"] = @(_misspelledRange.location + _misspelledRange.length - 1); + result[@"suggestions"] = _suggestions; + return result; +} + +- (void)dealloc { + [_suggestions release]; + [super dealloc]; +} + +@end diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPluginTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPluginTest.mm new file mode 100644 index 00000000000..92fc4469b88 --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPluginTest.mm @@ -0,0 +1,341 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.h" + +#import +#import + +#pragma mark - + +// A mock class representing the UITextChecker used in the tests. +// +// Because OCMock doesn't support mocking NSRange as method arguments, +// this is necessary. +@interface MockTextChecker : UITextChecker + +// The range of misspelled word based on the startingIndex. +// +// Key is the starting index, value is the range +@property(strong, nonatomic) NSMutableDictionary* startingIndexToRange; + +// The suggestions of misspelled word based on the starting index of the misspelled word. +// +// Key is a string representing the range of the misspelled word, value is the suggestions. +@property(strong, nonatomic) + NSMutableDictionary*>* rangeToSuggestions; + +// Mock the spell checking results. +// +// When no misspelled word should be detected, pass (NSNotFound, 0) for the `range` parameter, and +// an empty array for `suggestions`. +// +// Call `reset` to remove all the mocks. +- (void)mockResultRange:(NSRange)range + suggestions:(nonnull NSArray*)suggestions + withStartingIndex:(NSInteger)startingIndex; + +// Remove all mocks. +- (void)reset; + +@end + +@implementation MockTextChecker + +- (instancetype)init { + self = [super init]; + if (self) { + _startingIndexToRange = [[NSMutableDictionary alloc] init]; + _rangeToSuggestions = [[NSMutableDictionary alloc] init]; + } + return self; +} + +- (void)mockResultRange:(NSRange)range + suggestions:(NSArray*)suggestions + withStartingIndex:(NSInteger)startingIndex { + NSValue* valueForRange = [NSValue valueWithRange:range]; + self.startingIndexToRange[@(startingIndex)] = valueForRange; + NSString* rangeString = NSStringFromRange(valueForRange.rangeValue); + self.rangeToSuggestions[rangeString] = suggestions; +} + +- (void)reset { + [self.startingIndexToRange removeAllObjects]; + [self.rangeToSuggestions removeAllObjects]; +} + +#pragma mark UITextChecker Overrides + +- (NSRange)rangeOfMisspelledWordInString:(NSString*)stringToCheck + range:(NSRange)range + startingAt:(NSInteger)startingOffset + wrap:(BOOL)wrapFlag + language:(NSString*)language { + return self.startingIndexToRange[@(startingOffset)].rangeValue; +} + +- (NSArray*)guessesForWordRange:(NSRange)range + inString:(NSString*)string + language:(NSString*)language { + return self.rangeToSuggestions[NSStringFromRange(range)]; +} + +@end + +@interface FlutterSpellCheckPlugin () +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result; +- (UITextChecker*)textChecker; +@end + +@interface FlutterSpellCheckPluginTest : XCTestCase + +@property(strong, nonatomic) id mockMethodChannel; +@property(strong, nonatomic) FlutterSpellCheckPlugin* plugin; +@property(strong, nonatomic) id mockTextChecker; +@property(strong, nonatomic) id partialMockPlugin; + +@end + +#pragma mark - + +@implementation FlutterSpellCheckPluginTest + +- (void)setUp { + [super setUp]; + self.mockMethodChannel = OCMClassMock([FlutterMethodChannel class]); + self.plugin = [[FlutterSpellCheckPlugin alloc] init]; + __weak FlutterSpellCheckPlugin* weakPlugin = self.plugin; + OCMStub([self.mockMethodChannel invokeMethod:[OCMArg any] + arguments:[OCMArg any] + result:[OCMArg any]]) + .andDo(^(NSInvocation* invocation) { + NSString* name; + id args; + FlutterResult result; + [invocation getArgument:&name atIndex:2]; + [invocation getArgument:&args atIndex:3]; + [invocation getArgument:&result atIndex:4]; + FlutterMethodCall* methodCall = [FlutterMethodCall methodCallWithMethodName:name + arguments:args]; + [weakPlugin handleMethodCall:methodCall result:result]; + }); + self.mockTextChecker = [[MockTextChecker alloc] init]; +} + +- (void)tearDown { + self.plugin = nil; + [super tearDown]; +} + +#pragma mark - Tests + +// Test to make sure the while loop that checks all the misspelled word stops when the a +// `NSNotFound` is found. +- (void)testFindAllSpellCheckSuggestionsForText { + self.partialMockPlugin = OCMPartialMock(self.plugin); + OCMStub([self.partialMockPlugin textChecker]).andReturn(self.mockTextChecker); + id textCheckerClassMock = OCMClassMock([UITextChecker class]); + [[[textCheckerClassMock stub] andReturn:@[ @"en" ]] availableLanguages]; + NSArray* suggestions1 = @[ @"suggestion 1", @"suggestion 2" ]; + NSArray* suggestions2 = @[ @"suggestion 3", @"suggestion 4" ]; + // 0-4 is a misspelled word. + [self mockUITextCheckerWithExpectedMisspelledWordRange:NSMakeRange(0, 5) + startingIndex:0 + suggestions:suggestions1]; + // 5-9 is a misspelled word. + [self mockUITextCheckerWithExpectedMisspelledWordRange:NSMakeRange(5, 5) + startingIndex:5 + suggestions:suggestions2]; + // No misspelled word after index 10. + [self mockUITextCheckerWithExpectedMisspelledWordRange:NSMakeRange(NSNotFound, 0) + startingIndex:10 + suggestions:@[]]; + __block NSArray* capturedResult; + [self.mockMethodChannel invokeMethod:@"SpellCheck.initiateSpellCheck" + arguments:@[ @"en", @"ksajlkdf aslkdfl kasdf asdfjk" ] + result:^(id _Nullable result) { + capturedResult = result; + }]; + XCTAssertTrue(capturedResult.count == 2); + NSDictionary* suggestionsJSON1 = capturedResult.firstObject; + XCTAssertEqualObjects(suggestionsJSON1[@"startIndex"], @0); + XCTAssertEqualObjects(suggestionsJSON1[@"endIndex"], @4); + XCTAssertEqualObjects(suggestionsJSON1[@"suggestions"], suggestions1); + NSDictionary* suggestionsJSON2 = capturedResult[1]; + XCTAssertEqualObjects(suggestionsJSON2[@"startIndex"], @5); + XCTAssertEqualObjects(suggestionsJSON2[@"endIndex"], @9); + XCTAssertEqualObjects(suggestionsJSON2[@"suggestions"], suggestions2); + [self.mockTextChecker reset]; + [textCheckerClassMock stopMocking]; +} + +// Test to make sure while loop that checks all the misspelled word stops when the last word is +// misspelled (aka nextIndex is out of bounds) +- (void)testStopFindingMoreWhenTheLastWordIsMisspelled { + self.partialMockPlugin = OCMPartialMock(self.plugin); + OCMStub([self.partialMockPlugin textChecker]).andReturn(self.mockTextChecker); + id textCheckerClassMock = OCMClassMock([UITextChecker class]); + [[[textCheckerClassMock stub] andReturn:@[ @"en" ]] availableLanguages]; + NSArray* suggestions1 = @[ @"suggestion 1", @"suggestion 2" ]; + NSArray* suggestions2 = @[ @"suggestion 3", @"suggestion 4" ]; + // 0-4 is a misspelled word. + [self mockUITextCheckerWithExpectedMisspelledWordRange:NSMakeRange(0, 5) + startingIndex:0 + suggestions:suggestions1]; + // 5-9 is a misspelled word. + [self mockUITextCheckerWithExpectedMisspelledWordRange:NSMakeRange(6, 4) + startingIndex:5 + suggestions:suggestions2]; + + __block NSArray* capturedResult; + [self.mockMethodChannel invokeMethod:@"SpellCheck.initiateSpellCheck" + arguments:@[ @"en", @"hejjo abcd" ] + result:^(id _Nullable result) { + capturedResult = result; + }]; + XCTAssertTrue(capturedResult.count == 2); + NSDictionary* suggestionsJSON1 = capturedResult.firstObject; + XCTAssertEqualObjects(suggestionsJSON1[@"startIndex"], @0); + XCTAssertEqualObjects(suggestionsJSON1[@"endIndex"], @4); + XCTAssertEqualObjects(suggestionsJSON1[@"suggestions"], suggestions1); + NSDictionary* suggestionsJSON2 = capturedResult[1]; + XCTAssertEqualObjects(suggestionsJSON2[@"startIndex"], @6); + XCTAssertEqualObjects(suggestionsJSON2[@"endIndex"], @9); + XCTAssertEqualObjects(suggestionsJSON2[@"suggestions"], suggestions2); + [self.mockTextChecker reset]; + [textCheckerClassMock stopMocking]; +} + +- (void)testStopFindingMoreWhenTheWholeStringIsAMisspelledWord { + self.partialMockPlugin = OCMPartialMock(self.plugin); + OCMStub([self.partialMockPlugin textChecker]).andReturn(self.mockTextChecker); + id textCheckerClassMock = OCMClassMock([UITextChecker class]); + [[[textCheckerClassMock stub] andReturn:@[ @"en" ]] availableLanguages]; + NSArray* suggestions1 = @[ @"suggestion 1", @"suggestion 2" ]; + // 0-4 is a misspelled word. + [self mockUITextCheckerWithExpectedMisspelledWordRange:NSMakeRange(0, 5) + startingIndex:0 + suggestions:suggestions1]; + + __block NSArray* capturedResult; + [self.mockMethodChannel invokeMethod:@"SpellCheck.initiateSpellCheck" + arguments:@[ @"en", @"hejjo" ] + result:^(id _Nullable result) { + capturedResult = result; + }]; + XCTAssertTrue(capturedResult.count == 1); + NSDictionary* suggestionsJSON1 = capturedResult.firstObject; + XCTAssertEqualObjects(suggestionsJSON1[@"startIndex"], @0); + XCTAssertEqualObjects(suggestionsJSON1[@"endIndex"], @4); + XCTAssertEqualObjects(suggestionsJSON1[@"suggestions"], suggestions1); + [self.mockTextChecker reset]; + [textCheckerClassMock stopMocking]; +} + +- (void)testInitiateSpellCheckWithNoMisspelledWord { + self.partialMockPlugin = OCMPartialMock(self.plugin); + OCMStub([self.partialMockPlugin textChecker]).andReturn(self.mockTextChecker); + id textCheckerClassMock = OCMClassMock([UITextChecker class]); + [[[textCheckerClassMock stub] andReturn:@[ @"en" ]] availableLanguages]; + [self mockUITextCheckerWithExpectedMisspelledWordRange:NSMakeRange(NSNotFound, 0) + startingIndex:0 + suggestions:@[]]; + __block id capturedResult; + [self.mockMethodChannel invokeMethod:@"SpellCheck.initiateSpellCheck" + arguments:@[ @"en", @"helloo" ] + result:^(id _Nullable result) { + capturedResult = result; + }]; + XCTAssertEqualObjects(capturedResult, @[]); + [textCheckerClassMock stopMocking]; +} + +- (void)testUnsupportedLanguageShouldReturnNil { + self.partialMockPlugin = OCMPartialMock(self.plugin); + OCMStub([self.partialMockPlugin textChecker]).andReturn(self.mockTextChecker); + id textCheckerClassMock = OCMClassMock([UITextChecker class]); + [[[textCheckerClassMock stub] andReturn:@[ @"en" ]] availableLanguages]; + [self mockUITextCheckerWithExpectedMisspelledWordRange:NSMakeRange(0, 5) + startingIndex:0 + suggestions:@[]]; + __block id capturedResult; + [self.mockMethodChannel invokeMethod:@"SpellCheck.initiateSpellCheck" + arguments:@[ @"xx", @"helloo" ] + result:^(id _Nullable result) { + capturedResult = result; + }]; + XCTAssertNil(capturedResult); + [textCheckerClassMock stopMocking]; +} + +- (void)testEmptyStringShouldReturnEmptyResults { + self.partialMockPlugin = OCMPartialMock(self.plugin); + OCMStub([self.partialMockPlugin textChecker]).andReturn(self.mockTextChecker); + // Use real UITextChecker for this as we want to rely on the actual behavior of UITextChecker + // to ensure that spell checks on an empty result always return empty. + [self.partialMockPlugin stopMocking]; + + id textCheckerClassMock = OCMClassMock([UITextChecker class]); + [[[textCheckerClassMock stub] andReturn:@[ @"en" ]] availableLanguages]; + __block id capturedResult; + [self.mockMethodChannel invokeMethod:@"SpellCheck.initiateSpellCheck" + arguments:@[ @"en", @"" ] + result:^(id _Nullable result) { + capturedResult = result; + }]; + XCTAssertEqualObjects(capturedResult, @[]); + [textCheckerClassMock stopMocking]; +} + +- (void)testNullStringArgumentShouldReturnNilResults { + self.partialMockPlugin = OCMPartialMock(self.plugin); + OCMStub([self.partialMockPlugin textChecker]).andReturn(self.mockTextChecker); + id textCheckerClassMock = OCMClassMock([UITextChecker class]); + [[[textCheckerClassMock stub] andReturn:@[ @"en" ]] availableLanguages]; + __block id capturedResult; + [self.mockMethodChannel invokeMethod:@"SpellCheck.initiateSpellCheck" + arguments:@[ @"en", [NSNull null] ] + result:^(id _Nullable result) { + capturedResult = result; + }]; + XCTAssertNil(capturedResult); + [textCheckerClassMock stopMocking]; +} + +- (void)testNullLanguageArgumentShouldReturnNilResults { + self.partialMockPlugin = OCMPartialMock(self.plugin); + OCMStub([self.partialMockPlugin textChecker]).andReturn(self.mockTextChecker); + id textCheckerClassMock = OCMClassMock([UITextChecker class]); + [[[textCheckerClassMock stub] andReturn:@[ @"en" ]] availableLanguages]; + __block id capturedResult; + [self.mockMethodChannel invokeMethod:@"SpellCheck.initiateSpellCheck" + arguments:@[ [NSNull null], @"some string" ] + result:^(id _Nullable result) { + capturedResult = result; + }]; + XCTAssertNil(capturedResult); + [textCheckerClassMock stopMocking]; +} + +- (void)testUITextCheckerIsInitializedAfterMethodChannelCall { + XCTAssertNil([self.plugin textChecker]); + __block id capturedResult; + [self.mockMethodChannel invokeMethod:@"SpellCheck.initiateSpellCheck" + arguments:@[ [NSNull null], @"some string" ] + result:^(id _Nullable result) { + capturedResult = result; + }]; + XCTAssertNotNil([self.plugin textChecker]); +} + +- (void)mockUITextCheckerWithExpectedMisspelledWordRange:(NSRange)expectedRange + startingIndex:(NSInteger)startingIndex + suggestions:(NSArray*)suggestions { + [self.mockTextChecker mockResultRange:expectedRange + suggestions:suggestions + withStartingIndex:startingIndex]; +} + +@end diff --git a/engine/src/flutter/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj b/engine/src/flutter/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj index d2f9e6ee03f..3632c55063a 100644 --- a/engine/src/flutter/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj +++ b/engine/src/flutter/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj @@ -69,6 +69,7 @@ 0D6AB6C922BB05E200EEE540 /* IosUnitTestsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IosUnitTestsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 0D6AB6CF22BB05E200EEE540 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 0D6AB73E22BD8F0200EEE540 /* FlutterEngineConfig.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = FlutterEngineConfig.xcconfig; sourceTree = ""; }; + 689EC1E2281B30D3008FEB58 /* FlutterSpellCheckPluginTest.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FlutterSpellCheckPluginTest.mm; sourceTree = ""; }; 68B6091227F62F990036AC78 /* VsyncWaiterIosTest.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = VsyncWaiterIosTest.mm; sourceTree = ""; }; 3DD7D38C27D2B81000DA365C /* FlutterUndoManagerPluginTest.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FlutterUndoManagerPluginTest.mm; sourceTree = ""; }; F7521D7226BB671E005F15C5 /* libios_test_flutter.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libios_test_flutter.dylib; path = "../../../../out/$(FLUTTER_ENGINE)/libios_test_flutter.dylib"; sourceTree = ""; }; @@ -98,6 +99,7 @@ 0AC232E924BA71D300A85907 /* Source */ = { isa = PBXGroup; children = ( + 689EC1E2281B30D3008FEB58 /* FlutterSpellCheckPluginTest.mm */, 68B6091227F62F990036AC78 /* VsyncWaiterIosTest.mm */, 3DD7D38C27D2B81000DA365C /* FlutterUndoManagerPluginTest.mm */, F7A3FDE026B9E0A300EADD61 /* FlutterAppDelegateTest.mm */,