chromium/ash/system/cast/cast_detailed_view_unittest.cc

// Copyright 2022 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/system/cast/cast_detailed_view.h"

#include <memory>
#include <vector>

#include "ash/public/cpp/test/test_cast_config_controller.h"
#include "ash/public/cpp/test/test_system_tray_client.h"
#include "ash/style/pill_button.h"
#include "ash/system/tray/fake_detailed_view_delegate.h"
#include "ash/system/tray/hover_highlight_view.h"
#include "ash/test/ash_test_base.h"
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/memory/raw_ptr.h"
#include "ui/gfx/geometry/point.h"
#include "ui/views/view.h"
#include "ui/views/view_utils.h"
#include "ui/views/widget/widget.h"

namespace ash {

class CastDetailedViewTest : public AshTestBase {
 public:
  CastDetailedViewTest() = default;

  // AshTestBase:
  void SetUp() override {
    AshTestBase::SetUp();
    // Create a widget so tests can click on views.
    widget_ = CreateFramelessTestWidget();
    widget_->SetFullscreen(true);
    delegate_ = std::make_unique<FakeDetailedViewDelegate>();
    detailed_view_ = widget_->SetContentsView(
        std::make_unique<CastDetailedView>(delegate_.get()));
  }

  void TearDown() override {
    widget_.reset();
    detailed_view_ = nullptr;
    delegate_.reset();
    AshTestBase::TearDown();
  }

  std::vector<views::View*> GetDeviceViews() {
    std::vector<views::View*> views;
    for (const auto& it : detailed_view_->view_to_sink_map_) {
      views.push_back(it.first);
    }
    return views;
  }

  std::vector<raw_ptr<views::View, VectorExperimental>> GetExtraViewsForSink(
      const std::string& sink_id) {
    return detailed_view_->sink_extra_views_map_[sink_id];
  }

  views::View* GetZeroStateView() { return detailed_view_->zero_state_view_; }

  // Adds two simulated cast devices.
  void AddCastDevices() {
    std::vector<SinkAndRoute> devices;
    SinkAndRoute device1;
    device1.sink.id = "fake_sink_id_1";
    device1.sink.name = "Sink Name 1";
    device1.sink.sink_icon_type = SinkIconType::kCast;
    devices.push_back(device1);
    SinkAndRoute device2;
    device2.sink.id = "fake_sink_id_2";
    device2.sink.name = "Sink Name 2";
    device2.sink.sink_icon_type = SinkIconType::kCast;
    devices.push_back(device2);
    detailed_view_->OnDevicesUpdated(devices);
  }

  // Adds simulated cast sinks and routes.
  void OnDevicesUpdated(const std::vector<SinkAndRoute>& devices) {
    detailed_view_->OnDevicesUpdated(devices);
  }

  // Removes simulated cast devices.
  void ResetCastDevices() { detailed_view_->OnDevicesUpdated({}); }

  views::View* GetAddAccessCodeDeviceView() {
    return detailed_view_->add_access_code_device_;
  }

  std::unique_ptr<views::Widget> widget_;
  TestCastConfigController cast_config_;
  std::unique_ptr<FakeDetailedViewDelegate> delegate_;
  raw_ptr<CastDetailedView, DanglingUntriaged> detailed_view_ = nullptr;
};

TEST_F(CastDetailedViewTest, ViewsCreatedForCastDevices) {
  // Adding cast devices creates views.
  AddCastDevices();
  EXPECT_EQ(GetDeviceViews().size(), 2u);

  for (views::View* view : GetDeviceViews()) {
    // Device views are children of the rounded container.
    EXPECT_STREQ(view->parent()->GetClassName(), "RoundedContainer");

    // Device views don't have a "stop casting" button by default.
    ASSERT_TRUE(views::IsViewClass<HoverHighlightView>(view));
    HoverHighlightView* row = static_cast<HoverHighlightView*>(view);
    EXPECT_FALSE(row->right_view());
  }
}

TEST_F(CastDetailedViewTest, ClickOnViewClosesBubble) {
  AddCastDevices();
  std::vector<views::View*> views = GetDeviceViews();
  ASSERT_FALSE(views.empty());
  views::View* first_view = views[0];

  // Clicking on a view triggers a cast session and closes the bubble.
  LeftClickOn(first_view);
  EXPECT_EQ(cast_config_.cast_to_sink_count(), 1u);
  EXPECT_EQ(delegate_->close_bubble_call_count(), 1u);
}

TEST_F(CastDetailedViewTest, CastToSinkClosingBubbleDoesNotCrash) {
  AddCastDevices();
  std::vector<views::View*> views = GetDeviceViews();
  ASSERT_FALSE(views.empty());
  views::View* first_view = views[0];

  // In multi-monitor situations, casting will create a picker window to choose
  // the desktop to cast. This causes a window activation that closes the
  // system tray bubble and deletes the widget owning the CastDetailedView.
  cast_config_.set_cast_to_sink_closure(
      base::BindOnce([](CastDetailedViewTest* test) { test->widget_.reset(); },
                     base::Unretained(this)));
  LeftClickOn(first_view);
  EXPECT_EQ(cast_config_.cast_to_sink_count(), 1u);
  // No crash.
}

TEST_F(CastDetailedViewTest, AccessCodeCasting) {
  cast_config_.set_access_code_casting_enabled(true);
  ResetCastDevices();
  views::View* add_access_code_device = GetAddAccessCodeDeviceView();
  ASSERT_TRUE(add_access_code_device);

  LeftClickOn(add_access_code_device);
  EXPECT_EQ(GetSystemTrayClient()->show_access_code_casting_dialog_count(), 1);
  // The bubble is not closed via the delegate, because it happens via a focus
  // change when the dialog appears.
  EXPECT_EQ(delegate_->close_bubble_call_count(), 0u);
}

// When the screen is locked, we should not show the access code device button,
// since this opens a dialog that can't be accessed when the screen is locked.
TEST_F(CastDetailedViewTest, AccessCodeCastingButtonScreenLocked) {
  cast_config_.set_access_code_casting_enabled(true);
  GetSessionControllerClient()->LockScreen();
  ResetCastDevices();
  views::View* add_access_code_device = GetAddAccessCodeDeviceView();
  EXPECT_FALSE(add_access_code_device);
}

TEST_F(CastDetailedViewTest, ZeroStateView) {
  // The zero state view shows when there are no cast devices.
  ASSERT_TRUE(GetDeviceViews().empty());
  EXPECT_TRUE(GetZeroStateView());

  // Adding cast devices hides the zero state view.
  AddCastDevices();
  EXPECT_FALSE(GetZeroStateView());

  // Removing cast devices shows the zero state view.
  ResetCastDevices();
  EXPECT_TRUE(GetZeroStateView());
}

TEST_F(CastDetailedViewTest, StopCastingButton) {
  // Set up a fake sink and route, as if this Chromebook is casting to the
  // device.
  std::vector<SinkAndRoute> devices;
  SinkAndRoute device;
  device.sink.id = "fake_sink_id_1";
  device.sink.name = "Sink Name 1";
  device.sink.sink_icon_type = SinkIconType::kCast;
  device.route.id = "fake_route_id_1";
  device.route.title = "Title 1";
  // Simulate a local source (this Chromebook).
  device.route.is_local_source = true;
  devices.push_back(device);
  OnDevicesUpdated(devices);

  std::vector<views::View*> views = GetDeviceViews();
  ASSERT_EQ(views.size(), 1u);
  ASSERT_TRUE(views::IsViewClass<HoverHighlightView>(views[0]));
  HoverHighlightView* row = static_cast<HoverHighlightView*>(views[0]);
  ASSERT_TRUE(row);

  // The row contains a button on the right.
  views::View* right_view = row->right_view();
  ASSERT_TRUE(right_view);
  EXPECT_TRUE(views::IsViewClass<PillButton>(right_view));
  EXPECT_EQ(right_view->GetTooltipText(gfx::Point()), u"Stop casting");

  // Clicking on the button stops casting.
  LeftClickOn(right_view);
  EXPECT_EQ(cast_config_.stop_casting_count(), 1u);
  EXPECT_EQ(cast_config_.stop_casting_route_id(), "fake_route_id_1");
  EXPECT_EQ(delegate_->close_bubble_call_count(), 1u);
}

TEST_F(CastDetailedViewTest, NoStopCastingButtonForNonLocalSource) {
  // Set up a fake sink and a route as if some non-local source is casting to
  // the device.
  std::vector<SinkAndRoute> devices;
  SinkAndRoute device;
  device.sink.id = "fake_sink_id_1";
  device.sink.name = "Sink Name 1";
  device.sink.sink_icon_type = SinkIconType::kCast;
  device.route.id = "fake_route_id_1";
  device.route.title = "Title 1";
  // Simulate a non-local source (not this Chromebook).
  device.route.is_local_source = false;
  devices.push_back(device);
  OnDevicesUpdated(devices);

  std::vector<views::View*> views = GetDeviceViews();
  ASSERT_EQ(views.size(), 1u);
  ASSERT_TRUE(views::IsViewClass<HoverHighlightView>(views[0]));
  HoverHighlightView* row = static_cast<HoverHighlightView*>(views[0]);
  ASSERT_TRUE(row);

  // The row does not contains a right view because there is no stop casting
  // button because the cast source is not the local machine.
  EXPECT_FALSE(row->right_view());
}

TEST_F(CastDetailedViewTest, FreezeButton) {
  // Set up a fake sink and route, as if this Chromebook is casting to the
  // device. And, the route may be frozen.
  std::vector<SinkAndRoute> devices;
  SinkAndRoute device;
  device.sink.id = "fake_sink_id_1";
  device.sink.name = "Sink Name 1";
  device.sink.sink_icon_type = SinkIconType::kCast;
  device.route.id = "fake_route_id_1";
  device.route.title = "Title 1";
  // Simulate a local source (this Chromebook).
  device.route.is_local_source = true;
  device.route.freeze_info.can_freeze = true;
  devices.push_back(device);
  OnDevicesUpdated(devices);

  std::vector<raw_ptr<views::View, VectorExperimental>> views =
      GetExtraViewsForSink("fake_sink_id_1");
  ASSERT_EQ(views.size(), 2u);
  auto* freeze_button = views[0].get();
  EXPECT_TRUE(views::IsViewClass<PillButton>(freeze_button));
  EXPECT_EQ(freeze_button->GetTooltipText(gfx::Point()), u"Pause casting");

  // Clicking on the button pauses casting.
  LeftClickOn(freeze_button);
  EXPECT_EQ(cast_config_.freeze_route_count(), 1u);
  EXPECT_EQ(cast_config_.freeze_route_route_id(), "fake_route_id_1");
  EXPECT_EQ(delegate_->close_bubble_call_count(), 1u);
}

}  // namespace ash