chromium/chrome/test/data/webui/lens/overlay/overlay_cursor_test.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'chrome-untrusted://lens/lens_overlay_app.js';

import type {RectF} from '//resources/mojo/ui/gfx/geometry/mojom/geometry.mojom-webui.js';
import {BrowserProxyImpl} from 'chrome-untrusted://lens/browser_proxy.js';
import type {CursorTooltipElement} from 'chrome-untrusted://lens/cursor_tooltip.js';
import type {LensPageRemote} from 'chrome-untrusted://lens/lens.mojom-webui.js';
import type {LensOverlayAppElement} from 'chrome-untrusted://lens/lens_overlay_app.js';
import type {SelectionOverlayElement} from 'chrome-untrusted://lens/selection_overlay.js';
import {loadTimeData} from 'chrome-untrusted://resources/js/load_time_data.js';
import {assertEquals, assertFalse, assertStringContains, assertTrue} from 'chrome-untrusted://webui-test/chai_assert.js';
import {flushTasks, waitAfterNextRender} from 'chrome-untrusted://webui-test/polymer_test_util.js';
import {isVisible} from 'chrome-untrusted://webui-test/test_util.js';

import {fakeScreenshotBitmap, waitForScreenshotRendered} from '../utils/image_utils.js';
import {createObject} from '../utils/object_utils.js';
import {simulateStartDrag} from '../utils/selection_utils.js';
import {createLine, createParagraph, createText, createWord} from '../utils/text_utils.js';

import {TestLensOverlayBrowserProxy} from './test_overlay_browser_proxy.js';

function isRendered(el: HTMLElement) {
  return isVisible(el) && getComputedStyle(el).visibility !== 'hidden';
}

suite('OverlayCursor', () => {
  let lensOverlayElement: LensOverlayAppElement;
  let selectionOverlayElement: SelectionOverlayElement;
  let cursorTooltip: CursorTooltipElement;
  let tooltipEl: HTMLElement;
  let testBrowserProxy: TestLensOverlayBrowserProxy;
  let callbackRouterRemote: LensPageRemote;

  setup(async () => {
    // Resetting the HTML needs to be the first thing we do in setup to
    // guarantee that any singleton instances don't change while any UI is still
    // attached to the DOM.
    document.body.innerHTML = window.trustedTypes!.emptyHTML;

    testBrowserProxy = new TestLensOverlayBrowserProxy();
    callbackRouterRemote =
        testBrowserProxy.callbackRouter.$.bindNewPipeAndPassRemote();
    BrowserProxyImpl.setInstance(testBrowserProxy);

    // Turn off the shimmer. Since the shimmer is resource intensive, turn off
    // to prevent from causing issues in the tests. Also override localized
    // strings so tests don't fail if the strings change.
    loadTimeData.overrideValues({
      'enableShimmer': false,
      'cursorTooltipTextHighlightMessage': 'Select text',
      'cursorTooltipClickMessage': 'Select object',
      'cursorTooltipDragMessage': 'Drag to search',
      'cursorTooltipLivePageMessage': 'Click to exit',
    });

    lensOverlayElement = document.createElement('lens-overlay-app');
    document.body.appendChild(lensOverlayElement);
    await waitAfterNextRender(lensOverlayElement);

    selectionOverlayElement = lensOverlayElement.$.selectionOverlay;
    cursorTooltip = lensOverlayElement.$.cursorTooltip;
    tooltipEl = cursorTooltip.$.cursorTooltip;

    // Send a fake screenshot to unhide the selection overlay.
    testBrowserProxy.page.screenshotDataReceived(fakeScreenshotBitmap());
    await waitForScreenshotRendered(selectionOverlayElement);

    // Since the size of the Selection Overlay is based on the screenshot which
    // is not loaded in the test, we need to force the overlay to take up the
    // viewport.
    selectionOverlayElement.$.selectionOverlay.style.width = '100%';
    selectionOverlayElement.$.selectionOverlay.style.height = '100%';
    await waitAfterNextRender(lensOverlayElement);

    await addWords();
    await addObjects();
  });

  // Normalizes the given values to the size of selection overlay.
  function normalizedBox(box: RectF): RectF {
    const boundingRect = selectionOverlayElement.getBoundingClientRect();
    return {
      x: box.x / boundingRect.width,
      y: box.y / boundingRect.height,
      width: box.width / boundingRect.width,
      height: box.height / boundingRect.height,
    };
  }

  function addWords(): Promise<void> {
    const text = createText([
      createParagraph([
        createLine([
          createWord(
              'hello', normalizedBox({x: 20, y: 20, width: 30, height: 10})),
          createWord(
              'there', normalizedBox({x: 50, y: 20, width: 50, height: 10})),
          createWord(
              'test', normalizedBox({x: 80, y: 20, width: 30, height: 10})),
        ]),
      ]),
    ]);
    callbackRouterRemote.textReceived(text);
    return flushTasks();
  }

  function addObjects(): Promise<void> {
    const objects =
        [
          {x: 80, y: 20, width: 25, height: 10},
          {x: 70, y: 35, width: 20, height: 10},
        ]
            .map(
                (rect, i) => createObject(
                    i.toString(), normalizedBox(rect), /*isMaskClick=*/ true));
    callbackRouterRemote.objectsReceived(objects);
    return flushTasks();
  }

  async function simulateHover(el: Element) {
    const elBounds = el.getBoundingClientRect();
    const moveDetails = {
      clientX: (elBounds.top + elBounds.bottom) / 2,
      clientY: (elBounds.left + elBounds.right) / 2,
      bubbles: true,
      composed: true,
    };
    el.dispatchEvent(new PointerEvent('pointerenter'));
    el.dispatchEvent(new PointerEvent('pointermove', moveDetails));
    return waitAfterNextRender(lensOverlayElement);
  }

  async function simulateUnhover(el: Element) {
    el.dispatchEvent(new PointerEvent('pointerleave'));
    return waitAfterNextRender(lensOverlayElement);
  }

  async function simulateEnterViewport() {
    const appContainerEl =
        lensOverlayElement.shadowRoot!.querySelector('.app-container')!;
    simulateHover(appContainerEl);
    return waitAfterNextRender(lensOverlayElement);
  }

  test('Text', async () => {
    await simulateEnterViewport();

    // Hover over a text element.
    const textLayer = selectionOverlayElement.$.textSelectionLayer;
    const textElement = textLayer.shadowRoot!.querySelector('.word')!;
    await simulateHover(textElement);

    // Verify the cursor tooltip changed to text string.
    assertTrue(isRendered(tooltipEl));
    assertStringContains(tooltipEl.innerHTML, 'Select text');

    // Verify the cursor changed to text and the cursor image changed to text
    // icon.
    assertEquals('text', document.body.style.cursor);
    assertEquals(
        'url("text.svg")',
        selectionOverlayElement.style.getPropertyValue('--cursor-img-url'));

    await simulateUnhover(textElement);

    // Verify the cursor changed to unset and the cursor image is still Lens
    // icon.
    assertEquals('unset', document.body.style.cursor);
    assertEquals(
        'url("lens.svg")',
        selectionOverlayElement.style.getPropertyValue('--cursor-img-url'));
  });

  test('Object', async () => {
    await simulateEnterViewport();

    // Hover over a object element.
    const objectLayer = selectionOverlayElement.$.objectSelectionLayer;
    const objectElement = objectLayer.shadowRoot!.querySelector('.object')!;

    await simulateHover(objectElement);

    // Verify the cursor tooltip changed to object string.
    assertTrue(isRendered(tooltipEl));
    assertStringContains(tooltipEl.innerHTML, 'Select object');

    // Verify the cursor image changed to Lens icon.
    assertEquals(
        'url("lens.svg")',
        selectionOverlayElement.style.getPropertyValue('--cursor-img-url'));

    await simulateUnhover(objectElement);

    // Verify the cursor changed to unset and the cursor image is still Lens
    // icon.
    assertEquals('unset', document.body.style.cursor);
    assertEquals(
        'url("lens.svg")',
        selectionOverlayElement.style.getPropertyValue('--cursor-img-url'));
  });

  test('RegionSelection', async () => {
    await simulateEnterViewport();

    // Hover over a selection overlay.
    await simulateHover(selectionOverlayElement.$.selectionOverlay);

    // Verify the cursor tooltip changed to object string.
    assertTrue(isRendered(tooltipEl));
    assertStringContains(tooltipEl.innerHTML, 'Drag to search');

    // Start a drag that goes outside the overlay boundaries.
    const boundingRect = selectionOverlayElement.getBoundingClientRect();
    await simulateStartDrag(
        selectionOverlayElement, {x: 10, y: 10},
        {x: boundingRect.right + 2000, y: boundingRect.bottom + 2000});

    // Verify the cursor tooltip is still object string.
    assertTrue(isRendered(tooltipEl));
    assertStringContains(tooltipEl.innerHTML, 'Drag to search');

    // Verify the cursor changed to crosshair and the cursor image changed to
    // Lens icon.
    assertEquals('crosshair', document.body.style.cursor);
    assertEquals(
        'url("lens.svg")',
        selectionOverlayElement.style.getPropertyValue('--cursor-img-url'));
  });


  test('ExitScrimTooltip', async () => {
    await simulateEnterViewport();

    // Hover over the background scrim.
    await simulateHover(lensOverlayElement.$.backgroundScrim);

    // Verify the cursor tooltip changed to object string.
    assertTrue(isRendered(tooltipEl));
    assertStringContains(tooltipEl.innerHTML, 'Click to exit');
  });

  test('HideTooltip', async () => {
    await simulateEnterViewport();

    // Hover over some text.
    const textLayer = selectionOverlayElement.$.textSelectionLayer;
    const textElement = textLayer.shadowRoot!.querySelector('.word')!;
    await simulateHover(textElement);
    assertTrue(isRendered(tooltipEl));

    // Simulate changing hover from text to close button.
    await simulateUnhover(textElement);
    await simulateUnhover(selectionOverlayElement);
    await simulateHover(lensOverlayElement.$.closeButton);

    // Verify no tooltip.
    assertFalse(isRendered(tooltipEl));

    // Hover over the background scrim.
    await simulateUnhover(lensOverlayElement.$.closeButton);
    await simulateHover(lensOverlayElement.$.backgroundScrim);
    assertTrue(isRendered(tooltipEl));

    // Simulate changing hover from background scrim to close button.
    await simulateUnhover(lensOverlayElement.$.backgroundScrim);
    await simulateHover(lensOverlayElement.$.closeButton);

    // Verify no tooltip.
    assertFalse(isRendered(tooltipEl));

    // Simulate changing hover from close button back to background scrim.
    await simulateUnhover(lensOverlayElement.$.closeButton);
    await simulateHover(lensOverlayElement.$.backgroundScrim);

    // Verify tooltip shows again.
    assertTrue(isRendered(tooltipEl));

    // Simulate changing hover over info.
    await simulateUnhover(lensOverlayElement.$.backgroundScrim);
    await simulateHover(lensOverlayElement.$.moreOptionsButton);

    // Verify no tooltip.
    assertFalse(isRendered(tooltipEl));

    // Open the info menu.
    lensOverlayElement.$.moreOptionsButton.click();
    await flushTasks();
    assertTrue(isVisible(lensOverlayElement.$.moreOptionsMenu));

    // Hover the background.
    await simulateUnhover(lensOverlayElement.$.moreOptionsButton);
    await simulateHover(lensOverlayElement.$.backgroundScrim);

    // Simulate changing hover from background to info menu.
    await simulateUnhover(lensOverlayElement.$.backgroundScrim);
    await simulateHover(lensOverlayElement.$.moreOptionsMenu);

    // Verify no tooltip.
    assertFalse(isRendered(tooltipEl));
  });

  test('HideCursor', async () => {
    await simulateEnterViewport();

    // Hover over overlay.
    await simulateHover(selectionOverlayElement.$.selectionOverlay);

    // Verify cursor shows.
    const cursor = selectionOverlayElement.$.cursor;
    assertEquals('', cursor.className);

    // Simulate changing hover from text to close button.
    await simulateUnhover(selectionOverlayElement.$.selectionOverlay);
    await simulateHover(lensOverlayElement.$.closeButton);

    // Verify no cursor.
    assertEquals('hidden', cursor.className);
  });
});