/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, you can obtain one at http://mozilla.org/MPL/2.0/. */

const tabmail = document.getElementById("tabmail");
registerCleanupFunction(() => {
  tabmail.closeOtherTabs(tabmail.tabInfo[0]);
});

async function withTab(callback) {
  const tab = tabmail.openTab("contentTab", {
    url: "chrome://mochitests/content/browser/comm/mail/base/test/widgets/files/treeListbox.xhtml",
  });
  await BrowserTestUtils.browserLoaded(tab.browser);
  await new Promise(resolve => requestAnimationFrame(resolve));

  tab.browser.focus();
  await SpecialPowers.spawn(tab.browser, [], callback);

  tabmail.closeTab(tab);
}

add_task(async function testKeyboard() {
  await withTab(subtestKeyboard);
});
add_task(async function testMouse() {
  await withTab(subtestMouse);
});
add_task(async function testMutation() {
  await withTab(subtestMutation);
});
add_task(async function testExpandCollapse() {
  await withTab(subtestExpandCollapse);
});
add_task(async function testSelectOnRemoval1() {
  await withTab(subtestSelectOnRemoval1);
});
add_task(async function testSelectOnRemoval2() {
  await withTab(subtestSelectOnRemoval2);
});
add_task(async function testSelectOnRemoval3() {
  await withTab(subtestSelectOnRemoval3);
});
add_task(async function testUnselectable() {
  await withTab(subtestUnselectable);
});

/**
 * Tests keyboard navigation up and down the list for single and multiselection.
 */
async function subtestKeyboard() {
  const doc = content.document;

  const list = doc.querySelector(`ul[is="tree-listbox"]`);
  Assert.ok(!!list, "the list exists");

  const initialRowIds = [
    "row-1",
    "row-2",
    "row-2-1",
    "row-2-2",
    "row-3",
    "row-3-1",
    "row-3-1-1",
    "row-3-1-2",
  ];
  Assert.equal(list.rowCount, initialRowIds.length, "rowCount is correct");
  Assert.deepEqual(
    list.rows.map(r => r.id),
    initialRowIds,
    "initial rows are correct"
  );
  Assert.equal(list.selectedIndex, 0, "selectedIndex is set to 0");

  const selectHandler = {
    seenEvent: null,
    selectedAtEvent: null,

    reset() {
      this.seenEvent = null;
      this.selectedAtEvent = null;
    },
    handleEvent(event) {
      this.seenEvent = event;
      this.selectedAtEvent = list.selectedIndex;
    },
  };

  function pressKey(key, modifiers = {}, expectEvent = true) {
    info(`pressing ${key} with ${modifiers}`);

    selectHandler.reset();
    list.addEventListener("select", selectHandler);
    EventUtils.synthesizeKey(key, modifiers, content);
    list.removeEventListener("select", selectHandler);
    Assert.equal(
      !!selectHandler.seenEvent,
      expectEvent,
      `'select' event ${expectEvent ? "fired" : "did not fire"}`
    );
  }

  function checkSelected(expectedIndex, expectedId) {
    Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
    if (selectHandler.selectedAtEvent !== null) {
      // Check the value was already set when the select event fired.
      Assert.deepEqual(
        selectHandler.selectedAtEvent,
        expectedIndex,
        "selectedIndex was correct at the last 'select' event"
      );
    }

    Assert.deepEqual(
      Array.from(list.querySelectorAll(".selected"), row => row.id),
      [expectedId],
      "correct rows have the 'selected' class"
    );
  }

  function checkMultiSelected(indices, ids) {
    Assert.equal(
      indices.length,
      ids.length,
      "number of selected rows should be correct"
    );

    Assert.deepEqual(
      Array.from(list.querySelectorAll(".selected"), row => row.id),
      ids,
      "correct rows have the 'selected' class"
    );
  }

  // Key down the list.

  list.focus();
  for (let i = 1; i < initialRowIds.length; i++) {
    pressKey("KEY_ArrowDown");
    checkSelected(i, initialRowIds[i]);
  }

  pressKey("KEY_ArrowDown", {}, false);
  checkSelected(7, "row-3-1-2");

  pressKey("KEY_PageDown", {}, false);
  checkSelected(7, "row-3-1-2");

  pressKey("KEY_End", {}, false);
  checkSelected(7, "row-3-1-2");

  // And up again.

  for (let i = initialRowIds.length - 2; i >= 0; i--) {
    pressKey("KEY_ArrowUp");
    checkSelected(i, initialRowIds[i]);
  }

  pressKey("KEY_ArrowUp", {}, false);
  checkSelected(0, "row-1");

  pressKey("KEY_PageUp", {}, false);
  checkSelected(0, "row-1");

  pressKey("KEY_Home", {}, false);
  checkSelected(0, "row-1");

  // Jump around.

  pressKey("KEY_End");
  checkSelected(7, "row-3-1-2");

  pressKey("KEY_PageUp");
  checkSelected(0, "row-1");

  pressKey("KEY_PageDown");
  checkSelected(7, "row-3-1-2");

  pressKey("KEY_Home");
  checkSelected(0, "row-1");

  // Multiselection.

  list.ariaMultiSelectable = true;
  pressKey("KEY_ArrowDown", { accelKey: true }, true);
  checkMultiSelected([0, 1], ["row-1", "row-2"]);

  pressKey("KEY_ArrowDown", { accelKey: true }, true);
  checkMultiSelected([0, 1, 2], ["row-1", "row-2", "row-2-1"]);

  pressKey("KEY_ArrowUp", { accelKey: true }, true);
  checkMultiSelected([0, 1, 2], ["row-1", "row-2", "row-2-1"]);

  pressKey("KEY_End", { shiftKey: true }, true);
  checkMultiSelected(
    [0, 1, 2, 3, 4, 5, 6, 7],
    [
      "row-1",
      "row-2",
      "row-2-1",
      "row-2-2",
      "row-3",
      "row-3-1",
      "row-3-1-1",
      "row-3-1-2",
    ]
  );

  pressKey("KEY_Home", { shiftKey: true }, true);
  checkMultiSelected(
    [0, 1, 2, 3, 4, 5, 6, 7],
    [
      "row-1",
      "row-2",
      "row-2-1",
      "row-2-2",
      "row-3",
      "row-3-1",
      "row-3-1-1",
      "row-3-1-2",
    ]
  );

  pressKey("KEY_Home");
  checkSelected(0, "row-1");

  // Ensure modifier keys don't do anything if multiselection is disabled.
  list.ariaMultiSelectable = false;

  pressKey("KEY_ArrowDown", { accelKey: true }, true);
  checkSelected(1, "row-2");

  pressKey("KEY_ArrowDown", { shiftKey: true }, true);
  checkSelected(2, "row-2-1");

  pressKey("KEY_Home");
  checkSelected(0, "row-1");
}

/**
 * Tests mouse clicks for single and multiselection.
 */
async function subtestMouse() {
  const doc = content.document;
  const list = doc.querySelector(`ul[is="tree-listbox"]`);

  Assert.equal(list.selectedIndex, 0, "selectedIndex is set to 0");

  let selectPromise;
  function promiseSelectEvent() {
    selectPromise = new Promise(resolve =>
      list.addEventListener("select", () => resolve(list.selectedIndex), {
        once: true,
      })
    );
  }

  function selectRow(id, modifiers = {}) {
    promiseSelectEvent();
    const row = doc.getElementById(id);
    EventUtils.synthesizeMouseAtCenter(row, modifiers, content);
  }

  async function checkSelected(expectedIndex, expectedId) {
    await selectPromise;
    Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
    Assert.deepEqual(
      Array.from(list.querySelectorAll(".selected"), row => row.id),
      [expectedId],
      "correct rows have the 'selected' class"
    );
  }

  function checkMultiSelected(indices, ids) {
    Assert.equal(
      indices.length,
      ids.length,
      "number of selected rows should be correct"
    );

    Assert.deepEqual(
      Array.from(list.querySelectorAll(".selected"), row => row.id),
      ids,
      "correct rows have the 'selected' class"
    );
  }

  list.focus();

  selectRow("row-3-1-2");
  await checkSelected(7, "row-3-1-2");

  selectRow("row-2-2");
  await checkSelected(3, "row-2-2");

  selectRow("row-1");
  await checkSelected(0, "row-1");

  // Multiselection.

  list.ariaMultiSelectable = true;

  selectRow("row-2-2", { accelKey: true });
  checkMultiSelected([0, 3], ["row-1", "row-2-2"]);

  selectRow("row-3-1-2", { accelKey: true });
  checkMultiSelected([0, 3, 7], ["row-1", "row-2-2", "row-3-1-2"]);

  selectRow("row-3-1-1", { accelKey: true });
  checkMultiSelected(
    [0, 3, 6, 7],
    ["row-1", "row-2-2", "row-3-1-1", "row-3-1-2"]
  );

  selectRow("row-1");
  await checkSelected(0, "row-1");

  selectRow("row-3-1-2", { shiftKey: true });
  checkMultiSelected(
    [0, 1, 2, 3, 4, 5, 6, 7],
    [
      "row-1",
      "row-2",
      "row-2-1",
      "row-2-2",
      "row-3",
      "row-3-1",
      "row-3-1-1",
      "row-3-1-2",
    ]
  );

  // Escape should select the first selected row.
  EventUtils.synthesizeKey("VK_ESCAPE", {}, content);
  await checkSelected(0, "row-1");

  // Ensure modifier keys don't do anything if multiselection is disabled.
  list.ariaMultiSelectable = false;

  selectRow("row-2-2", { accelKey: true });
  await checkSelected(3, "row-2-2");

  selectRow("row-1", { shiftKey: true });
  await checkSelected(0, "row-1");
}

/**
 * Tests that rows added to or removed from the tree cause their parent rows
 * to gain or lose the 'children' class as appropriate. This is done with a
 * mutation observer so the tree is not updated immediately, but at the end of
 * the event loop.
 */
async function subtestMutation() {
  const doc = content.document;
  const list = doc.querySelector(`ul[is="tree-listbox"]`);
  const idsWithChildren = ["row-2", "row-3", "row-3-1"];
  const idsWithoutChildren = [
    "row-1",
    "row-2-1",
    "row-2-2",
    "row-3-1-1",
    "row-3-1-2",
  ];

  // Check the initial state.

  function createNewRow() {
    const template = doc.getElementById("rowToAdd");
    return template.content.cloneNode(true).firstElementChild;
  }

  function checkHasClass(id, shouldHaveClass = true) {
    const row = doc.getElementById(id);
    if (shouldHaveClass) {
      Assert.ok(
        row.classList.contains("children"),
        `${id} should have the 'children' class`
      );
    } else {
      Assert.ok(
        !row.classList.contains("children"),
        `${id} should NOT have the 'children' class`
      );
    }
  }

  for (const id of idsWithChildren) {
    checkHasClass(id, true);
  }
  for (const id of idsWithoutChildren) {
    checkHasClass(id, false);
  }

  // Add a new row without children to the end of the list.

  info("adding new row to end of list");
  let newRow = list.appendChild(createNewRow());
  // Wait for mutation observer. It does nothing, but let's be sure.
  await new Promise(r => content.setTimeout(r));
  checkHasClass("new-row", false);
  newRow.remove();
  await new Promise(r => content.setTimeout(r));

  // Add and remove a single row to rows with existing children.

  for (const id of idsWithChildren) {
    const row = doc.getElementById(id);

    info(`adding new row to ${id}`);
    newRow = row.querySelector("ul").appendChild(createNewRow());
    // Wait for mutation observer. It does nothing, but let's be sure.
    await new Promise(r => content.setTimeout(r));
    checkHasClass("new-row", false);
    checkHasClass(id, true);

    info(`removing new row from ${id}`);
    newRow.remove();
    // Wait for mutation observer. It does nothing, but let's be sure.
    await new Promise(r => content.setTimeout(r));
    checkHasClass(id, true);

    if (id == "row-3-1") {
      checkHasClass("row-3", true);
    }
  }

  // Add and remove a single row to rows without existing children.

  for (const id of idsWithoutChildren) {
    const row = doc.getElementById(id);
    const childList = row.appendChild(doc.createElement("ul"));

    info(`adding new row to ${id}`);
    newRow = childList.appendChild(createNewRow());
    // Wait for mutation observer.
    await new Promise(r => content.setTimeout(r));
    checkHasClass("new-row", false);
    checkHasClass(id, true);

    info(`removing new row from ${id}`);
    newRow.remove();
    // Wait for mutation observer.
    await new Promise(r => content.setTimeout(r));
    checkHasClass(id, false);

    // This time remove the child list, not the row itself.

    info(`adding new row to ${id} again`);
    newRow = childList.appendChild(createNewRow());
    // Wait for mutation observer.
    await new Promise(r => content.setTimeout(r));
    checkHasClass("new-row", false);
    checkHasClass(id, true);

    info(`removing child list from ${id}`);
    childList.remove();
    // Wait for mutation observer.
    await new Promise(r => content.setTimeout(r));
    checkHasClass(id, false);

    if (["row-2-1", "row-2-2"].includes(id)) {
      checkHasClass("row-2", true);
    } else if (["row-3-1-1", "row-3-1-2"].includes(id)) {
      checkHasClass("row-3-1", true);
      checkHasClass("row-3", true);
    }
  }

  // Add a row with children and a grandchild to the end of the list. The new
  // row should be given the "children" class. The child with a grandchild
  // should be given the "children" class. I think it's safe to assume this
  // works no matter where in the tree it's added.

  const template = doc.getElementById("rowsToAdd");
  newRow = template.content.cloneNode(true).firstElementChild;
  list.appendChild(newRow);
  // Wait for mutation observer.
  await new Promise(r => content.setTimeout(r));
  checkHasClass("added-row", true);
  checkHasClass("added-row-1", true);
  checkHasClass("added-row-1-1", false);
  checkHasClass("added-row-2", false);
  newRow.remove();
  await new Promise(r => content.setTimeout(r));

  // Add a new row without children to the middle of the list. Selection should
  // be maintained.

  list.selectedIndex = 5; // row-3-1

  info("adding new row to middle of list");
  newRow = template.content.cloneNode(true).firstElementChild;
  list.insertBefore(newRow, list.querySelector("#row-3"));
  await new Promise(r => content.setTimeout(r));
  Assert.equal(list.selectedIndex, 9, "row-3-1 is still selected");

  newRow.remove();
  await new Promise(r => content.setTimeout(r));
  Assert.equal(list.selectedIndex, 5, "row-3-1 is still selected");

  list.selectedIndex = 0;
}

/**
 * Checks that expanding and collapsing works. Twisties in the test file are
 * styled as coloured squares: red for collapsed, green for expanded.
 *
 * NOTE: This is practically the same test as in browser_treeView.js,
 * but for TreeListbox instead of TreeView. If you make changes here
 * you may want to make changes there too.
 */
async function subtestExpandCollapse() {
  const doc = content.document;
  const list = doc.querySelector(`ul[is="tree-listbox"]`);
  const allIds = [
    "row-1",
    "row-2",
    "row-2-1",
    "row-2-2",
    "row-3",
    "row-3-1",
    "row-3-1-1",
    "row-3-1-2",
  ];
  const idsWithoutChildren = [
    "row-1",
    "row-2-1",
    "row-2-2",
    "row-3-1-1",
    "row-3-1-2",
  ];

  const listener = {
    reset() {
      this.collapsedRow = null;
      this.expandedRow = null;
    },
    handleEvent(event) {
      if (event.type == "collapsed") {
        this.collapsedRow = event.target;
      } else if (event.type == "expanded") {
        this.expandedRow = event.target;
      }
    },
  };
  list.addEventListener("collapsed", listener);
  list.addEventListener("expanded", listener);

  const selectHandler = {
    seenEvent: null,
    selectedAtEvent: null,

    reset() {
      this.seenEvent = null;
      this.selectedAtEvent = null;
    },
    handleEvent(event) {
      this.seenEvent = event;
      this.selectedAtEvent = list.selectedIndex;
    },
  };

  Assert.equal(
    list.querySelectorAll("collapsed").length,
    0,
    "no rows are collapsed"
  );
  Assert.equal(list.rowCount, 8, "row count");
  Assert.deepEqual(
    list.rows.map(r => r.id),
    [
      "row-1",
      "row-2",
      "row-2-1",
      "row-2-2",
      "row-3",
      "row-3-1",
      "row-3-1-1",
      "row-3-1-2",
    ],
    "rows property"
  );

  function checkSelected(expectedIndex, expectedId) {
    Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
    const selected = [...list.querySelectorAll(".selected")].map(row => row.id);
    Assert.deepEqual(
      selected,
      [expectedId],
      "correct rows have the 'selected' class"
    );
  }

  checkSelected(0, "row-1");

  // More than ANIMATION_DURATION_MS.
  const ANIMATION_WAIT_MS = 250;

  // Click the twisties of rows without children.

  async function performChange(id, expectedChange, changeCallback) {
    listener.reset();
    const row = doc.getElementById(id);
    const before = row.classList.contains("collapsed");

    changeCallback(row);

    if (expectedChange == "collapsed") {
      Assert.ok(!before, `${id} was expanded`);
      Assert.ok(row.classList.contains("collapsed"), `${id} collapsed`);
      Assert.equal(listener.collapsedRow, row, `${id} fired 'collapse' event`);
      Assert.ok(!listener.expandedRow, `${id} did not fire 'expand' event`);

      // It would be good to check the child list was visible during the
      // collapse animation, but that may already be complete by the time we
      // get here, so a check wouldn't be reliable.

      // Check the child list is removed from layout calculation after the
      // collapse animation.
      await new Promise(r => content.setTimeout(r, ANIMATION_WAIT_MS));
      Assert.ok(
        row.querySelector("ul").hidden,
        `${id}'s child list is hidden after the animation`
      );
    } else if (expectedChange == "expanded") {
      Assert.ok(before, `${id} was collapsed`);
      Assert.ok(!row.classList.contains("collapsed"), `${id} expanded`);
      Assert.ok(!listener.collapsedRow, `${id} did not fire 'collapse' event`);
      Assert.equal(listener.expandedRow, row, `${id} fired 'expand' event`);

      // It would be good to check the child list was visible during the
      // expand animation, but that may already be complete by the time we
      // get here, so a check wouldn't prove anything.

      // Check the child list is part of the layout calculation after the
      // expand animation.
      await new Promise(r => content.setTimeout(r, ANIMATION_WAIT_MS));
      Assert.ok(
        !row.querySelector("ul").hidden,
        `${id}'s child list is shown after the animation`
      );
    } else {
      Assert.equal(
        row.classList.contains("collapsed"),
        before,
        `${id} state did not change`
      );
    }
  }

  async function clickTwisty(id, expectedChange) {
    info(`clicking the twisty on ${id}`);
    await performChange(id, expectedChange, row =>
      EventUtils.synthesizeMouseAtCenter(
        row.querySelector(".twisty"),
        {},
        content
      )
    );
  }

  async function doubleClick(id, expectedChange) {
    info(`double clicking on ${id}`);
    await performChange(id, expectedChange, row =>
      EventUtils.synthesizeMouseAtCenter(row, { clickCount: 2 }, content)
    );
  }

  for (const id of idsWithoutChildren) {
    await clickTwisty(id, null);
    Assert.equal(list.querySelector(".selected").id, id);
  }

  checkSelected(7, "row-3-1-2");

  // Click the twisties of rows with children.

  function checkRowsAreHidden(...hiddenIds) {
    const remainingIds = allIds.slice();

    for (const id of allIds) {
      if (hiddenIds.includes(id)) {
        Assert.equal(doc.getElementById(id).clientHeight, 0, `${id} is hidden`);
        remainingIds.splice(remainingIds.indexOf(id), 1);
      } else {
        Assert.greater(
          doc.getElementById(id).clientHeight,
          0,
          `${id} is visible`
        );
      }
    }

    Assert.equal(list.rowCount, 8 - hiddenIds.length, "row count");
    Assert.deepEqual(
      list.rows.map(r => r.id),
      remainingIds,
      "rows property"
    );
  }

  // Collapse row 2.

  await clickTwisty("row-2", "collapsed");
  checkRowsAreHidden("row-2-1", "row-2-2");
  checkSelected(5, "row-3-1-2");

  // Collapse row 3.

  await clickTwisty("row-3", "collapsed");
  checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
  checkSelected(2, "row-3");

  // Expand row 2.

  await doubleClick("row-2", "expanded");
  checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
  checkSelected(4, "row-3");

  // Expand row 3.

  await doubleClick("row-3", "expanded");
  checkRowsAreHidden();
  checkSelected(4, "row-3");

  // Collapse row 3-1.

  await clickTwisty("row-3-1", "collapsed");
  checkRowsAreHidden("row-3-1-1", "row-3-1-2");
  checkSelected(4, "row-3");

  // Collapse row 3.

  await clickTwisty("row-3", "collapsed");
  checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
  checkSelected(4, "row-3");

  // Expand row 3.

  await clickTwisty("row-3", "expanded");
  checkRowsAreHidden("row-3-1-1", "row-3-1-2");
  checkSelected(4, "row-3");

  // Expand row 3-1.

  await clickTwisty("row-3-1", "expanded");
  checkRowsAreHidden();
  checkSelected(4, "row-3");

  // Test key presses.

  async function pressKey(id, key, expectedChange) {
    info(`pressing ${key}`);
    await performChange(id, expectedChange, () => {
      EventUtils.synthesizeKey(key, {}, content);
    });
  }

  // Row 0 has no children or parent, nothing should happen.

  list.selectedIndex = 0;
  await pressKey("row-1", "VK_LEFT");
  checkSelected(0, "row-1");
  await pressKey("row-1", "VK_RIGHT");
  checkSelected(0, "row-1");

  // Collapse row 2.

  list.selectedIndex = 1;
  await pressKey("row-2", "VK_LEFT", "collapsed");
  checkRowsAreHidden("row-2-1", "row-2-2");
  checkSelected(1, "row-2");

  await pressKey("row-2", "VK_LEFT");
  checkRowsAreHidden("row-2-1", "row-2-2");
  checkSelected(1, "row-2");

  // Collapse row 3.

  list.selectedIndex = 2;
  await pressKey("row-3", "VK_LEFT", "collapsed");
  checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
  checkSelected(2, "row-3");

  await pressKey("row-3", "VK_LEFT");
  checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
  checkSelected(2, "row-3");

  // Expand row 2.

  list.selectedIndex = 1;
  await pressKey("row-2", "VK_RIGHT", "expanded");
  checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
  checkSelected(1, "row-2");

  // Expand row 3.

  list.selectedIndex = 4;
  await pressKey("row-3", "VK_RIGHT", "expanded");
  checkRowsAreHidden();
  checkSelected(4, "row-3");

  // Go down the tree to row 3-1-1.

  await pressKey("row-3", "VK_RIGHT");
  checkRowsAreHidden();
  checkSelected(5, "row-3-1");

  await pressKey("row-3", "VK_RIGHT");
  checkRowsAreHidden();
  checkSelected(6, "row-3-1-1");

  await pressKey("row-3-1-1", "VK_RIGHT");
  checkRowsAreHidden();
  checkSelected(6, "row-3-1-1");

  // Collapse row 3-1.

  await pressKey("row-3-1-1", "VK_LEFT");
  checkRowsAreHidden();
  checkSelected(5, "row-3-1");

  await pressKey("row-3-1", "VK_LEFT", "collapsed");
  checkRowsAreHidden("row-3-1-1", "row-3-1-2");
  checkSelected(5, "row-3-1");

  // Collapse row 3.

  await pressKey("row-3-1", "VK_LEFT");
  checkRowsAreHidden("row-3-1-1", "row-3-1-2");
  checkSelected(4, "row-3");

  await pressKey("row-3", "VK_LEFT", "collapsed");
  checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
  checkSelected(4, "row-3");

  // Expand row 3.

  await pressKey("row-3", "VK_RIGHT", "expanded");
  checkRowsAreHidden("row-3-1-1", "row-3-1-2");
  checkSelected(4, "row-3");

  await pressKey("row-3", "VK_RIGHT");
  checkRowsAreHidden("row-3-1-1", "row-3-1-2");
  checkSelected(5, "row-3-1");

  // Expand row 3-1.

  await pressKey("row-3-1", "VK_RIGHT", "expanded");
  checkRowsAreHidden();
  checkSelected(5, "row-3-1");

  await pressKey("row-3-1", "VK_RIGHT");
  checkRowsAreHidden();
  checkSelected(6, "row-3-1-1");

  await pressKey("row-3-1-1", "VK_RIGHT");
  checkRowsAreHidden();
  checkSelected(6, "row-3-1-1");

  // Toggle expansion of row 3-1 with Enter key.

  list.selectedIndex = 5;
  await pressKey("row-3-1", "KEY_Enter", "collapsed");
  checkRowsAreHidden("row-3-1-1", "row-3-1-2");
  checkSelected(5, "row-3-1");

  await pressKey("row-3-1", "KEY_Enter", "expanded");
  checkRowsAreHidden();
  checkSelected(5, "row-3-1");

  // Same again, with a RTL tree.

  info("switching to RTL");
  doc.documentElement.dir = "rtl";

  // Row 0 has no children or parent, nothing should happen.

  list.selectedIndex = 0;
  await pressKey("row-1", "VK_RIGHT");
  checkSelected(0, "row-1");
  await pressKey("row-1", "VK_LEFT");
  checkSelected(0, "row-1");

  // Collapse row 2.

  list.selectedIndex = 1;
  await pressKey("row-2", "VK_RIGHT", "collapsed");
  checkRowsAreHidden("row-2-1", "row-2-2");
  checkSelected(1, "row-2");

  await pressKey("row-2", "VK_RIGHT");
  checkRowsAreHidden("row-2-1", "row-2-2");
  checkSelected(1, "row-2");

  // Collapse row 3.

  list.selectedIndex = 2;
  await pressKey("row-3", "VK_RIGHT", "collapsed");
  checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
  checkSelected(2, "row-3");

  await pressKey("row-3", "VK_RIGHT");
  checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
  checkSelected(2, "row-3");

  // Expand row 2.

  list.selectedIndex = 1;
  await pressKey("row-2", "VK_LEFT", "expanded");
  checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
  checkSelected(1, "row-2");

  // Expand row 3.

  list.selectedIndex = 4;
  await pressKey("row-3", "VK_LEFT", "expanded");
  checkRowsAreHidden();
  checkSelected(4, "row-3");

  // Go down the tree to row 3-1-1.

  await pressKey("row-3", "VK_LEFT");
  checkRowsAreHidden();
  checkSelected(5, "row-3-1");

  await pressKey("row-3", "VK_LEFT");
  checkRowsAreHidden();
  checkSelected(6, "row-3-1-1");

  await pressKey("row-3-1-1", "VK_LEFT");
  checkRowsAreHidden();
  checkSelected(6, "row-3-1-1");

  // Collapse row 3-1.

  await pressKey("row-3-1-1", "VK_RIGHT");
  checkRowsAreHidden();
  checkSelected(5, "row-3-1");

  await pressKey("row-3-1", "VK_RIGHT", "collapsed");
  checkRowsAreHidden("row-3-1-1", "row-3-1-2");
  checkSelected(5, "row-3-1");

  // Collapse row 3.

  await pressKey("row-3-1", "VK_RIGHT");
  checkRowsAreHidden("row-3-1-1", "row-3-1-2");
  checkSelected(4, "row-3");

  await pressKey("row-3", "VK_RIGHT", "collapsed");
  checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
  checkSelected(4, "row-3");

  // Expand row 3.

  await pressKey("row-3", "VK_LEFT", "expanded");
  checkRowsAreHidden("row-3-1-1", "row-3-1-2");
  checkSelected(4, "row-3");

  await pressKey("row-3", "VK_LEFT");
  checkRowsAreHidden("row-3-1-1", "row-3-1-2");
  checkSelected(5, "row-3-1");

  // Expand row 3-1.

  await pressKey("row-3-1", "VK_LEFT", "expanded");
  checkRowsAreHidden();
  checkSelected(5, "row-3-1");

  await pressKey("row-3-1", "VK_LEFT");
  checkRowsAreHidden();
  checkSelected(6, "row-3-1-1");

  await pressKey("row-3-1-1", "VK_LEFT");
  checkRowsAreHidden();
  checkSelected(6, "row-3-1-1");

  // Use the class methods for expanding and collapsing.

  selectHandler.reset();
  list.addEventListener("select", selectHandler);
  listener.reset();

  list.collapseRowAtIndex(6); // No children, no effect.
  await new Promise(r => content.setTimeout(r, ANIMATION_WAIT_MS));
  Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
  Assert.ok(!listener.collapsedRow, "'collapsed' event did not fire");

  list.expandRowAtIndex(6); // No children, no effect.
  await new Promise(r => content.setTimeout(r, ANIMATION_WAIT_MS));
  Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
  Assert.ok(!listener.expandedRow, "'expanded' event did not fire");

  list.collapseRowAtIndex(1); // Item with children that aren't selected.
  await new Promise(r => content.setTimeout(r, ANIMATION_WAIT_MS));
  Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
  Assert.equal(
    listener.collapsedRow.id,
    "row-2",
    "row-2 fired 'collapsed' event"
  );
  listener.reset();

  list.expandRowAtIndex(1); // Item with children that aren't selected.
  await new Promise(r => content.setTimeout(r, ANIMATION_WAIT_MS));
  Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
  Assert.equal(
    listener.expandedRow.id,
    "row-2",
    "row-2 fired 'expanded' event"
  );
  listener.reset();

  list.collapseRowAtIndex(5); // Item with children that are selected.
  await new Promise(r => content.setTimeout(r, ANIMATION_WAIT_MS));
  Assert.ok(selectHandler.seenEvent, "'select' event fired");
  Assert.equal(
    selectHandler.selectedAtEvent,
    5,
    "selectedIndex was correct when 'select' event fired"
  );
  Assert.equal(
    listener.collapsedRow.id,
    "row-3-1",
    "row-3-1 fired 'collapsed' event"
  );
  checkRowsAreHidden("row-3-1-1", "row-3-1-2");
  checkSelected(5, "row-3-1");
  selectHandler.reset();
  listener.reset();

  list.expandRowAtIndex(5); // Selected item with children.
  await new Promise(r => content.setTimeout(r, ANIMATION_WAIT_MS));
  Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
  Assert.equal(
    listener.expandedRow.id,
    "row-3-1",
    "row-3-1 fired 'expanded' event"
  );
  checkRowsAreHidden();
  checkSelected(5, "row-3-1");
  listener.reset();

  list.selectedIndex = 7;
  selectHandler.reset();

  list.collapseRowAtIndex(4); // Item with grandchildren that are selected.
  await new Promise(r => content.setTimeout(r, ANIMATION_WAIT_MS));
  Assert.ok(selectHandler.seenEvent, "'select' event fired");
  Assert.equal(
    selectHandler.selectedAtEvent,
    4,
    "selectedIndex was correct when 'select' event fired"
  );
  Assert.equal(
    listener.collapsedRow.id,
    "row-3",
    "row-3 fired 'collapsed' event"
  );
  checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
  checkSelected(4, "row-3");
  selectHandler.reset();
  listener.reset();

  list.expandRowAtIndex(4); // Selected item with grandchildren.
  await new Promise(r => content.setTimeout(r, ANIMATION_WAIT_MS));
  Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
  Assert.equal(
    listener.expandedRow.id,
    "row-3",
    "row-3 fired 'expanded' event"
  );
  checkRowsAreHidden();
  checkSelected(4, "row-3");
  listener.reset();

  list.removeEventListener("collapsed", listener);
  list.removeEventListener("expanded", listener);
  list.removeEventListener("select", selectHandler);
  doc.documentElement.dir = null;
}

/**
 * Tests what happens to selection when a row is removed.
 */
async function subtestSelectOnRemoval1() {
  const doc = content.document;
  const list = doc.getElementById("deleteTree");

  let selectPromise;
  function promiseSelectEvent() {
    selectPromise = new Promise(resolve =>
      list.addEventListener(
        "select",
        () => resolve([list.selectedIndex, list.selectedRow?.id ?? null]),
        {
          once: true,
        }
      )
    );
  }
  // dRow-1
  // dRow-2
  //   dRow-2-1
  //   dRow-2-2
  // dRow-3
  //   dRow-3-1
  //     dRow-3-1-1
  //     dRow-3-1-2
  //     dRow-3-1-3
  // dRow-4
  //   dRow-4-1
  //   dRow-4-2
  //   dRow-4-3
  //     dRow-4-3-1
  //     dRow-4-3-2
  //   dRow-4-4
  // dRow-5
  //   dRow-5-1
  // dRow-6

  // Delete a row that is selected, and not at the top level. Selection should
  // move to the next row under the shared parent.

  list.selectedIndex = 2;
  Assert.equal(list.selectedRow.id, "dRow-2-1");

  promiseSelectEvent();
  list.querySelector("#dRow-2-1").remove();
  Assert.deepEqual(
    await selectPromise,
    [2, "dRow-2-2"],
    "selection moved to the next row"
  );

  // dRow-1
  // dRow-2
  //   dRow-2-2
  // dRow-3
  //   dRow-3-1
  //     dRow-3-1-1
  //     dRow-3-1-2
  //     dRow-3-1-3
  // dRow-4
  //   dRow-4-1
  //   dRow-4-2
  //   dRow-4-3
  //     dRow-4-3-1
  //     dRow-4-3-2
  //   dRow-4-4
  // dRow-5
  //   dRow-5-1
  // dRow-6

  // Delete a row that contains the selected, and at the top level. Selection
  // should move to the next top-level row.

  Assert.equal(list.selectedRow.id, "dRow-2-2");
  promiseSelectEvent();
  list.querySelector("#dRow-2").remove();
  Assert.deepEqual(
    await selectPromise,
    [1, "dRow-3"],
    "selection moved to the next row"
  );

  // dRow-1
  // dRow-3
  //   dRow-3-1
  //     dRow-3-1-1
  //     dRow-3-1-2
  //     dRow-3-1-3
  // dRow-4
  //   dRow-4-1
  //   dRow-4-2
  //   dRow-4-3
  //     dRow-4-3-1
  //     dRow-4-3-2
  //   dRow-4-4
  // dRow-5
  //   dRow-5-1
  // dRow-6

  // Delete the first top-level row that is selected. Should select the first
  // row.
  list.selectedIndex = 0;
  Assert.equal(list.selectedRow.id, "dRow-1");
  promiseSelectEvent();
  list.querySelector("#dRow-1").remove();
  Assert.deepEqual(
    await selectPromise,
    [0, "dRow-3"],
    "selection moved to the first row"
  );

  // dRow-3
  //   dRow-3-1
  //     dRow-3-1-1
  //     dRow-3-1-2
  //     dRow-3-1-3
  // dRow-4
  //   dRow-4-1
  //   dRow-4-2
  //   dRow-4-3
  //     dRow-4-3-1
  //     dRow-4-3-2
  //   dRow-4-4
  // dRow-5
  //   dRow-5-1
  // dRow-6

  // Delete the last top-level row that is selected. Should select the last row.
  list.selectedIndex = 14;
  Assert.equal(list.selectedRow.id, "dRow-6");
  promiseSelectEvent();
  list.querySelector("#dRow-6").remove();
  Assert.deepEqual(
    await selectPromise,
    [13, "dRow-5-1"],
    "selection moved to the new last row"
  );

  // dRow-3
  //   dRow-3-1
  //     dRow-3-1-1
  //     dRow-3-1-2
  //     dRow-3-1-3
  // dRow-4
  //   dRow-4-1
  //   dRow-4-2
  //   dRow-4-3
  //     dRow-4-3-1
  //     dRow-4-3-2
  //   dRow-4-4
  // dRow-5
  //   dRow-5-1

  // Delete the last selected descendant should move selection to the new
  // descendant child.
  list.selectedIndex = 11;
  Assert.equal(list.selectedRow.id, "dRow-4-4");
  promiseSelectEvent();
  list.querySelector("#dRow-4-4").remove();
  Assert.deepEqual(
    await selectPromise,
    [10, "dRow-4-3-2"],
    "selection moved to the new last row"
  );

  // dRow-3
  //   dRow-3-1
  //     dRow-3-1-1
  //     dRow-3-1-2
  //     dRow-3-1-3
  // dRow-4
  //   dRow-4-1
  //   dRow-4-2
  //   dRow-4-3
  //     dRow-4-3-1
  //     dRow-4-3-2
  // dRow-5
  //   dRow-5-1

  // Delete the first selected child should move selection to the new first child.
  list.selectedIndex = 6;
  Assert.equal(list.selectedRow.id, "dRow-4-1");
  promiseSelectEvent();
  list.querySelector("#dRow-4-1").remove();
  Assert.deepEqual(
    await selectPromise,
    [6, "dRow-4-2"],
    "selection moved to the new first row"
  );

  // dRow-3
  //   dRow-3-1
  //     dRow-3-1-1
  //     dRow-3-1-2
  //     dRow-3-1-3
  // dRow-4
  //   dRow-4-2
  //   dRow-4-3
  //     dRow-4-3-1
  //     dRow-4-3-2
  // dRow-5
  //   dRow-5-1

  // Delete a row that isn't selected. Nothing should happen.

  list.selectedIndex = 2;
  Assert.equal(list.selectedRow.id, "dRow-3-1-1");

  list.querySelector("#dRow-3-1-2").remove();
  await new Promise(resolve => content.setTimeout(resolve));
  Assert.equal(list.selectedIndex, 2, "selection did not change");
  Assert.equal(list.selectedRow.id, "dRow-3-1-1", "selection did not change");

  // dRow-3
  //   dRow-3-1
  //     dRow-3-1-1
  //     dRow-3-1-3
  // dRow-4
  //   dRow-4-2
  //   dRow-4-3
  //     dRow-4-3-1
  //     dRow-4-3-2
  // dRow-5
  //   dRow-5-1

  // Deleting the last row under a parent that contains the selection should
  // select the parent.
  list.selectedIndex = 2;
  Assert.equal(list.selectedRow.id, "dRow-3-1-1");

  promiseSelectEvent();
  const rowToReplace = list.querySelector("#dRow-3-1");
  rowToReplace.remove();
  Assert.deepEqual(
    await selectPromise,
    [0, "dRow-3"],
    "selection moved to the parent row"
  );

  // dRow-3
  // dRow-4
  //   dRow-4-2
  //   dRow-4-3
  //     dRow-4-3-1
  //     dRow-4-3-2
  // dRow-5
  //   dRow-5-1

  // Deleting several rows under a parent, should select the parent row.
  list.selectedIndex = 4;
  Assert.equal(list.selectedRow.id, "dRow-4-3-1");
  promiseSelectEvent();
  list.querySelector("#dRow-4 ul").remove();
  Assert.deepEqual(
    await selectPromise,
    [1, "dRow-4"],
    "selection moved to the parent row"
  );

  // Delete the last remaining rows. The selected index should be -1.

  promiseSelectEvent();
  list.replaceChildren();
  Assert.deepEqual(await selectPromise, [-1, null], "selection was cleared");

  // Add back a row. One of the row's children was selected, this should be
  // removed and the selection set to the top-level row.

  promiseSelectEvent();
  list.appendChild(rowToReplace);
  Assert.deepEqual(
    await selectPromise,
    [0, "dRow-3-1"],
    "selection set to the added row"
  );
  Assert.ok(list.querySelector("#dRow-3-1-1"), "child of the added row exists");
  Assert.ok(
    !list.querySelector("#dRow-3-1-1").classList.contains("selected"),
    "child of the added row is not selected"
  );
}

/**
 * Tests what happens to selection when a row is removed.
 */
async function subtestSelectOnRemoval2() {
  const doc = content.document;
  const list = doc.querySelector(`ul[is="tree-listbox"]`);

  let selectPromise;
  function promiseSelectEvent() {
    selectPromise = new Promise(resolve =>
      list.addEventListener("select", () => resolve(list.selectedIndex), {
        once: true,
      })
    );
  }

  // Delete row-3 containing the selection.

  list.selectedIndex = 7; // row-3-1-2

  promiseSelectEvent();
  list.querySelector("#row-3").remove();
  Assert.equal(await selectPromise, 3, "selection moved to the last row");

  // Delete row-2. Selection should move to the only row.

  promiseSelectEvent();
  list.querySelector("#row-2").remove();
  Assert.equal(
    await selectPromise,
    0, // row-1
    "selection moved to the last row"
  );
}

/**
 * Tests what happens to selection when elements above it are removed.
 */
async function subtestSelectOnRemoval3() {
  const doc = content.document;
  const list = doc.querySelector(`ul[is="tree-listbox"]`);

  // Delete a row.

  list.selectedIndex = 6; // row-3-1-1

  list.querySelector("#row-2-1").remove();
  await new Promise(r => content.setTimeout(r));
  Assert.deepEqual(
    list.rows.map(r => r.id),
    ["row-1", "row-2", "row-2-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"]
  );
  Assert.equal(
    list.selectedIndex,
    5, // row-3-1-1
    "selection moved to the previous top-level row"
  );

  // Delete an element that isn't a row.

  list.querySelector("#row-2 div").remove();
  await new Promise(r => content.setTimeout(r));
  Assert.deepEqual(
    list.rows.map(r => r.id),
    ["row-1", "row-2", "row-2-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"]
  );
  Assert.equal(
    list.selectedIndex,
    5, // row-3-1-1
    "selection moved to the previous top-level row"
  );

  // Delete an element that contains a row.

  list.querySelector("#row-2 ul").remove();
  await new Promise(r => content.setTimeout(r));
  Assert.deepEqual(
    list.rows.map(r => r.id),
    ["row-1", "row-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"]
  );
  Assert.equal(
    list.selectedIndex,
    4, // row-3-1-1
    "selection moved to the previous top-level row"
  );
}

/**
 * Tests that rows marked as unselectable cannot be selected.
 */
async function subtestUnselectable() {
  const doc = content.document;

  const list = doc.querySelector(`ul#unselectableTree`);
  Assert.ok(!!list, "the list exists");

  const initialRowIds = [
    "uRow-2-1",
    "uRow-2-2",
    "uRow-3-1",
    "uRow-3-1-1",
    "uRow-3-1-2",
  ];
  Assert.equal(list.rowCount, initialRowIds.length, "rowCount is correct");
  Assert.deepEqual(
    list.rows.map(r => r.id),
    initialRowIds,
    "initial rows are correct"
  );

  function checkSelected(expectedIndex, expectedId) {
    Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
    Assert.deepEqual(
      Array.from(list.querySelectorAll(".selected"), row => row.id),
      [expectedId],
      "correct rows have the 'selected' class"
    );
  }

  checkSelected(0, "uRow-2-1");

  // Clicking unselectable rows should not change the selection.
  EventUtils.synthesizeMouseAtCenter(
    doc.querySelector("#uRow-1 > div"),
    {},
    content
  );
  checkSelected(0, "uRow-2-1");
  EventUtils.synthesizeMouseAtCenter(
    doc.querySelector("#uRow-2 > div"),
    {},
    content
  );
  checkSelected(0, "uRow-2-1");
  EventUtils.synthesizeMouseAtCenter(
    doc.querySelector("#uRow-3 > div"),
    {},
    content
  );
  checkSelected(0, "uRow-2-1");

  // Unselectable rows should not be accessible by keyboard.
  EventUtils.synthesizeKey("KEY_ArrowUp", {}, content);
  checkSelected(0, "uRow-2-1");
  EventUtils.synthesizeKey("KEY_ArrowDown", {}, content);
  EventUtils.synthesizeKey("KEY_ArrowDown", {}, content);
  checkSelected(2, "uRow-3-1");
  EventUtils.synthesizeKey("KEY_Home", {}, content);
  checkSelected(0, "uRow-2-1");
  EventUtils.synthesizeKey("KEY_End", {}, content);
  checkSelected(4, "uRow-3-1-2");
  EventUtils.synthesizeKey("KEY_PageUp", {}, content);
  checkSelected(0, "uRow-2-1");
  EventUtils.synthesizeKey("KEY_PageDown", {}, content);
  checkSelected(4, "uRow-3-1-2");

  EventUtils.synthesizeKey("VK_LEFT", {}, content); // Move up to 3-1.
  checkSelected(2, "uRow-3-1");
  EventUtils.synthesizeKey("VK_LEFT", {}, content); // Collapse.
  checkSelected(2, "uRow-3-1");
  EventUtils.synthesizeKey("VK_LEFT", {}, content); // Try to move to 3.
  checkSelected(2, "uRow-3-1");
}
