From 63868e954f39108265642f01e26c743e99d650c1 Mon Sep 17 00:00:00 2001 From: AndroidX Test Team Date: Fri, 11 Sep 2026 06:57:17 -0700 Subject: [PATCH] Fix `isDisplayingAtLeast()` matcher to factor in the scale of ancestor views. `isDisplayingAtLeast(N)` determines whether at least N% of a view is visible on the screen by comparing its visible area size against its total view size. Prior to this change, the total view size calculation did not account for ancestor view scales, whereas the visible area size did. Because of this mismatch, `isDisplayingAtLeast(90)` would incorrectly return `false` even when the view was fully visible, specifically when one or more ancestor views were scaled down (shrunk). This change resolves the issue by incorporating ancestor view scaling into the total view size calculation. PiperOrigin-RevId: 979797311 --- .github/workflows/ci.yml | 39 ++-- espresso/CHANGELOG.md | 1 + .../test/espresso/matcher/ViewMatchers.java | 19 +- .../androidx/test/espresso/matcher/BUILD | 39 +++- .../IsDisplayingAtLeastIntegrationTest.java | 203 ++++++++++++++++++ .../espresso/matcher/ViewMatchers1Test.java | 85 -------- 6 files changed, 276 insertions(+), 110 deletions(-) create mode 100644 espresso/core/javatests/androidx/test/espresso/matcher/IsDisplayingAtLeastIntegrationTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57c46906b..0f7ef5fc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,20 +14,23 @@ on: env: cache-version: v2 +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Check out repository code - uses: actions/checkout@v4 - - name: Install Java 21 - uses: actions/setup-java@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Install Java 21 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 with: distribution: 'zulu' java-version: '21' - name: 'Cache Bazel files' - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cache/bazel key: ${{ runner.os }}-${{ env.cache-version }}-bazel-build-${{ github.sha }} @@ -42,7 +45,7 @@ jobs: cp bazel-bin/axt_m2repository.zip ~/download shell: bash - name: 'Upload local snapshot for tests' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: local-snapshot path: ~/download @@ -55,14 +58,14 @@ jobs: timeout-minutes: 20 steps: - name: Check out repository code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install Java 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 with: distribution: 'zulu' java-version: '21' - name: 'Cache Bazel files' - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cache/bazel key: ${{ runner.os }}-${{ env.cache-version }}-bazel-test-${{ github.sha }} @@ -81,23 +84,21 @@ jobs: timeout-minutes: 20 steps: - name: Check out repository code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install Java 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 with: distribution: 'zulu' java-version: '21' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4 - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - name: 'Cache Gradle files' - uses: gradle/gradle-build-action@v3 + sudo udevadm trigger --name-match=kvm - name: 'Download local snapshot for tests' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: local-snapshot path: ~/download @@ -107,7 +108,11 @@ jobs: unzip ~/download/axt_m2repository.zip -d ~/.m2/ shell: bash - name: 'Setup Android SDK' - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2 + with: + # The default value also includes the obsolete 'tools' package, which + # was removed from the SDK repository. + packages: 'platform-tools' - name: 'Run gradle tests' run: | cd ${{ github.workspace }}/gradle-tests @@ -115,7 +120,7 @@ jobs: shell: bash - name: 'Upload test reports' if: success() || failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: test-reports path: gradle-tests/**/build/reports/androidTests/ diff --git a/espresso/CHANGELOG.md b/espresso/CHANGELOG.md index 110c0a51f..e411153da 100644 --- a/espresso/CHANGELOG.md +++ b/espresso/CHANGELOG.md @@ -20,6 +20,7 @@ The following artifacts were released: * Don't suppress AppNotIdleException if dumpThreadStates throws. * Remove Espresso.onIdle tracing * Fix NullPointerException in UiControllerImpl. +* Fix `isDisplayingAtLeast` matcher to factor in the scale of ancestor views. **New Features** diff --git a/espresso/core/java/androidx/test/espresso/matcher/ViewMatchers.java b/espresso/core/java/androidx/test/espresso/matcher/ViewMatchers.java index 4246e8d2f..abc2de5d7 100644 --- a/espresso/core/java/androidx/test/espresso/matcher/ViewMatchers.java +++ b/espresso/core/java/androidx/test/espresso/matcher/ViewMatchers.java @@ -994,12 +994,21 @@ protected boolean matchesSafely(View view, Description mismatchDescription) { Rect screen = getScreenWithoutStatusBarActionBar(view); - float viewHeight = (view.getHeight() > screen.height()) ? screen.height() : view.getHeight(); - float viewWidth = (view.getWidth() > screen.width()) ? screen.width() : view.getWidth(); + // Calculate cumulative scale from the view and its ancestors. + // Note: Assumes an axis-aligned hierarchy (no ancestor rotation). + float totalScaleX = view.getScaleX(); + float totalScaleY = view.getScaleY(); + ViewParent parent = view.getParent(); + while (parent instanceof View) { + View parentView = (View) parent; + totalScaleX *= parentView.getScaleX(); + totalScaleY *= parentView.getScaleY(); + parent = parentView.getParent(); + } - // factor in the View's scaleX and scaleY properties. - viewHeight = Math.min(view.getHeight() * Math.abs(view.getScaleY()), screen.height()); - viewWidth = Math.min(view.getWidth() * Math.abs(view.getScaleX()), screen.width()); + // factor in the View and its ancestors' scaleX and scaleY properties. + float viewHeight = Math.min(view.getHeight() * Math.abs(totalScaleY), screen.height()); + float viewWidth = Math.min(view.getWidth() * Math.abs(totalScaleX), screen.width()); double maxArea = viewHeight * viewWidth; double visibleArea = visibleParts.height() * visibleParts.width(); diff --git a/espresso/core/javatests/androidx/test/espresso/matcher/BUILD b/espresso/core/javatests/androidx/test/espresso/matcher/BUILD index ff544d8fb..1458296bd 100644 --- a/espresso/core/javatests/androidx/test/espresso/matcher/BUILD +++ b/espresso/core/javatests/androidx/test/espresso/matcher/BUILD @@ -6,6 +6,7 @@ load( "//build_extensions:android_library_test.bzl", "axt_android_library_test", ) +load("//build_extensions:axt_android_local_test.bzl", "axt_android_local_test") load( "//build_extensions:phone_devices.bzl", "apis", @@ -129,11 +130,8 @@ axt_android_library_test( ":utils", "//core", "//espresso/core/java/androidx/test/espresso", - "//espresso/core/java/androidx/test/espresso/action", - "//espresso/core/java/androidx/test/espresso/assertion", "//espresso/core/java/androidx/test/espresso/matcher", "//ext/junit", - "//opensource/androidx:annotation", "//runner/android_junit_runner", "//runner/rules", "//testapps/ui_testapp/java/androidx/test/ui/app:lib_exported", @@ -174,3 +172,38 @@ axt_android_library_test( "@maven//:org_mockito_mockito_core", ], ) + +axt_android_local_test( + name = "IsDisplayingAtLeastIntegrationTest", + srcs = ["IsDisplayingAtLeastIntegrationTest.java"], + deps = [ + ":utils", + "//core", + "//core/javatests/androidx/test/core/app/testing", + "//core/javatests/androidx/test/core/app/testing:manifest", + "//espresso/core/java/androidx/test/espresso", + "//espresso/core/java/androidx/test/espresso/assertion", + "//espresso/core/java/androidx/test/espresso/matcher", + "//ext/junit", + "//runner/android_junit_runner", + "@maven//:org_hamcrest_hamcrest_core", + ], +) + +axt_android_library_test( + name = "IsDisplayingAtLeastIntegrationTest_android", + srcs = ["IsDisplayingAtLeastIntegrationTest.java"], + deps = [ + ":utils", + "//core", + "//core/javatests/androidx/test/core/app/testing", + "//core/javatests/androidx/test/core/app/testing:manifest", + "//espresso/core/java/androidx/test/espresso", + "//espresso/core/java/androidx/test/espresso/assertion", + "//espresso/core/java/androidx/test/espresso/matcher", + "//ext/junit", + "//runner/android_junit_runner", + "@maven//:junit_junit", + "@maven//:org_hamcrest_hamcrest_core", + ], +) diff --git a/espresso/core/javatests/androidx/test/espresso/matcher/IsDisplayingAtLeastIntegrationTest.java b/espresso/core/javatests/androidx/test/espresso/matcher/IsDisplayingAtLeastIntegrationTest.java new file mode 100644 index 000000000..3f99ff787 --- /dev/null +++ b/espresso/core/javatests/androidx/test/espresso/matcher/IsDisplayingAtLeastIntegrationTest.java @@ -0,0 +1,203 @@ +/* + * Copyright (C) 2026 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.test.espresso.matcher; + +import static androidx.test.core.app.ApplicationProvider.getApplicationContext; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.MatcherTestUtils.getDescription; +import static androidx.test.espresso.matcher.MatcherTestUtils.getMismatchDescription; +import static androidx.test.espresso.matcher.ViewMatchers.assertThat; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast; +import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertThrows; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import androidx.test.core.app.testing.UiActivity; +import androidx.test.espresso.matcher.ViewMatchers.Visibility; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Integration tests for {@link ViewMatchers#isDisplayingAtLeast(int)}. */ +@RunWith(AndroidJUnit4.class) +@LargeTest +public class IsDisplayingAtLeastIntegrationTest { + + @Rule + public ActivityScenarioRule activityScenarioRule = + new ActivityScenarioRule<>(UiActivity.class); + + @Test + public void invalidPercentageRange() { + assertThrows(IllegalArgumentException.class, () -> isDisplayingAtLeast(-1)); + assertThrows(IllegalArgumentException.class, () -> isDisplayingAtLeast(101)); + } + + @Test + public void fullyDisplayed() { + View[] childHolder = new View[1]; + activityScenarioRule + .getScenario() + .onActivity( + activity -> { + View child = new View(activity); + childHolder[0] = child; + activity.setContentView(child, new ViewGroup.LayoutParams(100, 100)); + }); + + onView(is(childHolder[0])).check(matches(isDisplayingAtLeast(100))); + } + + @Test + public void fullyDisplayed_withScale() { + View[] childHolder = new View[1]; + activityScenarioRule + .getScenario() + .onActivity( + activity -> { + FrameLayout parent = new FrameLayout(activity); + parent.setScaleX(0.5f); + View child = new View(activity); + childHolder[0] = child; + child.setScaleY(0.5f); + parent.addView(child, new FrameLayout.LayoutParams(100, 100)); + activity.setContentView( + parent, + new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + }); + + onView(is(childHolder[0])).check(matches(isDisplayingAtLeast(100))); + } + + @Test + public void partiallyDisplayed() { + View[] childHolder = new View[1]; + activityScenarioRule + .getScenario() + .onActivity( + activity -> { + FrameLayout parent = new FrameLayout(activity); + View child = new View(activity); + childHolder[0] = child; + parent.addView(child, new FrameLayout.LayoutParams(100, 100)); + activity.setContentView(parent, new ViewGroup.LayoutParams(50, 50)); + }); + + // Set the view to be 100x100: 10,000 pixels, parent 50x50: 2,500 pixels (25% visible) + onView(is(childHolder[0])).check(matches(isDisplayingAtLeast(20))); + onView(is(childHolder[0])).check(matches(not(isDisplayingAtLeast(30)))); + } + + @Test + public void partiallyDisplayed_withScale() { + View[] childHolder = new View[1]; + activityScenarioRule + .getScenario() + .onActivity( + activity -> { + FrameLayout parent = new FrameLayout(activity); + parent.setScaleY(-0.9f); + View child = new View(activity); + childHolder[0] = child; + child.setScaleX(0.6f); + parent.addView(child, new FrameLayout.LayoutParams(100, 100)); + activity.setContentView(parent, new ViewGroup.LayoutParams(60, 60)); + }); + + // Scaled child: 60x90 = 5,400 pixels (horizontal range [20, 80]), + // parent 60x60 clips child to 40x54 = 2,160 pixels (40% visible). + onView(is(childHolder[0])).check(matches(isDisplayingAtLeast(39))); + onView(is(childHolder[0])).check(matches(not(isDisplayingAtLeast(41)))); + } + + @Test + public void gone() { + View[] childHolder = new View[1]; + activityScenarioRule + .getScenario() + .onActivity( + activity -> { + View child = new View(activity); + childHolder[0] = child; + child.setVisibility(View.GONE); + activity.setContentView(child, new ViewGroup.LayoutParams(100, 100)); + }); + + onView(is(childHolder[0])).check(matches(not(isDisplayingAtLeast(5)))); + } + + @Test + public void description() { + assertThat( + getDescription(isDisplayingAtLeast(15)), + is( + "(" + + getDescription(withEffectiveVisibility(Visibility.VISIBLE)) + + " and view.getGlobalVisibleRect() covers at least <15> percent of the view's" + + " area)")); + } + + @Test + public void mismatchDescription_wrongVisibility() { + View view = new View(getApplicationContext()); + view.setVisibility(View.GONE); + assertThat( + getMismatchDescription(isDisplayingAtLeast(15), view), + is(getMismatchDescription(withEffectiveVisibility(Visibility.VISIBLE), view))); + } + + @Test + public void mismatchDescription_notVisible() { + View view = new View(getApplicationContext()); + view.setVisibility(View.VISIBLE); + assertThat( + getMismatchDescription(isDisplayingAtLeast(15), view), + is("view was <0> percent visible to the user")); + } + + @Test + public void mismatchDescription_lowVisibility() { + View[] childHolder = new View[1]; + activityScenarioRule + .getScenario() + .onActivity( + activity -> { + // Set the area of the view to 100x100 = 10,000, parent to 50x50 = 2,500: 25% visible + FrameLayout parent = new FrameLayout(activity); + View child = new View(activity); + childHolder[0] = child; + parent.addView(child, new FrameLayout.LayoutParams(100, 100)); + activity.setContentView(parent, new ViewGroup.LayoutParams(50, 50)); + }); + + onView(is(childHolder[0])) + .check( + (view, noViewFoundException) -> + assertThat( + getMismatchDescription(isDisplayingAtLeast(35), view), + is("view was <25> percent visible to the user"))); + } +} diff --git a/espresso/core/javatests/androidx/test/espresso/matcher/ViewMatchers1Test.java b/espresso/core/javatests/androidx/test/espresso/matcher/ViewMatchers1Test.java index cc258f443..657dfb1db 100644 --- a/espresso/core/javatests/androidx/test/espresso/matcher/ViewMatchers1Test.java +++ b/espresso/core/javatests/androidx/test/espresso/matcher/ViewMatchers1Test.java @@ -28,7 +28,6 @@ import static androidx.test.espresso.matcher.ViewMatchers.isClickable; import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast; import static androidx.test.espresso.matcher.ViewMatchers.isEnabled; import static androidx.test.espresso.matcher.ViewMatchers.isNotChecked; import static androidx.test.espresso.matcher.ViewMatchers.isNotClickable; @@ -85,7 +84,6 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.stubbing.Answer; /** Unit tests for {@link ViewMatchers}. */ @LargeTest @@ -736,89 +734,6 @@ public void isDisplayed_mismatchDescription_emptyRectangle() { is("view.getGlobalVisibleRect() returned empty rectangle")); } - @Test - public void isDisplayingAtLeast_invalidPercentageRange() { - assertThrows(IllegalArgumentException.class, () -> isDisplayingAtLeast(-1)); - assertThrows(IllegalArgumentException.class, () -> isDisplayingAtLeast(101)); - } - - @Test - public void isDisplayingAtLeastTest() { - GlobalVisibleRectProvider providerMock = mock(GlobalVisibleRectProvider.class); - View view = new GlobalVisibleRectTestView(context, providerMock); - - view.setVisibility(View.GONE); - assertFalse(isDisplayingAtLeast(5).matches(view)); - - // Set the view to be 100x100: 10,000 pixels - view.setVisibility(View.VISIBLE); - view.layout(0, 0, 100, 100); - when(providerMock.get(any(), any())) - .then( - (Answer) - invocation -> { - // Set the output rectangle to 50x50: 2500 pixels - Rect argRect = invocation.getArgument(0); - argRect.set(0, 0, 50, 50); - return true; - }); - - assertFalse(isDisplayingAtLeast(30).matches(view)); - assertTrue(isDisplayingAtLeast(20).matches(view)); - } - - @Test - public void isDisplayingAtLeast_description() { - assertThat( - getDescription(isDisplayingAtLeast(15)), - is( - "(" - + getDescription(withEffectiveVisibility(Visibility.VISIBLE)) - + " and view.getGlobalVisibleRect() covers at least <15> percent of the view's" - + " area)")); - } - - @Test - public void isDisplayingAtLeast_mismatchDescription_wrongVisibility() { - View view = new View(context); - view.setVisibility(View.GONE); - assertThat( - getMismatchDescription(isDisplayingAtLeast(15), view), - is(getMismatchDescription(withEffectiveVisibility(Visibility.VISIBLE), view))); - } - - @Test - public void isDisplayingAtLeast_mismatchDescription_notVisible() { - GlobalVisibleRectProvider providerMock = mock(GlobalVisibleRectProvider.class); - View view = new GlobalVisibleRectTestView(context, providerMock); - view.setVisibility(View.VISIBLE); - when(providerMock.get(any(), any())).thenReturn(false); - assertThat( - getMismatchDescription(isDisplayingAtLeast(15), view), - is("view was <0> percent visible to the user")); - } - - @Test - public void isDisplayingAtLeast_mismatchDescription_lowVisibility() { - GlobalVisibleRectProvider providerMock = mock(GlobalVisibleRectProvider.class); - View view = new GlobalVisibleRectTestView(context, providerMock); - view.setVisibility(View.VISIBLE); - // Set the area of the view to 100x100 = 10,000 - view.layout(0, 0, 100, 100); - when(providerMock.get(any(), any())) - .then( - (Answer) - invocation -> { - // Set the output rectangle to 50x50: 2500 pixels - Rect argRect = invocation.getArgument(0); - argRect.set(0, 0, 50, 50); - return true; - }); - assertThat( - getMismatchDescription(isDisplayingAtLeast(35), view), - is("view was <25> percent visible to the user")); - } - /** This interface is used to mock the {@link View#getGlobalVisibleRect(Rect, Point)} method. */ interface GlobalVisibleRectProvider { boolean get(Rect r, Point offset);