From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- comm/mail/base/test/browser/browser_treeView.js | 1941 +++++++++++++++++++++++ 1 file changed, 1941 insertions(+) create mode 100644 comm/mail/base/test/browser/browser_treeView.js (limited to 'comm/mail/base/test/browser/browser_treeView.js') diff --git a/comm/mail/base/test/browser/browser_treeView.js b/comm/mail/base/test/browser/browser_treeView.js new file mode 100644 index 0000000000..bf1eeda35a --- /dev/null +++ b/comm/mail/base/test/browser/browser_treeView.js @@ -0,0 +1,1941 @@ +/* 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/. */ + +let tabmail = document.getElementById("tabmail"); +registerCleanupFunction(() => { + tabmail.closeOtherTabs(tabmail.tabInfo[0]); +}); + +// We wish to run several variants of each test with minor differences, based on +// changes in the source file, in order to verify that certain variables don't +// impact behavior. +const TEST_VARIANTS = ["header", "no-header"]; + +/** + * Run a given test in a new tab and script sandbox. + * + * @param {Function} test - The test function to run in the sandbox. + * @param {string} filenameFragment - The fragment of the filename representing + * the test variant to run. + * @param {object[]} sandboxArgs - Arguments to the sandbox spawner to pass to + * the test function. + */ +async function runTestInSandbox(test, filenameFragment, sandboxArgs = []) { + // Create a new tab with our custom content. + const tab = tabmail.openTab("contentTab", { + url: `chrome://mochitests/content/browser/comm/mail/base/test/browser/files/tree-element-test-${filenameFragment}.xhtml`, + }); + + await BrowserTestUtils.browserLoaded(tab.browser); + tab.browser.focus(); + + // Spawn a new JavaScript sandbox for the tab and run the test function inside + // of it. + await SpecialPowers.spawn(tab.browser, sandboxArgs, test); + + tabmail.closeTab(tab); +} + +/** + * Checks that interactions with the widget do as expected. + */ +add_task(async function testKeyboardAndMouse() { + for (const variant of TEST_VARIANTS) { + info(`Running keyboard and mouse test for ${variant}`); + await runTestInSandbox(subtestKeyboardAndMouse, variant, [variant]); + } +}); + +async function subtestKeyboardAndMouse(variant) { + let doc = content.document; + + let list = doc.getElementById("testTree"); + Assert.ok(!!list, "the list exists"); + + async function doListActionAndWaitForRowBuffer(actionFn) { + // Filling the row buffer is fiddly timing, so provide an event to indicate + // that actions which may trigger changes in the row buffer have finished. + const eventName = "_treerowbufferfill"; + list._rowBufferReadyEvent = new content.CustomEvent(eventName); + + const promise = new Promise(resolve => + list.addEventListener(eventName, resolve, { once: true }) + ); + + await actionFn(); + await promise; + + list._rowBufferReadyEvent = null; + } + + async function scrollListToPosition(topOfScroll) { + await doListActionAndWaitForRowBuffer(() => { + list.scrollTo(0, topOfScroll); + }); + } + + Assert.equal(list._rowElementName, "test-row"); + Assert.equal(list._rowElementClass, content.customElements.get("test-row")); + Assert.equal( + list._toleranceSize, + 26, + "list should have tolerance twice the number of visible rows" + ); + + // We should be scrolled to the top already, but this will ensure we get an + // event fire one way or another. + await scrollListToPosition(0); + + let rows = list.querySelectorAll(`tr[is="test-row"]`); + // Count is calculated from the height of `list` divided by + // TestCardRow.ROW_HEIGHT, plus list._toleranceSize. + Assert.equal(rows.length, 13 + 26, "the list has the right number of rows"); + + Assert.equal(doc.activeElement, doc.body); + + // Verify the tab order of list elements by tabbing both forward and backward + // through them. + EventUtils.synthesizeKey("VK_TAB", {}, content); + Assert.equal( + doc.activeElement.id, + "before", + "the element before the list should have focus" + ); + + if (variant == "header") { + // Tab order changes slightly if the table has a header versus if it + // doesn't, so we need to account for that variation. + EventUtils.synthesizeKey("VK_TAB", {}, content); + Assert.equal( + doc.activeElement.id, + "testColButton", + "the list header button should have focus" + ); + } + + EventUtils.synthesizeKey("VK_TAB", {}, content); + Assert.equal(doc.activeElement.id, "testBody", "the list should have focus"); + + EventUtils.synthesizeKey("VK_TAB", {}, content); + Assert.equal( + doc.activeElement.id, + "after", + "the element after the list should have focus" + ); + + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content); + Assert.equal(doc.activeElement.id, "testBody", "the list should have focus"); + + if (variant == "header") { + // Tab order changes slightly if the table has a header versus if it + // doesn't, so we need to account for that variation. + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content); + Assert.equal( + doc.activeElement.id, + "testColButton", + "the list header button should have focus" + ); + } + + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content); + Assert.equal( + doc.activeElement.id, + "before", + "the element before the list should have focus" + ); + + // Check initial selection. + + let selectHandler = { + seenEvent: null, + currentAtEvent: null, + selectedAtEvent: null, + t0: Date.now(), + time: 0, + + reset() { + this.seenEvent = null; + this.currentAtEvent = null; + this.selectedAtEvent = null; + this.t0 = Date.now(); + }, + handleEvent(event) { + this.seenEvent = event; + this.currentAtEvent = list.currentIndex; + this.selectedAtEvent = list.selectedIndices; + this.time = Date.now() - this.t0; + }, + }; + + /** + * Check if the spacerTop TBODY of the TreeViewTable is properly allocating + * the height of non existing rows. + * + * @param {int} rows - The number of rows that the spacerTop should be + * simulating their height allocation. + */ + function checkTopSpacerHeight(rows) { + let table = doc.querySelector(`[is="tree-view-table"]`); + // -26 to account for the tolerance buffer. + Assert.equal( + table.spacerTop.clientHeight, + list.getRowAtIndex(rows).clientHeight * (rows - 26), + "The top spacer has the correct height" + ); + } + + function checkCurrent(expectedIndex) { + Assert.equal(list.currentIndex, expectedIndex, "currentIndex is correct"); + if (selectHandler.currentAtEvent !== null) { + Assert.equal( + selectHandler.currentAtEvent, + expectedIndex, + "currentIndex was correct at the last 'select' event" + ); + } + + let current = list.querySelectorAll(".current"); + if (expectedIndex == -1) { + Assert.equal(current.length, 0, "no rows have the 'current' class"); + } else { + Assert.equal(current.length, 1, "only one row has the 'current' class"); + Assert.equal( + current[0].index, + expectedIndex, + "correct row has the 'current' class" + ); + } + } + + function checkSelected(...expectedIndices) { + Assert.deepEqual( + list.selectedIndices, + expectedIndices, + "selectedIndices are correct" + ); + + if (selectHandler.selectedAtEvent !== null) { + // Check the value was already set when the select event fired. + Assert.deepEqual( + selectHandler.selectedAtEvent, + expectedIndices, + "selectedIndices were correct at the last 'select' event" + ); + } + + let selected = [...list.querySelectorAll(".selected")].map( + row => row.index + ); + expectedIndices.sort((a, b) => a - b); + Assert.deepEqual( + selected, + expectedIndices, + "correct rows have the 'selected' class" + ); + } + + checkCurrent(0); + checkSelected(0); + + // Click on some individual rows. + + const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" + ); + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + async function clickOnRow(index, modifiers = {}, expectEvent = true) { + if (modifiers.shiftKey) { + info(`clicking on row ${index} with shift key`); + } else if (modifiers.accelKey) { + info(`clicking on row ${index} with ctrl key`); + } else { + info(`clicking on row ${index}`); + } + + let x = list.clientWidth / 2; + let y = list.table.header.clientHeight + index * 50 + 25; + + selectHandler.reset(); + list.addEventListener("select", selectHandler, { once: true }); + EventUtils.synthesizeMouse(list, x, y, modifiers, content); + await TestUtils.waitForCondition( + () => !!selectHandler.seenEvent == expectEvent, + `'select' event should ${expectEvent ? "" : "not "}get fired` + ); + } + + await clickOnRow(0, {}, false); + checkCurrent(0); + checkSelected(0); + + await clickOnRow(1); + checkCurrent(1); + checkSelected(1); + + await clickOnRow(2); + checkCurrent(2); + checkSelected(2); + + // Select multiple rows by shift-clicking. + + await clickOnRow(4, { shiftKey: true }); + checkCurrent(4); + checkSelected(2, 3, 4); + + // Holding ctrl and shift should always produce a range selection. + await clickOnRow(6, { accelKey: true, shiftKey: true }); + checkCurrent(6); + checkSelected(2, 3, 4, 5, 6); + + await clickOnRow(0, { shiftKey: true }); + checkCurrent(0); + checkSelected(0, 1, 2); + + await clickOnRow(2, { shiftKey: true }); + checkCurrent(2); + checkSelected(2); + + // Select multiple rows by ctrl-clicking. + + await clickOnRow(5, { accelKey: true }); + checkCurrent(5); + checkSelected(2, 5); + + await clickOnRow(1, { accelKey: true }); + checkCurrent(1); + checkSelected(1, 2, 5); + + await clickOnRow(5, { accelKey: true }); + checkCurrent(5); + checkSelected(1, 2); + + await clickOnRow(1, { accelKey: true }); + checkCurrent(1); + checkSelected(2); + + await clickOnRow(2, { accelKey: true }); + checkCurrent(2); + checkSelected(); + + // Move around by pressing keys. + + async function pressKey(key, modifiers = {}, expectEvent = true) { + if (modifiers.shiftKey) { + info(`pressing ${key} with shift key`); + } else if (modifiers.accelKey) { + info(`pressing ${key} with accel key`); + } else { + info(`pressing ${key}`); + } + + selectHandler.reset(); + list.addEventListener("select", selectHandler, { once: true }); + EventUtils.synthesizeKey(key, modifiers, content); + await TestUtils.waitForCondition( + () => !!selectHandler.seenEvent == expectEvent, + `'select' event should ${expectEvent ? "" : "not "}get fired` + ); + // We don't enforce any delay on multiselection. + let multiselect = + (AppConstants.platform == "macosx" && key == " ") || + modifiers.shiftKey || + modifiers.accelKey; + if (expectEvent && !multiselect) { + // We have data-select-delay="250" in treeView.xhtml + Assert.greater(selectHandler.time, 240, "should select only after delay"); + } + } + + await pressKey("VK_UP"); + checkCurrent(1); + checkSelected(1); + + await pressKey("VK_UP", { accelKey: true }, false); + checkCurrent(0); + checkSelected(1); + + // Without Ctrl selection moves with focus again. + await pressKey("VK_UP"); + checkCurrent(0); + checkSelected(0); + + // Does nothing. + await pressKey("VK_UP", {}, false); + checkCurrent(0); + checkSelected(0); + + await pressKey("VK_DOWN", { accelKey: true }, false); + checkCurrent(1); + checkSelected(0); + + await pressKey("VK_DOWN", { accelKey: true }, false); + checkCurrent(2); + checkSelected(0); + + // Multi select with only Space on macOS on a focused row, since Cmd+Space is + // captured by the OS. + if (AppConstants.platform == "macosx") { + await pressKey(" "); + } else { + // Multi select with Ctrl+Space for Windows and Linux. + await pressKey(" ", { accelKey: true }); + } + checkCurrent(2); + checkSelected(0, 2); + + await pressKey("VK_DOWN", { accelKey: true }, false); + checkCurrent(3); + checkSelected(0, 2); + + await pressKey("VK_DOWN", { accelKey: true }, false); + checkCurrent(4); + checkSelected(0, 2); + + if (AppConstants.platform == "macosx") { + await pressKey(" "); + } else { + await pressKey(" ", { accelKey: true }); + } + checkCurrent(4); + checkSelected(0, 2, 4); + + // Single selection restored with normal navigation. + await pressKey("VK_UP"); + checkCurrent(3); + checkSelected(3); + + // We don't allow unselecting a selected row with Space on macOS due to + // conflict with the `mail.advance_on_spacebar` pref. + if (AppConstants.platform != "macosx") { + // Can select none using Ctrl+Space. + await pressKey(" ", { accelKey: true }); + checkCurrent(3); + checkSelected(); + } + + await pressKey("VK_DOWN"); + checkCurrent(4); + checkSelected(4); + + await pressKey("VK_HOME", { accelKey: true }, false); + checkCurrent(0); + checkSelected(4); + + if (AppConstants.platform == "macosx") { + // We can't clear the selection with only Space on macOS, simulate a Arrow + // Up to force clear selection and only select the top most row. + await pressKey("VK_UP"); + } else { + // Select only the current item with Space (no modifier). + await pressKey(" "); + } + checkCurrent(0); + checkSelected(0); + + // The list is 630px high, so rows 0-12 are fully or partly visible. + + await doListActionAndWaitForRowBuffer(async () => { + await pressKey("VK_PAGE_DOWN"); + }); + checkCurrent(13); + checkSelected(13); + Assert.equal( + list.getFirstVisibleIndex(), + 1, + "should have scrolled down a page" + ); + + await doListActionAndWaitForRowBuffer(async () => { + await pressKey("VK_PAGE_UP", { shiftKey: true }); + }); + checkCurrent(0); + checkSelected(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13); + Assert.equal( + list.getFirstVisibleIndex(), + 0, + "should have scrolled up a page" + ); + + // Shrink shift selection. + await pressKey("VK_DOWN", { shiftKey: true }); + checkCurrent(1); + checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13); + + await pressKey("VK_DOWN", { accelKey: true }, false); + checkCurrent(2); + checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13); + + await pressKey("VK_DOWN", { accelKey: true }, false); + checkCurrent(3); + checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13); + + if (AppConstants.platform == "macosx") { + // We don't allow unselecting a selected row with Space on macOS due to + // conflict with the `mail.advance_on_spacebar` pref, so simulate a + // CMD+click to unselect it. + await clickOnRow(3, { accelKey: true }); + } else { + // Break the shift sequence by Ctrl+Space. + await pressKey(" ", { accelKey: true }); + } + checkCurrent(3); + checkSelected(1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13); + + await pressKey("VK_DOWN", { shiftKey: true }); + checkCurrent(4); + checkSelected(3, 4); + + // Reverse selection direction. + await pressKey("VK_HOME", { shiftKey: true }); + checkCurrent(0); + checkSelected(0, 1, 2, 3); + + // Now rows 138-149 are fully visible. + + await doListActionAndWaitForRowBuffer(async () => { + await pressKey("VK_END"); + }); + checkCurrent(149); + checkSelected(149); + Assert.equal( + list.getFirstVisibleIndex(), + 137, + "should have scrolled to the end" + ); + checkTopSpacerHeight(137); + + // Does nothing. + await pressKey("VK_DOWN", {}, false); + checkCurrent(149); + checkSelected(149); + Assert.equal( + list.getFirstVisibleIndex(), + 137, + "should not have changed view" + ); + checkTopSpacerHeight(137); + + await doListActionAndWaitForRowBuffer(async () => { + await pressKey("VK_PAGE_UP"); + }); + checkCurrent(136); + checkSelected(136); + Assert.equal( + list.getFirstVisibleIndex(), + 136, + "should have scrolled up a page" + ); + + await doListActionAndWaitForRowBuffer(async () => { + await pressKey("VK_PAGE_DOWN", { shiftKey: true }); + }); + checkCurrent(149); + checkSelected( + 136, + 137, + 138, + 139, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 149 + ); + Assert.equal( + list.getFirstVisibleIndex(), + 137, + "should have scrolled down a page" + ); + checkTopSpacerHeight(137); + + await doListActionAndWaitForRowBuffer(async () => { + await pressKey("VK_HOME"); + }); + checkCurrent(0); + checkSelected(0); + Assert.equal( + list.getFirstVisibleIndex(), + 0, + "should have scrolled to the beginning" + ); + + // Scroll around. Which rows are current and selected should be remembered + // even if the row element itself disappears. + + selectHandler.reset(); + await scrollListToPosition(125); + checkCurrent(0); + checkSelected(0); + Assert.equal( + list.getFirstVisibleIndex(), + 2, + "getFirstVisibleIndex is correct" + ); + + await scrollListToPosition(2525); + Assert.equal(list.currentIndex, 0, "currentIndex is still set"); + Assert.ok( + !list.querySelector(".current"), + "no visible rows have the 'current' class" + ); + Assert.deepEqual(list.selectedIndices, [0], "selectedIndices are still set"); + Assert.ok( + !list.querySelector(".selected"), + "no visible rows have the 'selected' class" + ); + Assert.equal( + list.getFirstVisibleIndex(), + 50, + "getFirstVisibleIndex is correct" + ); + Assert.ok(!selectHandler.seenEvent, "should not have fired 'select' event"); + checkTopSpacerHeight(50); + + await doListActionAndWaitForRowBuffer(async () => { + await pressKey("VK_DOWN"); + }); + checkCurrent(1); + checkSelected(1); + Assert.equal( + list.getFirstVisibleIndex(), + 1, + "should have scrolled so that the second row is in view" + ); + + selectHandler.reset(); + await scrollListToPosition(0); + checkCurrent(1); + checkSelected(1); + Assert.equal( + list.getFirstVisibleIndex(), + 0, + "getFirstVisibleIndex is correct" + ); + Assert.ok( + !selectHandler.seenEvent, + "'select' event did not fire as expected" + ); + + await pressKey("VK_UP"); + checkCurrent(0); + checkSelected(0); + Assert.equal( + list.getFirstVisibleIndex(), + 0, + "should have scrolled so that the first row is in view" + ); + + // Some literal edge cases. Clicking on a partially visible row should + // scroll it into view. + + // Calculate the visible area in order to verify that rows appear where they + // are intended to appear. + const listRect = list.getBoundingClientRect(); + const headerHeight = list.table.header.clientHeight; + + const visibleRect = new content.DOMRect( + listRect.x, + listRect.y + headerHeight, + listRect.width, + listRect.height - headerHeight + ); + + Assert.equal(visibleRect.height, 630, "the table body should be 630px tall"); + + rows = list.querySelectorAll(`tr[is="test-row"]`); + let bcr = rows[12].getBoundingClientRect(); + Assert.less( + Math.round(bcr.top), + Math.round(visibleRect.bottom), + "top of row 12 is visible" + ); + Assert.greater( + Math.round(bcr.bottom), + Math.round(visibleRect.bottom), + "bottom of row 12 is not visible" + ); + + await doListActionAndWaitForRowBuffer(async () => { + await clickOnRow(12); + }); + rows = list.querySelectorAll(`tr[is="test-row"]`); + bcr = rows[12].getBoundingClientRect(); + Assert.less( + Math.round(bcr.top), + Math.round(visibleRect.bottom), + "the top of row 12 should be visible" + ); + Assert.equal( + Math.round(bcr.bottom), + Math.round(visibleRect.bottom), + "row 12 should be at the bottom of the visible area" + ); + + bcr = rows[0].getBoundingClientRect(); + Assert.less( + Math.round(bcr.top), + Math.round(visibleRect.top), + "top of row 0 is not visible" + ); + Assert.greater( + Math.round(bcr.bottom), + Math.round(visibleRect.top), + "bottom of row 0 is visible" + ); + + await doListActionAndWaitForRowBuffer(async () => { + await clickOnRow(0); + }); + rows = list.querySelectorAll(`tr[is="test-row"]`); + bcr = rows[0].getBoundingClientRect(); + Assert.equal( + Math.round(bcr.top), + Math.round(visibleRect.top), + "row 0 should be at the top of the visible area" + ); + Assert.greater( + Math.round(bcr.bottom), + Math.round(visibleRect.top), + "the bottom of row 0 should be visible" + ); +} + +/** + * Checks that changes in the view are propagated to the list. + */ +add_task(async function testRowCountChange() { + for (const variant of TEST_VARIANTS) { + info(`Running row count change test for ${variant}`); + await runTestInSandbox(subtestRowCountChange, variant); + } +}); + +async function subtestRowCountChange() { + let doc = content.document; + + let ROW_HEIGHT = 50; + let list = doc.getElementById("testTree"); + + async function doListActionAndWaitForRowBuffer(actionFn) { + // Filling the row buffer is fiddly timing, so provide an event to indicate + // that actions which may trigger changes in the row buffer have finished. + const eventName = "_treerowbufferfill"; + list._rowBufferReadyEvent = new content.CustomEvent(eventName); + + const promise = new Promise(resolve => + list.addEventListener(eventName, resolve, { once: true }) + ); + + await actionFn(); + await promise; + + list._rowBufferReadyEvent = null; + } + + let view = list.view; + let rows; + + // Check the initial state. + + function checkRows(first, last) { + let expectedIndices = []; + for (let i = first; i <= last; i++) { + expectedIndices.push(i); + } + rows = list.querySelectorAll(`tr[is="test-row"]`); + Assert.deepEqual( + Array.from(rows, r => r.index), + expectedIndices, + "the list has the right rows" + ); + Assert.deepEqual( + Array.from(rows, r => r.dataset.value), + view.values.slice(first, last + 1), + "the list has the right rows" + ); + } + + function checkSelected(indices, existingIndices) { + Assert.deepEqual(list.selectedIndices, indices); + let selectedRows = list.querySelectorAll(`tr[is="test-row"].selected`); + Assert.deepEqual( + Array.from(selectedRows, r => r.index), + existingIndices + ); + } + + let expectedCount = 150; + + // Select every tenth row. We'll check what is selected remains selected. + + list.selectedIndices = [4, 14, 24, 34, 44]; + + function getRowsHeight() { + return list.scrollHeight - list.table.header.clientHeight; + } + + async function addValues(index, values) { + view.values.splice(index, 0, ...values); + info(`Added ${values.join(", ")} at ${index}`); + info(view.values); + + expectedCount += values.length; + Assert.equal( + view.rowCount, + expectedCount, + "the view has the right number of rows" + ); + + await doListActionAndWaitForRowBuffer(() => { + list.rowCountChanged(index, values.length); + }); + + Assert.equal( + getRowsHeight(), + expectedCount * ROW_HEIGHT, + "space for all rows is allocated" + ); + } + + async function removeValues(index, count, expectedRemoved) { + let values = view.values.splice(index, count); + info(`Removed ${values.join(", ")} from ${index}`); + info(view.values); + + Assert.deepEqual(values, expectedRemoved); + + expectedCount -= values.length; + Assert.equal( + view.rowCount, + expectedCount, + "the view has the right number of rows" + ); + + await doListActionAndWaitForRowBuffer(() => { + list.rowCountChanged(index, -count); + }); + + Assert.equal( + getRowsHeight(), + expectedCount * ROW_HEIGHT, + "space for all rows is allocated" + ); + } + + async function scrollListToPosition(topOfScroll) { + await doListActionAndWaitForRowBuffer(() => { + list.scrollTo(0, topOfScroll); + }); + } + + Assert.equal( + view.rowCount, + expectedCount, + "the view has the right number of rows" + ); + Assert.equal(list.scrollTop, 0, "the list is scrolled to the top"); + Assert.equal( + getRowsHeight(), + expectedCount * ROW_HEIGHT, + "space for all rows is allocated" + ); + + // We should be scrolled to the top already, but this will ensure we get an + // event fire one way or another. + await scrollListToPosition(0); + + checkRows(0, 38); + checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]); + Assert.equal(getRowsHeight(), 150 * 50); + + // Add a value at the end. Only the scroll height should change. + + await addValues(150, [150]); + checkRows(0, 38); + checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]); + Assert.equal(getRowsHeight(), 151 * 50); + + // Add more values at the end. Only the scroll height should change. + + await addValues(151, [151, 152, 153]); + checkRows(0, 38); + checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]); + Assert.equal(getRowsHeight(), 154 * 50); + + // Add values between the last row and the end. + // Only the scroll height should change. + + await addValues(40, ["39a", "39b"]); + checkRows(0, 38); + checkSelected([4, 14, 24, 34, 46], [4, 14, 24, 34]); + Assert.equal(getRowsHeight(), 156 * 50); + + // Add values between the last visible row and the last row. + // The changed rows and those below them should be updated. + + await addValues(18, ["17a", "17b", "17c"]); + checkRows(0, 38); + // Hard-coded sanity checks to prove checkRows is working as intended. + Assert.equal(rows[17].dataset.value, "17"); + Assert.equal(rows[18].dataset.value, "17a"); + Assert.equal(rows[19].dataset.value, "17b"); + Assert.equal(rows[20].dataset.value, "17c"); + Assert.equal(rows[21].dataset.value, "18"); + checkSelected([4, 14, 27, 37, 49], [4, 14, 27, 37]); + Assert.equal(getRowsHeight(), 159 * 50); + + // Add values in the visible rows. + // The changed rows and those below them should be updated. + + await addValues(8, ["7a", "7b"]); + checkRows(0, 38); + Assert.equal(rows[7].dataset.value, "7"); + Assert.equal(rows[8].dataset.value, "7a"); + Assert.equal(rows[9].dataset.value, "7b"); + Assert.equal(rows[10].dataset.value, "8"); + Assert.equal(rows[22].dataset.value, "17c"); + checkSelected([4, 16, 29, 39, 51], [4, 16, 29]); + Assert.equal(getRowsHeight(), 161 * 50); + + // Add a value at the start. All rows should be updated. + + await addValues(0, [-1]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-1"); + Assert.equal(rows[1].dataset.value, "0"); + Assert.equal(rows[22].dataset.value, "17b"); + checkSelected([5, 17, 30, 40, 52], [5, 17, 30]); + Assert.equal(getRowsHeight(), 162 * 50); + + // Add more values at the start. All rows should be updated. + + await addValues(0, [-3, -2]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-3"); + Assert.equal(rows[1].dataset.value, "-2"); + Assert.equal(rows[2].dataset.value, "-1"); + Assert.equal(rows[22].dataset.value, "17"); + checkSelected([7, 19, 32, 42, 54], [7, 19, 32]); + Assert.equal(getRowsHeight(), 164 * 50); + + Assert.equal(list.scrollTop, 0, "the list is still scrolled to the top"); + + // Remove values in the order we added them. + + await removeValues(160, 1, [150]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-3"); + Assert.equal(rows[22].dataset.value, "17"); + checkSelected([7, 19, 32, 42, 54], [7, 19, 32]); + Assert.equal(getRowsHeight(), 163 * 50); + + await removeValues(160, 3, [151, 152, 153]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-3"); + Assert.equal(rows[22].dataset.value, "17"); + checkSelected([7, 19, 32, 42, 54], [7, 19, 32]); + Assert.equal(getRowsHeight(), 160 * 50); + + await removeValues(48, 2, ["39a", "39b"]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-3"); + Assert.equal(rows[22].dataset.value, "17"); + checkSelected([7, 19, 32, 42, 52], [7, 19, 32]); + Assert.equal(getRowsHeight(), 158 * 50); + + await removeValues(23, 3, ["17a", "17b", "17c"]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-3"); + Assert.equal(rows[22].dataset.value, "17"); + checkSelected([7, 19, 29, 39, 49], [7, 19, 29]); + Assert.equal(getRowsHeight(), 155 * 50); + + await removeValues(11, 2, ["7a", "7b"]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-3"); + Assert.equal(rows[10].dataset.value, "7"); + Assert.equal(rows[11].dataset.value, "8"); + Assert.equal(rows[22].dataset.value, "19"); + checkSelected([7, 17, 27, 37, 47], [7, 17, 27, 37]); + Assert.equal(getRowsHeight(), 153 * 50); + + await removeValues(2, 1, [-1]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "-3"); + Assert.equal(rows[1].dataset.value, "-2"); + Assert.equal(rows[2].dataset.value, "0"); + Assert.equal(rows[22].dataset.value, "20"); + checkSelected([6, 16, 26, 36, 46], [6, 16, 26, 36]); + Assert.equal(getRowsHeight(), 152 * 50); + + await removeValues(0, 2, [-3, -2]); + checkRows(0, 38); + Assert.equal(rows[0].dataset.value, "0"); + Assert.equal(rows[1].dataset.value, "1"); + Assert.equal(rows[22].dataset.value, "22"); + checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]); + Assert.equal(getRowsHeight(), 150 * 50); + + Assert.equal(list.scrollTop, 0, "the list is still scrolled to the top"); + + // Now scroll to the middle and repeat. + + await scrollListToPosition(1735); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "8"); + Assert.equal(rows[65].dataset.value, "73"); + checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]); + + await addValues(150, [150]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "8"); + Assert.equal(rows[65].dataset.value, "73"); + checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]); + + await addValues(38, ["37a"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "8"); + Assert.equal(rows[29].dataset.value, "37"); + Assert.equal(rows[30].dataset.value, "37a"); + Assert.equal(rows[31].dataset.value, "38"); + Assert.equal(rows[65].dataset.value, "72"); + checkSelected([4, 14, 24, 34, 45], [14, 24, 34, 45]); + + await addValues(25, ["24a"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "8"); + Assert.equal(rows[16].dataset.value, "24"); + Assert.equal(rows[17].dataset.value, "24a"); + Assert.equal(rows[18].dataset.value, "25"); + Assert.equal(rows[65].dataset.value, "71"); + checkSelected([4, 14, 24, 35, 46], [14, 24, 35, 46]); + + await addValues(11, ["10a"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "8"); + Assert.equal(rows[2].dataset.value, "10"); + Assert.equal(rows[3].dataset.value, "10a"); + Assert.equal(rows[4].dataset.value, "11"); + Assert.equal(rows[65].dataset.value, "70"); + checkSelected([4, 15, 25, 36, 47], [15, 25, 36, 47]); + + await addValues(0, ["-1"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "7"); + Assert.equal(rows[65].dataset.value, "69"); + checkSelected([5, 16, 26, 37, 48], [16, 26, 37, 48]); + + Assert.equal( + list.scrollTop, + 1735, + "the list is still scrolled to the middle" + ); + + await removeValues(154, 1, [150]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "7"); + Assert.equal(rows[65].dataset.value, "69"); + checkSelected([5, 16, 26, 37, 48], [16, 26, 37, 48]); + + await removeValues(41, 1, ["37a"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "7"); + Assert.equal(rows[65].dataset.value, "70"); + checkSelected([5, 16, 26, 37, 47], [16, 26, 37, 47]); + + await removeValues(27, 1, ["24a"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "7"); + Assert.equal(rows[65].dataset.value, "71"); + checkSelected([5, 16, 26, 36, 46], [16, 26, 36, 46]); + + await removeValues(12, 1, ["10a"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "7"); + Assert.equal(rows[65].dataset.value, "72"); + checkSelected([5, 15, 25, 35, 45], [15, 25, 35, 45]); + + await removeValues(0, 1, ["-1"]); + checkRows(8, 73); + Assert.equal(rows[0].dataset.value, "8"); + Assert.equal(rows[65].dataset.value, "73"); + checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]); + + Assert.equal( + list.scrollTop, + 1735, + "the list is still scrolled to the middle" + ); + + // Now scroll to the bottom and repeat. + + await scrollListToPosition(6870); + checkRows(111, 149); + Assert.equal(rows[0].dataset.value, "111"); + Assert.equal(rows[38].dataset.value, "149"); + checkSelected([4, 14, 24, 34, 44], []); + + await addValues(50, [50]); + checkRows(111, 150); + Assert.equal(rows[0].dataset.value, "110"); + Assert.equal(rows[39].dataset.value, "149"); + checkSelected([4, 14, 24, 34, 44], []); + + await addValues(49, ["48a"]); + checkRows(111, 151); + Assert.equal(rows[0].dataset.value, "109"); + Assert.equal(rows[40].dataset.value, "149"); + checkSelected([4, 14, 24, 34, 44], []); + + await addValues(30, ["29a"]); + checkRows(111, 152); + Assert.equal(rows[0].dataset.value, "108"); + Assert.equal(rows[41].dataset.value, "149"); + checkSelected([4, 14, 24, 35, 45], []); + + await addValues(0, ["-1"]); + checkRows(111, 153); + Assert.equal(rows[0].dataset.value, "107"); + Assert.equal(rows[42].dataset.value, "149"); + checkSelected([5, 15, 25, 36, 46], []); + + Assert.equal( + list.scrollTop, + 6870, + "the list is still scrolled to the bottom" + ); + + await removeValues(53, 1, [50]); + checkRows(111, 152); + Assert.equal(rows[0].dataset.value, "108"); + Assert.equal(rows[41].dataset.value, "149"); + checkSelected([5, 15, 25, 36, 46], []); + + await removeValues(51, 1, ["48a"]); + checkRows(111, 151); + Assert.equal(rows[0].dataset.value, "109"); + Assert.equal(rows[40].dataset.value, "149"); + checkSelected([5, 15, 25, 36, 46], []); + + await removeValues(31, 1, ["29a"]); + checkRows(111, 150); + Assert.equal(rows[0].dataset.value, "110"); + Assert.equal(rows[39].dataset.value, "149"); + checkSelected([5, 15, 25, 35, 45], []); + + await removeValues(0, 1, ["-1"]); + checkRows(111, 149); + Assert.equal(rows[0].dataset.value, "111"); + Assert.equal(rows[38].dataset.value, "149"); + checkSelected([4, 14, 24, 34, 44], []); + + Assert.equal( + list.scrollTop, + 6870, + "the list is still scrolled to the bottom" + ); + + // Remove a selected row and check the selection changes. + + await scrollListToPosition(0); + + checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]); + + await removeValues(3, 3, [3, 4, 5]); // 4 is selected. + checkSelected([11, 21, 31, 41], [11, 21, 31]); + + await addValues(3, [3, 4, 5]); + checkSelected([14, 24, 34, 44], [14, 24, 34]); + + // Remove some consecutive selected rows. + + list.selectedIndices = [6, 7, 8, 9]; + checkSelected([6, 7, 8, 9], [6, 7, 8, 9]); + + await removeValues(7, 1, [7]); + checkSelected([6, 7, 8], [6, 7, 8]); + + await removeValues(7, 1, [8]); + checkSelected([6, 7], [6, 7]); + + await removeValues(7, 1, [9]); + checkSelected([6], [6]); + + // Reset the list. + + await addValues(7, [7, 8, 9]); + list.selectedIndex = -1; +} + +/** + * 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_treeListbox.js, but + * for TreeView instead of TreeListbox. If you make changes here you + * may want to make changes there too. + */ +add_task(async function testExpandCollapse() { + await runTestInSandbox(subtestExpandCollapse, "levels"); +}); + +async function subtestExpandCollapse() { + let doc = content.document; + let list = doc.getElementById("testTree"); + let allIds = [ + "row-1", + "row-2", + "row-2-1", + "row-2-2", + "row-3", + "row-3-1", + "row-3-1-1", + "row-3-1-2", + ]; + let idsWithoutChildren = [ + "row-1", + "row-2-1", + "row-2-2", + "row-3-1-1", + "row-3-1-2", + ]; + + let listener = { + reset() { + this.collapsedIndex = null; + this.expandedIndex = null; + }, + handleEvent(event) { + if (event.type == "collapsed") { + this.collapsedIndex = event.detail; + } else if (event.type == "expanded") { + this.expandedIndex = event.detail; + } + }, + }; + list.addEventListener("collapsed", listener); + list.addEventListener("expanded", listener); + + let 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.view.rowCount, 8, "row count"); + Assert.deepEqual( + Array.from(list.table.body.children, 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 checkCurrent(expectedIndex) { + Assert.equal(list.currentIndex, expectedIndex, "currentIndex is correct"); + const current = list.querySelectorAll(".current"); + if (expectedIndex == -1) { + Assert.equal(current.length, 0, "no rows have the 'current' class"); + } else { + Assert.equal(current.length, 1, "only one row has the 'current' class"); + Assert.equal( + current[0].index, + expectedIndex, + "correct row has the 'current' class" + ); + } + } + + function checkMultiSelect(...expectedIds) { + let selected = [...list.querySelectorAll(".selected")].map(row => row.id); + Assert.deepEqual(selected, expectedIds, "selection should be correct"); + } + + function checkSelectedAndCurrent(expectedIndex, expectedId) { + Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct"); + let selected = [...list.querySelectorAll(".selected")].map(row => row.id); + Assert.deepEqual( + selected, + [expectedId], + "correct rows have the 'selected' class" + ); + checkCurrent(expectedIndex); + } + + list.selectedIndex = 0; + checkSelectedAndCurrent(0, "row-1"); + + // Click the twisties of rows without children. + + function performChange(id, expectedChange, changeCallback) { + listener.reset(); + let row = doc.getElementById(id); + let before = row.classList.contains("collapsed"); + + changeCallback(row); + + row = doc.getElementById(id); + if (expectedChange == "collapsed") { + Assert.ok(!before, `${id} was expanded`); + Assert.ok(row.classList.contains("collapsed"), `${id} collapsed`); + Assert.notEqual( + listener.collapsedIndex, + null, + `${id} fired 'collapse' event` + ); + Assert.ok(!listener.expandedIndex, `${id} did not fire 'expand' event`); + } else if (expectedChange == "expanded") { + Assert.ok(before, `${id} was collapsed`); + Assert.ok(!row.classList.contains("collapsed"), `${id} expanded`); + Assert.ok( + !listener.collapsedIndex, + `${id} did not fire 'collapse' event` + ); + Assert.notEqual( + listener.expandedIndex, + null, + `${id} fired 'expand' event` + ); + } else { + Assert.equal( + row.classList.contains("collapsed"), + before, + `${id} state did not change` + ); + } + } + + function clickTwisty(id, expectedChange) { + info(`clicking the twisty on ${id}`); + performChange(id, expectedChange, row => + EventUtils.synthesizeMouseAtCenter( + row.querySelector(".twisty"), + {}, + content + ) + ); + } + + function clickThread(id, expectedChange) { + info(`clicking the thread on ${id}`); + performChange(id, expectedChange, row => { + EventUtils.synthesizeMouseAtCenter( + row.querySelector(".tree-button-thread"), + {}, + content + ); + }); + } + + for (let id of idsWithoutChildren) { + clickTwisty(id, null); + Assert.equal(list.querySelector(".selected").id, id); + } + + checkSelectedAndCurrent(7, "row-3-1-2"); + + // Click the twisties of rows with children. + + function checkRowsAreHidden(...hiddenIds) { + let remainingIds = allIds.slice(); + + for (let id of allIds) { + if (hiddenIds.includes(id)) { + Assert.ok(!doc.getElementById(id), `${id} is hidden`); + remainingIds.splice(remainingIds.indexOf(id), 1); + } else { + Assert.greater( + doc.getElementById(id).clientHeight, + 0, + `${id} is visible` + ); + } + } + + Assert.equal(list.view.rowCount, 8 - hiddenIds.length, "row count"); + Assert.deepEqual( + Array.from(list.table.body.children, r => r.id), + remainingIds, + "rows property" + ); + } + + // Collapse row 2. + + clickTwisty("row-2", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelectedAndCurrent(5, "row-3-1-2"); + + // Collapse row 3. + + clickTwisty("row-3", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(2, "row-3"); + + // Expand row 2. + + clickTwisty("row-2", "expanded"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + // Expand row 3. + + clickTwisty("row-3", "expanded"); + checkRowsAreHidden(); + checkSelectedAndCurrent(4, "row-3"); + + // Collapse row 3-1. + + clickTwisty("row-3-1", "collapsed"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + // Collapse row 3. + + clickTwisty("row-3", "collapsed"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + // Expand row 3. + + clickTwisty("row-3", "expanded"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + // Expand row 3-1. + + clickTwisty("row-3-1", "expanded"); + checkRowsAreHidden(); + checkSelectedAndCurrent(4, "row-3"); + + // Test key presses. + + function pressKey(id, key, expectedChange) { + info(`pressing ${key}`); + performChange(id, expectedChange, row => { + EventUtils.synthesizeKey(key, {}, content); + }); + } + + // Row 0 has no children or parent, nothing should happen. + + list.selectedIndex = 0; + pressKey("row-1", "VK_LEFT"); + checkSelectedAndCurrent(0, "row-1"); + pressKey("row-1", "VK_RIGHT"); + checkSelectedAndCurrent(0, "row-1"); + + // Collapse row 2. + + list.selectedIndex = 1; + pressKey("row-2", "VK_LEFT", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelectedAndCurrent(1, "row-2"); + + pressKey("row-2", "VK_LEFT"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelectedAndCurrent(1, "row-2"); + + // Collapse row 3. + + list.selectedIndex = 2; + pressKey("row-3", "VK_LEFT", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(2, "row-3"); + + pressKey("row-3", "VK_LEFT"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(2, "row-3"); + + // Expand row 2. + + list.selectedIndex = 1; + pressKey("row-2", "VK_RIGHT", "expanded"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(1, "row-2"); + + // Expand row 3. + + list.selectedIndex = 4; + pressKey("row-3", "VK_RIGHT", "expanded"); + checkRowsAreHidden(); + checkSelectedAndCurrent(4, "row-3"); + + // Go down the tree to row 3-1-1. + + pressKey("row-3", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(5, "row-3-1"); + + pressKey("row-3", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-1"); + + pressKey("row-3-1-1", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-1"); + + // Collapse row 3-1. + + pressKey("row-3-1-1", "VK_LEFT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(5, "row-3-1"); + + pressKey("row-3-1", "VK_LEFT", "collapsed"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(5, "row-3-1"); + + // Collapse row 3. + + pressKey("row-3-1", "VK_LEFT"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + pressKey("row-3", "VK_LEFT", "collapsed"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + // Expand row 3. + + pressKey("row-3", "VK_RIGHT", "expanded"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + pressKey("row-3", "VK_RIGHT"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(5, "row-3-1"); + + // Expand row 3-1. + + pressKey("row-3-1", "VK_RIGHT", "expanded"); + checkRowsAreHidden(); + checkSelectedAndCurrent(5, "row-3-1"); + + pressKey("row-3-1", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-1"); + + pressKey("row-3-1-1", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-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; + pressKey("row-1", "VK_RIGHT"); + checkSelectedAndCurrent(0, "row-1"); + pressKey("row-1", "VK_LEFT"); + checkSelectedAndCurrent(0, "row-1"); + + // Collapse row 2. + + list.selectedIndex = 1; + pressKey("row-2", "VK_RIGHT", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelectedAndCurrent(1, "row-2"); + + pressKey("row-2", "VK_RIGHT"); + checkRowsAreHidden("row-2-1", "row-2-2"); + checkSelectedAndCurrent(1, "row-2"); + + // Collapse row 3. + + list.selectedIndex = 2; + pressKey("row-3", "VK_RIGHT", "collapsed"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(2, "row-3"); + + pressKey("row-3", "VK_RIGHT"); + checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(2, "row-3"); + + // Expand row 2. + + list.selectedIndex = 1; + pressKey("row-2", "VK_LEFT", "expanded"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(1, "row-2"); + + // Expand row 3. + + list.selectedIndex = 4; + pressKey("row-3", "VK_LEFT", "expanded"); + checkRowsAreHidden(); + checkSelectedAndCurrent(4, "row-3"); + + // Go down the tree to row 3-1-1. + + pressKey("row-3", "VK_LEFT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(5, "row-3-1"); + + pressKey("row-3", "VK_LEFT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-1"); + + pressKey("row-3-1-1", "VK_LEFT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-1"); + + // Collapse row 3-1. + + pressKey("row-3-1-1", "VK_RIGHT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(5, "row-3-1"); + + pressKey("row-3-1", "VK_RIGHT", "collapsed"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(5, "row-3-1"); + + // Collapse row 3. + + pressKey("row-3-1", "VK_RIGHT"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + pressKey("row-3", "VK_RIGHT", "collapsed"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + // Expand row 3. + + pressKey("row-3", "VK_LEFT", "expanded"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + + pressKey("row-3", "VK_LEFT"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(5, "row-3-1"); + + // Expand row 3-1. + + pressKey("row-3-1", "VK_LEFT", "expanded"); + checkRowsAreHidden(); + checkSelectedAndCurrent(5, "row-3-1"); + + pressKey("row-3-1", "VK_LEFT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(6, "row-3-1-1"); + + pressKey("row-3-1-1", "VK_LEFT"); + checkRowsAreHidden(); + checkSelectedAndCurrent(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. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.ok(!listener.collapsedIndex, "'collapsed' event did not fire"); + + list.expandRowAtIndex(6); // No children, no effect. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.ok(!listener.expandedIndex, "'expanded' event did not fire"); + + list.collapseRowAtIndex(1); // Item with children that aren't selected. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.equal(listener.collapsedIndex, 1, "row-2 fired 'collapsed' event"); + listener.reset(); + + list.expandRowAtIndex(1); // Item with children that aren't selected. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.equal(listener.expandedIndex, 1, "row-2 fired 'expanded' event"); + listener.reset(); + + list.collapseRowAtIndex(5); // Item with children that are selected. + Assert.ok(selectHandler.seenEvent, "'select' event fired"); + Assert.equal( + selectHandler.selectedAtEvent, + 5, + "selectedIndex was correct when 'select' event fired" + ); + Assert.equal(listener.collapsedIndex, 5, "row-3-1 fired 'collapsed' event"); + checkRowsAreHidden("row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(5, "row-3-1"); + selectHandler.reset(); + listener.reset(); + + list.expandRowAtIndex(5); // Selected item with children. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.equal(listener.expandedIndex, 5, "row-3-1 fired 'expanded' event"); + checkRowsAreHidden(); + checkSelectedAndCurrent(5, "row-3-1"); + listener.reset(); + + list.selectedIndex = 7; + selectHandler.reset(); + + list.collapseRowAtIndex(4); // Item with grandchildren that are selected. + Assert.ok(selectHandler.seenEvent, "'select' event fired"); + Assert.equal( + selectHandler.selectedAtEvent, + 4, + "selectedIndex was correct when 'select' event fired" + ); + Assert.equal(listener.collapsedIndex, 4, "row-3 fired 'collapsed' event"); + checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2"); + checkSelectedAndCurrent(4, "row-3"); + selectHandler.reset(); + listener.reset(); + + list.expandRowAtIndex(4); // Selected item with grandchildren. + Assert.ok(!selectHandler.seenEvent, "'select' event did not fire"); + Assert.equal(listener.expandedIndex, 4, "row-3 fired 'expanded' event"); + checkRowsAreHidden(); + checkSelectedAndCurrent(4, "row-3"); + listener.reset(); + + // Click thread for already expanded thread. Should select all in thread. + selectHandler.reset(); + clickThread("row-3"); // Item with grandchildren. + Assert.ok(selectHandler.seenEvent, "'select' event fired"); + Assert.equal( + selectHandler.selectedAtEvent, + 4, + "selectedIndex was correct when 'select' event fired" + ); + checkRowsAreHidden(); + checkMultiSelect("row-3", "row-3-1", "row-3-1-1", "row-3-1-2"); + checkCurrent(4); + + // Click thread for collapsed thread. Should expand the thread and select all + // children. + list.collapseRowAtIndex(1); // Item with children that aren't selected. + Assert.equal(listener.collapsedIndex, 1, "row-2 fired 'collapsed' event"); + checkRowsAreHidden("row-2-1", "row-2-2"); + clickThread("row-2", "expanded"); + Assert.equal(listener.expandedIndex, 1, "row-2 fired 'expanded' event"); + checkMultiSelect("row-2", "row-2-1", "row-2-2"); + checkCurrent(1); + + // Select multiple messages in an expanded thread by keyboard, ending with a + // child message, then collapse the thread. After that, currentIndex should + // be the root message. + selectHandler.reset(); + list.selectedIndex = 1; + checkSelectedAndCurrent(1, "row-2"); + info(`pressing VK_DOWN with shift key twice`); + EventUtils.synthesizeKey("VK_DOWN", { shiftKey: true }, content); + EventUtils.synthesizeKey("VK_DOWN", { shiftKey: true }, content); + checkMultiSelect("row-2", "row-2-1", "row-2-2"); + checkCurrent(3); + clickTwisty("row-2", "collapsed"); + checkSelectedAndCurrent(1, "row-2"); + + list.removeEventListener("collapsed", listener); + list.removeEventListener("expanded", listener); + list.removeEventListener("select", selectHandler); + doc.documentElement.dir = null; +} + +/** + * Checks that the row widget can be changed, redrawing the rows and + * maintaining the selection. + */ +add_task(async function testRowClassChange() { + for (const variant of TEST_VARIANTS) { + info(`Running row class change test for ${variant}`); + await runTestInSandbox(subtestRowClassChange, variant); + } +}); + +async function subtestRowClassChange() { + let doc = content.document; + let list = doc.getElementById("testTree"); + let indices = (list.selectedIndices = [1, 2, 3, 5, 8, 13, 21, 34]); + list.currentIndex = 5; + + for (let row of list.table.body.children) { + Assert.equal(row.getAttribute("is"), "test-row"); + Assert.equal(row.clientHeight, 50); + Assert.equal( + row.classList.contains("selected"), + indices.includes(row.index) + ); + Assert.equal(row.classList.contains("current"), row.index == 5); + } + + info("switching row class to AlternativeCardRow"); + list.setAttribute("rows", "alternative-row"); + Assert.deepEqual(list.selectedIndices, indices); + Assert.equal(list.currentIndex, 5); + + for (let row of list.table.body.children) { + Assert.equal(row.getAttribute("is"), "alternative-row"); + Assert.equal(row.clientHeight, 80); + Assert.equal( + row.classList.contains("selected"), + indices.includes(row.index) + ); + Assert.equal(row.classList.contains("current"), row.index == 5); + } + + list.selectedIndex = -1; + Assert.deepEqual(list.selectedIndices, []); + Assert.equal(list.currentIndex, -1); + + info("switching row class to TestCardRow"); + list.setAttribute("rows", "test-row"); + Assert.deepEqual(list.selectedIndices, []); + Assert.equal(list.currentIndex, -1); + + for (let row of list.table.body.children) { + Assert.equal(row.getAttribute("is"), "test-row"); + Assert.equal(row.clientHeight, 50); + Assert.ok(!row.classList.contains("selected")); + Assert.ok(!row.classList.contains("current")); + } +} + +/** + * Checks that resizing the widget automatically adds more rows if necessary. + */ +add_task(async function testResize() { + for (const variant of TEST_VARIANTS) { + info(`Running resize test for ${variant}`); + await runTestInSandbox(subtestResize, variant); + } +}); + +async function subtestResize() { + let doc = content.document; + + let list = doc.getElementById("testTree"); + Assert.ok(!!list, "the list exists"); + + async function doListActionAndWaitForRowBuffer(actionFn) { + // Filling the row buffer is fiddly timing, so provide an event to indicate + // that actions which may trigger changes in the row buffer have finished. + const eventName = "_treerowbufferfill"; + list._rowBufferReadyEvent = new content.CustomEvent(eventName); + + const promise = new Promise(resolve => + list.addEventListener(eventName, resolve, { once: true }) + ); + + await actionFn(); + await promise; + + list._rowBufferReadyEvent = null; + } + + async function scrollVerticallyBy(scrollDistance) { + await doListActionAndWaitForRowBuffer(() => { + list.scrollBy(0, scrollDistance); + }); + } + + async function changeHeightTo(newHeight) { + await doListActionAndWaitForRowBuffer(() => { + list.style.height = `${newHeight}px`; + }); + } + + let rowCount = function () { + return list.querySelectorAll(`tr[is="test-row"]`).length; + }; + + let originalHeight = list.clientHeight; + + // We should already be at the top, but this will force us to have finished + // loading before we trigger another scroll. Otherwise, we may get back a fill + // event for the initial fill when we expect an event in response to a scroll. + await doListActionAndWaitForRowBuffer(() => { + list.scrollTo(0, 0); + }); + + // Start by scrolling to somewhere in the middle of the list, so that we + // don't have to think about buffer rows that don't exist at the ends. + await scrollVerticallyBy(2650); + + // The list has enough space for 13 visible rows, and 26 buffer rows should + // exist above and below. + Assert.equal( + rowCount(), + 13 + 26 + 26, + "the list should contain the right number of rows" + ); + + // Make the list shorter by 5 rows. This should not affect the number of rows, + // but this is a bit flaky, so check we have at least the minimum required. + await changeHeightTo(originalHeight - 250); + Assert.equal(list._toleranceSize, 16); + Assert.greaterOrEqual( + rowCount(), + 8 + 26 + 26, + "making the list shorter should not change the number of rows" + ); + + // Scrolling the list by any amount should remove excess rows. + await scrollVerticallyBy(50); + Assert.equal( + rowCount(), + 8 + 16 + 16, + "scrolling the list after resize should remove the excess rows" + ); + + // Return to the original height. More buffer rows should be added. We have + // to wait for the ResizeObserver to be triggered. + await changeHeightTo(originalHeight); + Assert.equal(list._toleranceSize, 26); + Assert.equal( + rowCount(), + 13 + 26 + 26, + "making the list taller should change the number of rows" + ); + + // Make the list taller by 5 rows. We have to wait for the ResizeObserver + // to be triggered. + await changeHeightTo(originalHeight + 250); + Assert.equal(list._toleranceSize, 36); + Assert.equal( + rowCount(), + 18 + 36 + 36, + "making the list taller should change the number of rows" + ); + + // Scrolling the list should not affect the number of rows. + await scrollVerticallyBy(50); + Assert.equal( + rowCount(), + 18 + 36 + 36, + "scrolling the list should not change the number of rows" + ); +} -- cgit v1.2.3