// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {getTrustedHTML} from 'chrome://resources/js/static_types.js';
import {assertArrayEquals, assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chromeos/chai_assert.js';
import {MockVolumeManager} from '../../../background/js/mock_volume_manager.js';
import {FakeEntryImpl} from '../../../common/js/files_app_entry_types.js';
import {RootType} from '../../../common/js/volume_manager_types.js';
import {FileListModel} from '../file_list_model.js';
import type {MetadataModel} from '../metadata/metadata_model.js';
import {MockMetadataModel} from '../metadata/mock_metadata.js';
import type {A11yAnnounce} from './a11y_announce.js';
import {FileListSelectionModel} from './file_list_selection_model.js';
import {FileTable} from './file_table.js';
import type {FileTableList} from './file_table_list.js';
let volumeManager: MockVolumeManager;
let metadataModel: MetadataModel;
let element: HTMLElement;
let a11y: A11yAnnounce;
// Set up test components.
export function setUp() {
// Setup mock components.
volumeManager = new MockVolumeManager();
metadataModel = new MockMetadataModel({}) as unknown as MetadataModel;
const a11Messages = [];
a11y = {
speakA11yMessage: (text) => {
a11Messages.push(text);
},
};
// Create DOM element parent of the file list under test.
element = setupBody();
}
/**
* Returns the element used to parent the file list. The element is
* attached to the body, and styled for visual display.
*
*/
function setupBody(): HTMLElement {
document.body.innerHTML = getTrustedHTML`
<style>
list {
display: block;
height: 200px;
width: 800px;
}
</style>
`;
const element = document.createElement('div');
document.body.appendChild(element);
return element;
}
function key(keyName: string) {
return {
bubbles: true,
composed: true,
key: keyName,
};
}
/**
* @param code event.code value.
*/
function ctrlAndKey(keyName: string, code?: string) {
return {
ctrlKey: true,
shiftKey: false,
altKey: false,
bubbles: true,
composed: true,
key: keyName,
code: code,
// Get keyCode for key like A, B but not for Escape, Arrow, etc.
// A==65, B==66, etc.
keyCode: (keyName && keyName.length === 1) ? keyName.charCodeAt(0) :
undefined,
};
}
/**
* Tests that the keyboard can be used to navigate the FileTableList.
*/
export function testMultipleSelectionWithKeyboard() {
// Render the FileTable on |element|.
const fullPage = true;
FileTable.decorate(element, metadataModel, volumeManager, a11y, fullPage);
// Overwrite the selectionModel of the FileTable class (since events
// would be handled by cr.ui.ListSelectionModel otherwise).
const sm = new FileListSelectionModel();
const table = element as unknown as FileTable;
table.selectionModel = sm;
// Add FileTableList file entries, then draw and focus the table list.
const entries = [
new FakeEntryImpl('entry1-label', RootType.CROSTINI),
new FakeEntryImpl('entry2-label', RootType.CROSTINI),
new FakeEntryImpl('entry3-label', RootType.CROSTINI),
];
const dataModel = new FileListModel(metadataModel);
dataModel.splice(0, 0, ...entries);
const tableList = table.list;
tableList.dataModel = dataModel;
tableList.redraw();
tableList.focus();
// Grab all the elements in the file list.
const listItem0 = tableList.items[0]!;
const listItem1 = tableList.items[1]!;
const listItem2 = tableList.items[2]!;
// Assert file table list |item| selection state.
function assertItemIsSelected(item: HTMLElement, selected = true) {
if (selected) {
assertTrue(item.hasAttribute('selected'));
assertEquals('true', item.getAttribute('aria-selected'));
} else {
assertFalse(item.hasAttribute('selected'));
assertEquals(null, item.getAttribute('aria-selected'));
}
}
// Assert file table list |item| focus/lead state.
function assertItemIsTheLead(item: HTMLElement, lead = true) {
if (lead) {
assertEquals('lead', item.getAttribute('lead'));
assertEquals(item.id, tableList.getAttribute('aria-activedescendant'));
} else {
assertFalse(item.hasAttribute('lead'));
assertFalse(item.id === tableList.getAttribute('aria-activedescendant'));
}
}
// FileTableList always allows multiple selection.
assertEquals('true', tableList.getAttribute('aria-multiselectable'));
// Home key selects the first item (listItem0).
tableList.dispatchEvent(new KeyboardEvent('keydown', key('Home')));
// Only 1 item selected.
assertEquals(1, sm.selectedIndexes.length);
// listItem0 should be selected and focused.
assertEquals(0, sm.selectedIndexes[0]);
assertItemIsTheLead(listItem0);
assertItemIsSelected(listItem0);
// Only one item is selected: multiple selection should be inactive.
assertFalse(sm.getCheckSelectMode());
// ArrowDown moves and selects next item.
tableList.dispatchEvent(new KeyboardEvent('keydown', key('ArrowDown')));
// Only listItem1 should be selected.
assertEquals(1, sm.selectedIndexes.length);
assertEquals(1, sm.selectedIndexes[0]);
// listItem1 should be focused.
assertItemIsTheLead(listItem1);
assertItemIsSelected(listItem1);
// Only one item is selected: multiple selection should be inactive.
assertFalse(sm.getCheckSelectMode());
// Ctrl+ArrowDown only moves the focus.
tableList.dispatchEvent(
new KeyboardEvent('keydown', ctrlAndKey('ArrowDown')));
// listItem1 should be not focused but still selected.
assertEquals(1, sm.selectedIndexes.length);
assertEquals(1, sm.selectedIndexes[0]);
assertItemIsTheLead(listItem1, false);
assertItemIsSelected(listItem1);
// listItem2 should be focused but not selected.
assertItemIsTheLead(listItem2);
assertItemIsSelected(listItem2, false);
// Only one item is selected: multiple selection should be inactive.
assertFalse(sm.getCheckSelectMode());
// Ctrl+Space selects the focused item.
tableList.dispatchEvent(
new KeyboardEvent('keydown', ctrlAndKey(' ', 'Space')));
// Multiple selection mode should now be activated.
assertTrue(sm.getCheckSelectMode());
// Both listItem1 and listItem2 should be selected.
assertEquals(2, sm.selectedIndexes.length);
assertEquals(1, sm.selectedIndexes[0]);
assertEquals(2, sm.selectedIndexes[1]);
// listItem1 should not be focused.
assertItemIsTheLead(listItem1, false);
assertItemIsSelected(listItem1);
// listItem1 should be focused and selected.
assertItemIsTheLead(listItem2);
assertItemIsSelected(listItem2);
// Hit Esc to cancel the whole selection.
tableList.dispatchEvent(new KeyboardEvent('keydown', key('Escape')));
// The item with the focus should not change.
assertItemIsTheLead(listItem2);
// But there should be no selected items anymore.
assertFalse(sm.getCheckSelectMode());
assertEquals(0, sm.selectedIndexes.length);
for (let i = 0; i < tableList.items.length; i++) {
if (i !== 2) {
// Item 2 should have focus.
assertFalse(
tableList.items[i]!.hasAttribute('lead'),
'item ' + i + ' should not have focus');
}
assertEquals(
'false', tableList.items[i]!.getAttribute('aria-selected'),
'item ' + i + ' should have aria-selected=false');
assertFalse(
tableList.items[i]!.hasAttribute('selected'),
'item ' + i + ' should not have selected attr');
}
}
export function testKeyboardOperations() {
// Render the FileTable on |element|.
const fullPage = true;
FileTable.decorate(element, metadataModel, volumeManager, a11y, fullPage);
// Overwrite the selectionModel of the FileTable class (since events
// would be handled by cr.ui.ListSelectionModel otherwise).
const sm = new FileListSelectionModel();
const table = element as unknown as FileTable;
table.selectionModel = sm;
// Add FileTableList file entries, then draw and focus the table list.
const entries = [
new FakeEntryImpl('entry1-label', RootType.CROSTINI),
new FakeEntryImpl('entry2-label', RootType.CROSTINI),
new FakeEntryImpl('entry3-label', RootType.CROSTINI),
];
const dataModel = new FileListModel(metadataModel);
dataModel.splice(0, 0, ...entries);
const tableList = table.list;
tableList.dataModel = dataModel;
tableList.redraw();
tableList.focus();
// Home key selects the first item (index 0).
tableList.dispatchEvent(new KeyboardEvent('keydown', key('Home')));
// Only 1 item selected.
assertEquals(1, sm.selectedIndexes.length);
// Index 0 should be selected and focused.
assertEquals(0, sm.selectedIndexes[0]);
// End key selects the last item (index 2).
tableList.dispatchEvent(new KeyboardEvent('keydown', key('End')));
// Only 1 item selected.
assertEquals(1, sm.selectedIndexes.length);
// Index 2 should be selected and focused.
assertEquals(2, sm.selectedIndexes[0]);
// Ctrl+A key selects all items.
tableList.dispatchEvent(new KeyboardEvent('keydown', ctrlAndKey('A')));
// All 3 items are selected.
assertEquals(3, sm.selectedIndexes.length);
assertEquals(0, sm.selectedIndexes[0]);
assertEquals(1, sm.selectedIndexes[1]);
assertEquals(2, sm.selectedIndexes[2]);
// Escape key selects all items.
tableList.dispatchEvent(new KeyboardEvent('keydown', key('Escape')));
// All 3 items are selected.
assertEquals(0, sm.selectedIndexes.length);
// Home key selects the first item (index 0).
tableList.dispatchEvent(new KeyboardEvent('keydown', key('Home')));
assertEquals(1, sm.selectedIndexes.length);
assertEquals(0, sm.selectedIndexes[0]);
// ArrowDown moves and selects next item.
tableList.dispatchEvent(new KeyboardEvent('keydown', key('ArrowDown')));
// Only index 1 should be selected.
assertEquals(1, sm.selectedIndexes.length);
assertEquals(1, sm.selectedIndexes[0]);
// ArrowUp moves and selects previous item.
tableList.dispatchEvent(new KeyboardEvent('keydown', key('ArrowUp')));
// Only index 0 should be selected.
assertEquals(1, sm.selectedIndexes.length);
assertEquals(0, sm.selectedIndexes[0]);
// ArrowLeft and ArrowRight aren't really implemented.
tableList.dispatchEvent(new KeyboardEvent('keydown', key('ArrowLeft')));
// Selected item remains the same.
assertEquals(1, sm.selectedIndexes.length);
assertEquals(0, sm.selectedIndexes[0]);
tableList.dispatchEvent(new KeyboardEvent('keydown', key('ArrowRight')));
assertEquals(1, sm.selectedIndexes.length);
assertEquals(0, sm.selectedIndexes[0]);
}
// Force round number heights to simplify the math in the test.
const ITEM_HEIGHT = 40;
const GROUP_HEADING_HEIGHT = 20;
function setupFileTableList(): FileTableList {
FileTable.decorate(element, metadataModel, volumeManager, a11y, true);
const table = element as unknown as FileTable;
// Add 10 fake files.
const entries = [];
for (let i = 1; i <= 10; i++) {
entries.push(new FakeEntryImpl(`${i}.txt`, RootType.RECENT));
}
const dataModel = new FileListModel(metadataModel);
// Disable group by.
dataModel.shouldShowGroupHeading = () => false;
dataModel.splice(0, 0, ...entries);
const tableList = table.list as FileTableList;
tableList.dataModel = dataModel;
// Mock item size.
tableList['getDefaultItemHeight_'] = () => ITEM_HEIGHT;
tableList['getGroupHeadingHeight_'] = () => GROUP_HEADING_HEIGHT;
return tableList;
}
/**
*/
function enableGroupByForDataModel(fileListModel: FileListModel) {
const RecentDateBucket = chrome.fileManagerPrivate.RecentDateBucket;
// Mock group by information.
fileListModel.shouldShowGroupHeading = () => true;
fileListModel.getGroupBySnapshot = () => {
return [
{
startIndex: 0,
endIndex: 1,
label: 'today',
group: RecentDateBucket.TODAY,
},
{
startIndex: 2,
endIndex: 2,
label: 'yesterday',
group: RecentDateBucket.YESTERDAY,
},
{
startIndex: 3,
endIndex: 4,
label: 'earlier_this_week',
group: RecentDateBucket.EARLIER_THIS_WEEK,
},
{
startIndex: 5,
endIndex: 6,
label: 'earlier_this_month',
group: RecentDateBucket.EARLIER_THIS_MONTH,
},
{
startIndex: 7,
endIndex: 8,
label: 'earlier_this_year',
group: RecentDateBucket.EARLIER_THIS_YEAR,
},
{
startIndex: 9,
endIndex: 9,
label: 'older',
group: RecentDateBucket.OLDER,
},
];
};
}
export function testGetItemTop() {
const tableList = setupFileTableList();
// No group heading, so only the item height is used.
const len = tableList.dataModel?.length ?? 0;
for (let i = 0; i < len; i++) {
assertEquals(tableList.getItemTop(i), i * ITEM_HEIGHT);
}
// Enable group by.
enableGroupByForDataModel(tableList.dataModel);
// Item 0 is in group #1/today, nothing is above it.
assertEquals(tableList.getItemTop(0), 0);
// Item 1 is in group #1/today, 1 item above + 1 header.
assertEquals(tableList.getItemTop(1), 1 * ITEM_HEIGHT + GROUP_HEADING_HEIGHT);
// Item 2 is in group #2/yesterday, 2 items above + 1 header.
assertEquals(tableList.getItemTop(2), 2 * ITEM_HEIGHT + GROUP_HEADING_HEIGHT);
// Item 3 is in group #3/earlier_this_week, 3 items above + 2 headers.
assertEquals(
tableList.getItemTop(3), 3 * ITEM_HEIGHT + 2 * GROUP_HEADING_HEIGHT);
// Item 4 is in group #3/earlier_this_week, 4 items above + 3 headers.
assertEquals(
tableList.getItemTop(4), 4 * ITEM_HEIGHT + 3 * GROUP_HEADING_HEIGHT);
// Item 5 is in group #4/earlier_this_month, 5 items above + 3 headers.
assertEquals(
tableList.getItemTop(5), 5 * ITEM_HEIGHT + 3 * GROUP_HEADING_HEIGHT);
// Item 6 is in group #4/earlier_this_month, 6 items above + 4 headers.
assertEquals(
tableList.getItemTop(6), 6 * ITEM_HEIGHT + 4 * GROUP_HEADING_HEIGHT);
// Item 7 is in group #5/earlier_this_year, 7 items above + 4 headers.
assertEquals(
tableList.getItemTop(7), 7 * ITEM_HEIGHT + 4 * GROUP_HEADING_HEIGHT);
// Item 8 is in group #5/earlier_this_year, 8 items above + 5 headers.
assertEquals(
tableList.getItemTop(8), 8 * ITEM_HEIGHT + 5 * GROUP_HEADING_HEIGHT);
// Item 9 is in group #6/older, 9 items above + 5 headers.
assertEquals(
tableList.getItemTop(9), 9 * ITEM_HEIGHT + 5 * GROUP_HEADING_HEIGHT);
}
export function testGetAfterFillerHeight() {
const tableList = setupFileTableList();
// No group heading, so only the item height is used.
const totalLength = tableList.dataModel?.length ?? 0;
for (let i = 0; i < totalLength; i++) {
assertEquals(
tableList.getAfterFillerHeight(i),
i === 0 ? 1 : (totalLength - i) * ITEM_HEIGHT);
}
// Enable group by.
enableGroupByForDataModel(tableList.dataModel);
// A special case handled in file_table.js.
assertEquals(tableList.getAfterFillerHeight(0), 1);
// Item 1 is in group #1/today, 9 items below + 5 headers.
assertEquals(
tableList.getAfterFillerHeight(1),
9 * ITEM_HEIGHT + 5 * GROUP_HEADING_HEIGHT);
// Item 1 is in group #2/yesterday, 8 items below + 4 headers.
assertEquals(
tableList.getAfterFillerHeight(2),
8 * ITEM_HEIGHT + 4 * GROUP_HEADING_HEIGHT);
// Item 1 is in group #3/earlier_this_week, 7 items below + 3 headers.
assertEquals(
tableList.getAfterFillerHeight(3),
7 * ITEM_HEIGHT + 3 * GROUP_HEADING_HEIGHT);
// Item 1 is in group #3/earlier_this_week, 6 items below + 3 headers.
assertEquals(
tableList.getAfterFillerHeight(4),
6 * ITEM_HEIGHT + 3 * GROUP_HEADING_HEIGHT);
// Item 1 is in group #4/earlier_this_month, 5 items below + 2 headers.
assertEquals(
tableList.getAfterFillerHeight(5),
5 * ITEM_HEIGHT + 2 * GROUP_HEADING_HEIGHT);
// Item 1 is in group #4/earlier_this_month, 4 items below + 2 headers.
assertEquals(
tableList.getAfterFillerHeight(6),
4 * ITEM_HEIGHT + 2 * GROUP_HEADING_HEIGHT);
// Item 7 is in group #5/earlier_this_year, 3 items below + 1 header.
assertEquals(
tableList.getAfterFillerHeight(7),
3 * ITEM_HEIGHT + 1 * GROUP_HEADING_HEIGHT);
// Item 8 is in group #5/earlier_this_year, 2 items below + 1 header.
assertEquals(
tableList.getAfterFillerHeight(8),
2 * ITEM_HEIGHT + 1 * GROUP_HEADING_HEIGHT);
// Item 9 is in group #6/older, 1 item below.
assertEquals(tableList.getAfterFillerHeight(9), 1 * ITEM_HEIGHT);
}
export function testGetIndexForListOffset() {
const tableList = setupFileTableList();
// No group heading.
// index height total height
// -----------------------------------
// Item 0 40 40
// Item 1 40 80
// Item 2 40 120
// Item 3 40 160
// Item 4 40 200
// Item 5 40 240
// Item 6 40 280
// Item 7 40 320
// Item 8 40 360
// Item 9 40 400
assertEquals(tableList['getIndexForListOffset_'](0), 0);
assertEquals(tableList['getIndexForListOffset_'](40), 1);
assertEquals(tableList['getIndexForListOffset_'](100), 2);
assertEquals(tableList['getIndexForListOffset_'](200), 5);
assertEquals(tableList['getIndexForListOffset_'](240), 6);
assertEquals(tableList['getIndexForListOffset_'](300), 7);
// Note: The returned index could be an invalid array index, which is
// expected e.g. 10 here is larger than the largest index 9 in the array.
assertEquals(tableList['getIndexForListOffset_'](400), 10);
// Enable group by.
enableGroupByForDataModel(tableList.dataModel);
// index height total height
// -----------------------------------
// Heading 1 20 20
// Item 0 40 60
// Item 1 40 100
// Heading 2 20 120
// Item 2 40 160
// Heading 3 20 180
// Item 3 40 220
// Item 4 40 260
// Heading 4 20 280
// Item 5 40 320
// Item 6 40 360
// Heading 5 20 380
// Item 7 40 420
// Item 8 40 460
// Heading 6 20 480
// Item 9 40 520
assertEquals(tableList['getIndexForListOffset_'](0), 0);
assertEquals(tableList['getIndexForListOffset_'](40), 0);
assertEquals(tableList['getIndexForListOffset_'](100), 2);
assertEquals(tableList['getIndexForListOffset_'](200), 3);
assertEquals(tableList['getIndexForListOffset_'](240), 4);
assertEquals(tableList['getIndexForListOffset_'](300), 5);
assertEquals(tableList['getIndexForListOffset_'](400), 7);
assertEquals(tableList['getIndexForListOffset_'](500), 9);
}
export function testGetHitElements() {
const tableList = setupFileTableList();
// No group heading.
// index height total height
// -----------------------------------
// Item 0 40 40
// Item 1 40 80
// Item 2 40 120
// Item 3 40 160
// Item 4 40 200
// Item 5 40 240
// Item 6 40 280
// Item 7 40 320
// Item 8 40 360
// Item 9 40 400
// Passing -1 for 1st/3rd parameter because we don't care the x coordinates
// and the width of the drag selection.
assertArrayEquals(tableList.getHitElements(-1, 10), [0]);
assertArrayEquals(
tableList.getHitElements(-1, 50, -1, 200), [1, 2, 3, 4, 5, 6]);
assertArrayEquals(tableList.getHitElements(-1, 240, -1, 100), [5, 6, 7, 8]);
// Enable group by.
enableGroupByForDataModel(tableList.dataModel);
// index height total height
// -----------------------------------
// Heading 1 20 20
// Item 0 40 60
// Item 1 40 100
// Heading 2 20 120
// Item 2 40 160
// Heading 3 20 180
// Item 3 40 220
// Item 4 40 260
// Heading 4 20 280
// Item 5 40 320
// Item 6 40 360
// Heading 5 20 380
// Item 7 40 420
// Item 8 40 460
// Heading 6 20 480
// Item 9 40 520
// Passing -1 for 1st/3rd parameter because we don't care the x coordinates
// and the width of the drag selection.
assertArrayEquals(tableList.getHitElements(-1, 10), []);
assertArrayEquals(tableList.getHitElements(-1, 40), [0]);
assertArrayEquals(tableList.getHitElements(-1, 50, -1, 200), [0, 1, 2, 3, 4]);
assertArrayEquals(tableList.getHitElements(-1, 220, -1, 100), [3, 4, 5]);
}