chromium/ios/chrome/browser/shared/ui/elements/fade_truncating_label_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/shared/ui/elements/fade_truncating_label.h"
#import "ios/chrome/browser/shared/ui/elements/fade_truncating_label+Testing.h"

#import <vector>

#import "base/strings/sys_string_conversions.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "testing/gtest_mac.h"
#import "testing/platform_test.h"
#import "third_party/ocmock/OCMock/OCMock.h"
#import "third_party/ocmock/gtest_support.h"

namespace {

/// Width used for `kShortText`, `kTwoLineText` and `kThreeLineText` to be true.
const NSInteger kLimitedWidth = 200;
/// Text that can be drawn on one line using `kLimitedWidth`.
const NSString* kShortText = @"short text";
/// Text that can be drawn on two lines using `kLimitedWidth`.
const NSString* kTwoLinesText = @"text that should wrap on two lines";
/// Text that can be drawn on three lines using `kLimitedWidth`.
const NSString* kThreeLinesText =
    @"Long text that should take three lines when displayed";

/// Returns the number of line needed to draw `label` in `bounds`.
NSInteger NumberOfDrawnLine(UILabel* label, CGRect bounds) {
  CGRect bounding_rect = [label textRectForBounds:bounds
                           limitedToNumberOfLines:label.numberOfLines];
  NSInteger number_of_drawn_line =
      bounding_rect.size.height / label.font.lineHeight;
  return number_of_drawn_line;
}

/// Struct used to store parameters of `drawAttributedString`.
struct DrawAttributedStringParams {
  NSAttributedString* attributed_string;
  CGRect rect;
  BOOL apply_gradient;
  CGFloat alignment_offset;
};

class FadeTruncatingLabelTest : public PlatformTest {
 public:
  FadeTruncatingLabelTest() : drawAttributedString_params_() {}

 protected:
  void SetUp() override {
    short_text_ = [[FadeTruncatingLabel alloc] init];
    short_text_.text = [kShortText copy];
    two_lines_text_ = [[FadeTruncatingLabel alloc] init];
    two_lines_text_.text = [kTwoLinesText copy];
    three_lines_text_ = [[FadeTruncatingLabel alloc] init];
    three_lines_text_.text = [kThreeLinesText copy];
    labels_ = @[ short_text_, two_lines_text_, three_lines_text_ ];

    for (FadeTruncatingLabel* label in labels_) {
      StubDrawAttributedStringInRect(label);
    }

    drawAttributedString_params_.clear();
  }

  /// Stubs calls to `drawAttributedString` and store parameters in
  /// `drawAttributedString_params_`.
  void StubDrawAttributedStringInRect(FadeTruncatingLabel* label) {
    id partial_mock = OCMPartialMock(label);
    OCMStub([[partial_mock ignoringNonObjectArgs]
                drawAttributedString:[OCMArg any]
                              inRect:CGRectZero
                       applyGradient:NO
                     alignmentOffset:0.0])
        .andDo(^(NSInvocation* invocation) {
          __unsafe_unretained NSAttributedString* attributed_string = nil;
          CGRect rect = CGRectZero;
          BOOL apply_gradient = NO;
          CGFloat alignment_offset = 0.0;
          [invocation getArgument:&attributed_string atIndex:2];
          [invocation getArgument:&rect atIndex:3];
          [invocation getArgument:&apply_gradient atIndex:4];
          [invocation getArgument:&alignment_offset atIndex:5];
          drawAttributedString_params_.push_back(
              {attributed_string, rect, apply_gradient, alignment_offset});
        });
  }

  /// Checks that calls to `textRectForBounds` with `bounds` on `label` returns
  /// valid bounds.
  void ExpectValidBoundingRect(FadeTruncatingLabel* label, CGRect bounds) {
    CGRect bounding_rect = [label textRectForBounds:bounds
                             limitedToNumberOfLines:label.numberOfLines];

    EXPECT_TRUE(CGRectContainsRect(bounds, bounding_rect));

    // Compare `bounding_rect` with the bounding rect returned by `UILabel`.
    UILabel* ui_label = [[UILabel alloc] init];
    ui_label.lineBreakMode = NSLineBreakByWordWrapping;
    ui_label.text = label.text;
    ui_label.numberOfLines = label.numberOfLines;
    CGRect ui_label_bounding_rect =
        [ui_label textRectForBounds:bounds
             limitedToNumberOfLines:label.numberOfLines];
    EXPECT_NEAR(bounding_rect.size.height, ui_label_bounding_rect.size.height,
                1.0);
    // FadeTruncatingLabel fills the last line, so it might be wider than
    // UILabel.
    EXPECT_GE(bounding_rect.size.width, ui_label_bounding_rect.size.width);
  }

  /// Checks that only the last line `should_have_gradient`.
  void ExpectGradientOnLastLine(BOOL should_have_gradient) {
    if (drawAttributedString_params_.empty()) {
      return;
    }
    const size_t last_line = drawAttributedString_params_.size() - 1;
    for (size_t i = 0; i < last_line; i++) {
      EXPECT_FALSE(drawAttributedString_params_[i].apply_gradient);
    }
    EXPECT_EQ(!drawAttributedString_params_[last_line].apply_gradient,
              !should_have_gradient);
  }

  /// Checks that calls to `drawAttributedString` use valid rect when drawing in
  /// `bounds`.
  void ExpectValidDrawingRect(CGRect bounds) {
    if (drawAttributedString_params_.empty()) {
      return;
    }
    // When the available height is smaller than one line, it start to draw
    // outside of `bounds` to center the text in the available space.
    if (bounds.size.height < short_text_.font.lineHeight + FLT_EPSILON) {
      EXPECT_EQ(drawAttributedString_params_.size(), 1u);
      const DrawAttributedStringParams& param = drawAttributedString_params_[0];
      CGPoint start_x = CGPointMake(param.rect.origin.x + FLT_EPSILON,
                                    CGRectGetMidY(param.rect));
      CGPoint end_x =
          CGPointMake(param.rect.origin.x + param.rect.size.width - FLT_EPSILON,
                      CGRectGetMidY(param.rect));
      EXPECT_TRUE(CGRectContainsPoint(bounds, start_x));
      EXPECT_TRUE(CGRectContainsPoint(bounds, end_x));
    } else {
      for (const DrawAttributedStringParams& param :
           drawAttributedString_params_) {
        EXPECT_TRUE(CGRectContainsRect(bounds, param.rect));
      }
    }
  }

  std::vector<DrawAttributedStringParams> drawAttributedString_params_;
  NSArray<FadeTruncatingLabel*>* labels_;
  FadeTruncatingLabel* short_text_;
  FadeTruncatingLabel* two_lines_text_;
  FadeTruncatingLabel* three_lines_text_;
};

// Tests that this file's constants are valid.
TEST_F(FadeTruncatingLabelTest, ValidConstants) {
  CGRect bounds = CGRectMake(0, 0, kLimitedWidth, FLT_MAX);
  UILabel* label = [[UILabel alloc] init];
  label.lineBreakMode = NSLineBreakByWordWrapping;
  label.numberOfLines = 0;

  label.text = [kShortText copy];
  EXPECT_EQ(NumberOfDrawnLine(label, bounds), 1);
  label.text = [kTwoLinesText copy];
  EXPECT_EQ(NumberOfDrawnLine(label, bounds), 2);
  label.text = [kThreeLinesText copy];
  EXPECT_EQ(NumberOfDrawnLine(label, bounds), 3);
}

// Tests that FadeTruncatinglabel returns valid bounding rect when calling
// `textRectForBounds`
TEST_F(FadeTruncatingLabelTest, ValidBoundingRect) {
  CGFloat max_bound = 200;
  CGFloat bound_increment = 20;
  NSInteger max_number_of_lines = 4;

  for (CGFloat width = 0.0; width < max_bound; width += bound_increment) {
    for (CGFloat height = 0.0; height < max_bound; height += bound_increment) {
      for (NSInteger number_of_lines = 0; number_of_lines < max_number_of_lines;
           ++number_of_lines) {
        CGRect bounds = CGRectMake(0.0, 0.0, width, height);
        for (FadeTruncatingLabel* label in labels_) {
          label.numberOfLines = number_of_lines;
          ExpectValidBoundingRect(label, bounds);
        }
      }
    }
  }
}

// Tests that FadeTruncatingLabel draws with valid rect when calling
// `drawTextInRect`.
TEST_F(FadeTruncatingLabelTest, ValidDrawingRect) {
  CGFloat max_bound = 200;
  CGFloat bound_increment = 20;
  NSInteger max_number_of_lines = 4;

  for (CGFloat width = 1.0; width < max_bound; width += bound_increment) {
    for (CGFloat height = 1.0; height < max_bound; height += bound_increment) {
      for (NSInteger number_of_lines = 0; number_of_lines < max_number_of_lines;
           ++number_of_lines) {
        CGRect bounds = CGRectMake(0.0, 0.0, width, height);
        for (FadeTruncatingLabel* label in labels_) {
          label.numberOfLines = number_of_lines;
          drawAttributedString_params_.clear();
          [label drawTextInRect:bounds];
          ExpectValidDrawingRect(bounds);
        }
      }
    }
  }
}

// Tests that the gradient is only applied on the last line.
TEST_F(FadeTruncatingLabelTest, GradientOnLastLine) {
  CGRect bounds = CGRectMake(0, 0, kLimitedWidth, FLT_MAX);
  for (size_t label_index = 0; label_index < labels_.count; label_index++) {
    FadeTruncatingLabel* label = labels_[label_index];
    // Number of lines used by the label.
    const size_t label_number_of_line = label_index + 1;
    for (size_t number_of_lines = 1;
         number_of_lines <= label_number_of_line + 1; number_of_lines++) {
      drawAttributedString_params_.clear();
      label.numberOfLines = number_of_lines;
      CGRect text_rect = [label textRectForBounds:bounds
                           limitedToNumberOfLines:label.numberOfLines];
      [label drawTextInRect:text_rect];
      ExpectGradientOnLastLine(label_number_of_line > number_of_lines);
    }
  }
}

}  // namespace