chromium/ios/chrome/browser/ui/browser_container/browser_edit_menu_handler_unittest.mm

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "ios/chrome/browser/ui/browser_container/browser_edit_menu_handler.h"

#import "base/test/ios/wait_util.h"
#import "base/test/scoped_feature_list.h"
#import "ios/chrome/browser/link_to_text/ui_bundled/link_to_text_mediator.h"
#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
#import "ios/chrome/browser/shared/model/web_state_list/test/fake_web_state_list_delegate.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/ui/browser_container/browser_container_view_controller.h"
#import "ios/chrome/browser/ui/partial_translate/partial_translate_mediator.h"
#import "ios/chrome/browser/web/model/chrome_web_client.h"
#import "ios/chrome/test/providers/partial_translate/test_partial_translate.h"
#import "ios/chrome/test/scoped_key_window.h"
#import "ios/web/public/test/scoped_testing_web_client.h"
#import "ios/web/public/test/web_state_test_util.h"
#import "ios/web/public/test/web_task_environment.h"
#import "testing/gtest/include/gtest/gtest.h"
#import "testing/gtest_mac.h"
#import "testing/platform_test.h"
#import "ui/base/device_form_factor.h"

namespace {

// Creates a recursive string description of `menuElement`.
// Each array element is a string "depth:type:id" with
// - depth: the depth of the node in the submenus trees
// - type: "a" for action, "c" for command, "m" for submenu, "u" for others
// - id: an id (dependant on the type).
NSArray* MenuDescription(UIMenuElement* menuElement, int indent) {
  if ([menuElement isKindOfClass:[UIAction class]]) {
    UIAction* action = (UIAction*)menuElement;
    return
        @[ [NSString stringWithFormat:@"%d:a:%@", indent, action.identifier] ];
  }
  if ([menuElement isKindOfClass:[UICommand class]]) {
    UICommand* command = (UICommand*)menuElement;
    return
        @[ [NSString stringWithFormat:@"%d:c:%@", indent,
                                      NSStringFromSelector(command.action)] ];
  }
  if ([menuElement isKindOfClass:[UIDeferredMenuElement class]]) {
    // It is not possible to have info from UIDeferredMenuElement as they
    // are only a block.
    return @[ [NSString stringWithFormat:@"%d:d", indent] ];
  }
  if ([menuElement isKindOfClass:[UIMenu class]]) {
    UIMenu* menu = (UIMenu*)menuElement;
    NSMutableArray* array = [NSMutableArray
        arrayWithObject:[NSString stringWithFormat:@"%d:m:%@", indent,
                                                   menu.identifier]];
    for (UIMenuElement* child in menu.children) {
      [array addObjectsFromArray:MenuDescription(child, indent + 1)];
    }
    return array;
  }
  return @[ [NSString
      stringWithFormat:@"%d:u:%@", indent, menuElement.description] ];
}

NSString* kPageHTML = @"<html>"
                       "  <body>"
                       "    This is a simple HTML file."
                       "  </body>"
                       "</html>";

// Return the base menu depending on the environment.
NSMutableArray* GetExpectedMenu() {
  if (@available(iOS 18, *)) {
    return [NSMutableArray arrayWithArray:@[
      @"0:m:com.apple.menu.standard-edit",
      @"1:c:cut:",
      @"1:c:copy:",
      @"1:c:paste:",
      @"1:c:delete:",
      @"1:c:select:",
      @"1:c:selectAll:",
      @"0:m:com.apple.menu.replace",
      @"1:c:promptForReplace:",
      @"1:c:transliterateChinese:",
      @"1:c:_insertDrawing:",
      @"1:m:com.apple.menu.autofill",
      @"2:m:com.apple.menu.insert-from-external-sources",
      @"2:c:captureTextFromCamera:",
      @"1:c:_startWritingTools:",
      @"0:m:com.apple.menu.open",
      @"0:m:com.apple.menu.format",
      @"1:m:com.apple.menu.text-style",
      @"2:c:toggleBoldface:",
      @"2:c:toggleItalics:",
      @"2:c:toggleUnderline:",
      @"1:m:com.apple.menu.writing-direction",
      @"2:c:makeTextWritingDirectionRightToLeft:",
      @"2:c:makeTextWritingDirectionLeftToRight:",
      @"1:c:_showTextFormattingOptions:",
      @"0:m:com.apple.menu.lookup",
      @"1:c:findSelected:",
      @"1:c:_define:",
      @"1:c:_translate:",
      @"0:m:com.apple.menu.learn",
      @"1:c:addShortcut:",
      @"0:m:com.apple.command.speech",
      @"1:c:_accessibilitySpeak:",
      @"1:c:_accessibilitySpeakLanguageSelection:",
      @"1:c:_accessibilityPauseSpeaking:",
      @"0:m:com.apple.menu.share",
      @"1:c:share:"
    ]];
  } else if (@available(iOS 17.4, *)) {
    return [NSMutableArray arrayWithArray:@[
      @"0:m:com.apple.menu.standard-edit",
      @"1:c:cut:",
      @"1:c:copy:",
      @"1:c:paste:",
      @"1:c:delete:",
      @"1:c:select:",
      @"1:c:selectAll:",
      @"0:m:com.apple.menu.replace",
      @"1:c:promptForReplace:",
      @"1:c:transliterateChinese:",
      @"1:c:_insertDrawing:",
      @"1:m:com.apple.menu.autofill",
      @"2:m:com.apple.menu.insert-from-external-sources",
      @"2:c:captureTextFromCamera:",
      @"0:m:com.apple.menu.open",
      @"0:m:com.apple.menu.format",
      @"1:m:com.apple.menu.text-style",
      @"2:c:toggleBoldface:",
      @"2:c:toggleItalics:",
      @"2:c:toggleUnderline:",
      @"1:m:com.apple.menu.writing-direction",
      @"2:c:makeTextWritingDirectionRightToLeft:",
      @"2:c:makeTextWritingDirectionLeftToRight:",
      @"1:c:_showTextFormattingOptions:",
      @"0:m:com.apple.menu.lookup",
      @"1:c:findSelected:",
      @"1:c:_define:",
      @"1:c:_translate:",
      @"0:m:com.apple.menu.learn",
      @"1:c:addShortcut:",
      @"0:m:com.apple.command.speech",
      @"1:c:_accessibilitySpeak:",
      @"1:c:_accessibilitySpeakLanguageSelection:",
      @"1:c:_accessibilityPauseSpeaking:",
      @"0:m:com.apple.menu.share",
      @"1:c:share:"
    ]];
  } else if (@available(iOS 17, *)) {
    return [NSMutableArray arrayWithArray:@[
      @"0:m:com.apple.menu.standard-edit",
      @"1:c:cut:",
      @"1:c:copy:",
      @"1:c:paste:",
      @"1:c:delete:",
      @"1:c:select:",
      @"1:c:selectAll:",
      @"0:m:com.apple.menu.replace",
      @"1:c:_promptForReplace:",
      @"1:c:_transliterateChinese:",
      @"1:c:_insertDrawing:",
      @"1:m:com.apple.menu.autofill",
      @"2:m:com.apple.menu.insert-from-external-sources",
      @"2:c:captureTextFromCamera:",
      @"0:m:com.apple.menu.open",
      @"0:m:com.apple.menu.format",
      @"1:m:com.apple.menu.text-style",
      @"2:c:toggleBoldface:",
      @"2:c:toggleItalics:",
      @"2:c:toggleUnderline:",
      @"1:m:com.apple.menu.writing-direction",
      @"2:c:makeTextWritingDirectionRightToLeft:",
      @"2:c:makeTextWritingDirectionLeftToRight:",
      @"1:c:_showTextFormattingOptions:",
      @"0:m:com.apple.menu.lookup",
      @"1:c:_findSelected:",
      @"1:c:_define:",
      @"1:c:_translate:",
      @"0:m:com.apple.menu.learn",
      @"1:c:_addShortcut:",
      @"0:m:com.apple.command.speech",
      @"1:c:_accessibilitySpeak:",
      @"1:c:_accessibilitySpeakLanguageSelection:",
      @"1:c:_accessibilityPauseSpeaking:",
      @"0:m:com.apple.menu.share",
      @"1:c:_share:"
    ]];
  }
  NSMutableArray* expectedMenuDescription = [NSMutableArray arrayWithArray:@[
    @"0:m:com.apple.menu.standard-edit",
    @"1:c:cut:",
    @"1:c:copy:",
    @"1:c:paste:",
    @"1:c:delete:",
    @"1:c:select:",
    @"1:c:selectAll:",
    @"0:m:com.apple.menu.replace",
    @"1:c:_promptForReplace:",
    @"1:c:_transliterateChinese:",
    @"1:c:_insertDrawing:",
    @"1:c:captureTextFromCamera:",
    @"0:m:com.apple.menu.open",
    @"0:m:com.apple.menu.format",
    @"1:m:com.apple.menu.text-style",
    @"2:c:toggleBoldface:",
    @"2:c:toggleItalics:",
    @"2:c:toggleUnderline:",
    @"1:m:com.apple.menu.writing-direction",
    @"2:c:makeTextWritingDirectionRightToLeft:",
    @"2:c:makeTextWritingDirectionLeftToRight:",
    @"0:m:com.apple.menu.lookup",
    @"1:c:_findSelected:",
    @"1:c:_define:",
    @"1:c:_translate:",
    @"0:m:com.apple.menu.learn",
    @"1:c:_addShortcut:",
    @"0:m:com.apple.command.speech",
    @"1:c:_accessibilitySpeak:",
    @"1:c:_accessibilitySpeakLanguageSelection:",
    @"1:c:_accessibilityPauseSpeaking:",
    @"0:m:com.apple.menu.share",
    @"1:c:_share:"
  ]];
  return expectedMenuDescription;
}

// Add Open In New Canvas on iPad
void AddOpenInNewCanvas(NSMutableArray* menu) {
  if (ui::GetDeviceFormFactor() != ui::DEVICE_FORM_FACTOR_TABLET) {
    return;
  }
  for (unsigned int i = 0; i < menu.count; i++) {
    if ([menu[i] isEqualToString:@"0:m:com.apple.menu.format"]) {
      [menu insertObject:@"1:c:_openInNewCanvas:" atIndex:i];
      return;
    }
  }
}

// Modify the expected menu for partial translate
void AddPartialTranslate(NSMutableArray* menu) {
  for (unsigned int i = 0; i < menu.count; i++) {
    if ([menu[i] isEqualToString:@"0:m:com.apple.menu.lookup"]) {
      [menu insertObject:@"0:m:chromecommand.menu" atIndex:i];
      // Deferred action.
      [menu insertObject:@"1:d" atIndex:i + 1];
      break;
    }
  }
  for (unsigned int i = 0; i < menu.count; i++) {
    if ([menu[i] isEqualToString:@"1:c:_translate:"]) {
      [menu removeObjectAtIndex:i];
      break;
    }
  }
}

// Modify the expected menu for Link to text
void AddLinkToText(NSMutableArray* menu) {
  [menu addObjectsFromArray:@[ @"0:m:chromecommand.menu.linktotext", @"1:d" ]];
}
}  // namespace

// A fake partial translate provider.
@interface TestPartialTranslateControllerFactory
    : NSObject <PartialTranslateControllerFactory>
@end

@implementation TestPartialTranslateControllerFactory

- (id<PartialTranslateController>)
    createTranslateControllerForSourceText:(NSString*)sourceText
                                anchorRect:(CGRect)anchor
                               inIncognito:(BOOL)inIncognito {
  return nil;
}

- (NSUInteger)maximumCharacterLimit {
  return 1100;
}

@end

// A delegate used to intercept the menu of the webView.
@interface EditMenuInteractionDelegate
    : NSObject <UIEditMenuInteractionDelegate>
@property(nonatomic, copy) NSArray* menuDescription;
@end

@implementation EditMenuInteractionDelegate

- (UIMenu*)editMenuInteraction:(UIEditMenuInteraction*)interaction
          menuForConfiguration:(UIEditMenuConfiguration*)configuration
              suggestedActions:(NSArray<UIMenuElement*>*)suggestedActions
    API_AVAILABLE(ios(16.0)) {
  NSMutableArray* descriptions = [NSMutableArray array];
  for (UIMenuElement* element in suggestedActions) {
    [descriptions addObjectsFromArray:MenuDescription(element, 0)];
  }
  self.menuDescription = descriptions;
  // Returning nil will fail the menu presentation.
  return nil;
}

@end

// Tests that the structure of the edit menu stays the same starting with iOS16.
// These are purposed to catch future changes in the menu.
class BrowserEditMenuHandlerTest : public PlatformTest {
 public:
  BrowserEditMenuHandlerTest()
      : web_client_(std::make_unique<ChromeWebClient>()),
        web_state_list_(&web_state_list_delegate_) {
    browser_state_ = TestChromeBrowserState::Builder().Build();

    web::WebState::CreateParams params(browser_state_.get());
    web_state_ = web::WebState::Create(params);
  }

  void SetUp() override {
    PlatformTest::SetUp();
    base_view_controller_ = [[UIViewController alloc] init];
    [scoped_key_window_.Get() setRootViewController:base_view_controller_];
  }

  void TearDown() override {
    // Reset the partial translate factory
    ios::provider::test::SetPartialTranslateControllerFactory(nil);
    PlatformTest::TearDown();
  }

  void SetupTranslateControllerFactory() {
    TestPartialTranslateControllerFactory* factory =
        [[TestPartialTranslateControllerFactory alloc] init];
    ios::provider::test::SetPartialTranslateControllerFactory(factory);
  }

  NSArray* GetMenuDescription() API_AVAILABLE(ios(16.0)) {
    EditMenuInteractionDelegate* delegate =
        [[EditMenuInteractionDelegate alloc] init];
    UIEditMenuInteraction* interaction =
        [[UIEditMenuInteraction alloc] initWithDelegate:delegate];
    [web_state_->GetView() addInteraction:interaction];

    UIEditMenuConfiguration* config = [UIEditMenuConfiguration
        configurationWithIdentifier:@"test.config"
                        sourcePoint:CGPointMake(10, 10)];
    [interaction presentEditMenuWithConfiguration:config];
    if (!base::test::ios::WaitUntilConditionOrTimeout(
            base::test::ios::kWaitForUIElementTimeout, ^{
              return delegate.menuDescription != nil;
            })) {
      return nil;
    };
    return delegate.menuDescription;
  }

 protected:
  web::WebTaskEnvironment task_environment_{
      base::test::TaskEnvironment::TimeSource::MOCK_TIME};
  web::ScopedTestingWebClient web_client_;
  std::unique_ptr<TestChromeBrowserState> browser_state_;
  FakeWebStateListDelegate web_state_list_delegate_;
  WebStateList web_state_list_;
  std::unique_ptr<web::WebState> web_state_;
  UIViewController* base_view_controller_;
  ScopedKeyWindow scoped_key_window_;
};

// Test the base structure of the menu.
TEST_F(BrowserEditMenuHandlerTest, CheckBaseMenuDescription) {
  if (@available(iOS 16, *)) {
    NSMutableArray* expectedMenuDescription = GetExpectedMenu();
    AddOpenInNewCanvas(expectedMenuDescription);
    [base_view_controller_.view addSubview:web_state_->GetView()];
    web::test::LoadHtml(kPageHTML, web_state_.get());

    EXPECT_NSEQ(expectedMenuDescription, GetMenuDescription());
  }
}

// Test the structure of the menu with Chrome actions.
TEST_F(BrowserEditMenuHandlerTest, CheckCustomizedMenuDescription) {
  if (@available(iOS 16, *)) {
    NSMutableArray* expectedMenuDescription = GetExpectedMenu();
    AddOpenInNewCanvas(expectedMenuDescription);
    AddPartialTranslate(expectedMenuDescription);
    AddLinkToText(expectedMenuDescription);
    base::test::ScopedFeatureList feature_list_;
    feature_list_.InitWithFeatures({kIOSEditMenuPartialTranslate}, {});
    SetupTranslateControllerFactory();
    PartialTranslateMediator* partial_translate_mediator =
        [[PartialTranslateMediator alloc]
              initWithWebStateList:&web_state_list_
            withBaseViewController:base_view_controller_
                       prefService:browser_state_->GetPrefs()
              fullscreenController:nullptr
                         incognito:NO];

    LinkToTextMediator* link_to_text_mediator =
        [[LinkToTextMediator alloc] initWithWebStateList:&web_state_list_];
    BrowserEditMenuHandler* handler = [[BrowserEditMenuHandler alloc] init];
    handler.partialTranslateDelegate = partial_translate_mediator;
    handler.linkToTextDelegate = link_to_text_mediator;
    BrowserContainerViewController* container_vc =
        [[BrowserContainerViewController alloc] init];
    container_vc.browserEditMenuHandler = handler;
    [container_vc willMoveToParentViewController:base_view_controller_];
    [base_view_controller_ addChildViewController:container_vc];
    [base_view_controller_.view addSubview:container_vc.view];
    [container_vc didMoveToParentViewController:base_view_controller_];

    [container_vc setContentView:web_state_->GetView()];
    web::test::LoadHtml(kPageHTML, web_state_.get());
    EXPECT_NSEQ(expectedMenuDescription, GetMenuDescription());
    handler.partialTranslateDelegate = nil;
    [partial_translate_mediator shutdown];
  }
}