blob: 34970d2990650fd7ed3d02e898bfb07c054def44 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterViewController.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterDartProject_Internal.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterTextInputSemanticsObject.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterViewController_Internal.h"
#import <OCMock/OCMock.h>
#import "flutter/testing/testing.h"
@interface FlutterTextFieldMock : FlutterTextField
@property(nonatomic) NSString* lastUpdatedString;
@property(nonatomic) NSRange lastUpdatedSelection;
@end
@implementation FlutterTextFieldMock
- (void)updateString:(NSString*)string withSelection:(NSRange)selection {
_lastUpdatedString = string;
_lastUpdatedSelection = selection;
}
@end
@interface FlutterInputPluginTestObjc : NSObject
- (bool)testEmptyCompositionRange;
- (bool)testClearClientDuringComposing;
@end
@implementation FlutterInputPluginTestObjc
- (bool)testEmptyCompositionRange {
id engineMock = OCMClassMock([FlutterEngine class]);
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
nibName:@""
bundle:nil];
FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
[plugin handleMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInput.setClient"
arguments:@[
@(1), @{
@"inputAction" : @"action",
@"inputType" : @{@"name" : @"inputName"},
}
]]
result:^(id){
}];
FlutterMethodCall* call = [FlutterMethodCall methodCallWithMethodName:@"TextInput.setEditingState"
arguments:@{
@"text" : @"Text",
@"selectionBase" : @(0),
@"selectionExtent" : @(0),
@"composingBase" : @(-1),
@"composingExtent" : @(-1),
}];
NSDictionary* expectedState = @{
@"selectionBase" : @(0),
@"selectionExtent" : @(0),
@"selectionAffinity" : @"TextAffinity.upstream",
@"selectionIsDirectional" : @(NO),
@"composingBase" : @(-1),
@"composingExtent" : @(-1),
@"text" : @"Text",
};
NSData* updateCall = [[FlutterJSONMethodCodec sharedInstance]
encodeMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInputClient.updateEditingState"
arguments:@[ @(1), expectedState ]]];
OCMExpect( // NOLINT(google-objc-avoid-throwing-exception)
[binaryMessengerMock sendOnChannel:@"flutter/textinput" message:updateCall]);
[plugin handleMethodCall:call
result:^(id){
}];
@try {
OCMVerify( // NOLINT(google-objc-avoid-throwing-exception)
[binaryMessengerMock sendOnChannel:@"flutter/textinput" message:updateCall]);
} @catch (...) {
return false;
}
return true;
}
- (bool)testClearClientDuringComposing {
// Set up FlutterTextInputPlugin.
id engineMock = OCMClassMock([FlutterEngine class]);
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
nibName:@""
bundle:nil];
FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
// Set input client 1.
[plugin handleMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInput.setClient"
arguments:@[
@(1), @{
@"inputAction" : @"action",
@"inputType" : @{@"name" : @"inputName"},
}
]]
result:^(id){
}];
// Set editing state with an active composing range.
[plugin handleMethodCall:[FlutterMethodCall methodCallWithMethodName:@"TextInput.setEditingState"
arguments:@{
@"text" : @"Text",
@"selectionBase" : @(0),
@"selectionExtent" : @(0),
@"composingBase" : @(0),
@"composingExtent" : @(1),
}]
result:^(id){
}];
// Verify composing range is (0, 1).
NSDictionary* editingState = [plugin editingState];
EXPECT_EQ([editingState[@"composingBase"] intValue], 0);
EXPECT_EQ([editingState[@"composingExtent"] intValue], 1);
// Clear input client.
[plugin handleMethodCall:[FlutterMethodCall methodCallWithMethodName:@"TextInput.clearClient"
arguments:@[]]
result:^(id){
}];
// Verify composing range is collapsed.
editingState = [plugin editingState];
EXPECT_EQ([editingState[@"composingBase"] intValue], [editingState[@"composingExtent"] intValue]);
return true;
}
- (bool)testFirstRectForCharacterRange {
id engineMock = OCMClassMock([FlutterEngine class]);
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
FlutterViewController* controllerMock = OCMClassMock([FlutterViewController class]);
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[controllerMock engine])
.andReturn(engineMock);
id viewMock = OCMClassMock([NSView class]);
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[viewMock bounds])
.andReturn(NSMakeRect(0, 0, 200, 200));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
controllerMock.viewLoaded)
.andReturn(YES);
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[controllerMock flutterView])
.andReturn(viewMock);
id windowMock = OCMClassMock([NSWindow class]);
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[viewMock window])
.andReturn(windowMock);
OCMExpect( // NOLINT(google-objc-avoid-throwing-exception)
[viewMock convertRect:NSMakeRect(28, 10, 2, 19) toView:nil])
.andReturn(NSMakeRect(28, 10, 2, 19));
OCMExpect( // NOLINT(google-objc-avoid-throwing-exception)
[windowMock convertRectToScreen:NSMakeRect(28, 10, 2, 19)])
.andReturn(NSMakeRect(38, 20, 2, 19));
FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:controllerMock];
FlutterMethodCall* call = [FlutterMethodCall
methodCallWithMethodName:@"TextInput.setEditableSizeAndTransform"
arguments:@{
@"height" : @(20.0),
@"transform" : @[
@(1.0), @(0.0), @(0.0), @(0.0), @(0.0), @(1.0), @(0.0), @(0.0), @(0.0),
@(0.0), @(1.0), @(0.0), @(20.0), @(10.0), @(0.0), @(1.0)
],
@"width" : @(400.0),
}];
[plugin handleMethodCall:call
result:^(id){
}];
call = [FlutterMethodCall methodCallWithMethodName:@"TextInput.setCaretRect"
arguments:@{
@"height" : @(19.0),
@"width" : @(2.0),
@"x" : @(8.0),
@"y" : @(0.0),
}];
[plugin handleMethodCall:call
result:^(id){
}];
NSRect rect = [plugin firstRectForCharacterRange:NSMakeRange(0, 0) actualRange:nullptr];
@try {
OCMVerify( // NOLINT(google-objc-avoid-throwing-exception)
[windowMock convertRectToScreen:NSMakeRect(28, 10, 2, 19)]);
} @catch (...) {
return false;
}
return NSEqualRects(rect, NSMakeRect(38, 20, 2, 19));
}
- (bool)testSetEditingStateWithTextEditingDelta {
id engineMock = OCMClassMock([FlutterEngine class]);
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
nibName:@""
bundle:nil];
FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
[plugin handleMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInput.setClient"
arguments:@[
@(1), @{
@"inputAction" : @"action",
@"enableDeltaModel" : @"true",
@"inputType" : @{@"name" : @"inputName"},
}
]]
result:^(id){
}];
FlutterMethodCall* call = [FlutterMethodCall methodCallWithMethodName:@"TextInput.setEditingState"
arguments:@{
@"text" : @"Text",
@"selectionBase" : @(0),
@"selectionExtent" : @(0),
@"composingBase" : @(-1),
@"composingExtent" : @(-1),
}];
[plugin handleMethodCall:call
result:^(id){
}];
// The setEditingState call is ACKed back to the framework.
@try {
OCMVerify( // NOLINT(google-objc-avoid-throwing-exception)
[binaryMessengerMock
sendOnChannel:@"flutter/textinput"
message:[OCMArg checkWithBlock:^BOOL(NSData* callData) {
FlutterMethodCall* call =
[[FlutterJSONMethodCodec sharedInstance] decodeMethodCall:callData];
return [[call method]
isEqualToString:@"TextInputClient.updateEditingStateWithDeltas"];
}]]);
} @catch (...) {
return false;
}
return true;
}
- (bool)testOperationsThatTriggerDelta {
id engineMock = OCMClassMock([FlutterEngine class]);
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
nibName:@""
bundle:nil];
FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
[plugin handleMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInput.setClient"
arguments:@[
@(1), @{
@"inputAction" : @"action",
@"enableDeltaModel" : @"true",
@"inputType" : @{@"name" : @"inputName"},
}
]]
result:^(id){
}];
[plugin insertText:@"text to insert"];
NSDictionary* deltaToFramework = @{
@"oldText" : @"",
@"deltaText" : @"text to insert",
@"deltaStart" : @(0),
@"deltaEnd" : @(0),
@"selectionBase" : @(14),
@"selectionExtent" : @(14),
@"selectionAffinity" : @"TextAffinity.upstream",
@"selectionIsDirectional" : @(false),
@"composingBase" : @(-1),
@"composingExtent" : @(-1),
};
NSDictionary* expectedState = @{
@"deltas" : @[ deltaToFramework ],
};
NSData* updateCall = [[FlutterJSONMethodCodec sharedInstance]
encodeMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInputClient.updateEditingStateWithDeltas"
arguments:@[ @(1), expectedState ]]];
@try {
OCMVerify( // NOLINT(google-objc-avoid-throwing-exception)
[binaryMessengerMock sendOnChannel:@"flutter/textinput" message:updateCall]);
} @catch (...) {
return false;
}
[plugin setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
deltaToFramework = @{
@"oldText" : @"text to insert",
@"deltaText" : @"marked text",
@"deltaStart" : @(14),
@"deltaEnd" : @(25),
@"selectionBase" : @(25),
@"selectionExtent" : @(25),
@"selectionAffinity" : @"TextAffinity.upstream",
@"selectionIsDirectional" : @(false),
@"composingBase" : @(14),
@"composingExtent" : @(25),
};
expectedState = @{
@"deltas" : @[ deltaToFramework ],
};
updateCall = [[FlutterJSONMethodCodec sharedInstance]
encodeMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInputClient.updateEditingStateWithDeltas"
arguments:@[ @(1), expectedState ]]];
@try {
OCMVerify( // NOLINT(google-objc-avoid-throwing-exception)
[binaryMessengerMock sendOnChannel:@"flutter/textinput" message:updateCall]);
} @catch (...) {
return false;
}
[plugin unmarkText];
deltaToFramework = @{
@"oldText" : @"text to insertmarked text",
@"deltaText" : @"",
@"deltaStart" : @(-1),
@"deltaEnd" : @(-1),
@"selectionBase" : @(25),
@"selectionExtent" : @(25),
@"selectionAffinity" : @"TextAffinity.upstream",
@"selectionIsDirectional" : @(false),
@"composingBase" : @(-1),
@"composingExtent" : @(-1),
};
expectedState = @{
@"deltas" : @[ deltaToFramework ],
};
updateCall = [[FlutterJSONMethodCodec sharedInstance]
encodeMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInputClient.updateEditingStateWithDeltas"
arguments:@[ @(1), expectedState ]]];
@try {
OCMVerify( // NOLINT(google-objc-avoid-throwing-exception)
[binaryMessengerMock sendOnChannel:@"flutter/textinput" message:updateCall]);
} @catch (...) {
return false;
}
return true;
}
- (bool)testLocalTextAndSelectionUpdateAfterDelta {
id engineMock = OCMClassMock([FlutterEngine class]);
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
nibName:@""
bundle:nil];
FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
[plugin handleMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInput.setClient"
arguments:@[
@(1), @{
@"inputAction" : @"action",
@"enableDeltaModel" : @"true",
@"inputType" : @{@"name" : @"inputName"},
}
]]
result:^(id){
}];
[plugin insertText:@"text to insert"];
NSDictionary* deltaToFramework = @{
@"oldText" : @"",
@"deltaText" : @"text to insert",
@"deltaStart" : @(0),
@"deltaEnd" : @(0),
@"selectionBase" : @(14),
@"selectionExtent" : @(14),
@"selectionAffinity" : @"TextAffinity.upstream",
@"selectionIsDirectional" : @(false),
@"composingBase" : @(-1),
@"composingExtent" : @(-1),
};
NSDictionary* expectedState = @{
@"deltas" : @[ deltaToFramework ],
};
NSData* updateCall = [[FlutterJSONMethodCodec sharedInstance]
encodeMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInputClient.updateEditingStateWithDeltas"
arguments:@[ @(1), expectedState ]]];
@try {
OCMVerify( // NOLINT(google-objc-avoid-throwing-exception)
[binaryMessengerMock sendOnChannel:@"flutter/textinput" message:updateCall]);
} @catch (...) {
return false;
}
bool localTextAndSelectionUpdated = [plugin.string isEqualToString:@"text to insert"] &&
NSEqualRanges(plugin.selectedRange, NSMakeRange(14, 0));
return localTextAndSelectionUpdated;
}
@end
namespace flutter::testing {
namespace {
// Allocates and returns an engine configured for the text fixture resource configuration.
FlutterEngine* CreateTestEngine() {
NSString* fixtures = @(testing::GetFixturesPath());
FlutterDartProject* project = [[FlutterDartProject alloc]
initWithAssetsPath:fixtures
ICUDataPath:[fixtures stringByAppendingString:@"/icudtl.dat"]];
return [[FlutterEngine alloc] initWithName:@"test" project:project allowHeadlessExecution:true];
}
} // namespace
TEST(FlutterTextInputPluginTest, TestEmptyCompositionRange) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testEmptyCompositionRange]);
}
TEST(FlutterTextInputPluginTest, TestClearClientDuringComposing) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testClearClientDuringComposing]);
}
TEST(FlutterTextInputPluginTest, TestFirstRectForCharacterRange) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testFirstRectForCharacterRange]);
}
TEST(FlutterTextInputPluginTest, TestSetEditingStateWithTextEditingDelta) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testSetEditingStateWithTextEditingDelta]);
}
TEST(FlutterTextInputPluginTest, TestOperationsThatTriggerDelta) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testOperationsThatTriggerDelta]);
}
TEST(FlutterTextInputPluginTest, TestLocalTextAndSelectionUpdateAfterDelta) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testLocalTextAndSelectionUpdateAfterDelta]);
}
TEST(FlutterTextInputPluginTest, CanWorkWithFlutterTextField) {
FlutterEngine* engine = CreateTestEngine();
NSString* fixtures = @(testing::GetFixturesPath());
FlutterDartProject* project = [[FlutterDartProject alloc]
initWithAssetsPath:fixtures
ICUDataPath:[fixtures stringByAppendingString:@"/icudtl.dat"]];
FlutterViewController* viewController = [[FlutterViewController alloc] initWithProject:project];
[viewController loadView];
[engine setViewController:viewController];
// Create a NSWindow so that the native text field can become first responder.
NSWindow* window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600)
styleMask:NSBorderlessWindowMask
backing:NSBackingStoreBuffered
defer:NO];
window.contentView = viewController.view;
engine.semanticsEnabled = YES;
auto bridge = engine.accessibilityBridge.lock();
FlutterPlatformNodeDelegateMac delegate(engine, viewController);
ui::AXTree tree;
ui::AXNode ax_node(&tree, nullptr, 0, 0);
ui::AXNodeData node_data;
node_data.SetValue("initial text");
ax_node.SetData(node_data);
delegate.Init(engine.accessibilityBridge, &ax_node);
FlutterTextPlatformNode text_platform_node(&delegate, viewController);
FlutterTextFieldMock* mockTextField =
[[FlutterTextFieldMock alloc] initWithPlatformNode:&text_platform_node
fieldEditor:viewController.textInputPlugin];
[viewController.view addSubview:mockTextField];
[mockTextField becomeFirstResponder];
NSDictionary* arguments = @{
@"inputAction" : @"action",
@"inputType" : @{@"name" : @"inputName"},
};
FlutterMethodCall* methodCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
arguments:@[ @(1), arguments ]];
FlutterResult result = ^(id result) {
};
[viewController.textInputPlugin handleMethodCall:methodCall result:result];
arguments = @{
@"text" : @"new text",
@"selectionBase" : @(1),
@"selectionExtent" : @(2),
@"composingBase" : @(-1),
@"composingExtent" : @(-1),
};
methodCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.setEditingState"
arguments:arguments];
[viewController.textInputPlugin handleMethodCall:methodCall result:result];
EXPECT_EQ([mockTextField.lastUpdatedString isEqualToString:@"new text"], YES);
EXPECT_EQ(NSEqualRanges(mockTextField.lastUpdatedSelection, NSMakeRange(1, 1)), YES);
}
TEST(FlutterTextInputPluginTest, CanNotBecomeResponderIfNoViewController) {
FlutterEngine* engine = CreateTestEngine();
NSString* fixtures = @(testing::GetFixturesPath());
FlutterDartProject* project = [[FlutterDartProject alloc]
initWithAssetsPath:fixtures
ICUDataPath:[fixtures stringByAppendingString:@"/icudtl.dat"]];
FlutterViewController* viewController = [[FlutterViewController alloc] initWithProject:project];
[viewController loadView];
[engine setViewController:viewController];
// Creates a NSWindow so that the native text field can become first responder.
NSWindow* window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600)
styleMask:NSBorderlessWindowMask
backing:NSBackingStoreBuffered
defer:NO];
window.contentView = viewController.view;
engine.semanticsEnabled = YES;
auto bridge = engine.accessibilityBridge.lock();
FlutterPlatformNodeDelegateMac delegate(engine, viewController);
ui::AXTree tree;
ui::AXNode ax_node(&tree, nullptr, 0, 0);
ui::AXNodeData node_data;
node_data.SetValue("initial text");
ax_node.SetData(node_data);
delegate.Init(engine.accessibilityBridge, &ax_node);
FlutterTextPlatformNode text_platform_node(&delegate, viewController);
FlutterTextField* textField = text_platform_node.GetNativeViewAccessible();
EXPECT_EQ([textField becomeFirstResponder], YES);
// Removes view controller.
[engine setViewController:nil];
FlutterTextPlatformNode text_platform_node_no_controller(&delegate, nil);
textField = text_platform_node_no_controller.GetNativeViewAccessible();
EXPECT_EQ([textField becomeFirstResponder], NO);
}
} // namespace flutter::testing