public final class

SpannedSubject

extends Subject

 java.lang.Object

↳Subject

↳androidx.media3.test.utils.truth.SpannedSubject

Gradle dependencies

compile group: 'androidx.media3', name: 'media3-test-utils', version: '1.0.0-alpha03'

  • groupId: androidx.media3
  • artifactId: media3-test-utils
  • version: 1.0.0-alpha03

Artifact androidx.media3:media3-test-utils:1.0.0-alpha03 it located at Google repository (https://maven.google.com/)

Overview

A Truth Subject for assertions on instances containing text styling.

Summary

Methods
public static SpannedSubjectassertThat(Spanned spanned)

Convenience method to create a SpannedSubject.

public SpannedSubject.AbsoluteSizedhasAbsoluteSizeSpanBetween(int start, int end)

Checks that the subject has a from start to end.

public SpannedSubject.AlignedhasAlignmentSpanBetween(int start, int end)

Checks that the subject has a from start to end.

public SpannedSubject.ColoredhasBackgroundColorSpanBetween(int start, int end)

Checks that the subject has a from start to end.

public SpannedSubject.WithSpanFlagshasBoldItalicSpanBetween(int start, int end)

Checks that the subject has bold and italic styling from start to end.

public SpannedSubject.WithSpanFlagshasBoldSpanBetween(int start, int end)

Checks that the subject has a bold span from start to end.

public SpannedSubject.ColoredhasForegroundColorSpanBetween(int start, int end)

Checks that the subject has a from start to end.

public SpannedSubject.WithSpanFlagshasHorizontalTextInVerticalContextSpanBetween(int start, int end)

Checks that the subject has an HorizontalTextInVerticalContextSpan from start to end.

public SpannedSubject.WithSpanFlagshasItalicSpanBetween(int start, int end)

Checks that the subject has an italic span from start to end.

public voidhasNoAbsoluteSizeSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

public voidhasNoAlignmentSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

public voidhasNoBackgroundColorSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

public voidhasNoForegroundColorSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

public voidhasNoHorizontalTextInVerticalContextSpanBetween(int start, int end)

Checks that the subject has no HorizontalTextInVerticalContextSpans on any of the text between start and end.

public voidhasNoRelativeSizeSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

public voidhasNoRubySpanBetween(int start, int end)

Checks that the subject has no RubySpans on any of the text between start and end.

public voidhasNoSpans()

public voidhasNoStrikethroughSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

public voidhasNoStyleSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

public voidhasNoTextEmphasisSpanBetween(int start, int end)

Checks that the subject has no TextEmphasisSpans on any of the text between start and end.

public voidhasNoTypefaceSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

public voidhasNoUnderlineSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

public SpannedSubject.RelativeSizedhasRelativeSizeSpanBetween(int start, int end)

Checks that the subject has a from start to end.

public SpannedSubject.RubyTexthasRubySpanBetween(int start, int end)

Checks that the subject has a RubySpan from start to end.

public SpannedSubject.WithSpanFlagshasStrikethroughSpanBetween(int start, int end)

Checks that the subject has an from start to end.

public SpannedSubject.EmphasizedTexthasTextEmphasisSpanBetween(int start, int end)

Checks that the subject has an TextEmphasisSpan from start to end.

public SpannedSubject.TypefacedhasTypefaceSpanBetween(int start, int end)

Checks that the subject has a from start to end.

public SpannedSubject.WithSpanFlagshasUnderlineSpanBetween(int start, int end)

Checks that the subject has an from start to end.

public static <any>spanned()

from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Methods

public static <any> spanned()

public static SpannedSubject assertThat(Spanned spanned)

Convenience method to create a SpannedSubject.

Can be statically imported alongside other Truth assertThat methods.

Parameters:

spanned: The subject under test.

Returns:

An object for conducting assertions on the subject.

public void hasNoSpans()

public SpannedSubject.WithSpanFlags hasItalicSpanBetween(int start, int end)

Checks that the subject has an italic span from start to end.

Parameters:

start: The start of the expected span.
end: The end of the expected span.

Returns:

A SpannedSubject.WithSpanFlags object for optional additional assertions on the flags.

public SpannedSubject.WithSpanFlags hasBoldSpanBetween(int start, int end)

Checks that the subject has a bold span from start to end.

Parameters:

start: The start of the expected span.
end: The end of the expected span.

Returns:

A SpannedSubject.WithSpanFlags object for optional additional assertions on the flags.

public SpannedSubject.WithSpanFlags hasBoldItalicSpanBetween(int start, int end)

Checks that the subject has bold and italic styling from start to end.

This can either be:

  • A single with span.getStyle() == Typeface.BOLD_ITALIC.
  • Two s, one with span.getStyle() == Typeface.BOLD and the other with span.getStyle() == Typeface.ITALIC.

Parameters:

start: The start of the expected span.
end: The end of the expected span.

Returns:

A SpannedSubject.WithSpanFlags object for optional additional assertions on the flags.

public void hasNoStyleSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

This fails even if the start and end indexes don't exactly match.

Parameters:

start: The start index to start searching for spans.
end: The end index to stop searching for spans.

public SpannedSubject.WithSpanFlags hasUnderlineSpanBetween(int start, int end)

Checks that the subject has an from start to end.

Parameters:

start: The start of the expected span.
end: The end of the expected span.

Returns:

A SpannedSubject.WithSpanFlags object for optional additional assertions on the flags.

public void hasNoUnderlineSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

This fails even if the start and end indexes don't exactly match.

Parameters:

start: The start index to start searching for spans.
end: The end index to stop searching for spans.

public SpannedSubject.WithSpanFlags hasStrikethroughSpanBetween(int start, int end)

Checks that the subject has an from start to end.

Parameters:

start: The start of the expected span.
end: The end of the expected span.

Returns:

A SpannedSubject.WithSpanFlags object for optional additional assertions on the flags.

public void hasNoStrikethroughSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

This fails even if the start and end indexes don't exactly match.

Parameters:

start: The start index to start searching for spans.
end: The end index to stop searching for spans.

public SpannedSubject.Aligned hasAlignmentSpanBetween(int start, int end)

Checks that the subject has a from start to end.

The alignment is asserted in a follow-up method call on the return SpannedSubject.Aligned object.

Parameters:

start: The start of the expected span.
end: The end of the expected span.

Returns:

A SpannedSubject.Aligned object to assert on the alignment of the matching spans.

public void hasNoAlignmentSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

This fails even if the start and end indexes don't exactly match.

Parameters:

start: The start index to start searching for spans.
end: The end index to stop searching for spans.

public SpannedSubject.Colored hasForegroundColorSpanBetween(int start, int end)

Checks that the subject has a from start to end.

The color is asserted in a follow-up method call on the return SpannedSubject.Colored object.

Parameters:

start: The start of the expected span.
end: The end of the expected span.

Returns:

A SpannedSubject.Colored object to assert on the color of the matching spans.

public void hasNoForegroundColorSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

This fails even if the start and end indexes don't exactly match.

Parameters:

start: The start index to start searching for spans.
end: The end index to stop searching for spans.

public SpannedSubject.Colored hasBackgroundColorSpanBetween(int start, int end)

Checks that the subject has a from start to end.

The color is asserted in a follow-up method call on the return SpannedSubject.Colored object.

Parameters:

start: The start of the expected span.
end: The end of the expected span.

Returns:

A SpannedSubject.Colored object to assert on the color of the matching spans.

public void hasNoBackgroundColorSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

This fails even if the start and end indexes don't exactly match.

Parameters:

start: The start index to start searching for spans.
end: The end index to stop searching for spans.

public SpannedSubject.Typefaced hasTypefaceSpanBetween(int start, int end)

Checks that the subject has a from start to end.

The font is asserted in a follow-up method call on the return SpannedSubject.Typefaced object.

Parameters:

start: The start of the expected span.
end: The end of the expected span.

Returns:

A SpannedSubject.Typefaced object to assert on the font of the matching spans.

public void hasNoTypefaceSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

This fails even if the start and end indexes don't exactly match.

Parameters:

start: The start index to start searching for spans.
end: The end index to stop searching for spans.

public SpannedSubject.AbsoluteSized hasAbsoluteSizeSpanBetween(int start, int end)

Checks that the subject has a from start to end.

The size is asserted in a follow-up method call on the return SpannedSubject.AbsoluteSized object.

Parameters:

start: The start of the expected span.
end: The end of the expected span.

Returns:

A SpannedSubject.AbsoluteSized object to assert on the size of the matching spans.

public void hasNoAbsoluteSizeSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

This fails even if the start and end indexes don't exactly match.

Parameters:

start: The start index to start searching for spans.
end: The end index to stop searching for spans.

public SpannedSubject.RelativeSized hasRelativeSizeSpanBetween(int start, int end)

Checks that the subject has a from start to end.

The size is asserted in a follow-up method call on the return SpannedSubject.RelativeSized object.

Parameters:

start: The start of the expected span.
end: The end of the expected span.

Returns:

A SpannedSubject.RelativeSized object to assert on the size of the matching spans.

public void hasNoRelativeSizeSpanBetween(int start, int end)

Checks that the subject has no s on any of the text between start and end.

This fails even if the start and end indexes don't exactly match.

Parameters:

start: The start index to start searching for spans.
end: The end index to stop searching for spans.

public SpannedSubject.RubyText hasRubySpanBetween(int start, int end)

Checks that the subject has a RubySpan from start to end.

The ruby-text is asserted in a follow-up method call on the return SpannedSubject.RubyText object.

Parameters:

start: The start of the expected span.
end: The end of the expected span.

Returns:

A SpannedSubject.Colored object to assert on the color of the matching spans.

public void hasNoRubySpanBetween(int start, int end)

Checks that the subject has no RubySpans on any of the text between start and end.

This fails even if the start and end indexes don't exactly match.

Parameters:

start: The start index to start searching for spans.
end: The end index to stop searching for spans.

public SpannedSubject.WithSpanFlags hasHorizontalTextInVerticalContextSpanBetween(int start, int end)

Checks that the subject has an HorizontalTextInVerticalContextSpan from start to end.

Parameters:

start: The start of the expected span.
end: The end of the expected span.

Returns:

A SpannedSubject.WithSpanFlags object for optional additional assertions on the flags.

public SpannedSubject.EmphasizedText hasTextEmphasisSpanBetween(int start, int end)

Checks that the subject has an TextEmphasisSpan from start to end.

Parameters:

start: The start of the expected span.
end: The end of the expected span.

Returns:

A SpannedSubject.EmphasizedText object for optional additional assertions on the flags.

public void hasNoTextEmphasisSpanBetween(int start, int end)

Checks that the subject has no TextEmphasisSpans on any of the text between start and end.

This fails even if the start and end indexes don't exactly match.

Parameters:

start: The start index to start searching for spans.
end: The end index to stop searching for spans.

public void hasNoHorizontalTextInVerticalContextSpanBetween(int start, int end)

Checks that the subject has no HorizontalTextInVerticalContextSpans on any of the text between start and end.

This fails even if the start and end indexes don't exactly match.

Parameters:

start: The start index to start searching for spans.
end: The end index to stop searching for spans.

Source

/*
 * Copyright (C) 2019 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */
package androidx.media3.test.utils.truth;

import static com.google.common.truth.Fact.fact;
import static com.google.common.truth.Fact.simpleFact;
import static com.google.common.truth.Truth.assertAbout;

import android.graphics.Typeface;
import android.text.Layout.Alignment;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.AlignmentSpan;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.RelativeSizeSpan;
import android.text.style.StrikethroughSpan;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import android.text.style.UnderlineSpan;
import androidx.annotation.CheckResult;
import androidx.annotation.ColorInt;
import androidx.annotation.Nullable;
import androidx.media3.common.text.HorizontalTextInVerticalContextSpan;
import androidx.media3.common.text.RubySpan;
import androidx.media3.common.text.TextAnnotation;
import androidx.media3.common.text.TextEmphasisSpan;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import com.google.common.truth.Fact;
import com.google.common.truth.FailureMetadata;
import com.google.common.truth.Subject;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.checkerframework.checker.nullness.compatqual.NullableType;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;

/** A Truth {@link Subject} for assertions on {@link Spanned} instances containing text styling. */
@UnstableApi
public final class SpannedSubject extends Subject {

  @Nullable private final Spanned actual;

  private SpannedSubject(FailureMetadata metadata, @Nullable Spanned actual) {
    super(metadata, actual);
    this.actual = actual;
  }

  public static Factory<SpannedSubject, Spanned> spanned() {
    return SpannedSubject::new;
  }

  /**
   * Convenience method to create a SpannedSubject.
   *
   * <p>Can be statically imported alongside other Truth {@code assertThat} methods.
   *
   * @param spanned The subject under test.
   * @return An object for conducting assertions on the subject.
   */
  public static SpannedSubject assertThat(@Nullable Spanned spanned) {
    return assertAbout(spanned()).that(spanned);
  }

  public void hasNoSpans() {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return;
    }

    Object[] spans = actual.getSpans(0, actual.length(), Object.class);
    if (spans.length > 0) {
      failWithoutActual(
          simpleFact("Expected no spans"), fact("in text", actual), actualSpansFact());
    }
  }

  /**
   * Checks that the subject has an italic span from {@code start} to {@code end}.
   *
   * @param start The start of the expected span.
   * @param end The end of the expected span.
   * @return A {@link WithSpanFlags} object for optional additional assertions on the flags.
   */
  public WithSpanFlags hasItalicSpanBetween(int start, int end) {
    return hasStyleSpan(start, end, Typeface.ITALIC);
  }

  /**
   * Checks that the subject has a bold span from {@code start} to {@code end}.
   *
   * @param start The start of the expected span.
   * @param end The end of the expected span.
   * @return A {@link WithSpanFlags} object for optional additional assertions on the flags.
   */
  public WithSpanFlags hasBoldSpanBetween(int start, int end) {
    return hasStyleSpan(start, end, Typeface.BOLD);
  }

  private WithSpanFlags hasStyleSpan(int start, int end, int style) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return ALREADY_FAILED_WITH_FLAGS;
    }

    List<Integer> allFlags = new ArrayList<>();
    boolean matchingSpanFound = false;
    for (StyleSpan span : findMatchingSpans(start, end, StyleSpan.class)) {
      allFlags.add(actual.getSpanFlags(span));
      if (span.getStyle() == style) {
        matchingSpanFound = true;
        break;
      }
    }
    if (matchingSpanFound) {
      return check("StyleSpan (start=%s,end=%s,style=%s)", start, end, style)
          .about(spanFlags())
          .that(allFlags);
    }

    failWithExpectedSpan(start, end, StyleSpan.class, actual.toString().substring(start, end));
    return ALREADY_FAILED_WITH_FLAGS;
  }

  /**
   * Checks that the subject has bold and italic styling from {@code start} to {@code end}.
   *
   * <p>This can either be:
   *
   * <ul>
   *   <li>A single {@link StyleSpan} with {@code span.getStyle() == Typeface.BOLD_ITALIC}.
   *   <li>Two {@link StyleSpan}s, one with {@code span.getStyle() == Typeface.BOLD} and the other
   *       with {@code span.getStyle() == Typeface.ITALIC}.
   * </ul>
   *
   * @param start The start of the expected span.
   * @param end The end of the expected span.
   * @return A {@link WithSpanFlags} object for optional additional assertions on the flags.
   */
  public WithSpanFlags hasBoldItalicSpanBetween(int start, int end) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return ALREADY_FAILED_WITH_FLAGS;
    }

    List<Integer> allFlags = new ArrayList<>();
    List<Integer> styles = new ArrayList<>();
    for (StyleSpan span : findMatchingSpans(start, end, StyleSpan.class)) {
      allFlags.add(actual.getSpanFlags(span));
      styles.add(span.getStyle());
    }
    if (styles.isEmpty()) {
      failWithExpectedSpan(start, end, StyleSpan.class, actual.subSequence(start, end).toString());
      return ALREADY_FAILED_WITH_FLAGS;
    }

    if (styles.contains(Typeface.BOLD_ITALIC)
        || (styles.contains(Typeface.BOLD) && styles.contains(Typeface.ITALIC))) {
      return check("StyleSpan (start=%s,end=%s)", start, end).about(spanFlags()).that(allFlags);
    }
    failWithoutActual(
        simpleFact(
            String.format("No matching StyleSpans found between start=%s,end=%s", start, end)),
        fact("in text", actual.toString()),
        fact("expected to contain either", Collections.singletonList(Typeface.BOLD_ITALIC)),
        fact("or both", Arrays.asList(Typeface.BOLD, Typeface.ITALIC)),
        fact("but found styles", styles));
    return ALREADY_FAILED_WITH_FLAGS;
  }

  /**
   * Checks that the subject has no {@link StyleSpan}s on any of the text between {@code start} and
   * {@code end}.
   *
   * <p>This fails even if the start and end indexes don't exactly match.
   *
   * @param start The start index to start searching for spans.
   * @param end The end index to stop searching for spans.
   */
  public void hasNoStyleSpanBetween(int start, int end) {
    hasNoSpansOfTypeBetween(StyleSpan.class, start, end);
  }

  /**
   * Checks that the subject has an {@link UnderlineSpan} from {@code start} to {@code end}.
   *
   * @param start The start of the expected span.
   * @param end The end of the expected span.
   * @return A {@link WithSpanFlags} object for optional additional assertions on the flags.
   */
  public WithSpanFlags hasUnderlineSpanBetween(int start, int end) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return ALREADY_FAILED_WITH_FLAGS;
    }

    List<UnderlineSpan> underlineSpans = findMatchingSpans(start, end, UnderlineSpan.class);
    if (underlineSpans.size() == 1) {
      return check("UnderlineSpan (start=%s,end=%s)", start, end)
          .about(spanFlags())
          .that(Collections.singletonList(actual.getSpanFlags(underlineSpans.get(0))));
    }
    failWithExpectedSpan(start, end, UnderlineSpan.class, actual.toString().substring(start, end));
    return ALREADY_FAILED_WITH_FLAGS;
  }

  /**
   * Checks that the subject has no {@link UnderlineSpan}s on any of the text between {@code start}
   * and {@code end}.
   *
   * <p>This fails even if the start and end indexes don't exactly match.
   *
   * @param start The start index to start searching for spans.
   * @param end The end index to stop searching for spans.
   */
  public void hasNoUnderlineSpanBetween(int start, int end) {
    hasNoSpansOfTypeBetween(UnderlineSpan.class, start, end);
  }

  /**
   * Checks that the subject has an {@link StrikethroughSpan} from {@code start} to {@code end}.
   *
   * @param start The start of the expected span.
   * @param end The end of the expected span.
   * @return A {@link WithSpanFlags} object for optional additional assertions on the flags.
   */
  public WithSpanFlags hasStrikethroughSpanBetween(int start, int end) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return ALREADY_FAILED_WITH_FLAGS;
    }

    List<StrikethroughSpan> strikethroughSpans =
        findMatchingSpans(start, end, StrikethroughSpan.class);
    if (strikethroughSpans.size() == 1) {
      return check("StrikethroughSpan (start=%s,end=%s)", start, end)
          .about(spanFlags())
          .that(Collections.singletonList(actual.getSpanFlags(strikethroughSpans.get(0))));
    }
    failWithExpectedSpan(
        start, end, StrikethroughSpan.class, actual.toString().substring(start, end));
    return ALREADY_FAILED_WITH_FLAGS;
  }

  /**
   * Checks that the subject has no {@link StrikethroughSpan}s on any of the text between {@code
   * start} and {@code end}.
   *
   * <p>This fails even if the start and end indexes don't exactly match.
   *
   * @param start The start index to start searching for spans.
   * @param end The end index to stop searching for spans.
   */
  public void hasNoStrikethroughSpanBetween(int start, int end) {
    hasNoSpansOfTypeBetween(StrikethroughSpan.class, start, end);
  }

  /**
   * Checks that the subject has a {@link AlignmentSpan} from {@code start} to {@code end}.
   *
   * <p>The alignment is asserted in a follow-up method call on the return {@link Aligned} object.
   *
   * @param start The start of the expected span.
   * @param end The end of the expected span.
   * @return A {@link Aligned} object to assert on the alignment of the matching spans.
   */
  @CheckResult
  public Aligned hasAlignmentSpanBetween(int start, int end) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return ALREADY_FAILED_ALIGNED;
    }

    List<AlignmentSpan> alignmentSpans = findMatchingSpans(start, end, AlignmentSpan.class);
    if (alignmentSpans.isEmpty()) {
      failWithExpectedSpan(
          start, end, AlignmentSpan.class, actual.toString().substring(start, end));
      return ALREADY_FAILED_ALIGNED;
    }
    return check("AlignmentSpan (start=%s,end=%s)", start, end)
        .about(alignmentSpans(actual))
        .that(alignmentSpans);
  }

  /**
   * Checks that the subject has no {@link AlignmentSpan}s on any of the text between {@code start}
   * and {@code end}.
   *
   * <p>This fails even if the start and end indexes don't exactly match.
   *
   * @param start The start index to start searching for spans.
   * @param end The end index to stop searching for spans.
   */
  public void hasNoAlignmentSpanBetween(int start, int end) {
    hasNoSpansOfTypeBetween(AlignmentSpan.class, start, end);
  }

  /**
   * Checks that the subject has a {@link ForegroundColorSpan} from {@code start} to {@code end}.
   *
   * <p>The color is asserted in a follow-up method call on the return {@link Colored} object.
   *
   * @param start The start of the expected span.
   * @param end The end of the expected span.
   * @return A {@link Colored} object to assert on the color of the matching spans.
   */
  @CheckResult
  public Colored hasForegroundColorSpanBetween(int start, int end) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return ALREADY_FAILED_COLORED;
    }

    List<ForegroundColorSpan> foregroundColorSpans =
        findMatchingSpans(start, end, ForegroundColorSpan.class);
    if (foregroundColorSpans.isEmpty()) {
      failWithExpectedSpan(
          start, end, ForegroundColorSpan.class, actual.toString().substring(start, end));
      return ALREADY_FAILED_COLORED;
    }
    return check("ForegroundColorSpan (start=%s,end=%s)", start, end)
        .about(foregroundColorSpans(actual))
        .that(foregroundColorSpans);
  }

  /**
   * Checks that the subject has no {@link ForegroundColorSpan}s on any of the text between {@code
   * start} and {@code end}.
   *
   * <p>This fails even if the start and end indexes don't exactly match.
   *
   * @param start The start index to start searching for spans.
   * @param end The end index to stop searching for spans.
   */
  public void hasNoForegroundColorSpanBetween(int start, int end) {
    hasNoSpansOfTypeBetween(ForegroundColorSpan.class, start, end);
  }

  /**
   * Checks that the subject has a {@link BackgroundColorSpan} from {@code start} to {@code end}.
   *
   * <p>The color is asserted in a follow-up method call on the return {@link Colored} object.
   *
   * @param start The start of the expected span.
   * @param end The end of the expected span.
   * @return A {@link Colored} object to assert on the color of the matching spans.
   */
  @CheckResult
  public Colored hasBackgroundColorSpanBetween(int start, int end) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return ALREADY_FAILED_COLORED;
    }

    List<BackgroundColorSpan> backgroundColorSpans =
        findMatchingSpans(start, end, BackgroundColorSpan.class);
    if (backgroundColorSpans.isEmpty()) {
      failWithExpectedSpan(
          start, end, BackgroundColorSpan.class, actual.toString().substring(start, end));
      return ALREADY_FAILED_COLORED;
    }
    return check("BackgroundColorSpan (start=%s,end=%s)", start, end)
        .about(backgroundColorSpans(actual))
        .that(backgroundColorSpans);
  }

  /**
   * Checks that the subject has no {@link BackgroundColorSpan}s on any of the text between {@code
   * start} and {@code end}.
   *
   * <p>This fails even if the start and end indexes don't exactly match.
   *
   * @param start The start index to start searching for spans.
   * @param end The end index to stop searching for spans.
   */
  public void hasNoBackgroundColorSpanBetween(int start, int end) {
    hasNoSpansOfTypeBetween(BackgroundColorSpan.class, start, end);
  }

  /**
   * Checks that the subject has a {@link TypefaceSpan} from {@code start} to {@code end}.
   *
   * <p>The font is asserted in a follow-up method call on the return {@link Typefaced} object.
   *
   * @param start The start of the expected span.
   * @param end The end of the expected span.
   * @return A {@link Typefaced} object to assert on the font of the matching spans.
   */
  @CheckResult
  public Typefaced hasTypefaceSpanBetween(int start, int end) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return ALREADY_FAILED_TYPEFACED;
    }

    List<TypefaceSpan> backgroundColorSpans = findMatchingSpans(start, end, TypefaceSpan.class);
    if (backgroundColorSpans.isEmpty()) {
      failWithExpectedSpan(start, end, TypefaceSpan.class, actual.toString().substring(start, end));
      return ALREADY_FAILED_TYPEFACED;
    }
    return check("TypefaceSpan (start=%s,end=%s)", start, end)
        .about(typefaceSpans(actual))
        .that(backgroundColorSpans);
  }

  /**
   * Checks that the subject has no {@link TypefaceSpan}s on any of the text between {@code start}
   * and {@code end}.
   *
   * <p>This fails even if the start and end indexes don't exactly match.
   *
   * @param start The start index to start searching for spans.
   * @param end The end index to stop searching for spans.
   */
  public void hasNoTypefaceSpanBetween(int start, int end) {
    hasNoSpansOfTypeBetween(TypefaceSpan.class, start, end);
  }

  /**
   * Checks that the subject has a {@link AbsoluteSizeSpan} from {@code start} to {@code end}.
   *
   * <p>The size is asserted in a follow-up method call on the return {@link AbsoluteSized} object.
   *
   * @param start The start of the expected span.
   * @param end The end of the expected span.
   * @return A {@link AbsoluteSized} object to assert on the size of the matching spans.
   */
  @CheckResult
  public AbsoluteSized hasAbsoluteSizeSpanBetween(int start, int end) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return ALREADY_FAILED_ABSOLUTE_SIZED;
    }

    List<AbsoluteSizeSpan> absoluteSizeSpans =
        findMatchingSpans(start, end, AbsoluteSizeSpan.class);
    if (absoluteSizeSpans.isEmpty()) {
      failWithExpectedSpan(
          start, end, AbsoluteSizeSpan.class, actual.toString().substring(start, end));
      return ALREADY_FAILED_ABSOLUTE_SIZED;
    }
    return check("AbsoluteSizeSpan (start=%s,end=%s)", start, end)
        .about(absoluteSizeSpans(actual))
        .that(absoluteSizeSpans);
  }

  /**
   * Checks that the subject has no {@link AbsoluteSizeSpan}s on any of the text between {@code
   * start} and {@code end}.
   *
   * <p>This fails even if the start and end indexes don't exactly match.
   *
   * @param start The start index to start searching for spans.
   * @param end The end index to stop searching for spans.
   */
  public void hasNoAbsoluteSizeSpanBetween(int start, int end) {
    hasNoSpansOfTypeBetween(AbsoluteSizeSpan.class, start, end);
  }

  /**
   * Checks that the subject has a {@link RelativeSizeSpan} from {@code start} to {@code end}.
   *
   * <p>The size is asserted in a follow-up method call on the return {@link RelativeSized} object.
   *
   * @param start The start of the expected span.
   * @param end The end of the expected span.
   * @return A {@link RelativeSized} object to assert on the size of the matching spans.
   */
  @CheckResult
  public RelativeSized hasRelativeSizeSpanBetween(int start, int end) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return ALREADY_FAILED_RELATIVE_SIZED;
    }

    List<RelativeSizeSpan> relativeSizeSpans =
        findMatchingSpans(start, end, RelativeSizeSpan.class);
    if (relativeSizeSpans.isEmpty()) {
      failWithExpectedSpan(
          start, end, RelativeSizeSpan.class, actual.toString().substring(start, end));
      return ALREADY_FAILED_RELATIVE_SIZED;
    }
    return check("RelativeSizeSpan (start=%s,end=%s)", start, end)
        .about(relativeSizeSpans(actual))
        .that(relativeSizeSpans);
  }

  /**
   * Checks that the subject has no {@link RelativeSizeSpan}s on any of the text between {@code
   * start} and {@code end}.
   *
   * <p>This fails even if the start and end indexes don't exactly match.
   *
   * @param start The start index to start searching for spans.
   * @param end The end index to stop searching for spans.
   */
  public void hasNoRelativeSizeSpanBetween(int start, int end) {
    hasNoSpansOfTypeBetween(RelativeSizeSpan.class, start, end);
  }

  /**
   * Checks that the subject has a {@link RubySpan} from {@code start} to {@code end}.
   *
   * <p>The ruby-text is asserted in a follow-up method call on the return {@link RubyText} object.
   *
   * @param start The start of the expected span.
   * @param end The end of the expected span.
   * @return A {@link Colored} object to assert on the color of the matching spans.
   */
  @CheckResult
  public RubyText hasRubySpanBetween(int start, int end) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return ALREADY_FAILED_WITH_TEXT;
    }

    List<RubySpan> rubySpans = findMatchingSpans(start, end, RubySpan.class);
    if (rubySpans.isEmpty()) {
      failWithExpectedSpan(start, end, RubySpan.class, actual.toString().substring(start, end));
      return ALREADY_FAILED_WITH_TEXT;
    }
    return check("RubySpan (start=%s,end=%s)", start, end).about(rubySpans(actual)).that(rubySpans);
  }

  /**
   * Checks that the subject has no {@link RubySpan}s on any of the text between {@code start} and
   * {@code end}.
   *
   * <p>This fails even if the start and end indexes don't exactly match.
   *
   * @param start The start index to start searching for spans.
   * @param end The end index to stop searching for spans.
   */
  public void hasNoRubySpanBetween(int start, int end) {
    hasNoSpansOfTypeBetween(RubySpan.class, start, end);
  }

  /**
   * Checks that the subject has an {@link HorizontalTextInVerticalContextSpan} from {@code start}
   * to {@code end}.
   *
   * @param start The start of the expected span.
   * @param end The end of the expected span.
   * @return A {@link WithSpanFlags} object for optional additional assertions on the flags.
   */
  public WithSpanFlags hasHorizontalTextInVerticalContextSpanBetween(int start, int end) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return ALREADY_FAILED_WITH_FLAGS;
    }

    List<HorizontalTextInVerticalContextSpan> horizontalInVerticalSpans =
        findMatchingSpans(start, end, HorizontalTextInVerticalContextSpan.class);
    if (horizontalInVerticalSpans.size() == 1) {
      return check("HorizontalTextInVerticalContextSpan (start=%s,end=%s)", start, end)
          .about(spanFlags())
          .that(Collections.singletonList(actual.getSpanFlags(horizontalInVerticalSpans.get(0))));
    }
    failWithExpectedSpan(
        start,
        end,
        HorizontalTextInVerticalContextSpan.class,
        actual.toString().substring(start, end));
    return ALREADY_FAILED_WITH_FLAGS;
  }

  /**
   * Checks that the subject has an {@link TextEmphasisSpan} from {@code start} to {@code end}.
   *
   * @param start The start of the expected span.
   * @param end The end of the expected span.
   * @return A {@link EmphasizedText} object for optional additional assertions on the flags.
   */
  public EmphasizedText hasTextEmphasisSpanBetween(int start, int end) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return ALREADY_FAILED_WITH_MARK_AND_POSITION;
    }

    List<TextEmphasisSpan> textEmphasisSpans =
        findMatchingSpans(start, end, TextEmphasisSpan.class);
    if (textEmphasisSpans.size() == 1) {
      return check("TextEmphasisSpan (start=%s,end=%s)", start, end)
          .about(textEmphasisSubjects(actual))
          .that(textEmphasisSpans);
    }
    failWithExpectedSpan(
        start, end, TextEmphasisSpan.class, actual.toString().substring(start, end));
    return ALREADY_FAILED_WITH_MARK_AND_POSITION;
  }

  /**
   * Checks that the subject has no {@link TextEmphasisSpan}s on any of the text between {@code
   * start} and {@code end}.
   *
   * <p>This fails even if the start and end indexes don't exactly match.
   *
   * @param start The start index to start searching for spans.
   * @param end The end index to stop searching for spans.
   */
  public void hasNoTextEmphasisSpanBetween(int start, int end) {
    hasNoSpansOfTypeBetween(TextEmphasisSpan.class, start, end);
  }

  /**
   * Checks that the subject has no {@link HorizontalTextInVerticalContextSpan}s on any of the text
   * between {@code start} and {@code end}.
   *
   * <p>This fails even if the start and end indexes don't exactly match.
   *
   * @param start The start index to start searching for spans.
   * @param end The end index to stop searching for spans.
   */
  public void hasNoHorizontalTextInVerticalContextSpanBetween(int start, int end) {
    hasNoSpansOfTypeBetween(HorizontalTextInVerticalContextSpan.class, start, end);
  }

  /**
   * Checks that the subject has no spans of type {@code spanClazz} on any of the text between
   * {@code start} and {@code end}.
   *
   * <p>This fails even if the start and end indexes don't exactly match.
   *
   * @param start The start index to start searching for spans.
   * @param end The end index to stop searching for spans.
   */
  private void hasNoSpansOfTypeBetween(Class<?> spanClazz, int start, int end) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return;
    }

    @NullableType Object[] matchingSpans = actual.getSpans(start, end, spanClazz);
    if (matchingSpans.length != 0) {
      failWithoutActual(
          simpleFact(
              String.format(
                  "Found unexpected %ss between start=%s,end=%s",
                  spanClazz.getSimpleName(), start, end)),
          simpleFact("expected none"),
          actualSpansFact());
    }
  }

  private <T> List<T> findMatchingSpans(int startIndex, int endIndex, Class<T> spanClazz) {
    if (actual == null) {
      failWithoutActual(simpleFact("Spanned must not be null"));
      return Collections.emptyList();
    }

    List<T> spans = new ArrayList<>();
    for (T span : actual.getSpans(startIndex, endIndex, spanClazz)) {
      if (actual.getSpanStart(span) == startIndex && actual.getSpanEnd(span) == endIndex) {
        spans.add(span);
      }
    }
    return Collections.unmodifiableList(spans);
  }

  @RequiresNonNull("actual")
  private void failWithExpectedSpan(
      int start, int end, Class<?> spanType, String spannedSubstring) {
    failWithoutActual(
        simpleFact("No matching span found"),
        fact("in text", actual),
        fact("expected", getSpanAsString(start, end, spanType, spannedSubstring)),
        actualSpansFact());
  }

  @RequiresNonNull("actual")
  private Fact actualSpansFact() {
    String actualSpans = getAllSpansAsString(actual);
    if (actualSpans.isEmpty()) {
      return Fact.simpleFact("but found no spans");
    } else {
      return Fact.fact("but found", actualSpans);
    }
  }

  private static String getAllSpansAsString(Spanned spanned) {
    List<String> actualSpanStrings = new ArrayList<>();
    for (Object span : spanned.getSpans(0, spanned.length(), Object.class)) {
      actualSpanStrings.add(getSpanAsString(span, spanned));
    }
    return TextUtils.join("\n", actualSpanStrings);
  }

  private static String getSpanAsString(Object span, Spanned spanned) {
    int spanStart = spanned.getSpanStart(span);
    int spanEnd = spanned.getSpanEnd(span);
    return getSpanAsString(
        spanStart, spanEnd, span.getClass(), spanned.toString().substring(spanStart, spanEnd));
  }

  private static String getSpanAsString(
      int start, int end, Class<?> span, String spannedSubstring) {
    return String.format(
        "start=%s\tend=%s\ttype=%s\tsubstring='%s'",
        start, end, span.getSimpleName(), spannedSubstring);
  }

  /**
   * Allows additional assertions to be made on the flags of matching spans.
   *
   * <p>Identical to {@link WithSpanFlags}, but this should be returned from {@code with...()}
   * methods while {@link WithSpanFlags} should be returned from {@code has...()} methods.
   *
   * <p>See Flag constants on {@link Spanned} for possible values.
   */
  public interface AndSpanFlags {

    /**
     * Checks that one of the matched spans has the expected {@code flags}.
     *
     * @param flags The expected flags. See SPAN_* constants on {@link Spanned} for possible values.
     */
    void andFlags(int flags);
  }

  private static final AndSpanFlags ALREADY_FAILED_AND_FLAGS = flags -> {};

  /**
   * Allows additional assertions to be made on the flags of matching spans.
   *
   * <p>Identical to {@link AndSpanFlags}, but this should be returned from {@code has...()} methods
   * while {@link AndSpanFlags} should be returned from {@code with...()} methods.
   */
  public interface WithSpanFlags {

    /**
     * Checks that one of the matched spans has the expected {@code flags}.
     *
     * @param flags The expected flags. See SPAN_* constants on {@link Spanned} for possible values.
     */
    void withFlags(int flags);
  }

  private static final WithSpanFlags ALREADY_FAILED_WITH_FLAGS = flags -> {};

  private static Factory<SpanFlagsSubject, List<Integer>> spanFlags() {
    return SpanFlagsSubject::new;
  }

  private static final class SpanFlagsSubject extends Subject
      implements AndSpanFlags, WithSpanFlags {

    private final List<Integer> flags;

    private SpanFlagsSubject(FailureMetadata metadata, List<Integer> flags) {
      super(metadata, flags);
      this.flags = flags;
    }

    @Override
    public void andFlags(int flags) {
      check("contains()").that(this.flags).contains(flags);
    }

    @Override
    public void withFlags(int flags) {
      andFlags(flags);
    }
  }

  /** Allows assertions about the alignment of a span. */
  public interface Aligned {

    /**
     * Checks that at least one of the matched spans has the expected {@code alignment}.
     *
     * @param alignment The expected alignment.
     * @return A {@link WithSpanFlags} object for optional additional assertions on the flags.
     */
    AndSpanFlags withAlignment(Alignment alignment);
  }

  private static final Aligned ALREADY_FAILED_ALIGNED = alignment -> ALREADY_FAILED_AND_FLAGS;

  private static Factory<AlignmentSpansSubject, List<AlignmentSpan>> alignmentSpans(
      Spanned actualSpanned) {
    return (FailureMetadata metadata, List<AlignmentSpan> spans) ->
        new AlignmentSpansSubject(metadata, spans, actualSpanned);
  }

  private static final class AlignmentSpansSubject extends Subject implements Aligned {

    private final List<AlignmentSpan> actualSpans;
    private final Spanned actualSpanned;

    private AlignmentSpansSubject(
        FailureMetadata metadata, List<AlignmentSpan> actualSpans, Spanned actualSpanned) {
      super(metadata, actualSpans);
      this.actualSpans = actualSpans;
      this.actualSpanned = actualSpanned;
    }

    @Override
    public AndSpanFlags withAlignment(Alignment alignment) {
      List<Integer> matchingSpanFlags = new ArrayList<>();
      List<Alignment> spanAlignments = new ArrayList<>();

      for (AlignmentSpan span : actualSpans) {
        spanAlignments.add(span.getAlignment());
        if (span.getAlignment().equals(alignment)) {
          matchingSpanFlags.add(actualSpanned.getSpanFlags(span));
        }
      }

      check("alignment").that(spanAlignments).containsExactly(alignment);
      return check("flags").about(spanFlags()).that(matchingSpanFlags);
    }
  }

  /** Allows assertions about the color of a span. */
  public interface Colored {

    /**
     * Checks that at least one of the matched spans has the expected {@code color}.
     *
     * @param color The expected color.
     * @return A {@link WithSpanFlags} object for optional additional assertions on the flags.
     */
    AndSpanFlags withColor(@ColorInt int color);
  }

  private static final Colored ALREADY_FAILED_COLORED = color -> ALREADY_FAILED_AND_FLAGS;

  private static Factory<ForegroundColorSpansSubject, List<ForegroundColorSpan>>
      foregroundColorSpans(Spanned actualSpanned) {
    return (FailureMetadata metadata, List<ForegroundColorSpan> spans) ->
        new ForegroundColorSpansSubject(metadata, spans, actualSpanned);
  }

  private static final class ForegroundColorSpansSubject extends Subject implements Colored {

    private final List<ForegroundColorSpan> actualSpans;
    private final Spanned actualSpanned;

    private ForegroundColorSpansSubject(
        FailureMetadata metadata, List<ForegroundColorSpan> actualSpans, Spanned actualSpanned) {
      super(metadata, actualSpans);
      this.actualSpans = actualSpans;
      this.actualSpanned = actualSpanned;
    }

    @Override
    public AndSpanFlags withColor(@ColorInt int color) {
      List<Integer> matchingSpanFlags = new ArrayList<>();
      // Use hex strings for comparison so the values in error messages are more human readable.
      List<String> spanColors = new ArrayList<>();

      for (ForegroundColorSpan span : actualSpans) {
        spanColors.add(String.format("0x%08X", span.getForegroundColor()));
        if (span.getForegroundColor() == color) {
          matchingSpanFlags.add(actualSpanned.getSpanFlags(span));
        }
      }

      String expectedColorString = String.format("0x%08X", color);
      check("foregroundColor").that(spanColors).containsExactly(expectedColorString);
      return check("flags").about(spanFlags()).that(matchingSpanFlags);
    }
  }

  private static Factory<BackgroundColorSpansSubject, List<BackgroundColorSpan>>
      backgroundColorSpans(Spanned actualSpanned) {
    return (FailureMetadata metadata, List<BackgroundColorSpan> spans) ->
        new BackgroundColorSpansSubject(metadata, spans, actualSpanned);
  }

  private static final class BackgroundColorSpansSubject extends Subject implements Colored {

    private final List<BackgroundColorSpan> actualSpans;
    private final Spanned actualSpanned;

    private BackgroundColorSpansSubject(
        FailureMetadata metadata, List<BackgroundColorSpan> actualSpans, Spanned actualSpanned) {
      super(metadata, actualSpans);
      this.actualSpans = actualSpans;
      this.actualSpanned = actualSpanned;
    }

    @Override
    public AndSpanFlags withColor(@ColorInt int color) {
      List<Integer> matchingSpanFlags = new ArrayList<>();
      // Use hex strings for comparison so the values in error messages are more human readable.
      List<String> spanColors = new ArrayList<>();

      for (BackgroundColorSpan span : actualSpans) {
        spanColors.add(String.format("0x%08X", span.getBackgroundColor()));
        if (span.getBackgroundColor() == color) {
          matchingSpanFlags.add(actualSpanned.getSpanFlags(span));
        }
      }

      String expectedColorString = String.format("0x%08X", color);
      check("backgroundColor").that(spanColors).containsExactly(expectedColorString);
      return check("flags").about(spanFlags()).that(matchingSpanFlags);
    }
  }

  /** Allows assertions about the typeface of a span. */
  public interface Typefaced {

    /**
     * Checks that at least one of the matched spans has the expected {@code fontFamily}.
     *
     * @param fontFamily The expected font family.
     * @return A {@link WithSpanFlags} object for optional additional assertions on the flags.
     */
    AndSpanFlags withFamily(String fontFamily);
  }

  private static final Typefaced ALREADY_FAILED_TYPEFACED = color -> ALREADY_FAILED_AND_FLAGS;

  private static Factory<TypefaceSpansSubject, List<TypefaceSpan>> typefaceSpans(
      Spanned actualSpanned) {
    return (FailureMetadata metadata, List<TypefaceSpan> spans) ->
        new TypefaceSpansSubject(metadata, spans, actualSpanned);
  }

  private static final class TypefaceSpansSubject extends Subject implements Typefaced {

    private final List<TypefaceSpan> actualSpans;
    private final Spanned actualSpanned;

    private TypefaceSpansSubject(
        FailureMetadata metadata, List<TypefaceSpan> actualSpans, Spanned actualSpanned) {
      super(metadata, actualSpans);
      this.actualSpans = actualSpans;
      this.actualSpanned = actualSpanned;
    }

    @Override
    public AndSpanFlags withFamily(String fontFamily) {
      List<Integer> matchingSpanFlags = new ArrayList<>();
      List<@NullableType String> spanFontFamilies = new ArrayList<>();

      for (TypefaceSpan span : actualSpans) {
        spanFontFamilies.add(span.getFamily());
        if (Util.areEqual(span.getFamily(), fontFamily)) {
          matchingSpanFlags.add(actualSpanned.getSpanFlags(span));
        }
      }

      check("family").that(spanFontFamilies).containsExactly(fontFamily);
      return check("flags").about(spanFlags()).that(matchingSpanFlags);
    }
  }

  /** Allows assertions about the absolute size of a span. */
  public interface AbsoluteSized {

    /**
     * Checks that at least one of the matched spans has the expected {@code size}.
     *
     * @param size The expected size.
     * @return A {@link WithSpanFlags} object for optional additional assertions on the flags.
     */
    AndSpanFlags withAbsoluteSize(int size);
  }

  private static final AbsoluteSized ALREADY_FAILED_ABSOLUTE_SIZED =
      size -> ALREADY_FAILED_AND_FLAGS;

  private static Factory<AbsoluteSizeSpansSubject, List<AbsoluteSizeSpan>> absoluteSizeSpans(
      Spanned actualSpanned) {
    return (FailureMetadata metadata, List<AbsoluteSizeSpan> spans) ->
        new AbsoluteSizeSpansSubject(metadata, spans, actualSpanned);
  }

  private static final class AbsoluteSizeSpansSubject extends Subject implements AbsoluteSized {

    private final List<AbsoluteSizeSpan> actualSpans;
    private final Spanned actualSpanned;

    private AbsoluteSizeSpansSubject(
        FailureMetadata metadata, List<AbsoluteSizeSpan> actualSpans, Spanned actualSpanned) {
      super(metadata, actualSpans);
      this.actualSpans = actualSpans;
      this.actualSpanned = actualSpanned;
    }

    @Override
    public AndSpanFlags withAbsoluteSize(int size) {
      List<Integer> matchingSpanFlags = new ArrayList<>();
      List<Integer> spanSizes = new ArrayList<>();

      for (AbsoluteSizeSpan span : actualSpans) {
        spanSizes.add(span.getSize());
        if (span.getSize() == size) {
          matchingSpanFlags.add(actualSpanned.getSpanFlags(span));
        }
      }

      check("absoluteSize").that(spanSizes).containsExactly(size);
      return check("flags").about(spanFlags()).that(matchingSpanFlags);
    }
  }

  /** Allows assertions about the relative size of a span. */
  public interface RelativeSized {
    /**
     * Checks that at least one of the matched spans has the expected {@code sizeChange}.
     *
     * @param sizeChange The expected size change.
     * @return A {@link WithSpanFlags} object for optional additional assertions on the flags.
     */
    AndSpanFlags withSizeChange(float sizeChange);
  }

  private static final RelativeSized ALREADY_FAILED_RELATIVE_SIZED =
      sizeChange -> ALREADY_FAILED_AND_FLAGS;

  private static Factory<RelativeSizeSpansSubject, List<RelativeSizeSpan>> relativeSizeSpans(
      Spanned actualSpanned) {
    return (FailureMetadata metadata, List<RelativeSizeSpan> spans) ->
        new RelativeSizeSpansSubject(metadata, spans, actualSpanned);
  }

  private static final class RelativeSizeSpansSubject extends Subject implements RelativeSized {

    private final List<RelativeSizeSpan> actualSpans;
    private final Spanned actualSpanned;

    private RelativeSizeSpansSubject(
        FailureMetadata metadata, List<RelativeSizeSpan> actualSpans, Spanned actualSpanned) {
      super(metadata, actualSpans);
      this.actualSpans = actualSpans;
      this.actualSpanned = actualSpanned;
    }

    @Override
    public AndSpanFlags withSizeChange(float size) {
      List<Integer> matchingSpanFlags = new ArrayList<>();
      List<Float> spanSizes = new ArrayList<>();

      for (RelativeSizeSpan span : actualSpans) {
        spanSizes.add(span.getSizeChange());
        if (span.getSizeChange() == size) {
          matchingSpanFlags.add(actualSpanned.getSpanFlags(span));
        }
      }

      check("sizeChange").that(spanSizes).containsExactly(size);
      return check("flags").about(spanFlags()).that(matchingSpanFlags);
    }
  }

  /** Allows assertions about a span's ruby text and its position. */
  public interface RubyText {

    /**
     * Checks that at least one of the matched spans has the expected {@code text}.
     *
     * @param text The expected text.
     * @param position The expected position of the text.
     * @return A {@link WithSpanFlags} object for optional additional assertions on the flags.
     */
    AndSpanFlags withTextAndPosition(String text, @TextAnnotation.Position int position);
  }

  private static final RubyText ALREADY_FAILED_WITH_TEXT =
      (text, position) -> ALREADY_FAILED_AND_FLAGS;

  private static Factory<RubySpansSubject, List<RubySpan>> rubySpans(Spanned actualSpanned) {
    return (FailureMetadata metadata, List<RubySpan> spans) ->
        new RubySpansSubject(metadata, spans, actualSpanned);
  }

  private static final class RubySpansSubject extends Subject implements RubyText {

    private final List<RubySpan> actualSpans;
    private final Spanned actualSpanned;

    private RubySpansSubject(
        FailureMetadata metadata, List<RubySpan> actualSpans, Spanned actualSpanned) {
      super(metadata, actualSpans);
      this.actualSpans = actualSpans;
      this.actualSpanned = actualSpanned;
    }

    @Override
    public AndSpanFlags withTextAndPosition(String text, @TextAnnotation.Position int position) {
      List<Integer> matchingSpanFlags = new ArrayList<>();
      List<TextAndPosition> spanTextsAndPositions = new ArrayList<>();
      for (RubySpan span : actualSpans) {
        spanTextsAndPositions.add(new TextAndPosition(span.rubyText, span.position));
        if (span.rubyText.equals(text)) {
          matchingSpanFlags.add(actualSpanned.getSpanFlags(span));
        }
      }
      check("rubyTextAndPosition")
          .that(spanTextsAndPositions)
          .containsExactly(new TextAndPosition(text, position));
      return check("flags").about(spanFlags()).that(matchingSpanFlags);
    }

    private static final class TextAndPosition {
      private final String text;
      private final @TextAnnotation.Position int position;

      private TextAndPosition(String text, int position) {
        this.text = text;
        this.position = position;
      }

      @Override
      public boolean equals(@Nullable Object o) {
        if (this == o) {
          return true;
        }
        if (o == null || getClass() != o.getClass()) {
          return false;
        }

        TextAndPosition that = (TextAndPosition) o;
        if (position != that.position) {
          return false;
        }
        return text.equals(that.text);
      }

      @Override
      public int hashCode() {
        int result = text.hashCode();
        result = 31 * result + position;
        return result;
      }

      @Override
      public String toString() {
        return String.format("{text='%s',position=%s}", text, position);
      }
    }
  }

  /** Allows assertions about a span's text emphasis mark and its position. */
  public interface EmphasizedText {
    /**
     * Checks that at least one of the matched spans has the expected {@code mark} and {@code
     * position}.
     *
     * @param markShape The expected mark shape.
     * @param markFill The expected mark fill style.
     * @param position The expected position of the mark.
     * @return A {@link AndSpanFlags} object for optional additional assertions on the flags.
     */
    AndSpanFlags withMarkAndPosition(
        @TextEmphasisSpan.MarkShape int markShape,
        @TextEmphasisSpan.MarkFill int markFill,
        @TextAnnotation.Position int position);
  }

  private static final EmphasizedText ALREADY_FAILED_WITH_MARK_AND_POSITION =
      (markShape, markFill, position) -> ALREADY_FAILED_AND_FLAGS;

  private static Factory<TextEmphasisSubject, List<TextEmphasisSpan>> textEmphasisSubjects(
      Spanned actualSpanned) {
    return (FailureMetadata metadata, List<TextEmphasisSpan> spans) ->
        new TextEmphasisSubject(metadata, spans, actualSpanned);
  }

  private static final class TextEmphasisSubject extends Subject implements EmphasizedText {

    private final List<TextEmphasisSpan> actualSpans;
    private final Spanned actualSpanned;

    private TextEmphasisSubject(
        FailureMetadata metadata, List<TextEmphasisSpan> actualSpans, Spanned actualSpanned) {
      super(metadata, actualSpans);
      this.actualSpans = actualSpans;
      this.actualSpanned = actualSpanned;
    }

    @Override
    public AndSpanFlags withMarkAndPosition(
        @TextEmphasisSpan.MarkShape int markShape,
        @TextEmphasisSpan.MarkFill int markFill,
        @TextAnnotation.Position int position) {
      List<Integer> matchingSpanFlags = new ArrayList<>();
      List<MarkAndPosition> textEmphasisMarksAndPositions = new ArrayList<>();
      for (TextEmphasisSpan span : actualSpans) {
        textEmphasisMarksAndPositions.add(
            new MarkAndPosition(span.markShape, span.markFill, span.position));
        if (span.markFill == markFill && span.markShape == markShape && span.position == position) {
          matchingSpanFlags.add(actualSpanned.getSpanFlags(span));
        }
      }
      check("textEmphasisMarkAndPosition")
          .that(textEmphasisMarksAndPositions)
          .containsExactly(new MarkAndPosition(markShape, markFill, position));
      return check("flags").about(spanFlags()).that(matchingSpanFlags);
    }

    private static final class MarkAndPosition {

      private final @TextEmphasisSpan.MarkShape int markShape;
      private final @TextEmphasisSpan.MarkFill int markFill;
      private final @TextAnnotation.Position int position;

      private MarkAndPosition(
          @TextEmphasisSpan.MarkShape int markShape,
          @TextEmphasisSpan.MarkFill int markFill,
          @TextAnnotation.Position int position) {
        this.markFill = markFill;
        this.markShape = markShape;
        this.position = position;
      }

      @Override
      public boolean equals(@Nullable Object o) {
        if (this == o) {
          return true;
        }
        if (o == null || getClass() != o.getClass()) {
          return false;
        }

        TextEmphasisSubject.MarkAndPosition that = (TextEmphasisSubject.MarkAndPosition) o;
        return (position == that.position)
            && (markShape == that.markShape)
            && (markFill == that.markFill);
      }

      @Override
      public int hashCode() {
        int result = markShape;
        result = 31 * result + markFill;
        result = 31 * result + position;
        return result;
      }

      @Override
      public String toString() {
        return String.format(
            "{markShape=%s,markFill=%s,position=%s}", markShape, markFill, position);
      }
    }
  }
}