chromium/ui/file_manager/file_manager/foreground/js/file_manager_commands_unittest.ts

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

import {assertArrayEquals, assertEquals, assertFalse, assertNotEquals, assertTrue} from 'chrome://webui-test/chromeos/chai_assert.js';

import {MockVolumeManager} from '../../background/js/mock_volume_manager.js';
import type {VolumeInfo} from '../../background/js/volume_info.js';
import {entriesToURLs} from '../../common/js/entry_utils.js';
import {FakeEntryImpl, type FilesAppEntry} from '../../common/js/files_app_entry_types.js';
import {installMockChrome, MockMetrics} from '../../common/js/mock_chrome.js';
import type {MockFileSystem} from '../../common/js/mock_entry.js';
import {MockDirectoryEntry, MockEntry} from '../../common/js/mock_entry.js';
import {waitUntil} from '../../common/js/test_error_reporting.js';
import {RootType, VolumeType} from '../../common/js/volume_manager_types.js';
import {addVolume, convertVolumeInfoAndMetadataToVolume, updateIsInteractiveVolume} from '../../state/ducks/volumes.js';
import {createMyFilesDataWithVolumeEntry} from '../../state/ducks/volumes_unittest.js';
import {createFakeVolumeMetadata, setUpFileManagerOnWindow, setupStore, waitDeepEquals} from '../../state/for_tests.js';

import type {CommandHandlerDeps, FilesCommandId} from './command_handler.js';
import {CommandHandler, ValidMenuCommandsForUma} from './command_handler.js';
import type {Command} from './ui/command.js';
import {CanExecuteEvent} from './ui/command.js';

let mockMetrics: MockMetrics;

function getMetricName(metricIndex: number): string|undefined {
  return ValidMenuCommandsForUma[metricIndex];
}

interface ExtraCanExecuteCommandProperties {
  target: {
    selectedItems?: Array<Entry|FilesAppEntry>,
                 classList: {contains: () => boolean},
    parentElement?: {contextElement: null},
    dataset?: Record<string, any>,
  };
}

interface CurrentSelection {
  entries: MockEntry[];
  iconType: string;
  totalCount: number;
}

function createMockEvent(
    commandId?: string,
    properties?: ExtraCanExecuteCommandProperties): CanExecuteEvent {
  const command = {
    hidden: false,
    setHidden: (hidden: boolean) => {
      event.command.hidden = hidden;
    },
  } as unknown as Command;
  if (commandId) {
    command.id = commandId;
  }
  let event = new CanExecuteEvent(command);
  event.canExecute = true;
  if (properties) {
    event = Object.assign(properties, event);
  }
  return event;
}

/**
 * Checks that the `toggle-holding-space` command is appropriately enabled/
 * disabled given the current selection state and executes as expected.
 */
export async function testToggleHoldingSpaceCommand() {
  // Verify `toggle-holding-space` command exists.
  const command = CommandHandler.getCommand('toggle-holding-space');
  assertNotEquals(command, undefined);

  let getHoldingSpaceStateCalled = false;

  /**
   * Mock chrome APIs.
   */
  let itemUrls: string[] = [];
  mockMetrics = new MockMetrics();
  const mockChrome = {
    metricsPrivate: mockMetrics,
    fileManagerPrivate: {
      getHoldingSpaceState:
          (callback: (state: chrome.fileManagerPrivate.HoldingSpaceState) =>
               void) => {
            callback({itemUrls});
            getHoldingSpaceStateCalled = true;
          },
    },

    runtime: {},
  };
  installMockChrome(mockChrome);

  // Mock volume manager.
  const volumeManager = new MockVolumeManager();

  // Create `DOWNLOADS` volume.
  const downloadsVolumeInfo = volumeManager.createVolumeInfo(
      VolumeType.DOWNLOADS, 'downloadsVolumeId', 'Downloads volume');
  const downloadsFileSystem = downloadsVolumeInfo.fileSystem as MockFileSystem;

  // Create `REMOVABLE` volume.
  const removableVolumeInfo = volumeManager.createVolumeInfo(
      VolumeType.REMOVABLE, 'removableVolumeId', 'Removable volume');
  const removableFileSystem = removableVolumeInfo.fileSystem as MockFileSystem;

  // Mock file/folder entries.
  const audioFileEntry = new MockEntry(downloadsFileSystem, '/audio.mp3');
  const downloadFileEntry = new MockEntry(downloadsFileSystem, '/download.txt');
  const folderEntry = MockDirectoryEntry.create(downloadsFileSystem, '/folder');
  new MockEntry(downloadsFileSystem, '/image.png');
  const removableFileEntry =
      new MockEntry(removableFileSystem, '/removable.txt');
  new MockEntry(downloadsFileSystem, 'video.mp4');

  // Define test cases.
  const testCases = [
    {
      description: 'Tests empty selection in `Downloads`',
      currentRootType: RootType.DOWNLOADS,
      currentVolumeInfo: {
        volumeType: VolumeType.DOWNLOADS,
      },
      itemUrls: [],
      selection: [],
      expect: {
        canExecute: false,
        hidden: true,
        isAdd: false,
      },
    },
    {
      description: 'Tests selection from supported volume in `Downloads`',
      currentRootType: RootType.DOWNLOADS,
      currentVolumeInfo: {
        volumeType: VolumeType.DOWNLOADS,
      },
      itemUrls: [],
      selection: [downloadFileEntry],
      expect: {
        canExecute: true,
        hidden: false,
        entries: [downloadFileEntry],
        isAdd: true,
      },
    },
    {
      description:
          'Tests folder selection from supported volume in `Downloads`',
      currentRootType: RootType.DOWNLOADS,
      currentVolumeInfo: {
        volumeType: VolumeType.DOWNLOADS,
      },
      itemUrls: [],
      selection: [folderEntry],
      expect: {
        canExecute: true,
        hidden: false,
        entries: [folderEntry],
        isAdd: true,
      },
    },
    {
      description:
          'Tests pinned selection from supported volume in `Downloads`',
      currentRootType: RootType.DOWNLOADS,
      currentVolumeInfo: {
        volumeType: VolumeType.DOWNLOADS,
      },
      itemUrls: entriesToURLs([downloadFileEntry]),
      selection: [downloadFileEntry],
      expect: {
        canExecute: true,
        hidden: false,
        entries: [downloadFileEntry],
        isAdd: false,
      },
    },
    {
      description: 'Tests selection from supported volume in `Recent`',
      currentRootType: RootType.RECENT,
      currentVolumeInfo: null,
      selection: [downloadFileEntry],
      itemUrls: [],
      expect: {
        canExecute: true,
        hidden: false,
        entries: [downloadFileEntry],
        isAdd: true,
      },
    },
    {
      description: 'Test selection from unsupported volume in `Recent`',
      currentRootType: RootType.RECENT,
      currentVolumeInfo: null,
      itemUrls: [],
      selection: [removableFileEntry],
      expect: {
        canExecute: false,
        hidden: true,
        isAdd: false,
      },
    },
    {
      description: 'Test selection from mix of volumes in `Recent`',
      currentRootType: RootType.RECENT,
      currentVolumeInfo: null,
      itemUrls: [],
      selection: [audioFileEntry, removableFileEntry, downloadFileEntry],
      expect: {
        canExecute: true,
        hidden: false,
        entries: [audioFileEntry, downloadFileEntry],
        isAdd: true,
      },
    },
  ];

  // Run test cases.
  for (const testCase of testCases) {
    console.log('Starting test case... ' + testCase.description);

    // Mock `Event`.
    const event = createMockEvent();

    // Mock `FileManager`.
    const fileManager = {
      directoryModel: {
        getCurrentRootType: () => testCase.currentRootType,
        getCurrentVolumeInfo: () => testCase.currentVolumeInfo,
      },
      selectionHandler: {
        selection: {entries: testCase.selection},
      },
      volumeManager: volumeManager,
    } as unknown as CommandHandlerDeps;

    // Mock `chrome.fileManagerPrivate.getHoldingSpaceState()` response.
    itemUrls = testCase.itemUrls;

    // Verify `command.canExecute()` results in expected `event` state.
    getHoldingSpaceStateCalled = false;
    command.canExecute(event, fileManager);
    if (testCase.expect.canExecute) {
      await waitUntil(() => getHoldingSpaceStateCalled);
      // Wait for the command.checkHoldingSpaceState() promise to finish.
      await new Promise(resolve => setTimeout(resolve));
    }

    assertEquals(event.canExecute, testCase.expect.canExecute);
    assertEquals(event.command.hidden, testCase.expect.hidden);

    if (!event.canExecute || event.command.hidden) {
      continue;
    }

    // Mock private API.
    let didInteractWithMockPrivateApi = false;
    chrome.fileManagerPrivate.toggleAddedToHoldingSpace =
        (entries: Entry[], isAdd: boolean) => {
          didInteractWithMockPrivateApi = true;
          assertArrayEquals(entries, testCase.expect.entries as Entry[]);
          assertEquals(isAdd, testCase.expect.isAdd);
        };

    // Reset cache of metrics recorded.
    mockMetrics.metricCalls['FileBrowser.MenuItemSelected'] = [];

    const commandEvent = new CustomEvent('command', {
      detail: {
        command: {
          hidden: false,
          setHidden: (hidden: boolean) => {
            event.command.hidden = hidden;
          },
        } as unknown as Command,
      },
    });

    // Verify `command.execute()` results in expected mock API interactions.
    command.execute(commandEvent, fileManager);
    assertTrue(didInteractWithMockPrivateApi);

    // Verify metrics recorded.
    const calls = mockMetrics.metricCalls['FileBrowser.MenuItemSelected'] || [];
    assertTrue(calls.length > 0);
    // The index is 2nd position argument, we're only checking the first call.
    const metricIndex = calls[0][1];
    assertEquals(
        getMetricName(metricIndex),
        testCase.expect.isAdd ? 'pin-to-holding-space' :
                                'unpin-from-holding-space');
  }
}

/**
 * Checks that the 'extract-all' command is enabled or disabled
 * dependent on the current selection.
 */
export async function testExtractAllCommand() {
  // Check: `extract-all` command exists.
  const command = CommandHandler.getCommand('extract-all');
  assertNotEquals(command, undefined);

  /**
   * Mock chrome startIOTask API.
   */
  const mockChrome = {
    fileManagerPrivate: {
      startIOTask: () => {},
    },
    runtime: {},
  };
  installMockChrome(mockChrome);

  // Mock volume manager.
  const volumeManager = new MockVolumeManager();

  // Create `DOWNLOADS` volume.
  const downloadsVolumeInfo = volumeManager.createVolumeInfo(
      VolumeType.DOWNLOADS, 'downloadsVolumeId', 'Downloads volume');
  const downloadsFileSystem = downloadsVolumeInfo.fileSystem as MockFileSystem;

  // Mock file entries.
  const folderEntry = MockDirectoryEntry.create(downloadsFileSystem, '/folder');
  const textFileEntry = new MockEntry(downloadsFileSystem, '/file.txt');
  const zipFileEntry = new MockEntry(downloadsFileSystem, '/archive.zip');
  const imageFileEntry = new MockEntry(downloadsFileSystem, '/image.jpg');

  // Mock `Event`.
  const event = createMockEvent();

  // The current selection for testing.
  const currentSelection: CurrentSelection = {
    entries: [],
    iconType: 'none',
    totalCount: 0,
  };

  // Mock `FileManager`.
  const fileManager = {
    directoryModel: {
      isOnNative: () => true,
      isReadOnly: () => false,
      getCurrentRootType: () => RootType.DOWNLOADS,
    },
    metadataModel: {
      getCache: () => [],
    },
    getCurrentDirectoryEntry: () => folderEntry,
    getSelection: () => currentSelection,
    volumeManager: volumeManager,
  } as unknown as CommandHandlerDeps;

  // Check: canExecute is false and command is hidden with no selection.
  command.canExecute(event, fileManager);
  assertFalse(event.canExecute);
  assertTrue(event.command.hidden);

  // Check: canExecute is true and command is visible with a single ZIP file.
  currentSelection.entries = [zipFileEntry];
  currentSelection.iconType = 'archive';
  currentSelection.totalCount = 1;
  command.canExecute(event, fileManager);
  assertTrue(event.canExecute);
  assertFalse(event.command.hidden);

  // Check: `zip-selection` command exists.
  const zipCommand = CommandHandler.getCommand('zip-selection');
  assertNotEquals(command, undefined);

  // Check: ZIP canExecute is false and command hidden with a single ZIP file.
  zipCommand.canExecute(event, fileManager);
  assertFalse(event.canExecute);
  assertTrue(event.command.hidden);

  // Check: canExecute is false and command hidden for no ZIP multi-selection.
  currentSelection.entries = [imageFileEntry, textFileEntry];
  currentSelection.totalCount = 2;
  command.canExecute(event, fileManager);
  assertFalse(event.canExecute);
  assertTrue(event.command.hidden);

  // Check: canExecute is true and command visible for ZIP multiple selection.
  currentSelection.entries = [zipFileEntry, textFileEntry];
  currentSelection.totalCount = 2;
  command.canExecute(event, fileManager);
  assertTrue(event.canExecute);
  assertFalse(event.command.hidden);

  // Check: ZIP canExecute is true and command visible for multiple selection.
  zipCommand.canExecute(event, fileManager);
  assertTrue(event.canExecute);
  assertFalse(event.command.hidden);
}

/**
 * Tests that rename command should be disabled for Recent entry.
 */
export async function testRenameCommand() {
  // Check: `rename` command exists.
  const command = CommandHandler.getCommand('rename');
  assertNotEquals(command, undefined);

  // Mock volume manager.
  const volumeManager = new MockVolumeManager();

  // Create `documents_root` volume.
  const documentsRootVolumeInfo = volumeManager.createVolumeInfo(
      VolumeType.MEDIA_VIEW,
      'com.android.providers.media.documents:documents_root', 'Documents');

  // Mock file entries.
  const recentEntry = new FakeEntryImpl('Recent', RootType.RECENT);
  const pdfEntry = MockDirectoryEntry.create(
      documentsRootVolumeInfo.fileSystem as MockFileSystem,
      'Documents/abc.pdf');


  // Mock `Event`.
  const event = createMockEvent(undefined, {
    target: {
      classList: {contains: () => false},
      selectedItems: [pdfEntry],
    },
  });

  // The current selection for testing.
  const currentSelection = {
    entries: [pdfEntry],
    iconType: 'none',
    totalCount: 1,
  };

  // Mock `FileManager`.
  const fileManager = {
    directoryModel: {
      isOnNative: () => true,
      isReadOnly: () => false,
      getCurrentRootType: () => null,
    },
    getCurrentDirectoryEntry: () => recentEntry,
    getSelection: () => currentSelection,
    volumeManager: volumeManager,
    ui: {
      actionbar: {
        contains: () => false,
      },
    },
  } as unknown as CommandHandlerDeps;

  // Check: canExecute is false and command is disabled.
  command.canExecute(event, fileManager);
  assertFalse(event.canExecute);
  assertFalse(event.command.hidden);
}

/**
 * Create and add a Downloads volume to the store. Update the volume as
 * non-interactive.
 */
async function createAndAddNonInteractiveDownloadsVolume():
    Promise<VolumeInfo> {
  setUpFileManagerOnWindow();
  // Dispatch an action to add MyFiles volume.
  const store = setupStore();
  const {fileData, volumeInfo} = createMyFilesDataWithVolumeEntry();
  const volumeMetadata = createFakeVolumeMetadata(volumeInfo);
  const volume =
      convertVolumeInfoAndMetadataToVolume(volumeInfo, volumeMetadata);
  store.dispatch(addVolume(volumeInfo, volumeMetadata));

  // Expect the newly added volume is in the store.
  const wantNewVol = {
    allEntries: {
      [fileData.key]: fileData,
    },
    volumes: {
      [volume.volumeId]: volume,
    },
  };
  await waitDeepEquals(store, wantNewVol, (state) => ({
                                            allEntries: state.allEntries,
                                            volumes: state.volumes,
                                          }));

  // Dispatch an action to set |isInteractive| for the volume to false.
  store.dispatch(updateIsInteractiveVolume({
    volumeId: volumeInfo.volumeId,
    isInteractive: false,
  }));
  // Expect the volume is set to non-interactive.
  const wantUpdatedVol = {
    volumes: {
      [volumeInfo.volumeId]: {
        ...volume,
        isInteractive: false,
      },
    },
  };
  await waitDeepEquals(store, wantUpdatedVol, (state) => ({
                                                volumes: state.volumes,
                                              }));
  return volumeInfo;
}

/**
 * Tests that the paste, cut, copy and new-folder commands should be
 * disabled and hidden when there are no selected entries but the current
 * directory is on a non-interactive volume (e.g. when the blank space in a
 * non-interactive directory is right clicked).
 */
export async function testCommandsForNonInteractiveVolumeAndNoEntries() {
  const nonInteractiveVolumeInfo =
      await createAndAddNonInteractiveDownloadsVolume();

  const currentSelection: CurrentSelection = {
    entries: [],
    iconType: 'none',
    totalCount: 0,
  };

  // Mock `FileManager`.
  const fileManager = {
    getCurrentDirectoryEntry: () => null,
    // Selection includes entry on non-interactive volume.
    getSelection: () => currentSelection,
    directoryModel: {
      getCurrentDirEntry: () => null,
      // Navigate to the non-interactive volume.
      getCurrentRootType: () => RootType.DOWNLOADS,
      getCurrentVolumeInfo: () => nonInteractiveVolumeInfo,
    },
    document: {
      getElementsByClassName: () => [],
    },
    // Allow paste command.
    fileTransferController: {
      queryPasteCommandEnabled: () => true,
    },
    ui: {
      actionbar: {
        contains: () => false,
      },
      directoryTree: {
        contains: () => false,
      },
    },
  } as unknown as CommandHandlerDeps;

  // Check each command is disabled and hidden.
  const commandNames: FilesCommandId[] = [
    'paste',
    'cut',
    'copy',
    'new-folder',
  ];
  for (const commandName of commandNames) {
    // Check: command exists.
    const command = CommandHandler.getCommand(commandName);
    assertNotEquals(command, undefined);

    // Mock `Event`.
    const event = createMockEvent(commandName, {
      target: {
        classList: {contains: () => false},
        parentElement: {
          contextElement: null,
        },
        dataset: {},
      },
    });
    command.canExecute(event, fileManager);
    assertFalse(event.canExecute);
    assertTrue(event.command.hidden);
  }
}

/**
 * Tests that the paste, cut, copy, new-folder, delete, move-to-trash,
 * paste-into-folder, rename, extract-all and zip-selection commands should be
 * disabled and hidden for an entry on a non-interactive volume.
 */
export async function testCommandsForEntriesOnNonInteractiveVolume() {
  // Create non-interactive volume.
  const nonInteractiveVolumeInfo =
      await createAndAddNonInteractiveDownloadsVolume();

  // Mock volume manager.
  const volumeManager = new MockVolumeManager();

  // Create file entry on non-interactive volume.
  const nonInteractiveVolumeEntry = MockDirectoryEntry.create(
      nonInteractiveVolumeInfo.fileSystem as MockFileSystem, 'abc.pdf');
  const currentSelection = {
    entries: [nonInteractiveVolumeEntry],
    iconType: 'none',
    totalCount: 1,
  };

  // Mock `FileManager`.
  const fileManager = {
    getCurrentDirectoryEntry: () => null,
    // Selection includes entry on non-interactive volume.
    getSelection: () => currentSelection,
    directoryModel: {
      getCurrentDirEntry: () => null,
      getCurrentRootType: () => null,
      getCurrentVolumeInfo: () => null,
    },
    document: {
      getElementsByClassName: () => [],
    },
    // Allow copy, cut and paste command.
    fileTransferController: {
      canCopyOrDrag: () => true,
      canCutOrDrag: () => true,
      queryPasteCommandEnabled: () => true,
    },
    ui: {
      directoryTree: {
        contains: () => false,
      },
      actionbar: {
        contains: () => false,
      },
    },
    volumeManager: volumeManager,
  } as unknown as CommandHandlerDeps;

  // Check each command is disabled and hidden.
  const commandNames: FilesCommandId[] = [
    'paste',
    'cut',
    'copy',
    'new-folder',
    'delete',
    'move-to-trash',
    'paste-into-folder',
    'rename',
    'extract-all',
    'zip-selection',
  ];
  for (const commandName of commandNames) {
    // Check: command exists.
    const command = CommandHandler.getCommand(commandName);
    assertNotEquals(command, undefined);

    // Mock `Event`.
    const event = createMockEvent(commandName, {
      target: {
        selectedItems: currentSelection.entries,
        classList: {contains: () => false},
      },
    });

    command.canExecute(event, fileManager);
    assertFalse(event.canExecute);
    assertTrue(event.command.hidden);
  }
}