chromium/content/public/android/javatests/src/org/chromium/content/browser/androidoverlay/DialogOverlayImplPixelTest.java

// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.content.browser.androidoverlay;

import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Rect;
import android.view.Surface;

import androidx.test.InstrumentationRegistry;
import androidx.test.filters.MediumTest;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.chromium.base.ThreadUtils;
import org.chromium.base.test.BaseJUnit4ClassRunner;
import org.chromium.base.test.util.DisabledTest;
import org.chromium.base.test.util.Feature;
import org.chromium.base.test.util.UrlUtils;
import org.chromium.content.browser.RenderCoordinatesImpl;
import org.chromium.content.browser.androidoverlay.DialogOverlayImplTestRule.Client;

import java.util.concurrent.Callable;

/** Pixel tests for DialogOverlayImpl. These use UiAutomation, so they only run in JB or above. */
@RunWith(BaseJUnit4ClassRunner.class)
@DisabledTest(message = "https://crbug.com/1462304")
public class DialogOverlayImplPixelTest {
    // Color that we'll fill the overlay with.

    @Rule
    public DialogOverlayImplTestRule mActivityTestRule =
            new DialogOverlayImplTestRule(TEST_PAGE_DATA_URL);

    private static final int OVERLAY_FILL_COLOR = Color.BLUE;

    // CSS coordinates of a div that we'll try to cover with an overlay.
    private static final int DIV_X_CSS = 10;
    private static final int DIV_Y_CSS = 20;
    private static final int DIV_WIDTH_CSS = 300;
    private static final int DIV_HEIGHT_CSS = 200;

    // Provide a solid-color div that's positioned / sized by DIV_*_CSS.
    private static final String TEST_PAGE_STYLE =
            "<style>"
                    + "div {"
                    + "left: "
                    + DIV_X_CSS
                    + "px;"
                    + "top: "
                    + DIV_Y_CSS
                    + "px;"
                    + "width: "
                    + DIV_WIDTH_CSS
                    + "px;"
                    + "height: "
                    + DIV_HEIGHT_CSS
                    + "px;"
                    + "position: absolute;"
                    + "background: red;"
                    + "}"
                    + "</style>";
    private static final String TEST_PAGE_DATA_URL =
            UrlUtils.encodeHtmlDataUri(
                    "<html>" + TEST_PAGE_STYLE + "<body><div></div></body></html>");

    // Number of retries for various race-prone operations.
    private static final int NUM_RETRIES = 10;

    // Delay (msec) between retries.
    private static final int RETRY_DELAY = 50;

    // Number of rows and columns that we consider as optional due to rounding and blending diffs.
    private static final int FUZZY_PIXELS = 1;

    // DIV_*_CSS converted to screen pixels.
    int mDivXPx;
    int mDivYPx;
    int mDivWidthPx;
    int mDivHeightPx;

    // Target area boundaries.
    // We allow a range because of page / device scaling.  The size of the div and the size of the
    // area of overlap can be off by a pixel in either direction.  The div can be blended around the
    // edge, while the overlay position can round differently.
    int mTargetAreaMinPx;
    int mTargetAreaMaxPx;

    // Maximum status bar height that we'll work with.  This just lets us restrict the area of the
    // screenshot that we inspect, since it's slow.  This should also include the URL bar.
    private static final int mStatusBarMaxHeightPx = 300;

    // Area of interest that contains the div, since the whole image is big.
    Rect mAreaOfInterestPx;

    // Screenshot of the test page, before we do anything.
    Bitmap mInitialScreenshot;

    RenderCoordinatesImpl mCoordinates;

    @Before
    public void setUp() {
        takeScreenshotOfBackground();
        mCoordinates = mActivityTestRule.getRenderCoordinates();
    }

    // Take a screenshot via UiAutomation, which captures all overlays.
    Bitmap takeScreenshot() {
        return InstrumentationRegistry.getInstrumentation().getUiAutomation().takeScreenshot();
    }

    // Fill |surface| with OVERLAY_FILL_COLOR and return a screenshot.  Note that we have no idea
    // how long it takes before the image posts, so the screenshot might not reflect it.  Be
    // prepared to retry.  Note that we always draw the same thing, so it's okay if a retry gets a
    // screenshot of a previous surface; they're identical.
    Bitmap fillSurface(Surface surface) {
        Canvas canvas = surface.lockCanvas(null);
        canvas.drawColor(OVERLAY_FILL_COLOR);
        surface.unlockCanvasAndPost(canvas);
        return takeScreenshot();
    }

    int convertCSSToScreenPixels(int css) {
        return (int)
                (css * mCoordinates.getPageScaleFactor() * mCoordinates.getDeviceScaleFactor());
    }

    // Since ContentShell makes our solid color div have some textured background, we have to be
    // somewhat lenient here.  Plus, sometimes the edges of the div are blended.
    boolean isApproximatelyRed(int color) {
        int r = Color.red(color);
        return r > 100 && Color.green(color) < r && Color.blue(color) < r;
    }

    // Take a screenshot, and wait until we get one that has the background div in it.
    void takeScreenshotOfBackground() {
        mAreaOfInterestPx = new Rect();
        for (int retries = 0; retries < NUM_RETRIES; retries++) {
            // Compute the div position in screen pixels.  We recompute these since they sometimes
            // take a while to settle, also.
            mDivXPx = convertCSSToScreenPixels(DIV_X_CSS);
            mDivYPx = convertCSSToScreenPixels(DIV_Y_CSS);
            mDivWidthPx = convertCSSToScreenPixels(DIV_WIDTH_CSS);
            mDivHeightPx = convertCSSToScreenPixels(DIV_HEIGHT_CSS);

            // Allow one edge on each side to be non-overlapping or misdetected.
            mTargetAreaMaxPx = mDivWidthPx * mDivHeightPx;
            mTargetAreaMinPx = (mDivWidthPx - FUZZY_PIXELS) * (mDivHeightPx - FUZZY_PIXELS);

            // Don't read the whole bitmap.  It's quite big.  Assume that the status bar is only at
            // the top, and that it's at most mStatusBarMaxHeightPx px tall.  We also allow a bit of
            // room on each side for rounding issues.  Setting these too large just slows down the
            // test, without affecting the result.
            mAreaOfInterestPx.left = mDivXPx - FUZZY_PIXELS;
            mAreaOfInterestPx.top = mDivYPx - FUZZY_PIXELS;
            mAreaOfInterestPx.right = mDivXPx + mDivWidthPx - 1 + FUZZY_PIXELS;
            mAreaOfInterestPx.bottom = mDivYPx + mDivHeightPx + mStatusBarMaxHeightPx;

            mInitialScreenshot = takeScreenshot();

            int area = 0;
            for (int ry = mAreaOfInterestPx.top; ry <= mAreaOfInterestPx.bottom; ry++) {
                for (int rx = mAreaOfInterestPx.left; rx <= mAreaOfInterestPx.right; rx++) {
                    if (isApproximatelyRed(mInitialScreenshot.getPixel(rx, ry))) area++;
                }
            }

            // It's okay if we have some randomly colored other pixels.
            if (area >= mTargetAreaMinPx) return;

            try {
                Thread.sleep(RETRY_DELAY);
            } catch (Exception e) {
            }
        }

        Assert.assertTrue(false);
    }

    // Count how many pixels in the div are covered by OVERLAY_FILL_COLOR in |overlayScreenshot|,
    // and return it.
    int countDivPixelsCoveredByOverlay(Bitmap overlayScreenshot) {
        // Find pixels that changed from the source color to the target color.  This should avoid
        // issues like changes in the status bar, unless we're really unlucky.  It assumes that the
        // div is actually the expected size; coloring the entire page red would fool this.
        int area = 0;
        for (int ry = mAreaOfInterestPx.top; ry <= mAreaOfInterestPx.bottom; ry++) {
            for (int rx = mAreaOfInterestPx.left; rx <= mAreaOfInterestPx.right; rx++) {
                if (isApproximatelyRed(mInitialScreenshot.getPixel(rx, ry))
                        && overlayScreenshot.getPixel(rx, ry) == OVERLAY_FILL_COLOR) {
                    area++;
                }
            }
        }

        return area;
    }

    // Assert that |surface| exactly covers the target div on the page.  Note that we assume that
    // you have not drawn anything to |surface| yet, so that we can still see the div.
    void assertDivIsExactlyCovered(Surface surface) {
        // Draw two colors, and count as the area the ones that change between screenshots.  This
        // lets us notice if the status bar is occluding something, even if it happens to be the
        // same color.
        int area = 0;
        int targetArea = mDivWidthPx * mDivHeightPx;
        for (int retries = 0; retries < NUM_RETRIES; retries++) {
            // We fill the overlay every time, in case a resize was pending.  Eventually, we should
            // reach a steady-state where the surface is resized, and this (or a previous) filled-in
            // surface is on the screen.
            Bitmap overlayScreenshot = fillSurface(surface);
            area = countDivPixelsCoveredByOverlay(overlayScreenshot);
            if (area >= mTargetAreaMinPx && area <= mTargetAreaMaxPx) return;

            // There are several reasons this can fail besides being broken.  We don't know how long
            // it takes for fillSurface()'s output to make it to the display.  We also don't know
            // how long scheduleLayout() takes.  Just try a few times, since the whole thing should
            // take only a frame or two to settle.
            try {
                Thread.sleep(RETRY_DELAY);
            } catch (Exception e) {
            }
        }

        // Assert so that we get a helpful message in the log.
        Assert.assertEquals(targetArea, area);
    }

    // Wait for |overlay| to become ready, get its surface, and return it.
    Surface waitForSurface(DialogOverlayImpl overlay) throws Exception {
        Assert.assertNotNull(overlay);
        final Client.Event event = mActivityTestRule.getClient().nextEvent();
        Assert.assertTrue(event.surfaceKey > 0);
        return ThreadUtils.runOnUiThreadBlocking(
                new Callable<Surface>() {
                    @Override
                    public Surface call() {
                        return DialogOverlayImplJni.get()
                                .lookupSurfaceForTesting((int) event.surfaceKey);
                    }
                });
    }

    @Test
    @MediumTest
    @Feature({"AndroidOverlay"})
    public void testInitialPosition() throws Exception {
        // Test that the initial position supplied for the overlay covers the <div> we created.
        final DialogOverlayImpl overlay =
                mActivityTestRule.createOverlay(mDivXPx, mDivYPx, mDivWidthPx, mDivHeightPx);
        Surface surface = waitForSurface(overlay);

        assertDivIsExactlyCovered(surface);
    }

    @Test
    @MediumTest
    @Feature({"AndroidOverlay"})
    public void testScheduleLayout() throws Exception {
        // Test that scheduleLayout() moves the overlay to cover the <div>.
        final DialogOverlayImpl overlay = mActivityTestRule.createOverlay(0, 0, 10, 10);
        Surface surface = waitForSurface(overlay);

        final org.chromium.gfx.mojom.Rect rect = new org.chromium.gfx.mojom.Rect();
        rect.x = mDivXPx;
        rect.y = mDivYPx;
        rect.width = mDivWidthPx;
        rect.height = mDivHeightPx;

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    overlay.scheduleLayout(rect);
                });

        assertDivIsExactlyCovered(surface);
    }
}