/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

goog.module('goog.ui.ContainerTest');
goog.setTestOnly();

const BrowserEvent = goog.require('goog.events.BrowserEvent');
const Component = goog.require('goog.ui.Component');
const Container = goog.require('goog.ui.Container');
const Control = goog.require('goog.ui.Control');
const GoogEvent = goog.require('goog.events.Event');
const GoogTestingEvent = goog.require('goog.testing.events.Event');
const KeyCodes = goog.require('goog.events.KeyCodes');
const KeyEvent = goog.require('goog.events.KeyEvent');
const PointerFallbackEventType = goog.require('goog.events.PointerFallbackEventType');
const TagName = goog.require('goog.dom.TagName');
const aria = goog.require('goog.a11y.aria');
const classlist = goog.require('goog.dom.classlist');
const dispose = goog.require('goog.dispose');
const dom = goog.require('goog.dom');
const googEvents = goog.require('goog.events');
const testSuite = goog.require('goog.testing.testSuite');
const testingEvents = goog.require('goog.testing.events');

let sandbox;
let containerElement;
let container;
let keyContainer;
let listContainer;

/**
 * Test container to which the elements have to be added with
 * {@code container.addChild(element, false)}
 */
class ListContainer extends Container {
  constructor() {
    super();
    Container.call(this);
  }

  /**
   * @override
   */
  createDom() {
    super.createDom();
    const ul = this.getDomHelper().createDom(TagName.UL);
    this.forEachChild(function(child) {
      child.createDom();
      const childEl = child.getElement();
      ul.appendChild(this.getDomHelper().createDom(TagName.LI, {}, childEl));
    }, this);
    this.getContentElement().appendChild(ul);
  }
}

/** Test container for tracking key events being handled. */
class KeyHandlingContainer extends Container {
  constructor() {
    super();
    Container.call(this);
    this.keyEventsHandled = 0;
  }

  /** @override */
  handleKeyEventInternal() {
    this.keyEventsHandled++;
    return false;
  }
}

/**
 * Checks that getHighlighted() returns the expected value and checks
 * that the child at this index is highlighted and other children are not.
 * @param {string} explanation Message indicating what is expected.
 * @param {number} index Expected return value of getHighlightedIndex().
 */
function assertHighlightedIndex(explanation, index) {
  assertEquals(explanation, index, container.getHighlightedIndex());
  for (let i = 0; i < container.getChildCount(); i++) {
    if (i == index) {
      assertTrue(
          'Child at highlighted index should be highlighted',
          container.getChildAt(i).isHighlighted());
    } else {
      assertFalse(
          'Only child at highlighted index should be highlighted',
          container.getChildAt(i).isHighlighted());
    }
  }
}

testSuite({
  setUpPage() {
    sandbox = dom.getElement('sandbox');
  },

  setUp() {
    container = new Container();
    keyContainer = null;
    listContainer = null;

    sandbox.innerHTML = '<div id="containerElement" class="goog-container">\n' +
        '  <div class="goog-control" id="hello">Hello</div>\n' +
        '  <div class="goog-control" id="world">World</div>\n' +
        '</div>';
    containerElement = dom.getElement('containerElement');
  },

  tearDown() {
    dom.removeChildren(sandbox);
    container.dispose();
    dispose(keyContainer);
    dispose(listContainer);
  },

  testDecorateHidden() {
    containerElement.style.display = 'none';

    assertTrue('Container must be visible', container.isVisible());
    container.decorate(containerElement);
    assertFalse('Container must be hidden', container.isVisible());
    container.forEachChild((control) => {
      assertTrue(
          'Child control ' + control.getId() + ' must report being ' +
              'visible, even if in a hidden container',
          control.isVisible());
    });
  },

  testDecorateDisabled() {
    classlist.add(containerElement, 'goog-container-disabled');

    assertTrue('Container must be enabled', container.isEnabled());
    container.decorate(containerElement);
    assertFalse('Container must be disabled', container.isEnabled());
    container.forEachChild((control) => {
      assertFalse(
          'Child control ' + control.getId() + ' must be disabled, ' +
              'because the host container is disabled',
          control.isEnabled());
    });
  },

  testDecorateFocusableContainer() {
    container.decorate(containerElement);
    assertTrue('Container must be focusable', container.isFocusable());
    container.forEachChild((control) => {
      assertFalse(
          'Child control ' + control.getId() + ' must not be ' +
              'focusable',
          control.isSupportedState(Component.State.FOCUSED));
    });
  },

  testDecorateFocusableChildrenContainer() {
    container.setFocusable(false);
    container.setFocusableChildrenAllowed(true);
    container.decorate(containerElement);
    assertFalse('Container must not be focusable', container.isFocusable());
    container.forEachChild((control) => {
      assertTrue(
          'Child control ' + control.getId() + ' must be ' +
              'focusable',
          control.isSupportedState(Component.State.FOCUSED));
    });
  },

  testHighlightOnEnter() {
    // This interaction test ensures that containers enforce that children
    // get highlighted on mouseover, and that one and only one child may
    // be highlighted at a time.  Although integration tests aren't the
    // best, it's difficult to test these event-based interactions due to
    // their disposition toward the "misunderstood contract" problem.

    container.decorate(containerElement);
    assertFalse(
        'Child 0 should initially not be highlighted',
        container.getChildAt(0).isHighlighted());

    testingEvents.fireMouseOverEvent(
        container.getChildAt(0).getElement(), sandbox);
    assertTrue(
        'Child 0 should become highlighted after a mouse over',
        container.getChildAt(0).isHighlighted());
    assertEquals(
        'Child 0 should be the active descendant',
        container.getChildAt(0).getElement(),
        aria.getActiveDescendant(container.getElement()));

    testingEvents.fireMouseOverEvent(
        container.getChildAt(1).getElement(),
        container.getChildAt(0).getElement());
    assertFalse(
        'Child 0 should lose highlight when child 1 is moused ' +
            'over, even if no mouseout occurs.',
        container.getChildAt(0).isHighlighted());
    assertTrue(
        'Child 1 should now be highlighted.',
        container.getChildAt(1).isHighlighted());
    assertEquals(
        'Child 1 should be the active descendant',
        container.getChildAt(1).getElement(),
        aria.getActiveDescendant(container.getElement()));
  },

  testHighlightOnEnterPreventable() {
    container.decorate(containerElement);
    googEvents.listen(container, Component.EventType.ENTER, (event) => {
      event.preventDefault();
    });
    testingEvents.fireMouseOverEvent(
        container.getChildAt(0).getElement(), sandbox);
    assertFalse(
        'Child 0 should not be highlighted if preventDefault called',
        container.getChildAt(0).isHighlighted());
  },

  testHighlightDisabled() {
    // Another interaction test.  Already tested in control_test.
    container.decorate(containerElement);
    container.getChildAt(0).setEnabled(false);
    testingEvents.fireMouseOverEvent(
        container.getChildAt(0).getElement(), sandbox);
    assertFalse(
        'Disabled children should not be highlighted',
        container.getChildAt(0).isHighlighted());
  },

  /** @suppress {visibility} suppression added to enable type checking */
  testGetOwnerControl() {
    container.decorate(containerElement);

    assertEquals(
        'Must return appropriate control given an element in the ' +
            'control.',
        container.getChildAt(1),
        container.getOwnerControl(container.getChildAt(1).getElement()));

    assertNull(
        'Must return null for element not associated with control.',
        container.getOwnerControl(document.body));
    assertNull(
        'Must return null if given null node', container.getOwnerControl(null));
  },

  testShowEvent() {
    container.decorate(containerElement);
    container.setVisible(false);
    let eventFired = false;
    googEvents.listen(container, Component.EventType.SHOW, () => {
      assertFalse(
          'Container must not be visible when SHOW event is ' +
              'fired',
          container.isVisible());
      eventFired = true;
    });
    container.setVisible(true);
    assertTrue('SHOW event expected', eventFired);
  },

  testAfterShowEvent() {
    container.decorate(containerElement);
    container.setVisible(false);
    let eventFired = false;
    googEvents.listen(container, Container.EventType.AFTER_SHOW, () => {
      assertTrue(
          'Container must be visible when AFTER_SHOW event is ' +
              'fired',
          container.isVisible());
      eventFired = true;
    });
    container.setVisible(true);
    assertTrue('AFTER_SHOW event expected', eventFired);
  },

  testHideEvents() {
    const events = [];
    container.decorate(containerElement);
    container.setVisible(true);
    const eventFired = false;
    googEvents.listen(container, Component.EventType.HIDE, (e) => {
      assertTrue(
          'Container must be visible when HIDE event is fired',
          container.isVisible());
      events.push(e.type);
    });
    googEvents.listen(container, Container.EventType.AFTER_HIDE, (e) => {
      assertFalse(
          'Container must not be visible when AFTER_HIDE event is fired',
          container.isVisible());
      events.push(e.type);
    });
    container.setVisible(false);
    assertArrayEquals(
        'HIDE event followed by AFTER_HIDE expected',
        [
          Component.EventType.HIDE,
          Container.EventType.AFTER_HIDE,
        ],
        events);
  },

  testGetOwnerControlWithNoRenderingInAddChild() {
    listContainer = new ListContainer();
    const control = new Control('item');
    listContainer.addChild(control);
    listContainer.render();
    /** @suppress {visibility} suppression added to enable type checking */
    const ownerControl = listContainer.getOwnerControl(control.getElement());

    assertEquals(
        'Control was added with addChild(control, false)', control,
        ownerControl);
  },

  /** @suppress {checkTypes} suppression added to enable type checking */
  testHandleKeyEvent_onlyHandlesWhenVisible() {
    keyContainer = new KeyHandlingContainer();
    keyContainer.decorate(containerElement);

    keyContainer.setVisible(false);
    keyContainer.handleKeyEvent(new GoogEvent());
    assertEquals(
        'No key events should be handled', 0, keyContainer.keyEventsHandled);

    keyContainer.setVisible(true);
    keyContainer.handleKeyEvent(new GoogEvent());
    assertEquals(
        'One key event should be handled', 1, keyContainer.keyEventsHandled);
  },

  /** @suppress {checkTypes} suppression added to enable type checking */
  testHandleKeyEvent_onlyHandlesWhenEnabled() {
    keyContainer = new KeyHandlingContainer();
    keyContainer.decorate(containerElement);
    keyContainer.setVisible(true);

    keyContainer.setEnabled(false);
    keyContainer.handleKeyEvent(new GoogEvent());
    assertEquals(
        'No key events should be handled', 0, keyContainer.keyEventsHandled);

    keyContainer.setEnabled(true);
    keyContainer.handleKeyEvent(new GoogEvent());
    assertEquals(
        'One key event should be handled', 1, keyContainer.keyEventsHandled);
  },

  /** @suppress {checkTypes} suppression added to enable type checking */
  testHandleKeyEvent_childlessContainersIgnoreKeyEvents() {
    keyContainer = new KeyHandlingContainer();
    keyContainer.render();
    keyContainer.setVisible(true);

    keyContainer.handleKeyEvent(new GoogEvent());
    assertEquals(
        'No key events should be handled', 0, keyContainer.keyEventsHandled);

    keyContainer.addChild(new Control());
    keyContainer.handleKeyEvent(new GoogEvent());
    assertEquals(
        'One key event should be handled', 1, keyContainer.keyEventsHandled);
  },

  /** @suppress {checkTypes} suppression added to enable type checking */
  testHandleKeyEvent_alwaysHandlesWithKeyEventTarget() {
    keyContainer = new KeyHandlingContainer();
    keyContainer.render();
    keyContainer.setKeyEventTarget(dom.createDom(TagName.DIV));
    keyContainer.setVisible(true);

    keyContainer.handleKeyEvent(new GoogEvent());
    assertEquals(
        'One key events should be handled', 1, keyContainer.keyEventsHandled);
  },

  testHandleKeyEventInternal_onlyHandlesUnmodified() {
    container.setKeyEventTarget(sandbox);
    const event = new KeyEvent(KeyCodes.ESC, 0, false, null);

    const propertyNames = ['shiftKey', 'altKey', 'ctrlKey', 'metaKey'];

    // Verify that the event is not handled whenever a modifier key is true.
    let propertyName;
    for (let i = 0; propertyName = propertyNames[i]; i++) {
      assertTrue(
          'Event should be handled when modifer key is not pressed.',
          container.handleKeyEventInternal(event));
      event[propertyName] = true;
      assertFalse(
          'Event should not be handled when modifer key is pressed.',
          container.handleKeyEventInternal(event));
      event[propertyName] = false;
    }
  },

  testOpenFollowsHighlight() {
    container.decorate(containerElement);
    container.setOpenFollowsHighlight(true);
    assertTrue(
        'isOpenFollowsHighlight should return true',
        container.isOpenFollowsHighlight());

    // Make the children openable.
    container.forEachChild((child) => {
      child.setSupportedState(Component.State.OPENED, true);
    });
    // Open child 1 initially.
    container.getChildAt(1).setOpen(true);

    assertFalse(
        'Child 0 should initially not be highlighted',
        container.getChildAt(0).isHighlighted());
    testingEvents.fireMouseOverEvent(
        container.getChildAt(0).getElement(), sandbox);
    assertTrue(
        'Child 0 should become highlighted after a mouse over',
        container.getChildAt(0).isHighlighted());
    assertTrue(
        'Child 0 should become open after higlighted',
        container.getChildAt(0).isOpen());
    assertFalse(
        'Child 1 should become closed once 0 is open',
        container.getChildAt(1).isOpen());
    assertEquals(
        'OpenItem should be child 0', container.getChildAt(0),
        container.getOpenItem());
  },

  testOpenNotFollowsHighlight() {
    container.decorate(containerElement);
    container.setOpenFollowsHighlight(false);
    assertFalse(
        'isOpenFollowsHighlight should return false',
        container.isOpenFollowsHighlight());

    // Make the children openable.
    container.forEachChild((child) => {
      child.setSupportedState(Component.State.OPENED, true);
    });
    // Open child 1 initially.
    container.getChildAt(1).setOpen(true);

    assertFalse(
        'Child 0 should initially not be highlighted',
        container.getChildAt(0).isHighlighted());
    testingEvents.fireMouseOverEvent(
        container.getChildAt(0).getElement(), sandbox);
    assertTrue(
        'Child 0 should become highlighted after a mouse over',
        container.getChildAt(0).isHighlighted());
    assertFalse(
        'Child 0 should remain closed after higlighted',
        container.getChildAt(0).isOpen());
    assertTrue('Child 1 should remain open', container.getChildAt(1).isOpen());
    assertEquals(
        'OpenItem should be child 1', container.getChildAt(1),
        container.getOpenItem());
  },

  testRemoveChild() {
    dom.removeChildren(containerElement);
    container.decorate(containerElement);

    const a = new Control('A');
    const b = new Control('B');
    const c = new Control('C');

    a.setId('a');
    b.setId('b');
    c.setId('c');

    container.addChild(a, true);
    container.addChild(b, true);
    container.addChild(c, true);

    container.setHighlightedIndex(2);

    assertEquals(
        'Parent must remove and return child by ID', b,
        container.removeChild('b'));
    assertNull(
        'Parent must no longer contain this child', container.getChild('b'));
    assertEquals(
        'Highlighted index must be decreased', 1,
        container.getHighlightedIndex());
    assertTrue(
        'The removed control must handle its own mouse events',
        b.isHandleMouseEvents());

    assertEquals(
        'Parent must remove and return child', c, container.removeChild(c));
    assertNull(
        'Parent must no longer contain this child', container.getChild('c'));
    assertFalse('This child must no longer be highlighted', c.isHighlighted());
    assertTrue(
        'The removed control must handle its own mouse events',
        c.isHandleMouseEvents());

    assertEquals(
        'Parent must remove and return child by index', a,
        container.removeChildAt(0));
    assertNull(
        'Parent must no longer contain this child', container.getChild('a'));
    assertTrue(
        'The removed control must handle its own mouse events',
        a.isHandleMouseEvents());
  },

  testRemoveHighlightedDisposedChild() {
    dom.removeChildren(containerElement);
    container.decorate(containerElement);

    const a = new Control('A');
    container.addChild(a, true);

    container.setHighlightedIndex(0);
    a.dispose();
    container.removeChild(a);
    container.dispose();
  },

  testUpdateHighlightedIndex_updatesWhenChildrenAreAdded() {
    dom.removeChildren(containerElement);
    container.decorate(containerElement);

    const a = new Control('A');
    const b = new Control('B');
    const c = new Control('C');

    container.addChild(a);
    container.setHighlightedIndex(0);
    assertHighlightedIndex('Highlighted index should match set value', 0);

    // Add child before the highlighted one.
    container.addChildAt(b, 0);
    assertHighlightedIndex('Highlighted index should be increased', 1);

    // Add child after the highlighted one.
    container.addChildAt(c, 2);
    assertHighlightedIndex('Highlighted index should not change', 1);

    container.dispose();
  },

  testUpdateHighlightedIndex_updatesWhenChildrenAreMoved() {
    dom.removeChildren(containerElement);
    container.decorate(containerElement);

    const a = new Control('A');
    const b = new Control('B');
    const c = new Control('C');

    container.addChild(a);
    container.addChild(b);
    container.addChild(c);

    // Highlight 'c' and swap 'a' and 'b'
    // [a, b, c] -> [a, b, *c] -> [b, a, *c] (* indicates the highlighted child)
    container.setHighlightedIndex(2);
    container.addChildAt(a, 1, false);
    assertHighlightedIndex('Highlighted index should not change', 2);

    // Move the highlighted child 'c' from index 2 to index 1.
    // [b, a, *c] -> [b, *c, a]
    container.addChildAt(c, 1, false);
    assertHighlightedIndex('Highlighted index must follow the moved child', 1);

    // Take the element in front of the highlighted index and move it behind it.
    // [b, *c, a] -> [*c, a, b]
    container.addChildAt(b, 2, false);
    assertHighlightedIndex('Highlighted index must be decreased', 0);

    // And move the element back to the front.
    // [*c, a, b] -> [b, *c, a]
    container.addChildAt(b, 0, false);
    assertHighlightedIndex('Highlighted index must be increased', 1);

    container.dispose();
  },

  testUpdateHighlightedIndex_notChangedOnNoOp() {
    dom.removeChildren(containerElement);
    container.decorate(containerElement);

    container.addChild(new Control('A'));
    container.addChild(new Control('B'));
    container.setHighlightedIndex(1);

    // Re-add a child to its current position.
    container.addChildAt(container.getChildAt(0), 0, false);
    assertHighlightedIndex('Highlighted index must not change', 1);

    container.dispose();
  },

  testUpdateHighlightedIndex_notChangedWhenNoChildSelected() {
    dom.removeChildren(containerElement);
    container.decorate(containerElement);

    const a = new Control('A');
    const b = new Control('B');
    const c = new Control('C');
    container.addChild(a);
    container.addChild(b);
    container.addChild(c);

    // Move children around.
    container.addChildAt(a, 2, false);
    container.addChildAt(b, 1, false);
    container.addChildAt(c, 2, false);

    assertHighlightedIndex('Highlighted index must not change', -1);

    container.dispose();
  },

  testUpdateHighlightedIndex_indexStaysInBoundsWhenMovedToMaxIndex() {
    dom.removeChildren(containerElement);
    container.decorate(containerElement);

    const a = new Control('A');
    const b = new Control('B');
    container.addChild(a);
    container.addChild(b);

    // Move higlighted child to an index one behind last child.
    container.setHighlightedIndex(0);
    container.addChildAt(a, 2);

    assertEquals(
        'Child should be moved to index 1', a, container.getChildAt(1));
    assertEquals('Child count should not change', 2, container.getChildCount());
    assertHighlightedIndex('Highlighted index must point to new index', 1);

    container.dispose();
  },

  testSetPointerEventsEnabled() {
    container.setPointerEventsEnabled(true);
    container.decorate(containerElement);

    const child = container.getChildAt(0);

    assertFalse(
        'Child should not be active before pointerdown event.',
        child.isActive());

    const pointerdown = new GoogTestingEvent(
        PointerFallbackEventType.POINTERDOWN, child.getElement());
    pointerdown.button = BrowserEvent.MouseButton.LEFT;
    testingEvents.fireBrowserEvent(pointerdown);

    assertTrue(
        'Child should be active after pointerdown event.', child.isActive());

    const pointerup = new GoogTestingEvent(
        PointerFallbackEventType.POINTERUP, child.getElement());
    pointerup.button = BrowserEvent.MouseButton.LEFT;
    testingEvents.fireBrowserEvent(pointerup);

    assertFalse(
        'Child should not be active after pointerup event.', child.isActive());

    container.dispose();
  },
});
