chromium/ui/file_manager/file_manager/state/ducks/current_directory_unittest.ts

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

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

import {fakeMyFilesVolumeId} from '../../background/js/mock_volume_manager.js';
import type {MockFileSystem} from '../../common/js/mock_entry.js';
import {RootType, VolumeType} from '../../common/js/volume_manager_types.js';
import {FakeFileSelectionHandler} from '../../foreground/js/fake_file_selection_handler.js';
import {MetadataItem} from '../../foreground/js/metadata/metadata_item.js';
import {type CurrentDirectory, type FileTasks, FileTaskType, PropStatus} from '../../state/state.js';
import {clearCachedEntries} from '../ducks/all_entries.js';
import {fetchFileTasks} from '../ducks/current_directory.js';
import {allEntriesSize, assertAllEntriesEqual, assertStateEquals, setUpFileManagerOnWindow, setupStore, updateContent, updMetadata, waitDeepEquals} from '../for_tests.js';
import {getFilesData, type Store} from '../store.js';

import {changeDirectory, updateSelection} from './current_directory.js';


let fileSystem: MockFileSystem;

export function setUp() {
  // changeDirectory() reducer uses the VolumeManager.
  setUpFileManagerOnWindow();
  window.fileManager.selectionHandler = new FakeFileSelectionHandler();

  fileSystem = window.fileManager.volumeManager
                   .getCurrentProfileVolumeInfo(
                       VolumeType.DOWNLOADS)!.fileSystem as MockFileSystem;
  fileSystem.populate([
    '/dir-1/',
    '/dir-2/sub-dir/',
    '/dir-2/file.txt',
    '/dir-3/',
  ]);
}

function cd(store: Store, directory: DirectoryEntry) {
  store.dispatch(changeDirectory(
      {to: directory, toKey: directory.toURL(), status: PropStatus.SUCCESS}));
}
function changeSelection(store: Store, entries: Entry[]) {
  store.dispatch(updateSelection({
    selectedKeys: entries.map(e => e.toURL()),
    entries,
  }));
}

export function testChangeDirectoryFromEmpty() {
  const store = setupStore();
  const dir1 = fileSystem.entries['/dir-1'] as DirectoryEntry;
  // The current directory starts empty.
  assertTrue(store.getState().currentDirectory?.key === undefined);

  // Change Directory happens in 2 steps.
  // First step, start the directory change.
  store.dispatch(changeDirectory({toKey: dir1.toURL()}));
  const want: CurrentDirectory = {
    key: dir1.toURL(),
    status: PropStatus.STARTED,
    rootType: undefined,
    pathComponents: [],
    content: {
      keys: [],
      status: PropStatus.SUCCESS,
    },
    selection: {
      keys: [],
      dirCount: 0,
      fileCount: 0,
      hostedCount: undefined,
      offlineCachedCount: 0,
      fileTasks: {
        policyDefaultHandlerStatus: undefined,
        defaultTask: undefined,
        tasks: [],
        status: PropStatus.SUCCESS,
      },
    },
    hasDlpDisabledFiles: false,
  };
  assertStateEquals(want, store.getState().currentDirectory);

  // Finish the directory change.
  store.dispatch(changeDirectory(
      {toKey: dir1.toURL(), to: dir1, status: PropStatus.SUCCESS}));

  want.status = PropStatus.SUCCESS;
  want.key = dir1.toURL();
  want.rootType = RootType.DOWNLOADS;
  want.pathComponents = [
    {name: 'Downloads', label: 'Downloads', key: fileSystem.root.toURL()},
    {name: dir1.name, label: dir1.name, key: dir1.toURL()},
  ];
  assertStateEquals(want, store.getState().currentDirectory);
}

export function testChangeDirectoryTwice() {
  const store = setupStore();
  const dir2 = fileSystem.entries['/dir-2'] as DirectoryEntry;
  const subDir = fileSystem.entries['/dir-2/sub-dir'] as DirectoryEntry;
  const dir1 = fileSystem.entries['/dir-1'] as DirectoryEntry;
  cd(store, dir2);
  updateContent(store, [subDir]);
  changeSelection(store, [subDir]);
  cd(store, dir1);
  const want: CurrentDirectory = {
    key: dir1.toURL(),
    status: PropStatus.SUCCESS,
    rootType: RootType.DOWNLOADS,
    pathComponents: [
      {name: 'Downloads', label: 'Downloads', key: fileSystem.root.toURL()},
      {name: dir1.name, label: dir1.name, key: dir1.toURL()},
    ],
    content: {
      keys: [],
      status: PropStatus.SUCCESS,
    },
    selection: {
      keys: [],
      dirCount: 0,
      fileCount: 0,
      hostedCount: undefined,
      offlineCachedCount: 0,
      fileTasks: {
        policyDefaultHandlerStatus: undefined,
        defaultTask: undefined,
        tasks: [],
        status: PropStatus.SUCCESS,
      },
    },
    hasDlpDisabledFiles: false,
  };

  assertStateEquals(want, store.getState().currentDirectory);
}

export function testChangeSelection() {
  const store = setupStore();
  const dir2 = fileSystem.entries['/dir-2'] as DirectoryEntry;
  const subDir = fileSystem.entries['/dir-2/sub-dir'] as DirectoryEntry;
  const file = fileSystem.entries['/dir-2/file.txt'] as DirectoryEntry;
  cd(store, dir2);
  updateContent(store, [subDir, file]);
  changeSelection(store, [subDir]);

  const want: CurrentDirectory = {
    key: dir2.toURL(),
    status: PropStatus.SUCCESS,
    rootType: RootType.DOWNLOADS,
    pathComponents: [
      {name: 'Downloads', label: 'Downloads', key: fileSystem.root.toURL()},
      {name: dir2.name, label: dir2.name, key: dir2.toURL()},
    ],
    content: {
      keys: [subDir.toURL(), file.toURL()],
      status: PropStatus.SUCCESS,
    },
    selection: {
      keys: [subDir.toURL()],
      dirCount: 1,
      fileCount: 0,
      hostedCount: undefined,
      offlineCachedCount: 1,
      fileTasks: {
        policyDefaultHandlerStatus: undefined,
        defaultTask: undefined,
        tasks: [],
        status: PropStatus.STARTED,
      },
    },
    hasDlpDisabledFiles: false,
  };
  assertStateEquals(want, store.getState().currentDirectory);

  // Change the selection for a completely different one:
  changeSelection(store, [file]);
  want.selection.keys = [file.toURL()];
  want.selection.dirCount = 0;
  want.selection.fileCount = 1;
  assertStateEquals(want, store.getState().currentDirectory);

  // Append to the selection.
  changeSelection(store, [file, subDir]);
  want.selection.keys = [file.toURL(), subDir.toURL()];
  want.selection.dirCount = 1;
  want.selection.fileCount = 1;
  want.selection.offlineCachedCount = 2;
  assertStateEquals(want, store.getState().currentDirectory);
}

export function testChangeDirectoryContent() {
  const store = setupStore();
  const dir2 = fileSystem.entries['/dir-2'] as DirectoryEntry;
  const subDir = fileSystem.entries['/dir-2/sub-dir'] as DirectoryEntry;
  const file = fileSystem.entries['/dir-2/file.txt']!;
  cd(store, dir2);

  const want: CurrentDirectory = {
    key: dir2.toURL(),
    status: PropStatus.SUCCESS,
    rootType: RootType.DOWNLOADS,
    pathComponents: [
      {name: 'Downloads', label: 'Downloads', key: fileSystem.root.toURL()},
      {name: dir2.name, label: dir2.name, key: dir2.toURL()},
    ],
    content: {
      keys: [],
      status: PropStatus.SUCCESS,
    },
    selection: {
      keys: [],
      dirCount: 0,
      fileCount: 0,
      hostedCount: undefined,
      offlineCachedCount: 0,
      fileTasks: {
        policyDefaultHandlerStatus: undefined,
        defaultTask: undefined,
        tasks: [],
        status: PropStatus.SUCCESS,
      },
    },
    hasDlpDisabledFiles: false,
  };
  assertStateEquals(want, store.getState().currentDirectory);
  assertEquals(
      1, allEntriesSize(store.getState()), 'only dir-2 should be cached');
  assertAllEntriesEqual(store, [`filesystem:${fakeMyFilesVolumeId}/dir-2`]);

  // Send the content update:
  updateContent(store, [subDir]);
  want.content.keys = [subDir.toURL()];
  assertStateEquals(want, store.getState().currentDirectory);
  assertEquals(
      2, allEntriesSize(store.getState()),
      'dir-2 and dir-2/sub-dir should be cached');
  assertAllEntriesEqual(store, [
    `filesystem:${fakeMyFilesVolumeId}/dir-2`,
    `filesystem:${fakeMyFilesVolumeId}/dir-2/sub-dir`,
  ]);

  // Send another content update - it should replace the original:
  updateContent(store, [file]);
  want.content.keys = [file.toURL()];
  assertStateEquals(want, store.getState().currentDirectory);
  assertEquals(
      3, allEntriesSize(store.getState()),
      'dir-2, dir-2/sub-dir and dir-2/file should be cached');
  assertAllEntriesEqual(store, [
    `filesystem:${fakeMyFilesVolumeId}/dir-2`,
    `filesystem:${fakeMyFilesVolumeId}/dir-2/file.txt`,
    `filesystem:${fakeMyFilesVolumeId}/dir-2/sub-dir`,
  ]);

  // Clear cached entries: only dir2 and file should be kept.
  store.dispatch(clearCachedEntries());
  assertEquals(
      2, allEntriesSize(store.getState()),
      'only dir-2 and dir-2/file should still be cached');
  assertAllEntriesEqual(store, [
    `filesystem:${fakeMyFilesVolumeId}/dir-2`,
    `filesystem:${fakeMyFilesVolumeId}/dir-2/file.txt`,
  ]);
}

export function testComputeHasDlpDisabledFiles() {
  const store = setupStore();
  const dir2 = fileSystem.entries['/dir-2'] as DirectoryEntry;
  const subDir = fileSystem.entries['/dir-2/sub-dir'] as DirectoryEntry;
  const file = fileSystem.entries['/dir-2/file.txt']!;
  cd(store, dir2);
  updateContent(store, [subDir, file]);

  const want: CurrentDirectory = {
    key: dir2.toURL(),
    status: PropStatus.SUCCESS,
    rootType: RootType.DOWNLOADS,
    pathComponents: [
      {name: 'Downloads', label: 'Downloads', key: fileSystem.root.toURL()},
      {name: dir2.name, label: dir2.name, key: dir2.toURL()},
    ],
    content: {
      keys: [subDir.toURL(), file.toURL()],
      status: PropStatus.SUCCESS,
    },
    selection: {
      keys: [],
      dirCount: 0,
      fileCount: 0,
      hostedCount: undefined,
      offlineCachedCount: 0,
      fileTasks: {
        policyDefaultHandlerStatus: undefined,
        defaultTask: undefined,
        tasks: [],
        status: PropStatus.SUCCESS,
      },
    },
    hasDlpDisabledFiles: false,
  };
  assertStateEquals(want, store.getState().currentDirectory);

  // Send the metadata update:
  const metadata: MetadataItem = new MetadataItem();
  metadata.isRestrictedForDestination = true;
  updMetadata(store, [{entry: file, metadata}]);
  want.hasDlpDisabledFiles = true;
  assertStateEquals(want, store.getState().currentDirectory);

  // Send a content update and "remove" the disabled file:
  updateContent(store, [subDir]);
  want.content.keys = [subDir.toURL()];
  want.hasDlpDisabledFiles = false;
  assertStateEquals(want, store.getState().currentDirectory);
}

function mockGetFileTasks(tasks: chrome.fileManagerPrivate.FileTask[]) {
  const mocked =
      (_entries: Entry[], _sourceUrls: string[],
       callback: (resultingTasks: chrome.fileManagerPrivate.ResultingTasks) =>
           void) => {
        setTimeout(callback, 0, {tasks});
      };
  chrome.fileManagerPrivate.getFileTasks = mocked;
}

const fakeFileTasks: chrome.fileManagerPrivate.FileTask = {
  descriptor: {
    appId: 'handler-extension-id1',
    taskType: 'app',
    actionId: 'any',
  },
  isDefault: false,
  isGenericFileHandler: false,
  title: 'app 1',
  iconUrl: undefined,
  isDlpBlocked: false,
};

export async function testFetchTasks(done: () => void) {
  const store = setupStore();
  const dir2 = fileSystem.entries['/dir-2'] as DirectoryEntry;
  const subDir = fileSystem.entries['/dir-2/sub-dir'] as DirectoryEntry;
  const file = fileSystem.entries['/dir-2/file.txt']!;
  cd(store, dir2);
  changeSelection(store, [subDir, file]);

  const filesData = getFilesData(store.getState(), [file.toURL()]);
  const want: FileTasks = {
    policyDefaultHandlerStatus: undefined,
    defaultTask: undefined,
    tasks: [],
    status: PropStatus.SUCCESS,
  };

  // Mock returning 0 tasks, returns SUCCESS and empty tasks.
  mockGetFileTasks([]);
  store.dispatch(fetchFileTasks(filesData));
  await waitDeepEquals(store, want, (state) => {
    return state.currentDirectory?.selection.fileTasks;
  });

  // Mock the private API results with one task which is the default task.
  mockGetFileTasks([fakeFileTasks]);
  want.tasks = [
    {
      iconType: '',
      descriptor: {
        appId: 'handler-extension-id1',
        taskType: FileTaskType.APP,
        actionId: 'any',
      },
      isDefault: false,
      isGenericFileHandler: false,
      title: 'app 1',
      iconUrl: undefined,
      isDlpBlocked: false,
    },
  ];
  want.defaultTask = {...want.tasks[0]!};
  store.dispatch(fetchFileTasks(filesData));
  await waitDeepEquals(
      store, want, (state) => state.currentDirectory?.selection.fileTasks);

  // Mock the API task as genericFileHandler, so it shouldn't be a default task.
  const genericTask = {
    ...fakeFileTasks,
    isGenericFileHandler: true,
  };
  mockGetFileTasks([genericTask]);
  want.tasks[0]!.isGenericFileHandler = true;
  want.defaultTask = undefined;
  store.dispatch(fetchFileTasks(filesData));
  await waitDeepEquals(
      store, want, (state) => state.currentDirectory?.selection.fileTasks);

  done();
}