From 7263699c2e092399a3caf30d5ce655410540fbaa Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 10 Dec 2019 17:38:49 +0000 Subject: [PATCH] Create Truth SpannedSubject for style assertions This will be used in subtitle decoding tests I followed this guide: https://truth.dev/extension.html PiperOrigin-RevId: 284787298 --- RELEASENOTES.md | 3 + .../testutil/truth/SpannedSubject.java | 237 ++++++++++++++++++ .../testutil/truth/SpannedSubjectTest.java | 145 +++++++++++ 3 files changed, 385 insertions(+) create mode 100644 testutils/src/main/java/com/google/android/exoplayer2/testutil/truth/SpannedSubject.java create mode 100644 testutils/src/test/java/com/google/android/exoplayer2/testutil/truth/SpannedSubjectTest.java diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 57c96a4442..e2e8e5659b 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -22,6 +22,9 @@ * Add support for attaching DRM sessions to clear content in the demo app. * UI: Exclude `DefaultTimeBar` region from system gesture detection ([#6685](https://github.com/google/ExoPlayer/issues/6685)). +* Add `SpannedSubject` to testutils, for assertions on + [Span-styled text]( https://developer.android.com/guide/topics/text/spans) + (e.g. subtitles). ### 2.11.0 (2019-12-11) ### diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/truth/SpannedSubject.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/truth/SpannedSubject.java new file mode 100644 index 0000000000..584da08977 --- /dev/null +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/truth/SpannedSubject.java @@ -0,0 +1,237 @@ +/* + * 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 com.google.android.exoplayer2.testutil.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.Spanned; +import android.text.TextUtils; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import androidx.annotation.Nullable; +import com.google.common.truth.FailureMetadata; +import com.google.common.truth.Subject; +import java.util.ArrayList; +import java.util.List; + +/** A Truth {@link Subject} for assertions on {@link Spanned} instances containing text styling. */ +// TODO: add support for more Spans i.e. all those used in com.google.android.exoplayer2.text. +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 spanned() { + return SpannedSubject::new; + } + + /** + * Convenience method to create a SpannedSubject. + * + *

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); + } + + /** + * Checks that the subject has an italic span from {@code startIndex} to {@code endIndex}. + * + * @param startIndex The start of the expected span. + * @param endIndex The end of the expected span. + * @param flags The flags of the expected span. See constants on {@link Spanned} for more + * information. + */ + public void hasItalicSpan(int startIndex, int endIndex, int flags) { + hasStyleSpan(startIndex, endIndex, flags, Typeface.ITALIC); + } + + /** + * Checks that the subject has a bold span from {@code startIndex} to {@code endIndex}. + * + * @param startIndex The start of the expected span. + * @param endIndex The end of the expected span. + * @param flags The flags of the expected span. See constants on {@link Spanned} for more + * information. + */ + public void hasBoldSpan(int startIndex, int endIndex, int flags) { + hasStyleSpan(startIndex, endIndex, flags, Typeface.BOLD); + } + + private void hasStyleSpan(int startIndex, int endIndex, int flags, int style) { + if (actual == null) { + failWithoutActual(simpleFact("Spanned must not be null")); + return; + } + + for (StyleSpan span : findMatchingSpans(startIndex, endIndex, flags, StyleSpan.class)) { + if (span.getStyle() == style) { + return; + } + } + + failWithExpectedSpan( + startIndex, + endIndex, + flags, + new StyleSpan(style), + actual.toString().substring(startIndex, endIndex)); + } + + /** + * Checks that the subject has bold and italic styling from {@code startIndex} to {@code + * endIndex}. + * + *

This can either be: + * + *

+ * + * @param startIndex The start of the expected span. + * @param endIndex The end of the expected span. + * @param flags The flags of the expected span. See constants on {@link Spanned} for more + * information. + */ + public void hasBoldItalicSpan(int startIndex, int endIndex, int flags) { + if (actual == null) { + failWithoutActual(simpleFact("Spanned must not be null")); + return; + } + + List styles = new ArrayList<>(); + for (StyleSpan span : findMatchingSpans(startIndex, endIndex, flags, StyleSpan.class)) { + styles.add(span.getStyle()); + } + if (styles.size() == 1 && styles.contains(Typeface.BOLD_ITALIC)) { + return; + } else if (styles.size() == 2 + && styles.contains(Typeface.BOLD) + && styles.contains(Typeface.ITALIC)) { + return; + } + + String spannedSubstring = actual.toString().substring(startIndex, endIndex); + String boldSpan = + spanToString(startIndex, endIndex, flags, new StyleSpan(Typeface.BOLD), spannedSubstring); + String italicSpan = + spanToString(startIndex, endIndex, flags, new StyleSpan(Typeface.ITALIC), spannedSubstring); + String boldItalicSpan = + spanToString( + startIndex, endIndex, flags, new StyleSpan(Typeface.BOLD_ITALIC), spannedSubstring); + + failWithoutActual( + simpleFact("No matching span found"), + fact("in text", actual.toString()), + fact("expected either", boldItalicSpan), + fact("or both", boldSpan + "\n" + italicSpan), + fact("but found", actualSpansString())); + } + + /** + * Checks that the subject has an underline span from {@code startIndex} to {@code endIndex}. + * + * @param startIndex The start of the expected span. + * @param endIndex The end of the expected span. + * @param flags The flags of the expected span. See constants on {@link Spanned} for more + * information. + */ + public void hasUnderlineSpan(int startIndex, int endIndex, int flags) { + if (actual == null) { + failWithoutActual(simpleFact("Spanned must not be null")); + return; + } + + List underlineSpans = + findMatchingSpans(startIndex, endIndex, flags, UnderlineSpan.class); + if (underlineSpans.size() == 1) { + return; + } + failWithExpectedSpan( + startIndex, + endIndex, + flags, + new UnderlineSpan(), + actual.toString().substring(startIndex, endIndex)); + } + + private List findMatchingSpans( + int startIndex, int endIndex, int flags, Class spanClazz) { + List spans = new ArrayList<>(); + for (T span : actual.getSpans(startIndex, endIndex, spanClazz)) { + if (actual.getSpanStart(span) == startIndex + && actual.getSpanEnd(span) == endIndex + && actual.getSpanFlags(span) == flags) { + spans.add(span); + } + } + return spans; + } + + private void failWithExpectedSpan( + int start, int end, int flags, Object span, String spannedSubstring) { + failWithoutActual( + simpleFact("No matching span found"), + fact("in text", actual), + fact("expected", spanToString(start, end, flags, span, spannedSubstring)), + fact("but found", actualSpansString())); + } + + private String actualSpansString() { + List actualSpanStrings = new ArrayList<>(); + for (Object span : actual.getSpans(0, actual.length(), /* type= */ Object.class)) { + actualSpanStrings.add(spanToString(span, actual)); + } + return TextUtils.join("\n", actualSpanStrings); + } + + private static String spanToString(Object span, Spanned spanned) { + int spanStart = spanned.getSpanStart(span); + int spanEnd = spanned.getSpanEnd(span); + return spanToString( + spanStart, + spanEnd, + spanned.getSpanFlags(span), + span, + spanned.toString().substring(spanStart, spanEnd)); + } + + private static String spanToString( + int start, int end, int flags, Object span, String spannedSubstring) { + String suffix; + if (span instanceof StyleSpan) { + suffix = "\tstyle=" + ((StyleSpan) span).getStyle(); + } else { + suffix = ""; + } + return String.format( + "start=%s\tend=%s\tflags=%s\ttype=%s\tsubstring='%s'%s", + start, end, flags, span.getClass().getSimpleName(), spannedSubstring, suffix); + } +} diff --git a/testutils/src/test/java/com/google/android/exoplayer2/testutil/truth/SpannedSubjectTest.java b/testutils/src/test/java/com/google/android/exoplayer2/testutil/truth/SpannedSubjectTest.java new file mode 100644 index 0000000000..2370361a0d --- /dev/null +++ b/testutils/src/test/java/com/google/android/exoplayer2/testutil/truth/SpannedSubjectTest.java @@ -0,0 +1,145 @@ +/* + * 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 com.google.android.exoplayer2.testutil.truth; + +import static com.google.android.exoplayer2.testutil.truth.SpannedSubject.assertThat; +import static com.google.android.exoplayer2.testutil.truth.SpannedSubject.spanned; +import static com.google.common.truth.ExpectFailure.assertThat; +import static com.google.common.truth.ExpectFailure.expectFailureAbout; + +import android.graphics.Typeface; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.truth.ExpectFailure; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link SpannedSubject}. */ +@RunWith(AndroidJUnit4.class) +public class SpannedSubjectTest { + + @Test + public void italicSpan_success() { + SpannableString spannable = SpannableString.valueOf("test with italic section"); + int start = "test with ".length(); + int end = start + "italic".length(); + spannable.setSpan(new StyleSpan(Typeface.ITALIC), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + assertThat(spannable).hasItalicSpan(start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + } + + @Test + public void italicSpan_mismatchedFlags() { + SpannableString spannable = SpannableString.valueOf("test with italic section"); + int start = "test with ".length(); + int end = start + "italic".length(); + spannable.setSpan(new StyleSpan(Typeface.ITALIC), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + AssertionError failure = + expectFailure( + whenTesting -> + whenTesting + .that(spannable) + .hasItalicSpan(start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)); + + assertThat(failure).factKeys().contains("No matching span found"); + assertThat(failure).factValue("in text").isEqualTo(spannable.toString()); + assertThat(failure).factValue("expected").contains("flags=" + Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + assertThat(failure) + .factValue("but found") + .contains("flags=" + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + @Test + public void italicSpan_null() { + AssertionError failure = + expectFailure( + whenTesting -> + whenTesting.that(null).hasItalicSpan(0, 5, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)); + + assertThat(failure).factKeys().containsExactly("Spanned must not be null"); + } + + @Test + public void boldSpan_success() { + SpannableString spannable = SpannableString.valueOf("test with bold section"); + int start = "test with ".length(); + int end = start + "bold".length(); + spannable.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + assertThat(spannable).hasBoldSpan(start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + } + + @Test + public void boldItalicSpan_withOneSpan() { + SpannableString spannable = SpannableString.valueOf("test with bold & italic section"); + int start = "test with ".length(); + int end = start + "bold & italic".length(); + spannable.setSpan( + new StyleSpan(Typeface.BOLD_ITALIC), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + assertThat(spannable).hasBoldItalicSpan(start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + } + + @Test + public void boldItalicSpan_withTwoSpans() { + SpannableString spannable = SpannableString.valueOf("test with bold & italic section"); + int start = "test with ".length(); + int end = start + "bold & italic".length(); + spannable.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + spannable.setSpan(new StyleSpan(Typeface.ITALIC), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + assertThat(spannable).hasBoldItalicSpan(start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + } + + @Test + public void boldItalicSpan_mismatchedStartIndex() { + SpannableString spannable = SpannableString.valueOf("test with bold & italic section"); + int start = "test with ".length(); + int end = start + "bold & italic".length(); + spannable.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + spannable.setSpan(new StyleSpan(Typeface.ITALIC), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + int incorrectStart = start - 2; + AssertionError expected = + expectFailure( + whenTesting -> + whenTesting + .that(spannable) + .hasBoldItalicSpan(incorrectStart, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)); + assertThat(expected).factValue("expected either").contains("start=" + incorrectStart); + assertThat(expected).factValue("but found").contains("start=" + start); + } + + @Test + public void underlineSpan_success() { + SpannableString spannable = SpannableString.valueOf("test with underlined section"); + int start = "test with ".length(); + int end = start + "underlined".length(); + spannable.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + assertThat(spannable).hasUnderlineSpan(start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + } + + private static AssertionError expectFailure( + ExpectFailure.SimpleSubjectBuilderCallback callback) { + return expectFailureAbout(spanned(), callback); + } +}