chromium/ash/clipboard/clipboard_history_resource_manager_unittest.cc

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

#include "ash/clipboard/clipboard_history_resource_manager.h"

#include <string>

#include "ash/clipboard/clipboard_history.h"
#include "ash/clipboard/clipboard_history_controller_impl.h"
#include "ash/clipboard/clipboard_history_item.h"
#include "ash/clipboard/clipboard_history_url_title_fetcher.h"
#include "ash/clipboard/clipboard_history_util.h"
#include "ash/constants/ash_features.h"
#include "ash/public/cpp/clipboard_image_model_factory.h"
#include "ash/shell.h"
#include "ash/test/ash_test_base.h"
#include "base/functional/callback.h"
#include "base/location.h"
#include "base/memory/raw_ptr.h"
#include "base/run_loop.h"
#include "base/task/sequenced_task_runner.h"
#include "base/test/gmock_callback_support.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/test_future.h"
#include "chromeos/constants/chromeos_features.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/skia/include/core/SkBitmap.h"
#include "ui/base/clipboard/scoped_clipboard_writer.h"
#include "ui/gfx/image/image_skia.h"

namespace ash {

namespace {

using ::testing::_;
using ::testing::Bool;
using ::testing::StrictMock;
using ::testing::WithArg;
using ::testing::WithParamInterface;

class MockClipboardImageModelFactory : public ClipboardImageModelFactory {
 public:
  MOCK_METHOD(void,
              Render,
              (const base::UnguessableToken&,
               const std::string&,
               const gfx::Size&,
               ImageModelCallback),
              (override));
  MOCK_METHOD(void, CancelRequest, (const base::UnguessableToken&), (override));
  MOCK_METHOD(void, Activate, (), (override));
  MOCK_METHOD(void, Deactivate, (), (override));
  MOCK_METHOD(void, RenderCurrentPendingRequests, (), (override));
  void OnShutdown() override {}
};

class MockClipboardHistoryUrlTitleFetcher
    : public ClipboardHistoryUrlTitleFetcher {
 public:
  MOCK_METHOD(void,
              QueryHistory,
              (const GURL& url, OnHistoryQueryCompleteCallback callback),
              (override));
};

void FlushMessageLoop() {
  base::RunLoop run_loop;
  base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
      FROM_HERE, run_loop.QuitClosure());
  run_loop.Run();
}

SkBitmap GetRandomBitmap() {
  SkColor color = rand() % 0xFFFFFF + 1;

  SkBitmap bitmap;
  bitmap.allocN32Pixels(24, 24);
  bitmap.eraseARGB(255, SkColorGetR(color), SkColorGetG(color),
                   SkColorGetB(color));
  return bitmap;
}

ui::ImageModel GetRandomImageModel() {
  return ui::ImageModel::FromImageSkia(
      gfx::ImageSkia::CreateFrom1xBitmap(GetRandomBitmap()));
}

}  // namespace

// Tests -----------------------------------------------------------------------

class ClipboardHistoryResourceManagerTest : public AshTestBase {
 public:
  ClipboardHistoryResourceManagerTest()
      : AshTestBase(base::test::TaskEnvironment::TimeSource::MOCK_TIME) {}
  ClipboardHistoryResourceManagerTest(
      const ClipboardHistoryResourceManagerTest&) = delete;
  ClipboardHistoryResourceManagerTest& operator=(
      const ClipboardHistoryResourceManagerTest&) = delete;
  ~ClipboardHistoryResourceManagerTest() override = default;

  // AshTestBase::
  void SetUp() override {
    AshTestBase::SetUp();
    clipboard_history_ =
        Shell::Get()->clipboard_history_controller()->history();
    resource_manager_ =
        Shell::Get()->clipboard_history_controller()->resource_manager();
    mock_image_factory_ =
        std::make_unique<StrictMock<MockClipboardImageModelFactory>>();
  }

  const ClipboardHistory* clipboard_history() const {
    return clipboard_history_;
  }

  const ClipboardHistoryResourceManager* resource_manager() {
    return resource_manager_;
  }

  MockClipboardImageModelFactory* mock_image_factory() {
    return mock_image_factory_.get();
  }

 private:
  raw_ptr<const ClipboardHistory, DanglingUntriaged> clipboard_history_;
  raw_ptr<const ClipboardHistoryResourceManager, DanglingUntriaged>
      resource_manager_;
  std::unique_ptr<MockClipboardImageModelFactory> mock_image_factory_;
};

// Tests that an image model is rendered when HTML with an <img> tag is copied.
TEST_F(ClipboardHistoryResourceManagerTest, BasicImgCachedImageModel) {
  ui::ImageModel expected_image_model = GetRandomImageModel();
  ON_CALL(*mock_image_factory(), Render)
      .WillByDefault(WithArg<3>(
          [&](ClipboardImageModelFactory::ImageModelCallback callback) {
            std::move(callback).Run(expected_image_model);
          }));
  EXPECT_CALL(*mock_image_factory(), CancelRequest).Times(0);
  EXPECT_CALL(*mock_image_factory(), Render).Times(1);

  {
    ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
    scw.WriteHTML(u"<img test>", "source_url");
  }
  FlushMessageLoop();

  ASSERT_EQ(clipboard_history()->GetItems().size(), 1u);
  const auto& item = clipboard_history()->GetItems().front();
  ASSERT_TRUE(item.display_image().has_value());
  EXPECT_EQ(item.display_image().value(), expected_image_model);
}

// Tests that an image model is rendered when HTML with a <table> tag is copied.
TEST_F(ClipboardHistoryResourceManagerTest, BasicTableCachedImageModel) {
  ui::ImageModel expected_image_model = GetRandomImageModel();
  ON_CALL(*mock_image_factory(), Render)
      .WillByDefault(WithArg<3>(
          [&](ClipboardImageModelFactory::ImageModelCallback callback) {
            std::move(callback).Run(expected_image_model);
          }));
  EXPECT_CALL(*mock_image_factory(), CancelRequest).Times(0);
  EXPECT_CALL(*mock_image_factory(), Render).Times(1);

  {
    ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
    scw.WriteHTML(u"<table test>", "source_url");
  }
  FlushMessageLoop();

  ASSERT_EQ(clipboard_history()->GetItems().size(), 1u);
  const auto& item = clipboard_history()->GetItems().front();
  ASSERT_TRUE(item.display_image().has_value());
  EXPECT_EQ(item.display_image().value(), expected_image_model);
}

// Tests that an image model is not rendered when HTML without render-eligible
// tags is copied.
TEST_F(ClipboardHistoryResourceManagerTest, BasicIneligibleCachedImageModel) {
  ui::ImageModel expected_image_model = GetRandomImageModel();
  ON_CALL(*mock_image_factory(), Render)
      .WillByDefault(WithArg<3>(
          [&](ClipboardImageModelFactory::ImageModelCallback callback) {
            std::move(callback).Run(expected_image_model);
          }));
  EXPECT_CALL(*mock_image_factory(), CancelRequest).Times(0);
  EXPECT_CALL(*mock_image_factory(), Render).Times(0);

  {
    ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
    scw.WriteHTML(u"HTML with no img or table tag", "source_url");
  }
  FlushMessageLoop();

  ASSERT_EQ(clipboard_history()->GetItems().size(), 1u);
  EXPECT_FALSE(
      clipboard_history()->GetItems().front().display_image().has_value());
}

// Tests that copying duplicate HTML to the buffer results in only one render
// request.
TEST_F(ClipboardHistoryResourceManagerTest, DuplicateHTML) {
  ui::ImageModel expected_image_model = GetRandomImageModel();
  ON_CALL(*mock_image_factory(), Render)
      .WillByDefault(WithArg<3>(
          [&](ClipboardImageModelFactory::ImageModelCallback callback) {
            std::move(callback).Run(expected_image_model);
          }));
  EXPECT_CALL(*mock_image_factory(), CancelRequest).Times(0);
  EXPECT_CALL(*mock_image_factory(), Render).Times(1);

  // Write identical markup from two different source URLs so that both items
  // are added to the clipboard history.
  {
    ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
    scw.WriteHTML(u"<img test>", "source_url_1");
  }
  FlushMessageLoop();

  {
    ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
    scw.WriteHTML(u"<img test>", "source_url_2");
  }
  FlushMessageLoop();

  // Because the HTML for the two items renders to the same image, we should
  // only try to render one time.
  auto items = clipboard_history()->GetItems();
  EXPECT_EQ(items.size(), 2u);
  for (const auto& item : items) {
    ASSERT_TRUE(item.display_image().has_value());
    EXPECT_EQ(item.display_image().value(), expected_image_model);
  }
}

// Tests that copying different HTML items results in each one being rendered.
TEST_F(ClipboardHistoryResourceManagerTest, DifferentHTML) {
  ui::ImageModel first_expected_image_model = GetRandomImageModel();
  ui::ImageModel second_expected_image_model = GetRandomImageModel();
  std::deque<ui::ImageModel> expected_image_models{first_expected_image_model,
                                                   second_expected_image_model};
  ON_CALL(*mock_image_factory(), Render)
      .WillByDefault(WithArg<3>(
          [&](ClipboardImageModelFactory::ImageModelCallback callback) {
            std::move(callback).Run(expected_image_models.front());
            expected_image_models.pop_front();
          }));
  EXPECT_CALL(*mock_image_factory(), Render).Times(2);
  EXPECT_CALL(*mock_image_factory(), CancelRequest).Times(0);
  {
    ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
    scw.WriteHTML(u"<img test>", "source_url");
  }
  FlushMessageLoop();

  {
    ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
    scw.WriteHTML(u"<img different>", "source_url");
  }
  FlushMessageLoop();

  std::list<ClipboardHistoryItem> items = clipboard_history()->GetItems();
  ASSERT_EQ(items.size(), 2u);
  ASSERT_TRUE(items.front().display_image().has_value());
  EXPECT_EQ(items.front().display_image().value(), second_expected_image_model);

  items.pop_front();
  ASSERT_TRUE(items.front().display_image().has_value());
  EXPECT_EQ(items.front().display_image().value(), first_expected_image_model);
}

// Tests that copying content with non-HTML display formats does not result in
// any render requests.
TEST_F(ClipboardHistoryResourceManagerTest, IneligibleDisplayTypes) {
  EXPECT_CALL(*mock_image_factory(), Render).Times(0);
  EXPECT_CALL(*mock_image_factory(), CancelRequest).Times(0);

  // Write clipboard data with what would otherwise be render-eligible markup,
  // alongside an image. The image data format takes higher precedence, so no
  // image model should be rendered.
  {
    ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
    scw.WriteHTML(u"<img test>", "source_url");
    scw.WriteImage(GetRandomBitmap());
  }
  FlushMessageLoop();

  // There should be a display image for the bitmap, but no render request
  // should have been issued.
  ASSERT_EQ(clipboard_history()->GetItems().size(), 1u);
  EXPECT_TRUE(
      clipboard_history()->GetItems().front().display_image().has_value());

  // Write clipboard data without an HTML format. No image model should be
  // rendered.
  {
    ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
    scw.WriteText(u"test");
    scw.WriteRTF("rtf");
    scw.WriteBookmark(u"bookmark_title", "test_url");
  }
  FlushMessageLoop();

  // There should be neither a display image nor any issued render request.
  ASSERT_EQ(clipboard_history()->GetItems().size(), 2u);
  EXPECT_FALSE(
      clipboard_history()->GetItems().front().display_image().has_value());
}

// Tests that a placeholder image model is cached while rendering is ongoing.
TEST_F(ClipboardHistoryResourceManagerTest, PlaceholderDuringRender) {
  constexpr const auto kRenderDelay = base::Seconds(1);
  ui::ImageModel expected_image_model = GetRandomImageModel();
  ON_CALL(*mock_image_factory(), Render)
      .WillByDefault(WithArg<3>(
          [&](ClipboardImageModelFactory::ImageModelCallback callback) {
            // Delay the processing of the rendered image until after the
            // clipboard history item has been created.
            base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
                FROM_HERE,
                base::BindOnce(std::move(callback), expected_image_model),
                kRenderDelay);
          }));
  EXPECT_CALL(*mock_image_factory(), CancelRequest).Times(0);
  EXPECT_CALL(*mock_image_factory(), Render).Times(1);

  base::test::TestFuture<bool> operation_confirmed_future_;
  Shell::Get()
      ->clipboard_history_controller()
      ->set_confirmed_operation_callback_for_test(
          operation_confirmed_future_.GetRepeatingCallback());

  {
    ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
    scw.WriteHTML(u"<img test>", "source_url");
  }

  // Wait for the clipboard history item to be created. This allows us to check
  // for the item's intermediate placeholder image model.
  EXPECT_TRUE(operation_confirmed_future_.Take());

  // Between the time a clipboard history item is first created and the time its
  // image model finishes rendering, it should have a placeholder HTML preview.
  ASSERT_EQ(clipboard_history()->GetItems().size(), 1u);
  const auto& item = clipboard_history()->GetItems().front();
  ASSERT_TRUE(item.display_image().has_value());
  EXPECT_NE(item.display_image().value(), expected_image_model);
  EXPECT_EQ(item.display_image().value(),
            clipboard_history_util::GetHtmlPreviewPlaceholder());

  // Allow the resource manager to process the rendered image model.
  task_environment()->FastForwardBy(kRenderDelay);
  FlushMessageLoop();

  // After the resource manager processes the rendered image, it should be
  // cached in the clipboard history item.
  ASSERT_TRUE(item.display_image().has_value());
  EXPECT_EQ(item.display_image().value(), expected_image_model);
}

// Base class for `ClipboardHistoryMenuResourceManager` tests parameterized by
// whether the clipboard history URL titles feature is enabled.
class ClipboardHistoryResourceManagerUrlTitlesTest
    : public ClipboardHistoryResourceManagerTest,
      public WithParamInterface</*enable_url_titles=*/bool> {
 public:
  ClipboardHistoryResourceManagerUrlTitlesTest() {
    scoped_feature_list_.InitWithFeatureStates(
        {{chromeos::features::kClipboardHistoryRefresh,
          IsClipboardHistoryUrlTitlesEnabled()},
         {features::kClipboardHistoryUrlTitles,
          IsClipboardHistoryUrlTitlesEnabled()},
         {chromeos::features::kJelly, IsClipboardHistoryUrlTitlesEnabled()}});
  }

  // ClipboardHistoryResourceManagerTest:
  void SetUp() override {
    ClipboardHistoryResourceManagerTest::SetUp();
    Shell::Get()
        ->clipboard_history_controller()
        ->set_confirmed_operation_callback_for_test(
            operation_confirmed_future_.GetRepeatingCallback());
  }

  void WriteTextToClipboardAndConfirm(const std::u16string& str) {
    EXPECT_FALSE(operation_confirmed_future_.IsReady());
    {
      ui::ScopedClipboardWriter scw(ui::ClipboardBuffer::kCopyPaste);
      scw.WriteText(str);
    }
    EXPECT_TRUE(operation_confirmed_future_.Take());
  }

  bool IsClipboardHistoryUrlTitlesEnabled() const { return GetParam(); }

  StrictMock<MockClipboardHistoryUrlTitleFetcher>& mock_url_title_fetcher() {
    return mock_url_title_fetcher_;
  }

 private:
  base::test::ScopedFeatureList scoped_feature_list_;
  StrictMock<MockClipboardHistoryUrlTitleFetcher> mock_url_title_fetcher_;
  base::test::TestFuture<bool> operation_confirmed_future_;
};

INSTANTIATE_TEST_SUITE_P(All,
                         ClipboardHistoryResourceManagerUrlTitlesTest,
                         /*enable_url_titles=*/Bool());

// Verifies the value of clipboard history text items' secondary display text
// based on whether their display text is a URL, whether the URL title fetcher
// finds a title for the URL, and what that title is.
TEST_P(ClipboardHistoryResourceManagerUrlTitlesTest, SecondaryDisplayText) {
  struct {
    const std::u16string text;
    const std::optional<std::u16string> returned_title;
    const std::optional<std::u16string> expected_secondary_display_text;
  } test_cases[]{
      // Test that copying a visited URL sets the item's secondary display text
      // with the page's title.
      {u"https://visited.com", u"Title", u"Title"},
      // Test that a visited URL's page title has its whitespace trimmed before
      // being set as an item's secondary display text.
      {u"https://visited.com", u" Title ", u"Title"},
      // Test that a whitespace-only title is not treated as text an item should
      // display.
      {u"https://visited.com", u" ", std::nullopt},
      // Test that copying an unvisited URL triggers a history query but does
      // not set the item's secondary display text.
      {u"https://unvisited.com", std::nullopt, std::nullopt},
      // Test that copying non-URL text does not trigger a history query or set
      // the item's secondary display text.
      {u"Not a URL", std::nullopt, std::nullopt},
  };

  for (const auto& [text, returned_title, expected_secondary_display_text] :
       test_cases) {
    const GURL url(text);
    const bool should_fetch_title =
        IsClipboardHistoryUrlTitlesEnabled() && url.is_valid();

    EXPECT_CALL(mock_url_title_fetcher(), QueryHistory(url, _))
        .Times(should_fetch_title ? 1 : 0)
        .WillOnce(base::test::RunOnceCallback<1>(returned_title));

    WriteTextToClipboardAndConfirm(text);
    ASSERT_FALSE(clipboard_history()->IsEmpty());
    const auto& item = clipboard_history()->GetItems().front();
    EXPECT_EQ(
        item.secondary_display_text(),
        should_fetch_title ? expected_secondary_display_text : std::nullopt);
  }
}

}  // namespace ash