chromium/chromeos/ash/components/kiosk/vision/kiosk_vision_unittest.cc

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

#include "chromeos/ash/components/kiosk/vision/kiosk_vision.h"

#include <cstdint>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

#include "base/check_deref.h"
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/functional/callback_helpers.h"
#include "base/ranges/algorithm.h"
#include "base/run_loop.h"
#include "base/test/task_environment.h"
#include "base/test/test_future.h"
#include "base/time/time.h"
#include "chromeos/ash/components/dbus/dlcservice/dlcservice.pb.h"
#include "chromeos/ash/components/dbus/dlcservice/dlcservice_client.h"
#include "chromeos/ash/components/dbus/dlcservice/fake_dlcservice_client.h"
#include "chromeos/ash/components/kiosk/vision/internal/camera_service_connector.h"
#include "chromeos/ash/components/kiosk/vision/internal/fake_cros_camera_service.h"
#include "chromeos/ash/components/kiosk/vision/internal/pref_observer.h"
#include "chromeos/ash/components/kiosk/vision/pref_names.h"
#include "components/media_effects/test/fake_video_source.h"
#include "components/prefs/pref_service.h"
#include "components/prefs/testing_pref_service.h"
#include "content/public/browser/video_capture_service.h"
#include "content/public/test/browser_task_environment.h"
#include "content/public/test/test_image_transport_factory.h"
#include "media/capture/video/chromeos/mojom/cros_camera_service.mojom-forward.h"
#include "media/capture/video/chromeos/mojom/cros_camera_service.mojom.h"
#include "services/video_capture/public/cpp/mock_video_capture_service.h"
#include "services/video_capture/public/cpp/mock_video_source_provider.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/cros_system_api/dbus/dlcservice/dbus-constants.h"

namespace ash::kiosk_vision {

using testing::ElementsAre;
using testing::ElementsAreArray;
using testing::IsEmpty;

namespace {

void RegisterKioskVisionPrefs(TestingPrefServiceSimple& local_state) {
  RegisterLocalStatePrefs(local_state.registry());
}

void EnableKioskVisionTelemetryPref(PrefService& pref_service) {
  pref_service.SetBoolean(prefs::kKioskVisionTelemetryEnabled, true);
}

void DisableKioskVisionTelemetryPref(PrefService& pref_service) {
  pref_service.SetBoolean(prefs::kKioskVisionTelemetryEnabled, false);
}

DlcserviceClient::InstallResult InstallKioskVisionDlc(
    FakeDlcserviceClient& service) {
  base::test::TestFuture<const DlcserviceClient::InstallResult&> future;
  dlcservice::InstallRequest request;
  request.set_id(kKioskVisionDlcId);
  service.Install(request, future.GetCallback(), base::DoNothing());
  return future.Take();
}

dlcservice::DlcsWithContent GetExistingDlcs(FakeDlcserviceClient& service) {
  base::test::TestFuture<const dlcservice::DlcsWithContent&> future;
  service.GetExistingDlcs(
      base::BindOnce([](const std::string_view err,
                        const dlcservice::DlcsWithContent& dlcs) {
        return dlcs;
      }).Then(future.GetCallback()));
  return future.Take();
}

bool IsKioskVisionDlcInstalled(FakeDlcserviceClient& service) {
  const dlcservice::DlcsWithContent& dlcs = GetExistingDlcs(service);
  return base::ranges::any_of(dlcs.dlc_infos(), [](const auto& info) {
    return info.id() == kKioskVisionDlcId;
  });
}

cros::mojom::KioskVisionAppearancePtr NewFakeAppearance(int person_id) {
  auto appearance = cros::mojom::KioskVisionAppearance::New();
  appearance->person_id = person_id;
  appearance->face = cros::mojom::KioskVisionFaceDetection::New();
  appearance->face->box = cros::mojom::KioskVisionBoundingBox::New();
  return appearance;
}

cros::mojom::KioskVisionDetectionPtr NewFakeDetectionOfPersons(
    std::vector<int> person_ids) {
  constexpr int64_t kFakeTimestamp = 1718727537817601;

  std::vector<cros::mojom::KioskVisionAppearancePtr> appearances;
  for (int person_id : person_ids) {
    appearances.emplace_back(NewFakeAppearance(person_id));
  }

  return cros::mojom::KioskVisionDetection::New(kFakeTimestamp,
                                                std::move(appearances));
}

cros::mojom::KioskVisionTrackPtr NewFakeTrackOfPerson(int person_id,
                                                      int count) {
  constexpr int64_t kFakeTimestamp = 1719215767226132;

  std::vector<cros::mojom::KioskVisionAppearancePtr> appearances;
  for (int i = 0; i < count; i++) {
    appearances.emplace_back(NewFakeAppearance(person_id));
  }

  return cros::mojom::KioskVisionTrack::New(
      person_id, kFakeTimestamp, kFakeTimestamp, std::move(appearances));
}

media::VideoCaptureFormat CreateCaptureFormat(const gfx::Size& frame_size,
                                              float frame_rate) {
  return media::VideoCaptureFormat(frame_size, frame_rate,
                                   media::PIXEL_FORMAT_I420);
}

media::VideoCaptureDeviceInfo CreateCaptureDeviceInfo(
    const std::string& device_id,
    const std::string& display_name,
    const std::string& model_id,
    media::VideoFacingMode camera_facing_mode,
    media::VideoCaptureFormats video_capture_formats =
        media::VideoCaptureFormats{
            CreateCaptureFormat(gfx::Size(720, 640), 30.f)}) {
  media::VideoCaptureDeviceInfo info{media::VideoCaptureDeviceDescriptor(
      display_name, device_id, model_id, media::VideoCaptureApi::UNKNOWN,
      media::VideoCaptureControlSupport())};
  info.supported_formats = video_capture_formats;
  info.descriptor.facing = camera_facing_mode;
  return info;
}

CameraServiceConnector::Status GetCameraConnectorStatus(
    const KioskVision& kiosk_vision) {
  auto& camera_connector =
      CHECK_DEREF(kiosk_vision.GetCameraConnectorForTesting());
  return camera_connector.status();
}

}  // namespace

class KioskVisionTest : public testing::Test {
 public:
  void SetUp() override {
    RegisterKioskVisionPrefs(local_state_);

    // Tell the fake DLC service to only record successful installs.
    fake_dlcservice_.set_skip_adding_dlc_info_on_error(true);

    content::ImageTransportFactory::SetFactory(
        std::make_unique<content::TestImageTransportFactory>());

    content::OverrideVideoCaptureServiceForTesting(
        &mock_video_capture_service_);
    ON_CALL(mock_video_capture_service_,
            DoConnectToVideoSourceProvider(testing::_))
        .WillByDefault(testing::Invoke(
            [this](
                mojo::PendingReceiver<video_capture::mojom::VideoSourceProvider>
                    receiver) {
              if (source_provider_receiver_.is_bound()) {
                source_provider_receiver_.reset();
              }
              source_provider_receiver_.Bind(std::move(receiver));
              base::RunLoop().RunUntilIdle();
            }));

    ON_CALL(mock_source_provider_, DoGetSourceInfos(testing::_))
        .WillByDefault(
            testing::Invoke([this](video_capture::mojom::VideoSourceProvider::
                                       GetSourceInfosCallback& callback) {
              std::move(callback).Run(
                  video_capture::mojom::VideoSourceProvider::
                      GetSourceInfosResult::kSuccess,
                  device_infos_);
            }));

    ON_CALL(mock_source_provider_, DoGetVideoSource(testing::_, testing::_))
        .WillByDefault(testing::Invoke(
            [this](const std::string& device_id,
                   mojo::PendingReceiver<video_capture::mojom::VideoSource>*
                       receiver) {
              source_receivers_.Add(&fake_video_source_, std::move(*receiver));
            }));
  }

  void TearDown() override { content::ImageTransportFactory::Terminate(); }

  KioskVisionTest() : source_provider_receiver_(&mock_source_provider_) {}

 protected:
  content::BrowserTaskEnvironment browser_task_environment_{
      base::test::TaskEnvironment::TimeSource::MOCK_TIME};
  FakeCrosCameraService fake_cros_camera_service_;
  TestingPrefServiceSimple local_state_;
  FakeDlcserviceClient fake_dlcservice_;
  std::vector<media::VideoCaptureDeviceInfo> device_infos_ = {
      CreateCaptureDeviceInfo("/dev/video0",
                              "Integrated Webcam",
                              "0123:4567",
                              media::MEDIA_VIDEO_FACING_NONE)};
  mojo::Receiver<video_capture::mojom::VideoSourceProvider>
      source_provider_receiver_;
  video_capture::MockVideoSourceProvider mock_source_provider_;
  video_capture::MockVideoCaptureService mock_video_capture_service_;
  FakeVideoSource fake_video_source_;
  mojo::ReceiverSet<video_capture::mojom::VideoSource> source_receivers_;
};

TEST_F(KioskVisionTest, TelemetryPrefIsDisabledByDefault) {
  ASSERT_FALSE(IsTelemetryPrefEnabled(local_state_));
}

TEST_F(KioskVisionTest, InstallsDlcWhenEnabled) {
  ASSERT_FALSE(IsKioskVisionDlcInstalled(fake_dlcservice_));
  EnableKioskVisionTelemetryPref(local_state_);

  KioskVision vision(&local_state_);

  EXPECT_TRUE(IsKioskVisionDlcInstalled(fake_dlcservice_));
}

TEST_F(KioskVisionTest, RetriesToInstallDlcOnErrorAfterDelay) {
  ASSERT_FALSE(IsKioskVisionDlcInstalled(fake_dlcservice_));
  EnableKioskVisionTelemetryPref(local_state_);

  // Prepare one install error, such that a second install attempt should work.
  fake_dlcservice_.set_install_errors({dlcservice::kErrorNoImageFound});

  KioskVision vision(&local_state_);

  EXPECT_FALSE(IsKioskVisionDlcInstalled(fake_dlcservice_))
      << "First install attempt should fail";

  browser_task_environment_.FastForwardBy(base::Seconds(1));
  EXPECT_TRUE(IsKioskVisionDlcInstalled(fake_dlcservice_))
      << "Second install didn't work, or there was no retry";
}

TEST_F(KioskVisionTest, UninstallsDlcWhenDisabled) {
  DisableKioskVisionTelemetryPref(local_state_);

  const auto& result = InstallKioskVisionDlc(fake_dlcservice_);
  ASSERT_EQ(result.error, dlcservice::kErrorNone);
  EXPECT_TRUE(IsKioskVisionDlcInstalled(fake_dlcservice_));

  KioskVision vision(&local_state_);

  EXPECT_FALSE(IsKioskVisionDlcInstalled(fake_dlcservice_));
}

TEST_F(KioskVisionTest, BindsDetectionObserver) {
  ASSERT_FALSE(fake_cros_camera_service_.HasObserver());
  EnableKioskVisionTelemetryPref(local_state_);

  KioskVision vision(&local_state_);
  base::RunLoop().RunUntilIdle();

  ASSERT_TRUE(fake_cros_camera_service_.WaitForObserver());
  ASSERT_TRUE(fake_cros_camera_service_.HasObserver());
}

TEST_F(KioskVisionTest, TelemetryProcessorIsNullWhenDisabled) {
  DisableKioskVisionTelemetryPref(local_state_);

  KioskVision vision(&local_state_);

  base::RunLoop().RunUntilIdle();
  ASSERT_EQ(vision.GetTelemetryProcessor(), nullptr);
}

TEST_F(KioskVisionTest, TelemetryProcessorIsNotNullWhenEnabled) {
  EnableKioskVisionTelemetryPref(local_state_);

  KioskVision vision(&local_state_);

  base::RunLoop().RunUntilIdle();
  ASSERT_NE(vision.GetTelemetryProcessor(), nullptr);
}

TEST_F(KioskVisionTest, TelemetryProcessorBecomesNullOnceDisabled) {
  EnableKioskVisionTelemetryPref(local_state_);

  KioskVision vision(&local_state_);

  base::RunLoop().RunUntilIdle();
  EXPECT_NE(vision.GetTelemetryProcessor(), nullptr);

  DisableKioskVisionTelemetryPref(local_state_);

  base::RunLoop().RunUntilIdle();
  ASSERT_EQ(vision.GetTelemetryProcessor(), nullptr);
}

TEST_F(KioskVisionTest, TelemetryProcessorStartsWithoutDetections) {
  EnableKioskVisionTelemetryPref(local_state_);

  KioskVision vision(&local_state_);

  ASSERT_TRUE(fake_cros_camera_service_.WaitForObserver());

  auto& processor = CHECK_DEREF(vision.GetTelemetryProcessor());

  base::RunLoop().RunUntilIdle();

  ASSERT_THAT(processor.TakeIdsProcessed(), IsEmpty());
  ASSERT_THAT(processor.TakeErrors(), IsEmpty());
}

TEST_F(KioskVisionTest, TelemetryProcessorReceivesDetections) {
  EnableKioskVisionTelemetryPref(local_state_);

  KioskVision vision(&local_state_);

  ASSERT_TRUE(fake_cros_camera_service_.WaitForObserver());

  auto& processor = CHECK_DEREF(vision.GetTelemetryProcessor());

  fake_cros_camera_service_.EmitFakeDetection(
      NewFakeDetectionOfPersons({123, 45}));

  base::RunLoop().RunUntilIdle();
  EXPECT_THAT(processor.TakeIdsProcessed(), ElementsAreArray({123, 45}));
  EXPECT_THAT(processor.TakeErrors(), IsEmpty());
}

TEST_F(KioskVisionTest, TelemetryProcessorReceivesTracks) {
  EnableKioskVisionTelemetryPref(local_state_);

  KioskVision vision(&local_state_);

  ASSERT_TRUE(fake_cros_camera_service_.WaitForObserver());

  auto& processor = CHECK_DEREF(vision.GetTelemetryProcessor());

  fake_cros_camera_service_.EmitFakeTrack(
      NewFakeTrackOfPerson(123, /*count=*/3));

  base::RunLoop().RunUntilIdle();
  EXPECT_THAT(processor.TakeIdsProcessed(), ElementsAreArray({123}));
  EXPECT_THAT(processor.TakeErrors(), IsEmpty());
}

TEST_F(KioskVisionTest, TelemetryProcessorReceivesErrors) {
  EnableKioskVisionTelemetryPref(local_state_);

  KioskVision vision(&local_state_);

  ASSERT_TRUE(fake_cros_camera_service_.WaitForObserver());

  auto& processor = CHECK_DEREF(vision.GetTelemetryProcessor());

  auto error = cros::mojom::KioskVisionError::MODEL_ERROR;
  fake_cros_camera_service_.EmitFakeError(error);

  base::RunLoop().RunUntilIdle();
  EXPECT_THAT(processor.TakeIdsProcessed(), IsEmpty());
  EXPECT_THAT(processor.TakeErrors(), ElementsAre(error));
}

TEST_F(KioskVisionTest, VideoStreamHasStarted) {
  EnableKioskVisionTelemetryPref(local_state_);
  KioskVision vision(&local_state_);

  EXPECT_CALL(mock_source_provider_, DoGetVideoSource(testing::_, testing::_))
      .WillOnce(testing::Invoke(
          [this](const std::string& device_id,
                 mojo::PendingReceiver<video_capture::mojom::VideoSource>*
                     receiver) {
            source_receivers_.Add(&fake_video_source_, std::move(*receiver));
          }));
  base::RunLoop().RunUntilIdle();

  EXPECT_EQ(GetCameraConnectorStatus(vision),
            CameraServiceConnector::Status::kVideoStreamStarted);
}

TEST_F(KioskVisionTest, CameraNotConnected) {
  EnableKioskVisionTelemetryPref(local_state_);
  device_infos_ = {};

  KioskVision vision(&local_state_);
  base::RunLoop().RunUntilIdle();

  EXPECT_EQ(GetCameraConnectorStatus(vision),
            CameraServiceConnector::Status::kCameraNotConnected);
}

TEST_F(KioskVisionTest, VideoStreamFatalError) {
  EnableKioskVisionTelemetryPref(local_state_);
  KioskVision vision(&local_state_);

  base::RunLoop().RunUntilIdle();
  fake_video_source_.SendError(
      media::VideoCaptureError::
          kCrosHalV3DeviceDelegateFailedToOpenCameraDevice);
  base::RunLoop().RunUntilIdle();

  EXPECT_EQ(GetCameraConnectorStatus(vision),
            CameraServiceConnector::Status::kFatalErrorOrDisconnection);
}

TEST_F(KioskVisionTest, CheckVideoFormat) {
  EnableKioskVisionTelemetryPref(local_state_);
  device_infos_ = {CreateCaptureDeviceInfo(
      "/dev/video0", "Integrated Webcam", "0123:4567",
      media::MEDIA_VIDEO_FACING_NONE,
      media::VideoCaptureFormats{
          CreateCaptureFormat(
              gfx::Size(kRequestedFormatWidth - 100, kRequestedFormatHeight),
              kMinimumFrameRate + 10),
          CreateCaptureFormat(
              gfx::Size(kRequestedFormatWidth, kRequestedFormatHeight - 5),
              kMinimumFrameRate + 3),
          CreateCaptureFormat(gfx::Size(kRequestedFormatWidth + 50,
                                        kRequestedFormatHeight + 100),
                              kMinimumFrameRate + 5),  // The best format
          CreateCaptureFormat(
              gfx::Size(kRequestedFormatWidth, kRequestedFormatHeight),
              kMinimumFrameRate - 1),
          CreateCaptureFormat(gfx::Size(kRequestedFormatWidth + 100,
                                        kRequestedFormatHeight + 100),
                              kMinimumFrameRate + 10),
          CreateCaptureFormat(gfx::Size(kRequestedFormatWidth + 100,
                                        kRequestedFormatHeight + 100),
                              kMinimumFrameRate + 5),
      })};

  KioskVision vision(&local_state_);
  base::RunLoop().RunUntilIdle();

  EXPECT_EQ(fake_video_source_.get_requested_settings().requested_format,
            device_infos_[0].supported_formats[2]);
}

}  // namespace ash::kiosk_vision